Skip to content

fix(writer): thirteen encoders never reported zone-map MIN/MAX stats - #387

Merged
dfa1 merged 17 commits into
mainfrom
fix/zone-map-stats-constant-runend-zigzag
Sep 13, 2026
Merged

dfa1 merged 17 commits into
mainfrom
fix/zone-map-stats-constant-runend-zigzag

Conversation

@dfa1

@dfa1 dfa1 commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Summary

Same failure mode as #382 (AlpRdEncodingEncoder, already merged): thirteen more encoders hardcoded null, null for zone-map stats regardless of input, silently defeating RowFilter pruning for any column they encoded. Found via a systematic audit of every writer/src/main/java/io/github/dfa1/vortex/writer/encode/ encoder after fixing #382.

Fixed, one commit each:

  • ConstantEncodingEncoder never reports MIN/MAX zone-map stats #384 ConstantEncodingEncoder — min == max == the one repeated value, free to report.
  • RunEndEncodingEncoder never reports MIN/MAX zone-map stats #385 RunEndEncodingEncoder — tracks min/max across every run's value (unsigned-aware for U8/U16/U32/U64).
  • ZigZagEncodingEncoder never reports MIN/MAX zone-map stats #386 ZigZagEncodingEncoder — tracks min/max over the original signed values (zigzag's bit-interleaving isn't order-preserving).
  • SequenceEncodingEncoder — an arithmetic sequence's extremes are always its first/last element, free to report.
  • PcoEncodingEncoder, RleEncodingEncoder, SparseEncodingEncoder, PatchedEncodingEncoder — computed via PrimitiveEncodingEncoder#minMaxStats over the original typed array, independent of each encoder's own internal transform.
  • ZstdEncodingEncoder — all four paths (primitive/varbin x nullable/non-nullable); nullable primitive compacts to valid-only elements first (matching fix(writer,reader): stop zone-map pruning from silently disabling itself #381's MaskedEncodingEncoder fix).
  • FsstEncodingEncoder, VarBinViewEncodingEncoder — lexicographic string min/max via VarBinEncodingEncoder#minMaxStats; Binary stays unmapped.
  • ExtEncodingEncoder#encodeCascade — its non-cascade encode() was already correct; the cascade path lost stats because CascadingCompressor#spliceResult takes a step's stats verbatim and never derives them from a resolved open child.
  • DateTimePartsEncodingEncoder — computed over the original pre-split i64 timestamp, not any of the three split parts.

Also:

  • Added EncodeResult.of(root, buffers, stats) / CascadeStep.open(root, buffers, children, stats) factories taking a {min, max}-or-null pair directly, replacing the repeated stats != null ? stats[0] : null, stats != null ? stats[1] : null at every call site.
  • Added ZoneMapStatsCoverageTest: a fitness function asserting every default-registered encoder that accepts a Primitive/Extension/Utf8 dtype has a coverage case reporting real stats. A future encoder missing this now fails a test instead of shipping invisibly -- the whole existing test suite (round-trip property tests, Rust-interop tests) only ever checks decoded values, never stats presence, since broken pruning changes nothing about what a scan returns, only how much it reads.

Test plan

  • ./mvnw verify green across the whole reactor (twice, before and after the follow-up ten encoders)
  • New unit tests per encoder (Stats nested class in each *EncodingEncoderTest) plus the new ZoneMapStatsCoverageTest
  • Manually verified end-to-end against a real 200M-row/2.4GB file (vortex-demo): a price filter entirely outside the column's domain went from fetching ~54% of the file to ~0.3%

🤖 Generated with Claude Code

https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi

dfa1 added 17 commits September 12, 2026 21:24
encode() built its EncodeResult via EncodeResult.simple(...), which
defaults stats to null regardless of the constant value being encoded
-- for a constant array min == max == that value by construction, no
scan needed, so any column the cascade collapsed to vortex.constant
lost zone-map pruning for free.

Fixes #384.
encode() hardcoded (null, null) regardless of the run values actually
encoded. RunEnd is specifically favored for clustered, low-cardinality
data -- exactly the shape where zone-map pruning otherwise pays off
most -- so this was losing the biggest win for its best-fit workload.

Tracks min/max across every run's value in the loop that already
builds them, with unsigned comparison for U8/U16/U32/U64 (a raw bit
pattern that looks negative signed can be a huge unsigned magnitude).

Fixes #385.
encode() hardcoded (null, null) regardless of the signed input. Stats
must come from the original values, not the zigzag-transformed
output -- the bit-interleaving is not order-preserving (e.g. -1 maps
to 1, 1 maps to 2), so tracking min/max over the transformed output
would have been wrong even if present.

Tracks min/max over the original values in the same per-PType switch
that already computes the zigzag mapping.

Fixes #386.
Same bug, three encoders -- one line naming all three plus their
issue numbers reads better than three near-duplicate paragraphs.
EncodeResult.of(root, buffers, stats) and CascadeStep.open(...,
stats) take a {min, max}-or-null pair directly (the shape
PrimitiveEncodingEncoder#minMaxStats / VarBinEncodingEncoder#minMaxStats
already return) instead of making every call site repeat
"stats != null ? stats[0] : null, stats != null ? stats[1] : null".
Every encode path hardcoded (null, null). An arithmetic sequence is
monotonic (or constant) end to end, so its extremes are always the
first and last element -- free to report from the base/multiplier
already computed to validate the sequence, no extra scan needed.
encode() hardcoded (null, null) regardless of input. Computed via
PrimitiveEncodingEncoder#minMaxStats over the original typed array,
independent of Pco's internal latent/ANS transform.
The numeric encode() path hardcoded (null, null) regardless of input
(encodeBool and the empty path correctly stay null: Bool has no
zone-map min/max, matching ZoneMapStatCodec#zoneMinMaxDtype).
Computed via PrimitiveEncodingEncoder#minMaxStats over the original
typed array, independent of RLE's own value/index/offset transform.
Both the terminal encode() and the cascade-open encodeCascade() path
hardcoded (null, null) for the numeric fill=0 path regardless of
input (encodeBool correctly stays null: Bool has no zone-map
min/max). Computed via PrimitiveEncodingEncoder#minMaxStats over the
original dense array, which already includes both the fill positions
and the patches, so no fill+patch reconstruction is needed.
Both encode() and encodeCascade() hardcoded (null, null) regardless
of input. Computed via PrimitiveEncodingEncoder#minMaxStats over the
original typed array, independent of which values get patched out.
All four encode paths (primitive/varbin x nullable/non-nullable)
hardcoded (null, null) regardless of input. Primitive paths use
PrimitiveEncodingEncoder#minMaxStats (nullable case first compacted
to only valid elements via PrimitiveArrays#compact, matching
MaskedEncodingEncoder's #381 fix -- the dense values array carries
placeholder garbage at invalid positions). Utf8 paths use
VarBinEncodingEncoder#minMaxStats (which already skips nulls itself);
Binary stays unmapped, matching VarBin's own "not usefully
zone-mapped" convention for binary blobs.
Both encode() and encodeCascade() hardcoded (null, null) regardless
of input. Utf8 columns now report lexicographic min/max via
VarBinEncodingEncoder#minMaxStats over the original String[], before
symbol-table compression; Binary stays unmapped (blobs aren't
usefully zone-mapped).
…tats

encode() hardcoded (null, null) regardless of input. Utf8 columns now
report lexicographic min/max via VarBinEncodingEncoder#minMaxStats;
Binary stays unmapped, matching VarBin's own convention.
…N/MAX stats

encode() already propagated the storage encoder's own stats correctly;
encodeCascade() hardcoded (null, null) instead, because
CascadingCompressor#spliceResult takes a step's stats verbatim and
never derives them from a resolved open child -- an Extension column
the cascade opens (rather than encode()'s direct path) silently lost
its stats. Computed independently over the original data via
PrimitiveEncodingEncoder#minMaxStats when the storage dtype is
Primitive, matching ZoneMapStatCodec#zoneMinMaxDtype's own rule that
an Extension's zone-map min/max is its storage primitive's, unwrapped.
…X stats

Both encode() and encodeCascade() hardcoded (null, null) regardless
of input. Computed via PrimitiveEncodingEncoder#minMaxStats over the
original i64 timestamp array, before it's split into days/seconds/
subseconds -- signed i64 order matches chronological order regardless
of the split, so no part-level reconstruction is needed.
Ten of these bugs shipped invisibly because the whole test suite
(round-trip property tests, Rust-interop tests) only ever checks
decoded VALUES, never stats presence -- broken pruning changes
nothing about what a scan returns, only how much it reads, so no
existing test could catch it.

ZoneMapStatsCoverageTest closes the gap two ways: a registry-driven
check that every default-registered encoder accepting a
Primitive/Extension/Utf8 dtype has a coverage case (add a new
stats-eligible encoder without a case here and it fails), plus the
actual per-encoder assertion that encode() reports stats for
representative non-empty input.
Same entry style as the #384/#385/#386 line -- name the encoders and
the PR, skip re-explaining a bug already described three times above.
@dfa1 dfa1 changed the title fix(writer): three more encoders never reported zone-map MIN/MAX stats fix(writer): thirteen encoders never reported zone-map MIN/MAX stats Sep 13, 2026
@dfa1
dfa1 merged commit c1eaf24 into main Sep 13, 2026
6 checks passed
@dfa1
dfa1 deleted the fix/zone-map-stats-constant-runend-zigzag branch September 13, 2026 06:15
dfa1 added a commit that referenced this pull request Sep 13, 2026
…0025)

Thirteen encoders across #382/#384-#387 independently forgot to
compute zone-map stats -- not one bad encoder, an architecture with
no structural guarantee, closed only by a coverage test that itself
has to be remembered for every future encoder.

VortexWriter#writeSegment (the one choke point every written segment
already passes through) now falls back to a new
ZoneMapStatCodec#columnMinMax whenever the winning encoder's own
EncodeResult/CascadeStep didn't already supply stats, computed
directly from the segment's untouched (dtype, data) -- mirroring
columnSum, which already worked this way and never had this bug.
Encoders may still report a cheaper override (kept for
Constant/Sequence/RunEnd/ZigZag); none are required to anymore.

Confirmed against the Rust reference (spiraldb/vortex): it has no
per-encoding stats special-casing either -- StatsSet::compute_stat
runs one generic min_max reduction over every array's canonical form.
The new ComparableValues marker interface (implemented by
DateTimePartsData) is the write-side mirror of exactly what Rust's
own canonical.rs::decode_to_temporal does for the same encoding: no
special-casing in the generic path, just a carrier that knows its own
comparable form.

ZoneMapStatsCoverageTest (asserting every encoder self-reports) is
replaced by ZoneMapStatCodecTest (asserting columnMinMax handles
every dtype shape) -- the guarantee moved from a per-encoder
enumeration to the actual new choke point.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant