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,1772 @@
|
|
|
1
|
+
"""gates run / gates spec-exact: anti-cheat gates for a speed claim.
|
|
2
|
+
|
|
3
|
+
A speed number is only worth publishing if the engine did the work it claims. These
|
|
4
|
+
gates probe a live OpenAI-compatible endpoint (``/v1/chat/completions``, streamed) and
|
|
5
|
+
check a ``bench serve`` run directory. Each gate ends PASS, FAIL, UNPROVEN or NOT RUN
|
|
6
|
+
with the requests and numbers that decided it:
|
|
7
|
+
|
|
8
|
+
caching replaying prompts must not be >10% faster (TTFT or decode) than the
|
|
9
|
+
first time (median over pairs, and in >= 2/3 of the pairs), and a
|
|
10
|
+
prompt changed by one character must not get the old answer back
|
|
11
|
+
(output / "semantic" caching).
|
|
12
|
+
token_count usage.completion_tokens == streamed token ids (or logprob entries)
|
|
13
|
+
== re-tokenized text (when a tokenizer is given). A reply cut at
|
|
14
|
+
max_tokens whose logprob stream is shorter than usage is UNPROVEN, not
|
|
15
|
+
FAIL: special tokens (end of turn, padding) can be counted but not streamed;
|
|
16
|
+
re-tokenized text that covers usage settles it. Copied strings (rare,
|
|
17
|
+
echo) and text after EOS are not held to the re-tokenized count.
|
|
18
|
+
output_length with ignore_eos the output is exactly max_tokens long; in a run dir,
|
|
19
|
+
per-prompt lengths are within +/-10% of the reference engine's.
|
|
20
|
+
unique_prompts (run dir) the load schedule never repeats a prompt inside a level.
|
|
21
|
+
spec_exact greedy outputs with speculation (A) match the engine without it (B);
|
|
22
|
+
early divergences may not exceed the A/A floor of B run twice.
|
|
23
|
+
hidden_probes T=1 requests (top_p 1, top_k off) must not equal the greedy output on
|
|
24
|
+
every prompt (temperature honoured; seeds reported); top-k logprobs must
|
|
25
|
+
be finite, normalised, sorted, and the greedy token must be the argmax.
|
|
26
|
+
rare_tokens CJK, emoji, code identifiers, long numbers and uncommon Unicode must
|
|
27
|
+
be reproduced verbatim (catches pruned vocabularies / logit masks); a
|
|
28
|
+
slip in the random ASCII nonces around a string is recorded, not a miss.
|
|
29
|
+
|
|
30
|
+
The live probes run as one randomly interleaved stream: each repeat (and each perturbed
|
|
31
|
+
prompt) follows its first-seen request a few requests later, mixed with every other probe,
|
|
32
|
+
so an engine cannot tell a probe by its position and slow drift over the run affects
|
|
33
|
+
both members of a pair alike. Nothing about a probe is predictable from this code: the
|
|
34
|
+
seed is drawn fresh for every run (and recorded), every probe prompt carries a random
|
|
35
|
+
tag, the echo instruction, its delimiters and padding are drawn from pools, and the rare
|
|
36
|
+
strings are sampled from a pool and generated at random around a random nonce. For an
|
|
37
|
+
evaluation, run the probes on the sealed prompt set (``--prompts sealed.jsonl``) so not
|
|
38
|
+
even the underlying prompts were public.
|
|
39
|
+
|
|
40
|
+
Refusing a probe never helps: a probe that gets an HTTP error or a broken stream is
|
|
41
|
+
evidence. A refused rare string counts as a miss (unless the reference engine refuses it
|
|
42
|
+
too); a refused cache, count, ignore_eos, sampling or logprob probe makes its gate at
|
|
43
|
+
least UNPROVEN, with the status and error listed. Only when the endpoint answers no
|
|
44
|
+
probe at all are the live gates NOT RUN. A gate that the inputs asked for (or allowed)
|
|
45
|
+
and that ends NOT RUN makes the record's overall verdict UNPROVEN, never PASS. The
|
|
46
|
+
optional extensions the probes add (``Client.OPTIONAL``: token ids, special tokens in the
|
|
47
|
+
stream, top_p / top_k on the samples) are the exception: a server that rejects one with a
|
|
48
|
+
400 gets the request again without it, and the reply records the dropped field.
|
|
49
|
+
|
|
50
|
+
Limitation: the hidden probes run in their own sequential stream (concurrency 1), not
|
|
51
|
+
inside the ``bench serve`` load, so an engine that behaves only when idle is not caught
|
|
52
|
+
by them; ``probe_bodies()`` returns the probe requests for a load runner to mix in.
|
|
53
|
+
|
|
54
|
+
The SSE client here is deliberately small and private to the gates (the ``bench serve``
|
|
55
|
+
client is the timing reference). Times are ``time.perf_counter_ns`` on the client.
|
|
56
|
+
|
|
57
|
+
Run directory layout read by the static checks (as bench serve writes it):
|
|
58
|
+
outputs/<engine>/<bucket>/<id>.json {"text", "token_ids", "finish_reason", "usage", "error"?, ...}
|
|
59
|
+
inputs/<engine>/<bucket>/<id>.json the request body (max_tokens, ignore_eos / min_tokens);
|
|
60
|
+
inputs/<bucket>/<id>.json else the engine-independent copy
|
|
61
|
+
load mode (bench serve): inputs/<engine>/load-<level>/<bucket>/<id>[~k].json request bodies and
|
|
62
|
+
outputs/<engine>/load-<level>/<bucket>/<id>[~k].json ({"id", "salt", "level", ...});
|
|
63
|
+
or any ``*schedule*.jsonl`` / ``*requests*.jsonl`` with one load request per line:
|
|
64
|
+
{"level": <concurrency or rate>, "prompt_id"|"id": "...", "salt"?: "...", "prompt_sha256"?: "...",
|
|
65
|
+
"engine"?: "..."}
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import hashlib
|
|
71
|
+
import json
|
|
72
|
+
import math
|
|
73
|
+
import os
|
|
74
|
+
import random
|
|
75
|
+
import secrets
|
|
76
|
+
import statistics
|
|
77
|
+
import time
|
|
78
|
+
import unicodedata
|
|
79
|
+
import urllib.error
|
|
80
|
+
import urllib.request
|
|
81
|
+
from dataclasses import dataclass, field
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
|
|
84
|
+
from inferencekit import evidence as ev
|
|
85
|
+
from inferencekit import prompts as ps
|
|
86
|
+
from inferencekit.ops.correctness import first_divergence
|
|
87
|
+
from inferencekit.ops.intake import apply_pin
|
|
88
|
+
from inferencekit.registry import Arg, OpError, operation
|
|
89
|
+
from inferencekit.stats import combine_claims
|
|
90
|
+
|
|
91
|
+
GATES = ("caching", "token_count", "output_length", "unique_prompts", "spec_exact", "hidden_probes", "rare_tokens")
|
|
92
|
+
LIVE = {"caching", "token_count", "output_length", "spec_exact", "hidden_probes", "rare_tokens"}
|
|
93
|
+
STATIC = {"token_count", "output_length", "unique_prompts"}
|
|
94
|
+
# "gates" is a v2 evidence kind; on a v1-only store the record falls back to "correctness".
|
|
95
|
+
KIND = "gates" if "gates" in ev.KINDS else "correctness"
|
|
96
|
+
|
|
97
|
+
# Strings a model must copy verbatim, by the part of the vocabulary a pruned vocabulary or a
|
|
98
|
+
# logit mask would drop first. Each run samples one string per class and wraps it in random
|
|
99
|
+
# ASCII nonces; "code" and "number" are generated fresh every run.
|
|
100
|
+
RARE_POOL = {
|
|
101
|
+
"cjk": ("東京特許許可局許可局長", "鬱蒼とした森の奥で", "魑魅魍魎が跋扈する", "齟齬を来す麒麟と鸚鵡", "饕餮纹的青铜鼎",
|
|
102
|
+
"薔薇の棘と檸檬の皮"),
|
|
103
|
+
"hangul": ("한국어 맞춤법 검사기", "훈민정음 해례본", "닭볶음탕과 떡볶이"),
|
|
104
|
+
"emoji": ("🦀🧬🪐🫧", "🦩🪼🫎🪿", "🧿🪬🫀🫁", "🛸🌌🔭🧪", "🦔🪵🍄🌰"),
|
|
105
|
+
"emoji_zwj": ("👩🏽🔬 👨👩👧👦", "🧑🏿🚀 👩🌾", "🧑🤝🧑 👨🏻💻"),
|
|
106
|
+
"unicode": ("ꙮ ᚠᚢᚦᚨᚱᚲ Ⱎ ꓘ", "ᚠᛇᚻ ᛒᛦᚦ ᚹᛁᚾ", "Ꭰ ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ", "ꝏ ȸ ȹ ʬ ʭ"),
|
|
107
|
+
"devanagari": ("नमस्ते दुनिया", "संस्कृतम् भाषा", "क्षत्रिय ज्ञान"),
|
|
108
|
+
"arabic": ("مرحبا بالعالم", "اللغة العربية الفصحى", "خوارزمية الترتيب"),
|
|
109
|
+
"math": ("Ελληνικά ∮ E⋅da = Q, ∀x∈ℝ", "∑ᵢ xᵢ² ≤ ∏ⱼ ‖vⱼ‖", "ℵ₀ < 𝔠 ⊆ ℘(ℕ)"),
|
|
110
|
+
}
|
|
111
|
+
RARE_STRINGS = tuple((cls, s) for cls, xs in RARE_POOL.items() for s in xs) # the fixed part of the pool
|
|
112
|
+
# Echo prompts: instruction phrasings x delimiter pairs x padding, all drawn per probe pair.
|
|
113
|
+
ECHO_TEMPLATES = (
|
|
114
|
+
"Repeat the text between {o} and {c} exactly, character for character. Output only that text.\n{o}{s}{c}",
|
|
115
|
+
"Copy the string inside {o} {c} verbatim and reply with nothing else.\n{o}{s}{c}",
|
|
116
|
+
"Please echo back exactly what appears between {o} and {c}, keeping every character unchanged.\n\n{o}{s}{c}",
|
|
117
|
+
"Echo task: return the characters enclosed by {o} and {c}, unchanged, with no commentary. {o}{s}{c}",
|
|
118
|
+
"Here is a string: {o}{s}{c}\nWrite it out again exactly as given and output only that string.",
|
|
119
|
+
)
|
|
120
|
+
ECHO_DELIMS = (("<<<", ">>>"), ("[[", "]]"), ("{{", "}}"), ("«", "»"), ("⟦", "⟧"), ("BEGIN:", ":END"))
|
|
121
|
+
PADDING = ("", "Reference {n}.", "(ticket {n})", "Request id {n}. Thanks.", "Batch {n}: one item.", "Note {n}.")
|
|
122
|
+
INSTANT = 1e6 # "speedup" recorded when a repeat streams all its tokens in one instant
|
|
123
|
+
NONCE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --------------------------------------------------------------------------- SSE client
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class Reply:
|
|
130
|
+
"""One streamed completion, as the client saw it."""
|
|
131
|
+
|
|
132
|
+
status: int = 0
|
|
133
|
+
error: str | None = None
|
|
134
|
+
text: str = ""
|
|
135
|
+
reasoning: str = "" # delta.reasoning_content of reasoning models (counted as output tokens)
|
|
136
|
+
token_ids: list | None = None
|
|
137
|
+
lp: list | None = None # logprobs.content entries, one per token
|
|
138
|
+
finish_reason: str | None = None
|
|
139
|
+
usage: dict | None = None
|
|
140
|
+
ttft_ms: float | None = None
|
|
141
|
+
total_ms: float | None = None
|
|
142
|
+
t_first_ns: int | None = None
|
|
143
|
+
t_last_ns: int | None = None
|
|
144
|
+
chunks: int = 0
|
|
145
|
+
dropped: list = field(default_factory=list) # request fields the server rejected
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def ok(self) -> bool:
|
|
149
|
+
return self.status == 200 and self.error is None
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def full_text(self) -> str:
|
|
153
|
+
"""Everything generated: reasoning (if streamed separately) then the answer."""
|
|
154
|
+
return self.reasoning + self.text
|
|
155
|
+
|
|
156
|
+
def streamed_count(self) -> tuple[int | None, str | None]:
|
|
157
|
+
"""Tokens counted from the stream itself: token ids, else logprob entries."""
|
|
158
|
+
if self.token_ids:
|
|
159
|
+
return len(self.token_ids), "token_ids"
|
|
160
|
+
if self.lp:
|
|
161
|
+
return len(self.lp), "logprobs"
|
|
162
|
+
return None, None
|
|
163
|
+
|
|
164
|
+
def usage_count(self) -> int | None:
|
|
165
|
+
u = self.usage or {}
|
|
166
|
+
v = u.get("completion_tokens")
|
|
167
|
+
return v if isinstance(v, int) else None
|
|
168
|
+
|
|
169
|
+
def decode_tok_s(self) -> float | None:
|
|
170
|
+
n, _ = self.streamed_count()
|
|
171
|
+
n = n if n is not None else self.usage_count()
|
|
172
|
+
if not n or n < 2 or self.t_first_ns is None or self.t_last_ns is None or self.t_last_ns <= self.t_first_ns:
|
|
173
|
+
return None
|
|
174
|
+
return (n - 1) / ((self.t_last_ns - self.t_first_ns) / 1e9)
|
|
175
|
+
|
|
176
|
+
def summary(self) -> dict:
|
|
177
|
+
n, how = self.streamed_count()
|
|
178
|
+
return {"status": self.status, "error": self.error, "text": self.text[:2000],
|
|
179
|
+
"reasoning": self.reasoning[:2000] or None, "finish_reason": self.finish_reason,
|
|
180
|
+
"usage_completion_tokens": self.usage_count(), "streamed_tokens": n, "streamed_method": how,
|
|
181
|
+
"ttft_ms": self.ttft_ms, "total_ms": self.total_ms, "decode_tok_s": self.decode_tok_s(),
|
|
182
|
+
"chunks": self.chunks, "dropped_fields": self.dropped}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def chat_url(base_url: str) -> str:
|
|
186
|
+
base = base_url.rstrip("/")
|
|
187
|
+
if base.endswith("/chat/completions"):
|
|
188
|
+
return base
|
|
189
|
+
return base + ("/chat/completions" if base.endswith("/v1") else "/v1/chat/completions")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def models_url(base_url: str) -> str:
|
|
193
|
+
base = base_url.rstrip("/")
|
|
194
|
+
for suffix in ("/chat/completions",):
|
|
195
|
+
if base.endswith(suffix):
|
|
196
|
+
base = base[: -len(suffix)]
|
|
197
|
+
return base + ("/models" if base.endswith("/v1") else "/v1/models")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Client:
|
|
201
|
+
"""Minimal streaming client for an OpenAI-compatible chat endpoint."""
|
|
202
|
+
|
|
203
|
+
# Extensions the probes add. A 400 on a request carrying ones the server has not yet accepted is
|
|
204
|
+
# retried without them (top_k alone first, see SUSPECT_FIRST); a field refused that way is left out
|
|
205
|
+
# of later requests too.
|
|
206
|
+
OPTIONAL = ("return_token_ids", "skip_special_tokens", "top_p", "top_k")
|
|
207
|
+
|
|
208
|
+
def __init__(self, base_url: str, model: str | None, api_key: str | None = None, timeout: float = 120.0,
|
|
209
|
+
token_ids: bool = True, extra: dict | None = None):
|
|
210
|
+
self.base_url, self.api_key, self.timeout = base_url, api_key, timeout
|
|
211
|
+
self.ask_ids = token_ids
|
|
212
|
+
self.extra = dict(extra or {})
|
|
213
|
+
self.accepted: set[str] = set() # optional fields seen in a request that got a 200
|
|
214
|
+
self.rejected: set[str] = set() # optional fields the server refused with a 400
|
|
215
|
+
self.model = model or self._first_model()
|
|
216
|
+
|
|
217
|
+
def _headers(self) -> dict:
|
|
218
|
+
h = {"Content-Type": "application/json", "Accept": "text/event-stream"}
|
|
219
|
+
if self.api_key:
|
|
220
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
221
|
+
return h
|
|
222
|
+
|
|
223
|
+
def _first_model(self) -> str:
|
|
224
|
+
req = urllib.request.Request(models_url(self.base_url), headers=self._headers())
|
|
225
|
+
try:
|
|
226
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r: # noqa: S310 - user-given endpoint
|
|
227
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
228
|
+
return data["data"][0]["id"]
|
|
229
|
+
except (urllib.error.URLError, OSError, ValueError, KeyError, IndexError) as e:
|
|
230
|
+
raise OpError(f"cannot list models at {models_url(self.base_url)} ({e}); pass --model") from None
|
|
231
|
+
|
|
232
|
+
def body(self, messages, max_tokens, temperature=0.0, seed=None, ignore_eos=False, logprobs=False,
|
|
233
|
+
top_logprobs=0, full_vocab=False, keep_special=False) -> dict:
|
|
234
|
+
"""One request. ``full_vocab``: sample from the whole distribution (top_p 1, top_k off), not
|
|
235
|
+
the model's default truncation. ``keep_special``: stream special tokens too, so every counted
|
|
236
|
+
token shows up in the stream (vLLM and SGLang ``skip_special_tokens``)."""
|
|
237
|
+
b = {"model": self.model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature,
|
|
238
|
+
"stream": True, "stream_options": {"include_usage": True}}
|
|
239
|
+
if seed is not None:
|
|
240
|
+
b["seed"] = seed
|
|
241
|
+
if ignore_eos:
|
|
242
|
+
b["ignore_eos"] = True
|
|
243
|
+
if logprobs:
|
|
244
|
+
b["logprobs"] = True
|
|
245
|
+
if top_logprobs:
|
|
246
|
+
b["top_logprobs"] = top_logprobs
|
|
247
|
+
if full_vocab:
|
|
248
|
+
b["top_p"], b["top_k"] = 1.0, -1
|
|
249
|
+
if keep_special:
|
|
250
|
+
b["skip_special_tokens"] = False
|
|
251
|
+
if self.ask_ids:
|
|
252
|
+
b["return_token_ids"] = True
|
|
253
|
+
for k, v in self.extra.items():
|
|
254
|
+
b.setdefault(k, v)
|
|
255
|
+
return b
|
|
256
|
+
|
|
257
|
+
def _ours(self, body: dict) -> list[str]:
|
|
258
|
+
"""Optional fields in ``body`` that the probes set (a value from --extra-body is the user's)."""
|
|
259
|
+
return [k for k in self.OPTIONAL if k in body and (k not in self.extra or self.extra[k] != body[k])]
|
|
260
|
+
|
|
261
|
+
def _without(self, body: dict, keys: list[str]) -> dict:
|
|
262
|
+
out = {k: v for k, v in body.items() if k not in keys}
|
|
263
|
+
out.update({k: self.extra[k] for k in keys if k in self.extra}) # the user's own value, if any
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
# Dropped alone first on a 400, so that refusing it does not take the other fields with it: top_k is
|
|
267
|
+
# the one servers refuse most (the OpenAI API has no top_k; some servers want top_k > 0).
|
|
268
|
+
SUSPECT_FIRST = ("top_k",)
|
|
269
|
+
|
|
270
|
+
def send(self, body: dict) -> Reply:
|
|
271
|
+
known = [k for k in self._ours(body) if k in self.rejected] # refused before: do not ask again
|
|
272
|
+
if known:
|
|
273
|
+
body = self._without(body, known)
|
|
274
|
+
reply = self._send(body)
|
|
275
|
+
# Only fields this server has not already taken can be the reason for a 400.
|
|
276
|
+
unsure = [k for k in self._ours(body) if k not in self.accepted] if reply.status == 400 else []
|
|
277
|
+
tries = [[k] for k in self.SUSPECT_FIRST if k in unsure and len(unsure) > 1] + ([unsure] if unsure else [])
|
|
278
|
+
drop: list[str] = []
|
|
279
|
+
for drop in tries:
|
|
280
|
+
reply = self._send(self._without(body, drop))
|
|
281
|
+
if reply.status != 400:
|
|
282
|
+
break
|
|
283
|
+
if drop and reply.ok:
|
|
284
|
+
body = self._without(body, drop)
|
|
285
|
+
self.rejected.update(drop)
|
|
286
|
+
if "return_token_ids" in drop:
|
|
287
|
+
self.ask_ids = False
|
|
288
|
+
if reply.ok:
|
|
289
|
+
self.accepted.update(self._ours(body))
|
|
290
|
+
reply.dropped = known + drop
|
|
291
|
+
return reply
|
|
292
|
+
|
|
293
|
+
def _send(self, body: dict) -> Reply:
|
|
294
|
+
rep = Reply()
|
|
295
|
+
req = urllib.request.Request(chat_url(self.base_url), data=json.dumps(body).encode("utf-8"),
|
|
296
|
+
headers=self._headers(), method="POST")
|
|
297
|
+
t0 = time.perf_counter_ns()
|
|
298
|
+
try:
|
|
299
|
+
resp = urllib.request.urlopen(req, timeout=self.timeout) # noqa: S310 - user-given endpoint
|
|
300
|
+
except urllib.error.HTTPError as e:
|
|
301
|
+
rep.status = e.code
|
|
302
|
+
rep.error = e.read()[:500].decode("utf-8", "replace")
|
|
303
|
+
return rep
|
|
304
|
+
except (urllib.error.URLError, OSError) as e:
|
|
305
|
+
rep.error = f"connection failed: {e}"
|
|
306
|
+
return rep
|
|
307
|
+
rep.status = resp.status
|
|
308
|
+
ids, lps = [], []
|
|
309
|
+
try:
|
|
310
|
+
with resp:
|
|
311
|
+
for raw in resp:
|
|
312
|
+
line = raw.decode("utf-8", "replace").strip()
|
|
313
|
+
if not line.startswith("data:"):
|
|
314
|
+
continue
|
|
315
|
+
payload = line[5:].strip()
|
|
316
|
+
if payload == "[DONE]":
|
|
317
|
+
break
|
|
318
|
+
try:
|
|
319
|
+
chunk = json.loads(payload)
|
|
320
|
+
except ValueError:
|
|
321
|
+
continue
|
|
322
|
+
now = time.perf_counter_ns()
|
|
323
|
+
rep.chunks += 1
|
|
324
|
+
if chunk.get("usage"):
|
|
325
|
+
rep.usage = chunk["usage"]
|
|
326
|
+
for ch in chunk.get("choices") or []:
|
|
327
|
+
delta = ch.get("delta") or {}
|
|
328
|
+
piece = delta.get("content") or ""
|
|
329
|
+
think = delta.get("reasoning_content") or delta.get("reasoning") or ""
|
|
330
|
+
tids = ch.get("token_ids") or delta.get("token_ids") or []
|
|
331
|
+
lpc = ((ch.get("logprobs") or {}).get("content")) or []
|
|
332
|
+
if piece or think or tids or lpc:
|
|
333
|
+
if rep.t_first_ns is None:
|
|
334
|
+
rep.t_first_ns = now
|
|
335
|
+
rep.ttft_ms = (now - t0) / 1e6
|
|
336
|
+
rep.t_last_ns = now
|
|
337
|
+
rep.text += piece
|
|
338
|
+
rep.reasoning += think
|
|
339
|
+
ids.extend(tids)
|
|
340
|
+
lps.extend(lpc)
|
|
341
|
+
if ch.get("finish_reason"):
|
|
342
|
+
rep.finish_reason = ch["finish_reason"]
|
|
343
|
+
except (OSError, ValueError) as e:
|
|
344
|
+
rep.error = f"stream broke: {e}"
|
|
345
|
+
rep.total_ms = (time.perf_counter_ns() - t0) / 1e6
|
|
346
|
+
rep.token_ids = ids or None
|
|
347
|
+
rep.lp = lps or None
|
|
348
|
+
return rep
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --------------------------------------------------------------------------- helpers
|
|
352
|
+
|
|
353
|
+
def load_tokenizer(name: str | None):
|
|
354
|
+
"""A ``text -> token count`` function from a Hugging Face tokenizer, or None."""
|
|
355
|
+
if not name:
|
|
356
|
+
return None
|
|
357
|
+
try:
|
|
358
|
+
from transformers import AutoTokenizer # optional; not a dependency
|
|
359
|
+
except ImportError:
|
|
360
|
+
return None
|
|
361
|
+
tok = AutoTokenizer.from_pretrained(name)
|
|
362
|
+
return lambda text: len(tok(text, add_special_tokens=False)["input_ids"])
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _msgs(text: str) -> list[dict]:
|
|
366
|
+
return [{"role": "user", "content": text}]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def nonce(rng: random.Random, n: int = 16) -> str:
|
|
370
|
+
return "".join(rng.choice(NONCE_ALPHABET) for _ in range(n))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def perturb(s: str, rng: random.Random) -> str:
|
|
374
|
+
"""The same string with exactly one character changed."""
|
|
375
|
+
i = rng.randrange(len(s))
|
|
376
|
+
choices = [c for c in NONCE_ALPHABET if c != s[i]]
|
|
377
|
+
return s[:i] + rng.choice(choices) + s[i + 1:]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def echo_style(rng: random.Random) -> tuple[str, tuple[str, str], str, bool]:
|
|
381
|
+
"""(template, delimiters, padding, padding first?) for one echo probe or pair."""
|
|
382
|
+
pad = rng.choice(PADDING).format(n=nonce(rng, 6))
|
|
383
|
+
return rng.choice(ECHO_TEMPLATES), rng.choice(ECHO_DELIMS), pad, rng.random() < 0.5
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def echo_prompt(s: str, style) -> str:
|
|
387
|
+
tmpl, (o, c), pad, first = style
|
|
388
|
+
body = tmpl.format(o=o, c=c, s=s)
|
|
389
|
+
if not pad:
|
|
390
|
+
return body
|
|
391
|
+
return f"{pad}\n{body}" if first else f"{body}\n{pad}"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def rare_targets(rng: random.Random) -> list[tuple[str, str]]:
|
|
395
|
+
"""One string per class from the pool plus fresh code / number strings, each between random nonces."""
|
|
396
|
+
out = [(cls, rng.choice(xs)) for cls, xs in RARE_POOL.items()]
|
|
397
|
+
ident = "".join(rng.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(5))
|
|
398
|
+
out.append(("code", f"__{ident}_{nonce(rng, 4)}__ fn_{nonce(rng, 7)} {ident.capitalize()}Handler"
|
|
399
|
+
f"V{rng.randrange(2, 99)}"))
|
|
400
|
+
out.append(("number", "".join(rng.choice("0123456789") for _ in range(rng.randrange(30, 46)))))
|
|
401
|
+
out.append(("number", f"0x{rng.getrandbits(64):016x} {rng.randrange(1, 10)}.{rng.randrange(10**8):08d}e-"
|
|
402
|
+
f"{rng.randrange(10, 40)}"))
|
|
403
|
+
return [(cls, f"{nonce(rng, 4)} {t} {nonce(rng, 4)}") for cls, t in out]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def rare_core(target: str) -> str:
|
|
407
|
+
"""The rare string itself, without the random ASCII nonce on each side ("<nonce> <string> <nonce>").
|
|
408
|
+
|
|
409
|
+
The nonces only make a probe unpredictable. A pruned vocabulary or a logit mask loses the rare
|
|
410
|
+
characters, never plain ASCII letters, so a slip in a nonce says nothing about the vocabulary."""
|
|
411
|
+
head, sep, rest = target.partition(" ")
|
|
412
|
+
core, sep2, _ = rest.rpartition(" ")
|
|
413
|
+
return core if sep and sep2 and core else target
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def tag_prompt(text: str, rng: random.Random) -> str:
|
|
417
|
+
"""The prompt with a random tag, so a probe is never a prompt seen before (dev set or not)."""
|
|
418
|
+
t = nonce(rng, 8)
|
|
419
|
+
forms = (f"[{t}] {text}", f"{text}\n\n(ref {t})", f"Request {t}:\n{text}", f"{text} #{t}")
|
|
420
|
+
return rng.choice(forms)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def reproduced(target: str, text: str) -> bool:
|
|
424
|
+
"""Verbatim (or Unicode-NFC-equivalent) occurrence of target in text."""
|
|
425
|
+
return target in text or unicodedata.normalize("NFC", target) in unicodedata.normalize("NFC", text)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _median(xs):
|
|
429
|
+
xs = [x for x in xs if x is not None]
|
|
430
|
+
return statistics.median(xs) if xs else None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _gate(name, verdict, reason, metrics=None, details=None, limitations=None) -> dict:
|
|
434
|
+
return {"gate": name, "verdict": verdict, "reason": reason, "metrics": metrics or {},
|
|
435
|
+
"details": details or [], "limitations": limitations or []}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _failed(jobs: list) -> list[dict]:
|
|
439
|
+
"""Probes that got no usable reply (HTTP error, broken stream, never sent): evidence, not noise."""
|
|
440
|
+
out = []
|
|
441
|
+
for j in jobs:
|
|
442
|
+
r = j.reply
|
|
443
|
+
if r is None or not r.ok:
|
|
444
|
+
out.append({"kind": j.kind, "key": j.key, "status": r.status if r else None,
|
|
445
|
+
"error": ((r.error if r else "not sent") or "")[:300]})
|
|
446
|
+
return out
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _fail_note(failed: list[dict], what: str = "probe") -> str:
|
|
450
|
+
by: dict[str, int] = {}
|
|
451
|
+
for f in failed:
|
|
452
|
+
k = f"HTTP {f['status']}" if f["status"] else "no response"
|
|
453
|
+
by[k] = by.get(k, 0) + 1
|
|
454
|
+
first = next((f["error"] for f in failed if f["error"]), "")
|
|
455
|
+
return (f"{len(failed)} {what}(s) failed ({', '.join(f'{k} x{v}' for k, v in sorted(by.items()))}"
|
|
456
|
+
+ (f"; first error: {first[:160]}" if first else "") + ")")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def alive(jobs: dict) -> tuple[bool, str]:
|
|
460
|
+
"""(the endpoint answered at least one probe, first error otherwise)."""
|
|
461
|
+
allj = [j for js in jobs.values() for j in js if not j.kind.endswith("reference")]
|
|
462
|
+
if any(j.reply is not None and j.reply.ok for j in allj):
|
|
463
|
+
return True, ""
|
|
464
|
+
failed = _failed(allj)
|
|
465
|
+
return False, _fail_note(failed) if failed else "no probe was sent"
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _sequence(rep: Reply) -> tuple[list, str]:
|
|
469
|
+
"""What to compare token by token: ids, else logprob token strings, else characters."""
|
|
470
|
+
if rep.token_ids:
|
|
471
|
+
return list(rep.token_ids), "token_ids"
|
|
472
|
+
if rep.lp:
|
|
473
|
+
return [e.get("token") for e in rep.lp], "logprob_tokens"
|
|
474
|
+
return list(rep.full_text), "characters"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# --------------------------------------------------------------------------- live probe plan
|
|
478
|
+
|
|
479
|
+
@dataclass
|
|
480
|
+
class Job:
|
|
481
|
+
kind: str
|
|
482
|
+
key: str
|
|
483
|
+
body: dict
|
|
484
|
+
meta: dict = field(default_factory=dict)
|
|
485
|
+
reply: Reply | None = None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class Prober:
|
|
489
|
+
def __init__(self, client: Client, max_tokens: int, seed: int, log: list):
|
|
490
|
+
self.c, self.max_tokens, self.rng, self.log = client, max_tokens, random.Random(seed), log
|
|
491
|
+
|
|
492
|
+
def job(self, kind, key, text, **kw) -> Job:
|
|
493
|
+
max_tokens = kw.pop("max_tokens", self.max_tokens)
|
|
494
|
+
meta = kw.pop("meta", {})
|
|
495
|
+
return Job(kind, key, self.c.body(_msgs(text), max_tokens, **kw), meta)
|
|
496
|
+
|
|
497
|
+
def run(self, jobs: list[Job], shuffle: bool = True) -> None:
|
|
498
|
+
order = list(jobs)
|
|
499
|
+
if shuffle:
|
|
500
|
+
self.rng.shuffle(order)
|
|
501
|
+
for j in order:
|
|
502
|
+
j.reply = self.c.send(j.body)
|
|
503
|
+
self.log.append({"kind": j.kind, "key": j.key, "meta": j.meta,
|
|
504
|
+
"request": {k: v for k, v in j.body.items() if k != "messages"},
|
|
505
|
+
"prompt_sha256": hashlib.sha256(json.dumps(j.body["messages"], ensure_ascii=False)
|
|
506
|
+
.encode("utf-8")).hexdigest(),
|
|
507
|
+
"reply": j.reply.summary()})
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def stagger(first: list, then: list) -> list:
|
|
511
|
+
"""[f0, f1, t0, f2, t1, ...]: each follow-up shortly after its first request, so slow drift
|
|
512
|
+
(clocks, other tenants) hits a first-seen request and its repeat alike."""
|
|
513
|
+
out = []
|
|
514
|
+
for i, f in enumerate(first):
|
|
515
|
+
out.append(f)
|
|
516
|
+
if i >= 1:
|
|
517
|
+
out.append(then[i - 1])
|
|
518
|
+
out += then[len(first) - 1:] if first else []
|
|
519
|
+
return out
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def interleave(chains: list[list], rng: random.Random) -> list:
|
|
523
|
+
"""A uniformly random merge of several sequences that keeps each sequence's own order."""
|
|
524
|
+
chains = [list(c) for c in chains if c]
|
|
525
|
+
out = []
|
|
526
|
+
while chains:
|
|
527
|
+
r = rng.randrange(sum(len(c) for c in chains))
|
|
528
|
+
for c in chains:
|
|
529
|
+
if r < len(c):
|
|
530
|
+
out.append(c.pop(0))
|
|
531
|
+
break
|
|
532
|
+
r -= len(c)
|
|
533
|
+
chains = [c for c in chains if c]
|
|
534
|
+
return out
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
N_COUNT, N_IGNORE_EOS, N_LOGPROBS = 4, 3, 3 # count, ignore_eos and logprob probes per run
|
|
538
|
+
SAMPLING_DEFAULT = 8 # hidden_probes sampling prompts when --sampling-prompts is not given
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def probe_pool(items: list[dict]) -> list[dict]:
|
|
542
|
+
return [it for it in items if it["bucket"] in ("short", "medium")] or list(items)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def probes_needed(gates: set, n_cache: int, n_sampling: int) -> int:
|
|
546
|
+
"""Distinct short/medium prompts the live probes draw: 2 warm-ups plus each wanted gate's share."""
|
|
547
|
+
return (2 + (n_cache if "caching" in gates else 0) + (N_COUNT if "token_count" in gates else 0)
|
|
548
|
+
+ (N_IGNORE_EOS if "output_length" in gates else 0)
|
|
549
|
+
+ (n_sampling + N_LOGPROBS if "hidden_probes" in gates else 0))
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def sampling_count(pool_size: int, gates: set, n_cache: int, asked: int | None) -> tuple[int, str | None]:
|
|
553
|
+
"""(sampling prompts, limitation or None). ``asked`` is used as given. Unset, it is SAMPLING_DEFAULT, or
|
|
554
|
+
what a smaller prompt set leaves room for (never below SAMPLING_MIN_FAIL), and the record says so."""
|
|
555
|
+
if asked is not None or "hidden_probes" not in gates:
|
|
556
|
+
return (SAMPLING_DEFAULT if asked is None else asked), None
|
|
557
|
+
n = min(SAMPLING_DEFAULT, max(SAMPLING_MIN_FAIL, pool_size - probes_needed(gates, n_cache, 0)))
|
|
558
|
+
if n == SAMPLING_DEFAULT:
|
|
559
|
+
return n, None
|
|
560
|
+
return n, (f"the prompt set has {pool_size} short/medium prompts, room for {n} sampling prompts, not "
|
|
561
|
+
f"{SAMPLING_DEFAULT}: an honest engine whose samples match greedy on a prompt with chance 1/3 "
|
|
562
|
+
f"FAILs with chance 1 in {3 ** n}, not 1 in {3 ** SAMPLING_DEFAULT} (derived; use a larger set, or "
|
|
563
|
+
"fewer --cache-prompts)")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def plan_and_run(client: Client, items: list[dict], *, gates: set, max_tokens: int, seed: int, n_cache: int,
|
|
567
|
+
log: list, reference: Client | None, echo_tokens: int = 64,
|
|
568
|
+
n_sampling: int = SAMPLING_DEFAULT) -> dict[str, list[Job]]:
|
|
569
|
+
"""Build the probe jobs, run them as one randomly interleaved stream, return them grouped by kind.
|
|
570
|
+
|
|
571
|
+
Ordered pairs (first-seen -> repeat, original -> perturbed) stay in order but are
|
|
572
|
+
staggered and mixed with every other probe, so nothing marks a request as a probe and
|
|
573
|
+
drift over the run affects both members of a pair alike.
|
|
574
|
+
"""
|
|
575
|
+
rng = random.Random(seed)
|
|
576
|
+
pr = Prober(client, max_tokens, seed, log)
|
|
577
|
+
pool = probe_pool(items)
|
|
578
|
+
need = probes_needed(gates, n_cache, n_sampling)
|
|
579
|
+
if len(pool) < need:
|
|
580
|
+
raise OpError(f"the prompt set has {len(pool)} short/medium prompts; the probes need {need} distinct ones "
|
|
581
|
+
"(use a larger set, or lower --sampling-prompts / --cache-prompts)")
|
|
582
|
+
rng.shuffle(pool)
|
|
583
|
+
take = iter(pool)
|
|
584
|
+
|
|
585
|
+
def nxt():
|
|
586
|
+
return tag_prompt(ps.prompt_text(next(take)), rng)
|
|
587
|
+
|
|
588
|
+
echo_max = max(max_tokens, echo_tokens)
|
|
589
|
+
warm = [pr.job("warmup", f"w{i}", nxt()) for i in range(2)]
|
|
590
|
+
pr.run(warm, shuffle=False)
|
|
591
|
+
chains, free, out = [], [], {"warmup": warm}
|
|
592
|
+
if "caching" in gates:
|
|
593
|
+
texts = [nxt() for _ in range(n_cache)]
|
|
594
|
+
first = [pr.job("cache_first", f"c{i}", t) for i, t in enumerate(texts)]
|
|
595
|
+
again = [pr.job("cache_repeat", f"c{i}", t) for i, t in enumerate(texts)]
|
|
596
|
+
nonces = [nonce(rng) for _ in range(4)]
|
|
597
|
+
styles = [echo_style(rng) for _ in nonces] # one style per pair: the pair differs in one character only
|
|
598
|
+
echo1 = [pr.job("echo_orig", f"e{i}", echo_prompt(s, st), meta={"nonce": s}, max_tokens=echo_max)
|
|
599
|
+
for i, (s, st) in enumerate(zip(nonces, styles))]
|
|
600
|
+
perturbed = [perturb(s, rng) for s in nonces]
|
|
601
|
+
echo2 = [pr.job("echo_perturbed", f"e{i}", echo_prompt(p, st), meta={"nonce": p, "orig": s},
|
|
602
|
+
max_tokens=echo_max) for i, (s, p, st) in enumerate(zip(nonces, perturbed, styles))]
|
|
603
|
+
chains += [stagger(first, again), stagger(echo1, echo2)]
|
|
604
|
+
out.update(cache_first=first, cache_repeat=again, echo_orig=echo1, echo_perturbed=echo2)
|
|
605
|
+
# Count and exact-length probes ask for special tokens in the stream: an engine that counts an
|
|
606
|
+
# end-of-turn token in usage but streams nothing for it would otherwise look like it lies.
|
|
607
|
+
if "token_count" in gates:
|
|
608
|
+
out["count"] = [pr.job("count", f"n{i}", nxt(), logprobs=True, keep_special=True) for i in range(N_COUNT)]
|
|
609
|
+
free += out["count"]
|
|
610
|
+
if "output_length" in gates:
|
|
611
|
+
out["ignore_eos"] = [pr.job("ignore_eos", f"l{i}", nxt(), ignore_eos=True, logprobs=True, keep_special=True)
|
|
612
|
+
for i in range(N_IGNORE_EOS)]
|
|
613
|
+
free += out["ignore_eos"]
|
|
614
|
+
if "hidden_probes" in gates:
|
|
615
|
+
samp = []
|
|
616
|
+
for i in range(n_sampling):
|
|
617
|
+
t = nxt()
|
|
618
|
+
sa, sb = rng.randrange(1, 2**31), rng.randrange(1, 2**31)
|
|
619
|
+
samp += [pr.job("greedy", f"s{i}", t),
|
|
620
|
+
pr.job("sample_a", f"s{i}", t, temperature=1.0, seed=sa, full_vocab=True),
|
|
621
|
+
pr.job("sample_a2", f"s{i}", t, temperature=1.0, seed=sa, full_vocab=True),
|
|
622
|
+
pr.job("sample_b", f"s{i}", t, temperature=1.0, seed=sb, full_vocab=True)]
|
|
623
|
+
lps = [pr.job("logprobs", f"p{i}", nxt(), logprobs=True, top_logprobs=5) for i in range(N_LOGPROBS)]
|
|
624
|
+
free += samp + lps
|
|
625
|
+
out.update(sampling=samp, logprobs=lps)
|
|
626
|
+
if "rare_tokens" in gates:
|
|
627
|
+
rare = [pr.job("rare", f"r{i}", echo_prompt(t, echo_style(rng)),
|
|
628
|
+
meta={"class": cls, "target": t, "core": rare_core(t)}, max_tokens=echo_max)
|
|
629
|
+
for i, (cls, t) in enumerate(rare_targets(rng))]
|
|
630
|
+
free += rare
|
|
631
|
+
out["rare"] = rare
|
|
632
|
+
if reference is not None:
|
|
633
|
+
ref_jobs = [Job("rare_reference", j.key, {**reference.body(j.body["messages"], j.body["max_tokens"])},
|
|
634
|
+
j.meta) for j in rare]
|
|
635
|
+
Prober(reference, max_tokens, seed, log).run(ref_jobs, shuffle=False)
|
|
636
|
+
out["rare_reference"] = ref_jobs
|
|
637
|
+
pr.run(interleave(chains + [[j] for j in free], rng), shuffle=False)
|
|
638
|
+
return out
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def probe_bodies(seed: int | None = None, model: str = "", max_tokens: int = 64, n_sampling: int = SAMPLING_DEFAULT,
|
|
642
|
+
n_logprobs: int = N_LOGPROBS, prompts: str = "dev") -> list[dict]:
|
|
643
|
+
"""Hidden-probe requests for a load runner to mix into its schedule (``{"id", "kind", "body"}``).
|
|
644
|
+
|
|
645
|
+
The sampling probes come in groups that share a prompt (greedy, two samples with one seed, one
|
|
646
|
+
with another); the logprob probes ask for top-5 logprobs. Run them inside the load, write their
|
|
647
|
+
replies as ``outputs/<engine>/probes/<id>.json`` and ``gates run --run-dir`` checks them with the
|
|
648
|
+
same rules as the live hidden_probes gate. ``optional`` lists body fields a runner may drop and
|
|
649
|
+
resend if the server answers 400 (as the live client does).
|
|
650
|
+
"""
|
|
651
|
+
rng = random.Random(secrets.randbits(63) if seed is None else seed)
|
|
652
|
+
pool = [it for it in ps.load(prompts) if it["bucket"] in ("short", "medium")]
|
|
653
|
+
if len(pool) < n_sampling + n_logprobs:
|
|
654
|
+
raise OpError(f"the prompt set has {len(pool)} short/medium prompts; the probes need "
|
|
655
|
+
f"{n_sampling + n_logprobs} distinct ones")
|
|
656
|
+
rng.shuffle(pool)
|
|
657
|
+
take = iter(pool)
|
|
658
|
+
c = Client.__new__(Client) # body() only: no endpoint is contacted
|
|
659
|
+
c.model, c.ask_ids, c.extra = model, False, {}
|
|
660
|
+
out = []
|
|
661
|
+
sample = {"temperature": 1.0, "full_vocab": True}
|
|
662
|
+
for i in range(n_sampling):
|
|
663
|
+
t = tag_prompt(ps.prompt_text(next(take)), rng)
|
|
664
|
+
sa, sb = rng.randrange(1, 2**31), rng.randrange(1, 2**31)
|
|
665
|
+
for kind, kw in (("greedy", {}), ("sample_a", {**sample, "seed": sa}), ("sample_a2", {**sample, "seed": sa}),
|
|
666
|
+
("sample_b", {**sample, "seed": sb})):
|
|
667
|
+
body = c.body(_msgs(t), max_tokens, **kw)
|
|
668
|
+
out.append({"id": f"probe-s{i}-{kind}", "kind": kind, "group": f"s{i}", "body": body,
|
|
669
|
+
"optional": [k for k in Client.OPTIONAL if k in body]})
|
|
670
|
+
for i in range(n_logprobs):
|
|
671
|
+
out.append({"id": f"probe-p{i}", "kind": "logprobs", "group": f"p{i}",
|
|
672
|
+
"body": c.body(_msgs(tag_prompt(ps.prompt_text(next(take)), rng)), max_tokens, logprobs=True,
|
|
673
|
+
top_logprobs=5), "optional": []})
|
|
674
|
+
return out
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# --------------------------------------------------------------------------- live gates
|
|
678
|
+
|
|
679
|
+
def gate_caching(jobs: dict, threshold: float) -> dict:
|
|
680
|
+
first = {j.key: j.reply for j in jobs.get("cache_first", [])}
|
|
681
|
+
again = {j.key: j.reply for j in jobs.get("cache_repeat", [])}
|
|
682
|
+
failed = _failed([j for k in ("cache_first", "cache_repeat", "echo_orig", "echo_perturbed")
|
|
683
|
+
for j in jobs.get(k, [])])
|
|
684
|
+
rows, ttft_sp, dec_sp = [], [], []
|
|
685
|
+
for k, a in first.items():
|
|
686
|
+
b = again.get(k)
|
|
687
|
+
if not (a and b and a.ok and b.ok):
|
|
688
|
+
continue
|
|
689
|
+
t = a.ttft_ms / b.ttft_ms if a.ttft_ms and b.ttft_ms else None
|
|
690
|
+
d = (b.decode_tok_s() / a.decode_tok_s()) if a.decode_tok_s() and b.decode_tok_s() else None
|
|
691
|
+
if d is None and a.decode_tok_s() and b.t_first_ns is not None and b.t_first_ns == b.t_last_ns \
|
|
692
|
+
and (b.streamed_count()[0] or b.usage_count() or 0) >= 2:
|
|
693
|
+
d = INSTANT # every token arrived at once: nothing was decoded
|
|
694
|
+
ttft_sp.append(t)
|
|
695
|
+
dec_sp.append(d)
|
|
696
|
+
rows.append({"key": k, "first_ttft_ms": a.ttft_ms, "repeat_ttft_ms": b.ttft_ms, "ttft_speedup": t,
|
|
697
|
+
"first_decode_tok_s": a.decode_tok_s(), "repeat_decode_tok_s": b.decode_tok_s(),
|
|
698
|
+
"decode_speedup": d, "same_text": a.full_text == b.full_text})
|
|
699
|
+
mt, md = _median(ttft_sp), _median(dec_sp)
|
|
700
|
+
cached, unclear = [], 0
|
|
701
|
+
origs = {j.key: j for j in jobs.get("echo_orig", [])}
|
|
702
|
+
for j in jobs.get("echo_perturbed", []):
|
|
703
|
+
o = origs.get(j.key)
|
|
704
|
+
if not (o and o.reply and o.reply.ok and j.reply and j.reply.ok):
|
|
705
|
+
continue # counted in `failed`
|
|
706
|
+
new, old = j.meta["nonce"], j.meta["orig"]
|
|
707
|
+
# The old string coming back for the new prompt is a cache hit; a model that echoes
|
|
708
|
+
# neither string tells us nothing.
|
|
709
|
+
hit = old in j.reply.full_text and new not in j.reply.full_text
|
|
710
|
+
if hit:
|
|
711
|
+
cached.append({"key": j.key, "sent": new, "got": j.reply.full_text[:200], "first_reply_to": old})
|
|
712
|
+
elif new not in j.reply.full_text and old not in j.reply.full_text:
|
|
713
|
+
unclear += 1
|
|
714
|
+
judged = sum(1 for j in jobs.get("echo_perturbed", []) if j.reply and j.reply.ok
|
|
715
|
+
and origs.get(j.key) and origs[j.key].reply and origs[j.key].reply.ok) - unclear
|
|
716
|
+
metrics = {"ttft_speedup_median": mt, "decode_speedup_median": md, "pairs": len(rows),
|
|
717
|
+
"perturbed_cache_hits": len(cached), "perturbed_pairs": judged, "failed_probes": len(failed)}
|
|
718
|
+
lim = []
|
|
719
|
+
problems = []
|
|
720
|
+
|
|
721
|
+
def most(xs): # a speedup must be typical, not one lucky pair: >= 2/3 of pairs beyond the limit
|
|
722
|
+
xs = [x for x in xs if x is not None]
|
|
723
|
+
return bool(xs) and 3 * sum(1 for x in xs if x > 1 + threshold) >= 2 * len(xs)
|
|
724
|
+
|
|
725
|
+
if md is not None and md > 1 + threshold and most(dec_sp):
|
|
726
|
+
problems.append(f"repeated prompts decode {md:.2f}x faster than first-seen (output caching?)")
|
|
727
|
+
if mt is not None and mt > 1 + threshold and most(ttft_sp):
|
|
728
|
+
problems.append(f"repeated prompts reach the first token {mt:.2f}x faster than first-seen: a prefix or output "
|
|
729
|
+
"cache is on; benchmark rounds that replay prompts would be inflated (disable prefix "
|
|
730
|
+
"caching for timing, or use unique prompts per round)")
|
|
731
|
+
if cached:
|
|
732
|
+
problems.append(f"{len(cached)} prompt(s) changed by one character got the previous answer back "
|
|
733
|
+
"(similarity/semantic output cache)")
|
|
734
|
+
if unclear:
|
|
735
|
+
lim.append(f"{unclear} perturbed echo probe(s) were inconclusive (the model did not echo either string)")
|
|
736
|
+
if mt is None or md is None:
|
|
737
|
+
lim.append("timing pairs incomplete; the timing half of the gate is weaker")
|
|
738
|
+
if problems:
|
|
739
|
+
return _gate("caching", "FAIL", "; ".join(problems), metrics, rows + cached + failed, lim)
|
|
740
|
+
if failed:
|
|
741
|
+
return _gate("caching", "UNPROVEN", f"{_fail_note(failed)}: a refused probe cannot be judged, so the cache "
|
|
742
|
+
"check is incomplete", metrics, rows + failed, lim)
|
|
743
|
+
if not rows and not judged:
|
|
744
|
+
return _gate("caching", "UNPROVEN", "no first/repeat pair and no perturbed pair could be judged", metrics,
|
|
745
|
+
rows, lim)
|
|
746
|
+
if mt is None or md is None:
|
|
747
|
+
return _gate("caching", "UNPROVEN", "no cache hits on perturbed prompts, but the repeat timing could not be "
|
|
748
|
+
"measured", metrics, rows, lim)
|
|
749
|
+
return _gate("caching", "PASS", f"repeat speedup median TTFT {mt:.2f}x, decode {md:.2f}x (limit "
|
|
750
|
+
f"{1 + threshold:.2f}x); 0 perturbed prompts answered from cache", metrics, rows, lim)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def count_rows(replies: list[tuple], count_tokens) -> list[dict]:
|
|
754
|
+
"""One row per usable reply; ``replies`` holds (key, reply) or (key, reply, max_tokens)."""
|
|
755
|
+
rows = []
|
|
756
|
+
for key, r, *mt in replies:
|
|
757
|
+
if not r or not r.ok:
|
|
758
|
+
continue
|
|
759
|
+
n, how = r.streamed_count()
|
|
760
|
+
retok = count_tokens(r.full_text) if count_tokens else None
|
|
761
|
+
rows.append({"key": key, "usage": r.usage_count(), "streamed": n, "streamed_method": how,
|
|
762
|
+
"retokenized": retok, "finish_reason": r.finish_reason, "max_tokens": mt[0] if mt else None})
|
|
763
|
+
return rows
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# Replies whose text is not expected to re-tokenize to the generated count: after EOS (ignore_eos) an
|
|
767
|
+
# engine may generate special tokens it strips from the text, and a copied rare or echo string can be
|
|
768
|
+
# generated as a non-canonical split (17 generated tokens re-tokenize to 22, say).
|
|
769
|
+
RETOK_EXEMPT = ("ignore_eos:", "rare:", "echo_orig:", "echo_perturbed:")
|
|
770
|
+
# The copy probes (rare, echo) ask for no logprobs, so on an engine without token ids usage and the text
|
|
771
|
+
# are their only counts, and the gap is set aside the same way: the count probes check usage. (13 Oct:
|
|
772
|
+
# rare 'ArrF क्षत्रिय ज्ञान 6bzC' copied verbatim, usage 17, re-tokenized 14, nothing streamed; observed.)
|
|
773
|
+
# An ignore_eos reply asks for logprobs; without a stream its text stays the check on usage.
|
|
774
|
+
COPY_PROBES = ("rare:", "echo_orig:", "echo_perturbed:")
|
|
775
|
+
UNSTREAMED_EOS = "tokens not streamed: special tokens after EOS?"
|
|
776
|
+
UNSTREAMED_CUT = "tokens counted but not streamed (a held-back partial character at the cut?)"
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def unstreamed(u, s, how, finish, max_tokens) -> bool:
|
|
780
|
+
"""A logprob stream shorter than usage on a reply cut at max_tokens: the gap may be special tokens
|
|
781
|
+
(end of turn, padding) that were generated and counted but never streamed. The stream cannot tell
|
|
782
|
+
that from an engine that stops early and over-reports, so such a row is unproven, not failed."""
|
|
783
|
+
return (u is not None and s is not None and s < u and how == "logprobs" and finish == "length"
|
|
784
|
+
and max_tokens is not None and u == max_tokens)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def text_covers(t, u, retok_tol: float) -> bool:
|
|
788
|
+
"""The re-tokenized text holds at least as many tokens as usage claims (and not wildly more): it
|
|
789
|
+
refutes over-reporting, the only cheat a short stream could hide."""
|
|
790
|
+
return t is not None and u is not None and u <= t <= u + max(2, retok_tol * u)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def judge_counts(rows: list[dict], retok_tol: float) -> dict:
|
|
794
|
+
"""Sort count rows into {"bad": mismatches (FAIL), "unsettled": logprob streams short of usage at the
|
|
795
|
+
max_tokens limit (UNPROVEN), "by_text": such streams whose re-tokenized text covers usage (a
|
|
796
|
+
limitation), "retok_only": re-tokenize-only gaps on RETOK_EXEMPT rows (a limitation), "checked": rows
|
|
797
|
+
with >= 2 independent counts}."""
|
|
798
|
+
bad, unsettled, by_text, retok_only, checked = [], [], [], [], 0
|
|
799
|
+
for row in rows:
|
|
800
|
+
u, s, t = row["usage"], row["streamed"], row["retokenized"]
|
|
801
|
+
key = str(row.get("key", ""))
|
|
802
|
+
have = [x for x in (u, s, t) if x is not None]
|
|
803
|
+
if len(have) < 2:
|
|
804
|
+
continue
|
|
805
|
+
why = []
|
|
806
|
+
# An engine may count the end-of-sequence token in usage without streaming it.
|
|
807
|
+
slack = 1 if row.get("finish_reason") == "stop" else 0
|
|
808
|
+
stream_agrees = u is not None and s is not None and abs(u - s) <= slack
|
|
809
|
+
settled = False
|
|
810
|
+
if u is not None and s is not None and not stream_agrees:
|
|
811
|
+
if unstreamed(u, s, row.get("streamed_method"), row.get("finish_reason"), row.get("max_tokens")):
|
|
812
|
+
what = UNSTREAMED_EOS if key.startswith("ignore_eos:") else UNSTREAMED_CUT
|
|
813
|
+
if text_covers(t, u, retok_tol): # the text proves the count the stream could not
|
|
814
|
+
settled = True
|
|
815
|
+
by_text.append({**row, "problem": f"{u - s} {what}; the re-tokenized text ({t}) covers usage"})
|
|
816
|
+
else:
|
|
817
|
+
unsettled.append({**row, "problem": f"{u - s} {what}"})
|
|
818
|
+
else:
|
|
819
|
+
why.append(f"usage {u} != streamed {s}")
|
|
820
|
+
ref = s if s is not None else u
|
|
821
|
+
set_aside = False
|
|
822
|
+
if t is not None and ref is not None and abs(t - ref) > max(2, retok_tol * ref) and not settled:
|
|
823
|
+
if u is not None and key.startswith(RETOK_EXEMPT) and (s is not None or key.startswith(COPY_PROBES)):
|
|
824
|
+
# Stream and usage already judged this reply (agreeing, or caught above), or it is a copy
|
|
825
|
+
# probe with usage alone; the text is not a fair count here.
|
|
826
|
+
set_aside = s is None
|
|
827
|
+
retok_only.append({**row, "problem": f"re-tokenized {t} vs {ref}"})
|
|
828
|
+
else:
|
|
829
|
+
why.append(f"re-tokenized {t} vs {ref}")
|
|
830
|
+
checked += not set_aside # usage against the text alone, set aside: nothing was checked
|
|
831
|
+
if why:
|
|
832
|
+
bad.append({**row, "problem": "; ".join(why)})
|
|
833
|
+
return {"bad": bad, "unsettled": unsettled, "by_text": by_text, "retok_only": retok_only, "checked": checked}
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def unstreamed_note(rows: list[dict], after_eos: bool | None = None) -> str:
|
|
837
|
+
"""'a to b tokens not streamed: ...' for each kind of row: ignore_eos replies (``after_eos``, or by
|
|
838
|
+
key) and replies cut at max_tokens without it."""
|
|
839
|
+
out = []
|
|
840
|
+
for eos, what in ((True, UNSTREAMED_EOS), (False, UNSTREAMED_CUT)):
|
|
841
|
+
gaps = sorted(r["usage"] - r["streamed"] for r in rows
|
|
842
|
+
if (str(r.get("key", "")).startswith("ignore_eos:") if after_eos is None else after_eos) == eos)
|
|
843
|
+
if gaps:
|
|
844
|
+
out.append(f"{gaps[0]} to {gaps[-1]} {what}" if gaps[0] != gaps[-1] else f"{gaps[0]} {what}")
|
|
845
|
+
return "; ".join(out)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def gate_token_count(jobs: dict, count_tokens, retok_tol: float, static_rows: list[dict] | None = None) -> dict:
|
|
849
|
+
replies = [(f"{k}:{j.key}", j.reply, j.body.get("max_tokens")) for k, js in jobs.items()
|
|
850
|
+
if not k.endswith("reference") for j in js]
|
|
851
|
+
rows = count_rows(replies, count_tokens) + list(static_rows or [])
|
|
852
|
+
judged = judge_counts(rows, retok_tol)
|
|
853
|
+
bad, unsettled, checked = judged["bad"], judged["unsettled"], judged["checked"]
|
|
854
|
+
failed = _failed(jobs.get("count", []))
|
|
855
|
+
lim = []
|
|
856
|
+
if count_tokens is None:
|
|
857
|
+
lim.append("no tokenizer given: re-tokenized counts not checked (pass --tokenizer)")
|
|
858
|
+
if judged["retok_only"]:
|
|
859
|
+
lim.append(f"{len(judged['retok_only'])} ignore_eos / echo / rare repl(ies) re-tokenize to a different count "
|
|
860
|
+
"than they streamed or usage says (special tokens stripped from the text, or a copied string "
|
|
861
|
+
"generated as a non-canonical split); the re-tokenized gap is not counted as a mismatch (their "
|
|
862
|
+
"stream, where there is one, was judged against usage, and the count probes check usage)")
|
|
863
|
+
if judged["by_text"]:
|
|
864
|
+
lim.append(f"{len(judged['by_text'])} repl(ies) cut at max_tokens streamed fewer logprob entries than usage "
|
|
865
|
+
f"counts ({unstreamed_note(judged['by_text'])}), but their re-tokenized text covers usage: the "
|
|
866
|
+
"count is proven by the text, not the stream")
|
|
867
|
+
methods = sorted({r["streamed_method"] for r in rows if r.get("streamed_method")})
|
|
868
|
+
metrics = {"replies_checked": checked, "mismatches": len(bad), "unstreamed_at_limit": len(unsettled),
|
|
869
|
+
"unstreamed_settled_by_text": len(judged["by_text"]),
|
|
870
|
+
"retokenize_only_gaps": len(judged["retok_only"]), "streamed_methods": methods,
|
|
871
|
+
"failed_probes": len(failed)}
|
|
872
|
+
if bad:
|
|
873
|
+
return _gate("token_count", "FAIL", f"{len(bad)}/{checked} replies report a token count that the stream "
|
|
874
|
+
"does not support", metrics, bad[:50] + unsettled[:20] + failed, lim)
|
|
875
|
+
notes = []
|
|
876
|
+
if failed:
|
|
877
|
+
notes.append(_fail_note(failed, "count probe"))
|
|
878
|
+
if unsettled:
|
|
879
|
+
notes.append(f"{len(unsettled)}/{checked} replies cut at max_tokens stream fewer logprob entries than usage "
|
|
880
|
+
f"counts ({unstreamed_note(unsettled)}): the stream cannot prove the count (the engine returned "
|
|
881
|
+
"no token ids, and skip_special_tokens: false on the count and ignore_eos probes did not bring "
|
|
882
|
+
"the missing tokens into the stream" + ("; pass --tokenizer so the text can settle it)"
|
|
883
|
+
if count_tokens is None else ")"))
|
|
884
|
+
if notes:
|
|
885
|
+
return _gate("token_count", "UNPROVEN", "; ".join(notes) + f"; {checked - len(unsettled)} other replies agree",
|
|
886
|
+
metrics, unsettled[:50] + failed, lim)
|
|
887
|
+
if checked == 0:
|
|
888
|
+
verdict = "UNPROVEN" if rows else "NOT RUN"
|
|
889
|
+
return _gate("token_count", verdict, "no reply carried two independent token counts (no usage, token ids, "
|
|
890
|
+
"logprobs or tokenizer)", metrics, judged["retok_only"][:20], lim)
|
|
891
|
+
return _gate("token_count", "PASS", f"{checked} replies: usage, streamed and re-tokenized counts agree",
|
|
892
|
+
metrics, judged["by_text"][:20] + judged["retok_only"][:20], lim)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def gate_output_length_live(jobs: dict, count_tokens) -> dict:
|
|
896
|
+
rows, bad, stop_short, short_stream, by_text, usage_only = [], [], [], [], [], 0
|
|
897
|
+
failed = _failed(jobs.get("ignore_eos", []))
|
|
898
|
+
for j in jobs.get("ignore_eos", []):
|
|
899
|
+
r = j.reply
|
|
900
|
+
want = j.body["max_tokens"]
|
|
901
|
+
if not r or not r.ok:
|
|
902
|
+
continue
|
|
903
|
+
n, how = r.streamed_count()
|
|
904
|
+
if n is None and count_tokens is not None:
|
|
905
|
+
n, how = count_tokens(r.full_text), "retokenized"
|
|
906
|
+
if n is None:
|
|
907
|
+
n, how = r.usage_count(), "usage"
|
|
908
|
+
usage_only += 1
|
|
909
|
+
u, st = r.usage_count(), r.streamed_count()[0]
|
|
910
|
+
slack = 1 if r.finish_reason == "stop" else 0 # the EOS token may be counted on one side only
|
|
911
|
+
disagree = u is not None and st is not None and abs(u - st) > slack
|
|
912
|
+
row = {"key": j.key, "max_tokens": want, "length": n, "method": how, "finish_reason": r.finish_reason,
|
|
913
|
+
"usage": u, "streamed": st}
|
|
914
|
+
rows.append(row)
|
|
915
|
+
if how == "retokenized": # detokenize -> retokenize is not exact; allow a small slack
|
|
916
|
+
wrong = n is None or abs(n - want) > max(2, 0.02 * want)
|
|
917
|
+
else:
|
|
918
|
+
wrong = n != want
|
|
919
|
+
problems = []
|
|
920
|
+
if u is not None and u > want:
|
|
921
|
+
problems.append(f"usage {u} > max_tokens {want}")
|
|
922
|
+
if u is not None and st is not None and st > u + slack:
|
|
923
|
+
problems.append(f"streamed {st} > usage {u}")
|
|
924
|
+
if r.finish_reason == "length" and u is not None and u < want:
|
|
925
|
+
problems.append(f"finish_reason 'length' with usage {u} < max_tokens {want}")
|
|
926
|
+
if not wrong and not problems:
|
|
927
|
+
continue
|
|
928
|
+
if not problems and unstreamed(u, st, how, r.finish_reason, want):
|
|
929
|
+
# usage says max_tokens and the reply was cut there, but some counted tokens never reached the
|
|
930
|
+
# logprob stream (an engine that skips special tokens after EOS); the stream cannot settle it,
|
|
931
|
+
# re-tokenized text that covers max_tokens can.
|
|
932
|
+
retok = count_tokens(r.full_text) if count_tokens is not None else None
|
|
933
|
+
(by_text if text_covers(retok, u, 0.02) else short_stream).append(
|
|
934
|
+
{**row, "retokenized": retok, "problem": f"{u - st} {UNSTREAMED_EOS}"})
|
|
935
|
+
elif not problems and r.finish_reason == "stop" and not disagree and n is not None and n < want:
|
|
936
|
+
stop_short.append(row) # ended at EOS with consistent counts: ignore_eos not supported
|
|
937
|
+
else:
|
|
938
|
+
bad.append({**row, "problem": "; ".join(problems) or f"length {n} != max_tokens {want}"})
|
|
939
|
+
metrics = {"ignore_eos_requests": len(rows), "exact": len(rows) - len(bad) - len(stop_short) - len(short_stream),
|
|
940
|
+
"unstreamed_at_limit": len(short_stream), "unstreamed_settled_by_text": len(by_text),
|
|
941
|
+
"failed_probes": len(failed)}
|
|
942
|
+
lim = [f"{usage_only} length(s) known only from usage (no token ids, logprobs or tokenizer)"] if usage_only else []
|
|
943
|
+
if by_text:
|
|
944
|
+
lim.append(f"{len(by_text)} ignore_eos repl(ies) streamed fewer logprob entries than max_tokens "
|
|
945
|
+
f"({unstreamed_note(by_text, after_eos=True)}), but their re-tokenized text covers max_tokens: the "
|
|
946
|
+
"length is proven by the text, not the stream")
|
|
947
|
+
if bad:
|
|
948
|
+
return _gate("output_length", "FAIL", f"{len(bad)}/{len(rows)} ignore_eos replies are not max_tokens long "
|
|
949
|
+
"(finish_reason 'length' below max_tokens, usage above it, or usage disagrees with the stream)",
|
|
950
|
+
metrics, bad + short_stream + failed, lim)
|
|
951
|
+
if failed:
|
|
952
|
+
return _gate("output_length", "UNPROVEN", f"{_fail_note(failed, 'ignore_eos request')}: the engine refused "
|
|
953
|
+
"exact-length requests, so fixed-length runs cannot be checked live (compare lengths against a "
|
|
954
|
+
"reference engine: gates run --run-dir --reference-engine)", metrics, failed + rows, lim)
|
|
955
|
+
unsure = []
|
|
956
|
+
if stop_short:
|
|
957
|
+
unsure.append(f"{len(stop_short)}/{len(rows)} ignore_eos replies stopped at EOS (finish_reason 'stop', counts "
|
|
958
|
+
"consistent): the engine does not honour ignore_eos, so fixed-length runs are not fixed length; "
|
|
959
|
+
"compare lengths against a reference engine instead (gates run --run-dir --reference-engine)")
|
|
960
|
+
if short_stream:
|
|
961
|
+
unsure.append(f"{len(short_stream)}/{len(rows)} ignore_eos replies report usage = max_tokens and finish "
|
|
962
|
+
f"'length' but stream fewer logprob entries ({unstreamed_note(short_stream, after_eos=True)}): "
|
|
963
|
+
"the length is not proven by the stream (the engine returned no token ids and did not stream "
|
|
964
|
+
"the missing tokens with skip_special_tokens: false; compare lengths against a reference "
|
|
965
|
+
"engine: gates run --run-dir --reference-engine)")
|
|
966
|
+
if unsure:
|
|
967
|
+
return _gate("output_length", "UNPROVEN", "; ".join(unsure), metrics, stop_short + short_stream, lim)
|
|
968
|
+
if not rows:
|
|
969
|
+
return _gate("output_length", "NOT RUN", "no ignore_eos probe was sent", metrics)
|
|
970
|
+
verdict = "UNPROVEN" if usage_only == len(rows) else "PASS"
|
|
971
|
+
return _gate("output_length", verdict, f"{len(rows)} ignore_eos replies are exactly max_tokens long"
|
|
972
|
+
+ (" (by usage only)" if verdict == "UNPROVEN" else ""), metrics, rows, lim)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def check_logprob_entry(e: dict, greedy: bool) -> list[str]:
|
|
976
|
+
out = []
|
|
977
|
+
lp = e.get("logprob")
|
|
978
|
+
top = e.get("top_logprobs") or []
|
|
979
|
+
vals = [t.get("logprob") for t in top]
|
|
980
|
+
if not isinstance(lp, (int, float)) or not math.isfinite(lp) or lp > 1e-6:
|
|
981
|
+
out.append(f"chosen logprob {lp!r} not finite and <= 0")
|
|
982
|
+
if any(not isinstance(v, (int, float)) or not math.isfinite(v) or v > 1e-6 for v in vals):
|
|
983
|
+
out.append("a top logprob is not finite and <= 0")
|
|
984
|
+
return out
|
|
985
|
+
if vals:
|
|
986
|
+
if any(vals[i] < vals[i + 1] - 1e-6 for i in range(len(vals) - 1)):
|
|
987
|
+
out.append("top_logprobs not sorted")
|
|
988
|
+
mass = sum(math.exp(v) for v in vals)
|
|
989
|
+
if mass > 1 + 1e-3:
|
|
990
|
+
out.append(f"top-k probability mass {mass:.4f} > 1 (not normalised)")
|
|
991
|
+
if greedy and isinstance(lp, (int, float)) and lp < max(vals) - 1e-3:
|
|
992
|
+
out.append(f"greedy token logprob {lp:.4f} < top logprob {max(vals):.4f} (greedy token is not the argmax)")
|
|
993
|
+
if greedy and top and top[0].get("token") != e.get("token") and isinstance(lp, (int, float)) \
|
|
994
|
+
and abs(lp - vals[0]) > 1e-3:
|
|
995
|
+
out.append(f"greedy token {e.get('token')!r} != top-1 {top[0].get('token')!r}")
|
|
996
|
+
return out
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
HIDDEN_LIMIT = ("probes ran in their own sequential stream (concurrency 1), not inside the benchmark load: an engine "
|
|
1000
|
+
"that behaves only when idle is not caught here (mix probe_bodies() into the load and pass --run-dir "
|
|
1001
|
+
"to check their replies)")
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
# hidden_probes sampling rule. A confident model can return its greedy answer at T=1 by chance: on
|
|
1005
|
+
# 14 Oct, short extraction answers came back byte for byte equal to greedy from stock engines, on 2/3,
|
|
1006
|
+
# 0/3 and 1/3 prompts on three hosts (observed), so take p ~ 1/3 as the chance that both samples of a
|
|
1007
|
+
# prompt equal greedy (inferred). The old rule (FAIL when >= half of 3 prompts matched) then failed an
|
|
1008
|
+
# honest engine with P = 3p^2(1-p) + p^3 = 7/27, about 26% of runs (derived). FAIL now needs every one
|
|
1009
|
+
# of at least SAMPLING_MIN_FAIL prompts to match: with n = 8, P = p^8 = 1/6561, about 1.5e-4 (derived).
|
|
1010
|
+
# >= 3/4 matching is UNPROVEN (6-7 of 8: about 2% at p = 1/3, derived). An engine that ignores
|
|
1011
|
+
# temperature matches on every prompt, so it still FAILs.
|
|
1012
|
+
SAMPLING_MIN_FAIL = 4
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def sampling_verdict(equal: int, n: int) -> tuple[str, str]:
|
|
1016
|
+
"""(verdict, reason) for ``equal`` of ``n`` prompts whose T=1 samples both equal the greedy output."""
|
|
1017
|
+
if n >= SAMPLING_MIN_FAIL and equal == n:
|
|
1018
|
+
return "FAIL", f"T=1 samples equal the greedy output on all {n} prompts (temperature ignored?)"
|
|
1019
|
+
if 4 * equal >= 3 * n:
|
|
1020
|
+
why = (f"with fewer than {SAMPLING_MIN_FAIL} prompts that can be chance" if n < SAMPLING_MIN_FAIL
|
|
1021
|
+
else "more than chance usually gives on a confident model, but not all, so temperature is not shown "
|
|
1022
|
+
"to be ignored")
|
|
1023
|
+
return "UNPROVEN", (f"T=1 samples equal the greedy output on {equal}/{n} prompts: {why} (rerun with more "
|
|
1024
|
+
"--sampling-prompts)")
|
|
1025
|
+
return "PASS", f"T=1 samples differ from greedy on {n - equal}/{n} prompts"
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def gate_hidden_probes(jobs: dict, where: str = "live") -> dict:
|
|
1029
|
+
by_key: dict[str, dict] = {}
|
|
1030
|
+
for j in jobs.get("sampling", []):
|
|
1031
|
+
by_key.setdefault(j.key, {})[j.kind] = j.reply
|
|
1032
|
+
samp_failed = _failed(jobs.get("sampling", []))
|
|
1033
|
+
rows, ignored, seed_ok, seed_checked = [], 0, 0, 0
|
|
1034
|
+
for k, d in by_key.items():
|
|
1035
|
+
g, a, a2, b = (d.get(x) for x in ("greedy", "sample_a", "sample_a2", "sample_b"))
|
|
1036
|
+
if not all(r and r.ok for r in (g, a, b)):
|
|
1037
|
+
continue
|
|
1038
|
+
same_as_greedy = a.full_text == g.full_text and b.full_text == g.full_text
|
|
1039
|
+
ignored += same_as_greedy
|
|
1040
|
+
seeded = None
|
|
1041
|
+
if a2 and a2.ok:
|
|
1042
|
+
seeded = a.full_text == a2.full_text
|
|
1043
|
+
seed_checked += 1
|
|
1044
|
+
seed_ok += bool(seeded)
|
|
1045
|
+
rows.append({"key": k, "samples_equal_greedy": same_as_greedy, "seed_reproduced": seeded,
|
|
1046
|
+
"seeds_differ": a.full_text != b.full_text})
|
|
1047
|
+
sub, details = [], list(rows)
|
|
1048
|
+
lim = [HIDDEN_LIMIT] if where == "live" else []
|
|
1049
|
+
metrics = {"sampling_prompts": len(rows), "samples_equal_greedy": ignored,
|
|
1050
|
+
"seed_reproduced": f"{seed_ok}/{seed_checked}", "failed_probes": len(samp_failed)}
|
|
1051
|
+
if rows:
|
|
1052
|
+
sub.append(sampling_verdict(ignored, len(rows)))
|
|
1053
|
+
if len(rows) < SAMPLING_MIN_FAIL:
|
|
1054
|
+
lim.append(f"only {len(rows)} sampling prompt(s) came back complete: too few to FAIL an engine that "
|
|
1055
|
+
f"ignores temperature (at least {SAMPLING_MIN_FAIL} are needed; the default is "
|
|
1056
|
+
f"{SAMPLING_DEFAULT})")
|
|
1057
|
+
if seed_checked and seed_ok < seed_checked:
|
|
1058
|
+
lim.append(f"the same seed reproduced the sample on {seed_ok}/{seed_checked} prompts: the engine ignores "
|
|
1059
|
+
"`seed` or samples nondeterministically (seeded comparisons are not possible)")
|
|
1060
|
+
refused = sorted({k for j in jobs.get("sampling", []) if j.reply
|
|
1061
|
+
for k in j.reply.dropped if k in ("top_p", "top_k")})
|
|
1062
|
+
if refused:
|
|
1063
|
+
lim.append(f"the server refused {' and '.join(refused)} on the T=1 samples (top_p 1 and top_k -1 ask for the "
|
|
1064
|
+
f"whole distribution), so they used its default {' and '.join(refused)}: a default that cuts the "
|
|
1065
|
+
"distribution makes samples equal to greedy more likely")
|
|
1066
|
+
if samp_failed:
|
|
1067
|
+
sub.append(("UNPROVEN", _fail_note(samp_failed, "sampling probe")))
|
|
1068
|
+
details += samp_failed
|
|
1069
|
+
elif not rows:
|
|
1070
|
+
sub.append(("UNPROVEN", "no sampling probe group came back complete"))
|
|
1071
|
+
lp_jobs = jobs.get("logprobs", [])
|
|
1072
|
+
lp_failed = _failed(lp_jobs)
|
|
1073
|
+
lp_missing = [j.key for j in lp_jobs if j.reply and j.reply.ok and not j.reply.lp]
|
|
1074
|
+
lp_bad, lp_entries, lp_replies = [], 0, 0
|
|
1075
|
+
for j in lp_jobs:
|
|
1076
|
+
r = j.reply
|
|
1077
|
+
if not r or not r.ok or not r.lp:
|
|
1078
|
+
continue
|
|
1079
|
+
lp_replies += 1
|
|
1080
|
+
for pos, e in enumerate(r.lp):
|
|
1081
|
+
lp_entries += 1
|
|
1082
|
+
for prob in check_logprob_entry(e, greedy=True):
|
|
1083
|
+
lp_bad.append({"key": j.key, "position": pos, "problem": prob})
|
|
1084
|
+
metrics.update(logprob_replies=lp_replies, logprob_positions=lp_entries, logprob_violations=len(lp_bad),
|
|
1085
|
+
logprob_failed=len(lp_failed), logprob_missing=len(lp_missing))
|
|
1086
|
+
if lp_bad:
|
|
1087
|
+
sub.append(("FAIL", f"{len(lp_bad)} logprob violations in {lp_entries} positions"))
|
|
1088
|
+
details += lp_bad[:50]
|
|
1089
|
+
elif lp_replies:
|
|
1090
|
+
sub.append(("PASS", f"{lp_entries} positions: logprobs finite, sorted, normalised, greedy == argmax"))
|
|
1091
|
+
if lp_failed:
|
|
1092
|
+
sub.append(("UNPROVEN", _fail_note(lp_failed, "logprob probe")))
|
|
1093
|
+
details += lp_failed
|
|
1094
|
+
if lp_missing:
|
|
1095
|
+
sub.append(("UNPROVEN", f"{len(lp_missing)}/{len(lp_jobs)} logprob probes came back without logprobs "
|
|
1096
|
+
"(logprobs were requested): the logprob check could not run"))
|
|
1097
|
+
if not lp_jobs:
|
|
1098
|
+
sub.append(("UNPROVEN", "no logprob probe"))
|
|
1099
|
+
verdict = combine_claims(v for v, _ in sub)
|
|
1100
|
+
return _gate("hidden_probes", verdict, "; ".join(f"{v}: {why}" for v, why in sub), metrics, details, lim)
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def gate_rare(jobs: dict) -> dict:
|
|
1104
|
+
# A probe is judged on the rare string itself (meta "core"); the random ASCII nonces around it only
|
|
1105
|
+
# make it unpredictable. In the 13-14 Oct records all 3 rare_tokens FAILs (two stock vLLM configs and
|
|
1106
|
+
# one custom engine, none with a reference engine; observed) were one miss each: the model cut the
|
|
1107
|
+
# trailing nonce ("... 72Ry" -> "... 72R", or dropped it) and ended at EOS well under max_tokens, with
|
|
1108
|
+
# the CJK / emoji / Hangul string intact. A pruned vocabulary or logit mask cannot cause that slip.
|
|
1109
|
+
ref = {j.key: j.reply for j in jobs.get("rare_reference", [])}
|
|
1110
|
+
rows, misses, ok_n, refused, slips = [], [], 0, 0, []
|
|
1111
|
+
for j in jobs.get("rare", []):
|
|
1112
|
+
r = j.reply
|
|
1113
|
+
rr = ref.get(j.key)
|
|
1114
|
+
core = j.meta.get("core") or j.meta["target"]
|
|
1115
|
+
ref_errored = rr is not None and not rr.ok
|
|
1116
|
+
ref_hit = reproduced(core, rr.full_text) if rr is not None and rr.ok else None
|
|
1117
|
+
row = {"key": j.key, "class": j.meta["class"], "target": j.meta["target"], "reference_reproduced": ref_hit}
|
|
1118
|
+
if not r or not r.ok:
|
|
1119
|
+
# A refused rare string is a miss (it is exactly what a pruned vocabulary would want to
|
|
1120
|
+
# hide), unless the reference engine refuses it too.
|
|
1121
|
+
refused += 1
|
|
1122
|
+
row.update(reproduced=False, refused=True, status=r.status if r else None,
|
|
1123
|
+
error=((r.error if r else "not sent") or "")[:200], reference_refused=ref_errored)
|
|
1124
|
+
rows.append(row)
|
|
1125
|
+
if not ref_errored:
|
|
1126
|
+
misses.append(row)
|
|
1127
|
+
continue
|
|
1128
|
+
ok_n += 1
|
|
1129
|
+
verbatim = reproduced(j.meta["target"], r.full_text)
|
|
1130
|
+
hit = verbatim or reproduced(core, r.full_text)
|
|
1131
|
+
row.update(reproduced=hit, nonces_intact=verbatim, got=r.full_text[:200])
|
|
1132
|
+
rows.append(row)
|
|
1133
|
+
if hit and not verbatim:
|
|
1134
|
+
slips.append(row)
|
|
1135
|
+
if not hit and ref_hit is not False:
|
|
1136
|
+
misses.append(row)
|
|
1137
|
+
if not rows:
|
|
1138
|
+
return _gate("rare_tokens", "NOT RUN", "no rare-string probe was sent")
|
|
1139
|
+
lim = [] if ref else ["no reference engine: a miss may be the model's own weakness (pass --reference-url)"]
|
|
1140
|
+
if slips:
|
|
1141
|
+
lim.append(f"{len(slips)} rare string(s) came back intact inside a slipped random ASCII nonce (a model copy "
|
|
1142
|
+
"error; an engine that drops rare tokens keeps plain ASCII), not counted as misses")
|
|
1143
|
+
metrics = {"probes": len(rows), "answered": ok_n, "refused": refused,
|
|
1144
|
+
"reproduced": sum(1 for r in rows if r["reproduced"]), "nonce_slips": len(slips),
|
|
1145
|
+
"counted_misses": len(misses)}
|
|
1146
|
+
if misses:
|
|
1147
|
+
classes = sorted({m["class"] for m in misses})
|
|
1148
|
+
n_ref = sum(1 for m in misses if m.get("refused"))
|
|
1149
|
+
return _gate("rare_tokens", "FAIL", f"{len(misses)}/{len(rows)} rare strings not reproduced verbatim "
|
|
1150
|
+
f"({', '.join(classes)})" + (f"; {n_ref} of them refused with an error" if n_ref else ""),
|
|
1151
|
+
metrics, misses, lim)
|
|
1152
|
+
n_rep = metrics["reproduced"]
|
|
1153
|
+
said = f"{n_rep} rare strings reproduced verbatim" + (f" ({len(slips)} inside a slipped ASCII nonce)" if slips
|
|
1154
|
+
else "")
|
|
1155
|
+
shared = ok_n - n_rep
|
|
1156
|
+
said += f"; {shared} missed by the reference engine too (not counted)" if shared else ""
|
|
1157
|
+
if refused:
|
|
1158
|
+
return _gate("rare_tokens", "UNPROVEN", f"{said}; {refused} refused by both engines (not judged)", metrics,
|
|
1159
|
+
[r for r in rows if r.get("refused")] + slips, lim)
|
|
1160
|
+
return _gate("rare_tokens", "PASS", said, metrics, slips, lim)
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
# --------------------------------------------------------------------------- spec exactness
|
|
1164
|
+
|
|
1165
|
+
def spec_exact(a: Client, b: Client, items: list[dict], max_tokens: int, early_tokens: int, tolerance: int,
|
|
1166
|
+
log: list) -> dict:
|
|
1167
|
+
"""A (speculative) vs B (plain), and B vs B again for the A/A floor, greedy, same prompts."""
|
|
1168
|
+
rows = []
|
|
1169
|
+
for it in items:
|
|
1170
|
+
msgs = it["messages"]
|
|
1171
|
+
rb = b.send(b.body(msgs, max_tokens))
|
|
1172
|
+
ra = a.send(a.body(msgs, max_tokens))
|
|
1173
|
+
rb2 = b.send(b.body(msgs, max_tokens))
|
|
1174
|
+
for tag, r in (("B", rb), ("A", ra), ("B2", rb2)):
|
|
1175
|
+
log.append({"kind": f"spec_{tag}", "key": it["id"], "reply": r.summary()})
|
|
1176
|
+
if not (ra.ok and rb.ok and rb2.ok):
|
|
1177
|
+
rows.append({"id": it["id"], "error": ra.error or rb.error or rb2.error})
|
|
1178
|
+
continue
|
|
1179
|
+
sa, ma = _sequence(ra)
|
|
1180
|
+
sb, mb = _sequence(rb)
|
|
1181
|
+
sb2, _ = _sequence(rb2)
|
|
1182
|
+
if ma != mb: # compare like with like
|
|
1183
|
+
sa, sb, sb2, ma = list(ra.full_text), list(rb.full_text), list(rb2.full_text), "characters"
|
|
1184
|
+
rows.append({"id": it["id"], "bucket": it.get("bucket"), "method": ma,
|
|
1185
|
+
"ab_first_divergence": first_divergence(sa, sb),
|
|
1186
|
+
"aa_first_divergence": first_divergence(sb, sb2), "len_a": len(sa), "len_b": len(sb)})
|
|
1187
|
+
ok = [r for r in rows if "error" not in r]
|
|
1188
|
+
errs = [r for r in rows if "error" in r]
|
|
1189
|
+
if not ok:
|
|
1190
|
+
return _gate("spec_exact", "UNPROVEN" if rows else "NOT RUN", "no prompt got three successful replies"
|
|
1191
|
+
+ (f" ({len(errs)} failed; first error: {str(errs[0]['error'])[:160]})" if errs else ""), {}, rows)
|
|
1192
|
+
|
|
1193
|
+
def early(key):
|
|
1194
|
+
return sum(1 for r in ok if r[key] is not None and r[key] < early_tokens)
|
|
1195
|
+
|
|
1196
|
+
ab_div = [r["ab_first_divergence"] for r in ok if r["ab_first_divergence"] is not None]
|
|
1197
|
+
aa_div = [r["aa_first_divergence"] for r in ok if r["aa_first_divergence"] is not None]
|
|
1198
|
+
e_ab, e_aa = early("ab_first_divergence"), early("aa_first_divergence")
|
|
1199
|
+
metrics = {"prompts": len(ok), "ab_identical": len(ok) - len(ab_div), "aa_identical": len(ok) - len(aa_div),
|
|
1200
|
+
"ab_early_divergences": e_ab, "aa_early_divergences": e_aa, "early_tokens": early_tokens,
|
|
1201
|
+
"ab_first_divergence_median": _median(ab_div), "aa_first_divergence_median": _median(aa_div),
|
|
1202
|
+
"methods": sorted({r["method"] for r in ok})}
|
|
1203
|
+
lim = []
|
|
1204
|
+
if "characters" in metrics["methods"]:
|
|
1205
|
+
lim.append("no token ids or logprobs: compared characters (divergence positions are in characters)")
|
|
1206
|
+
diverged = [r for r in ok if r["ab_first_divergence"] is not None][:50]
|
|
1207
|
+
if e_ab > e_aa + tolerance:
|
|
1208
|
+
return _gate("spec_exact", "FAIL", f"{e_ab}/{len(ok)} prompts diverge from the non-speculative engine before "
|
|
1209
|
+
f"token {early_tokens}; the A/A floor is {e_aa} (+{tolerance} allowed)", metrics,
|
|
1210
|
+
diverged + errs, lim)
|
|
1211
|
+
# Late divergences: one prompt in eight above the A/A floor is allowed for reordered float
|
|
1212
|
+
# sums in the verify pass; more is not proven exact (the quality gates must decide).
|
|
1213
|
+
late_allow = tolerance + max(1, len(ok) // 8)
|
|
1214
|
+
metrics["late_allowed"] = late_allow
|
|
1215
|
+
if len(ab_div) > len(aa_div) + late_allow:
|
|
1216
|
+
return _gate("spec_exact", "UNPROVEN", f"{len(ab_div)}/{len(ok)} prompts diverge from the non-speculative "
|
|
1217
|
+
f"engine (all at or after token {early_tokens}) vs an A/A floor of {len(aa_div)} (+{late_allow} "
|
|
1218
|
+
"allowed): speculation is not shown to be exact; compare quality before publishing", metrics,
|
|
1219
|
+
diverged + errs, lim)
|
|
1220
|
+
if errs:
|
|
1221
|
+
return _gate("spec_exact", "UNPROVEN", f"{len(errs)} prompt(s) failed (first error: "
|
|
1222
|
+
f"{str(errs[0]['error'])[:160]}); on the other {len(ok)}: {len(ok) - len(ab_div)} identical",
|
|
1223
|
+
metrics, diverged + errs, lim)
|
|
1224
|
+
return _gate("spec_exact", "PASS", f"{len(ok) - len(ab_div)}/{len(ok)} identical; early divergences {e_ab} <= "
|
|
1225
|
+
f"A/A floor {e_aa} + {tolerance}; any divergence {len(ab_div)} <= A/A {len(aa_div)} + {late_allow}",
|
|
1226
|
+
metrics, diverged, lim)
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
# --------------------------------------------------------------------------- static run-dir checks
|
|
1230
|
+
|
|
1231
|
+
def _read_json(p: Path):
|
|
1232
|
+
try:
|
|
1233
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
1234
|
+
except (OSError, ValueError):
|
|
1235
|
+
return None
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _out_len(o: dict) -> tuple[int | None, str | None]:
|
|
1239
|
+
"""Generated length of a saved output, or (None, None) when it is unreadable or the request failed."""
|
|
1240
|
+
if o.get("error"):
|
|
1241
|
+
return None, None
|
|
1242
|
+
if isinstance(o.get("token_ids"), list):
|
|
1243
|
+
return len(o["token_ids"]), "token_ids"
|
|
1244
|
+
u = (o.get("usage") or {}).get("completion_tokens")
|
|
1245
|
+
return (u, "usage") if isinstance(u, int) else (None, None)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def outputs_of(root: Path) -> dict[str, dict]:
|
|
1249
|
+
"""{id: output} for outputs/<engine>/<bucket>/<id>.json (root = outputs/<engine>; load-* and probes skipped)."""
|
|
1250
|
+
out = {}
|
|
1251
|
+
if root.is_dir():
|
|
1252
|
+
for p in sorted(root.glob("*/*.json")):
|
|
1253
|
+
if p.parent.name.startswith("load-") or p.parent.name == "probes":
|
|
1254
|
+
continue
|
|
1255
|
+
o = _read_json(p)
|
|
1256
|
+
out[p.stem] = {**o, "_bucket": p.parent.name} if isinstance(o, dict) else {"_bucket": p.parent.name,
|
|
1257
|
+
"_unreadable": True}
|
|
1258
|
+
return out
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def static_counts(run_dir: Path, engine: str) -> list[dict]:
|
|
1262
|
+
rows = []
|
|
1263
|
+
for k, o in outputs_of(run_dir / "outputs" / engine).items():
|
|
1264
|
+
if o.get("_unreadable") or o.get("error"):
|
|
1265
|
+
continue
|
|
1266
|
+
ids = o.get("token_ids")
|
|
1267
|
+
u = (o.get("usage") or {}).get("completion_tokens")
|
|
1268
|
+
rows.append({"key": f"run:{k}", "usage": u if isinstance(u, int) else None,
|
|
1269
|
+
"streamed": len(ids) if isinstance(ids, list) else None,
|
|
1270
|
+
"streamed_method": "token_ids" if isinstance(ids, list) else None, "retokenized": None,
|
|
1271
|
+
"finish_reason": o.get("finish_reason")})
|
|
1272
|
+
return rows
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _inputs_of(run_dir: Path, engine: str) -> dict[str, dict]:
|
|
1276
|
+
"""{id: request body}: inputs/<engine>/<bucket>/ (bench serve) else the shared inputs/<bucket>/."""
|
|
1277
|
+
out: dict[str, dict] = {}
|
|
1278
|
+
for root in (run_dir / "inputs", run_dir / "inputs" / engine): # the engine's own copy wins
|
|
1279
|
+
if not root.is_dir():
|
|
1280
|
+
continue
|
|
1281
|
+
for p in sorted(root.glob("*/*.json")):
|
|
1282
|
+
if p.parent.name.startswith("load-"):
|
|
1283
|
+
continue
|
|
1284
|
+
body = _read_json(p)
|
|
1285
|
+
if isinstance(body, dict) and ("max_tokens" in body or "messages" in body or "prompt" in body):
|
|
1286
|
+
out[p.stem] = body
|
|
1287
|
+
return out
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def _exact_length(req: dict) -> int | None:
|
|
1291
|
+
"""max_tokens when the request forces an exact length (ignore_eos, or min_tokens == max_tokens)."""
|
|
1292
|
+
mt = req.get("max_tokens") if isinstance(req.get("max_tokens"), int) else req.get("max_completion_tokens")
|
|
1293
|
+
if not isinstance(mt, int):
|
|
1294
|
+
return None
|
|
1295
|
+
if req.get("ignore_eos") or req.get("min_tokens") == mt:
|
|
1296
|
+
return mt
|
|
1297
|
+
return None
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def gate_output_length_static(run_dir: Path, engine: str, reference: Path | None, tol: float,
|
|
1301
|
+
max_outliers: float) -> dict:
|
|
1302
|
+
cand = outputs_of(run_dir / "outputs" / engine)
|
|
1303
|
+
if not cand:
|
|
1304
|
+
return _gate("output_length", "NOT RUN", f"no outputs under outputs/{engine}/")
|
|
1305
|
+
sub, details, metrics = [], [], {}
|
|
1306
|
+
# Exact-length requests must come back exactly max_tokens long; a missing or failed output is unproven.
|
|
1307
|
+
eos_bad, eos_missing, eos_n = [], [], 0
|
|
1308
|
+
for k, req in _inputs_of(run_dir, engine).items():
|
|
1309
|
+
want = _exact_length(req)
|
|
1310
|
+
if want is None:
|
|
1311
|
+
continue
|
|
1312
|
+
eos_n += 1
|
|
1313
|
+
o = cand.get(k)
|
|
1314
|
+
n, how = _out_len(o) if o and not o.get("_unreadable") else (None, None)
|
|
1315
|
+
if n is None:
|
|
1316
|
+
eos_missing.append({"id": k, "max_tokens": want, "problem": "no readable output" if not o or
|
|
1317
|
+
o.get("_unreadable") else f"request failed: {str(o.get('error'))[:120]}"})
|
|
1318
|
+
elif n != want:
|
|
1319
|
+
eos_bad.append({"id": k, "max_tokens": want, "length": n, "method": how})
|
|
1320
|
+
if eos_n:
|
|
1321
|
+
v = "FAIL" if eos_bad else ("UNPROVEN" if eos_missing else "PASS")
|
|
1322
|
+
sub.append((v, f"exact length: {eos_n - len(eos_bad) - len(eos_missing)}/{eos_n} outputs exactly max_tokens "
|
|
1323
|
+
f"long, {len(eos_bad)} wrong, {len(eos_missing)} missing or failed"))
|
|
1324
|
+
details += eos_bad[:50] + eos_missing[:50]
|
|
1325
|
+
metrics.update(exact_length_outputs=eos_n, exact_length_wrong=len(eos_bad), exact_length_missing=len(eos_missing))
|
|
1326
|
+
if reference is not None:
|
|
1327
|
+
ref = outputs_of(reference)
|
|
1328
|
+
ratios, outl, missing, ref_n = [], [], [], 0
|
|
1329
|
+
for k, r_o in ref.items():
|
|
1330
|
+
m, _ = _out_len(r_o) if not r_o.get("_unreadable") else (None, None)
|
|
1331
|
+
if m is None:
|
|
1332
|
+
continue # the reference itself has no length here: nothing to compare against
|
|
1333
|
+
ref_n += 1
|
|
1334
|
+
o = cand.get(k)
|
|
1335
|
+
n, _ = _out_len(o) if o and not o.get("_unreadable") else (None, None)
|
|
1336
|
+
if n is None:
|
|
1337
|
+
# A prompt the candidate did not answer is a failure, not a skipped row.
|
|
1338
|
+
missing.append({"id": k, "reference_length": m, "problem": "no readable candidate output" if not o or
|
|
1339
|
+
o.get("_unreadable") else f"candidate request failed: {str(o.get('error'))[:120]}"})
|
|
1340
|
+
continue
|
|
1341
|
+
ratio = n / m if m else (1.0 if n == 0 else None)
|
|
1342
|
+
ratios.append(ratio if ratio is not None else 0.0)
|
|
1343
|
+
if ratio is None or abs(ratio - 1) > tol:
|
|
1344
|
+
outl.append({"id": k, "length": n, "reference_length": m,
|
|
1345
|
+
"ratio": round(ratio, 4) if ratio is not None else None})
|
|
1346
|
+
coverage = (ref_n - len(missing)) / ref_n if ref_n else None
|
|
1347
|
+
metrics.update(reference_outputs=ref_n, reference_pairs=len(ratios), outside_tolerance=len(outl),
|
|
1348
|
+
missing_outputs=len(missing), coverage=coverage, length_ratio_median=_median(ratios))
|
|
1349
|
+
if ref_n:
|
|
1350
|
+
off = len(outl) + len(missing) # missing outputs count as outliers
|
|
1351
|
+
frac = off / ref_n
|
|
1352
|
+
total_ratio = sum(ratios) / ref_n # missing outputs count as length 0
|
|
1353
|
+
bad = frac > max_outliers or abs(total_ratio - 1) > tol
|
|
1354
|
+
v = "FAIL" if bad else ("UNPROVEN" if missing else "PASS")
|
|
1355
|
+
sub.append((v, f"{len(outl)}/{ref_n} prompts outside +/-{tol:.0%} of the reference length and "
|
|
1356
|
+
f"{len(missing)} without a candidate output (together {frac:.0%}, allowed "
|
|
1357
|
+
f"{max_outliers:.0%}); coverage {coverage:.0%}; mean ratio {total_ratio:.3f}"))
|
|
1358
|
+
details += outl[:50] + missing[:50]
|
|
1359
|
+
else:
|
|
1360
|
+
sub.append(("NOT RUN", "the reference outputs carry no lengths"))
|
|
1361
|
+
if not sub:
|
|
1362
|
+
return _gate("output_length", "NOT RUN", "no exact-length requests and no reference outputs given", metrics)
|
|
1363
|
+
return _gate("output_length", combine_claims(v for v, _ in sub), "; ".join(f"{v}: {w}" for v, w in sub),
|
|
1364
|
+
metrics, details)
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _load_requests(run_dir: Path) -> list[dict]:
|
|
1368
|
+
"""One row per load request: {"source", "engine", "level", "key", "salt", "prompt"}.
|
|
1369
|
+
|
|
1370
|
+
bench serve layout first (inputs/<engine>/load-<level>/<bucket>/<id>[~k].json request bodies,
|
|
1371
|
+
else outputs/<engine>/load-<level>/...), then any *schedule*.jsonl / *requests*.jsonl."""
|
|
1372
|
+
rows = []
|
|
1373
|
+
for kind in ("inputs", "outputs"):
|
|
1374
|
+
base = run_dir / kind
|
|
1375
|
+
found = False
|
|
1376
|
+
for d in sorted(base.glob("*/load-*")) if base.is_dir() else []:
|
|
1377
|
+
if not d.is_dir():
|
|
1378
|
+
continue
|
|
1379
|
+
found = True
|
|
1380
|
+
engine, level = d.parent.name, d.name[len("load-"):]
|
|
1381
|
+
for p in sorted(d.glob("*/*.json")):
|
|
1382
|
+
o = _read_json(p)
|
|
1383
|
+
if not isinstance(o, dict):
|
|
1384
|
+
continue
|
|
1385
|
+
pid = o.get("id") or p.stem.split("~")[0]
|
|
1386
|
+
if kind == "inputs" and (o.get("messages") is not None or o.get("prompt") is not None):
|
|
1387
|
+
key = hashlib.sha256(json.dumps(o.get("messages", o.get("prompt")), ensure_ascii=False,
|
|
1388
|
+
sort_keys=True).encode("utf-8")).hexdigest()
|
|
1389
|
+
else:
|
|
1390
|
+
key = f"{pid}|{o.get('salt') or ''}"
|
|
1391
|
+
salt = o.get("salt") if kind == "outputs" else _salt_of(o)
|
|
1392
|
+
rows.append({"source": str(p.relative_to(run_dir)), "engine": engine, "level": level, "key": key,
|
|
1393
|
+
"salt": salt, "prompt": pid})
|
|
1394
|
+
if found:
|
|
1395
|
+
return rows
|
|
1396
|
+
for f in sorted(p for p in run_dir.rglob("*.jsonl") if "schedule" in p.name or "requests" in p.name):
|
|
1397
|
+
for line in f.read_text(encoding="utf-8").splitlines():
|
|
1398
|
+
try:
|
|
1399
|
+
r = json.loads(line) if line.strip() else None
|
|
1400
|
+
except ValueError:
|
|
1401
|
+
continue
|
|
1402
|
+
if not isinstance(r, dict):
|
|
1403
|
+
continue
|
|
1404
|
+
level = r.get("level", r.get("concurrency", r.get("rate_rps")))
|
|
1405
|
+
if level is None:
|
|
1406
|
+
continue
|
|
1407
|
+
if r.get("prompt_sha256"):
|
|
1408
|
+
key = r["prompt_sha256"]
|
|
1409
|
+
elif r.get("messages") is not None or r.get("prompt") is not None:
|
|
1410
|
+
key = hashlib.sha256(json.dumps(r.get("messages", r.get("prompt")), ensure_ascii=False,
|
|
1411
|
+
sort_keys=True).encode("utf-8")).hexdigest()
|
|
1412
|
+
elif r.get("prompt_id", r.get("id")) is not None:
|
|
1413
|
+
key = f"{r.get('prompt_id', r.get('id'))}|{r.get('salt') or ''}"
|
|
1414
|
+
else:
|
|
1415
|
+
continue
|
|
1416
|
+
rows.append({"source": str(f.relative_to(run_dir)), "engine": r.get("engine") or str(
|
|
1417
|
+
f.relative_to(run_dir).parent), "level": level, "key": key, "salt": r.get("salt"),
|
|
1418
|
+
"prompt": r.get("prompt_id", r.get("id", key))})
|
|
1419
|
+
return rows
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _salt_of(body: dict) -> str | None:
|
|
1423
|
+
"""The bench serve reuse tag ("[ik-...] ") at the start of the first user message, if any."""
|
|
1424
|
+
for m in body.get("messages") or []:
|
|
1425
|
+
c = m.get("content") if isinstance(m, dict) else None
|
|
1426
|
+
if isinstance(c, str):
|
|
1427
|
+
if c.startswith("[ik-") and "] " in c[:40]:
|
|
1428
|
+
return c[1:c.index("] ")]
|
|
1429
|
+
return None
|
|
1430
|
+
return None
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def has_load_data(run_dir: Path) -> bool:
|
|
1434
|
+
return bool(_load_requests(run_dir))
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def gate_unique_prompts(run_dir: Path) -> dict:
|
|
1438
|
+
reqs = _load_requests(run_dir)
|
|
1439
|
+
if not reqs:
|
|
1440
|
+
return _gate("unique_prompts", "NOT RUN", "no load requests in the run dir (bench serve load-<level>/ "
|
|
1441
|
+
"inputs or outputs, or a *schedule*.jsonl / *requests*.jsonl)")
|
|
1442
|
+
seen: dict[tuple, dict] = {}
|
|
1443
|
+
repeats, salted = [], 0
|
|
1444
|
+
for r in reqs:
|
|
1445
|
+
scope = (r["engine"], r["level"])
|
|
1446
|
+
counts = seen.setdefault(scope, {})
|
|
1447
|
+
counts[r["key"]] = counts.get(r["key"], 0) + 1
|
|
1448
|
+
salted += bool(r["salt"])
|
|
1449
|
+
if counts[r["key"]] == 2:
|
|
1450
|
+
repeats.append({"source": r["source"], "engine": r["engine"], "level": r["level"], "prompt": r["prompt"]})
|
|
1451
|
+
metrics = {"requests": len(reqs), "levels": len(seen), "repeated_prompts": len(repeats), "salted_reuses": salted}
|
|
1452
|
+
lim = []
|
|
1453
|
+
if salted:
|
|
1454
|
+
lim.append(f"{salted} load request(s) reuse a prompt behind a unique tag (the prompt pool ran out): exact and "
|
|
1455
|
+
"prefix caches cannot serve them, a near-duplicate cache could")
|
|
1456
|
+
if repeats:
|
|
1457
|
+
return _gate("unique_prompts", "FAIL", f"{len(repeats)} prompt(s) repeat inside a load level (a cache would "
|
|
1458
|
+
"serve them)", metrics, repeats[:50], lim)
|
|
1459
|
+
return _gate("unique_prompts", "PASS", f"{len(reqs)} load requests over {len(seen)} level(s): no prompt repeats "
|
|
1460
|
+
"inside a level", metrics, [], lim)
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def static_probe_jobs(run_dir: Path, engine: str) -> dict:
|
|
1464
|
+
"""Hidden-probe replies a load runner saved under outputs/<engine>/probes/<id>.json (see probe_bodies)."""
|
|
1465
|
+
d = run_dir / "outputs" / engine / "probes"
|
|
1466
|
+
jobs: dict[str, list] = {}
|
|
1467
|
+
for p in sorted(d.glob("*.json")) if d.is_dir() else []:
|
|
1468
|
+
o = _read_json(p)
|
|
1469
|
+
if not isinstance(o, dict) or not o.get("kind"):
|
|
1470
|
+
continue
|
|
1471
|
+
lp = o.get("logprobs")
|
|
1472
|
+
lp = lp.get("content") if isinstance(lp, dict) else lp
|
|
1473
|
+
rep = Reply(status=200 if not o.get("error") else int(o.get("status") or 500), error=o.get("error"),
|
|
1474
|
+
text=o.get("text") or "", reasoning=o.get("reasoning") or "", token_ids=o.get("token_ids"),
|
|
1475
|
+
lp=lp if isinstance(lp, list) and lp else None, finish_reason=o.get("finish_reason"),
|
|
1476
|
+
usage=o.get("usage"))
|
|
1477
|
+
kind = o["kind"]
|
|
1478
|
+
job = Job(kind, o.get("group") or p.stem, {}, {}, rep)
|
|
1479
|
+
jobs.setdefault("logprobs" if kind == "logprobs" else "sampling", []).append(job)
|
|
1480
|
+
return jobs
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
# --------------------------------------------------------------------------- record
|
|
1484
|
+
|
|
1485
|
+
def _metrics_of(gates: list[dict]) -> list[dict]:
|
|
1486
|
+
out = []
|
|
1487
|
+
for g in gates:
|
|
1488
|
+
for k, v in g["metrics"].items():
|
|
1489
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
1490
|
+
continue
|
|
1491
|
+
unit = "x" if "speedup" in k or "ratio" in k else ("tokens" if "divergence" in k or "early" in k
|
|
1492
|
+
else "fraction" if "coverage" in k else "count")
|
|
1493
|
+
stat = "median" if "median" in k else "count"
|
|
1494
|
+
out.append(ev.metric(f"{g['gate']}.{k}", v, unit, stat))
|
|
1495
|
+
return out
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def overall(gates: list[dict]) -> str:
|
|
1499
|
+
"""The record's verdict. A gate the inputs asked for (or allowed) that ended NOT RUN counts as
|
|
1500
|
+
UNPROVEN: missing evidence is never a pass."""
|
|
1501
|
+
return combine_claims("UNPROVEN" if g["verdict"] == "NOT RUN" and g.get("applicable") else g["verdict"]
|
|
1502
|
+
for g in gates)
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _record(call, gates: list[dict], *, live: bool, engine: str | None, model: str | None, prompt_set: dict | None,
|
|
1506
|
+
log: list, extra: dict) -> dict:
|
|
1507
|
+
verdict = overall(gates)
|
|
1508
|
+
store = ev.Store()
|
|
1509
|
+
with store.staging_dir() as staging:
|
|
1510
|
+
artifacts = []
|
|
1511
|
+
if log:
|
|
1512
|
+
p = staging / "probes.jsonl"
|
|
1513
|
+
p.write_text("".join(json.dumps(x, ensure_ascii=False) + "\n" for x in log), encoding="utf-8")
|
|
1514
|
+
artifacts.append(ev.artifact(p, staging, "probe requests and replies"))
|
|
1515
|
+
lim = [f"{g['gate']}: {x}" for g in gates for x in g["limitations"]]
|
|
1516
|
+
missing = [g["gate"] for g in gates if g["verdict"] == "NOT RUN"]
|
|
1517
|
+
summary = f"gates {verdict}: " + ", ".join(f"{g['gate']} {g['verdict']}" for g in gates)
|
|
1518
|
+
rec = ev.build(call, kind=KIND, claim_class="observed" if live else "derived", summary=summary,
|
|
1519
|
+
identity_=ev.identity(engine={"name": engine}, model={"id": model}),
|
|
1520
|
+
metrics=_metrics_of(gates), artifacts=artifacts, missing=missing, limitations=lim,
|
|
1521
|
+
data={"verdict": verdict, "gates": {g["gate"]: g for g in gates}, "prompt_set": prompt_set,
|
|
1522
|
+
"not_run_but_expected": [g["gate"] for g in gates
|
|
1523
|
+
if g["verdict"] == "NOT RUN" and g.get("applicable")],
|
|
1524
|
+
"gates_format": "inferencekit.gates.v2", **extra})
|
|
1525
|
+
return store.save(rec, staging)
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _load_prompts(name_or_path: str) -> list[dict]:
|
|
1529
|
+
try:
|
|
1530
|
+
items = ps.load(name_or_path)
|
|
1531
|
+
except (OSError, ValueError) as e:
|
|
1532
|
+
raise OpError(f"cannot read prompt set {name_or_path!r}: {e}") from None
|
|
1533
|
+
if not items:
|
|
1534
|
+
raise OpError(f"prompt set {name_or_path!r} is empty")
|
|
1535
|
+
return items
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def _extra(text: str | None) -> dict:
|
|
1539
|
+
if not text:
|
|
1540
|
+
return {}
|
|
1541
|
+
try:
|
|
1542
|
+
val = json.loads(text)
|
|
1543
|
+
except ValueError:
|
|
1544
|
+
raise OpError("extra_body must be a JSON object") from None
|
|
1545
|
+
if not isinstance(val, dict):
|
|
1546
|
+
raise OpError("extra_body must be a JSON object")
|
|
1547
|
+
return val
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def _template_kwargs(extra: dict, flag: str | None, items: list[dict]) -> dict:
|
|
1551
|
+
"""chat_template_kwargs for every probe, merged per key: --extra-body's, --chat-template-kwargs, then the
|
|
1552
|
+
prompt records' own field when every record carries the same one (probes are built from the set's text,
|
|
1553
|
+
so a per-record value that differs between records cannot follow a probe)."""
|
|
1554
|
+
try:
|
|
1555
|
+
own = json.loads(flag) if flag else {}
|
|
1556
|
+
except ValueError:
|
|
1557
|
+
raise OpError("chat_template_kwargs must be a JSON object") from None
|
|
1558
|
+
if not isinstance(own, dict):
|
|
1559
|
+
raise OpError("chat_template_kwargs must be a JSON object")
|
|
1560
|
+
recs = [it.get("chat_template_kwargs") for it in items if isinstance(it, dict)]
|
|
1561
|
+
shared = recs[0] if recs and isinstance(recs[0], dict) and all(r == recs[0] for r in recs) else None
|
|
1562
|
+
if shared is None and any(isinstance(r, dict) for r in recs):
|
|
1563
|
+
raise OpError("prompt records carry different chat_template_kwargs; gates probes need one setting for "
|
|
1564
|
+
"the whole set (pass --chat-template-kwargs)")
|
|
1565
|
+
out: dict = {}
|
|
1566
|
+
for layer in (extra.get("chat_template_kwargs"), own, shared):
|
|
1567
|
+
if isinstance(layer, dict):
|
|
1568
|
+
out.update(layer)
|
|
1569
|
+
return out
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def _api_key(env_name: str | None) -> str | None:
|
|
1573
|
+
if not env_name:
|
|
1574
|
+
return None
|
|
1575
|
+
v = os.environ.get(env_name)
|
|
1576
|
+
if not v:
|
|
1577
|
+
raise OpError(f"environment variable {env_name} is empty")
|
|
1578
|
+
return v
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
@operation("run_gates", ("gates", "run"), "Anti-cheat gates: probe a live OpenAI-compatible endpoint (caching, "
|
|
1582
|
+
"token counts, exact lengths, sampling/logprob probes, rare-token pack) and check a bench serve run dir "
|
|
1583
|
+
"(unique prompts per load level, lengths vs the reference). Each gate: PASS/FAIL/UNPROVEN/NOT RUN; a "
|
|
1584
|
+
"refused probe is evidence, and an expected gate that did not run makes the verdict UNPROVEN.",
|
|
1585
|
+
[Arg("base_url", "string", "Endpoint base URL, e.g. http://127.0.0.1:8000 (live gates need it)."),
|
|
1586
|
+
Arg("model", "string", "Model name to send (default: the first id from /v1/models)."),
|
|
1587
|
+
Arg("engine", "string", "Engine label: names outputs/<engine>/ in the run dir and the record."),
|
|
1588
|
+
Arg("run_dir", "string", "bench serve run directory for the static checks."),
|
|
1589
|
+
Arg("reference_engine", "string", "Reference engine label under <run_dir>/outputs/ for length checks."),
|
|
1590
|
+
Arg("reference_outputs", "string", "Or: a reference outputs directory (<bucket>/<id>.json)."),
|
|
1591
|
+
Arg("reference_url", "string", "Reference endpoint for the rare-token pack (misses it shares are "
|
|
1592
|
+
"not counted)."),
|
|
1593
|
+
Arg("reference_api_key_env", "string", "Environment variable with the reference endpoint's API key "
|
|
1594
|
+
"(default: none)."),
|
|
1595
|
+
Arg("spec_off_url", "string", "Same model without speculative decoding: runs the spec_exact gate with "
|
|
1596
|
+
"base_url as the speculative engine."),
|
|
1597
|
+
Arg("prompts", "string", "Prompt set for the probes: 'dev' or a .jsonl path (use the sealed set for an "
|
|
1598
|
+
"evaluation).", default="dev"),
|
|
1599
|
+
Arg("gates", "array", "Gates to run (default: every gate the inputs allow).", choices=GATES),
|
|
1600
|
+
Arg("tokenizer", "string", "Hugging Face tokenizer id for re-tokenized counts (needs transformers)."),
|
|
1601
|
+
Arg("max_tokens", "integer", "Generation length of the probes.", default=64),
|
|
1602
|
+
Arg("cache_prompts", "integer", "Prompts replayed by the caching gate.", default=6),
|
|
1603
|
+
Arg("sampling_prompts", "integer", "hidden_probes: prompts sampled at T=1 (each sent greedy, twice with "
|
|
1604
|
+
"one seed, once with another). FAIL needs the samples to equal greedy on every prompt and at "
|
|
1605
|
+
"least 4 prompts; >= 3/4 equal is UNPROVEN. Default 8, or fewer (not below 4) when the prompt "
|
|
1606
|
+
"set is too small, recorded as a limitation."),
|
|
1607
|
+
Arg("threshold", "number", "Caching gate: largest allowed repeat speedup - 1.", default=0.10),
|
|
1608
|
+
Arg("length_tolerance", "number", "Per-prompt length tolerance vs the reference.", default=0.10),
|
|
1609
|
+
Arg("max_length_outliers", "number", "Share of prompts allowed outside the length tolerance (missing "
|
|
1610
|
+
"outputs count).", default=0.10),
|
|
1611
|
+
Arg("spec_limit", "integer", "spec_exact: prompts used (0 = all).", default=16),
|
|
1612
|
+
Arg("early_tokens", "integer", "spec_exact: a divergence before this token is 'early'.", default=32),
|
|
1613
|
+
Arg("spec_tolerance", "integer", "spec_exact: early divergences allowed above the A/A floor.", default=0),
|
|
1614
|
+
Arg("seed", "integer", "Probe seed (default: drawn at random for every run and recorded; pass it only to "
|
|
1615
|
+
"replay a run)."),
|
|
1616
|
+
Arg("echo_tokens", "integer", "max_tokens of the echo / rare-string probes (room for a reasoning "
|
|
1617
|
+
"model's preamble).", default=64),
|
|
1618
|
+
Arg("extra_body", "string", "JSON object merged into every request, e.g. "
|
|
1619
|
+
"'{\"chat_template_kwargs\": {\"enable_thinking\": false}}'."),
|
|
1620
|
+
Arg("api_key_env", "string", "Name of the environment variable holding the API key (value never stored)."),
|
|
1621
|
+
Arg("timeout", "number", "Per-request timeout in seconds.", default=120.0),
|
|
1622
|
+
Arg("chat_template_kwargs", "string", "JSON object sent as chat_template_kwargs on every request, e.g. "
|
|
1623
|
+
"'{\"enable_thinking\": false}' (merged over --extra-body's; a chat_template_kwargs field shared by "
|
|
1624
|
+
"every record of --prompts is merged over this, as bench serve does per request).")])
|
|
1625
|
+
def run_gates(call, base_url, model, engine, run_dir, reference_engine, reference_outputs, reference_url,
|
|
1626
|
+
reference_api_key_env, spec_off_url, prompts, gates, tokenizer, max_tokens, cache_prompts, threshold,
|
|
1627
|
+
length_tolerance, max_length_outliers, spec_limit, early_tokens, spec_tolerance, seed, echo_tokens,
|
|
1628
|
+
extra_body, api_key_env, timeout, chat_template_kwargs=None, sampling_prompts=None):
|
|
1629
|
+
wanted = set(gates or GATES)
|
|
1630
|
+
if not base_url and not run_dir:
|
|
1631
|
+
raise OpError("give base_url (live gates) and/or run_dir (static checks)")
|
|
1632
|
+
if sampling_prompts is not None and sampling_prompts < 1:
|
|
1633
|
+
raise OpError(f"sampling_prompts must be at least 1 (FAIL needs at least {SAMPLING_MIN_FAIL}; the default "
|
|
1634
|
+
f"is {SAMPLING_DEFAULT})")
|
|
1635
|
+
if "spec_exact" in wanted and not spec_off_url and gates:
|
|
1636
|
+
raise OpError("spec_exact needs spec_off_url (the same model without speculation)")
|
|
1637
|
+
key = _api_key(api_key_env)
|
|
1638
|
+
ref_key = _api_key(reference_api_key_env)
|
|
1639
|
+
extra = _extra(extra_body)
|
|
1640
|
+
count_tokens = load_tokenizer(tokenizer)
|
|
1641
|
+
seed = secrets.randbits(63) if seed is None else int(seed)
|
|
1642
|
+
results, log = [], []
|
|
1643
|
+
items = _load_prompts(prompts)
|
|
1644
|
+
ctk = apply_pin(_template_kwargs(extra, chat_template_kwargs, items), run_dir, "gates run")
|
|
1645
|
+
if ctk:
|
|
1646
|
+
extra["chat_template_kwargs"] = ctk
|
|
1647
|
+
rd = Path(run_dir) if run_dir else None
|
|
1648
|
+
if rd is not None and not rd.is_dir():
|
|
1649
|
+
raise OpError(f"run dir {rd} not found")
|
|
1650
|
+
# Gates the inputs ask for: named in --gates, or (by default) every gate the inputs make possible.
|
|
1651
|
+
if gates:
|
|
1652
|
+
expected = set(gates)
|
|
1653
|
+
else:
|
|
1654
|
+
expected = set()
|
|
1655
|
+
if base_url:
|
|
1656
|
+
expected |= LIVE - {"spec_exact"}
|
|
1657
|
+
if spec_off_url:
|
|
1658
|
+
expected.add("spec_exact")
|
|
1659
|
+
if rd is not None and engine:
|
|
1660
|
+
expected |= {"token_count", "output_length"}
|
|
1661
|
+
if rd is not None and has_load_data(rd):
|
|
1662
|
+
expected.add("unique_prompts")
|
|
1663
|
+
jobs: dict = {}
|
|
1664
|
+
client = None
|
|
1665
|
+
up, why_down = True, ""
|
|
1666
|
+
sampling_note = None
|
|
1667
|
+
if base_url:
|
|
1668
|
+
client = Client(base_url, model, key, timeout, extra=extra)
|
|
1669
|
+
ref_client = (Client(reference_url, None, ref_key, timeout, extra=extra)
|
|
1670
|
+
if reference_url and "rare_tokens" in wanted else None)
|
|
1671
|
+
n_sampling, sampling_note = sampling_count(len(probe_pool(items)), wanted & LIVE, cache_prompts,
|
|
1672
|
+
sampling_prompts)
|
|
1673
|
+
jobs = plan_and_run(client, items, gates=wanted & LIVE, max_tokens=max_tokens, seed=seed,
|
|
1674
|
+
n_cache=cache_prompts, log=log, reference=ref_client, echo_tokens=echo_tokens,
|
|
1675
|
+
n_sampling=n_sampling)
|
|
1676
|
+
up, why_down = alive(jobs)
|
|
1677
|
+
down = _gate("", "NOT RUN", f"the endpoint answered no probe: {why_down}")
|
|
1678
|
+
static_rows = static_counts(rd, engine) if rd is not None and engine else []
|
|
1679
|
+
static_probes = static_probe_jobs(rd, engine) if rd is not None and engine else {}
|
|
1680
|
+
|
|
1681
|
+
def live(name, fn):
|
|
1682
|
+
if not client:
|
|
1683
|
+
return _gate(name, "NOT RUN", "no base_url")
|
|
1684
|
+
return fn() if up else {**down, "gate": name}
|
|
1685
|
+
|
|
1686
|
+
for g in GATES:
|
|
1687
|
+
if g not in wanted:
|
|
1688
|
+
continue
|
|
1689
|
+
if g == "caching":
|
|
1690
|
+
res = live(g, lambda: gate_caching(jobs, threshold))
|
|
1691
|
+
elif g == "token_count":
|
|
1692
|
+
if (client and up) or static_rows:
|
|
1693
|
+
res = gate_token_count(jobs if up else {}, count_tokens, 0.02, static_rows)
|
|
1694
|
+
elif client:
|
|
1695
|
+
res = {**down, "gate": g}
|
|
1696
|
+
else:
|
|
1697
|
+
res = _gate(g, "NOT RUN", "no base_url and no run-dir outputs (pass --engine)")
|
|
1698
|
+
elif g == "output_length":
|
|
1699
|
+
parts = []
|
|
1700
|
+
if client:
|
|
1701
|
+
parts.append(live(g, lambda: gate_output_length_live(jobs, count_tokens)))
|
|
1702
|
+
if rd is not None and engine:
|
|
1703
|
+
ref = Path(reference_outputs) if reference_outputs else (
|
|
1704
|
+
rd / "outputs" / reference_engine if reference_engine else None)
|
|
1705
|
+
parts.append(gate_output_length_static(rd, engine, ref, length_tolerance, max_length_outliers))
|
|
1706
|
+
res = _merge(g, parts) if parts else _gate(g, "NOT RUN", "no base_url and no run dir + engine")
|
|
1707
|
+
elif g == "unique_prompts":
|
|
1708
|
+
res = gate_unique_prompts(rd) if rd is not None else _gate(g, "NOT RUN", "no run_dir")
|
|
1709
|
+
elif g == "spec_exact":
|
|
1710
|
+
if client and spec_off_url:
|
|
1711
|
+
off = Client(spec_off_url, None, key, timeout, extra=extra)
|
|
1712
|
+
sel = items[:spec_limit] if spec_limit else items
|
|
1713
|
+
res = spec_exact(client, off, sel, max_tokens, early_tokens, spec_tolerance, log)
|
|
1714
|
+
else:
|
|
1715
|
+
res = _gate(g, "NOT RUN", "needs base_url and spec_off_url (or use gates spec-exact)")
|
|
1716
|
+
elif g == "hidden_probes":
|
|
1717
|
+
parts = []
|
|
1718
|
+
if client:
|
|
1719
|
+
part = live(g, lambda: gate_hidden_probes(jobs))
|
|
1720
|
+
if sampling_note:
|
|
1721
|
+
part["limitations"] = [*part.get("limitations", []), sampling_note]
|
|
1722
|
+
parts.append(part)
|
|
1723
|
+
if static_probes:
|
|
1724
|
+
parts.append(gate_hidden_probes(static_probes, where="run dir"))
|
|
1725
|
+
res = _merge(g, parts) if parts else _gate(g, "NOT RUN", "no base_url and no probe replies in the run dir")
|
|
1726
|
+
else: # rare_tokens
|
|
1727
|
+
res = live(g, lambda: gate_rare(jobs))
|
|
1728
|
+
res["applicable"] = g in expected
|
|
1729
|
+
results.append(res)
|
|
1730
|
+
return _record(call, results, live=client is not None, engine=engine, model=client.model if client else model,
|
|
1731
|
+
prompt_set=ps.identity(prompts), log=log,
|
|
1732
|
+
extra={"endpoint_kind": "openai-chat" if client else None, "max_tokens": max_tokens,
|
|
1733
|
+
"seed": seed, "expected_gates": sorted(expected)})
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def _merge(name: str, parts: list[dict]) -> dict:
|
|
1737
|
+
"""One gate from its live and run-dir halves."""
|
|
1738
|
+
if len(parts) == 1:
|
|
1739
|
+
return parts[0]
|
|
1740
|
+
tags = ("live", "run dir")
|
|
1741
|
+
return _gate(name, combine_claims(p["verdict"] for p in parts),
|
|
1742
|
+
" | ".join(f"{t}: {p['verdict']}: {p['reason']}" for t, p in zip(tags, parts)),
|
|
1743
|
+
{f"{t.replace(' ', '_')}_{k}": v for t, p in zip(tags, parts) for k, v in p["metrics"].items()},
|
|
1744
|
+
[d for p in parts for d in p["details"]], [x for p in parts for x in p["limitations"]])
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
@operation("gates_spec_exact", ("gates", "spec-exact"), "Speculative-decoding exactness: greedy outputs of engine A "
|
|
1748
|
+
"(speculation on) vs B (off) on the prompt set; FAIL if early divergences exceed B's own A/A floor.",
|
|
1749
|
+
[Arg("a", "string", "Base URL of the engine with speculation / MTP.", required=True),
|
|
1750
|
+
Arg("b", "string", "Base URL of the same model without speculation.", required=True),
|
|
1751
|
+
Arg("model", "string", "Model name for A (default: first id from A's /v1/models)."),
|
|
1752
|
+
Arg("model_b", "string", "Model name for B (default: first id from B's /v1/models)."),
|
|
1753
|
+
Arg("prompts", "string", "Prompt set: 'dev' or a .jsonl path.", default="dev"),
|
|
1754
|
+
Arg("limit", "integer", "Use the first N prompts (0 = all).", default=0),
|
|
1755
|
+
Arg("max_tokens", "integer", "Greedy generation length.", default=128),
|
|
1756
|
+
Arg("early_tokens", "integer", "A divergence before this token counts as early.", default=32),
|
|
1757
|
+
Arg("tolerance", "integer", "Early divergences allowed above the A/A floor.", default=0),
|
|
1758
|
+
Arg("extra_body", "string", "JSON object merged into every request."),
|
|
1759
|
+
Arg("api_key_env", "string", "Name of the environment variable holding the API key."),
|
|
1760
|
+
Arg("timeout", "number", "Per-request timeout in seconds.", default=120.0)])
|
|
1761
|
+
def gates_spec_exact(call, a, b, model, model_b, prompts, limit, max_tokens, early_tokens, tolerance, extra_body,
|
|
1762
|
+
api_key_env, timeout):
|
|
1763
|
+
key = _api_key(api_key_env)
|
|
1764
|
+
extra = _extra(extra_body)
|
|
1765
|
+
ca, cb = Client(a, model, key, timeout, extra=extra), Client(b, model_b, key, timeout, extra=extra)
|
|
1766
|
+
items = _load_prompts(prompts)
|
|
1767
|
+
items = items[:limit] if limit else items
|
|
1768
|
+
log: list = []
|
|
1769
|
+
res = spec_exact(ca, cb, items, max_tokens, early_tokens, tolerance, log)
|
|
1770
|
+
res["applicable"] = True
|
|
1771
|
+
return _record(call, [res], live=True, engine=None, model=ca.model, prompt_set=ps.identity(prompts), log=log,
|
|
1772
|
+
extra={"endpoint_kind": "openai-chat", "max_tokens": max_tokens})
|