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,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