Viewing files at commit 2735d7f5558eCommit detailsView latest

Browse files
tests/test_agent_campaign.py 340 lines · 18.2 KB · python
1
"""Campaign contracts and transaction boundaries; no paid agent invocations."""
2
from __future__ import annotations
3
import copy
4
import json
5
import os
6
from pathlib import Path
7
import sys
8
import tempfile
9
import unittest
10
from unittest import mock
11
12
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
13
from oakbench import agent_campaign as campaign
14
15
16
class CampaignTest(unittest.TestCase):
17
    def setUp(self):
18
        self.tmp = tempfile.TemporaryDirectory()
19
        self.root = Path(self.tmp.name).resolve()
20
        self.manifest_path = self.root / "manifest.json"
21
        self.raw = json.loads((campaign.ROOT / "config/agent-campaign-smoke.json").read_text())
22
        self.raw.update(agents_config=str(campaign.ROOT / "config/agents.toml"),
23
                        subjects_config=str(campaign.ROOT / "config/subjects.toml"))
24
        self.write_manifest()
25
26
    def tearDown(self):
27
        self.tmp.cleanup()
28
29
    def write_manifest(self):
30
        self.manifest_path.write_text(json.dumps(self.raw))
31
32
    def manifest(self):
33
        self.write_manifest()
34
        return campaign.load_manifest(self.manifest_path)
35
36
    def plan(self):
37
        with mock.patch.object(campaign, "resolve_runtime", return_value=({}, {}, {})):
38
            return campaign.create_plan(self.manifest_path, self.root / "campaign")
39
40
    def test_schedule_determinism_balance_and_identity(self):
41
        first = campaign.schedule(self.manifest())
42
        self.assertEqual(first, campaign.schedule(self.manifest()))
43
        self.assertEqual(len({trial["trial_id"] for trial in first}), len(first))
44
        self.assertEqual([x["subject"] for x in first[:2]], list(reversed([x["subject"] for x in first[2:]])))
45
        self.assertEqual(first[0]["block_id"], first[1]["block_id"])
46
        self.assertNotEqual(first[0]["block_id"], first[2]["block_id"])
47
        self.raw["instruction_levels"] = ["full-docs", "zero-shot"]
48
        self.raw["max_trials"] = 8
49
        self.assertEqual(len(campaign.schedule(self.manifest())), 8)
50
51
    def test_invalid_limits_unknown_fields_duplicates_and_paths(self):
52
        for key, value in (("schema_version", True), ("repetitions", True), ("repetitions", 0),
53
                           ("max_trials", -1), ("max_wall_seconds", float("nan")),
54
                           ("trial_timeout_seconds", float("inf")), ("seed", True),
55
                           ("subjects", ["git", "git"]), ("subjects", ["../escape"]),
56
                           ("workflows", ["history_archaeology"]), ("typo", 1)):
57
            with self.subTest(key=key, value=value):
58
                raw = dict(self.raw)
59
                raw[key] = value
60
                self.manifest_path.write_text(json.dumps(raw))
61
                with self.assertRaises(campaign.CampaignError):
62
                    campaign.load_manifest(self.manifest_path)
63
        self.manifest_path.write_text('{"schema_version":1,"schema_version":1}')
64
        with self.assertRaises(campaign.CampaignError):
65
            campaign.load_manifest(self.manifest_path)
66
67
    def test_huge_schedule_rejected_before_materialization(self):
68
        self.raw["repetitions"] = 10 ** 30
69
        with self.assertRaises(campaign.CampaignError):
70
            campaign.schedule(self.manifest())
71
72
    def test_freeze_collision_and_tamper_rejected(self):
73
        plan = self.plan()
74
        directory = self.root / "campaign"
75
        self.assertEqual(campaign.load_plan(directory), plan)
76
        with mock.patch.object(campaign, "resolve_runtime", return_value=({}, {}, {})):
77
            with self.assertRaises(campaign.CampaignError):
