bigpowers 2.89.1 → 2.90.1
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 +19 -0
- package/extensions/omp-hooks.ts +8 -0
- package/package.json +1 -1
- package/scripts/ab-sandbox.sh +85 -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-arm-bigpowers.sh +42 -0
- package/scripts/lib/ab-eval.py +285 -0
- package/scripts/lib/ab-sandbox/agent.Dockerfile +33 -0
- package/scripts/lib/ab-sandbox/filter +6 -0
- package/scripts/lib/ab-sandbox/proxy.Dockerfile +13 -0
- package/scripts/lib/ab-sandbox/tinyproxy.conf +19 -0
- package/scripts/lib/ab_metrics.py +123 -0
- package/scripts/lib/ab_progress.py +103 -0
- package/scripts/lib/ab_report.py +116 -0
- package/scripts/lib/plan-consistency-check.sh +4 -2
- package/scripts/lib/run-benchmark.py +30 -16
- package/scripts/omp-smoke.ts +27 -1
- package/scripts/run-ab-eval.sh +7 -0
- package/scripts/run-story-verify.sh +7 -0
- package/scripts/validate-omp-extension.sh +22 -0
- package/tests/test_ab_ablation.py +91 -0
- package/tests/test_ab_eval.py +309 -0
- package/tests/test_ab_pi.py +63 -0
- package/tests/test_ab_progress.py +81 -0
- package/tests/test_ab_sandbox.py +94 -0
- package/tests/test_ab_score.py +113 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# story: e58s01 e58s02 e58s03 e58s06
|
|
3
|
+
"""A/B eval adapter output parsers: turn an agent CLI's stdout into cost, token and turn counts.
|
|
4
|
+
|
|
5
|
+
claude-json one result object (claude -p --output-format json)
|
|
6
|
+
opencode-jsonl one event per line (opencode run --format json); step_finish carries tokens + cost
|
|
7
|
+
pi-json one event per line (pi -p --mode json); assistant message_end carries usage + cost
|
|
8
|
+
none no metrics
|
|
9
|
+
|
|
10
|
+
tap_score reads `node --test` summaries for a task's partial-credit score (e58s03).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def empty_metrics() -> dict:
|
|
19
|
+
return {"cost_usd": None, "input_tokens": None, "output_tokens": None, "turns": None,
|
|
20
|
+
"models": None, "agent_reported_error": None}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def json_lines(stdout: str) -> list[dict]:
|
|
24
|
+
events = []
|
|
25
|
+
for line in stdout.strip().splitlines():
|
|
26
|
+
try:
|
|
27
|
+
obj = json.loads(line)
|
|
28
|
+
except json.JSONDecodeError:
|
|
29
|
+
continue
|
|
30
|
+
if isinstance(obj, dict):
|
|
31
|
+
events.append(obj)
|
|
32
|
+
return events
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_metrics(parser: str, stdout: str) -> dict:
|
|
36
|
+
if parser == "claude-json":
|
|
37
|
+
return parse_claude_json(stdout)
|
|
38
|
+
if parser == "opencode-jsonl":
|
|
39
|
+
return parse_opencode_jsonl(stdout)
|
|
40
|
+
if parser == "pi-json":
|
|
41
|
+
return parse_pi_json(stdout)
|
|
42
|
+
return empty_metrics()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_claude_json(stdout: str) -> dict:
|
|
46
|
+
metrics = empty_metrics()
|
|
47
|
+
result = next((e for e in reversed(json_lines(stdout)) if "num_turns" in e), None)
|
|
48
|
+
if result is None:
|
|
49
|
+
return metrics
|
|
50
|
+
input_tokens, output_tokens = token_totals(result)
|
|
51
|
+
metrics.update({
|
|
52
|
+
"cost_usd": result.get("total_cost_usd"),
|
|
53
|
+
"input_tokens": input_tokens,
|
|
54
|
+
"output_tokens": output_tokens,
|
|
55
|
+
"turns": result.get("num_turns"),
|
|
56
|
+
"models": sorted(result.get("modelUsage") or {}) or None,
|
|
57
|
+
"agent_reported_error": bool(result.get("is_error")),
|
|
58
|
+
})
|
|
59
|
+
return metrics
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def token_totals(result: dict) -> tuple[int | None, int | None]:
|
|
63
|
+
"""Whole-tree tokens from modelUsage (incl. subagents and cache), else main-loop usage."""
|
|
64
|
+
per_model = [m for m in (result.get("modelUsage") or {}).values() if "inputTokens" in m]
|
|
65
|
+
if per_model:
|
|
66
|
+
inputs = sum(m.get("inputTokens", 0) + m.get("cacheReadInputTokens", 0)
|
|
67
|
+
+ m.get("cacheCreationInputTokens", 0) for m in per_model)
|
|
68
|
+
return inputs, sum(m.get("outputTokens", 0) for m in per_model)
|
|
69
|
+
usage = result.get("usage") or {}
|
|
70
|
+
if "input_tokens" not in usage:
|
|
71
|
+
return None, usage.get("output_tokens")
|
|
72
|
+
inputs = (usage["input_tokens"] + usage.get("cache_read_input_tokens", 0)
|
|
73
|
+
+ usage.get("cache_creation_input_tokens", 0))
|
|
74
|
+
return inputs, usage.get("output_tokens")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_opencode_jsonl(stdout: str) -> dict:
|
|
78
|
+
"""Sum step_finish parts: tokens = {input, output, reasoning, cache: {read, write}}, cost in USD."""
|
|
79
|
+
metrics = empty_metrics()
|
|
80
|
+
events = json_lines(stdout)
|
|
81
|
+
steps = [(e.get("part") or {}) for e in events if e.get("type") == "step_finish"]
|
|
82
|
+
errored = any(e.get("type") == "error" for e in events)
|
|
83
|
+
if not steps:
|
|
84
|
+
metrics["agent_reported_error"] = errored or None
|
|
85
|
+
return metrics
|
|
86
|
+
cost = inputs = outputs = 0.0
|
|
87
|
+
for part in steps:
|
|
88
|
+
tokens = part.get("tokens") or {}
|
|
89
|
+
cache = tokens.get("cache") or {}
|
|
90
|
+
cost += part.get("cost") or 0
|
|
91
|
+
inputs += tokens.get("input", 0) + cache.get("read", 0) + cache.get("write", 0)
|
|
92
|
+
outputs += tokens.get("output", 0) + tokens.get("reasoning", 0)
|
|
93
|
+
metrics.update({"cost_usd": round(cost, 6), "input_tokens": int(inputs), "output_tokens": int(outputs),
|
|
94
|
+
"turns": len(steps), "agent_reported_error": errored})
|
|
95
|
+
return metrics
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_pi_json(stdout: str) -> dict:
|
|
99
|
+
metrics = empty_metrics()
|
|
100
|
+
events = json_lines(stdout)
|
|
101
|
+
replies = [e["message"] for e in events if e.get("type") == "message_end"
|
|
102
|
+
and (e.get("message") or {}).get("role") == "assistant"]
|
|
103
|
+
if not replies:
|
|
104
|
+
return metrics
|
|
105
|
+
aborted = any(e.get("type") == "agent_settled" and e.get("aborted") for e in events)
|
|
106
|
+
cost = inputs = outputs = 0.0
|
|
107
|
+
for msg in replies:
|
|
108
|
+
usage = msg.get("usage") or {}
|
|
109
|
+
cost += (usage.get("cost") or {}).get("total") or 0
|
|
110
|
+
inputs += usage.get("input", 0) + usage.get("cacheRead", 0) + usage.get("cacheWrite", 0)
|
|
111
|
+
outputs += usage.get("output", 0)
|
|
112
|
+
errored = aborted or any(m.get("stopReason") in ("error", "aborted") for m in replies)
|
|
113
|
+
metrics.update({"cost_usd": round(cost, 6), "input_tokens": int(inputs), "output_tokens": int(outputs),
|
|
114
|
+
"turns": len(replies), "agent_reported_error": errored})
|
|
115
|
+
return metrics
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def tap_score(stdout: str) -> float | None:
|
|
119
|
+
"""Share of tests passing over every TAP summary (`node --test --test-reporter=tap`: piped
|
|
120
|
+
output is the spec reporter otherwise). Cancelled tests (timeouts) count as failed."""
|
|
121
|
+
passed = sum(int(n) for n in re.findall(r"^# pass (\d+)", stdout, re.M))
|
|
122
|
+
failed = sum(int(n) for n in re.findall(r"^# (?:fail|cancelled) (\d+)", stdout, re.M))
|
|
123
|
+
return round(passed / (passed + failed), 4) if passed + failed else None
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# story: e58s05
|
|
3
|
+
"""Live view of an A/B eval while it runs: reads every ab-live.jsonl under the given folders
|
|
4
|
+
(one per shard) and prints runs, pass rate, score and cost per task and arm, each arm's delta
|
|
5
|
+
against vanilla with its bootstrap CI, and how those deltas evolved as runs accumulated.
|
|
6
|
+
|
|
7
|
+
Usage: python3 scripts/lib/ab_progress.py <out-dir>... [--watch SECONDS]
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
from ab_report import aggregate, mean # type: ignore[import] # noqa: E402
|
|
20
|
+
|
|
21
|
+
CHECKPOINTS = (5, 10, 20, 30, 50, 75, 100)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load(dirs: list[Path]) -> list[dict]:
|
|
25
|
+
records = []
|
|
26
|
+
for folder in dirs:
|
|
27
|
+
for live in sorted(folder.rglob("ab-live.jsonl")):
|
|
28
|
+
records += [json.loads(line) for line in live.read_text().splitlines() if line.strip()]
|
|
29
|
+
return records
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ordered(values: list[str], preferred: str) -> list[str]:
|
|
33
|
+
unique = sorted(set(values))
|
|
34
|
+
return [preferred] + [v for v in unique if v != preferred] if preferred in unique else unique
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fmt(value, places=2) -> str:
|
|
38
|
+
return "-" if value is None else f"{value:.{places}f}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def table(records: list[dict], tasks: list[str], arms: list[str]) -> list[str]:
|
|
42
|
+
lines = [f"{'task':<8} {'arm':<10} {'runs':>5} {'pass':>6} {'score':>6} {'cost$':>7} {'errors':>6}"]
|
|
43
|
+
for task in tasks:
|
|
44
|
+
for arm in arms:
|
|
45
|
+
runs = [r for r in records if r["task"] == task and r["arm"] == arm]
|
|
46
|
+
if runs:
|
|
47
|
+
lines.append(f"{task:<8} {arm:<10} {len(runs):>5} {fmt(mean([float(r['pass']) for r in runs])):>6} "
|
|
48
|
+
f"{fmt(mean([r.get('score') for r in runs])):>6} {fmt(mean([r['cost_usd'] for r in runs]), 3):>7} "
|
|
49
|
+
f"{sum(r['agent_error'] for r in runs):>6}")
|
|
50
|
+
return lines
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def delta_line(name: str, d: dict) -> str:
|
|
54
|
+
line = f" {name} - vanilla: pass {d['pass_rate']:+.2f} {d['ci95']}"
|
|
55
|
+
if "score" in d:
|
|
56
|
+
line += f" score {d['score']:+.3f} {d['score_ci95']}"
|
|
57
|
+
return line
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def evolution(records: list[dict], tasks: list[str], arms: list[str]) -> list[str]:
|
|
61
|
+
"""Deltas using only runs with index <= each checkpoint: shows the CI narrowing over time."""
|
|
62
|
+
top = max(r["run"] for r in records)
|
|
63
|
+
lines = []
|
|
64
|
+
for cp in [c for c in CHECKPOINTS if c < top] + [top]:
|
|
65
|
+
subset = [r for r in records if r["run"] <= cp]
|
|
66
|
+
for name, d in aggregate(subset, arms, tasks)["deltas_vs_vanilla"].items():
|
|
67
|
+
lines.append(f" runs<={cp:<4}{delta_line(name, d)[1:]}")
|
|
68
|
+
return lines
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render(records: list[dict]) -> str:
|
|
72
|
+
tasks = sorted({r["task"] for r in records})
|
|
73
|
+
arms = ordered([r["arm"] for r in records], "vanilla")
|
|
74
|
+
models = sorted({str(r.get("model")) for r in records})
|
|
75
|
+
out = [f"A/B progress — {len(records)} runs, model {', '.join(models)}, at {datetime.now():%H:%M:%S}", ""]
|
|
76
|
+
out += table(records, tasks, arms)
|
|
77
|
+
result = aggregate(records, arms, tasks)
|
|
78
|
+
if result["deltas_vs_vanilla"]:
|
|
79
|
+
out += ["", "deltas (all runs so far, 95% CI):"]
|
|
80
|
+
out += [delta_line(n, d) for n, d in result["deltas_vs_vanilla"].items()]
|
|
81
|
+
out += ["", "evolution (pass and score delta by run index):"] + evolution(records, tasks, arms)
|
|
82
|
+
return "\n".join(out)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv: list[str] | None = None) -> int:
|
|
86
|
+
p = argparse.ArgumentParser(description="Live A/B eval progress across shards")
|
|
87
|
+
p.add_argument("dirs", nargs="+", type=Path)
|
|
88
|
+
p.add_argument("--watch", type=int, help="repeat every N seconds")
|
|
89
|
+
args = p.parse_args(argv)
|
|
90
|
+
while True:
|
|
91
|
+
records = load(args.dirs)
|
|
92
|
+
if not records:
|
|
93
|
+
print("no ab-live.jsonl found under: " + ", ".join(map(str, args.dirs)))
|
|
94
|
+
return 2
|
|
95
|
+
print(render(records), flush=True)
|
|
96
|
+
if not args.watch:
|
|
97
|
+
return 0
|
|
98
|
+
time.sleep(args.watch)
|
|
99
|
+
print()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
sys.exit(main())
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# story: e58s01 e58s03 e58s04
|
|
3
|
+
"""A/B eval aggregation and reports — pure functions over run records (no agent, no subprocess
|
|
4
|
+
except reading the bigpowers git SHA). Split from ab-eval.py to keep both under the 300-line cap."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import random
|
|
9
|
+
import subprocess
|
|
10
|
+
from datetime import date
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
16
|
+
BOOTSTRAP_RESAMPLES = 1000
|
|
17
|
+
BOOTSTRAP_SEED = 58
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── aggregation ──────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def mean(values: list) -> float | None:
|
|
23
|
+
vals = [v for v in values if v is not None]
|
|
24
|
+
return round(sum(vals) / len(vals), 4) if vals else None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bootstrap_delta_ci(treatment: list[float], control: list[float]) -> list[float]:
|
|
28
|
+
"""Seeded 95% CI of mean(treatment) - mean(control); bools or scores in [0, 1]."""
|
|
29
|
+
rng = random.Random(BOOTSTRAP_SEED)
|
|
30
|
+
deltas = []
|
|
31
|
+
for _ in range(BOOTSTRAP_RESAMPLES):
|
|
32
|
+
t = [rng.choice(treatment) for _ in treatment]
|
|
33
|
+
c = [rng.choice(control) for _ in control]
|
|
34
|
+
deltas.append(sum(t) / len(t) - sum(c) / len(c))
|
|
35
|
+
deltas.sort()
|
|
36
|
+
lo = deltas[int(0.025 * BOOTSTRAP_RESAMPLES)]
|
|
37
|
+
hi = deltas[int(0.975 * BOOTSTRAP_RESAMPLES) - 1]
|
|
38
|
+
return [round(lo, 4), round(hi, 4)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def arm_summary(runs: list[dict]) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"runs": len(runs),
|
|
44
|
+
"pass_rate": mean([float(r["pass"]) for r in runs]),
|
|
45
|
+
"mean_score": mean([r.get("score") for r in runs]),
|
|
46
|
+
"tamper_rate": mean([float(r["tampered"]) for r in runs]),
|
|
47
|
+
"agent_error_rate": mean([float(r["agent_error"]) for r in runs]),
|
|
48
|
+
"mean_cost_usd": mean([r["cost_usd"] for r in runs]),
|
|
49
|
+
"mean_input_tokens": mean([r["input_tokens"] for r in runs]),
|
|
50
|
+
"mean_output_tokens": mean([r["output_tokens"] for r in runs]),
|
|
51
|
+
"mean_turns": mean([r["turns"] for r in runs]),
|
|
52
|
+
"mean_duration_s": mean([r["duration_s"] for r in runs]),
|
|
53
|
+
"mean_diff_lines": mean([r["diff_lines"] for r in runs]),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def aggregate(records: list[dict], arms: list[str], tasks: list[str]) -> dict:
|
|
58
|
+
by_arm = {a: [r for r in records if r["arm"] == a] for a in arms}
|
|
59
|
+
summary = {a: arm_summary(runs) for a, runs in by_arm.items()}
|
|
60
|
+
per_task = {t: {a: mean([float(r["pass"]) for r in by_arm[a] if r["task"] == t]) for a in arms}
|
|
61
|
+
for t in tasks}
|
|
62
|
+
per_task_score = {t: {a: mean([r.get("score") for r in by_arm[a] if r["task"] == t]) for a in arms}
|
|
63
|
+
for t in tasks}
|
|
64
|
+
deltas = {a: arm_delta(by_arm, summary, a) for a in arms if a != "vanilla"} if "vanilla" in arms else {}
|
|
65
|
+
return {"arms_summary": summary, "per_task": per_task, "per_task_score": per_task_score,
|
|
66
|
+
"delta": deltas.get("bigpowers"), "deltas_vs_vanilla": deltas}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def arm_delta(by_arm: dict, summary: dict, arm: str) -> dict:
|
|
70
|
+
"""arm - vanilla: pass rate and score with seeded bootstrap CIs, tamper rate and cost (e58s04)."""
|
|
71
|
+
tr, va = summary[arm], summary["vanilla"]
|
|
72
|
+
cost = None
|
|
73
|
+
if tr["mean_cost_usd"] is not None and va["mean_cost_usd"] is not None:
|
|
74
|
+
cost = round(tr["mean_cost_usd"] - va["mean_cost_usd"], 4)
|
|
75
|
+
delta = {
|
|
76
|
+
"pass_rate": round(tr["pass_rate"] - va["pass_rate"], 4),
|
|
77
|
+
"ci95": bootstrap_delta_ci([r["pass"] for r in by_arm[arm]], [r["pass"] for r in by_arm["vanilla"]]),
|
|
78
|
+
"tamper_rate": round(tr["tamper_rate"] - va["tamper_rate"], 4),
|
|
79
|
+
"cost_usd": cost,
|
|
80
|
+
}
|
|
81
|
+
scores = {a: [r["score"] for r in by_arm[a] if r.get("score") is not None] for a in (arm, "vanilla")}
|
|
82
|
+
if scores[arm] and scores["vanilla"]:
|
|
83
|
+
delta["score"] = round(tr["mean_score"] - va["mean_score"], 4)
|
|
84
|
+
delta["score_ci95"] = bootstrap_delta_ci(scores[arm], scores["vanilla"])
|
|
85
|
+
return delta
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── reporting ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def bigpowers_sha() -> str:
|
|
91
|
+
res = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=REPO_ROOT, capture_output=True, text=True)
|
|
92
|
+
return res.stdout.strip() or "unknown"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_reports(out: Path, meta: dict, result: dict, records: list[dict]) -> Path:
|
|
96
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
today = date.today().isoformat()
|
|
98
|
+
report = out / f"AB-{today}.yaml"
|
|
99
|
+
report.write_text(yaml.safe_dump({**meta, **result}, sort_keys=False))
|
|
100
|
+
with (out / f"ab-runs-{today}.jsonl").open("w") as f:
|
|
101
|
+
for rec in records:
|
|
102
|
+
f.write(json.dumps({**rec, "bigpowers_sha": meta["bigpowers_sha"]}) + "\n")
|
|
103
|
+
return report
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def print_summary(result: dict) -> None:
|
|
107
|
+
print(f"{'arm':<10} {'pass':>6} {'score':>6} {'tamper':>7} {'cost$':>8} {'turns':>6} {'secs':>7}")
|
|
108
|
+
for arm, s in result["arms_summary"].items():
|
|
109
|
+
fmt = lambda v, p=2: "-" if v is None else f"{v:.{p}f}"
|
|
110
|
+
print(f"{arm:<10} {fmt(s['pass_rate']):>6} {fmt(s['mean_score']):>6} {fmt(s['tamper_rate']):>7} "
|
|
111
|
+
f"{fmt(s['mean_cost_usd'], 3):>8} {fmt(s['mean_turns'], 1):>6} {fmt(s['mean_duration_s'], 0):>7}")
|
|
112
|
+
for arm, d in result.get("deltas_vs_vanilla", {}).items():
|
|
113
|
+
line = f"delta {arm:<10} - vanilla: pass {d['pass_rate']:+.2f} CI {d['ci95']}"
|
|
114
|
+
if "score" in d:
|
|
115
|
+
line += f" score {d['score']:+.2f} CI {d['score_ci95']}"
|
|
116
|
+
print(line)
|
|
@@ -69,7 +69,7 @@ for tasks in "${TASKS[@]+"${TASKS[@]}"}"; do
|
|
|
69
69
|
sid="$(echo "$base" | grep -oE 'e[0-9]+s[0-9]+' | head -1)"
|
|
70
70
|
[[ -n "$sid" ]] || continue
|
|
71
71
|
|
|
72
|
-
spec_name="$(grep -E '^spec:' "$tasks" | head -1 | sed -E 's/^spec:[[:space:]]*//; s/^"(.*)"$/\1/')"
|
|
72
|
+
spec_name="$(grep -E '^spec:' "$tasks" | head -1 | sed -E 's/^spec:[[:space:]]*//; s/^"(.*)"$/\1/' || true)"
|
|
73
73
|
spec="$CAPSULE/$spec_name"
|
|
74
74
|
if [[ -z "$spec_name" || ! -f "$spec" ]]; then
|
|
75
75
|
report CRITICAL "Story $sid tasks.yaml declares no valid spec: file"
|
|
@@ -92,7 +92,9 @@ for tasks in "${TASKS[@]+"${TASKS[@]}"}"; do
|
|
|
92
92
|
report CRITICAL "Story $sid tasks.yaml missing runnable verify: commands"
|
|
93
93
|
else
|
|
94
94
|
verify_cmd="$("$PYTHON_BIN" "$SCRIPT_LIB_DIR/extract-story-verify.py" "$REPO_ROOT" 2>/dev/null \
|
|
95
|
-
| awk -F'\t' -v s="$sid" '$1 == s { print $4;
|
|
95
|
+
| awk -F'\t' -v s="$sid" '$1 == s && !found { print $4; found = 1 }')"
|
|
96
|
+
# awk reads to the end: exiting early closes the pipe, Python fails to flush and exits 120,
|
|
97
|
+
# and pipefail turns that into a random failure of this check.
|
|
96
98
|
if [[ -n "$verify_cmd" ]]; then
|
|
97
99
|
is_executable_verify "$verify_cmd" \
|
|
98
100
|
|| report CRITICAL "Story $sid verify: is prose, not a runnable command: $verify_cmd"
|
|
@@ -60,26 +60,44 @@ def scenario_pass_rate(scenario: dict, runs: int, with_skill: bool) -> tuple[flo
|
|
|
60
60
|
if not command:
|
|
61
61
|
return (-1.0, "missing_command")
|
|
62
62
|
if not with_skill:
|
|
63
|
-
|
|
63
|
+
# No agent runs here, so there is no honest without-skill number (e58s01).
|
|
64
|
+
# Real with/without deltas come from scripts/run-ab-eval.sh.
|
|
65
|
+
return (-1.0, "unmeasured")
|
|
64
66
|
passes = sum(1 for _ in range(runs) if run_code_grader(command, REPO_ROOT))
|
|
65
67
|
return (passes / runs, "code")
|
|
66
68
|
|
|
67
69
|
|
|
68
|
-
def pass_at_k(scenarios: list[dict], runs: int, with_skill: bool) -> float:
|
|
70
|
+
def pass_at_k(scenarios: list[dict], runs: int, with_skill: bool) -> float | None:
|
|
69
71
|
total_weight = 0.0
|
|
70
72
|
weighted = 0.0
|
|
71
73
|
for sc in scenarios:
|
|
72
74
|
rate, status = scenario_pass_rate(sc, runs, with_skill)
|
|
73
|
-
if status
|
|
75
|
+
if status in ("pending_agent_eval", "unmeasured"):
|
|
74
76
|
continue
|
|
75
77
|
weight = float(sc.get("weight", 1.0))
|
|
76
78
|
total_weight += weight
|
|
77
79
|
weighted += weight * max(rate, 0.0)
|
|
78
80
|
if total_weight == 0:
|
|
79
|
-
return
|
|
81
|
+
return None
|
|
80
82
|
return round(weighted / total_weight, 2)
|
|
81
83
|
|
|
82
84
|
|
|
85
|
+
def delta_of(with_score: float | None, without_score: float | None) -> float | None:
|
|
86
|
+
if with_score is None or without_score is None:
|
|
87
|
+
return None
|
|
88
|
+
return round(with_score - without_score, 2)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def delta_summary(val_delta: float | None) -> str:
|
|
92
|
+
if val_delta is None:
|
|
93
|
+
return "UNMEASURED: no without-skill agent run — use scripts/run-ab-eval.sh for a real delta"
|
|
94
|
+
if val_delta < 0:
|
|
95
|
+
return f"REGRESSION: validation Δ {val_delta} — do NOT ship"
|
|
96
|
+
if val_delta < 0.05:
|
|
97
|
+
return f"WARN: validation Δ {val_delta} — marginal contribution"
|
|
98
|
+
return f"OK: validation Δ {val_delta}"
|
|
99
|
+
|
|
100
|
+
|
|
83
101
|
def partition_scenarios(scenarios: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
84
102
|
train, validation = [], []
|
|
85
103
|
for sc in scenarios:
|
|
@@ -93,13 +111,13 @@ def partition_scenarios(scenarios: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
|
93
111
|
|
|
94
112
|
def build_split_report(scenarios: list[dict], runs: int) -> dict:
|
|
95
113
|
if not scenarios:
|
|
96
|
-
return {"with_skill":
|
|
114
|
+
return {"with_skill": None, "without_skill": None, "delta": None, "scenarios": []}
|
|
97
115
|
with_score = pass_at_k(scenarios, runs, with_skill=True)
|
|
98
116
|
without_score = pass_at_k(scenarios, runs, with_skill=False)
|
|
99
117
|
return {
|
|
100
118
|
"with_skill": with_score,
|
|
101
119
|
"without_skill": without_score,
|
|
102
|
-
"delta":
|
|
120
|
+
"delta": delta_of(with_score, without_score),
|
|
103
121
|
"scenarios": [sc["id"] for sc in scenarios],
|
|
104
122
|
}
|
|
105
123
|
|
|
@@ -119,13 +137,15 @@ def scenario_details(scenarios: list[dict], runs: int) -> list[dict]:
|
|
|
119
137
|
}
|
|
120
138
|
)
|
|
121
139
|
continue
|
|
140
|
+
with_value = round(max(with_rate, 0.0), 2)
|
|
141
|
+
without_value = None if without_rate < 0 else round(without_rate, 2)
|
|
122
142
|
rows.append(
|
|
123
143
|
{
|
|
124
144
|
"id": sc["id"],
|
|
125
145
|
"split": sc.get("split", "validation"),
|
|
126
|
-
"with_pass_rate":
|
|
127
|
-
"without_pass_rate":
|
|
128
|
-
"delta":
|
|
146
|
+
"with_pass_rate": with_value,
|
|
147
|
+
"without_pass_rate": without_value,
|
|
148
|
+
"delta": delta_of(with_value, without_value),
|
|
129
149
|
"weight": sc.get("weight", 1.0),
|
|
130
150
|
}
|
|
131
151
|
)
|
|
@@ -180,13 +200,7 @@ def run_benchmark(skill: str, baseline: bool = False) -> dict:
|
|
|
180
200
|
shutil.copy(yaml_path, REPORTS_DIR / f"BASELINE-{skill}.yaml")
|
|
181
201
|
shutil.copy(json_path, REPORTS_DIR / f"baseline-{skill}.json")
|
|
182
202
|
print(f"Pinned baseline for {skill}")
|
|
183
|
-
|
|
184
|
-
if val_delta < 0:
|
|
185
|
-
print(f"REGRESSION: validation Δ {val_delta} — do NOT ship")
|
|
186
|
-
elif val_delta < 0.05:
|
|
187
|
-
print(f"WARN: validation Δ {val_delta} — marginal contribution")
|
|
188
|
-
else:
|
|
189
|
-
print(f"OK: validation Δ {val_delta}")
|
|
203
|
+
print(delta_summary(report["validation"]["delta"]))
|
|
190
204
|
return report
|
|
191
205
|
|
|
192
206
|
|
package/scripts/omp-smoke.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// fails here exactly as it does in real pi, instead of silently passing.
|
|
11
11
|
// See pi 0.85.1 src/core/extensions/loader.ts (createExtensionRuntime).
|
|
12
12
|
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
13
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
14
16
|
import bigpowers from "../extensions/omp-hooks.ts";
|
|
15
17
|
|
|
16
18
|
type AnyObj = Record<string, unknown>;
|
|
@@ -118,3 +120,27 @@ console.log(JSON.stringify({
|
|
|
118
120
|
sendCalls,
|
|
119
121
|
runDetails: runResult.details,
|
|
120
122
|
}));
|
|
123
|
+
|
|
124
|
+
// Silent-probe check (BUG-2026-11-03-omp-hooks-stderr-leak, issue #189).
|
|
125
|
+
// The tool_call handler resolves the branch of the directory the command will
|
|
126
|
+
// run in. When that directory is not a repository, a git probe that inherits
|
|
127
|
+
// fd 2 paints "fatal: not a git repository" over the TUI. The handler must be
|
|
128
|
+
// invisible: validate-omp-extension.sh runs this mode with stderr captured and
|
|
129
|
+
// fails on any leaked git noise.
|
|
130
|
+
if (process.env.OMP_SMOKE_SILENT_TOOL_CALL === "1") {
|
|
131
|
+
const toolCall = events.get("tool_call");
|
|
132
|
+
if (typeof toolCall !== "function") throw new Error("tool_call handler not registered");
|
|
133
|
+
const nonRepo = mkdtempSync(join(tmpdir(), "omp-nonrepo-"));
|
|
134
|
+
try {
|
|
135
|
+
const verdict = await toolCall({
|
|
136
|
+
toolName: "bash",
|
|
137
|
+
input: { command: `cd ${nonRepo} && ls` },
|
|
138
|
+
});
|
|
139
|
+
// Fail-closed contract: a harmless command in a non-repo dir is not blocked.
|
|
140
|
+
if (verdict && (verdict as { block?: boolean }).block) {
|
|
141
|
+
throw new Error(`harmless command in a non-repo dir was blocked: ${JSON.stringify(verdict)}`);
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(nonRepo, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# story: e58s01
|
|
3
|
+
# run-ab-eval.sh — run the same tasks with and without bigpowers; grade only the outcome.
|
|
4
|
+
# Advisory: exits 0 after a completed run, 2 on configuration errors. See specs/benchmarks/SCHEMA.md.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
source "$(dirname "${BASH_SOURCE[0]}")/lib/python-env.sh"
|
|
7
|
+
exec "$PYTHON" "$(dirname "${BASH_SOURCE[0]}")/lib/ab-eval.py" "$@"
|
|
@@ -82,6 +82,13 @@ run_story_verify() {
|
|
|
82
82
|
fi
|
|
83
83
|
echo "FAIL: $sid [$src]$note — $cmd"
|
|
84
84
|
echo " output: $(echo "$RUN_OUT" | tail -3 | tr '\n' ' ')"
|
|
85
|
+
# Name the failing test. unittest/pytest footers ("FAILED (failures=1)") say a test
|
|
86
|
+
# broke but not which one, so a red gate is undiagnosable from CI logs alone
|
|
87
|
+
# (BUG-2026-11-03-story-verify-output-hides-failing-test).
|
|
88
|
+
local detail
|
|
89
|
+
detail="$(echo "$RUN_OUT" | grep -E '^(FAIL|ERROR): |\.\.\. (FAIL|ERROR)$|^AssertionError|^[A-Za-z]*Error: |^Traceback' \
|
|
90
|
+
| head -3 | tr '\n' ' ')"
|
|
91
|
+
[ -n "$detail" ] && echo " detail: $detail"
|
|
85
92
|
FAIL=$((FAIL + 1))
|
|
86
93
|
return 1
|
|
87
94
|
}
|
|
@@ -121,6 +121,28 @@ else
|
|
|
121
121
|
rm -f "$SMOKE_ERR"
|
|
122
122
|
fi
|
|
123
123
|
|
|
124
|
+
# ── 7. tool_call is silent outside a repo (BUG-2026-11-03, issue #189) ────────
|
|
125
|
+
# A guard that inspects the command must not paint git's stderr on the agent
|
|
126
|
+
# TTY. The probe runs the REAL tool_call handler with a command that cds into a
|
|
127
|
+
# directory that is not a repository; any leaked "fatal:" lands on this
|
|
128
|
+
# script's captured stderr because execSync inherits fd 2.
|
|
129
|
+
echo "--- [OMP] tool_call stays quiet outside a git repo ---"
|
|
130
|
+
if [[ ! -f "$SMOKE" ]] || ! command -v node >/dev/null 2>&1; then
|
|
131
|
+
echo "warn: node or $SMOKE unavailable — skipping the silent-probe check"
|
|
132
|
+
else
|
|
133
|
+
SILENT_ERR=$(mktemp)
|
|
134
|
+
if OMP_SMOKE_SILENT_TOOL_CALL=1 node "$SMOKE" >/dev/null 2>"$SILENT_ERR"; then
|
|
135
|
+
if grep -qi 'fatal: not a git repository' "$SILENT_ERR"; then
|
|
136
|
+
ta_err "tool_call leaked git stderr onto the TUI: $(grep -i -m1 'fatal: not a git repository' "$SILENT_ERR")"
|
|
137
|
+
else
|
|
138
|
+
ta_ok "tool_call writes nothing to stderr for a non-repo target"
|
|
139
|
+
fi
|
|
140
|
+
else
|
|
141
|
+
ta_err "silent tool_call probe failed — $(head -3 "$SILENT_ERR" 2>/dev/null | tr '\n' ' ')"
|
|
142
|
+
fi
|
|
143
|
+
rm -f "$SILENT_ERR"
|
|
144
|
+
fi
|
|
145
|
+
|
|
124
146
|
# ── Summary ────────────────────────────────────────────────────────────────
|
|
125
147
|
echo "---"
|
|
126
148
|
if [[ "$ERRORS" -eq 0 ]]; then
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# story: e58s04
|
|
3
|
+
"""tests/test_ab_ablation.py — ablation arms: skill subsets, seed choice, per-arm deltas.
|
|
4
|
+
|
|
5
|
+
Run: python3 tests/test_ab_ablation.py
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
16
|
+
from test_ab_eval import REPO_ROOT, rep # noqa: E402
|
|
17
|
+
|
|
18
|
+
ARM_SCRIPT = REPO_ROOT / "scripts" / "lib" / "ab-arm-bigpowers.sh"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestArmSetup(unittest.TestCase):
|
|
22
|
+
def setup_arm(self, **env):
|
|
23
|
+
work = Path(tempfile.mkdtemp(prefix="ab-arm-"))
|
|
24
|
+
self.addCleanup(shutil.rmtree, work, ignore_errors=True)
|
|
25
|
+
full_env = {**os.environ, "AB_BP_ROOT": str(REPO_ROOT), **env}
|
|
26
|
+
subprocess.run(["bash", str(ARM_SCRIPT)], cwd=work, env=full_env, check=True, capture_output=True)
|
|
27
|
+
return work
|
|
28
|
+
|
|
29
|
+
def skills(self, work, folder):
|
|
30
|
+
path = work / folder / "skills"
|
|
31
|
+
return sorted(p.name for p in path.iterdir()) if path.is_dir() else []
|
|
32
|
+
|
|
33
|
+
def test_default_installs_every_skill_and_the_seed(self):
|
|
34
|
+
work = self.setup_arm()
|
|
35
|
+
self.assertEqual(len(self.skills(work, ".agents")), len(list((REPO_ROOT / "skills").glob("*/"))))
|
|
36
|
+
self.assertTrue((work / "AGENTS.md").is_file())
|
|
37
|
+
self.assertTrue((work / "scripts").is_dir())
|
|
38
|
+
|
|
39
|
+
def test_skill_subset(self):
|
|
40
|
+
work = self.setup_arm(AB_BP_SKILLS="develop-tdd,verify-work")
|
|
41
|
+
self.assertEqual(self.skills(work, ".agents"), ["develop-tdd", "verify-work"])
|
|
42
|
+
self.assertEqual(self.skills(work, ".claude"), ["develop-tdd", "verify-work"])
|
|
43
|
+
self.assertTrue((work / "scripts").is_dir())
|
|
44
|
+
|
|
45
|
+
def test_unknown_skill_fails_setup(self):
|
|
46
|
+
with self.assertRaises(subprocess.CalledProcessError):
|
|
47
|
+
self.setup_arm(AB_BP_SKILLS="no-such-skill")
|
|
48
|
+
|
|
49
|
+
def test_seed_only(self):
|
|
50
|
+
work = self.setup_arm(AB_BP_SKILLS="none")
|
|
51
|
+
self.assertTrue((work / "AGENTS.md").is_file())
|
|
52
|
+
self.assertEqual(self.skills(work, ".agents"), [])
|
|
53
|
+
self.assertFalse((work / "scripts").exists())
|
|
54
|
+
|
|
55
|
+
def test_skills_without_seed(self):
|
|
56
|
+
work = self.setup_arm(AB_BP_SEED="none")
|
|
57
|
+
self.assertFalse((work / "AGENTS.md").exists())
|
|
58
|
+
self.assertFalse((work / "CLAUDE.md").exists())
|
|
59
|
+
self.assertGreater(len(self.skills(work, ".agents")), 0)
|
|
60
|
+
|
|
61
|
+
def test_other_seed_file(self):
|
|
62
|
+
work = self.setup_arm(AB_BP_SKILLS="none", AB_BP_SEED="specs/benchmarks/ab/arms/tdd-line/AGENTS.md")
|
|
63
|
+
self.assertNotIn("bigpowers", (work / "AGENTS.md").read_text().lower())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestDeltasVsVanilla(unittest.TestCase):
|
|
67
|
+
def rec(self, arm, passed, score=None):
|
|
68
|
+
return {"arm": arm, "task": "t", "pass": passed, "score": score, "tampered": False, "agent_error": False,
|
|
69
|
+
"cost_usd": 0.01, "input_tokens": None, "output_tokens": None, "turns": None,
|
|
70
|
+
"duration_s": 1.0, "diff_lines": 1}
|
|
71
|
+
|
|
72
|
+
def test_every_arm_gets_a_delta(self):
|
|
73
|
+
records = [self.rec("vanilla", False, 0.5), self.rec("vanilla", True, 1.0),
|
|
74
|
+
self.rec("bigpowers", True, 1.0), self.rec("bigpowers", True, 1.0),
|
|
75
|
+
self.rec("bp-seed", False, 0.5), self.rec("bp-seed", False, 0.5)]
|
|
76
|
+
result = rep.aggregate(records, ["vanilla", "bigpowers", "bp-seed"], ["t"])
|
|
77
|
+
deltas = result["deltas_vs_vanilla"]
|
|
78
|
+
self.assertEqual(sorted(deltas), ["bigpowers", "bp-seed"])
|
|
79
|
+
self.assertEqual(deltas["bigpowers"]["pass_rate"], 0.5)
|
|
80
|
+
self.assertEqual(deltas["bp-seed"]["pass_rate"], -0.5)
|
|
81
|
+
self.assertEqual(deltas["bp-seed"]["score"], -0.25)
|
|
82
|
+
self.assertEqual(len(deltas["bp-seed"]["ci95"]), 2)
|
|
83
|
+
self.assertEqual(result["delta"], deltas["bigpowers"])
|
|
84
|
+
|
|
85
|
+
def test_no_vanilla_means_no_deltas(self):
|
|
86
|
+
result = rep.aggregate([self.rec("bigpowers", True)], ["bigpowers"], ["t"])
|
|
87
|
+
self.assertEqual(result["deltas_vs_vanilla"], {})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
unittest.main(verbosity=1)
|