From 2d857e3e10e6986599a7a53d578e8974438b7143 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sun, 13 Sep 2026 05:20:05 +0000 Subject: [PATCH] Cut weak interest matches and add the rating-driven affinity signal (step 2) An interest matches an article only when it is in the top three by z and z >= 1.0, so the Matches line, the stored rows and the weights agree. The new bounded affinity signal blends each matched interest's rating-derived weight, gated on attributable ratings like feed affinity. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc --- README.md | 23 ++- config.example.toml | 12 +- src/config.rs | 31 +++- src/curate/rank.rs | 7 +- src/curate/signals.rs | 314 ++++++++++++++++++++++++++++++++-- src/curate/telemetry.rs | 23 ++- src/web/dashboard/mod.rs | 3 +- src/web/dashboard/settings.rs | 4 + 8 files changed, 384 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 315c7ae..139bd43 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,12 @@ prints what resolved. | `curation.recent_rejection_days` | `7` | Churn window for recent low triage/deep assessments. | | `curation.recent_rejection_floor` | `3.0` | Scores below this floor are excluded during the churn window (except auto-includes). | | `curation.ranking.*` | see below | Every weight, quota, gate and threshold of the personalized ranker. | +| `curation.ranking.affinity_floor` / `affinity_full` | `15` / `40` | Interest-attributable ratings where affinity starts and reaches full weight. | +| `curation.ranking.weights.preliminary.affinity` | `0.10` | Rating-derived interest affinity in the preliminary blend. | +| `curation.ranking.weights.preliminary.interest` | `0.30` | Interest similarity in the preliminary blend. | +| `curation.ranking.weights.preliminary.social` | `0.05` | Social signal in the preliminary blend. | +| `curation.ranking.weights.utility.affinity` | `0.05` | Rating-derived interest affinity in the utility score. | +| `curation.ranking.weights.utility.knn` | `0.10` | Rated-neighbour preference in the utility score. | | `editorial.summary_model` | `editor` | Which `[llm]` role writes the per-article summaries: `editor` (with per-article bulk fallback) or `bulk`. | | `editorial.summary_input_tokens` | `3000` | Article text offered to the summary prompt. | | `publish.epub_dir` | `/srv/bookorbit/libraries/daily-epub` | Both EPUB editions land here by atomic copy, and this is the directory the OPDS feed lists. The editions are distinguished by a `(X4)` tag in **both** the filename and `dc:title` — libraries and OPDS clients list books by title, so the filename alone would make them look identical. Point a BookOrbit watched folder at it if you want its UI too. **Renamed from `bookorbit_dir`**; the old key is a hard config error. | @@ -448,7 +454,9 @@ prints what resolved. gated: `knn` (rated-neighbour preference) ramps from `knn_floor` (8) to `knn_full` (25) rated articles with embeddings, `feed` (feed affinity) from `feed_floor` (15) to `feed_full` (40) attributable ratings; below the floor the -signal is absent. Ratings decay with `rating_half_life_days` (60) over +signal is absent. `affinity` (rating-derived interest affinity) likewise ramps +from `affinity_floor` (15) to `affinity_full` (40) interest-attributable ratings. +Ratings decay with `rating_half_life_days` (60) over `rating_lookback_days` (180); `neighbour_k` (5) neighbours per side and `negative_coefficient` (0.75) shape the signal. `slop_author_penalty` (0.75) is the fraction of the blend and utility removed from every candidate whose @@ -457,13 +465,13 @@ limit. `triage_max` (800), `deep_keep` (120), `shortlist_keep` (60), `assessment_reuse_days` (3), `semantic_min_words` (300), `exploration_slots` (5), `[curation.ranking.quotas]` (`triage` 60 · `interest` 20 · `knn` 20), `[curation.ranking.weights.utility]` -(`quality` 0.40 · `fit` 0.20 · `knn` 0.15 · `interest` 0.10 · `feed` 0.05 · +(`quality` 0.40 · `fit` 0.20 · `knn` 0.10 · `affinity` 0.05 · `interest` 0.10 · `feed` 0.05 · `triage` 0.05 · `social` 0.03 · `heuristic` 0.02, over the signals present for each article of the deep set) and `[curation.ranking.diversity]` (`cluster_threshold` 0.85, `per_cluster_cap` 2, `utility_protected` 10) drive the LLM triage, deep assessment, utility ranking and diversification stages. -`[curation.ranking.weights.preliminary]` (`interest` 0.35 · `knn` 0.25 · -`heuristic` 0.20 · `feed` 0.10 · `social` 0.10) blends the cheap signals; weights +`[curation.ranking.weights.preliminary]` (`interest` 0.30 · `knn` 0.25 · +`affinity` 0.10 · `heuristic` 0.20 · `feed` 0.10 · `social` 0.05) blends the cheap signals; weights are renormalized over the signals present for each article, so they need not sum to 1. `embedding_retention_days` (120) and `telemetry_retention_days` (180) are what `features prune` enforces. Validation: weights non-negative; `deep_keep ≥ @@ -992,11 +1000,12 @@ From spec §7, plus what implementation turned up: ceiling; a protocol that is neither means another impl. Voyage AI embeddings sit behind the analogous `EmbeddingBackend` trait in `curate/embedding.rs`. - **Triage and union admission replace the heuristic gate.** Every eligible - article gets interest, rated-neighbour, feed-affinity, social and heuristic - signals, then DeepSeek reads its opening (up to `triage_max`). The deep set is + article gets interest, rated-neighbour, feed-affinity, interest-affinity, + social and heuristic signals, then DeepSeek reads its opening (up to + `triage_max`). The deep set is the union of triage, interest, neighbour, exploration, blend and auto-include retrievers. `explain` shows the assessment and `admitted_by`. Learned signals - stay absent until their gates open (8 and 15 ratings respectively). + stay absent until their gates open (8, 15, and 15 ratings respectively). - **Deep assessment and diversity are live.** DeepSeek reads a representative beginning/middle/end sample, separates editorial quality from reader fit, and records descriptive facets. Utility is normalized over the deep set; embedding diff --git a/config.example.toml b/config.example.toml index 99153b5..dc3c7c0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -138,7 +138,7 @@ slop_value = -1.0 # AI slop: a full negative; the author pena verdicts_in_prompt = 60 # Every weight, quota, gate and threshold of the personalized ranker. The -# learned signals (`knn`, `feed`) contribute nothing until their gates open: +# learned signals (`knn`, `feed`, `affinity`) contribute nothing until their gates open: # the weight ramps linearly from `*_floor` to `*_full` rated articles. [curation.ranking] triage_max = 800 # eligible articles the triage LLM reads @@ -153,6 +153,8 @@ knn_floor = 8 knn_full = 25 feed_floor = 15 feed_full = 40 +affinity_floor = 15 +affinity_full = 40 slop_author_penalty = 0.75 # blend and utility × 0.25 for authors with an AI slop verdict semantic_min_words = 300 exploration_slots = 5 @@ -166,16 +168,18 @@ knn = 20 # Weights need not sum to 1; they are renormalized over the present signals. [curation.ranking.weights.preliminary] -interest = 0.35 +interest = 0.30 knn = 0.25 +affinity = 0.10 heuristic = 0.20 feed = 0.10 -social = 0.10 +social = 0.05 [curation.ranking.weights.utility] quality = 0.40 fit = 0.20 -knn = 0.15 +knn = 0.10 +affinity = 0.05 interest = 0.10 feed = 0.05 triage = 0.05 diff --git a/src/config.rs b/src/config.rs index 8e32a1e..d5bceb1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -488,6 +488,8 @@ pub struct RankingConfig { pub knn_full: usize, pub feed_floor: usize, pub feed_full: usize, + pub affinity_floor: usize, + pub affinity_full: usize, /// Fraction of the preliminary blend and the utility removed from any /// candidate whose author has a current *AI slop* verdict (§9.3). `1.0` /// zeroes such candidates; `0.0` disables the penalty. @@ -516,6 +518,8 @@ impl Default for RankingConfig { knn_full: 25, feed_floor: 15, feed_full: 40, + affinity_floor: 15, + affinity_full: 40, slop_author_penalty: 0.75, semantic_min_words: 300, exploration_slots: 5, @@ -558,6 +562,7 @@ pub struct RankingWeights { pub struct PreliminaryWeights { pub interest: f64, pub knn: f64, + pub affinity: f64, pub heuristic: f64, pub feed: f64, pub social: f64, @@ -566,11 +571,12 @@ pub struct PreliminaryWeights { impl Default for PreliminaryWeights { fn default() -> Self { Self { - interest: 0.35, + interest: 0.30, knn: 0.25, + affinity: 0.10, heuristic: 0.20, feed: 0.10, - social: 0.10, + social: 0.05, } } } @@ -581,6 +587,7 @@ pub struct UtilityWeights { pub quality: f64, pub fit: f64, pub knn: f64, + pub affinity: f64, pub interest: f64, pub feed: f64, pub triage: f64, @@ -593,7 +600,8 @@ impl Default for UtilityWeights { Self { quality: 0.40, fit: 0.20, - knn: 0.15, + knn: 0.10, + affinity: 0.05, interest: 0.10, feed: 0.05, triage: 0.05, @@ -1204,7 +1212,10 @@ impl Config { .into(), )); } - if ranking.knn_full <= ranking.knn_floor || ranking.feed_full <= ranking.feed_floor { + if ranking.knn_full <= ranking.knn_floor + || ranking.feed_full <= ranking.feed_floor + || ranking.affinity_full <= ranking.affinity_floor + { return Err(ConfigError::Invalid( "curation.ranking *_full must be > *_floor >= 0".into(), )); @@ -1229,12 +1240,14 @@ impl Config { let weights = [ preliminary.interest, preliminary.knn, + preliminary.affinity, preliminary.heuristic, preliminary.feed, preliminary.social, utility.quality, utility.fit, utility.knn, + utility.affinity, utility.interest, utility.feed, utility.triage, @@ -2084,10 +2097,15 @@ mod tests { ); assert_eq!((ranking.knn_floor, ranking.knn_full), (8, 25)); assert_eq!((ranking.feed_floor, ranking.feed_full), (15, 40)); + assert_eq!((ranking.affinity_floor, ranking.affinity_full), (15, 40)); assert_eq!(ranking.rating_half_life_days, 60.0); assert_eq!(ranking.negative_coefficient, 0.75); - assert_eq!(ranking.weights.preliminary.interest, 0.35); + assert_eq!(ranking.weights.preliminary.interest, 0.30); + assert_eq!(ranking.weights.preliminary.affinity, 0.10); + assert_eq!(ranking.weights.preliminary.social, 0.05); assert_eq!(ranking.weights.utility.quality, 0.40); + assert_eq!(ranking.weights.utility.knn, 0.10); + assert_eq!(ranking.weights.utility.affinity, 0.05); assert_eq!(ranking.diversity.per_cluster_cap, 2); assert_eq!(ranking.embedding_retention_days, 120); assert_eq!(ranking.telemetry_retention_days, 180); @@ -2106,6 +2124,9 @@ mod tests { bad.curation.ranking.knn_full = bad.curation.ranking.knn_floor; assert!(bad.validate().is_err(), "*_full must exceed *_floor"); let mut bad = Config::default(); + bad.curation.ranking.affinity_full = bad.curation.ranking.affinity_floor; + assert!(bad.validate().is_err(), "*_full must exceed *_floor"); + let mut bad = Config::default(); bad.curation.ranking.shortlist_keep = bad.curation.ranking.deep_keep + 1; assert!(bad.validate().is_err(), "deep_keep >= shortlist_keep"); let mut bad = Config::default(); diff --git a/src/curate/rank.rs b/src/curate/rank.rs index 85452b2..e8e7c6d 100644 --- a/src/curate/rank.rs +++ b/src/curate/rank.rs @@ -62,6 +62,11 @@ fn calculate_utility_for( ("quality", configured.quality, 1.0), ("fit", configured.fit, 1.0), ("knn", configured.knn, candidate.signals.knn_gate), + ( + "affinity", + configured.affinity, + candidate.signals.affinity_gate, + ), ("interest", configured.interest, 1.0), ("feed", configured.feed, candidate.signals.feed_gate), ("triage", configured.triage, 1.0), @@ -342,7 +347,7 @@ mod tests { } assert!(!b.signals.weights.contains_key("interest")); assert!( - (a.signals.weights["knn"] / a.signals.weights["quality"] - (0.15 * 0.5) / 0.40).abs() + (a.signals.weights["knn"] / a.signals.weights["quality"] - (0.10 * 0.5) / 0.40).abs() < 1e-9 ); } diff --git a/src/curate/signals.rs b/src/curate/signals.rs index aefd9cf..4d1c715 100644 --- a/src/curate/signals.rs +++ b/src/curate/signals.rs @@ -14,11 +14,14 @@ use crate::config::{PreliminaryWeights, RankingConfig, VoyageConfig}; use crate::curate::embedding::{dot, load_article_embeddings}; use crate::curate::prefilter; use crate::db::Db; +use crate::interests::{self, Rate}; use crate::types::{Article, ArticleId, FeedId, SourceKind}; /// Below this many embedded eligible articles the z-score is too noisy, so the /// interest signal falls back to the raw top-1 cosine (§9.1). pub const INTEREST_ZSCORE_MIN_ARTICLES: usize = 30; +/// Weak top-three matches are omitted everywhere they are presented or credited. +pub const MATCH_MIN_Z: f64 = 1.0; /// Standard-deviation floor for the per-interest z-score (§9.1). const ZSCORE_STD_FLOOR: f64 = 1e-3; /// How many interests and rated neighbours `signals_json` records (§7.5). @@ -30,7 +33,8 @@ pub const AGGREGATOR_FEED_SHARE: f64 = 0.25; /// The signal names that go through the percentile normalizer, in the order /// they are rendered (§12.2). LLM scores (`triage`, `quality`, `fit`) are /// absolute and arrive in steps 4–5. -pub const PERCENTILE_SIGNALS: [&str; 5] = ["interest", "knn", "feed", "social", "heuristic"]; +pub const PERCENTILE_SIGNALS: [&str; 6] = + ["interest", "knn", "feed", "affinity", "social", "heuristic"]; #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct TopInterest { @@ -56,6 +60,7 @@ pub struct Signals { pub interest_top1_cos: Option, pub knn: Option, pub feed: Option, + pub affinity: Option, pub social: Option, pub heuristic: Option, /// Mid-rank percentiles of the present signals (§12.2). @@ -80,6 +85,8 @@ pub struct Signals { pub knn_gate: f64, #[serde(skip)] pub feed_gate: f64, + #[serde(skip)] + pub affinity_gate: f64, /// `ranking.slop_author_penalty`, applied when `slop_author` is set. #[serde(skip)] pub slop_penalty: f64, @@ -105,6 +112,7 @@ impl Signals { "interest_top1_cos" => self.interest_top1_cos, "knn" => self.knn, "feed" => self.feed, + "affinity" => self.affinity, "social" => self.social, "heuristic" => self.heuristic, _ => None, @@ -130,8 +138,10 @@ impl Signals { pub struct PreferenceSummary { pub rated_with_embeddings: usize, pub attributable_feed_ratings: usize, + pub attributable_interest_ratings: usize, pub knn_gate: f64, pub feed_gate: f64, + pub affinity_gate: f64, } /// One rated article with an embedding: the unit of the preference state (§9.2). @@ -179,12 +189,15 @@ pub struct PreferenceState { pub examples: Vec, feed_rates: HashMap, author_rates: HashMap, + interest_rates: HashMap, /// Normalized keys of authors with a current *AI slop* verdict (§9.3). slop_authors: HashSet, pub slop_author_penalty: f64, pub attributable_feed_ratings: usize, + pub attributable_interest_ratings: usize, pub knn_gate: f64, pub feed_gate: f64, + pub affinity_gate: f64, } impl PreferenceState { @@ -204,12 +217,28 @@ impl PreferenceState { examples, feed_rates, author_rates, + interest_rates: HashMap::new(), slop_authors: HashSet::new(), slop_author_penalty: ranking.slop_author_penalty, attributable_feed_ratings, + attributable_interest_ratings: 0, + affinity_gate: 0.0, } } + /// Register rating-derived interest rates after the embedding examples are built. + pub fn with_interest_rates( + mut self, + rates_by_name: HashMap, + attributable: usize, + ranking: &RankingConfig, + ) -> Self { + self.interest_rates = rates_by_name; + self.attributable_interest_ratings = attributable; + self.affinity_gate = gate(attributable, ranking.affinity_floor, ranking.affinity_full); + self + } + /// Register the authors whose current verdict is *AI slop*; keys are /// normalized like [`normalize_author`] and empty ones are dropped. pub fn with_slop_authors(mut self, authors: I) -> Self @@ -234,8 +263,7 @@ impl PreferenceState { self.slop_authors.len() } - /// Load `db::current_ratings(rating_lookback_days)` joined to - /// `article_embeddings`; ratings without an embedding are skipped (§9.2). + /// Load current ratings; only kNN/feed examples require an embedding. pub async fn load( db: &Db, voyage: &VoyageConfig, @@ -249,7 +277,7 @@ impl PreferenceState { .collect::>(); let embeddings = load_article_embeddings(db, voyage, &ids).await?; let mut examples = Vec::new(); - for rating in ratings { + for rating in &ratings { let Some(embedding) = embeddings.get(&rating.article_id).cloned() else { continue; }; @@ -264,8 +292,8 @@ impl PreferenceState { let age_days = (now.as_second() - rating.event_at.as_second()).max(0) as f64 / 86_400.0; examples.push(RatedExample { article_id: rating.article_id, - label: rating.label, - title: rating.title, + label: rating.label.clone(), + title: rating.title.clone(), value: rating.value, decay: decay(age_days, ranking.rating_half_life_days), embedding, @@ -274,16 +302,51 @@ impl PreferenceState { aggregator_only, }); } + let match_rows = interests::matches_for_articles(db, &ids).await?; + let rated = ratings + .iter() + .map(|rating| { + let age_days = + (now.as_second() - rating.event_at.as_second()).max(0) as f64 / 86_400.0; + ( + rating.article_id, + rating.value, + decay(age_days, ranking.rating_half_life_days), + ) + }) + .collect::>(); + let matched = match_rows + .iter() + .map(|row| (row.article_id, row.interest_id, row.z)) + .collect::>(); + let rates = interests::rates(&rated, &matched); + let names = match_rows + .iter() + .map(|row| (row.interest_id, row.name.as_str())) + .collect::>(); + let rates_by_name = rates + .by_interest + .into_iter() + .filter_map(|(interest_id, rate)| { + names + .get(&interest_id) + .map(|name| ((*name).to_string(), rate)) + }) + .collect(); let slop_authors = db.slop_authors().await?; - Ok(Self::build(examples, ranking).with_slop_authors(slop_authors)) + Ok(Self::build(examples, ranking) + .with_interest_rates(rates_by_name, rates.attributable, ranking) + .with_slop_authors(slop_authors)) } pub fn summary(&self) -> PreferenceSummary { PreferenceSummary { rated_with_embeddings: self.examples.len(), attributable_feed_ratings: self.attributable_feed_ratings, + attributable_interest_ratings: self.attributable_interest_ratings, knn_gate: self.knn_gate, feed_gate: self.feed_gate, + affinity_gate: self.affinity_gate, } } @@ -297,21 +360,54 @@ impl PreferenceState { self.attributable_feed_ratings, ranking.feed_floor ) }; + let affinity_detail = if self.affinity_gate > 0.0 { + format!("(n={})", self.attributable_interest_ratings) + } else { + format!( + "(n={} < {})", + self.attributable_interest_ratings, ranking.affinity_floor + ) + }; tracing::info!( rated_with_embeddings = self.examples.len(), knn_gate = self.knn_gate, feed_gate = self.feed_gate, + affinity_gate = self.affinity_gate, slop_authors = self.slop_authors.len(), - "preference: {} rated articles with embeddings → knn gate {:.2}; feed gate {:.1} {}; {} slop authors (penalty {:.2})", + "preference: {} rated articles with embeddings → knn gate {:.2}; feed gate {:.1} {}; affinity gate {:.1} {}; {} slop authors (penalty {:.2})", self.examples.len(), self.knn_gate, self.feed_gate, feed_detail, + self.affinity_gate, + affinity_detail, self.slop_authors.len(), self.slop_author_penalty ); } + /// Match-strength-weighted preference for the article's rated interests. + fn affinity(&self, top: &[TopInterest]) -> Option { + if self.affinity_gate <= 0.0 { + return None; + } + let mut weighted = 0.0; + let mut strength_sum = 0.0; + for interest in top { + let Some(rate) = self + .interest_rates + .get(&interest.name) + .filter(|rate| rate.n > 0) + else { + continue; + }; + let strength = (interest.z / 3.0).clamp(0.0, 1.0); + weighted += strength * (rate.weight() - 0.5); + strength_sum += strength; + } + (strength_sum > 0.0).then_some(weighted / strength_sum) + } + /// Signed rated-neighbour preference and the three nearest rated articles /// (§9.2). Absent when the gate is closed or there are no examples. pub fn knn(&self, candidate: &[f32], ranking: &RankingConfig) -> (Option, Vec) { @@ -552,6 +648,7 @@ pub fn interest_matches( let top_mean = all.iter().map(|item| item.z).sum::() / all.len() as f64; 0.7 * all[0].z + 0.3 * top_mean }; + all.retain(|interest| interest.z >= MATCH_MIN_Z); ( article_id, InterestMatch { @@ -580,10 +677,12 @@ pub fn compute( let mut signals = Signals::baseline(article); signals.knn_gate = preference.knn_gate; signals.feed_gate = preference.feed_gate; + signals.affinity_gate = preference.affinity_gate; if let Some(matched) = interests.get(&article.id) { signals.interest = Some(matched.score); signals.interest_top1_cos = Some(matched.top1_cos); signals.top_interests = matched.top_interests.clone(); + signals.affinity = preference.affinity(&matched.top_interests); } if let Some(embedding) = article_embeddings.get(&article.id) { let (knn, neighbours) = preference.knn(embedding, ranking); @@ -680,6 +779,7 @@ pub fn preliminary_blend(signals: &mut Signals, configured: &PreliminaryWeights) let candidates = [ ("interest", configured.interest, 1.0), ("knn", configured.knn, signals.knn_gate), + ("affinity", configured.affinity, signals.affinity_gate), ("heuristic", configured.heuristic, 1.0), ("feed", configured.feed, signals.feed_gate), ("social", configured.social, 1.0), @@ -817,6 +917,47 @@ mod tests { assert!((matched[&2].top1_cos - 0.707).abs() < 0.01); } + #[test] + fn match_cut_keeps_the_score_from_the_uncut_top_three() { + let mut articles = HashMap::new(); + for id in 1..=30 { + let mut vector = vec![0.0; 30]; + vector[id - 1] = 1.0; + articles.insert(id as ArticleId, vector); + } + let interest_at_z = |target: f64| { + let mean = -target / 29.0; + let spread = ((30.0 - target * target - target * target / 29.0) / 812.0).sqrt(); + let mut vector = vec![mean + spread; 30]; + vector[0] = target; + vector[29] = mean - 28.0 * spread; + unit( + &vector + .into_iter() + .map(|value| value as f32) + .collect::>(), + ) + }; + let interests = HashMap::from([ + ("first".to_string(), interest_at_z(2.0)), + ("second".to_string(), interest_at_z(1.5)), + ("weak third".to_string(), interest_at_z(0.4)), + ]); + + let matched = interest_matches(&articles, &interests); + let first = &matched[&1]; + assert_eq!( + first + .top_interests + .iter() + .map(|interest| interest.name.as_str()) + .collect::>(), + vec!["first", "second"] + ); + let uncut_score = 0.7 * 2.0 + 0.3 * ((2.0 + 1.5 + 0.4) / 3.0); + assert!((first.score - uncut_score).abs() < 1e-5, "{}", first.score); + } + #[test] fn interest_falls_back_to_raw_cosine_under_thirty_articles() { let (articles, interests) = interest_fixture(10); @@ -826,6 +967,11 @@ mod tests { (m.score - m.top1_cos).abs() < 1e-9, "article {id} should use raw top-1" ); + assert!( + m.top_interests + .iter() + .all(|interest| interest.z >= MATCH_MIN_Z) + ); } assert!((matched[&2].score - 0.707).abs() < 0.01); } @@ -930,6 +1076,99 @@ mod tests { assert_eq!(state.knn(&unit(&[1.0, 0.0]), &ranking), (None, Vec::new())); } + #[test] + fn affinity_is_absent_under_the_gate_and_without_rated_interests() { + let top = [TopInterest { + name: "Rust".into(), + z: 3.0, + cos: 0.8, + }]; + let rates = HashMap::from([( + "Rust".to_string(), + Rate { + up: 3.0, + down: 0.0, + n: 1, + }, + )]); + let closed = PreferenceState::build(Vec::new(), &ranking()).with_interest_rates( + rates, + 1, + &ranking(), + ); + assert_eq!(closed.affinity(&top), None); + + let mut open_ranking = ranking(); + open_ranking.affinity_floor = 0; + open_ranking.affinity_full = 1; + let empty = PreferenceState::build(Vec::new(), &open_ranking).with_interest_rates( + HashMap::new(), + 1, + &open_ranking, + ); + assert_eq!(empty.affinity(&top), None); + } + + #[tokio::test] + async fn affinity_load_counts_ratings_without_embeddings() { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open_and_migrate(&dir.path().join("signals.db")) + .await + .unwrap(); + sqlx::query( + "INSERT INTO articles (id, canonical_url, title, first_seen) + VALUES (1, 'https://example.com/1', 'Rated', '2026-09-13T00:00:00Z')", + ) + .execute(db.pool()) + .await + .unwrap(); + let now = Timestamp::now(); + let interests::AddOutcome::Added(interest_id) = + interests::add(&db, "Rust", None, now).await.unwrap() + else { + unreachable!(); + }; + sqlx::query( + "INSERT INTO article_interests (article_id, interest_id, cos, z) + VALUES (1, ?, 0.8, 3.0)", + ) + .bind(interest_id) + .execute(db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO rating_events + (article_id, kind, source, label, value, event_at) + VALUES (1, 'explicit', 'test', 'loved', 1.0, ?)", + ) + .bind(now.to_string()) + .execute(db.pool()) + .await + .unwrap(); + let mut ranking = ranking(); + ranking.affinity_floor = 0; + ranking.affinity_full = 1; + let voyage = VoyageConfig { + enabled: false, + ..VoyageConfig::default() + }; + + let state = PreferenceState::load(&db, &voyage, &ranking, now) + .await + .unwrap(); + assert!(state.examples.is_empty()); + assert_eq!(state.attributable_interest_ratings, 1); + assert_eq!(state.affinity_gate, 1.0); + let affinity = state + .affinity(&[TopInterest { + name: "Rust".into(), + z: 3.0, + cos: 0.8, + }]) + .unwrap(); + assert!((affinity - 1.0 / 6.0).abs() < 1e-9); + } + // --- §9.3 feed affinity --- #[test] @@ -1165,8 +1404,8 @@ mod tests { assert!((signals.weights.values().sum::() - 1.0).abs() < 1e-9); assert!(!signals.weights.contains_key("knn")); assert!(!signals.weights.contains_key("social")); - // 0.35/0.55 × 0.8 + 0.20/0.55 × 0.4 = 0.6545… - assert!((blend - 65.4545).abs() < 0.01, "{blend}"); + // 0.30/0.50 × 0.8 + 0.20/0.50 × 0.4 = 0.64. + assert!((blend - 64.0).abs() < 1e-9, "{blend}"); let mut only_heuristic = Signals { heuristic: Some(2.0), @@ -1192,6 +1431,61 @@ mod tests { assert!((signals.weights["knn"] - 0.125 / 0.325).abs() < 1e-9); } + #[test] + fn liked_interest_outranks_disliked_interest_with_affinity_in_the_blend() { + let mut ranking = ranking(); + ranking.affinity_floor = 0; + ranking.affinity_full = 1; + let rates = HashMap::from([ + ( + "liked".to_string(), + Rate { + up: 3.0, + down: 0.0, + n: 1, + }, + ), + ( + "disliked".to_string(), + Rate { + up: 0.0, + down: 4.0 / 3.0, + n: 1, + }, + ), + ]); + let state = + PreferenceState::build(Vec::new(), &ranking).with_interest_rates(rates, 1, &ranking); + let top = |name: &str| { + vec![TopInterest { + name: name.into(), + z: 3.0, + cos: 0.8, + }] + }; + let mut signals = [ + Signals { + affinity: state.affinity(&top("liked")), + heuristic: Some(1.0), + affinity_gate: state.affinity_gate, + ..Signals::default() + }, + Signals { + affinity: state.affinity(&top("disliked")), + heuristic: Some(1.0), + affinity_gate: state.affinity_gate, + ..Signals::default() + }, + ]; + normalize(&mut signals.iter_mut().collect::>()); + for signal in &mut signals { + preliminary_blend(signal, &ranking.weights.preliminary); + assert!((signal.weights.values().sum::() - 1.0).abs() < 1e-9); + assert!(signal.weights.contains_key("affinity")); + } + assert!(signals[0].preliminary > signals[1].preliminary); + } + #[test] fn compute_scores_every_article_and_leaves_ungated_signals_absent() { let articles = vec![article(1, &[10]), article(2, &[20]), article(3, &[30])]; diff --git a/src/curate/telemetry.rs b/src/curate/telemetry.rs index f1685e3..76512d2 100644 --- a/src/curate/telemetry.rs +++ b/src/curate/telemetry.rs @@ -19,10 +19,11 @@ use crate::report::RunReport; use crate::types::{ArticleId, Candidate, NearMiss}; /// Signal names rendered by `explain`, in the order of §7.5. -const RENDERED_SIGNALS: [&str; 8] = [ +const RENDERED_SIGNALS: [&str; 9] = [ "interest", "knn", "feed", + "affinity", "social", "heuristic", "triage", @@ -159,6 +160,7 @@ pub fn serialize_signals(signals: &Signals, auto_include: bool) -> String { "interest_top1_cos", "knn", "feed", + "affinity", "social", "heuristic", ] { @@ -1157,11 +1159,17 @@ mod tests { Signals { interest: Some(1.2), interest_top1_cos: Some(0.61), + affinity: Some(0.2), heuristic: Some(heuristic), - norm: BTreeMap::from([("heuristic".into(), norm), ("interest".into(), 0.9)]), + norm: BTreeMap::from([ + ("affinity".into(), 0.7), + ("heuristic".into(), norm), + ("interest".into(), 0.9), + ]), weights: BTreeMap::from([ - ("heuristic".into(), 0.2 / 0.55), - ("interest".into(), 0.35 / 0.55), + ("affinity".into(), 0.1 / 0.6), + ("heuristic".into(), 0.2 / 0.6), + ("interest".into(), 0.3 / 0.6), ]), top_interests: vec![TopInterest { name: "Gaussian Splatting".into(), @@ -1186,6 +1194,7 @@ mod tests { assert_eq!(parsed["v"], 1); assert_eq!(parsed["raw"]["heuristic"], 41.0); assert_eq!(parsed["raw"]["interest_top1_cos"], 0.61); + assert_eq!(parsed["raw"]["affinity"], 0.2); assert_eq!(parsed["present"]["heuristic"], true); assert_eq!(parsed["present"]["knn"], false); assert_eq!(parsed["present"]["quality"], false); @@ -1385,7 +1394,11 @@ mod tests { ); let squashed = text.split_whitespace().collect::>().join(" "); assert!( - squashed.contains("heuristic 41.000 · 0.550 · 0.364"), + squashed.contains("heuristic 41.000 · 0.550 · 0.333"), + "{text}" + ); + assert!( + squashed.contains("affinity 0.200 · 0.700 · 0.167"), "{text}" ); assert!(squashed.contains("knn absent"), "{text}"); diff --git a/src/web/dashboard/mod.rs b/src/web/dashboard/mod.rs index 43fcc5c..7330bbc 100644 --- a/src/web/dashboard/mod.rs +++ b/src/web/dashboard/mod.rs @@ -58,10 +58,11 @@ pub fn router() -> Router { // --------------------------------------------------------------------------- /// Signal names in the order of curation plan §7.5. -pub const SIGNAL_NAMES: [&str; 8] = [ +pub const SIGNAL_NAMES: [&str; 9] = [ "interest", "knn", "feed", + "affinity", "social", "heuristic", "triage", diff --git a/src/web/dashboard/settings.rs b/src/web/dashboard/settings.rs index 13f6ca3..158d92f 100644 --- a/src/web/dashboard/settings.rs +++ b/src/web/dashboard/settings.rs @@ -351,6 +351,8 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[ ("curation.ranking.knn_full", "Rated articles at which the knn signal reaches full weight. Must be > knn_floor."), ("curation.ranking.feed_floor", "Attributable ratings before the feed-affinity signal starts to count."), ("curation.ranking.feed_full", "Attributable ratings at which feed affinity reaches full weight. Must be > feed_floor."), + ("curation.ranking.affinity_floor", "Interest-attributable ratings before the affinity signal starts to count."), + ("curation.ranking.affinity_full", "Interest-attributable ratings at which affinity reaches full weight. Must be > affinity_floor."), ("curation.ranking.semantic_min_words", "Bodies shorter than this are not embedded."), ("curation.ranking.exploration_slots", "Shortlist slots reserved for exploration picks."), ("curation.ranking.embedding_retention_days", "features prune: unrated, unpublished vectors older than this are deleted."), @@ -360,12 +362,14 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[ ("curation.ranking.quotas.knn", "Deep-set slots filled by rated-neighbour preference."), ("curation.ranking.weights.preliminary.interest", "Interest similarity in the preliminary blend."), ("curation.ranking.weights.preliminary.knn", "Rated-neighbour preference in the preliminary blend."), + ("curation.ranking.weights.preliminary.affinity", "Rating-derived interest affinity in the preliminary blend."), ("curation.ranking.weights.preliminary.heuristic", "Heuristic score in the preliminary blend."), ("curation.ranking.weights.preliminary.feed", "Feed affinity in the preliminary blend."), ("curation.ranking.weights.preliminary.social", "Social signal in the preliminary blend."), ("curation.ranking.weights.utility.quality", "Deep-assessment quality in the utility score."), ("curation.ranking.weights.utility.fit", "Deep-assessment fit in the utility score."), ("curation.ranking.weights.utility.knn", "Rated-neighbour preference in the utility score."), + ("curation.ranking.weights.utility.affinity", "Rating-derived interest affinity in the utility score."), ("curation.ranking.weights.utility.interest", "Interest similarity in the utility score."), ("curation.ranking.weights.utility.feed", "Feed affinity in the utility score."), ("curation.ranking.weights.utility.triage", "Triage score in the utility score."),