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#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
40pub enum Health {
41    /// Delivering at or near its established rate.
42    #[default]
43    Healthy,
44    /// Evidence of a drop, not yet conclusive. Repair may pre-empt on this.
45    Suspect,
46    /// Confirmed sustained collapse. Prefer moving work away.
47    Degraded,
48    /// Nothing arriving for longer than the stall timeout.
49    Stalled,
50    /// Failed and not worth retrying within this transfer.
51    Dead,
52}
53
54impl Health {
55    /// Should the scheduler treat this connection as a repair victim on sight?
56    pub fn is_suspect_or_worse(self) -> bool {
57        self >= Health::Suspect
58    }
59}
60
61/// Per-connection collapse detector.
62#[derive(Clone, Debug)]
63pub struct CollapseDetector {
64    /// Established rate estimate (bytes/s), slow EWMA — the reference level.
65    long: f64,
66    /// Recent rate estimate (bytes/s), fast EWMA — the test level.
67    short: f64,
68    /// Two-sided CUSUM accumulator for downward shifts.
69    cusum_down: f64,
70    /// Samples observed; the detector abstains until it has a reference.
71    n: u32,
72    /// Fraction of the long rate below which the short rate is suspicious.
73    ratio_suspect: f64,
74    /// Fraction below which a collapse is confirmed.
75    ratio_degraded: f64,
76    /// CUSUM decision threshold, in units of the long rate.
77    cusum_h: f64,
78    /// CUSUM slack: shifts smaller than this fraction are ignored as noise.
79    cusum_k: f64,
80    health: Health,
81}
82
83impl Default for CollapseDetector {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89/// Samples required before the detector will grade anything. Below this it has
90/// no reference level, and grading noise as collapse is worse than not grading.
91pub const WARMUP: u32 = 4;
92
93impl CollapseDetector {
94    pub fn new() -> Self {
95        Self {
96            long: 0.0,
97            short: 0.0,
98            cusum_down: 0.0,
99            n: 0,
100            ratio_suspect: 0.55,
101            ratio_degraded: 0.30,
102            cusum_h: 2.0,
103            cusum_k: 0.25,
104            health: Health::Healthy,
105        }
106    }
107
108    /// Record an observed instantaneous rate (bytes/s) for this connection.
109    pub fn observe_rate(&mut self, rate: f64) {
110        let r = rate.max(0.0);
111        self.n = self.n.saturating_add(1);
112        if self.n == 1 {
113            self.long = r;
114            self.short = r;
115            return;
116        }
117        self.short = 0.45 * r + 0.55 * self.short;
118        // The reference level is FROZEN while evidence is accumulating. This is
119        // textbook CUSUM and getting it wrong is subtle: an adaptive reference
120        // chases the drop, the normalised deviation shrinks toward the slack `k`,
121        // and evidence never accumulates. Measured on a sustained 45% drop, an
122        // adaptive reference plateaus at 0.93 against a threshold of 2.0 — it
123        // never fires, because the decline has quietly become "normal".
124        if self.cusum_down <= 0.0 {
125            self.long = 0.08 * r + 0.92 * self.long;
126        }
127
128        if self.n <= WARMUP || self.long <= 0.0 {
129            return;
130        }
131
132        // CUSUM on the normalised downward deviation, with slack k.
133        let dev = (self.long - r) / self.long - self.cusum_k;
134        self.cusum_down = (self.cusum_down + dev).max(0.0);
135
136        let ratio = self.short / self.long;
137        self.health = if ratio <= self.ratio_degraded || self.cusum_down >= 2.0 * self.cusum_h {
138            Health::Degraded
139        } else if ratio <= self.ratio_suspect || self.cusum_down >= self.cusum_h {
140            Health::Suspect
141        } else {
142            // Deliberately does NOT zero the accumulator. An earlier version
143            // reset it here, which is a subtle self-defeat: this branch is taken
144            // on every sample where the evidence has not YET crossed the
145            // threshold, i.e. throughout accumulation, so the sum was wiped each
146            // time and a slow decline could never be detected at all.
147            //
148            // Recovery needs no special case. When the rate returns to the
149            // reference, `dev` goes negative (-k per sample) and the `max(0.0)`
150            // above walks the accumulator back down to zero on its own.
151            Health::Healthy
152        };
153    }
154
155    /// Escalate on wall-clock silence, which no rate sample can express: a
156    /// connection delivering nothing produces no observations at all.
157    pub fn observe_silence(&mut self, since_progress_s: f64, stall_timeout_s: f64) {
158        if since_progress_s >= stall_timeout_s {
159            self.health = Health::Stalled;
160        } else if since_progress_s >= 0.5 * stall_timeout_s && self.health < Health::Suspect {
161            // Halfway to the stall timeout with nothing arriving is already
162            // evidence, and waiting for the full timeout is what cost the
163            // earlier implementation its detection lag.
164            self.health = Health::Suspect;
165        }
166    }
167
168    pub fn mark_dead(&mut self) {
169        self.health = Health::Dead;
170    }
171
172    pub fn health(&self) -> Health {
173        self.health
174    }
175
176    /// Established rate, for scheduling decisions that need a number.
177    pub fn rate(&self) -> f64 {
178        // Once a collapse is evident the SHORT window is the honest estimate:
179        // projecting a laggard's finish time from its pre-collapse rate is what
180        // makes a scheduler fail to repair.
181        if self.health.is_suspect_or_worse() {
182            self.short
183        } else {
184            self.long
185        }
186    }
187
188    pub fn samples(&self) -> u32 {
189        self.n
190    }
191
192    /// Clear detection state after work has been moved away, so the next
193    /// episode is judged on fresh evidence.
194    pub fn reset_after_repair(&mut self) {
195        self.cusum_down = 0.0;
196        if self.health == Health::Suspect {
197            self.health = Health::Healthy;
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// The measured failure case: a connection collapsing to 3% of its rate.
207    #[test]
208    fn hard_collapse_is_detected_within_a_few_arrivals() {
209        let mut d = CollapseDetector::new();
210        for _ in 0..10 {
211            d.observe_rate(4.0e6);
212        }
213        assert_eq!(d.health(), Health::Healthy);
214
215        let mut arrivals_to_suspect = None;
216        for i in 1..=10 {
217            d.observe_rate(0.12e6); // 3% of 4 MB/s
218            if arrivals_to_suspect.is_none() && d.health().is_suspect_or_worse() {
219                arrivals_to_suspect = Some(i);
220            }
221        }
222        let k = arrivals_to_suspect.expect("a 97% collapse must be detected");
223        assert!(
224            k <= 3,
225            "collapse must be flagged within 3 arrivals, took {k}"
226        );
227        assert_eq!(
228            d.health(),
229            Health::Degraded,
230            "sustained collapse must confirm"
231        );
232    }
233
234    /// The guard that matters just as much: ordinary jitter is not a collapse.
235    /// Each spurious detection costs a full `delta`.
236    #[test]
237    fn stable_noisy_connection_is_not_flagged() {
238        let mut d = CollapseDetector::new();
239        // +/-30% multiplicative jitter around a stable mean, deterministic.
240        let jitter = [
241            1.0, 0.72, 1.28, 0.85, 1.15, 0.78, 1.22, 0.93, 1.07, 0.80, 1.20, 1.0,
242        ];
243        for rep in 0..6 {
244            for j in jitter {
245                d.observe_rate(4.0e6 * j * if rep % 2 == 0 { 1.0 } else { 0.98 });
246            }
247        }
248        assert_eq!(
249            d.health(),
250            Health::Healthy,
251            "30% jitter must not be graded as collapse (false positives cost a delta each)"
252        );
253    }
254
255    #[test]
256    fn slow_degradation_is_caught_by_cusum() {
257        let mut d = CollapseDetector::new();
258        for _ in 0..12 {
259            d.observe_rate(4.0e6);
260        }
261        // A 45% drop. The short/long ratio settles near 0.60 -- ABOVE the
262        // suspect floor -- so the ratio test alone never fires and CUSUM is the
263        // only mechanism that can accumulate the evidence. Measured: the
264        // accumulator crosses h at the 12th post-drop sample.
265        let mut k = None;
266        for i in 1..=20 {
267            d.observe_rate(2.2e6);
268            if k.is_none() && d.health().is_suspect_or_worse() {
269                k = Some(i);
270            }
271        }
272        let k = k.expect("a sustained 45% drop must eventually be flagged by CUSUM");
273        assert!(
274            (8..=16).contains(&k),
275            "slow degradation should be caught in ~12 samples, took {k}"
276        );
277    }
278
279    #[test]
280    fn recovery_clears_suspicion() {
281        let mut d = CollapseDetector::new();
282        for _ in 0..10 {
283            d.observe_rate(4.0e6);
284        }
285        for _ in 0..3 {
286            d.observe_rate(0.2e6);
287        }
288        assert!(d.health().is_suspect_or_worse());
289        for _ in 0..25 {
290            d.observe_rate(4.0e6);
291        }
292        assert_eq!(
293            d.health(),
294            Health::Healthy,
295            "a recovered connection must be usable again"
296        );
297    }
298
299    #[test]
300    fn silence_escalates_before_the_stall_timeout() {
301        let mut d = CollapseDetector::new();
302        for _ in 0..8 {
303            d.observe_rate(4.0e6);
304        }
305        d.observe_silence(2.0, 8.0);
306        assert_eq!(
307            d.health(),
308            Health::Healthy,
309            "a quarter of the timeout is not evidence"
310        );
311        d.observe_silence(4.5, 8.0);
312        assert_eq!(
313            d.health(),
314            Health::Suspect,
315            "past half the stall timeout must pre-empt, not wait for the full timeout"
316        );
317        d.observe_silence(8.1, 8.0);
318        assert_eq!(d.health(), Health::Stalled);
319    }
320
321    #[test]
322    fn detector_abstains_during_warmup() {
323        let mut d = CollapseDetector::new();
324        d.observe_rate(4.0e6);
325        d.observe_rate(0.01e6);
326        assert_eq!(
327            d.health(),
328            Health::Healthy,
329            "with no reference level established, grading is guesswork"
330        );
331    }
332
333    #[test]
334    fn collapsed_rate_estimate_is_the_short_window() {
335        let mut d = CollapseDetector::new();
336        for _ in 0..12 {
337            d.observe_rate(4.0e6);
338        }
339        let before = d.rate();
340        for _ in 0..4 {
341            d.observe_rate(0.1e6);
342        }
343        assert!(before > 3.0e6);
344        assert!(
345            d.rate() < 1.0e6,
346            "once collapse is evident the estimate must follow the SHORT window, got {}",
347            d.rate()
348        );
349    }
350}