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.
- pluginbench/__init__.py +3 -0
- pluginbench/__main__.py +3 -0
- pluginbench/assessment-charts.html +790 -0
- pluginbench/assessment.py +316 -0
- pluginbench/assessment_charts.py +1225 -0
- pluginbench/assessment_metrics.py +1956 -0
- pluginbench/assessment_models.py +127 -0
- pluginbench/baselines.py +792 -0
- pluginbench/claude.py +374 -0
- pluginbench/cli.py +672 -0
- pluginbench/code_diff.py +207 -0
- pluginbench/codex.py +451 -0
- pluginbench/codex_events.py +585 -0
- pluginbench/collectors.py +591 -0
- pluginbench/compare.py +927 -0
- pluginbench/components.py +129 -0
- pluginbench/coverage_report.py +610 -0
- pluginbench/datasets.py +207 -0
- pluginbench/evaluation.example.yaml +45 -0
- pluginbench/evaluation.py +1651 -0
- pluginbench/evaluation_report.py +1004 -0
- pluginbench/fallow_parse.mjs +101 -0
- pluginbench/harbor_backend.py +2946 -0
- pluginbench/isolation.py +314 -0
- pluginbench/judge.py +683 -0
- pluginbench/language_analysis.py +915 -0
- pluginbench/ledger.py +217 -0
- pluginbench/measurement_profiles.py +616 -0
- pluginbench/native_treatment_runtime.js +221 -0
- pluginbench/native_treatments.py +155 -0
- pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
- pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
- pluginbench/observer_data/go-analysis-v1/main.go +64 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
- pluginbench/parallel.py +101 -0
- pluginbench/processes.py +100 -0
- pluginbench/remeasure.py +992 -0
- pluginbench/run_lock.py +227 -0
- pluginbench/run_status.py +518 -0
- pluginbench/study.py +1858 -0
- pluginbench/study_review.py +924 -0
- pluginbench/submissions.py +486 -0
- pluginbench/task_sources.py +403 -0
- pluginbench/treatments.py +388 -0
- pluginbench/trial_containers.py +94 -0
- pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench/vendor/ZRENDER-LICENSE +29 -0
- pluginbench/vendor/echarts.min.js +48 -0
- pluginbench-0.5.0.dist-info/METADATA +294 -0
- pluginbench-0.5.0.dist-info/RECORD +58 -0
- pluginbench-0.5.0.dist-info/WHEEL +4 -0
- pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
- pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
- 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)
|