gioser
⎇ d6b8f18b43c9bd9bf7268e48b61392a2d62bb229 crates/ente-brain/src/crystallize.rs Rust · 169 lines · 5.3 KB History Who touched what raw

Pausa: 11 crates del fractal Ente #0 con cerebro completo d6b8f18b4 · 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::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}
24
25#[derive(Debug, Clone, Copy)]
26pub struct CrystallizationParams {
27    pub min_support: u64,
28    pub min_conditional_prob: f64,
29    pub min_pmi: f64,
30}
31
32impl Default for CrystallizationParams {
33    fn default() -> Self {
34        Self {
35            min_support: 5,
36            min_conditional_prob: 0.7,
37            min_pmi: 0.5,
38        }
39    }
40}
41
42pub fn detect_crystals(obs: &Observer, params: &CrystallizationParams) -> Vec<Crystal> {
43    let mut out = Vec::new();
44    for ((a, b), &count) in obs.cooccurrences() {
45        if count < params.min_support { continue; }
46        let cp = obs.conditional_prob(a, b);
47        if cp < params.min_conditional_prob { continue; }
48        let mi = obs.pmi(a, b);
49        if mi < params.min_pmi { continue; }
50        out.push(Crystal {
51            antecedent: a.clone(),
52            consequent: b.clone(),
53            conditional_prob: cp,
54            pmi: mi,
55            support: count,
56        });
57    }
58    // Orden estable: por confianza descendente para fácil inspección.
59    out.sort_by(|x, y| y.conditional_prob.partial_cmp(&x.conditional_prob).unwrap_or(std::cmp::Ordering::Equal));
60    out
61}
62
63/// Genera un snippet KCL representando la regla cristalizada. El snippet usa
64/// la sintaxis tagged union del schema `rule.k` (Single + EventKind nested).
65pub fn crystal_to_kcl(c: &Crystal) -> String {
66    let id = Ulid::new();
67    format!(
68r#"# Auto-cristalizado:
69#   antecedent → consequent  |  P(c|a) = {cp:.3}, PMI = {pmi:.3} bits, support = {sup}
70Rule {{
71    id = "{id}"
72    priority = 5
73    when = EventPattern {{
74        type = "Single"
75        kind = EventKind {{tag = "{ant_tag}"{ant_extra}}}
76    }}
77    scope = Scope {{}}
78    then = [
79        Action {{
80            kind = "Log"
81            level = "info"
82            message = "crystal: {ant_tag} → {con_tag} (auto, P={cp:.2}, PMI={pmi:.2})"
83        }}
84    ]
85}}
86"#,
87        id = id,
88        cp = c.conditional_prob,
89        pmi = c.pmi,
90        sup = c.support,
91        ant_tag = kind_tag(&c.antecedent),
92        ant_extra = kind_extra(&c.antecedent),
93        con_tag = kind_tag(&c.consequent),
94    )
95}
96
97fn kind_tag(k: &EventKind) -> &'static str {
98    match k {
99        EventKind::EnteSpawned => "EnteSpawned",
100        EventKind::EnteDied => "EnteDied",
101        EventKind::BusAnnounce => "BusAnnounce",
102        EventKind::BusInvoke => "BusInvoke",
103        EventKind::BusInvokeOf(_) => "BusInvokeOf",
104        EventKind::DeviceAdded => "DeviceAdded",
105        EventKind::DeviceRemoved => "DeviceRemoved",
106        EventKind::Custom(_) => "Custom",
107    }
108}
109
110fn kind_extra(k: &EventKind) -> String {
111    match k {
112        EventKind::Custom(s) => format!(", custom = \"{}\"", s.replace('"', "\\\"")),
113        // Para BusInvokeOf el cap se omitiría por simplicidad; el snippet
114        // promovido es la versión "genérica BusInvoke" salvo que el operador
115        // edite manualmente.
116        _ => String::new(),
117    }
118}
119
120/// Convierte un cristal a una `Rule` ejecutable por el motor. Útil para
121/// "auto-aprendizaje" donde cristales se promueven a reglas vivas tras
122/// validar con el operador.
123pub fn crystal_to_rule(c: &Crystal) -> Rule {
124    Rule {
125        id: Ulid::new(),
126        priority: 5,
127        when: EventPattern::Single { kind: c.antecedent.clone() },
128        scope: Scope::default(),
129        then: vec![Action::Log {
130            level: LogLevel::Info,
131            message: format!(
132                "crystal: {:?} → {:?} (P={:.2}, PMI={:.2}, n={})",
133                c.antecedent, c.consequent, c.conditional_prob, c.pmi, c.support
134            ),
135        }],
136    }
137}
138
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::rules::EventKind::*;
144
145    #[test]
146    fn detects_perfect_correlation() {
147        let mut obs = Observer::new(100);
148        for _ in 0..10 {
149            obs.record(EnteSpawned);
150            obs.record(EnteDied);
151        }
152        let crystals = detect_crystals(&obs, &CrystallizationParams {
153            min_support: 3,
154            min_conditional_prob: 0.5,
155            min_pmi: 0.0,
156        });
157        assert!(crystals.iter().any(|c| matches!(c.antecedent, EnteSpawned)
158                                       && matches!(c.consequent, EnteDied)));
159    }
160
161    #[test]
162    fn rejects_below_threshold() {
163        let mut obs = Observer::new(100);
164        // Sin co-ocurrencia significativa.
165        for _ in 0..3 { obs.record(EnteSpawned); }
166        let crystals = detect_crystals(&obs, &CrystallizationParams::default());
167        assert!(crystals.is_empty(), "no debería haber cristales: {:?}", crystals);
168    }
169}