Null validity and real-world file compatibility are the two themes of this release. The
Raincloud conformance suite — 247 public datasets written
by the Python vortex-data bindings — exposed a systematic family of silent-corruption bugs where
nullable encoding children's validity masks were dropped during decode, causing null rows to appear
as invented values (0.0, the FoR base, empty string). All five affected encodings are patched:
fastlanes.bitpacked validity-child chain, vortex.dict (eager + layout), vortex.runend,
vortex.sparse (primitive + utf8/binary), vortex.datetimeparts, and fastlanes.alprd.
Real-world files also uncovered scan failures: mixed per-column chunk grids, nested struct layout,
FSST-compressed string-dict offsets, RLE double/float pools, narrow-integer dict pools, all-null
columns, and zone-map stats from the current Rust writer format (vortex.zoned). Unsigned integer
silent-corruption in CSV export and filter predicates is also fixed. The conformance suite itself
ships as a gate-running weekly workflow plus four per-PR JNI interop fixtures.
Fixed
- Per-zone stats from current Rust writers (
vortex.zoned, vortex-jni 0.76.0) decode again. The 0.76 zoned layout replaced the legacyvortex.statsbit-set metadata with an aggregate-function spec list and dropped the per-stat truncation flags; the reader now reconstructs the stats table from that spec list (min/max/sum/null_count), socolumnZoneStatsand aggregate push-down work against those files instead of throwingClassCastException. (#197) - Scans of files whose columns use different chunk grids no longer fail with
mixed per-column chunking beyond 1-vs-N is not supported. The scan planner now splits at the merged boundary grid — the sorted union of every column's chunk boundaries, matching the Rust reference — and decodes each column's covering chunk once, slicing it zero-copy to each window. This handles both nested grids (Raincloudemotions-dataset-for-nlp, wherelabel's coarse chunks nest insidetext's finer grid) and disjoint grids (uci-beijing-multi-site-air-quality, where numeric andstationboundaries do not nest). Aligned N-vs-N and 1-vs-N scans keep their existing slice-free fast path. (#221) - Hardened the
vortex.zonedmetadata decoder against malformed input: an attacker-controlled length varint could overflow itspos + lenbounds checks and crash withNegativeArraySizeException/IndexOutOfBoundsException; the checks are now overflow-safe and unparseable metadata falls back to per-chunk stats. Decimal columns — whose Rust zone table keeps asumfield this reader cannot map — now also fall back instead of decoding a misaligned table. (#197) - CSV export renders nested struct columns as JSON object cells (
{"field":value,...}, strings JSON-escaped, null fields as JSONnull, nested structs recursed) instead of throwingunsupported array type: StructArray. (#217) - Scanning a struct whose columns include a nested struct no longer fails chunk planning. A nested
vortex.structlayout column (e.g. Raincloudcountries-of-the-world'sdatacolumn) is now treated as a single full-range chunk source and decoded through the layout registry's new struct decoder into aStructArray, matching the Rust reference (a nested struct spans its parent's row range, not an independent chunking).schema/count/inspectalready worked; plain scans andselectof sibling columns now work too. CSV export of the struct column itself remains unsupported (a separate rendering limitation). (#207) - CSV export renders unsigned integer columns (U8–U64) with their unsigned values — high-half values previously printed as two's-complement negatives (uci-wine
magnesiumU8 132 exported as -124), silent corruption found by the Raincloud conformance suite. (#208) - Unsigned integer columns are handled unsigned in the remaining consumers: the CLI
filterpredicates (magnesium >= 130now matches its high-half U8 rows instead of silently dropping them — the worst class of the bug: wrong query results), the TUI grid and inspector views, and the Calcite adapter (U8/U16/U32 map to the next wider signed SQL type so their full range fits; U64 staysBIGINTand fails loud rather than surfacing a negative for values ≥ 2^63, both when read and when summed). (#216) fastlanes.rledecodes F64/F32 value pools (LazyRleDoubleArray,LazyRleFloatArray) — files from the Python bindings RLE-encode double columns with long constant runs, which previously failed to scan withunsupported ptype F64. (#209)- Lazy dict decode now covers I8/U8/I16/U16 value columns (
DictByteArray,DictShortArray) — files from the Python bindings dict-encode narrow-integer columns, which previously failed to scan withunsupported ptype for lazy dict. (#206) - CSV export handles all-null (
DType.Null) columns as empty fields instead of throwingunsupported array type: NullArray. (#211) - String-dict columns whose values are FSST-compressed no longer fail to scan with
IndexOutOfBoundsException— the dictionary value offsets are now read at their true ptype width instead of a hardcoded 8-byte stride (uci-magic-gamma-telescope'sclasscolumn decompressed to 4-byte offsets). (#215) - Null rows no longer silently decode as values: wrapper decoders now propagate the row validity that files from the Python bindings carry deep in the encoding tree. Three representations were dropped — a trailing validity child on
fastlanes.bitpacked(reached throughvortex.alp/vortex.zigzag/fastlanes.for, which delegate validity to their encoded child per the RustValidityChildcontract), dict pools with invalid slots, and dict codes with their own validity — across both the eagervortex.dictdecoder and the lazy dict layout path. Found on real data: penguins and kepler exported invented values (32.1,0.0) for thousands of null cells. (#210) vortex.runendpropagates nullable run-values' validity: a null run now nulls every row it covers instead of expanding to a filler value (uci-online-retailcustomeridu16? nulls previously decoded as the FoR base). Row validity is a lazy run-end bool over the same run-ends, matching the RustValidityVTable<RunEnd>. (#225)vortex.sparsepropagates nullability: afill_value: nullarray nulls every unpatched position (world-energy-consumptionbiofuel_cons_change_pctf64? previously decoded them as 0.0), and a null patch value nulls its own position. Row validity is a lazy sparse bool whose fill isfill_value.is_valid()and whose patch bits are the patch values' validity, matching the RustValidityVTable<Sparse>. (#226)vortex.sparseover utf8/binary values now carries the same row validity as the primitive path: afill_value: nullstring column nulls every unpatched position instead of rendering an empty string, and a nullable patch value nulls its own position instead of surfacing its raw bytes. The RustValidityVTable<Sparse>is generic over the values encoding, so VarBin reuses the identical sparse-bool validity — completing the #226 fix for string/binary columns. (#232)vortex.datetimepartspropagates null component rows instead of throwingDateTimeParts: null cell at index: when any part (days/seconds/subseconds) decodes to a null — as a nullablevortex.runendchild now does after #225 — the reassembled timestamp row is null, unblocking scans of bi-yalelanguages and bi-euro2016. (#235)fastlanes.alprdnow propagates left_parts validity: null float rows no longer decode as0.0. (#234)
Added
- Integration test for null-fill Sparse and null-run RunEnd round-trip, running on every gate. (#233)
- Real-world conformance suite against the Raincloud corpus: 247 public datasets whose Vortex files are written by the Python bindings, each validated value-for-value against its Parquet sibling.
scripts/hydrate-raincloud-corpus.shhydrates any subset (cache → mirror → local build),RaincloudConformanceIntegrationTesttests whatever is hydrated against the checked-in per-dataset status matrix, and a weekly workflow runs a size-capped sweep. First triage found four reader gaps on real data, including one silent-corruption bug (unsigned integers rendered as signed). (#205)