Skip to main content

subetha_cxc/
loss_class_sensor.rs

1//! Loss-class sensor: congestion-vs-wireless loss differentiation.
2//!
3//! A lost shard means two very different things. A *congestion* drop says the
4//! path is overfull: raise parity broadly and ease off the gas. A *wireless*
5//! drop is a random radio hit on an otherwise-fine path: recover it locally
6//! with FEC / interleaving and do NOT back off. Treating one as the other is
7//! the classic mistake - over-driving a congested path, or needlessly throttling
8//! a clean one - so the controller wants to know which it is.
9//!
10//! This sensor is a hybrid of two end-to-end loss-differentiation algorithms
11//! from Cen, Cosman & Voelker, "End-to-end differentiation of congestion and
12//! wireless losses" (IEEE/ACM Trans. Networking 11(5), 2003):
13//!
14//!  - **mBiaz** (inter-arrival). `T_min` is the minimum packet inter-arrival
15//!    seen. The paper's wireless window for a gap of `n` is `[(n+1) * T_min,
16//!    (n+1.25) * T_min)` - the 1.25 upper factor is the modified-Biaz tuning
17//!    (Fig. 2), tightening the original Biaz `[(n+1) * T_min, (n+2) * T_min)` to
18//!    cut congestion misclassification. The bridge ships a block as one GSO
19//!    burst, so its shards arrive back-to-back and a SUB-window spacing is a
20//!    batched arrival, not evidence of loss type. So here Biaz votes congestion
21//!    only when the spacing is at or above the window (a genuine queuing delay);
22//!    in-window and burst spacing are left to Spike.
23//!  - **Spike** (relative one-way trip time). With `rtt_min` / `rtt_max` the
24//!    min / max ROTT seen, the path is in a congestion *spike* when its ROTT
25//!    rises above `rtt_min + alpha * (rtt_max - rtt_min)` and leaves it when it
26//!    falls below `rtt_min + beta * (rtt_max - rtt_min)`, with `alpha = 1/2`,
27//!    `beta = 1/3` (the paper's values; the hysteresis keeps the state from
28//!    flapping). A loss inside the spike is congestion; outside it is wireless.
29//!    The ROTT is receiver-minus-sender timestamps; a constant clock offset
30//!    cancels in the min / max range, exact on same-machine and low-skew links.
31//!
32//! The hybrid calls a loss *congestion* when EITHER signal flags it - Biaz sees
33//! queuing delay or the path is in a Spike - and *wireless* only when neither
34//! does. Congestion is the costlier miss - the paper notes a congestion loss
35//! mistaken for wireless "will not be reduced when the network is congested" -
36//! so the tie breaks to congestion.
37//!
38//! The sensor holds no clock and does no I/O: the caller supplies the
39//! inter-arrival and ROTT (microseconds), so it is deterministic and
40//! exhaustively testable with synthetic traces.
41
42/// One classified loss event.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum LossClass {
45    /// A random wireless drop: recover locally (FEC / interleave), do not
46    /// inflate the effective-loss the controller uses to pace.
47    Wireless,
48    /// A congestion drop: raise parity broadly and consider pacing.
49    Congestion,
50}
51
52/// mBiaz wireless-window upper factor (modified Biaz, Fig. 2): the window ends
53/// at `(n + 1.25) * T_min`, and spacing at or above it is genuine queuing delay
54/// (congestion). The original Biaz used `n + 2`; 1.25 cuts congestion
55/// misclassification.
56const BIAZ_UPPER: f64 = 1.25;
57/// EWMA gain for the recent-congestion share the 2-bit class code reads.
58const CONGESTION_GAIN: f64 = 1.0 / 8.0;
59/// Minimum standing-queue delay (microseconds) for the path to count as
60/// congested. A genuine queue adds at least ~1 ms of standing delay; sub-
61/// millisecond excursions are scheduling / clock / decode-backlog jitter, which
62/// has no real queue.
63const MIN_QUEUE_US: f64 = 1000.0;
64/// Samples in the recent-ROTT-min window. The congestion signal is the recent
65/// FLOOR (fastest recent packet) rising above the all-time floor (RTprop) - a
66/// real standing queue raises every packet, including the fastest, while a decode
67/// / scheduling backlog only inflates the SLOW packets, never the recent min, so
68/// this rejects the backlog that the receiver's own processing adds to the ROTT.
69const SPIKE_WINDOW: usize = 32;
70/// Fraction of [`MIN_QUEUE_US`] below which the queue is judged drained (Spike
71/// leave), giving hysteresis so the congestion state does not flap at the edge.
72const SPIKE_LEAVE_FRAC: f64 = 0.5;
73
74/// Stateful loss differentiator. `observe_interarrival` / `observe_owd` feed it
75/// the running timing; `classify` runs the hybrid on a detected loss.
76#[derive(Debug)]
77pub struct LossClassSensor {
78    /// Minimum inter-arrival seen (`T_min`), microseconds.
79    t_min: f64,
80    /// Minimum ROTT seen (`RTprop` baseline), microseconds.
81    rtt_min: f64,
82    /// The last [`SPIKE_WINDOW`] ROTT samples, for the recent-floor (windowed
83    /// min) the congestion detector compares against `rtt_min`.
84    recent_owd: std::collections::VecDeque<f64>,
85    /// Whether the path is currently in a congestion spike (hysteresis).
86    in_spike: bool,
87    /// EWMA of per-loss class (1 = congestion, 0 = wireless).
88    congestion_ewma: f64,
89    /// Losses classified so far (0 = the code is "no loss yet").
90    losses_seen: u64,
91}
92
93impl Default for LossClassSensor {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl LossClassSensor {
100    /// A fresh sensor with no timing baseline yet.
101    pub fn new() -> Self {
102        Self {
103            t_min: f64::INFINITY,
104            rtt_min: f64::INFINITY,
105            recent_owd: std::collections::VecDeque::new(),
106            in_spike: false,
107            congestion_ewma: 0.0,
108            losses_seen: 0,
109        }
110    }
111
112    /// Record one packet inter-arrival (microseconds) to update `T_min`. Zero
113    /// or negative spacings (duplicate / reordered arrivals) are ignored.
114    pub fn observe_interarrival(&mut self, ia_us: f64) {
115        if ia_us > 0.0 && ia_us < self.t_min {
116            self.t_min = ia_us;
117        }
118    }
119
120    /// Record one relative one-way trip time (microseconds) to update the RTprop
121    /// baseline and the recent-floor congestion detector.
122    pub fn observe_owd(&mut self, owd_us: f64) {
123        if owd_us < self.rtt_min {
124            self.rtt_min = owd_us;
125        }
126        self.recent_owd.push_back(owd_us);
127        while self.recent_owd.len() > SPIKE_WINDOW {
128            self.recent_owd.pop_front();
129        }
130        // The standing-queue delay is how far the recent FLOOR (the fastest of
131        // the last `SPIKE_WINDOW` packets) sits above the all-time floor RTprop.
132        // A real queue raises every packet including the fastest; a decode /
133        // scheduling backlog inflates only the slow packets, leaving the recent
134        // min at the true floor - so this reads the queue, not the backlog.
135        let recent_min = self
136            .recent_owd
137            .iter()
138            .copied()
139            .fold(f64::INFINITY, f64::min);
140        let queue = recent_min - self.rtt_min;
141        if !self.in_spike && queue > MIN_QUEUE_US {
142            self.in_spike = true;
143        } else if self.in_spike && queue < MIN_QUEUE_US * SPIKE_LEAVE_FRAC {
144            self.in_spike = false;
145        }
146    }
147
148    /// Classify a loss of `gap` consecutive packets given the inter-arrival
149    /// (microseconds) measured across it. mBiaz calls it wireless when the
150    /// spacing fits the gap's wireless window; Spike calls it wireless when the
151    /// path is not in a congestion spike; the hybrid is wireless only when both
152    /// agree, else congestion.
153    pub fn classify(&mut self, gap: u32, interarrival_us: f64) -> LossClass {
154        let n = gap.max(1) as f64;
155        // mBiaz's wireless window is `[(n+1)*T_min, (n+1.25)*T_min)`. The bridge
156        // ships a block as one GSO super-buffer, so its shards arrive back-to-
157        // back and a SUB-window inter-arrival is a batched arrival, not evidence
158        // of loss type. So Biaz only votes CONGESTION when the spacing is ABOVE
159        // the window `(n+1.25)*T_min` - a genuine queuing delay; in-window or
160        // burst spacing is left to Spike. A loss is congestion when EITHER Biaz
161        // sees queuing OR the path is in a congestion spike, and wireless only
162        // when neither does (the conservative tie-break to congestion).
163        let biaz_congestion = self.t_min.is_finite()
164            && self.t_min > 0.0
165            && interarrival_us >= (n + BIAZ_UPPER) * self.t_min;
166        let class = if biaz_congestion || self.in_spike {
167            LossClass::Congestion
168        } else {
169            LossClass::Wireless
170        };
171        let sample = if class == LossClass::Congestion { 1.0 } else { 0.0 };
172        self.congestion_ewma += (sample - self.congestion_ewma) * CONGESTION_GAIN;
173        self.losses_seen += 1;
174        class
175    }
176
177    /// Spread (max - min, microseconds) of the recent ROTT window - the path's
178    /// current delay variation, which bounds how late a reordered packet can
179    /// arrive. A consumer uses it to set a reorder-tolerant retransmit grace so
180    /// jitter is not mistaken for loss. Zero before two samples.
181    pub fn recent_owd_spread_us(&self) -> f64 {
182        if self.recent_owd.len() < 2 {
183            return 0.0;
184        }
185        let mut lo = f64::INFINITY;
186        let mut hi = f64::NEG_INFINITY;
187        for &v in &self.recent_owd {
188            lo = lo.min(v);
189            hi = hi.max(v);
190        }
191        hi - lo
192    }
193
194    /// Share of recent loss classified congestion (0..=1); 0 before any loss.
195    pub fn congestion_fraction(&self) -> f32 {
196        if self.losses_seen == 0 {
197            0.0
198        } else {
199            self.congestion_ewma as f32
200        }
201    }
202
203    /// 2-bit class code for the `Loss` frame: 0 = no loss yet, 1 = wireless,
204    /// 2 = congestion, 3 = mixed (recent loss split between the two).
205    pub fn class_code(&self) -> u8 {
206        if self.losses_seen == 0 {
207            0
208        } else if self.congestion_ewma < 0.25 {
209            1
210        } else if self.congestion_ewma > 0.75 {
211            2
212        } else {
213            3
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    /// A pure last-hop-wireless pattern: steady spacing (so `T_min` is the
223    /// spacing), flat ROTT (no spike), and single-packet gaps whose
224    /// inter-arrival is ~2 * T_min - the wireless signature `(n+1) * T_min`
225    /// with n = 1. Every loss must classify wireless.
226    #[test]
227    fn pure_wireless_gap_pattern_classifies_wireless() {
228        let mut s = LossClassSensor::new();
229        // Steady 1000us spacing establishes T_min, flat 5000us ROTT (no spike).
230        for _ in 0..50 {
231            s.observe_interarrival(1000.0);
232            s.observe_owd(5000.0);
233        }
234        // A single-packet gap (n=1) whose inter-arrival is 2*T_min: wireless.
235        for _ in 0..20 {
236            assert_eq!(s.classify(1, 2000.0), LossClass::Wireless);
237        }
238        assert_eq!(s.class_code(), 1, "class code = wireless");
239        assert!(s.congestion_fraction() < 0.1, "low congestion fraction");
240    }
241
242    /// A pure congestion pattern: a ROTT spike well above the range midpoint
243    /// puts the path in the spike state, so every loss classifies congestion
244    /// regardless of inter-arrival.
245    #[test]
246    fn pure_congestion_rott_spike_classifies_congestion() {
247        let mut s = LossClassSensor::new();
248        // Establish a ROTT range, then spike high (queue building).
249        for i in 0..50u64 {
250            s.observe_interarrival(1000.0);
251            s.observe_owd(5000.0 + i as f64 * 200.0); // climbing toward a spike
252        }
253        // A loss during the spike is congestion even with a "wireless-looking"
254        // inter-arrival, because Spike overrides via the conservative hybrid.
255        for _ in 0..20 {
256            assert_eq!(s.classify(1, 2000.0), LossClass::Congestion);
257        }
258        assert_eq!(s.class_code(), 2, "class code = congestion");
259        assert!(s.congestion_fraction() > 0.9, "high congestion fraction");
260    }
261
262    /// Spike hysteresis: the path enters the spike above the alpha threshold and
263    /// only leaves below the beta threshold, so a ROTT between the two holds the
264    /// previous state.
265    #[test]
266    fn recent_owd_spread_tracks_jitter() {
267        let mut s = LossClassSensor::new();
268        // A flat ROTT has zero spread (no jitter -> a tight reorder grace).
269        for _ in 0..40 {
270            s.observe_owd(5000.0);
271        }
272        assert_eq!(s.recent_owd_spread_us(), 0.0, "flat ROTT has no spread");
273        // Jitter widens the spread (a looser reorder grace is warranted).
274        for i in 0..40 {
275            s.observe_owd(5000.0 + (i % 8) as f64 * 1000.0);
276        }
277        assert!(
278            s.recent_owd_spread_us() >= 6000.0,
279            "jitter must widen the spread, got {}",
280            s.recent_owd_spread_us()
281        );
282    }
283
284    #[test]
285    fn spike_state_has_hysteresis() {
286        let mut s = LossClassSensor::new();
287        // RTprop baseline 5000us. A SUSTAINED 2000us queue (> MIN_QUEUE 1000us)
288        // raises the recent floor and enters the spike.
289        for _ in 0..40 {
290            s.observe_owd(5000.0);
291        }
292        for _ in 0..40 {
293            s.observe_owd(7000.0);
294        }
295        assert_eq!(s.classify(1, 0.0), LossClass::Congestion, "sustained queue enters spike");
296        // Partial drain to a 700us queue (between the 500us leave floor and the
297        // 1000us enter floor): hysteresis holds the spike.
298        for _ in 0..40 {
299            s.observe_owd(5700.0);
300        }
301        assert_eq!(s.classify(1, 0.0), LossClass::Congestion, "hysteresis holds spike");
302        // Full drain back to RTprop (queue 0 < the 500us leave floor): leaves.
303        for _ in 0..40 {
304            s.observe_owd(5000.0);
305        }
306        assert_eq!(s.classify(1, 0.0), LossClass::Wireless, "drained queue leaves spike");
307    }
308
309    /// The hybrid is conservative: mBiaz saying wireless does NOT override a
310    /// congestion spike - both must agree on wireless.
311    #[test]
312    fn hybrid_breaks_ties_to_congestion() {
313        let mut s = LossClassSensor::new();
314        for _ in 0..40 {
315            s.observe_interarrival(1000.0);
316        }
317        // Force a spike with a sustained ms-scale queue (RTprop 1000us, then a
318        // sustained 6000us: a 5000us standing queue > the MIN_QUEUE floor).
319        for _ in 0..40 {
320            s.observe_owd(1000.0);
321        }
322        for _ in 0..40 {
323            s.observe_owd(6000.0);
324        }
325        // mBiaz alone would say wireless (2*T_min spacing), but the spike wins.
326        assert_eq!(s.classify(1, 2000.0), LossClass::Congestion);
327    }
328
329    /// The mBiaz window scales with the gap: a gap of n=3 puts the wireless
330    /// window upper bound at (3+1.25)*T_min, and only spacing above it is read
331    /// as congestion. Sub-window (burst) spacing defers to Spike.
332    #[test]
333    fn biaz_window_tracks_gap_size() {
334        let mut s = LossClassSensor::new();
335        for _ in 0..40 {
336            s.observe_interarrival(1000.0);
337            s.observe_owd(5000.0); // flat, no spike
338        }
339        // gap=3: the wireless window ends at (3+1.25)*T_min = 4250.
340        // In-window spacing (4100) with no spike -> wireless.
341        assert_eq!(s.classify(3, 4100.0), LossClass::Wireless);
342        // Sub-window (burst-like) spacing defers to Spike; no spike -> wireless.
343        assert_eq!(s.classify(3, 2000.0), LossClass::Wireless);
344        // Above-window spacing is genuine queuing delay -> congestion.
345        assert_eq!(s.classify(3, 5000.0), LossClass::Congestion);
346    }
347}