78
                campaign.create_plan(self.manifest_path, directory)
79
        plan["schedule"][0]["subject"] = "wrong"
80
        (directory / "plan.json").write_text(json.dumps(plan))
81
        with self.assertRaises(campaign.CampaignError):
82
            campaign.load_plan(directory)
83
84
    def test_interrupted_trial_is_never_pending_and_reserves_budget(self):
85
        plan = self.plan()
86
        directory = self.root / "campaign"
87
        trial = plan["schedule"][0]["trial_id"]
88
        campaign.append_event(directory, plan, {"event": "started", "trial_id": trial, "timeout_seconds": 60})
89
        status = campaign.campaign_status(plan, campaign.load_events(directory, plan))
90
        self.assertEqual(status["interrupted_trial_ids"], [trial])
91
        self.assertNotIn(trial, status["pending_trial_ids"])
92
        self.assertEqual(status["charged_episode_seconds"], 60)
93
94
    def test_torn_duplicate_unknown_nonfinite_and_bad_duration_journal_fail_closed(self):
95
        plan = self.plan()
96
        directory = self.root / "campaign"
97
        trial = plan["schedule"][0]["trial_id"]
98
        start = {"event": "started", "trial_id": trial, "plan_sha256": plan["plan_sha256"], "timeout_seconds": 60}
99
        variants = [b'{"event":', b'[]\n', (json.dumps(start) + '\n') .encode() * 2,
100
                    (json.dumps({**start, "trial_id": "other"}) + '\n').encode(),
101
                    (json.dumps({**start, "timeout_seconds": float("nan")}) + '\n').encode()]
102
        for wall in (True, -1, float("inf")):
103
            finished = {"event": "finished", "trial_id": trial, "plan_sha256": plan["plan_sha256"],
104
                        "outcome": "error", "error": "test", "wall_seconds": wall}
105
            variants.append((json.dumps(start) + '\n' + json.dumps(finished) + '\n').encode())
106
        for content in variants:
107
            with self.subTest(content=content):
108
                (directory / "events.jsonl").write_bytes(content)
109
                with self.assertRaises(campaign.CampaignError):
110
                    campaign.load_events(directory, plan)
111
                self.assertEqual((directory / "events.jsonl").read_bytes(), content)
112
113
    def test_torn_new_start_after_old_interruption_reserves_next_trial(self):
114
        plan = self.plan()
115
        directory = self.root / "campaign"
116
        first, second, third = plan["schedule"][:3]
117
        for trial in (first, second):
118
            campaign.append_event(directory, plan, {"event": "started", "trial_id": trial["trial_id"], "timeout_seconds": 60})
119
        campaign.append_event(directory, plan, {"event": "finished", "trial_id": second["trial_id"],
120
            "outcome": "error", "wall_seconds": 1})
121
        with (directory / "events.jsonl").open("ab") as stream:
122
            stream.write(b'{"event":"started"')
123
        events = campaign.load_events(directory, plan, tolerate_torn=True)
124
        self.assertEqual(events[-2]["trial_id"], third["trial_id"])
125
        self.assertTrue(events[-2]["uncertain_torn_start"])
126
        state = campaign.campaign_status(plan, events)
127
        self.assertNotIn(third["trial_id"], state["pending_trial_ids"])
128
129
    def test_system_tmp_alias_is_accepted_but_arbitrary_symlinks_are_not(self):
130
        self.assertEqual(campaign.safe_path(Path("/tmp") / "campaign-safe-path-check"),
131
                         (Path("/tmp") / "campaign-safe-path-check").resolve())
132
133
    def test_terminal_row_identity_cannot_be_rebound(self):
134
        from agent_metrics_schema import example_row
135
        plan = self.plan()
136
        directory = self.root / "campaign"
137
        trial = plan["schedule"][0]
138
        row = example_row()
139
        row.update(task_id=trial["trial_id"], bench_id=plan["campaign_id"], subject=trial["subject"],
140
                   scenario=trial["workflow"], run=trial["repetition"], outcome="pass")
