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 v3.0
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 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// ─── 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) {
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        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    // ── graph-lite ────────────────────────────────────────────────────────────
299    {
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    // ── petgraph (only when `graph` feature is active) ────────────────────────
314    #[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
338/// Format a `Vec<BackendBenchResult>` as a markdown table.
339///
340/// Suitable for logging to stderr or writing to a report file.
341pub 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// ─── Tests ───────────────────────────────────────────────────────────────────
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    /// S5.4-A: the bench runs to completion without panicking and returns
382    /// results for all compiled backends.
383    #[test]
384    fn cross_backend_bench_runs_without_panic() {
385        let results = run_cross_backend_bench(20, 10);
386        // Always at minimum flat + graph-lite.
387        assert!(
388            results.len() >= 2,
389            "must have at least flat and graph-lite results"
390        );
391        // When the `graph` feature is on, we also get petgraph.
392        #[cfg(feature = "graph")]
393        assert_eq!(
394            results.len(),
395            3,
396            "with `graph` feature: flat + graph-lite + petgraph"
397        );
398    }
399
400    /// S5.4-B: backend names are correct and in the right order.
401    #[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    /// S5.4-C: graph-lite candidate count >= flat candidate count.
411    /// (graph-lite ⊇ flat — the graph superset property must hold.)
412    #[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    /// S5.4-D: petgraph candidate count == graph-lite candidate count.
427    /// (Same BFS semantics, same MAX_HOPS/MAX_FAN_OUT, same edge data.)
428    #[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    /// S5.4-E: recall@10 for graph-lite >= recall@10 for flat (superset property).
444    #[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    /// S5.4-F: format_results_markdown returns non-empty string with headers.
459    #[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    /// S5.4-G: V25_DECISION_CRITERION documents the conclusion.
470    #[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}