aimds_analysis/
behavioral.rs

1//! Behavioral analysis using temporal attractors
2//!
3//! Uses temporal-attractor-studio for attractor-based anomaly detection
4//! with Lyapunov exponent calculations.
5//!
6//! Performance target: <100ms p99 (87ms baseline + 13ms overhead)
7
8use midstreamer_attractor::{AttractorAnalyzer, AttractorInfo};
9use crate::errors::{AnalysisError, AnalysisResult};
10use std::sync::Arc;
11use std::sync::RwLock;
12
13/// Behavioral profile representing normal system behavior
14#[derive(Debug, Clone)]
15pub struct BehaviorProfile {
16    /// Baseline attractors learned from normal behavior
17    pub baseline_attractors: Vec<AttractorInfo>,
18    /// Dimensions of state space
19    pub dimensions: usize,
20    /// Anomaly detection threshold
21    pub threshold: f64,
22}
23
24impl Default for BehaviorProfile {
25    fn default() -> Self {
26        Self {
27            baseline_attractors: Vec::new(),
28            dimensions: 10,
29            threshold: 0.75,
30        }
31    }
32}
33
34/// Anomaly score from behavioral analysis
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
36pub struct AnomalyScore {
37    /// Anomaly score (0.0 = normal, 1.0 = highly anomalous)
38    pub score: f64,
39    /// Whether this is classified as anomalous
40    pub is_anomalous: bool,
41    /// Confidence in the classification
42    pub confidence: f64,
43}
44
45impl AnomalyScore {
46    /// Create normal score
47    pub fn normal() -> Self {
48        Self {
49            score: 0.0,
50            is_anomalous: false,
51            confidence: 1.0,
52        }
53    }
54
55    /// Create anomalous score
56    pub fn anomalous(score: f64, confidence: f64) -> Self {
57        Self {
58            score,
59            is_anomalous: true,
60            confidence,
61        }
62    }
63}
64
65/// Behavioral analyzer using temporal attractors
66pub struct BehavioralAnalyzer {
67    #[allow(dead_code)]
68    analyzer: Arc<AttractorAnalyzer>,
69    profile: Arc<RwLock<BehaviorProfile>>,
70}
71
72impl BehavioralAnalyzer {
73    /// Create new behavioral analyzer
74    pub fn new(dimensions: usize) -> AnalysisResult<Self> {
75        let analyzer = AttractorAnalyzer::new(dimensions, 1000);
76
77        let profile = BehaviorProfile {
78            dimensions,
79            threshold: 0.75,
80            ..Default::default()
81        };
82
83        Ok(Self {
84            analyzer: Arc::new(analyzer),
85            profile: Arc::new(RwLock::new(profile)),
86        })
87    }
88
89    /// Analyze behavior sequence for anomalies
90    ///
91    /// Uses temporal-attractor-studio to:
92    /// 1. Calculate Lyapunov exponents
93    /// 2. Identify attractors in state space
94    /// 3. Compare against baseline behavior
95    ///
96    /// Performance: <100ms p99 (87ms baseline + overhead)
97    pub async fn analyze_behavior(&self, sequence: &[f64]) -> AnalysisResult<AnomalyScore> {
98        if sequence.is_empty() {
99            return Err(AnalysisError::InvalidInput("Empty sequence".to_string()));
100        }
101
102        // Extract needed values before await to avoid holding lock across await
103        let (dimensions, baseline_attractors, baseline_len, threshold) = {
104            let profile = self.profile.read().unwrap();
105            (profile.dimensions, profile.baseline_attractors.clone(), profile.baseline_attractors.len(), profile.threshold)
106        };
107
108        // Validate dimensions
109        let expected_len = dimensions;
110        if !sequence.len().is_multiple_of(expected_len) {
111            return Err(AnalysisError::InvalidInput(
112                format!("Sequence length {} not divisible by dimensions {}",
113                    sequence.len(), expected_len)
114            ));
115        }
116
117        // Use temporal-attractor-studio for analysis
118        let attractor_result = tokio::task::spawn_blocking({
119            let seq = sequence.to_vec();
120            move || {
121                // Create temporary analyzer for thread safety
122                let mut temp_analyzer = AttractorAnalyzer::new(dimensions, 1000);
123
124                // Add all points from sequence
125                for (i, chunk) in seq.chunks(dimensions).enumerate() {
126                    let point = midstreamer_attractor::PhasePoint::new(
127                        chunk.to_vec(),
128                        i as u64,
129                    );
130                    temp_analyzer.add_point(point)?;
131                }
132
133                // Analyze trajectory
134                temp_analyzer.analyze()
135            }
136        })
137        .await
138        .map_err(|e| AnalysisError::Internal(e.to_string()))?
139        .map_err(|e| AnalysisError::TemporalAttractor(e.to_string()))?;
140
141        // If no baseline, this is likely training data
142        if baseline_attractors.is_empty() {
143            return Ok(AnomalyScore::normal());
144        }
145
146        // Calculate deviation from baseline using Lyapunov exponents
147        let current_lyapunov = attractor_result.lyapunov_exponents.first().copied().unwrap_or(0.0);
148        let baseline_lyapunov: f64 = baseline_attractors.iter()
149            .filter_map(|a| a.lyapunov_exponents.first().copied())
150            .sum::<f64>() / baseline_len as f64;
151
152        // Calculate deviation from baseline
153        let deviation = (current_lyapunov - baseline_lyapunov).abs();
154        let normalized_deviation = if baseline_lyapunov.abs() > 1e-10 {
155            (deviation / baseline_lyapunov.abs()).min(1.0)
156        } else {
157            0.0
158        };
159
160        // Determine if anomalous
161        let is_anomalous = normalized_deviation > threshold;
162        let confidence: f64 = if is_anomalous {
163            ((normalized_deviation - threshold) / (1.0 - threshold)).clamp(0.0, 1.0)
164        } else {
165            (1.0 - (normalized_deviation / threshold)).clamp(0.0, 1.0)
166        };
167
168        Ok(AnomalyScore {
169            score: normalized_deviation,
170            is_anomalous,
171            confidence,
172        })
173    }
174
175    /// Train baseline behavior profile
176    pub async fn train_baseline(&self, sequences: Vec<Vec<f64>>) -> AnalysisResult<()> {
177        if sequences.is_empty() {
178            return Err(AnalysisError::InvalidInput("No training sequences".to_string()));
179        }
180
181        let mut attractors = Vec::new();
182        let dimensions = self.profile.read().unwrap().dimensions;
183
184        for sequence in sequences {
185            let result = tokio::task::spawn_blocking({
186                let seq = sequence.clone();
187                let dims = dimensions;
188                move || {
189                    let mut temp_analyzer = AttractorAnalyzer::new(dims, 1000);
190
191                    // Add all points from sequence
192                    for (i, chunk) in seq.chunks(dims).enumerate() {
193                        let point = midstreamer_attractor::PhasePoint::new(
194                            chunk.to_vec(),
195                            i as u64,
196                        );
197                        temp_analyzer.add_point(point)?;
198                    }
199
200                    // Analyze trajectory
201                    temp_analyzer.analyze()
202                }
203            })
204            .await
205            .map_err(|e| AnalysisError::Internal(e.to_string()))?
206            .map_err(|e| AnalysisError::TemporalAttractor(e.to_string()))?;
207
208            attractors.push(result);
209        }
210
211        let mut profile = self.profile.write().unwrap();
212        profile.baseline_attractors = attractors;
213
214        tracing::info!("Trained baseline with {} attractors", profile.baseline_attractors.len());
215
216        Ok(())
217    }
218
219    /// Check if score indicates anomaly
220    pub fn is_anomalous(&self, score: &AnomalyScore) -> bool {
221        score.is_anomalous
222    }
223
224    /// Update anomaly detection threshold
225    pub fn set_threshold(&self, threshold: f64) {
226        let mut profile = self.profile.write().unwrap();
227        profile.threshold = threshold.clamp(0.0, 1.0);
228    }
229
230    /// Get current threshold
231    pub fn threshold(&self) -> f64 {
232        self.profile.read().unwrap().threshold
233    }
234
235    /// Get number of baseline attractors
236    pub fn baseline_count(&self) -> usize {
237        self.profile.read().unwrap().baseline_attractors.len()
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[tokio::test]
246    async fn test_analyzer_creation() {
247        let analyzer = BehavioralAnalyzer::new(10).unwrap();
248        assert_eq!(analyzer.threshold(), 0.75);
249        assert_eq!(analyzer.baseline_count(), 0);
250    }
251
252    #[tokio::test]
253    async fn test_empty_sequence() {
254        let analyzer = BehavioralAnalyzer::new(10).unwrap();
255        let result = analyzer.analyze_behavior(&[]).await;
256        assert!(result.is_err());
257    }
258
259    #[tokio::test]
260    async fn test_invalid_dimensions() {
261        let analyzer = BehavioralAnalyzer::new(10).unwrap();
262        let sequence = vec![1.0; 15]; // Not divisible by 10
263        let result = analyzer.analyze_behavior(&sequence).await;
264        assert!(result.is_err());
265    }
266
267    #[tokio::test]
268    async fn test_normal_behavior_without_baseline() {
269        let analyzer = BehavioralAnalyzer::new(10).unwrap();
270        let sequence = vec![0.5; 1000]; // 10 dimensions * 100 points (minimum required)
271        let score = analyzer.analyze_behavior(&sequence).await.unwrap();
272        assert!(!score.is_anomalous);
273    }
274
275    #[tokio::test]
276    async fn test_threshold_update() {
277        let analyzer = BehavioralAnalyzer::new(10).unwrap();
278        analyzer.set_threshold(0.9);
279        assert!((analyzer.threshold() - 0.9).abs() < 1e-6);
280    }
281
282    #[tokio::test]
283    async fn test_anomaly_score_helpers() {
284        let normal = AnomalyScore::normal();
285        assert!(!normal.is_anomalous);
286        assert_eq!(normal.score, 0.0);
287
288        let anomalous = AnomalyScore::anomalous(0.9, 0.95);
289        assert!(anomalous.is_anomalous);
290        assert_eq!(anomalous.score, 0.9);
291    }
292}