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.
- package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
- package/.pi/package.json +1 -1
- package/CHANGELOG.md +7 -0
- 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/.pi/package.json
CHANGED
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
package/scripts/ab-sandbox.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*)
|
|
84
|
+
*) sandbox_die "usage: $(basename "$0") up|down|status" ;;
|
|
85
85
|
esac
|
package/scripts/adapters/agy.sh
CHANGED
|
@@ -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')
|
package/scripts/adapters/mimo.sh
CHANGED
|
@@ -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')
|
package/scripts/adapters/pi.sh
CHANGED
|
@@ -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
|
-
|
|
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 ""
|
|
@@ -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
|
|
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:"
|
package/tests/test_ab_eval.py
CHANGED
|
@@ -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)
|
package/tests/test_ab_score.py
CHANGED
|
@@ -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 = [
|
|
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)
|