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
|
@@ -0,0 +1,1651 @@
|
|
|
1
|
+
"""One evaluation: prepare tasks, run vanilla and guidance, measure, optionally review."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
from contextlib import nullcontext
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator, model_validator
|
|
20
|
+
|
|
21
|
+
from pluginbench import (
|
|
22
|
+
baselines,
|
|
23
|
+
claude,
|
|
24
|
+
codex,
|
|
25
|
+
components,
|
|
26
|
+
harbor_backend,
|
|
27
|
+
isolation,
|
|
28
|
+
run_lock,
|
|
29
|
+
submissions,
|
|
30
|
+
)
|
|
31
|
+
from pluginbench.treatments import Treatment, resolve_treatment, treatment_inventory
|
|
32
|
+
from pluginbench.native_treatments import NativeCapabilities, DELIVERY_POLICY, admit
|
|
33
|
+
from pluginbench.task_sources import Tasks, cache_root
|
|
34
|
+
from pluginbench.measurement_profiles import (
|
|
35
|
+
REMOVED_TEST_OVERLAP,
|
|
36
|
+
MeasurementSettings,
|
|
37
|
+
blocking_findings,
|
|
38
|
+
not_measured,
|
|
39
|
+
capability_plan,
|
|
40
|
+
prepare_profiles,
|
|
41
|
+
probe_measurements,
|
|
42
|
+
readiness_state,
|
|
43
|
+
)
|
|
44
|
+
from pluginbench.assessment_metrics import scoped_code_size, scoped_test_size
|
|
45
|
+
from pluginbench.datasets import TaskCatalog, TaskEntry, load_tasks, portable_native_identity
|
|
46
|
+
from pluginbench.collectors import Collector
|
|
47
|
+
from pluginbench.evaluation_report import write_evaluation_report
|
|
48
|
+
from pluginbench.remeasure import (
|
|
49
|
+
_native_state,
|
|
50
|
+
_object,
|
|
51
|
+
_preserved_inventory,
|
|
52
|
+
load_profiles,
|
|
53
|
+
propose_remeasurement,
|
|
54
|
+
validate_remeasurement,
|
|
55
|
+
)
|
|
56
|
+
from pluginbench import study
|
|
57
|
+
from pluginbench.study import _text_files, load_study, report_study, resume_study, run_study
|
|
58
|
+
from pluginbench.study_review import (
|
|
59
|
+
_criteria_digest,
|
|
60
|
+
_fixed_criteria,
|
|
61
|
+
_review_directory,
|
|
62
|
+
recover_review_citations,
|
|
63
|
+
review_study,
|
|
64
|
+
validate_review_revision,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
Measurement = Literal[
|
|
68
|
+
"tokens", "lines", "cyclomatic", "cognitive", "dead_code", "coverage", "review"
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Runner(BaseModel):
|
|
73
|
+
model_config = ConfigDict(extra="forbid")
|
|
74
|
+
harness: Literal["codex", "claude"] = Field(description="Selects the coding or review CLI.")
|
|
75
|
+
model: str = Field(
|
|
76
|
+
min_length=1,
|
|
77
|
+
description="Selects the model ID, with an exact claude-* ID required for Claude.",
|
|
78
|
+
)
|
|
79
|
+
reasoning: Literal["minimal", "low", "medium", "high", "xhigh", "max", "ultra"] = Field(
|
|
80
|
+
description="Selects reasoning effort, subject to harness and evaluation validation."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@model_validator(mode="after")
|
|
84
|
+
def supported_effort(self) -> Runner:
|
|
85
|
+
if self.harness == "claude" and re.fullmatch(r"claude-[a-z0-9-]+", self.model) is None:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"Claude requires an exact claude-* model ID; floating aliases such as sonnet or opus cannot be compared reliably"
|
|
88
|
+
)
|
|
89
|
+
if self.harness == "claude" and self.reasoning not in {
|
|
90
|
+
"low",
|
|
91
|
+
"medium",
|
|
92
|
+
"high",
|
|
93
|
+
"xhigh",
|
|
94
|
+
"max",
|
|
95
|
+
}:
|
|
96
|
+
raise ValueError("Claude reasoning must be low, medium, high, xhigh or max")
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Reviewer(Runner):
|
|
101
|
+
timeout_seconds: int | None = Field(
|
|
102
|
+
default=None,
|
|
103
|
+
strict=True,
|
|
104
|
+
ge=1,
|
|
105
|
+
description="Sets the review timeout in seconds, or inherits the evaluation timeout when null.",
|
|
106
|
+
)
|
|
107
|
+
guidance: list[Path] = Field(
|
|
108
|
+
default_factory=list,
|
|
109
|
+
description="Selects files or directories of review criteria relative to the config file.",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Evaluation(BaseModel):
|
|
114
|
+
model_config = ConfigDict(extra="forbid")
|
|
115
|
+
name: str = Field(
|
|
116
|
+
default="evaluation", min_length=1, description="Names the evaluation in plans and reports."
|
|
117
|
+
)
|
|
118
|
+
runner: Runner = Field(description="Selects the coding harness, model, and reasoning effort.")
|
|
119
|
+
treatments: dict[str, Treatment] = Field(
|
|
120
|
+
description="Maps treatment names to nonempty native packages, with vanilla included automatically."
|
|
121
|
+
)
|
|
122
|
+
capabilities: NativeCapabilities = Field(
|
|
123
|
+
default_factory=NativeCapabilities,
|
|
124
|
+
description="Sets the same native agent capabilities for vanilla and every treatment.",
|
|
125
|
+
)
|
|
126
|
+
tasks: Tasks = Field(
|
|
127
|
+
description="Selects exactly one task source and a unique nonempty task inventory."
|
|
128
|
+
)
|
|
129
|
+
measurements: list[Measurement] = Field(
|
|
130
|
+
min_length=1,
|
|
131
|
+
description="Selects unique measurements, with review requiring reviewer settings.",
|
|
132
|
+
)
|
|
133
|
+
measurement_settings: MeasurementSettings = Field(
|
|
134
|
+
default_factory=MeasurementSettings,
|
|
135
|
+
description="Configures measurement scopes, tools, coverage profiles, and readiness requirements.",
|
|
136
|
+
)
|
|
137
|
+
reviewer: Reviewer | None = Field(
|
|
138
|
+
default=None,
|
|
139
|
+
description="Configures the independent reviewer exactly when review is selected.",
|
|
140
|
+
)
|
|
141
|
+
repeats: int = Field(
|
|
142
|
+
default=3,
|
|
143
|
+
strict=True,
|
|
144
|
+
ge=1,
|
|
145
|
+
description="Sets the number of attempts for each task and arm.",
|
|
146
|
+
)
|
|
147
|
+
seed: int = Field(
|
|
148
|
+
default=42,
|
|
149
|
+
strict=True,
|
|
150
|
+
description="Seeds attempt ordering and identities without guaranteeing deterministic model sampling.",
|
|
151
|
+
)
|
|
152
|
+
timeout_seconds: int = Field(
|
|
153
|
+
default=1800,
|
|
154
|
+
strict=True,
|
|
155
|
+
ge=1,
|
|
156
|
+
description="Sets the coding agent timeout in seconds, including child work.",
|
|
157
|
+
)
|
|
158
|
+
# A fixed default keeps the comparison identity the same on every machine; 4 is the
|
|
159
|
+
# most harbor_backend.capacity_jobs ever allows.
|
|
160
|
+
jobs: int = Field(
|
|
161
|
+
default=2,
|
|
162
|
+
strict=True,
|
|
163
|
+
ge=1,
|
|
164
|
+
le=4,
|
|
165
|
+
description="Sets concurrent jobs from one to four, subject to Docker capacity.",
|
|
166
|
+
)
|
|
167
|
+
container_cpus: int | None = Field(
|
|
168
|
+
default=None,
|
|
169
|
+
strict=True,
|
|
170
|
+
ge=1,
|
|
171
|
+
description="Sets a common CPU limit covering all selected task requirements, or uses their common declaration.",
|
|
172
|
+
)
|
|
173
|
+
container_memory_mb: int | None = Field(
|
|
174
|
+
default=None,
|
|
175
|
+
strict=True,
|
|
176
|
+
ge=256,
|
|
177
|
+
description="Sets a common memory limit in MB covering all selected task requirements, or uses their common declaration.",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@model_validator(mode="before")
|
|
181
|
+
@classmethod
|
|
182
|
+
def legacy_guidance(cls, value: object) -> object:
|
|
183
|
+
return study.normalize_legacy_guidance(value)
|
|
184
|
+
|
|
185
|
+
@field_validator("measurements", mode="before")
|
|
186
|
+
@classmethod
|
|
187
|
+
def removed_measurements(cls, value: object) -> object:
|
|
188
|
+
if isinstance(value, list) and "test_overlap" in value:
|
|
189
|
+
raise ValueError(REMOVED_TEST_OVERLAP)
|
|
190
|
+
return value
|
|
191
|
+
|
|
192
|
+
@model_validator(mode="after")
|
|
193
|
+
def coherent(self) -> Evaluation:
|
|
194
|
+
if self.runner.reasoning == "ultra":
|
|
195
|
+
raise ValueError("Harbor does not support ultra reasoning; select a supported effort")
|
|
196
|
+
if len(set(self.measurements)) != len(self.measurements):
|
|
197
|
+
raise ValueError("measurements must be unique")
|
|
198
|
+
if ("review" in self.measurements) != (self.reviewer is not None):
|
|
199
|
+
raise ValueError("provide reviewer settings exactly when review is selected")
|
|
200
|
+
if any(
|
|
201
|
+
not name.strip()
|
|
202
|
+
or name in {"baseline", "vanilla"}
|
|
203
|
+
or not (item.guidance or item.skills or item.plugins)
|
|
204
|
+
for name, item in self.treatments.items()
|
|
205
|
+
):
|
|
206
|
+
raise ValueError("treatments need nonempty named sets; vanilla is automatic")
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _resolve(base: Path, path: Path) -> Path:
|
|
211
|
+
expanded = path.expanduser()
|
|
212
|
+
return (expanded if expanded.is_absolute() else base / expanded).absolute()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def resolve_evaluation(raw: object, base: Path) -> Evaluation:
|
|
216
|
+
config = Evaluation.model_validate(raw)
|
|
217
|
+
if config.tasks.source is not None:
|
|
218
|
+
config.tasks.source = _resolve(base, config.tasks.source)
|
|
219
|
+
if config.measurement_settings.fallow_config is not None:
|
|
220
|
+
config.measurement_settings.fallow_config = _resolve(
|
|
221
|
+
base, config.measurement_settings.fallow_config
|
|
222
|
+
)
|
|
223
|
+
config.treatments = {
|
|
224
|
+
name: resolve_treatment(item, base, config.runner.harness)
|
|
225
|
+
for name, item in config.treatments.items()
|
|
226
|
+
}
|
|
227
|
+
if config.reviewer:
|
|
228
|
+
files = [
|
|
229
|
+
file for path in config.reviewer.guidance for file in _text_files(_resolve(base, path))
|
|
230
|
+
]
|
|
231
|
+
if len(set(files)) != len(files):
|
|
232
|
+
raise ValueError("review guidance supplies the same text twice")
|
|
233
|
+
config.reviewer.guidance = files
|
|
234
|
+
return config
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def load_evaluation(path: Path) -> Evaluation:
|
|
238
|
+
source = path.expanduser().absolute()
|
|
239
|
+
return resolve_evaluation(yaml.safe_load(source.read_text()), source.parent)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _task_limits(configuration: dict[str, object]) -> dict[str, int]:
|
|
243
|
+
fields = {
|
|
244
|
+
"cpus": ("environment", "cpus"),
|
|
245
|
+
"memory_mb": ("environment", "memory_mb"),
|
|
246
|
+
"build_timeout_seconds": ("environment", "build_timeout_sec"),
|
|
247
|
+
"agent_timeout_seconds": ("agent", "timeout_sec"),
|
|
248
|
+
}
|
|
249
|
+
limits = {}
|
|
250
|
+
for name, (section, key) in fields.items():
|
|
251
|
+
value = _object(configuration[section]).get(key)
|
|
252
|
+
if (
|
|
253
|
+
isinstance(value, bool)
|
|
254
|
+
or not isinstance(value, (int, float))
|
|
255
|
+
or value <= 0
|
|
256
|
+
or int(value) != value
|
|
257
|
+
):
|
|
258
|
+
raise ValueError(f"{section}.{key} must be a positive whole number")
|
|
259
|
+
limits[name] = int(value)
|
|
260
|
+
return limits
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _container_limits(config: Evaluation, declared: dict[str, dict[str, int]]) -> dict[str, int]:
|
|
264
|
+
limits = {}
|
|
265
|
+
for key, field, fallback in (
|
|
266
|
+
("cpus", "container_cpus", 1),
|
|
267
|
+
("memory_mb", "container_memory_mb", 2048),
|
|
268
|
+
):
|
|
269
|
+
value = getattr(config, field)
|
|
270
|
+
requested = {row[key] for row in declared.values()}
|
|
271
|
+
if value is None:
|
|
272
|
+
if len(requested) > 1:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
f"Selected tasks declare different {key}; provide an explicit common {field}"
|
|
275
|
+
)
|
|
276
|
+
value = next(iter(requested), fallback)
|
|
277
|
+
for task, row in declared.items():
|
|
278
|
+
if value < row[key]:
|
|
279
|
+
raise ValueError(
|
|
280
|
+
f"{field}={value} is below {row[key]} requested by task {task}; reduced resources require native fitness validation and are currently unsupported"
|
|
281
|
+
)
|
|
282
|
+
limits[key] = value
|
|
283
|
+
return limits
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def plan_evaluation(config: Evaluation) -> dict[str, object]:
|
|
287
|
+
for treatment in config.treatments.values():
|
|
288
|
+
admit(treatment, config.runner.harness)
|
|
289
|
+
source = config.tasks.resolve()
|
|
290
|
+
suite = source.parent if source.name == "tasks" else source
|
|
291
|
+
identities = {}
|
|
292
|
+
declared = {}
|
|
293
|
+
capabilities = {}
|
|
294
|
+
coding_images = {}
|
|
295
|
+
submission_sources: dict[str, dict[str, object]] = {}
|
|
296
|
+
for task in config.tasks.task_ids:
|
|
297
|
+
path = suite / "tasks" / task
|
|
298
|
+
admission = harbor_backend.validate_native_task(path)
|
|
299
|
+
identities[task] = admission["task_assets_sha256"]
|
|
300
|
+
coding_images[task] = admission["coding_image"]
|
|
301
|
+
task_limits = _task_limits(_object(admission["configuration"]))
|
|
302
|
+
verifier = _object(admission["verifier_configuration"])
|
|
303
|
+
for key in ("cpus", "memory_mb"):
|
|
304
|
+
native_value = verifier.get(key)
|
|
305
|
+
if type(native_value) is not int or native_value <= 0:
|
|
306
|
+
raise ValueError(f"verifier.{key} must be a positive whole number")
|
|
307
|
+
task_limits[key] = max(task_limits[key], native_value)
|
|
308
|
+
declared[task] = task_limits
|
|
309
|
+
capabilities[task] = capability_plan(path, config.measurements, config.measurement_settings)
|
|
310
|
+
try:
|
|
311
|
+
contract = submissions.reviewed_contract(path)
|
|
312
|
+
except (OSError, ValueError) as error:
|
|
313
|
+
submission_sources[task] = {"status": "unsupported", "reason": str(error)}
|
|
314
|
+
else:
|
|
315
|
+
submission_sources[task] = {
|
|
316
|
+
"status": "requires_preparation",
|
|
317
|
+
"contract": contract,
|
|
318
|
+
"reason": "grader baseline and frozen fixture must be qualified before coding",
|
|
319
|
+
}
|
|
320
|
+
limits = _container_limits(config, declared)
|
|
321
|
+
capacity = harbor_backend.capacity_jobs(**limits)
|
|
322
|
+
if config.jobs > capacity:
|
|
323
|
+
# jobs is part of the comparison and vanilla cache identity, so it is never lowered.
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"jobs={config.jobs} does not fit Docker capacity for containers of "
|
|
326
|
+
f"{limits['cpus']} CPUs and {limits['memory_mb']} MB; set jobs: {capacity} or fewer"
|
|
327
|
+
)
|
|
328
|
+
jobs = config.jobs
|
|
329
|
+
criteria = (
|
|
330
|
+
_fixed_criteria([file.read_text() for file in config.reviewer.guidance])
|
|
331
|
+
if config.reviewer
|
|
332
|
+
else None
|
|
333
|
+
)
|
|
334
|
+
comparison: dict[str, object] = {
|
|
335
|
+
"tasks": identities,
|
|
336
|
+
"runner": config.runner.model_dump(mode="json"),
|
|
337
|
+
"delivery": DELIVERY_POLICY,
|
|
338
|
+
"capabilities": config.capabilities.model_dump(mode="json"),
|
|
339
|
+
"measurement_settings": config.measurement_settings.model_dump(mode="json"),
|
|
340
|
+
"fallow_config_sha256": hashlib.sha256(
|
|
341
|
+
config.measurement_settings.fallow_config.read_bytes()
|
|
342
|
+
).hexdigest()
|
|
343
|
+
if config.measurement_settings.fallow_config is not None
|
|
344
|
+
else None,
|
|
345
|
+
"measurements": sorted(config.measurements),
|
|
346
|
+
"reviewer": config.reviewer.model_dump(mode="json", exclude={"guidance"})
|
|
347
|
+
if config.reviewer
|
|
348
|
+
else None,
|
|
349
|
+
"review_criteria_sha256": _criteria_digest(criteria) if criteria else None,
|
|
350
|
+
"jobs": jobs,
|
|
351
|
+
"backend": "harbor-native",
|
|
352
|
+
"source_policy": submissions.SOURCE_POLICY,
|
|
353
|
+
"task_limits": {
|
|
354
|
+
task: {
|
|
355
|
+
"declared": row,
|
|
356
|
+
"effective": {**row, **limits, "agent_timeout_seconds": config.timeout_seconds},
|
|
357
|
+
"adapted_agent_budget": config.timeout_seconds != row["agent_timeout_seconds"],
|
|
358
|
+
}
|
|
359
|
+
for task, row in declared.items()
|
|
360
|
+
},
|
|
361
|
+
"isolation": harbor_backend.runtime_identity(**limits),
|
|
362
|
+
}
|
|
363
|
+
if config.tasks.references:
|
|
364
|
+
comparison["task_sources"] = config.tasks.references
|
|
365
|
+
coding = len(config.tasks.task_ids) * (1 + len(config.treatments)) * config.repeats
|
|
366
|
+
return {
|
|
367
|
+
"name": config.name,
|
|
368
|
+
"coding_calls": coding,
|
|
369
|
+
"review_calls": coding if config.reviewer else 0,
|
|
370
|
+
"runner": config.runner.model_dump(mode="json"),
|
|
371
|
+
"treatments": {name: treatment_inventory(item) for name, item in config.treatments.items()},
|
|
372
|
+
"delivery": DELIVERY_POLICY,
|
|
373
|
+
"capabilities": config.capabilities.model_dump(mode="json"),
|
|
374
|
+
"tasks": config.tasks.task_ids,
|
|
375
|
+
"measurements": config.measurements,
|
|
376
|
+
"measurement_capabilities": capabilities,
|
|
377
|
+
"coding_images": coding_images,
|
|
378
|
+
"submission_sources": submission_sources,
|
|
379
|
+
"repeats": config.repeats,
|
|
380
|
+
"jobs": jobs,
|
|
381
|
+
"container_limits": limits,
|
|
382
|
+
"vanilla": "reuse a verified isolated vanilla cohort when available; otherwise fresh vanilla attempts; native built-in instructions remain",
|
|
383
|
+
"call_estimate": "maximum new root attempts; native subagent turns are additional and metered from session traces; baseline hits resolve before coding",
|
|
384
|
+
"comparison_identity": comparison,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _write(path: Path, value: object) -> None:
|
|
389
|
+
path.write_text(json.dumps(value, indent=2) + "\n")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@dataclass(frozen=True)
|
|
393
|
+
class _Caches:
|
|
394
|
+
"""Caches every output folder shares, so a new output folder reuses their entries."""
|
|
395
|
+
|
|
396
|
+
native: Path
|
|
397
|
+
vanilla: Path
|
|
398
|
+
reference_agents: Path
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _caches(output: Path) -> _Caches:
|
|
402
|
+
"""Resolve the cache root and check where it leads before anything is prepared.
|
|
403
|
+
|
|
404
|
+
A symlinked cache location is followed here once, so the later no-symlink checks in each
|
|
405
|
+
cache see its real path. A symlink inside the root, or a root that overlaps the output,
|
|
406
|
+
fails now instead of after image preparation.
|
|
407
|
+
"""
|
|
408
|
+
base = cache_root().parent.resolve()
|
|
409
|
+
run = output.resolve()
|
|
410
|
+
if run.is_relative_to(base) or base.is_relative_to(run):
|
|
411
|
+
raise ValueError(f"output {run} must not overlap the PluginBench cache {base}")
|
|
412
|
+
caches = _Caches(
|
|
413
|
+
native=base / "prepared-images/native",
|
|
414
|
+
vanilla=base / "vanilla",
|
|
415
|
+
reference_agents=base / "reference-agents",
|
|
416
|
+
)
|
|
417
|
+
for path in (caches.native, caches.vanilla, caches.reference_agents):
|
|
418
|
+
if path.resolve() != path:
|
|
419
|
+
raise ValueError(f"PluginBench cache directories must not be symlinks: {path}")
|
|
420
|
+
return caches
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _launcher_sha256(harness: str) -> dict[str, str]:
|
|
424
|
+
return {name: components.source_sha256(name) for name in components.launcher_files(harness)}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _assert_launcher(
|
|
428
|
+
selection: dict[str, object], identity: dict[str, object] | None, harness: str
|
|
429
|
+
) -> None:
|
|
430
|
+
"""Coding resumes only with the launcher version that coded the run's earlier attempts."""
|
|
431
|
+
current = components.VERSIONS["launcher"]
|
|
432
|
+
try:
|
|
433
|
+
if identity is None:
|
|
434
|
+
isolation = _object(_object(selection["comparison_identity"])["isolation"])
|
|
435
|
+
saved = harbor_backend.runtime_contract(isolation)["launcher_version"]
|
|
436
|
+
elif "launcher_version" in identity:
|
|
437
|
+
saved = identity["launcher_version"]
|
|
438
|
+
else:
|
|
439
|
+
saved = components.saved_launcher_version(harness, identity.get("launcher_sha256"))
|
|
440
|
+
except components.UnknownSourceError as error:
|
|
441
|
+
raise ValueError(
|
|
442
|
+
f"{error}. This build's launcher is version {current}: resume this run with the "
|
|
443
|
+
"build that started it, or start a new run"
|
|
444
|
+
) from error
|
|
445
|
+
if saved != current:
|
|
446
|
+
raise ValueError(
|
|
447
|
+
f"this run codes with PluginBench launcher version {saved}, and this build's "
|
|
448
|
+
f"launcher is version {current}: resume this run with the build that started it, "
|
|
449
|
+
"or start a new run"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _vanilla_identity(
|
|
454
|
+
config: Evaluation,
|
|
455
|
+
comparison: dict[str, object],
|
|
456
|
+
harness_version: str,
|
|
457
|
+
task_environment: dict[str, object],
|
|
458
|
+
) -> dict[str, object]:
|
|
459
|
+
"""Everything a vanilla cohort's cache key binds; `check` previews with the run's own key."""
|
|
460
|
+
return {
|
|
461
|
+
"delivery": DELIVERY_POLICY,
|
|
462
|
+
"capabilities": config.capabilities.model_dump(mode="json"),
|
|
463
|
+
"task_assets": comparison["tasks"],
|
|
464
|
+
"task_environment": task_environment,
|
|
465
|
+
"runner": comparison["runner"],
|
|
466
|
+
"harness_version": harness_version,
|
|
467
|
+
"isolation": comparison["isolation"],
|
|
468
|
+
"timeout_seconds": config.timeout_seconds,
|
|
469
|
+
"jobs": config.jobs,
|
|
470
|
+
"seed": config.seed,
|
|
471
|
+
"launcher_version": components.VERSIONS["launcher"],
|
|
472
|
+
"launcher_sha256": _launcher_sha256(config.runner.harness),
|
|
473
|
+
# run_study binds the same settings from its frozen recipe.
|
|
474
|
+
"study": {
|
|
475
|
+
"harness": config.runner.harness,
|
|
476
|
+
"model": config.runner.model,
|
|
477
|
+
"reasoning": config.runner.reasoning,
|
|
478
|
+
"jobs": config.jobs,
|
|
479
|
+
"timeout_seconds": config.timeout_seconds,
|
|
480
|
+
"seed": config.seed,
|
|
481
|
+
},
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _reference_agents(
|
|
486
|
+
identity: dict[str, object], destination: Path, cache: Path
|
|
487
|
+
) -> dict[str, dict[str, object]]:
|
|
488
|
+
"""Oracle and nop qualify a preparation, not a run: reuse a pass for the same preparation.
|
|
489
|
+
|
|
490
|
+
Only passing results are kept, so a broken or flaky result is always retried.
|
|
491
|
+
"""
|
|
492
|
+
key = str(identity["preparation_key"])
|
|
493
|
+
images = {
|
|
494
|
+
name: _object(identity[name])["image_id"]
|
|
495
|
+
for name in ("image_identity", "grader_image_identity")
|
|
496
|
+
}
|
|
497
|
+
entry = cache / f"{key}.json"
|
|
498
|
+
if entry.is_file():
|
|
499
|
+
saved = _object(json.loads(entry.read_text()))
|
|
500
|
+
if saved.get("preparation_key") != key:
|
|
501
|
+
raise ValueError(f"reference agent cache entry changed: {entry}")
|
|
502
|
+
# A re-prepared image under the same key has not been qualified yet, nor has a pass
|
|
503
|
+
# with a different supplied git identity (absent in both when the task has its own).
|
|
504
|
+
if saved.get("images") == images and saved.get("supplied_git_identity") == identity.get(
|
|
505
|
+
"supplied_git_identity"
|
|
506
|
+
):
|
|
507
|
+
return TypeAdapter(dict[str, dict[str, object]]).validate_python(saved["rows"])
|
|
508
|
+
rows = _reference_agent_rows(harbor_backend.run_reference_agents(identity, destination))
|
|
509
|
+
if all(row["state"] != "broken" for row in rows.values()):
|
|
510
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
511
|
+
temporary = cache / f".{key}.{os.getpid()}.tmp"
|
|
512
|
+
passed: dict[str, object] = {"preparation_key": key, "images": images, "rows": rows}
|
|
513
|
+
if "supplied_git_identity" in identity:
|
|
514
|
+
passed["supplied_git_identity"] = identity["supplied_git_identity"]
|
|
515
|
+
_write(temporary, passed)
|
|
516
|
+
temporary.replace(entry)
|
|
517
|
+
return rows
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class CheckFailed(ValueError):
|
|
521
|
+
"""A blocking check finding; no coding call was made. `report` holds the whole matrix."""
|
|
522
|
+
|
|
523
|
+
def __init__(self, report: dict[str, object], findings: list[str], path: Path) -> None:
|
|
524
|
+
super().__init__(
|
|
525
|
+
"Check failed; no coding calls were made. Repair the inputs and start a new "
|
|
526
|
+
f"evaluation. See {path}.\n" + "\n".join(findings)
|
|
527
|
+
)
|
|
528
|
+
self.report = report
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@dataclass
|
|
532
|
+
class _Checked:
|
|
533
|
+
"""What a passed check hands to coding, all in the same new run root."""
|
|
534
|
+
|
|
535
|
+
config: Evaluation
|
|
536
|
+
root: Path
|
|
537
|
+
comparison: dict[str, object]
|
|
538
|
+
harness_versions: dict[str, str]
|
|
539
|
+
profiles: dict[str, list[Collector]]
|
|
540
|
+
scopes: dict[str, object]
|
|
541
|
+
task_environment: dict[str, object]
|
|
542
|
+
capabilities: dict[str, object]
|
|
543
|
+
caches: _Caches
|
|
544
|
+
vanilla_identity: dict[str, object]
|
|
545
|
+
report: dict[str, object]
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _harness_version(harness: str) -> str:
|
|
549
|
+
native = (claude.preflight if harness == "claude" else codex.preflight)()
|
|
550
|
+
version = str(native.get("harness_version", ""))
|
|
551
|
+
match = re.search(r"(?<![\w.+-])\d+\.\d+\.\d+(?![\w.+-])", version)
|
|
552
|
+
if match is None:
|
|
553
|
+
raise ValueError(f"cannot pin {harness} CLI version: {version}")
|
|
554
|
+
if harness == "codex":
|
|
555
|
+
codex.admit_version(match.group())
|
|
556
|
+
with tempfile.TemporaryDirectory(prefix="pluginbench-auth-check-") as temporary:
|
|
557
|
+
codex.runtime_environment(Path(temporary) / "private", harness)
|
|
558
|
+
return match.group()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _coding_environment_row(
|
|
562
|
+
image: dict[str, object], probe: dict[str, object]
|
|
563
|
+
) -> dict[str, object]:
|
|
564
|
+
passed = probe.get("status") == "passed"
|
|
565
|
+
supplied = probe.get("supplied_git_identity")
|
|
566
|
+
note = None
|
|
567
|
+
if isinstance(supplied, dict):
|
|
568
|
+
note = (
|
|
569
|
+
"the task sets no git identity, so PluginBench supplied "
|
|
570
|
+
f"{supplied['name']} <{supplied['email']}> to its agents"
|
|
571
|
+
)
|
|
572
|
+
return {
|
|
573
|
+
"state": "ready" if passed else "broken",
|
|
574
|
+
"reason": None if passed else probe.get("reason"),
|
|
575
|
+
**({"note": note} if note else {}),
|
|
576
|
+
"image_source": image["source"],
|
|
577
|
+
"ignored_dockerfile": image["ignored_dockerfile"],
|
|
578
|
+
"git_probe": probe,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _reference_agent_rows(results: dict[str, object]) -> dict[str, dict[str, object]]:
|
|
583
|
+
"""Oracle must produce a nonempty patch with reward 1; nop must get reward 0."""
|
|
584
|
+
rows: dict[str, dict[str, object]] = {}
|
|
585
|
+
for agent, expected in (("oracle", True), ("nop", False)):
|
|
586
|
+
result = _object(results[agent])
|
|
587
|
+
if result["status"] == "not_configured":
|
|
588
|
+
rows[agent] = {"state": "not_configured", "reason": result["reason"]}
|
|
589
|
+
continue
|
|
590
|
+
reward = harbor_backend.binary_native_reward(result.get("verifier_result"))
|
|
591
|
+
reason = None
|
|
592
|
+
if result["status"] != "complete":
|
|
593
|
+
reason = str(result.get("reason") or "trial did not complete")
|
|
594
|
+
elif _object(result["cleanup"]).get("status") != "complete":
|
|
595
|
+
reason = "trial cleanup incomplete"
|
|
596
|
+
elif result.get("exception_info") is not None:
|
|
597
|
+
reason = f"trial failed: {result['exception_info']}"
|
|
598
|
+
elif expected and not result.get("patch_bytes"):
|
|
599
|
+
reason = "reference solution produced an empty graded patch"
|
|
600
|
+
elif reward is not expected:
|
|
601
|
+
reason = f"verifier reward is {reward}; expected {int(expected)}"
|
|
602
|
+
rows[agent] = {"state": "broken" if reason else "ready", "reason": reason}
|
|
603
|
+
return rows
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _check(config: Evaluation, output: Path, progress: Callable[[str], None] | None) -> _Checked:
|
|
607
|
+
"""Every no-model check, in a new run root. Raises CheckFailed on any blocking finding."""
|
|
608
|
+
plan = plan_evaluation(config)
|
|
609
|
+
source = config.tasks.resolve()
|
|
610
|
+
suite = source.parent if source.name == "tasks" else source
|
|
611
|
+
limits = TypeAdapter(dict[str, int]).validate_python(plan["container_limits"])
|
|
612
|
+
config = config.model_copy(
|
|
613
|
+
update={
|
|
614
|
+
"jobs": plan["jobs"],
|
|
615
|
+
"container_cpus": limits["cpus"],
|
|
616
|
+
"container_memory_mb": limits["memory_mb"],
|
|
617
|
+
}
|
|
618
|
+
)
|
|
619
|
+
comparison = _object(plan["comparison_identity"])
|
|
620
|
+
root = output.expanduser().resolve()
|
|
621
|
+
if root.exists():
|
|
622
|
+
raise ValueError(f"output must be a new directory: {root}")
|
|
623
|
+
caches = _caches(root)
|
|
624
|
+
harbor_backend.preflight(jobs=config.jobs, **limits)
|
|
625
|
+
harness_versions: dict[str, str] = {
|
|
626
|
+
harness: _harness_version(harness)
|
|
627
|
+
for harness in {
|
|
628
|
+
config.runner.harness,
|
|
629
|
+
*([config.reviewer.harness] if config.reviewer else []),
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if config.reviewer and config.reviewer.harness == "codex":
|
|
633
|
+
isolation.codex_code_mode_host()
|
|
634
|
+
coding_images = _object(plan["coding_images"])
|
|
635
|
+
warnings = [
|
|
636
|
+
f"{task}: runs the prebuilt image {image['image']}; {image['ignored_dockerfile']} is "
|
|
637
|
+
"never built, so changes to it have no effect"
|
|
638
|
+
for task, value in coding_images.items()
|
|
639
|
+
if (image := _object(value))["ignored_dockerfile"]
|
|
640
|
+
]
|
|
641
|
+
if progress:
|
|
642
|
+
for warning in warnings:
|
|
643
|
+
progress("Warning: " + warning)
|
|
644
|
+
root.mkdir(parents=True)
|
|
645
|
+
prepared = root / "inputs"
|
|
646
|
+
prepared.mkdir()
|
|
647
|
+
entries = []
|
|
648
|
+
profiles = {}
|
|
649
|
+
scopes: dict[str, object] = {}
|
|
650
|
+
task_environment: dict[str, object] = {}
|
|
651
|
+
capabilities: dict[str, object] = {}
|
|
652
|
+
matrix: dict[str, dict[str, dict[str, object]]] = {}
|
|
653
|
+
for task in config.tasks.task_ids:
|
|
654
|
+
if progress:
|
|
655
|
+
progress(f"Preparing native Harbor task {task}")
|
|
656
|
+
destination = prepared / task
|
|
657
|
+
effective = TypeAdapter(dict[str, int]).validate_python(
|
|
658
|
+
_object(_object(comparison["task_limits"])[task])["effective"]
|
|
659
|
+
)
|
|
660
|
+
identity = harbor_backend.prepare_native_task(
|
|
661
|
+
task_dir=suite / "tasks" / task,
|
|
662
|
+
destination=destination,
|
|
663
|
+
harness=config.runner.harness,
|
|
664
|
+
version=harness_versions[config.runner.harness],
|
|
665
|
+
limits={**limits, "setup_timeout": effective["build_timeout_seconds"]},
|
|
666
|
+
cache=caches.native,
|
|
667
|
+
)
|
|
668
|
+
native_task = Path(str(identity["task_dir"]))
|
|
669
|
+
fixture = Path(str(identity["fixture_dir"]))
|
|
670
|
+
if identity["original_task_assets_sha256"] != _object(comparison["tasks"])[task]:
|
|
671
|
+
raise ValueError("native task assets changed after planning")
|
|
672
|
+
# Before anything saves the identity: a task without a git identity gets PluginBench's.
|
|
673
|
+
git_probe = harbor_backend.probe_coding_environment(identity)
|
|
674
|
+
if "supplied_git_identity" in git_probe:
|
|
675
|
+
identity["supplied_git_identity"] = git_probe["supplied_git_identity"]
|
|
676
|
+
codex.write_json(destination / "artifacts/native-preparation.json", identity)
|
|
677
|
+
model = importlib.import_module("harbor.models.task.task").Task(native_task)
|
|
678
|
+
entries.append(
|
|
679
|
+
TaskEntry(
|
|
680
|
+
id=task,
|
|
681
|
+
prompt=model.instruction,
|
|
682
|
+
fixture=fixture.relative_to(prepared),
|
|
683
|
+
native_task=native_task.relative_to(prepared),
|
|
684
|
+
provenance={"native_task_identity": json.dumps(identity, sort_keys=True)},
|
|
685
|
+
)
|
|
686
|
+
)
|
|
687
|
+
profiles[task] = prepare_profiles(
|
|
688
|
+
fixture,
|
|
689
|
+
destination / "measurements",
|
|
690
|
+
config.measurements,
|
|
691
|
+
config.measurement_settings,
|
|
692
|
+
identity,
|
|
693
|
+
)
|
|
694
|
+
provenance = json.loads(
|
|
695
|
+
(destination / "measurements/measurement-provenance.json").read_text()
|
|
696
|
+
)
|
|
697
|
+
scopes[task] = {
|
|
698
|
+
"source_scope": json.loads(provenance["source_scope"]),
|
|
699
|
+
"test_scope": json.loads(provenance["test_scope"]),
|
|
700
|
+
"version": provenance["measurement_profile_version"],
|
|
701
|
+
}
|
|
702
|
+
rows = probe_measurements(
|
|
703
|
+
fixture,
|
|
704
|
+
destination / "measurement-readiness",
|
|
705
|
+
profiles[task],
|
|
706
|
+
config.measurements,
|
|
707
|
+
_object(provenance["capabilities"]),
|
|
708
|
+
)
|
|
709
|
+
qualification = _object(identity.get("source_qualification", {}))
|
|
710
|
+
qualified = (
|
|
711
|
+
identity.get("source_policy") == submissions.SOURCE_POLICY
|
|
712
|
+
and qualification.get("status") == "complete"
|
|
713
|
+
)
|
|
714
|
+
submission_source: dict[str, object] = {
|
|
715
|
+
"status": "available" if qualified else "unavailable",
|
|
716
|
+
"source_policy": submissions.SOURCE_POLICY,
|
|
717
|
+
"reason": None
|
|
718
|
+
if qualified
|
|
719
|
+
else qualification.get(
|
|
720
|
+
"reason", "canonical submission source has no compatible preparation proof"
|
|
721
|
+
),
|
|
722
|
+
}
|
|
723
|
+
# A task without a reviewed patch-grading contract has no submission source to qualify.
|
|
724
|
+
if _object(_object(plan["submission_sources"])[task])["status"] == "unsupported":
|
|
725
|
+
submission_source["configured"] = False
|
|
726
|
+
rows["submission_source"] = {
|
|
727
|
+
**submission_source,
|
|
728
|
+
"state": readiness_state(submission_source),
|
|
729
|
+
}
|
|
730
|
+
capabilities[task] = rows
|
|
731
|
+
task_environment[task] = portable_native_identity(identity)
|
|
732
|
+
matrix[task] = {
|
|
733
|
+
name: {"state": row["state"], "reason": row.get("reason")} for name, row in rows.items()
|
|
734
|
+
}
|
|
735
|
+
matrix[task]["coding_environment"] = _coding_environment_row(
|
|
736
|
+
_object(coding_images[task]), git_probe
|
|
737
|
+
)
|
|
738
|
+
matrix[task].update(
|
|
739
|
+
_reference_agents(identity, root / "check" / task, caches.reference_agents)
|
|
740
|
+
)
|
|
741
|
+
if progress:
|
|
742
|
+
for name, row in matrix[task].items():
|
|
743
|
+
if row["state"] != "ready":
|
|
744
|
+
progress(f"{task}: {name} {row['state']} — {row['reason']}")
|
|
745
|
+
elif row.get("note"):
|
|
746
|
+
progress(f"{task}: {name} ready — {row['note']}")
|
|
747
|
+
_write(
|
|
748
|
+
prepared / "tasks.json",
|
|
749
|
+
TaskCatalog(schema_version=1, tasks=entries).model_dump(mode="json"),
|
|
750
|
+
)
|
|
751
|
+
_write(root / "measurement-readiness.json", capabilities)
|
|
752
|
+
# Saved here, and loaded back as a resumed run loads them, so check fails where run would.
|
|
753
|
+
_write(
|
|
754
|
+
prepared / _PROFILES,
|
|
755
|
+
{
|
|
756
|
+
task: [
|
|
757
|
+
{
|
|
758
|
+
**collector.model_dump(mode="json"),
|
|
759
|
+
"config": str(collector.config.relative_to(prepared)),
|
|
760
|
+
}
|
|
761
|
+
for collector in collectors
|
|
762
|
+
]
|
|
763
|
+
# A task with no collector stays listed: it gets every measure except the missing ones.
|
|
764
|
+
for task, collectors in profiles.items()
|
|
765
|
+
},
|
|
766
|
+
)
|
|
767
|
+
_saved_profiles(root)
|
|
768
|
+
vanilla_identity = _vanilla_identity(
|
|
769
|
+
config, comparison, harness_versions[config.runner.harness], task_environment
|
|
770
|
+
)
|
|
771
|
+
vanilla_cache = baselines.preview(
|
|
772
|
+
path=caches.vanilla,
|
|
773
|
+
tasks=tuple(load_tasks(prepared / "tasks.json")),
|
|
774
|
+
repeats=config.repeats,
|
|
775
|
+
identity=vanilla_identity,
|
|
776
|
+
)
|
|
777
|
+
findings = blocking_findings(matrix, require_ready=config.measurement_settings.require_ready)
|
|
778
|
+
# Decided once here and saved with the run: status and the report read this list. Under
|
|
779
|
+
# require_ready a broken measure blocks instead, so nothing is skipped.
|
|
780
|
+
skipped = [] if config.measurement_settings.require_ready else not_measured(matrix)
|
|
781
|
+
for row in skipped:
|
|
782
|
+
warning = (
|
|
783
|
+
f"{row['task']}: {row['measure']} is broken, so it is not measured for this task: "
|
|
784
|
+
f"{row['reason']}"
|
|
785
|
+
)
|
|
786
|
+
if progress:
|
|
787
|
+
progress("Warning: " + warning)
|
|
788
|
+
warnings.append(warning)
|
|
789
|
+
report: dict[str, object] = {
|
|
790
|
+
"status": "blocked" if findings else "ready",
|
|
791
|
+
"model_calls": 0,
|
|
792
|
+
"require_ready": config.measurement_settings.require_ready,
|
|
793
|
+
"harness_versions": harness_versions,
|
|
794
|
+
"coding_images": coding_images,
|
|
795
|
+
"warnings": warnings,
|
|
796
|
+
"findings": findings,
|
|
797
|
+
"not_measured": skipped,
|
|
798
|
+
"matrix": matrix,
|
|
799
|
+
"vanilla_cache": vanilla_cache,
|
|
800
|
+
}
|
|
801
|
+
_write(root / "check.json", report)
|
|
802
|
+
if findings:
|
|
803
|
+
raise CheckFailed(report, findings, root / "check.json")
|
|
804
|
+
return _Checked(
|
|
805
|
+
config=config,
|
|
806
|
+
root=root,
|
|
807
|
+
comparison=comparison,
|
|
808
|
+
harness_versions=harness_versions,
|
|
809
|
+
profiles=profiles,
|
|
810
|
+
scopes=scopes,
|
|
811
|
+
task_environment=task_environment,
|
|
812
|
+
capabilities=capabilities,
|
|
813
|
+
caches=caches,
|
|
814
|
+
vanilla_identity=vanilla_identity,
|
|
815
|
+
report=report,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def check_evaluation(
|
|
820
|
+
config: Evaluation, output: Path, *, progress: Callable[[str], None] | None = None
|
|
821
|
+
) -> dict[str, object]:
|
|
822
|
+
"""Prepare every task and run every no-model check; never starts a coding attempt."""
|
|
823
|
+
return _check(config, output, progress).report
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def run_evaluation(
|
|
827
|
+
config: Evaluation, output: Path, *, progress: Callable[[str], None] | None = None
|
|
828
|
+
) -> Path:
|
|
829
|
+
return resume_evaluation(
|
|
830
|
+
prepare_evaluation(config, output, progress=progress), progress=progress
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def prepare_evaluation(
|
|
835
|
+
config: Evaluation, output: Path, *, progress: Callable[[str], None] | None = None
|
|
836
|
+
) -> Path:
|
|
837
|
+
"""Check, then save everything coding needs, so any later process can run or resume it."""
|
|
838
|
+
checked = _check(config, output, progress)
|
|
839
|
+
config = checked.config
|
|
840
|
+
root = checked.root
|
|
841
|
+
comparison = checked.comparison
|
|
842
|
+
harness_versions = checked.harness_versions
|
|
843
|
+
profiles = checked.profiles
|
|
844
|
+
scopes = checked.scopes
|
|
845
|
+
task_environment = checked.task_environment
|
|
846
|
+
capabilities = checked.capabilities
|
|
847
|
+
prepared = root / "inputs"
|
|
848
|
+
fixed_guidance = []
|
|
849
|
+
if config.reviewer:
|
|
850
|
+
for index, original in enumerate(config.reviewer.guidance):
|
|
851
|
+
target = prepared / f"review-{index}.md"
|
|
852
|
+
shutil.copy2(original, target)
|
|
853
|
+
fixed_guidance.append(target)
|
|
854
|
+
if (
|
|
855
|
+
_criteria_digest(_fixed_criteria([file.read_text() for file in fixed_guidance]))
|
|
856
|
+
!= comparison["review_criteria_sha256"]
|
|
857
|
+
):
|
|
858
|
+
raise ValueError("fixed review criteria changed during preparation")
|
|
859
|
+
supplied = any("supplied_git_identity" in _object(row) for row in task_environment.values())
|
|
860
|
+
recipe = {
|
|
861
|
+
# 4 makes builds that cannot supply a git identity refuse the run before coding.
|
|
862
|
+
"schema_version": 4 if supplied else 3,
|
|
863
|
+
"backend": "harbor",
|
|
864
|
+
"harness_version": harness_versions[config.runner.harness],
|
|
865
|
+
"container_cpus": config.container_cpus,
|
|
866
|
+
"container_memory_mb": config.container_memory_mb,
|
|
867
|
+
"name": config.name,
|
|
868
|
+
"harness": config.runner.harness,
|
|
869
|
+
"tasks": str(prepared / "tasks.json"),
|
|
870
|
+
"treatments": {
|
|
871
|
+
name: item.model_dump(mode="json") for name, item in config.treatments.items()
|
|
872
|
+
},
|
|
873
|
+
"capabilities": config.capabilities.model_dump(mode="json"),
|
|
874
|
+
"model": config.runner.model,
|
|
875
|
+
"reasoning": config.runner.reasoning,
|
|
876
|
+
"repeats": config.repeats,
|
|
877
|
+
"seed": config.seed,
|
|
878
|
+
"timeout_seconds": config.timeout_seconds,
|
|
879
|
+
"jobs": config.jobs,
|
|
880
|
+
}
|
|
881
|
+
_write(prepared / "study.json", recipe)
|
|
882
|
+
comparison.update(
|
|
883
|
+
task_environment=task_environment,
|
|
884
|
+
harness_version=harness_versions[config.runner.harness],
|
|
885
|
+
measurement_capabilities=capabilities,
|
|
886
|
+
)
|
|
887
|
+
selection = {
|
|
888
|
+
**config.model_dump(mode="json"),
|
|
889
|
+
"comparison_identity": comparison,
|
|
890
|
+
"task_scopes": scopes,
|
|
891
|
+
"measurement_capabilities": capabilities,
|
|
892
|
+
"measurement_readiness": capabilities,
|
|
893
|
+
"not_measured": checked.report["not_measured"],
|
|
894
|
+
"artifact_base": "evidence",
|
|
895
|
+
"measurement_required": any(profiles.values()),
|
|
896
|
+
"measurement_revision": None,
|
|
897
|
+
"review_revision": None,
|
|
898
|
+
}
|
|
899
|
+
_write(prepared / "vanilla-identity.json", checked.vanilla_identity)
|
|
900
|
+
_save_selection(root, selection)
|
|
901
|
+
return root
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
# Measurement profiles saved at preparation, so measurement can start after coding resumes.
|
|
905
|
+
_PROFILES = "profiles.json"
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _saved_profiles(root: Path) -> dict[str, list[Collector]]:
|
|
909
|
+
path = root / "inputs" / _PROFILES
|
|
910
|
+
if not path.is_file():
|
|
911
|
+
raise ValueError(
|
|
912
|
+
"this run predates saved measurement profiles, so measurement cannot start on "
|
|
913
|
+
"resume; its coding evidence is kept"
|
|
914
|
+
)
|
|
915
|
+
return load_profiles(path)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _measurement_unstarted(root: Path, selection: dict[str, object]) -> bool:
|
|
919
|
+
"""Measurement is required and no observer revision has started: it uses saved profiles."""
|
|
920
|
+
return bool(selection["measurement_required"]) and not any(
|
|
921
|
+
(root / "evidence/observer-revisions" / str(candidate)).exists()
|
|
922
|
+
for candidate in (
|
|
923
|
+
selection.get("pending_measurement_revision", "measurements"),
|
|
924
|
+
selection.get("recovery_measurement_revision"),
|
|
925
|
+
)
|
|
926
|
+
if candidate is not None
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _review_preflight(selection: dict[str, object]) -> None:
|
|
931
|
+
"""The reviewer's CLI, credentials and sandbox host, checked before any paid review call."""
|
|
932
|
+
reviewer = _object(selection["reviewer"])
|
|
933
|
+
harness = str(reviewer["harness"])
|
|
934
|
+
(claude.preflight if harness == "claude" else codex.preflight)()
|
|
935
|
+
with tempfile.TemporaryDirectory(prefix="pluginbench-auth-check-") as temporary:
|
|
936
|
+
codex.runtime_environment(Path(temporary) / "private", harness)
|
|
937
|
+
if harness == "codex":
|
|
938
|
+
isolation.codex_code_mode_host()
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _assert_resumable(root: Path, selection: dict[str, object]) -> None:
|
|
942
|
+
"""Fail before any resumed coding call on what would otherwise fail after it."""
|
|
943
|
+
if _measurement_unstarted(root, selection):
|
|
944
|
+
_saved_profiles(root)
|
|
945
|
+
if selection.get("reviewer") and selection["review_revision"] is None:
|
|
946
|
+
_review_preflight(selection)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _code(
|
|
950
|
+
root: Path,
|
|
951
|
+
selection: dict[str, object],
|
|
952
|
+
*,
|
|
953
|
+
progress: Callable[[str], None] | None,
|
|
954
|
+
guard: Callable[[], None] | None,
|
|
955
|
+
) -> None:
|
|
956
|
+
"""Start coding, or resume it: complete attempts are kept and never coded again."""
|
|
957
|
+
evidence = root / "evidence"
|
|
958
|
+
config = _saved_evaluation(selection)
|
|
959
|
+
recipe = _object(json.loads((root / "inputs/study.json").read_text()))
|
|
960
|
+
identity_path = root / "inputs/vanilla-identity.json"
|
|
961
|
+
# Runs prepared before vanilla identities were saved resume without the vanilla cache.
|
|
962
|
+
identity = _object(json.loads(identity_path.read_text())) if identity_path.is_file() else None
|
|
963
|
+
resuming = (evidence / "run.json").is_file()
|
|
964
|
+
|
|
965
|
+
def same_coder() -> None:
|
|
966
|
+
# Checked only once reconciliation has found a slot left to code.
|
|
967
|
+
version = _harness_version(config.runner.harness)
|
|
968
|
+
if version != recipe["harness_version"]:
|
|
969
|
+
raise ValueError(
|
|
970
|
+
f"{config.runner.harness} CLI is {version}, but this run codes with "
|
|
971
|
+
f"{recipe['harness_version']}; install that version to resume it"
|
|
972
|
+
)
|
|
973
|
+
# One comparison must not hold attempts coded by two launchers.
|
|
974
|
+
_assert_launcher(selection, identity, config.runner.harness)
|
|
975
|
+
|
|
976
|
+
if resuming:
|
|
977
|
+
resume_study(
|
|
978
|
+
evidence,
|
|
979
|
+
progress=progress,
|
|
980
|
+
baseline_cache=_caches(root).vanilla if identity else None,
|
|
981
|
+
baseline_identity=identity,
|
|
982
|
+
guard=guard,
|
|
983
|
+
before_coding=same_coder,
|
|
984
|
+
)
|
|
985
|
+
else:
|
|
986
|
+
run_study(
|
|
987
|
+
load_study(root / "inputs/study.json"),
|
|
988
|
+
evidence,
|
|
989
|
+
progress=progress,
|
|
990
|
+
baseline_cache=_caches(root).vanilla if identity else None,
|
|
991
|
+
baseline_identity=identity,
|
|
992
|
+
guard=guard,
|
|
993
|
+
)
|
|
994
|
+
if not (root / "inputs" / _PROFILES).is_file():
|
|
995
|
+
return
|
|
996
|
+
# Measurement contexts refer to the same run-owned frozen task assets as grading/review.
|
|
997
|
+
frozen_catalog = TaskCatalog.model_validate_json((evidence / "frozen/tasks.json").read_text())
|
|
998
|
+
frozen_tasks = {
|
|
999
|
+
entry.id: evidence / "frozen" / entry.native_task for entry in frozen_catalog.tasks
|
|
1000
|
+
}
|
|
1001
|
+
for task, collectors in _saved_profiles(root).items():
|
|
1002
|
+
for collector in collectors:
|
|
1003
|
+
if collector.kind == "analysis" or (
|
|
1004
|
+
collector.kind == "coverage" and collector.report_format is not None
|
|
1005
|
+
):
|
|
1006
|
+
payload = json.loads(collector.config.read_text())
|
|
1007
|
+
payload["native_task_identity"]["task_dir"] = str(frozen_tasks[task])
|
|
1008
|
+
_write(collector.config, payload)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _saved_evaluation(selection: dict[str, object]) -> Evaluation:
|
|
1012
|
+
normalized = _object(study.normalize_legacy_guidance(selection))
|
|
1013
|
+
return Evaluation.model_validate(
|
|
1014
|
+
{key: normalized[key] for key in Evaluation.model_fields if key in normalized}
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _finish_evaluation(
|
|
1019
|
+
root: Path,
|
|
1020
|
+
*,
|
|
1021
|
+
selection: dict[str, object],
|
|
1022
|
+
profiles: dict[str, list[Collector]],
|
|
1023
|
+
guidance: list[Path],
|
|
1024
|
+
progress: Callable[[str], None] | None,
|
|
1025
|
+
) -> Path:
|
|
1026
|
+
evidence = root / "evidence"
|
|
1027
|
+
config = _saved_evaluation(selection)
|
|
1028
|
+
if profiles and selection["measurement_revision"] is None:
|
|
1029
|
+
revision = _next_measurement_revision(evidence)
|
|
1030
|
+
if selection.get("pending_measurement_revision") is not None:
|
|
1031
|
+
selection["recovery_measurement_revision"] = selection["pending_measurement_revision"]
|
|
1032
|
+
selection["pending_measurement_revision"] = revision
|
|
1033
|
+
_save_selection(root, selection)
|
|
1034
|
+
propose_remeasurement(
|
|
1035
|
+
evidence,
|
|
1036
|
+
profiles=profiles,
|
|
1037
|
+
revision=revision,
|
|
1038
|
+
progress=progress,
|
|
1039
|
+
jobs=config.jobs,
|
|
1040
|
+
masks_listed_pairs=bool(selection.get("not_measured")),
|
|
1041
|
+
)
|
|
1042
|
+
_adopt_measurement_revision(root, selection, revision)
|
|
1043
|
+
_save_selection(root, selection)
|
|
1044
|
+
if config.reviewer and selection["review_revision"] is None:
|
|
1045
|
+
if progress:
|
|
1046
|
+
progress("Reviewing all submissions independently with fixed criteria")
|
|
1047
|
+
review_study(
|
|
1048
|
+
evidence,
|
|
1049
|
+
harness=config.reviewer.harness,
|
|
1050
|
+
model=config.reviewer.model,
|
|
1051
|
+
effort=config.reviewer.reasoning,
|
|
1052
|
+
timeout=config.reviewer.timeout_seconds or config.timeout_seconds,
|
|
1053
|
+
seed=config.seed,
|
|
1054
|
+
revision="review",
|
|
1055
|
+
guidance=guidance,
|
|
1056
|
+
jobs=config.jobs,
|
|
1057
|
+
)
|
|
1058
|
+
selection["review_revision"] = "review"
|
|
1059
|
+
_save_selection(root, selection)
|
|
1060
|
+
if config.reviewer and selection["review_revision"] is not None:
|
|
1061
|
+
selection["review_revision"] = recover_review_citations(
|
|
1062
|
+
evidence, str(selection["review_revision"])
|
|
1063
|
+
)
|
|
1064
|
+
_save_selection(root, selection)
|
|
1065
|
+
regenerate_report(root)
|
|
1066
|
+
return root
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _load_selection(root: Path) -> dict[str, object]:
|
|
1070
|
+
path = root / "evaluation.json"
|
|
1071
|
+
if not path.is_file():
|
|
1072
|
+
raise ValueError(
|
|
1073
|
+
f"{root} is not a run directory (it has no evaluation.json); give the directory "
|
|
1074
|
+
"that `pluginbench run` printed"
|
|
1075
|
+
)
|
|
1076
|
+
binding = json.loads((root / "evaluation-binding.json").read_text())
|
|
1077
|
+
if hashlib.sha256(path.read_bytes()).hexdigest() != binding["sha256"]:
|
|
1078
|
+
raise ValueError("saved evaluation settings changed")
|
|
1079
|
+
return _object(json.loads(path.read_text()))
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _selected_revision(selection: dict[str, object], key: str) -> str | None:
|
|
1083
|
+
value = selection[key]
|
|
1084
|
+
if value is not None and not isinstance(value, str):
|
|
1085
|
+
raise ValueError(f"saved {key} must be a string or null")
|
|
1086
|
+
return value
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _adopt_measurement_revision(root: Path, selection: dict[str, object], revision: str) -> None:
|
|
1090
|
+
evidence = root / "evidence"
|
|
1091
|
+
directory = evidence / "observer-revisions" / revision
|
|
1092
|
+
validate_remeasurement(evidence, directory)
|
|
1093
|
+
state = json.loads((directory / "revision.json").read_text())
|
|
1094
|
+
saved_history = selection.get("measurement_history", [])
|
|
1095
|
+
if not isinstance(saved_history, list):
|
|
1096
|
+
raise ValueError("measurement history must be a list of bound revision receipts")
|
|
1097
|
+
history = list(saved_history)
|
|
1098
|
+
prior = selection.get("recovery_measurement_revision")
|
|
1099
|
+
if prior is not None:
|
|
1100
|
+
path = evidence / "observer-revisions" / str(prior) / "revision.json"
|
|
1101
|
+
receipt = study._reference(evidence, path)
|
|
1102
|
+
if receipt not in history:
|
|
1103
|
+
history.append(receipt)
|
|
1104
|
+
selection["measurement_history"] = history
|
|
1105
|
+
profiles = load_profiles(study._bound_path(evidence, state["frozen_profiles"]))
|
|
1106
|
+
comparison = _object(selection["comparison_identity"])
|
|
1107
|
+
comparison["effective_observer_scripts"] = {
|
|
1108
|
+
task: {
|
|
1109
|
+
collector.kind: hashlib.sha256(Path(command[1]).read_bytes()).hexdigest()
|
|
1110
|
+
for collector in collectors
|
|
1111
|
+
for command in (collector.command or collector.syntax_command,)
|
|
1112
|
+
if command and len(command) > 1 and Path(command[1]).is_file()
|
|
1113
|
+
}
|
|
1114
|
+
for task, collectors in profiles.items()
|
|
1115
|
+
}
|
|
1116
|
+
selection["comparison_identity"] = comparison
|
|
1117
|
+
selection["measurement_revision"] = revision
|
|
1118
|
+
selection.pop("pending_measurement_revision", None)
|
|
1119
|
+
selection.pop("recovery_measurement_revision", None)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _next_measurement_revision(evidence: Path) -> str:
|
|
1123
|
+
revision = "measurements"
|
|
1124
|
+
index = 2
|
|
1125
|
+
while (evidence / "observer-revisions" / revision).exists():
|
|
1126
|
+
revision = f"measurements-{index}"
|
|
1127
|
+
index += 1
|
|
1128
|
+
return revision
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _sole_owner(root: Path) -> bool:
|
|
1132
|
+
"""Whether this process may treat a measurement left active as dead.
|
|
1133
|
+
|
|
1134
|
+
`resume_evaluation` measures only while it holds run.lock, and the flock ends with its
|
|
1135
|
+
process, even on SIGKILL. So a measurement the holder finds still active was left by a process
|
|
1136
|
+
that ended. A plan may assume the same while no live process holds the lock. A held lock with
|
|
1137
|
+
no owner record yet counts as another owner.
|
|
1138
|
+
"""
|
|
1139
|
+
return run_lock.held_here(root) or run_lock.owner(root) is None
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _measurement_to_recover(
|
|
1143
|
+
evidence: Path, selection: dict[str, object]
|
|
1144
|
+
) -> tuple[str, dict[str, object]] | None:
|
|
1145
|
+
"""The first revision a resume names whose saved state it can use, with that state.
|
|
1146
|
+
|
|
1147
|
+
None when each one has no state file or failed before it saved frozen profiles.
|
|
1148
|
+
"""
|
|
1149
|
+
for candidate in (
|
|
1150
|
+
selection.get("pending_measurement_revision", "measurements"),
|
|
1151
|
+
selection.get("recovery_measurement_revision"),
|
|
1152
|
+
):
|
|
1153
|
+
if candidate is None:
|
|
1154
|
+
continue
|
|
1155
|
+
revision = str(candidate)
|
|
1156
|
+
directory = evidence / "observer-revisions" / revision
|
|
1157
|
+
if (
|
|
1158
|
+
directory.is_symlink()
|
|
1159
|
+
or directory.resolve().parent != (evidence / "observer-revisions").resolve()
|
|
1160
|
+
):
|
|
1161
|
+
raise ValueError("observer recovery must stay inside its native study")
|
|
1162
|
+
if not (directory / "revision.json").exists():
|
|
1163
|
+
continue
|
|
1164
|
+
state = _object(json.loads((directory / "revision.json").read_text()))
|
|
1165
|
+
if state["status"] != "failed" or all(
|
|
1166
|
+
key in state for key in ("profile_inventory", "frozen_profiles")
|
|
1167
|
+
):
|
|
1168
|
+
return revision, state
|
|
1169
|
+
return None
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _resume_inputs(
|
|
1173
|
+
root: Path, *, resuming: bool, progress: Callable[[str], None] | None = None
|
|
1174
|
+
) -> tuple[dict[str, object], dict[str, list[Collector]], list[Path], int]:
|
|
1175
|
+
"""What a resume has left after coding. With `resuming`, dead active state is closed on disk."""
|
|
1176
|
+
selection = _load_selection(root)
|
|
1177
|
+
config = _saved_evaluation(selection)
|
|
1178
|
+
evidence = root / "evidence"
|
|
1179
|
+
run, manifest, original = _native_state(evidence)
|
|
1180
|
+
profiles: dict[str, list[Collector]] = {}
|
|
1181
|
+
|
|
1182
|
+
def measuring_again(abandoned: str, why: str) -> None:
|
|
1183
|
+
if progress:
|
|
1184
|
+
progress(
|
|
1185
|
+
f"Measurement revision {abandoned} {why}, so it cannot continue. Its files stay; "
|
|
1186
|
+
f"measurement starts again as {_next_measurement_revision(evidence)}"
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
if selection["measurement_revision"] is not None:
|
|
1190
|
+
validate_remeasurement(
|
|
1191
|
+
evidence, evidence / "observer-revisions" / str(selection["measurement_revision"])
|
|
1192
|
+
)
|
|
1193
|
+
elif _measurement_unstarted(root, selection):
|
|
1194
|
+
profiles = _saved_profiles(root)
|
|
1195
|
+
elif selection["measurement_required"]:
|
|
1196
|
+
found = _measurement_to_recover(evidence, selection)
|
|
1197
|
+
if found is None:
|
|
1198
|
+
# Each named revision ended before it saved frozen profiles: a kill left its
|
|
1199
|
+
# directory with no state file, or a stop recorded it as failed first. Its directory
|
|
1200
|
+
# stays as it is; measurement starts again under a new name with the prepared profiles.
|
|
1201
|
+
if not _sole_owner(root):
|
|
1202
|
+
raise ValueError(
|
|
1203
|
+
"observer recovery has no saved frozen profiles and another process may own "
|
|
1204
|
+
"this run; cannot replay it"
|
|
1205
|
+
)
|
|
1206
|
+
measuring_again(
|
|
1207
|
+
str(selection.get("pending_measurement_revision", "measurements")),
|
|
1208
|
+
"ended before it saved its frozen profiles",
|
|
1209
|
+
)
|
|
1210
|
+
# Nothing to bind in measurement history: the revision saved no state.
|
|
1211
|
+
selection["pending_measurement_revision"] = None
|
|
1212
|
+
profiles = _saved_profiles(root)
|
|
1213
|
+
else:
|
|
1214
|
+
revision, state = found
|
|
1215
|
+
directory = evidence / "observer-revisions" / revision
|
|
1216
|
+
selection["pending_measurement_revision"] = revision
|
|
1217
|
+
if state["status"] == "collecting" and _sole_owner(root):
|
|
1218
|
+
# Its process ended without recording why; record it as failed, keep its files.
|
|
1219
|
+
state.update(
|
|
1220
|
+
status="failed",
|
|
1221
|
+
error="its process ended while collecting; found by a later resume",
|
|
1222
|
+
)
|
|
1223
|
+
if resuming:
|
|
1224
|
+
study._write(directory / "revision.json", state)
|
|
1225
|
+
measuring_again(revision, "was left collecting by a process that ended")
|
|
1226
|
+
if state["status"] == "complete":
|
|
1227
|
+
validate_remeasurement(evidence, directory)
|
|
1228
|
+
_adopt_measurement_revision(root, selection, revision)
|
|
1229
|
+
elif state["status"] == "failed":
|
|
1230
|
+
if (
|
|
1231
|
+
study._bound_document(evidence, state["source_run"]) != run
|
|
1232
|
+
or study._bound_path(evidence, state["source_assessment"]) != original
|
|
1233
|
+
or _preserved_inventory(evidence, manifest, original.parent)
|
|
1234
|
+
!= state["preserved"]
|
|
1235
|
+
):
|
|
1236
|
+
raise ValueError("saved native inputs changed after failed observer collection")
|
|
1237
|
+
if study._inventory(directory / "profiles") != state["profile_inventory"]:
|
|
1238
|
+
raise ValueError("frozen observer profiles changed")
|
|
1239
|
+
profiles = load_profiles(study._bound_path(evidence, state["frozen_profiles"]))
|
|
1240
|
+
else:
|
|
1241
|
+
raise ValueError(
|
|
1242
|
+
"observer collection is still marked active and another process may own "
|
|
1243
|
+
"this run; cannot replay it"
|
|
1244
|
+
)
|
|
1245
|
+
guidance = []
|
|
1246
|
+
calls = 0
|
|
1247
|
+
if config.reviewer:
|
|
1248
|
+
if selection["review_revision"] is not None:
|
|
1249
|
+
validate_review_revision(evidence, str(selection["review_revision"]))
|
|
1250
|
+
else:
|
|
1251
|
+
if (evidence / "review-revisions/review").exists():
|
|
1252
|
+
raise ValueError("review evidence already exists; model calls cannot be replayed")
|
|
1253
|
+
guidance = _frozen_review_guidance(root, selection, config.reviewer)
|
|
1254
|
+
calls = sum(record.stages[-1].snapshot is not None for record in manifest.records)
|
|
1255
|
+
return selection, profiles, guidance, calls
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _frozen_review_guidance(
|
|
1259
|
+
root: Path, selection: dict[str, object], reviewer: Reviewer
|
|
1260
|
+
) -> list[Path]:
|
|
1261
|
+
"""The run's saved review standards, refused if they no longer match its comparison."""
|
|
1262
|
+
guidance = [root / "inputs" / f"review-{index}.md" for index in range(len(reviewer.guidance))]
|
|
1263
|
+
criteria = _fixed_criteria([file.read_text() for file in guidance])
|
|
1264
|
+
if (
|
|
1265
|
+
_criteria_digest(criteria)
|
|
1266
|
+
!= _object(selection["comparison_identity"])["review_criteria_sha256"]
|
|
1267
|
+
):
|
|
1268
|
+
raise ValueError("frozen review criteria changed")
|
|
1269
|
+
return guidance
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _assigned(selection: dict[str, object]) -> int:
|
|
1273
|
+
config = _saved_evaluation(selection)
|
|
1274
|
+
return len(config.tasks.task_ids) * config.repeats * (len(config.treatments) + 1)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _coding_done(root: Path) -> bool:
|
|
1278
|
+
run = root / "evidence/run.json"
|
|
1279
|
+
return run.is_file() and json.loads(run.read_text())["status"] == "complete"
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _coding_left(root: Path, selection: dict[str, object]) -> int:
|
|
1283
|
+
"""Coding calls a resume makes: one per slot without a complete attempt."""
|
|
1284
|
+
run = root / "evidence/run.json"
|
|
1285
|
+
if not run.is_file():
|
|
1286
|
+
return _assigned(selection)
|
|
1287
|
+
states = _object(json.loads(run.read_text())["attempt_states"])
|
|
1288
|
+
return sum(state != "complete" for state in states.values())
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def plan_resume(root: Path) -> dict[str, object]:
|
|
1292
|
+
root = root.expanduser().resolve()
|
|
1293
|
+
selection = _load_selection(root)
|
|
1294
|
+
if not _coding_done(root):
|
|
1295
|
+
if _measurement_unstarted(root, selection):
|
|
1296
|
+
_saved_profiles(root)
|
|
1297
|
+
reviewer = selection.get("reviewer")
|
|
1298
|
+
return {
|
|
1299
|
+
"name": selection["name"],
|
|
1300
|
+
"coding_calls": _coding_left(root, selection),
|
|
1301
|
+
# At most one review per assigned attempt; only submitted attempts are reviewed.
|
|
1302
|
+
"review_calls": _assigned(selection) if reviewer else 0,
|
|
1303
|
+
"collect_measurements": bool(selection["measurement_required"]),
|
|
1304
|
+
"jobs": selection["jobs"],
|
|
1305
|
+
"vanilla": "keep complete attempts; code pending slots and replace interrupted ones",
|
|
1306
|
+
}
|
|
1307
|
+
selection, profiles, _, calls = _resume_inputs(root, resuming=False)
|
|
1308
|
+
return {
|
|
1309
|
+
"name": selection["name"],
|
|
1310
|
+
"coding_calls": 0,
|
|
1311
|
+
"review_calls": calls,
|
|
1312
|
+
"collect_measurements": bool(profiles),
|
|
1313
|
+
"jobs": selection["jobs"],
|
|
1314
|
+
"vanilla": "reuse immutable saved submissions; no coding replay",
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def resume_evaluation(
|
|
1319
|
+
root: Path,
|
|
1320
|
+
*,
|
|
1321
|
+
progress: Callable[[str], None] | None = None,
|
|
1322
|
+
guard: Callable[[], None] | None = None,
|
|
1323
|
+
) -> Path:
|
|
1324
|
+
"""Run whatever a prepared evaluation has left: coding, then measurement and reviews.
|
|
1325
|
+
|
|
1326
|
+
Holds run.lock for the whole call, taking it unless this process already has it, so a run
|
|
1327
|
+
never has two processes working on it. Must then run in the main thread.
|
|
1328
|
+
"""
|
|
1329
|
+
root = root.expanduser().resolve()
|
|
1330
|
+
with nullcontext() if run_lock.held_here(root) else run_lock.hold(root):
|
|
1331
|
+
return _resume(root, progress=progress, guard=guard)
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _resume(
|
|
1335
|
+
root: Path, *, progress: Callable[[str], None] | None, guard: Callable[[], None] | None
|
|
1336
|
+
) -> Path:
|
|
1337
|
+
selection = _load_selection(root)
|
|
1338
|
+
if not _coding_done(root):
|
|
1339
|
+
_assert_resumable(root, selection)
|
|
1340
|
+
_code(root, selection, progress=progress, guard=guard)
|
|
1341
|
+
if guard:
|
|
1342
|
+
guard()
|
|
1343
|
+
selection, profiles, guidance, calls = _resume_inputs(root, resuming=True, progress=progress)
|
|
1344
|
+
if calls:
|
|
1345
|
+
_review_preflight(selection)
|
|
1346
|
+
return _finish_evaluation(
|
|
1347
|
+
root, selection=selection, profiles=profiles, guidance=guidance, progress=progress
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def plan_review(root: Path, revision: str) -> dict[str, object]:
|
|
1352
|
+
"""Check that a finished run can be reviewed again as `revision`, and count its calls."""
|
|
1353
|
+
root = root.expanduser().resolve()
|
|
1354
|
+
selection = _load_selection(root)
|
|
1355
|
+
config = _saved_evaluation(selection)
|
|
1356
|
+
if config.reviewer is None:
|
|
1357
|
+
raise ValueError("this run has no reviewer, so it has no review to repeat")
|
|
1358
|
+
if selection["review_revision"] is None:
|
|
1359
|
+
raise ValueError(
|
|
1360
|
+
f"this run has not finished its first review; finish it with `pluginbench run {root}`"
|
|
1361
|
+
)
|
|
1362
|
+
if _review_directory(root / "evidence", revision).exists():
|
|
1363
|
+
raise ValueError(f"review revision {revision} already exists; choose a new --revision name")
|
|
1364
|
+
_frozen_review_guidance(root, selection, config.reviewer)
|
|
1365
|
+
_, manifest, _ = _native_state(root / "evidence")
|
|
1366
|
+
return {
|
|
1367
|
+
"name": selection["name"],
|
|
1368
|
+
"revision": revision,
|
|
1369
|
+
"review_calls": sum(record.stages[-1].snapshot is not None for record in manifest.records),
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def review_evaluation(
|
|
1374
|
+
root: Path, revision: str, *, progress: Callable[[str], None] | None = None
|
|
1375
|
+
) -> str:
|
|
1376
|
+
"""Review every submission again with the frozen reviewer as `revision`, then select it.
|
|
1377
|
+
|
|
1378
|
+
Earlier revisions stay in evidence. Returns the selected revision: `<revision>-citations`
|
|
1379
|
+
when offline citation recovery repaired its formatting.
|
|
1380
|
+
"""
|
|
1381
|
+
root = root.expanduser().resolve()
|
|
1382
|
+
plan_review(root, revision)
|
|
1383
|
+
selection = _load_selection(root)
|
|
1384
|
+
config = _saved_evaluation(selection)
|
|
1385
|
+
if config.reviewer is None:
|
|
1386
|
+
raise ValueError("this run has no reviewer, so it has no review to repeat")
|
|
1387
|
+
guidance = _frozen_review_guidance(root, selection, config.reviewer)
|
|
1388
|
+
_review_preflight(selection)
|
|
1389
|
+
if progress:
|
|
1390
|
+
progress(f"Reviewing all submissions again as review revision {revision}")
|
|
1391
|
+
review_study(
|
|
1392
|
+
root / "evidence",
|
|
1393
|
+
harness=config.reviewer.harness,
|
|
1394
|
+
model=config.reviewer.model,
|
|
1395
|
+
effort=config.reviewer.reasoning,
|
|
1396
|
+
timeout=config.reviewer.timeout_seconds or config.timeout_seconds,
|
|
1397
|
+
seed=config.seed,
|
|
1398
|
+
revision=revision,
|
|
1399
|
+
guidance=guidance,
|
|
1400
|
+
jobs=config.jobs,
|
|
1401
|
+
)
|
|
1402
|
+
selected = recover_review_citations(root / "evidence", revision)
|
|
1403
|
+
selection["review_revision"] = selected
|
|
1404
|
+
_save_selection(root, selection)
|
|
1405
|
+
regenerate_report(root)
|
|
1406
|
+
return selected
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _save_selection(root: Path, selection: object) -> None:
|
|
1410
|
+
# A stop between these writes would leave settings that every later command refuses.
|
|
1411
|
+
with run_lock.deferred_stop():
|
|
1412
|
+
_write(root / "evaluation.json", selection)
|
|
1413
|
+
_write(
|
|
1414
|
+
root / "evaluation-binding.json",
|
|
1415
|
+
{"sha256": hashlib.sha256((root / "evaluation.json").read_bytes()).hexdigest()},
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _measurement_collection(root: Path, selection: dict[str, object]) -> dict[str, object] | None:
|
|
1420
|
+
revision = _selected_revision(selection, "measurement_revision")
|
|
1421
|
+
if revision is None:
|
|
1422
|
+
return None
|
|
1423
|
+
evidence = root / "evidence"
|
|
1424
|
+
path = evidence / "observer-revisions" / revision / "revision.json"
|
|
1425
|
+
state = study._bound_document(evidence, study._reference(evidence, path))
|
|
1426
|
+
manifest = study._bound_document(evidence, state["proposed_assessment"])
|
|
1427
|
+
tasks = {record["id"]: record["task_id"] for record in manifest["records"]}
|
|
1428
|
+
available = 0
|
|
1429
|
+
failures = []
|
|
1430
|
+
for observation in state["observations"]:
|
|
1431
|
+
outcomes = observation["outcomes"]
|
|
1432
|
+
for family, outcome in outcomes.items():
|
|
1433
|
+
if outcome["status"] == "available":
|
|
1434
|
+
available += 1
|
|
1435
|
+
continue
|
|
1436
|
+
receipts = [repeat[family] for repeat in observation["repeats"]]
|
|
1437
|
+
diagnostics = [
|
|
1438
|
+
study._bound_document(evidence, receipt).get("reason")
|
|
1439
|
+
for receipt in receipts
|
|
1440
|
+
if str(receipt["path"]).endswith("failure.json")
|
|
1441
|
+
]
|
|
1442
|
+
failures.append(
|
|
1443
|
+
{
|
|
1444
|
+
"attempt_id": observation["attempt_id"],
|
|
1445
|
+
"task": tasks[observation["attempt_id"]],
|
|
1446
|
+
"stage": observation["stage"],
|
|
1447
|
+
"family": family,
|
|
1448
|
+
**outcome,
|
|
1449
|
+
"receipts": receipts,
|
|
1450
|
+
"diagnostics": diagnostics,
|
|
1451
|
+
}
|
|
1452
|
+
)
|
|
1453
|
+
history = []
|
|
1454
|
+
saved_history = selection.get("measurement_history", [])
|
|
1455
|
+
if not isinstance(saved_history, list):
|
|
1456
|
+
raise ValueError("measurement history must be a list of bound revision receipts")
|
|
1457
|
+
for receipt in saved_history:
|
|
1458
|
+
previous = study._bound_document(evidence, receipt)
|
|
1459
|
+
history.append(
|
|
1460
|
+
{"receipt": receipt, "status": previous["status"], "revision": previous["revision"]}
|
|
1461
|
+
)
|
|
1462
|
+
return {
|
|
1463
|
+
"revision": revision,
|
|
1464
|
+
"status": "partial" if failures else "complete",
|
|
1465
|
+
"procedure_status": state["status"],
|
|
1466
|
+
"count_scope": "attempt/stage/family observations",
|
|
1467
|
+
"available_families": available,
|
|
1468
|
+
"unavailable_families": len(failures),
|
|
1469
|
+
"failures": failures,
|
|
1470
|
+
"revision_receipt": study._reference(evidence, path),
|
|
1471
|
+
"history": history,
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def regenerate_report(root: Path, *, review_revision: str | None = None) -> Path:
|
|
1476
|
+
root = root.expanduser().resolve()
|
|
1477
|
+
config = _load_selection(root)
|
|
1478
|
+
comparison = _object(config["comparison_identity"])
|
|
1479
|
+
config["comparison_identity"] = comparison
|
|
1480
|
+
evidence = root / "evidence"
|
|
1481
|
+
output = root / "report"
|
|
1482
|
+
if review_revision is not None:
|
|
1483
|
+
review_path = validate_review_revision(evidence, review_revision)
|
|
1484
|
+
state = study._bound_document(evidence, study._reference(evidence, review_path))
|
|
1485
|
+
protocol = study._bound_document(evidence, state["protocol"])
|
|
1486
|
+
reviewer = _object(config["reviewer"])
|
|
1487
|
+
expected = {
|
|
1488
|
+
"harness": reviewer["harness"],
|
|
1489
|
+
"model": reviewer["model"],
|
|
1490
|
+
"effort": reviewer["reasoning"],
|
|
1491
|
+
"criteria_sha256": comparison["review_criteria_sha256"],
|
|
1492
|
+
"seed": config["seed"],
|
|
1493
|
+
"jobs": config["jobs"],
|
|
1494
|
+
"timeout_seconds": reviewer.get("timeout_seconds") or config["timeout_seconds"],
|
|
1495
|
+
"stage": "handoff",
|
|
1496
|
+
}
|
|
1497
|
+
if any(protocol.get(key) != value for key, value in expected.items()):
|
|
1498
|
+
raise ValueError("review projection differs from the fixed comparison settings")
|
|
1499
|
+
original_revision = _selected_revision(config, "review_revision")
|
|
1500
|
+
previous_path = (
|
|
1501
|
+
validate_review_revision(evidence, original_revision) if original_revision else None
|
|
1502
|
+
)
|
|
1503
|
+
states = {review_path}
|
|
1504
|
+
if previous_path is not None:
|
|
1505
|
+
states.add(previous_path)
|
|
1506
|
+
starts = {
|
|
1507
|
+
study._bound_path(evidence, row["native_start"]).resolve()
|
|
1508
|
+
for path in states
|
|
1509
|
+
for row in study._bound_document(evidence, study._reference(evidence, path))["records"]
|
|
1510
|
+
if row.get("native_start") is not None
|
|
1511
|
+
}
|
|
1512
|
+
config["review_projection"] = {
|
|
1513
|
+
"original_revision": original_revision,
|
|
1514
|
+
"selected_revision": review_revision,
|
|
1515
|
+
"revision_receipts": [study._reference(evidence, path) for path in sorted(states)],
|
|
1516
|
+
"native_invocations_in_these_revisions": len(starts),
|
|
1517
|
+
"usage_accounting": "Charts show the selected revision; bound prior revision receipts retain failed calls and their usage.",
|
|
1518
|
+
}
|
|
1519
|
+
config["review_revision"] = review_revision
|
|
1520
|
+
output = review_path.parent / "evaluation-report"
|
|
1521
|
+
report_path = report_study(
|
|
1522
|
+
evidence,
|
|
1523
|
+
measurement_revision=_selected_revision(config, "measurement_revision"),
|
|
1524
|
+
review_revision=_selected_revision(config, "review_revision"),
|
|
1525
|
+
)
|
|
1526
|
+
report = json.loads(report_path.read_text())
|
|
1527
|
+
recipe = json.loads((evidence / "frozen/resolved-recipe.json").read_text())
|
|
1528
|
+
packages = {}
|
|
1529
|
+
if "treatments" in recipe:
|
|
1530
|
+
packages = {
|
|
1531
|
+
name: treatment_inventory(
|
|
1532
|
+
resolve_treatment(
|
|
1533
|
+
Treatment.model_validate(item), evidence / "frozen", recipe["harness"]
|
|
1534
|
+
)
|
|
1535
|
+
)
|
|
1536
|
+
for name, item in recipe["treatments"].items()
|
|
1537
|
+
}
|
|
1538
|
+
else:
|
|
1539
|
+
for name, files in recipe["guidance"].items():
|
|
1540
|
+
rows = [
|
|
1541
|
+
{
|
|
1542
|
+
"path": str(Path(file).relative_to(root)),
|
|
1543
|
+
"sha256": hashlib.sha256(Path(file).read_bytes()).hexdigest(),
|
|
1544
|
+
}
|
|
1545
|
+
for file in files
|
|
1546
|
+
]
|
|
1547
|
+
packages[name] = {
|
|
1548
|
+
"files": rows,
|
|
1549
|
+
"sha256": hashlib.sha256(
|
|
1550
|
+
json.dumps([row["sha256"] for row in rows]).encode()
|
|
1551
|
+
).hexdigest(),
|
|
1552
|
+
}
|
|
1553
|
+
config["guidance_packages"] = packages
|
|
1554
|
+
runtime_profiles = {
|
|
1555
|
+
json.dumps(
|
|
1556
|
+
{
|
|
1557
|
+
"model": attempt["model"],
|
|
1558
|
+
**{
|
|
1559
|
+
key: attempt["provenance"].get(key)
|
|
1560
|
+
for key in (
|
|
1561
|
+
"harness",
|
|
1562
|
+
"harness_version",
|
|
1563
|
+
"reasoning_effort",
|
|
1564
|
+
"comparison_reasoning_effort",
|
|
1565
|
+
"effort_evidence_source",
|
|
1566
|
+
)
|
|
1567
|
+
},
|
|
1568
|
+
},
|
|
1569
|
+
sort_keys=True,
|
|
1570
|
+
)
|
|
1571
|
+
for attempt in report["attempts"]
|
|
1572
|
+
}
|
|
1573
|
+
comparison["observed_runtime_profiles"] = [
|
|
1574
|
+
json.loads(profile) for profile in sorted(runtime_profiles)
|
|
1575
|
+
]
|
|
1576
|
+
if config.get("reviewer"):
|
|
1577
|
+
reviewer = _object(config["reviewer"])
|
|
1578
|
+
judge_profiles = set()
|
|
1579
|
+
for attempt in report["attempts"]:
|
|
1580
|
+
review = attempt.get("independent_review", {})
|
|
1581
|
+
execution = review.get("execution") or {}
|
|
1582
|
+
host = review.get("requested_harness", reviewer["harness"])
|
|
1583
|
+
profile = {
|
|
1584
|
+
"harness": f"{host}-cli",
|
|
1585
|
+
"actual_models": execution.get("actual_primary_models"),
|
|
1586
|
+
"effective_effort": execution.get("runtime_effort_evidence"),
|
|
1587
|
+
"harness_version": codex.observed_cli_version(execution.get("startup"), host=host)
|
|
1588
|
+
or execution.get("launch_harness_version"),
|
|
1589
|
+
"requested_effort": review.get("requested_reasoning_effort"),
|
|
1590
|
+
"effort_evidence_source": execution.get(
|
|
1591
|
+
"effort_evidence_source",
|
|
1592
|
+
"native session event"
|
|
1593
|
+
if execution.get("runtime_effort_evidence")
|
|
1594
|
+
else "unknown",
|
|
1595
|
+
),
|
|
1596
|
+
}
|
|
1597
|
+
judge_profiles.add(json.dumps(profile, sort_keys=True))
|
|
1598
|
+
comparison["observed_reviewer_profiles"] = [
|
|
1599
|
+
json.loads(profile) for profile in sorted(judge_profiles)
|
|
1600
|
+
]
|
|
1601
|
+
manifest = json.loads((evidence / "run.json").read_text())
|
|
1602
|
+
if manifest["status"] != "complete":
|
|
1603
|
+
# A run stopped during coding reports what it has, and the report says it is partial.
|
|
1604
|
+
coded = list(manifest["attempt_states"].values())
|
|
1605
|
+
config["unfinished_run"] = {"complete": coded.count("complete"), "assigned": len(coded)}
|
|
1606
|
+
input_path = evidence / manifest["assessment_input"]["path"]
|
|
1607
|
+
records = {row["id"]: row for row in json.loads(input_path.read_text())["records"]}
|
|
1608
|
+
for attempt in report["attempts"]:
|
|
1609
|
+
scope = _object(_object(config["task_scopes"])[attempt["task_id"]])
|
|
1610
|
+
# Canonical attempt contains digest only; bound input owns the snapshot paths.
|
|
1611
|
+
record = records[attempt["id"]]
|
|
1612
|
+
# An attempt that is not coded yet has no starting snapshot.
|
|
1613
|
+
if record.get("before") is None:
|
|
1614
|
+
continue
|
|
1615
|
+
before = input_path.parent / record["before"]["path"]
|
|
1616
|
+
for stage in attempt["stages"]:
|
|
1617
|
+
submitted = next(row for row in record["stages"] if row["id"] == stage["id"])
|
|
1618
|
+
if submitted["snapshot"] is None:
|
|
1619
|
+
continue
|
|
1620
|
+
after = input_path.parent / submitted["snapshot"]["path"]
|
|
1621
|
+
stage["code_size"] = scoped_code_size(
|
|
1622
|
+
before=before,
|
|
1623
|
+
after=after,
|
|
1624
|
+
scope=TypeAdapter(list[str]).validate_python(scope["source_scope"]),
|
|
1625
|
+
change=stage["change"],
|
|
1626
|
+
)
|
|
1627
|
+
stage["test_code_size"] = scoped_test_size(
|
|
1628
|
+
before=before,
|
|
1629
|
+
after=after,
|
|
1630
|
+
scope=TypeAdapter(list[str]).validate_python(scope["test_scope"]),
|
|
1631
|
+
change=stage["change"],
|
|
1632
|
+
)
|
|
1633
|
+
for task, value in _object(report.get("references", {})).items():
|
|
1634
|
+
# Reference solutions use the task's declared source scope, like every submission.
|
|
1635
|
+
reference = _object(value)
|
|
1636
|
+
if reference.get("status") != "available":
|
|
1637
|
+
continue
|
|
1638
|
+
reference["code_size"] = scoped_code_size(
|
|
1639
|
+
before=evidence / str(_object(reference["before"])["path"]),
|
|
1640
|
+
after=evidence / str(_object(reference["after"])["path"]),
|
|
1641
|
+
scope=TypeAdapter(list[str]).validate_python(
|
|
1642
|
+
_object(_object(config["task_scopes"])[task])["source_scope"]
|
|
1643
|
+
),
|
|
1644
|
+
change=_object(reference["change"]),
|
|
1645
|
+
)
|
|
1646
|
+
prices = root / "prices.json"
|
|
1647
|
+
if prices.is_file():
|
|
1648
|
+
# Dated prices are reporting input, not measurement settings, so they stay outside the binding.
|
|
1649
|
+
config["prices"] = _object(json.loads(prices.read_text()))
|
|
1650
|
+
config["measurement_collection"] = _measurement_collection(root, config)
|
|
1651
|
+
return write_evaluation_report(report, config, output)
|