Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
9.1 KiB
YOUR STEP: 3 — Embeddings, signals, telemetry (plan §21 step 3)
You are working in a git worktree on branch curation-v2-step3, which branches from the step 1
commit. Another agent is implementing step 2 (the Claude editor) in parallel on the main checkout;
you will not see its changes and must not need them. Read git log -3 and the current
src/curate/, src/pipeline.rs, src/config.rs, src/db.rs, src/report.rs, src/main.rs first.
Do not run git checkout, git stash, git worktree or git commit.
Scope (plan §4.3, §7.1, §7.2, §7.4, §7.5, §9, §12.2 normalization only, §12.4 preliminary blend,
§15.2 explain, §15.4 log line parts, §16, §17 Voyage row, §19 [voyage] + [curation.ranking]).
The old heuristic prefilter still gates in this step; signals are computed for every article that
passes hygiene and persisted, nothing about selection changes yet.
src/curate/embedding.rs(§4.3, §7.1, §7.2):EmbeddingBackendtrait mirroringChatBackend(with a mock for tests),VoyageBackend(POST {base_url}/embeddings,Authorization: Bearer, body{input, model, input_type: "document"|"query", truncation: true, output_dimension, output_dtype: "float"}, response mapped by index and length-checked, usagetotal_tokensmetered), batching bybatch_sizewithbuffer_unordered(max_concurrent_requests), retry 429/5xx viaRetryPolicy, f32 little-endian BLOB encode/decode with length and finiteness checks,dot(a, b)with a dimension check, and cache orchestration:article_embeddingskeyed by(model, dimension, input_hash = sha256 of the embedded text), overwritten on change. Embedded text ="Title: {title}\n\n{plain body}"viacurate::prompt_text, whitespace collapsed, cut atmax_input_charson a char boundary — no feed name, author or scores. Interests go tointerest_embeddingswithinput_type = "query"and the bare interest string. A failed batch leaves those articles without embeddings; never fatal. A VoyageUsageMeterwithprice_per_mtok = 0.02andmax_daily_usd(0.50) as a runaway guard. Key only fromDAILY_EPUB_VOYAGE__API_KEY. Raw vectors never reach logs or reports.- Config (§4.3, §19):
[voyage](enabled,base_url = "https://api.voyageai.com/v1",model = "voyage-4-lite",output_dimension = 512,batch_size = 32,max_concurrent_requests = 4,max_input_chars = 60000,max_daily_usd = 0.50) and the complete[curation.ranking]block from §19 (triage_max,deep_keep,shortlist_keep,assessment_reuse_days,rating_lookback_days,rating_half_life_days,neighbour_k,negative_coefficient,knn_floor/full,feed_floor/full,semantic_min_words,exploration_slots,embedding_retention_days,telemetry_retention_days,[curation.ranking.quotas],[curation.ranking.weights.preliminary],[curation.ranking.weights.utility],[curation.ranking.diversity]) with the plan's defaults and the validation rules in §19 (weights non-negative;deep_keep ≥ shortlist_keep ≥ target_article_count;*_full > *_floor ≥ 0;0 ≤ cluster_threshold ≤ 1;per_cluster_cap ≥ 1; batch sizes ≥ 1; dimension ∈ {256,512,1024,2048}). Steps 4–5 will use the ranking keys; you only add and validate them. Keepprefilter_keepfor now. Startup logs whether Voyage is enabled. Updateconfig.example.tomland the README. src/curate/signals.rs(§9): aSignalstype where every signal isOption<f64>(absent ≠ 0):interest(§9.1 z-scored interest match, top-three interests recorded; raw top-1 cosine fallback under 30 embedded articles, logged),knn(§9.2 preference state fromdb::current_ratings(rating_lookback_days)joined toarticle_embeddings, decayed weightsvalue × 0.5^(age/half_life), top-k positives and negatives,knn = pos − negative_coefficient × neg, top-three neighbours recorded, gate rampclamp((n − knn_floor)/(knn_full − knn_floor), 0, 1)multiplying the weight, absent at 0),feed(§9.3 Beta-smoothed decayed rate credited to distinct directSourceKind::Feedfeeds, mean over the article's rated feeds, gatefeed_floor/feed_full),social(existing composite; absent with no rows),heuristic(longform_points(word_count)− excerpt-only − roundup penalty, fromprefilter.rswithout the social, Scour/HN, multi-source terms; expose those pieces as functions). Log once per run:preference: N rated articles with embeddings → knn gate X; feed gate Y (n=…). Also implement the mid-rank percentile normalizer of §12.2 and the preliminary blend of §12.4 (present-and-active weights renormalized) sosignals_json.norm/weightscan be written now.- Pipeline: after social enrichment, for every article that passes hygiene, compute embeddings
(cached), signals, and the preliminary blend, then run the old prefilter as today. New stage
timings
embedandsignals.--skip-embeddingsuses cached embeddings only (zero Voyage calls);--skip-llmunchanged.StageCountsgainseligible,embedded,rated_with_embeddings, and the report/print_reportshow them. The paper must still build when Voyage is down or unconfigured (§17):interest/knnabsent, never a penalty. src/curate/telemetry.rs(§7.4, §7.5): thecandidate_runswriter. One row per considered article per run, upserted withINSERT … ON CONFLICT(run_id, article_id) DO UPDATEsetting every column. Hygiene-excluded articles get thin rows (stage='excluded',excluded_reason∈blocked | published_before | recently_rejected,signals_json='{}'). In this step map the old pipeline onto the stage vocabulary: passed hygiene →eligible; cut by the prefilter →eligible+excluded_reason='not_admitted'; prefilter survivors →admitted; Stage-A scored →assessed; sent to selection →shortlisted; picked →selected(+editor_whyif awhyexists on the pick, else NULL); unpicked shortlist →excluded_reason='not_selected'.signals_jsonfollows §7.5 exactly (v: 1,raw,norm,present,weights= effective preliminary weights,top_interests,neighbours,exploration: false,auto_include,notes).utilityandrank_utilitystay NULL until step 5.- CLI (§15.2, §16) in
src/main.rs:explain --date YYYY-MM-DD (--article ID | --url URL) [--run-id N]andexplain --date YYYY-MM-DD --near-misses [N]printing the persistedcandidate_runsrow for the latest non-dry run of that date (or--run-id): stage and reason; every raw/normalized signal with presence and effective weight; top interests with z; nearest rated neighbours; assessments fromarticle_assessmentswhen present (empty until step 4); admitted_by; the editor'swhy.--urlcanonicalizes (dedupe) and looks the article up; if absent fromarticles, print that it was never ingested.--near-misseslists the top N by preliminary blend (utility from step 5 when present) that were not selected.features backfill [--days 30] [--rated-only] [--all] [--yes]embeds rated and published articles first, then interests, then other recent articles only under--all, prints an estimate and asks for confirmation above 5M tokens unless--yes, and is idempotent (warm cache ⇒ zero calls).features pruneremovesarticle_embeddingsrows for articles neither rated nor published older thanembedding_retention_daysandcandidate_runsrows whose run started more thantelemetry_retention_daysago.features backfilltakes no lock yet (step 6 addslock.rs). - Types (§18):
Signals,Signalhelpers,RatedArticlealready exists — extend if needed. Do not createCandidateyet (step 4/5).
Tests (§20 "Embeddings", "Interest z-scores", "Preference", "Normalization", plus telemetry/explain):
BLOB round trip; wrong length and non-finite rejected; cache hit on same hash, miss on changed
text/model/dimension; response mapped by index and length-checked; a failed batch does not abort the
others; embedded text contains no feed name or author; a broad interest with uniformly high cosine does
not dominate while a specific interest with one strong match does; raw fallback under 30 articles; one
loved article gives a positive knn to a near neighbour; two unrelated loved clusters both score high
(anti-centroid); good moves the signal 0.35× as much as loved; decay halves at the half-life; gate
0 below knn_floor, 1 at knn_full, linear between; feed credit sums to 1 across direct feeds; feed
affinity uses the mean; constant signal → 0.5 for everyone; ties get equal percentiles (400 identical
zeros → all 0.5, no id ramp); absent values do not shift others; effective weights sum to 1; a candidate
missing a signal is scored on the rest; candidate_runs rows written for every considered article with
the right stage/reason on a mocked run; --skip-embeddings makes zero Voyage calls; a Voyage failure
still publishes; explain renders from persisted rows and reports "never ingested"; features prune
respects rated/published.