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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc
This commit is contained in:
2026-09-13 05:20:05 +00:00
co-authored by Claude Fable 5.1
parent f0c0927ab8
commit 2d857e3e10
8 changed files with 384 additions and 33 deletions
+16 -7
View File
@@ -409,6 +409,12 @@ prints what resolved.
| `curation.recent_rejection_days` | `7` | Churn window for recent low triage/deep assessments. | | `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.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.*` | 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_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. | | `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. | | `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 gated: `knn` (rated-neighbour preference) ramps from `knn_floor` (8) to
`knn_full` (25) rated articles with embeddings, `feed` (feed affinity) from `knn_full` (25) rated articles with embeddings, `feed` (feed affinity) from
`feed_floor` (15) to `feed_full` (40) attributable ratings; below the floor the `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 `rating_lookback_days` (180); `neighbour_k` (5) neighbours per side and
`negative_coefficient` (0.75) shape the signal. `slop_author_penalty` (0.75) `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 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), `deep_keep` (120), `shortlist_keep` (60), `assessment_reuse_days` (3),
`semantic_min_words` (300), `exploration_slots` (5), `[curation.ranking.quotas]` `semantic_min_words` (300), `exploration_slots` (5), `[curation.ranking.quotas]`
(`triage` 60 · `interest` 20 · `knn` 20), `[curation.ranking.weights.utility]` (`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 `triage` 0.05 · `social` 0.03 · `heuristic` 0.02, over the signals present for
each article of the deep set) and `[curation.ranking.diversity]` each article of the deep set) and `[curation.ranking.diversity]`
(`cluster_threshold` 0.85, `per_cluster_cap` 2, `utility_protected` 10) drive (`cluster_threshold` 0.85, `per_cluster_cap` 2, `utility_protected` 10) drive
the LLM triage, deep assessment, utility ranking and diversification stages. the LLM triage, deep assessment, utility ranking and diversification stages.
`[curation.ranking.weights.preliminary]` (`interest` 0.35 · `knn` 0.25 · `[curation.ranking.weights.preliminary]` (`interest` 0.30 · `knn` 0.25 ·
`heuristic` 0.20 · `feed` 0.10 · `social` 0.10) blends the cheap signals; weights `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 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 to 1. `embedding_retention_days` (120) and `telemetry_retention_days` (180) are
what `features prune` enforces. Validation: weights non-negative; `deep_keep ≥ 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 ceiling; a protocol that is neither means another impl. Voyage AI embeddings
sit behind the analogous `EmbeddingBackend` trait in `curate/embedding.rs`. sit behind the analogous `EmbeddingBackend` trait in `curate/embedding.rs`.
- **Triage and union admission replace the heuristic gate.** Every eligible - **Triage and union admission replace the heuristic gate.** Every eligible
article gets interest, rated-neighbour, feed-affinity, social and heuristic article gets interest, rated-neighbour, feed-affinity, interest-affinity,
signals, then DeepSeek reads its opening (up to `triage_max`). The deep set is 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 the union of triage, interest, neighbour, exploration, blend and auto-include
retrievers. `explain` shows the assessment and `admitted_by`. Learned signals 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 - **Deep assessment and diversity are live.** DeepSeek reads a representative
beginning/middle/end sample, separates editorial quality from reader fit, and beginning/middle/end sample, separates editorial quality from reader fit, and
records descriptive facets. Utility is normalized over the deep set; embedding records descriptive facets. Utility is normalized over the deep set; embedding
+8 -4
View File
@@ -138,7 +138,7 @@ slop_value = -1.0 # AI slop: a full negative; the author pena
verdicts_in_prompt = 60 verdicts_in_prompt = 60
# Every weight, quota, gate and threshold of the personalized ranker. The # 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. # the weight ramps linearly from `*_floor` to `*_full` rated articles.
[curation.ranking] [curation.ranking]
triage_max = 800 # eligible articles the triage LLM reads triage_max = 800 # eligible articles the triage LLM reads
@@ -153,6 +153,8 @@ knn_floor = 8
knn_full = 25 knn_full = 25
feed_floor = 15 feed_floor = 15
feed_full = 40 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 slop_author_penalty = 0.75 # blend and utility × 0.25 for authors with an AI slop verdict
semantic_min_words = 300 semantic_min_words = 300
exploration_slots = 5 exploration_slots = 5
@@ -166,16 +168,18 @@ knn = 20
# Weights need not sum to 1; they are renormalized over the present signals. # Weights need not sum to 1; they are renormalized over the present signals.
[curation.ranking.weights.preliminary] [curation.ranking.weights.preliminary]
interest = 0.35 interest = 0.30
knn = 0.25 knn = 0.25
affinity = 0.10
heuristic = 0.20 heuristic = 0.20
feed = 0.10 feed = 0.10
social = 0.10 social = 0.05
[curation.ranking.weights.utility] [curation.ranking.weights.utility]
quality = 0.40 quality = 0.40
fit = 0.20 fit = 0.20
knn = 0.15 knn = 0.10
affinity = 0.05
interest = 0.10 interest = 0.10
feed = 0.05 feed = 0.05
triage = 0.05 triage = 0.05
+26 -5
View File
@@ -488,6 +488,8 @@ pub struct RankingConfig {
pub knn_full: usize, pub knn_full: usize,
pub feed_floor: usize, pub feed_floor: usize,
pub feed_full: usize, pub feed_full: usize,
pub affinity_floor: usize,
pub affinity_full: usize,
/// Fraction of the preliminary blend and the utility removed from any /// Fraction of the preliminary blend and the utility removed from any
/// candidate whose author has a current *AI slop* verdict (§9.3). `1.0` /// candidate whose author has a current *AI slop* verdict (§9.3). `1.0`
/// zeroes such candidates; `0.0` disables the penalty. /// zeroes such candidates; `0.0` disables the penalty.
@@ -516,6 +518,8 @@ impl Default for RankingConfig {
knn_full: 25, knn_full: 25,
feed_floor: 15, feed_floor: 15,
feed_full: 40, feed_full: 40,
affinity_floor: 15,
affinity_full: 40,
slop_author_penalty: 0.75, slop_author_penalty: 0.75,
semantic_min_words: 300, semantic_min_words: 300,
exploration_slots: 5, exploration_slots: 5,
@@ -558,6 +562,7 @@ pub struct RankingWeights {
pub struct PreliminaryWeights { pub struct PreliminaryWeights {
pub interest: f64, pub interest: f64,
pub knn: f64, pub knn: f64,
pub affinity: f64,
pub heuristic: f64, pub heuristic: f64,
pub feed: f64, pub feed: f64,
pub social: f64, pub social: f64,
@@ -566,11 +571,12 @@ pub struct PreliminaryWeights {
impl Default for PreliminaryWeights { impl Default for PreliminaryWeights {
fn default() -> Self { fn default() -> Self {
Self { Self {
interest: 0.35, interest: 0.30,
knn: 0.25, knn: 0.25,
affinity: 0.10,
heuristic: 0.20, heuristic: 0.20,
feed: 0.10, feed: 0.10,
social: 0.10, social: 0.05,
} }
} }
} }
@@ -581,6 +587,7 @@ pub struct UtilityWeights {
pub quality: f64, pub quality: f64,
pub fit: f64, pub fit: f64,
pub knn: f64, pub knn: f64,
pub affinity: f64,
pub interest: f64, pub interest: f64,
pub feed: f64, pub feed: f64,
pub triage: f64, pub triage: f64,
@@ -593,7 +600,8 @@ impl Default for UtilityWeights {
Self { Self {
quality: 0.40, quality: 0.40,
fit: 0.20, fit: 0.20,
knn: 0.15, knn: 0.10,
affinity: 0.05,
interest: 0.10, interest: 0.10,
feed: 0.05, feed: 0.05,
triage: 0.05, triage: 0.05,
@@ -1204,7 +1212,10 @@ impl Config {
.into(), .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( return Err(ConfigError::Invalid(
"curation.ranking *_full must be > *_floor >= 0".into(), "curation.ranking *_full must be > *_floor >= 0".into(),
)); ));
@@ -1229,12 +1240,14 @@ impl Config {
let weights = [ let weights = [
preliminary.interest, preliminary.interest,
preliminary.knn, preliminary.knn,
preliminary.affinity,
preliminary.heuristic, preliminary.heuristic,
preliminary.feed, preliminary.feed,
preliminary.social, preliminary.social,
utility.quality, utility.quality,
utility.fit, utility.fit,
utility.knn, utility.knn,
utility.affinity,
utility.interest, utility.interest,
utility.feed, utility.feed,
utility.triage, utility.triage,
@@ -2084,10 +2097,15 @@ mod tests {
); );
assert_eq!((ranking.knn_floor, ranking.knn_full), (8, 25)); assert_eq!((ranking.knn_floor, ranking.knn_full), (8, 25));
assert_eq!((ranking.feed_floor, ranking.feed_full), (15, 40)); 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.rating_half_life_days, 60.0);
assert_eq!(ranking.negative_coefficient, 0.75); 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.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.diversity.per_cluster_cap, 2);
assert_eq!(ranking.embedding_retention_days, 120); assert_eq!(ranking.embedding_retention_days, 120);
assert_eq!(ranking.telemetry_retention_days, 180); assert_eq!(ranking.telemetry_retention_days, 180);
@@ -2106,6 +2124,9 @@ mod tests {
bad.curation.ranking.knn_full = bad.curation.ranking.knn_floor; bad.curation.ranking.knn_full = bad.curation.ranking.knn_floor;
assert!(bad.validate().is_err(), "*_full must exceed *_floor"); assert!(bad.validate().is_err(), "*_full must exceed *_floor");
let mut bad = Config::default(); 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; bad.curation.ranking.shortlist_keep = bad.curation.ranking.deep_keep + 1;
assert!(bad.validate().is_err(), "deep_keep >= shortlist_keep"); assert!(bad.validate().is_err(), "deep_keep >= shortlist_keep");
let mut bad = Config::default(); let mut bad = Config::default();
+6 -1
View File
@@ -62,6 +62,11 @@ fn calculate_utility_for(
("quality", configured.quality, 1.0), ("quality", configured.quality, 1.0),
("fit", configured.fit, 1.0), ("fit", configured.fit, 1.0),
("knn", configured.knn, candidate.signals.knn_gate), ("knn", configured.knn, candidate.signals.knn_gate),
(
"affinity",
configured.affinity,
candidate.signals.affinity_gate,
),
("interest", configured.interest, 1.0), ("interest", configured.interest, 1.0),
("feed", configured.feed, candidate.signals.feed_gate), ("feed", configured.feed, candidate.signals.feed_gate),
("triage", configured.triage, 1.0), ("triage", configured.triage, 1.0),
@@ -342,7 +347,7 @@ mod tests {
} }
assert!(!b.signals.weights.contains_key("interest")); assert!(!b.signals.weights.contains_key("interest"));
assert!( 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 < 1e-9
); );
} }
+304 -10
View File
@@ -14,11 +14,14 @@ use crate::config::{PreliminaryWeights, RankingConfig, VoyageConfig};
use crate::curate::embedding::{dot, load_article_embeddings}; use crate::curate::embedding::{dot, load_article_embeddings};
use crate::curate::prefilter; use crate::curate::prefilter;
use crate::db::Db; use crate::db::Db;
use crate::interests::{self, Rate};
use crate::types::{Article, ArticleId, FeedId, SourceKind}; use crate::types::{Article, ArticleId, FeedId, SourceKind};
/// Below this many embedded eligible articles the z-score is too noisy, so the /// 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). /// interest signal falls back to the raw top-1 cosine (§9.1).
pub const INTEREST_ZSCORE_MIN_ARTICLES: usize = 30; 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). /// Standard-deviation floor for the per-interest z-score (§9.1).
const ZSCORE_STD_FLOOR: f64 = 1e-3; const ZSCORE_STD_FLOOR: f64 = 1e-3;
/// How many interests and rated neighbours `signals_json` records (§7.5). /// 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 /// The signal names that go through the percentile normalizer, in the order
/// they are rendered (§12.2). LLM scores (`triage`, `quality`, `fit`) are /// they are rendered (§12.2). LLM scores (`triage`, `quality`, `fit`) are
/// absolute and arrive in steps 4–5. /// 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)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct TopInterest { pub struct TopInterest {
@@ -56,6 +60,7 @@ pub struct Signals {
pub interest_top1_cos: Option<f64>, pub interest_top1_cos: Option<f64>,
pub knn: Option<f64>, pub knn: Option<f64>,
pub feed: Option<f64>, pub feed: Option<f64>,
pub affinity: Option<f64>,
pub social: Option<f64>, pub social: Option<f64>,
pub heuristic: Option<f64>, pub heuristic: Option<f64>,
/// Mid-rank percentiles of the present signals (§12.2). /// Mid-rank percentiles of the present signals (§12.2).
@@ -80,6 +85,8 @@ pub struct Signals {
pub knn_gate: f64, pub knn_gate: f64,
#[serde(skip)] #[serde(skip)]
pub feed_gate: f64, pub feed_gate: f64,
#[serde(skip)]
pub affinity_gate: f64,
/// `ranking.slop_author_penalty`, applied when `slop_author` is set. /// `ranking.slop_author_penalty`, applied when `slop_author` is set.
#[serde(skip)] #[serde(skip)]
pub slop_penalty: f64, pub slop_penalty: f64,
@@ -105,6 +112,7 @@ impl Signals {
"interest_top1_cos" => self.interest_top1_cos, "interest_top1_cos" => self.interest_top1_cos,
"knn" => self.knn, "knn" => self.knn,
"feed" => self.feed, "feed" => self.feed,
"affinity" => self.affinity,
"social" => self.social, "social" => self.social,
"heuristic" => self.heuristic, "heuristic" => self.heuristic,
_ => None, _ => None,
@@ -130,8 +138,10 @@ impl Signals {
pub struct PreferenceSummary { pub struct PreferenceSummary {
pub rated_with_embeddings: usize, pub rated_with_embeddings: usize,
pub attributable_feed_ratings: usize, pub attributable_feed_ratings: usize,
pub attributable_interest_ratings: usize,
pub knn_gate: f64, pub knn_gate: f64,
pub feed_gate: f64, pub feed_gate: f64,
pub affinity_gate: f64,
} }
/// One rated article with an embedding: the unit of the preference state (§9.2). /// One rated article with an embedding: the unit of the preference state (§9.2).
@@ -179,12 +189,15 @@ pub struct PreferenceState {
pub examples: Vec<RatedExample>, pub examples: Vec<RatedExample>,
feed_rates: HashMap<FeedId, FeedRate>, feed_rates: HashMap<FeedId, FeedRate>,
author_rates: HashMap<String, FeedRate>, author_rates: HashMap<String, FeedRate>,
interest_rates: HashMap<String, Rate>,
/// Normalized keys of authors with a current *AI slop* verdict (§9.3). /// Normalized keys of authors with a current *AI slop* verdict (§9.3).
slop_authors: HashSet<String>, slop_authors: HashSet<String>,
pub slop_author_penalty: f64, pub slop_author_penalty: f64,
pub attributable_feed_ratings: usize, pub attributable_feed_ratings: usize,
pub attributable_interest_ratings: usize,
pub knn_gate: f64, pub knn_gate: f64,
pub feed_gate: f64, pub feed_gate: f64,
pub affinity_gate: f64,
} }
impl PreferenceState { impl PreferenceState {
@@ -204,12 +217,28 @@ impl PreferenceState {
examples, examples,
feed_rates, feed_rates,
author_rates, author_rates,
interest_rates: HashMap::new(),
slop_authors: HashSet::new(), slop_authors: HashSet::new(),
slop_author_penalty: ranking.slop_author_penalty, slop_author_penalty: ranking.slop_author_penalty,
attributable_feed_ratings, 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<String, Rate>,
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 /// Register the authors whose current verdict is *AI slop*; keys are
/// normalized like [`normalize_author`] and empty ones are dropped. /// normalized like [`normalize_author`] and empty ones are dropped.
pub fn with_slop_authors<I, S>(mut self, authors: I) -> Self pub fn with_slop_authors<I, S>(mut self, authors: I) -> Self
@@ -234,8 +263,7 @@ impl PreferenceState {
self.slop_authors.len() self.slop_authors.len()
} }
/// Load `db::current_ratings(rating_lookback_days)` joined to /// Load current ratings; only kNN/feed examples require an embedding.
/// `article_embeddings`; ratings without an embedding are skipped (§9.2).
pub async fn load( pub async fn load(
db: &Db, db: &Db,
voyage: &VoyageConfig, voyage: &VoyageConfig,
@@ -249,7 +277,7 @@ impl PreferenceState {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let embeddings = load_article_embeddings(db, voyage, &ids).await?; let embeddings = load_article_embeddings(db, voyage, &ids).await?;
let mut examples = Vec::new(); let mut examples = Vec::new();
for rating in ratings { for rating in &ratings {
let Some(embedding) = embeddings.get(&rating.article_id).cloned() else { let Some(embedding) = embeddings.get(&rating.article_id).cloned() else {
continue; 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; let age_days = (now.as_second() - rating.event_at.as_second()).max(0) as f64 / 86_400.0;
examples.push(RatedExample { examples.push(RatedExample {
article_id: rating.article_id, article_id: rating.article_id,
label: rating.label, label: rating.label.clone(),
title: rating.title, title: rating.title.clone(),
value: rating.value, value: rating.value,
decay: decay(age_days, ranking.rating_half_life_days), decay: decay(age_days, ranking.rating_half_life_days),
embedding, embedding,
@@ -274,16 +302,51 @@ impl PreferenceState {
aggregator_only, 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::<Vec<_>>();
let matched = match_rows
.iter()
.map(|row| (row.article_id, row.interest_id, row.z))
.collect::<Vec<_>>();
let rates = interests::rates(&rated, &matched);
let names = match_rows
.iter()
.map(|row| (row.interest_id, row.name.as_str()))
.collect::<HashMap<_, _>>();
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?; 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 { pub fn summary(&self) -> PreferenceSummary {
PreferenceSummary { PreferenceSummary {
rated_with_embeddings: self.examples.len(), rated_with_embeddings: self.examples.len(),
attributable_feed_ratings: self.attributable_feed_ratings, attributable_feed_ratings: self.attributable_feed_ratings,
attributable_interest_ratings: self.attributable_interest_ratings,
knn_gate: self.knn_gate, knn_gate: self.knn_gate,
feed_gate: self.feed_gate, feed_gate: self.feed_gate,
affinity_gate: self.affinity_gate,
} }
} }
@@ -297,21 +360,54 @@ impl PreferenceState {
self.attributable_feed_ratings, ranking.feed_floor 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!( tracing::info!(
rated_with_embeddings = self.examples.len(), rated_with_embeddings = self.examples.len(),
knn_gate = self.knn_gate, knn_gate = self.knn_gate,
feed_gate = self.feed_gate, feed_gate = self.feed_gate,
affinity_gate = self.affinity_gate,
slop_authors = self.slop_authors.len(), 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.examples.len(),
self.knn_gate, self.knn_gate,
self.feed_gate, self.feed_gate,
feed_detail, feed_detail,
self.affinity_gate,
affinity_detail,
self.slop_authors.len(), self.slop_authors.len(),
self.slop_author_penalty self.slop_author_penalty
); );
} }
/// Match-strength-weighted preference for the article's rated interests.
fn affinity(&self, top: &[TopInterest]) -> Option<f64> {
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 /// Signed rated-neighbour preference and the three nearest rated articles
/// (§9.2). Absent when the gate is closed or there are no examples. /// (§9.2). Absent when the gate is closed or there are no examples.
pub fn knn(&self, candidate: &[f32], ranking: &RankingConfig) -> (Option<f64>, Vec<Neighbour>) { pub fn knn(&self, candidate: &[f32], ranking: &RankingConfig) -> (Option<f64>, Vec<Neighbour>) {
@@ -552,6 +648,7 @@ pub fn interest_matches(
let top_mean = all.iter().map(|item| item.z).sum::<f64>() / all.len() as f64; let top_mean = all.iter().map(|item| item.z).sum::<f64>() / all.len() as f64;
0.7 * all[0].z + 0.3 * top_mean 0.7 * all[0].z + 0.3 * top_mean
}; };
all.retain(|interest| interest.z >= MATCH_MIN_Z);
( (
article_id, article_id,
InterestMatch { InterestMatch {
@@ -580,10 +677,12 @@ pub fn compute(
let mut signals = Signals::baseline(article); let mut signals = Signals::baseline(article);
signals.knn_gate = preference.knn_gate; signals.knn_gate = preference.knn_gate;
signals.feed_gate = preference.feed_gate; signals.feed_gate = preference.feed_gate;
signals.affinity_gate = preference.affinity_gate;
if let Some(matched) = interests.get(&article.id) { if let Some(matched) = interests.get(&article.id) {
signals.interest = Some(matched.score); signals.interest = Some(matched.score);
signals.interest_top1_cos = Some(matched.top1_cos); signals.interest_top1_cos = Some(matched.top1_cos);
signals.top_interests = matched.top_interests.clone(); signals.top_interests = matched.top_interests.clone();
signals.affinity = preference.affinity(&matched.top_interests);
} }
if let Some(embedding) = article_embeddings.get(&article.id) { if let Some(embedding) = article_embeddings.get(&article.id) {
let (knn, neighbours) = preference.knn(embedding, ranking); let (knn, neighbours) = preference.knn(embedding, ranking);
@@ -680,6 +779,7 @@ pub fn preliminary_blend(signals: &mut Signals, configured: &PreliminaryWeights)
let candidates = [ let candidates = [
("interest", configured.interest, 1.0), ("interest", configured.interest, 1.0),
("knn", configured.knn, signals.knn_gate), ("knn", configured.knn, signals.knn_gate),
("affinity", configured.affinity, signals.affinity_gate),
("heuristic", configured.heuristic, 1.0), ("heuristic", configured.heuristic, 1.0),
("feed", configured.feed, signals.feed_gate), ("feed", configured.feed, signals.feed_gate),
("social", configured.social, 1.0), ("social", configured.social, 1.0),
@@ -817,6 +917,47 @@ mod tests {
assert!((matched[&2].top1_cos - 0.707).abs() < 0.01); 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::<Vec<_>>(),
)
};
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<_>>(),
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] #[test]
fn interest_falls_back_to_raw_cosine_under_thirty_articles() { fn interest_falls_back_to_raw_cosine_under_thirty_articles() {
let (articles, interests) = interest_fixture(10); let (articles, interests) = interest_fixture(10);
@@ -826,6 +967,11 @@ mod tests {
(m.score - m.top1_cos).abs() < 1e-9, (m.score - m.top1_cos).abs() < 1e-9,
"article {id} should use raw top-1" "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); 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())); 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 --- // --- §9.3 feed affinity ---
#[test] #[test]
@@ -1165,8 +1404,8 @@ mod tests {
assert!((signals.weights.values().sum::<f64>() - 1.0).abs() < 1e-9); assert!((signals.weights.values().sum::<f64>() - 1.0).abs() < 1e-9);
assert!(!signals.weights.contains_key("knn")); assert!(!signals.weights.contains_key("knn"));
assert!(!signals.weights.contains_key("social")); assert!(!signals.weights.contains_key("social"));
// 0.35/0.55 × 0.8 + 0.20/0.55 × 0.4 = 0.6545… // 0.30/0.50 × 0.8 + 0.20/0.50 × 0.4 = 0.64.
assert!((blend - 65.4545).abs() < 0.01, "{blend}"); assert!((blend - 64.0).abs() < 1e-9, "{blend}");
let mut only_heuristic = Signals { let mut only_heuristic = Signals {
heuristic: Some(2.0), heuristic: Some(2.0),
@@ -1192,6 +1431,61 @@ mod tests {
assert!((signals.weights["knn"] - 0.125 / 0.325).abs() < 1e-9); 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::<Vec<_>>());
for signal in &mut signals {
preliminary_blend(signal, &ranking.weights.preliminary);
assert!((signal.weights.values().sum::<f64>() - 1.0).abs() < 1e-9);
assert!(signal.weights.contains_key("affinity"));
}
assert!(signals[0].preliminary > signals[1].preliminary);
}
#[test] #[test]
fn compute_scores_every_article_and_leaves_ungated_signals_absent() { fn compute_scores_every_article_and_leaves_ungated_signals_absent() {
let articles = vec![article(1, &[10]), article(2, &[20]), article(3, &[30])]; let articles = vec![article(1, &[10]), article(2, &[20]), article(3, &[30])];
+18 -5
View File
@@ -19,10 +19,11 @@ use crate::report::RunReport;
use crate::types::{ArticleId, Candidate, NearMiss}; use crate::types::{ArticleId, Candidate, NearMiss};
/// Signal names rendered by `explain`, in the order of §7.5. /// Signal names rendered by `explain`, in the order of §7.5.
const RENDERED_SIGNALS: [&str; 8] = [ const RENDERED_SIGNALS: [&str; 9] = [
"interest", "interest",
"knn", "knn",
"feed", "feed",
"affinity",
"social", "social",
"heuristic", "heuristic",
"triage", "triage",
@@ -159,6 +160,7 @@ pub fn serialize_signals(signals: &Signals, auto_include: bool) -> String {
"interest_top1_cos", "interest_top1_cos",
"knn", "knn",
"feed", "feed",
"affinity",
"social", "social",
"heuristic", "heuristic",
] { ] {
@@ -1157,11 +1159,17 @@ mod tests {
Signals { Signals {
interest: Some(1.2), interest: Some(1.2),
interest_top1_cos: Some(0.61), interest_top1_cos: Some(0.61),
affinity: Some(0.2),
heuristic: Some(heuristic), 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([ weights: BTreeMap::from([
("heuristic".into(), 0.2 / 0.55), ("affinity".into(), 0.1 / 0.6),
("interest".into(), 0.35 / 0.55), ("heuristic".into(), 0.2 / 0.6),
("interest".into(), 0.3 / 0.6),
]), ]),
top_interests: vec![TopInterest { top_interests: vec![TopInterest {
name: "Gaussian Splatting".into(), name: "Gaussian Splatting".into(),
@@ -1186,6 +1194,7 @@ mod tests {
assert_eq!(parsed["v"], 1); assert_eq!(parsed["v"], 1);
assert_eq!(parsed["raw"]["heuristic"], 41.0); assert_eq!(parsed["raw"]["heuristic"], 41.0);
assert_eq!(parsed["raw"]["interest_top1_cos"], 0.61); 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"]["heuristic"], true);
assert_eq!(parsed["present"]["knn"], false); assert_eq!(parsed["present"]["knn"], false);
assert_eq!(parsed["present"]["quality"], false); assert_eq!(parsed["present"]["quality"], false);
@@ -1385,7 +1394,11 @@ mod tests {
); );
let squashed = text.split_whitespace().collect::<Vec<_>>().join(" "); let squashed = text.split_whitespace().collect::<Vec<_>>().join(" ");
assert!( 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}" "{text}"
); );
assert!(squashed.contains("knn absent"), "{text}"); assert!(squashed.contains("knn absent"), "{text}");
+2 -1
View File
@@ -58,10 +58,11 @@ pub fn router() -> Router<AppState> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Signal names in the order of curation plan §7.5. /// Signal names in the order of curation plan §7.5.
pub const SIGNAL_NAMES: [&str; 8] = [ pub const SIGNAL_NAMES: [&str; 9] = [
"interest", "interest",
"knn", "knn",
"feed", "feed",
"affinity",
"social", "social",
"heuristic", "heuristic",
"triage", "triage",
+4
View File
@@ -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.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_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.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.semantic_min_words", "Bodies shorter than this are not embedded."),
("curation.ranking.exploration_slots", "Shortlist slots reserved for exploration picks."), ("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."), ("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.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.interest", "Interest similarity in the preliminary blend."),
("curation.ranking.weights.preliminary.knn", "Rated-neighbour preference 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.heuristic", "Heuristic score in the preliminary blend."),
("curation.ranking.weights.preliminary.feed", "Feed affinity 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.preliminary.social", "Social signal in the preliminary blend."),
("curation.ranking.weights.utility.quality", "Deep-assessment quality in the utility score."), ("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.fit", "Deep-assessment fit in the utility score."),
("curation.ranking.weights.utility.knn", "Rated-neighbour preference 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.interest", "Interest similarity in the utility score."),
("curation.ranking.weights.utility.feed", "Feed affinity 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."), ("curation.ranking.weights.utility.triage", "Triage score in the utility score."),