lac/tests/conformance.rs
Kamal Tufekcic 7862cb1d9d
All checks were successful
CI / lint (push) Successful in 5s
CI / fuzz-regression (push) Successful in 14s
CI / build (push) Successful in 4s
CI / test (push) Successful in 6m54s
CI / publish (push) Successful in 8s
initial commit
Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
2026-04-23 14:58:32 +03:00

369 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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!(" }},");
}
}