From a52f4123dde07f018de1e05d8c7dbfe577492dc4 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Mon, 7 Sep 2026 17:34:40 +0000 Subject: [PATCH] Credit ratings to the author, and less to aggregator feeds A rating on an article that arrived only via an aggregator (HN, Lobsters, Reddit, Scour) used to count fully against that aggregator feed. Now the aggregator feed gets a quarter of the credit and the article's author gets the full credit, so future articles by the same author from any feed carry the history. The feed signal is the mean over rated direct feeds and the rated author. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4 --- .../2026-09-02-personalized-curation-v2.md | 8 +- src/curate/signals.rs | 159 +++++++++++++++--- src/web/dashboard/ratings.rs | 10 +- 3 files changed, 152 insertions(+), 25 deletions(-) diff --git a/docs/plans/2026-09-02-personalized-curation-v2.md b/docs/plans/2026-09-02-personalized-curation-v2.md index 1b1a8ff..a5d14da 100644 --- a/docs/plans/2026-09-02-personalized-curation-v2.md +++ b/docs/plans/2026-09-02-personalized-curation-v2.md @@ -497,7 +497,7 @@ Computed in `src/curate/signals.rs` after embeddings. Every signal is `Option, /// Distinct direct feeds that carried the rated article (§9.3). pub feeds: Vec, + /// Whitespace-normalized, lowercase author key (§9.3). + pub author: Option, + /// Whether the article arrived only through link aggregators (§9.3). + pub aggregator_only: bool, } impl RatedExample { @@ -153,6 +160,7 @@ impl FeedRate { pub struct PreferenceState { pub examples: Vec, feed_rates: HashMap, + author_rates: HashMap, pub attributable_feed_ratings: usize, pub knn_gate: f64, pub feed_gate: f64, @@ -160,8 +168,11 @@ pub struct PreferenceState { impl PreferenceState { /// Build the state from already-loaded examples (pure; tests use this). - pub fn build(examples: Vec, ranking: &RankingConfig) -> Self { - let (feed_rates, attributable_feed_ratings) = feed_rates(&examples); + pub fn build(mut examples: Vec, ranking: &RankingConfig) -> Self { + for example in &mut examples { + example.author = normalize_author(example.author.as_deref()); + } + let (feed_rates, author_rates, attributable_feed_ratings) = feed_rates(&examples); Self { knn_gate: gate(examples.len(), ranking.knn_floor, ranking.knn_full), feed_gate: gate( @@ -171,6 +182,7 @@ impl PreferenceState { ), examples, feed_rates, + author_rates, attributable_feed_ratings, } } @@ -194,12 +206,14 @@ impl PreferenceState { let Some(embedding) = embeddings.get(&rating.article_id).cloned() else { continue; }; - let feeds = db - .get_article(rating.article_id) - .await? + let article = db.get_article(rating.article_id).await?; + let feeds = article.as_ref().map(direct_feeds).unwrap_or_default(); + let author = article .as_ref() - .map(direct_feeds) - .unwrap_or_default(); + .and_then(|article| normalize_author(article.author.as_deref())); + let aggregator_only = article + .as_ref() + .is_some_and(crate::discovery::aggregator_only); 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, @@ -209,6 +223,8 @@ impl PreferenceState { decay: decay(age_days, ranking.rating_half_life_days), embedding, feeds, + author, + aggregator_only, }); } Ok(Self::build(examples, ranking)) @@ -298,16 +314,23 @@ impl PreferenceState { (knn, neighbours) } - /// Mean Beta-smoothed rate over the article's rated direct feeds (§9.3). + /// Mean Beta-smoothed rate over the article's rated direct feeds and author + /// (§9.3). pub fn feed(&self, article: &Article) -> Option { if self.feed_gate <= 0.0 { return None; } - let rates = direct_feeds(article) + let mut rates = direct_feeds(article) .into_iter() .filter_map(|feed| self.feed_rates.get(&feed)) .map(|rate| rate.rate()) .collect::>(); + if let Some(rate) = normalize_author(article.author.as_deref()) + .as_ref() + .and_then(|author| self.author_rates.get(author)) + { + rates.push(rate.rate()); + } (!rates.is_empty()).then(|| rates.iter().sum::() / rates.len() as f64) } @@ -315,6 +338,13 @@ impl PreferenceState { pub fn feed_credit(&self, feed: FeedId) -> Option<(f64, f64)> { self.feed_rates.get(&feed).map(|rate| (rate.up, rate.down)) } + + /// Per-author `(up, down)` credit, exposed for tests of §9.3. + pub fn author_credit(&self, author: &str) -> Option<(f64, f64)> { + normalize_author(Some(author)) + .and_then(|author| self.author_rates.get(&author)) + .map(|rate| (rate.up, rate.down)) + } } /// `0.5 ^ (age_days / half_life_days)` (§9.2). @@ -354,22 +384,46 @@ pub fn direct_feeds(article: &Article) -> Vec { feeds } -fn feed_rates(examples: &[RatedExample]) -> (HashMap, usize) { - let mut rates: HashMap = HashMap::new(); +fn normalize_author(author: Option<&str>) -> Option { + let normalized = author? + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase(); + (!normalized.is_empty()).then_some(normalized) +} + +fn feed_rates( + examples: &[RatedExample], +) -> (HashMap, HashMap, usize) { + let mut feed_rates: HashMap = HashMap::new(); + let mut author_rates: HashMap = HashMap::new(); let mut attributable = 0; for example in examples { - if example.feeds.is_empty() { - continue; + let weight = example.weight(); + if !example.feeds.is_empty() { + let feed_weight = if example.aggregator_only { + weight * AGGREGATOR_FEED_SHARE + } else { + weight + }; + let credit = feed_weight / example.feeds.len() as f64; + for feed in &example.feeds { + let rate = feed_rates.entry(*feed).or_default(); + rate.up += credit.max(0.0); + rate.down += (-credit).max(0.0); + } } - attributable += 1; - let credit = example.weight() / example.feeds.len() as f64; - for feed in &example.feeds { - let rate = rates.entry(*feed).or_default(); - rate.up += credit.max(0.0); - rate.down += (-credit).max(0.0); + if let Some(author) = &example.author { + let rate = author_rates.entry(author.clone()).or_default(); + rate.up += weight.max(0.0); + rate.down += (-weight).max(0.0); + } + if !example.feeds.is_empty() || example.author.is_some() { + attributable += 1; } } - (rates, attributable) + (feed_rates, author_rates, attributable) } /// The interest match of §9.1 for one article. @@ -620,6 +674,8 @@ mod tests { decay: 1.0, embedding: unit(embedding), feeds: vec![id], + author: None, + aggregator_only: false, } } @@ -845,6 +901,69 @@ mod tests { assert_eq!(state.feed(&article(9, &[99])), None); } + #[test] + fn aggregator_only_rating_splits_credit_between_feed_and_author() { + let mut rated = example(1, "loved", 1.0, &[1.0, 0.0]); + rated.decay = 0.4; + rated.feeds = vec![10]; + rated.author = Some("example author".into()); + rated.aggregator_only = true; + let state = PreferenceState::build(vec![rated], &ranking()); + + assert!((state.feed_credit(10).unwrap().0 - 0.25 * 0.4).abs() < 1e-9); + assert!((state.author_credit("example author").unwrap().0 - 0.4).abs() < 1e-9); + } + + #[test] + fn author_affinity_applies_across_feeds_with_normalized_keys() { + let mut ranking = ranking(); + ranking.feed_floor = 0; + ranking.feed_full = 1; + let mut rated = example(1, "loved", 1.0, &[1.0, 0.0]); + rated.feeds = vec![10]; + rated.author = Some("Ada Lovelace".into()); + let state = PreferenceState::build(vec![rated], &ranking); + let mut candidate = article(2, &[99]); + candidate.author = Some(" ADA lovelace ".into()); + + assert_eq!(state.examples[0].author.as_deref(), Some("ada lovelace")); + assert_eq!(state.feed_credit(99), None); + assert!((state.feed(&candidate).unwrap() - 2.0 / 3.0).abs() < 1e-9); + assert_eq!(state.author_credit(" ADA Lovelace "), Some((1.0, 0.0))); + } + + #[test] + fn feed_affinity_means_rated_feed_and_rated_author() { + let mut ranking = ranking(); + ranking.feed_floor = 0; + ranking.feed_full = 1; + let mut feed_loved = example(1, "loved", 1.0, &[1.0, 0.0]); + feed_loved.feeds = vec![10]; + let mut author_down = example(2, "not_for_me", -1.0, &[1.0, 0.0]); + author_down.feeds.clear(); + author_down.author = Some("writer".into()); + let state = PreferenceState::build(vec![feed_loved, author_down], &ranking); + let mut candidate = article(3, &[10]); + candidate.author = Some("Writer".into()); + + assert!((state.feed(&candidate).unwrap() - 0.5).abs() < 1e-9); + } + + #[test] + fn aggregator_only_without_author_keeps_feed_behavior_at_reduced_credit() { + let mut ranking = ranking(); + ranking.feed_floor = 0; + ranking.feed_full = 1; + let mut rated = example(1, "not_for_me", -1.0, &[1.0, 0.0]); + rated.feeds = vec![10]; + rated.aggregator_only = true; + let state = PreferenceState::build(vec![rated], &ranking); + + assert_eq!(state.feed_credit(10), Some((0.0, 0.25))); + assert_eq!(state.author_credit(""), None); + assert!((state.feed(&article(2, &[10])).unwrap() - 1.0 / 2.25).abs() < 1e-9); + } + #[test] fn feed_is_absent_when_the_gate_is_closed() { let ranking = ranking(); // feed_floor 15 diff --git a/src/web/dashboard/ratings.rs b/src/web/dashboard/ratings.rs index a88f96b..ecd8881 100644 --- a/src/web/dashboard/ratings.rs +++ b/src/web/dashboard/ratings.rs @@ -58,7 +58,8 @@ pub fn routes() -> Router { pub struct FeedCredit { pub feed_id: FeedId, pub feed_title: String, - /// `value × decay / n` over the article's `n` direct feeds. + /// `value × decay / n` over the article's `n` direct feeds, reduced for an + /// aggregator-only article. pub credit: f64, } @@ -97,7 +98,12 @@ pub fn contribution( let feed_credits = if rating.label == "cleared" || feeds.is_empty() { Vec::new() } else { - let credit = weight / feeds.len() as f64; + let feed_weight = if article.is_some_and(crate::discovery::aggregator_only) { + weight * signals::AGGREGATOR_FEED_SHARE + } else { + weight + }; + let credit = feed_weight / feeds.len() as f64; feeds .iter() .map(|feed_id| FeedCredit {