//! Integration tests for P5-2: write two synthetic eval runs into a //! SQLite store, then drive `compute_aggregate` / `store_aggregate` / //! `compare_runs` end-to-end. Mirrors the test plan in //! `tasks/p5/p5-2-metrics-compare.md`. //! //! Snapshot of `CompareReport` JSON is pinned at //! `tests/fixtures/eval/compare-1.json`. use std::fs; use std::path::PathBuf; use kebab_config::Config; use kebab_core::{ ChunkId, ChunkerVersion, Citation, DocumentId, IndexVersion, Lang, RetrievalDetail, SearchHit, SearchMode, asset::WorkspacePath, }; use kebab_eval::{ AggregateMetrics, CompareOpts, CompareReport, ComparisonKind, GoldenQuery, QueryResult, compare_runs_with_config, compute_aggregate_with_config, store_aggregate_with_config, }; use kebab_store_sqlite::{EvalRunRow, SqliteStore}; use tempfile::TempDir; use time::OffsetDateTime; fn cfg_with_data_dir(tmp: &TempDir, golden_yaml: &str) -> Config { let mut cfg = Config::defaults(); cfg.storage.data_dir = tmp.path().to_string_lossy().into_owned(); cfg.storage.runs_dir = tmp.path().join("runs").to_string_lossy().into_owned(); cfg.storage.copy_threshold_mb = 0; let golden_path = tmp.path().join("golden.yaml"); fs::write(&golden_path, golden_yaml).unwrap(); // Point both metrics + compare at the temp golden via env override. // SAFELY scoped — `set_var` is process-global so callers serialise // tests via the `serial_test`-style guard below. unsafe { std::env::set_var("KEBAB_EVAL_GOLDEN", &golden_path); } cfg } fn golden_yaml_basic() -> &'static str { r#" - id: q-001 query: hit at rank 1 expected_doc_ids: ["doc-1"] expected_chunk_ids: ["chunk-1"] - id: q-002 query: hit at rank 4 expected_doc_ids: ["doc-2"] expected_chunk_ids: ["chunk-2"] - id: q-003 query: miss everywhere expected_doc_ids: ["doc-3"] expected_chunk_ids: ["chunk-3"] "# } fn hit(rank: u32, chunk_id: &str, doc_id: &str) -> SearchHit { SearchHit { rank, chunk_id: ChunkId(chunk_id.into()), doc_id: DocumentId(doc_id.into()), doc_path: WorkspacePath::new(format!("docs/{doc_id}.md")).unwrap(), heading_path: vec!["root".into()], section_label: None, snippet: "snip".into(), citation: Citation::Line { path: WorkspacePath::new(format!("docs/{doc_id}.md")).unwrap(), start: 1, end: 1, section: None, }, retrieval: RetrievalDetail { method: SearchMode::Lexical, fusion_score: 1.0 / f32::from(u16::try_from(rank).unwrap_or(1)), lexical_score: Some(1.0), vector_score: None, lexical_rank: Some(rank), vector_rank: None, }, index_version: IndexVersion("idx@1".into()), embedding_model: None, chunker_version: ChunkerVersion("test@1".into()), // fb-32: synthetic eval fixtures don't exercise staleness; // pin UNIX_EPOCH + stale=false so hits stay deterministic. indexed_at: OffsetDateTime::UNIX_EPOCH, stale: false, score_kind: kebab_core::ScoreKind::Rrf, repo: None, code_lang: None, } } fn qr(query_id: &str, hits: Vec) -> QueryResult { QueryResult { query_id: query_id.into(), query: format!("query for {query_id}"), mode: SearchMode::Lexical, hits_top_k: hits, answer: None, elapsed_ms: 1, error: None, } } fn write_run( store: &SqliteStore, run_id: &str, chunker_version: &str, created_at: OffsetDateTime, queries: Vec, ) { let snapshot = serde_json::json!({ "config": {}, "chunker_version": chunker_version, }); let snapshot_text = serde_json::to_string(&snapshot).unwrap(); let row = EvalRunRow { run_id, suite: "golden", config_snapshot_json: &snapshot_text, aggregate_json: "{}", commit_hash: Some("0000000"), created_at, }; let results: Vec<(String, String)> = queries .into_iter() .map(|qr| { let json = serde_json::to_string(&qr).unwrap(); (qr.query_id, json) }) .collect(); store.record_eval_run_with_results(&row, &results).unwrap(); } /// Each test mutates a process-global env var (`KEBAB_EVAL_GOLDEN`) and /// expects to see its own write. Take this mutex around the body of /// every test that touches `KEBAB_EVAL_GOLDEN` so two concurrent test /// threads don't trip over each other's golden YAML. fn env_guard() -> std::sync::MutexGuard<'static, ()> { use std::sync::{Mutex, OnceLock}; static M: OnceLock> = OnceLock::new(); M.get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } #[test] fn compute_and_store_aggregate_round_trips() { let _g = env_guard(); let tmp = TempDir::new().unwrap(); let cfg = cfg_with_data_dir(&tmp, golden_yaml_basic()); let store = SqliteStore::open(&cfg).unwrap(); store.run_migrations().unwrap(); let now = OffsetDateTime::UNIX_EPOCH; write_run( &store, "run_a", "test@1", now, vec![ qr("q-001", vec![hit(1, "chunk-1", "doc-1")]), qr( "q-002", vec![ hit(1, "x", "x"), hit(2, "x", "x"), hit(3, "x", "x"), hit(4, "chunk-2", "doc-2"), ], ), qr("q-003", vec![hit(1, "x", "x")]), ], ); drop(store); let agg = compute_aggregate_with_config(&cfg, "run_a").unwrap(); // hit@1 = 1/3, hit@5 = 2/3, MRR = (1 + 0.25 + 0)/3 ≈ 0.4167. assert_eq!(agg.hit_at_k[&1], 0.3333); assert_eq!(agg.hit_at_k[&5], 0.6667); assert_eq!(agg.mrr, 0.4167); store_aggregate_with_config(&cfg, "run_a", &agg).unwrap(); let store = SqliteStore::open(&cfg).unwrap(); let row = store.load_eval_run("run_a").unwrap().unwrap(); let parsed: AggregateMetrics = serde_json::from_str(&row.aggregate_json).unwrap(); // f32 round-trip via JSON is exact for our 4-decimal-rounded // values, so direct equality is OK here (NaN fields are handled // by the `serialize_f32_nan_as_null` round-trip — `citation_coverage` // and `refusal_correctness` come back as NaN). Compare on JSON // bytes instead, which is what `store_aggregate` writes. assert_eq!( serde_json::to_string(&parsed).unwrap(), serde_json::to_string(&agg).unwrap() ); } #[test] fn store_aggregate_rejects_missing_run() { let _g = env_guard(); let tmp = TempDir::new().unwrap(); let cfg = cfg_with_data_dir(&tmp, golden_yaml_basic()); let agg = AggregateMetrics { hit_at_k: Default::default(), mrr: 0.0, recall_at_k_doc: Default::default(), precision_at_k_chunk: Default::default(), citation_coverage: f32::NAN, groundedness: 0.0, empty_result_rate: 0.0, refusal_correctness: f32::NAN, total_queries: 0, failed_queries: 0, }; let err = store_aggregate_with_config(&cfg, "run_does_not_exist", &agg).unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("run_does_not_exist"), "msg = {msg}"); } #[test] fn compare_runs_classifies_win_loss_draw_regression() { let _g = env_guard(); let tmp = TempDir::new().unwrap(); let cfg = cfg_with_data_dir(&tmp, golden_yaml_basic()); let store = SqliteStore::open(&cfg).unwrap(); store.run_migrations().unwrap(); let now = OffsetDateTime::UNIX_EPOCH; // Run A: // q-001 rank 1 → hit // q-002 rank 4 → hit // q-003 miss write_run( &store, "run_a", "test@1", now, vec![ qr("q-001", vec![hit(1, "chunk-1", "doc-1")]), qr( "q-002", vec![ hit(1, "x", "x"), hit(2, "x", "x"), hit(3, "x", "x"), hit(4, "chunk-2", "doc-2"), ], ), qr("q-003", vec![hit(1, "x", "x")]), ], ); // Run B: // q-001 rank 2 → still hit (Loss vs A — worse rank) // q-002 rank 1 → hit (Win — improved rank) // q-003 hit @ rank 1 → hit (Win — was miss in A) write_run( &store, "run_b", "test@1", now, vec![ qr("q-001", vec![hit(1, "x", "x"), hit(2, "chunk-1", "doc-1")]), qr("q-002", vec![hit(1, "chunk-2", "doc-2")]), qr("q-003", vec![hit(1, "chunk-3", "doc-3")]), ], ); drop(store); let report = compare_runs_with_config(&cfg, "run_a", "run_b", &CompareOpts::default()).unwrap(); let by_id: std::collections::HashMap<&str, &kebab_eval::QueryComparison> = report .per_query .iter() .map(|c| (c.query_id.as_str(), c)) .collect(); assert_eq!(by_id["q-001"].kind, ComparisonKind::Loss); assert_eq!(by_id["q-002"].kind, ComparisonKind::Win); assert_eq!(by_id["q-003"].kind, ComparisonKind::Win); assert_eq!(report.deltas["chunker_version_match"], "exact"); } #[test] fn compare_strict_mode_refuses_chunker_version_mismatch() { let _g = env_guard(); let tmp = TempDir::new().unwrap(); let cfg = cfg_with_data_dir(&tmp, golden_yaml_basic()); let store = SqliteStore::open(&cfg).unwrap(); store.run_migrations().unwrap(); let now = OffsetDateTime::UNIX_EPOCH; write_run( &store, "run_a", "test@1", now, vec![qr("q-001", vec![hit(1, "chunk-1", "doc-1")])], ); write_run( &store, "run_b", "test@2", now, vec![qr("q-001", vec![hit(1, "chunk-1", "doc-1")])], ); drop(store); let opts = CompareOpts { strict_chunker_version: true, }; let err = compare_runs_with_config(&cfg, "run_a", "run_b", &opts).unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("chunker_version mismatch"), "msg = {msg}"); } #[test] fn compare_graceful_falls_back_to_doc_id() { let _g = env_guard(); let tmp = TempDir::new().unwrap(); let cfg = cfg_with_data_dir(&tmp, golden_yaml_basic()); let store = SqliteStore::open(&cfg).unwrap(); store.run_migrations().unwrap(); let now = OffsetDateTime::UNIX_EPOCH; // Run A uses test@1 chunker; run B uses test@2 — chunk_ids no longer // align, but doc_ids do. write_run( &store, "run_a", "test@1", now, vec![qr("q-001", vec![hit(1, "chunk-1", "doc-1")])], ); write_run( &store, "run_b", "test@2", now, // Different chunk_id, same doc_id → exact-mode matching would // miss; doc-id fallback should still register a hit. vec![qr("q-001", vec![hit(1, "chunk-1-renamed", "doc-1")])], ); drop(store); let report = compare_runs_with_config(&cfg, "run_a", "run_b", &CompareOpts::default()).unwrap(); assert_eq!(report.deltas["chunker_version_match"], "fallback_doc"); let q1 = report .per_query .iter() .find(|c| c.query_id == "q-001") .unwrap(); // Both runs hit doc-1 at rank 1 → Draw. assert_eq!(q1.kind, ComparisonKind::Draw); assert_eq!(q1.a_hit_rank, Some(1)); assert_eq!(q1.b_hit_rank, Some(1)); } #[test] fn compare_report_snapshot_matches_fixture() { let _g = env_guard(); let tmp = TempDir::new().unwrap(); let cfg = cfg_with_data_dir(&tmp, golden_yaml_basic()); let store = SqliteStore::open(&cfg).unwrap(); store.run_migrations().unwrap(); let now = OffsetDateTime::UNIX_EPOCH; write_run( &store, "run_a", "test@1", now, vec![ qr("q-001", vec![hit(1, "chunk-1", "doc-1")]), qr( "q-002", vec![ hit(1, "x", "x"), hit(2, "x", "x"), hit(3, "x", "x"), hit(4, "chunk-2", "doc-2"), ], ), qr("q-003", vec![hit(1, "x", "x")]), ], ); write_run( &store, "run_b", "test@1", now, vec![ qr("q-001", vec![hit(1, "x", "x"), hit(2, "chunk-1", "doc-1")]), qr("q-002", vec![hit(1, "chunk-2", "doc-2")]), qr("q-003", vec![hit(1, "chunk-3", "doc-3")]), ], ); drop(store); let report = compare_runs_with_config(&cfg, "run_a", "run_b", &CompareOpts::default()).unwrap(); let actual = projection(&report); let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("fixtures") .join("eval") .join("compare-1.json"); if std::env::var("UPDATE_SNAPSHOTS").is_ok() { fs::write( &fixture, format!("{}\n", serde_json::to_string_pretty(&actual).unwrap()), ) .unwrap(); } let expected_text = fs::read_to_string(&fixture) .unwrap_or_else(|e| panic!("missing fixture {}: {e}", fixture.display())); let expected: serde_json::Value = serde_json::from_str(&expected_text).unwrap(); assert_eq!( actual, expected, "compare report drift — re-run with UPDATE_SNAPSHOTS=1 if intended" ); } /// Project a `CompareReport` to the stable-across-runs subset. /// `aggregate_*` and `deltas` are deterministic; per-query rows keep /// only `(query_id, kind, ranks, note)` and discard volatile fields. fn projection(r: &CompareReport) -> serde_json::Value { serde_json::json!({ "run_a": r.run_a, "run_b": r.run_b, "aggregate_a": r.aggregate_a, "aggregate_b": r.aggregate_b, "deltas": r.deltas, "per_query": r.per_query, }) } #[test] fn render_report_md_is_human_readable() { let _g = env_guard(); let tmp = TempDir::new().unwrap(); let cfg = cfg_with_data_dir(&tmp, golden_yaml_basic()); let store = SqliteStore::open(&cfg).unwrap(); store.run_migrations().unwrap(); let now = OffsetDateTime::UNIX_EPOCH; write_run( &store, "run_a", "test@1", now, vec![qr("q-001", vec![hit(1, "chunk-1", "doc-1")])], ); write_run( &store, "run_b", "test@1", now, vec![qr("q-001", vec![hit(2, "chunk-1", "doc-1")])], ); drop(store); let report = compare_runs_with_config(&cfg, "run_a", "run_b", &CompareOpts::default()).unwrap(); let md = kebab_eval::render_report_md(&report); assert!(md.starts_with("# Eval compare:"), "md = {md}"); assert!(md.contains("hit@1")); assert!(md.contains("MRR")); assert!(md.contains("Wins")); assert!(md.contains("q-001")); } #[test] fn lang_default_is_used_when_omitted_in_yaml() { // Round-trip safety: GoldenQuery without `lang` should parse fine. let yaml = "- id: only\n query: q\n"; let _g = env_guard(); let tmp = TempDir::new().unwrap(); let golden = tmp.path().join("g.yaml"); fs::write(&golden, yaml).unwrap(); let qs: Vec = serde_yaml::from_str(yaml).unwrap(); assert_eq!(qs.len(), 1); assert_eq!(qs[0].lang, Lang(String::new())); }