pluginbench 0.5.0__py3-none-any.whl

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.
Files changed (58) hide show
  1. pluginbench/__init__.py +3 -0
  2. pluginbench/__main__.py +3 -0
  3. pluginbench/assessment-charts.html +790 -0
  4. pluginbench/assessment.py +316 -0
  5. pluginbench/assessment_charts.py +1225 -0
  6. pluginbench/assessment_metrics.py +1956 -0
  7. pluginbench/assessment_models.py +127 -0
  8. pluginbench/baselines.py +792 -0
  9. pluginbench/claude.py +374 -0
  10. pluginbench/cli.py +672 -0
  11. pluginbench/code_diff.py +207 -0
  12. pluginbench/codex.py +451 -0
  13. pluginbench/codex_events.py +585 -0
  14. pluginbench/collectors.py +591 -0
  15. pluginbench/compare.py +927 -0
  16. pluginbench/components.py +129 -0
  17. pluginbench/coverage_report.py +610 -0
  18. pluginbench/datasets.py +207 -0
  19. pluginbench/evaluation.example.yaml +45 -0
  20. pluginbench/evaluation.py +1651 -0
  21. pluginbench/evaluation_report.py +1004 -0
  22. pluginbench/fallow_parse.mjs +101 -0
  23. pluginbench/harbor_backend.py +2946 -0
  24. pluginbench/isolation.py +314 -0
  25. pluginbench/judge.py +683 -0
  26. pluginbench/language_analysis.py +915 -0
  27. pluginbench/ledger.py +217 -0
  28. pluginbench/measurement_profiles.py +616 -0
  29. pluginbench/native_treatment_runtime.js +221 -0
  30. pluginbench/native_treatments.py +155 -0
  31. pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
  32. pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
  33. pluginbench/observer_data/go-analysis-v1/main.go +64 -0
  34. pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
  35. pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
  36. pluginbench/parallel.py +101 -0
  37. pluginbench/processes.py +100 -0
  38. pluginbench/remeasure.py +992 -0
  39. pluginbench/run_lock.py +227 -0
  40. pluginbench/run_status.py +518 -0
  41. pluginbench/study.py +1858 -0
  42. pluginbench/study_review.py +924 -0
  43. pluginbench/submissions.py +486 -0
  44. pluginbench/task_sources.py +403 -0
  45. pluginbench/treatments.py +388 -0
  46. pluginbench/trial_containers.py +94 -0
  47. pluginbench/vendor/ECHARTS-LICENSE +222 -0
  48. pluginbench/vendor/ECHARTS-NOTICE +5 -0
  49. pluginbench/vendor/ZRENDER-LICENSE +29 -0
  50. pluginbench/vendor/echarts.min.js +48 -0
  51. pluginbench-0.5.0.dist-info/METADATA +294 -0
  52. pluginbench-0.5.0.dist-info/RECORD +58 -0
  53. pluginbench-0.5.0.dist-info/WHEEL +4 -0
  54. pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
  55. pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
  56. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
  57. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
  58. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
