1use std::collections::{BTreeMap, VecDeque};
5
6use metrics::{counter, gauge};
7use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use tracing::debug;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum FeedbackLabel {
15 TruePositive,
17 FalsePositive,
19 Unknown,
21}
22
23impl std::fmt::Display for FeedbackLabel {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 match self {
26 Self::TruePositive => write!(f, "true_positive"),
27 Self::FalsePositive => write!(f, "false_positive"),
28 Self::Unknown => write!(f, "unknown"),
29 }
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AnomalyFeedback {
36 pub anomaly_id: String,
38 pub measurement: String,
40 pub tags: BTreeMap<String, String>,
42 pub timestamp: i64,
44 pub label: FeedbackLabel,
46 pub feedback_at: i64,
48 pub principal: String,
50}
51
52pub struct AnomalyPrecisionTracker {
54 feedback: RwLock<VecDeque<AnomalyFeedback>>,
55 min_precision: f64,
57 max_entries: usize,
59}
60
61#[derive(Debug, Clone)]
63pub struct PrecisionStats {
64 pub total_labeled: usize,
66 pub true_positives: usize,
68 pub false_positives: usize,
70 pub precision: Option<f64>,
72 pub below_threshold: bool,
74}
75
76impl AnomalyPrecisionTracker {
77 #[must_use]
79 pub fn new(min_precision: f64) -> Self {
80 Self {
81 feedback: RwLock::new(VecDeque::new()),
82 min_precision,
83 max_entries: 100_000,
84 }
85 }
86
87 #[must_use]
89 pub fn with_max_entries(mut self, max: usize) -> Self {
90 self.max_entries = max;
91 self
92 }
93
94 pub fn submit_feedback(&self, feedback: AnomalyFeedback) {
96 counter!(
97 "chronix_anomaly_feedback_total",
98 "label" => feedback.label.to_string()
99 )
100 .increment(1);
101
102 if feedback.label == FeedbackLabel::FalsePositive {
103 counter!("chronix_anomaly_false_positives_total").increment(1);
104 }
105
106 let mut store = self.feedback.write();
107 store.push_back(feedback);
108 while store.len() > self.max_entries {
109 store.pop_front();
110 }
111
112 let stats = Self::compute_precision_inner(store.iter(), self.min_precision);
114 if let Some(p) = stats.precision {
115 gauge!("chronix_anomaly_precision_ratio").set(p);
116 }
117
118 debug!(
119 total_feedback = store.len(),
120 precision = ?stats.precision,
121 "Anomaly feedback submitted"
122 );
123 }
124
125 #[must_use]
127 pub fn precision(&self) -> PrecisionStats {
128 let store = self.feedback.read();
129 Self::compute_precision_inner(store.iter(), self.min_precision)
130 }
131
132 #[must_use]
134 pub fn precision_for_measurement(&self, measurement: &str) -> PrecisionStats {
135 let store = self.feedback.read();
136 Self::compute_precision_inner(
137 store.iter().filter(|f| f.measurement == measurement),
138 self.min_precision,
139 )
140 }
141
142 #[must_use]
144 pub fn all_feedback(&self) -> Vec<AnomalyFeedback> {
145 self.feedback.read().iter().cloned().collect()
146 }
147
148 #[must_use]
150 pub fn feedback_for_measurement(&self, measurement: &str) -> Vec<AnomalyFeedback> {
151 self.feedback
152 .read()
153 .iter()
154 .filter(|f| f.measurement == measurement)
155 .cloned()
156 .collect()
157 }
158
159 #[must_use]
161 pub fn false_positives(&self) -> Vec<AnomalyFeedback> {
162 self.feedback
163 .read()
164 .iter()
165 .filter(|f| f.label == FeedbackLabel::FalsePositive)
166 .cloned()
167 .collect()
168 }
169
170 #[must_use]
172 pub fn count(&self) -> usize {
173 self.feedback.read().len()
174 }
175
176 fn compute_precision_inner<'a>(
177 entries: impl Iterator<Item = &'a AnomalyFeedback>,
178 min_precision: f64,
179 ) -> PrecisionStats {
180 let mut tp = 0usize;
181 let mut fp = 0usize;
182 for e in entries {
183 match e.label {
184 FeedbackLabel::TruePositive => tp += 1,
185 FeedbackLabel::FalsePositive => fp += 1,
186 FeedbackLabel::Unknown => {}
187 }
188 }
189 let total_labeled = tp + fp;
190 let precision = if total_labeled > 0 {
191 Some(tp as f64 / total_labeled as f64)
192 } else {
193 None
194 };
195 PrecisionStats {
196 total_labeled,
197 true_positives: tp,
198 false_positives: fp,
199 precision,
200 below_threshold: precision.is_some_and(|p| p < min_precision),
201 }
202 }
203}
204
205impl Default for AnomalyPrecisionTracker {
206 fn default() -> Self {
207 Self::new(0.8)
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn make_feedback(id: &str, measurement: &str, label: FeedbackLabel) -> AnomalyFeedback {
216 AnomalyFeedback {
217 anomaly_id: id.to_string(),
218 measurement: measurement.to_string(),
219 tags: BTreeMap::new(),
220 timestamp: 1_700_000_000_000,
221 label,
222 feedback_at: 1_700_000_001_000,
223 principal: "analyst".to_string(),
224 }
225 }
226
227 #[test]
228 fn empty_precision() {
229 let tracker = AnomalyPrecisionTracker::new(0.8);
230 let stats = tracker.precision();
231 assert_eq!(stats.total_labeled, 0);
232 assert!(stats.precision.is_none());
233 assert!(!stats.below_threshold);
234 }
235
236 #[test]
237 fn perfect_precision() {
238 let tracker = AnomalyPrecisionTracker::new(0.8);
239 tracker.submit_feedback(make_feedback("a1", "cpu", FeedbackLabel::TruePositive));
240 tracker.submit_feedback(make_feedback("a2", "cpu", FeedbackLabel::TruePositive));
241 tracker.submit_feedback(make_feedback("a3", "cpu", FeedbackLabel::TruePositive));
242
243 let stats = tracker.precision();
244 assert_eq!(stats.true_positives, 3);
245 assert_eq!(stats.false_positives, 0);
246 assert!((stats.precision.unwrap() - 1.0).abs() < f64::EPSILON);
247 assert!(!stats.below_threshold);
248 }
249
250 #[test]
251 fn precision_with_false_positives() {
252 let tracker = AnomalyPrecisionTracker::new(0.8);
253 for i in 0..3 {
255 tracker.submit_feedback(make_feedback(
256 &format!("tp{i}"),
257 "cpu",
258 FeedbackLabel::TruePositive,
259 ));
260 }
261 for i in 0..2 {
262 tracker.submit_feedback(make_feedback(
263 &format!("fp{i}"),
264 "cpu",
265 FeedbackLabel::FalsePositive,
266 ));
267 }
268
269 let stats = tracker.precision();
270 assert_eq!(stats.total_labeled, 5);
271 assert!((stats.precision.unwrap() - 0.6).abs() < f64::EPSILON);
272 assert!(stats.below_threshold); }
274
275 #[test]
276 fn unknown_labels_excluded() {
277 let tracker = AnomalyPrecisionTracker::new(0.8);
278 tracker.submit_feedback(make_feedback("a1", "cpu", FeedbackLabel::TruePositive));
279 tracker.submit_feedback(make_feedback("a2", "cpu", FeedbackLabel::Unknown));
280 tracker.submit_feedback(make_feedback("a3", "cpu", FeedbackLabel::Unknown));
281
282 let stats = tracker.precision();
283 assert_eq!(stats.total_labeled, 1);
284 assert!((stats.precision.unwrap() - 1.0).abs() < f64::EPSILON);
285 assert_eq!(tracker.count(), 3);
286 }
287
288 #[test]
289 fn per_measurement_precision() {
290 let tracker = AnomalyPrecisionTracker::new(0.8);
291 tracker.submit_feedback(make_feedback("a1", "cpu", FeedbackLabel::TruePositive));
292 tracker.submit_feedback(make_feedback("a2", "cpu", FeedbackLabel::FalsePositive));
293 tracker.submit_feedback(make_feedback("a3", "mem", FeedbackLabel::TruePositive));
294
295 let cpu = tracker.precision_for_measurement("cpu");
296 assert!((cpu.precision.unwrap() - 0.5).abs() < f64::EPSILON);
297
298 let mem = tracker.precision_for_measurement("mem");
299 assert!((mem.precision.unwrap() - 1.0).abs() < f64::EPSILON);
300 }
301
302 #[test]
303 fn false_positives_query() {
304 let tracker = AnomalyPrecisionTracker::new(0.8);
305 tracker.submit_feedback(make_feedback("a1", "cpu", FeedbackLabel::TruePositive));
306 tracker.submit_feedback(make_feedback("a2", "cpu", FeedbackLabel::FalsePositive));
307 tracker.submit_feedback(make_feedback("a3", "cpu", FeedbackLabel::FalsePositive));
308
309 let fps = tracker.false_positives();
310 assert_eq!(fps.len(), 2);
311 }
312
313 #[test]
314 fn feedback_label_display() {
315 assert_eq!(FeedbackLabel::TruePositive.to_string(), "true_positive");
316 assert_eq!(FeedbackLabel::FalsePositive.to_string(), "false_positive");
317 assert_eq!(FeedbackLabel::Unknown.to_string(), "unknown");
318 }
319
320 #[test]
321 fn feedback_serde_roundtrip() {
322 let fb = make_feedback("a1", "cpu", FeedbackLabel::TruePositive);
323 let json = serde_json::to_string(&fb).unwrap();
324 let de: AnomalyFeedback = serde_json::from_str(&json).unwrap();
325 assert_eq!(de.anomaly_id, "a1");
326 assert_eq!(de.label, FeedbackLabel::TruePositive);
327 }
328
329 #[test]
330 fn bounded_fifo_eviction() {
331 let tracker = AnomalyPrecisionTracker::new(0.8).with_max_entries(5);
332
333 for i in 0..8 {
335 tracker.submit_feedback(make_feedback(
336 &format!("a{i}"),
337 "cpu",
338 FeedbackLabel::TruePositive,
339 ));
340 }
341
342 assert_eq!(tracker.count(), 5);
343 let all = tracker.all_feedback();
344 let ids: Vec<_> = all.iter().map(|f| f.anomaly_id.as_str()).collect();
346 assert_eq!(ids, vec!["a3", "a4", "a5", "a6", "a7"]);
347 }
348
349 #[test]
350 fn bounded_precision_uses_retained_entries() {
351 let tracker = AnomalyPrecisionTracker::new(0.8).with_max_entries(4);
354
355 tracker.submit_feedback(make_feedback("tp1", "cpu", FeedbackLabel::TruePositive));
356 tracker.submit_feedback(make_feedback("tp2", "cpu", FeedbackLabel::TruePositive));
357 tracker.submit_feedback(make_feedback("fp1", "cpu", FeedbackLabel::FalsePositive));
358 tracker.submit_feedback(make_feedback("fp2", "cpu", FeedbackLabel::FalsePositive));
359
360 let stats = tracker.precision();
361 assert!((stats.precision.unwrap() - 0.5).abs() < f64::EPSILON);
362
363 tracker.submit_feedback(make_feedback("tp3", "cpu", FeedbackLabel::TruePositive));
365 tracker.submit_feedback(make_feedback("tp4", "cpu", FeedbackLabel::TruePositive));
366
367 assert_eq!(tracker.count(), 4);
368 let stats = tracker.precision();
369 assert!((stats.precision.unwrap() - 0.5).abs() < f64::EPSILON);
371 }
372}