mod bench_fixtures;
use anyhow::Result;
use rusqlite::Connection;
use remem::{
db, memory,
retrieval::{entity, search, search_multihop},
summarize,
};
use bench_fixtures::{
coding_session_fixtures, insert_memory_at, insert_seed_memories, search_eval_memories,
setup_full_schema, summary_xml_partial, summary_xml_skip, summary_xml_with_all_fields,
};
fn precision_at_k(result_ids: &[i64], relevant_ids: &[i64], k: usize) -> f64 {
let top_k: Vec<i64> = result_ids.iter().copied().take(k).collect();
if top_k.is_empty() {
return 0.0;
}
let hits = top_k.iter().filter(|id| relevant_ids.contains(id)).count();
hits as f64 / top_k.len() as f64
}
fn recall_at_k(result_ids: &[i64], relevant_ids: &[i64], k: usize) -> f64 {
if relevant_ids.is_empty() {
return 1.0;
}
let top_k: Vec<i64> = result_ids.iter().copied().take(k).collect();
let hits = relevant_ids.iter().filter(|id| top_k.contains(id)).count();
hits as f64 / relevant_ids.len() as f64
}
#[test]
fn bench_memory_capture_rate() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let fixtures = coding_session_fixtures();
let mut total_meaningful = 0;
let mut total_captured = 0;
for session in &fixtures {
for event in &session.events {
memory::insert_event(
&conn,
&session.session_id,
&session.project,
&event.event_type,
&event.summary,
event.detail.as_deref(),
event.files.as_deref(),
None,
)?;
if event.event_type == "file_edit" && event.detail.is_some() {
total_meaningful += 1;
memory::insert_memory(
&conn,
Some(&session.session_id),
&session.project,
None,
&event.summary,
event.detail.as_deref().unwrap_or(&event.summary),
"discovery",
event.files.as_deref(),
)?;
total_captured += 1;
}
}
}
let mcr = if total_meaningful > 0 {
total_captured as f64 / total_meaningful as f64
} else {
0.0
};
eprintln!(
"[MCR] meaningful={} captured={} rate={:.2}",
total_meaningful, total_captured, mcr
);
assert!(
mcr >= 0.8,
"Memory Capture Rate {:.2} below threshold 0.8",
mcr
);
let memories = memory::get_recent_memories(&conn, "tools/remem", 50)?;
assert!(
!memories.is_empty(),
"No memories found after capture pipeline"
);
Ok(())
}
#[test]
fn bench_search_precision_and_recall_fts() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let seeds = search_eval_memories();
let ids = insert_seed_memories(&conn, &seeds)?;
let relevant_ids: Vec<i64> = seeds
.iter()
.zip(ids.iter())
.filter(|(s, _)| s.relevant_to_fts_query)
.map(|(_, id)| *id)
.collect();
let results = search::search(
&conn,
Some("FTS5 search"),
Some("tools/remem"),
None,
10,
0,
true,
)?;
let result_ids: Vec<i64> = results.iter().map(|m| m.id).collect();
let p5 = precision_at_k(&result_ids, &relevant_ids, 5);
let r10 = recall_at_k(&result_ids, &relevant_ids, 10);
eprintln!(
"[Search FTS5] results={} relevant={} P@5={:.2} R@10={:.2}",
results.len(),
relevant_ids.len(),
p5,
r10
);
assert!(
p5 >= 0.6,
"Precision@5 {:.2} below threshold 0.6 (results: {:?})",
p5,
results.iter().map(|m| &m.title).collect::<Vec<_>>()
);
assert!(r10 >= 0.5, "Recall@10 {:.2} below threshold 0.5", r10);
Ok(())
}
#[test]
fn bench_search_precision_decay_query() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let seeds = search_eval_memories();
let ids = insert_seed_memories(&conn, &seeds)?;
let relevant_ids: Vec<i64> = seeds
.iter()
.zip(ids.iter())
.filter(|(s, _)| s.relevant_to_decay_query)
.map(|(_, id)| *id)
.collect();
let results = search::search(
&conn,
Some("time decay"),
Some("tools/remem"),
None,
10,
0,
true,
)?;
let result_ids: Vec<i64> = results.iter().map(|m| m.id).collect();
let p5 = precision_at_k(&result_ids, &relevant_ids, 5);
let r10 = recall_at_k(&result_ids, &relevant_ids, 10);
eprintln!(
"[Search Decay] results={} relevant={} P@5={:.2} R@10={:.2}",
results.len(),
relevant_ids.len(),
p5,
r10
);
assert!(
r10 > 0.0,
"No relevant results found for 'time decay' query"
);
Ok(())
}
#[test]
fn bench_context_score_prefers_decisions_and_recent() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let now = chrono::Utc::now().timestamp();
let decision_recent = insert_memory_at(
&conn,
"tools/remem",
"Use FTS5 over Tantivy",
"Decided to use SQLite FTS5 for simplicity",
"decision",
now - 2 * 86400, "project",
)?;
let bugfix_recent = insert_memory_at(
&conn,
"tools/remem",
"Fix search crash on hyphen",
"Wrapped tokens in quotes for FTS5 MATCH safety",
"bugfix",
now - 86400, "project",
)?;
let discovery_old = insert_memory_at(
&conn,
"tools/remem",
"SQLite WAL mode",
"WAL journal mode improves concurrency",
"discovery",
now - 60 * 86400, "project",
)?;
let decision_old = insert_memory_at(
&conn,
"tools/remem",
"Chose Rust over Python",
"Single binary deployment without runtime",
"decision",
now - 45 * 86400, "project",
)?;
let session_activity = insert_memory_at(
&conn,
"tools/remem",
"Session: fixed CI pipeline",
"Updated GitHub Actions workflow",
"session_activity",
now - 3 * 86400, "project",
)?;
let memories = memory::get_recent_memories(&conn, "tools/remem", 50)?;
assert!(!memories.is_empty(), "No memories found");
let mut scored: Vec<(i64, &str, &str, f64)> = memories
.iter()
.map(|m| {
let type_weight: f64 = match m.memory_type.as_str() {
"decision" => 3.0,
"bugfix" => 2.5,
"architecture" => 2.0,
"preference" => 1.5,
"discovery" => 1.0,
_ => 0.5,
};
let age_days = (now - m.updated_at_epoch) / 86400;
let time_decay: f64 = if age_days <= 7 {
1.0
} else if age_days <= 30 {
0.7
} else {
0.4
};
let score = type_weight * time_decay;
(m.id, m.title.as_str(), m.memory_type.as_str(), score)
})
.collect();
scored.sort_by(|a, b| b.3.partial_cmp(&a.3).unwrap());
eprintln!("[Context Score] Ranking:");
for (id, title, mtype, score) in &scored {
eprintln!(" #{} [{:.1}] {} ({})", id, score, title, mtype);
}
assert_eq!(
scored[0].0, decision_recent,
"Recent decision should rank #1"
);
assert_eq!(scored[1].0, bugfix_recent, "Recent bugfix should rank #2");
let old_decision_score = scored.iter().find(|s| s.0 == decision_old).unwrap().3;
let old_discovery_score = scored.iter().find(|s| s.0 == discovery_old).unwrap().3;
assert!(
old_decision_score > old_discovery_score,
"Old decision ({:.1}) should outrank old discovery ({:.1})",
old_decision_score,
old_discovery_score
);
let session_score = scored.iter().find(|s| s.0 == session_activity).unwrap().3;
assert!(
session_score < 1.0,
"Session activity score {:.1} should be low",
session_score
);
let top3_high_value = scored
.iter()
.take(3)
.filter(|s| s.2 == "decision" || s.2 == "bugfix")
.count();
let context_score = top3_high_value as f64 / 3.0;
eprintln!(
"[Context Score] high_value_in_top3={}/3 = {:.2}",
top3_high_value, context_score
);
assert!(
context_score >= 0.6,
"Context relevance {:.2} below threshold 0.6",
context_score
);
Ok(())
}
#[test]
fn bench_cross_session_decision_retrieval() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
memory::insert_memory(
&conn,
Some("sess-A"),
"tools/remem",
Some("fts5-vs-tantivy"),
"Chose FTS5 over Tantivy for search",
"Decision: Use SQLite FTS5 instead of Tantivy. Rationale: single-file DB, \
no additional binary, trigram tokenizer handles CJK, good enough performance \
for <100k memories.",
"decision",
None,
)?;
let results = search::search(
&conn,
Some("FTS5 Tantivy search decision"),
Some("tools/remem"),
None,
5,
0,
true,
)?;
eprintln!(
"[Cross-Session] query='FTS5 Tantivy' results={}",
results.len()
);
assert!(
!results.is_empty(),
"Cross-session decision not found by search"
);
assert_eq!(
results[0].title, "Chose FTS5 over Tantivy for search",
"Decision should be the top result"
);
assert_eq!(
results[0].memory_type, "decision",
"Result should be a decision type"
);
memory::insert_memory(
&conn,
Some("sess-C"),
"tools/remem",
Some("fts5-vs-tantivy"),
"Chose FTS5 over Tantivy for search",
"Updated decision: Still using FTS5. Added LIKE fallback for short queries. \
Performance confirmed at 5ms for 10k records.",
"decision",
None,
)?;
let all_fts = search::search(
&conn,
Some("FTS5 Tantivy"),
Some("tools/remem"),
None,
10,
0,
true,
)?;
let fts_decisions: Vec<_> = all_fts
.iter()
.filter(|m| m.topic_key.as_deref() == Some("fts5-vs-tantivy"))
.collect();
assert_eq!(
fts_decisions.len(),
1,
"topic_key upsert should prevent duplicates, found {}",
fts_decisions.len()
);
assert!(
fts_decisions[0].text.contains("LIKE fallback"),
"Memory should contain updated content"
);
Ok(())
}
#[test]
fn bench_global_scope_cross_project() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let now = chrono::Utc::now().timestamp();
insert_memory_at(
&conn,
"tools/remem",
"Prefer English commit messages",
"Always use English for git commit messages and code comments",
"preference",
now,
"global",
)?;
insert_memory_at(
&conn,
"tools/remem",
"FTS5 search design",
"Search implementation details specific to remem",
"architecture",
now,
"project",
)?;
let project_b_memories = memory::get_recent_memories(&conn, "web/dashboard", 50)?;
let global_visible = project_b_memories
.iter()
.any(|m| m.title == "Prefer English commit messages");
let project_leaked = project_b_memories
.iter()
.any(|m| m.title == "FTS5 search design");
eprintln!(
"[Global Scope] project_b sees: {} memories, global_visible={}, project_leaked={}",
project_b_memories.len(),
global_visible,
project_leaked
);
assert!(
global_visible,
"Global preference should be visible in Project B"
);
assert!(
!project_leaked,
"Project-scoped memory should NOT leak to Project B"
);
Ok(())
}
#[test]
fn bench_time_decay_ranks_newer_higher() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let now = chrono::Utc::now().timestamp();
let new_id = insert_memory_at(
&conn,
"tools/remem",
"Database connection pooling strategy",
"Use single connection with WAL mode for SQLite",
"decision",
now - 2 * 86400, "project",
)?;
let old_id = insert_memory_at(
&conn,
"tools/remem",
"Database connection pooling approach",
"Originally considered connection pool but SQLite works better single-threaded",
"decision",
now - 60 * 86400, "project",
)?;
let results = search::search(
&conn,
Some("database connection pooling"),
Some("tools/remem"),
None,
10,
0,
true,
)?;
assert!(
results.len() >= 2,
"Should find both memories, found {}",
results.len()
);
eprintln!("[Time Decay] Results:");
for m in &results {
let age = (now - m.updated_at_epoch) / 86400;
eprintln!(" #{} age={}d {}", m.id, age, m.title);
}
let found_new = results.iter().any(|m| m.id == new_id);
let found_old = results.iter().any(|m| m.id == old_id);
assert!(found_new, "New memory should be in search results");
assert!(found_old, "Old memory should be in search results");
let score = |m: &memory::Memory| -> f64 {
let type_weight: f64 = match m.memory_type.as_str() {
"decision" => 3.0,
"bugfix" => 2.5,
_ => 1.0,
};
let age_days = (now - m.updated_at_epoch) / 86400;
let decay: f64 = if age_days <= 7 {
1.0
} else if age_days <= 30 {
0.7
} else {
0.4
};
type_weight * decay
};
let new_mem = results.iter().find(|m| m.id == new_id).unwrap();
let old_mem = results.iter().find(|m| m.id == old_id).unwrap();
let new_score = score(new_mem);
let old_score = score(old_mem);
eprintln!(
"[Time Decay] new_score={:.1} old_score={:.1}",
new_score, old_score
);
assert!(
new_score > old_score,
"Newer memory score ({:.1}) should exceed older ({:.1})",
new_score,
old_score
);
Ok(())
}
#[test]
fn bench_summary_parse_full() -> Result<()> {
let xml = summary_xml_with_all_fields();
let parsed = summarize::parse_summary(&xml);
assert!(parsed.is_some(), "Full summary should parse successfully");
let p = parsed.unwrap();
assert!(
p.request
.as_ref()
.is_some_and(|r| r.contains("SessionStart preferences")),
"request field should be extracted"
);
assert!(
p.decisions.as_ref().is_some_and(|d| d.contains("project")),
"decisions field should be extracted"
);
assert!(
p.learned.as_ref().is_some_and(|l| l.contains("FTS5")),
"learned field should be extracted"
);
assert!(
p.preferences
.as_ref()
.is_some_and(|pref| pref.contains("English")),
"preferences field should be extracted"
);
assert!(p.completed.is_some(), "completed field should be extracted");
assert!(
p.next_steps.is_some(),
"next_steps field should be extracted"
);
eprintln!(
"[Summary Parse] All 6 fields extracted: request={} completed={} decisions={} learned={} next_steps={} preferences={}",
p.request.is_some(),
p.completed.is_some(),
p.decisions.is_some(),
p.learned.is_some(),
p.next_steps.is_some(),
p.preferences.is_some(),
);
Ok(())
}
#[test]
fn bench_summary_parse_skip() {
let xml = summary_xml_skip();
let parsed = summarize::parse_summary(&xml);
assert!(parsed.is_none(), "skip_summary should return None");
}
#[test]
fn bench_summary_parse_partial() -> Result<()> {
let xml = summary_xml_partial();
let parsed = summarize::parse_summary(&xml);
assert!(parsed.is_some(), "Partial summary should parse");
let p = parsed.unwrap();
assert!(p.request.is_some(), "request should be present");
assert!(p.completed.is_some(), "completed should be present");
assert!(p.decisions.is_none(), "decisions should be absent");
assert!(p.learned.is_none(), "learned should be absent");
assert!(p.preferences.is_none(), "preferences should be absent");
Ok(())
}
#[test]
fn bench_summary_promote_creates_candidates() -> Result<()> {
let mut conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let session_id = "promote-test-001";
let project = "tools/remem";
db::record_captured_event(
&conn,
&db::CaptureEventInput {
host: "codex-cli",
session_id,
project,
cwd: Some(project),
event_type: "session_stop",
role: None,
tool_name: None,
content: "summary source payload",
task_kind: None,
},
)?;
memory::promote_summary_to_memory_candidates(
&mut conn,
session_id,
project,
Some("Implement benchmark framework"),
Some("Use FTS5 for search, chose Rust for single-binary deployment"),
Some("FTS5 trigram tokenizer handles CJK well"),
Some("Always run cargo check before cargo test"),
)?;
let memory_count: i64 =
conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
let decisions: i64 = conn.query_row(
"SELECT COUNT(*) FROM memory_candidates WHERE memory_type = 'decision'",
[],
|row| row.get(0),
)?;
let discoveries: i64 = conn.query_row(
"SELECT COUNT(*) FROM memory_candidates WHERE memory_type = 'discovery'",
[],
|row| row.get(0),
)?;
let preferences: i64 = conn.query_row(
"SELECT COUNT(*) FROM memory_candidates WHERE memory_type = 'preference'",
[],
|row| row.get(0),
)?;
eprintln!(
"[Promote] active_memories={} decisions={} discoveries={} preferences={}",
memory_count, decisions, discoveries, preferences,
);
assert_eq!(memory_count, 0, "Summaries must not write active memories");
assert!(
decisions > 0,
"Decisions should be promoted to memory candidates"
);
assert!(
discoveries > 0,
"Discoveries should be promoted to memory candidates"
);
assert!(preferences > 0, "Preferences should become candidates");
let project_prefs: i64 = conn.query_row(
"SELECT COUNT(*) FROM memory_candidates
WHERE memory_type = 'preference' AND scope = 'project'",
[],
|row| row.get(0),
)?;
eprintln!("[Promote] project preference candidates: {project_prefs}/{preferences}");
assert!(
project_prefs > 0,
"Preference candidates should default to project scope"
);
Ok(())
}
#[test]
fn bench_search_filter_by_type() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
memory::insert_memory(
&conn,
Some("s1"),
"tools/remem",
None,
"FTS5 search design",
"Technical architecture for search",
"architecture",
None,
)?;
memory::insert_memory(
&conn,
Some("s1"),
"tools/remem",
None,
"Fixed FTS5 crash",
"Wrapped tokens in quotes",
"bugfix",
None,
)?;
memory::insert_memory(
&conn,
Some("s1"),
"tools/remem",
None,
"Search uses FTS5",
"Decision to use FTS5",
"decision",
None,
)?;
let decisions = search::search(
&conn,
Some("FTS5"),
Some("tools/remem"),
Some("decision"),
10,
0,
true,
)?;
assert_eq!(
decisions.len(),
1,
"Type filter should return only decisions"
);
assert_eq!(decisions[0].memory_type, "decision");
let bugfixes = search::search(
&conn,
Some("FTS5"),
Some("tools/remem"),
Some("bugfix"),
10,
0,
true,
)?;
assert_eq!(bugfixes.len(), 1, "Type filter should return only bugfixes");
let all = search::search(&conn, Some("FTS5"), Some("tools/remem"), None, 10, 0, true)?;
assert_eq!(all.len(), 3, "Without type filter should return all 3");
Ok(())
}
#[test]
fn bench_topic_key_dedup() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let project = "tools/remem";
let topic = "search-strategy";
let id1 = memory::insert_memory(
&conn,
Some("s1"),
project,
Some(topic),
"Search strategy v1",
"Initial FTS5 approach",
"decision",
None,
)?;
let id2 = memory::insert_memory(
&conn,
Some("s2"),
project,
Some(topic),
"Search strategy v2",
"FTS5 with LIKE fallback for short tokens",
"decision",
None,
)?;
assert_eq!(id1, id2, "Same topic_key should return same ID (upsert)");
let all = memory::get_recent_memories(&conn, project, 50)?;
let strategy_mems: Vec<_> = all
.iter()
.filter(|m| m.topic_key.as_deref() == Some(topic))
.collect();
assert_eq!(
strategy_mems.len(),
1,
"Should have exactly 1 memory for topic_key"
);
assert!(
strategy_mems[0].text.contains("LIKE fallback"),
"Content should be the updated version"
);
Ok(())
}
#[test]
fn bench_multi_hop_entity_graph_retrieval() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let id1 = memory::insert_memory(
&conn,
Some("s1"),
"personal",
None,
"Family update from Melanie",
"Melanie mentioned her son Tom started kindergarten this fall. \
She also talked about her daughter Sarah who is in 3rd grade.",
"discovery",
None,
)?;
let id2 = memory::insert_memory(
&conn,
Some("s2"),
"personal",
None,
"Tom's hobbies",
"Tom loves dinosaurs and building Lego sets. He wants a T-Rex for his birthday.",
"discovery",
None,
)?;
let id3 = memory::insert_memory(
&conn,
Some("s3"),
"personal",
None,
"Sarah's school activities",
"Sarah is on the school swim team and loves reading Harry Potter books.",
"discovery",
None,
)?;
memory::insert_memory(
&conn,
Some("s4"),
"personal",
None,
"Weekend plans",
"Going hiking at the national park this Saturday.",
"discovery",
None,
)?;
entity::link_entities(
&conn,
id1,
&[
"Melanie".to_string(),
"Tom".to_string(),
"Sarah".to_string(),
],
)?;
entity::link_entities(&conn, id2, &["Tom".to_string(), "Lego".to_string()])?;
entity::link_entities(&conn, id3, &["Sarah".to_string()])?;
let standard = search::search(
&conn,
Some("Melanie kids"),
Some("personal"),
None,
10,
0,
true,
)?;
let standard_ids: Vec<i64> = standard.iter().map(|m| m.id).collect();
let multi = search_multihop::search_multi_hop(
&conn,
"Melanie kids",
Some("personal"),
10,
0,
None,
None,
true,
false,
)?;
let multi_ids: Vec<i64> = multi.memories.iter().map(|m| m.id).collect();
eprintln!("[Multi-hop] Standard search found: {:?}", standard_ids);
eprintln!("[Multi-hop] Multi-hop search found: {:?}", multi_ids);
eprintln!("[Multi-hop] Hops: {}", multi.hops);
eprintln!(
"[Multi-hop] Entities discovered: {:?}",
multi.entities_discovered
);
assert!(
standard_ids.contains(&id1),
"Standard search should find Melanie memory"
);
assert!(
multi_ids.contains(&id1),
"Multi-hop should find Melanie memory"
);
assert!(
multi_ids.contains(&id2),
"Multi-hop should find Tom's hobbies via entity graph"
);
assert!(
multi_ids.contains(&id3),
"Multi-hop should find Sarah's activities via entity graph"
);
assert!(
!multi.entities_discovered.is_empty(),
"Should have discovered entities from first-hop results",
);
let relevant = vec![id1, id2, id3];
let multi_recall = recall_at_k(&multi_ids, &relevant, 10);
eprintln!("[Multi-hop] Multi-hop R@10={:.2}", multi_recall);
assert!(
multi_recall >= 1.0,
"Multi-hop should find all relevant memories, R@10={:.2}",
multi_recall,
);
Ok(())
}
#[test]
fn bench_entity_graph_expansion_finds_related() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let id_a = memory::insert_memory(
&conn,
Some("s1"),
"proj",
None,
"Project setup with React and TypeScript",
"Configured React with TypeScript template.",
"architecture",
None,
)?;
let id_b = memory::insert_memory(
&conn,
Some("s2"),
"proj",
None,
"TypeScript strict mode config",
"Enabled strict mode in tsconfig.",
"decision",
None,
)?;
let id_c = memory::insert_memory(
&conn,
Some("s3"),
"proj",
None,
"ESLint config for strict mode",
"Added eslint-config-strict rules.",
"decision",
None,
)?;
entity::link_entities(
&conn,
id_a,
&["React".to_string(), "TypeScript".to_string()],
)?;
entity::link_entities(&conn, id_b, &["TypeScript".to_string()])?;
entity::link_entities(&conn, id_c, &["ESLint".to_string()])?;
let expanded = entity::expand_via_entity_graph(&conn, &[id_a], &[], None, 10)?;
assert!(
expanded.contains(&id_b),
"Graph expansion from A should find B (shared entity: TypeScript). Got: {:?}",
expanded,
);
assert!(
!expanded.contains(&id_c),
"Graph expansion from A should NOT find C (no shared entity)",
);
Ok(())
}
#[test]
fn bench_aggregate_report() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_full_schema(&conn)?;
let seeds = search_eval_memories();
let ids = insert_seed_memories(&conn, &seeds)?;
let fts_relevant: Vec<i64> = seeds
.iter()
.zip(ids.iter())
.filter(|(s, _)| s.relevant_to_fts_query)
.map(|(_, id)| *id)
.collect();
let decay_relevant: Vec<i64> = seeds
.iter()
.zip(ids.iter())
.filter(|(s, _)| s.relevant_to_decay_query)
.map(|(_, id)| *id)
.collect();
let fts_results = search::search(
&conn,
Some("FTS5 search"),
Some("tools/remem"),
None,
10,
0,
true,
)?;
let fts_ids: Vec<i64> = fts_results.iter().map(|m| m.id).collect();
let decay_results = search::search(
&conn,
Some("time decay ranking"),
Some("tools/remem"),
None,
10,
0,
true,
)?;
let decay_ids: Vec<i64> = decay_results.iter().map(|m| m.id).collect();
eprintln!("\n========================================");
eprintln!(" remem Benchmark Report");
eprintln!("========================================");
eprintln!(
" FTS5 Search P@5={:.2} R@10={:.2}",
precision_at_k(&fts_ids, &fts_relevant, 5),
recall_at_k(&fts_ids, &fts_relevant, 10)
);
eprintln!(
" Decay Search P@5={:.2} R@10={:.2}",
precision_at_k(&decay_ids, &decay_relevant, 5),
recall_at_k(&decay_ids, &decay_relevant, 10)
);
eprintln!(" Total seeds: {}", seeds.len());
eprintln!(" FTS relevant: {}", fts_relevant.len());
eprintln!(" Decay relevant: {}", decay_relevant.len());
eprintln!("========================================\n");
Ok(())
}