bigpowers 2.91.1 → 2.92.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.91.1",
3
+ "version": "2.92.0",
4
4
  "description": "80 skills — 73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "gemini-extension"
package/.pi/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.91.1",
3
+ "version": "2.92.0",
4
4
  "description": "80 skills — 73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "pi-package"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [2.92.0](https://github.com/danielvm-git/bigpowers/compare/v2.91.1...v2.92.0) (2026-11-03)
2
+
3
+
4
+ ### Features
5
+
6
+ * **benchmarks:** lean value-stream and waste metrics for the A/B eval, via pi in the Docker sandbox (e58s05–e58s11) ([#195](https://github.com/danielvm-git/bigpowers/issues/195)) ([11f5c8f](https://github.com/danielvm-git/bigpowers/commit/11f5c8f4b6407c67c4131620861345b49fce6193)), closes [#191](https://github.com/danielvm-git/bigpowers/issues/191)
7
+
1
8
  ## [2.91.1](https://github.com/danielvm-git/bigpowers/compare/v2.91.0...v2.91.1) (2026-11-03)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.91.1",
3
+ "version": "2.92.0",
4
4
  "description": "73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -15,7 +15,7 @@ INTERNAL_NET="bp-ab-internal"
15
15
  EGRESS_NET="bp-ab-egress"
16
16
  ASSETS="$(cd "$(dirname "${BASH_SOURCE[0]}")/lib/ab-sandbox" && pwd)"
17
17
 
18
- die() { echo "ERROR: $*" >&2; exit 1; }
18
+ sandbox_die() { echo "ERROR: $*" >&2; exit 1; }
19
19
 
20
20
  have_image() { docker image inspect "$1" >/dev/null 2>&1; }
21
21
  have_net() { docker network inspect "$1" >/dev/null 2>&1; }
@@ -43,7 +43,7 @@ wait_for_proxy() {
43
43
  sleep 1
44
44
  done
45
45
  docker logs "$PROXY_NAME" >&2 || true
46
- die "proxy did not start listening"
46
+ sandbox_die "proxy did not start listening"
47
47
  }
48
48
 
49
49
  cmd_up() {
@@ -81,5 +81,5 @@ case "${1:-status}" in
81
81
  up) cmd_up ;;
82
82
  down) cmd_down ;;
83
83
  status) cmd_status ;;
84
- *) die "usage: $(basename "$0") up|down|status" ;;
84
+ *) sandbox_die "usage: $(basename "$0") up|down|status" ;;
85
85
  esac
@@ -38,7 +38,7 @@ wire_context() {
38
38
  }
39
39
 
40
40
  # If stdin is a pipe/redirect, read the SkillIR JSON and render
41
- if [[ ! -t 0 ]]; then
41
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
42
42
  JSON_INPUT=$(cat)
43
43
  if [[ -n "$JSON_INPUT" ]]; then
44
44
  IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
@@ -29,7 +29,7 @@ wire_context() {
29
29
  }
30
30
 
31
31
  # If stdin is a pipe/redirect, read the SkillIR JSON and render
32
- if [[ ! -t 0 ]]; then
32
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
33
33
  JSON_INPUT=$(cat)
34
34
  if [[ -n "$JSON_INPUT" ]]; then
35
35
  IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
@@ -101,7 +101,7 @@ if [[ "${1:-}" == "--list-hook-events" ]]; then
101
101
  fi
102
102
 
103
103
  # If stdin is a pipe/redirect, read the SkillIR JSON and render
104
- if [[ ! -t 0 ]]; then
104
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
105
105
  JSON_INPUT=$(cat)
106
106
  if [[ -n "$JSON_INPUT" ]]; then
107
107
  IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
@@ -30,7 +30,7 @@ wire_context() {
30
30
  }
31
31
 
32
32
  # If stdin is a pipe/redirect, read the SkillIR JSON and render
33
- if [[ ! -t 0 ]]; then
33
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
34
34
  JSON_INPUT=$(cat)
