//! 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 0–100. pub fn calculate_utility(candidates: &mut [Candidate], configured: &UtilityWeights) { let indices = (0..candidates.len()).collect::>(); 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::>(); for signals in &mut normalized { signals.norm.clear(); signals.weights.clear(); } let mut signal_refs = normalized.iter_mut().collect::>(); 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::>(); let total = weighted.iter().map(|(_, weight, _)| weight).sum::(); 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::() * 100.0, ); } } fn ranked_indices(candidates: &[Candidate], indices: &[usize]) -> Vec { 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>, ranking: &RankingConfig, ) -> RankSummary { let deep = candidates .iter() .enumerate() .filter(|(_, candidate)| matches!(candidate.stage.as_str(), "admitted" | "assessed")) .map(|(index, _)| index) .collect::>(); 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::::new(); let mut members: HashMap> = 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::>(); let mut admitted = HashSet::new(); let mut admitted_per_cluster = HashMap::::new(); let admit = |index: usize, admitted: &mut HashSet, counts: &mut HashMap| { 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, admitted_per_cluster: &mut HashMap, mut suppressed: Option<&mut HashSet>, ) { 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 { 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::() - 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::>() .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::>(); let embeddings = (1..=6) .map(|id| (id, vector(0.01 * id as f32))) .collect::>(); 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::>(); 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::>(), (1..=6).map(Some).collect::>() ); assert_eq!( candidates .iter() .map(|c| c.cluster_rank) .collect::>(), (1..=6).map(Some).collect::>() ); } #[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::>(); 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::>(); 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::() - 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()); } }