//! Longitudinal tracking and drift detection for neural topology changes //! over extended observation periods. use ruv_neural_core::embedding::NeuralEmbedding; /// Direction of observed trend in neural embeddings. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrendDirection { /// No significant change from baseline. Stable, /// Embedding distances are decreasing (closer to baseline). Improving, /// Embedding distances are increasing (drifting from baseline). Degrading, /// Embeddings alternate between improving and degrading. Oscillating, } /// Tracks neural topology changes over extended periods, detecting drift /// from an established baseline. pub struct LongitudinalTracker { /// Baseline embeddings representing the reference state. baseline_embeddings: Vec, /// Current trajectory of observations. current_trajectory: Vec, /// Threshold above which drift is considered significant. drift_threshold: f64, } impl LongitudinalTracker { /// Create a new tracker with the given drift threshold. pub fn new(drift_threshold: f64) -> Self { Self { baseline_embeddings: Vec::new(), current_trajectory: Vec::new(), drift_threshold, } } /// Set the baseline embeddings (the reference state). pub fn set_baseline(&mut self, embeddings: Vec) { self.baseline_embeddings = embeddings; } /// Add a new observation to the current trajectory. pub fn add_observation(&mut self, embedding: NeuralEmbedding) { self.current_trajectory.push(embedding); } /// Number of observations in the current trajectory. pub fn num_observations(&self) -> usize { self.current_trajectory.len() } /// Compute the mean drift from baseline. /// /// Returns the average Euclidean distance from each trajectory embedding /// to the nearest baseline embedding. Returns 0.0 if either baseline or /// trajectory is empty. pub fn compute_drift(&self) -> f64 { if self.baseline_embeddings.is_empty() || self.current_trajectory.is_empty() { return 0.0; } let total_drift: f64 = self .current_trajectory .iter() .map(|obs| self.min_distance_to_baseline(obs)) .sum(); total_drift / self.current_trajectory.len() as f64 } /// Detect the overall trend direction from the trajectory. /// /// Compares drift of the first half vs second half of the trajectory. pub fn detect_trend(&self) -> TrendDirection { if self.current_trajectory.len() < 4 || self.baseline_embeddings.is_empty() { return TrendDirection::Stable; } let mid = self.current_trajectory.len() / 2; let first_half: Vec = self.current_trajectory[..mid] .iter() .map(|obs| self.min_distance_to_baseline(obs)) .collect(); let second_half: Vec = self.current_trajectory[mid..] .iter() .map(|obs| self.min_distance_to_baseline(obs)) .collect(); let first_mean = mean(&first_half); let second_mean = mean(&second_half); let diff = second_mean - first_mean; if diff.abs() < self.drift_threshold * 0.1 { // Check for oscillation by looking at alternating signs let diffs: Vec = self .current_trajectory .windows(2) .map(|w| { self.min_distance_to_baseline(&w[1]) - self.min_distance_to_baseline(&w[0]) }) .collect(); let sign_changes = diffs .windows(2) .filter(|w| w[0].signum() != w[1].signum()) .count(); if sign_changes > diffs.len() / 2 { return TrendDirection::Oscillating; } TrendDirection::Stable } else if diff > 0.0 { TrendDirection::Degrading } else { TrendDirection::Improving } } /// Compute an anomaly score for a single embedding. /// /// Returns a score in [0, 1] where 1 means highly anomalous relative /// to the baseline. Based on how far the embedding is from the baseline /// relative to the drift threshold. pub fn anomaly_score(&self, embedding: &NeuralEmbedding) -> f64 { if self.baseline_embeddings.is_empty() { return 0.0; } let dist = self.min_distance_to_baseline(embedding); // Sigmoid-like mapping: score = 1 - exp(-dist / threshold) 1.0 - (-dist / self.drift_threshold).exp() } /// Minimum Euclidean distance from an embedding to any baseline embedding. fn min_distance_to_baseline(&self, embedding: &NeuralEmbedding) -> f64 { self.baseline_embeddings .iter() .filter_map(|base| base.euclidean_distance(embedding).ok()) .fold(f64::MAX, f64::min) } } /// Compute the arithmetic mean of a slice. fn mean(values: &[f64]) -> f64 { if values.is_empty() { return 0.0; } values.iter().sum::() / values.len() as f64 } #[cfg(test)] mod tests { use super::*; use ruv_neural_core::brain::Atlas; use ruv_neural_core::embedding::EmbeddingMetadata; use ruv_neural_core::topology::CognitiveState; fn make_embedding(vector: Vec, timestamp: f64) -> NeuralEmbedding { NeuralEmbedding::new( vector, timestamp, EmbeddingMetadata { subject_id: Some("subj1".to_string()), session_id: None, cognitive_state: Some(CognitiveState::Rest), source_atlas: Atlas::Schaefer100, embedding_method: "test".to_string(), }, ) .unwrap() } #[test] fn empty_tracker_returns_zero_drift() { let tracker = LongitudinalTracker::new(1.0); assert_eq!(tracker.compute_drift(), 0.0); } #[test] fn no_drift_when_same_as_baseline() { let mut tracker = LongitudinalTracker::new(1.0); tracker.set_baseline(vec![make_embedding(vec![0.0, 0.0], 0.0)]); tracker.add_observation(make_embedding(vec![0.0, 0.0], 1.0)); assert!(tracker.compute_drift() < 1e-10); } #[test] fn detects_known_drift() { let mut tracker = LongitudinalTracker::new(1.0); tracker.set_baseline(vec![make_embedding(vec![0.0, 0.0, 0.0], 0.0)]); // Add observations that progressively drift for i in 1..=10 { let offset = i as f64; tracker.add_observation(make_embedding(vec![offset, 0.0, 0.0], i as f64)); } let drift = tracker.compute_drift(); assert!(drift > 1.0, "Expected significant drift, got {}", drift); } #[test] fn degrading_trend_detected() { let mut tracker = LongitudinalTracker::new(1.0); tracker.set_baseline(vec![make_embedding(vec![0.0, 0.0], 0.0)]); // First half: close to baseline for i in 1..=5 { tracker.add_observation(make_embedding(vec![0.1 * i as f64, 0.0], i as f64)); } // Second half: far from baseline for i in 6..=10 { tracker.add_observation(make_embedding(vec![2.0 * i as f64, 0.0], i as f64)); } assert_eq!(tracker.detect_trend(), TrendDirection::Degrading); } #[test] fn improving_trend_detected() { let mut tracker = LongitudinalTracker::new(1.0); tracker.set_baseline(vec![make_embedding(vec![0.0, 0.0], 0.0)]); // First half: far from baseline for i in 1..=5 { tracker.add_observation(make_embedding( vec![10.0 - i as f64 * 1.5, 0.0], i as f64, )); } // Second half: close to baseline for i in 6..=10 { tracker.add_observation(make_embedding(vec![0.1, 0.0], i as f64)); } assert_eq!(tracker.detect_trend(), TrendDirection::Improving); } #[test] fn anomaly_score_increases_with_distance() { let mut tracker = LongitudinalTracker::new(2.0); tracker.set_baseline(vec![make_embedding(vec![0.0, 0.0], 0.0)]); let near = make_embedding(vec![0.1, 0.0], 1.0); let far = make_embedding(vec![10.0, 10.0], 2.0); let score_near = tracker.anomaly_score(&near); let score_far = tracker.anomaly_score(&far); assert!(score_near < score_far); assert!(score_near >= 0.0 && score_near <= 1.0); assert!(score_far >= 0.0 && score_far <= 1.0); } #[test] fn anomaly_score_zero_without_baseline() { let tracker = LongitudinalTracker::new(1.0); let emb = make_embedding(vec![5.0, 5.0], 1.0); assert_eq!(tracker.anomaly_score(&emb), 0.0); } }