1use crate::benchctx::{copy_fixture, locate_fixtures_dir, BenchmarkCorpus, GroundTruth};
6use scc_core::{mean_reciprocal_rank, recall_at_k, RankingArm};
7use serde::Serialize;
8use std::collections::{BTreeMap, HashSet};
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize)]
12pub struct ArmScores {
14 pub arm: String,
15 pub recall_at_1: f64,
16 pub recall_at_5: f64,
17 pub recall_at_10: f64,
18 pub mrr: f64,
19 pub tasks: usize,
20}
21
22#[derive(Debug, Clone, Serialize, Default)]
23pub struct RetrievalSummary {
25 pub arms: Vec<ArmScores>,
26 pub per_task: Vec<TaskArmRow>,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct TaskArmRow {
32 pub task: String,
33 pub arm: String,
34 pub recall_at_1: f64,
35 pub recall_at_5: f64,
36 pub recall_at_10: f64,
37 pub mrr: f64,
38}
39
40fn gold_keys(gt: &GroundTruth) -> HashSet<String> {
42 let mut s = HashSet::new();
43 for f in >.files {
44 s.insert(format!("file:{f}"));
45 }
46 for n in >.symbols {
47 s.insert(format!("symbol:{n}"));
48 }
49 for n in >.components {
50 s.insert(format!("component:{n}"));
51 }
52 for n in >.routes {
53 s.insert(format!("route:{n}"));
54 }
55 s
56}
57
58fn push_key(out: &mut Vec<String>, seen: &mut HashSet<String>, key: String) {
60 if seen.insert(key.clone()) {
61 out.push(key);
62 }
63}
64
65fn keys_for_hit(kind: &str, name: &str, file: &str) -> Vec<String> {
67 let mut v = vec![format!("{kind}:{name}")];
68 if kind == "file" {
69 if let Some(base) = name.rsplit('/').next() {
70 if base != name {
71 v.push(format!("file:{base}"));
72 }
73 }
74 }
75 if !file.is_empty() {
76 v.push(format!("file:{file}"));
77 if let Some(base) = file.rsplit('/').next() {
78 v.push(format!("file:{base}"));
79 }
80 }
81 if kind == "symbol" {
82 if let Some(last) = name.rsplit(['.', ':']).next() {
83 if last != name {
84 v.push(format!("symbol:{last}"));
85 }
86 }
87 }
88 v
89}
90
91pub fn run_retrieval_benchmark(
94 k: usize,
95 arms: &[RankingArm],
96 min_recall_at_10: f64,
97 repo_filter: Option<&str>,
98) -> Result<RetrievalSummary, String> {
99 let fixtures = locate_fixtures_dir().ok_or("cannot locate fixtures/ directory")?;
100 let corpus_path = fixtures
101 .parent()
102 .map(|p| p.join("benchmarks/tasks.json"))
103 .ok_or("cannot locate benchmarks/tasks.json")?;
104 let text = std::fs::read_to_string(&corpus_path)
105 .map_err(|e| format!("read {}: {e}", corpus_path.display()))?;
106 let corpus: BenchmarkCorpus =
107 serde_json::from_str(&text).map_err(|e| format!("tasks.json: {e}"))?;
108
109 let mut by_repo: BTreeMap<String, Vec<&crate::benchctx::BenchTask>> = BTreeMap::new();
110 for t in &corpus.tasks {
111 if let Some(f) = repo_filter {
112 if t.repo != f {
113 continue;
114 }
115 }
116 by_repo.entry(t.repo.clone()).or_default().push(t);
117 }
118 if by_repo.is_empty() {
119 return Err("no retrieval tasks matched".into());
120 }
121
122 let mut summary = RetrievalSummary::default();
123 let mut acc: BTreeMap<&'static str, (f64, f64, f64, f64, usize)> = BTreeMap::new();
124
125 for (repo, tasks) in &by_repo {
126 let src = fixtures.join(repo);
127 if !src.is_dir() {
128 continue;
129 }
130 let tmp = tempfile::TempDir::new().map_err(|e| e.to_string())?;
131 let root = tmp.path().join("repo");
132 copy_fixture(&src, &root);
133 crate::commands::cmd_index(&root, true).map_err(|e| format!("index {repo}: {e}"))?;
134 let store = crate::open_store(&root).map_err(|e| e.to_string())?;
135 let config = crate::load_config(&root).map_err(|e| e.to_string())?;
136 let comp = crate::compiler(&store, &config, Vec::new()).map_err(|e| e.to_string())?;
137 let ctx = comp.ctx();
138 let bm25 = ctx
139 .store
140 .meta_get("bm25_corpus")
141 .ok()
142 .flatten()
143 .and_then(|raw| serde_json::from_str::<scc_core::Bm25CorpusStats>(&raw).ok());
144
145 for task in tasks {
146 let gold = gold_keys(&task.ground_truth);
147 if gold.is_empty() {
148 continue;
149 }
150 for arm in arms {
151 let ranked = ranked_keys_for_arm(
152 &ctx,
153 &root,
154 &task.goal,
155 *arm,
156 k.max(10),
157 bm25.as_ref(),
158 &gold,
159 );
160 let r1 = recall_at_k(&ranked, &gold, 1);
161 let r5 = recall_at_k(&ranked, &gold, 5);
162 let r10 = recall_at_k(&ranked, &gold, 10);
163 let mrr = mean_reciprocal_rank(&ranked, &gold);
164 summary.per_task.push(TaskArmRow {
165 task: task.id.clone(),
166 arm: arm.as_str().to_string(),
167 recall_at_1: r1,
168 recall_at_5: r5,
169 recall_at_10: r10,
170 mrr,
171 });
172 let e = acc.entry(arm.as_str()).or_insert((0.0, 0.0, 0.0, 0.0, 0));
173 e.0 += r1;
174 e.1 += r5;
175 e.2 += r10;
176 e.3 += mrr;
177 e.4 += 1;
178 }
179 }
180 }
181
182 for arm in arms {
183 if let Some((r1, r5, r10, mrr, n)) = acc.get(arm.as_str()) {
184 let count = *n;
185 if count > 0 {
186 let n = count as f64;
187 summary.arms.push(ArmScores {
188 arm: arm.as_str().to_string(),
189 recall_at_1: r1 / n,
190 recall_at_5: r5 / n,
191 recall_at_10: r10 / n,
192 mrr: mrr / n,
193 tasks: count,
194 });
195 }
196 }
197 }
198
199 if min_recall_at_10 > 0.0 {
200 for a in &summary.arms {
201 if a.recall_at_10 < min_recall_at_10 {
202 return Err(format!(
203 "retrieval gate failed: {} recall@10 {:.3} < {min_recall_at_10}",
204 a.arm, a.recall_at_10
205 ));
206 }
207 }
208 }
209 let _ = k;
210 Ok(summary)
211}
212
213fn push_entity_rank(
215 out: &mut Vec<String>,
216 seen: &mut HashSet<String>,
217 keys: Vec<String>,
218 gold: &HashSet<String>,
219) {
220 if let Some(k) = keys.iter().find(|k| gold.contains(*k)) {
221 push_key(out, seen, k.clone());
222 } else if let Some(k) = keys.into_iter().next() {
223 push_key(out, seen, k);
224 }
225}
226
227fn ranked_keys_from_surface(
229 ctx: &scc_context::ContextCompiler<'_>,
230 goal: &str,
231 stages: &scc_context::SurfacePipelineStages,
232 gold: &HashSet<String>,
233) -> Vec<String> {
234 let mut out = Vec::new();
235 let mut seen = HashSet::new();
236 let budget = 8000usize;
237 let request = scc_context::surface::SurfaceRequest {
238 mode: scc_context::surface::SurfaceMode::Task {
239 goal,
240 visible: None,
241 },
242 budget,
243 explain: false,
244 policy: scc_context::surface::SurfacePolicy::defaults(budget),
245 semantic: None,
246 };
247 let result = scc_context::build_surface_staged(ctx, request, stages);
248 let map = scc_context::surface::compile_surface_map(ctx);
249 for id in &result.rendered_ids {
250 if let Some(e) = map.entries.iter().find(|ent| &ent.id == id) {
251 let qn = e
252 .qualified_name
253 .rsplit(['.', ':'])
254 .next()
255 .unwrap_or(&e.qualified_name);
256 let mut keys = keys_for_hit("symbol", qn, &e.path);
257 if let Some(c) = &e.component {
258 keys.push(format!("component:{c}"));
259 }
260 push_entity_rank(&mut out, &mut seen, keys, gold);
261 } else if let Some(ent) = ctx.view.entity(id) {
262 let file = ent
263 .attributes
264 .get("file")
265 .and_then(|v| v.as_str())
266 .unwrap_or("");
267 push_entity_rank(
268 &mut out,
269 &mut seen,
270 keys_for_hit(&ent.kind, &ent.name, file),
271 gold,
272 );
273 }
274 }
275 out
276}
277
278fn ranked_keys_for_arm(
280 ctx: &scc_context::ContextCompiler<'_>,
281 root: &Path,
282 goal: &str,
283 arm: RankingArm,
284 limit: usize,
285 corpus: Option<&scc_core::Bm25CorpusStats>,
286 gold: &HashSet<String>,
287) -> Vec<String> {
288 match arm {
289 RankingArm::ProductionBlended => {
290 return ranked_keys_from_surface(
291 ctx,
292 goal,
293 &scc_context::SurfacePipelineStages::default(),
294 gold,
295 );
296 }
297 RankingArm::NoTaskPpr => {
298 let stages = scc_context::SurfacePipelineStages {
299 task_ppr: false,
300 ..scc_context::SurfacePipelineStages::default()
301 };
302 return ranked_keys_from_surface(ctx, goal, &stages, gold);
303 }
304 RankingArm::NoGlobalPpr => {
305 let stages = scc_context::SurfacePipelineStages {
306 global_ppr: false,
307 ..scc_context::SurfacePipelineStages::default()
308 };
309 return ranked_keys_from_surface(ctx, goal, &stages, gold);
310 }
311 _ => {}
312 }
313 let mut out = Vec::new();
314 let mut seen = HashSet::new();
315 let hits = scc_context::relevance::collect_relevance_candidates(
316 &ctx.view, goal, limit, arm, Some(root), corpus,
317 );
318 for h in hits {
319 let file = ctx
320 .view
321 .entity(&h.id)
322 .and_then(|e| e.attributes.get("file"))
323 .and_then(|v| v.as_str())
324 .unwrap_or("");
325 push_entity_rank(
326 &mut out,
327 &mut seen,
328 keys_for_hit(&h.kind, &h.name, file),
329 gold,
330 );
331 }
332 out
333}
334
335pub fn print_summary(s: &RetrievalSummary) {
337 println!("scc bench retrieval — Recall@k / MRR (production ranker unchanged)");
338 println!(
339 " {:<22} {:>8} {:>8} {:>9} {:>7} {:>6}",
340 "arm", "R@1", "R@5", "R@10", "MRR", "tasks"
341 );
342 for a in &s.arms {
343 println!(
344 " {:<22} {:>8.3} {:>8.3} {:>9.3} {:>7.3} {:>6}",
345 a.arm, a.recall_at_1, a.recall_at_5, a.recall_at_10, a.mrr, a.tasks
346 );
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn retrieval_eval_runs_on_http_service_fixture() {
357 let summary = run_retrieval_benchmark(
358 10,
359 &[RankingArm::LexicalThenGraph, RankingArm::QueryRouted],
360 0.0,
361 Some("http-service-python"),
362 )
363 .expect("retrieval bench");
364 assert!(!summary.arms.is_empty());
365 let lex = summary
366 .arms
367 .iter()
368 .find(|a| a.arm == "lexical-then-graph")
369 .expect("lexical arm");
370 assert!(
371 lex.recall_at_10 > 0.0,
372 "lexical lens must retrieve some gold on the fixture: {lex:?}"
373 );
374 assert!(lex.mrr >= 0.0);
375 }
376
377 #[test]
378 fn entity_aliases_occupy_one_rank_slot() {
380 let gold: HashSet<String> = ["file:src/a.py".into()].into_iter().collect();
381 let mut out = Vec::new();
382 let mut seen = HashSet::new();
383 push_entity_rank(
384 &mut out,
385 &mut seen,
386 vec![
387 "symbol:handleList".into(),
388 "file:src/a.py".into(),
389 "file:a.py".into(),
390 ],
391 &gold,
392 );
393 assert_eq!(out, vec!["file:src/a.py".to_string()]);
394 assert_eq!(recall_at_k(&out, &gold, 1), 1.0);
395 }
396}