1use 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
75pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum SearchArm {
135 Hybrid,
137 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
153fn 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
164fn 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
242pub 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
282const AB_MARGIN: f64 = 0.02;
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
290#[serde(rename_all = "snake_case")]
291pub enum AbVerdict {
292 FlipSafe,
296 KeepHybrid,
299 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
314fn 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#[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 pub dense_active_queries: usize,
340}
341
342#[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 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
387pub fn run_ab(
390 project_root: &Path,
391 queries: &[EvalQuery],
392 index: &BM25Index,
393 config: &HybridConfig,
394) -> AbReport {
395 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
419pub 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
498fn 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 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 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 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 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 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 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 assert!(report.bm25.avg_recall_at_5 > 0.0);
713 assert_eq!(report.bm25.dense_active_queries, 0);
714 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 let v: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
724 assert_eq!(v["total_queries"], 1);
725 }
726}