initial commit
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

Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
This commit is contained in:
Kamal Tufekcic 2026-04-23 14:58:32 +03:00
commit 7862cb1d9d
No known key found for this signature in database
2884 changed files with 16797 additions and 0 deletions

369
tests/conformance.rs Normal file
View file

@ -0,0 +1,369 @@
//! 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!(" }},");
}
}