141
        row["agent"].update(name=trial["agent"], model=trial["model"],
142
                             instruction_level=trial["instruction_level"], environment="minimal")
143
        campaign.validate_trial_row(row, trial, plan)
144
        for key, value in (("subject", "wrong"), ("scenario", "wrong"), ("run", 999), ("bench_id", "other")):
145
            wrong = copy.deepcopy(row)
146
            wrong[key] = value
147
            with self.subTest(key=key), self.assertRaises(campaign.CampaignError):
148
                campaign.validate_trial_row(wrong, trial, plan)
149
        for key in ("name", "model", "instruction_level"):
150
            wrong = copy.deepcopy(row)
151
            wrong["agent"][key] = "wrong"
152
            with self.subTest(agent_key=key), self.assertRaises(campaign.CampaignError):
153
                campaign.validate_trial_row(wrong, trial, plan)
154
        campaign.append_event(directory, plan, {"event": "started", "trial_id": trial["trial_id"], "timeout_seconds": 60})
155
        row["subject"] = "wrong"
156
        campaign.append_event(directory, plan, {"event": "finished", "trial_id": trial["trial_id"],
157
            "outcome": "pass", "row": row, "wall_seconds": 1})
158
        with self.assertRaises(campaign.CampaignError):
159
            campaign.load_events(directory, plan)
160
161
    def test_symlink_destinations_and_lock_rejected(self):
162
        alias = self.root / "alias"
163
        alias.symlink_to(self.root, target_is_directory=True)
164
        with self.assertRaises(campaign.CampaignError):
165
            campaign.safe_path(alias / "campaign")
166
        plan = self.plan()
167
        directory = self.root / "campaign"
168
        for name in ("events.jsonl", ".run.lock", "trials"):
169
            path = directory / name
170
            path.symlink_to(self.root / "outside")
171
            with self.assertRaises(campaign.CampaignError):
172
                campaign.safe_path(path)
173
            path.unlink()
174
175
    def test_exclusive_runner_ownership(self):
176
        self.plan()
177
        with campaign.campaign_lock(self.root / "campaign"):
178
            with self.assertRaises(campaign.CampaignError):
179
                with campaign.campaign_lock(self.root / "campaign"):
180
                    pass
181
182
    def test_preflight_drift_real_optin_and_attestation(self):
183
        plan = self.plan()
184
        directory = self.root / "campaign"
185
        with mock.patch.object(campaign, "resolve_runtime", return_value=({}, {}, {"changed": True})):
186
            with self.assertRaises(campaign.CampaignError):
187
                campaign.preflight(plan, directory)
188
        from agent_workflow import AgentConfig
189
        real = AgentConfig("real", "codex_exec_json", "openai", Path("/binary"), "pinned", "1")
190
        with mock.patch.object(campaign, "resolve_runtime", return_value=({"real": real}, {}, {})):
191
            with self.assertRaises(campaign.CampaignError):
192
                campaign.preflight(plan, directory)
193
            with self.assertRaises(campaign.CampaignError):
194
                campaign.preflight(plan, directory, enable_real=True)
195
            plan["manifest"]["isolation_attestation"] = {
196
                "worker_root": str(self.root), "unrestricted_cli_acknowledged": True}
197
            self.assertEqual(campaign.preflight(plan, directory, enable_real=True)[0]["real"], real)
198
199
    def test_lock_provenance_drift_names_the_key(self):
200
        plan = self.plan()
201
        plan["provenance"] = {"measurement_lock_path": "/old/lock"}
202
        with mock.patch.object(campaign, "resolve_runtime", return_value=({}, {}, {"measurement_lock_path": "/new/lock"})):
203
            with self.assertRaisesRegex(campaign.CampaignError, "measurement_lock_path"):
204
                campaign.preflight(plan, self.root / "campaign")
205
206
    def test_fixture_cache_reuses_generation_and_rejects_mutated_payload(self):
