Skip to main content

llmtrace_security/
fpr_monitor.rs

1//! Production FPR monitoring with drift detection (R-IS-01).
2//!
3//! This module provides rolling-window false-positive rate monitoring per
4//! detection category, with drift detection and alert generation.  When the
5//! observed FPR deviates from an established baseline beyond a configurable
6//! threshold, the monitor produces [`FprDriftAlert`] values that can be
7//! converted into [`SecurityFinding`]s for the standard alerting pipeline.
8//!
9//! # Usage
10//!
11//! ```
12//! use llmtrace_security::fpr_monitor::{FprMonitor, FprMonitorConfig};
13//! use std::time::Duration;
14//!
15//! let config = FprMonitorConfig {
16//!     window_duration: Duration::from_secs(600),
17//!     drift_threshold: 0.02,
18//!     min_window_samples: 100,
19//!     categories: vec!["injection".into(), "jailbreak".into()],
20//! };
21//! let mut monitor = FprMonitor::new(config);
22//! monitor.record_event("injection", true, 0.92);
23//! ```
24
25use std::collections::{HashMap, VecDeque};
26use std::time::{Duration, Instant};
27
28use llmtrace_core::{SecurityFinding, SecuritySeverity};
29
30// ────────────────────────────────────────────────────────────────────────────
31// Configuration
32// ────────────────────────────────────────────────────────────────────────────
33
34/// Configuration for the FPR monitor.
35#[derive(Debug, Clone)]
36pub struct FprMonitorConfig {
37    /// Maximum age of events retained in the sliding window.
38    pub window_duration: Duration,
39    /// Minimum absolute FPR deviation from baseline that triggers an alert.
40    pub drift_threshold: f64,
41    /// Minimum number of samples in the window before drift detection fires.
42    pub min_window_samples: usize,
43    /// Detection categories to monitor.
44    pub categories: Vec<String>,
45}
46
47impl Default for FprMonitorConfig {
48    fn default() -> Self {
49        Self {
50            window_duration: Duration::from_secs(600),
51            drift_threshold: 0.02,
52            min_window_samples: 100,
53            categories: Vec::new(),
54        }
55    }
56}
57
58// ────────────────────────────────────────────────────────────────────────────
59// Event record
60// ────────────────────────────────────────────────────────────────────────────
61
62/// A single recorded detection event.
63#[derive(Debug, Clone)]
64struct FprEvent {
65    timestamp: Instant,
66    was_flagged: bool,
67    confidence: f64,
68}
69
70// ────────────────────────────────────────────────────────────────────────────
71// Drift alert
72// ────────────────────────────────────────────────────────────────────────────
73
74/// Alert produced when FPR drift is detected for a category.
75#[derive(Debug, Clone)]
76pub struct FprDriftAlert {
77    /// Detection category that drifted.
78    pub category: String,
79    /// Expected baseline FPR for this category.
80    pub baseline_fpr: f64,
81    /// Observed FPR in the current window.
82    pub current_fpr: f64,
83    /// Absolute deviation: `|current_fpr - baseline_fpr|`.
84    pub deviation: f64,
85    /// Number of samples in the current window.
86    pub window_size: usize,
87    /// When the drift was detected.
88    pub detected_at: Instant,
89}
90
91// ────────────────────────────────────────────────────────────────────────────
92// Per-category summary
93// ────────────────────────────────────────────────────────────────────────────
94
95/// Summary statistics for a single monitored category.
96#[derive(Debug, Clone)]
97pub struct FprCategorySummary {
98    /// Detection category name.
99    pub category: String,
100    /// Current FPR in the sliding window.
101    pub current_fpr: f64,
102    /// Total number of samples in the window.
103    pub sample_count: usize,
104    /// Number of flagged samples in the window.
105    pub flagged_count: usize,
106    /// Mean confidence score across all samples in the window.
107    pub avg_confidence: f64,
108}
109
110/// Aggregated summary across all monitored categories.
111#[derive(Debug, Clone)]
112pub struct FprMonitorSummary {
113    /// Per-category summaries.
114    pub categories: Vec<FprCategorySummary>,
115}
116
117// ────────────────────────────────────────────────────────────────────────────
118// FPR Monitor
119// ────────────────────────────────────────────────────────────────────────────
120
121/// Rolling-window FPR monitor with per-category drift detection.
122#[derive(Debug)]
123pub struct FprMonitor {
124    config: FprMonitorConfig,
125    windows: HashMap<String, VecDeque<FprEvent>>,
126}
127
128impl FprMonitor {
129    /// Create a new monitor with the given configuration.
130    ///
131    /// Pre-initialises empty windows for each configured category.
132    #[must_use]
133    pub fn new(config: FprMonitorConfig) -> Self {
134        let mut windows = HashMap::new();
135        for cat in &config.categories {
136            windows.insert(cat.clone(), VecDeque::new());
137        }
138        Self { config, windows }
139    }
140
141    /// Record a detection event for the given category.
142    ///
143    /// If the category was not in the initial config, it is created on the fly.
144    pub fn record_event(&mut self, category: &str, was_flagged: bool, confidence: f64) {
145        self.record_event_at(category, Instant::now(), was_flagged, confidence);
146    }
147
148    /// Record a detection event with an explicit timestamp (useful for testing).
149    pub fn record_event_at(
150        &mut self,
151        category: &str,
152        at: Instant,
153        was_flagged: bool,
154        confidence: f64,
155    ) {
156        let window = self.windows.entry(category.to_string()).or_default();
157        prune_window(window, self.config.window_duration);
158        window.push_back(FprEvent {
159            timestamp: at,
160            was_flagged,
161            confidence: confidence.clamp(0.0, 1.0),
162        });
163    }
164
165    /// Compute the current FPR for a category within the sliding window.
166    ///
167    /// Returns `None` if the category has no recorded events.
168    #[must_use]
169    pub fn current_fpr(&mut self, category: &str) -> Option<f64> {
170        let window = self.windows.get_mut(category)?;
171        prune_window(window, self.config.window_duration);
172        if window.is_empty() {
173            return None;
174        }
175        let flagged = window.iter().filter(|e| e.was_flagged).count();
176        Some(flagged as f64 / window.len() as f64)
177    }
178
179    /// Check whether the current FPR for `category` has drifted from `baseline_fpr`.
180    ///
181    /// Returns `Some(alert)` when the absolute deviation exceeds the configured
182    /// threshold and the window contains at least `min_window_samples` events.
183    /// Returns `None` otherwise.
184    pub fn check_drift(&mut self, category: &str, baseline_fpr: f64) -> Option<FprDriftAlert> {
185        let window = self.windows.get_mut(category)?;
186        prune_window(window, self.config.window_duration);
187
188        let count = window.len();
189        if count < self.config.min_window_samples {
190            return None;
191        }
192
193        let flagged = window.iter().filter(|e| e.was_flagged).count();
194        let current = flagged as f64 / count as f64;
195        let deviation = (current - baseline_fpr).abs();
196
197        if deviation < self.config.drift_threshold {
198            return None;
199        }
200
201        Some(FprDriftAlert {
202            category: category.to_string(),
203            baseline_fpr,
204            current_fpr: current,
205            deviation,
206            window_size: count,
207            detected_at: Instant::now(),
208        })
209    }
210
211    /// Check drift across all categories against the provided baselines.
212    pub fn check_all_drift(&mut self, baselines: &HashMap<String, f64>) -> Vec<FprDriftAlert> {
213        let categories: Vec<String> = baselines.keys().cloned().collect();
214        let mut alerts = Vec::new();
215        for cat in categories {
216            if let Some(baseline) = baselines.get(&cat) {
217                if let Some(alert) = self.check_drift(&cat, *baseline) {
218                    alerts.push(alert);
219                }
220            }
221        }
222        alerts
223    }
224
225    /// Convert a slice of drift alerts into [`SecurityFinding`]s.
226    #[must_use]
227    pub fn to_security_findings(alerts: &[FprDriftAlert]) -> Vec<SecurityFinding> {
228        alerts.iter().map(alert_to_finding).collect()
229    }
230
231    /// Produce a summary of all tracked categories.
232    pub fn summary(&mut self) -> FprMonitorSummary {
233        let categories: Vec<String> = self.windows.keys().cloned().collect();
234        let mut summaries = Vec::with_capacity(categories.len());
235        for cat in categories {
236            if let Some(cs) = self.category_summary(&cat) {
237                summaries.push(cs);
238            }
239        }
240        summaries.sort_by(|a, b| a.category.cmp(&b.category));
241        FprMonitorSummary {
242            categories: summaries,
243        }
244    }
245
246    /// Produce a summary for a single category.
247    fn category_summary(&mut self, category: &str) -> Option<FprCategorySummary> {
248        let window = self.windows.get_mut(category)?;
249        prune_window(window, self.config.window_duration);
250
251        let sample_count = window.len();
252        let flagged_count = window.iter().filter(|e| e.was_flagged).count();
253        let current_fpr = if sample_count == 0 {
254            0.0
255        } else {
256            flagged_count as f64 / sample_count as f64
257        };
258        let avg_confidence = if sample_count == 0 {
259            0.0
260        } else {
261            window.iter().map(|e| e.confidence).sum::<f64>() / sample_count as f64
262        };
263
264        Some(FprCategorySummary {
265            category: category.to_string(),
266            current_fpr,
267            sample_count,
268            flagged_count,
269            avg_confidence,
270        })
271    }
272}
273
274// ────────────────────────────────────────────────────────────────────────────
275// Helpers
276// ────────────────────────────────────────────────────────────────────────────
277
278/// Remove events older than `window_duration` from the front of the deque.
279fn prune_window(window: &mut VecDeque<FprEvent>, window_duration: Duration) {
280    let cutoff = Instant::now() - window_duration;
281    while window.front().is_some_and(|e| e.timestamp < cutoff) {
282        window.pop_front();
283    }
284}
285
286/// Convert a single drift alert into a [`SecurityFinding`].
287#[must_use]
288fn alert_to_finding(alert: &FprDriftAlert) -> SecurityFinding {
289    let severity = if alert.deviation >= 0.10 {
290        SecuritySeverity::High
291    } else if alert.deviation >= 0.05 {
292        SecuritySeverity::Medium
293    } else {
294        SecuritySeverity::Low
295    };
296
297    let description = format!(
298        "FPR drift detected for category '{}': baseline={:.4}, current={:.4}, deviation={:.4} ({} samples)",
299        alert.category,
300        alert.baseline_fpr,
301        alert.current_fpr,
302        alert.deviation,
303        alert.window_size,
304    );
305
306    let requires_alert = severity >= SecuritySeverity::High;
307    let mut finding = SecurityFinding::new(
308        severity,
309        "fpr_drift".to_string(),
310        description,
311        1.0 - alert.deviation.min(1.0),
312    );
313    finding
314        .metadata
315        .insert("category".to_string(), alert.category.clone());
316    finding.metadata.insert(
317        "baseline_fpr".to_string(),
318        format!("{:.6}", alert.baseline_fpr),
319    );
320    finding.metadata.insert(
321        "current_fpr".to_string(),
322        format!("{:.6}", alert.current_fpr),
323    );
324    finding
325        .metadata
326        .insert("deviation".to_string(), format!("{:.6}", alert.deviation));
327    finding
328        .metadata
329        .insert("window_size".to_string(), alert.window_size.to_string());
330    finding.requires_alert = requires_alert;
331    finding
332}
333
334// ────────────────────────────────────────────────────────────────────────────
335// Tests
336// ────────────────────────────────────────────────────────────────────────────
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn default_config() -> FprMonitorConfig {
343        FprMonitorConfig {
344            window_duration: Duration::from_secs(600),
345            drift_threshold: 0.02,
346            min_window_samples: 10,
347            categories: vec!["injection".into(), "jailbreak".into()],
348        }
349    }
350
351    // -- Empty monitor returns no drift ------------------------------------
352
353    #[test]
354    fn empty_monitor_returns_no_drift() {
355        let mut monitor = FprMonitor::new(default_config());
356        let result = monitor.check_drift("injection", 0.05);
357        assert!(result.is_none());
358    }
359
360    #[test]
361    fn empty_monitor_current_fpr_is_none_for_unknown() {
362        let mut monitor = FprMonitor::new(default_config());
363        assert!(monitor.current_fpr("nonexistent").is_none());
364    }
365
366    #[test]
367    fn empty_monitor_current_fpr_is_none_for_known_category() {
368        let mut monitor = FprMonitor::new(default_config());
369        assert!(monitor.current_fpr("injection").is_none());
370    }
371
372    // -- Recording events updates counts -----------------------------------
373
374    #[test]
375    fn recording_events_updates_counts() {
376        let mut monitor = FprMonitor::new(default_config());
377        monitor.record_event("injection", true, 0.9);
378        monitor.record_event("injection", false, 0.2);
379        monitor.record_event("injection", true, 0.85);
380
381        let summary = monitor.summary();
382        let inj = summary
383            .categories
384            .iter()
385            .find(|c| c.category == "injection")
386            .unwrap();
387        assert_eq!(inj.sample_count, 3);
388        assert_eq!(inj.flagged_count, 2);
389    }
390
391    #[test]
392    fn current_fpr_reflects_recorded_events() {
393        let mut monitor = FprMonitor::new(default_config());
394        for _ in 0..3 {
395            monitor.record_event("injection", true, 0.9);
396        }
397        for _ in 0..7 {
398            monitor.record_event("injection", false, 0.1);
399        }
400        let fpr = monitor.current_fpr("injection").unwrap();
401        assert!((fpr - 0.3).abs() < f64::EPSILON);
402    }
403
404    // -- Per-category tracking ---------------------------------------------
405
406    #[test]
407    fn per_category_tracking() {
408        let mut monitor = FprMonitor::new(default_config());
409        monitor.record_event("injection", true, 0.9);
410        monitor.record_event("injection", false, 0.1);
411        monitor.record_event("jailbreak", true, 0.8);
412
413        let inj_fpr = monitor.current_fpr("injection").unwrap();
414        let jb_fpr = monitor.current_fpr("jailbreak").unwrap();
415
416        assert!((inj_fpr - 0.5).abs() < f64::EPSILON);
417        assert!((jb_fpr - 1.0).abs() < f64::EPSILON);
418    }
419
420    // -- Drift detection fires when exceeding threshold --------------------
421
422    #[test]
423    fn drift_fires_when_exceeding_threshold() {
424        let config = FprMonitorConfig {
425            min_window_samples: 10,
426            drift_threshold: 0.02,
427            ..default_config()
428        };
429        let mut monitor = FprMonitor::new(config);
430
431        // Baseline: 5% FPR. Observed: 30% (3 flagged out of 10).
432        for _ in 0..3 {
433            monitor.record_event("injection", true, 0.9);
434        }
435        for _ in 0..7 {
436            monitor.record_event("injection", false, 0.1);
437        }
438
439        let alert = monitor.check_drift("injection", 0.05);
440        assert!(alert.is_some());
441        let alert = alert.unwrap();
442        assert_eq!(alert.category, "injection");
443        assert!((alert.baseline_fpr - 0.05).abs() < f64::EPSILON);
444        assert!((alert.current_fpr - 0.3).abs() < f64::EPSILON);
445        assert!((alert.deviation - 0.25).abs() < f64::EPSILON);
446        assert_eq!(alert.window_size, 10);
447    }
448
449    // -- Drift detection does NOT fire below threshold ---------------------
450
451    #[test]
452    fn no_drift_below_threshold() {
453        let config = FprMonitorConfig {
454            min_window_samples: 10,
455            drift_threshold: 0.05,
456            ..default_config()
457        };
458        let mut monitor = FprMonitor::new(config);
459
460        // Baseline: 10% FPR. Observed: 10% (1 flagged out of 10). Deviation = 0.
461        monitor.record_event("injection", true, 0.9);
462        for _ in 0..9 {
463            monitor.record_event("injection", false, 0.1);
464        }
465
466        let alert = monitor.check_drift("injection", 0.10);
467        assert!(alert.is_none());
468    }
469
470    #[test]
471    fn drift_fires_when_exactly_at_threshold() {
472        let config = FprMonitorConfig {
473            min_window_samples: 10,
474            drift_threshold: 0.10,
475            ..default_config()
476        };
477        let mut monitor = FprMonitor::new(config);
478
479        // Observed: 20% FPR (2/10). Baseline: 10%. Deviation: 10% = threshold exactly.
480        for _ in 0..2 {
481            monitor.record_event("injection", true, 0.9);
482        }
483        for _ in 0..8 {
484            monitor.record_event("injection", false, 0.1);
485        }
486
487        // deviation = 0.10, threshold = 0.10: fires because deviation >= threshold
488        let alert = monitor.check_drift("injection", 0.10);
489        assert!(alert.is_some());
490        let alert = alert.unwrap();
491        assert!((alert.deviation - 0.10).abs() < f64::EPSILON);
492    }
493
494    // -- Min samples enforcement -------------------------------------------
495
496    #[test]
497    fn min_samples_enforcement() {
498        let config = FprMonitorConfig {
499            min_window_samples: 100,
500            drift_threshold: 0.02,
501            ..default_config()
502        };
503        let mut monitor = FprMonitor::new(config);
504
505        // Only 10 events, well above baseline, but under min_window_samples.
506        for _ in 0..10 {
507            monitor.record_event("injection", true, 0.9);
508        }
509
510        let alert = monitor.check_drift("injection", 0.05);
511        assert!(alert.is_none());
512    }
513
514    #[test]
515    fn drift_fires_once_min_samples_reached() {
516        let config = FprMonitorConfig {
517            min_window_samples: 20,
518            drift_threshold: 0.02,
519            ..default_config()
520        };
521        let mut monitor = FprMonitor::new(config);
522
523        // 19 events: not enough
524        for _ in 0..19 {
525            monitor.record_event("injection", true, 0.9);
526        }
527        assert!(monitor.check_drift("injection", 0.05).is_none());
528
529        // 20th event: now enough
530        monitor.record_event("injection", true, 0.9);
531        assert!(monitor.check_drift("injection", 0.05).is_some());
532    }
533
534    // -- Window pruning of old events --------------------------------------
535
536    #[test]
537    fn window_prunes_old_events() {
538        let config = FprMonitorConfig {
539            window_duration: Duration::from_secs(2),
540            min_window_samples: 1,
541            drift_threshold: 0.02,
542            ..default_config()
543        };
544        let mut monitor = FprMonitor::new(config);
545
546        let old = Instant::now() - Duration::from_secs(10);
547        monitor.record_event_at("injection", old, true, 0.9);
548        monitor.record_event_at("injection", old, true, 0.9);
549
550        // Recent event
551        monitor.record_event("injection", false, 0.1);
552
553        // Old events should be pruned
554        let fpr = monitor.current_fpr("injection").unwrap();
555        assert!(fpr.abs() < f64::EPSILON);
556
557        let summary = monitor.summary();
558        let inj = summary
559            .categories
560            .iter()
561            .find(|c| c.category == "injection")
562            .unwrap();
563        assert_eq!(inj.sample_count, 1);
564        assert_eq!(inj.flagged_count, 0);
565    }
566
567    #[test]
568    fn pruned_events_do_not_affect_drift() {
569        let config = FprMonitorConfig {
570            window_duration: Duration::from_secs(1),
571            min_window_samples: 5,
572            drift_threshold: 0.02,
573            ..default_config()
574        };
575        let mut monitor = FprMonitor::new(config);
576
577        let old = Instant::now() - Duration::from_secs(10);
578        // 10 old flagged events
579        for _ in 0..10 {
580            monitor.record_event_at("injection", old, true, 0.9);
581        }
582        // 5 recent clean events
583        for _ in 0..5 {
584            monitor.record_event("injection", false, 0.1);
585        }
586
587        // Baseline 50%: after pruning, current FPR = 0%, so deviation = 0.50.
588        // But the old events are gone, current FPR = 0.
589        let fpr = monitor.current_fpr("injection").unwrap();
590        assert!(fpr.abs() < f64::EPSILON);
591    }
592
593    // -- Multiple categories tracked independently -------------------------
594
595    #[test]
596    fn multiple_categories_independent() {
597        let mut monitor = FprMonitor::new(default_config());
598
599        // Injection: 100% flagged
600        for _ in 0..10 {
601            monitor.record_event("injection", true, 0.9);
602        }
603        // Jailbreak: 0% flagged
604        for _ in 0..10 {
605            monitor.record_event("jailbreak", false, 0.1);
606        }
607
608        let baselines: HashMap<String, f64> =
609            [("injection".into(), 0.05), ("jailbreak".into(), 0.05)]
610                .into_iter()
611                .collect();
612
613        let alerts = monitor.check_all_drift(&baselines);
614        // Only injection should drift (1.0 vs 0.05 = 0.95 deviation)
615        // Jailbreak: 0.0 vs 0.05 = 0.05 deviation > 0.02 threshold, so also drifts
616        assert_eq!(alerts.len(), 2);
617
618        let inj_alert = alerts.iter().find(|a| a.category == "injection").unwrap();
619        assert!((inj_alert.current_fpr - 1.0).abs() < f64::EPSILON);
620
621        let jb_alert = alerts.iter().find(|a| a.category == "jailbreak").unwrap();
622        assert!(jb_alert.current_fpr.abs() < f64::EPSILON);
623    }
624
625    #[test]
626    fn check_all_drift_only_drifted_categories() {
627        let config = FprMonitorConfig {
628            min_window_samples: 10,
629            drift_threshold: 0.05,
630            ..default_config()
631        };
632        let mut monitor = FprMonitor::new(config);
633
634        // Injection: 50% flagged (baseline 5% -> deviation 45%)
635        for _ in 0..5 {
636            monitor.record_event("injection", true, 0.9);
637        }
638        for _ in 0..5 {
639            monitor.record_event("injection", false, 0.1);
640        }
641
642        // Jailbreak: ~5% flagged, close to baseline
643        monitor.record_event("jailbreak", true, 0.8);
644        for _ in 0..19 {
645            monitor.record_event("jailbreak", false, 0.1);
646        }
647
648        let baselines: HashMap<String, f64> =
649            [("injection".into(), 0.05), ("jailbreak".into(), 0.05)]
650                .into_iter()
651                .collect();
652
653        let alerts = monitor.check_all_drift(&baselines);
654        assert_eq!(alerts.len(), 1);
655        assert_eq!(alerts[0].category, "injection");
656    }
657
658    // -- Summary computation -----------------------------------------------
659
660    #[test]
661    fn summary_computes_stats() {
662        let mut monitor = FprMonitor::new(default_config());
663
664        monitor.record_event("injection", true, 0.90);
665        monitor.record_event("injection", false, 0.10);
666        monitor.record_event("injection", true, 0.80);
667        monitor.record_event("injection", false, 0.20);
668
669        let summary = monitor.summary();
670        let inj = summary
671            .categories
672            .iter()
673            .find(|c| c.category == "injection")
674            .unwrap();
675
676        assert_eq!(inj.sample_count, 4);
677        assert_eq!(inj.flagged_count, 2);
678        assert!((inj.current_fpr - 0.5).abs() < f64::EPSILON);
679        assert!((inj.avg_confidence - 0.5).abs() < f64::EPSILON);
680    }
681
682    #[test]
683    fn summary_empty_category_has_zero_stats() {
684        let mut monitor = FprMonitor::new(default_config());
685        let summary = monitor.summary();
686        for cat in &summary.categories {
687            assert_eq!(cat.sample_count, 0);
688            assert_eq!(cat.flagged_count, 0);
689            assert!(cat.current_fpr.abs() < f64::EPSILON);
690            assert!(cat.avg_confidence.abs() < f64::EPSILON);
691        }
692    }
693
694    #[test]
695    fn summary_sorted_by_category() {
696        let config = FprMonitorConfig {
697            categories: vec!["zzz".into(), "aaa".into(), "mmm".into()],
698            ..default_config()
699        };
700        let mut monitor = FprMonitor::new(config);
701        let summary = monitor.summary();
702        let names: Vec<&str> = summary
703            .categories
704            .iter()
705            .map(|c| c.category.as_str())
706            .collect();
707        assert_eq!(names, vec!["aaa", "mmm", "zzz"]);
708    }
709
710    // -- SecurityFinding generation from alerts ----------------------------
711
712    #[test]
713    fn security_finding_from_small_drift() {
714        let alert = FprDriftAlert {
715            category: "injection".into(),
716            baseline_fpr: 0.05,
717            current_fpr: 0.08,
718            deviation: 0.03,
719            window_size: 200,
720            detected_at: Instant::now(),
721        };
722        let findings = FprMonitor::to_security_findings(&[alert]);
723        assert_eq!(findings.len(), 1);
724        let f = &findings[0];
725        assert_eq!(f.finding_type, "fpr_drift");
726        assert_eq!(f.severity, SecuritySeverity::Low);
727        assert!(!f.requires_alert);
728        assert!(f.description.contains("injection"));
729        assert_eq!(f.metadata["category"], "injection");
730        assert_eq!(f.metadata["window_size"], "200");
731    }
732
733    #[test]
734    fn security_finding_from_medium_drift() {
735        let alert = FprDriftAlert {
736            category: "pii".into(),
737            baseline_fpr: 0.01,
738            current_fpr: 0.08,
739            deviation: 0.07,
740            window_size: 500,
741            detected_at: Instant::now(),
742        };
743        let findings = FprMonitor::to_security_findings(&[alert]);
744        assert_eq!(findings[0].severity, SecuritySeverity::Medium);
745    }
746
747    #[test]
748    fn security_finding_from_large_drift() {
749        let alert = FprDriftAlert {
750            category: "toxicity".into(),
751            baseline_fpr: 0.02,
752            current_fpr: 0.15,
753            deviation: 0.13,
754            window_size: 1000,
755            detected_at: Instant::now(),
756        };
757        let findings = FprMonitor::to_security_findings(&[alert]);
758        assert_eq!(findings[0].severity, SecuritySeverity::High);
759        assert!(findings[0].requires_alert);
760    }
761
762    #[test]
763    fn security_finding_metadata_populated() {
764        let alert = FprDriftAlert {
765            category: "injection".into(),
766            baseline_fpr: 0.05,
767            current_fpr: 0.30,
768            deviation: 0.25,
769            window_size: 100,
770            detected_at: Instant::now(),
771        };
772        let findings = FprMonitor::to_security_findings(&[alert]);
773        let f = &findings[0];
774        assert_eq!(f.metadata["category"], "injection");
775        assert!(f.metadata.contains_key("baseline_fpr"));
776        assert!(f.metadata.contains_key("current_fpr"));
777        assert!(f.metadata.contains_key("deviation"));
778        assert!(f.metadata.contains_key("window_size"));
779    }
780
781    #[test]
782    fn empty_alerts_produce_empty_findings() {
783        let findings = FprMonitor::to_security_findings(&[]);
784        assert!(findings.is_empty());
785    }
786
787    // -- Edge cases --------------------------------------------------------
788
789    #[test]
790    fn single_sample_below_min_window() {
791        let mut monitor = FprMonitor::new(default_config());
792        monitor.record_event("injection", true, 0.95);
793
794        let fpr = monitor.current_fpr("injection").unwrap();
795        assert!((fpr - 1.0).abs() < f64::EPSILON);
796
797        // min_window_samples = 10, only 1 sample -> no drift
798        assert!(monitor.check_drift("injection", 0.05).is_none());
799    }
800
801    #[test]
802    fn all_flagged() {
803        let config = FprMonitorConfig {
804            min_window_samples: 5,
805            ..default_config()
806        };
807        let mut monitor = FprMonitor::new(config);
808        for _ in 0..10 {
809            monitor.record_event("injection", true, 0.99);
810        }
811
812        let fpr = monitor.current_fpr("injection").unwrap();
813        assert!((fpr - 1.0).abs() < f64::EPSILON);
814
815        let alert = monitor.check_drift("injection", 0.05);
816        assert!(alert.is_some());
817        let alert = alert.unwrap();
818        assert!((alert.deviation - 0.95).abs() < f64::EPSILON);
819    }
820
821    #[test]
822    fn none_flagged() {
823        let config = FprMonitorConfig {
824            min_window_samples: 5,
825            ..default_config()
826        };
827        let mut monitor = FprMonitor::new(config);
828        for _ in 0..10 {
829            monitor.record_event("injection", false, 0.01);
830        }
831
832        let fpr = monitor.current_fpr("injection").unwrap();
833        assert!(fpr.abs() < f64::EPSILON);
834
835        // Baseline 0% -> deviation 0 -> no drift
836        assert!(monitor.check_drift("injection", 0.0).is_none());
837    }
838
839    #[test]
840    fn dynamic_category_creation() {
841        let mut monitor = FprMonitor::new(default_config());
842        // "pii" was not in initial config
843        monitor.record_event("pii", true, 0.7);
844        let fpr = monitor.current_fpr("pii").unwrap();
845        assert!((fpr - 1.0).abs() < f64::EPSILON);
846    }
847
848    #[test]
849    fn confidence_clamped_to_unit_range() {
850        let mut monitor = FprMonitor::new(default_config());
851        monitor.record_event("injection", true, 1.5);
852        monitor.record_event("injection", false, -0.5);
853
854        let summary = monitor.summary();
855        let inj = summary
856            .categories
857            .iter()
858            .find(|c| c.category == "injection")
859            .unwrap();
860        assert!((inj.avg_confidence - 0.5).abs() < f64::EPSILON);
861    }
862
863    #[test]
864    fn drift_below_baseline_also_detected() {
865        let config = FprMonitorConfig {
866            min_window_samples: 10,
867            drift_threshold: 0.02,
868            ..default_config()
869        };
870        let mut monitor = FprMonitor::new(config);
871
872        // Baseline: 50% FPR. Observed: 0% FPR. Deviation: 0.50.
873        for _ in 0..10 {
874            monitor.record_event("injection", false, 0.1);
875        }
876
877        let alert = monitor.check_drift("injection", 0.50);
878        assert!(alert.is_some());
879        let alert = alert.unwrap();
880        assert!((alert.deviation - 0.50).abs() < f64::EPSILON);
881    }
882
883    #[test]
884    fn multiple_findings_from_multiple_alerts() {
885        let alerts = vec![
886            FprDriftAlert {
887                category: "injection".into(),
888                baseline_fpr: 0.05,
889                current_fpr: 0.30,
890                deviation: 0.25,
891                window_size: 100,
892                detected_at: Instant::now(),
893            },
894            FprDriftAlert {
895                category: "jailbreak".into(),
896                baseline_fpr: 0.03,
897                current_fpr: 0.10,
898                deviation: 0.07,
899                window_size: 200,
900                detected_at: Instant::now(),
901            },
902        ];
903        let findings = FprMonitor::to_security_findings(&alerts);
904        assert_eq!(findings.len(), 2);
905
906        let types: Vec<&str> = findings.iter().map(|f| f.finding_type.as_str()).collect();
907        assert!(types.iter().all(|t| *t == "fpr_drift"));
908    }
909}