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