Skip to main content

midstreamer_attractor/
lib.rs

1//! # Temporal-Attractor-Studio
2//!
3//! Dynamical systems and strange attractors analysis.
4//!
5//! ## Features
6//! - Attractor classification (point, limit cycle, strange)
7//! - Lyapunov exponent calculation
8//! - Phase space analysis
9//! - Trajectory visualization data
10//! - Stability detection
11
12use serde::{Deserialize, Serialize};
13use std::collections::VecDeque;
14use thiserror::Error;
15
16/// Attractor analysis errors
17#[derive(Debug, Error)]
18pub enum AttractorError {
19    #[error("Insufficient data: need at least {0} points")]
20    InsufficientData(usize),
21
22    #[error("Invalid dimension: {0}")]
23    InvalidDimension(usize),
24
25    #[error("Computation error: {0}")]
26    ComputationError(String),
27}
28
29/// Types of attractors
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub enum AttractorType {
32    /// Point attractor (stable equilibrium)
33    PointAttractor,
34    /// Limit cycle (periodic behavior)
35    LimitCycle,
36    /// Strange attractor (chaotic behavior)
37    StrangeAttractor,
38    /// No clear attractor detected
39    Unknown,
40}
41
42/// A point in phase space
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PhasePoint {
45    pub coordinates: Vec<f64>,
46    pub timestamp: u64,
47}
48
49impl PhasePoint {
50    pub fn new(coordinates: Vec<f64>, timestamp: u64) -> Self {
51        Self {
52            coordinates,
53            timestamp,
54        }
55    }
56
57    pub fn dimension(&self) -> usize {
58        self.coordinates.len()
59    }
60}
61
62/// A trajectory in phase space
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Trajectory {
65    pub points: VecDeque<PhasePoint>,
66    pub max_length: usize,
67}
68
69impl Trajectory {
70    pub fn new(max_length: usize) -> Self {
71        Self {
72            points: VecDeque::new(),
73            max_length,
74        }
75    }
76
77    pub fn push(&mut self, point: PhasePoint) {
78        if self.points.len() >= self.max_length {
79            self.points.pop_front();
80        }
81        self.points.push_back(point);
82    }
83
84    pub fn len(&self) -> usize {
85        self.points.len()
86    }
87
88    pub fn is_empty(&self) -> bool {
89        self.points.is_empty()
90    }
91
92    pub fn clear(&mut self) {
93        self.points.clear();
94    }
95}
96
97/// Information about a detected attractor
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct AttractorInfo {
100    pub attractor_type: AttractorType,
101    pub dimension: usize,
102    pub lyapunov_exponents: Vec<f64>,
103    pub is_stable: bool,
104    pub confidence: f64,
105}
106
107impl AttractorInfo {
108    pub fn is_chaotic(&self) -> bool {
109        matches!(self.attractor_type, AttractorType::StrangeAttractor)
110    }
111
112    pub fn max_lyapunov_exponent(&self) -> Option<f64> {
113        self.lyapunov_exponents
114            .iter()
115            .copied()
116            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
117    }
118}
119
120/// Behavior summary statistics
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct BehaviorSummary {
123    pub total_points: usize,
124    pub dimension: usize,
125    pub attractor_info: Option<AttractorInfo>,
126    pub mean_velocity: f64,
127    pub trajectory_length: f64,
128}
129
130/// Attractor analyzer
131pub struct AttractorAnalyzer {
132    embedding_dimension: usize,
133    min_points_for_analysis: usize,
134    trajectory: Trajectory,
135}
136
137impl AttractorAnalyzer {
138    /// Create a new attractor analyzer
139    pub fn new(embedding_dimension: usize, max_trajectory_length: usize) -> Self {
140        Self {
141            embedding_dimension,
142            min_points_for_analysis: 100,
143            trajectory: Trajectory::new(max_trajectory_length),
144        }
145    }
146
147    /// Add a point to the trajectory
148    pub fn add_point(&mut self, point: PhasePoint) -> Result<(), AttractorError> {
149        if point.dimension() != self.embedding_dimension {
150            return Err(AttractorError::InvalidDimension(point.dimension()));
151        }
152
153        self.trajectory.push(point);
154        Ok(())
155    }
156
157    /// Analyze the current trajectory
158    pub fn analyze(&self) -> Result<AttractorInfo, AttractorError> {
159        if self.trajectory.len() < self.min_points_for_analysis {
160            return Err(AttractorError::InsufficientData(
161                self.min_points_for_analysis,
162            ));
163        }
164
165        // Calculate Lyapunov exponents
166        let lyapunov_exponents = self.calculate_lyapunov_exponents()?;
167
168        // Classify attractor type based on Lyapunov exponents
169        let attractor_type = self.classify_attractor(&lyapunov_exponents);
170
171        // Determine stability
172        let is_stable = lyapunov_exponents.iter().all(|&l| l < 0.0);
173
174        // Calculate confidence based on data quality
175        let confidence = self.calculate_confidence();
176
177        Ok(AttractorInfo {
178            attractor_type,
179            dimension: self.embedding_dimension,
180            lyapunov_exponents,
181            is_stable,
182            confidence,
183        })
184    }
185
186    /// Calculate Lyapunov exponents for the trajectory
187    fn calculate_lyapunov_exponents(&self) -> Result<Vec<f64>, AttractorError> {
188        if self.trajectory.len() < 2 {
189            return Ok(vec![0.0; self.embedding_dimension]);
190        }
191
192        let mut exponents = vec![0.0; self.embedding_dimension];
193
194        // Simplified Lyapunov calculation
195        // In production, this would use more sophisticated methods
196        let points: Vec<&PhasePoint> = self.trajectory.points.iter().collect();
197
198        // Dimension loop reads naturally with an integer index; the
199        // iter_mut().enumerate() rewrite suggested by clippy would
200        // require a separate accumulator and obscure the algorithm.
201        #[allow(clippy::needless_range_loop)]
202        for dim in 0..self.embedding_dimension {
203            let mut sum_log_divergence = 0.0;
204            let mut count = 0;
205
206            for i in 1..points.len() {
207                let diff = points[i].coordinates[dim] - points[i - 1].coordinates[dim];
208                if diff.abs() > 1e-10 {
209                    sum_log_divergence += diff.abs().ln();
210                    count += 1;
211                }
212            }
213
214            if count > 0 {
215                exponents[dim] = sum_log_divergence / count as f64;
216            }
217        }
218
219        Ok(exponents)
220    }
221
222    /// Classify attractor based on Lyapunov exponents
223    fn classify_attractor(&self, lyapunov_exponents: &[f64]) -> AttractorType {
224        let max_exponent = lyapunov_exponents
225            .iter()
226            .copied()
227            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
228            .unwrap_or(0.0);
229
230        if max_exponent > 0.1 {
231            // Positive Lyapunov exponent indicates chaos
232            AttractorType::StrangeAttractor
233        } else if max_exponent > -0.1 && self.detect_periodicity() {
234            // Near-zero with periodicity indicates limit cycle
235            AttractorType::LimitCycle
236        } else if max_exponent < -0.1 {
237            // Negative Lyapunov exponent indicates stable point
238            AttractorType::PointAttractor
239        } else {
240            AttractorType::Unknown
241        }
242    }
243
244    /// Detect if trajectory shows periodic behavior
245    fn detect_periodicity(&self) -> bool {
246        if self.trajectory.len() < 20 {
247            return false;
248        }
249
250        // Simple autocorrelation check
251        let points: Vec<&PhasePoint> = self.trajectory.points.iter().collect();
252        let n = points.len();
253
254        // Check for repeating patterns
255        for lag in 5..n / 4 {
256            let mut correlation = 0.0;
257            let mut count = 0;
258
259            for i in 0..n - lag {
260                for dim in 0..self.embedding_dimension {
261                    let diff =
262                        (points[i].coordinates[dim] - points[i + lag].coordinates[dim]).abs();
263                    correlation += diff;
264                    count += 1;
265                }
266            }
267
268            let avg_diff = correlation / count as f64;
269            if avg_diff < 0.1 {
270                return true; // Found periodic pattern
271            }
272        }
273
274        false
275    }
276
277    /// Calculate confidence in the analysis
278    fn calculate_confidence(&self) -> f64 {
279        let data_ratio = self.trajectory.len() as f64 / self.min_points_for_analysis as f64;
280        data_ratio.min(1.0)
281    }
282
283    /// Get trajectory statistics
284    pub fn get_trajectory_stats(&self) -> BehaviorSummary {
285        let total_points = self.trajectory.len();
286
287        let mut trajectory_length = 0.0;
288        let mut velocity_sum = 0.0;
289
290        let points: Vec<&PhasePoint> = self.trajectory.points.iter().collect();
291
292        for i in 1..points.len() {
293            let mut distance = 0.0;
294            for dim in 0..self.embedding_dimension {
295                let diff = points[i].coordinates[dim] - points[i - 1].coordinates[dim];
296                distance += diff * diff;
297            }
298            let segment_length = distance.sqrt();
299            trajectory_length += segment_length;
300
301            let time_diff = (points[i].timestamp - points[i - 1].timestamp) as f64;
302            if time_diff > 0.0 {
303                velocity_sum += segment_length / time_diff;
304            }
305        }
306
307        let mean_velocity = if points.len() > 1 {
308            velocity_sum / (points.len() - 1) as f64
309        } else {
310            0.0
311        };
312
313        let attractor_info = if total_points >= self.min_points_for_analysis {
314            self.analyze().ok()
315        } else {
316            None
317        };
318
319        BehaviorSummary {
320            total_points,
321            dimension: self.embedding_dimension,
322            attractor_info,
323            mean_velocity,
324            trajectory_length,
325        }
326    }
327
328    /// Clear the trajectory
329    pub fn clear(&mut self) {
330        self.trajectory.clear();
331    }
332
333    /// Get current trajectory length
334    pub fn trajectory_length(&self) -> usize {
335        self.trajectory.len()
336    }
337}
338
339impl Default for AttractorAnalyzer {
340    fn default() -> Self {
341        Self::new(3, 10000)
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_phase_point() {
351        let point = PhasePoint::new(vec![1.0, 2.0, 3.0], 100);
352        assert_eq!(point.dimension(), 3);
353    }
354
355    #[test]
356    fn test_trajectory() {
357        let mut traj = Trajectory::new(10);
358        assert!(traj.is_empty());
359
360        traj.push(PhasePoint::new(vec![1.0, 2.0], 1));
361        assert_eq!(traj.len(), 1);
362
363        // Fill to capacity
364        for i in 2..=11 {
365            traj.push(PhasePoint::new(vec![i as f64, i as f64 * 2.0], i as u64));
366        }
367
368        // Should maintain max length
369        assert_eq!(traj.len(), 10);
370    }
371
372    #[test]
373    fn test_attractor_analyzer() {
374        let mut analyzer = AttractorAnalyzer::new(2, 1000);
375
376        // Add some points
377        for i in 0..150 {
378            let point = PhasePoint::new(vec![i as f64, (i * 2) as f64], i as u64 * 1000);
379            analyzer.add_point(point).unwrap();
380        }
381
382        assert_eq!(analyzer.trajectory_length(), 150);
383
384        let result = analyzer.analyze();
385        assert!(result.is_ok());
386
387        let info = result.unwrap();
388        assert_eq!(info.dimension, 2);
389        assert!(!info.lyapunov_exponents.is_empty());
390    }
391
392    #[test]
393    fn test_invalid_dimension() {
394        let mut analyzer = AttractorAnalyzer::new(3, 1000);
395
396        let point = PhasePoint::new(vec![1.0, 2.0], 100); // Only 2D
397
398        let result = analyzer.add_point(point);
399        assert!(result.is_err());
400    }
401
402    #[test]
403    fn test_insufficient_data() {
404        let analyzer = AttractorAnalyzer::new(2, 1000);
405
406        // Not enough points for analysis
407        let result = analyzer.analyze();
408        assert!(result.is_err());
409    }
410
411    #[test]
412    fn test_behavior_summary() {
413        let mut analyzer = AttractorAnalyzer::new(2, 1000);
414
415        for i in 0..50 {
416            let point = PhasePoint::new(vec![i as f64, i as f64], i as u64 * 100);
417            analyzer.add_point(point).unwrap();
418        }
419
420        let summary = analyzer.get_trajectory_stats();
421        assert_eq!(summary.total_points, 50);
422        assert_eq!(summary.dimension, 2);
423        assert!(summary.trajectory_length > 0.0);
424    }
425
426    #[test]
427    fn test_nan_handling_in_lyapunov_exponents() {
428        // Test that NaN values don't cause panics in max_lyapunov_exponent
429        let info = AttractorInfo {
430            attractor_type: AttractorType::StrangeAttractor,
431            dimension: 3,
432            lyapunov_exponents: vec![1.0, f64::NAN, -0.5],
433            is_stable: false,
434            confidence: 0.95,
435        };
436
437        // Should not panic, should handle NaN gracefully
438        let max_exp = info.max_lyapunov_exponent();
439        assert!(max_exp.is_some());
440        // With NaN handling, should return one of the valid values
441        let val = max_exp.unwrap();
442        assert!(val.is_finite(), "Should not return NaN");
443    }
444
445    #[test]
446    fn test_nan_handling_in_trajectory() {
447        // Test that NaN values in trajectory don't cause panics during analysis
448        let mut analyzer = AttractorAnalyzer::new(2, 1000);
449
450        // Add points including some with NaN to trigger edge cases
451        for i in 0..150 {
452            let coords = if i == 50 {
453                // Insert a point that could lead to NaN in calculations
454                vec![f64::NAN, i as f64]
455            } else {
456                vec![i as f64, (i * 2) as f64]
457            };
458
459            let point = PhasePoint::new(coords, i as u64 * 1000);
460            // Should not panic when adding or analyzing
461            let _ = analyzer.add_point(point);
462        }
463
464        // Analysis should complete without panicking
465        let result = analyzer.analyze();
466        assert!(result.is_ok(), "Analysis should handle NaN gracefully");
467
468        let info = result.unwrap();
469        // Verify the result is usable
470        assert_eq!(info.dimension, 2);
471    }
472
473    #[test]
474    fn test_all_nan_lyapunov_exponents() {
475        // Edge case: all NaN values
476        let info = AttractorInfo {
477            attractor_type: AttractorType::Unknown,
478            dimension: 2,
479            lyapunov_exponents: vec![f64::NAN, f64::NAN],
480            is_stable: false,
481            confidence: 0.5,
482        };
483
484        // Should not panic even with all NaN
485        let max_exp = info.max_lyapunov_exponent();
486        assert!(max_exp.is_some());
487    }
488}