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}