benchkit-cli 0.1.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 (149) hide show
  1. benchkit/__init__.py +1 -0
  2. benchkit/_pi_proxy.py +219 -0
  3. benchkit/answer_key_guard.ts +49 -0
  4. benchkit/artifacts.py +87 -0
  5. benchkit/benchmarks/__init__.py +105 -0
  6. benchkit/benchmarks/aider_polyglot.py +405 -0
  7. benchkit/benchmarks/arc.py +52 -0
  8. benchkit/benchmarks/banking77.py +149 -0
  9. benchkit/benchmarks/base.py +19 -0
  10. benchkit/benchmarks/boolq.py +49 -0
  11. benchkit/benchmarks/chartqa.py +133 -0
  12. benchkit/benchmarks/code_verifier.py +77 -0
  13. benchkit/benchmarks/downloads.py +166 -0
  14. benchkit/benchmarks/evalplus.py +288 -0
  15. benchkit/benchmarks/git_surgery.py +485 -0
  16. benchkit/benchmarks/gpqa.py +56 -0
  17. benchkit/benchmarks/gsm8k.py +97 -0
  18. benchkit/benchmarks/hellaswag.py +49 -0
  19. benchkit/benchmarks/humaneval.py +103 -0
  20. benchkit/benchmarks/ifeval.py +56 -0
  21. benchkit/benchmarks/ifeval_instructions.py +477 -0
  22. benchkit/benchmarks/mbpp.py +60 -0
  23. benchkit/benchmarks/mc_arena.py +479 -0
  24. benchkit/benchmarks/mcq.py +94 -0
  25. benchkit/benchmarks/medfailbench.py +537 -0
  26. benchkit/benchmarks/mmlu.py +54 -0
  27. benchkit/benchmarks/mmlu_pro.py +62 -0
  28. benchkit/benchmarks/mmvp.py +115 -0
  29. benchkit/benchmarks/ocrbench.py +96 -0
  30. benchkit/benchmarks/openbookqa.py +52 -0
  31. benchkit/benchmarks/patcheval.py +929 -0
  32. benchkit/benchmarks/piqa.py +52 -0
  33. benchkit/benchmarks/ruler.py +172 -0
  34. benchkit/benchmarks/ruler_generator.py +535 -0
  35. benchkit/benchmarks/sanity.py +174 -0
  36. benchkit/benchmarks/treejs_arena.py +268 -0
  37. benchkit/benchmarks/truthfulqa.py +52 -0
  38. benchkit/benchmarks/utils.py +25 -0
  39. benchkit/benchmarks/vision.py +230 -0
  40. benchkit/benchmarks/vstar.py +108 -0
  41. benchkit/benchmarks/winogrande.py +52 -0
  42. benchkit/benchmarks/xstest.py +116 -0
  43. benchkit/browser.py +429 -0
  44. benchkit/catalog.py +121 -0
  45. benchkit/cli.py +1000 -0
  46. benchkit/client.py +1413 -0
  47. benchkit/controls.py +87 -0
  48. benchkit/datasets/MMLU_LICENSE.txt +21 -0
  49. benchkit/datasets/README.md +142 -0
  50. benchkit/datasets/arc_challenge.jsonl +1172 -0
  51. benchkit/datasets/banking77.jsonl +3080 -0
  52. benchkit/datasets/banking77_intents.json +79 -0
  53. benchkit/datasets/boolq.jsonl +3270 -0
  54. benchkit/datasets/code_verifier.jsonl +500 -0
  55. benchkit/datasets/gpqa_diamond.jsonl +198 -0
  56. benchkit/datasets/gsm8k.jsonl +1319 -0
  57. benchkit/datasets/hellaswag.jsonl +1000 -0
  58. benchkit/datasets/humaneval.jsonl +164 -0
  59. benchkit/datasets/ifeval.jsonl +541 -0
  60. benchkit/datasets/mbpp.jsonl +500 -0
  61. benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
  62. benchkit/datasets/medfailbench_hard30.jsonl +30 -0
  63. benchkit/datasets/medfailbench_hard30.md +13 -0
  64. benchkit/datasets/openbookqa.jsonl +500 -0
  65. benchkit/datasets/piqa.jsonl +1838 -0
  66. benchkit/datasets/sanity_code.jsonl +6 -0
  67. benchkit/datasets/truthfulqa.jsonl +817 -0
  68. benchkit/datasets/winogrande.jsonl +1267 -0
  69. benchkit/datasets/xstest.csv +451 -0
  70. benchkit/decision.py +194 -0
  71. benchkit/demo.py +451 -0
  72. benchkit/dockerfiles.py +247 -0
  73. benchkit/engine.py +1270 -0
  74. benchkit/evaluation.py +195 -0
  75. benchkit/events.py +141 -0
  76. benchkit/executor.py +96 -0
  77. benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
  78. benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
  79. benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
  80. benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
  81. benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
  82. benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
  83. benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
  84. benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
  85. benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
  86. benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
  87. benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
  88. benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
  89. benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
  90. benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
  91. benchkit/git_surgery/secret-in-history/README.md +27 -0
  92. benchkit/git_surgery/secret-in-history/setup.sh +107 -0
  93. benchkit/git_surgery/secret-in-history/verify.sh +106 -0
  94. benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
  95. benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
  96. benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
  97. benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
  98. benchkit/history.py +474 -0
  99. benchkit/jobs.py +332 -0
  100. benchkit/leakage.py +51 -0
  101. benchkit/looping.py +873 -0
  102. benchkit/mc_render.py +274 -0
  103. benchkit/mc_viewer/dist/atlas.png +0 -0
  104. benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
  105. benchkit/mc_viewer/dist/version.json +4 -0
  106. benchkit/mc_viewer/dist/viewer.html +23 -0
  107. benchkit/mc_viewer/dist/viewer.js +2 -0
  108. benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
  109. benchkit/mc_viewer/dist/worker.js +2 -0
  110. benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
  111. benchkit/metrics.py +100 -0
  112. benchkit/ollama.py +60 -0
  113. benchkit/perf.py +353 -0
  114. benchkit/perf_report.py +207 -0
  115. benchkit/perturbations.py +208 -0
  116. benchkit/pi_agent.py +476 -0
  117. benchkit/pi_package/package-lock.json +1827 -0
  118. benchkit/pi_package/package.json +8 -0
  119. benchkit/report.py +142 -0
  120. benchkit/report_markdown.py +645 -0
  121. benchkit/results.py +688 -0
  122. benchkit/retry.py +163 -0
  123. benchkit/runner.py +769 -0
  124. benchkit/sandbox.py +1167 -0
  125. benchkit/template_check.py +291 -0
  126. benchkit/templates/arena.html +504 -0
  127. benchkit/templates/history.html +58 -0
  128. benchkit/templates/perf.html +509 -0
  129. benchkit/templates/report.html +769 -0
  130. benchkit/tui/__init__.py +5 -0
  131. benchkit/tui/app.py +87 -0
  132. benchkit/tui/app.tcss +512 -0
  133. benchkit/tui/formatting.py +84 -0
  134. benchkit/tui/screens/__init__.py +1 -0
  135. benchkit/tui/screens/connect.py +191 -0
  136. benchkit/tui/screens/detail.py +288 -0
  137. benchkit/tui/screens/help.py +84 -0
  138. benchkit/tui/screens/modals.py +544 -0
  139. benchkit/tui/screens/results.py +361 -0
  140. benchkit/tui/screens/run.py +988 -0
  141. benchkit/tui/screens/setup.py +733 -0
  142. benchkit/tui/theme.py +135 -0
  143. benchkit/tui/widgets.py +120 -0
  144. benchkit_cli-0.1.0.dist-info/METADATA +294 -0
  145. benchkit_cli-0.1.0.dist-info/RECORD +149 -0
  146. benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
  147. benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  148. benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  149. benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