35
35
  if [[ -n "$JSON_INPUT" ]]; then
36
36
  IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
@@ -41,7 +41,7 @@ wire_context() {
41
41
  }
42
42
 
43
43
  # If stdin is a pipe/redirect, read the SkillIR JSON and render
44
- if [[ ! -t 0 ]]; then
44
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
45
45
  JSON_INPUT=$(cat)
46
46
  if [[ -n "$JSON_INPUT" ]]; then
47
47
  IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
@@ -27,7 +27,7 @@ wire_context() {
27
27
  }
28
28
 
29
29
  # If stdin is a pipe/redirect, read the SkillIR JSON and render
30
- if [[ ! -t 0 ]]; then
30
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
31
31
  JSON_INPUT=$(cat)
32
32
  if [[ -n "$JSON_INPUT" ]]; then
33
33
  IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
@@ -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 ""
@@ -109,9 +109,16 @@ fi
109
109
  DEFECTS_FOUND=0
110
110
  mkdir -p specs/verifications
111
111
 
112
+ # A JS/TS source file that exists and is not a test: the files the boundary scan inspects.
113
+ is_scannable_source() {
114
+ local file="$1"
115
+ [[ "$file" =~ \.(js|ts|mjs)$ && -f "$file" ]] || return 1
116
+ [[ ! "$file" =~ (^|/)test/ && ! "$file" =~ \.test\. ]]
117
+ }
118
+
112
119
  # 2. Check for common unsafe property access patterns on JS/TS files
113
120
  for file in $CHANGED_FILES; do
114
- if [[ "$file" =~ \.(js|ts|mjs)$ ]] && [[ -f "$file" ]] && [[ ! "$file" =~ (^|/)test/ ]] && [[ ! "$file" =~ \.test\. ]]; then
121
+ if is_scannable_source "$file"; then
115
122
  # Look for unsafe chained property access like item.brand.toLowerCase() or item.prop.subprop
116
123
  if grep -nE '\.[a-zA-Z0-9_]+\.(toLowerCase|toUpperCase|trim|length|slice)\(' "$file" | grep -v '\?\.' >/dev/null 2>&1; then
117
124
  echo "WARNING [boundary-mutation]: Potential unhandled undefined access without optional chaining in $file:"
@@ -9,7 +9,6 @@ import importlib.util
9
9
  import io
10
10
  import json
11
11
  import shutil
12
- import subprocess
13
12
  import sys
14
13
  import tempfile
15
14
  import unittest
@@ -249,49 +248,6 @@ class TestCli(Sandbox):
249
248
  self.assertIsNone(report["delta"])
250
249
 
251
250
 
