bigpowers 2.91.0 → 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/.agents/skills/guard-git/SKILL.md +7 -2
- package/.cline/skills/guard-git/SKILL.md +7 -2
- package/.codebuddy/skills/guard-git/SKILL.md +7 -2
- package/.codex/skills/guard-git/SKILL.md +7 -2
- package/.copilot/skills/guard-git/SKILL.md +7 -2
- package/.cursor/rules/guard-git.mdc +7 -2
- package/.gemini/extensions/bigpowers/commands/prompts/guard-git.md +7 -2
- package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
- package/.gemini/extensions/bigpowers/skills/guard-git/SKILL.md +7 -2
- package/.hermes/skills/guard-git/SKILL.md +7 -2
- package/.kilocode/rules/guard-git.md +7 -2
- package/.mimocode/skills/guard-git/SKILL.md +7 -2
- package/.opencode/skills/guard-git/SKILL.md +7 -2
- package/.pi/package.json +1 -1
- package/.pi/prompts/guard-git.md +7 -2
- package/.pi/skills/guard-git/SKILL.md +7 -2
- package/.qwen/skills/guard-git/SKILL.md +7 -2
- package/.trae/skills/guard-git/SKILL.md +7 -2
- package/.windsurf/rules/guard-git.md +7 -2
- package/.zcode/skills/guard-git/SKILL.md +7 -2
- package/CHANGELOG.md +15 -0
- package/extensions/omp-hooks.ts +197 -58
- package/hooks/pre/bash.sh +54 -7
- package/hooks/pre-tool-use.sh +49 -11
- 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/lib/git-argv.py +168 -0
- package/scripts/lib/golden-suite-gates.sh +2 -0
- package/scripts/omp-guard-probe.ts +46 -0
- package/scripts/test-adapter-render.sh +1 -1
- package/scripts/test-guard-git-normalize.sh +189 -0
- package/scripts/test-test-lock-hook.sh +7 -7
- package/scripts/verify-adversarial.sh +8 -1
- package/skills/guard-git/SKILL.md +7 -2
- package/skills-lock.json +1 -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
|
@@ -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)}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Normalize git argv before applying the guard-git deny rules (#184).
|
|
3
|
+
|
|
4
|
+
The old guard matched literal substrings, so `git -C repo reset --hard`,
|
|
5
|
+
`git reset -q --hard` and `git checkout -- .` all slipped through: the
|
|
6
|
+
destructive tokens are still there, only their position or a global flag
|
|
7
|
+
changed. Matching on the raw command string cannot see that.
|
|
8
|
+
|
|
9
|
+
This module tokenizes the command (quote-aware), finds every `git` invocation,
|
|
10
|
+
strips the global options that precede the subcommand (`-C <path>`,
|
|
11
|
+
`--no-pager`, `-c k=v`, `--git-dir=…`, …), and then decides on the
|
|
12
|
+
(subcommand, arguments) pair. `extensions/omp-hooks.ts` implements the same
|
|
13
|
+
rules in TypeScript; `scripts/test-guard-git-normalize.sh` fails if the two
|
|
14
|
+
disagree on any fixture row.
|
|
15
|
+
|
|
16
|
+
Rule table (keep in sync with the TS port):
|
|
17
|
+
|
|
18
|
+
reset block when any argument is --hard
|
|
19
|
+
clean block when any short flag carries `f` (-f, -fd, -fdx) or --force
|
|
20
|
+
branch block on -D, or on --delete together with --force/-f
|
|
21
|
+
checkout block on -f/--force, or on a `.` pathspec (with or without --)
|
|
22
|
+
restore block on --worktree, or on a `.` pathspec
|
|
23
|
+
push block on -f/--force/+refspec, unless --force-with-lease is given
|
|
24
|
+
|
|
25
|
+
CLI: git-argv.py --command "<shell command>" → prints `block: <reason>` or `allow`
|
|
26
|
+
Exit: 0 allow, 10 block, 2 usage error.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import shlex
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
# Global options that consume the next token as their value.
|
|
34
|
+
VALUE_FLAGS = {
|
|
35
|
+
"-C",
|
|
36
|
+
"-c",
|
|
37
|
+
"--exec-path",
|
|
38
|
+
"--git-dir",
|
|
39
|
+
"--work-tree",
|
|
40
|
+
"--namespace",
|
|
41
|
+
"--super-prefix",
|
|
42
|
+
"--config-env",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Global options that stand alone.
|
|
46
|
+
BARE_FLAGS = {
|
|
47
|
+
"-p",
|
|
48
|
+
"--paginate",
|
|
49
|
+
"--no-pager",
|
|
50
|
+
"--bare",
|
|
51
|
+
"--no-replace-objects",
|
|
52
|
+
"--literal-pathspecs",
|
|
53
|
+
"--glob-pathspecs",
|
|
54
|
+
"--icase-pathspecs",
|
|
55
|
+
"--no-optional-locks",
|
|
56
|
+
"--version",
|
|
57
|
+
"--help",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Command separators: the guard judges each git invocation on its own.
|
|
61
|
+
SEPARATORS = {";", "&&", "||", "|", "&", "(", ")", "\n"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def tokenize(command: str) -> list[str]:
|
|
65
|
+
"""Quote-aware split. Bad quoting degrades to whitespace splitting, never to eval."""
|
|
66
|
+
try:
|
|
67
|
+
return shlex.split(command, comments=False, posix=True)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return command.split()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def git_invocations(tokens: list[str]) -> list[tuple[str, list[str], int]]:
|
|
73
|
+
"""Yield (subcommand, args, index) for every git call in the token stream."""
|
|
74
|
+
found: list[tuple[str, list[str], int]] = []
|
|
75
|
+
i = 0
|
|
76
|
+
while i < len(tokens):
|
|
77
|
+
token = tokens[i]
|
|
78
|
+
if token.rsplit("/", 1)[-1] != "git":
|
|
79
|
+
i += 1
|
|
80
|
+
continue
|
|
81
|
+
start = i
|
|
82
|
+
j = i + 1
|
|
83
|
+
# Skip global options between `git` and the subcommand.
|
|
84
|
+
while j < len(tokens):
|
|
85
|
+
opt = tokens[j]
|
|
86
|
+
if opt in SEPARATORS:
|
|
87
|
+
break
|
|
88
|
+
name = opt.split("=", 1)[0]
|
|
89
|
+
if name in VALUE_FLAGS:
|
|
90
|
+
j += 2 if "=" not in opt else 1
|
|
91
|
+
continue
|
|
92
|
+
if name in BARE_FLAGS or (opt.startswith("-") and not opt.startswith("--") and len(opt) > 1):
|
|
93
|
+
j += 1
|
|
94
|
+
continue
|
|
95
|
+
break
|
|
96
|
+
if j >= len(tokens) or tokens[j] in SEPARATORS:
|
|
97
|
+
i = j + 1
|
|
98
|
+
continue
|
|
99
|
+
sub = tokens[j]
|
|
100
|
+
args: list[str] = []
|
|
101
|
+
k = j + 1
|
|
102
|
+
while k < len(tokens) and tokens[k] not in SEPARATORS:
|
|
103
|
+
args.append(tokens[k])
|
|
104
|
+
k += 1
|
|
105
|
+
found.append((sub, args, start))
|
|
106
|
+
i = k
|
|
107
|
+
return found
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _short_flag_has(args: list[str], letter: str) -> bool:
|
|
111
|
+
for arg in args:
|
|
112
|
+
if arg.startswith("-") and not arg.startswith("--") and letter in arg[1:]:
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def classify_git(sub: str, args: list[str]) -> str | None:
|
|
118
|
+
"""Return a block reason, or None to allow."""
|
|
119
|
+
if sub == "reset" and "--hard" in args:
|
|
120
|
+
return "git reset --hard discards uncommitted work"
|
|
121
|
+
if sub == "clean" and (_short_flag_has(args, "f") or "--force" in args):
|
|
122
|
+
return "git clean -f deletes untracked files"
|
|
123
|
+
if sub == "branch":
|
|
124
|
+
forced = "-D" in args or ("--delete" in args and ("--force" in args or _short_flag_has(args, "f")))
|
|
125
|
+
if forced:
|
|
126
|
+
return "git branch -D deletes an unmerged branch"
|
|
127
|
+
if sub == "checkout":
|
|
128
|
+
if "-f" in args or "--force" in args:
|
|
129
|
+
return "git checkout -f discards local changes"
|
|
130
|
+
if "." in args:
|
|
131
|
+
return "git checkout . discards local changes"
|
|
132
|
+
if sub == "restore":
|
|
133
|
+
if "--worktree" in args or "." in args:
|
|
134
|
+
return "git restore discards local changes"
|
|
135
|
+
if sub == "push":
|
|
136
|
+
leased = any(a == "--force-with-lease" or a.startswith("--force-with-lease=") for a in args)
|
|
137
|
+
forced = (
|
|
138
|
+
"-f" in args
|
|
139
|
+
or "--force" in args
|
|
140
|
+
or any(a.startswith("+") and not a.startswith("++") for a in args)
|
|
141
|
+
)
|
|
142
|
+
if forced and not leased:
|
|
143
|
+
return "force push rewrites remote history"
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def classify(command: str) -> str | None:
|
|
148
|
+
for sub, args, _idx in git_invocations(tokenize(command)):
|
|
149
|
+
reason = classify_git(sub, args)
|
|
150
|
+
if reason:
|
|
151
|
+
return reason
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main(argv: list[str]) -> int:
|
|
156
|
+
if len(argv) == 3 and argv[1] == "--command":
|
|
157
|
+
reason = classify(argv[2])
|
|
158
|
+
if reason:
|
|
159
|
+
print(f"block: {reason}")
|
|
160
|
+
return 10
|
|
161
|
+
print("allow")
|
|
162
|
+
return 0
|
|
163
|
+
print(__doc__, file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main(sys.argv))
|