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/cli.py ADDED
@@ -0,0 +1,672 @@
1
+ """The pluginbench command line: init, check, run, status, stop, compare, report, review, judge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import time
13
+ from collections.abc import Callable, Iterator
14
+ from contextlib import ExitStack, contextmanager, nullcontext
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+
18
+ import typer
19
+ import yaml
20
+
21
+ from pluginbench import __version__, run_lock
22
+ from pluginbench.isolation import RuntimeDriftError
23
+ from pluginbench.evaluation import (
24
+ CheckFailed,
25
+ check_evaluation,
26
+ load_evaluation,
27
+ plan_evaluation,
28
+ plan_resume,
29
+ plan_review,
30
+ prepare_evaluation,
31
+ regenerate_report,
32
+ resume_evaluation,
33
+ review_evaluation,
34
+ )
35
+ from pluginbench.run_status import format_status, run_status
36
+
37
+ app = typer.Typer(
38
+ help="Compare native instructions, skills, and plugins with vanilla coding agents.",
39
+ no_args_is_help=True,
40
+ invoke_without_command=True,
41
+ )
42
+ tasks_app = typer.Typer(help="Browse tasks and emit a pinned evaluation YAML block.")
43
+ app.add_typer(tasks_app, name="tasks")
44
+ judge_app = typer.Typer(help="Blinded pairwise judging of two arms' matched submissions.")
45
+ app.add_typer(judge_app, name="judge")
46
+
47
+
48
+ @tasks_app.command("pick")
49
+ def pick(
50
+ dataset: str = typer.Option(
51
+ ..., "--dataset", help="Harbor dataset, e.g. datacurve/deep-swe-1-1."
52
+ ),
53
+ version: str | None = typer.Option(
54
+ None, "--version", help="Dataset version; resolved and pinned if omitted."
55
+ ),
56
+ copy: bool = typer.Option(
57
+ False, "--copy", help="Also copy the emitted tasks YAML to the clipboard."
58
+ ),
59
+ ) -> None:
60
+ """Search, preview and multiselect tasks with fzf; no evaluation calls."""
61
+ from pluginbench.task_sources import copy_yaml, pick_tasks
62
+
63
+ try:
64
+ block = pick_tasks(dataset, version)
65
+ if block is not None:
66
+ typer.echo(block, nl=False)
67
+ if copy:
68
+ copy_yaml(block)
69
+ typer.echo("Copied tasks YAML to clipboard.", err=True)
70
+ except (OSError, ValueError, ImportError, subprocess.SubprocessError) as exc:
71
+ _error(exc)
72
+
73
+
74
+ def _error(exc: Exception) -> None:
75
+ if isinstance(exc, CheckFailed):
76
+ typer.echo(_readiness_table(exc.report), err=True)
77
+ typer.echo(f"Error: {exc}", err=True)
78
+ raise typer.Exit(2)
79
+
80
+
81
+ def _readiness_table(report: dict[str, object]) -> str:
82
+ """One row per task, one column per measure or check, each cell a readiness state."""
83
+ matrix = report["matrix"]
84
+ assert isinstance(matrix, dict)
85
+ columns = list(dict.fromkeys(name for rows in matrix.values() for name in rows))
86
+ table = [["task", *columns]] + [
87
+ [task, *(str(rows.get(name, {}).get("state", "-")) for name in columns)]
88
+ for task, rows in matrix.items()
89
+ ]
90
+ widths = [max(len(row[index]) for row in table) for index in range(len(columns) + 1)]
91
+ return "\n".join(
92
+ " ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in table
93
+ )
94
+
95
+
96
+ def _vanilla_cache_lines(report: dict[str, object]) -> list[str]:
97
+ """One line per vanilla attempt: would `run` reuse it from the cache?"""
98
+ rows = report["vanilla_cache"]
99
+ assert isinstance(rows, list)
100
+ hits = sum(row["status"] == "hit" for row in rows)
101
+ return [f"Vanilla cache: {hits} of {len(rows)} attempts hit"] + [
102
+ f" {row['task_id']} repeat {row['repeat']}: {row['status']} ({row['cache_key'][:12]})"
103
+ for row in rows
104
+ ]
105
+
106
+
107
+ def _new_run_directory(prefix: str = "") -> Path:
108
+ return Path("runs") / (prefix + datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ"))
109
+
110
+
111
+ def _plan_summary(plan: dict[str, object]) -> str:
112
+ """The readable form of a plan from plan_evaluation or plan_resume."""
113
+ lines = [f"Evaluation: {plan['name']}"] if "name" in plan else []
114
+ runner = plan.get("runner")
115
+ if isinstance(runner, dict):
116
+ lines.append(
117
+ f"Runner: {runner['harness']} {runner['model']}, reasoning {runner['reasoning']}"
118
+ )
119
+ treatments = plan.get("treatments")
120
+ if isinstance(treatments, dict):
121
+ lines.append("Arms: " + ", ".join(["vanilla", *treatments]))
122
+ tasks = plan.get("tasks")
123
+ if isinstance(tasks, list):
124
+ lines.append(f"Tasks ({len(tasks)}): " + ", ".join(tasks))
125
+ for label, key in (("Repeats", "repeats"), ("Jobs", "jobs")):
126
+ if key in plan:
127
+ lines.append(f"{label}: {plan[key]}")
128
+ lines.append(f"Coding attempts: {plan['coding_calls']} Review calls: {plan['review_calls']}")
129
+ measurements = plan.get("measurements")
130
+ if isinstance(measurements, list):
131
+ lines.append("Measurements: " + (", ".join(measurements) or "none"))
132
+ capabilities = plan.get("measurement_capabilities")
133
+ if isinstance(capabilities, dict):
134
+ unavailable = [
135
+ f" {task}: {name}: {row.get('reason')}"
136
+ for task, rows in capabilities.items()
137
+ for name, row in rows.items()
138
+ if row.get("status") != "available"
139
+ ]
140
+ if unavailable:
141
+ lines += ["Unavailable measurements:", *unavailable]
142
+ if "collect_measurements" in plan:
143
+ pending = "collected after coding" if plan["collect_measurements"] else "none pending"
144
+ lines.append(f"Measurements: {pending}")
145
+ if "vanilla" in plan:
146
+ lines.append(f"Reuse: {plan['vanilla']}")
147
+ return "\n".join(lines)
148
+
149
+
150
+ def _notifier() -> str:
151
+ return "osascript" if sys.platform == "darwin" else "notify-send"
152
+
153
+
154
+ def _notify(message: str) -> None:
155
+ """Best-effort desktop notification; a failed notice never fails the run."""
156
+ if sys.platform == "darwin":
157
+ text = json.dumps(message, ensure_ascii=False)
158
+ command = [_notifier(), "-e", f'display notification {text} with title "PluginBench"']
159
+ else:
160
+ command = [_notifier(), "PluginBench", message]
161
+ try:
162
+ subprocess.run(command, capture_output=True, check=False, timeout=10)
163
+ except (OSError, subprocess.SubprocessError):
164
+ pass # No notifier installed, or it hung: the run's outcome does not depend on it.
165
+
166
+
167
+ def _notifying_progress(attempts: object) -> Callable[[str], None]:
168
+ started = False
169
+
170
+ def progress(message: str) -> None:
171
+ nonlocal started
172
+ typer.echo(message)
173
+ # The first coding attempt starts only after preparation and readiness checks pass.
174
+ if not started and message.startswith("Coding "):
175
+ started = True
176
+ _notify(f"Setup passed; {attempts} coding attempts started.")
177
+
178
+ return progress
179
+
180
+
181
+ @contextmanager
182
+ def _events(root: Path) -> Iterator[Callable[..., None]]:
183
+ """Append one JSON line per event to events.jsonl, so a background run can be followed."""
184
+ lock = threading.Lock()
185
+ with (root / "events.jsonl").open("a", encoding="utf-8") as stream:
186
+
187
+ def event(kind: str, **fields: object) -> None:
188
+ line = json.dumps({"at": datetime.now(UTC).isoformat(), "event": kind, **fields})
189
+ with lock:
190
+ stream.write(line + "\n")
191
+ stream.flush()
192
+
193
+ yield event
194
+
195
+
196
+ def _ended(event: Callable[..., None], exc: BaseException) -> None:
197
+ """Record why work ended early: `stop` (SIGTERM) and Ctrl-C stop it; anything else fails."""
198
+ if isinstance(exc, KeyboardInterrupt):
199
+ event("stopped", reason=str(exc) or "interrupted")
200
+ else:
201
+ event("failed", error=f"{type(exc).__name__}: {exc}")
202
+
203
+
204
+ def _work(
205
+ root: Path,
206
+ *,
207
+ lock: run_lock.RunLock | None,
208
+ progress: Callable[[str], None],
209
+ notify: bool,
210
+ ) -> None:
211
+ """Own the run directory and run what it has left; SIGTERM from `stop` interrupts it."""
212
+ owned = nullcontext(lock) if lock else run_lock.hold(root)
213
+ with owned as lock, _events(root) as event:
214
+ event("started", pid=os.getpid(), root=str(root))
215
+
216
+ def logged(message: str) -> None:
217
+ event("progress", message=message)
218
+ progress(message)
219
+
220
+ try:
221
+ resume_evaluation(root, progress=logged, guard=lock.assert_in_place)
222
+ except BaseException as exc:
223
+ _ended(event, exc)
224
+ if notify:
225
+ outcome = "stopped" if isinstance(exc, KeyboardInterrupt) else "failed"
226
+ _notify(f"Run {outcome}: {str(exc) or type(exc).__name__}")
227
+ raise
228
+ event("finished", report=str(root / "report/report.html"))
229
+ if notify:
230
+ _notify(f"Run finished. Report: {root / 'report/report.html'}")
231
+ typer.echo(f"Report: {root / 'report/report.html'}")
232
+ typer.echo(f"Graphable data: {root / 'report/results.json'}")
233
+
234
+
235
+ def _worker_argv(root: Path, *, notify: bool) -> list[str]:
236
+ return [
237
+ sys.executable,
238
+ "-m",
239
+ "pluginbench",
240
+ "run",
241
+ str(root),
242
+ "--yes",
243
+ "--foreground",
244
+ *(["--notify"] if notify else []),
245
+ ]
246
+
247
+
248
+ _DETACH_TIMEOUT = 30.0
249
+ _SYSTEMD_SCOPE = ["systemd-run", "--user", "--scope", "--collect"]
250
+
251
+
252
+ def _systemd_scope() -> list[str]:
253
+ """`systemd-run` arguments that start a command in its own user scope, if this host can.
254
+
255
+ A scope keeps the command's PID, environment, and output, but moves it out of the
256
+ launcher's cgroup, so stopping the launching terminal, agent session, or service does not
257
+ kill the run. Hosts without a reachable systemd user manager get an empty list.
258
+ """
259
+ if shutil.which(_SYSTEMD_SCOPE[0]) is None:
260
+ return []
261
+ try:
262
+ probe = subprocess.run(
263
+ [*_SYSTEMD_SCOPE, "--quiet", "true"],
264
+ stdin=subprocess.DEVNULL,
265
+ capture_output=True,
266
+ timeout=10,
267
+ check=False,
268
+ )
269
+ except (OSError, subprocess.SubprocessError):
270
+ return []
271
+ return _SYSTEMD_SCOPE if probe.returncode == 0 else []
272
+
273
+
274
+ def _detach(root: Path, *, notify: bool) -> None:
275
+ """Start the run in its own session and user scope; return once it holds run.lock."""
276
+ log = root / "run.log"
277
+ scope = _systemd_scope()
278
+ with log.open("ab") as output:
279
+ # systemd-run execs the worker in place, so the child's PID is the worker's.
280
+ child = subprocess.Popen(
281
+ [*scope, *_worker_argv(root, notify=notify)],
282
+ stdin=subprocess.DEVNULL,
283
+ stdout=output,
284
+ stderr=subprocess.STDOUT,
285
+ start_new_session=True,
286
+ )
287
+ deadline = time.monotonic() + _DETACH_TIMEOUT
288
+ while (run_lock.owner(root) or {}).get("pid") != child.pid:
289
+ if child.poll() is not None:
290
+ raise ValueError(
291
+ f"background run exited with code {child.returncode} before it started; see {log}"
292
+ )
293
+ if time.monotonic() > deadline:
294
+ raise ValueError(f"background run did not start within {_DETACH_TIMEOUT:g}s; see {log}")
295
+ time.sleep(0.05)
296
+ where = "a systemd user scope" if scope else "a new session"
297
+ typer.echo(f"Started run {root} in the background (process group {child.pid}, in {where}).")
298
+ typer.echo(f"Watch it: pluginbench status {root} --watch")
299
+ typer.echo(f"Events: {root / 'events.jsonl'} Log: {log}")
300
+ typer.echo(f"Stop it: pluginbench stop {root}")
301
+ typer.echo(f"Resume it after a stop or crash: pluginbench run {root}")
302
+
303
+
304
+ @app.callback()
305
+ def main(version: bool = typer.Option(False, "--version", is_eager=True)) -> None:
306
+ if version:
307
+ typer.echo(__version__)
308
+ raise typer.Exit()
309
+
310
+
311
+ @app.command("init")
312
+ def init(
313
+ config: Path = typer.Argument(
314
+ Path("evaluation.yaml"), help="Where to write the starter evaluation config."
315
+ ),
316
+ ) -> None:
317
+ """Write a starter evaluation config to edit; no checks and no model calls."""
318
+ try:
319
+ if config.exists():
320
+ raise ValueError(f"{config} already exists; edit it, or give another path")
321
+ starter = Path(__file__).with_name("evaluation.example.yaml").read_text()
322
+ with config.open("x", encoding="utf-8") as stream:
323
+ stream.write(starter)
324
+ except (OSError, ValueError) as exc:
325
+ _error(exc)
326
+ typer.echo(f"Wrote {config}. Replace its /path/to placeholders, then run:")
327
+ typer.echo(f" pluginbench check {config}")
328
+
329
+
330
+ @app.command("run")
331
+ def run(
332
+ target: Path = typer.Argument(
333
+ ...,
334
+ help="Evaluation YAML or JSON to start, or a run directory to resume.",
335
+ ),
336
+ output: Path | None = typer.Option(
337
+ None, "--output", help="Directory for a new run; default runs/<timestamp>."
338
+ ),
339
+ dry_run: bool = typer.Option(
340
+ False,
341
+ "--dry-run",
342
+ help="Show what would run; no preparation and no model calls.",
343
+ ),
344
+ as_json: bool = typer.Option(False, "--json", help="With --dry-run, print the plan as JSON."),
345
+ yes: bool = typer.Option(False, "--yes", help="Start without confirmation."),
346
+ notify: bool = typer.Option(
347
+ False,
348
+ "--notify",
349
+ help="Desktop notifications when coding starts and when the run finishes or fails.",
350
+ ),
351
+ foreground: bool = typer.Option(
352
+ False,
353
+ "--foreground",
354
+ help="Run in this process instead of in the background.",
355
+ ),
356
+ ) -> None:
357
+ """Start a run from a config, or resume a run directory.
358
+
359
+ Checks run first, in this process. The run then continues in the background unless
360
+ --foreground is set. Every setting comes from the config; a resume uses the saved one.
361
+ """
362
+ owned = ExitStack()
363
+ lock: run_lock.RunLock | None = None
364
+ try:
365
+ if as_json and not dry_run:
366
+ raise ValueError("--json prints the dry-run plan; add --dry-run")
367
+ if not target.exists():
368
+ raise ValueError(
369
+ f"{target} does not exist; give an evaluation YAML or JSON, or a run directory"
370
+ )
371
+ evaluation = None
372
+ if target.is_dir():
373
+ if output is not None:
374
+ raise ValueError(
375
+ f"{target} is a run directory, which resumes in place; "
376
+ "--output names the directory for a new run from a config"
377
+ )
378
+ if foreground and not dry_run:
379
+ # Own the run before planning, so a background start never races its parent.
380
+ lock = owned.enter_context(run_lock.hold(target))
381
+ plan = plan_resume(target)
382
+ else:
383
+ evaluation = load_evaluation(target)
384
+ plan = plan_evaluation(evaluation)
385
+ if dry_run:
386
+ typer.echo(json.dumps(plan, indent=2) if as_json else _plan_summary(plan))
387
+ return
388
+ typer.echo(_plan_summary(plan))
389
+ if notify and shutil.which(_notifier()) is None:
390
+ typer.echo(
391
+ f"Warning: --notify needs {_notifier()}, which is not on PATH; "
392
+ "no notification will be sent.",
393
+ err=True,
394
+ )
395
+ if not yes and not typer.confirm("Start evaluation?"):
396
+ raise typer.Exit()
397
+ progress = _notifying_progress(plan["coding_calls"]) if notify else typer.echo
398
+ if evaluation is None:
399
+ root = target.expanduser().resolve()
400
+ else:
401
+ destination = output or _new_run_directory()
402
+ try:
403
+ root = prepare_evaluation(evaluation, destination, progress=typer.echo)
404
+ except BaseException as exc:
405
+ if notify:
406
+ _notify(f"Run failed: {str(exc) or type(exc).__name__}")
407
+ raise
408
+ if foreground:
409
+ _work(root, lock=lock, progress=progress, notify=notify)
410
+ else:
411
+ _detach(root, notify=notify)
412
+ except (
413
+ OSError,
414
+ ValueError,
415
+ yaml.YAMLError,
416
+ subprocess.SubprocessError,
417
+ RuntimeDriftError,
418
+ ) as exc:
419
+ _error(exc)
420
+ finally:
421
+ owned.close()
422
+
423
+
424
+ @app.command("check")
425
+ def check(
426
+ config: Path = typer.Argument(
427
+ ..., help="Evaluation YAML or JSON; relative paths use its directory."
428
+ ),
429
+ output: Path | None = typer.Option(
430
+ None, "--output", help="New directory for prepared inputs and check.json."
431
+ ),
432
+ ) -> None:
433
+ """Prepare tasks and run every no-model check; exit 2 if the run would fail or lose data."""
434
+ try:
435
+ destination = output or _new_run_directory("check-")
436
+ report = check_evaluation(load_evaluation(config), destination, progress=typer.echo)
437
+ typer.echo("\n".join(_vanilla_cache_lines(report)))
438
+ typer.echo(_readiness_table(report))
439
+ typer.echo(f"Ready. Check: {destination / 'check.json'}")
440
+ except (
441
+ OSError,
442
+ ValueError,
443
+ yaml.YAMLError,
444
+ subprocess.SubprocessError,
445
+ RuntimeDriftError,
446
+ ) as exc:
447
+ _error(exc)
448
+
449
+
450
+ @app.command("status")
451
+ def status(
452
+ run: Path = typer.Argument(..., help="A run directory started by `pluginbench run`."),
453
+ watch: bool = typer.Option(
454
+ False, "--watch", help="Redraw every 2 seconds; exit when the run ends or is stopped."
455
+ ),
456
+ as_json: bool = typer.Option(
457
+ False, "--json", help="Print one JSON object (schema in docs/running-evaluations.md)."
458
+ ),
459
+ ) -> None:
460
+ """Show a run's phase, attempts, outcomes, tokens, reuse and measures; never writes."""
461
+ try:
462
+ if watch and as_json:
463
+ raise ValueError("--watch redraws the table; for JSON, run status --json repeatedly")
464
+ decode_errors = 0
465
+ while True:
466
+ try:
467
+ current = run_status(run)
468
+ except json.JSONDecodeError:
469
+ # A settings or report file caught mid-write; skip this redraw, not the watch.
470
+ decode_errors += 1
471
+ if not watch or decode_errors > _WATCH_DECODE_RETRIES:
472
+ raise
473
+ time.sleep(_WATCH_INTERVAL)
474
+ continue
475
+ decode_errors = 0
476
+ if as_json:
477
+ typer.echo(json.dumps(current, indent=2))
478
+ return
479
+ if watch and sys.stdout.isatty():
480
+ typer.echo("\033[H\033[2J", nl=False) # Clear the screen before redrawing.
481
+ typer.echo(format_status(current))
482
+ if not watch or not current["active"]:
483
+ return
484
+ time.sleep(_WATCH_INTERVAL)
485
+ except (OSError, ValueError) as exc:
486
+ _error(exc)
487
+
488
+
489
+ _WATCH_INTERVAL = 2.0
490
+ _WATCH_DECODE_RETRIES = 5
491
+
492
+
493
+ @app.command("stop")
494
+ def stop(
495
+ run: Path = typer.Argument(..., help="A run directory started by `pluginbench run`."),
496
+ ) -> None:
497
+ """Stop a run: SIGTERM only the process group its run.lock records; nothing else."""
498
+ try:
499
+ owner = run_lock.stop(run)
500
+ except (OSError, ValueError) as exc:
501
+ _error(exc)
502
+ return
503
+ if owner is None:
504
+ typer.echo(f"No active run owns {run}; nothing signalled.")
505
+ else:
506
+ typer.echo(
507
+ f"Sent SIGTERM to process group {owner['pgid']}. Attempts already coding finish "
508
+ "and are kept; no new attempt starts."
509
+ )
510
+
511
+
512
+ @app.command("compare")
513
+ def compare(
514
+ runs: list[str] = typer.Argument(
515
+ ...,
516
+ help="Evaluation directories. DIR=ARM,ARM takes only those packages from DIR.",
517
+ ),
518
+ gates: Path = typer.Option(..., "--gates", help="Gates YAML: splits and gate rules."),
519
+ ) -> None:
520
+ """Print per-package measures and gate results from saved reports; no model calls."""
521
+ from pluginbench.compare import compare as compare_runs
522
+
523
+ try:
524
+ typer.echo(compare_runs(runs, gates))
525
+ except (OSError, ValueError, yaml.YAMLError) as exc:
526
+ _error(exc)
527
+
528
+
529
+ @app.command("report")
530
+ def report(
531
+ run: Path = typer.Argument(..., help="A run directory started by `pluginbench run`."),
532
+ ) -> None:
533
+ """Rebuild JSON, CSV and charts from saved evidence; no model calls."""
534
+ try:
535
+ path = regenerate_report(run)
536
+ typer.echo(f"Report: {path.parent / 'report.html'}")
537
+ unfinished = json.loads(path.read_text()).get("unfinished_run")
538
+ if unfinished:
539
+ resume = shlex.join(["pluginbench", "run", str(run)])
540
+ typer.echo(
541
+ f"This run is not finished: {unfinished['complete']} of "
542
+ f"{unfinished['assigned']} attempts are complete, so the report is partial. "
543
+ f"Finish it with: {resume}",
544
+ err=True,
545
+ )
546
+ except (OSError, ValueError, yaml.YAMLError) as exc:
547
+ _error(exc)
548
+
549
+
550
+ @app.command("review")
551
+ def review(
552
+ run: Path = typer.Argument(..., help="A finished run directory."),
553
+ revision: str = typer.Option(
554
+ ..., "--revision", help="Name for the new review revision, such as review-2."
555
+ ),
556
+ yes: bool = typer.Option(False, "--yes", help="Start without confirmation."),
557
+ ) -> None:
558
+ """Review every submission again with the run's frozen reviewer and criteria.
559
+
560
+ Earlier review revisions stay in evidence. The new one is selected and the report rebuilt.
561
+ """
562
+ try:
563
+ with run_lock.hold(run):
564
+ plan = plan_review(run, revision)
565
+ typer.echo(f"{plan['review_calls']} review calls as review revision {revision}.")
566
+ if not yes and not typer.confirm("Start review?"):
567
+ raise typer.Exit()
568
+ _review(run, revision)
569
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
570
+ _error(exc)
571
+
572
+
573
+ def _review(run: Path, revision: str) -> None:
574
+ """Run a planned review while this process owns the run, recording it in events.jsonl."""
575
+ with _events(run) as event:
576
+ event("started", command="review", pid=os.getpid(), revision=revision)
577
+
578
+ def logged(message: str) -> None:
579
+ event("progress", message=message)
580
+ typer.echo(message)
581
+
582
+ try:
583
+ selected = review_evaluation(run, revision, progress=logged)
584
+ except BaseException as exc:
585
+ _ended(event, exc)
586
+ raise
587
+ event("finished", command="review", revision=selected)
588
+ typer.echo(f"Selected review revision {selected}.")
589
+ typer.echo(f"Report: {run / 'report/report.html'}")
590
+
591
+
592
+ @judge_app.command("prepare")
593
+ def judge_prepare(
594
+ arms: list[str] = typer.Argument(
595
+ ..., help="Two RUN_DIR=ARM specs; both may name the same run."
596
+ ),
597
+ tasks: str = typer.Option(..., "--tasks", help="Comma-separated task names to judge."),
598
+ exclude: str = typer.Option(
599
+ "", "--exclude", help="Comma-separated task names to refuse, such as holdout tasks."
600
+ ),
601
+ criteria: Path = typer.Option(..., "--criteria", help="The review standard judges apply."),
602
+ seed: int = typer.Option(..., "--seed", help="Seed for each pair's A/B order."),
603
+ output: Path = typer.Option(..., "--output", help="New directory for briefs and the key."),
604
+ ) -> None:
605
+ """Write blinded judge briefs for each task and repeat both arms have; no model calls."""
606
+ from pluginbench.judge import prepare
607
+
608
+ def names(value: str) -> list[str]:
609
+ return [name.strip() for name in value.split(",") if name.strip()]
610
+
611
+ try:
612
+ directory = prepare(
613
+ arms=arms,
614
+ tasks=names(tasks),
615
+ exclude=names(exclude),
616
+ criteria=criteria,
617
+ seed=seed,
618
+ output=output,
619
+ )
620
+ except (OSError, ValueError, KeyError) as exc:
621
+ _error(exc)
622
+ return
623
+ typer.echo(f"Prepared {directory}. The key is {directory / 'key.json'}; keep it from judges.")
624
+ omitted = json.loads((directory / "key.json").read_text()).get("omitted") or []
625
+ if omitted:
626
+ slots = ", ".join(f"{row['arm']} {row['task']} repeat {row['repeat']}" for row in omitted)
627
+ typer.echo(
628
+ f"Left out, with their pairs, the attempts that have no saved submission: {slots}.",
629
+ err=True,
630
+ )
631
+
632
+
633
+ @judge_app.command("run")
634
+ def judge_run(
635
+ directory: Path = typer.Argument(..., help="A directory from `pluginbench judge prepare`."),
636
+ judges: list[str] = typer.Option(
637
+ ..., "--judge", help="HARNESS:MODEL:EFFORT, such as codex:gpt-6-astra:high; repeatable."
638
+ ),
639
+ timeout: int = typer.Option(900, "--timeout", help="Seconds per judge call."),
640
+ jobs: int = typer.Option(1, "--jobs", help="Judge calls at once, 1 to 16."),
641
+ ) -> None:
642
+ """Make one read-only call per judge and task; completed calls are skipped."""
643
+ from pluginbench.judge import run as run_judges
644
+
645
+ try:
646
+ failed = run_judges(
647
+ directory, judges=judges, timeout=timeout, jobs=jobs, progress=typer.echo
648
+ )
649
+ except (OSError, ValueError, KeyError) as exc:
650
+ _error(exc)
651
+ return
652
+ if failed:
653
+ typer.echo(f"{failed} judge calls failed; run again to retry them.", err=True)
654
+ raise typer.Exit(2)
655
+ typer.echo(f"All judge calls complete. Tally: pluginbench judge tally {directory}")
656
+
657
+
658
+ @judge_app.command("tally")
659
+ def judge_tally(
660
+ directory: Path = typer.Argument(..., help="A directory whose judge calls are complete."),
661
+ ) -> None:
662
+ """Unblind with the key and score each pair; writes tally.json; no model calls."""
663
+ from pluginbench.judge import format_tally, tally
664
+
665
+ try:
666
+ result = tally(directory)
667
+ (directory / "tally.json").write_text(json.dumps(result, indent=2) + "\n")
668
+ except (OSError, ValueError, KeyError) as exc:
669
+ _error(exc)
670
+ return
671
+ typer.echo(format_tally(result))
672
+ typer.echo(f"\nTally: {directory / 'tally.json'}")