252
- @unittest.skipUnless(shutil.which("node"), "node is needed for the starter tasks")
253
- class TestStarterTasks(unittest.TestCase):
254
- """Each shipped task must fail as given and pass with its reference solution."""
255
-
256
- TASKS = REPO_ROOT / "specs" / "benchmarks" / "ab" / "tasks"
257
-
258
- def grade_copy(self, task_dir: Path, with_solution: bool) -> bool:
259
- task = yaml.safe_load((task_dir / "task.yaml").read_text())
260
- work = Path(tempfile.mkdtemp(prefix="ab-starter-"))
261
- try:
262
- shutil.copytree(task_dir / "fixture", work, dirs_exist_ok=True)
263
- if with_solution:
264
- shutil.copytree(task_dir / "solution", work, dirs_exist_ok=True)
265
- shutil.copytree(task_dir / "hidden", work, dirs_exist_ok=True)
266
- res = subprocess.run(["bash", "-c", task["grade"]], cwd=work, capture_output=True)
267
- return res.returncode == 0
268
- finally:
269
- shutil.rmtree(work, ignore_errors=True)
270
-
271
- def test_eight_tasks_ship(self):
272
- self.assertGreaterEqual(len(list(self.TASKS.glob("*/task.yaml"))), 8)
273
-
274
- def test_fixture_fails_and_solution_passes(self):
275
- for task_yaml in sorted(self.TASKS.glob("*/task.yaml")):
276
- with self.subTest(task=task_yaml.parent.name):
277
- self.assertFalse(self.grade_copy(task_yaml.parent, with_solution=False))
278
- self.assertTrue(self.grade_copy(task_yaml.parent, with_solution=True))
279
-
280
- def test_prompts_never_name_bigpowers(self):
281
- for task_yaml in self.TASKS.glob("*/task.yaml"):
282
- self.assertNotIn("bigpowers", yaml.safe_load(task_yaml.read_text())["prompt"].lower())
283
-
284
- def test_grade_commands_never_pass_a_bare_directory(self):
285
- """`node --test test/` exits 1 on node 24 (it resolves the directory as a module path), so
286
- such a task can never pass however good the solution is, while a hidden-only `score`
287
- command still looks healthy (BUG found in e58s07: ab-11 and ab-13 read pass=0 forever)."""
288
- for task_yaml in self.TASKS.glob("*/task.yaml"):
289
- with self.subTest(task=task_yaml.parent.name):
290
- for token in yaml.safe_load(task_yaml.read_text())["grade"].split():
291
- self.assertNotIn(token.rstrip("/"), ("test", "test-hidden"),
292
- f"{task_yaml.parent.name} grades a directory, not a glob")
293
-
294
-
295
251
  class TestRunBenchmarkHonesty(unittest.TestCase):
296
252
  def test_without_skill_arm_is_unmeasured_not_zero(self):
