Skip to content

Commit bbf63f5

Browse files
dfa1claude
andcommitted
test: gate-running interop test for null-fill Sparse and null-run RunEnd (#233)
Four JNI-written fixtures verify that the #225/#226 validity fixes hold on every gate run, not just the weekly Raincloud conformance sweep: - jniNullFillSparse_f64_nullRowsDecodeNull: ~98% null column, JNI chooses vortex.sparse with null fill; every unpatched row must decode as null. - jniNullFillSparse_i64_nullRowsDecodeNull: same shape for i64 (nuclear_share_energy). - jniNullRunRunEnd_i16_nullRowsDecodeNull: i16 null/value runs, JNI picks vortex.runend; null runs must decode as null, not the FoR base. - jniNullRunRunEnd_i64_nullRowsDecodeNull: same for i64. The encoding assertions are hard-coded (JNI version is pinned); a future JNI bump that changes the choice should refresh the fixture. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1d10aa7 commit bbf63f5

1 file changed

Lines changed: 254 additions & 0 deletions

File tree

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
package io.github.dfa1.vortex.integration;
2+
3+
import dev.vortex.api.Session;
4+
import dev.vortex.api.VortexWriter;
5+
import dev.vortex.arrow.ArrowAllocation;
6+
import dev.vortex.jni.NativeLoader;
7+
import io.github.dfa1.vortex.inspect.InspectorTree;
8+
import io.github.dfa1.vortex.reader.ReadRegistry;
9+
import io.github.dfa1.vortex.reader.ScanOptions;
10+
import io.github.dfa1.vortex.reader.VortexReader;
11+
import io.github.dfa1.vortex.reader.array.Array;
12+
import io.github.dfa1.vortex.reader.array.DoubleArray;
13+
import io.github.dfa1.vortex.reader.array.LongArray;
14+
import io.github.dfa1.vortex.reader.array.MaskedArray;
15+
import io.github.dfa1.vortex.reader.array.ShortArray;
16+
import org.apache.arrow.c.ArrowArray;
17+
import org.apache.arrow.c.ArrowSchema;
18+
import org.apache.arrow.c.Data;
19+
import org.apache.arrow.memory.BufferAllocator;
20+
import org.apache.arrow.vector.BigIntVector;
21+
import org.apache.arrow.vector.Float8Vector;
22+
import org.apache.arrow.vector.SmallIntVector;
23+
import org.apache.arrow.vector.VectorSchemaRoot;
24+
import org.apache.arrow.vector.types.FloatingPointPrecision;
25+
import org.apache.arrow.vector.types.pojo.ArrowType;
26+
import org.apache.arrow.vector.types.pojo.Field;
27+
import org.apache.arrow.vector.types.pojo.Schema;
28+
import org.junit.jupiter.api.Test;
29+
import org.junit.jupiter.api.io.TempDir;
30+
31+
import java.io.IOException;
32+
import java.nio.file.Path;
33+
import java.util.ArrayList;
34+
import java.util.HashMap;
35+
import java.util.List;
36+
import java.util.Random;
37+
import java.util.function.ObjIntConsumer;
38+
39+
import static org.assertj.core.api.Assertions.assertThat;
40+
41+
/// Gate-running interop cover for null-fill Sparse (#226) and null-run RunEnd (#225) validity.
42+
///
43+
/// The `RunEnd`/`Sparse` validity fixes are exercised by the weekly Raincloud conformance
44+
/// corpus only; this test reproduces both shapes on every `verify` run from a JNI-written
45+
/// (Rust reference) file, so a validity regression reddens the per-PR gate rather than
46+
/// surfacing a week later.
47+
///
48+
/// The bundled `vortex-jni` compressor is version-pinned, so its encoding choice for a crafted
49+
/// input is deterministic — verified by probing: a mostly-null column with a small fraction of
50+
/// scattered non-`ALP`-able values compresses to `vortex.sparse` with a null fill scalar, and a
51+
/// column of distinct value runs interleaved with null runs compresses to `vortex.runend` with
52+
/// null run-values. Both assertions below therefore hard-check the chosen encoding; a future JNI
53+
/// bump that changes the choice is a deliberate change that should refresh this fixture.
54+
class NullSparseRunEndInteropIntegrationTest {
55+
56+
private static final Session SESSION = Session.create();
57+
private static final BufferAllocator ALLOCATOR = ArrowAllocation.rootAllocator();
58+
private static final int ROWS = 5000;
59+
60+
static {
61+
NativeLoader.loadJni();
62+
}
63+
64+
@Test
65+
void jniNullFillSparse_f64_nullRowsDecodeNull(@TempDir Path tmp) throws IOException {
66+
// Given — a nullable f64 column that is ~98% null with scattered random (non-ALP-able)
67+
// values. This is the world-energy `biofuel_cons_change_pct` shape: the JNI compressor
68+
// picks vortex.sparse with a null fill, so every unpatched row must decode as null (#226).
69+
Schema schema = new Schema(List.of(
70+
Field.nullable("v", new ArrowType.FloatingPoint(FloatingPointPrecision.DOUBLE))));
71+
Random rng = new Random(226);
72+
Double[] expected = new Double[ROWS];
73+
Path file = tmp.resolve("null_fill_sparse_f64.vtx");
74+
writeJni(file, schema, (root, i) -> {
75+
Float8Vector vec = (Float8Vector) root.getVector("v");
76+
if (i % 50 == 0) {
77+
double value = rng.nextDouble() * 1e9;
78+
expected[i] = value;
79+
vec.setSafe(i, value);
80+
} else {
81+
vec.setNull(i);
82+
}
83+
});
84+
85+
// When
86+
List<Double> result = readF64(file);
87+
88+
// Then — encoding is null-fill sparse, and every row round-trips value-for-value
89+
// (null in ⇒ null out, value in ⇒ value out).
90+
assertThat(usedEncodings(file)).contains("vortex.sparse");
91+
assertThat(result).containsExactly(expected);
92+
}
93+
94+
@Test
95+
void jniNullFillSparse_i64_nullRowsDecodeNull(@TempDir Path tmp) throws IOException {
96+
// Given — the nuclear_share_energy shape: a nullable i64 column, ~99% null with a few
97+
// scattered random values; the JNI compressor picks vortex.sparse with a null fill.
98+
Schema schema = new Schema(List.of(Field.nullable("v", new ArrowType.Int(64, true))));
99+
Random rng = new Random(2266);
100+
Long[] expected = new Long[ROWS];
101+
Path file = tmp.resolve("null_fill_sparse_i64.vtx");
102+
writeJni(file, schema, (root, i) -> {
103+
BigIntVector vec = (BigIntVector) root.getVector("v");
104+
if (i % 97 == 0) {
105+
long value = rng.nextLong();
106+
expected[i] = value;
107+
vec.setSafe(i, value);
108+
} else {
109+
vec.setNull(i);
110+
}
111+
});
112+
113+
// When
114+
List<Long> result = readI64(file);
115+
116+
// Then
117+
assertThat(usedEncodings(file)).contains("vortex.sparse");
118+
assertThat(result).containsExactly(expected);
119+
}
120+
121+
@Test
122+
void jniNullRunRunEnd_i16_nullRowsDecodeNull(@TempDir Path tmp) throws IOException {
123+
// Given — the uci-online-retail `customerid` u16? shape: runs of a distinct value per
124+
// block interleaved with whole null runs. The JNI compressor picks vortex.runend; before
125+
// #225 a null run expanded to the run's filler value instead of null.
126+
Schema schema = new Schema(List.of(Field.nullable("v", new ArrowType.Int(16, true))));
127+
Short[] expected = new Short[ROWS];
128+
Path file = tmp.resolve("null_run_runend_i16.vtx");
129+
writeJni(file, schema, (root, i) -> {
130+
SmallIntVector vec = (SmallIntVector) root.getVector("v");
131+
int block = i / 100;
132+
if (block % 4 == 0) {
133+
vec.setNull(i);
134+
} else {
135+
short value = (short) (1000 + block);
136+
expected[i] = value;
137+
vec.setSafe(i, value);
138+
}
139+
});
140+
141+
// When
142+
List<Short> result = readI16(file);
143+
144+
// Then — encoding is run-end, and null runs decode as null (not the run filler).
145+
assertThat(usedEncodings(file)).contains("vortex.runend");
146+
assertThat(result).containsExactly(expected);
147+
}
148+
149+
@Test
150+
void jniNullRunRunEnd_i64_nullRowsDecodeNull(@TempDir Path tmp) throws IOException {
151+
// Given — a nullable i64 column of distinct value runs interleaved with null runs; the
152+
// JNI compressor picks vortex.runend with nullable run-values (#225).
153+
Schema schema = new Schema(List.of(Field.nullable("v", new ArrowType.Int(64, true))));
154+
Long[] expected = new Long[ROWS];
155+
Path file = tmp.resolve("null_run_runend_i64.vtx");
156+
writeJni(file, schema, (root, i) -> {
157+
BigIntVector vec = (BigIntVector) root.getVector("v");
158+
int block = i / 200;
159+
if (block % 3 == 0) {
160+
vec.setNull(i);
161+
} else {
162+
long value = 100_000L + block;
163+
expected[i] = value;
164+
vec.setSafe(i, value);
165+
}
166+
});
167+
168+
// When
169+
List<Long> result = readI64(file);
170+
171+
// Then
172+
assertThat(usedEncodings(file)).contains("vortex.runend");
173+
assertThat(result).containsExactly(expected);
174+
}
175+
176+
// ── JNI write helper ──────────────────────────────────────────────────────
177+
178+
private static void writeJni(Path file, Schema schema, ObjIntConsumer<VectorSchemaRoot> fill) throws IOException {
179+
String uri = file.toAbsolutePath().toUri().toString();
180+
try (VortexWriter writer = VortexWriter.create(SESSION, uri, schema, new HashMap<>(), ALLOCATOR);
181+
VectorSchemaRoot root = VectorSchemaRoot.create(schema, ALLOCATOR)) {
182+
for (var vec : root.getFieldVectors()) {
183+
vec.setInitialCapacity(ROWS);
184+
vec.allocateNew();
185+
}
186+
for (int i = 0; i < ROWS; i++) {
187+
fill.accept(root, i);
188+
}
189+
root.setRowCount(ROWS);
190+
try (ArrowArray arr = ArrowArray.allocateNew(ALLOCATOR);
191+
ArrowSchema as = ArrowSchema.allocateNew(ALLOCATOR)) {
192+
Data.exportVectorSchemaRoot(ALLOCATOR, root, null, arr, as);
193+
writer.writeBatch(arr.memoryAddress(), as.memoryAddress());
194+
}
195+
}
196+
}
197+
198+
// ── Java read helpers ─────────────────────────────────────────────────────
199+
200+
private static List<String> usedEncodings(Path file) throws IOException {
201+
try (VortexReader reader = VortexReader.open(file, ReadRegistry.loadAll())) {
202+
return new ArrayList<>(InspectorTree.build(reader).usedEncodings());
203+
}
204+
}
205+
206+
private static List<Double> readF64(Path file) throws IOException {
207+
var out = new ArrayList<Double>();
208+
forEachValue(file, (masked, inner, i) -> out.add(masked.isValid(i) ? ((DoubleArray) inner).getDouble(i) : null));
209+
return out;
210+
}
211+
212+
private static List<Long> readI64(Path file) throws IOException {
213+
var out = new ArrayList<Long>();
214+
forEachValue(file, (masked, inner, i) -> out.add(masked.isValid(i) ? ((LongArray) inner).getLong(i) : null));
215+
return out;
216+
}
217+
218+
private static List<Short> readI16(Path file) throws IOException {
219+
var out = new ArrayList<Short>();
220+
forEachValue(file, (masked, inner, i) -> out.add(masked.isValid(i) ? ((ShortArray) inner).getShort(i) : null));
221+
return out;
222+
}
223+
224+
/// Scans the single-column `file` and hands each row's validity view, the unwrapped inner
225+
/// payload [Array], and the row index to `consumer`. A nullable column decodes to a
226+
/// [MaskedArray], so this centralizes the unwrap the value readers share.
227+
///
228+
/// @param file the Vortex file to scan
229+
/// @param consumer receiver of `(maskedColumn, innerPayload, rowIndex)` per row
230+
/// @throws IOException if the file cannot be opened or scanned
231+
private static void forEachValue(Path file, RowConsumer consumer) throws IOException {
232+
try (VortexReader reader = VortexReader.open(file, ReadRegistry.loadAll());
233+
var iter = reader.scan(ScanOptions.all())) {
234+
iter.forEachRemaining(chunk -> {
235+
MaskedArray masked = chunk.column("v");
236+
Array inner = masked.inner();
237+
for (long i = 0; i < masked.length(); i++) {
238+
consumer.accept(masked, inner, i);
239+
}
240+
});
241+
}
242+
}
243+
244+
/// Per-row callback used by [#forEachValue(Path, RowConsumer)].
245+
@FunctionalInterface
246+
private interface RowConsumer {
247+
/// Consumes one decoded row.
248+
///
249+
/// @param masked the nullable column as a [MaskedArray] for validity checks
250+
/// @param inner the unwrapped non-nullable payload [Array]
251+
/// @param index the row index within the current chunk
252+
void accept(MaskedArray masked, Array inner, long index);
253+
}
254+
}

0 commit comments

Comments
 (0)