Skip to content

Commit 82d510c

Browse files
eval: add IR-style search benchmark + regression tests for 50d1a4d
New developer tool: `--search-eval [--queries path.toml] [--report path.json]` runs a labeled query set through the same pipeline as the live TUI and reports standard information-retrieval metrics: - MRR (mean reciprocal rank) — primary "find the right one" metric - P@1 — did we nail it first try? - Recall@5 / @10 / @20 — does target appear in the top N? - Failure list — queries below rank 20 Metrics are aggregated overall and per category, so a change that helps one query shape but regresses another is visible instead of averaged out. Privacy: real query text and session ids stay local. The committed `eval/search-queries.example.toml` shows the schema with synthetic placeholders. `eval/search-queries.toml` is gitignored. Per-run JSON reports go to `eval/runs/<sha>.json` (also gitignored) for diffing. Workflow: 1. Capture baseline before any search change: `--search-eval --report ...` 2. Make the change 3. Re-run; diff JSON to see per-query rank deltas 4. If MRR drops or a category regresses, fix or revert Also adds regression tests in src/log_search.rs for the bug fixes from the previous commit (per testing.instructions.md regression-test policy): - read_tail_returns_whole_small_file — verifies the 2 MB threshold - read_tail_big_file_captures_head_and_tail — covers the short-read fix - search_uses_or_semantics_so_missing_term_does_not_kill_recall — covers the AND→OR default switch 129 unit tests pass; clippy clean on both crates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 50d1a4d commit 82d510c

6 files changed

Lines changed: 600 additions & 0 deletions

File tree

eval/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Private query set with real session ids and topic words.
2+
# Generated from eval/search-queries.example.toml — replaced with real data.
3+
search-queries.toml
4+
5+
# Per-run JSON reports — large, noisy in diffs, regenerate on demand.
6+
runs/

eval/search-queries.example.toml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Search evaluation queries — EXAMPLE
2+
#
3+
# Copy this file to `eval/search-queries.toml` and replace with real queries
4+
# from your own session history. `eval/search-queries.toml` is gitignored
5+
# so private query text + real session ids stay local.
6+
#
7+
# Each [[query]] entry needs:
8+
# text = the query string you'd type in the TUI
9+
# target = the expected session id (UUID for Copilot, hash for Claude, etc.)
10+
# category = bucket for per-category aggregation
11+
# notes = optional human note, not used in scoring
12+
#
13+
# Suggested categories — keep coverage balanced across these so the report
14+
# tells you where strengths and weaknesses live:
15+
#
16+
# exact-title query is verbatim (or near-verbatim) of the title
17+
# partial-recall most words right, some missing or out of order
18+
# typo misspelled or phonetic spelling
19+
# semantic-only target uses different vocabulary; semantic only
20+
# keyword tool/library name lookup (e.g. "ratatui mouse")
21+
# person-name queries with a teammate's name
22+
# time-fuzzy "last week", "this morning", etc.
23+
#
24+
# Run with:
25+
# agent-session-tui --search-eval
26+
# agent-session-tui --search-eval --report eval/runs/<sha>.json
27+
28+
[[query]]
29+
text = "rust tantivy index threshold"
30+
target = "00000000-0000-0000-0000-000000000000"
31+
category = "keyword"
32+
notes = "Replace target with a real session id from your own data."
33+
34+
[[query]]
35+
text = "memo summarizer prototype"
36+
target = "00000000-0000-0000-0000-000000000001"
37+
category = "exact-title"
38+
39+
[[query]]
40+
text = "yaml provider config refactor"
41+
target = "00000000-0000-0000-0000-000000000002"
42+
category = "partial-recall"

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod models;
1212
pub mod process_info;
1313
pub mod provider;
1414
pub mod search;
15+
pub mod search_eval;
1516
pub mod supervisor;
1617
pub mod testing;
1718
pub mod ui;