207
        import workflow_ab
208
        from dataclasses import replace
209
        counter = mock.Mock(side_effect=workflow_ab.WORKFLOWS["wide_config_refactor"].make_fixture)
210
        workflow = replace(workflow_ab.WORKFLOWS["wide_config_refactor"], make_fixture=counter)
211
        plan = self.plan()
212
        directory = self.root / "campaign"
213
        fixture = campaign.cached_fixture(directory, plan, workflow)
214
        self.assertEqual(fixture, directory / "fixtures" / workflow.name)
215
        self.assertEqual(campaign.cached_fixture(directory, plan, workflow), fixture)
216
        self.assertEqual(counter.call_count, 1)
217
        payload = next(fixture.glob("services/*/config.toml"))
218
        payload.write_text("corrupt fixture")
219
        with self.assertRaisesRegex(campaign.CampaignError, "fixture.*drift"):
220
            campaign.cached_fixture(directory, plan, workflow)
221
        self.assertEqual(counter.call_count, 1)
222
223
    def test_completed_infrastructure_error_not_retried_on_resume(self):
224
        self.plan()
225
        directory = self.root / "campaign"
226
        import agent_workflow
227
        import workflow_ab
228
        from oakbench.runlock import LockInfo
229
        from contextlib import contextmanager
230
        @contextmanager
231
        def locked(label):
232
            yield LockInfo(0, True)
233
        agent = agent_workflow.AgentConfig("mock", "deterministic_mock", "local", None, None, "1")
234
        subject = workflow_ab.Subject("git", "git", "Git", Path("/git"))
235
        with mock.patch.object(campaign, "preflight", return_value=({"mock": agent}, {"git": subject, "oak_installed": subject})), \
236
             mock.patch("oakbench.runlock.measurement_lock", locked), \
237
             mock.patch.object(campaign, "cached_fixture", return_value=self.root), \
238
             mock.patch.object(agent_workflow, "run_one", side_effect=RuntimeError("fixture setup failed")) as run:
239
            state = campaign.run_campaign(directory, limit=1)
240
            self.assertEqual(state["finished"], 1)
241
            campaign.run_campaign(directory, resume=True, limit=1)
242
            self.assertEqual(run.call_count, 2)
243
            events = campaign.load_events(directory, campaign.load_plan(directory))
244
            self.assertEqual(len({event["trial_id"] for event in events if event["event"] == "started"}), 2)
245
            self.assertEqual([event["outcome"] for event in events if event["event"] == "finished"], ["error", "error"])
246
247
    def test_mkdir_crash_after_durable_start_is_not_pending(self):
248
        from contextlib import contextmanager
249
        from oakbench.runlock import LockInfo
250
        self.plan()
251
        directory = self.root / "campaign"
252
        @contextmanager
253
        def locked(label):
254
            yield LockInfo(0, True)
255
        original = Path.mkdir
256
        def crash(path, *args, **kwargs):
257
            if path.parent.name == "trials":
258
                raise KeyboardInterrupt("simulated process crash")
259
            return original(path, *args, **kwargs)
260
        with mock.patch.object(campaign, "preflight", return_value=({}, {})), \
261
             mock.patch("oakbench.runlock.measurement_lock", locked), \
262
             mock.patch.object(Path, "mkdir", crash):
263
            with self.assertRaises(KeyboardInterrupt):
264
                campaign.run_campaign(directory)
265
        plan = campaign.load_plan(directory)
266
        events = campaign.load_events(directory, plan)
267
        self.assertEqual(len(events), 1)
268
        state = campaign.campaign_status(plan, events)
269
        self.assertEqual(state["interrupted_trial_ids"], [plan["schedule"][0]["trial_id"]])
270
        self.assertNotIn(plan["schedule"][0]["trial_id"], state["pending_trial_ids"])
271
272
    def test_schema_rejected_row_remains_in_terminal_evidence(self):
273
        import agent_workflow
274
        from contextlib import contextmanager
