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/__init__.py +13 -0
- polypress/caccel.py +164 -0
- polypress/cli.py +255 -0
- polypress/codec.py +343 -0
- polypress/dtz.py +978 -0
- polypress/fast.py +1397 -0
- polypress/stream.py +438 -0
- polypress/tcz.c +207 -0
- polypress/turbo.py +1515 -0
- polypress-0.2.0.dist-info/METADATA +121 -0
- polypress-0.2.0.dist-info/RECORD +15 -0
- polypress-0.2.0.dist-info/WHEEL +5 -0
- polypress-0.2.0.dist-info/entry_points.txt +2 -0
- polypress-0.2.0.dist-info/licenses/LICENSE +21 -0
- polypress-0.2.0.dist-info/top_level.txt +1 -0
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)
|