Skip to main content

lean_ctx/core/
eval_harness.rs

1//! Retrieval evaluation harness for lean-ctx hybrid search.
2//!
3//! Runs a standardized query→expected_file benchmark to measure Recall@k,
4//! MRR (Mean Reciprocal Rank), and latency. Outputs NDJSON scorecards.
5//!
6//! Usage: `lean-ctx benchmark --eval [path]`
7
8use std::path::Path;
9use std::time::Instant;
10
11use crate::core::bm25_index::BM25Index;
12use crate::core::hybrid_search::HybridConfig;
13
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct EvalQuery {
16    pub query: String,
17    pub expected_files: Vec<String>,
18    #[serde(default)]
19    pub category: String,
20}
21
22#[derive(Debug, Clone, serde::Serialize)]
23pub struct EvalResult {
24    pub query: String,
25    pub category: String,
26    pub recall_at_5: f64,
27    pub recall_at_10: f64,
28    pub mrr: f64,
29    pub latency_us: u64,
30    pub retrieved_files: Vec<String>,
31    pub expected_files: Vec<String>,
32}
33
34#[derive(Debug, Clone, serde::Serialize)]
35pub struct EvalScorecard {
36    pub project: String,
37    pub total_queries: usize,
38    pub avg_recall_at_5: f64,
39    pub avg_recall_at_10: f64,
40    pub avg_mrr: f64,
41    pub avg_latency_us: u64,
42    pub per_category: Vec<CategoryScore>,
43    pub results: Vec<EvalResult>,
44}
45
46#[derive(Debug, Clone, serde::Serialize)]
47pub struct CategoryScore {
48    pub category: String,
49    pub count: usize,
50    pub avg_recall_at_5: f64,
51    pub avg_mrr: f64,
52}
53
54impl std::fmt::Display for EvalScorecard {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        writeln!(f, "Eval: {} ({} queries)", self.project, self.total_queries)?;
57        writeln!(f, "  R@5:  {:.1}%", self.avg_recall_at_5 * 100.0)?;
58        writeln!(f, "  R@10: {:.1}%", self.avg_recall_at_10 * 100.0)?;
59        writeln!(f, "  MRR:  {:.3}", self.avg_mrr)?;
60        writeln!(f, "  Latency: {}µs avg", self.avg_latency_us)?;
61        for cat in &self.per_category {
62            writeln!(
63                f,
64                "  [{:12}] R@5={:.1}% MRR={:.3} (n={})",
65                cat.category,
66                cat.avg_recall_at_5 * 100.0,
67                cat.avg_mrr,
68                cat.count
69            )?;
70        }
71        Ok(())
72    }
73}
74
75/// Run evaluation using the full hybrid search pipeline (BM25 + embeddings + SPLADE).
76/// Falls back to BM25-only if embeddings are not available.
77pub fn run_eval(
78    project_root: &Path,
79    queries: &[EvalQuery],
80    index: &BM25Index,
81    config: &HybridConfig,
82) -> EvalScorecard {
83    let label = project_root
84        .file_name()
85        .and_then(|s| s.to_str())
86        .unwrap_or("unknown")
87        .to_string();
88
89    let mut results = Vec::with_capacity(queries.len());
90
91    for q in queries {
92        let start = Instant::now();
93        let retrieved = hybrid_eval_search(project_root, &q.query, index, config);
94        let latency = start.elapsed().as_micros() as u64;
95
96        let recall_5 = recall_at_k(&retrieved, &q.expected_files, 5);
97        let recall_10 = recall_at_k(&retrieved, &q.expected_files, 10);
98        let mrr = mean_reciprocal_rank(&retrieved, &q.expected_files);
99
100        results.push(EvalResult {
101            query: q.query.clone(),
102            category: q.category.clone(),
103            recall_at_5: recall_5,
104            recall_at_10: recall_10,
105            mrr,
106            latency_us: latency,
107            retrieved_files: retrieved.into_iter().take(10).collect(),
108            expected_files: q.expected_files.clone(),
109        });
110    }
111
112    let total = results.len();
113    let avg_r5 = results.iter().map(|r| r.recall_at_5).sum::<f64>() / total.max(1) as f64;
114    let avg_r10 = results.iter().map(|r| r.recall_at_10).sum::<f64>() / total.max(1) as f64;
115    let avg_mrr = results.iter().map(|r| r.mrr).sum::<f64>() / total.max(1) as f64;
116    let avg_lat = results.iter().map(|r| r.latency_us).sum::<u64>() / total.max(1) as u64;
117
118    let per_category = build_category_scores(&results);
119
120    EvalScorecard {
121        project: label,
122        total_queries: total,
123        avg_recall_at_5: avg_r5,
124        avg_recall_at_10: avg_r10,
125        avg_mrr,
126        avg_latency_us: avg_lat,
127        per_category,
128        results,
129    }
130}
131
132/// Which retrieval pipeline an eval arm exercises (#686 default-flip decision).
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum SearchArm {
135    /// Full default pipeline: BM25 + dense embeddings + SPLADE + RRF + rerank.
136    Hybrid,
137    /// Pure lexical BM25 — the **conservative lower bound** of the lean
138    /// (`dense_enabled = false`) path, which additionally keeps graph proximity,
139    /// reranking and SPLADE on top. If pure BM25 already matches hybrid, the real
140    /// lean path is ≥ that, so flipping the default cannot regress quality.
141    Bm25Only,
142}
143
144impl SearchArm {
145    fn label(self) -> &'static str {
146        match self {
147            SearchArm::Hybrid => "hybrid (dense on)",
148            SearchArm::Bm25Only => "bm25-only (lean lower bound)",
149        }
150    }
151}
152
153/// Full hybrid search for eval: BM25 + dense embeddings + SPLADE + RRF.
154/// Falls back to BM25-only when embeddings are unavailable.
155fn hybrid_eval_search(
156    project_root: &Path,
157    query: &str,
158    index: &BM25Index,
159    config: &HybridConfig,
160) -> Vec<String> {
161    search_arm(project_root, query, index, config, SearchArm::Hybrid).0
162}
163
164/// Runs one retrieval arm. Returns the ranked repo-relative file paths **and**
165/// whether the dense pipeline actually contributed — so an A/B run can flag an
166/// environment without working embeddings instead of silently comparing BM25 to
167/// itself and reporting a misleading "flip is safe".
168fn search_arm(
169    project_root: &Path,
170    query: &str,
171    index: &BM25Index,
172    config: &HybridConfig,
173    arm: SearchArm,
174) -> (Vec<String>, bool) {
175    if arm == SearchArm::Hybrid {
176        #[cfg(feature = "embeddings")]
177        {
178            if let Ok(results) = try_hybrid_search(project_root, query, index, config) {
179                return (results, true);
180            }
181        }
182    }
183    let _ = project_root;
184    (bm25_only_search(index, query, config), false)
185}
186
187fn bm25_only_search(index: &BM25Index, query: &str, config: &HybridConfig) -> Vec<String> {
188    index
189        .search(query, config.bm25_candidates)
190        .iter()
191        .map(|r| r.file_path.clone())
192        .collect()
193}
194
195#[cfg(feature = "embeddings")]
196fn try_hybrid_search(
197    project_root: &Path,
198    query: &str,
199    index: &BM25Index,
200    config: &HybridConfig,
201) -> Result<Vec<String>, String> {
202    use crate::core::dense_backend;
203    use crate::tools::ctx_semantic_search;
204
205    let (engine, mut embed_idx) = ctx_semantic_search::load_engine_and_index_pub(project_root)?;
206
207    let (aligned, _coverage, changed_files) = ctx_semantic_search::ensure_embeddings_for_eval(
208        project_root,
209        index,
210        engine,
211        &mut embed_idx,
212    )?;
213
214    let backend = dense_backend::DenseBackendKind::try_from_env()?;
215    let candidate_k = config.bm25_candidates.max(config.dense_candidates);
216
217    let mut results = dense_backend::hybrid_results(
218        backend,
219        project_root,
220        index,
221        engine,
222        &aligned,
223        &changed_files,
224        query,
225        candidate_k,
226        config,
227        None,
228        None,
229    )?;
230
231    if config.splade_weight > 0.0 {
232        let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, candidate_k);
233        if !splade.is_empty() {
234            ctx_semantic_search::boost_with_splade_pub(&mut results, &splade, config.splade_weight);
235        }
236    }
237
238    results.truncate(10);
239    Ok(results.iter().map(|r| r.file_path.clone()).collect())
240}
241
242/// Generate self-eval queries from an indexed codebase.
243/// Picks random symbols/files and constructs retrieval queries.
244pub fn generate_self_eval(index: &BM25Index, max_queries: usize) -> Vec<EvalQuery> {
245    let mut queries = Vec::new();
246
247    for chunk in index.chunks.iter().take(max_queries * 2) {
248        if queries.len() >= max_queries {
249            break;
250        }
251        if chunk.symbol_name.is_empty() || chunk.file_path.is_empty() {
252            continue;
253        }
254
255        let category = if chunk.symbol_name.starts_with("fn ") || chunk.symbol_name.contains("()") {
256            "function"
257        } else if chunk.symbol_name.starts_with("struct ")
258            || chunk.symbol_name.starts_with("class ")
259        {
260            "type"
261        } else {
262            "symbol"
263        };
264
265        let clean_name = chunk
266            .symbol_name
267            .replace("fn ", "")
268            .replace("struct ", "")
269            .replace("class ", "")
270            .replace("()", "");
271
272        queries.push(EvalQuery {
273            query: format!("where is {clean_name} defined"),
274            expected_files: vec![chunk.file_path.clone()],
275            category: category.to_string(),
276        });
277    }
278
279    queries
280}
281
282// ── A/B retrieval comparison (#686): dense default vs lean lower bound ────────
283
284/// Recall slack (absolute, 0–1) within which the lean arm counts as "matching"
285/// the dense default. 0.02 ≈ two percentage points of recall@5.
286const AB_MARGIN: f64 = 0.02;
287
288/// Verdict of a dense-vs-lean retrieval A/B.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
290#[serde(rename_all = "snake_case")]
291pub enum AbVerdict {
292    /// Pure BM25 already matches hybrid within `AB_MARGIN` on both recall@5 and
293    /// MRR. The richer lean path is ≥ pure BM25, so flipping the default to
294    /// dense-off cannot regress retrieval quality.
295    FlipSafe,
296    /// Dense adds recall beyond the margin. Evaluate the full lean path
297    /// (BM25+graph+rerank+SPLADE) before flipping; keep hybrid as the default.
298    KeepHybrid,
299    /// The dense pipeline never actually ran (no working embeddings in this
300    /// environment), so the two arms are identical and the run proves nothing.
301    Inconclusive,
302}
303
304impl AbVerdict {
305    pub fn label(self) -> &'static str {
306        match self {
307            AbVerdict::FlipSafe => "FLIP-SAFE",
308            AbVerdict::KeepHybrid => "KEEP-HYBRID",
309            AbVerdict::Inconclusive => "INCONCLUSIVE",
310        }
311    }
312}
313
314/// Pure verdict decision, split out so it is unit-testable without embeddings.
315fn decide_verdict(
316    delta_recall_at_5: f64,
317    delta_mrr: f64,
318    dense_active_queries: usize,
319) -> AbVerdict {
320    if dense_active_queries == 0 {
321        AbVerdict::Inconclusive
322    } else if delta_recall_at_5 >= -AB_MARGIN && delta_mrr >= -AB_MARGIN {
323        AbVerdict::FlipSafe
324    } else {
325        AbVerdict::KeepHybrid
326    }
327}
328
329/// Aggregate score for one retrieval arm.
330#[derive(Debug, Clone, serde::Serialize)]
331pub struct ArmScore {
332    pub arm: String,
333    pub avg_recall_at_5: f64,
334    pub avg_recall_at_10: f64,
335    pub avg_mrr: f64,
336    pub avg_latency_us: u64,
337    /// Number of queries (of `total_queries`) where the dense pipeline actually
338    /// contributed. Always 0 for the BM25 arm.
339    pub dense_active_queries: usize,
340}
341
342/// Full dense-vs-lean A/B scorecard for the default-flip decision (#686).
343#[derive(Debug, Clone, serde::Serialize)]
344pub struct AbReport {
345    pub project: String,
346    pub total_queries: usize,
347    pub hybrid: ArmScore,
348    pub bm25: ArmScore,
349    /// bm25 − hybrid. Negative ⇒ the lean lower bound trails the dense default.
350    pub delta_recall_at_5: f64,
351    pub delta_mrr: f64,
352    pub verdict: AbVerdict,
353}
354
355fn run_arm(
356    project_root: &Path,
357    queries: &[EvalQuery],
358    index: &BM25Index,
359    config: &HybridConfig,
360    arm: SearchArm,
361) -> ArmScore {
362    let (mut r5, mut r10, mut mrr) = (0.0, 0.0, 0.0);
363    let mut latency = 0u64;
364    let mut dense_active = 0usize;
365    for q in queries {
366        let start = Instant::now();
367        let (retrieved, dense) = search_arm(project_root, &q.query, index, config, arm);
368        latency += start.elapsed().as_micros() as u64;
369        r5 += recall_at_k(&retrieved, &q.expected_files, 5);
370        r10 += recall_at_k(&retrieved, &q.expected_files, 10);
371        mrr += mean_reciprocal_rank(&retrieved, &q.expected_files);
372        if dense {
373            dense_active += 1;
374        }
375    }
376    let n = queries.len().max(1) as f64;
377    ArmScore {
378        arm: arm.label().to_string(),
379        avg_recall_at_5: r5 / n,
380        avg_recall_at_10: r10 / n,
381        avg_mrr: mrr / n,
382        avg_latency_us: latency / queries.len().max(1) as u64,
383        dense_active_queries: dense_active,
384    }
385}
386
387/// Run the dense-vs-lean retrieval A/B over `queries` and decide whether the
388/// default search path can be flipped to dense-off without losing quality.
389pub fn run_ab(
390    project_root: &Path,
391    queries: &[EvalQuery],
392    index: &BM25Index,
393    config: &HybridConfig,
394) -> AbReport {
395    // Canonicalize first so a relative root like "." still yields a real label.
396    let label = project_root
397        .canonicalize()
398        .ok()
399        .as_deref()
400        .or(Some(project_root))
401        .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
402        .unwrap_or_else(|| "unknown".to_string());
403    let hybrid = run_arm(project_root, queries, index, config, SearchArm::Hybrid);
404    let bm25 = run_arm(project_root, queries, index, config, SearchArm::Bm25Only);
405    let delta_recall_at_5 = bm25.avg_recall_at_5 - hybrid.avg_recall_at_5;
406    let delta_mrr = bm25.avg_mrr - hybrid.avg_mrr;
407    let verdict = decide_verdict(delta_recall_at_5, delta_mrr, hybrid.dense_active_queries);
408    AbReport {
409        project: label,
410        total_queries: queries.len(),
411        hybrid,
412        bm25,
413        delta_recall_at_5,
414        delta_mrr,
415        verdict,
416    }
417}
418
419/// Loads a curated eval suite: one JSON [`EvalQuery`] per line; blank lines and
420/// `#` comments are ignored. Real labelled queries — no generation, no mocks.
421pub fn load_suite(path: &Path) -> std::io::Result<Vec<EvalQuery>> {
422    let text = std::fs::read_to_string(path)?;
423    let mut out = Vec::new();
424    for (i, line) in text.lines().enumerate() {
425        let t = line.trim();
426        if t.is_empty() || t.starts_with('#') {
427            continue;
428        }
429        let q: EvalQuery = serde_json::from_str(t).map_err(|e| {
430            std::io::Error::new(
431                std::io::ErrorKind::InvalidData,
432                format!("{}:{}: {e}", path.display(), i + 1),
433            )
434        })?;
435        out.push(q);
436    }
437    Ok(out)
438}
439
440impl AbReport {
441    pub fn to_json(&self) -> String {
442        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
443    }
444}
445
446impl std::fmt::Display for AbReport {
447    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448        writeln!(
449            f,
450            "Retrieval A/B: {} ({} queries) — #686 default-flip decision",
451            self.project, self.total_queries
452        )?;
453        writeln!(
454            f,
455            "  {:<30} R@5={:>6.1}%  R@10={:>6.1}%  MRR={:>5.3}  {:>7}µs  dense:{}/{}",
456            self.hybrid.arm,
457            self.hybrid.avg_recall_at_5 * 100.0,
458            self.hybrid.avg_recall_at_10 * 100.0,
459            self.hybrid.avg_mrr,
460            self.hybrid.avg_latency_us,
461            self.hybrid.dense_active_queries,
462            self.total_queries,
463        )?;
464        writeln!(
465            f,
466            "  {:<30} R@5={:>6.1}%  R@10={:>6.1}%  MRR={:>5.3}  {:>7}µs",
467            self.bm25.arm,
468            self.bm25.avg_recall_at_5 * 100.0,
469            self.bm25.avg_recall_at_10 * 100.0,
470            self.bm25.avg_mrr,
471            self.bm25.avg_latency_us,
472        )?;
473        writeln!(
474            f,
475            "  Δ(bm25−hybrid): R@5={:+.1}pp  MRR={:+.3}",
476            self.delta_recall_at_5 * 100.0,
477            self.delta_mrr,
478        )?;
479        writeln!(f, "  Verdict: {}", self.verdict.label())?;
480        let note = match self.verdict {
481            AbVerdict::FlipSafe => {
482                "pure BM25 matches hybrid within margin; the richer lean path is ≥ this \
483                 → flipping the default to dense-off is safe."
484            }
485            AbVerdict::KeepHybrid => {
486                "dense adds recall beyond the margin → evaluate the full lean path before \
487                 flipping; keep hybrid default."
488            }
489            AbVerdict::Inconclusive => {
490                "dense pipeline did not run (no embeddings here) → both arms identical; \
491                 re-run where embeddings are built."
492            }
493        };
494        writeln!(f, "  {note}")
495    }
496}
497
498/// Normalizes path separators so comparisons are platform-independent (the
499/// retrieved paths use the OS separator — `\` on Windows — while expected paths
500/// in eval fixtures use `/`).
501fn normalize_sep(p: &str) -> String {
502    p.replace('\\', "/")
503}
504
505fn recall_at_k(retrieved: &[String], expected: &[String], k: usize) -> f64 {
506    if expected.is_empty() {
507        return 0.0;
508    }
509    let top_k: Vec<String> = retrieved.iter().take(k).map(|r| normalize_sep(r)).collect();
510    let hits = expected
511        .iter()
512        .filter(|e| {
513            let e = normalize_sep(e);
514            top_k.iter().any(|r| r.ends_with(&e) || e.ends_with(r))
515        })
516        .count();
517    hits as f64 / expected.len() as f64
518}
519
520fn mean_reciprocal_rank(retrieved: &[String], expected: &[String]) -> f64 {
521    for (rank, r) in retrieved.iter().enumerate() {
522        let r = normalize_sep(r);
523        if expected.iter().any(|e| {
524            let e = normalize_sep(e);
525            r.ends_with(&e) || e.ends_with(&r)
526        }) {
527            return 1.0 / (rank as f64 + 1.0);
528        }
529    }
530    0.0
531}
532
533fn build_category_scores(results: &[EvalResult]) -> Vec<CategoryScore> {
534    use std::collections::HashMap;
535    let mut cat_map: HashMap<&str, Vec<&EvalResult>> = HashMap::new();
536    for r in results {
537        cat_map.entry(r.category.as_str()).or_default().push(r);
538    }
539
540    let mut scores: Vec<CategoryScore> = cat_map
541        .into_iter()
542        .map(|(cat, items)| {
543            let n = items.len();
544            CategoryScore {
545                category: cat.to_string(),
546                count: n,
547                avg_recall_at_5: items.iter().map(|r| r.recall_at_5).sum::<f64>() / n as f64,
548                avg_mrr: items.iter().map(|r| r.mrr).sum::<f64>() / n as f64,
549            }
550        })
551        .collect();
552    scores.sort_by(|a, b| a.category.cmp(&b.category));
553    scores
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn recall_at_k_full_match() {
562        let retrieved = vec!["a.rs".into(), "b.rs".into(), "c.rs".into()];
563        let expected = vec!["a.rs".into()];
564        assert_eq!(recall_at_k(&retrieved, &expected, 5), 1.0);
565    }
566
567    #[test]
568    fn recall_at_k_matches_across_path_separators() {
569        // Retrieved paths may use the OS separator (backslash on Windows) while
570        // expected fixtures use '/'. They must still match.
571        let retrieved = vec!["proj\\src\\auth.rs".into(), "proj\\src\\db.rs".into()];
572        let expected = vec!["src/auth.rs".into()];
573        assert_eq!(recall_at_k(&retrieved, &expected, 5), 1.0);
574        assert_eq!(mean_reciprocal_rank(&retrieved, &expected), 1.0);
575    }
576
577    #[test]
578    fn recall_at_k_no_match() {
579        let retrieved = vec!["x.rs".into(), "y.rs".into()];
580        let expected = vec!["a.rs".into()];
581        assert_eq!(recall_at_k(&retrieved, &expected, 5), 0.0);
582    }
583
584    #[test]
585    fn recall_at_k_partial() {
586        let retrieved = vec!["a.rs".into(), "x.rs".into()];
587        let expected = vec!["a.rs".into(), "b.rs".into()];
588        assert_eq!(recall_at_k(&retrieved, &expected, 5), 0.5);
589    }
590
591    #[test]
592    fn mrr_first_hit() {
593        let retrieved = vec!["a.rs".into(), "b.rs".into()];
594        let expected = vec!["a.rs".into()];
595        assert_eq!(mean_reciprocal_rank(&retrieved, &expected), 1.0);
596    }
597
598    #[test]
599    fn mrr_second_hit() {
600        let retrieved = vec!["x.rs".into(), "a.rs".into()];
601        let expected = vec!["a.rs".into()];
602        assert_eq!(mean_reciprocal_rank(&retrieved, &expected), 0.5);
603    }
604
605    #[test]
606    fn mrr_no_hit() {
607        let retrieved = vec!["x.rs".into()];
608        let expected = vec!["a.rs".into()];
609        assert_eq!(mean_reciprocal_rank(&retrieved, &expected), 0.0);
610    }
611
612    #[test]
613    fn empty_expected() {
614        assert_eq!(recall_at_k(&["a.rs".into()], &[], 5), 0.0);
615    }
616
617    #[test]
618    fn scorecard_display() {
619        let sc = EvalScorecard {
620            project: "test".into(),
621            total_queries: 10,
622            avg_recall_at_5: 0.8,
623            avg_recall_at_10: 0.9,
624            avg_mrr: 0.75,
625            avg_latency_us: 100,
626            per_category: vec![],
627            results: vec![],
628        };
629        let s = format!("{sc}");
630        assert!(s.contains("80.0%"));
631        assert!(s.contains("0.750"));
632    }
633
634    #[test]
635    fn verdict_flip_safe_when_lean_matches() {
636        // Lean equal to hybrid, within margin, and even ahead → all flip-safe.
637        assert_eq!(decide_verdict(0.0, 0.0, 5), AbVerdict::FlipSafe);
638        assert_eq!(decide_verdict(-0.01, -0.005, 5), AbVerdict::FlipSafe);
639        assert_eq!(decide_verdict(0.05, 0.03, 5), AbVerdict::FlipSafe);
640    }
641
642    #[test]
643    fn verdict_keep_hybrid_when_dense_helps() {
644        // Dense ahead beyond the margin on either metric → keep hybrid.
645        assert_eq!(decide_verdict(-0.10, 0.0, 5), AbVerdict::KeepHybrid);
646        assert_eq!(decide_verdict(0.0, -0.10, 3), AbVerdict::KeepHybrid);
647    }
648
649    #[test]
650    fn verdict_inconclusive_without_dense() {
651        // No dense-active query ⇒ arms are identical regardless of the deltas.
652        assert_eq!(decide_verdict(-0.5, -0.5, 0), AbVerdict::Inconclusive);
653        assert_eq!(decide_verdict(0.0, 0.0, 0), AbVerdict::Inconclusive);
654    }
655
656    #[test]
657    fn load_suite_parses_and_skips_comments() {
658        let dir = tempfile::tempdir().unwrap();
659        let p = dir.path().join("s.ndjson");
660        std::fs::write(
661            &p,
662            "# header comment\n\n\
663             {\"query\":\"reciprocal rank fusion\",\"expected_files\":[\"core/hybrid_search.rs\"]}\n",
664        )
665        .unwrap();
666        let q = load_suite(&p).unwrap();
667        assert_eq!(q.len(), 1);
668        assert_eq!(q[0].query, "reciprocal rank fusion");
669        assert_eq!(
670            q[0].expected_files,
671            vec!["core/hybrid_search.rs".to_string()]
672        );
673    }
674
675    #[test]
676    fn load_suite_rejects_bad_json() {
677        let dir = tempfile::tempdir().unwrap();
678        let p = dir.path().join("bad.ndjson");
679        std::fs::write(&p, "{not valid json}\n").unwrap();
680        assert!(load_suite(&p).is_err());
681    }
682
683    #[test]
684    fn run_ab_plumbing_on_synthetic_index() {
685        use crate::core::bm25_index::{BM25Index, ChunkKind, CodeChunk, tokenize};
686
687        let index = BM25Index::from_chunks_for_test(vec![CodeChunk {
688            file_path: "core/hybrid_search.rs".into(),
689            symbol_name: "reciprocal_rank_fusion".into(),
690            kind: ChunkKind::Function,
691            start_line: 1,
692            end_line: 20,
693            // Natural-language body so the query tokens match (the indexer keeps
694            // `snake_case` identifiers as single tokens, so a bare symbol name
695            // would not match the space-separated query).
696            content: "Combine two ranked result lists using reciprocal rank fusion scoring.".into(),
697            tokens: tokenize("combine two ranked result lists reciprocal rank fusion scoring"),
698            token_count: 0,
699        }]);
700        let queries = vec![EvalQuery {
701            query: "reciprocal rank fusion".into(),
702            expected_files: vec!["core/hybrid_search.rs".into()],
703            category: "test".into(),
704        }];
705
706        // Isolate any embedding side effects to a throwaway root.
707        let dir = tempfile::tempdir().unwrap();
708        let report = run_ab(dir.path(), &queries, &index, &HybridConfig::default());
709
710        assert_eq!(report.total_queries, 1);
711        // The BM25 arm must find the lexical match and never reports dense activity.
712        assert!(report.bm25.avg_recall_at_5 > 0.0);
713        assert_eq!(report.bm25.dense_active_queries, 0);
714        // The verdict follows the (separately unit-tested) contract for whatever
715        // dense availability this environment happens to have.
716        let expected = decide_verdict(
717            report.delta_recall_at_5,
718            report.delta_mrr,
719            report.hybrid.dense_active_queries,
720        );
721        assert_eq!(report.verdict, expected);
722        // Serialization stays valid JSON.
723        let v: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
724        assert_eq!(v["total_queries"], 1);
725    }
726}