//! Temporal sliding-window embeddings for brain graph sequences. //! //! Embeds a time series of brain graphs into trajectory vectors by combining //! each graph's embedding with an exponentially-weighted average of past embeddings. use ruv_neural_core::embedding::{EmbeddingTrajectory, NeuralEmbedding}; use ruv_neural_core::error::{Result, RuvNeuralError}; use ruv_neural_core::graph::{BrainGraph, BrainGraphSequence}; use ruv_neural_core::traits::EmbeddingGenerator; use crate::default_metadata; /// Temporal embedder that enriches each graph embedding with historical context. pub struct TemporalEmbedder { /// Base embedder for individual graphs. base_embedder: Box, /// Number of past embeddings to consider in the context window. window_size: usize, /// Exponential decay factor for weighting past embeddings (0 < decay <= 1). decay: f64, } impl TemporalEmbedder { /// Create a new temporal embedder. /// /// - `base`: the embedding generator for individual graphs /// - `window`: how many past embeddings to incorporate pub fn new(base: Box, window: usize) -> Self { Self { base_embedder: base, window_size: window, decay: 0.8, } } /// Set the exponential decay factor. pub fn with_decay(mut self, decay: f64) -> Self { self.decay = decay.clamp(0.01, 1.0); self } /// Embed a full sequence of graphs into a trajectory. pub fn embed_sequence(&self, sequence: &BrainGraphSequence) -> Result { if sequence.is_empty() { return Err(RuvNeuralError::Embedding( "Cannot embed empty graph sequence".into(), )); } let mut history: Vec = Vec::new(); let mut embeddings = Vec::with_capacity(sequence.graphs.len()); let mut timestamps = Vec::with_capacity(sequence.graphs.len()); for graph in &sequence.graphs { let emb = self.embed_with_context(graph, &history)?; timestamps.push(graph.timestamp); history.push(self.base_embedder.embed(graph)?); embeddings.push(emb); } Ok(EmbeddingTrajectory { embeddings, timestamps, }) } /// Embed a single graph with temporal context from past embeddings. /// /// The output concatenates: /// 1. The current graph's base embedding /// 2. An exponentially-weighted average of past embeddings (zero-padded if no history) pub fn embed_with_context( &self, graph: &BrainGraph, history: &[NeuralEmbedding], ) -> Result { let current = self.base_embedder.embed(graph)?; let base_dim = current.dimension; let context = self.compute_context(history, base_dim); let mut values = Vec::with_capacity(base_dim * 2); values.extend_from_slice(¤t.vector); values.extend_from_slice(&context); let meta = default_metadata("temporal", graph.atlas); NeuralEmbedding::new(values, graph.timestamp, meta) } /// Compute the exponentially-weighted context vector from history. fn compute_context(&self, history: &[NeuralEmbedding], dim: usize) -> Vec { if history.is_empty() { return vec![0.0; dim]; } let window_start = if history.len() > self.window_size { history.len() - self.window_size } else { 0 }; let window = &history[window_start..]; let mut context = vec![0.0; dim]; let mut total_weight = 0.0; for (i, emb) in window.iter().rev().enumerate() { let w = self.decay.powi(i as i32); total_weight += w; let usable_dim = dim.min(emb.dimension); for j in 0..usable_dim { context[j] += w * emb.vector[j]; } } if total_weight > 1e-12 { for v in &mut context { *v /= total_weight; } } context } /// Output dimension: base dimension * 2 (current + context). pub fn output_dimension(&self) -> usize { self.base_embedder.embedding_dim() * 2 } } #[cfg(test)] mod tests { use super::*; use crate::topology_embed::TopologyEmbedder; use ruv_neural_core::brain::Atlas; use ruv_neural_core::graph::{BrainEdge, ConnectivityMetric}; use ruv_neural_core::signal::FrequencyBand; fn make_graph(timestamp: f64) -> BrainGraph { BrainGraph { num_nodes: 3, edges: vec![ BrainEdge { source: 0, target: 1, weight: 1.0, metric: ConnectivityMetric::Coherence, frequency_band: FrequencyBand::Alpha, }, BrainEdge { source: 1, target: 2, weight: 0.5, metric: ConnectivityMetric::Coherence, frequency_band: FrequencyBand::Alpha, }, ], timestamp, window_duration_s: 0.5, atlas: Atlas::Custom(3), } } #[test] fn test_temporal_embed_no_history() { let embedder = TemporalEmbedder::new(Box::new(TopologyEmbedder::new()), 5); let graph = make_graph(0.0); let emb = embedder.embed_with_context(&graph, &[]).unwrap(); let base_dim = TopologyEmbedder::new().embedding_dim(); assert_eq!(emb.dimension, base_dim * 2); for i in base_dim..emb.dimension { assert!( emb.vector[i].abs() < 1e-12, "Context should be zero with no history" ); } } #[test] fn test_temporal_embed_sequence() { let base = Box::new(TopologyEmbedder::new()); let embedder = TemporalEmbedder::new(base, 3); let sequence = BrainGraphSequence { graphs: vec![make_graph(0.0), make_graph(0.5), make_graph(1.0)], window_step_s: 0.5, }; let trajectory = embedder.embed_sequence(&sequence).unwrap(); assert_eq!(trajectory.len(), 3); assert_eq!(trajectory.timestamps.len(), 3); let base_dim = TopologyEmbedder::new().embedding_dim(); for i in base_dim..trajectory.embeddings[0].dimension { assert!(trajectory.embeddings[0].vector[i].abs() < 1e-12); } let has_nonzero = trajectory.embeddings[2].vector[base_dim..] .iter() .any(|v| v.abs() > 1e-12); assert!( has_nonzero, "Third embedding should have non-zero temporal context" ); } #[test] fn test_temporal_empty_sequence_fails() { let embedder = TemporalEmbedder::new(Box::new(TopologyEmbedder::new()), 3); let sequence = BrainGraphSequence { graphs: vec![], window_step_s: 0.5, }; assert!(embedder.embed_sequence(&sequence).is_err()); } }