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 v3.0 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) {
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 let (r5, r10, counts, lats) = run_backend(&conn, &cases, &backend);
287 results.push(BackendBenchResult {
288 backend: "flat".to_string(),
289 n_cases: r5.len(),
290 mean_recall_at_5: mean(&r5),
291 mean_recall_at_10: mean(&r10),
292 mean_candidate_count: mean(&counts),
293 mean_latency_us: mean(&lats),
294 p99_latency_us: percentile(lats, 99.0),
295 });
296 }
297
298 {
300 let backend = crate::backend::GraphLiteBackend;
301 let (r5, r10, counts, lats) = run_backend(&conn, &cases, &backend);
302 results.push(BackendBenchResult {
303 backend: "graph-lite".to_string(),
304 n_cases: r5.len(),
305 mean_recall_at_5: mean(&r5),
306 mean_recall_at_10: mean(&r10),
307 mean_candidate_count: mean(&counts),
308 mean_latency_us: mean(&lats),
309 p99_latency_us: percentile(lats, 99.0),
310 });
311 }
312
313 #[cfg(feature = "graph")]
315 {
316 match crate::backend::PetgraphBackend::from_conn(&conn) {
317 Ok(backend) => {
318 let (r5, r10, counts, lats) = run_backend(&conn, &cases, &backend);
319 results.push(BackendBenchResult {
320 backend: "petgraph".to_string(),
321 n_cases: r5.len(),
322 mean_recall_at_5: mean(&r5),
323 mean_recall_at_10: mean(&r10),
324 mean_candidate_count: mean(&counts),
325 mean_latency_us: mean(&lats),
326 p99_latency_us: percentile(lats, 99.0),
327 });
328 }
329 Err(e) => {
330 eprintln!("kimetsu-brain: petgraph bench: failed to build graph: {e}");
331 }
332 }
333 }
334
335 results
336}
337
338pub fn format_results_markdown(results: &[BackendBenchResult]) -> String {
342 let mut out = String::new();
343 out.push_str("## S5.4 Cross-backend benchmark results\n\n");
344 out.push_str(
345 "| backend | n_cases | recall@5 | recall@10 | mean_candidates | mean_µs | p99_µs |\n",
346 );
347 out.push_str(
348 "|---------|---------|----------|-----------|-----------------|---------|--------|\n",
349 );
350 for r in results {
351 out.push_str(&format!(
352 "| {} | {} | {:.3} | {:.3} | {:.1} | {:.1} | {:.1} |\n",
353 r.backend,
354 r.n_cases,
355 r.mean_recall_at_5,
356 r.mean_recall_at_10,
357 r.mean_candidate_count,
358 r.mean_latency_us,
359 r.p99_latency_us,
360 ));
361 }
362 out.push('\n');
363 out.push_str("### Environment note\n");
364 out.push_str(
365 "These numbers are from a **synthetic FTS corpus** (in-memory SQLite, no embedding \
366 model). Recall@k reflects FTS keyword matching only. Semantic recall (embedding + ANN) \
367 is absent: run `--features embeddings` against a real brain.db with an EvalFixture \
368 for production-quality numbers.\n\n",
369 );
370 out.push_str("### v2.5 decision criterion\n\n");
371 out.push_str(V25_DECISION_CRITERION);
372 out
373}
374
375#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
384 fn cross_backend_bench_runs_without_panic() {
385 let results = run_cross_backend_bench(20, 10);
386 assert!(
388 results.len() >= 2,
389 "must have at least flat and graph-lite results"
390 );
391 #[cfg(feature = "graph")]
393 assert_eq!(
394 results.len(),
395 3,
396 "with `graph` feature: flat + graph-lite + petgraph"
397 );
398 }
399
400 #[test]
402 fn cross_backend_bench_backend_names() {
403 let results = run_cross_backend_bench(10, 5);
404 assert_eq!(results[0].backend, "flat");
405 assert_eq!(results[1].backend, "graph-lite");
406 #[cfg(feature = "graph")]
407 assert_eq!(results[2].backend, "petgraph");
408 }
409
410 #[test]
413 fn graph_lite_candidate_count_gte_flat() {
414 let results = run_cross_backend_bench(30, 10);
415 let flat = &results[0];
416 let graph_lite = &results[1];
417 assert!(
418 graph_lite.mean_candidate_count >= flat.mean_candidate_count,
419 "graph-lite must return at least as many candidates as flat; \
420 flat={:.1} graph-lite={:.1}",
421 flat.mean_candidate_count,
422 graph_lite.mean_candidate_count,
423 );
424 }
425
426 #[cfg(feature = "graph")]
429 #[test]
430 fn petgraph_candidate_count_equals_graph_lite() {
431 let results = run_cross_backend_bench(30, 10);
432 let graph_lite = &results[1];
433 let petgraph = &results[2];
434 assert!(
435 (petgraph.mean_candidate_count - graph_lite.mean_candidate_count).abs() < 1.0,
436 "petgraph and graph-lite must return the same candidate count (same BFS semantics); \
437 graph-lite={:.1} petgraph={:.1}",
438 graph_lite.mean_candidate_count,
439 petgraph.mean_candidate_count,
440 );
441 }
442
443 #[test]
445 fn graph_lite_recall_gte_flat() {
446 let results = run_cross_backend_bench(30, 10);
447 let flat = &results[0];
448 let graph_lite = &results[1];
449 assert!(
450 graph_lite.mean_recall_at_10 >= flat.mean_recall_at_10 - 1e-9,
451 "graph-lite recall@10 must be >= flat recall@10; \
452 flat={:.3} graph-lite={:.3}",
453 flat.mean_recall_at_10,
454 graph_lite.mean_recall_at_10,
455 );
456 }
457
458 #[test]
460 fn format_results_markdown_includes_headers() {
461 let results = run_cross_backend_bench(10, 5);
462 let md = format_results_markdown(&results);
463 assert!(md.contains("S5.4 Cross-backend benchmark results"));
464 assert!(md.contains("recall@5"));
465 assert!(md.contains("v2.5 decision criterion"));
466 assert!(md.contains("VERDICT"));
467 }
468
469 #[test]
471 fn v25_decision_criterion_documents_verdict() {
472 assert!(V25_DECISION_CRITERION.contains("VERDICT"));
473 assert!(V25_DECISION_CRITERION.contains("NOT justified"));
474 }
475}