⎇ ca75ba185f880d431eb118250d3931a91d354b2e
crates/ente-brain/src/crystallize.rs
History
Who touched what
raw
Cristales temporales, replay, lease/renew y audit streaming
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}