pluginbench/study.py ADDED
@@ -0,0 +1,1858 @@
1
+ """Native treatments, independent attempts, and a source-bound study report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import dataclasses
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import random
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+ from dataclasses import dataclass, field
15
+ from functools import partial
16
+ from threading import Event
17
+ from concurrent.futures import CancelledError
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import Any, Literal
21
+
22
+ import yaml
23
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
24
+
25
+ from pluginbench import codex, harbor_backend, submissions, trial_containers
26
+ from pluginbench.codex_events import failed_before_first_turn
27
+ from pluginbench.harbor_backend import binary_native_reward as _binary_native_reward
28
+ from pluginbench.treatments import (
29
+ Treatment, resolve_treatment, freeze_treatment, treatment_inventory,
30
+ )
31
+ from pluginbench.native_treatments import NativeCapabilities, DELIVERY_POLICY, admit
32
+ from pluginbench.baselines import BaselineCache
33
+ from pluginbench.ledger import LEDGER_NAME, Ledger
34
+ from pluginbench.run_lock import RunMoved
35
+ from pluginbench.parallel import run_jobs, validate_jobs
36
+ from pluginbench.assessment import _correctness, build_assessment, write_assessment
37
+ from pluginbench.assessment_metrics import EXCLUDED_NAMES, inspect_snapshot
38
+ from pluginbench.evaluation_report import TRUSTED_TEST_OVERLAP
39
+ from pluginbench.assessment_models import (
40
+ ArtifactRef,
41
+ AssessmentInput,
42
+ CorrectnessReceipt,
43
+ )
44
+ from pluginbench.collectors import (
45
+ Collector,
46
+ collect_measurements,
47
+ freeze_collectors,
48
+ resolve_collector_command,
49
+ )
50
+ from pluginbench.datasets import TaskSpec, load_tasks, native_identity, source_metadata
51
+
52
+
53
+ def normalize_legacy_guidance(value: object) -> object:
54
+ # Old file-only configurations now use native instruction delivery. Historical
55
+ # reports keep their recorded protocol; there is no legacy execution path.
56
+ if isinstance(value, dict) and "guidance" in value:
57
+ if "treatments" in value:
58
+ raise ValueError("use treatments or legacy guidance, not both")
59
+ value = dict(value)
60
+ guidance = value.pop("guidance")
61
+ if not isinstance(guidance, dict):
62
+ raise ValueError("guidance must be a mapping")
63
+ value["treatments"] = {
64
+ name: {"guidance": paths} for name, paths in guidance.items()
65
+ }
66
+ return value
67
+
68
+
69
+ class StudyRecipe(BaseModel):
70
+ model_config = ConfigDict(extra="forbid")
71
+
72
+ # 3: runtimes record declared component versions. Builds before it refuse these runs,
73
+ # so a run is never coded by two launchers that cannot tell each other apart.
74
+ # 4: a task needs the git identity PluginBench supplies. Builds before it would code that
75
+ # task without one, and every commit there would fail. Other runs stay at 3.
76
+ schema_version: Literal[1, 2, 3, 4]
77
+ name: str = Field(min_length=1)
78
+ backend: Literal["harbor"] = "harbor"
79
+ harness: Literal["codex", "claude"] = "codex"
80
+ tasks: Path
81
+ harness_version: str | None = None
82
+ container_cpus: int = Field(default=1, strict=True, ge=1)
83
+ container_memory_mb: int = Field(default=2048, strict=True, ge=256)
84
+ treatments: dict[str, Treatment]
85
+ capabilities: NativeCapabilities = Field(default_factory=NativeCapabilities)
86
+ model: str = Field(default="gpt-6.1-sol", min_length=1)
87
+ reasoning: Literal["minimal", "low", "medium", "high", "xhigh", "max", "ultra"] = "medium"
88
+ repeats: int = Field(default=3, strict=True, ge=1)
89
+ jobs: int = Field(default=1, strict=True, ge=1, le=16)
90
+ timeout_seconds: int = Field(default=600, strict=True, ge=1)
91
+ seed: int = Field(default=42, strict=True)
92
+ auth_file: Path = Path("~/.codex/auth.json")
93
+ collectors: list[Collector] = Field(default_factory=list)
94
+
95
+ @model_validator(mode="before")
96
+ @classmethod
97
+ def legacy_guidance(cls, value: object) -> object:
98
+ return normalize_legacy_guidance(value)
99
+
100
+ @field_validator("treatments")
101
+ @classmethod
102
+ def condition_names(cls, value: dict[str, Treatment]) -> dict[str, Treatment]:
103
+ if any(
104
+ not name.strip() or name in {"baseline", "vanilla"}
105
+ or not (item.guidance or item.skills or item.plugins)
106
+ for name, item in value.items()
107
+ ):
108
+ raise ValueError("treatments need named, nonempty conditions; vanilla is automatic")
109
+ return value
110
+
111
+ @field_validator("collectors")
112
+ @classmethod
113
+ def distinct_collectors(cls, value: list[Collector]) -> list[Collector]:
114
+ if len({collector.kind for collector in value}) != len(value):
115
+ raise ValueError("each collector kind may appear only once")
116
+ return value
117
+
118
+ @model_validator(mode="after")
119
+ def supported_effort(self) -> StudyRecipe:
120
+ if self.reasoning == "ultra":
121
+ raise ValueError("Harbor does not support ultra effort")
122
+ if self.harness == "claude" and self.reasoning not in {
123
+ "low",
124
+ "medium",
125
+ "high",
126
+ "xhigh",
127
+ "max",
128
+ }:
129
+ raise ValueError("unsupported Claude effort")
130
+ return self
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class ResolvedStudy:
135
+ recipe: StudyRecipe
136
+ tasks: tuple[TaskSpec, ...]
137
+ treatments: dict[str, Treatment]
138
+ source: Path
139
+ treatment_selection: dict[str, dict[str, object]] = field(default_factory=dict)
140
+
141
+
142
+ def _resolve(base: Path, path: Path) -> Path:
143
+ expanded = path.expanduser()
144
+ return (expanded if expanded.is_absolute() else base / expanded).resolve()
145
+
146
+
147
+ def _text_files(path: Path) -> list[Path]:
148
+ if any(candidate.is_symlink() for candidate in (path, *path.parents)):
149
+ raise ValueError(f"guidance must not be a symlink: {path}")
150
+ all_candidates = sorted(path.rglob("*")) if path.is_dir() else [path]
151
+ for candidate in all_candidates:
152
+ if candidate.is_symlink():
153
+ raise ValueError(f"guidance contains a symlink: {candidate}")
154
+ selected = path / "skills" if (path / "skills").is_dir() else path
155
+ if selected.is_dir() and not (selected / "SKILL.md").is_file():
156
+ skill_roots = [item for item in selected.iterdir() if (item / "SKILL.md").is_file()]
157
+ candidates = (
158
+ [file for skill in sorted(skill_roots) for file in sorted(skill.rglob("*"))]
159
+ if skill_roots
160
+ else all_candidates
161
+ )
162
+ else:
163
+ candidates = sorted(selected.rglob("*")) if selected.is_dir() else [selected]
164
+ files = []
165
+ for candidate in candidates:
166
+ if not candidate.is_file() or candidate.suffix.lower() not in {".md", ".txt"}:
167
+ if not path.is_dir():
168
+ raise ValueError(f"select text guidance only (.md/.txt): {candidate}")
169
+ continue
170
+ candidate.read_text(encoding="utf-8")
171
+ files.append(candidate.resolve())
172
+ if not files:
173
+ raise ValueError(f"guidance contains no text: {path}")
174
+ return files
175
+
176
+
177
+ def load_study(path: Path) -> ResolvedStudy:
178
+ source = path.resolve()
179
+ recipe = StudyRecipe.model_validate(yaml.safe_load(source.read_text()))
180
+ base = source.parent
181
+ recipe.tasks = _resolve(base, recipe.tasks)
182
+ recipe.auth_file = _resolve(base, recipe.auth_file)
183
+ tasks = _load_tasks(recipe)
184
+ if recipe.schema_version < 4:
185
+ recipe.schema_version = 3
186
+ recipe.treatments = {
187
+ name: resolve_treatment(item, base, recipe.harness)
188
+ for name, item in recipe.treatments.items()
189
+ }
190
+ selection = {
191
+ name: treatment_inventory(item) for name, item in recipe.treatments.items()
192
+ }
193
+ for collector in recipe.collectors:
194
+ collector.config = _resolve(base, collector.config)
195
+ for command_field in ("command", "syntax_command"):
196
+ command = getattr(collector, command_field)
197
+ if command:
198
+ setattr(
199
+ collector,
200
+ command_field,
201
+ resolve_collector_command(base, command),
202
+ )
203
+ return ResolvedStudy(recipe, tasks, recipe.treatments, source, selection)
204
+
205
+
206
+ def _load_tasks(recipe: StudyRecipe) -> tuple[TaskSpec, ...]:
207
+ tasks = load_tasks(recipe.tasks)
208
+ for task in tasks:
209
+ codex.reject_guidance(task.fixture, include_ancestors=False, reject_git=True)
210
+ native_identity(task)
211
+ return tuple(tasks)
212
+
213
+
214
+ def plan_study(study: ResolvedStudy) -> dict[str, Any]:
215
+ recipe = study.recipe
216
+ entries = [
217
+ {"task_id": task.task_id, "arm": arm, "attempt": repeat}
218
+ for repeat in range(1, recipe.repeats + 1)
219
+ for task in study.tasks
220
+ for arm in ["baseline", *study.treatments]
221
+ ]
222
+ random.Random(recipe.seed).shuffle(entries)
223
+ for index, entry in enumerate(entries):
224
+ identity = json.dumps([recipe.seed, index, entry], sort_keys=True).encode()
225
+ entry["id"] = hashlib.sha256(identity).hexdigest()[:16]
226
+ return {
227
+ "schema_version": 1,
228
+ "name": recipe.name,
229
+ "host": recipe.harness,
230
+ "backend": recipe.backend,
231
+ "source_policy": submissions.SOURCE_POLICY,
232
+ "model": recipe.model,
233
+ "requested_reasoning_effort": recipe.reasoning,
234
+ "delivery": DELIVERY_POLICY,
235
+ "treatment_selection": study.treatment_selection,
236
+ "capabilities": recipe.capabilities.model_dump(mode="json"),
237
+ "baseline": "no supplied custom guidance; native built-in instructions remain",
238
+ "seed": recipe.seed,
239
+ "jobs": recipe.jobs,
240
+ "initial_calls": len(entries),
241
+ "entries": entries,
242
+ }
243
+
244
+
245
+ def _write(path: Path, value: object) -> None:
246
+ path.parent.mkdir(parents=True, exist_ok=True)
247
+ temporary = path.with_name(path.name + ".tmp")
248
+ temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
249
+ temporary.replace(path)
250
+
251
+
252
+ def _digest(path: Path) -> str:
253
+ return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
254
+
255
+
256
+ def _reference(root: Path, path: Path, *, snapshot: bool = False) -> dict[str, str]:
257
+ digest = str(inspect_snapshot(path)["digest"]) if snapshot else _digest(path)
258
+ return {"path": path.relative_to(root).as_posix(), "sha256": digest}
259
+
260
+
261
+ def _inventory(root: Path) -> dict[str, str]:
262
+ inventory = {}
263
+ for path in sorted(root.rglob("*")):
264
+ if path.is_symlink():
265
+ raise ValueError(f"frozen input contains a symlink: {path}")
266
+ if path.is_file():
267
+ inventory[path.relative_to(root).as_posix()] = _digest(path)
268
+ inventory[path.relative_to(root).as_posix() + ":mode"] = str(
269
+ path.stat().st_mode & 0o777
270
+ )
271
+ return inventory
272
+
273
+
274
+ def _assert_frozen(root: Path) -> None:
275
+ expected = json.loads((root / "frozen-inventory.json").read_text())
276
+ if _inventory(root / "frozen") != expected:
277
+ raise ValueError("frozen study inputs changed")
278
+ selection = root / "frozen/treatment-selection.json"
279
+ if selection.exists():
280
+ recipe = json.loads((root / "frozen/resolved-recipe.json").read_text())
281
+ actual = {
282
+ name: treatment_inventory(resolve_treatment(
283
+ Treatment.model_validate(value), root / "frozen", recipe["harness"],
284
+ ))
285
+ for name, value in recipe["treatments"].items()
286
+ }
287
+ if actual != json.loads(selection.read_text()):
288
+ raise ValueError("frozen native treatment inventory changed")
289
+
290
+
291
+ def _freeze(study: ResolvedStudy, root: Path) -> ResolvedStudy:
292
+ frozen = root / "frozen"
293
+ frozen.mkdir()
294
+ shutil.copyfile(study.source, frozen / "source-recipe.yaml")
295
+ _freeze_tasks(study, frozen)
296
+ runner = frozen / "runner"
297
+ runner.mkdir()
298
+ for source_file in sorted([*Path(__file__).parent.glob("*.py"), *Path(__file__).parent.glob("native_*.js")]):
299
+ shutil.copyfile(source_file, runner / source_file.name)
300
+ _write(frozen / "treatment-selection.json", study.treatment_selection)
301
+ treatments = {
302
+ name: freeze_treatment(item, frozen / "treatments" / str(index))
303
+ for index, (name, item) in enumerate(study.treatments.items())
304
+ }
305
+ recipe = study.recipe.model_copy(deep=True)
306
+ recipe.tasks = frozen / "tasks.json"
307
+ recipe.treatments = treatments
308
+ recipe.collectors = freeze_collectors(recipe.collectors, frozen / "collectors")
309
+ public_recipe = recipe.model_dump(mode="json", exclude={"auth_file"})
310
+ public_recipe["treatments"] = {
311
+ name: {
312
+ kind: [str(path.relative_to(frozen)) for path in getattr(item, kind)]
313
+ for kind in ("guidance", "skills", "plugins")
314
+ }
315
+ for name, item in treatments.items()
316
+ }
317
+ _write(frozen / "resolved-recipe.json", public_recipe)
318
+ _write(root / "frozen-inventory.json", _inventory(frozen))
319
+ _assert_frozen(root)
320
+ tasks = _load_tasks(recipe)
321
+ return ResolvedStudy(
322
+ recipe,
323
+ tasks,
324
+ treatments,
325
+ study.source,
326
+ study.treatment_selection,
327
+ )
328
+
329
+
330
+ def _freeze_tasks(study: ResolvedStudy, frozen: Path) -> None:
331
+ entries = []
332
+ for index, task in enumerate(study.tasks):
333
+ fixture = frozen / "suite" / str(index) / "source"
334
+ native_task = frozen / "suite" / str(index) / "task"
335
+ shutil.copytree(task.fixture, fixture)
336
+ shutil.copytree(task.native_task, native_task)
337
+ identity = native_identity(task)
338
+ identity.update(task_dir=str(native_task), fixture_dir=str(fixture))
339
+ entries.append(
340
+ {
341
+ "id": task.task_id,
342
+ "prompt": task.prompt,
343
+ "fixture": str(fixture.relative_to(frozen)),
344
+ "native_task": str(native_task.relative_to(frozen)),
345
+ "provenance": {**task.provenance, "native_task_identity": json.dumps(identity)},
346
+ }
347
+ )
348
+ _write(frozen / "tasks.json", {"schema_version": 1, "tasks": entries})
349
+
350
+
351
+ def _native_grade_binding(
352
+ *, snapshot: Path, task: TaskSpec, artifacts: Path, execution: dict[str, Any]
353
+ ) -> dict[str, Any]:
354
+ """Grade only an independently validated canonical submission."""
355
+ verifier = execution.get("native_verifier_result")
356
+ source = submissions.validate(
357
+ before=task.fixture,
358
+ task_dir=task.native_task,
359
+ identity=native_identity(task),
360
+ artifacts=artifacts,
361
+ execution=execution,
362
+ snapshot=snapshot,
363
+ )
364
+ result: dict[str, Any] = {
365
+ **source,
366
+ "pass": None,
367
+ "native_verifier_result": verifier,
368
+ "binding": "canonical patch submission",
369
+ # Legacy report fields identify the canonical submission. validate() above
370
+ # proves reconstruction parity; neither field is a post-verifier export.
371
+ "graded_source_digest": source["snapshot_digest"],
372
+ "measured_source_digest": source["snapshot_digest"],
373
+ }
374
+ if source["trusted_test_overlap"]:
375
+ result["reason"] = TRUSTED_TEST_OVERLAP
376
+ return result
377
+ result["binding"] = "model.patch reconstructs measured source"
378
+ result["pass"] = _binary_native_reward(verifier)
379
+ if result["pass"] is None:
380
+ result["reason"] = "native reward is unavailable or not a single binary result"
381
+ return result
382
+
383
+
384
+ def _collect(
385
+ root: Path,
386
+ snapshot: Path,
387
+ directory: Path,
388
+ collectors: list[Collector],
389
+ *,
390
+ before: Path | None = None,
391
+ ) -> dict[str, dict[str, str]]:
392
+ files = collect_measurements(snapshot, directory, collectors, before=before)
393
+ return {kind: _reference(root, path) for kind, path in files.items()}
394
+
395
+
396
+ def _work(execution: dict[str, Any]) -> dict[str, Any]:
397
+ return {
398
+ "elapsed_seconds": execution.get("elapsed_seconds"),
399
+ "tokens": execution.get("usage"),
400
+ "dollars": execution.get("native_dollars"),
401
+ "token_format": execution.get(
402
+ "token_format", "native Codex; cached input and reasoning output are subsets"
403
+ ),
404
+ }
405
+
406
+
407
+ def _consistent_observation(executions: list[dict[str, Any]], key: str) -> str | None:
408
+ observations = [execution.get(key, []) for execution in executions]
409
+ if not all(isinstance(value, list) and len(value) == 1 for value in observations):
410
+ return None
411
+ values = {value[0] for value in observations}
412
+ return next(iter(values)) if len(values) == 1 else None
413
+
414
+
415
+ def _observed_harness_version(executions: list[dict[str, Any]]) -> str | None:
416
+ versions = [
417
+ codex.observed_cli_version(
418
+ execution.get("startup"),
419
+ host="claude"
420
+ if execution.get("launch", {}).get("harness") == "claude-cli"
421
+ else "codex",
422
+ )
423
+ or execution.get("launch_harness_version")
424
+ for execution in executions
425
+ ]
426
+ return versions[0] if versions and versions[0] and len(set(versions)) == 1 else None
427
+
428
+
429
+ class BackendUnavailable(ValueError):
430
+ """A coding session failed before its first model turn; the run stops instead of scoring it."""
431
+
432
+
433
+ def _call_error(error: BaseException) -> str:
434
+ """The saved error of a failed coding call. A timeout's own text holds its command line."""
435
+ if not isinstance(error, subprocess.TimeoutExpired):
436
+ return f"{type(error).__name__}: {error}"
437
+ command = error.cmd if isinstance(error.cmd, list) else []
438
+ process = "the Harbor worker" if "pluginbench.harbor_backend" in command else "a Docker command"
439
+ return f"{process} exceeded its budget of {error.timeout:g} seconds"
440
+
441
+
442
+ # Attempts that may finish in a row with a failed call and no submission before the run stops.
443
+ CONSECUTIVE_FAILURE_LIMIT = 3
444
+
445
+
446
+ def _failed_call(root: Path, record: dict[str, Any]) -> str | None:
447
+ """`task · arm · repeat: error` for an attempt whose call failed with no submission."""
448
+ for stage in record["stages"]:
449
+ path = root / "attempts" / record["id"] / stage["id"] / "execution.json"
450
+ execution = json.loads(path.read_text()) if path.is_file() else {}
451
+ error = execution.get("error")
452
+ if error and execution.get("submission_collected") is not True:
453
+ if isinstance(error, dict): # Harbor's exception_info for the agent.
454
+ error = f"{error.get('exception_type')}: {error.get('exception_message')}"
455
+ return f"{record['task_id']} · {record['arm']} · repeat {record['attempt']}: {error}"
456
+ return None
457
+
458
+
459
+ def _execute_stage(
460
+ *,
461
+ root: Path,
462
+ area: Path,
463
+ entry: dict[str, Any],
464
+ task: TaskSpec,
465
+ study: ResolvedStudy,
466
+ stage_id: str,
467
+ prompt: str,
468
+ on_native_start: Callable[[], None],
469
+ expected_runtime: dict[str, object],
470
+ guard: Callable[[], None] = lambda: None,
471
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
472
+ """Run one native invocation and persist its evidence under root.
473
+
474
+ `guard` runs before each write to root. A run moved during the call therefore writes
475
+ nothing more to its old path; that call's evidence is lost with its temporary work area,
476
+ and resume discards the attempt by its invocation receipt.
477
+ """
478
+ _assert_frozen(root)
479
+ interrupted: KeyboardInterrupt | None = None
480
+ try:
481
+ codex.reject_guidance(area / "workspace", include_ancestors=True, reject_git=False)
482
+ on_native_start()
483
+ execution = harbor_backend.invoke_native(
484
+ area=area,
485
+ task_dir=task.native_task,
486
+ entry={**entry, "host": study.recipe.harness},
487
+ protocol={
488
+ "models": {study.recipe.harness: study.recipe.model},
489
+ "reasoning": study.recipe.reasoning,
490
+ "isolation": expected_runtime,
491
+ "native_task_identity": native_identity(task),
492
+ "harness_version": study.recipe.harness_version,
493
+ "container_cpus": study.recipe.container_cpus,
494
+ "container_memory_mb": study.recipe.container_memory_mb,
495
+ "treatment": study.treatments.get(str(entry["arm"]), Treatment()).model_dump(mode="json"),
496
+ "capabilities": study.recipe.capabilities.model_dump(mode="json"),
497
+ "delivery": DELIVERY_POLICY,
498
+ **(
499
+ {"auth_file": str(study.recipe.auth_file)}
500
+ if study.recipe.harness == "codex"
501
+ else {}
502
+ ),
503
+ },
504
+ prompt=prompt,
505
+ timeout=study.recipe.timeout_seconds,
506
+ )
507
+ # A worker process that overruns its budget is a failed call like any other.
508
+ except (OSError, ValueError, subprocess.TimeoutExpired, KeyboardInterrupt) as exc:
509
+ artifacts = area / "artifacts"
510
+ execution = codex.parse_events(
511
+ "",
512
+ [],
513
+ study.recipe.harness,
514
+ requested_effort=study.recipe.reasoning,
515
+ )
516
+ try:
517
+ codex.validate_artifacts(artifacts)
518
+ except (OSError, ValueError):
519
+ pass
520
+ else:
521
+ stdout = artifacts / "stdout.jsonl"
522
+ execution.update(
523
+ codex.parse_events(
524
+ stdout.read_text() if stdout.is_file() else "",
525
+ [path.read_text() for path in sorted(artifacts.glob("host-trace-*.jsonl"))],
526
+ study.recipe.harness,
527
+ requested_effort=study.recipe.reasoning,
528
+ )
529
+ )
530
+ runtime_receipt = artifacts / "execution.json"
531
+ if runtime_receipt.is_file():
532
+ execution.update(json.loads(runtime_receipt.read_text()))
533
+ execution.update(successful_native_completion=False, error=_call_error(exc))
534
+ if isinstance(exc, subprocess.TimeoutExpired):
535
+ # The worker never returned a verifier result, so a patch it collected cannot be
536
+ # graded. Left as collected, the attempt would be scored as a fail.
537
+ execution["submission_collected"] = False
538
+ if isinstance(exc, KeyboardInterrupt):
539
+ interrupted = exc
540
+ guard()
541
+ target = root / "attempts" / entry["id"] / stage_id
542
+ target.mkdir(parents=True)
543
+ artifacts = area / "artifacts"
544
+ artifact_error = None
545
+ if artifacts.exists() or artifacts.is_symlink():
546
+ artifact_error = codex.copy_artifacts(artifacts, target / "artifacts")
547
+ if artifacts.is_symlink():
548
+ artifacts.unlink()
549
+ else:
550
+ shutil.rmtree(artifacts)
551
+ if artifact_error:
552
+ execution = {
553
+ **codex.parse_events("", [], study.recipe.harness),
554
+ "successful_native_completion": False,
555
+ "submission_collected": False,
556
+ "error": artifact_error,
557
+ }
558
+ _write(target / "execution.json", execution)
559
+ if interrupted is not None:
560
+ shutil.copytree(
561
+ area / "workspace",
562
+ target / "interrupted-source",
563
+ symlinks=True,
564
+ ignore=shutil.ignore_patterns(*EXCLUDED_NAMES),
565
+ )
566
+ _write(
567
+ target / "interrupted-source.json",
568
+ {"status": "unsubmitted interrupted workspace; not graded"},
569
+ )
570
+ raise interrupted
571
+ backend_error = failed_before_first_turn(execution)
572
+ if backend_error is not None:
573
+ # Never recorded as complete: the attempt stays interrupted, so a resume discards it
574
+ # and codes its slot again, and it never reaches the vanilla cache.
575
+ raise BackendUnavailable(
576
+ f"{study.recipe.harness} failed before its first model turn in attempt {entry['id']} "
577
+ f"({entry['task_id']} · {entry['arm']} · repeat {entry['attempt']}): {backend_error}. "
578
+ "The attempt is not scored and the run stops. Resume the run once the backend is "
579
+ "reachable; this attempt is coded again"
580
+ )
581
+ if artifact_error or execution.get("submission_collected") is not True:
582
+ exported_digest = execution.get("submission_export_digest")
583
+ if not artifact_error and isinstance(exported_digest, str):
584
+ try:
585
+ exported = inspect_snapshot(area / "workspace")
586
+ if exported["digest"] != exported_digest:
587
+ raise ValueError("stopped export changed before failure evidence was frozen")
588
+ shutil.copytree(
589
+ area / "workspace",
590
+ target / "ungraded-submission",
591
+ symlinks=True,
592
+ ignore=shutil.ignore_patterns(*EXCLUDED_NAMES),
593
+ )
594
+ _write(
595
+ target / "ungraded-submission.json",
596
+ {
597
+ "status": "stopped export; cleanup proof incomplete; not graded",
598
+ "digest": exported_digest,
599
+ },
600
+ )
601
+ except ValueError as error:
602
+ execution["error"] = str(error)
603
+ _write(target / "execution.json", execution)
604
+ _write(
605
+ target / "unsupported-submission.json",
606
+ {"error": execution.get("error", "Harbor did not freeze a complete submission")},
607
+ )
608
+ return (
609
+ {
610
+ "id": stage_id,
611
+ "snapshot": None,
612
+ "correctness": None,
613
+ "evidence": None,
614
+ "work": _work(execution),
615
+ },
616
+ execution,
617
+ )
618
+ try:
619
+ exported = inspect_snapshot(area / "workspace")
620
+ if exported["digest"] != execution.get("submission_export_digest"):
621
+ raise ValueError("stopped native export changed before submission freezing")
622
+ except ValueError as exc:
623
+ raw = target / "unsupported-submission"
624
+ shutil.copytree(area / "workspace", raw, symlinks=True)
625
+ links = {}
626
+ for directory, names, files in os.walk(raw, followlinks=False):
627
+ for name in [*names, *files]:
628
+ path = Path(directory) / name
629
+ if path.is_symlink():
630
+ links[path.relative_to(raw).as_posix()] = os.readlink(path)
631
+ _write(target / "unsupported-submission.json", {"error": str(exc), "symlinks": links})
632
+ return (
633
+ {
634
+ "id": stage_id,
635
+ "snapshot": None,
636
+ "correctness": None,
637
+ "evidence": None,
638
+ "work": _work(execution),
639
+ },
640
+ execution,
641
+ )
642
+ diagnostic = target / "diagnostic-source"
643
+ shutil.copytree(
644
+ area / "workspace",
645
+ diagnostic,
646
+ symlinks=True,
647
+ ignore=shutil.ignore_patterns(*EXCLUDED_NAMES),
648
+ )
649
+ snapshot = target / "snapshot"
650
+ try:
651
+ submissions.validate_export(
652
+ diagnostic=diagnostic, artifacts=target / "artifacts", execution=execution
653
+ )
654
+ submissions.reconstruct(
655
+ before=task.fixture,
656
+ task_dir=task.native_task,
657
+ identity=native_identity(task),
658
+ artifacts=target / "artifacts",
659
+ execution=execution,
660
+ destination=snapshot,
661
+ )
662
+ except (OSError, ValueError) as error:
663
+ _write(
664
+ target / "unsupported-submission.json",
665
+ {
666
+ "source_policy": submissions.SOURCE_POLICY,
667
+ "error": str(error),
668
+ "diagnostic_source_path": diagnostic.name,
669
+ "diagnostic_source_digest": inspect_snapshot(diagnostic)["digest"],
670
+ "diagnostic_source_metadata_digest": harbor_backend.native_task_assets(diagnostic),
671
+ "diagnostic_artifacts_metadata_digest": harbor_backend.native_task_assets(
672
+ target / "artifacts"
673
+ ),
674
+ },
675
+ )
676
+ return (
677
+ {
678
+ "id": stage_id,
679
+ "snapshot": None,
680
+ "correctness": None,
681
+ "evidence": None,
682
+ "work": _work(execution),
683
+ },
684
+ execution,
685
+ )
686
+ guard()
687
+ stage = _verify_snapshot(
688
+ root=root,
689
+ snapshot=snapshot,
690
+ task=task,
691
+ study=study,
692
+ target=target,
693
+ stage_id=stage_id,
694
+ execution=execution,
695
+ )
696
+ return stage, execution
697
+
698
+
699
+ def _verify_snapshot(
700
+ *,
701
+ root: Path,
702
+ snapshot: Path,
703
+ task: TaskSpec,
704
+ study: ResolvedStudy,
705
+ target: Path,
706
+ stage_id: str,
707
+ execution: dict[str, Any],
708
+ ) -> dict[str, Any]:
709
+ snapshot_ref = _reference(root, snapshot, snapshot=True)
710
+ metadata = source_metadata(snapshot)
711
+ result = _native_grade_binding(
712
+ snapshot=snapshot, task=task, artifacts=target / "artifacts", execution=execution
713
+ )
714
+ _write(target / "verifier-result.json", result)
715
+ checks: dict[str, bool | None] = {
716
+ "behavior": result["pass"],
717
+ "verifier": result["pass"],
718
+ "native_completion": execution.get("successful_native_completion") is True,
719
+ }
720
+ receipt = CorrectnessReceipt(
721
+ schema_version=1,
722
+ snapshot_digest=snapshot_ref["sha256"],
723
+ checks=checks,
724
+ command=["Harbor", "native-verifier"],
725
+ tool="native Harbor verifier; source-bound model.patch",
726
+ )
727
+ _write(target / "correctness.json", receipt.model_dump(mode="json"))
728
+ evidence = _collect(
729
+ root, snapshot, target / "evidence", study.recipe.collectors, before=task.fixture
730
+ )
731
+ if (
732
+ _reference(root, snapshot, snapshot=True) != snapshot_ref
733
+ or source_metadata(snapshot) != metadata
734
+ ):
735
+ raise ValueError("submitted snapshot changed during verification or collection")
736
+ _assert_frozen(root)
737
+ return {
738
+ "id": stage_id,
739
+ "snapshot": snapshot_ref,
740
+ "correctness": _reference(root, target / "correctness.json"),
741
+ "evidence": evidence or None,
742
+ "work": _work(execution),
743
+ }
744
+
745
+
746
+ def _versioned_artifact(path: Path) -> Path:
747
+ version = path.with_name(path.stem + "." + _digest(path).removeprefix("sha256:") + path.suffix)
748
+ if not version.exists():
749
+ # Copy, then rename, so a killed copy never leaves a partial version that looks whole.
750
+ temporary = version.with_name(version.name + ".tmp")
751
+ shutil.copyfile(path, temporary)
752
+ temporary.replace(version)
753
+ return version
754
+
755
+
756
+ def _checkpoint(
757
+ root: Path,
758
+ study: ResolvedStudy,
759
+ manifest: dict[str, Any],
760
+ states: dict[str, str],
761
+ *,
762
+ status: str,
763
+ error: str | None = None,
764
+ ) -> None:
765
+ """Keep assigned attempts visible, including pending and interrupted work."""
766
+ existing = json.loads((root / "run.json").read_text()) if (root / "run.json").is_file() else {}
767
+ for record in manifest["records"]:
768
+ for stage in record["stages"]:
769
+ execution = root / "attempts" / record["id"] / stage["id"] / "execution.json"
770
+ if stage["snapshot"] is None and execution.is_file():
771
+ stage["work"] = _work(json.loads(execution.read_text()))
772
+ AssessmentInput.model_validate(manifest)
773
+ _write(root / "assessment-input.json", manifest)
774
+ checkpoint_input = _versioned_artifact(root / "assessment-input.json")
775
+ checkpoint_preflight = _versioned_artifact(root / "runtime-preflight.json")
776
+ # plan.json is rewritten on resume; run.json binds the version this checkpoint used.
777
+ checkpoint_plan = _versioned_artifact(root / "plan.json")
778
+ executions: dict[str, dict[str, dict[str, str]]] = {}
779
+ verifier_results: dict[str, dict[str, dict[str, str]]] = {}
780
+ submission_errors: dict[str, dict[str, dict[str, str]]] = {}
781
+ for record in manifest["records"]:
782
+ executions[record["id"]] = {}
783
+ verifier_results[record["id"]] = {}
784
+ submission_errors[record["id"]] = {}
785
+ for stage in record["stages"]:
786
+ target = root / "attempts" / record["id"] / stage["id"]
787
+ progress = root / "progress" / record["id"] / stage["id"]
788
+ execution = target / "execution.json"
789
+ if not execution.is_file():
790
+ execution = progress / f"execution.{states[record['id']]}.json"
791
+ _write(execution, {"attempt_status": states[record["id"]], "usage": None})
792
+ executions[record["id"]][stage["id"]] = _reference(root, execution)
793
+ verifier_result = target / "verifier-result.json"
794
+ if verifier_result.is_file():
795
+ verifier_results[record["id"]][stage["id"]] = _reference(root, verifier_result)
796
+ if stage["snapshot"] is None:
797
+ failure = target / "unsupported-submission.json"
798
+ if not failure.is_file():
799
+ failure = progress / f"submission.{states[record['id']]}.json"
800
+ _write(failure, {"status": states[record["id"]], "error": error})
801
+ submission_errors[record["id"]][stage["id"]] = _reference(root, failure)
802
+ invocation_starts = {
803
+ path.name: {marker.stem: _reference(root, marker) for marker in sorted(path.glob("*.json"))}
804
+ for path in sorted((root / "invocations").glob("*"))
805
+ if path.is_dir()
806
+ }
807
+ calls = sum(len(starts) for starts in invocation_starts.values())
808
+ _write(
809
+ root / "run.json",
810
+ {
811
+ **{key: existing[key] for key in ("evidence_kind",) if key in existing},
812
+ "schema_version": 1,
813
+ "status": status,
814
+ "error": error,
815
+ "attempt_states": states,
816
+ "completed_attempts": sum(state == "complete" for state in states.values()),
817
+ "initial_calls": len(states),
818
+ "actual_calls": calls,
819
+ "invocation_starts": invocation_starts,
820
+ "jobs": study.recipe.jobs,
821
+ "call_unit": "native invocation requests; startup may fail before a model call",
822
+ "assessment_input": _reference(root, checkpoint_input),
823
+ "plan": _reference(root, checkpoint_plan),
824
+ "runtime_preflight": _reference(root, checkpoint_preflight),
825
+ "task_provenance": {
826
+ task.task_id: task.provenance for task in study.tasks if task.provenance is not None
827
+ },
828
+ "calibration": _reference(root, root / "calibration.json")
829
+ if (root / "calibration.json").is_file()
830
+ else None,
831
+ "executions": executions,
832
+ "verifier_results": verifier_results,
833
+ "submission_errors": submission_errors,
834
+ "baseline_origin": {
835
+ path.parent.name: {
836
+ **json.loads(path.read_text()),
837
+ "evidence": _reference(root, path),
838
+ }
839
+ for path in sorted((root / "baseline-evidence").glob("*/origin.json"))
840
+ },
841
+ # Interrupted attempts a resume replaced; their calls stay in actual_calls.
842
+ "discarded_attempts": {
843
+ path.stem: {**json.loads(path.read_text()), "evidence": _reference(root, path)}
844
+ for path in sorted((root / "discarded").glob("*.json"))
845
+ },
846
+ },
847
+ )
848
+
849
+
850
+ def _pending_record(entry: dict[str, Any]) -> dict[str, Any]:
851
+ return {
852
+ **{key: entry[key] for key in ("id", "task_id", "attempt", "arm")},
853
+ "model": None,
854
+ "stages": [{"id": "handoff", "snapshot": None, "work": None}],
855
+ }
856
+
857
+
858
+ @dataclass
859
+ class _Run:
860
+ """The state one coding pass shares; only the coordinator thread changes it."""
861
+
862
+ root: Path
863
+ study: ResolvedStudy
864
+ plan: dict[str, Any]
865
+ manifest: dict[str, Any]
866
+ states: dict[str, str]
867
+ ledger: Ledger
868
+
869
+ @property
870
+ def records(self) -> list[dict[str, Any]]:
871
+ records: list[dict[str, Any]] = self.manifest["records"]
872
+ return records
873
+
874
+
875
+ def run_study(
876
+ study: ResolvedStudy,
877
+ output: Path,
878
+ *,
879
+ progress: Callable[[str], None] | None = None,
880
+ baseline_cache: Path | None = None,
881
+ baseline_identity: dict[str, object] | None = None,
882
+ guard: Callable[[], None] | None = None,
883
+ ) -> Path:
884
+ validate_jobs(study.recipe.jobs)
885
+ for treatment in study.treatments.values():
886
+ admit(treatment, study.recipe.harness)
887
+ if baseline_cache is not None and not baseline_identity:
888
+ raise ValueError("baseline reuse requires a fixed coding identity")
889
+ root = output.resolve()
890
+ if root.exists():
891
+ raise ValueError(f"study output must be a new directory: {root}")
892
+ root.mkdir(parents=True)
893
+ frozen = _freeze(study, root)
894
+ plan = plan_study(frozen)
895
+ _write(root / "plan.json", plan)
896
+ preflight: dict[str, Any] = {"status": "not started"}
897
+ _write(root / "runtime-preflight.json", preflight)
898
+ states = {entry["id"]: "pending" for entry in plan["entries"]}
899
+ manifest: dict[str, Any] = {
900
+ "schema_version": 1,
901
+ "name": study.recipe.name,
902
+ "records": [_pending_record(entry) for entry in plan["entries"]],
903
+ }
904
+ _checkpoint(root, frozen, manifest, states, status="preflight")
905
+ ledger = Ledger.create(root / LEDGER_NAME, plan["entries"], states)
906
+ return _run_attempts(
907
+ _Run(root, frozen, plan, manifest, states, ledger),
908
+ progress=progress,
909
+ baseline_cache=baseline_cache,
910
+ baseline_identity=baseline_identity,
911
+ guard=guard,
912
+ )
913
+
914
+
915
+ def _frozen_study(root: Path) -> ResolvedStudy:
916
+ """The study frozen in `root`, at root's current path.
917
+
918
+ Saved absolute paths may name the location where the run started. Tasks and treatments are
919
+ read from this copy instead, so a moved, stopped run never uses another run's files.
920
+ """
921
+ frozen = root / "frozen"
922
+ saved = json.loads((frozen / "resolved-recipe.json").read_text())
923
+ source = StudyRecipe.model_validate(yaml.safe_load((frozen / "source-recipe.yaml").read_text()))
924
+ auth_file = source.auth_file.expanduser()
925
+ if not auth_file.is_absolute():
926
+ raise ValueError("resuming a study needs an absolute auth_file in its source recipe")
927
+ recipe = StudyRecipe.model_validate(
928
+ {**saved, "tasks": frozen / "tasks.json", "auth_file": auth_file}
929
+ )
930
+ for collector in recipe.collectors:
931
+ if not collector.config.resolve().is_relative_to(frozen.resolve()):
932
+ raise ValueError("study collectors were frozen at another path; resume them there")
933
+ recipe.treatments = {
934
+ name: resolve_treatment(item, frozen, recipe.harness)
935
+ for name, item in recipe.treatments.items()
936
+ }
937
+ tasks = tuple(
938
+ dataclasses.replace(
939
+ task,
940
+ provenance={
941
+ **task.provenance,
942
+ "native_task_identity": json.dumps(
943
+ {
944
+ **native_identity(task),
945
+ "task_dir": str(task.native_task),
946
+ "fixture_dir": str(task.fixture),
947
+ }
948
+ ),
949
+ },
950
+ )
951
+ for task in _load_tasks(recipe)
952
+ )
953
+ return ResolvedStudy(
954
+ recipe,
955
+ tasks,
956
+ recipe.treatments,
957
+ frozen / "source-recipe.yaml",
958
+ json.loads((frozen / "treatment-selection.json").read_text()),
959
+ )
960
+
961
+
962
+ def _replacement(plan: dict[str, Any], slot: int, discarded: dict[str, Any]) -> dict[str, Any]:
963
+ """Same task, arm, repeat and slot as the discarded attempt, under a new derived ID."""
964
+ entry = {key: discarded[key] for key in ("task_id", "arm", "attempt")}
965
+ identity = json.dumps([plan["seed"], slot, entry, "replaces", discarded["id"]], sort_keys=True)
966
+ return {**entry, "id": hashlib.sha256(identity.encode()).hexdigest()[:16]}
967
+
968
+
969
+ def _discard(root: Path, slot: int, entry: dict[str, Any], replacement: str) -> None:
970
+ """Write the discarded attempt's receipt: its started invocations and any saved usage."""
971
+ starts = sorted((root / "invocations" / entry["id"]).glob("*.json"))
972
+ stages = {}
973
+ for marker in starts:
974
+ execution = root / "attempts" / entry["id"] / marker.stem / "execution.json"
975
+ saved = json.loads(execution.read_text()) if execution.is_file() else None
976
+ stages[marker.stem] = {
977
+ "invocation_start": _reference(root, marker),
978
+ "execution": _reference(root, execution) if saved is not None else None,
979
+ "work": _work(saved) if saved is not None else None,
980
+ "usage_status": "saved before the interruption"
981
+ if saved is not None
982
+ else "unknown: the run was killed before this attempt saved an execution receipt",
983
+ }
984
+ _write(
985
+ root / "discarded" / f"{entry['id']}.json",
986
+ {
987
+ **{key: entry[key] for key in ("id", "task_id", "arm", "attempt")},
988
+ "slot": slot,
989
+ "state": "discarded-interrupted",
990
+ "replaced_by": replacement,
991
+ "stages": stages,
992
+ },
993
+ )
994
+
995
+
996
+ def _reconcile(
997
+ root: Path, run: dict[str, Any], ledger: Ledger
998
+ ) -> tuple[dict[str, Any], dict[str, Any], dict[str, str]]:
999
+ """Plan, records and states for the next coding pass; never discards finished work.
1000
+
1001
+ - complete, or a record receipt on disk: kept as complete.
1002
+ - started (an invocation receipt exists) but unfinished: discarded-interrupted, and its slot
1003
+ gets a new pending attempt. Its model call is never replayed.
1004
+ - never started: pending again; files it left are observer copies with no paid evidence.
1005
+
1006
+ Each receipt is written before the ledger changes, and the ledger decides which attempt owns
1007
+ each slot, so a resume that is itself interrupted can run again.
1008
+ """
1009
+ plan = _bound_document(root, run["plan"])
1010
+ checkpoint = _bound_document(root, run["assessment_input"])
1011
+ saved = {record["id"]: record for record in checkpoint["records"]}
1012
+ live = {
1013
+ row["slot"]: row for row in ledger.attempts() if row["state"] != "discarded-interrupted"
1014
+ }
1015
+ if sorted(live) != list(range(len(plan["entries"]))):
1016
+ raise ValueError("ledger slots differ from the saved study plan")
1017
+ records = []
1018
+ states: dict[str, str] = {}
1019
+ for slot in range(len(plan["entries"])):
1020
+ row = live[slot]
1021
+ entry = {key: row[key] for key in ("id", "task_id", "arm", "attempt")}
1022
+ receipt = root / "records" / f"{entry['id']}.json"
1023
+ if row["state"] == "complete" or receipt.is_file():
1024
+ if row["record"] is not None:
1025
+ record = _bound_document(root, row["record"])
1026
+ elif receipt.is_file():
1027
+ record = json.loads(receipt.read_text())
1028
+ stages = [stage["id"] for stage in record["stages"]]
1029
+ ledger.complete(entry["id"], _reference(root, receipt), stages)
1030
+ else:
1031
+ # Older runs kept records only in assessment-input.json.
1032
+ record = saved[entry["id"]]
1033
+ records.append(record)
1034
+ states[entry["id"]] = "complete"
1035
+ elif not list((root / "invocations" / entry["id"]).glob("*.json")):
1036
+ if row["state"] != "pending":
1037
+ for leftover in ("attempts", "baseline-evidence"):
1038
+ if (root / leftover / entry["id"]).exists():
1039
+ shutil.rmtree(root / leftover / entry["id"])
1040
+ ledger.set_state(entry["id"], "pending")
1041
+ records.append(_pending_record(entry))
1042
+ states[entry["id"]] = "pending"
1043
+ else:
1044
+ replacement = _replacement(plan, slot, entry)
1045
+ _discard(root, slot, entry, replacement["id"])
1046
+ ledger.replace(entry["id"], replacement)
1047
+ records.append(_pending_record(replacement))
1048
+ states[replacement["id"]] = "pending"
1049
+ entry = replacement
1050
+ if plan["entries"][slot]["id"] != entry["id"]:
1051
+ decisions = plan.get("baseline_cache")
1052
+ if decisions and plan["entries"][slot]["id"] in decisions:
1053
+ decisions[entry["id"]] = decisions.pop(plan["entries"][slot]["id"])
1054
+ plan["entries"][slot] = entry
1055
+ _write(root / "plan.json", plan)
1056
+ return plan, {**checkpoint, "records": records}, states
1057
+
1058
+
1059
+ def resume_study(
1060
+ output: Path,
1061
+ *,
1062
+ progress: Callable[[str], None] | None = None,
1063
+ baseline_cache: Path | None = None,
1064
+ baseline_identity: dict[str, object] | None = None,
1065
+ guard: Callable[[], None] | None = None,
1066
+ before_coding: Callable[[], None] | None = None,
1067
+ ) -> Path:
1068
+ """Code every slot that has no complete attempt; keep complete attempts as they are.
1069
+
1070
+ `before_coding` runs once reconciliation has found a slot left to code, and may refuse.
1071
+ A run.json older than the ledger and the record receipts cannot tell that.
1072
+ """
1073
+ root = output.resolve()
1074
+ if baseline_cache is not None and not baseline_identity:
1075
+ raise ValueError("baseline reuse requires a fixed coding identity")
1076
+ _assert_frozen(root)
1077
+ run = json.loads((root / "run.json").read_text())
1078
+ if run["status"] == "complete":
1079
+ return root
1080
+ frozen = _frozen_study(root)
1081
+ validate_jobs(frozen.recipe.jobs)
1082
+ for treatment in frozen.treatments.values():
1083
+ admit(treatment, frozen.recipe.harness)
1084
+ path = root / LEDGER_NAME
1085
+ # Runs from before the ledger kept their states only in run.json.
1086
+ ledger = Ledger.open(path) if path.exists() else Ledger.create(
1087
+ path, _bound_document(root, run["plan"])["entries"], run["attempt_states"]
1088
+ )
1089
+ try:
1090
+ plan, manifest, states = _reconcile(root, run, ledger)
1091
+ if before_coding is not None and any(state != "complete" for state in states.values()):
1092
+ before_coding()
1093
+ if progress:
1094
+ kept = sum(state == "complete" for state in states.values())
1095
+ discarded = len(list((root / "discarded").glob("*.json")))
1096
+ progress(
1097
+ f"Resuming: {kept} complete attempts kept, {len(states) - kept} to code, "
1098
+ f"{discarded} interrupted attempts discarded"
1099
+ )
1100
+ _checkpoint(root, frozen, manifest, states, status="running")
1101
+ except BaseException:
1102
+ # `_run_attempts` closes the ledger once it owns the run.
1103
+ ledger.close()
1104
+ raise
1105
+ return _run_attempts(
1106
+ _Run(root, frozen, plan, manifest, states, ledger),
1107
+ progress=progress,
1108
+ baseline_cache=baseline_cache,
1109
+ baseline_identity=baseline_identity,
1110
+ guard=guard,
1111
+ resumed=True,
1112
+ )
1113
+
1114
+
1115
+ def _assert_task_runtimes(frozen: ResolvedStudy, current: dict[str, object]) -> None:
1116
+ """New attempts join those coded before: each task's saved runtime must still hold.
1117
+
1118
+ A task prepared on another Docker engine is checked for its images before any call.
1119
+ """
1120
+ checked: list[dict[str, Any]] = []
1121
+ for task in frozen.tasks:
1122
+ prepared = native_identity(task)
1123
+ saved = prepared.get("isolation")
1124
+ if not isinstance(saved, dict):
1125
+ raise ValueError(f"task {task.task_id} has no saved runtime")
1126
+ contract = harbor_backend.runtime_contract(saved)
1127
+ if contract not in checked:
1128
+ checked.append(contract)
1129
+ harbor_backend.assert_runtime(saved, jobs=frozen.recipe.jobs)
1130
+ engines = harbor_backend.engine_id(saved), harbor_backend.engine_id(current)
1131
+ if None not in engines and engines[0] != engines[1]:
1132
+ harbor_backend.assert_prepared_images(prepared, current, label=f"task {task.task_id}")
1133
+
1134
+
1135
+ def _run_attempts(
1136
+ run: _Run,
1137
+ *,
1138
+ progress: Callable[[str], None] | None,
1139
+ baseline_cache: Path | None,
1140
+ baseline_identity: dict[str, object] | None,
1141
+ guard: Callable[[], None] | None,
1142
+ resumed: bool = False,
1143
+ ) -> Path:
1144
+ """Code every slot whose attempt is not complete, checkpointing each state change.
1145
+
1146
+ A resumed pass first stops the containers a killed attempt left running.
1147
+ """
1148
+ root, frozen, plan, manifest, states, ledger = (
1149
+ run.root, run.study, run.plan, run.manifest, run.states, run.ledger
1150
+ )
1151
+ records = run.records
1152
+ tasks = {task.task_id: task for task in frozen.tasks}
1153
+ cache = None
1154
+ # A run stopped after its last attempt finished has nothing to code. It finishes without
1155
+ # the coding runtime, which may have changed since, and caches finished vanilla repeats
1156
+ # under the runtime they were coded with.
1157
+ coding = any(state != "complete" for state in states.values())
1158
+
1159
+ def skipped(error: Exception) -> None:
1160
+ if progress:
1161
+ progress(
1162
+ f"Some finished vanilla repeats were not cached in {baseline_cache}: "
1163
+ f"{type(error).__name__}: {error}"
1164
+ )
1165
+
1166
+ try:
1167
+ expected_runtime: dict[str, object] = {}
1168
+ requested_runtime = baseline_identity.get("isolation") if baseline_identity else None
1169
+ if isinstance(requested_runtime, dict):
1170
+ expected_runtime = requested_runtime
1171
+ elif baseline_identity is not None and coding:
1172
+ raise ValueError("baseline reuse requires a fixed filesystem isolation identity")
1173
+ if coding:
1174
+ expected_runtime = (
1175
+ harbor_backend.assert_runtime(expected_runtime, jobs=frozen.recipe.jobs)
1176
+ if baseline_identity is not None
1177
+ else harbor_backend.runtime_identity(
1178
+ cpus=frozen.recipe.container_cpus,
1179
+ memory_mb=frozen.recipe.container_memory_mb,
1180
+ )
1181
+ )
1182
+ _assert_task_runtimes(frozen, expected_runtime)
1183
+ if resumed:
1184
+ trial_containers.stop_left_running(
1185
+ root, expected_runtime=expected_runtime, progress=progress
1186
+ )
1187
+ if baseline_cache is not None:
1188
+ assert baseline_identity is not None
1189
+ try:
1190
+ if not expected_runtime:
1191
+ raise ValueError("baseline reuse requires a fixed filesystem isolation identity")
1192
+ cache = BaselineCache(
1193
+ path=baseline_cache,
1194
+ root=root,
1195
+ tasks=frozen.tasks,
1196
+ entries=plan["entries"],
1197
+ identity={
1198
+ **baseline_identity,
1199
+ # New entries record the runtime they run with; saved ones still match.
1200
+ "isolation": expected_runtime,
1201
+ "delivery": DELIVERY_POLICY,
1202
+ "capabilities": frozen.recipe.capabilities.model_dump(mode="json"),
1203
+ "study": {
1204
+ key: getattr(frozen.recipe, key)
1205
+ for key in (
1206
+ "harness",
1207
+ "model",
1208
+ "reasoning",
1209
+ "jobs",
1210
+ "timeout_seconds",
1211
+ "seed",
1212
+ )
1213
+ },
1214
+ },
1215
+ ).reserve()
1216
+ except Exception as error:
1217
+ # With nothing left to code, caching finished repeats is best effort: no
1218
+ # failure of the cache, whatever its type, may decide that the run finishes.
1219
+ if coding:
1220
+ raise
1221
+ skipped(error)
1222
+ if cache is not None:
1223
+ # Decisions for complete attempts record what happened when they ran.
1224
+ decisions = plan.get("baseline_cache", {})
1225
+ decisions.update(
1226
+ {
1227
+ attempt_id: decision
1228
+ for attempt_id, decision in cache.decisions().items()
1229
+ if states[attempt_id] != "complete"
1230
+ }
1231
+ )
1232
+ plan["baseline_cache"] = decisions
1233
+ _write(root / "plan.json", plan)
1234
+ _checkpoint(root, frozen, manifest, states, status="preflight")
1235
+ # A kill between completion and publication leaves a finished repeat uncached.
1236
+ try:
1237
+ for record in records:
1238
+ if states[record["id"]] == "complete":
1239
+ cache.publish(record)
1240
+ except Exception as error:
1241
+ if coding:
1242
+ raise
1243
+ cache.close()
1244
+ cache = None
1245
+ skipped(error)
1246
+ if coding:
1247
+ preflight = harbor_backend.preflight(
1248
+ jobs=frozen.recipe.jobs,
1249
+ cpus=frozen.recipe.container_cpus,
1250
+ memory_mb=frozen.recipe.container_memory_mb,
1251
+ )
1252
+ preflight["filesystem_isolation"] = expected_runtime
1253
+ preflight["harness_version"] = frozen.recipe.harness_version
1254
+ if guard:
1255
+ guard() # Preflight checks Docker, which is slow enough for the run to be moved.
1256
+ _write(root / "runtime-preflight.json", preflight)
1257
+ _checkpoint(
1258
+ root,
1259
+ frozen,
1260
+ manifest,
1261
+ states,
1262
+ status="running",
1263
+ )
1264
+
1265
+ # Attempts that made a call in this invocation, and the latest of them, in the order
1266
+ # they finished, that each ended with an error and no submission.
1267
+ called: set[str] = set()
1268
+ failed_in_a_row: list[str] = []
1269
+
1270
+ def commit(index: int, record: dict[str, Any], status: str) -> None:
1271
+ if guard:
1272
+ guard()
1273
+ records[index] = record
1274
+ states[record["id"]] = status
1275
+ if status == "complete":
1276
+ # The record receipt is on disk before the ledger calls the attempt complete.
1277
+ receipt = root / "records" / f"{record['id']}.json"
1278
+ _write(receipt, record)
1279
+ ledger.complete(
1280
+ record["id"],
1281
+ _reference(root, receipt),
1282
+ [stage["id"] for stage in record["stages"]],
1283
+ )
1284
+ else:
1285
+ ledger.set_state(record["id"], status)
1286
+ _checkpoint(root, frozen, manifest, states, status="running")
1287
+ if cache is not None and status == "complete":
1288
+ # Cached as it finishes, so a run that stops later still keeps it for reuse.
1289
+ cache.publish(record)
1290
+ if status == "complete" and record["id"] in called:
1291
+ error = _failed_call(root, record)
1292
+ if error is None:
1293
+ failed_in_a_row.clear()
1294
+ else:
1295
+ failed_in_a_row.append(error)
1296
+ if len(failed_in_a_row) == CONSECUTIVE_FAILURE_LIMIT:
1297
+ # A setup that fails every call must not spend the whole budget.
1298
+ raise ValueError(
1299
+ f"the last {CONSECUTIVE_FAILURE_LIMIT} attempts to finish each failed "
1300
+ "without a submission, so no new attempt starts: "
1301
+ + "; ".join(failed_in_a_row)
1302
+ + ". They stay recorded. `pluginbench run <run-dir>` codes the pending "
1303
+ "slots"
1304
+ )
1305
+
1306
+ def invoked(index: int, record: dict[str, Any], stage_id: str) -> None:
1307
+ if guard:
1308
+ guard()
1309
+ records[index] = record
1310
+ called.add(record["id"])
1311
+ receipt = root / "invocations" / record["id"] / f"{stage_id}.json"
1312
+ ledger.stage_started(record["id"], stage_id, _reference(root, receipt))
1313
+ _checkpoint(root, frozen, manifest, states, status="running")
1314
+
1315
+ def submitted(index: int) -> None:
1316
+ if guard:
1317
+ guard()
1318
+ entry = plan["entries"][index]
1319
+ if progress:
1320
+ progress(
1321
+ f"Coding {index + 1}/{len(plan['entries'])}: {entry['task_id']} · {entry['arm']} · repeat {entry['attempt']}"
1322
+ )
1323
+ states[entry["id"]] = "running"
1324
+ ledger.set_state(entry["id"], "running")
1325
+ _checkpoint(root, frozen, manifest, states, status="running")
1326
+
1327
+ def attempt(
1328
+ index: int, cancelled: Event, emit: Callable[[Callable[[], None]], None]
1329
+ ) -> None:
1330
+ entry = plan["entries"][index]
1331
+ task = tasks[entry["task_id"]]
1332
+ record = copy.deepcopy(records[index])
1333
+ stage_id = "handoff"
1334
+ completed = False
1335
+
1336
+ def unmoved() -> None:
1337
+ # Workers check too: evidence they write must never recreate a moved run's path.
1338
+ if guard:
1339
+ guard()
1340
+
1341
+ def started() -> None:
1342
+ if cancelled.is_set():
1343
+ raise CancelledError("attempt cancelled before its next native invocation")
1344
+ harbor_backend.assert_runtime(expected_runtime, jobs=frozen.recipe.jobs)
1345
+ unmoved()
1346
+ receipt = root / "invocations" / entry["id"] / f"{stage_id}.json"
1347
+ _write(
1348
+ receipt,
1349
+ {
1350
+ "attempt_id": entry["id"],
1351
+ "stage": stage_id,
1352
+ "status": "native invocation started",
1353
+ },
1354
+ )
1355
+ emit(partial(invoked, index, copy.deepcopy(record), stage_id))
1356
+
1357
+ try:
1358
+ unmoved()
1359
+ attempt_dir = root / "attempts" / entry["id"]
1360
+ imported = cache.import_attempt(entry) if cache is not None else None
1361
+ if imported is not None:
1362
+ record = imported
1363
+ before = _reference(root, task.fixture, snapshot=True)
1364
+ before_evidence = _collect(
1365
+ root,
1366
+ task.fixture,
1367
+ attempt_dir / "before-evidence",
1368
+ frozen.recipe.collectors,
1369
+ before=task.fixture,
1370
+ )
1371
+ record.update(before=before, before_evidence=before_evidence or None)
1372
+ if imported is not None:
1373
+ for stage in record["stages"]:
1374
+ target = attempt_dir / stage["id"]
1375
+ execution = json.loads((target / "execution.json").read_text())
1376
+ if stage["snapshot"] is not None:
1377
+ regraded = harbor_backend.regrade_native(
1378
+ artifacts=target / "artifacts",
1379
+ task_dir=task.native_task,
1380
+ identity=native_identity(task),
1381
+ directory=target / "native-regrade",
1382
+ artifact_hashes=execution["native_artifact_hashes"],
1383
+ )
1384
+ execution.update(
1385
+ native_regrade=regraded,
1386
+ native_verifier_result=regraded.get("verifier_result")
1387
+ if regraded.get("exception_info") is None
1388
+ else None,
1389
+ )
1390
+ unmoved()
1391
+ _write(target / "execution.json", execution)
1392
+ verified = _verify_snapshot(
1393
+ root=root,
1394
+ snapshot=root / stage["snapshot"]["path"],
1395
+ task=task,
1396
+ study=frozen,
1397
+ target=target,
1398
+ stage_id=stage["id"],
1399
+ execution=execution,
1400
+ )
1401
+ stage.update(verified)
1402
+ completed = True
1403
+ return
1404
+ if cancelled.is_set():
1405
+ return
1406
+ with tempfile.TemporaryDirectory(prefix="pluginbench-study-") as temporary:
1407
+ area = Path(temporary) / "handoff"
1408
+ shutil.copytree(task.fixture, area / "workspace")
1409
+ stage, execution = _execute_stage(
1410
+ root=root,
1411
+ area=area,
1412
+ entry=entry,
1413
+ task=task,
1414
+ study=frozen,
1415
+ stage_id="handoff",
1416
+ prompt="",
1417
+ on_native_start=started,
1418
+ expected_runtime=expected_runtime,
1419
+ guard=unmoved,
1420
+ )
1421
+ record.update(
1422
+ stages=[stage],
1423
+ model=_consistent_observation([execution], "actual_primary_models"),
1424
+ runtime={
1425
+ "harness": f"{frozen.recipe.harness}-cli",
1426
+ "harness_version": _observed_harness_version([execution]),
1427
+ "requested_reasoning_effort": frozen.recipe.reasoning,
1428
+ "reasoning_effort": _consistent_observation(
1429
+ [execution], "runtime_effort_evidence"
1430
+ ),
1431
+ },
1432
+ )
1433
+ completed = True
1434
+ finally:
1435
+ emit(
1436
+ partial(
1437
+ commit,
1438
+ index,
1439
+ copy.deepcopy(record),
1440
+ # Work that finished while a stop drained it is kept, not discarded.
1441
+ "complete" if completed else "interrupted",
1442
+ )
1443
+ )
1444
+
1445
+ run_jobs(
1446
+ [
1447
+ index
1448
+ for index, entry in enumerate(plan["entries"])
1449
+ if states[entry["id"]] != "complete"
1450
+ ],
1451
+ jobs=frozen.recipe.jobs,
1452
+ operation=attempt,
1453
+ on_submit=submitted,
1454
+ )
1455
+ _checkpoint(root, frozen, manifest, states, status="complete")
1456
+ except RunMoved:
1457
+ raise # Writing anything now would recreate the old path.
1458
+ except BaseException as exc:
1459
+ for attempt_id, state in states.items():
1460
+ if state == "running":
1461
+ states[attempt_id] = "interrupted"
1462
+ for record in records:
1463
+ observed = []
1464
+ for stage in record["stages"]:
1465
+ execution_path = root / "attempts" / record["id"] / stage["id"] / "execution.json"
1466
+ observed.append(
1467
+ json.loads(execution_path.read_text()) if execution_path.is_file() else {}
1468
+ )
1469
+ record["model"] = _consistent_observation(observed, "actual_primary_models")
1470
+ _checkpoint(
1471
+ root,
1472
+ frozen,
1473
+ manifest,
1474
+ states,
1475
+ status="interrupted",
1476
+ error=f"{type(exc).__name__}: {exc}",
1477
+ )
1478
+ report_study(root)
1479
+ raise
1480
+ finally:
1481
+ if cache is not None:
1482
+ cache.close()
1483
+ ledger.close()
1484
+ report_study(root)
1485
+ return root
1486
+
1487
+
1488
+ def _bound_path(root: Path, reference: object) -> Path:
1489
+ artifact = ArtifactRef.model_validate(reference)
1490
+ path = root / artifact.path
1491
+ if not path.resolve().is_relative_to(root) or path.is_symlink():
1492
+ raise ValueError("run artifact must remain inside its run directory")
1493
+ if _digest(path) != artifact.sha256:
1494
+ raise ValueError(f"run artifact changed after the run: {artifact.path}")
1495
+ return path
1496
+
1497
+
1498
+ def _bound_document(root: Path, reference: object) -> dict[str, Any]:
1499
+ document: dict[str, Any] = json.loads(_bound_path(root, reference).read_text())
1500
+ return document
1501
+
1502
+
1503
+ def _assert_saved_sources(*, root: Path, run: dict[str, Any], plan: dict[str, Any]) -> None:
1504
+ """Recheck the canonical boundary before observing a new-policy saved run."""
1505
+ policy = plan.get("source_policy")
1506
+ if policy is None:
1507
+ return # Historical captured-tree cohorts keep their original semantics.
1508
+ if policy != submissions.SOURCE_POLICY:
1509
+ raise ValueError("saved submission source policy is incompatible")
1510
+ tasks = {task.task_id: task for task in load_tasks(root / "frozen/tasks.json")}
1511
+ manifest = _bound_document(root, run["assessment_input"])
1512
+ for record in manifest["records"]:
1513
+ task = tasks[record["task_id"]]
1514
+ for stage in record["stages"]:
1515
+ target = root / "attempts" / record["id"] / stage["id"]
1516
+ if stage.get("snapshot") is None:
1517
+ failure_ref = run["submission_errors"][record["id"]][stage["id"]]
1518
+ failure = _bound_document(root, failure_ref)
1519
+ if "diagnostic_source_path" in failure:
1520
+ diagnostic = target / "diagnostic-source"
1521
+ if (
1522
+ failure["diagnostic_source_path"] != diagnostic.name
1523
+ or inspect_snapshot(diagnostic)["digest"]
1524
+ != failure.get("diagnostic_source_digest")
1525
+ or harbor_backend.native_task_assets(diagnostic)
1526
+ != failure.get("diagnostic_source_metadata_digest")
1527
+ or harbor_backend.native_task_assets(target / "artifacts")
1528
+ != failure.get("diagnostic_artifacts_metadata_digest")
1529
+ ):
1530
+ raise ValueError("unsupported submission diagnostics changed")
1531
+ continue
1532
+ execution = _bound_document(root, run["executions"][record["id"]][stage["id"]])
1533
+ submissions.validate_export(
1534
+ diagnostic=target / "diagnostic-source",
1535
+ artifacts=target / "artifacts",
1536
+ execution=execution,
1537
+ )
1538
+ receipt = submissions.validate(
1539
+ before=task.fixture,
1540
+ task_dir=task.native_task,
1541
+ identity=native_identity(task),
1542
+ artifacts=target / "artifacts",
1543
+ execution=execution,
1544
+ snapshot=root / stage["snapshot"]["path"],
1545
+ )
1546
+ if receipt["snapshot_digest"] != stage["snapshot"]["sha256"]:
1547
+ raise ValueError("saved canonical snapshot binding changed")
1548
+
1549
+
1550
+ def report_study(
1551
+ root: Path,
1552
+ *,
1553
+ measurement_revision: str | None = None,
1554
+ review_revision: str | None = None,
1555
+ ) -> Path:
1556
+ root = root.resolve()
1557
+ _assert_frozen(root)
1558
+ run = json.loads((root / "run.json").read_text())
1559
+ _bound_document(root, run["assessment_input"])
1560
+ plan = _bound_document(root, run["plan"])
1561
+ _assert_saved_sources(root=root, run=run, plan=plan)
1562
+ preflight = _bound_document(root, run["runtime_preflight"])
1563
+ assigned_harness_version = preflight.get("harness_version")
1564
+ if isinstance(assigned_harness_version, str):
1565
+ assigned_harness_version = assigned_harness_version.removeprefix("codex-cli ")
1566
+ if run.get("calibration"):
1567
+ _bound_document(root, run["calibration"])
1568
+ manifest_path = root / run["assessment_input"]["path"]
1569
+ output = root / "report"
1570
+ measurement_state = None
1571
+ if measurement_revision is not None:
1572
+ from pluginbench.remeasure import validate_remeasurement
1573
+
1574
+ directory = root / "observer-revisions" / measurement_revision
1575
+ manifest_path = validate_remeasurement(root, directory)
1576
+ output = directory / "report-native"
1577
+ measurement_state = json.loads((directory / "revision.json").read_text())
1578
+ review_path = None
1579
+ if review_revision is not None:
1580
+ from pluginbench.study_review import validate_review_revision
1581
+
1582
+ review_path = validate_review_revision(root, review_revision)
1583
+ output = review_path.parent / "report-native"
1584
+ output = (
1585
+ output / "measurements" / measurement_revision
1586
+ if measurement_revision is not None
1587
+ else output / "original"
1588
+ )
1589
+ if output.resolve() != output:
1590
+ raise ValueError("review projection output cannot follow symlinks")
1591
+ report = build_assessment(manifest_path)
1592
+ if measurement_state is not None:
1593
+ report["measurement_revision"] = measurement_state
1594
+ if measurement_state.get("references") is not None:
1595
+ from pluginbench.remeasure import reference_comparison
1596
+
1597
+ references = _bound_document(root, measurement_state["references"])
1598
+ for value in references.values():
1599
+ if value.get("status") == "available":
1600
+ value["comparison"] = reference_comparison(root, value)
1601
+ report["references"] = references
1602
+ report["study_progress"] = {
1603
+ "status": run.get("status", "complete"),
1604
+ "completed_attempts": run["completed_attempts"],
1605
+ "assigned_attempts": len(plan["entries"]),
1606
+ "native_invocations": run["actual_calls"],
1607
+ "error": run.get("error"),
1608
+ }
1609
+ report["baseline_origin"] = run.get("baseline_origin", {})
1610
+ report["discarded_attempts"] = run.get("discarded_attempts", {})
1611
+ for discarded in report["discarded_attempts"].values():
1612
+ if _bound_document(root, discarded["evidence"]) != {
1613
+ key: value for key, value in discarded.items() if key != "evidence"
1614
+ }:
1615
+ raise ValueError("discarded attempt differs from its bound evidence")
1616
+ for origin in report["baseline_origin"].values():
1617
+ if _bound_document(root, origin["evidence"]) != {
1618
+ key: value for key, value in origin.items() if key != "evidence"
1619
+ }:
1620
+ raise ValueError("baseline origin differs from its bound evidence")
1621
+ for reference in origin["historical_evidence"]:
1622
+ _bound_path(root, reference)
1623
+ if review_path is not None:
1624
+ review = json.loads(review_path.read_text())
1625
+ recovery = None
1626
+ if review.get("recovery"):
1627
+ recovery = _bound_document(root, review["recovery"])
1628
+ original_review = _bound_document(root, recovery["original_state"])
1629
+ if original_review["protocol"] != review["protocol"]:
1630
+ original_protocol = _bound_document(root, original_review["protocol"])
1631
+ recovered_protocol = _bound_document(root, review["protocol"])
1632
+ if review_revision is None or recovered_protocol != {
1633
+ **original_protocol,
1634
+ "revision": review_revision,
1635
+ }:
1636
+ raise ValueError("recovered review protocol differs from its original review")
1637
+ if recovery.get("original_protocol"):
1638
+ if recovery["original_protocol"] != original_review["protocol"]:
1639
+ raise ValueError("review recovery refers to a different original protocol")
1640
+ _bound_document(root, recovery["original_protocol"])
1641
+ for reference in [
1642
+ recovery["parser"],
1643
+ recovery["adapter"],
1644
+ *recovery["native_artifacts"],
1645
+ ]:
1646
+ _bound_path(root, reference)
1647
+ protocol = _bound_document(root, review["protocol"])
1648
+ _bound_document(root, review["original_run"])
1649
+ adapter = ArtifactRef.model_validate(review["adapter"])
1650
+ if _digest(root / adapter.path) != adapter.sha256:
1651
+ raise ValueError("review adapter changed after the review")
1652
+ observations = []
1653
+ for row in review["records"]:
1654
+ observation = (
1655
+ _bound_document(root, row["observation"]) if row.get("observation") else None
1656
+ )
1657
+ if observation and observation.get("recovery"):
1658
+ _bound_document(root, observation["recovery"]["original_observation"])
1659
+ issues = None
1660
+ severity_counts = None
1661
+ category_counts = None
1662
+ if row["status"] == "reviewed" and observation and observation.get("response"):
1663
+ from pluginbench.study_review import JudgeResponse
1664
+
1665
+ response = JudgeResponse.model_validate(observation["response"])
1666
+ if (
1667
+ response.status != "reviewed"
1668
+ or response.outside_packet_access
1669
+ or response.packet_id != row["packet_id"]
1670
+ ):
1671
+ raise ValueError("reviewed record differs from its validated response")
1672
+ issues = [issue.model_dump(mode="json") for issue in response.issues]
1673
+ category_counts = {
1674
+ category: sum(issue["category"] == category for issue in issues)
1675
+ for category in ("correctness", "scope", "maintainability", "tests")
1676
+ }
1677
+ severity_counts = {
1678
+ severity: sum(issue["severity"] == severity for issue in issues)
1679
+ for severity in ("P0", "P1", "P2", "P3")
1680
+ }
1681
+ observations.append(
1682
+ {
1683
+ "attempt_id": row["id"],
1684
+ "stage": protocol["stage"],
1685
+ **protocol["selections"][row["id"]],
1686
+ "status": row["status"],
1687
+ "finding_count": len(issues) if issues is not None else None,
1688
+ "issues": issues,
1689
+ "severity_counts": severity_counts,
1690
+ "category_counts": category_counts,
1691
+ "criteria_sha256": protocol.get("criteria_sha256"),
1692
+ "reason": row.get("reason"),
1693
+ "recovered": bool(
1694
+ row["status"] == "reviewed" and observation and observation.get("recovery")
1695
+ ),
1696
+ "work": _work(observation["execution"]) if observation else None,
1697
+ "execution": observation.get("execution") if observation else None,
1698
+ "limitations": observation.get("response", {}).get("limitations", [])
1699
+ if observation and observation.get("response")
1700
+ else [],
1701
+ }
1702
+ )
1703
+ report["independent_review"] = {
1704
+ "status": review["status"],
1705
+ "stage": protocol["stage"],
1706
+ "assigned": review["assigned"],
1707
+ "native_invocation_requests": review["native_invocations"],
1708
+ "requested_model": protocol["model"],
1709
+ "requested_harness": protocol.get("harness", "codex"),
1710
+ "requested_reasoning_effort": protocol["effort"],
1711
+ "criteria_sha256": protocol.get("criteria_sha256"),
1712
+ "revision": review_revision,
1713
+ "records": observations,
1714
+ "recovery": {
1715
+ "method": recovery["method"],
1716
+ "counts": recovery.get("counts"),
1717
+ "native_invocations": recovery["native_invocations"],
1718
+ }
1719
+ if recovery
1720
+ else None,
1721
+ "limitations": review["limitations"],
1722
+ "cost_accounting": "Judge work is separate from implementation work; unavailable usage stays unknown.",
1723
+ }
1724
+ by_attempt = {row["attempt_id"]: row for row in observations}
1725
+ for attempt in report["attempts"]:
1726
+ attempt["independent_review"] = {
1727
+ **by_attempt[attempt["id"]],
1728
+ "requested_model": protocol["model"],
1729
+ "requested_harness": protocol.get("harness", "codex"),
1730
+ "requested_reasoning_effort": protocol["effort"],
1731
+ }
1732
+ entries = {entry["id"]: entry for entry in plan["entries"]}
1733
+ configured = json.loads((root / "frozen/resolved-recipe.json").read_text())["collectors"]
1734
+ revised_profiles = (
1735
+ _bound_document(root, measurement_state["frozen_profiles"])
1736
+ if measurement_state is not None
1737
+ else {}
1738
+ )
1739
+ for attempt in report["attempts"]:
1740
+ entry = entries[attempt["id"]]
1741
+ if any(attempt[key] != entry[key] for key in ("task_id", "arm", "attempt")):
1742
+ raise ValueError("assessment attempt differs from its frozen plan")
1743
+ attempt["configured_metrics"] = sorted(
1744
+ set(attempt["configured_metrics"])
1745
+ | {collector["kind"] for collector in configured}
1746
+ | {collector["kind"] for collector in revised_profiles.get(attempt["task_id"], [])}
1747
+ )
1748
+ attempt["provenance"]["attempt_status"] = run.get("attempt_states", {}).get(
1749
+ attempt["id"], "complete"
1750
+ )
1751
+ executions = []
1752
+ for stage in attempt["stages"]:
1753
+ execution = _bound_document(root, run["executions"][attempt["id"]][stage["id"]])
1754
+ executions.append(execution)
1755
+ verifier_result = (
1756
+ run.get("verifier_results", {}).get(attempt["id"], {}).get(stage["id"])
1757
+ )
1758
+ if verifier_result:
1759
+ original_result = _bound_document(root, verifier_result)
1760
+ if "task_result" not in stage:
1761
+ stage["task_result"] = original_result
1762
+ reward = _binary_native_reward(original_result.get("native_verifier_result"))
1763
+ if (
1764
+ reward is not None
1765
+ and original_result.get("pass") is None
1766
+ and stage["snapshot_digest"] is not None
1767
+ and original_result.get("binding") == "model.patch reconstructs measured source"
1768
+ and original_result.get("graded_source_digest")
1769
+ == original_result.get("measured_source_digest")
1770
+ == stage["snapshot_digest"]
1771
+ ):
1772
+ stage["task_result"] = {**original_result, "pass": reward}
1773
+ stage["task_result"].pop("reason", None)
1774
+ receipt = CorrectnessReceipt.model_validate(
1775
+ {
1776
+ "schema_version": 1,
1777
+ **{
1778
+ key: stage["correctness"][key]
1779
+ for key in ("snapshot_digest", "command", "tool", "tool_version")
1780
+ },
1781
+ "checks": {
1782
+ **stage["correctness"]["checks"],
1783
+ "behavior": reward,
1784
+ "verifier": reward,
1785
+ },
1786
+ }
1787
+ )
1788
+ stage["correctness"] = _correctness(receipt, ["behavior"])
1789
+ stage["correctness"]["projection"] = {
1790
+ "method": "source-bound binary reward",
1791
+ "original_verifier_result": verifier_result,
1792
+ }
1793
+ stage["execution_provenance"] = {
1794
+ "artifact": run["executions"][attempt["id"]][stage["id"]],
1795
+ "observed_harness_version": codex.observed_cli_version(
1796
+ execution.get("startup"), host=plan.get("host", "codex")
1797
+ ),
1798
+ **{
1799
+ key: execution.get(key)
1800
+ for key in (
1801
+ "actual_primary_models",
1802
+ "runtime_effort_evidence",
1803
+ "successful_native_completion",
1804
+ "timed_out",
1805
+ "exit_code",
1806
+ "partial_usage",
1807
+ "usage_accounting",
1808
+ "actual_descendant_models",
1809
+ "native_treatment",
1810
+ "native_session_usage",
1811
+ "stream_errors",
1812
+ "error",
1813
+ "attempt_status",
1814
+ )
1815
+ },
1816
+ }
1817
+ if stage["snapshot_digest"] is None:
1818
+ stage["submission_error"] = _bound_document(
1819
+ root, run["submission_errors"][attempt["id"]][stage["id"]]
1820
+ )
1821
+ if attempt["model"] != _consistent_observation(executions, "actual_primary_models"):
1822
+ raise ValueError("assessment model differs from its bound execution evidence")
1823
+ attempt["provenance"].update(
1824
+ {
1825
+ "input": f"{plan.get('host', 'codex')}-study",
1826
+ "host": plan.get("host", "codex"),
1827
+ "harness": f"{plan.get('host', 'codex')}-cli",
1828
+ "harness_version": _observed_harness_version(executions),
1829
+ "reasoning_effort": _consistent_observation(executions, "runtime_effort_evidence"),
1830
+ "runtime_source": "native execution receipts",
1831
+ "guidance_delivery": plan["delivery"],
1832
+ "requested_model": plan["model"],
1833
+ "assigned_model": plan["model"],
1834
+ "assigned_harness_version": assigned_harness_version,
1835
+ "assigned_reasoning_effort": plan["requested_reasoning_effort"],
1836
+ "study_seed": plan["seed"],
1837
+ "execution_backend": "harbor",
1838
+ "task": run.get("task_provenance", {}).get(attempt["task_id"]),
1839
+ }
1840
+ )
1841
+ if (
1842
+ plan.get("host") == "claude"
1843
+ and all(
1844
+ codex.validated_claude_launch(
1845
+ execution, plan["model"], plan["requested_reasoning_effort"]
1846
+ )
1847
+ for execution in executions
1848
+ )
1849
+ and executions
1850
+ ):
1851
+ attempt["provenance"].update(
1852
+ comparison_reasoning_effort=plan["requested_reasoning_effort"],
1853
+ launch_harness_version=_observed_harness_version(executions),
1854
+ effort_evidence_source="native session event"
1855
+ if _consistent_observation(executions, "runtime_effort_evidence")
1856
+ else "requested launch setting; effective effort unverified",
1857
+ )
1858
+ return write_assessment(report, output)