use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ScorerKind {
#[default]
Cosine,
Delta,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvidenceBudget {
#[serde(default)]
pub max_evidence: Option<usize>,
#[serde(default)]
pub stop_when_sufficient: bool,
#[serde(default = "default_sufficiency_threshold")]
pub sufficiency_threshold: f32,
#[serde(default)]
pub scorer: ScorerKind,
}
fn default_sufficiency_threshold() -> f32 {
0.8
}
impl Default for EvidenceBudget {
fn default() -> Self {
Self {
max_evidence: None,
stop_when_sufficient: false,
sufficiency_threshold: default_sufficiency_threshold(),
scorer: ScorerKind::Cosine,
}
}
}
impl EvidenceBudget {
pub fn capped(max: usize) -> Self {
Self {
max_evidence: Some(max),
..Self::default()
}
}
pub fn early_stop(threshold: f32) -> Self {
Self {
stop_when_sufficient: true,
sufficiency_threshold: threshold,
..Self::default()
}
}
}
pub struct EvidenceCandidate<'a> {
pub content: &'a str,
pub embedding: Option<&'a [f32]>,
pub retrieval_score: f32,
}
pub struct EvidenceContext<'a> {
pub query: &'a str,
pub query_embedding: Option<&'a [f32]>,
pub candidate: &'a EvidenceCandidate<'a>,
pub selected: &'a [EvidenceCandidate<'a>],
}
pub trait EvidenceScorer: Send + Sync {
fn score(&self, ctx: &EvidenceContext<'_>) -> f32;
fn name(&self) -> &str;
}
#[derive(Debug, Clone, Default)]
pub struct CosineScorer;
impl EvidenceScorer for CosineScorer {
fn score(&self, ctx: &EvidenceContext<'_>) -> f32 {
match (ctx.query_embedding, ctx.candidate.embedding) {
(Some(q), Some(c)) if !q.is_empty() && q.len() == c.len() => {
let sim = cosine(q, c);
if sim.is_finite() && sim > 0.0 {
sim.clamp(0.0, 1.0)
} else {
ctx.candidate.retrieval_score.clamp(0.0, 1.0)
}
}
_ => ctx.candidate.retrieval_score.clamp(0.0, 1.0),
}
}
fn name(&self) -> &str {
"cosine"
}
}
pub struct DeltaScorer {
impact_fn: Box<dyn Fn(&EvidenceContext<'_>) -> f32 + Send + Sync>,
}
impl DeltaScorer {
pub fn new<F>(impact_fn: F) -> Self
where
F: Fn(&EvidenceContext<'_>) -> f32 + Send + Sync + 'static,
{
Self {
impact_fn: Box::new(impact_fn),
}
}
pub fn stub() -> Self {
Self::new(|ctx| {
let cand_tokens: std::collections::HashSet<&str> =
ctx.candidate.content.split_whitespace().collect();
if cand_tokens.is_empty() {
return 0.0;
}
let seen: std::collections::HashSet<&str> = ctx
.selected
.iter()
.flat_map(|c| c.content.split_whitespace())
.collect();
let novel = cand_tokens.iter().filter(|t| !seen.contains(*t)).count();
let novelty = novel as f32 / cand_tokens.len() as f32;
(novelty * ctx.candidate.retrieval_score.clamp(0.0, 1.0)).clamp(0.0, 1.0)
})
}
pub fn impact(&self, ctx: &EvidenceContext<'_>) -> f32 {
(self.impact_fn)(ctx).clamp(0.0, 1.0)
}
}
impl EvidenceScorer for DeltaScorer {
fn score(&self, ctx: &EvidenceContext<'_>) -> f32 {
self.impact(ctx)
}
fn name(&self) -> &str {
"delta"
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvidenceSelectionReport {
pub scorer: String,
pub examined: usize,
pub returned: usize,
pub cumulative_score: f32,
pub stopped_early: bool,
pub capped: bool,
}
pub struct BudgetSelection {
pub keep: usize,
pub report: EvidenceSelectionReport,
}
pub fn select_within_budget(
candidates: &[EvidenceCandidate<'_>],
budget: &EvidenceBudget,
scorer: &dyn EvidenceScorer,
query: &str,
query_embedding: Option<&[f32]>,
) -> BudgetSelection {
let cap = budget.max_evidence.unwrap_or(candidates.len());
let hard_cap = cap.min(candidates.len());
let mut selected: Vec<EvidenceCandidate<'_>> = Vec::new();
let mut cumulative = 0.0_f32;
let mut stopped_early = false;
let mut examined = 0_usize;
for cand in candidates.iter() {
if selected.len() >= hard_cap {
break;
}
examined += 1;
let ctx = EvidenceContext {
query,
query_embedding,
candidate: cand,
selected: &selected,
};
let s = scorer.score(&ctx).clamp(0.0, 1.0);
cumulative += s;
selected.push(EvidenceCandidate {
content: cand.content,
embedding: cand.embedding,
retrieval_score: cand.retrieval_score,
});
if budget.stop_when_sufficient && cumulative >= budget.sufficiency_threshold {
stopped_early = true;
break;
}
}
let keep = selected.len();
let capped = budget.max_evidence.is_some() && keep >= hard_cap && keep < candidates.len();
BudgetSelection {
keep,
report: EvidenceSelectionReport {
scorer: scorer.name().to_string(),
examined,
returned: keep,
cumulative_score: cumulative,
stopped_early,
capped,
},
}
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
let mut dot = 0.0_f32;
let mut na = 0.0_f32;
let mut nb = 0.0_f32;
for i in 0..a.len() {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
let denom = (na.sqrt() * nb.sqrt()).max(f32::EPSILON);
dot / denom
}
#[cfg(test)]
mod tests {
use super::*;
fn cand(content: &'static str, score: f32) -> EvidenceCandidate<'static> {
EvidenceCandidate {
content,
embedding: None,
retrieval_score: score,
}
}
#[test]
fn max_evidence_cap_is_respected() {
let cands = vec![
cand("alpha one", 0.9),
cand("beta two", 0.8),
cand("gamma three", 0.7),
cand("delta four", 0.6),
];
let budget = EvidenceBudget::capped(2);
let sel = select_within_budget(&cands, &budget, &CosineScorer, "q", None);
assert_eq!(sel.keep, 2, "cap must bound the returned count");
assert!(sel.report.capped);
assert!(!sel.report.stopped_early);
}
#[test]
fn early_stop_fires_at_threshold() {
let cands = vec![
cand("alpha", 0.5),
cand("beta", 0.5),
cand("gamma", 0.5),
cand("delta", 0.5),
];
let budget = EvidenceBudget::early_stop(0.8);
let sel = select_within_budget(&cands, &budget, &CosineScorer, "q", None);
assert_eq!(sel.keep, 2, "should stop after cumulative 1.0 clears 0.8");
assert!(sel.report.stopped_early);
assert!(sel.report.cumulative_score >= 0.8);
}
#[test]
fn early_stop_one_strong_chunk_clears_bar() {
let cands = vec![cand("alpha", 0.85), cand("beta", 0.85), cand("gamma", 0.85)];
let budget = EvidenceBudget::early_stop(0.8);
let sel = select_within_budget(&cands, &budget, &CosineScorer, "q", None);
assert_eq!(sel.keep, 1, "a single 0.85 chunk clears the 0.8 bar");
assert!(sel.report.stopped_early);
}
#[test]
fn scorer_trait_is_swappable() {
let distinct = vec![cand("alpha one", 0.9), cand("beta two", 0.9)];
let redundant = vec![cand("alpha one", 0.9), cand("alpha one", 0.9)];
let budget = EvidenceBudget {
stop_when_sufficient: true,
sufficiency_threshold: 1.5,
scorer: ScorerKind::Delta,
..Default::default()
};
let scorer = DeltaScorer::stub();
let s_distinct = select_within_budget(&distinct, &budget, &scorer, "q", None);
let s_redundant = select_within_budget(&redundant, &budget, &scorer, "q", None);
assert!(
s_distinct.report.cumulative_score > s_redundant.report.cumulative_score,
"delta scorer must reward novelty: distinct={} redundant={}",
s_distinct.report.cumulative_score,
s_redundant.report.cumulative_score
);
assert_eq!(s_distinct.report.scorer, "delta");
}
#[test]
fn injectable_closure_is_honoured() {
let scorer = DeltaScorer::new(|_ctx| 1.0);
let cands = vec![cand("alpha", 0.1), cand("beta", 0.1)];
let budget = EvidenceBudget {
stop_when_sufficient: true,
sufficiency_threshold: 0.9,
scorer: ScorerKind::Delta,
..Default::default()
};
let sel = select_within_budget(&cands, &budget, &scorer, "q", None);
assert_eq!(
sel.keep, 1,
"closure scoring 1.0 clears 0.9 after one chunk"
);
}
#[test]
fn no_budget_keeps_everything() {
let cands = vec![cand("a", 0.9), cand("b", 0.8), cand("c", 0.7)];
let budget = EvidenceBudget::default();
let sel = select_within_budget(&cands, &budget, &CosineScorer, "q", None);
assert_eq!(sel.keep, 3, "default budget caps nothing and never stops");
assert!(!sel.report.capped);
assert!(!sel.report.stopped_early);
}
#[test]
fn property_larger_budget_is_prefix_superset() {
let mut state: u64 = 0x9E3779B97F4A7C15;
let mut next = || {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
((state >> 33) as f32) / (u32::MAX as f32)
};
let contents: Vec<String> = (0..40).map(|i| format!("chunk-{i}-token{i}")).collect();
let mut cands: Vec<EvidenceCandidate<'_>> = contents
.iter()
.map(|c| EvidenceCandidate {
content: c.as_str(),
embedding: None,
retrieval_score: next(),
})
.collect();
cands.sort_by(|a, b| {
b.retrieval_score
.partial_cmp(&a.retrieval_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
for small in 1..=cands.len() {
for large in small..=cands.len() {
let bs = EvidenceBudget::capped(small);
let bl = EvidenceBudget::capped(large);
let ss = select_within_budget(&cands, &bs, &CosineScorer, "q", None);
let sl = select_within_budget(&cands, &bl, &CosineScorer, "q", None);
assert!(
sl.keep >= ss.keep,
"larger budget kept fewer: small={} large={} got {} vs {}",
small,
large,
sl.keep,
ss.keep
);
for i in 0..ss.keep {
assert_eq!(
cands[i].content,
contents_sorted(&cands)[i],
"internal: index drift"
);
}
assert!(
ss.keep <= sl.keep,
"prefix invariant violated: {} > {}",
ss.keep,
sl.keep
);
}
}
}
fn contents_sorted<'a>(cands: &'a [EvidenceCandidate<'a>]) -> Vec<&'a str> {
cands.iter().map(|c| c.content).collect()
}
}