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/runner.py ADDED
@@ -0,0 +1,769 @@
1
+ """Headless run rendering for `benchkit --headless`.
2
+
3
+ The TUI is the primary interface; this module keeps a scriptable, log-friendly
4
+ path for CI and remote shells.
5
+
6
+ Two renderings share one event sink:
7
+
8
+ * An interactive terminal gets a live dashboard - the active job with its own
9
+ bar and counters, the streaming task activity, the last few finished tasks
10
+ and an overall bar with an ETA. Notable events (errors, timeouts, loop kills,
11
+ finished jobs) are printed above the dashboard so they stay in scrollback.
12
+ * A pipe, a file or a CI log gets plain timestamped lines instead, because a
13
+ redrawing dashboard is worthless once the escape codes are stripped.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import signal
20
+ import time
21
+ from collections import deque
22
+ from dataclasses import dataclass, field
23
+ from types import FrameType
24
+
25
+ from rich.console import Console, ConsoleOptions, RenderResult
26
+ from rich.live import Live
27
+ from rich.text import Text
28
+
29
+ from benchkit.client import InferenceClient
30
+ from benchkit.engine import (
31
+ Engine,
32
+ GenerationProgress,
33
+ JobCompleted,
34
+ JobSpec,
35
+ JobStarted,
36
+ ModelUnloaded,
37
+ RunCompleted,
38
+ RunControls,
39
+ RunFailed,
40
+ RunStarted,
41
+ TaskCompleted,
42
+ TaskPhase,
43
+ TaskRecord,
44
+ slice_label,
45
+ )
46
+ from benchkit.metrics import (
47
+ aggregate_tok_s,
48
+ decision_speed,
49
+ effective_concurrency,
50
+ is_decision,
51
+ stream_tok_s,
52
+ )
53
+
54
+ RECENT_TASKS = 5
55
+
56
+
57
+ def _fmt_time(seconds: float) -> str:
58
+ seconds = round(seconds)
59
+ if seconds >= 3600:
60
+ return f"{seconds // 3600}h {seconds % 3600 // 60}m"
61
+ if seconds >= 60:
62
+ return f"{seconds // 60}m {seconds % 60}s"
63
+ return f"{seconds}s"
64
+
65
+
66
+ def _plural(count: int, word: str) -> str:
67
+ return f"{count:,} {word}{'' if count == 1 else 's'}"
68
+
69
+
70
+ def _score_style(score: float) -> str:
71
+ if score >= 80:
72
+ return "green"
73
+ if score < 50:
74
+ return "red"
75
+ return "white"
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class _Glyphs:
80
+ """Drawing characters, degraded for terminals without UTF-8."""
81
+
82
+ spinner: tuple[str, ...]
83
+ full: str
84
+ partial: tuple[str, ...]
85
+ empty: str
86
+ passed: str
87
+ failed: str
88
+ error: str
89
+ dot: str
90
+
91
+ @classmethod
92
+ def for_console(cls, console: Console) -> _Glyphs:
93
+ encoding = (console.encoding or "").lower().replace("-", "")
94
+ if encoding.startswith("utf") and not console.legacy_windows:
95
+ return cls(
96
+ spinner=("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"),
97
+ full="█",
98
+ partial=("▏", "▎", "▍", "▌", "▋", "▊", "▉"),
99
+ empty="░",
100
+ passed="✓",
101
+ failed="✗",
102
+ error="!",
103
+ dot="·",
104
+ )
105
+ return cls(
106
+ spinner=("|", "/", "-", "\\"),
107
+ full="#",
108
+ partial=(),
109
+ empty=".",
110
+ passed="+",
111
+ failed="x",
112
+ error="!",
113
+ dot="-",
114
+ )
115
+
116
+ def frame(self) -> str:
117
+ return self.spinner[int(time.monotonic() * 10) % len(self.spinner)]
118
+
119
+
120
+ def _bar(
121
+ fraction: float,
122
+ width: int,
123
+ glyphs: _Glyphs,
124
+ *,
125
+ style: str = "white",
126
+ empty_style: str = "bright_black",
127
+ ) -> Text:
128
+ """Render a progress bar, using partial blocks for sub-cell precision."""
129
+ width = max(4, width)
130
+ exact = min(1.0, max(0.0, fraction)) * width
131
+ filled = int(exact)
132
+ bar = Text(glyphs.full * filled, style=style)
133
+ remainder = exact - filled
134
+ if glyphs.partial and filled < width and remainder > 1 / (len(glyphs.partial) + 1):
135
+ index = min(len(glyphs.partial) - 1, int(remainder * len(glyphs.partial)))
136
+ bar.append(glyphs.partial[index], style=style)
137
+ filled += 1
138
+ bar.append(glyphs.empty * (width - filled), style=empty_style)
139
+ return bar
140
+
141
+
142
+ def _spread(left: Text, right: Text, width: int) -> Text:
143
+ """Left/right justify two runs of text on one line."""
144
+ room = max(0, width - right.cell_len - 1)
145
+ if left.cell_len > room:
146
+ left = left.copy()
147
+ left.truncate(room, overflow="ellipsis")
148
+ line = left.copy()
149
+ line.append(" " * max(1, width - left.cell_len - right.cell_len))
150
+ line.append_text(right)
151
+ return line
152
+
153
+
154
+ @dataclass
155
+ class _LiveStats:
156
+ """Outcome counters for one headless job."""
157
+
158
+ passed: int = 0
159
+ failed: int = 0
160
+ errors: int = 0
161
+ harness_errors: int = 0
162
+ loops: int = 0
163
+ points: float = 0.0
164
+
165
+ def add(self, record: TaskRecord) -> None:
166
+ if record.passed:
167
+ self.passed += 1
168
+ elif record.error:
169
+ self.errors += 1
170
+ else:
171
+ self.failed += 1
172
+ self.points += record.score if record.score > 0 or not record.passed else 1.0
173
+ self.harness_errors += int(record.harness_error)
174
+ self.loops += int(record.loop_state == "looping")
175
+
176
+ def fields(self, *, live_looping: bool = False) -> dict[str, int]:
177
+ return {
178
+ "passed": self.passed,
179
+ "failed": self.failed,
180
+ "errors": self.errors,
181
+ "loops": self.loops + int(live_looping),
182
+ }
183
+
184
+ @property
185
+ def completed(self) -> int:
186
+ return self.passed + self.failed + self.errors
187
+
188
+ @property
189
+ def score(self) -> float | None:
190
+ scored = self.completed - self.harness_errors
191
+ return self.points / scored * 100 if scored else None
192
+
193
+
194
+ def _score_text(score: float | None, width: int = 6) -> Text:
195
+ """Right aligned live score, dimmed until the first task lands."""
196
+ if score is None:
197
+ return Text("--.-%".rjust(width), style="dim")
198
+ return Text(f"{score:.1f}%".rjust(width), style=f"bold {_score_style(score)}")
199
+
200
+
201
+ def _counters(stats: _LiveStats, *, live_looping: bool = False) -> Text:
202
+ """`PASS 9 FAIL 2 ERR 1 LOOP 0`, with zero counts dimmed."""
203
+ fields = stats.fields(live_looping=live_looping)
204
+ text = Text()
205
+ for label, key, style in (
206
+ ("PASS", "passed", "green"),
207
+ ("FAIL", "failed", "red"),
208
+ ("ERR", "errors", "yellow"),
209
+ ("LOOP", "loops", "magenta"),
210
+ ):
211
+ value = fields[key]
212
+ if text:
213
+ text.append(" ")
214
+ text.append(label, style=f"bold {style}" if value else "dim")
215
+ text.append(f" {value}", style="" if value else "dim")
216
+ return text
217
+
218
+
219
+ @dataclass
220
+ class _JobView:
221
+ """Live state of the job the engine is currently running."""
222
+
223
+ index: int
224
+ job: JobSpec
225
+ total: int
226
+ completed: int = 0
227
+ stats: _LiveStats = field(default_factory=_LiveStats)
228
+ activity: Text = field(default_factory=Text)
229
+ live_looping: bool = False
230
+ concurrency: int = 1
231
+ looping_positions: set[int] = field(default_factory=set)
232
+ active_positions: set[int] = field(default_factory=set)
233
+ progress_by_position: dict[int, GenerationProgress] = field(default_factory=dict)
234
+ completed_chars: int = 0
235
+
236
+ @property
237
+ def generated_chars(self) -> int:
238
+ """Characters generated by completed and currently streaming tasks."""
239
+ return self.completed_chars + sum(
240
+ progress.thinking_chars + progress.response_chars
241
+ for progress in self.progress_by_position.values()
242
+ )
243
+
244
+
245
+ @dataclass
246
+ class _RunState:
247
+ """Everything the dashboard needs, updated from the engine sink."""
248
+
249
+ jobs: list[JobSpec] = field(default_factory=list)
250
+ overall_total: int = 0
251
+ overall_done: int = 0
252
+ started: float = field(default_factory=time.monotonic)
253
+ current: _JobView | None = None
254
+ recent: deque[Text] = field(default_factory=lambda: deque(maxlen=RECENT_TASKS))
255
+ stopping: bool = False
256
+
257
+ @property
258
+ def elapsed(self) -> float:
259
+ return time.monotonic() - self.started
260
+
261
+ @property
262
+ def eta(self) -> float | None:
263
+ if not self.overall_done or self.overall_done >= self.overall_total:
264
+ return None
265
+ rate = self.elapsed / self.overall_done
266
+ return rate * (self.overall_total - self.overall_done)
267
+
268
+
269
+ class _Dashboard:
270
+ """Renderable redrawn by `rich.live.Live` on every refresh."""
271
+
272
+ def __init__(self, state: _RunState, glyphs: _Glyphs) -> None:
273
+ self.state = state
274
+ self.glyphs = glyphs
275
+
276
+ def __rich_console__(
277
+ self, console: Console, options: ConsoleOptions
278
+ ) -> RenderResult:
279
+ width = max(40, options.max_width)
280
+ state = self.state
281
+ view = state.current
282
+
283
+ yield Text()
284
+ if view is not None:
285
+ yield from self._job_lines(view, width)
286
+ yield from state.recent
287
+ if state.recent:
288
+ yield Text()
289
+ yield self._overall_line(width)
290
+
291
+ def _job_lines(self, view: _JobView, width: int) -> RenderResult:
292
+ glyphs = self.glyphs
293
+ title = Text()
294
+ title.append(view.job.benchmark_label, style="bold")
295
+ title.append(f" {glyphs.dot} ", style="dim")
296
+ title.append(view.job.model)
297
+ title.append(f" {glyphs.dot} {view.job.harness_label}", style="cyan")
298
+ meta = Text(
299
+ f"job {view.index + 1}/{len(self.state.jobs)} {glyphs.dot} "
300
+ f"{slice_label(view.job.slice_spec)} {glyphs.dot} "
301
+ f"{view.concurrency} concurrent",
302
+ style="dim",
303
+ )
304
+ yield _spread(title, meta, width)
305
+
306
+ counters = _counters(view.stats, live_looping=view.live_looping)
307
+ progress = Text(f"{view.completed}/{view.total}".rjust(9), style="dim")
308
+ score = _score_text(view.stats.score)
309
+ tail = Text.assemble(progress, " ", score, " ", counters)
310
+ bar_width = max(8, width - tail.cell_len - 2)
311
+ yield Text.assemble(
312
+ _bar(view.completed / max(view.total, 1), bar_width, glyphs), " ", tail
313
+ )
314
+
315
+ activity = Text()
316
+ activity.append(glyphs.frame(), style="cyan")
317
+ activity.append(" ")
318
+ activity.append_text(view.activity)
319
+ activity.truncate(width, overflow="ellipsis")
320
+ yield activity
321
+ yield Text()
322
+
323
+ def _overall_line(self, width: int) -> Text:
324
+ state = self.state
325
+ glyphs = self.glyphs
326
+ percent = state.overall_done / max(state.overall_total, 1)
327
+ parts = [f"{state.overall_done}/{state.overall_total} tasks"]
328
+ parts.append(f"{percent * 100:.0f}%")
329
+ parts.append(_fmt_time(state.elapsed))
330
+ eta = state.eta
331
+ if eta is not None and not state.stopping:
332
+ parts.append(f"~{_fmt_time(eta)} left")
333
+ if state.stopping:
334
+ parts.append("stopping")
335
+ label = Text("overall ", style="dim")
336
+ tail = Text(f" {f' {glyphs.dot} '.join(parts)}", style="dim")
337
+ bar_width = max(8, width - label.cell_len - tail.cell_len)
338
+ return Text.assemble(
339
+ label,
340
+ _bar(
341
+ percent,
342
+ bar_width,
343
+ glyphs,
344
+ style="bright_black",
345
+ empty_style="bright_black",
346
+ ),
347
+ tail,
348
+ )
349
+
350
+
351
+ class _Reporter:
352
+ """Turns engine events into either a live dashboard or plain log lines."""
353
+
354
+ def __init__(self, console: Console, *, verbose: bool) -> None:
355
+ self.console = console
356
+ self.verbose = verbose
357
+ self.glyphs = _Glyphs.for_console(console)
358
+ self.plain = not console.is_terminal
359
+ self.state = _RunState()
360
+ self._live: Live | None = None
361
+
362
+ def __enter__(self) -> _Reporter:
363
+ if not self.plain:
364
+ self._live = Live(
365
+ _Dashboard(self.state, self.glyphs),
366
+ console=self.console,
367
+ refresh_per_second=10,
368
+ transient=True,
369
+ )
370
+ self._live.__enter__()
371
+ return self
372
+
373
+ def __exit__(self, *exc: object) -> None:
374
+ if self._live is not None:
375
+ self._live.__exit__(*exc)
376
+ self._live = None
377
+
378
+ def log(self, renderable: object) -> None:
379
+ """Print above the dashboard, where it stays in scrollback."""
380
+ # Log lines are read by machines as often as by people, so a piped run
381
+ # keeps one event on one line instead of wrapping at 80 columns.
382
+ self.console.print(renderable, soft_wrap=self.plain)
383
+
384
+ def _stamp(self) -> str:
385
+ return time.strftime("%H:%M:%S")
386
+
387
+ # -- events ---------------------------------------------------------
388
+
389
+ def __call__(self, event: object) -> None:
390
+ if isinstance(event, RunStarted):
391
+ self.on_run_started(event)
392
+ elif isinstance(event, JobStarted):
393
+ self.on_job_started(event)
394
+ elif isinstance(event, TaskPhase):
395
+ self.on_task_phase(event)
396
+ elif isinstance(event, GenerationProgress):
397
+ self.on_generation(event)
398
+ elif isinstance(event, TaskCompleted):
399
+ self.on_task_completed(event)
400
+ elif isinstance(event, JobCompleted):
401
+ self.on_job_completed(event)
402
+ elif isinstance(event, RunFailed):
403
+ self.log(Text.assemble(("Run failed: ", "bold red"), event.message))
404
+ elif isinstance(event, ModelUnloaded):
405
+ self.on_model_unloaded(event)
406
+ elif isinstance(event, RunCompleted):
407
+ self.on_run_completed(event)
408
+
409
+ def on_run_started(self, event: RunStarted) -> None:
410
+ self.state.jobs = list(event.jobs)
411
+ self.state.overall_total = event.total_tasks
412
+ self.state.started = time.monotonic()
413
+ models = len({job.model for job in event.jobs})
414
+ benchmarks = len({job.benchmark for job in event.jobs})
415
+ self.log(
416
+ Text.assemble(
417
+ (_plural(len(event.jobs), "job"), "bold"),
418
+ (
419
+ f" {self.glyphs.dot} {_plural(models, 'model')}"
420
+ f" {self.glyphs.dot} {_plural(benchmarks, 'benchmark')}"
421
+ f" {self.glyphs.dot} {_plural(event.total_tasks, 'task')}",
422
+ "dim",
423
+ ),
424
+ )
425
+ )
426
+
427
+ def on_job_started(self, event: JobStarted) -> None:
428
+ self.state.current = _JobView(
429
+ index=event.index,
430
+ job=event.job,
431
+ total=max(event.total, 1),
432
+ concurrency=event.concurrency,
433
+ )
434
+ self.state.recent.clear()
435
+ if self.plain:
436
+ self.log(
437
+ Text(
438
+ f"[{self._stamp()}] job {event.index + 1}/{len(self.state.jobs)}"
439
+ f" {event.job.benchmark_label} on {event.job.model}"
440
+ f" via {event.job.harness_label}"
441
+ f" {self.glyphs.dot} {event.total} tasks"
442
+ f" {self.glyphs.dot} {event.concurrency} concurrent"
443
+ f" {self.glyphs.dot} {slice_label(event.job.slice_spec)}",
444
+ style="dim",
445
+ )
446
+ )
447
+
448
+ def _activity(self, label: str, detail: Text) -> None:
449
+ view = self.state.current
450
+ if view is None:
451
+ return
452
+ text = Text()
453
+ text.append(label, style="bold")
454
+ text.append(f" {self.glyphs.dot} ", style="dim")
455
+ text.append_text(detail)
456
+ view.activity = text
457
+
458
+ def _concurrent_activity(self, view: _JobView) -> None:
459
+ """Show one stable aggregate instead of whichever stream updated last."""
460
+ detail = Text(
461
+ f"{_plural(len(view.active_positions), 'active request')}"
462
+ f" {self.glyphs.dot} {view.generated_chars:,} chars generated",
463
+ style="dim",
464
+ )
465
+ if view.looping_positions:
466
+ detail.append(f" {self.glyphs.dot} ", style="dim")
467
+ detail.append(
468
+ _plural(len(view.looping_positions), "looping request"),
469
+ style="bold magenta",
470
+ )
471
+ self._activity("Concurrent generation", detail)
472
+
473
+ def on_task_phase(self, event: TaskPhase) -> None:
474
+ view = self.state.current
475
+ if view is None:
476
+ return
477
+ if event.phase == "generating":
478
+ view.looping_positions.discard(event.position)
479
+ view.active_positions.add(event.position)
480
+ else:
481
+ view.active_positions.discard(event.position)
482
+ view.live_looping = bool(view.looping_positions)
483
+ if view.concurrency > 1:
484
+ self._concurrent_activity(view)
485
+ return
486
+ style = "yellow" if event.phase in {"timed_out", "loop_killed"} else "dim"
487
+ self._activity(event.label, Text(event.activity, style=style))
488
+
489
+ def on_generation(self, event: GenerationProgress) -> None:
490
+ view = self.state.current
491
+ if view is None:
492
+ return
493
+ view.active_positions.add(event.position)
494
+ view.progress_by_position[event.position] = event
495
+ if event.loop_state == "looping":
496
+ view.looping_positions.add(event.position)
497
+ else:
498
+ view.looping_positions.discard(event.position)
499
+ view.live_looping = bool(view.looping_positions)
500
+ if view.concurrency > 1:
501
+ self._concurrent_activity(view)
502
+ return
503
+ detail = Text(event.phase, style="dim")
504
+ detail.append(
505
+ f" {self.glyphs.dot} "
506
+ f"{event.thinking_chars + event.response_chars:,} chars"
507
+ f" {self.glyphs.dot} {event.elapsed_s:.0f}s",
508
+ style="dim",
509
+ )
510
+ if event.loop_state == "looping":
511
+ detail.append(f" {self.glyphs.dot} ", style="dim")
512
+ detail.append("LOOPING", style="bold magenta")
513
+ elif event.loop_state == "suspected":
514
+ detail.append(f" {self.glyphs.dot} loop suspected", style="magenta")
515
+ if event.loop_kill_remaining_s is not None:
516
+ detail.append(
517
+ f" {self.glyphs.dot} kill in {event.loop_kill_remaining_s:.1f}s",
518
+ style="bold red",
519
+ )
520
+ self._activity(event.label, detail)
521
+
522
+ def _task_line(self, record: TaskRecord, position: int, total: int) -> Text:
523
+ glyphs = self.glyphs
524
+ if record.passed:
525
+ mark, style, word = glyphs.passed, "green", "PASS"
526
+ elif record.score > 0:
527
+ mark, style, word = glyphs.failed, "yellow", f"PARTIAL {record.score:.0%}"
528
+ elif record.loop_killed:
529
+ mark, style, word = glyphs.error, "magenta", "LOOP KILLED"
530
+ elif record.timed_out:
531
+ mark, style, word = glyphs.error, "yellow", "TIMEOUT"
532
+ elif record.length_exceeded:
533
+ mark, style, word = glyphs.error, "yellow", "LENGTH LIMIT"
534
+ elif record.error:
535
+ mark, style, word = glyphs.error, "yellow", "ERROR"
536
+ else:
537
+ mark, style, word = glyphs.failed, "red", "FAIL"
538
+
539
+ line = Text(" ")
540
+ line.append(mark, style=style)
541
+ line.append(" ")
542
+ line.append(f"{position}/{total}".rjust(len(str(total)) * 2 + 1), style="dim")
543
+ line.append(" ")
544
+ line.append(word.ljust(11), style=f"bold {style}")
545
+ line.append(record.label)
546
+ if record.decision:
547
+ line.append(f" {record.decision['latency_ms']:.0f} ms", style="dim")
548
+ else:
549
+ line.append(
550
+ f" {record.response_time_s:.1f}s {glyphs.dot} "
551
+ f"{record.tok_s:.0f} stream tok/s",
552
+ style="dim",
553
+ )
554
+ if record.recovered_cycle:
555
+ line.append(f" {glyphs.dot} loop recovered", style="yellow")
556
+ elif record.loop_state == "looping" and not record.loop_killed:
557
+ line.append(f" {glyphs.dot} looped", style="magenta")
558
+ if record.error:
559
+ line.append(f" {record.error}", style="dim")
560
+ return line
561
+
562
+ def on_task_completed(self, event: TaskCompleted) -> None:
563
+ view = self.state.current
564
+ record = event.record
565
+ self.state.overall_done += 1
566
+ if view is not None:
567
+ view.stats.add(record)
568
+ view.completed = event.completed
569
+ view.looping_positions.discard(record.index + 1)
570
+ view.active_positions.discard(record.index + 1)
571
+ last_progress = view.progress_by_position.pop(record.index + 1, None)
572
+ streamed_chars = (
573
+ last_progress.thinking_chars + last_progress.response_chars
574
+ if last_progress is not None
575
+ else 0
576
+ )
577
+ view.completed_chars += max(
578
+ streamed_chars,
579
+ len(record.thinking) + len(record.response),
580
+ )
581
+ view.live_looping = bool(view.looping_positions)
582
+ if view.concurrency > 1:
583
+ self._concurrent_activity(view)
584
+ total = view.total if view is not None else event.completed
585
+ line = self._task_line(record, record.index + 1, total)
586
+ if self.plain:
587
+ stamped = Text()
588
+ stamped.append(f"[{self._stamp()}]", style="dim")
589
+ self.log(stamped.append_text(line))
590
+ else:
591
+ self.state.recent.append(line)
592
+ # Anything that is not an ordinary miss is worth keeping in
593
+ # scrollback, since the dashboard window scrolls it away.
594
+ if (
595
+ record.error
596
+ or record.timed_out
597
+ or record.loop_killed
598
+ or record.length_exceeded
599
+ ):
600
+ self.log(line)
601
+ if self.verbose:
602
+ self._verbose_task(record)
603
+
604
+ def _verbose_task(self, record: TaskRecord) -> None:
605
+ console = self.console
606
+ console.print()
607
+ console.print(Text(record.label, style="bold"))
608
+ console.print(Text("Prompt", style="dim"))
609
+ console.print(record.prompt, markup=False, highlight=False)
610
+ if record.thinking:
611
+ console.print(Text("Thinking", style="dim"))
612
+ console.print(record.thinking, markup=False, highlight=False)
613
+ console.print(Text("Response", style="dim"))
614
+ console.print(record.response, markup=False, highlight=False)
615
+ console.print()
616
+
617
+ def on_job_completed(self, event: JobCompleted) -> None:
618
+ result = event.result
619
+ view = self.state.current
620
+ self.state.current = None
621
+ glyphs = self.glyphs
622
+ stamp = f"[{self._stamp()}] " if self.plain else ""
623
+
624
+ if not result["total"]:
625
+ self.log(
626
+ Text.assemble(
627
+ (f"{stamp}{glyphs.error} ", "yellow"),
628
+ ("skipped".ljust(8), "bold yellow"),
629
+ (f"{result['benchmark']} on {result['model']}", "dim"),
630
+ )
631
+ )
632
+ return
633
+
634
+ stats = view.stats if view is not None else _LiveStats()
635
+ partial = self.state.stopping or result["total"] < (
636
+ view.total if view is not None else result["total"]
637
+ )
638
+ line = Text()
639
+ line.append(
640
+ f"{stamp}{glyphs.error if partial else glyphs.passed} ", style="dim"
641
+ )
642
+ line.append(result.get("benchmark_label", result["benchmark"]), style="bold")
643
+ line.append(f" {glyphs.dot} ", style="dim")
644
+ line.append(result["model"])
645
+ line.append(
646
+ f" {glyphs.dot} {result.get('harness_label', 'Direct')}", style="cyan"
647
+ )
648
+ line.append(" ")
649
+ line.append_text(_score_text(result["score"]))
650
+ line.append(" ")
651
+ line.append_text(_counters(stats))
652
+ if is_decision(result):
653
+ speed, latency = decision_speed(result)
654
+ throughput = f" {glyphs.dot} {speed} {glyphs.dot} {latency}"
655
+ elif result.get("concurrency", 1) > 1:
656
+ throughput = (
657
+ f" {glyphs.dot} {aggregate_tok_s(result):.1f} aggregate tok/s"
658
+ f" {glyphs.dot} {stream_tok_s(result):.1f} stream tok/s"
659
+ f" {glyphs.dot} {effective_concurrency(result):.2f}x effective"
660
+ )
661
+ else:
662
+ throughput = f" {glyphs.dot} {stream_tok_s(result):.1f} tok/s"
663
+ line.append(
664
+ f" {_plural(result['total'], 'task')}"
665
+ f"{' (partial)' if partial else ''}"
666
+ f"{throughput}"
667
+ f" {glyphs.dot} {result.get('loop_kills', 0)} killed"
668
+ f" {glyphs.dot} {_fmt_time(result['total_time'])}",
669
+ style="dim",
670
+ )
671
+ self.log(line)
672
+
673
+ def on_model_unloaded(self, event: ModelUnloaded) -> None:
674
+ stamp = f"[{self._stamp()}] " if self.plain else ""
675
+ if event.error:
676
+ self.log(
677
+ Text.assemble(
678
+ (f"{stamp}force-unload failed for ", "bold red"),
679
+ (event.model, "bold"),
680
+ (f" — {event.error}", "red"),
681
+ )
682
+ )
683
+ else:
684
+ self.log(
685
+ Text.assemble(
686
+ (stamp, "dim"),
687
+ ("force-unloading ", "dim"),
688
+ (event.model, "bold"),
689
+ (" before the next model", "dim"),
690
+ )
691
+ )
692
+
693
+ def on_run_completed(self, event: RunCompleted) -> None:
694
+ if not event.results:
695
+ return
696
+ glyphs = self.glyphs
697
+ word = "Stopped" if event.stopped else "Finished"
698
+ self.log(
699
+ Text(
700
+ f"{word} {_plural(len(event.results), 'job')}"
701
+ f" {glyphs.dot} {_plural(self.state.overall_done, 'task')}"
702
+ f" {glyphs.dot} {_fmt_time(event.elapsed)}",
703
+ style="dim",
704
+ )
705
+ )
706
+
707
+
708
+ def run(
709
+ client: InferenceClient,
710
+ jobs: list[JobSpec],
711
+ console: Console,
712
+ verbose: bool = False,
713
+ force_unload: bool = False,
714
+ ) -> tuple[list[dict], str | None]:
715
+ """Run every job, printing progress to `console`.
716
+
717
+ Returns the results plus the failure message when the run ended early.
718
+ Ctrl+C cancels every active request, so a partial run still reports and
719
+ still writes its artifacts; a second Ctrl+C aborts.
720
+ """
721
+ controls = RunControls(force_unload=force_unload)
722
+ reporter = _Reporter(console, verbose=verbose)
723
+ if force_unload:
724
+ reporter.log(
725
+ Text(
726
+ "Force-unload enabled: each model is evicted from VRAM "
727
+ "before the next model loads",
728
+ style="dim",
729
+ )
730
+ )
731
+ engine = Engine(client=client, jobs=jobs, sink=reporter, controls=controls)
732
+
733
+ def on_interrupt(signum: int, frame: FrameType | None) -> None:
734
+ if controls.stopped:
735
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
736
+ raise KeyboardInterrupt
737
+ reporter.state.stopping = True
738
+ controls.stop()
739
+ reporter.log(
740
+ Text("Interrupted, cancelling active requests…", style="bold yellow")
741
+ )
742
+
743
+ def on_terminate(signum: int, frame: FrameType | None) -> None:
744
+ # SIGHUP (for example, a dropped SSH session) and SIGTERM do not offer
745
+ # an interactive second signal. Unwind immediately so Engine.finally
746
+ # can remove its containers, networks, images, builders, and volumes.
747
+ controls.stop()
748
+ signal.signal(signum, signal.SIG_IGN)
749
+ raise SystemExit(128 + signum)
750
+
751
+ handlers = [(signal.SIGINT, on_interrupt), (signal.SIGTERM, on_terminate)]
752
+ if hasattr(signal, "SIGHUP"):
753
+ handlers.append((signal.SIGHUP, on_terminate))
754
+ previous: dict[int, signal.Handlers] = {}
755
+ for signum, handler in handlers:
756
+ with contextlib.suppress(ValueError): # not on the main thread
757
+ previous[signum] = signal.signal(signum, handler)
758
+
759
+ try:
760
+ with reporter:
761
+ results = engine.run()
762
+ except KeyboardInterrupt:
763
+ console.print(Text("Aborted.", style="bold yellow"))
764
+ return [], "interrupted"
765
+ finally:
766
+ for signum, handler in previous.items():
767
+ signal.signal(signum, handler)
768
+
769
+ return results, engine.failure