polypress 0.2.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.
polypress/turbo.py ADDED
@@ -0,0 +1,1515 @@
1
+ """Turbo: the speed fork. Same ideas as fast.py, none of the double encodes.
2
+
3
+ Master spends 83-92% of encode time compressing bytes it then throws away
4
+ (measured 2026-08-03, cProfile, 10 MB slices):
5
+
6
+ cdc_nndss 2.38s total, 1.97s of it the plain-fallback guard (83%)
7
+ chicago_permits 13.02s total, only ~1.1s producing bytes anyone keeps (9%)
8
+
9
+ The guards exist for one structural reason: master concatenates every column
10
+ into ONE lzma stream, so no column has a size of its own. Shrinking column A
11
+ can swell column B, so nothing can be judged locally and every decision has to
12
+ be made by encoding the whole table twice and comparing.
13
+
14
+ Measured cost of making the streams independent (2026-08-03):
15
+
16
+ payload cdc_nndss chicago_permits usgs_quakes seattle_fire911
17
+ binary columns +4.2% +5.2% +0.2% +0.3%
18
+ string groups +13.2% +13.1% -1.0% -12.4%
19
+
20
+ Cheap for the binary payloads, expensive for strings on tables whose text
21
+ columns resemble each other. So turbo splits the binary payloads (and gets
22
+ parallelism and local decisions from it) and keeps the string groups in one
23
+ pile by default -- `text_mode` exposes the trade because it goes both ways.
24
+
25
+ What that buys, and it is the whole point: **a column's compressed size is now
26
+ its own**, so every decision can be measured on that column alone instead of by
27
+ re-encoding the table. No never-worse guard, no lenient/strict second pass, no
28
+ 2D on/off second pass, no front-coding trial over the whole pile.
29
+
30
+ Threading is free on top: lzma and bz2 release the GIL (measured 2.53x on 4
31
+ threads for xz-9e, 3.85x for bzip2), so the per-column compressions run
32
+ concurrently with no pickling and no copied table.
33
+
34
+ NOT byte-compatible with fast.py, and deliberately so -- this is a different
35
+ container (`PPZT`). Invariant 1 binds fast.py and the C encoder to each other;
36
+ it does not bind this.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import bz2
42
+ import json
43
+ import lzma
44
+ import os
45
+ import sys
46
+ from concurrent.futures import ThreadPoolExecutor
47
+ from typing import Dict, List, Optional, Tuple
48
+
49
+ import numpy as np
50
+
51
+ from . import dtz
52
+ from . import fast
53
+
54
+ MAGIC = b"PPZT"
55
+
56
+ # Kept identical to fast.py so the two produce comparable payloads and any size
57
+ # difference is attributable to the container and the decisions, not the codec.
58
+ XZ = fast.XZ
59
+ PROBE = fast._PROBE
60
+
61
+ # How many rows a decision may look at. Every choice here is a comparison
62
+ # between two orderings of the same column, and a comparison does not need the
63
+ # whole column -- master probes full columns and spends 1.06s of a 13s encode
64
+ # doing it. Generous enough that a rare value still appears.
65
+ DECIDE_ROWS = 20000
66
+
67
+ # The sample-scale model check that replaces the plain fallback. Validated on
68
+ # 81 corpus100 datasets 2026-08-03: at margin 0.95 it costs 1,575 bytes of
69
+ # 30,335,521 (+0.005%) against always encoding both ways, and runs in 0.11s
70
+ # where the thing it replaces is 31x dearer.
71
+ #
72
+ # The margin is one-sided on purpose. A sample under-rates the columnar model
73
+ # in two ways that both push the same direction -- fixed costs (alphabets,
74
+ # metadata) do not shrink with the sample while the payload does, and the
75
+ # reorder win grows with row count. So the sample never over-rates the model,
76
+ # and requiring the row-wise candidate to win by a margin cannot discard a win.
77
+ SAMPLE_ROWS = 4000
78
+ SAMPLE_MARGIN_NUM, SAMPLE_MARGIN_DEN = 95, 100
79
+
80
+ _CPUS = max(1, (os.cpu_count() or 2) - 1)
81
+ # Least binary payload worth giving its own compressor. Below this the thread
82
+ # saves no wall-clock and the lost cross-column sharing is pure cost.
83
+ BIN_GROUP_BYTES = 4 << 20
84
+ # How many string groups get a whole-pile front-coding trial.
85
+ #
86
+ # Turbo's trials are cheap probes rather than master's full-strength
87
+ # compressions, so more of them LOOK affordable. Measured on the bench set,
88
+ # more of them are worse: 1 -> -10.90%, 2 -> -10.25%, 3 -> -9.81%, 8 -> -9.19%,
89
+ # monotonically. The reason is the same one that broke the row-wise probe --
90
+ # a decision taken at preset 1 does not transfer to preset 9e, so every extra
91
+ # trial is another chance to be confidently wrong. Cheap trials do not mean
92
+ # more trials.
93
+ FRONT_CANDIDATES = 1
94
+ # Each concurrent lzma encoder wants its own dictionary. Measured 2026-08-03:
95
+ # 4 threads on a 10 MB table cost +486 MB. Cap the pool by input size so a big
96
+ # table cannot walk through the 1-2 GB ceiling.
97
+ _XZ_ENCODER_MB = 130
98
+
99
+
100
+ def _pool_size(nbytes: int) -> int:
101
+ """Threads we can afford for compression on an input of this size."""
102
+ budget_mb = 900
103
+ per = min(_XZ_ENCODER_MB, 40 + nbytes / 1e6)
104
+ return max(1, min(_CPUS, int(budget_mb // max(per, 1))))
105
+
106
+
107
+ # ------------------------------------------------------------------ helpers
108
+
109
+ def _xz(b: bytes) -> bytes:
110
+ return lzma.compress(b, **XZ)
111
+
112
+
113
+ def _unxz(b: bytes) -> bytes:
114
+ return lzma.decompress(b, **XZ)
115
+
116
+
117
+ def _probe(b: bytes) -> int:
118
+ return len(lzma.compress(b, **PROBE))
119
+
120
+
121
+ # The string pile is decided with a STRONGER probe than everything else, and
122
+ # it has to be. Measured on `opendata_howard_county` 2026-08-03, varying only
123
+ # the preset used for the front-coding trials:
124
+ #
125
+ # preset 1 354,059 (+6.29% vs master) 0.66s
126
+ # preset 4 354,059 (+6.29%) 1.05s
127
+ # preset 6 333,124 (+0.01%) 1.37s
128
+ # preset 9e 333,124 (+0.01%) 1.45s
129
+ #
130
+ # The entire deficit on that table was the probe being too weak to rank the
131
+ # candidates the way the real entropy stage would -- the same mismatch that
132
+ # makes the row-wise sample probe unreliable. Preset 6 is where the ranking
133
+ # converges, and it costs 0.7s where 9e costs 0.8s.
134
+ #
135
+ # It matters here and not elsewhere because this is the one decision taken over
136
+ # the whole shared pile rather than over a single independent column.
137
+ FRONT_PROBE = dict(format=lzma.FORMAT_RAW,
138
+ filters=[{"id": lzma.FILTER_LZMA2, "preset": 6}])
139
+
140
+
141
+ # The pile trials only have to RANK candidates, and a prefix ranks them the
142
+ # same way the whole pile does. Measured on three tables, truncating to 0.5 MB
143
+ # changed no decision at all while cutting the trial cost by a third:
144
+ #
145
+ # howard_county full 333,124 / 1.39s 0.5 MB 333,124 / 0.83s
146
+ # text_heavy full 907,989 / 3.54s 0.5 MB 907,989 / 2.19s
147
+ # mixed_wide full 445,069 / 1.55s 0.5 MB 445,069 / 1.01s
148
+ #
149
+ # 1 MB is used rather than 0.5 for margin -- the saving between them is small
150
+ # and a prefix that misses a whole group would not.
151
+ FRONT_PROBE_BYTES = 1 << 20
152
+
153
+
154
+ def _probe_front(b: bytes) -> int:
155
+ return len(lzma.compress(b[:FRONT_PROBE_BYTES], **FRONT_PROBE))
156
+
157
+
158
+ def _stable_perm(ids: np.ndarray) -> np.ndarray:
159
+ return np.argsort(ids, kind="stable")
160
+
161
+
162
+ # --------------------------------------------------------------- decisions
163
+
164
+ def _as_dict_or_text(cells, j, nrows) -> dict:
165
+ uniq = sorted(set(cells))
166
+ if len(uniq) <= fast.DICT_MAX and len(uniq) * 2 <= max(nrows, 2):
167
+ idx = {v: i for i, v in enumerate(uniq)}
168
+ return {"kind": "dict", "alpha": uniq,
169
+ "ids": np.array([idx[v] for v in cells], dtype=np.int64),
170
+ "j": j}
171
+ return {"kind": "text", "cells": cells, "j": j}
172
+
173
+
174
+ # Rows sampled when searching for a numeric column's predictor. The search is
175
+ # O(numeric columns^2) array subtractions, so it runs on a sample and the
176
+ # winner is confirmed on the full column.
177
+ PRED_SAMPLE = 2500
178
+ # A predictor must beat storing the column on its own by this much before it is
179
+ # worth the metadata and the decode dependency.
180
+ PRED_MARGIN_NUM, PRED_MARGIN_DEN = 15, 16
181
+
182
+
183
+ def _packed_cost(a: np.ndarray) -> int:
184
+ k = fast.diff_order(a)
185
+ return fast.packed_len(np.diff(a, n=k) if k else a)
186
+
187
+
188
+ def pick_num_parents(plan, nrows) -> Dict[int, Optional[int]]:
189
+ """Predict a numeric column from ANOTHER numeric column.
190
+
191
+ Polypress differences down a column (time) and never across (columns), so
192
+ a table of nested aggregates is invisible to it. `covid_19_vaccinations` is
193
+ 80 columns of the same measurement sliced by age --
194
+ series_complete_5plus >= series_complete_12plus >= series_complete_18plus,
195
+ each a subset count of the last -- so a neighbouring column predicts this
196
+ one far better than its own past does. Measured 2026-08-03: 26.2% off the
197
+ packed numeric payload, with `booster_doses` going 84,413 -> 20,126 from
198
+ `booster_doses_5plus`.
199
+
200
+ That table is exactly where master loses 21.9% to plain compression, and
201
+ the reason is this: its redundancy runs ACROSS the row, and the only thing
202
+ in the codec that could see it was the row-wise candidate.
203
+
204
+ No compression is needed to decide. `pack_ints` already prices a residual,
205
+ so the whole search is integer subtraction and `packed_len`.
206
+
207
+ Cycles are impossible by construction: a column may only be predicted from
208
+ one already placed, exactly as `pick_parents` does for dictionaries, and
209
+ the resulting order is stored so the decoder can walk it.
210
+ """
211
+ nums = [i for i, c in enumerate(plan)
212
+ if c["kind"] == "num" and c["ints"].size == nrows]
213
+ out: Dict[int, Optional[int]] = {i: None for i in nums}
214
+ if len(nums) < 2 or nrows < fast.MIN_ROWS_FOR_PARENTS:
215
+ return out
216
+
217
+ step = max(1, nrows // PRED_SAMPLE)
218
+ samp = {i: np.ascontiguousarray(plan[i]["ints"][::step]) for i in nums}
219
+ own = {i: _packed_cost(samp[i]) for i in nums}
220
+
221
+ # Only commensurable columns: same decimal count, and magnitudes within a
222
+ # factor. Subtracting a percentage from a count is arithmetically fine and
223
+ # never smaller.
224
+ hi = {i: (int(np.abs(samp[i]).max()) or 1) for i in nums}
225
+ cand: Dict[int, List[Tuple[int, int]]] = {}
226
+ for i in nums:
227
+ row = []
228
+ for j in nums:
229
+ if i == j or plan[i]["dec"] != plan[j]["dec"]:
230
+ continue
231
+ if hi[i] > 64 * hi[j] or hi[j] > 64 * hi[i]:
232
+ continue
233
+ c = _packed_cost(samp[i] - samp[j])
234
+ if c * PRED_MARGIN_DEN < own[i] * PRED_MARGIN_NUM:
235
+ row.append((c, j))
236
+ row.sort(key=lambda r: (r[0], r[1]))
237
+ cand[i] = row
238
+
239
+ # Greedy, in the same shape as pick_parents: start from the column that is
240
+ # cheapest on its own, then repeatedly attach whichever remaining column
241
+ # gains most from something already placed.
242
+ placed = set()
243
+ root = min(nums, key=lambda i: (own[i], i))
244
+ placed.add(root)
245
+ remaining = [i for i in nums if i != root]
246
+ while remaining:
247
+ best = None
248
+ for i in remaining:
249
+ for c, j in cand[i]:
250
+ if j in placed:
251
+ gain = own[i] - c
252
+ if best is None or gain > best[0]:
253
+ best = (gain, i, j)
254
+ break
255
+ if best is None:
256
+ nxt = min(remaining, key=lambda i: (own[i], i))
257
+ out[nxt] = None
258
+ placed.add(nxt)
259
+ remaining.remove(nxt)
260
+ continue
261
+ _g, i, j = best
262
+ out[i] = j
263
+ placed.add(i)
264
+ remaining.remove(i)
265
+
266
+ # Confirm each choice on the FULL column -- the search ran on a sample and
267
+ # a sample of a differenced series exaggerates the residuals.
268
+ for i in nums:
269
+ j = out[i]
270
+ if j is None:
271
+ continue
272
+ full_own = _packed_cost(plan[i]["ints"])
273
+ full_pred = _packed_cost(plan[i]["ints"] - plan[j]["ints"])
274
+ if full_pred * PRED_MARGIN_DEN >= full_own * PRED_MARGIN_NUM:
275
+ out[i] = None
276
+ return out
277
+
278
+
279
+ # --------------------------------------------------- derived string columns
280
+ #
281
+ # Real tables republish the same value in several columns at different
282
+ # precisions and formats. `traffic_incidents` stores one point three times:
283
+ #
284
+ # longitude = "-114.09470548026574"
285
+ # latitude = "51.034986038356934"
286
+ # point = "POINT (-114.09470548026574 51.034986038356934)"
287
+ # id = "2026-07-31T00:38:59" + latitude + longitude
288
+ #
289
+ # No general compressor can see through that, because the copies are far apart
290
+ # in the file and formatted differently -- and a COLUMN codec cannot either,
291
+ # because it never compares two columns. It is the reason `traffic` is one of
292
+ # the tables where master falls back to compressing the raw rows.
293
+ #
294
+ # Two patterns do nearly all of the available gain, measured on the unbiased
295
+ # 100 (`results/cross-column-summary.txt`): geometry republished as text, and
296
+ # keys built by pasting other columns together. Both are the same shape -- a
297
+ # template of literals and slices of other columns -- so both are built here
298
+ # and nothing more general is attempted.
299
+ #
300
+ # The trap already paid for: a SUBSTRING detector finds almost nothing, because
301
+ # the duplication is usually at a different precision. Matching whole cell
302
+ # values and their prefixes is what makes it fire.
303
+
304
+ DERIVE_PROBE_ROWS = 200
305
+ DERIVE_MIN_PART = 5
306
+ # A SCREEN, not the decision. The decision is the measured never-worse check at
307
+ # the bottom of find_derived, which compares the template plus its exceptions
308
+ # against simply storing the column.
309
+ #
310
+ # This started at 5% and that was the all-or-nothing mistake CLAUDE.md warns
311
+ # about. `traffic`'s `id` is start_dt + latitude + longitude for 62% of its
312
+ # rows and something else for the rest, so a 5% cap threw away a formula that
313
+ # covers nearly two thirds of a column holding 50,000 distinct 55-character
314
+ # strings. A partly-right model is still worth far more than no model.
315
+ DERIVE_MAX_EX_NUM, DERIVE_MAX_EX_DEN = 1, 2 # screen out below 50%
316
+
317
+
318
+ def _build_template(target: str, srcs: List[Tuple[int, str]]):
319
+ """Cover `target` with literals and slices of other columns, left to right.
320
+
321
+ Greedy and longest-first. A part is either ("l", text) or
322
+ ("c", column, start, length) meaning that column's value sliced -- the
323
+ slice is what catches `id`, whose first field is a 19-character prefix of
324
+ `start_dt` rather than the whole of it.
325
+ """
326
+ parts, i, lit = [], 0, []
327
+ n = len(target)
328
+ while i < n:
329
+ best = None
330
+ for j, v in srcs:
331
+ if not v:
332
+ continue
333
+ # longest prefix of v that matches at i
334
+ m = 0
335
+ lim = min(len(v), n - i)
336
+ while m < lim and v[m] == target[i + m]:
337
+ m += 1
338
+ if m < DERIVE_MIN_PART:
339
+ continue
340
+ whole = (m == len(v))
341
+ # Prefer a whole-value match to a prefix of equal length, since it
342
+ # survives a source whose length varies between rows.
343
+ key = (m, 1 if whole else 0)
344
+ if best is None or key > (best[0], 1 if best[2] else 0):
345
+ best = (m, j, whole)
346
+ if best is None:
347
+ lit.append(target[i])
348
+ i += 1
349
+ continue
350
+ if lit:
351
+ parts.append(("l", "".join(lit)))
352
+ lit = []
353
+ m, j, whole = best
354
+ # A match that consumed the source's ENTIRE value is recorded as "the
355
+ # whole value" rather than "the first m characters". That distinction
356
+ # is the difference between working and not: `traffic`'s latitude runs
357
+ # 16 to 18 characters row to row, so a fixed-length slice built from
358
+ # one row reproduced only 124 rows in 200, while the same template with
359
+ # a whole-value part reproduces all of them.
360
+ parts.append(("c", j, 0, -1 if whole else m))
361
+ i += m
362
+ if lit:
363
+ parts.append(("l", "".join(lit)))
364
+ return parts
365
+
366
+
367
+ def _apply_template(parts, rowvals: List[str]) -> str:
368
+ out = []
369
+ for p in parts:
370
+ if p[0] == "l":
371
+ out.append(p[1])
372
+ else:
373
+ v = rowvals[p[1]]
374
+ out.append(v[p[2]:p[2] + p[3]] if p[3] >= 0 else v[p[2]:])
375
+ return "".join(out)
376
+
377
+
378
+ def _render(parts, rv: Dict[int, List[str]], k: int) -> str:
379
+ """Row k of a derived column, from only the columns it references."""
380
+ out = []
381
+ for p in parts:
382
+ if p[0] == "l":
383
+ out.append(p[1])
384
+ else:
385
+ v = rv[p[1]][k]
386
+ out.append(v[p[2]:p[2] + p[3]] if p[3] >= 0 else v[p[2]:])
387
+ return "".join(out)
388
+
389
+
390
+ def _stored_cost_bytes(col, vals: List[str]) -> bytes:
391
+ """The bytes this column would occupy if left alone, in its real form.
392
+
393
+ A dictionary column is ids plus a sorted alphabet, not its values joined.
394
+ Getting this wrong is what made `find_derived` fire on densely-coded
395
+ tables where it had nothing to win.
396
+ """
397
+ if col["kind"] == "dict":
398
+ w = fast._width(len(col["alpha"]))
399
+ return (col["ids"].astype(w).tobytes()
400
+ + "\n".join(col["alpha"]).encode("utf-8"))
401
+ return "\n".join(vals).encode("utf-8")
402
+
403
+
404
+ def _col_values(col) -> List[str]:
405
+ if col["kind"] == "text":
406
+ return col["cells"]
407
+ if col["kind"] == "dict":
408
+ alpha = col["alpha"]
409
+ return [alpha[i] for i in col["ids"].tolist()]
410
+ return None
411
+
412
+
413
+ def find_derived(plan, nrows) -> Dict[int, dict]:
414
+ """Which string columns are a formula over other columns?
415
+
416
+ Sources are restricted to columns that are NOT themselves derived, so the
417
+ dependency graph is one level deep and no cycle is possible. Every
418
+ candidate is confirmed by rebuilding the whole column and counting the
419
+ rows it fails on; the failures are stored as exceptions, and a column with
420
+ too many is rejected outright rather than carried.
421
+ """
422
+ strcols = [i for i, c in enumerate(plan) if c["kind"] in ("text", "dict")]
423
+ if len(strcols) < 2 or nrows < 32:
424
+ return {}
425
+ vals = {i: _col_values(plan[i]) for i in strcols}
426
+ # A column can only be a source if it is smaller than the target, else the
427
+ # two would derive each other and the search would pick arbitrarily.
428
+ width = {i: sum(len(v) for v in vals[i][:DERIVE_PROBE_ROWS])
429
+ for i in strcols}
430
+
431
+ out: Dict[int, dict] = {}
432
+ for tgt in strcols:
433
+ srcpos = [j for j in strcols if j != tgt and width[j] < width[tgt]]
434
+ if not srcpos:
435
+ continue
436
+ # Build a template from a few rows and keep the one that verifies best
437
+ best = None
438
+ for r in range(0, min(nrows, 5)):
439
+ srcs = [(j, vals[j][r]) for j in srcpos]
440
+ parts = _build_template(vals[tgt][r], srcs)
441
+ if not any(p[0] == "c" for p in parts):
442
+ continue
443
+ # Only the columns the template actually references are gathered.
444
+ # Building a full row of every column here made this quadratic in
445
+ # table width for no reason.
446
+ refs = sorted({p[1] for p in parts if p[0] == "c"})
447
+ rv = {j: vals[j] for j in refs}
448
+ hits = 0
449
+ stride = max(1, nrows // min(nrows, DERIVE_PROBE_ROWS))
450
+ checked = 0
451
+ tv = vals[tgt]
452
+ for k in range(0, nrows, stride):
453
+ checked += 1
454
+ if _render(parts, rv, k) == tv[k]:
455
+ hits += 1
456
+ if checked and (best is None or hits > best[0]):
457
+ best = (hits, checked, parts)
458
+ if best is None:
459
+ continue
460
+ hits, checked, parts = best
461
+ if hits * DERIVE_MAX_EX_DEN < checked * (DERIVE_MAX_EX_DEN
462
+ - DERIVE_MAX_EX_NUM):
463
+ continue
464
+ # confirm over every row, collecting the exceptions
465
+ refs = sorted({p[1] for p in parts if p[0] == "c"})
466
+ rv = {j: vals[j] for j in refs}
467
+ tv = vals[tgt]
468
+ limit = nrows * DERIVE_MAX_EX_NUM // DERIVE_MAX_EX_DEN
469
+ expos, exvals, bailed = [], [], False
470
+ for k in range(nrows):
471
+ if _render(parts, rv, k) != tv[k]:
472
+ expos.append(k)
473
+ exvals.append(tv[k])
474
+ if len(expos) > limit:
475
+ bailed = True
476
+ break
477
+ if bailed:
478
+ continue
479
+ # Never worse: the template plus its exceptions has to beat storing the
480
+ # column. Measured on the column alone, which is sound here because the
481
+ # column really is its own stream.
482
+ #
483
+ # The alternative must be how the column would ACTUALLY be stored, not
484
+ # how it looks as raw text. A dictionary column costs ids plus an
485
+ # alphabet, which is far less than its values joined -- comparing
486
+ # against the joined text made every dictionary column look expensive
487
+ # and fired formulas that lost badly. On
488
+ # `covid_19_case_surveillance` that shipped 92,603 against master's
489
+ # 79,587: the formula's exceptions tripled the text pile, +16.35%, on
490
+ # exactly the densely-coded shape this codec is best at.
491
+ keep = _probe(_stored_cost_bytes(plan[tgt], vals[tgt]))
492
+ got = _probe("\n".join(exvals).encode("utf-8")) + 40 * len(parts)
493
+ got += _probe(fast.pack_ints(
494
+ np.diff(np.array(expos, dtype=np.int64),
495
+ prepend=np.int64(0)))) if expos else 0
496
+ if got >= keep:
497
+ continue
498
+ out[tgt] = {"parts": parts, "expos": expos, "exvals": exvals}
499
+ return out
500
+
501
+
502
+ def _num_order(pred: Dict[int, Optional[int]]) -> List[int]:
503
+ """Positions in an order where every predictor precedes its dependant."""
504
+ order, seen = [], set()
505
+
506
+ def visit(i):
507
+ if i in seen:
508
+ return
509
+ seen.add(i)
510
+ p = pred.get(i)
511
+ if p is not None:
512
+ visit(p)
513
+ order.append(i)
514
+
515
+ for i in sorted(pred):
516
+ visit(i)
517
+ return order
518
+
519
+
520
+ try: # optional, and only an accelerator
521
+ import pyarrow as _pa
522
+ except Exception: # noqa: BLE001
523
+ _pa = None
524
+
525
+
526
+ def _uniq_ids_py(cells) -> Tuple[List[str], np.ndarray]:
527
+ idx: Dict[str, int] = {}
528
+ ids = np.empty(len(cells), dtype=np.int64)
529
+ get = idx.get
530
+ for i, c in enumerate(cells):
531
+ k = get(c)
532
+ if k is None:
533
+ k = idx[c] = len(idx)
534
+ ids[i] = k
535
+ return list(idx), ids
536
+
537
+
538
+ def _uniq_ids(cells) -> Tuple[List[str], np.ndarray]:
539
+ """The distinct values and each cell's index into them, in ONE pass.
540
+
541
+ This is the hottest loop in the encoder -- one dict lookup per cell, and a
542
+ wide table has millions of them. On `chicago_permits` (80,000 x 116, 9.3M
543
+ cells) the Python version is 1.07s of a 2.43s classify.
544
+
545
+ Arrow's `dictionary_encode` is the same operation with a C hash table, and
546
+ it is 2x faster here including the cost of getting the strings into an
547
+ Arrow array. Verified equivalent on 25 corpus tables, every column: the
548
+ alphabets match and both index arrays rebuild the original cells exactly.
549
+ The alphabet ORDER is not relied on -- callers sort it explicitly, because
550
+ front-coding needs a sorted alphabet.
551
+
552
+ pyarrow is an accelerator, never a requirement: anything unexpected falls
553
+ back to the Python loop rather than failing. It is not used to READ the
554
+ CSV, deliberately -- `dtz` owns that, and its strictness about encodings is
555
+ a correctness rule this must not quietly route around.
556
+ """
557
+ if _pa is None or not cells:
558
+ return _uniq_ids_py(cells)
559
+ try:
560
+ a = _pa.array(cells, type=_pa.string()).dictionary_encode()
561
+ return a.dictionary.to_pylist(), np.asarray(a.indices, dtype=np.int64)
562
+ except Exception: # noqa: BLE001
563
+ return _uniq_ids_py(cells)
564
+
565
+
566
+ def _numeric_lenient_fast(uniq: List[str], ids: np.ndarray, nrows: int):
567
+ """`fast._numeric_lenient`, decided on the DISTINCT values.
568
+
569
+ The original walks every cell twice with a regex. But the answer depends
570
+ only on the distinct values and how often each occurs, and distinct values
571
+ are a small fraction of cells -- measured on a 40 MB, 116-column table,
572
+ 409,971 distinct against 4,725,260 cells, **8.7%**. So the per-cell regex
573
+ work becomes per-distinct-value work and the rest is numpy indexing.
574
+
575
+ This is the user's point: do not analyse and then bucket, bucket as you
576
+ analyse. The dictionary pass has to happen anyway for any column that is
577
+ not cleanly numeric, so the numeric question is answered out of work
578
+ already done rather than by another full scan.
579
+
580
+ Must agree with `fast._numeric_lenient` exactly -- `tests/test_turbo.py`
581
+ and `_check_lenient_agrees` below pin it.
582
+ """
583
+ n = nrows
584
+ if n == 0:
585
+ return None
586
+ limit = int(n * fast.EX_MAX_FRACTION)
587
+ mult = np.bincount(ids, minlength=len(uniq))
588
+
589
+ counts: Dict[int, int] = {}
590
+ bad = 0
591
+ for i, c in enumerate(uniq):
592
+ m = int(mult[i])
593
+ if not fast._NUM_RE.match(c):
594
+ bad += m
595
+ if bad > limit:
596
+ return None
597
+ continue
598
+ k = c.find(".")
599
+ d = 0 if k < 0 else len(c) - k - 1
600
+ if d > fast.EX_MAX_DEC:
601
+ bad += m
602
+ if bad > limit:
603
+ return None
604
+ continue
605
+ counts[d] = counts.get(d, 0) + m
606
+ if not counts:
607
+ return None
608
+ dec = min(counts, key=lambda d: (-counts[d], d))
609
+
610
+ vals_u = np.zeros(len(uniq), dtype=np.int64)
611
+ okm = np.zeros(len(uniq), dtype=bool)
612
+ nex = 0
613
+ for i, c in enumerate(uniq):
614
+ v = fast._exact_int(c, dec)
615
+ if v is None:
616
+ nex += int(mult[i])
617
+ if nex > limit:
618
+ return None
619
+ else:
620
+ vals_u[i] = v
621
+ okm[i] = True
622
+
623
+ ok = okm[ids]
624
+ if ok.all():
625
+ return None # plain _numeric already handles this
626
+ if not ok.any():
627
+ return None
628
+ expos = np.flatnonzero(~ok)
629
+ exvals = [uniq[i] for i in ids[expos].tolist()]
630
+
631
+ # forward fill, then patch any leading run with the first good value --
632
+ # the same rule as the original, done with a running maximum instead of a
633
+ # Python loop
634
+ a_rows = vals_u[ids]
635
+ pos = np.where(ok, np.arange(n), -1)
636
+ np.maximum.accumulate(pos, out=pos)
637
+ first_ok = int(np.argmax(ok))
638
+ pos[pos < 0] = first_ok
639
+ a = a_rows[pos]
640
+ if a.size and int(np.abs(a).max()) >= fast.INT_LIMIT:
641
+ return None
642
+ return a, dec, expos, exvals
643
+
644
+
645
+ def classify(table) -> List[dict]:
646
+ """Bucket as you analyse: ONE PASS over each column's cells, with every candidate bucket built from
647
+ that pass and the losers discarded.
648
+
649
+ `fast.classify` walks a column up to four times: parse it as strict
650
+ numbers; failing that, walk it again with a regex for the lenient numeric
651
+ test; then build a set for the dictionary; then build the ids. Measured on
652
+ a 40 MB, 116-column table, the lenient walk alone is 0.77s of a 1.70s
653
+ classify -- and it is entirely redundant, because the answer depends only
654
+ on the DISTINCT values and how often each occurs. Distinct values are 8.7%
655
+ of cells on that table.
656
+
657
+ So the distinct set and the ids are built once, and every later question --
658
+ is this numeric, is it numeric apart from a few cells, is it a dictionary
659
+ or free text -- is answered out of that one pass. A bucket that turns out
660
+ ineligible is simply dropped.
661
+ """
662
+ nrows = len(table.rows)
663
+ plan: List[dict] = []
664
+ for j in range(len(table.columns)):
665
+ cells = table.column(j)
666
+ num = fast._numeric(cells) # strict, C-accelerated, cheap
667
+ if num is not None:
668
+ plan.append({"kind": "num", "ints": num[0], "dec": num[1],
669
+ "j": j, "ex": None})
670
+ continue
671
+
672
+ uniq, ids = _uniq_ids(cells)
673
+ if len(uniq) <= fast.DICT_MAX and len(uniq) * 2 <= max(nrows, 2):
674
+ # the alphabet is stored SORTED -- front-coding depends on it
675
+ srt = sorted(range(len(uniq)), key=lambda i: uniq[i])
676
+ rank = np.empty(len(uniq), dtype=np.int64)
677
+ rank[srt] = np.arange(len(uniq))
678
+ alt = {"kind": "dict", "alpha": [uniq[i] for i in srt],
679
+ "ids": rank[ids], "j": j}
680
+ else:
681
+ alt = {"kind": "text", "cells": cells, "j": j}
682
+
683
+ lax = _numeric_lenient_fast(uniq, ids, nrows)
684
+ if lax is None:
685
+ plan.append(alt)
686
+ continue
687
+ plan.append({"kind": "num", "ints": lax[0], "dec": lax[1], "j": j,
688
+ "ex": (lax[2], lax[3]), "_alt": alt, "_cells": cells})
689
+
690
+ # The lenient-versus-dictionary decision has to be judged WITH the
691
+ # cross-column predictor, not before it. `sideways` is the case: 43 of its
692
+ # 46 numeric columns are lenient, each one is worse than a dictionary on
693
+ # its own, and judging them alone demoted all 43 -- deleting exactly the
694
+ # structure the predictor exists to exploit, and the measured 26.2% with
695
+ # it. Judging a model before the model that depends on it exists is the
696
+ # same ordering trap as the numeric gate.
697
+ pend = [i for i, c in enumerate(plan) if "_alt" in c]
698
+ if not pend:
699
+ return plan
700
+ npred = pick_num_parents(plan, nrows)
701
+ # Snapshot the predictor arrays BEFORE any demotion. The loop rewrites
702
+ # plan[i] in place, so a column demoted early no longer has "ints" and a
703
+ # later column's lookup of it would fail -- and every predictor here was
704
+ # chosen against the pre-demotion plan anyway, so the snapshot is also the
705
+ # correct thing to price against.
706
+ snap = {i: c["ints"] for i, c in enumerate(plan) if c["kind"] == "num"}
707
+ for i in pend:
708
+ c = plan[i]
709
+ k = npred.get(i)
710
+ pints = snap.get(k) if k is not None else None
711
+ if not _lenient_keeps(c["_cells"], c, pints, nrows):
712
+ plan[i] = c["_alt"]
713
+ else:
714
+ c.pop("_alt")
715
+ c.pop("_cells")
716
+ return plan
717
+
718
+
719
+ def _lenient_keeps(cells, col, pints, nrows) -> bool:
720
+ """Is this column cheaper as numbers-with-exceptions than as a dictionary?
721
+
722
+ `fast._lenient_promising` asks the same question but cannot see a
723
+ predictor, so it prices the column against its own past only.
724
+ """
725
+ a = col["ints"]
726
+ resid = a if pints is None else a - pints
727
+ k = fast.diff_order(resid)
728
+ num = fast._probe_bytes(
729
+ fast.pack_ints(np.diff(resid, n=k) if k else resid))
730
+ expos = col["ex"][0]
731
+ num += fast._probe_bytes(
732
+ fast.pack_ints(np.diff(expos, prepend=np.int64(0))))
733
+ uniq = sorted(set(cells))
734
+ if len(uniq) <= fast.DICT_MAX and len(uniq) * 2 <= max(nrows, 2):
735
+ idx = {v: i for i, v in enumerate(uniq)}
736
+ ids = np.array([idx[v] for v in cells], dtype=np.int64)
737
+ alt = fast._probe_bytes(
738
+ ids.astype(fast._width(len(uniq))).tobytes()) + fast._probe_len(uniq)
739
+ else:
740
+ alt = fast._probe_len(cells)
741
+ return num < alt
742
+
743
+
744
+ def pick_parents(plan, nrows) -> Tuple[Dict[int, Optional[int]], List[int]]:
745
+ """Same nomination as fast.pick_parents; the confirmation is now local.
746
+
747
+ Master confirms a parent with `_probe_bytes` over the whole column, which
748
+ is correct there only because it happens to be comparing two orderings of
749
+ the same bytes. Here the confirmation is also *sufficient*, because the
750
+ column really is its own stream -- master's version could be right about
751
+ the column and wrong about the file.
752
+ """
753
+ dict_pos = [p for p, c in enumerate(plan) if c["kind"] == "dict"]
754
+ if nrows < fast.MIN_ROWS_FOR_PARENTS or len(dict_pos) < 2:
755
+ return {p: None for p in dict_pos}, list(dict_pos)
756
+
757
+ npairs = max(1, len(dict_pos) * (len(dict_pos) - 1))
758
+ rows = min(fast.MI_SAMPLE,
759
+ max(fast.MI_MIN_SAMPLE, fast.MI_BUDGET // npairs))
760
+ step = max(1, nrows // rows)
761
+ sample = {p: np.ascontiguousarray(plan[p]["ids"][::step]) for p in dict_pos}
762
+ sizes = {p: len(plan[p]["alpha"]) for p in dict_pos}
763
+ base, distinct = {}, {}
764
+ for p in dict_pos:
765
+ base[p], distinct[p] = fast._entropy_and_distinct(sample[p])
766
+
767
+ # Every ordered pair is independent, so the search runs across the pool.
768
+ #
769
+ # It is worth being clear about what this does NOT fix. The search never
770
+ # touches the raw cells -- it works on the integer ids produced by the
771
+ # single classify pass, so the table really is read once. What costs is
772
+ # the number of PAIRS: 96 dictionary columns on a 40 MB table is 9,120
773
+ # ordered pairs. Accumulating those statistics during the scan instead
774
+ # would be 548 million increments against a handful of vectorised counts
775
+ # per pair, so fusing this into the pass makes it slower, not faster. The
776
+ # quadratic is inherent to comparing every column with every other.
777
+ #
778
+ # numpy releases the GIL only partly here, so the measured speedup is
779
+ # 1.86x at four threads rather than four -- real, and free.
780
+ #
781
+ # Each task owns one `a` and returns its results; the merge is serial so
782
+ # no two threads write the same dict, and the iteration order that decides
783
+ # ties is unchanged.
784
+ def _row(a):
785
+ ha, ma = base[a], distinct[a]
786
+ out = []
787
+ for b in dict_pos:
788
+ if a == b:
789
+ continue
790
+ g = fast._score(base[b] - fast._cond_entropy_corrected(
791
+ sample[a], sample[b], sizes[b], ha, ma))
792
+ if g > fast._MIN_GAIN_SCORE:
793
+ out.append((b, g))
794
+ return a, out
795
+
796
+ from collections import defaultdict
797
+ gain = defaultdict(dict)
798
+ if len(dict_pos) > 8:
799
+ with ThreadPoolExecutor(min(_CPUS, 4)) as ex:
800
+ rows_out = list(ex.map(_row, dict_pos))
801
+ else:
802
+ rows_out = [_row(a) for a in dict_pos]
803
+ for a, out in rows_out:
804
+ for b, g in out:
805
+ gain[b][a] = g
806
+
807
+ root = min(dict_pos, key=lambda p: fast._score(base[p]))
808
+ placed, order = {root}, [root]
809
+ parent: Dict[int, Optional[int]] = {root: None}
810
+ remaining = [p for p in dict_pos if p != root]
811
+ while remaining:
812
+ best = None
813
+ for b in remaining:
814
+ for a, g in gain[b].items():
815
+ if a in placed and (best is None or g > best[2]):
816
+ best = (b, a, g)
817
+ if best is None:
818
+ b = min(remaining, key=lambda p: fast._score(base[p]))
819
+ parent[b] = None
820
+ else:
821
+ b, a, _ = best
822
+ parent[b] = a
823
+ placed.add(b)
824
+ order.append(b)
825
+ remaining.remove(b)
826
+
827
+ # Confirm on a sample rather than the whole column -- this is a comparison
828
+ # between two orderings, and a comparison converges long before the column
829
+ # is exhausted.
830
+ dstep = max(1, nrows // DECIDE_ROWS)
831
+ for b in order:
832
+ a = parent.get(b)
833
+ if a is None:
834
+ continue
835
+ ids = plan[b]["ids"]
836
+ perm = _stable_perm(plan[a]["ids"])
837
+ w = fast._width(len(plan[b]["alpha"]))
838
+ got = ids[perm][::dstep].astype(w).tobytes()
839
+ raw = ids[::dstep].astype(w).tobytes()
840
+ if _probe(got) >= _probe(raw):
841
+ parent[b] = None
842
+ return parent, order
843
+
844
+
845
+ def pick_text_parents(plan, nrows, order) -> Dict[int, Optional[int]]:
846
+ """As fast.pick_text_parents, but every trial runs on a row sample.
847
+
848
+ Master probes `TEXT_PARENT_CANDIDATES` full columns per text column; on
849
+ chicago_permits that is 1.06s of a 13s encode, for a decision that is a
850
+ comparison between orderings.
851
+ """
852
+ text_pos = [p for p, c in enumerate(plan) if c["kind"] == "text"]
853
+ out: Dict[int, Optional[int]] = {p: None for p in text_pos}
854
+ dict_pos = [p for p, c in enumerate(plan) if c["kind"] == "dict"]
855
+ if nrows < fast.MIN_ROWS_FOR_PARENTS or not dict_pos or not text_pos:
856
+ return out
857
+
858
+ npairs = max(1, len(text_pos) * len(dict_pos))
859
+ rows = min(fast.MI_SAMPLE,
860
+ max(fast.MI_MIN_SAMPLE, fast.MI_BUDGET // npairs))
861
+ step = max(1, nrows // rows)
862
+ dsample = {p: np.ascontiguousarray(plan[p]["ids"][::step])
863
+ for p in dict_pos}
864
+ dbase = {p: fast._entropy_and_distinct(dsample[p]) for p in dict_pos}
865
+ dstep = max(1, nrows // DECIDE_ROWS)
866
+
867
+ def choose(tp):
868
+ cells = plan[tp]["cells"][::step]
869
+ uniq = sorted(set(cells))
870
+ idx = {v: i for i, v in enumerate(uniq)}
871
+ ids = np.array([idx[v] for v in cells], dtype=np.int64)
872
+ base_t = fast._entropy(ids)
873
+ ranked = []
874
+ for dp in dict_pos:
875
+ ha, ma = dbase[dp]
876
+ g = fast._score(base_t - fast._cond_entropy_corrected(
877
+ dsample[dp], ids, len(uniq), ha, ma))
878
+ if g > fast._MIN_GAIN_SCORE:
879
+ ranked.append((g, dp))
880
+ if not ranked:
881
+ return tp, None
882
+ ranked.sort(key=lambda r: (-r[0], r[1]))
883
+ full = plan[tp]["cells"]
884
+ keep = _probe("\n".join(full[::dstep]).encode("utf-8"))
885
+ best, best_cost = None, keep
886
+ for _g, dp in ranked[:fast.TEXT_PARENT_CANDIDATES]:
887
+ perm = _stable_perm(plan[dp]["ids"])
888
+ trial = [full[i] for i in perm[::dstep]]
889
+ cost = _probe("\n".join(trial).encode("utf-8"))
890
+ if cost < best_cost:
891
+ best, best_cost = dp, cost
892
+ return tp, best
893
+
894
+ with ThreadPoolExecutor(_CPUS) as ex:
895
+ for tp, best in ex.map(choose, text_pos):
896
+ out[tp] = best
897
+ return out
898
+
899
+
900
+ def _choose_front(groups: List[List[str]], ndict: int) -> frozenset:
901
+ """Which string groups to front-code, decided on the WHOLE pile.
902
+
903
+ An earlier version of this decided per group, measuring each group against
904
+ itself, on the reasoning that turbo's streams are independent so local is
905
+ global. **That reasoning does not apply here.** The binary payloads are
906
+ independent; the string groups are deliberately NOT -- they stay in one
907
+ pile because splitting them costs up to 13.2%. So a group that is smaller
908
+ on its own can still make the pile bigger, which is exactly the trap
909
+ CLAUDE.md records from three separate per-group designs that all lost.
910
+
911
+ Deciding on the whole pile costs up to three trials. Master pays for those
912
+ at full strength; here they run at the probe preset, so the pile is
913
+ compressed cheaply three times rather than dearly three times. The
914
+ candidates are ranked by bytes AT STAKE, not by ratio -- a numeric
915
+ column's exception group is a run of identical values sharing 98% of its
916
+ bytes and would otherwise outrank everything while having nothing to win.
917
+ """
918
+ # Each group's bytes, computed ONCE, exactly as `_pack_strings` would.
919
+ # The old version called `_pack_strings` per trial, rebuilding the whole
920
+ # pile -- about 30 MB of string joins on a wide table -- three times over,
921
+ # and then handed all but the first megabyte straight to the bin because
922
+ # the probe truncates. Assembling only the prefix is the same measurement
923
+ # for a fraction of the work: 1.15s of choose_front on chicago_permits.
924
+ def group_bytes(i, fc):
925
+ g = groups[i]
926
+ if any("\n" in s for s in g):
927
+ return b"".join(s.encode("utf-8") for s in g)
928
+ if fc:
929
+ return fast._front_code(g)
930
+ return "\n".join(g).encode("utf-8")
931
+
932
+ plainb = [None] * len(groups)
933
+ frontb: Dict[int, bytes] = {}
934
+
935
+ def pile(front):
936
+ out, tot = [], 0
937
+ for i in range(len(groups)):
938
+ if i in front:
939
+ b = frontb.get(i)
940
+ if b is None:
941
+ b = frontb[i] = group_bytes(i, True)
942
+ else:
943
+ b = plainb[i]
944
+ if b is None:
945
+ b = plainb[i] = group_bytes(i, False)
946
+ out.append(b)
947
+ tot += len(b)
948
+ if tot >= FRONT_PROBE_BYTES:
949
+ break
950
+ return b"".join(out)
951
+
952
+ best = frozenset()
953
+ best_cost = _probe_front(pile(best))
954
+
955
+ if ndict:
956
+ alpha = frozenset(range(ndict))
957
+ c = _probe_front(pile(alpha))
958
+ if c < best_cost:
959
+ best, best_cost = alpha, c
960
+
961
+ cands = []
962
+ for i in range(ndict, len(groups)):
963
+ g = groups[i]
964
+ if not g or len(g) < 8 or any("\n" in s for s in g):
965
+ continue
966
+ sh, tot = fast._prefix_stats(g)
967
+ if sh <= 0 or tot <= 0 or sh * fast.FC_MIN_DEN <= tot * fast.FC_MIN_NUM:
968
+ continue
969
+ stake = sh * len(g) // min(len(g), 4000)
970
+ if stake < fast.FC_MIN_BYTES:
971
+ continue
972
+ cands.append((stake, i))
973
+ # Greedy over several candidates, each confirmed on the whole pile and
974
+ # kept only if it helps. Master stops at one because each of its trials
975
+ # costs a full-strength compression of the pile; here a trial is a cheap
976
+ # probe, so more of them are affordable -- and they are needed. Stopping at
977
+ # one left datasets whose date and code columns EACH want front-coding
978
+ # stuck at master's size when per-group had them 2.5% smaller.
979
+ cands.sort(key=lambda c: (-c[0], c[1]))
980
+ for _stake, i in cands[:FRONT_CANDIDATES]:
981
+ trial = frozenset(best | {i})
982
+ c = _probe_front(pile(trial))
983
+ if c < best_cost:
984
+ best, best_cost = trial, c
985
+ return best
986
+
987
+
988
+ # ------------------------------------------------------------ the row-wise
989
+ # candidate. Not a fallback -- a member of the model family, chosen by the
990
+ # same sample the other decisions use. It is what covers tables whose
991
+ # redundancy runs across the row rather than down the column: on
992
+ # covid_19_vaccinations, splitting into columns makes the data 100.2% larger,
993
+ # and master loses 21.9% there because this candidate is only reachable
994
+ # through a guard.
995
+
996
+ def _canonical(table) -> bytes:
997
+ return fast._canonical_bytes(table)
998
+
999
+
1000
+ class _Cap:
1001
+ """A size the row-wise candidate must beat, which can TIGHTEN mid-flight.
1002
+
1003
+ The two candidates race on separate cores, so when the columnar one lands
1004
+ first it can publish its size and let the row-wise one give up immediately
1005
+ instead of finishing a compression that has already lost. Without this,
1006
+ `text_dissimilar` -- nominated by the sample, beaten by a mile in reality
1007
+ -- cost 7.04 MB/s down to 2.24 MB/s for a candidate that never had a
1008
+ chance. An int assignment is atomic under the GIL, so no lock is needed.
1009
+ """
1010
+
1011
+ __slots__ = ("v",)
1012
+
1013
+ def __init__(self, v: int) -> None:
1014
+ self.v = v
1015
+
1016
+ def tighten(self, v: int) -> None:
1017
+ if v < self.v:
1018
+ self.v = v
1019
+
1020
+
1021
+ _CAP_CHUNK = 1 << 20
1022
+
1023
+
1024
+ def _compress_capped(data: bytes, cap: _Cap, kind: str) -> Optional[bytes]:
1025
+ """`data` compressed, or None as soon as it cannot fit under `cap`.
1026
+
1027
+ Compressed output only grows, so abandoning cannot change WHICH candidate
1028
+ wins, only the time spent losing. Feeding LZMA2 and bzip2 in chunks is
1029
+ byte-identical to a single call -- verified on master, which is what makes
1030
+ the cap safe rather than merely fast.
1031
+ """
1032
+ comp = (lzma.LZMACompressor(**XZ) if kind == "xz" else bz2.BZ2Compressor(9))
1033
+ out, total = [], 0
1034
+ for i in range(0, len(data), _CAP_CHUNK):
1035
+ piece = comp.compress(data[i:i + _CAP_CHUNK])
1036
+ total += len(piece)
1037
+ if total >= cap.v:
1038
+ return None
1039
+ out.append(piece)
1040
+ piece = comp.flush()
1041
+ total += len(piece)
1042
+ if total >= cap.v:
1043
+ return None
1044
+ out.append(piece)
1045
+ return b"".join(out)
1046
+
1047
+
1048
+ def _rowwise(table, cap: Optional[_Cap] = None) -> Optional[bytes]:
1049
+ canon = _canonical(table)
1050
+ try:
1051
+ back = fast._from_canonical(canon)
1052
+ if back.rows != table.rows or back.columns != table.columns:
1053
+ return None
1054
+ except Exception:
1055
+ return None
1056
+ del back
1057
+ if cap is None:
1058
+ cap = _Cap(len(canon))
1059
+ # bzip2 FIRST. It is roughly 20x faster than xz at these settings, so
1060
+ # running it first buys a real cap for almost nothing, and xz then abandons
1061
+ # a hopeless candidate far sooner. Running xz first meant its cap was
1062
+ # whatever the columnar encoder had published, which on a close table is no
1063
+ # cap at all.
1064
+ best = None
1065
+ for magic, kind in ((b"B", "bz2"), (b"X", "xz")):
1066
+ blob = _compress_capped(canon, cap, kind)
1067
+ if blob is not None and (best is None or len(blob) + 1 < len(best)):
1068
+ best = magic + blob
1069
+ cap.tighten(len(blob))
1070
+ return best
1071
+
1072
+
1073
+ def _sample_ratio(table) -> float:
1074
+ """Row-wise size over columnar size, on a sample. A NOMINATOR only.
1075
+
1076
+ Measured 2026-08-03 and it is not safe as a decider: on `sideways` the
1077
+ probe says 1.099 where the truth is 0.860, and it still says 1.736 when
1078
+ given every row -- so the error is not sampling, it is the compressor. The
1079
+ two candidates respond differently to compression strength, because the
1080
+ row-major CSV holds long-range redundancy that a strong coder finds and a
1081
+ weak one does not, while the columnar payload is already de-correlated.
1082
+
1083
+ Raising the probe's dictionary to 64 MB was tried on the hypothesis that
1084
+ reach was the problem: 1.088 against 1.099, i.e. no effect. Recorded so it
1085
+ is not tried again.
1086
+
1087
+ So this only nominates, and the real capped candidate decides. Sweeping the
1088
+ threshold over 81 corpus100 datasets: at 1.05 all four genuine row-wise
1089
+ wins are nominated and 14 of 81 pay; 1.15 keeps the margin that `sideways`
1090
+ needs at 27 of 81.
1091
+ """
1092
+ n = len(table.rows)
1093
+ if n < 200:
1094
+ return 1e9
1095
+ step = max(1, n // SAMPLE_ROWS)
1096
+ sub = dtz.Table(list(table.columns), table.rows[::step])
1097
+ # BOTH sides must use the SAME compressor or the comparison is meaningless.
1098
+ # Patching `fast.XZ` does not do it: turbo's `_xz` reads turbo's own module
1099
+ # global, bound to the same dict at import. Getting that wrong measured the
1100
+ # columnar candidate at 9e and the row-wise one at preset 1, so row-wise
1101
+ # could never win and `sideways` shipped 70.6% larger than master.
1102
+ global XZ
1103
+ real = XZ
1104
+ try:
1105
+ XZ = PROBE
1106
+ col = len(_encode_columnar(sub, threads=1, probe=True))
1107
+ canon = fast._canonical_bytes(sub)
1108
+ row = min(len(lzma.compress(canon, **PROBE)),
1109
+ len(bz2.compress(canon, 1))) + 1
1110
+ except Exception:
1111
+ return 1e9
1112
+ finally:
1113
+ XZ = real
1114
+ return row / float(col) if col else 1e9
1115
+
1116
+
1117
+ # Nominate the row-wise candidate below this ratio. Generous on purpose: a
1118
+ # nomination costs TIME (the real candidate is built) and never bytes, because
1119
+ # the real candidate has to actually be smaller to win.
1120
+ # 0 disables the row-wise candidate entirely. That is the DEFAULT, and it is
1121
+ # the user's call, made after seeing the trade measured.
1122
+ #
1123
+ # Measured on 63 unselected corpus100 datasets, 255.2 MB, all round-trip exact
1124
+ # either way:
1125
+ #
1126
+ # row-wise candidate on 13,638,428 B 50.2s 5.08 MB/s
1127
+ # row-wise candidate off 13,640,906 B 24.1s 10.58 MB/s
1128
+ #
1129
+ # **2.08x the speed for 2,478 bytes -- +0.018%.** The guarantee given up is
1130
+ # "never larger than plain xz/bzip2 of the same table". It is given up
1131
+ # knowingly: a codec that needs a second opinion about its own model on 4 of
1132
+ # 63 tables should fix the model, not carry the second opinion.
1133
+ #
1134
+ # Set to ~1.10 to restore it as a nominator; the real candidate then races the
1135
+ # columnar one under a shared cap and decides by measurement.
1136
+ NOMINATE_ROWWISE = 0.0
1137
+
1138
+
1139
+ # ------------------------------------------------------------------- codec
1140
+
1141
+ def encode(table, text_mode: str = "pile") -> bytes:
1142
+ """Columnar, with the row-wise candidate raced against it when nominated.
1143
+
1144
+ The two candidates are independent, so when the sample nominates the
1145
+ row-wise one they are built concurrently and the smaller wins. That makes
1146
+ the guarantee a measurement rather than a prediction, while the tables the
1147
+ sample confidently rejects -- 67 of 81 in the corpus sweep -- never build
1148
+ the second candidate at all.
1149
+ """
1150
+ # NOMINATE_ROWWISE <= 0 disables the row-wise candidate entirely, and skips
1151
+ # the nominator with it. That is the "no fallbacks at all" position: the
1152
+ # codec must be good enough not to need a second opinion about its own
1153
+ # model. It is the largest single speed lever left -- on a 40 MB
1154
+ # text-heavy table the nominator says 1.117, just under the 1.15 threshold,
1155
+ # and the candidate it then builds LOSES (3,961,432 against 3,527,412)
1156
+ # after costing 5.78s of a 14.95s encode.
1157
+ if NOMINATE_ROWWISE <= 0:
1158
+ return _encode_columnar(table, text_mode=text_mode)
1159
+ if _sample_ratio(table) >= NOMINATE_ROWWISE:
1160
+ return _encode_columnar(table, text_mode=text_mode)
1161
+
1162
+ cap = _Cap(1 << 62)
1163
+
1164
+ def columnar():
1165
+ blob = _encode_columnar(table, text_mode, max(1, _CPUS - 2))
1166
+ cap.tighten(len(blob) - 5) # the row-wise container costs 5 bytes
1167
+ return blob
1168
+
1169
+ with ThreadPoolExecutor(2) as ex:
1170
+ f_col = ex.submit(columnar)
1171
+ f_row = ex.submit(_rowwise, table, cap)
1172
+ col = f_col.result()
1173
+ try:
1174
+ row = f_row.result()
1175
+ except Exception:
1176
+ row = None
1177
+ if row is not None and len(row) + 5 < len(col):
1178
+ return MAGIC + b"R" + row
1179
+ return col
1180
+
1181
+
1182
+ def _encode_columnar(table, text_mode: str = "pile", threads: int = 0,
1183
+ probe: bool = False) -> bytes:
1184
+ plan = classify(table)
1185
+ nrows = len(table.rows)
1186
+ # Derived columns are resolved BEFORE the parent searches so those never
1187
+ # spend effort on a column that is about to become a formula.
1188
+ #
1189
+ # This runs in probe mode TOO, and it has to. The probe exists to decide
1190
+ # whether the row-wise candidate is worth building, so it must model the
1191
+ # columnar candidate as it will actually be built. Skipping the formulas
1192
+ # here made the probe badly under-rate exactly the tables the formulas
1193
+ # rescue: `traffic` ends up 32.7% SMALLER than master once its `point` and
1194
+ # `id` columns become formulas, and it was still paying 2.5s to build a
1195
+ # row-wise candidate that had already lost.
1196
+ for pos, d in find_derived(plan, nrows).items():
1197
+ plan[pos] = {"kind": "drv", "j": plan[pos]["j"],
1198
+ "parts": d["parts"], "expos": d["expos"],
1199
+ "exvals": d["exvals"]}
1200
+ # In probe mode skip BOTH parent searches. They are the dearest part of the
1201
+ # analysis -- on text_heavy the probe was 31% of total encode time, almost
1202
+ # all of it the O(columns^2) entropy search re-run on the sample -- and
1203
+ # omitting them only makes the columnar candidate look WORSE, which lowers
1204
+ # the ratio and nominates the row-wise candidate more readily. Nominations
1205
+ # cost time and never bytes, so the error is on the safe side by
1206
+ # construction, and a false nomination is now cheap because the row-wise
1207
+ # candidate races under a cap that the columnar one tightens.
1208
+ if probe:
1209
+ dict_pos = [p for p, c in enumerate(plan) if c["kind"] == "dict"]
1210
+ parent = {p: None for p in dict_pos}
1211
+ order = list(dict_pos)
1212
+ tparent = {p: None for p, c in enumerate(plan) if c["kind"] == "text"}
1213
+ else:
1214
+ parent, order = pick_parents(plan, nrows)
1215
+ tparent = pick_text_parents(plan, nrows, order)
1216
+ npred = ({i: None for i, c in enumerate(plan) if c["kind"] == "num"}
1217
+ if probe else pick_num_parents(plan, nrows))
1218
+
1219
+ bins: List[bytes] = []
1220
+ sgroups: List[List[str]] = []
1221
+ specs: List[Optional[dict]] = [None] * len(plan)
1222
+
1223
+ for pos in order:
1224
+ col = plan[pos]
1225
+ ids = col["ids"]
1226
+ par = parent.get(pos)
1227
+ if par is not None:
1228
+ ids = ids[_stable_perm(plan[par]["ids"])]
1229
+ w = fast._width(len(col["alpha"]))
1230
+ bins.append(ids.astype(w).tobytes())
1231
+ sgroups.append(col["alpha"])
1232
+ specs[pos] = {"kind": "dict", "n": len(col["alpha"]), "w": w,
1233
+ "parent": par}
1234
+
1235
+ for pos, col in enumerate(plan):
1236
+ if col["kind"] == "text":
1237
+ tp = tparent.get(pos)
1238
+ cells = col["cells"]
1239
+ if tp is not None:
1240
+ cells = [cells[i] for i in _stable_perm(plan[tp]["ids"])]
1241
+ sgroups.append(cells)
1242
+ specs[pos] = {"kind": "text"} if tp is None else \
1243
+ {"kind": "text", "parent": tp}
1244
+ elif col["kind"] == "drv":
1245
+ specs[pos] = {"kind": "drv", "parts": col["parts"],
1246
+ "nex": len(col["expos"])}
1247
+ if col["expos"]:
1248
+ bins.append(fast.pack_ints(
1249
+ np.diff(np.array(col["expos"], dtype=np.int64),
1250
+ prepend=np.int64(0))))
1251
+ sgroups.append(col["exvals"])
1252
+ elif col["kind"] == "num":
1253
+ a = col["ints"]
1254
+ j = npred.get(pos)
1255
+ resid = a if j is None else a - plan[j]["ints"]
1256
+ k = fast.diff_order(resid)
1257
+ bins.append(fast.pack_ints(np.diff(resid, n=k) if k else resid))
1258
+ specs[pos] = {"kind": "num", "dec": col["dec"], "k": k,
1259
+ "warm": resid[:k].tolist(), "pred": j}
1260
+ fast._emit_exceptions(col, specs[pos], bins, sgroups)
1261
+
1262
+ front = _choose_front(sgroups, len(order))
1263
+ txt_data, smeta, length_arrays = fast._pack_strings(sgroups, front)
1264
+ # "front is exactly the dictionary alphabets" is the common case, and one
1265
+ # archive-level flag spells it in 7 bytes rather than 7 per group. On a
1266
+ # small archive that is real money -- metadata was 2.7% of an 18 KB file.
1267
+ compact = bool(order) and front == frozenset(range(len(order)))
1268
+ if compact:
1269
+ for m in smeta:
1270
+ m.pop("fc", None)
1271
+ n_before = len(bins)
1272
+ bins.extend(fast.pack_ints(a) for a in length_arrays)
1273
+
1274
+ # --- the parallel part.
1275
+ #
1276
+ # One stream per column is what makes every decision above local, but it is
1277
+ # not free: measured 2026-08-03, independence costs +0.2% to +5.2% on the
1278
+ # binary payloads because neighbouring columns do help compress each other.
1279
+ # Paying that in full is unnecessary -- the parallelism only needs as many
1280
+ # independent pieces as there are threads, not as many as there are
1281
+ # columns. So consecutive payloads are glued into a few groups, which keeps
1282
+ # most of the sharing and still saturates the pool. Consecutive rather than
1283
+ # scattered because adjacent columns are the ones that resemble each other.
1284
+ # The string pile is ONE stream for size, which makes it one thread and,
1285
+ # on a wide text-heavy table, the entire critical path -- measured on a
1286
+ # 40 MB, 116-column table the pile was 15.3 MB taking 3.73s while the 6.0
1287
+ # MB of binary payload took 0.96s beside it.
1288
+ #
1289
+ # Cutting it into contiguous chunks costs far less than splitting it by
1290
+ # column, because a chunk keeps its neighbours: per-column splitting was
1291
+ # measured at +13.1% on this table, contiguous chunking at +2.0% for four
1292
+ # pieces. Same 4 MB rule as the binary side, so a small pile stays whole
1293
+ # and pays nothing.
1294
+ #
1295
+ # chunks 1: 3,510,950 B 3.73s
1296
+ # 2: 3,558,501 B (+1.35%) 1.90s
1297
+ # 4: 3,581,213 B (+2.00%) 1.08s <- 3.47x
1298
+ # 8: 3,717,415 B (+5.88%) 0.76s
1299
+ strparts = (_split_pile(txt_data, smeta) if text_mode == "split"
1300
+ else _chunk(txt_data, BIN_GROUP_BYTES, _CPUS))
1301
+ total = sum(len(b) for b in bins) + len(txt_data)
1302
+ nthreads = threads or _pool_size(total)
1303
+ # Group by BYTES, not by column count. Splitting a 1.4 MB payload sixteen
1304
+ # ways buys no wall-clock -- it compresses in 0.16s either way -- and pays
1305
+ # the full independence penalty, which on coded_admin is 4.2% of the binary
1306
+ # payload and most of that table's whole deficit against master.
1307
+ binbytes = sum(len(b) for b in bins)
1308
+ ngroups = (max(1, min(len(bins), max(1, nthreads),
1309
+ binbytes // BIN_GROUP_BYTES)) if bins else 0)
1310
+ chunk = max(1, -(-len(bins) // ngroups)) if ngroups else 1
1311
+ bingroups = [b"".join(bins[i:i + chunk])
1312
+ for i in range(0, len(bins), chunk)]
1313
+ parts = bingroups + strparts
1314
+ if nthreads > 1 and len(parts) > 1:
1315
+ with ThreadPoolExecutor(nthreads) as ex:
1316
+ blobs = list(ex.map(_xz, parts))
1317
+ else:
1318
+ blobs = [_xz(p) for p in parts]
1319
+
1320
+ meta = {"columns": table.columns, "nrows": nrows, "cols": specs,
1321
+ "order": order, "smeta": smeta, "nlenbins": len(bins) - n_before,
1322
+ "ngroups": len(bingroups), "nstr": len(strparts),
1323
+ "binlens": [len(b) for b in bins],
1324
+ "numorder": _num_order(npred),
1325
+ "sizes": [len(b) for b in blobs]}
1326
+ if compact:
1327
+ meta["fc"] = 1
1328
+ meta_b = _xz(json.dumps(meta, separators=(",", ":")).encode())
1329
+ return (MAGIC + b"C" + len(meta_b).to_bytes(4, "big") + meta_b
1330
+ + b"".join(blobs))
1331
+
1332
+
1333
+ def _chunk(data: bytes, target: int, cap: int) -> List[bytes]:
1334
+ """`data` in contiguous pieces of about `target` bytes, at most `cap`.
1335
+
1336
+ Contiguous on purpose: a chunk keeps its neighbours, so the compressor
1337
+ still sees the local redundancy. Splitting the same pile by column instead
1338
+ was measured at +13.1% against +2.0% for four contiguous chunks.
1339
+ """
1340
+ if not data:
1341
+ return [data]
1342
+ k = max(1, min(cap, len(data) // max(target, 1)))
1343
+ if k <= 1:
1344
+ return [data]
1345
+ n = -(-len(data) // k)
1346
+ return [data[i:i + n] for i in range(0, len(data), n)]
1347
+
1348
+
1349
+ def _split_pile(txt_data: bytes, smeta) -> List[bytes]:
1350
+ out, at = [], 0
1351
+ for m in smeta:
1352
+ out.append(txt_data[at:at + m["b"]])
1353
+ at += m["b"]
1354
+ return out
1355
+
1356
+
1357
+ def decode(blob: bytes):
1358
+ if blob[:4] != MAGIC:
1359
+ raise ValueError("not a turbo archive")
1360
+ mode = blob[4:5]
1361
+ if mode == b"R":
1362
+ body = blob[5:]
1363
+ if body[:1] == b"X":
1364
+ return fast._from_canonical(_unxz(body[1:]))
1365
+ if body[:1] == b"B":
1366
+ return fast._from_canonical(bz2.decompress(body[1:]))
1367
+ raise ValueError("bad row-wise marker")
1368
+ if mode != b"C":
1369
+ raise ValueError("bad turbo mode")
1370
+
1371
+ ml = int.from_bytes(blob[5:9], "big")
1372
+ meta = json.loads(_unxz(blob[9:9 + ml]))
1373
+ at = 9 + ml
1374
+ parts = []
1375
+ for size in meta["sizes"]:
1376
+ parts.append(_unxz(blob[at:at + size]))
1377
+ at += size
1378
+
1379
+ ng = meta["ngroups"]
1380
+ binblob = b"".join(parts[:ng])
1381
+ cuts, at = [], 0
1382
+ for L in meta["binlens"]:
1383
+ cuts.append(binblob[at:at + L])
1384
+ at += L
1385
+ txt_data = b"".join(parts[ng:])
1386
+
1387
+ nrows, specs = meta["nrows"], meta["cols"]
1388
+ nlen = meta["nlenbins"]
1389
+ length_bins = cuts[len(cuts) - nlen:] if nlen else []
1390
+ legacy = len(meta["order"]) if meta.get("fc") else 0
1391
+ texts = fast._unpack_strings(txt_data, meta["smeta"], length_bins, legacy)
1392
+
1393
+ cols: List[Optional[List[str]]] = [None] * len(specs)
1394
+ ids_by_pos: Dict[int, np.ndarray] = {}
1395
+ bi = ti = 0
1396
+ for pos in meta["order"]:
1397
+ sp = specs[pos]
1398
+ alpha = texts[ti]
1399
+ ti += 1
1400
+ ids = np.frombuffer(cuts[bi], dtype=sp["w"]).astype(np.int64)
1401
+ bi += 1
1402
+ par = sp["parent"]
1403
+ if par is not None:
1404
+ perm = _stable_perm(ids_by_pos[par])
1405
+ out = np.empty_like(ids)
1406
+ out[perm] = ids
1407
+ ids = out
1408
+ ids_by_pos[pos] = ids
1409
+ cols[pos] = (np.array(alpha, dtype=object)[ids].tolist()
1410
+ if alpha else [])
1411
+
1412
+ # A numeric column may be stored as its difference from another numeric
1413
+ # column, and the predictor is not necessarily earlier in the file. So the
1414
+ # payloads are unpacked in file order -- which needs no predictor -- and
1415
+ # the additions are resolved afterwards in dependency order.
1416
+ resid_by_pos: Dict[int, np.ndarray] = {}
1417
+ ex_by_pos: Dict[int, Tuple[np.ndarray, List[str]]] = {}
1418
+ drv: List[int] = []
1419
+ drv_ex: Dict[int, Tuple[np.ndarray, List[str]]] = {}
1420
+
1421
+ for pos, sp in enumerate(specs):
1422
+ if sp["kind"] == "drv":
1423
+ drv.append(pos)
1424
+ nex = sp.get("nex", 0)
1425
+ if nex:
1426
+ drv_ex[pos] = (
1427
+ np.cumsum(fast.unpack_ints(cuts[bi], nex)), texts[ti])
1428
+ bi += 1
1429
+ ti += 1
1430
+ elif sp["kind"] == "text":
1431
+ cells = list(texts[ti])
1432
+ ti += 1
1433
+ tp = sp.get("parent")
1434
+ if tp is not None:
1435
+ perm = _stable_perm(ids_by_pos[tp])
1436
+ restored = [None] * len(cells)
1437
+ for k, src in enumerate(perm):
1438
+ restored[src] = cells[k]
1439
+ cells = restored
1440
+ cols[pos] = cells
1441
+ elif sp["kind"] == "num":
1442
+ k = sp["k"]
1443
+ d = fast.unpack_ints(cuts[bi], nrows - k)
1444
+ bi += 1
1445
+ resid_by_pos[pos] = (
1446
+ fast._undiff(d, np.array(sp["warm"], dtype=np.int64), k)
1447
+ if k else d)
1448
+ nex = sp.get("nex", 0)
1449
+ if nex:
1450
+ ex_by_pos[pos] = (
1451
+ np.cumsum(fast.unpack_ints(cuts[bi], nex)), texts[ti])
1452
+ bi += 1
1453
+ ti += 1
1454
+
1455
+ ints_by_pos: Dict[int, np.ndarray] = {}
1456
+ for pos in meta.get("numorder", sorted(resid_by_pos)):
1457
+ if pos not in resid_by_pos:
1458
+ continue
1459
+ j = specs[pos].get("pred")
1460
+ ints_by_pos[pos] = (resid_by_pos[pos] if j is None
1461
+ else resid_by_pos[pos] + ints_by_pos[j])
1462
+
1463
+ for pos, a in ints_by_pos.items():
1464
+ cells = fast.ints_to_cells(a, specs[pos]["dec"])
1465
+ ex = ex_by_pos.get(pos)
1466
+ if ex is not None:
1467
+ expos, exvals = ex
1468
+ for i, p in enumerate(expos.tolist()):
1469
+ cells[p] = exvals[i]
1470
+ cols[pos] = cells
1471
+
1472
+ # Derived columns last, in DEPENDENCY order.
1473
+ #
1474
+ # An earlier version did this in position order, on the stated assumption
1475
+ # that "their sources are never themselves derived". That assumption was
1476
+ # never enforced and is false: on
1477
+ # `data_real_property_tax_receivables_dkna-i698` columns 8 and 9 are both
1478
+ # formulas over column 12, which is itself a formula over column 13. Built
1479
+ # in position order, column 12 did not exist when 8 asked for it.
1480
+ #
1481
+ # It failed in the worst possible way -- `encode` SUCCEEDED and wrote an
1482
+ # archive that could never be decoded. A crash on write costs nothing; a
1483
+ # crash on read costs the data.
1484
+ #
1485
+ # Chains are legitimate and worth keeping, so the fix is to order rather
1486
+ # than to refuse them. Cycles cannot occur: `find_derived` only accepts a
1487
+ # source strictly narrower than its target, which is a strict order. The
1488
+ # no-progress check is there anyway, because the decoder treats its input
1489
+ # as hostile and a corrupt header must not spin forever.
1490
+ pending = list(drv)
1491
+ while pending:
1492
+ progress = False
1493
+ still = []
1494
+ for pos in pending:
1495
+ parts = [tuple(p) for p in specs[pos]["parts"]]
1496
+ refs = [p[1] for p in parts if p[0] == "c"]
1497
+ if any(r < 0 or r >= len(cols) or cols[r] is None for r in refs):
1498
+ still.append(pos)
1499
+ continue
1500
+ rv = {r: cols[r] for r in refs}
1501
+ cells = [_render(parts, rv, k) for k in range(nrows)]
1502
+ ex = drv_ex.get(pos)
1503
+ if ex is not None:
1504
+ expos, exvals = ex
1505
+ for i, p in enumerate(expos.tolist()):
1506
+ cells[p] = exvals[i]
1507
+ cols[pos] = cells
1508
+ progress = True
1509
+ if not progress:
1510
+ raise ValueError("derived columns do not resolve: "
1511
+ "cyclic or dangling reference in the header")
1512
+ pending = still
1513
+
1514
+ rows = [list(r) for r in zip(*cols)]
1515
+ return dtz.Table(list(meta["columns"]), rows)