⎇ d6b8f18b43c9bd9bf7268e48b61392a2d62bb229
crates/ente-brain/src/crystallize.rs
History
Who touched what
raw
Pausa: 11 crates del fractal Ente #0 con cerebro completo
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}