Skip to content

Commit 76e4c74

Browse files
dfa1claude
andcommitted
feat(variant): decode variant columns Java-side (constant + chunked)
ConstantEncodingDecoder now handles DType.Variant by unwrapping the variant scalar to its typed inner scalar and materialising the inner-typed constant array; the dispatch was refactored to thread the dtype explicitly so Extension and Variant can recurse without re-reading the buffer. ChunkedEncodingDecoder wraps Variant chunks under their inner dtype. VariantEncodingDecoder.accepts now reports Variant. Together with VariantEncodingDecoder this makes the Java reader symmetric with the writer: a Java-encoded variant column round-trips through Java decode, with core storage exposing each row's inner value (verified for constant and row-varying columns). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 752f0ea commit 76e4c74

4 files changed

Lines changed: 122 additions & 40 deletions

File tree

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,23 @@ private static Array wrap(List<Array> chunks, DType dtype, long totalRows) {
8686
if (dtype instanceof DType.Struct struct) {
8787
return wrapStruct(chunks, struct, totalRows);
8888
}
89+
if (dtype instanceof DType.Variant) {
90+
// Each chunk decoded as Variant materialises to its inner-typed constant array
91+
// (see ConstantEncodingDecoder). Wrap the chunks under that inner dtype; the
92+
// VariantArray container re-applies the logical Variant dtype.
93+
if (chunks.isEmpty()) {
94+
throw new VortexException(EncodingId.VORTEX_CHUNKED, "chunked variant has no chunks");
95+
}
96+
DType innerDtype = chunks.get(0).dtype();
97+
if (innerDtype instanceof DType.Primitive innerPt) {
98+
return wrapPrimitive(chunks, innerPt, innerDtype, totalRows);
99+
}
100+
if (innerDtype instanceof DType.Bool) {
101+
return ChunkedBoolArray.of(innerDtype, totalRows, chunks);
102+
}
103+
throw new VortexException(EncodingId.VORTEX_CHUNKED,
104+
"chunked variant inner dtype not supported: " + innerDtype);
105+
}
89106
throw new VortexException(EncodingId.VORTEX_CHUNKED,
90107
"chunked not supported for dtype: " + dtype);
91108
}

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

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -52,69 +52,77 @@ public Array decode(DecodeContext ctx) {
5252
throw new VortexException(EncodingId.VORTEX_CONSTANT, "invalid scalar value", e);
5353
}
5454

55-
long n = ctx.rowCount();
55+
return arrayFromScalar(ctx, scalar, ctx.dtype(), ctx.rowCount());
56+
}
5657

