gioser

tawasuyu/arje

Rust 38TOML 12Markdown 1410 Definitions

⎇ ca75ba185f880d431eb118250d3931a91d354b2e crates/ente-brain/src/crystallize.rs Rust · 196 lines · 6.5 KB History Who touched what raw

Cristales temporales, replay, lease/renew y audit streaming ca75ba185 · 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 ulid::Ulid;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Crystal {
18    pub antecedent: EventKind,
19    pub consequent: EventKind,
20    pub conditional_prob: f64,
21    pub pmi: f64,
22    pub support: u64,
23    /// Estadísticas del gap temporal entre antecedent → consequent.
24    /// None si no hay histograma. Habilita generación de reglas Sequence
25    /// con `within_ms = (mean + 2σ) * 1000`.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub gap_stats: Option<GapStats>,
28}
29
30#[derive(Debug, Clone, Copy)]
31pub struct CrystallizationParams {
32    pub min_support: u64,
33    pub min_conditional_prob: f64,
34    pub min_pmi: f64,
35}
36
37impl Default for CrystallizationParams {
38    fn default() -> Self {
39        Self {
40            min_support: 5,
41            min_conditional_prob: 0.7,
42            min_pmi: 0.5,
43        }
44    }
45}
46
47pub fn detect_crystals(obs: &Observer, params: &CrystallizationParams) -> Vec<Crystal> {
48    let mut out = Vec::new();
49    for ((a, b), &count) in obs.cooccurrences() {
50        if count < params.min_support { continue; }
51        let cp = obs.conditional_prob(a, b);
52        if cp < params.min_conditional_prob { continue; }
53        let mi = obs.pmi(a, b);
54        if mi < params.min_pmi { continue; }
55        // Stats del histograma si existen para este par.
56        let gap_stats = obs.gap_histograms()
57            .get(&(a.clone(), b.clone()))
58            .map(|h| h.stats());
59        out.push(Crystal {
60            antecedent: a.clone(),
61            consequent: b.clone(),
62            conditional_prob: cp,
63            pmi: mi,
64            support: count,
65            gap_stats,
66        });
67    }
68    out.sort_by(|x, y| y.conditional_prob.partial_cmp(&x.conditional_prob).unwrap_or(std::cmp::Ordering::Equal));
69    out
70}
71
72/// Genera un snippet KCL representando la regla cristalizada. El snippet usa
73/// la sintaxis tagged union del schema `rule.k` (Single + EventKind nested).
74pub fn crystal_to_kcl(c: &Crystal) -> String {
75    let id = Ulid::new();
76    format!(
77r#"# Auto-cristalizado:
78#   antecedent → consequent  |  P(c|a) = {cp:.3}, PMI = {pmi:.3} bits, support = {sup}
79Rule {{
80    id = "{id}"
81    priority = 5
82    when = EventPattern {{
83        type = "Single"
84        kind = EventKind {{tag = "{ant_tag}"{ant_extra}}}
85    }}
86    scope = Scope {{}}
87    then = [
88        Action {{
89            kind = "Log"
90            level = "info"
91            message = "crystal: {ant_tag} → {con_tag} (auto, P={cp:.2}, PMI={pmi:.2})"
92        }}
93    ]
94}}
95"#,
96        id = id,
97        cp = c.conditional_prob,
98        pmi = c.pmi,
99        sup = c.support,
100        ant_tag = kind_tag(&c.antecedent),
101        ant_extra = kind_extra(&c.antecedent),
102        con_tag = kind_tag(&c.consequent),
103    )
104}
105
106fn kind_tag(k: &EventKind) -> &'static str {
107    match k {
108        EventKind::EnteSpawned => "EnteSpawned",
109        EventKind::EnteDied => "EnteDied",
110        EventKind::BusAnnounce => "BusAnnounce",
111        EventKind::BusInvoke => "BusInvoke",
112        EventKind::BusInvokeOf(_) => "BusInvokeOf",
113        EventKind::DeviceAdded => "DeviceAdded",
114        EventKind::DeviceRemoved => "DeviceRemoved",
115        EventKind::Custom(_) => "Custom",
116    }
117}
118
119fn kind_extra(k: &EventKind) -> String {
120    match k {
121        EventKind::Custom(s) => format!(", custom = \"{}\"", s.replace('"', "\\\"")),
122        // Para BusInvokeOf el cap se omitiría por simplicidad; el snippet
123        // promovido es la versión "genérica BusInvoke" salvo que el operador
124        // edite manualmente.
125        _ => String::new(),
126    }
127}
128
129/// Convierte un cristal a una `Rule` ejecutable. Si hay gap_stats con
130/// muestras suficientes (≥ 4), genera una regla `Sequence` con
131/// `within_ms = (mean + 2σ) * 1000`. 2σ cubre ~95% de la distribución
132/// asumiendo normalidad — captura el "tiempo típico de respuesta" del
133/// patrón observado. Si no hay stats, fallback a `Single { antecedent }`.
134pub fn crystal_to_rule(c: &Crystal) -> Rule {
135    let when = match &c.gap_stats {
136        Some(s) if s.count >= 4 => {
137            // Mínimo 1ms para evitar within_ms=0 cuando varianza colapsa.
138            let bound_secs = (s.mean_secs + 2.0 * s.stddev_secs).max(0.001);
139            EventPattern::Sequence {
140                kinds: vec![c.antecedent.clone(), c.consequent.clone()],
141                within_ms: (bound_secs * 1000.0).ceil() as u64,
142            }
143        }
144        _ => EventPattern::Single { kind: c.antecedent.clone() },
145    };
146    let message = match &c.gap_stats {
147        Some(s) if s.count >= 4 => format!(
148            "crystal seq: {:?} → {:?} (P={:.2}, PMI={:.2}, gap={:.3}±{:.3}s)",
149            c.antecedent, c.consequent, c.conditional_prob, c.pmi,
150            s.mean_secs, s.stddev_secs,
151        ),
152        _ => format!(
153            "crystal: {:?} → {:?} (P={:.2}, PMI={:.2}, n={})",
154            c.antecedent, c.consequent, c.conditional_prob, c.pmi, c.support
155        ),
156    };
157    Rule {
158        id: Ulid::new(),
159        priority: 5,
160        when,
161        scope: Scope::default(),
162        then: vec![Action::Log { level: LogLevel::Info, message }],
163    }
164}
165
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::rules::EventKind::*;
171
172    #[test]
173    fn detects_perfect_correlation() {
174        let mut obs = Observer::new(100);
175        for _ in 0..10 {
176            obs.record(EnteSpawned);
177            obs.record(EnteDied);
178        }
179        let crystals = detect_crystals(&obs, &CrystallizationParams {
180            min_support: 3,
181            min_conditional_prob: 0.5,
182            min_pmi: 0.0,
183        });
184        assert!(crystals.iter().any(|c| matches!(c.antecedent, EnteSpawned)
185                                       && matches!(c.consequent, EnteDied)));
186    }
187
188    #[test]
189    fn rejects_below_threshold() {
190        let mut obs = Observer::new(100);
191        // Sin co-ocurrencia significativa.
192        for _ in 0..3 { obs.record(EnteSpawned); }
193        let crystals = detect_crystals(&obs, &CrystallizationParams::default());
194        assert!(crystals.is_empty(), "no debería haber cristales: {:?}", crystals);
195    }
196}