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,733 @@
1
+ """Setup screen: choose models, benchmarks and task slices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual import work
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.content import Content
11
+ from textual.screen import Screen
12
+ from textual.widgets import (
13
+ Button,
14
+ Checkbox,
15
+ Footer,
16
+ Header,
17
+ Input,
18
+ Label,
19
+ Select,
20
+ SelectionList,
21
+ Static,
22
+ )
23
+ from textual.widgets.selection_list import Selection
24
+
25
+ from benchkit import catalog
26
+ from benchkit.benchmarks import DESCRIPTIONS, REGISTRY
27
+ from benchkit.demo import DemoClient
28
+ from benchkit.engine import (
29
+ MAX_REPAIR_ATTEMPTS,
30
+ JobSpec,
31
+ SliceError,
32
+ expand_jobs,
33
+ parse_slice,
34
+ route_decision_jobs,
35
+ slice_label,
36
+ slice_task_count,
37
+ task_count,
38
+ )
39
+ from benchkit.jobs import job_matrix
40
+ from benchkit.perturbations import CHOICE_ORDER, unsupported_benchmarks
41
+ from benchkit.template_check import TemplateReport, check_template
42
+ from benchkit.tui.formatting import fmt_count, fmt_size
43
+ from benchkit.tui.screens.modals import (
44
+ DatasetsScreen,
45
+ LimitScreen,
46
+ TemplateCheckScreen,
47
+ )
48
+ from benchkit.tui.theme import score_palette
49
+ from benchkit.tui.widgets import SectionTitle
50
+
51
+ MODEL_NAME_WIDTH = 40
52
+ BENCHMARK_NAME_WIDTH = 15
53
+ BENCHMARK_COUNT_WIDTH = 13
54
+
55
+
56
+ class SetupScreen(Screen[None]):
57
+ """Pick what to run. Everything here is keyboard driven."""
58
+
59
+ BINDINGS = [
60
+ Binding("s", "start", "Start run"),
61
+ Binding("f5", "start", "Start run", show=False),
62
+ Binding("a", "select_all", "Select all"),
63
+ Binding("n", "select_none", "Clear"),
64
+ Binding("i", "invert", "Invert"),
65
+ Binding("l", "set_limit", "Task limit"),
66
+ Binding("p", "toggle_perturbation", "Choice order"),
67
+ Binding("v", "check_templates", "Check templates"),
68
+ Binding("d", "datasets", "Datasets"),
69
+ Binding("slash", "focus_filter", "Filter"),
70
+ Binding("escape", "back", "Back"),
71
+ ]
72
+
73
+ def __init__(self) -> None:
74
+ super().__init__()
75
+ self.model_order: list[str] = []
76
+ self.model_meta: dict[str, dict] = {}
77
+ self.selected_models: set[str] = set()
78
+ self.bench_order: list[str] = list(REGISTRY.keys())
79
+ self.selected_benchmarks: set[str] = set()
80
+ self.counts: dict[str, int] = {}
81
+ self.limits: dict[str, str] = {}
82
+ self.template_status: dict[str, str] = {}
83
+ self.checking_templates = False
84
+ self.missing_data = _missing_data()
85
+
86
+ def compose(self) -> ComposeResult:
87
+ yield Header(show_clock=True)
88
+ with Horizontal(id="setup-body"):
89
+ with Vertical(classes="pane", id="model-pane"):
90
+ yield SectionTitle("Models", id="model-title")
91
+ yield Input(
92
+ placeholder="Filter models…", id="model-filter", classes="filter"
93
+ )
94
+ yield SelectionList(id="model-list")
95
+ with Vertical(classes="pane", id="bench-pane"):
96
+ yield SectionTitle("Benchmarks", id="bench-title")
97
+ yield Input(
98
+ placeholder="Filter benchmarks…",
99
+ id="bench-filter",
100
+ classes="filter",
101
+ )
102
+ yield SelectionList(id="bench-list")
103
+ with Horizontal(id="setup-options"):
104
+ yield Label("Tasks per benchmark", classes="field-label wide")
105
+ yield Input(placeholder="all", id="global-limit", classes="limit-input")
106
+ yield Static(
107
+ "N first N · -N last N · A-B range · press [b]l[/b] for a per-benchmark limit",
108
+ classes="hint",
109
+ )
110
+ with Horizontal(id="setup-harness"):
111
+ yield Label("Harness", classes="field-label wide")
112
+ yield Select(
113
+ [
114
+ ("Direct API", "direct"),
115
+ ("Pi agent", "pi"),
116
+ ("Direct + Pi", "both"),
117
+ ],
118
+ value="direct",
119
+ allow_blank=False,
120
+ id="harness",
121
+ )
122
+ yield Input(
123
+ value="0",
124
+ type="integer",
125
+ max_length=2,
126
+ id="repair-attempts",
127
+ classes="repair-input",
128
+ )
129
+ yield Static(
130
+ f"sanitized feedback turns (0-{MAX_REPAIR_ATTEMPTS})",
131
+ classes="hint",
132
+ )
133
+ with Horizontal(id="setup-perturbation"):
134
+ yield Label("Perturbation", classes="field-label wide")
135
+ yield Checkbox(
136
+ "Run clean + choice-order",
137
+ id="choice-order",
138
+ compact=True,
139
+ )
140
+ yield Label("Seed", id="perturbation-seed-label")
141
+ yield Input(
142
+ value="42",
143
+ id="perturbation-seed",
144
+ classes="seed-input",
145
+ disabled=True,
146
+ )
147
+ yield Static(
148
+ "paired shuffled options · supported multiple-choice benchmarks only",
149
+ classes="hint",
150
+ )
151
+ with Horizontal(id="setup-unload"):
152
+ yield Label("Model unload", classes="field-label wide")
153
+ yield Checkbox(
154
+ "Force unload between models",
155
+ id="force-unload",
156
+ compact=True,
157
+ )
158
+ yield Static(
159
+ "evict each model from VRAM before the next model loads "
160
+ "(llama.cpp /models/unload)",
161
+ classes="hint",
162
+ )
163
+ with Horizontal(id="setup-footer"):
164
+ yield Static("", id="plan-summary")
165
+ yield Button("Datasets", id="datasets")
166
+ yield Button("Check templates", id="check-templates")
167
+ yield Button("Start run ▸", id="start", variant="primary")
168
+ yield Footer()
169
+
170
+ def on_mount(self) -> None:
171
+ self.sub_title = f"{self.app.client.label} · {self.app.client.host}"
172
+ self.model_order = [model["name"] for model in self.app.models]
173
+ self.model_meta = {model["name"]: model for model in self.app.models}
174
+ self.selected_models = {
175
+ name for name, model in self.model_meta.items() if _model_is_loaded(model)
176
+ }
177
+ if not self.selected_models and len(self.model_order) == 1:
178
+ self.selected_models = set(self.model_order)
179
+ self.selected_benchmarks = {"sanity"} if "sanity" in REGISTRY else set()
180
+
181
+ self._rebuild_models()
182
+ self._rebuild_benchmarks()
183
+ self._refresh_summary()
184
+ self.query_one("#model-list", SelectionList).focus()
185
+ self.watch(self.app, "theme", self._theme_changed, init=False)
186
+ self._count_tasks()
187
+
188
+ def _theme_changed(self, _theme: str) -> None:
189
+ self._rebuild_benchmarks()
190
+
191
+ # Task counting ----------------------------------------------------
192
+
193
+ @work(thread=True, exclusive=True, group="counts")
194
+ def _count_tasks(self) -> None:
195
+ for key in list(REGISTRY):
196
+ try:
197
+ count = task_count(key)
198
+ except Exception:
199
+ count = -1
200
+ self.app.call_from_thread(self._count_ready, key, count)
201
+
202
+ def _count_ready(self, key: str, count: int) -> None:
203
+ self.counts[key] = count
204
+ self._rebuild_benchmarks()
205
+ self._refresh_summary()
206
+
207
+ # List construction ------------------------------------------------
208
+
209
+ def _model_prompt(self, name: str) -> Text:
210
+ meta = self.model_meta.get(name, {})
211
+ status = str(meta.get("status") or "")
212
+ if isinstance(meta.get("loaded"), bool):
213
+ detail = status or ("loaded" if meta["loaded"] else "unloaded")
214
+ else:
215
+ detail = (
216
+ fmt_size(meta.get("size")) or status or str(meta.get("owned_by") or "")
217
+ )
218
+ text = Text()
219
+ text.append(_clip(name, MODEL_NAME_WIDTH).ljust(MODEL_NAME_WIDTH))
220
+ status = self.template_status.get(name)
221
+ if status == "checking":
222
+ text.append("… checking".rjust(11), style="dim")
223
+ elif status == "pass":
224
+ text.append("✓ template".rjust(11), style="green")
225
+ elif status == "warn":
226
+ text.append("! template".rjust(11), style="yellow")
227
+ elif status == "fail":
228
+ text.append("✗ template".rjust(11), style="red")
229
+ elif status == "unavailable":
230
+ text.append("— n/a".rjust(11), style="dim")
231
+ elif meta.get("decision"):
232
+ text.append("decision".rjust(9), style="cyan")
233
+ else:
234
+ text.append(detail.rjust(9), style="dim")
235
+ return text
236
+
237
+ def _bench_prompt(self, key: str) -> Text:
238
+ count = self.counts.get(key)
239
+ custom_count = getattr(REGISTRY[key], "list_task_count", "")
240
+ if count is None:
241
+ count_text = "counting…"
242
+ elif count < 0:
243
+ count_text = "unavailable"
244
+ else:
245
+ count_text = f"{custom_count or fmt_count(count)} tasks"
246
+
247
+ text = Text()
248
+ text.append(_clip(key, BENCHMARK_NAME_WIDTH).ljust(BENCHMARK_NAME_WIDTH))
249
+ text.append(count_text.rjust(BENCHMARK_COUNT_WIDTH), style="dim")
250
+ limit = self.limits.get(key)
251
+ if limit:
252
+ accent = score_palette(self.app.current_theme.dark)["mid"]
253
+ text.append(f" {slice_label(limit)}", style=f"bold {accent}")
254
+ else:
255
+ if key in self.missing_data:
256
+ text.append(" ↓ download", style="yellow")
257
+ text.append(f" {DESCRIPTIONS.get(key, '')}", style="dim")
258
+ return text
259
+
260
+ def _rebuild_models(self) -> None:
261
+ widget = self.query_one("#model-list", SelectionList)
262
+ needle = self.query_one("#model-filter", Input).value.strip().lower()
263
+ visible = [name for name in self.model_order if needle in name.lower()]
264
+ highlighted = widget.highlighted
265
+ widget.clear_options()
266
+ widget.add_options(
267
+ [
268
+ Selection(self._model_prompt(name), name, name in self.selected_models)
269
+ for name in visible
270
+ ]
271
+ )
272
+ if visible:
273
+ widget.highlighted = min(highlighted or 0, len(visible) - 1)
274
+ self.query_one("#model-title", SectionTitle).set_detail(
275
+ f"{len(self.selected_models)}/{len(self.model_order)} selected"
276
+ )
277
+
278
+ def _rebuild_benchmarks(self) -> None:
279
+ widget = self.query_one("#bench-list", SelectionList)
280
+ needle = self.query_one("#bench-filter", Input).value.strip().lower()
281
+ visible = [
282
+ key
283
+ for key in self.bench_order
284
+ if needle in key.lower() or needle in DESCRIPTIONS.get(key, "").lower()
285
+ ]
286
+ highlighted = widget.highlighted
287
+ widget.clear_options()
288
+ widget.add_options(
289
+ [
290
+ Selection(self._bench_prompt(key), key, key in self.selected_benchmarks)
291
+ for key in visible
292
+ ]
293
+ )
294
+ if visible:
295
+ widget.highlighted = min(highlighted or 0, len(visible) - 1)
296
+ self._refresh_bench_title(len(visible))
297
+
298
+ def _refresh_bench_title(self, shown: int | None = None) -> None:
299
+ detail = f"{len(self.selected_benchmarks)}/{len(self.bench_order)} selected"
300
+ if shown is None:
301
+ shown = self.query_one("#bench-list", SelectionList).option_count
302
+ if shown != len(self.bench_order):
303
+ detail = f"{detail} · {shown} shown"
304
+ self.query_one("#bench-title", SectionTitle).set_detail(detail)
305
+
306
+ # Events -----------------------------------------------------------
307
+
308
+ def on_input_changed(self, event: Input.Changed) -> None:
309
+ if event.input.id == "model-filter":
310
+ self._rebuild_models()
311
+ elif event.input.id == "bench-filter":
312
+ self._rebuild_benchmarks()
313
+ elif event.input.id in {"global-limit", "repair-attempts"}:
314
+ self._refresh_summary()
315
+
316
+ def on_input_submitted(self, event: Input.Submitted) -> None:
317
+ if event.input.id in {"model-filter", "bench-filter"}:
318
+ target = (
319
+ "#model-list" if event.input.id == "model-filter" else "#bench-list"
320
+ )
321
+ self.query_one(target, SelectionList).focus()
322
+ elif event.input.id in {
323
+ "global-limit",
324
+ "perturbation-seed",
325
+ "repair-attempts",
326
+ }:
327
+ self.action_start()
328
+
329
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
330
+ if event.checkbox.id != "choice-order":
331
+ return
332
+ self.query_one("#perturbation-seed", Input).disabled = not event.value
333
+ self._refresh_summary()
334
+
335
+ def on_select_changed(self, event: Select.Changed) -> None:
336
+ if event.select.id == "harness":
337
+ self._refresh_summary()
338
+
339
+ def on_selection_list_selected_changed(
340
+ self, event: SelectionList.SelectedChanged
341
+ ) -> None:
342
+ widget = event.selection_list
343
+ visible = {
344
+ widget.get_option_at_index(index).value
345
+ for index in range(widget.option_count)
346
+ }
347
+ chosen = set(widget.selected)
348
+ if widget.id == "model-list":
349
+ self.selected_models = (self.selected_models - visible) | chosen
350
+ self.query_one("#model-title", SectionTitle).set_detail(
351
+ f"{len(self.selected_models)}/{len(self.model_order)} selected"
352
+ )
353
+ else:
354
+ self.selected_benchmarks = (self.selected_benchmarks - visible) | chosen
355
+ self._refresh_bench_title()
356
+ self._refresh_summary()
357
+
358
+ def on_button_pressed(self, event: Button.Pressed) -> None:
359
+ if event.button.id == "start":
360
+ self.action_start()
361
+ elif event.button.id == "check-templates":
362
+ self.action_check_templates()
363
+ elif event.button.id == "datasets":
364
+ self.action_datasets()
365
+
366
+ # Actions ----------------------------------------------------------
367
+
368
+ def _focused_list(self) -> SelectionList | None:
369
+ node = self.focused
370
+ while node is not None:
371
+ if isinstance(node, SelectionList):
372
+ return node
373
+ node = node.parent
374
+ return None
375
+
376
+ def action_select_all(self) -> None:
377
+ widget = self._focused_list()
378
+ if widget is not None:
379
+ widget.select_all()
380
+
381
+ def action_select_none(self) -> None:
382
+ widget = self._focused_list()
383
+ if widget is not None:
384
+ widget.deselect_all()
385
+
386
+ def action_invert(self) -> None:
387
+ widget = self._focused_list()
388
+ if widget is not None:
389
+ widget.toggle_all()
390
+
391
+ def action_focus_filter(self) -> None:
392
+ widget = self._focused_list()
393
+ target = (
394
+ "#bench-filter"
395
+ if widget is not None and widget.id == "bench-list"
396
+ else "#model-filter"
397
+ )
398
+ self.query_one(target, Input).focus()
399
+
400
+ def action_set_limit(self) -> None:
401
+ widget = self.query_one("#bench-list", SelectionList)
402
+ index = widget.highlighted
403
+ if index is None:
404
+ return
405
+ key = widget.get_option_at_index(index).value
406
+ total = slice_task_count(key) if self.counts.get(key, 0) >= 0 else -1
407
+
408
+ def apply(spec: str | None) -> None:
409
+ if spec:
410
+ self.limits[key] = spec
411
+ else:
412
+ self.limits.pop(key, None)
413
+ self._rebuild_benchmarks()
414
+ self._refresh_summary()
415
+
416
+ self.app.push_screen(LimitScreen(key, self.limits.get(key, ""), total), apply)
417
+
418
+ def action_toggle_perturbation(self) -> None:
419
+ checkbox = self.query_one("#choice-order", Checkbox)
420
+ checkbox.value = not checkbox.value
421
+
422
+ def action_check_templates(self) -> None:
423
+ targets = [
424
+ name
425
+ for name in self.model_order
426
+ if not self.selected_models or name in self.selected_models
427
+ ]
428
+ if not targets:
429
+ return
430
+ if self.app.demo:
431
+ self.notify("Template checks require a llama.cpp or llama-swap server.")
432
+ return
433
+ for name in targets:
434
+ self.template_status[name] = "checking"
435
+ self.checking_templates = True
436
+ self._rebuild_models()
437
+ self.query_one("#check-templates", Button).disabled = True
438
+ self.query_one("#start", Button).disabled = True
439
+ self._run_template_checks(targets)
440
+
441
+ @work(thread=True, exclusive=True, group="template-checks")
442
+ def _run_template_checks(self, targets: list[str]) -> None:
443
+ reports: list[TemplateReport] = []
444
+ for name in targets:
445
+ report = check_template(self.app.client, name)
446
+ reports.append(report)
447
+ self.app.call_from_thread(self._template_check_ready, report)
448
+ self.app.call_from_thread(self._template_checks_finished, reports)
449
+
450
+ def _template_check_ready(self, report: TemplateReport) -> None:
451
+ self.template_status[report.model] = report.status
452
+ self._rebuild_models()
453
+
454
+ def _template_checks_finished(self, reports: list[TemplateReport]) -> None:
455
+ self.checking_templates = False
456
+ self.query_one("#check-templates", Button).disabled = False
457
+ self.query_one("#start", Button).disabled = False
458
+ self.app.push_screen(TemplateCheckScreen(reports))
459
+
460
+ def action_datasets(self) -> None:
461
+ def refresh(installed: bool | None) -> None:
462
+ if installed:
463
+ self.missing_data = _missing_data()
464
+ self._rebuild_benchmarks()
465
+
466
+ self.app.push_screen(DatasetsScreen(), refresh)
467
+
468
+ def action_back(self) -> None:
469
+ if self.checking_templates:
470
+ self.notify("Wait for the template check to finish.", timeout=3)
471
+ return
472
+ self.app.pop_screen()
473
+
474
+ def action_start(self) -> None:
475
+ if self.checking_templates:
476
+ self.notify("Wait for the template check to finish.", timeout=3)
477
+ return
478
+ jobs = self._build_jobs()
479
+ if jobs is None:
480
+ return
481
+ if self.app.demo and isinstance(self.app.client, DemoClient):
482
+ perturbations = sorted(
483
+ {
484
+ (job.perturbation, job.perturbation_seed)
485
+ for job in jobs
486
+ if job.perturbation
487
+ }
488
+ )
489
+ self.app.client.prime(
490
+ sorted({job.benchmark for job in jobs}),
491
+ perturbations=perturbations,
492
+ )
493
+ fetching = sorted({job.benchmark for job in jobs} & self.missing_data)
494
+ if fetching:
495
+ self.notify(
496
+ f"Downloading {', '.join(fetching)} before the first task.",
497
+ timeout=6,
498
+ )
499
+ self.app.start_run(jobs, force_unload=self._force_unload_enabled())
500
+
501
+ # Planning ---------------------------------------------------------
502
+
503
+ def _global_limit(self) -> str:
504
+ return self.query_one("#global-limit", Input).value.strip()
505
+
506
+ def _limit_for(self, key: str) -> str | None:
507
+ return self.limits.get(key) or self._global_limit() or None
508
+
509
+ def _choice_order_enabled(self) -> bool:
510
+ return self.query_one("#choice-order", Checkbox).value
511
+
512
+ def _force_unload_enabled(self) -> bool:
513
+ return self.query_one("#force-unload", Checkbox).value
514
+
515
+ def _harnesses(self) -> tuple[str, ...]:
516
+ value = self.query_one("#harness", Select).value
517
+ return ("direct", "pi") if value == "both" else (str(value),)
518
+
519
+ def _repair_attempts(self) -> int:
520
+ value = self.query_one("#repair-attempts", Input).value.strip()
521
+ try:
522
+ return int(value)
523
+ except ValueError:
524
+ return -1
525
+
526
+ def _perturbation_seed(self) -> int | None:
527
+ value = self.query_one("#perturbation-seed", Input).value.strip()
528
+ try:
529
+ return int(value)
530
+ except ValueError:
531
+ return None
532
+
533
+ def _build_jobs(self) -> list[JobSpec] | None:
534
+ models = [name for name in self.model_order if name in self.selected_models]
535
+ benches = [key for key in self.bench_order if key in self.selected_benchmarks]
536
+
537
+ if not models:
538
+ self.notify("Select at least one model", severity="error", timeout=4)
539
+ self.query_one("#model-list", SelectionList).focus()
540
+ return None
541
+ if not benches:
542
+ self.notify("Select at least one benchmark", severity="error", timeout=4)
543
+ self.query_one("#bench-list", SelectionList).focus()
544
+ return None
545
+ if self.app.demo and "pi" in self._harnesses():
546
+ self.notify(
547
+ "Pi harness is unavailable in demo mode.",
548
+ severity="error",
549
+ timeout=5,
550
+ )
551
+ self.query_one("#harness", Select).focus()
552
+ return None
553
+
554
+ repair_attempts = self._repair_attempts()
555
+ if not 0 <= repair_attempts <= MAX_REPAIR_ATTEMPTS:
556
+ self.notify(
557
+ f"Repair attempts must be between 0 and {MAX_REPAIR_ATTEMPTS}.",
558
+ severity="error",
559
+ timeout=4,
560
+ )
561
+ self.query_one("#repair-attempts", Input).focus()
562
+ return None
563
+
564
+ choice_order = self._choice_order_enabled()
565
+ seed = self._perturbation_seed()
566
+ if choice_order and seed is None:
567
+ self.notify(
568
+ "Choice-order seed must be an integer.",
569
+ severity="error",
570
+ timeout=4,
571
+ )
572
+ self.query_one("#perturbation-seed", Input).focus()
573
+ return None
574
+ if choice_order:
575
+ unsupported = unsupported_benchmarks(CHOICE_ORDER, benches)
576
+ if unsupported:
577
+ self.notify(
578
+ "Choice-order is unsupported for: " + ", ".join(unsupported),
579
+ severity="error",
580
+ timeout=6,
581
+ )
582
+ self.query_one("#bench-list", SelectionList).focus()
583
+ return None
584
+
585
+ for key in benches:
586
+ total = slice_task_count(key) if self.counts.get(key, 0) >= 0 else -1
587
+ if total < 0:
588
+ self.notify(
589
+ f"{key}: dataset could not be loaded.",
590
+ severity="error",
591
+ timeout=6,
592
+ )
593
+ return None
594
+ spec = self._limit_for(key)
595
+ try:
596
+ # A count of 0 means the tally is still running, which
597
+ # parse_slice reads as "syntax only".
598
+ parse_slice(spec, max(total, 0))
599
+ except SliceError:
600
+ self.notify(
601
+ f"{key}: {spec!r} is not a valid slice. Use N, -N or A-B.",
602
+ severity="error",
603
+ timeout=6,
604
+ )
605
+ return None
606
+
607
+ jobs = job_matrix(
608
+ models,
609
+ [(key, self._limit_for(key), None) for key in benches],
610
+ self._harnesses(),
611
+ repair_attempts=repair_attempts,
612
+ **(
613
+ {"perturbation": CHOICE_ORDER, "perturbation_seed": seed}
614
+ if choice_order and seed is not None
615
+ else {}
616
+ ),
617
+ )
618
+ try:
619
+ jobs = route_decision_jobs(jobs, self.app.client)
620
+ except ValueError as exc:
621
+ self.notify(str(exc), severity="error", timeout=8)
622
+ self.query_one("#bench-list", SelectionList).focus()
623
+ return None
624
+ expanded = expand_jobs(jobs, self.app.client)
625
+ if not expanded:
626
+ self.notify(
627
+ "No supported RULER context bucket fits the selected model(s).",
628
+ severity="error",
629
+ timeout=6,
630
+ )
631
+ return None
632
+ return expanded
633
+
634
+ def _planned_tasks(self, key: str) -> int:
635
+ loaded_total = self.counts.get(key, 0)
636
+ if loaded_total <= 0:
637
+ return 0
638
+ total = slice_task_count(key)
639
+ try:
640
+ start, end = parse_slice(self._limit_for(key), total)
641
+ except SliceError:
642
+ return 0
643
+ return max(0, end - start)
644
+
645
+ def _variant_count(self, key: str, model: str) -> int:
646
+ buckets = getattr(REGISTRY[key], "context_buckets", ())
647
+ if not buckets:
648
+ return 1
649
+ context = self.model_meta.get(model, {}).get("context_length")
650
+ if isinstance(context, int) and not isinstance(context, bool) and context > 0:
651
+ return sum(bucket <= context for bucket in buckets)
652
+ return len(buckets)
653
+
654
+ def _refresh_summary(self) -> None:
655
+ models = len(self.selected_models)
656
+ benches = [key for key in self.bench_order if key in self.selected_benchmarks]
657
+ choice_order = self._choice_order_enabled()
658
+ unsupported = (
659
+ unsupported_benchmarks(CHOICE_ORDER, benches) if choice_order else []
660
+ )
661
+ multiplier = (2 if choice_order else 1) * len(self._harnesses())
662
+ runs = (
663
+ sum(
664
+ self._variant_count(key, model)
665
+ for model in self.selected_models
666
+ for key in benches
667
+ )
668
+ * multiplier
669
+ )
670
+ tasks = (
671
+ sum(
672
+ self._planned_tasks(key) * self._variant_count(key, model)
673
+ for model in self.selected_models
674
+ for key in benches
675
+ )
676
+ * multiplier
677
+ )
678
+
679
+ if runs == 0:
680
+ summary = Content.from_markup(
681
+ "[dim]Nothing selected yet — pick models and benchmarks.[/dim]"
682
+ )
683
+ elif unsupported:
684
+ summary = Content.from_markup(
685
+ "[red]Choice-order unsupported for: $unsupported[/red]"
686
+ "[dim] — change the benchmark selection or turn it off.[/dim]",
687
+ unsupported=", ".join(unsupported),
688
+ )
689
+ else:
690
+ summary = Content.from_markup(
691
+ "[b]$models[/b][dim] model$ms × [/dim][b]$benches[/b][dim] benchmark$bs"
692
+ " = [/dim][b]$runs[/b][dim] run$rs · [/dim][b]$tasks[/b][dim] tasks total"
693
+ "$paired[/dim]",
694
+ models=models,
695
+ ms="" if models == 1 else "s",
696
+ benches=len(benches),
697
+ bs="" if len(benches) == 1 else "s",
698
+ runs=runs,
699
+ rs="" if runs == 1 else "s",
700
+ tasks=fmt_count(tasks),
701
+ paired=(
702
+ (" · clean + choice-order" if choice_order else "")
703
+ + (
704
+ f" · {self._repair_attempts()} verifier repair turn(s)"
705
+ if self._repair_attempts() > 0
706
+ else ""
707
+ )
708
+ + (
709
+ " · force unload between models"
710
+ if self._force_unload_enabled()
711
+ else ""
712
+ )
713
+ ),
714
+ )
715
+ self.query_one("#plan-summary", Static).update(summary)
716
+
717
+
718
+ def _clip(text: str, width: int) -> str:
719
+ return text if len(text) <= width else text[: width - 1] + "…"
720
+
721
+
722
+ def _model_is_loaded(model: dict) -> bool:
723
+ """Return whether discovery positively identified a resident model."""
724
+ loaded = model.get("loaded")
725
+ if isinstance(loaded, bool):
726
+ return loaded
727
+ status = str(model.get("status") or "").strip().lower()
728
+ return status in {"loaded", "running"}
729
+
730
+
731
+ def _missing_data() -> set[str]:
732
+ """Benchmarks whose data a run would have to download first."""
733
+ return {entry.benchmark for entry in catalog.statuses() if entry.installable}