#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
pub enum Health {
#[default]
Healthy,
Suspect,
Degraded,
Stalled,
Dead,
}
impl Health {
pub fn is_suspect_or_worse(self) -> bool {
self >= Health::Suspect
}
}
#[derive(Clone, Debug)]
pub struct CollapseDetector {
long: f64,
short: f64,
cusum_down: f64,
n: u32,
ratio_suspect: f64,
ratio_degraded: f64,
cusum_h: f64,
cusum_k: f64,
health: Health,
}
impl Default for CollapseDetector {
fn default() -> Self {
Self::new()
}
}
pub const WARMUP: u32 = 4;
impl CollapseDetector {
pub fn new() -> Self {
Self {
long: 0.0,
short: 0.0,
cusum_down: 0.0,
n: 0,
ratio_suspect: 0.55,
ratio_degraded: 0.30,
cusum_h: 2.0,
cusum_k: 0.25,
health: Health::Healthy,
}
}
pub fn observe_rate(&mut self, rate: f64) {
let r = rate.max(0.0);
self.n = self.n.saturating_add(1);
if self.n == 1 {
self.long = r;
self.short = r;
return;
}
self.short = 0.45 * r + 0.55 * self.short;
if self.cusum_down <= 0.0 {
self.long = 0.08 * r + 0.92 * self.long;
}
if self.n <= WARMUP || self.long <= 0.0 {
return;
}
let dev = (self.long - r) / self.long - self.cusum_k;
self.cusum_down = (self.cusum_down + dev).max(0.0);
let ratio = self.short / self.long;
self.health = if ratio <= self.ratio_degraded || self.cusum_down >= 2.0 * self.cusum_h {
Health::Degraded
} else if ratio <= self.ratio_suspect || self.cusum_down >= self.cusum_h {
Health::Suspect
} else {
Health::Healthy
};
}
pub fn observe_silence(&mut self, since_progress_s: f64, stall_timeout_s: f64) {
if since_progress_s >= stall_timeout_s {
self.health = Health::Stalled;
} else if since_progress_s >= 0.5 * stall_timeout_s && self.health < Health::Suspect {
self.health = Health::Suspect;
}
}
pub fn mark_dead(&mut self) {
self.health = Health::Dead;
}
pub fn health(&self) -> Health {
self.health
}
pub fn rate(&self) -> f64 {
if self.health.is_suspect_or_worse() {
self.short
} else {
self.long
}
}
pub fn samples(&self) -> u32 {
self.n
}
pub fn reset_after_repair(&mut self) {
self.cusum_down = 0.0;
if self.health == Health::Suspect {
self.health = Health::Healthy;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hard_collapse_is_detected_within_a_few_arrivals() {
let mut d = CollapseDetector::new();
for _ in 0..10 {
d.observe_rate(4.0e6);
}
assert_eq!(d.health(), Health::Healthy);
let mut arrivals_to_suspect = None;
for i in 1..=10 {
d.observe_rate(0.12e6); if arrivals_to_suspect.is_none() && d.health().is_suspect_or_worse() {
arrivals_to_suspect = Some(i);
}
}
let k = arrivals_to_suspect.expect("a 97% collapse must be detected");
assert!(
k <= 3,
"collapse must be flagged within 3 arrivals, took {k}"
);
assert_eq!(
d.health(),
Health::Degraded,
"sustained collapse must confirm"
);
}
#[test]
fn stable_noisy_connection_is_not_flagged() {
let mut d = CollapseDetector::new();
let jitter = [
1.0, 0.72, 1.28, 0.85, 1.15, 0.78, 1.22, 0.93, 1.07, 0.80, 1.20, 1.0,
];
for rep in 0..6 {
for j in jitter {
d.observe_rate(4.0e6 * j * if rep % 2 == 0 { 1.0 } else { 0.98 });
}
}
assert_eq!(
d.health(),
Health::Healthy,
"30% jitter must not be graded as collapse (false positives cost a delta each)"
);
}
#[test]
fn slow_degradation_is_caught_by_cusum() {
let mut d = CollapseDetector::new();
for _ in 0..12 {
d.observe_rate(4.0e6);
}
let mut k = None;
for i in 1..=20 {
d.observe_rate(2.2e6);
if k.is_none() && d.health().is_suspect_or_worse() {
k = Some(i);
}
}
let k = k.expect("a sustained 45% drop must eventually be flagged by CUSUM");
assert!(
(8..=16).contains(&k),
"slow degradation should be caught in ~12 samples, took {k}"
);
}
#[test]
fn recovery_clears_suspicion() {
let mut d = CollapseDetector::new();
for _ in 0..10 {
d.observe_rate(4.0e6);
}
for _ in 0..3 {
d.observe_rate(0.2e6);
}
assert!(d.health().is_suspect_or_worse());
for _ in 0..25 {
d.observe_rate(4.0e6);
}
assert_eq!(
d.health(),
Health::Healthy,
"a recovered connection must be usable again"
);
}
#[test]
fn silence_escalates_before_the_stall_timeout() {
let mut d = CollapseDetector::new();
for _ in 0..8 {
d.observe_rate(4.0e6);
}
d.observe_silence(2.0, 8.0);
assert_eq!(
d.health(),
Health::Healthy,
"a quarter of the timeout is not evidence"
);
d.observe_silence(4.5, 8.0);
assert_eq!(
d.health(),
Health::Suspect,
"past half the stall timeout must pre-empt, not wait for the full timeout"
);
d.observe_silence(8.1, 8.0);
assert_eq!(d.health(), Health::Stalled);
}
#[test]
fn detector_abstains_during_warmup() {
let mut d = CollapseDetector::new();
d.observe_rate(4.0e6);
d.observe_rate(0.01e6);
assert_eq!(
d.health(),
Health::Healthy,
"with no reference level established, grading is guesswork"
);
}
#[test]
fn collapsed_rate_estimate_is_the_short_window() {
let mut d = CollapseDetector::new();
for _ in 0..12 {
d.observe_rate(4.0e6);
}
let before = d.rate();
for _ in 0..4 {
d.observe_rate(0.1e6);
}
assert!(before > 3.0e6);
assert!(
d.rate() < 1.0e6,
"once collapse is evident the estimate must follow the SHORT window, got {}",
d.rate()
);
}
}