//! Phase 3 – Transfer Timeline //! //! Records domain transfer events in the EXO temporal causal graph so the //! system can review its own transfer history and anticipate the next //! beneficial `(src, dst)` pair to activate. use ruvector_domain_expansion::DomainId; use crate::{ AnticipationHint, ConsolidationConfig, ConsolidationResult, TemporalConfig, TemporalMemory, }; use exo_core::{Metadata, Pattern, PatternId, SubstrateTime}; const DIM: usize = 64; // ─── embedding helpers ──────────────────────────────────────────────────────── /// FNV-1a hash of a string, normalised to [0, 1]. fn domain_hash(id: &str) -> f32 { let mut h: u32 = 0x811c_9dc5; for b in id.bytes() { h ^= b as u32; h = h.wrapping_mul(0x0100_0193); } h as f32 / u32::MAX as f32 } /// Build a 64-dim pattern embedding for a transfer event. /// /// Layout: /// * `[0]` – src domain hash (normalised) /// * `[1]` – dst domain hash (normalised) /// * `[2]` – cycle (log-normalised to [0, 1] over 1 000 cycles) /// * `[3]` – delta_reward (clamped to [0, 1]) /// * `[4..64]` – sinusoidal harmonics of `(src_hash + dst_hash)` fn build_embedding(src: &DomainId, dst: &DomainId, cycle: u64, delta_reward: f32) -> Vec { let mut emb = vec![0.0f32; DIM]; let sh = domain_hash(&src.0); let dh = domain_hash(&dst.0); emb[0] = sh; emb[1] = dh; emb[2] = (cycle as f32).ln_1p() / (1_000.0_f32).ln_1p(); emb[3] = delta_reward.clamp(0.0, 1.0); for i in 4..DIM { let phase = (sh + dh) * i as f32 * std::f32::consts::PI / DIM as f32; emb[i] = phase.sin() * 0.5 + 0.5; } emb } // ─── TransferTimeline ───────────────────────────────────────────────────────── /// Records transfer events in the temporal causal graph and provides /// anticipation hints for the next beneficial transfer. pub struct TransferTimeline { memory: TemporalMemory, last_transfer_id: Option, /// Total transfer events recorded (short-term + consolidated). count: usize, } impl TransferTimeline { /// Create with a low salience threshold so even weak transfers are kept. pub fn new() -> Self { let config = TemporalConfig { consolidation: ConsolidationConfig { salience_threshold: 0.1, ..Default::default() }, ..Default::default() }; Self { memory: TemporalMemory::new(config), last_transfer_id: None, count: 0, } } /// Record a transfer event. /// /// `delta_reward` is the improvement in arm reward after transfer /// (`> 0` = positive transfer, `< 0` = negative transfer). /// /// Each event is linked causally to the previous one so the temporal /// causal graph can trace the full transfer trajectory. pub fn record_transfer( &mut self, src: &DomainId, dst: &DomainId, cycle: u64, delta_reward: f32, ) -> crate::Result { let embedding = build_embedding(src, dst, cycle, delta_reward); let salience = delta_reward.abs().clamp(0.1, 1.0); let antecedents: Vec = self.last_transfer_id.iter().copied().collect(); let pattern = Pattern { id: PatternId::new(), embedding, metadata: Metadata::default(), timestamp: SubstrateTime::now(), antecedents: antecedents.clone(), salience, }; let id = self.memory.store(pattern, &antecedents)?; self.last_transfer_id = Some(id); self.count += 1; Ok(id) } /// Consolidate short-term transfer events to long-term memory. pub fn consolidate(&self) -> ConsolidationResult { self.memory.consolidate() } /// Return anticipation hints based on recent transfer causality. /// /// If a previous transfer was recorded the hints suggest continuing /// the same causal chain and sequential pattern. pub fn anticipate_next(&self) -> Vec { match self.last_transfer_id { Some(id) => vec![ AnticipationHint::CausalChain { context: id }, AnticipationHint::SequentialPattern { recent: vec![id] }, ], None => vec![], } } /// Total number of transfer events recorded. pub fn count(&self) -> usize { self.count } /// Causal graph reference for advanced queries. pub fn causal_graph(&self) -> &crate::CausalGraph { self.memory.causal_graph() } } impl Default for TransferTimeline { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_record_and_count() { let mut tl = TransferTimeline::new(); let src = DomainId("retrieval".to_string()); let dst = DomainId("graph".to_string()); tl.record_transfer(&src, &dst, 1, 0.3).unwrap(); tl.record_transfer(&src, &dst, 2, 0.5).unwrap(); assert_eq!(tl.count(), 2); } #[test] fn test_consolidate() { let mut tl = TransferTimeline::new(); let src = DomainId("a".to_string()); let dst = DomainId("b".to_string()); for i in 0..5 { tl.record_transfer(&src, &dst, i, 0.4).unwrap(); } let result = tl.consolidate(); assert!(result.num_consolidated >= 1); } #[test] fn test_anticipate_empty() { let tl = TransferTimeline::new(); assert!(tl.anticipate_next().is_empty()); } #[test] fn test_anticipate_after_record() { let mut tl = TransferTimeline::new(); let src = DomainId("x".to_string()); let dst = DomainId("y".to_string()); tl.record_transfer(&src, &dst, 1, 0.4).unwrap(); let hints = tl.anticipate_next(); assert!(!hints.is_empty()); } #[test] fn test_embedding_values() { let src = DomainId("retrieval".to_string()); let dst = DomainId("graph".to_string()); let emb = build_embedding(&src, &dst, 42, 0.7); assert_eq!(emb.len(), DIM); assert!((emb[3] - 0.7).abs() < 1e-6); } }