mnemo_core/query/evidence.rs
1//! Cost-aware, answer-impact-scored evidence selection for recall.
2//!
3//! The default recall path front-loads: it returns the top-`limit`
4//! records sorted by the fused retrieval score. For an LLM caller that
5//! pays per evidence chunk (context tokens), that is wasteful — most
6//! answers are decided by the first one or two strongly-relevant
7//! chunks, and the rest are dead weight.
8//!
9//! This module adds an opt-in *evidence budget* that runs over the
10//! already-ranked candidate list and returns **the smallest prefix
11//! that clears a configurable sufficiency bar**, capped by an optional
12//! `max_evidence`. It is purely subtractive: it only ever returns a
13//! prefix of the input ordering, so it can never reorder or "silently
14//! lower" the retrieval's top-k cosine ordering (see the property test
15//! in this module).
16//!
17//! # Answer-impact scoring
18//!
19//! Relevance is computed through a pluggable [`EvidenceScorer`] trait,
20//! so callers can swap the signal used to decide sufficiency:
21//!
22//! - [`CosineScorer`] (default) — cosine similarity of the candidate
23//! embedding against the query embedding, falling back to the
24//! retrieval score when embeddings are absent or degenerate.
25//! - [`DeltaScorer`] — an *answer-impact* scorer: it scores a chunk by
26//! whether adding it to the evidence set already selected would
27//! change a downstream answer. The actual "would the answer change?"
28//! judgement is an injectable closure so the core stays
29//! model-agnostic; [`DeltaScorer::stub`] ships a deterministic
30//! marginal-novelty heuristic for tests and offline use.
31//!
32//! # Wiring
33//!
34//! [`RecallRequest::evidence_budget`](crate::query::recall::RecallRequest::evidence_budget)
35//! carries the serializable [`EvidenceBudget`] config. When the config
36//! selects [`ScorerKind::Delta`] AND the engine has a scorer attached
37//! via [`MnemoEngine::with_evidence_scorer`](crate::query::MnemoEngine::with_evidence_scorer),
38//! that scorer is used; otherwise the path falls back to
39//! [`CosineScorer`]. The default read path (no `evidence_budget`) is
40//! unchanged.
41
42use serde::{Deserialize, Serialize};
43
44/// Which relevance signal the budget uses to decide sufficiency.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "snake_case")]
47pub enum ScorerKind {
48 /// Cosine similarity of candidate vs query embedding (default).
49 #[default]
50 Cosine,
51 /// Answer-impact / marginal-delta scoring. Requires an
52 /// [`EvidenceScorer`] attached to the engine, else falls back to
53 /// cosine.
54 Delta,
55}
56
57/// Serializable per-query evidence budget.
58///
59/// Attach via
60/// [`RecallRequest::evidence_budget`](crate::query::recall::RecallRequest::evidence_budget).
61/// All fields are optional knobs; the zero-config default
62/// ([`EvidenceBudget::default`]) caps nothing and never early-stops,
63/// so an explicitly-`Some(EvidenceBudget::default())` request still
64/// behaves like the legacy path.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct EvidenceBudget {
67 /// Hard cap on the number of evidence chunks returned. `None`
68 /// leaves the count bounded only by the recall `limit`.
69 #[serde(default)]
70 pub max_evidence: Option<usize>,
71 /// When `true`, stop accumulating evidence as soon as the running
72 /// sufficiency score clears [`sufficiency_threshold`]. The caller
73 /// gets the smallest prefix that clears the bar.
74 #[serde(default)]
75 pub stop_when_sufficient: bool,
76 /// Cumulative sufficiency score the selected set must reach before
77 /// early-stop fires. Scores are summed across selected chunks, so
78 /// for a cosine signal in `[0, 1]` a threshold of `0.8` is cleared
79 /// by one `0.85` chunk or two `0.4`/`0.45` chunks. Ignored when
80 /// `stop_when_sufficient` is `false`.
81 #[serde(default = "default_sufficiency_threshold")]
82 pub sufficiency_threshold: f32,
83 /// Which scorer computes the per-chunk relevance.
84 #[serde(default)]
85 pub scorer: ScorerKind,
86}
87
88fn default_sufficiency_threshold() -> f32 {
89 0.8
90}
91
92impl Default for EvidenceBudget {
93 fn default() -> Self {
94 Self {
95 max_evidence: None,
96 stop_when_sufficient: false,
97 sufficiency_threshold: default_sufficiency_threshold(),
98 scorer: ScorerKind::Cosine,
99 }
100 }
101}
102
103impl EvidenceBudget {
104 /// Convenience constructor: cap at `max` chunks, no early-stop.
105 pub fn capped(max: usize) -> Self {
106 Self {
107 max_evidence: Some(max),
108 ..Self::default()
109 }
110 }
111
112 /// Convenience constructor: early-stop once cumulative score
113 /// clears `threshold`.
114 pub fn early_stop(threshold: f32) -> Self {
115 Self {
116 stop_when_sufficient: true,
117 sufficiency_threshold: threshold,
118 ..Self::default()
119 }
120 }
121}
122
123/// A single recall candidate handed to the scorer / budget selector.
124///
125/// Deliberately borrows so the recall path can build these cheaply
126/// from its `(MemoryRecord, f32)` working set without cloning content
127/// or embeddings.
128pub struct EvidenceCandidate<'a> {
129 /// The candidate's textual content.
130 pub content: &'a str,
131 /// The candidate's stored embedding, if any.
132 pub embedding: Option<&'a [f32]>,
133 /// The fused retrieval score this candidate already earned (used
134 /// as the cosine fallback when embeddings are unavailable).
135 pub retrieval_score: f32,
136}
137
138/// Read-only context a scorer sees for one candidate.
139pub struct EvidenceContext<'a> {
140 /// The raw query string.
141 pub query: &'a str,
142 /// The embedded query vector, if the embedder produced a
143 /// non-degenerate one.
144 pub query_embedding: Option<&'a [f32]>,
145 /// The candidate being scored.
146 pub candidate: &'a EvidenceCandidate<'a>,
147 /// Candidates already admitted to the evidence set, in selection
148 /// order. Lets a marginal/answer-impact scorer reason about
149 /// novelty vs what is already present.
150 pub selected: &'a [EvidenceCandidate<'a>],
151}
152
153/// Pluggable relevance signal for the evidence budget.
154///
155/// Implementors return a score in `[0, 1]` representing how much this
156/// candidate contributes to answering the query *given what is already
157/// selected*. The budget sums these to decide sufficiency.
158pub trait EvidenceScorer: Send + Sync {
159 /// Score one candidate in `[0, 1]`.
160 fn score(&self, ctx: &EvidenceContext<'_>) -> f32;
161 /// Stable identifier surfaced in the selection diagnostics.
162 fn name(&self) -> &str;
163}
164
165/// Default scorer: cosine similarity of candidate vs query embedding.
166///
167/// When either embedding is missing or degenerate (e.g. the engine
168/// runs `NoopEmbedding`, whose vectors are all-zero), it falls back to
169/// the candidate's fused retrieval score so the budget stays usable
170/// without a real embedder.
171#[derive(Debug, Clone, Default)]
172pub struct CosineScorer;
173
174impl EvidenceScorer for CosineScorer {
175 fn score(&self, ctx: &EvidenceContext<'_>) -> f32 {
176 match (ctx.query_embedding, ctx.candidate.embedding) {
177 (Some(q), Some(c)) if !q.is_empty() && q.len() == c.len() => {
178 let sim = cosine(q, c);
179 if sim.is_finite() && sim > 0.0 {
180 sim.clamp(0.0, 1.0)
181 } else {
182 ctx.candidate.retrieval_score.clamp(0.0, 1.0)
183 }
184 }
185 _ => ctx.candidate.retrieval_score.clamp(0.0, 1.0),
186 }
187 }
188
189 fn name(&self) -> &str {
190 "cosine"
191 }
192}
193
194/// Answer-impact scorer: scores a chunk by whether including it would
195/// change a downstream answer, relative to the evidence already
196/// selected.
197///
198/// The "would the answer change?" judgement is an injectable closure
199/// (`impact_fn`) so the engine core never embeds a model. The closure
200/// receives the same [`EvidenceContext`] the trait does and returns a
201/// score in `[0, 1]`. A typical production wiring calls an LLM:
202/// *"given the answer derivable from `selected`, does adding
203/// `candidate` change it? rate 0–1"*.
204///
205/// [`DeltaScorer::stub`] ships a deterministic marginal-novelty
206/// heuristic (high when the candidate's token set is novel vs what is
207/// already selected, low when redundant) so tests and offline runs
208/// have a model-free default that still exhibits diminishing returns.
209pub struct DeltaScorer {
210 impact_fn: Box<dyn Fn(&EvidenceContext<'_>) -> f32 + Send + Sync>,
211}
212
213impl DeltaScorer {
214 /// Build a scorer from a caller-supplied answer-impact closure.
215 pub fn new<F>(impact_fn: F) -> Self
216 where
217 F: Fn(&EvidenceContext<'_>) -> f32 + Send + Sync + 'static,
218 {
219 Self {
220 impact_fn: Box::new(impact_fn),
221 }
222 }
223
224 /// Model-free stub: marginal-novelty heuristic. The candidate's
225 /// score is the fraction of its whitespace tokens that do NOT
226 /// already appear in any selected candidate, scaled by its
227 /// retrieval score. A fresh chunk scores near its retrieval score;
228 /// a fully-redundant chunk scores ~0 — so the budget exhibits the
229 /// diminishing-returns shape an answer-impact signal should.
230 pub fn stub() -> Self {
231 Self::new(|ctx| {
232 let cand_tokens: std::collections::HashSet<&str> =
233 ctx.candidate.content.split_whitespace().collect();
234 if cand_tokens.is_empty() {
235 return 0.0;
236 }
237 let seen: std::collections::HashSet<&str> = ctx
238 .selected
239 .iter()
240 .flat_map(|c| c.content.split_whitespace())
241 .collect();
242 let novel = cand_tokens.iter().filter(|t| !seen.contains(*t)).count();
243 let novelty = novel as f32 / cand_tokens.len() as f32;
244 (novelty * ctx.candidate.retrieval_score.clamp(0.0, 1.0)).clamp(0.0, 1.0)
245 })
246 }
247
248 /// Score via the injected closure.
249 pub fn impact(&self, ctx: &EvidenceContext<'_>) -> f32 {
250 (self.impact_fn)(ctx).clamp(0.0, 1.0)
251 }
252}
253
254impl EvidenceScorer for DeltaScorer {
255 fn score(&self, ctx: &EvidenceContext<'_>) -> f32 {
256 self.impact(ctx)
257 }
258
259 fn name(&self) -> &str {
260 "delta"
261 }
262}
263
264/// Diagnostics returned alongside the trimmed evidence set.
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct EvidenceSelectionReport {
267 /// Scorer used (`"cosine"` / `"delta"` / a custom scorer's name).
268 pub scorer: String,
269 /// Number of candidates examined before the budget cut the list.
270 pub examined: usize,
271 /// Number of candidates returned after the budget.
272 pub returned: usize,
273 /// Cumulative sufficiency score of the returned set.
274 pub cumulative_score: f32,
275 /// `true` when early-stop fired (cumulative cleared the threshold
276 /// before the candidate list / cap was exhausted).
277 pub stopped_early: bool,
278 /// `true` when `max_evidence` truncated the set.
279 pub capped: bool,
280}
281
282/// Result of [`select_within_budget`]: the indices to keep (a prefix of
283/// the input order) plus diagnostics.
284pub struct BudgetSelection {
285 /// Number of leading candidates to retain. Always `<=` input len.
286 pub keep: usize,
287 pub report: EvidenceSelectionReport,
288}
289
290/// Select the smallest prefix of `candidates` that satisfies `budget`.
291///
292/// `candidates` MUST already be in the recall's ranked order (highest
293/// fused score first). This function never reorders — it only chooses
294/// how many leading candidates to keep — which is what guarantees the
295/// "a larger budget never lowers the top-k ordering" property.
296///
297/// Sufficiency is the running sum of per-candidate scores from
298/// `scorer`. With `stop_when_sufficient`, accumulation halts the
299/// moment the sum reaches `sufficiency_threshold`. `max_evidence`
300/// applies as a hard cap regardless.
301pub fn select_within_budget(
302 candidates: &[EvidenceCandidate<'_>],
303 budget: &EvidenceBudget,
304 scorer: &dyn EvidenceScorer,
305 query: &str,
306 query_embedding: Option<&[f32]>,
307) -> BudgetSelection {
308 let cap = budget.max_evidence.unwrap_or(candidates.len());
309 let hard_cap = cap.min(candidates.len());
310
311 let mut selected: Vec<EvidenceCandidate<'_>> = Vec::new();
312 let mut cumulative = 0.0_f32;
313 let mut stopped_early = false;
314 let mut examined = 0_usize;
315
316 for cand in candidates.iter() {
317 if selected.len() >= hard_cap {
318 break;
319 }
320 examined += 1;
321 let ctx = EvidenceContext {
322 query,
323 query_embedding,
324 candidate: cand,
325 selected: &selected,
326 };
327 let s = scorer.score(&ctx).clamp(0.0, 1.0);
328 cumulative += s;
329 // `EvidenceCandidate` is Copy-cheap (two refs + a float).
330 selected.push(EvidenceCandidate {
331 content: cand.content,
332 embedding: cand.embedding,
333 retrieval_score: cand.retrieval_score,
334 });
335 if budget.stop_when_sufficient && cumulative >= budget.sufficiency_threshold {
336 stopped_early = true;
337 break;
338 }
339 }
340
341 let keep = selected.len();
342 let capped = budget.max_evidence.is_some() && keep >= hard_cap && keep < candidates.len();
343
344 BudgetSelection {
345 keep,
346 report: EvidenceSelectionReport {
347 scorer: scorer.name().to_string(),
348 examined,
349 returned: keep,
350 cumulative_score: cumulative,
351 stopped_early,
352 capped,
353 },
354 }
355}
356
357fn cosine(a: &[f32], b: &[f32]) -> f32 {
358 let mut dot = 0.0_f32;
359 let mut na = 0.0_f32;
360 let mut nb = 0.0_f32;
361 for i in 0..a.len() {
362 dot += a[i] * b[i];
363 na += a[i] * a[i];
364 nb += b[i] * b[i];
365 }
366 let denom = (na.sqrt() * nb.sqrt()).max(f32::EPSILON);
367 dot / denom
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn cand(content: &'static str, score: f32) -> EvidenceCandidate<'static> {
375 EvidenceCandidate {
376 content,
377 embedding: None,
378 retrieval_score: score,
379 }
380 }
381
382 #[test]
383 fn max_evidence_cap_is_respected() {
384 let cands = vec![
385 cand("alpha one", 0.9),
386 cand("beta two", 0.8),
387 cand("gamma three", 0.7),
388 cand("delta four", 0.6),
389 ];
390 let budget = EvidenceBudget::capped(2);
391 let sel = select_within_budget(&cands, &budget, &CosineScorer, "q", None);
392 assert_eq!(sel.keep, 2, "cap must bound the returned count");
393 assert!(sel.report.capped);
394 assert!(!sel.report.stopped_early);
395 }
396
397 #[test]
398 fn early_stop_fires_at_threshold() {
399 // Two 0.5 chunks clear a 0.8 cumulative bar after the second.
400 let cands = vec![
401 cand("alpha", 0.5),
402 cand("beta", 0.5),
403 cand("gamma", 0.5),
404 cand("delta", 0.5),
405 ];
406 let budget = EvidenceBudget::early_stop(0.8);
407 let sel = select_within_budget(&cands, &budget, &CosineScorer, "q", None);
408 assert_eq!(sel.keep, 2, "should stop after cumulative 1.0 clears 0.8");
409 assert!(sel.report.stopped_early);
410 assert!(sel.report.cumulative_score >= 0.8);
411 }
412
413 #[test]
414 fn early_stop_one_strong_chunk_clears_bar() {
415 let cands = vec![cand("alpha", 0.85), cand("beta", 0.85), cand("gamma", 0.85)];
416 let budget = EvidenceBudget::early_stop(0.8);
417 let sel = select_within_budget(&cands, &budget, &CosineScorer, "q", None);
418 assert_eq!(sel.keep, 1, "a single 0.85 chunk clears the 0.8 bar");
419 assert!(sel.report.stopped_early);
420 }
421
422 #[test]
423 fn scorer_trait_is_swappable() {
424 // The stub DeltaScorer penalises redundant content, so two
425 // identical chunks accumulate slower than two distinct chunks.
426 let distinct = vec![cand("alpha one", 0.9), cand("beta two", 0.9)];
427 let redundant = vec![cand("alpha one", 0.9), cand("alpha one", 0.9)];
428 let budget = EvidenceBudget {
429 stop_when_sufficient: true,
430 sufficiency_threshold: 1.5,
431 scorer: ScorerKind::Delta,
432 ..Default::default()
433 };
434 let scorer = DeltaScorer::stub();
435
436 let s_distinct = select_within_budget(&distinct, &budget, &scorer, "q", None);
437 let s_redundant = select_within_budget(&redundant, &budget, &scorer, "q", None);
438
439 // Distinct chunks contribute more novelty, so the cumulative
440 // score after two chunks is strictly higher for the distinct
441 // set than the redundant set.
442 assert!(
443 s_distinct.report.cumulative_score > s_redundant.report.cumulative_score,
444 "delta scorer must reward novelty: distinct={} redundant={}",
445 s_distinct.report.cumulative_score,
446 s_redundant.report.cumulative_score
447 );
448 // And the swapped-in scorer is reported by name.
449 assert_eq!(s_distinct.report.scorer, "delta");
450 }
451
452 #[test]
453 fn injectable_closure_is_honoured() {
454 // A closure that always returns 1.0 clears any single-chunk bar
455 // immediately, proving the LLM-callback seam is live.
456 let scorer = DeltaScorer::new(|_ctx| 1.0);
457 let cands = vec![cand("alpha", 0.1), cand("beta", 0.1)];
458 let budget = EvidenceBudget {
459 stop_when_sufficient: true,
460 sufficiency_threshold: 0.9,
461 scorer: ScorerKind::Delta,
462 ..Default::default()
463 };
464 let sel = select_within_budget(&cands, &budget, &scorer, "q", None);
465 assert_eq!(
466 sel.keep, 1,
467 "closure scoring 1.0 clears 0.9 after one chunk"
468 );
469 }
470
471 #[test]
472 fn no_budget_keeps_everything() {
473 let cands = vec![cand("a", 0.9), cand("b", 0.8), cand("c", 0.7)];
474 let budget = EvidenceBudget::default();
475 let sel = select_within_budget(&cands, &budget, &CosineScorer, "q", None);
476 assert_eq!(sel.keep, 3, "default budget caps nothing and never stops");
477 assert!(!sel.report.capped);
478 assert!(!sel.report.stopped_early);
479 }
480
481 // ---- Property: a larger budget never reorders or drops a
482 // higher-ranked item that a smaller budget kept. Because the
483 // selector only ever returns a prefix, keep(b1) <= keep(b2) for
484 // b1.max_evidence <= b2.max_evidence, and the kept set of the
485 // smaller budget is always a prefix of the larger's.
486 #[test]
487 fn property_larger_budget_is_prefix_superset() {
488 // Deterministic pseudo-random candidate scores via a simple
489 // LCG so the test stays reproducible without a rng dep.
490 let mut state: u64 = 0x9E3779B97F4A7C15;
491 let mut next = || {
492 state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
493 ((state >> 33) as f32) / (u32::MAX as f32)
494 };
495 let contents: Vec<String> = (0..40).map(|i| format!("chunk-{i}-token{i}")).collect();
496 let mut cands: Vec<EvidenceCandidate<'_>> = contents
497 .iter()
498 .map(|c| EvidenceCandidate {
499 content: c.as_str(),
500 embedding: None,
501 retrieval_score: next(),
502 })
503 .collect();
504 // Caller contract: candidates arrive ranked. Sort desc.
505 cands.sort_by(|a, b| {
506 b.retrieval_score
507 .partial_cmp(&a.retrieval_score)
508 .unwrap_or(std::cmp::Ordering::Equal)
509 });
510
511 for small in 1..=cands.len() {
512 for large in small..=cands.len() {
513 let bs = EvidenceBudget::capped(small);
514 let bl = EvidenceBudget::capped(large);
515 let ss = select_within_budget(&cands, &bs, &CosineScorer, "q", None);
516 let sl = select_within_budget(&cands, &bl, &CosineScorer, "q", None);
517 // Larger budget keeps at least as many.
518 assert!(
519 sl.keep >= ss.keep,
520 "larger budget kept fewer: small={} large={} got {} vs {}",
521 small,
522 large,
523 sl.keep,
524 ss.keep
525 );
526 // The smaller budget's kept set is exactly the prefix
527 // of the larger's — i.e. ordering is preserved and no
528 // higher-ranked item is silently dropped.
529 for i in 0..ss.keep {
530 assert_eq!(
531 cands[i].content,
532 contents_sorted(&cands)[i],
533 "internal: index drift"
534 );
535 }
536 assert!(
537 ss.keep <= sl.keep,
538 "prefix invariant violated: {} > {}",
539 ss.keep,
540 sl.keep
541 );
542 }
543 }
544 }
545
546 // Helper kept intentionally trivial: the candidate slice IS its own
547 // ranked order, so the "sorted contents" are just the contents in
548 // place. Exists to make the prefix assertion above explicit.
549 fn contents_sorted<'a>(cands: &'a [EvidenceCandidate<'a>]) -> Vec<&'a str> {
550 cands.iter().map(|c| c.content).collect()
551 }
552}