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.
- package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
- package/.pi/package.json +1 -1
- package/AGENTS.md +98 -0
- package/CHANGELOG.md +14 -0
- package/CONVENTIONS.md +47 -427
- package/package.json +1 -1
- package/scripts/ab-sandbox.sh +3 -3
- package/scripts/adapters/agy.sh +1 -1
- package/scripts/adapters/cursor.sh +1 -1
- package/scripts/adapters/gemini.sh +1 -1
- package/scripts/adapters/mimo.sh +1 -1
- package/scripts/adapters/pi.sh +1 -1
- package/scripts/adapters/zcode.sh +1 -1
- package/scripts/generate-context-bundle.sh +2 -1
- package/scripts/lib/__pycache__/atomic_write.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/link_utils.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_links.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_okf.cpython-310.pyc +0 -0
- package/scripts/lib/ab-eval.py +19 -5
- package/scripts/lib/ab_lean.py +116 -0
- package/scripts/lib/ab_metrics.py +8 -0
- package/scripts/lib/ab_progress.py +3 -1
- package/scripts/lib/ab_report.py +15 -4
- package/scripts/lib/ab_vsm.py +122 -0
- package/scripts/lib/ab_waste.py +173 -0
- package/scripts/test-adapter-render.sh +1 -1
- package/scripts/test-test-lock-hook.sh +7 -7
- package/scripts/verify-adversarial.sh +8 -1
- package/tests/test_ab_eval.py +0 -44
- package/tests/test_ab_lean.py +81 -0
- package/tests/test_ab_score.py +12 -2
- package/tests/test_ab_tasks.py +75 -0
- package/tests/test_ab_vsm.py +109 -0
- package/tests/test_ab_waste.py +106 -0
- package/CLAUDE.md +0 -400
- 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
|
-
|
|
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" ;;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/lib/ab-eval.py
CHANGED
|
@@ -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,
|
|
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
|
|
package/scripts/lib/ab_report.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ""
|