275
        from oakbench.runlock import LockInfo
276
        plan = self.plan()
277
        directory = self.root / "campaign"
278
        @contextmanager
279
        def locked(label):
280
            yield LockInfo(0, True)
281
        subjects = {trial["subject"]: object() for trial in plan["schedule"]}
282
        with mock.patch.object(campaign, "preflight", return_value=({"mock": object()}, subjects)), \
283
             mock.patch("oakbench.runlock.measurement_lock", locked), \
284
             mock.patch.object(campaign, "cached_fixture", return_value=self.root), \
285
             mock.patch.object(agent_workflow, "run_one", return_value={"invalid": "original evidence"}):
286
            campaign.run_campaign(directory, limit=1)
287
        events = campaign.load_events(directory, plan)
288
        self.assertEqual(events[1]["outcome"], "error")
289
        self.assertEqual(events[1]["rejected_row"]["invalid"], "original evidence")
290
        self.assertEqual((directory / "rows.jsonl").read_bytes(), b"")
291
292
    def test_noop_resume_repairs_missing_or_stale_rows_from_finished_journal(self):
293
        from agent_metrics_schema import example_row
294
        self.raw.update(subjects=["git"], repetitions=1)
295
        self.write_manifest()
296
        plan = self.plan()
297
        directory = self.root / "campaign"
298
        trial = plan["schedule"][0]
299
        row = example_row()
300
        row.update(task_id=trial["trial_id"], bench_id=plan["campaign_id"], subject=trial["subject"],
301
                   scenario=trial["workflow"], run=trial["repetition"], outcome="pass")
302
        row["agent"].update(name=trial["agent"], model=trial["model"],
303
                             instruction_level=trial["instruction_level"], environment="minimal")
304
        campaign.append_event(directory, plan, {"event": "started", "trial_id": trial["trial_id"], "timeout_seconds": 60})
305
        campaign.append_event(directory, plan, {"event": "finished", "trial_id": trial["trial_id"],
306
            "outcome": "pass", "row": row, "wall_seconds": 1})
307
        journal = (directory / "events.jsonl").read_bytes()
308
        for state in ("missing", "stale"):
309
            with self.subTest(state=state):
310
                if state == "stale":
311
                    (directory / "rows.jsonl").write_bytes(b"stale output")
312
                with mock.patch.object(campaign, "preflight", return_value=({}, {})), \
313
                     mock.patch("agent_workflow.run_one") as run:
314
                    status = campaign.run_campaign(directory, resume=True)
315
                    run.assert_not_called()
316
                self.assertEqual(status["finished"], 1)
317
                self.assertEqual((directory / "rows.jsonl").read_bytes(), campaign.canonical(row) + b"\n")
318
                self.assertEqual((directory / "events.jsonl").read_bytes(), journal)
319
320
    def test_disabled_measurement_lock_starts_nothing(self):
321
        self.plan()
322
        directory = self.root / "campaign"
323
        with mock.patch.object(campaign, "preflight", return_value=({}, {})), \
324
             mock.patch.dict(os.environ, {"OAK_BENCH_LOCK": "off"}):
325
            with self.assertRaises(campaign.CampaignError):
326
                campaign.run_campaign(directory)
327
        self.assertFalse((directory / "events.jsonl").exists())
328
329
    def test_artifact_hashes_exclude_links(self):
330
        trial = self.root / "trial"
331
        (trial / "artifacts").mkdir(parents=True)
332
        (trial / "artifacts/prompt.txt").write_text("fixed prompt")
333
        (trial / "artifacts/escape").symlink_to(self.manifest_path)
334
        hashes = campaign.artifact_hashes(trial)
335
        self.assertEqual(set(hashes), {"artifacts/prompt.txt"})
336
        self.assertEqual(hashes["artifacts/prompt.txt"], campaign.file_hash(trial / "artifacts/prompt.txt"))
337
338
339
if __name__ == "__main__":
340
    unittest.main()