prueba
This commit is contained in:
@@ -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 <n> | crystals | crystal-kcl <i> | promote <i> | remove <ulid> | audit <limit> | flush-audit | reload [path]");
|
||||
eprintln!("válidos: list-rules | entropy | top <n> | crystals | crystal-json <i> | promote <i> | remove <ulid> | audit <limit> | 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"); }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Cr
|
||||
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(),
|
||||
}
|
||||
/// 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
|
||||
|
||||
@@ -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<Self> {
|
||||
let rules: Vec<Rule> = serde_json::from_str(json)?;
|
||||
let mut engine = Self::empty();
|
||||
|
||||
@@ -28,8 +28,8 @@ pub struct BrainState {
|
||||
pub engine: Arc<RwLock<RuleEngine>>,
|
||||
pub observer: Arc<RwLock<Observer>>,
|
||||
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<Arc<PathBuf>>,
|
||||
/// Audit log en memoria. Cada promote/remove deja huella aquí.
|
||||
pub audit: Arc<RwLock<crate::audit::AuditLog>>,
|
||||
@@ -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<CorrelationEntry>),
|
||||
Crystals(Vec<Crystal>),
|
||||
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(|_| "<serialize failed>".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}")),
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -53,19 +53,47 @@ pub fn load_rules_file(path: &Path) -> anyhow::Result<Vec<Rule>> {
|
||||
extract_rules_from_json(&raw)
|
||||
}
|
||||
|
||||
/// Extrae un `Vec<Rule>` de JSON que puede ser:
|
||||
/// 1. Array directo: `[{...}, {...}]`
|
||||
/// 2. Object con un campo array: `{"rules": [...]}`
|
||||
/// Extrae un `Vec<Rule>` 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<Vec<Rule>> {
|
||||
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<Rule> = 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::<serde_json::Value>(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)
|
||||
}
|
||||
|
||||
@@ -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<u8>,
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user