//! Observador estadístico. Mantiene marginales y co-ocurrencias dentro de una //! ventana deslizante. Calcula entropía de Shannon e información mutua para //! identificar correlaciones significativas. //! //! Diseño: //! - Counters incrementales: cada `record()` es O(window_size) en el peor //! caso (actualiza co-ocurrencias con cada evento del window). //! - Sin recomputaciones globales: marginales y joint counts son state. //! - El cálculo de H(X), P(B|A), I(A;B) es O(|distinct events|). use crate::rules::EventKind; use std::collections::{HashMap, VecDeque}; use std::time::Instant; /// Evento timestamped. El timestamp se conserva para futuras políticas de /// expiración por tiempo (no sólo por count). #[derive(Debug, Clone)] pub struct TimedEvent { pub kind: EventKind, pub at: Instant, } pub struct Observer { window: VecDeque, window_size: usize, marginal: HashMap, cooccur: HashMap<(EventKind, EventKind), u64>, total: u64, /// Last-seen timestamps para aplicar decay en query time. None = sin /// time-decay (modo tradicional). last_seen_marginal: HashMap, last_seen_cooccur: HashMap<(EventKind, EventKind), Instant>, /// Half-life del decay exponencial en segundos. None = sin decay /// (las consultas devuelven los counts crudos). half_life_secs: Option, } impl Observer { pub fn new(window_size: usize) -> Self { Self { window: VecDeque::with_capacity(window_size), window_size, marginal: HashMap::new(), cooccur: HashMap::new(), total: 0, last_seen_marginal: HashMap::new(), last_seen_cooccur: HashMap::new(), half_life_secs: None, } } /// Activa decay exponencial con half-life en segundos. λ = ln(2)/half_life. /// Aplicado en query time sobre los counts crudos usando last_seen. pub fn with_half_life(mut self, half_life_secs: f64) -> Self { if half_life_secs > 0.0 { self.half_life_secs = Some(half_life_secs); } self } pub fn half_life(&self) -> Option { self.half_life_secs } /// Registra un evento. Actualiza marginales y co-ocurrencias contra todo /// evento aún en la ventana. pub fn record(&mut self, kind: EventKind) { let now = Instant::now(); let timed = TimedEvent { kind: kind.clone(), at: now }; // Co-ocurrencias: este evento con cada uno previo en ventana. for w in &self.window { let key = (w.kind.clone(), kind.clone()); *self.cooccur.entry(key.clone()).or_insert(0) += 1; self.last_seen_cooccur.insert(key, now); } self.window.push_back(timed); if self.window.len() > self.window_size { self.window.pop_front(); } *self.marginal.entry(kind.clone()).or_insert(0) += 1; self.last_seen_marginal.insert(kind, now); self.total += 1; } /// Aplica el decay sobre un count crudo dado el `last_seen` correspondiente. /// Si half_life es None, devuelve el count tal cual (sin decay). fn decay(&self, count: u64, last_seen: Option) -> f64 { let raw = count as f64; let (hl, last) = match (self.half_life_secs, last_seen) { (Some(hl), Some(t)) => (hl, t), _ => return raw, }; let age_secs = Instant::now().duration_since(last).as_secs_f64(); raw * 0.5_f64.powf(age_secs / hl) } /// Marginal con decay aplicado. pub fn marginal_decayed(&self, k: &EventKind) -> f64 { let raw = self.marginal.get(k).copied().unwrap_or(0); let last = self.last_seen_marginal.get(k).copied(); self.decay(raw, last) } /// Cooccurrence con decay aplicado. pub fn cooccur_decayed(&self, a: &EventKind, b: &EventKind) -> f64 { let raw = self.cooccur.get(&(a.clone(), b.clone())).copied().unwrap_or(0); let last = self.last_seen_cooccur.get(&(a.clone(), b.clone())).copied(); self.decay(raw, last) } /// Entropía de Shannon de la distribución marginal de eventos. /// H(X) = −Σ p(x) log₂ p(x). Unidad: bits. pub fn shannon_entropy(&self) -> f64 { if self.total == 0 { return 0.0; } let total = self.total as f64; self.marginal.values() .map(|&c| { let p = c as f64 / total; if p > 0.0 { -p * p.log2() } else { 0.0 } }) .sum() } /// P(b | a) = "dado que algo siguió a `a` dentro del window, qué fracción /// fue `b`". Suma 1 sobre todos los b posibles para un a fijo. /// /// Implementación: cooccur_decayed(a, b) / Σ_x cooccur_decayed(a, x). /// Si half_life is None, los decayed values son los counts crudos. pub fn conditional_prob(&self, a: &EventKind, b: &EventKind) -> f64 { let joint = self.cooccur_decayed(a, b); let row_total: f64 = self.cooccur.keys() .filter(|(x, _)| x == a) .map(|(x, y)| self.cooccur_decayed(x, y)) .sum(); if row_total <= 0.0 { 0.0 } else { joint / row_total } } /// Información mutua puntual entre `a` y `b` con decay aplicado: /// PMI(a, b) = log₂( P(a, b) / (P(a) · P(b)) ). /// Positivo → más correlacionados de lo que sugiere independencia. pub fn pmi(&self, a: &EventKind, b: &EventKind) -> f64 { // Total decayed: suma de marginales con decay (no usamos self.total // directo porque debería ser consistente con los decayed values). let total_decayed: f64 = self.marginal.keys() .map(|k| self.marginal_decayed(k)) .sum(); if total_decayed <= 0.0 { return 0.0; } let joint = self.cooccur_decayed(a, b) / total_decayed; let pa = self.marginal_decayed(a) / total_decayed; let pb = self.marginal_decayed(b) / total_decayed; if joint <= 0.0 || pa <= 0.0 || pb <= 0.0 { return 0.0; } (joint / (pa * pb)).log2() } /// Información mutua acumulada de la pareja (a, b) ponderada por su /// probabilidad conjunta. Útil como medida de "interés" del par. pub fn weighted_pmi(&self, a: &EventKind, b: &EventKind) -> f64 { if self.total == 0 { return 0.0; } let joint = self.cooccur .get(&(a.clone(), b.clone())) .copied() .unwrap_or(0) as f64 / self.total as f64; joint * self.pmi(a, b) } pub fn marginals(&self) -> &HashMap { &self.marginal } pub fn cooccurrences(&self) -> &HashMap<(EventKind, EventKind), u64> { &self.cooccur } pub fn total(&self) -> u64 { self.total } pub fn window_size(&self) -> usize { self.window_size } pub fn current_window(&self) -> usize { self.window.len() } /// Últimos N eventos del window, en orden cronológico (más viejo primero). /// Si N > window.len(), devuelve todo el window. pub fn recent(&self, n: usize) -> impl Iterator { let start = self.window.len().saturating_sub(n); self.window.range(start..) } } #[cfg(test)] mod tests { use super::*; use crate::rules::EventKind::*; #[test] fn entropy_zero_for_single_event() { let mut obs = Observer::new(10); for _ in 0..5 { obs.record(EnteSpawned); } // Distribución degenerada: una sola observación posible → H = 0. assert!(obs.shannon_entropy() < 1e-9); } #[test] fn entropy_one_for_balanced_binary() { let mut obs = Observer::new(100); for _ in 0..10 { obs.record(EnteSpawned); } for _ in 0..10 { obs.record(EnteDied); } // Bernoulli(0.5) → H = 1 bit. assert!((obs.shannon_entropy() - 1.0).abs() < 1e-9); } #[test] fn conditional_prob_perfect_dependency() { let mut obs = Observer::new(100); // Spawned siempre seguido por Died. for _ in 0..5 { obs.record(EnteSpawned); obs.record(EnteDied); } let p = obs.conditional_prob(&EnteSpawned, &EnteDied); assert!(p > 0.0, "esperamos correlación positiva, got {p}"); } }