use std::collections::VecDeque;
const WINDOW_CAPACITY: usize = 50;
#[derive(Debug, Clone, PartialEq)]
pub enum SpcAlert {
Rule1(f64),
Rule2,
Rule3,
Rule4,
}
#[derive(Debug)]
pub struct SpcMonitor {
window: VecDeque<f64>,
mean: f64,
m2: f64,
count: u64,
}
impl Default for SpcMonitor {
fn default() -> Self {
Self::new()
}
}
impl SpcMonitor {
pub fn new() -> Self {
Self {
window: VecDeque::with_capacity(WINDOW_CAPACITY),
mean: 0.0,
m2: 0.0,
count: 0,
}
}
pub fn mean(&self) -> f64 {
self.mean
}
pub fn std_dev(&self) -> f64 {
if self.count < 2 {
return 0.0;
}
(self.m2 / (self.count - 1) as f64).sqrt()
}
pub fn push(&mut self, sample_ms: f64) -> Option<SpcAlert> {
let base_mean = self.mean;
let base_sd = self.std_dev();
let base_n = self.window.len();
self.update_stats(sample_ms);
self.window.push_back(sample_ms);
if self.window.len() > WINDOW_CAPACITY {
self.window.pop_front();
self.recompute_stats();
}
if base_n < 2 || base_sd == 0.0 {
return None;
}
if (sample_ms - base_mean).abs() > 3.0 * base_sd {
tracing::warn!(
spc_rule = 1,
sample_ms,
mean_ms = base_mean,
sigma_ms = base_sd,
"SPC Rule 1: scan latency outlier beyond ±3σ"
);
return Some(SpcAlert::Rule1(sample_ms));
}
if self.window.len() < 9 {
return None;
}
let recent: Vec<f64> = self.window.iter().copied().rev().take(9).rev().collect();
{
let last_3 = &recent[6..];
let above = last_3
.iter()
.filter(|&&v| v > base_mean + 2.0 * base_sd)
.count();
let below = last_3
.iter()
.filter(|&&v| v < base_mean - 2.0 * base_sd)
.count();
if above >= 2 || below >= 2 {
tracing::warn!(
spc_rule = 4,
mean_ms = base_mean,
"SPC Rule 4: 2 of 3 consecutive scan latencies beyond ±2σ"
);
return Some(SpcAlert::Rule4);
}
}
{
let above = recent.iter().filter(|&&v| v > base_mean).count();
let below = recent.iter().filter(|&&v| v < base_mean).count();
if above == 9 || below == 9 {
tracing::warn!(
spc_rule = 2,
mean_ms = base_mean,
"SPC Rule 2: 9 consecutive scan latencies on same side of mean"
);
return Some(SpcAlert::Rule2);
}
}
{
let last_6 = &recent[3..];
let incr = last_6.windows(2).all(|w| w[1] > w[0]);
let decr = last_6.windows(2).all(|w| w[1] < w[0]);
if incr || decr {
tracing::warn!(
spc_rule = 3,
increasing = incr,
"SPC Rule 3: 6 consecutive scan latencies monotonically trending"
);
return Some(SpcAlert::Rule3);
}
}
None
}
fn update_stats(&mut self, x: f64) {
self.count += 1;
let delta = x - self.mean;
self.mean += delta / self.count as f64;
let delta2 = x - self.mean;
self.m2 += delta * delta2;
}
fn recompute_stats(&mut self) {
let n = self.window.len();
if n == 0 {
self.mean = 0.0;
self.m2 = 0.0;
self.count = 0;
return;
}
let mean: f64 = self.window.iter().sum::<f64>() / n as f64;
let m2: f64 = self.window.iter().map(|&x| (x - mean).powi(2)).sum();
self.mean = mean;
self.m2 = m2;
self.count = n as u64;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rule2_fires_on_nine_consecutive_above_mean() {
let mut mon = SpcMonitor::new();
for i in 0..20u32 {
let v = if i % 2 == 0 { 9.5 } else { 10.5 };
let alert = mon.push(v);
assert!(!matches!(alert, Some(SpcAlert::Rule2)), "premature at {i}");
}
let baseline_mean = mon.mean();
assert!(
(baseline_mean - 10.0).abs() < 0.5,
"bad baseline: {baseline_mean}"
);
let _ = mon.push(baseline_mean - 1.0);
for i in 0..8u32 {
let alert = mon.push(baseline_mean + 1.0);
assert!(!matches!(alert, Some(SpcAlert::Rule2)), "too early at {i}");
}
assert_eq!(mon.push(baseline_mean + 1.0), Some(SpcAlert::Rule2));
}
#[test]
fn rule2_does_not_fire_on_alternating_samples() {
let mut mon = SpcMonitor::new();
for i in 0..29u32 {
let v = if i % 2 == 0 { 9.0 } else { 11.0 };
assert!(
!matches!(mon.push(v), Some(SpcAlert::Rule2)),
"fired at {i}"
);
}
}
#[test]
fn rule1_fires_on_outlier() {
let mut mon = SpcMonitor::new();
for i in 0..20u32 {
mon.push(10.0 + (i % 3) as f64 * 0.5);
}
let mean = mon.mean();
let sd = mon.std_dev();
let outlier = mean + 4.0 * sd;
assert!(matches!(mon.push(outlier), Some(SpcAlert::Rule1(_))));
}
}