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,235 @@
|
|
|
1
|
+
"""Quality statistics: KL and TV on truncated top-k lists, position buckets, CIs, Wilson, McNemar.
|
|
2
|
+
|
|
3
|
+
KL(reference || engine) from top-k lists
|
|
4
|
+
----------------------------------------
|
|
5
|
+
Servers return only the k most likely tokens per position (k <= 20 for vLLM's default
|
|
6
|
+
``--max-logprobs``), so neither distribution is known in full. ``kl_topk`` builds two
|
|
7
|
+
comparable distributions over the same finite support:
|
|
8
|
+
|
|
9
|
+
1. Support U = the union of both top-k lists.
|
|
10
|
+
2. A token in U that one side did not list gets, on that side, an imputed probability
|
|
11
|
+
``min(p_min, tail / (m + 1))``: ``tail`` is that side's unlisted mass
|
|
12
|
+
(1 - sum of its listed probabilities), ``m`` is how many tokens of U it is missing,
|
|
13
|
+
and ``p_min`` is its smallest listed probability (an unlisted token cannot be more
|
|
14
|
+
likely than the k-th listed one). The "+1" leaves a share of the tail for the rest of
|
|
15
|
+
the vocabulary.
|
|
16
|
+
3. Everything outside U is merged into one OTHER bucket on each side: 1 - sum over U.
|
|
17
|
+
4. Probabilities are floored at 1e-10 and each side is renormalised, then
|
|
18
|
+
KL = sum_u p(u) log(p(u) / q(u)) over U and OTHER.
|
|
19
|
+
|
|
20
|
+
Properties: identical inputs give exactly 0; when the lists cover the whole vocabulary
|
|
21
|
+
the result is the exact KL; merging the tail into OTHER can only lower KL (data
|
|
22
|
+
processing inequality), while step 2 is an approximation in either direction. Treat
|
|
23
|
+
the value as a well-defined, comparable divergence between engines scored the same
|
|
24
|
+
way, not as the exact full-vocabulary KL.
|
|
25
|
+
|
|
26
|
+
Both lists must have the same length. A top-20 reference list against a top-5 engine
|
|
27
|
+
list puts 15 tokens on the engine side at imputed tail values, which reads as divergence
|
|
28
|
+
even for identical distributions (0.03 nats on a peaked 200-token toy model, more on
|
|
29
|
+
real vocabularies). ``quality score`` therefore cuts both lists to the engine's k (plus
|
|
30
|
+
the token actually generated) before calling ``kl_topk``, and pairs the noise floor at
|
|
31
|
+
that same k. A smaller k makes the value coarser (less sensitive), never biased upward.
|
|
32
|
+
|
|
33
|
+
Total variation from top-k lists
|
|
34
|
+
--------------------------------
|
|
35
|
+
``tv_topk`` builds the same two distributions (union support, imputed missing entries,
|
|
36
|
+
one OTHER bucket) and returns TV = 1/2 * sum |p(u) - q(u)| over U and OTHER: the share of
|
|
37
|
+
probability mass the engine moved, between 0 and 1. Identical inputs give exactly 0, full
|
|
38
|
+
lists give the exact TV, and merging the tail into OTHER can only lower it. Unlike KL it
|
|
39
|
+
stays bounded when one side gives a token almost no mass, so a single bad imputation
|
|
40
|
+
cannot dominate the mean. ``tv_counts`` is the same distance between two sample
|
|
41
|
+
histograms (``quality sampling`` tests draws against a known distribution instead:
|
|
42
|
+
``quality/sampling.py``).
|
|
43
|
+
|
|
44
|
+
Confidence intervals
|
|
45
|
+
--------------------
|
|
46
|
+
Tokens inside one generation are correlated, so every CI resamples whole prompts
|
|
47
|
+
(cluster bootstrap) and pools the per-token values of the drawn prompts. Paired CIs
|
|
48
|
+
(engine minus noise floor) draw the same prompts for both arms.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import math
|
|
54
|
+
import random
|
|
55
|
+
from fractions import Fraction
|
|
56
|
+
from typing import Hashable, Mapping, Sequence
|
|
57
|
+
|
|
58
|
+
from inferencekit.stats import Z95, _draw, percentile_ci
|
|
59
|
+
|
|
60
|
+
FLOOR = 1e-10
|
|
61
|
+
OTHER = "__other__"
|
|
62
|
+
# Position buckets (token index inside the generated text, 0-based).
|
|
63
|
+
BUCKETS = ((0, 15), (16, 63), (64, 255), (256, None))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bucket_label(lo: int, hi: int | None) -> str:
|
|
67
|
+
return f"{lo}+" if hi is None else f"{lo}-{hi}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
BUCKET_LABELS = tuple(bucket_label(lo, hi) for lo, hi in BUCKETS)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def bucket_of(position: int) -> str:
|
|
74
|
+
for lo, hi in BUCKETS:
|
|
75
|
+
if position >= lo and (hi is None or position <= hi):
|
|
76
|
+
return bucket_label(lo, hi)
|
|
77
|
+
raise ValueError(position)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --------------------------------------------------------------------------- KL
|
|
81
|
+
|
|
82
|
+
def _complete(known: Mapping[Hashable, float], support: set) -> dict:
|
|
83
|
+
"""Probabilities on ``support`` plus OTHER, from one side's listed logprobs."""
|
|
84
|
+
probs = {k: math.exp(lp) for k, lp in known.items() if lp is not None}
|
|
85
|
+
listed = sum(probs.values())
|
|
86
|
+
tail = max(0.0, 1.0 - listed)
|
|
87
|
+
missing = [k for k in support if k not in probs]
|
|
88
|
+
if missing:
|
|
89
|
+
cap = min(probs.values()) if probs else 1.0
|
|
90
|
+
each = max(min(cap, tail / (len(missing) + 1)), FLOOR)
|
|
91
|
+
for k in missing:
|
|
92
|
+
probs[k] = each
|
|
93
|
+
probs = {k: max(v, FLOOR) for k, v in probs.items() if k in support}
|
|
94
|
+
probs[OTHER] = max(1.0 - sum(probs.values()), FLOOR)
|
|
95
|
+
total = sum(probs.values())
|
|
96
|
+
return {k: v / total for k, v in probs.items()}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def kl_topk(reference: Mapping[Hashable, float], engine: Mapping[Hashable, float]) -> float:
|
|
100
|
+
"""KL(reference || engine) in nats from two {token: logprob} top-k lists (see module doc)."""
|
|
101
|
+
support = {k for k, v in reference.items() if v is not None} | {k for k, v in engine.items() if v is not None}
|
|
102
|
+
p = _complete(reference, support)
|
|
103
|
+
q = _complete(engine, support)
|
|
104
|
+
kl = 0.0
|
|
105
|
+
for k, pk in p.items():
|
|
106
|
+
if pk > 0:
|
|
107
|
+
kl += pk * math.log(pk / q[k])
|
|
108
|
+
return max(kl, 0.0)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def kl_full(p_logprobs: Sequence[float], q_logprobs: Sequence[float]) -> float:
|
|
112
|
+
"""Exact KL(p || q) for two full log-distributions over the same vocabulary."""
|
|
113
|
+
return sum(math.exp(a) * (a - b) for a, b in zip(p_logprobs, q_logprobs))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --------------------------------------------------------------------------- TV
|
|
117
|
+
|
|
118
|
+
def tv_topk(reference: Mapping[Hashable, float], engine: Mapping[Hashable, float]) -> float:
|
|
119
|
+
"""Total variation between two {token: logprob} top-k lists, on the support ``kl_topk`` uses."""
|
|
120
|
+
support = {k for k, v in reference.items() if v is not None} | {k for k, v in engine.items() if v is not None}
|
|
121
|
+
p = _complete(reference, support)
|
|
122
|
+
q = _complete(engine, support)
|
|
123
|
+
return min(1.0, 0.5 * sum(abs(p[k] - q[k]) for k in p))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def tv_full(p_logprobs: Sequence[float], q_logprobs: Sequence[float]) -> float:
|
|
127
|
+
"""Exact TV for two full log-distributions over the same vocabulary."""
|
|
128
|
+
return 0.5 * sum(abs(math.exp(a) - math.exp(b)) for a, b in zip(p_logprobs, q_logprobs))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def tv_counts(a: Mapping[Hashable, int], b: Mapping[Hashable, int]) -> float | None:
|
|
132
|
+
"""TV between the empirical distributions of two sample histograms ({outcome: count}); None if one is empty."""
|
|
133
|
+
na, nb = sum(a.values()), sum(b.values())
|
|
134
|
+
if na <= 0 or nb <= 0:
|
|
135
|
+
return None
|
|
136
|
+
return 0.5 * sum(abs(a.get(k, 0) / na - b.get(k, 0) / nb) for k in set(a) | set(b))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --------------------------------------------------------------------------- CIs over prompts
|
|
140
|
+
|
|
141
|
+
def pooled(units: Sequence[tuple[float, int]]) -> float | None:
|
|
142
|
+
"""Pooled mean sum(sums) / sum(counts) of per-prompt (sum, count) pairs."""
|
|
143
|
+
s = sum(u[0] for u in units)
|
|
144
|
+
n = sum(u[1] for u in units)
|
|
145
|
+
return s / n if n else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cluster_ci(units: Sequence[tuple[float, int]], resamples: int = 2000, seed: int = 0) -> tuple:
|
|
149
|
+
"""95% CI of the pooled mean, resampling prompts (each unit = one prompt's (sum, count))."""
|
|
150
|
+
units = [u for u in units if u[1] > 0]
|
|
151
|
+
k = len(units)
|
|
152
|
+
if k < 2 or resamples <= 0:
|
|
153
|
+
return (None, None)
|
|
154
|
+
rng = random.Random(seed)
|
|
155
|
+
reps = []
|
|
156
|
+
for _ in range(resamples):
|
|
157
|
+
s = n = 0.0
|
|
158
|
+
for _ in range(k):
|
|
159
|
+
a, b = units[_draw(rng, k)]
|
|
160
|
+
s += a
|
|
161
|
+
n += b
|
|
162
|
+
reps.append(s / n if n else float("nan"))
|
|
163
|
+
return percentile_ci(reps)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def paired_delta_ci(engine: Sequence[tuple[float, int]], floor: Sequence[tuple[float, int]],
|
|
167
|
+
resamples: int = 2000, seed: int = 0) -> dict:
|
|
168
|
+
"""pooled(engine) - pooled(floor) with a paired bootstrap 95% CI.
|
|
169
|
+
|
|
170
|
+
``engine[i]`` and ``floor[i]`` must be the same prompt; prompts where either arm has no
|
|
171
|
+
tokens are dropped. Each resample draws prompt indices once and uses them for both arms.
|
|
172
|
+
"""
|
|
173
|
+
pairs = [(e, f) for e, f in zip(engine, floor) if e[1] > 0 and f[1] > 0]
|
|
174
|
+
k = len(pairs)
|
|
175
|
+
out = {"n_prompts": k, "delta": None, "ci95": (None, None)}
|
|
176
|
+
if k == 0:
|
|
177
|
+
return out
|
|
178
|
+
out["delta"] = pooled([p[0] for p in pairs]) - pooled([p[1] for p in pairs])
|
|
179
|
+
if k < 2 or resamples <= 0:
|
|
180
|
+
return out
|
|
181
|
+
rng = random.Random(seed)
|
|
182
|
+
reps = []
|
|
183
|
+
for _ in range(resamples):
|
|
184
|
+
es = en = fs = fn = 0.0
|
|
185
|
+
for _ in range(k):
|
|
186
|
+
(a, b), (c, d) = pairs[_draw(rng, k)]
|
|
187
|
+
es += a
|
|
188
|
+
en += b
|
|
189
|
+
fs += c
|
|
190
|
+
fn += d
|
|
191
|
+
reps.append(es / en - fs / fn)
|
|
192
|
+
out["ci95"] = percentile_ci(reps)
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def margin_verdict(worse_lo: float | None, worse_hi: float | None, margin: float) -> tuple[str, str]:
|
|
197
|
+
"""PASS when the whole CI of "how much worse than the floor" is <= margin, FAIL when all of it is above.
|
|
198
|
+
|
|
199
|
+
``worse`` is oriented so that positive = the engine is worse than the noise floor.
|
|
200
|
+
"""
|
|
201
|
+
if worse_lo is None or worse_hi is None:
|
|
202
|
+
return "UNPROVEN", "no confidence interval (need >= 2 prompts scored for both the engine and the floor)"
|
|
203
|
+
if worse_hi <= margin + 1e-12:
|
|
204
|
+
return "PASS", f"CI [{worse_lo:.4g}, {worse_hi:.4g}] <= margin {margin:g}"
|
|
205
|
+
if worse_lo > margin + 1e-12:
|
|
206
|
+
return "FAIL", f"CI [{worse_lo:.4g}, {worse_hi:.4g}] > margin {margin:g}"
|
|
207
|
+
return "UNPROVEN", f"CI [{worse_lo:.4g}, {worse_hi:.4g}] straddles margin {margin:g}: score more prompts"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# --------------------------------------------------------------------------- task accuracy
|
|
211
|
+
|
|
212
|
+
def wilson_ci(successes: int, n: int, z: float = Z95) -> tuple[float | None, float | None]:
|
|
213
|
+
"""Wilson score interval for a binomial proportion (good coverage even near 0 or 1)."""
|
|
214
|
+
if n <= 0:
|
|
215
|
+
return (None, None)
|
|
216
|
+
p = successes / n
|
|
217
|
+
denom = 1 + z * z / n
|
|
218
|
+
centre = (p + z * z / (2 * n)) / denom
|
|
219
|
+
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
|
|
220
|
+
return (max(0.0, centre - half), min(1.0, centre + half))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def mcnemar_exact(b: int, c: int) -> float:
|
|
224
|
+
"""Two-sided exact McNemar p-value from the discordant counts.
|
|
225
|
+
|
|
226
|
+
b = items only arm A got right, c = items only arm B got right. Under H0 (same
|
|
227
|
+
accuracy) each discordant item is a fair coin, so min(b, c) ~ Binomial(b + c, 1/2).
|
|
228
|
+
Computed with integers (2**-n underflows a float for n > 1074).
|
|
229
|
+
"""
|
|
230
|
+
n = b + c
|
|
231
|
+
if n == 0:
|
|
232
|
+
return 1.0
|
|
233
|
+
k = min(b, c)
|
|
234
|
+
tail = sum(math.comb(n, i) for i in range(k + 1))
|
|
235
|
+
return float(min(Fraction(1), Fraction(2 * tail, 2 ** n)))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Needle in a haystack: can the engine still retrieve one fact from a long context?
|
|
2
|
+
|
|
3
|
+
Long-context paths (chunked prefill, paged/sliding attention, quantised KV cache, RoPE
|
|
4
|
+
scaling) can break retrieval while short-prompt quality looks fine. This builds, for a
|
|
5
|
+
target context length and a depth (0 = start, 1 = end), a haystack of synthetic filler
|
|
6
|
+
sentences with one "needle" sentence holding a random 7-digit code, then asks for the
|
|
7
|
+
code. Correct = the code's digits appear in the reply.
|
|
8
|
+
|
|
9
|
+
Everything is generated from a seed (no third-party text, nothing to license): the
|
|
10
|
+
filler is grammatical nonsense about rooms, boxes and colours, so it never contains a
|
|
11
|
+
competing code. Lengths are hit approximately: characters per token are measured once
|
|
12
|
+
with the server's ``/tokenize`` when it has one (else 4.0 is assumed), and the actual
|
|
13
|
+
prompt length reported by the server (``usage.prompt_tokens``) is recorded per item.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import random
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
_ADJ = ("quiet", "narrow", "amber", "distant", "hollow", "crowded", "frozen", "silver", "patient", "brittle")
|
|
22
|
+
_NOUN = ("archive", "harbour", "orchard", "workshop", "station", "library", "market", "garden", "tower", "bridge")
|
|
23
|
+
_PLACE = ("the northern valley", "the old quarter", "the river delta", "the high plateau", "the east coast",
|
|
24
|
+
"the lower town", "the island chain", "the western ridge")
|
|
25
|
+
_VERB = ("stores", "counts", "repairs", "ships", "sorts", "paints", "weighs", "labels")
|
|
26
|
+
_THING = ("wooden crates", "paper maps", "glass jars", "copper wires", "woollen blankets", "clay tiles",
|
|
27
|
+
"brass keys", "linen sacks")
|
|
28
|
+
_COLOR = ("red", "green", "blue", "violet", "orange", "teal", "golden", "grey")
|
|
29
|
+
_ANIMAL = ("falcon", "otter", "lynx", "heron", "badger", "salmon", "gecko", "raven")
|
|
30
|
+
|
|
31
|
+
QUESTION = "What is the access code for the {color} {animal} vault? Reply with the number only."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def filler_sentence(rng: random.Random) -> str:
|
|
35
|
+
# Small numbers in words only, so the filler never contains a digit string like the code.
|
|
36
|
+
return (f"Every morning the {rng.choice(_ADJ)} {rng.choice(_NOUN)} of {rng.choice(_PLACE)} "
|
|
37
|
+
f"{rng.choice(_VERB)} {rng.choice(('two', 'five', 'nine', 'twelve', 'forty'))} "
|
|
38
|
+
f"{rng.choice(_THING)}.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build(target_tokens: int, depth: float, seed: int, chars_per_token: float = 4.0) -> dict:
|
|
42
|
+
"""{messages, code, depth, target_tokens, needle} for one haystack item."""
|
|
43
|
+
rng = random.Random(f"niah:{seed}:{target_tokens}:{depth}")
|
|
44
|
+
color, animal = rng.choice(_COLOR), rng.choice(_ANIMAL)
|
|
45
|
+
code = str(rng.randrange(1_000_000, 10_000_000))
|
|
46
|
+
needle = f"The access code for the {color} {animal} vault is {code}."
|
|
47
|
+
question = QUESTION.format(color=color, animal=animal)
|
|
48
|
+
budget = max(0, int(target_tokens * chars_per_token) - len(needle) - len(question) - 200)
|
|
49
|
+
sentences, size = [], 0
|
|
50
|
+
while size < budget:
|
|
51
|
+
s = filler_sentence(rng)
|
|
52
|
+
sentences.append(s)
|
|
53
|
+
size += len(s) + 1
|
|
54
|
+
pos = round(min(max(depth, 0.0), 1.0) * len(sentences))
|
|
55
|
+
sentences.insert(pos, needle)
|
|
56
|
+
context = " ".join(sentences)
|
|
57
|
+
content = (f"Read the following document.\n\n<document>\n{context}\n</document>\n\n{question}")
|
|
58
|
+
return {"messages": [{"role": "user", "content": content}], "code": code, "depth": depth,
|
|
59
|
+
"target_tokens": target_tokens, "needle_sentence_index": pos, "sentences": len(sentences)}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def found(reply: str | None, code: str) -> bool:
|
|
63
|
+
return bool(reply) and code in re.sub(r"[\s,._'-]", "", reply)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""A small, dependency-free Parquet reader: just enough to read a flat table of string columns.
|
|
2
|
+
|
|
3
|
+
It exists so ``quality gsm8k`` can read the Hugging Face copy of GSM8K (a Parquet file)
|
|
4
|
+
without pulling in pyarrow. Supported: flat schemas (no nested or repeated columns),
|
|
5
|
+
BYTE_ARRAY / INT32 / INT64 columns, REQUIRED or OPTIONAL, PLAIN and dictionary encodings
|
|
6
|
+
(PLAIN_DICTIONARY / RLE_DICTIONARY), data pages v1 and v2, and the UNCOMPRESSED, SNAPPY
|
|
7
|
+
and GZIP codecs. Anything else raises ``ParquetError`` instead of returning wrong data.
|
|
8
|
+
|
|
9
|
+
Format reference: github.com/apache/parquet-format (parquet.thrift, Encodings.md); the
|
|
10
|
+
footer and page headers use the Thrift compact protocol; Snappy is the raw block format.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import struct
|
|
16
|
+
import zlib
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ParquetError(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ----------------------------------------------------------------------------- thrift compact protocol
|
|
25
|
+
|
|
26
|
+
class _Thrift:
|
|
27
|
+
"""Reads Thrift compact-protocol structs as {field_id: value} dicts."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, buf: bytes, pos: int = 0):
|
|
30
|
+
self.buf, self.pos = buf, pos
|
|
31
|
+
|
|
32
|
+
def byte(self) -> int:
|
|
33
|
+
if self.pos >= len(self.buf):
|
|
34
|
+
raise ParquetError("truncated thrift data")
|
|
35
|
+
b = self.buf[self.pos]
|
|
36
|
+
self.pos += 1
|
|
37
|
+
return b
|
|
38
|
+
|
|
39
|
+
def varint(self) -> int:
|
|
40
|
+
shift = result = 0
|
|
41
|
+
while True:
|
|
42
|
+
b = self.byte()
|
|
43
|
+
result |= (b & 0x7F) << shift
|
|
44
|
+
if not b & 0x80:
|
|
45
|
+
return result
|
|
46
|
+
shift += 7
|
|
47
|
+
|
|
48
|
+
def zigzag(self) -> int:
|
|
49
|
+
n = self.varint()
|
|
50
|
+
return (n >> 1) ^ -(n & 1)
|
|
51
|
+
|
|
52
|
+
def binary(self) -> bytes:
|
|
53
|
+
n = self.varint()
|
|
54
|
+
out = self.buf[self.pos:self.pos + n]
|
|
55
|
+
if len(out) != n:
|
|
56
|
+
raise ParquetError("truncated thrift binary")
|
|
57
|
+
self.pos += n
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
def value(self, t: int):
|
|
61
|
+
if t in (1, 2): # bool inside a container: one byte, 1 = true
|
|
62
|
+
return self.byte() == 1
|
|
63
|
+
if t == 3:
|
|
64
|
+
return struct.unpack("b", bytes([self.byte()]))[0]
|
|
65
|
+
if t in (4, 5, 6):
|
|
66
|
+
return self.zigzag()
|
|
67
|
+
if t == 7:
|
|
68
|
+
out = struct.unpack_from("<d", self.buf, self.pos)[0]
|
|
69
|
+
self.pos += 8
|
|
70
|
+
return out
|
|
71
|
+
if t == 8:
|
|
72
|
+
return self.binary()
|
|
73
|
+
if t in (9, 10):
|
|
74
|
+
head = self.byte()
|
|
75
|
+
size, et = head >> 4, head & 0x0F
|
|
76
|
+
if size == 15:
|
|
77
|
+
size = self.varint()
|
|
78
|
+
return [self.value(et) for _ in range(size)]
|
|
79
|
+
if t == 11:
|
|
80
|
+
size = self.varint()
|
|
81
|
+
if not size:
|
|
82
|
+
return {}
|
|
83
|
+
kv = self.byte()
|
|
84
|
+
return {self.value(kv >> 4): self.value(kv & 0x0F) for _ in range(size)}
|
|
85
|
+
if t == 12:
|
|
86
|
+
return self.struct()
|
|
87
|
+
raise ParquetError(f"unknown thrift type {t}")
|
|
88
|
+
|
|
89
|
+
def struct(self) -> dict:
|
|
90
|
+
out, last = {}, 0
|
|
91
|
+
while True:
|
|
92
|
+
head = self.byte()
|
|
93
|
+
if head == 0:
|
|
94
|
+
return out
|
|
95
|
+
delta, t = head >> 4, head & 0x0F
|
|
96
|
+
fid = last + delta if delta else self.zigzag()
|
|
97
|
+
last = fid
|
|
98
|
+
out[fid] = True if t == 1 else False if t == 2 else self.value(t)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ----------------------------------------------------------------------------- codecs and encodings
|
|
102
|
+
|
|
103
|
+
def snappy_decompress(data: bytes) -> bytes:
|
|
104
|
+
"""Raw (unframed) Snappy block decompression."""
|
|
105
|
+
t = _Thrift(data)
|
|
106
|
+
size = t.varint()
|
|
107
|
+
out = bytearray()
|
|
108
|
+
pos, n = t.pos, len(data)
|
|
109
|
+
while pos < n:
|
|
110
|
+
tag = data[pos]
|
|
111
|
+
pos += 1
|
|
112
|
+
kind = tag & 3
|
|
113
|
+
if kind == 0: # literal
|
|
114
|
+
ln = tag >> 2
|
|
115
|
+
if ln >= 60:
|
|
116
|
+
nb = ln - 59
|
|
117
|
+
ln = int.from_bytes(data[pos:pos + nb], "little")
|
|
118
|
+
pos += nb
|
|
119
|
+
ln += 1
|
|
120
|
+
out += data[pos:pos + ln]
|
|
121
|
+
pos += ln
|
|
122
|
+
continue
|
|
123
|
+
if kind == 1:
|
|
124
|
+
ln = ((tag >> 2) & 7) + 4
|
|
125
|
+
off = ((tag >> 5) << 8) | data[pos]
|
|
126
|
+
pos += 1
|
|
127
|
+
elif kind == 2:
|
|
128
|
+
ln = (tag >> 2) + 1
|
|
129
|
+
off = int.from_bytes(data[pos:pos + 2], "little")
|
|
130
|
+
pos += 2
|
|
131
|
+
else:
|
|
132
|
+
ln = (tag >> 2) + 1
|
|
133
|
+
off = int.from_bytes(data[pos:pos + 4], "little")
|
|
134
|
+
pos += 4
|
|
135
|
+
if off <= 0 or off > len(out):
|
|
136
|
+
raise ParquetError("corrupt snappy data (bad copy offset)")
|
|
137
|
+
start = len(out) - off
|
|
138
|
+
if off >= ln:
|
|
139
|
+
out += out[start:start + ln]
|
|
140
|
+
else: # overlapping copy: repeat the last `off` bytes
|
|
141
|
+
for i in range(ln):
|
|
142
|
+
out.append(out[start + i])
|
|
143
|
+
if len(out) != size:
|
|
144
|
+
raise ParquetError(f"corrupt snappy data ({len(out)} bytes, header says {size})")
|
|
145
|
+
return bytes(out)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _decompress(codec: int, data: bytes, size: int) -> bytes:
|
|
149
|
+
if codec == 0:
|
|
150
|
+
return data
|
|
151
|
+
if codec == 1:
|
|
152
|
+
return snappy_decompress(data)
|
|
153
|
+
if codec == 2:
|
|
154
|
+
return zlib.decompress(data, 47) # gzip or zlib header
|
|
155
|
+
raise ParquetError(f"unsupported compression codec {codec} (supported: none, snappy, gzip)")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _hybrid(buf: bytes, pos: int, end: int, width: int, count: int) -> list[int]:
|
|
159
|
+
"""RLE / bit-packed hybrid decoding of ``count`` values of ``width`` bits from buf[pos:end]."""
|
|
160
|
+
out: list[int] = []
|
|
161
|
+
if width == 0:
|
|
162
|
+
return [0] * count
|
|
163
|
+
nbytes = (width + 7) // 8
|
|
164
|
+
mask = (1 << width) - 1
|
|
165
|
+
t = _Thrift(buf, pos)
|
|
166
|
+
while len(out) < count and t.pos < end:
|
|
167
|
+
head = t.varint()
|
|
168
|
+
if head & 1: # bit-packed: (head >> 1) groups of 8 values
|
|
169
|
+
ngroups = head >> 1
|
|
170
|
+
raw = int.from_bytes(buf[t.pos:t.pos + ngroups * width], "little")
|
|
171
|
+
t.pos += ngroups * width
|
|
172
|
+
out.extend((raw >> (i * width)) & mask for i in range(ngroups * 8))
|
|
173
|
+
else: # run of one value
|
|
174
|
+
value = int.from_bytes(buf[t.pos:t.pos + nbytes], "little")
|
|
175
|
+
t.pos += nbytes
|
|
176
|
+
out.extend([value] * (head >> 1))
|
|
177
|
+
if len(out) < count:
|
|
178
|
+
raise ParquetError("RLE data ended early")
|
|
179
|
+
return out[:count]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _plain(ptype: int, buf: bytes, pos: int, count: int) -> list:
|
|
183
|
+
if ptype == 6: # BYTE_ARRAY
|
|
184
|
+
out = []
|
|
185
|
+
for _ in range(count):
|
|
186
|
+
(ln,) = struct.unpack_from("<I", buf, pos)
|
|
187
|
+
out.append(bytes(buf[pos + 4:pos + 4 + ln]))
|
|
188
|
+
pos += 4 + ln
|
|
189
|
+
return out
|
|
190
|
+
if ptype in (1, 2): # INT32, INT64
|
|
191
|
+
fmt = "<i" if ptype == 1 else "<q"
|
|
192
|
+
return list(struct.unpack_from(f"<{count}{fmt[1]}", buf, pos))
|
|
193
|
+
raise ParquetError(f"unsupported physical type {ptype}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ----------------------------------------------------------------------------- reading
|
|
197
|
+
|
|
198
|
+
def _column(data: bytes, meta: dict, ptype: int, max_def: int) -> list:
|
|
199
|
+
codec, total = meta.get(4, 0), meta.get(5, 0)
|
|
200
|
+
first_data, first_dict = meta.get(9), meta.get(11)
|
|
201
|
+
start = first_dict if first_dict and (first_data is None or first_dict < first_data) else first_data
|
|
202
|
+
dictionary = None
|
|
203
|
+
values: list = []
|
|
204
|
+
pos = start
|
|
205
|
+
while len(values) < total:
|
|
206
|
+
t = _Thrift(data, pos)
|
|
207
|
+
header = t.struct()
|
|
208
|
+
pos = t.pos
|
|
209
|
+
ptype_page, usize, csize = header.get(1), header.get(2, 0), header.get(3, 0)
|
|
210
|
+
body = data[pos:pos + csize]
|
|
211
|
+
pos += csize
|
|
212
|
+
if ptype_page == 2: # dictionary page
|
|
213
|
+
dh = header.get(7) or {}
|
|
214
|
+
dictionary = _plain(ptype, _decompress(codec, body, usize), 0, dh.get(1, 0))
|
|
215
|
+
continue
|
|
216
|
+
if ptype_page == 0: # data page v1: [def levels][values], all compressed together
|
|
217
|
+
dh = header.get(5) or {}
|
|
218
|
+
n, enc = dh.get(1, 0), dh.get(2, 0)
|
|
219
|
+
page = _decompress(codec, body, usize)
|
|
220
|
+
p = 0
|
|
221
|
+
if max_def:
|
|
222
|
+
(ln,) = struct.unpack_from("<I", page, 0)
|
|
223
|
+
defs = _hybrid(page, 4, 4 + ln, max_def.bit_length(), n)
|
|
224
|
+
p = 4 + ln
|
|
225
|
+
else:
|
|
226
|
+
defs = [0] * n
|
|
227
|
+
elif ptype_page == 3: # data page v2: [rep levels][def levels] uncompressed, then values
|
|
228
|
+
dh = header.get(8) or {}
|
|
229
|
+
n, enc = dh.get(1, 0), dh.get(4, 0)
|
|
230
|
+
dlen, rlen = dh.get(5, 0), dh.get(6, 0)
|
|
231
|
+
if rlen:
|
|
232
|
+
raise ParquetError("repeated columns are not supported")
|
|
233
|
+
defs = _hybrid(body, 0, dlen, max_def.bit_length(), n) if max_def else [0] * n
|
|
234
|
+
rest = body[dlen:]
|
|
235
|
+
page = _decompress(codec, rest, usize - dlen) if dh.get(7, True) else rest
|
|
236
|
+
p = 0
|
|
237
|
+
else:
|
|
238
|
+
continue # index pages and anything else carry no values
|
|
239
|
+
present = sum(1 for d in defs if d == max_def)
|
|
240
|
+
if enc == 0:
|
|
241
|
+
vals = _plain(ptype, page, p, present)
|
|
242
|
+
elif enc in (2, 8):
|
|
243
|
+
if dictionary is None:
|
|
244
|
+
raise ParquetError("dictionary-encoded page without a dictionary")
|
|
245
|
+
width = page[p]
|
|
246
|
+
idx = _hybrid(page, p + 1, len(page), width, present)
|
|
247
|
+
vals = [dictionary[i] for i in idx]
|
|
248
|
+
else:
|
|
249
|
+
raise ParquetError(f"unsupported value encoding {enc}")
|
|
250
|
+
it = iter(vals)
|
|
251
|
+
values.extend(next(it) if d == max_def else None for d in defs)
|
|
252
|
+
return values
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def read_table(source: str | Path | bytes) -> dict[str, list]:
|
|
256
|
+
"""{column name: values} of a flat Parquet file (BYTE_ARRAY values as bytes; None for nulls).
|
|
257
|
+
|
|
258
|
+
A malformed file raises ``ParquetError`` (a ``ValueError``), never a low-level decoding error."""
|
|
259
|
+
data = source if isinstance(source, (bytes, bytearray)) else Path(source).read_bytes()
|
|
260
|
+
try:
|
|
261
|
+
return _read_table(data)
|
|
262
|
+
except ParquetError:
|
|
263
|
+
raise
|
|
264
|
+
except (struct.error, zlib.error, IndexError, KeyError, TypeError, AttributeError, ValueError,
|
|
265
|
+
OverflowError, RecursionError) as e: # offsets or lengths that point outside the file, bad codecs
|
|
266
|
+
raise ParquetError(f"malformed Parquet file ({type(e).__name__}: {e})") from None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _read_table(data: bytes) -> dict[str, list]:
|
|
270
|
+
if len(data) < 12 or data[:4] != b"PAR1" or data[-4:] != b"PAR1":
|
|
271
|
+
raise ParquetError("not a Parquet file (missing PAR1 magic)")
|
|
272
|
+
(meta_len,) = struct.unpack_from("<I", data, len(data) - 8)
|
|
273
|
+
meta = _Thrift(data, len(data) - 8 - meta_len).struct()
|
|
274
|
+
schema = meta.get(2) or []
|
|
275
|
+
if not schema:
|
|
276
|
+
raise ParquetError("Parquet file without a schema")
|
|
277
|
+
leaves = schema[1:]
|
|
278
|
+
if any(el.get(5) for el in leaves):
|
|
279
|
+
raise ParquetError("nested columns are not supported")
|
|
280
|
+
info = {}
|
|
281
|
+
for el in leaves:
|
|
282
|
+
name = el[4].decode("utf-8")
|
|
283
|
+
rep = el.get(3, 0)
|
|
284
|
+
if rep == 2:
|
|
285
|
+
raise ParquetError(f"column {name!r} is repeated; not supported")
|
|
286
|
+
info[name] = (el.get(1), 1 if rep == 1 else 0)
|
|
287
|
+
out: dict[str, list] = {name: [] for name in info}
|
|
288
|
+
for rg in meta.get(4) or []:
|
|
289
|
+
for chunk in rg.get(1) or []:
|
|
290
|
+
cm = chunk.get(3) or {}
|
|
291
|
+
name = b".".join(cm.get(3) or []).decode("utf-8")
|
|
292
|
+
if name not in info:
|
|
293
|
+
raise ParquetError(f"column {name!r} is not in the schema")
|
|
294
|
+
ptype, max_def = info[name]
|
|
295
|
+
out[name].extend(_column(data, cm, ptype, max_def))
|
|
296
|
+
return out
|