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.
- benchkit/__init__.py +1 -0
- benchkit/_pi_proxy.py +219 -0
- benchkit/answer_key_guard.ts +49 -0
- benchkit/artifacts.py +87 -0
- benchkit/benchmarks/__init__.py +105 -0
- benchkit/benchmarks/aider_polyglot.py +405 -0
- benchkit/benchmarks/arc.py +52 -0
- benchkit/benchmarks/banking77.py +149 -0
- benchkit/benchmarks/base.py +19 -0
- benchkit/benchmarks/boolq.py +49 -0
- benchkit/benchmarks/chartqa.py +133 -0
- benchkit/benchmarks/code_verifier.py +77 -0
- benchkit/benchmarks/downloads.py +166 -0
- benchkit/benchmarks/evalplus.py +288 -0
- benchkit/benchmarks/git_surgery.py +485 -0
- benchkit/benchmarks/gpqa.py +56 -0
- benchkit/benchmarks/gsm8k.py +97 -0
- benchkit/benchmarks/hellaswag.py +49 -0
- benchkit/benchmarks/humaneval.py +103 -0
- benchkit/benchmarks/ifeval.py +56 -0
- benchkit/benchmarks/ifeval_instructions.py +477 -0
- benchkit/benchmarks/mbpp.py +60 -0
- benchkit/benchmarks/mc_arena.py +479 -0
- benchkit/benchmarks/mcq.py +94 -0
- benchkit/benchmarks/medfailbench.py +537 -0
- benchkit/benchmarks/mmlu.py +54 -0
- benchkit/benchmarks/mmlu_pro.py +62 -0
- benchkit/benchmarks/mmvp.py +115 -0
- benchkit/benchmarks/ocrbench.py +96 -0
- benchkit/benchmarks/openbookqa.py +52 -0
- benchkit/benchmarks/patcheval.py +929 -0
- benchkit/benchmarks/piqa.py +52 -0
- benchkit/benchmarks/ruler.py +172 -0
- benchkit/benchmarks/ruler_generator.py +535 -0
- benchkit/benchmarks/sanity.py +174 -0
- benchkit/benchmarks/treejs_arena.py +268 -0
- benchkit/benchmarks/truthfulqa.py +52 -0
- benchkit/benchmarks/utils.py +25 -0
- benchkit/benchmarks/vision.py +230 -0
- benchkit/benchmarks/vstar.py +108 -0
- benchkit/benchmarks/winogrande.py +52 -0
- benchkit/benchmarks/xstest.py +116 -0
- benchkit/browser.py +429 -0
- benchkit/catalog.py +121 -0
- benchkit/cli.py +1000 -0
- benchkit/client.py +1413 -0
- benchkit/controls.py +87 -0
- benchkit/datasets/MMLU_LICENSE.txt +21 -0
- benchkit/datasets/README.md +142 -0
- benchkit/datasets/arc_challenge.jsonl +1172 -0
- benchkit/datasets/banking77.jsonl +3080 -0
- benchkit/datasets/banking77_intents.json +79 -0
- benchkit/datasets/boolq.jsonl +3270 -0
- benchkit/datasets/code_verifier.jsonl +500 -0
- benchkit/datasets/gpqa_diamond.jsonl +198 -0
- benchkit/datasets/gsm8k.jsonl +1319 -0
- benchkit/datasets/hellaswag.jsonl +1000 -0
- benchkit/datasets/humaneval.jsonl +164 -0
- benchkit/datasets/ifeval.jsonl +541 -0
- benchkit/datasets/mbpp.jsonl +500 -0
- benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
- benchkit/datasets/medfailbench_hard30.jsonl +30 -0
- benchkit/datasets/medfailbench_hard30.md +13 -0
- benchkit/datasets/openbookqa.jsonl +500 -0
- benchkit/datasets/piqa.jsonl +1838 -0
- benchkit/datasets/sanity_code.jsonl +6 -0
- benchkit/datasets/truthfulqa.jsonl +817 -0
- benchkit/datasets/winogrande.jsonl +1267 -0
- benchkit/datasets/xstest.csv +451 -0
- benchkit/decision.py +194 -0
- benchkit/demo.py +451 -0
- benchkit/dockerfiles.py +247 -0
- benchkit/engine.py +1270 -0
- benchkit/evaluation.py +195 -0
- benchkit/events.py +141 -0
- benchkit/executor.py +96 -0
- benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
- benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
- benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
- benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
- benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
- benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
- benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
- benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
- benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
- benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
- benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
- benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
- benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
- benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
- benchkit/git_surgery/secret-in-history/README.md +27 -0
- benchkit/git_surgery/secret-in-history/setup.sh +107 -0
- benchkit/git_surgery/secret-in-history/verify.sh +106 -0
- benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
- benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
- benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
- benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
- benchkit/history.py +474 -0
- benchkit/jobs.py +332 -0
- benchkit/leakage.py +51 -0
- benchkit/looping.py +873 -0
- benchkit/mc_render.py +274 -0
- benchkit/mc_viewer/dist/atlas.png +0 -0
- benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
- benchkit/mc_viewer/dist/version.json +4 -0
- benchkit/mc_viewer/dist/viewer.html +23 -0
- benchkit/mc_viewer/dist/viewer.js +2 -0
- benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
- benchkit/mc_viewer/dist/worker.js +2 -0
- benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
- benchkit/metrics.py +100 -0
- benchkit/ollama.py +60 -0
- benchkit/perf.py +353 -0
- benchkit/perf_report.py +207 -0
- benchkit/perturbations.py +208 -0
- benchkit/pi_agent.py +476 -0
- benchkit/pi_package/package-lock.json +1827 -0
- benchkit/pi_package/package.json +8 -0
- benchkit/report.py +142 -0
- benchkit/report_markdown.py +645 -0
- benchkit/results.py +688 -0
- benchkit/retry.py +163 -0
- benchkit/runner.py +769 -0
- benchkit/sandbox.py +1167 -0
- benchkit/template_check.py +291 -0
- benchkit/templates/arena.html +504 -0
- benchkit/templates/history.html +58 -0
- benchkit/templates/perf.html +509 -0
- benchkit/templates/report.html +769 -0
- benchkit/tui/__init__.py +5 -0
- benchkit/tui/app.py +87 -0
- benchkit/tui/app.tcss +512 -0
- benchkit/tui/formatting.py +84 -0
- benchkit/tui/screens/__init__.py +1 -0
- benchkit/tui/screens/connect.py +191 -0
- benchkit/tui/screens/detail.py +288 -0
- benchkit/tui/screens/help.py +84 -0
- benchkit/tui/screens/modals.py +544 -0
- benchkit/tui/screens/results.py +361 -0
- benchkit/tui/screens/run.py +988 -0
- benchkit/tui/screens/setup.py +733 -0
- benchkit/tui/theme.py +135 -0
- benchkit/tui/widgets.py +120 -0
- benchkit_cli-0.1.0.dist-info/METADATA +294 -0
- benchkit_cli-0.1.0.dist-info/RECORD +149 -0
- benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
- benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
}
|