bigpowers 2.91.1 → 2.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
  2. package/.pi/package.json +1 -1
  3. package/AGENTS.md +98 -0
  4. package/CHANGELOG.md +14 -0
  5. package/CONVENTIONS.md +47 -427
  6. package/package.json +1 -1
  7. package/scripts/ab-sandbox.sh +3 -3
  8. package/scripts/adapters/agy.sh +1 -1
  9. package/scripts/adapters/cursor.sh +1 -1
  10. package/scripts/adapters/gemini.sh +1 -1
  11. package/scripts/adapters/mimo.sh +1 -1
  12. package/scripts/adapters/pi.sh +1 -1
  13. package/scripts/adapters/zcode.sh +1 -1
  14. package/scripts/generate-context-bundle.sh +2 -1
  15. package/scripts/lib/__pycache__/atomic_write.cpython-310.pyc +0 -0
  16. package/scripts/lib/__pycache__/link_utils.cpython-310.pyc +0 -0
  17. package/scripts/lib/__pycache__/srp_engine_links.cpython-310.pyc +0 -0
  18. package/scripts/lib/__pycache__/srp_engine_okf.cpython-310.pyc +0 -0
  19. package/scripts/lib/ab-eval.py +19 -5
  20. package/scripts/lib/ab_lean.py +116 -0
  21. package/scripts/lib/ab_metrics.py +8 -0
  22. package/scripts/lib/ab_progress.py +3 -1
  23. package/scripts/lib/ab_report.py +15 -4
  24. package/scripts/lib/ab_vsm.py +122 -0
  25. package/scripts/lib/ab_waste.py +173 -0
  26. package/scripts/test-adapter-render.sh +1 -1
  27. package/scripts/test-test-lock-hook.sh +7 -7
  28. package/scripts/verify-adversarial.sh +8 -1
  29. package/tests/test_ab_eval.py +0 -44
  30. package/tests/test_ab_lean.py +81 -0
  31. package/tests/test_ab_score.py +12 -2
  32. package/tests/test_ab_tasks.py +75 -0
  33. package/tests/test_ab_vsm.py +109 -0
  34. package/tests/test_ab_waste.py +106 -0
  35. package/CLAUDE.md +0 -400
  36. package/GEMINI.md +0 -75
@@ -51,7 +51,8 @@ while IFS= read -r row; do
51
51
 
52
52
  adapter_script="$SCRIPT_DIR/adapters/${adapter}.sh"
53
53
  # shellcheck source=/dev/null
54
- source "$adapter_script"
54
+ # stdin is this loop's target list: an adapter that reads stdin when sourced would swallow it.
55
+ source "$adapter_script" </dev/null
55
56
  case "$mode" in
56
57
  symlink) wire_context_mode symlink "$file" ;;
