use std::collections::BTreeSet;
use chrono::{DateTime, Duration, TimeZone, Utc};
use mw_memory::engine::{BuiltinEngine, MemoryEngine};
use mw_memory::{Memory, Query};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
fn fixed_now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 7, 19, 12, 0, 0).unwrap()
}
#[derive(Deserialize)]
struct Corpus {
memories: Vec<MemSpec>,
}
#[derive(Deserialize)]
struct MemSpec {
id: i64,
text: String,
tags: Vec<String>,
importance: f32,
mentions: u32,
days_old: i64,
}
#[derive(Deserialize)]
struct Questions {
questions: Vec<QuerySpec>,
}
#[derive(Deserialize)]
struct QuerySpec {
id: String,
text: String,
#[serde(default)]
task_tags: Vec<String>,
relevant_ids: Vec<i64>,
}
#[derive(Serialize)]
struct SystemResult {
system: &'static str,
top5: Vec<i64>,
recall_at_1: f64,
recall_at_5: f64,
reciprocal_rank: f64,
}
#[derive(Serialize)]
struct QuestionResult {
query_id: String,
query: String,
relevant_ids: Vec<i64>,
systems: Vec<SystemResult>,
}
fn recall_at_k(ranked: &[i64], rel: &BTreeSet<i64>, k: usize) -> f64 {
if rel.is_empty() {
return 0.0;
}
let hits = ranked.iter().take(k).filter(|id| rel.contains(id)).count();
hits as f64 / rel.len() as f64
}
fn reciprocal_rank(ranked: &[i64], rel: &BTreeSet<i64>) -> f64 {
ranked
.iter()
.position(|id| rel.contains(id))
.map(|i| 1.0 / (i as f64 + 1.0))
.unwrap_or(0.0)
}
fn rank_builtin(memories: &[Memory], q: &QuerySpec, now: DateTime<Utc>) -> Vec<i64> {
let query = if q.task_tags.is_empty() {
Query::new(&q.text, now)
} else {
Query::new(&q.text, now).with_task(q.task_tags.clone())
};
let eng = BuiltinEngine::new(memories.to_vec());
eng.retrieve(&query, memories.len())
.into_iter()
.map(|s| s.memory.id)
.collect()
}
fn rank_keyword(memories: &[Memory], q: &QuerySpec) -> Vec<i64> {
let terms = terms(&q.text);
let mut scored: Vec<(usize, i64)> = memories
.iter()
.map(|m| {
let hay = m.text.to_lowercase();
let hits = terms.iter().filter(|t| hay.contains(*t)).count();
(hits, m.id)
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored.into_iter().map(|(_, id)| id).collect()
}
fn rank_fts5(conn: &Connection, memories: &[Memory], q: &QuerySpec) -> rusqlite::Result<Vec<i64>> {
let terms = terms(&q.text);
let match_expr = terms
.iter()
.map(|t| format!("\"{}\"", t.replace('"', "")))
.collect::<Vec<_>>()
.join(" OR ");
let mut ranked: Vec<i64> = Vec::new();
if !match_expr.is_empty() {
let mut stmt =
conn.prepare("SELECT rowid FROM mem_fts WHERE mem_fts MATCH ?1 ORDER BY bm25(mem_fts), rowid")?;
let rows = stmt.query_map([&match_expr], |r| r.get::<_, i64>(0))?;
for id in rows {
ranked.push(id?);
}
}
let matched: BTreeSet<i64> = ranked.iter().copied().collect();
for m in memories {
if !matched.contains(&m.id) {
ranked.push(m.id);
}
}
Ok(ranked)
}
fn terms(text: &str) -> Vec<String> {
let mut seen = BTreeSet::new();
text.split(|c: char| !c.is_alphanumeric())
.map(|w| w.to_lowercase())
.filter(|w| w.len() >= 2)
.filter(|w| seen.insert(w.clone()))
.collect()
}
#[derive(Serialize)]
struct Row {
system: String,
recall_at_1: f64,
recall_at_5: f64,
mrr: f64,
}
fn run_set(
dir: &str,
results_subdir: &str,
questions_file: &str,
conn: &Connection,
memories: &[Memory],
now: DateTime<Utc>,
) -> anyhow::Result<Vec<Row>> {
let questions: Questions =
serde_json::from_str(&std::fs::read_to_string(format!("{dir}/{questions_file}"))?)?;
let results_dir = format!("{dir}/{results_subdir}");
std::fs::create_dir_all(&results_dir)?;
const SYSTEMS: [&str; 3] = ["builtin", "keyword", "fts5"];
let mut sum_r1 = [0.0f64; 3];
let mut sum_r5 = [0.0f64; 3];
let mut sum_mrr = [0.0f64; 3];
for q in &questions.questions {
let rel: BTreeSet<i64> = q.relevant_ids.iter().copied().collect();
let rankings = [
rank_builtin(memories, q, now),
rank_keyword(memories, q),
rank_fts5(conn, memories, q)?,
];
let mut systems = Vec::with_capacity(3);
for (i, ranked) in rankings.iter().enumerate() {
let r1 = recall_at_k(ranked, &rel, 1);
let r5 = recall_at_k(ranked, &rel, 5);
let rr = reciprocal_rank(ranked, &rel);
sum_r1[i] += r1;
sum_r5[i] += r5;
sum_mrr[i] += rr;
systems.push(SystemResult {
system: SYSTEMS[i],
top5: ranked.iter().take(5).copied().collect(),
recall_at_1: r1,
recall_at_5: r5,
reciprocal_rank: rr,
});
}
let out = QuestionResult {
query_id: q.id.clone(),
query: q.text.clone(),
relevant_ids: q.relevant_ids.clone(),
systems,
};
let json = serde_json::to_string_pretty(&out)? + "\n";
std::fs::write(format!("{results_dir}/{}.json", q.id), json)?;
}
let n = questions.questions.len() as f64;
let rows: Vec<Row> = (0..3)
.map(|i| Row {
system: SYSTEMS[i].to_string(),
recall_at_1: sum_r1[i] / n,
recall_at_5: sum_r5[i] / n,
mrr: sum_mrr[i] / n,
})
.collect();
#[derive(Serialize)]
struct Summary<'a> {
corpus_items: usize,
questions: usize,
fixed_now: String,
rows: &'a [Row],
}
let summary = Summary {
corpus_items: memories.len(),
questions: questions.questions.len(),
fixed_now: now.to_rfc3339(),
rows: &rows,
};
std::fs::write(
format!("{results_dir}/summary.json"),
serde_json::to_string_pretty(&summary)? + "\n",
)?;
Ok(rows)
}
fn print_table(label: &str, questions: usize, now: DateTime<Utc>, rows: &[Row]) {
println!(
"\n{label} · {} queries · now={}\n",
questions,
now.date_naive()
);
println!("{:<10} {:>10} {:>10} {:>8}", "system", "recall@1", "recall@5", "MRR");
for r in rows {
println!(
"{:<10} {:>10.3} {:>10.3} {:>8.3}",
r.system, r.recall_at_1, r.recall_at_5, r.mrr
);
}
}
fn main() -> anyhow::Result<()> {
let dir = std::env::args().nth(1).unwrap_or_else(|| "benchmarks".into());
let dir = dir.trim_end_matches('/');
let corpus: Corpus = serde_json::from_str(&std::fs::read_to_string(format!("{dir}/corpus.json"))?)?;
let now = fixed_now();
let memories: Vec<Memory> = corpus
.memories
.iter()
.map(|m| Memory {
id: m.id,
text: m.text.clone(),
created_at: now - Duration::days(m.days_old + 10),
last_used: now - Duration::days(m.days_old),
mentions: m.mentions,
importance: m.importance,
tags: m.tags.clone(),
embedding: None,
})
.collect();
let conn = Connection::open_in_memory()?;
conn.execute("CREATE VIRTUAL TABLE mem_fts USING fts5(text)", [])?;
{
let mut ins = conn.prepare("INSERT INTO mem_fts(rowid, text) VALUES (?1, ?2)")?;
for m in &memories {
ins.execute(rusqlite::params![m.id, m.text])?;
}
}
let text_rows = run_set(dir, "results", "questions.json", &conn, &memories, now)?;
let intent_rows = run_set(dir, "results_intent", "questions_intent.json", &conn, &memories, now)?;
let count = |file: &str| -> usize {
std::fs::read_to_string(format!("{dir}/{file}"))
.ok()
.and_then(|s| serde_json::from_str::<Questions>(&s).ok())
.map(|q| q.questions.len())
.unwrap_or(0)
};
println!("recall benchmark · {} memories · now={}", memories.len(), now.date_naive());
print_table("term-overlap set (results/)", count("questions.json"), now, &text_rows);
print_table("intent set (results_intent/)", count("questions_intent.json"), now, &intent_rows);
println!("\nper-question results written to {dir}/results/*.json and {dir}/results_intent/*.json");
Ok(())
}