Skip to content

Commit d3d245a

Browse files
dfa1claude
andcommitted
test(writer): property + mutation coverage for Delta/AlpRd encoders
Add seeded-random round-trip property tests for the fastlanes.delta and vortex.alprd encoders, and bring both under the writer pitest scope. - Delta: cover all eight integer ptypes and sizes 0/1/5/1024/1025/3000, exercising the multi-chunk loop, cross-chunk transpose and offset-slice tail that the prior <16-element cases never reached; assert the zone-map min/max stats (signed int64 + unsigned uint64 ordering) and the empty no-stats guard. - AlpRd: bit-exact round-trip over random finite f32/f64 across the same sizes, hitting the exception-heavy and multi-chunk paths. - Add accepts() coverage for both sides. Pitest: Delta survivors 22->8, writer test strength 82%->85%. The remaining survivors are round-trip-invariant (Delta internal base/padding representation; AlpRd dictionary-selection heuristic) and are pinned by the Rust interop and file-size integration tests, not by Java round-trips. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8a1b5db commit d3d245a

3 files changed

Lines changed: 246 additions & 0 deletions

File tree

writer/pom.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
<param>io.github.dfa1.vortex.writer.WriteRegistry</param>
8383
<param>io.github.dfa1.vortex.writer.WriteRegistry$Builder</param>
8484
<param>io.github.dfa1.vortex.writer.VortexWriter</param>
85+
<param>io.github.dfa1.vortex.writer.encode.DeltaEncodingEncoder</param>
86+
<param>io.github.dfa1.vortex.writer.encode.AlpRdEncodingEncoder</param>
8587
</targetClasses>
8688
</configuration>
8789
</plugin>

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.github.dfa1.vortex.writer.encode;
22

33
import io.github.dfa1.vortex.encoding.DTypes;
4+
import io.github.dfa1.vortex.reader.array.DoubleArray;
5+
import io.github.dfa1.vortex.reader.array.FloatArray;
46
import io.github.dfa1.vortex.reader.decode.DecodeContext;
57

68
import io.github.dfa1.vortex.reader.ReadRegistry;
@@ -10,6 +12,12 @@
1012
import io.github.dfa1.vortex.reader.decode.BitpackedEncodingDecoder;
1113
import io.github.dfa1.vortex.reader.decode.PrimitiveEncodingDecoder;
1214
import org.junit.jupiter.api.Test;
15+
import org.junit.jupiter.params.ParameterizedTest;
16+
import org.junit.jupiter.params.provider.Arguments;
17+
import org.junit.jupiter.params.provider.MethodSource;
18+
19+
import java.util.Random;
20+
import java.util.stream.Stream;
1321

