Curation v2 step 3: Voyage embeddings, cheap signals, candidate telemetry
- embedding.rs: EmbeddingBackend + VoyageBackend, batched bounded-concurrency client with its own UsageMeter, f32 BLOB codec, article/interest embedding cache keyed by model, dimension and sha256 of the embedded text. - signals.rs: z-scored interest match, decayed rated-neighbour preference with the knn gate, feed affinity with the feed gate, social, text heuristic without social terms, mid-rank percentile normalizer, preliminary blend. - telemetry.rs: candidate_runs writer with §7.5 signals_json, explain and near-misses renderers, prune. - [voyage] and the full [curation.ranking] config with validation. - CLI: explain, features backfill|prune, generate --skip-embeddings. - Pipeline: hygiene rows, embed and signals stages before the old prefilter; same-date regeneration no longer excludes its own picks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,14 @@
|
||||
//! feed excerpts stand in for summaries (notes §6).
|
||||
|
||||
pub mod editorial;
|
||||
pub mod embedding;
|
||||
pub mod llm;
|
||||
pub mod prefilter;
|
||||
pub mod profile;
|
||||
pub mod score;
|
||||
pub mod select;
|
||||
pub mod signals;
|
||||
pub mod telemetry;
|
||||
|
||||
use jiff::civil::Date;
|
||||
|
||||
|
||||
+24
-1
@@ -90,7 +90,7 @@ impl PrefilterContext {
|
||||
let since = today
|
||||
.checked_sub(jiff::Span::new().days(STALE_LOOKBACK_DAYS))
|
||||
.unwrap_or(today);
|
||||
let already_published = db.previously_published_ids().await?;
|
||||
let already_published = db.previously_published_ids_before(today).await?;
|
||||
let recently_rejected = db.recently_low_scored_ids(STALE_LOW_SCORE, since).await?;
|
||||
tracing::debug!(
|
||||
published = already_published.len(),
|
||||
@@ -197,6 +197,29 @@ pub fn social_points(social_score: f64) -> f64 {
|
||||
MAX_SOCIAL_POINTS * (social_score / SOCIAL_SATURATION).min(1.0).sqrt()
|
||||
}
|
||||
|
||||
/// Text-only heuristic used by personalized ranking (§9.3).
|
||||
pub fn text_heuristic(article: &Article) -> f64 {
|
||||
longform_points(article.word_count)
|
||||
- excerpt_only_penalty(article)
|
||||
- roundup_penalty(&article.title)
|
||||
}
|
||||
|
||||
pub fn excerpt_only_penalty(article: &Article) -> f64 {
|
||||
if article.excerpt_only {
|
||||
EXCERPT_ONLY_PENALTY
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn roundup_penalty(title: &str) -> f64 {
|
||||
if looks_like_roundup(title) {
|
||||
ROUNDUP_TITLE_PENALTY
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Score one article 0–100 from word count, social proof, source signals,
|
||||
/// and the excerpt/roundup/blocklist penalties (§3.5).
|
||||
pub fn score_article(article: &Article, _ctx: &PrefilterContext, cfg: &Config) -> f64 {
|
||||
|
||||
@@ -125,6 +125,16 @@ pub fn load_profile(path: &Path) -> anyhow::Result<ProfileFile> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the exact standing-interest union used in the system prompt.
|
||||
pub fn load_standing_interests(
|
||||
opml_path: &Path,
|
||||
profile_path: &Path,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
let opml = parse_interests(opml_path)?;
|
||||
let profile = load_profile(profile_path)?;
|
||||
Ok(union_interests(opml, profile.interests))
|
||||
}
|
||||
|
||||
fn union_interests(opml: Vec<String>, profile: Vec<String>) -> Vec<String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -0,0 +1,981 @@
|
||||
//! 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::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;
|
||||
/// 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;
|
||||
|
||||
/// 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"];
|
||||
|
||||
#[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 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>,
|
||||
/// Gate ramps applied to the learned signals' weights (§9.2, §9.3).
|
||||
#[serde(skip)]
|
||||
pub knn_gate: f64,
|
||||
#[serde(skip)]
|
||||
pub feed_gate: f64,
|
||||
}
|
||||
|
||||
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,
|
||||
"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 knn_gate: f64,
|
||||
pub feed_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>,
|
||||
}
|
||||
|
||||
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>,
|
||||
pub attributable_feed_ratings: usize,
|
||||
pub knn_gate: f64,
|
||||
pub feed_gate: f64,
|
||||
}
|
||||
|
||||
impl PreferenceState {
|
||||
/// Build the state from already-loaded examples (pure; tests use this).
|
||||
pub fn build(examples: Vec<RatedExample>, ranking: &RankingConfig) -> Self {
|
||||
let (feed_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,
|
||||
attributable_feed_ratings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load `db::current_ratings(rating_lookback_days)` joined to
|
||||
/// `article_embeddings`; ratings without an embedding are skipped (§9.2).
|
||||
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 feeds = db
|
||||
.get_article(rating.article_id)
|
||||
.await?
|
||||
.as_ref()
|
||||
.map(direct_feeds)
|
||||
.unwrap_or_default();
|
||||
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,
|
||||
value: rating.value,
|
||||
decay: decay(age_days, ranking.rating_half_life_days),
|
||||
embedding,
|
||||
feeds,
|
||||
});
|
||||
}
|
||||
Ok(Self::build(examples, ranking))
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> PreferenceSummary {
|
||||
PreferenceSummary {
|
||||
rated_with_embeddings: self.examples.len(),
|
||||
attributable_feed_ratings: self.attributable_feed_ratings,
|
||||
knn_gate: self.knn_gate,
|
||||
feed_gate: self.feed_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
|
||||
)
|
||||
};
|
||||
tracing::info!(
|
||||
rated_with_embeddings = self.examples.len(),
|
||||
knn_gate = self.knn_gate,
|
||||
feed_gate = self.feed_gate,
|
||||
"preference: {} rated articles with embeddings → knn gate {:.2}; feed gate {:.1} {}",
|
||||
self.examples.len(),
|
||||
self.knn_gate,
|
||||
self.feed_gate,
|
||||
feed_detail
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 (§9.3).
|
||||
pub fn feed(&self, article: &Article) -> Option<f64> {
|
||||
if self.feed_gate <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let rates = direct_feeds(article)
|
||||
.into_iter()
|
||||
.filter_map(|feed| self.feed_rates.get(&feed))
|
||||
.map(|rate| rate.rate())
|
||||
.collect::<Vec<_>>();
|
||||
(!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))
|
||||
}
|
||||
}
|
||||
|
||||
/// `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 feed_rates(examples: &[RatedExample]) -> (HashMap<FeedId, FeedRate>, usize) {
|
||||
let mut rates: HashMap<FeedId, FeedRate> = HashMap::new();
|
||||
let mut attributable = 0;
|
||||
for example in examples {
|
||||
if example.feeds.is_empty() {
|
||||
continue;
|
||||
}
|
||||
attributable += 1;
|
||||
let credit = example.weight() / example.feeds.len() as f64;
|
||||
for feed in &example.feeds {
|
||||
let rate = rates.entry(*feed).or_default();
|
||||
rate.up += credit.max(0.0);
|
||||
rate.down += (-credit).max(0.0);
|
||||
}
|
||||
}
|
||||
(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
|
||||
};
|
||||
(
|
||||
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;
|
||||
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();
|
||||
}
|
||||
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.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.
|
||||
pub fn preliminary_blend(signals: &mut Signals, configured: &PreliminaryWeights) -> Option<f64> {
|
||||
let candidates = [
|
||||
("interest", configured.interest, 1.0),
|
||||
("knn", configured.knn, signals.knn_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.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],
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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 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!((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()));
|
||||
}
|
||||
|
||||
// --- §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 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.35/0.55 × 0.8 + 0.20/0.55 × 0.4 = 0.6545…
|
||||
assert!((blend - 65.4545).abs() < 0.01, "{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 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
//! Per-run candidate telemetry: the `candidate_runs` writer, `signals_json`,
|
||||
//! the `explain` command and feature retention (plan §7.4–7.5, §15.2, §16).
|
||||
//!
|
||||
//! One row per considered article per run says where it stopped and why. Rows
|
||||
//! are upserted on every stage transition with every column set (never
|
||||
//! `COALESCE`), so the last write for a run is the whole truth.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use jiff::Timestamp;
|
||||
use jiff::civil::Date;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row as _;
|
||||
|
||||
use crate::curate::signals::{Neighbour, Signals, TopInterest};
|
||||
use crate::db::{Db, fmt_ts};
|
||||
use crate::types::ArticleId;
|
||||
|
||||
/// The stage vocabulary of §7.4, in pipeline order.
|
||||
pub const STAGES: [&str; 7] = [
|
||||
"excluded",
|
||||
"eligible",
|
||||
"triaged",
|
||||
"admitted",
|
||||
"assessed",
|
||||
"shortlisted",
|
||||
"selected",
|
||||
];
|
||||
|
||||
/// Signal names rendered by `explain`, including the LLM ones steps 4–5 add.
|
||||
const RENDERED_SIGNALS: [&str; 8] = [
|
||||
"interest",
|
||||
"knn",
|
||||
"feed",
|
||||
"social",
|
||||
"heuristic",
|
||||
"triage",
|
||||
"quality",
|
||||
"fit",
|
||||
];
|
||||
|
||||
/// One `candidate_runs` row (§7.4).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CandidateRun<'a> {
|
||||
pub run_id: i64,
|
||||
pub article_id: ArticleId,
|
||||
pub stage: &'a str,
|
||||
pub excluded_reason: Option<&'a str>,
|
||||
/// JSON array of retriever names, first = the one that admitted it.
|
||||
pub admitted_by: Option<&'a str>,
|
||||
pub signals_json: &'a str,
|
||||
pub utility: Option<f64>,
|
||||
pub rank_utility: Option<i64>,
|
||||
pub cluster_id: Option<i64>,
|
||||
pub cluster_rank: Option<i64>,
|
||||
pub editor_why: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Upsert one row, setting every column (§7.4).
|
||||
pub async fn write(db: &Db, row: &CandidateRun<'_>) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO candidate_runs
|
||||
(run_id, article_id, stage, excluded_reason, admitted_by, signals_json,
|
||||
utility, rank_utility, cluster_id, cluster_rank, editor_why)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id, article_id) DO UPDATE SET
|
||||
stage = excluded.stage,
|
||||
excluded_reason = excluded.excluded_reason,
|
||||
admitted_by = excluded.admitted_by,
|
||||
signals_json = excluded.signals_json,
|
||||
utility = excluded.utility,
|
||||
rank_utility = excluded.rank_utility,
|
||||
cluster_id = excluded.cluster_id,
|
||||
cluster_rank = excluded.cluster_rank,
|
||||
editor_why = excluded.editor_why",
|
||||
)
|
||||
.bind(row.run_id)
|
||||
.bind(row.article_id)
|
||||
.bind(row.stage)
|
||||
.bind(row.excluded_reason)
|
||||
.bind(row.admitted_by)
|
||||
.bind(row.signals_json)
|
||||
.bind(row.utility)
|
||||
.bind(row.rank_utility)
|
||||
.bind(row.cluster_id)
|
||||
.bind(row.cluster_rank)
|
||||
.bind(row.editor_why)
|
||||
.execute(db.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The thin row of a hygiene exclusion: keys, `stage = 'excluded'`, the reason
|
||||
/// and `signals_json = '{}'` (§8.1).
|
||||
pub async fn thin_excluded(
|
||||
db: &Db,
|
||||
run_id: i64,
|
||||
article_id: ArticleId,
|
||||
reason: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
write(
|
||||
db,
|
||||
&CandidateRun {
|
||||
run_id,
|
||||
article_id,
|
||||
stage: "excluded",
|
||||
excluded_reason: Some(reason),
|
||||
admitted_by: None,
|
||||
signals_json: "{}",
|
||||
utility: None,
|
||||
rank_utility: None,
|
||||
cluster_id: None,
|
||||
cluster_rank: None,
|
||||
editor_why: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `signals_json` (§7.5). Missing signals are absent from `raw`/`norm` and
|
||||
/// `false` in `present`; `weights` are the effective weights.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SignalsJson {
|
||||
pub v: i64,
|
||||
#[serde(default)]
|
||||
pub raw: BTreeMap<String, f64>,
|
||||
#[serde(default)]
|
||||
pub norm: BTreeMap<String, f64>,
|
||||
#[serde(default)]
|
||||
pub present: BTreeMap<String, bool>,
|
||||
#[serde(default)]
|
||||
pub weights: BTreeMap<String, f64>,
|
||||
#[serde(default)]
|
||||
pub top_interests: Vec<TopInterest>,
|
||||
#[serde(default)]
|
||||
pub neighbours: Vec<Neighbour>,
|
||||
#[serde(default)]
|
||||
pub exploration: bool,
|
||||
#[serde(default)]
|
||||
pub auto_include: bool,
|
||||
#[serde(default)]
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl SignalsJson {
|
||||
/// The blend implied by the stored effective weights, 0–100.
|
||||
pub fn blend(&self) -> Option<f64> {
|
||||
let mut score = 0.0;
|
||||
let mut any = false;
|
||||
for (name, weight) in &self.weights {
|
||||
if let Some(value) = self.norm.get(name) {
|
||||
score += weight * value;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
any.then_some(score * 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the signals of §7.5 for one article.
|
||||
pub fn serialize_signals(signals: &Signals, auto_include: bool) -> String {
|
||||
let mut raw = BTreeMap::new();
|
||||
for name in [
|
||||
"interest",
|
||||
"interest_top1_cos",
|
||||
"knn",
|
||||
"feed",
|
||||
"social",
|
||||
"heuristic",
|
||||
] {
|
||||
if let Some(value) = signals.raw(name) {
|
||||
raw.insert(name.to_string(), value);
|
||||
}
|
||||
}
|
||||
let present = RENDERED_SIGNALS
|
||||
.into_iter()
|
||||
.map(|name| (name.to_string(), signals.present(name)))
|
||||
.collect();
|
||||
serde_json::to_string(&SignalsJson {
|
||||
v: 1,
|
||||
raw,
|
||||
norm: signals.norm.clone(),
|
||||
present,
|
||||
weights: signals.weights.clone(),
|
||||
top_interests: signals.top_interests.clone(),
|
||||
neighbours: signals.neighbours.clone(),
|
||||
exploration: false,
|
||||
auto_include,
|
||||
notes: signals.notes.clone(),
|
||||
})
|
||||
.unwrap_or_else(|_| "{}".into())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `explain` (§15.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A `candidate_runs` row joined to its article title.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExplainRow {
|
||||
pub run_id: i64,
|
||||
pub article_id: ArticleId,
|
||||
pub title: String,
|
||||
pub stage: String,
|
||||
pub excluded_reason: Option<String>,
|
||||
pub admitted_by: Option<String>,
|
||||
pub signals_json: String,
|
||||
pub utility: Option<f64>,
|
||||
pub rank_utility: Option<i64>,
|
||||
pub cluster_id: Option<i64>,
|
||||
pub cluster_rank: Option<i64>,
|
||||
pub editor_why: Option<String>,
|
||||
}
|
||||
|
||||
impl ExplainRow {
|
||||
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Self {
|
||||
Self {
|
||||
run_id: row.get("run_id"),
|
||||
article_id: row.get("article_id"),
|
||||
title: row.get("title"),
|
||||
stage: row.get("stage"),
|
||||
excluded_reason: row.get("excluded_reason"),
|
||||
admitted_by: row.get("admitted_by"),
|
||||
signals_json: row.get("signals_json"),
|
||||
utility: row.get("utility"),
|
||||
rank_utility: row.get("rank_utility"),
|
||||
cluster_id: row.get("cluster_id"),
|
||||
cluster_rank: row.get("cluster_rank"),
|
||||
editor_why: row.get("editor_why"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn signals(&self) -> Option<SignalsJson> {
|
||||
serde_json::from_str(&self.signals_json).ok()
|
||||
}
|
||||
|
||||
/// Utility when step 5 has written it, else the preliminary blend.
|
||||
pub fn score(&self) -> Option<f64> {
|
||||
self.utility
|
||||
.or_else(|| self.signals().and_then(|signals| signals.blend()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The run `explain` reads: `--run-id` when given (and of that date), else the
|
||||
/// latest non-dry run of the date.
|
||||
pub async fn resolve_run(
|
||||
db: &Db,
|
||||
date: Date,
|
||||
requested: Option<i64>,
|
||||
) -> Result<Option<i64>, sqlx::Error> {
|
||||
let row = match requested {
|
||||
Some(run_id) => {
|
||||
sqlx::query("SELECT id FROM runs WHERE id = ? AND date = ?")
|
||||
.bind(run_id)
|
||||
.bind(date.to_string())
|
||||
.fetch_optional(db.pool())
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query(
|
||||
"SELECT id FROM runs WHERE date = ? AND status != 'dry_run'
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(date.to_string())
|
||||
.fetch_optional(db.pool())
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(row.map(|row| row.get("id")))
|
||||
}
|
||||
|
||||
pub async fn explain_row(
|
||||
db: &Db,
|
||||
run_id: i64,
|
||||
article_id: ArticleId,
|
||||
) -> Result<Option<ExplainRow>, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
||||
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
||||
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
|
||||
FROM candidate_runs cr JOIN articles a ON a.id = cr.article_id
|
||||
WHERE cr.run_id = ? AND cr.article_id = ?",
|
||||
)
|
||||
.bind(run_id)
|
||||
.bind(article_id)
|
||||
.fetch_optional(db.pool())
|
||||
.await?;
|
||||
Ok(row.as_ref().map(ExplainRow::from_row))
|
||||
}
|
||||
|
||||
/// The top `limit` rows by utility-or-blend that were not selected (§15.2).
|
||||
pub async fn near_misses(
|
||||
db: &Db,
|
||||
run_id: i64,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ExplainRow>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
||||
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
||||
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
|
||||
FROM candidate_runs cr JOIN articles a ON a.id = cr.article_id
|
||||
WHERE cr.run_id = ? AND cr.stage != 'selected' AND cr.stage != 'excluded'",
|
||||
)
|
||||
.bind(run_id)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
let mut output = rows.iter().map(ExplainRow::from_row).collect::<Vec<_>>();
|
||||
output.sort_by(|left, right| {
|
||||
right
|
||||
.score()
|
||||
.unwrap_or(f64::NEG_INFINITY)
|
||||
.total_cmp(&left.score().unwrap_or(f64::NEG_INFINITY))
|
||||
.then_with(|| left.article_id.cmp(&right.article_id))
|
||||
});
|
||||
output.truncate(limit);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn fmt_opt(value: Option<f64>) -> String {
|
||||
value
|
||||
.map(|v| format!("{v:.3}"))
|
||||
.unwrap_or_else(|| "—".into())
|
||||
}
|
||||
|
||||
/// Render one persisted row the way §15.2 lists it.
|
||||
pub async fn render_explain(db: &Db, row: &ExplainRow) -> Result<String, sqlx::Error> {
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(out, "article {}: {}", row.article_id, row.title);
|
||||
let _ = write!(out, "run {} · stage: {}", row.run_id, row.stage);
|
||||
if let Some(reason) = &row.excluded_reason {
|
||||
let _ = write!(out, " · reason: {reason}");
|
||||
}
|
||||
let _ = writeln!(out);
|
||||
if let Some(signals) = row.signals() {
|
||||
let _ = writeln!(out, "signals (raw · norm · weight):");
|
||||
for name in RENDERED_SIGNALS {
|
||||
let present = signals.present.get(name).copied().unwrap_or(false);
|
||||
if present {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {name:<10} {:>8} · {:>6} · {:>6}",
|
||||
fmt_opt(signals.raw.get(name).copied()),
|
||||
fmt_opt(signals.norm.get(name).copied()),
|
||||
fmt_opt(signals.weights.get(name).copied()),
|
||||
);
|
||||
} else {
|
||||
let _ = writeln!(out, " {name:<10} absent");
|
||||
}
|
||||
}
|
||||
if let Some(blend) = signals.blend() {
|
||||
let _ = writeln!(out, "preliminary blend: {blend:.1}");
|
||||
}
|
||||
if let Some(cos) = signals.raw.get("interest_top1_cos") {
|
||||
let _ = writeln!(out, "interest top-1 cosine: {cos:.3}");
|
||||
}
|
||||
if !signals.top_interests.is_empty() {
|
||||
let _ = writeln!(out, "top interests:");
|
||||
for interest in &signals.top_interests {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} · z {:.2} · cos {:.3}",
|
||||
interest.name, interest.z, interest.cos
|
||||
);
|
||||
}
|
||||
}
|
||||
if !signals.neighbours.is_empty() {
|
||||
let _ = writeln!(out, "nearest rated neighbours:");
|
||||
for neighbour in &signals.neighbours {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} · cos {:.3} · article {} · {}",
|
||||
neighbour.label, neighbour.cos, neighbour.article_id, neighbour.title
|
||||
);
|
||||
}
|
||||
}
|
||||
if signals.exploration || signals.auto_include {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"flags: exploration={} auto_include={}",
|
||||
signals.exploration, signals.auto_include
|
||||
);
|
||||
}
|
||||
for note in &signals.notes {
|
||||
let _ = writeln!(out, "note: {note}");
|
||||
}
|
||||
}
|
||||
|
||||
let assessments = sqlx::query(
|
||||
"SELECT stage, model, score, fit, kind, facets_json, rationale, category,
|
||||
paywalled_guess, assessed_at
|
||||
FROM article_assessments WHERE article_id = ? ORDER BY stage",
|
||||
)
|
||||
.bind(row.article_id)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
if !assessments.is_empty() {
|
||||
let _ = writeln!(out, "assessments:");
|
||||
for assessment in assessments {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} · {} · score {} · fit {} · kind {} · category {} · paywalled={} · {}",
|
||||
assessment.get::<String, _>("stage"),
|
||||
assessment.get::<String, _>("model"),
|
||||
fmt_opt(assessment.get::<Option<f64>, _>("score")),
|
||||
fmt_opt(assessment.get::<Option<f64>, _>("fit")),
|
||||
assessment
|
||||
.get::<Option<String>, _>("kind")
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
assessment
|
||||
.get::<Option<String>, _>("category")
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
assessment.get::<i64, _>("paywalled_guess") != 0,
|
||||
assessment
|
||||
.get::<Option<String>, _>("rationale")
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
if let Some(facets) = assessment.get::<Option<String>, _>("facets_json") {
|
||||
let _ = writeln!(out, " facets: {facets}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if row.utility.is_some() || row.rank_utility.is_some() {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"utility: {} · rank {}",
|
||||
fmt_opt(row.utility),
|
||||
row.rank_utility
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "—".into())
|
||||
);
|
||||
}
|
||||
if let Some(cluster) = row.cluster_id {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"cluster: {cluster} · rank {}",
|
||||
row.cluster_rank
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "—".into())
|
||||
);
|
||||
}
|
||||
if let Some(admitted_by) = &row.admitted_by {
|
||||
let _ = writeln!(out, "admitted by: {admitted_by}");
|
||||
}
|
||||
if let Some(why) = &row.editor_why {
|
||||
let _ = writeln!(out, "editor: {why}");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What `explain` was asked about.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExplainTarget {
|
||||
Article(ArticleId),
|
||||
Url(String),
|
||||
}
|
||||
|
||||
/// `explain --date D (--article ID | --url URL) [--run-id N]` as text (§15.2).
|
||||
pub async fn explain(
|
||||
db: &Db,
|
||||
date: Date,
|
||||
run_id: Option<i64>,
|
||||
target: &ExplainTarget,
|
||||
) -> anyhow::Result<String> {
|
||||
let article_id = match target {
|
||||
ExplainTarget::Article(id) => *id,
|
||||
ExplainTarget::Url(url) => {
|
||||
let canonical = crate::dedupe::canonical_url(url)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid article URL {url:?}"))?;
|
||||
match db.article_id_for_url(&canonical).await? {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Ok(format!(
|
||||
"{canonical} was never ingested: it is not in `articles`, so this is a feed problem, not a ranking problem."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if db.get_article(article_id).await?.is_none() {
|
||||
return Ok(format!(
|
||||
"article {article_id} was never ingested: it is not in `articles`."
|
||||
));
|
||||
}
|
||||
let Some(run_id) = resolve_run(db, date, run_id).await? else {
|
||||
return Ok(match run_id {
|
||||
Some(id) => format!("run {id} is not a run for {date}"),
|
||||
None => format!("no non-dry run recorded for {date}"),
|
||||
});
|
||||
};
|
||||
match explain_row(db, run_id, article_id).await? {
|
||||
Some(row) => Ok(render_explain(db, &row).await?),
|
||||
None => Ok(format!(
|
||||
"article {article_id} was not considered by run {run_id} for {date} (outside its ingest window, or telemetry pruned)."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `explain --date D --near-misses [N]` as text (§15.2).
|
||||
pub async fn explain_near_misses(
|
||||
db: &Db,
|
||||
date: Date,
|
||||
run_id: Option<i64>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<String> {
|
||||
let Some(run_id) = resolve_run(db, date, run_id).await? else {
|
||||
return Ok(format!("no non-dry run recorded for {date}"));
|
||||
};
|
||||
let rows = near_misses(db, run_id, limit).await?;
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"run {run_id} · {date} · top {} not selected, by {}:",
|
||||
rows.len(),
|
||||
if rows.iter().any(|row| row.utility.is_some()) {
|
||||
"utility"
|
||||
} else {
|
||||
"preliminary blend"
|
||||
}
|
||||
);
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
let reason = row
|
||||
.excluded_reason
|
||||
.as_deref()
|
||||
.map(|reason| format!(", {reason}"))
|
||||
.unwrap_or_default();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:>3}. {:>6} · {} · {}{} · article {}",
|
||||
index + 1,
|
||||
row.score()
|
||||
.map(|score| format!("{score:.1}"))
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
row.title,
|
||||
row.stage,
|
||||
reason,
|
||||
row.article_id
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `features prune` (§7.1, §7.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Delete `article_embeddings` for articles neither rated nor published that
|
||||
/// are older than `embedding_retention_days`, and `candidate_runs` rows whose
|
||||
/// run started more than `telemetry_retention_days` ago. Returns the counts.
|
||||
pub async fn prune(
|
||||
db: &Db,
|
||||
embedding_retention_days: i64,
|
||||
telemetry_retention_days: i64,
|
||||
now: Timestamp,
|
||||
) -> Result<(u64, u64), sqlx::Error> {
|
||||
let cutoff = |days: i64| {
|
||||
now.checked_sub(jiff::Span::new().hours(days.max(0).saturating_mul(24)))
|
||||
.unwrap_or(Timestamp::UNIX_EPOCH)
|
||||
};
|
||||
let embeddings = sqlx::query(
|
||||
"DELETE FROM article_embeddings
|
||||
WHERE article_id IN (
|
||||
SELECT ae.article_id
|
||||
FROM article_embeddings ae JOIN articles a ON a.id = ae.article_id
|
||||
WHERE a.first_seen < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM rating_events re WHERE re.article_id = ae.article_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM issue_articles ia WHERE ia.article_id = ae.article_id)
|
||||
)",
|
||||
)
|
||||
.bind(fmt_ts(cutoff(embedding_retention_days)))
|
||||
.execute(db.pool())
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
let telemetry = sqlx::query(
|
||||
"DELETE FROM candidate_runs
|
||||
WHERE run_id IN (SELECT id FROM runs WHERE started_at < ?)",
|
||||
)
|
||||
.bind(fmt_ts(cutoff(telemetry_retention_days)))
|
||||
.execute(db.pool())
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok((embeddings, telemetry))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::curate::embedding::encode_blob;
|
||||
|
||||
async fn db_with_articles(ids: &[ArticleId]) -> (tempfile::TempDir, Db) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Db::open_and_migrate(&dir.path().join("telemetry.db"))
|
||||
.await
|
||||
.unwrap();
|
||||
for id in ids {
|
||||
sqlx::query(
|
||||
"INSERT INTO articles (id, canonical_url, title, first_seen)
|
||||
VALUES (?, ?, ?, '2026-08-15T00:00:00Z')",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(format!("https://example.com/{id}"))
|
||||
.bind(format!("Article {id}"))
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
(dir, db)
|
||||
}
|
||||
|
||||
fn date() -> Date {
|
||||
"2026-09-02".parse().unwrap()
|
||||
}
|
||||
|
||||
fn signals(heuristic: f64, norm: f64) -> Signals {
|
||||
Signals {
|
||||
interest: Some(1.2),
|
||||
interest_top1_cos: Some(0.61),
|
||||
heuristic: Some(heuristic),
|
||||
norm: BTreeMap::from([("heuristic".into(), norm), ("interest".into(), 0.9)]),
|
||||
weights: BTreeMap::from([
|
||||
("heuristic".into(), 0.2 / 0.55),
|
||||
("interest".into(), 0.35 / 0.55),
|
||||
]),
|
||||
top_interests: vec![TopInterest {
|
||||
name: "Gaussian Splatting".into(),
|
||||
z: 3.4,
|
||||
cos: 0.61,
|
||||
}],
|
||||
neighbours: vec![Neighbour {
|
||||
article_id: 812,
|
||||
label: "loved".into(),
|
||||
cos: 0.71,
|
||||
title: "A rated piece".into(),
|
||||
}],
|
||||
notes: vec!["knn gate 0.60 (n=14 rated with embeddings)".into()],
|
||||
..Signals::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signals_json_follows_the_plan_shape() {
|
||||
let json = serialize_signals(&signals(41.0, 0.55), false);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["v"], 1);
|
||||
assert_eq!(parsed["raw"]["heuristic"], 41.0);
|
||||
assert_eq!(parsed["raw"]["interest_top1_cos"], 0.61);
|
||||
assert_eq!(parsed["present"]["heuristic"], true);
|
||||
assert_eq!(parsed["present"]["knn"], false);
|
||||
assert_eq!(parsed["present"]["quality"], false);
|
||||
assert!(parsed["raw"].get("knn").is_none());
|
||||
assert!(parsed["norm"].get("knn").is_none());
|
||||
assert_eq!(parsed["exploration"], false);
|
||||
assert_eq!(parsed["auto_include"], false);
|
||||
assert_eq!(parsed["top_interests"][0]["name"], "Gaussian Splatting");
|
||||
assert_eq!(parsed["neighbours"][0]["article_id"], 812);
|
||||
assert_eq!(
|
||||
parsed["notes"][0],
|
||||
"knn gate 0.60 (n=14 rated with embeddings)"
|
||||
);
|
||||
let typed: SignalsJson = serde_json::from_str(&json).unwrap();
|
||||
let weights: f64 = typed.weights.values().sum();
|
||||
assert!((weights - 1.0).abs() < 1e-9);
|
||||
assert!(typed.blend().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rows_are_upserted_with_every_column_replaced() {
|
||||
let (_dir, db) = db_with_articles(&[1]).await;
|
||||
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||||
write(
|
||||
&db,
|
||||
&CandidateRun {
|
||||
run_id,
|
||||
article_id: 1,
|
||||
stage: "eligible",
|
||||
excluded_reason: Some("not_admitted"),
|
||||
admitted_by: None,
|
||||
signals_json: "{}",
|
||||
utility: Some(1.0),
|
||||
rank_utility: None,
|
||||
cluster_id: None,
|
||||
cluster_rank: None,
|
||||
editor_why: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
write(
|
||||
&db,
|
||||
&CandidateRun {
|
||||
run_id,
|
||||
article_id: 1,
|
||||
stage: "selected",
|
||||
excluded_reason: None,
|
||||
admitted_by: Some("[\"prefilter\"]"),
|
||||
signals_json: "{\"v\":1}",
|
||||
utility: None,
|
||||
rank_utility: None,
|
||||
cluster_id: None,
|
||||
cluster_rank: None,
|
||||
editor_why: Some("because"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let row = explain_row(&db, run_id, 1).await.unwrap().unwrap();
|
||||
assert_eq!(row.stage, "selected");
|
||||
assert_eq!(row.excluded_reason, None, "no COALESCE");
|
||||
assert_eq!(row.utility, None);
|
||||
assert_eq!(row.admitted_by.as_deref(), Some("[\"prefilter\"]"));
|
||||
assert_eq!(row.editor_why.as_deref(), Some("because"));
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM candidate_runs")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explain_renders_persisted_rows_and_reports_never_ingested() {
|
||||
let (_dir, db) = db_with_articles(&[1, 2]).await;
|
||||
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||||
thin_excluded(&db, run_id, 2, "blocked").await.unwrap();
|
||||
let json = serialize_signals(&signals(41.0, 0.55), true);
|
||||
write(
|
||||
&db,
|
||||
&CandidateRun {
|
||||
run_id,
|
||||
article_id: 1,
|
||||
stage: "shortlisted",
|
||||
excluded_reason: Some("not_selected"),
|
||||
admitted_by: Some("[\"prefilter\"]"),
|
||||
signals_json: &json,
|
||||
utility: None,
|
||||
rank_utility: None,
|
||||
cluster_id: None,
|
||||
cluster_rank: None,
|
||||
editor_why: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// A later dry run must not shadow the real one.
|
||||
let dry = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||||
sqlx::query("UPDATE runs SET status = 'dry_run' WHERE id = ?")
|
||||
.bind(dry)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolve_run(&db, date(), None).await.unwrap(), Some(run_id));
|
||||
assert_eq!(
|
||||
resolve_run(&db, date(), Some(dry)).await.unwrap(),
|
||||
Some(dry)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_run(&db, "2026-01-01".parse().unwrap(), Some(dry))
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
let text = explain(&db, date(), None, &ExplainTarget::Article(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(text.contains("article 1: Article 1"), "{text}");
|
||||
assert!(
|
||||
text.contains("stage: shortlisted · reason: not_selected"),
|
||||
"{text}"
|
||||
);
|
||||
let squashed = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(
|
||||
squashed.contains("heuristic 41.000 · 0.550 · 0.364"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(squashed.contains("knn absent"), "{text}");
|
||||
assert!(squashed.contains("quality absent"), "{text}");
|
||||
assert!(
|
||||
text.contains("Gaussian Splatting · z 3.40 · cos 0.610"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("loved · cos 0.710 · article 812 · A rated piece"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("admitted by: [\"prefilter\"]"), "{text}");
|
||||
assert!(text.contains("auto_include=true"), "{text}");
|
||||
assert!(text.contains("preliminary blend:"), "{text}");
|
||||
assert!(text.contains("note: knn gate"), "{text}");
|
||||
|
||||
let by_url = explain(
|
||||
&db,
|
||||
date(),
|
||||
None,
|
||||
&ExplainTarget::Url("https://example.com/1?utm_source=x".into()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(by_url, text, "--url canonicalizes and finds the same row");
|
||||
|
||||
let thin = explain(&db, date(), None, &ExplainTarget::Article(2))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(thin.contains("stage: excluded · reason: blocked"), "{thin}");
|
||||
|
||||
let missing = explain(
|
||||
&db,
|
||||
date(),
|
||||
None,
|
||||
&ExplainTarget::Url("https://nowhere.example/post".into()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(missing.contains("never ingested"), "{missing}");
|
||||
let missing_id = explain(&db, date(), None, &ExplainTarget::Article(99))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(missing_id.contains("never ingested"), "{missing_id}");
|
||||
|
||||
let no_run = explain(
|
||||
&db,
|
||||
"2026-01-01".parse().unwrap(),
|
||||
None,
|
||||
&ExplainTarget::Article(1),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(no_run.contains("no non-dry run"), "{no_run}");
|
||||
let not_considered = explain(&db, date(), Some(dry), &ExplainTarget::Article(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
not_considered.contains("was not considered by run"),
|
||||
"{not_considered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn near_misses_rank_by_blend_and_skip_selected_and_excluded() {
|
||||
let (_dir, db) = db_with_articles(&[1, 2, 3, 4, 5]).await;
|
||||
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||||
let rows = [
|
||||
(1, "selected", None, 0.9),
|
||||
(2, "shortlisted", Some("not_selected"), 0.7),
|
||||
(3, "eligible", Some("not_admitted"), 0.95),
|
||||
(4, "shortlisted", Some("not_selected"), 0.1),
|
||||
];
|
||||
for (id, stage, reason, norm) in rows {
|
||||
let json = serialize_signals(&signals(10.0, norm), false);
|
||||
write(
|
||||
&db,
|
||||
&CandidateRun {
|
||||
run_id,
|
||||
article_id: id,
|
||||
stage,
|
||||
excluded_reason: reason,
|
||||
admitted_by: None,
|
||||
signals_json: &json,
|
||||
utility: None,
|
||||
rank_utility: None,
|
||||
cluster_id: None,
|
||||
cluster_rank: None,
|
||||
editor_why: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
thin_excluded(&db, run_id, 5, "published_before")
|
||||
.await
|
||||
.unwrap();
|
||||
let misses = near_misses(&db, run_id, 10).await.unwrap();
|
||||
assert_eq!(
|
||||
misses.iter().map(|row| row.article_id).collect::<Vec<_>>(),
|
||||
vec![3, 2, 4]
|
||||
);
|
||||
let text = explain_near_misses(&db, date(), None, 2).await.unwrap();
|
||||
assert!(
|
||||
text.contains("top 2 not selected, by preliminary blend"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Article 3 · eligible, not_admitted"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(!text.contains("Article 4"), "{text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prune_respects_rated_and_published() {
|
||||
let (_dir, db) = db_with_articles(&[1, 2, 3, 4]).await;
|
||||
let now = Timestamp::now();
|
||||
let old = fmt_ts(now - jiff::Span::new().hours(200 * 24));
|
||||
sqlx::query("UPDATE articles SET first_seen = ? WHERE id IN (1, 2, 3)")
|
||||
.bind(&old)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let blob = encode_blob(&[0.5, 0.5]).unwrap();
|
||||
for id in 1..=4 {
|
||||
sqlx::query(
|
||||
"INSERT INTO article_embeddings
|
||||
(article_id, model, dimension, input_hash, embedding, created_at)
|
||||
VALUES (?, 'voyage-4-lite', 2, 'h', ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&blob)
|
||||
.bind(&old)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO rating_events (article_id, kind, source, label, value, event_at)
|
||||
VALUES (1, 'explicit', 'cli', 'loved', 1.0, ?)",
|
||||
)
|
||||
.bind(&old)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO issues (date, issue_number, generated_at) VALUES ('2026-02-01', 1, ?);
|
||||
INSERT INTO issue_articles (issue_date, article_id, section) VALUES ('2026-02-01', 2, 'Top Stories');",
|
||||
)
|
||||
.bind(&old)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let old_run = db
|
||||
.start_run("2026-02-01".parse().unwrap(), now)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE runs SET started_at = ? WHERE id = ?")
|
||||
.bind(&old)
|
||||
.bind(old_run)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let new_run = db.start_run(date(), now).await.unwrap();
|
||||
thin_excluded(&db, old_run, 1, "blocked").await.unwrap();
|
||||
thin_excluded(&db, new_run, 1, "blocked").await.unwrap();
|
||||
|
||||
let (embeddings, telemetry) = prune(&db, 120, 180, now).await.unwrap();
|
||||
assert_eq!(
|
||||
embeddings, 1,
|
||||
"only the old, unrated, unpublished article 3"
|
||||
);
|
||||
assert_eq!(telemetry, 1, "only the old run's rows");
|
||||
let remaining: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT article_id FROM article_embeddings ORDER BY article_id")
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, vec![1, 2, 4]);
|
||||
let runs: Vec<i64> = sqlx::query_scalar("SELECT run_id FROM candidate_runs")
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(runs, vec![new_run]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user