Skip to main content

scc_cli/
benchctx.rs

1//! Context benchmark (docs/TEST_PLAN.md §84–87, M0/M4): score task packs
2//! against the ground-truth corpus in `benchmarks/tasks.json`.
3//!
4//! Metrics:
5//! - recall: ground-truth entities surfaced in the pack / total ground truth
6//! - precision: ground-truth entities / pack entities of ground-truth kinds
7//! - localization: ground-truth files mentioned in the pack content
8//! - hallucination: nonexistent entities that must NOT surface
9//! - budget: pack under the configured token budget
10
11use serde::Deserialize;
12use std::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Deserialize)]
16// trace:v1 id=impl.crates-scc-cli-src-benchctx.benchmark-corpus
17pub struct BenchmarkCorpus {
18    pub version: u32,
19    #[serde(default)]
20    pub description: Option<String>,
21    pub tasks: Vec<BenchTask>,
22}
23
24#[derive(Debug, Clone, Deserialize)]
25// trace:v1 id=impl.crates-scc-cli-src-benchctx.bench-task
26pub struct BenchTask {
27    pub id: String,
28    pub repo: String,
29    pub goal: String,
30    pub ground_truth: GroundTruth,
31    #[serde(default)]
32    pub hallucinations: Vec<Hallucination>,
33}
34
35#[derive(Debug, Clone, Default, Deserialize)]
36// trace:v1 id=impl.crates-scc-cli-src-benchctx.ground-truth
37pub struct GroundTruth {
38    #[serde(default)]
39    pub files: Vec<String>,
40    #[serde(default)]
41    pub symbols: Vec<String>,
42    #[serde(default)]
43    pub components: Vec<String>,
44    #[serde(default)]
45    pub data: Vec<String>,
46    #[serde(default)]
47    pub tests: Vec<String>,
48    #[serde(default)]
49    pub routes: Vec<String>,
50    #[serde(default)]
51    pub stores: Vec<String>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
55// trace:v1 id=impl.crates-scc-cli-src-benchctx.hallucination
56pub struct Hallucination {
57    #[serde(rename = "type")]
58    pub kind: String,
59    pub name: String,
60}
61
62#[derive(Debug, Clone, Default)]
63// trace:v1 id=impl.crates-scc-cli-src-benchctx.task-result
64pub struct TaskResult {
65    pub id: String,
66    pub recall: f64,
67    pub precision: f64,
68    pub localization: f64,
69    pub tokens: usize,
70    pub budget_ok: bool,
71    pub hallucinations_hit: Vec<String>,
72    pub missed: Vec<String>,
73}
74
75#[derive(Debug, Clone, Default)]
76// trace:v1 id=impl.crates-scc-cli-src-benchctx.bench-summary
77pub struct BenchSummary {
78    pub tasks: usize,
79    pub mean_recall: f64,
80    pub mean_precision: f64,
81    pub mean_localization: f64,
82    pub budget_ok: usize,
83    pub hallucination_violations: usize,
84    pub results: Vec<TaskResult>,
85}
86
87/// Locate the fixtures directory: walk up from cwd; fall back to the
88/// build-time manifest path (dev tooling).
89// trace:v1 id=impl.crates-scc-cli-src-benchctx.locate-fixtures-dir
90pub fn locate_fixtures_dir() -> Option<PathBuf> {
91    let mut dir = std::env::current_dir().ok()?;
92    loop {
93        if dir.join("fixtures").join("http-service-python").is_dir() {
94            return Some(dir.join("fixtures"));
95        }
96        if !dir.pop() {
97            break;
98        }
99    }
100    let manifest: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
101    let candidate = manifest
102        .parent()
103        .and_then(|p| p.parent())
104        .map(|p| p.join("fixtures"))
105        .filter(|p| p.join("http-service-python").is_dir());
106    candidate
107}
108
109// trace:v1 id=impl.crates-scc-cli-src-benchctx.copy-fixture
110pub(crate) fn copy_fixture(src: &Path, dst: &Path) {
111    std::fs::create_dir_all(dst).unwrap();
112    for entry in std::fs::read_dir(src).unwrap() {
113        let entry = entry.unwrap();
114        let name = entry.file_name();
115        if name == ".scc" {
116            continue;
117        }
118        let from = entry.path();
119        let to = dst.join(&name);
120        if from.is_dir() {
121            std::fs::create_dir_all(&to).unwrap();
122            copy_fixture(&from, &to);
123        } else {
124            std::fs::copy(&from, &to).unwrap();
125        }
126    }
127}
128
129/// Normalize a pack entity id to a `(kind, name)` pair.
130// trace:v1 id=impl.crates-scc-cli-src-benchctx.normalize-id
131fn normalize_id(id: &str) -> Option<(String, String)> {
132    // repo://<repo>/<kind>/<rest...>
133    let rest = id.strip_prefix("repo://")?;
134    let mut parts = rest.splitn(3, '/');
135    let _repo = parts.next()?;
136    let kind = parts.next()?.to_string();
137    let name_rest = parts.next()?;
138    match kind.as_str() {
139        "symbol" | "test" => {
140            // file/name — the LAST segment is the encoded name
141            let idx = name_rest.rfind('/')?;
142            let name = scc_core::decode_component(&name_rest[idx + 1..]);
143            if kind == "symbol" {
144                Some((kind, name))
145            } else {
146                Some((kind, scc_core::sanitize_key(&name)))
147            }
148        }
149        "file" | "component" | "data" | "route" | "store" | "topic" | "external_api" => {
150            // these kinds use sanitize_key ids (lowercase, _ -> -)
151            let name = scc_core::sanitize_key(&scc_core::decode_component(name_rest));
152            Some((kind, name))
153        }
154        _ => None,
155    }
156}
157
158/// A merged cluster `root+services` sanitizes to `root-services`. That
159/// entity still *is* the `root` and `services` regions for recall: gold
160/// named the member directories, type-narrowed CALLS merged the behavior.
161// trace:v1 id=impl.scc.bench.merged-component-hit work=WORK-phase-7-of-scc-x-ripwire-lessons-1-one-hop-type-narrowing-from-unique satisfies=REQ-implement-phase-7-of-scc-x-ripwire-lessons-1-one-hop-type-narrowing
162fn component_name_hit(pack_names: &BTreeSet<String>, gold: &str) -> bool {
163    if pack_names.contains(gold) {
164        return true;
165    }
166    pack_names.iter().any(|p| {
167        p == gold
168            || p.starts_with(&format!("{gold}-"))
169            || p.ends_with(&format!("-{gold}"))
170            || p.contains(&format!("-{gold}-"))
171    })
172}
173
174// trace:v1 id=impl.crates-scc-cli-src-benchctx.score-task-public
175pub fn score_task_public(
176    repo_dir: &Path,
177    task: &BenchTask,
178) -> Result<TaskResult, String> {
179    score_task(repo_dir, task)
180}
181
182// trace:v1 id=impl.crates-scc-cli-src-benchctx.score-task
183fn score_task(
184    repo_dir: &Path,
185    task: &BenchTask,
186) -> Result<TaskResult, String> {
187    let tmp = tempfile::TempDir::new().map_err(|e| e.to_string())?;
188    let root = tmp.path().join("repo");
189    copy_fixture(repo_dir, &root);
190
191    crate::commands::cmd_index(&root, true).map_err(|e| format!("index: {e}"))?;
192    let artifact_json = crate::commands::cmd_context_task_json(&root, &task.goal, &[], &[], None)
193        .map_err(|e| format!("task: {e}"))?;
194    // The artifact is {pack, delta, delta_ids}; recall scores the PACK.
195    let artifact: serde_json::Value =
196        serde_json::from_str(&artifact_json).map_err(|e| format!("artifact json: {e}"))?;
197    let pack = artifact["pack"].clone();
198
199    let ids: Vec<String> = pack["entity_ids"]
200        .as_array()
201        .map(|a| {
202            a.iter()
203                .filter_map(|v| v.as_str().map(|s| s.to_string()))
204                .collect()
205        })
206        .unwrap_or_default();
207    let content = pack["content"].as_str().unwrap_or("");
208    let tokens = pack["tokens"].as_u64().unwrap_or(0) as usize;
209    let budget = pack["budget"].as_u64().unwrap_or(u64::MAX) as usize;
210    let budget_ok = tokens <= budget;
211
212    // normalized pack entities by kind
213    let mut pack_by_kind: std::collections::BTreeMap<String, BTreeSet<String>> =
214        Default::default();
215    for id in &ids {
216        if let Some((kind, name)) = normalize_id(id) {
217            pack_by_kind.entry(kind).or_default().insert(name);
218        }
219    }
220
221    let gt = &task.ground_truth;
222    let mut gt_total = 0usize;
223    let mut gt_hit = 0usize;
224    let mut missed: Vec<String> = Vec::new();
225    let check = |kind: &str, names: &[String],
226                     pack_by_kind: &std::collections::BTreeMap<String, BTreeSet<String>>,
227                     gt_total: &mut usize, gt_hit: &mut usize, missed: &mut Vec<String>| {
228        for name in names {
229            *gt_total += 1;
230            let norm = if kind == "symbol" {
231                name.clone()
232            } else {
233                scc_core::sanitize_key(name)
234            };
235            let hit = if kind == "component" {
236                pack_by_kind
237                    .get(kind)
238                    .map(|set| component_name_hit(set, &norm))
239                    .unwrap_or(false)
240            } else {
241                pack_by_kind
242                    .get(kind)
243                    .map(|set| set.contains(&norm))
244                    .unwrap_or(false)
245            };
246            if hit {
247                *gt_hit += 1;
248            } else {
249                missed.push(format!("{kind}:{name}"));
250            }
251        }
252    };
253    check("file", &gt.files, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
254    check("symbol", &gt.symbols, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
255    check("component", &gt.components, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
256    check("data", &gt.data, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
257    check("test", &gt.tests, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
258    check("route", &gt.routes, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
259    check("store", &gt.stores, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
260
261    let recall = if gt_total == 0 { 1.0 } else { gt_hit as f64 / gt_total as f64 };
262
263    // precision over pack entities of ground-truth kinds
264    let gt_kinds: BTreeSet<&str> = ["file", "symbol", "component", "data", "test", "route", "store"]
265        .iter()
266        .copied()
267        .collect();
268    let gt_names: BTreeSet<String> = gt
269        .files
270        .iter()
271        .chain(&gt.symbols)
272        .chain(&gt.components)
273        .chain(&gt.data)
274        .chain(&gt.tests)
275        .chain(&gt.routes)
276        .chain(&gt.stores)
277        .cloned()
278        .collect();
279    let mut pack_total = 0usize;
280    let mut pack_hit = 0usize;
281    for (kind, names) in &pack_by_kind {
282        if !gt_kinds.contains(kind.as_str()) {
283            continue;
284        }
285        for name in names {
286            pack_total += 1;
287            if gt_names.contains(name) {
288                pack_hit += 1;
289            }
290        }
291    }
292    let precision = if pack_total == 0 { 0.0 } else { pack_hit as f64 / pack_total as f64 };
293
294    // localization: gt files mentioned anywhere in the pack content
295    let mut file_hits = 0usize;
296    for f in &gt.files {
297        if content.contains(f) {
298            file_hits += 1;
299        }
300    }
301    let localization = if gt.files.is_empty() {
302        1.0
303    } else {
304        file_hits as f64 / gt.files.len() as f64
305    };
306
307    // hallucinations must NOT surface
308    let mut hallucinations_hit: Vec<String> = Vec::new();
309    for h in &task.hallucinations {
310        let surfaced = pack_by_kind
311            .get(h.kind.as_str())
312            .map(|set| set.contains(&h.name))
313            .unwrap_or(false)
314            || content.contains(&h.name);
315        if surfaced {
316            hallucinations_hit.push(format!("{}:{}", h.kind, h.name));
317        }
318    }
319
320    Ok(TaskResult {
321        id: task.id.clone(),
322        recall,
323        precision,
324        localization,
325        tokens,
326        budget_ok,
327        hallucinations_hit,
328        missed,
329    })
330}
331// trace:v1 id=impl.scc.bench.context work=WORK-SCC-001 verifies=REQ-SCC-TEST
332
333/// Run the full context benchmark against `benchmarks/tasks.json`.
334// trace:v1 id=impl.crates-scc-cli-src-benchctx.run-context-benchmark
335pub fn run_context_benchmark(min_recall: f64, min_precision: f64) -> Result<BenchSummary, String> {
336    let fixtures = locate_fixtures_dir().ok_or("cannot locate fixtures/ directory")?;
337    let corpus_path = fixtures
338        .parent()
339        .map(|p| p.join("benchmarks/tasks.json"))
340        .or_else(|| {
341            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
342                .parent()
343                .and_then(|p| p.parent())
344                .map(|p| p.join("benchmarks/tasks.json"))
345        })
346        .ok_or("cannot locate benchmarks/tasks.json")?;
347    let text = std::fs::read_to_string(&corpus_path).map_err(|e| e.to_string())?;
348    let corpus: BenchmarkCorpus = serde_json::from_str(&text).map_err(|e| e.to_string())?;
349
350    let mut summary = BenchSummary {
351        tasks: corpus.tasks.len(),
352        ..Default::default()
353    };
354    for task in &corpus.tasks {
355        let repo_dir = fixtures.join(&task.repo);
356        if !repo_dir.is_dir() {
357            return Err(format!("fixture repo missing: {}", task.repo));
358        }
359        match score_task(&repo_dir, task) {
360            Ok(r) => {
361                summary.mean_recall += r.recall;
362                summary.mean_precision += r.precision;
363                summary.mean_localization += r.localization;
364                if r.budget_ok {
365                    summary.budget_ok += 1;
366                }
367                summary.hallucination_violations += r.hallucinations_hit.len();
368                summary.results.push(r);
369            }
370            Err(e) => return Err(format!("task {} failed: {e}", task.id)),
371        }
372    }
373    let n = corpus.tasks.len() as f64;
374    summary.mean_recall /= n;
375    summary.mean_precision /= n;
376    summary.mean_localization /= n;
377
378    // fail when the quality gate is not met
379    if summary.mean_recall < min_recall {
380        return Err(format!(
381            "benchmark gate failed: mean recall {:.3} < {min_recall}",
382            summary.mean_recall
383        ));
384    }
385    // precision gate (§40): recall alone rewards returning everything.
386    // Default 0.0 keeps the gate recall-only unless a floor is set.
387    if summary.mean_precision < min_precision {
388        return Err(format!(
389            "benchmark gate failed: mean precision {:.3} < {min_precision}",
390            summary.mean_precision
391        ));
392    }
393    if summary.hallucination_violations > 0 {
394        return Err(format!(
395            "benchmark gate failed: {} hallucination violation(s)",
396            summary.hallucination_violations
397        ));
398    }
399    Ok(summary)
400}
401
402// trace:v1 id=impl.crates-scc-cli-src-benchctx.print-summary
403pub fn print_summary(s: &BenchSummary) {
404    println!("scc bench context — ground-truth corpus");
405    println!(
406        "  tasks: {}   mean recall: {:.3}   mean precision: {:.3}   mean localization: {:.3}   budget-ok: {}/{}   hallucination violations: {}",
407        s.tasks, s.mean_recall, s.mean_precision, s.mean_localization, s.budget_ok, s.tasks,
408        s.hallucination_violations
409    );
410    println!("  {:<42} {:>7} {:>9} {:>12} {:>7} {:>6}", "task", "recall", "precision", "localization", "tokens", "budget");
411    for r in &s.results {
412        println!(
413            "  {:<42} {:>7.3} {:>9.3} {:>12.3} {:>7} {:>6}",
414            r.id, r.recall, r.precision, r.localization, r.tokens, if r.budget_ok { "ok" } else { "OVER" }
415        );
416        for m in &r.missed {
417            println!("      missed: {m}");
418        }
419        for h in &r.hallucinations_hit {
420            println!("      HALLUCINATION: {h}");
421        }
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430// trace:v1 id=impl.crates-scc-cli-src-benchctx.normalize-id-handles-encoding
431    fn normalize_id_handles_encoding() {
432        let id = "repo://repo/symbol/src/asr/client.ts/transcribe";
433        let (kind, name) = normalize_id(id).unwrap();
434        assert_eq!(kind, "symbol");
435        assert_eq!(name, "transcribe");
436        let id2 = "repo://repo/symbol/api/routes.ts/renderTranscript";
437        let (_, name2) = normalize_id(id2).unwrap();
438        assert_eq!(name2, "renderTranscript");
439        let (k3, n3) = normalize_id("repo://repo/component/services").unwrap();
440        assert_eq!(k3, "component");
441        assert_eq!(n3, "services");
442    }
443
444    #[test]
445    // trace:v1 id=test.scc.bench.merged-component-hit verifies=REQ-implement-phase-7-of-scc-x-ripwire-lessons-1-one-hop-type-narrowing exercises=impl.scc.bench.merged-component-hit
446    fn merged_cluster_satisfies_member_region_gold() {
447        let pack: BTreeSet<String> = ["root-services".into()].into();
448        assert!(component_name_hit(&pack, "root"));
449        assert!(component_name_hit(&pack, "services"));
450        assert!(component_name_hit(&pack, "root-services"));
451        assert!(!component_name_hit(&pack, "tests"));
452        assert!(!component_name_hit(&pack, "service"));
453    }
454
455    #[test]
456// trace:v1 id=impl.crates-scc-cli-src-benchctx.corpus-is-valid-json
457    fn corpus_is_valid_json() {
458        let fixtures = locate_fixtures_dir().expect("fixtures dir");
459        let path = fixtures
460            .parent()
461            .unwrap()
462            .join("benchmarks/tasks.json");
463        let text = std::fs::read_to_string(path).unwrap();
464        let corpus: BenchmarkCorpus = serde_json::from_str(&text).unwrap();
465        assert!(corpus.tasks.len() >= 14, "corpus needs >= 14 tasks");
466        let repos: BTreeSet<&str> = corpus.tasks.iter().map(|t| t.repo.as_str()).collect();
467        assert!(repos.len() >= 4, "corpus must span >= 4 fixture repos");
468    }
469}
470
471#[cfg(test)]
472mod match_tests {
473    use super::*;
474
475    #[test]
476// trace:v1 id=impl.crates-scc-cli-src-benchctx.test-entity-matches-sanitized-gt
477    fn test_entity_matches_sanitized_gt() {
478        // pack id for a test entity
479        let id = "repo://repo/test/tests/test-transcripts.py/test-normalization-preserves-raw";
480        let (kind, name) = normalize_id(id).unwrap();
481        assert_eq!(kind, "test");
482        let gt_norm = scc_core::sanitize_key("test_normalization_preserves_raw");
483        assert_eq!(name, gt_norm, "sanitized gt must equal normalized id name");
484    }
485
486    #[test]
487// trace:v1 id=impl.crates-scc-cli-src-benchctx.file-ids-sanitize-underscores
488    fn file_ids_sanitize_underscores() {
489        let id = "repo://repo/file/tests/test-transcripts.py";
490        let (kind, name) = normalize_id(id).unwrap();
491        assert_eq!(kind, "file");
492        assert_eq!(name, scc_core::sanitize_key("tests/test_transcripts.py"));
493    }
494
495    #[test]
496// trace:v1 id=impl.crates-scc-cli-src-benchctx.route-ids-match
497    fn route_ids_match() {
498        let id = "repo://repo/route/get-/api/transcripts/-id";
499        let (kind, name) = normalize_id(id).unwrap();
500        assert_eq!(kind, "route");
501        assert_eq!(name, scc_core::sanitize_key("GET /api/transcripts/:id"));
502    }
503}