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
+26 -5
View File
@@ -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();
+6 -1
View File
@@ -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
);
}
+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::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<f64>,
pub knn: Option<f64>,
pub feed: Option<f64>,
pub affinity: Option<f64>,
pub social: Option<f64>,
pub heuristic: Option<f64>,
/// 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<RatedExample>,
feed_rates: HashMap<FeedId, FeedRate>,
author_rates: HashMap<String, FeedRate>,
interest_rates: HashMap<String, Rate>,
/// Normalized keys of authors with a current *AI slop* verdict (§9.3).
slop_authors: HashSet<String>,
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<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
/// normalized like [`normalize_author`] and empty ones are dropped.
pub fn with_slop_authors<I, S>(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::<Vec<_>>();
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::<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?;
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<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
/// (§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>) {
@@ -552,6 +648,7 @@ pub fn interest_matches(
let top_mean = all.iter().map(|item| item.z).sum::<f64>() / 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::<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]
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::<f64>() - 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::<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]
fn compute_scores_every_article_and_leaves_ungated_signals_absent() {
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};
/// 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::<Vec<_>>().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}");
+2 -1
View File
@@ -58,10 +58,11 @@ pub fn router() -> Router<AppState> {
// ---------------------------------------------------------------------------
/// 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",
+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.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."),