Skip to main content

kimetsu_brain/
backend_bench.rs

1//! S5.4: Cross-backend benchmark harness.
2//!
3//! Runs the **same synthetic corpus** through `flat`, `graph-lite`, and
4//! (when the `graph` feature is active) `petgraph` backends, reporting
5//! recall@k / MRR / candidate-set size / latency (µs) per backend.
6//!
7//! # Environment
8//!
9//! Full retrieval-quality numbers (precision vs. ground-truth relevant set)
10//! require either:
11//!   (a) A real memories corpus with known relevant sets, or
12//!   (b) An embedding model for semantic matching.
13//!
14//! Neither is present in the CI / local test environment (no Docker, no model
15//! weights). This harness therefore runs on a **synthetic FTS corpus** —
16//! deterministic keyword-keyed memories and queries — and measures:
17//!
18//! * **Recall@k (FTS-quality)**: fraction of seeded relevant memories that
19//!   appear in the top-k candidates. On this corpus FTS recall is the primary
20//!   signal; semantic recall (embedding + ANN) is skipped.
21//! * **Candidate set size**: how many additional candidates graph expansion adds
22//!   over flat — a proxy for graph-vs-flat coverage delta.
23//! * **Latency (µs)**: wall-clock time for `memory_candidates` per backend,
24//!   measured on the in-memory SQLite DB (no I/O noise).
25//!
26//! # Full-numbers note
27//!
28//! To get production-quality recall@k / MRR numbers:
29//! 1. Build with `--features embeddings` and run against a populated brain.db.
30//! 2. Use [`crate::eval::EvalFixture`] to define the ground-truth relevant sets.
31//! 3. Run `kstress local --matrix emb` to include the embedding + ANN path.
32//!
33//! The harness is structured so that full numbers slot in without API changes —
34//! [`BackendBenchResult`] already carries the fields that the embedding path
35//! would populate.
36//!
37//! # v2.5 Decision criterion
38//!
39//! See [`V25_DECISION_CRITERION`] for the documented criterion.
40
41use std::time::Instant;
42
43use rusqlite::Connection;
44
45use crate::eval::{mean, recall_at_k};
46use crate::schema;
47
48// ─── Decision criterion (S5.4 deliverable) ───────────────────────────────────
49
50/// Documented v2.5 decision criterion for the embedded graph DB question.
51///
52/// An embedded graph DB (Kùzu/Cozo) is justified at v2.5 ONLY IF ALL of the
53/// following hold:
54///
55/// 1. **petgraph materially beats graph-lite on recall@k** — concretely, more
56///    than 5 pp improvement in recall@10 on the production eval corpus (full
57///    embedding + ANN path), consistently across ≥ 20 query cases. A 0–2 pp
58///    difference is noise and does not justify the complexity.
59///
60/// 2. **In-memory graph size exceeds safe RAM budget** — at v2.x corpus sizes
61///    (< 100k memories) a `petgraph::Graph<String,String>` uses roughly
62///    `n_nodes * 80B + n_edges * 64B` ≈ 40 MB at 500k nodes/1M edges. If
63///    production corpora exceed 1M memories, in-memory petgraph becomes
64///    impractical and an embedded graph DB provides the necessary
65///    memory-mapped storage + native graph queries.
66///
67/// 3. **Graph algorithm latency matters for SLA** — if centrality / community
68///    detection / shortest-path queries become a hot path (e.g., real-time
69///    consolidation triggers), Kùzu/Cozo's native Cypher/Datalog engine will
70///    outperform petgraph's ad-hoc Rust traversals. At < 10 queries/sec with
71///    async scheduling, this is unlikely to matter.
72///
73/// **Current spike result (no embedding environment)**:
74///   * On a 50-memory synthetic FTS corpus, petgraph expands the candidate set
75///     by the same amount as graph-lite (same edge traversal semantics, same
76///     MAX_HOPS/MAX_FAN_OUT). Recall@k is identical.
77///   * petgraph BFS is ~5–20 µs faster than graph-lite's iterative SQLite per-hop
78///     queries at small scale; at 10k+ memories graph-lite's SQLite-backed BFS
79///     may become measurably slower, but that cross-over has NOT been measured.
80///   * RAM: < 1 MB at 50 nodes. At 100k memories ≈ 8 MB — well within budget.
81///
82/// **Conclusion for v2.5**: Kùzu/Cozo is NOT justified yet. The embedded
83/// petgraph in remote is sufficient for the 100k-memory scale. Revisit at v2.6
84/// if (a) corpus exceeds 500k memories or (b) the embedding eval shows > 5 pp
85/// recall lift for petgraph-specific algorithms (e.g., PPR-weighted expansion).
86pub 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// ─── Result types ─────────────────────────────────────────────────────────────
111
112/// Per-backend result from a single cross-backend benchmark run.
113#[derive(Debug, Clone)]
114pub struct BackendBenchResult {
115    /// Backend variant name: `"flat"`, `"graph-lite"`, `"petgraph"`.
116    pub backend: String,
117    /// Number of query cases evaluated.
118    pub n_cases: usize,
119    /// Mean recall@5 across all cases (range [0, 1]).
120    pub mean_recall_at_5: f64,
121    /// Mean recall@10 across all cases (range [0, 1]).
122    pub mean_recall_at_10: f64,
123    /// Mean candidate set size (total candidates returned per query).
124    pub mean_candidate_count: f64,
125    /// Mean latency in microseconds per `memory_candidates` call.
126    pub mean_latency_us: f64,
127    /// P99 latency in microseconds.
128    pub p99_latency_us: f64,
129}
130
131// ─── Synthetic corpus helpers ─────────────────────────────────────────────────
132
133/// Seed the in-memory DB with `n` keyword-keyed memories.
134///
135/// Each memory text is `"keyword_{bucket} fact about rust tooling number {i}"`.
136/// Returns the list of (memory_id, bucket) pairs for ground-truth construction.
137fn seed_synthetic_corpus(conn: &Connection, n: usize) -> Vec<(String, usize)> {
138    let buckets = 10usize; // keyword space
139    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
164/// Seed some edges: chain memories within the same bucket via `supersedes` edges.
165///
166/// For each bucket: mem-{0}, mem-{10}, mem-{20}, … are chained.
167/// This lets graph-lite and petgraph expansion find connected memories that FTS
168/// might not surface (when the query only matches the first node in the chain).
169fn 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        // Chain: [0] → [1] → [2] → ...
178        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
189/// Build query cases for the synthetic corpus.
190///
191/// Each case: query = `"keyword_{bucket} rust"`, relevant = all mem-{i} where
192/// i % 10 == bucket.
193fn 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
208// ─── Per-backend runner ────────────────────────────────────────────────────────
209
210/// Run `n_queries` cases against `backend` on `conn`, returning per-case metrics.
211fn 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        // Extract ranked memory ids from the candidate set.
232        // Candidates are ordered by the backend: flat hits first (by raw_relevance
233        // order from FTS), graph-reached appended. We use this order for recall@k.
234        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
262// ─── Public entry point ───────────────────────────────────────────────────────
263
264/// Run the cross-backend benchmark on a fresh in-memory SQLite DB.
265///
266/// Seeds `corpus_size` synthetic memories (default: 50), runs `n_queries` query
267/// cases (default: 10), and returns a `Vec<BackendBenchResult>` — one per
268/// backend (`flat`, `graph-lite`, and optionally `petgraph`).
269///
270/// This is the S5.4 harness. See module-level docs and [`V25_DECISION_CRITERION`]
271/// for context and interpretation.
272pub 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    // Seed corpus + edges.
277    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    // ── flat ──────────────────────────────────────────────────────────────────
284    {
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    // ── graph-lite ────────────────────────────────────────────────────────────
301    {
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    // ── petgraph (only when `graph` feature is active) ────────────────────────
318    #[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
342/// Format a `Vec<BackendBenchResult>` as a markdown table.
343///
344/// Suitable for logging to stderr or writing to a report file.
345pub 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// ─── Tests ───────────────────────────────────────────────────────────────────
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    /// S5.4-A: the bench runs to completion without panicking and returns
386    /// results for all compiled backends.
387    #[test]
388    fn cross_backend_bench_runs_without_panic() {
389        let results = run_cross_backend_bench(20, 10);
390        // Always at minimum flat + graph-lite.
391        assert!(
392            results.len() >= 2,
393            "must have at least flat and graph-lite results"
394        );
395        // When the `graph` feature is on, we also get petgraph.
396        #[cfg(feature = "graph")]
397        assert_eq!(
398            results.len(),
399            3,
400            "with `graph` feature: flat + graph-lite + petgraph"
401        );
402    }
403
404    /// S5.4-B: backend names are correct and in the right order.
405    #[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    /// S5.4-C: graph-lite candidate count >= flat candidate count.
415    /// (graph-lite ⊇ flat — the graph superset property must hold.)
416    #[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    /// S5.4-D: petgraph candidate count == graph-lite candidate count.
431    /// (Same BFS semantics, same MAX_HOPS/MAX_FAN_OUT, same edge data.)
432    #[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    /// S5.4-E: recall@10 for graph-lite >= recall@10 for flat (superset property).
448    #[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    /// S5.4-F: format_results_markdown returns non-empty string with headers.
463    #[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    /// S5.4-G: V25_DECISION_CRITERION documents the conclusion.
474    #[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}