benchkit-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. benchkit/__init__.py +1 -0
  2. benchkit/_pi_proxy.py +219 -0
  3. benchkit/answer_key_guard.ts +49 -0
  4. benchkit/artifacts.py +87 -0
  5. benchkit/benchmarks/__init__.py +105 -0
  6. benchkit/benchmarks/aider_polyglot.py +405 -0
  7. benchkit/benchmarks/arc.py +52 -0
  8. benchkit/benchmarks/banking77.py +149 -0
  9. benchkit/benchmarks/base.py +19 -0
  10. benchkit/benchmarks/boolq.py +49 -0
  11. benchkit/benchmarks/chartqa.py +133 -0
  12. benchkit/benchmarks/code_verifier.py +77 -0
  13. benchkit/benchmarks/downloads.py +166 -0
  14. benchkit/benchmarks/evalplus.py +288 -0
  15. benchkit/benchmarks/git_surgery.py +485 -0
  16. benchkit/benchmarks/gpqa.py +56 -0
  17. benchkit/benchmarks/gsm8k.py +97 -0
  18. benchkit/benchmarks/hellaswag.py +49 -0
  19. benchkit/benchmarks/humaneval.py +103 -0
  20. benchkit/benchmarks/ifeval.py +56 -0
  21. benchkit/benchmarks/ifeval_instructions.py +477 -0
  22. benchkit/benchmarks/mbpp.py +60 -0
  23. benchkit/benchmarks/mc_arena.py +479 -0
  24. benchkit/benchmarks/mcq.py +94 -0
  25. benchkit/benchmarks/medfailbench.py +537 -0
  26. benchkit/benchmarks/mmlu.py +54 -0
  27. benchkit/benchmarks/mmlu_pro.py +62 -0
  28. benchkit/benchmarks/mmvp.py +115 -0
  29. benchkit/benchmarks/ocrbench.py +96 -0
  30. benchkit/benchmarks/openbookqa.py +52 -0
  31. benchkit/benchmarks/patcheval.py +929 -0
  32. benchkit/benchmarks/piqa.py +52 -0
  33. benchkit/benchmarks/ruler.py +172 -0
  34. benchkit/benchmarks/ruler_generator.py +535 -0
  35. benchkit/benchmarks/sanity.py +174 -0
  36. benchkit/benchmarks/treejs_arena.py +268 -0
  37. benchkit/benchmarks/truthfulqa.py +52 -0
  38. benchkit/benchmarks/utils.py +25 -0
  39. benchkit/benchmarks/vision.py +230 -0
  40. benchkit/benchmarks/vstar.py +108 -0
  41. benchkit/benchmarks/winogrande.py +52 -0
  42. benchkit/benchmarks/xstest.py +116 -0
  43. benchkit/browser.py +429 -0
  44. benchkit/catalog.py +121 -0
  45. benchkit/cli.py +1000 -0
  46. benchkit/client.py +1413 -0
  47. benchkit/controls.py +87 -0
  48. benchkit/datasets/MMLU_LICENSE.txt +21 -0
  49. benchkit/datasets/README.md +142 -0
  50. benchkit/datasets/arc_challenge.jsonl +1172 -0
  51. benchkit/datasets/banking77.jsonl +3080 -0
  52. benchkit/datasets/banking77_intents.json +79 -0
  53. benchkit/datasets/boolq.jsonl +3270 -0
  54. benchkit/datasets/code_verifier.jsonl +500 -0
  55. benchkit/datasets/gpqa_diamond.jsonl +198 -0
  56. benchkit/datasets/gsm8k.jsonl +1319 -0
  57. benchkit/datasets/hellaswag.jsonl +1000 -0
  58. benchkit/datasets/humaneval.jsonl +164 -0
  59. benchkit/datasets/ifeval.jsonl +541 -0
  60. benchkit/datasets/mbpp.jsonl +500 -0
  61. benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
  62. benchkit/datasets/medfailbench_hard30.jsonl +30 -0
  63. benchkit/datasets/medfailbench_hard30.md +13 -0
  64. benchkit/datasets/openbookqa.jsonl +500 -0
  65. benchkit/datasets/piqa.jsonl +1838 -0
  66. benchkit/datasets/sanity_code.jsonl +6 -0
  67. benchkit/datasets/truthfulqa.jsonl +817 -0
  68. benchkit/datasets/winogrande.jsonl +1267 -0
  69. benchkit/datasets/xstest.csv +451 -0
  70. benchkit/decision.py +194 -0
  71. benchkit/demo.py +451 -0
  72. benchkit/dockerfiles.py +247 -0
  73. benchkit/engine.py +1270 -0
  74. benchkit/evaluation.py +195 -0
  75. benchkit/events.py +141 -0
  76. benchkit/executor.py +96 -0
  77. benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
  78. benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
  79. benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
  80. benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
  81. benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
  82. benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
  83. benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
  84. benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
  85. benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
  86. benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
  87. benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
  88. benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
  89. benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
  90. benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
  91. benchkit/git_surgery/secret-in-history/README.md +27 -0
  92. benchkit/git_surgery/secret-in-history/setup.sh +107 -0
  93. benchkit/git_surgery/secret-in-history/verify.sh +106 -0
  94. benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
  95. benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
  96. benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
  97. benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
  98. benchkit/history.py +474 -0
  99. benchkit/jobs.py +332 -0
  100. benchkit/leakage.py +51 -0
  101. benchkit/looping.py +873 -0
  102. benchkit/mc_render.py +274 -0
  103. benchkit/mc_viewer/dist/atlas.png +0 -0
  104. benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
  105. benchkit/mc_viewer/dist/version.json +4 -0
  106. benchkit/mc_viewer/dist/viewer.html +23 -0
  107. benchkit/mc_viewer/dist/viewer.js +2 -0
  108. benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
  109. benchkit/mc_viewer/dist/worker.js +2 -0
  110. benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
  111. benchkit/metrics.py +100 -0
  112. benchkit/ollama.py +60 -0
  113. benchkit/perf.py +353 -0
  114. benchkit/perf_report.py +207 -0
  115. benchkit/perturbations.py +208 -0
  116. benchkit/pi_agent.py +476 -0
  117. benchkit/pi_package/package-lock.json +1827 -0
  118. benchkit/pi_package/package.json +8 -0
  119. benchkit/report.py +142 -0
  120. benchkit/report_markdown.py +645 -0
  121. benchkit/results.py +688 -0
  122. benchkit/retry.py +163 -0
  123. benchkit/runner.py +769 -0
  124. benchkit/sandbox.py +1167 -0
  125. benchkit/template_check.py +291 -0
  126. benchkit/templates/arena.html +504 -0
  127. benchkit/templates/history.html +58 -0
  128. benchkit/templates/perf.html +509 -0
  129. benchkit/templates/report.html +769 -0
  130. benchkit/tui/__init__.py +5 -0
  131. benchkit/tui/app.py +87 -0
  132. benchkit/tui/app.tcss +512 -0
  133. benchkit/tui/formatting.py +84 -0
  134. benchkit/tui/screens/__init__.py +1 -0
  135. benchkit/tui/screens/connect.py +191 -0
  136. benchkit/tui/screens/detail.py +288 -0
  137. benchkit/tui/screens/help.py +84 -0
  138. benchkit/tui/screens/modals.py +544 -0
  139. benchkit/tui/screens/results.py +361 -0
  140. benchkit/tui/screens/run.py +988 -0
  141. benchkit/tui/screens/setup.py +733 -0
  142. benchkit/tui/theme.py +135 -0
  143. benchkit/tui/widgets.py +120 -0
  144. benchkit_cli-0.1.0.dist-info/METADATA +294 -0
  145. benchkit_cli-0.1.0.dist-info/RECORD +149 -0
  146. benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
  147. benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  148. benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  149. benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
