1use serde::Deserialize;
12use std::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Deserialize)]
16pub 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)]
25pub 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)]
36pub 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)]
55pub struct Hallucination {
57 #[serde(rename = "type")]
58 pub kind: String,
59 pub name: String,
60}
61
62#[derive(Debug, Clone, Default)]
63pub 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)]
76pub 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
87pub 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
109pub(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
129fn normalize_id(id: &str) -> Option<(String, String)> {
132 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 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 let name = scc_core::sanitize_key(&scc_core::decode_component(name_rest));
152 Some((kind, name))
153 }
154 _ => None,
155 }
156}
157
158fn 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
174pub fn score_task_public(
176 repo_dir: &Path,
177 task: &BenchTask,
178) -> Result<TaskResult, String> {
179 score_task(repo_dir, task)
180}
181
182fn 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 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 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", >.files, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
254 check("symbol", >.symbols, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
255 check("component", >.components, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
256 check("data", >.data, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
257 check("test", >.tests, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
258 check("route", >.routes, &pack_by_kind, &mut gt_total, &mut gt_hit, &mut missed);
259 check("store", >.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 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(>.symbols)
272 .chain(>.components)
273 .chain(>.data)
274 .chain(>.tests)
275 .chain(>.routes)
276 .chain(>.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 let mut file_hits = 0usize;
296 for f in >.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 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}
331pub 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 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 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
402pub 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]
430fn 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 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]
456fn 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]
476fn test_entity_matches_sanitized_gt() {
478 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]
487fn 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]
496fn 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}