use ruv_neural_core::embedding::NeuralEmbedding;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrendDirection {
Stable,
Improving,
Degrading,
Oscillating,
}
pub struct LongitudinalTracker {
baseline_embeddings: Vec<NeuralEmbedding>,
current_trajectory: Vec<NeuralEmbedding>,
drift_threshold: f64,
}
impl LongitudinalTracker {
pub fn new(drift_threshold: f64) -> Self {
Self {
baseline_embeddings: Vec::new(),
current_trajectory: Vec::new(),
drift_threshold,
}
}
pub fn set_baseline(&mut self, embeddings: Vec<NeuralEmbedding>) {
self.baseline_embeddings = embeddings;
}
pub fn add_observation(&mut self, embedding: NeuralEmbedding) {
self.current_trajectory.push(embedding);
}
pub fn num_observations(&self) -> usize {
self.current_trajectory.len()
}
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
}
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<f64> = self.current_trajectory[..mid]
.iter()
.map(|obs| self.min_distance_to_baseline(obs))
.collect();
let second_half: Vec<f64> = 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 {
let diffs: Vec<f64> = 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
}
}
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);
1.0 - (-dist / self.drift_threshold).exp()
}
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)
}
}
fn mean(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
values.iter().sum::<f64>() / 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<f64>, 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)]);
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)]);
for i in 1..=5 {
tracker.add_observation(make_embedding(vec![0.1 * i as f64, 0.0], i as f64));
}
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)]);
for i in 1..=5 {
tracker.add_observation(make_embedding(
vec![10.0 - i as f64 * 1.5, 0.0],
i as f64,
));
}
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);
}
}