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.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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)
|