Skip to main content

optirs_core/streaming/adaptive_streaming/
anomaly_detection.rs

1// Anomaly detection for streaming optimization data
2//
3// This module provides comprehensive anomaly detection capabilities for streaming
4// data including statistical outlier detection, machine learning-based methods,
5// ensemble approaches, and adaptive threshold management.
6
7use super::config::*;
8use super::optimizer::{Adaptation, AdaptationType, StreamingDataPoint};
9
10use crate::utils::scalar_or;
11use scirs2_core::numeric::Float;
12use std::collections::{HashMap, VecDeque};
13use std::time::{Duration, Instant};
14
15/// Comprehensive anomaly detector for streaming data
16pub struct AnomalyDetector<A: Float + Send + Sync> {
17    /// Anomaly detection configuration
18    config: AnomalyConfig,
19    /// Statistical anomaly detectors
20    statistical_detectors: HashMap<String, Box<dyn StatisticalAnomalyDetector<A>>>,
21    /// Machine learning-based detectors
22    ml_detectors: HashMap<String, Box<dyn MLAnomalyDetector<A>>>,
23    /// Ensemble detector
24    ensemble_detector: EnsembleAnomalyDetector<A>,
25    /// Adaptive threshold manager
26    threshold_manager: AdaptiveThresholdManager<A>,
27    /// Anomaly history and statistics
28    anomaly_history: VecDeque<AnomalyEvent<A>>,
29    /// False positive tracker
30    false_positive_tracker: FalsePositiveTracker<A>,
31    /// Anomaly response system
32    response_system: AnomalyResponseSystem<A>,
33    /// Bounded window of the most recently scored data points.
34    ///
35    /// `create_anomaly_context`/`calculate_recent_statistics` compute from this
36    /// window; before it existed they returned a hard-coded two-feature summary
37    /// that was wrong for every stream and silently assumed a feature width of
38    /// exactly two.
39    recent_points: VecDeque<StreamingDataPoint<A>>,
40    /// Capacity of `recent_points`.
41    recent_capacity: usize,
42    /// Externally supplied context signals, set by the owning optimizer via
43    /// [`AnomalyDetector::update_context_signals`]. Empty means "the caller has
44    /// not reported any", which is honest — the detector has no direct access
45    /// to the performance tracker, resource manager or drift detector.
46    context_performance_metrics: Vec<A>,
47    context_resource_usage: Vec<A>,
48    context_drift_indicators: Vec<A>,
49    /// Bounded window of recent ensemble anomaly scores, used to recalibrate
50    /// detector thresholds against `AnomalyConfig::contamination_rate`.
51    recent_scores: VecDeque<A>,
52    /// Points scored since the last threshold recalibration.
53    points_since_recalibration: usize,
54}
55
56/// Anomaly event record
57#[derive(Debug, Clone)]
58pub struct AnomalyEvent<A: Float + Send + Sync> {
59    /// Event ID
60    pub id: u64,
61    /// Timestamp of detection
62    pub timestamp: Instant,
63    /// Anomaly type
64    pub anomaly_type: AnomalyType,
65    /// Anomaly severity
66    pub severity: AnomalySeverity,
67    /// Confidence score
68    pub confidence: A,
69    /// Anomalous data point
70    pub data_point: StreamingDataPoint<A>,
71    /// Detector that found the anomaly
72    pub detector_name: String,
73    /// Anomaly score
74    pub anomaly_score: A,
75    /// Context information
76    pub context: AnomalyContext<A>,
77    /// Response actions taken
78    pub response_actions: Vec<String>,
79}
80
81/// Types of anomalies that can be detected
82#[derive(Debug, Clone, PartialEq, Eq, Hash)]
83pub enum AnomalyType {
84    /// Statistical outlier
85    StatisticalOutlier,
86    /// Sudden change in pattern
87    PatternChange,
88    /// Temporal anomaly
89    TemporalAnomaly,
90    /// Spatial anomaly in feature space
91    SpatialAnomaly,
92    /// Contextual anomaly
93    ContextualAnomaly,
94    /// Collective anomaly
95    CollectiveAnomaly,
96    /// Point anomaly
97    PointAnomaly,
98    /// Data quality anomaly
99    DataQualityAnomaly,
100    /// Performance anomaly
101    PerformanceAnomaly,
102    /// Custom anomaly type
103    Custom(String),
104}
105
106/// Anomaly severity levels
107#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
108pub enum AnomalySeverity {
109    /// Low severity
110    Low,
111    /// Medium severity
112    Medium,
113    /// High severity
114    High,
115    /// Critical severity requiring immediate action
116    Critical,
117}
118
119/// Context information for anomaly
120#[derive(Debug, Clone)]
121pub struct AnomalyContext<A: Float + Send + Sync> {
122    /// Recent data statistics
123    pub recent_statistics: DataStatistics<A>,
124    /// Performance metrics at detection time
125    pub performance_metrics: Vec<A>,
126    /// Resource usage at detection time
127    pub resource_usage: Vec<A>,
128    /// Recent drift indicators
129    pub drift_indicators: Vec<A>,
130    /// Time since last anomaly
131    pub time_since_last_anomaly: Duration,
132}
133
134/// Data statistics for anomaly context
135#[derive(Debug, Clone)]
136pub struct DataStatistics<A: Float + Send + Sync> {
137    /// Mean values for features
138    pub means: Vec<A>,
139    /// Standard deviations for features
140    pub std_devs: Vec<A>,
141    /// Minimum values
142    pub min_values: Vec<A>,
143    /// Maximum values
144    pub max_values: Vec<A>,
145    /// Median values
146    pub medians: Vec<A>,
147    /// Skewness values
148    pub skewness: Vec<A>,
149    /// Kurtosis values
150    pub kurtosis: Vec<A>,
151}
152
153/// Trait for statistical anomaly detectors
154pub trait StatisticalAnomalyDetector<A: Float + Send + Sync>: Send + Sync {
155    /// Detects anomalies in the given data point
156    fn detect_anomaly(
157        &mut self,
158        data_point: &StreamingDataPoint<A>,
159    ) -> Result<AnomalyDetectionResult<A>, String>;
160
161    /// Updates the detector with new data
162    fn update(&mut self, data_point: &StreamingDataPoint<A>) -> Result<(), String>;
163
164    /// Resets the detector state
165    fn reset(&mut self);
166
167    /// Gets the detector name
168    fn name(&self) -> String;
169
170    /// Gets current detection threshold
171    fn get_threshold(&self) -> A;
172
173    /// Sets detection threshold
174    fn set_threshold(&mut self, threshold: A);
175}
176
177/// Trait for machine learning-based anomaly detectors
178pub trait MLAnomalyDetector<A: Float + Send + Sync>: Send + Sync {
179    /// Detects anomalies using ML model
180    fn detect_anomaly(
181        &mut self,
182        data_point: &StreamingDataPoint<A>,
183    ) -> Result<AnomalyDetectionResult<A>, String>;
184
185    /// Trains the ML model with new data
186    fn train(&mut self, training_data: &[StreamingDataPoint<A>]) -> Result<(), String>;
187
188    /// Updates the model incrementally
189    fn update_incremental(&mut self, data_point: &StreamingDataPoint<A>) -> Result<(), String>;
190
191    /// Records the ground truth for one of this detector's predictions.
192    ///
193    /// Quality metrics for an unsupervised novelty detector are unknowable
194    /// without labels, so they are only defined once outcomes are fed back in
195    /// through this method (see [`AnomalyDetector::record_detection_outcome`]).
196    fn record_outcome(&mut self, predicted_anomaly: bool, was_true_anomaly: bool);
197
198    /// Gets model performance metrics.
199    ///
200    /// Returns an error while no labelled outcome has been recorded: an
201    /// accuracy figure invented before any ground truth exists would be a
202    /// fabrication, not a measurement.
203    fn get_performance_metrics(&self) -> Result<MLModelMetrics<A>, String>;
204
205    /// Gets detector name
206    fn name(&self) -> String;
207}
208
209// The confusion-matrix counters, the bounded score-retention buffer and the
210// full-curve ROC computation live in `anomaly_scoring`; `DetectionCounters` is
211// re-exported here so its existing path keeps resolving.
212pub use super::anomaly_scoring::DetectionCounters;
213
214/// Result of anomaly detection
215#[derive(Debug, Clone)]
216pub struct AnomalyDetectionResult<A: Float + Send + Sync> {
217    /// Whether an anomaly was detected
218    pub is_anomaly: bool,
219    /// Anomaly score (higher = more anomalous)
220    pub anomaly_score: A,
221    /// Confidence in the detection
222    pub confidence: A,
223    /// Anomaly type if detected
224    pub anomaly_type: Option<AnomalyType>,
225    /// Severity level
226    pub severity: AnomalySeverity,
227    /// Additional metadata
228    pub metadata: HashMap<String, A>,
229}
230
231/// Performance metrics for ML models
232#[derive(Debug, Clone)]
233pub struct MLModelMetrics<A: Float + Send + Sync> {
234    /// Accuracy of anomaly detection
235    pub accuracy: A,
236    /// Precision (true positives / (true positives + false positives))
237    pub precision: A,
238    /// Recall (true positives / (true positives + false negatives))
239    pub recall: A,
240    /// F1 score
241    pub f1_score: A,
242    /// Area under the ROC curve, traced over every threshold the retained
243    /// labelled scores admit, or `None` while fewer than two labelled scores of
244    /// either class have been fed back.
245    ///
246    /// This is deliberately optional. A detector's confusion matrix alone fixes
247    /// exactly one point on the ROC curve, so no area can be derived from it;
248    /// the single-operating-point substitute `(TPR + TNR) / 2` that used to be
249    /// reported here was a different statistic wearing the AUC's name. Call
250    /// [`DetectionCounters::auc_roc`] for the reason it is unavailable.
251    pub auc_roc: Option<A>,
252    /// False positive rate
253    pub false_positive_rate: A,
254    /// Training time
255    pub training_time: Duration,
256    /// Inference time per sample
257    pub inference_time: Duration,
258}
259
260// The ensemble voting detector lives in `anomaly_ensemble`; its types are
261// re-exported here so their existing paths keep resolving.
262pub use super::anomaly_ensemble::{
263    EnsembleAnomalyDetector, EnsembleConfig, EnsembleVotingStrategy,
264};
265
266/// Performance tracking for individual detectors
267#[derive(Debug, Clone)]
268pub struct DetectorPerformance<A: Float + Send + Sync> {
269    /// Recent accuracy
270    pub recent_accuracy: A,
271    /// Historical accuracy
272    pub historical_accuracy: A,
273    /// False positive rate
274    pub false_positive_rate: A,
275    /// False negative rate
276    pub false_negative_rate: A,
277    /// Detection latency
278    pub detection_latency: Duration,
279    /// Reliability score
280    pub reliability_score: A,
281}
282
283/// Adaptive threshold management system
284pub struct AdaptiveThresholdManager<A: Float + Send + Sync> {
285    /// Current thresholds for different detectors
286    thresholds: HashMap<String, A>,
287    /// Threshold bounds
288    threshold_bounds: HashMap<String, (A, A)>,
289}
290
291/// Threshold adaptation strategies
292#[derive(Debug, Clone)]
293pub enum ThresholdAdaptationStrategy {
294    /// Fixed thresholds
295    Fixed,
296    /// Performance-based adaptation
297    PerformanceBased,
298    /// Quantile-based adaptation
299    QuantileBased { quantile: f64 },
300    /// ROC-optimized thresholds
301    ROCOptimized,
302    /// Precision-recall optimized
303    PROptimized,
304    /// False positive rate controlled
305    FPRControlled { target_fpr: f64 },
306    /// Adaptive based on data distribution
307    DistributionAdaptive,
308}
309
310/// Performance feedback for threshold adaptation
311#[derive(Debug, Clone)]
312pub struct ThresholdPerformanceFeedback<A: Float + Send + Sync> {
313    /// Detector name
314    pub detector_name: String,
315    /// Threshold value
316    pub threshold: A,
317    /// True positives
318    pub true_positives: usize,
319    /// False positives
320    pub false_positives: usize,
321    /// True negatives
322    pub true_negatives: usize,
323    /// False negatives
324    pub false_negatives: usize,
325    /// Timestamp
326    pub timestamp: Instant,
327}
328
329/// Threshold adaptation parameters
330#[derive(Debug, Clone)]
331pub struct ThresholdAdaptationParams<A: Float + Send + Sync> {
332    /// Learning rate for threshold updates
333    pub learning_rate: A,
334    /// Momentum for threshold changes
335    pub momentum: A,
336    /// Minimum threshold change
337    pub min_change: A,
338    /// Maximum threshold change per update
339    pub max_change: A,
340    /// Adaptation frequency
341    pub adaptation_frequency: usize,
342}
343
344/// False positive tracking system
345pub struct FalsePositiveTracker<A: Float + Send + Sync> {
346    /// Recent false positive events
347    false_positives: VecDeque<FalsePositiveEvent<A>>,
348    /// False positive rate calculation
349    fp_rate_calculator: FPRateCalculator<A>,
350}
351
352/// False positive event
353#[derive(Debug, Clone)]
354pub struct FalsePositiveEvent<A: Float + Send + Sync> {
355    /// Event timestamp
356    pub timestamp: Instant,
357    /// Data point incorrectly flagged
358    pub data_point: StreamingDataPoint<A>,
359    /// Detector that generated false positive
360    pub detector_name: String,
361    /// Anomaly score given
362    pub anomaly_score: A,
363    /// Context at time of false positive
364    pub context: AnomalyContext<A>,
365}
366
367/// False positive rate calculator
368pub struct FPRateCalculator<A: Float + Send + Sync> {
369    /// Recent detection results
370    recent_results: VecDeque<DetectionResult>,
371    /// Calculation window size
372    window_size: usize,
373    /// Current false positive rate
374    current_fp_rate: A,
375}
376
377/// Detection result for FP rate calculation
378#[derive(Debug, Clone)]
379pub struct DetectionResult {
380    /// Timestamp
381    pub timestamp: Instant,
382    /// Was anomaly detected
383    pub anomaly_detected: bool,
384    /// Was it actually an anomaly (ground truth)
385    pub ground_truth: Option<bool>,
386    /// Detector name
387    pub detector_name: String,
388}
389
390/// Patterns in false positives
391#[derive(Debug, Clone)]
392pub struct FalsePositivePatterns<A: Float + Send + Sync> {
393    /// Temporal patterns
394    pub temporal_patterns: Vec<TemporalPattern>,
395    /// Feature-based patterns
396    pub feature_patterns: HashMap<String, A>,
397    /// Context patterns
398    pub context_patterns: Vec<ContextPattern<A>>,
399    /// Detector-specific patterns
400    pub detector_patterns: HashMap<String, Vec<A>>,
401}
402
403/// Temporal pattern in false positives
404#[derive(Debug, Clone)]
405pub struct TemporalPattern {
406    /// Pattern type
407    pub pattern_type: TemporalPatternType,
408    /// Pattern strength
409    pub strength: f64,
410    /// Pattern period (if periodic)
411    pub period: Option<Duration>,
412    /// Pattern confidence
413    pub confidence: f64,
414}
415
416/// Types of temporal patterns
417#[derive(Debug, Clone)]
418pub enum TemporalPatternType {
419    /// Periodic false positives
420    Periodic,
421    /// False positives at specific times
422    TimeSpecific,
423    /// Burst of false positives
424    Burst,
425    /// Gradual increase in false positives
426    Trend,
427}
428
429/// Context pattern for false positives
430#[derive(Debug, Clone)]
431pub struct ContextPattern<A: Float + Send + Sync> {
432    /// Context features associated with false positives
433    pub context_features: Vec<A>,
434    /// Pattern frequency
435    pub frequency: usize,
436    /// Pattern reliability
437    pub reliability: A,
438}
439
440/// False positive mitigation strategies
441#[derive(Debug, Clone)]
442pub enum FPMitigationStrategy {
443    /// Adjust detection thresholds
444    ThresholdAdjustment,
445    /// Feature selection/weighting
446    FeatureAdjustment,
447    /// Ensemble reweighting
448    EnsembleReweighting,
449    /// Context-aware filtering
450    ContextFiltering,
451    /// Temporal filtering
452    TemporalFiltering,
453    /// Model retraining
454    ModelRetraining,
455}
456
457/// Anomaly response system
458pub struct AnomalyResponseSystem<A: Float + Send + Sync> {
459    /// Response strategies
460    response_strategies: HashMap<AnomalyType, Vec<ResponseAction>>,
461    /// Response execution engine
462    response_executor: ResponseExecutor<A>,
463    /// Monotonically increasing response identifier.
464    next_response_id: u64,
465    /// Messages written by executed `Log` actions.
466    log_entries: VecDeque<String>,
467    /// Messages written by executed `Alert` actions.
468    alert_entries: VecDeque<String>,
469    /// Data points held by executed `Quarantine` actions.
470    quarantined_points: VecDeque<StreamingDataPoint<A>>,
471    /// Threshold adjustment requested by `ModelAdjustment` actions, awaiting
472    /// application by the owning detector.
473    pending_threshold_adjustment: Option<f64>,
474    /// Monitoring level, raised by `IncreaseMonitoring` actions.
475    monitoring_level: u32,
476}
477
478/// Number of scored data points retained for context statistics.
479const RECENT_POINT_WINDOW: usize = 512;
480
481/// Bound on the response log / alert / execution histories.
482const RESPONSE_HISTORY_CAPACITY: usize = 1000;
483
484/// Bound on the quarantine buffer.
485const QUARANTINE_CAPACITY: usize = 256;
486
487/// Response actions for anomalies
488#[derive(Debug, Clone)]
489pub enum ResponseAction {
490    /// Log the anomaly
491    Log,
492    /// Alert operators
493    Alert,
494    /// Quarantine the data
495    Quarantine,
496    /// Adjust model parameters
497    ModelAdjustment,
498    /// Increase monitoring
499    IncreaseMonitoring,
500    /// Trigger recovery procedure
501    TriggerRecovery,
502    /// Custom action
503    Custom(String),
504}
505
506/// Response execution engine
507pub struct ResponseExecutor<A: Float + Send + Sync> {
508    /// Pending responses
509    pending_responses: VecDeque<PendingResponse<A>>,
510    /// Response execution history
511    execution_history: VecDeque<ResponseExecution<A>>,
512    /// Resource limits for responses
513    resource_limits: ResponseResourceLimits,
514}
515
516/// Pending response action
517#[derive(Debug, Clone)]
518pub struct PendingResponse<A: Float + Send + Sync> {
519    /// Response ID
520    pub id: u64,
521    /// Associated anomaly event
522    pub anomaly_event: AnomalyEvent<A>,
523    /// Response action to execute
524    pub action: ResponseAction,
525    /// Priority level
526    pub priority: ResponsePriority,
527    /// Scheduled execution time
528    pub scheduled_time: Instant,
529    /// Timeout for execution
530    pub timeout: Duration,
531}
532
533/// Response execution record
534#[derive(Debug, Clone)]
535pub struct ResponseExecution<A: Float + Send + Sync> {
536    /// Execution ID
537    pub id: u64,
538    /// Response that was executed
539    pub response: PendingResponse<A>,
540    /// Execution start time
541    pub start_time: Instant,
542    /// Execution duration
543    pub duration: Duration,
544    /// Success status
545    pub success: bool,
546    /// Error message if failed
547    pub error_message: Option<String>,
548    /// Resources consumed
549    pub resources_consumed: HashMap<String, A>,
550}
551
552/// Response priority levels
553#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
554pub enum ResponsePriority {
555    /// Low priority
556    Low = 0,
557    /// Normal priority
558    Normal = 1,
559    /// High priority
560    High = 2,
561    /// Critical priority
562    Critical = 3,
563}
564
565/// Resource limits for response execution
566#[derive(Debug, Clone)]
567pub struct ResponseResourceLimits {
568    /// Maximum concurrent responses
569    pub max_concurrent_responses: usize,
570    /// Maximum CPU usage for responses
571    pub max_cpu_usage: f64,
572    /// Maximum memory usage for responses
573    pub max_memory_usage: usize,
574    /// Maximum response execution time
575    pub max_execution_time: Duration,
576}
577
578/// Effectiveness metrics for responses
579#[derive(Debug, Clone)]
580pub struct EffectivenessMetrics<A: Float + Send + Sync> {
581    /// Success rate
582    pub success_rate: A,
583    /// Average response time
584    pub avg_response_time: Duration,
585    /// Problem resolution rate
586    pub resolution_rate: A,
587    /// False alarm reduction
588    pub false_alarm_reduction: A,
589    /// Cost-benefit ratio
590    pub cost_benefit_ratio: A,
591}
592
593/// Response outcome record
594#[derive(Debug, Clone)]
595pub struct ResponseOutcome<A: Float + Send + Sync> {
596    /// Response execution
597    pub execution: ResponseExecution<A>,
598    /// Outcome measurement
599    pub outcome: OutcomeMeasurement<A>,
600    /// Follow-up required
601    pub follow_up_required: bool,
602    /// Lessons learned
603    pub lessons_learned: Vec<String>,
604}
605
606/// Measurement of response outcome
607#[derive(Debug, Clone)]
608pub struct OutcomeMeasurement<A: Float + Send + Sync> {
609    /// Did response resolve the issue
610    pub issue_resolved: bool,
611    /// Time to resolution
612    pub time_to_resolution: Duration,
613    /// Performance impact
614    pub performance_impact: A,
615    /// Side effects observed
616    pub side_effects: Vec<String>,
617    /// Overall effectiveness score
618    pub effectiveness_score: A,
619}
620
621/// Trend analysis for response effectiveness
622#[derive(Debug, Clone)]
623pub struct TrendAnalysis<A: Float + Send + Sync> {
624    /// Trend direction
625    pub trend_direction: TrendDirection,
626    /// Trend magnitude
627    pub trend_magnitude: A,
628    /// Trend confidence
629    pub trend_confidence: A,
630    /// Trend stability
631    pub trend_stability: A,
632}
633
634/// Trend directions
635#[derive(Debug, Clone, PartialEq, Eq)]
636pub enum TrendDirection {
637    /// Improving effectiveness
638    Improving,
639    /// Declining effectiveness
640    Declining,
641    /// Stable effectiveness
642    Stable,
643    /// Oscillating effectiveness
644    Oscillating,
645}
646
647/// Escalation rules for severe anomalies
648#[derive(Debug, Clone)]
649pub struct EscalationRule<A: Float + Send + Sync> {
650    /// Rule name
651    pub name: String,
652    /// Conditions for escalation
653    pub conditions: Vec<EscalationCondition<A>>,
654    /// Escalation actions
655    pub actions: Vec<EscalationAction>,
656    /// Priority level
657    pub priority: EscalationPriority,
658}
659
660/// Escalation conditions
661#[derive(Debug, Clone)]
662pub struct EscalationCondition<A: Float + Send + Sync> {
663    /// Condition type
664    pub condition_type: EscalationConditionType,
665    /// Threshold value
666    pub threshold: A,
667    /// Time window for condition
668    pub time_window: Duration,
669}
670
671/// Types of escalation conditions
672#[derive(Debug, Clone)]
673pub enum EscalationConditionType {
674    /// Multiple anomalies in time window
675    MultipleAnomalies,
676    /// High severity anomaly
677    HighSeverity,
678    /// Response failure
679    ResponseFailure,
680    /// Performance degradation
681    PerformanceDegradation,
682    /// Resource exhaustion
683    ResourceExhaustion,
684}
685
686/// Escalation actions
687#[derive(Debug, Clone)]
688pub enum EscalationAction {
689    /// Notify administrators
690    NotifyAdmin,
691    /// Trigger emergency protocols
692    EmergencyProtocol,
693    /// Shutdown affected systems
694    SystemShutdown,
695    /// Activate backup systems
696    ActivateBackup,
697    /// Increase resource allocation
698    IncreaseResources,
699}
700
701/// Escalation priority levels
702#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
703pub enum EscalationPriority {
704    /// Normal escalation
705    Normal = 0,
706    /// Urgent escalation
707    Urgent = 1,
708    /// Emergency escalation
709    Emergency = 2,
710}
711
712impl<A: Float + Default + Clone + std::iter::Sum + Send + Sync + 'static> AnomalyDetector<A> {
713    /// Creates a new anomaly detector
714    pub fn new(config: &StreamingConfig) -> Result<Self, String> {
715        let anomaly_config = config.anomaly_config.clone();
716
717        let mut statistical_detectors: HashMap<String, Box<dyn StatisticalAnomalyDetector<A>>> =
718            HashMap::new();
719        let mut ml_detectors: HashMap<String, Box<dyn MLAnomalyDetector<A>>> = HashMap::new();
720
721        // Initialize statistical detectors
722        statistical_detectors.insert(
723            "zscore".to_string(),
724            Box::new(super::anomaly_statistical::ZScoreDetector::new(
725                anomaly_config.threshold,
726            )?),
727        );
728        statistical_detectors.insert(
729            "iqr".to_string(),
730            Box::new(super::anomaly_statistical::IQRDetector::new(
731                anomaly_config.threshold,
732            )?),
733        );
734
735        // Initialize ML detectors based on method
736        match anomaly_config.detection_method {
737            AnomalyDetectionMethod::IsolationForest => {
738                ml_detectors.insert(
739                    "isolation_forest".to_string(),
740                    Box::new(super::anomaly_ml::IsolationForestDetector::new()?),
741                );
742            }
743            AnomalyDetectionMethod::OneClassSVM => {
744                ml_detectors.insert(
745                    "one_class_svm".to_string(),
746                    Box::new(super::anomaly_ml::OneClassSvmDetector::new()?),
747                );
748            }
749            AnomalyDetectionMethod::LocalOutlierFactor => {
750                ml_detectors.insert(
751                    "lof".to_string(),
752                    Box::new(super::anomaly_ml::LofDetector::new()?),
753                );
754            }
755            _ => {
756                // Use statistical methods for other cases
757            }
758        }
759
760        let ensemble_detector = EnsembleAnomalyDetector::new(EnsembleVotingStrategy::Weighted)?;
761        // `AnomalyConfig::enable_adaptive_threshold` and `contamination_rate`
762        // are honoured by `recalibrate_thresholds` below, which is the code
763        // that actually moves thresholds. A `ThresholdAdaptationStrategy` was
764        // also derived here and handed to the manager, but the manager never
765        // read it -- two parallel spellings of the same setting, one of them
766        // inert. Only the working one remains.
767        let threshold_manager = AdaptiveThresholdManager::new()?;
768        let false_positive_tracker = FalsePositiveTracker::new();
769        let response_system = AnomalyResponseSystem::new(&anomaly_config.response_strategy)?;
770
771        let recent_capacity = RECENT_POINT_WINDOW;
772        Ok(Self {
773            config: anomaly_config,
774            statistical_detectors,
775            ml_detectors,
776            ensemble_detector,
777            threshold_manager,
778            anomaly_history: VecDeque::with_capacity(10000),
779            false_positive_tracker,
780            response_system,
781            recent_points: VecDeque::with_capacity(recent_capacity),
782            recent_capacity,
783            context_performance_metrics: Vec::new(),
784            context_resource_usage: Vec::new(),
785            context_drift_indicators: Vec::new(),
786            recent_scores: VecDeque::with_capacity(recent_capacity),
787            points_since_recalibration: 0,
788        })
789    }
790
791    /// Recalibrates every statistical detector's threshold to the empirical
792    /// `1 - contamination_rate` quantile of the recent ensemble anomaly scores
793    /// (CF1).
794    ///
795    /// `AnomalyConfig::contamination_rate` — the assumed fraction of the stream
796    /// that is anomalous — previously had no reader at all, so the "assumption"
797    /// influenced nothing: thresholds stayed wherever they were initialised no
798    /// matter how the stream was actually distributed. Calibrating to that
799    /// quantile is the standard use of a contamination parameter: it makes the
800    /// detector flag approximately that fraction of points.
801    ///
802    /// Runs only when `enable_adaptive_threshold` is set, and only once every
803    /// `window_size` scored points. A `contamination_rate` outside `(0, 1)` is
804    /// an honest error rather than a silently clamped guess.
805    fn recalibrate_thresholds(&mut self) -> Result<(), String> {
806        if !self.config.enable_adaptive_threshold {
807            return Ok(());
808        }
809        let window = self.config.window_size.max(1);
810        if self.points_since_recalibration < window || self.recent_scores.len() < window {
811            return Ok(());
812        }
813        self.points_since_recalibration = 0;
814
815        let contamination = self.config.contamination_rate;
816        if !(contamination > 0.0 && contamination < 1.0) {
817            return Err(format!(
818                "AnomalyConfig::contamination_rate must be in (0, 1), got {contamination}"
819            ));
820        }
821
822        let mut scores: Vec<A> = self.recent_scores.iter().copied().collect();
823        scores.sort_by(crate::utils::total_order);
824        // Nearest-rank quantile: index of the first score at or above the
825        // (1 - contamination) quantile of the window.
826        let rank = ((1.0 - contamination) * scores.len() as f64).floor() as usize;
827        let index = rank.min(scores.len().saturating_sub(1));
828        let Some(&target) = scores.get(index) else {
829            return Ok(());
830        };
831
832        for (name, detector) in &mut self.statistical_detectors {
833            let bounded = match self.threshold_manager.threshold_bounds.get(name) {
834                Some(&(low, high)) => target.max(low).min(high),
835                None => target,
836            };
837            detector.set_threshold(bounded);
838            self.threshold_manager
839                .thresholds
840                .insert(name.clone(), bounded);
841        }
842        Ok(())
843    }
844
845    /// Test-only list of thresholds the contamination calibration has applied.
846    #[cfg(test)]
847    pub(crate) fn calibrated_threshold_names_for_test(&self) -> Vec<A> {
848        self.threshold_manager
849            .thresholds
850            .values()
851            .copied()
852            .collect()
853    }
854
855    /// Threshold currently applied to `detector_name` by the contamination-rate
856    /// calibration, if it has run.
857    pub fn calibrated_threshold(&self, detector_name: &str) -> Option<A> {
858        self.threshold_manager
859            .thresholds
860            .get(detector_name)
861            .copied()
862    }
863
864    /// Supplies the context signals that the detector cannot observe itself.
865    ///
866    /// The owning optimizer knows the current performance, resource and drift
867    /// state; feeding them in here makes [`AnomalyContext`] carry real values
868    /// instead of the placeholders it used to be built from.
869    pub fn update_context_signals(
870        &mut self,
871        performance_metrics: Vec<A>,
872        resource_usage: Vec<A>,
873        drift_indicators: Vec<A>,
874    ) {
875        self.context_performance_metrics = performance_metrics;
876        self.context_resource_usage = resource_usage;
877        self.context_drift_indicators = drift_indicators;
878    }
879
880    /// Feeds ground truth for the most recent detection back into the
881    /// detectors and the false-positive tracker.
882    ///
883    /// This is the only path by which the ML detectors' quality metrics become
884    /// defined; without it `get_performance_metrics` honestly reports that it
885    /// has nothing to measure.
886    pub fn record_detection_outcome(&mut self, predicted_anomaly: bool, was_true_anomaly: bool) {
887        for detector in self.ml_detectors.values_mut() {
888            detector.record_outcome(predicted_anomaly, was_true_anomaly);
889        }
890        // Every ensemble member is scored against its *own* verdict on the most
891        // recent point, which is what `EnsembleVotingStrategy::Adaptive` needs
892        // to weight them by measured skill.
893        self.ensemble_detector.record_outcome(was_true_anomaly);
894        self.false_positive_tracker
895            .record_outcome(predicted_anomaly, was_true_anomaly);
896
897        // A confirmed false positive is attributed to the most recent recorded
898        // anomaly, which is the one the caller is giving feedback on.
899        if predicted_anomaly && !was_true_anomaly {
900            if let Some(event) = self.anomaly_history.back().cloned() {
901                self.false_positive_tracker.record_false_positive(&event);
902            }
903        }
904    }
905
906    /// Number of confirmed false positives retained by the tracker.
907    pub fn confirmed_false_positive_count(&self) -> usize {
908        self.false_positive_tracker.confirmed_false_positive_count()
909    }
910
911    /// Selects how the per-detector verdicts are combined.
912    ///
913    /// The constructor builds a `Weighted` ensemble; `Adaptive` is the strategy
914    /// that derives its weights from the ground-truth feedback recorded by
915    /// [`Self::record_detection_outcome`], and was unreachable while no setter
916    /// existed.
917    pub fn set_ensemble_voting_strategy(&mut self, strategy: EnsembleVotingStrategy) {
918        self.ensemble_detector.set_voting_strategy(strategy);
919    }
920
921    /// Sets the weight [`EnsembleVotingStrategy::Weighted`] gives one detector.
922    ///
923    /// Without this the configured-weight strategy had no way to be configured,
924    /// so it always fell back to the uniform weight of one.
925    pub fn set_detector_weight(&mut self, detector_name: &str, weight: A) {
926        self.ensemble_detector
927            .set_detector_weight(detector_name, weight);
928    }
929
930    /// Balanced accuracy measured for one ensemble member, or `None` before any
931    /// ground truth has been recorded for it.
932    pub fn detector_balanced_accuracy(&self, detector_name: &str) -> Option<f64> {
933        self.ensemble_detector
934            .detector_balanced_accuracy(detector_name)
935    }
936
937    /// Number of response executions the response system has recorded.
938    pub fn response_execution_count(&self) -> usize {
939        self.response_system.execution_count()
940    }
941
942    /// Entries written by executed `Log` response actions.
943    pub fn response_log_entry_count(&self) -> usize {
944        self.response_system.log_entry_count()
945    }
946
947    /// Entries written by executed `Alert` response actions.
948    pub fn response_alert_entry_count(&self) -> usize {
949        self.response_system.alert_entry_count()
950    }
951
952    /// Data points held by executed `Quarantine` response actions.
953    pub fn quarantined_point_count(&self) -> usize {
954        self.response_system.quarantined_count()
955    }
956
957    /// Monitoring level, raised by executed `IncreaseMonitoring` actions.
958    pub fn monitoring_level(&self) -> u32 {
959        self.response_system.monitoring_level()
960    }
961
962    /// Quality metrics for every registered ML detector that has labelled
963    /// outcomes recorded. Detectors without ground truth are reported with the
964    /// error explaining why, rather than a fabricated score.
965    pub fn ml_performance_metrics(&self) -> HashMap<String, Result<MLModelMetrics<A>, String>> {
966        self.ml_detectors
967            .iter()
968            .map(|(name, detector)| (name.clone(), detector.get_performance_metrics()))
969            .collect()
970    }
971
972    /// Number of data points currently retained for context statistics.
973    pub fn recent_window_len(&self) -> usize {
974        self.recent_points.len()
975    }
976
977    /// Builds the anomaly context for a data point without recording an event.
978    ///
979    /// Exposed so callers (and tests) can inspect the real statistics the
980    /// detector derives from its retained window.
981    pub fn build_context_for_test(
982        &self,
983        data_point: &StreamingDataPoint<A>,
984    ) -> Result<AnomalyContext<A>, String> {
985        self.create_anomaly_context(data_point)
986    }
987
988    /// Adds a scored point to the bounded context window.
989    fn remember_point(&mut self, data_point: &StreamingDataPoint<A>) {
990        if self.recent_points.len() >= self.recent_capacity {
991            self.recent_points.pop_front();
992        }
993        self.recent_points.push_back(data_point.clone());
994    }
995
996    /// Detects anomalies in a data point
997    pub fn detect_anomaly(&mut self, data_point: &StreamingDataPoint<A>) -> Result<bool, String> {
998        let mut detection_results = HashMap::new();
999
1000        // Run statistical detectors
1001        for (name, detector) in &mut self.statistical_detectors {
1002            let result = detector.detect_anomaly(data_point)?;
1003            detection_results.insert(name.clone(), result);
1004        }
1005
1006        // Run ML detectors
1007        for (name, detector) in &mut self.ml_detectors {
1008            let result = detector.detect_anomaly(data_point)?;
1009            detection_results.insert(name.clone(), result);
1010        }
1011
1012        // Combine results using ensemble
1013        let ensemble_result = self.ensemble_detector.combine_results(detection_results)?;
1014
1015        // Retain the point for the context statistics window regardless of the
1016        // verdict — the summary describes the recent stream, not just the
1017        // anomalies in it.
1018        self.remember_point(data_point);
1019
1020        // Feed the contamination-rate calibration window (CF1).
1021        if self.recent_scores.len() >= self.recent_capacity {
1022            self.recent_scores.pop_front();
1023        }
1024        self.recent_scores.push_back(ensemble_result.anomaly_score);
1025        self.points_since_recalibration = self.points_since_recalibration.saturating_add(1);
1026        self.recalibrate_thresholds()?;
1027
1028        // Check if anomaly was detected
1029        if ensemble_result.is_anomaly {
1030            // Create anomaly event
1031            let mut anomaly_event = AnomalyEvent {
1032                id: self.generate_event_id(),
1033                timestamp: Instant::now(),
1034                anomaly_type: ensemble_result
1035                    .anomaly_type
1036                    .as_ref()
1037                    .cloned()
1038                    .unwrap_or(AnomalyType::StatisticalOutlier),
1039                severity: ensemble_result.severity.clone(),
1040                confidence: ensemble_result.confidence,
1041                data_point: data_point.clone(),
1042                detector_name: "ensemble".to_string(),
1043                anomaly_score: ensemble_result.anomaly_score,
1044                context: self.create_anomaly_context(data_point)?,
1045                response_actions: Vec::new(),
1046            };
1047
1048            // Trigger the configured responses and record what actually ran,
1049            // so `response_actions` reflects real executions instead of the
1050            // empty vector a no-op `trigger_response` left behind.
1051            anomaly_event.response_actions =
1052                self.response_system.trigger_response(&anomaly_event)?;
1053
1054            // Any threshold adjustment the responses asked for is applied for
1055            // real, not merely logged.
1056            let pending_adjustment = self
1057                .response_system
1058                .take_pending_threshold_adjustment()
1059                // CF1: with adaptive thresholding switched off, a response may
1060                // still be recorded but must not move any threshold.
1061                .filter(|_| self.config.enable_adaptive_threshold);
1062            if let Some(adjustment) = pending_adjustment {
1063                let magnitude = A::from(adjustment).ok_or_else(|| {
1064                    format!("threshold adjustment {adjustment} is not representable")
1065                })?;
1066                for detector in self.statistical_detectors.values_mut() {
1067                    let updated = detector.get_threshold() + magnitude;
1068                    detector.set_threshold(updated);
1069                }
1070                self.ensemble_detector.adjust_sensitivity(magnitude)?;
1071            }
1072
1073            // Record anomaly
1074            self.record_anomaly(anomaly_event)?;
1075
1076            return Ok(true);
1077        }
1078
1079        // Update detectors with normal data
1080        for detector in self.statistical_detectors.values_mut() {
1081            detector.update(data_point)?;
1082        }
1083
1084        for detector in self.ml_detectors.values_mut() {
1085            detector.update_incremental(data_point)?;
1086        }
1087
1088        Ok(false)
1089    }
1090
1091    /// Generates unique event ID
1092    fn generate_event_id(&self) -> u64 {
1093        self.anomaly_history.len() as u64 + 1
1094    }
1095
1096    /// Creates anomaly context
1097    fn create_anomaly_context(
1098        &self,
1099        data_point: &StreamingDataPoint<A>,
1100    ) -> Result<AnomalyContext<A>, String> {
1101        // Calculate recent statistics from the retained window.
1102        let recent_statistics = self.calculate_recent_statistics(data_point)?;
1103
1104        // Calculate time since last anomaly. With no prior anomaly there is no
1105        // interval to report, so use the age of the oldest retained
1106        // observation — a real measurement — rather than a fixed hour.
1107        let time_since_last_anomaly = match self.anomaly_history.back() {
1108            Some(last_anomaly) => last_anomaly.timestamp.elapsed(),
1109            None => self
1110                .recent_points
1111                .front()
1112                .map(|point| point.timestamp.elapsed())
1113                .unwrap_or(Duration::ZERO),
1114        };
1115
1116        Ok(AnomalyContext {
1117            recent_statistics,
1118            // Reported by the owning optimizer through
1119            // `update_context_signals`; empty means "not reported", which is
1120            // honest, unlike the 0.8/0.7, 0.6/0.5, 0.1 placeholders this used
1121            // to invent.
1122            performance_metrics: self.context_performance_metrics.clone(),
1123            resource_usage: self.context_resource_usage.clone(),
1124            drift_indicators: self.context_drift_indicators.clone(),
1125            time_since_last_anomaly,
1126        })
1127    }
1128
1129    /// Computes per-feature statistics over the retained window of recent data
1130    /// points.
1131    ///
1132    /// The summary is dimension-general: it is sized from the widest feature
1133    /// vector actually observed (including the point being classified), so a
1134    /// stream with any number of features is described correctly. Every
1135    /// quantity — mean, standard deviation, min, max, median, skewness and
1136    /// kurtosis — is computed from the real observations.
1137    fn calculate_recent_statistics(
1138        &self,
1139        data_point: &StreamingDataPoint<A>,
1140    ) -> Result<DataStatistics<A>, String> {
1141        let feature_count = self
1142            .recent_points
1143            .iter()
1144            .map(|point| point.features.len())
1145            .chain(std::iter::once(data_point.features.len()))
1146            .max()
1147            .unwrap_or(0);
1148
1149        let mut means = Vec::with_capacity(feature_count);
1150        let mut std_devs = Vec::with_capacity(feature_count);
1151        let mut min_values = Vec::with_capacity(feature_count);
1152        let mut max_values = Vec::with_capacity(feature_count);
1153        let mut medians = Vec::with_capacity(feature_count);
1154        let mut skewness = Vec::with_capacity(feature_count);
1155        let mut kurtosis = Vec::with_capacity(feature_count);
1156
1157        for index in 0..feature_count {
1158            let mut column: Vec<A> = self
1159                .recent_points
1160                .iter()
1161                .filter_map(|point| point.features.get(index).copied())
1162                .collect();
1163            if let Some(value) = data_point.features.get(index) {
1164                column.push(*value);
1165            }
1166
1167            if column.is_empty() {
1168                means.push(A::zero());
1169                std_devs.push(A::zero());
1170                min_values.push(A::zero());
1171                max_values.push(A::zero());
1172                medians.push(A::zero());
1173                skewness.push(A::zero());
1174                kurtosis.push(A::zero());
1175                continue;
1176            }
1177
1178            let count = A::from(column.len())
1179                .ok_or_else(|| format!("sample count {} is not representable", column.len()))?;
1180            let mean = column.iter().fold(A::zero(), |acc, &v| acc + v) / count;
1181            let variance = column
1182                .iter()
1183                .fold(A::zero(), |acc, &v| acc + (v - mean) * (v - mean))
1184                / count;
1185            let std_dev = variance.sqrt();
1186
1187            let minimum = column
1188                .iter()
1189                .copied()
1190                .reduce(|a, b| {
1191                    if super::statistics::total_order(&b, &a) == std::cmp::Ordering::Less {
1192                        b
1193                    } else {
1194                        a
1195                    }
1196                })
1197                .unwrap_or_else(A::zero);
1198            let maximum = column
1199                .iter()
1200                .copied()
1201                .reduce(|a, b| {
1202                    if super::statistics::total_order(&b, &a) == std::cmp::Ordering::Greater {
1203                        b
1204                    } else {
1205                        a
1206                    }
1207                })
1208                .unwrap_or_else(A::zero);
1209            let median = super::statistics::median_in_place(&mut column).unwrap_or(mean);
1210
1211            // Standardised third and fourth central moments. Both are
1212            // undefined for a constant column, which is reported as zero
1213            // (a symmetric, mesokurtic degenerate distribution) rather than a
1214            // division by zero.
1215            let (skew, kurt) = if std_dev > A::zero() {
1216                let mut third = A::zero();
1217                let mut fourth = A::zero();
1218                for &value in column.iter() {
1219                    let z = (value - mean) / std_dev;
1220                    let z2 = z * z;
1221                    third = third + z2 * z;
1222                    fourth = fourth + z2 * z2;
1223                }
1224                let three = A::from(3.0).ok_or_else(|| "3.0 is not representable".to_string())?;
1225                // Excess kurtosis, so a Gaussian column reports ~0.
1226                (third / count, fourth / count - three)
1227            } else {
1228                (A::zero(), A::zero())
1229            };
1230
1231            means.push(mean);
1232            std_devs.push(std_dev);
1233            min_values.push(minimum);
1234            max_values.push(maximum);
1235            medians.push(median);
1236            skewness.push(skew);
1237            kurtosis.push(kurt);
1238        }
1239
1240        Ok(DataStatistics {
1241            means,
1242            std_devs,
1243            min_values,
1244            max_values,
1245            medians,
1246            skewness,
1247            kurtosis,
1248        })
1249    }
1250
1251    /// Records anomaly in history
1252    fn record_anomaly(&mut self, anomaly_event: AnomalyEvent<A>) -> Result<(), String> {
1253        if self.anomaly_history.len() >= 10000 {
1254            self.anomaly_history.pop_front();
1255        }
1256        self.anomaly_history.push_back(anomaly_event);
1257        Ok(())
1258    }
1259
1260    /// Applies adaptation to anomaly detection parameters
1261    pub fn apply_adaptation(&mut self, adaptation: &Adaptation<A>) -> Result<(), String> {
1262        if adaptation.adaptation_type == AdaptationType::AnomalyDetection {
1263            // Adjust detection thresholds
1264            let threshold_adjustment = adaptation.magnitude;
1265
1266            for detector in self.statistical_detectors.values_mut() {
1267                let current_threshold = detector.get_threshold();
1268                let new_threshold = current_threshold + threshold_adjustment;
1269                detector.set_threshold(new_threshold);
1270            }
1271
1272            // Update ensemble configuration
1273            self.ensemble_detector
1274                .adjust_sensitivity(threshold_adjustment)?;
1275        }
1276
1277        Ok(())
1278    }
1279
1280    /// Gets recent anomaly events
1281    pub fn get_recent_anomalies(&self, count: usize) -> Vec<&AnomalyEvent<A>> {
1282        self.anomaly_history.iter().rev().take(count).collect()
1283    }
1284
1285    /// Gets diagnostic information
1286    pub fn get_diagnostics(&self) -> AnomalyDiagnostics {
1287        AnomalyDiagnostics {
1288            total_anomalies: self.anomaly_history.len(),
1289            recent_anomaly_rate: self.calculate_recent_anomaly_rate(),
1290            false_positive_rate: self.false_positive_tracker.get_current_fp_rate(),
1291            detector_count: self.statistical_detectors.len() + self.ml_detectors.len(),
1292            response_success_rate: self.response_system.get_success_rate(),
1293            response_executions: self.response_system.execution_count(),
1294            recent_window_len: self.recent_points.len(),
1295        }
1296    }
1297
1298    /// Calculates recent anomaly rate
1299    fn calculate_recent_anomaly_rate(&self) -> f64 {
1300        let recent_window = Duration::from_secs(3600); // 1 hour
1301                                                       // `Instant::now() - Duration` panics if the process has been up for
1302                                                       // less than the window, so subtract with a checked operation.
1303        let now = Instant::now();
1304
1305        let recent_count = self
1306            .anomaly_history
1307            .iter()
1308            .filter(|event| now.duration_since(event.timestamp) <= recent_window)
1309            .count();
1310
1311        recent_count as f64 / recent_window.as_secs_f64() // Anomalies per second
1312    }
1313}
1314
1315// The classic statistical detectors (z-score and IQR) live in
1316// `super::anomaly_statistical`; the real machine-learning detectors (isolation
1317// forest, online one-class SVM and local outlier factor) live in
1318// `super::anomaly_ml`, where the latter three were previously stubs here that
1319// returned the constants 0.3 / 0.2 / 0.1 regardless of their input.
1320
1321// The real machine-learning detectors (isolation forest, online one-class SVM
1322// and local outlier factor) live in `super::anomaly_ml`; they were previously
1323// three stubs here that returned the constants 0.3 / 0.2 / 0.1 regardless of
1324// their input.
1325
1326// Simplified implementations for supporting structures
1327
1328impl<A: Float + Default + Clone + Send + Sync + Send + Sync> AdaptiveThresholdManager<A> {
1329    fn new() -> Result<Self, String> {
1330        Ok(Self {
1331            thresholds: HashMap::new(),
1332            threshold_bounds: HashMap::new(),
1333        })
1334    }
1335}
1336
1337impl<A: Float + Default + Clone + Send + Sync + Send + Sync> FalsePositiveTracker<A> {
1338    fn new() -> Self {
1339        Self {
1340            false_positives: VecDeque::with_capacity(1000),
1341            fp_rate_calculator: FPRateCalculator {
1342                recent_results: VecDeque::with_capacity(1000),
1343                window_size: 1000,
1344                current_fp_rate: scalar_or(0.05, A::zero()),
1345            },
1346        }
1347    }
1348
1349    /// Observed false-positive rate, or `None` before any labelled outcome has
1350    /// been recorded.
1351    ///
1352    /// The constructor seeds `current_fp_rate` with the *target* rate, so
1353    /// reporting it unconditionally would present a configuration value as a
1354    /// measurement.
1355    fn get_current_fp_rate(&self) -> Option<f64> {
1356        if self.fp_rate_calculator.recent_results.is_empty() {
1357            return None;
1358        }
1359        self.fp_rate_calculator.current_fp_rate.to_f64()
1360    }
1361
1362    /// Records ground truth for one prediction and recomputes the observed
1363    /// false-positive rate over the sliding evaluation window.
1364    fn record_outcome(&mut self, predicted_anomaly: bool, was_true_anomaly: bool) {
1365        let calculator = &mut self.fp_rate_calculator;
1366        if calculator.recent_results.len() >= calculator.window_size {
1367            calculator.recent_results.pop_front();
1368        }
1369        calculator.recent_results.push_back(DetectionResult {
1370            timestamp: Instant::now(),
1371            anomaly_detected: predicted_anomaly,
1372            ground_truth: Some(was_true_anomaly),
1373            detector_name: "ensemble".to_string(),
1374        });
1375
1376        // The false-positive rate is `FP / (FP + TN)`: the fraction of
1377        // genuinely normal points that were incorrectly flagged.
1378        let negatives = calculator
1379            .recent_results
1380            .iter()
1381            .filter(|result| result.ground_truth == Some(false))
1382            .count();
1383        if negatives > 0 {
1384            let false_positives = calculator
1385                .recent_results
1386                .iter()
1387                .filter(|result| result.anomaly_detected && result.ground_truth == Some(false))
1388                .count();
1389            if let Some(rate) = A::from(false_positives as f64 / negatives as f64) {
1390                calculator.current_fp_rate = rate;
1391            }
1392        }
1393    }
1394
1395    /// Records the full detail of a confirmed false positive.
1396    fn record_false_positive(&mut self, event: &AnomalyEvent<A>) {
1397        if self.false_positives.len() >= RESPONSE_HISTORY_CAPACITY {
1398            self.false_positives.pop_front();
1399        }
1400        self.false_positives.push_back(FalsePositiveEvent {
1401            timestamp: event.timestamp,
1402            data_point: event.data_point.clone(),
1403            detector_name: event.detector_name.clone(),
1404            anomaly_score: event.anomaly_score,
1405            context: event.context.clone(),
1406        });
1407    }
1408
1409    /// Number of confirmed false positives retained.
1410    fn confirmed_false_positive_count(&self) -> usize {
1411        self.false_positives.len()
1412    }
1413}
1414
1415impl<A: Float + Default + Clone + Send + Sync + Send + Sync> AnomalyResponseSystem<A> {
1416    fn new(response_strategy: &AnomalyResponseStrategy) -> Result<Self, String> {
1417        let mut response_strategies = HashMap::new();
1418
1419        // Set up default response strategies
1420        match response_strategy {
1421            AnomalyResponseStrategy::Ignore => {
1422                response_strategies
1423                    .insert(AnomalyType::StatisticalOutlier, vec![ResponseAction::Log]);
1424            }
1425            AnomalyResponseStrategy::Filter => {
1426                response_strategies.insert(
1427                    AnomalyType::StatisticalOutlier,
1428                    vec![ResponseAction::Quarantine],
1429                );
1430            }
1431            AnomalyResponseStrategy::Adaptive => {
1432                response_strategies.insert(
1433                    AnomalyType::StatisticalOutlier,
1434                    vec![ResponseAction::Log, ResponseAction::ModelAdjustment],
1435                );
1436            }
1437            _ => {
1438                response_strategies
1439                    .insert(AnomalyType::StatisticalOutlier, vec![ResponseAction::Alert]);
1440            }
1441        }
1442
1443        Ok(Self {
1444            response_strategies,
1445            response_executor: ResponseExecutor {
1446                pending_responses: VecDeque::new(),
1447                execution_history: VecDeque::with_capacity(1000),
1448                resource_limits: ResponseResourceLimits {
1449                    max_concurrent_responses: 10,
1450                    max_cpu_usage: 0.2,
1451                    max_memory_usage: 100 * 1024 * 1024, // 100MB
1452                    max_execution_time: Duration::from_secs(60),
1453                },
1454            },
1455            next_response_id: 0,
1456            log_entries: VecDeque::with_capacity(RESPONSE_HISTORY_CAPACITY),
1457            alert_entries: VecDeque::with_capacity(RESPONSE_HISTORY_CAPACITY),
1458            quarantined_points: VecDeque::with_capacity(QUARANTINE_CAPACITY),
1459            pending_threshold_adjustment: None,
1460            monitoring_level: 0,
1461        })
1462    }
1463
1464    /// Queues and executes the responses configured for this anomaly type,
1465    /// returning the names of the actions that actually ran.
1466    ///
1467    /// Every action either performs a real, observable state change (a log or
1468    /// alert entry, a quarantined data point, a queued threshold adjustment, a
1469    /// raised monitoring level) or is recorded as an honest failure with the
1470    /// reason — there is no subsystem behind `TriggerRecovery` or a custom
1471    /// action, so claiming success for them would be a fabrication.
1472    fn trigger_response(&mut self, event: &AnomalyEvent<A>) -> Result<Vec<String>, String> {
1473        let actions = self
1474            .response_strategies
1475            .get(&event.anomaly_type)
1476            .or_else(|| {
1477                self.response_strategies
1478                    .get(&AnomalyType::StatisticalOutlier)
1479            })
1480            .cloned()
1481            .unwrap_or_default();
1482
1483        if actions.is_empty() {
1484            return Ok(Vec::new());
1485        }
1486
1487        let priority = match event.severity {
1488            AnomalySeverity::Critical => ResponsePriority::Critical,
1489            AnomalySeverity::High => ResponsePriority::High,
1490            AnomalySeverity::Medium => ResponsePriority::Normal,
1491            AnomalySeverity::Low => ResponsePriority::Low,
1492        };
1493        let timeout = self.response_executor.resource_limits.max_execution_time;
1494
1495        for action in actions {
1496            if self.response_executor.pending_responses.len()
1497                >= self
1498                    .response_executor
1499                    .resource_limits
1500                    .max_concurrent_responses
1501            {
1502                // Respect the configured concurrency limit instead of growing
1503                // an unbounded queue.
1504                break;
1505            }
1506            self.next_response_id += 1;
1507            self.response_executor
1508                .pending_responses
1509                .push_back(PendingResponse {
1510                    id: self.next_response_id,
1511                    anomaly_event: event.clone(),
1512                    action,
1513                    priority: priority.clone(),
1514                    scheduled_time: Instant::now(),
1515                    timeout,
1516                });
1517        }
1518
1519        self.execute_pending_responses()
1520    }
1521
1522    /// Drains the pending queue, highest priority first, executing each action.
1523    fn execute_pending_responses(&mut self) -> Result<Vec<String>, String> {
1524        // Highest priority first; the queue is small (bounded by
1525        // `max_concurrent_responses`) so a sort is cheap.
1526        self.response_executor
1527            .pending_responses
1528            .make_contiguous()
1529            .sort_by(|a, b| b.priority.cmp(&a.priority));
1530
1531        let mut executed = Vec::new();
1532        while let Some(pending) = self.response_executor.pending_responses.pop_front() {
1533            let started = Instant::now();
1534            let outcome = self.perform_action(&pending);
1535            let duration = started.elapsed();
1536
1537            let (success, error_message) = match &outcome {
1538                Ok(()) => (true, None),
1539                Err(reason) => (false, Some(reason.clone())),
1540            };
1541            if success {
1542                executed.push(format!("{:?}", pending.action));
1543            }
1544
1545            let mut resources_consumed = HashMap::new();
1546            if let Some(millis) = A::from(duration.as_secs_f64() * 1000.0) {
1547                resources_consumed.insert("execution_time_ms".to_string(), millis);
1548            }
1549
1550            let execution = ResponseExecution {
1551                id: pending.id,
1552                response: pending,
1553                start_time: started,
1554                duration,
1555                success,
1556                error_message,
1557                resources_consumed,
1558            };
1559
1560            if self.response_executor.execution_history.len() >= RESPONSE_HISTORY_CAPACITY {
1561                self.response_executor.execution_history.pop_front();
1562            }
1563            self.response_executor
1564                .execution_history
1565                .push_back(execution);
1566        }
1567
1568        Ok(executed)
1569    }
1570
1571    /// Carries out a single response action.
1572    fn perform_action(&mut self, pending: &PendingResponse<A>) -> Result<(), String> {
1573        match &pending.action {
1574            ResponseAction::Log => {
1575                self.push_bounded(
1576                    ResponseChannel::Log,
1577                    format!(
1578                        "anomaly {} type={:?} severity={:?} score={:?}",
1579                        pending.anomaly_event.id,
1580                        pending.anomaly_event.anomaly_type,
1581                        pending.anomaly_event.severity,
1582                        pending.anomaly_event.anomaly_score.to_f64()
1583                    ),
1584                );
1585                Ok(())
1586            }
1587            ResponseAction::Alert => {
1588                self.push_bounded(
1589                    ResponseChannel::Alert,
1590                    format!(
1591                        "ALERT: anomaly {} severity={:?}",
1592                        pending.anomaly_event.id, pending.anomaly_event.severity
1593                    ),
1594                );
1595                Ok(())
1596            }
1597            ResponseAction::Quarantine => {
1598                if self.quarantined_points.len() >= QUARANTINE_CAPACITY {
1599                    self.quarantined_points.pop_front();
1600                }
1601                self.quarantined_points
1602                    .push_back(pending.anomaly_event.data_point.clone());
1603                Ok(())
1604            }
1605            ResponseAction::ModelAdjustment => {
1606                // Ask the owning detector to raise its thresholds in
1607                // proportion to the severity of what got through. The caller
1608                // applies this via `take_pending_threshold_adjustment`, so the
1609                // adjustment is a real state change rather than a log line.
1610                let step = match pending.anomaly_event.severity {
1611                    AnomalySeverity::Critical => 0.20,
1612                    AnomalySeverity::High => 0.10,
1613                    AnomalySeverity::Medium => 0.05,
1614                    AnomalySeverity::Low => 0.01,
1615                };
1616                self.pending_threshold_adjustment =
1617                    Some(self.pending_threshold_adjustment.unwrap_or(0.0) + step);
1618                Ok(())
1619            }
1620            ResponseAction::IncreaseMonitoring => {
1621                self.monitoring_level = self.monitoring_level.saturating_add(1);
1622                Ok(())
1623            }
1624            ResponseAction::TriggerRecovery => Err(
1625                "no recovery procedure is registered with this response system; \
1626                 a recovery handler must be installed before this action can run"
1627                    .to_string(),
1628            ),
1629            ResponseAction::Custom(name) => Err(format!(
1630                "no handler is registered for custom response action '{name}'"
1631            )),
1632        }
1633    }
1634
1635    fn push_bounded(&mut self, channel: ResponseChannel, message: String) {
1636        let sink = match channel {
1637            ResponseChannel::Log => &mut self.log_entries,
1638            ResponseChannel::Alert => &mut self.alert_entries,
1639        };
1640        if sink.len() >= RESPONSE_HISTORY_CAPACITY {
1641            sink.pop_front();
1642        }
1643        sink.push_back(message);
1644    }
1645
1646    /// Takes any threshold adjustment the responses requested, clearing it.
1647    fn take_pending_threshold_adjustment(&mut self) -> Option<f64> {
1648        self.pending_threshold_adjustment.take()
1649    }
1650
1651    /// Fraction of executed responses that succeeded, or `None` when nothing
1652    /// has been executed yet.
1653    ///
1654    /// Returning `None` is the honest answer for an empty history; the previous
1655    /// implementation reported a hard-coded `0.85` from the moment the system
1656    /// was constructed.
1657    fn get_success_rate(&self) -> Option<f64> {
1658        let history = &self.response_executor.execution_history;
1659        if history.is_empty() {
1660            return None;
1661        }
1662        let successes = history.iter().filter(|execution| execution.success).count();
1663        Some(successes as f64 / history.len() as f64)
1664    }
1665
1666    /// Entries written by executed `Log` actions.
1667    fn log_entry_count(&self) -> usize {
1668        self.log_entries.len()
1669    }
1670
1671    /// Entries written by executed `Alert` actions.
1672    fn alert_entry_count(&self) -> usize {
1673        self.alert_entries.len()
1674    }
1675
1676    /// Data points held by executed `Quarantine` actions.
1677    fn quarantined_count(&self) -> usize {
1678        self.quarantined_points.len()
1679    }
1680
1681    /// Current monitoring level, raised by `IncreaseMonitoring` actions.
1682    fn monitoring_level(&self) -> u32 {
1683        self.monitoring_level
1684    }
1685
1686    /// Number of response executions recorded.
1687    fn execution_count(&self) -> usize {
1688        self.response_executor.execution_history.len()
1689    }
1690}
1691
1692/// Sinks that a response action can write to.
1693#[derive(Debug, Clone, Copy)]
1694enum ResponseChannel {
1695    Log,
1696    Alert,
1697}
1698
1699/// Diagnostic information for anomaly detection
1700#[derive(Debug, Clone)]
1701pub struct AnomalyDiagnostics {
1702    /// Anomalies retained in the history buffer.
1703    pub total_anomalies: usize,
1704    /// Anomalies per second over the last hour.
1705    pub recent_anomaly_rate: f64,
1706    /// Observed false-positive rate, or `None` before any labelled outcome has
1707    /// been recorded (it is genuinely unmeasurable until then).
1708    pub false_positive_rate: Option<f64>,
1709    /// Number of registered detectors.
1710    pub detector_count: usize,
1711    /// Fraction of executed responses that succeeded, or `None` before any
1712    /// response has run.
1713    pub response_success_rate: Option<f64>,
1714    /// Number of response executions recorded.
1715    pub response_executions: usize,
1716    /// Data points currently retained for context statistics.
1717    pub recent_window_len: usize,
1718}
1719
1720#[cfg(test)]
1721#[path = "anomaly_detection_regression_tests.rs"]
1722mod regression_tests;