refactor(brain): A2 — split arje-brain en 3 sub-crates
DAG de dependencias limpio (modularidad horizontal):
- arje-brain-rules — rules + engine + dispatch (motor determinista)
- arje-brain-cognitive — observer + crystallize (estadística)
- arje-brain-audit — audit chain → CAS (accountability)
- arje-brain — umbrella de integración (introspect +
autopromote + metrics + loader)
Habilitador clave: TimedEvent movido de observer.rs a rules.rs
(engine lo necesitaba, era el único acoplo que rompía el DAG).
arje-brain re-exporta la API de los 3 sub-crates: arje-zero y chasqui
(consumidores) no requieren cambios. cargo check --workspace verde.
24 tests del brain pasan (4 rules + 6 cognitive + 5 audit + 9 umbrella).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
//! 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 (array, objeto-con-array, o JSONL). El motor no
|
||||
//! acepta Rules construidas a mano sin pasar por validate() — ver
|
||||
//! `engine::insert`.
|
||||
|
||||
use arje_card::Capability;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
use ulid::Ulid;
|
||||
|
||||
/// Evento timestamped. El timestamp se conserva para futuras políticas de
|
||||
/// expiración por tiempo (no sólo por count). Tipo base compartido entre
|
||||
/// el motor de reglas (`engine`) y el observador estadístico (`cognitive`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimedEvent {
|
||||
pub kind: EventKind,
|
||||
pub at: Instant,
|
||||
}
|
||||
|
||||
/// Triplet [Sujeto + Evento + Acción]. Inmutable tras carga.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Rule {
|
||||
pub id: Ulid,
|
||||
#[serde(default = "default_priority")]
|
||||
pub priority: u8,
|
||||
pub when: EventPattern,
|
||||
pub then: Vec<Action>,
|
||||
#[serde(default)]
|
||||
pub scope: Scope,
|
||||
}
|
||||
|
||||
fn default_priority() -> u8 { 5 }
|
||||
|
||||
impl Rule {
|
||||
pub fn validate(&self) -> Result<(), RuleError> {
|
||||
if self.then.is_empty() {
|
||||
return Err(RuleError::EmptyActions);
|
||||
}
|
||||
self.when.validate_recursive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RuleError {
|
||||
EmptyActions,
|
||||
EmptySequence,
|
||||
EmptyCompound,
|
||||
InvalidPriority,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuleError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::EmptyActions => write!(f, "regla sin acciones"),
|
||||
Self::EmptySequence => write!(f, "Sequence pattern con kinds vacío"),
|
||||
Self::EmptyCompound => write!(f, "Either/All con patterns vacío"),
|
||||
Self::InvalidPriority => write!(f, "prioridad fuera de rango"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RuleError {}
|
||||
|
||||
/// Match del sujeto. Vacío en todos los campos = match cualquier Ente.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Scope {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject_id: Option<Ulid>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject_label: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject_has_cap: Option<Capability>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
pub fn is_wildcard(&self) -> bool {
|
||||
self.subject_id.is_none()
|
||||
&& self.subject_label.is_none()
|
||||
&& self.subject_has_cap.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Patrón de evento que dispara una regla. Tagged union — JSON con campo
|
||||
/// `type`. Soporta composición recursiva (Either/All) sobre Single y
|
||||
/// Sequence atómicos.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum EventPattern {
|
||||
/// Match un único evento por kind.
|
||||
Single { kind: EventKind },
|
||||
/// Match si los últimos N eventos del history coinciden en orden con
|
||||
/// `kinds`, todos dentro de `within_ms` (0 = sin límite temporal).
|
||||
Sequence {
|
||||
kinds: Vec<EventKind>,
|
||||
#[serde(default)]
|
||||
within_ms: u64,
|
||||
},
|
||||
/// OR: match si cualquier sub-pattern matchea.
|
||||
Either { patterns: Vec<EventPattern> },
|
||||
/// AND: match si todos los sub-patterns matchean simultáneamente
|
||||
/// contra el mismo (event, history).
|
||||
All { patterns: Vec<EventPattern> },
|
||||
}
|
||||
|
||||
impl EventPattern {
|
||||
/// `true` si el pattern es atómico (no recursivo) y puede ser indexado
|
||||
/// por discriminante de `EventKind` para dispatch O(1). Compuestos
|
||||
/// (Either/All) se evalúan en un bucket de fallback.
|
||||
pub fn is_simple(&self) -> bool {
|
||||
matches!(self, Self::Single { .. } | Self::Sequence { .. })
|
||||
}
|
||||
|
||||
/// Última `EventKind` que dispara la evaluación de un pattern atómico.
|
||||
/// Devuelve None para compuestos.
|
||||
pub fn trigger_kind(&self) -> Option<&EventKind> {
|
||||
match self {
|
||||
Self::Single { kind } => Some(kind),
|
||||
Self::Sequence { kinds, .. } => kinds.last(),
|
||||
Self::Either { .. } | Self::All { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validación recursiva. Compuestos vacíos se rechazan al cargar.
|
||||
pub fn validate_recursive(&self) -> Result<(), RuleError> {
|
||||
match self {
|
||||
Self::Single { .. } => Ok(()),
|
||||
Self::Sequence { kinds, .. } => {
|
||||
if kinds.is_empty() { Err(RuleError::EmptySequence) } else { Ok(()) }
|
||||
}
|
||||
Self::Either { patterns } | Self::All { patterns } => {
|
||||
if patterns.is_empty() {
|
||||
return Err(RuleError::EmptyCompound);
|
||||
}
|
||||
for p in patterns { p.validate_recursive()?; }
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tipo de evento que dispara reglas. Indexado por discriminante en el motor.
|
||||
/// Diseñado para que `EventKindDiscriminant::from(&kind)` sea barato (no hash
|
||||
/// del payload, sólo del tag).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub enum EventKind {
|
||||
EnteSpawned,
|
||||
EnteDied,
|
||||
BusAnnounce,
|
||||
BusInvoke,
|
||||
BusInvokeOf(Capability),
|
||||
DeviceAdded,
|
||||
DeviceRemoved,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LogLevel { Trace, Debug, Info, Warn, Error }
|
||||
|
||||
impl LogLevel {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Trace => "trace",
|
||||
Self::Debug => "debug",
|
||||
Self::Info => "info",
|
||||
Self::Warn => "warn",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "PascalCase")]
|
||||
pub enum Action {
|
||||
Log {
|
||||
#[serde(default = "default_log_level")]
|
||||
level: LogLevel,
|
||||
message: String,
|
||||
},
|
||||
Notify {
|
||||
target_id: Ulid,
|
||||
message: String,
|
||||
},
|
||||
/// `card_blob` es JSON del EntityCard codificado en base64. El motor lo
|
||||
/// decodifica y forwarda como SpawnRequest al graph.
|
||||
Spawn {
|
||||
card_blob: String,
|
||||
},
|
||||
Invoke {
|
||||
target_cap: Capability,
|
||||
/// blob crudo (en JSON viaja como base64 vía `blob_b64`).
|
||||
#[serde(with = "blob_b64")]
|
||||
blob: Vec<u8>,
|
||||
},
|
||||
Inhibit {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_log_level() -> LogLevel { LogLevel::Info }
|
||||
|
||||
mod blob_b64 {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
STANDARD.decode(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user