Skip to main content

scc_cli/
benchret.rs

1//! Retrieval Recall@k / MRR over `benchmarks/tasks.json` gold.
2//!
3//! Compares inspectable ranking arms. Does not change production ranking.
4
5use 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)]
12// trace:exempt reason=internal-detail
13pub 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)]
23// trace:exempt reason=internal-detail
24pub struct RetrievalSummary {
25    pub arms: Vec<ArmScores>,
26    pub per_task: Vec<TaskArmRow>,
27}
28
29#[derive(Debug, Clone, Serialize)]
30// trace:exempt reason=internal-detail
31pub 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
40// trace:exempt reason=internal-detail
41fn gold_keys(gt: &GroundTruth) -> HashSet<String> {
42    let mut s = HashSet::new();
43    for f in &gt.files {
44        s.insert(format!("file:{f}"));
45    }
46    for n in &gt.symbols {
47        s.insert(format!("symbol:{n}"));
48    }
49    for n in &gt.components {
50        s.insert(format!("component:{n}"));
51    }
52    for n in &gt.routes {
53        s.insert(format!("route:{n}"));
54    }
55    s
56}
57
58// trace:exempt reason=internal-detail
59fn 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
65// trace:exempt reason=internal-detail
66fn 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
91/// Run retrieval eval. `repo_filter` limits to one fixture repo id.
92// trace:v1 id=impl.scc.cli.bench-retrieval work=WORK-ripwire-lessons-phase2 satisfies=REQ-retrieval-eval,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no
93pub 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
213// trace:exempt reason=internal-detail
214fn 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
227// trace:exempt reason=internal-detail
228fn 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
278// trace:exempt reason=internal-detail
279fn 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
335// trace:exempt reason=internal-detail
336pub 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    // trace:v1 id=test.scc.cli.retrieval-eval verifies=REQ-retrieval-eval exercises=impl.scc.cli.bench-retrieval
356    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    // trace:v1 id=test.scc.cli.retrieval-entity-rank verifies=REQ-retrieval-eval,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no exercises=impl.scc.cli.bench-retrieval
379    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}