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:
+26
-5
@@ -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
@@ -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
@@ -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
@@ -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}");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."),
|
||||
|
||||
Reference in New Issue
Block a user