57
58
  copy) wire_context_mode copy "$file" ;;
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- # story: e58s01 e58s02 e58s03 e58s05 e58s06 e58s07
2
+ # story: e58s01 e58s02 e58s03 e58s05 e58s06 e58s07 e58s08 e58s09
3
3
  """A/B eval: run the same tasks with and without bigpowers and grade only the outcome.
4
4
 
5
5
  Each run gets a fresh temp git workdir (outside the repo) copied from the task fixture.
@@ -25,7 +25,9 @@ from pathlib import Path
25
25
  import yaml
26
26
 
27
27
  sys.path.insert(0, str(Path(__file__).resolve().parent))
28
- from ab_metrics import parse_metrics, tap_score # type: ignore[import] # noqa: E402
28
+ from ab_metrics import parse_metrics, tap_score, tap_failures, tap_failed_names # type: ignore[import] # noqa: E402
29
+ from ab_vsm import vsm_from # type: ignore[import] # noqa: E402
30
+ from ab_waste import waste_for # type: ignore[import] # noqa: E402
29
31
  from ab_report import ( # type: ignore[import] # noqa: E402
30
32
  BOOTSTRAP_RESAMPLES, BOOTSTRAP_SEED, aggregate, bigpowers_sha, print_summary, write_reports,
31
33
  )
@@ -180,6 +182,8 @@ def run_once(task: dict, arm: str, setup: str, adapter: dict, model: str | None,
180
182
 
181
183
  tampered, protected_added = protected_changes(workdir, before)
182
184
  diff = changed_lines(workdir, base)
185
+ waste = waste_for(workdir, base, stdout, adapter.get("parser", "none"), task["dir"],
186
+ trace_dir / f"{task['id']}-{arm}-{run_idx}.patch.gz" if trace_dir else None)
183
187
  shutil.copytree(task["dir"] / "hidden", workdir, dirs_exist_ok=True)
184
188
  cmd, genv = grade_cmd(adapter, task["grade"], env)
185
189
  passed = sh(cmd, workdir, genv, timeout=300).returncode == 0
@@ -187,12 +191,22 @@ def run_once(task: dict, arm: str, setup: str, adapter: dict, model: str | None,
187
191
  if task.get("score"):
188
192
  cmd, genv = grade_cmd(adapter, task["score"], env)
189
193
  score = tap_score(sh(cmd, workdir, genv, timeout=300).stdout)
194
+ bugs_opened = 0
195
+ bug_details = []
196
+ if task.get("audit"):
197
+ if (task["dir"] / "audit").is_dir():
198
+ shutil.copytree(task["dir"] / "audit", workdir, dirs_exist_ok=True)
199
+ cmd, genv = grade_cmd(adapter, task["audit"], env)
200
+ out = sh(cmd, workdir, genv, timeout=300).stdout
201
+ bugs_opened = tap_failures(out)
202
+ bug_details = tap_failed_names(out)
190
203
  metrics = parse_metrics(adapter.get("parser", "none"), stdout)
191
204
  return {
192
- "task": task["id"], "arm": arm, "run": run_idx, "pass": passed, "score": score, "tampered": tampered,
205
+ "task": task["id"], "arm": arm, "run": run_idx, "pass": passed, "score": score,
206
+ "bugs_opened": bugs_opened, "bug_details": bug_details, "tampered": tampered,
193
207
  "protected_lines_added": protected_added, "diff_lines": diff, "duration_s": duration, "agent_exit": exit_code,
194
208
  "agent_error": timed_out or exit_code != 0 or bool(metrics.pop("agent_reported_error")),
195
- "timed_out": timed_out, **metrics,
209
+ "timed_out": timed_out, "vsm": vsm_from(adapter.get("parser", "none"), stdout), "waste": waste, **metrics,
196
210
  }
197
211
  finally:
198
212
  shutil.rmtree(home, ignore_errors=True)
@@ -262,7 +276,7 @@ def main(argv: list[str] | None = None) -> int:
262
276
  with live.open("a") as f:
263
277
  f.write(json.dumps({**rec, "bigpowers_sha": sha, "model": model}) + "\n")
264
278
  print(f" {task['id']:<8} {arm:<10} run {i}: {'PASS' if rec['pass'] else 'FAIL'}"
265
- f"{' TAMPERED' if rec['tampered'] else ''}{' AGENT-ERROR' if rec['agent_error'] else ''}")
279
+ f"{' TAMPERED' if rec['tampered'] else ''}{' AGENT-ERROR' if rec['agent_error'] else ''}", flush=True)
266
280
  except ConfigError as exc:
267
281
  print(f"ERROR: {exc}")
268
282
  return 2
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s10
3
+ """Lean views of an A/B run: each arm's mean value stream (stage time and share) and waste metrics,
4
+ deltas against vanilla with seeded bootstrap CIs, and text VSM bars plus a waste table.
5
+
6
+ Reads the `vsm` (e58s08) and `waste` (e58s09) blocks of run records; runs without them are skipped.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from ab_report import bootstrap_delta_ci, mean # type: ignore[import]
11
+
12
+ STAGES = ["plan", "design", "build", "test", "deploy", "maintain"]
13
+ LETTER = {"plan": "P", "design": "D", "build": "B", "test": "T", "deploy": "R", "maintain": "M"}
14
+ WASTE_KEYS = ["test_runs", "redundant_test_runs", "rework_loops", "reedits", "rereads",
15
+ "repeated_commands", "review_load", "diff_ratio", "pce"]
16
+ # (key, label, per-run extractor) for the deltas against vanilla.
17
+ DELTAS = [
18
+ ("lead_time_s", "lead time (s)", lambda r: r["vsm"]["lead_time_s"]),
19
+ ("time_to_first_build_s", "first build (s)", lambda r: r["vsm"]["time_to_first_build_s"]),
20
+ ("switches", "stage switches", lambda r: r["vsm"]["switches"]),
21
+ ("build_share", "build share", lambda r: share(r, "build")),
22
+ ("test_share", "test share", lambda r: share(r, "test")),
23
+ ("test_runs", "test runs", lambda r: r["waste"].get("test_runs")),
24
+ ("redundant_test_runs", "redundant test runs", lambda r: r["waste"].get("redundant_test_runs")),
25
+ ("rework_loops", "rework loops", lambda r: r["waste"].get("rework_loops")),
26
+ ("reedits", "re-edits", lambda r: r["waste"].get("reedits")),
27
+ ("review_load", "review load", lambda r: r["waste"].get("review_load")),
28
+ ("diff_ratio", "diff ratio", lambda r: r["waste"].get("diff_ratio")),
29
+ ("ftt", "first-time-through", lambda r: None if r["waste"].get("ftt") is None else float(r["waste"]["ftt"])),
30
+ ("pce", "PCE", lambda r: r["waste"].get("pce")),
31
+ ]
32
+
33
+
34
+ def share(run: dict, stage: str) -> float | None:
35
+ lead = run["vsm"]["lead_time_s"]
36
+ return run["vsm"]["stages"].get(stage, {}).get("s", 0) / lead if lead else None
37
+
38
+
39
+ def lean_runs(records: list[dict], arm: str) -> list[dict]:
40
+ return [r for r in records if r["arm"] == arm and r.get("vsm") and r.get("waste")]
41
+
42
+
43
+ def arm_lean(runs: list[dict], all_runs: list[dict]) -> dict:
44
+ passes = sum(r["pass"] for r in all_runs)
45
+ cost = sum(r.get("cost_usd") or 0 for r in all_runs)
46
+ return {
47
+ "runs": len(runs),
48
+ "lead_time_s": mean([r["vsm"]["lead_time_s"] for r in runs]),
49
+ "time_to_first_build_s": mean([r["vsm"]["time_to_first_build_s"] for r in runs]),
50
+ "switches": mean([r["vsm"]["switches"] for r in runs]),
51
+ "stage_s": {s: mean([r["vsm"]["stages"].get(s, {}).get("s", 0) for r in runs]) for s in STAGES},
52
+ "stage_share": {s: mean([share(r, s) for r in runs]) for s in STAGES},
53
+ "waste": {k: mean([r["waste"].get(k) for r in runs]) for k in WASTE_KEYS},
54
+ "ftt_rate": mean([None if r["waste"].get("ftt") is None else float(r["waste"]["ftt"]) for r in runs]),
55
+ "leftover_rate": mean([float(bool(r["waste"].get("leftovers"))) for r in runs]),
56
+ "cost_per_pass": round(cost / passes, 6) if passes else None,
57
+ }
58
+
59
+
60
+ def lean_summary(records: list[dict], arms: list[str]) -> dict | None:
61
+ by_arm = {a: lean_runs(records, a) for a in arms}
62
+ if not any(by_arm.values()):
63
+ return None
64
+ summary = {a: arm_lean(runs, [r for r in records if r["arm"] == a]) for a, runs in by_arm.items() if runs}
65
+ deltas = {}
66
+ if "vanilla" in summary:
67
+ for arm in summary:
68
+ if arm == "vanilla":
69
+ continue
70
+ deltas[arm] = {}
71
+ for key, _, get in DELTAS:
72
+ tr = [v for v in map(get, by_arm[arm]) if v is not None]
73
+ va = [v for v in map(get, by_arm["vanilla"]) if v is not None]
74
+ if tr and va:
75
+ deltas[arm][key] = {"delta": round(sum(tr) / len(tr) - sum(va) / len(va), 4),
76
+ "ci95": bootstrap_delta_ci(tr, va)}
77
+ return {"arms": summary, "deltas": deltas}
78
+
79
+
80
+ def bar(arm: dict, scale: float) -> str:
81
+ """Stage letters, one per `scale` seconds of mean stage time: the bar's length is the lead time."""
82
+ return "".join(LETTER[s] * round((arm["stage_s"][s] or 0) / scale) for s in STAGES)
83
+
84
+
85
+ def fmt(value, places=2) -> str:
86
+ return "-" if value is None else f"{value:.{places}f}"
87
+
88
+
89
+ def lean_lines(summary: dict | None, width: int = 50) -> list[str]:
90
+ if not summary:
91
+ return []
92
+ arms = summary["arms"]
93
+ scale = max((a["lead_time_s"] or 0) for a in arms.values()) / width or 1
94
+ out = ["", "value stream (bar length = mean lead time; P plan, D design, B build, T test, R release, M maintain):"]
95
+ for name, a in arms.items():
96
+ out.append(f" {name:<10} |{bar(a, scale):<{width}}| {fmt(a['lead_time_s'], 0):>5} s "
97
+ f"first build {fmt(a['time_to_first_build_s'], 0)} s PCE {fmt(a['waste']['pce'])} "
98
+ f"FTT {fmt(a['ftt_rate'])} $/pass {fmt(a['cost_per_pass'], 4)}")
99
+ out += ["", f" {'waste (mean per run)':<22}" + "".join(f"{n:>11}" for n in arms) + " delta vs vanilla [95% CI]"]
100
+ for key, label, _ in DELTAS:
101
+ if key in ("lead_time_s", "time_to_first_build_s"):
102
+ continue
103
+ values = "".join(f"{fmt(row_value(a, key)):>11}" for a in arms.values())
104
+ ds = " ".join(f"{n} {d[key]['delta']:+.2f} {d[key]['ci95']}" for n, d in summary["deltas"].items() if key in d)
105
+ out.append(f" {label:<22}{values} {ds}")
106
+ return out
107
+
108
+
109
+ def row_value(arm: dict, key: str):
110
+ if key == "switches":
111
+ return arm["switches"]
112
+ if key in ("build_share", "test_share"):
113
+ return arm["stage_share"][key.split("_")[0]]
114
+ if key == "ftt":
115
+ return arm["ftt_rate"]
116
+ return arm["waste"].get(key)
@@ -121,3 +121,11 @@ def tap_score(stdout: str) -> float | None:
121
121
  passed = sum(int(n) for n in re.findall(r"^# pass (\d+)", stdout, re.M))
