viterbi 0.0.1

Pure-Rust, no-unsafe convolutional encoder and hard-decision Viterbi (MLSE) decoder — CCSDS K=7 R=1/2 inner code for concatenated FEC.
Documentation
// Author: BolivarTech
// Version: 1.0.0
// Date: 2026-07-02
//! MSB-first bit <-> byte packing and the block wrappers (R16). Sole owner of this conversion.

/// A byte-packed coded block carrying its exact bit length. Fields are `pub` for ergonomic
/// construction, but a caller-built `CodedBlock` is **fully validated by `decode_block`** before use
/// (`nbits` multiple of `N`, within the byte buffer, >= the tail) — an inconsistent one yields
/// `DecodeError::LengthMismatch`, never UB or a panic (R16).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodedBlock {
    pub bytes: Vec<u8>,
    pub nbits: usize,
}
/// A byte-packed decoded (info) block carrying its exact bit length.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedBlock {
    pub bytes: Vec<u8>,
    pub nbits: usize,
}

/// Set the MSB-first bit at index `i` (bit 7 of byte 0 is index 0) to 1.
///
/// The **single source** of the MSB-first bit-order convention for the whole crate (R16): every
/// producer of packed bytes routes its bit writes through here so the convention lives in one place.
pub(crate) fn set_bit(bytes: &mut [u8], i: usize) {
    bytes[i / 8] |= 1 << (7 - (i % 8));
}

/// Read the MSB-first bit at index `i` as 0/1.
///
/// The **single source** of the MSB-first bit-order convention for the whole crate (R16): every
/// consumer of packed bytes routes its bit reads through here.
pub(crate) fn get_bit(bytes: &[u8], i: usize) -> u8 {
    (bytes[i / 8] >> (7 - (i % 8))) & 1
}

/// Pack `nbits` bits (each 0/1, MSB-first within each byte) into bytes; the final byte is zero-padded.
///
/// Allocating convenience form used only by tests; the hot paths use [`pack_into`]. Gated behind
/// `#[cfg(test)]` because it has no non-test callers (the decoder packs via [`pack_into`]).
#[cfg(test)]
pub(crate) fn pack(bits: &[u8], nbits: usize) -> Vec<u8> {
    let mut out = vec![0u8; nbits.div_ceil(8)];
    for (i, &b) in bits.iter().take(nbits).enumerate() {
        if b & 1 == 1 {
            set_bit(&mut out, i);
        }
    }
    out
}

/// Unpack the first `nbits` bits (MSB-first) of `bytes` into a `Vec` of 0/1 samples.
///
/// Allocating convenience form used only by tests; the hot paths use [`unpack_into`]. Gated behind
/// `#[cfg(test)]` because it has no non-test callers (the decoder unpacks via [`unpack_into`]).
#[cfg(test)]
pub(crate) fn unpack(bytes: &[u8], nbits: usize) -> Vec<u8> {
    (0..nbits).map(|i| get_bit(bytes, i)).collect()
}

/// In-place unpack into a caller-owned buffer (cleared, then filled) — the reuse-friendly form used
/// by the decoder so hot paths avoid per-call allocation. Same MSB-first convention as [`unpack`].
pub(crate) fn unpack_into(bytes: &[u8], nbits: usize, out: &mut Vec<u8>) {
    // Internal contract: callers size `bytes` to hold `nbits` bits. The `decode_block` path validates
    // this before calling; the debug_assert documents/checks it (release relies on the validated caller).
    debug_assert!(
        nbits.div_ceil(8) <= bytes.len(),
        "unpack_into: bytes too short for nbits"
    );
    out.clear();
    for i in 0..nbits {
        out.push(get_bit(bytes, i));
    }
}

/// In-place pack into a caller-owned buffer (resized, then filled). Same MSB-first convention as [`pack`].
pub(crate) fn pack_into(bits: &[u8], nbits: usize, out: &mut Vec<u8>) {
    // Internal contract: callers provide at least `nbits` bit-samples. `take(nbits)` degrades gracefully
    // (missing bits pack as 0) rather than panicking; the debug_assert documents/checks the precondition.
    debug_assert!(
        bits.len() >= nbits,
        "pack_into: fewer than nbits input bits"
    );
    out.clear();
    out.resize(nbits.div_ceil(8), 0);
    for (i, &b) in bits.iter().take(nbits).enumerate() {
        if b & 1 == 1 {
            set_bit(out, i);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;
    #[test]
    fn pack_msb_first_byte_aligned() {
        assert_eq!(pack(&[1, 0, 1, 1, 0, 0, 0, 0], 8), vec![0xB0]);
    }
    #[test]
    fn pack_non_byte_aligned_zero_pads() {
        assert_eq!(pack(&[1, 0, 1], 3), vec![0xA0]); // 101 00000
    }
    #[test]
    fn unpack_is_inverse_of_pack() {
        let bits = [1, 0, 1, 1, 0, 1, 0, 0, 1, 0];
        assert_eq!(unpack(&pack(&bits, bits.len()), bits.len()), bits);
    }
    #[test]
    fn empty_round_trips() {
        assert_eq!(pack(&[], 0), Vec::<u8>::new());
        assert_eq!(unpack(&[], 0), Vec::<u8>::new());
    }
    #[test]
    fn pack_into_matches_pack_incl_non_byte_aligned() {
        // `pack_into` is the reuse-friendly form; it must produce the same bytes as `pack`,
        // including a non-byte-aligned length (final byte zero-padded, MSB-first).
        let bits = [1u8, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1]; // 11 bits (not a multiple of 8)
        for &n in &[0usize, 3, 8, 11] {
            let mut out = Vec::new();
            pack_into(&bits, n, &mut out);
            assert_eq!(
                out,
                pack(&bits, n),
                "pack_into must match pack for nbits={n}"
            );
        }
    }

    proptest! {
        /// R14/R16: `unpack(pack(bits, n), n) == bits` for random 0/1 bit vectors of random length,
        /// including non-multiples of 8 (final-byte zero padding is unambiguous).
        #[test]
        fn unpack_pack_bit_roundtrip(bits in proptest::collection::vec(0u8..=1, 0..300)) {
            let n = bits.len();
            prop_assert_eq!(unpack(&pack(&bits, n), n), bits);
        }
    }
}