1use 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)]
15pub enum LoopArm {
17 Baseline,
18 Scc,
19 Ripwire,
20}
21
22impl LoopArm {
24 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 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)]
45pub 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 #[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)]
79pub 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)]
103pub 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)]
113pub 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
122pub 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)]
223fn 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)]
298fn 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
348fn 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
373fn 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
378fn 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
420fn 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
435fn 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
450fn 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
457fn 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
471fn 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
525fn 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
546fn 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
559fn 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
569fn 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
593fn 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
615fn 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
640fn 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
679fn 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
690fn 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
720fn 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
760fn 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 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
793fn 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
812fn 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
824fn 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)]
856fn 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)]
950fn 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
1005fn 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
1029fn 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
1056fn 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)]
1083fn 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
1151pub 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 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 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 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 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 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 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 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 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 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 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 fn agent_loop_with_agent_cmd_refuses_without_opt_in() {
1520 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}