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/judge.py
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"""Blinded pairwise judging of two arms' matched submissions: prepare, run, and tally.
|
|
2
|
+
|
|
3
|
+
`prepare` reads saved evidence only. Each submission's diff comes from its review packet, or
|
|
4
|
+
from its saved snapshots when the run has no packet for it, with headers normalized so
|
|
5
|
+
neither arm's tooling fingerprints it. `run` sends one read-only
|
|
6
|
+
call per judge and task through the reviewer launch path; the judge's workspace is a copy of
|
|
7
|
+
one task folder, so the key, arm names, traces, and verifier results are never inside it.
|
|
8
|
+
`tally` unblinds with the key and applies the correctness-first, strict-majority rule.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import random
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from collections.abc import Callable, Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from threading import Event
|
|
24
|
+
from typing import Any, Literal
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
from pydantic import StrictInt, StrictStr
|
|
28
|
+
|
|
29
|
+
from pluginbench import codex, study
|
|
30
|
+
from pluginbench.assessment import _load_manifest
|
|
31
|
+
from pluginbench.assessment_models import Attempt, InputModel, Snapshot
|
|
32
|
+
from pluginbench.compare import ARM_NAMES
|
|
33
|
+
from pluginbench.evaluation_report import correctness_state
|
|
34
|
+
from pluginbench.parallel import run_jobs, validate_jobs
|
|
35
|
+
from pluginbench.datasets import TaskCatalog
|
|
36
|
+
from pluginbench.study_review import (
|
|
37
|
+
check_read_only_trace,
|
|
38
|
+
copy_source,
|
|
39
|
+
verified_final,
|
|
40
|
+
write_source_diff,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
KEY = "key.json"
|
|
44
|
+
PANEL = "panel.json"
|
|
45
|
+
SCHEMA = "response-schema.json"
|
|
46
|
+
# An overlap pass counts as a pass: the verifier graded the production code with its own tests.
|
|
47
|
+
PASSING = {"pass", "overlap_pass"}
|
|
48
|
+
HARNESSES = {"codex", "claude"}
|
|
49
|
+
|
|
50
|
+
BRIEF = """\
|
|
51
|
+
You are judging code changes as the engineer who will maintain this code next. Work \
|
|
52
|
+
read-only: do not edit any file, run state-changing commands, or start subagents.
|
|
53
|
+
|
|
54
|
+
Read the review standard first: review-standard.md. Its checks are the only criteria.
|
|
55
|
+
|
|
56
|
+
Task given to the authors: instruction.md. Read-only starting source: source/. Read the \
|
|
57
|
+
starting source wherever you need context.
|
|
58
|
+
|
|
59
|
+
Each pair below holds two independent changes, A and B, that try to solve the same task from \
|
|
60
|
+
the same starting source. Read each diff. Judge the code each change leaves behind. Do not \
|
|
61
|
+
reward length, comments, or explanation. Do not try to guess which tool or process wrote \
|
|
62
|
+
either change. An empty diff makes no change.
|
|
63
|
+
|
|
64
|
+
{pairs}
|
|
65
|
+
|
|
66
|
+
For each pair, decide which change leaves the code in better shape for its next maintainer \
|
|
67
|
+
under the review standard: A, B, or tie. Use tie only when you cannot separate them on any \
|
|
68
|
+
check.
|
|
69
|
+
|
|
70
|
+
End your final message with only this JSON, one entry per pair:
|
|
71
|
+
|
|
72
|
+
{{"pairs": [{{"pair": 1, "preferred": "A", "decisive_checks": [3, 8], "reason": "One or two \
|
|
73
|
+
sentences that name the concrete code that decided it."}}]}}
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PairVerdict(InputModel):
|
|
78
|
+
pair: StrictInt
|
|
79
|
+
preferred: Literal["A", "B", "tie"]
|
|
80
|
+
decisive_checks: list[StrictInt]
|
|
81
|
+
reason: StrictStr
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Verdicts(InputModel):
|
|
85
|
+
pairs: list[PairVerdict]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Saved:
|
|
90
|
+
"""What a review packet is built from: an attempt's snapshots and its task instruction."""
|
|
91
|
+
|
|
92
|
+
before: Snapshot
|
|
93
|
+
after: Snapshot
|
|
94
|
+
instruction: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class Submission:
|
|
99
|
+
run: Path
|
|
100
|
+
arm: str
|
|
101
|
+
attempt: str
|
|
102
|
+
repeat: int
|
|
103
|
+
correctness: str
|
|
104
|
+
before_digest: str
|
|
105
|
+
# The review packet, or what stands in for it when the run has none.
|
|
106
|
+
source: Path | Saved
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def normalize_diff(text: str) -> str:
|
|
110
|
+
"""Rewrite file headers to `a/` and `b/` and drop `index` lines.
|
|
111
|
+
|
|
112
|
+
Review packets diff `before/` against `after/` with `--no-prefix`; other sources use
|
|
113
|
+
`a/` and `b/` with other hash lengths. Either fingerprints its source, so both become
|
|
114
|
+
`diff --git a/<path> b/<path>` with no `index` line. Hunks are untouched.
|
|
115
|
+
"""
|
|
116
|
+
lines = []
|
|
117
|
+
in_header = False
|
|
118
|
+
for line in text.splitlines(keepends=True):
|
|
119
|
+
body = line.rstrip("\n")
|
|
120
|
+
if body.startswith("diff --git "):
|
|
121
|
+
in_header = True
|
|
122
|
+
path = _header_path(body.removeprefix("diff --git "))
|
|
123
|
+
line = f"diff --git {_prefixed('a', path)} {_prefixed('b', path)}\n"
|
|
124
|
+
elif in_header and (body.startswith("@@") or body == "GIT binary patch"):
|
|
125
|
+
in_header = False
|
|
126
|
+
elif in_header and body.startswith("index "):
|
|
127
|
+
continue
|
|
128
|
+
elif in_header and body.startswith(("--- ", "+++ ")) and body[4:] != "/dev/null":
|
|
129
|
+
marker, side = body[:4], "a" if body.startswith("---") else "b"
|
|
130
|
+
line = f"{marker}{_prefixed(side, _strip_prefix(body[4:]))}\n"
|
|
131
|
+
lines.append(line)
|
|
132
|
+
return "".join(lines)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_PREFIXES = ("before/", "after/", "a/", "b/")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _strip_prefix(token: str) -> str:
|
|
139
|
+
quote = '"' if token.startswith('"') and token.endswith('"') else ""
|
|
140
|
+
inner = token[1:-1] if quote else token
|
|
141
|
+
for prefix in _PREFIXES:
|
|
142
|
+
if inner.startswith(prefix):
|
|
143
|
+
return quote + inner.removeprefix(prefix) + quote
|
|
144
|
+
raise ValueError(f"unexpected diff path {token!r}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _prefixed(side: str, path: str) -> str:
|
|
148
|
+
return f'"{side}/{path[1:]}' if path.startswith('"') else f"{side}/{path}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _header_path(header: str) -> str:
|
|
152
|
+
"""The one path in `X/p Y/p`; renames are off, so both sides name the same file."""
|
|
153
|
+
for quote in ("", '"'):
|
|
154
|
+
for first in _PREFIXES:
|
|
155
|
+
for second in _PREFIXES:
|
|
156
|
+
extra = len(first) + len(second) + 1 + 4 * len(quote)
|
|
157
|
+
length, odd = divmod(len(header) - extra, 2)
|
|
158
|
+
if odd or length <= 0:
|
|
159
|
+
continue
|
|
160
|
+
path = header[len(quote) + len(first) :][:length]
|
|
161
|
+
if header == f"{quote}{first}{path}{quote} {quote}{second}{path}{quote}":
|
|
162
|
+
return f'"{path}"' if quote else path
|
|
163
|
+
raise ValueError(f"unexpected diff header {header!r}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_arm(spec: str) -> tuple[Path, str]:
|
|
167
|
+
directory, _, arm = spec.partition("=")
|
|
168
|
+
if not directory or not arm:
|
|
169
|
+
raise ValueError(f"{spec!r}: give RUN_DIR=ARM")
|
|
170
|
+
return Path(directory), ARM_NAMES.get(arm, arm)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _packets(run: Path, base: str, wanted: set[str]) -> dict[str, tuple[str, Path]]:
|
|
174
|
+
"""Each wanted attempt's (submission digest, packet), from the first revision that has one."""
|
|
175
|
+
found: dict[str, tuple[str, Path]] = {}
|
|
176
|
+
revisions = run / base / "review-revisions"
|
|
177
|
+
for revision in sorted(revisions.iterdir()) if revisions.is_dir() else []:
|
|
178
|
+
mapping = revision / "mapping.json"
|
|
179
|
+
if not mapping.is_file():
|
|
180
|
+
continue
|
|
181
|
+
for row in json.loads(mapping.read_text()):
|
|
182
|
+
packet = revision / "packets" / str(row["packet_id"])
|
|
183
|
+
if (
|
|
184
|
+
row["id"] in wanted
|
|
185
|
+
and row["id"] not in found
|
|
186
|
+
and (packet / "source.diff").is_file()
|
|
187
|
+
):
|
|
188
|
+
found[row["id"]] = (str(row["submission_digest"]), packet)
|
|
189
|
+
return found
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _saved_evidence(evidence: Path) -> tuple[dict[str, Attempt], dict[str, str]]:
|
|
193
|
+
"""A run's attempts with their snapshots, and each task's instruction.
|
|
194
|
+
|
|
195
|
+
Checked as a review checks them before it builds packets: the frozen inputs against their
|
|
196
|
+
inventory, the attempt records against the run's reference, each snapshot against its digest.
|
|
197
|
+
"""
|
|
198
|
+
evidence = evidence.resolve()
|
|
199
|
+
try:
|
|
200
|
+
study._assert_frozen(evidence)
|
|
201
|
+
run = json.loads((evidence / "run.json").read_text())
|
|
202
|
+
_, records = _load_manifest(study._bound_path(evidence, run["assessment_input"]))
|
|
203
|
+
tasks = json.loads((evidence / "frozen/tasks.json").read_text())
|
|
204
|
+
except (OSError, KeyError, TypeError, yaml.YAMLError) as error:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"{evidence.parent} has an attempt with no review packet, and its saved snapshots "
|
|
207
|
+
f"cannot be read: {error}"
|
|
208
|
+
) from error
|
|
209
|
+
catalog = TaskCatalog.model_validate(tasks)
|
|
210
|
+
return {record.id: record for record in records}, {
|
|
211
|
+
entry.id: entry.prompt for entry in catalog.tasks
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _packet_files(submission: Submission, target: Path) -> Path:
|
|
216
|
+
"""The submission's review packet, or the same files built in `target` from its snapshots.
|
|
217
|
+
|
|
218
|
+
A run without a reviewer, or one that stopped before review, has no packets. The built
|
|
219
|
+
folder holds what `prepare` reads from a packet: `before/`, `task.txt`, and `source.diff`.
|
|
220
|
+
"""
|
|
221
|
+
saved = submission.source
|
|
222
|
+
if isinstance(saved, Path):
|
|
223
|
+
return saved
|
|
224
|
+
target.mkdir(parents=True)
|
|
225
|
+
copy_source(saved.before, target / "before")
|
|
226
|
+
copy_source(saved.after, target / "after")
|
|
227
|
+
write_source_diff(target)
|
|
228
|
+
shutil.rmtree(target / "after")
|
|
229
|
+
(target / "task.txt").write_text(saved.instruction)
|
|
230
|
+
return target
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def load_submissions(
|
|
234
|
+
run: Path, arm: str, tasks: Sequence[str]
|
|
235
|
+
) -> tuple[dict[tuple[str, int], Submission], list[tuple[str, int]]]:
|
|
236
|
+
"""One arm's submissions on the given tasks, keyed by (task, repeat).
|
|
237
|
+
|
|
238
|
+
Each is bound to its review packet, or to its saved snapshots when it has no packet. Also
|
|
239
|
+
returned: the (task, repeat) slots whose attempt left no submission, such as one that was
|
|
240
|
+
not coded yet. They cannot be judged.
|
|
241
|
+
"""
|
|
242
|
+
results = run / "report" / "results.json"
|
|
243
|
+
if not results.is_file():
|
|
244
|
+
raise ValueError(f"{run} has no report/results.json; run `pluginbench report`")
|
|
245
|
+
data = json.loads(results.read_text())
|
|
246
|
+
rows = [
|
|
247
|
+
row
|
|
248
|
+
for row in data["attempts"]
|
|
249
|
+
if ARM_NAMES.get(row["guidance"], row["guidance"]) == arm and row["task"] in tasks
|
|
250
|
+
]
|
|
251
|
+
if not rows:
|
|
252
|
+
raise ValueError(f"{run} has no {arm} attempts on the given tasks")
|
|
253
|
+
base = str(data.get("artifact_base", "evidence"))
|
|
254
|
+
packets = _packets(run, base, {row["id"] for row in rows})
|
|
255
|
+
records, instructions = (
|
|
256
|
+
({}, {}) if all(row["id"] in packets for row in rows) else _saved_evidence(run / base)
|
|
257
|
+
)
|
|
258
|
+
submissions: dict[tuple[str, int], Submission] = {}
|
|
259
|
+
unavailable: list[tuple[str, int]] = []
|
|
260
|
+
for row in rows:
|
|
261
|
+
key = (row["task"], int(row["repeat"]))
|
|
262
|
+
where = f"{run} {arm} {row['task']} repeat {key[1]}"
|
|
263
|
+
if key in submissions:
|
|
264
|
+
raise ValueError(f"{where} appears twice")
|
|
265
|
+
artifacts = row["artifacts"]
|
|
266
|
+
source: Path | Saved
|
|
267
|
+
if row["id"] in packets:
|
|
268
|
+
digest, source = packets[row["id"]]
|
|
269
|
+
binding = json.loads(source.with_name(source.name + ".json").read_text())
|
|
270
|
+
if digest != artifacts["snapshot_digest"]:
|
|
271
|
+
raise ValueError(f"{where}: review packet is bound to another submission")
|
|
272
|
+
if binding["before_digest"] != artifacts["before_digest"]:
|
|
273
|
+
raise ValueError(f"{where}: review packet is bound to another starting source")
|
|
274
|
+
else:
|
|
275
|
+
record = records.get(row["id"])
|
|
276
|
+
if record is None:
|
|
277
|
+
raise ValueError(f"{where}: the run's records have no such attempt")
|
|
278
|
+
before, after = record.before, record.stages[0].snapshot
|
|
279
|
+
if before is None or after is None:
|
|
280
|
+
# Left out only when the report agrees that nothing was submitted.
|
|
281
|
+
if artifacts["snapshot_digest"] is not None:
|
|
282
|
+
raise ValueError(f"{where}: the report names a submission the run did not save")
|
|
283
|
+
unavailable.append(key)
|
|
284
|
+
continue
|
|
285
|
+
if after.digest != artifacts["snapshot_digest"]:
|
|
286
|
+
raise ValueError(f"{where}: the saved snapshot is not the reported submission")
|
|
287
|
+
if before.digest != artifacts["before_digest"]:
|
|
288
|
+
raise ValueError(f"{where}: the saved starting source is not the reported one")
|
|
289
|
+
if row["task"] not in instructions:
|
|
290
|
+
raise ValueError(f"{where}: the run's frozen task catalog has no such task")
|
|
291
|
+
source = Saved(before, after, instructions[row["task"]])
|
|
292
|
+
submissions[key] = Submission(
|
|
293
|
+
run=run,
|
|
294
|
+
arm=arm,
|
|
295
|
+
attempt=row["id"],
|
|
296
|
+
repeat=key[1],
|
|
297
|
+
correctness=correctness_state(row["correctness"], row.get("native_grading")),
|
|
298
|
+
before_digest=artifacts["before_digest"],
|
|
299
|
+
source=source,
|
|
300
|
+
)
|
|
301
|
+
return submissions, unavailable
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def prepare(
|
|
305
|
+
*,
|
|
306
|
+
arms: Sequence[str],
|
|
307
|
+
tasks: Sequence[str],
|
|
308
|
+
exclude: Sequence[str],
|
|
309
|
+
criteria: Path,
|
|
310
|
+
seed: int,
|
|
311
|
+
output: Path,
|
|
312
|
+
) -> Path:
|
|
313
|
+
"""Write one blinded task folder and brief per task, and the key that unblinds them."""
|
|
314
|
+
if len(arms) != 2:
|
|
315
|
+
raise ValueError("give exactly two RUN_DIR=ARM specs")
|
|
316
|
+
(first_run, first_arm), (second_run, second_arm) = (parse_arm(spec) for spec in arms)
|
|
317
|
+
if first_arm == second_arm:
|
|
318
|
+
raise ValueError(f"both sides are {first_arm}; judge two different arms")
|
|
319
|
+
if not tasks or len(set(tasks)) != len(tasks):
|
|
320
|
+
raise ValueError("--tasks must name at least one task, each once")
|
|
321
|
+
refused = sorted(set(tasks) & set(exclude))
|
|
322
|
+
if refused:
|
|
323
|
+
raise ValueError(f"refusing excluded tasks: {', '.join(refused)}")
|
|
324
|
+
if output.exists():
|
|
325
|
+
raise ValueError(f"{output} already exists; give a new directory")
|
|
326
|
+
standard = criteria.read_text()
|
|
327
|
+
loaded = {
|
|
328
|
+
first_arm: load_submissions(first_run, first_arm, tasks),
|
|
329
|
+
second_arm: load_submissions(second_run, second_arm, tasks),
|
|
330
|
+
}
|
|
331
|
+
sides = {arm: submissions for arm, (submissions, _) in loaded.items()}
|
|
332
|
+
# Slots with no submission are left out of the pairs and named in the key.
|
|
333
|
+
omitted = [
|
|
334
|
+
{"arm": arm, "task": task, "repeat": repeat}
|
|
335
|
+
for arm, (_, unavailable) in loaded.items()
|
|
336
|
+
for task, repeat in unavailable
|
|
337
|
+
]
|
|
338
|
+
rng = random.Random(seed)
|
|
339
|
+
output.mkdir(parents=True)
|
|
340
|
+
built: dict[tuple[str, str], Path] = {}
|
|
341
|
+
|
|
342
|
+
def packet_of(submission: Submission) -> Path:
|
|
343
|
+
key = (submission.arm, submission.attempt)
|
|
344
|
+
if key not in built:
|
|
345
|
+
# Numbered, because arm names and attempt IDs are not safe path components.
|
|
346
|
+
built[key] = _packet_files(submission, output / "submissions" / str(len(built) + 1))
|
|
347
|
+
return built[key]
|
|
348
|
+
|
|
349
|
+
folders: dict[str, object] = {}
|
|
350
|
+
for index, task in enumerate(tasks, start=1):
|
|
351
|
+
name = f"task-{index}"
|
|
352
|
+
repeats = sorted(
|
|
353
|
+
repeat
|
|
354
|
+
for (owner, repeat) in sides[first_arm].keys() & sides[second_arm].keys()
|
|
355
|
+
if owner == task
|
|
356
|
+
)
|
|
357
|
+
if not repeats:
|
|
358
|
+
raise ValueError(f"{task} has no repeat present in both {first_arm} and {second_arm}")
|
|
359
|
+
folder = output / name
|
|
360
|
+
folder.mkdir()
|
|
361
|
+
instruction: bytes | None = None
|
|
362
|
+
pairs: dict[str, object] = {}
|
|
363
|
+
lines = []
|
|
364
|
+
for number, repeat in enumerate(repeats, start=1):
|
|
365
|
+
pair = (sides[first_arm][task, repeat], sides[second_arm][task, repeat])
|
|
366
|
+
if pair[0].before_digest != pair[1].before_digest:
|
|
367
|
+
raise ValueError(f"{task} repeat {repeat}: the arms start from different source")
|
|
368
|
+
for submission in pair:
|
|
369
|
+
text = (packet_of(submission) / "task.txt").read_bytes()
|
|
370
|
+
if instruction not in (None, text):
|
|
371
|
+
raise ValueError(f"{task}: the arms were given different instructions")
|
|
372
|
+
instruction = text
|
|
373
|
+
if rng.random() < 0.5:
|
|
374
|
+
pair = (pair[1], pair[0])
|
|
375
|
+
for side, submission in zip("AB", pair, strict=True):
|
|
376
|
+
diff = (packet_of(submission) / "source.diff").read_text()
|
|
377
|
+
(folder / f"pair-{number}-{side}.diff").write_text(normalize_diff(diff))
|
|
378
|
+
pairs[str(number)] = {
|
|
379
|
+
side: {
|
|
380
|
+
"arm": submission.arm,
|
|
381
|
+
"run": str(submission.run.resolve()),
|
|
382
|
+
"attempt": submission.attempt,
|
|
383
|
+
"repeat": submission.repeat,
|
|
384
|
+
"correctness": submission.correctness,
|
|
385
|
+
}
|
|
386
|
+
for side, submission in zip("AB", pair, strict=True)
|
|
387
|
+
}
|
|
388
|
+
lines.append(f"- Pair {number}: A = pair-{number}-A.diff, B = pair-{number}-B.diff")
|
|
389
|
+
assert instruction is not None
|
|
390
|
+
(folder / "instruction.md").write_bytes(instruction)
|
|
391
|
+
(folder / "review-standard.md").write_text(standard)
|
|
392
|
+
(folder / "source").symlink_to(
|
|
393
|
+
(packet_of(sides[first_arm][task, repeats[0]]) / "before").resolve()
|
|
394
|
+
)
|
|
395
|
+
study._write(folder / SCHEMA, Verdicts.model_json_schema())
|
|
396
|
+
(output / f"{name}.brief.md").write_text(BRIEF.format(pairs="\n".join(lines)))
|
|
397
|
+
folders[name] = {"task": task, "pairs": pairs}
|
|
398
|
+
study._write(
|
|
399
|
+
output / KEY,
|
|
400
|
+
{
|
|
401
|
+
"schema_version": 1,
|
|
402
|
+
"arms": {first_arm: str(first_run.resolve()), second_arm: str(second_run.resolve())},
|
|
403
|
+
"seed": seed,
|
|
404
|
+
"criteria": str(criteria.resolve()),
|
|
405
|
+
"tasks": folders,
|
|
406
|
+
"omitted": omitted,
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
return output
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@dataclass(frozen=True)
|
|
413
|
+
class Judge:
|
|
414
|
+
harness: str
|
|
415
|
+
model: str
|
|
416
|
+
effort: str
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def label(self) -> str:
|
|
420
|
+
return re.sub(r"[^A-Za-z0-9._-]", "-", f"{self.harness}-{self.model}-{self.effort}")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def parse_judge(spec: str) -> Judge:
|
|
424
|
+
harness, model, effort = (spec.split(":") + ["", ""])[:3]
|
|
425
|
+
if harness not in HARNESSES or not model or not effort or spec.count(":") != 2:
|
|
426
|
+
raise ValueError(f"{spec!r}: give HARNESS:MODEL:EFFORT with harness codex or claude")
|
|
427
|
+
return Judge(harness, model, effort)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _read_key(directory: Path) -> dict[str, Any]:
|
|
431
|
+
path = directory / KEY
|
|
432
|
+
if not path.is_file():
|
|
433
|
+
raise ValueError(f"{directory} has no {KEY}; run `pluginbench judge prepare` first")
|
|
434
|
+
key: dict[str, Any] = json.loads(path.read_text())
|
|
435
|
+
return key
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _panel(directory: Path) -> list[str]:
|
|
439
|
+
path = directory / PANEL
|
|
440
|
+
return list(json.loads(path.read_text())) if path.is_file() else []
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _verdict_path(directory: Path, judge: str, task: str) -> Path:
|
|
444
|
+
return directory / "results" / judge / f"{task}.json"
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def run(
|
|
448
|
+
directory: Path,
|
|
449
|
+
*,
|
|
450
|
+
judges: Sequence[str],
|
|
451
|
+
timeout: int,
|
|
452
|
+
jobs: int,
|
|
453
|
+
progress: Callable[[str], None],
|
|
454
|
+
) -> int:
|
|
455
|
+
"""Make each missing judge call; return how many calls failed. Completed calls are kept."""
|
|
456
|
+
validate_jobs(jobs)
|
|
457
|
+
if timeout <= 0:
|
|
458
|
+
raise ValueError("timeout must be positive")
|
|
459
|
+
panel = [parse_judge(spec) for spec in judges]
|
|
460
|
+
if not panel or len({judge.label for judge in panel}) != len(panel):
|
|
461
|
+
raise ValueError("give at least one --judge, each once")
|
|
462
|
+
tasks = _read_key(directory)["tasks"]
|
|
463
|
+
# Record every requested judge before any call, so tally can refuse a panel cut short.
|
|
464
|
+
study._write(directory / PANEL, sorted(set(_panel(directory)) | {j.label for j in panel}))
|
|
465
|
+
calls = [
|
|
466
|
+
(judge, task)
|
|
467
|
+
for judge in panel
|
|
468
|
+
for task in tasks
|
|
469
|
+
if not _verdict_path(directory, judge.label, task).is_file()
|
|
470
|
+
]
|
|
471
|
+
progress(f"{len(calls)} judge calls to make; {len(panel) * len(tasks) - len(calls)} done.")
|
|
472
|
+
failures: list[str] = []
|
|
473
|
+
|
|
474
|
+
def call(
|
|
475
|
+
item: tuple[Judge, str], cancelled: Event, emit: Callable[[Callable[[], None]], None]
|
|
476
|
+
) -> None:
|
|
477
|
+
judge, task = item
|
|
478
|
+
if cancelled.is_set():
|
|
479
|
+
return
|
|
480
|
+
try:
|
|
481
|
+
_judge_task(directory, judge, task, expected=set(tasks[task]["pairs"]), timeout=timeout)
|
|
482
|
+
progress(f"{judge.label} {task}: done")
|
|
483
|
+
# A failed call leaves no verdict file, so the next run makes it again.
|
|
484
|
+
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
485
|
+
failures.append(f"{judge.label} {task}")
|
|
486
|
+
progress(f"{judge.label} {task}: failed: {exc}")
|
|
487
|
+
|
|
488
|
+
run_jobs(calls, jobs=jobs, operation=call)
|
|
489
|
+
return len(failures)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _judge_task(
|
|
493
|
+
directory: Path, judge: Judge, task: str, *, expected: set[str], timeout: int
|
|
494
|
+
) -> None:
|
|
495
|
+
"""One read-only call; raw evidence goes to a new call-N folder, the verdict file last."""
|
|
496
|
+
verdict = _verdict_path(directory, judge.label, task)
|
|
497
|
+
raw = verdict.with_suffix("")
|
|
498
|
+
raw.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
attempt = raw / f"call-{len(list(raw.glob('call-*'))) + 1}"
|
|
500
|
+
folder = directory / task
|
|
501
|
+
with tempfile.TemporaryDirectory(prefix="pluginbench-judge-") as temporary:
|
|
502
|
+
area = Path(temporary)
|
|
503
|
+
workspace = area / "workspace"
|
|
504
|
+
# Copy, never link: the judge's sandbox can read only its workspace.
|
|
505
|
+
shutil.copytree(folder, workspace, symlinks=True, ignore=shutil.ignore_patterns("source"))
|
|
506
|
+
shutil.copytree((folder / "source").resolve(), workspace / "source", symlinks=True)
|
|
507
|
+
try:
|
|
508
|
+
execution = codex.invoke(
|
|
509
|
+
area=area,
|
|
510
|
+
entry={"host": judge.harness},
|
|
511
|
+
protocol={"models": {judge.harness: judge.model}, "reasoning": judge.effort},
|
|
512
|
+
prompt=(directory / f"{task}.brief.md").read_text(),
|
|
513
|
+
timeout=timeout,
|
|
514
|
+
output_schema=workspace / SCHEMA,
|
|
515
|
+
)
|
|
516
|
+
finally:
|
|
517
|
+
artifacts = area / "artifacts"
|
|
518
|
+
if artifacts.exists():
|
|
519
|
+
codex.copy_artifacts(artifacts, attempt)
|
|
520
|
+
final = verified_final(execution, model=judge.model, effort=judge.effort, harness=judge.harness)
|
|
521
|
+
check_read_only_trace(
|
|
522
|
+
execution, harness=judge.harness, forbidden=(str(directory.resolve()), "../", KEY)
|
|
523
|
+
)
|
|
524
|
+
verdicts = Verdicts.model_validate_json(final)
|
|
525
|
+
answered = [str(row.pair) for row in verdicts.pairs]
|
|
526
|
+
if sorted(answered) != sorted(expected):
|
|
527
|
+
raise ValueError(f"judge answered pairs {answered}, expected {sorted(expected)}")
|
|
528
|
+
study._write(verdict, verdicts.model_dump(mode="json"))
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _majority(votes: Sequence[str]) -> str | None:
|
|
532
|
+
"""The verdict more than half of the votes share, if any."""
|
|
533
|
+
if not votes:
|
|
534
|
+
return None
|
|
535
|
+
verdict, count = Counter(votes).most_common(1)[0]
|
|
536
|
+
return verdict if count * 2 > len(votes) else None
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def tally(directory: Path) -> dict[str, Any]:
|
|
540
|
+
"""Unblind and score every pair: correctness first, then a strict majority of judges."""
|
|
541
|
+
key = _read_key(directory)
|
|
542
|
+
tasks: dict[str, Any] = key["tasks"]
|
|
543
|
+
results = directory / "results"
|
|
544
|
+
started = (
|
|
545
|
+
{path.name for path in results.iterdir() if path.is_dir()} if results.is_dir() else set()
|
|
546
|
+
)
|
|
547
|
+
judges = sorted(started | set(_panel(directory)))
|
|
548
|
+
if not judges:
|
|
549
|
+
raise ValueError(f"{directory} has no judge results; run `pluginbench judge run` first")
|
|
550
|
+
missing = [
|
|
551
|
+
f"{judge} {task}"
|
|
552
|
+
for judge in judges
|
|
553
|
+
for task in tasks
|
|
554
|
+
if not _verdict_path(directory, judge, task).is_file()
|
|
555
|
+
]
|
|
556
|
+
if missing:
|
|
557
|
+
raise ValueError("judge results missing: " + ", ".join(missing))
|
|
558
|
+
votes = {
|
|
559
|
+
(judge, task): {
|
|
560
|
+
str(row["pair"]): str(row["preferred"])
|
|
561
|
+
for row in json.loads(_verdict_path(directory, judge, task).read_text())["pairs"]
|
|
562
|
+
}
|
|
563
|
+
for judge in judges
|
|
564
|
+
for task in tasks
|
|
565
|
+
}
|
|
566
|
+
arms = list(key["arms"])
|
|
567
|
+
wins = dict.fromkeys(arms, 0.0)
|
|
568
|
+
decided = {"correctness": dict.fromkeys(arms, 0.0), "judges": dict.fromkeys(arms, 0.0)}
|
|
569
|
+
side_a = dict.fromkeys(judges, 0)
|
|
570
|
+
agreed = dict.fromkeys(judges, 0)
|
|
571
|
+
comparable = dict.fromkeys(judges, 0)
|
|
572
|
+
per_task = []
|
|
573
|
+
for name, entry in tasks.items():
|
|
574
|
+
task_wins = dict.fromkeys(arms, 0.0)
|
|
575
|
+
rows = []
|
|
576
|
+
for number, sides in entry["pairs"].items():
|
|
577
|
+
pair_votes = {judge: votes[judge, name][number] for judge in judges}
|
|
578
|
+
for judge, vote in pair_votes.items():
|
|
579
|
+
side_a[judge] += vote == "A"
|
|
580
|
+
others = _majority([v for other, v in pair_votes.items() if other != judge])
|
|
581
|
+
if others is not None:
|
|
582
|
+
comparable[judge] += 1
|
|
583
|
+
agreed[judge] += vote == others
|
|
584
|
+
passed = {side: sides[side]["correctness"] in PASSING for side in "AB"}
|
|
585
|
+
if passed["A"] != passed["B"]:
|
|
586
|
+
by, shares = "correctness", {"A": float(passed["A"]), "B": float(passed["B"])}
|
|
587
|
+
else:
|
|
588
|
+
majority = _majority(list(pair_votes.values()))
|
|
589
|
+
by = "judges"
|
|
590
|
+
shares = (
|
|
591
|
+
{"A": float(majority == "A"), "B": float(majority == "B")}
|
|
592
|
+
if majority in ("A", "B")
|
|
593
|
+
else {"A": 0.5, "B": 0.5}
|
|
594
|
+
)
|
|
595
|
+
for side in "AB":
|
|
596
|
+
arm = sides[side]["arm"]
|
|
597
|
+
wins[arm] += shares[side]
|
|
598
|
+
task_wins[arm] += shares[side]
|
|
599
|
+
decided[by][arm] += shares[side]
|
|
600
|
+
rows.append(
|
|
601
|
+
{
|
|
602
|
+
"pair": int(number),
|
|
603
|
+
"decided_by": by,
|
|
604
|
+
"wins": {sides[side]["arm"]: shares[side] for side in "AB"},
|
|
605
|
+
"sides": sides,
|
|
606
|
+
"votes": pair_votes,
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
per_task.append({"task": entry["task"], "folder": name, "wins": task_wins, "pairs": rows})
|
|
610
|
+
pairs_total = sum(len(row["pairs"]) for row in per_task)
|
|
611
|
+
by_correctness = sum(
|
|
612
|
+
pair["decided_by"] == "correctness" for row in per_task for pair in row["pairs"]
|
|
613
|
+
)
|
|
614
|
+
return {
|
|
615
|
+
"schema_version": 1,
|
|
616
|
+
"rule": "If exactly one side passed (pass or overlap pass), it wins without a vote. "
|
|
617
|
+
"Otherwise the side more than half of the judges prefer wins; anything else is half each.",
|
|
618
|
+
"judges": judges,
|
|
619
|
+
"pairs": pairs_total,
|
|
620
|
+
"weighted_wins": wins,
|
|
621
|
+
"correctness_decided": {"pairs": by_correctness, "wins": decided["correctness"]},
|
|
622
|
+
"judged": {"pairs": pairs_total - by_correctness, "wins": decided["judges"]},
|
|
623
|
+
"judge_stats": {
|
|
624
|
+
judge: {
|
|
625
|
+
"side_a_rate": side_a[judge] / pairs_total,
|
|
626
|
+
"agreement": agreed[judge] / comparable[judge] if comparable[judge] else None,
|
|
627
|
+
"agreement_pairs": comparable[judge],
|
|
628
|
+
}
|
|
629
|
+
for judge in judges
|
|
630
|
+
},
|
|
631
|
+
"agreement_definition": "Share of pairs where the judge's vote equals the verdict "
|
|
632
|
+
"more than half of the other judges gave; pairs without such a verdict are left out. "
|
|
633
|
+
"Side-A rate and agreement cover every pair, including correctness-decided ones.",
|
|
634
|
+
"tasks": per_task,
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _table(rows: list[list[str]]) -> list[str]:
|
|
639
|
+
widths = [max(len(row[index]) for row in rows) for index in range(len(rows[0]))]
|
|
640
|
+
return [
|
|
641
|
+
" ".join(c.ljust(w) for c, w in zip(row, widths, strict=True)).rstrip() for row in rows
|
|
642
|
+
]
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def format_tally(result: dict[str, Any]) -> str:
|
|
646
|
+
"""The tally as text: totals, one row per task, then one row per judge."""
|
|
647
|
+
wins: dict[str, float] = result["weighted_wins"]
|
|
648
|
+
arms = list(wins)
|
|
649
|
+
|
|
650
|
+
def scores(values: dict[str, float]) -> str:
|
|
651
|
+
return ", ".join(f"{arm} {values[arm]:g}" for arm in arms)
|
|
652
|
+
|
|
653
|
+
correctness, judged = result["correctness_decided"], result["judged"]
|
|
654
|
+
tasks = [["task", "pairs", *arms, "correctness-decided"]] + [
|
|
655
|
+
[
|
|
656
|
+
row["task"],
|
|
657
|
+
str(len(row["pairs"])),
|
|
658
|
+
*(f"{row['wins'][arm]:g}" for arm in arms),
|
|
659
|
+
str(sum(pair["decided_by"] == "correctness" for pair in row["pairs"])),
|
|
660
|
+
]
|
|
661
|
+
for row in result["tasks"]
|
|
662
|
+
]
|
|
663
|
+
judges = [["judge", "side-A rate", "agreement"]] + [
|
|
664
|
+
[
|
|
665
|
+
judge,
|
|
666
|
+
f"{row['side_a_rate']:.2f}",
|
|
667
|
+
"-"
|
|
668
|
+
if row["agreement"] is None
|
|
669
|
+
else f"{row['agreement']:.2f} of {row['agreement_pairs']}",
|
|
670
|
+
]
|
|
671
|
+
for judge, row in result["judge_stats"].items()
|
|
672
|
+
]
|
|
673
|
+
return "\n".join(
|
|
674
|
+
[
|
|
675
|
+
f"Weighted wins over {result['pairs']} pairs: {scores(wins)}",
|
|
676
|
+
f"Correctness-decided pairs: {correctness['pairs']} ({scores(correctness['wins'])})",
|
|
677
|
+
f"Judged pairs: {judged['pairs']} ({scores(judged['wins'])})",
|
|
678
|
+
"",
|
|
679
|
+
*_table(tasks),
|
|
680
|
+
"",
|
|
681
|
+
*_table(judges),
|
|
682
|
+
]
|
|
683
|
+
)
|