⎇ ca75ba185f880d431eb118250d3931a91d354b2e
crates/ente-brain/src/introspect.rs
Historia
Quién tocó qué
crudo
Cristales temporales, replay, lease/renew y audit streaming
1//! Introspect API. Unix Domain Socket + framing length-prefijo + bincode.
2//!
3//! Una herramienta externa (ej. `brainctl`) puede consultar el estado del
4//! cerebro sin tocar el bus interno del fractal. Esto separa observación de
5//! ejecución — la introspección es read-only por diseño.
6
7use crate::crystallize::{detect_crystals, Crystal, CrystallizationParams};
8use crate::engine::RuleEngine;
9use crate::observer::Observer;
10use crate::rules::Rule;
11use serde::{Deserialize, Serialize};
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16use tokio::net::{UnixListener, UnixStream};
17use tokio::sync::RwLock;
18use tracing::{debug, info, trace, warn};
19use ulid::Ulid;
20
21const MAX_FRAME: usize = 4 * 1024 * 1024; // 4 MiB — correlation matrices crecen
22
23/// Estado compartido entre el bucle del Init y el servidor de introspección.
24/// `Arc<RwLock<...>>` permite muchos lectores concurrentes (introspect) y
25/// un escritor (el dispatcher de eventos en el bucle primordial).
26#[derive(Clone)]
27pub struct BrainState {
28 pub engine: Arc<RwLock<RuleEngine>>,
29 pub observer: Arc<RwLock<Observer>>,
30 pub params: CrystallizationParams,
31 /// Path opcional donde apendear reglas promovidas como KCL. Si Some,
32 /// cada PromoteCrystal añade el snippet al archivo (append-only).
33 pub rules_out: Option<Arc<PathBuf>>,
34 /// Audit log en memoria. Cada promote/remove deja huella aquí.
35 pub audit: Arc<RwLock<crate::audit::AuditLog>>,
36}
37
38impl BrainState {
39 pub fn new(window_size: usize) -> Self {
40 Self::with_params(window_size, CrystallizationParams::default())
41 }
42
43 pub fn with_params(window_size: usize, params: CrystallizationParams) -> Self {
44 Self {
45 engine: Arc::new(RwLock::new(RuleEngine::empty())),
46 observer: Arc::new(RwLock::new(Observer::new(window_size))),
47 params,
48 rules_out: None,
49 audit: Arc::new(RwLock::new(crate::audit::AuditLog::new())),
50 }
51 }
52
53 pub fn with_rules_out(mut self, path: PathBuf) -> Self {
54 self.rules_out = Some(Arc::new(path));
55 self
56 }
57}
58
59/// Append-only writer del KCL snippet a `rules_out`. Crea el archivo con
60/// header si no existe; en caso contrario sólo apendea.
61pub fn append_kcl_snippet(path: &Path, snippet: &str) -> std::io::Result<()> {
62 if let Some(parent) = path.parent() {
63 std::fs::create_dir_all(parent)?;
64 }
65 let exists = path.exists();
66 let mut file = std::fs::OpenOptions::new()
67 .create(true)
68 .append(true)
69 .open(path)?;
70 if !exists {
71 writeln!(file, "# Reglas promovidas automáticamente desde cristales.")?;
72 writeln!(file, "# Cada bloque proviene de PromoteCrystal vía brainctl.")?;
73 writeln!(file)?;
74 }
75 writeln!(file, "{snippet}")?;
76 Ok(())
77}
78
79#[derive(Debug, Serialize, Deserialize)]
80pub enum IntrospectRequest {
81 /// Lista resumida de reglas vivas.
82 ListRules,
83 /// Detalle de una regla concreta.
84 GetRule(Ulid),
85 /// Snapshot de la entropía y conteos básicos.
86 EntropySnapshot,
87 /// Top N pares (a, b) por co-ocurrencia.
88 TopCorrelations { n: usize },
89 /// Cristales detectados con los parámetros del BrainState.
90 Crystals,
91 /// Genera el snippet KCL de un cristal específico (índice tras Crystals).
92 CrystalKcl { index: usize },
93 /// Promueve el cristal #index a regla viva en el motor. Devuelve el
94 /// rule_id asignado y el snippet KCL para auditoría/persistencia.
95 PromoteCrystal { index: usize },
96 /// Elimina una regla viva por id. Útil para revertir un promote.
97 RemoveRule { id: Ulid },
98 /// Lista las últimas N entradas del audit log. limit=0 = todas.
99 ListAudit { limit: usize },
100 /// Persiste todas las entries pendientes al CAS y actualiza el head
101 /// pointer si el log lo tiene configurado.
102 FlushAudit,
103 /// Recarga reglas desde el archivo configurado por --rules-out (o el
104 /// path provisto). Vacía el engine antes de cargar.
105 ReloadRules { path: Option<String> },
106 /// Verifica la cadena audit recorriendo prev_sha hasta el genesis,
107 /// validando integridad de cada entry contra el CAS.
108 VerifyAudit,
109 /// Reconstruye el engine desde la cadena audit. Vacía engine y aplica
110 /// PromoteCrystal/RemoveRule en orden cronológico.
111 ReplayAudit,
112 /// Mantiene la conexión abierta y empuja cada `AuditEntry` nuevo en
113 /// frames `IntrospectResponse::AuditStreamFrame` hasta que el cliente
114 /// cierra. Tras esta request no se aceptan más requests en la misma conn.
115 StreamAudit,
116}
117
118#[derive(Debug, Serialize, Deserialize)]
119pub enum IntrospectResponse {
120 Rules(Vec<RuleSummary>),
121 Rule(Option<Rule>),
122 Entropy { value_bits: f64, sample_size: u64, distinct_kinds: usize, window_full: bool },
123 Correlations(Vec<CorrelationEntry>),
124 Crystals(Vec<Crystal>),
125 Kcl(String),
126 /// Resultado de PromoteCrystal: id de la regla creada + snippet KCL para
127 /// que el operador lo persista en disco si quiere.
128 Promoted { rule_id: Ulid, kcl_snippet: String },
129 /// Resultado de RemoveRule: true si existía, false si ya no.
130 Removed(bool),
131 /// Entradas del audit log (más recientes al final).
132 AuditEntries(Vec<crate::audit::AuditEntry>),
133 /// Resultado de FlushAudit: cuántas entries se escribieron y SHA del head.
134 Flushed { written: usize, head_sha: Option<[u8; 32]>, total_flushed: u64 },
135 /// Resultado de ReloadRules: número total de reglas tras el reload.
136 Reloaded { count: usize },
137 /// Resultado de VerifyAudit.
138 AuditVerified(crate::audit::VerificationReport),
139 /// Resultado de ReplayAudit.
140 Replayed(crate::audit::ReplayReport),
141 /// Frame de streaming. El cliente lee estos en bucle hasta EOF.
142 AuditStreamFrame(crate::audit::AuditEntry),
143 Error(String),
144}
145
146#[derive(Debug, Serialize, Deserialize)]
147pub struct RuleSummary {
148 pub id: Ulid,
149 pub priority: u8,
150 pub event_kind_tag: String,
151 pub action_count: usize,
152 pub scope_wildcard: bool,
153}
154
155#[derive(Debug, Serialize, Deserialize)]
156pub struct CorrelationEntry {
157 pub a: String,
158 pub b: String,
159 pub joint_count: u64,
160 pub conditional_prob: f64,
161 pub pmi_bits: f64,
162}
163
164pub struct IntrospectServer {
165 state: BrainState,
166}
167
168impl IntrospectServer {
169 pub fn new(state: BrainState) -> Self { Self { state } }
170
171 /// Spawn del listener. Devuelve cuando bind() falla; en caso contrario
172 /// corre indefinidamente.
173 pub async fn serve(self, path: &Path) -> anyhow::Result<()> {
174 let _ = std::fs::remove_file(path);
175 if let Some(parent) = path.parent() {
176 let _ = std::fs::create_dir_all(parent);
177 }
178 let listener = UnixListener::bind(path)?;
179 info!(path = %path.display(), "brain introspect escuchando");
180 let arc_self = Arc::new(self);
181 loop {
182 match listener.accept().await {
183 Ok((stream, _)) => {
184 trace!("introspect conn aceptada");
185 let me = arc_self.clone();
186 tokio::spawn(async move {
187 if let Err(e) = me.handle(stream).await {
188 warn!(?e, "introspect conn ended");
189 }
190 });
191 }
192 Err(e) => {
193 warn!(?e, "introspect accept failed");
194 return Ok(());
195 }
196 }
197 }
198 }
199
200 async fn handle(self: Arc<Self>, mut stream: UnixStream) -> anyhow::Result<()> {
201 loop {
202 let mut len_buf = [0u8; 4];
203 if stream.read_exact(&mut len_buf).await.is_err() {
204 return Ok(()); // EOF
205 }
206 let len = u32::from_be_bytes(len_buf) as usize;
207 if len > MAX_FRAME {
208 anyhow::bail!("frame oversize: {len}");
209 }
210 let mut buf = vec![0u8; len];
211 stream.read_exact(&mut buf).await?;
212 let req: IntrospectRequest = bincode::deserialize(&buf)?;
213 debug!(?req, "introspect request");
214
215 // StreamAudit toma posesión de la conn — no más requests aquí.
216 if matches!(req, IntrospectRequest::StreamAudit) {
217 return self.stream_audit(stream).await;
218 }
219
220 let resp = self.dispatch(req).await;
221
222 let out = bincode::serialize(&resp)?;
223 stream.write_u32(out.len() as u32).await?;
224 stream.write_all(&out).await?;
225 }
226 }
227
228 /// Modo streaming: subscribe al audit log y empuja cada entry como
229 /// frame `AuditStreamFrame`. La función retorna cuando el cliente
230 /// cierra (write falla) o el subscriber se desconecta.
231 async fn stream_audit(self: Arc<Self>, mut stream: UnixStream) -> anyhow::Result<()> {
232 let mut rx = self.state.audit.write().await.subscribe();
233 info!("audit stream client conectado");
234 while let Some(entry) = rx.recv().await {
235 let frame = IntrospectResponse::AuditStreamFrame(entry);
236 let bytes = bincode::serialize(&frame)?;
237 if stream.write_u32(bytes.len() as u32).await.is_err() { break; }
238 if stream.write_all(&bytes).await.is_err() { break; }
239 }
240 info!("audit stream client desconectado");
241 Ok(())
242 }
243
244 async fn dispatch(&self, req: IntrospectRequest) -> IntrospectResponse {
245 match req {
246 IntrospectRequest::ListRules => {
247 let engine = self.state.engine.read().await;
248 let rules = engine.rules()
249 .map(|r| RuleSummary {
250 id: r.id,
251 priority: r.priority,
252 event_kind_tag: format!("{:?}", r.when),
253 action_count: r.then.len(),
254 scope_wildcard: r.scope.is_wildcard(),
255 })
256 .collect();
257 IntrospectResponse::Rules(rules)
258 }
259 IntrospectRequest::GetRule(id) => {
260 let engine = self.state.engine.read().await;
261 let found = engine.rules()
262 .find(|r| r.id == id)
263 .map(|r| Rule::clone(r));
264 IntrospectResponse::Rule(found)
265 }
266 IntrospectRequest::EntropySnapshot => {
267 let obs = self.state.observer.read().await;
268 IntrospectResponse::Entropy {
269 value_bits: obs.shannon_entropy(),
270 sample_size: obs.total(),
271 distinct_kinds: obs.marginals().len(),
272 window_full: obs.current_window() >= obs.window_size(),
273 }
274 }
275 IntrospectRequest::TopCorrelations { n } => {
276 let obs = self.state.observer.read().await;
277 let mut entries: Vec<CorrelationEntry> = obs.cooccurrences().iter()
278 .map(|((a, b), &joint)| CorrelationEntry {
279 a: format!("{:?}", a),
280 b: format!("{:?}", b),
281 joint_count: joint,
282 conditional_prob: obs.conditional_prob(a, b),
283 pmi_bits: obs.pmi(a, b),
284 })
285 .collect();
286 entries.sort_by(|x, y| y.joint_count.cmp(&x.joint_count));
287 entries.truncate(n);
288 IntrospectResponse::Correlations(entries)
289 }
290 IntrospectRequest::Crystals => {
291 let obs = self.state.observer.read().await;
292 let crystals = detect_crystals(&obs, &self.state.params);
293 IntrospectResponse::Crystals(crystals)
294 }
295 IntrospectRequest::CrystalKcl { index } => {
296 let obs = self.state.observer.read().await;
297 let crystals = detect_crystals(&obs, &self.state.params);
298 match crystals.get(index) {
299 Some(c) => IntrospectResponse::Kcl(crate::crystallize::crystal_to_kcl(c)),
300 None => IntrospectResponse::Error(format!("no crystal at index {index}")),
301 }
302 }
303 IntrospectRequest::PromoteCrystal { index } => {
304 let crystals = {
305 let obs = self.state.observer.read().await;
306 detect_crystals(&obs, &self.state.params)
307 };
308 match crystals.get(index) {
309 Some(c) => {
310 let rule = crate::crystallize::crystal_to_rule(c);
311 let snippet = crate::crystallize::crystal_to_kcl(c);
312 let rule_id = rule.id;
313 self.state.engine.write().await.insert(rule);
314 // Persistencia opcional al archivo KCL.
315 if let Some(path) = self.state.rules_out.as_ref() {
316 if let Err(e) = append_kcl_snippet(path, &snippet) {
317 warn!(?e, path = %path.display(), "rules_out append falló");
318 } else {
319 info!(path = %path.display(), %rule_id, "regla persistida a .k");
320 }
321 }
322 // Audit entry
323 self.state.audit.write().await.append(
324 crate::audit::AuditAction::PromoteCrystal {
325 rule_id, crystal: c.clone(),
326 }
327 );
328 IntrospectResponse::Promoted { rule_id, kcl_snippet: snippet }
329 }
330 None => IntrospectResponse::Error(format!("no crystal at index {index}")),
331 }
332 }
333 IntrospectRequest::RemoveRule { id } => {
334 let removed = self.state.engine.write().await.remove(id);
335 if removed {
336 self.state.audit.write().await.append(
337 crate::audit::AuditAction::RemoveRule { rule_id: id }
338 );
339 }
340 IntrospectResponse::Removed(removed)
341 }
342 IntrospectRequest::ListAudit { limit } => {
343 let audit = self.state.audit.read().await;
344 IntrospectResponse::AuditEntries(audit.recent(limit).cloned().collect())
345 }
346 IntrospectRequest::FlushAudit => {
347 let mut audit = self.state.audit.write().await;
348 match audit.flush_to_cas() {
349 Ok(written) => IntrospectResponse::Flushed {
350 written,
351 head_sha: audit.last_flushed_sha(),
352 total_flushed: audit.flushed_count(),
353 },
354 Err(e) => IntrospectResponse::Error(format!("flush_to_cas: {e}")),
355 }
356 }
357 IntrospectRequest::VerifyAudit => {
358 let head = self.state.audit.read().await.last_flushed_sha();
359 let head = match head {
360 Some(h) => h,
361 None => return IntrospectResponse::Error(
362 "audit log sin entries flushadas — nada que verificar".into()
363 ),
364 };
365 let report = crate::audit::verify_chain_from_cas(head);
366 IntrospectResponse::AuditVerified(report)
367 }
368 IntrospectRequest::StreamAudit => {
369 // Inalcanzable por construcción: handle() detecta StreamAudit
370 // antes de llamar a dispatch(). Pero el match exige cubrir.
371 IntrospectResponse::Error(
372 "StreamAudit no debe llegar a dispatch — bug del handler".into()
373 )
374 }
375 IntrospectRequest::ReplayAudit => {
376 let head = self.state.audit.read().await.last_flushed_sha();
377 let head = match head {
378 Some(h) => h,
379 None => return IntrospectResponse::Error(
380 "audit log sin entries flushadas — nada que replayar".into()
381 ),
382 };
383 let mut engine = self.state.engine.write().await;
384 *engine = crate::engine::RuleEngine::empty();
385 let report = crate::audit::replay_chain(head, &mut engine);
386 IntrospectResponse::Replayed(report)
387 }
388 IntrospectRequest::ReloadRules { path } => {
389 // Path explícito gana sobre el rules_out configurado.
390 let resolved = path.map(std::path::PathBuf::from)
391 .or_else(|| self.state.rules_out.as_ref().map(|p| p.as_path().to_path_buf()));
392 let path = match resolved {
393 Some(p) => p,
394 None => return IntrospectResponse::Error(
395 "ReloadRules sin path y sin rules_out configurado".into()
396 ),
397 };
398 let rules = match crate::kcl_loader::load_rules_file(&path) {
399 Ok(r) => r,
400 Err(e) => return IntrospectResponse::Error(format!("load: {e}")),
401 };
402 // Vaciamos el engine antes de re-cargar — semántica clean-slate.
403 let mut engine = self.state.engine.write().await;
404 *engine = crate::engine::RuleEngine::empty();
405 let count = rules.len();
406 for r in rules { engine.insert(r); }
407 drop(engine);
408 self.state.audit.write().await.append(
409 crate::audit::AuditAction::LoadRulesFile {
410 path: path.to_string_lossy().into_owned(),
411 count,
412 }
413 );
414 IntrospectResponse::Reloaded { count }
415 }
416 }
417 }
418}
419
420// Cliente helper para tools externos (brainctl).
421pub async fn call(path: &Path, req: IntrospectRequest) -> anyhow::Result<IntrospectResponse> {
422 let mut stream = UnixStream::connect(path).await?;
423 let buf = bincode::serialize(&req)?;
424 stream.write_u32(buf.len() as u32).await?;
425 stream.write_all(&buf).await?;
426
427 let mut len_buf = [0u8; 4];
428 stream.read_exact(&mut len_buf).await?;
429 let len = u32::from_be_bytes(len_buf) as usize;
430 if len > MAX_FRAME {
431 anyhow::bail!("response oversize: {len}");
432 }
433 let mut buf = vec![0u8; len];
434 stream.read_exact(&mut buf).await?;
435 Ok(bincode::deserialize(&buf)?)
436}
437
438/// Consume la lista marginal del observer para humanos. Suprime el detalle
439/// crudo de `EventKind` (ej. payloads largos en BusInvokeOf).
440pub fn marginal_summary(obs: &Observer) -> Vec<(String, u64)> {
441 let mut entries: Vec<(String, u64)> = obs.marginals().iter()
442 .map(|(k, &c)| (format!("{:?}", k), c))
443 .collect();
444 entries.sort_by(|x, y| y.1.cmp(&x.1));
445 entries
446}
447