d6b8f18b43
PID 1 boot + bus interno autenticado + cerebro KCL/Rust: - 6 lib crates de infra (card, bus, cas, kernel, soma, wasm, snapshot) - ente-brain: motor de reglas O(1), observer Shannon, cristalización, audit hash-chain, persistencia rules.k, Prometheus /metrics - KCL schemas card.k + rule.k como gramática autoritativa - compat-logind D-Bus, ente-echo demo provider, ente-zero PID 1 - 22 tests OK, ~3.8k LOC Rust + ~300 LOC KCL Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
170 lines
5.3 KiB
Rust
170 lines
5.3 KiB
Rust
//! Cristalización: del flujo observado a reglas explícitas.
|
|
//!
|
|
//! Detecta pares (a, b) donde:
|
|
//! - support(a, b) ≥ min_support (suficientes muestras para no ser ruido)
|
|
//! - P(b|a) ≥ min_conditional_prob (a predice b con confianza)
|
|
//! - PMI(a; b) ≥ min_pmi (más correlacionados que random)
|
|
//!
|
|
//! Cada cristal puede emitirse como snippet KCL (texto humano-readable) o
|
|
//! como `Rule` ejecutable directamente por el motor.
|
|
|
|
use crate::observer::Observer;
|
|
use crate::rules::{Action, EventKind, EventPattern, LogLevel, Rule, Scope};
|
|
use serde::{Deserialize, Serialize};
|
|
use ulid::Ulid;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Crystal {
|
|
pub antecedent: EventKind,
|
|
pub consequent: EventKind,
|
|
pub conditional_prob: f64,
|
|
pub pmi: f64,
|
|
pub support: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct CrystallizationParams {
|
|
pub min_support: u64,
|
|
pub min_conditional_prob: f64,
|
|
pub min_pmi: f64,
|
|
}
|
|
|
|
impl Default for CrystallizationParams {
|
|
fn default() -> Self {
|
|
Self {
|
|
min_support: 5,
|
|
min_conditional_prob: 0.7,
|
|
min_pmi: 0.5,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn detect_crystals(obs: &Observer, params: &CrystallizationParams) -> Vec<Crystal> {
|
|
let mut out = Vec::new();
|
|
for ((a, b), &count) in obs.cooccurrences() {
|
|
if count < params.min_support { continue; }
|
|
let cp = obs.conditional_prob(a, b);
|
|
if cp < params.min_conditional_prob { continue; }
|
|
let mi = obs.pmi(a, b);
|
|
if mi < params.min_pmi { continue; }
|
|
out.push(Crystal {
|
|
antecedent: a.clone(),
|
|
consequent: b.clone(),
|
|
conditional_prob: cp,
|
|
pmi: mi,
|
|
support: count,
|
|
});
|
|
}
|
|
// Orden estable: por confianza descendente para fácil inspección.
|
|
out.sort_by(|x, y| y.conditional_prob.partial_cmp(&x.conditional_prob).unwrap_or(std::cmp::Ordering::Equal));
|
|
out
|
|
}
|
|
|
|
/// Genera un snippet KCL representando la regla cristalizada. El snippet usa
|
|
/// la sintaxis tagged union del schema `rule.k` (Single + EventKind nested).
|
|
pub fn crystal_to_kcl(c: &Crystal) -> String {
|
|
let id = Ulid::new();
|
|
format!(
|
|
r#"# Auto-cristalizado:
|
|
# antecedent → consequent | P(c|a) = {cp:.3}, PMI = {pmi:.3} bits, support = {sup}
|
|
Rule {{
|
|
id = "{id}"
|
|
priority = 5
|
|
when = EventPattern {{
|
|
type = "Single"
|
|
kind = EventKind {{tag = "{ant_tag}"{ant_extra}}}
|
|
}}
|
|
scope = Scope {{}}
|
|
then = [
|
|
Action {{
|
|
kind = "Log"
|
|
level = "info"
|
|
message = "crystal: {ant_tag} → {con_tag} (auto, P={cp:.2}, PMI={pmi:.2})"
|
|
}}
|
|
]
|
|
}}
|
|
"#,
|
|
id = id,
|
|
cp = c.conditional_prob,
|
|
pmi = c.pmi,
|
|
sup = c.support,
|
|
ant_tag = kind_tag(&c.antecedent),
|
|
ant_extra = kind_extra(&c.antecedent),
|
|
con_tag = kind_tag(&c.consequent),
|
|
)
|
|
}
|
|
|
|
fn kind_tag(k: &EventKind) -> &'static str {
|
|
match k {
|
|
EventKind::EnteSpawned => "EnteSpawned",
|
|
EventKind::EnteDied => "EnteDied",
|
|
EventKind::BusAnnounce => "BusAnnounce",
|
|
EventKind::BusInvoke => "BusInvoke",
|
|
EventKind::BusInvokeOf(_) => "BusInvokeOf",
|
|
EventKind::DeviceAdded => "DeviceAdded",
|
|
EventKind::DeviceRemoved => "DeviceRemoved",
|
|
EventKind::Custom(_) => "Custom",
|
|
}
|
|
}
|
|
|
|
fn kind_extra(k: &EventKind) -> String {
|
|
match k {
|
|
EventKind::Custom(s) => format!(", custom = \"{}\"", s.replace('"', "\\\"")),
|
|
// Para BusInvokeOf el cap se omitiría por simplicidad; el snippet
|
|
// promovido es la versión "genérica BusInvoke" salvo que el operador
|
|
// edite manualmente.
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
/// Convierte un cristal a una `Rule` ejecutable por el motor. Útil para
|
|
/// "auto-aprendizaje" donde cristales se promueven a reglas vivas tras
|
|
/// validar con el operador.
|
|
pub fn crystal_to_rule(c: &Crystal) -> Rule {
|
|
Rule {
|
|
id: Ulid::new(),
|
|
priority: 5,
|
|
when: EventPattern::Single { kind: c.antecedent.clone() },
|
|
scope: Scope::default(),
|
|
then: vec![Action::Log {
|
|
level: LogLevel::Info,
|
|
message: format!(
|
|
"crystal: {:?} → {:?} (P={:.2}, PMI={:.2}, n={})",
|
|
c.antecedent, c.consequent, c.conditional_prob, c.pmi, c.support
|
|
),
|
|
}],
|
|
}
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::rules::EventKind::*;
|
|
|
|
#[test]
|
|
fn detects_perfect_correlation() {
|
|
let mut obs = Observer::new(100);
|
|
for _ in 0..10 {
|
|
obs.record(EnteSpawned);
|
|
obs.record(EnteDied);
|
|
}
|
|
let crystals = detect_crystals(&obs, &CrystallizationParams {
|
|
min_support: 3,
|
|
min_conditional_prob: 0.5,
|
|
min_pmi: 0.0,
|
|
});
|
|
assert!(crystals.iter().any(|c| matches!(c.antecedent, EnteSpawned)
|
|
&& matches!(c.consequent, EnteDied)));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_below_threshold() {
|
|
let mut obs = Observer::new(100);
|
|
// Sin co-ocurrencia significativa.
|
|
for _ in 0..3 { obs.record(EnteSpawned); }
|
|
let crystals = detect_crystals(&obs, &CrystallizationParams::default());
|
|
assert!(crystals.is_empty(), "no debería haber cristales: {:?}", crystals);
|
|
}
|
|
}
|