inferencekit-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. inferencekit/__init__.py +9 -0
  2. inferencekit/__main__.py +5 -0
  3. inferencekit/analysis/__init__.py +1 -0
  4. inferencekit/analysis/ncu_csv.py +284 -0
  5. inferencekit/analysis/nsys_sqlite.py +518 -0
  6. inferencekit/canonical.py +104 -0
  7. inferencekit/checkpoint.py +396 -0
  8. inferencekit/cli.py +274 -0
  9. inferencekit/cuda/bw_peak.cu +252 -0
  10. inferencekit/cuda/torch_bw.py +60 -0
  11. inferencekit/engines/__init__.py +7 -0
  12. inferencekit/engines/adapters.py +190 -0
  13. inferencekit/engines/clip/__init__.py +13 -0
  14. inferencekit/engines/clip/comfyui.py +749 -0
  15. inferencekit/engines/clip/diffusers_ltx.py +601 -0
  16. inferencekit/engines/clip/noise.py +46 -0
  17. inferencekit/engines/clip/official_ltx.py +316 -0
  18. inferencekit/engines/clip/server.py +542 -0
  19. inferencekit/engines/clip/stepdump.py +182 -0
  20. inferencekit/engines/gates.py +94 -0
  21. inferencekit/engines/sglang_worker.py +86 -0
  22. inferencekit/engines/vllm_worker.py +129 -0
  23. inferencekit/evidence.py +427 -0
  24. inferencekit/gpuinfo.py +93 -0
  25. inferencekit/hardware.py +135 -0
  26. inferencekit/mcp_server.py +178 -0
  27. inferencekit/ops/__init__.py +30 -0
  28. inferencekit/ops/bench.py +184 -0
  29. inferencekit/ops/bench_audio.py +384 -0
  30. inferencekit/ops/bench_clip.py +819 -0
  31. inferencekit/ops/bench_image.py +393 -0
  32. inferencekit/ops/bench_rounds.py +297 -0
  33. inferencekit/ops/bench_serve.py +866 -0
  34. inferencekit/ops/ceiling.py +135 -0
  35. inferencekit/ops/compare.py +378 -0
  36. inferencekit/ops/correctness.py +153 -0
  37. inferencekit/ops/doctor.py +486 -0
  38. inferencekit/ops/gates.py +1772 -0
  39. inferencekit/ops/gates_clip.py +930 -0
  40. inferencekit/ops/intake.py +1321 -0
  41. inferencekit/ops/ledger.py +749 -0
  42. inferencekit/ops/manifest.py +309 -0
  43. inferencekit/ops/ncu.py +183 -0
  44. inferencekit/ops/nsys.py +224 -0
  45. inferencekit/ops/prompts.py +316 -0
  46. inferencekit/ops/quality.py +2161 -0
  47. inferencekit/ops/quality_clip.py +690 -0
  48. inferencekit/ops/quality_clip_steps.py +341 -0
  49. inferencekit/ops/report.py +317 -0
  50. inferencekit/ops/review.py +119 -0
  51. inferencekit/ops/rocprof.py +89 -0
  52. inferencekit/ops/roofline.py +392 -0
  53. inferencekit/ops/store.py +61 -0
  54. inferencekit/proc.py +172 -0
  55. inferencekit/prompts/ATTRIBUTION.md +56 -0
  56. inferencekit/prompts/__init__.py +167 -0
  57. inferencekit/prompts/build.py +518 -0
  58. inferencekit/prompts/dev-load.jsonl.gz +0 -0
  59. inferencekit/prompts/dev-load.manifest.json +212 -0
  60. inferencekit/prompts/dev.jsonl.gz +0 -0
  61. inferencekit/prompts/dev.manifest.json +214 -0
  62. inferencekit/prompts/exclusions.json +54 -0
  63. inferencekit/quality/__init__.py +23 -0
  64. inferencekit/quality/client.py +66 -0
  65. inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
  66. inferencekit/quality/fake.py +329 -0
  67. inferencekit/quality/gsm8k.py +224 -0
  68. inferencekit/quality/logprobs.py +106 -0
  69. inferencekit/quality/metrics.py +235 -0
  70. inferencekit/quality/niah.py +63 -0
  71. inferencekit/quality/parquet.py +296 -0
  72. inferencekit/quality/sampling.py +262 -0
  73. inferencekit/quality/step_replay.py +217 -0
  74. inferencekit/quality/video_metrics.py +304 -0
  75. inferencekit/registry.py +206 -0
  76. inferencekit/review/__init__.py +6 -0
  77. inferencekit/review/diff.py +163 -0
  78. inferencekit/review/media.py +181 -0
  79. inferencekit/review/page.py +920 -0
  80. inferencekit/review/rundir.py +844 -0
  81. inferencekit/roofline_headers.py +504 -0
  82. inferencekit/schemas/evidence.v1.json +130 -0
  83. inferencekit/schemas/evidence.v2.json +499 -0
  84. inferencekit/schemas/intake.v1.json +276 -0
  85. inferencekit/schemas/run-manifest.v1.json +118 -0
  86. inferencekit/serve/__init__.py +8 -0
  87. inferencekit/serve/audio_client.py +330 -0
  88. inferencekit/serve/client.py +446 -0
  89. inferencekit/serve/clip_client.py +351 -0
  90. inferencekit/serve/clip_rounds.py +429 -0
  91. inferencekit/serve/image_client.py +373 -0
  92. inferencekit/serve/loadgen.py +136 -0
  93. inferencekit/serve/metrics.py +203 -0
  94. inferencekit/serve/profiles.py +105 -0
  95. inferencekit/serve/prompts_io.py +125 -0
  96. inferencekit/serve/rounds.py +1111 -0
  97. inferencekit/serve/servers.py +897 -0
  98. inferencekit/stats.py +249 -0
  99. inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
  100. inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
  101. inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
  102. inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  103. inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  104. inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
