Skip to main content

infotheory/
prediction.rs

1//! Shared online prediction abstractions for byte and bit consumers.
2//!
3//! The crate keeps byte prediction first-class while also exposing a bit-native
4//! layer for consumers whose natural symbol is a bit.  A byte model can be
5//! queried as a bit model through a live prefix-mass view: the model remains a
6//! byte model, but each bit query renormalizes over the surviving byte prefix.
7//!
8//! # Predictor Contract
9//!
10//! All `RateBackendPredictor` (and wrapper) implementations **must emit only
11//! finite, non-negative** probabilities and log-probabilities. Callers (including
12//! `BinaryPrediction` constructors, `BytePrefixMass`, mixture bit paths, and
13//! entropy coders) may rely on this. Non-finite or negative outputs from a
14//! predictor indicate an internal bug and are treated as contract violations
15//! (surfaced via `panic!` with rich context in both debug and release builds for
16//! infallible `BinaryPrediction` constructors and the `binary_prediction_from_*`
17//! helpers).
18//! Legitimate 0.5 / uniform policies remain only in the documented mathematical
19//! cases below (measure-zero conditioning limits, not arithmetic corruption).
20//!
21//! Legitimate 0.5 / uniform policies (distinct from error masking):
22//! - `BytePrefixMass::prediction` returns exact 0.5 when the current subtree
23//!   mass is zero (conditioning on a measure-zero event under the byte model;
24//!   the joint sequence prob is already zero; prevents NaN in coders).
25//! - `from_raw_weights` (and thus public `from_pdf`/`from_log_probs`/`from_cdf`)
26//!   falls back to uniform 1/256 when the *input row* has zero or non-finite
27//!   total mass after sanitizing (construction-time robustness for invalid
28//!   caller-provided PDFs; `from_log_probs` documents the all-invalid case).
29
30/// Online byte-level predictor trait re-exported for prediction-oriented APIs.
31pub use crate::mixture::OnlineBytePredictor;
32
33/// Bit ordering used when factorizing byte symbols.
34#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
35#[non_exhaustive]
36pub enum BitOrder {
37    /// Most-significant bit first, matching Infotheory's AC bitwise fast path.
38    #[default]
39    MsbFirst,
40    /// Least-significant bit first.
41    LsbFirst,
42}
43
44/// Semantic interpretation of a bit stream.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46#[non_exhaustive]
47pub enum BitStreamSemantics {
48    /// Bits are the fixed-width representation of byte symbols.
49    ///
50    /// This is a byte-native view: streams must begin and end on whole-byte
51    /// boundaries, and any provided total bit count must therefore be a
52    /// multiple of `8`.
53    BytePacked {
54        /// Bit ordering used when factorizing each byte symbol.
55        order: BitOrder,
56    },
57    /// Bits are the actual modeled symbols, not a byte factorization.
58    ///
59    /// Native bit backends consume these symbols directly. Byte-native
60    /// backends instead adapt this view to the literal byte symbols `0` and
61    /// `1`, with probabilities renormalized over just those two outcomes.
62    ///
63    /// This therefore supports arbitrary non-multiple-of-8 lengths while
64    /// preserving truthful capability metadata: native bit support remains
65    /// distinguishable from byte-symbol adaptation.
66    BinaryTokens,
67}
68
69impl Default for BitStreamSemantics {
70    fn default() -> Self {
71        // Generic bit sessions default to a byte-native view. Planner configs
72        // use their own binary-token default because AIXI/AIQI interfaces are
73        // commonly not byte-aligned.
74        Self::BytePacked {
75            order: BitOrder::MsbFirst,
76        }
77    }
78}
79
80/// Probability pair for the next binary symbol.
81#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct BinaryPrediction {
83    /// Probability of observing `0`.
84    pub p0: f64,
85    /// Probability of observing `1`.
86    pub p1: f64,
87}
88
89impl BinaryPrediction {
90    /// Construct a normalized binary prediction from `P(1)` with a numerical floor.
91    ///
92    /// Finite values outside `[0, 1]` are clamped after applying the numerical
93    /// floor. Non-finite `floor` values are treated as the default floor.
94    ///
95    /// # Panics
96    ///
97    /// Panics when `p1` is not finite. This constructor is intended for
98    /// predictor outputs that have already satisfied the predictor contract; use
99    /// [`Self::checked_from_prob_one`] for caller-controlled input.
100    pub fn from_prob_one(p1: f64, floor: f64) -> Self {
101        Self::checked_from_prob_one(p1, floor).unwrap_or_else(|| {
102            panic!(
103                "RateBackendPredictor emitted non-finite p1 to BinaryPrediction::from_prob_one; \
104                 this is now a hard contract violation (predictors must emit only finite \
105                 non-negative values). See prediction.rs module docs and BinaryPrediction ctors."
106            )
107        })
108    }
109
110    /// Checked variant of [`Self::from_prob_one`].
111    ///
112    /// Returns `None` when `p1` is not finite. Finite values outside `[0, 1]`
113    /// are clamped using the same floor semantics as [`Self::from_prob_one`].
114    pub fn checked_from_prob_one(p1: f64, floor: f64) -> Option<Self> {
115        if !p1.is_finite() {
116            return None;
117        }
118        let floor = binary_floor(floor);
119        let p1 = p1.clamp(floor, 1.0 - floor);
120        Some(Self { p0: 1.0 - p1, p1 })
121    }
122
123    /// Construct an exact normalized binary prediction from `P(1)`.
124    ///
125    /// This preserves hard support semantics: exact `0` and `1` probabilities
126    /// remain exact. Entropy coders should apply their own finite-count floor at
127    /// the coding boundary rather than here.
128    ///
129    /// # Panics
130    ///
131    /// Panics when `p1` is not finite. This constructor is intended for
132    /// predictor outputs that have already satisfied the predictor contract; use
133    /// [`Self::checked_from_prob_one_exact`] for caller-controlled input.
134    pub fn from_prob_one_exact(p1: f64) -> Self {
135        Self::checked_from_prob_one_exact(p1).unwrap_or_else(|| {
136            panic!(
137                "RateBackendPredictor emitted non-finite p1 to BinaryPrediction::from_prob_one_exact; \
138                 this is now a hard contract violation (predictors must emit only finite \
139                 non-negative values). See prediction.rs module docs and BinaryPrediction ctors."
140            )
141        })
142    }
143
144    /// Checked variant of [`Self::from_prob_one_exact`].
145    ///
146    /// Returns `None` when `p1` is not finite. Finite values outside `[0, 1]`
147    /// are clamped exactly as in [`Self::from_prob_one_exact`].
148    pub fn checked_from_prob_one_exact(p1: f64) -> Option<Self> {
149        if !p1.is_finite() {
150            return None;
151        }
152        let p1 = p1.clamp(0.0, 1.0);
153        Some(Self { p0: 1.0 - p1, p1 })
154    }
155
156    /// Probability of `bit`.
157    #[inline]
158    pub fn prob(self, bit: bool) -> f64 {
159        if bit { self.p1 } else { self.p0 }
160    }
161}
162
163/// Minimal bit predictor interface for true binary consumers.
164pub trait OnlineBitPredictor {
165    /// Optional stream-start hook.
166    fn begin_bit_stream(
167        &mut self,
168        _total_bits: Option<u64>,
169        _semantics: BitStreamSemantics,
170    ) -> Result<(), String> {
171        Ok(())
172    }
173
174    /// Optional stream-finalization hook.
175    fn finish_bit_stream(&mut self) -> Result<(), String> {
176        Ok(())
177    }
178
179    /// Predict the next bit without updating state.
180    fn bit_prediction(&mut self) -> BinaryPrediction;
181
182    /// Observe a bit while fitting/adapting.
183    fn update_bit(&mut self, bit: bool);
184
185    /// Observe a bit as conditioning only.
186    fn update_bit_frozen(&mut self, bit: bool) {
187        self.update_bit(bit);
188    }
189}
190
191/// Live byte-prefix state used to query a 256-way byte PDF as conditional bits.
192#[derive(Clone, Debug)]
193pub struct BytePrefixMass {
194    tree: [f64; 512],
195    node: usize,
196    order: BitOrder,
197    bits_seen: u8,
198    symbol: u8,
199}
200
201const BYTE_PREFIX_TREE_ROOT: usize = 1;
202const BYTE_PREFIX_TREE_LEAF_BASE: usize = 256;
203
204impl BytePrefixMass {
205    /// Build a prefix-mass state from a byte PDF.
206    ///
207    /// Slices shorter than 256 are treated as zero-padded on the right
208    /// (i.e. missing entries contribute 0 mass). This is an explicit
209    /// construction-time contract for the public API (graceful handling of
210    /// partial rows); see also the private `from_raw_weights` and module-level
211    /// docs on legitimate uniform fallbacks.
212    pub fn from_pdf(pdf: &[f64], order: BitOrder) -> Self {
213        let mut weights = [0.0f64; 256];
214        for (idx, weight) in weights.iter_mut().enumerate() {
215            *weight = pdf.get(idx).copied().unwrap_or(0.0);
216        }
217        Self::from_raw_weights(weights, order)
218    }
219
220    /// Build a prefix-mass state from byte log-probabilities or log-weights.
221    ///
222    /// Finite entries are exponentiated after subtracting the maximum finite
223    /// entry for numerical stability. Non-finite entries are treated as zero
224    /// mass, and an all-invalid row therefore falls back to the same uniform
225    /// distribution as [`Self::from_pdf`].
226    ///
227    /// The input slice is truncated to at most 256 entries (excess ignored);
228    /// shorter slices are zero-padded (explicit contract, see [`Self::from_pdf`]).
229    pub fn from_log_probs(log_probs: &[f64], order: BitOrder) -> Self {
230        let log_probs = &log_probs[..log_probs.len().min(256)];
231        let max_log = log_probs
232            .iter()
233            .copied()
234            .filter(|lp| lp.is_finite())
235            .fold(f64::NEG_INFINITY, f64::max);
236        let mut weights = [0.0f64; 256];
237        if max_log.is_finite() {
238            for (weight, &lp) in weights.iter_mut().zip(log_probs.iter()) {
239                *weight = if lp.is_finite() {
240                    (lp - max_log).exp()
241                } else {
242                    0.0
243                };
244            }
245        }
246        Self::from_raw_weights(weights, order)
247    }
248
249    /// Build a prefix-mass state from a normalized byte CDF row.
250    pub fn from_cdf(cdf: [f64; 257], order: BitOrder) -> Self {
251        let mut weights = [0.0f64; 256];
252        for (idx, weight) in weights.iter_mut().enumerate() {
253            *weight = cdf[idx + 1] - cdf[idx];
254        }
255        Self::from_raw_weights(weights, order)
256    }
257
258    fn from_raw_weights(mut weights: [f64; 256], order: BitOrder) -> Self {
259        let mut total = 0.0f64;
260        for weight in &mut weights {
261            *weight = if weight.is_finite() && *weight > 0.0 {
262                *weight
263            } else {
264                0.0
265            };
266            total += *weight;
267        }
268        if !total.is_finite() || total <= 0.0 {
269            weights.fill(1.0 / 256.0);
270        } else {
271            let inv = 1.0 / total;
272            for weight in &mut weights {
273                *weight *= inv;
274            }
275        }
276        Self::from_normalized_pdf(weights, order)
277    }
278
279    fn from_normalized_pdf(pdf: [f64; 256], order: BitOrder) -> Self {
280        let mut tree = [0.0f64; 512];
281        for (symbol, &mass) in pdf.iter().enumerate() {
282            let leaf = BYTE_PREFIX_TREE_LEAF_BASE + byte_prefix_leaf_offset(order, symbol as u8);
283            tree[leaf] = mass;
284        }
285        for node in (1..BYTE_PREFIX_TREE_LEAF_BASE).rev() {
286            tree[node] = tree[node * 2] + tree[node * 2 + 1];
287        }
288        Self {
289            tree,
290            node: BYTE_PREFIX_TREE_ROOT,
291            order,
292            bits_seen: 0,
293            symbol: 0,
294        }
295    }
296
297    /// Query the current conditional probability of the next bit.
298    pub fn prediction(&self) -> BinaryPrediction {
299        if self.is_complete() {
300            return BinaryPrediction::from_prob_one_exact(0.5);
301        }
302        let total = self.tree[self.node];
303        if !total.is_finite() || total <= 0.0 {
304            // Legitimate policy: zero mass under the byte model means we are
305            // conditioning on a measure-zero event for the prefix. The joint
306            // probability of the observed sequence is already 0; returning the
307            // max-entropy distribution (0.5) prevents NaN propagation into
308            // arithmetic coders.
309            return BinaryPrediction::from_prob_one_exact(0.5);
310        }
311        let one = self.tree[self.node * 2 + 1];
312        let p1 = one / total;
313        BinaryPrediction::from_prob_one_exact(p1)
314    }
315
316    /// Observe a bit, discarding the impossible sibling branch.
317    pub fn observe(&mut self, bit: bool) {
318        debug_assert!(
319            !self.is_complete(),
320            "BytePrefixMass::observe called after a full byte was already observed"
321        );
322        if self.is_complete() {
323            return;
324        }
325        self.node = self.node * 2 + usize::from(bit);
326        match self.order {
327            BitOrder::MsbFirst => {
328                self.symbol |= u8::from(bit) << (7 - self.bits_seen);
329            }
330            BitOrder::LsbFirst => {
331                self.symbol |= u8::from(bit) << self.bits_seen;
332            }
333        }
334        self.bits_seen = self.bits_seen.saturating_add(1);
335    }
336
337    /// Whether a full byte has been observed.
338    #[inline]
339    pub fn is_complete(&self) -> bool {
340        self.bits_seen >= 8
341    }
342
343    /// Whether the current byte prefix has consumed at least one bit but has
344    /// not completed a full byte yet.
345    #[inline]
346    pub fn has_partial_bits(&self) -> bool {
347        self.bits_seen > 0 && !self.is_complete()
348    }
349
350    /// Current completed symbol. Meaningful once [`Self::is_complete`] is true.
351    #[inline]
352    pub fn symbol(&self) -> u8 {
353        self.symbol
354    }
355}
356
357#[inline]
358fn byte_prefix_leaf_offset(order: BitOrder, symbol: u8) -> usize {
359    match order {
360        BitOrder::MsbFirst => usize::from(symbol),
361        BitOrder::LsbFirst => usize::from(symbol.reverse_bits()),
362    }
363}
364
365#[inline]
366fn binary_floor(floor: f64) -> f64 {
367    if floor.is_finite() {
368        floor.clamp(1e-12, 0.499_999_999_999)
369    } else {
370        1e-12
371    }
372}
373
374/// Convert a pair of raw probabilities into a normalized [`BinaryPrediction`].
375///
376/// Inputs must be finite and non-negative (predictor contract). When both masses
377/// are exactly zero the conditioning event has measure zero under the model; the
378/// maximum-entropy extension `P(1)=0.5` is returned via [`BinaryPrediction::from_prob_one_exact`].
379#[inline]
380pub(crate) fn binary_prediction_from_probs(p0: f64, p1: f64, floor: f64) -> BinaryPrediction {
381    assert!(
382        p0.is_finite() && p0 >= 0.0 && p1.is_finite() && p1 >= 0.0,
383        "RateBackendPredictor emitted invalid probability to binary_prediction_from_probs: p0={p0}, p1={p1}; \
384         Predictor contract violation (must emit only finite non-negative values)"
385    );
386    let sum: f64 = p0 + p1;
387    if sum > 0.0 {
388        BinaryPrediction::from_prob_one(p1 / sum, floor)
389    } else {
390        // Measure-zero conditioning limit: both symbol masses are exactly zero.
391        BinaryPrediction::from_prob_one_exact(0.5)
392    }
393}
394
395/// Convert a pair of natural-log probabilities into a normalized [`BinaryPrediction`].
396///
397/// Uses a log-max shift before exponentiating to avoid catastrophic underflow
398/// when both `logp0` and `logp1` are very negative (e.g. deep inside a long
399/// conditioning context).  Numerically, shifting by `max(logp0, logp1)` before
400/// calling `exp` keeps the dominant term at `1.0` and the ratio exact.
401///
402/// `floor` is forwarded to [`BinaryPrediction::from_prob_one`] and clamped to
403/// `[1e-12, 0.5)` so that exact zero/one log-probs are softened at the coding
404/// boundary rather than silently propagating infinities.
405#[inline]
406pub(crate) fn binary_prediction_from_log_probs(
407    logp0: f64,
408    logp1: f64,
409    floor: f64,
410) -> BinaryPrediction {
411    if logp0.is_nan() || logp1.is_nan() {
412        panic!(
413            "RateBackendPredictor emitted NaN log probability to binary_prediction_from_log_probs: \
414             logp0={logp0}, logp1={logp1}; contract violation"
415        );
416    }
417    let max_log: f64 = logp0.max(logp1);
418    if max_log.is_infinite() {
419        if max_log == f64::NEG_INFINITY {
420            // Both log-probs are exactly -inf: measure-zero conditioning limit.
421            return BinaryPrediction::from_prob_one_exact(0.5);
422        }
423        panic!(
424            "RateBackendPredictor emitted +Inf log probability to binary_prediction_from_log_probs: \
425             logp0={logp0}, logp1={logp1}; contract violation"
426        );
427    }
428    // After the guards above, each logp is finite or exactly -inf; max_log is finite.
429    // IEEE 754: (-inf) - finite = -inf, and exp(-inf) = 0.0 — no explicit branch needed.
430    let p0: f64 = (logp0 - max_log).exp();
431    let p1: f64 = (logp1 - max_log).exp();
432    binary_prediction_from_probs(p0, p1, floor)
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    fn normalize_pdf_for_test(mut pdf: [f64; 256]) -> [f64; 256] {
440        let sum: f64 = pdf.iter().sum();
441        for p in &mut pdf {
442            *p /= sum;
443        }
444        pdf
445    }
446
447    fn assert_binary_prediction_close(actual: BinaryPrediction, expected: BinaryPrediction) {
448        assert!((actual.p0 - expected.p0).abs() < 1e-12);
449        assert!((actual.p1 - expected.p1).abs() < 1e-12);
450    }
451
452    #[test]
453    fn checked_binary_prediction_constructors_reject_non_finite_prob_one() {
454        for p1 in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
455            assert!(BinaryPrediction::checked_from_prob_one(p1, 0.0).is_none());
456            assert!(BinaryPrediction::checked_from_prob_one_exact(p1).is_none());
457        }
458
459        assert_binary_prediction_close(
460            BinaryPrediction::checked_from_prob_one(1.2, 0.01).expect("finite p1 should construct"),
461            BinaryPrediction {
462                p0: 1.0 - 0.99,
463                p1: 0.99,
464            },
465        );
466        assert_eq!(
467            BinaryPrediction::checked_from_prob_one_exact(-0.5),
468            Some(BinaryPrediction { p0: 1.0, p1: 0.0 })
469        );
470    }
471
472    fn bit_at(symbol: u8, order: BitOrder, bit_idx: u8) -> bool {
473        match order {
474            BitOrder::MsbFirst => ((symbol >> (7 - bit_idx)) & 1) == 1,
475            BitOrder::LsbFirst => ((symbol >> bit_idx) & 1) == 1,
476        }
477    }
478
479    fn extend_observed_prefix(observed: &mut u8, order: BitOrder, bit_idx: u8, bit: bool) {
480        match order {
481            BitOrder::MsbFirst => *observed |= u8::from(bit) << (7 - bit_idx),
482            BitOrder::LsbFirst => *observed |= u8::from(bit) << bit_idx,
483        }
484    }
485
486    fn prefix_matches(value: u8, observed: u8, order: BitOrder, bits_seen: u8) -> bool {
487        for bit_idx in 0..bits_seen {
488            if bit_at(value, order, bit_idx) != bit_at(observed, order, bit_idx) {
489                return false;
490            }
491        }
492        true
493    }
494
495    fn direct_prediction(
496        pdf: &[f64; 256],
497        order: BitOrder,
498        observed: u8,
499        bits_seen: u8,
500    ) -> BinaryPrediction {
501        if bits_seen >= 8 {
502            return BinaryPrediction::from_prob_one_exact(0.5);
503        }
504        let mut p0 = 0.0f64;
505        let mut p1 = 0.0f64;
506        for (value, &mass) in pdf.iter().enumerate() {
507            let value = value as u8;
508            if !prefix_matches(value, observed, order, bits_seen) {
509                continue;
510            }
511            if bit_at(value, order, bits_seen) {
512                p1 += mass;
513            } else {
514                p0 += mass;
515            }
516        }
517        let total = p0 + p1;
518        let p1 = if total.is_finite() && total > 0.0 {
519            p1 / total
520        } else {
521            0.5
522        };
523        BinaryPrediction::from_prob_one_exact(p1)
524    }
525
526    fn cdf_from_pdf(pdf: &[f64; 256]) -> [f64; 257] {
527        let mut cdf = [0.0f64; 257];
528        let mut acc = 0.0f64;
529        for idx in 0..256usize {
530            acc += pdf[idx];
531            cdf[idx + 1] = acc;
532        }
533        cdf
534    }
535
536    #[test]
537    fn byte_prefix_product_matches_symbol_probability_msb() {
538        let mut pdf = [0.0f64; 256];
539        for (idx, slot) in pdf.iter_mut().enumerate() {
540            *slot = (idx + 1) as f64;
541        }
542        let pdf = normalize_pdf_for_test(pdf);
543
544        let symbol = 0b1010_0110u8;
545        let mut prefix = BytePrefixMass::from_pdf(&pdf, BitOrder::MsbFirst);
546        let mut product = 1.0f64;
547        for bit_idx in 0..8u8 {
548            let bit = ((symbol >> (7 - bit_idx)) & 1) == 1;
549            let pred = prefix.prediction();
550            product *= pred.prob(bit);
551            prefix.observe(bit);
552        }
553        assert!(prefix.is_complete());
554        assert_eq!(prefix.symbol(), symbol);
555        assert!((product - pdf[symbol as usize]).abs() < 1e-12);
556    }
557
558    #[test]
559    fn byte_prefix_product_matches_symbol_probability_lsb() {
560        let mut pdf = [0.0f64; 256];
561        for (idx, slot) in pdf.iter_mut().enumerate() {
562            *slot = (idx + 3) as f64;
563        }
564        let pdf = normalize_pdf_for_test(pdf);
565
566        let symbol = 0b1010_0110u8;
567        let mut prefix = BytePrefixMass::from_pdf(&pdf, BitOrder::LsbFirst);
568        let mut product = 1.0f64;
569        for bit_idx in 0..8u8 {
570            let bit = ((symbol >> bit_idx) & 1) == 1;
571            let pred = prefix.prediction();
572            product *= pred.prob(bit);
573            prefix.observe(bit);
574        }
575        assert!(prefix.is_complete());
576        assert_eq!(prefix.symbol(), symbol);
577        assert!((product - pdf[symbol as usize]).abs() < 1e-12);
578    }
579
580    #[test]
581    fn byte_prefix_preserves_zero_mass_until_coder_boundary() {
582        let mut pdf = [0.0f64; 256];
583        pdf[0b1010_0000] = 0.25;
584        pdf[0b1010_0001] = 0.75;
585
586        let mut prefix = BytePrefixMass::from_pdf(&pdf, BitOrder::MsbFirst);
587        for bit_idx in 0..4u8 {
588            let bit = ((0b1010_0000u8 >> (7 - bit_idx)) & 1) == 1;
589            let prediction = prefix.prediction();
590            assert_eq!(prediction.prob(bit), 1.0);
591            assert_eq!(prediction.prob(!bit), 0.0);
592            prefix.observe(bit);
593        }
594
595        let impossible = prefix.prediction();
596        assert_eq!(impossible.p1, 0.0);
597        assert_eq!(impossible.p0, 1.0);
598    }
599
600    #[test]
601    fn byte_prefix_msb_prediction_preserves_tiny_positive_tail_mass() {
602        let eps = 5e-17;
603        let mut pdf = [eps; 256];
604        pdf[0] = 1.0 - (255.0 * eps);
605        let pdf = normalize_pdf_for_test(pdf);
606
607        let prediction = BytePrefixMass::from_pdf(&pdf, BitOrder::MsbFirst).prediction();
608        let expected = pdf[128..256].iter().copied().sum::<f64>();
609        assert!(expected > 0.0);
610        assert!(prediction.p1 > 0.0);
611        assert!((prediction.p1 - expected).abs() < 1e-18);
612    }
613
614    #[test]
615    fn byte_prefix_lsb_prediction_preserves_tiny_positive_tail_mass() {
616        let eps = 5e-17;
617        let mut pdf = [eps; 256];
618        pdf[0] = 1.0 - (255.0 * eps);
619        let pdf = normalize_pdf_for_test(pdf);
620
621        let prediction = BytePrefixMass::from_pdf(&pdf, BitOrder::LsbFirst).prediction();
622        let expected = pdf
623            .iter()
624            .enumerate()
625            .filter(|(idx, _)| (idx & 1) == 1)
626            .map(|(_, &p)| p)
627            .sum::<f64>();
628        assert!(expected > 0.0);
629        assert!(prediction.p1 > 0.0);
630        assert!((prediction.p1 - expected).abs() < 1e-18);
631    }
632
633    #[test]
634    fn byte_prefix_lsb_preserves_zero_mass_until_coder_boundary() {
635        let mut pdf = [0.0f64; 256];
636        pdf[0b0000_1010] = 0.25;
637        pdf[0b1000_1010] = 0.75;
638
639        let symbol = 0b0000_1010u8;
640        let mut prefix = BytePrefixMass::from_pdf(&pdf, BitOrder::LsbFirst);
641        for bit_idx in 0..7u8 {
642            let bit = ((symbol >> bit_idx) & 1) == 1;
643            let prediction = prefix.prediction();
644            assert_eq!(prediction.prob(bit), 1.0);
645            assert_eq!(prediction.prob(!bit), 0.0);
646            prefix.observe(bit);
647        }
648    }
649
650    #[test]
651    fn byte_prefix_prediction_matches_direct_reference_for_both_orders() {
652        let mut pdf = [0.0f64; 256];
653        for (idx, slot) in pdf.iter_mut().enumerate() {
654            *slot = ((idx * 37 + 11) % 257 + 1) as f64;
655        }
656        let pdf = normalize_pdf_for_test(pdf);
657
658        for order in [BitOrder::MsbFirst, BitOrder::LsbFirst] {
659            for symbol in 0u8..=255u8 {
660                let mut prefix = BytePrefixMass::from_pdf(&pdf, order);
661                let mut observed = 0u8;
662                for bit_idx in 0..8u8 {
663                    let expected = direct_prediction(&pdf, order, observed, bit_idx);
664                    let actual = prefix.prediction();
665                    assert_binary_prediction_close(actual, expected);
666
667                    let bit = bit_at(symbol, order, bit_idx);
668                    prefix.observe(bit);
669                    extend_observed_prefix(&mut observed, order, bit_idx, bit);
670                }
671                assert!(prefix.is_complete());
672                assert_eq!(prefix.symbol(), symbol);
673            }
674        }
675    }
676
677    #[test]
678    fn byte_prefix_from_cdf_matches_from_pdf_for_both_orders() {
679        let mut pdf = [0.0f64; 256];
680        for (idx, slot) in pdf.iter_mut().enumerate() {
681            *slot = ((idx * 19 + 7) % 193 + 1) as f64;
682        }
683        let pdf = normalize_pdf_for_test(pdf);
684        let cdf = cdf_from_pdf(&pdf);
685
686        for order in [BitOrder::MsbFirst, BitOrder::LsbFirst] {
687            let symbol = 0b1010_0110u8;
688            let mut from_pdf = BytePrefixMass::from_pdf(&pdf, order);
689            let mut from_cdf = BytePrefixMass::from_cdf(cdf, order);
690            for bit_idx in 0..8u8 {
691                assert_binary_prediction_close(from_pdf.prediction(), from_cdf.prediction());
692                let bit = bit_at(symbol, order, bit_idx);
693                from_pdf.observe(bit);
694                from_cdf.observe(bit);
695            }
696            assert_eq!(from_pdf.symbol(), symbol);
697            assert_eq!(from_cdf.symbol(), symbol);
698        }
699    }
700
701    #[test]
702    fn byte_prefix_from_log_probs_matches_from_pdf_for_both_orders() {
703        let mut pdf = [0.0f64; 256];
704        for (idx, slot) in pdf.iter_mut().enumerate() {
705            *slot = ((idx * 23 + 5) % 211 + 1) as f64;
706        }
707        let pdf = normalize_pdf_for_test(pdf);
708
709        let mut log_probs = [f64::NEG_INFINITY; 256];
710        for (dst, &mass) in log_probs.iter_mut().zip(pdf.iter()) {
711            *dst = mass.ln() + 17.0;
712        }
713
714        for order in [BitOrder::MsbFirst, BitOrder::LsbFirst] {
715            let symbol = 0b1010_0110u8;
716            let mut from_pdf = BytePrefixMass::from_pdf(&pdf, order);
717            let mut from_log_probs = BytePrefixMass::from_log_probs(&log_probs, order);
718            for bit_idx in 0..8u8 {
719                assert_binary_prediction_close(from_pdf.prediction(), from_log_probs.prediction());
720                let bit = bit_at(symbol, order, bit_idx);
721                from_pdf.observe(bit);
722                from_log_probs.observe(bit);
723            }
724            assert_eq!(from_pdf.symbol(), symbol);
725            assert_eq!(from_log_probs.symbol(), symbol);
726        }
727    }
728
729    #[test]
730    fn binary_prediction_from_probs_normalizes_and_floors() {
731        let pred = binary_prediction_from_probs(2.0, 6.0, 1e-6);
732        assert!((pred.p0 - 0.25).abs() < 1e-12);
733        assert!((pred.p1 - 0.75).abs() < 1e-12);
734    }
735
736    #[test]
737    fn binary_prediction_from_probs_both_zero_returns_exact_half() {
738        let pred = binary_prediction_from_probs(0.0, 0.0, 1e-6);
739        assert!((pred.p1 - 0.5).abs() < 1e-12);
740        assert!((pred.p0 - 0.5).abs() < 1e-12);
741    }
742
743    #[test]
744    fn binary_prediction_from_log_probs_both_neg_inf_returns_exact_half() {
745        let pred = binary_prediction_from_log_probs(f64::NEG_INFINITY, f64::NEG_INFINITY, 1e-6);
746        assert!((pred.p1 - 0.5).abs() < 1e-12);
747    }
748
749    #[test]
750    #[should_panic(expected = "invalid probability")]
751    fn binary_prediction_from_probs_panics_on_nan() {
752        let _ = binary_prediction_from_probs(f64::NAN, 0.5, 1e-6);
753    }
754
755    #[test]
756    #[should_panic(expected = "invalid probability")]
757    fn binary_prediction_from_probs_panics_on_negative() {
758        let _ = binary_prediction_from_probs(-1.0, 0.5, 1e-6);
759    }
760
761    #[test]
762    #[should_panic(expected = "NaN log probability")]
763    fn binary_prediction_from_log_probs_panics_on_nan() {
764        let _ = binary_prediction_from_log_probs(f64::NAN, -1.0, 1e-6);
765    }
766
767    #[test]
768    #[should_panic(expected = "+Inf log probability")]
769    fn binary_prediction_from_log_probs_panics_on_pos_inf() {
770        let _ = binary_prediction_from_log_probs(0.0, f64::INFINITY, 1e-6);
771    }
772
773    #[test]
774    fn byte_prefix_partial_bits_reports_only_in_progress_prefixes() {
775        let pdf = [1.0 / 256.0; 256];
776        let mut prefix = BytePrefixMass::from_pdf(&pdf, BitOrder::MsbFirst);
777        assert!(!prefix.has_partial_bits());
778
779        prefix.observe(true);
780        assert!(prefix.has_partial_bits());
781
782        for _ in 1..8u8 {
783            prefix.observe(false);
784        }
785        assert!(prefix.is_complete());
786        assert!(!prefix.has_partial_bits());
787    }
788}