Skip to main content

chronix_analytics/
feedback.rs

1//! Anomaly false-positive feedback — precision tracking and threshold
2//! auto-adjustment recommendations.
3
4use std::collections::{BTreeMap, VecDeque};
5
6use metrics::{counter, gauge};
7use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use tracing::debug;
10
11/// Feedback label for an anomaly detection.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum FeedbackLabel {
15    /// Confirmed anomaly.
16    TruePositive,
17    /// False alarm.
18    FalsePositive,
19    /// Unknown / not labeled.
20    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/// A feedback entry for an anomaly.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AnomalyFeedback {
36    /// Unique anomaly ID.
37    pub anomaly_id: String,
38    /// Measurement where the anomaly was detected.
39    pub measurement: String,
40    /// Series tags.
41    pub tags: BTreeMap<String, String>,
42    /// Timestamp of the anomaly.
43    pub timestamp: i64,
44    /// Feedback label.
45    pub label: FeedbackLabel,
46    /// When feedback was submitted (nanos since epoch).
47    pub feedback_at: i64,
48    /// Principal who submitted the feedback.
49    pub principal: String,
50}
51
52/// Precision tracker — computes rolling precision from feedback.
53pub struct AnomalyPrecisionTracker {
54    feedback: RwLock<VecDeque<AnomalyFeedback>>,
55    /// Minimum acceptable precision (default: 0.8).
56    min_precision: f64,
57    /// Maximum feedback entries (FIFO eviction). Default: 100,000.
58    max_entries: usize,
59}
60
61/// Precision statistics.
62#[derive(Debug, Clone)]
63pub struct PrecisionStats {
64    /// Total feedback entries (excluding unknown).
65    pub total_labeled: usize,
66    /// True positives.
67    pub true_positives: usize,
68    /// False positives.
69    pub false_positives: usize,
70    /// Precision = TP / (TP + FP). None if no labeled data.
71    pub precision: Option<f64>,
72    /// Whether precision is below the minimum threshold.
73    pub below_threshold: bool,
74}
75
76impl AnomalyPrecisionTracker {
77    /// Create a new precision tracker.
78    #[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    /// Set maximum feedback entries (FIFO eviction when exceeded).
88    #[must_use]
89    pub fn with_max_entries(mut self, max: usize) -> Self {
90        self.max_entries = max;
91        self
92    }
93
94    /// Submit feedback for an anomaly.
95    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        // Update precision gauge
113        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    /// Compute current precision statistics.
126    #[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    /// Compute precision for a specific measurement.
133    #[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    /// Get all feedback entries.
143    #[must_use]
144    pub fn all_feedback(&self) -> Vec<AnomalyFeedback> {
145        self.feedback.read().iter().cloned().collect()
146    }
147
148    /// Get feedback for a measurement.
149    #[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    /// Get false-positive feedback entries.
160    #[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    /// Total feedback count.
171    #[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        // 3 TP, 2 FP → precision = 0.6
254        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); // 0.6 < 0.8
273    }
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        // Submit 8 feedback entries, only last 5 should remain
334        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        // Oldest entries (a0, a1, a2) should have been evicted
345        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        // Max 4 entries: 2 TP then 2 FP → precision=0.5
352        // Then add 2 more TP → evicts the 2 original TP, leaving [FP, FP, TP, TP] → 0.5
353        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        // Add 2 more TP → evicts tp1, tp2 → [fp1, fp2, tp3, tp4]
364        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        // Still 2TP/2FP = 0.5
370        assert!((stats.precision.unwrap() - 0.5).abs() < f64::EPSILON);
371    }
372}