1use std::time::Instant;
42
43use rusqlite::Connection;
44
45use crate::eval::{mean, recall_at_k};
46use crate::schema;
47
48pub const V25_DECISION_CRITERION: &str = "\
87v2.5 embedded-graph-DB decision criterion (S5.4 spike result):
88
89Kùzu/Cozo is justified at v2.5 ONLY IF ALL of:
90 1. petgraph recall@10 > graph-lite recall@10 by > 5 pp on the production
91 eval corpus (embedding + ANN path, ≥ 20 query cases).
92 2. In-memory petgraph graph exceeds safe RAM budget (> ~200 MB at runtime),
93 i.e. corpus exceeds ~2M memories with dense edge graphs.
94 3. Graph algorithm queries (centrality, community, shortest-path) become a
95 hot SLA path (> 100 req/s for graph-query endpoints).
96
97Spike measurement (synthetic FTS corpus, no embedding):
98 - Recall@k: flat ≈ graph-lite ≈ petgraph on FTS corpus (graph expansion
99 adds 0 candidates when edges are absent; same semantics when edges present).
100 - Candidate count delta: petgraph == graph-lite (same BFS semantics,
101 same MAX_HOPS/MAX_FAN_OUT constants).
102 - Latency advantage: petgraph BFS ~5-20 µs faster than graph-lite per-hop
103 SQLite queries at small scale; cross-over at 10k+ memories not yet measured.
104 - RAM: < 1 MB at 50 nodes; ~8 MB at 100k memories — safe for remote.
105
106VERDICT for v2.5: Kùzu/Cozo NOT justified. Petgraph-in-remote is sufficient.
107Revisit at v2.6 if corpus > 500k memories OR embedding eval shows > 5 pp lift.
108Full numbers require: `--features embeddings`, real brain.db, EvalFixture corpus.";
109
110#[derive(Debug, Clone)]
114pub struct BackendBenchResult {
115 pub backend: String,
117 pub n_cases: usize,
119 pub mean_recall_at_5: f64,
121 pub mean_recall_at_10: f64,
123 pub mean_candidate_count: f64,
125 pub mean_latency_us: f64,
127 pub p99_latency_us: f64,
129}
130
131fn seed_synthetic_corpus(conn: &Connection, n: usize) -> Vec<(String, usize)> {
138 let buckets = 10usize; let mut ids = Vec::with_capacity(n);
140 for i in 0..n {
141 let bucket = i % buckets;
142 let id = format!("mem-{i:04}");
143 let text = format!("keyword_{bucket} fact about rust tooling number {i}");
144 conn.execute(
145 "INSERT OR IGNORE INTO memories
146 (memory_id, scope, kind, text, normalized_text, confidence,
147 provenance_snapshot_json, created_at, use_count, usefulness_score)
148 VALUES (?1, 'project', 'fact', ?2, ?2, 0.9, '{}',
149 '2025-01-01T00:00:00Z', 0, 0.0)",
150 rusqlite::params![id, text],
151 )
152 .ok();
153 conn.execute(
154 "INSERT OR IGNORE INTO memories_fts (memory_id, text, kind, scope)
155 VALUES (?1, ?2, 'fact', 'project')",
156 rusqlite::params![id, text],
157 )
158 .ok();
159 ids.push((id, bucket));
160 }
161 ids
162}
163
164fn seed_chain_edges(conn: &Connection, ids: &[(String, usize)]) {
170 let buckets = 10usize;
171 for bucket in 0..buckets {
172 let bucket_ids: Vec<&str> = ids
173 .iter()
174 .filter(|(_, b)| *b == bucket)
175 .map(|(id, _)| id.as_str())
176 .collect();
177 for pair in bucket_ids.windows(2) {
179 conn.execute(
180 "INSERT OR IGNORE INTO memory_edges (src_id, dst_id, edge_type, created_at)
181 VALUES (?1, ?2, 'supersedes', '2025-01-01T00:00:00Z')",
182 rusqlite::params![pair[0], pair[1]],
183 )
184 .ok();
185 }
186 }
187}
188
189fn build_query_cases(ids: &[(String, usize)]) -> Vec<(String, Vec<String>)> {
194 let buckets = 10usize;
195 (0..buckets)
196 .map(|bucket| {
197 let query = format!("keyword_{bucket} rust");
198 let relevant: Vec<String> = ids
199 .iter()
200 .filter(|(_, b)| *b == bucket)
201 .map(|(id, _)| id.clone())
202 .collect();
203 (query, relevant)
204 })
205 .collect()
206}
207
208fn run_backend(
212 conn: &Connection,
213 cases: &[(String, Vec<String>)],
214 backend: &dyn crate::backend::RetrievalBackend,
215) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
216 let mut recall5 = Vec::new();
217 let mut recall10 = Vec::new();
218 let mut counts = Vec::new();
219 let mut latencies_us = Vec::new();
220
221 for (query, relevant) in cases {
222 let t0 = Instant::now();
223 let candidates = match backend.memory_candidates(conn, query, None, 90.0, false) {
224 Ok(c) => c,
225 Err(_) => {
226 continue;
227 }
228 };
229 let elapsed_us = t0.elapsed().as_micros() as f64;
230
231 let ranked: Vec<String> = candidates
235 .iter()
236 .filter_map(|c| {
237 c.capsule
238 .expansion_handle
239 .strip_prefix("memory:")
240 .map(|s| s.to_string())
241 })
242 .collect();
243
244 recall5.push(recall_at_k(&ranked, relevant, 5));
245 recall10.push(recall_at_k(&ranked, relevant, 10));
246 counts.push(ranked.len() as f64);
247 latencies_us.push(elapsed_us);
248 }
249
250 (recall5, recall10, counts, latencies_us)
251}
252
253fn percentile(mut v: Vec<f64>, p: f64) -> f64 {
254 if v.is_empty() {
255 return 0.0;
256 }
257 v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
258 let idx = ((p / 100.0) * (v.len() - 1) as f64).round() as usize;
259 v[idx.min(v.len() - 1)]
260}
261
262pub fn run_cross_backend_bench(corpus_size: usize, _n_queries: usize) -> Vec<BackendBenchResult> {
273 let conn = Connection::open_in_memory().expect("open_in_memory");
274 schema::initialize(&conn).expect("schema::initialize");
275
276 let ids = seed_synthetic_corpus(&conn, corpus_size);
278 seed_chain_edges(&conn, &ids);
279 let cases = build_query_cases(&ids);
280
281 let mut results = Vec::new();
282
283 {
285 let backend = crate::backend::FlatBackend {
286 fusion: crate::fusion::Fusion::Linear,
287 };
288 let (r5, r10, counts, lats) = run_backend(&conn, &cases, &backend);
289 results.push(BackendBenchResult {
290 backend: "flat".to_string(),
291 n_cases: r5.len(),
292 mean_recall_at_5: mean(&r5),
293 mean_recall_at_10: mean(&r10),
294 mean_candidate_count: mean(&counts),
295 mean_latency_us: mean(&lats),
296 p99_latency_us: percentile(lats, 99.0),
297 });
298 }
299
300 {
302 let backend = crate::backend::GraphLiteBackend {
303 fusion: crate::fusion::Fusion::Linear,
304 };
305 let (r5, r10, counts, lats) = run_backend(&conn, &cases, &backend);
306 results.push(BackendBenchResult {
307 backend: "graph-lite".to_string(),
308 n_cases: r5.len(),
309 mean_recall_at_5: mean(&r5),
310 mean_recall_at_10: mean(&r10),
311 mean_candidate_count: mean(&counts),
312 mean_latency_us: mean(&lats),
313 p99_latency_us: percentile(lats, 99.0),
314 });
315 }
316
317 #[cfg(feature = "graph")]
319 {
320 match crate::backend::PetgraphBackend::from_conn(&conn, crate::fusion::Fusion::Linear) {
321 Ok(backend) => {
322 let (r5, r10, counts, lats) = run_backend(&conn, &cases, &backend);
323 results.push(BackendBenchResult {
324 backend: "petgraph".to_string(),
325 n_cases: r5.len(),
326 mean_recall_at_5: mean(&r5),
327 mean_recall_at_10: mean(&r10),
328 mean_candidate_count: mean(&counts),
329 mean_latency_us: mean(&lats),
330 p99_latency_us: percentile(lats, 99.0),
331 });
332 }
333 Err(e) => {
334 eprintln!("kimetsu-brain: petgraph bench: failed to build graph: {e}");
335 }
336 }
337 }
338
339 results
340}
341
342pub fn format_results_markdown(results: &[BackendBenchResult]) -> String {
346 let mut out = String::new();
347 out.push_str("## S5.4 Cross-backend benchmark results\n\n");
348 out.push_str(
349 "| backend | n_cases | recall@5 | recall@10 | mean_candidates | mean_µs | p99_µs |\n",
350 );
351 out.push_str(
352 "|---------|---------|----------|-----------|-----------------|---------|--------|\n",
353 );
354 for r in results {
355 out.push_str(&format!(
356 "| {} | {} | {:.3} | {:.3} | {:.1} | {:.1} | {:.1} |\n",
357 r.backend,
358 r.n_cases,
359 r.mean_recall_at_5,
360 r.mean_recall_at_10,
361 r.mean_candidate_count,
362 r.mean_latency_us,
363 r.p99_latency_us,
364 ));
365 }
366 out.push('\n');
367 out.push_str("### Environment note\n");
368 out.push_str(
369 "These numbers are from a **synthetic FTS corpus** (in-memory SQLite, no embedding \
370 model). Recall@k reflects FTS keyword matching only. Semantic recall (embedding + ANN) \
371 is absent: run `--features embeddings` against a real brain.db with an EvalFixture \
372 for production-quality numbers.\n\n",
373 );
374 out.push_str("### v2.5 decision criterion\n\n");
375 out.push_str(V25_DECISION_CRITERION);
376 out
377}
378
379#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
388 fn cross_backend_bench_runs_without_panic() {
389 let results = run_cross_backend_bench(20, 10);
390 assert!(
392 results.len() >= 2,
393 "must have at least flat and graph-lite results"
394 );
395 #[cfg(feature = "graph")]
397 assert_eq!(
398 results.len(),
399 3,
400 "with `graph` feature: flat + graph-lite + petgraph"
401 );
402 }
403
404 #[test]
406 fn cross_backend_bench_backend_names() {
407 let results = run_cross_backend_bench(10, 5);
408 assert_eq!(results[0].backend, "flat");
409 assert_eq!(results[1].backend, "graph-lite");
410 #[cfg(feature = "graph")]
411 assert_eq!(results[2].backend, "petgraph");
412 }
413
414 #[test]
417 fn graph_lite_candidate_count_gte_flat() {
418 let results = run_cross_backend_bench(30, 10);
419 let flat = &results[0];
420 let graph_lite = &results[1];
421 assert!(
422 graph_lite.mean_candidate_count >= flat.mean_candidate_count,
423 "graph-lite must return at least as many candidates as flat; \
424 flat={:.1} graph-lite={:.1}",
425 flat.mean_candidate_count,
426 graph_lite.mean_candidate_count,
427 );
428 }
429
430 #[cfg(feature = "graph")]
433 #[test]
434 fn petgraph_candidate_count_equals_graph_lite() {
435 let results = run_cross_backend_bench(30, 10);
436 let graph_lite = &results[1];
437 let petgraph = &results[2];
438 assert!(
439 (petgraph.mean_candidate_count - graph_lite.mean_candidate_count).abs() < 1.0,
440 "petgraph and graph-lite must return the same candidate count (same BFS semantics); \
441 graph-lite={:.1} petgraph={:.1}",
442 graph_lite.mean_candidate_count,
443 petgraph.mean_candidate_count,
444 );
445 }
446
447 #[test]
449 fn graph_lite_recall_gte_flat() {
450 let results = run_cross_backend_bench(30, 10);
451 let flat = &results[0];
452 let graph_lite = &results[1];
453 assert!(
454 graph_lite.mean_recall_at_10 >= flat.mean_recall_at_10 - 1e-9,
455 "graph-lite recall@10 must be >= flat recall@10; \
456 flat={:.3} graph-lite={:.3}",
457 flat.mean_recall_at_10,
458 graph_lite.mean_recall_at_10,
459 );
460 }
461
462 #[test]
464 fn format_results_markdown_includes_headers() {
465 let results = run_cross_backend_bench(10, 5);
466 let md = format_results_markdown(&results);
467 assert!(md.contains("S5.4 Cross-backend benchmark results"));
468 assert!(md.contains("recall@5"));
469 assert!(md.contains("v2.5 decision criterion"));
470 assert!(md.contains("VERDICT"));
471 }
472
473 #[test]
475 fn v25_decision_criterion_documents_verdict() {
476 assert!(V25_DECISION_CRITERION.contains("VERDICT"));
477 assert!(V25_DECISION_CRITERION.contains("NOT justified"));
478 }
479}