Skip to content

Commit 26df9ab

Browse files
dfa1claude
andcommitted
test: deterministic coverage for validity propagation (#210 review)
The silent-corruption fix shipped with only the hydration-gated conformance suite covering it. These CI-runnable unit tests pin each validity-propagation seam directly through the decoder harness. - BitpackedEncodingDecoderTest (new): pins validityChildIndex — trailing bool validity at index 0 (no patches), 2 (patches, no chunk offsets), 3 (patches with chunk offsets); no-validity plain array; unexpected child count throws with the expected counts. bit_width=0 constant-residual shape isolates index selection from the packed unpack. - DictEncodingDecoderTest.RowValidity: decodeRustProto's three null representations — pool-null (koi_gmag), codes-null (koi_smet_err2), both (AND semantics), and an out-of-range code guarded as VortexException. - Alp/ZigZag MaskedChildPropagation: masked encoded child surfaces as a MaskedArray with nulls preserved and values decoded (ALP also over the LazyConstant broadcast arm); plain child stays unmasked. - DictLayoutDecoderTest (new): buildLazyDictPrimitive pool/codes gather driven through the public decode() seam with a stub LayoutDecodeContext. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1e066e5 commit 26df9ab

5 files changed

Lines changed: 692 additions & 3 deletions

File tree

reader/src/test/java/io/github/dfa1/vortex/reader/decode/AlpEncodingDecoderTest.java

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
import io.github.dfa1.vortex.core.proto.ProtoALPMetadata;
66
import io.github.dfa1.vortex.core.proto.ProtoPatchesMetadata;
77
import io.github.dfa1.vortex.reader.ReadRegistry;
8+
import io.github.dfa1.vortex.reader.array.Array;
89
import io.github.dfa1.vortex.reader.array.DoubleArray;
910
import io.github.dfa1.vortex.reader.array.FloatArray;
11+
import io.github.dfa1.vortex.reader.array.MaskedArray;
12+
import org.junit.jupiter.api.Nested;
1013
import org.junit.jupiter.api.Test;
1114

1215
import java.lang.foreign.Arena;
@@ -21,7 +24,8 @@
2124
class AlpEncodingDecoderTest {
2225

2326
private static final AlpEncodingDecoder SUT = new AlpEncodingDecoder();
24-
private static final ReadRegistry REGISTRY = TestRegistry.ofDecoders(SUT, new PrimitiveEncodingDecoder());
27+
private static final ReadRegistry REGISTRY = TestRegistry.ofDecoders(
28+
SUT, new PrimitiveEncodingDecoder(), new BoolEncodingDecoder());
2529

2630
private static final DType F64 = DType.F64;
2731
private static final DType F32 = DType.F32;
@@ -161,4 +165,90 @@ void decode_patches_nonUnsignedIndexPtype_throws() {
161165
// When / Then
162166
assertThatThrownBy(() -> SUT.decode(ctx)).hasMessageContaining("non-unsigned patch index ptype");
163167
}
168+
169+
/// An ALP array's validity IS its encoded child's (`ValidityChild<ALP>`, #210): a nullable
170+
/// encoded primitive child surfaces as a [MaskedArray], and that mask must ride through both the
171+
/// per-row lazy path and the single-value constant-broadcast path rather than being flattened.
172+
@Nested
173+
class MaskedChildPropagation {
174+
175+
@Test
176+
void maskedEncodedChild_lazyPath_propagatesNullsAndDecodesValues() {
177+
// Given — encoded I64 [100,200,300] with row 1 null; exp_e=2 scales by 0.01
178+
ArrayNode validityNode = new ArrayNode(EncodingId.VORTEX_BOOL, null, new ArrayNode[0], new int[]{1});
179+
ArrayNode enc = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null,
180+
new ArrayNode[]{validityNode}, new int[]{0});
181+
byte[] meta = new ProtoALPMetadata(2, 0, null).encode();
182+
ArrayNode node = new ArrayNode(EncodingId.VORTEX_ALP, MemorySegment.ofArray(meta),
183+
new ArrayNode[]{enc}, new int[0]);
184+
MemorySegment[] segs = {leLongs(100L, 200L, 300L), boolBitmap(true, false, true)};
185+
DecodeContext ctx = new DecodeContext(node, F64, 3, segs, REGISTRY, Arena.ofAuto());
186+
187+
// When
188+
Array result = SUT.decode(ctx);
189+
190+
// Then — nulls survive and valid rows decode through the lazy ALP path
191+
MaskedArray masked = (MaskedArray) result;
192+
assertThat(masked.isValid(0)).isTrue();
193+
assertThat(masked.isValid(1)).isFalse();
194+
assertThat(masked.isValid(2)).isTrue();
195+
DoubleArray inner = (DoubleArray) masked.inner();
196+
assertThat(inner.getDouble(0)).isCloseTo(1.0, within(1e-9));
197+
assertThat(inner.getDouble(2)).isCloseTo(3.0, within(1e-9));
198+
}
199+
200+
@Test
201+
void maskedEncodedChild_constantBroadcast_preservesValidity() {
202+
// Given — a single encoded value broadcast to 3 rows (capacity < n, no patches) routes
203+
// through the LazyConstant arm; the validity mask must survive that path too.
204+
ArrayNode validityNode = new ArrayNode(EncodingId.VORTEX_BOOL, null, new ArrayNode[0], new int[]{1});
205+
ArrayNode enc = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null,
206+
new ArrayNode[]{validityNode}, new int[]{0});
207+
byte[] meta = new ProtoALPMetadata(2, 0, null).encode(); // *0.01
208+
ArrayNode node = new ArrayNode(EncodingId.VORTEX_ALP, MemorySegment.ofArray(meta),
209+
new ArrayNode[]{enc}, new int[0]);
210+
MemorySegment[] segs = {leLongs(100L), boolBitmap(true, false, true)};
211+
DecodeContext ctx = new DecodeContext(node, F64, 3, segs, REGISTRY, Arena.ofAuto());
212+
213+
// When
214+
Array result = SUT.decode(ctx);
215+
216+
// Then — every row is the broadcast constant 1.0, and row 1 stays null
217+
MaskedArray masked = (MaskedArray) result;
218+
assertThat(masked.isValid(0)).isTrue();
219+
assertThat(masked.isValid(1)).isFalse();
220+
assertThat(masked.isValid(2)).isTrue();
221+
DoubleArray inner = (DoubleArray) masked.inner();
222+
for (int i = 0; i < 3; i++) {
223+
assertThat(inner.getDouble(i)).as("row %d", i).isCloseTo(1.0, within(1e-9));
224+
}
225+
}
226+
227+
@Test
228+
void plainEncodedChild_returnsPlainArray() {
229+
// Given — a non-nullable encoded child (no validity): no-regression path must NOT mask
230+
ArrayNode enc = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null, new ArrayNode[0], new int[]{0});
231+
byte[] meta = new ProtoALPMetadata(2, 0, null).encode();
232+
ArrayNode node = new ArrayNode(EncodingId.VORTEX_ALP, MemorySegment.ofArray(meta),
233+
new ArrayNode[]{enc}, new int[0]);
234+
DecodeContext ctx = new DecodeContext(node, F64, 2,
235+
new MemorySegment[]{leLongs(100L, 200L)}, REGISTRY, Arena.ofAuto());
236+
237+
// When
238+
Array result = SUT.decode(ctx);
239+
240+
// Then
241+
assertThat(result).isInstanceOf(DoubleArray.class).isNotInstanceOf(MaskedArray.class);
242+
}
243+
244+
private MemorySegment boolBitmap(boolean... valid) {
245+
byte[] bytes = new byte[(valid.length + 7) / 8];
246+
for (int i = 0; i < valid.length; i++) {
247+
if (valid[i]) {
248+
bytes[i >>> 3] |= (byte) (1 << (i & 7));
249+
}
250+
}
251+
return MemorySegment.ofArray(bytes);
252+
}
253+
}
164254
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package io.github.dfa1.vortex.reader.decode;
2+
3+
import io.github.dfa1.vortex.core.error.VortexException;
4+
import io.github.dfa1.vortex.core.model.DType;
5+
import io.github.dfa1.vortex.core.model.EncodingId;
6+
import io.github.dfa1.vortex.core.proto.ProtoBitPackedMetadata;
7+
import io.github.dfa1.vortex.core.proto.ProtoPType;
8+
import io.github.dfa1.vortex.core.proto.ProtoPatchesMetadata;
9+
import io.github.dfa1.vortex.encoding.TestSegments;
10+
import io.github.dfa1.vortex.reader.ReadRegistry;
11+
import io.github.dfa1.vortex.reader.array.Array;
12+
import io.github.dfa1.vortex.reader.array.IntArray;
13+
import io.github.dfa1.vortex.reader.array.MaskedArray;
14+
import org.junit.jupiter.api.Nested;
15+
import org.junit.jupiter.api.Test;
16+
17+
import java.lang.foreign.Arena;
18+
import java.lang.foreign.MemorySegment;
19+
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
22+
23+
/// Pins the validity-child propagation that [BitpackedEncodingDecoder] performs for the
24+
/// `ValidityChild<BitPacked>` shape (#210): a trailing bool validity child that stacked
25+
/// encodings (ALP, FoR) delegate down to bitpacked, and whose position in the child vector
26+
/// depends on the patch shape. Dropping it silently un-nulls rows.
27+
///
28+
/// Every fixture uses `bit_width=0`, the real constant-residual shape seen when bitpacked is
29+
/// nested under FoR/RLE (penguins `bill_length` decodes alp -> for -> bitpacked with the
30+
/// residual collapsed to zero width). That makes the unpacked base deterministically all-zero,
31+
/// so the tests isolate validity-index selection and mask wrapping from the packed-bit unpack.
32+
/// Patch children then write real non-zero values, proving the patch path and validity index
33+
/// coexist.
34+
class BitpackedEncodingDecoderTest {
35+
36+
private static final BitpackedEncodingDecoder SUT = new BitpackedEncodingDecoder();
37+
private static final ReadRegistry REGISTRY = TestRegistry.ofDecoders(
38+
SUT, new PrimitiveEncodingDecoder(), new BoolEncodingDecoder());
39+
40+
private static final EncodingId BITPACKED = EncodingId.FASTLANES_BITPACKED;
41+
private static final EncodingId PRIMITIVE = EncodingId.VORTEX_PRIMITIVE;
42+
private static final EncodingId BOOL = EncodingId.VORTEX_BOOL;
43+
private static final DType I32 = new DType.Primitive(io.github.dfa1.vortex.core.model.PType.I32, true);
44+
45+
@Test
46+
void encodingId_isFastlanesBitpacked() {
47+
// Given / When / Then
48+
assertThat(SUT.encodingId()).isEqualTo(EncodingId.FASTLANES_BITPACKED);
49+
}
50+
51+
@Nested
52+
class ValidityChildIndex {
53+
54+
@Test
55+
void noPatches_trailingValidityAtIndexZero_masksRows() {
56+
// Given — no patches, so the validity child is the first (index 0) child. This is the
57+
// penguins bill_length shape: bitpacked residual (bit_width 0 -> all zero) carrying only
58+
// a validity child. Rows 0,2,4 valid; 1,3,5 null.
59+
byte[] meta = new ProtoBitPackedMetadata(0, 0, null).encode();
60+
MemorySegment validity = boolBitmap(true, false, true, false, true, false);
61+
ArrayNode validityNode = new ArrayNode(BOOL, null, new ArrayNode[0], new int[]{1});
62+
ArrayNode node = new ArrayNode(BITPACKED, MemorySegment.ofArray(meta),
63+
new ArrayNode[]{validityNode}, new int[]{0});
64+
DecodeContext ctx = new DecodeContext(node, I32, 6,
65+
new MemorySegment[]{empty(), validity}, REGISTRY, Arena.ofAuto());
66+
67+
// When
68+
Array result = SUT.decode(ctx);
69+
70+
// Then — a MaskedArray whose validity mirrors the child; base values all zero
71+
MaskedArray masked = assertMasked(result);
72+
assertValidity(masked, true, false, true, false, true, false);
73+
IntArray inner = (IntArray) masked.inner();
74+
for (int i = 0; i < 6; i++) {
75+
assertThat(inner.getInt(i)).as("row %d", i).isZero();
76+
}
77+
}
78+
79+
@Test
80+
void patchesWithoutChunkOffsets_validityAtIndexTwo_masksRows() {
81+
// Given — patches present but no chunk offsets: children are [indices, values, validity],
82+
// so validity sits at index 2. One patch overwrites row 2 with 700; rows 1,4 null.
83+
ProtoPatchesMetadata pm = new ProtoPatchesMetadata(1L, 0L, ProtoPType.U8, null, null, null);
84+
byte[] meta = new ProtoBitPackedMetadata(0, 0, pm).encode();
85+
86+
ArrayNode indices = new ArrayNode(PRIMITIVE, null, new ArrayNode[0], new int[]{1});
87+
ArrayNode values = new ArrayNode(PRIMITIVE, null, new ArrayNode[0], new int[]{2});
88+
ArrayNode validityNode = new ArrayNode(BOOL, null, new ArrayNode[0], new int[]{3});
89+
ArrayNode node = new ArrayNode(BITPACKED, MemorySegment.ofArray(meta),
90+
new ArrayNode[]{indices, values, validityNode}, new int[]{0});
91+
92+
MemorySegment[] segs = {
93+
empty(), // 0: packed (unread, bit_width 0)
94+
MemorySegment.ofArray(new byte[]{2}), // 1: patch index -> row 2
95+
TestSegments.leInts(700), // 2: patch value
96+
boolBitmap(true, false, true, true, false) // 3: validity
97+
};
98+
DecodeContext ctx = new DecodeContext(node, I32, 5, segs, REGISTRY, Arena.ofAuto());
99+
100+
// When
101+
Array result = SUT.decode(ctx);
102+
103+
// Then — validity at index 2 survives; the patch wrote row 2
104+
MaskedArray masked = assertMasked(result);
105+
assertValidity(masked, true, false, true, true, false);
106+
IntArray inner = (IntArray) masked.inner();
107+
assertThat(inner.getInt(2)).isEqualTo(700);
108+
assertThat(inner.getInt(0)).isZero();
109+
assertThat(inner.getInt(4)).isZero();
110+
}
111+
112+
@Test
113+
void patchesWithChunkOffsets_validityAtIndexThree_masksRows() {
114+
// Given — patches carrying chunk offsets: children are [indices, values, chunkOffsets,
115+
// validity], pushing validity to index 3. The chunk-offsets child is never decoded by the
116+
// patch path, so it is a placeholder. One patch overwrites row 1 with 900; rows 3,4 null.
117+
ProtoPatchesMetadata pm = new ProtoPatchesMetadata(1L, 0L, ProtoPType.U8, 1L, ProtoPType.U32, 0L);
118+
byte[] meta = new ProtoBitPackedMetadata(0, 0, pm).encode();
119+
120+
ArrayNode indices = new ArrayNode(PRIMITIVE, null, new ArrayNode[0], new int[]{1});
121+
ArrayNode values = new ArrayNode(PRIMITIVE, null, new ArrayNode[0], new int[]{2});
122+
ArrayNode chunkOffsets = new ArrayNode(PRIMITIVE, null, new ArrayNode[0], new int[]{0}); // placeholder
123+
ArrayNode validityNode = new ArrayNode(BOOL, null, new ArrayNode[0], new int[]{3});
124+
ArrayNode node = new ArrayNode(BITPACKED, MemorySegment.ofArray(meta),
125+
new ArrayNode[]{indices, values, chunkOffsets, validityNode}, new int[]{0});
126+
127+
MemorySegment[] segs = {
128+
empty(),
129+
MemorySegment.ofArray(new byte[]{1}), // patch index -> row 1
130+
TestSegments.leInts(900), // patch value
131+
boolBitmap(true, true, true, false, false)
132+
};
133+
DecodeContext ctx = new DecodeContext(node, I32, 5, segs, REGISTRY, Arena.ofAuto());
134+
135+
// When
136+
Array result = SUT.decode(ctx);
137+
138+
// Then — validity at index 3 survives; the patch wrote row 1
139+
MaskedArray masked = assertMasked(result);
140+
assertValidity(masked, true, true, true, false, false);
141+
IntArray inner = (IntArray) masked.inner();
142+
assertThat(inner.getInt(1)).isEqualTo(900);
143+
assertThat(inner.getInt(0)).isZero();
144+
}
145+
146+
@Test
147+
void noValidityChild_returnsPlainArray() {
148+
// Given — no patches and zero children: nothing to mask, so the decoder returns the bare
149+
// values array, never a MaskedArray (the no-regression path for non-nullable columns).
150+
byte[] meta = new ProtoBitPackedMetadata(0, 0, null).encode();
151+
ArrayNode node = new ArrayNode(BITPACKED, MemorySegment.ofArray(meta),
152+
new ArrayNode[0], new int[]{0});
153+
DecodeContext ctx = new DecodeContext(node, I32, 4,
154+
new MemorySegment[]{empty()}, REGISTRY, Arena.ofAuto());
155+
156+
// When
157+
Array result = SUT.decode(ctx);
158+
159+
// Then
160+
assertThat(result).isInstanceOf(IntArray.class).isNotInstanceOf(MaskedArray.class);
161+
}
162+
163+
@Test
164+
void unexpectedChildCount_throwsWithExpectedCounts() {
165+
// Given — no patches (expected 0 or 1 children) but two children are present: an
166+
// ambiguous shape a trusted encoder never emits, so decode fails loudly.
167+
byte[] meta = new ProtoBitPackedMetadata(0, 0, null).encode();
168+
ArrayNode a = new ArrayNode(BOOL, null, new ArrayNode[0], new int[]{1});
169+
ArrayNode b = new ArrayNode(BOOL, null, new ArrayNode[0], new int[]{1});
170+
ArrayNode node = new ArrayNode(BITPACKED, MemorySegment.ofArray(meta),
171+
new ArrayNode[]{a, b}, new int[]{0});
172+
DecodeContext ctx = new DecodeContext(node, I32, 4,
173+
new MemorySegment[]{empty(), boolBitmap(true, true, true, true)}, REGISTRY, Arena.ofAuto());
174+
175+
// When / Then
176+
assertThatThrownBy(() -> SUT.decode(ctx))
177+
.isInstanceOf(VortexException.class)
178+
.hasMessageContaining("expected 0 or 1 children")
179+
.hasMessageContaining("got 2");
180+
}
181+
}
182+
183+
// ── helpers ─────────────────────────────────────────────────────────────────
184+
185+
private static MaskedArray assertMasked(Array result) {
186+
assertThat(result).isInstanceOf(MaskedArray.class);
187+
return (MaskedArray) result;
188+
}
189+
190+
private static void assertValidity(MaskedArray masked, boolean... expected) {
191+
for (int i = 0; i < expected.length; i++) {
192+
assertThat(masked.isValid(i)).as("valid row %d", i).isEqualTo(expected[i]);
193+
}
194+
}
195+
196+
/// Builds an LSB-first packed validity bitmap (`MaterializedBoolArray` layout) where bit `i`
197+
/// is set when `valid[i]` is true.
198+
private static MemorySegment boolBitmap(boolean... valid) {
199+
byte[] bytes = new byte[(valid.length + 7) / 8];
200+
for (int i = 0; i < valid.length; i++) {
201+
if (valid[i]) {
202+
bytes[i >>> 3] |= (byte) (1 << (i & 7));
203+
}
204+
}
205+
return MemorySegment.ofArray(bytes);
206+
}
207+
208+
private static MemorySegment empty() {
209+
return MemorySegment.ofArray(new byte[0]);
210+
}
211+
}

0 commit comments

Comments
 (0)