src/log_search.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,86 @@ fn escape_query(q: &str) -> String {
518518
}
519519
out
520520
}
521+
522+
523+
#[cfg(test)]
524+
mod tests {
525+
use super::*;
526+
use std::io::Write;
527+
528+
/// Regression for `WHOLE_FILE_THRESHOLD` (raised 512 KB → 2 MB in commit
529+
/// 50d1a4d). A small file must be returned in full, not head/tail-split.
530+
#[test]
531+
fn read_tail_returns_whole_small_file() {
532+
let tmp = tempfile::tempdir().unwrap();
533+
let path = tmp.path().join("small.jsonl");
534+
let body = "first line\nsecond line\n".repeat(100);
535+
std::fs::write(&path, &body).unwrap();
536+
let out = read_tail(&path).expect("read_tail should succeed");
537+
assert_eq!(out, body, "small file must be returned verbatim");
538+
}
539+
540+
/// Regression for the short-read bug fixed in 50d1a4d. Previously the
541+
/// big-file branch used `f.read(&mut head_bytes)` which is permitted to
542+
/// return fewer bytes than requested. Now it uses `take().read_to_end()`,
543+
/// which loops until EOF or the cap. We verify head + tail contain the
544+
/// expected markers placed at known offsets in a 3 MB file.
545+
#[test]
546+
fn read_tail_big_file_captures_head_and_tail() {
547+
let tmp = tempfile::tempdir().unwrap();
548+
let path = tmp.path().join("big.jsonl");
549+
let mut f = std::fs::File::create(&path).unwrap();
550+
// ~3 MB total: HEAD_TOKEN near start, filler, TAIL_TOKEN near end.
551+
write!(f, "HEAD_TOKEN_AT_START\n").unwrap();
552+
let filler = "x".repeat(3 * 1024 * 1024);
553+
f.write_all(filler.as_bytes()).unwrap();
554+
write!(f, "\nTAIL_TOKEN_AT_END\n").unwrap();
555+
drop(f);
556+
let out = read_tail(&path).expect("read_tail should succeed");
557+
assert!(
558+
out.contains("HEAD_TOKEN_AT_START"),
559+
"head slice must include the start marker"
560+
);
561+
assert!(
562+
out.contains("TAIL_TOKEN_AT_END"),
563+
"tail slice must include the end marker"
564+
);
565+
// Sanity: head + tail strictly smaller than the whole file (skipped middle).
566+
assert!(
567+
(out.len() as u64) < (HEAD_BYTES + TAIL_BYTES + 1024 + 1024),
568+
"big-file branch must NOT return the whole file"
569+
);
570+
}
571+
572+
/// Regression for the AND-default-killed-recall bug (50d1a4d). When the
573+
/// query contains a term not present in any indexed document, the search
574+
/// must still return matches on the other terms — not silently drop them.
575+
#[test]
576+
fn search_uses_or_semantics_so_missing_term_does_not_kill_recall() {
577+
let tmp = tempfile::tempdir().unwrap();
578+
let searcher = LogSearcher::open_or_create(tmp.path()).expect("open index");
579+
// Hand-add one doc via the searcher's own writer mutex — opening a
580+
// second writer on the same Index would deadlock on tantivy's lock.
581+
{
582+
let mut writer = searcher.writer.lock().unwrap();
583+
let mut doc = TantivyDocument::default();
584+
doc.add_text(searcher.session_id_field, "session-1");
585+
doc.add_text(
586+
searcher.content_field,
587+
"alpha beta gamma delta epsilon zeta",
588+
);
589+
writer.add_document(doc).expect("add doc");
590+
writer.commit().expect("commit");
591+
}
592+
searcher.reader.reload().expect("reload");
593+
594+
// Two of three terms exist; "xyzzy" does not appear anywhere.
595+
// Strict AND would return 0; OR-default must still find session-1.
596+
let hits = searcher.search("alpha beta xyzzy");
597+
assert!(
598+
hits.contains_key("session-1"),
599+
"OR-default must surface docs matching ≥1 query term, got hits: {:?}",
600+
hits
601+
);
602+
}
603+
}

src/main.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod models;
1010
mod process_info;
1111
mod provider;
1212
mod search;
13+
mod search_eval;
1314
mod supervisor;
1415
mod ui;
1516
mod util;
@@ -278,6 +279,26 @@ async fn main() -> Result<()> {
278279
}
279280
// -----------------------------------------------------------------------
280281

282+
// --- search-eval IR benchmark -------------------------------------------
283+
// `--search-eval [--queries path/to/queries.toml] [--report out.json]`
284+
// runs a labeled query set through the same pipeline and reports
285+
// MRR / P@1 / Recall@K aggregated overall and per category. Default
286+
// queries file is `eval/search-queries.toml` (gitignored) with fallback
287+
// to `eval/search-queries.example.toml`.
288+
if args.iter().any(|a| a == "--search-eval") {
289+
let queries: Option<String> = args
290+
.iter()
291+
.position(|a| a == "--queries")
292+
.and_then(|i| args.get(i + 1).cloned());
293+
let report: Option<String> = args
294+
.iter()
295+
.position(|a| a == "--report")
296+
.and_then(|i| args.get(i + 1).cloned());
297+
search_eval::run_search_eval(&registry, &config, queries.as_deref(), report.as_deref())?;
298+
return Ok(());
299+
}
300+
// -----------------------------------------------------------------------
301+
281302
let registry = Arc::new(registry);
282303

283304
let (event_tx, event_rx) = mpsc::unbounded_channel();

0 commit comments

Comments
 (0)