1use 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
76pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum SearchArm {
136 Hybrid,
138 Bm25Only,
143 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
161fn 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
172struct ArmRun {
177 files: Vec<String>,
178 dense_active: bool,
179 output_tokens: usize,
180}
181
182fn 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
219fn 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
300pub 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
340const AB_MARGIN: f64 = 0.02;
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
348#[serde(rename_all = "snake_case")]
349pub enum AbVerdict {
350 FlipSafe,
354 KeepHybrid,
357 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
372fn 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#[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 pub dense_active_queries: usize,
398 pub avg_output_tokens: u64,
401}
402
403#[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 pub explore: ArmScore,
413 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
455pub fn run_ab(
458 project_root: &Path,
459 queries: &[EvalQuery],
460 index: &BM25Index,
461 config: &HybridConfig,
462) -> AbReport {
463 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
489pub 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
580fn 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 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 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 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 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 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 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 assert!(report.bm25.avg_recall_at_5 > 0.0);
795 assert_eq!(report.bm25.dense_active_queries, 0);
796 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 assert_eq!(report.explore.arm, "explore (citations)");
806 assert_eq!(report.explore.dense_active_queries, 0);
807 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 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}