1422
import static org.assertj.core.api.Assertions.assertThat;
1523
import static org.assertj.core.api.Assertions.within;
@@ -72,4 +80,94 @@ void encode_f64_metadata_rightBitWidth_isNonZero() throws Exception {
7280
// Then
7381
assertThat(meta.right_bit_width()).isGreaterThan(0);
7482
}
83+
84+
// Property test: ALPRD is a lossless raw-bit split (dictionary left parts + bit-packed right parts,
85+
// exceptions stored verbatim), so the round-trip must be *bit-exact* for arbitrary finite values —
86+
// including -0.0 and exception-heavy random data where most left parts miss the 8-entry dictionary.
87+
// Sizes past 512 (the sampling window) and past one bit-pack chunk exercise the exception + multi-
88+
// chunk paths the 5-element happy-path tests never reach.
89+
@ParameterizedTest(name = "f64/{0}")
90+
@MethodSource("sizes")
91+
void encodeDecode_randomF64_isBitExact(int n) {
92+
// Given
93+
double[] values = randomDoubles(n, new Random(0xA1B2C3D4L + n));
94+
var decoder = new AlpRdEncodingDecoder();
95+
ReadRegistry registry = TestRegistry.ofDecoders(decoder, new BitpackedEncodingDecoder(), new PrimitiveEncodingDecoder());
96+
97+
// When
98+
EncodeResult encoded = new AlpRdEncodingEncoder().encode(DTypes.F64, values, EncodeTestHelper.testCtx());
99+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(encoded, n, DTypes.F64, registry);
100+
DoubleArray result = (DoubleArray) decoder.decode(ctx);
101+
102+
// Then
103+
assertThat(result.length()).isEqualTo(n);
104+
for (int i = 0; i < n; i++) {
105+
assertThat(Double.doubleToRawLongBits(result.getDouble(i)))
106+
.as("idx %d", i).isEqualTo(Double.doubleToRawLongBits(values[i]));
107+
}
108+
}
109+
110+
@ParameterizedTest(name = "f32/{0}")
111+
@MethodSource("sizes")
112+
void encodeDecode_randomF32_isBitExact(int n) {
113+
// Given
114+
float[] values = randomFloats(n, new Random(0xE5F60718L + n));
115+
var decoder = new AlpRdEncodingDecoder();
116+
ReadRegistry registry = TestRegistry.ofDecoders(decoder, new BitpackedEncodingDecoder(), new PrimitiveEncodingDecoder());
117+
118+
// When
119+
EncodeResult encoded = new AlpRdEncodingEncoder().encode(DTypes.F32, values, EncodeTestHelper.testCtx());
120+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(encoded, n, DTypes.F32, registry);
121+
FloatArray result = (FloatArray) decoder.decode(ctx);
122+
123+
// Then
124+
assertThat(result.length()).isEqualTo(n);
125+
for (int i = 0; i < n; i++) {
126+
assertThat(Float.floatToRawIntBits(result.getFloat(i)))
127+
.as("idx %d", i).isEqualTo(Float.floatToRawIntBits(values[i]));
128+
}
129+
}
130+
131+
@Test
132+
void accepts_floatPtypesOnly() {
133+
// Given / When / Then — only F32/F64 are encodable; integers and non-primitives are rejected
134+
var encoder = new AlpRdEncodingEncoder();
135+
var decoder = new AlpRdEncodingDecoder();
136+
assertThat(encoder.accepts(DTypes.F32)).isTrue();
137+
assertThat(encoder.accepts(DTypes.F64)).isTrue();
138+
assertThat(decoder.accepts(DTypes.F32)).isTrue();
139+
assertThat(decoder.accepts(DTypes.F64)).isTrue();
140+
assertThat(encoder.accepts(DTypes.I64)).isFalse();
141+
assertThat(encoder.accepts(DTypes.UTF8)).isFalse();
142+
assertThat(decoder.accepts(DTypes.I32)).isFalse();
143+
}
144+
145+
private static Stream<Arguments> sizes() {
146+
// 0 → empty path; 1/5 → sub-sample; 1024/1025/3000 → past the 512 sample window + multi-chunk.
147+
return Stream.of(0, 1, 5, 1024, 1025, 3000).map(Arguments::of);
148+
}
149+
150+
private static double[] randomDoubles(int n, Random rng) {
151+
double[] a = new double[n];
152+
for (int i = 0; i < n; i++) {
153+
double d;
154+
do {
155+
d = Double.longBitsToDouble(rng.nextLong());
156+
} while (!Double.isFinite(d));
157+
a[i] = d;
158+
}
159+
return a;
160+
}
161+
162+
private static float[] randomFloats(int n, Random rng) {
163+
float[] a = new float[n];
164+
for (int i = 0; i < n; i++) {
165+
float f;
166+
do {
167+
f = Float.intBitsToFloat(rng.nextInt());
168+
} while (!Float.isFinite(f));
169+
a[i] = f;
170+
}
171+
return a;
172+
}
75173
}

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

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.github.dfa1.vortex.writer.encode;
22

3+
import io.github.dfa1.vortex.core.DType;
4+
import io.github.dfa1.vortex.core.PType;
35
import io.github.dfa1.vortex.reader.array.Array;
46
import io.github.dfa1.vortex.encoding.DTypes;
57
import io.github.dfa1.vortex.reader.decode.DecodeContext;
@@ -8,15 +10,21 @@
810
import io.github.dfa1.vortex.reader.ReadRegistry;
911
import io.github.dfa1.vortex.reader.decode.TestRegistry;
1012
import io.github.dfa1.vortex.proto.DeltaMetadata;
13+
import io.github.dfa1.vortex.proto.ScalarValue;
1114
import io.github.dfa1.vortex.reader.decode.DeltaEncodingDecoder;
1215
import io.github.dfa1.vortex.reader.decode.PrimitiveEncodingDecoder;
1316
import org.junit.jupiter.api.Test;
1417
import org.junit.jupiter.params.ParameterizedTest;
1518
import org.junit.jupiter.params.provider.Arguments;
1619
import org.junit.jupiter.params.provider.MethodSource;
20+
import org.junit.jupiter.params.provider.ValueSource;
1721