122
122
  failed = sum(int(n) for n in re.findall(r"^# (?:fail|cancelled) (\d+)", stdout, re.M))
123
123
  return round(passed / (passed + failed), 4) if passed + failed else None
124
+
125
+
126
+ def tap_failures(stdout: str) -> int:
127
+ return sum(int(n) for n in re.findall(r"^# (?:fail|cancelled) (\d+)", stdout, re.M))
128
+
129
+
130
+ def tap_failed_names(stdout: str) -> list[str]:
131
+ return [m.strip() for m in re.findall(r"^not ok \d+ - (.+)$", stdout, re.M)]
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- # story: e58s05
2
+ # story: e58s05 e58s10
3
3
  """Live view of an A/B eval while it runs: reads every ab-live.jsonl under the given folders
4
4
  (one per shard) and prints runs, pass rate, score and cost per task and arm, each arm's delta
5
5
  against vanilla with its bootstrap CI, and how those deltas evolved as runs accumulated.
@@ -16,6 +16,7 @@ from datetime import datetime
16
16
  from pathlib import Path
17
17
 
18
18
  sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ from ab_lean import lean_lines # type: ignore[import] # noqa: E402
19
20
  from ab_report import aggregate, mean # type: ignore[import] # noqa: E402
20
21
 
21
22
  CHECKPOINTS = (5, 10, 20, 30, 50, 75, 100)
@@ -79,6 +80,7 @@ def render(records: list[dict]) -> str:
79
80
  out += ["", "deltas (all runs so far, 95% CI):"]
80
81
  out += [delta_line(n, d) for n, d in result["deltas_vs_vanilla"].items()]
81
82
  out += ["", "evolution (pass and score delta by run index):"] + evolution(records, tasks, arms)
83
+ out += lean_lines(result.get("lean"))
82
84
  return "\n".join(out)
83
85
 
84
86
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- # story: e58s01 e58s03 e58s04
2
+ # story: e58s01 e58s03 e58s04 e58s10
3
3
  """A/B eval aggregation and reports — pure functions over run records (no agent, no subprocess
4
4
  except reading the bigpowers git SHA). Split from ab-eval.py to keep both under the 300-line cap."""
5
5
  from __future__ import annotations
@@ -47,6 +47,7 @@ def arm_summary(runs: list[dict]) -> dict:
47
47
  "runs": len(runs),
48
48
  "pass_rate": mean([float(r["pass"]) for r in runs]),
49
49
  "mean_score": mean([r.get("score") for r in runs]),
50
+ "mean_bugs_opened": mean([r.get("bugs_opened", 0) for r in runs]),
50
51
  "tamper_rate": mean([float(r["tampered"]) for r in runs]),
51
52
  "agent_error_rate": mean([float(r["agent_error"]) for r in runs]),
52
53
  "mean_cost_usd": mean([r["cost_usd"] for r in runs]),
@@ -66,8 +67,10 @@ def aggregate(records: list[dict], arms: list[str], tasks: list[str]) -> dict:
66
67
  per_task_score = {t: {a: mean([r.get("score") for r in by_arm[a] if r["task"] == t]) for a in arms}
67
68
  for t in tasks}
68
69
  deltas = {a: arm_delta(by_arm, summary, a) for a in arms if a != "vanilla"} if "vanilla" in arms else {}
70
+ from ab_lean import lean_summary # type: ignore[import] # lazy: ab_lean imports this module
69
71
  return {"arms_summary": summary, "per_task": per_task, "per_task_score": per_task_score,
70
- "delta": deltas.get("bigpowers"), "deltas_vs_vanilla": deltas}
72
+ "delta": deltas.get("bigpowers"), "deltas_vs_vanilla": deltas,
73
+ "lean": lean_summary(records, arms)}
71
74
 
72
75
 
73
76
  def arm_delta(by_arm: dict, summary: dict, arm: str) -> dict:
@@ -86,6 +89,8 @@ def arm_delta(by_arm: dict, summary: dict, arm: str) -> dict:
86
89
  if scores[arm] and scores["vanilla"]:
87
90
  delta["score"] = round(tr["mean_score"] - va["mean_score"], 4)
88
91
  delta["score_ci95"] = bootstrap_delta_ci(scores[arm], scores["vanilla"])
92
+ if tr.get("mean_bugs_opened") is not None and va.get("mean_bugs_opened") is not None:
93
+ delta["bugs_opened"] = round(tr["mean_bugs_opened"] - va["mean_bugs_opened"], 4)
89
94
  return delta
90
95
 
91
96
 
@@ -111,13 +116,19 @@ def write_reports(out: Path, meta: dict, result: dict, records: list[dict]) -> P
111
116
 
112
117
 
113
118
  def print_summary(result: dict) -> None:
114
- print(f"{'arm':<10} {'pass':>6} {'score':>6} {'tamper':>7} {'cost$':>8} {'turns':>6} {'secs':>7}")
119
+ print(f"{'arm':<10} {'pass':>6} {'score':>6} {'bugs':>6} {'tamper':>7} {'cost$':>8} {'turns':>6} {'secs':>7}")
115
120
  for arm, s in result["arms_summary"].items():
116
121
  fmt = lambda v, p=2: "-" if v is None else f"{v:.{p}f}"
117
- print(f"{arm:<10} {fmt(s['pass_rate']):>6} {fmt(s['mean_score']):>6} {fmt(s['tamper_rate']):>7} "
122
+ b_str = "-" if s.get("mean_bugs_opened") is None else f"{s['mean_bugs_opened']:.1f}"
123
+ print(f"{arm:<10} {fmt(s['pass_rate']):>6} {fmt(s['mean_score']):>6} {b_str:>6} {fmt(s['tamper_rate']):>7} "
118
124
  f"{fmt(s['mean_cost_usd'], 3):>8} {fmt(s['mean_turns'], 1):>6} {fmt(s['mean_duration_s'], 0):>7}")
119
125
  for arm, d in result.get("deltas_vs_vanilla", {}).items():
120
126
  line = f"delta {arm:<10} - vanilla: pass {d['pass_rate']:+.2f} CI {d['ci95']}"
121
127
  if "score" in d:
122
128
  line += f" score {d['score']:+.2f} CI {d['score_ci95']}"
129
+ if "bugs_opened" in d:
130
+ line += f" bugs {d['bugs_opened']:+.1f}"
131
+ print(line)
132
+ from ab_lean import lean_lines # type: ignore[import]
133
+ for line in lean_lines(result.get("lean")):
123
134
  print(line)
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s08
3
+ """Value stream map of one A/B run: credit each model turn's time, tokens and cost to the lean stage
4
+ of the action it takes (plan, design, build, test, deploy, maintain; wrap-up when it takes none).
5
+
6
+ Tools are only 1-3% of a run's time, so time is credited per turn, not per tool call. pi stamps an
7
+ assistant message when it starts, so a turn lasts until the next assistant message starts: the model
8
+ time plus the tools it ran.
9
+ Rules: specs/benchmarks/ab/vsm.yaml.
10
+
11
+ CLI: python3 scripts/lib/ab_vsm.py <trace.jsonl.gz>... (one VSM line per saved transcript)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import gzip
16
+ import json
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+
23
+ REPO_ROOT = Path(__file__).resolve().parents[2]
24
+ RULES_PATH = REPO_ROOT / "specs" / "benchmarks" / "ab" / "vsm.yaml"
25
+ WRAP_UP = "wrap-up"
26
+
27
+
28
+ def load_rules(path: Path = RULES_PATH) -> dict:
29
+ rules = yaml.safe_load(path.read_text())
30
+ rules["compiled"] = [(r["stage"], re.compile(r["tools"]), re.compile(r["target"])) for r in rules["rules"]]
31
+ return rules
32
+
33
+
34
+ def classify_call(tool: str, args: dict | None, rules: dict) -> str:
35
+ args = args or {}
36
+ target = str(args.get("command") or args.get("path") or args.get("file_path") or "")
37
+ for stage, tools, pattern in rules["compiled"]:
38
+ if tools.search(tool or "") and pattern.search(target):
39
+ return stage
40
+ return "design"
41
+
42
+
43
+ def turn_stage(message: dict, rules: dict) -> str:
44
+ calls = [c for c in message.get("content") or [] if isinstance(c, dict) and c.get("type") == "toolCall"]
45
+ stages = {classify_call(c.get("name", ""), c.get("arguments"), rules) for c in calls}
46
+ return next((s for s in rules["turn_priority"] if s in stages), WRAP_UP)
47
+
48
+
49
+ def pi_messages(stdout: str) -> list[dict]:
50
+ messages = []
51
+ for line in stdout.splitlines():
52
+ try:
53
+ event = json.loads(line)
54
+ except json.JSONDecodeError:
55
+ continue
56
+ if isinstance(event, dict) and event.get("type") == "message_end":
57
+ message = event.get("message") or {}
58
+ if isinstance(message.get("timestamp"), (int, float)):
59
+ messages.append(message)
60
+ return messages
61
+
62
+
63
+ def vsm_from_pi(stdout: str, rules: dict) -> dict | None:
64
+ messages = pi_messages(stdout)
65
+ if not any(m.get("role") == "assistant" for m in messages):
66
+ return None
67
+ start = messages[0]["timestamp"]
68
+ stages: dict[str, dict] = {}
69
+ sequence, first_build = [], None
70
+ turns = [m for m in messages if m.get("role") == "assistant"]
71
+ for i, message in enumerate(turns):
72
+ stage = turn_stage(message, rules)
73
+ end = turns[i + 1]["timestamp"] if i + 1 < len(turns) else messages[-1]["timestamp"]
74
+ usage = message.get("usage") or {}
75
+ slot = stages.setdefault(stage, {"s": 0.0, "cost_usd": 0.0, "output_tokens": 0, "turns": 0})
76
+ slot["s"] = round(slot["s"] + (end - message["timestamp"]) / 1000, 3)
77
+ slot["cost_usd"] = round(slot["cost_usd"] + ((usage.get("cost") or {}).get("total") or 0), 6)
78
+ slot["output_tokens"] += usage.get("output", 0)
79
+ slot["turns"] += 1
80
+ sequence.append(stage)
81
+ if stage == "build" and first_build is None:
82
+ first_build = round((message["timestamp"] - start) / 1000, 3)
83
+ return {
84
+ "lead_time_s": round((messages[-1]["timestamp"] - start) / 1000, 3),
85
+ "time_to_first_build_s": first_build,
86
+ "switches": sum(1 for a, b in zip(sequence, sequence[1:]) if a != b),
87
+ "sequence": sequence,
88
+ "stages": stages,
89
+ }
90
+
91
+
92
+ def vsm_from(parser: str, stdout: str, rules: dict | None = None) -> dict | None:
93
+ """vsm block for a run record; None when the adapter's transcript format is not supported."""
94
+ if parser != "pi-json":
95
+ return None
96
+ return vsm_from_pi(stdout, rules or load_rules())
97
+
98
+
99
+ def describe(name: str, block: dict | None) -> str:
100
+ if not block:
101
+ return f"{name:<32} no VSM (unsupported or empty transcript)"
102
+ lead = block["lead_time_s"] or 1
103
+ parts = sorted(block["stages"].items(), key=lambda kv: -kv[1]["s"])
104
+ shares = " ".join(f"{stage} {v['s']:.0f}s {100 * v['s'] / lead:.0f}%" for stage, v in parts if v["turns"])
105
+ return f"{name:<32} lead {block['lead_time_s']:6.1f}s first build {block['time_to_first_build_s']}s " \
106
+ f"switches {block['switches']:>2} | {shares}"
107
+
108
+
109
+ def main(argv: list[str]) -> int:
110
+ if not argv:
111
+ print("usage: python3 scripts/lib/ab_vsm.py <trace.jsonl.gz>...")
112
+ return 2
113
+ rules = load_rules()
114
+ for name in argv:
115
+ path = Path(name)
116
+ text = gzip.decompress(path.read_bytes()).decode() if path.suffix == ".gz" else path.read_text()
117
+ print(describe(path.name, vsm_from_pi(text, rules)))
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s09 e58s10
3
+ """Lean waste metrics for one A/B run, from its final patch and its pi transcript.
4
+
5
+ Patch (any adapter): changed lines per file class, the reference solution's size, extra files,
6
+ leftover scratch files and the review load a person would face. Transcript (pi): relearning
7
+ (rereads, repeated commands), defects and rework (failing test runs after the first build edit,
8
+ re-edits), first-time-through and process cycle efficiency. See LEAN-VSM_LATEST.md and
9
+ specs/benchmarks/ab/vsm.yaml (file classes reuse the stage rules; `leftovers` regex).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import gzip
15
+ import json
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ from collections import Counter
21
+ from pathlib import Path
22
+
23
+ from ab_vsm import classify_call, load_rules, vsm_from_pi # type: ignore[import]
24
+
25
+ CLASS_OF_STAGE = {"build": "product", "test": "test", "maintain": "doc", "plan": "plan"}
26
+ FAILED = re.compile(r'"exit_code":\s*[1-9]|(?:^|\\n|\n)\s*(?:ℹ|#)\s*fail\s+[1-9]|(?:^|\\n|\n)not ok ')
27
+
28
+
29
+ def git(cwd: Path, *args: str) -> str:
30
+ res = subprocess.run(["git", "-c", "user.name=ab", "-c", "user.email=ab@local", *args],
31
+ cwd=cwd, capture_output=True, text=True)
32
+ return res.stdout
33
+
34
+
35
+ def file_class(path: str, rules: dict) -> str:
36
+ cls = CLASS_OF_STAGE.get(classify_call("edit", {"path": path}, rules), "product")
37
+ return "doc" if cls == "product" and path.endswith(".md") else cls
38
+
39
+
40
+ def numstat(cwd: Path, base: str) -> dict[str, int]:
41
+ changed = {}
42
+ for line in git(cwd, "diff", "--cached", "--numstat", base).splitlines():
43
+ added, deleted, path = line.split("\t", 2)
44
+ changed[path] = sum(int(n) for n in (added, deleted) if n.isdigit())
45
+ return changed
46
+
47
+
48
+ @functools.lru_cache(maxsize=None)
49
+ def solution_files(task_dir: str) -> dict[str, int] | None:
50
+ """Changed lines per file of the reference solution, applied over the fixture."""
51
+ task = Path(task_dir)
52
+ if not (task / "solution").is_dir():
53
+ return None
54
+ work = Path(tempfile.mkdtemp(prefix="ab-solution-"))
55
+ try:
56
+ shutil.copytree(task / "fixture", work, dirs_exist_ok=True)
57
+ git(work, "init", "-q")
58
+ git(work, "add", "-A")
59
+ git(work, "commit", "-q", "--no-verify", "-m", "fixture")
60
+ shutil.copytree(task / "solution", work, dirs_exist_ok=True)
61
+ git(work, "add", "-A")
62
+ return numstat(work, "HEAD")
63
+ finally:
64
+ shutil.rmtree(work, ignore_errors=True)
65
+
66
+
67
+ def patch_waste(workdir: Path, base: str, task_dir: Path, patch_path: Path | None, rules: dict) -> dict:
68
+ git(workdir, "add", "-A")
69
+ patch = git(workdir, "diff", "--cached", base)
70
+ if patch_path is not None:
71
+ patch_path.parent.mkdir(parents=True, exist_ok=True)
72
+ patch_path.write_bytes(gzip.compress(patch.encode()))
73
+ changed = numstat(workdir, base)
74
+ lines = Counter()
75
+ for path, n in changed.items():
76
+ lines[file_class(path, rules)] += n
77
+ added = [line[2:] for line in git(workdir, "diff", "--cached", "--name-status", base).splitlines()
78
+ if line.startswith("A\t")]
79
+ solution = solution_files(str(task_dir))
80
+ solution_lines = sum(solution.values()) if solution else None
81
+ product = [p for p in changed if file_class(p, rules) == "product"]
82
+ doc_words = sum(len(line[1:].split()) for line in md_added_lines(patch))
83
+ return {
84
+ "product_lines": lines["product"], "test_lines": lines["test"],
85
+ "doc_lines": lines["doc"], "plan_lines": lines["plan"],
86
+ "solution_lines": solution_lines,
87
+ "diff_ratio": round(lines["product"] / solution_lines, 3) if solution_lines else None,
88
+ "extra_files": sorted(p for p in product if solution is not None and p not in solution),
89
+ "leftovers": sorted(p for p in added if re.search(rules["leftovers"], p)),
90
+ "review_load": sum(changed.values()) + len(changed) + doc_words,
91
+ }
92
+
93
+
94
+ def md_added_lines(patch: str) -> list[str]:
95
+ out, in_md = [], False
96
+ for line in patch.splitlines():
97
+ if line.startswith("+++ "):
98
+ in_md = line.endswith(".md")
99
+ elif in_md and line.startswith("+"):
100
+ out.append(line)
101
+ return out
102
+
103
+
104
+ def tool_calls(stdout: str) -> list[dict]:
105
+ """Ordered tool calls with their stage and whether a test run failed."""
106
+ calls, by_id = [], {}
107
+ for line in stdout.splitlines():
108
+ try:
109
+ event = json.loads(line)
110
+ except json.JSONDecodeError:
111
+ continue
112
+ if not isinstance(event, dict):
113
+ continue
114
+ if event.get("type") == "tool_execution_start":
115
+ call = {"tool": event.get("toolName", ""), "args": event.get("args") or {}, "failed": False}
116
+ by_id[event.get("toolCallId")] = call
117
+ calls.append(call)
118
+ elif event.get("type") == "tool_execution_end" and event.get("toolCallId") in by_id:
119
+ by_id[event["toolCallId"]]["failed"] = bool(FAILED.search(json.dumps(event.get("result"), ensure_ascii=False)))
120
+ return calls
121
+
122
+
123
+ def transcript_waste(stdout: str, rules: dict | None = None) -> dict | None:
124
+ rules = rules or load_rules()
125
+ vsm = vsm_from_pi(stdout, rules)
126
+ calls = tool_calls(stdout)
127
+ if vsm is None and not calls:
128
+ return None
129
+ for call in calls:
130
+ call["stage"] = classify_call(call["tool"], call["args"], rules)
131
+ reads = Counter(c["args"].get("path") for c in calls if c["tool"] == "read")
132
+ commands = Counter(c["args"].get("command") for c in calls if c["tool"] == "bash" and c["stage"] != "test")
133
+ edits = Counter(c["args"].get("path") for c in calls if c["tool"] in ("edit", "write"))
134
+ first_build = next((i for i, c in enumerate(calls) if c["stage"] == "build"), None)
135
+ runs = [c for c in calls if c["tool"] == "bash" and c["stage"] == "test"]
136
+ after = [c for c in calls[first_build:] if c in runs] if first_build is not None else []
137
+ rework = sum(c["failed"] for c in after)
138
+ redundant = redundant_runs(calls)
139
+ stages = (vsm or {}).get("stages", {})
140
+ lead = (vsm or {}).get("lead_time_s") or 0
141
+ value_add = sum(stages.get(s, {}).get("s", 0) for s in ("build", "test"))
142
+ return {
143
+ "rereads": sum(n - 1 for n in reads.values()),
144
+ "repeated_commands": sum(n - 1 for n in commands.values()),
145
+ "test_runs": len(runs), "redundant_test_runs": redundant, "rework_loops": rework,
146
+ "reedits": sum(n - 1 for n in edits.values()),
147
+ "ftt": bool(after) and rework == 0,
148
+ "pce": round(value_add / lead, 3) if lead else None,
149
+ }
150
+
151
+
152
+ def redundant_runs(calls: list[dict]) -> int:
153
+ """Passing test runs with no file edited since the previous passing run (overprocessing, e58s10)."""
154
+ redundant, green, dirty = 0, False, False
155
+ for call in calls:
156
+ if call["tool"] in ("edit", "write"):
157
+ dirty = True
158
+ elif call["tool"] == "bash" and call["stage"] == "test" and not call["failed"]:
159
+ redundant += green and not dirty
160
+ green, dirty = True, False
161
+ return redundant
162
+
163
+
164
+ EMPTY_TRANSCRIPT = dict.fromkeys(["rereads", "repeated_commands", "test_runs", "redundant_test_runs",
165
+ "rework_loops", "reedits", "ftt", "pce"])
166
+
167
+
168
+ def waste_for(workdir: Path, base: str, stdout: str, parser: str, task_dir: Path,
169
+ patch_path: Path | None = None) -> dict:
170
+ """The run record's waste block; transcript metrics are None for non-pi adapters."""
171
+ rules = load_rules()
172
+ transcript = transcript_waste(stdout, rules) if parser == "pi-json" else None
173
+ return {**patch_waste(workdir, base, task_dir, patch_path, rules), **(transcript or EMPTY_TRANSCRIPT)}
@@ -60,7 +60,7 @@ for adapter in scripts/adapters/*.sh; do
60
60
  IR_DESC_ESCAPED="test desc"; IR_MODEL=""
61
61
  declare -f render_skill >/dev/null || exit 1
62
62
  render_skill
63
- ' _ "$PWD/$adapter" "$nm" >/dev/null 2>&1 )
63
+ ' _ "$PWD/$adapter" "$nm" </dev/null >/dev/null 2>&1 )
64
64
  }
65
65
 
66
66
  dest="$TMP_ROOT/$id"
@@ -10,7 +10,7 @@ HOOK="$REPO_ROOT/hooks/pre-tool-use.sh"
10
10
  pass_count=0
11
11
  fail_count=0
12
12
 
13
- assert_denied() {
13
+ lock_assert_denied() {
14
14
  local label="$1"
15
15
  local input="$2"
16
16
  local expected_substr="${3:-}"
@@ -30,7 +30,7 @@ assert_denied() {
30
30
  fi
31
31
  }
32
32
 
33
- assert_allowed() {
33
+ lock_assert_allowed() {
34
34
  local label="$1"
35
35
  local input="$2"
36
36
  local output exit_code=0
@@ -64,7 +64,7 @@ git add .
64
64
  git commit -q -m "feat(app): initial commit"
65
65
 
66
66
  # 1. Without a RED commit yet during fix_bug, test edits should be allowed (to author the RED test)
67
- assert_allowed "Test file edit allowed when authoring RED test" \
67
+ lock_assert_allowed "Test file edit allowed when authoring RED test" \
68
68
  '{"tool": "Edit", "file_path": "test/app.test.js"}'
69
69
 
70
70
  # 2. Commit a RED test
@@ -73,23 +73,23 @@ git add test/app.test.js
73
73
  git commit -q -m "test(app): red test reproducing defect"
74
74
 
75
75
  # 3. Now that a RED commit exists and active_flow is fix_bug, test edits must be LOCKED
76
- assert_denied "Test file edit denied after RED commit in fix_bug" \
76
+ lock_assert_denied "Test file edit denied after RED commit in fix_bug" \
77
77
  '{"tool": "Edit", "file_path": "test/app.test.js"}' \
78
78
  "locked"
79
79
 
80
- assert_denied "Test file write denied after RED commit in fix_bug" \
80
+ lock_assert_denied "Test file write denied after RED commit in fix_bug" \
81
81
  '{"tool": "Write", "file_path": "tests/test_other.py"}' \
82
82
  "locked"
83
83
 
84
84
  # 4. Source file edits must remain allowed
85
- assert_allowed "Source file edit allowed after RED commit in fix_bug" \
85
+ lock_assert_allowed "Source file edit allowed after RED commit in fix_bug" \
86
86
  '{"tool": "Edit", "file_path": "src/app.js"}'
87
87
 
88
88
  # 5. When active_flow is not fix_bug, test edits are allowed
89
89
  cat > specs/state.yaml <<'YAML'
90
90
  active_flow: build_epic
91
91
  YAML
92
- assert_allowed "Test file edit allowed when active_flow is not fix_bug" \
92
+ lock_assert_allowed "Test file edit allowed when active_flow is not fix_bug" \
93
93
  '{"tool": "Edit", "file_path": "test/app.test.js"}'
94
94
 
95
95
  echo ""