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}