⎇ f4eb7dd94404c2fadd8099c61ca11d2860bcff7a
crates/ente-brain/src/crystallize.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Capability quotas, CAS gc, patrones burst/silence, snapshot incremental
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}