Files
the-daily-epub/src/curate/rank.rs
T
thalladaandClaude Fable 5.1 05a74a0dcf Curation v2 step 5: deep assessment, utility, diversified shortlist
assess.rs replaces score.rs (DEEP_INSTRUCTIONS, representative sample
with [BEGINNING]/[MIDDLE]/[END], facets, cached deep rows with --rescore
bypass), rank.rs adds the utility blend over present signals with gate
ramps and the leader-clustered shortlist (cap 2 → 3 → uncapped, protected
top-N, exploration reserve), editor.rs replaces select.rs with the §13
rendering and utility-ordered fallbacks. ScoredArticle is gone; Candidate
is the only flow type. deep_batch_size replaces score_batch_size.

Started by Codex (cut off by its usage limit mid-verification) and
finished by a Claude agent from docs/plans/curation-v2-briefs/step5.md;
reviewed against plan §12–§13.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 16:04:43 +00:00

587 lines
21 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.
//! Utility normalization, leader clustering and diversified shortlisting (§12.2–§12.5).
use std::collections::{HashMap, HashSet};
use crate::config::{RankingConfig, UtilityWeights};
use crate::curate::embedding::dot;
use crate::curate::signals;
use crate::types::{ArticleId, Candidate};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RankSummary {
pub shortlisted: usize,
pub clusters: usize,
}
/// Re-normalize cheap signals over the deep set and calculate utility on 0100.
pub fn calculate_utility(candidates: &mut [Candidate], configured: &UtilityWeights) {
let indices = (0..candidates.len()).collect::<Vec<_>>();
calculate_utility_for(candidates, &indices, configured);
}
fn calculate_utility_for(
candidates: &mut [Candidate],
indices: &[usize],
configured: &UtilityWeights,
) {
let mut normalized = indices
.iter()
.map(|index| candidates[*index].signals.clone())
.collect::<Vec<_>>();
for signals in &mut normalized {
signals.norm.clear();
signals.weights.clear();
}
let mut signal_refs = normalized.iter_mut().collect::<Vec<_>>();
signals::normalize(&mut signal_refs);
for (index, signals) in indices.iter().zip(normalized) {
candidates[*index].signals.norm = signals.norm;
candidates[*index].signals.weights.clear();
}
for index in indices {
let candidate = &mut candidates[*index];
if let Some(triage) = &candidate.assessment.triage {
candidate
.signals
.norm
.insert("triage".into(), (triage.interest / 10.0).clamp(0.0, 1.0));
}
if let Some(deep) = &candidate.assessment.deep {
candidate
.signals
.norm
.insert("quality".into(), (deep.quality / 10.0).clamp(0.0, 1.0));
candidate
.signals
.norm
.insert("fit".into(), (deep.fit / 10.0).clamp(0.0, 1.0));
}
let weighted = [
("quality", configured.quality, 1.0),
("fit", configured.fit, 1.0),
("knn", configured.knn, candidate.signals.knn_gate),
("interest", configured.interest, 1.0),
("feed", configured.feed, candidate.signals.feed_gate),
("triage", configured.triage, 1.0),
("social", configured.social, 1.0),
("heuristic", configured.heuristic, 1.0),
]
.into_iter()
.filter_map(|(name, weight, gate)| {
let value = candidate.signals.norm.get(name).copied()?;
let effective = weight * gate;
(effective > 0.0).then_some((name, effective, value))
})
.collect::<Vec<_>>();
let total = weighted.iter().map(|(_, weight, _)| weight).sum::<f64>();
if total <= 0.0 {
candidate.utility = None;
continue;
}
candidate.signals.weights = weighted
.iter()
.map(|(name, weight, _)| ((*name).to_string(), weight / total))
.collect();
candidate.utility = Some(
weighted
.iter()
.map(|(_, weight, value)| weight / total * value)
.sum::<f64>()
* 100.0,
);
}
}
fn ranked_indices(candidates: &[Candidate], indices: &[usize]) -> Vec<usize> {
let mut sorted = indices.to_vec();
sorted.sort_by(|left, right| {
candidates[*right]
.utility
.unwrap_or(f64::NEG_INFINITY)
.total_cmp(&candidates[*left].utility.unwrap_or(f64::NEG_INFINITY))
.then_with(|| {
candidates[*left]
.article
.id
.cmp(&candidates[*right].article.id)
})
});
sorted
}
#[derive(Debug)]
struct Cluster {
id: i64,
leader: usize,
}
/// Rank the admitted deep set and leave only the diversified shortlist at
/// `stage = shortlisted`. All deep-set articles receive ranks and clusters.
pub fn shortlist(
candidates: &mut [Candidate],
embeddings: &HashMap<ArticleId, Vec<f32>>,
ranking: &RankingConfig,
) -> RankSummary {
let deep = candidates
.iter()
.enumerate()
.filter(|(_, candidate)| matches!(candidate.stage.as_str(), "admitted" | "assessed"))
.map(|(index, _)| index)
.collect::<Vec<_>>();
calculate_utility_for(candidates, &deep, &ranking.weights.utility);
let sorted = ranked_indices(candidates, &deep);
for (rank, index) in sorted.iter().enumerate() {
candidates[*index].rank_utility = Some(rank as i64 + 1);
candidates[*index].cluster = None;
candidates[*index].cluster_rank = None;
}
let mut clusters = Vec::<Cluster>::new();
let mut members: HashMap<i64, Vec<usize>> = HashMap::new();
for index in &sorted {
let assigned = embeddings
.get(&candidates[*index].article.id)
.and_then(|vector| {
clusters.iter().find_map(|cluster| {
let leader_id = candidates[cluster.leader].article.id;
let leader = embeddings.get(&leader_id)?;
dot(vector, leader)
.ok()
.filter(|cosine| *cosine >= ranking.diversity.cluster_threshold)
.map(|_| cluster.id)
})
});
let cluster_id = assigned.unwrap_or_else(|| {
let id = clusters.len() as i64 + 1;
clusters.push(Cluster { id, leader: *index });
id
});
let cluster_members = members.entry(cluster_id).or_default();
cluster_members.push(*index);
candidates[*index].cluster = Some(cluster_id);
candidates[*index].cluster_rank = Some(cluster_members.len() as i64);
}
let protected = sorted
.iter()
.take(ranking.diversity.utility_protected)
.copied()
.collect::<HashSet<_>>();
let mut admitted = HashSet::new();
let mut admitted_per_cluster = HashMap::<i64, usize>::new();
let admit = |index: usize, admitted: &mut HashSet<usize>, counts: &mut HashMap<i64, usize>| {
if admitted.insert(index)
&& let Some(cluster) = candidates[index].cluster
{
*counts.entry(cluster).or_default() += 1;
}
};
for index in &sorted {
if protected.contains(index) || candidates[*index].auto_include {
admit(*index, &mut admitted, &mut admitted_per_cluster);
}
}
for index in sorted
.iter()
.filter(|index| candidates[**index].exploration)
.take(3)
{
if admitted.len() >= ranking.shortlist_keep {
break;
}
admit(*index, &mut admitted, &mut admitted_per_cluster);
}
let target = ranking.shortlist_keep.max(admitted.len());
let mut suppressed_at_base_cap = HashSet::new();
admit_under_cap(
candidates,
&sorted,
target,
ranking.diversity.per_cluster_cap,
&mut admitted,
&mut admitted_per_cluster,
Some(&mut suppressed_at_base_cap),
);
if admitted.len() < target {
admit_under_cap(
candidates,
&sorted,
target,
3,
&mut admitted,
&mut admitted_per_cluster,
None,
);
}
if admitted.len() < target {
for index in &sorted {
if admitted.len() >= target {
break;
}
admit(*index, &mut admitted, &mut admitted_per_cluster);
}
}
for index in deep {
if admitted.contains(&index) {
candidates[index].stage = "shortlisted".into();
candidates[index].excluded_reason = None;
} else {
// The stage stays where the article stopped (`admitted` when the
// deep assessment never happened, else `assessed`).
candidates[index].excluded_reason = Some(
if suppressed_at_base_cap.contains(&index) {
"cluster_suppressed"
} else {
"shortlist_cap"
}
.into(),
);
}
}
RankSummary {
shortlisted: admitted.len(),
clusters: clusters.len(),
}
}
#[allow(clippy::too_many_arguments)]
fn admit_under_cap(
candidates: &[Candidate],
sorted: &[usize],
target: usize,
cap: usize,
admitted: &mut HashSet<usize>,
admitted_per_cluster: &mut HashMap<i64, usize>,
mut suppressed: Option<&mut HashSet<usize>>,
) {
for index in sorted {
if admitted.len() >= target || admitted.contains(index) {
continue;
}
let Some(cluster) = candidates[*index].cluster else {
continue;
};
if admitted_per_cluster.get(&cluster).copied().unwrap_or(0) >= cap {
if let Some(suppressed) = suppressed.as_deref_mut() {
suppressed.insert(*index);
}
continue;
}
admitted.insert(*index);
*admitted_per_cluster.entry(cluster).or_default() += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DiversityConfig;
use crate::curate::prefilter::tests::article;
use crate::curate::signals::Signals;
use crate::types::{Deep, Facets};
fn candidate(id: i64, utility_hint: f64) -> Candidate {
let mut candidate = Candidate::new(article(id, &format!("article {id}"), 800), false);
candidate.stage = "assessed".into();
candidate.signals = Signals {
heuristic: Some(utility_hint),
..Signals::default()
};
candidate.assessment.deep = Some(Deep {
quality: utility_hint,
fit: utility_hint,
category: Some("Top Stories".into()),
rationale: "specific".into(),
paywalled_guess: false,
facets: Facets::default(),
model: "mock".into(),
prompt_version: 1,
assessed_at: "2026-09-02T00:00:00Z".parse().expect("timestamp"),
});
candidate
}
fn vector(angle: f32) -> Vec<f32> {
vec![angle.cos(), angle.sin()]
}
fn ranking(keep: usize, protected: usize, cap: usize, threshold: f64) -> RankingConfig {
RankingConfig {
shortlist_keep: keep,
diversity: DiversityConfig {
cluster_threshold: threshold,
per_cluster_cap: cap,
utility_protected: protected,
},
..RankingConfig::default()
}
}
#[test]
fn utility_renormalizes_present_signals_and_gates_learned_ones() {
let mut a = candidate(1, 8.0);
let mut b = candidate(2, 4.0);
a.signals.interest = Some(1.0);
b.signals.interest = None;
a.signals.knn = Some(0.9);
a.signals.knn_gate = 0.5;
let mut values = vec![a, b];
calculate_utility(&mut values, &UtilityWeights::default());
let [a, b] = values.as_slice() else {
panic!("two values")
};
for candidate in [&a, &b] {
assert!((candidate.signals.weights.values().sum::<f64>() - 1.0).abs() < 1e-9);
assert!(candidate.utility.is_some());
}
assert!(!b.signals.weights.contains_key("interest"));
assert!(
(a.signals.weights["knn"] / a.signals.weights["quality"] - (0.15 * 0.5) / 0.40).abs()
< 1e-9
);
}
#[test]
fn duplicates_cluster_and_the_third_is_suppressed() {
let ranking = ranking(3, 0, 2, 0.85);
let mut candidates = vec![
candidate(1, 9.0),
candidate(2, 8.0),
candidate(3, 7.0),
candidate(4, 6.0),
];
let embeddings = HashMap::from([
(1, vector(0.0)),
(2, vector(0.1)),
(3, vector(0.2)),
(4, vector(2.0)),
]);
let summary = shortlist(&mut candidates, &embeddings, &ranking);
assert_eq!(summary.shortlisted, 3);
assert_eq!(candidates[0].cluster, candidates[1].cluster);
assert_eq!(candidates[1].cluster, candidates[2].cluster);
assert_eq!(
candidates[2].excluded_reason.as_deref(),
Some("cluster_suppressed")
);
}
#[test]
fn protected_items_survive_and_count_toward_the_cap() {
let ranking = ranking(3, 2, 1, 0.85);
let mut candidates = vec![
candidate(1, 9.0),
candidate(2, 8.0),
candidate(3, 7.0),
candidate(4, 6.0),
];
let embeddings = HashMap::from([
(1, vector(0.0)),
(2, vector(0.05)),
(3, vector(0.1)),
(4, vector(2.0)),
]);
shortlist(&mut candidates, &embeddings, &ranking);
assert_eq!(candidates[0].stage, "shortlisted");
assert_eq!(candidates[1].stage, "shortlisted");
assert_ne!(candidates[2].stage, "shortlisted");
}
#[test]
fn bridge_case_uses_leaders_not_transitive_components() {
let ranking = ranking(3, 0, 2, 0.80);
let mut candidates = vec![candidate(1, 9.0), candidate(2, 8.0), candidate(3, 7.0)];
// A at 0°, B at 72°, C at 36°: A~C and B~C, but A not~B.
let embeddings =
HashMap::from([(1, vector(0.0)), (2, vector(1.2566)), (3, vector(0.6283))]);
let summary = shortlist(&mut candidates, &embeddings, &ranking);
assert_eq!(summary.clusters, 2);
assert_eq!(candidates[0].cluster, candidates[2].cluster);
assert_ne!(candidates[0].cluster, candidates[1].cluster);
}
#[test]
fn missing_embeddings_are_singletons_and_never_suppressed() {
let ranking = ranking(3, 0, 1, 0.85);
let mut candidates = vec![candidate(1, 9.0), candidate(2, 8.0), candidate(3, 7.0)];
shortlist(&mut candidates, &HashMap::new(), &ranking);
assert!(
candidates
.iter()
.all(|candidate| candidate.stage == "shortlisted")
);
assert_eq!(
candidates
.iter()
.filter_map(|candidate| candidate.cluster)
.collect::<HashSet<_>>()
.len(),
3
);
}
#[test]
fn caps_relax_to_three_then_uncapped_when_the_shortlist_is_short() {
// Six near-duplicates, keep 5: cap 2 admits two, cap 3 admits a third,
// and the uncapped pass fills the remaining two slots in utility order.
let ranking = ranking(5, 0, 2, 0.85);
let mut candidates = (1..=6)
.map(|id| candidate(id, 10.0 - id as f64))
.collect::<Vec<_>>();
let embeddings = (1..=6)
.map(|id| (id, vector(0.01 * id as f32)))
.collect::<HashMap<_, _>>();
let summary = shortlist(&mut candidates, &embeddings, &ranking);
assert_eq!(summary.clusters, 1);
assert_eq!(summary.shortlisted, 5);
let shortlisted = candidates
.iter()
.filter(|candidate| candidate.stage == "shortlisted")
.map(|candidate| candidate.article.id)
.collect::<Vec<_>>();
assert_eq!(shortlisted, vec![1, 2, 3, 4, 5], "filled in utility order");
assert_eq!(candidates[5].stage, "assessed");
assert_eq!(
candidates[5].excluded_reason.as_deref(),
Some("cluster_suppressed")
);
assert_eq!(
candidates
.iter()
.map(|c| c.rank_utility)
.collect::<Vec<_>>(),
(1..=6).map(Some).collect::<Vec<_>>()
);
assert_eq!(
candidates
.iter()
.map(|c| c.cluster_rank)
.collect::<Vec<_>>(),
(1..=6).map(Some).collect::<Vec<_>>()
);
}
#[test]
fn shortlist_cap_is_the_reason_beyond_the_keep() {
let ranking = ranking(2, 0, 2, 0.85);
let mut candidates = vec![candidate(1, 9.0), candidate(2, 8.0), candidate(3, 7.0)];
shortlist(&mut candidates, &HashMap::new(), &ranking);
assert_eq!(candidates[0].stage, "shortlisted");
assert_eq!(candidates[1].stage, "shortlisted");
assert_eq!(candidates[2].stage, "assessed");
assert_eq!(
candidates[2].excluded_reason.as_deref(),
Some("shortlist_cap")
);
}
#[test]
fn exploration_picks_get_up_to_three_reserved_slots() {
// Keep 4 with the four best by utility being ordinary articles: three
// exploration picks are still reserved seats, the fourth is not.
let ranking = ranking(4, 0, 2, 0.85);
let mut candidates = (1..=8)
.map(|id| candidate(id, 10.0 - id as f64))
.collect::<Vec<_>>();
for candidate in candidates.iter_mut().skip(4) {
candidate.exploration = true;
}
let summary = shortlist(&mut candidates, &HashMap::new(), &ranking);
assert_eq!(summary.shortlisted, 4);
let shortlisted = candidates
.iter()
.filter(|candidate| candidate.stage == "shortlisted")
.map(|candidate| candidate.article.id)
.collect::<Vec<_>>();
assert_eq!(shortlisted, vec![1, 5, 6, 7]);
assert_eq!(
candidates[7].excluded_reason.as_deref(),
Some("shortlist_cap")
);
}
#[test]
fn auto_includes_are_admitted_regardless_and_count_toward_their_cluster() {
let ranking = ranking(2, 0, 1, 0.85);
let mut candidates = vec![candidate(1, 9.0), candidate(2, 8.0), candidate(3, 1.0)];
candidates[2].auto_include = true;
let embeddings = HashMap::from([(1, vector(0.0)), (2, vector(2.0)), (3, vector(0.05))]);
let summary = shortlist(&mut candidates, &embeddings, &ranking);
assert_eq!(summary.shortlisted, 2);
assert_eq!(candidates[2].stage, "shortlisted", "auto-include survives");
// The auto-include filled its cluster's single seat, so the stronger
// near-duplicate is suppressed and the unrelated article gets the slot.
assert_eq!(candidates[0].cluster, candidates[2].cluster);
assert_eq!(candidates[0].stage, "assessed");
assert_eq!(
candidates[0].excluded_reason.as_deref(),
Some("cluster_suppressed")
);
assert_eq!(candidates[1].stage, "shortlisted");
}
#[test]
fn unassessed_articles_rank_on_present_signals_and_keep_their_stage() {
// DeepSeek down: no quality/fit anywhere, utility comes from what is present.
let ranking = ranking(2, 0, 2, 0.85);
let mut candidates = vec![candidate(1, 3.0), candidate(2, 6.0), candidate(3, 9.0)];
for candidate in &mut candidates {
candidate.assessment.deep = None;
candidate.stage = "admitted".into();
}
candidates[0].assessment.triage = Some(crate::types::Triage {
interest: 9.0,
kind: "essay".into(),
why: "promising".into(),
model: "mock".into(),
prompt_version: 1,
assessed_at: "2026-09-02T00:00:00Z".parse().expect("timestamp"),
});
let summary = shortlist(&mut candidates, &HashMap::new(), &ranking);
assert_eq!(summary.shortlisted, 2);
for candidate in &candidates {
assert!(candidate.utility.is_some(), "scored on present signals");
assert!(!candidate.signals.weights.contains_key("quality"));
assert!(!candidate.signals.weights.contains_key("fit"));
assert!((candidate.signals.weights.values().sum::<f64>() - 1.0).abs() < 1e-9);
}
// Triage (0.05) outweighs heuristic (0.02): the triaged article with
// the weakest heuristic overtakes the middle one.
assert_eq!(candidates[2].rank_utility, Some(1));
assert_eq!(candidates[0].rank_utility, Some(2));
assert_eq!(candidates[1].rank_utility, Some(3));
assert_eq!(
candidates[1].stage, "admitted",
"never assessed, so not `assessed`"
);
assert_eq!(
candidates[1].excluded_reason.as_deref(),
Some("shortlist_cap")
);
}
#[test]
fn percentiles_are_taken_over_the_deep_set_only() {
// The eligible-but-not-admitted article has the strongest heuristic;
// it must not shift the deep set's percentiles or receive a utility.
let ranking = ranking(10, 0, 2, 0.85);
let mut candidates = vec![candidate(1, 5.0), candidate(2, 5.0), candidate(3, 5.0)];
candidates[2].stage = "triaged".into();
candidates[2].excluded_reason = Some("not_admitted".into());
candidates[2].signals.heuristic = Some(99.0);
candidates[1].signals.heuristic = Some(5.0);
shortlist(&mut candidates, &HashMap::new(), &ranking);
assert_eq!(
candidates[0].signals.norm["heuristic"], 0.5,
"ties share a percentile"
);
assert_eq!(candidates[1].signals.norm["heuristic"], 0.5);
assert!(candidates[2].utility.is_none());
assert!(candidates[2].rank_utility.is_none());
}
}