369 lines
14 KiB
Rust
369 lines
14 KiB
Rust
//! 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<u8> = 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<u8> = 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<u8>, 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!(" }},");
|
||
}
|
||
}
|