agentic-bench 0.2.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.
- abench/__init__.py +107 -0
- abench/__main__.py +7 -0
- abench/_version.py +4 -0
- abench/cli.py +389 -0
- abench/core/__init__.py +72 -0
- abench/core/dataset.py +421 -0
- abench/core/metrics.py +505 -0
- abench/core/workload.py +614 -0
- abench/data/recording_rates.json +23 -0
- abench/replay/__init__.py +14 -0
- abench/replay/engine.py +553 -0
- abench/replay/transport.py +157 -0
- abench/runner.py +637 -0
- abench/serve/__init__.py +31 -0
- abench/serve/endpoints.py +388 -0
- abench/serve/report.py +365 -0
- abench/serve/telemetry.py +166 -0
- agentic_bench-0.2.0.dist-info/METADATA +169 -0
- agentic_bench-0.2.0.dist-info/RECORD +24 -0
- agentic_bench-0.2.0.dist-info/WHEEL +5 -0
- agentic_bench-0.2.0.dist-info/entry_points.txt +2 -0
- agentic_bench-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentic_bench-0.2.0.dist-info/licenses/NOTICE +44 -0
- agentic_bench-0.2.0.dist-info/top_level.txt +1 -0
abench/core/workload.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, Alibaba Group and its affiliates
|
|
3
|
+
|
|
4
|
+
"""Workload construction: turn the normalized DeepSWE sessions into a
|
|
5
|
+
concrete, parameterized benchmark plan.
|
|
6
|
+
|
|
7
|
+
This is where the four benchmark *knobs* live:
|
|
8
|
+
|
|
9
|
+
1. **System-prompt length** (``system_prompt_len``) — the original
|
|
10
|
+
trace system prompt is stripped by ``dataset.py``; here we rebuild a
|
|
11
|
+
shared system prompt tiled to an exact token length. Content is
|
|
12
|
+
irrelevant to performance, so we simply repeat a canonical base block
|
|
13
|
+
until we hit the target token count. The same bytes are used for
|
|
14
|
+
every session, so the system prefix is maximally cacheable (matching
|
|
15
|
+
production, where the system prompt is shared and static).
|
|
16
|
+
|
|
17
|
+
2. **Turns** (``min_turns`` / ``max_turns``) — a pure *filter/cap*
|
|
18
|
+
knob. Sessions shorter than ``min_turns`` are dropped; sessions
|
|
19
|
+
longer than ``max_turns`` are truncated *only* when ``max_turns`` is
|
|
20
|
+
set. We never pad short sessions to a turn count.
|
|
21
|
+
|
|
22
|
+
3. **Peak context** (``min_context`` / ``max_context``) — an explicit
|
|
23
|
+
token band on a session's peak recorded context. Stating both edges
|
|
24
|
+
keeps it obvious which sessions a band admits, and lets the upper
|
|
25
|
+
edge be pinned under the serving context window.
|
|
26
|
+
|
|
27
|
+
4. **Output length** (``output_policy``) — per-turn generated tokens.
|
|
28
|
+
``trace`` uses the recorded ``completion_tokens``; ``fixed:K`` emits
|
|
29
|
+
K tokens/turn; ``budget:T`` spreads a total budget evenly across the
|
|
30
|
+
session's turns (``T // num_turns``, floored at 1).
|
|
31
|
+
|
|
32
|
+
The output is a list of :class:`PlannedSession` objects consumed by the
|
|
33
|
+
closed-loop replay engine.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import random
|
|
41
|
+
import sys
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
44
|
+
|
|
45
|
+
from abench.core.dataset import NormSession, NormTurn
|
|
46
|
+
|
|
47
|
+
# A neutral base block used to synthesize system prompts of arbitrary
|
|
48
|
+
# length. Content does not affect serving performance; only token count
|
|
49
|
+
# does. Repeating a fixed block keeps the prefix byte-identical (and thus
|
|
50
|
+
# fully cacheable) across every session.
|
|
51
|
+
_SYS_BASE = (
|
|
52
|
+
"You are a helpful software engineering assistant operating inside a "
|
|
53
|
+
"sandboxed repository. You can read files, run shell commands, and edit "
|
|
54
|
+
"code to resolve the assigned task. Always inspect the relevant modules "
|
|
55
|
+
"before making a change and verify your edits with the available tools. "
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Neutral text block for synthesizing per-turn user/tool messages.
|
|
59
|
+
_TURN_BASE = (
|
|
60
|
+
"The user has provided additional context and tool output for this turn. "
|
|
61
|
+
"Please review the following information and continue working on the task. "
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ----------------------------------------------------------------------
|
|
66
|
+
# Planned workload structures
|
|
67
|
+
# ----------------------------------------------------------------------
|
|
68
|
+
@dataclass
|
|
69
|
+
class PlannedTurn:
|
|
70
|
+
"""A turn with a resolved output length, ready to be issued."""
|
|
71
|
+
|
|
72
|
+
index: int
|
|
73
|
+
input_delta: List[Dict[str, str]]
|
|
74
|
+
assistant_text: str
|
|
75
|
+
max_new_tokens: int
|
|
76
|
+
recorded_input_tokens: int
|
|
77
|
+
recorded_cached_tokens: int
|
|
78
|
+
pre_gap_ms: float
|
|
79
|
+
# Recorded tool calls, carried through from NormTurn.
|
|
80
|
+
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
|
|
81
|
+
# Kept so the report can check the achieved mean output length against
|
|
82
|
+
# what the trace recorded, even when the policy does not force lengths.
|
|
83
|
+
recorded_output_tokens: int = 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class PlannedSession:
|
|
88
|
+
"""A session with a concrete system prompt and per-turn output plan."""
|
|
89
|
+
|
|
90
|
+
session_id: str
|
|
91
|
+
system_prompt: str
|
|
92
|
+
turns: List[PlannedTurn]
|
|
93
|
+
routing_key: Optional[str] = None
|
|
94
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def num_turns(self) -> int:
|
|
98
|
+
return len(self.turns)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ----------------------------------------------------------------------
|
|
102
|
+
# System-prompt tiling
|
|
103
|
+
# ----------------------------------------------------------------------
|
|
104
|
+
def measure_text_tokens(text: str, tokenizer: Optional[Any]) -> int:
|
|
105
|
+
"""Token count of ``text`` under ``tokenizer`` (chars/4 when absent)."""
|
|
106
|
+
if not text:
|
|
107
|
+
return 0
|
|
108
|
+
if tokenizer is not None:
|
|
109
|
+
try:
|
|
110
|
+
return len(tokenizer.encode(text))
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
return max(1, len(text) // 4)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _trim_to_token_target(text: str, target_tokens: int, tokenizer) -> str:
|
|
117
|
+
"""Char-trim an over-long tiling until it encodes to ~``target_tokens``.
|
|
118
|
+
|
|
119
|
+
decode(encode(t)) is not length-preserving under BPE — re-encoding a
|
|
120
|
+
decoded string can merge differently — so a prompt built by decoding
|
|
121
|
+
truncated ids lands short (an 8192-token target once landed at 5831). The
|
|
122
|
+
robust construction is to keep an over-long tiling and trim characters
|
|
123
|
+
while measuring. Callers must still report the measured length; the
|
|
124
|
+
result is exact or within a couple of tokens.
|
|
125
|
+
"""
|
|
126
|
+
cur = measure_text_tokens(text, tokenizer)
|
|
127
|
+
guard = 0
|
|
128
|
+
while cur > target_tokens and guard < 512:
|
|
129
|
+
if cur - target_tokens <= 2:
|
|
130
|
+
text = text[:-1]
|
|
131
|
+
else:
|
|
132
|
+
# Remove roughly the surplus in characters; 4 chars/token is
|
|
133
|
+
# the pessimistic bound, so we approach from above.
|
|
134
|
+
cut = max(1, (cur - target_tokens) * len(text) // (cur * 4))
|
|
135
|
+
text = text[:-cut]
|
|
136
|
+
cur = measure_text_tokens(text, tokenizer)
|
|
137
|
+
guard += 1
|
|
138
|
+
return text
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_system_prompt(
|
|
142
|
+
target_tokens: int,
|
|
143
|
+
tokenizer: Optional[Any] = None,
|
|
144
|
+
base: str = _SYS_BASE,
|
|
145
|
+
) -> str:
|
|
146
|
+
"""Return a system prompt tiled to ``target_tokens`` tokens.
|
|
147
|
+
|
|
148
|
+
``target_tokens == 0`` returns an empty string (no system prompt).
|
|
149
|
+
``target_tokens < 0`` is treated as "use the base block once".
|
|
150
|
+
|
|
151
|
+
With a tokenizer the returned text encodes to the target length
|
|
152
|
+
(within a couple of tokens); the caller should measure it with
|
|
153
|
+
:func:`measure_text_tokens` and use that number for context
|
|
154
|
+
accounting instead of assuming the target.
|
|
155
|
+
"""
|
|
156
|
+
if target_tokens == 0:
|
|
157
|
+
return ""
|
|
158
|
+
if target_tokens < 0:
|
|
159
|
+
return base
|
|
160
|
+
|
|
161
|
+
base_tok = max(1, measure_text_tokens(base, tokenizer))
|
|
162
|
+
# Tiling with margin: the trim pass only shrinks, so start above the
|
|
163
|
+
# target (re-encoding a decoded tiling can compress by tens of
|
|
164
|
+
# percent, so a bare 1x margin is not enough).
|
|
165
|
+
reps = max(2, (target_tokens * 3) // base_tok + 4)
|
|
166
|
+
text = base * reps
|
|
167
|
+
if tokenizer is not None:
|
|
168
|
+
guard = 0
|
|
169
|
+
while measure_text_tokens(text, tokenizer) < target_tokens + base_tok:
|
|
170
|
+
text = base * (reps * 2)
|
|
171
|
+
reps *= 2
|
|
172
|
+
guard += 1
|
|
173
|
+
if guard > 8:
|
|
174
|
+
break
|
|
175
|
+
return _trim_to_token_target(text, target_tokens, tokenizer)
|
|
176
|
+
|
|
177
|
+
# Char-based fallback: 4 chars/token heuristic.
|
|
178
|
+
return text[: target_tokens * 4]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ----------------------------------------------------------------------
|
|
182
|
+
# Output-length policy
|
|
183
|
+
# ----------------------------------------------------------------------
|
|
184
|
+
def make_output_policy(spec: str) -> Callable[[NormSession, NormTurn], int]:
|
|
185
|
+
"""Return a function mapping (session, turn) -> max_new_tokens.
|
|
186
|
+
|
|
187
|
+
Every policy yields a length to *pin*: the engine sends it as
|
|
188
|
+
``max_completion_tokens`` together with ``ignore_eos``, so a turn
|
|
189
|
+
never ends on the model's own EOS. There is deliberately no
|
|
190
|
+
"let the model decide" policy — a reasoning model left to itself runs
|
|
191
|
+
to the generation cap and emits many times the recorded work, which
|
|
192
|
+
makes the run incomparable with the trace it came from.
|
|
193
|
+
|
|
194
|
+
``spec`` grammar:
|
|
195
|
+
- ``trace`` : recorded completion_tokens (min 1)
|
|
196
|
+
- ``fixed:K`` : constant K tokens/turn
|
|
197
|
+
- ``budget:T`` : floor(T / num_turns), min 1, same for all turns
|
|
198
|
+
"""
|
|
199
|
+
spec = (spec or "trace").strip()
|
|
200
|
+
if spec == "trace":
|
|
201
|
+
return lambda sess, turn: max(1, turn.output_tokens)
|
|
202
|
+
if spec.startswith("fixed:"):
|
|
203
|
+
k = max(1, int(spec.split(":", 1)[1]))
|
|
204
|
+
return lambda sess, turn: k
|
|
205
|
+
if spec.startswith("budget:"):
|
|
206
|
+
total = max(1, int(spec.split(":", 1)[1]))
|
|
207
|
+
return lambda sess, turn: max(1, total // max(1, sess.num_turns))
|
|
208
|
+
raise ValueError(f"unknown output policy: {spec!r}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ----------------------------------------------------------------------
|
|
212
|
+
# Gap-distribution policy (synthetic mode)
|
|
213
|
+
# ----------------------------------------------------------------------
|
|
214
|
+
def make_gap_policy(spec: str, cap_ms: float = 30_000.0, seed: int = 42) -> Callable[[int], float]:
|
|
215
|
+
"""Return a function mapping ``turn_index`` -> ``gap_ms``.
|
|
216
|
+
|
|
217
|
+
``spec`` grammar:
|
|
218
|
+
- ``zero`` : no gap (pure multi-turn chat)
|
|
219
|
+
- ``fixed:Ms`` : constant M milliseconds
|
|
220
|
+
- ``uniform:lo,hi`` : uniform random in [lo, hi]
|
|
221
|
+
- ``exponential:mean`` : exponential with given mean (default)
|
|
222
|
+
|
|
223
|
+
All values are capped at ``cap_ms``. T1 (turn_index == 0) always
|
|
224
|
+
returns 0 — the first turn has no preceding tool wait.
|
|
225
|
+
"""
|
|
226
|
+
spec = (spec or "exponential:3000").strip()
|
|
227
|
+
|
|
228
|
+
def _bad() -> ValueError:
|
|
229
|
+
return ValueError(f"unknown gap policy: {spec!r}")
|
|
230
|
+
|
|
231
|
+
if spec == "zero":
|
|
232
|
+
return lambda idx: 0.0
|
|
233
|
+
|
|
234
|
+
if spec.startswith("fixed:"):
|
|
235
|
+
try:
|
|
236
|
+
ms = float(spec.split(":", 1)[1])
|
|
237
|
+
except ValueError:
|
|
238
|
+
raise _bad() from None
|
|
239
|
+
ms = min(ms, cap_ms)
|
|
240
|
+
return lambda idx: 0.0 if idx == 0 else ms
|
|
241
|
+
|
|
242
|
+
if spec.startswith("uniform:"):
|
|
243
|
+
parts = spec.split(":", 1)[1].split(",")
|
|
244
|
+
try:
|
|
245
|
+
lo, hi = float(parts[0]), float(parts[1])
|
|
246
|
+
except ValueError:
|
|
247
|
+
raise _bad() from None
|
|
248
|
+
hi = min(hi, cap_ms)
|
|
249
|
+
lo = min(lo, hi)
|
|
250
|
+
rng = random.Random(seed)
|
|
251
|
+
return lambda idx: 0.0 if idx == 0 else min(rng.uniform(lo, hi), cap_ms)
|
|
252
|
+
|
|
253
|
+
if spec.startswith("exponential:"):
|
|
254
|
+
try:
|
|
255
|
+
mean = float(spec.split(":", 1)[1])
|
|
256
|
+
except ValueError:
|
|
257
|
+
raise _bad() from None
|
|
258
|
+
rng = random.Random(seed)
|
|
259
|
+
return lambda idx: 0.0 if idx == 0 else min(rng.expovariate(1.0 / mean), cap_ms)
|
|
260
|
+
|
|
261
|
+
raise ValueError(f"unknown gap policy: {spec!r}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ----------------------------------------------------------------------
|
|
265
|
+
# Benchmark profiles (frozen reference configurations)
|
|
266
|
+
# ----------------------------------------------------------------------
|
|
267
|
+
@dataclass
|
|
268
|
+
class Profile:
|
|
269
|
+
"""A frozen, reproducible benchmark configuration.
|
|
270
|
+
|
|
271
|
+
Profiles select real sessions by their **peak recorded context** and
|
|
272
|
+
never truncate turns, so a tier keeps each trajectory's natural
|
|
273
|
+
length. The output policy is an independent knob that always
|
|
274
|
+
defaults to ``trace`` (the dataset's recorded output lengths).
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
name: str
|
|
278
|
+
min_turns: int
|
|
279
|
+
min_context: int
|
|
280
|
+
max_context: int
|
|
281
|
+
system_prompt_len: int
|
|
282
|
+
description: str
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# Bands are chosen from the full DeepSWE v1.1 index so every tier has a
|
|
286
|
+
# pool of thousands. ``large`` stops at 176K because 176K + an 8K system
|
|
287
|
+
# prompt still leaves ~18K of headroom under a 200K serving context
|
|
288
|
+
# window; sessions above that cannot be compared across models.
|
|
289
|
+
PROFILES: Dict[str, Profile] = {
|
|
290
|
+
"mixed": Profile(
|
|
291
|
+
name="mixed",
|
|
292
|
+
min_turns=3,
|
|
293
|
+
min_context=8_000,
|
|
294
|
+
max_context=176_000,
|
|
295
|
+
system_prompt_len=8_192,
|
|
296
|
+
description=(
|
|
297
|
+
"Whole usable band, 8-176K peak context (natural mix: 20% under "
|
|
298
|
+
"48K, 56% 48-128K, 25% over 128K; ~48 turns). Primary scan — one "
|
|
299
|
+
"pool, no rebalancing, as MLPerf does."
|
|
300
|
+
),
|
|
301
|
+
),
|
|
302
|
+
"small": Profile(
|
|
303
|
+
name="small",
|
|
304
|
+
min_turns=3,
|
|
305
|
+
min_context=8_000,
|
|
306
|
+
max_context=48_000,
|
|
307
|
+
system_prompt_len=8_192,
|
|
308
|
+
description="Light prefill: 8-48K peak context, ~20 turns.",
|
|
309
|
+
),
|
|
310
|
+
"mid": Profile(
|
|
311
|
+
name="mid",
|
|
312
|
+
min_turns=3,
|
|
313
|
+
min_context=48_000,
|
|
314
|
+
max_context=128_000,
|
|
315
|
+
system_prompt_len=8_192,
|
|
316
|
+
description="Moderate prefill: 48-128K peak context, ~48 turns.",
|
|
317
|
+
),
|
|
318
|
+
"large": Profile(
|
|
319
|
+
name="large",
|
|
320
|
+
min_turns=3,
|
|
321
|
+
min_context=128_000,
|
|
322
|
+
max_context=176_000,
|
|
323
|
+
system_prompt_len=8_192,
|
|
324
|
+
description="Heavy prefill: 128-176K peak context, ~80 turns.",
|
|
325
|
+
),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ----------------------------------------------------------------------
|
|
330
|
+
# Selection + planning
|
|
331
|
+
# ----------------------------------------------------------------------
|
|
332
|
+
def select_sessions(
|
|
333
|
+
sessions: List[NormSession],
|
|
334
|
+
min_turns: int = 1,
|
|
335
|
+
max_turns: Optional[int] = None,
|
|
336
|
+
min_context: Optional[int] = None,
|
|
337
|
+
max_context: Optional[int] = None,
|
|
338
|
+
num_sessions: Optional[int] = None,
|
|
339
|
+
seed: int = 0,
|
|
340
|
+
sys_prompt_tokens: int = 0,
|
|
341
|
+
) -> List[NormSession]:
|
|
342
|
+
"""Filter and sample sessions according to the turn/context knobs.
|
|
343
|
+
|
|
344
|
+
* ``max_turns`` truncates long sessions (only when set).
|
|
345
|
+
* ``min_turns`` drops sessions that are too short (never padded).
|
|
346
|
+
* ``min_context`` / ``max_context`` keep sessions whose peak context
|
|
347
|
+
falls inside the band. The band is tested *after* the optional
|
|
348
|
+
``max_turns`` cap, so it describes what will actually be replayed.
|
|
349
|
+
* ``max_context`` is the MODEL context window (the user-facing
|
|
350
|
+
contract): sessions are kept while their recorded peak fits the
|
|
351
|
+
window left over after the injected prompt,
|
|
352
|
+
``recorded_peak <= max_context - sys_prompt_tokens``.
|
|
353
|
+
``min_context`` applies to the same replayed-peak axis
|
|
354
|
+
(recorded peak + ``sys_prompt_tokens``). With no injection
|
|
355
|
+
(``sys_prompt_tokens == 0``) both apply to the recorded peak alone.
|
|
356
|
+
"""
|
|
357
|
+
if num_sessions is not None and num_sessions < 0:
|
|
358
|
+
raise ValueError(f"num_sessions must be >= 0, got {num_sessions}")
|
|
359
|
+
pool: List[NormSession] = []
|
|
360
|
+
for s in sessions:
|
|
361
|
+
if s.num_turns < min_turns:
|
|
362
|
+
continue
|
|
363
|
+
turns = s.turns
|
|
364
|
+
if max_turns is not None and len(turns) > max_turns:
|
|
365
|
+
turns = turns[:max_turns]
|
|
366
|
+
cand = NormSession(
|
|
367
|
+
session_id=s.session_id,
|
|
368
|
+
system_prompt=s.system_prompt,
|
|
369
|
+
turns=turns,
|
|
370
|
+
source=s.source,
|
|
371
|
+
)
|
|
372
|
+
peak = cand.peak_input_tokens + sys_prompt_tokens
|
|
373
|
+
if min_context is not None and peak < min_context:
|
|
374
|
+
continue
|
|
375
|
+
if max_context is not None and peak > max_context:
|
|
376
|
+
continue
|
|
377
|
+
pool.append(cand)
|
|
378
|
+
|
|
379
|
+
rng = random.Random(seed)
|
|
380
|
+
rng.shuffle(pool)
|
|
381
|
+
if num_sessions is not None and num_sessions < len(pool):
|
|
382
|
+
pool = pool[:num_sessions]
|
|
383
|
+
return pool
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
_RATES_PATH = os.path.join(
|
|
387
|
+
os.path.dirname(os.path.dirname(__file__)), "data", "recording_rates.json"
|
|
388
|
+
)
|
|
389
|
+
# Last-resort value if the shipped table cannot be read at all; the table
|
|
390
|
+
# carries the authoritative pooled fit under "_pooled".
|
|
391
|
+
_POOLED_FALLBACK = 140.0
|
|
392
|
+
_rates_cache: Optional[Dict[str, float]] = None
|
|
393
|
+
_pooled_cache: Optional[float] = None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _load_rates() -> None:
|
|
397
|
+
global _rates_cache, _pooled_cache
|
|
398
|
+
try:
|
|
399
|
+
with open(_RATES_PATH) as fh:
|
|
400
|
+
raw = json.load(fh)
|
|
401
|
+
_rates_cache = {k: float(v) for k, v in raw.items() if not k.startswith("_")}
|
|
402
|
+
_pooled_cache = float(raw.get("_pooled", _POOLED_FALLBACK))
|
|
403
|
+
except Exception as exc: # noqa: BLE001
|
|
404
|
+
print(
|
|
405
|
+
f"[warn] cannot read {_RATES_PATH} ({exc}); every session falls back "
|
|
406
|
+
f"to the pooled rate {_POOLED_FALLBACK} tok/s, so extracted tool "
|
|
407
|
+
f"waits will not match runs made with the table",
|
|
408
|
+
file=sys.stderr,
|
|
409
|
+
)
|
|
410
|
+
_rates_cache = {}
|
|
411
|
+
_pooled_cache = _POOLED_FALLBACK
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _recording_rates() -> Dict[str, float]:
|
|
415
|
+
"""Per-source-model output rate (tokens/s) of the recorded runs."""
|
|
416
|
+
if _rates_cache is None:
|
|
417
|
+
_load_rates()
|
|
418
|
+
return _rates_cache or {}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _pooled_rate() -> float:
|
|
422
|
+
if _pooled_cache is None:
|
|
423
|
+
_load_rates()
|
|
424
|
+
return _pooled_cache or _POOLED_FALLBACK
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _recording_rate(session: NormSession) -> float:
|
|
428
|
+
"""Estimate the output rate of the model that recorded ``session``.
|
|
429
|
+
|
|
430
|
+
``interval = tool_time + output_tokens / rate`` with tool time
|
|
431
|
+
unobserved, so the rate is taken as the 5th percentile of
|
|
432
|
+
``interval / output_tokens`` — the turns with the least tool work.
|
|
433
|
+
That percentile assumes those turns carry no tool work at all, which
|
|
434
|
+
makes the rate an *upper* bound and the generation time removed below
|
|
435
|
+
a *lower* bound: the split never eats into real tool idle.
|
|
436
|
+
|
|
437
|
+
The percentile is read from a table fitted per source model over the
|
|
438
|
+
whole corpus (tens of thousands of intervals each) rather than fitted
|
|
439
|
+
per session. Within one session tool time correlates with output
|
|
440
|
+
length, so a per-session regression is not identified: a Theil-Sen
|
|
441
|
+
variant returned 32 tok/s at the 10th percentile of small-tier
|
|
442
|
+
sessions and zeroed 26% of turns, against 88-295 tok/s per model.
|
|
443
|
+
"""
|
|
444
|
+
model = str((session.source or {}).get("model", ""))
|
|
445
|
+
rate = _recording_rates().get(model)
|
|
446
|
+
if rate:
|
|
447
|
+
return rate
|
|
448
|
+
ratios = sorted(
|
|
449
|
+
t.pre_gap_ms / 1000.0 / t.output_tokens
|
|
450
|
+
for t in session.turns
|
|
451
|
+
if t.output_tokens > 0 and t.pre_gap_ms > 0
|
|
452
|
+
)
|
|
453
|
+
if len(ratios) >= 20:
|
|
454
|
+
return 1.0 / ratios[int(0.05 * len(ratios))]
|
|
455
|
+
return _pooled_rate()
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def tool_delays_ms(session: NormSession) -> List[float]:
|
|
459
|
+
"""Split each recorded turn-to-turn interval into its tool-wait part.
|
|
460
|
+
|
|
461
|
+
``NormTurn.pre_gap_ms`` is the delta between consecutive recorded agent
|
|
462
|
+
steps, so it covers the tool execution *plus* one turn of the recording
|
|
463
|
+
model's generation (the interval correlates with the *next* turn's
|
|
464
|
+
output length at r=0.68, against r=0.16 for the previous turn's, so it
|
|
465
|
+
is the generation of the turn the interval leads into). Charging that
|
|
466
|
+
to the replayed server would pay for inference twice.
|
|
467
|
+
|
|
468
|
+
Splitting it from a table rather than from the replay's own latency
|
|
469
|
+
keeps the delay a property of the dataset alone, so the same
|
|
470
|
+
trajectory offers identical idle time to every server under test — as
|
|
471
|
+
MLPerf's dataset-side ``delay_seconds`` does. The split is a modelling
|
|
472
|
+
assumption, not a measurement: the dataset has no per-tool duration
|
|
473
|
+
field, and the only independent probe (turn 0, which has no tool call
|
|
474
|
+
before it) is unusable because non-agent steps carry whole-second
|
|
475
|
+
timestamps. Sanity bound: every fitted rate exceeds the index's
|
|
476
|
+
``n_output_tokens / agent_duration_seconds`` by 1.6-3.6x, as it must,
|
|
477
|
+
since that ratio charges tool time to generation.
|
|
478
|
+
"""
|
|
479
|
+
rate = _recording_rate(session)
|
|
480
|
+
return [
|
|
481
|
+
max(0.0, t.pre_gap_ms - 1000.0 * t.output_tokens / rate)
|
|
482
|
+
for t in session.turns
|
|
483
|
+
]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def plan_workload(
|
|
487
|
+
sessions: List[NormSession],
|
|
488
|
+
system_prompt_len: int,
|
|
489
|
+
output_policy: str,
|
|
490
|
+
tokenizer: Optional[Any] = None,
|
|
491
|
+
routing_key_fn: Optional[Callable[[NormSession], Optional[str]]] = None,
|
|
492
|
+
system_prompt: Optional[str] = None,
|
|
493
|
+
) -> List[PlannedSession]:
|
|
494
|
+
"""Attach a concrete system prompt and per-turn output plan.
|
|
495
|
+
|
|
496
|
+
The synthesized system prompt is shared (byte-identical) across all
|
|
497
|
+
sessions so its KV is fully reusable — matching production. Pass
|
|
498
|
+
``system_prompt`` to reuse an already-built prompt (the CLI builds it
|
|
499
|
+
once up front so its measured length can inform session selection).
|
|
500
|
+
"""
|
|
501
|
+
if system_prompt is None:
|
|
502
|
+
system_prompt = build_system_prompt(system_prompt_len, tokenizer)
|
|
503
|
+
sys_len = measure_text_tokens(system_prompt, tokenizer)
|
|
504
|
+
out_fn = make_output_policy(output_policy)
|
|
505
|
+
|
|
506
|
+
planned: List[PlannedSession] = []
|
|
507
|
+
for s in sessions:
|
|
508
|
+
delays = tool_delays_ms(s)
|
|
509
|
+
pturns = [
|
|
510
|
+
PlannedTurn(
|
|
511
|
+
index=t.index,
|
|
512
|
+
input_delta=t.input_delta,
|
|
513
|
+
assistant_text=t.assistant_text,
|
|
514
|
+
max_new_tokens=out_fn(s, t),
|
|
515
|
+
recorded_input_tokens=t.input_tokens,
|
|
516
|
+
recorded_cached_tokens=t.cached_tokens,
|
|
517
|
+
pre_gap_ms=delay,
|
|
518
|
+
tool_calls=t.tool_calls,
|
|
519
|
+
recorded_output_tokens=t.output_tokens,
|
|
520
|
+
)
|
|
521
|
+
for t, delay in zip(s.turns, delays)
|
|
522
|
+
]
|
|
523
|
+
planned.append(
|
|
524
|
+
PlannedSession(
|
|
525
|
+
session_id=s.session_id,
|
|
526
|
+
system_prompt=system_prompt,
|
|
527
|
+
turns=pturns,
|
|
528
|
+
routing_key=routing_key_fn(s) if routing_key_fn else None,
|
|
529
|
+
metadata={
|
|
530
|
+
"num_turns": s.num_turns,
|
|
531
|
+
"peak_input_tokens": s.peak_input_tokens,
|
|
532
|
+
"system_prompt_tokens": sys_len,
|
|
533
|
+
"model": s.source.get("model", ""),
|
|
534
|
+
},
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
return planned
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ----------------------------------------------------------------------
|
|
541
|
+
# Synthetic workload generation (no dataset required)
|
|
542
|
+
# ----------------------------------------------------------------------
|
|
543
|
+
def _tile_text(target_tokens: int, base: str, tokenizer: Optional[Any]) -> str:
|
|
544
|
+
"""Tile ``base`` to approximately ``target_tokens`` tokens."""
|
|
545
|
+
if target_tokens <= 0:
|
|
546
|
+
return ""
|
|
547
|
+
if tokenizer is not None:
|
|
548
|
+
base_tok = max(1, measure_text_tokens(base, tokenizer))
|
|
549
|
+
reps = max(2, (target_tokens * 3) // base_tok + 4)
|
|
550
|
+
return _trim_to_token_target(base * reps, target_tokens, tokenizer)
|
|
551
|
+
return base * max(1, target_tokens // max(1, len(base) // 4))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def synthesize_sessions(
|
|
555
|
+
num_sessions: int,
|
|
556
|
+
turns: int,
|
|
557
|
+
input_increment: int,
|
|
558
|
+
output_len: int,
|
|
559
|
+
system_prompt_len: int,
|
|
560
|
+
gap_policy: str = "exponential:3000",
|
|
561
|
+
gap_cap_ms: float = 30_000.0,
|
|
562
|
+
tokenizer: Optional[Any] = None,
|
|
563
|
+
routing_key_fn: Optional[Callable[[str], Optional[str]]] = None,
|
|
564
|
+
seed: int = 42,
|
|
565
|
+
) -> List[PlannedSession]:
|
|
566
|
+
"""Generate synthetic PlannedSessions without a dataset.
|
|
567
|
+
|
|
568
|
+
Each session has ``turns`` turns. Every turn adds ``input_increment``
|
|
569
|
+
tokens of new context (simulating tool output / user follow-up), so peak
|
|
570
|
+
context grows linearly. Output is fixed at ``output_len`` tokens/turn.
|
|
571
|
+
Inter-turn gaps follow ``gap_policy`` (T1 always has gap 0).
|
|
572
|
+
"""
|
|
573
|
+
if num_sessions < 0:
|
|
574
|
+
raise ValueError(f"num_sessions must be >= 0, got {num_sessions}")
|
|
575
|
+
gap_fn = make_gap_policy(gap_policy, cap_ms=gap_cap_ms, seed=seed)
|
|
576
|
+
system_prompt = build_system_prompt(system_prompt_len, tokenizer)
|
|
577
|
+
user_block = _tile_text(input_increment, _TURN_BASE, tokenizer)
|
|
578
|
+
assistant_block = _tile_text(output_len, _SYS_BASE, tokenizer)
|
|
579
|
+
|
|
580
|
+
planned: List[PlannedSession] = []
|
|
581
|
+
for si in range(num_sessions):
|
|
582
|
+
sid = f"synthetic-{si:04d}"
|
|
583
|
+
pturns: List[PlannedTurn] = []
|
|
584
|
+
for ti in range(turns):
|
|
585
|
+
pturns.append(
|
|
586
|
+
PlannedTurn(
|
|
587
|
+
index=ti,
|
|
588
|
+
input_delta=[{"role": "user", "content": user_block}],
|
|
589
|
+
assistant_text=assistant_block,
|
|
590
|
+
max_new_tokens=output_len,
|
|
591
|
+
recorded_input_tokens=input_increment,
|
|
592
|
+
recorded_cached_tokens=0,
|
|
593
|
+
pre_gap_ms=gap_fn(ti),
|
|
594
|
+
recorded_output_tokens=output_len,
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
rk = routing_key_fn(sid) if routing_key_fn else None
|
|
598
|
+
planned.append(
|
|
599
|
+
PlannedSession(
|
|
600
|
+
session_id=sid,
|
|
601
|
+
system_prompt=system_prompt,
|
|
602
|
+
turns=pturns,
|
|
603
|
+
routing_key=rk,
|
|
604
|
+
metadata={
|
|
605
|
+
"synthetic": True,
|
|
606
|
+
"num_turns": turns,
|
|
607
|
+
"input_increment": input_increment,
|
|
608
|
+
"output_len": output_len,
|
|
609
|
+
"gap_policy": gap_policy,
|
|
610
|
+
"gap_cap_ms": gap_cap_ms,
|
|
611
|
+
},
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
return planned
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Output tokens/s of the model that recorded each DeepSWE v1.1 trajectory, fitted as the 5th percentile of (agent-step interval / output tokens) over the full 22,490-session corpus (1,704,844 intervals). workload.tool_delays_ms subtracts output_tokens/rate from the recorded interval, leaving the tool wait. Refit by pooling (pre_gap_ms, output_tokens) per source.model over a normalized corpus.",
|
|
3
|
+
"_pooled": 140.0,
|
|
4
|
+
"anthropic/claude-opus-4-8": 118.2,
|
|
5
|
+
"anthropic/claude-sonnet-4-6": 101.3,
|
|
6
|
+
"anthropic/claude-sonnet-5": 122.8,
|
|
7
|
+
"moonshot/vellise-0716": 116.5,
|
|
8
|
+
"moonshotai/kimi-k2.7-code": 59.4,
|
|
9
|
+
"openai/gpt-5.4": 163.6,
|
|
10
|
+
"openai/gpt-5.5": 73.2,
|
|
11
|
+
"openai/gpt-5.6-luna": 163.7,
|
|
12
|
+
"openai/gpt-5.6-sol": 118.6,
|
|
13
|
+
"openai/gpt-5.6-terra": 149.8,
|
|
14
|
+
"openai/muse-spark-1.1": 150.3,
|
|
15
|
+
"vertex_ai/claude-fable-5": 160.5,
|
|
16
|
+
"vertex_ai/claude-opus-5": 129.7,
|
|
17
|
+
"vertex_ai/gemini-3.1-pro-preview": 141.8,
|
|
18
|
+
"vertex_ai/gemini-3.5-flash": 292.8,
|
|
19
|
+
"vertex_ai/gemini-3.6-flash": 171.7,
|
|
20
|
+
"vertex_ai/gemini-flash-early-exp2": 192.1,
|
|
21
|
+
"xai/grok-4.5": 160.6,
|
|
22
|
+
"zai/glm-5.2": 84.6
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, Alibaba Group and its affiliates
|
|
3
|
+
|
|
4
|
+
"""Closed-loop replay engine.
|
|
5
|
+
|
|
6
|
+
Owns the concurrency model (fixed active sessions, per-turn sequencing),
|
|
7
|
+
tool-wait gaps, pinned decode lengths, warmup and the run fuse. Turn-level
|
|
8
|
+
HTTP plumbing is in ``abench.replay.transport``; server discovery and
|
|
9
|
+
telemetry are in ``abench.serve``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from abench.replay.engine import run_closed_loop, warmup
|
|
13
|
+
|
|
14
|
+
__all__ = ["run_closed_loop", "warmup"]
|