bigpowers 2.89.1 → 2.90.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 +12 -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/run-ab-eval.sh +7 -0
- package/scripts/run-story-verify.sh +7 -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,309 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# story: e58s01
|
|
3
|
+
"""tests/test_ab_eval.py — A/B eval runner (vanilla vs bigpowers) with a fake adapter.
|
|
4
|
+
|
|
5
|
+
Run: python3 tests/test_ab_eval.py
|
|
6
|
+
No agent is called: every adapter here is a small shell command.
|
|
7
|
+
"""
|
|
8
|
+
import importlib.util
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import unittest
|
|
16
|
+
from contextlib import redirect_stdout
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
22
|
+
LIB = REPO_ROOT / "scripts" / "lib"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_module(name: str, filename: str):
|
|
26
|
+
spec = importlib.util.spec_from_file_location(name, LIB / filename)
|
|
27
|
+
assert spec is not None and spec.loader is not None
|
|
28
|
+
mod = importlib.util.module_from_spec(spec)
|
|
29
|
+
sys.modules[name] = mod
|
|
30
|
+
spec.loader.exec_module(mod)
|
|
31
|
+
return mod
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
ab = load_module("ab_eval", "ab-eval.py")
|
|
35
|
+
rep = load_module("ab_report", "ab_report.py")
|
|
36
|
+
met = load_module("ab_metrics", "ab_metrics.py")
|
|
37
|
+
bench = load_module("run_benchmark", "run-benchmark.py")
|
|
38
|
+
|
|
39
|
+
SOLVE_IF_SKILLS = 'if [ -d .claude/skills ]; then echo 42 > answer.txt; fi'
|
|
40
|
+
CLAUDE_JSON = json.dumps({
|
|
41
|
+
"type": "result", "subtype": "success", "is_error": False, "num_turns": 7,
|
|
42
|
+
"total_cost_usd": 0.1234, "usage": {"input_tokens": 1500, "output_tokens": 300},
|
|
43
|
+
"modelUsage": {"claude-opus-5-5": {"costUSD": 0.1}, "claude-haiku-5-5": {"costUSD": 0.0234}},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Sandbox(unittest.TestCase):
|
|
48
|
+
"""Builds a throwaway config + one task, so tests never touch specs/benchmarks/ab/."""
|
|
49
|
+
|
|
50
|
+
def setUp(self):
|
|
51
|
+
self.tmp = Path(tempfile.mkdtemp(prefix="ab-test-"))
|
|
52
|
+
self.tasks_dir = self.tmp / "tasks"
|
|
53
|
+
task = self.tasks_dir / "t-01"
|
|
54
|
+
(task / "fixture" / "test").mkdir(parents=True)
|
|
55
|
+
(task / "fixture" / "test" / "a.test").write_text("keep me\n")
|
|
56
|
+
(task / "hidden").mkdir()
|
|
57
|
+
(task / "hidden" / "secret.check").write_text('test "$(cat answer.txt 2>/dev/null)" = 42\n')
|
|
58
|
+
(task / "task.yaml").write_text(yaml.safe_dump({
|
|
59
|
+
"id": "t-01", "title": "answer", "kind": "feature",
|
|
60
|
+
"prompt": "Write 42 to answer.txt",
|
|
61
|
+
"protected": ["test/a.test"],
|
|
62
|
+
"grade": "sh secret.check",
|
|
63
|
+
"timeout_s": 30,
|
|
64
|
+
}))
|
|
65
|
+
self.config = self.tmp / "config.yaml"
|
|
66
|
+
self.config.write_text(yaml.safe_dump({
|
|
67
|
+
"k": 1,
|
|
68
|
+
"arms": {"vanilla": {"setup": "true"}, "bigpowers": {"setup": "mkdir -p .claude/skills"}},
|
|
69
|
+
"adapters": {"claude": {"command": "false", "parser": "claude-json"}},
|
|
70
|
+
}))
|
|
71
|
+
self.out = self.tmp / "out"
|
|
72
|
+
|
|
73
|
+
def tearDown(self):
|
|
74
|
+
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
75
|
+
|
|
76
|
+
def run_main(self, *extra):
|
|
77
|
+
argv = ["--config", str(self.config), "--tasks-dir", str(self.tasks_dir),
|
|
78
|
+
"--out", str(self.out), *extra]
|
|
79
|
+
buf = io.StringIO()
|
|
80
|
+
with redirect_stdout(buf):
|
|
81
|
+
code = ab.main(argv)
|
|
82
|
+
return code, buf.getvalue()
|
|
83
|
+
|
|
84
|
+
def records(self):
|
|
85
|
+
lines = next(self.out.glob("ab-runs-*.jsonl")).read_text().splitlines()
|
|
86
|
+
return [json.loads(line) for line in lines]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestFairComparison(Sandbox):
|
|
90
|
+
def test_skill_dependent_agent_shows_full_delta(self):
|
|
91
|
+
code, _ = self.run_main("--adapter-cmd", SOLVE_IF_SKILLS, "--k", "2")
|
|
92
|
+
self.assertEqual(code, 0)
|
|
93
|
+
report = yaml.safe_load(next(self.out.glob("AB-*.yaml")).read_text())
|
|
94
|
+
self.assertEqual(report["arms_summary"]["bigpowers"]["pass_rate"], 1.0)
|
|
95
|
+
self.assertEqual(report["arms_summary"]["vanilla"]["pass_rate"], 0.0)
|
|
96
|
+
self.assertEqual(report["delta"]["pass_rate"], 1.0)
|
|
97
|
+
self.assertEqual(report["per_task"]["t-01"]["bigpowers"], 1.0)
|
|
98
|
+
|
|
99
|
+
def test_hidden_tests_are_not_visible_to_agent(self):
|
|
100
|
+
peek = "find . -name 'secret*' | grep -q . && exit 1; echo 42 > answer.txt"
|
|
101
|
+
self.run_main("--adapter-cmd", peek, "--arms", "vanilla")
|
|
102
|
+
self.assertTrue(all(r["pass"] for r in self.records()))
|
|
103
|
+
|
|
104
|
+
def test_workdir_lives_outside_repo(self):
|
|
105
|
+
where = f"pwd > {self.tmp}/where.txt"
|
|
106
|
+
self.run_main("--adapter-cmd", where, "--arms", "vanilla")
|
|
107
|
+
workdir = Path((self.tmp / "where.txt").read_text().strip()).resolve()
|
|
108
|
+
self.assertNotIn(str(REPO_ROOT.resolve()), str(workdir))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestRunRecord(Sandbox):
|
|
112
|
+
def test_deleting_protected_test_is_tampering(self):
|
|
113
|
+
self.run_main("--adapter-cmd", "rm test/a.test; echo 42 > answer.txt", "--arms", "vanilla")
|
|
114
|
+
rec = self.records()[0]
|
|
115
|
+
self.assertTrue(rec["tampered"])
|
|
116
|
+
self.assertTrue(rec["pass"])
|
|
117
|
+
|
|
118
|
+
def test_untouched_protected_test_is_not_tampering(self):
|
|
119
|
+
self.run_main("--adapter-cmd", "echo 42 > answer.txt", "--arms", "vanilla")
|
|
120
|
+
self.assertFalse(self.records()[0]["tampered"])
|
|
121
|
+
|
|
122
|
+
def test_appending_tests_is_not_tampering(self): # TDD arms add tests to visible files
|
|
123
|
+
self.run_main("--adapter-cmd", "echo more >> test/a.test; echo 42 > answer.txt", "--arms", "vanilla")
|
|
124
|
+
rec = self.records()[0]
|
|
125
|
+
self.assertFalse(rec["tampered"])
|
|
126
|
+
self.assertEqual(rec["protected_lines_added"], 1)
|
|
127
|
+
|
|
128
|
+
def test_editing_a_protected_line_is_tampering(self):
|
|
129
|
+
self.run_main("--adapter-cmd", "echo weak > test/a.test; echo 42 > answer.txt", "--arms", "vanilla")
|
|
130
|
+
self.assertTrue(self.records()[0]["tampered"])
|
|
131
|
+
|
|
132
|
+
def test_diff_lines_count_agent_changes_only(self):
|
|
133
|
+
self.run_main("--adapter-cmd", "printf '42\\nx\\n' > answer.txt", "--arms", "bigpowers")
|
|
134
|
+
self.assertEqual(self.records()[0]["diff_lines"], 2)
|
|
135
|
+
|
|
136
|
+
def test_agent_failure_is_recorded_not_fatal(self):
|
|
137
|
+
code, _ = self.run_main("--adapter-cmd", "exit 1", "--arms", "vanilla")
|
|
138
|
+
self.assertEqual(code, 0)
|
|
139
|
+
rec = self.records()[0]
|
|
140
|
+
self.assertTrue(rec["agent_error"])
|
|
141
|
+
self.assertFalse(rec["pass"])
|
|
142
|
+
|
|
143
|
+
def test_prompt_reaches_adapter(self):
|
|
144
|
+
self.run_main("--adapter-cmd", 'printf "%s" "$AB_PROMPT" > got.txt; cp got.txt ' + str(self.tmp),
|
|
145
|
+
"--arms", "vanilla")
|
|
146
|
+
self.assertEqual((self.tmp / "got.txt").read_text(), "Write 42 to answer.txt")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TestMetrics(unittest.TestCase):
|
|
150
|
+
def test_claude_json_fields_are_parsed(self):
|
|
151
|
+
m = met.parse_metrics("claude-json", "noise line\n" + CLAUDE_JSON + "\n")
|
|
152
|
+
self.assertEqual(m["cost_usd"], 0.1234)
|
|
153
|
+
self.assertEqual(m["input_tokens"], 1500)
|
|
154
|
+
self.assertEqual(m["output_tokens"], 300)
|
|
155
|
+
self.assertEqual(m["turns"], 7)
|
|
156
|
+
self.assertEqual(m["models"], ["claude-haiku-5-5", "claude-opus-5-5"])
|
|
157
|
+
self.assertFalse(m["agent_reported_error"])
|
|
158
|
+
|
|
159
|
+
def test_unparseable_output_gives_nulls(self):
|
|
160
|
+
m = met.parse_metrics("claude-json", "not json")
|
|
161
|
+
self.assertIsNone(m["cost_usd"])
|
|
162
|
+
|
|
163
|
+
def test_bootstrap_is_deterministic_and_brackets_estimate(self):
|
|
164
|
+
a = [True, True, False, True, True, False]
|
|
165
|
+
b = [False, True, False, False, True, False]
|
|
166
|
+
ci1 = rep.bootstrap_delta_ci(a, b)
|
|
167
|
+
ci2 = rep.bootstrap_delta_ci(a, b)
|
|
168
|
+
self.assertEqual(ci1, ci2)
|
|
169
|
+
self.assertLessEqual(ci1[0], 2 / 6)
|
|
170
|
+
self.assertGreaterEqual(ci1[1], 2 / 6)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
OPENCODE_JSONL = "\n".join(json.dumps(e) for e in [
|
|
174
|
+
{"type": "step_start", "part": {"type": "step-start"}},
|
|
175
|
+
{"type": "step_finish", "part": {"type": "step-finish", "cost": 0.01,
|
|
176
|
+
"tokens": {"input": 100, "output": 20, "reasoning": 5,
|
|
177
|
+
"cache": {"read": 300, "write": 50}}}},
|
|
178
|
+
{"type": "text", "part": {"type": "text", "text": "done"}},
|
|
179
|
+
{"type": "step_finish", "part": {"type": "step-finish", "cost": 0.02,
|
|
180
|
+
"tokens": {"input": 10, "output": 2, "reasoning": 0,
|
|
181
|
+
"cache": {"read": 400, "write": 0}}}},
|
|
182
|
+
])
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TestOpencodeParser(unittest.TestCase):
|
|
186
|
+
def test_opencode_jsonl_sums_steps(self):
|
|
187
|
+
m = met.parse_metrics("opencode-jsonl", "log noise\n" + OPENCODE_JSONL + "\n")
|
|
188
|
+
self.assertAlmostEqual(m["cost_usd"], 0.03)
|
|
189
|
+
self.assertEqual(m["input_tokens"], 100 + 300 + 50 + 10 + 400)
|
|
190
|
+
self.assertEqual(m["output_tokens"], 20 + 5 + 2)
|
|
191
|
+
self.assertEqual(m["turns"], 2)
|
|
192
|
+
self.assertFalse(m["agent_reported_error"])
|
|
193
|
+
|
|
194
|
+
def test_opencode_error_event_is_agent_error(self):
|
|
195
|
+
out = OPENCODE_JSONL + "\n" + json.dumps({"type": "error", "error": {"name": "APIError"}})
|
|
196
|
+
self.assertTrue(met.parse_metrics("opencode-jsonl", out)["agent_reported_error"])
|
|
197
|
+
|
|
198
|
+
def test_opencode_without_steps_gives_nulls(self):
|
|
199
|
+
self.assertIsNone(met.parse_metrics("opencode-jsonl", "")["cost_usd"])
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestRunEnvironment(Sandbox):
|
|
203
|
+
def test_each_run_gets_fresh_home_removed_afterwards(self):
|
|
204
|
+
cmd = (f'ls -A "$AB_HOME" | wc -l | tr -d " " > {self.tmp}/n.txt; '
|
|
205
|
+
f'echo "$AB_HOME" > {self.tmp}/home.txt; echo 42 > answer.txt')
|
|
206
|
+
self.run_main("--adapter-cmd", cmd, "--arms", "vanilla")
|
|
207
|
+
self.assertEqual((self.tmp / "n.txt").read_text().strip(), "0")
|
|
208
|
+
home = Path((self.tmp / "home.txt").read_text().strip())
|
|
209
|
+
self.assertTrue(str(home))
|
|
210
|
+
self.assertFalse(home.exists())
|
|
211
|
+
|
|
212
|
+
def test_adapter_stdin_is_closed(self):
|
|
213
|
+
self.run_main("--adapter-cmd", "cat > /dev/null; echo 42 > answer.txt", "--arms", "vanilla")
|
|
214
|
+
self.assertTrue(self.records()[0]["pass"])
|
|
215
|
+
|
|
216
|
+
def test_adapter_default_model_is_used_and_recorded(self):
|
|
217
|
+
cfg = yaml.safe_load(self.config.read_text())
|
|
218
|
+
cfg["adapters"]["fake"] = {
|
|
219
|
+
"command": f'printf "%s" "$AB_MODEL" > {self.tmp}/model.txt; echo 42 > answer.txt',
|
|
220
|
+
"parser": "none", "default_model": "prov/model-x"}
|
|
221
|
+
self.config.write_text(yaml.safe_dump(cfg))
|
|
222
|
+
self.run_main("--adapter", "fake", "--arms", "vanilla")
|
|
223
|
+
self.assertEqual((self.tmp / "model.txt").read_text(), "prov/model-x")
|
|
224
|
+
report = yaml.safe_load(next(self.out.glob("AB-*.yaml")).read_text())
|
|
225
|
+
self.assertEqual(report["model"], "prov/model-x")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TestCli(Sandbox):
|
|
229
|
+
def test_unknown_arm_exits_2(self):
|
|
230
|
+
code, out = self.run_main("--arms", "nope")
|
|
231
|
+
self.assertEqual(code, 2)
|
|
232
|
+
self.assertIn("vanilla", out)
|
|
233
|
+
|
|
234
|
+
def test_unknown_task_exits_2(self):
|
|
235
|
+
code, _ = self.run_main("--tasks", "missing")
|
|
236
|
+
self.assertEqual(code, 2)
|
|
237
|
+
|
|
238
|
+
def test_dry_run_spends_nothing(self):
|
|
239
|
+
marker = self.tmp / "ran.txt"
|
|
240
|
+
code, out = self.run_main("--adapter-cmd", f"touch {marker}", "--dry-run")
|
|
241
|
+
self.assertEqual(code, 0)
|
|
242
|
+
self.assertFalse(marker.exists())
|
|
243
|
+
self.assertIn("t-01", out)
|
|
244
|
+
self.assertFalse(self.out.exists())
|
|
245
|
+
|
|
246
|
+
def test_single_arm_has_no_delta(self):
|
|
247
|
+
self.run_main("--adapter-cmd", "true", "--arms", "vanilla")
|
|
248
|
+
report = yaml.safe_load(next(self.out.glob("AB-*.yaml")).read_text())
|
|
249
|
+
self.assertIsNone(report["delta"])
|
|
250
|
+
|
|
251
|
+
|
|
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
|
+
class TestRunBenchmarkHonesty(unittest.TestCase):
|
|
296
|
+
def test_without_skill_arm_is_unmeasured_not_zero(self):
|
|
297
|
+
sc = {"id": "s1", "grader": {"type": "code", "command": "true"}}
|
|
298
|
+
_, status = bench.scenario_pass_rate(sc, runs=1, with_skill=False)
|
|
299
|
+
self.assertEqual(status, "unmeasured")
|
|
300
|
+
report = bench.build_split_report([sc], runs=1)
|
|
301
|
+
self.assertIsNone(report["without_skill"])
|
|
302
|
+
self.assertIsNone(report["delta"])
|
|
303
|
+
|
|
304
|
+
def test_summary_says_unmeasured(self):
|
|
305
|
+
self.assertIn("UNMEASURED", bench.delta_summary(None))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# story: e58s06
|
|
3
|
+
"""tests/test_ab_pi.py — pi adapter: pi-json metrics parser and the config default adapter.
|
|
4
|
+
|
|
5
|
+
Run: python3 tests/test_ab_pi.py
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
+
from test_ab_eval import Sandbox, met # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def assistant_end(inp, out, cache_read, cost, stop="toolUse"):
|
|
19
|
+
usage = {"input": inp, "output": out, "cacheRead": cache_read, "cacheWrite": 0, "cost": {"total": cost}}
|
|
20
|
+
return {"type": "message_end", "message": {"role": "assistant", "stopReason": stop, "usage": usage}}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def stream(*events):
|
|
24
|
+
return "\n".join(json.dumps(e) for e in events)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestPiParser(unittest.TestCase):
|
|
28
|
+
def test_sums_assistant_usage(self):
|
|
29
|
+
out = stream({"type": "session", "id": "x"},
|
|
30
|
+
{"type": "message_end", "message": {"role": "user", "content": "hi"}},
|
|
31
|
+
assistant_end(1390, 56, 256, 0.000243),
|
|
32
|
+
assistant_end(55, 3, 1664, 0.000015, stop="stop"),
|
|
33
|
+
{"type": "agent_settled", "aborted": False})
|
|
34
|
+
m = met.parse_metrics("pi-json", out)
|
|
35
|
+
self.assertEqual((m["input_tokens"], m["output_tokens"], m["turns"]), (1390 + 256 + 55 + 1664, 59, 2))
|
|
36
|
+
self.assertAlmostEqual(m["cost_usd"], 0.000258)
|
|
37
|
+
self.assertFalse(m["agent_reported_error"])
|
|
38
|
+
|
|
39
|
+
def test_provider_error_is_agent_error(self):
|
|
40
|
+
out = stream(assistant_end(10, 0, 0, 0.0, stop="error"), {"type": "agent_settled", "aborted": False})
|
|
41
|
+
self.assertTrue(met.parse_metrics("pi-json", out)["agent_reported_error"])
|
|
42
|
+
|
|
43
|
+
def test_aborted_run_is_agent_error(self):
|
|
44
|
+
out = stream(assistant_end(10, 1, 0, 0.0, stop="stop"), {"type": "agent_settled", "aborted": True})
|
|
45
|
+
self.assertTrue(met.parse_metrics("pi-json", out)["agent_reported_error"])
|
|
46
|
+
|
|
47
|
+
def test_no_events_is_unknown(self):
|
|
48
|
+
self.assertIsNone(met.parse_metrics("pi-json", "boom")["agent_reported_error"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestDefaultAdapter(Sandbox):
|
|
52
|
+
def test_config_default_adapter_is_used(self):
|
|
53
|
+
config = yaml.safe_load(self.config.read_text())
|
|
54
|
+
config["adapters"]["fakepi"] = {"command": "echo 42 > answer.txt", "parser": "none"}
|
|
55
|
+
config["default_adapter"] = "fakepi"
|
|
56
|
+
self.config.write_text(yaml.safe_dump(config))
|
|
57
|
+
_, out = self.run_main("--arms", "vanilla")
|
|
58
|
+
self.assertIn("adapter 'fakepi'", out)
|
|
59
|
+
self.assertTrue(self.records()[0]["pass"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
unittest.main(verbosity=1)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# story: e58s05
|
|
3
|
+
"""tests/test_ab_progress.py — live records, interleaved runs, saved transcripts, ab_progress.
|
|
4
|
+
|
|
5
|
+
Run: python3 tests/test_ab_progress.py
|
|
6
|
+
"""
|
|
7
|
+
import gzip
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
import unittest
|
|
12
|
+
from contextlib import redirect_stdout
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
16
|
+
from test_ab_eval import Sandbox, load_module # noqa: E402
|
|
17
|
+
|
|
18
|
+
prog = load_module("ab_progress", "ab_progress.py")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestLiveRun(Sandbox):
|
|
22
|
+
def live(self):
|
|
23
|
+
return [json.loads(line) for line in (self.out / "ab-live.jsonl").read_text().splitlines()]
|
|
24
|
+
|
|
25
|
+
def test_records_stream_with_sha_and_model(self):
|
|
26
|
+
self.run_main("--adapter-cmd", "echo 42 > answer.txt", "--k", "2", "--model", "m-1")
|
|
27
|
+
live = self.live()
|
|
28
|
+
self.assertEqual(len(live), 4)
|
|
29
|
+
self.assertTrue(all(r["bigpowers_sha"] and r["model"] == "m-1" for r in live))
|
|
30
|
+
|
|
31
|
+
def test_runs_are_interleaved(self):
|
|
32
|
+
self.run_main("--adapter-cmd", "echo 42 > answer.txt", "--k", "2")
|
|
33
|
+
order = [(r["run"], r["arm"]) for r in self.live()]
|
|
34
|
+
self.assertEqual(order, [(1, "vanilla"), (1, "bigpowers"), (2, "vanilla"), (2, "bigpowers")])
|
|
35
|
+
|
|
36
|
+
def test_transcripts_are_saved(self):
|
|
37
|
+
self.run_main("--adapter-cmd", "echo transcript-line; echo 42 > answer.txt", "--arms", "vanilla")
|
|
38
|
+
traces = sorted((self.out / "traces").glob("*.jsonl.gz"))
|
|
39
|
+
self.assertEqual([t.name for t in traces], ["t-01-vanilla-1.jsonl.gz"])
|
|
40
|
+
self.assertIn("transcript-line", gzip.decompress(traces[0].read_bytes()).decode())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestProgress(unittest.TestCase):
|
|
44
|
+
def shard(self, tmp: Path, name: str, records: list[dict]) -> Path:
|
|
45
|
+
folder = tmp / name
|
|
46
|
+
folder.mkdir()
|
|
47
|
+
(folder / "ab-live.jsonl").write_text("".join(json.dumps(r) + "\n" for r in records))
|
|
48
|
+
return folder
|
|
49
|
+
|
|
50
|
+
def rec(self, arm, run, passed, score):
|
|
51
|
+
return {"task": "ab-11", "arm": arm, "run": run, "pass": passed, "score": score, "tampered": False,
|
|
52
|
+
"agent_error": False, "cost_usd": 0.01, "input_tokens": None, "output_tokens": None,
|
|
53
|
+
"turns": 5, "duration_s": 1.0, "diff_lines": 3, "bigpowers_sha": "abc", "model": "m"}
|
|
54
|
+
|
|
55
|
+
def test_reads_shards_and_prints_evolution(self):
|
|
56
|
+
import tempfile
|
|
57
|
+
tmp = Path(tempfile.mkdtemp(prefix="ab-prog-"))
|
|
58
|
+
a = self.shard(tmp, "s1", [self.rec("vanilla", i, i % 2 == 0, 0.5) for i in range(1, 11)]
|
|
59
|
+
+ [self.rec("bigpowers", i, True, 1.0) for i in range(1, 11)])
|
|
60
|
+
b = self.shard(tmp, "s2", [self.rec("vanilla", i, False, 0.4) for i in range(1, 6)]
|
|
61
|
+
+ [self.rec("bigpowers", i, True, 1.0) for i in range(1, 6)])
|
|
62
|
+
buf = io.StringIO()
|
|
63
|
+
with redirect_stdout(buf):
|
|
64
|
+
code = prog.main([str(a), str(b)])
|
|
65
|
+
text = buf.getvalue()
|
|
66
|
+
self.assertEqual(code, 0)
|
|
67
|
+
self.assertIn("30 runs", text)
|
|
68
|
+
self.assertRegex(text, r"ab-11\s+vanilla\s+15")
|
|
69
|
+
self.assertRegex(text, r"ab-11\s+bigpowers\s+15")
|
|
70
|
+
self.assertIn("bigpowers - vanilla", text)
|
|
71
|
+
self.assertIn("evolution", text)
|
|
72
|
+
|
|
73
|
+
def test_no_live_file_exits_2(self):
|
|
74
|
+
import tempfile
|
|
75
|
+
buf = io.StringIO()
|
|
76
|
+
with redirect_stdout(buf):
|
|
77
|
+
self.assertEqual(prog.main([tempfile.mkdtemp()]), 2)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
unittest.main(verbosity=1)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# story: e58s07
|
|
3
|
+
"""tests/test_ab_sandbox.py — Docker sandbox: grade_wrapper, AB_TIMEOUT, container isolation.
|
|
4
|
+
|
|
5
|
+
Run: python3 tests/test_ab_sandbox.py
|
|
6
|
+
The isolation tests need Docker and `bash scripts/ab-sandbox.sh up`; they skip otherwise.
|
|
7
|
+
"""
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
+
from test_ab_eval import REPO_ROOT, Sandbox # noqa: E402
|
|
18
|
+
|
|
19
|
+
AGENT_IMAGE = "bp-ab-agent:pi-1.5.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sandbox_up() -> bool:
|
|
23
|
+
if not shutil.which("docker"):
|
|
24
|
+
return False
|
|
25
|
+
res = subprocess.run(["bash", str(REPO_ROOT / "scripts" / "ab-sandbox.sh"), "status"], capture_output=True)
|
|
26
|
+
return res.returncode == 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestGradeWrapper(Sandbox):
|
|
30
|
+
def add_adapter(self, **extra):
|
|
31
|
+
config = yaml.safe_load(self.config.read_text())
|
|
32
|
+
config["adapters"]["boxed"] = {"command": "echo $AB_TIMEOUT > timeout.txt; cp timeout.txt "
|
|
33
|
+
+ str(self.tmp) + "/; echo 42 > answer.txt", "parser": "none", **extra}
|
|
34
|
+
self.config.write_text(yaml.safe_dump(config))
|
|
35
|
+
|
|
36
|
+
def test_grade_and_score_go_through_the_wrapper(self):
|
|
37
|
+
log = self.tmp / "wrapped.log"
|
|
38
|
+
self.add_adapter(grade_wrapper=f'echo "$AB_CMD" >> {log}; bash -c "$AB_CMD"')
|
|
39
|
+
task_yaml = self.tasks_dir / "t-01" / "task.yaml"
|
|
40
|
+
task = yaml.safe_load(task_yaml.read_text())
|
|
41
|
+
task["score"] = "printf '# pass 1\\n# fail 0\\n'"
|
|
42
|
+
task_yaml.write_text(yaml.safe_dump(task))
|
|
43
|
+
self.run_main("--adapter", "boxed", "--arms", "vanilla")
|
|
44
|
+
rec = self.records()[0]
|
|
45
|
+
self.assertTrue(rec["pass"])
|
|
46
|
+
self.assertEqual(rec["score"], 1.0)
|
|
47
|
+
self.assertEqual(len(log.read_text().splitlines()), 2)
|
|
48
|
+
|
|
49
|
+
def test_timeout_is_exported_with_margin(self):
|
|
50
|
+
self.add_adapter()
|
|
51
|
+
self.run_main("--adapter", "boxed", "--arms", "vanilla")
|
|
52
|
+
self.assertEqual((self.tmp / "timeout.txt").read_text().strip(), "10")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestSandboxScript(unittest.TestCase):
|
|
56
|
+
def test_sandbox_script_syntax_and_usage(self):
|
|
57
|
+
script = REPO_ROOT / "scripts" / "ab-sandbox.sh"
|
|
58
|
+
self.assertTrue(script.exists())
|
|
59
|
+
self.assertTrue(bool(script.stat().st_mode & 0o111), "ab-sandbox.sh must be executable")
|
|
60
|
+
res = subprocess.run(["bash", "-n", str(script)], capture_output=True, text=True)
|
|
61
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
62
|
+
res = subprocess.run(["bash", str(script), "unknown-cmd"], capture_output=True, text=True)
|
|
63
|
+
self.assertEqual(res.returncode, 1)
|
|
64
|
+
self.assertIn("usage: ab-sandbox.sh up|down|status", res.stderr)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@unittest.skipUnless(sandbox_up(), "Docker sandbox not up (bash scripts/ab-sandbox.sh up)")
|
|
68
|
+
class TestIsolation(unittest.TestCase):
|
|
69
|
+
def agent(self, script: str, network: str = "bp-ab-internal") -> subprocess.CompletedProcess:
|
|
70
|
+
return subprocess.run(
|
|
71
|
+
["docker", "run", "--rm", "--network", network,
|
|
72
|
+
"-e", "HTTPS_PROXY=http://bp-ab-proxy:8888", "-e", "HTTP_PROXY=http://bp-ab-proxy:8888",
|
|
73
|
+
AGENT_IMAGE, "bash", "-c", script], capture_output=True, text=True, timeout=120)
|
|
74
|
+
|
|
75
|
+
def test_host_paths_are_invisible(self):
|
|
76
|
+
res = self.agent(f'test ! -e /Users && test ! -e "{REPO_ROOT}" && echo hidden')
|
|
77
|
+
self.assertEqual(res.stdout.strip(), "hidden", res.stderr)
|
|
78
|
+
|
|
79
|
+
def test_runs_as_non_root(self):
|
|
80
|
+
self.assertNotEqual(self.agent("id -u").stdout.strip(), "0")
|
|
81
|
+
|
|
82
|
+
def test_only_the_model_provider_is_reachable(self):
|
|
83
|
+
probe = "node -e \"fetch(process.argv[1]).then(r=>console.log('ok',r.status)).catch(e=>console.log('blocked'))\""
|
|
84
|
+
self.assertIn("blocked", self.agent(f"{probe} https://example.com").stdout)
|
|
85
|
+
self.assertIn("ok", self.agent(f"{probe} https://opencode.ai").stdout)
|
|
86
|
+
|
|
87
|
+
def test_grading_container_has_no_network(self):
|
|
88
|
+
res = self.agent("node -e \"fetch('https://opencode.ai').then(()=>console.log('open'))"
|
|
89
|
+
".catch(()=>console.log('none'))\"", network="none")
|
|
90
|
+
self.assertIn("none", res.stdout)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
unittest.main(verbosity=1)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# story: e58s03
|
|
3
|
+
"""tests/test_ab_score.py — partial-credit scoring and the hard tasks (ab-11, ab-13).
|
|
4
|
+
|
|
5
|
+
Run: python3 tests/test_ab_score.py
|
|
6
|
+
Split from test_ab_eval.py (300-line cap); reuses its Sandbox and module loaders.
|
|
7
|
+
"""
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
18
|
+
from test_ab_eval import REPO_ROOT, Sandbox, met, rep # noqa: E402
|
|
19
|
+
|
|
20
|
+
TASKS = REPO_ROOT / "specs" / "benchmarks" / "ab" / "tasks"
|
|
21
|
+
HARD_TASKS = ["ab-11-ledger-rounding", "ab-13-orders-split"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestTapScore(unittest.TestCase):
|
|
25
|
+
def test_share_of_passing_tests(self):
|
|
26
|
+
self.assertEqual(met.tap_score("# tests 4\n# pass 3\n# fail 1\n"), 0.75)
|
|
27
|
+
|
|
28
|
+
def test_several_summaries_are_summed(self):
|
|
29
|
+
self.assertEqual(met.tap_score("# pass 2\n# fail 0\nnoise\n# pass 1\n# fail 1\n"), 0.75)
|
|
30
|
+
|
|
31
|
+
def test_no_summary_is_none(self):
|
|
32
|
+
self.assertIsNone(met.tap_score("SyntaxError: boom\n"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestScoreInRuns(Sandbox):
|
|
36
|
+
def set_score(self, command):
|
|
37
|
+
task_yaml = self.tasks_dir / "t-01" / "task.yaml"
|
|
38
|
+
task = yaml.safe_load(task_yaml.read_text())
|
|
39
|
+
task["score"] = command
|
|
40
|
+
task_yaml.write_text(yaml.safe_dump(task))
|
|
41
|
+
|
|
42
|
+
def test_score_command_sets_score(self):
|
|
43
|
+
self.set_score("printf '# pass 3\\n# fail 1\\n'; exit 1")
|
|
44
|
+
self.run_main("--adapter-cmd", "echo 42 > answer.txt", "--arms", "vanilla")
|
|
45
|
+
self.assertEqual(self.records()[0]["score"], 0.75)
|
|
46
|
+
|
|
47
|
+
def test_no_score_key_means_null(self):
|
|
48
|
+
self.run_main("--adapter-cmd", "echo 42 > answer.txt")
|
|
49
|
+
self.assertIsNone(self.records()[0]["score"])
|
|
50
|
+
report = yaml.safe_load(next(self.out.glob("AB-*.yaml")).read_text())
|
|
51
|
+
self.assertIsNone(report["delta"].get("score"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestScoreInReport(unittest.TestCase):
|
|
55
|
+
def rec(self, arm, task, score):
|
|
56
|
+
return {"arm": arm, "task": task, "pass": score == 1.0, "tampered": False, "agent_error": False,
|
|
57
|
+
"cost_usd": None, "input_tokens": None, "output_tokens": None, "turns": None,
|
|
58
|
+
"duration_s": 1.0, "diff_lines": 1, "score": score}
|
|
59
|
+
|
|
60
|
+
def test_mean_score_and_delta(self):
|
|
61
|
+
records = [self.rec("vanilla", "t", 0.5), self.rec("vanilla", "t", 0.7),
|
|
62
|
+
self.rec("bigpowers", "t", 0.9), self.rec("bigpowers", "t", 1.0)]
|
|
63
|
+
result = rep.aggregate(records, ["vanilla", "bigpowers"], ["t"])
|
|
64
|
+
self.assertEqual(result["arms_summary"]["vanilla"]["mean_score"], 0.6)
|
|
65
|
+
self.assertEqual(result["per_task_score"]["t"]["bigpowers"], 0.95)
|
|
66
|
+
self.assertEqual(result["delta"]["score"], 0.35)
|
|
67
|
+
lo, hi = result["delta"]["score_ci95"]
|
|
68
|
+
self.assertLessEqual(lo, 0.35)
|
|
69
|
+
self.assertGreaterEqual(hi, 0.35)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@unittest.skipUnless(shutil.which("node"), "node is needed for the hard tasks")
|
|
73
|
+
class TestHardTasks(unittest.TestCase):
|
|
74
|
+
"""Each hard task must fail as given, pass with its solution, and give partial credit."""
|
|
75
|
+
|
|
76
|
+
def grade(self, name: str, with_solution: bool) -> tuple[bool, float | None]:
|
|
77
|
+
task_dir = TASKS / name
|
|
78
|
+
task = yaml.safe_load((task_dir / "task.yaml").read_text())
|
|
79
|
+
work = Path(tempfile.mkdtemp(prefix="ab-hard-"))
|
|
80
|
+
try:
|
|
81
|
+
shutil.copytree(task_dir / "fixture", work, dirs_exist_ok=True)
|
|
82
|
+
if with_solution:
|
|
83
|
+
shutil.copytree(task_dir / "solution", work, dirs_exist_ok=True)
|
|
84
|
+
shutil.copytree(task_dir / "hidden", work, dirs_exist_ok=True)
|
|
85
|
+
run = lambda cmd: subprocess.run(["bash", "-c", cmd], cwd=work, capture_output=True, text=True)
|
|
86
|
+
passed = run(task["grade"]).returncode == 0
|
|
87
|
+
return passed, met.tap_score(run(task["score"]).stdout)
|
|
88
|
+
finally:
|
|
89
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
90
|
+
|
|
91
|
+
def test_fixture_fails_with_partial_score(self):
|
|
92
|
+
for name in HARD_TASKS:
|
|
93
|
+
with self.subTest(task=name):
|
|
94
|
+
passed, score = self.grade(name, with_solution=False)
|
|
95
|
+
self.assertFalse(passed)
|
|
96
|
+
assert score is not None
|
|
97
|
+
self.assertLess(score, 1.0)
|
|
98
|
+
|
|
99
|
+
def test_solution_passes_with_full_score(self):
|
|
100
|
+
for name in HARD_TASKS:
|
|
101
|
+
with self.subTest(task=name):
|
|
102
|
+
self.assertEqual(self.grade(name, with_solution=True), (True, 1.0))
|
|
103
|
+
|
|
104
|
+
def test_score_runs_hidden_tests_only(self):
|
|
105
|
+
for name in HARD_TASKS:
|
|
106
|
+
with self.subTest(task=name):
|
|
107
|
+
score_cmd = yaml.safe_load((TASKS / name / "task.yaml").read_text())["score"]
|
|
108
|
+
self.assertIn("test-hidden", score_cmd)
|
|
109
|
+
self.assertNotRegex(score_cmd, r"(^|\s)test/")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
unittest.main(verbosity=1)
|