1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5pub struct ThresholdRange {
6 pub min: f64,
7 pub max: f64,
8}
9
10impl ThresholdRange {
11 pub fn new(min: f64, max: f64) -> Self {
12 Self { min, max }
13 }
14
15 pub fn contains(&self, value: f64) -> bool {
16 value >= self.min && value <= self.max
17 }
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct StaticThreshold {
23 pub range: ThresholdRange,
24}
25
26impl StaticThreshold {
27 pub fn new(min: f64, max: f64) -> Self {
28 Self {
29 range: ThresholdRange::new(min, max),
30 }
31 }
32
33 pub fn check(&self, value: f64) -> ThresholdResult {
34 if value.is_nan() {
35 return ThresholdResult {
36 value,
37 within_range: false,
38 distance_from_range: f64::NAN,
39 confidence: 0.0,
40 };
41 }
42 let within = self.range.contains(value);
43 let distance = if within {
44 0.0
45 } else if value < self.range.min {
46 self.range.min - value
47 } else {
48 value - self.range.max
49 };
50 ThresholdResult {
51 value,
52 within_range: within,
53 distance_from_range: distance,
54 confidence: 1.0,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub enum ThresholdMethod {
62 Static,
63 MovingAverage { window: usize },
64 ExponentialSmoothing { alpha: f64 },
65 Percentile { lower: f64, upper: f64 },
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ThresholdConfig {
71 pub method: ThresholdMethod,
72 pub warmup_samples: usize,
73 pub sensitivity: f64,
74}
75
76impl Default for ThresholdConfig {
77 fn default() -> Self {
78 Self {
79 method: ThresholdMethod::MovingAverage { window: 10 },
80 warmup_samples: 5,
81 sensitivity: 2.0,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct ThresholdResult {
89 pub value: f64,
90 pub within_range: bool,
91 pub distance_from_range: f64,
92 pub confidence: f64,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct AdaptiveThreshold {
98 pub base: ThresholdRange,
99 pub std_dev_multiplier: f64,
100 pub history: Vec<f64>,
101 pub config: ThresholdConfig,
102}
103
104impl AdaptiveThreshold {
105 pub fn new(config: ThresholdConfig) -> Self {
106 Self {
107 base: ThresholdRange::new(f64::NEG_INFINITY, f64::INFINITY),
108 std_dev_multiplier: config.sensitivity,
109 history: Vec::new(),
110 config,
111 }
112 }
113
114 pub fn is_warmed_up(&self) -> bool {
115 self.history.len() >= self.config.warmup_samples
116 }
117
118 pub fn update(&mut self, value: f64) {
119 if !value.is_nan() {
120 self.history.push(value);
121 }
122 }
123
124 pub fn current_range(&self) -> ThresholdRange {
125 if !self.is_warmed_up() {
126 return ThresholdRange::new(f64::NEG_INFINITY, f64::INFINITY);
127 }
128
129 let data: Vec<f64> = self.history.iter().copied().filter(|v| !v.is_nan()).collect();
130 if data.is_empty() {
131 return ThresholdRange::new(f64::NEG_INFINITY, f64::INFINITY);
132 }
133
134 let (center, spread) = match &self.config.method {
135 ThresholdMethod::Static => {
136 let mean = data.iter().sum::<f64>() / data.len() as f64;
137 let std_dev = compute_std_dev(&data, mean);
138 (mean, std_dev)
139 }
140 ThresholdMethod::MovingAverage { window } => {
141 let smoothed = moving_average(&data, *window);
142 let last = *smoothed.last().unwrap_or(&0.0);
143 let std_dev = compute_std_dev(&data, last);
144 (last, std_dev)
145 }
146 ThresholdMethod::ExponentialSmoothing { alpha } => {
147 let smoothed = exponential_smoothing(&data, *alpha);
148 let last = *smoothed.last().unwrap_or(&0.0);
149 let std_dev = compute_std_dev(&data, last);
150 (last, std_dev)
151 }
152 ThresholdMethod::Percentile { lower, upper } => {
153 let lo = percentile(&data, *lower);
154 let hi = percentile(&data, *upper);
155 return ThresholdRange::new(lo, hi);
156 }
157 };
158
159 let margin = spread * self.std_dev_multiplier;
160 ThresholdRange::new(center - margin, center + margin)
161 }
162
163 pub fn check(&self, value: f64) -> ThresholdResult {
164 if value.is_nan() {
165 return ThresholdResult {
166 value,
167 within_range: false,
168 distance_from_range: f64::NAN,
169 confidence: 0.0,
170 };
171 }
172
173 let range = self.current_range();
174 let within = range.contains(value);
175 let distance = if within {
176 0.0
177 } else if value < range.min {
178 range.min - value
179 } else {
180 value - range.max
181 };
182
183 let confidence = if self.is_warmed_up() {
184 1.0_f64.min(self.history.len() as f64 / (self.config.warmup_samples * 3) as f64)
185 } else {
186 0.0
187 };
188
189 ThresholdResult {
190 value,
191 within_range: within,
192 distance_from_range: distance,
193 confidence,
194 }
195 }
196}
197
198pub fn moving_average(data: &[f64], window: usize) -> Vec<f64> {
201 if window == 0 || data.is_empty() {
202 return data.to_vec();
203 }
204 let mut result = Vec::with_capacity(data.len());
205 for i in 0..data.len() {
206 let start = i.saturating_sub(window - 1);
207 let window_slice = &data[start..=i];
208 let avg = window_slice.iter().sum::<f64>() / window_slice.len() as f64;
209 result.push(avg);
210 }
211 result
212}
213
214pub fn exponential_smoothing(data: &[f64], alpha: f64) -> Vec<f64> {
215 if data.is_empty() {
216 return Vec::new();
217 }
218 let alpha = alpha.clamp(0.0, 1.0);
219 let mut result = Vec::with_capacity(data.len());
220 result.push(data[0]);
221 for i in 1..data.len() {
222 let prev = result[i - 1];
223 let smoothed = alpha * data[i] + (1.0 - alpha) * prev;
224 result.push(smoothed);
225 }
226 result
227}
228
229pub fn percentile(data: &[f64], p: f64) -> f64 {
230 if data.is_empty() {
231 return f64::NAN;
232 }
233 let p = p.clamp(0.0, 100.0);
234 let mut sorted: Vec<f64> = data.iter().copied().filter(|v| !v.is_nan()).collect();
235 if sorted.is_empty() {
236 return f64::NAN;
237 }
238 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
239
240 if sorted.len() == 1 {
241 return sorted[0];
242 }
243
244 let idx = (p / 100.0) * (sorted.len() - 1) as f64;
245 let lower = idx.floor() as usize;
246 let upper = idx.ceil() as usize;
247 let lower = lower.min(sorted.len() - 1);
248 let upper = upper.min(sorted.len() - 1);
249 let frac = idx - lower as f64;
250 sorted[lower] * (1.0 - frac) + sorted[upper] * frac
251}
252
253fn compute_std_dev(data: &[f64], mean: f64) -> f64 {
254 if data.is_empty() {
255 return 0.0;
256 }
257 let variance: f64 =
258 data.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / data.len() as f64;
259 variance.sqrt()
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
269 fn static_threshold_in_range() {
270 let t = StaticThreshold::new(0.0, 100.0);
271 let r = t.check(50.0);
272 assert!(r.within_range);
273 assert_eq!(r.distance_from_range, 0.0);
274 }
275
276 #[test]
277 fn static_threshold_above_range() {
278 let t = StaticThreshold::new(0.0, 100.0);
279 let r = t.check(150.0);
280 assert!(!r.within_range);
281 assert!((r.distance_from_range - 50.0).abs() < 1e-9);
282 }
283
284 #[test]
285 fn static_threshold_below_range() {
286 let t = StaticThreshold::new(0.0, 100.0);
287 let r = t.check(-10.0);
288 assert!(!r.within_range);
289 assert!((r.distance_from_range - 10.0).abs() < 1e-9);
290 }
291
292 #[test]
293 fn static_threshold_boundary() {
294 let t = StaticThreshold::new(0.0, 100.0);
295 assert!(t.check(0.0).within_range);
296 assert!(t.check(100.0).within_range);
297 }
298
299 #[test]
302 fn moving_average_basic() {
303 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
304 let result = moving_average(&data, 3);
305 assert!((result[0] - 1.0).abs() < 1e-9);
306 assert!((result[2] - 2.0).abs() < 1e-9);
307 assert!((result[4] - 4.0).abs() < 1e-9);
308 }
309
310 #[test]
311 fn moving_average_window_one() {
312 let data = vec![1.0, 2.0, 3.0];
313 let result = moving_average(&data, 1);
314 assert_eq!(result, data);
315 }
316
317 #[test]
318 fn moving_average_empty() {
319 let data: Vec<f64> = vec![];
320 let result = moving_average(&data, 3);
321 assert!(result.is_empty());
322 }
323
324 #[test]
327 fn exponential_smoothing_basic() {
328 let data = vec![1.0, 2.0, 3.0];
329 let result = exponential_smoothing(&data, 0.5);
330 assert!((result[0] - 1.0).abs() < 1e-9);
331 assert!((result[1] - 1.5).abs() < 1e-9);
332 assert!((result[2] - 2.25).abs() < 1e-9);
333 }
334
335 #[test]
336 fn exponential_smoothing_alpha_zero() {
337 let data = vec![1.0, 2.0, 3.0];
338 let result = exponential_smoothing(&data, 0.0);
339 assert!((result[0] - 1.0).abs() < 1e-9);
340 assert!((result[1] - 1.0).abs() < 1e-9);
341 assert!((result[2] - 1.0).abs() < 1e-9);
342 }
343
344 #[test]
345 fn exponential_smoothing_alpha_one() {
346 let data = vec![1.0, 2.0, 3.0];
347 let result = exponential_smoothing(&data, 1.0);
348 assert_eq!(result, data);
349 }
350
351 #[test]
354 fn percentile_median() {
355 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
356 let result = percentile(&data, 50.0);
357 assert!((result - 3.0).abs() < 1e-9);
358 }
359
360 #[test]
361 fn percentile_min() {
362 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
363 let result = percentile(&data, 0.0);
364 assert!((result - 1.0).abs() < 1e-9);
365 }
366
367 #[test]
368 fn percentile_max() {
369 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
370 let result = percentile(&data, 100.0);
371 assert!((result - 5.0).abs() < 1e-9);
372 }
373
374 #[test]
375 fn percentile_empty() {
376 let data: Vec<f64> = vec![];
377 let result = percentile(&data, 50.0);
378 assert!(result.is_nan());
379 }
380
381 #[test]
384 fn adaptive_threshold_learns_normal_data() {
385 let config = ThresholdConfig {
386 method: ThresholdMethod::MovingAverage { window: 5 },
387 warmup_samples: 3,
388 sensitivity: 2.0,
389 };
390 let mut at = AdaptiveThreshold::new(config);
391 for v in &[10.0, 11.0, 10.5, 10.2, 9.8] {
392 at.update(*v);
393 }
394 assert!(at.is_warmed_up());
395 let result = at.check(10.3);
397 assert!(result.within_range);
398 assert!(result.confidence > 0.0);
399 }
400
401 #[test]
402 fn adaptive_threshold_detects_anomaly() {
403 let config = ThresholdConfig {
404 method: ThresholdMethod::MovingAverage { window: 5 },
405 warmup_samples: 3,
406 sensitivity: 1.0, };
408 let mut at = AdaptiveThreshold::new(config);
409 for v in &[10.0, 10.1, 9.9, 10.0, 10.1] {
410 at.update(*v);
411 }
412 let result = at.check(100.0);
414 assert!(!result.within_range);
415 }
416
417 #[test]
418 fn adaptive_threshold_warmup_period() {
419 let config = ThresholdConfig {
420 method: ThresholdMethod::MovingAverage { window: 3 },
421 warmup_samples: 5,
422 sensitivity: 2.0,
423 };
424 let mut at = AdaptiveThreshold::new(config);
425 assert!(!at.is_warmed_up());
426 at.update(10.0);
427 at.update(10.0);
428 at.update(10.0);
429 at.update(10.0);
430 assert!(!at.is_warmed_up());
431 at.update(10.0);
432 assert!(at.is_warmed_up());
433 }
434
435 #[test]
436 fn adaptive_threshold_not_warmed_accepts_anything() {
437 let config = ThresholdConfig {
438 method: ThresholdMethod::MovingAverage { window: 3 },
439 warmup_samples: 10,
440 sensitivity: 2.0,
441 };
442 let at = AdaptiveThreshold::new(config);
443 let result = at.check(99999.0);
444 assert!(result.within_range); assert_eq!(result.confidence, 0.0);
446 }
447
448 #[test]
451 fn tighter_sensitivity_narrows_range() {
452 let config_tight = ThresholdConfig {
453 method: ThresholdMethod::MovingAverage { window: 5 },
454 warmup_samples: 3,
455 sensitivity: 0.5,
456 };
457 let config_loose = ThresholdConfig {
458 method: ThresholdMethod::MovingAverage { window: 5 },
459 warmup_samples: 3,
460 sensitivity: 3.0,
461 };
462 let data = vec![10.0, 11.0, 9.0, 10.5, 9.5];
463 let mut at_tight = AdaptiveThreshold::new(config_tight);
464 let mut at_loose = AdaptiveThreshold::new(config_loose);
465 for v in &data {
466 at_tight.update(*v);
467 at_loose.update(*v);
468 }
469 let range_tight = at_tight.current_range();
470 let range_loose = at_loose.current_range();
471 assert!(range_tight.max - range_tight.min < range_loose.max - range_loose.min);
472 }
473
474 #[test]
477 fn constant_data() {
478 let config = ThresholdConfig {
479 method: ThresholdMethod::MovingAverage { window: 3 },
480 warmup_samples: 2,
481 sensitivity: 2.0,
482 };
483 let mut at = AdaptiveThreshold::new(config);
484 for _ in 0..5 {
485 at.update(42.0);
486 }
487 let range = at.current_range();
489 assert!((range.min - 42.0).abs() < 1e-9);
490 assert!((range.max - 42.0).abs() < 1e-9);
491 }
492
493 #[test]
494 fn single_sample() {
495 let config = ThresholdConfig {
496 method: ThresholdMethod::MovingAverage { window: 3 },
497 warmup_samples: 1,
498 sensitivity: 2.0,
499 };
500 let mut at = AdaptiveThreshold::new(config);
501 at.update(10.0);
502 assert!(at.is_warmed_up());
503 let range = at.current_range();
504 assert!((range.min - 10.0).abs() < 1e-9);
505 assert!((range.max - 10.0).abs() < 1e-9);
506 }
507
508 #[test]
509 fn nan_handling_static() {
510 let t = StaticThreshold::new(0.0, 100.0);
511 let r = t.check(f64::NAN);
512 assert!(!r.within_range);
513 assert!(r.distance_from_range.is_nan());
514 }
515
516 #[test]
517 fn nan_handling_adaptive() {
518 let config = ThresholdConfig {
519 method: ThresholdMethod::MovingAverage { window: 3 },
520 warmup_samples: 2,
521 sensitivity: 2.0,
522 };
523 let mut at = AdaptiveThreshold::new(config);
524 at.update(f64::NAN);
525 at.update(f64::NAN);
526 assert!(!at.is_warmed_up()); let r = at.check(f64::NAN);
529 assert!(!r.within_range);
530 }
531
532 #[test]
533 fn all_same_value_percentile() {
534 let data = vec![5.0; 10];
535 let result = percentile(&data, 50.0);
536 assert!((result - 5.0).abs() < 1e-9);
537 }
538
539 #[test]
542 fn serde_threshold_range() {
543 let range = ThresholdRange::new(1.0, 10.0);
544 let json = serde_json::to_string(&range).unwrap();
545 let deserialized: ThresholdRange = serde_json::from_str(&json).unwrap();
546 assert_eq!(range, deserialized);
547 }
548
549 #[test]
550 fn serde_adaptive_threshold() {
551 let config = ThresholdConfig {
552 method: ThresholdMethod::ExponentialSmoothing { alpha: 0.3 },
553 warmup_samples: 5,
554 sensitivity: 1.5,
555 };
556 let mut at = AdaptiveThreshold::new(config);
557 at.base = ThresholdRange::new(0.0, 100.0);
558 at.update(10.0);
559 at.update(11.0);
560 at.update(9.0);
561 let json = serde_json::to_string(&at).unwrap();
562 let deserialized: AdaptiveThreshold = serde_json::from_str(&json).unwrap();
563 assert_eq!(deserialized.history.len(), 3);
564 assert!((deserialized.history[0] - 10.0).abs() < 1e-9);
565 assert!((deserialized.base.min - 0.0).abs() < 1e-9);
566 }
567
568 #[test]
569 fn serde_threshold_result() {
570 let result = ThresholdResult {
571 value: 42.0,
572 within_range: true,
573 distance_from_range: 0.0,
574 confidence: 0.95,
575 };
576 let json = serde_json::to_string(&result).unwrap();
577 let deserialized: ThresholdResult = serde_json::from_str(&json).unwrap();
578 assert_eq!(result, deserialized);
579 }
580}