1use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::store::db::Database;
12use crate::store::memory::{CreateMemoryInput, Scope, SearchQuery};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct BenchmarkScenario {
17 pub name: String,
19 pub description: Option<String>,
21 pub project: String,
23 #[serde(default)]
25 pub seed_memories: Vec<BenchmarkSeedMemory>,
26 pub queries: Vec<BenchmarkQuery>,
28}
29
30#[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#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BenchmarkQuery {
43 pub query: String,
45 #[serde(default)]
48 pub expected_titles: Vec<String>,
49 #[serde(default)]
51 pub expected_keywords: Vec<String>,
52 #[serde(default)]
54 pub expected_rank: u32,
55 #[serde(default = "default_k")]
57 pub k: u32,
58}
59
60fn default_k() -> u32 {
61 5
62}
63
64#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct BenchmarkMetrics {
76 pub mrr: f64,
78 pub precision_at_k: f64,
80 pub recall_at_k: f64,
82 pub hit_rate: f64,
84 pub f1_at_k: f64,
86 pub avg_latency_ms: f64,
88}
89
90#[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
105pub 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 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 toml::from_str(&content)
124 .map_err(|e| crate::error::MnemeError::Config(format!("TOML parse error: {}", e)))?
125 };
126 Ok(scenario)
127 }
128
129 pub fn run(&self, scenario: &BenchmarkScenario) -> crate::error::Result<BenchmarkResult> {
131 let memories = self.db.memories();
133 let project = scenario.project.clone();
134
135 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 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 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
295pub 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
378pub 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}