pluginbench 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pluginbench/__init__.py +3 -0
- pluginbench/__main__.py +3 -0
- pluginbench/assessment-charts.html +790 -0
- pluginbench/assessment.py +316 -0
- pluginbench/assessment_charts.py +1225 -0
- pluginbench/assessment_metrics.py +1956 -0
- pluginbench/assessment_models.py +127 -0
- pluginbench/baselines.py +792 -0
- pluginbench/claude.py +374 -0
- pluginbench/cli.py +672 -0
- pluginbench/code_diff.py +207 -0
- pluginbench/codex.py +451 -0
- pluginbench/codex_events.py +585 -0
- pluginbench/collectors.py +591 -0
- pluginbench/compare.py +927 -0
- pluginbench/components.py +129 -0
- pluginbench/coverage_report.py +610 -0
- pluginbench/datasets.py +207 -0
- pluginbench/evaluation.example.yaml +45 -0
- pluginbench/evaluation.py +1651 -0
- pluginbench/evaluation_report.py +1004 -0
- pluginbench/fallow_parse.mjs +101 -0
- pluginbench/harbor_backend.py +2946 -0
- pluginbench/isolation.py +314 -0
- pluginbench/judge.py +683 -0
- pluginbench/language_analysis.py +915 -0
- pluginbench/ledger.py +217 -0
- pluginbench/measurement_profiles.py +616 -0
- pluginbench/native_treatment_runtime.js +221 -0
- pluginbench/native_treatments.py +155 -0
- pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
- pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
- pluginbench/observer_data/go-analysis-v1/main.go +64 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
- pluginbench/parallel.py +101 -0
- pluginbench/processes.py +100 -0
- pluginbench/remeasure.py +992 -0
- pluginbench/run_lock.py +227 -0
- pluginbench/run_status.py +518 -0
- pluginbench/study.py +1858 -0
- pluginbench/study_review.py +924 -0
- pluginbench/submissions.py +486 -0
- pluginbench/task_sources.py +403 -0
- pluginbench/treatments.py +388 -0
- pluginbench/trial_containers.py +94 -0
- pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench/vendor/ZRENDER-LICENSE +29 -0
- pluginbench/vendor/echarts.min.js +48 -0
- pluginbench-0.5.0.dist-info/METADATA +294 -0
- pluginbench-0.5.0.dist-info/RECORD +58 -0
- pluginbench-0.5.0.dist-info/WHEEL +4 -0
- pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
- pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
pluginbench/remeasure.py
ADDED
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
"""Propose repeatable offline observer revisions without replacing native study evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import partial
|
|
15
|
+
from threading import Event
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from pluginbench import harbor_backend, study
|
|
19
|
+
from pluginbench.parallel import run_jobs, validate_jobs
|
|
20
|
+
from pluginbench.assessment import _load_manifest, build_assessment
|
|
21
|
+
from pluginbench.assessment_metrics import (
|
|
22
|
+
compare_evidence,
|
|
23
|
+
compare_snapshots,
|
|
24
|
+
evaluate_evidence,
|
|
25
|
+
inspect_snapshot,
|
|
26
|
+
)
|
|
27
|
+
from pluginbench.assessment_models import ArtifactRef, AssessmentInput, MetricKind
|
|
28
|
+
from pluginbench.collectors import (
|
|
29
|
+
Collector,
|
|
30
|
+
collect_measurements,
|
|
31
|
+
collector_identity,
|
|
32
|
+
container_runtime,
|
|
33
|
+
freeze_collectors,
|
|
34
|
+
resolve_collector_command,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _object(value: object) -> dict[str, object]:
|
|
39
|
+
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
40
|
+
raise ValueError("remeasurement requires a JSON object")
|
|
41
|
+
return dict(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _canonical(value: object) -> object:
|
|
45
|
+
if isinstance(value, dict):
|
|
46
|
+
return {key: _canonical(item) for key, item in value.items()}
|
|
47
|
+
if isinstance(value, list):
|
|
48
|
+
return sorted(
|
|
49
|
+
(_canonical(item) for item in value), key=lambda item: json.dumps(item, sort_keys=True)
|
|
50
|
+
)
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalized(measurement: dict[str, object]) -> dict[str, object]:
|
|
55
|
+
# Evaluated metrics include counts, identities, denominators, populations and
|
|
56
|
+
# limitations. Raw execution paths/timing are intentionally not compared.
|
|
57
|
+
return {
|
|
58
|
+
kind: {
|
|
59
|
+
key: _canonical(_object(value).get(key)) for key in ("status", "provenance", "metrics")
|
|
60
|
+
}
|
|
61
|
+
for kind, value in measurement.items()
|
|
62
|
+
if kind != "snapshot_digest"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class CollectedInput:
|
|
68
|
+
repeats: list[dict[MetricKind, ArtifactRef]]
|
|
69
|
+
outcomes: dict[str, object]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# A task with no collector has two empty repeats and no family outcome.
|
|
73
|
+
_NOTHING_COLLECTED = CollectedInput(repeats=[{}, {}], outcomes={})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class MeasurementInput:
|
|
78
|
+
index: int
|
|
79
|
+
key: tuple[str, str, str]
|
|
80
|
+
stage: str
|
|
81
|
+
snapshot: Path
|
|
82
|
+
snapshot_digest: str
|
|
83
|
+
before: Path
|
|
84
|
+
before_digest: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _family_outcomes(
|
|
88
|
+
normalized: list[dict[str, object]],
|
|
89
|
+
repeats: list[dict[MetricKind, ArtifactRef]],
|
|
90
|
+
) -> dict[str, object]:
|
|
91
|
+
outcomes: dict[str, object] = {}
|
|
92
|
+
for kind in repeats[0]:
|
|
93
|
+
states = [
|
|
94
|
+
"available" if refs[kind].path.name == "envelope.json" else "unavailable"
|
|
95
|
+
for refs in repeats
|
|
96
|
+
]
|
|
97
|
+
matching = (
|
|
98
|
+
states == ["available", "available"] and normalized[0][kind] == normalized[1][kind]
|
|
99
|
+
)
|
|
100
|
+
status = (
|
|
101
|
+
"available"
|
|
102
|
+
if matching
|
|
103
|
+
else ("nonrepeatable" if "available" in states else "unavailable")
|
|
104
|
+
)
|
|
105
|
+
outcomes[kind] = {
|
|
106
|
+
"status": status,
|
|
107
|
+
"repeat_states": states,
|
|
108
|
+
"reason": None
|
|
109
|
+
if matching
|
|
110
|
+
else (
|
|
111
|
+
"collector unavailable in both repeats"
|
|
112
|
+
if status == "unavailable"
|
|
113
|
+
else "two valid matching measurements were not obtained"
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
return outcomes
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _validate_failure(
|
|
120
|
+
*,
|
|
121
|
+
path: Path,
|
|
122
|
+
collector: Collector,
|
|
123
|
+
snapshot_digest: str,
|
|
124
|
+
before_digest: str,
|
|
125
|
+
repeat: int,
|
|
126
|
+
) -> None:
|
|
127
|
+
failure = _object(json.loads(path.read_text()))
|
|
128
|
+
if set(failure) != {
|
|
129
|
+
"schema_version",
|
|
130
|
+
"status",
|
|
131
|
+
"reason",
|
|
132
|
+
"snapshot_digest",
|
|
133
|
+
"before_digest",
|
|
134
|
+
"collector",
|
|
135
|
+
"execution",
|
|
136
|
+
"repeat_index",
|
|
137
|
+
}:
|
|
138
|
+
raise ValueError("invalid collector failure receipt fields")
|
|
139
|
+
if (
|
|
140
|
+
type(failure["schema_version"]) is not int
|
|
141
|
+
or failure["schema_version"] != 1
|
|
142
|
+
or failure["status"] != "unavailable"
|
|
143
|
+
or failure["snapshot_digest"] != snapshot_digest
|
|
144
|
+
or failure["before_digest"] != before_digest
|
|
145
|
+
or type(failure["repeat_index"]) is not int
|
|
146
|
+
or failure["repeat_index"] != repeat
|
|
147
|
+
or failure["collector"] != collector_identity(collector)
|
|
148
|
+
or not isinstance(failure["reason"], str)
|
|
149
|
+
or not failure["reason"]
|
|
150
|
+
):
|
|
151
|
+
raise ValueError("collector failure differs from its bound source/profile/repeat")
|
|
152
|
+
execution = _object(failure["execution"])
|
|
153
|
+
if execution.get("path") != "execution.json":
|
|
154
|
+
raise ValueError("collector failure must bind its own execution receipt")
|
|
155
|
+
receipt = path.parent / "execution.json"
|
|
156
|
+
if execution != {"path": "execution.json", "sha256": study._digest(receipt)}:
|
|
157
|
+
raise ValueError("collector failure execution receipt changed")
|
|
158
|
+
executions = json.loads(receipt.read_text())
|
|
159
|
+
if not isinstance(executions, list):
|
|
160
|
+
raise ValueError("collector failure execution receipt must be a list")
|
|
161
|
+
if collector.failure_protocol is not None and (
|
|
162
|
+
len(executions) != 1 or _object(executions[0]).get("exit_code") != 3
|
|
163
|
+
):
|
|
164
|
+
raise ValueError("trusted coverage adapter failure lacks a classified unavailable exit")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _verify_native_references(root: Path, value: object, seen: set[Path]) -> None:
|
|
168
|
+
if isinstance(value, list):
|
|
169
|
+
for item in value:
|
|
170
|
+
_verify_native_references(root, item, seen)
|
|
171
|
+
elif isinstance(value, dict):
|
|
172
|
+
if set(value) == {"path", "sha256"}:
|
|
173
|
+
path = study._bound_path(root, value)
|
|
174
|
+
if path in seen:
|
|
175
|
+
return
|
|
176
|
+
seen.add(path)
|
|
177
|
+
if path.suffix == ".json":
|
|
178
|
+
document = json.loads(path.read_text())
|
|
179
|
+
# Assessment references belong to that manifest and include source
|
|
180
|
+
# directories. The manifest loader checks them below.
|
|
181
|
+
if isinstance(document, dict) and "name" in document and "records" in document:
|
|
182
|
+
return
|
|
183
|
+
_verify_native_references(root, document, seen)
|
|
184
|
+
else:
|
|
185
|
+
for item in value.values():
|
|
186
|
+
_verify_native_references(root, item, seen)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _native_state(root: Path) -> tuple[dict[str, object], AssessmentInput, Path]:
|
|
190
|
+
study._assert_frozen(root)
|
|
191
|
+
run = _object(json.loads((root / "run.json").read_text()))
|
|
192
|
+
states = _object(run.get("attempt_states"))
|
|
193
|
+
if (
|
|
194
|
+
run.get("status") != "complete"
|
|
195
|
+
or not states
|
|
196
|
+
or any(value != "complete" for value in states.values())
|
|
197
|
+
):
|
|
198
|
+
raise ValueError("remeasurement requires every native attempt to be complete")
|
|
199
|
+
original = study._bound_path(root, run["assessment_input"])
|
|
200
|
+
manifest = AssessmentInput.model_validate_json(original.read_text())
|
|
201
|
+
plan = study._bound_document(root, run["plan"])
|
|
202
|
+
study._assert_saved_sources(root=root, run=run, plan=plan)
|
|
203
|
+
entries = {entry["id"]: entry for entry in plan["entries"]}
|
|
204
|
+
if set(states) != {record.id for record in manifest.records} or set(entries) != set(states):
|
|
205
|
+
raise ValueError("native attempt identities differ from their frozen plan")
|
|
206
|
+
for record in manifest.records:
|
|
207
|
+
if any(
|
|
208
|
+
getattr(record, key) != entries[record.id][key] for key in ("task_id", "arm", "attempt")
|
|
209
|
+
):
|
|
210
|
+
raise ValueError("native assignment differs from its frozen plan")
|
|
211
|
+
if record.before is None:
|
|
212
|
+
raise ValueError("remeasurement requires a saved starting snapshot")
|
|
213
|
+
for stage in record.stages:
|
|
214
|
+
executions = _object(_object(run.get("executions")).get(record.id))
|
|
215
|
+
study._bound_document(root, executions[stage.id])
|
|
216
|
+
study._bound_document(root, run["runtime_preflight"])
|
|
217
|
+
if run.get("calibration"):
|
|
218
|
+
study._bound_document(root, run["calibration"])
|
|
219
|
+
seen: set[Path] = set()
|
|
220
|
+
for value in _object(run.get("baseline_origin", {})).values():
|
|
221
|
+
origin = _object(value)
|
|
222
|
+
if study._bound_document(root, origin["evidence"]) != {
|
|
223
|
+
key: item for key, item in origin.items() if key != "evidence"
|
|
224
|
+
}:
|
|
225
|
+
raise ValueError("baseline origin differs from its bound evidence")
|
|
226
|
+
# original_record retains origin_run's namespace, including snapshots.
|
|
227
|
+
# Other provenance references belong to the imported run.
|
|
228
|
+
history = origin.get("historical_evidence")
|
|
229
|
+
if not isinstance(history, list):
|
|
230
|
+
raise ValueError("baseline origin requires historical evidence references")
|
|
231
|
+
_verify_native_references(
|
|
232
|
+
root,
|
|
233
|
+
{
|
|
234
|
+
key: item
|
|
235
|
+
for key, item in origin.items()
|
|
236
|
+
if key not in {"original_record", "evidence"}
|
|
237
|
+
},
|
|
238
|
+
seen,
|
|
239
|
+
)
|
|
240
|
+
_verify_native_references(
|
|
241
|
+
root, {key: value for key, value in run.items() if key != "baseline_origin"}, seen
|
|
242
|
+
)
|
|
243
|
+
# Validate source, correctness and metric-envelope bindings too.
|
|
244
|
+
_load_manifest(original)
|
|
245
|
+
build_assessment(original)
|
|
246
|
+
return run, manifest, original
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _preserved_inventory(root: Path, manifest: AssessmentInput, base: Path) -> dict[str, object]:
|
|
250
|
+
files: dict[str, str] = {}
|
|
251
|
+
snapshots: dict[str, str] = {}
|
|
252
|
+
for name in ("frozen", "report"):
|
|
253
|
+
directory = root / name
|
|
254
|
+
if directory.exists():
|
|
255
|
+
files.update(
|
|
256
|
+
{f"{name}/{path}": digest for path, digest in study._inventory(directory).items()}
|
|
257
|
+
)
|
|
258
|
+
for name in ("run.json", "frozen-inventory.json"):
|
|
259
|
+
files[name] = study._digest(root / name)
|
|
260
|
+
for record in manifest.records:
|
|
261
|
+
for reference in (record.before, *(stage.snapshot for stage in record.stages)):
|
|
262
|
+
if reference is None:
|
|
263
|
+
continue
|
|
264
|
+
path = (base / reference.path).resolve()
|
|
265
|
+
if not path.is_relative_to(root):
|
|
266
|
+
raise ValueError("snapshot must remain inside the native study")
|
|
267
|
+
snapshots[path.relative_to(root).as_posix()] = str(inspect_snapshot(path)["digest"])
|
|
268
|
+
references = [stage.correctness for stage in record.stages]
|
|
269
|
+
references += list((record.before_evidence or {}).values())
|
|
270
|
+
for stage in record.stages:
|
|
271
|
+
references += list((stage.evidence or {}).values())
|
|
272
|
+
for reference in references:
|
|
273
|
+
if reference is not None:
|
|
274
|
+
path = (base / reference.path).resolve()
|
|
275
|
+
if not path.is_relative_to(root):
|
|
276
|
+
raise ValueError("native artifact must remain inside the native study")
|
|
277
|
+
files[path.relative_to(root).as_posix()] = study._digest(path)
|
|
278
|
+
# These native receipts are not necessarily under review/report directories.
|
|
279
|
+
run = _object(json.loads((root / "run.json").read_text()))
|
|
280
|
+
for group in ("executions", "verifier_results", "submission_errors", "invocation_starts"):
|
|
281
|
+
for stages in _object(run.get(group, {})).values():
|
|
282
|
+
for receipt_ref in _object(stages).values():
|
|
283
|
+
path = study._bound_path(root, receipt_ref)
|
|
284
|
+
files[path.relative_to(root).as_posix()] = study._digest(path)
|
|
285
|
+
for key in (
|
|
286
|
+
"assessment_input",
|
|
287
|
+
"plan",
|
|
288
|
+
"runtime_preflight",
|
|
289
|
+
"calibration",
|
|
290
|
+
):
|
|
291
|
+
if run.get(key):
|
|
292
|
+
path = study._bound_path(root, run[key])
|
|
293
|
+
files[path.relative_to(root).as_posix()] = study._digest(path)
|
|
294
|
+
diagnostics = {
|
|
295
|
+
path.relative_to(root).as_posix(): harbor_backend.native_task_assets(path)
|
|
296
|
+
for path in sorted(root.glob("attempts/*/*/diagnostic-source"))
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
"files": files,
|
|
300
|
+
"snapshots": snapshots,
|
|
301
|
+
**({"diagnostics": diagnostics} if diagnostics else {}),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _rebased(manifest: AssessmentInput, base: Path, destination: Path) -> AssessmentInput:
|
|
306
|
+
revised = manifest.model_copy(deep=True)
|
|
307
|
+
|
|
308
|
+
def rebase(reference: ArtifactRef) -> ArtifactRef:
|
|
309
|
+
return reference.model_copy(
|
|
310
|
+
update={"path": Path(os.path.relpath((base / reference.path).resolve(), destination))}
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
for record in revised.records:
|
|
314
|
+
record.before = rebase(record.before) if record.before else None
|
|
315
|
+
record.before_evidence = (
|
|
316
|
+
{kind: rebase(ref) for kind, ref in record.before_evidence.items()}
|
|
317
|
+
if record.before_evidence
|
|
318
|
+
else None
|
|
319
|
+
)
|
|
320
|
+
for stage in record.stages:
|
|
321
|
+
stage.snapshot = rebase(stage.snapshot) if stage.snapshot else None
|
|
322
|
+
stage.correctness = rebase(stage.correctness) if stage.correctness else None
|
|
323
|
+
stage.evidence = (
|
|
324
|
+
{kind: rebase(ref) for kind, ref in stage.evidence.items()}
|
|
325
|
+
if stage.evidence
|
|
326
|
+
else None
|
|
327
|
+
)
|
|
328
|
+
return AssessmentInput.model_validate(revised.model_dump())
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def load_profiles(path: Path) -> dict[str, list[Collector]]:
|
|
332
|
+
"""Load a JSON task-to-collector mapping with the study's path semantics."""
|
|
333
|
+
source = path.resolve()
|
|
334
|
+
document = _object(json.loads(source.read_text()))
|
|
335
|
+
profiles = {}
|
|
336
|
+
for task_id, definitions in document.items():
|
|
337
|
+
if not task_id or not isinstance(definitions, list):
|
|
338
|
+
raise ValueError("profiles must map task IDs to collector lists")
|
|
339
|
+
collectors = [Collector.model_validate(value) for value in definitions]
|
|
340
|
+
for collector in collectors:
|
|
341
|
+
collector.config = study._resolve(source.parent, collector.config)
|
|
342
|
+
for field in ("command", "syntax_command"):
|
|
343
|
+
command = getattr(collector, field)
|
|
344
|
+
if command:
|
|
345
|
+
setattr(collector, field, resolve_collector_command(source.parent, command))
|
|
346
|
+
profiles[task_id] = collectors
|
|
347
|
+
return profiles
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _apply_reference(before: Path, patch: Path, destination: Path) -> str | None:
|
|
351
|
+
"""Apply a task's reference patch to a copy of its starting snapshot; return a failure reason."""
|
|
352
|
+
# Apply outside any repository so Git cannot resolve paths against an enclosing work tree.
|
|
353
|
+
with tempfile.TemporaryDirectory(prefix="pluginbench-reference-") as temporary:
|
|
354
|
+
work = Path(temporary) / "snapshot"
|
|
355
|
+
shutil.copytree(before, work, symlinks=True)
|
|
356
|
+
applied = subprocess.run(
|
|
357
|
+
["git", "apply", "--whitespace=nowarn", str(patch.resolve())],
|
|
358
|
+
cwd=work,
|
|
359
|
+
capture_output=True,
|
|
360
|
+
text=True,
|
|
361
|
+
check=False,
|
|
362
|
+
)
|
|
363
|
+
if applied.returncode:
|
|
364
|
+
return "reference patch does not apply: " + applied.stderr.strip()[:400]
|
|
365
|
+
shutil.copytree(work, destination, symlinks=True)
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _reference_record(
|
|
370
|
+
repeats: list[dict[str, object]], change: dict[str, object]
|
|
371
|
+
) -> dict[str, object]:
|
|
372
|
+
"""Accept a reference only when its two collections normalize identically."""
|
|
373
|
+
if len(repeats) != 2 or _normalized(repeats[0]) != _normalized(repeats[1]):
|
|
374
|
+
return {"status": "unavailable", "reason": "reference measurements are not repeatable"}
|
|
375
|
+
return {"status": "available", "snapshot_digest": change["after_digest"], "change": change}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def reference_locations(
|
|
379
|
+
root: Path,
|
|
380
|
+
*,
|
|
381
|
+
before: tuple[Path, str, dict[str, str]],
|
|
382
|
+
after: tuple[Path, str, dict[str, str]],
|
|
383
|
+
) -> dict[str, object]:
|
|
384
|
+
"""Snapshot bindings and raw report refs that reference_comparison reads back."""
|
|
385
|
+
sides = {"before": before, "after": after}
|
|
386
|
+
return {
|
|
387
|
+
**{
|
|
388
|
+
side: study._reference(root, snapshot, snapshot=True)
|
|
389
|
+
for side, (snapshot, _, _) in sides.items()
|
|
390
|
+
},
|
|
391
|
+
# Raw reports, not extracted metrics, so reports re-extract them like submissions.
|
|
392
|
+
"evidence_base": {side: base for side, (_, base, _) in sides.items()},
|
|
393
|
+
"evidence": {side: refs for side, (_, _, refs) in sides.items()},
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def reference_comparison(root: Path, reference: dict[str, object]) -> dict[str, object]:
|
|
398
|
+
"""Re-extract stored raw reference reports at report time, exactly as submissions are."""
|
|
399
|
+
evaluated = []
|
|
400
|
+
for side in ("before", "after"):
|
|
401
|
+
bound = _object(reference[side])
|
|
402
|
+
snapshot = (root / str(bound["path"])).resolve()
|
|
403
|
+
if (
|
|
404
|
+
not snapshot.is_relative_to(root.resolve())
|
|
405
|
+
or inspect_snapshot(snapshot)["digest"] != bound["sha256"]
|
|
406
|
+
):
|
|
407
|
+
raise ValueError("reference snapshot changed after measurement")
|
|
408
|
+
refs = _object(_object(reference["evidence"])[side])
|
|
409
|
+
directory = (root / str(_object(reference["evidence_base"])[side])).resolve()
|
|
410
|
+
if not directory.is_relative_to(root.resolve()):
|
|
411
|
+
raise ValueError("reference evidence must stay inside its native study")
|
|
412
|
+
evaluated.append(
|
|
413
|
+
evaluate_evidence(
|
|
414
|
+
snapshot, {str(kind): str(path) for kind, path in refs.items()}, directory
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
return compare_evidence(evaluated[0], evaluated[1], change=_object(reference["change"]))
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _collect_references(
|
|
421
|
+
root: Path,
|
|
422
|
+
directory: Path,
|
|
423
|
+
manifest: AssessmentInput,
|
|
424
|
+
base: Path,
|
|
425
|
+
frozen: dict[str, list[Collector]],
|
|
426
|
+
) -> dict[str, object]:
|
|
427
|
+
"""Measure each task's reference solution with the frozen code collectors (not coverage)."""
|
|
428
|
+
catalog = root / "frozen/tasks.json"
|
|
429
|
+
entries = _object(json.loads(catalog.read_text())).get("tasks", []) if catalog.is_file() else []
|
|
430
|
+
if not isinstance(entries, list):
|
|
431
|
+
raise ValueError("frozen task catalog requires a task list")
|
|
432
|
+
tasks = {
|
|
433
|
+
str(entry["id"]): root / "frozen" / str(entry["native_task"])
|
|
434
|
+
for entry in entries
|
|
435
|
+
if isinstance(entry, dict)
|
|
436
|
+
}
|
|
437
|
+
befores: dict[str, Path] = {}
|
|
438
|
+
for record in manifest.records:
|
|
439
|
+
assert record.before is not None
|
|
440
|
+
befores.setdefault(record.task_id, (base / record.before.path).resolve())
|
|
441
|
+
references: dict[str, object] = {}
|
|
442
|
+
for task, collectors in sorted(frozen.items()):
|
|
443
|
+
code = [collector for collector in collectors if collector.kind != "coverage"]
|
|
444
|
+
patch = tasks[task] / "solution/solution.patch" if task in tasks else None
|
|
445
|
+
if patch is None or not patch.is_file():
|
|
446
|
+
references[task] = {
|
|
447
|
+
"status": "unavailable",
|
|
448
|
+
"reason": "task ships no solution/solution.patch",
|
|
449
|
+
}
|
|
450
|
+
continue
|
|
451
|
+
if not code:
|
|
452
|
+
references[task] = {"status": "unavailable", "reason": "no code collectors selected"}
|
|
453
|
+
continue
|
|
454
|
+
target = directory / "references" / task
|
|
455
|
+
snapshot = target / "snapshot"
|
|
456
|
+
failure = _apply_reference(befores[task], patch, snapshot)
|
|
457
|
+
if failure is not None:
|
|
458
|
+
references[task] = {"status": "unavailable", "reason": failure}
|
|
459
|
+
continue
|
|
460
|
+
evaluations, stored = [], {}
|
|
461
|
+
for label in ("before", "0", "1"):
|
|
462
|
+
source = befores[task] if label == "before" else snapshot
|
|
463
|
+
evidence = (target / "measurements" / label).resolve()
|
|
464
|
+
paths = collect_measurements(source, evidence, code, before=befores[task])
|
|
465
|
+
refs = {
|
|
466
|
+
kind: path.resolve().relative_to(evidence).as_posix()
|
|
467
|
+
for kind, path in paths.items()
|
|
468
|
+
}
|
|
469
|
+
stored[label] = (evidence.relative_to(root.resolve()).as_posix(), refs)
|
|
470
|
+
evaluations.append(evaluate_evidence(source, refs, evidence))
|
|
471
|
+
references[task] = {
|
|
472
|
+
**_reference_record(evaluations[1:], compare_snapshots(befores[task], snapshot)),
|
|
473
|
+
**reference_locations(
|
|
474
|
+
root,
|
|
475
|
+
before=(befores[task], *stored["before"]),
|
|
476
|
+
after=(snapshot, *stored["0"]),
|
|
477
|
+
),
|
|
478
|
+
"patch_sha256": hashlib.sha256(patch.read_bytes()).hexdigest(),
|
|
479
|
+
}
|
|
480
|
+
return references
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _container_runtimes(collectors: list[Collector]) -> list[dict[str, object]]:
|
|
484
|
+
"""The runtimes of the containers one task's collectors start, one after another."""
|
|
485
|
+
return [runtime for item in collectors if (runtime := container_runtime(item)) is not None]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def propose_remeasurement(
|
|
489
|
+
root: Path,
|
|
490
|
+
*,
|
|
491
|
+
profiles: dict[str, list[Collector]],
|
|
492
|
+
revision: str,
|
|
493
|
+
progress: Callable[[str], None] | None = None,
|
|
494
|
+
jobs: int = 1,
|
|
495
|
+
masks_listed_pairs: bool = False,
|
|
496
|
+
) -> Path:
|
|
497
|
+
"""Write a separate proposal; exceptions retain diagnostics and never promote it.
|
|
498
|
+
|
|
499
|
+
masks_listed_pairs: the run's report hides pairs its check listed as not measured. Protocol 3
|
|
500
|
+
marks that, so an earlier build, which accepts only 2 and would publish those values, refuses.
|
|
501
|
+
"""
|
|
502
|
+
validate_jobs(jobs)
|
|
503
|
+
root = root.resolve()
|
|
504
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", revision):
|
|
505
|
+
raise ValueError("revision must be a single named directory")
|
|
506
|
+
parent = root / "observer-revisions"
|
|
507
|
+
if (
|
|
508
|
+
parent.is_symlink()
|
|
509
|
+
or parent.resolve() != parent
|
|
510
|
+
or (parent.exists() and not parent.is_dir())
|
|
511
|
+
):
|
|
512
|
+
raise ValueError(
|
|
513
|
+
"measurement revision parent must be a real directory inside its native study"
|
|
514
|
+
)
|
|
515
|
+
# A task may have no collector, such as one without a coverage tool when only coverage needs
|
|
516
|
+
# one; its measures stay not configured. At least one task must have something to collect.
|
|
517
|
+
if not any(profiles.values()) or any(
|
|
518
|
+
len({item.kind for item in collectors}) != len(collectors)
|
|
519
|
+
for collectors in profiles.values()
|
|
520
|
+
):
|
|
521
|
+
raise ValueError("collectors must be uniquely named, and at least one task needs one")
|
|
522
|
+
run, manifest, original = _native_state(root)
|
|
523
|
+
if set(profiles) != {record.task_id for record in manifest.records}:
|
|
524
|
+
raise ValueError("replacement profiles must cover every native task exactly")
|
|
525
|
+
protected = _preserved_inventory(root, manifest, original.parent)
|
|
526
|
+
directory = parent / revision
|
|
527
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
528
|
+
state: dict[str, object] = {
|
|
529
|
+
"schema_version": 3 if masks_listed_pairs else 2,
|
|
530
|
+
"status": "collecting",
|
|
531
|
+
"jobs": jobs,
|
|
532
|
+
"revision": revision,
|
|
533
|
+
"source_run": study._reference(root, root / "run.json"),
|
|
534
|
+
"source_assessment": run["assessment_input"],
|
|
535
|
+
"preserved": protected,
|
|
536
|
+
}
|
|
537
|
+
try:
|
|
538
|
+
previous = directory / "previous"
|
|
539
|
+
previous.mkdir()
|
|
540
|
+
shutil.copy2(root / "run.json", previous / "run.json")
|
|
541
|
+
shutil.copy2(original, previous / "assessment-input.json")
|
|
542
|
+
if (root / "report").is_dir():
|
|
543
|
+
shutil.copytree(root / "report", previous / "report")
|
|
544
|
+
frozen = {
|
|
545
|
+
task: freeze_collectors(collectors, directory / "profiles" / str(index))
|
|
546
|
+
for index, (task, collectors) in enumerate(sorted(profiles.items()))
|
|
547
|
+
}
|
|
548
|
+
profile_path = directory / "profiles.json"
|
|
549
|
+
study._write(
|
|
550
|
+
profile_path,
|
|
551
|
+
{
|
|
552
|
+
task: [item.model_dump(mode="json") for item in collectors]
|
|
553
|
+
for task, collectors in frozen.items()
|
|
554
|
+
},
|
|
555
|
+
)
|
|
556
|
+
profile_inventory = study._inventory(directory / "profiles")
|
|
557
|
+
state.update(
|
|
558
|
+
frozen_profiles=study._reference(root, profile_path),
|
|
559
|
+
profile_inventory=profile_inventory,
|
|
560
|
+
)
|
|
561
|
+
study._write(directory / "revision.json", state)
|
|
562
|
+
revised = _rebased(manifest, original.parent, directory)
|
|
563
|
+
observations: dict[tuple[str, str, str], CollectedInput] = {}
|
|
564
|
+
inputs: list[MeasurementInput] = []
|
|
565
|
+
input_indices: dict[tuple[str, str, str], int] = {}
|
|
566
|
+
cases: list[tuple[str, str, str, str, tuple[str, str, str]]] = []
|
|
567
|
+
for record in manifest.records:
|
|
568
|
+
assert record.before is not None
|
|
569
|
+
for stage_id, snapshot_ref in [
|
|
570
|
+
("before", record.before),
|
|
571
|
+
*(
|
|
572
|
+
(stage.id, stage.snapshot)
|
|
573
|
+
for stage in record.stages
|
|
574
|
+
if stage.snapshot is not None
|
|
575
|
+
),
|
|
576
|
+
]:
|
|
577
|
+
assert snapshot_ref is not None
|
|
578
|
+
key = (
|
|
579
|
+
record.task_id,
|
|
580
|
+
snapshot_ref.sha256,
|
|
581
|
+
record.before.sha256,
|
|
582
|
+
)
|
|
583
|
+
if frozen[record.task_id] and key not in input_indices:
|
|
584
|
+
index = len(inputs)
|
|
585
|
+
input_indices[key] = index
|
|
586
|
+
inputs.append(
|
|
587
|
+
MeasurementInput(
|
|
588
|
+
index=index,
|
|
589
|
+
key=key,
|
|
590
|
+
stage=stage_id,
|
|
591
|
+
snapshot=(original.parent / snapshot_ref.path).resolve(),
|
|
592
|
+
snapshot_digest=snapshot_ref.sha256,
|
|
593
|
+
before=(original.parent / record.before.path).resolve(),
|
|
594
|
+
before_digest=record.before.sha256,
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
cases.append((record.id, stage_id, snapshot_ref.sha256, record.before.sha256, key))
|
|
598
|
+
input_states = {str(item.index): "pending" for item in inputs}
|
|
599
|
+
state["input_states"] = input_states
|
|
600
|
+
|
|
601
|
+
def update_input(item: MeasurementInput, status: str, repeat: int | None = None) -> None:
|
|
602
|
+
input_states[str(item.index)] = status
|
|
603
|
+
if repeat is not None:
|
|
604
|
+
state["current_input"] = {
|
|
605
|
+
"task_id": item.key[0],
|
|
606
|
+
"stage": item.stage,
|
|
607
|
+
"snapshot_digest": item.snapshot_digest,
|
|
608
|
+
"repeat": repeat + 1,
|
|
609
|
+
"index": item.index,
|
|
610
|
+
}
|
|
611
|
+
if progress:
|
|
612
|
+
progress(
|
|
613
|
+
f"{item.key[0]}/{item.stage}: collecting input {item.index + 1}, repeat {repeat + 1}/2"
|
|
614
|
+
)
|
|
615
|
+
study._write(directory / "revision.json", state)
|
|
616
|
+
|
|
617
|
+
def accept(item: MeasurementInput, collected: CollectedInput) -> None:
|
|
618
|
+
observations[item.key] = collected
|
|
619
|
+
update_input(item, "complete")
|
|
620
|
+
|
|
621
|
+
def collect_input(
|
|
622
|
+
item: MeasurementInput,
|
|
623
|
+
cancelled: Event,
|
|
624
|
+
emit: Callable[[Callable[[], None]], None],
|
|
625
|
+
) -> None:
|
|
626
|
+
repeats: list[dict[MetricKind, ArtifactRef]] = []
|
|
627
|
+
normalized = []
|
|
628
|
+
try:
|
|
629
|
+
for repeat in range(2):
|
|
630
|
+
if cancelled.is_set():
|
|
631
|
+
emit(partial(update_input, item, "interrupted"))
|
|
632
|
+
return
|
|
633
|
+
evidence = directory / "measurements" / str(item.index) / str(repeat)
|
|
634
|
+
emit(partial(update_input, item, "running", repeat))
|
|
635
|
+
paths = collect_measurements(
|
|
636
|
+
item.snapshot, evidence, frozen[item.key[0]], before=item.before
|
|
637
|
+
)
|
|
638
|
+
result = evaluate_evidence(
|
|
639
|
+
item.snapshot,
|
|
640
|
+
{kind: str(path.resolve()) for kind, path in paths.items()},
|
|
641
|
+
evidence.resolve(),
|
|
642
|
+
)
|
|
643
|
+
normalized.append(_normalized(result))
|
|
644
|
+
study._write(evidence / "normalized.json", normalized[-1])
|
|
645
|
+
refs: dict[MetricKind, ArtifactRef] = {}
|
|
646
|
+
for collector in frozen[item.key[0]]:
|
|
647
|
+
path = paths.get(collector.kind)
|
|
648
|
+
if path is None:
|
|
649
|
+
path = evidence / collector.kind / "failure.json"
|
|
650
|
+
failure = _object(json.loads(path.read_text()))
|
|
651
|
+
failure["repeat_index"] = repeat
|
|
652
|
+
study._write(path, failure)
|
|
653
|
+
_validate_failure(
|
|
654
|
+
path=path,
|
|
655
|
+
collector=collector,
|
|
656
|
+
snapshot_digest=item.snapshot_digest,
|
|
657
|
+
before_digest=item.before_digest,
|
|
658
|
+
repeat=repeat,
|
|
659
|
+
)
|
|
660
|
+
refs[collector.kind] = ArtifactRef.model_validate(
|
|
661
|
+
study._reference(directory, path)
|
|
662
|
+
)
|
|
663
|
+
repeats.append(refs)
|
|
664
|
+
outcomes = _family_outcomes(normalized, repeats)
|
|
665
|
+
emit(partial(accept, item, CollectedInput(repeats=repeats, outcomes=outcomes)))
|
|
666
|
+
except BaseException:
|
|
667
|
+
emit(partial(update_input, item, "failed"))
|
|
668
|
+
raise
|
|
669
|
+
|
|
670
|
+
# Inputs run `jobs` at a time, and each runs its task's containers one after another.
|
|
671
|
+
containers = [
|
|
672
|
+
runtimes for item in inputs if (runtimes := _container_runtimes(frozen[item.key[0]]))
|
|
673
|
+
]
|
|
674
|
+
if containers:
|
|
675
|
+
harbor_backend.assert_capacity(containers, jobs=jobs)
|
|
676
|
+
run_jobs(inputs, jobs=jobs, operation=collect_input)
|
|
677
|
+
bindings = []
|
|
678
|
+
proposed_records = {record.id: record for record in revised.records}
|
|
679
|
+
for attempt_id, stage_id, snapshot_digest, before_digest, key in cases:
|
|
680
|
+
collected = observations[key] if frozen[key[0]] else _NOTHING_COLLECTED
|
|
681
|
+
repeats = collected.repeats
|
|
682
|
+
bindings.append(
|
|
683
|
+
{
|
|
684
|
+
"attempt_id": attempt_id,
|
|
685
|
+
"stage": stage_id,
|
|
686
|
+
"snapshot_digest": snapshot_digest,
|
|
687
|
+
"before_digest": before_digest,
|
|
688
|
+
"outcomes": collected.outcomes,
|
|
689
|
+
"repeats": [
|
|
690
|
+
{
|
|
691
|
+
kind: study._reference(root, directory / reference.path)
|
|
692
|
+
for kind, reference in refs.items()
|
|
693
|
+
}
|
|
694
|
+
for refs in repeats
|
|
695
|
+
],
|
|
696
|
+
}
|
|
697
|
+
)
|
|
698
|
+
proposed = proposed_records[attempt_id]
|
|
699
|
+
supplied = {collector.kind for collector in frozen[key[0]]}
|
|
700
|
+
available = {
|
|
701
|
+
kind: ref
|
|
702
|
+
for kind, ref in repeats[0].items()
|
|
703
|
+
if _object(collected.outcomes[kind])["status"] == "available"
|
|
704
|
+
}
|
|
705
|
+
if stage_id == "before":
|
|
706
|
+
proposed.before_evidence = {
|
|
707
|
+
**{
|
|
708
|
+
kind: ref
|
|
709
|
+
for kind, ref in (proposed.before_evidence or {}).items()
|
|
710
|
+
if kind not in supplied
|
|
711
|
+
},
|
|
712
|
+
**available,
|
|
713
|
+
} or None
|
|
714
|
+
else:
|
|
715
|
+
stage = next(stage for stage in proposed.stages if stage.id == stage_id)
|
|
716
|
+
stage.evidence = {
|
|
717
|
+
**{
|
|
718
|
+
kind: ref
|
|
719
|
+
for kind, ref in (stage.evidence or {}).items()
|
|
720
|
+
if kind not in supplied
|
|
721
|
+
},
|
|
722
|
+
**available,
|
|
723
|
+
} or None
|
|
724
|
+
if (
|
|
725
|
+
_preserved_inventory(root, manifest, original.parent) != protected
|
|
726
|
+
or study._inventory(directory / "profiles") != profile_inventory
|
|
727
|
+
):
|
|
728
|
+
raise ValueError("native evidence or frozen observer profile changed during collection")
|
|
729
|
+
_native_state(root)
|
|
730
|
+
if progress:
|
|
731
|
+
progress("Measuring task reference solutions")
|
|
732
|
+
references_path = directory / "references.json"
|
|
733
|
+
study._write(
|
|
734
|
+
references_path, _collect_references(root, directory, manifest, original.parent, frozen)
|
|
735
|
+
)
|
|
736
|
+
proposed_path = directory / "assessment-input.json"
|
|
737
|
+
study._write(proposed_path, revised.model_dump(mode="json"))
|
|
738
|
+
assessment = build_assessment(proposed_path)
|
|
739
|
+
report_path = directory / "assessment-report.json"
|
|
740
|
+
study._write(report_path, assessment)
|
|
741
|
+
state.pop("current_input", None)
|
|
742
|
+
state.update(
|
|
743
|
+
status="complete",
|
|
744
|
+
purpose="offline observer proposal; native run and reports unchanged",
|
|
745
|
+
original_run=study._reference(root, previous / "run.json"),
|
|
746
|
+
original_assessment=study._reference(root, original),
|
|
747
|
+
saved_assessment=study._reference(root, previous / "assessment-input.json"),
|
|
748
|
+
original_reports=study._inventory(previous / "report")
|
|
749
|
+
if (previous / "report").is_dir()
|
|
750
|
+
else {},
|
|
751
|
+
preserved=protected,
|
|
752
|
+
frozen_profiles=study._reference(root, profile_path),
|
|
753
|
+
profile_inventory=profile_inventory,
|
|
754
|
+
measurement_inventory=study._inventory(directory / "measurements"),
|
|
755
|
+
references=study._reference(root, references_path),
|
|
756
|
+
reference_inventory=study._inventory(directory / "references")
|
|
757
|
+
if (directory / "references").is_dir()
|
|
758
|
+
else {},
|
|
759
|
+
observations=bindings,
|
|
760
|
+
proposed_assessment=study._reference(root, proposed_path),
|
|
761
|
+
proposed_report=study._reference(root, report_path),
|
|
762
|
+
normalization="evaluated metrics/counts/sets/populations and configuration/observer identity; raw temporary paths and execution timings excluded",
|
|
763
|
+
repeats=2,
|
|
764
|
+
unique_inputs=len(observations),
|
|
765
|
+
)
|
|
766
|
+
study._write(directory / "revision.json", state)
|
|
767
|
+
if progress:
|
|
768
|
+
progress(
|
|
769
|
+
f"Observer proposal complete: {len(observations)} unique inputs, two repeats per family"
|
|
770
|
+
)
|
|
771
|
+
return directory / "revision.json"
|
|
772
|
+
except BaseException as exc:
|
|
773
|
+
state.update(status="failed", error=f"{type(exc).__name__}: {exc}")
|
|
774
|
+
study._write(directory / "revision.json", state)
|
|
775
|
+
raise
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def validate_remeasurement(root: Path, directory: Path) -> Path:
|
|
779
|
+
"""Read-only validation of a finished proposal; never rerun collectors or models."""
|
|
780
|
+
if directory.is_symlink():
|
|
781
|
+
raise ValueError("measurement revision cannot be a symlink")
|
|
782
|
+
root, directory = root.resolve(), directory.resolve()
|
|
783
|
+
if directory.parent != root / "observer-revisions":
|
|
784
|
+
raise ValueError("measurement revision must remain inside its native study")
|
|
785
|
+
protocol = _object(json.loads((directory / "revision.json").read_text()))
|
|
786
|
+
if (
|
|
787
|
+
protocol.get("status") != "complete"
|
|
788
|
+
or protocol.get("repeats") != 2
|
|
789
|
+
or protocol.get("revision") != directory.name
|
|
790
|
+
):
|
|
791
|
+
raise ValueError("measurement revision is incomplete")
|
|
792
|
+
validate_jobs(protocol.get("jobs"))
|
|
793
|
+
if protocol.get("schema_version") not in {2, 3}:
|
|
794
|
+
raise ValueError("unsupported measurement revision protocol")
|
|
795
|
+
run, original, original_path = _native_state(root)
|
|
796
|
+
if _preserved_inventory(root, original, original_path.parent) != protocol.get("preserved"):
|
|
797
|
+
raise ValueError("native evidence changed after observer collection")
|
|
798
|
+
if study._bound_document(root, protocol["original_run"]) != run:
|
|
799
|
+
raise ValueError("saved native run differs from the current study")
|
|
800
|
+
if study._bound_path(root, protocol["original_assessment"]) != original_path:
|
|
801
|
+
raise ValueError("original assessment differs from the observer proposal")
|
|
802
|
+
saved = study._bound_path(root, protocol["saved_assessment"])
|
|
803
|
+
if saved.read_bytes() != original_path.read_bytes():
|
|
804
|
+
raise ValueError("saved original assessment differs")
|
|
805
|
+
saved_reports = (
|
|
806
|
+
study._inventory(directory / "previous/report")
|
|
807
|
+
if (directory / "previous/report").is_dir()
|
|
808
|
+
else {}
|
|
809
|
+
)
|
|
810
|
+
if saved_reports != protocol.get("original_reports"):
|
|
811
|
+
raise ValueError("saved original reports changed")
|
|
812
|
+
if study._inventory(directory / "profiles") != protocol.get("profile_inventory"):
|
|
813
|
+
raise ValueError("frozen measurement profile changed")
|
|
814
|
+
if study._inventory(directory / "measurements") != protocol.get("measurement_inventory"):
|
|
815
|
+
raise ValueError("measurement artifacts changed")
|
|
816
|
+
if "references" in protocol:
|
|
817
|
+
study._bound_path(root, protocol["references"])
|
|
818
|
+
references = (
|
|
819
|
+
study._inventory(directory / "references")
|
|
820
|
+
if (directory / "references").is_dir()
|
|
821
|
+
else {}
|
|
822
|
+
)
|
|
823
|
+
if references != protocol.get("reference_inventory"):
|
|
824
|
+
raise ValueError("reference measurement artifacts changed")
|
|
825
|
+
profiles = load_profiles(study._bound_path(root, protocol["frozen_profiles"]))
|
|
826
|
+
if set(profiles) != {record.task_id for record in original.records}:
|
|
827
|
+
raise ValueError("observer profiles differ from native task assignment")
|
|
828
|
+
proposed_path = study._bound_path(root, protocol["proposed_assessment"])
|
|
829
|
+
study._bound_path(root, protocol["proposed_report"])
|
|
830
|
+
proposed = AssessmentInput.model_validate_json(proposed_path.read_text())
|
|
831
|
+
expected = _rebased(original, original_path.parent, proposed_path.parent)
|
|
832
|
+
if len(proposed.records) != len(expected.records):
|
|
833
|
+
raise ValueError("observer proposal changed native records")
|
|
834
|
+
without_metrics = proposed.model_copy(deep=True)
|
|
835
|
+
for record, old in zip(without_metrics.records, expected.records, strict=True):
|
|
836
|
+
supplied = {collector.kind for collector in profiles[old.task_id]}
|
|
837
|
+
if {
|
|
838
|
+
kind: ref
|
|
839
|
+
for kind, ref in (record.before_evidence or {}).items()
|
|
840
|
+
if kind not in supplied
|
|
841
|
+
} != {
|
|
842
|
+
kind: ref for kind, ref in (old.before_evidence or {}).items() if kind not in supplied
|
|
843
|
+
}:
|
|
844
|
+
raise ValueError("observer proposal changed unsupplied measurements")
|
|
845
|
+
record.before_evidence = old.before_evidence
|
|
846
|
+
if len(record.stages) != len(old.stages):
|
|
847
|
+
raise ValueError("observer proposal changed native stage history")
|
|
848
|
+
for stage, old_stage in zip(record.stages, old.stages, strict=True):
|
|
849
|
+
if stage.snapshot is None and stage.evidence != old_stage.evidence:
|
|
850
|
+
raise ValueError("unavailable submission cannot receive measurements")
|
|
851
|
+
if {
|
|
852
|
+
kind: ref for kind, ref in (stage.evidence or {}).items() if kind not in supplied
|
|
853
|
+
} != {
|
|
854
|
+
kind: ref
|
|
855
|
+
for kind, ref in (old_stage.evidence or {}).items()
|
|
856
|
+
if kind not in supplied
|
|
857
|
+
}:
|
|
858
|
+
raise ValueError("observer proposal changed unsupplied measurements")
|
|
859
|
+
stage.evidence = old_stage.evidence
|
|
860
|
+
if without_metrics.model_dump() != expected.model_dump():
|
|
861
|
+
raise ValueError("observer proposal changed native source/correctness/work fields")
|
|
862
|
+
observations = protocol.get("observations")
|
|
863
|
+
if not isinstance(observations, list):
|
|
864
|
+
raise ValueError("measurement observations missing")
|
|
865
|
+
indexed = {}
|
|
866
|
+
for value in observations:
|
|
867
|
+
observation = _object(value)
|
|
868
|
+
key = (observation.get("attempt_id"), observation.get("stage"))
|
|
869
|
+
if key in indexed:
|
|
870
|
+
raise ValueError("duplicate measurement observation")
|
|
871
|
+
indexed[key] = observation
|
|
872
|
+
expected_keys = {
|
|
873
|
+
(record.id, stage)
|
|
874
|
+
for record in original.records
|
|
875
|
+
for stage in ["before", *(item.id for item in record.stages if item.snapshot is not None)]
|
|
876
|
+
}
|
|
877
|
+
if set(indexed) != expected_keys:
|
|
878
|
+
raise ValueError("observer proposal omits a native stage")
|
|
879
|
+
input_contexts: dict[str, tuple[str, str, str]] = {}
|
|
880
|
+
context_inputs: dict[tuple[str, str, str], str] = {}
|
|
881
|
+
for record in proposed.records:
|
|
882
|
+
assert record.before is not None
|
|
883
|
+
for stage_id, snapshot_ref, evidence in [
|
|
884
|
+
("before", record.before, record.before_evidence),
|
|
885
|
+
*((stage.id, stage.snapshot, stage.evidence) for stage in record.stages),
|
|
886
|
+
]:
|
|
887
|
+
if snapshot_ref is None:
|
|
888
|
+
continue
|
|
889
|
+
observation = indexed[(record.id, stage_id)]
|
|
890
|
+
if (
|
|
891
|
+
observation.get("snapshot_digest") != snapshot_ref.sha256
|
|
892
|
+
or observation.get("before_digest") != record.before.sha256
|
|
893
|
+
):
|
|
894
|
+
raise ValueError("measurement observation binds another source")
|
|
895
|
+
repeats = observation.get("repeats")
|
|
896
|
+
if not isinstance(repeats, list) or len(repeats) != 2:
|
|
897
|
+
raise ValueError("measurement repeat missing")
|
|
898
|
+
context = (
|
|
899
|
+
record.task_id,
|
|
900
|
+
snapshot_ref.sha256,
|
|
901
|
+
record.before.sha256,
|
|
902
|
+
)
|
|
903
|
+
input_index: str | None = None
|
|
904
|
+
normalized = []
|
|
905
|
+
bound_repeats: list[dict[MetricKind, ArtifactRef]] = []
|
|
906
|
+
for index, values in enumerate(repeats):
|
|
907
|
+
refs = _object(values)
|
|
908
|
+
if set(refs) != {collector.kind for collector in profiles[record.task_id]}:
|
|
909
|
+
raise ValueError("partial measurement repeat")
|
|
910
|
+
paths = {
|
|
911
|
+
kind: study._bound_path(root, reference) for kind, reference in refs.items()
|
|
912
|
+
}
|
|
913
|
+
valid_paths: dict[str, str] = {}
|
|
914
|
+
bound_refs: dict[MetricKind, ArtifactRef] = {}
|
|
915
|
+
for collector in profiles[record.task_id]:
|
|
916
|
+
path = paths[collector.kind]
|
|
917
|
+
if not path.is_relative_to(directory):
|
|
918
|
+
raise ValueError("repeat artifact escapes its measurement revision")
|
|
919
|
+
parts = path.relative_to(directory).parts
|
|
920
|
+
if (
|
|
921
|
+
len(parts) != 5
|
|
922
|
+
or parts[0] != "measurements"
|
|
923
|
+
or not parts[1].isdigit()
|
|
924
|
+
or parts[2:4] != (str(index), collector.kind)
|
|
925
|
+
or parts[4] not in {"envelope.json", "failure.json"}
|
|
926
|
+
):
|
|
927
|
+
raise ValueError("repeat artifact does not bind a distinct recorded repeat")
|
|
928
|
+
if input_index is None:
|
|
929
|
+
input_index = parts[1]
|
|
930
|
+
elif input_index != parts[1]:
|
|
931
|
+
raise ValueError("supplied families bind different input contexts")
|
|
932
|
+
if (parts[1] in input_contexts and input_contexts[parts[1]] != context) or (
|
|
933
|
+
context in context_inputs and context_inputs[context] != parts[1]
|
|
934
|
+
):
|
|
935
|
+
raise ValueError(
|
|
936
|
+
"measurement input identity differs from deduplicated context"
|
|
937
|
+
)
|
|
938
|
+
input_contexts[parts[1]] = context
|
|
939
|
+
context_inputs[context] = parts[1]
|
|
940
|
+
bound_refs[collector.kind] = ArtifactRef.model_validate(
|
|
941
|
+
study._reference(directory, path)
|
|
942
|
+
)
|
|
943
|
+
if path.name == "failure.json":
|
|
944
|
+
_validate_failure(
|
|
945
|
+
path=path,
|
|
946
|
+
collector=collector,
|
|
947
|
+
snapshot_digest=snapshot_ref.sha256,
|
|
948
|
+
before_digest=record.before.sha256,
|
|
949
|
+
repeat=index,
|
|
950
|
+
)
|
|
951
|
+
continue
|
|
952
|
+
envelope = _object(json.loads(path.read_text()))
|
|
953
|
+
if (
|
|
954
|
+
envelope.get("config_digest") != study._digest(collector.config)
|
|
955
|
+
or envelope.get("scope") != collector.scope
|
|
956
|
+
or (
|
|
957
|
+
collector.tool_version is not None
|
|
958
|
+
and envelope.get("tool_version") != collector.tool_version
|
|
959
|
+
)
|
|
960
|
+
):
|
|
961
|
+
raise ValueError("measurement differs from its frozen collector profile")
|
|
962
|
+
valid_paths[collector.kind] = str(path)
|
|
963
|
+
bound_repeats.append(bound_refs)
|
|
964
|
+
normalized.append(
|
|
965
|
+
_normalized(
|
|
966
|
+
evaluate_evidence(
|
|
967
|
+
(proposed_path.parent / snapshot_ref.path).resolve(),
|
|
968
|
+
valid_paths,
|
|
969
|
+
directory,
|
|
970
|
+
)
|
|
971
|
+
)
|
|
972
|
+
)
|
|
973
|
+
outcomes = _family_outcomes(normalized, bound_repeats)
|
|
974
|
+
if observation.get("outcomes") != outcomes:
|
|
975
|
+
raise ValueError("recorded family outcome differs from bound repeats")
|
|
976
|
+
for collector in profiles[record.task_id]:
|
|
977
|
+
available = _object(outcomes[collector.kind])["status"] == "available"
|
|
978
|
+
reference = (evidence or {}).get(collector.kind)
|
|
979
|
+
if available:
|
|
980
|
+
first = study._bound_path(root, _object(repeats[0])[collector.kind])
|
|
981
|
+
if (
|
|
982
|
+
reference is None
|
|
983
|
+
or (proposed_path.parent / reference.path).resolve() != first
|
|
984
|
+
or reference.sha256 != study._digest(first)
|
|
985
|
+
):
|
|
986
|
+
raise ValueError("proposed evidence is not the first bound measurement")
|
|
987
|
+
elif reference is not None:
|
|
988
|
+
raise ValueError("unavailable family retained successful or stale evidence")
|
|
989
|
+
if protocol.get("unique_inputs") != len(input_contexts):
|
|
990
|
+
raise ValueError("measurement unique input count differs")
|
|
991
|
+
build_assessment(proposed_path)
|
|
992
|
+
return proposed_path
|