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,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