Skip to main content

cbtop/
adaptive_threshold.rs

1//! Adaptive Threshold Learning System (PMAT-037)
2//!
3//! Dynamic threshold learning that adjusts warning/critical bounds based on historical baseline data.
4//!
5//! # Features
6//!
7//! - Baseline learning from historical samples (μ±2σ)
8//! - Percentile-based threshold computation
9//! - Outlier filtering to prevent over-learning
10//! - User override support for static thresholds
11//!
12//! # Falsification Criteria (F1291-F1300)
13//!
14//! See `tests/adaptive_threshold_f1291.rs` for falsification tests.
15
16/// Minimum samples required for learning
17pub const MIN_SAMPLES_FOR_LEARNING: usize = 10;
18
19/// Default confidence level for bounds (95%)
20pub const DEFAULT_CONFIDENCE_LEVEL: f64 = 0.95;
21
22/// Default outlier threshold (3 standard deviations)
23pub const DEFAULT_OUTLIER_THRESHOLD: f64 = 3.0;
24
25/// Threshold direction
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ThresholdDirection {
28    /// Upper bound (warn if above)
29    Upper,
30    /// Lower bound (warn if below)
31    Lower,
32    /// Both bounds (warn if outside range)
33    Both,
34}
35
36impl ThresholdDirection {
37    /// Get direction name
38    pub fn name(&self) -> &'static str {
39        match self {
40            Self::Upper => "upper",
41            Self::Lower => "lower",
42            Self::Both => "both",
43        }
44    }
45}
46
47/// Learned threshold bounds
48#[derive(Debug, Clone)]
49pub struct LearnedThreshold {
50    /// Metric name
51    pub metric: String,
52    /// Sample mean
53    pub mean: f64,
54    /// Sample standard deviation
55    pub std_dev: f64,
56    /// Number of samples
57    pub sample_count: usize,
58    /// Lower bound (warning)
59    pub lower_bound: f64,
60    /// Upper bound (warning)
61    pub upper_bound: f64,
62    /// Lower bound (critical)
63    pub lower_critical: f64,
64    /// Upper bound (critical)
65    pub upper_critical: f64,
66    /// Coefficient of variation
67    pub cv: f64,
68    /// Confidence level used
69    pub confidence_level: f64,
70    /// Threshold direction
71    pub direction: ThresholdDirection,
72}
73
74impl LearnedThreshold {
75    /// Check if value is within warning bounds
76    pub fn is_warning(&self, value: f64) -> bool {
77        match self.direction {
78            ThresholdDirection::Upper => value > self.upper_bound,
79            ThresholdDirection::Lower => value < self.lower_bound,
80            ThresholdDirection::Both => value < self.lower_bound || value > self.upper_bound,
81        }
82    }
83
84    /// Check if value is critical
85    pub fn is_critical(&self, value: f64) -> bool {
86        match self.direction {
87            ThresholdDirection::Upper => value > self.upper_critical,
88            ThresholdDirection::Lower => value < self.lower_critical,
89            ThresholdDirection::Both => value < self.lower_critical || value > self.upper_critical,
90        }
91    }
92
93    /// Check if value is normal
94    pub fn is_normal(&self, value: f64) -> bool {
95        !self.is_warning(value)
96    }
97
98    /// Export to JSON
99    pub fn to_json(&self) -> String {
100        format!(
101            r#"{{"metric":"{}","mean":{},"std_dev":{},"cv":{},"lower_bound":{},"upper_bound":{},"sample_count":{}}}"#,
102            self.metric,
103            self.mean,
104            self.std_dev,
105            self.cv,
106            self.lower_bound,
107            self.upper_bound,
108            self.sample_count
109        )
110    }
111}
112
113/// Threshold learner
114#[derive(Debug)]
115pub struct ThresholdLearner {
116    /// Metric name
117    metric: String,
118    /// Historical samples
119    samples: Vec<f64>,
120    /// Maximum samples to keep
121    max_samples: usize,
122    /// Outlier threshold (std devs)
123    outlier_threshold: f64,
124    /// Confidence level
125    confidence_level: f64,
126    /// Warning multiplier (std devs from mean)
127    warning_multiplier: f64,
128    /// Critical multiplier (std devs from mean)
129    critical_multiplier: f64,
130    /// Threshold direction
131    direction: ThresholdDirection,
132    /// User override value (if set)
133    override_value: Option<f64>,
134}
135
136impl ThresholdLearner {
137    /// Create new learner
138    pub fn new(metric: &str) -> Self {
139        Self {
140            metric: metric.to_string(),
141            samples: Vec::new(),
142            max_samples: 1000,
143            outlier_threshold: DEFAULT_OUTLIER_THRESHOLD,
144            confidence_level: DEFAULT_CONFIDENCE_LEVEL,
145            warning_multiplier: 2.0,
146            critical_multiplier: 3.0,
147            direction: ThresholdDirection::Upper,
148            override_value: None,
149        }
150    }
151
152    /// Set threshold direction
153    pub fn with_direction(mut self, direction: ThresholdDirection) -> Self {
154        self.direction = direction;
155        self
156    }
157
158    /// Set warning multiplier
159    pub fn with_warning_multiplier(mut self, multiplier: f64) -> Self {
160        self.warning_multiplier = multiplier.max(0.5);
161        self
162    }
163
164    /// Set critical multiplier
165    pub fn with_critical_multiplier(mut self, multiplier: f64) -> Self {
166        self.critical_multiplier = multiplier.max(1.0);
167        self
168    }
169
170    /// Set outlier threshold
171    pub fn with_outlier_threshold(mut self, threshold: f64) -> Self {
172        self.outlier_threshold = threshold.max(2.0);
173        self
174    }
175
176    /// Set max samples
177    pub fn with_max_samples(mut self, max: usize) -> Self {
178        self.max_samples = max.max(10);
179        self
180    }
181
182    /// Set user override
183    pub fn with_override(mut self, value: f64) -> Self {
184        self.override_value = Some(value);
185        self
186    }
187
188    /// Clear override
189    pub fn clear_override(&mut self) {
190        self.override_value = None;
191    }
192
193    /// Add sample
194    pub fn add_sample(&mut self, value: f64) {
195        self.samples.push(value);
196
197        // Trim to max samples
198        if self.samples.len() > self.max_samples {
199            self.samples.remove(0);
200        }
201    }
202
203    /// Add multiple samples
204    pub fn add_samples(&mut self, values: &[f64]) {
205        for &v in values {
206            self.add_sample(v);
207        }
208    }
209
210    /// Get sample count
211    pub fn sample_count(&self) -> usize {
212        self.samples.len()
213    }
214
215    /// Check if sufficient samples
216    pub fn has_sufficient_samples(&self) -> bool {
217        self.samples.len() >= MIN_SAMPLES_FOR_LEARNING
218    }
219
220    /// Calculate mean
221    fn mean(data: &[f64]) -> f64 {
222        if data.is_empty() {
223            return 0.0;
224        }
225        data.iter().sum::<f64>() / data.len() as f64
226    }
227
228    /// Calculate standard deviation
229    fn std_dev(data: &[f64], mean: f64) -> f64 {
230        if data.len() < 2 {
231            return 0.0;
232        }
233        let variance =
234            data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (data.len() - 1) as f64;
235        variance.sqrt()
236    }
237
238    /// Filter outliers
239    pub fn filter_outliers(&self) -> Vec<f64> {
240        if self.samples.len() < 3 {
241            return self.samples.clone();
242        }
243
244        let mean = Self::mean(&self.samples);
245        let std_dev = Self::std_dev(&self.samples, mean);
246
247        if std_dev < 1e-10 {
248            return self.samples.clone();
249        }
250
251        self.samples
252            .iter()
253            .filter(|&&x| ((x - mean) / std_dev).abs() <= self.outlier_threshold)
254            .copied()
255            .collect()
256    }
257
258    /// Learn baseline threshold
259    pub fn learn_baseline(&self) -> Option<LearnedThreshold> {
260        if !self.has_sufficient_samples() {
261            return None;
262        }
263
264        // Filter outliers first
265        let filtered = self.filter_outliers();
266        if filtered.len() < MIN_SAMPLES_FOR_LEARNING {
267            return None;
268        }
269
270        let mean = Self::mean(&filtered);
271        let std_dev = Self::std_dev(&filtered, mean);
272        let cv = if mean.abs() > 1e-10 {
273            (std_dev / mean.abs()) * 100.0
274        } else {
275            0.0
276        };
277
278        // Compute bounds
279        let (lower_bound, upper_bound) = match self.direction {
280            ThresholdDirection::Upper => {
281                (f64::NEG_INFINITY, mean + self.warning_multiplier * std_dev)
282            }
283            ThresholdDirection::Lower => (mean - self.warning_multiplier * std_dev, f64::INFINITY),
284            ThresholdDirection::Both => (
285                mean - self.warning_multiplier * std_dev,
286                mean + self.warning_multiplier * std_dev,
287            ),
288        };
289
290        let (lower_critical, upper_critical) = match self.direction {
291            ThresholdDirection::Upper => {
292                (f64::NEG_INFINITY, mean + self.critical_multiplier * std_dev)
293            }
294            ThresholdDirection::Lower => (mean - self.critical_multiplier * std_dev, f64::INFINITY),
295            ThresholdDirection::Both => (
296                mean - self.critical_multiplier * std_dev,
297                mean + self.critical_multiplier * std_dev,
298            ),
299        };
300
301        Some(LearnedThreshold {
302            metric: self.metric.clone(),
303            mean,
304            std_dev,
305            sample_count: filtered.len(),
306            lower_bound,
307            upper_bound,
308            lower_critical,
309            upper_critical,
310            cv,
311            confidence_level: self.confidence_level,
312            direction: self.direction,
313        })
314    }
315
316    /// Get percentile threshold
317    pub fn percentile_threshold(&self, percentile: f64) -> Option<f64> {
318        if self.samples.is_empty() {
319            return None;
320        }
321
322        let mut sorted = self.samples.clone();
323        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
324
325        let p = percentile.clamp(0.0, 100.0);
326        let idx = ((p / 100.0) * (sorted.len() - 1) as f64).round() as usize;
327        Some(sorted[idx.min(sorted.len() - 1)])
328    }
329
330    /// Get effective threshold (respects override)
331    pub fn get_effective_threshold(&self) -> Option<f64> {
332        if let Some(override_val) = self.override_value {
333            return Some(override_val);
334        }
335
336        self.learn_baseline().map(|t| t.upper_bound)
337    }
338
339    /// Check value against learned threshold
340    pub fn check(&self, value: f64) -> ThresholdCheck {
341        // Override takes precedence
342        if let Some(override_val) = self.override_value {
343            let is_exceeded = match self.direction {
344                ThresholdDirection::Upper => value > override_val,
345                ThresholdDirection::Lower => value < override_val,
346                ThresholdDirection::Both => false, // Not applicable for single override
347            };
348            return ThresholdCheck {
349                value,
350                threshold: override_val,
351                is_warning: is_exceeded,
352                is_critical: false,
353                is_override: true,
354            };
355        }
356
357        // Use learned threshold
358        if let Some(learned) = self.learn_baseline() {
359            ThresholdCheck {
360                value,
361                threshold: learned.upper_bound,
362                is_warning: learned.is_warning(value),
363                is_critical: learned.is_critical(value),
364                is_override: false,
365            }
366        } else {
367            // Insufficient data
368            ThresholdCheck {
369                value,
370                threshold: 0.0,
371                is_warning: false,
372                is_critical: false,
373                is_override: false,
374            }
375        }
376    }
377
378    /// Clear all samples
379    pub fn clear(&mut self) {
380        self.samples.clear();
381    }
382}
383
384/// Result of threshold check
385#[derive(Debug, Clone)]
386pub struct ThresholdCheck {
387    /// Checked value
388    pub value: f64,
389    /// Threshold used
390    pub threshold: f64,
391    /// Is warning triggered
392    pub is_warning: bool,
393    /// Is critical triggered
394    pub is_critical: bool,
395    /// Was override used
396    pub is_override: bool,
397}
398
399impl ThresholdCheck {
400    /// Check if passed (not warning or critical)
401    pub fn passed(&self) -> bool {
402        !self.is_warning && !self.is_critical
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn test_threshold_direction() {
412        assert_eq!(ThresholdDirection::Upper.name(), "upper");
413        assert_eq!(ThresholdDirection::Lower.name(), "lower");
414        assert_eq!(ThresholdDirection::Both.name(), "both");
415    }
416
417    #[test]
418    fn test_learner_creation() {
419        let learner = ThresholdLearner::new("cpu_temp");
420        assert_eq!(learner.sample_count(), 0);
421        assert!(!learner.has_sufficient_samples());
422    }
423
424    #[test]
425    fn test_add_samples() {
426        let mut learner = ThresholdLearner::new("test");
427        learner.add_sample(10.0);
428        learner.add_sample(11.0);
429        assert_eq!(learner.sample_count(), 2);
430    }
431
432    #[test]
433    fn test_learn_baseline() {
434        let mut learner = ThresholdLearner::new("test");
435
436        // Add enough samples
437        for i in 0..20 {
438            learner.add_sample(100.0 + (i % 3) as f64);
439        }
440
441        let threshold = learner.learn_baseline().unwrap();
442        assert!(threshold.mean > 99.0 && threshold.mean < 103.0);
443        assert!(threshold.std_dev > 0.0);
444    }
445
446    #[test]
447    fn test_override() {
448        let mut learner = ThresholdLearner::new("test").with_override(50.0);
449
450        for i in 0..20 {
451            learner.add_sample(100.0 + i as f64);
452        }
453
454        let effective = learner.get_effective_threshold().unwrap();
455        assert_eq!(effective, 50.0);
456    }
457
458    #[test]
459    fn test_percentile() {
460        let mut learner = ThresholdLearner::new("test");
461
462        for i in 0..100 {
463            learner.add_sample(i as f64);
464        }
465
466        let p50 = learner.percentile_threshold(50.0).unwrap();
467        assert!((p50 - 50.0).abs() < 1.0);
468    }
469}