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
@@ -0,0 +1,988 @@
1
+ """Live run screen: progress, streaming task results and run controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from rich.text import Text
9
+ from textual import events, work
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal, Vertical
13
+ from textual.content import Content
14
+ from textual.message import Message
15
+ from textual.screen import Screen
16
+ from textual.widgets import DataTable, Footer, Header, ProgressBar, Static
17
+
18
+ from benchkit.engine import (
19
+ Engine,
20
+ GenerationProgress,
21
+ JobCompleted,
22
+ JobSpec,
23
+ JobStarted,
24
+ ModelUnloaded,
25
+ RunCompleted,
26
+ RunControls,
27
+ RunFailed,
28
+ TaskCompleted,
29
+ TaskPhase,
30
+ TaskRecord,
31
+ benchmark,
32
+ plan_total_tasks,
33
+ )
34
+ from benchkit.report import save
35
+ from benchkit.tui.formatting import (
36
+ bar,
37
+ fmt_count,
38
+ fmt_duration,
39
+ result_color,
40
+ score_color,
41
+ throughput_stat,
42
+ )
43
+ from benchkit.tui.screens.modals import TaskDetailScreen
44
+ from benchkit.tui.widgets import SectionTitle, StatCard, apply_compact
45
+
46
+
47
+ class EngineMessage(Message):
48
+ """An engine event forwarded from the worker thread."""
49
+
50
+ def __init__(self, event: object) -> None:
51
+ super().__init__()
52
+ self.event = event
53
+
54
+
55
+ def _variant_cap_note(jobs: list[JobSpec]) -> str:
56
+ """Describe context variants omitted by served-context discovery."""
57
+ groups: dict[tuple[str, str], list[str]] = {}
58
+ for job in jobs:
59
+ if job.variant is not None:
60
+ groups.setdefault((job.model, job.benchmark), []).append(job.variant)
61
+
62
+ notes: list[str] = []
63
+ for (model, benchmark_key), variants in groups.items():
64
+ expected = tuple(
65
+ str(value)
66
+ for value in getattr(benchmark(benchmark_key), "variant_labels", ())
67
+ )
68
+ if not expected or set(expected).issubset(variants):
69
+ continue
70
+ highest = next(
71
+ (value for value in reversed(expected) if value in variants),
72
+ "none",
73
+ )
74
+ notes.append(f"{model} {benchmark_key.upper()} ≤{highest}")
75
+ return ", ".join(notes)
76
+
77
+
78
+ def _include_in_overall(job: JobSpec) -> bool:
79
+ """Match the engine/report rule for model-level score aggregation."""
80
+ return job.perturbation is None and getattr(
81
+ benchmark(job.benchmark), "include_in_overall", True
82
+ )
83
+
84
+
85
+ class RunScreen(Screen[None]):
86
+ """Everything that happens while benchmarks are running."""
87
+
88
+ BINDINGS = [
89
+ Binding("p", "pause", "Pause"),
90
+ Binding("k", "skip", "Skip job"),
91
+ Binding("x", "stop", "Stop run"),
92
+ Binding("u", "toggle_unload", "Force unload"),
93
+ Binding("f", "toggle_failures", "Failures only"),
94
+ Binding("t", "toggle_follow", "Follow"),
95
+ Binding("escape", "stop", "Stop run", show=False),
96
+ ]
97
+
98
+ def __init__(self, jobs: list[JobSpec], *, force_unload: bool = False) -> None:
99
+ super().__init__()
100
+ self.jobs = jobs
101
+ self.force_unload = force_unload
102
+ self.controls = RunControls(force_unload=force_unload)
103
+ self.records: dict[int, list[TaskRecord]] = {}
104
+ self.current_index = -1
105
+ self.current_total = 0
106
+ self.current_passed = 0
107
+ self.current_score_points = 0.0
108
+ self.current_completed = 0
109
+ self.current_scored_total = 0
110
+ self.latency_sum = 0.0
111
+ self.decision_latencies_ms: list[float] = []
112
+ self.tok_s_sum = 0.0
113
+ self.output_tokens_sum = 0
114
+ self.job_started_at = time.monotonic()
115
+ self.overall_total = 0
116
+ self.overall_completed = 0
117
+ self.overall_score_points = 0.0
118
+ self.overall_scored_tasks = 0
119
+ self.started_at = time.monotonic()
120
+ self.only_failures = False
121
+ self.follow = True
122
+ self.finished = False
123
+ self.current_loops = 0
124
+ self.current_suspected = 0
125
+ self.current_recovered = 0
126
+ self.current_concurrency = 1
127
+ self.completed_generated_chars = 0
128
+ self.live_progress: dict[str, GenerationProgress] = {}
129
+ self.live_tasks: dict[str, TaskPhase] = {}
130
+ self.generating_tasks: set[str] = set()
131
+ self.live_rows: set[str] = set()
132
+ self.live_modal: TaskDetailScreen | None = None
133
+ self.live_modal_key: str | None = None
134
+
135
+ def compose(self) -> ComposeResult:
136
+ yield Header(show_clock=True)
137
+ with Vertical(id="run-root"):
138
+ with Horizontal(id="run-head"):
139
+ yield Static("Starting…", id="run-title")
140
+ yield Static("QUEUED", id="run-state")
141
+ with Vertical(id="progress-block"):
142
+ yield Static("", id="job-progress-label")
143
+ yield ProgressBar(total=100, show_eta=False, id="job-progress")
144
+ yield Static("", id="overall-progress-label")
145
+ yield ProgressBar(total=100, show_eta=False, id="overall-progress")
146
+ with Horizontal(id="stat-row"):
147
+ yield StatCard("Accuracy", "--", id="stat-accuracy")
148
+ yield StatCard("Passed", "0", id="stat-passed")
149
+ yield StatCard("Failed", "0", id="stat-failed")
150
+ yield StatCard("Loops", "0", id="stat-loops")
151
+ yield StatCard("Live", "--", id="stat-live")
152
+ yield StatCard("Throughput", "--", id="stat-speed")
153
+ yield StatCard("Elapsed", "0s", id="stat-elapsed")
154
+ with Horizontal(id="run-body"):
155
+ with Vertical(classes="pane", id="queue-pane"):
156
+ yield SectionTitle("Run queue", id="queue-title")
157
+ yield DataTable(id="queue", cursor_type="row", zebra_stripes=True)
158
+ with Vertical(classes="pane", id="task-pane"):
159
+ yield SectionTitle("Tasks", "newest last", id="task-title")
160
+ yield DataTable(id="tasks", cursor_type="row", zebra_stripes=True)
161
+ yield Footer()
162
+
163
+ def on_mount(self) -> None:
164
+ self.overall_total = plan_total_tasks(self.jobs)
165
+ subtitle = f"{len(self.jobs)} run(s) · {fmt_count(self.overall_total)} tasks"
166
+ if cap_note := _variant_cap_note(self.jobs):
167
+ subtitle += f" · served cap: {cap_note}"
168
+ if self.force_unload:
169
+ subtitle += " · force unload"
170
+ self.sub_title = subtitle
171
+
172
+ queue = self.query_one("#queue", DataTable)
173
+ queue.add_column("#", key="index", width=3)
174
+ queue.add_column("Model", key="model")
175
+ queue.add_column("Harness", key="harness", width=18)
176
+ queue.add_column("Benchmark", key="benchmark")
177
+ queue.add_column("Progress", key="progress", width=10)
178
+ queue.add_column("Score", key="score", width=7)
179
+ queue.add_column("Status", key="status", width=8)
180
+ for index, job in enumerate(self.jobs):
181
+ queue.add_row(
182
+ str(index + 1),
183
+ job.model,
184
+ job.harness_label,
185
+ job.benchmark_label,
186
+ Text("—", style="dim"),
187
+ Text("—", style="dim"),
188
+ Text("queued", style="dim"),
189
+ key=str(index),
190
+ )
191
+
192
+ tasks = self.query_one("#tasks", DataTable)
193
+ tasks.add_column("#", key="index", width=4)
194
+ tasks.add_column("Task", key="task", width=28)
195
+ tasks.add_column("Result", key="result", width=9)
196
+ tasks.add_column("Loop", key="loop", width=9)
197
+ tasks.add_column("Latency", key="latency", width=8)
198
+ tasks.add_column("Stream tok/s", key="tok_s", width=12)
199
+
200
+ self.query_one("#overall-progress", ProgressBar).update(
201
+ total=max(self.overall_total, 1), progress=0
202
+ )
203
+ self._set_state("RUNNING", "running")
204
+ self.set_interval(0.5, self._tick)
205
+ self._start_engine()
206
+
207
+ @property
208
+ def dark(self) -> bool:
209
+ return self.app.current_theme.dark
210
+
211
+ def on_resize(self, event: events.Resize) -> None:
212
+ apply_compact(self, event.size.height)
213
+
214
+ def on_unmount(self) -> None:
215
+ self.controls.stop()
216
+
217
+ # Engine -----------------------------------------------------------
218
+
219
+ @work(thread=True, exclusive=True, group="engine")
220
+ def _start_engine(self) -> None:
221
+ engine = Engine(
222
+ client=self.app.client,
223
+ jobs=self.jobs,
224
+ sink=lambda event: self.post_message(EngineMessage(event)),
225
+ controls=self.controls,
226
+ )
227
+ try:
228
+ results = engine.run()
229
+ except Exception as exc:
230
+ self.app.call_from_thread(
231
+ self.notify, f"Run failed: {exc}", severity="error", timeout=10
232
+ )
233
+ self.app.call_from_thread(self._finished, [], None)
234
+ return
235
+
236
+ output: Path | None = None
237
+ if results:
238
+ self.app.call_from_thread(self._saving)
239
+ try:
240
+ output = save(
241
+ results,
242
+ provider=getattr(self.app.client, "label", ""),
243
+ host=getattr(self.app.client, "host", ""),
244
+ )
245
+ except Exception as exc:
246
+ self.app.call_from_thread(
247
+ self.notify, f"Could not save reports: {exc}", severity="error"
248
+ )
249
+ self.app.call_from_thread(self._finished, results, output)
250
+
251
+ def on_engine_message(self, message: EngineMessage) -> None:
252
+ event = message.event
253
+ if isinstance(event, JobStarted):
254
+ self._job_started(event)
255
+ elif isinstance(event, TaskPhase):
256
+ self._task_phase(event)
257
+ elif isinstance(event, GenerationProgress):
258
+ self._generation_progress(event)
259
+ elif isinstance(event, TaskCompleted):
260
+ self._task_completed(event)
261
+ elif isinstance(event, JobCompleted):
262
+ self._job_completed(event)
263
+ elif isinstance(event, RunCompleted):
264
+ self._run_completed(event)
265
+ elif isinstance(event, ModelUnloaded):
266
+ if event.error:
267
+ self.notify(
268
+ f"Force-unload failed for {event.model}: {event.error}",
269
+ severity="error",
270
+ timeout=6,
271
+ )
272
+ else:
273
+ self.notify(f"Force-unloading {event.model}", timeout=3)
274
+ elif isinstance(event, RunFailed):
275
+ self.notify(event.message, severity="error", timeout=10)
276
+
277
+ def _job_started(self, event: JobStarted) -> None:
278
+ self.current_index = event.index
279
+ self.current_total = event.total
280
+ self.current_passed = 0
281
+ self.current_score_points = 0.0
282
+ self.current_completed = 0
283
+ self.current_scored_total = 0
284
+ self.latency_sum = 0.0
285
+ self.decision_latencies_ms: list[float] = []
286
+ self.tok_s_sum = 0.0
287
+ self.output_tokens_sum = 0
288
+ self.job_started_at = time.monotonic()
289
+ self.current_loops = 0
290
+ self.current_suspected = 0
291
+ self.current_recovered = 0
292
+ self.current_concurrency = event.concurrency
293
+ self.completed_generated_chars = 0
294
+ self.live_progress.clear()
295
+ self.live_tasks.clear()
296
+ self.generating_tasks.clear()
297
+ self.live_rows.clear()
298
+ self.records[event.index] = []
299
+
300
+ job = event.job
301
+ title = Content.from_markup(
302
+ "[b]$bench[/b] [dim]on[/dim] $model [cyan]$harness[/cyan]"
303
+ "[dim]$slice$parallel[/dim]",
304
+ bench=job.benchmark_label,
305
+ model=job.model,
306
+ harness=job.harness_label,
307
+ slice=f" slice {job.slice_spec}" if job.slice_spec else "",
308
+ parallel=f" · {event.concurrency} concurrent",
309
+ )
310
+ self.query_one("#run-title", Static).update(title)
311
+
312
+ self.query_one("#job-progress", ProgressBar).update(
313
+ total=max(event.total, 1), progress=0
314
+ )
315
+ self.query_one("#tasks", DataTable).clear()
316
+ self._update_queue_row(event.index, status="running", style="bold")
317
+ self._update_labels()
318
+
319
+ @staticmethod
320
+ def _live_key(event: TaskPhase | GenerationProgress) -> str:
321
+ return f"{event.index}:{event.position - 1}"
322
+
323
+ def _generated_chars(self) -> int:
324
+ """Monotonic job total across finished and currently streaming tasks."""
325
+ return self.completed_generated_chars + sum(
326
+ progress.thinking_chars + progress.response_chars
327
+ for progress in self.live_progress.values()
328
+ )
329
+
330
+ def _update_live_summary(self) -> None:
331
+ """Render aggregate concurrency without jumping between task names."""
332
+ if self.current_index < 0:
333
+ return
334
+ active = len(self.generating_tasks)
335
+ generated_chars = self._generated_chars()
336
+ self.query_one("#job-progress-label", Static).update(
337
+ f"Job {self.current_index + 1}/{len(self.jobs)} · "
338
+ f"{fmt_count(self.current_completed)}/{fmt_count(self.current_total)} "
339
+ f"complete · {fmt_count(active)} active · "
340
+ f"{fmt_count(generated_chars)} chars generated"
341
+ )
342
+ live_value = (
343
+ f"{active} ACTIVE"
344
+ if active
345
+ else "FINALIZING"
346
+ if self.live_tasks
347
+ else "DONE"
348
+ if self.current_completed
349
+ else "STARTING"
350
+ )
351
+ self.query_one("#stat-live", StatCard).set_state(
352
+ live_value,
353
+ f"{fmt_count(generated_chars)} chars generated this job",
354
+ )
355
+
356
+ def _update_live_state(self) -> None:
357
+ """Summarize all live requests instead of displaying the last event."""
358
+ if self.controls.stopped:
359
+ self._set_state("STOPPED", "done")
360
+ return
361
+ if self.controls.skip_requested:
362
+ self._set_state("REQUESTS CANCELLED", "paused")
363
+ return
364
+ if self.controls.paused:
365
+ self._set_state("PAUSED", "paused")
366
+ return
367
+
368
+ progress = list(self.live_progress.values())
369
+ active_progress = [
370
+ self.live_progress[key]
371
+ for key in self.generating_tasks
372
+ if key in self.live_progress
373
+ ]
374
+ kill_remaining = [
375
+ item.loop_kill_remaining_s
376
+ for item in progress
377
+ if item.loop_kill_remaining_s is not None
378
+ ]
379
+ looping = sum(item.loop_state == "looping" for item in progress)
380
+ suspected = sum(item.loop_state == "suspected" for item in progress)
381
+ if kill_remaining:
382
+ self._set_state(
383
+ f"LOOPING · KILL IN {min(kill_remaining):.1f}s",
384
+ "looping",
385
+ )
386
+ elif looping:
387
+ self._set_state(
388
+ f"{fmt_count(looping)} LOOPING",
389
+ "looping",
390
+ )
391
+ elif suspected:
392
+ self._set_state(
393
+ f"{fmt_count(suspected)} LOOP SUSPECTED",
394
+ "paused",
395
+ )
396
+ elif self.generating_tasks:
397
+ if self.current_concurrency > 1:
398
+ self._set_state(
399
+ f"{fmt_count(len(self.generating_tasks))} ACTIVE",
400
+ "running",
401
+ )
402
+ else:
403
+ phase = active_progress[0].phase if active_progress else "waiting"
404
+ self._set_state(
405
+ {
406
+ "thinking": "THINKING",
407
+ "answering": "ANSWERING",
408
+ }.get(phase, "WAITING FOR MODEL"),
409
+ "running",
410
+ )
411
+ elif self.live_tasks:
412
+ self._set_state("EVALUATING", "evaluating")
413
+ else:
414
+ self._set_state("RUNNING", "running")
415
+
416
+ def _add_live_row(self, event: TaskPhase) -> None:
417
+ key = self._live_key(event)
418
+ if key in self.live_rows:
419
+ return
420
+ table = self.query_one("#tasks", DataTable)
421
+ table.add_row(
422
+ str(event.position),
423
+ event.label,
424
+ Text("WAIT", style="dim"),
425
+ Text("observing", style="dim"),
426
+ "0s",
427
+ "—",
428
+ key=key,
429
+ )
430
+ self.live_rows.add(key)
431
+ if self.follow:
432
+ table.scroll_end(animate=False)
433
+
434
+ def _task_phase(self, event: TaskPhase) -> None:
435
+ key = self._live_key(event)
436
+ if event.phase == "generating":
437
+ self.live_tasks[key] = event
438
+ self.generating_tasks.add(key)
439
+ self._add_live_row(event)
440
+ elif event.phase == "evaluating":
441
+ self.generating_tasks.discard(key)
442
+ if key in self.live_rows:
443
+ self.query_one("#tasks", DataTable).update_cell(
444
+ key, "result", Text("EVAL", style="bold")
445
+ )
446
+ elif event.phase == "loop_killed":
447
+ self.generating_tasks.discard(key)
448
+ else:
449
+ self.generating_tasks.discard(key)
450
+
451
+ self._update_live_state()
452
+ self._update_live_summary()
453
+
454
+ def _generation_progress(self, event: GenerationProgress) -> None:
455
+ key = self._live_key(event)
456
+ self.live_progress[key] = event
457
+ self.generating_tasks.add(key)
458
+ phase = self.live_tasks.get(key)
459
+ if phase is not None:
460
+ self._add_live_row(phase)
461
+ self._update_live_state()
462
+ self._update_live_summary()
463
+ live_loops = self.current_loops + sum(
464
+ progress.loop_state == "looping" for progress in self.live_progress.values()
465
+ )
466
+ live_suspected = self.current_suspected + sum(
467
+ progress.loop_state == "suspected"
468
+ for progress in self.live_progress.values()
469
+ )
470
+ kill_remaining = [
471
+ progress.loop_kill_remaining_s
472
+ for progress in self.live_progress.values()
473
+ if progress.loop_kill_remaining_s is not None
474
+ ]
475
+ self.query_one("#stat-loops", StatCard).set_state(
476
+ str(live_loops),
477
+ (
478
+ f"kill in {min(kill_remaining):.1f}s"
479
+ if kill_remaining
480
+ else f"{live_suspected} suspect · {self.current_recovered} recovered"
481
+ ),
482
+ )
483
+
484
+ if key in self.live_rows:
485
+ table = self.query_one("#tasks", DataTable)
486
+ table.update_cell(
487
+ key,
488
+ "result",
489
+ Text(event.phase[:7].upper(), style="bold"),
490
+ )
491
+ table.update_cell(
492
+ key,
493
+ "loop",
494
+ _loop_cell(event.loop_state, event.loop_source),
495
+ )
496
+ table.update_cell(
497
+ key,
498
+ "latency",
499
+ fmt_duration(event.elapsed_s),
500
+ )
501
+
502
+ if (
503
+ self.live_modal is not None
504
+ and self.live_modal.is_mounted
505
+ and self.live_modal_key == key
506
+ ):
507
+ self.live_modal.update_live(_progress_task_data(event))
508
+
509
+ def _task_completed(self, event: TaskCompleted) -> None:
510
+ record = event.record
511
+ key = f"{event.index}:{record.index}"
512
+ self.records.setdefault(event.index, []).append(record)
513
+ self.current_completed = event.completed
514
+ self.current_passed = event.passed
515
+ self.current_score_points = (
516
+ event.score_points
517
+ if event.score_points is not None
518
+ else float(event.passed)
519
+ )
520
+ self.current_scored_total = (
521
+ event.scored_total
522
+ if event.scored_total is not None
523
+ else event.completed - int(record.harness_error)
524
+ )
525
+ self.overall_completed += 1
526
+ if _include_in_overall(event.job) and not record.harness_error:
527
+ self.overall_score_points += (
528
+ record.score if record.score > 0 or not record.passed else 1.0
529
+ )
530
+ self.overall_scored_tasks += 1
531
+ self.latency_sum += record.response_time_s
532
+ if record.decision:
533
+ self.decision_latencies_ms.append(record.decision["latency_ms"])
534
+ self.tok_s_sum += record.tok_s
535
+ self.output_tokens_sum += record.output_tokens
536
+ self.current_loops += int(record.loop_state == "looping")
537
+ self.current_suspected += int(
538
+ record.loop_state == "suspected" and not record.recovered_cycle
539
+ )
540
+ self.current_recovered += int(record.recovered_cycle)
541
+
542
+ self._add_task_row(event.index, record)
543
+ last_progress = self.live_progress.pop(key, None)
544
+ streamed_chars = (
545
+ last_progress.thinking_chars + last_progress.response_chars
546
+ if last_progress is not None
547
+ else 0
548
+ )
549
+ self.completed_generated_chars += max(
550
+ streamed_chars,
551
+ len(record.thinking) + len(record.response),
552
+ )
553
+ self.live_tasks.pop(key, None)
554
+ self.generating_tasks.discard(key)
555
+ self.live_rows.discard(key)
556
+ live_loops = sum(
557
+ progress.loop_state == "looping" for progress in self.live_progress.values()
558
+ )
559
+ live_suspected = sum(
560
+ progress.loop_state == "suspected"
561
+ for progress in self.live_progress.values()
562
+ )
563
+ self.query_one("#stat-loops", StatCard).set_state(
564
+ str(self.current_loops + live_loops),
565
+ f"{self.current_suspected + live_suspected} suspect · "
566
+ f"{self.current_recovered} recovered",
567
+ )
568
+ if (
569
+ self.live_modal is not None
570
+ and self.live_modal.is_mounted
571
+ and self.live_modal_key == key
572
+ ):
573
+ self.live_modal.update_live(_record_task_data(record), finished=True)
574
+ self.live_modal = None
575
+ self.live_modal_key = None
576
+ self.query_one("#job-progress", ProgressBar).update(progress=event.completed)
577
+ self.query_one("#overall-progress", ProgressBar).update(
578
+ progress=self.overall_completed
579
+ )
580
+ self._update_live_state()
581
+ score = (
582
+ self.current_score_points / self.current_scored_total * 100
583
+ if self.current_scored_total
584
+ else 0.0
585
+ )
586
+ self._update_queue_row(
587
+ event.index,
588
+ progress=bar(event.completed / max(self.current_total, 1), 10),
589
+ score=Text(f"{score:.1f}%", style=f"bold {score_color(score, self.dark)}"),
590
+ )
591
+ self._update_labels()
592
+ self._update_stats()
593
+
594
+ def _job_completed(self, event: JobCompleted) -> None:
595
+ result = event.result
596
+ if not result["total"]:
597
+ self._update_queue_row(
598
+ event.index,
599
+ progress=bar(0.0, 10),
600
+ score=Text("—", style="dim"),
601
+ status="skipped",
602
+ style="dim",
603
+ )
604
+ return
605
+
606
+ score = result["score"]
607
+ self._update_queue_row(
608
+ event.index,
609
+ progress=bar(1.0, 10),
610
+ score=Text(f"{score:.1f}%", style=f"bold {score_color(score, self.dark)}"),
611
+ status="skipped" if event.skipped else "done",
612
+ style="dim" if event.skipped else "",
613
+ )
614
+
615
+ def _run_completed(self, event: RunCompleted) -> None:
616
+ self._set_state("STOPPED" if event.stopped else "COMPLETE", "done")
617
+
618
+ def _saving(self) -> None:
619
+ self._set_state("SAVING", "done")
620
+
621
+ def _finished(self, results: list[dict], output: Path | None) -> None:
622
+ self.finished = True
623
+ if not results:
624
+ self.notify("Run stopped before any task finished.", timeout=4)
625
+ self.app.pop_screen()
626
+ return
627
+ self.app.show_results(results, output)
628
+
629
+ # Table helpers ----------------------------------------------------
630
+
631
+ def _add_task_row(self, job_index: int, record: TaskRecord) -> None:
632
+ table = self.query_one("#tasks", DataTable)
633
+ key = f"{job_index}:{record.index}"
634
+ if self.only_failures and record.passed:
635
+ if key in self.live_rows:
636
+ table.remove_row(key)
637
+ return
638
+ if key in self.live_rows:
639
+ table.update_cell(key, "result", _result_cell(record, self.dark))
640
+ table.update_cell(
641
+ key,
642
+ "loop",
643
+ _loop_cell(
644
+ record.loop_state,
645
+ record.loop_source,
646
+ recovered=record.recovered_cycle,
647
+ ),
648
+ )
649
+ table.update_cell(key, "latency", fmt_duration(record.response_time_s))
650
+ table.update_cell(
651
+ key,
652
+ "tok_s",
653
+ f"{record.tok_s:.1f}" if record.tok_s else "—",
654
+ )
655
+ else:
656
+ table.add_row(
657
+ str(record.index + 1),
658
+ record.label,
659
+ _result_cell(record, self.dark),
660
+ _loop_cell(
661
+ record.loop_state,
662
+ record.loop_source,
663
+ recovered=record.recovered_cycle,
664
+ ),
665
+ fmt_duration(record.response_time_s),
666
+ f"{record.tok_s:.1f}" if record.tok_s else "—",
667
+ key=key,
668
+ )
669
+ if self.follow:
670
+ table.scroll_end(animate=False)
671
+
672
+ def _rebuild_tasks(self) -> None:
673
+ table = self.query_one("#tasks", DataTable)
674
+ table.clear()
675
+ self.live_rows.clear()
676
+ for record in sorted(
677
+ self.records.get(self.current_index, []), key=lambda item: item.index
678
+ ):
679
+ if self.only_failures and record.passed:
680
+ continue
681
+ table.add_row(
682
+ str(record.index + 1),
683
+ record.label,
684
+ _result_cell(record, self.dark),
685
+ _loop_cell(
686
+ record.loop_state,
687
+ record.loop_source,
688
+ recovered=record.recovered_cycle,
689
+ ),
690
+ fmt_duration(record.response_time_s),
691
+ f"{record.tok_s:.1f}" if record.tok_s else "—",
692
+ key=f"{self.current_index}:{record.index}",
693
+ )
694
+ for key, phase in sorted(
695
+ self.live_tasks.items(), key=lambda item: item[1].position
696
+ ):
697
+ self._add_live_row(phase)
698
+ progress = self.live_progress.get(key)
699
+ if progress is None:
700
+ continue
701
+ table.update_cell(
702
+ key,
703
+ "result",
704
+ Text(progress.phase[:7].upper(), style="bold"),
705
+ )
706
+ table.update_cell(
707
+ key,
708
+ "loop",
709
+ _loop_cell(progress.loop_state, progress.loop_source),
710
+ )
711
+ table.update_cell(key, "latency", fmt_duration(progress.elapsed_s))
712
+ if self.follow:
713
+ table.scroll_end(animate=False)
714
+
715
+ def _update_queue_row(
716
+ self,
717
+ index: int,
718
+ *,
719
+ progress: str | None = None,
720
+ score: Text | None = None,
721
+ status: str | None = None,
722
+ style: str = "",
723
+ ) -> None:
724
+ queue = self.query_one("#queue", DataTable)
725
+ key = str(index)
726
+ if progress is not None:
727
+ queue.update_cell(key, "progress", Text(progress, style="dim"))
728
+ if score is not None:
729
+ queue.update_cell(key, "score", score)
730
+ if status is not None:
731
+ queue.update_cell(key, "status", Text(status, style=style or ""))
732
+
733
+ def _update_labels(self) -> None:
734
+ overall_label = self.query_one("#overall-progress-label", Static)
735
+ self._update_live_summary()
736
+ overall_label.update(
737
+ f"Overall · {fmt_count(self.overall_completed)}/"
738
+ f"{fmt_count(self.overall_total)} tasks"
739
+ )
740
+
741
+ def _update_stats(self) -> None:
742
+ completed = max(self.current_completed, 1)
743
+ scored = max(self.current_scored_total, 1)
744
+ measured_elapsed = max(time.monotonic() - self.job_started_at, 0.001)
745
+ elapsed = (
746
+ max(self.latency_sum / self.current_concurrency, 0.001)
747
+ if getattr(self.app.client, "simulated_timing", False)
748
+ and self.latency_sum > 0
749
+ else measured_elapsed
750
+ )
751
+ score = self.current_score_points / scored * 100
752
+ failed = self.current_completed - self.current_passed
753
+ accuracy = self.query_one("#stat-accuracy", StatCard)
754
+ current_job = self.jobs[self.current_index]
755
+ include_current = _include_in_overall(current_job)
756
+ if not include_current:
757
+ overall_hint = (
758
+ "paired perturbation · not averaged"
759
+ if current_job.perturbation
760
+ else "per-context · not averaged"
761
+ )
762
+ elif self.overall_scored_tasks:
763
+ overall = self.overall_score_points / self.overall_scored_tasks * 100
764
+ overall_hint = f"{overall:.1f}% overall"
765
+ else:
766
+ overall_hint = "— overall"
767
+ accuracy.set_state(f"{score:.1f}%", overall_hint)
768
+ self.query_one("#stat-passed", StatCard).set_state(str(self.current_passed))
769
+ self.query_one("#stat-failed", StatCard).set_state(str(failed))
770
+ speed = self.query_one("#stat-speed", StatCard)
771
+ stream_speed = self.tok_s_sum / completed
772
+ if self.decision_latencies_ms:
773
+ latencies = sorted(self.decision_latencies_ms)
774
+ speed.set_state(
775
+ f"{self.current_completed / elapsed:.0f} dec/s",
776
+ f"p50 {latencies[len(latencies) // 2]:.0f} ms",
777
+ )
778
+ return
779
+ speed.set_state(
780
+ *throughput_stat(
781
+ concurrency=self.current_concurrency,
782
+ aggregate=self.output_tokens_sum / elapsed,
783
+ stream=stream_speed,
784
+ effective=min(
785
+ self.current_concurrency,
786
+ self.latency_sum / elapsed,
787
+ ),
788
+ precision=0,
789
+ )
790
+ )
791
+
792
+ def _tick(self) -> None:
793
+ elapsed = time.monotonic() - self.started_at
794
+ self.query_one("#stat-elapsed", StatCard).set_state(fmt_duration(elapsed))
795
+
796
+ def _set_state(self, label: str, css_class: str) -> None:
797
+ state = self.query_one("#run-state", Static)
798
+ state.set_classes(f"state {css_class}")
799
+ state.update(label)
800
+
801
+ # Events -----------------------------------------------------------
802
+
803
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
804
+ if event.data_table.id != "tasks":
805
+ return
806
+ selected_key = str(event.row_key.value)
807
+ progress = self.live_progress.get(selected_key)
808
+ if progress is not None:
809
+ modal = TaskDetailScreen(
810
+ progress.job.title,
811
+ _progress_task_data(progress),
812
+ live=True,
813
+ )
814
+ self.live_modal = modal
815
+ self.live_modal_key = selected_key
816
+ self.app.push_screen(modal, self._live_modal_closed)
817
+ return
818
+ job_index, _, position = selected_key.partition(":")
819
+ records = self.records.get(int(job_index), [])
820
+ record = next((r for r in records if r.index == int(position)), None)
821
+ if record is None:
822
+ return
823
+ job = self.jobs[int(job_index)]
824
+ self.app.push_screen(TaskDetailScreen(job.title, _record_task_data(record)))
825
+
826
+ def _live_modal_closed(self, _result: None) -> None:
827
+ self.live_modal = None
828
+ self.live_modal_key = None
829
+
830
+ # Actions ----------------------------------------------------------
831
+
832
+ def action_pause(self) -> None:
833
+ if self.finished:
834
+ return
835
+ paused = self.controls.toggle_pause()
836
+ self._set_state(
837
+ "PAUSED" if paused else "RUNNING", "paused" if paused else "running"
838
+ )
839
+ self.notify(
840
+ "Paused after active requests finish" if paused else "Resumed",
841
+ timeout=2,
842
+ )
843
+
844
+ def action_skip(self) -> None:
845
+ if self.finished:
846
+ return
847
+ self.controls.skip_job()
848
+ self._set_state("REQUEST CANCELLED", "paused")
849
+ self.notify("Active requests cancelled — moving to the next job.", timeout=2)
850
+
851
+ def action_stop(self) -> None:
852
+ if self.finished:
853
+ return
854
+ self.controls.stop()
855
+ self._set_state("STOPPED", "done")
856
+ self.notify("Run stopped. Active requests cancelled.", timeout=2)
857
+
858
+ def action_toggle_failures(self) -> None:
859
+ self.only_failures = not self.only_failures
860
+ self.query_one("#task-title", SectionTitle).set_detail(
861
+ "failures only" if self.only_failures else "newest last"
862
+ )
863
+ self._rebuild_tasks()
864
+
865
+ def action_toggle_follow(self) -> None:
866
+ self.follow = not self.follow
867
+ self.notify(f"Auto-scroll {'on' if self.follow else 'off'}", timeout=2)
868
+
869
+ def action_toggle_unload(self) -> None:
870
+ if self.finished:
871
+ return
872
+ enabled = self.controls.toggle_force_unload()
873
+ self.notify(
874
+ f"Force unload {'on' if enabled else 'off'} "
875
+ "— applies at the next model switch",
876
+ timeout=3,
877
+ )
878
+
879
+
880
+ def _result_cell(record: TaskRecord, dark: bool = True) -> Text:
881
+ label = (
882
+ "LOOP KILL"
883
+ if record.loop_killed
884
+ else "TIMEOUT"
885
+ if record.timed_out
886
+ else "LENGTH"
887
+ if record.length_exceeded
888
+ else "ERROR"
889
+ if record.error
890
+ else "PASS"
891
+ if record.passed
892
+ else f"{record.score:.0%}"
893
+ if record.score > 0
894
+ else "FAIL"
895
+ )
896
+ color = (
897
+ score_color(record.score * 100, dark)
898
+ if not record.passed and record.score > 0 and not record.error
899
+ else result_color(record.passed, bool(record.error), dark)
900
+ )
901
+ return Text(label, style=f"bold {color}")
902
+
903
+
904
+ def _loop_label(state: str, source: str) -> str:
905
+ if state == "looping":
906
+ return "OUTPUT LOOP" if source == "answer" else "LOOPING"
907
+ if state == "suspected":
908
+ return "SUSPECT"
909
+ if state == "clear":
910
+ return "NO TRACE" if source == "answer" else "CLEAR"
911
+ if state == "observing":
912
+ return "WATCHING"
913
+ return "NO TRACE"
914
+
915
+
916
+ def _loop_cell(state: str, source: str, *, recovered: bool = False) -> Text:
917
+ label = "RECOVERED" if recovered else _loop_label(state, source)
918
+ style = (
919
+ "bold yellow"
920
+ if recovered
921
+ else "bold red"
922
+ if state == "looping"
923
+ else "bold yellow"
924
+ if state == "suspected"
925
+ else "green"
926
+ if state == "clear" and source != "answer"
927
+ else "dim"
928
+ )
929
+ return Text(label, style=style)
930
+
931
+
932
+ def _progress_task_data(event: GenerationProgress) -> dict:
933
+ return {
934
+ "task_id": event.label,
935
+ "passed": None,
936
+ "prompt": event.prompt,
937
+ "thinking": event.thinking,
938
+ "response": event.response,
939
+ "error": "",
940
+ "tok_s": 0.0,
941
+ "response_time_s": event.elapsed_s,
942
+ "live_phase": event.phase,
943
+ "trace_status": event.trace_status,
944
+ "loop_state": event.loop_state,
945
+ "loop_score": event.loop_score,
946
+ "loop_source": event.loop_source,
947
+ "loop_kill_remaining_s": event.loop_kill_remaining_s,
948
+ "thinking_chars": event.thinking_chars,
949
+ "response_chars": event.response_chars,
950
+ }
951
+
952
+
953
+ def _record_task_data(record: TaskRecord) -> dict:
954
+ return {
955
+ "task_id": record.label,
956
+ "passed": record.passed,
957
+ "score": record.score * 100,
958
+ "prompt": record.prompt,
959
+ "thinking": record.thinking,
960
+ "response": record.response,
961
+ "error": record.error,
962
+ "tok_s": record.tok_s,
963
+ "response_time_s": record.response_time_s,
964
+ "output_tokens": record.output_tokens,
965
+ "done_reason": record.done_reason,
966
+ "timed_out": record.timed_out,
967
+ "loop_killed": record.loop_killed,
968
+ "length_exceeded": record.length_exceeded,
969
+ "loop_kill_score": record.loop_kill_score,
970
+ "loop_killed_at_s": record.loop_killed_at_s,
971
+ "trace_status": record.trace_status,
972
+ "thinking_time_s": record.thinking_time_s,
973
+ "time_to_first_answer_s": record.time_to_first_answer_s,
974
+ "loop_state": record.loop_state,
975
+ "loop_score": record.loop_score,
976
+ "loop_source": record.loop_source,
977
+ "loop_detected_at_s": record.loop_detected_at_s,
978
+ "repeated_ngram_coverage": record.repeated_ngram_coverage,
979
+ "max_window_similarity": record.max_window_similarity,
980
+ "low_novelty_windows": record.low_novelty_windows,
981
+ "max_repeated_block": record.max_repeated_block,
982
+ "loop_evidence": record.loop_evidence,
983
+ "active_cycle": record.active_cycle,
984
+ "recovered_cycle": record.recovered_cycle,
985
+ "cycle_period_tokens": record.cycle_period_tokens,
986
+ "cycle_repetitions": record.cycle_repetitions,
987
+ "repeated_suffix_tokens": record.repeated_suffix_tokens,
988
+ }