Skip to main content

hya_core/
detect.rs

1//! Fast collapse detection for connection goodput.
2//!
3//! # Why this exists
4//!
5//! The scheduler cannot repair a divergence it has not observed. Measurement of
6//! the earlier EWMA-only estimator showed a *fixed* detection cost of roughly
7//! 0.25–0.9 s when a source's rate collapsed by ~97%: the makespan ratio against
8//! the fluid oracle was 2.44× on a 12 MB object and only fell to 1.02× at 192 MB,
9//! because the excess is a constant that amortises rather than a per-byte
10//! inefficiency. An EWMA is a *smoother*; asking it to detect a step change is
11//! asking the wrong question of it, and the lag is structural: after a collapse,
12//! the very samples the estimator needs arrive at the collapsed rate.
13//!
14//! # What this does instead
15//!
16//! Two independent mechanisms, because they fail in different directions.
17//!
18//! * **Dual-window ratio.** A short window (recent arrivals) against a long
19//!   window (the connection's established rate). Responds within the short
20//!   window, but is noisy.
21//! * **Two-sided CUSUM.** Accumulates normalised deviations from the established
22//!   rate and fires when the running sum passes a threshold `h`. Slower than the
23//!   ratio test on a hard collapse but far more resistant to variance, so it
24//!   catches slow degradation the ratio test smooths over.
25//!
26//! A connection is graded on the strongest evidence available, and the grade —
27//! not a raw rate — is what the scheduler acts on. Repair may fire on `Suspect`
28//! long before the stall timeout would expire.
29//!
30//! # The false-positive cost is real and asymmetric
31//!
32//! A missed collapse costs the transfer up to a stall timeout. A *spurious*
33//! detection costs one `delta` per unnecessary repair, and on a high-RTT path
34//! `delta` is hundreds of milliseconds. The thresholds here are therefore set to
35//! tolerate ordinary jitter — the `stable_noisy_connection_is_not_flagged` test
36//! pins that behaviour, and it is as load-bearing as the detection tests.
37
38/// How healthy a connection looks, on the evidence so far.
39/// `short / long` above which a rate is still climbing; see
40/// [`CollapseDetector::rising`].
41const RISING_RATIO: f64 = 1.15;
42
43#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
44pub enum Health {
45    /// Delivering at or near its established rate.
46    #[default]
47    Healthy,
48    /// Evidence of a drop, not yet conclusive. Repair may pre-empt on this.
49    Suspect,
50    /// Confirmed sustained collapse. Prefer moving work away.
51    Degraded,
52    /// Nothing arriving for longer than the stall timeout.
53    Stalled,
54    /// Failed and not worth retrying within this transfer.
55    Dead,
56}
57
58impl Health {
59    /// Should the scheduler treat this connection as a repair victim on sight?
60    pub fn is_suspect_or_worse(self) -> bool {
61        self >= Health::Suspect
62    }
63}
64
65/// Per-connection collapse detector.
66#[derive(Clone, Debug)]
67pub struct CollapseDetector {
68    /// Established rate estimate (bytes/s), slow EWMA — the reference level.
69    long: f64,
70    /// Recent rate estimate (bytes/s), fast EWMA — the test level.
71    short: f64,
72    /// Two-sided CUSUM accumulator for downward shifts.
73    cusum_down: f64,
74    /// Samples observed; the detector abstains until it has a reference.
75    n: u32,
76    /// Fraction of the long rate below which the short rate is suspicious.
77    ratio_suspect: f64,
78    /// Fraction below which a collapse is confirmed.
79    ratio_degraded: f64,
80    /// CUSUM decision threshold, in units of the long rate.
81    cusum_h: f64,
82    /// CUSUM slack: shifts smaller than this fraction are ignored as noise.
83    cusum_k: f64,
84    health: Health,
85}
86
87impl Default for CollapseDetector {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93/// Samples required before the detector will grade anything. Below this it has
94/// no reference level, and grading noise as collapse is worse than not grading.
95pub const WARMUP: u32 = 4;
96
97impl CollapseDetector {
98    pub fn new() -> Self {
99        Self {
100            long: 0.0,
101            short: 0.0,
102            cusum_down: 0.0,
103            n: 0,
104            ratio_suspect: 0.55,
105            ratio_degraded: 0.30,
106            cusum_h: 2.0,
107            cusum_k: 0.25,
108            health: Health::Healthy,
109        }
110    }
111
112    /// Record an observed instantaneous rate (bytes/s) for this connection.
113    pub fn observe_rate(&mut self, rate: f64) {
114        let r = rate.max(0.0);
115        self.n = self.n.saturating_add(1);
116        if self.n == 1 {
117            self.long = r;
118            self.short = r;
119            return;
120        }
121        self.short = 0.45 * r + 0.55 * self.short;
122        // The reference level is FROZEN while evidence is accumulating. This is
123        // textbook CUSUM and getting it wrong is subtle: an adaptive reference
124        // chases the drop, the normalised deviation shrinks toward the slack `k`,
125        // and evidence never accumulates. Measured on a sustained 45% drop, an
126        // adaptive reference plateaus at 0.93 against a threshold of 2.0 — it
127        // never fires, because the decline has quietly become "normal".
128        if self.cusum_down <= 0.0 {
129            self.long = 0.08 * r + 0.92 * self.long;
130        }
131
132        if self.n <= WARMUP || self.long <= 0.0 {
133            return;
134        }
135
136        // CUSUM on the normalised downward deviation, with slack k.
137        let dev = (self.long - r) / self.long - self.cusum_k;
138        self.cusum_down = (self.cusum_down + dev).max(0.0);
139
140        let ratio = self.short / self.long;
141        self.health = if ratio <= self.ratio_degraded || self.cusum_down >= 2.0 * self.cusum_h {
142            Health::Degraded
143        } else if ratio <= self.ratio_suspect || self.cusum_down >= self.cusum_h {
144            Health::Suspect
145        } else {
146            // Deliberately does NOT zero the accumulator. An earlier version
147            // reset it here, which is a subtle self-defeat: this branch is taken
148            // on every sample where the evidence has not YET crossed the
149            // threshold, i.e. throughout accumulation, so the sum was wiped each
150            // time and a slow decline could never be detected at all.
151            //
152            // Recovery needs no special case. When the rate returns to the
153            // reference, `dev` goes negative (-k per sample) and the `max(0.0)`
154            // above walks the accumulator back down to zero on its own.
155            Health::Healthy
156        };
157    }
158
159    /// Escalate on wall-clock silence, which no rate sample can express: a
160    /// connection delivering nothing produces no observations at all.
161    pub fn observe_silence(&mut self, since_progress_s: f64, stall_timeout_s: f64) {
162        if since_progress_s >= stall_timeout_s {
163            self.health = Health::Stalled;
164        } else if since_progress_s >= 0.5 * stall_timeout_s && self.health < Health::Suspect {
165            // Halfway to the stall timeout with nothing arriving is already
166            // evidence, and waiting for the full timeout is what cost the
167            // earlier implementation its detection lag.
168            self.health = Health::Suspect;
169        }
170    }
171
172    pub fn mark_dead(&mut self) {
173        self.health = Health::Dead;
174    }
175
176    pub fn health(&self) -> Health {
177        self.health
178    }
179
180    /// Established rate, for scheduling decisions that need a number.
181    pub fn rate(&self) -> f64 {
182        // Once a collapse is evident the SHORT window is the honest estimate:
183        // projecting a laggard's finish time from its pre-collapse rate is what
184        // makes a scheduler fail to repair.
185        if self.health.is_suspect_or_worse() {
186            self.short
187        } else {
188            self.long
189        }
190    }
191
192    pub fn samples(&self) -> u32 {
193        self.n
194    }
195
196    /// Is the rate still climbing?
197    ///
198    /// The short average follows a rising rate within a couple of samples and
199    /// the long one trails it, so while a connection is still in TCP slow start
200    /// the two disagree by a wide margin. That disagreement is the honest
201    /// answer to "is this connection slow?": not yet known. A repair that reads
202    /// a climbing rate as a settled one moves work off a connection that was a
203    /// second away from matching its peers — measured on a 100 ms path, one
204    /// flow at 16 MB/s against its twin at 49 had 256 MB taken from it, and
205    /// both were at 90 MB/s before the stolen bytes had been re-requested. The
206    /// margin is wide enough that steady-state jitter does not trip it and
207    /// narrow enough that a connection settled at half its peers' rate is
208    /// reported as settled, since its two averages agree.
209    pub fn rising(&self) -> bool {
210        self.n >= 2 && self.short > self.long * RISING_RATIO
211    }
212
213    /// Clear detection state after work has been moved away, so the next
214    /// episode is judged on fresh evidence.
215    pub fn reset_after_repair(&mut self) {
216        self.cusum_down = 0.0;
217        if self.health == Health::Suspect {
218            self.health = Health::Healthy;
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    /// The measured failure case: a connection collapsing to 3% of its rate.
228    #[test]
229    fn hard_collapse_is_detected_within_a_few_arrivals() {
230        let mut d = CollapseDetector::new();
231        for _ in 0..10 {
232            d.observe_rate(4.0e6);
233        }
234        assert_eq!(d.health(), Health::Healthy);
235
236        let mut arrivals_to_suspect = None;
237        for i in 1..=10 {
238            d.observe_rate(0.12e6); // 3% of 4 MB/s
239            if arrivals_to_suspect.is_none() && d.health().is_suspect_or_worse() {
240                arrivals_to_suspect = Some(i);
241            }
242        }
243        let k = arrivals_to_suspect.expect("a 97% collapse must be detected");
244        assert!(
245            k <= 3,
246            "collapse must be flagged within 3 arrivals, took {k}"
247        );
248        assert_eq!(
249            d.health(),
250            Health::Degraded,
251            "sustained collapse must confirm"
252        );
253    }
254
255    /// The guard that matters just as much: ordinary jitter is not a collapse.
256    /// Each spurious detection costs a full `delta`.
257    #[test]
258    fn stable_noisy_connection_is_not_flagged() {
259        let mut d = CollapseDetector::new();
260        // +/-30% multiplicative jitter around a stable mean, deterministic.
261        let jitter = [
262            1.0, 0.72, 1.28, 0.85, 1.15, 0.78, 1.22, 0.93, 1.07, 0.80, 1.20, 1.0,
263        ];
264        for rep in 0..6 {
265            for j in jitter {
266                d.observe_rate(4.0e6 * j * if rep % 2 == 0 { 1.0 } else { 0.98 });
267            }
268        }
269        assert_eq!(
270            d.health(),
271            Health::Healthy,
272            "30% jitter must not be graded as collapse (false positives cost a delta each)"
273        );
274    }
275
276    #[test]
277    fn slow_degradation_is_caught_by_cusum() {
278        let mut d = CollapseDetector::new();
279        for _ in 0..12 {
280            d.observe_rate(4.0e6);
281        }
282        // A 45% drop. The short/long ratio settles near 0.60 -- ABOVE the
283        // suspect floor -- so the ratio test alone never fires and CUSUM is the
284        // only mechanism that can accumulate the evidence. Measured: the
285        // accumulator crosses h at the 12th post-drop sample.
286        let mut k = None;
287        for i in 1..=20 {
288            d.observe_rate(2.2e6);
289            if k.is_none() && d.health().is_suspect_or_worse() {
290                k = Some(i);
291            }
292        }
293        let k = k.expect("a sustained 45% drop must eventually be flagged by CUSUM");
294        assert!(
295            (8..=16).contains(&k),
296            "slow degradation should be caught in ~12 samples, took {k}"
297        );
298    }
299
300    #[test]
301    fn recovery_clears_suspicion() {
302        let mut d = CollapseDetector::new();
303        for _ in 0..10 {
304            d.observe_rate(4.0e6);
305        }
306        for _ in 0..3 {
307            d.observe_rate(0.2e6);
308        }
309        assert!(d.health().is_suspect_or_worse());
310        for _ in 0..25 {
311            d.observe_rate(4.0e6);
312        }
313        assert_eq!(
314            d.health(),
315            Health::Healthy,
316            "a recovered connection must be usable again"
317        );
318    }
319
320    #[test]
321    fn silence_escalates_before_the_stall_timeout() {
322        let mut d = CollapseDetector::new();
323        for _ in 0..8 {
324            d.observe_rate(4.0e6);
325        }
326        d.observe_silence(2.0, 8.0);
327        assert_eq!(
328            d.health(),
329            Health::Healthy,
330            "a quarter of the timeout is not evidence"
331        );
332        d.observe_silence(4.5, 8.0);
333        assert_eq!(
334            d.health(),
335            Health::Suspect,
336            "past half the stall timeout must pre-empt, not wait for the full timeout"
337        );
338        d.observe_silence(8.1, 8.0);
339        assert_eq!(d.health(), Health::Stalled);
340    }
341
342    #[test]
343    fn detector_abstains_during_warmup() {
344        let mut d = CollapseDetector::new();
345        d.observe_rate(4.0e6);
346        d.observe_rate(0.01e6);
347        assert_eq!(
348            d.health(),
349            Health::Healthy,
350            "with no reference level established, grading is guesswork"
351        );
352    }
353
354    #[test]
355    fn collapsed_rate_estimate_is_the_short_window() {
356        let mut d = CollapseDetector::new();
357        for _ in 0..12 {
358            d.observe_rate(4.0e6);
359        }
360        let before = d.rate();
361        for _ in 0..4 {
362            d.observe_rate(0.1e6);
363        }
364        assert!(before > 3.0e6);
365        assert!(
366            d.rate() < 1.0e6,
367            "once collapse is evident the estimate must follow the SHORT window, got {}",
368            d.rate()
369        );
370    }
371}