Skip to main content

scc_cli/
benchloop.rs

1//! Deterministic agent-loop comparison: baseline vs SCC vs Ripwire.
2//!
3//! This is a locator loop (retrieve context, open named files), not an LLM
4//! run. Ripwire is a black-box CLI. A missing binary is `skipped`, never a
5//! claimed win. Production ranking is unchanged.
6
7use crate::benchctx::{copy_fixture, locate_fixtures_dir, BenchmarkCorpus};
8use scc_core::subtokens;
9use serde::Serialize;
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15// trace:exempt reason=internal-detail
16pub enum LoopArm {
17    Baseline,
18    Scc,
19    Ripwire,
20}
21
22// trace:exempt reason=internal-detail
23impl LoopArm {
24    // trace:exempt reason=internal-detail
25    pub fn as_str(self) -> &'static str {
26        match self {
27            LoopArm::Baseline => "baseline",
28            LoopArm::Scc => "scc",
29            LoopArm::Ripwire => "ripwire",
30        }
31    }
32
33    // trace:exempt reason=internal-detail
34    pub fn parse(s: &str) -> Option<Self> {
35        match s.trim() {
36            "baseline" => Some(LoopArm::Baseline),
37            "scc" => Some(LoopArm::Scc),
38            "ripwire" => Some(LoopArm::Ripwire),
39            _ => None,
40        }
41    }
42}
43
44#[derive(Debug, Clone, Serialize)]
45// trace:exempt reason=internal-detail
46pub struct LoopTaskRow {
47    pub task: String,
48    pub repo: String,
49    pub arm: String,
50    pub status: String,
51    pub localization: f64,
52    pub first_correct_rank: Option<usize>,
53    pub files_opened: usize,
54    pub search_calls: usize,
55    pub scc_calls: usize,
56    pub substitution_rate: f64,
57    pub contaminated: bool,
58    #[serde(default)]
59    pub protocol: String,
60    #[serde(default)]
61    pub read_calls: usize,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub first_correct_ms: Option<u64>,
64    #[serde(default)]
65    pub wrong_first: usize,
66    #[serde(default)]
67    pub jsonl_events: usize,
68    /// Recall of gold test names/paths against pack tests_to_run. `None`
69    /// when the task has no gold tests (omitted from clustered mean).
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub tests_localization: Option<f64>,
72    #[serde(default)]
73    pub tests_hit: usize,
74    #[serde(default)]
75    pub tests_gold: usize,
76}
77
78#[derive(Debug, Clone, Serialize, Default)]
79// trace:exempt reason=internal-detail
80pub struct LoopArmSummary {
81    pub arm: String,
82    pub status: String,
83    pub tasks: usize,
84    pub clustered_localization: f64,
85    pub pooled_localization: f64,
86    pub mean_substitution: f64,
87    pub skipped: usize,
88    #[serde(default)]
89    pub mean_search: f64,
90    #[serde(default)]
91    pub mean_read: f64,
92    #[serde(default)]
93    pub mean_files_opened: f64,
94    #[serde(default)]
95    pub clustered_tests: f64,
96    #[serde(default)]
97    pub pooled_tests: f64,
98    #[serde(default)]
99    pub tests_tasks: usize,
100}
101
102#[derive(Debug, Clone, Serialize, Default)]
103// trace:exempt reason=internal-detail
104pub struct LoopSummary {
105    pub arms: Vec<LoopArmSummary>,
106    pub per_task: Vec<LoopTaskRow>,
107    pub ripwire_bin: Option<String>,
108    #[serde(default)]
109    pub protocol: String,
110}
111
112#[derive(Debug, Clone, Default)]
113// trace:exempt reason=internal-detail
114pub struct LoopOptions {
115    pub k: usize,
116    pub repo_filter: Option<String>,
117    pub ripwire_bin: Option<PathBuf>,
118    pub explore: bool,
119    pub agent_cmd: Option<String>,
120}
121
122/// Run the three-way locator loop. `min_delta` is unused here (measure-only
123/// in tests); the CLI applies it after printing.
124// trace:v1 id=impl.scc.cli.bench-loop work=WORK-ripwire-lessons-phase5 satisfies=REQ-agent-loop-three-way,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no
125pub fn run_agent_loop(arms: &[LoopArm], opts: &LoopOptions) -> Result<LoopSummary, String> {
126    if opts.agent_cmd.is_some() {
127        crate::benchagent::require_paid_opt_in()?;
128    }
129    let k = opts.k.max(1);
130    let fixtures = locate_fixtures_dir().ok_or("cannot locate fixtures/ directory")?;
131    let corpus_path = fixtures
132        .parent()
133        .map(|p| p.join("benchmarks/tasks.json"))
134        .ok_or("cannot locate benchmarks/tasks.json")?;
135    let text = std::fs::read_to_string(&corpus_path)
136        .map_err(|e| format!("read {}: {e}", corpus_path.display()))?;
137    let corpus: BenchmarkCorpus =
138        serde_json::from_str(&text).map_err(|e| format!("tasks.json: {e}"))?;
139
140    let ripwire = resolve_ripwire(opts.ripwire_bin.as_deref());
141    let mut summary = LoopSummary {
142        ripwire_bin: ripwire.as_ref().map(|p| p.display().to_string()),
143        protocol: if opts.explore {
144            "explore".into()
145        } else {
146            "locator".into()
147        },
148        ..Default::default()
149    };
150
151    let mut by_repo: BTreeMap<String, Vec<&crate::benchctx::BenchTask>> = BTreeMap::new();
152    for t in &corpus.tasks {
153        if let Some(f) = &opts.repo_filter {
154            if t.repo != *f {
155                continue;
156            }
157        }
158        by_repo.entry(t.repo.clone()).or_default().push(t);
159    }
160    if by_repo.is_empty() {
161        return Err("no agent-loop tasks matched".into());
162    }
163
164    for (repo, tasks) in &by_repo {
165        let src = fixtures.join(repo);
166        if !src.is_dir() {
167            continue;
168        }
169        let tmp = tempfile::TempDir::new().map_err(|e| e.to_string())?;
170        let root = tmp.path().join("repo");
171        copy_fixture(&src, &root);
172        crate::commands::cmd_index(&root, true).map_err(|e| format!("index {repo}: {e}"))?;
173
174        let indexed = indexed_paths(&root)?;
175        for task in tasks {
176            let gold = &task.ground_truth.files;
177            if gold.is_empty() {
178                continue;
179            }
180            let gold_tests = &task.ground_truth.tests;
181            let contaminated = std::env::var("SCC_GOLD").is_ok();
182            for arm in arms {
183                let row = if opts.explore {
184                    run_explore_arm(
185                        *arm,
186                        &root,
187                        &task.id,
188                        &task.repo,
189                        &task.goal,
190                        gold,
191                        gold_tests,
192                        &indexed,
193                        k,
194                        ripwire.as_deref(),
195                        contaminated,
196                        opts.agent_cmd.as_deref(),
197                    )?
198                } else {
199                    run_arm(
200                        *arm,
201                        &root,
202                        &task.id,
203                        &task.repo,
204                        &task.goal,
205                        gold,
206                        gold_tests,
207                        &indexed,
208                        k,
209                        ripwire.as_deref(),
210                        contaminated,
211                    )?
212                };
213                summary.per_task.push(row);
214            }
215        }
216    }
217
218    summary.arms = aggregate(&summary.per_task, arms);
219    Ok(summary)
220}
221
222#[allow(clippy::too_many_arguments)]
223// trace:exempt reason=internal-detail
224fn run_arm(
225    arm: LoopArm,
226    root: &Path,
227    task_id: &str,
228    repo: &str,
229    goal: &str,
230    gold: &[String],
231    gold_tests: &[String],
232    indexed: &[String],
233    k: usize,
234    ripwire: Option<&Path>,
235    contaminated: bool,
236) -> Result<LoopTaskRow, String> {
237    match arm {
238        LoopArm::Baseline => {
239            let opened = baseline_open(root, goal, k);
240            Ok(score_row(
241                task_id,
242                repo,
243                arm,
244                "ran",
245                &opened,
246                gold,
247                gold_tests,
248                "",
249                1,
250                0,
251                0.0,
252                contaminated,
253            ))
254        }
255        LoopArm::Scc => {
256            let pack = scc_pack(root, goal)?;
257            let opened = files_from_text(&pack, indexed, k);
258            Ok(score_row(
259                task_id,
260                repo,
261                arm,
262                "ran",
263                &opened,
264                gold,
265                gold_tests,
266                &pack,
267                0,
268                1,
269                1.0,
270                contaminated,
271            ))
272        }
273        LoopArm::Ripwire => match ripwire {
274            None => Ok(skipped_row(task_id, repo, "locator", contaminated)),
275            Some(bin) => {
276                let out = run_ripwire(bin, root, goal)?;
277                let opened = files_from_ripwire(&out, indexed, k);
278                Ok(score_row(
279                    task_id,
280                    repo,
281                    arm,
282                    "ran",
283                    &opened,
284                    gold,
285                    gold_tests,
286                    &out,
287                    0,
288                    0,
289                    0.0,
290                    contaminated,
291                ))
292            }
293        },
294    }
295}
296
297#[allow(clippy::too_many_arguments)]
298// trace:exempt reason=internal-detail
299fn score_row(
300    task: &str,
301    repo: &str,
302    arm: LoopArm,
303    status: &str,
304    opened: &[String],
305    gold: &[String],
306    gold_tests: &[String],
307    pack: &str,
308    search_calls: usize,
309    scc_calls: usize,
310    substitution_rate: f64,
311    contaminated: bool,
312) -> LoopTaskRow {
313    let gold_set: BTreeSet<String> = gold.iter().cloned().collect();
314    let hits = opened
315        .iter()
316        .filter(|f| gold_matches(f, &gold_set))
317        .count();
318    let localization = if gold.is_empty() {
319        1.0
320    } else {
321        hits as f64 / gold.len() as f64
322    };
323    let first_correct_rank = opened.iter().position(|f| gold_matches(f, &gold_set)).map(|i| i + 1);
324    let (tests_localization, tests_hit, tests_gold) = score_tests(pack, gold_tests);
325    LoopTaskRow {
326        task: task.to_string(),
327        repo: repo.to_string(),
328        arm: arm.as_str().to_string(),
329        status: status.to_string(),
330        localization,
331        first_correct_rank,
332        files_opened: opened.len(),
333        search_calls,
334        scc_calls,
335        substitution_rate,
336        contaminated,
337        protocol: "locator".into(),
338        read_calls: 0,
339        first_correct_ms: None,
340        wrong_first: 0,
341        jsonl_events: 0,
342        tests_localization,
343        tests_hit,
344        tests_gold,
345    }
346}
347
348// trace:exempt reason=internal-detail
349fn skipped_row(task_id: &str, repo: &str, protocol: &str, contaminated: bool) -> LoopTaskRow {
350    LoopTaskRow {
351        task: task_id.to_string(),
352        repo: repo.to_string(),
353        arm: LoopArm::Ripwire.as_str().to_string(),
354        status: "skipped".into(),
355        localization: 0.0,
356        first_correct_rank: None,
357        files_opened: 0,
358        search_calls: 0,
359        scc_calls: 0,
360        substitution_rate: 0.0,
361        contaminated,
362        protocol: protocol.into(),
363        read_calls: 0,
364        first_correct_ms: None,
365        wrong_first: 0,
366        jsonl_events: 0,
367        tests_localization: None,
368        tests_hit: 0,
369        tests_gold: 0,
370    }
371}
372
373// trace:exempt reason=internal-detail
374fn gold_matches(opened: &str, gold: &BTreeSet<String>) -> bool {
375    gold.iter().any(|g| opened == g || opened.ends_with(&format!("/{g}")) || g.ends_with(&format!("/{opened}")))
376}
377
378/// Names and paths from SCC TESTS rows and Ripwire `<test p="...">` pack rows.
379// trace:v1 id=impl.scc.cli.loop-tests work=WORK-phase-18-of-scc-x-ripwire-lessons-score-tests-to-run-in-the-agent-loop satisfies=REQ-implement-phase-18-of-scc-x-ripwire-lessons-score-tests-to-run-in-the implements=PLAN-phase-18-of-scc-x-ripwire-lessons-score-tests-to-run-in-the-agent-loop
380fn tests_from_pack(pack: &str) -> Vec<String> {
381    let mut out: Vec<String> = Vec::new();
382    for line in pack.lines() {
383        let t = line.trim();
384        if let Some(p) = xml_test_path(t) {
385            out.push(p);
386            continue;
387        }
388        if !(t.starts_with("- ") && (t.contains(" — ") || t.contains(" -- "))) {
389            continue;
390        }
391        let rest = t.trim_start_matches("- ");
392        let left = rest
393            .split(" — ")
394            .next()
395            .unwrap_or(rest)
396            .split(" -- ")
397            .next()
398            .unwrap_or(rest)
399            .trim();
400        if let Some((name, after)) = left.split_once(" (") {
401            let name = name.trim();
402            if !name.is_empty() {
403                out.push(name.to_string());
404            }
405            if let Some(file) = after.strip_suffix(')') {
406                let file = file.trim();
407                if !file.is_empty() {
408                    out.push(file.to_string());
409                }
410            }
411        } else if !left.is_empty() {
412            out.push(left.to_string());
413        }
414    }
415    out.sort();
416    out.dedup();
417    out
418}
419
420// trace:exempt reason=internal-detail
421fn xml_test_path(t: &str) -> Option<String> {
422    let idx = t.find("<test ")?;
423    let rest = &t[idx..];
424    let p = rest.find("p=\"")?;
425    let start = p + 3;
426    let end = rest[start..].find('"')?;
427    let path = rest[start..start + end].trim();
428    if path.is_empty() {
429        None
430    } else {
431        Some(path.to_string())
432    }
433}
434
435/// Gold kebab-case `it()` ids match SCC titles; file paths do not match function names.
436// trace:v1 id=impl.scc.cli.loop-test-match work=WORK-phase-20-of-scc-x-ripwire-lessons-raise-tests-to-run-recall-for-type-scr satisfies=REQ-implement-phase-20-of-scc-x-ripwire-lessons-raise-tests-to-run-recall implements=PLAN-phase-20-of-scc-x-ripwire-lessons-raise-tests-to-run-recall-for-type-scr
437fn test_name_matches(proposed: &str, gold: &str) -> bool {
438    if proposed.eq_ignore_ascii_case(gold) {
439        return true;
440    }
441    if gold.contains('/') && (proposed.ends_with(gold) || gold.ends_with(proposed)) {
442        return true;
443    }
444    let p: Vec<String> = ident_tokens(proposed).collect();
445    let g: Vec<String> = ident_tokens(gold).collect();
446    let gold_l = gold.to_ascii_lowercase();
447    p.contains(&gold_l) || (!g.is_empty() && p == g)
448}
449
450// trace:exempt reason=internal-detail
451fn ident_tokens(s: &str) -> impl Iterator<Item = String> + '_ {
452    s.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
453        .filter(|t| !t.is_empty())
454        .map(|t| t.to_ascii_lowercase())
455}
456
457/// `None` when the task has no gold tests — omit from clustered mean.
458// trace:exempt reason=internal-detail
459fn score_tests(pack: &str, gold: &[String]) -> (Option<f64>, usize, usize) {
460    if gold.is_empty() {
461        return (None, 0, 0);
462    }
463    let proposed = tests_from_pack(pack);
464    let hits = gold
465        .iter()
466        .filter(|g| proposed.iter().any(|p| test_name_matches(p, g)))
467        .count();
468    (Some(hits as f64 / gold.len() as f64), hits, gold.len())
469}
470
471// trace:exempt reason=internal-detail
472fn aggregate(rows: &[LoopTaskRow], arms: &[LoopArm]) -> Vec<LoopArmSummary> {
473    let mut out = Vec::new();
474    for arm in arms {
475        let mine: Vec<&LoopTaskRow> = rows.iter().filter(|r| r.arm == arm.as_str()).collect();
476        let skipped = mine.iter().filter(|r| r.status == "skipped").count();
477        let ran: Vec<&LoopTaskRow> = mine.iter().copied().filter(|r| r.status == "ran").collect();
478        let mut by_repo: BTreeMap<String, Vec<f64>> = BTreeMap::new();
479        let mut by_repo_tests: BTreeMap<String, Vec<f64>> = BTreeMap::new();
480        let mut pooled = 0.0;
481        let mut subst = 0.0;
482        let mut search = 0.0;
483        let mut read = 0.0;
484        let mut files = 0.0;
485        let mut pooled_tests = 0.0;
486        let mut tests_n = 0usize;
487        for r in &ran {
488            by_repo.entry(r.repo.clone()).or_default().push(r.localization);
489            pooled += r.localization;
490            subst += r.substitution_rate;
491            search += r.search_calls as f64;
492            read += r.read_calls as f64;
493            files += r.files_opened as f64;
494            if let Some(t) = r.tests_localization {
495                by_repo_tests.entry(r.repo.clone()).or_default().push(t);
496                pooled_tests += t;
497                tests_n += 1;
498            }
499        }
500        let clustered = clustered_mean(&by_repo);
501        let n = ran.len();
502        out.push(LoopArmSummary {
503            arm: arm.as_str().to_string(),
504            status: if n == 0 && skipped > 0 {
505                "skipped".into()
506            } else {
507                "ran".into()
508            },
509            tasks: n,
510            clustered_localization: clustered,
511            pooled_localization: if n == 0 { 0.0 } else { pooled / n as f64 },
512            mean_substitution: if n == 0 { 0.0 } else { subst / n as f64 },
513            skipped,
514            mean_search: if n == 0 { 0.0 } else { search / n as f64 },
515            mean_read: if n == 0 { 0.0 } else { read / n as f64 },
516            mean_files_opened: if n == 0 { 0.0 } else { files / n as f64 },
517            clustered_tests: clustered_mean(&by_repo_tests),
518            pooled_tests: if tests_n == 0 { 0.0 } else { pooled_tests / tests_n as f64 },
519            tests_tasks: tests_n,
520        });
521    }
522    out
523}
524
525// trace:exempt reason=internal-detail
526fn clustered_mean(per_repo: &BTreeMap<String, Vec<f64>>) -> f64 {
527    if per_repo.is_empty() {
528        return 0.0;
529    }
530    let mut acc = 0.0;
531    let mut n = 0usize;
532    for vals in per_repo.values() {
533        if vals.is_empty() {
534            continue;
535        }
536        acc += vals.iter().sum::<f64>() / vals.len() as f64;
537        n += 1;
538    }
539    if n == 0 {
540        0.0
541    } else {
542        acc / n as f64
543    }
544}
545
546// trace:exempt reason=internal-detail
547fn indexed_paths(root: &Path) -> Result<Vec<String>, String> {
548    let store = crate::open_store(root).map_err(|e| e.to_string())?;
549    let mut paths: Vec<String> = store
550        .all_files()
551        .map_err(|e| e.to_string())?
552        .into_iter()
553        .map(|(p, _, _, _, _)| p)
554        .collect();
555    paths.sort();
556    Ok(paths)
557}
558
559// trace:exempt reason=internal-detail
560fn scc_pack(root: &Path, goal: &str) -> Result<String, String> {
561    let store = crate::open_store(root).map_err(|e| e.to_string())?;
562    let config = crate::load_config(root).map_err(|e| e.to_string())?;
563    let comp = crate::compiler(&store, &config, Vec::new()).map_err(|e| e.to_string())?;
564    let ctx = comp.ctx();
565    let pack = ctx.task_context(goal, &[], &[], Some(4000));
566    Ok(pack.content)
567}
568
569// trace:exempt reason=internal-detail
570fn baseline_open(root: &Path, goal: &str, k: usize) -> Vec<String> {
571    let q: BTreeSet<String> = subtokens(goal).into_iter().collect();
572    if q.is_empty() {
573        return Vec::new();
574    }
575    let mut scored: Vec<(i32, String)> = Vec::new();
576    walk_files(root, root, &mut |rel, full| {
577        let mut hay = rel.to_ascii_lowercase();
578        if let Ok(text) = std::fs::read_to_string(full) {
579            let take = text.chars().take(8000).collect::<String>();
580            hay.push(' ');
581            hay.push_str(&take);
582        }
583        let toks = subtokens(&hay);
584        let score = toks.iter().filter(|t| q.contains(*t)).count() as i32;
585        if score > 0 {
586            scored.push((score, rel.to_string()));
587        }
588    });
589    scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
590    scored.into_iter().take(k).map(|(_, p)| p).collect()
591}
592
593// trace:exempt reason=internal-detail
594fn walk_files(root: &Path, dir: &Path, visit: &mut impl FnMut(&str, &Path)) {
595    let Ok(rd) = std::fs::read_dir(dir) else {
596        return;
597    };
598    for entry in rd.flatten() {
599        let name = entry.file_name();
600        if name == ".scc" || name == ".git" {
601            continue;
602        }
603        let path = entry.path();
604        if path.is_dir() {
605            walk_files(root, &path, visit);
606        } else if path.is_file() {
607            if let Ok(rel) = path.strip_prefix(root) {
608                let rel = rel.to_string_lossy().replace('\\', "/");
609                visit(&rel, &path);
610            }
611        }
612    }
613}
614
615/// Prefer FETCH `handle=scc://...` keys. Stale handles are refused (not
616/// guessed as a path). Packs without handles fall back to path matching.
617/// Path mentions may fill leftover slots only for files that were not
618/// refused via a stale handle.
619// trace:v1 id=impl.scc.cli.explore-handles work=WORK-phase-13-of-scc-x-ripwire-lessons-1-go-extract-time-receiver-field-as satisfies=REQ-implement-phase-13-of-scc-x-ripwire-lessons-1-go-extract-time-recei implements=PLAN-phase-13-of-scc-x-ripwire-lessons-1-go-extract-time-receiver-field-as
620fn files_from_pack(text: &str, root: &Path, indexed: &[String], k: usize) -> Vec<String> {
621    let (saw_handle, mut opened, refused) = files_from_handles(text, root, indexed, k);
622    if !saw_handle {
623        return files_from_text(text, indexed, k);
624    }
625    if opened.len() >= k {
626        return opened;
627    }
628    for p in files_from_text(text, indexed, k) {
629        if refused.contains(&p) || opened.iter().any(|x| x == &p) {
630            continue;
631        }
632        opened.push(p);
633        if opened.len() >= k {
634            break;
635        }
636    }
637    opened
638}
639
640// trace:exempt reason=internal-detail
641fn files_from_handles(
642    text: &str,
643    root: &Path,
644    indexed: &[String],
645    k: usize,
646) -> (bool, Vec<String>, BTreeSet<String>) {
647    let mut saw = false;
648    let mut out = Vec::new();
649    let mut seen = BTreeSet::new();
650    let mut refused = BTreeSet::new();
651    for raw in text.split_whitespace() {
652        let token = raw
653            .strip_prefix("handle=")
654            .unwrap_or(raw)
655            .trim_end_matches([',', ';', ')', ']']);
656        if !token.starts_with("scc://") {
657            continue;
658        }
659        saw = true;
660        match scc_context::structural_source::resolve_handle_to_path(root, token) {
661            Ok(p) => {
662                let hit = indexed
663                    .iter()
664                    .any(|i| i == &p || i.ends_with(&format!("/{p}")));
665                if hit && seen.insert(p.clone()) && out.len() < k {
666                    out.push(p);
667                }
668            }
669            Err(_) => {
670                if let Some(p) = path_from_handle(token) {
671                    refused.insert(p);
672                }
673            }
674        }
675    }
676    (saw, out, refused)
677}
678
679// trace:exempt reason=internal-detail
680fn path_from_handle(token: &str) -> Option<String> {
681    let h = scc_core::ContentHandle::parse(token).ok()?;
682    match h.kind {
683        scc_core::HandleKind::File => Some(h.key),
684        scc_core::HandleKind::Symbol => h.key.split_once("::").map(|(p, _)| p.to_string()),
685        scc_core::HandleKind::Span => h.key.split_once(":L").map(|(p, _)| p.to_string()),
686        _ => None,
687    }
688}
689
690// trace:exempt reason=internal-detail
691fn files_from_text(text: &str, indexed: &[String], k: usize) -> Vec<String> {
692    let mut hits: Vec<(usize, String)> = Vec::new();
693    for p in indexed {
694        if let Some(idx) = text.find(p) {
695            hits.push((idx, p.clone()));
696            continue;
697        }
698        if let Some(base) = p.rsplit('/').next() {
699            if base.len() >= 3 {
700                if let Some(idx) = text.find(base) {
701                    hits.push((idx, p.clone()));
702                }
703            }
704        }
705    }
706    hits.sort_by_key(|(i, p)| (*i, p.clone()));
707    let mut out = Vec::new();
708    let mut seen = BTreeSet::new();
709    for (_, p) in hits {
710        if seen.insert(p.clone()) {
711            out.push(p);
712        }
713        if out.len() >= k {
714            break;
715        }
716    }
717    out
718}
719
720// trace:exempt reason=internal-detail
721fn files_from_ripwire(text: &str, indexed: &[String], k: usize) -> Vec<String> {
722    let mut ordered: Vec<String> = Vec::new();
723    let mut seen = BTreeSet::new();
724    for (prefix, quote) in [("p=\"", '"'), ("p='", '\'')] {
725        let mut rest = text;
726        while let Some(i) = rest.find(prefix) {
727            let after = &rest[i + prefix.len()..];
728            if let Some(end) = after.find(quote) {
729                let p = after[..end].replace('\\', "/");
730                if !p.is_empty() && seen.insert(p.clone()) {
731                    ordered.push(p);
732                }
733                rest = &after[end + 1..];
734            } else {
735                break;
736            }
737        }
738    }
739    if ordered.is_empty() {
740        return files_from_text(text, indexed, k);
741    }
742    let mut out = Vec::new();
743    for p in ordered {
744        if let Some(hit) = indexed.iter().find(|idx| {
745            *idx == &p || idx.ends_with(&format!("/{p}")) || p.ends_with(&format!("/{idx}"))
746        }) {
747            if !out.iter().any(|x| x == hit) {
748                out.push(hit.clone());
749            }
750        } else if !out.contains(&p) {
751            out.push(p);
752        }
753        if out.len() >= k {
754            break;
755        }
756    }
757    out
758}
759
760// trace:exempt reason=internal-detail
761fn resolve_ripwire(explicit: Option<&Path>) -> Option<PathBuf> {
762    if let Some(p) = explicit {
763        if p.is_file() {
764            return Some(p.to_path_buf());
765        }
766    }
767    if let Ok(p) = std::env::var("RIPWIRE_BIN") {
768        let pb = PathBuf::from(p);
769        if pb.is_file() {
770            return Some(pb);
771        }
772    }
773    // Unit tests must not depend on a machine-local vendor tree. CLI and
774    // `cargo run` still search known build dirs so a present binary is
775    // measured instead of silently skipped.
776    if !cfg!(test) {
777        for cand in [
778            "/tmp/vendor/ripwire/build/ripwire",
779            "/tmp/vendor/ripwire/build-gxx/ripwire",
780        ] {
781            let p = PathBuf::from(cand);
782            if p.is_file() {
783                return Some(p);
784            }
785        }
786        if let Some(p) = scan_ripwire_builds() {
787            return Some(p);
788        }
789    }
790    which("ripwire")
791}
792
793// trace:exempt reason=internal-detail
794fn scan_ripwire_builds() -> Option<PathBuf> {
795    let root = Path::new("/tmp/vendor/ripwire");
796    let rd = std::fs::read_dir(root).ok()?;
797    let mut bins: Vec<PathBuf> = rd
798        .filter_map(|e| e.ok())
799        .map(|e| e.path())
800        .filter(|p| {
801            p.file_name()
802                .and_then(|n| n.to_str())
803                .is_some_and(|n| n.starts_with("build"))
804        })
805        .map(|p| p.join("ripwire"))
806        .filter(|p| p.is_file())
807        .collect();
808    bins.sort();
809    bins.into_iter().next()
810}
811
812// trace:exempt reason=internal-detail
813fn which(name: &str) -> Option<PathBuf> {
814    let path = std::env::var("PATH").ok()?;
815    for dir in path.split(':') {
816        let p = Path::new(dir).join(name);
817        if p.is_file() {
818            return Some(p);
819        }
820    }
821    None
822}
823
824// trace:exempt reason=internal-detail
825fn run_ripwire(bin: &Path, root: &Path, goal: &str) -> Result<String, String> {
826    let out = Command::new(bin)
827        .arg(root)
828        .arg(format!("--pack-task={goal}"))
829        .stdout(std::process::Stdio::piped())
830        .stderr(std::process::Stdio::piped())
831        .output();
832    let out = match out {
833        Ok(o) => o,
834        Err(e) => return Err(format!("ripwire spawn: {e}")),
835    };
836    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
837    if !out.status.success() {
838        return Err(format!(
839            "ripwire status {} stderr={} stdout={}",
840            out.status,
841            String::from_utf8_lossy(&out.stderr),
842            stdout
843        ));
844    }
845    if stdout.trim().is_empty() {
846        return Err(format!(
847            "ripwire empty stdout (status {}): {}",
848            out.status,
849            String::from_utf8_lossy(&out.stderr)
850        ));
851    }
852    Ok(stdout)
853}
854
855#[allow(clippy::too_many_arguments)]
856// trace:v1 id=impl.scc.cli.bench-explore 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
857fn run_explore_arm(
858    arm: LoopArm,
859    root: &Path,
860    task_id: &str,
861    repo: &str,
862    goal: &str,
863    gold: &[String],
864    gold_tests: &[String],
865    indexed: &[String],
866    k: usize,
867    ripwire: Option<&Path>,
868    contaminated: bool,
869    agent_cmd: Option<&str>,
870) -> Result<LoopTaskRow, String> {
871    if let Some(cmd) = agent_cmd {
872        return run_explore_agent_cmd(
873            arm,
874            root,
875            task_id,
876            repo,
877            goal,
878            gold,
879            gold_tests,
880            indexed,
881            k,
882            ripwire,
883            contaminated,
884            cmd,
885        );
886    }
887    match arm {
888        LoopArm::Baseline => {
889            let jsonl = explore_baseline_jsonl(root, goal, k);
890            Ok(score_explore(
891                task_id,
892                repo,
893                arm,
894                "ran",
895                &jsonl,
896                root,
897                gold,
898                gold_tests,
899                "",
900                0,
901                0.0,
902                contaminated,
903            ))
904        }
905        LoopArm::Scc => {
906            let pack = scc_pack(root, goal)?;
907            let opened = files_from_pack(&pack, root, indexed, k);
908            let jsonl = explore_pack_jsonl("task_context", goal, &opened);
909            Ok(score_explore(
910                task_id,
911                repo,
912                arm,
913                "ran",
914                &jsonl,
915                root,
916                gold,
917                gold_tests,
918                &pack,
919                1,
920                1.0,
921                contaminated,
922            ))
923        }
924        LoopArm::Ripwire => match ripwire {
925            None => Ok(skipped_row(task_id, repo, "explore", contaminated)),
926            Some(bin) => {
927                let out = run_ripwire(bin, root, goal)?;
928                let opened = files_from_ripwire(&out, indexed, k);
929                let jsonl = explore_ripwire_jsonl(goal, &opened);
930                Ok(score_explore(
931                    task_id,
932                    repo,
933                    arm,
934                    "ran",
935                    &jsonl,
936                    root,
937                    gold,
938                    gold_tests,
939                    &out,
940                    0,
941                    0.0,
942                    contaminated,
943                ))
944            }
945        },
946    }
947}
948
949#[allow(clippy::too_many_arguments)]
950// trace:exempt reason=internal-detail
951fn run_explore_agent_cmd(
952    arm: LoopArm,
953    root: &Path,
954    task_id: &str,
955    repo: &str,
956    goal: &str,
957    gold: &[String],
958    gold_tests: &[String],
959    indexed: &[String],
960    k: usize,
961    ripwire: Option<&Path>,
962    contaminated: bool,
963    cmd: &str,
964) -> Result<LoopTaskRow, String> {
965    if arm == LoopArm::Ripwire && ripwire.is_none() {
966        return Ok(skipped_row(task_id, repo, "explore", contaminated));
967    }
968    let pack = match arm {
969        LoopArm::Scc => scc_pack(root, goal)?,
970        LoopArm::Ripwire => run_ripwire(ripwire.unwrap(), root, goal)?,
971        LoopArm::Baseline => String::new(),
972    };
973    let pack_path = root.join(".scc").join("explore-pack.txt");
974    let _ = std::fs::create_dir_all(root.join(".scc"));
975    std::fs::write(&pack_path, &pack).map_err(|e| e.to_string())?;
976    let out = Command::new("sh")
977        .arg("-c")
978        .arg(cmd)
979        .current_dir(root)
980        .env("SCC_GOAL", goal)
981        .env("SCC_ARM", arm.as_str())
982        .env("SCC_PACK", pack_path.display().to_string())
983        .output()
984        .map_err(|e| format!("explore agent: {e}"))?;
985    let jsonl = String::from_utf8_lossy(&out.stdout).to_string();
986    let scc_calls = if arm == LoopArm::Scc { 1 } else { 0 };
987    let subst = if arm == LoopArm::Scc { 1.0 } else { 0.0 };
988    let _ = (indexed, k);
989    Ok(score_explore(
990        task_id,
991        repo,
992        arm,
993        "ran",
994        &jsonl,
995        root,
996        gold,
997        gold_tests,
998        &pack,
999        scc_calls,
1000        subst,
1001        contaminated,
1002    ))
1003}
1004
1005// trace:exempt reason=internal-detail
1006fn explore_baseline_jsonl(root: &Path, goal: &str, k: usize) -> String {
1007    let mut lines = Vec::new();
1008    lines.push(
1009        serde_json::json!({
1010            "type": "tool_use",
1011            "name": "grep",
1012            "input": {"query": goal}
1013        })
1014        .to_string(),
1015    );
1016    for p in baseline_open(root, goal, k) {
1017        lines.push(
1018            serde_json::json!({
1019                "type": "tool_use",
1020                "name": "read",
1021                "input": {"file_path": p}
1022            })
1023            .to_string(),
1024        );
1025    }
1026    lines.join("\n")
1027}
1028
1029// trace:exempt reason=internal-detail
1030fn explore_pack_jsonl(tool: &str, goal: &str, files: &[String]) -> String {
1031    let mut lines = Vec::new();
1032    lines.push(
1033        serde_json::json!({
1034            "type": "item.completed",
1035            "item": {
1036                "type": "mcp_tool_call",
1037                "tool": tool,
1038                "arguments": {"goal": goal}
1039            }
1040        })
1041        .to_string(),
1042    );
1043    for p in files {
1044        lines.push(
1045            serde_json::json!({
1046                "type": "tool_use",
1047                "name": "read",
1048                "input": {"file_path": p}
1049            })
1050            .to_string(),
1051        );
1052    }
1053    lines.join("\n")
1054}
1055
1056// trace:exempt reason=internal-detail
1057fn explore_ripwire_jsonl(goal: &str, files: &[String]) -> String {
1058    let mut lines = Vec::new();
1059    lines.push(
1060        serde_json::json!({
1061            "type": "item.completed",
1062            "item": {
1063                "type": "command_execution",
1064                "command": format!("ripwire . --pack-task={goal}")
1065            }
1066        })
1067        .to_string(),
1068    );
1069    for p in files {
1070        lines.push(
1071            serde_json::json!({
1072                "type": "tool_use",
1073                "name": "read",
1074                "input": {"file_path": p}
1075            })
1076            .to_string(),
1077        );
1078    }
1079    lines.join("\n")
1080}
1081
1082#[allow(clippy::too_many_arguments)]
1083// trace:exempt reason=internal-detail
1084fn score_explore(
1085    task: &str,
1086    repo: &str,
1087    arm: LoopArm,
1088    status: &str,
1089    jsonl: &str,
1090    root: &Path,
1091    gold: &[String],
1092    gold_tests: &[String],
1093    pack: &str,
1094    scc_calls: usize,
1095    substitution_rate: f64,
1096    contaminated: bool,
1097) -> LoopTaskRow {
1098    let m = crate::benchagent::metrics_from_jsonl(jsonl, root, task, gold);
1099    let gold_set: BTreeSet<String> = gold.iter().cloned().collect();
1100    let opened: Vec<String> = jsonl
1101        .lines()
1102        .filter_map(|line| {
1103            let v: serde_json::Value = serde_json::from_str(line).ok()?;
1104            let input = v.get("input").or_else(|| {
1105                v.get("item")
1106                    .and_then(|i| i.get("arguments"))
1107            })?;
1108            input.get("file_path").and_then(|p| p.as_str()).map(|s| s.to_string())
1109        })
1110        .collect();
1111    let hits = opened
1112        .iter()
1113        .filter(|f| gold_matches(f, &gold_set))
1114        .count();
1115    let localization = if gold.is_empty() {
1116        1.0
1117    } else {
1118        hits as f64 / gold.len() as f64
1119    };
1120    let first_correct_rank = opened.iter().position(|f| gold_matches(f, &gold_set)).map(|i| i + 1);
1121    let search_calls = m.search_tool_calls;
1122    let subst = if scc_calls + search_calls == 0 {
1123        substitution_rate
1124    } else {
1125        scc_calls as f64 / (scc_calls + search_calls) as f64
1126    };
1127    let (tests_localization, tests_hit, tests_gold) = score_tests(pack, gold_tests);
1128    LoopTaskRow {
1129        task: task.to_string(),
1130        repo: repo.to_string(),
1131        arm: arm.as_str().to_string(),
1132        status: status.to_string(),
1133        localization,
1134        first_correct_rank,
1135        files_opened: m.files_opened,
1136        search_calls,
1137        scc_calls,
1138        substitution_rate: subst,
1139        contaminated,
1140        protocol: "explore".into(),
1141        read_calls: m.read_tool_calls,
1142        first_correct_ms: m.first_correct_ms,
1143        wrong_first: m.wrong_first_locations,
1144        jsonl_events: m.total_tool_calls,
1145        tests_localization,
1146        tests_hit,
1147        tests_gold,
1148    }
1149}
1150
1151// trace:exempt reason=internal-detail
1152pub fn print_loop_summary(s: &LoopSummary) {
1153    let proto = if s.protocol == "explore" {
1154        "explore JSONL"
1155    } else {
1156        "locator"
1157    };
1158    println!("scc bench loop — {proto} arms (clustered localization; production ranker unchanged)");
1159    if let Some(bin) = &s.ripwire_bin {
1160        println!("  ripwire: {bin}");
1161    } else {
1162        println!("  ripwire: skipped (no binary; set RIPWIRE_BIN or --ripwire-bin)");
1163    }
1164    println!(
1165        "  {:<12} {:>8} {:>12} {:>10} {:>10} {:>8} {:>8} {:>8} {:>10} {:>8}",
1166        "arm", "status", "clustered", "pooled", "subst", "search", "read", "tasks", "tests_cl", "tests_n"
1167    );
1168    for a in &s.arms {
1169        println!(
1170            "  {:<12} {:>8} {:>12.3} {:>10.3} {:>10.3} {:>8.2} {:>8.2} {:>8} {:>10.3} {:>8}",
1171            a.arm,
1172            a.status,
1173            a.clustered_localization,
1174            a.pooled_localization,
1175            a.mean_substitution,
1176            a.mean_search,
1177            a.mean_read,
1178            a.tasks,
1179            a.clustered_tests,
1180            a.tests_tasks
1181        );
1182    }
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::*;
1188
1189    #[test]
1190    // trace:v1 id=test.scc.cli.bench-loop verifies=REQ-agent-loop-three-way exercises=impl.scc.cli.bench-loop
1191    fn agent_loop_scc_beats_or_matches_baseline_on_fixture() {
1192        let opts = LoopOptions {
1193            k: 10,
1194            repo_filter: Some("http-service-python".into()),
1195            ripwire_bin: None,
1196            explore: false,
1197            agent_cmd: None,
1198        };
1199        let summary = run_agent_loop(&[LoopArm::Baseline, LoopArm::Scc, LoopArm::Ripwire], &opts)
1200            .expect("loop");
1201        let base = summary
1202            .arms
1203            .iter()
1204            .find(|a| a.arm == "baseline")
1205            .expect("baseline");
1206        let scc = summary.arms.iter().find(|a| a.arm == "scc").expect("scc");
1207        assert!(base.tasks > 0, "baseline must run tasks");
1208        assert!(scc.tasks > 0, "scc must run tasks");
1209        assert_eq!(scc.mean_substitution, 1.0);
1210        assert_eq!(base.mean_substitution, 0.0);
1211        assert!(
1212            scc.clustered_localization + 1e-9 >= base.clustered_localization,
1213            "SCC pack locator must not lose to lexical baseline on this fixture: scc={} base={}",
1214            scc.clustered_localization,
1215            base.clustered_localization
1216        );
1217        let rw = summary.arms.iter().find(|a| a.arm == "ripwire").expect("ripwire arm");
1218        if summary.ripwire_bin.is_none() {
1219            assert_eq!(rw.status, "skipped");
1220        }
1221        assert!(
1222            summary.per_task.iter().all(|r| !r.contaminated),
1223            "harness must not inject SCC_GOLD"
1224        );
1225    }
1226
1227    #[test]
1228    // trace:exempt reason=internal-detail
1229    fn clustered_mean_is_not_pooled() {
1230        let mut m = BTreeMap::new();
1231        m.insert("a".into(), vec![1.0, 1.0, 1.0, 1.0]);
1232        m.insert("b".into(), vec![0.0]);
1233        let c = clustered_mean(&m);
1234        let pooled = 4.0 / 5.0;
1235        assert!((c - 0.5f64).abs() < 1e-9, "clustered={c}");
1236        assert!((pooled - 0.8f64).abs() < 1e-9);
1237    }
1238
1239    #[test]
1240    // trace:v1 id=test.scc.cli.bench-loop-ripwire verifies=REQ-agent-loop-three-way exercises=impl.scc.cli.bench-loop
1241    fn agent_loop_ripwire_runs_when_binary_is_configured() {
1242        let gxx = PathBuf::from("/tmp/vendor/ripwire/build-gxx/ripwire");
1243        let cmake = PathBuf::from("/tmp/vendor/ripwire/build/ripwire");
1244        let bin = if gxx.is_file() {
1245            Some(gxx)
1246        } else if cmake.is_file() {
1247            Some(cmake)
1248        } else {
1249            None
1250        };
1251        let opts = LoopOptions {
1252            k: 10,
1253            repo_filter: Some("http-service-python".into()),
1254            ripwire_bin: bin,
1255            explore: false,
1256            agent_cmd: None,
1257        };
1258        let summary = run_agent_loop(&[LoopArm::Ripwire], &opts).expect("loop");
1259        let rw = summary
1260            .arms
1261            .iter()
1262            .find(|a| a.arm == "ripwire")
1263            .expect("ripwire arm");
1264        if summary.ripwire_bin.is_some() {
1265            assert_eq!(
1266                rw.status, "ran",
1267                "a present Ripwire binary must be scored, never skipped as an SCC win"
1268            );
1269            assert!(rw.tasks > 0);
1270        } else {
1271            assert_eq!(rw.status, "skipped");
1272        }
1273    }
1274
1275    #[test]
1276    // trace:exempt reason=internal-detail
1277    fn ripwire_p_attr_paths_parse() {
1278        let xml = r#"<ctx><d p="src/server.py" n="handle"/><d p="src/db.py"/></ctx>"#;
1279        let indexed = vec!["src/server.py".into(), "src/db.py".into()];
1280        let got = files_from_ripwire(xml, &indexed, 10);
1281        assert_eq!(got, vec!["src/server.py", "src/db.py"]);
1282    }
1283
1284    #[test]
1285    // trace:v1 id=test.scc.cli.explore-handles verifies=REQ-implement-phase-13-of-scc-x-ripwire-lessons-1-go-extract-time-recei exercises=impl.scc.cli.explore-handles
1286    fn explore_prefers_fetch_handles_and_refuses_stale() {
1287        let dir = tempfile::TempDir::new().unwrap();
1288        let root = dir.path();
1289        std::fs::write(root.join("a.py"), "print(1)\n").unwrap();
1290        let hash = scc_core::fnv1a64_hex(b"print(1)\n");
1291        let fresh = scc_core::ContentHandle::for_file("r", "e", "a.py", &hash).to_string();
1292        let indexed = vec!["a.py".into()];
1293        let pack = format!("FETCH\n- a.py handle={fresh}\n# a.py mentioned");
1294        let got = files_from_pack(&pack, root, &indexed, 10);
1295        assert_eq!(got, vec!["a.py"]);
1296        let stale = scc_core::ContentHandle::for_file("r", "e", "a.py", "aaaaaaaaaaaaaaaa").to_string();
1297        let refused = files_from_pack(
1298            &format!("FETCH\n- a.py handle={stale}\n"),
1299            root,
1300            &indexed,
1301            10,
1302        );
1303        assert!(
1304            refused.is_empty(),
1305            "stale handle must not fall back to guessing a.py: {refused:?}"
1306        );
1307        let fallback = files_from_pack("IMPLEMENTATION\na.py\n", root, &indexed, 10);
1308        assert_eq!(fallback, vec!["a.py"]);
1309        std::fs::write(root.join("b.py"), "print(2)\n").unwrap();
1310        let indexed2 = vec!["a.py".into(), "b.py".into()];
1311        let mixed = files_from_pack(
1312            &format!("FETCH\n- a.py handle={stale}\nIMPLEMENTATION\nb.py\n"),
1313            root,
1314            &indexed2,
1315            10,
1316        );
1317        assert_eq!(
1318            mixed,
1319            vec!["b.py"],
1320            "stale a.py must stay refused while other paths can still fill: {mixed:?}"
1321        );
1322    }
1323
1324    #[test]
1325    // trace:v1 id=test.scc.cli.bench-explore verifies=REQ-implement-phase-7-of-scc-x-ripwire-lessons-1-one-hop-type-narrowing exercises=impl.scc.cli.bench-explore
1326    fn explore_protocol_emits_jsonl_metrics_and_scc_does_not_grep() {
1327        let opts = LoopOptions {
1328            k: 10,
1329            repo_filter: Some("http-service-python".into()),
1330            ripwire_bin: None,
1331            explore: true,
1332            agent_cmd: None,
1333        };
1334        let summary = run_agent_loop(&[LoopArm::Baseline, LoopArm::Scc, LoopArm::Ripwire], &opts)
1335            .expect("explore");
1336        assert_eq!(summary.protocol, "explore");
1337        let base = summary
1338            .arms
1339            .iter()
1340            .find(|a| a.arm == "baseline")
1341            .expect("baseline");
1342        let scc = summary.arms.iter().find(|a| a.arm == "scc").expect("scc");
1343        assert!(base.tasks > 0);
1344        assert!(scc.tasks > 0);
1345        assert!(
1346            base.mean_search >= 1.0,
1347            "baseline pack-consumer must grep: search={}",
1348            base.mean_search
1349        );
1350        assert_eq!(
1351            scc.mean_search, 0.0,
1352            "SCC pack-consumer must not grep after task_context"
1353        );
1354        assert!(scc.mean_read > 0.0, "SCC must read pack files");
1355        assert_eq!(scc.mean_substitution, 1.0);
1356        assert_eq!(base.mean_substitution, 0.0);
1357        assert!(
1358            summary
1359                .per_task
1360                .iter()
1361                .filter(|r| r.arm == "scc")
1362                .all(|r| r.protocol == "explore" && r.jsonl_events > 0 && r.first_correct_ms.is_some()),
1363            "SCC explore rows must carry JSONL first-correct: {:?}",
1364            summary.per_task
1365        );
1366        let rw = summary.arms.iter().find(|a| a.arm == "ripwire").expect("ripwire");
1367        if summary.ripwire_bin.is_none() {
1368            assert_eq!(rw.status, "skipped");
1369        }
1370        assert!(summary.per_task.iter().all(|r| !r.contaminated));
1371        let scc_tests: Vec<_> = summary
1372            .per_task
1373            .iter()
1374            .filter(|r| r.arm == "scc" && r.tests_localization.is_some())
1375            .collect();
1376        assert!(
1377            !scc_tests.is_empty(),
1378            "http-service-python has gold tests; SCC must report tests_localization"
1379        );
1380        assert!(
1381            summary
1382                .per_task
1383                .iter()
1384                .filter(|r| r.arm == "baseline" && r.tests_localization.is_some())
1385                .all(|r| r.tests_localization == Some(0.0)),
1386            "baseline has no tests_to_run list and must score 0 when gold tests exist"
1387        );
1388        assert!(
1389            summary
1390                .per_task
1391                .iter()
1392                .filter(|r| r.arm == "scc" && r.tests_gold == 0)
1393                .all(|r| r.tests_localization.is_none()),
1394            "empty gold tests must be omitted, not scored as 1.0"
1395        );
1396    }
1397
1398    #[test]
1399    // trace:v1 id=test.scc.cli.loop-tests verifies=REQ-implement-phase-18-of-scc-x-ripwire-lessons-score-tests-to-run-in-the exercises=impl.scc.cli.loop-tests
1400    fn tests_from_pack_parses_scc_rows_and_ripwire_xml() {
1401        let scc = "TESTS\n- test_normalization_preserves_raw (tests/test_transcripts.py) — direct\nFETCH\n- src/server.py handle=scc://abc\n";
1402        let got = tests_from_pack(scc);
1403        assert!(
1404            got.iter()
1405                .any(|s| s == "test_normalization_preserves_raw"),
1406            "SCC name: {got:?}"
1407        );
1408        assert!(
1409            got.iter().any(|s| s == "tests/test_transcripts.py"),
1410            "SCC file: {got:?}"
1411        );
1412        assert!(
1413            !got.iter().any(|s| s.contains("handle=")),
1414            "FETCH rows are not tests: {got:?}"
1415        );
1416
1417        let rw = r#"<ctx><test p="tests/test_transcripts.py">test_transcripts</test></ctx>"#;
1418        let rw_got = tests_from_pack(rw);
1419        assert_eq!(rw_got, vec!["tests/test_transcripts.py"]);
1420        assert!(
1421            !test_name_matches("tests/test_transcripts.py", "test_normalization_preserves_raw"),
1422            "Ripwire file-only rows must not match a gold function name"
1423        );
1424    }
1425
1426    #[test]
1427    // trace:v1 id=test.scc.cli.score-tests verifies=REQ-implement-phase-18-of-scc-x-ripwire-lessons-score-tests-to-run-in-the exercises=impl.scc.cli.loop-tests
1428    fn score_tests_omits_empty_gold_and_zeros_baseline() {
1429        assert_eq!(score_tests("whatever", &[]).0, None);
1430        let gold = vec!["test_foo".into()];
1431        assert_eq!(score_tests("", &gold), (Some(0.0), 0, 1));
1432        let pack = "- test_foo (tests/t.py) — direct\n";
1433        assert_eq!(score_tests(pack, &gold), (Some(1.0), 1, 1));
1434        let ascii = "- test_foo (tests/t.py) -- import\n";
1435        assert_eq!(score_tests(ascii, &gold), (Some(1.0), 1, 1));
1436    }
1437
1438    #[test]
1439    // trace:v1 id=test.scc.cli.loop-test-match verifies=REQ-implement-phase-20-of-scc-x-ripwire-lessons-raise-tests-to-run-recall exercises=impl.scc.cli.loop-test-match
1440    fn score_tests_matches_kebab_it_titles_not_ripwire_files() {
1441        let gold = vec!["computes-order-totals-from-line-items".into()];
1442        let pack = "- computes order totals from line items (tests/orders.test.ts) — import\n";
1443        assert_eq!(score_tests(pack, &gold), (Some(1.0), 1, 1));
1444        assert_eq!(
1445            score_tests(r#"<test p="tests/orders.test.ts"/>"#, &gold),
1446            (Some(0.0), 0, 1)
1447        );
1448        let gold_api = vec!["joins-user-names-from-the-api-response".into()];
1449        let pack_api =
1450            "- joins user names from the API response (web/view.test.ts) — import\n";
1451        assert_eq!(score_tests(pack_api, &gold_api), (Some(1.0), 1, 1));
1452    }
1453
1454    #[test]
1455    // trace:v1 id=test.scc.cli.loop-test-ts verifies=REQ-implement-phase-20-of-scc-x-ripwire-lessons-raise-tests-to-run-recall exercises=impl.scc.cli.loop-test-match
1456    fn tests_to_run_hits_typescript_it_titles() {
1457        let opts = LoopOptions {
1458            k: 10,
1459            repo_filter: Some("large-ts".into()),
1460            ripwire_bin: None,
1461            explore: false,
1462            agent_cmd: None,
1463        };
1464        let summary = run_agent_loop(&[LoopArm::Scc], &opts).expect("loop");
1465        let hits: Vec<_> = summary
1466            .per_task
1467            .iter()
1468            .filter(|r| r.arm == "scc" && r.tests_localization == Some(1.0))
1469            .map(|r| r.task.as_str())
1470            .collect();
1471        assert!(
1472            hits.contains(&"large-ts.order-total-invariant"),
1473            "SCC TESTS must list the it() title that gold kebab-case names: {hits:?} rows={:?}",
1474            summary.per_task
1475        );
1476        let qw = LoopOptions {
1477            k: 10,
1478            repo_filter: Some("queue-worker-ts".into()),
1479            ripwire_bin: None,
1480            explore: false,
1481            agent_cmd: None,
1482        };
1483        let qw_summary = run_agent_loop(&[LoopArm::Scc], &qw).expect("queue-worker");
1484        let qw_hits: Vec<_> = qw_summary
1485            .per_task
1486            .iter()
1487            .filter(|r| r.arm == "scc" && r.tests_localization == Some(1.0))
1488            .map(|r| r.task.as_str())
1489            .collect();
1490        assert!(
1491            qw_hits.contains(&"queue-worker.street-vocabulary"),
1492            "fixed relative imports must let import-reason tests_to_run fire: {qw_hits:?} rows={:?}",
1493            qw_summary.per_task
1494        );
1495        let api = LoopOptions {
1496            k: 10,
1497            repo_filter: Some("ts-api-web".into()),
1498            ripwire_bin: None,
1499            explore: false,
1500            agent_cmd: None,
1501        };
1502        let api_summary = run_agent_loop(&[LoopArm::Scc], &api).expect("ts-api-web");
1503        let api_hits: Vec<_> = api_summary
1504            .per_task
1505            .iter()
1506            .filter(|r| r.arm == "scc" && r.tests_localization == Some(1.0))
1507            .map(|r| r.task.as_str())
1508            .collect();
1509        assert!(
1510            api_hits.contains(&"ts-api-web.contract-field")
1511                && api_hits.contains(&"ts-api-web.creation-test"),
1512            "kebab gold must match it() titles that contain acronyms: {api_hits:?} rows={:?}",
1513            api_summary.per_task
1514        );
1515    }
1516
1517    #[test]
1518    // trace:v1 id=test.scc.cli.bench-loop-paid-gate verifies=REQ-agent-loop-three-way exercises=impl.scc.cli.bench-loop
1519    fn agent_loop_with_agent_cmd_refuses_without_opt_in() {
1520        // Shares the benchagent env lock: this test removes the opt-in while
1521        // mock tests set it (parallel threads, one process environment).
1522        let _guard = crate::benchagent::PAID_TEST_ENV_LOCK.lock();
1523        let prev = std::env::var("SCC_ALLOW_PAID_BENCHMARKS").ok();
1524        std::env::remove_var("SCC_ALLOW_PAID_BENCHMARKS");
1525        let opts = LoopOptions {
1526            k: 1,
1527            repo_filter: None,
1528            ripwire_bin: None,
1529            explore: true,
1530            agent_cmd: Some("codex exec --json --skip-git-repo-check -C . -".into()),
1531        };
1532        let err = run_agent_loop(&[LoopArm::Scc], &opts).unwrap_err();
1533        assert!(
1534            err.contains("SCC_ALLOW_PAID_BENCHMARKS"),
1535            "refusal must name the opt-in: {err}"
1536        );
1537        match prev {
1538            Some(v) => std::env::set_var("SCC_ALLOW_PAID_BENCHMARKS", v),
1539            None => std::env::remove_var("SCC_ALLOW_PAID_BENCHMARKS"),
1540        }
1541    }
1542
1543}