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}