Skip to main content

lsp_max/primitives/
spc.rs

1//! Western Electric SPC Rules for scan-latency monitoring.
2//!
3//! Ported from wasm4pm/src/spc.rs. Unlike wasm4pm (where UCL/CL/LCL are
4//! pre-computed externally), `SpcMonitor` owns the sliding window and derives
5//! mean/std-dev via Welford online update, recomputing on window eviction.
6//!
7//! | Rule | Signal | Min buffer |
8//! |------|--------|-----------|
9//! | 1 | Any point beyond ±3σ | 2 |
10//! | 2 | 9 consecutive same side of mean | 9 |
11//! | 3 | 6 consecutive strictly monotone | 6 |
12//! | 4 | 2 of 3 consecutive beyond ±2σ | 3 (reliable from 9) |
13
14use std::collections::VecDeque;
15
16const WINDOW_CAPACITY: usize = 50;
17
18/// A Western Electric special-cause signal.
19#[derive(Debug, Clone, PartialEq)]
20pub enum SpcAlert {
21    /// Rule 1: latest sample beyond ±3σ. Carries the sample value in ms.
22    Rule1(f64),
23    /// Rule 2: 9 consecutive samples on the same side of the mean.
24    Rule2,
25    /// Rule 3: 6 consecutive samples strictly increasing or decreasing.
26    Rule3,
27    /// Rule 4: 2 of 3 consecutive samples beyond ±2σ on the same side.
28    Rule4,
29}
30
31/// Sliding-window SPC monitor for scan-latency samples (milliseconds).
32///
33/// Call `push(sample_ms)` after every `scan_uri_classified()` invocation.
34/// Returns the highest-priority alert when a Western Electric rule fires.
35/// Priority: Rule1 > Rule4 > Rule2 > Rule3.
36#[derive(Debug)]
37pub struct SpcMonitor {
38    window: VecDeque<f64>,
39    mean: f64,
40    m2: f64,
41    count: u64,
42}
43
44impl Default for SpcMonitor {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl SpcMonitor {
51    /// Create a new monitor with an empty window.
52    pub fn new() -> Self {
53        Self {
54            window: VecDeque::with_capacity(WINDOW_CAPACITY),
55            mean: 0.0,
56            m2: 0.0,
57            count: 0,
58        }
59    }
60
61    /// Current window mean in milliseconds (0.0 with no samples).
62    pub fn mean(&self) -> f64 {
63        self.mean
64    }
65
66    /// Sample standard deviation (Bessel-corrected). Returns 0.0 with <2 samples.
67    pub fn std_dev(&self) -> f64 {
68        if self.count < 2 {
69            return 0.0;
70        }
71        (self.m2 / (self.count - 1) as f64).sqrt()
72    }
73
74    /// Push a new duration sample (ms) and evaluate Western Electric rules.
75    ///
76    /// Rules are evaluated against the pre-sample baseline (mean/σ snapshot before
77    /// `update_stats`). This matches Western Electric semantics: the control-chart
78    /// limits are derived from prior data, not from data that includes the very point
79    /// being evaluated (which would let outliers dilute their own deviation).
80    pub fn push(&mut self, sample_ms: f64) -> Option<SpcAlert> {
81        // Snapshot BEFORE incorporating the new sample.
82        let base_mean = self.mean;
83        let base_sd = self.std_dev();
84        let base_n = self.window.len();
85
86        self.update_stats(sample_ms);
87        self.window.push_back(sample_ms);
88        if self.window.len() > WINDOW_CAPACITY {
89            self.window.pop_front();
90            self.recompute_stats();
91        }
92
93        // Need at least 2 prior samples to have a meaningful baseline.
94        if base_n < 2 || base_sd == 0.0 {
95            return None;
96        }
97
98        // Rule 1 (highest priority) — evaluated against pre-sample baseline.
99        if (sample_ms - base_mean).abs() > 3.0 * base_sd {
100            tracing::warn!(
101                spc_rule = 1,
102                sample_ms,
103                mean_ms = base_mean,
104                sigma_ms = base_sd,
105                "SPC Rule 1: scan latency outlier beyond ±3σ"
106            );
107            return Some(SpcAlert::Rule1(sample_ms));
108        }
109
110        if self.window.len() < 9 {
111            return None;
112        }
113
114        let recent: Vec<f64> = self.window.iter().copied().rev().take(9).rev().collect();
115
116        // Rule 4 — 2 of 3 consecutive beyond ±2σ.
117        {
118            let last_3 = &recent[6..];
119            let above = last_3
120                .iter()
121                .filter(|&&v| v > base_mean + 2.0 * base_sd)
122                .count();
123            let below = last_3
124                .iter()
125                .filter(|&&v| v < base_mean - 2.0 * base_sd)
126                .count();
127            if above >= 2 || below >= 2 {
128                tracing::warn!(
129                    spc_rule = 4,
130                    mean_ms = base_mean,
131                    "SPC Rule 4: 2 of 3 consecutive scan latencies beyond ±2σ"
132                );
133                return Some(SpcAlert::Rule4);
134            }
135        }
136
137        // Rule 2 — 9 consecutive on same side of mean.
138        {
139            let above = recent.iter().filter(|&&v| v > base_mean).count();
140            let below = recent.iter().filter(|&&v| v < base_mean).count();
141            if above == 9 || below == 9 {
142                tracing::warn!(
143                    spc_rule = 2,
144                    mean_ms = base_mean,
145                    "SPC Rule 2: 9 consecutive scan latencies on same side of mean"
146                );
147                return Some(SpcAlert::Rule2);
148            }
149        }
150
151        // Rule 3: last 6 of the 9 strictly monotone.
152        {
153            let last_6 = &recent[3..];
154            let incr = last_6.windows(2).all(|w| w[1] > w[0]);
155            let decr = last_6.windows(2).all(|w| w[1] < w[0]);
156            if incr || decr {
157                tracing::warn!(
158                    spc_rule = 3,
159                    increasing = incr,
160                    "SPC Rule 3: 6 consecutive scan latencies monotonically trending"
161                );
162                return Some(SpcAlert::Rule3);
163            }
164        }
165
166        None
167    }
168
169    fn update_stats(&mut self, x: f64) {
170        self.count += 1;
171        let delta = x - self.mean;
172        self.mean += delta / self.count as f64;
173        let delta2 = x - self.mean;
174        self.m2 += delta * delta2;
175    }
176
177    fn recompute_stats(&mut self) {
178        let n = self.window.len();
179        if n == 0 {
180            self.mean = 0.0;
181            self.m2 = 0.0;
182            self.count = 0;
183            return;
184        }
185        let mean: f64 = self.window.iter().sum::<f64>() / n as f64;
186        let m2: f64 = self.window.iter().map(|&x| (x - mean).powi(2)).sum();
187        self.mean = mean;
188        self.m2 = m2;
189        self.count = n as u64;
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn rule2_fires_on_nine_consecutive_above_mean() {
199        let mut mon = SpcMonitor::new();
200        // Warm up: alternating to establish a stable baseline.
201        for i in 0..20u32 {
202            let v = if i % 2 == 0 { 9.5 } else { 10.5 };
203            let alert = mon.push(v);
204            assert!(!matches!(alert, Some(SpcAlert::Rule2)), "premature at {i}");
205        }
206        let baseline_mean = mon.mean();
207        assert!(
208            (baseline_mean - 10.0).abs() < 0.5,
209            "bad baseline: {baseline_mean}"
210        );
211
212        // Anchor: one below-mean sample ensures the elevation run starts clean.
213        // Without this, the last warmup sample (10.5) can sit above the drifting
214        // mean and make the window [10.5, 11.0×8] appear as 9-in-a-row.
215        let _ = mon.push(baseline_mean - 1.0);
216
217        // Push 8 elevated samples — Rule 2 must NOT fire yet.
218        for i in 0..8u32 {
219            let alert = mon.push(baseline_mean + 1.0);
220            assert!(!matches!(alert, Some(SpcAlert::Rule2)), "too early at {i}");
221        }
222        // 9th consecutive above-mean → Rule 2 fires.
223        assert_eq!(mon.push(baseline_mean + 1.0), Some(SpcAlert::Rule2));
224    }
225
226    #[test]
227    fn rule2_does_not_fire_on_alternating_samples() {
228        let mut mon = SpcMonitor::new();
229        for i in 0..29u32 {
230            let v = if i % 2 == 0 { 9.0 } else { 11.0 };
231            assert!(
232                !matches!(mon.push(v), Some(SpcAlert::Rule2)),
233                "fired at {i}"
234            );
235        }
236    }
237
238    #[test]
239    fn rule1_fires_on_outlier() {
240        let mut mon = SpcMonitor::new();
241        for i in 0..20u32 {
242            mon.push(10.0 + (i % 3) as f64 * 0.5);
243        }
244        let mean = mon.mean();
245        let sd = mon.std_dev();
246        let outlier = mean + 4.0 * sd;
247        assert!(matches!(mon.push(outlier), Some(SpcAlert::Rule1(_))));
248    }
249}