lsp_max/primitives/
spc.rs1use std::collections::VecDeque;
15
16const WINDOW_CAPACITY: usize = 50;
17
18#[derive(Debug, Clone, PartialEq)]
20pub enum SpcAlert {
21 Rule1(f64),
23 Rule2,
25 Rule3,
27 Rule4,
29}
30
31#[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 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 pub fn mean(&self) -> f64 {
63 self.mean
64 }
65
66 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 pub fn push(&mut self, sample_ms: f64) -> Option<SpcAlert> {
81 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 if base_n < 2 || base_sd == 0.0 {
95 return None;
96 }
97
98 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 {
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 {
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 {
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 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 let _ = mon.push(baseline_mean - 1.0);
216
217 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 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}