@@ -326,6 +326,310 @@ At decode time the registry maps the ID string from the Layout node to the right
326326Custom encodings can be added at build time: ` Registry.builder().register(myEncoding).build() ` .
327327Files with unrecognised IDs throw ` VortexException ` unless the builder enabled ` allowUnknown() ` .
328328
329+ ## Why cascading compression
330+
331+ Vortex stores each column as a tree of encodings. The leaves are raw memory
332+ segments, the inner nodes describe how those bytes turn back into values.
333+ Without cascading, the writer picks one encoding from a static list and stops.
334+ With cascading, the writer samples the data, lets candidate encodings expose
335+ their open child slots, and recursively picks the best inner encoding for
336+ every child. That recursion is what turns a per-encoding sales-pitch into
337+ real compression.
338+
339+ Six representative columns, both paths. Reproduce with:
340+
341+ ```
342+ ./mvnw verify -DskipTests
343+ java --enable-native-access=ALL-UNNAMED -cp "..." \
344+ io.github.dfa1.vortex.performance.CompressionShowcase
345+ ```
346+
347+ Numbers below: 1 000 000 rows per column, JDK 25, vortex-java HEAD.
348+
349+ ### The encodings in play, and what they're good for
350+
351+ Before reading the table, you need to know what the writer's pick means. The
352+ labels in the size table aren't marketing terms — each one is a concrete
353+ encoding with a sweet-spot data shape.
354+
355+ | Encoding | What it does | "Friendly" data shape |
356+ | ------------------| --------------------------------------------------------------------------------------------------------| ----------------------------------------------------------------------|
357+ | ** Primitive** | Raw little-endian bytes. The baseline. | Truly random data — no overhead, no compression. |
358+ | ** FoR** | Frame-of-reference. Subtract ` min(col) ` from every value; store the minimum + the residuals. | Bounded range: timestamps, monotonic IDs, anything where ` max - min ` is small. |
359+ | ** Bitpacked** | Packs N values into the smallest bit-width that fits the maximum value. | Small-range integers (e.g. FoR residuals, dict codes, small counts). |
360+ | ** Dict** | Build a values table; store one code per row pointing into it. | Low-cardinality strings or numbers. ** Loses** above ~ 50 % distinct. |
361+ | ** ALP** | Adaptive Lossless float compression. Detects a per-column scale + exponent so that values become small integers, then stores the mantissa. | "Physical world" doubles — prices, sensor readings, percentages. ** Loses** on truly random F64. |
362+ | ** RLE / RunEnd** | Replace each run of identical values with ` (run_end_index, value) ` . | Long runs of repeated values — status flags, partition IDs, slow-tick counters. |
363+ | ** VarBin** | Concatenate all bytes, store offsets per row. Arrow's classic string layout. | Strings of any cardinality — the safe default for Utf8. |
364+ | ** FSST** | Builds a per-column symbol table of the most common byte bigrams, then rewrites the strings as 1-byte codes (or escape + literal). | Short, repetitive-ish strings — log lines, identifiers, JSON keys. Truly random strings beat it. |
365+ | ** Constant** | Store the scalar once. Done. | All values identical. |
366+
367+ So "ALP-friendly doubles" = doubles that can be written as ` mantissa × 10^-exp `
368+ with a small mantissa. "FoR-friendly" = bounded range. "RLE-friendly" = long
369+ runs. The cascading compressor does the matchmaking; this table tells you
370+ when each match is a win.
371+
372+ ### Headline table
373+
374+ ```
375+ dataset raw bytes no-cascade cascade(3) ratio
376+ --------------------------------------------------------------------------------
377+ monotonic-timestamps 8 000 000 8 000 664 2 501 832 3.20x
378+ low-card-categorical 4 200 000 1 000 921 1 000 921 4.20x
379+ random-doubles 8 000 000 12 751 764 8 000 672 1.00x
380+ alp-friendly-doubles 8 000 000 8 000 748 1 256 208 6.37x
381+ rle-int 4 000 000 4 000 656 1 604 2 493.77x
382+ highcard-strings 6 000 000 17 977 750 10 551 040 0.57x
383+ ```
384+
385+ ratio = raw / cascade(3).
386+
387+ Three patterns to notice before we dig in:
388+
389+ - ** Cascading is usually a strict improvement** — never bigger, often dramatically
390+ smaller (RLE: 4 MB → 1.6 KB; ALP-friendly F64: 8 MB → 1.3 MB).
391+ - ** Without cascading, the writer can make the file * bigger* than raw.** Static
392+ fallbacks pick the first encoding that "accepts" the dtype, even when it
393+ fails to compress (random F64 → ALP adds overhead; high-cardinality strings
394+ → Dict blows up the values vector).
395+ - ** The cascade isn't magic.** Truly random data ends up close to raw size
396+ because there's no structure to exploit.
397+
398+ ### 1. Monotonic timestamps — FoR + Bitpacked
399+
400+ Sensor / log streams produce strictly increasing UNIX timestamps. Each value
401+ is only ~ 1 second above the previous one, so the * delta* needs ~ 1 bit, but
402+ the * absolute* value is 64 bits.
403+
404+ ```
405+ dtype: I64
406+ sample data: [1767225600, 1767225601, 1767225602, 1767225603, …]
407+ ```
408+
409+ ** Without cascading** — ` Primitive ` (raw 8 bytes/row) → 8 MB.
410+
411+ ```
412+ Primitive(I64)
413+ │
414+ 8 000 000 bytes
415+ ```
416+
417+ ** With cascading depth 3** — sample shows all-positive small deltas. FoR
418+ subtracts the minimum so residuals fit in a tiny number of bits; the open
419+ residual child cascades into Bitpacked, which packs ` ~20 ` bits per row
420+ instead of 64.
421+
422+ ```
423+ FoR
424+ ref = 1767225600
425+ │
426+ Bitpacked
427+ bit_width = 20
428+ │
429+ ~2.5 MB packed
430+ ```
431+
432+ 3.2× smaller than raw. The same shape covers row IDs, monotonic counters,
433+ millisecond timestamps, anything with bounded local deltas.
434+
435+ ### 2. Low-cardinality categorical — Dict
436+
437+ A column of ticker symbols repeats the same 5 distinct strings across a
438+ million rows.
439+
440+ ```
441+ dtype: Utf8
442+ sample data: ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "AAPL", "MSFT", …]
443+ ```
444+
445+ ** Without cascading** — the default codec list picks Dict before VarBin, so
446+ even the no-cascade path catches this one.
447+
448+ ```
449+ Dict
450+ / \
451+ Values Codes
452+ (5 strings) (1 M × U8)
453+ ~20 B ~1 MB
454+ ```
455+
456+ ** With cascading depth 3** — same shape. Dict already wins; codes are tight
457+ (1 byte each because cardinality ≤ 256). 4.2× smaller than raw with no
458+ recursive work needed.
459+
460+ The new cardinality gate (added in 0.6) only kicks in * above* 50 % distinct;
461+ this column is at 0.0005 % distinct, far below the gate.
462+
463+ ### 3. Random doubles — Primitive (no compression possible)
464+
465+ The worst case. Truly random F64 values have no exploitable structure.
466+
467+ ```
468+ dtype: F64
469+ sample data: [0.733, 0.642, 0.218, 0.875, 0.157, 0.488, 0.999, …]
470+ ```
471+
472+ ** Without cascading** — the static list tries ALP first. ALP detects no
473+ common scale factor, falls back to its uncompressed path, and the resulting
474+ file is * bigger* than raw (12.7 MB vs 8 MB) thanks to per-row mantissa /
475+ exponent bookkeeping.
476+
477+ ```
478+ ALP (degenerate)
479+ / \ \
480+ Encoded Patch Patch
481+ (no-op) idx values
482+ 8 MB ~4.7 MB
483+ ```
484+
485+ ** With cascading depth 3** — the cost-based selector measures ALP, sees it's
486+ worse than primitive, and picks ` Primitive ` raw.
487+
488+ ```
489+ Primitive(F64)
490+ │
491+ 8 000 000 bytes
492+ ```
493+
494+ Lesson: cascading is the cheapest insurance against the writer making the
495+ file * larger* . Without it, "first match" can lose to raw bytes.
496+
497+ ### 4. Slowly-varying doubles — ALP + Bitpacked
498+
499+ Stock prices, sensor readings, and most "physical world" doubles drift
500+ slowly. They're representable as ` mantissa × 10^-exp ` with a small mantissa,
501+ which is exactly what ALP is for.
502+
503+ ```
504+ dtype: F64
505+ sample data: [100.05, 100.04, 100.06, 100.07, 100.05, 100.03, …]
506+ ```
507+
508+ ** Without cascading** — ALP picks the right shape, but its mantissa child
509+ gets emitted as raw ` Primitive(I64) ` → ~ 8 MB.
510+
511+ ```
512+ ALP
513+ e=2 f=1
514+ │
515+ Primitive(I64)
516+ │
517+ 8 000 000 bytes
518+ ```
519+
520+ ** With cascading depth 3** — the same ALP outer, but its mantissa child
521+ cascades into FoR + Bitpacked. The mantissa range fits in ~ 10 bits.
522+
523+ ```
524+ ALP
525+ e=2 f=1
526+ │
527+ FoR
528+ │
529+ Bitpacked
530+ bit_width = 10
531+ │
532+ ~1.25 MB
533+ ```
534+
535+ 6.4× smaller than raw. This is where Vortex really shines vs Parquet's
536+ fixed page-level codecs: nested arithmetic encodings stack cleanly.
537+
538+ ### 5. Run-encoded ints — RunEnd
539+
540+ Long runs of the same value: status flags, monotonic counters that tick
541+ slowly, partition IDs.
542+
543+ ```
544+ dtype: I32
545+ sample data: [1,1,1,…(10 000)…,1, 2,2,2,…(10 000)…,2, 3,3,…]
546+ ```
547+
548+ ** Without cascading** — ` Primitive ` → 4 MB.
549+
550+ ```
551+ Primitive(I32)
552+ │
553+ 4 000 000 bytes
554+ ```
555+
556+ ** With cascading depth 3** — the run structure is detected, RunEnd encodes
557+ each run as ` (end_index, value) ` and both children compress.
558+
559+ ```
560+ RunEnd
561+ / \
562+ Run ends Values
563+ (100 ints) (100 ints)
564+ ~400 B ~400 B
565+ ↓ ↓
566+ Bitpacked Bitpacked
567+ (or FoR) (or FoR)
568+ ```
569+
570+ ** 2 493×** smaller than raw — the run is so long that the actual payload
571+ collapses to ~ 1.6 KB.
572+
573+ ### 6. High-cardinality strings — Dict fail / FSST partial win
574+
575+ A million all-distinct random 6-character strings — the kind of column that
576+ turns into a tar pit for dictionary-style encodings.
577+
578+ ```
579+ dtype: Utf8
580+ sample data: ["wkzqof", "tdmgxh", "ablrpe", "yvcjsi", …]
581+ ```
582+
583+ ** Without cascading** — Dict is the first acceptor for Utf8 in the default
584+ codec list. It builds a dictionary nearly as big as the input plus a 4-byte
585+ code per row.
586+
587+ ```
588+ Dict
589+ / \
590+ Values Codes
591+ (1 M strings) (1 M × U32)
592+ ~10 MB ~4 MB
593+ = 18 MB (3× raw!)
594+ ```
595+
596+ ** With cascading depth 3** — the new (0.6) cardinality gate in ` DictEncoding `
597+ detects > 50 % distinct on the sample, returns ` notApplicable ` , and the
598+ cascade rotates to FSST.
599+
600+ ```
601+ FSST
602+ ╭────────┼────────╮
603+ Symbol Symbol Compressed
604+ table lengths payload
605+ (~2 KB) (~255 B) (~5 MB)
606+ │
607+ ↓ cascade
608+ uncompressed_lens codes_offsets
609+ (Constant: ~4 B) (FoR+Bitpacked)
610+ ~5 MB
611+ ```
612+
613+ Result: 10.5 MB — still larger than the 6 MB raw, but ** 42 % smaller than
614+ no-cascade** . Truly random short strings are hard for any encoder (Rust hits
615+ the same wall on this input). Java's FSST symbol-table builder is also less
616+ aggressive than Rust's for now — see ` TODO.md ` .
617+
618+ ### Takeaways
619+
620+ - ** Always use ` WriteOptions.cascading(...) ` unless you have a reason not
621+ to.** The default is ` cascading(0) ` for legacy compatibility; we'll
622+ probably flip the default in a future major.
623+ - ** Cascade depth 3** is the sweet spot in practice. Deeper costs more
624+ encode CPU for tiny diminishing returns; shallower misses key combos
625+ like ALP→FoR→Bitpacked.
626+ - ** The encoding tree is the API.** If you ` vortex inspect <file> ` you'll
627+ see the exact structure of each column, with sizes per node. No black
628+ box.
629+ - ** Codec choice is data-dependent.** No single encoding is "best". The
630+ point of cascading is to let the writer admit when it's wrong and try
631+ again.
632+
329633## Benchmarks
330634
331635JMH throughput (ops/s = full-file scans per second). Higher is better.
0 commit comments