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/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)
|