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
587 lines
21 KiB
Rust
587 lines
21 KiB
Rust
//! 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::<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());
|
||
}
|
||
}
|