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,329 @@
|
|
|
1
|
+
"""A deterministic fake OpenAI-compatible server, for tests and dry runs of the quality pipeline.
|
|
2
|
+
|
|
3
|
+
It serves a toy "model" whose next-token distribution is a fixed function of the last
|
|
4
|
+
two tokens, so everything (greedy text, top-k logprobs, teacher-forced prompt logprobs,
|
|
5
|
+
exact KL between two fakes) is reproducible and computable in closed form.
|
|
6
|
+
|
|
7
|
+
Endpoints: ``GET /v1/models``, ``POST /tokenize`` (vLLM style, ``messages`` or
|
|
8
|
+
``prompt``), ``POST /v1/chat/completions`` (``logprobs``/``top_logprobs``, vLLM's
|
|
9
|
+
``return_tokens_as_token_ids`` and ``return_token_ids``), ``POST /v1/completions`` with
|
|
10
|
+
a token-id prompt and vLLM's ``prompt_logprobs``.
|
|
11
|
+
|
|
12
|
+
``perturb`` shifts the logits by a fixed pattern: a fake with ``perturb=0`` plays the
|
|
13
|
+
reference; one with ``perturb > 0`` plays a lower-precision engine. ``sharpness`` scales
|
|
14
|
+
the logits (> 1 gives the peaked next-token distributions of a real LLM). ``max_logprobs``
|
|
15
|
+
mimics vLLM's cap (HTTP 400 above it); ``has_tokenize=False`` drops ``/tokenize`` (404), like
|
|
16
|
+
servers without vLLM's tokenizer API. ``answers`` maps a substring of the user prompt
|
|
17
|
+
to a canned reply (for GSM8K tests). The toy tokenizer reads ``<tN>`` as token N, so the
|
|
18
|
+
text of a generation re-tokenizes to its own ids.
|
|
19
|
+
|
|
20
|
+
``logprob_mode``: "ok"; "ignore" answers without logprobs (an engine with no logprob
|
|
21
|
+
support); "refuse" answers HTTP 400 when logprobs are asked for.
|
|
22
|
+
|
|
23
|
+
Sampling: a chat request with ``temperature > 0`` samples, with ``top_k``, ``top_p`` and
|
|
24
|
+
``seed`` applied the usual way (temperature, then top-k, then top-p on the renormalised
|
|
25
|
+
rest). ``seeds``: "honour" (a draw is a function of seed, prompt and position), "ignore"
|
|
26
|
+
(a fresh draw per request, not reproducible), "fixed" (every seed gives the same draw).
|
|
27
|
+
``rng_salt`` changes the random stream (a second engine with a correct sampler but other
|
|
28
|
+
random numbers); ``wrong_top_p`` / ``wrong_temperature`` replace the requested top_p / temperature
|
|
29
|
+
when sampling (sampler bugs; the logprobs stay right). ``top_p_order="joint"`` samples as SGLang
|
|
30
|
+
does: top-p over the whole distribution, then the first top_k tokens. ``batch_shift`` moves the
|
|
31
|
+
logits by ``batch_shift * (requests in flight - 1)`` times a fixed pattern, like an engine whose
|
|
32
|
+
numerics change with the batch; ``max_inflight`` is the most requests seen in flight at once.
|
|
33
|
+
|
|
34
|
+
Server facts for concurrency probes: ``server_info`` is served at ``GET /get_server_info``
|
|
35
|
+
(SGLang) and ``props`` at ``GET /props`` (llama.cpp); ``slots`` caps how many generation
|
|
36
|
+
requests run at once (the rest wait) and ``delay_s`` is how long each one takes.
|
|
37
|
+
|
|
38
|
+
Usage::
|
|
39
|
+
|
|
40
|
+
with serve(FakeLM()) as base_url:
|
|
41
|
+
...
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import contextlib
|
|
47
|
+
import json
|
|
48
|
+
import math
|
|
49
|
+
import random
|
|
50
|
+
import re
|
|
51
|
+
import threading
|
|
52
|
+
import time
|
|
53
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FakeLM:
|
|
57
|
+
BOS, ASSISTANT = 1, 2
|
|
58
|
+
_MARK = re.compile(r"<t(\d+)>")
|
|
59
|
+
|
|
60
|
+
def __init__(self, vocab: int = 16, perturb: float = 0.0, max_logprobs: int = 20, ids: str = "vllm",
|
|
61
|
+
answers: dict | None = None, name: str = "fake-model", max_model_len: int = 4096,
|
|
62
|
+
has_tokenize: bool = True, sharpness: float = 1.0, logprob_mode: str = "ok",
|
|
63
|
+
seeds: str = "honour", rng_salt: str = "", wrong_top_p: float | None = None,
|
|
64
|
+
server_info: dict | None = None, props: dict | None = None, slots: int | None = None,
|
|
65
|
+
delay_s: float = 0.0, wrong_temperature: float | None = None, batch_shift: float = 0.0,
|
|
66
|
+
top_p_order: str = "top_k_first"):
|
|
67
|
+
self.vocab, self.perturb, self.max_logprobs, self.ids = vocab, perturb, max_logprobs, ids
|
|
68
|
+
self.sharpness = sharpness
|
|
69
|
+
self.has_tokenize = has_tokenize
|
|
70
|
+
self.answers = answers or {}
|
|
71
|
+
self.name, self.max_model_len = name, max_model_len
|
|
72
|
+
self.logprob_mode, self.seeds, self.rng_salt, self.wrong_top_p = logprob_mode, seeds, rng_salt, wrong_top_p
|
|
73
|
+
self.wrong_temperature, self.batch_shift, self.top_p_order = wrong_temperature, batch_shift, top_p_order
|
|
74
|
+
self._inflight = self.max_inflight = 0
|
|
75
|
+
self._tl = threading.local() # requests in flight when this thread's request arrived (batch_shift)
|
|
76
|
+
self.server_info, self.props, self.delay_s = server_info, props, delay_s
|
|
77
|
+
self._slots = threading.Semaphore(slots) if slots else None
|
|
78
|
+
self._draws = 0
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
self.requests: list[dict] = []
|
|
81
|
+
|
|
82
|
+
# ----------------------------------------------------------------- the toy model
|
|
83
|
+
def logprobs(self, context: list[int]) -> list[float]:
|
|
84
|
+
a = context[-1] if context else 0
|
|
85
|
+
b = context[-2] if len(context) > 1 else 0
|
|
86
|
+
logits = [3.0 * math.sin(1.7 * a + 0.37 * b * j + 0.91 * j + 0.13 * j * j) for j in range(self.vocab)]
|
|
87
|
+
if self.perturb:
|
|
88
|
+
logits = [x + self.perturb * math.cos(2.3 * j + 0.7 * a) for j, x in enumerate(logits)]
|
|
89
|
+
batch = getattr(self._tl, "inflight", 1)
|
|
90
|
+
if self.batch_shift and batch > 1:
|
|
91
|
+
logits = [x + self.batch_shift * (batch - 1) * math.cos(1.3 * j + 0.4 * a) for j, x in enumerate(logits)]
|
|
92
|
+
if self.sharpness != 1.0:
|
|
93
|
+
logits = [self.sharpness * x for x in logits]
|
|
94
|
+
m = max(logits)
|
|
95
|
+
lse = m + math.log(sum(math.exp(x - m) for x in logits))
|
|
96
|
+
return [x - lse for x in logits]
|
|
97
|
+
|
|
98
|
+
def tokenize_text(self, text: str) -> list[int]:
|
|
99
|
+
def chars(part: str) -> list[int]:
|
|
100
|
+
return [3 + (ord(c) % (self.vocab - 3)) for c in part]
|
|
101
|
+
|
|
102
|
+
ids, pos = [], 0
|
|
103
|
+
for m in self._MARK.finditer(text):
|
|
104
|
+
if int(m.group(1)) < self.vocab:
|
|
105
|
+
ids += chars(text[pos:m.start()]) + [int(m.group(1))]
|
|
106
|
+
pos = m.end()
|
|
107
|
+
return ids + chars(text[pos:])
|
|
108
|
+
|
|
109
|
+
def template(self, messages: list[dict]) -> list[int]:
|
|
110
|
+
ids = [self.BOS]
|
|
111
|
+
for m in messages:
|
|
112
|
+
ids += self.tokenize_text(f"{m.get('role')}:{m.get('content')}")
|
|
113
|
+
return ids + [self.ASSISTANT]
|
|
114
|
+
|
|
115
|
+
def greedy(self, prompt: list[int], n: int) -> list[int]:
|
|
116
|
+
ctx, out = list(prompt), []
|
|
117
|
+
for _ in range(n):
|
|
118
|
+
lp = self.logprobs(ctx)
|
|
119
|
+
t = max(range(self.vocab), key=lambda j: lp[j])
|
|
120
|
+
out.append(t)
|
|
121
|
+
ctx.append(t)
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
def top(self, lp: list[float], k: int) -> list[int]:
|
|
125
|
+
return sorted(range(self.vocab), key=lambda j: -lp[j])[:k]
|
|
126
|
+
|
|
127
|
+
def _rng(self, body: dict, prompt: list[int], step: int) -> random.Random:
|
|
128
|
+
where = f"{','.join(map(str, prompt))}|{step}"
|
|
129
|
+
if self.seeds == "ignore":
|
|
130
|
+
with self._lock:
|
|
131
|
+
self._draws += 1
|
|
132
|
+
return random.Random(f"{self.rng_salt}|draw|{self._draws}")
|
|
133
|
+
if self.seeds == "fixed":
|
|
134
|
+
return random.Random(f"{self.rng_salt}|fixed|{where}")
|
|
135
|
+
return random.Random(f"{self.rng_salt}|{body.get('seed')}|{where}")
|
|
136
|
+
|
|
137
|
+
def sample(self, prompt: list[int], n: int, body: dict) -> list[int]:
|
|
138
|
+
"""Seeded sampling: temperature, then top-k, then top-p on the renormalised remainder (``joint``: top-p
|
|
139
|
+
over the whole distribution, then the first top_k)."""
|
|
140
|
+
temp = float(body["temperature"] if self.wrong_temperature is None else self.wrong_temperature)
|
|
141
|
+
top_k = int(body.get("top_k") or 0)
|
|
142
|
+
top_p = self.wrong_top_p if self.wrong_top_p is not None else body.get("top_p")
|
|
143
|
+
ctx, out = list(prompt), []
|
|
144
|
+
for step in range(n):
|
|
145
|
+
lp = self.logprobs(ctx)
|
|
146
|
+
m = max(lp)
|
|
147
|
+
w = {j: math.exp((x - m) / temp) for j, x in enumerate(lp)}
|
|
148
|
+
order = sorted(w, key=lambda j: (-w[j], j))
|
|
149
|
+
joint = self.top_p_order == "joint"
|
|
150
|
+
if top_k > 0 and not joint:
|
|
151
|
+
order = order[:top_k]
|
|
152
|
+
if top_p is not None and float(top_p) < 1.0:
|
|
153
|
+
total, kept, acc = sum(w[j] for j in order), [], 0.0
|
|
154
|
+
for j in order:
|
|
155
|
+
kept.append(j)
|
|
156
|
+
acc += w[j] / total
|
|
157
|
+
if acc >= float(top_p):
|
|
158
|
+
break
|
|
159
|
+
order = kept
|
|
160
|
+
if top_k > 0 and joint:
|
|
161
|
+
order = order[:top_k]
|
|
162
|
+
u = self._rng(body, prompt, step).random() * sum(w[j] for j in order)
|
|
163
|
+
t = order[-1]
|
|
164
|
+
for j in order:
|
|
165
|
+
u -= w[j]
|
|
166
|
+
if u < 0:
|
|
167
|
+
t = j
|
|
168
|
+
break
|
|
169
|
+
out.append(t)
|
|
170
|
+
ctx.append(t)
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
# ----------------------------------------------------------------- endpoints
|
|
174
|
+
def _tokstr(self, t: int, as_ids: bool) -> str:
|
|
175
|
+
return f"token_id:{t}" if as_ids else f"<t{t}>"
|
|
176
|
+
|
|
177
|
+
def chat(self, body: dict) -> tuple[int, dict]:
|
|
178
|
+
messages = body.get("messages") or []
|
|
179
|
+
text = "\n".join(str(m.get("content")) for m in messages)
|
|
180
|
+
for key, reply in self.answers.items():
|
|
181
|
+
if key in text:
|
|
182
|
+
return 200, {"id": "fake", "object": "chat.completion", "model": self.name,
|
|
183
|
+
"choices": [{"index": 0, "finish_reason": "stop",
|
|
184
|
+
"message": {"role": "assistant", "content": reply}}],
|
|
185
|
+
"usage": {"prompt_tokens": len(text), "completion_tokens": len(reply.split())}}
|
|
186
|
+
k = int(body.get("top_logprobs") or 0)
|
|
187
|
+
if body.get("logprobs") and self.logprob_mode == "refuse":
|
|
188
|
+
return 400, {"error": {"message": "logprobs are not supported by this server", "code": 400}}
|
|
189
|
+
if body.get("logprobs") and k > self.max_logprobs:
|
|
190
|
+
return 400, {"error": {"message": f"Requested sample logprobs of {k}, which is greater than max "
|
|
191
|
+
f"allowed: {self.max_logprobs}", "code": 400}}
|
|
192
|
+
prompt = self.template(messages)
|
|
193
|
+
n = int(body.get("max_tokens") or 16)
|
|
194
|
+
gen = self.sample(prompt, n, body) if float(body.get("temperature") or 0) > 0 else self.greedy(prompt, n)
|
|
195
|
+
as_ids = bool(body.get("return_tokens_as_token_ids")) and self.ids == "vllm"
|
|
196
|
+
choice: dict = {"index": 0, "finish_reason": "length",
|
|
197
|
+
"message": {"role": "assistant", "content": "".join(f"<t{t}>" for t in gen)}}
|
|
198
|
+
if body.get("logprobs") and self.logprob_mode == "ok":
|
|
199
|
+
content, ctx = [], list(prompt)
|
|
200
|
+
for t in gen:
|
|
201
|
+
lp = self.logprobs(ctx)
|
|
202
|
+
entry = {"token": self._tokstr(t, as_ids), "logprob": lp[t], "bytes": None,
|
|
203
|
+
"top_logprobs": [{"token": self._tokstr(j, as_ids), "logprob": lp[j], "bytes": None}
|
|
204
|
+
for j in self.top(lp, k)]}
|
|
205
|
+
if self.ids == "llamacpp":
|
|
206
|
+
entry["id"] = t
|
|
207
|
+
for alt, j in zip(entry["top_logprobs"], self.top(lp, k)):
|
|
208
|
+
alt["id"] = j
|
|
209
|
+
content.append(entry)
|
|
210
|
+
ctx.append(t)
|
|
211
|
+
choice["logprobs"] = {"content": content}
|
|
212
|
+
resp = {"id": "fake", "object": "chat.completion", "model": self.name, "choices": [choice],
|
|
213
|
+
"usage": {"prompt_tokens": len(prompt), "completion_tokens": len(gen),
|
|
214
|
+
"total_tokens": len(prompt) + len(gen)}}
|
|
215
|
+
if body.get("return_token_ids") and self.ids == "vllm":
|
|
216
|
+
choice["token_ids"] = gen
|
|
217
|
+
resp["prompt_token_ids"] = prompt
|
|
218
|
+
return 200, resp
|
|
219
|
+
|
|
220
|
+
def completions(self, body: dict) -> tuple[int, dict]:
|
|
221
|
+
prompt = body.get("prompt")
|
|
222
|
+
if not (isinstance(prompt, list) and all(isinstance(t, int) for t in prompt)):
|
|
223
|
+
prompt = self.tokenize_text(str(prompt))
|
|
224
|
+
if len(prompt) + int(body.get("max_tokens") or 1) > self.max_model_len:
|
|
225
|
+
return 400, {"error": {"message": "This model's maximum context length is exceeded", "code": 400}}
|
|
226
|
+
k = body.get("prompt_logprobs")
|
|
227
|
+
if k is not None and int(k) > self.max_logprobs:
|
|
228
|
+
return 400, {"error": {"message": f"Requested prompt logprobs of {k}, which is greater than max "
|
|
229
|
+
f"allowed: {self.max_logprobs}", "code": 400}}
|
|
230
|
+
choice: dict = {"index": 0, "text": "", "finish_reason": "length"}
|
|
231
|
+
if k is not None:
|
|
232
|
+
plp: list = [None]
|
|
233
|
+
for i in range(1, len(prompt)):
|
|
234
|
+
lp = self.logprobs(prompt[:i])
|
|
235
|
+
ranks = {j: r + 1 for r, j in enumerate(self.top(lp, self.vocab))}
|
|
236
|
+
keep = self.top(lp, int(k)) + [prompt[i]]
|
|
237
|
+
plp.append({str(j): {"logprob": lp[j], "rank": ranks[j], "decoded_token": f"<t{j}>"}
|
|
238
|
+
for j in keep})
|
|
239
|
+
choice["prompt_logprobs"] = plp
|
|
240
|
+
gen = self.greedy(prompt, int(body.get("max_tokens") or 1))
|
|
241
|
+
choice["text"] = "".join(f"<t{t}>" for t in gen)
|
|
242
|
+
return 200, {"id": "fake", "object": "text_completion", "model": self.name, "choices": [choice],
|
|
243
|
+
"usage": {"prompt_tokens": len(prompt), "completion_tokens": len(gen)}}
|
|
244
|
+
|
|
245
|
+
def generate(self, body: dict) -> tuple[int, dict]:
|
|
246
|
+
"""A chat request as the server runs it: wait for a slot, take ``delay_s``, answer."""
|
|
247
|
+
with self._lock:
|
|
248
|
+
self._inflight += 1
|
|
249
|
+
self._tl.inflight = self._inflight
|
|
250
|
+
self.max_inflight = max(self.max_inflight, self._inflight)
|
|
251
|
+
try:
|
|
252
|
+
return self._generate(body)
|
|
253
|
+
finally:
|
|
254
|
+
with self._lock:
|
|
255
|
+
self._inflight -= 1
|
|
256
|
+
|
|
257
|
+
def _generate(self, body: dict) -> tuple[int, dict]:
|
|
258
|
+
if self._slots is None:
|
|
259
|
+
if self.delay_s:
|
|
260
|
+
time.sleep(self.delay_s)
|
|
261
|
+
return self.chat(body)
|
|
262
|
+
with self._slots:
|
|
263
|
+
if self.delay_s:
|
|
264
|
+
time.sleep(self.delay_s)
|
|
265
|
+
return self.chat(body)
|
|
266
|
+
|
|
267
|
+
def tokenize(self, body: dict) -> tuple[int, dict]:
|
|
268
|
+
if "messages" in body:
|
|
269
|
+
ids = self.template(body["messages"])
|
|
270
|
+
else:
|
|
271
|
+
ids = self.tokenize_text(str(body.get("prompt", "")))
|
|
272
|
+
return 200, {"tokens": ids, "count": len(ids), "max_model_len": self.max_model_len}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _handler(lm: FakeLM):
|
|
276
|
+
class Handler(BaseHTTPRequestHandler):
|
|
277
|
+
def log_message(self, *args): # keep test output quiet
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
def _send(self, status: int, obj: dict):
|
|
281
|
+
data = json.dumps(obj).encode()
|
|
282
|
+
self.send_response(status)
|
|
283
|
+
self.send_header("Content-Type", "application/json")
|
|
284
|
+
self.send_header("Content-Length", str(len(data)))
|
|
285
|
+
self.end_headers()
|
|
286
|
+
self.wfile.write(data)
|
|
287
|
+
|
|
288
|
+
def do_GET(self):
|
|
289
|
+
path = self.path.rstrip("/")
|
|
290
|
+
if path == "/v1/models":
|
|
291
|
+
self._send(200, {"object": "list", "data": [{"id": lm.name, "object": "model"}]})
|
|
292
|
+
elif path == "/get_server_info" and lm.server_info is not None:
|
|
293
|
+
self._send(200, lm.server_info)
|
|
294
|
+
elif path == "/props" and lm.props is not None:
|
|
295
|
+
self._send(200, lm.props)
|
|
296
|
+
else:
|
|
297
|
+
self._send(404, {"error": {"message": "not found"}})
|
|
298
|
+
|
|
299
|
+
def do_POST(self):
|
|
300
|
+
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
|
|
301
|
+
lm.requests.append({"path": self.path, "body": body})
|
|
302
|
+
routes = {"/v1/chat/completions": lm.generate, "/v1/completions": lm.completions}
|
|
303
|
+
if lm.has_tokenize:
|
|
304
|
+
routes["/tokenize"] = lm.tokenize
|
|
305
|
+
fn = routes.get(self.path)
|
|
306
|
+
if fn is None:
|
|
307
|
+
self._send(404, {"error": {"message": "not found"}})
|
|
308
|
+
return
|
|
309
|
+
status, obj = fn(body)
|
|
310
|
+
self._send(status, obj)
|
|
311
|
+
|
|
312
|
+
return Handler
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class _Server(ThreadingHTTPServer):
|
|
316
|
+
request_queue_size = 128 # many short concurrent connections (sampling histograms)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@contextlib.contextmanager
|
|
320
|
+
def serve(lm: FakeLM, host: str = "127.0.0.1"):
|
|
321
|
+
"""Run ``lm`` on a free port in a background thread; yields the base URL."""
|
|
322
|
+
server = _Server((host, 0), _handler(lm))
|
|
323
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
324
|
+
thread.start()
|
|
325
|
+
try:
|
|
326
|
+
yield f"http://{host}:{server.server_address[1]}"
|
|
327
|
+
finally:
|
|
328
|
+
server.shutdown()
|
|
329
|
+
server.server_close()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""GSM8K: the pinned test split (downloaded once, cached), the zero-shot chain-of-thought prompt,
|
|
2
|
+
and answer extraction.
|
|
3
|
+
|
|
4
|
+
Data: the 1,319-problem test split of GSM8K (Cobbe et al. 2021,
|
|
5
|
+
github.com/openai/grade-school-math, MIT licence in ``data/gsm8k_LICENSE.txt``). It is not
|
|
6
|
+
shipped: the first ``quality gsm8k`` run downloads the Hugging Face dataset ``openai/gsm8k``
|
|
7
|
+
(config ``main``, split ``test``) at a pinned revision, checks the Parquet file's sha256,
|
|
8
|
+
converts it to JSONL (one ``{"question", "answer"}`` object per line, ``json.dumps``
|
|
9
|
+
defaults) and checks that file's sha256 too. Those JSONL bytes are identical to the
|
|
10
|
+
upstream ``test.jsonl`` and to the file early versions shipped, so ``dataset_sha256`` in evidence
|
|
11
|
+
records is unchanged. The cache is ``~/.cache/inferencekit/gsm8k/`` (``$XDG_CACHE_HOME``
|
|
12
|
+
and ``INFERENCEKIT_CACHE_DIR`` move it). The gold answer is the number after the last
|
|
13
|
+
``####`` in ``answer``.
|
|
14
|
+
|
|
15
|
+
Prompt (zero-shot, one user turn): the question, then an instruction to reason step by
|
|
16
|
+
step and finish with ``#### <number>``.
|
|
17
|
+
|
|
18
|
+
Extraction (first rule that finds a number wins; the rule used is recorded per item):
|
|
19
|
+
1. ``hash``: the first number after the last ``####``;
|
|
20
|
+
2. ``boxed``: the number inside the last ``\\boxed{...}`` (many chat models use it);
|
|
21
|
+
3. ``last``: the last number anywhere in the response.
|
|
22
|
+
Numbers are normalised before comparison: thousands separators, ``$`` and a trailing
|
|
23
|
+
``.`` are dropped; ``-1,234.50`` becomes Decimal("-1234.5"). A response with no number
|
|
24
|
+
is wrong. Correct means numerically equal to the gold answer.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
from decimal import Decimal, InvalidOperation
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from inferencekit.canonical import sha256_hex
|
|
39
|
+
from inferencekit.registry import OpError
|
|
40
|
+
|
|
41
|
+
DATASET = "openai/gsm8k (main, test); github.com/openai/grade-school-math; MIT"
|
|
42
|
+
# Pinned source: the Hugging Face dataset repo at one commit, and the hashes of what it holds.
|
|
43
|
+
HF_REPO = "openai/gsm8k"
|
|
44
|
+
HF_REVISION = "740312add88f781978c0658806c59bc2815b9866"
|
|
45
|
+
HF_FILE = "main/test-00000-of-00001.parquet"
|
|
46
|
+
PARQUET_SHA256 = "ee7b8da9e381df27b9e3f7758a159ab2bdaa4dbaa910546cbbc47e0cb44e4f59" # = its LFS oid
|
|
47
|
+
JSONL_SHA256 = "3730d312f6e3440559ace48831e51066acaca737f6eabec99bccb9e4b3c39d14"
|
|
48
|
+
N_TEST = 1319
|
|
49
|
+
INSTRUCTION = ("Solve the problem step by step. At the end, write the final answer as a single number "
|
|
50
|
+
"on its own line in the form \"#### <number>\".")
|
|
51
|
+
|
|
52
|
+
_NUM = re.compile(r"-?\$?\d[\d,]*(?:\.\d+)?|-?\.\d+")
|
|
53
|
+
_BOXED = re.compile(r"\\boxed\{([^{}]*)\}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cache_dir() -> Path:
|
|
57
|
+
base = os.environ.get("INFERENCEKIT_CACHE_DIR") or os.path.join(
|
|
58
|
+
os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache"), "inferencekit")
|
|
59
|
+
return Path(base) / "gsm8k"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def source_url() -> str:
|
|
63
|
+
endpoint = (os.environ.get("HF_ENDPOINT") or "https://huggingface.co").rstrip("/")
|
|
64
|
+
return f"{endpoint}/datasets/{HF_REPO}/resolve/{HF_REVISION}/{HF_FILE}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parquet_path() -> Path:
|
|
68
|
+
return cache_dir() / Path(HF_FILE).name
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def manual_download_command() -> str:
|
|
72
|
+
p = _parquet_path()
|
|
73
|
+
return f"mkdir -p '{p.parent}' && curl -fL -o '{p}' '{source_url()}'"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parquet_to_jsonl(parquet: str | Path | bytes) -> bytes:
|
|
77
|
+
"""The GSM8K Parquet table as JSONL bytes: {"question", "answer"} per line, json.dumps defaults."""
|
|
78
|
+
from inferencekit.quality.parquet import read_table
|
|
79
|
+
|
|
80
|
+
table = read_table(parquet)
|
|
81
|
+
if "question" not in table or "answer" not in table:
|
|
82
|
+
raise OpError(f"GSM8K Parquet file without question/answer columns (has {sorted(table)})")
|
|
83
|
+
rows = []
|
|
84
|
+
for q, a in zip(table["question"], table["answer"]):
|
|
85
|
+
if q is None or a is None:
|
|
86
|
+
raise OpError("GSM8K Parquet file with an empty question or answer")
|
|
87
|
+
rows.append(json.dumps({"question": q.decode("utf-8"), "answer": a.decode("utf-8")}) + "\n")
|
|
88
|
+
return "".join(rows).encode("utf-8")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _download(dest: Path, timeout: float = 120.0) -> None:
|
|
92
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
tmp = dest.with_name(dest.name + f".part-{os.getpid()}")
|
|
94
|
+
req = urllib.request.Request(source_url(), headers={"User-Agent": "inferencekit (quality gsm8k)"})
|
|
95
|
+
try:
|
|
96
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp, open(tmp, "wb") as f: # noqa: S310
|
|
97
|
+
while chunk := resp.read(1 << 16):
|
|
98
|
+
f.write(chunk)
|
|
99
|
+
os.replace(tmp, dest)
|
|
100
|
+
finally:
|
|
101
|
+
tmp.unlink(missing_ok=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _sha(data: bytes) -> str:
|
|
105
|
+
return hashlib.sha256(data).hexdigest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def data_path(fetch: bool = True) -> Path:
|
|
109
|
+
"""The cached GSM8K test split as JSONL (sha256-checked), downloading it once if needed.
|
|
110
|
+
|
|
111
|
+
Order: the cached JSONL; else the cached Parquet file (downloaded here, or placed by hand
|
|
112
|
+
with ``manual_download_command()``); else a download from Hugging Face at HF_REVISION.
|
|
113
|
+
``fetch=False`` or ``HF_HUB_OFFLINE=1`` never touches the network."""
|
|
114
|
+
jsonl = cache_dir() / f"gsm8k-test-{HF_REVISION[:12]}.jsonl"
|
|
115
|
+
if jsonl.is_file() and _sha(jsonl.read_bytes()) == JSONL_SHA256:
|
|
116
|
+
return jsonl
|
|
117
|
+
pq = _parquet_path()
|
|
118
|
+
if not (pq.is_file() and _sha(pq.read_bytes()) == PARQUET_SHA256):
|
|
119
|
+
offline = os.environ.get("HF_HUB_OFFLINE", "").strip().lower() in ("1", "true", "yes", "on")
|
|
120
|
+
bad_cache = pq.is_file()
|
|
121
|
+
reason = ("HF_HUB_OFFLINE is set" if offline else "fetching is off" if not fetch else "")
|
|
122
|
+
if bad_cache:
|
|
123
|
+
reason = f"the cached {pq.name} has the wrong sha256" + (f" and {reason}" if reason else "")
|
|
124
|
+
if fetch and not offline: # (re)download; a cached file with the wrong hash is replaced
|
|
125
|
+
reason = ""
|
|
126
|
+
try:
|
|
127
|
+
_download(pq)
|
|
128
|
+
except (urllib.error.URLError, OSError, TimeoutError) as e:
|
|
129
|
+
reason = f"download failed: {getattr(e, 'reason', None) or e}"
|
|
130
|
+
if not reason and _sha(pq.read_bytes()) != PARQUET_SHA256:
|
|
131
|
+
reason = f"downloaded file sha256 {_sha(pq.read_bytes())} is not the pinned {PARQUET_SHA256}"
|
|
132
|
+
pq.unlink(missing_ok=True)
|
|
133
|
+
if reason:
|
|
134
|
+
raise OpError(
|
|
135
|
+
f"GSM8K test split is not available ({reason}). It is downloaded once from the Hugging Face "
|
|
136
|
+
f"dataset {HF_REPO} (config main, split test) at revision {HF_REVISION} and cached in "
|
|
137
|
+
f"{cache_dir()}. Fetch it by hand (on another machine if this one is offline, then copy it to "
|
|
138
|
+
f"{_parquet_path()} here):\n {manual_download_command()}\nexpected sha256 {PARQUET_SHA256}. "
|
|
139
|
+
"Or pass --data with that Parquet file or any GSM8K-format JSONL.")
|
|
140
|
+
data = parquet_to_jsonl(pq)
|
|
141
|
+
if _sha(data) != JSONL_SHA256:
|
|
142
|
+
raise OpError(f"GSM8K: the JSONL converted from {pq} has sha256 {_sha(data)}, not the pinned "
|
|
143
|
+
f"{JSONL_SHA256} (Parquet reader problem; please report it)")
|
|
144
|
+
tmp = jsonl.with_name(jsonl.name + f".part-{os.getpid()}")
|
|
145
|
+
tmp.write_bytes(data)
|
|
146
|
+
os.replace(tmp, jsonl)
|
|
147
|
+
return jsonl
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load(path: str | Path | None = None, limit: int | None = None) -> tuple[list[dict], str]:
|
|
151
|
+
"""[{idx, question, gold}] and the sha256 of the JSONL bytes.
|
|
152
|
+
|
|
153
|
+
``path``: a GSM8K-format JSONL file, or a Parquet file with question/answer columns
|
|
154
|
+
(converted as for the pinned split). None: the pinned test split (downloaded once)."""
|
|
155
|
+
p = Path(path) if path else data_path()
|
|
156
|
+
if not p.is_file():
|
|
157
|
+
raise OpError(f"GSM8K data file not found: {p}")
|
|
158
|
+
try:
|
|
159
|
+
raw = parquet_to_jsonl(p) if p.suffix == ".parquet" else p.read_bytes()
|
|
160
|
+
text = raw.decode("utf-8")
|
|
161
|
+
except (OSError, ValueError) as e: # ParquetError, not UTF-8
|
|
162
|
+
raise OpError(f"{p}: {e}") from None
|
|
163
|
+
items = []
|
|
164
|
+
for i, line in enumerate(text.splitlines()):
|
|
165
|
+
if not line.strip():
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
obj = json.loads(line)
|
|
169
|
+
gold = obj.get("gold")
|
|
170
|
+
if gold is None:
|
|
171
|
+
gold = obj["answer"].split("####")[-1].strip()
|
|
172
|
+
items.append({"idx": obj.get("idx", i), "question": obj["question"], "gold": str(gold)})
|
|
173
|
+
except (ValueError, KeyError, TypeError, AttributeError) as e:
|
|
174
|
+
raise OpError(f"{p}:{i + 1}: not a GSM8K row ({type(e).__name__}: {e}); need "
|
|
175
|
+
"{\"question\", \"answer\" ending in '#### <n>'} or {\"question\", \"gold\"}") from None
|
|
176
|
+
if limit is not None and limit >= 0:
|
|
177
|
+
items = items[:limit]
|
|
178
|
+
return items, sha256_hex(raw)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def messages(question: str) -> list[dict]:
|
|
182
|
+
return [{"role": "user", "content": f"{question}\n\n{INSTRUCTION}"}]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def normalize(text: str | None) -> Decimal | None:
|
|
186
|
+
if text is None:
|
|
187
|
+
return None
|
|
188
|
+
s = text.replace(",", "").replace("$", "").replace(" ", "").strip().rstrip(".")
|
|
189
|
+
try:
|
|
190
|
+
return Decimal(s).normalize() if s else None
|
|
191
|
+
except InvalidOperation:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _first_number(text: str) -> str | None:
|
|
196
|
+
m = _NUM.search(text)
|
|
197
|
+
return m.group(0) if m else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _last_number(text: str) -> str | None:
|
|
201
|
+
found = _NUM.findall(text)
|
|
202
|
+
return found[-1] if found else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def extract(response: str | None) -> tuple[str | None, str]:
|
|
206
|
+
"""(number string, rule) from a model response; (None, "none") when no number is found."""
|
|
207
|
+
if not response:
|
|
208
|
+
return None, "none"
|
|
209
|
+
if "####" in response:
|
|
210
|
+
n = _first_number(response.rsplit("####", 1)[1])
|
|
211
|
+
if n is not None:
|
|
212
|
+
return n, "hash"
|
|
213
|
+
boxed = _BOXED.findall(response)
|
|
214
|
+
if boxed:
|
|
215
|
+
n = _last_number(boxed[-1])
|
|
216
|
+
if n is not None:
|
|
217
|
+
return n, "boxed"
|
|
218
|
+
n = _last_number(response)
|
|
219
|
+
return (n, "last") if n is not None else (None, "none")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def is_correct(extracted: str | None, gold: str) -> bool:
|
|
223
|
+
a, b = normalize(extracted), normalize(gold)
|
|
224
|
+
return a is not None and b is not None and a == b
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Turn server logprob payloads into token ids.
|
|
2
|
+
|
|
3
|
+
Chat completions (OpenAI format) name tokens by *string*, which is ambiguous. Token ids
|
|
4
|
+
are recovered, in this order, and the method is recorded:
|
|
5
|
+
``token_id_strings`` vLLM with ``return_tokens_as_token_ids``: tokens read "token_id:1234";
|
|
6
|
+
``id_field`` servers that add an integer ``id`` to each logprob entry (llama-server);
|
|
7
|
+
``return_token_ids`` vLLM with ``return_token_ids``: ``choices[0].token_ids`` (chosen tokens only);
|
|
8
|
+
``strings`` none of the above: keys stay token strings (scoring then re-tokenizes).
|
|
9
|
+
|
|
10
|
+
Teacher forcing uses vLLM's ``prompt_logprobs`` on ``/v1/completions`` (a vLLM extension):
|
|
11
|
+
``choices[0].prompt_logprobs`` is a list with one entry per prompt token; entry 0 is
|
|
12
|
+
null (the first token has no context) and entry i maps token id -> {logprob, rank,
|
|
13
|
+
decoded_token} for the top-k tokens at position i plus the token actually there.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
_TOKEN_ID = re.compile(r"^token_id:(\d+)$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _entry_id(entry: dict):
|
|
24
|
+
tok = entry.get("token")
|
|
25
|
+
if isinstance(tok, str):
|
|
26
|
+
m = _TOKEN_ID.match(tok)
|
|
27
|
+
if m:
|
|
28
|
+
return int(m.group(1)), "token_id_strings"
|
|
29
|
+
for key in ("id", "token_id"):
|
|
30
|
+
if isinstance(entry.get(key), int):
|
|
31
|
+
return entry[key], "id_field"
|
|
32
|
+
return None, None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_chat_logprobs(response: dict) -> dict:
|
|
36
|
+
"""{token_ids, tokens, logprobs, top: [{key: logprob}], method, top_k_returned, text, finish_reason}.
|
|
37
|
+
|
|
38
|
+
``top[i]`` always contains the chosen token as well as the returned alternatives.
|
|
39
|
+
Keys are ints when ids could be recovered, else ``"s:" + token string``.
|
|
40
|
+
"""
|
|
41
|
+
choice = (response.get("choices") or [{}])[0]
|
|
42
|
+
content = ((choice.get("logprobs") or {}).get("content")) or []
|
|
43
|
+
side_ids = choice.get("token_ids")
|
|
44
|
+
ids, tokens, lps, tops, methods = [], [], [], [], set()
|
|
45
|
+
for i, entry in enumerate(content):
|
|
46
|
+
tid, how = _entry_id(entry)
|
|
47
|
+
if tid is None and isinstance(side_ids, list) and i < len(side_ids):
|
|
48
|
+
tid, how = side_ids[i], "return_token_ids"
|
|
49
|
+
methods.add(how or "strings")
|
|
50
|
+
key = tid if tid is not None else "s:" + str(entry.get("token"))
|
|
51
|
+
top = {}
|
|
52
|
+
for alt in entry.get("top_logprobs") or []:
|
|
53
|
+
aid, _ = _entry_id(alt)
|
|
54
|
+
akey = aid if aid is not None else "s:" + str(alt.get("token"))
|
|
55
|
+
if akey not in top:
|
|
56
|
+
top[akey] = alt.get("logprob")
|
|
57
|
+
top_k = len(top)
|
|
58
|
+
top.setdefault(key, entry.get("logprob"))
|
|
59
|
+
ids.append(tid)
|
|
60
|
+
tokens.append(entry.get("token"))
|
|
61
|
+
lps.append(entry.get("logprob"))
|
|
62
|
+
tops.append({"k": top_k, "top": top})
|
|
63
|
+
if isinstance(side_ids, list) and side_ids and len(side_ids) == len(ids):
|
|
64
|
+
mismatch = [i for i, (a, b) in enumerate(zip(ids, side_ids)) if a is not None and a != b]
|
|
65
|
+
else:
|
|
66
|
+
mismatch = []
|
|
67
|
+
method = "strings" if "strings" in methods or not methods else sorted(methods)[0]
|
|
68
|
+
if None in ids:
|
|
69
|
+
method = "strings"
|
|
70
|
+
msg = choice.get("message") or {}
|
|
71
|
+
return {"token_ids": ids if method != "strings" else None, "tokens": tokens, "logprobs": lps,
|
|
72
|
+
"top": [t["top"] for t in tops], "method": method if content else "none",
|
|
73
|
+
"top_k_returned": min((t["k"] for t in tops), default=0),
|
|
74
|
+
"id_mismatch_positions": mismatch, "text": msg.get("content") if isinstance(msg, dict) else None,
|
|
75
|
+
"finish_reason": choice.get("finish_reason"), "side_token_ids": side_ids}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_prompt_logprobs(response: dict) -> list[dict | None]:
|
|
79
|
+
"""[{token_id: (logprob, rank)} | None] per prompt position, from a vLLM /v1/completions response."""
|
|
80
|
+
choice = (response.get("choices") or [{}])[0]
|
|
81
|
+
plp = choice.get("prompt_logprobs")
|
|
82
|
+
if plp is None:
|
|
83
|
+
plp = response.get("prompt_logprobs")
|
|
84
|
+
if not isinstance(plp, list):
|
|
85
|
+
return []
|
|
86
|
+
out: list[dict | None] = []
|
|
87
|
+
for entry in plp:
|
|
88
|
+
if not entry:
|
|
89
|
+
out.append(None)
|
|
90
|
+
continue
|
|
91
|
+
pos = {}
|
|
92
|
+
for k, v in entry.items():
|
|
93
|
+
if isinstance(v, dict):
|
|
94
|
+
pos[int(k)] = (v.get("logprob"), v.get("rank"))
|
|
95
|
+
else: # a bare float
|
|
96
|
+
pos[int(k)] = (float(v), None)
|
|
97
|
+
out.append(pos)
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def argmax_token(position: dict) -> int:
|
|
102
|
+
"""The reference's most likely token at a position: rank 1 if ranks are given, else max logprob."""
|
|
103
|
+
ranked = [k for k, (_, rank) in position.items() if rank == 1]
|
|
104
|
+
if ranked:
|
|
105
|
+
return ranked[0]
|
|
106
|
+
return max(position, key=lambda k: position[k][0])
|