1mod threshold;
18mod types;
19
20pub use threshold::{LearnedWorkloadThreshold, MlThresholdConfig};
21pub use types::{
22 AnomalyResult, ClassificationMetrics, MlThresholdError, MlThresholdResult, TimeSeriesFeatures,
23 WorkloadClass,
24};
25
26use std::collections::HashMap;
27
28#[derive(Debug)]
30pub struct AdaptiveThresholdMl {
31 config: MlThresholdConfig,
33 thresholds: HashMap<WorkloadClass, LearnedWorkloadThreshold>,
35 metrics: ClassificationMetrics,
37 training_mode: bool,
39 global_threshold: f64,
41}
42
43impl AdaptiveThresholdMl {
44 pub fn new(config: MlThresholdConfig) -> Self {
46 Self {
47 config,
48 thresholds: HashMap::new(),
49 metrics: ClassificationMetrics::default(),
50 training_mode: true,
51 global_threshold: 15.0,
52 }
53 }
54
55 pub fn classify_workload(&self, features: &TimeSeriesFeatures) -> WorkloadClass {
57 if features.cv < 10.0 && features.autocorr_lag1 > 0.5 {
59 WorkloadClass::ComputeBound
60 } else if features.cv > 18.0 && features.autocorr_lag1 < 0.3 {
61 WorkloadClass::MemoryBound
62 } else if features.cv < 12.0 {
63 WorkloadClass::Matmul
64 } else if features.cv > 15.0 {
65 WorkloadClass::Ffn
66 } else {
67 WorkloadClass::Attention
68 }
69 }
70
71 pub fn get_threshold(&self, workload: WorkloadClass) -> f64 {
73 if let Some(learned) = self.thresholds.get(&workload) {
74 if learned.confidence >= self.config.min_confidence
75 && learned.training_samples >= self.config.min_training_samples
76 && !learned.is_stale(self.config.max_threshold_age)
77 {
78 return learned.cv_threshold;
79 }
80 }
81
82 workload.default_cv_threshold() * self.config.cold_start_multiplier
84 }
85
86 pub fn detect_anomaly(&self, values: &[f64]) -> MlThresholdResult<AnomalyResult> {
88 let features =
89 TimeSeriesFeatures::extract(values).ok_or(MlThresholdError::InsufficientData {
90 have: values.len(),
91 need: 10,
92 })?;
93
94 let workload = self.classify_workload(&features);
95 let threshold = self.get_threshold(workload);
96
97 let is_anomaly = features.cv > threshold;
98 let score = features.cv / threshold;
99
100 let confidence = self
101 .thresholds
102 .get(&workload)
103 .map(|t| t.confidence)
104 .unwrap_or(0.0);
105
106 let reason = if is_anomaly {
107 format!("CV {:.2}% exceeds threshold {:.2}%", features.cv, threshold)
108 } else {
109 format!("CV {:.2}% within threshold {:.2}%", features.cv, threshold)
110 };
111
112 Ok(AnomalyResult {
113 is_anomaly,
114 score,
115 threshold,
116 confidence,
117 workload_class: workload,
118 reason,
119 })
120 }
121
122 pub fn train(&mut self, values: &[f64], is_anomaly: bool) -> MlThresholdResult<()> {
124 let features =
125 TimeSeriesFeatures::extract(values).ok_or(MlThresholdError::InsufficientData {
126 have: values.len(),
127 need: 10,
128 })?;
129
130 let workload = self.classify_workload(&features);
131
132 let threshold = self
134 .thresholds
135 .entry(workload)
136 .or_insert_with(|| LearnedWorkloadThreshold::new(workload));
137
138 threshold.update(&features, is_anomaly);
139
140 let predicted = features.cv > threshold.cv_threshold;
142 match (predicted, is_anomaly) {
143 (true, true) => self.metrics.true_positives += 1,
144 (true, false) => self.metrics.false_positives += 1,
145 (false, false) => self.metrics.true_negatives += 1,
146 (false, true) => self.metrics.false_negatives += 1,
147 }
148
149 Ok(())
150 }
151
152 pub fn check_drift(&self, values: &[f64]) -> MlThresholdResult<Option<f64>> {
154 let features =
155 TimeSeriesFeatures::extract(values).ok_or(MlThresholdError::InsufficientData {
156 have: values.len(),
157 need: 10,
158 })?;
159
160 let workload = self.classify_workload(&features);
161
162 if let Some(threshold) = self.thresholds.get(&workload) {
163 Ok(threshold.check_drift(&features))
164 } else {
165 Ok(None)
166 }
167 }
168
169 pub fn get_metrics(&self) -> &ClassificationMetrics {
171 &self.metrics
172 }
173
174 pub fn reset_metrics(&mut self) {
176 self.metrics = ClassificationMetrics::default();
177 }
178
179 pub fn get_learned_threshold(
181 &self,
182 workload: WorkloadClass,
183 ) -> Option<&LearnedWorkloadThreshold> {
184 self.thresholds.get(&workload)
185 }
186
187 pub fn learned_workloads(&self) -> Vec<WorkloadClass> {
189 self.thresholds.keys().copied().collect()
190 }
191
192 pub fn export_state(&self) -> HashMap<String, (f64, f64, usize)> {
194 self.thresholds
195 .iter()
196 .map(|(k, v)| {
197 (
198 k.name().to_string(),
199 (v.cv_threshold, v.confidence, v.training_samples),
200 )
201 })
202 .collect()
203 }
204
205 pub fn import_state(&mut self, state: HashMap<String, (f64, f64, usize)>) {
207 for (name, (threshold, confidence, samples)) in state {
208 let Some(workload) = WorkloadClass::from_name(&name) else {
209 continue;
210 };
211
212 let mut learned = LearnedWorkloadThreshold::new(workload);
213 learned.cv_threshold = threshold;
214 learned.confidence = confidence;
215 learned.training_samples = samples;
216
217 self.thresholds.insert(workload, learned);
218 }
219 }
220
221 pub fn config(&self) -> &MlThresholdConfig {
223 &self.config
224 }
225}
226
227pub const DEFAULT_MIN_TRAINING_SAMPLES: usize = 50;
229
230pub const DEFAULT_MIN_CONFIDENCE: f64 = 0.7;
232
233pub const DEFAULT_DRIFT_ZSCORE: f64 = 3.0;
235
236#[cfg(test)]
237mod tests;