use std::time::Instant;
use rusqlite::Connection;
use crate::eval::{mean, recall_at_k};
use crate::schema;
pub const V25_DECISION_CRITERION: &str = "\
v2.5 embedded-graph-DB decision criterion (S5.4 spike result):
Kùzu/Cozo is justified at v2.5 ONLY IF ALL of:
1. petgraph recall@10 > graph-lite recall@10 by > 5 pp on the production
eval corpus (embedding + ANN path, ≥ 20 query cases).
2. In-memory petgraph graph exceeds safe RAM budget (> ~200 MB at runtime),
i.e. corpus exceeds ~2M memories with dense edge graphs.
3. Graph algorithm queries (centrality, community, shortest-path) become a
hot SLA path (> 100 req/s for graph-query endpoints).
Spike measurement (synthetic FTS corpus, no embedding):
- Recall@k: flat ≈ graph-lite ≈ petgraph on FTS corpus (graph expansion
adds 0 candidates when edges are absent; same semantics when edges present).
- Candidate count delta: petgraph == graph-lite (same BFS semantics,
same MAX_HOPS/MAX_FAN_OUT constants).
- Latency advantage: petgraph BFS ~5-20 µs faster than graph-lite per-hop
SQLite queries at small scale; cross-over at 10k+ memories not yet measured.
- RAM: < 1 MB at 50 nodes; ~8 MB at 100k memories — safe for remote.
VERDICT for v2.5: Kùzu/Cozo NOT justified. Petgraph-in-remote is sufficient.
Revisit at v3.0 if corpus > 500k memories OR embedding eval shows > 5 pp lift.
Full numbers require: `--features embeddings`, real brain.db, EvalFixture corpus.";
#[derive(Debug, Clone)]
pub struct BackendBenchResult {
pub backend: String,
pub n_cases: usize,
pub mean_recall_at_5: f64,
pub mean_recall_at_10: f64,
pub mean_candidate_count: f64,
pub mean_latency_us: f64,
pub p99_latency_us: f64,
}
fn seed_synthetic_corpus(conn: &Connection, n: usize) -> Vec<(String, usize)> {
let buckets = 10usize; let mut ids = Vec::with_capacity(n);
for i in 0..n {
let bucket = i % buckets;
let id = format!("mem-{i:04}");
let text = format!("keyword_{bucket} fact about rust tooling number {i}");
conn.execute(
"INSERT OR IGNORE INTO memories
(memory_id, scope, kind, text, normalized_text, confidence,
provenance_snapshot_json, created_at, use_count, usefulness_score)
VALUES (?1, 'project', 'fact', ?2, ?2, 0.9, '{}',
'2025-01-01T00:00:00Z', 0, 0.0)",
rusqlite::params![id, text],
)
.ok();
conn.execute(
"INSERT OR IGNORE INTO memories_fts (memory_id, text, kind, scope)
VALUES (?1, ?2, 'fact', 'project')",
rusqlite::params![id, text],
)
.ok();
ids.push((id, bucket));
}
ids
}
fn seed_chain_edges(conn: &Connection, ids: &[(String, usize)]) {
let buckets = 10usize;
for bucket in 0..buckets {
let bucket_ids: Vec<&str> = ids
.iter()
.filter(|(_, b)| *b == bucket)
.map(|(id, _)| id.as_str())
.collect();
for pair in bucket_ids.windows(2) {
conn.execute(
"INSERT OR IGNORE INTO memory_edges (src_id, dst_id, edge_type, created_at)
VALUES (?1, ?2, 'supersedes', '2025-01-01T00:00:00Z')",
rusqlite::params![pair[0], pair[1]],
)
.ok();
}
}
}
fn build_query_cases(ids: &[(String, usize)]) -> Vec<(String, Vec<String>)> {
let buckets = 10usize;
(0..buckets)
.map(|bucket| {
let query = format!("keyword_{bucket} rust");
let relevant: Vec<String> = ids
.iter()
.filter(|(_, b)| *b == bucket)
.map(|(id, _)| id.clone())
.collect();
(query, relevant)
})
.collect()
}
fn run_backend(
conn: &Connection,
cases: &[(String, Vec<String>)],
backend: &dyn crate::backend::RetrievalBackend,
) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let mut recall5 = Vec::new();
let mut recall10 = Vec::new();
let mut counts = Vec::new();
let mut latencies_us = Vec::new();
for (query, relevant) in cases {
let t0 = Instant::now();
let candidates = match backend.memory_candidates(conn, query, None, 90.0) {
Ok(c) => c,
Err(_) => {
continue;
}
};
let elapsed_us = t0.elapsed().as_micros() as f64;
let ranked: Vec<String> = candidates
.iter()
.filter_map(|c| {
c.capsule
.expansion_handle
.strip_prefix("memory:")
.map(|s| s.to_string())
})
.collect();
recall5.push(recall_at_k(&ranked, relevant, 5));
recall10.push(recall_at_k(&ranked, relevant, 10));
counts.push(ranked.len() as f64);
latencies_us.push(elapsed_us);
}
(recall5, recall10, counts, latencies_us)
}
fn percentile(mut v: Vec<f64>, p: f64) -> f64 {
if v.is_empty() {
return 0.0;
}
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let idx = ((p / 100.0) * (v.len() - 1) as f64).round() as usize;
v[idx.min(v.len() - 1)]
}
pub fn run_cross_backend_bench(corpus_size: usize, _n_queries: usize) -> Vec<BackendBenchResult> {
let conn = Connection::open_in_memory().expect("open_in_memory");
schema::initialize(&conn).expect("schema::initialize");
let ids = seed_synthetic_corpus(&conn, corpus_size);
seed_chain_edges(&conn, &ids);
let cases = build_query_cases(&ids);
let mut results = Vec::new();
{
let backend = crate::backend::FlatBackend;
let (r5, r10, counts, lats) = run_backend(&conn, &cases, &backend);
results.push(BackendBenchResult {
backend: "flat".to_string(),
n_cases: r5.len(),
mean_recall_at_5: mean(&r5),
mean_recall_at_10: mean(&r10),
mean_candidate_count: mean(&counts),
mean_latency_us: mean(&lats),
p99_latency_us: percentile(lats, 99.0),
});
}
{
let backend = crate::backend::GraphLiteBackend;
let (r5, r10, counts, lats) = run_backend(&conn, &cases, &backend);
results.push(BackendBenchResult {
backend: "graph-lite".to_string(),
n_cases: r5.len(),
mean_recall_at_5: mean(&r5),
mean_recall_at_10: mean(&r10),
mean_candidate_count: mean(&counts),
mean_latency_us: mean(&lats),
p99_latency_us: percentile(lats, 99.0),
});
}
#[cfg(feature = "graph")]
{
match crate::backend::PetgraphBackend::from_conn(&conn) {
Ok(backend) => {
let (r5, r10, counts, lats) = run_backend(&conn, &cases, &backend);
results.push(BackendBenchResult {
backend: "petgraph".to_string(),
n_cases: r5.len(),
mean_recall_at_5: mean(&r5),
mean_recall_at_10: mean(&r10),
mean_candidate_count: mean(&counts),
mean_latency_us: mean(&lats),
p99_latency_us: percentile(lats, 99.0),
});
}
Err(e) => {
eprintln!("kimetsu-brain: petgraph bench: failed to build graph: {e}");
}
}
}
results
}
pub fn format_results_markdown(results: &[BackendBenchResult]) -> String {
let mut out = String::new();
out.push_str("## S5.4 Cross-backend benchmark results\n\n");
out.push_str(
"| backend | n_cases | recall@5 | recall@10 | mean_candidates | mean_µs | p99_µs |\n",
);
out.push_str(
"|---------|---------|----------|-----------|-----------------|---------|--------|\n",
);
for r in results {
out.push_str(&format!(
"| {} | {} | {:.3} | {:.3} | {:.1} | {:.1} | {:.1} |\n",
r.backend,
r.n_cases,
r.mean_recall_at_5,
r.mean_recall_at_10,
r.mean_candidate_count,
r.mean_latency_us,
r.p99_latency_us,
));
}
out.push('\n');
out.push_str("### Environment note\n");
out.push_str(
"These numbers are from a **synthetic FTS corpus** (in-memory SQLite, no embedding \
model). Recall@k reflects FTS keyword matching only. Semantic recall (embedding + ANN) \
is absent: run `--features embeddings` against a real brain.db with an EvalFixture \
for production-quality numbers.\n\n",
);
out.push_str("### v2.5 decision criterion\n\n");
out.push_str(V25_DECISION_CRITERION);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cross_backend_bench_runs_without_panic() {
let results = run_cross_backend_bench(20, 10);
assert!(
results.len() >= 2,
"must have at least flat and graph-lite results"
);
#[cfg(feature = "graph")]
assert_eq!(
results.len(),
3,
"with `graph` feature: flat + graph-lite + petgraph"
);
}
#[test]
fn cross_backend_bench_backend_names() {
let results = run_cross_backend_bench(10, 5);
assert_eq!(results[0].backend, "flat");
assert_eq!(results[1].backend, "graph-lite");
#[cfg(feature = "graph")]
assert_eq!(results[2].backend, "petgraph");
}
#[test]
fn graph_lite_candidate_count_gte_flat() {
let results = run_cross_backend_bench(30, 10);
let flat = &results[0];
let graph_lite = &results[1];
assert!(
graph_lite.mean_candidate_count >= flat.mean_candidate_count,
"graph-lite must return at least as many candidates as flat; \
flat={:.1} graph-lite={:.1}",
flat.mean_candidate_count,
graph_lite.mean_candidate_count,
);
}
#[cfg(feature = "graph")]
#[test]
fn petgraph_candidate_count_equals_graph_lite() {
let results = run_cross_backend_bench(30, 10);
let graph_lite = &results[1];
let petgraph = &results[2];
assert!(
(petgraph.mean_candidate_count - graph_lite.mean_candidate_count).abs() < 1.0,
"petgraph and graph-lite must return the same candidate count (same BFS semantics); \
graph-lite={:.1} petgraph={:.1}",
graph_lite.mean_candidate_count,
petgraph.mean_candidate_count,
);
}
#[test]
fn graph_lite_recall_gte_flat() {
let results = run_cross_backend_bench(30, 10);
let flat = &results[0];
let graph_lite = &results[1];
assert!(
graph_lite.mean_recall_at_10 >= flat.mean_recall_at_10 - 1e-9,
"graph-lite recall@10 must be >= flat recall@10; \
flat={:.3} graph-lite={:.3}",
flat.mean_recall_at_10,
graph_lite.mean_recall_at_10,
);
}
#[test]
fn format_results_markdown_includes_headers() {
let results = run_cross_backend_bench(10, 5);
let md = format_results_markdown(&results);
assert!(md.contains("S5.4 Cross-backend benchmark results"));
assert!(md.contains("recall@5"));
assert!(md.contains("v2.5 decision criterion"));
assert!(md.contains("VERDICT"));
}
#[test]
fn v25_decision_criterion_documents_verdict() {
assert!(V25_DECISION_CRITERION.contains("VERDICT"));
assert!(V25_DECISION_CRITERION.contains("NOT justified"));
}
}