Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ private static long readCode(MemorySegment codes, long i, PType codePType) {
private static void setBit(MemorySegment bits, long i) {
long byteIdx = i >>> 3;
byte cur = bits.get(ValueLayout.JAVA_BYTE, byteIdx);
bits.set(ValueLayout.JAVA_BYTE, byteIdx, (byte) (cur | (1 << (i & 7))));
bits.set(ValueLayout.JAVA_BYTE, byteIdx, (byte) ((cur & 0xff) | (1 << (i & 7))));
}

private static Array decodeUtf8DictLegacy(DecodeContext ctx, MemorySegment meta) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ private static short[] readShorts(MemorySegment buf, int count, PType ptype) {
return out;
}

private static double[] readDoubles(MemorySegment buf, int count) {
// Package-private (not private) so the broadcast branch (cap < count, a
// ConstantEncoding values pool) is testable directly: through decode() a
// constant child always materializes to `count` full elements, so cap == count.
static double[] readDoubles(MemorySegment buf, int count) {
double[] out = new double[count];
long cap = SegmentBroadcast.capacity(buf, 8);
for (int i = 0; i < count; i++) {
Expand All @@ -176,7 +179,7 @@ private static double[] readDoubles(MemorySegment buf, int count) {
return out;
}

private static float[] readFloats(MemorySegment buf, int count) {
static float[] readFloats(MemorySegment buf, int count) {
float[] out = new float[count];
long cap = SegmentBroadcast.capacity(buf, 4);
for (int i = 0; i < count; i++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ private static BoolArray gatherRowValidity(Array codes, BoolArray codesValidity,
if (valid) {
long byteIdx = i >>> 3;
byte cur = bits.get(ValueLayout.JAVA_BYTE, byteIdx);
bits.set(ValueLayout.JAVA_BYTE, byteIdx, (byte) (cur | (1 << (i & 7))));
bits.set(ValueLayout.JAVA_BYTE, byteIdx, (byte) ((cur & 0xff) | (1 << (i & 7))));
}
}
return new MaterializedBoolArray(DType.BOOL, n, bits.asReadOnly());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.dfa1.vortex.reader;

import io.github.dfa1.vortex.core.error.VortexException;
import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.LayoutId;
import io.github.dfa1.vortex.reader.ScanIterator.ChunkSpec;
Expand All @@ -12,6 +13,7 @@
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/// Unit tests for [ScanIterator#buildChunks(Map)] — the scan planner that merges each column's
/// chunk grid into one split grid (the Rust reference's `StructReader::register_splits` union of
Expand Down Expand Up @@ -120,6 +122,21 @@ void disjointBoundariesSpliceAtTheMergedGrid() {
assertThat(coveringFlat(result.get(2), B)).isSameAs(coveringFlat(result.get(3), B));
}

@Test
void columnShorterThanMergedGridThrows() {
// Given two columns whose totals disagree — A spans 8 rows [4, 4] but B only 6 [3, 3]. A
// well-formed file keeps every column the same length; a malformed/untrusted layout may not.
// The merged grid {0, 3, 4, 6, 8} then produces a window [6, 8) that lies beyond B's last
// chunk, so the covering-chunk advance loop runs B's cursor off the end. The guard must
// surface that as a VortexException rather than an ArrayIndexOutOfBoundsException.
var columnFlats = flats(A, new long[]{4, 4}, B, new long[]{3, 3});

// When / Then
assertThatThrownBy(() -> ScanIterator.buildChunks(columnFlats))
.isInstanceOf(VortexException.class)
.hasMessageContaining("no chunk covering");
}

@Test
void emptyProjectionYieldsNoChunks() {
// Given no columns
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,46 @@ void scan_disjointMixedGrid_streamsExactValuesAcrossMergedWindows(@TempDir Path
assertThat(streamedB).isEqualTo(MIXED_B_EXPECTED);
}

// A NESTED struct column "s" (a vortex.struct under the root struct) with two I64 fields x, y,
// stored as ONE full-range subtree beside the per-chunk column "a" [2, 3, 2]. collectFlats treats
// the whole struct subtree as a single full-range chunk source, so the scan decodes it once as a
// StructArray and slices it per window — the ScanIterator.sliceArray StructArray branch that no
// primitive-only file reaches.
private static final long[] NESTED_X = {1000, 1001, 1002, 1003, 1004, 1005, 1006};
private static final long[] NESTED_Y = {2000, 2001, 2002, 2003, 2004, 2005, 2006};
private static final List<Long> NESTED_A_EXPECTED =
List.of(10L, 11L, 12L, 13L, 14L, 15L, 16L);

@Test
void scan_nestedStructColumn_slicesEachFieldPerWindow(@TempDir Path tmp) throws Exception {
// Given a struct whose second column "s" is itself a struct {x, y} spanning all 7 rows, beside
// a per-chunk column "a" chunked [2, 3, 2]. Neither the Java nor JNI writer emits a nested
// struct beside a per-chunk column in one file, so it is hand-assembled here.
Path file = writeNestedStructFile(tmp);
var streamedA = new ArrayList<Long>();
var streamedX = new ArrayList<Long>();
var streamedY = new ArrayList<Long>();

// When streaming the whole scan — "s" is decoded once over the full range then sliced into
// each merged-grid window {0,2,5,7} via ScanIterator.sliceArray's StructArray arm, which
// recursively slices each field into the window.
try (var reader = VortexReader.open(file, registry());
var iter = reader.scan(ScanOptions.all())) {
iter.forEachRemaining(chunk -> {
streamedA.addAll(values(chunk.column("a")));
io.github.dfa1.vortex.reader.array.StructArray s = chunk.column("s");
streamedX.addAll(fieldValues(s, 0));
streamedY.addAll(fieldValues(s, 1));
});
}

// Then every field value survives the per-window struct slice, including the middle window
// [2,5) sliced at a nonzero offset within the single full-range struct.
assertThat(streamedA).isEqualTo(NESTED_A_EXPECTED);
assertThat(streamedX).containsExactly(1000L, 1001L, 1002L, 1003L, 1004L, 1005L, 1006L);
assertThat(streamedY).containsExactly(2000L, 2001L, 2002L, 2003L, 2004L, 2005L, 2006L);
}

// ── Helpers ────────────────────────────────────────────────────────────────

private static ReadRegistry registry() {
Expand All @@ -330,6 +370,101 @@ private static List<Map<String, List<Long>>> streamAll(VortexReader reader) {
return out;
}

/// Reads field `fieldIdx` of a (possibly sliced) [StructArray] as a Long list.
private static List<Long> fieldValues(io.github.dfa1.vortex.reader.array.StructArray s, int fieldIdx) {
return values(s.field(fieldIdx));
}

/// Assembles a struct file: column "a" chunked over [CHUNK_ROWS] (one flat per chunk) and a nested
/// struct column "s" {x, y} stored as one full-range struct subtree (flat x, flat y). Segment
/// order: a-chunk0..a-chunkN, then x, then y. Layout: flat=0, chunked=1, struct=2; array spec
/// vortex.primitive=0.
private static Path writeNestedStructFile(Path dir) throws Exception {
int numChunks = CHUNK_ROWS.length;
List<byte[]> segments = new ArrayList<>();
for (int k = 0; k < numChunks; k++) {
segments.add(primitiveSegment(COL_A[k], null));
}
int xSegIdx = segments.size();
segments.add(primitiveSegment(NESTED_X, null));
int ySegIdx = segments.size();
segments.add(primitiveSegment(NESTED_Y, null));

long[] segOffsets = new long[segments.size()];
long[] segLengths = new long[segments.size()];
long off = 0;
for (int i = 0; i < segments.size(); i++) {
segOffsets[i] = off;
segLengths[i] = segments.get(i).length;
off += segments.get(i).length;
}

ByteBuffer footerBuf = MalformedFiles.buildFooter(
new String[]{"vortex.primitive", "vortex.bool"},
new String[]{"vortex.flat", "vortex.chunked", "vortex.struct"},
segOffsets, segLengths);
ByteBuffer dtypeBuf = buildNestedStructDtype();
ByteBuffer layoutBuf = buildNestedStructLayout(CHUNK_ROWS, xSegIdx, ySegIdx);

return writeVtxFile(dir, "nested.vtx", segments, footerBuf, dtypeBuf, layoutBuf);
}

/// Root struct DType {a: I64 non-null, s: struct {x: I64, y: I64} non-null}.
private static ByteBuffer buildNestedStructDtype() {
var fbb = new FbsBuilder(512);
int primA = FbsPrimitive.createFbsPrimitive(fbb, FbsPType.I64, false);
int dtA = FbsDType.createFbsDType(fbb, FbsType.FbsPrimitive, primA);

int primX = FbsPrimitive.createFbsPrimitive(fbb, FbsPType.I64, false);
int dtX = FbsDType.createFbsDType(fbb, FbsType.FbsPrimitive, primX);
int primY = FbsPrimitive.createFbsPrimitive(fbb, FbsPType.I64, false);
int dtY = FbsDType.createFbsDType(fbb, FbsType.FbsPrimitive, primY);
int innerNames = FbsStruct_.createNamesVector(fbb,
new int[]{fbb.createString("x"), fbb.createString("y")});
int innerDtypes = FbsStruct_.createDtypesVector(fbb, new int[]{dtX, dtY});
int innerStruct = FbsStruct_.createFbsStruct_(fbb, innerNames, innerDtypes, false);
int dtS = FbsDType.createFbsDType(fbb, FbsType.FbsStruct_, innerStruct);

int names = FbsStruct_.createNamesVector(fbb,
new int[]{fbb.createString("a"), fbb.createString("s")});
int dtypes = FbsStruct_.createDtypesVector(fbb, new int[]{dtA, dtS});
int rootStruct = FbsStruct_.createFbsStruct_(fbb, names, dtypes, false);
int dt = FbsDType.createFbsDType(fbb, FbsType.FbsStruct_, rootStruct);
FbsDType.finishFbsDTypeBuffer(fbb, dt);
return MalformedFiles.slice(fbb);
}

/// Layout: root struct(2) → [chunked "a" (flat per chunk), struct(2) "s" → [flat x, flat y]].
/// Both "s" fields are single full-range flats, so the whole subtree is one full-range chunk
/// source alongside "a"'s per-chunk grid.
private static ByteBuffer buildNestedStructLayout(long[] chunkRows, int xSegIdx, int ySegIdx) {
var fbb = new FbsBuilder(1024);
int numChunks = chunkRows.length;
long total = 0;
for (long r : chunkRows) {
total += r;
}
int[] flatOffs = new int[numChunks];
for (int k = 0; k < numChunks; k++) {
int segV = FbsLayout.createSegmentsVector(fbb, new long[]{k});
flatOffs[k] = FbsLayout.createFbsLayout(fbb, 0, chunkRows[k], 0, 0, segV);
}
int chunkedChildV = FbsLayout.createChildrenVector(fbb, flatOffs);
int chunkedA = FbsLayout.createFbsLayout(fbb, 1, total, 0, chunkedChildV, 0);

int xSegV = FbsLayout.createSegmentsVector(fbb, new long[]{xSegIdx});
int flatX = FbsLayout.createFbsLayout(fbb, 0, total, 0, 0, xSegV);
int ySegV = FbsLayout.createSegmentsVector(fbb, new long[]{ySegIdx});
int flatY = FbsLayout.createFbsLayout(fbb, 0, total, 0, 0, ySegV);
int sChildV = FbsLayout.createChildrenVector(fbb, new int[]{flatX, flatY});
int structS = FbsLayout.createFbsLayout(fbb, 2, total, 0, sChildV, 0);

int structChildV = FbsLayout.createChildrenVector(fbb, new int[]{chunkedA, structS});
int structOff = FbsLayout.createFbsLayout(fbb, 2, total, 0, structChildV, 0);
FbsLayout.finishFbsLayoutBuffer(fbb, structOff);
return MalformedFiles.slice(fbb);
}

private static List<Long> values(Array arr) {
List<Long> out = new ArrayList<>();
if (arr instanceof MaskedArray masked) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,28 @@ void indexedClampAndEmptyChunk() {
assertThat(empty.getFloat(0)).isZero();
}

@Test
void materializeConstantRunAndEmptyChunk() {
// Given a constant-run chunk (1 distinct value) and, separately, an empty chunk
// (0 distinct values): materialize's processChunk fast path must emit the lone
// value — or 0.0f for the empty pool — for every row without touching indices.
var constant = new LazyRleFloatArray(F32, 3, new float[]{6.25f}, new int[1024],
new long[]{0L}, 0L, 1L, 1, 0);
var empty = new LazyRleFloatArray(F32, 2, new float[0], new int[1024],
new long[]{0L}, 0L, 0L, 1, 0);

// When / Then
try (var arena = Arena.ofConfined()) {
var constantResult = constant.materialize(arena);
assertThat(constantResult.getAtIndex(VortexFormat.LE_FLOAT, 0)).isEqualTo(6.25f);
assertThat(constantResult.getAtIndex(VortexFormat.LE_FLOAT, 2)).isEqualTo(6.25f);

var emptyResult = empty.materialize(arena);
assertThat(emptyResult.getAtIndex(VortexFormat.LE_FLOAT, 0)).isEqualTo(0.0f);
assertThat(emptyResult.getAtIndex(VortexFormat.LE_FLOAT, 1)).isEqualTo(0.0f);
}
}

@Test
void materializeDecodesEveryRow() {
// Given a mixed chunk; materialize must emit each logical row once, honoring
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,53 @@ void codeOutOfRangeWithPoolValidity_throwsVortexException() {
.hasMessageContaining("out of range for pool validity");
}

@Test
void codesBroadcast_slowPathSetsValidityBits() {
// Given — a single-element codes buffer (codesCap == 1 < rowCount == 4, the
// ConstantEncoding fan-out shape) forces rowValidity's broadcast slow path
// (the `i % codesCap` loop and the setBit S3034 fix site). The lone code points at
// valid pool slot 0, so every row is valid and setBit runs on each iteration.
ArrayNode codesNode = primitiveNode(0);
ArrayNode valuesNode = maskedPrimitiveNode(1, 2);
MemorySegment[] segs = {
u8Codes(0), // 1 code, broadcast across 4 rows
TestSegments.leInts(10, 20),
boolBitmap(true, false) // pool slot 0 valid, slot 1 invalid
};

// When
Array result = decodeDict(DType.I32, PType.U8, 2, 4, segs, codesNode, valuesNode);

// Then — the broadcast code resolves to valid slot 0 for every row
MaskedArray masked = assertMasked(result);
assertValidity(masked, true, true, true, true);
assertIntValues(masked, 10, 10, 10, 10);
}

@ParameterizedTest(name = "codes={0}")
@org.junit.jupiter.params.provider.EnumSource(value = PType.class, names = {"U16", "U32"})
void poolNull_readsWiderCodeWidths(PType codePType) {
// Given — codes at the wider widths (U16/U32) with a pool-null slot. rowValidity must
// read each code at the correct stride via readCode's U16/U32 arms (only exercised when
// pool validity is present), then mask rows whose code points at the dead slot. Here
// codesCap == rowCount, so the fast validity loop runs.
ArrayNode codesNode = primitiveNode(0);
ArrayNode valuesNode = maskedPrimitiveNode(1, 2);
MemorySegment[] segs = {
codeSegment(codePType, new long[]{0, 1, 0, 1}),
TestSegments.leInts(10, 20),
boolBitmap(true, false) // slot 1 invalid
};

// When
Array result = decodeDict(DType.I32, codePType, 2, 4, segs, codesNode, valuesNode);

// Then — rows referencing the dead slot 1 are null; their expanded value is still present
MaskedArray masked = assertMasked(result);
assertValidity(masked, true, false, true, false);
assertIntValues(masked, 10, 20, 10, 20);
}

private Array decodeDict(DType dtype, PType codePType, int valuesLen, long rowCount,
MemorySegment[] segs, ArrayNode codesNode, ArrayNode valuesNode) {
MemorySegment meta = MemorySegment.ofArray(
Expand Down
Loading
Loading