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/fast.py ADDED
@@ -0,0 +1,1397 @@
1
+ """Vectorised table codec -- same ideas as smart.py, none of the Python loops.
2
+
3
+ Three changes made this 15-50x faster AND slightly smaller:
4
+
5
+ 1. all prediction is numpy. Polynomial extrapolation of order k is just the
6
+ k-th finite difference, so the "local function builder" is np.diff. The
7
+ 2D version is np.diff twice, once along each axis.
8
+
9
+ 2. residuals are packed as one-byte varints with an escape, vectorised --
10
+ no per-value Python loop.
11
+
12
+ 3. the entropy stage is xz/zstd (C) instead of our Python range coder. The
13
+ range coder was the bottleneck and it was not paying for itself.
14
+
15
+ 4. CROSS-COLUMN STRUCTURE IS EXPLOITED BY REORDERING, NOT CONTEXT MODELLING.
16
+ To code City given Postal Code, sort the rows by Postal Code: equal
17
+ postal codes become adjacent, so City collapses into long runs that xz
18
+ eats. The permutation costs nothing to store because the decoder has
19
+ already reconstructed the parent column and can recompute the same
20
+ stable argsort. Measured on 40k EV rows this beat the conditioned range
21
+ coder 2,328 B vs 6,901 B.
22
+
23
+ Fidelity: the logical table round-trips exactly, cell for cell.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import bz2
29
+ import json
30
+ import lzma
31
+ import math
32
+ from collections import Counter, defaultdict
33
+ from typing import Dict, List, Optional, Tuple
34
+
35
+ import numpy as np
36
+
37
+ from . import caccel
38
+ from . import codec
39
+ from . import dtz
40
+
41
+ DICT_MAX = 1 << 16
42
+ MI_SAMPLE = 40000 # ceiling on rows sampled for column correlation
43
+ MI_MIN_SAMPLE = 1500 # floor, so a very wide table still measures something
44
+ MI_BUDGET = 150_000_000 # cap on (column pairs x sampled rows)
45
+ MIN_2D_GROUP = 3
46
+ INT_LIMIT = 1 << 62
47
+ XZ = dict(format=lzma.FORMAT_RAW,
48
+ filters=[{"id": lzma.FILTER_LZMA2, "preset": 9 | lzma.PRESET_EXTREME}])
49
+ ESCAPE = 255
50
+ MAGIC = b"PPZ1" # Polypress container
51
+ MAGIC_V0 = b"FAST" # pre-rename archives still open
52
+ # Fallback magics are 4 bytes with the codec baked in rather than 4 + a method
53
+ # byte. That one byte matters: the fallback exists to tie a general compressor
54
+ # that we would otherwise lose to, and the tie is measured against its bare
55
+ # output stream. Every byte of container is a byte of deficit.
56
+ MAGIC_RAW_XZ = b"PPZX"
57
+ MAGIC_RAW_BZ = b"PPZB"
58
+
59
+ # Fallback codecs. stdlib only, deliberately. An adversarial suite found four
60
+ # tables where the modelling lost to a plain general-purpose compressor -- by
61
+ # 0.8% to 11% -- because this codec always finishes with xz and xz is not
62
+ # always the best finisher. Carrying xz and bzip2 candidates makes it
63
+ # impossible to lose to either. brotli won two of those four by under 1% and
64
+ # is NOT carried: it is not in the standard library and would mean linking
65
+ # libbrotli into the C port, which is a poor trade for <1% on data that is
66
+ # incompressible anyway.
67
+ # Sorting cannot create useful runs in a handful of rows, and the parent
68
+ # search is O(columns^2). Below this row count, skip it entirely.
69
+ MIN_ROWS_FOR_PARENTS = 8
70
+ # The planar predictor differences down the rows, so it needs rows to work
71
+ # with. On a 1-row table a "group" is pure bookkeeping: it restructures the
72
+ # payload, reduces nothing, and -- because it counted as a trick that fired --
73
+ # used to suppress the fallback on exactly the table that needed it most.
74
+ MIN_ROWS_FOR_2D = 3
75
+
76
+
77
+ # ------------------------------------------------------------------ packing
78
+
79
+ def zigzag(a: np.ndarray) -> np.ndarray:
80
+ """Signed -> unsigned, using the bit trick rather than arithmetic.
81
+
82
+ The obvious `(-a << 1) - 1` overflows int64: differences between values
83
+ near the +/-2^62 limit legitimately reach 2^63, and negating or doubling
84
+ those in signed arithmetic wraps to nonsense. `(a << 1) ^ (a >> 63)` is
85
+ exact because it relies on the wraparound instead of fighting it."""
86
+ a = np.ascontiguousarray(a, dtype=np.int64)
87
+ with np.errstate(over="ignore"):
88
+ u = (a << np.int64(1)) ^ (a >> np.int64(63))
89
+ return u.view(np.uint64)
90
+
91
+
92
+ def unzigzag(u: np.ndarray) -> np.ndarray:
93
+ u = np.ascontiguousarray(u, dtype=np.uint64)
94
+ return ((u >> np.uint64(1)).astype(np.int64)
95
+ ^ -(u & np.uint64(1)).astype(np.int64))
96
+
97
+
98
+ def pack_ints(res: np.ndarray) -> bytes:
99
+ """One byte per small value, escape + 4 bytes for the rest."""
100
+ if caccel.HAVE_C:
101
+ return caccel.pack(res)
102
+ u = zigzag(res)
103
+ small = u < ESCAPE
104
+ head = np.full(u.size, ESCAPE, dtype=np.uint8)
105
+ head[small] = u[small].astype(np.uint8)
106
+ tail = u[~small]
107
+ if tail.size and int(tail.max()) >= (1 << 32):
108
+ return b"\x08" + head.tobytes() + tail.astype("<u8").tobytes()
109
+ return b"\x04" + head.tobytes() + tail.astype("<u4").tobytes()
110
+
111
+
112
+ def unpack_ints(buf: bytes, n: int) -> np.ndarray:
113
+ if caccel.HAVE_C:
114
+ return caccel.unpack(buf, n)
115
+ width = buf[0]
116
+ head = np.frombuffer(buf[1:1 + n], dtype=np.uint8).astype(np.uint64)
117
+ big = head == ESCAPE
118
+ nbig = int(big.sum())
119
+ if nbig:
120
+ at = 1 + n
121
+ if width == 8:
122
+ tail = np.frombuffer(buf[at:at + 8 * nbig], dtype="<u8")
123
+ else:
124
+ tail = np.frombuffer(buf[at:at + 4 * nbig],
125
+ dtype="<u4").astype(np.uint64)
126
+ head = head.copy()
127
+ head[big] = tail
128
+ return unzigzag(head)
129
+
130
+
131
+ def ints_to_cells(a: np.ndarray, dec: int) -> List[str]:
132
+ """Fixed-point integers back to their printed text, vectorised.
133
+
134
+ Equivalent to codec.int_to_cell per element, but the digit formatting
135
+ happens inside numpy instead of a Python loop -- this was over half of
136
+ decode time."""
137
+ if a.size == 0:
138
+ return []
139
+ if caccel.HAVE_C:
140
+ return caccel.cells_from_ints(a, dec)
141
+ if dec == 0:
142
+ return list(map(str, a.tolist()))
143
+ scale = 10 ** dec
144
+ pos = "%d.%0" + str(dec) + "d"
145
+ neg = "-" + pos
146
+ out = []
147
+ ap = out.append
148
+ for x in a.tolist():
149
+ if x < 0:
150
+ x = -x
151
+ ap(neg % (x // scale, x % scale))
152
+ else:
153
+ ap(pos % (x // scale, x % scale))
154
+ return out
155
+
156
+
157
+ def packed_len(res: np.ndarray) -> int:
158
+ u = zigzag(res)
159
+ esc = u[u >= ESCAPE]
160
+ width = 8 if esc.size and int(esc.max()) >= (1 << 32) else 4
161
+ return 1 + res.size + width * int(esc.size)
162
+
163
+
164
+ # ----------------------------------------------------------------- analysis
165
+
166
+ def _numeric(cells):
167
+ """Scaled-integer view of a column, or None. C first, numpy fallback."""
168
+ if not cells:
169
+ return None
170
+ if caccel.HAVE_C:
171
+ return caccel.parse_column(cells)
172
+ num = codec.as_numeric_column(cells)
173
+ if num is None or not num[0]:
174
+ return None
175
+ if max(abs(min(num[0])), abs(max(num[0]))) >= INT_LIMIT:
176
+ return None
177
+ return np.array(num[0], dtype=np.int64), num[1]
178
+
179
+
180
+ # At most this fraction of a column may be exception cells. It is a screen,
181
+ # not the decision -- the decision is the whole-file guard in encode(). The
182
+ # point of the screen is to refuse the pathological shapes cheaply: a column
183
+ # of ragged-decimal floats has exceptions everywhere and is exactly the case
184
+ # that was measured 9-23% worse and reverted.
185
+ EX_MAX_FRACTION = 0.05
186
+ # The C port's POW10 table stops here, so both implementations refuse beyond it.
187
+ EX_MAX_DEC = 18
188
+
189
+ _NUM_RE = codec._NUMERIC
190
+
191
+
192
+ def _exact_int(cell: str, dec: int):
193
+ """The cell as a scaled integer, or None if it cannot be reproduced.
194
+
195
+ Exactly codec.as_numeric_column's per-cell test: parse, print back, and
196
+ require the original string. That is what rejects "007", "1.50" at three
197
+ decimals, and -- the case that started this -- "-0.0", which parses to 0
198
+ and prints as "0.0".
199
+ """
200
+ if not _NUM_RE.match(cell):
201
+ return None
202
+ v = codec.cell_to_int(cell, dec)
203
+ # The C parser refuses anything past the 2^62 acceptance limit while it is
204
+ # still accumulating digits, so a value beyond it is an exception there.
205
+ # Python's ints are arbitrary precision and would happily take it, which
206
+ # would put the two implementations on different plans for the same file.
207
+ if v >= INT_LIMIT or v <= -INT_LIMIT:
208
+ return None
209
+ return v if codec.int_to_cell(v, dec) == cell else None
210
+
211
+
212
+ def _numeric_lenient(cells):
213
+ """A column that is numeric apart from a few cells that are not.
214
+
215
+ The numeric test is all or nothing, and that turns out to be expensive.
216
+ The Treasury yield curve -- the matrix table the planar predictor exists
217
+ for -- contains four blank cells in 72,048, and those four drop all eight
218
+ rate columns to the dictionary path. With no numeric columns there is no
219
+ group, and the archive is 59,309 B instead of 34,856 B. Four cells, 41% of
220
+ the file. Separately, one "-0.0" in 26,304 cells disqualifies a whole
221
+ temperature column and splits a 21-column matrix into 18 and 3.
222
+
223
+ The exceptions are recorded by position and stored as text, and their
224
+ slots in the integer array are FORWARD-FILLED from the previous good
225
+ value. Filling rather than removing is what keeps every column the same
226
+ length, which is what keeps the planar predictor able to stack them --
227
+ worth another 18% on the yield curve on top of the 28% for being numeric
228
+ at all. The fill values are never seen: the decoder overwrites those
229
+ positions with the stored strings.
230
+
231
+ The decimal count is the most common one rather than the maximum, with
232
+ ties going to the smaller count so the choice cannot depend on dict order.
233
+ Anything that does not reproduce exactly at that count becomes an
234
+ exception, which folds blanks, "-0.0", leading zeros and stray decimal
235
+ counts into one mechanism.
236
+ """
237
+ n = len(cells)
238
+ if n == 0:
239
+ return None
240
+ limit = int(n * EX_MAX_FRACTION)
241
+
242
+ counts: Dict[int, int] = {}
243
+ bad = 0
244
+ for c in cells:
245
+ if not _NUM_RE.match(c):
246
+ bad += 1
247
+ if bad > limit:
248
+ return None
249
+ continue
250
+ i = c.find(".")
251
+ d = 0 if i < 0 else len(c) - i - 1
252
+ if d > EX_MAX_DEC:
253
+ # counted as an exception candidate rather than as a decimal
254
+ # count, so the histogram stays a fixed 0..18 array in the C port
255
+ bad += 1
256
+ if bad > limit:
257
+ return None
258
+ continue
259
+ counts[d] = counts.get(d, 0) + 1
260
+ if not counts:
261
+ return None
262
+ dec = min(counts, key=lambda d: (-counts[d], d))
263
+
264
+ vals: List[Optional[int]] = [None] * n
265
+ expos: List[int] = []
266
+ exvals: List[str] = []
267
+ for i, c in enumerate(cells):
268
+ v = _exact_int(c, dec)
269
+ if v is None:
270
+ expos.append(i)
271
+ exvals.append(c)
272
+ if len(expos) > limit:
273
+ return None
274
+ else:
275
+ vals[i] = v
276
+ if not expos:
277
+ return None # plain _numeric already handles this
278
+
279
+ # forward fill, then patch any leading run with the first good value
280
+ fill = None
281
+ first_good = None
282
+ for i in range(n):
283
+ if vals[i] is None:
284
+ vals[i] = fill
285
+ else:
286
+ fill = vals[i]
287
+ if first_good is None:
288
+ first_good = vals[i]
289
+ if first_good is None:
290
+ return None
291
+ for i in range(n):
292
+ if vals[i] is None:
293
+ vals[i] = first_good
294
+ else:
295
+ break
296
+
297
+ a = np.array(vals, dtype=np.int64)
298
+ if a.size and int(np.abs(a).max()) >= INT_LIMIT:
299
+ return None
300
+ return a, dec, np.array(expos, dtype=np.int64), exvals
301
+
302
+
303
+ def _lenient_promising(cells, a, expos, nrows) -> bool:
304
+ """Could storing this column as numbers possibly beat leaving it alone?
305
+
306
+ The never-worse guard costs a whole second encode, and profiling says that
307
+ second encode IS the cost -- the lenient scan itself is 0.04-0.09s where
308
+ the extra encode is 0.9-1.4s. On four large datasets the guard runs, finds
309
+ nothing, and doubles encode time for zero bytes.
310
+
311
+ This is a one-sided screen, and the direction matters. The alternative cost
312
+ is deliberately OVER-estimated: it includes the dictionary's alphabet, and
313
+ it ignores the reorder parent that would make the column cheaper still. So
314
+ the real alternative is never more expensive than this estimate, and
315
+ `numeric >= estimate` therefore implies `numeric >= real`. Skipping on that
316
+ basis cannot discard a win on this column.
317
+
318
+ It is only a nominator. Whenever any column looks promising the full
319
+ end-to-end guard still runs and still decides.
320
+ """
321
+ k = diff_order(a)
322
+ num = _probe_bytes(pack_ints(np.diff(a, n=k) if k else a))
323
+ num += _probe_bytes(pack_ints(np.diff(expos, prepend=np.int64(0))))
324
+ uniq = sorted(set(cells))
325
+ if len(uniq) <= DICT_MAX and len(uniq) * 2 <= max(nrows, 2):
326
+ idx = {v: i for i, v in enumerate(uniq)}
327
+ ids = np.array([idx[v] for v in cells], dtype=np.int64)
328
+ alt = _probe_bytes(ids.astype(_width(len(uniq))).tobytes())
329
+ alt += _probe_len(uniq)
330
+ else:
331
+ alt = _probe_len(cells)
332
+ return num < alt
333
+
334
+
335
+ def classify(table, lenient: bool = True) -> List[dict]:
336
+ nrows = len(table.rows)
337
+ plan = []
338
+ for j in range(len(table.columns)):
339
+ cells = table.column(j)
340
+ num = _numeric(cells)
341
+ if num is not None:
342
+ plan.append({"kind": "num", "ints": num[0], "dec": num[1],
343
+ "j": j, "ex": None})
344
+ continue
345
+ lax = _numeric_lenient(cells) if lenient else None
346
+ if lax is not None:
347
+ plan.append({"kind": "num", "ints": lax[0], "dec": lax[1],
348
+ "j": j, "ex": (lax[2], lax[3]),
349
+ "exp": _lenient_promising(cells, lax[0], lax[2],
350
+ nrows)})
351
+ continue
352
+ uniq = sorted(set(cells))
353
+ if len(uniq) <= DICT_MAX and len(uniq) * 2 <= max(nrows, 2):
354
+ idx = {v: i for i, v in enumerate(uniq)}
355
+ plan.append({"kind": "dict", "alpha": uniq,
356
+ "ids": np.array([idx[v] for v in cells],
357
+ dtype=np.int64), "j": j})
358
+ continue
359
+ plan.append({"kind": "text", "cells": cells, "j": j})
360
+ return plan
361
+
362
+
363
+ def find_2d_groups(plan, nrows: int = 1 << 30) -> List[List[int]]:
364
+ if nrows < MIN_ROWS_FOR_2D:
365
+ return []
366
+ groups, run = [], []
367
+ for pos, col in enumerate(plan):
368
+ if col["kind"] != "num" or col["ints"].size == 0:
369
+ if len(run) >= MIN_2D_GROUP:
370
+ groups.append(run)
371
+ run = []
372
+ continue
373
+ if not run:
374
+ run = [pos]
375
+ continue
376
+ prev = plan[run[-1]]
377
+ hi_a = int(np.abs(prev["ints"]).max()) or 1
378
+ hi_b = int(np.abs(col["ints"]).max()) or 1
379
+ if prev["dec"] == col["dec"] and hi_a <= 8 * hi_b and hi_b <= 8 * hi_a:
380
+ run.append(pos)
381
+ else:
382
+ if len(run) >= MIN_2D_GROUP:
383
+ groups.append(run)
384
+ run = [pos]
385
+ if len(run) >= MIN_2D_GROUP:
386
+ groups.append(run)
387
+ return groups
388
+
389
+
390
+ def diff_order(a: np.ndarray) -> int:
391
+ """Polynomial degree whose residuals pack smallest. Order k residuals are
392
+ the k-th finite difference, so this is just np.diff k times."""
393
+ best, best_cost = 0, packed_len(a)
394
+ for k in range(1, min(4, a.size - 1) + 1):
395
+ c = packed_len(np.diff(a, n=k)) + 8 * k
396
+ if c < best_cost:
397
+ best, best_cost = k, c
398
+ return best
399
+
400
+
401
+ # ------------------------------------------------- cross-column conditioning
402
+
403
+ def _counts_entropy(counts: np.ndarray, n: int) -> float:
404
+ p = counts / n
405
+ return float(-np.sum(p * np.log2(p)))
406
+
407
+
408
+ # Entropy scores are COMPARED as integers, never as floats.
409
+ #
410
+ # The parent search ranks columns by entropy differences, and for a long time
411
+ # both implementations tried to agree on those differences bit for bit. That is
412
+ # not achievable and the attempt was hiding a real defect. `np.sum` is pairwise
413
+ # for small arrays -- which `csrc/ppz_encode.c:pairwise_sum` reproduces exactly,
414
+ # and for a 23-bin marginal the two agree to the last bit -- but on a 13,147-bin
415
+ # joint histogram numpy takes a SIMD reduction whose grouping depends on the
416
+ # CPU's vector width. Measured on this machine: `np.sum` differs from numpy's
417
+ # own documented scalar algorithm at that size. So no portable C can match it,
418
+ # and two numpy builds on different hardware need not match each other either.
419
+ #
420
+ # The last bit was never meaningful. A gain of 0.4912703838362144 and one of
421
+ # 0.4912703838362162 say the same thing about a column, and letting the
422
+ # difference between them choose a parent -- and therefore every byte of the
423
+ # archive after it -- is the bug. Scores are quantised to a grid about 1e-6
424
+ # wide and compared as int64. Anything closer than that is a tie, and ties fall
425
+ # to the lower column index in both implementations, which is a rule they can
426
+ # actually both keep.
427
+ #
428
+ # `floor(x * SCALE + 0.5)` rather than round(): Python's round() is
429
+ # banker's rounding and C's llround() is half-away-from-zero, and every score
430
+ # here is non-negative.
431
+ _SCORE_SCALE = 1 << 20
432
+
433
+
434
+ def _score(x: float) -> int:
435
+ return int(math.floor(x * _SCORE_SCALE + 0.5))
436
+
437
+
438
+ # The nomination floor, 0.05 bits, on the same integer grid.
439
+ _MIN_GAIN_SCORE = _score(0.05)
440
+
441
+
442
+ # Above this many joint bins, counting by bincount would allocate more than
443
+ # it saves and np.unique's sort is the better trade. Real survey columns have
444
+ # cardinalities in the tens, so the product is tiny and this never trips.
445
+ JOINT_BINCOUNT_MAX = 1 << 20
446
+
447
+
448
+ def _joint_counts(xs: np.ndarray, ys: np.ndarray, ny: int) -> np.ndarray:
449
+ """Counts of each distinct (x, y) pair, ascending by combined key.
450
+
451
+ np.unique sorts, which is O(n log n) and was the single largest cost in
452
+ encoding a wide table. When the combined key space is small -- which is
453
+ the normal case for categorical columns -- bincount does it in one pass.
454
+
455
+ Both branches return counts in ascending key order, which matters for more
456
+ than tidiness: the caller sums them, np.sum is pairwise, and a different
457
+ order would give a different last bit, a different parent, and a different
458
+ archive. The two paths are interchangeable only because the order matches.
459
+ """
460
+ key = xs * ny + ys
461
+ if key.size:
462
+ hi = int(key.max())
463
+ if hi < JOINT_BINCOUNT_MAX:
464
+ c = np.bincount(key)
465
+ return c[c > 0]
466
+ _, jc = np.unique(key, return_counts=True)
467
+ return jc
468
+
469
+
470
+ def _cond_entropy_corrected(xs: np.ndarray, ys: np.ndarray, ny: int,
471
+ hx: float, mx_size: int) -> float:
472
+ """H(Y|X) with a Miller-Madow correction.
473
+
474
+ Computed as H(X,Y) - H(X) from one combined key, so it is two numpy
475
+ passes instead of two Python Counters. That mattered: on a 209-column
476
+ survey table this function was 94% of encode time, called once per
477
+ ordered column pair -- 38,220 times.
478
+
479
+ `hx` and `mx_size` describe X alone. They used to be recomputed inside
480
+ here, once for every Y -- 420 times per column on a 421-column table, for
481
+ a value that never changed. They are now hoisted to the caller. The
482
+ arithmetic is written in the same shape as before so the result is the
483
+ same double, not merely the same number.
484
+
485
+ The correction is not optional. A parent with many distinct values scores
486
+ near-zero conditional entropy purely because each of its values is seen a
487
+ handful of times, which is how an earlier version picked nonsense parents.
488
+ """
489
+ n = xs.size
490
+ if n == 0:
491
+ return 0.0
492
+ jc = _joint_counts(xs, ys, ny)
493
+ h = _counts_entropy(jc, n) - hx
494
+ return h + (jc.size - mx_size) / (2.0 * n * math.log(2))
495
+
496
+
497
+ def _entropy_and_distinct(ys: np.ndarray) -> Tuple[float, int]:
498
+ """H(Y) and the number of distinct values, from one bincount.
499
+
500
+ Both are needed per column and both come from the same counts, so taking
501
+ them together halves the work and guarantees they agree.
502
+ """
503
+ n = ys.size
504
+ if n == 0:
505
+ return 0.0, 0
506
+ counts = np.bincount(ys)
507
+ counts = counts[counts > 0]
508
+ return _counts_entropy(counts, n), int(counts.size)
509
+
510
+
511
+ def _entropy(ys: np.ndarray) -> float:
512
+ return _entropy_and_distinct(ys)[0]
513
+
514
+
515
+ def pick_parents(plan, nrows) -> Tuple[Dict[int, Optional[int]], List[int]]:
516
+ dict_pos = [p for p, c in enumerate(plan) if c["kind"] == "dict"]
517
+ if nrows < MIN_ROWS_FOR_PARENTS or len(dict_pos) < 2:
518
+ return {p: None for p in dict_pos}, list(dict_pos)
519
+
520
+ # Parent search is O(columns^2) pairs. A wide table has a lot of them --
521
+ # 209 columns is 38,220 -- so trade sample depth against pair count and
522
+ # keep the total work bounded. Narrow tables are unaffected: they hit the
523
+ # MI_SAMPLE ceiling instead.
524
+ npairs = max(1, len(dict_pos) * (len(dict_pos) - 1))
525
+ rows = min(MI_SAMPLE, max(MI_MIN_SAMPLE, MI_BUDGET // npairs))
526
+ step = max(1, nrows // rows)
527
+ sample = {p: np.ascontiguousarray(plan[p]["ids"][::step]) for p in dict_pos}
528
+ sizes = {p: len(plan[p]["alpha"]) for p in dict_pos}
529
+ base, distinct = {}, {}
530
+ for p in dict_pos:
531
+ base[p], distinct[p] = _entropy_and_distinct(sample[p])
532
+
533
+ gain = defaultdict(dict)
534
+ for a in dict_pos:
535
+ # H(X) and X's distinct count are the same for every b, so they are
536
+ # computed once per a rather than once per pair
537
+ ha, ma = base[a], distinct[a]
538
+ for b in dict_pos:
539
+ if a == b:
540
+ continue
541
+ g = _score(base[b] - _cond_entropy_corrected(
542
+ sample[a], sample[b], sizes[b], ha, ma))
543
+ if g > _MIN_GAIN_SCORE:
544
+ gain[b][a] = g
545
+
546
+ root = min(dict_pos, key=lambda p: _score(base[p]))
547
+ placed, order = {root}, [root]
548
+ parent: Dict[int, Optional[int]] = {root: None}
549
+
550
+ # `remaining` is a list in column order, not a set. Two candidate pairs can
551
+ # have exactly equal gain -- duplicated or near-duplicated columns are
552
+ # common in survey extracts -- and the winner is whichever the loop reaches
553
+ # first, because the comparison is strictly `>`. With a set, "first" is an
554
+ # artefact of CPython's hash table, so the bytes of the archive depended on
555
+ # it. A list makes the tie-break explicit and reproducible: lowest column
556
+ # index wins. That matters for the C port, which has to reproduce this
557
+ # exactly, and it is worth having regardless -- an encoder whose output can
558
+ # shift with an interpreter's internals is not one to build a format on.
559
+ remaining = [p for p in dict_pos if p != root]
560
+ while remaining:
561
+ best = None
562
+ for b in remaining:
563
+ for a, g in gain[b].items():
564
+ if a in placed and (best is None or g > best[2]):
565
+ best = (b, a, g)
566
+ if best is None:
567
+ b = min(remaining, key=lambda p: _score(base[p]))
568
+ parent[b] = None
569
+ else:
570
+ b, a, _ = best
571
+ parent[b] = a
572
+ placed.add(b)
573
+ order.append(b)
574
+ remaining.remove(b)
575
+
576
+ # Never-worse, the same guard text columns already had.
577
+ #
578
+ # This asymmetry was doing real damage. Text parents are chosen by
579
+ # measurement and can decline; dictionary parents were chosen by
580
+ # conditional entropy and taken on trust. So every experiment that moved a
581
+ # column out of `text` -- ragged-decimal numerics, raising the dictionary
582
+ # threshold -- traded a measured decision for an unmeasured one and lost,
583
+ # and the loss landed on a different column than the one being changed,
584
+ # which is why per-column probes never saw it coming.
585
+ #
586
+ # Entropy stays as the nominator; it is good at that and cheap. What is
587
+ # added is the check that the nomination actually pays. A parent is kept
588
+ # only if permuting by it beats leaving the column alone.
589
+ for b in order:
590
+ a = parent.get(b)
591
+ if a is None:
592
+ continue
593
+ ids = plan[b]["ids"]
594
+ perm = np.argsort(plan[a]["ids"], kind="stable")
595
+ w = _width(len(plan[b]["alpha"]))
596
+ if _probe_bytes(ids[perm].astype(w).tobytes()) >= \
597
+ _probe_bytes(ids.astype(w).tobytes()):
598
+ parent[b] = None
599
+ return parent, order
600
+
601
+
602
+ # How many entropy-nominated parents actually get compressed and compared.
603
+ # Each candidate costs one cheap pass over the column, and the score is a good
604
+ # enough nominator that the winner is usually in the first few -- but "usually"
605
+ # was doing more work than it should. Measured end to end over 18 tables:
606
+ #
607
+ # candidates 1 3 5 10 all
608
+ # total 8,839,893 8,833,390 8,800,253 8,796,082 8,790,196
609
+ # vs 3 +0.07% 0.00% -0.38% -0.42% -0.49%
610
+ # encode s 9.6 10.4 11.2 12.9 17.4
611
+ #
612
+ # 5 takes the bulk of the available gain for 8% more encode time; going to
613
+ # "all" costs 67% more time for a further 0.11%. The win is concentrated where
614
+ # it is most wanted -- chicago_permits, the worst dataset in the corpus, drops
615
+ # 3.5% at this setting.
616
+ #
617
+ # One caveat worth keeping: widening this is NOT monotonic per file. Each
618
+ # column's choice is probed alone, but every text column ends up in one shared
619
+ # blob that is compressed together, so a locally better ordering can be
620
+ # globally worse -- noaa_gsoy_ord is 6,559 B at 3 and 6,588 B at 10. That is
621
+ # the same local-versus-global trap as the 2D groups, and it is the reason
622
+ # this constant is set by measurement rather than raised to "all".
623
+ TEXT_PARENT_CANDIDATES = 5
624
+
625
+ # A fast stand-in for the real entropy stage, used only to choose between
626
+ # orderings. Preset 1 ranks the candidates the same way preset 9 does at a
627
+ # fraction of the cost, and nothing it produces is ever stored.
628
+ _PROBE = dict(format=lzma.FORMAT_RAW,
629
+ filters=[{"id": lzma.FILTER_LZMA2, "preset": 1}])
630
+
631
+
632
+ def _probe_len(cells: List[str]) -> int:
633
+ return len(lzma.compress("\n".join(cells).encode("utf-8"), **_PROBE))
634
+
635
+
636
+ def _probe_bytes(b: bytes) -> int:
637
+ return len(lzma.compress(b, **_PROBE))
638
+
639
+
640
+ def pick_text_parents(plan, nrows, parent, order) -> Dict[int, Optional[int]]:
641
+ """Choose a reorder parent for each text column.
642
+
643
+ Text columns were the one place the reordering idea was never applied, and
644
+ they are exactly where it was most needed: on the datasets this codec does
645
+ worst on, the text blob is 59-71% of the output and receives no modelling
646
+ at all. Measured on real data, sorting a text column by the right
647
+ dictionary column takes 21-45% off it -- for free, by the same argument
648
+ that makes it free for dictionary columns.
649
+
650
+ Text columns are leaves: a text column may HAVE a parent but never BE one.
651
+ That is not a modelling decision, it is a decoding one -- the permutation
652
+ is recomputed from a parent the decoder has already rebuilt, and text
653
+ columns are rebuilt after every dictionary column, so a text parent could
654
+ not be guaranteed available in time. Keeping them leaves also means no
655
+ cycle is possible and the existing decode order still holds.
656
+
657
+ Scoring reuses the dictionary machinery by factorising the text column
658
+ into ids. That is only ever used to score -- the column is still stored as
659
+ text.
660
+ """
661
+ text_pos = [p for p, c in enumerate(plan) if c["kind"] == "text"]
662
+ out: Dict[int, Optional[int]] = {p: None for p in text_pos}
663
+ dict_pos = [p for p, c in enumerate(plan) if c["kind"] == "dict"]
664
+ if nrows < MIN_ROWS_FOR_PARENTS or not dict_pos or not text_pos:
665
+ return out
666
+
667
+ npairs = max(1, len(text_pos) * len(dict_pos))
668
+ rows = min(MI_SAMPLE, max(MI_MIN_SAMPLE, MI_BUDGET // npairs))
669
+ step = max(1, nrows // rows)
670
+
671
+ dsample = {p: np.ascontiguousarray(plan[p]["ids"][::step]) for p in dict_pos}
672
+ dbase = {}
673
+ for p in dict_pos:
674
+ dbase[p] = _entropy_and_distinct(dsample[p])
675
+
676
+ for tp in text_pos:
677
+ cells = plan[tp]["cells"][::step]
678
+ uniq = sorted(set(cells))
679
+ idx = {v: i for i, v in enumerate(uniq)}
680
+ ids = np.array([idx[v] for v in cells], dtype=np.int64)
681
+ base_t = _entropy(ids)
682
+ ranked = []
683
+ for dp in dict_pos:
684
+ ha, ma = dbase[dp]
685
+ g = _score(base_t - _cond_entropy_corrected(
686
+ dsample[dp], ids, len(uniq), ha, ma))
687
+ if g > _MIN_GAIN_SCORE:
688
+ ranked.append((g, dp))
689
+ if not ranked:
690
+ continue
691
+ ranked.sort(key=lambda r: (-r[0], r[1]))
692
+
693
+ # Conditional entropy is the right criterion for dictionary columns and
694
+ # the wrong one here. It measures how often the parent pins down the
695
+ # exact value; what actually shrinks a text column is having *similar*
696
+ # strings adjacent, which is not the same thing. Worse, reordering
697
+ # destroys whatever useful order the file already had -- a table
698
+ # written in time order often has address locality for free. Trusting
699
+ # the score alone made one real dataset 66% LARGER.
700
+ #
701
+ # So the score is used only to nominate candidates, and the decision is
702
+ # measured. A cheap preset picks between them; the real entropy stage
703
+ # runs later on whichever won. Never-worse is the same rule the
704
+ # fallback container follows, and for the same reason.
705
+ full = plan[tp]["cells"]
706
+ keep_none = _probe_len(full)
707
+ best, best_cost = None, keep_none
708
+ for _g, dp in ranked[:TEXT_PARENT_CANDIDATES]:
709
+ perm = np.argsort(plan[dp]["ids"], kind="stable")
710
+ cost = _probe_len([full[i] for i in perm])
711
+ if cost < best_cost:
712
+ best, best_cost = dp, cost
713
+ out[tp] = best
714
+ return out
715
+
716
+
717
+ def _emit_exceptions(col, spec, bins, sgroups) -> None:
718
+ """Store the cells a numeric column could not represent.
719
+
720
+ Positions are delta-coded before packing -- exceptions are sorted and
721
+ usually sparse, so the gaps are far smaller than the indices. The strings
722
+ join the text blob, where a run of empty cells or of "-0.0" costs
723
+ essentially nothing.
724
+
725
+ Emitted immediately after the column's own payload, in both the binary and
726
+ the string stream, so the decoder recovers them at the same point in its
727
+ own walk without needing an index.
728
+ """
729
+ ex = col.get("ex")
730
+ if not ex:
731
+ return
732
+ expos, exvals = ex
733
+ spec["nex"] = int(expos.size)
734
+ bins.append(pack_ints(np.diff(expos, prepend=np.int64(0))))
735
+ sgroups.append(exvals)
736
+
737
+
738
+ def _apply_exceptions(cells, sp, cuts, texts, bi, ti):
739
+ """Inverse of _emit_exceptions. Returns (cells, bi, ti)."""
740
+ nex = sp.get("nex", 0)
741
+ if not nex:
742
+ return cells, bi, ti
743
+ expos = np.cumsum(unpack_ints(cuts[bi], nex))
744
+ bi += 1
745
+ exvals = texts[ti]
746
+ ti += 1
747
+ for i, p in enumerate(expos.tolist()):
748
+ cells[p] = exvals[i]
749
+ return cells, bi, ti
750
+
751
+
752
+ def _width(n: int) -> str:
753
+ return "<u1" if n <= 256 else ("<u2" if n <= 65536 else "<u4")
754
+
755
+
756
+ # Strings are stored as one concatenated UTF-8 blob plus an array of byte
757
+ # lengths. Joining on a separator -- "\n", "\x00", anything -- is wrong,
758
+ # because a cell is allowed to contain that byte. This costs nothing: the
759
+ # lengths compress to almost nothing and the blob compresses exactly as well
760
+ # as the joined form did.
761
+
762
+ def _front_code(words: List[str]) -> bytes:
763
+ """Sorted words as (shared prefix length, remainder).
764
+
765
+ A dictionary column's alphabet is stored sorted, so neighbours share long
766
+ prefixes -- "CHICAGO AVE", "CHICAGO BLVD". Front-coding replaces each word
767
+ with how much it shares with the previous one plus the rest.
768
+
769
+ Worth being sceptical of, because RLE over the same blob was tried here and
770
+ bought +0.3%: xz already finds adjacent repeats. This is a different shape
771
+ of redundancy, and measured end to end it is worth -1.19% overall and
772
+ -7.39% on seattle_fire911 -- but it LOSES on four of thirteen datasets, so
773
+ it is chosen by measurement rather than applied on principle.
774
+
775
+ The prefix length is one byte, capped at 255. Only ever applied to groups
776
+ with no embedded newline, so no remainder can contain one either and the
777
+ newline join stays unambiguous.
778
+
779
+ The shared prefix is counted in BYTES, not characters. That is not a
780
+ detail: the C port compares bytes, and if this counted characters the two
781
+ would produce different archives for any word with a non-ASCII prefix.
782
+ Cutting a multi-byte character in half is harmless here because the halves
783
+ are only ever concatenated back together before decoding.
784
+ """
785
+ lens = bytearray()
786
+ rest = []
787
+ prev = b""
788
+ for w in words:
789
+ b = w.encode("utf-8")
790
+ n = 0
791
+ m = min(len(prev), len(b), 255)
792
+ while n < m and prev[n] == b[n]:
793
+ n += 1
794
+ lens.append(n)
795
+ rest.append(b[n:])
796
+ prev = b
797
+ return bytes(lens) + b"\n".join(rest)
798
+
799
+
800
+ def _un_front_code(chunk: bytes, n: int) -> List[str]:
801
+ lens = chunk[:n]
802
+ rest = chunk[n:].split(b"\n")
803
+ out: List[str] = []
804
+ prev = b""
805
+ for i in range(n):
806
+ w = prev[:lens[i]] + rest[i]
807
+ out.append(w.decode("utf-8"))
808
+ prev = w
809
+ return out
810
+
811
+
812
+ # How many groups get a real end-to-end trial. Each one costs a compression of
813
+ # the whole text pile, so this is what bounds the price -- and the price is the
814
+ # whole argument for the value being 1. Measured across 21 tables:
815
+ #
816
+ # K encode total bytes vs K=0
817
+ # 0 28.5s 9,646,428 +0 (1.00x)
818
+ # 1 32.9s 9,633,980 -12,448 (1.16x)
819
+ # 2 40.0s 9,633,419 -13,009 (1.41x)
820
+ # 3 43.1s 9,633,419 -13,009 (1.52x)
821
+ #
822
+ # One trial takes 96% of everything available for a sixth of the time penalty.
823
+ # The stake-ranked first candidate is the right one nearly every time, which is
824
+ # what a good nominator is for.
825
+ TEXT_FC_CANDIDATES = 1
826
+
827
+
828
+ # Below this estimated saving a candidate is not worth a trial, and below this
829
+ # share of its own bytes it is not worth one either. Both are floors on the
830
+ # NOMINATION only -- every survivor is still decided by measuring the whole
831
+ # pile. Their job is to stop the trials being spent on nothing, which is what
832
+ # takes encode time from 21s to 57s across the test set.
833
+ FC_MIN_BYTES = 1024
834
+ FC_MIN_NUM, FC_MIN_DEN = 1, 4 # shared/total must exceed 1/4
835
+
836
+
837
+ def _prefix_stats(words: List[str], cap: int = 4000) -> Tuple[int, int]:
838
+ """(bytes shared with the previous word, total bytes), both integers.
839
+
840
+ This measures the one property that makes front-coding pay -- how much
841
+ consecutive values have in common. It is deliberately NOT a compression
842
+ probe: a probe run on the group alone was measured +2.50% worse end to end,
843
+ because it cannot see what happens once the group is concatenated with
844
+ everything else.
845
+
846
+ Returned as two integers rather than a ratio so the C port can rank
847
+ candidates by cross-multiplying and never has to agree about a float.
848
+ """
849
+ n = min(len(words), cap)
850
+ if n < 2:
851
+ return 0, 0
852
+ shared = 0
853
+ prev = words[0].encode("utf-8")
854
+ total = len(prev)
855
+ for i in range(1, n):
856
+ b = words[i].encode("utf-8")
857
+ m = min(len(prev), len(b), 255)
858
+ k = 0
859
+ while k < m and prev[k] == b[k]:
860
+ k += 1
861
+ shared += k
862
+ total += len(b)
863
+ prev = b
864
+ return shared, total
865
+
866
+
867
+ def _choose_front(groups: List[List[str]], ndict: int) -> frozenset:
868
+ """Which string groups to front-code, decided on the WHOLE text pile.
869
+
870
+ Three designs that decided per group were measured and all lost: a cheap
871
+ per-group compression probe (+2.50%) and a prefix-ratio threshold at 0.3,
872
+ 0.5 and 0.7 (+3.29%, +2.45%, +0.57%). The reason is worth remembering --
873
+ a long shared prefix IS a long LZ77 match, and xz already codes it
874
+ cheaply, so front-coding can remove the very anchor xz was matching on.
875
+ High prefix sharing is necessary and nowhere near sufficient.
876
+
877
+ So the ratio only NOMINATES, capped at TEXT_FC_CANDIDATES, and every
878
+ decision is a measurement of the entire pile. Measured that way: -0.39%
879
+ overall and never worse on any table, with weather_hourly's time column
880
+ going 9,438 -> 434 bytes and treasury_yields' date column -19.5%.
881
+ """
882
+ def build(front):
883
+ packed = _pack_strings(groups, front)
884
+ return packed, lzma.compress(packed[0], **XZ)
885
+
886
+ best = frozenset()
887
+ best_packed, best_zb = build(best)
888
+ best_z = len(best_zb)
889
+
890
+ # the alphabets as one block, which is what shipped before this
891
+ if ndict:
892
+ alpha = frozenset(range(ndict))
893
+ packed, zb = build(alpha)
894
+ if len(zb) < best_z:
895
+ best, best_packed, best_zb, best_z = alpha, packed, zb, len(zb)
896
+
897
+ # Then the most promising remaining groups, one at a time, each confirmed.
898
+ #
899
+ # Ranked by ESTIMATED BYTES AT STAKE, not by ratio. Ranking by ratio was
900
+ # wrong in a way worth recording: a numeric column's exception group is a
901
+ # run of identical values -- 47 copies of "-0.0" -- which shares 98% of its
902
+ # bytes and so outranked everything, and with three trial slots those tiny
903
+ # groups crowded out the real prize. weather_hourly's timestamp column has
904
+ # a ratio of only 0.74 but about a megabyte at stake against that group's
905
+ # 184 bytes, and it was being skipped entirely.
906
+ cands = []
907
+ for i in range(ndict, len(groups)):
908
+ g = groups[i]
909
+ if not g or any("\n" in s for s in g):
910
+ continue
911
+ sh, tot = _prefix_stats(g)
912
+ if sh <= 0 or tot <= 0:
913
+ continue
914
+ if sh * FC_MIN_DEN <= tot * FC_MIN_NUM:
915
+ continue
916
+ sampled = min(len(g), 4000)
917
+ stake = sh * len(g) // sampled
918
+ if stake < FC_MIN_BYTES:
919
+ continue
920
+ cands.append((stake, i))
921
+ # descending stake, ties by group index so both implementations agree
922
+ cands.sort(key=lambda c: (-c[0], c[1]))
923
+ for _stake, i in cands[:TEXT_FC_CANDIDATES]:
924
+ trial = frozenset(best | {i})
925
+ packed, zb = build(trial)
926
+ if len(zb) < best_z:
927
+ best, best_packed, best_zb, best_z = trial, packed, zb, len(zb)
928
+ # the winner's pile and its compressed bytes are returned so the caller
929
+ # never compresses the same pile a second time
930
+ return best, best_packed, best_zb
931
+
932
+
933
+ def _pack_strings(groups: List[List[str]], front=frozenset()):
934
+ """Concatenate string groups, paying for length prefixes only where a
935
+ value actually contains a newline.
936
+
937
+ Newline-joining is what xz likes -- the separator sits in the same stream
938
+ as the content and models well. But a cell may legally contain a newline,
939
+ which silently split one value into two. So each group records whether it
940
+ is newline-joined; the rare group that is not carries an explicit length
941
+ array. On real tables no group needs one, and the joined layout is kept."""
942
+ parts, metas, length_arrays = [], [], []
943
+ for i, g in enumerate(groups):
944
+ if any("\n" in s for s in g):
945
+ blobs = [s.encode("utf-8") for s in g]
946
+ data = b"".join(blobs)
947
+ length_arrays.append(np.fromiter((len(b) for b in blobs),
948
+ dtype=np.int64, count=len(blobs)))
949
+ metas.append({"n": len(g), "b": len(data), "nl": False})
950
+ elif i in front:
951
+ data = _front_code(g)
952
+ metas.append({"n": len(g), "b": len(data), "nl": True, "fc": 1})
953
+ else:
954
+ data = "\n".join(g).encode("utf-8")
955
+ metas.append({"n": len(g), "b": len(data), "nl": True})
956
+ parts.append(data)
957
+ return b"".join(parts), metas, length_arrays
958
+
959
+
960
+ def _unpack_strings(data: bytes, metas, length_bins,
961
+ legacy_nfront: int = 0) -> List[List[str]]:
962
+ """`legacy_nfront` covers archives written with the archive-level "fc"
963
+ flag, which meant "the first N groups are front-coded". Those are only
964
+ hours old but they exist on disk, and reading one under the new per-group
965
+ rule would return wrong strings rather than an error -- the worst outcome
966
+ for a decoder. Both spellings are honoured."""
967
+ out, at, li = [], 0, 0
968
+ for i, m in enumerate(metas):
969
+ chunk = data[at:at + m["b"]]
970
+ at += m["b"]
971
+ if m["n"] == 0:
972
+ out.append([])
973
+ continue
974
+ if m["nl"]:
975
+ if m.get("fc") or i < legacy_nfront:
976
+ out.append(_un_front_code(chunk, m["n"]))
977
+ else:
978
+ out.append(chunk.decode("utf-8").split("\n"))
979
+ else:
980
+ lengths = unpack_ints(length_bins[li], m["n"])
981
+ li += 1
982
+ ends = np.cumsum(lengths)
983
+ starts = ends - lengths
984
+ out.append([chunk[int(starts[i]):int(ends[i])].decode("utf-8")
985
+ for i in range(m["n"])])
986
+ return out
987
+
988
+
989
+ # --------------------------------------------------------------- fallbacks
990
+
991
+ def _canonical_bytes(table) -> bytes:
992
+ """The table as canonical CSV -- the form a general compressor would see.
993
+
994
+ Deliberately the same shape as the input file rather than some private
995
+ layout, because the fallback only earns its place if it matches what
996
+ `xz file.csv` would have produced. A private layout that compresses worse
997
+ than the user's own CSV would be a fallback that does not fall back.
998
+ """
999
+ import csv
1000
+ import io
1001
+ buf = io.StringIO()
1002
+ w = csv.writer(buf, lineterminator="\n")
1003
+ w.writerow(table.columns)
1004
+ w.writerows(table.rows)
1005
+ return buf.getvalue().encode("utf-8")
1006
+
1007
+
1008
+ def _from_canonical(data: bytes):
1009
+ import csv
1010
+ import io
1011
+ rows = list(csv.reader(io.StringIO(data.decode("utf-8"), newline="")))
1012
+ if not rows:
1013
+ return dtz.Table([], [])
1014
+ return dtz.Table(rows[0], rows[1:])
1015
+
1016
+
1017
+ # Input block size for the capped compressors below. Only affects how often
1018
+ # the output is checked against the cap, never the bytes produced: LZMA2 and
1019
+ # bzip2 are streams, and feeding one in pieces gives the identical output to a
1020
+ # single call. Verified for both.
1021
+ _CAP_CHUNK = 1 << 20
1022
+
1023
+
1024
+ def _compress_capped(data: bytes, cap: int, kind: str) -> Optional[bytes]:
1025
+ """`data` compressed, or None as soon as the result cannot fit in `cap`.
1026
+
1027
+ The point is to make "always check the fallback" affordable. Compressed
1028
+ output only grows, so once it has passed the cap the candidate has lost and
1029
+ the rest of the input is wasted work. On a table the modelling wins
1030
+ handsomely -- which is most of them -- that abort happens early, because
1031
+ the modelled archive is a small fraction of what the plain CSV compresses
1032
+ to.
1033
+
1034
+ Aborting cannot change the answer, only the time taken: the bytes returned
1035
+ when it does NOT abort are exactly `lzma.compress`/`bz2.compress` would
1036
+ have produced.
1037
+ """
1038
+ comp = (lzma.LZMACompressor(**XZ) if kind == "xz"
1039
+ else bz2.BZ2Compressor(9))
1040
+ out, total = [], 0
1041
+ for i in range(0, len(data), _CAP_CHUNK):
1042
+ piece = comp.compress(data[i:i + _CAP_CHUNK])
1043
+ total += len(piece)
1044
+ if total >= cap:
1045
+ return None
1046
+ out.append(piece)
1047
+ piece = comp.flush()
1048
+ total += len(piece)
1049
+ if total >= cap:
1050
+ return None
1051
+ out.append(piece)
1052
+ return b"".join(out)
1053
+
1054
+
1055
+ def _raw_candidates(table, limit: int) -> Optional[bytes]:
1056
+ """Smallest standard-codec encoding of the whole table, or None.
1057
+
1058
+ Returns None unless it beats `limit`, and also unless it round-trips --
1059
+ CSV quoting is not lossless for every conceivable cell, so the candidate
1060
+ is parsed back and compared before it is allowed to win. A fallback that
1061
+ corrupts data is worse than losing by 11%.
1062
+
1063
+ Called on every table now, not only when no trick fired. The compressors
1064
+ are capped at `limit` so a hopeless candidate is abandoned part-way rather
1065
+ than finished and then thrown away.
1066
+ """
1067
+ canon = _canonical_bytes(table)
1068
+ try:
1069
+ # Parsed ONCE. This used to call _from_canonical twice, once for the
1070
+ # rows and once for the columns, which was affordable while the
1071
+ # fallback ran only on tables where no trick fired and is not now that
1072
+ # it runs on every table.
1073
+ back = _from_canonical(canon)
1074
+ if back.rows != table.rows or back.columns != table.columns:
1075
+ return None
1076
+ except Exception:
1077
+ return None
1078
+
1079
+ best = None
1080
+ for magic, kind in ((MAGIC_RAW_XZ, "xz"), (MAGIC_RAW_BZ, "bz2")):
1081
+ # 4 bytes of magic ride in front, so the compressor's own budget is
1082
+ # that much smaller than the archive it has to beat.
1083
+ cap = (len(best) if best is not None else limit) - len(magic)
1084
+ if cap <= 0:
1085
+ continue
1086
+ blob = _compress_capped(canon, cap, kind)
1087
+ if blob is not None:
1088
+ best = magic + blob
1089
+ return best
1090
+
1091
+
1092
+ # ------------------------------------------------------------------- codec
1093
+
1094
+ def encode(table) -> bytes:
1095
+ """Smallest of the modelled encoding and the plain fallbacks.
1096
+
1097
+ The fallbacks are not run unconditionally -- they roughly double encode
1098
+ time, since the entropy stage dominates. They are run only when none of
1099
+ the three modelling tricks fired, which is precisely the case where this
1100
+ codec has degenerated into "split into columns, then xz" and a different
1101
+ finisher may well beat it. When any trick fired, the modelled output wins
1102
+ by a margin no general compressor closes, and the extra work is skipped.
1103
+ """
1104
+ # Numeric-with-exceptions, measured. Recovering a column that is numeric
1105
+ # apart from a few cells is worth a great deal where it applies -- 41% of
1106
+ # the Treasury yield curve -- but it also moves a column out of the
1107
+ # dictionary path, and that is precisely the trade that made the reverted
1108
+ # ragged-decimal experiment 9-23% worse. So the old behaviour is encoded
1109
+ # too and kept if it is smaller: never-worse per file, not on average.
1110
+ #
1111
+ # The guard costs a whole second encode, so it is nominated first. The
1112
+ # plan is built once and reused, and the second encode only happens when
1113
+ # some column's numbers actually look cheaper than leaving it alone. That
1114
+ # screen is one-sided -- it over-estimates the alternative -- so it can
1115
+ # only decline cases that could not have won. Without it, four large
1116
+ # datasets encoded twice and kept the first result every time.
1117
+ plan = classify(table)
1118
+ any_lax = any(c.get("ex") for c in plan)
1119
+ promising = any(c.get("exp") for c in plan)
1120
+
1121
+ lenient = True
1122
+ if not any_lax:
1123
+ # the two plans are identical, so there is nothing to choose between
1124
+ blob, fired, ngroups, _ = _encode_plan(table, plan=plan)
1125
+ elif not promising:
1126
+ del plan
1127
+ lenient = False
1128
+ blob, fired, ngroups, _ = _encode_plan(table, use_lenient=False)
1129
+ else:
1130
+ blob, fired, ngroups, _ = _encode_plan(table, plan=plan)
1131
+ del plan
1132
+ alt, alt_fired, alt_groups, _ = _encode_plan(table, use_lenient=False)
1133
+ if len(alt) < len(blob):
1134
+ blob, fired, ngroups, lenient = alt, alt_fired, alt_groups, False
1135
+
1136
+ # The planar predictor was the last decision in this codec taken on trust,
1137
+ # and measuring it showed the trust was misplaced: of the three tables in
1138
+ # the corpus where a group forms at all, two came out LARGER for it --
1139
+ # nyc_collisions by 2,377 B and wide_random by 15,733 B -- against one real
1140
+ # win of 5.98% on random_floats. Grouped columns lose their own measured
1141
+ # differencing order and are forced into one fixed scheme, so a group
1142
+ # trades several measured decisions for a single unmeasured one, which is
1143
+ # exactly the asymmetry commit 20dd96e removed from the parent search.
1144
+ #
1145
+ # The guard has to be END TO END, not per group. Two cheaper checks were
1146
+ # tried and both give the wrong answer: raw packed length shows -0.0% on
1147
+ # nyc_collisions where the real effect is +28.1%, and a per-group probe
1148
+ # says wide_random gains 1.4% where the file actually loses 0.69% -- the
1149
+ # group's bytes are concatenated with every other payload and compressed
1150
+ # together, so nothing short of the whole container can see the result.
1151
+ #
1152
+ # Encoding twice is affordable because groups are rare: they formed on 2 of
1153
+ # 18 tables here and 3 of 24 in the full sweep. Tables without a group pay
1154
+ # nothing at all.
1155
+ if ngroups:
1156
+ alt, alt_fired, _, _ = _encode_plan(table, use_2d=False,
1157
+ use_lenient=lenient)
1158
+ if len(alt) < len(blob):
1159
+ blob, fired = alt, alt_fired
1160
+
1161
+ # The fallbacks are ALWAYS considered. They used to be skipped whenever any
1162
+ # trick fired, on the theory that a table where one fired cannot lose to
1163
+ # plain xz. That theory was measured against 18 tables and held; measured
1164
+ # against 100 unselected ones it fails on 3, once by 28.1% -- the shipped
1165
+ # archive was 2,830,752 bytes where the xz fallback nobody ran was
1166
+ # 2,210,086. Invariant 2 says "never worse, MEASURED, not assumed", and a
1167
+ # gate that decides which encodings are even built is exactly an assumption.
1168
+ #
1169
+ # It is affordable because `_raw_candidates` stops compressing the moment
1170
+ # the candidate cannot win; see there.
1171
+ alt = _raw_candidates(table, len(blob))
1172
+ return alt if alt is not None else blob
1173
+
1174
+
1175
+ def _encode_plan(table, use_2d: bool = True, use_lenient: bool = True,
1176
+ plan=None) -> Tuple[bytes, int, int, int]:
1177
+ # `plan` lets the caller hand in a classification it has already paid for.
1178
+ # Nothing here writes to it, so it is safe to share between encodes that
1179
+ # agree about `use_lenient`.
1180
+ if plan is None:
1181
+ plan = classify(table, lenient=use_lenient)
1182
+ nrows = len(table.rows)
1183
+ groups = find_2d_groups(plan, nrows) if use_2d else []
1184
+ in_group = {pos: gi for gi, g in enumerate(groups) for pos in g}
1185
+ parent, order = pick_parents(plan, nrows)
1186
+
1187
+ bins: List[bytes] = [] # binary payloads
1188
+ sgroups: List[List[str]] = [] # string payloads, length-prefixed
1189
+ specs: List[Optional[dict]] = [None] * len(plan)
1190
+
1191
+ for pos in order:
1192
+ col = plan[pos]
1193
+ ids = col["ids"]
1194
+ par = parent.get(pos)
1195
+ if par is not None:
1196
+ perm = np.argsort(plan[par]["ids"], kind="stable")
1197
+ ids = ids[perm]
1198
+ w = _width(len(col["alpha"]))
1199
+ bins.append(ids.astype(w).tobytes())
1200
+ sgroups.append(col["alpha"])
1201
+ specs[pos] = {"kind": "dict", "n": len(col["alpha"]), "w": w,
1202
+ "parent": par}
1203
+
1204
+ tparent = pick_text_parents(plan, nrows, parent, order)
1205
+
1206
+ for pos, col in enumerate(plan):
1207
+ if col["kind"] == "text":
1208
+ tp = tparent.get(pos)
1209
+ cells = col["cells"]
1210
+ if tp is not None:
1211
+ # exactly the dictionary-column trick, and free for exactly
1212
+ # the same reason: the decoder has already rebuilt the parent
1213
+ # and recomputes the same stable argsort
1214
+ perm = np.argsort(plan[tp]["ids"], kind="stable")
1215
+ cells = [cells[i] for i in perm]
1216
+ sgroups.append(cells)
1217
+ specs[pos] = {"kind": "text"} if tp is None else \
1218
+ {"kind": "text", "parent": tp}
1219
+ elif col["kind"] == "num" and pos in in_group:
1220
+ specs[pos] = {"kind": "grp", "dec": col["dec"], "g": in_group[pos]}
1221
+ _emit_exceptions(col, specs[pos], bins, sgroups)
1222
+ elif col["kind"] == "num":
1223
+ a = col["ints"]
1224
+ k = diff_order(a)
1225
+ bins.append(pack_ints(np.diff(a, n=k) if k else a))
1226
+ specs[pos] = {"kind": "num", "dec": col["dec"], "k": k,
1227
+ "warm": a[:k].tolist()}
1228
+ _emit_exceptions(col, specs[pos], bins, sgroups)
1229
+
1230
+ for g in groups:
1231
+ M = np.stack([plan[pos]["ints"] for pos in g], axis=1) # rows x cols
1232
+ D = np.diff(np.diff(M, axis=0), axis=1)
1233
+ side = np.concatenate([M[0], np.diff(M, axis=0)[:, 0]])
1234
+ bins.append(pack_ints(np.concatenate([side, D.ravel()])))
1235
+
1236
+ # Front-coding the dictionary alphabets, measured. The alphabets are the
1237
+ # first len(order) string groups by construction, and they are sorted, so
1238
+ # neighbours share long prefixes. Worth -1.19% overall and -7.39% on
1239
+ # seattle_fire911 -- but it LOSES on four of thirteen datasets, so the
1240
+ # blob is built both ways and the smaller kept.
1241
+ #
1242
+ # This guard is cheap in a way the others are not: it re-compresses only
1243
+ # the text pile, not the whole container. Compare the exceptions guard,
1244
+ # which costs a second full encode.
1245
+ front, (txt_data, smeta, length_arrays), txt_b = \
1246
+ _choose_front(sgroups, len(order))
1247
+ # "front == exactly the alphabets" is the common case, and the older
1248
+ # archive-level flag spells it in 7 bytes instead of 7 per group. Same
1249
+ # information, smaller metadata, same payload; the decoder reads both.
1250
+ compact = bool(order) and front == frozenset(range(len(order)))
1251
+ if compact:
1252
+ for m in smeta:
1253
+ m.pop("fc", None)
1254
+
1255
+ n_before = len(bins)
1256
+ bins.extend(pack_ints(a) for a in length_arrays) # always last
1257
+ meta = {"columns": table.columns, "nrows": nrows, "cols": specs,
1258
+ "groups": groups, "order": order,
1259
+ "bins": [len(b) for b in bins],
1260
+ "smeta": smeta, "nlenbins": len(bins) - n_before}
1261
+ if compact:
1262
+ meta["fc"] = 1
1263
+ meta_b = lzma.compress(json.dumps(meta, separators=(",", ":")).encode(),
1264
+ **XZ)
1265
+ bin_b = lzma.compress(b"".join(bins), **XZ)
1266
+ blob = (MAGIC + len(meta_b).to_bytes(4, "big")
1267
+ + len(bin_b).to_bytes(4, "big") + len(txt_b).to_bytes(4, "big")
1268
+ + meta_b + bin_b + txt_b)
1269
+
1270
+ # Did any of the three ideas actually do something? A parent-sorted
1271
+ # column, a column whose differences packed smaller than its values, or a
1272
+ # 2D group. If none did, this run was just "columns, then xz".
1273
+ fired = (sum(1 for s in specs if s and s["kind"] == "dict"
1274
+ and s["parent"] is not None)
1275
+ + sum(1 for s in specs if s and s["kind"] == "num" and s["k"])
1276
+ + len(groups))
1277
+ nlax = sum(1 for c in plan if c.get("ex"))
1278
+ return blob, fired, len(groups), nlax
1279
+
1280
+
1281
+ def decode(blob: bytes):
1282
+ if blob[:4] == MAGIC_RAW_XZ:
1283
+ return _from_canonical(lzma.decompress(blob[4:], **XZ))
1284
+ if blob[:4] == MAGIC_RAW_BZ:
1285
+ return _from_canonical(bz2.decompress(blob[4:]))
1286
+ if blob[:4] not in (MAGIC, MAGIC_V0):
1287
+ raise ValueError("not a Polypress archive")
1288
+ ml = int.from_bytes(blob[4:8], "big")
1289
+ bl = int.from_bytes(blob[8:12], "big")
1290
+ o = 16
1291
+ meta = json.loads(lzma.decompress(blob[o:o + ml], **XZ))
1292
+ o += ml
1293
+ raw = lzma.decompress(blob[o:o + bl], **XZ)
1294
+ o += bl
1295
+ txt_data = lzma.decompress(blob[o:], **XZ)
1296
+
1297
+ nrows, specs = meta["nrows"], meta["cols"]
1298
+ cuts, at = [], 0
1299
+ for size in meta["bins"]:
1300
+ cuts.append(raw[at:at + size])
1301
+ at += size
1302
+
1303
+ nlen = meta["nlenbins"]
1304
+ length_bins = cuts[len(cuts) - nlen:] if nlen else []
1305
+ # Which groups are front-coded is recorded per group in smeta. The
1306
+ # archive-level "fc" is the older spelling, kept readable.
1307
+ legacy = len(meta["order"]) if meta.get("fc") else 0
1308
+ texts = _unpack_strings(txt_data, meta["smeta"], length_bins, legacy)
1309
+
1310
+ cols: List[Optional[List[str]]] = [None] * len(specs)
1311
+ ids_by_pos: Dict[int, np.ndarray] = {}
1312
+ bi = ti = 0
1313
+
1314
+ for pos in meta["order"]:
1315
+ sp = specs[pos]
1316
+ alpha = texts[ti]
1317
+ ti += 1
1318
+ ids = np.frombuffer(cuts[bi], dtype=sp["w"]).astype(np.int64)
1319
+ bi += 1
1320
+ par = sp["parent"]
1321
+ if par is not None:
1322
+ perm = np.argsort(ids_by_pos[par], kind="stable")
1323
+ out = np.empty_like(ids)
1324
+ out[perm] = ids
1325
+ ids = out
1326
+ ids_by_pos[pos] = ids
1327
+ cols[pos] = (np.array(alpha, dtype=object)[ids].tolist()
1328
+ if alpha else [])
1329
+
1330
+ # Exceptions belonging to grouped columns are read here, in the same walk
1331
+ # the encoder wrote them, but cannot be applied until the group has been
1332
+ # reconstructed further down.
1333
+ grp_ex: Dict[int, Tuple[np.ndarray, List[str]]] = {}
1334
+
1335
+ for pos, sp in enumerate(specs):
1336
+ if sp["kind"] == "text":
1337
+ cells = list(texts[ti])
1338
+ ti += 1
1339
+ tp = sp.get("parent")
1340
+ if tp is not None:
1341
+ perm = np.argsort(ids_by_pos[tp], kind="stable")
1342
+ restored = [None] * len(cells)
1343
+ for k, src in enumerate(perm):
1344
+ restored[src] = cells[k]
1345
+ cells = restored
1346
+ cols[pos] = cells
1347
+ elif sp["kind"] == "grp":
1348
+ nex = sp.get("nex", 0)
1349
+ if nex:
1350
+ grp_ex[pos] = (np.cumsum(unpack_ints(cuts[bi], nex)),
1351
+ texts[ti])
1352
+ bi += 1
1353
+ ti += 1
1354
+ elif sp["kind"] == "num":
1355
+ k = sp["k"]
1356
+ d = unpack_ints(cuts[bi], nrows - k)
1357
+ bi += 1
1358
+ a = _undiff(d, np.array(sp["warm"], dtype=np.int64), k) if k else d
1359
+ cells = ints_to_cells(a, sp["dec"])
1360
+ cells, bi, ti = _apply_exceptions(cells, sp, cuts, texts, bi, ti)
1361
+ cols[pos] = cells
1362
+
1363
+ for gi, g in enumerate(meta["groups"]):
1364
+ w = len(g)
1365
+ n_side = w + (nrows - 1)
1366
+ flat = unpack_ints(cuts[bi], n_side + (nrows - 1) * (w - 1))
1367
+ bi += 1
1368
+ row0 = flat[:w]
1369
+ col0 = flat[w:n_side]
1370
+ D = flat[n_side:].reshape(nrows - 1, w - 1)
1371
+ D1 = np.concatenate([col0[:, None], D], axis=1).cumsum(axis=1)
1372
+ M = np.concatenate([row0[None, :], D1], axis=0).cumsum(axis=0)
1373
+ for c, pos in enumerate(g):
1374
+ cells = ints_to_cells(M[:, c], specs[pos]["dec"])
1375
+ ex = grp_ex.get(pos)
1376
+ if ex is not None:
1377
+ expos, exvals = ex
1378
+ for i, p in enumerate(expos.tolist()):
1379
+ cells[p] = exvals[i]
1380
+ cols[pos] = cells
1381
+
1382
+ # zip transposes at C speed; the nested comprehension did not
1383
+ rows = [list(r) for r in zip(*cols)]
1384
+ return dtz.Table(list(meta["columns"]), rows)
1385
+
1386
+
1387
+ def _undiff(d: np.ndarray, warm: np.ndarray, k: int) -> np.ndarray:
1388
+ """Invert k-fold differencing.
1389
+
1390
+ `warm` holds the first k ORIGINAL values, not the differences, so each
1391
+ level's leading term has to be re-derived: the first element of the j-th
1392
+ difference is np.diff(warm, n=j)[0]."""
1393
+ a = d
1394
+ for j in range(k - 1, -1, -1):
1395
+ first = warm[0] if j == 0 else np.diff(warm, n=j)[0]
1396
+ a = np.concatenate([[first], a]).cumsum()
1397
+ return a