Files
the-daily-epub/src/curate/signals.rs
T
thalladaandClaude Fable 5.1 2d857e3e10 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
2026-09-13 05:20:05 +00:00

1514 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Cheap per-article ranking signals, the mid-rank percentile normalizer and the
//! preliminary blend (plan §9, §12.2, §12.4).
//!
//! Every signal is an `Option<f64>`: `None` means *absent*, which is never a
//! numeric zero. Absent signals are left out of the percentile computation and
//! of the blend, whose remaining weights are renormalized (§12.2, §12.4).
use std::collections::{BTreeMap, HashMap, HashSet};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
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).
const RECORDED_TOP: usize = 3;
/// Aggregators carried the link rather than authored the article, so they get
/// only a small share of an aggregator-only article's feed-affinity credit.
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; 6] =
["interest", "knn", "feed", "affinity", "social", "heuristic"];
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct TopInterest {
pub name: String,
pub z: f64,
pub cos: f64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Neighbour {
pub article_id: ArticleId,
pub label: String,
pub cos: f64,
pub title: String,
}
/// Every cheap signal for one article, plus what the normalizer and the blend
/// derived from them (§9, §12.2, §12.4).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Signals {
pub interest: Option<f64>,
/// Raw top-1 cosine behind `interest`, recorded for `explain` (§7.5).
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).
#[serde(default)]
pub norm: BTreeMap<String, f64>,
/// Effective preliminary weights after gating and renormalization (§12.4).
#[serde(default)]
pub weights: BTreeMap<String, f64>,
#[serde(default)]
pub top_interests: Vec<TopInterest>,
#[serde(default)]
pub neighbours: Vec<Neighbour>,
#[serde(default)]
pub notes: Vec<String>,
/// Preliminary blend on a 0–100 scale; `None` when nothing is present.
pub preliminary: Option<f64>,
/// The author has a current *AI slop* verdict (§9.3).
#[serde(default)]
pub slop_author: bool,
/// Gate ramps applied to the learned signals' weights (§9.2, §9.3).
#[serde(skip)]
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,
}
impl Signals {
/// The multiplier the slop-author penalty applies to the blend and the
/// utility: `1 − penalty` for a reported author, `1` otherwise (§9.3).
pub fn slop_factor(&self) -> f64 {
if self.slop_author {
(1.0 - self.slop_penalty).clamp(0.0, 1.0)
} else {
1.0
}
}
}
impl Signals {
/// The raw value of a named signal, `None` when absent or unknown.
pub fn raw(&self, name: &str) -> Option<f64> {
match name {
"interest" => self.interest,
"interest_top1_cos" => self.interest_top1_cos,
"knn" => self.knn,
"feed" => self.feed,
"affinity" => self.affinity,
"social" => self.social,
"heuristic" => self.heuristic,
_ => None,
}
}
pub fn present(&self, name: &str) -> bool {
self.raw(name).is_some()
}
/// The signals every eligible article gets without embeddings or ratings.
pub fn baseline(article: &Article) -> Self {
Self {
social: (!article.social.is_empty()).then(|| article.social_score()),
heuristic: Some(prefilter::text_heuristic(article)),
..Self::default()
}
}
}
/// What the run log and the report say about the learned signals (§9.2, §15.4).
#[derive(Debug, Clone, Copy, Default, PartialEq)]
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).
#[derive(Debug, Clone, PartialEq)]
pub struct RatedExample {
pub article_id: ArticleId,
pub label: String,
pub title: String,
/// The vote's value (`loved` 1.0, `good` 0.35, `not_for_me` −1.0).
pub value: f64,
/// `0.5 ^ (age_days / half_life_days)` at the time of the run.
pub decay: f64,
pub embedding: Vec<f32>,
/// Distinct direct feeds that carried the rated article (§9.3).
pub feeds: Vec<FeedId>,
/// Whitespace-normalized, lowercase author key (§9.3).
pub author: Option<String>,
/// Whether the article arrived only through link aggregators (§9.3).
pub aggregator_only: bool,
}
impl RatedExample {
/// `weight_i = value_i × decay_i` (§9.2).
pub fn weight(&self) -> f64 {
self.value * self.decay
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
struct FeedRate {
up: f64,
down: f64,
}
impl FeedRate {
/// Beta-smoothed rate `(up + 1) / (up + down + 2)` (§9.3).
fn rate(self) -> f64 {
(self.up + 1.0) / (self.up + self.down + 2.0)
}
}
/// Rated-neighbour and feed-affinity state, built once per run (§9.2, §9.3).
#[derive(Debug, Clone, Default)]
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 {
/// Build the state from already-loaded examples (pure; tests use this).
pub fn build(mut examples: Vec<RatedExample>, ranking: &RankingConfig) -> Self {
for example in &mut examples {
example.author = normalize_author(example.author.as_deref());
}
let (feed_rates, author_rates, attributable_feed_ratings) = feed_rates(&examples);
Self {
knn_gate: gate(examples.len(), ranking.knn_floor, ranking.knn_full),
feed_gate: gate(
attributable_feed_ratings,
ranking.feed_floor,
ranking.feed_full,
),
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
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.slop_authors = authors
.into_iter()
.filter_map(|author| normalize_author(Some(author.as_ref())))
.collect();
self
}
/// Whether the article's author has a current *AI slop* verdict (§9.3).
pub fn is_slop_author(&self, article: &Article) -> bool {
normalize_author(article.author.as_deref())
.is_some_and(|author| self.slop_authors.contains(&author))
}
pub fn slop_author_count(&self) -> usize {
self.slop_authors.len()
}
/// Load current ratings; only kNN/feed examples require an embedding.
pub async fn load(
db: &Db,
voyage: &VoyageConfig,
ranking: &RankingConfig,
now: Timestamp,
) -> anyhow::Result<Self> {
let ratings = db.current_ratings(ranking.rating_lookback_days).await?;
let ids = ratings
.iter()
.map(|rating| rating.article_id)
.collect::<Vec<_>>();
let embeddings = load_article_embeddings(db, voyage, &ids).await?;
let mut examples = Vec::new();
for rating in &ratings {
let Some(embedding) = embeddings.get(&rating.article_id).cloned() else {
continue;
};
let article = db.get_article(rating.article_id).await?;
let feeds = article.as_ref().map(direct_feeds).unwrap_or_default();
let author = article
.as_ref()
.and_then(|article| normalize_author(article.author.as_deref()));
let aggregator_only = article
.as_ref()
.is_some_and(crate::discovery::aggregator_only);
let age_days = (now.as_second() - rating.event_at.as_second()).max(0) as f64 / 86_400.0;
examples.push(RatedExample {
article_id: rating.article_id,
label: rating.label.clone(),
title: rating.title.clone(),
value: rating.value,
decay: decay(age_days, ranking.rating_half_life_days),
embedding,
feeds,
author,
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_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,
}
}
/// The once-per-run log line of §9.2.
pub fn log(&self, ranking: &RankingConfig) {
let feed_detail = if self.feed_gate > 0.0 {
format!("(n={})", self.attributable_feed_ratings)
} else {
format!(
"(n={} < {})",
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} {}; 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>) {
if self.knn_gate <= 0.0 || self.examples.is_empty() {
return (None, Vec::new());
}
let mut scored = self
.examples
.iter()
.filter_map(|example| {
dot(candidate, &example.embedding)
.ok()
.map(|s| (s, example))
})
.collect::<Vec<_>>();
scored.sort_by(|left, right| right.0.total_cmp(&left.0));
let side = |positive: bool| -> Option<f64> {
let chosen = scored
.iter()
.filter(|(_, example)| (example.weight() > 0.0) == positive)
.take(ranking.neighbour_k.max(1))
.collect::<Vec<_>>();
let denominator = chosen
.iter()
.map(|(_, example)| example.weight().abs())
.sum::<f64>();
(denominator > 0.0).then(|| {
chosen
.iter()
.map(|(similarity, example)| example.weight().abs() * similarity)
.sum::<f64>()
/ denominator
})
};
let positive = side(true);
let negative = side(false);
let knn = (positive.is_some() || negative.is_some()).then(|| {
positive.unwrap_or(0.0) - ranking.negative_coefficient * negative.unwrap_or(0.0)
});
let neighbours = scored
.iter()
.take(RECORDED_TOP)
.map(|(cos, example)| Neighbour {
article_id: example.article_id,
label: example.label.clone(),
cos: *cos,
title: example.title.clone(),
})
.collect();
(knn, neighbours)
}
/// Mean Beta-smoothed rate over the article's rated direct feeds and author
/// (§9.3).
pub fn feed(&self, article: &Article) -> Option<f64> {
if self.feed_gate <= 0.0 {
return None;
}
let mut rates = direct_feeds(article)
.into_iter()
.filter_map(|feed| self.feed_rates.get(&feed))
.map(|rate| rate.rate())
.collect::<Vec<_>>();
if let Some(rate) = normalize_author(article.author.as_deref())
.as_ref()
.and_then(|author| self.author_rates.get(author))
{
rates.push(rate.rate());
}
(!rates.is_empty()).then(|| rates.iter().sum::<f64>() / rates.len() as f64)
}
/// Per-feed `(up, down)` credit, exposed for tests of §9.3.
pub fn feed_credit(&self, feed: FeedId) -> Option<(f64, f64)> {
self.feed_rates.get(&feed).map(|rate| (rate.up, rate.down))
}
/// Per-author `(up, down)` credit, exposed for tests of §9.3.
pub fn author_credit(&self, author: &str) -> Option<(f64, f64)> {
normalize_author(Some(author))
.and_then(|author| self.author_rates.get(&author))
.map(|rate| (rate.up, rate.down))
}
}
/// `0.5 ^ (age_days / half_life_days)` (§9.2).
pub fn decay(age_days: f64, half_life_days: f64) -> f64 {
if half_life_days <= 0.0 {
return 1.0;
}
0.5f64.powf(age_days.max(0.0) / half_life_days)
}
/// `clamp((n − floor) / (full − floor), 0, 1)` (§9.2).
pub fn gate(n: usize, floor: usize, full: usize) -> f64 {
if n <= floor {
0.0
} else if n >= full || full <= floor {
1.0
} else {
(n - floor) as f64 / (full - floor) as f64
}
}
/// Distinct `SourceKind::Feed` feeds that carried the article; the best entry's
/// feed when there are none (§9.3).
pub fn direct_feeds(article: &Article) -> Vec<FeedId> {
let mut feeds = article
.sources
.iter()
.filter(|source| source.kind == SourceKind::Feed)
.map(|source| source.feed_id)
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
if feeds.is_empty() && article.feed_id != 0 {
feeds.push(article.feed_id);
}
feeds.sort_unstable();
feeds
}
fn normalize_author(author: Option<&str>) -> Option<String> {
let normalized = author?
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
(!normalized.is_empty()).then_some(normalized)
}
fn feed_rates(
examples: &[RatedExample],
) -> (HashMap<FeedId, FeedRate>, HashMap<String, FeedRate>, usize) {
let mut feed_rates: HashMap<FeedId, FeedRate> = HashMap::new();
let mut author_rates: HashMap<String, FeedRate> = HashMap::new();
let mut attributable = 0;
for example in examples {
let weight = example.weight();
if !example.feeds.is_empty() {
let feed_weight = if example.aggregator_only {
weight * AGGREGATOR_FEED_SHARE
} else {
weight
};
let credit = feed_weight / example.feeds.len() as f64;
for feed in &example.feeds {
let rate = feed_rates.entry(*feed).or_default();
rate.up += credit.max(0.0);
rate.down += (-credit).max(0.0);
}
}
if let Some(author) = &example.author {
let rate = author_rates.entry(author.clone()).or_default();
rate.up += weight.max(0.0);
rate.down += (-weight).max(0.0);
}
if !example.feeds.is_empty() || example.author.is_some() {
attributable += 1;
}
}
(feed_rates, author_rates, attributable)
}
/// The interest match of §9.1 for one article.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct InterestMatch {
pub score: f64,
pub top1_cos: f64,
pub top_interests: Vec<TopInterest>,
}
/// Z-scored standing-interest match for every embedded article (§9.1).
///
/// Below [`INTEREST_ZSCORE_MIN_ARTICLES`] embedded articles the score is the raw
/// top-1 cosine instead, and that is logged.
pub fn interest_matches(
articles: &HashMap<ArticleId, Vec<f32>>,
interests: &HashMap<String, Vec<f32>>,
) -> HashMap<ArticleId, InterestMatch> {
if articles.is_empty() || interests.is_empty() {
return HashMap::new();
}
let fallback = articles.len() < INTEREST_ZSCORE_MIN_ARTICLES;
if fallback {
tracing::info!(
embedded = articles.len(),
"fewer than {INTEREST_ZSCORE_MIN_ARTICLES} embedded articles; interest uses the raw top-1 cosine"
);
}
let mut matches: HashMap<ArticleId, Vec<TopInterest>> = HashMap::new();
for (name, interest) in interests {
let similarities = articles
.iter()
.filter_map(|(article_id, article)| {
dot(interest, article).ok().map(|cos| (*article_id, cos))
})
.collect::<Vec<_>>();
if similarities.is_empty() {
continue;
}
let n = similarities.len() as f64;
let mean = similarities.iter().map(|(_, cos)| cos).sum::<f64>() / n;
let variance = similarities
.iter()
.map(|(_, cos)| (cos - mean).powi(2))
.sum::<f64>()
/ n;
let std = variance.sqrt().max(ZSCORE_STD_FLOOR);
for (article_id, cos) in similarities {
matches.entry(article_id).or_default().push(TopInterest {
name: name.clone(),
z: (cos - mean) / std,
cos,
});
}
}
matches
.into_iter()
.map(|(article_id, mut all)| {
all.sort_by(|left, right| {
right
.z
.total_cmp(&left.z)
.then_with(|| left.name.cmp(&right.name))
});
let top1_cos = all
.iter()
.map(|item| item.cos)
.fold(f64::NEG_INFINITY, f64::max);
all.truncate(RECORDED_TOP);
let score = if fallback {
top1_cos
} else {
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 {
score,
top1_cos,
top_interests: all,
},
)
})
.collect()
}
/// Every cheap signal for the eligible set, normalized and blended (§9, §12.2,
/// §12.4). Pure: the preference state is already loaded.
pub fn compute(
articles: &[Article],
article_embeddings: &HashMap<ArticleId, Vec<f32>>,
interest_embeddings: &HashMap<String, Vec<f32>>,
preference: &PreferenceState,
ranking: &RankingConfig,
) -> HashMap<ArticleId, Signals> {
let interests = interest_matches(article_embeddings, interest_embeddings);
let mut all = articles
.iter()
.map(|article| {
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);
signals.knn = knn;
signals.neighbours = neighbours;
}
signals.feed = preference.feed(article);
if preference.is_slop_author(article) {
signals.slop_author = true;
signals.slop_penalty = preference.slop_author_penalty;
signals.notes.push(format!(
"author reported as AI slop: blend and utility × {:.2}",
signals.slop_factor()
));
}
if preference.knn_gate > 0.0 {
signals.notes.push(format!(
"knn gate {:.2} (n={} rated with embeddings)",
preference.knn_gate,
preference.examples.len()
));
}
signals
})
.collect::<Vec<_>>();
normalize(&mut all.iter_mut().collect::<Vec<_>>());
for signals in &mut all {
preliminary_blend(signals, &ranking.weights.preliminary);
}
articles.iter().map(|article| article.id).zip(all).collect()
}
/// [`compute`] with the preference state loaded from the database.
pub async fn compute_all(
db: &Db,
articles: &[Article],
article_embeddings: &HashMap<ArticleId, Vec<f32>>,
interest_embeddings: &HashMap<String, Vec<f32>>,
voyage: &VoyageConfig,
ranking: &RankingConfig,
now: Timestamp,
) -> anyhow::Result<(HashMap<ArticleId, Signals>, PreferenceSummary)> {
let preference = PreferenceState::load(db, voyage, ranking, now).await?;
preference.log(ranking);
Ok((
compute(
articles,
article_embeddings,
interest_embeddings,
&preference,
ranking,
),
preference.summary(),
))
}
/// Mid-rank percentiles over the present values of each signal (§12.2).
///
/// `p(x) = (count_below + (count_equal + 1) / 2) / n_present`; fewer than two
/// present values or all-equal values give 0.5. Article id never breaks ties.
pub fn normalize(signals: &mut [&mut Signals]) {
for name in PERCENTILE_SIGNALS {
let mut values = signals
.iter()
.filter_map(|signal| signal.raw(name))
.collect::<Vec<_>>();
if values.is_empty() {
continue;
}
values.sort_by(f64::total_cmp);
let n = values.len() as f64;
let constant = values.len() < 2 || values.first() == values.last();
for signal in signals.iter_mut() {
let Some(value) = signal.raw(name) else {
continue;
};
let percentile = if constant {
0.5
} else {
let below = values.partition_point(|other| *other < value);
let equal = values.partition_point(|other| *other <= value) - below;
(below as f64 + (equal as f64 + 1.0) / 2.0) / n
};
signal.norm.insert(name.to_string(), percentile);
}
}
}
/// The preliminary blend of §12.4 on a 0–100 scale: present-and-active
/// signals only, learned weights multiplied by their gate, renormalized to 1,
/// then scaled by the slop-author factor (§9.3).
pub fn preliminary_blend(signals: &mut Signals, configured: &PreliminaryWeights) -> Option<f64> {
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),
];
let active = candidates
.into_iter()
.filter_map(|(name, weight, gate)| {
let norm = *signals.norm.get(name)?;
let effective = weight * gate;
(effective > 0.0).then_some((name, effective, norm))
})
.collect::<Vec<_>>();
let total = active.iter().map(|(_, weight, _)| weight).sum::<f64>();
if total <= 0.0 {
signals.weights.clear();
signals.preliminary = None;
return None;
}
signals.weights = active
.iter()
.map(|(name, weight, _)| ((*name).to_string(), weight / total))
.collect();
let blend = active
.iter()
.map(|(_, weight, norm)| weight / total * norm)
.sum::<f64>()
* 100.0
* signals.slop_factor();
signals.preliminary = Some(blend);
Some(blend)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExtractMethod, SourceRef};
fn ranking() -> RankingConfig {
RankingConfig::default()
}
fn unit(values: &[f32]) -> Vec<f32> {
let norm = values.iter().map(|v| v * v).sum::<f32>().sqrt();
values.iter().map(|v| v / norm).collect()
}
fn example(id: ArticleId, label: &str, value: f64, embedding: &[f32]) -> RatedExample {
RatedExample {
article_id: id,
label: label.into(),
title: format!("rated {id}"),
value,
decay: 1.0,
embedding: unit(embedding),
feeds: vec![id],
author: None,
aggregator_only: false,
}
}
fn article(id: ArticleId, feeds: &[FeedId]) -> Article {
Article {
id,
canonical_url: format!("https://example.com/{id}"),
title: format!("Article {id}"),
best_entry_id: id,
content_html: String::new(),
word_count: 1000,
excerpt_only: false,
image_count: 0,
sources: feeds
.iter()
.map(|feed| SourceRef {
entry_id: id,
feed_id: *feed,
feed_title: format!("feed {feed}"),
category: None,
kind: SourceKind::Feed,
})
.collect(),
first_seen: "2026-08-15T00:00:00Z".parse().unwrap(),
url: format!("https://example.com/{id}"),
author: None,
publication: None,
feed_id: feeds.first().copied().unwrap_or(0),
feed_title: String::new(),
category: None,
published_at: None,
comments_url: None,
image_urls: vec![],
social: vec![],
extract_method: ExtractMethod::Miniflux,
}
}
fn with_heuristic(value: Option<f64>) -> Signals {
Signals {
heuristic: value,
..Signals::default()
}
}
// --- §9.1 interest z-scores ---
fn interest_fixture(n: usize) -> (HashMap<ArticleId, Vec<f32>>, HashMap<String, Vec<f32>>) {
// Article 1 sits on axis x; the rest sit near axis y with a tiny spread.
let mut articles = HashMap::new();
articles.insert(1, unit(&[1.0, 0.0, 0.0]));
for id in 2..=n as ArticleId {
articles.insert(id, unit(&[0.0, 1.0, 0.001 * id as f32]));
}
// "Broad" is about equally close to everything; "Specific" matches only article 1.
let mut interests = HashMap::new();
interests.insert("Broad".to_string(), unit(&[1.0, 1.0, 0.0]));
interests.insert("Specific".to_string(), unit(&[1.0, 0.0, 0.0]));
(articles, interests)
}
#[test]
fn specific_interest_with_one_strong_match_beats_a_broad_one() {
let (articles, interests) = interest_fixture(40);
let matched = interest_matches(&articles, &interests);
let strong = &matched[&1];
assert_eq!(strong.top_interests[0].name, "Specific");
assert!(
strong.top_interests[0].z > 3.0,
"z = {}",
strong.top_interests[0].z
);
let others = (2..=40)
.map(|id| matched[&id].score)
.fold(f64::NEG_INFINITY, f64::max);
assert!(strong.score > others + 2.0, "{} vs {others}", strong.score);
// Raw cosine would have called Broad a near-tie everywhere (≈0.707).
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);
let matched = interest_matches(&articles, &interests);
for (id, m) in &matched {
assert!(
(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);
}
// --- §9.2 preference ---
#[test]
fn one_loved_article_gives_a_positive_knn_to_a_near_neighbour() {
let mut ranking = ranking();
ranking.knn_floor = 0;
ranking.knn_full = 1;
let state = PreferenceState::build(vec![example(1, "loved", 1.0, &[1.0, 0.0])], &ranking);
let (knn, neighbours) = state.knn(&unit(&[0.9, 0.1]), &ranking);
assert!(knn.unwrap() > 0.9);
assert_eq!(neighbours.len(), 1);
assert_eq!(neighbours[0].label, "loved");
let (far, _) = state.knn(&unit(&[0.0, 1.0]), &ranking);
assert!(far.unwrap().abs() < 1e-6);
}
#[test]
fn two_unrelated_loved_clusters_both_score_high() {
let mut ranking = ranking();
ranking.knn_floor = 0;
ranking.knn_full = 1;
ranking.neighbour_k = 2;
let state = PreferenceState::build(
vec![
example(1, "loved", 1.0, &[1.0, 0.0, 0.0]),
example(2, "loved", 1.0, &[0.98, 0.02, 0.0]),
example(3, "loved", 1.0, &[0.0, 1.0, 0.0]),
example(4, "loved", 1.0, &[0.0, 0.98, 0.02]),
],
&ranking,
);
let (near_a, _) = state.knn(&unit(&[1.0, 0.0, 0.0]), &ranking);
let (near_b, _) = state.knn(&unit(&[0.0, 1.0, 0.0]), &ranking);
assert!(near_a.unwrap() > 0.95, "{near_a:?}");
assert!(near_b.unwrap() > 0.95, "{near_b:?}");
// A centroid would have put both at ~0.7.
}
#[test]
fn good_carries_a_third_of_loved() {
let loved = example(1, "loved", 1.0, &[1.0, 0.0]);
let good = example(2, "good", 0.35, &[1.0, 0.0]);
assert!((good.weight() / loved.weight() - 0.35).abs() < 1e-9);
// A mixed neighbourhood: the far example pulls the mean down by 0.35× as
// much weight when it is merely "good" as when it is "loved".
let mut ranking = ranking();
ranking.knn_floor = 0;
ranking.knn_full = 1;
let near = example(1, "loved", 1.0, &[1.0, 0.0]);
let candidate = unit(&[1.0, 0.0]);
let both_loved = PreferenceState::build(
vec![near.clone(), example(2, "loved", 1.0, &[0.0, 1.0])],
&ranking,
);
let one_good =
PreferenceState::build(vec![near, example(2, "good", 0.35, &[0.0, 1.0])], &ranking);
let pull_loved = 1.0 - both_loved.knn(&candidate, &ranking).0.unwrap();
let pull_good = 1.0 - one_good.knn(&candidate, &ranking).0.unwrap();
assert!(pull_good < pull_loved);
// Weighted means: 0.5 vs 1/1.35 → pulls 0.5 vs 0.35/1.35.
assert!((pull_good / pull_loved - 0.35 / 1.35 / 0.5).abs() < 1e-9);
}
#[test]
fn negatives_subtract_with_the_negative_coefficient() {
let mut ranking = ranking();
ranking.knn_floor = 0;
ranking.knn_full = 1;
let state =
PreferenceState::build(vec![example(1, "not_for_me", -1.0, &[1.0, 0.0])], &ranking);
let (knn, _) = state.knn(&unit(&[1.0, 0.0]), &ranking);
assert!((knn.unwrap() + ranking.negative_coefficient).abs() < 1e-9);
}
#[test]
fn decay_halves_at_the_half_life() {
assert!((decay(60.0, 60.0) - 0.5).abs() < 1e-12);
assert!((decay(0.0, 60.0) - 1.0).abs() < 1e-12);
assert!((decay(120.0, 60.0) - 0.25).abs() < 1e-12);
}
#[test]
fn gate_is_zero_below_floor_one_at_full_and_linear_between() {
assert_eq!(gate(0, 8, 25), 0.0);
assert_eq!(gate(8, 8, 25), 0.0);
assert_eq!(gate(25, 8, 25), 1.0);
assert_eq!(gate(100, 8, 25), 1.0);
assert!((gate(16, 8, 24) - 0.5).abs() < 1e-9);
assert!((gate(9, 8, 25) - 1.0 / 17.0).abs() < 1e-9);
}
#[test]
fn knn_is_absent_when_the_gate_is_closed() {
let ranking = ranking(); // knn_floor 8
let state = PreferenceState::build(vec![example(1, "loved", 1.0, &[1.0, 0.0])], &ranking);
assert_eq!(state.knn_gate, 0.0);
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]
fn feed_credit_sums_to_one_across_direct_feeds() {
let mut rated = example(1, "loved", 1.0, &[1.0, 0.0]);
rated.feeds = vec![10, 20, 30];
let state = PreferenceState::build(vec![rated], &ranking());
let total: f64 = [10, 20, 30]
.iter()
.map(|feed| state.feed_credit(*feed).unwrap().0)
.sum();
assert!((total - 1.0).abs() < 1e-9);
assert!((state.feed_credit(10).unwrap().0 - 1.0 / 3.0).abs() < 1e-9);
}
#[test]
fn feed_affinity_is_the_mean_over_rated_feeds() {
let mut ranking = ranking();
ranking.feed_floor = 0;
ranking.feed_full = 1;
let mut loved = example(1, "loved", 1.0, &[1.0, 0.0]);
loved.feeds = vec![10];
let mut down = example(2, "not_for_me", -1.0, &[1.0, 0.0]);
down.feeds = vec![20];
let state = PreferenceState::build(vec![loved, down], &ranking);
// feed 10: (1+1)/(1+0+2) = 2/3; feed 20: (0+1)/(0+1+2) = 1/3; unrated 99 ignored.
let both = state.feed(&article(7, &[10, 20, 99])).unwrap();
assert!((both - 0.5).abs() < 1e-9, "{both}");
let best = state.feed(&article(8, &[10])).unwrap();
assert!((best - 2.0 / 3.0).abs() < 1e-9);
assert_eq!(state.feed(&article(9, &[99])), None);
}
#[test]
fn aggregator_only_rating_splits_credit_between_feed_and_author() {
let mut rated = example(1, "loved", 1.0, &[1.0, 0.0]);
rated.decay = 0.4;
rated.feeds = vec![10];
rated.author = Some("example author".into());
rated.aggregator_only = true;
let state = PreferenceState::build(vec![rated], &ranking());
assert!((state.feed_credit(10).unwrap().0 - 0.25 * 0.4).abs() < 1e-9);
assert!((state.author_credit("example author").unwrap().0 - 0.4).abs() < 1e-9);
}
#[test]
fn author_affinity_applies_across_feeds_with_normalized_keys() {
let mut ranking = ranking();
ranking.feed_floor = 0;
ranking.feed_full = 1;
let mut rated = example(1, "loved", 1.0, &[1.0, 0.0]);
rated.feeds = vec![10];
rated.author = Some("Ada Lovelace".into());
let state = PreferenceState::build(vec![rated], &ranking);
let mut candidate = article(2, &[99]);
candidate.author = Some(" ADA lovelace ".into());
assert_eq!(state.examples[0].author.as_deref(), Some("ada lovelace"));
assert_eq!(state.feed_credit(99), None);
assert!((state.feed(&candidate).unwrap() - 2.0 / 3.0).abs() < 1e-9);
assert_eq!(state.author_credit(" ADA Lovelace "), Some((1.0, 0.0)));
}
#[test]
fn feed_affinity_means_rated_feed_and_rated_author() {
let mut ranking = ranking();
ranking.feed_floor = 0;
ranking.feed_full = 1;
let mut feed_loved = example(1, "loved", 1.0, &[1.0, 0.0]);
feed_loved.feeds = vec![10];
let mut author_down = example(2, "not_for_me", -1.0, &[1.0, 0.0]);
author_down.feeds.clear();
author_down.author = Some("writer".into());
let state = PreferenceState::build(vec![feed_loved, author_down], &ranking);
let mut candidate = article(3, &[10]);
candidate.author = Some("Writer".into());
assert!((state.feed(&candidate).unwrap() - 0.5).abs() < 1e-9);
}
#[test]
fn aggregator_only_without_author_keeps_feed_behavior_at_reduced_credit() {
let mut ranking = ranking();
ranking.feed_floor = 0;
ranking.feed_full = 1;
let mut rated = example(1, "not_for_me", -1.0, &[1.0, 0.0]);
rated.feeds = vec![10];
rated.aggregator_only = true;
let state = PreferenceState::build(vec![rated], &ranking);
assert_eq!(state.feed_credit(10), Some((0.0, 0.25)));
assert_eq!(state.author_credit(""), None);
assert!((state.feed(&article(2, &[10])).unwrap() - 1.0 / 2.25).abs() < 1e-9);
}
// --- §9.3 slop authors ---
#[test]
fn slop_authors_are_normalized_and_scale_the_preliminary_blend() {
let state = PreferenceState::build(Vec::new(), &ranking()).with_slop_authors([
" Content FARM ",
"",
" ",
]);
assert_eq!(state.slop_author_count(), 1);
let mut reported = article(1, &[10]);
reported.author = Some("content farm".into());
let mut other = article(2, &[10]);
other.author = Some("real writer".into());
let anonymous = article(3, &[10]);
assert!(state.is_slop_author(&reported));
assert!(!state.is_slop_author(&other));
assert!(!state.is_slop_author(&anonymous));
let articles = vec![reported, other, anonymous];
let computed = compute(
&articles,
&HashMap::new(),
&HashMap::new(),
&state,
&ranking(),
);
let reported = &computed[&1];
let other = &computed[&2];
assert!(reported.slop_author);
assert!(!other.slop_author);
// Identical heuristic/social inputs: the only difference is the factor.
assert!((reported.preliminary.unwrap() - other.preliminary.unwrap() * 0.25).abs() < 1e-9);
assert!(
reported
.notes
.iter()
.any(|note| note.contains("author reported as AI slop"))
);
assert!(!other.notes.iter().any(|note| note.contains("AI slop")));
}
#[test]
fn slop_factor_is_neutral_without_a_report_and_clamped_with_one() {
let mut signals = Signals {
slop_penalty: 0.75,
..Signals::default()
};
assert_eq!(signals.slop_factor(), 1.0);
signals.slop_author = true;
assert!((signals.slop_factor() - 0.25).abs() < 1e-9);
signals.slop_penalty = 1.0;
assert_eq!(signals.slop_factor(), 0.0);
signals.slop_penalty = 0.0;
assert_eq!(signals.slop_factor(), 1.0);
}
#[test]
fn feed_is_absent_when_the_gate_is_closed() {
let ranking = ranking(); // feed_floor 15
let mut loved = example(1, "loved", 1.0, &[1.0, 0.0]);
loved.feeds = vec![10];
let state = PreferenceState::build(vec![loved], &ranking);
assert_eq!(state.feed_gate, 0.0);
assert_eq!(state.feed(&article(7, &[10])), None);
}
#[test]
fn direct_feeds_fall_back_to_the_best_entry_feed() {
let mut a = article(1, &[]);
a.feed_id = 42;
assert_eq!(direct_feeds(&a), vec![42]);
assert_eq!(direct_feeds(&article(2, &[5, 3, 5])), vec![3, 5]);
}
// --- §12.2 normalization, §12.4 blend ---
#[test]
fn constant_signal_normalizes_to_half_for_everyone() {
let mut values = [with_heuristic(Some(7.0)), with_heuristic(Some(7.0))];
let mut refs = values.iter_mut().collect::<Vec<_>>();
normalize(&mut refs);
assert!(values.iter().all(|v| v.norm["heuristic"] == 0.5));
let mut single = [with_heuristic(Some(3.0))];
let mut refs = single.iter_mut().collect::<Vec<_>>();
normalize(&mut refs);
assert_eq!(single[0].norm["heuristic"], 0.5);
}
#[test]
fn ties_get_equal_percentiles_without_an_id_ramp() {
let mut values = (0..400)
.map(|_| with_heuristic(Some(0.0)))
.collect::<Vec<_>>();
let mut refs = values.iter_mut().collect::<Vec<_>>();
normalize(&mut refs);
assert!(values.iter().all(|v| v.norm["heuristic"] == 0.5));
let mut mixed = [
with_heuristic(Some(1.0)),
with_heuristic(Some(2.0)),
with_heuristic(Some(2.0)),
with_heuristic(Some(3.0)),
];
let mut refs = mixed.iter_mut().collect::<Vec<_>>();
normalize(&mut refs);
assert_eq!(mixed[0].norm["heuristic"], 0.25);
assert_eq!(mixed[1].norm["heuristic"], 0.625);
assert_eq!(mixed[2].norm["heuristic"], 0.625);
assert_eq!(mixed[3].norm["heuristic"], 1.0);
}
#[test]
fn absent_values_do_not_shift_present_values() {
let mut values = [
with_heuristic(Some(1.0)),
with_heuristic(Some(2.0)),
with_heuristic(None),
];
let mut refs = values.iter_mut().collect::<Vec<_>>();
normalize(&mut refs);
assert!(!values[2].norm.contains_key("heuristic"));
// n_present = 2: the absent third value does not widen the scale.
assert_eq!(values[0].norm["heuristic"], 0.5);
assert_eq!(values[1].norm["heuristic"], 1.0);
}
#[test]
fn effective_weights_sum_to_one_and_missing_signals_are_skipped() {
let mut signals = Signals {
interest: Some(1.0),
heuristic: Some(2.0),
norm: BTreeMap::from([("interest".into(), 0.8), ("heuristic".into(), 0.4)]),
..Signals::default()
};
let blend = preliminary_blend(&mut signals, &PreliminaryWeights::default()).unwrap();
assert!((signals.weights.values().sum::<f64>() - 1.0).abs() < 1e-9);
assert!(!signals.weights.contains_key("knn"));
assert!(!signals.weights.contains_key("social"));
// 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),
norm: BTreeMap::from([("heuristic".into(), 0.4)]),
..Signals::default()
};
let blend = preliminary_blend(&mut only_heuristic, &PreliminaryWeights::default());
assert!((blend.unwrap() - 40.0).abs() < 1e-9);
assert_eq!(only_heuristic.weights["heuristic"], 1.0);
}
#[test]
fn learned_weights_are_multiplied_by_their_gate() {
let mut signals = Signals {
knn: Some(0.5),
heuristic: Some(2.0),
knn_gate: 0.5,
norm: BTreeMap::from([("knn".into(), 1.0), ("heuristic".into(), 0.0)]),
..Signals::default()
};
preliminary_blend(&mut signals, &PreliminaryWeights::default());
// knn 0.25 × 0.5 = 0.125 against heuristic 0.20.
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])];
let mut embeddings = HashMap::new();
embeddings.insert(1, unit(&[1.0, 0.0]));
embeddings.insert(2, unit(&[0.0, 1.0]));
let mut interests = HashMap::new();
interests.insert("Axis".to_string(), unit(&[1.0, 0.0]));
let state = PreferenceState::build(vec![example(9, "loved", 1.0, &[1.0, 0.0])], &ranking());
let signals = compute(&articles, &embeddings, &interests, &state, &ranking());
assert_eq!(signals.len(), 3);
assert!(signals[&1].interest.is_some());
assert!(signals[&3].interest.is_none(), "no embedding → absent");
assert!(
signals
.values()
.all(|s| s.knn.is_none() && s.feed.is_none())
);
assert!(
signals
.values()
.all(|s| s.heuristic.is_some() && s.preliminary.is_some())
);
}
}