gioser
⎇ f4eb7dd94404c2fadd8099c61ca11d2860bcff7a crates/ente-brain/src/crystallize.rs Rust · 290 siq'ikuna · 10.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

Capability quotas, CAS gc, patrones burst/silence, snapshot incremental f4eb7dd94 · Sergio ·

1//! Cristalización: del flujo observado a reglas explícitas.
2//!
3//! Detecta pares (a, b) donde:
4//!   - support(a, b) ≥ min_support  (suficientes muestras para no ser ruido)
5//!   - P(b|a) ≥ min_conditional_prob (a predice b con confianza)
6//!   - PMI(a; b) ≥ min_pmi          (más correlacionados que random)
7//!
8//! Cada cristal puede emitirse como snippet KCL (texto humano-readable) o
9//! como `Rule` ejecutable directamente por el motor.
10
11use crate::observer::{GapStats, Observer};
12use crate::rules::{Action, EventKind, EventPattern, LogLevel, Rule, Scope};
13use serde::{Deserialize, Serialize};
14use std::time::Instant;
15use ulid::Ulid;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Crystal {
19    pub antecedent: EventKind,
20    pub consequent: EventKind,
21    pub conditional_prob: f64,
22    pub pmi: f64,
23    pub support: u64,
24    /// Estadísticas del gap temporal entre antecedent → consequent.
25    /// None si no hay histograma. Habilita generación de reglas Sequence
26    /// con `within_ms = (mean + 2σ) * 1000`.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub gap_stats: Option<GapStats>,
29}
30
31#[derive(Debug, Clone, Copy)]
32pub struct CrystallizationParams {
33    pub min_support: u64,
34    pub min_conditional_prob: f64,
35    pub min_pmi: f64,
36}
37
38impl Default for CrystallizationParams {
39    fn default() -> Self {
40        Self {
41            min_support: 5,
42            min_conditional_prob: 0.7,
43            min_pmi: 0.5,
44        }
45    }
46}
47
48pub fn detect_crystals(obs: &Observer, params: &CrystallizationParams) -> Vec<Crystal> {
49    let mut out = Vec::new();
50    for ((a, b), &count) in obs.cooccurrences() {
51        if count < params.min_support { continue; }
52        let cp = obs.conditional_prob(a, b);
53        if cp < params.min_conditional_prob { continue; }
54        let mi = obs.pmi(a, b);
55        if mi < params.min_pmi { continue; }
56        // Stats del histograma si existen para este par.
57        let gap_stats = obs.gap_histograms()
58            .get(&(a.clone(), b.clone()))
59            .map(|h| h.stats());
60        out.push(Crystal {
61            antecedent: a.clone(),
62            consequent: b.clone(),
63            conditional_prob: cp,
64            pmi: mi,
65            support: count,
66            gap_stats,
67        });
68    }
69    out.sort_by(|x, y| y.conditional_prob.partial_cmp(&x.conditional_prob).unwrap_or(std::cmp::Ordering::Equal));
70    out
71}
72
73/// Genera un snippet KCL representando la regla cristalizada. El snippet usa
74/// la sintaxis tagged union del schema `rule.k` (Single + EventKind nested).
75pub fn crystal_to_kcl(c: &Crystal) -> String {
76    let id = Ulid::new();
77    format!(
78r#"# Auto-cristalizado:
79#   antecedent → consequent  |  P(c|a) = {cp:.3}, PMI = {pmi:.3} bits, support = {sup}
80Rule {{
81    id = "{id}"
82    priority = 5
83    when = EventPattern {{
84        type = "Single"
85        kind = EventKind {{tag = "{ant_tag}"{ant_extra}}}
86    }}
87    scope = Scope {{}}
88    then = [
89        Action {{
90            kind = "Log"
91            level = "info"
92            message = "crystal: {ant_tag} → {con_tag} (auto, P={cp:.2}, PMI={pmi:.2})"
93        }}
94    ]
95}}
96"#,
97        id = id,
98        cp = c.conditional_prob,
99        pmi = c.pmi,
100        sup = c.support,
101        ant_tag = kind_tag(&c.antecedent),
102        ant_extra = kind_extra(&c.antecedent),
103        con_tag = kind_tag(&c.consequent),
104    )
105}
106
107fn kind_tag(k: &EventKind) -> &'static str {
108    match k {
109        EventKind::EnteSpawned => "EnteSpawned",
110        EventKind::EnteDied => "EnteDied",
111        EventKind::BusAnnounce => "BusAnnounce",
112        EventKind::BusInvoke => "BusInvoke",
113        EventKind::BusInvokeOf(_) => "BusInvokeOf",
114        EventKind::DeviceAdded => "DeviceAdded",
115        EventKind::DeviceRemoved => "DeviceRemoved",
116        EventKind::Custom(_) => "Custom",
117    }
118}
119
120fn kind_extra(k: &EventKind) -> String {
121    match k {
122        EventKind::Custom(s) => format!(", custom = \"{}\"", s.replace('"', "\\\"")),
123        // Para BusInvokeOf el cap se omitiría por simplicidad; el snippet
124        // promovido es la versión "genérica BusInvoke" salvo que el operador
125        // edite manualmente.
126        _ => String::new(),
127    }
128}
129
130/// Convierte un cristal a una `Rule` ejecutable. Si hay gap_stats con
131/// muestras suficientes (≥ 4), genera una regla `Sequence` con
132/// `within_ms = (mean + 2σ) * 1000`. 2σ cubre ~95% de la distribución
133/// asumiendo normalidad — captura el "tiempo típico de respuesta" del
134/// patrón observado. Si no hay stats, fallback a `Single { antecedent }`.
135pub fn crystal_to_rule(c: &Crystal) -> Rule {
136    let when = match &c.gap_stats {
137        Some(s) if s.count >= 4 => {
138            // Mínimo 1ms para evitar within_ms=0 cuando varianza colapsa.
139            let bound_secs = (s.mean_secs + 2.0 * s.stddev_secs).max(0.001);
140            EventPattern::Sequence {
141                kinds: vec![c.antecedent.clone(), c.consequent.clone()],
142                within_ms: (bound_secs * 1000.0).ceil() as u64,
143            }
144        }
145        _ => EventPattern::Single { kind: c.antecedent.clone() },
146    };
147    let message = match &c.gap_stats {
148        Some(s) if s.count >= 4 => format!(
149            "crystal seq: {:?} → {:?} (P={:.2}, PMI={:.2}, gap={:.3}±{:.3}s)",
150            c.antecedent, c.consequent, c.conditional_prob, c.pmi,
151            s.mean_secs, s.stddev_secs,
152        ),
153        _ => format!(
154            "crystal: {:?} → {:?} (P={:.2}, PMI={:.2}, n={})",
155            c.antecedent, c.consequent, c.conditional_prob, c.pmi, c.support
156        ),
157    };
158    Rule {
159        id: Ulid::new(),
160        priority: 5,
161        when,
162        scope: Scope::default(),
163        then: vec![Action::Log { level: LogLevel::Info, message }],
164    }
165}
166
167
168// ============================================================================
169// Patrones extendidos: Burst (alta frecuencia) y Silence (ausencia prolongada).
170// Estos cristales son sobre un único kind, no pares — capturan dinámicas
171// temporales de eventos individuales.
172// ============================================================================
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub enum PatternCrystal {
176    /// Mismo evento aparece con frecuencia alta. `frequency_per_sec` se
177    /// estima sobre el window de observación.
178    Burst {
179        kind: EventKind,
180        count: u64,
181        frequency_per_sec: f64,
182    },
183    /// Evento que dejó de aparecer. `since_secs` es el tiempo desde la
184    /// última observación.
185    Silence {
186        kind: EventKind,
187        last_count: u64,
188        since_secs: f64,
189    },
190}
191
192#[derive(Debug, Clone, Copy)]
193pub struct PatternParams {
194    /// Mínimo de ocurrencias para considerar Burst.
195    pub burst_min_count: u64,
196    /// Frecuencia mínima (eventos por segundo) para considerar Burst.
197    pub burst_min_freq_hz: f64,
198    /// Tiempo desde última ocurrencia para considerar Silence.
199    pub silence_min_secs: f64,
200    /// Mínimo total previo para considerar Silence (eventos < N son ruido).
201    pub silence_min_prior_count: u64,
202}
203
204impl Default for PatternParams {
205    fn default() -> Self {
206        Self {
207            burst_min_count: 10,
208            burst_min_freq_hz: 5.0,
209            silence_min_secs: 30.0,
210            silence_min_prior_count: 3,
211        }
212    }
213}
214
215/// Detecta Bursts y Silences sobre la distribución marginal del observer.
216/// La frecuencia de un Burst se aproxima asumiendo que la observación cubre
217/// el rango entre `last_seen` y `Instant::now()` para ese kind.
218pub fn detect_pattern_crystals(obs: &Observer, params: &PatternParams) -> Vec<PatternCrystal> {
219    let mut out = Vec::new();
220    let now = Instant::now();
221    for (kind, &count) in obs.marginals() {
222        let last_seen = obs.last_seen_marginal(kind);
223        // ---- Burst ----
224        if count >= params.burst_min_count {
225            // Aproximación: si vimos `count` eventos hasta `last_seen`, y el
226            // primer evento sucedió en algún momento del window, la freq es
227            // count / window_age. Sin tiempo del primer evento, usamos
228            // last_seen → now como denominador (subestima freq) o asumimos
229            // ventana fija de 60s. Usamos la última como aproximación.
230            let elapsed = last_seen
231                .map(|t| now.saturating_duration_since(t).as_secs_f64().max(0.001))
232                .unwrap_or(60.0);
233            // Estimación conservadora: count / max(window_age, 1s).
234            // Si tenemos histograma, podríamos refinar — TODO.
235            let freq = count as f64 / elapsed.max(1.0);
236            if freq >= params.burst_min_freq_hz {
237                out.push(PatternCrystal::Burst {
238                    kind: kind.clone(),
239                    count,
240                    frequency_per_sec: freq,
241                });
242            }
243        }
244        // ---- Silence ----
245        if count >= params.silence_min_prior_count {
246            if let Some(t) = last_seen {
247                let since = now.saturating_duration_since(t).as_secs_f64();
248                if since >= params.silence_min_secs {
249                    out.push(PatternCrystal::Silence {
250                        kind: kind.clone(),
251                        last_count: count,
252                        since_secs: since,
253                    });
254                }
255            }
256        }
257    }
258    out
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::rules::EventKind::*;
265
266    #[test]
267    fn detects_perfect_correlation() {
268        let mut obs = Observer::new(100);
269        for _ in 0..10 {
270            obs.record(EnteSpawned);
271            obs.record(EnteDied);
272        }
273        let crystals = detect_crystals(&obs, &CrystallizationParams {
274            min_support: 3,
275            min_conditional_prob: 0.5,
276            min_pmi: 0.0,
277        });
278        assert!(crystals.iter().any(|c| matches!(c.antecedent, EnteSpawned)
279                                       && matches!(c.consequent, EnteDied)));
280    }
281
282    #[test]
283    fn rejects_below_threshold() {
284        let mut obs = Observer::new(100);
285        // Sin co-ocurrencia significativa.
286        for _ in 0..3 { obs.record(EnteSpawned); }
287        let crystals = detect_crystals(&obs, &CrystallizationParams::default());
288        assert!(crystals.is_empty(), "no debería haber cristales: {:?}", crystals);
289    }
290}