inferencekit-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. inferencekit/__init__.py +9 -0
  2. inferencekit/__main__.py +5 -0
  3. inferencekit/analysis/__init__.py +1 -0
  4. inferencekit/analysis/ncu_csv.py +284 -0
  5. inferencekit/analysis/nsys_sqlite.py +518 -0
  6. inferencekit/canonical.py +104 -0
  7. inferencekit/checkpoint.py +396 -0
  8. inferencekit/cli.py +274 -0
  9. inferencekit/cuda/bw_peak.cu +252 -0
  10. inferencekit/cuda/torch_bw.py +60 -0
  11. inferencekit/engines/__init__.py +7 -0
  12. inferencekit/engines/adapters.py +190 -0
  13. inferencekit/engines/clip/__init__.py +13 -0
  14. inferencekit/engines/clip/comfyui.py +749 -0
  15. inferencekit/engines/clip/diffusers_ltx.py +601 -0
  16. inferencekit/engines/clip/noise.py +46 -0
  17. inferencekit/engines/clip/official_ltx.py +316 -0
  18. inferencekit/engines/clip/server.py +542 -0
  19. inferencekit/engines/clip/stepdump.py +182 -0
  20. inferencekit/engines/gates.py +94 -0
  21. inferencekit/engines/sglang_worker.py +86 -0
  22. inferencekit/engines/vllm_worker.py +129 -0
  23. inferencekit/evidence.py +427 -0
  24. inferencekit/gpuinfo.py +93 -0
  25. inferencekit/hardware.py +135 -0
  26. inferencekit/mcp_server.py +178 -0
  27. inferencekit/ops/__init__.py +30 -0
  28. inferencekit/ops/bench.py +184 -0
  29. inferencekit/ops/bench_audio.py +384 -0
  30. inferencekit/ops/bench_clip.py +819 -0
  31. inferencekit/ops/bench_image.py +393 -0
  32. inferencekit/ops/bench_rounds.py +297 -0
  33. inferencekit/ops/bench_serve.py +866 -0
  34. inferencekit/ops/ceiling.py +135 -0
  35. inferencekit/ops/compare.py +378 -0
  36. inferencekit/ops/correctness.py +153 -0
  37. inferencekit/ops/doctor.py +486 -0
  38. inferencekit/ops/gates.py +1772 -0
  39. inferencekit/ops/gates_clip.py +930 -0
  40. inferencekit/ops/intake.py +1321 -0
  41. inferencekit/ops/ledger.py +749 -0
  42. inferencekit/ops/manifest.py +309 -0
  43. inferencekit/ops/ncu.py +183 -0
  44. inferencekit/ops/nsys.py +224 -0
  45. inferencekit/ops/prompts.py +316 -0
  46. inferencekit/ops/quality.py +2161 -0
  47. inferencekit/ops/quality_clip.py +690 -0
  48. inferencekit/ops/quality_clip_steps.py +341 -0
  49. inferencekit/ops/report.py +317 -0
  50. inferencekit/ops/review.py +119 -0
  51. inferencekit/ops/rocprof.py +89 -0
  52. inferencekit/ops/roofline.py +392 -0
  53. inferencekit/ops/store.py +61 -0
  54. inferencekit/proc.py +172 -0
  55. inferencekit/prompts/ATTRIBUTION.md +56 -0
  56. inferencekit/prompts/__init__.py +167 -0
  57. inferencekit/prompts/build.py +518 -0
  58. inferencekit/prompts/dev-load.jsonl.gz +0 -0
  59. inferencekit/prompts/dev-load.manifest.json +212 -0
  60. inferencekit/prompts/dev.jsonl.gz +0 -0
  61. inferencekit/prompts/dev.manifest.json +214 -0
  62. inferencekit/prompts/exclusions.json +54 -0
  63. inferencekit/quality/__init__.py +23 -0
  64. inferencekit/quality/client.py +66 -0
  65. inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
  66. inferencekit/quality/fake.py +329 -0
  67. inferencekit/quality/gsm8k.py +224 -0
  68. inferencekit/quality/logprobs.py +106 -0
  69. inferencekit/quality/metrics.py +235 -0
  70. inferencekit/quality/niah.py +63 -0
  71. inferencekit/quality/parquet.py +296 -0
  72. inferencekit/quality/sampling.py +262 -0
  73. inferencekit/quality/step_replay.py +217 -0
  74. inferencekit/quality/video_metrics.py +304 -0
  75. inferencekit/registry.py +206 -0
  76. inferencekit/review/__init__.py +6 -0
  77. inferencekit/review/diff.py +163 -0
  78. inferencekit/review/media.py +181 -0
  79. inferencekit/review/page.py +920 -0
  80. inferencekit/review/rundir.py +844 -0
  81. inferencekit/roofline_headers.py +504 -0
  82. inferencekit/schemas/evidence.v1.json +130 -0
  83. inferencekit/schemas/evidence.v2.json +499 -0
  84. inferencekit/schemas/intake.v1.json +276 -0
  85. inferencekit/schemas/run-manifest.v1.json +118 -0
  86. inferencekit/serve/__init__.py +8 -0
  87. inferencekit/serve/audio_client.py +330 -0
  88. inferencekit/serve/client.py +446 -0
  89. inferencekit/serve/clip_client.py +351 -0
  90. inferencekit/serve/clip_rounds.py +429 -0
  91. inferencekit/serve/image_client.py +373 -0
  92. inferencekit/serve/loadgen.py +136 -0
  93. inferencekit/serve/metrics.py +203 -0
  94. inferencekit/serve/profiles.py +105 -0
  95. inferencekit/serve/prompts_io.py +125 -0
  96. inferencekit/serve/rounds.py +1111 -0
  97. inferencekit/serve/servers.py +897 -0
  98. inferencekit/stats.py +249 -0
  99. inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
  100. inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
  101. inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
  102. inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  103. inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  104. inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
@@ -0,0 +1,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])