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
This commit is contained in:
2026-09-02 16:04:43 +00:00
co-authored by Claude Fable 5.1
parent 10f4afd091
commit 05a74a0dcf
22 changed files with 2710 additions and 1298 deletions
+586
View File
@@ -0,0 +1,586 @@
//! 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());
}
}