Skip to main content

mneme/
bench.rs

1//! Memory benchmarks para evaluar la calidad del retrieval.
2//!
3//! Inspirado por Mem0's LoCoMo / LongMemEval / BEAM. Permite definir
4//! escenarios de prueba (memorias a cargar + preguntas con expected answers)
5//! y medir métricas como precision@k, recall@k, MRR, y faithfulness.
6
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::store::db::Database;
12use crate::store::memory::{CreateMemoryInput, Scope, SearchQuery};
13
14/// Un escenario de benchmark: memorias semilla + preguntas con respuestas esperadas.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct BenchmarkScenario {
17    /// Nombre del escenario (ej: "rust-decisions", "auth-patterns").
18    pub name: String,
19    /// Descripción opcional.
20    pub description: Option<String>,
21    /// Proyecto al que se cargan las memorias de prueba.
22    pub project: String,
23    /// Memorias semilla a cargar antes de evaluar.
24    #[serde(default)]
25    pub seed_memories: Vec<BenchmarkSeedMemory>,
26    /// Preguntas de evaluación.
27    pub queries: Vec<BenchmarkQuery>,
28}
29
30/// Una memoria semilla para el benchmark.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct BenchmarkSeedMemory {
33    pub title: String,
34    pub content: String,
35    pub memory_type: String,
36    pub importance: String,
37    pub tags: Vec<String>,
38}
39
40/// Una pregunta con respuestas esperadas.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BenchmarkQuery {
43    /// El query de búsqueda.
44    pub query: String,
45    /// IDs de memorias que se esperan encontrar (1-indexed por orden en seed).
46    /// Permite expected_memory_id o expected_keywords.
47    #[serde(default)]
48    pub expected_titles: Vec<String>,
49    /// Keywords que deberían aparecer en al menos un resultado top-k.
50    #[serde(default)]
51    pub expected_keywords: Vec<String>,
52    /// Posición esperada (1-indexed) en los resultados. 0 = cualquier posición.
53    #[serde(default)]
54    pub expected_rank: u32,
55    /// Profundidad k para métricas (default 5).
56    #[serde(default = "default_k")]
57    pub k: u32,
58}
59
60fn default_k() -> u32 {
61    5
62}
63
64/// Resultados de un escenario ejecutado.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct BenchmarkResult {
67    pub scenario_name: String,
68    pub total_queries: u32,
69    pub metrics: BenchmarkMetrics,
70    pub per_query: Vec<QueryResult>,
71}
72
73/// Métricas agregadas.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct BenchmarkMetrics {
76    /// Mean Reciprocal Rank @ k
77    pub mrr: f64,
78    /// Precision @ k: promedio de (relevantos en top-k) / k
79    pub precision_at_k: f64,
80    /// Recall @ k: promedio de (relevantos en top-k) / total relevante
81    pub recall_at_k: f64,
82    /// Hit rate @ k: promedio de queries con al menos 1 relevante en top-k
83    pub hit_rate: f64,
84    /// F1 @ k: promedio de F1 scores
85    pub f1_at_k: f64,
86    /// Latencia promedio de búsqueda (ms)
87    pub avg_latency_ms: f64,
88}
89
90/// Resultado individual de un query.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct QueryResult {
93    pub query: String,
94    pub k: u32,
95    pub relevant_found: u32,
96    pub total_relevant: u32,
97    pub reciprocal_rank: f64,
98    pub precision: f64,
99    pub recall: f64,
100    pub hit: bool,
101    pub latency_ms: u64,
102    pub top_titles: Vec<String>,
103}
104
105/// Runner de benchmarks.
106pub struct BenchmarkRunner {
107    db: std::sync::Arc<Database>,
108}
109
110impl BenchmarkRunner {
111    pub fn new(db: std::sync::Arc<Database>) -> Self {
112        Self { db }
113    }
114
115    /// Carga un escenario desde un archivo TOML o JSON.
116    pub fn load_scenario(&self, path: &Path) -> crate::error::Result<BenchmarkScenario> {
117        let content = std::fs::read_to_string(path)?;
118        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
119        let scenario = if ext == "json" {
120            serde_json::from_str(&content)?
121        } else {
122            // Default to TOML
123            toml::from_str(&content)
124                .map_err(|e| crate::error::MnemeError::Config(format!("TOML parse error: {}", e)))?
125        };
126        Ok(scenario)
127    }
128
129    /// Ejecuta un escenario: carga memorias, corre queries, mide métricas.
130    pub fn run(&self, scenario: &BenchmarkScenario) -> crate::error::Result<BenchmarkResult> {
131        // 1. Cargar memorias semilla
132        let memories = self.db.memories();
133        let project = scenario.project.clone();
134
135        // Limpiar memorias existentes del proyecto (para reproducibilidad)
136        let _ = memories.forget_project(&project);
137
138        for seed in &scenario.seed_memories {
139            let memory_type = seed
140                .memory_type
141                .parse()
142                .unwrap_or(crate::store::memory::MemoryType::Note);
143            let importance = seed
144                .importance
145                .parse()
146                .unwrap_or(crate::store::memory::Importance::Medium);
147            let input = CreateMemoryInput {
148                project: project.clone(),
149                scope: Some(Scope::Project),
150                title: seed.title.clone(),
151                content: seed.content.clone(),
152                what: None,
153                why: None,
154                context: None,
155                learned: None,
156                memory_type,
157                importance,
158                tags: seed.tags.clone(),
159                topic_key: None,
160                capture_prompt: None,
161                encrypt: false,
162                valid_from: None,
163                valid_until: None,
164                provenance: Some("benchmark/seed".to_string()),
165            };
166            memories.save(input, None, None)?;
167        }
168
169        // 2. Ejecutar cada query
170        let mut per_query = Vec::new();
171        let mut sum_mrr = 0.0;
172        let mut sum_precision = 0.0;
173        let mut sum_recall = 0.0;
174        let mut sum_hit = 0u32;
175        let mut sum_f1 = 0.0;
176        let mut sum_latency = 0u64;
177
178        for q in &scenario.queries {
179            let k = if q.k == 0 { 5 } else { q.k };
180            let start = std::time::Instant::now();
181            let search_query = SearchQuery {
182                text: q.query.clone(),
183                project: Some(project.clone()),
184                scope: Some(Scope::Project),
185                memory_type: None,
186                importance: None,
187                tags: Vec::new(),
188                limit: k,
189                include_snippet: false,
190                all_projects: false,
191            };
192            let weights = crate::store::search::SearchWeights::default();
193            let results = memories.search(&search_query, &weights, None)?;
194            let latency_ms = start.elapsed().as_millis() as u64;
195
196            let top_titles: Vec<String> = results
197                .iter()
198                .take(k as usize)
199                .map(|r| r.memory.title.clone())
200                .collect();
201
202            // Calcular relevance
203            let mut relevant_found = 0u32;
204            let mut reciprocal_rank = 0.0;
205            for (idx, r) in results.iter().take(k as usize).enumerate() {
206                let title = &r.memory.title;
207                let is_relevant = q.expected_titles.iter().any(|t| t == title)
208                    || q.expected_keywords.iter().any(|kw| {
209                        r.memory.content.to_lowercase().contains(&kw.to_lowercase())
210                            || r.memory.title.to_lowercase().contains(&kw.to_lowercase())
211                    });
212                if is_relevant {
213                    relevant_found += 1;
214                    if reciprocal_rank == 0.0 {
215                        reciprocal_rank = 1.0 / (idx as f64 + 1.0);
216                    }
217                }
218            }
219
220            let total_relevant = if q.expected_titles.is_empty() && q.expected_keywords.is_empty() {
221                0
222            } else {
223                q.expected_titles.len().max(q.expected_keywords.len()) as u32
224            };
225
226            let precision = if k > 0 {
227                relevant_found as f64 / k as f64
228            } else {
229                0.0
230            };
231            let recall = if total_relevant > 0 {
232                relevant_found as f64 / total_relevant as f64
233            } else if relevant_found > 0 {
234                1.0
235            } else {
236                0.0
237            };
238            let f1 = if precision + recall > 0.0 {
239                2.0 * precision * recall / (precision + recall)
240            } else {
241                0.0
242            };
243            let hit = relevant_found > 0;
244
245            sum_mrr += reciprocal_rank;
246            sum_precision += precision;
247            sum_recall += recall;
248            if hit {
249                sum_hit += 1;
250            }
251            sum_f1 += f1;
252            sum_latency += latency_ms;
253
254            per_query.push(QueryResult {
255                query: q.query.clone(),
256                k,
257                relevant_found,
258                total_relevant,
259                reciprocal_rank,
260                precision,
261                recall,
262                hit,
263                latency_ms,
264                top_titles,
265            });
266        }
267
268        let total = scenario.queries.len() as f64;
269        let metrics = if total > 0.0 {
270            BenchmarkMetrics {
271                mrr: sum_mrr / total,
272                precision_at_k: sum_precision / total,
273                recall_at_k: sum_recall / total,
274                hit_rate: sum_hit as f64 / total,
275                f1_at_k: sum_f1 / total,
276                avg_latency_ms: if total > 0.0 {
277                    sum_latency as f64 / total
278                } else {
279                    0.0
280                },
281            }
282        } else {
283            BenchmarkMetrics::default()
284        };
285
286        Ok(BenchmarkResult {
287            scenario_name: scenario.name.clone(),
288            total_queries: scenario.queries.len() as u32,
289            metrics,
290            per_query,
291        })
292    }
293}
294
295/// Genera un escenario de benchmark de ejemplo para Rust.
296pub fn example_rust_scenario() -> BenchmarkScenario {
297    BenchmarkScenario {
298        name: "rust-decisions".to_string(),
299        description: Some("Evalúa retrieval de decisiones arquitectónicas sobre Rust".to_string()),
300        project: "bench-rust".to_string(),
301        seed_memories: vec![
302            BenchmarkSeedMemory {
303                title: "Async runtime: tokio vs async-std".to_string(),
304                content: "Decidimos usar tokio como runtime async por mejor ecosystem, tracing integrado, y amplia adoption en la comunidad Rust.".to_string(),
305                memory_type: "decision".to_string(),
306                importance: "high".to_string(),
307                tags: vec!["rust".to_string(), "async".to_string(), "architecture".to_string()],
308            },
309            BenchmarkSeedMemory {
310                title: "Error handling: anyhow vs thiserror".to_string(),
311                content: "Para errores de library usamos thiserror (typed errors), para binarios usamos anyhow (con context).".to_string(),
312                memory_type: "decision".to_string(),
313                importance: "medium".to_string(),
314                tags: vec!["rust".to_string(), "errors".to_string()],
315            },
316            BenchmarkSeedMemory {
317                title: "Web framework: axum vs actix-web".to_string(),
318                content: "Elegimos axum por mejor integración con tower ecosystem y menor curva de aprendizaje.".to_string(),
319                memory_type: "decision".to_string(),
320                importance: "high".to_string(),
321                tags: vec!["rust".to_string(), "web".to_string(), "architecture".to_string()],
322            },
323            BenchmarkSeedMemory {
324                title: "Database: rusqlite con FTS5".to_string(),
325                content: "Usamos rusqlite con FTS5 para full-text search nativo sin dependencias externas. Embeddings via fastembed ONNX.".to_string(),
326                memory_type: "decision".to_string(),
327                importance: "high".to_string(),
328                tags: vec!["rust".to_string(), "database".to_string(), "sqlite".to_string()],
329            },
330            BenchmarkSeedMemory {
331                title: "CLI: clap con derive".to_string(),
332                content: "Usamos clap v4 con derive macros para type-safe argument parsing.".to_string(),
333                memory_type: "convention".to_string(),
334                importance: "medium".to_string(),
335                tags: vec!["rust".to_string(), "cli".to_string()],
336            },
337        ],
338        queries: vec![
339            BenchmarkQuery {
340                query: "qué runtime async usamos".to_string(),
341                expected_titles: vec!["Async runtime: tokio vs async-std".to_string()],
342                expected_keywords: vec!["tokio".to_string()],
343                expected_rank: 1,
344                k: 3,
345            },
346            BenchmarkQuery {
347                query: "cómo manejamos errores".to_string(),
348                expected_titles: vec!["Error handling: anyhow vs thiserror".to_string()],
349                expected_keywords: vec!["anyhow".to_string(), "thiserror".to_string()],
350                expected_rank: 1,
351                k: 3,
352            },
353            BenchmarkQuery {
354                query: "framework web".to_string(),
355                expected_titles: vec!["Web framework: axum vs actix-web".to_string()],
356                expected_keywords: vec!["axum".to_string()],
357                expected_rank: 1,
358                k: 3,
359            },
360            BenchmarkQuery {
361                query: "base de datos sqlite fulltext".to_string(),
362                expected_titles: vec!["Database: rusqlite con FTS5".to_string()],
363                expected_keywords: vec!["rusqlite".to_string(), "fts5".to_string()],
364                expected_rank: 1,
365                k: 3,
366            },
367            BenchmarkQuery {
368                query: "cli argument parsing".to_string(),
369                expected_titles: vec!["CLI: clap con derive".to_string()],
370                expected_keywords: vec!["clap".to_string()],
371                expected_rank: 1,
372                k: 3,
373            },
374        ],
375    }
376}
377
378/// Reporta los resultados como tabla markdown.
379pub fn format_report(result: &BenchmarkResult) -> String {
380    let mut out = String::new();
381    out.push_str(&format!("# Benchmark: {}\n\n", result.scenario_name));
382    out.push_str(&format!("Queries ejecutados: {}\n\n", result.total_queries));
383    out.push_str("## Métricas agregadas\n\n");
384    out.push_str("| Métrica | Valor |\n|---------|-------|\n");
385    out.push_str(&format!("| MRR @ k | {:.4} |\n", result.metrics.mrr));
386    out.push_str(&format!(
387        "| Precision @ k | {:.4} |\n",
388        result.metrics.precision_at_k
389    ));
390    out.push_str(&format!(
391        "| Recall @ k | {:.4} |\n",
392        result.metrics.recall_at_k
393    ));
394    out.push_str(&format!(
395        "| Hit Rate @ k | {:.4} |\n",
396        result.metrics.hit_rate
397    ));
398    out.push_str(&format!("| F1 @ k | {:.4} |\n", result.metrics.f1_at_k));
399    out.push_str(&format!(
400        "| Avg latency (ms) | {:.2} |\n",
401        result.metrics.avg_latency_ms
402    ));
403    out.push_str("\n## Per-query\n\n");
404    out.push_str("| Query | k | Rel/Total | P | R | F1 | MRR | Hit | Latency |\n");
405    out.push_str("|-------|---|-----------|---|---|---|-----|-----|---------|\n");
406    for q in &result.per_query {
407        let f1 = if q.precision + q.recall > 0.0 {
408            2.0 * q.precision * q.recall / (q.precision + q.recall)
409        } else {
410            0.0
411        };
412        out.push_str(&format!(
413            "| {} | {} | {}/{} | {:.2} | {:.2} | {:.2} | {:.2} | {} | {}ms |\n",
414            truncate(&q.query, 30),
415            q.k,
416            q.relevant_found,
417            q.total_relevant,
418            q.precision,
419            q.recall,
420            f1,
421            q.reciprocal_rank,
422            if q.hit { "✓" } else { "✗" },
423            q.latency_ms,
424        ));
425    }
426    out
427}
428
429fn truncate(s: &str, max: usize) -> String {
430    if s.chars().count() <= max {
431        s.to_string()
432    } else {
433        let truncated: String = s.chars().take(max.saturating_sub(1)).collect();
434        format!("{}…", truncated)
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_example_scenario_loads() {
444        let scenario = example_rust_scenario();
445        assert_eq!(scenario.name, "rust-decisions");
446        assert_eq!(scenario.seed_memories.len(), 5);
447        assert_eq!(scenario.queries.len(), 5);
448    }
449
450    #[test]
451    fn test_metrics_default() {
452        let m = BenchmarkMetrics::default();
453        assert_eq!(m.mrr, 0.0);
454        assert_eq!(m.precision_at_k, 0.0);
455    }
456
457    #[test]
458    fn test_truncate() {
459        assert_eq!(truncate("short", 10), "short");
460        assert_eq!(truncate("a long string here", 10), "a long st…");
461    }
462
463    #[test]
464    fn test_format_report_includes_metrics() {
465        let result = BenchmarkResult {
466            scenario_name: "test".to_string(),
467            total_queries: 2,
468            metrics: BenchmarkMetrics {
469                mrr: 0.5,
470                precision_at_k: 0.6,
471                recall_at_k: 0.7,
472                hit_rate: 0.5,
473                f1_at_k: 0.65,
474                avg_latency_ms: 5.0,
475            },
476            per_query: vec![],
477        };
478        let report = format_report(&result);
479        assert!(report.contains("# Benchmark: test"));
480        assert!(report.contains("MRR @ k | 0.5000"));
481        assert!(report.contains("Precision @ k | 0.6000"));
482    }
483}