Skip to content

Commit dbe44aa

Browse files
dfa1claude
andcommitted
test(writer): property-based lossless round-trip for ALP (f32/f64)
Seeded @MethodSource generators feed ~100 random arrays per width — ALP-friendly decimals (clean exponent path), fully-random bit patterns (patch path), and a curated edge array (±0, subnormals, MIN/MAX, NaN, ±Inf) — through encode → decode and assert the result is bit-identical to the input. Far stronger than the existing isCloseTo example tests. Surfaced one documented edge: ALP collapses -0.0 → +0.0 (its round-trip check treats -0.0 == 0.0, so signed zero isn't patched; matches the Rust reference). Value-lossless, not bit-lossless for signed zero — the assertion canonicalizes ±0 and the class doc explains why; everything else must be bit-exact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1ecba3d commit dbe44aa

1 file changed

Lines changed: 101 additions & 0 deletions

File tree

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@
1818
import org.junit.jupiter.api.Test;
1919
import org.junit.jupiter.params.ParameterizedTest;
2020
import org.junit.jupiter.params.provider.CsvSource;
21+
import org.junit.jupiter.params.provider.MethodSource;
2122

2223
import java.lang.foreign.MemorySegment;
2324
import java.nio.ByteBuffer;
2425
import java.nio.ByteOrder;
26+
import java.util.Random;
27+
import java.util.stream.Stream;
2528

2629
import static org.assertj.core.api.Assertions.assertThat;
2730
import static org.assertj.core.api.Assertions.within;
@@ -243,4 +246,102 @@ void encode_f64_metadata_expE_isNonZero() throws Exception {
243246
assertThat(meta.exp_e()).isGreaterThan(0);
244247
}
245248
}
249+
250+
/// Property-based round-trip. ALP is a **lossless** codec: every value either fits the
251+
/// exponent model exactly or is stored verbatim as a patch. So `decode(encode(x))` must
252+
/// equal `x` **bit-for-bit** (raw bits — NaN payloads, subnormals, extremes all checked) —
253+
/// a stronger guarantee than the `isCloseTo` example tests above. Seeded for reproducibility.
254+
///
255+
/// One documented exception: signed zero. ALP's round-trip check treats `-0.0 == 0.0`, so
256+
/// `-0.0` is not patched and decodes to `+0.0`. This is value-lossless but not bit-lossless,
257+
/// and matches the Rust reference (forcing a patch would diverge from it). The assertion
258+
/// canonicalizes ±0 to capture exactly that — everything else must be bit-identical.
259+
@Nested
260+
class PropertyRoundTrip {
261+
262+
@ParameterizedTest
263+
@MethodSource("f64Cases")
264+
void f64_losslessBitExact(double[] values) {
265+
// When
266+
EncodeResult enc = ENCODER.encode(DTypes.F64, values, EncodeTestHelper.testCtx());
267+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(enc, values.length, DTypes.F64, REGISTRY);
268+
DoubleArray result = (DoubleArray) DECODER.decode(ctx);
269+
270+
// Then — bit-exact for every element (±0 canonicalized; see class doc)
271+
for (int i = 0; i < values.length; i++) {
272+
assertThat(canon(result.getDouble(i)))
273+
.as("index %d value %s", i, values[i])
274+
.isEqualTo(canon(values[i]));
275+
}
276+
}
277+
278+
@ParameterizedTest
279+
@MethodSource("f32Cases")
280+
void f32_losslessBitExact(float[] values) {
281+
// When
282+
EncodeResult enc = ENCODER.encode(DTypes.F32, values, EncodeTestHelper.testCtx());
283+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(enc, values.length, DTypes.F32, REGISTRY);
284+
FloatArray result = (FloatArray) DECODER.decode(ctx);
285+
286+
// Then — bit-exact (±0 canonicalized; see class doc)
287+
for (int i = 0; i < values.length; i++) {
288+
assertThat(canon(result.getFloat(i)))
289+
.as("index %d value %s", i, values[i])
290+
.isEqualTo(canon(values[i]));
291+
}
292+
}
293+
294+
private static long canon(double d) {
295+
return d == 0.0 ? 0L : Double.doubleToRawLongBits(d); // map +0.0 and -0.0 to one key
296+
}
297+
298+
private static int canon(float f) {
299+
return f == 0.0f ? 0 : Float.floatToRawIntBits(f);
300+
}
301+
302+
static Stream<double[]> f64Cases() {
303+
Random rng = new Random(0xA1F64L);
304+
Stream.Builder<double[]> b = Stream.builder();
305+
// Curated edges: ±0, subnormals, extremes, non-finite — the classic float corner cases.
306+
b.add(new double[]{0.0, -0.0, 1.0, -1.0, 0.5, -0.25, 100.0, 3.14159,
307+
Double.MIN_VALUE, Double.MAX_VALUE, Double.MIN_NORMAL, 1e-300, 1e300,
308+
Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY});
309+
for (int n = 0; n < 50; n++) {
310+
int len = 1 + rng.nextInt(80);
311+
double[] a = new double[len];
312+
for (int i = 0; i < len; i++) {
313+
a[i] = switch (rng.nextInt(3)) {
314+
// ALP-friendly decimal (clean exponent path)
315+
case 0 -> (rng.nextInt(2_000_000) - 1_000_000) / Math.pow(10, rng.nextInt(7));
316+
// fully random bits (mostly the patch path)
317+
case 1 -> Double.longBitsToDouble(rng.nextLong());
318+
default -> rng.nextGaussian() * Math.pow(10, rng.nextInt(20) - 10);
319+
};
320+
}
321+
b.add(a);
322+
}
323+
return b.build();
324+
}
325+
326+
static Stream<float[]> f32Cases() {
327+
Random rng = new Random(0xA1F32L);
328+
Stream.Builder<float[]> b = Stream.builder();
329+
b.add(new float[]{0.0f, -0.0f, 1.0f, -1.0f, 0.5f, -0.25f, 100.0f, 3.14159f,
330+
Float.MIN_VALUE, Float.MAX_VALUE, Float.MIN_NORMAL, 1e-30f, 1e30f,
331+
Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY});
332+
for (int n = 0; n < 50; n++) {
333+
int len = 1 + rng.nextInt(80);
334+
float[] a = new float[len];
335+
for (int i = 0; i < len; i++) {
336+
a[i] = switch (rng.nextInt(3)) {
337+
case 0 -> (rng.nextInt(2_000_000) - 1_000_000) / (float) Math.pow(10, rng.nextInt(5));
338+
case 1 -> Float.intBitsToFloat(rng.nextInt());
339+
default -> (float) (rng.nextGaussian() * Math.pow(10, rng.nextInt(12) - 6));
340+
};
341+
}
342+
b.add(a);
343+
}
344+
return b.build();
345+
}
346+
}
246347
}

0 commit comments

Comments
 (0)