From ebfdf4317080203efe0ac13e2a3bbb9a4c866378 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 7 May 2026 01:04:12 +0000 Subject: [PATCH] prueba --- crates/ente-brain/examples/brainctl.rs | 16 +++--- crates/ente-brain/src/autopromote.rs | 10 ++-- crates/ente-brain/src/crystallize.rs | 68 +++++--------------------- crates/ente-brain/src/engine.rs | 2 +- crates/ente-brain/src/introspect.rs | 55 +++++++++++---------- crates/ente-brain/src/lib.rs | 2 +- crates/ente-brain/src/loader.rs | 54 +++++++++++++++----- crates/ente-brain/src/rules.rs | 10 ++-- crates/ente-zero/src/main.rs | 2 +- 9 files changed, 102 insertions(+), 117 deletions(-) diff --git a/crates/ente-brain/examples/brainctl.rs b/crates/ente-brain/examples/brainctl.rs index b781e8e..5b051b1 100644 --- a/crates/ente-brain/examples/brainctl.rs +++ b/crates/ente-brain/examples/brainctl.rs @@ -5,7 +5,7 @@ //! cargo run --example brainctl -p ente-brain -- entropy //! cargo run --example brainctl -p ente-brain -- top 10 //! cargo run --example brainctl -p ente-brain -- crystals -//! cargo run --example brainctl -p ente-brain -- crystal-kcl 0 +//! cargo run --example brainctl -p ente-brain -- crystal-json 0 //! //! Path del socket: $ENTE_BRAIN_SOCK o $XDG_RUNTIME_DIR/ente-brain.sock @@ -42,9 +42,9 @@ async fn main() -> anyhow::Result<()> { IntrospectRequest::TopCorrelations { n } } "crystals" => IntrospectRequest::Crystals, - "crystal-kcl" => { + "crystal-json" => { let i: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); - IntrospectRequest::CrystalKcl { index: i } + IntrospectRequest::CrystalJson { index: i } } "promote" => { let i: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); @@ -70,7 +70,7 @@ async fn main() -> anyhow::Result<()> { } other => { eprintln!("subcomando desconocido: {other}"); - eprintln!("válidos: list-rules | entropy | top | crystals | crystal-kcl | promote | remove | audit | flush-audit | reload [path]"); + eprintln!("válidos: list-rules | entropy | top | crystals | crystal-json | promote | remove | audit | flush-audit | reload [path]"); std::process::exit(2); } }; @@ -114,11 +114,11 @@ fn print_response(r: &IntrospectResponse) { c.antecedent, c.consequent, c.conditional_prob, c.pmi, c.support); } } - IntrospectResponse::Kcl(s) => println!("{s}"), - IntrospectResponse::Promoted { rule_id, kcl_snippet } => { + IntrospectResponse::Json(s) => println!("{s}"), + IntrospectResponse::Promoted { rule_id, rule_json } => { println!("regla creada: {rule_id}"); - println!("--- KCL para auditoría / persistencia ---"); - println!("{kcl_snippet}"); + println!("--- JSON para auditoría / persistencia ---"); + println!("{rule_json}"); } IntrospectResponse::Removed(was_present) => { if *was_present { println!("regla eliminada"); } diff --git a/crates/ente-brain/src/autopromote.rs b/crates/ente-brain/src/autopromote.rs index bd028fb..a54929e 100644 --- a/crates/ente-brain/src/autopromote.rs +++ b/crates/ente-brain/src/autopromote.rs @@ -7,8 +7,8 @@ //! evita ráfagas de duplicados de la misma estadística). use crate::audit::AuditAction; -use crate::crystallize::{crystal_to_kcl, crystal_to_rule, detect_crystals, Crystal, CrystallizationParams}; -use crate::introspect::{append_kcl_snippet, BrainState}; +use crate::crystallize::{crystal_to_rule, detect_crystals, Crystal, CrystallizationParams}; +use crate::introspect::{append_rule_jsonl, BrainState}; use crate::rules::EventKind; use std::collections::HashSet; use std::sync::Arc; @@ -79,14 +79,14 @@ async fn run_one_pass( async fn promote_one(state: &BrainState, c: &Crystal) { let rule = crystal_to_rule(c); - let snippet = crystal_to_kcl(c); let rule_id = rule.id; - state.engine.write().await.insert(rule); if let Some(path) = state.rules_out.as_ref() { - if let Err(e) = append_kcl_snippet(path, &snippet) { + if let Err(e) = append_rule_jsonl(path, &rule) { warn!(?e, "autopromote: rules_out append falló"); } } + state.engine.write().await.insert(rule); + state.audit.write().await.append(AuditAction::PromoteCrystal { rule_id, crystal: c.clone(), diff --git a/crates/ente-brain/src/crystallize.rs b/crates/ente-brain/src/crystallize.rs index 660ed4c..6a90660 100644 --- a/crates/ente-brain/src/crystallize.rs +++ b/crates/ente-brain/src/crystallize.rs @@ -5,8 +5,9 @@ //! - 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. +//! Cada cristal se materializa como `Rule` ejecutable (`crystal_to_rule`). +//! Para persistencia/transporte, `crystal_to_json_pretty` serializa la Rule +//! resultante con serde — sin formatos intermedios. use crate::observer::{GapStats, Observer}; use crate::rules::{Action, EventKind, EventPattern, LogLevel, Rule, Scope}; @@ -70,61 +71,14 @@ pub fn detect_crystals(obs: &Observer, params: &CrystallizationParams) -> Vec 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(), - } +/// Serializa la `Rule` derivada del cristal como JSON pretty-printed. Ese +/// JSON es el formato canónico de persistencia: el loader lo lee como una +/// línea de JSONL o como elemento de un array. Los stats del cristal (P, PMI, +/// support) viven en el audit log vía `AuditAction::PromoteCrystal`, no se +/// duplican aquí. +pub fn crystal_to_json_pretty(c: &Crystal) -> String { + serde_json::to_string_pretty(&crystal_to_rule(c)) + .expect("Rule serialize should never fail") } /// Convierte un cristal a una `Rule` ejecutable. Si hay gap_stats con diff --git a/crates/ente-brain/src/engine.rs b/crates/ente-brain/src/engine.rs index 358ef6c..e70ca0f 100644 --- a/crates/ente-brain/src/engine.rs +++ b/crates/ente-brain/src/engine.rs @@ -71,7 +71,7 @@ impl RuleEngine { Self { rules: Vec::new(), by_kind: HashMap::new(), compound: Vec::new() } } - /// Carga reglas desde JSON (lista de Rule). Usado tras validación KCL. + /// Carga reglas desde JSON (lista de Rule). pub fn load_json(json: &str) -> anyhow::Result { let rules: Vec = serde_json::from_str(json)?; let mut engine = Self::empty(); diff --git a/crates/ente-brain/src/introspect.rs b/crates/ente-brain/src/introspect.rs index 2590a12..77972d0 100644 --- a/crates/ente-brain/src/introspect.rs +++ b/crates/ente-brain/src/introspect.rs @@ -28,8 +28,8 @@ pub struct BrainState { pub engine: Arc>, pub observer: Arc>, pub params: CrystallizationParams, - /// Path opcional donde apendear reglas promovidas como KCL. Si Some, - /// cada PromoteCrystal añade el snippet al archivo (append-only). + /// Path opcional donde apendear reglas promovidas en JSONL. Si Some, + /// cada PromoteCrystal añade una línea (append-only) con la Rule serializada. pub rules_out: Option>, /// Audit log en memoria. Cada promote/remove deja huella aquí. pub audit: Arc>, @@ -56,23 +56,22 @@ impl BrainState { } } -/// Append-only writer del KCL snippet a `rules_out`. Crea el archivo con -/// header si no existe; en caso contrario sólo apendea. -pub fn append_kcl_snippet(path: &Path, snippet: &str) -> std::io::Result<()> { +/// Append-only writer de una `Rule` serializada a `rules_out` en formato +/// JSONL: una línea = un Rule JSON. Idempotente respecto a re-flushes +/// porque el caller se encarga de no apendar la misma rule dos veces. +/// El loader (`loader::extract_rules_from_json`) acepta tanto JSONL como +/// arrays — el archivo es legible en ambos modos. +pub fn append_rule_jsonl(path: &Path, rule: &Rule) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let exists = path.exists(); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(path)?; - if !exists { - writeln!(file, "# Reglas promovidas automáticamente desde cristales.")?; - writeln!(file, "# Cada bloque proviene de PromoteCrystal vía brainctl.")?; - writeln!(file)?; - } - writeln!(file, "{snippet}")?; + let line = serde_json::to_string(rule) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + writeln!(file, "{line}")?; Ok(()) } @@ -88,10 +87,11 @@ pub enum IntrospectRequest { TopCorrelations { n: usize }, /// Cristales detectados con los parámetros del BrainState. Crystals, - /// Genera el snippet KCL de un cristal específico (índice tras Crystals). - CrystalKcl { index: usize }, + /// Serializa la Rule derivada de un cristal específico como JSON + /// (índice tras Crystals). + CrystalJson { index: usize }, /// Promueve el cristal #index a regla viva en el motor. Devuelve el - /// rule_id asignado y el snippet KCL para auditoría/persistencia. + /// rule_id asignado y el JSON de la Rule para auditoría/persistencia. PromoteCrystal { index: usize }, /// Elimina una regla viva por id. Útil para revertir un promote. RemoveRule { id: Ulid }, @@ -128,10 +128,10 @@ pub enum IntrospectResponse { Entropy { value_bits: f64, sample_size: u64, distinct_kinds: usize, window_full: bool }, Correlations(Vec), Crystals(Vec), - Kcl(String), - /// Resultado de PromoteCrystal: id de la regla creada + snippet KCL para - /// que el operador lo persista en disco si quiere. - Promoted { rule_id: Ulid, kcl_snippet: String }, + Json(String), + /// Resultado de PromoteCrystal: id de la regla creada + JSON de la Rule + /// para que el operador lo persista en disco si quiere. + Promoted { rule_id: Ulid, rule_json: String }, /// Resultado de RemoveRule: true si existía, false si ya no. Removed(bool), /// Entradas del audit log (más recientes al final). @@ -302,11 +302,11 @@ impl IntrospectServer { let crystals = detect_crystals(&obs, &self.state.params); IntrospectResponse::Crystals(crystals) } - IntrospectRequest::CrystalKcl { index } => { + IntrospectRequest::CrystalJson { index } => { let obs = self.state.observer.read().await; let crystals = detect_crystals(&obs, &self.state.params); match crystals.get(index) { - Some(c) => IntrospectResponse::Kcl(crate::crystallize::crystal_to_kcl(c)), + Some(c) => IntrospectResponse::Json(crate::crystallize::crystal_to_json_pretty(c)), None => IntrospectResponse::Error(format!("no crystal at index {index}")), } } @@ -318,15 +318,16 @@ impl IntrospectServer { match crystals.get(index) { Some(c) => { let rule = crate::crystallize::crystal_to_rule(c); - let snippet = crate::crystallize::crystal_to_kcl(c); let rule_id = rule.id; - self.state.engine.write().await.insert(rule); - // Persistencia opcional al archivo KCL. + let rule_json = serde_json::to_string_pretty(&rule) + .unwrap_or_else(|_| "".into()); + self.state.engine.write().await.insert(rule.clone()); + // Persistencia opcional al archivo JSONL. if let Some(path) = self.state.rules_out.as_ref() { - if let Err(e) = append_kcl_snippet(path, &snippet) { + if let Err(e) = append_rule_jsonl(path, &rule) { warn!(?e, path = %path.display(), "rules_out append falló"); } else { - info!(path = %path.display(), %rule_id, "regla persistida a .k"); + info!(path = %path.display(), %rule_id, "regla persistida a JSONL"); } } // Audit entry @@ -335,7 +336,7 @@ impl IntrospectServer { rule_id, crystal: c.clone(), } ); - IntrospectResponse::Promoted { rule_id, kcl_snippet: snippet } + IntrospectResponse::Promoted { rule_id, rule_json } } None => IntrospectResponse::Error(format!("no crystal at index {index}")), } diff --git a/crates/ente-brain/src/lib.rs b/crates/ente-brain/src/lib.rs index d9d3447..49ac4eb 100644 --- a/crates/ente-brain/src/lib.rs +++ b/crates/ente-brain/src/lib.rs @@ -8,7 +8,7 @@ //! 4. `observer` — sliding window + marginales + co-ocurrencias //! + Shannon entropy + información mutua //! 5. `crystallize` — detección de patrones estadísticamente significativos -//! y generación de snippets KCL +//! y materialización en `Rule` ejecutables //! 6. `introspect` — Unix socket bincode API para tools externos //! //! Diseño de inmutabilidad: diff --git a/crates/ente-brain/src/loader.rs b/crates/ente-brain/src/loader.rs index afe3f0c..93a9a50 100644 --- a/crates/ente-brain/src/loader.rs +++ b/crates/ente-brain/src/loader.rs @@ -53,19 +53,47 @@ pub fn load_rules_file(path: &Path) -> anyhow::Result> { extract_rules_from_json(&raw) } -/// Extrae un `Vec` de JSON que puede ser: -/// 1. Array directo: `[{...}, {...}]` -/// 2. Object con un campo array: `{"rules": [...]}` +/// Extrae un `Vec` de un blob de texto. Acepta tres formas: +/// 1. JSONL: una `Rule` por línea (el formato que escribe `append_rule_jsonl`). +/// 2. Array directo: `[{...}, {...}]`. +/// 3. Object con un campo array: `{"rules": [...]}`. +/// +/// Heurística: si el primer carácter no-blanco es `[` o `{` con formato +/// "objeto-con-array", parseamos como JSON único; en otro caso intentamos +/// línea-por-línea. Líneas vacías o que empiecen con `#` se ignoran (compat +/// con archivos editados a mano que dejen comentarios estilo shell). pub fn extract_rules_from_json(raw: &str) -> anyhow::Result> { - let v: serde_json::Value = serde_json::from_str(raw)?; - let arr = match v { - serde_json::Value::Array(_) => v, - serde_json::Value::Object(map) => map - .into_values() - .find(|x| x.is_array()) - .ok_or_else(|| anyhow::anyhow!("JSON no contiene ningún array"))?, - _ => anyhow::bail!("JSON debe ser array o object con campo array"), - }; - let rules: Vec = serde_json::from_value(arr)?; + let trimmed_start = raw.trim_start(); + let looks_jsonl = trimmed_start.starts_with('{') + && raw.lines().filter(|l| { + let t = l.trim(); + !t.is_empty() && !t.starts_with('#') + }).count() > 1; + + if !looks_jsonl { + // Camino clásico: un único documento JSON (array o objeto). + if let Ok(v) = serde_json::from_str::(raw) { + let arr = match v { + serde_json::Value::Array(_) => v, + serde_json::Value::Object(map) => map + .into_values() + .find(|x| x.is_array()) + .ok_or_else(|| anyhow::anyhow!("JSON no contiene ningún array"))?, + _ => anyhow::bail!("JSON debe ser array o object con campo array"), + }; + return Ok(serde_json::from_value(arr)?); + } + // Caer a JSONL si el documento único no parsea — útil para archivos + // que mezclan comentarios `#` (no JSON válido como documento único). + } + + let mut rules = Vec::new(); + for (idx, line) in raw.lines().enumerate() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { continue; } + let rule: Rule = serde_json::from_str(t) + .map_err(|e| anyhow::anyhow!("JSONL línea {}: {e}", idx + 1))?; + rules.push(rule); + } Ok(rules) } diff --git a/crates/ente-brain/src/rules.rs b/crates/ente-brain/src/rules.rs index e9c7835..8c17a27 100644 --- a/crates/ente-brain/src/rules.rs +++ b/crates/ente-brain/src/rules.rs @@ -1,7 +1,9 @@ -//! Tipos de regla. Equivalente Rust de `schema/rule.k`. +//! Tipos de regla. La fuente de verdad del shape es esta definición Rust; +//! `schema/rule.k` queda como referencia de diseño no cargada. //! -//! Cargables desde JSON (que KCL produce tras validación). El motor no acepta -//! Rules construidas a mano sin pasar por validate() — ver `engine::insert`. +//! Cargables desde JSON (array, objeto-con-array, o JSONL). El motor no +//! acepta Rules construidas a mano sin pasar por validate() — ver +//! `engine::insert`. use ente_card::Capability; use serde::{Deserialize, Serialize}; @@ -178,7 +180,7 @@ pub enum Action { }, Invoke { target_cap: Capability, - /// blob crudo (base64 si viene de KCL, bytes ya en Rust). + /// blob crudo (en JSON viaja como base64 vía `blob_b64`). #[serde(with = "blob_b64")] blob: Vec, }, diff --git a/crates/ente-zero/src/main.rs b/crates/ente-zero/src/main.rs index de641b3..eac4e46 100644 --- a/crates/ente-zero/src/main.rs +++ b/crates/ente-zero/src/main.rs @@ -207,7 +207,7 @@ async fn primordial_loop( spawn_audit_auto_flush(brain.clone()); } - // Carga inicial de reglas vía KCL o JSON, si --rules path proporcionado. + // Carga inicial de reglas desde JSON/JSONL si --rules path proporcionado. if let Some(path) = &rules_path { match ente_brain::load_rules_file(path) { Ok(rules) => {