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}