mjolnir-cli 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.
Files changed (80) hide show
  1. mjolnir_cli/__init__.py +8 -0
  2. mjolnir_cli/__main__.py +4 -0
  3. mjolnir_cli/accuracy.py +333 -0
  4. mjolnir_cli/autoresearch.py +539 -0
  5. mjolnir_cli/benchy.py +475 -0
  6. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
  7. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
  8. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
  9. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
  10. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
  11. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
  12. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
  13. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
  14. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
  15. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
  16. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
  17. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
  18. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
  19. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
  20. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
  21. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
  22. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
  23. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
  24. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
  25. mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
  26. mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
  27. mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
  28. mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
  29. mjolnir_cli/bundled/docker/perses/README.md +89 -0
  30. mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
  31. mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
  32. mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
  33. mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
  34. mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
  35. mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
  36. mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
  37. mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
  38. mjolnir_cli/cachectl.py +146 -0
  39. mjolnir_cli/charts.py +1687 -0
  40. mjolnir_cli/cli.py +1480 -0
  41. mjolnir_cli/clidoc.py +170 -0
  42. mjolnir_cli/config.py +346 -0
  43. mjolnir_cli/dockerctl.py +533 -0
  44. mjolnir_cli/drivers/fa2_driver.py +89 -0
  45. mjolnir_cli/drivers/fi_driver.py +134 -0
  46. mjolnir_cli/e2e.py +302 -0
  47. mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
  48. mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
  49. mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
  50. mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
  51. mjolnir_cli/fonts/OFL.txt +92 -0
  52. mjolnir_cli/gate.py +233 -0
  53. mjolnir_cli/history.py +159 -0
  54. mjolnir_cli/interactive.py +383 -0
  55. mjolnir_cli/legs.py +109 -0
  56. mjolnir_cli/litellm.py +254 -0
  57. mjolnir_cli/perses.py +214 -0
  58. mjolnir_cli/picker.py +70 -0
  59. mjolnir_cli/readme.py +1020 -0
  60. mjolnir_cli/stack.py +340 -0
  61. mjolnir_cli/tasks.py +122 -0
  62. mjolnir_cli/theme.py +281 -0
  63. mjolnir_cli/thor.py +108 -0
  64. mjolnir_cli/verify/__init__.py +2 -0
  65. mjolnir_cli/verify/cli.py +816 -0
  66. mjolnir_cli/verify/docker.py +334 -0
  67. mjolnir_cli/verify/kvcache.py +180 -0
  68. mjolnir_cli/verify/legs.py +134 -0
  69. mjolnir_cli/verify/ncu.py +141 -0
  70. mjolnir_cli/verify/profile.py +312 -0
  71. mjolnir_cli/verify/report.py +177 -0
  72. mjolnir_cli/verify/results.py +157 -0
  73. mjolnir_cli/verify/spec.py +279 -0
  74. mjolnir_cli/verify/stages.py +817 -0
  75. mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
  76. mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
  77. mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
  78. mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
  79. mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  80. mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
