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
pluginbench/compare.py ADDED
@@ -0,0 +1,927 @@
1
+ """Compare packages across saved evaluations and check declared gates.
2
+
3
+ Reads each evaluation's report/results.json and nothing else: no rebuild, no model calls.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import math
11
+ import random
12
+ import statistics
13
+ from dataclasses import dataclass, field, replace
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ import yaml
18
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
19
+
20
+ from pluginbench.evaluation_report import correctness_state
21
+
22
+ # Older runs call the package without guidance "baseline".
23
+ ARM_NAMES = {"baseline": "vanilla"}
24
+ PASS_STATES = {"pass"}
25
+ # Any other correctness, such as unknown, could be a pass or a fail.
26
+ GRADED_STATES = {"pass", "fail", "overlap_pass"}
27
+ TOKEN_METRICS = ["Input tokens", "Output tokens"]
28
+ BOOTSTRAP_DRAWS = 10_000
29
+ BOOTSTRAP_SEED = 0
30
+ MIN_PAIRED_TASKS = 6
31
+ # Saved comparison-identity fields two runs must share before a gate compares their packages on a
32
+ # task. `{task}` is that task; `*` is each key both runs saved. The guidance packages (the
33
+ # treatment under test), jobs, seed, container limits, and the Docker runtime may differ.
34
+ IDENTITY_FIELDS = (
35
+ "runner.harness",
36
+ "runner.model",
37
+ "runner.reasoning",
38
+ "harness_version",
39
+ "capabilities",
40
+ "tasks.{task}",
41
+ "task_limits.{task}.effective.agent_timeout_seconds",
42
+ "measurement_settings.source_scope",
43
+ "measurement_settings.test_scope",
44
+ "measurement_settings.coverage",
45
+ "measurement_settings.coverage_profiles.*",
46
+ "fallow_config_sha256",
47
+ "effective_observer_scripts.{task}.*",
48
+ "measurement_capabilities.{task}.*.tool_version",
49
+ "reviewer.harness",
50
+ "reviewer.model",
51
+ "reviewer.reasoning",
52
+ "review_criteria_sha256",
53
+ )
54
+
55
+
56
+ class Gate(BaseModel):
57
+ """One gate: a candidate's value against another package's, within a limit."""
58
+
59
+ model_config = ConfigDict(extra="forbid")
60
+
61
+ name: str = Field(min_length=1, description="Names the gate uniquely within the gates file.")
62
+ against: str = Field(
63
+ min_length=1, description="Names the comparator arm as it appears in the comparison report."
64
+ )
65
+ direction: Literal["higher", "lower"] = Field(
66
+ description="Selects whether values must meet a lower ceiling or a higher floor."
67
+ )
68
+ correctness: Literal["passes", "unknown"] | None = Field(
69
+ default=None, description="Selects pass or unknown counts instead of metric values."
70
+ )
71
+ per_task: bool = Field(
72
+ default=False,
73
+ description="Checks correctness separately for each task instead of the aggregate.",
74
+ )
75
+ metrics: list[str] | None = Field(
76
+ default=None,
77
+ min_length=1,
78
+ description="Names report metrics to sum for a metric gate instead of correctness.",
79
+ )
80
+ statistic: Literal["total", "mean_of_task_medians", "mean_of_task_median_ratios"] | None = (
81
+ Field(
82
+ default=None,
83
+ description="Selects the point statistic for metric gates without a confidence bound.",
84
+ )
85
+ )
86
+ reference_floor: float | None = Field(
87
+ default=None,
88
+ description="Excludes tasks whose reference is below this floor when computing reference ratios.",
89
+ )
90
+ tolerance: float | None = Field(
91
+ default=None,
92
+ ge=0,
93
+ description="Sets nonnegative additive slack instead of a factor for a point gate.",
94
+ )
95
+ factor: float | None = Field(
96
+ default=None,
97
+ gt=0,
98
+ description="Sets multiplicative slack for a point gate or the ceiling for a token-ratio bound.",
99
+ )
100
+ bound: Literal["lower", "upper"] | None = Field(
101
+ default=None,
102
+ description="Selects a paired confidence endpoint for pass-rate differences or input-plus-output token ratios.",
103
+ )
104
+ margin: float | None = Field(
105
+ default=None,
106
+ ge=-1,
107
+ le=1,
108
+ description="Sets the required lower pass-rate difference from minus one to one for a correctness bound.",
109
+ )
110
+
111
+ @model_validator(mode="after")
112
+ def one_measure(self) -> Gate:
113
+ if (self.correctness is None) == (self.metrics is None):
114
+ raise ValueError(f"gate {self.name!r} needs exactly one of correctness or metrics")
115
+ if self.bound is not None:
116
+ if self.per_task or self.tolerance is not None or self.reference_floor is not None:
117
+ raise ValueError("bound gates cannot use per_task, tolerance, or reference_floor")
118
+ if self.statistic is not None:
119
+ raise ValueError("bound gates average repeats within task; omit statistic")
120
+ if self.correctness == "passes":
121
+ if (
122
+ self.bound != "lower"
123
+ or self.direction != "higher"
124
+ or self.margin is None
125
+ or self.factor is not None
126
+ ):
127
+ raise ValueError(
128
+ "pass-rate bounds need bound: lower, direction: higher, margin"
129
+ )
130
+ elif self.metrics is not None and sorted(self.metrics) == sorted(TOKEN_METRICS):
131
+ if (
132
+ self.bound != "upper"
133
+ or self.direction != "lower"
134
+ or self.factor is None
135
+ or self.margin is not None
136
+ ):
137
+ raise ValueError("token bounds need bound: upper, direction: lower, factor")
138
+ else:
139
+ raise ValueError("bound gates support correctness: passes or input/output tokens")
140
+ elif self.margin is not None:
141
+ raise ValueError("margin needs a bound gate")
142
+ elif (self.metrics is None) != (self.statistic is None):
143
+ raise ValueError(f"gate {self.name!r}: metrics and statistic go together")
144
+ if self.per_task and self.correctness is None:
145
+ raise ValueError(f"gate {self.name!r}: per_task applies to correctness gates only")
146
+ if self.reference_floor is not None and self.statistic != "mean_of_task_median_ratios":
147
+ raise ValueError(f"gate {self.name!r}: reference_floor needs a ratio statistic")
148
+ if self.tolerance is not None and self.factor is not None:
149
+ raise ValueError(f"gate {self.name!r}: give a tolerance or a factor, not both")
150
+ return self
151
+
152
+
153
+ class Gates(BaseModel):
154
+ model_config = ConfigDict(extra="forbid")
155
+
156
+ # Required: whether an overlap pass counts as a pass is the study's choice, never a default.
157
+ count_overlap_passes: bool = Field(
158
+ description="Declares whether native overlap passes count as passes."
159
+ )
160
+ splits: dict[str, list[str]] = Field(
161
+ default_factory=dict,
162
+ description="Maps split names to task IDs, with all tasks also compared automatically.",
163
+ )
164
+ gates: list[Gate] = Field(
165
+ description="Lists uniquely named rules for comparing each candidate with its comparator."
166
+ )
167
+
168
+ @model_validator(mode="after")
169
+ def unique_names(self) -> Gates:
170
+ names = [gate.name for gate in self.gates]
171
+ if len(names) != len(set(names)):
172
+ raise ValueError("gate names must be unique")
173
+ return self
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class Attempt:
178
+ id: str
179
+ run: str
180
+ task: str
181
+ arm: str
182
+ correctness: str
183
+ values: dict[str, float | None]
184
+ # Why a metric has no value, from the report's observation.
185
+ reasons: dict[str, str] = field(default_factory=dict)
186
+
187
+
188
+ @dataclass
189
+ class Evidence:
190
+ attempts: list[Attempt] = field(default_factory=list)
191
+ references: dict[tuple[str, str], float | None] = field(default_factory=dict)
192
+ # Each run's saved comparison identity; None for a run saved before reports carried one.
193
+ identities: dict[str, object] = field(default_factory=dict)
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class Measure:
198
+ """One package's value for a metric gate over a split's tasks."""
199
+
200
+ value: float | None
201
+ tasks: dict[str, float]
202
+ missing: dict[str, str]
203
+ # Tasks measured on only some attempts, with why the others have no value.
204
+ partial: dict[str, str]
205
+ measured: int
206
+ assigned: int
207
+ # Measured repeats per task, so a comparison can show where the two sides differ.
208
+ repeats: dict[str, int]
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class PairedMeasure:
213
+ """Equal task weights; each task contributes one difference or ratio."""
214
+
215
+ tasks: dict[str, float]
216
+ missing: dict[str, str]
217
+ value: float | None
218
+ interval: tuple[float, float] | None
219
+
220
+
221
+ @dataclass(frozen=True)
222
+ class PairedComparison:
223
+ passes: PairedMeasure
224
+ tokens: PairedMeasure
225
+
226
+
227
+ def _paired_summary(
228
+ tasks: dict[str, float], missing: dict[str, str], *, ratio: bool = False
229
+ ) -> PairedMeasure:
230
+ # Sort before sampling so input order cannot change the deterministic interval.
231
+ values = [math.log(tasks[task]) if ratio else tasks[task] for task in sorted(tasks)]
232
+ value = statistics.mean(values) if values else None
233
+ interval = None
234
+ if len(values) >= MIN_PAIRED_TASKS:
235
+ rng = random.Random(BOOTSTRAP_SEED)
236
+ draws = [
237
+ statistics.mean(rng.choices(values, k=len(values))) for _ in range(BOOTSTRAP_DRAWS)
238
+ ]
239
+ quantiles = statistics.quantiles(draws, n=20, method="inclusive")
240
+ interval = (quantiles[0], quantiles[-1])
241
+ if ratio:
242
+ value = math.exp(value) if value is not None else None
243
+ interval = (math.exp(interval[0]), math.exp(interval[1])) if interval is not None else None
244
+ return PairedMeasure(tasks, missing, value, interval)
245
+
246
+
247
+ def sign_test(differences: list[float]) -> float:
248
+ """Exact two-sided binomial sign test; ties do not count, all ties give p=1."""
249
+ positive = sum(value > 0 for value in differences)
250
+ negative = sum(value < 0 for value in differences)
251
+ n = positive + negative
252
+ return min(1.0, 2 * sum(math.comb(n, k) for k in range(min(positive, negative) + 1)) / (1 << n))
253
+
254
+
255
+ def paired_comparison(
256
+ evidence: Evidence, gates: Gates, candidate: str, against: str, tasks: list[str]
257
+ ) -> PairedComparison:
258
+ """Match tasks, average each arm's repeats, then resample whole task pairs."""
259
+ differences: dict[str, float] = {}
260
+ ratios: dict[str, float] = {}
261
+ missing_passes: dict[str, str] = {}
262
+ missing_tokens: dict[str, str] = {}
263
+ states = PASS_STATES | ({"overlap_pass"} if gates.count_overlap_passes else set())
264
+ for task in sorted(set(tasks)):
265
+ sides = [
266
+ [row for row in evidence.attempts if row.arm == arm and row.task == task]
267
+ for arm in (candidate, against)
268
+ ]
269
+ if not all(sides):
270
+ why = "no attempts: " + ", ".join(
271
+ arm for arm, rows in zip((candidate, against), sides, strict=True) if not rows
272
+ )
273
+ missing_passes[task] = missing_tokens[task] = why
274
+ continue
275
+ if any(row.correctness not in GRADED_STATES for rows in sides for row in rows):
276
+ missing_passes[task] = "unknown correctness on at least one attempt"
277
+ else:
278
+ rates = [sum(row.correctness in states for row in rows) / len(rows) for rows in sides]
279
+ differences[task] = rates[0] - rates[1]
280
+ totals = [[_sum(row.values, TOKEN_METRICS) for row in rows] for rows in sides]
281
+ if any(
282
+ value is None or not math.isfinite(value) or value < 0
283
+ for side in totals
284
+ for value in side
285
+ ):
286
+ missing_tokens[task] = (
287
+ "missing, nonfinite, or negative token total on at least one attempt"
288
+ )
289
+ continue
290
+ means = [statistics.mean(value for value in side if value is not None) for side in totals]
291
+ if min(means) <= 0:
292
+ missing_tokens[task] = "nonpositive mean tokens"
293
+ continue
294
+ ratios[task] = means[0] / means[1]
295
+ return PairedComparison(
296
+ _paired_summary(differences, missing_passes),
297
+ _paired_summary(ratios, missing_tokens, ratio=True),
298
+ )
299
+
300
+
301
+ def load_gates(path: Path) -> Gates:
302
+ return Gates.model_validate(yaml.safe_load(path.read_text()))
303
+
304
+
305
+ def load_evidence(specs: list[str]) -> Evidence:
306
+ """Read runs given as DIR or DIR=ARM,ARM; one run may supply each package and task."""
307
+ evidence = Evidence()
308
+ owners: dict[tuple[str, str], int] = {}
309
+ for index, spec in enumerate(specs):
310
+ directory, _, selection = spec.partition("=")
311
+ wanted = {ARM_NAMES.get(arm, arm) for arm in selection.split(",") if arm}
312
+ results = Path(directory) / "report" / "results.json"
313
+ if not results.is_file():
314
+ raise ValueError(f"{directory} has no report/results.json; run `pluginbench report`")
315
+ data = json.loads(results.read_text())
316
+ evidence.identities[directory] = data.get("comparison_identity")
317
+ values: dict[str, dict[str, float | None]] = {}
318
+ reasons: dict[str, dict[str, str]] = {}
319
+ for row in data["observations"]:
320
+ measured = row.get("status") == "measured" and isinstance(row.get("value"), int | float)
321
+ values.setdefault(row["attempt_id"], {})[row["metric"]] = (
322
+ float(row["value"]) if measured else None
323
+ )
324
+ if not measured:
325
+ reasons.setdefault(row["attempt_id"], {})[row["metric"]] = str(
326
+ row.get("reason") or row.get("status") or "no value"
327
+ )
328
+ found: set[str] = set()
329
+ for row in data["attempts"]:
330
+ arm = ARM_NAMES.get(row["guidance"], row["guidance"])
331
+ found.add(arm)
332
+ if wanted and arm not in wanted:
333
+ continue
334
+ # Repeats come from one input; a second input with the same package would double them.
335
+ owner = owners.setdefault((arm, row["task"]), index)
336
+ if owner != index:
337
+ raise ValueError(
338
+ f"{arm} on {row['task']} comes from both {specs[owner]} and {spec}; "
339
+ "select packages with DIR=ARM,ARM"
340
+ )
341
+ evidence.attempts.append(
342
+ Attempt(
343
+ id=row["id"],
344
+ run=directory,
345
+ task=row["task"],
346
+ arm=arm,
347
+ correctness=correctness_state(row["correctness"], row.get("native_grading")),
348
+ values=values.get(row["id"], {}),
349
+ reasons=reasons.get(row["id"], {}),
350
+ )
351
+ )
352
+ if wanted - found:
353
+ raise ValueError(f"{directory} has no package {', '.join(sorted(wanted - found))}")
354
+ for row in data.get("reference_values", []):
355
+ key = (row["task"], row["metric"])
356
+ value = float(row["value"]) if row.get("status") == "measured" else None
357
+ if evidence.references.setdefault(key, value) != value:
358
+ raise ValueError(f"runs disagree on the reference {row['metric']} of {row['task']}")
359
+ return evidence
360
+
361
+
362
+ def _identity_values(identity: object, path: str, task: str) -> dict[str, object]:
363
+ """The values saved at a dotted IDENTITY_FIELDS path, by their resolved path."""
364
+ found: dict[str, object] = {"": identity}
365
+ for part in path.split("."):
366
+ found = {
367
+ f"{label}.{key}".lstrip("."): value[key]
368
+ for label, value in found.items()
369
+ if isinstance(value, dict)
370
+ for key in (value if part == "*" else [task if part == "{task}" else part])
371
+ if key in value
372
+ }
373
+ return found
374
+
375
+
376
+ def _shown(value: object) -> str:
377
+ text = json.dumps(value, sort_keys=True)
378
+ # Long enough for a quoted SHA-256 digest; longer values, such as commands, show as a digest.
379
+ return text if len(text) <= 80 else "sha256:" + hashlib.sha256(text.encode()).hexdigest()[:12]
380
+
381
+
382
+ def _check_comparable(evidence: Evidence, gates: Gates) -> list[str]:
383
+ """Refuse runs whose packages a gate compares on a task when their saved identities differ.
384
+
385
+ Returns notes on what could not be checked: a run saved before reports carried an identity,
386
+ or a field only one of the two runs saved.
387
+ """
388
+ runs = {(row.arm, row.task): row.run for row in evidence.attempts}
389
+ pairs: dict[tuple[str, str], set[str]] = {}
390
+ for against in {gate.against for gate in gates.gates}:
391
+ for (_, task), run in runs.items():
392
+ other = runs.get((against, task))
393
+ if other is not None and other != run:
394
+ first, second = sorted((run, other))
395
+ pairs.setdefault((first, second), set()).add(task)
396
+ notes: list[str] = []
397
+ errors: list[str] = []
398
+ for (first, second), tasks in sorted(pairs.items()):
399
+ identities = (evidence.identities.get(first), evidence.identities.get(second))
400
+ old = [
401
+ run for run, identity in zip((first, second), identities, strict=True) if not identity
402
+ ]
403
+ if old:
404
+ notes.append(f"{' and '.join(old)} saved no comparison identity; not checked")
405
+ continue
406
+ differ: dict[str, str] = {}
407
+ unchecked: set[str] = set()
408
+ for task in sorted(tasks):
409
+ for path in IDENTITY_FIELDS:
410
+ mine, theirs = (_identity_values(identity, path, task) for identity in identities)
411
+ unchecked |= mine.keys() ^ theirs.keys()
412
+ differ |= {
413
+ label: f"{_shown(mine[label])} vs {_shown(theirs[label])}"
414
+ for label in sorted(mine.keys() & theirs.keys())
415
+ if mine[label] != theirs[label]
416
+ }
417
+ if differ:
418
+ errors += [
419
+ f"{first} and {second} differ, so their packages cannot be compared on "
420
+ + ", ".join(sorted(tasks))
421
+ + ":",
422
+ *(f" {label}: {values}" for label, values in differ.items()),
423
+ ]
424
+ if unchecked:
425
+ notes.append(
426
+ f"{first} and {second}: saved by only one, not checked: "
427
+ + ", ".join(sorted(unchecked))
428
+ )
429
+ if errors:
430
+ raise ValueError("\n".join(errors))
431
+ return notes
432
+
433
+
434
+ def _sum(values: dict[str, float | None], metrics: list[str]) -> float | None:
435
+ parts = [values.get(metric) for metric in metrics]
436
+ return None if any(part is None for part in parts) else sum(part or 0.0 for part in parts)
437
+
438
+
439
+ def _reference_total(evidence: Evidence, task: str, metrics: list[str]) -> float | None:
440
+ parts = [evidence.references.get((task, metric)) for metric in metrics]
441
+ return None if any(part is None for part in parts) else sum(part or 0.0 for part in parts)
442
+
443
+
444
+ def _reference(evidence: Evidence, task: str, gate: Gate) -> tuple[float | None, str | None]:
445
+ assert gate.metrics is not None
446
+ reference = _reference_total(evidence, task, gate.metrics)
447
+ if reference is None:
448
+ return None, "no measured reference"
449
+ if gate.reference_floor is not None and reference < gate.reference_floor:
450
+ return (
451
+ None,
452
+ f"reference below floor ({_number(reference)} < {_number(gate.reference_floor)})",
453
+ )
454
+ # A negative reference would invert the ratio: adding complexity would score below removing it.
455
+ if reference <= 0:
456
+ return None, f"reference is not positive ({_number(reference)})"
457
+ return reference, None
458
+
459
+
460
+ def measure(evidence: Evidence, gate: Gate, arm: str, tasks: list[str]) -> Measure:
461
+ """Per-task statistics first, then one value over the tasks that have one."""
462
+ assert gate.metrics is not None
463
+ per_task: dict[str, float] = {}
464
+ missing: dict[str, str] = {}
465
+ partial: dict[str, str] = {}
466
+ repeats: dict[str, int] = {}
467
+ for task in tasks:
468
+ attempts = [row for row in evidence.attempts if row.arm == arm and row.task == task]
469
+ values = [_sum(row.values, gate.metrics) for row in attempts]
470
+ present = [value for value in values if value is not None]
471
+ repeats[task] = len(present)
472
+ why = "; ".join(
473
+ sorted(
474
+ {
475
+ _missing_reason(row, gate.metrics)
476
+ for row, value in zip(attempts, values, strict=True)
477
+ if value is None
478
+ }
479
+ )
480
+ )
481
+ if present and len(present) < len(attempts):
482
+ partial[task] = (
483
+ f"{len(attempts) - len(present)} of {len(attempts)} attempts unmeasured: {why}"
484
+ )
485
+ if not present:
486
+ missing[task] = f"no measured value: {why}" if attempts else "no attempts"
487
+ continue
488
+ if gate.statistic == "total":
489
+ per_task[task] = sum(present)
490
+ elif gate.statistic == "mean_of_task_medians":
491
+ per_task[task] = statistics.median(present)
492
+ else:
493
+ reference, reason = _reference(evidence, task, gate)
494
+ if reference is None:
495
+ missing[task] = str(reason)
496
+ continue
497
+ per_task[task] = statistics.median(value / reference for value in present)
498
+ return Measure(
499
+ value=_combine(gate, list(per_task.values())),
500
+ tasks=per_task,
501
+ missing=missing,
502
+ partial=partial,
503
+ measured=sum(repeats.values()),
504
+ assigned=sum(row.arm == arm and row.task in repeats for row in evidence.attempts),
505
+ repeats=repeats,
506
+ )
507
+
508
+
509
+ def _missing_reason(row: Attempt, metrics: list[str]) -> str:
510
+ metric = next(metric for metric in metrics if row.values.get(metric) is None)
511
+ return f"{metric}: {row.reasons.get(metric, 'no observation')}"
512
+
513
+
514
+ def _combine(gate: Gate, values: list[float]) -> float | None:
515
+ if not values:
516
+ return None
517
+ return sum(values) if gate.statistic == "total" else statistics.mean(values)
518
+
519
+
520
+ @dataclass(frozen=True)
521
+ class Verdict:
522
+ candidate: float | None
523
+ comparator: float | None
524
+ limit: float | None
525
+ result: str
526
+ detail: str
527
+
528
+
529
+ def _limit(gate: Gate, comparator: float) -> float:
530
+ if gate.factor is not None:
531
+ # Scale by the comparator's size, so a negative comparator still allows the same slack.
532
+ return comparator + abs(comparator) * (gate.factor - 1)
533
+ tolerance = gate.tolerance or 0.0
534
+ return comparator + tolerance if gate.direction == "lower" else comparator - tolerance
535
+
536
+
537
+ def _within(gate: Gate, value: float, limit: float) -> bool:
538
+ return value <= limit if gate.direction == "lower" else value >= limit
539
+
540
+
541
+ def _correctness_counts(
542
+ evidence: Evidence, gates: Gates, arm: str, tasks: list[str], kind: str
543
+ ) -> dict[str, int | None]:
544
+ states = PASS_STATES | ({"overlap_pass"} if gates.count_overlap_passes else set())
545
+ if kind == "unknown":
546
+ states = {"unknown"}
547
+ counts: dict[str, int | None] = {}
548
+ for task in tasks:
549
+ rows = [row for row in evidence.attempts if row.arm == arm and row.task == task]
550
+ counts[task] = sum(row.correctness in states for row in rows) if rows else None
551
+ return counts
552
+
553
+
554
+ def _count_verdict(
555
+ gate: Gate, mine: dict[str, int], theirs: dict[str, int], tasks: list[str]
556
+ ) -> Verdict:
557
+ if gate.per_task:
558
+ failing = [
559
+ task for task in tasks if not _within(gate, mine[task], _limit(gate, theirs[task]))
560
+ ]
561
+ return Verdict(
562
+ None,
563
+ None,
564
+ None,
565
+ "fail" if failing else "pass",
566
+ f"{len(tasks) - len(failing)} of {len(tasks)} tasks within limit"
567
+ + (": fails " + ", ".join(failing) if failing else ""),
568
+ )
569
+ value = float(sum(mine.values()))
570
+ comparator = float(sum(theirs.values()))
571
+ limit = _limit(gate, comparator)
572
+ result = "pass" if _within(gate, value, limit) else "fail"
573
+ return Verdict(value, comparator, limit, result, f"{len(tasks)} of {len(tasks)} tasks")
574
+
575
+
576
+ def check_gate(
577
+ evidence: Evidence, gates: Gates, gate: Gate, candidate: str, tasks: list[str]
578
+ ) -> Verdict:
579
+ if gate.bound is not None:
580
+ paired = paired_comparison(evidence, gates, candidate, gate.against, tasks)
581
+ paired_measure = paired.passes if gate.correctness else paired.tokens
582
+ detail = (
583
+ f"{len(paired_measure.tasks)} of {len(set(tasks))} tasks; "
584
+ f"estimate {_stat_number(paired_measure.value)}"
585
+ )
586
+ limit = gate.margin if gate.correctness else gate.factor
587
+ if paired_measure.interval is None or paired_measure.missing:
588
+ reason = (
589
+ f"too few tasks for interval (need {MIN_PAIRED_TASKS})"
590
+ if paired_measure.interval is None
591
+ else "incomplete task pairs"
592
+ )
593
+ return Verdict(None, None, limit, "undetermined", f"{detail}; {reason}")
594
+ value = paired_measure.interval[0 if gate.bound == "lower" else 1]
595
+ assert limit is not None
596
+ return Verdict(
597
+ value,
598
+ None,
599
+ limit,
600
+ "pass" if _within(gate, value, limit) else "fail",
601
+ f"{detail}; {gate.bound} bound of two-sided 90% interval",
602
+ )
603
+ if gate.correctness is not None:
604
+ arms = (candidate, gate.against)
605
+ attempts = {
606
+ arm: [row for row in evidence.attempts if row.arm == arm and row.task in tasks]
607
+ for arm in arms
608
+ }
609
+ repeats = {
610
+ arm: {task: sum(row.task == task for row in attempts[arm]) for task in tasks}
611
+ for arm in arms
612
+ }
613
+ absent = sorted(task for task in tasks if not all(repeats[arm][task] for arm in arms))
614
+ if absent:
615
+ return Verdict(None, None, None, "undetermined", "no attempts: " + ", ".join(absent))
616
+ # Counts over different repeats do not compare: 3 passes of 5 beat 2 of 3 at a lower rate.
617
+ unequal = [
618
+ f"{task} {repeats[candidate][task]} vs {repeats[gate.against][task]}"
619
+ for task in dict.fromkeys(tasks)
620
+ if repeats[candidate][task] != repeats[gate.against][task]
621
+ ]
622
+ if unequal:
623
+ return Verdict(
624
+ None, None, None, "undetermined", "attempts per task differ: " + ", ".join(unequal)
625
+ )
626
+ # Every task has attempts on both sides now, so no count is None.
627
+ mine, theirs = (
628
+ {
629
+ task: count or 0
630
+ for task, count in _correctness_counts(
631
+ evidence, gates, arm, tasks, gate.correctness
632
+ ).items()
633
+ }
634
+ for arm in arms
635
+ )
636
+ verdict = _count_verdict(gate, mine, theirs, tasks)
637
+ unknown = [
638
+ [row for row in attempts[arm] if row.correctness not in GRADED_STATES] for arm in arms
639
+ ]
640
+ if gate.correctness != "passes" or not any(unknown):
641
+ return verdict
642
+ # Each unknown could be a pass or a fail. Pass counts only grow with more passes, so
643
+ # counting each side's unknowns all as passes or all as fails gives the extreme results.
644
+ mine_high, theirs_high = (
645
+ {task: counts[task] + sum(row.task == task for row in rows) for task in counts}
646
+ for counts, rows in zip((mine, theirs), unknown, strict=True)
647
+ )
648
+ results = {
649
+ _count_verdict(gate, side, other, tasks).result
650
+ for side in (mine, mine_high)
651
+ for other in (theirs, theirs_high)
652
+ }
653
+ unknowns = (
654
+ f"{len(unknown[0])} candidate and {len(unknown[1])} comparator attempts "
655
+ "with unknown correctness"
656
+ )
657
+ if len(results) > 1:
658
+ return Verdict(
659
+ verdict.candidate,
660
+ verdict.comparator,
661
+ None,
662
+ "undetermined",
663
+ f"{unknowns} could change the result",
664
+ )
665
+ return replace(verdict, detail=f"{verdict.detail}; {unknowns} cannot change the result")
666
+ mine_measure = measure(evidence, gate, candidate, tasks)
667
+ theirs_measure = measure(evidence, gate, gate.against, tasks)
668
+ # Any assigned attempt without a value leaves the gate open: deciding on the attempts that
669
+ # were measured could pass a candidate on whatever survived measurement. A ratio gate never
670
+ # uses a task without a usable reference, so that task's attempts are not counted here.
671
+ # A set: a split may name a task twice, and its attempts count once.
672
+ counted = {
673
+ task
674
+ for task in tasks
675
+ if gate.statistic != "mean_of_task_median_ratios"
676
+ or _reference(evidence, task, gate)[0] is not None
677
+ }
678
+ sides = [
679
+ (
680
+ sum(side.repeats[task] for task in counted),
681
+ sum(row.arm == arm and row.task in counted for row in evidence.attempts),
682
+ )
683
+ for side, arm in ((mine_measure, candidate), (theirs_measure, gate.against))
684
+ ]
685
+ if any(measured < assigned for measured, assigned in sides):
686
+ return Verdict(
687
+ mine_measure.value,
688
+ theirs_measure.value,
689
+ None,
690
+ "undetermined",
691
+ f"missing {', '.join(gate.metrics or [])} values: "
692
+ + " and ".join(f"{measured}/{assigned}" for measured, assigned in sides)
693
+ + " attempts measured",
694
+ )
695
+ # A task one package was never run on would drop out of the comparison silently.
696
+ present = [
697
+ {row.task for row in evidence.attempts if row.arm == arm}
698
+ for arm in (candidate, gate.against)
699
+ ]
700
+ one_sided = sorted(task for task in counted if (task in present[0]) != (task in present[1]))
701
+ if one_sided:
702
+ return Verdict(
703
+ mine_measure.value,
704
+ theirs_measure.value,
705
+ None,
706
+ "undetermined",
707
+ "attempts on one side only: " + ", ".join(one_sided),
708
+ )
709
+ # Both sides over the same tasks, so a task missing on one side is not compared.
710
+ common = [task for task in tasks if task in mine_measure.tasks and task in theirs_measure.tasks]
711
+ if not common:
712
+ return Verdict(None, None, None, "undetermined", f"0 of {len(tasks)} tasks comparable")
713
+ unequal = [
714
+ f"{task} {mine_measure.repeats[task]} vs {theirs_measure.repeats[task]}"
715
+ for task in common
716
+ if mine_measure.repeats[task] != theirs_measure.repeats[task]
717
+ ]
718
+ mine_value = _combine(gate, [mine_measure.tasks[task] for task in common])
719
+ theirs_value = _combine(gate, [theirs_measure.tasks[task] for task in common])
720
+ assert mine_value is not None and theirs_value is not None
721
+ limit = _limit(gate, theirs_value)
722
+ return Verdict(
723
+ mine_value,
724
+ theirs_value,
725
+ limit,
726
+ "pass" if _within(gate, mine_value, limit) else "fail",
727
+ f"{len(common)} of {len(tasks)} tasks"
728
+ + ("; measured repeats differ: " + ", ".join(unequal) if unequal else ""),
729
+ )
730
+
731
+
732
+ def _number(value: float | None) -> str:
733
+ if value is None:
734
+ return "missing"
735
+ if abs(value) >= 1_000_000:
736
+ return f"{value / 1_000_000:.2f}M"
737
+ if float(value).is_integer():
738
+ return str(int(value))
739
+ return f"{value:.2f}"
740
+
741
+
742
+ def _stat_number(value: float | None) -> str:
743
+ return "missing" if value is None else f"{value:.6g}"
744
+
745
+
746
+ def _paired_section(
747
+ evidence: Evidence, gates: Gates, arms: list[str], tasks: list[str]
748
+ ) -> list[str]:
749
+ lines = [
750
+ "",
751
+ "Paired task statistics (candidate minus / divided by comparator)",
752
+ f"90% percentile bootstrap intervals; {BOOTSTRAP_DRAWS} task draws; seed {BOOTSTRAP_SEED}.",
753
+ f"Intervals need at least {MIN_PAIRED_TASKS} tasks; repeats are averaged within each task.",
754
+ ]
755
+ for against in dict.fromkeys(gate.against for gate in gates.gates):
756
+ for candidate in arms:
757
+ if candidate == against:
758
+ continue
759
+ paired = paired_comparison(evidence, gates, candidate, against, tasks)
760
+ lines += ["", f"{candidate} against {against}"]
761
+ summary = [
762
+ [
763
+ "paired measure",
764
+ "estimate",
765
+ "90% interval",
766
+ "tasks",
767
+ "exact sign-test p (two-sided)",
768
+ ]
769
+ ]
770
+ for name, result in (
771
+ ("pass-rate difference", paired.passes),
772
+ ("geometric token ratio", paired.tokens),
773
+ ):
774
+ interval = (
775
+ f"[{_stat_number(result.interval[0])}, {_stat_number(result.interval[1])}]"
776
+ if result.interval is not None
777
+ else "too few tasks"
778
+ )
779
+ summary.append(
780
+ [
781
+ name,
782
+ _stat_number(result.value),
783
+ interval,
784
+ f"{len(result.tasks)}/{len(set(tasks))}",
785
+ _stat_number(sign_test(list(result.tasks.values())))
786
+ if name == "pass-rate difference" and result.tasks
787
+ else "",
788
+ ]
789
+ )
790
+ lines += _table(summary)
791
+ per_task = [["task", "pass-rate difference", "token ratio"]]
792
+ for task in sorted(set(tasks)):
793
+ per_task.append(
794
+ [
795
+ task,
796
+ _stat_number(paired.passes.tasks.get(task)),
797
+ _stat_number(paired.tokens.tasks.get(task)),
798
+ ]
799
+ )
800
+ lines += ["", *_table(per_task)]
801
+ for name, result in (("passes", paired.passes), ("tokens", paired.tokens)):
802
+ lines += [
803
+ f" Excluded {name}, {task}: {why}" for task, why in result.missing.items()
804
+ ]
805
+ return lines
806
+
807
+
808
+ def _table(rows: list[list[str]]) -> list[str]:
809
+ widths = [max(len(row[index]) for row in rows) for index in range(len(rows[0]))]
810
+ return [
811
+ " ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)).rstrip()
812
+ for row in rows
813
+ ]
814
+
815
+
816
+ def _arms(evidence: Evidence) -> list[str]:
817
+ seen = list(dict.fromkeys(row.arm for row in evidence.attempts))
818
+ return sorted(seen, key=lambda arm: (arm != "vanilla", seen.index(arm)))
819
+
820
+
821
+ def _section(evidence: Evidence, gates: Gates, split: str, tasks: list[str]) -> list[str]:
822
+ present = sorted({row.task for row in evidence.attempts} & set(tasks))
823
+ lines = [f"Split {split}: {len(present)} of {len(tasks)} tasks have attempts"]
824
+ if not present:
825
+ return lines
826
+ arms = _arms(evidence)
827
+ rows = [["measure", *arms]]
828
+ counts: dict[str, dict[str, int]] = {arm: {} for arm in arms}
829
+ for arm in arms:
830
+ mine = [row for row in evidence.attempts if row.arm == arm and row.task in tasks]
831
+ counts[arm] = {
832
+ "attempts": len(mine),
833
+ "confirmed passes": sum(row.correctness == "pass" for row in mine),
834
+ "overlap passes": sum(row.correctness == "overlap_pass" for row in mine),
835
+ "unknown": sum(row.correctness == "unknown" for row in mine),
836
+ }
837
+ counts[arm]["passes"] = counts[arm]["confirmed passes"] + (
838
+ counts[arm]["overlap passes"] if gates.count_overlap_passes else 0
839
+ )
840
+ for name in ("attempts", "confirmed passes", "overlap passes", "passes", "unknown"):
841
+ rows.append([name, *(str(counts[arm][name]) for arm in arms)])
842
+ passes = {arm: _correctness_counts(evidence, gates, arm, tasks, "passes") for arm in arms}
843
+ for task in tasks:
844
+ cells = []
845
+ for arm in arms:
846
+ assigned = sum(row.arm == arm and row.task == task for row in evidence.attempts)
847
+ cells.append(f"{passes[arm][task]}/{assigned}" if assigned else "no attempts")
848
+ rows.append([f"passes: {task}", *cells])
849
+ notes: list[str] = []
850
+ shown: set[tuple[object, ...]] = set()
851
+ for gate in gates.gates:
852
+ if gate.metrics is None or gate.bound is not None:
853
+ continue
854
+ key = (tuple(gate.metrics), gate.statistic, gate.reference_floor)
855
+ if key in shown:
856
+ continue
857
+ shown.add(key)
858
+ cells = []
859
+ results = {arm: measure(evidence, gate, arm, tasks) for arm in arms}
860
+ for arm, result in results.items():
861
+ count = f"{result.measured}/{result.assigned} attempts"
862
+ if gate.statistic != "total":
863
+ count = f"{len(result.tasks)}/{len(tasks)} tasks, {count}"
864
+ cells.append(f"{_number(result.value)} ({count})")
865
+ notes += [
866
+ f"{gate.name}, {arm}, {task}: {why}"
867
+ for found in (result.missing, result.partial)
868
+ for task, why in found.items()
869
+ ]
870
+ rows.append([gate.name, *cells])
871
+ # Per-task values, so one task (or a tiny reference) cannot move the mean unseen.
872
+ for task in dict.fromkeys(tasks):
873
+ label = f"{gate.name}: {task}"
874
+ if gate.statistic == "mean_of_task_median_ratios":
875
+ reference = _reference_total(evidence, task, gate.metrics)
876
+ label += f" (reference {_number(reference)})"
877
+ rows.append([label, *(_number(results[arm].tasks.get(task)) for arm in arms)])
878
+ lines += ["", *_table(rows)]
879
+ if notes:
880
+ lines += ["", "Missing values (not counted as 0):", *(f" {note}" for note in notes)]
881
+ verdicts = [
882
+ ["gate", "candidate", "against", "value", "comparator", "limit", "result", "detail"]
883
+ ]
884
+ for gate in gates.gates:
885
+ if gate.against not in arms:
886
+ raise ValueError(
887
+ f"gate {gate.name!r} compares against {gate.against}, which no run has"
888
+ )
889
+ for candidate in arms:
890
+ if candidate == gate.against:
891
+ continue
892
+ verdict = check_gate(evidence, gates, gate, candidate, tasks)
893
+ number = _stat_number if gate.bound is not None else _number
894
+ verdicts.append(
895
+ [
896
+ gate.name,
897
+ candidate,
898
+ gate.against,
899
+ number(verdict.candidate) if verdict.candidate is not None else "",
900
+ number(verdict.comparator) if verdict.comparator is not None else "",
901
+ ("≤ " if gate.direction == "lower" else "≥ ") + number(verdict.limit)
902
+ if verdict.limit is not None
903
+ else "",
904
+ verdict.result,
905
+ verdict.detail,
906
+ ]
907
+ )
908
+ lines += ["", "Gates", *_table(verdicts)]
909
+ lines += _paired_section(evidence, gates, arms, tasks)
910
+ return lines
911
+
912
+
913
+ def compare(specs: list[str], gates_path: Path) -> str:
914
+ """The gate table for each split, then for every task the runs supply."""
915
+ gates = load_gates(gates_path)
916
+ evidence = load_evidence(specs)
917
+ all_tasks = sorted({row.task for row in evidence.attempts})
918
+ notes = _check_comparable(evidence, gates)
919
+ lines = [
920
+ "Overlap passes count as passes."
921
+ if gates.count_overlap_passes
922
+ else "Overlap passes do not count as passes.",
923
+ *(f"Run identity: {note}" for note in notes),
924
+ ]
925
+ for split, tasks in [*gates.splits.items(), ("all", all_tasks)]:
926
+ lines += ["", *_section(evidence, gates, split, tasks)]
927
+ return "\n".join(lines)