297
253
  sc = {"id": "s1", "grader": {"type": "code", "command": "true"}}
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s10
3
+ """tests/test_ab_lean.py — lean views: per-arm VSM and waste summary, deltas, bars, redundant tests.
4
+
5
+ Run: python3 tests/test_ab_lean.py
6
+ """
7
+ import sys
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ from test_ab_eval import load_module, rep # noqa: E402
13
+ from test_ab_waste import Stream, waste # noqa: E402
14
+
15
+ lean = load_module("ab_lean", "ab_lean.py")
16
+
17
+
18
+ def run(arm, lead, build, test, passed=True, cost=0.01, test_runs=3, rework=0):
19
+ stages = {"plan": {"s": lead * 0.1}, "design": {"s": lead * 0.2}, "build": {"s": build}, "test": {"s": test}}
20
+ vsm = {"lead_time_s": lead, "time_to_first_build_s": lead * 0.3, "switches": 4, "stages": stages}
21
+ w = {"test_runs": test_runs, "redundant_test_runs": 1, "rework_loops": rework, "reedits": 0,
22
+ "rereads": 0, "repeated_commands": 0, "review_load": 50, "diff_ratio": 1.2, "ftt": rework == 0,
23
+ "pce": round((build + test) / lead, 3), "leftovers": [], "extra_files": []}
24
+ return {"task": "t", "arm": arm, "pass": passed, "score": None, "tampered": False, "agent_error": False,
25
+ "cost_usd": cost, "input_tokens": None, "output_tokens": None, "turns": 5, "duration_s": lead,
26
+ "diff_lines": 5, "vsm": vsm, "waste": w}
27
+
28
+
29
+ def records():
30
+ return ([run("vanilla", 70, 28, 21) for _ in range(5)]
31
+ + [run("bigpowers", 140, 20, 70, cost=0.02, test_runs=8, rework=1) for _ in range(5)])
32
+
33
+
34
+ class TestLeanSummary(unittest.TestCase):
35
+ def test_per_arm_summary_and_deltas(self):
36
+ summary = lean.lean_summary(records(), ["vanilla", "bigpowers"])
37
+ va, bp = summary["arms"]["vanilla"], summary["arms"]["bigpowers"]
38
+ self.assertEqual(va["lead_time_s"], 70)
39
+ self.assertEqual(round(va["stage_share"]["build"], 2), 0.4)
40
+ self.assertEqual(bp["waste"]["test_runs"], 8)
41
+ self.assertEqual(bp["ftt_rate"], 0.0)
42
+ self.assertEqual(va["cost_per_pass"], 0.01)
43
+ d = summary["deltas"]["bigpowers"]
44
+ self.assertEqual(d["lead_time_s"]["delta"], 70)
45
+ self.assertEqual(d["test_runs"]["delta"], 5)
46
+ self.assertEqual(len(d["build_share"]["ci95"]), 2)
47
+
48
+ def test_old_records_give_none(self):
49
+ old = [{k: v for k, v in r.items() if k not in ("vsm", "waste")} for r in records()]
50
+ self.assertIsNone(lean.lean_summary(old, ["vanilla", "bigpowers"]))
51
+ self.assertEqual(lean.lean_lines(None), [])
52
+
53
+ def test_report_carries_lean(self):
54
+ result = rep.aggregate(records(), ["vanilla", "bigpowers"], ["t"])
55
+ self.assertIn("bigpowers", result["lean"]["deltas"])
56
+
57
+
58
+ class TestViews(unittest.TestCase):
59
+ def test_bars_scale_with_lead_time(self):
60
+ lines = lean.lean_lines(lean.lean_summary(records(), ["vanilla", "bigpowers"]))
61
+ bars = {line.split()[0]: line for line in lines if line.split() and line.split()[0] in ("vanilla", "bigpowers")}
62
+ va_len = bars["vanilla"].count("|") and len(bars["vanilla"].split("|")[1].rstrip())
63
+ bp_len = len(bars["bigpowers"].split("|")[1].rstrip())
64
+ self.assertAlmostEqual(bp_len / va_len, 2, delta=0.2)
65
+ self.assertIn("T", bars["bigpowers"])
66
+ self.assertTrue(any("rework" in line for line in lines))
67
+
68
+
69
+ class TestRedundantTestRuns(unittest.TestCase):
70
+ def test_repeated_green_run_without_edit_is_redundant(self):
71
+ stream = (Stream()
72
+ .call("edit", {"path": "/work/src/a.js"})
73
+ .call("bash", {"command": "npm test"}, "# pass 3\n# fail 0\n")
74
+ .call("bash", {"command": "npm test 2>&1 | tail -3"}, "# pass 3\n# fail 0\n")
75
+ .call("edit", {"path": "/work/src/a.js"})
76
+ .call("bash", {"command": "npm test"}, "# pass 3\n# fail 0\n"))
77
+ self.assertEqual(waste.transcript_waste(stream.text())["redundant_test_runs"], 1)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ unittest.main(verbosity=1)
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- # story: e58s03
2
+ # story: e58s03 e58s11
3
3
  """tests/test_ab_score.py — partial-credit scoring and the hard tasks (ab-11, ab-13).
4
4
 
5
5
  Run: python3 tests/test_ab_score.py
@@ -18,7 +18,17 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
18
18
  from test_ab_eval import REPO_ROOT, Sandbox, met, rep # noqa: E402
19
19
 
20
20
  TASKS = REPO_ROOT / "specs" / "benchmarks" / "ab" / "tasks"
21
- HARD_TASKS = ["ab-11-ledger-rounding", "ab-13-orders-split"]
21
+ HARD_TASKS = [
22
+ "ab-09-inventory-reservations",
23
+ "ab-11-ledger-rounding",
24
+ "ab-12-todo-due-dates",
25
+ "ab-13-orders-split",
26
+ "ab-15-config-precedence",
27
+ "ab-16-site-tags-rss",
28
+ "ab-17-payment-alias",
29
+ "ab-19-catalog-segregation",
30
+ "ab-20-brand-discount",
31
+ ]
22
32
 
23
33
 
24
34
  class TestTapScore(unittest.TestCase):
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s01 e58s02 e58s07 e58s09
3
+ """tests/test_ab_tasks.py — every shipped A/B task is calibrated: its fixture fails, its reference
4
+ solution passes, its prompt never names bigpowers, and its grade command uses file globs.
5
+
6
+ Run: python3 tests/test_ab_tasks.py
7
+ Split from test_ab_eval.py (300-line cap).
8
+ """
9
+ import json
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import unittest
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ from test_ab_eval import REPO_ROOT # noqa: E402
21
+
22
+
23
+ @unittest.skipUnless(shutil.which("node"), "node is needed for the starter tasks")
24
+ class TestStarterTasks(unittest.TestCase):
25
+ """Each shipped task must fail as given and pass with its reference solution."""
26
+
27
+ TASKS = REPO_ROOT / "specs" / "benchmarks" / "ab" / "tasks"
28
+
29
+ def grade_copy(self, task_dir: Path, with_solution: bool) -> bool:
30
+ task = yaml.safe_load((task_dir / "task.yaml").read_text())
31
+ work = Path(tempfile.mkdtemp(prefix="ab-starter-"))
32
+ try:
33
+ shutil.copytree(task_dir / "fixture", work, dirs_exist_ok=True)
34
+ if with_solution:
35
+ shutil.copytree(task_dir / "solution", work, dirs_exist_ok=True)
36
+ shutil.copytree(task_dir / "hidden", work, dirs_exist_ok=True)
37
+ res = subprocess.run(["bash", "-c", task["grade"]], cwd=work, capture_output=True)
38
+ return res.returncode == 0
39
+ finally:
40
+ shutil.rmtree(work, ignore_errors=True)
41
+
42
+ def test_eight_tasks_ship(self):
43
+ self.assertGreaterEqual(len(list(self.TASKS.glob("*/task.yaml"))), 8)
44
+
45
+ def test_fixture_fails_and_solution_passes(self):
46
+ for task_yaml in sorted(self.TASKS.glob("*/task.yaml")):
47
+ with self.subTest(task=task_yaml.parent.name):
48
+ self.assertFalse(self.grade_copy(task_yaml.parent, with_solution=False))
49
+ self.assertTrue(self.grade_copy(task_yaml.parent, with_solution=True))
50
+
51
+ def test_prompts_never_name_bigpowers(self):
52
+ for task_yaml in self.TASKS.glob("*/task.yaml"):
53
+ self.assertNotIn("bigpowers", yaml.safe_load(task_yaml.read_text())["prompt"].lower())
54
+
55
+ def test_fixture_test_scripts_never_pass_a_bare_directory(self):
56
+ """`npm test` must work in every fixture: a `node --test test/` script fails on node 24, and
57
+ agents then spend the run repairing the harness (found by e58s09 waste metrics)."""
58
+ for package in self.TASKS.glob("*/fixture/package.json"):
59
+ with self.subTest(task=package.parent.parent.name):
60
+ script = json.loads(package.read_text()).get("scripts", {}).get("test", "")
61
+ self.assertNotRegex(script, r"--test\s+\S*/(\s|$)", script)
62
+
63
+ def test_grade_commands_never_pass_a_bare_directory(self):
64
+ """`node --test test/` exits 1 on node 24 (it resolves the directory as a module path), so
65
+ such a task can never pass however good the solution is, while a hidden-only `score`
66
+ command still looks healthy (BUG found in e58s07: ab-11 and ab-13 read pass=0 forever)."""
67
+ for task_yaml in self.TASKS.glob("*/task.yaml"):
68
+ with self.subTest(task=task_yaml.parent.name):
69
+ for token in yaml.safe_load(task_yaml.read_text())["grade"].split():
70
+ self.assertNotIn(token.rstrip("/"), ("test", "test-hidden"),
71
+ f"{task_yaml.parent.name} grades a directory, not a glob")
72
+
73
+
74
+ if __name__ == "__main__":
75
+ unittest.main(verbosity=1)
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s08
3
+ """tests/test_ab_vsm.py — value stream map per run: stage rules, per-turn time, run records.
4
+
5
+ Run: python3 tests/test_ab_vsm.py
6
+ """
7
+ import json
8
+ import sys
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+ from test_ab_eval import Sandbox, load_module # noqa: E402
14
+
15
+ vsm = load_module("ab_vsm", "ab_vsm.py")
16
+ STAGES = {"plan", "design", "build", "test", "deploy", "maintain", "wrap-up"}
17
+
18
+
19
+ def assistant(ts_s, cost=0.001, out=10, calls=()):
20
+ content = [{"type": "toolCall", "name": tool, "arguments": args} for tool, args in calls]
21
+ usage = {"input": 100, "output": out, "cacheRead": 0, "cacheWrite": 0, "cost": {"total": cost}}
22
+ return {"type": "message_end", "message": {"role": "assistant", "timestamp": int(ts_s * 1000),
23
+ "content": content, "usage": usage}}
24
+
25
+
26
+ def tool_result(ts_s):
27
+ return {"type": "message_end", "message": {"role": "toolResult", "timestamp": int(ts_s * 1000)}}
28
+
29
+
30
+ def transcript():
31
+ events = [
32
+ {"type": "session", "id": "s"},
33
+ {"type": "message_end", "message": {"role": "user", "timestamp": 0}},
34
+ assistant(0, calls=[("read", {"path": "/work/ISSUE.md"})]), tool_result(9),
35
+ assistant(10, calls=[("bash", {"command": "cat src/reports.js"})]), tool_result(24),
36
+ assistant(25, calls=[("edit", {"path": "/work/src/reports.js"})]), tool_result(39),
37
+ assistant(40, calls=[("bash", {"command": "npm test 2>&1 | tail"})]), tool_result(49),
38
+ assistant(50, calls=[("bash", {"command": "git commit -am fix"})]), tool_result(54),
39
+ assistant(55, out=40),
40
+ {"type": "agent_settled", "aborted": False},
41
+ ]
42
+ return "\n".join(json.dumps(e) for e in events)
43
+
44
+
45
+ class TestRules(unittest.TestCase):
46
+ def setUp(self):
47
+ self.rules = vsm.load_rules()
48
+
49
+ def test_rules_file_uses_known_stages(self):
50
+ self.assertTrue({r["stage"] for r in self.rules["rules"]} <= STAGES)
51
+ self.assertTrue(set(self.rules["turn_priority"]) <= STAGES)
52
+
53
+ def test_calls_are_classified(self):
54
+ cases = [
55
+ ("read", {"path": "/work/ISSUE.md"}, "plan"),
56
+ ("bash", {"command": "sed -n 1,80p .agents/skills/fix-bug/SKILL.md"}, "plan"),
57
+ ("read", {"path": "/work/src/reports.js"}, "design"),
58
+ ("edit", {"path": "/work/src/reports.js"}, "build"),
59
+ ("edit", {"path": "/work/test/ledger.test.js"}, "test"),
60
+ ("bash", {"command": "npm test 2>&1 | tail -20"}, "test"),
61
+ ("bash", {"command": "node --test test/"}, "test"),
62
+ ("bash", {"command": "git checkout -b fix/rounding"}, "deploy"),
63
+ ("bash", {"command": "git commit -am 'fix'"}, "deploy"),
64
+ ("write", {"path": "/work/specs/bugs/BUG-001.md"}, "maintain"),
65
+ ("edit", {"path": "/work/docs/LEDGER.md"}, "maintain"),
66
+ ("write", {"path": "/work/specs/plan.md"}, "plan"),
67
+ ]
68
+ for tool, args, expected in cases:
69
+ with self.subTest(tool=tool, args=args):
70
+ self.assertEqual(vsm.classify_call(tool, args, self.rules), expected)
71
+
72
+
73
+ class TestStageTime(unittest.TestCase):
74
+ def test_turn_time_goes_to_the_stage_of_its_action(self):
75
+ block = vsm.vsm_from("pi-json", transcript())
76
+ seconds = {stage: round(v["s"], 1) for stage, v in block["stages"].items()}
77
+ self.assertEqual(seconds, {"plan": 10.0, "design": 15.0, "build": 15.0, "test": 10.0,
78
+ "deploy": 5.0, "wrap-up": 0.0})
79
+ self.assertEqual(block["lead_time_s"], 55.0)
80
+ self.assertEqual(block["time_to_first_build_s"], 25.0)
81
+ self.assertEqual(block["switches"], 5)
82
+ self.assertEqual(block["sequence"], ["plan", "design", "build", "test", "deploy", "wrap-up"])
83
+ self.assertEqual(block["stages"]["wrap-up"]["output_tokens"], 40)
84
+ self.assertAlmostEqual(sum(v["cost_usd"] for v in block["stages"].values()), 0.006)
85
+
86
+ def test_build_wins_a_turn_with_several_calls(self):
87
+ events = [assistant(0, calls=[("read", {"path": "/work/ISSUE.md"}), ("edit", {"path": "/work/src/a.js"})]),
88
+ tool_result(5)]
89
+ block = vsm.vsm_from("pi-json", "\n".join(json.dumps(e) for e in events))
90
+ self.assertEqual(block["sequence"], ["build"])
91
+
92
+ def test_unsupported_or_empty_is_none(self):
93
+ self.assertIsNone(vsm.vsm_from("none", transcript()))
94
+ self.assertIsNone(vsm.vsm_from("pi-json", "not json"))
95
+
96
+
97
+ class TestRunRecord(Sandbox):
98
+ def test_record_carries_vsm(self):
99
+ script = self.tmp / "transcript.jsonl"
100
+ script.write_text(transcript() + "\n")
101
+ self.run_main("--adapter-cmd", f"cat {script}; echo 42 > answer.txt", "--parser", "pi-json",
102
+ "--arms", "vanilla")
103
+ rec = self.records()[0]
104
+ self.assertEqual(rec["vsm"]["stages"]["build"]["turns"], 1)
105
+ self.assertEqual(rec["vsm"]["lead_time_s"], 55.0)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ unittest.main(verbosity=1)
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s09
3
+ """tests/test_ab_waste.py — lean waste metrics: relearning, rework, first-time-through, patch metrics.
4
+
5
+ Run: python3 tests/test_ab_waste.py
6
+ """
7
+ import gzip
8
+ import json
9
+ import sys
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+ from test_ab_eval import Sandbox, load_module # noqa: E402
15
+
16
+ waste = load_module("ab_waste", "ab_waste.py")
17
+
18
+
19
+ class Stream:
20
+ """Builds a pi JSON transcript: assistant turns with one tool call each, plus tool results."""
21
+
22
+ def __init__(self):
23
+ self.events, self.t, self.n = [], 0, 0
24
+
25
+ def call(self, tool, args, result_text="ok", exit_code=0):
26
+ self.n += 1
27
+ cid = f"c{self.n}"
28
+ self.events.append({"type": "message_end", "message": {
29
+ "role": "assistant", "timestamp": self.t * 1000, "usage": {"output": 5, "cost": {"total": 0.001}},
30
+ "content": [{"type": "toolCall", "name": tool, "arguments": args}]}})
31
+ self.events.append({"type": "tool_execution_start", "toolCallId": cid, "toolName": tool, "args": args})
32
+ result = {"content": [{"type": "text", "text": result_text}], "details": {"exit_code": exit_code}}
33
+ self.events.append({"type": "tool_execution_end", "toolCallId": cid, "toolName": tool,
34
+ "result": result, "isError": False})
35
+ self.events.append({"type": "message_end", "message": {"role": "toolResult",
36
+ "timestamp": (self.t + 1) * 1000}})
37
+ self.t += 10
38
+ return self
39
+
40
+ def text(self):
41
+ return "\n".join(json.dumps(e) for e in self.events)
42
+
43
+
44
+ class TestTranscriptWaste(unittest.TestCase):
45
+ def test_relearning_and_rework_are_counted(self):
46
+ stream = (Stream()
47
+ .call("read", {"path": "/work/src/a.js"})
48
+ .call("read", {"path": "/work/src/a.js"})
49
+ .call("bash", {"command": "ls"})
50
+ .call("bash", {"command": "ls"})
51
+ .call("edit", {"path": "/work/src/a.js"})
52
+ .call("bash", {"command": "npm test 2>&1 | tail"}, "ℹ pass 3\nℹ fail 1\n")
53
+ .call("edit", {"path": "/work/src/a.js"})
54
+ .call("bash", {"command": "npm test 2>&1 | tail"}, "ℹ pass 4\nℹ fail 0\n"))
55
+ got = waste.transcript_waste(stream.text())
56
+ self.assertEqual((got["rereads"], got["repeated_commands"], got["test_runs"], got["rework_loops"],
57
+ got["reedits"], got["ftt"]), (1, 1, 2, 1, 1, False))
58
+
59
+ def test_non_zero_exit_is_a_failing_run(self):
60
+ stream = Stream().call("edit", {"path": "/work/src/a.js"}).call(
61
+ "bash", {"command": "node --test test/"}, "boom", exit_code=1)
62
+ self.assertEqual(waste.transcript_waste(stream.text())["rework_loops"], 1)
63
+
64
+ def test_clean_run_is_first_time_through(self):
65
+ stream = Stream().call("edit", {"path": "/work/src/a.js"}).call(
66
+ "bash", {"command": "npm test"}, "# pass 5\n# fail 0\n")
67
+ got = waste.transcript_waste(stream.text())
68
+ self.assertEqual((got["rework_loops"], got["ftt"]), (0, True))
69
+ self.assertGreater(got["pce"], 0)
70
+
71
+ def test_no_transcript_gives_none(self):
72
+ self.assertIsNone(waste.transcript_waste("not json"))
73
+
74
+
75
+ class TestPatchWaste(Sandbox):
76
+ AGENT = ("echo 42 > answer.txt; printf 'a\\nb\\n' > extra.js; "
77
+ "printf 'one two three\\n' > notes.md; echo x > debug.tmp.js")
78
+
79
+ def setUp(self):
80
+ super().setUp()
81
+ solution = self.tasks_dir / "t-01" / "solution"
82
+ solution.mkdir()
83
+ (solution / "answer.txt").write_text("42\n")
84
+
85
+ def test_patch_metrics_and_saved_patch(self):
86
+ self.run_main("--adapter-cmd", self.AGENT, "--arms", "vanilla")
87
+ got = self.records()[0]["waste"]
88
+ self.assertEqual(got["solution_lines"], 1)
89
+ self.assertIn("extra.js", got["extra_files"])
90
+ self.assertNotIn("answer.txt", got["extra_files"])
91
+ self.assertEqual(got["leftovers"], ["debug.tmp.js"])
92
+ self.assertEqual(got["doc_lines"], 1)
93
+ self.assertGreater(got["diff_ratio"], 1)
94
+ self.assertGreaterEqual(got["review_load"], got["product_lines"] + 3)
95
+ patch = self.out / "traces" / "t-01-vanilla-1.patch.gz"
96
+ self.assertIn("extra.js", gzip.decompress(patch.read_bytes()).decode())
97
+
98
+ def test_non_pi_adapter_has_patch_metrics_only(self):
99
+ self.run_main("--adapter-cmd", self.AGENT, "--arms", "vanilla")
100
+ got = self.records()[0]["waste"]
101
+ self.assertIsNone(got["rework_loops"])
102
+ self.assertIsNotNone(got["review_load"])
103
+
104
+
105
+ if __name__ == "__main__":
106
+ unittest.main(verbosity=1)