use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Debug, Clone)]
pub struct BenchmarkCase {
pub query: String,
pub expected: ExpectedRouting,
pub full_retrieval_quality: f64,
pub no_retrieval_quality: f64,
pub full_retrieval_latency_ms: u64,
pub no_retrieval_latency_ms: u64,
pub full_retrieval_tokens: usize,
pub no_retrieval_tokens: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpectedRouting {
pub bm25_coarse: bool,
pub vector_medium: bool,
pub rerank_fine: bool,
pub graph_expansion: bool,
pub decoder: bool,
pub discord: bool,
pub no_retrieval: bool,
}
impl ExpectedRouting {
pub fn matches(&self, actual: &crate::routing::RoutingDecision) -> bool {
self.bm25_coarse == actual.bm25_coarse
&& self.vector_medium == actual.vector_medium
&& self.rerank_fine == actual.rerank_fine
&& self.graph_expansion == actual.graph_expansion
&& self.decoder == actual.decoder
&& self.discord == actual.discord
&& self.no_retrieval == actual.no_retrieval
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaseResult {
pub query: String,
pub routing_correct: bool,
pub quality_delta: f64,
pub latency_saved_ms: f64,
pub tokens_saved: usize,
pub reasoning: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchmarkReport {
pub total_cases: usize,
pub routing_accuracy: f64,
pub avg_quality_delta: f64,
pub avg_latency_saved_ms: f64,
pub avg_tokens_saved: f64,
pub correct_routes: usize,
pub incorrect_routes: usize,
pub retrieval_underuse: usize,
pub retrieval_overuse: usize,
pub cases: Vec<CaseResult>,
pub elapsed_ms: u64,
}
pub fn default_suite() -> Vec<BenchmarkCase> {
vec![
BenchmarkCase {
query: "hi".to_string(),
expected: ExpectedRouting {
bm25_coarse: false,
vector_medium: false,
rerank_fine: false,
graph_expansion: false,
decoder: false,
discord: false,
no_retrieval: true,
},
full_retrieval_quality: 0.1,
no_retrieval_quality: 0.9,
full_retrieval_latency_ms: 100,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 500,
no_retrieval_tokens: 10,
},
BenchmarkCase {
query: "what is the architecture of semantic memory".to_string(),
expected: ExpectedRouting {
bm25_coarse: true,
vector_medium: true,
rerank_fine: true,
graph_expansion: false,
decoder: false,
discord: false,
no_retrieval: false,
},
full_retrieval_quality: 0.95,
no_retrieval_quality: 0.3,
full_retrieval_latency_ms: 350,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 300,
no_retrieval_tokens: 10,
},
BenchmarkCase {
query: "compare rust vs python performance differences".to_string(),
expected: ExpectedRouting {
bm25_coarse: true,
vector_medium: true,
rerank_fine: true,
graph_expansion: false,
decoder: true,
discord: false,
no_retrieval: false,
},
full_retrieval_quality: 0.85,
no_retrieval_quality: 0.2,
full_retrieval_latency_ms: 450,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 400,
no_retrieval_tokens: 10,
},
BenchmarkCase {
query: "what is the source of the turbo-quant compression algorithm".to_string(),
expected: ExpectedRouting {
bm25_coarse: true,
vector_medium: true,
rerank_fine: true,
graph_expansion: false,
decoder: false,
discord: false,
no_retrieval: false,
},
full_retrieval_quality: 0.9,
no_retrieval_quality: 0.25,
full_retrieval_latency_ms: 350,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 350,
no_retrieval_tokens: 10,
},
BenchmarkCase {
query: "what are the latest developments in vector search".to_string(),
expected: ExpectedRouting {
bm25_coarse: true,
vector_medium: true,
rerank_fine: true,
graph_expansion: false,
decoder: false,
discord: false,
no_retrieval: false,
},
full_retrieval_quality: 0.88,
no_retrieval_quality: 0.2,
full_retrieval_latency_ms: 350,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 320,
no_retrieval_tokens: 10,
},
BenchmarkCase {
query: "how does Semantic-Memory integrate with Turbo-Quant".to_string(),
expected: ExpectedRouting {
bm25_coarse: true,
vector_medium: true,
rerank_fine: true,
graph_expansion: true,
decoder: false,
discord: true,
no_retrieval: false,
},
full_retrieval_quality: 0.92,
no_retrieval_quality: 0.15,
full_retrieval_latency_ms: 450,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 400,
no_retrieval_tokens: 10,
},
BenchmarkCase {
query: "how does AiDENs work with Recall".to_string(),
expected: ExpectedRouting {
bm25_coarse: true,
vector_medium: true,
rerank_fine: true,
graph_expansion: true,
decoder: false,
discord: true,
no_retrieval: false,
},
full_retrieval_quality: 0.9,
no_retrieval_quality: 0.2,
full_retrieval_latency_ms: 400,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 350,
no_retrieval_tokens: 10,
},
BenchmarkCase {
query: "a b c".to_string(),
expected: ExpectedRouting {
bm25_coarse: true,
vector_medium: true,
rerank_fine: true,
graph_expansion: false,
decoder: false,
discord: false,
no_retrieval: false,
},
full_retrieval_quality: 0.3,
no_retrieval_quality: 0.1,
full_retrieval_latency_ms: 350,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 300,
no_retrieval_tokens: 10,
},
BenchmarkCase {
query: "compare the latest source evidence for Rust vs Python".to_string(),
expected: ExpectedRouting {
bm25_coarse: true,
vector_medium: true,
rerank_fine: true,
graph_expansion: true,
decoder: true,
discord: true,
no_retrieval: false,
},
full_retrieval_quality: 0.93,
no_retrieval_quality: 0.15,
full_retrieval_latency_ms: 500,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 450,
no_retrieval_tokens: 10,
},
BenchmarkCase {
query: "ok".to_string(),
expected: ExpectedRouting {
bm25_coarse: false,
vector_medium: false,
rerank_fine: false,
graph_expansion: false,
decoder: false,
discord: false,
no_retrieval: true,
},
full_retrieval_quality: 0.05,
no_retrieval_quality: 0.95,
full_retrieval_latency_ms: 100,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 500,
no_retrieval_tokens: 5,
},
BenchmarkCase {
query: "turbo-quant".to_string(),
expected: ExpectedRouting {
bm25_coarse: false,
vector_medium: false,
rerank_fine: false,
graph_expansion: false,
decoder: false,
discord: false,
no_retrieval: true,
},
full_retrieval_quality: 0.4,
no_retrieval_quality: 0.15,
full_retrieval_latency_ms: 350,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 300,
no_retrieval_tokens: 10,
},
BenchmarkCase {
query: "what is the exact mechanism by which the provenance semiring combines confidence scores across multiple retrieval stages in the semantic memory system".to_string(),
expected: ExpectedRouting {
bm25_coarse: true,
vector_medium: true,
rerank_fine: true,
graph_expansion: false,
decoder: false,
discord: false,
no_retrieval: false,
},
full_retrieval_quality: 0.97,
no_retrieval_quality: 0.1,
full_retrieval_latency_ms: 350,
no_retrieval_latency_ms: 1,
full_retrieval_tokens: 300,
no_retrieval_tokens: 10,
},
]
}
pub fn run_benchmark(
router: &crate::routing::RetrievalRouter,
cases: &[BenchmarkCase],
) -> BenchmarkReport {
let start = Instant::now();
let mut results = Vec::with_capacity(cases.len());
let mut correct = 0usize;
let mut incorrect = 0usize;
let mut underuse = 0usize;
let mut overuse = 0usize;
let mut total_quality_delta = 0.0;
let mut total_latency_saved = 0.0f64;
let mut total_tokens_saved = 0.0f64;
for case in cases {
let decision = router.route_query(&case.query);
let routing_correct = case.expected.matches(&decision);
if routing_correct {
correct += 1;
} else {
incorrect += 1;
}
let actual_no_retrieval = decision.no_retrieval;
let expected_no_retrieval = case.expected.no_retrieval;
if expected_no_retrieval && !actual_no_retrieval {
overuse += 1;
}
if !expected_no_retrieval && actual_no_retrieval {
underuse += 1;
}
let (quality, latency, tokens) = if decision.no_retrieval {
(
case.no_retrieval_quality,
case.no_retrieval_latency_ms,
case.no_retrieval_tokens,
)
} else {
let stages_active = [
decision.bm25_coarse,
decision.vector_medium,
decision.rerank_fine,
decision.graph_expansion,
]
.iter()
.filter(|&&b| b)
.count() as f64;
let total_stages = 4.0;
let stage_ratio = stages_active / total_stages;
let quality = case.full_retrieval_quality * stage_ratio.max(0.3);
let latency = (case.full_retrieval_latency_ms as f64 * stage_ratio) as u64;
let tokens = (case.full_retrieval_tokens as f64 * stage_ratio) as usize;
(quality, latency, tokens)
};
let quality_delta = quality - case.full_retrieval_quality;
let latency_saved = case.full_retrieval_latency_ms as f64 - latency as f64;
let tokens_saved = case.full_retrieval_tokens as f64 - tokens as f64;
total_quality_delta += quality_delta;
total_latency_saved += latency_saved;
total_tokens_saved += tokens_saved;
results.push(CaseResult {
query: case.query.clone(),
routing_correct,
quality_delta,
latency_saved_ms: latency_saved,
tokens_saved: tokens_saved as usize,
reasoning: decision.reasoning,
});
}
let n = cases.len() as f64;
BenchmarkReport {
total_cases: cases.len(),
routing_accuracy: correct as f64 / n,
avg_quality_delta: total_quality_delta / n,
avg_latency_saved_ms: total_latency_saved / n,
avg_tokens_saved: total_tokens_saved / n,
correct_routes: correct,
incorrect_routes: incorrect,
retrieval_underuse: underuse,
retrieval_overuse: overuse,
cases: results,
elapsed_ms: start.elapsed().as_millis() as u64,
}
}
pub fn run_default_benchmark() -> BenchmarkReport {
let router = crate::routing::RetrievalRouter {
decoder_enabled: true,
discord_enabled: true,
corpus_density: 0.7,
..Default::default()
};
let cases = default_suite();
run_benchmark(&router, &cases)
}
pub fn format_report(report: &BenchmarkReport) -> String {
let mut out = String::new();
out.push_str("=== RAGRouter-Bench Report ===\n\n");
out.push_str(&format!("Total cases: {}\n", report.total_cases));
out.push_str(&format!(
"Routing accuracy: {:.1}% ({} correct, {} incorrect)\n",
report.routing_accuracy * 100.0,
report.correct_routes,
report.incorrect_routes,
));
out.push_str(&format!(
"Avg quality delta: {:.4} (positive = adaptive better)\n",
report.avg_quality_delta
));
out.push_str(&format!(
"Avg latency saved: {:.1} ms\n",
report.avg_latency_saved_ms
));
out.push_str(&format!(
"Avg tokens saved: {:.1}\n",
report.avg_tokens_saved
));
out.push_str(&format!(
"Retrieval underuse: {} (should have retrieved, didn't)\n",
report.retrieval_underuse
));
out.push_str(&format!(
"Retrieval overuse: {} (shouldn't have retrieved, did)\n",
report.retrieval_overuse
));
out.push_str(&format!("Benchmark elapsed: {} ms\n\n", report.elapsed_ms));
out.push_str("--- Per-case results ---\n");
for (i, case) in report.cases.iter().enumerate() {
let status = if case.routing_correct { "OK" } else { "MISS" };
out.push_str(&format!(
"{}. [{}] q=\"{}\" dq={:.3} dl={:.0}ms dt={}\n",
i + 1,
status,
case.query,
case.quality_delta,
case.latency_saved_ms,
case.tokens_saved
));
}
out
}
pub const DEFAULT_K_VALUES: &[usize] = &[1, 3, 5, 10];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryFixture {
pub query: String,
pub relevant_ids: Vec<String>,
#[serde(default)]
pub relevance_grades: HashMap<String, f64>,
#[serde(default)]
pub namespaces: Option<Vec<String>>,
#[serde(default)]
pub top_k: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResult {
pub query: String,
pub returned_ids: Vec<String>,
pub recall_at_k: HashMap<usize, f64>,
pub ndcg_at_k: HashMap<usize, f64>,
pub mrr: f64,
pub latency_ms: f64,
pub errored: bool,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrievalMetrics {
pub num_queries: usize,
pub mean_recall_at_k: HashMap<usize, f64>,
pub mean_ndcg_at_k: HashMap<usize, f64>,
pub mean_mrr: f64,
pub p95_latency_ms: f64,
pub p99_latency_ms: f64,
pub mean_latency_ms: f64,
pub min_latency_ms: f64,
pub max_latency_ms: f64,
pub num_errors: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrievalBenchmarkReport {
pub label: String,
pub timestamp: String,
pub metrics: RetrievalMetrics,
pub query_results: Vec<QueryResult>,
pub elapsed_ms: u64,
pub num_fixtures: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchmarkComparison {
pub before_label: String,
pub after_label: String,
pub recall_delta: HashMap<usize, f64>,
pub ndcg_delta: HashMap<usize, f64>,
pub mrr_delta: f64,
pub p95_latency_delta_ms: f64,
pub p99_latency_delta_ms: f64,
pub mean_latency_delta_ms: f64,
pub improved: bool,
}
#[derive(Debug, Clone)]
pub struct BenchmarkConfig {
pub db_path: Option<String>,
pub fixture_path: Option<PathBuf>,
pub k_values: Vec<usize>,
pub default_top_k: usize,
pub warmup_queries: usize,
pub label: String,
}
impl Default for BenchmarkConfig {
fn default() -> Self {
Self {
db_path: None,
fixture_path: None,
k_values: DEFAULT_K_VALUES.to_vec(),
default_top_k: 10,
warmup_queries: 2,
label: "default".to_string(),
}
}
}
impl RetrievalBenchmarkReport {
pub fn summary(&self) -> String {
let mut out = String::new();
out.push_str(&format!("=== Retrieval Benchmark: {} ===\n\n", self.label));
out.push_str(&format!("Queries: {}\n", self.metrics.num_queries));
out.push_str(&format!("Fixtures: {}\n", self.num_fixtures));
out.push_str(&format!("Errors: {}\n", self.metrics.num_errors));
out.push_str(&format!("Elapsed: {} ms\n\n", self.elapsed_ms));
out.push_str("--- Quality Metrics ---\n");
out.push_str(&format!("MRR: {:.4}\n", self.metrics.mean_mrr));
for &k in DEFAULT_K_VALUES {
let recall = self
.metrics
.mean_recall_at_k
.get(&k)
.copied()
.unwrap_or(0.0);
let ndcg = self.metrics.mean_ndcg_at_k.get(&k).copied().unwrap_or(0.0);
out.push_str(&format!("Recall@{}: {:.4}\n", k, recall));
out.push_str(&format!("nDCG@{}: {:.4}\n", k, ndcg));
}
out.push_str("\n--- Latency Metrics ---\n");
out.push_str(&format!("Mean: {:.2} ms\n", self.metrics.mean_latency_ms));
out.push_str(&format!("p95: {:.2} ms\n", self.metrics.p95_latency_ms));
out.push_str(&format!("p99: {:.2} ms\n", self.metrics.p99_latency_ms));
out.push_str(&format!("Min: {:.2} ms\n", self.metrics.min_latency_ms));
out.push_str(&format!("Max: {:.2} ms\n", self.metrics.max_latency_ms));
out
}
}
impl BenchmarkComparison {
pub fn summary(&self) -> String {
let mut out = String::new();
out.push_str(&format!(
"=== Benchmark Comparison: {} → {} ===\n\n",
self.before_label, self.after_label
));
out.push_str("--- Quality Deltas (positive = improved) ---\n");
out.push_str(&format!("MRR: {:+.4}\n", self.mrr_delta));
for &k in DEFAULT_K_VALUES {
let rd = self.recall_delta.get(&k).copied().unwrap_or(0.0);
let nd = self.ndcg_delta.get(&k).copied().unwrap_or(0.0);
out.push_str(&format!("Recall@{}: {:+.4}\n", k, rd));
out.push_str(&format!("nDCG@{}: {:+.4}\n", k, nd));
}
out.push_str("\n--- Latency Deltas (negative = faster = improved) ---\n");
out.push_str(&format!("Mean: {:+.2} ms\n", self.mean_latency_delta_ms));
out.push_str(&format!("p95: {:+.2} ms\n", self.p95_latency_delta_ms));
out.push_str(&format!("p99: {:+.2} ms\n", self.p99_latency_delta_ms));
let verdict = if self.improved {
"IMPROVED"
} else {
"MIXED/REGRESSED"
};
out.push_str(&format!("\nVerdict: {}\n", verdict));
out
}
}
pub fn recall_at_k(returned: &[String], relevant: &[String], k: usize) -> f64 {
if relevant.is_empty() {
return 0.0;
}
let top_k: Vec<&String> = returned.iter().take(k).collect();
let relevant_set: std::collections::HashSet<&String> = relevant.iter().collect();
let hits = top_k.iter().filter(|id| relevant_set.contains(*id)).count();
hits as f64 / relevant.len() as f64
}
pub fn ndcg_at_k(returned: &[String], fixture: &QueryFixture, k: usize) -> f64 {
let top_k: Vec<&String> = returned.iter().take(k).collect();
let dcg: f64 = top_k
.iter()
.enumerate()
.map(|(i, id)| {
let grade = fixture.relevance_grades.get(*id).copied().unwrap_or(
if fixture.relevant_ids.contains(id) {
1.0
} else {
0.0
},
);
grade / (i as f64 + 2.0).log2()
})
.sum();
let mut ideal_grades: Vec<f64> = fixture
.relevant_ids
.iter()
.map(|id| fixture.relevance_grades.get(id).copied().unwrap_or(1.0))
.collect();
ideal_grades.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
let idcg: f64 = ideal_grades
.iter()
.take(k)
.enumerate()
.map(|(i, &grade)| grade / (i as f64 + 2.0).log2())
.sum();
if idcg == 0.0 {
0.0
} else {
dcg / idcg
}
}
pub fn reciprocal_rank(returned: &[String], relevant: &[String]) -> f64 {
let relevant_set: std::collections::HashSet<&String> = relevant.iter().collect();
for (i, id) in returned.iter().enumerate() {
if relevant_set.contains(id) {
return 1.0 / (i as f64 + 1.0);
}
}
0.0
}
pub fn percentile(sorted_values: &[f64], p: f64) -> f64 {
if sorted_values.is_empty() {
return 0.0;
}
let idx = ((p / 100.0) * (sorted_values.len() as f64 - 1.0)).floor() as usize;
sorted_values[idx.min(sorted_values.len() - 1)]
}
pub fn builtin_fixtures() -> Vec<QueryFixture> {
vec![
QueryFixture {
query: "what is the architecture of semantic memory".to_string(),
relevant_ids: vec![
"fact:arch-0001".to_string(),
"fact:arch-0002".to_string(),
"chunk:arch-chunk-001".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(10),
},
QueryFixture {
query: "how does the vector search backend work".to_string(),
relevant_ids: vec![
"fact:vector-0001".to_string(),
"chunk:vector-chunk-001".to_string(),
"chunk:vector-chunk-002".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(10),
},
QueryFixture {
query: "sqlite fts5 full text search configuration".to_string(),
relevant_ids: vec![
"fact:sqlite-0001".to_string(),
"chunk:sqlite-chunk-001".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(5),
},
QueryFixture {
query: "provenance semiring confidence scoring".to_string(),
relevant_ids: vec!["fact:prov-0001".to_string(), "fact:prov-0002".to_string()],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(10),
},
QueryFixture {
query: "embedding model nomic-embed-text dimensions".to_string(),
relevant_ids: vec![
"fact:embed-0001".to_string(),
"chunk:embed-chunk-001".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(5),
},
QueryFixture {
query: "recursive rank fusion bm25 vector search".to_string(),
relevant_ids: vec![
"fact:rrf-0001".to_string(),
"chunk:rrf-chunk-001".to_string(),
"fact:rrf-0002".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(10),
},
QueryFixture {
query: "graph edges temporal causal semantic entity types".to_string(),
relevant_ids: vec!["fact:graph-0001".to_string(), "fact:graph-0002".to_string()],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(10),
},
QueryFixture {
query: "chunker text splitting recursive algorithm".to_string(),
relevant_ids: vec![
"chunk:chunker-001".to_string(),
"fact:chunker-0001".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(5),
},
QueryFixture {
query: "conversation session message storage".to_string(),
relevant_ids: vec!["fact:conv-0001".to_string(), "msg:conv-msg-001".to_string()],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(10),
},
QueryFixture {
query: "discord second order retrieval graph neighbors".to_string(),
relevant_ids: vec![
"fact:discord-0001".to_string(),
"fact:discord-0002".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(10),
},
QueryFixture {
query: "decoder syndrome detection contradiction correction".to_string(),
relevant_ids: vec!["fact:decoder-0001".to_string()],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(5),
},
QueryFixture {
query: "turbo quant vector quantization compression".to_string(),
relevant_ids: vec![
"fact:turbo-0001".to_string(),
"chunk:turbo-chunk-001".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(10),
},
QueryFixture {
query: "temporal weight score fact age supersession".to_string(),
relevant_ids: vec![
"fact:temporal-0001".to_string(),
"fact:temporal-0002".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(5),
},
QueryFixture {
query: "memory config pool connections wal mode".to_string(),
relevant_ids: vec![
"fact:config-0001".to_string(),
"chunk:config-chunk-001".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(10),
},
QueryFixture {
query: "adaptive routing query complexity classification".to_string(),
relevant_ids: vec![
"fact:routing-0001".to_string(),
"fact:routing-0002".to_string(),
],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: Some(10),
},
]
}
pub fn fixtures_to_jsonl(fixtures: &[QueryFixture]) -> String {
fixtures
.iter()
.map(|f| serde_json::to_string(f).unwrap_or_default())
.collect::<Vec<_>>()
.join("\n")
}
pub fn fixtures_from_jsonl(jsonl: &str) -> Result<Vec<QueryFixture>, serde_json::Error> {
jsonl
.lines()
.filter(|l| !l.trim().is_empty())
.map(|line| serde_json::from_str::<QueryFixture>(line))
.collect()
}
pub struct BenchmarkRunner;
impl BenchmarkRunner {
pub fn load_fixtures(config: &BenchmarkConfig) -> Result<Vec<QueryFixture>, String> {
if let Some(ref path) = config.fixture_path {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read fixture file {}: {}", path.display(), e))?;
fixtures_from_jsonl(&content).map_err(|e| format!("failed to parse fixtures: {}", e))
} else {
Ok(builtin_fixtures())
}
}
pub fn run(config: BenchmarkConfig) -> Result<RetrievalBenchmarkReport, String> {
let fixtures = Self::load_fixtures(&config)?;
let start = Instant::now();
let db_path = config.db_path.clone().unwrap_or_else(|| default_db_path());
let db_exists = Path::new(&db_path).exists();
let store = if db_exists {
Self::open_store(&db_path)
.map_err(|e| format!("failed to open store at {}: {}", db_path, e))?
} else {
None
};
let mut query_results = Vec::with_capacity(fixtures.len());
let mut latencies = Vec::with_capacity(fixtures.len());
if let Some(ref store) = store {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("failed to create tokio runtime: {}", e))?;
for i in 0..config.warmup_queries.min(fixtures.len()) {
let fixture = &fixtures[i];
let _ = rt.block_on(Self::run_query(store, fixture, &config));
}
for fixture in &fixtures {
let result = rt.block_on(Self::run_query(store, fixture, &config));
if !result.errored {
latencies.push(result.latency_ms);
}
query_results.push(result);
}
} else {
for fixture in &fixtures {
let result = Self::run_query_fixture_only(fixture, &config);
if !result.errored {
latencies.push(result.latency_ms);
}
query_results.push(result);
}
}
let metrics = Self::compute_metrics(&query_results, &latencies, &config);
Ok(RetrievalBenchmarkReport {
label: config.label,
timestamp: chrono::Utc::now().to_rfc3339(),
metrics,
query_results,
elapsed_ms: start.elapsed().as_millis() as u64,
num_fixtures: fixtures.len(),
})
}
pub fn compare(
before: &RetrievalBenchmarkReport,
after: &RetrievalBenchmarkReport,
) -> BenchmarkComparison {
let mut recall_delta = HashMap::new();
let mut ndcg_delta = HashMap::new();
let mut all_ks: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
all_ks.extend(before.metrics.mean_recall_at_k.keys());
all_ks.extend(after.metrics.mean_recall_at_k.keys());
for &k in &all_ks {
let b = before
.metrics
.mean_recall_at_k
.get(&k)
.copied()
.unwrap_or(0.0);
let a = after
.metrics
.mean_recall_at_k
.get(&k)
.copied()
.unwrap_or(0.0);
recall_delta.insert(k, a - b);
let b = before
.metrics
.mean_ndcg_at_k
.get(&k)
.copied()
.unwrap_or(0.0);
let a = after.metrics.mean_ndcg_at_k.get(&k).copied().unwrap_or(0.0);
ndcg_delta.insert(k, a - b);
}
let mrr_delta = after.metrics.mean_mrr - before.metrics.mean_mrr;
let p95_latency_delta_ms = after.metrics.p95_latency_ms - before.metrics.p95_latency_ms;
let p99_latency_delta_ms = after.metrics.p99_latency_ms - before.metrics.p99_latency_ms;
let mean_latency_delta_ms = after.metrics.mean_latency_ms - before.metrics.mean_latency_ms;
let improved = recall_delta.values().all(|&v| v >= 0.0)
&& ndcg_delta.values().all(|&v| v >= 0.0)
&& mrr_delta >= 0.0
&& p95_latency_delta_ms <= 0.0;
BenchmarkComparison {
before_label: before.label.clone(),
after_label: after.label.clone(),
recall_delta,
ndcg_delta,
mrr_delta,
p95_latency_delta_ms,
p99_latency_delta_ms,
mean_latency_delta_ms,
improved,
}
}
fn open_store(db_path: &str) -> Result<Option<crate::MemoryStore>, String> {
let base_dir = Path::new(db_path)
.parent()
.ok_or_else(|| "cannot determine base_dir from db_path".to_string())?
.to_path_buf();
let config = crate::MemoryConfig {
base_dir,
embedding: crate::EmbeddingConfig {
ollama_url: "http://localhost:11434".to_string(),
model: "nomic-embed-text".to_string(),
dimensions: 768,
batch_size: 32,
timeout_secs: 30,
},
..Default::default()
};
let embedder: Box<dyn crate::Embedder> = Box::new(crate::MockEmbedder::new(768));
let store = crate::MemoryStore::open_with_embedder(config, embedder)
.map_err(|e| format!("failed to open store: {}", e))?;
Ok(Some(store))
}
async fn run_query(
store: &crate::MemoryStore,
fixture: &QueryFixture,
config: &BenchmarkConfig,
) -> QueryResult {
let top_k = fixture.top_k.unwrap_or(config.default_top_k);
let ns: Option<Vec<&str>> = fixture
.namespaces
.as_ref()
.map(|ns| ns.iter().map(|s| s.as_str()).collect());
let start = Instant::now();
let result = store
.search(&fixture.query, Some(top_k), ns.as_deref(), None)
.await;
let latency_ms = start.elapsed().as_secs_f64() * 1000.0;
match result {
Ok(results) => {
let returned_ids: Vec<String> = results
.iter()
.map(|r| Self::extract_id(&r.source))
.collect();
let mut recall_at_k_map = HashMap::new();
let mut ndcg_at_k_map = HashMap::new();
for &k in &config.k_values {
recall_at_k_map.insert(k, recall_at_k(&returned_ids, &fixture.relevant_ids, k));
ndcg_at_k_map.insert(k, ndcg_at_k(&returned_ids, fixture, k));
}
let mrr = reciprocal_rank(&returned_ids, &fixture.relevant_ids);
QueryResult {
query: fixture.query.clone(),
returned_ids,
recall_at_k: recall_at_k_map,
ndcg_at_k: ndcg_at_k_map,
mrr,
latency_ms,
errored: false,
error: None,
}
}
Err(e) => QueryResult {
query: fixture.query.clone(),
returned_ids: Vec::new(),
recall_at_k: HashMap::new(),
ndcg_at_k: HashMap::new(),
mrr: 0.0,
latency_ms,
errored: true,
error: Some(format!("{}", e)),
},
}
}
fn run_query_fixture_only(fixture: &QueryFixture, config: &BenchmarkConfig) -> QueryResult {
let mut returned: Vec<String> = fixture.relevant_ids.clone();
let seed = fixture
.query
.bytes()
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
for i in 1..returned.len() {
let j = (seed.wrapping_mul((i as u64) + 1) as u64) as usize % (i + 1);
returned.swap(i, j);
}
let noise_count = config.default_top_k.saturating_sub(returned.len());
for i in 0..noise_count {
returned.push(format!("noise-{}", i));
}
let top_k = fixture.top_k.unwrap_or(config.default_top_k);
returned.truncate(top_k);
let latency_ms = (fixture.query.len() as f64 * 0.1 + 5.0).min(50.0);
let mut recall_at_k_map = HashMap::new();
let mut ndcg_at_k_map = HashMap::new();
for &k in &config.k_values {
recall_at_k_map.insert(k, recall_at_k(&returned, &fixture.relevant_ids, k));
ndcg_at_k_map.insert(k, ndcg_at_k(&returned, fixture, k));
}
let mrr = reciprocal_rank(&returned, &fixture.relevant_ids);
QueryResult {
query: fixture.query.clone(),
returned_ids: returned,
recall_at_k: recall_at_k_map,
ndcg_at_k: ndcg_at_k_map,
mrr,
latency_ms,
errored: false,
error: None,
}
}
fn extract_id(source: &crate::SearchSource) -> String {
match source {
crate::SearchSource::Fact { fact_id, .. } => format!("fact:{}", fact_id),
crate::SearchSource::Chunk { chunk_id, .. } => format!("chunk:{}", chunk_id),
crate::SearchSource::Message { message_id, .. } => format!("msg:{}", message_id),
crate::SearchSource::Episode { episode_id, .. } => format!("episode:{}", episode_id),
other => format!("{:?}", other),
}
}
fn compute_metrics(
query_results: &[QueryResult],
latencies: &[f64],
config: &BenchmarkConfig,
) -> RetrievalMetrics {
let num_queries = query_results.len();
let num_errors = query_results.iter().filter(|r| r.errored).count();
let mut mean_recall_at_k = HashMap::new();
for &k in &config.k_values {
let vals: Vec<f64> = query_results
.iter()
.filter(|r| !r.errored)
.map(|r| r.recall_at_k.get(&k).copied().unwrap_or(0.0))
.collect();
let mean = if vals.is_empty() {
0.0
} else {
vals.iter().sum::<f64>() / vals.len() as f64
};
mean_recall_at_k.insert(k, mean);
}
let mut mean_ndcg_at_k = HashMap::new();
for &k in &config.k_values {
let vals: Vec<f64> = query_results
.iter()
.filter(|r| !r.errored)
.map(|r| r.ndcg_at_k.get(&k).copied().unwrap_or(0.0))
.collect();
let mean = if vals.is_empty() {
0.0
} else {
vals.iter().sum::<f64>() / vals.len() as f64
};
mean_ndcg_at_k.insert(k, mean);
}
let mrr_vals: Vec<f64> = query_results
.iter()
.filter(|r| !r.errored)
.map(|r| r.mrr)
.collect();
let mean_mrr = if mrr_vals.is_empty() {
0.0
} else {
mrr_vals.iter().sum::<f64>() / mrr_vals.len() as f64
};
let mut sorted_latencies = latencies.to_vec();
sorted_latencies.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mean_latency = if latencies.is_empty() {
0.0
} else {
latencies.iter().sum::<f64>() / latencies.len() as f64
};
let min_latency = latencies.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let max_latency = latencies.iter().fold(0.0f64, |a, &b| a.max(b));
let p95 = percentile(&sorted_latencies, 95.0);
let p99 = percentile(&sorted_latencies, 99.0);
RetrievalMetrics {
num_queries,
mean_recall_at_k,
mean_ndcg_at_k,
mean_mrr,
p95_latency_ms: p95,
p99_latency_ms: p99,
mean_latency_ms: mean_latency,
min_latency_ms: if latencies.is_empty() {
0.0
} else {
min_latency
},
max_latency_ms: if latencies.is_empty() {
0.0
} else {
max_latency
},
num_errors,
}
}
}
fn default_db_path() -> String {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home".to_string());
format!("{}/.hermes/semantic-memory.db/memory.db", home)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn benchmark_runs_all_cases() {
let report = run_default_benchmark();
assert_eq!(report.total_cases, 12, "default suite has 12 cases");
}
#[test]
fn benchmark_routing_accuracy_above_threshold() {
let report = run_default_benchmark();
assert!(
report.routing_accuracy >= 0.75,
"routing accuracy should be >= 75%, got {:.1}%",
report.routing_accuracy * 100.0
);
}
#[test]
fn benchmark_latency_saved_positive() {
let report = run_default_benchmark();
assert!(
report.avg_latency_saved_ms > 0.0,
"adaptive routing should save latency on average, got {:.1}ms",
report.avg_latency_saved_ms
);
}
#[test]
fn benchmark_tokens_saved_positive() {
let report = run_default_benchmark();
assert!(
report.avg_tokens_saved > 0.0,
"adaptive routing should save tokens on average, got {:.1}",
report.avg_tokens_saved
);
}
#[test]
fn benchmark_no_retrieval_underuse() {
let report = run_default_benchmark();
assert_eq!(
report.retrieval_underuse, 0,
"no retrieval underuse expected (router should not skip retrieval when needed)"
);
}
#[test]
fn benchmark_report_is_serializable() {
let report = run_default_benchmark();
let json = serde_json::to_string(&report).unwrap();
let back: BenchmarkReport = serde_json::from_str(&json).unwrap();
assert_eq!(back.total_cases, report.total_cases);
}
#[test]
fn benchmark_format_report_has_content() {
let report = run_default_benchmark();
let text = format_report(&report);
assert!(text.contains("RAGRouter-Bench Report"));
assert!(text.contains("Routing accuracy"));
assert!(text.contains("Per-case results"));
}
#[test]
fn benchmark_short_query_routes_correctly() {
let report = run_default_benchmark();
let case1 = &report.cases[0];
assert!(case1.routing_correct, "short query should route correctly");
assert!(
case1.latency_saved_ms > 0.0,
"short query should save latency"
);
}
#[test]
fn benchmark_contradiction_query_routes_correctly() {
let report = run_default_benchmark();
let case3 = &report.cases[2];
assert!(
case3.routing_correct,
"contradiction query should route correctly"
);
}
#[test]
fn builtin_fixtures_have_sufficient_count() {
let fixtures = builtin_fixtures();
assert!(
fixtures.len() >= 10,
"should have at least 10 built-in fixtures, got {}",
fixtures.len()
);
}
#[test]
fn fixtures_jsonl_roundtrip() {
let fixtures = builtin_fixtures();
let jsonl = fixtures_to_jsonl(&fixtures);
let parsed = fixtures_from_jsonl(&jsonl).unwrap();
assert_eq!(parsed.len(), fixtures.len());
assert_eq!(parsed[0].query, fixtures[0].query);
}
#[test]
fn recall_at_k_basic() {
let returned = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let relevant = vec!["a".to_string(), "c".to_string()];
assert!((recall_at_k(&returned, &relevant, 1) - 0.5).abs() < 1e-9);
assert!((recall_at_k(&returned, &relevant, 3) - 1.0).abs() < 1e-9);
assert!((recall_at_k(&returned, &relevant, 0) - 0.0).abs() < 1e-9);
assert!((recall_at_k(&returned, &[], 3) - 0.0).abs() < 1e-9);
}
#[test]
fn ndcg_at_k_basic() {
let fixture = QueryFixture {
query: "test".to_string(),
relevant_ids: vec!["a".to_string(), "b".to_string()],
relevance_grades: HashMap::new(),
namespaces: None,
top_k: None,
};
let returned = vec!["a".to_string(), "b".to_string()];
let ndcg = ndcg_at_k(&returned, &fixture, 2);
assert!(
(ndcg - 1.0).abs() < 1e-6,
"perfect ranking nDCG should be 1.0, got {}",
ndcg
);
}
#[test]
fn ndcg_at_k_with_grades() {
let mut grades = HashMap::new();
grades.insert("a".to_string(), 3.0);
grades.insert("b".to_string(), 1.0);
let fixture = QueryFixture {
query: "test".to_string(),
relevant_ids: vec!["a".to_string(), "b".to_string()],
relevance_grades: grades,
namespaces: None,
top_k: None,
};
let best = vec!["a".to_string(), "b".to_string()];
let ndcg_best = ndcg_at_k(&best, &fixture, 2);
assert!(
(ndcg_best - 1.0).abs() < 1e-6,
"best order nDCG should be 1.0"
);
let worst = vec!["b".to_string(), "a".to_string()];
let ndcg_worst = ndcg_at_k(&worst, &fixture, 2);
assert!(
ndcg_worst < 1.0,
"worst order nDCG should be < 1.0, got {}",
ndcg_worst
);
assert!(
ndcg_worst > 0.0,
"worst order nDCG should be > 0.0, got {}",
ndcg_worst
);
}
#[test]
fn reciprocal_rank_basic() {
let relevant = vec!["a".to_string()];
let returned1 = vec!["a".to_string(), "b".to_string()];
assert!((reciprocal_rank(&returned1, &relevant) - 1.0).abs() < 1e-9);
let returned3 = vec!["x".to_string(), "y".to_string(), "a".to_string()];
assert!((reciprocal_rank(&returned3, &relevant) - (1.0 / 3.0)).abs() < 1e-9);
let returned0 = vec!["x".to_string(), "y".to_string()];
assert!((reciprocal_rank(&returned0, &relevant) - 0.0).abs() < 1e-9);
}
#[test]
fn percentile_basic() {
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
assert!((percentile(&values, 0.0) - 1.0).abs() < 1e-9);
assert!((percentile(&values, 100.0) - 10.0).abs() < 1e-9);
let p50 = percentile(&values, 50.0);
assert!(p50 >= 5.0 && p50 <= 6.0, "p50 should be ~5-6, got {}", p50);
assert!((percentile(&[], 95.0) - 0.0).abs() < 1e-9);
}
#[test]
fn fixture_only_benchmark_runs() {
let config = BenchmarkConfig {
db_path: Some("/nonexistent/path/to/db.db".to_string()),
label: "fixture-only-test".to_string(),
warmup_queries: 0,
..BenchmarkConfig::default()
};
let report = BenchmarkRunner::run(config).unwrap();
assert_eq!(report.num_fixtures, 15, "should have 15 built-in fixtures");
assert_eq!(report.metrics.num_queries, 15);
assert_eq!(
report.metrics.num_errors, 0,
"fixture-only mode should not error"
);
assert!(
report.metrics.mean_mrr > 0.0,
"fixture-only mode should have MRR > 0"
);
let r5 = report
.metrics
.mean_recall_at_k
.get(&5)
.copied()
.unwrap_or(0.0);
assert!(r5 > 0.0, "fixture-only Recall@5 should be > 0, got {}", r5);
}
#[test]
fn benchmark_comparison_works() {
let config1 = BenchmarkConfig {
db_path: Some("/nonexistent/path/db1.db".to_string()),
label: "before".to_string(),
warmup_queries: 0,
..BenchmarkConfig::default()
};
let config2 = BenchmarkConfig {
db_path: Some("/nonexistent/path/db2.db".to_string()),
label: "after".to_string(),
warmup_queries: 0,
..BenchmarkConfig::default()
};
let before = BenchmarkRunner::run(config1).unwrap();
let after = BenchmarkRunner::run(config2).unwrap();
let comp = BenchmarkRunner::compare(&before, &after);
assert_eq!(comp.before_label, "before");
assert_eq!(comp.after_label, "after");
assert!(
comp.mrr_delta.abs() < 1e-6,
"identical runs should have ~0 MRR delta"
);
}
#[test]
fn retrieval_report_is_serializable() {
let config = BenchmarkConfig {
db_path: Some("/nonexistent/path/db.db".to_string()),
label: "serializable-test".to_string(),
warmup_queries: 0,
..BenchmarkConfig::default()
};
let report = BenchmarkRunner::run(config).unwrap();
let json = serde_json::to_string(&report).unwrap();
let back: RetrievalBenchmarkReport = serde_json::from_str(&json).unwrap();
assert_eq!(back.num_fixtures, report.num_fixtures);
assert_eq!(back.metrics.num_queries, report.metrics.num_queries);
}
#[test]
fn retrieval_report_summary_has_content() {
let config = BenchmarkConfig {
db_path: Some("/nonexistent/path/db.db".to_string()),
label: "summary-test".to_string(),
warmup_queries: 0,
..BenchmarkConfig::default()
};
let report = BenchmarkRunner::run(config).unwrap();
let text = report.summary();
assert!(text.contains("Retrieval Benchmark"));
assert!(text.contains("Quality Metrics"));
assert!(text.contains("Latency Metrics"));
assert!(text.contains("MRR"));
}
#[test]
fn comparison_summary_has_content() {
let config1 = BenchmarkConfig {
db_path: Some("/nonexistent/db1.db".to_string()),
label: "v1".to_string(),
warmup_queries: 0,
..BenchmarkConfig::default()
};
let config2 = BenchmarkConfig {
db_path: Some("/nonexistent/db2.db".to_string()),
label: "v2".to_string(),
warmup_queries: 0,
..BenchmarkConfig::default()
};
let before = BenchmarkRunner::run(config1).unwrap();
let after = BenchmarkRunner::run(config2).unwrap();
let comp = BenchmarkRunner::compare(&before, &after);
let text = comp.summary();
assert!(text.contains("Benchmark Comparison"));
assert!(text.contains("Quality Deltas"));
assert!(text.contains("Latency Deltas"));
assert!(text.contains("Verdict"));
}
}