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
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
|
+
)
|