Skip to main content

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}