|
18 | 18 | import org.junit.jupiter.api.Test; |
19 | 19 | import org.junit.jupiter.params.ParameterizedTest; |
20 | 20 | import org.junit.jupiter.params.provider.CsvSource; |
| 21 | +import org.junit.jupiter.params.provider.MethodSource; |
21 | 22 |
|
22 | 23 | import java.lang.foreign.MemorySegment; |
23 | 24 | import java.nio.ByteBuffer; |
24 | 25 | import java.nio.ByteOrder; |
| 26 | +import java.util.Random; |
| 27 | +import java.util.stream.Stream; |
25 | 28 |
|
26 | 29 | import static org.assertj.core.api.Assertions.assertThat; |
27 | 30 | import static org.assertj.core.api.Assertions.within; |
@@ -243,4 +246,102 @@ void encode_f64_metadata_expE_isNonZero() throws Exception { |
243 | 246 | assertThat(meta.exp_e()).isGreaterThan(0); |
244 | 247 | } |
245 | 248 | } |
| 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 | + } |
246 | 347 | } |
0 commit comments