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