gemini_memory_rs/bm25/
explain.rs1use serde::{Deserialize, Serialize};
8use std::fmt::Write as _;
9
10use super::schema::Field;
11use crate::core::MemoryId;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct ScoreComponent {
16 pub term: String,
18 pub field: Field,
20 pub score: f32,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum BoostKind {
28 ExactEntity,
30 ExplicitSource,
32 SessionOverlay,
34 Confidence,
36 LowConfidencePenalty,
38 Recency,
40}
41
42impl BoostKind {
43 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct SearchExplanation {
59 pub memory_id: MemoryId,
61 pub components: Vec<ScoreComponent>,
63 pub boosts: Vec<(BoostKind, f32)>,
65 pub lexical_score: f32,
67 pub final_score: f32,
69}
70
71impl SearchExplanation {
72 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 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 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}