pluginbench 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. pluginbench/__init__.py +3 -0
  2. pluginbench/__main__.py +3 -0
  3. pluginbench/assessment-charts.html +790 -0
  4. pluginbench/assessment.py +316 -0
  5. pluginbench/assessment_charts.py +1225 -0
  6. pluginbench/assessment_metrics.py +1956 -0
  7. pluginbench/assessment_models.py +127 -0
  8. pluginbench/baselines.py +792 -0
  9. pluginbench/claude.py +374 -0
  10. pluginbench/cli.py +672 -0
  11. pluginbench/code_diff.py +207 -0
  12. pluginbench/codex.py +451 -0
  13. pluginbench/codex_events.py +585 -0
  14. pluginbench/collectors.py +591 -0
  15. pluginbench/compare.py +927 -0
  16. pluginbench/components.py +129 -0
  17. pluginbench/coverage_report.py +610 -0
  18. pluginbench/datasets.py +207 -0
  19. pluginbench/evaluation.example.yaml +45 -0
  20. pluginbench/evaluation.py +1651 -0
  21. pluginbench/evaluation_report.py +1004 -0
  22. pluginbench/fallow_parse.mjs +101 -0
  23. pluginbench/harbor_backend.py +2946 -0
  24. pluginbench/isolation.py +314 -0
  25. pluginbench/judge.py +683 -0
  26. pluginbench/language_analysis.py +915 -0
  27. pluginbench/ledger.py +217 -0
  28. pluginbench/measurement_profiles.py +616 -0
  29. pluginbench/native_treatment_runtime.js +221 -0
  30. pluginbench/native_treatments.py +155 -0
  31. pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
  32. pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
  33. pluginbench/observer_data/go-analysis-v1/main.go +64 -0
  34. pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
  35. pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
  36. pluginbench/parallel.py +101 -0
  37. pluginbench/processes.py +100 -0
  38. pluginbench/remeasure.py +992 -0
  39. pluginbench/run_lock.py +227 -0
  40. pluginbench/run_status.py +518 -0
  41. pluginbench/study.py +1858 -0
  42. pluginbench/study_review.py +924 -0
  43. pluginbench/submissions.py +486 -0
  44. pluginbench/task_sources.py +403 -0
  45. pluginbench/treatments.py +388 -0
  46. pluginbench/trial_containers.py +94 -0
  47. pluginbench/vendor/ECHARTS-LICENSE +222 -0
  48. pluginbench/vendor/ECHARTS-NOTICE +5 -0
  49. pluginbench/vendor/ZRENDER-LICENSE +29 -0
  50. pluginbench/vendor/echarts.min.js +48 -0
  51. pluginbench-0.5.0.dist-info/METADATA +294 -0
  52. pluginbench-0.5.0.dist-info/RECORD +58 -0
  53. pluginbench-0.5.0.dist-info/WHEEL +4 -0
  54. pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
  55. pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
  56. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
  57. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
  58. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
@@ -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)