Skip to content

Commit ff4a6a0

Browse files
dfa1claude
andcommitted
fix: propagate row validity through wrapper decoders; NullArray CSV export
Files from the Python bindings (vortex-data 0.69.0) carry row validity deep in the encoding tree, in three representations our decoders flattened away — every one silent corruption (nulls exported as invented values), found by the Raincloud conformance suite (#205): - a trailing bool validity child on fastlanes.bitpacked, reached through vortex.alp / vortex.zigzag / fastlanes.for, which per the Rust ValidityChild contract delegate their validity to the encoded child. AlpEncodingDecoder and ZigZagEncodingDecoder now decode that child as an Array (nullability inherited from the parent dtype), split off a MaskedArray, and re-wrap their result — the pattern FrameOfReferenceEncodingDecoder already used. Bitpacked decodes the trailing validity child at the index its patch shape dictates (0 / 2 / 3, mirroring the Rust vtable deserialize). - dict pools with invalid slots: null rows are codes pointing at an invalid pool entry; slot validity is now gathered through the codes into per-row validity. - dict codes with their own validity child: propagated directly, and combined with pool validity when both are present. Applied to the eager vortex.dict decoder (primitive + utf8 paths) and the lazy dict layout path. Also: CSV export renders all-null (DType.Null) columns as empty fields instead of throwing (kepler has fully-empty columns). Corpus evidence: penguins bill_length/bill_depth and 20k+ kepler cells exported values where the file holds nulls (verified against both the Parquet oracle and the Python bindings reading the same file). After the fix kepler passes value-for-value on all 153 columns and flips to ok in the conformance matrix; penguins' remaining failure is #208 only. Closes #210 Closes #211 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a9e4137 commit ff4a6a0

10 files changed

Lines changed: 290 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- 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 with `unsupported ptype for lazy dict`. ([#206](https://github.com/dfa1/vortex-java/issues/206))
13+
- CSV export handles all-null (`DType.Null`) columns as empty fields instead of throwing `unsupported array type: NullArray`. ([#211](https://github.com/dfa1/vortex-java/issues/211))
14+
- 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 through `vortex.alp`/`vortex.zigzag`/`fastlanes.for`, which delegate validity to their encoded child per the Rust `ValidityChild` contract), dict pools with invalid slots, and dict codes with their own validity — across both the eager `vortex.dict` decoder 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](https://github.com/dfa1/vortex-java/issues/210))
1315

1416
### Added
1517

csv/src/main/java/io/github/dfa1/vortex/csv/CsvExporter.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import io.github.dfa1.vortex.reader.array.LongArray;
1414
import io.github.dfa1.vortex.reader.array.ShortArray;
1515
import io.github.dfa1.vortex.reader.array.MaskedArray;
16+
import io.github.dfa1.vortex.reader.array.NullArray;
1617
import io.github.dfa1.vortex.reader.array.VarBinArray;
1718
import io.github.dfa1.vortex.reader.VortexReader;
1819
import io.github.dfa1.vortex.reader.ScanIterator;
@@ -151,6 +152,9 @@ private static String cellValue(Array arr, long rowIdx) {
151152
// Nullable columns decode as MaskedArray: null rows export as an empty field, valid
152153
// rows defer to the inner values array.
153154
case MaskedArray ma -> ma.isValid(rowIdx) ? cellValue(ma.inner(), rowIdx) : "";
155+
// All-null columns (DType.Null) hold only a row count: every cell is an empty
156+
// field, same rule as a MaskedArray null row.
157+
case NullArray ignored -> "";
154158
default -> throw new VortexException(
155159
"unsupported array type for CSV export: " + arr.getClass().getSimpleName());
156160
};

csv/src/test/java/io/github/dfa1/vortex/csv/CsvExporterTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import io.github.dfa1.vortex.core.model.DType;
55
import io.github.dfa1.vortex.writer.VortexWriter;
66
import io.github.dfa1.vortex.writer.WriteOptions;
7+
import io.github.dfa1.vortex.writer.encode.NullEncodingEncoder;
8+
import io.github.dfa1.vortex.writer.encode.PrimitiveEncodingEncoder;
79
import org.junit.jupiter.api.Test;
810
import org.junit.jupiter.api.io.TempDir;
911

@@ -69,6 +71,32 @@ void exportsToWriter(@TempDir Path tmp) throws Exception {
6971
assertThat(lines[2]).isEqualTo("2.7");
7072
}
7173

74+
@Test
75+
void exportsAllNullColumnAsEmptyFields(@TempDir Path tmp) throws Exception {
76+
// Given a file mixing a value column with an all-null (DType.Null) column —
77+
// the shape that made real-world exports throw (#211); the long[] carrier for
78+
// the null column only supplies the row count
79+
Path vortex = tmp.resolve("data.vortex");
80+
DType.Struct schema = new DType.Struct(
81+
List.of(ColumnName.of("id"), ColumnName.of("empty")),
82+
List.of(DType.I64, DType.NULL),
83+
false);
84+
try (FileChannel ch = FileChannel.open(vortex, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
85+
// explicit encoder list: the default cascading set has no DType.Null codec
86+
VortexWriter writer = VortexWriter.create(ch, schema, WriteOptions.defaults(),
87+
List.of(new NullEncodingEncoder(), new PrimitiveEncodingEncoder()))) {
88+
writer.writeChunk(Map.of(ColumnName.of("id"), new long[]{1L, 2L}, ColumnName.of("empty"), new long[2]));
89+
}
90+
Path csv = tmp.resolve("out.csv");
91+
92+
// When
93+
CsvExporter.exportCsv(vortex, csv);
94+
95+
// Then — null cells are empty fields, same as MaskedArray null rows
96+
List<String> result = Files.readAllLines(csv);
97+
assertThat(result).containsExactly("id,empty", "1,", "2,");
98+
}
99+
72100
@Test
73101
void suppressesHeaderWhenConfigured(@TempDir Path tmp) throws Exception {
74102
// Given

docs/compatibility.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,15 @@ Per-slug status lives in `integration/src/test/resources/raincloud/expected-stat
6262
(`ok` must pass; `gap:<issue>` must still fail, so a fix flips the entry in the same change;
6363
`untriaged` runs and reports without failing the build). A scheduled workflow
6464
(`raincloud-conformance.yml`) hydrates a size-capped subset weekly. Current triage —
65-
5 `ok`, 5 known gaps: nested struct columns in scan
65+
6 `ok`, 4 known gaps: nested struct columns in scan
6666
([#207](https://github.com/dfa1/vortex-java/issues/207)), unsigned integers rendered signed
6767
([#208](https://github.com/dfa1/vortex-java/issues/208) — silent corruption), RLE over F64
68-
([#209](https://github.com/dfa1/vortex-java/issues/209)), nullable ALP validity dropped
69-
([#210](https://github.com/dfa1/vortex-java/issues/210) — silent corruption), all-null
70-
columns in CSV export ([#211](https://github.com/dfa1/vortex-java/issues/211));
71-
237 slugs untriaged. Fixed so far by this suite: lazy dict U8/U16 values
72-
([#206](https://github.com/dfa1/vortex-java/issues/206)).
68+
([#209](https://github.com/dfa1/vortex-java/issues/209)); 237 slugs untriaged. Fixed so
69+
far by this suite: lazy dict U8/U16 values
70+
([#206](https://github.com/dfa1/vortex-java/issues/206)), row validity dropped by wrapper
71+
decoders ([#210](https://github.com/dfa1/vortex-java/issues/210) — silent corruption),
72+
all-null columns in CSV export
73+
([#211](https://github.com/dfa1/vortex-java/issues/211)).
7374

7475
## Encodings
7576

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ humaneval,untriaged
124124
insurance,untriaged
125125
international-football-results-from-1872-to-2017,untriaged
126126
iowa-liquor-sales,untriaged
127-
kepler-exoplanet-search-results,gap:211
127+
kepler-exoplanet-search-results,ok
128128
kepler-labelled-time-series-data,untriaged
129129
laion-400m,untriaged
130130
librispeech-test-clean,untriaged
@@ -166,7 +166,7 @@ osmi-mental-health-in-tech-2020,untriaged
166166
osmi-mental-health-in-tech-2021,untriaged
167167
osmi-mental-health-in-tech-2022,untriaged
168168
osmi-mental-health-in-tech-2023,untriaged
169-
palmer-archipelago-antarctica-penguin-data,gap:210
169+
palmer-archipelago-antarctica-penguin-data,gap:208
170170
peoples-speech-clean-validation,untriaged
171171
personal-key-indicators-of-heart-disease,untriaged
172172
pleias-synth,untriaged

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

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
import io.github.dfa1.vortex.core.proto.ProtoALPMetadata;
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.LazyAlpDoubleArray;
1213
import io.github.dfa1.vortex.reader.array.LazyAlpFloatArray;
1314
import io.github.dfa1.vortex.reader.array.LazyConstantDoubleArray;
1415
import io.github.dfa1.vortex.reader.array.LazyConstantFloatArray;
16+
import io.github.dfa1.vortex.reader.array.MaskedArray;
1517
import io.github.dfa1.vortex.reader.array.MaterializedDoubleArray;
1618
import io.github.dfa1.vortex.reader.array.MaterializedFloatArray;
1719

@@ -55,21 +57,38 @@ public Array decode(DecodeContext ctx) {
5557
PType ptype = p.ptype();
5658
long n = ctx.rowCount();
5759

58-
return switch (ptype) {
59-
case F64 -> decodeF64(ctx, meta, expE, expF, n);
60-
case F32 -> decodeF32(ctx, meta, expE, expF, n);
60+
// Validity mirrors the Rust reference (`ValidityChild<ALP>`): an ALP array's
61+
// validity IS its encoded child's validity, and the encoded child's dtype
62+
// inherits the ALP dtype's nullability. Decode the child as an Array so a
63+
// nullable primitive surfaces its MaskedArray instead of being flattened to a
64+
// raw segment (which silently dropped nulls — #210).
65+
DType.Primitive encodedDtype = new DType.Primitive(
66+
ptype == PType.F64 ? PType.I64 : PType.I32, p.nullable());
67+
Array encoded = ctx.decodeChild(0, encodedDtype, n);
68+
BoolArray validity = null;
69+
Array rawEncoded = encoded;
70+
if (encoded instanceof MaskedArray masked) {
71+
rawEncoded = masked.inner();
72+
validity = masked.validity();
73+
}
74+
MemorySegment src = ctx.materialize(rawEncoded);
75+
76+
Array result = switch (ptype) {
77+
case F64 -> decodeF64(ctx, meta, expE, expF, n, src);
78+
case F32 -> decodeF32(ctx, meta, expE, expF, n, src);
6179
default -> throw new VortexException(EncodingId.VORTEX_ALP, "unsupported dtype " + ptype);
6280
};
81+
return validity != null ? new MaskedArray(result, validity) : result;
6382
}
6483

65-
private static Array decodeF64(DecodeContext ctx, ProtoALPMetadata meta, int expE, int expF, long n) {
84+
private static Array decodeF64(DecodeContext ctx, ProtoALPMetadata meta, int expE, int expF, long n,
85+
MemorySegment src) {
6686
// Decode formula mirrors the Rust reference (`ALPFloat::decode_single`): two-step
6787
// `encoded * F10[f] * IF10[e]`. A pre-multiplied `scale = F10[f] * IF10[e]`
6888
// gives different IEEE rounding for non-trivial `expF`, breaking round-trip with
6989
// the encoder's verify step.
7090
double df = F10_F64[expF];
7191
double de = IF10_F64[expE];
72-
MemorySegment src = ctx.decodeChildSegment(0, DType.I64, n);
7392
long srcCap = SegmentBroadcast.capacity(src, 8);
7493

7594
if (meta.patches() == null) {
@@ -96,10 +115,10 @@ private static Array decodeF64(DecodeContext ctx, ProtoALPMetadata meta, int exp
96115
return new MaterializedDoubleArray(ctx.dtype(), n, buf.asReadOnly());
97116
}
98117

99-
private static Array decodeF32(DecodeContext ctx, ProtoALPMetadata meta, int expE, int expF, long n) {
118+
private static Array decodeF32(DecodeContext ctx, ProtoALPMetadata meta, int expE, int expF, long n,
119+
MemorySegment src) {
100120
float df = F10_F32[expF];
101121
float de = IF10_F32[expE];
102-
MemorySegment src = ctx.decodeChildSegment(0, DType.I32, n);
103122
long srcCap = SegmentBroadcast.capacity(src, 4);
104123

105124
if (meta.patches() == null) {

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import io.github.dfa1.vortex.core.proto.ProtoBitPackedMetadata;
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;
12+
import io.github.dfa1.vortex.reader.array.MaskedArray;
1113
import io.github.dfa1.vortex.reader.array.MaterializedByteArray;
1214
import io.github.dfa1.vortex.reader.array.MaterializedIntArray;
1315
import io.github.dfa1.vortex.reader.array.MaterializedLongArray;
@@ -59,13 +61,50 @@ public Array decode(DecodeContext ctx) {
5961
applyPatches(ctx, meta.patches(), output, ptype.byteSize());
6062
}
6163

62-
return switch (ptype) {
64+
Array values = switch (ptype) {
6365
case I64, U64 -> new MaterializedLongArray(ctx.dtype(), rowCount, output);
6466
case I32, U32 -> new MaterializedIntArray(ctx.dtype(), rowCount, output);
6567
case I16, U16 -> new MaterializedShortArray(ctx.dtype(), rowCount, output);
6668
case I8, U8 -> new MaterializedByteArray(ctx.dtype(), rowCount, output);
6769
default -> throw new VortexException(EncodingId.FASTLANES_BITPACKED, "unsupported ptype " + ptype);
6870
};
71+
return wrapValidity(ctx, meta, values, rowCount);
72+
}
73+
74+
/// Row validity mirrors the Rust reference (`BitPacked` vtable `deserialize`): the
75+
/// children are `[patch_indices, patch_values, patch_chunk_offsets?]` (present only
76+
/// with patches) followed by an optional trailing bool validity child. Encodings
77+
/// stacked above bitpacked (ALP, FoR, …) delegate their validity here
78+
/// (`ValidityChild`), so dropping this child silently un-nulls rows (#210).
79+
///
80+
/// @param ctx decode context
81+
/// @param meta bitpacked metadata (patch shape determines the validity index)
82+
/// @param values the unpacked (and patched) values array
83+
/// @param rowCount logical row count
84+
/// @return `values`, wrapped in a [MaskedArray] when a validity child is present
85+
private static Array wrapValidity(DecodeContext ctx, ProtoBitPackedMetadata meta, Array values, long rowCount) {
86+
int validityIdx = validityChildIndex(meta);
87+
int childCount = ctx.node().children().length;
88+
if (childCount == validityIdx) {
89+
return values;
90+
}
91+
if (childCount != validityIdx + 1) {
92+
throw new VortexException(EncodingId.FASTLANES_BITPACKED,
93+
"expected " + validityIdx + " or " + (validityIdx + 1) + " children, got " + childCount);
94+
}
95+
Array va = ctx.decodeChild(validityIdx, DType.BOOL, rowCount);
96+
if (!(va instanceof BoolArray validity)) {
97+
throw new VortexException(EncodingId.FASTLANES_BITPACKED,
98+
"validity child decoded to unexpected type: " + va.getClass().getSimpleName());
99+
}
100+
return new MaskedArray(values, validity);
101+
}
102+
103+
private static int validityChildIndex(ProtoBitPackedMetadata meta) {
104+
if (meta.patches() == null) {
105+
return 0;
106+
}
107+
return meta.patches().chunk_offsets_ptype() != null ? 3 : 2;
69108
}
70109

71110
private static void fastlanesUnpackToSeg(

0 commit comments

Comments
 (0)