Skip to main content

subetha_cxc/
burst_model_sensor.rs

1//! Gilbert-Elliott burst-loss model fitted online from the loss trace, giving
2//! a REAL mean burst length instead of a jitter-ratio heuristic.
3//!
4//! Wireless loss is bursty - a fade or a collision drops several frames in a
5//! row - so the loss process is well modelled by a two-state Markov chain: a
6//! Good state (no loss) and a Bad state (loss), with `p = P(Good -> Bad)` and
7//! `r = P(Bad -> Good)`. Fitting `(p, r)` from the observed loss sequence
8//! yields the mean burst length `1 / r` (the interleave depth needed to spread
9//! a burst across blocks so FEC recovers it) and the steady-state loss
10//! `p / (p + r)`.
11//!
12//! The fit uses Gilbert's moment method on two statistics maintained online:
13//! the marginal loss rate `pi_B = E[X]` and the lag-1 autocorrelation of the
14//! loss indicator `rho1`. For the two-state chain the second eigenvalue is
15//! `1 - p - r`, which equals `rho1`, so:
16//!
17//! ```text
18//!   pi_B = p / (p + r)            (marginal loss rate)
19//!   rho1 = 1 - p - r              (lag-1 autocorrelation)
20//!   => p + r = 1 - rho1
21//!   => p = pi_B * (1 - rho1)
22//!   => r = (1 - pi_B) * (1 - rho1)
23//!   => mean burst length = 1 / r = 1 / [(1 - pi_B)(1 - rho1)]
24//! ```
25//!
26//! Independent loss (`rho1 = 0`) gives a mean burst near 1 (single drops);
27//! correlated loss (`rho1 -> 1`) gives a long mean burst - exactly the signal
28//! the interleaver needs.
29
30/// Minimum samples before the fit is trusted - the autocorrelation needs a
31/// population, and a handful of losses do not pin down `(p, r)`.
32const MIN_SAMPLES: u64 = 100;
33
34/// Minimum loss events before the fit is trusted - autocorrelation of an
35/// almost-all-zero trace is dominated by noise.
36const MIN_LOSSES: u64 = 8;
37
38/// Cap on the reported mean burst length (the interleaver clamps to 16 anyway;
39/// this just keeps a near-degenerate fit from returning a huge number).
40const MAX_MEAN_BURST: f64 = 64.0;
41
42/// Online Gilbert-Elliott fit from the binary loss trace.
43#[derive(Debug, Clone, Default)]
44pub struct BurstModel {
45    n: u64,
46    losses: u64,
47    /// Count of adjacent samples that were BOTH losses (for `E[X_t X_{t+1}]`).
48    pairs: u64,
49    have_prev: bool,
50    prev_lost: bool,
51}
52
53impl BurstModel {
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Fold one loss-trace sample: `true` = the packet/shard was lost.
59    pub fn observe(&mut self, lost: bool) {
60        self.n += 1;
61        if lost {
62            self.losses += 1;
63            if self.have_prev && self.prev_lost {
64                self.pairs += 1;
65            }
66        }
67        self.have_prev = true;
68        self.prev_lost = lost;
69    }
70
71    /// Marginal loss rate `pi_B = E[X]`.
72    fn loss_rate(&self) -> f64 {
73        if self.n == 0 {
74            0.0
75        } else {
76            self.losses as f64 / self.n as f64
77        }
78    }
79
80    /// Lag-1 autocorrelation of the loss indicator, or `None` if undefined
81    /// (degenerate loss rate). Clamped to `0..1`: negative correlation is
82    /// treated as independent (`0`), since the model has no anti-burst regime.
83    fn lag1_autocorr(&self) -> Option<f64> {
84        if self.n < 2 {
85            return None;
86        }
87        let pi = self.loss_rate();
88        let var = pi * (1.0 - pi);
89        if var <= 0.0 {
90            return None;
91        }
92        let exx = self.pairs as f64 / (self.n - 1) as f64;
93        Some(((exx - pi * pi) / var).clamp(0.0, 0.999))
94    }
95
96    /// Fitted `(p, r)` transition probabilities, or `None` before a trustworthy
97    /// fit.
98    pub fn fit(&self) -> Option<(f64, f64)> {
99        if self.n < MIN_SAMPLES || self.losses < MIN_LOSSES {
100            return None;
101        }
102        let pi = self.loss_rate();
103        if pi <= 0.0 || pi >= 1.0 {
104            return None;
105        }
106        let rho1 = self.lag1_autocorr()?;
107        let one_minus = 1.0 - rho1;
108        let p = pi * one_minus;
109        let r = (1.0 - pi) * one_minus;
110        if r <= 0.0 {
111            return None;
112        }
113        Some((p, r))
114    }
115
116    /// Mean burst length `1 / r` (consecutive losses), or `None` before a fit.
117    pub fn mean_burst_len(&self) -> Option<f64> {
118        self.fit().map(|(_p, r)| (1.0 / r).clamp(1.0, MAX_MEAN_BURST))
119    }
120
121    /// Steady-state loss `p / (p + r)` (equals the marginal loss rate by
122    /// construction), or `None` before a fit.
123    pub fn steady_loss(&self) -> Option<f64> {
124        self.fit().map(|(p, r)| p / (p + r))
125    }
126
127    /// Samples folded so far (diagnostics).
128    pub fn samples(&self) -> u64 {
129        self.n
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    /// Drive the model with a synthetic two-state Markov trace of known
138    /// `(p, r)` and confirm the fit recovers the mean burst length within
139    /// tolerance. A simple xorshift keeps it deterministic without `rand`.
140    fn run_gilbert(p: f64, r: f64, n: u64, seed: u64) -> BurstModel {
141        let mut m = BurstModel::new();
142        let mut state_bad = false;
143        let mut x = seed | 1;
144        let mut next = || {
145            // xorshift64 -> uniform in [0, 1).
146            x ^= x << 13;
147            x ^= x >> 7;
148            x ^= x << 17;
149            (x >> 11) as f64 / (1u64 << 53) as f64
150        };
151        for _ in 0..n {
152            if state_bad {
153                m.observe(true);
154                if next() < r {
155                    state_bad = false;
156                }
157            } else {
158                m.observe(false);
159                if next() < p {
160                    state_bad = true;
161                }
162            }
163        }
164        m
165    }
166
167    #[test]
168    fn recovers_bursty_mean_length() {
169        // p = 0.02 (rarely enter a burst), r = 0.2 (bursts ~5 long).
170        let m = run_gilbert(0.02, 0.2, 200_000, 0x1234_5678);
171        let mean = m.mean_burst_len().expect("fitted");
172        assert!(
173            (3.5..=7.0).contains(&mean),
174            "mean burst {mean} should recover ~5 (1/r = 1/0.2)"
175        );
176    }
177
178    #[test]
179    fn independent_loss_has_unit_burst() {
180        // p = r path of an independent Bernoulli(0.1): enter and leave the bad
181        // state at the same rate, so bursts are ~1 (no correlation).
182        let m = run_gilbert(0.1, 0.9, 200_000, 0xdead_beef);
183        let mean = m.mean_burst_len().expect("fitted");
184        assert!(
185            mean < 1.6,
186            "independent loss mean burst {mean} should be near 1"
187        );
188    }
189
190    #[test]
191    fn distinguishes_bursty_from_independent() {
192        let bursty = run_gilbert(0.02, 0.2, 200_000, 1).mean_burst_len().unwrap();
193        let indep = run_gilbert(0.1, 0.9, 200_000, 2).mean_burst_len().unwrap();
194        assert!(
195            bursty > indep * 2.0,
196            "bursty {bursty} must clearly exceed independent {indep}"
197        );
198    }
199
200    #[test]
201    fn withholds_before_enough_data() {
202        let mut m = BurstModel::new();
203        for _ in 0..50 {
204            m.observe(false);
205        }
206        m.observe(true);
207        assert!(m.mean_burst_len().is_none(), "withheld before MIN_SAMPLES / MIN_LOSSES");
208    }
209}