//! Wire-format conformance fixtures. //! //! Each `DecodeFixture` pins a `(samples, bytes)` pair at the byte //! level. A second implementation of LAC MUST produce `samples` when //! fed `bytes` to its decoder. This test suite is the canonical //! reference for decoder conformance: byte-identical `bytes` across //! implementations aren't required (encoders have latitude in order / //! partition / k selection), but byte-identical decoder output is. //! //! # How this file works //! //! - `DECODE_FIXTURES` holds the pinned vectors. //! - `decode_fixtures` runs each fixture's bytes through `decode_frame` //! and asserts the output matches. This is the conformance test. //! - `encode_matches_fixtures` runs each fixture's samples through the //! reference encoder and asserts the bytes match. This catches //! unintentional drift in the reference's encoder strategy; a //! deliberate change (e.g. adding a new predictor or order) will fail //! this test and require regenerating the fixtures. //! - `generate_vectors` (ignored by default) prints the current //! reference encoder output in a paste-ready format. Run via //! `cargo test --test conformance generate_vectors -- //! --ignored --nocapture` to refresh the fixtures after an //! intentional encoder change. //! //! # Rejection fixtures //! //! `REJECT_FIXTURES` pins header-level malformed inputs to their //! expected `DecodeError` variants. These are hand-constructed — the //! encoder never emits them — so they verify decoder rejection paths //! across implementations. use lac::{DecodeError, decode_frame, encode_frame}; // ── Decode / encode fixtures ──────────────────────────────────────────────── struct DecodeFixture { name: &'static str, samples: &'static [i32], bytes: &'static [u8], } /// Pinned wire-format vectors. Populated from the reference encoder /// via `generate_vectors` below. See `ENCODER_PIN` comment at the top /// of the generator for the rationale on why this doubles as a drift /// canary for the encoder. const DECODE_FIXTURES: &[DecodeFixture] = &[ // ── Degenerate / smallest frames ─────────────────────────────── DecodeFixture { name: "single_zero", samples: &[0], bytes: &[0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x01, 0x04], }, DecodeFixture { name: "silence_4", samples: &[0, 0, 0, 0], bytes: &[0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x04, 0x07, 0x80], }, DecodeFixture { name: "silence_8", samples: &[0, 0, 0, 0, 0, 0, 0, 0], bytes: &[0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x08, 0x07, 0xf8], }, // ── Single-sample polarity + magnitude boundaries ────────────── DecodeFixture { name: "single_pos_one", samples: &[1], bytes: &[0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01], }, DecodeFixture { name: "single_neg_one", samples: &[-1], bytes: &[0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02], }, DecodeFixture { name: "single_full_scale_pos", samples: &[(1 << 23) - 1], bytes: &[ 0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x01, 0xbb, 0xff, 0xff, 0xf8, ], }, DecodeFixture { name: "single_full_scale_neg", samples: &[-((1 << 23) - 1)], bytes: &[ 0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x01, 0xbb, 0xff, 0xff, 0xf4, ], }, // ── DC and near-DC content ───────────────────────────────────── DecodeFixture { name: "dc_100_4", samples: &[100, 100, 100, 100], bytes: &[ 0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x04, 0x3b, 0x21, 0x90, 0xc8, 0x64, 0x00, ], }, // ── Alternating polarity (Nyquist-like) ──────────────────────── DecodeFixture { name: "alternating_small_4", samples: &[1000, -1000, 1000, -1000], bytes: &[ 0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x04, 0x53, 0xe8, 0x3e, 0x7b, 0xe8, 0x3e, 0x78, ], }, // ── Smooth polynomial — fixed predictor territory ────────────── DecodeFixture { name: "linear_ramp_8", samples: &[0, 100, 200, 300, 400, 500, 600, 700], bytes: &[ 0x1a, 0xcc, 0x02, 0x02, 0x02, 0x00, 0x08, 0x40, 0x00, 0xe0, 0x00, 0x34, 0x01, 0x20, 0x18, 0x30, 0x60, ], }, // ── 16-sample growing-amplitude (exercises partition + LPC) ──── DecodeFixture { name: "lfsr_noise_16", samples: &[ 21, -100, 42, -200, 51, -400, 71, -800, 90, -1600, 110, -3200, 130, -6400, 151, -12800, ], bytes: &[ 0x1a, 0xcc, 0x00, 0x01, 0x00, 0x00, 0x10, 0x44, 0xab, 0x8f, 0x54, 0x63, 0xec, 0xc2, 0x3f, 0x8e, 0x02, 0x7e, 0xc8, 0x5a, 0x71, 0xfe, 0x1b, 0x8c, 0x7f, 0xc4, 0x10, 0x47, 0xfe, 0x25, 0xc0, 0x4f, 0xfc, ], }, ]; // ── Rejection fixtures ────────────────────────────────────────────────────── struct RejectFixture { name: &'static str, bytes: &'static [u8], expected: DecodeError, } const REJECT_FIXTURES: &[RejectFixture] = &[ RejectFixture { name: "bad_sync_word", // Sync byte flipped to 0xFF; rest is a well-formed minimal // verbatim header so the decoder only rejects on the first // check. bytes: &[0xFF, 0xCC, 0x00, 0x00, 0x00, 0x00, 0x01], expected: DecodeError::BadSyncWord { got: 0xFFCC }, }, RejectFixture { name: "prediction_order_above_max", bytes: &[0x1A, 0xCC, 0x21, 0x00, 0x00, 0x00, 0x01], expected: DecodeError::InvalidPredictionOrder { got: 33 }, }, RejectFixture { name: "partition_order_above_max", bytes: &[0x1A, 0xCC, 0x00, 0x08, 0x00, 0x00, 0x01], expected: DecodeError::InvalidPartitionOrder { got: 8 }, }, RejectFixture { name: "coefficient_shift_above_max", // Non-zero prediction_order so the shift is actually used. // 2 bytes of (zero) coefficient follow so the header is // structurally valid before the shift check fires. bytes: &[0x1A, 0xCC, 0x01, 0x00, 0x06, 0x00, 0x01, 0x00, 0x00], expected: DecodeError::InvalidCoefficientShift { got: 6 }, }, RejectFixture { name: "coefficient_shift_without_order", // order = 0, shift = 3 — contradictory per spec §3.4. bytes: &[0x1A, 0xCC, 0x00, 0x00, 0x03, 0x00, 0x01], expected: DecodeError::CoefficientShiftWithoutOrder { shift: 3 }, }, RejectFixture { name: "zero_frame_sample_count", bytes: &[0x1A, 0xCC, 0x00, 0x00, 0x00, 0x00, 0x00], expected: DecodeError::InvalidParameter, }, RejectFixture { name: "frame_count_not_divisible_by_partition_count", // partition_order = 3 → 8 partitions, count = 7 doesn't divide. bytes: &[0x1A, 0xCC, 0x00, 0x03, 0x00, 0x00, 0x07], expected: DecodeError::InvalidParameter, }, RejectFixture { name: "truncated_before_header_complete", // Only 3 bytes — below the 7-byte fixed header minimum. bytes: &[0x1A, 0xCC, 0x00], expected: DecodeError::Truncated, }, RejectFixture { name: "truncated_before_coefficients", // prediction_order = 2 claims 4 trailing coefficient bytes, // but only 7 bytes are present. bytes: &[0x1A, 0xCC, 0x02, 0x00, 0x00, 0x00, 0x04], expected: DecodeError::Truncated, }, RejectFixture { name: "truncated_before_rice_bitstream", // Fully valid 7-byte header with count=1 and order=0 (no // coefficients), then no Rice bytes at all. Decoder reads the // header, enters Rice decode, tries to read the 5-bit `k` // field, and fails. Covers the third truncation class in // spec §6 (Rice-bitstream-level, distinct from header and // coefficient-array truncations above). bytes: &[0x1A, 0xCC, 0x00, 0x00, 0x00, 0x00, 0x01], expected: DecodeError::Truncated, }, RejectFixture { name: "rice_k_above_max", // Valid 7-byte verbatim header; first Rice byte stores `k=31` // in its high 5 bits (31 = 0b11111, left-shifted 3 = 0xF8). // The decoder reads `k` and rejects immediately — never // proceeds to the codeword — per spec §4.1 (k must be in // [0, 23]). bytes: &[0x1A, 0xCC, 0x00, 0x00, 0x00, 0x00, 0x01, 0xF8], expected: DecodeError::InvalidParameter, }, ]; // ── Tests ─────────────────────────────────────────────────────────────────── #[test] fn decode_fixtures() { // The canonical conformance check: every fixture's bytes must // decode to the claimed samples. Any second implementation's // decoder that fails this test is non-conformant. for f in DECODE_FIXTURES { if f.bytes.is_empty() { // Placeholder fixture — regenerate with `generate_vectors`. continue; } let decoded = decode_frame(f.bytes) .unwrap_or_else(|e| panic!("fixture {}: decode failed with {e:?}", f.name)); assert_eq!( decoded, f.samples, "fixture {}: decoded samples mismatch", f.name ); } } #[test] fn encode_matches_fixtures() { // Drift canary: the reference encoder currently produces these // exact bytes for these inputs. A deliberate change to the // encoder's search strategy (new predictor, different grid, // different tie-break) will fail this test and should be followed // by regenerating the `bytes` fields via `generate_vectors`. // // Second implementations are not obligated to produce byte- // identical output, so this test is reference-specific. for f in DECODE_FIXTURES { if f.bytes.is_empty() { continue; } let encoded = encode_frame(f.samples); assert_eq!( &encoded[..], f.bytes, "fixture {}: encoder output drifted from pinned bytes", f.name ); } } #[test] fn reject_fixtures() { for f in REJECT_FIXTURES { match decode_frame(f.bytes) { Ok(samples) => panic!( "fixture {}: expected {:?}, got Ok with {} samples", f.name, f.expected, samples.len() ), Err(e) => assert_eq!(e, f.expected, "fixture {}: wrong error variant", f.name), } } } /// Spec §6 rejection class 10: decoder **must** reject any codeword /// whose unary run length exceeds `u32::MAX >> k` (equivalently /// `q > (2³² − 1) / 2^k`). Lives here rather than in `REJECT_FIXTURES` /// because the minimal triggering payload is ~75 bytes of mostly /// zeros — a const array of that shape is noise, and the construction /// logic (`k = 23`, so `q_max = 511`; emit 512 unary zeros; then /// terminator + zero remainder) documents the intent better than a /// hex blob. #[test] fn reject_unary_run_above_cap() { // Header: sync + order=0 + po=0 + shift=0 + count=1. Rice section // begins at byte 7 with no coefficients in between. let mut bytes: Vec = vec![0x1A, 0xCC, 0x00, 0x00, 0x00, 0x00, 0x01]; // Rice payload, built bit-by-bit via the codec's own BitWriter // analogue below. Doing it with raw byte arithmetic here would // duplicate bit-ordering logic that already lives in `bit_io`. // We construct the payload into a separate Vec and append. // // Payload structure (541 bits total, padded to 544 = 68 bytes): // - 5 bits: k = 23 (triggers q_max = u32::MAX >> 23 = 511) // - 512 bits: unary zeros (one past the cap) // - 1 bit: terminator = 1 // - 23 bits: remainder = 0 let mut rice: Vec = Vec::with_capacity(68); let mut bit_accum: u32 = 0; let mut bits_in_accum: u32 = 0; let push_bits = |value: u32, count: u32, rice: &mut Vec, accum: &mut u32, n: &mut u32| { for i in (0..count).rev() { let bit = (value >> i) & 1; *accum = (*accum << 1) | bit; *n += 1; if *n == 8 { rice.push(*accum as u8); *accum = 0; *n = 0; } } }; push_bits(23, 5, &mut rice, &mut bit_accum, &mut bits_in_accum); // k = 23 for _ in 0..512 { push_bits(0, 1, &mut rice, &mut bit_accum, &mut bits_in_accum); } push_bits(1, 1, &mut rice, &mut bit_accum, &mut bits_in_accum); // terminator push_bits(0, 23, &mut rice, &mut bit_accum, &mut bits_in_accum); // remainder // Flush partial byte (left-aligned, matching spec §4.3). if bits_in_accum > 0 { rice.push((bit_accum << (8 - bits_in_accum)) as u8); } bytes.extend_from_slice(&rice); assert_eq!(bytes.len(), 7 + 68, "unexpected fixture length"); assert_eq!( decode_frame(&bytes), Err(DecodeError::InvalidParameter), "q=512 with k=23 exceeds u32::MAX >> k = 511; decoder must reject \ with InvalidParameter per spec §4.2" ); } #[test] #[ignore = "helper for refreshing the pinned byte literals"] fn generate_vectors() { // Prints DECODE_FIXTURES entries in paste-ready format. Run with // cargo test --test conformance generate_vectors -- --ignored --nocapture // then copy the output over the existing fixture bodies. Intended // for use after a deliberate change to encoder strategy; refuses // to run in normal test flow to avoid accidental acceptance of // silent drift. for f in DECODE_FIXTURES { let encoded = encode_frame(f.samples); eprintln!(" DecodeFixture {{"); eprintln!(" name: {:?},", f.name); eprint!(" samples: &["); for (i, s) in f.samples.iter().enumerate() { if i > 0 { eprint!(", "); } eprint!("{s}"); } eprintln!("],"); eprint!(" bytes: &["); for (i, b) in encoded.iter().enumerate() { if i > 0 { eprint!(", "); } eprint!("{b:#04x}"); } eprintln!("],"); eprintln!(" }},"); } }