@@ -0,0 +1,2161 @@
1
+ """quality trajectory / score / gsm8k / niah / sampling: does a faster engine still produce the model's output?
2
+
3
+ ``quality trajectory`` each engine's own greedy text over the OpenAI chat API, with the
4
+ top-k logprobs and token ids at every position, one JSON file per prompt under
5
+ ``<run-dir>/quality/<label>/`` plus an ``index.json`` of their sha256 hashes. An engine
6
+ that returns no logprobs (or refuses the field) gets a text-only trajectory instead.
7
+ ``quality score`` teacher-forces every trajectory through a higher-precision reference
8
+ (vLLM ``/v1/completions`` with a token-id prompt, ``max_tokens=1`` and the vLLM
9
+ extension ``prompt_logprobs``), then reports per engine and per position bucket:
10
+ top-1 agreement with the reference's argmax, KL(reference || engine) and total variation
11
+ on the top-k supports (approximations documented in ``quality/metrics.py``), the reference's NLL of
12
+ the engine's tokens (and its perplexity), and the first divergence from the
13
+ reference's own greedy trajectory (``--baseline``). The A/A noise floor is the
14
+ reference's second greedy run (``--baseline-repeat``, else the first one) scored the
15
+ same way: it measures how much the reference's decode path and its teacher-forced
16
+ prefill path (and, with a repeat, two runs of it) already disagree. Each engine gets
17
+ PASS / FAIL / UNPROVEN from paired bootstrap CIs (over prompts) of "engine minus
18
+ floor" against pre-registered margins. KL is compared at the engine's k: both lists
19
+ are cut to the k alternatives the engine returned (see ``_score_one``). A PASS also
20
+ needs clean inputs: every prompt the baseline covers scored for the engine and the
21
+ floor, the same prompt file, the same chat template, and every index.json listed by
22
+ an intact ``quality_trajectory`` evidence record; otherwise it becomes UNPROVEN.
23
+ ``quality gsm8k`` GSM8K test split, zero-shot chain of thought, temperature 0, exact
24
+ numeric match; accuracy with a Wilson CI; ``--paired-with`` adds McNemar's exact test
25
+ (refused when the two runs differ in dataset, prompt, max_tokens or chat template).
26
+ ``--concurrency 0`` sizes the request pool to the server; ``--time-budget-s`` stops early.
27
+ ``quality sampling`` first-token histograms under given sampling settings, candidate vs
28
+ baseline, against the baseline's own A/A floor (a second seed range).
29
+
30
+ Sources for the vLLM request fields (checked against vLLM main, commit 0fae39a, 13 Oct 2026):
31
+ ``vllm/entrypoints/openai/completion/protocol.py`` (``prompt_logprobs``,
32
+ ``return_tokens_as_token_ids``, ``return_token_ids``; ``CompletionResponseChoice.prompt_logprobs:
33
+ list[dict[int, Logprob] | None]``) and ``vllm/entrypoints/serve/tokenize/protocol.py``
34
+ (``TokenizeChatRequest``: ``messages``, ``add_generation_prompt`` (default true),
35
+ ``add_special_tokens`` (default false), ``chat_template_kwargs``; response ``tokens``).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import math
42
+ import re
43
+ import shutil
44
+ import statistics
45
+ import threading
46
+ import time
47
+ from collections import Counter
48
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
49
+ from pathlib import Path
50
+
51
+ from inferencekit import evidence as ev
52
+ from inferencekit import stats
53
+ from inferencekit.canonical import digest, file_sha256, sha256_hex
54
+ from inferencekit.ops.correctness import first_divergence
55
+ from inferencekit.ops.intake import apply_pin
56
+ from inferencekit.quality import gsm8k as g8
57
+ from inferencekit.quality import metrics as qm
58
+ from inferencekit.quality import sampling as sm
59
+ from inferencekit.quality.client import Client, HTTPError
60
+ from inferencekit.quality.logprobs import argmax_token, parse_chat_logprobs, parse_prompt_logprobs
61
+ from inferencekit.registry import Arg, OpError, operation
62
+
63
+ ENGINE_KINDS = ("vllm", "sglang", "llamacpp", "openai")
64
+ TRAJ_FORMAT = "inferencekit.quality.trajectory.v1"
65
+ INDEX_FORMAT = "inferencekit.quality.index.v1"
66
+ SCORE_FORMAT = "inferencekit.quality.score.v1"
67
+ _SAFE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
68
+ _MAX_ALLOWED = re.compile(r"max(?:imum)?(?: allowed)?\D{0,20}?(\d+)", re.IGNORECASE)
69
+
70
+
71
+ # --------------------------------------------------------------------------- shared helpers
72
+
73
+ def _kind(v2_kind: str) -> tuple[str, dict]:
74
+ """Evidence kind: the v2 kind once the v2 schema is merged; until then v1 ``correctness`` + data.v2_kind."""
75
+ if v2_kind in ev.KINDS:
76
+ return v2_kind, {}
77
+ return "correctness", {"v2_kind": v2_kind}
78
+
79
+
80
+ def _save(call, run_dir: Path | None, kind: str, **kw) -> dict:
81
+ """Build, store and (if a run dir is given) copy the record to ``<run-dir>/evidence/<id>.json``."""
82
+ real_kind, extra = _kind(kind)
83
+ kw["data"] = {**extra, **kw.get("data", {})}
84
+ rec = ev.Store().save(ev.build(call, kind=real_kind, **kw))
85
+ if run_dir is not None:
86
+ out = Path(run_dir) / "evidence"
87
+ out.mkdir(parents=True, exist_ok=True)
88
+ (out / f"{rec['evidence_id']}.json").write_text(json.dumps(rec, indent=2, ensure_ascii=False) + "\n")
89
+ return rec
90
+
91
+
92
+ def _json_arg(name: str, text: str | None) -> dict | None:
93
+ if not text:
94
+ return None
95
+ try:
96
+ value = json.loads(text)
97
+ except ValueError as e:
98
+ raise OpError(f"{name} is not valid JSON: {e}") from None
99
+ if not isinstance(value, dict):
100
+ raise OpError(f"{name} must be a JSON object")
101
+ return value
102
+
103
+
104
+ def _safe(name: str, what: str) -> str:
105
+ if not _SAFE.match(str(name)):
106
+ raise OpError(f"{what} {name!r} must match {_SAFE.pattern} (it becomes a file name)")
107
+ return str(name)
108
+
109
+
110
+ def load_prompts(path: str) -> tuple[list[dict], str]:
111
+ """Prompt-set JSONL or .jsonl.gz (contract format: id, bucket, messages, max_tokens) -> items, sha256 of
112
+ the JSONL bytes."""
113
+ from inferencekit import prompts as sets
114
+
115
+ p = sets.existing(path)
116
+ if not p.is_file():
117
+ raise OpError(f"prompts file not found: {path}")
118
+ try:
119
+ raw = sets.read_bytes(p)
120
+ text = raw.decode("utf-8")
121
+ except (OSError, ValueError) as e: # missing, a truncated / corrupt .jsonl.gz, or not UTF-8
122
+ raise OpError(f"prompts file {p}: cannot read ({e})") from None
123
+ items, seen = [], set()
124
+ for n, line in enumerate(text.splitlines(), 1):
125
+ if not line.strip():
126
+ continue
127
+ try:
128
+ obj = json.loads(line)
129
+ except ValueError:
130
+ raise OpError(f"{p}:{n}: not JSON") from None
131
+ if "messages" not in obj and "prompt" in obj:
132
+ obj["messages"] = [{"role": "user", "content": obj["prompt"]}]
133
+ if not isinstance(obj.get("messages"), list) or "id" not in obj:
134
+ raise OpError(f"{p}:{n}: need 'id' and 'messages' (OpenAI chat format)")
135
+ pid = _safe(obj["id"], f"{p}:{n}: prompt id")
136
+ if pid in seen:
137
+ raise OpError(f"{p}:{n}: duplicate prompt id {pid}")
138
+ seen.add(pid)
139
+ items.append(obj)
140
+ if not items:
141
+ raise OpError(f"{p}: no prompts")
142
+ return items, sha256_hex(raw)
143
+
144
+
145
+ def _post_capped(client: Client, path: str, body: dict, key: str, state: dict, lock: threading.Lock) -> tuple:
146
+ """POST with ``body[key] = state['k']``; on a "too many logprobs" 400, lower k (shared) and retry.
147
+
148
+ Engines cap top-k logprobs (vLLM: ``--max-logprobs``, default 20; the OpenAI API: 20).
149
+ The cap is parsed from the error ("... max allowed: 5") or k is halved.
150
+ """
151
+ while True:
152
+ with lock:
153
+ k = state["k"]
154
+ body = {**body, key: k}
155
+ try:
156
+ return client.post(path, body), body
157
+ except HTTPError as e:
158
+ msg = e.message.lower()
159
+ if e.status in (400, 422) and "logprob" in msg:
160
+ m = _MAX_ALLOWED.search(e.message)
161
+ new = int(m.group(1)) if m and 0 < int(m.group(1)) < k else k // 2
162
+ if new < 1 or new >= k:
163
+ raise
164
+ with lock:
165
+ state["k"] = min(state["k"], new)
166
+ state.setdefault("cap_errors", []).append(e.message[:200])
167
+ continue
168
+ raise
169
+
170
+
171
+ def _run_pool(fn, items, concurrency: int, serial_first: bool = False) -> list:
172
+ """``fn`` over ``items`` with up to ``concurrency`` in flight, results in order.
173
+
174
+ ``serial_first`` runs item 0 alone first: a logprob cap that request discovers
175
+ (``_post_capped``) then holds for every later request, so all results share one k.
176
+ """
177
+ items = list(items)
178
+ head = [fn(items[0])] if serial_first and items else []
179
+ rest = items[len(head):]
180
+ if concurrency <= 1:
181
+ return head + [fn(x) for x in rest]
182
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
183
+ return head + list(pool.map(fn, rest))
184
+
185
+
186
+ # --------------------------------------------------------------------------- quality trajectory
187
+
188
+ @operation("quality_trajectory", ("quality", "trajectory"),
189
+ "Greedy generation per prompt over the OpenAI chat API with top-k logprobs and token ids at every "
190
+ "position; one file per prompt under <run-dir>/quality/<label>/ (input to quality score).",
191
+ [Arg("base_url", "string", "Engine server, e.g. http://127.0.0.1:8000 (with or without /v1).",
192
+ required=True),
193
+ Arg("model", "string", "Model name the server expects.", required=True),
194
+ Arg("prompts", "string", "Prompt-set JSONL (id, bucket, messages, max_tokens).", required=True),
195
+ Arg("label", "string", "Name of this engine/configuration (directory name).", required=True),
196
+ Arg("run_dir", "string", "Run directory (runs/<run-id>).", required=True),
197
+ Arg("max_tokens", "integer", "Tokens to generate per prompt (default: each item's max_tokens, else 256)."),
198
+ Arg("top_logprobs", "integer", "Alternatives per position (lowered automatically to the engine's cap).",
199
+ default=20),
200
+ Arg("engine_kind", "string", "vllm adds return_tokens_as_token_ids / return_token_ids so token ids "
201
+ "are exact; others rely on id fields in the logprobs.", default="openai", choices=ENGINE_KINDS),
202
+ Arg("chat_template_kwargs", "string", "JSON object passed as chat_template_kwargs, e.g. "
203
+ "'{\"enable_thinking\": false}' (also used by quality score to tokenize the prompt)."),
204
+ Arg("seed", "integer", "Request seed.", default=0),
205
+ Arg("limit", "integer", "Only the first N prompts."),
206
+ Arg("concurrency", "integer", "Requests in flight (1 = sequential; batching can change numerics).",
207
+ default=1),
208
+ Arg("api_key_env", "string", "Name of the env var holding the API key (value never recorded).",
209
+ default="OPENAI_API_KEY"),
210
+ Arg("timeout", "integer", "Seconds per request.", default=600)])
211
+ def quality_trajectory(call, base_url, model, prompts, label, run_dir, max_tokens, top_logprobs, engine_kind,
212
+ chat_template_kwargs, seed, limit, concurrency, api_key_env, timeout):
213
+ label = _safe(label, "label")
214
+ items, prompts_sha = load_prompts(prompts)
215
+ if limit is not None:
216
+ items = items[:max(0, limit)]
217
+ if not items:
218
+ raise OpError("no prompts to run (limit 0?)")
219
+ base_kwargs = apply_pin(_json_arg("chat_template_kwargs", chat_template_kwargs) or {}, run_dir,
220
+ "quality trajectory")
221
+ if top_logprobs < 1:
222
+ raise OpError("top_logprobs must be >= 1")
223
+ run = Path(run_dir)
224
+ out_dir = run / "quality" / label
225
+ out_dir.mkdir(parents=True, exist_ok=True)
226
+ client = Client(base_url, api_key_env, timeout)
227
+ state, lock = {"k": top_logprobs}, threading.Lock()
228
+ tokenize_state = {"ok": True}
229
+
230
+ def one(item: dict) -> dict:
231
+ n = max_tokens or item.get("max_tokens") or 256
232
+ body = {"model": model, "messages": item["messages"], "max_tokens": int(n), "temperature": 0,
233
+ "seed": seed, "logprobs": True, "stream": False}
234
+ # The record's own chat_template_kwargs field is merged over the flag (per key), as bench serve does.
235
+ own = item.get("chat_template_kwargs")
236
+ kwargs = {**base_kwargs, **own} if isinstance(own, dict) else base_kwargs
237
+ if kwargs:
238
+ body["chat_template_kwargs"] = kwargs
239
+ if engine_kind == "vllm":
240
+ body.update(return_tokens_as_token_ids=True, return_token_ids=True)
241
+ plain = {k: v for k, v in body.items() if k not in ("logprobs", "top_logprobs")}
242
+ try:
243
+ if state.get("refused"): # the engine already refused logprobs once: ask for text only
244
+ resp, sent = client.post("/v1/chat/completions", plain), plain
245
+ else:
246
+ try:
247
+ resp, sent = _post_capped(client, "/v1/chat/completions", body, "top_logprobs", state, lock)
248
+ except HTTPError as e:
249
+ # An engine without logprob support may refuse the field outright: keep its text.
250
+ if e.status not in (400, 422) or "logprob" not in e.message.lower():
251
+ raise
252
+ resp, sent = client.post("/v1/chat/completions", plain), plain
253
+ with lock:
254
+ state["refused"] = e.message[:200]
255
+ # _post_capped first read the refusal as a cap and lowered k: those were not cap errors
256
+ state["cap_errors"] = [m for m in state.get("cap_errors", []) if m != state["refused"]]
257
+ except OpError as e:
258
+ return {"id": item["id"], "error": str(e)[:300]}
259
+ parsed = parse_chat_logprobs(resp)
260
+ if parsed["method"] == "none":
261
+ # No logprobs: keep a text-only trajectory. quality score re-tokenizes the text on the reference
262
+ # and still reports agreement and NLL; KL and TV need the engine's own top-k lists.
263
+ if not parsed["text"]:
264
+ return {"id": item["id"], "error": "response has neither logprobs.content nor text"}
265
+ parsed.update(method="text", token_ids=None, tokens=[], logprobs=[], top=[], top_k_returned=0,
266
+ id_mismatch_positions=[])
267
+ prompt_ids, prompt_src = resp.get("prompt_token_ids"), "response.prompt_token_ids"
268
+ if not prompt_ids and tokenize_state["ok"]:
269
+ tb = {"model": model, "messages": item["messages"], "add_generation_prompt": True,
270
+ "add_special_tokens": False}
271
+ if kwargs:
272
+ tb["chat_template_kwargs"] = kwargs
273
+ try:
274
+ prompt_ids, prompt_src = client.post("/tokenize", tb).get("tokens"), "engine_tokenize"
275
+ except OpError:
276
+ tokenize_state["ok"] = False
277
+ if not prompt_ids:
278
+ prompt_ids, prompt_src = None, None
279
+ doc = {"format": TRAJ_FORMAT, "id": item["id"], "bucket": item.get("bucket"), "label": label,
280
+ "engine_kind": engine_kind, "model": model, "base_url": client.base, "messages": item["messages"],
281
+ "chat_template_kwargs": kwargs or None, "request": sent,
282
+ "top_logprobs_requested": sent.get("top_logprobs"), "top_logprobs_returned": parsed["top_k_returned"],
283
+ "token_id_method": parsed["method"], "token_ids": parsed["token_ids"], "tokens": parsed["tokens"],
284
+ "logprobs": parsed["logprobs"],
285
+ "top_logprobs": [sorted(([k, v] for k, v in t.items()), key=lambda kv: -(kv[1] if kv[1] is not None else -1e30))
286
+ for t in parsed["top"]],
287
+ "id_mismatch_positions": parsed["id_mismatch_positions"],
288
+ "prompt_token_ids": prompt_ids, "prompt_token_ids_source": prompt_src,
289
+ "usage": resp.get("usage"), "finish_reason": parsed["finish_reason"], "text": parsed["text"]}
290
+ path = out_dir / f"{item['id']}.json"
291
+ path.write_text(json.dumps(doc, ensure_ascii=False) + "\n")
292
+ return {"id": item["id"], "file": path.name, "sha256": file_sha256(path)[0], "tokens": len(parsed["tokens"]),
293
+ "chars": len(parsed["text"] or ""), "k": parsed["top_k_returned"], "method": parsed["method"],
294
+ "mismatch": bool(parsed["id_mismatch_positions"]), "finish_reason": parsed["finish_reason"]}
295
+
296
+ results = _run_pool(one, items, concurrency, serial_first=True)
297
+ ok = [r for r in results if "error" not in r]
298
+ failed = [r for r in results if "error" in r]
299
+ index = {"format": INDEX_FORMAT, "label": label, "model": model, "engine_kind": engine_kind,
300
+ "prompts_sha256": prompts_sha, "files": {r["id"]: {"file": r["file"], "sha256": r["sha256"]} for r in ok},
301
+ "failed": {r["id"]: r["error"] for r in failed}}
302
+ index_path = out_dir / "index.json"
303
+ index_path.write_text(json.dumps(index, indent=1, sort_keys=True) + "\n")
304
+
305
+ methods = sorted({r["method"] for r in ok})
306
+ text_only = [r for r in ok if r["method"] == "text"]
307
+ k_min = min((r["k"] for r in ok if r["method"] != "text"), default=0)
308
+ limitations, missing = [], []
309
+ if failed:
310
+ missing.append("trajectories")
311
+ limitations.append(f"{len(failed)}/{len(items)} prompts failed (see data.failed)")
312
+ if text_only:
313
+ missing.append("logprobs") # a required feature: KL and TV cannot be computed for these prompts
314
+ why = f"it refused them: {state['refused']}" if state.get("refused") else "none in the response"
315
+ limitations.append(f"text only for {len(text_only)}/{len(ok)} prompts: the engine returned no logprobs "
316
+ f"({why}). quality score re-tokenizes their text on the reference and reports top-1 "
317
+ "agreement and NLL; KL and TV are not computed for them, and re-tokenized text can differ "
318
+ "from the tokens the engine actually produced")
319
+ if len(text_only) < len(ok) and k_min < top_logprobs:
320
+ limitations.append(f"engine returned only {k_min} alternatives per position (requested {top_logprobs}); "
321
+ f"quality score compares KL at top-{k_min} on both sides, which is less sensitive")
322
+ if "strings" in methods:
323
+ limitations.append("token ids not recoverable for some prompts (token strings only): quality score "
324
+ "re-tokenizes their text, and KL is not computed for them")
325
+ if any(r["mismatch"] for r in ok):
326
+ limitations.append("token ids from token_id strings and return_token_ids disagree at some positions")
327
+ truncated = sum(1 for r in ok if r["finish_reason"] == "length")
328
+ tokens = sum(r["tokens"] for r in ok)
329
+ metrics = [ev.metric("prompts_ok", len(ok), "prompts", "count", n=len(items)),
330
+ ev.metric("generated_tokens", tokens, "tokens", "sum", n=len(ok) - len(text_only)),
331
+ ev.metric("top_logprobs_returned", k_min, "tokens", "min", n=len(ok) - len(text_only))]
332
+ if text_only:
333
+ metrics.append(ev.metric("text_only_prompts", len(text_only), "prompts", "count", n=len(ok)))
334
+ ident = ev.identity(engine={"name": engine_kind}, model={"id": model},
335
+ workload={"gen_len": max_tokens, "batch": concurrency, "seed": seed})
336
+ if len(text_only) == len(ok) and ok:
337
+ what = f"{sum(r['chars'] for r in ok)} characters of text only (no logprobs)"
338
+ else:
339
+ what = (f"{tokens} tokens, top-{k_min} logprobs, token ids via {'/'.join(methods) or 'n/a'}"
340
+ + (f", {len(text_only)} text only" if text_only else ""))
341
+ return _save(call, run, "quality_trajectory", claim_class="observed",
342
+ summary=f"{label}: greedy trajectories for {len(ok)}/{len(items)} prompts, {what}",
343
+ identity_=ident, metrics=metrics, artifacts=[ev.artifact(index_path, run, "trajectory_index")],
344
+ missing=missing, limitations=limitations,
345
+ data={"label": label, "dir": f"quality/{label}", "prompts_sha256": prompts_sha,
346
+ "top_logprobs_requested": top_logprobs, "top_logprobs_returned_min": k_min,
347
+ "token_id_methods": methods, "truncated_at_max_tokens": truncated,
348
+ "text_only_prompts": len(text_only), "logprobs_refused": state.get("refused"),
349
+ "chat_template_kwargs": base_kwargs or None, "cap_errors": state.get("cap_errors", [])[:3],
350
+ "failed": index["failed"], "endpoint_kind": "openai-chat"})
351
+
352
+
353
+ # --------------------------------------------------------------------------- quality score
354
+
355
+ def _label_dirs(paths: list[str]) -> list[Path]:
356
+ out = []
357
+ for p in paths:
358
+ d = Path(p)
359
+ if (d / "index.json").is_file():
360
+ out.append(d)
361
+ elif d.is_dir():
362
+ out.extend(sorted(c for c in d.iterdir() if (c / "index.json").is_file()))
363
+ else:
364
+ raise OpError(f"trajectories: {p} is not a directory")
365
+ if not out:
366
+ raise OpError(f"no trajectory directories (with index.json) under {paths}")
367
+ return out
368
+
369
+
370
+ def load_trajectories(d: Path) -> tuple[str, dict[str, dict], list[str]]:
371
+ """(label, {prompt id: trajectory}, integrity problems). Files are checked against index.json."""
372
+ index = json.loads((d / "index.json").read_text())
373
+ problems, docs = [], {}
374
+ for pid, meta in sorted(index.get("files", {}).items()):
375
+ f = d / meta["file"]
376
+ if not f.is_file():
377
+ problems.append(f"{d.name}/{meta['file']} is missing")
378
+ continue
379
+ if file_sha256(f)[0] != meta["sha256"]:
380
+ problems.append(f"{d.name}/{meta['file']} does not match index.json (edited after capture?)")
381
+ continue
382
+ docs[pid] = json.loads(f.read_text())
383
+ return index.get("label") or d.name, docs, problems
384
+
385
+
386
+ def _is_kind(rec: dict, v2_kind: str) -> bool:
387
+ """A record of ``v2_kind``, written natively (v2) or as v1 ``correctness`` with data.v2_kind."""
388
+ return rec.get("kind") == v2_kind or (rec.get("data") or {}).get("v2_kind") == v2_kind
389
+
390
+
391
+ def _run_of(d: Path) -> Path | None:
392
+ """``runs/<id>`` for a ``runs/<id>/quality/<label>`` directory."""
393
+ return d.parent.parent if d.parent.name == "quality" else None
394
+
395
+
396
+ class _Records:
397
+ """Evidence records: a run's ``evidence/`` copies first, then the whole store (read once, on first need)."""
398
+
399
+ def __init__(self):
400
+ self._store: list[dict] | None = None
401
+
402
+ @staticmethod
403
+ def _local(run: Path | None) -> list[dict]:
404
+ out = []
405
+ if run is not None and (run / "evidence").is_dir():
406
+ for p in sorted((run / "evidence").glob("ev_*.json")):
407
+ try:
408
+ out.append(json.loads(p.read_text()))
409
+ except (OSError, ValueError):
410
+ continue
411
+ return out
412
+
413
+ def find(self, run: Path | None, want) -> tuple[dict | None, bool]:
414
+ """(an intact record for which ``want(record)`` is true or None, whether an edited match was seen)."""
415
+ edited = False
416
+ for source in ("run", "store"):
417
+ if source == "run":
418
+ recs = self._local(run)
419
+ else:
420
+ if self._store is None:
421
+ self._store = ev.Store().records()
422
+ recs = self._store
423
+ for rec in recs:
424
+ if want(rec):
425
+ if ev.integrity_problem(rec) is None:
426
+ return rec, False
427
+ edited = True
428
+ return None, edited
429
+
430
+
431
+ def _index_problem(d: Path, index_sha: str, records: _Records) -> str | None:
432
+ """None when an intact ``quality_trajectory`` evidence record lists this index.json by sha256.
433
+
434
+ The hashes inside index.json catch one trajectory file edited on its own; this catches
435
+ index.json rewritten to match edited files. An evidence id is a content hash, not a
436
+ signature: this makes an edit evident, it cannot stop someone who also forges a record.
437
+ """
438
+ def want(rec: dict) -> bool:
439
+ return _is_kind(rec, "quality_trajectory") and rec.get("claim_class") == "observed" and any(
440
+ a.get("role") == "trajectory_index" and a.get("sha256") == index_sha for a in rec.get("artifacts") or [])
441
+
442
+ rec, edited = records.find(_run_of(d), want)
443
+ if rec is not None:
444
+ return None
445
+ if edited:
446
+ return f"{d.name}: the quality_trajectory evidence record for its index.json was edited after capture"
447
+ return (f"{d.name}/index.json: no quality_trajectory evidence record lists it (rewritten after capture, "
448
+ "or produced without quality trajectory)")
449
+
450
+
451
+ def _load_set(d: Path, records: _Records) -> dict:
452
+ """One label dir: its trajectories, the prompt ids its index covers, and integrity problems."""
453
+ label, docs, problems = load_trajectories(d)
454
+ index_path = d / "index.json"
455
+ index = json.loads(index_path.read_text())
456
+ index_sha = file_sha256(index_path)[0]
457
+ problem = _index_problem(d, index_sha, records)
458
+ if problem:
459
+ problems.append(problem)
460
+ failed = dict(index.get("failed") or {})
461
+ return {"dir": d, "label": label, "docs": docs, "problems": problems, "failed": failed,
462
+ "ids": set(index.get("files") or {}) | set(failed), "prompts_sha256": index.get("prompts_sha256"),
463
+ "index_sha256": index_sha}
464
+
465
+
466
+ def _coverage(s: dict, results: list[dict], required: set) -> dict:
467
+ """Which of the required prompt ids (the baseline's) this trajectory set got scored on, and why not."""
468
+ ok = {r["id"] for r in results if "rows" in r}
469
+ errors = {r["id"]: r["error"] for r in results if "error" in r and r["id"] in required}
470
+ missing = sorted(required - ok)
471
+ failed = sorted(set(s["failed"]) & required)
472
+ absent = sorted(required - s["ids"])
473
+ rejected = sorted((required & s["ids"]) - set(s["failed"]) - set(s["docs"]))
474
+ return {"required": len(required), "scored": len(required & ok), "missing": len(missing),
475
+ "missing_ids": missing[:50], "failed_in_trajectory": failed[:50], "absent": absent[:50],
476
+ "rejected_by_integrity_check": rejected[:50], "scoring_errors": dict(sorted(errors.items())[:20]),
477
+ "extra_not_in_baseline": len(ok - required)}
478
+
479
+
480
+ def _coverage_text(label: str, cov: dict) -> str:
481
+ parts = []
482
+ if cov["failed_in_trajectory"]:
483
+ parts.append(f"{len(cov['failed_in_trajectory'])} failed during quality trajectory")
484
+ if cov["absent"]:
485
+ parts.append(f"{len(cov['absent'])} absent from its trajectories")
486
+ if cov["rejected_by_integrity_check"]:
487
+ parts.append(f"{len(cov['rejected_by_integrity_check'])} rejected by the integrity check")
488
+ if cov["scoring_errors"]:
489
+ first = next(iter(cov["scoring_errors"].values()))
490
+ parts.append(f"{len(cov['scoring_errors'])} scoring errors, e.g. {first[:120]}")
491
+ return f"{label}: {cov['missing']} of {cov['required']} prompts not scored ({'; '.join(parts) or 'see coverage'})"
492
+
493
+
494
+ def _doc_k(doc: dict) -> int:
495
+ """Alternatives per position the engine returned for this trajectory (its shortest list)."""
496
+ k = doc.get("top_logprobs_returned")
497
+ if isinstance(k, int):
498
+ return k
499
+ return min((len(t) for t in doc.get("top_logprobs") or []), default=0)
500
+
501
+
502
+ def _top(lp: dict, k: int, keep) -> dict:
503
+ """The ``k`` most likely entries of a {token id: logprob} list (ties broken by id), plus ``keep``."""
504
+ best = sorted((t for t, v in lp.items() if v is not None), key=lambda t: (-lp[t], t))[:k]
505
+ out = {t: lp[t] for t in best}
506
+ if lp.get(keep) is not None:
507
+ out[keep] = lp[keep]
508
+ return out
509
+
510
+
511
+ def _at_k(results: list[dict], k: int) -> list[dict]:
512
+ """Copies of scored results whose per-position ``kl`` / ``tv`` are those at top-``k`` (None where not computed)."""
513
+ return [{**r, "rows": [{**x, "kl": x["kl_at"].get(k), "tv": x["tv_at"].get(k)} for x in r["rows"]]}
514
+ for r in results]
515
+
516
+
517
+ class _PromptIds:
518
+ """Prompt token ids through the reference's chat template: /tokenize, else an HF tokenizer."""
519
+
520
+ def __init__(self, client: Client, model: str, tokenizer: str | None):
521
+ self.client, self.model, self.tokenizer_id = client, model, tokenizer
522
+ self.tokenize_ok = True
523
+ self._hf = None
524
+ self.hf_problem: str | None = None
525
+ self._cache: dict = {}
526
+ self.lock = threading.Lock()
527
+
528
+ def _hf_ids(self, messages, kwargs):
529
+ if self._hf is None:
530
+ try:
531
+ from transformers import AutoTokenizer # optional extra
532
+ except ImportError:
533
+ self._hf = False
534
+ self.hf_problem = "transformers not installed (pip install transformers)"
535
+ else:
536
+ try:
537
+ self._hf = AutoTokenizer.from_pretrained(self.tokenizer_id or self.model)
538
+ except Exception as e: # network, auth, unknown repo: fall through to the next source
539
+ self._hf, self.hf_problem = False, f"HF tokenizer failed to load: {str(e)[:200]}"
540
+ if self._hf is False:
541
+ return None
542
+ ids = self._hf.apply_chat_template(messages, add_generation_prompt=True, tokenize=True, **(kwargs or {}))
543
+ if not isinstance(ids, list): # a BatchEncoding in newer transformers
544
+ ids = ids["input_ids"]
545
+ return [int(t) for t in ids]
546
+
547
+ def get(self, doc: dict) -> tuple[list[int] | None, str | None]:
548
+ key = digest([doc["messages"], doc.get("chat_template_kwargs")])
549
+ if key in self._cache:
550
+ return self._cache[key]
551
+ ids, src = None, None
552
+ if self.tokenize_ok:
553
+ body = {"model": self.model, "messages": doc["messages"], "add_generation_prompt": True,
554
+ "add_special_tokens": False}
555
+ if doc.get("chat_template_kwargs"):
556
+ body["chat_template_kwargs"] = doc["chat_template_kwargs"]
557
+ try:
558
+ ids, src = self.client.post("/tokenize", body).get("tokens"), "reference_tokenize"
559
+ except HTTPError as e:
560
+ if e.status in (404, 405, 501):
561
+ self.tokenize_ok = False
562
+ else:
563
+ raise
564
+ if ids is None:
565
+ with self.lock:
566
+ ids = self._hf_ids(doc["messages"], doc.get("chat_template_kwargs"))
567
+ src = "hf_tokenizer" if ids is not None else None
568
+ if ids is None and doc.get("prompt_token_ids"):
569
+ ids, src = list(doc["prompt_token_ids"]), "engine_reported"
570
+ self._cache[key] = (ids, src)
571
+ return ids, src
572
+
573
+
574
+ def _retokenize(ref: Client, ref_model: str, doc: dict) -> list[int]:
575
+ """A trajectory's text as the reference tokenizer reads it (for engines without token ids)."""
576
+ return ref.post("/tokenize", {"model": ref_model, "prompt": doc.get("text") or "",
577
+ "add_special_tokens": False}).get("tokens") or []
578
+
579
+
580
+ def _as_text(doc: dict) -> dict:
581
+ """A trajectory with its token ids and lists removed: scored from re-tokenized text, as a text-only one is."""
582
+ return {**doc, "token_ids": None, "top_logprobs": [], "top_logprobs_returned": 0}
583
+
584
+
585
+ def _score_one(doc: dict, ref: Client, ref_model: str, pids: _PromptIds, state: dict, lock, cache: dict,
586
+ ks: set[int]) -> dict:
587
+ """Teacher-force one trajectory on the reference and return per-position results.
588
+
589
+ KL needs both sides to list the same number of alternatives: a top-20 reference list
590
+ against a top-5 engine list reads as divergence even when the two distributions are
591
+ identical (the reference names 15 tokens the engine never could). So at every position
592
+ both lists are cut to their k most likely tokens plus the trajectory token, with
593
+ k = min(alternatives the engine returned, the reference's k); ``kl`` is that value.
594
+ ``kl_at`` also holds the KL at every other k in ``ks`` (the k of each set being scored),
595
+ so the noise floor can be compared with each engine at the engine's k. ``tv`` and
596
+ ``tv_at`` are the total variation on the same cut lists.
597
+ """
598
+ out = {"id": doc["id"], "bucket": doc.get("bucket")}
599
+ try:
600
+ prompt_ids, src = pids.get(doc)
601
+ except OpError as e:
602
+ return {**out, "error": f"prompt tokenization failed: {e}"}
603
+ if not prompt_ids:
604
+ return {**out, "error": "no prompt token ids (reference has no /tokenize, no HF tokenizer installed, "
605
+ "and the trajectory has no engine-reported ids)"}
606
+ out["prompt_ids_source"] = src
607
+ engine_prompt = doc.get("prompt_token_ids")
608
+ if engine_prompt and list(engine_prompt) != list(prompt_ids):
609
+ out["template_mismatch"] = "engine prompt ids differ from the reference's"
610
+ elif not engine_prompt and (doc.get("usage") or {}).get("prompt_tokens") not in (None, len(prompt_ids)):
611
+ out["template_mismatch"] = (f"engine counted {doc['usage']['prompt_tokens']} prompt tokens, "
612
+ f"reference template gives {len(prompt_ids)}")
613
+ traj, kl_ok = doc.get("token_ids"), True
614
+ if traj is None: # token strings only: re-tokenize the text with the reference tokenizer
615
+ kl_ok = False
616
+ try:
617
+ traj = _retokenize(ref, ref_model, doc)
618
+ except OpError as e:
619
+ return {**out, "error": f"cannot re-tokenize the engine text: {e}"}
620
+ out["token_ids_source"] = "retokenized_text"
621
+ out["token_ids"] = list(traj)
622
+ if not traj:
623
+ return {**out, "error": "empty trajectory"}
624
+ full = list(prompt_ids) + list(traj)
625
+ key = digest(full)
626
+ try:
627
+ if key in cache:
628
+ plp, k_used = cache[key]
629
+ else:
630
+ body = {"model": ref_model, "prompt": full, "max_tokens": 1, "temperature": 0,
631
+ "add_special_tokens": False}
632
+ resp, sent = _post_capped(ref, "/v1/completions", body, "prompt_logprobs", state, lock)
633
+ plp, k_used = parse_prompt_logprobs(resp), sent["prompt_logprobs"]
634
+ cache[key] = (plp, k_used)
635
+ except OpError as e:
636
+ return {**out, "error": f"reference request failed: {e}"}
637
+ if len(plp) != len(full):
638
+ return {**out, "error": f"reference returned {len(plp)} prompt_logprobs for {len(full)} tokens"}
639
+ out["ref_k"] = k_used
640
+ out["k_kl"] = k_own = min(_doc_k(doc), k_used)
641
+ kset = sorted({min(k, k_used) for k in ks} | {k_own})
642
+ top = doc.get("top_logprobs") or []
643
+ rows = []
644
+ for t, tok in enumerate(traj):
645
+ pos = plp[len(prompt_ids) + t]
646
+ if not pos or tok not in pos:
647
+ return {**out, "error": f"reference prompt_logprobs lack the token at position {t}"}
648
+ ref_lp = {k: v[0] for k, v in pos.items()}
649
+ best = argmax_token(pos)
650
+ row = {"t": t, "agree": int(tok == best), "nll": -ref_lp[tok], "kl": None, "kl_at": {}, "tv": None,
651
+ "tv_at": {}, "gap": ref_lp[best] - ref_lp[tok] if tok != best else 0.0}
652
+ if kl_ok and t < len(top):
653
+ eng = {(int(k) if isinstance(k, int) else k): v for k, v in top[t] if v is not None}
654
+ if all(isinstance(k, int) for k in eng):
655
+ cut = {k: (_top(ref_lp, k, tok), _top(eng, k, tok)) for k in kset}
656
+ row["kl_at"] = {k: qm.kl_topk(a, b) for k, (a, b) in cut.items()}
657
+ row["tv_at"] = {k: qm.tv_topk(a, b) for k, (a, b) in cut.items()}
658
+ row["kl"], row["tv"] = row["kl_at"][k_own], row["tv_at"][k_own]
659
+ rows.append(row)
660
+ out["rows"] = rows
661
+ return out
662
+
663
+
664
+ def _units(prompts: list[dict], field: str, bucket: str | None) -> list[tuple[float, int]]:
665
+ """Per-prompt (sum, count) of ``field`` over positions in ``bucket`` (None = all positions)."""
666
+ units = []
667
+ for p in prompts:
668
+ vals = [r[field] for r in p.get("rows", []) if r[field] is not None
669
+ and (bucket is None or qm.bucket_of(r["t"]) == bucket)]
670
+ units.append((float(sum(vals)), len(vals)))
671
+ return units
672
+
673
+
674
+ def _summarize(prompts: list[dict], resamples: int, seed: int) -> dict:
675
+ """Pooled metrics with prompt-level bootstrap CIs, overall and per position bucket."""
676
+ def block(bucket):
677
+ out = {}
678
+ for field, name in (("agree", "top1_agreement"), ("kl", "kl_mean"), ("tv", "tv_mean"),
679
+ ("nll", "ref_nll_mean")):
680
+ u = _units(prompts, field, bucket)
681
+ n = sum(c for _, c in u)
682
+ out[name] = {"value": qm.pooled(u), "ci95": list(qm.cluster_ci(u, resamples, seed)), "n_tokens": n,
683
+ "n_prompts": sum(1 for _, c in u if c)}
684
+ nll = out["ref_nll_mean"]
685
+ out["ref_ppl"] = {"value": math.exp(nll["value"]) if nll["value"] is not None else None,
686
+ "ci95": [math.exp(x) if x is not None else None for x in nll["ci95"]]}
687
+ gaps = [r["gap"] for p in prompts for r in p.get("rows", [])
688
+ if not r["agree"] and (bucket is None or qm.bucket_of(r["t"]) == bucket)]
689
+ out["disagreement_gap_median"] = statistics.median(gaps) if gaps else None
690
+ return out
691
+
692
+ res = {"all": block(None)}
693
+ for b in qm.BUCKET_LABELS:
694
+ res[b] = block(b)
695
+ return res
696
+
697
+
698
+ def _divergence(a: dict[str, list], b: dict[str, list]) -> dict:
699
+ common = sorted(set(a) & set(b))
700
+ fds = {pid: first_divergence(a[pid], b[pid]) for pid in common}
701
+ diverged = [d for d in fds.values() if d is not None]
702
+ return {"n_prompts": len(common), "identical": len(common) - len(diverged),
703
+ "first_divergence_median": statistics.median(diverged) if diverged else None,
704
+ "first_divergence_min": min(diverged) if diverged else None, "per_prompt": fds}
705
+
706
+
707
+ @operation("quality_score", ("quality", "score"),
708
+ "Teacher-force engine trajectories on a higher-precision reference (vLLM prompt_logprobs): top-1 "
709
+ "agreement, KL, reference NLL, first divergence per position bucket, vs the A/A noise floor.",
710
+ [Arg("reference_url", "string", "Reference vLLM server (e.g. serving BF16).", required=True),
711
+ Arg("reference_model", "string", "Model name on the reference server.", required=True),
712
+ Arg("trajectories", "array", "Trajectory dirs (runs/<id>/quality/<label>) or their parent.",
713
+ required=True),
714
+ Arg("baseline", "string", "Trajectory dir of the reference's own greedy run (quality trajectory "
715
+ "against the reference): the path first divergence is measured from, and the A/A noise floor "
716
+ "when there is no --baseline-repeat."),
717
+ Arg("baseline_repeat", "string", "A second greedy run of the reference (e.g. after a relaunch): "
718
+ "scored like an engine, it is the A/A noise floor, including run-to-run first divergence."),
719
+ Arg("out", "string", "Output JSON (default: <run-dir>/quality/score.json)."),
720
+ Arg("prompt_logprobs", "integer", "Reference top-k per position (vLLM caps at --max-logprobs).",
721
+ default=20),
722
+ Arg("tokenizer", "string", "HF tokenizer id for the fallback when the reference has no /tokenize "
723
+ "(needs transformers). Default: reference_model."),
724
+ Arg("margin_agreement", "number", "Pre-registered margin: max drop in top-1 agreement vs the floor "
725
+ "(absolute fraction).", default=0.02),
726
+ Arg("margin_kl", "number", "Pre-registered margin: max increase in mean KL vs the floor (nats).",
727
+ default=0.02),
728
+ Arg("margin_tv", "number", "Pre-registered margin: max increase in mean total variation per token vs "
729
+ "the floor (probability mass, 0-1). Without it TV is reported but not judged: it is not a stand-in "
730
+ "for the KL margin (by Pinsker's inequality a KL of 0.02 nats allows a TV of up to 0.1)."),
731
+ Arg("margin_nll", "number", "Pre-registered margin: max increase in reference NLL per token vs the "
732
+ "floor (nats).", default=0.05),
733
+ Arg("resamples", "integer", "Bootstrap resamples.", default=2000),
734
+ Arg("seed", "integer", "Bootstrap seed.", default=0),
735
+ Arg("concurrency", "integer", "Reference requests in flight.", default=1),
736
+ Arg("api_key_env", "string", "Name of the env var holding the reference API key.",
737
+ default="OPENAI_API_KEY"),
738
+ Arg("timeout", "integer", "Seconds per request.", default=600)])
739
+ def quality_score(call, reference_url, reference_model, trajectories, baseline, baseline_repeat, out,
740
+ prompt_logprobs, tokenizer, margin_agreement, margin_kl, margin_tv, margin_nll, resamples, seed,
741
+ concurrency, api_key_env, timeout):
742
+ if baseline_repeat and not baseline:
743
+ raise OpError("--baseline-repeat needs --baseline")
744
+ base_dirs = [Path(p) for p in (baseline, baseline_repeat) if p]
745
+ for b in base_dirs:
746
+ if not (b / "index.json").is_file():
747
+ raise OpError(f"{b}: not a trajectory directory (no index.json)")
748
+ if baseline_repeat and Path(baseline_repeat).resolve() == Path(baseline).resolve():
749
+ raise OpError("--baseline-repeat must be a second greedy run of the reference, not --baseline again")
750
+ ref = Client(reference_url, api_key_env, timeout)
751
+ pids = _PromptIds(ref, reference_model, tokenizer)
752
+ state, lock, cache = {"k": prompt_logprobs}, threading.Lock(), {}
753
+ dirs = [d for d in _label_dirs(trajectories) if d.resolve() not in {b.resolve() for b in base_dirs}]
754
+ if not dirs:
755
+ raise OpError("no engine trajectories to score besides the baseline")
756
+ run = _run_of(dirs[0])
757
+ records = _Records()
758
+ sets = {d: _load_set(d, records) for d in dirs + base_dirs}
759
+ # every k some set returned: the floor is scored at each, so it can be paired with each engine at its k
760
+ # (text-only trajectories have no lists, k 0, and get no KL or TV)
761
+ ks = {min(_doc_k(doc), prompt_logprobs) for s in sets.values() for doc in s["docs"].values() if _doc_k(doc) > 0}
762
+ limitations, missing = [], []
763
+
764
+ def score_set(s: dict) -> list[dict]:
765
+ return _run_pool(lambda doc: _score_one(doc, ref, reference_model, pids, state, lock, cache, ks),
766
+ list(s["docs"].values()), concurrency, serial_first=True)
767
+
768
+ scored = {}
769
+ for d in dirs:
770
+ s = sets[d]
771
+ if s["label"] in scored:
772
+ raise OpError(f"two trajectory dirs have the label {s['label']}")
773
+ scored[s["label"]] = (s, score_set(s))
774
+ floor_label, floor_res, floor_set, path_ids = None, None, None, None
775
+ if baseline:
776
+ base_set = sets[base_dirs[0]]
777
+ # The reference's greedy path (first divergence is measured from it) ...
778
+ path_ids = {pid: d["token_ids"] for pid, d in base_set["docs"].items() if d.get("token_ids")}
779
+ # ... and the A/A floor: the reference's second greedy run scored like an engine when there is one
780
+ # (it also gives the run-to-run first-divergence floor), else its first run.
781
+ floor_set = sets[base_dirs[-1]]
782
+ floor_label, floor_res = floor_set["label"], score_set(floor_set)
783
+ # every engine must be scored on every prompt the reference was run on
784
+ required = set(base_set["ids"])
785
+ if not baseline_repeat:
786
+ limitations.append("noise floor = the baseline scored against its own teacher-forced prefill: it holds "
787
+ "decode-vs-prefill numerics only, no run-to-run variation, and no first-divergence "
788
+ "floor (pass --baseline-repeat, a second greedy run of the reference)")
789
+ else:
790
+ limitations.append("no --baseline (the reference's own greedy trajectories): no A/A noise floor and no "
791
+ "first-divergence measurement, so every verdict is UNPROVEN")
792
+ missing.append("noise floor")
793
+ required = set().union(*(s["ids"] for s, _ in scored.values()))
794
+ floor = floor_res is not None
795
+ # A trajectory without token ids is scored on the reference tokenizer's reading of its text. Where that
796
+ # reading differs from the tokens the model generated, agreement and NLL change, so such prompts are
797
+ # compared with the floor read the same way: the floor's text re-tokenized and scored like the engine's.
798
+ retok = {label: {r["id"] for r in res if r.get("token_ids_source") == "retokenized_text"}
799
+ for label, (_, res) in scored.items()}
800
+ floor_text_res, path_text = None, {}
801
+ if floor and any(retok.values()):
802
+ floor_text_res = score_set({"docs": {pid: _as_text(doc) for pid, doc in floor_set["docs"].items()}})
803
+ if not baseline_repeat: # the floor is the baseline: its re-tokenized ids are the path too
804
+ path_text = {r["id"]: r["token_ids"] for r in floor_text_res if r.get("token_ids")}
805
+ else: # first divergence of re-tokenized prompts is measured from the baseline's re-tokenized text
806
+ def text_ids(doc: dict) -> tuple:
807
+ try:
808
+ return doc["id"], _retokenize(ref, reference_model, doc)
809
+ except OpError:
810
+ return doc["id"], None
811
+
812
+ path_text = {pid: ids for pid, ids in _run_pool(text_ids, list(base_set["docs"].values()), concurrency)
813
+ if ids}
814
+ floor_text_by_id = {r["id"]: r for r in floor_text_res or []}
815
+ base_problems = [p for b in base_dirs for p in sets[b]["problems"]]
816
+ base_shas = {sets[b]["prompts_sha256"] for b in base_dirs}
817
+
818
+ report: dict = {"format": SCORE_FORMAT, "reference": {"url": ref.base, "model": reference_model},
819
+ "buckets": list(qm.BUCKET_LABELS), "noise_floor": floor_label,
820
+ "margins": {"agreement": margin_agreement, "kl": margin_kl, "tv": margin_tv, "nll": margin_nll},
821
+ "kl_method": "top-k union support, tail imputation, OTHER bucket (inferencekit.quality.metrics); "
822
+ "both lists cut to the engine's k per position; TV on the same support",
823
+ "required_prompts": len(required), "engines": {}}
824
+ floor_by_id = {r["id"]: r for r in floor_res} if floor else {}
825
+ floor_gaps, floor_mism = [], False
826
+ if floor:
827
+ report["floor"] = _engine_block(floor_label, floor_res, path_ids if baseline_repeat else None,
828
+ resamples, seed)
829
+ report["floor"]["source"] = "baseline_repeat" if baseline_repeat else "baseline"
830
+ fcov = report["floor"]["coverage"] = _coverage(floor_set, floor_res, required)
831
+ floor_mism = bool(report["floor"]["template_mismatch"])
832
+ if fcov["missing"]:
833
+ floor_gaps.append(f"the noise floor lacks {fcov['missing']} of {fcov['required']} prompts")
834
+ limitations.append(_coverage_text(f"{floor_label} (noise floor)", fcov))
835
+ verdicts, metrics, short_k = {}, [], {}
836
+ if floor_text_res is not None:
837
+ report["floor_retokenized"] = _engine_block(f"{floor_label} (re-tokenized)", floor_text_res, None,
838
+ resamples, seed)
839
+ for label, (s, results) in scored.items():
840
+ eng_path = path_ids
841
+ if path_ids is not None and retok[label]:
842
+ eng_path = {**{pid: ids for pid, ids in path_ids.items() if pid not in retok[label]},
843
+ **{pid: path_text[pid] for pid in retok[label] if pid in path_text}}
844
+ block = _engine_block(label, results, eng_path, resamples, seed)
845
+ cov = block["coverage"] = _coverage(s, results, required)
846
+ v = _verdict(results, floor_by_id, floor_text_by_id, floor, margin_agreement, margin_kl, margin_tv, margin_nll,
847
+ resamples, seed)
848
+ problems = []
849
+ if s["problems"] or base_problems:
850
+ problems.append("trajectory files, index.json or their evidence record failed the integrity check")
851
+ if cov["missing"]:
852
+ problems.append(f"{cov['missing']} of {cov['required']} prompts not scored")
853
+ limitations.append(_coverage_text(label, cov))
854
+ problems.extend(floor_gaps)
855
+ unpaired = [pid for pid in retok[label] if "rows" not in floor_text_by_id.get(pid, {})] if floor else []
856
+ if unpaired:
857
+ problems.append(f"{len(unpaired)} re-tokenized prompts have no noise floor re-tokenized the same way")
858
+ if block["template_mismatch"] or floor_mism:
859
+ problems.append("chat template mismatch between an engine and the reference")
860
+ if baseline and (s["prompts_sha256"] not in base_shas or len(base_shas) > 1):
861
+ problems.append("trajectories come from a different prompt file than the baseline (prompts_sha256)")
862
+ if problems: # data problems never yield a PASS
863
+ if v["verdict"] == "PASS":
864
+ v["verdict"] = "UNPROVEN"
865
+ v["reason"] += "; downgraded to UNPROVEN: " + "; ".join(problems)
866
+ else:
867
+ v["reason"] += "; data problems: " + "; ".join(problems)
868
+ block["data_problems"] = problems
869
+ block["verdict"] = v
870
+ verdicts[label] = v["verdict"]
871
+ report["engines"][label] = block
872
+ k_check = (v["checks"].get("kl_mean") or {}).get("top_k")
873
+ if k_check is not None and k_check < prompt_logprobs:
874
+ short_k[label] = k_check
875
+ metrics.append(ev.metric("prompts_scored", cov["scored"], "prompts", "count", n=cov["required"],
876
+ subject=label))
877
+ metrics.extend(_metrics_for(label, block))
878
+ if floor:
879
+ metrics.extend(_metrics_for(f"{floor_label} (noise floor)", report["floor"]))
880
+ if any(b["coverage"]["missing"] for b in report["engines"].values()) or floor_gaps:
881
+ missing.append("scored prompts")
882
+ integrity = list(dict.fromkeys(p for s in sets.values() for p in s["problems"]))
883
+ if integrity:
884
+ missing.append("input integrity")
885
+ limitations.extend(integrity[:10])
886
+ mism = sum(len(b["template_mismatch"]) for b in report["engines"].values())
887
+ if mism or floor_mism:
888
+ limitations.append(f"{mism} engine prompt(s){' and the noise floor' if floor_mism else ''} where the prompt "
889
+ "tokens differ from the reference chat template: the engine did not see the same context")
890
+ for label, k in short_k.items():
891
+ limitations.append(f"{label}: KL compared at top-{k} on both sides (the engine returned only {k} "
892
+ f"alternatives; requested {prompt_logprobs}): fewer alternatives make KL less sensitive")
893
+ for label, b in report["engines"].items():
894
+ if b["retokenized_prompts"]:
895
+ limitations.append(f"{label}: {b['retokenized_prompts']} of {b['n_scored']} prompts scored from "
896
+ "re-tokenized text (no engine token ids or logprobs): top-1 agreement and NLL only, "
897
+ "no KL or TV, compared with the noise floor's text re-tokenized the same way "
898
+ "(report floor_retokenized); the reference's tokenization of the text can differ from "
899
+ "the engine's")
900
+ # checks that could not be computed (a required input such as logprobs is missing), per engine: the verdict
901
+ # combines the checks that ran, so a reader applying "required feature missing -> no result" reads this
902
+ not_run = {label: [name for name, c in b["verdict"]["checks"].items()
903
+ if c["verdict"] == "NOT RUN" and "worse_than_floor" not in c]
904
+ for label, b in report["engines"].items()}
905
+ not_run = {label: names for label, names in not_run.items() if names}
906
+ if any("kl_mean" in names for names in not_run.values()):
907
+ missing.append("logprobs")
908
+ if state["k"] < prompt_logprobs:
909
+ limitations.append(f"reference returned top-{state['k']} prompt logprobs (requested {prompt_logprobs})")
910
+ srcs = sorted({r.get("prompt_ids_source") for _, res in scored.values() for r in res
911
+ if r.get("prompt_ids_source")})
912
+ if "engine_reported" in srcs:
913
+ limitations.append("prompt ids for some prompts came from the engine itself (no reference /tokenize and "
914
+ "no HF tokenizer): the chat template was not independently checked")
915
+ limitations.append("vLLM returns raw logprobs (before temperature/penalties) by default on both sides; "
916
+ "KL and TV on top-k lists are approximations (see data.kl_method)")
917
+ if pids.hf_problem and "hf_tokenizer" not in srcs:
918
+ limitations.append(f"HF tokenizer fallback unavailable: {pids.hf_problem}")
919
+ report["verdicts"] = verdicts
920
+ report["not_run"] = not_run
921
+ report["prompt_ids_sources"] = srcs
922
+ out_path = Path(out) if out else ((run / "quality" / "score.json") if run else Path("quality_score.json"))
923
+ out_path.parent.mkdir(parents=True, exist_ok=True)
924
+ out_path.write_text(json.dumps(report, indent=1, ensure_ascii=False) + "\n")
925
+ overall = stats.combine_claims(verdicts.values())
926
+ summary = f"{overall}: " + ", ".join(
927
+ f"{label} {verdicts[label]} (agreement {_pct(report['engines'][label]['all']['top1_agreement']['value'])}"
928
+ for label in verdicts) + (")" if verdicts else "") + (
929
+ f"; noise floor {_pct(report['floor']['all']['top1_agreement']['value'])}" if floor else "; no noise floor")
930
+ traj_ev = {d.name: {"prompts_sha256": s["prompts_sha256"], "index_sha256": s["index_sha256"]}
931
+ for d, s in sets.items()}
932
+ if len({v["prompts_sha256"] for v in traj_ev.values()}) > 1:
933
+ limitations.append("trajectory dirs come from different prompt files (prompts_sha256 differs): "
934
+ "engines are compared on their common prompt ids only")
935
+ return _save(call, run, "quality_trajectory", claim_class="derived", summary=summary,
936
+ identity_=ev.identity(engine={"name": "reference"}, model={"id": reference_model}),
937
+ metrics=metrics, artifacts=[ev.artifact(out_path, run, "quality_score")], missing=missing,
938
+ limitations=limitations,
939
+ data={"verdict": overall, "verdicts": verdicts, "noise_floor": floor_label,
940
+ "noise_floor_source": (report.get("floor") or {}).get("source"),
941
+ "margins": report["margins"], "kl_method": report["kl_method"],
942
+ "prompt_ids_sources": srcs, "reference_top_k": state["k"], "inputs": traj_ev,
943
+ "required_prompts": len(required), "not_run": not_run,
944
+ "text_only": {label: b["retokenized_prompts"] for label, b in report["engines"].items()
945
+ if b["retokenized_prompts"]},
946
+ "coverage": {label: {k: b["coverage"][k] for k in ("scored", "missing", "missing_ids")}
947
+ for label, b in report["engines"].items()},
948
+ "score_file": ev.artifact(out_path, run, "quality_score")["path"]})
949
+
950
+
951
+ def _pct(x) -> str:
952
+ return f"{100 * x:.2f}%" if x is not None else "n/a"
953
+
954
+
955
+ def _engine_block(label, results, path_ids, resamples, seed) -> dict:
956
+ ok = [r for r in results if "rows" in r]
957
+ block = {"label": label, "n_prompts": len(results), "n_scored": len(ok),
958
+ "errors": {r["id"]: r["error"] for r in results if "error" in r},
959
+ "template_mismatch": {r["id"]: r["template_mismatch"] for r in results if r.get("template_mismatch")},
960
+ "kl_top_k": sorted({r["k_kl"] for r in ok if any(x["kl"] is not None for x in r["rows"])}),
961
+ "retokenized_prompts": sum(1 for r in ok if r.get("token_ids_source") == "retokenized_text")}
962
+ block.update(_summarize(ok, resamples, seed))
963
+ by_input = {}
964
+ for b in sorted({r.get("bucket") or "none" for r in ok}):
965
+ sub = [r for r in ok if (r.get("bucket") or "none") == b]
966
+ by_input[b] = _summarize(sub, resamples, seed)["all"]
967
+ block["by_input_bucket"] = by_input
968
+ if path_ids is not None:
969
+ mine = {r["id"]: r["token_ids"] for r in ok}
970
+ block["first_divergence"] = _divergence(path_ids, mine)
971
+ block["per_prompt"] = {r["id"]: {"bucket": r.get("bucket"), "tokens": len(r["rows"]),
972
+ "agreement": sum(x["agree"] for x in r["rows"]) / len(r["rows"]),
973
+ "nll_mean": sum(x["nll"] for x in r["rows"]) / len(r["rows"])}
974
+ for r in ok}
975
+ return block
976
+
977
+
978
+ def _verdict(results, floor_by_id, floor_text_by_id, have_floor, m_agree, m_kl, m_tv, m_nll, resamples,
979
+ seed) -> dict:
980
+ if not have_floor:
981
+ return {"verdict": "UNPROVEN", "reason": "no A/A noise floor (pass --baseline)", "checks": {}}
982
+ # each prompt is paired with the floor scored through the same path: a re-tokenized engine prompt with the
983
+ # floor's re-tokenized text, one with token ids with the floor's own ids
984
+ pairs = [(r, (floor_text_by_id if r.get("token_ids_source") == "retokenized_text" else floor_by_id).get(r["id"]))
985
+ for r in results if "rows" in r]
986
+ pairs = [(e, f) for e, f in pairs if f and "rows" in f]
987
+ eng, flo = [e for e, _ in pairs], [f for _, f in pairs]
988
+ # KL depends on how many alternatives each side lists: both arms are compared at the smallest k either returned
989
+ with_kl = [min(e["k_kl"], f["k_kl"]) for e, f in zip(eng, flo)
990
+ if any(x["kl"] is not None for x in e["rows"]) and any(x["kl"] is not None for x in f["rows"])]
991
+ k_pair = min(with_kl) if with_kl else None
992
+ arms = {"agree": (eng, flo), "nll": (eng, flo)}
993
+ if k_pair is not None:
994
+ arms["kl"] = arms["tv"] = (_at_k(eng, k_pair), _at_k(flo, k_pair))
995
+ checks = {}
996
+ for field, name, margin, higher_better in (("agree", "top1_agreement", m_agree, True),
997
+ ("kl", "kl_mean", m_kl, False),
998
+ ("tv", "tv_mean", m_tv, False),
999
+ ("nll", "ref_nll_mean", m_nll, False)):
1000
+ e_arm, f_arm = arms.get(field, ([], []))
1001
+ e_u, f_u = _units(e_arm, field, None), _units(f_arm, field, None)
1002
+ if not any(c for _, c in e_u):
1003
+ checks[name] = {"verdict": "NOT RUN", "reason": "not computed for this engine"
1004
+ + (" (no token-id top-k lists on both sides)" if field in ("kl", "tv") else "")}
1005
+ continue
1006
+ d = qm.paired_delta_ci(e_u, f_u, resamples, seed)
1007
+ lo, hi = d["ci95"]
1008
+ if higher_better:
1009
+ worse = (-d["delta"] if d["delta"] is not None else None, -hi if hi is not None else None,
1010
+ -lo if lo is not None else None)
1011
+ else:
1012
+ worse = (d["delta"], lo, hi)
1013
+ if margin is None: # reported beside the judged checks, not judged (no pre-registered margin)
1014
+ v, why = "NOT RUN", "reported, not judged (no --margin-tv)"
1015
+ else:
1016
+ v, why = qm.margin_verdict(worse[1], worse[2], margin)
1017
+ checks[name] = {"verdict": v, "reason": why, "worse_than_floor": worse[0], "ci95": [worse[1], worse[2]],
1018
+ "margin": margin, "n_prompts": d["n_prompts"]}
1019
+ if field in ("kl", "tv"):
1020
+ checks[name]["top_k"] = k_pair
1021
+ verdict = stats.combine_claims(c["verdict"] for c in checks.values())
1022
+ if verdict == "NOT RUN":
1023
+ verdict = "UNPROVEN"
1024
+ return {"verdict": verdict, "reason": "; ".join(f"{k}: {c['reason']}" for k, c in checks.items()),
1025
+ "checks": checks}
1026
+
1027
+
1028
+ def _metrics_for(label: str, block: dict) -> list[dict]:
1029
+ out = []
1030
+ for bucket in ("all",) + qm.BUCKET_LABELS:
1031
+ b = block.get(bucket) or {}
1032
+ subj = label if bucket == "all" else f"{label} @pos {bucket}"
1033
+ agr, kl, nll = b.get("top1_agreement", {}), b.get("kl_mean", {}), b.get("ref_nll_mean", {})
1034
+ tv = b.get("tv_mean", {})
1035
+ if not agr.get("n_tokens"):
1036
+ continue
1037
+ out.append(ev.metric("top1_agreement_pct", 100 * agr["value"], "%", "pooled_mean", n=agr["n_prompts"],
1038
+ ci95=[100 * x if x is not None else None for x in agr["ci95"]], subject=subj))
1039
+ if kl.get("value") is not None:
1040
+ out.append(ev.metric("kl_ref_engine", kl["value"], "nats/token", "pooled_mean", n=kl["n_prompts"],
1041
+ ci95=kl["ci95"], subject=subj))
1042
+ if tv.get("value") is not None:
1043
+ out.append(ev.metric("tv_ref_engine", tv["value"], "probability/token", "pooled_mean",
1044
+ n=tv["n_prompts"], ci95=tv["ci95"], subject=subj))
1045
+ out.append(ev.metric("ref_nll", nll["value"], "nats/token", "pooled_mean", n=nll["n_prompts"],
1046
+ ci95=nll["ci95"], subject=subj))
1047
+ if bucket == "all":
1048
+ out.append(ev.metric("ref_ppl", b["ref_ppl"]["value"], "ppl", "exp_pooled_mean", n=nll["n_prompts"],
1049
+ ci95=b["ref_ppl"]["ci95"], subject=subj))
1050
+ fd = block.get("first_divergence")
1051
+ if fd and fd.get("n_prompts"):
1052
+ out.append(ev.metric("identical_to_reference_greedy_pct", 100 * fd["identical"] / fd["n_prompts"], "%",
1053
+ "rate", n=fd["n_prompts"], subject=label))
1054
+ if fd["first_divergence_median"] is not None:
1055
+ out.append(ev.metric("first_divergence_index", fd["first_divergence_median"], "tokens", "median",
1056
+ n=fd["n_prompts"] - fd["identical"], subject=label))
1057
+ return out
1058
+
1059
+
1060
+ # --------------------------------------------------------------------------- quality gsm8k
1061
+
1062
+ # Settings two gsm8k runs must share for McNemar's test to compare the same items the same way.
1063
+ _PAIR_KEYS = ("dataset_sha256", "prompt", "max_tokens", "temperature", "chat_template_kwargs", "backend")
1064
+
1065
+
1066
+ def _gsm8k_record_for(rec: dict, sha: str) -> bool:
1067
+ """``rec`` is a builtin ``quality gsm8k`` record whose results artifact has this sha256."""
1068
+ data = rec.get("data") or {}
1069
+ return _is_kind(rec, "quality_task") and data.get("task") == "gsm8k" and any(
1070
+ a.get("role") == "gsm8k_results" and a.get("sha256") == sha for a in rec.get("artifacts") or [])
1071
+
1072
+
1073
+ def _load_gsm8k_run(ref: str) -> dict:
1074
+ """An earlier ``quality gsm8k`` run: {rows, path, settings, evidence_id}.
1075
+
1076
+ ``ref`` is its results JSONL or its evidence id/path. Through an evidence id, the results
1077
+ file must still match the record's sha256. Through a JSONL path, the record is looked up
1078
+ by the file's sha256 (in that run's ``evidence/``, then the store); with none, ``settings``
1079
+ is None (the caller cannot check that both runs used the same dataset, prompt and budget).
1080
+ """
1081
+ p = Path(ref)
1082
+ if p.is_file() and p.suffix == ".jsonl":
1083
+ path = p
1084
+ sha = file_sha256(path)[0]
1085
+ run = path.parent.parent.parent if (path.parent.name == "gsm8k"
1086
+ and path.parent.parent.name == "quality") else None
1087
+ rec, _ = _Records().find(run, lambda r: _gsm8k_record_for(r, sha))
1088
+ else:
1089
+ rec = ev.Store().load(ref)
1090
+ problem = ev.integrity_problem(rec)
1091
+ if problem:
1092
+ raise OpError(f"paired_with: {problem}")
1093
+ rows_file = (rec.get("data") or {}).get("results_file")
1094
+ if (rec.get("data") or {}).get("task") != "gsm8k" or not rows_file:
1095
+ raise OpError(f"paired_with: {ref} is not a builtin quality gsm8k record")
1096
+ path = Path(rows_file).expanduser()
1097
+ if not path.is_file():
1098
+ raise OpError(f"paired_with: results file {rows_file} not found (run from the same directory)")
1099
+ if not _gsm8k_record_for(rec, file_sha256(path)[0]):
1100
+ raise OpError(f"paired_with: {path} changed since its evidence record {rec['evidence_id']} (edited?)")
1101
+ rows = {}
1102
+ for line in path.read_text().splitlines():
1103
+ if line.strip():
1104
+ r = json.loads(line)
1105
+ rows[int(r["idx"])] = r
1106
+ settings = {k: (rec.get("data") or {}).get(k) for k in _PAIR_KEYS} if rec else None
1107
+ return {"rows": rows, "path": str(path), "settings": settings,
1108
+ "evidence_id": rec.get("evidence_id") if rec else None}
1109
+
1110
+
1111
+ def _lm_eval(base: str, model: str, limit: int, concurrency: int, max_tokens: int, out_dir: Path,
1112
+ timeout: int) -> dict:
1113
+ """Run lm-evaluation-harness's gsm8k task against the chat endpoint; returns its accuracy numbers."""
1114
+ from inferencekit.proc import run as run_proc
1115
+
1116
+ exe = shutil.which("lm_eval") or shutil.which("lm-eval")
1117
+ if not exe:
1118
+ raise OpError("backend lm-eval: 'lm_eval' not found on PATH (pip install 'lm-eval[api]')")
1119
+ out_dir.mkdir(parents=True, exist_ok=True)
1120
+ argv = [exe, "--model", "local-chat-completions", "--model_args",
1121
+ f"model={model},base_url={base}/v1/chat/completions,num_concurrent={concurrency},max_retries=3,"
1122
+ "tokenized_requests=False", "--tasks", "gsm8k", "--apply_chat_template", "--num_fewshot", "0",
1123
+ "--limit", str(limit), "--gen_kwargs", f"temperature=0,max_gen_toks={max_tokens}",
1124
+ "--output_path", str(out_dir), "--log_samples"]
1125
+ res = run_proc(argv, timeout=timeout, stdout_path=out_dir / "lm_eval.stdout",
1126
+ stderr_path=out_dir / "lm_eval.stderr")
1127
+ if not res.ok:
1128
+ raise OpError(f"lm_eval exited {res.returncode}: {res.stderr[-400:]}")
1129
+ found = sorted(out_dir.rglob("results*.json"))
1130
+ if not found:
1131
+ raise OpError(f"lm_eval wrote no results*.json under {out_dir}")
1132
+ return parse_lm_eval_results(json.loads(found[-1].read_text())) | {"results_file": str(found[-1])}
1133
+
1134
+
1135
+ def parse_lm_eval_results(obj: dict) -> dict:
1136
+ task = (obj.get("results") or {}).get("gsm8k") or {}
1137
+ n = ((obj.get("n-samples") or {}).get("gsm8k") or {}).get("effective")
1138
+ return {"strict": task.get("exact_match,strict-match"), "flexible": task.get("exact_match,flexible-extract"),
1139
+ "n": n, "lm_eval_version": (obj.get("versions") or {}).get("gsm8k")}
1140
+
1141
+
1142
+ # Auto concurrency never sends more requests at once than the fixed default (32) did.
1143
+ AUTO_MAX_CONCURRENCY = 32
1144
+ _BURSTS = (8, 32) # timing-probe burst sizes; the next one runs only when the server ran the last one in parallel
1145
+ _SATURATED = 0.75 # a burst counts as run in parallel when parallelism >= this share of its size
1146
+
1147
+
1148
+ def _count(value) -> int | None:
1149
+ return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
1150
+
1151
+
1152
+ def _server_capacity(client: Client) -> tuple[int | None, str | None, dict]:
1153
+ """(requests the server runs at once, source, notes) from the server's own info endpoints.
1154
+
1155
+ SGLang: ``GET /get_server_info`` -> ``max_running_requests`` (top level, or in an
1156
+ ``internal_states`` entry). llama.cpp's llama-server: ``GET /props`` -> ``total_slots``.
1157
+ vLLM has neither (its ``max_num_seqs`` is not exposed), so it falls through to the probe.
1158
+ """
1159
+ notes: dict = {}
1160
+ try:
1161
+ info = client.get("/get_server_info")
1162
+ except OpError as e:
1163
+ notes["get_server_info"] = str(e)[:160]
1164
+ else:
1165
+ states = info.get("internal_states") if isinstance(info, dict) else None
1166
+ for entry in ([info] if isinstance(info, dict) else []) + [x for x in states or [] if isinstance(x, dict)]:
1167
+ n = _count(entry.get("max_running_requests"))
1168
+ if n:
1169
+ return n, "get_server_info", notes
1170
+ notes["get_server_info"] = "answered without a max_running_requests value"
1171
+ try:
1172
+ props = client.get("/props")
1173
+ except OpError as e:
1174
+ notes["props"] = str(e)[:160]
1175
+ else:
1176
+ n = _count(props.get("total_slots")) if isinstance(props, dict) else None
1177
+ if n:
1178
+ return n, "llamacpp_props", notes
1179
+ notes["props"] = "answered without total_slots"
1180
+ return None, None, notes
1181
+
1182
+
1183
+ def _timing_probe(client: Client, body: dict) -> tuple[int, dict]:
1184
+ """Requests the server serves at once, from timing: one request alone, then bursts of 8 (and 32).
1185
+
1186
+ With t1 the time of one request alone and tb the wall time of b identical requests sent
1187
+ together, b * t1 / tb is how many the server ran in parallel (b for a batching server,
1188
+ 1 for one that serves one at a time). A burst run fully in parallel only shows the server
1189
+ can do at least b, so the next, larger burst is tried. Failed requests give 1.
1190
+ """
1191
+ def timed(_=None) -> float:
1192
+ t = time.perf_counter()
1193
+ client.post("/v1/chat/completions", body)
1194
+ return time.perf_counter() - t
1195
+
1196
+ detail: dict = {"max_tokens": body["max_tokens"], "bursts": []}
1197
+ try:
1198
+ timed() # warm-up (lazy init, compilation, prefix cache): not timed
1199
+ t1 = min(timed(), timed())
1200
+ except OpError as e:
1201
+ return 1, {**detail, "error": f"probe request failed: {str(e)[:160]}"}
1202
+ detail["t1_s"] = round(t1, 4)
1203
+ found = 1
1204
+ for size in _BURSTS:
1205
+ t = time.perf_counter()
1206
+ try:
1207
+ with ThreadPoolExecutor(max_workers=size) as pool:
1208
+ list(pool.map(timed, range(size)))
1209
+ except OpError as e:
1210
+ detail["error"] = f"burst of {size} failed: {str(e)[:160]}"
1211
+ break
1212
+ wall = time.perf_counter() - t
1213
+ par = size * t1 / wall if wall > 0 else float(size)
1214
+ detail["bursts"].append({"size": size, "wall_s": round(wall, 4), "parallelism": round(par, 2)})
1215
+ if par < _SATURATED * size:
1216
+ found = round(par)
1217
+ break
1218
+ found = size
1219
+ return max(1, min(found, AUTO_MAX_CONCURRENCY)), detail
1220
+
1221
+
1222
+ def _auto_concurrency(base_url: str, api_key_env: str, timeout: int, body: dict) -> dict:
1223
+ """``{used, source, detail}``: the server's own capacity when it reports one, else the timing probe."""
1224
+ info_client = Client(base_url, api_key_env, min(timeout, 30))
1225
+ n, source, notes = _server_capacity(info_client)
1226
+ if n:
1227
+ return {"used": min(n, AUTO_MAX_CONCURRENCY), "source": source,
1228
+ "detail": {"server_reported": n, **({"capped_at": AUTO_MAX_CONCURRENCY}
1229
+ if n > AUTO_MAX_CONCURRENCY else {})}}
1230
+ used, detail = _timing_probe(Client(base_url, api_key_env, timeout), body)
1231
+ return {"used": used, "source": "timing_probe", "detail": {**detail, "server_info": notes}}
1232
+
1233
+
1234
+ def _run_budgeted(fn, items: list, concurrency: int, deadline: float) -> tuple[list, dict]:
1235
+ """``fn`` over ``items`` in order with up to ``concurrency`` in flight, stopping at a time budget.
1236
+
1237
+ A slot that frees up gets the next item only while ``now + mean latency so far`` is
1238
+ before ``deadline`` (``time.monotonic()``): an item sent now is projected to end one
1239
+ mean latency later. Once one item is held back, no later one is sent, so the results are
1240
+ always the first n items. Items already in flight finish, so the run can overrun the
1241
+ budget by about one request. The full run's projection uses Little's law: with c
1242
+ requests in flight and mean latency L, items finish at c / L per second.
1243
+ """
1244
+ workers = max(1, concurrency)
1245
+ results: dict = {}
1246
+ latencies: list[float] = []
1247
+ nxt, stopped, t0 = 0, False, time.monotonic()
1248
+
1249
+ def timed(item):
1250
+ t = time.monotonic()
1251
+ out = fn(item)
1252
+ return out, time.monotonic() - t
1253
+
1254
+ with ThreadPoolExecutor(max_workers=workers) as pool:
1255
+ running: dict = {}
1256
+ while True:
1257
+ while not stopped and nxt < len(items) and len(running) < workers:
1258
+ if latencies and time.monotonic() + statistics.fmean(latencies) > deadline:
1259
+ stopped = True
1260
+ break
1261
+ running[pool.submit(timed, items[nxt])] = nxt
1262
+ nxt += 1
1263
+ if not running:
1264
+ break
1265
+ done, _ = wait(running, return_when=FIRST_COMPLETED)
1266
+ for f in done:
1267
+ i = running.pop(f)
1268
+ results[i], dt = f.result()
1269
+ latencies.append(dt)
1270
+ mean = statistics.fmean(latencies) if latencies else None
1271
+ info = {"issued": nxt, "of": len(items), "stopped_early": stopped, "elapsed_s": round(time.monotonic() - t0, 1),
1272
+ "mean_latency_s": round(mean, 3) if mean is not None else None,
1273
+ "projected_full_s": round(len(items) * mean / workers, 1) if mean is not None else None}
1274
+ return [results[i] for i in range(nxt)], info
1275
+
1276
+
1277
+ @operation("quality_gsm8k", ("quality", "gsm8k"),
1278
+ "GSM8K test split (MIT; downloaded once from Hugging Face at a pinned revision, sha256-checked, "
1279
+ "cached in ~/.cache/inferencekit), zero-shot chain of thought at temperature 0 over the chat API: "
1280
+ "accuracy with a Wilson CI; --paired-with adds McNemar's exact test against another run.",
1281
+ [Arg("base_url", "string", "Engine server, e.g. http://127.0.0.1:8000.", required=True),
1282
+ Arg("model", "string", "Model name the server expects.", required=True),
1283
+ Arg("run_dir", "string", "Run directory (runs/<run-id>).", required=True),
1284
+ Arg("label", "string", "Name of this engine/configuration (file name).", default="engine"),
1285
+ Arg("limit", "integer", "First N problems of the test split (1319 = all).", default=1319),
1286
+ Arg("concurrency", "integer", "Requests in flight; 0 = auto: the server's own capacity (SGLang "
1287
+ "/get_server_info max_running_requests, llama.cpp /props total_slots), else a timing probe "
1288
+ f"(one request alone, then bursts of 8 and 32); at most {AUTO_MAX_CONCURRENCY}. The value used "
1289
+ "and its source are recorded.", default=32),
1290
+ Arg("time_budget_s", "integer", "Wall-clock budget in seconds, the auto-concurrency probe included: "
1291
+ "once the problems done so far project that the next one would end past it, no new problems are "
1292
+ "sent and the record covers the first n (a 'subset' limitation). Default: no budget."),
1293
+ Arg("max_tokens", "integer", "Fixed generation budget per problem.", default=1024),
1294
+ Arg("chat_template_kwargs", "string", "JSON object passed as chat_template_kwargs."),
1295
+ Arg("paired_with", "string", "Results JSONL or evidence id of another gsm8k run on the same items."),
1296
+ Arg("backend", "string", "builtin, or lm-eval (lm-evaluation-harness local-chat-completions; must "
1297
+ "be installed).", default="builtin", choices=("builtin", "lm-eval")),
1298
+ Arg("data", "string", "A different GSM8K-format JSONL (question, answer with '#### n'), or a Parquet "
1299
+ "file with those columns, instead of the pinned test split."),
1300
+ Arg("seed", "integer", "Request seed.", default=0),
1301
+ Arg("api_key_env", "string", "Name of the env var holding the API key.", default="OPENAI_API_KEY"),
1302
+ Arg("timeout", "integer", "Seconds per request (lm-eval: for the whole run).", default=600)])
1303
+ def quality_gsm8k(call, base_url, model, run_dir, label, limit, concurrency, time_budget_s, max_tokens,
1304
+ chat_template_kwargs, paired_with, backend, data, seed, api_key_env, timeout):
1305
+ t_start = time.monotonic()
1306
+ label = _safe(label, "label")
1307
+ if concurrency < 0:
1308
+ raise OpError("concurrency must be >= 1, or 0 for auto")
1309
+ if time_budget_s is not None and time_budget_s <= 0:
1310
+ raise OpError("time_budget_s must be > 0 seconds")
1311
+ if time_budget_s is not None and backend == "lm-eval":
1312
+ raise OpError("--time-budget-s needs the builtin backend (lm_eval runs its own request loop)")
1313
+ kwargs = apply_pin(_json_arg("chat_template_kwargs", chat_template_kwargs), run_dir, "quality gsm8k") or None
1314
+ if kwargs and backend == "lm-eval":
1315
+ # lm_eval's local-chat-completions has no way to send chat_template_kwargs: the record would claim a
1316
+ # template setting the engine never received.
1317
+ raise OpError("backend lm-eval cannot send chat_template_kwargs (given, or pinned by the intake); use the "
1318
+ "builtin backend, which sends them on every request")
1319
+ run = Path(run_dir)
1320
+ out_dir = run / "quality" / "gsm8k"
1321
+ out_dir.mkdir(parents=True, exist_ok=True)
1322
+ client = Client(base_url, api_key_env, timeout)
1323
+ items, data_sha = g8.load(data, limit)
1324
+ common = {"label": label, "dataset": g8.DATASET if not data else "custom", "dataset_sha256": data_sha,
1325
+ "backend": backend, "prompt": g8.INSTRUCTION, "max_tokens": max_tokens, "temperature": 0,
1326
+ "chat_template_kwargs": kwargs}
1327
+ other = None
1328
+ if paired_with: # checked before any request, so a mismatch costs nothing
1329
+ if backend == "lm-eval":
1330
+ raise OpError("--paired-with needs the builtin backend (lm-eval uses its own prompt and extraction)")
1331
+ other = _load_gsm8k_run(paired_with)
1332
+ if other["settings"] is not None:
1333
+ diff = [k for k in _PAIR_KEYS if other["settings"].get(k) != common.get(k)]
1334
+ if diff:
1335
+ raise OpError("paired_with: not the same evaluation, so McNemar's test would compare different "
1336
+ "items; it differs in " + ", ".join(
1337
+ f"{k} ({str(other['settings'].get(k))[:60]!r} there, {str(common.get(k))[:60]!r} "
1338
+ "here)" for k in diff))
1339
+ if concurrency == 0 and items:
1340
+ probe = {"model": model, "messages": g8.messages(items[0]["question"]), "max_tokens": 32, "temperature": 0,
1341
+ "seed": seed, "stream": False}
1342
+ if kwargs:
1343
+ probe["chat_template_kwargs"] = kwargs
1344
+ conc = {"requested": 0, **_auto_concurrency(base_url, api_key_env, timeout, probe)}
1345
+ else:
1346
+ conc = {"requested": concurrency, "used": max(1, concurrency), "source": "flag", "detail": {}}
1347
+ concurrency = conc["used"]
1348
+ # concurrency_source is kept by evidence.identity once the workload schema lists it (data.concurrency has it now)
1349
+ ident = ev.identity(engine={"name": label}, model={"id": model},
1350
+ workload={"gen_len": max_tokens, "batch": concurrency, "concurrency": concurrency,
1351
+ "concurrency_source": conc["source"], "seed": seed})
1352
+ if backend == "lm-eval":
1353
+ r = _lm_eval(client.base, model, len(items), concurrency, max_tokens, out_dir / f"{label}.lm_eval", timeout)
1354
+ acc, n = r["flexible"], r["n"] or len(items)
1355
+ ci = qm.wilson_ci(round(acc * n), n) if acc is not None else (None, None)
1356
+ return _save(call, run, "quality_task", claim_class="observed",
1357
+ summary=f"{label}: GSM8K (lm-eval) flexible-extract {_pct(acc)}, strict {_pct(r['strict'])}, "
1358
+ f"n={n}",
1359
+ identity_=ident,
1360
+ metrics=[ev.metric("gsm8k_accuracy_pct", 100 * acc if acc is not None else None, "%", "rate",
1361
+ n=n, ci95=[100 * x if x is not None else None for x in ci], subject=label),
1362
+ ev.metric("gsm8k_strict_accuracy_pct",
1363
+ 100 * r["strict"] if r["strict"] is not None else None, "%", "rate", n=n,
1364
+ subject=label)],
1365
+ artifacts=[ev.artifact(r["results_file"], run, "lm_eval_results")],
1366
+ limitations=["lm-eval backend: its own prompt and extraction (flexible-extract / strict-match), "
1367
+ "not comparable with the builtin backend; McNemar pairing not supported"],
1368
+ data={**common, "task": "gsm8k", "lm_eval": r, "extraction": "lm-eval filters",
1369
+ "concurrency": conc})
1370
+
1371
+ def one(item: dict) -> dict:
1372
+ body = {"model": model, "messages": g8.messages(item["question"]), "max_tokens": max_tokens,
1373
+ "temperature": 0, "seed": seed, "stream": False}
1374
+ if kwargs:
1375
+ body["chat_template_kwargs"] = kwargs
1376
+ row = {"idx": item["idx"], "gold": item["gold"]}
1377
+ try:
1378
+ resp = client.post("/v1/chat/completions", body)
1379
+ except OpError as e:
1380
+ return {**row, "error": str(e)[:300], "extracted": None, "rule": "none", "correct": False}
1381
+ choice = (resp.get("choices") or [{}])[0]
1382
+ text = (choice.get("message") or {}).get("content") or ""
1383
+ got, rule = g8.extract(text)
1384
+ return {**row, "extracted": got, "rule": rule, "correct": g8.is_correct(got, item["gold"]),
1385
+ "finish_reason": choice.get("finish_reason"),
1386
+ "completion_tokens": (resp.get("usage") or {}).get("completion_tokens"), "response": text}
1387
+
1388
+ budget = None
1389
+ if time_budget_s is not None:
1390
+ rows, budget = _run_budgeted(one, items, concurrency, t_start + time_budget_s)
1391
+ budget["budget_s"] = time_budget_s
1392
+ rows = sorted(rows, key=lambda r: r["idx"])
1393
+ else:
1394
+ rows = sorted(_run_pool(one, items, concurrency), key=lambda r: r["idx"])
1395
+ results_path = out_dir / f"{label}.jsonl"
1396
+ results_path.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows))
1397
+ n = len(rows)
1398
+ correct = sum(1 for r in rows if r["correct"])
1399
+ errors = sum(1 for r in rows if "error" in r)
1400
+ truncated = sum(1 for r in rows if r.get("finish_reason") == "length")
1401
+ rules = {k: sum(1 for r in rows if r["rule"] == k) for k in ("hash", "boxed", "last", "none")}
1402
+ acc = correct / n if n else None
1403
+ ci = qm.wilson_ci(correct, n)
1404
+ metrics = [ev.metric("gsm8k_accuracy_pct", 100 * acc if acc is not None else None, "%", "rate", n=n,
1405
+ ci95=[100 * x if x is not None else None for x in ci], subject=label),
1406
+ ev.metric("truncated_at_max_tokens", truncated, "items", "count", n=n, subject=label)]
1407
+ limitations, missing = [], []
1408
+ if errors:
1409
+ missing.append("responses")
1410
+ limitations.append(f"{errors} request(s) failed and count as wrong")
1411
+ if truncated:
1412
+ limitations.append(f"{truncated} response(s) hit max_tokens={max_tokens} (answer may be cut off)")
1413
+ stopped = bool(budget and budget["stopped_early"])
1414
+ if stopped:
1415
+ limitations.append(f"subset: first {n} of {len(items)} problems{'' if data else ' of the test split'}: "
1416
+ f"--time-budget-s {time_budget_s} stopped new requests (all {len(items)} were projected "
1417
+ f"to take about {budget['projected_full_s']:.0f} s at concurrency {concurrency})")
1418
+ elif n < 1319 and not data:
1419
+ limitations.append(f"subset: first {n} of 1319 test problems")
1420
+ if conc["source"] == "timing_probe" and conc["detail"].get("error"):
1421
+ limitations.append(f"auto concurrency: {conc['detail']['error']}; ran with {concurrency}")
1422
+ paired = None
1423
+ if other is not None:
1424
+ other_path, checked = other["path"], other["settings"] is not None
1425
+ other = other["rows"]
1426
+ common_idx = [r["idx"] for r in rows if r["idx"] in other]
1427
+ mine = {r["idx"]: r for r in rows}
1428
+ b = sum(1 for i in common_idx if mine[i]["correct"] and not other[i]["correct"])
1429
+ c = sum(1 for i in common_idx if not mine[i]["correct"] and other[i]["correct"])
1430
+ both = sum(1 for i in common_idx if mine[i]["correct"] and other[i]["correct"])
1431
+ m = len(common_idx)
1432
+ p = qm.mcnemar_exact(b, c)
1433
+ delta = (b - c) / m if m else None
1434
+ paired = {"other": other_path, "n_paired": m, "only_this_correct": b, "only_other_correct": c,
1435
+ "both_correct": both, "accuracy_delta": delta, "mcnemar_exact_p": p, "settings_checked": checked}
1436
+ if not checked:
1437
+ limitations.append(f"paired run {other_path}: its settings (dataset, prompt, max_tokens, chat template) "
1438
+ "could not be checked: no gsm8k evidence record matches that file")
1439
+ metrics.append(ev.metric("gsm8k_accuracy_delta_pct", 100 * delta if delta is not None else None, "%",
1440
+ "paired_difference", n=m, subject=label))
1441
+ metrics.append(ev.metric("mcnemar_exact_p", p, "p", "two_sided", n=b + c, subject=label))
1442
+ if m < n:
1443
+ limitations.append(f"paired on {m} of {n} items (the other run has fewer)")
1444
+ summary = (f"{label}: GSM8K {correct}/{n} = {_pct(acc)} (95% Wilson CI {_pct(ci[0])}-{_pct(ci[1])})"
1445
+ + (f"; vs other run {paired['accuracy_delta'] * 100:+.2f} pts, McNemar p={paired['mcnemar_exact_p']:.3g}"
1446
+ if paired and paired["accuracy_delta"] is not None else "")
1447
+ + (f"; time budget: first {n} of {len(items)}" if stopped else "")
1448
+ + (f"; concurrency {concurrency} ({conc['source']})" if conc["source"] != "flag" else ""))
1449
+ return _save(call, run, "quality_task", claim_class="observed", summary=summary, identity_=ident,
1450
+ metrics=metrics, artifacts=[ev.artifact(results_path, run, "gsm8k_results")], missing=missing,
1451
+ limitations=limitations,
1452
+ data={**common, "task": "gsm8k", "n": n, "correct": correct, "errors": errors,
1453
+ "extraction": "#### number, else last \\boxed{}, else last number; numeric equality",
1454
+ "extraction_rules_used": rules, "paired": paired, "results_file": str(results_path),
1455
+ "concurrency": conc, "time_budget": budget})
1456
+
1457
+
1458
+ # --------------------------------------------------------------------------- quality niah
1459
+
1460
+ def _chars_per_token(client: Client, model: str) -> tuple[float, str]:
1461
+ import random as _random
1462
+
1463
+ from inferencekit.quality import niah
1464
+
1465
+ rng = _random.Random(0)
1466
+ sample = " ".join(niah.filler_sentence(rng) for _ in range(60))
1467
+ try:
1468
+ n = len(client.post("/tokenize", {"model": model, "prompt": sample, "add_special_tokens": False})
1469
+ .get("tokens") or [])
1470
+ except OpError:
1471
+ return 4.0, "assumed 4.0 chars/token (no /tokenize)"
1472
+ return (len(sample) / n, "measured with /tokenize") if n else (4.0, "assumed 4.0 chars/token")
1473
+
1474
+
1475
+ @operation("quality_niah", ("quality", "niah"),
1476
+ "Needle in a haystack: retrieve a 7-digit code from synthetic filler at several context lengths and "
1477
+ "depths; accuracy per (length, depth) cell with Wilson CIs.",
1478
+ [Arg("base_url", "string", "Engine server, e.g. http://127.0.0.1:8000.", required=True),
1479
+ Arg("model", "string", "Model name the server expects.", required=True),
1480
+ Arg("run_dir", "string", "Run directory (runs/<run-id>).", required=True),
1481
+ Arg("label", "string", "Name of this engine/configuration (file name).", default="engine"),
1482
+ Arg("lengths", "array", "Target context lengths in tokens.", items="integer", default=[2048, 4096, 8192]),
1483
+ Arg("depths", "array", "Needle depths (0 = start, 1 = end).", items="number", default=[0.1, 0.5, 0.9]),
1484
+ Arg("trials", "integer", "Haystacks (different needles) per cell.", default=5),
1485
+ Arg("max_tokens", "integer", "Reply budget.", default=32),
1486
+ Arg("concurrency", "integer", "Requests in flight.", default=8),
1487
+ Arg("chat_template_kwargs", "string", "JSON object passed as chat_template_kwargs."),
1488
+ Arg("seed", "integer", "Seed for haystacks and requests.", default=0),
1489
+ Arg("api_key_env", "string", "Name of the env var holding the API key.", default="OPENAI_API_KEY"),
1490
+ Arg("timeout", "integer", "Seconds per request.", default=600)])
1491
+ def quality_niah(call, base_url, model, run_dir, label, lengths, depths, trials, max_tokens, concurrency,
1492
+ chat_template_kwargs, seed, api_key_env, timeout):
1493
+ from inferencekit.quality import niah
1494
+
1495
+ label = _safe(label, "label")
1496
+ kwargs = apply_pin(_json_arg("chat_template_kwargs", chat_template_kwargs), run_dir, "quality niah") or None
1497
+ run = Path(run_dir)
1498
+ out_dir = run / "quality" / "niah"
1499
+ out_dir.mkdir(parents=True, exist_ok=True)
1500
+ client = Client(base_url, api_key_env, timeout)
1501
+ cpt, cpt_method = _chars_per_token(client, model)
1502
+ items = [{**niah.build(n, d, seed * 1000 + t, cpt), "trial": t}
1503
+ for n in lengths for d in depths for t in range(trials)]
1504
+
1505
+ def one(item: dict) -> dict:
1506
+ body = {"model": model, "messages": item["messages"], "max_tokens": max_tokens, "temperature": 0,
1507
+ "seed": seed, "stream": False}
1508
+ if kwargs:
1509
+ body["chat_template_kwargs"] = kwargs
1510
+ row = {k: item[k] for k in ("target_tokens", "depth", "trial", "code", "needle_sentence_index")}
1511
+ row["messages_digest"] = digest(item["messages"])
1512
+ try:
1513
+ resp = client.post("/v1/chat/completions", body)
1514
+ except OpError as e:
1515
+ return {**row, "error": str(e)[:300], "correct": False}
1516
+ text = (((resp.get("choices") or [{}])[0]).get("message") or {}).get("content") or ""
1517
+ return {**row, "reply": text[:500], "correct": niah.found(text, item["code"]),
1518
+ "prompt_tokens": (resp.get("usage") or {}).get("prompt_tokens")}
1519
+
1520
+ rows = _run_pool(one, items, concurrency)
1521
+ path = out_dir / f"{label}.jsonl"
1522
+ path.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows))
1523
+ cells, metrics = [], []
1524
+ for n in lengths:
1525
+ for d in depths:
1526
+ cell = [r for r in rows if r["target_tokens"] == n and r["depth"] == d]
1527
+ k = sum(1 for r in cell if r["correct"])
1528
+ lo, hi = qm.wilson_ci(k, len(cell))
1529
+ actual = [r["prompt_tokens"] for r in cell if r.get("prompt_tokens")]
1530
+ cells.append({"target_tokens": n, "depth": d, "n": len(cell), "correct": k,
1531
+ "prompt_tokens_median": statistics.median(actual) if actual else None})
1532
+ metrics.append(ev.metric("niah_accuracy_pct", 100 * k / len(cell) if cell else None, "%", "rate",
1533
+ n=len(cell), ci95=[100 * x if x is not None else None for x in (lo, hi)],
1534
+ subject=f"{label} @{n} tok, depth {d:g}"))
1535
+ total = sum(1 for r in rows if r["correct"])
1536
+ lo, hi = qm.wilson_ci(total, len(rows))
1537
+ metrics.insert(0, ev.metric("niah_accuracy_pct", 100 * total / len(rows) if rows else None, "%", "rate",
1538
+ n=len(rows), ci95=[100 * x if x is not None else None for x in (lo, hi)],
1539
+ subject=label))
1540
+ errors = sum(1 for r in rows if "error" in r)
1541
+ limitations = ["synthetic filler: tests retrieval of one planted fact, not long-context reasoning",
1542
+ f"context lengths are approximate ({cpt_method}); see prompt_tokens_median per cell"]
1543
+ if errors:
1544
+ limitations.append(f"{errors} request(s) failed and count as wrong")
1545
+ ident = ev.identity(engine={"name": label}, model={"id": model},
1546
+ workload={"gen_len": max_tokens, "batch": concurrency, "seed": seed})
1547
+ return _save(call, run, "quality_task", claim_class="observed",
1548
+ summary=f"{label}: needle retrieval {total}/{len(rows)} = {_pct(total / len(rows) if rows else None)} "
1549
+ f"over lengths {lengths} x depths {depths}",
1550
+ identity_=ident, metrics=metrics, artifacts=[ev.artifact(path, run, "niah_results")],
1551
+ missing=["responses"] if errors else [], limitations=limitations,
1552
+ data={"task": "niah", "label": label, "cells": cells, "chars_per_token": cpt,
1553
+ "chars_per_token_method": cpt_method, "errors": errors, "results_file": str(path),
1554
+ "chat_template_kwargs": kwargs})
1555
+
1556
+
1557
+ # --------------------------------------------------------------------------- quality sampling
1558
+
1559
+ SAMPLING_FORMAT = "inferencekit.quality.sampling.v2"
1560
+ # Request fields the check sets itself; the same keys in --extra-body are dropped (and listed).
1561
+ _SAMPLING_OWN_KEYS = ("model", "messages", "max_tokens", "max_completion_tokens", "seed", "stream", "n", "logprobs",
1562
+ "top_logprobs", "return_token_ids", "return_tokens_as_token_ids")
1563
+ # Fields that change the first-token distribution in ways the check does not model: a value other than the
1564
+ # neutral one makes the verdict UNPROVEN, never PASS or FAIL. presence_penalty and frequency_penalty act on
1565
+ # generated tokens only: none yet.
1566
+ _UNMODELLED = {"repetition_penalty": (1, 1.0), "typical_p": (1, 1.0), "min_tokens": (0,), "logit_bias": ({},),
1567
+ "allowed_token_ids": ([],), "bad_words": ([],), "logits_processors": ([],), "guided_json": (),
1568
+ "guided_regex": (), "guided_choice": ([],), "guided_grammar": (), "structured_outputs": ({},),
1569
+ "response_format": ({"type": "text"},)}
1570
+ _TOP_LOGPROBS = 20
1571
+ _MIN_PROMPTS = 8 # informative prompts needed: a percentile bootstrap over fewer prompts covers poorly
1572
+ _NEAR_DETERMINISTIC = 0.98 # implied top-token probability from which a prompt adds nothing to TV (support only)
1573
+ _OOS_MAX = 0.005 # share of draws outside the kept tokens above which (Wilson 95% lower bound) the sampler FAILs
1574
+ _SEED_CHECK_PROMPTS = 8 # prompts re-asked with seeds already used, to see if a seed reproduces its draw
1575
+ _SEED_CHECK_SEEDS = 8
1576
+ _SEED_MATCH_MIN = 0.9 # share of repeats that must reproduce their first draw
1577
+ _SEED_INCONCLUSIVE = 0.8 # when draws would repeat this often by chance alone, the repeat check says nothing
1578
+ _DROPPED = {"incomplete": "a request failed (after a retry)",
1579
+ "logprobs_unstable": "the top-20 token ids changed between two identical requests",
1580
+ "beyond_top": "the kept tokens may reach past the listed top 20",
1581
+ "boundary": "a top-k, top-p or min-p cut within the rounding tolerance",
1582
+ "min_p_order": "min-p before and after top-p keep different tokens",
1583
+ "top_p_order": "top-p over the top-k tokens (Hugging Face, vLLM) and over the whole distribution "
1584
+ "(SGLang) keep different tokens",
1585
+ "bad_logprobs": "no usable log-probabilities",
1586
+ "near_deterministic": f"top token at {_NEAR_DETERMINISTIC:.0%} or more (support check only)"}
1587
+
1588
+
1589
+ def _first_token(resp: dict) -> tuple[int | None, str] | None:
1590
+ """The first generated token at max_tokens 1: (its id, or None, and its decoded text).
1591
+
1592
+ The id is vLLM's ``choices[0].token_ids`` (asked for with ``return_token_ids``); the text is
1593
+ the reply, or the reasoning text when the content is empty. None when the response has no
1594
+ ``choices[0].message`` (a malformed answer, counted as an error).
1595
+ """
1596
+ choice = (resp.get("choices") or [None])[0] if isinstance(resp, dict) else None
1597
+ msg = choice.get("message") if isinstance(choice, dict) else None
1598
+ if not isinstance(msg, dict):
1599
+ return None
1600
+ ids = choice.get("token_ids")
1601
+ tid = ids[0] if isinstance(ids, list) and ids and type(ids[0]) is int else None
1602
+ text = msg.get("content") or ""
1603
+ if not text:
1604
+ for key in ("reasoning_content", "reasoning"):
1605
+ if msg.get(key):
1606
+ text = "reasoning:" + str(msg[key])
1607
+ break
1608
+ return tid, str(text)
1609
+
1610
+
1611
+ def _first_top(resp: dict) -> tuple[list | None, str | None]:
1612
+ """The first position's listed alternatives [(id or None, text or None, logprob)], or (None, why not)."""
1613
+ from inferencekit.quality.logprobs import _entry_id
1614
+
1615
+ choice = (resp.get("choices") or [None])[0] if isinstance(resp, dict) else None
1616
+ lps = choice.get("logprobs") if isinstance(choice, dict) else None
1617
+ content = lps.get("content") if isinstance(lps, dict) else None
1618
+ if not isinstance(content, list) or not content or not isinstance(content[0], dict):
1619
+ return None, "the response has no logprobs"
1620
+ alts = content[0].get("top_logprobs")
1621
+ if not isinstance(alts, list) or not alts or not all(isinstance(a, dict) for a in alts):
1622
+ return None, "the response has no top_logprobs at the first position"
1623
+ out = []
1624
+ for a in alts:
1625
+ tid, _ = _entry_id(a)
1626
+ tok = a.get("token")
1627
+ text = str(tok) if isinstance(tok, str) and not tok.startswith("token_id:") else None
1628
+ out.append((tid, text, a.get("logprob")))
1629
+ return out, None
1630
+
1631
+
1632
+ def _text_bin(text: str) -> str:
1633
+ """The text a first token is binned by when ids are missing.
1634
+
1635
+ Detokenizers differ in a leading space (one-token decode vs decode in context) and in a
1636
+ partial UTF-8 character ('' or U+FFFD); both are removed from the draws and the listed tokens
1637
+ alike. One map applied to both sides can only merge bins, which can only lower TV.
1638
+ """
1639
+ return text.replace("�", "").strip()
1640
+
1641
+
1642
+ def _collision(hist: Counter) -> float:
1643
+ """Probability that two draws from this histogram's distribution are the same token."""
1644
+ n = sum(hist.values())
1645
+ return sum((c / n) ** 2 for c in hist.values()) if n else 1.0
1646
+
1647
+
1648
+ def _unmodelled(extra: dict) -> list[str]:
1649
+ return sorted(k for k, neutral in _UNMODELLED.items() if k in extra and extra[k] is not None
1650
+ and extra[k] not in neutral)
1651
+
1652
+
1653
+ class _Side:
1654
+ """One server's part of the check: per prompt a top-20 logprob request before and after S seeded draws."""
1655
+
1656
+ def __init__(self, name: str, client: Client, model: str, extra: dict, kwargs_for, concurrency: int):
1657
+ self.name, self.client, self.model, self.extra = name, client, model, extra
1658
+ self.kwargs_for, self.concurrency = kwargs_for, concurrency
1659
+ self.lp: dict[str, list] = {} # prompt id -> [first pass, second pass], each a listed-token list or None
1660
+ self.draws: dict[str, dict[int, tuple]] = {} # prompt id -> {seed: (token id or None, text)}
1661
+ self.errors: list[str] = []
1662
+ self.aborted: str | None = None
1663
+ self.requests = 0
1664
+ self.seed_check: dict = {}
1665
+ # vLLM's id fields: token ids for the draws (return_token_ids) and the listed tokens
1666
+ # (return_tokens_as_token_ids); dropped, in that order of preference, if the first request is refused
1667
+ self.fields = {"return_tokens_as_token_ids": True, "return_token_ids": True}
1668
+ self.refused: str | None = None
1669
+ self.k_state = {"k": _TOP_LOGPROBS}
1670
+ self.lock = threading.Lock()
1671
+ self.by_id = True # bins: token ids when every draw and listed token has one (set by bins())
1672
+ self.unmatched: str | None = None
1673
+
1674
+ def _body(self, item: dict, seed: int, logprobs: bool) -> dict:
1675
+ body = {**self.extra, "model": self.model, "messages": item["messages"], "max_tokens": 1, "seed": seed,
1676
+ "stream": False}
1677
+ if self.fields["return_token_ids"]:
1678
+ body["return_token_ids"] = True
1679
+ if logprobs:
1680
+ body["logprobs"] = True
1681
+ if self.fields["return_tokens_as_token_ids"]:
1682
+ body["return_tokens_as_token_ids"] = True
1683
+ kwargs = self.kwargs_for(item)
1684
+ if kwargs:
1685
+ body["chat_template_kwargs"] = kwargs
1686
+ return body
1687
+
1688
+ def _post(self, item: dict, seed: int, logprobs: bool) -> tuple:
1689
+ """(response, None), or (None, error) after one retry: a transient failure should not cost the prompt."""
1690
+ err = None
1691
+ for _ in range(2):
1692
+ with self.lock:
1693
+ self.requests += 1
1694
+ try:
1695
+ if logprobs:
1696
+ resp, _ = _post_capped(self.client, "/v1/chat/completions", self._body(item, seed, True),
1697
+ "top_logprobs", self.k_state, self.lock)
1698
+ else:
1699
+ resp = self.client.post("/v1/chat/completions", self._body(item, seed, False))
1700
+ except OpError as e:
1701
+ err = str(e)[:300]
1702
+ continue
1703
+ return resp, None
1704
+ return None, err
1705
+
1706
+ def _logprobs(self, item: dict) -> tuple:
1707
+ resp, err = self._post(item, 0, True)
1708
+ if err is not None:
1709
+ return item["id"], None, err
1710
+ top, why = _first_top(resp)
1711
+ return item["id"], top, why
1712
+
1713
+ def _draw(self, job) -> tuple:
1714
+ item, seed = job
1715
+ resp, err = self._post(item, seed, False)
1716
+ if err is not None:
1717
+ return item["id"], seed, None, err
1718
+ tok = _first_token(resp)
1719
+ if tok is None:
1720
+ return item["id"], seed, None, "response has no choices[0].message"
1721
+ return item["id"], seed, tok, None
1722
+
1723
+ def _pass(self, items: list[dict], slot: int) -> None:
1724
+ for pid, top, err in _run_pool(self._logprobs, items, self.concurrency):
1725
+ self.lp.setdefault(pid, [None, None])[slot] = top
1726
+ if err is not None:
1727
+ self.errors.append(f"{pid} logprobs: {err}")
1728
+
1729
+ def run(self, items: list[dict], seeds: range) -> None:
1730
+ """Logprobs (first pass), the seeded draws, logprobs again (second pass), then the seed check."""
1731
+ first = items[0]
1732
+ pid, top, err = self._logprobs(first) # alone first: a refused field, and the logprob cap, settle here
1733
+ for drop in ("return_tokens_as_token_ids", "return_token_ids"):
1734
+ if top is not None or err is None or "no top_logprobs" in err or "no logprobs" in err:
1735
+ break
1736
+ self.fields[drop] = False # perhaps only vLLM's id field is refused: ask once more without it
1737
+ pid, top, again = self._logprobs(first)
1738
+ if top is not None:
1739
+ self.refused = err[:200]
1740
+ err = again
1741
+ if top is None:
1742
+ self.aborted = (err or "no logprobs")[:300]
1743
+ self.errors.append(self.aborted)
1744
+ return
1745
+ self.lp[pid] = [top, None]
1746
+ self._pass(items[1:], 0)
1747
+ self.draws = {it["id"]: {} for it in items}
1748
+ for pid, seed, tok, err in _run_pool(self._draw, [(it, s) for it in items for s in seeds], self.concurrency):
1749
+ if err is None:
1750
+ self.draws[pid][seed] = tok
1751
+ else:
1752
+ self.errors.append(f"{pid} seed {seed}: {err}")
1753
+ self._pass(items, 1)
1754
+ self.bins()
1755
+ self.check_seeds(items, seeds)
1756
+
1757
+ def bins(self) -> None:
1758
+ """Token ids when every draw and every listed token has one; else text, which every one must have."""
1759
+ drawn = [d for got in self.draws.values() for d in got.values()]
1760
+ listed = [e for pair in self.lp.values() for top in pair if top for e in top]
1761
+ self.by_id = bool(drawn) and all(d[0] is not None for d in drawn) and all(e[0] is not None for e in listed)
1762
+ if not self.by_id and any(e[1] is None for e in listed):
1763
+ self.unmatched = ("draws and listed tokens cannot be matched: the listed tokens came as ids only "
1764
+ "(return_tokens_as_token_ids) and not every draw had an id")
1765
+
1766
+ def key(self, entry) -> object:
1767
+ return entry[0] if self.by_id else _text_bin(entry[1] or "")
1768
+
1769
+ def hist(self, pid: str, seeds) -> Counter:
1770
+ got = self.draws.get(pid, {})
1771
+ return Counter(self.key(got[s]) for s in seeds if s in got)
1772
+
1773
+ def check_seeds(self, items: list[dict], seeds: range) -> None:
1774
+ """Re-ask a few (prompt, seed) pairs already drawn: a server that honours the seed gives the same token.
1775
+
1776
+ Information only (it never blocks a PASS: unseeded draws are still draws from the distribution). The
1777
+ prompts with the most varied first token are used, so a chance repeat is unlikely; the repeat rate
1778
+ expected if the seed were ignored (the collision probability) is recorded.
1779
+ """
1780
+ complete = [it for it in items if all(s in self.draws.get(it["id"], {}) for s in seeds)]
1781
+ coll = {it["id"]: _collision(self.hist(it["id"], seeds)) for it in complete}
1782
+ pick = sorted(complete, key=lambda it: (coll[it["id"]], it["id"]))[:_SEED_CHECK_PROMPTS]
1783
+ again = list(seeds)[:_SEED_CHECK_SEEDS]
1784
+ results = _run_pool(self._draw, [(it, s) for it in pick for s in again], self.concurrency)
1785
+ ok = [(pid, s, tok) for pid, s, tok, err in results if err is None]
1786
+ same = sum(1 for pid, s, tok in ok if self.key(tok) == self.key(self.draws[pid][s]))
1787
+ chance = statistics.fmean(coll[it["id"]] for it in pick) if pick else 1.0
1788
+ if not ok:
1789
+ status = "not checked"
1790
+ elif chance > _SEED_INCONCLUSIVE:
1791
+ status = "inconclusive"
1792
+ else:
1793
+ status = "honoured" if same >= _SEED_MATCH_MIN * len(ok) else "ignored"
1794
+ self.seed_check = {"status": status, "repeats": len(ok), "same": same,
1795
+ "expected_same_if_ignored": round(chance, 4), "prompts": [it["id"] for it in pick],
1796
+ "seeds": again}
1797
+
1798
+
1799
+ def _pairs(side: _Side, top: list) -> list[tuple]:
1800
+ return [(side.key(e), e[2]) for e in top]
1801
+
1802
+
1803
+ def _judge(side: _Side, chosen: list[dict], cfg: dict, seeds: int, margin: float, resamples: int,
1804
+ bootstrap_seed: int, caps: list[str], unsent: list[str]) -> dict:
1805
+ """Per-prompt rows (draws vs the distribution the server's own logprobs imply) and this server's verdict.
1806
+
1807
+ ``caps``: settings the check does not compute; any makes the verdict UNPROVEN (the statistic is still
1808
+ reported). ``unsent``: sampling fields read as off because they were not sent, named in a FAIL's reason."""
1809
+ rows, dropped = [], Counter()
1810
+ for item in chosen:
1811
+ pid = item["id"]
1812
+ row: dict = {"id": pid, "bucket": item.get("bucket")}
1813
+ first, second = side.lp.get(pid) or (None, None)
1814
+ got = side.draws.get(pid, {})
1815
+ if side.aborted or side.unmatched or first is None or second is None or len(got) < seeds:
1816
+ row["status"] = "incomplete"
1817
+ else:
1818
+ a, b = _pairs(side, first), _pairs(side, second)
1819
+ row["listed"] = [[k, v] for k, v in a]
1820
+ if Counter(k for k, _ in a) != Counter(k for k, _ in b):
1821
+ row["status"] = "logprobs_unstable"
1822
+ row["listed_again"] = [[k, v] for k, v in b]
1823
+ else:
1824
+ va, vb = {}, {}
1825
+ for k, v in a:
1826
+ va.setdefault(k, []).append(v)
1827
+ for k, v in b:
1828
+ vb.setdefault(k, []).append(v)
1829
+ try:
1830
+ delta = max(abs(x - y) for k in va for x, y in zip(sorted(va[k]), sorted(vb[k])))
1831
+ except TypeError: # a logprob that is not a number
1832
+ delta = 0.0
1833
+ tol = max(sm.LOGPROB_TOL, delta)
1834
+ row.update(logprob_repeat_max_delta=delta, tolerance=tol)
1835
+ imp = sm.implied(a, cfg["temperature"], cfg["top_k"], cfg["top_p"], cfg["min_p"], tol)
1836
+ if imp["status"] != "ok":
1837
+ row.update(status=imp["status"], detail=imp["detail"])
1838
+ else:
1839
+ probs = imp["probs"]
1840
+ hist = side.hist(pid, range(seeds))
1841
+ listed = {k for k, _ in a}
1842
+ tv, expected = sm.tv_to(hist, probs), sm.expected_tv(probs, seeds)
1843
+ if cfg["temperature"] >= sm.GREEDY_T and cfg["temperature"] != 1.0:
1844
+ # diagnosis only: the same logprobs read as already temperature-scaled
1845
+ alt = sm.implied(a, 1.0, cfg["top_k"], cfg["top_p"], cfg["min_p"], tol)
1846
+ if alt["status"] == "ok":
1847
+ row["excess_if_tempered"] = (sm.tv_to(hist, alt["probs"])
1848
+ - sm.expected_tv(alt["probs"], seeds))
1849
+ row.update(status="informative" if imp["max_p"] < _NEAR_DETERMINISTIC else "near_deterministic",
1850
+ max_p=imp["max_p"], kept=imp["kept"], unseen_bound=imp["unseen_bound"],
1851
+ tv=tv, tv_expected=expected, excess=tv - expected,
1852
+ out_of_support=sum(n for k, n in hist.items() if k not in probs),
1853
+ outside_listed=sum(n for k, n in hist.items() if k not in listed),
1854
+ implied=[[k, p] for k, p in sorted(probs.items(), key=lambda kv: -kv[1])],
1855
+ histogram=[[k, n] for k, n in hist.most_common()])
1856
+ if row["status"] != "informative":
1857
+ dropped[row["status"]] += 1
1858
+ rows.append(row)
1859
+ inf = [r for r in rows if r["status"] == "informative"]
1860
+ sup = [r for r in rows if r["status"] in ("informative", "near_deterministic")]
1861
+ ex = [r["excess"] for r in inf]
1862
+ lo, hi = qm.cluster_ci([(x, 1) for x in ex], resamples, bootstrap_seed) if len(ex) >= 2 else (None, None)
1863
+ mean = (lambda key: statistics.fmean(r[key] for r in inf) if inf else None)
1864
+ if len(inf) < _MIN_PROMPTS:
1865
+ stat_v = "UNPROVEN"
1866
+ stat_why = (f"{len(inf)} informative prompt(s), need {_MIN_PROMPTS} (dropped: "
1867
+ + (", ".join(f"{n} {k}" for k, n in sorted(dropped.items())) or "none") + ")")
1868
+ else:
1869
+ stat_v, stat_why = qm.margin_verdict(lo, hi, margin)
1870
+ oos = sum(r["out_of_support"] for r in sup)
1871
+ outside = sum(r["outside_listed"] for r in sup)
1872
+ of = seeds * len(sup)
1873
+ wl, wh = qm.wilson_ci(oos, of)
1874
+ oos_fail = wl is not None and wl > _OOS_MAX
1875
+ stat = {"mean_tv": mean("tv"), "mean_tv_expected": mean("tv_expected"), "excess": mean("excess"),
1876
+ "ci95": [lo, hi], "margin": margin, "verdict": stat_v, "n_prompts": len(inf), "min_prompts": _MIN_PROMPTS,
1877
+ "resamples": resamples, "bootstrap_seed": bootstrap_seed, "seeds": seeds,
1878
+ "binned_by": "token_id" if side.by_id else "text"}
1879
+ support = {"draws": oos, "of": of, "rate": oos / of if of else None, "ci95": [wl, wh], "max_rate": _OOS_MAX,
1880
+ "outside_listed": outside, "prompts": len(sup), "fail": oos_fail}
1881
+ own = list(caps)
1882
+ if side.aborted:
1883
+ own.append(f"{side.name}: the first request failed, nothing was sampled: {side.aborted[:200]}")
1884
+ elif side.errors:
1885
+ own.append(f"{side.name}: {len(side.errors)} of {side.requests} requests failed after a retry "
1886
+ f"(e.g. {side.errors[0][:160]})")
1887
+ if side.unmatched:
1888
+ own.append(f"{side.name}: {side.unmatched}")
1889
+ fails = []
1890
+ if stat_v == "FAIL":
1891
+ fails.append(f"draws do not follow the server's own log-probabilities: mean excess TV {stat['excess']:+.4f}, "
1892
+ f"{stat_why}")
1893
+ alt = [r["excess_if_tempered"] for r in inf if "excess_if_tempered" in r]
1894
+ if len(alt) == len(inf):
1895
+ alo, ahi = qm.cluster_ci([(x, 1) for x in alt], resamples, bootstrap_seed)
1896
+ stat["excess_if_logprobs_were_tempered"] = {"excess": statistics.fmean(alt), "ci95": [alo, ahi]}
1897
+ if ahi is not None and ahi <= margin:
1898
+ fails.append(f"the draws do fit these log-probabilities read as already temperature-scaled (excess "
1899
+ f"{statistics.fmean(alt):+.4f}, 95% CI {alo:+.4f} to {ahi:+.4f}): the server likely "
1900
+ "returns log-probabilities after temperature, the contract requires raw ones")
1901
+ if oos_fail:
1902
+ fails.append(f"{oos} of {of} draws ({oos / of:.2%}, 95% CI {wl:.2%} to {wh:.2%}) are tokens these settings "
1903
+ f"remove (limit {_OOS_MAX:.1%})" + (f"; {outside} are not even in the listed top 20, where the "
1904
+ "kept set provably ends" if outside else ""))
1905
+ if fails and unsent:
1906
+ fails.append(f"{', '.join(unsent)} not sent and read as off: a server that applies its own default for them "
1907
+ "(vLLM applies generation_config.json) would also give this")
1908
+ if caps:
1909
+ verdict = "UNPROVEN"
1910
+ reason = ("; ".join(own) + f" (statistic alone: {stat_v}, {stat_why}"
1911
+ + (f"; would FAIL: {'; '.join(fails)}" if fails else "") + ")")
1912
+ elif fails:
1913
+ verdict, reason = "FAIL", "; ".join(fails) + ("; also: " + "; ".join(own) if own else "")
1914
+ elif own:
1915
+ verdict, reason = "UNPROVEN", "; ".join(own) + f" (statistic alone: {stat_v}, {stat_why})"
1916
+ elif stat_v != "PASS":
1917
+ verdict, reason = stat_v, stat_why
1918
+ else:
1919
+ verdict, reason = "PASS", f"{stat_why}; {oos} of {of} draws outside the kept tokens"
1920
+ return {"verdict": verdict, "reason": reason, "problems": own, "statistic": stat, "out_of_support": support,
1921
+ "prompts": {"asked": len(chosen), "informative": len(inf), "support_checked": len(sup),
1922
+ "dropped": dict(sorted(dropped.items())),
1923
+ "dropped_why": {k: _DROPPED.get(k, k) for k in sorted(dropped)}},
1924
+ "rows": rows}
1925
+
1926
+
1927
+ def _cross(cand: dict, base: dict, same_bins: bool) -> dict | None:
1928
+ """Information only: TV between the two servers' implied distributions, on prompts both could build."""
1929
+ if not same_bins:
1930
+ return {"n_prompts": 0, "note": "the two servers' tokens are binned differently (ids vs text)"}
1931
+ by_id = {r["id"]: r for r in base["rows"] if "implied" in r}
1932
+ per = []
1933
+ for r in cand["rows"]:
1934
+ other = by_id.get(r["id"])
1935
+ if "implied" in r and other is not None:
1936
+ per.append((r["id"], sm.tv_between(dict(map(tuple, r["implied"])), dict(map(tuple, other["implied"])))))
1937
+ if not per:
1938
+ return {"n_prompts": 0, "mean": None, "max": None, "max_prompt": None, "per_prompt": {}}
1939
+ worst = max(per, key=lambda kv: kv[1])
1940
+ return {"n_prompts": len(per), "mean": statistics.fmean(v for _, v in per), "max": worst[1],
1941
+ "max_prompt": worst[0], "per_prompt": dict(per)}
1942
+
1943
+
1944
+ @operation("quality_sampling", ("quality", "sampling"),
1945
+ "Sampler check (self-consistency): per prompt, the server's own top-20 first-token log-probabilities "
1946
+ "(asked twice) give the distribution its sampling settings imply; S seeded max_tokens-1 draws are "
1947
+ "tested against it (mean TV minus its exact expectation under correct sampling, bootstrap CI over "
1948
+ "prompts, against a margin), plus the share of draws outside the kept tokens. One server at a time; "
1949
+ "with --baseline-url the baseline is checked the same way and the two implied distributions compared "
1950
+ "(information only).",
1951
+ [Arg("base_url", "string", "Server to check, e.g. http://127.0.0.1:8000.", required=True),
1952
+ Arg("model", "string", "Model name the server expects.", required=True),
1953
+ Arg("baseline_url", "string", "Optional second server (e.g. the stock engine on the same checkpoint), "
1954
+ "checked the same way, after the first; its verdict is reported, the record's is the first's."),
1955
+ Arg("baseline_model", "string", "Model name on the baseline server (default: --model)."),
1956
+ Arg("run_dir", "string", "Run directory (runs/<run-id>).", required=True),
1957
+ Arg("label", "string", "Name of the server checked (file name).", default="engine"),
1958
+ Arg("prompts", "string", "Prompt-set JSONL (default: the packaged dev set)."),
1959
+ Arg("buckets", "array", "Prompt buckets to draw from (a file without bucket fields is used whole).",
1960
+ default=["very_short", "short"]),
1961
+ Arg("n_prompts", "integer", "Prompts: N, taken in turn from those buckets (file order inside each).",
1962
+ default=32),
1963
+ Arg("seeds", "integer", "Draws per prompt, seeds 0..S-1.", default=256),
1964
+ Arg("extra_body", "string", "JSON object of the sampling settings sent with every request; temperature "
1965
+ "is required, and top_k, top_p, min_p are read from it, e.g. "
1966
+ "'{\"temperature\": 0.6, \"top_k\": 20, \"top_p\": 0.95}'. The check sets max_tokens (1), seed, n, "
1967
+ "stream, logprobs, top_logprobs and vLLM's token-id fields itself."),
1968
+ Arg("chat_template_kwargs", "string", "JSON object passed as chat_template_kwargs, e.g. "
1969
+ "'{\"enable_thinking\": false}' (a model that opens with a fixed thinking token has nothing to "
1970
+ "test)."),
1971
+ Arg("margin", "number", "Pre-registered margin on the mean excess TV (probability mass).", default=0.02),
1972
+ Arg("resamples", "integer", "Bootstrap resamples over prompts.", default=10000),
1973
+ Arg("bootstrap_seed", "integer", "Bootstrap seed.", default=0),
1974
+ Arg("concurrency", "integer", "Requests in flight per server. 1 by default: a batched engine's logits "
1975
+ "change with the batch, so draws taken alongside other requests need not follow the "
1976
+ "log-probabilities of a request run alone.", default=1),
1977
+ Arg("api_key_env", "string", "Name of the env var holding the server's API key.",
1978
+ default="OPENAI_API_KEY"),
1979
+ Arg("baseline_api_key_env", "string", "Name of the env var holding the baseline's API key (default: "
1980
+ "--api-key-env)."),
1981
+ Arg("timeout", "integer", "Seconds per request.", default=600)])
1982
+ def quality_sampling(call, base_url, model, baseline_url, baseline_model, run_dir, label, prompts, buckets, n_prompts,
1983
+ seeds, extra_body, chat_template_kwargs, margin, resamples, bootstrap_seed, concurrency,
1984
+ api_key_env, baseline_api_key_env, timeout):
1985
+ from inferencekit import prompts as sets
1986
+
1987
+ label = _safe(label, "label")
1988
+ if n_prompts < 2:
1989
+ raise OpError("n_prompts must be >= 2 (the confidence interval resamples prompts)")
1990
+ if seeds < 2:
1991
+ raise OpError("seeds must be >= 2")
1992
+ if concurrency < 1 or resamples < 1:
1993
+ raise OpError("concurrency and resamples must be >= 1")
1994
+ extra = _json_arg("extra_body", extra_body) or {}
1995
+ # chat_template_kwargs layers as in bench serve: --extra-body lowest, then the flag (and the intake pin),
1996
+ # then each prompt record's own field
1997
+ base_kwargs = apply_pin({**(extra.pop("chat_template_kwargs", None) or {}),
1998
+ **(_json_arg("chat_template_kwargs", chat_template_kwargs) or {})},
1999
+ run_dir, "quality sampling")
2000
+ dropped = sorted(k for k in extra if k in _SAMPLING_OWN_KEYS)
2001
+ extra = {k: v for k, v in extra.items() if k not in _SAMPLING_OWN_KEYS}
2002
+ try:
2003
+ cfg, unsent = sm.sampling_settings(extra)
2004
+ except ValueError as e:
2005
+ raise OpError(f"--extra-body: {e}") from None
2006
+ source = prompts or str(sets.resolve("dev"))
2007
+ items, prompts_sha = load_prompts(source)
2008
+ set_name = sets.set_stem(source) if prompts else "dev"
2009
+ wanted = list(dict.fromkeys(buckets or []))
2010
+ if wanted and any(it.get("bucket") for it in items):
2011
+ # taken in turn from each bucket (file order inside a bucket): 32 prompts of a set with 100 very_short
2012
+ # and 100 short ones are 16 + 16, not 32 very_short
2013
+ queues = [[it for it in items if it.get("bucket") == b] for b in wanted]
2014
+ items = [q[i] for i in range(max(map(len, queues))) for q in queues if i < len(q)]
2015
+ chosen = items[:n_prompts]
2016
+ if len(chosen) < 2:
2017
+ raise OpError(f"{len(chosen)} prompt(s) in buckets {wanted} of {source}: need at least 2")
2018
+ bmodel = baseline_model or model
2019
+
2020
+ def kwargs_for(item: dict) -> dict:
2021
+ own = item.get("chat_template_kwargs")
2022
+ return {**base_kwargs, **own} if isinstance(own, dict) else base_kwargs
2023
+
2024
+ run = Path(run_dir)
2025
+ s_range = range(seeds)
2026
+ caps = [] # settings this check cannot compute, on both servers: no PASS and no FAIL
2027
+ unmodelled = _unmodelled(extra)
2028
+ if unmodelled:
2029
+ caps.append(f"--extra-body sets {', '.join(unmodelled)}, which change the first-token distribution in a "
2030
+ "way this check does not compute")
2031
+ cand = _Side("candidate", Client(base_url, api_key_env, timeout), model, extra, kwargs_for, concurrency)
2032
+ cand.run(chosen, s_range)
2033
+ res = _judge(cand, chosen, cfg, seeds, margin, resamples, bootstrap_seed, caps, unsent)
2034
+ base = bres = cross = None
2035
+ if baseline_url:
2036
+ base = _Side("baseline", Client(baseline_url, baseline_api_key_env or api_key_env, timeout), bmodel, extra,
2037
+ kwargs_for, concurrency)
2038
+ base.run(chosen, s_range)
2039
+ bres = _judge(base, chosen, cfg, seeds, margin, resamples, bootstrap_seed, caps, unsent)
2040
+ cross = _cross(res, bres, cand.by_id == base.by_id)
2041
+ verdict, reason, stat, support = res["verdict"], res["reason"], res["statistic"], res["out_of_support"]
2042
+
2043
+ limitations = []
2044
+ if bres is not None and bres["verdict"] != "PASS":
2045
+ limitations.append(f"BASELINE {bres['verdict']}: the baseline does not pass this same check, so its sampler "
2046
+ f"is no reference either: {bres['reason'][:300]}")
2047
+ limitations += [
2048
+ ("self-consistency: shows that the server's draws follow its own top-20 log-probabilities under these "
2049
+ "settings; whether those log-probabilities match the baseline's is the teacher-forced check's job "
2050
+ "(quality score)"),
2051
+ "max_tokens 1: the first generated token only (prefill), not later positions or longer contexts",
2052
+ ("the listed log-probabilities must be raw (log-softmax before temperature, top-k and top-p, vLLM's "
2053
+ "default); a server that returns processed ones reads as a wrong sampler")]
2054
+ sides = [cand] + ([base] if base else [])
2055
+ if any(side.by_id for side in sides):
2056
+ limitations.append("tokens are matched by id (vLLM's return_token_ids and return_tokens_as_token_ids)")
2057
+ if not all(side.by_id for side in sides):
2058
+ limitations.append("tokens are matched by their text, surrounding whitespace and U+FFFD removed (not every "
2059
+ "draw or listed token came with an id): tokens with the same text share a bin, which "
2060
+ "can only lower TV")
2061
+ for side in sides:
2062
+ if side.refused:
2063
+ limitations.append(f"{side.name} refused a vLLM token-id field, asked without it: {side.refused}")
2064
+ if side.k_state["k"] < _TOP_LOGPROBS:
2065
+ limitations.append(f"{side.name} lists at most {side.k_state['k']} log-probabilities (asked for "
2066
+ f"{_TOP_LOGPROBS}): fewer prompts can be tested")
2067
+ if side.seed_check.get("status") == "ignored":
2068
+ sc = side.seed_check
2069
+ limitations.append(f"{side.name} does not honour the request seed ({sc['same']} of {sc['repeats']} "
2070
+ f"repeats gave the same token, about {100 * sc['expected_same_if_ignored']:.0f}% would "
2071
+ "by chance): information only, its draws are still samples; seeding is checked by "
2072
+ "the gates")
2073
+ if unsent:
2074
+ limitations.append(f"{', '.join(unsent)} not sent: read as off, but a server that applies its own "
2075
+ "default (vLLM applies generation_config.json) then draws from another distribution; "
2076
+ "send every sampling field")
2077
+ if concurrency > 1:
2078
+ limitations.append(f"concurrency {concurrency}: a batched engine's logits change with the batch, so draws "
2079
+ "taken alongside other requests need not follow the log-probabilities of one alone")
2080
+ if dropped:
2081
+ limitations.append(f"--extra-body keys set by the check were ignored: {', '.join(dropped)}")
2082
+ if len(chosen) < n_prompts:
2083
+ limitations.append(f"only {len(chosen)} prompts in buckets {wanted} (asked for {n_prompts})")
2084
+ if cfg["min_p"] is not None:
2085
+ limitations.append("min_p: engines differ on whether it comes before or after top-p; a prompt counts only "
2086
+ "when both orders keep the same tokens")
2087
+ if cfg["top_k"] is not None and cfg["top_p"] is not None:
2088
+ limitations.append("top_k with top_p: SGLang takes top-p over the whole distribution, Hugging Face and vLLM "
2089
+ "over the top-k tokens; a prompt counts only when both keep the same tokens")
2090
+ limitations.append(f"the margin bounds the mean excess TV, not the sampler's own error: the excess grows "
2091
+ f"about with the square of a small error, so a sampler a few hundredths of probability off "
2092
+ f"can still PASS ({seeds} seeds x {len(chosen)} prompts)")
2093
+ missing = ["samples"] if any(side.errors for side in sides) else []
2094
+
2095
+ def side_doc(side: _Side, r: dict, url_model: str) -> dict:
2096
+ return {"url": side.client.base, "model": url_model, "verdict": r["verdict"], "reason": r["reason"],
2097
+ "problems": r["problems"], "statistic": r["statistic"], "out_of_support": r["out_of_support"],
2098
+ "prompts": r["prompts"], "seed_check": side.seed_check, "requests": side.requests,
2099
+ "errors": side.errors[:20], "token_id_fields": dict(side.fields), "top_logprobs": side.k_state["k"]}
2100
+
2101
+ out_dir = run / "quality" / "sampling"
2102
+ out_dir.mkdir(parents=True, exist_ok=True)
2103
+ path = out_dir / f"{label}.json"
2104
+ settings = {"max_tokens": 1, "extra_body": ev.redact(extra), "sampling": cfg, "not_sent": unsent,
2105
+ "chat_template_kwargs": base_kwargs or None, "seeds": [0, seeds - 1], "top_logprobs": _TOP_LOGPROBS,
2106
+ "concurrency": concurrency, "ignored_extra_body_keys": dropped,
2107
+ "near_deterministic": _NEAR_DETERMINISTIC, "logprob_tolerance": sm.LOGPROB_TOL}
2108
+ prompts_doc = {"name": set_name, "sha256": prompts_sha, "ids": [it["id"] for it in chosen]}
2109
+ doc = {"format": SAMPLING_FORMAT, "method": "self_consistency", "label": label, "verdict": verdict,
2110
+ "reason": reason, "settings": settings, "prompts": prompts_doc,
2111
+ "candidate": {**side_doc(cand, res, model), "per_prompt": res["rows"]},
2112
+ "baseline": {**side_doc(base, bres, bmodel), "per_prompt": bres["rows"]} if base else None,
2113
+ "cross_server": cross}
2114
+ path.write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
2115
+
2116
+ keep = ("id", "bucket", "status", "max_p", "kept", "tv", "tv_expected", "excess", "out_of_support")
2117
+ lo, hi = stat["ci95"]
2118
+ metrics = [ev.metric("first_token_tv_excess", stat["excess"], "probability", "mean", n=stat["n_prompts"],
2119
+ ci95=[lo, hi], subject=label),
2120
+ ev.metric("first_token_tv", stat["mean_tv"], "probability", "mean", n=stat["n_prompts"], subject=label),
2121
+ ev.metric("first_token_tv_expected", stat["mean_tv_expected"], "probability", "mean",
2122
+ n=stat["n_prompts"], subject=label),
2123
+ ev.metric("first_token_out_of_support", support["rate"], "fraction", "rate", n=support["of"],
2124
+ ci95=support["ci95"], subject=label)]
2125
+ if bres is not None:
2126
+ bs = bres["statistic"]
2127
+ metrics += [ev.metric("first_token_tv_excess", bs["excess"], "probability", "mean", n=bs["n_prompts"],
2128
+ ci95=bs["ci95"], subject="baseline"),
2129
+ ev.metric("first_token_out_of_support", bres["out_of_support"]["rate"], "fraction", "rate",
2130
+ n=bres["out_of_support"]["of"], ci95=bres["out_of_support"]["ci95"], subject="baseline"),
2131
+ ev.metric("first_token_implied_tv_between_servers", cross.get("mean"), "probability", "mean",
2132
+ n=cross.get("n_prompts"), subject=f"{label} vs baseline")]
2133
+ metrics.append(ev.metric("sampling_requests", sum(side.requests for side in sides), "requests", "count"))
2134
+
2135
+ def fmt(x, sign: str = "+") -> str:
2136
+ return f"{x:{sign}.4f}" if x is not None else "n/a"
2137
+
2138
+ summary = (f"{label}: {verdict}: sampled first tokens vs the server's own log-probabilities: mean TV "
2139
+ f"{fmt(stat['mean_tv'], '')}, expected {fmt(stat['mean_tv_expected'], '')}, excess {fmt(stat['excess'])} "
2140
+ f"(95% CI {fmt(lo)} to {fmt(hi)}, margin {margin:g}) over {stat['n_prompts']} prompts x {seeds} seeds; "
2141
+ f"{support['draws']} of {support['of']} draws outside the kept tokens")
2142
+ if bres is not None:
2143
+ summary += f"; baseline {bres['verdict']} on the same check"
2144
+ ident = ev.identity(engine={"name": label}, model={"id": model},
2145
+ workload={"gen_len": 1, "batch": concurrency, "concurrency": concurrency,
2146
+ "prompt_set": {"name": set_name, "sha256": prompts_sha}})
2147
+ data = {"task": "sampling", "method": "self_consistency", "label": label, "verdict": verdict, "reason": reason,
2148
+ "problems": res["problems"], "statistic": stat, "out_of_support": support, "prompts_used": res["prompts"],
2149
+ "settings": settings, "prompts": prompts_doc,
2150
+ "seed_checks": {"candidate": cand.seed_check, **({"baseline": base.seed_check} if base else {})},
2151
+ "errors": {"candidate": len(cand.errors), **({"baseline": len(base.errors)} if base else {})},
2152
+ "per_prompt": [{k: r[k] for k in keep if k in r} for r in res["rows"]],
2153
+ "baseline": None, "cross_server": None, "results_file": str(path)}
2154
+ if base is not None:
2155
+ data["baseline"] = {"url": base.client.base, "model": bmodel, "verdict": bres["verdict"],
2156
+ "reason": bres["reason"], "statistic": bres["statistic"],
2157
+ "out_of_support": bres["out_of_support"], "prompts_used": bres["prompts"]}
2158
+ data["cross_server"] = {k: v for k, v in cross.items() if k != "per_prompt"}
2159
+ return _save(call, run, "quality_task", claim_class="derived", summary=summary[:600], identity_=ident,
2160
+ metrics=metrics, artifacts=[ev.artifact(path, run, "sampling_histograms")], missing=missing,
2161
+ limitations=limitations, data=data)