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.
@@ -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"]