1822
import java.lang.foreign.Arena;
1923
import java.lang.foreign.MemorySegment;
24+
import java.lang.foreign.ValueLayout;
25+
import java.util.ArrayList;
26+
import java.util.List;
27+
import java.util.Random;
2028
import java.util.stream.Stream;
2129

2230
import static org.assertj.core.api.Assertions.assertThat;
@@ -120,4 +128,142 @@ void encode_i64_metadata_deltasLen_isNonZero() throws Exception {
120128
// Then
121129
assertThat(meta.deltas_len()).isGreaterThan(0);
122130
}
131+
132+
// Property test: seeded-random arrays across every accepted integer ptype and a range of sizes.
133+
// The hand-picked cases above all stay under one FastLanes chunk (1024); the 1024/1025/3000 sizes
134+
// here exercise the multi-chunk loop, the cross-chunk transpose, and the offset-slice tail — the
135+
// bulk of the encode/decode logic that small arrays never reach.
136+
@ParameterizedTest(name = "{0}")
137+
@MethodSource("randomIntegerArrays")
138+
void encodeDecode_randomAcrossPtypesAndSizes_isLossless(String name, DType dtype, Object data, int n) {
139+
// Given
140+
EncodeResult encoded = ENCODER.encode(dtype, data, EncodeTestHelper.testCtx());
141+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(encoded, n, dtype, REGISTRY);
142+
143+
// When
144+
Array result = DECODER.decode(ctx);
145+
146+
// Then — round-trip reproduces every element's raw bytes exactly
147+
assertThat(result.length()).isEqualTo(n);
148+
MemorySegment seg = result.materialize(Arena.ofAuto());
149+
PType ptype = ((DType.Primitive) dtype).ptype();
150+
for (int i = 0; i < n; i++) {
151+
long off = (long) i * ptype.byteSize();
152+
switch (ptype) {
153+
case I8, U8 -> assertThat(seg.get(ValueLayout.JAVA_BYTE, off)).as("idx %d", i).isEqualTo(((byte[]) data)[i]);
154+
case I16, U16 -> assertThat(seg.get(PTypeIO.LE_SHORT, off)).as("idx %d", i).isEqualTo(((short[]) data)[i]);
155+
case I32, U32 -> assertThat(seg.get(PTypeIO.LE_INT, off)).as("idx %d", i).isEqualTo(((int[]) data)[i]);
156+
case I64, U64 -> assertThat(seg.get(PTypeIO.LE_LONG, off)).as("idx %d", i).isEqualTo(((long[]) data)[i]);
157+
default -> throw new AssertionError(ptype);
158+
}
159+
}
160+
}
161+
162+
@ParameterizedTest
163+
@ValueSource(strings = {"I8", "I16", "I32", "I64", "U8", "U16", "U32", "U64"})
164+
void accepts_everyIntegerPtype_isTrue(String ptype) {
165+
// Given / When / Then
166+
assertThat(ENCODER.accepts(new DType.Primitive(PType.valueOf(ptype), false))).isTrue();
167+
assertThat(DECODER.accepts(new DType.Primitive(PType.valueOf(ptype), false))).isTrue();
168+
}
169+
170+
@Test
171+
void accepts_nonIntegerOrNonPrimitive_isFalse() {
172+
// Given / When / Then — floats and non-primitive dtypes are rejected by both sides
173+
assertThat(ENCODER.accepts(DTypes.F64)).isFalse();
174+
assertThat(ENCODER.accepts(DTypes.UTF8)).isFalse();
175+
assertThat(DECODER.accepts(DTypes.F32)).isFalse();
176+
assertThat(DECODER.accepts(DTypes.BOOL)).isFalse();
177+
}
178+
179+
@Test
180+
void encode_signedI64_statsCarryMinAndMax() throws Exception {
181+
// Given — unordered; min/max are interior so a broken scan (negated compare) picks a wrong value
182+
long[] data = {30L, -10L, 50L, 20L, 40L};
183+
184+
// When
185+
EncodeResult result = ENCODER.encode(DTypes.I64, data, EncodeTestHelper.testCtx());
186+
187+
// Then — signed stats use the int64 scalar field, min/max by signed ordering
188+
assertThat(result.hasStats()).isTrue();
189+
assertThat(scalar(result.statsMin()).int64_value()).isEqualTo(-10L);
190+
assertThat(scalar(result.statsMax()).int64_value()).isEqualTo(50L);
191+
}
192+
193+
@Test
194+
void encode_unsignedU64_statsUseUnsignedOrderingAndField() throws Exception {
195+
// Given — -1L is the max value under unsigned ordering but the min under signed ordering, so this
196+
// pins both the unsigned compare (lines 57/60) and the unsigned stats field (isUnsigned/statsBytes)
197+
long[] data = {1L, -1L, 5L};
198+
199+
// When
200+
EncodeResult result = ENCODER.encode(DTypes.U64, data, EncodeTestHelper.testCtx());
201+
202+
// Then
203+
assertThat(scalar(result.statsMin()).uint64_value()).isEqualTo(1L);
204+
assertThat(scalar(result.statsMax()).uint64_value()).isEqualTo(-1L);
205+
}
206+
207+
@Test
208+
void encode_empty_hasNoStats() {
209+
// Given / When — the n>0 guard must suppress stats for an empty array
210+
EncodeResult result = ENCODER.encode(DTypes.I64, new long[0], EncodeTestHelper.testCtx());
211+
212+
// Then
213+
assertThat(result.statsMin()).isNull();
214+
assertThat(result.statsMax()).isNull();
215+
assertThat(result.hasStats()).isFalse();
216+
}
217+
218+
private static ScalarValue scalar(byte[] bytes) throws java.io.IOException {
219+
MemorySegment seg = MemorySegment.ofArray(bytes);
220+
return ScalarValue.decode(seg, 0, seg.byteSize());
221+
}
222+
223+
private static Stream<Arguments> randomIntegerArrays() {
224+
Random rng = new Random(0xD317A1L);
225+
// 0 → empty path; 1/5 → sub-chunk; 1024 → exactly one chunk; 1025/3000 → multi-chunk + tail slice.
226+
int[] sizes = {0, 1, 5, 1024, 1025, 3000};
227+
DType[] dtypes = {DTypes.I8, DTypes.I16, DTypes.I32, DTypes.I64, DTypes.U8, DTypes.U16, DTypes.U32, DTypes.U64};
228+
List<Arguments> out = new ArrayList<>();
229+
for (DType dtype : dtypes) {
230+
PType ptype = ((DType.Primitive) dtype).ptype();
231+
for (int n : sizes) {
232+
out.add(Arguments.of(ptype + "/" + n, dtype, randomArray(ptype, n, rng), n));
233+
}
234+
}
235+
return out.stream();
236+
}
237+
238+
private static Object randomArray(PType ptype, int n, Random rng) {
239+
return switch (ptype) {
240+
case I8, U8 -> {
241+
byte[] a = new byte[n];
242+
rng.nextBytes(a);
243+
yield a;
244+
}
245+
case I16, U16 -> {
246+
short[] a = new short[n];
247+
for (int i = 0; i < n; i++) {
248+
a[i] = (short) rng.nextInt();
249+
}
250+
yield a;
251+
}
252+
case I32, U32 -> {
253+
int[] a = new int[n];
254+
for (int i = 0; i < n; i++) {
255+
a[i] = rng.nextInt();
256+
}
257+
yield a;
258+
}
259+
case I64, U64 -> {
260+
long[] a = new long[n];
261+
for (int i = 0; i < n; i++) {
262+
a[i] = rng.nextLong();
263+
}
264+
yield a;
265+
}
266+
default -> throw new AssertionError(ptype);
267+
};
268+
}
123269
}

0 commit comments

Comments
 (0)