@@ -0,0 +1,157 @@
1
+ """Structured verify results — the raw substrate for every chart and table.
2
+
3
+ Each ``mjolnir verify <stage>`` run writes ONE JSON document:
4
+
5
+ benchmarks/raw/verify/<stage>/<run_id>.json
6
+
7
+ Schema ``mjolnir.verify/1`` (all fields optional except the header):
8
+
9
+ {
10
+ "schema": "mjolnir.verify/1",
11
+ "stage": "sweep", # stage name (see stages.STAGES)
12
+ "run_id": "20261010-131259", # UTC timestamp, sortable
13
+ "ts": "2026-10-10T13:12:59+00:00",
14
+ "default": true, # ran with the stage's default parameters
15
+ "host": "jetsonthor", "image": "narandill/mjolnir-vllm:v0.31.0",
16
+ "kernel": {"version": "0.1.0", "git": "68ef2b3", "dirty": false},
17
+ "gate": {"gated": true, "clean": true, "server": "up"},
18
+ "params": {...}, # every knob the stage ran with
19
+ "series": [ # ncu stages: one entry per leg / variant
20
+ {"id": "fi", "label": "FlashInfer", "kind": "fi", "role": "baseline",
21
+ "flags": "", "kv_bits": 8,
22
+ "points": [{"L": 8192, "total_us": 96.1, "main_us": 89.2,
23
+ "merge_us": 6.9, "l2_sectors": 1.1e6, "ctas": 40,
24
+ "invocations": 50, "gbps": 174.8}]}
25
+ ],
26
+ "rows": [...], # harness stages: one dict per measurement
27
+ "cases": [...], # correctness: one dict per case
28
+ "profiles": [...], # fp6prof: full-ncu metric dicts
29
+ "summary": {...} # headline numbers computed at save time
30
+ }
31
+
32
+ Readers (charts, README, docs) pick the latest *default* run of a stage, so
33
+ an ad-hoc A/B with custom flags never replaces a headline chart by accident
34
+ (``latest(stage, default_only=False)`` returns any run).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import datetime as _dt
40
+ import json
41
+ import platform
42
+ import subprocess
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ SCHEMA = "mjolnir.verify/1"
47
+
48
+
49
+ def new_run_id() -> str:
50
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%d-%H%M%S")
51
+
52
+
53
+ def git_info(root: Path | None) -> dict[str, Any]:
54
+ if root is None:
55
+ return {}
56
+ try:
57
+ sha = subprocess.run(
58
+ ["git", "-C", str(root), "rev-parse", "--short", "HEAD"], capture_output=True, text=True, check=True
59
+ ).stdout.strip()
60
+ dirty = bool(
61
+ subprocess.run(
62
+ ["git", "-C", str(root), "status", "--porcelain", "--", "src/mjolnir"],
63
+ capture_output=True,
64
+ text=True,
65
+ check=True,
66
+ ).stdout.strip()
67
+ )
68
+ return {"git": sha, "dirty": dirty}
69
+ except (subprocess.CalledProcessError, FileNotFoundError):
70
+ return {}
71
+
72
+
73
+ def header(stage: str, run_id: str, *, image: str, default: bool, params: dict, kernel: dict, gate: dict) -> dict:
74
+ return {
75
+ "schema": SCHEMA,
76
+ "stage": stage,
77
+ "run_id": run_id,
78
+ "ts": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
79
+ "default": default,
80
+ "host": platform.node(),
81
+ "image": image,
82
+ "kernel": kernel,
83
+ "gate": gate,
84
+ "params": params,
85
+ }
86
+
87
+
88
+ def stage_dir(verify_dir: Path, stage: str) -> Path:
89
+ return verify_dir / stage
90
+
91
+
92
+ def save(verify_dir: Path, doc: dict) -> Path:
93
+ d = stage_dir(verify_dir, doc["stage"])
94
+ d.mkdir(parents=True, exist_ok=True)
95
+ p = d / f"{doc['run_id']}.json"
96
+ p.write_text(json.dumps(doc, indent=1, sort_keys=False) + "\n")
97
+ return p
98
+
99
+
100
+ def load(path: Path) -> dict | None:
101
+ try:
102
+ doc = json.loads(Path(path).read_text())
103
+ except (OSError, json.JSONDecodeError):
104
+ return None
105
+ if not isinstance(doc, dict) or doc.get("schema") != SCHEMA:
106
+ return None
107
+ return doc
108
+
109
+
110
+ def runs(verify_dir: Path, stage: str) -> list[dict]:
111
+ """Every saved run of a stage, oldest first."""
112
+ d = stage_dir(verify_dir, stage)
113
+ out = []
114
+ for p in sorted(d.glob("*.json")):
115
+ doc = load(p)
116
+ if doc:
117
+ doc["_path"] = str(p)
118
+ out.append(doc)
119
+ return out
120
+
121
+
122
+ def latest(verify_dir: Path, stage: str, default_only: bool = True) -> dict | None:
123
+ """The newest run of a stage — the newest *default-parameter* run unless
124
+ ``default_only`` is False (then any run). Falls back to any run when the
125
+ stage has no default run yet."""
126
+ rs = runs(verify_dir, stage)
127
+ if not rs:
128
+ return None
129
+ if default_only:
130
+ dflt = [r for r in rs if r.get("default")]
131
+ if dflt:
132
+ return dflt[-1]
133
+ return rs[-1]
134
+
135
+
136
+ # ── series helpers (shared by charts / README / docs) ───────────────────────
137
+
138
+
139
+ def series_by_id(doc: dict) -> dict[str, dict]:
140
+ return {s["id"]: s for s in doc.get("series", [])}
141
+
142
+
143
+ def point_map(series: dict, field: str = "total_us") -> dict[int, float]:
144
+ return {int(p["L"]): float(p[field]) for p in series.get("points", []) if p.get(field) is not None}
145
+
146
+
147
+ def speedup(base: dict, other: dict) -> dict[int, float]:
148
+ """base time / other time per L (>1 = ``other`` faster)."""
149
+ b, o = point_map(base), point_map(other)
150
+ return {L: b[L] / o[L] for L in sorted(b) if L in o and o[L] > 0}
151
+
152
+
153
+ def geomean(xs) -> float | None:
154
+ import math
155
+
156
+ xs = [x for x in xs if x and x > 0]
157
+ return math.exp(sum(math.log(x) for x in xs) / len(xs)) if xs else None
@@ -0,0 +1,279 @@
1
+ """``mjolnir verify spec`` — speculative decoding per config and draft count.
2
+
3
+ For every leg (``side:config``) and every number of draft tokens k, the
4
+ stage serves the config with ``--speculative-config`` overridden to k
5
+ (the server restarts per point, like ``kv-cache-size``), sends a fixed
6
+ prompt set at temperature 0 — the same text for every point — at each
7
+ concurrency, and records:
8
+
9
+ * tokens per verify step (1 + accepted / drafts) and per-position
10
+ acceptance, from vLLM's spec-decode counters before / after the block;
11
+ * decode tokens/s per request (streamed: tokens after the first ÷ time
12
+ from the first to the last token) and in aggregate, and TTFT.
13
+
14
+ Decode t/s = tokens per step ÷ step time, and every extra draft adds a
15
+ draft forward to the step — the best k is the one with the highest decode
16
+ t/s, which the stage reports per config and concurrency.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import threading
23
+ import time
24
+ import urllib.request
25
+ from pathlib import Path
26
+
27
+ import yaml
28
+
29
+ from mjolnir_cli import dockerctl
30
+ from mjolnir_cli.config import DEFAULT_IMAGE, STOCK_IMAGE, resolve
31
+ from mjolnir_cli.legs import verify_legs
32
+ from mjolnir_cli.gate import server_counters, spec_delta
33
+
34
+ DEFAULT_LEGS = verify_legs("verify.spec") # mjolnir_cli.legs
35
+ DEFAULT_DRAFTS = "1 2 3 4 5 6 7"
36
+ DEFAULT_CONCURRENCY = "1 4"
37
+ DEFAULT_MAX_TOKENS = 256
38
+
39
+ # A fixed, varied prompt set (acceptance depends on the text: code and
40
+ # lists accept more, prose less). Every point of the stage sends exactly these.
41
+ PROMPTS = [
42
+ "Explain how a transformer's attention mechanism works, step by step, with a small numeric example.",
43
+ "Write a Python function that parses an ISO-8601 timestamp without external libraries, then test it.",
44
+ "def quicksort(xs):\n # complete this implementation and explain its complexity\n",
45
+ "Summarize the causes and consequences of the 1929 stock market crash in five paragraphs.",
46
+ "Solve: a train leaves at 9:40 at 72 km/h, another at 10:05 at 90 km/h on the same track. When do they meet?",
47
+ "Write a SQL query that returns the top three customers by revenue per region, with window functions.",
48
+ "List the differences between TCP and UDP and when you would use each, as a table.",
49
+ "Rust: implement a thread-safe bounded queue with condition variables; explain the invariants.",
50
+ "Translate into German, French and Polish: 'The quick brown fox jumps over the lazy dog near the river bank.'",
51
+ "Write a bash script that finds the ten largest files under a directory and prints them in a table.",
52
+ "Prove that the square root of two is irrational.",
53
+ "Compose a short story about a lighthouse keeper who finds a message in a bottle.",
54
+ ]
55
+ LONG_SOURCE = (
56
+ "In the long history of computing, memory bandwidth has repeatedly set the pace of progress. Each "
57
+ "generation of accelerators added arithmetic faster than it added bytes per second, and every workload "
58
+ "that streams data once - decoding a language model token by token is one - became a question of how "
59
+ "few bytes it could read. "
60
+ )
61
+
62
+
63
+ def long_prompt(words: int) -> str:
64
+ reps = max(1, words // len(LONG_SOURCE.split()))
65
+ body = "".join(f"[{i}] {LONG_SOURCE}" for i in range(reps))
66
+ return body + "\nSummarize the argument above in five sentences and name its weakest step."
67
+
68
+
69
+ def prompt_set(long_words: int) -> list[str]:
70
+ return PROMPTS + ([long_prompt(long_words)] if long_words > 0 else [])
71
+
72
+
73
+ def _ints(v: str) -> list[int]:
74
+ return [int(x) for x in str(v).replace(",", " ").split() if x.strip()]
75
+
76
+
77
+ def _legs(spec: str) -> list[tuple[str, str]]:
78
+ from mjolnir_cli.verify.kvcache import _legs as legs
79
+
80
+ return legs(spec)
81
+
82
+
83
+ def spec_override(config_path: Path, k: int) -> str | None:
84
+ """The config's speculative-config with num_speculative_tokens = k, as
85
+ the JSON ``--speculative-config`` takes (None: the config has none)."""
86
+ try:
87
+ data = yaml.safe_load(Path(config_path).read_text()) or {}
88
+ except Exception: # noqa: BLE001
89
+ return None
90
+ sp = data.get("speculative-config")
91
+ if not isinstance(sp, dict):
92
+ return None
93
+ return json.dumps({**sp, "num_speculative_tokens": int(k)})
94
+
95
+
96
+ def stream_one(base_url: str, model: str, prompt: str, max_tokens: int, timeout: float = 600.0) -> dict:
97
+ """One streamed chat completion at temperature 0: TTFT, decode t/s, tokens."""
98
+ body = json.dumps(
99
+ {
100
+ "model": model,
101
+ "messages": [{"role": "user", "content": prompt}],
102
+ "max_tokens": max_tokens,
103
+ "temperature": 0.0,
104
+ "stream": True,
105
+ "stream_options": {"include_usage": True},
106
+ }
107
+ ).encode()
108
+ req = urllib.request.Request(
109
+ f"{base_url}/chat/completions", data=body, headers={"Content-Type": "application/json"}
110
+ )
111
+ t0 = time.perf_counter()
112
+ first = last = None
113
+ chunks = 0
114
+ tokens = None
115
+ with urllib.request.urlopen(req, timeout=timeout) as r:
116
+ for raw in r:
117
+ line = raw.decode(errors="replace").strip()
118
+ if not line.startswith("data:"):
119
+ continue
120
+ data = line[5:].strip()
121
+ if data == "[DONE]":
122
+ break
123
+ try:
124
+ ev = json.loads(data)
125
+ except json.JSONDecodeError:
126
+ continue
127
+ if ev.get("usage"):
128
+ tokens = ev["usage"].get("completion_tokens")
129
+ for ch in ev.get("choices") or []:
130
+ delta = ch.get("delta") or {}
131
+ if delta.get("content") or delta.get("reasoning_content") or delta.get("reasoning"):
132
+ now = time.perf_counter()
133
+ first = first if first is not None else now
134
+ last = now
135
+ chunks += 1
136
+ tokens = tokens if tokens is not None else chunks
137
+ out = {"tokens": tokens, "chunks": chunks, "ttft_ms": None, "decode_tps": None}
138
+ if first is not None:
139
+ out["ttft_ms"] = (first - t0) * 1000
140
+ if last is not None and last > first and tokens > 1:
141
+ out["decode_tps"] = (tokens - 1) / (last - first)
142
+ out["tokens_per_chunk"] = tokens / max(1, chunks)
143
+ return out
144
+
145
+
146
+ def run_block(s, prompts: list[str], conc: int, max_tokens: int) -> dict:
147
+ """Every prompt once, ``conc`` in flight; spec counters around it."""
148
+ before = server_counters(s.metrics_url)
149
+ results: list[dict] = []
150
+ errors: list[str] = []
151
+ lock = threading.Lock()
152
+ queue = list(prompts)
153
+
154
+ def worker():
155
+ while True:
156
+ with lock:
157
+ if not queue:
158
+ return
159
+ p = queue.pop(0)
160
+ try:
161
+ r = stream_one(s.base_url, s.served_model, p, max_tokens)
162
+ except Exception as e: # noqa: BLE001
163
+ with lock:
164
+ errors.append(repr(e))
165
+ continue
166
+ with lock:
167
+ results.append(r)
168
+
169
+ t0 = time.perf_counter()
170
+ threads = [threading.Thread(target=worker) for _ in range(conc)]
171
+ for t in threads:
172
+ t.start()
173
+ for t in threads:
174
+ t.join()
175
+ wall = time.perf_counter() - t0
176
+ stats = spec_delta(before, server_counters(s.metrics_url)) or {}
177
+ tps = [r["decode_tps"] for r in results if r.get("decode_tps")]
178
+ ttft = [r["ttft_ms"] for r in results if r.get("ttft_ms") is not None]
179
+ tokens = sum(r["tokens"] or 0 for r in results)
180
+ return {
181
+ "concurrency": conc,
182
+ "requests": len(results),
183
+ "errors": errors[:3],
184
+ "tokens": tokens,
185
+ "wall_s": round(wall, 2),
186
+ "decode_tps": round(sum(tps) / len(tps), 3) if tps else None, # per request, mean
187
+ "agg_tps": round(tokens / wall, 3) if wall > 0 else None, # all requests, incl. prefill time
188
+ "ttft_ms": round(sum(ttft) / len(ttft), 1) if ttft else None,
189
+ # per request, in completion order: the mean hides a few slow first steps
190
+ "ttft_p50_ms": round(sorted(ttft)[len(ttft) // 2], 1) if ttft else None,
191
+ "ttft_list_ms": [round(x, 1) for x in ttft],
192
+ **{k: stats.get(k) for k in ("acceptance_length", "acceptance_rate", "per_pos", "drafts", "preemptions")},
193
+ }
194
+
195
+
196
+ def best_k(rows: list[dict]) -> dict:
197
+ """{(config, concurrency): row with the highest per-request decode t/s}."""
198
+ out: dict = {}
199
+ for r in rows:
200
+ if r.get("decode_tps") is None:
201
+ continue
202
+ key = (r["config"], r["concurrency"])
203
+ if key not in out or r["decode_tps"] > out[key]["decode_tps"]:
204
+ out[key] = r
205
+ return out
206
+
207
+
208
+ def stage_spec(
209
+ ctx,
210
+ legs: str = DEFAULT_LEGS,
211
+ drafts: str = DEFAULT_DRAFTS,
212
+ concurrency: str = DEFAULT_CONCURRENCY,
213
+ max_tokens: int = DEFAULT_MAX_TOKENS,
214
+ long_words: int = 6000,
215
+ model: str | None = None,
216
+ restart: bool = False,
217
+ timeout: float = 1800.0,
218
+ ) -> dict:
219
+ """Serve every leg at every draft count; one block per concurrency."""
220
+ rows, cases = [], []
221
+ mj_image = ctx.runner.image or DEFAULT_IMAGE
222
+ params = {
223
+ "legs": legs,
224
+ "drafts": drafts,
225
+ "concurrency": concurrency,
226
+ "max_tokens": max_tokens,
227
+ "long_words": long_words,
228
+ "prompts": len(prompt_set(long_words)),
229
+ }
230
+ if dockerctl.container_running() and not restart and not ctx.runner.dry_run:
231
+ ctx.out(
232
+ "[red] a vLLM server is running — this stage restarts the server for every point. "
233
+ "Stop it in a window you own (mjolnir serve down), or pass --restart.[/red]"
234
+ )
235
+ return {"rows": [], "cases": [{"label": "server busy (not restarted)", "passed": False}], "params": params}
236
+ prompts = prompt_set(long_words)
237
+ logdir = ctx.logdir / "spec"
238
+ logdir.mkdir(parents=True, exist_ok=True)
239
+ for side, quant in _legs(legs):
240
+ s = resolve(model, quant, STOCK_IMAGE if side == "official" else mj_image, None)
241
+ for k in _ints(drafts):
242
+ point = f"{side}-{quant}-k{k}"
243
+ override = spec_override(s.config_path, k)
244
+ ctx.out(f" ▸ {point} ({s.image})")
245
+ if override is None:
246
+ cases.append({"label": point, "passed": False, "error": "config has no speculative-config"})
247
+ continue
248
+ serve_args = ("--speculative-config", override)
249
+ if ctx.runner.dry_run:
250
+ dockerctl.serve_up(s, wait=False, dry_run=True, serve_args=serve_args)
251
+ continue
252
+ err = None
253
+ log = logdir / f"{point}.log"
254
+ try:
255
+ dockerctl.serve_up(s, wait=True, wait_timeout_s=timeout, serve_args=serve_args)
256
+ for _ in range(2): # warm-up: graphs, caches, allocator
257
+ stream_one(s.base_url, s.served_model, PROMPTS[0], 32)
258
+ for conc in _ints(concurrency):
259
+ b = run_block(s, prompts, conc, max_tokens)
260
+ rows.append({"id": point, "side": side, "config": quant, "image": s.image, "k": k, **b})
261
+ ctx.out(
262
+ f" c={conc}: {b['acceptance_length'] or 0:.2f} tok/step · decode "
263
+ f"{b['decode_tps'] or 0:.1f} t/s/req · agg {b['agg_tps'] or 0:.1f} t/s · per pos "
264
+ f"{' '.join(f'{p:.2f}' for p in (b['per_pos'] or []))}"
265
+ + (f" · {len(b['errors'])} errors" if b["errors"] else "")
266
+ )
267
+ except (dockerctl.DockerError, TimeoutError, OSError) as e:
268
+ err = str(e)
269
+ finally:
270
+ dockerctl.dump_server_log(None, log)
271
+ dockerctl.serve_down(s)
272
+ dockerctl._run_quiet([dockerctl._docker(), "rm", "-f", dockerctl.CONTAINER_NAME], ok_codes=(0, 1))
273
+ ok = err is None and any(r["id"] == point and r.get("decode_tps") for r in rows)
274
+ cases.append({"label": point, "passed": ok, **({"error": err} if err else {}), "log": str(log)})
275
+ if not ok:
276
+ ctx.out(f" ❌ {err or 'no decode measured'} — {log}")
277
+ for (cfg, conc), r in sorted(best_k(rows).items()):
278
+ ctx.out(f" best k for {cfg} at c={conc}: k={r['k']} ({r['decode_tps']:.1f} t/s/req)")
279
+ return {"rows": rows, "cases": cases, "params": params}