gemini_memory_rs/bm25/
explain.rs

1//! Search explanation.
2//!
3//! "Why did B say that?" has to be answerable. Every hit carries the term-level
4//! and boost-level breakdown that produced its score, so a surprising retrieval
5//! can be traced to a field match or a ranking signal rather than guessed at.
6
7use serde::{Deserialize, Serialize};
8use std::fmt::Write as _;
9
10use super::schema::Field;
11use crate::core::MemoryId;
12
13/// One term's contribution from one field.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct ScoreComponent {
16    /// The matched term.
17    pub term: String,
18    /// The field it matched in.
19    pub field: Field,
20    /// Weighted BM25 contribution.
21    pub score: f32,
22}
23
24/// A non-lexical ranking signal.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum BoostKind {
28    /// The query named this record's subject or an entity it mentions.
29    ExactEntity,
30    /// The record rests on something the user said outright.
31    ExplicitSource,
32    /// The record was learned in the current session.
33    SessionOverlay,
34    /// Scaled by the record's aggregated confidence.
35    Confidence,
36    /// Applied to records below the confidence floor.
37    LowConfidencePenalty,
38    /// Time decay for episodic and non-persistent records.
39    Recency,
40}
41
42impl BoostKind {
43    /// A short label for rendered explanations.
44    pub fn label(self) -> &'static str {
45        match self {
46            Self::ExactEntity => "exact entity match",
47            Self::ExplicitSource => "explicit source",
48            Self::SessionOverlay => "current-session fact",
49            Self::Confidence => "confidence",
50            Self::LowConfidencePenalty => "low-confidence penalty",
51            Self::Recency => "recency",
52        }
53    }
54}
55
56/// The full derivation of one hit's score.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct SearchExplanation {
59    /// Which record this explains.
60    pub memory_id: MemoryId,
61    /// Per-term, per-field lexical contributions.
62    pub components: Vec<ScoreComponent>,
63    /// Ranking signals applied on top.
64    pub boosts: Vec<(BoostKind, f32)>,
65    /// Sum of the lexical contributions.
66    pub lexical_score: f32,
67    /// Score after boosts.
68    pub final_score: f32,
69}
70
71impl SearchExplanation {
72    /// Render the derivation as aligned text, for `explain-search` output.
73    pub fn render(&self) -> String {
74        let mut out = String::new();
75        let _ = writeln!(out, "candidate {}:", self.memory_id);
76        let mut components = self.components.clone();
77        components.sort_by(|a, b| {
78            b.score
79                .partial_cmp(&a.score)
80                .unwrap_or(std::cmp::Ordering::Equal)
81        });
82        for component in &components {
83            let _ = writeln!(
84                out,
85                "  {:<28} {:>6.2}",
86                format!("{} ({})", component.term, component.field.label()),
87                component.score
88            );
89        }
90        let _ = writeln!(
91            out,
92            "  {:<28} {:>6.2}",
93            "lexical subtotal", self.lexical_score
94        );
95        for (kind, amount) in &self.boosts {
96            let _ = writeln!(out, "  {:<28} {:>+6.2}", kind.label(), amount);
97        }
98        let _ = writeln!(out, "  {:<28} {:>6.2}", "final score", self.final_score);
99        out
100    }
101
102    /// The single strongest lexical contribution, if any.
103    pub fn top_component(&self) -> Option<&ScoreComponent> {
104        self.components.iter().max_by(|a, b| {
105            a.score
106                .partial_cmp(&b.score)
107                .unwrap_or(std::cmp::Ordering::Equal)
108        })
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn explanation() -> SearchExplanation {
117        SearchExplanation {
118            memory_id: MemoryId::new("mem_17"),
119            components: vec![
120                ScoreComponent {
121                    term: "restaurant".into(),
122                    field: Field::Statement,
123                    score: 5.9,
124                },
125                ScoreComponent {
126                    term: "wife".into(),
127                    field: Field::Aliases,
128                    score: 2.5,
129                },
130            ],
131            boosts: vec![(BoostKind::ExactEntity, 3.0), (BoostKind::Confidence, 0.4)],
132            lexical_score: 8.4,
133            final_score: 11.8,
134        }
135    }
136
137    #[test]
138    fn renders_a_readable_derivation() {
139        let rendered = explanation().render();
140        assert!(rendered.contains("candidate mem_17:"));
141        assert!(rendered.contains("restaurant (statement)"));
142        assert!(rendered.contains("exact entity match"));
143        assert!(rendered.contains("final score"));
144        // Strongest contribution is listed first.
145        let restaurant = rendered.find("restaurant").unwrap();
146        let wife = rendered.find("wife").unwrap();
147        assert!(restaurant < wife);
148    }
149
150    #[test]
151    fn reports_the_strongest_component() {
152        assert_eq!(explanation().top_component().unwrap().term, "restaurant");
153    }
154}