Skip to content

Commit 562f938

Browse files
dfa1claude
andcommitted
fix: AlpRd propagates left_parts validity; null float rows no longer decode as 0.0 (#234)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e149779 commit 562f938

4 files changed

Lines changed: 143 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525
- `vortex.sparse` propagates nullability: a `fill_value: null` array nulls every unpatched position (world-energy-consumption `biofuel_cons_change_pct` f64? previously decoded them as 0.0), and a null patch value nulls its own position. Row validity is a lazy sparse bool whose fill is `fill_value.is_valid()` and whose patch bits are the patch values' validity, matching the Rust `ValidityVTable<Sparse>`. ([#226](https://github.com/dfa1/vortex-java/issues/226))
2626
- `vortex.sparse` over utf8/binary values now carries the same row validity as the primitive path: a `fill_value: null` string 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 Rust `ValidityVTable<Sparse>` is generic over the values encoding, so VarBin reuses the identical sparse-bool validity — completing the #226 fix for string/binary columns. ([#232](https://github.com/dfa1/vortex-java/issues/232))
2727
- `vortex.datetimeparts` propagates null component rows instead of throwing `DateTimeParts: null cell at index`: when any part (days/seconds/subseconds) decodes to a null — as a nullable `vortex.runend` child now does after #225 — the reassembled timestamp row is null, unblocking scans of bi-yalelanguages and bi-euro2016. ([#235](https://github.com/dfa1/vortex-java/issues/235))
28+
- `fastlanes.alprd` now propagates left_parts validity: null float rows no longer decode as `0.0`. ([#234](https://github.com/dfa1/vortex-java/issues/234))
2829

2930
### Added
3031

32+
- Integration test for null-fill Sparse and null-run RunEnd round-trip, running on every gate. ([#233](https://github.com/dfa1/vortex-java/issues/233))
3133
- Real-world conformance suite against the [Raincloud](https://github.com/spiraldb/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.sh` hydrates any subset (cache → mirror → local build), `RaincloudConformanceIntegrationTest` tests 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](https://github.com/dfa1/vortex-java/issues/205))
3234

3335
## [0.12.0] — 2026-07-04

integration/src/test/resources/raincloud/expected-status.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ bi-cmsprovider,untriaged
2929
bi-commongovernment,untriaged
3030
bi-corporations,ok
3131
bi-eixo,untriaged
32-
bi-euro2016,gap:234
32+
bi-euro2016,ok
3333
bi-food,ok
3434
bi-generico,untriaged
3535
bi-hashtags,untriaged

reader/src/main/java/io/github/dfa1/vortex/reader/decode/AlpRdEncodingDecoder.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import io.github.dfa1.vortex.core.proto.ProtoALPRDMetadata;
99
import io.github.dfa1.vortex.core.proto.ProtoPatchesMetadata;
1010
import io.github.dfa1.vortex.reader.array.Array;
11+
import io.github.dfa1.vortex.reader.array.BoolArray;
1112
import io.github.dfa1.vortex.reader.array.IntArray;
1213
import io.github.dfa1.vortex.reader.array.LazyAlpRdDoubleArray;
1314
import io.github.dfa1.vortex.reader.array.LazyAlpRdFloatArray;
@@ -45,14 +46,22 @@ public Array decode(DecodeContext ctx) {
4546
long n = ctx.rowCount();
4647
PType ptype = p.ptype();
4748

48-
// Lazy path: keep left/right as typed Arrays + patches as a small short[] +
49-
// a lazy indices Array. No n-sized output buffer allocated.
49+
// Validity mirrors the Rust reference: an ALP-RD array's validity IS its
50+
// left_parts child's validity. Decode the child as an Array so a nullable
51+
// left_parts surfaces its MaskedArray; capture the mask and re-wrap the decoded
52+
// result rather than flattening it (which silently dropped nulls — #234).
5053
Array leftRaw = ctx.decodeChild(0, DType.U16, n);
51-
ShortArray leftArr = (ShortArray) unwrap(leftRaw);
54+
BoolArray validity = null;
55+
Array leftInner = leftRaw;
56+
if (leftRaw instanceof MaskedArray masked) {
57+
leftInner = masked.inner();
58+
validity = masked.validity();
59+
}
60+
ShortArray leftArr = (ShortArray) leftInner;
5261

5362
Patches patches = decodePatches(ctx, meta.patches());
5463

55-
return switch (ptype) {
64+
Array decoded = switch (ptype) {
5665
case F64 -> {
5766
Array rightRaw = ctx.decodeChild(1, DType.U64, n);
5867
LongArray rightArr = (LongArray) unwrap(rightRaw);
@@ -67,6 +76,7 @@ yield new LazyAlpRdFloatArray(ctx.dtype(), n, dict, rightBitWidth,
6776
}
6877
default -> throw new VortexException(EncodingId.VORTEX_ALPRD, "unsupported dtype " + ptype);
6978
};
79+
return validity != null ? new MaskedArray(decoded, validity) : decoded;
7080
}
7181

7282
private static Array unwrap(Array arr) {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package io.github.dfa1.vortex.reader.decode;
2+
3+
import io.github.dfa1.vortex.core.model.DType;
4+
import io.github.dfa1.vortex.core.model.EncodingId;
5+
import io.github.dfa1.vortex.core.proto.ProtoALPRDMetadata;
6+
import io.github.dfa1.vortex.core.proto.ProtoPType;
7+
import io.github.dfa1.vortex.reader.ReadRegistry;
8+
import io.github.dfa1.vortex.reader.array.Array;
9+
import io.github.dfa1.vortex.reader.array.DoubleArray;
10+
import io.github.dfa1.vortex.reader.array.MaskedArray;
11+
import org.junit.jupiter.api.Test;
12+
13+
import java.lang.foreign.Arena;
14+
import java.lang.foreign.MemorySegment;
15+
import java.nio.ByteBuffer;
16+
import java.nio.ByteOrder;
17+
import java.util.List;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.within;
21+
22+
class AlpRdEncodingDecoderTest {
23+
24+
private static final AlpRdEncodingDecoder SUT = new AlpRdEncodingDecoder();
25+
private static final ReadRegistry REGISTRY = TestRegistry.ofDecoders(
26+
SUT, new PrimitiveEncodingDecoder(), new BoolEncodingDecoder());
27+
28+
// ALP-RD reconstruction is `longBitsToDouble((dict[left] << rightBitWidth) | right)`.
29+
// With rightBitWidth=48, the dict codes are the top 16 bits of an f64 bit pattern:
30+
// 1.0 = 0x3FF0_0000_0000_0000 -> 0x3FF0; 2.0 = 0x4000_0000_0000_0000 -> 0x4000.
31+
private static final int RIGHT_BIT_WIDTH = 48;
32+
private static final int DICT_ONE = 0x3FF0;
33+
private static final int DICT_TWO = 0x4000;
34+
35+
@Test
36+
void decode_nullLeftParts_yieldsNullRows() {
37+
// Given — left codes [0,1,0] with row 1 null via the left_parts validity bitmap;
38+
// the decoder must carry that mask onto the reconstructed doubles (#234).
39+
DecodeContext ctx = maskedContext(new short[]{0, 1, 0}, new long[]{0, 0, 0},
40+
boolBitmap(true, false, true));
41+
42+
// When
43+
Array result = SUT.decode(ctx);
44+
45+
// Then — the null row survives and valid rows decode to their dictionary doubles
46+
MaskedArray masked = (MaskedArray) result;
47+
assertThat(masked.isValid(0)).isTrue();
48+
assertThat(masked.isValid(1)).isFalse();
49+
assertThat(masked.isValid(2)).isTrue();
50+
DoubleArray inner = (DoubleArray) masked.inner();
51+
assertThat(inner.getDouble(0)).isCloseTo(1.0, within(1e-12));
52+
assertThat(inner.getDouble(2)).isCloseTo(1.0, within(1e-12));
53+
}
54+
55+
@Test
56+
void decode_allValidLeftParts_yieldsPlainArray() {
57+
// Given — a non-nullable left_parts child (no validity): no-regression path must NOT mask
58+
DecodeContext ctx = plainContext(new short[]{0, 1, 0}, new long[]{0, 0, 0});
59+
60+
// When
61+
Array result = SUT.decode(ctx);
62+
63+
// Then — a bare DoubleArray, and every row decodes through the dictionary
64+
assertThat(result).isInstanceOf(DoubleArray.class).isNotInstanceOf(MaskedArray.class);
65+
DoubleArray inner = (DoubleArray) result;
66+
assertThat(inner.getDouble(0)).isCloseTo(1.0, within(1e-12));
67+
assertThat(inner.getDouble(1)).isCloseTo(2.0, within(1e-12));
68+
assertThat(inner.getDouble(2)).isCloseTo(1.0, within(1e-12));
69+
}
70+
71+
private static DecodeContext maskedContext(short[] leftCodes, long[] rightBits, MemorySegment validity) {
72+
// buffers: 0 = left u16 codes, 1 = right u64 payloads, 2 = validity bitmap
73+
ArrayNode validityNode = new ArrayNode(EncodingId.VORTEX_BOOL, null, new ArrayNode[0], new int[]{2});
74+
ArrayNode leftNode = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null,
75+
new ArrayNode[]{validityNode}, new int[]{0});
76+
ArrayNode rightNode = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null, new ArrayNode[0], new int[]{1});
77+
ArrayNode node = new ArrayNode(EncodingId.VORTEX_ALPRD, metaSegment(),
78+
new ArrayNode[]{leftNode, rightNode}, new int[0]);
79+
MemorySegment[] segs = {leShorts(leftCodes), leLongs(rightBits), validity};
80+
return new DecodeContext(node, DType.F64, leftCodes.length, segs, REGISTRY, Arena.ofAuto());
81+
}
82+
83+
private static DecodeContext plainContext(short[] leftCodes, long[] rightBits) {
84+
// buffers: 0 = left u16 codes, 1 = right u64 payloads (no validity child)
85+
ArrayNode leftNode = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null, new ArrayNode[0], new int[]{0});
86+
ArrayNode rightNode = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null, new ArrayNode[0], new int[]{1});
87+
ArrayNode node = new ArrayNode(EncodingId.VORTEX_ALPRD, metaSegment(),
88+
new ArrayNode[]{leftNode, rightNode}, new int[0]);
89+
MemorySegment[] segs = {leShorts(leftCodes), leLongs(rightBits)};
90+
return new DecodeContext(node, DType.F64, leftCodes.length, segs, REGISTRY, Arena.ofAuto());
91+
}
92+
93+
private static MemorySegment metaSegment() {
94+
byte[] meta = new ProtoALPRDMetadata(RIGHT_BIT_WIDTH, 2,
95+
List.of(DICT_ONE, DICT_TWO), ProtoPType.U16, null).encode();
96+
return MemorySegment.ofArray(meta);
97+
}
98+
99+
private static MemorySegment leShorts(short... vs) {
100+
byte[] b = new byte[vs.length * 2];
101+
ByteBuffer bb = ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN);
102+
for (short v : vs) {
103+
bb.putShort(v);
104+
}
105+
return MemorySegment.ofArray(b);
106+
}
107+
108+
private static MemorySegment leLongs(long... vs) {
109+
byte[] b = new byte[vs.length * 8];
110+
ByteBuffer bb = ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN);
111+
for (long v : vs) {
112+
bb.putLong(v);
113+
}
114+
return MemorySegment.ofArray(b);
115+
}
116+
117+
private static MemorySegment boolBitmap(boolean... valid) {
118+
byte[] bytes = new byte[(valid.length + 7) / 8];
119+
for (int i = 0; i < valid.length; i++) {
120+
if (valid[i]) {
121+
bytes[i >>> 3] |= (byte) (1 << (i & 7));
122+
}
123+
}
124+
return MemorySegment.ofArray(bytes);
125+
}
126+
}

0 commit comments

Comments
 (0)