57-
if (ctx.dtype() instanceof DType.Null) {
58-
return new NullArray(ctx.dtype(), n);
58+
/// Builds the constant array for `scalar` interpreted as `dtype`, broadcast to `n` rows.
59+
/// Recurses for Extension (its storage dtype) and Variant (the wrapped inner scalar).
60+
private static Array arrayFromScalar(DecodeContext ctx, ScalarValue scalar, DType dtype, long n) {
61+
if (dtype instanceof DType.Null) {
62+
return new NullArray(dtype, n);
5963
}
60-
61-
if (ctx.dtype() instanceof DType.Utf8 || ctx.dtype() instanceof DType.Binary) {
62-
return decodeString(ctx, scalar, n);
64+
if (dtype instanceof DType.Variant) {
65+
// A constant variant wraps a typed inner scalar (Scalar::variant(inner)); the
66+
// physical storage is the inner-typed constant array. The VariantArray wrapper
67+
// re-applies the logical Variant dtype.
68+
io.github.dfa1.vortex.proto.Scalar inner = scalar.variant_value();
69+
if (inner == null || inner.value() == null) {
70+
throw new VortexException(EncodingId.VORTEX_CONSTANT, "constant variant missing variant_value");
71+
}
72+
DType innerDtype = VariantEncodingDecoder.dtypeFromProto(inner.dtype());
73+
return arrayFromScalar(ctx, inner.value(), innerDtype, n);
6374
}
64-
65-
if (ctx.dtype() instanceof DType.Bool) {
66-
return decodeBool(ctx, scalar, n);
75+
if (dtype instanceof DType.Utf8 || dtype instanceof DType.Binary) {
76+
return decodeString(ctx, scalar, dtype, n);
6777
}
68-
69-
if (ctx.dtype() instanceof DType.Decimal) {
70-
return decodeDecimal(ctx, scalar, n);
78+
if (dtype instanceof DType.Bool) {
79+
return decodeBool(dtype, scalar, n);
7180
}
72-
73-
if (ctx.dtype() instanceof DType.Extension ext) {
74-
var storageCtx = new DecodeContext(ctx.node(), ext.storageDType(), ctx.rowCount(),
75-
ctx.segmentBuffers(), ctx.registry(), ctx.arena());
76-
Array storage = decode(storageCtx);
77-
// GenericArray needs a backing buffer; the recursive call now returns a
78-
// metadata-only LazyConstantXxxArray. Materialise once into the chunk arena
79-
// so downstream extension consumers that read via ArraySegments.of(arr) still
80-
// find a segment. Extension-on-constant is rare enough that the small alloc
81-
// doesn't matter — the bare primitive path stays buffer-free.
82-
return new GenericArray(ctx.dtype(), n, ArraySegments.of(storage, ctx.arena()));
81+
if (dtype instanceof DType.Decimal) {
82+
return decodeDecimal(dtype, scalar, n);
8383
}
84-
85-
if (!(ctx.dtype() instanceof DType.Primitive p)) {
86-
throw new VortexException(EncodingId.VORTEX_CONSTANT, "unsupported dtype " + ctx.dtype());
84+
if (dtype instanceof DType.Extension ext) {
85+
Array storage = arrayFromScalar(ctx, scalar, ext.storageDType(), n);
86+
// GenericArray needs a backing buffer; the recursive call returns a metadata-only
87+
// LazyConstantXxxArray. Materialise once into the chunk arena so downstream
88+
// extension consumers that read via ArraySegments.of(arr) still find a segment.
89+
// Extension-on-constant is rare enough that the small alloc doesn't matter — the
90+
// bare primitive path stays buffer-free.
91+
return new GenericArray(dtype, n, ArraySegments.of(storage, ctx.arena()));
92+
}
93+
if (!(dtype instanceof DType.Primitive p)) {
94+
throw new VortexException(EncodingId.VORTEX_CONSTANT, "unsupported dtype " + dtype);
8795
}
8896

8997
PType ptype = p.ptype();
9098
long rawBits = scalarToRawBits(scalar, ptype);
9199

92100
return switch (ptype) {
93-
case I64, U64 -> new LazyConstantLongArray(ctx.dtype(), n, rawBits);
94-
case I32, U32 -> new LazyConstantIntArray(ctx.dtype(), n, (int) rawBits);
95-
case F64 -> new LazyConstantDoubleArray(ctx.dtype(), n, Double.longBitsToDouble(rawBits));
96-
case F32 -> new LazyConstantFloatArray(ctx.dtype(), n, Float.intBitsToFloat((int) rawBits));
97-
case I16, U16 -> new LazyConstantShortArray(ctx.dtype(), n, (short) rawBits);
98-
case I8, U8 -> new LazyConstantByteArray(ctx.dtype(), n, (byte) rawBits);
101+
case I64, U64 -> new LazyConstantLongArray(dtype, n, rawBits);
102+
case I32, U32 -> new LazyConstantIntArray(dtype, n, (int) rawBits);
103+
case F64 -> new LazyConstantDoubleArray(dtype, n, Double.longBitsToDouble(rawBits));
104+
case F32 -> new LazyConstantFloatArray(dtype, n, Float.intBitsToFloat((int) rawBits));
105+
case I16, U16 -> new LazyConstantShortArray(dtype, n, (short) rawBits);
106+
case I8, U8 -> new LazyConstantByteArray(dtype, n, (byte) rawBits);
99107
default -> throw new VortexException(EncodingId.VORTEX_CONSTANT, "unsupported ptype " + ptype);
100108
};
101109
}
102110

103-
private static Array decodeDecimal(DecodeContext ctx, ScalarValue scalar, long n) {
111+
private static Array decodeDecimal(DType dtype, ScalarValue scalar, long n) {
104112
byte[] elemBytes = scalar.bytes_value();
105113
int elemLen = elemBytes.length;
106114
// Decode the single scalar value via LazyDecimalArray (reuses its LE byte-order logic),
107115
// then wrap in a constant array — O(1) allocation regardless of row count.
108-
var value = new LazyDecimalArray(ctx.dtype(), 1, MemorySegment.ofArray(elemBytes), elemLen).getDecimal(0);
109-
return new LazyConstantDecimalArray(ctx.dtype(), n, value, elemLen);
116+
var value = new LazyDecimalArray(dtype, 1, MemorySegment.ofArray(elemBytes), elemLen).getDecimal(0);
117+
return new LazyConstantDecimalArray(dtype, n, value, elemLen);
110118
}
111119

112-
private static Array decodeBool(DecodeContext ctx, ScalarValue scalar, long n) {
120+
private static Array decodeBool(DType dtype, ScalarValue scalar, long n) {
113121
boolean value = scalar.bool_value() != null && scalar.bool_value();
114-
return new LazyConstantBoolArray(ctx.dtype(), n, value);
122+
return new LazyConstantBoolArray(dtype, n, value);
115123
}
116124

117-
private static Array decodeString(DecodeContext ctx, ScalarValue scalar, long n) {
125+
private static Array decodeString(DecodeContext ctx, ScalarValue scalar, DType dtype, long n) {
118126
byte[] strBytes = scalar.string_value() != null
119127
? scalar.string_value().getBytes(StandardCharsets.UTF_8)
120128
: (scalar.bytes_value() != null ? scalar.bytes_value() : new byte[0]);
@@ -131,7 +139,7 @@ private static Array decodeString(DecodeContext ctx, ScalarValue scalar, long n)
131139
offsetsSeg.setAtIndex(PTypeIO.LE_INT, i, (int) (i * strLen));
132140
}
133141

134-
return new VarBinArray.OffsetMode(ctx.dtype(), n, bytesSeg.asReadOnly(), offsetsSeg.asReadOnly(), PType.I32);
142+
return new VarBinArray.OffsetMode(dtype, n, bytesSeg.asReadOnly(), offsetsSeg.asReadOnly(), PType.I32);
135143
}
136144

137145
private static long scalarToRawBits(ScalarValue scalar, PType ptype) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public EncodingId encodingId() {
2828

2929
@Override
3030
public boolean accepts(DType dtype) {
31-
return false;
31+
return dtype instanceof DType.Variant;
3232
}
3333

3434
@Override

writer/src/test/java/io/github/dfa1/vortex/writer/encode/VariantEncodingEncoderTest.java

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,63 @@ void adjacentEqualValues_coalesceIntoOneRun() throws Exception {
141141
}
142142
}
143143

144+
@Nested
145+
class RoundTrip {
146+
147+
private static final io.github.dfa1.vortex.reader.ReadRegistry REGISTRY =
148+
io.github.dfa1.vortex.reader.decode.TestRegistry.ofDecoders(
149+
new io.github.dfa1.vortex.reader.decode.VariantEncodingDecoder(),
150+
new io.github.dfa1.vortex.reader.decode.ConstantEncodingDecoder(),
151+
new io.github.dfa1.vortex.reader.decode.ChunkedEncodingDecoder(),
152+
new io.github.dfa1.vortex.reader.decode.PrimitiveEncodingDecoder());
153+
154+
private static io.github.dfa1.vortex.reader.decode.ArrayNode toArrayNode(EncodeNode node) {
155+
io.github.dfa1.vortex.reader.decode.ArrayNode[] children =
156+
new io.github.dfa1.vortex.reader.decode.ArrayNode[node.children().length];
157+
for (int i = 0; i < children.length; i++) {
158+
children[i] = toArrayNode(node.children()[i]);
159+
}
160+
return io.github.dfa1.vortex.reader.decode.ArrayNode.of(
161+
node.encodingId(), node.metadata(), children, node.bufferIndices());
162+
}
163+
164+
private static io.github.dfa1.vortex.reader.array.VariantArray decode(EncodeResult result, long rows) {
165+
MemorySegment[] bufs = result.buffers().toArray(MemorySegment[]::new);
166+
var ctx = new io.github.dfa1.vortex.reader.decode.DecodeContext(
167+
toArrayNode(result.rootNode()), VARIANT, rows, bufs, REGISTRY, java.lang.foreign.Arena.global());
168+
return (io.github.dfa1.vortex.reader.array.VariantArray) new io.github.dfa1.vortex.reader.decode.VariantEncodingDecoder().decode(ctx);
169+
}
170+
171+
@Test
172+
void constantColumn_decodesToBroadcastInnerValues() {
173+
// Given/When a constant column is encoded then decoded back
174+
var result = SUT.encode(VARIANT, VariantData.constant(4, i32Scalar(7L)), EncodeTestHelper.testCtx());
175+
var variant = decode(result, 4);
176+
177+
// Then core storage is the inner i32 value broadcast to every row
178+
assertThat(variant.length()).isEqualTo(4);
179+
assertThat(variant.shredded()).isNull();
180+
var core = (io.github.dfa1.vortex.reader.array.IntArray) variant.coreStorage();
181+
assertThat(core.dtype()).isEqualTo(new DType.Primitive(io.github.dfa1.vortex.core.PType.I32, false));
182+
for (long i = 0; i < 4; i++) {
183+
assertThat(core.getInt(i)).isEqualTo(7);
184+
}
185+
}
186+
187+
@Test
188+
void varyingColumn_decodesPerRowValuesInOrder() {
189+
// Given/When distinct per-row values are encoded (chunked) then decoded back
190+
var data = new VariantData(List.of(i32Scalar(10L), i32Scalar(20L), i32Scalar(30L)));
191+
var variant = decode(SUT.encode(VARIANT, data, EncodeTestHelper.testCtx()), 3);
192+
193+
// Then the chunked core storage yields each row's inner value in order
194+
var core = (io.github.dfa1.vortex.reader.array.IntArray) variant.coreStorage();
195+
assertThat(core.getInt(0)).isEqualTo(10);
196+
assertThat(core.getInt(1)).isEqualTo(20);
197+
assertThat(core.getInt(2)).isEqualTo(30);
198+
}
199+
}
200+
144201
@Nested
145202
class Errors {
146203

0 commit comments

Comments
 (0)