benchkit/cli.py ADDED
@@ -0,0 +1,1000 @@
1
+ """BenchKit entry point.
2
+
3
+ Running `benchkit` opens the full-screen terminal app. `--headless` keeps a
4
+ scriptable path that prints to stdout instead.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from dotenv import load_dotenv
13
+ from rich import box
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+
18
+ from benchkit import catalog
19
+ from benchkit.benchmarks import DESCRIPTIONS, REGISTRY
20
+ from benchkit.client import InferenceClient
21
+ from benchkit.engine import (
22
+ MAX_REPAIR_ATTEMPTS,
23
+ JobSpec,
24
+ SliceError,
25
+ expand_jobs,
26
+ parse_slice,
27
+ route_decision_jobs,
28
+ slice_task_count,
29
+ task_count,
30
+ )
31
+ from benchkit.history import serve_history
32
+ from benchkit.jobs import job_matrix
33
+ from benchkit.metrics import (
34
+ aggregate_tok_s,
35
+ decision_speed,
36
+ effective_concurrency,
37
+ is_decision,
38
+ latency_text,
39
+ stream_tok_s,
40
+ )
41
+ from benchkit.perf import DEFAULT_DEPTHS, PerfConfig, parse_depths, run_profile
42
+ from benchkit.perf_report import save_profile
43
+ from benchkit.perturbations import (
44
+ PERTURBATIONS,
45
+ perturbations_for,
46
+ unsupported_benchmarks,
47
+ )
48
+ from benchkit.report import save
49
+ from benchkit.runner import run
50
+ from benchkit.tui import run_tui
51
+
52
+ console = Console(highlight=False)
53
+
54
+
55
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
56
+ parser = argparse.ArgumentParser(
57
+ prog="benchkit",
58
+ description="Benchmark local LLMs with real evaluation suites.",
59
+ )
60
+ parser.add_argument(
61
+ "command",
62
+ nargs="?",
63
+ choices=("perf", "history", "render-check", "datasets"),
64
+ help=(
65
+ "Run a performance profile, browse historical results, check "
66
+ "that headless rendering works on this machine, or list and "
67
+ "install benchmark datasets"
68
+ ),
69
+ )
70
+ parser.add_argument(
71
+ "perf_model",
72
+ nargs="?",
73
+ metavar="MODEL",
74
+ help=(
75
+ "Model name for `benchkit perf MODEL`; `install` for "
76
+ "`benchkit datasets install [BENCHMARK ...]`"
77
+ ),
78
+ )
79
+ parser.add_argument(
80
+ "targets",
81
+ nargs="*",
82
+ metavar="BENCHMARK",
83
+ help="Datasets: benchmarks to install (default: every missing one)",
84
+ )
85
+ parser.add_argument("--host", help="Inference host, overrides BENCHKIT_HOST")
86
+ parser.add_argument(
87
+ "--demo",
88
+ action="store_true",
89
+ help="Run against a built-in offline model server (no inference server needed)",
90
+ )
91
+ parser.add_argument(
92
+ "--headless",
93
+ action="store_true",
94
+ help="Skip the TUI and run from flags, printing progress to stdout",
95
+ )
96
+ parser.add_argument(
97
+ "--models",
98
+ default="",
99
+ help="Headless: comma separated model names, or 'all'",
100
+ )
101
+ parser.add_argument(
102
+ "--benchmarks",
103
+ default="",
104
+ help=(
105
+ "Headless: comma separated benchmarks, e.g. humaneval:20,gsm8k:-50 "
106
+ "or aider-polyglot:cpp:5"
107
+ ),
108
+ )
109
+ parser.add_argument(
110
+ "--perturbation",
111
+ choices=PERTURBATIONS,
112
+ help=("Headless: add a paired perturbed run alongside each clean baseline"),
113
+ )
114
+ parser.add_argument(
115
+ "--perturbation-seed",
116
+ type=int,
117
+ default=42,
118
+ metavar="SEED",
119
+ help="Headless: deterministic perturbation seed (default: 42)",
120
+ )
121
+ parser.add_argument(
122
+ "--harness",
123
+ choices=("direct", "pi", "both"),
124
+ default="direct",
125
+ help=(
126
+ "Execution harness: raw API, latest stock Pi in Docker, or paired "
127
+ "direct + Pi runs (default: direct)"
128
+ ),
129
+ )
130
+ parser.add_argument(
131
+ "--decision-models",
132
+ default="",
133
+ metavar="MODELS",
134
+ help=(
135
+ "Comma-separated models to run as decision models over "
136
+ "/v1/systemone, for servers or proxies that do not advertise them "
137
+ "(llama.cpp's own listing is detected automatically)"
138
+ ),
139
+ )
140
+ parser.add_argument(
141
+ "--repair-attempts",
142
+ type=int,
143
+ choices=range(MAX_REPAIR_ATTEMPTS + 1),
144
+ default=0,
145
+ metavar=f"0-{MAX_REPAIR_ATTEMPTS}",
146
+ help=(
147
+ "Verifier-feedback repair turns after an incorrect first answer; "
148
+ "works with direct and Pi harnesses (default: 0; max: "
149
+ f"{MAX_REPAIR_ATTEMPTS})"
150
+ ),
151
+ )
152
+ parser.add_argument(
153
+ "--force-unload",
154
+ action="store_true",
155
+ help=(
156
+ "Headless: evict each model from VRAM between models "
157
+ "(llama.cpp POST /models/unload; Ollama keep_alive=0)"
158
+ ),
159
+ )
160
+ parser.add_argument(
161
+ "--list",
162
+ action="store_true",
163
+ help="Print the available benchmarks and exit",
164
+ )
165
+ parser.add_argument(
166
+ "-v",
167
+ "--verbose",
168
+ action="store_true",
169
+ help="Headless: print every prompt and response",
170
+ )
171
+ parser.add_argument(
172
+ "--depths",
173
+ default=",".join(
174
+ "minimal" if value == 0 else f"{value // 1024}k" for value in DEFAULT_DEPTHS
175
+ ),
176
+ help="Perf: comma-separated context depths (default: minimal,4k,16k,32k,64k)",
177
+ )
178
+ parser.add_argument(
179
+ "--gen",
180
+ type=int,
181
+ default=128,
182
+ metavar="TOKENS",
183
+ help="Perf: generated tokens per measured run (default: 128)",
184
+ )
185
+ parser.add_argument(
186
+ "--reps",
187
+ type=int,
188
+ default=5,
189
+ help="Perf: measured repetitions per depth (default: 5)",
190
+ )
191
+ parser.add_argument(
192
+ "--warmups",
193
+ type=int,
194
+ default=1,
195
+ help="Perf: discarded warmups per depth (default: 1)",
196
+ )
197
+ parser.add_argument(
198
+ "--config-note",
199
+ default="",
200
+ help="Perf: optional hardware or server configuration note",
201
+ )
202
+ parser.add_argument(
203
+ "--results-dir",
204
+ action="append",
205
+ default=None,
206
+ metavar="PATH",
207
+ help=(
208
+ "History: results directory to scan; repeat to combine archives "
209
+ "(default: ./results)"
210
+ ),
211
+ )
212
+ parser.add_argument(
213
+ "--no-open",
214
+ action="store_true",
215
+ help="History: print the local URL without opening a browser",
216
+ )
217
+ parser.add_argument(
218
+ "--port",
219
+ type=int,
220
+ default=0,
221
+ metavar="PORT",
222
+ help="History: local dashboard port; 0 chooses a free port (default: 0)",
223
+ )
224
+ return parser.parse_args(argv)
225
+
226
+
227
+ def _fmt_time(seconds: float) -> str:
228
+ seconds = round(seconds)
229
+ if seconds >= 60:
230
+ return f"{seconds // 60}m {seconds % 60}s"
231
+ return f"{seconds}s"
232
+
233
+
234
+ def _score_text(score: float) -> Text:
235
+ if score >= 80:
236
+ style = "bold green"
237
+ elif score < 50:
238
+ style = "bold red"
239
+ else:
240
+ style = "bold"
241
+ return Text(f"{score:.1f}%", style=style)
242
+
243
+
244
+ def _outcome_counts(result: dict) -> tuple[int, int, int]:
245
+ """Return pass, ordinary failure and error-task counts for a result."""
246
+ tasks = result.get("tasks", [])
247
+ failed = sum(not task.get("passed") and not task.get("error") for task in tasks)
248
+ errors = sum(not task.get("passed") and bool(task.get("error")) for task in tasks)
249
+ return int(result["passed"]), failed, errors
250
+
251
+
252
+ def _list_benchmarks() -> None:
253
+ table = Table(box=box.MINIMAL, border_style="dim", header_style="bold")
254
+ table.add_column("Benchmark")
255
+ table.add_column("Tasks", justify="right")
256
+ table.add_column("Description")
257
+ table.add_column("Perturbations")
258
+ table.add_column("Notes")
259
+ for key in REGISTRY:
260
+ try:
261
+ declared_label = getattr(REGISTRY[key], "list_task_count", "")
262
+ count = declared_label or f"{task_count(key):,}"
263
+ except Exception:
264
+ count = "?"
265
+ note = getattr(REGISTRY[key], "list_note", "")
266
+ table.add_row(
267
+ key,
268
+ count,
269
+ DESCRIPTIONS.get(key, ""),
270
+ f"[cyan]{' '.join(perturbations_for(key))}[/cyan]",
271
+ f"[yellow]{note}[/yellow]" if note else "",
272
+ )
273
+ console.print(table)
274
+
275
+
276
+ def _datasets_table() -> None:
277
+ table = Table(box=box.MINIMAL, border_style="dim", header_style="bold")
278
+ table.add_column("Benchmark")
279
+ table.add_column("Status")
280
+ table.add_column("Size", justify="right")
281
+ table.add_column("Source")
282
+ labels = {
283
+ "bundled": "[dim]bundled[/dim]",
284
+ "installed": "[green]✓ installed[/green]",
285
+ "download": "[yellow]✗ missing[/yellow]",
286
+ "manual": "[red]✗ manual setup[/red]",
287
+ }
288
+ entries = catalog.statuses()
289
+ for entry in entries:
290
+ state = (
291
+ entry.kind
292
+ if entry.kind == "bundled" or not entry.installed
293
+ else "installed"
294
+ )
295
+ source = entry.summary
296
+ if entry.kind == "manual" and not entry.installed and entry.hint:
297
+ source += f"\n[dim]{entry.hint}[/dim]"
298
+ table.add_row(
299
+ entry.benchmark,
300
+ labels[state],
301
+ catalog.size_label(entry),
302
+ source,
303
+ )
304
+ console.print(table)
305
+ missing = catalog.missing()
306
+ if missing:
307
+ console.print(
308
+ f"[yellow]{len(missing)} missing:[/yellow] {', '.join(missing)} "
309
+ "[dim]· run 'benchkit datasets install' to fetch them all[/dim]"
310
+ )
311
+ else:
312
+ console.print("[green]Every downloadable dataset is installed.[/green]")
313
+
314
+
315
+ def _install_datasets(keys: list[str]) -> bool:
316
+ """Install each benchmark's data with a progress line. True if all worked."""
317
+ from rich.progress import (
318
+ BarColumn,
319
+ DownloadColumn,
320
+ Progress,
321
+ SpinnerColumn,
322
+ TextColumn,
323
+ )
324
+
325
+ ok = True
326
+ for key in keys:
327
+ entry = catalog.status(key)
328
+ if entry.installed:
329
+ note = "bundled" if entry.kind == "bundled" else "already installed"
330
+ console.print(f"[dim]{key}: {note}[/dim]")
331
+ continue
332
+ with Progress(
333
+ SpinnerColumn(),
334
+ TextColumn("{task.description}"),
335
+ BarColumn(),
336
+ DownloadColumn(),
337
+ console=console,
338
+ transient=True,
339
+ ) as progress:
340
+ task = progress.add_task(f"Installing {key}", total=entry.size)
341
+ try:
342
+ catalog.install(
343
+ key,
344
+ lambda done, total, task=task: progress.update(
345
+ task, completed=done, total=total
346
+ ),
347
+ )
348
+ except Exception as exc:
349
+ ok = False
350
+ progress.stop()
351
+ console.print(f"[red]✗ {key}:[/red] {exc}")
352
+ continue
353
+ console.print(f"[green]✓ {key}[/green] [dim]{entry.location}[/dim]")
354
+ return ok
355
+
356
+
357
+ def _datasets(args: argparse.Namespace) -> None:
358
+ if args.perf_model is None:
359
+ _datasets_table()
360
+ return
361
+ if args.perf_model != "install":
362
+ console.print(
363
+ f"[red]Unknown datasets action:[/red] {args.perf_model} "
364
+ "[dim]· use 'benchkit datasets' or 'benchkit datasets install'[/dim]"
365
+ )
366
+ sys.exit(1)
367
+ unknown = [key for key in args.targets if key not in REGISTRY]
368
+ if unknown:
369
+ console.print(f"[red]Unknown benchmark(s):[/red] {', '.join(unknown)}")
370
+ sys.exit(1)
371
+ keys = args.targets or catalog.missing()
372
+ if not keys:
373
+ console.print("[green]Every downloadable dataset is already installed.[/green]")
374
+ return
375
+ if not _install_datasets(keys):
376
+ sys.exit(1)
377
+
378
+
379
+ def _render_check() -> None:
380
+ """Report whether this machine can run the suites that draw pictures.
381
+
382
+ Every treejs-arena task depends on two things BenchKit does not control:
383
+ a headless browser that can produce a WebGL canvas, and a route to the
384
+ module CDN the generated page imports from. This checks both, so a broken
385
+ server is diagnosed in one command instead of ten scored-zero tasks.
386
+ mc-arena needs the same browser, but only for its screenshots: its scores
387
+ come from the block list, and it needs Docker rather than the CDN.
388
+ """
389
+ from benchkit.browser import allowed_hosts, probe_environment
390
+
391
+ ok, detail = probe_environment(force=True)
392
+ if ok:
393
+ console.print(f"[green]Headless rendering works.[/green] [dim]{detail}[/dim]")
394
+ else:
395
+ console.print(f"[red]Headless rendering is unavailable.[/red] {detail}")
396
+
397
+ hosts = allowed_hosts()
398
+ if hosts:
399
+ console.print(
400
+ "[dim]Generated pages may fetch modules from:[/dim] " + ", ".join(hosts)
401
+ )
402
+ console.print(
403
+ "[dim]The machine needs outbound HTTPS to at least one of them; "
404
+ "otherwise every scene fails on a blocked import.[/dim]"
405
+ )
406
+ else:
407
+ console.print(
408
+ "[yellow]Network access is disabled "
409
+ "(BENCHKIT_RENDER_OFFLINE).[/yellow] [dim]Pages that import "
410
+ "three.js from a CDN cannot render.[/dim]"
411
+ )
412
+ console.print(
413
+ "[dim]mc-arena additionally needs Docker, and uses the browser only "
414
+ "for screenshots; its builds are scored either way.[/dim]"
415
+ )
416
+ if not ok:
417
+ sys.exit(1)
418
+
419
+
420
+ def _client(args: argparse.Namespace):
421
+ if args.demo:
422
+ from benchkit.demo import DemoClient
423
+
424
+ return DemoClient()
425
+
426
+ client = InferenceClient.from_env()
427
+ if args.host:
428
+ client.host = args.host.rstrip("/")
429
+ return client
430
+
431
+
432
+ def _headless_jobs(args: argparse.Namespace, available: list[str]) -> list[JobSpec]:
433
+ if args.models.strip().lower() in {"all", "*"}:
434
+ models = available
435
+ else:
436
+ models = [name.strip() for name in args.models.split(",") if name.strip()]
437
+
438
+ unknown = [name for name in models if name not in available]
439
+ if unknown:
440
+ console.print(f"[red]Unknown model(s):[/red] {', '.join(unknown)}")
441
+ console.print(f"[dim]Available: {', '.join(available)}[/dim]")
442
+ sys.exit(1)
443
+ if not models:
444
+ console.print("[red]--models is required with --headless[/red]")
445
+ sys.exit(1)
446
+
447
+ requested = [part for part in args.benchmarks.split(",") if part.strip()]
448
+
449
+ specs: list[tuple[str, str | None, str | None]] = []
450
+ for part in requested:
451
+ part = part.strip()
452
+ if not part:
453
+ continue
454
+ pieces = [piece.strip() for piece in part.split(":")]
455
+ key = pieces[0]
456
+ if key not in REGISTRY:
457
+ console.print(f"[red]Unknown benchmark:[/red] {key}")
458
+ console.print(f"[dim]Available: {', '.join(REGISTRY)}[/dim]")
459
+ sys.exit(1)
460
+ if len(pieces) > 3 or any(not piece for piece in pieces[1:]):
461
+ console.print(f"[red]Invalid benchmark selector:[/red] {part}")
462
+ sys.exit(1)
463
+
464
+ bench = REGISTRY[key]()
465
+ resolve_variant = getattr(bench, "resolve_variant", None)
466
+ variant = None
467
+ slice_spec = None
468
+ if len(pieces) == 2:
469
+ candidate = (
470
+ resolve_variant(pieces[1]) if callable(resolve_variant) else None
471
+ )
472
+ if candidate is not None:
473
+ variant = str(candidate)
474
+ else:
475
+ slice_spec = pieces[1]
476
+ elif len(pieces) == 3:
477
+ candidate = (
478
+ resolve_variant(pieces[1]) if callable(resolve_variant) else None
479
+ )
480
+ if candidate is None:
481
+ console.print(f"[red]Unknown {key} variant:[/red] {pieces[1]}")
482
+ sys.exit(1)
483
+ variant = str(candidate)
484
+ slice_spec = pieces[2]
485
+ try:
486
+ variant_count = getattr(bench, "variant_task_count", None)
487
+ total = (
488
+ int(variant_count(variant))
489
+ if variant is not None and callable(variant_count)
490
+ else slice_task_count(key)
491
+ )
492
+ except Exception as exc:
493
+ console.print(f"[red]Could not load {key}:[/red] {exc}")
494
+ sys.exit(1)
495
+ try:
496
+ parse_slice(slice_spec, total)
497
+ except SliceError as exc:
498
+ console.print(f"[red]{exc}[/red]")
499
+ sys.exit(1)
500
+ specs.append((key, slice_spec, variant))
501
+
502
+ if not specs:
503
+ console.print("[red]--benchmarks is required with --headless[/red]")
504
+ sys.exit(1)
505
+
506
+ if args.perturbation:
507
+ unsupported = unsupported_benchmarks(
508
+ args.perturbation, [key for key, _, _ in specs]
509
+ )
510
+ if unsupported:
511
+ console.print(
512
+ f"[red]{args.perturbation} is unsupported for:[/red] "
513
+ + ", ".join(unsupported)
514
+ )
515
+ supported = [
516
+ key for key in REGISTRY if args.perturbation in perturbations_for(key)
517
+ ]
518
+ console.print(f"[dim]Supported: {', '.join(supported)}[/dim]")
519
+ sys.exit(1)
520
+
521
+ harnesses = ("direct", "pi") if args.harness == "both" else (args.harness,)
522
+ return job_matrix(
523
+ models,
524
+ specs,
525
+ harnesses,
526
+ repair_attempts=args.repair_attempts,
527
+ perturbation=args.perturbation,
528
+ perturbation_seed=args.perturbation_seed,
529
+ )
530
+
531
+
532
+ def _headless(args: argparse.Namespace) -> None:
533
+ if args.demo and args.decision_models.strip():
534
+ console.print("[red]Decision models are unavailable in demo mode.[/red]")
535
+ sys.exit(1)
536
+ if args.demo and args.harness != "direct":
537
+ console.print("[red]Pi harness is unavailable in demo mode.[/red]")
538
+ sys.exit(1)
539
+ try:
540
+ client = _client(args)
541
+ except ValueError as exc:
542
+ console.print(f"[red]Configuration error:[/red] {exc}")
543
+ sys.exit(1)
544
+
545
+ console.print(f"[bold]BenchKit[/bold] [dim]{client.host}[/dim]")
546
+
547
+ try:
548
+ models = client.list_models()
549
+ except Exception as exc:
550
+ console.print(f"[red]Connection failed:[/red] {exc}")
551
+ sys.exit(1)
552
+
553
+ selected = _headless_jobs(args, [model["name"] for model in models])
554
+ forced = {name.strip() for name in args.decision_models.split(",") if name.strip()}
555
+ stray = sorted(forced - {job.model for job in selected})
556
+ if stray:
557
+ console.print(
558
+ "[red]--decision-models names models that are not in --models:[/red] "
559
+ + ", ".join(stray)
560
+ )
561
+ sys.exit(1)
562
+ try:
563
+ jobs = route_decision_jobs(selected, client, forced)
564
+ except ValueError as exc:
565
+ console.print(f"[red]{exc}[/red]")
566
+ sys.exit(1)
567
+ jobs = expand_jobs(jobs, client)
568
+ if not jobs:
569
+ console.print(
570
+ "[red]No supported RULER context bucket fits the selected model(s).[/red]"
571
+ )
572
+ sys.exit(1)
573
+ # Fetch what the run needs up front, with a progress bar, rather than
574
+ # stalling silently inside the first job's task loader.
575
+ missing = catalog.missing(dict.fromkeys(job.benchmark for job in jobs))
576
+ if missing:
577
+ console.print(f"[dim]Fetching datasets:[/dim] {', '.join(missing)}")
578
+ # A partial install (RULER without its essay corpus) can still serve
579
+ # some slices, so leave the verdict to the task loader.
580
+ if not _install_datasets(missing):
581
+ console.print(
582
+ "[yellow]Continuing; tasks that need the missing data will "
583
+ "fail when they load.[/yellow]"
584
+ )
585
+ if args.demo:
586
+ perturbations = sorted(
587
+ {
588
+ (job.perturbation, job.perturbation_seed)
589
+ for job in jobs
590
+ if job.perturbation
591
+ }
592
+ )
593
+ client.prime(
594
+ sorted({job.benchmark for job in jobs}),
595
+ perturbations=perturbations,
596
+ )
597
+
598
+ results, failure = run(
599
+ client,
600
+ jobs,
601
+ console,
602
+ args.verbose,
603
+ force_unload=args.force_unload,
604
+ )
605
+ if not results:
606
+ console.print("[yellow]No results.[/yellow]")
607
+ sys.exit(1 if failure else 0)
608
+
609
+ table = Table(
610
+ box=box.MINIMAL,
611
+ border_style="dim",
612
+ header_style="bold",
613
+ pad_edge=False,
614
+ padding=(0, 1),
615
+ )
616
+ # Fold long model and benchmark names instead of ellipsizing them: the
617
+ # summary is the part of a headless run people copy into a report.
618
+ table.add_column("Model", overflow="fold")
619
+ table.add_column("Harness")
620
+ table.add_column("Benchmark", overflow="fold")
621
+ table.add_column("Score", justify="right")
622
+ has_harness_pairs = any(
623
+ result.get("harness_score_delta_pp") is not None for result in results
624
+ )
625
+ if has_harness_pairs:
626
+ table.add_column("Harness score Δ", justify="right")
627
+ table.add_column("Loop-kill Δ", justify="right")
628
+ has_repairs = any(result.get("repair_attempts", 0) for result in results)
629
+ if has_repairs:
630
+ table.add_column("Repair Δ", justify="right")
631
+ table.add_column("Fixed", justify="right")
632
+ has_perturbations = any(result.get("perturbation") for result in results)
633
+ if has_perturbations:
634
+ table.add_column("Delta", justify="right")
635
+ table.add_column("Regress", justify="right")
636
+ table.add_column("Pass/Scored", justify="right")
637
+ table.add_column("Fail/Loop/TO/Len/HE", justify="right")
638
+ has_parallel = any(result.get("concurrency", 1) > 1 for result in results)
639
+ # Decision rows are measured in dec/s, so a table that mixes them with
640
+ # generating rows names the unit in every cell instead of the header.
641
+ has_decisions = any(is_decision(result) for result in results)
642
+ unit = " tok/s" if has_decisions else ""
643
+ if has_parallel:
644
+ table.add_column("Parallel", justify="right", style="dim")
645
+ table.add_column("Agg speed" if has_decisions else "Agg tok/s", justify="right")
646
+ table.add_column("Stream tok/s", justify="right", style="dim")
647
+ table.add_column("Eff", justify="right", style="dim")
648
+ else:
649
+ table.add_column("Speed" if has_decisions else "Tok/s", justify="right")
650
+ table.add_column("Avg Time", justify="right", style="dim")
651
+ table.add_column("Wall", justify="right", style="dim")
652
+
653
+ for result in results:
654
+ passed, failed, errors = _outcome_counts(result)
655
+ parallel = result.get("concurrency", 1) > 1
656
+ row = [
657
+ result["model"],
658
+ result.get("harness_label", "Direct"),
659
+ result.get("benchmark_label", result["benchmark"]),
660
+ _score_text(result["score"]),
661
+ ]
662
+ if has_harness_pairs:
663
+ harness_delta = result.get("harness_score_delta_pp")
664
+ row.append(
665
+ f"{float(harness_delta):+.1f}pp" if harness_delta is not None else "—"
666
+ )
667
+ loop_delta = result.get("loop_kill_delta_pp")
668
+ row.append(f"{float(loop_delta):+.1f}pp" if loop_delta is not None else "—")
669
+ if has_repairs:
670
+ if result.get("repair_attempts", 0):
671
+ row.extend(
672
+ [
673
+ f"{float(result.get('repair_delta_pp', 0)):+.1f}pp",
674
+ (
675
+ f"{result.get('repair_successes', 0)}/"
676
+ f"{result.get('repair_attempted', 0)}"
677
+ ),
678
+ ]
679
+ )
680
+ else:
681
+ row.extend(["—", "—"])
682
+ if has_perturbations:
683
+ delta = result.get("score_delta_pp")
684
+ row.extend(
685
+ [
686
+ f"{float(delta):+.1f}pp" if delta is not None else "—",
687
+ (
688
+ f"{result.get('regressions', 0)}/"
689
+ f"{result.get('paired_total', 0)}"
690
+ if result.get("perturbation")
691
+ else "—"
692
+ ),
693
+ ]
694
+ )
695
+ row.extend(
696
+ [
697
+ f"{passed}/{result.get('scored_total', result['total'])}",
698
+ f"{result.get('failures', failed)}/{result.get('loop_kills', 0)}/"
699
+ f"{result.get('timeouts', 0)}/"
700
+ f"{result.get('length_exceeded', 0)}/"
701
+ f"{result.get('harness_errors', errors)}",
702
+ ]
703
+ )
704
+ decisions = is_decision(result)
705
+ if has_parallel:
706
+ row.extend(
707
+ [
708
+ str(result.get("concurrency", 1)),
709
+ decision_speed(result)[0]
710
+ if decisions
711
+ else f"{aggregate_tok_s(result):.1f}{unit}"
712
+ if parallel
713
+ else "—",
714
+ "—" if decisions else f"{stream_tok_s(result):.1f}",
715
+ f"{effective_concurrency(result):.2f}x" if parallel else "—",
716
+ ]
717
+ )
718
+ else:
719
+ row.append(
720
+ decision_speed(result)[0]
721
+ if decisions
722
+ else f"{stream_tok_s(result):.1f}{unit}"
723
+ )
724
+ row.extend([latency_text(result), _fmt_time(result["total_time"])])
725
+ table.add_row(
726
+ *row,
727
+ )
728
+
729
+ console.print()
730
+ console.print(table)
731
+
732
+ out = save(results, provider=getattr(client, "label", ""), host=client.host)
733
+ console.print(f"[dim]Saved:[/dim] [white]{out}[/white]")
734
+ if (out / "arena.html").exists():
735
+ console.print(
736
+ f"[dim]Gallery:[/dim] [white]{out / 'arena.html'}[/white] "
737
+ "[dim]· click a preview to open the generated page[/dim]"
738
+ )
739
+ # A run whose render checks were all skipped still scores 100%, so say
740
+ # so plainly rather than letting the score imply the pages were judged.
741
+ workspaces = [
742
+ task.get("workspace") or {}
743
+ for result in results
744
+ for task in result.get("tasks") or []
745
+ ]
746
+ skipped = sum(
747
+ workspace.get("render_status") == "skipped"
748
+ and not workspace.get("build_status")
749
+ for workspace in workspaces
750
+ )
751
+ if skipped:
752
+ console.print(
753
+ f"[yellow]Skipped {skipped} render check(s):[/yellow] "
754
+ "[dim]this machine could not render them, so they were not "
755
+ "scored against the model · run 'benchkit render-check'[/dim]"
756
+ )
757
+ # mc-arena scores the block list, so a build without pictures is a
758
+ # missing illustration rather than an unjudged task.
759
+ unillustrated = sum(
760
+ workspace.get("render_status") == "skipped"
761
+ and bool(workspace.get("build_status"))
762
+ for workspace in workspaces
763
+ )
764
+ if unillustrated:
765
+ console.print(
766
+ f"[yellow]Rendered no image for {unillustrated} build(s):[/yellow] "
767
+ "[dim]the builds were still scored from their block lists · "
768
+ "run 'benchkit render-check'[/dim]"
769
+ )
770
+ if failure:
771
+ sys.exit(1)
772
+
773
+
774
+ def _perf(args: argparse.Namespace) -> None:
775
+ if args.demo:
776
+ console.print("[red]Performance profiling is unavailable in demo mode.[/red]")
777
+ sys.exit(1)
778
+ if not args.perf_model:
779
+ console.print("[red]A model name is required:[/red] benchkit perf MODEL")
780
+ sys.exit(1)
781
+ if args.gen < 1 or args.reps < 1 or args.warmups < 0:
782
+ console.print(
783
+ "[red]Perf settings require --gen and --reps above zero, "
784
+ "and --warmups at least zero.[/red]"
785
+ )
786
+ sys.exit(1)
787
+ try:
788
+ depths = parse_depths(args.depths)
789
+ client = _client(args)
790
+ except ValueError as exc:
791
+ console.print(f"[red]Configuration error:[/red] {exc}")
792
+ sys.exit(1)
793
+
794
+ console.print(f"[bold]BenchKit perf[/bold] [dim]{client.host}[/dim]")
795
+ try:
796
+ models = client.list_models()
797
+ except Exception as exc:
798
+ console.print(f"[red]Connection failed:[/red] {exc}")
799
+ sys.exit(1)
800
+
801
+ model_meta = next(
802
+ (model for model in models if model.get("name") == args.perf_model),
803
+ None,
804
+ )
805
+ if model_meta is None:
806
+ console.print(f"[red]Unknown model:[/red] {args.perf_model}")
807
+ console.print(
808
+ "[dim]Available: "
809
+ + ", ".join(str(model.get("name")) for model in models)
810
+ + "[/dim]"
811
+ )
812
+ sys.exit(1)
813
+ if client.provider != "openai":
814
+ console.print(
815
+ "[red]Perf v1 requires an OpenAI-compatible llama.cpp/llama-swap "
816
+ "endpoint.[/red]"
817
+ )
818
+ sys.exit(1)
819
+
820
+ model_status = str(model_meta.get("status") or "").strip().lower()
821
+ if model_status == "unloaded":
822
+ console.print(
823
+ "[yellow]Model status: unloaded[/yellow] "
824
+ "[dim]· the first request may measure a cold start[/dim]"
825
+ )
826
+ elif model_status == "loaded":
827
+ console.print(
828
+ "[green]Model status: loaded[/green] "
829
+ "[dim]· the model is already resident, so this is a warm test[/dim]"
830
+ )
831
+ elif model_status:
832
+ console.print(
833
+ f"[dim]Model status: {model_status} · cold/warm state is not known[/dim]"
834
+ )
835
+ else:
836
+ console.print(
837
+ "[dim]Model status: unavailable · cold/warm state is not known[/dim]"
838
+ )
839
+
840
+ config = PerfConfig(
841
+ model=args.perf_model,
842
+ depths=depths,
843
+ gen_tokens=args.gen,
844
+ reps=args.reps,
845
+ warmups=args.warmups,
846
+ config_note=args.config_note,
847
+ )
848
+ try:
849
+ profile = run_profile(
850
+ client,
851
+ config,
852
+ model_meta=model_meta,
853
+ progress=lambda message: console.print(f"[dim]· {message}[/dim]"),
854
+ )
855
+ except Exception as exc:
856
+ console.print(f"[red]Performance profile failed:[/red] {exc}")
857
+ sys.exit(1)
858
+
859
+ table = Table(
860
+ box=box.MINIMAL,
861
+ border_style="dim",
862
+ header_style="bold",
863
+ pad_edge=False,
864
+ )
865
+ table.add_column("Depth")
866
+ table.add_column("Input", justify="right")
867
+ table.add_column("PP tok/s", justify="right")
868
+ table.add_column("TG tok/s", justify="right")
869
+ table.add_column("TTFT", justify="right")
870
+ table.add_column("Wall", justify="right")
871
+ table.add_column("Overhead", justify="right")
872
+ table.add_column("Runs", justify="right")
873
+ for case in profile["cases"]:
874
+ inaccurate = not case.get("depth_within_tolerance", True)
875
+ depth = case["depth_label"]
876
+ if inaccurate:
877
+ depth = f"[yellow]{depth}![/yellow]"
878
+ table.add_row(
879
+ depth,
880
+ f"{case['actual_input_tokens']:,}" if case["actual_input_tokens"] else "—",
881
+ (f"{case['pp_tps']['median']:.1f}" if case.get("pp_reliable") else "n/a"),
882
+ f"{case['tg_tps']['median']:.1f}",
883
+ f"{case['ttft_s']['median']:.3f}s",
884
+ f"{case['wall_time_s']['median']:.2f}s",
885
+ (
886
+ f"{case['overhead_s']['median']:.3f}s"
887
+ if case.get("overhead_available")
888
+ else "—"
889
+ ),
890
+ f"{case['successful_reps']}/{args.reps}",
891
+ )
892
+ console.print()
893
+ console.print(table)
894
+
895
+ sweep = profile.get("sweep_summary") or {}
896
+ console.print(
897
+ "[dim]Sweep median · "
898
+ f"TG {float(sweep.get('tg_tps_median') or 0):.1f} tok/s · "
899
+ f"PP {float(sweep.get('pp_tps_median') or 0):.1f} tok/s"
900
+ "[/dim]"
901
+ )
902
+
903
+ calibrated = [
904
+ case for case in profile["cases"] if case.get("depth_method") == "calibrated"
905
+ ]
906
+ inaccurate = [
907
+ case
908
+ for case in profile["cases"]
909
+ if not case.get("depth_within_tolerance", True)
910
+ ]
911
+ estimated = [
912
+ case for case in profile["cases"] if case.get("depth_method") == "estimated"
913
+ ]
914
+ if calibrated:
915
+ console.print(
916
+ "[dim]Depth method: calibrated · native tokenizer is not exposed "
917
+ "by the server; server-reported input tokens were used.[/dim]"
918
+ )
919
+ if inaccurate:
920
+ labels = ", ".join(case["depth_label"] for case in inaccurate)
921
+ console.print(
922
+ f"[bold yellow]! Depth tolerance not reached for: {labels}. "
923
+ "Use the Actual Input column when comparing results.[/bold yellow]"
924
+ )
925
+ if estimated:
926
+ labels = ", ".join(case["depth_label"] for case in estimated)
927
+ console.print(
928
+ f"[bold yellow]Depth calibration unavailable for: {labels}. "
929
+ "Use the Actual Input column.[/bold yellow]"
930
+ )
931
+
932
+ drift = profile.get("drift_check") or {}
933
+ if drift.get("error"):
934
+ console.print(f"[yellow]Drift check failed:[/yellow] {drift['error']}")
935
+ elif drift:
936
+ message = (
937
+ f"Drift check · {drift['depth_label']} TG "
938
+ f"{drift['initial_tg_tps']:.1f} → {drift['final_tg_tps']:.1f} tok/s "
939
+ f"({drift['change']:+.1%})"
940
+ + ("" if drift.get("stable") else " · possible warmup/drift anomaly")
941
+ )
942
+ console.print(
943
+ f"[dim]{message}[/dim]"
944
+ if drift.get("stable")
945
+ else f"[bold yellow]{message}[/bold yellow]"
946
+ )
947
+
948
+ output = save_profile(profile)
949
+ console.print(
950
+ f"[dim]Saved:[/dim] [white]{output}[/white] "
951
+ "[dim]· perf.json · csv · md · html[/dim]"
952
+ )
953
+ console.print(
954
+ "[dim]Tip: Run the ruler benchmark to verify how much of the configured "
955
+ "context window is actually usable.[/dim]"
956
+ )
957
+
958
+
959
+ def main(argv: list[str] | None = None) -> None:
960
+ # Only the working directory's .env: walking up to parent directories would
961
+ # let a file in a shared parent such as /tmp redirect the host and API key.
962
+ load_dotenv(".env")
963
+ args = _parse_args(sys.argv[1:] if argv is None else argv)
964
+
965
+ if args.list:
966
+ _list_benchmarks()
967
+ return
968
+
969
+ if args.command == "render-check":
970
+ _render_check()
971
+ return
972
+
973
+ if args.command == "datasets":
974
+ _datasets(args)
975
+ return
976
+
977
+ if args.command == "perf":
978
+ _perf(args)
979
+ return
980
+
981
+ if args.command == "history":
982
+ if not 0 <= args.port <= 65535:
983
+ console.print("[red]--port must be between 0 and 65535.[/red]")
984
+ sys.exit(1)
985
+ serve_history(
986
+ args.results_dir or ["results"],
987
+ port=args.port,
988
+ open_browser=not args.no_open,
989
+ )
990
+ return
991
+
992
+ if args.headless:
993
+ _headless(args)
994
+ return
995
+
996
+ run_tui(demo=args.demo, host=args.host)
997
+
998
+
999
+ if __name__ == "__main__":
1000
+ main()