benchkit/engine.py ADDED
@@ -0,0 +1,1270 @@
1
+ """Benchmark execution engine shared by the TUI and the headless runner.
2
+
3
+ The engine is deliberately UI agnostic: it walks a list of jobs (one model x
4
+ one benchmark x one optional task slice), emits events as it goes, and returns
5
+ the same result dictionaries that :mod:`benchkit.report` already knows how to
6
+ serialize.
7
+
8
+ Only the stateful part lives here: the job loop, the request workers, verifier
9
+ repairs and per-task finalization. Job planning is in :mod:`benchkit.jobs`, the
10
+ events in :mod:`benchkit.events`, pause/skip/stop in :mod:`benchkit.controls`
11
+ and result shaping in :mod:`benchkit.results`. Their public names are
12
+ re-exported, so ``from benchkit.engine import ...`` keeps working.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import os
19
+ import threading
20
+ import time
21
+ from collections.abc import Callable
22
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
23
+ from dataclasses import dataclass, field, replace
24
+
25
+ import httpx
26
+
27
+ from benchkit import decision
28
+ from benchkit.benchmarks.base import Task
29
+ from benchkit.client import GenerationUpdate, InferenceClient
30
+ from benchkit.controls import RunControls
31
+ from benchkit.evaluation import (
32
+ EvaluationResult,
33
+ combine_attempts,
34
+ evaluate_response,
35
+ standalone_repair_prompt,
36
+ )
37
+ from benchkit.events import (
38
+ EngineEvent,
39
+ GenerationProgress,
40
+ JobCompleted,
41
+ JobStarted,
42
+ ModelUnloaded,
43
+ RunCompleted,
44
+ RunFailed,
45
+ RunStarted,
46
+ Sink,
47
+ TaskCompleted,
48
+ TaskPhase,
49
+ )
50
+ from benchkit.jobs import (
51
+ _BENCHMARKS,
52
+ _TASKS,
53
+ MAX_REPAIR_ATTEMPTS,
54
+ JobSpec,
55
+ SliceError,
56
+ benchmark,
57
+ expand_jobs,
58
+ option_count,
59
+ parse_slice,
60
+ plan_total_tasks,
61
+ prompt_for,
62
+ repair_budget,
63
+ route_decision_jobs,
64
+ slice_label,
65
+ slice_task_count,
66
+ task_count,
67
+ tasks_for,
68
+ tasks_for_job,
69
+ )
70
+ from benchkit.looping import LoopAnalyzer
71
+ from benchkit.perturbations import annotate_robustness, perturb_task
72
+ from benchkit.pi_agent import PiAgentRunner
73
+ from benchkit.results import (
74
+ JobTally,
75
+ TaskOutcome,
76
+ TaskRecord,
77
+ annotate_harness_effect,
78
+ empty_result,
79
+ job_result,
80
+ )
81
+ from benchkit.sandbox import cleanup_run_resources, run_resources_created
82
+
83
+ # ``_BENCHMARKS`` and ``_TASKS`` are the same dict objects as in benchkit.jobs, so
84
+ # patching the registry caches through this module still reaches the accessors.
85
+ __all__ = [
86
+ "ERROR_GENERATION",
87
+ "MAX_REPAIR_ATTEMPTS",
88
+ "_BENCHMARKS",
89
+ "_TASKS",
90
+ "Engine",
91
+ "EngineEvent",
92
+ "GenerationProgress",
93
+ "JobCompleted",
94
+ "JobSpec",
95
+ "JobStarted",
96
+ "ModelUnloaded",
97
+ "RunCompleted",
98
+ "RunControls",
99
+ "RunFailed",
100
+ "RunStarted",
101
+ "Sink",
102
+ "SliceError",
103
+ "TaskCompleted",
104
+ "TaskPhase",
105
+ "TaskRecord",
106
+ "annotate_harness_effect",
107
+ "benchmark",
108
+ "expand_jobs",
109
+ "option_count",
110
+ "parse_slice",
111
+ "plan_total_tasks",
112
+ "prompt_for",
113
+ "repair_budget",
114
+ "route_decision_jobs",
115
+ "slice_label",
116
+ "slice_task_count",
117
+ "task_count",
118
+ "tasks_for",
119
+ "tasks_for_job",
120
+ ]
121
+
122
+
123
+ class _GenerationCancelled(Exception):
124
+ """Unwind a streaming request from its own worker thread immediately."""
125
+
126
+
127
+ class _DoomLoopKilled(Exception):
128
+ """Unwind a generation that exceeded the configured loop threshold."""
129
+
130
+
131
+ def _env_float(name: str, default: float) -> float:
132
+ try:
133
+ return float(os.environ.get(name, str(default)))
134
+ except ValueError:
135
+ return default
136
+
137
+
138
+ def _env_bool(name: str, default: bool) -> bool:
139
+ raw = os.environ.get(name)
140
+ if raw is None:
141
+ return default
142
+ value = raw.strip().lower()
143
+ if value in {"1", "true", "yes", "on"}:
144
+ return True
145
+ if value in {"0", "false", "no", "off"}:
146
+ return False
147
+ return default
148
+
149
+
150
+ ERROR_GENERATION = {
151
+ "thinking": "",
152
+ "response": "",
153
+ "tok_s": 0.0,
154
+ "eval_count": 0,
155
+ "eval_duration_ns": 0,
156
+ "response_time_s": 0.0,
157
+ "done_reason": "error",
158
+ "timed_out": False,
159
+ "cancelled": False,
160
+ "loop_killed": False,
161
+ "length_exceeded": False,
162
+ "harness_error": False,
163
+ "trace_status": "unavailable",
164
+ }
165
+
166
+
167
+ @dataclass
168
+ class _GeneratedTask:
169
+ """Generation state handed from a request worker to serial evaluation."""
170
+
171
+ position: int
172
+ task: Task
173
+ prompt: str
174
+ perturbation: dict
175
+ gen: dict
176
+ error: str
177
+ errors: int
178
+ analyzer: LoopAnalyzer
179
+ first_answer_at: float | None
180
+ loop_detected_at: float | None
181
+
182
+
183
+ @dataclass
184
+ class Engine:
185
+ """Runs a list of jobs, streaming events to a sink."""
186
+
187
+ client: InferenceClient
188
+ jobs: list[JobSpec]
189
+ sink: Sink | None = None
190
+ controls: RunControls = field(default_factory=RunControls)
191
+ loop_kill_enabled: bool = field(
192
+ default_factory=lambda: _env_bool("BENCHKIT_LOOP_KILL", True)
193
+ )
194
+ loop_kill_percent: float = field(
195
+ default_factory=lambda: _env_float("BENCHKIT_LOOP_KILL_PERCENT", 80.0)
196
+ )
197
+ loop_kill_seconds: float = field(
198
+ default_factory=lambda: _env_float("BENCHKIT_LOOP_KILL_SECONDS", 10.0)
199
+ )
200
+ failure: str | None = field(default=None, init=False)
201
+ _emit_lock: threading.Lock = field(
202
+ default_factory=threading.Lock, init=False, repr=False
203
+ )
204
+ _evaluation_lock: threading.Lock = field(
205
+ default_factory=threading.Lock, init=False, repr=False
206
+ )
207
+ _pi_runner: PiAgentRunner | None = field(default=None, init=False, repr=False)
208
+ _workspace_pi_runners: dict[str, PiAgentRunner] = field(
209
+ default_factory=dict, init=False, repr=False
210
+ )
211
+ # Every Pi image is built right before a task and removed right after it;
212
+ # the count keeps an image alive while a concurrent task still uses it.
213
+ _pi_image_users: dict[str, int] = field(
214
+ default_factory=dict, init=False, repr=False
215
+ )
216
+ _pi_lock: threading.Lock = field(
217
+ default_factory=threading.Lock, init=False, repr=False
218
+ )
219
+
220
+ def __post_init__(self) -> None:
221
+ self.loop_kill_percent = min(100.0, max(0.0, self.loop_kill_percent))
222
+ self.loop_kill_seconds = max(0.0, self.loop_kill_seconds)
223
+
224
+ def emit(self, event: EngineEvent) -> None:
225
+ if self.sink is not None:
226
+ # Generation progress originates from multiple request workers.
227
+ # Keep every sink callback atomic so front-ends can stay ordinary,
228
+ # single-threaded state machines.
229
+ with self._emit_lock:
230
+ self.sink(event)
231
+
232
+ def _pi_entry(
233
+ self, bench: object | None = None, task: Task | None = None
234
+ ) -> tuple[str, PiAgentRunner]:
235
+ """Return the runner for a task's image and the key it is cached under."""
236
+ name = str(getattr(bench, "name", type(bench).__name__))
237
+ task_image_factory = getattr(bench, "pi_image_for_task", None)
238
+ image_factory = getattr(bench, "pi_image", None)
239
+ if callable(task_image_factory) and task is not None:
240
+ image = task_image_factory(task)
241
+ key = f"{name}:{image.image}"
242
+ elif callable(image_factory):
243
+ image, key = None, name
244
+ else:
245
+ with self._pi_lock:
246
+ if self._pi_runner is None:
247
+ if getattr(self.client, "provider", None) == "demo":
248
+ raise RuntimeError("Pi harness is unavailable in demo mode")
249
+ self._pi_runner = PiAgentRunner(self.client)
250
+ return "", self._pi_runner
251
+ with self._pi_lock:
252
+ if key not in self._workspace_pi_runners:
253
+ self._workspace_pi_runners[key] = PiAgentRunner(
254
+ self.client, image=image if image is not None else image_factory()
255
+ )
256
+ return key, self._workspace_pi_runners[key]
257
+
258
+ def _pi(
259
+ self, bench: object | None = None, task: Task | None = None
260
+ ) -> PiAgentRunner:
261
+ return self._pi_entry(bench, task)[1]
262
+
263
+ def _acquire_pi_image(self, bench: object, task: Task) -> str:
264
+ """Claim the task's Pi image so no other task removes it mid-use."""
265
+ key, _runner = self._pi_entry(bench, task)
266
+ with self._pi_lock:
267
+ self._pi_image_users[key] = self._pi_image_users.get(key, 0) + 1
268
+ return key
269
+
270
+ def _release_pi_image(self, key: str) -> None:
271
+ """Remove a task's Pi image once no running task needs it any more."""
272
+ with self._pi_lock:
273
+ users = self._pi_image_users.get(key, 0) - 1
274
+ if users > 0:
275
+ self._pi_image_users[key] = users
276
+ return
277
+ self._pi_image_users.pop(key, None)
278
+ runner = (
279
+ self._pi_runner
280
+ if key == ""
281
+ else self._workspace_pi_runners.pop(key, None)
282
+ )
283
+ if runner is not None:
284
+ # The runner rebuilds on its next prepare(); a failed removal is
285
+ # retried by the run-level, label-scoped sweep.
286
+ with contextlib.suppress(Exception):
287
+ runner.cleanup()
288
+
289
+ def run(self) -> list[dict]:
290
+ """Run every job and return the results, including a partial run.
291
+
292
+ An unexpected failure ends the run through ``RunFailed`` rather than an
293
+ exception, so the caller still gets the jobs that already finished and
294
+ can write a report for them.
295
+ """
296
+ self.jobs = expand_jobs(self.jobs, self.client)
297
+ results: list[dict] = []
298
+ started = time.time()
299
+ overall_total = plan_total_tasks(self.jobs)
300
+ self.emit(RunStarted(list(self.jobs), overall_total))
301
+
302
+ try:
303
+ for index, job in enumerate(self.jobs):
304
+ if self.controls.stopped:
305
+ break
306
+ try:
307
+ result, skipped = self._run_job(index, job, overall_total)
308
+ except Exception as exc:
309
+ self.failure = f"{type(exc).__name__}: {exc}"
310
+ self.emit(RunFailed(self.failure))
311
+ break
312
+ # Every job reports a terminal event, even an empty one, so the UI
313
+ # never leaves a queue row running.
314
+ self.emit(
315
+ JobCompleted(index, job, result or empty_result(job), skipped)
316
+ )
317
+ if result is not None:
318
+ results.append(result)
319
+ self._maybe_unload(index, job)
320
+ finally:
321
+ used_pi = self._pi_runner is not None or bool(self._workspace_pi_runners)
322
+ if self._pi_runner is not None:
323
+ with contextlib.suppress(Exception):
324
+ self._pi_runner.cleanup()
325
+ for runner in self._workspace_pi_runners.values():
326
+ with contextlib.suppress(Exception):
327
+ runner.cleanup()
328
+ # mc-arena builds on the same run builder without using Pi, so
329
+ # the sweep runs whenever anything was built, not only for Pi.
330
+ if used_pi or run_resources_created():
331
+ # Removes the run's shared builder, its build cache, and any
332
+ # image, container, network, or volume still carrying the run
333
+ # label. Scoped by label, never a global prune.
334
+ with contextlib.suppress(Exception):
335
+ cleanup_run_resources()
336
+
337
+ annotate_robustness(results)
338
+ annotate_harness_effect(results)
339
+ elapsed = (
340
+ round(sum(result["total_time"] for result in results), 1)
341
+ if getattr(self.client, "simulated_timing", False)
342
+ else round(time.time() - started, 1)
343
+ )
344
+ self.emit(RunCompleted(results, self.controls.stopped, elapsed))
345
+ return results
346
+
347
+ def _maybe_unload(self, index: int, job: JobSpec) -> None:
348
+ """Free VRAM once the last job for a model finishes.
349
+
350
+ Jobs run strictly one model at a time and ``_run_job`` only returns
351
+ after every in-flight request has drained, so by the time this runs the
352
+ model's slots are idle. When force-unload is on, the model is evicted
353
+ here (and the unload request completes) before the next model's job
354
+ submits its first request.
355
+ """
356
+ upcoming = self.jobs[index + 1 :]
357
+ if any(other.model == job.model for other in upcoming):
358
+ return
359
+ if len(self.jobs) <= 1:
360
+ return
361
+ if self.controls.force_unload:
362
+ error = ""
363
+ try:
364
+ self.client.force_unload_model(job.model)
365
+ except Exception as exc:
366
+ error = str(exc)
367
+ self.emit(ModelUnloaded(job.model, error))
368
+ else:
369
+ with contextlib.suppress(Exception):
370
+ self.client.unload_model(job.model)
371
+
372
+ def _max_parallel_requests(self, job: JobSpec, total: int) -> int:
373
+ """Resolve a safe worker count, keeping unknown clients serial."""
374
+ if total <= 1:
375
+ return 1
376
+ discover = getattr(self.client, "max_parallel_requests", None)
377
+ if not callable(discover):
378
+ return 1
379
+ try:
380
+ capacity = discover(job.model)
381
+ if isinstance(capacity, bool):
382
+ return 1
383
+ return min(total, max(1, int(capacity)))
384
+ except Exception:
385
+ # Capacity discovery is an optional optimization. A missing or
386
+ # non-standard monitoring endpoint must never block a benchmark.
387
+ return 1
388
+
389
+ def _run_job(
390
+ self, index: int, job: JobSpec, overall_total: int
391
+ ) -> tuple[dict | None, bool]:
392
+ bench = benchmark(job.benchmark)
393
+ if job.harness == "decision" and not decision.supports(job.benchmark):
394
+ raise ValueError(f"{job.benchmark} cannot run on a decision model")
395
+ if getattr(bench, "workspace_task", False) and job.harness != "pi":
396
+ raise ValueError(
397
+ f"{job.benchmark} requires the Pi harness; use --harness pi"
398
+ )
399
+ if getattr(bench, "vision", False) and job.harness == "pi":
400
+ raise ValueError(
401
+ f"{job.benchmark} sends images, which the Pi harness cannot "
402
+ "forward; use --harness direct"
403
+ )
404
+ all_tasks = tasks_for_job(job)
405
+
406
+ slice_spec = job.slice_spec
407
+ try:
408
+ start, end = parse_slice(slice_spec, len(all_tasks))
409
+ except SliceError:
410
+ start, end, slice_spec = 0, len(all_tasks), None
411
+ tasks = all_tasks[start:end]
412
+
413
+ concurrency = self._max_parallel_requests(job, len(tasks))
414
+ self.emit(
415
+ JobStarted(
416
+ index,
417
+ job,
418
+ len(tasks),
419
+ overall_total,
420
+ concurrency=concurrency,
421
+ )
422
+ )
423
+
424
+ if not tasks:
425
+ return None, False
426
+
427
+ # Pi images are built per task: the build lands in the job wall time
428
+ # but never in a task's response time. Container startup and every
429
+ # agent/tool turn do count.
430
+ wall_start = time.perf_counter()
431
+ tally = JobTally()
432
+ skipped = False
433
+ paused_time = 0.0
434
+ next_position = 0
435
+ futures: set[Future[_GeneratedTask]] = set()
436
+
437
+ with ThreadPoolExecutor(
438
+ max_workers=concurrency,
439
+ thread_name_prefix=f"benchkit-{index}",
440
+ ) as pool:
441
+
442
+ def fill_workers() -> None:
443
+ nonlocal next_position
444
+ while (
445
+ len(futures) < concurrency
446
+ and next_position < len(tasks)
447
+ and not self.controls.paused
448
+ and not self.controls.stopped
449
+ and not self.controls.skip_requested
450
+ ):
451
+ position = next_position
452
+ next_position += 1
453
+ future = pool.submit(
454
+ self._generate_task,
455
+ index,
456
+ job,
457
+ position,
458
+ len(tasks),
459
+ tasks[position],
460
+ bench,
461
+ )
462
+ futures.add(future)
463
+
464
+ fill_workers()
465
+ try:
466
+ while futures or next_position < len(tasks):
467
+ if not futures:
468
+ if self.controls.stopped or self.controls.skip_requested:
469
+ break
470
+ if self.controls.paused:
471
+ pause_started = time.perf_counter()
472
+ self.controls.wait_while_paused()
473
+ paused_time += time.perf_counter() - pause_started
474
+ if self.controls.stopped or self.controls.skip_requested:
475
+ break
476
+ fill_workers()
477
+ if not futures:
478
+ continue
479
+
480
+ done, _ = wait(
481
+ tuple(futures),
482
+ timeout=0.05,
483
+ return_when=FIRST_COMPLETED,
484
+ )
485
+ if not done:
486
+ continue
487
+
488
+ generated_batch: list[_GeneratedTask] = []
489
+ for future in done:
490
+ futures.discard(future)
491
+ generated_batch.append(future.result())
492
+
493
+ # Refill request slots before evaluation so the next
494
+ # generations can overlap local benchmark scoring.
495
+ fill_workers()
496
+
497
+ if self.controls.stopped or self.controls.skip_requested:
498
+ continue
499
+
500
+ for generated in sorted(
501
+ generated_batch, key=lambda item: item.position
502
+ ):
503
+ outcome = self._finalize_task(
504
+ index,
505
+ job,
506
+ len(tasks),
507
+ bench,
508
+ generated,
509
+ )
510
+ if outcome is None:
511
+ continue
512
+ tally.add(outcome)
513
+ self.emit(
514
+ TaskCompleted(
515
+ index,
516
+ job,
517
+ outcome.record,
518
+ tally.passed,
519
+ len(tally.records_by_position),
520
+ tally.score_points,
521
+ tally.scored_total,
522
+ )
523
+ )
524
+ except Exception:
525
+ self.controls.cancel_active()
526
+ for future in futures:
527
+ future.cancel()
528
+ raise
529
+
530
+ if self.controls.skip_requested:
531
+ skipped = self.controls.take_skip()
532
+
533
+ if not tally.records_by_position:
534
+ return None, skipped
535
+
536
+ measured_wall_time_s = max(0.0, time.perf_counter() - wall_start - paused_time)
537
+ # Demo mode deliberately accelerates its fake generations. Use their
538
+ # simulated request durations so its reports still demonstrate
539
+ # internally consistent throughput rather than thousands of tok/s.
540
+ wall_time_s = (
541
+ tally.total_response_time / concurrency
542
+ if getattr(self.client, "simulated_timing", False)
543
+ and tally.total_response_time > 0
544
+ else measured_wall_time_s
545
+ )
546
+ result = job_result(
547
+ job,
548
+ bench,
549
+ tasks,
550
+ tally,
551
+ slice_spec=slice_spec,
552
+ concurrency=concurrency,
553
+ wall_time_s=wall_time_s,
554
+ loop_kill_enabled=self.loop_kill_enabled,
555
+ loop_kill_percent=self.loop_kill_percent,
556
+ loop_kill_seconds=self.loop_kill_seconds,
557
+ )
558
+ return result, skipped
559
+
560
+ def _verify_response(
561
+ self,
562
+ bench: object,
563
+ task: Task,
564
+ response: str,
565
+ *,
566
+ decision: bool = False,
567
+ ) -> EvaluationResult:
568
+ """Run evaluators serially and convert infrastructure errors to data."""
569
+ with self._evaluation_lock:
570
+ try:
571
+ return evaluate_response(bench, task, response, decision=decision)
572
+ except Exception as exc:
573
+ return EvaluationResult(
574
+ score=0.0,
575
+ error=f"evaluation failed: {type(exc).__name__}: {exc}",
576
+ )
577
+
578
+ def _generate_direct_with_repairs(
579
+ self,
580
+ job: JobSpec,
581
+ prompt: str,
582
+ verifier: Callable[[str], EvaluationResult],
583
+ on_progress: Callable[[GenerationUpdate], None],
584
+ images: list[str] | None = None,
585
+ repairs: int | None = None,
586
+ ) -> dict:
587
+ """Run stateless direct calls with an explicit feedback transcript."""
588
+ attempts: list[tuple[dict, EvaluationResult]] = []
589
+ feedback_sent: list[str] = []
590
+ request_prompt = prompt
591
+ elapsed_offset = 0.0
592
+
593
+ repairs = job.repair_attempts if repairs is None else repairs
594
+ for attempt in range(repairs + 1):
595
+ thinking_parts: list[str] = []
596
+ response_parts: list[str] = []
597
+
598
+ def forward(
599
+ update: GenerationUpdate,
600
+ *,
601
+ number: int = attempt,
602
+ offset: float = elapsed_offset,
603
+ thinking_parts: list[str] = thinking_parts,
604
+ response_parts: list[str] = response_parts,
605
+ ) -> None:
606
+ thinking_parts.append(update.thinking)
607
+ response_parts.append(update.response)
608
+ on_progress(
609
+ replace(
610
+ update,
611
+ elapsed_s=offset + update.elapsed_s,
612
+ attempt=number,
613
+ )
614
+ )
615
+
616
+ gen = self.client.generate(
617
+ job.model,
618
+ request_prompt,
619
+ on_progress=forward,
620
+ cancel_event=self.controls.cancel_event,
621
+ **({"images": images} if images else {}),
622
+ )
623
+ terminal = bool(
624
+ gen.get("cancelled") or gen.get("timed_out") or gen.get("loop_killed")
625
+ )
626
+ # Recover per attempt: once attempts are combined, an earlier
627
+ # attempt's count would hide this one's missing usage.
628
+ self._recover_streamed_tokens(
629
+ job.model, gen, thinking_parts, response_parts
630
+ )
631
+ evaluation = (
632
+ EvaluationResult(score=0.0)
633
+ if terminal
634
+ else verifier(str(gen.get("response") or ""))
635
+ )
636
+ attempts.append((gen, evaluation))
637
+ if terminal or evaluation.passed or evaluation.error:
638
+ break
639
+ if attempt >= repairs:
640
+ break
641
+
642
+ feedback_sent.append(evaluation.feedback)
643
+ request_prompt = standalone_repair_prompt(
644
+ prompt,
645
+ str(gen.get("response") or ""),
646
+ evaluation.feedback,
647
+ attempt + 1,
648
+ repairs,
649
+ )
650
+ elapsed_offset += float(gen.get("response_time_s") or 0.0)
651
+
652
+ return combine_attempts(attempts, feedback_sent)
653
+
654
+ def _generate_task(
655
+ self,
656
+ index: int,
657
+ job: JobSpec,
658
+ position: int,
659
+ total: int,
660
+ task: Task,
661
+ bench: object,
662
+ ) -> _GeneratedTask:
663
+ """Generate one answer in a request worker, including live analysis."""
664
+ case = perturb_task(
665
+ job.benchmark,
666
+ task,
667
+ job.perturbation,
668
+ job.perturbation_seed,
669
+ )
670
+ task = case.evaluation_task
671
+ prompt = (
672
+ decision.render_request(bench, case.prompt_task)
673
+ if job.harness == "decision"
674
+ else prompt_for(bench, case.prompt_task, self.client, job.model)
675
+ )
676
+ # Vision suites attach local image files; only direct calls carry them.
677
+ images = list(case.prompt_task.metadata.get("images") or ())
678
+ error = ""
679
+ errors = 0
680
+ entry_point = str(task.metadata.get("entry_point", ""))
681
+ self.emit(
682
+ TaskPhase(
683
+ index=index,
684
+ job=job,
685
+ position=position + 1,
686
+ total=total,
687
+ task_id=task.id,
688
+ entry_point=entry_point,
689
+ phase="generating",
690
+ activity="waiting for model",
691
+ )
692
+ )
693
+ analyzer = LoopAnalyzer()
694
+ last_progress_emit = 0.0
695
+ last_progress_state = ""
696
+ reasoning_channel_seen = False
697
+ first_answer_at: float | None = None
698
+ loop_detected_at: float | None = None
699
+ loop_above_since: float | None = None
700
+ loop_killed_at_s: float | None = None
701
+ loop_kill_score = 0.0
702
+ current_attempt = 0
703
+ streamed_thinking_parts: list[str] = []
704
+ streamed_response_parts: list[str] = []
705
+
706
+ def on_progress(update: GenerationUpdate) -> None:
707
+ nonlocal analyzer
708
+ nonlocal current_attempt
709
+ nonlocal first_answer_at
710
+ nonlocal last_progress_emit
711
+ nonlocal last_progress_state
712
+ nonlocal loop_above_since
713
+ nonlocal loop_detected_at
714
+ nonlocal loop_killed_at_s
715
+ nonlocal loop_kill_score
716
+ nonlocal reasoning_channel_seen
717
+ nonlocal streamed_response_parts
718
+ nonlocal streamed_thinking_parts
719
+
720
+ if self.controls.cancel_requested:
721
+ raise _GenerationCancelled
722
+
723
+ if update.attempt != current_attempt:
724
+ current_attempt = update.attempt
725
+ analyzer = LoopAnalyzer()
726
+ reasoning_channel_seen = False
727
+ loop_above_since = None
728
+ loop_detected_at = None
729
+ last_progress_emit = 0.0
730
+ last_progress_state = ""
731
+ streamed_thinking_parts = []
732
+ streamed_response_parts = []
733
+ self.emit(
734
+ TaskPhase(
735
+ index=index,
736
+ job=job,
737
+ position=position + 1,
738
+ total=total,
739
+ task_id=task.id,
740
+ entry_point=entry_point,
741
+ phase="generating",
742
+ activity=(
743
+ f"running verifier-feedback repair {current_attempt}"
744
+ ),
745
+ )
746
+ )
747
+
748
+ streamed_thinking_parts.append(update.thinking)
749
+ streamed_response_parts.append(update.response)
750
+ analyzer.add(thinking=update.thinking, answer=update.response)
751
+ reasoning_channel_seen = (
752
+ reasoning_channel_seen or update.reasoning_channel_seen
753
+ )
754
+ if update.response and first_answer_at is None:
755
+ first_answer_at = update.elapsed_s
756
+
757
+ if analyzer.answer_chars:
758
+ live_phase = "answering"
759
+ elif analyzer.thinking_chars:
760
+ live_phase = "thinking"
761
+ else:
762
+ live_phase = "waiting"
763
+
764
+ trace_status = (
765
+ "observed"
766
+ if analyzer.thinking_chars
767
+ else "available_empty"
768
+ if reasoning_channel_seen
769
+ else "unavailable"
770
+ )
771
+ state_key = f"{live_phase}:{trace_status}"
772
+ now = time.monotonic()
773
+ if (
774
+ not update.done
775
+ and state_key == last_progress_state
776
+ and now - last_progress_emit < 0.25
777
+ ):
778
+ return
779
+ snapshot = analyzer.snapshot()
780
+ if snapshot.state == "looping" and loop_detected_at is None:
781
+ loop_detected_at = update.elapsed_s
782
+ threshold = self.loop_kill_percent / 100
783
+ if not self.loop_kill_enabled:
784
+ loop_above_since = None
785
+ remaining = None
786
+ elif snapshot.confirmed_cycle and snapshot.score >= threshold:
787
+ if loop_above_since is None:
788
+ loop_above_since = now
789
+ above_for = now - loop_above_since
790
+ remaining = max(0.0, self.loop_kill_seconds - above_for)
791
+ if above_for >= self.loop_kill_seconds and not update.done:
792
+ loop_killed_at_s = update.elapsed_s
793
+ loop_kill_score = snapshot.score
794
+ raise _DoomLoopKilled
795
+ else:
796
+ loop_above_since = None
797
+ remaining = None
798
+ last_progress_emit = now
799
+ last_progress_state = state_key
800
+ self.emit(
801
+ GenerationProgress(
802
+ index=index,
803
+ job=job,
804
+ position=position + 1,
805
+ total=total,
806
+ task_id=task.id,
807
+ entry_point=entry_point,
808
+ phase=live_phase,
809
+ elapsed_s=round(update.elapsed_s, 2),
810
+ thinking_chars=analyzer.thinking_chars,
811
+ response_chars=analyzer.answer_chars,
812
+ trace_status=trace_status,
813
+ loop_state=snapshot.state,
814
+ loop_score=snapshot.score,
815
+ loop_source=snapshot.source,
816
+ loop_kill_remaining_s=(
817
+ round(remaining, 2) if remaining is not None else None
818
+ ),
819
+ prompt=prompt,
820
+ thinking=analyzer.thinking,
821
+ response=analyzer.answer,
822
+ )
823
+ )
824
+
825
+ # Build this task's Pi image now, run the task, then remove the image:
826
+ # a run never holds more images than it has tasks in flight.
827
+ pi_image = self._acquire_pi_image(bench, task) if job.harness == "pi" else None
828
+ request_started = time.perf_counter()
829
+ try:
830
+ if pi_image is not None:
831
+ self.emit(
832
+ TaskPhase(
833
+ index=index,
834
+ job=job,
835
+ position=position + 1,
836
+ total=total,
837
+ task_id=task.id,
838
+ entry_point=entry_point,
839
+ phase="generating",
840
+ activity="building Pi sandbox image",
841
+ )
842
+ )
843
+ self._pi(bench, task).prepare()
844
+ # The build is setup, not generation: keep it out of the timing.
845
+ request_started = time.perf_counter()
846
+ generator = (
847
+ self._pi(bench, task)
848
+ if job.harness == "pi"
849
+ else decision.Decider(self.client)
850
+ if job.harness == "decision"
851
+ else self.client
852
+ )
853
+ workspace = bool(getattr(bench, "workspace_task", False))
854
+ workspace_setup = None
855
+ workspace_verifier = None
856
+ repair_prompt_builder = None
857
+ if workspace:
858
+
859
+ def workspace_setup(environment):
860
+ return bench.prepare_workspace(task, environment)
861
+
862
+ def workspace_verifier(environment, tool_trace):
863
+ return bench.verify_workspace(task, environment, tool_trace)
864
+
865
+ candidate_builder = getattr(bench, "build_repair_prompt", None)
866
+ if callable(candidate_builder):
867
+ repair_prompt_builder = candidate_builder
868
+
869
+ # Decision models emit no text to repair; routing already zeroes
870
+ # their repairs, this also covers jobs built without routing.
871
+ repairs = (
872
+ 0
873
+ if job.harness == "decision"
874
+ else repair_budget(bench, task, job.repair_attempts)
875
+ )
876
+ if repairs:
877
+
878
+ def verifier(response: str) -> EvaluationResult:
879
+ return self._verify_response(bench, task, response)
880
+
881
+ if job.harness == "pi":
882
+ gen = generator.generate(
883
+ job.model,
884
+ prompt,
885
+ on_progress=on_progress,
886
+ cancel_event=self.controls.cancel_event,
887
+ verifier=verifier,
888
+ repair_attempts=repairs,
889
+ workspace_setup=workspace_setup,
890
+ workspace_verifier=workspace_verifier,
891
+ repair_prompt_builder=repair_prompt_builder,
892
+ )
893
+ else:
894
+ gen = self._generate_direct_with_repairs(
895
+ job,
896
+ prompt,
897
+ verifier,
898
+ on_progress,
899
+ images,
900
+ repairs,
901
+ )
902
+ else:
903
+ gen = generator.generate(
904
+ job.model,
905
+ prompt,
906
+ on_progress=on_progress,
907
+ cancel_event=self.controls.cancel_event,
908
+ **(
909
+ {
910
+ "workspace_setup": workspace_setup,
911
+ "workspace_verifier": workspace_verifier,
912
+ }
913
+ if workspace
914
+ else {}
915
+ ),
916
+ **(
917
+ {"images": images} if images and job.harness == "direct" else {}
918
+ ),
919
+ )
920
+ except _GenerationCancelled:
921
+ gen = dict(ERROR_GENERATION)
922
+ gen.update(done_reason="cancelled", cancelled=True)
923
+ except _DoomLoopKilled:
924
+ elapsed_s = loop_killed_at_s or 0.0
925
+ thinking = analyzer.thinking
926
+ response = analyzer.answer
927
+ gen = dict(ERROR_GENERATION)
928
+ gen.update(
929
+ thinking=thinking,
930
+ response=response,
931
+ response_time_s=elapsed_s,
932
+ done_reason="loop_killed",
933
+ loop_killed=True,
934
+ loop_kill_score=loop_kill_score,
935
+ loop_killed_at_s=elapsed_s,
936
+ trace_status=(
937
+ "observed"
938
+ if thinking
939
+ else "available_empty"
940
+ if reasoning_channel_seen
941
+ else "unavailable"
942
+ ),
943
+ )
944
+ except Exception as exc:
945
+ gen = dict(ERROR_GENERATION)
946
+ if self.controls.cancel_requested:
947
+ gen.update(done_reason="cancelled", cancelled=True)
948
+ elif isinstance(exc, (TimeoutError, httpx.TimeoutException)):
949
+ error = f"generation timed out after {self.client.timeout:g}s"
950
+ gen.update(
951
+ done_reason="timeout",
952
+ timed_out=True,
953
+ response_time_s=self.client.timeout,
954
+ )
955
+ else:
956
+ error = f"{type(exc).__name__}: {exc}"
957
+ gen.update(
958
+ harness_error=True,
959
+ response_time_s=time.perf_counter() - request_started,
960
+ )
961
+ if not gen.get("cancelled"):
962
+ errors += 1
963
+ finally:
964
+ # Workspace verification runs inside generate(), so the image is
965
+ # done once it returns, whichever way it ended.
966
+ if pi_image is not None:
967
+ self._release_pi_image(pi_image)
968
+
969
+ self._recover_streamed_tokens(
970
+ job.model, gen, streamed_thinking_parts, streamed_response_parts
971
+ )
972
+
973
+ return _GeneratedTask(
974
+ position=position,
975
+ task=task,
976
+ prompt=prompt,
977
+ perturbation=case.details,
978
+ gen=gen,
979
+ error=error,
980
+ errors=errors,
981
+ analyzer=analyzer,
982
+ first_answer_at=first_answer_at,
983
+ loop_detected_at=loop_detected_at,
984
+ )
985
+
986
+ def _recover_streamed_tokens(
987
+ self,
988
+ model: str,
989
+ gen: dict,
990
+ thinking_parts: list[str],
991
+ response_parts: list[str],
992
+ ) -> None:
993
+ """Fill in output tokens for a terminal stream that reported no usage.
994
+
995
+ Only the token count is recovered. Decode timing stays zero, so the
996
+ item remains outside throughput coverage.
997
+ """
998
+ if not (
999
+ gen.get("loop_killed") or gen.get("timed_out") or gen.get("length_exceeded")
1000
+ ) or int(gen.get("eval_count") or 0):
1001
+ return
1002
+ token_count = self._count_streamed_tokens(model, thinking_parts, response_parts)
1003
+ if token_count is None:
1004
+ return
1005
+ response_time_s = float(gen.get("response_time_s") or 0.0)
1006
+ gen.update(
1007
+ eval_count=token_count,
1008
+ tokens_recovered=True,
1009
+ tok_s=token_count / response_time_s if response_time_s > 0 else 0.0,
1010
+ )
1011
+
1012
+ def _count_streamed_tokens(
1013
+ self,
1014
+ model: str,
1015
+ thinking_parts: list[str],
1016
+ response_parts: list[str],
1017
+ ) -> int | None:
1018
+ """Count terminal stream text when the final usage payload never arrived."""
1019
+ tokenize = getattr(self.client, "tokenize", None)
1020
+ if not callable(tokenize):
1021
+ return None
1022
+ total = 0
1023
+ observed = False
1024
+ for content in ("".join(thinking_parts), "".join(response_parts)):
1025
+ if not content:
1026
+ continue
1027
+ try:
1028
+ tokens = tokenize(model, content, add_special=False)
1029
+ except (TypeError, ValueError):
1030
+ return None
1031
+ if tokens is None:
1032
+ return None
1033
+ total += len(tokens)
1034
+ observed = True
1035
+ return total if observed else None
1036
+
1037
+ def _finalize_task(
1038
+ self,
1039
+ index: int,
1040
+ job: JobSpec,
1041
+ total: int,
1042
+ bench: object,
1043
+ generated: _GeneratedTask,
1044
+ ) -> TaskOutcome | None:
1045
+ """Evaluate a generated answer and build its deterministic task record."""
1046
+ gen = generated.gen
1047
+ if (
1048
+ self.controls.stopped
1049
+ or self.controls.skip_requested
1050
+ or gen.get("cancelled")
1051
+ ):
1052
+ return None
1053
+
1054
+ position = generated.position
1055
+ task = generated.task
1056
+ prompt = generated.prompt
1057
+ error = generated.error
1058
+ errors = generated.errors
1059
+ entry_point = str(task.metadata.get("entry_point", ""))
1060
+
1061
+ final_analyzer = generated.analyzer
1062
+ final_thinking = gen.get("thinking", "") or ""
1063
+ final_answer = gen.get("response", "") or ""
1064
+ if not final_analyzer.reconcile(
1065
+ thinking=final_thinking,
1066
+ answer=final_answer,
1067
+ ):
1068
+ final_analyzer = LoopAnalyzer()
1069
+ final_analyzer.add(
1070
+ thinking=final_thinking,
1071
+ answer=final_answer,
1072
+ )
1073
+ loop = final_analyzer.snapshot(final=True)
1074
+ loop_detected_at = generated.loop_detected_at
1075
+ if loop.state == "looping" and loop_detected_at is None:
1076
+ loop_detected_at = gen.get("response_time_s", 0.0)
1077
+ first_answer_at = generated.first_answer_at
1078
+ if first_answer_at is None and gen.get("response"):
1079
+ first_answer_at = gen.get("response_time_s", 0.0)
1080
+
1081
+ timed_out = bool(gen.get("timed_out"))
1082
+ loop_killed = bool(gen.get("loop_killed"))
1083
+ length_exceeded = bool(gen.get("length_exceeded"))
1084
+ harness_error = bool(gen.get("harness_error"))
1085
+ terminal_workspace_evaluation = bool(gen.get("workspace_evaluated")) and (
1086
+ "evaluation_score" in gen
1087
+ )
1088
+ evaluation_score = 0.0
1089
+ evaluation_details = dict(gen.get("evaluation_details") or {})
1090
+ contaminated = evaluation_details.get("contamination_verdict") == "CONTAMINATED"
1091
+ if loop_killed:
1092
+ ok = False
1093
+ error = (
1094
+ "doom loop killed after a confirmed active cycle stayed at or above "
1095
+ f"{self.loop_kill_percent:g}% for "
1096
+ f"{self.loop_kill_seconds:g}s"
1097
+ )
1098
+ errors += 1
1099
+ phase = "loop_killed"
1100
+ activity = "doom loop killed — skipping task"
1101
+ elif timed_out and not terminal_workspace_evaluation:
1102
+ ok = False
1103
+ if not error:
1104
+ timeout_s = float(gen.get("timeout_s") or self.client.timeout)
1105
+ error = f"generation timed out after {timeout_s:g}s"
1106
+ errors += 1
1107
+ phase = "timed_out"
1108
+ activity = "timed out — skipping task"
1109
+ elif length_exceeded and not terminal_workspace_evaluation:
1110
+ ok = False
1111
+ if not error:
1112
+ error = "generation reached the model output-token limit"
1113
+ errors += 1
1114
+ phase = "length_exceeded"
1115
+ activity = "output-token limit reached — scoring task as failed"
1116
+ elif harness_error:
1117
+ ok = False
1118
+ phase = "harness_error"
1119
+ activity = "harness error — excluding task from score"
1120
+ else:
1121
+ phase = "evaluating"
1122
+ activity = getattr(bench, "evaluation_activity", "evaluating response")
1123
+
1124
+ self.emit(
1125
+ TaskPhase(
1126
+ index=index,
1127
+ job=job,
1128
+ position=position + 1,
1129
+ total=total,
1130
+ task_id=task.id,
1131
+ entry_point=entry_point,
1132
+ phase=phase,
1133
+ activity=activity,
1134
+ )
1135
+ )
1136
+
1137
+ if (
1138
+ not loop_killed
1139
+ and (not timed_out or terminal_workspace_evaluation)
1140
+ and (not length_exceeded or terminal_workspace_evaluation)
1141
+ and not harness_error
1142
+ ):
1143
+ if "evaluation_score" in gen:
1144
+ evaluation_score = min(
1145
+ 1.0, max(0.0, float(gen.get("evaluation_score") or 0.0))
1146
+ )
1147
+ evaluation_error = str(gen.get("evaluation_error") or "")
1148
+ ok = evaluation_score >= 1.0 and not evaluation_error
1149
+ if evaluation_error and not error:
1150
+ error = evaluation_error
1151
+ errors += 1
1152
+ harness_error = True
1153
+ else:
1154
+ evaluation = self._verify_response(
1155
+ bench, task, gen["response"], decision="decision" in gen
1156
+ )
1157
+ evaluation_score = evaluation.score
1158
+ ok = evaluation.passed
1159
+ # Verifiers that record artifacts (rendered pages, screenshots,
1160
+ # workspace state) return them here; the repair path carries
1161
+ # them on the generation instead.
1162
+ evaluation_details = dict(evaluation.details)
1163
+ if evaluation.error and not error:
1164
+ error = evaluation.error
1165
+ errors += 1
1166
+ harness_error = True
1167
+ if not ok and not error and gen.get("evaluation_error"):
1168
+ ok = False
1169
+ error = str(gen["evaluation_error"])
1170
+ errors += 1
1171
+ harness_error = True
1172
+
1173
+ if contaminated:
1174
+ ok = False
1175
+ evaluation_score = 0.0
1176
+
1177
+ outcome = (
1178
+ "harness_error"
1179
+ if harness_error
1180
+ else "contaminated"
1181
+ if contaminated
1182
+ else "pass"
1183
+ if ok
1184
+ else "length_exceeded"
1185
+ if length_exceeded
1186
+ else "loop_killed"
1187
+ if loop_killed
1188
+ else "timeout"
1189
+ if timed_out
1190
+ else "fail"
1191
+ )
1192
+
1193
+ response_time_s = float(gen.get("response_time_s", 0.0))
1194
+ record = TaskRecord(
1195
+ index=position,
1196
+ task_id=task.id,
1197
+ passed=ok,
1198
+ tok_s=round(float(gen.get("tok_s", 0.0)), 1),
1199
+ response_time_s=round(response_time_s, 2),
1200
+ prompt=prompt,
1201
+ response=gen.get("response", ""),
1202
+ outcome=outcome,
1203
+ perturbation=generated.perturbation,
1204
+ score=evaluation_score,
1205
+ error=error,
1206
+ entry_point=entry_point,
1207
+ thinking=gen.get("thinking", ""),
1208
+ output_tokens=int(gen.get("eval_count", 0)),
1209
+ tokens_recovered=bool(gen.get("tokens_recovered")),
1210
+ done_reason=gen.get("done_reason", ""),
1211
+ timed_out=timed_out,
1212
+ loop_killed=loop_killed,
1213
+ length_exceeded=length_exceeded,
1214
+ harness_error=harness_error,
1215
+ contaminated=contaminated,
1216
+ loop_kill_score=float(gen.get("loop_kill_score", 0.0)),
1217
+ loop_killed_at_s=gen.get("loop_killed_at_s"),
1218
+ trace_status=gen.get("trace_status", "unavailable"),
1219
+ thinking_time_s=round(
1220
+ min(
1221
+ first_answer_at if first_answer_at is not None else response_time_s,
1222
+ response_time_s,
1223
+ )
1224
+ if gen.get("thinking")
1225
+ else 0.0,
1226
+ 2,
1227
+ ),
1228
+ time_to_first_answer_s=(
1229
+ round(first_answer_at, 2) if first_answer_at is not None else None
1230
+ ),
1231
+ loop_state=loop.state,
1232
+ loop_score=loop.score,
1233
+ loop_source=loop.source,
1234
+ loop_detected_at_s=(
1235
+ round(loop_detected_at, 2) if loop_detected_at is not None else None
1236
+ ),
1237
+ repeated_ngram_coverage=loop.repeated_ngram_coverage,
1238
+ max_window_similarity=loop.max_window_similarity,
1239
+ low_novelty_windows=loop.low_novelty_windows,
1240
+ max_repeated_block=loop.max_repeated_block,
1241
+ loop_evidence=loop.evidence,
1242
+ active_cycle=loop.active_cycle,
1243
+ recovered_cycle=loop.recovered_cycle,
1244
+ cycle_period_tokens=loop.cycle_period_tokens,
1245
+ cycle_repetitions=loop.cycle_repetitions,
1246
+ repeated_suffix_tokens=loop.repeated_suffix_tokens,
1247
+ harness=str(gen.get("harness") or job.harness),
1248
+ harness_version=str(gen.get("harness_version") or ""),
1249
+ input_tokens=int(gen.get("input_tokens") or 0),
1250
+ model_turns=int(gen.get("model_turns") or 1),
1251
+ tool_calls=int(gen.get("tool_calls") or 0),
1252
+ tool_trace=list(gen.get("tool_trace") or []),
1253
+ pi_scaffold=dict(gen.get("pi_scaffold") or {}),
1254
+ attempts=list(gen.get("attempts") or []),
1255
+ repair_attempts_used=int(gen.get("repair_attempts_used") or 0),
1256
+ repair_feedback=list(gen.get("repair_feedback") or []),
1257
+ first_attempt_score=float(gen.get("first_attempt_score", evaluation_score)),
1258
+ repaired=bool(gen.get("repaired")),
1259
+ workspace=evaluation_details,
1260
+ decision=decision.calibration(
1261
+ gen.get("decision"), task.metadata.get("answer")
1262
+ ),
1263
+ )
1264
+ return TaskOutcome(
1265
+ record=record,
1266
+ errors=errors,
1267
+ eval_count=int(gen.get("eval_count", 0)),
1268
+ eval_duration_ns=int(gen.get("eval_duration_ns", 0)),
1269
+ response_time_s=response_time_s,
1270
+ )