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