refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG
Reorganización física de crates/: - core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/ - shared/ (3 crates) se redistribuye en protocol/ e init/ - lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/ Renames de proyectos: - shipote → shuma (runtime de sandboxes) - nouser → akasha (explorador de Mónadas) - yahweh → nahual (motor GPUI, antes ui_engine/) - lapaloma → pineal (data-viz agnóstica) Fraccionamiento UI → core agnóstico: - vista-core (DeckState + snap, 175 LOC, 5 tests verdes) - barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes) - vista-web y barra-web ahora son thin DOM bindings Documentación nueva: - 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat + 10 módulos + apps/ - docs/STATUS.md con cifras reales por proyecto - docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas) - CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets) Automatización: - scripts/reorg.py — script idempotente que: git mv directorios, renombra package names, recomputa path = refs, reescribe imports rust, actualiza workspace Cargo.toml. Soporta --dry-run. - scripts/split-changelog.py — particiona CHANGELOG por componente. Validación: - cargo check --workspace pasa (124 crates + 2 nuevos cores). - 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# modules/akasha/ — Explorador semántico de Mónadas (era nouser)
|
||||
|
||||
**Propósito.** Daemon que descubre y consulta "Mónadas" (unidades
|
||||
semánticas auto-descriptivas) vía broker brahman. Provee embeddings
|
||||
locales (mock o real LLM) y un protocolo `Nous` line-delimited.
|
||||
|
||||
## Crates
|
||||
|
||||
| crate | tipo | rol |
|
||||
| --------------- | ----- | --------------------------------------------------------- |
|
||||
| `akasha-card` | lib | Definición Card del daemon + capabilities |
|
||||
| `akasha-core` | bin | Daemon: scanner FS + DB sled + cluster por embedding |
|
||||
| `akasha-nous` | lib | Protocolo Nous (JSON line-delimited) |
|
||||
| `akasha-nous-mock` | bin | Proveedor de embeddings deterministas (testing) |
|
||||
| `akasha-nous-real` | bin | Proveedor con fastembed/ort (LLM real) |
|
||||
|
||||
## Dependencias
|
||||
|
||||
- `akasha-core` ← `protocol/brahman-card`, `protocol/brahman-sidecar`,
|
||||
`akasha-card`, `akasha-nous`, `shuma-discern`.
|
||||
- `akasha-nous-real` ← `fastembed` + `ort` (heavy; profile opt-level=1
|
||||
en root Cargo.toml).
|
||||
- Consumido por: `apps/akasha-explorer` (GPUI dashboard).
|
||||
|
||||
## Estado
|
||||
|
||||
LOC 4,395. Pipeline de scan + embed + cluster funcional. Pendiente:
|
||||
cobertura de tests sobre el cluster engine (k-means actual es naive).
|
||||
Ver `docs/changelog/akasha.md`.
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "akasha-card"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Nouser — manifiesto de Mónada (agrupación semántica de archivos). Espejo de brahman-card pero para datos."
|
||||
|
||||
[dependencies]
|
||||
brahman-card = { path = "../../../protocol/brahman-card" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
@@ -0,0 +1,431 @@
|
||||
//! `akasha-card` — manifiesto de Mónada.
|
||||
//!
|
||||
//! Una **Mónada** es una agrupación semántica de archivos: el archivo
|
||||
//! físico no se mueve, pero su pertenencia se modela por un objeto
|
||||
//! ([`MonadManifest`]) con identidad propia, métricas y un "lente" de
|
||||
//! visualización. La idea hereda el espíritu de la Tarjeta de
|
||||
//! Presentación de Brahman (`brahman-card::Card`): un manifiesto
|
||||
//! tipado, validado y serializable que define qué es la entidad y
|
||||
//! cómo el sistema debe interactuar con ella.
|
||||
//!
|
||||
//! Diferencia con `brahman-card::Card`:
|
||||
//!
|
||||
//! | brahman::Card | akasha::MonadManifest |
|
||||
//! |-------------------------------------|-------------------------------|
|
||||
//! | Describe una **entidad runtime** | Describe una **agrupación** |
|
||||
//! | Tiene `payload`/`soma`/`supervision`| No tiene proceso detrás |
|
||||
//! | Vive durante una sesión | Vive en una DB persistente |
|
||||
//! | Fluye por handshake/postcard | Fluye por queries del backend |
|
||||
//!
|
||||
//! Este crate sólo define los tipos. La lógica de scan, cluster,
|
||||
//! attraction vive en `akasha-core`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use ulid::Ulid;
|
||||
|
||||
// Re-export para consumidores
|
||||
pub use ::ulid;
|
||||
|
||||
pub mod query;
|
||||
|
||||
/// Versión del esquema del manifiesto. Bump al cambiar el schema.
|
||||
pub const MONAD_SCHEMA_VERSION: u16 = 1;
|
||||
|
||||
/// Identificador opaco de un archivo registrado en la DB.
|
||||
pub type FileId = Ulid;
|
||||
|
||||
/// Identificador opaco de una Mónada.
|
||||
pub type MonadId = Ulid;
|
||||
|
||||
// =====================================================================
|
||||
// FileEntry — el archivo como dato indexado
|
||||
// =====================================================================
|
||||
|
||||
/// Registro físico de un archivo en la DB. Es la unidad atómica que
|
||||
/// pertenece a (potencialmente varias) Mónadas.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileEntry {
|
||||
pub id: FileId,
|
||||
pub path: PathBuf,
|
||||
/// Hash de contenido (blake3) — sólo se computa si el archivo es
|
||||
/// chico o el usuario lo pidió. `None` por default en Phase 0.
|
||||
#[serde(default)]
|
||||
pub content_hash: Option<[u8; 32]>,
|
||||
/// Tamaño en bytes.
|
||||
pub size: u64,
|
||||
/// `mtime` como ms desde UNIX_EPOCH.
|
||||
pub mtime_ms: u64,
|
||||
/// Extensión normalizada en lowercase, sin punto. `None` si no tiene.
|
||||
#[serde(default)]
|
||||
pub extension: Option<String>,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Lens — la "vista" preferida de una Mónada
|
||||
// =====================================================================
|
||||
|
||||
/// Lente de visualización dominante. La UI (nahual) elige cómo renderizar
|
||||
/// los miembros de una Mónada según este hint.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Lens {
|
||||
/// Grid genérico: thumbnail + nombre + meta.
|
||||
#[default]
|
||||
Grid,
|
||||
/// Editor de código con highlighting (rs, py, ts, ...).
|
||||
Code,
|
||||
/// Galería de imágenes (png, jpg, svg, ...).
|
||||
Gallery,
|
||||
/// Vista tabular (csv, sqlite, ...).
|
||||
Database,
|
||||
/// Texto renderizado (md, rst, txt).
|
||||
Markdown,
|
||||
/// Árbol jerárquico (cuando la Mónada es estructural).
|
||||
Tree,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// MonadManifest — la Tarjeta de Presentación de la Mónada
|
||||
// =====================================================================
|
||||
|
||||
/// Manifiesto de una Mónada. Equivalente conceptual a la Tarjeta de
|
||||
/// Presentación de Brahman, pero para una agrupación de datos.
|
||||
///
|
||||
/// Se serializa a JSON/TOML para persistencia y debugging; es el
|
||||
/// "ADN" que la UI lee para saber cómo presentar la Mónada sin tocar
|
||||
/// el disco.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MonadManifest {
|
||||
/// Versión del esquema. Bump = romper compatibilidad de DB.
|
||||
pub schema_version: u16,
|
||||
|
||||
/// Identificador opaco. ULID — orderable por tiempo de creación.
|
||||
pub id: MonadId,
|
||||
|
||||
/// Mónada de la que ésta fue derivada (split, merge), si aplica.
|
||||
#[serde(default)]
|
||||
pub lineage: Option<MonadId>,
|
||||
|
||||
/// Nombre humano corto (1-4 palabras, generado por reglas o por Nous).
|
||||
pub label: String,
|
||||
|
||||
/// Resumen de propósito (1-2 oraciones). Generado por Nous cuando
|
||||
/// la masa de la Mónada justifica la consulta.
|
||||
#[serde(default)]
|
||||
pub summary: String,
|
||||
|
||||
/// Centroide vectorial (embedding promedio de los miembros). Vacío
|
||||
/// en Phase 0 (sin embeddings); se llena cuando entran las
|
||||
/// pseudo-embeddings o el modelo real.
|
||||
#[serde(default)]
|
||||
pub centroid: Vec<f32>,
|
||||
|
||||
/// Identificador del modelo que produjo `centroid`. Si está set, los
|
||||
/// consumidores deben verificar coincidencia antes de comparar vía
|
||||
/// cosine similarity con embeddings recientes; al cambiar de modelo
|
||||
/// (mock-pseudo-32d → real-fastembed-384d, etc.) los centroides
|
||||
/// previos quedan inválidos por dimensión y semántica.
|
||||
/// `None` = legacy (centroides sin tag, pre-versioning).
|
||||
#[serde(default)]
|
||||
pub centroid_model: Option<String>,
|
||||
|
||||
/// Identidad estable derivada del origen de los miembros. Para
|
||||
/// Mónadas creadas por `cluster::by_directory`, es el path
|
||||
/// canónico del directorio padre. Permite que la hidratación
|
||||
/// reuse el mismo ULID across re-scans (mismo path_hint = misma
|
||||
/// identidad, aunque cambien los miembros internamente).
|
||||
/// `None` para Mónadas creadas por estrategias que no se anclan a
|
||||
/// un origen físico.
|
||||
#[serde(default)]
|
||||
pub path_hint: Option<String>,
|
||||
|
||||
/// Tokens dominantes: extensiones, palabras clave, etc.
|
||||
/// 5-10 elementos típicamente.
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
|
||||
/// Cantidad de miembros (== `members.len()`). Cacheado para evitar
|
||||
/// el cost de leer la lista cada vez.
|
||||
pub cardinality: u32,
|
||||
|
||||
/// Métrica de dispersión interna [0.0, 1.0]:
|
||||
/// - 0.0: todos los miembros son muy similares (Mónada coherente).
|
||||
/// - 1.0: miembros muy heterogéneos (sugerencia: bifurcar).
|
||||
///
|
||||
/// Calculada como entropía de Shannon normalizada sobre las
|
||||
/// extensiones de los miembros.
|
||||
#[serde(default)]
|
||||
pub entropy: f32,
|
||||
|
||||
/// Lente preferido para visualización en la UI.
|
||||
#[serde(default)]
|
||||
pub dominant_lens: Lens,
|
||||
|
||||
/// Archivos anclados manualmente: NO se mueven en re-clustering
|
||||
/// automático. El usuario "fija" estos miembros.
|
||||
#[serde(default)]
|
||||
pub pins: BTreeSet<FileId>,
|
||||
|
||||
/// IDs de archivos miembros (incluye pins).
|
||||
pub members: BTreeSet<FileId>,
|
||||
|
||||
/// Unix ms de creación de la Mónada.
|
||||
pub created_at_ms: u64,
|
||||
|
||||
/// Unix ms de la última actualización (re-cluster, re-name, ...).
|
||||
pub updated_at_ms: u64,
|
||||
|
||||
/// Forward-compat: campos JSON desconocidos preservados.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extensions: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Errores y validación
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MonadError {
|
||||
#[error("schema mismatch: got {got}, expected {expected}")]
|
||||
SchemaMismatch { got: u16, expected: u16 },
|
||||
#[error("label vacío")]
|
||||
EmptyLabel,
|
||||
#[error("label demasiado largo: {0} bytes (max 256)")]
|
||||
LabelTooLong(usize),
|
||||
#[error("entropía fuera de [0,1]: {0}")]
|
||||
InvalidEntropy(f32),
|
||||
#[error("Monad sin miembros y sin pins")]
|
||||
Empty,
|
||||
#[error("cardinalidad declarada {declared} ≠ members.len() {actual}")]
|
||||
CardinalityMismatch { declared: u32, actual: u32 },
|
||||
#[error("JSON inválido: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl MonadManifest {
|
||||
/// Constructor con defaults razonables. `id` y timestamps se
|
||||
/// generan; resto vacío.
|
||||
pub fn new(label: impl Into<String>) -> Self {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
Self {
|
||||
schema_version: MONAD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
lineage: None,
|
||||
label: label.into(),
|
||||
summary: String::new(),
|
||||
centroid: Vec::new(),
|
||||
centroid_model: None,
|
||||
path_hint: None,
|
||||
keywords: Vec::new(),
|
||||
cardinality: 0,
|
||||
entropy: 0.0,
|
||||
dominant_lens: Lens::default(),
|
||||
pins: BTreeSet::new(),
|
||||
members: BTreeSet::new(),
|
||||
created_at_ms: now_ms,
|
||||
updated_at_ms: now_ms,
|
||||
extensions: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validación semántica.
|
||||
pub fn validate(&self) -> Result<(), MonadError> {
|
||||
if self.schema_version != MONAD_SCHEMA_VERSION {
|
||||
return Err(MonadError::SchemaMismatch {
|
||||
got: self.schema_version,
|
||||
expected: MONAD_SCHEMA_VERSION,
|
||||
});
|
||||
}
|
||||
if self.label.trim().is_empty() {
|
||||
return Err(MonadError::EmptyLabel);
|
||||
}
|
||||
if self.label.len() > 256 {
|
||||
return Err(MonadError::LabelTooLong(self.label.len()));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&self.entropy) {
|
||||
return Err(MonadError::InvalidEntropy(self.entropy));
|
||||
}
|
||||
if self.members.is_empty() && self.pins.is_empty() {
|
||||
return Err(MonadError::Empty);
|
||||
}
|
||||
let actual = self.members.len() as u32;
|
||||
if self.cardinality != actual {
|
||||
return Err(MonadError::CardinalityMismatch {
|
||||
declared: self.cardinality,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serializa a JSON pretty.
|
||||
pub fn to_json_pretty(&self) -> Result<String, MonadError> {
|
||||
Ok(serde_json::to_string_pretty(self)?)
|
||||
}
|
||||
|
||||
/// Deserializa desde JSON y valida.
|
||||
pub fn from_json(src: &str) -> Result<Self, MonadError> {
|
||||
let m: Self = serde_json::from_str(src)?;
|
||||
m.validate()?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Recalcula `cardinality` y `updated_at_ms` desde `members`.
|
||||
/// Usar tras mutaciones del set de miembros.
|
||||
pub fn touch(&mut self) {
|
||||
self.cardinality = self.members.len() as u32;
|
||||
self.updated_at_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
|
||||
/// Proyecta el `MonadManifest` a la `brahman_card::Card` que viaja
|
||||
/// por el protocolo. La Card resultante:
|
||||
///
|
||||
/// - hereda `id` y `label` del manifiesto (ULID estable).
|
||||
/// - `kind = CardKind::Data` (se distingue de un Ente).
|
||||
/// - `payload = Virtual`, `supervision = Delegate`,
|
||||
/// `lifecycle = Daemon` — placeholder semántico: la Mónada no se
|
||||
/// "ejecuta", el daemon dueño la mantiene viva.
|
||||
/// - `data = Some(DataFacet { ... })` con summary, keywords,
|
||||
/// centroide, member_count, dispersión y un hint de presentación
|
||||
/// derivado del `dominant_lens`.
|
||||
/// - Los miembros completos NO viajan en la Card — se consultan al
|
||||
/// daemon dueño bajo demanda. Lo que viaja es metadata liviana
|
||||
/// apta para el wire postcard.
|
||||
pub fn to_brahman_card(&self) -> brahman_card::Card {
|
||||
use brahman_card::{
|
||||
Card, CardKind, DataFacet, Lifecycle, Payload, Priority, Supervision,
|
||||
};
|
||||
|
||||
let presentation_hint = match self.dominant_lens {
|
||||
Lens::Grid => "grid",
|
||||
Lens::Code => "code",
|
||||
Lens::Gallery => "gallery",
|
||||
Lens::Database => "database",
|
||||
Lens::Markdown => "markdown",
|
||||
Lens::Tree => "tree",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
Card {
|
||||
schema_version: brahman_card::CARD_SCHEMA_VERSION,
|
||||
id: self.id,
|
||||
label: self.label.clone(),
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::Delegate,
|
||||
lifecycle: Lifecycle::Daemon,
|
||||
priority: Priority::Normal,
|
||||
kind: CardKind::Data,
|
||||
data: Some(DataFacet {
|
||||
summary: self.summary.clone(),
|
||||
keywords: self.keywords.clone(),
|
||||
centroid: self.centroid.clone(),
|
||||
member_count: self.cardinality,
|
||||
dispersion: self.entropy,
|
||||
presentation_hint,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_minimal() {
|
||||
let mut m = MonadManifest::new("test");
|
||||
m.members.insert(Ulid::new());
|
||||
m.touch();
|
||||
m.validate().expect("debe validar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_label_rejected() {
|
||||
let mut m = MonadManifest::new("x");
|
||||
m.label = String::new();
|
||||
m.members.insert(Ulid::new());
|
||||
m.touch();
|
||||
assert!(matches!(m.validate(), Err(MonadError::EmptyLabel)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_out_of_range_rejected() {
|
||||
let mut m = MonadManifest::new("x");
|
||||
m.members.insert(Ulid::new());
|
||||
m.entropy = 1.5;
|
||||
m.touch();
|
||||
assert!(matches!(m.validate(), Err(MonadError::InvalidEntropy(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_members_rejected() {
|
||||
let m = MonadManifest::new("x");
|
||||
assert!(matches!(m.validate(), Err(MonadError::Empty)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinality_mismatch_caught() {
|
||||
let mut m = MonadManifest::new("x");
|
||||
m.members.insert(Ulid::new());
|
||||
// No llamamos touch — cardinality queda en 0 con 1 miembro.
|
||||
assert!(matches!(
|
||||
m.validate(),
|
||||
Err(MonadError::CardinalityMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_to_brahman_card() {
|
||||
let mut m = MonadManifest::new("test-monad");
|
||||
m.summary = "monad de prueba".into();
|
||||
m.keywords = vec!["rs".into(), "toml".into()];
|
||||
m.dominant_lens = Lens::Code;
|
||||
m.entropy = 0.42;
|
||||
m.members.insert(Ulid::new());
|
||||
m.members.insert(Ulid::new());
|
||||
m.members.insert(Ulid::new());
|
||||
m.touch();
|
||||
|
||||
let bc = m.to_brahman_card();
|
||||
assert_eq!(bc.id, m.id);
|
||||
assert_eq!(bc.label, "test-monad");
|
||||
assert_eq!(bc.kind, brahman_card::CardKind::Data);
|
||||
let data = bc.data.expect("data facet presente");
|
||||
assert_eq!(data.summary, "monad de prueba");
|
||||
assert_eq!(data.keywords, vec!["rs".to_string(), "toml".to_string()]);
|
||||
assert_eq!(data.member_count, 3);
|
||||
assert!((data.dispersion - 0.42).abs() < 1e-6);
|
||||
assert_eq!(data.presentation_hint, "code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip() {
|
||||
let mut m = MonadManifest::new("test-monad");
|
||||
m.members.insert(Ulid::new());
|
||||
m.members.insert(Ulid::new());
|
||||
m.keywords = vec!["rs".into(), "toml".into()];
|
||||
m.summary = "test summary".into();
|
||||
m.dominant_lens = Lens::Code;
|
||||
m.touch();
|
||||
let s = m.to_json_pretty().unwrap();
|
||||
let m2 = MonadManifest::from_json(&s).unwrap();
|
||||
assert_eq!(m2.label, m.label);
|
||||
assert_eq!(m2.cardinality, 2);
|
||||
assert_eq!(m2.dominant_lens, Lens::Code);
|
||||
assert_eq!(m2.keywords, m.keywords);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Wire types para consultar al daemon `akasha` por sus Mónadas.
|
||||
//!
|
||||
//! El daemon expone un Unix socket (cuyo path se publica en
|
||||
//! `Card.service_socket` y se descubre vía broker MatchEvent). Cada
|
||||
//! conexión es single-shot: una request JSON terminada en `\n`,
|
||||
//! una response JSON terminada en `\n`, cierre.
|
||||
//!
|
||||
//! Mismo patrón que `akasha-nous` (mock/real ↔ akasha-core), reusado
|
||||
//! ahora para que la UI (`akasha-explorer`) descubra y consulte al
|
||||
//! daemon sin hardcodear sockets ni pasar por brahman-admin.
|
||||
//!
|
||||
//! ## Contrato
|
||||
//!
|
||||
//! ```text
|
||||
//! C → S: {"kind":"list_monads"}\n
|
||||
//! S → C: {"engine":{...},"monads":[...]}\n
|
||||
//! ```
|
||||
//!
|
||||
//! En caso de error:
|
||||
//!
|
||||
//! ```text
|
||||
//! S → C: {"error":"unsupported kind"}\n
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{Lens, MonadId, MonadManifest};
|
||||
|
||||
// =====================================================================
|
||||
// Constants compartidos para el broker brahman
|
||||
// =====================================================================
|
||||
|
||||
/// Nombre del flow output del daemon (input del consumer/explorer).
|
||||
pub const FLOW_MONAD_LIST: &str = "monad-list";
|
||||
|
||||
/// Tipo del flow: el wire es JSON, así que el TypeRef es `primitive::json`.
|
||||
pub const FLOW_TYPE_NAME: &str = "json";
|
||||
|
||||
// =====================================================================
|
||||
// Wire request
|
||||
// =====================================================================
|
||||
|
||||
/// Request al daemon. El wire es JSON line-delimited (un objeto + `\n`
|
||||
/// por conexión).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum QueryRequest {
|
||||
/// Lista todas las Mónadas vivas del daemon, junto con metadata
|
||||
/// del engine. Pensado para que la UI haga snapshot polling.
|
||||
ListMonads,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Wire response
|
||||
// =====================================================================
|
||||
|
||||
/// Response a `ListMonads`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListMonadsResponse {
|
||||
/// Datos del engine (la Card que es "dueña" de las Mónadas).
|
||||
pub engine: EngineInfo,
|
||||
/// Mónadas vivas en este momento. Vista slim sin centroide ni
|
||||
/// member set para que el wire sea liviano: una Mónada con 50k
|
||||
/// archivos no debe transmitir 50k ULIDs cada poll.
|
||||
pub monads: Vec<MonadView>,
|
||||
}
|
||||
|
||||
/// Identidad del engine (Card kind=Ente que owns las Mónadas).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EngineInfo {
|
||||
pub id: Ulid,
|
||||
pub label: String,
|
||||
/// Path del directorio que el daemon está observando. `None` si
|
||||
/// el daemon corre sin watcher.
|
||||
#[serde(default)]
|
||||
pub watching: Option<String>,
|
||||
}
|
||||
|
||||
/// Vista slim de una Mónada — los campos que la UI necesita para
|
||||
/// renderizar una card sin pull del centroide ni del member set.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MonadView {
|
||||
pub id: MonadId,
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
pub cardinality: u32,
|
||||
#[serde(default)]
|
||||
pub entropy: f32,
|
||||
#[serde(default)]
|
||||
pub dominant_lens: Lens,
|
||||
#[serde(default)]
|
||||
pub path_hint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub centroid_model: Option<String>,
|
||||
}
|
||||
|
||||
impl MonadView {
|
||||
/// Proyecta un MonadManifest completo a su vista slim para wire.
|
||||
pub fn from_manifest(m: &MonadManifest) -> Self {
|
||||
Self {
|
||||
id: m.id,
|
||||
label: m.label.clone(),
|
||||
summary: m.summary.clone(),
|
||||
keywords: m.keywords.clone(),
|
||||
cardinality: m.cardinality,
|
||||
entropy: m.entropy,
|
||||
dominant_lens: m.dominant_lens,
|
||||
path_hint: m.path_hint.clone(),
|
||||
centroid_model: m.centroid_model.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error de protocolo retornado en lugar de la response normal.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
|
||||
#[error("akasha-engine: {error}")]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Transport
|
||||
// =====================================================================
|
||||
|
||||
pub mod transport {
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Variable de entorno para sobreescribir la ruta del socket del
|
||||
/// daemon (útil para tests / multi-daemon).
|
||||
pub const SOCKET_ENV: &str = "NOUSER_ENGINE_SOCKET";
|
||||
|
||||
/// Nombre por defecto del socket.
|
||||
pub const SOCKET_NAME: &str = "akasha-engine.sock";
|
||||
|
||||
/// Ruta canónica al socket del daemon. Honra `NOUSER_ENGINE_SOCKET`
|
||||
/// si está set, sino arma sobre `$XDG_RUNTIME_DIR` (con fallback
|
||||
/// `$TMPDIR`).
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var(SOCKET_ENV) {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join(SOCKET_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Cliente blocking — vive con los wire types para que un consumer
|
||||
// (UI, CLI, otro módulo) pueda hablar con el daemon importando sólo
|
||||
// `akasha-card`, sin arrastrar `akasha-core` (notify/walkdir/sled/blake3).
|
||||
// =====================================================================
|
||||
|
||||
/// Cliente síncrono para el query socket del daemon. Sólo Unix (el
|
||||
/// resto del ecosistema brahman es Unix-only de facto).
|
||||
#[cfg(unix)]
|
||||
pub mod client {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{ErrorResponse, ListMonadsResponse, QueryRequest};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum QueryError {
|
||||
#[error("conectar a {path}: {source}")]
|
||||
Connect {
|
||||
path: std::path::PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("I/O: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("serializacion: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
#[error("daemon: {0}")]
|
||||
Daemon(String),
|
||||
#[error("response vacía del daemon")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// Envía `ListMonads` al daemon en `socket` y devuelve la response.
|
||||
/// `timeout` se aplica tanto al read como al write del stream.
|
||||
pub fn list_monads(
|
||||
socket: &Path,
|
||||
timeout: Duration,
|
||||
) -> Result<ListMonadsResponse, QueryError> {
|
||||
let mut stream = UnixStream::connect(socket).map_err(|e| QueryError::Connect {
|
||||
path: socket.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
|
||||
let req = QueryRequest::ListMonads;
|
||||
let line = serde_json::to_string(&req)?;
|
||||
stream.write_all(line.as_bytes())?;
|
||||
stream.write_all(b"\n")?;
|
||||
stream.flush()?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut response = String::new();
|
||||
let n = reader.read_line(&mut response)?;
|
||||
if n == 0 {
|
||||
return Err(QueryError::Empty);
|
||||
}
|
||||
|
||||
if let Ok(resp) = serde_json::from_str::<ListMonadsResponse>(response.trim()) {
|
||||
return Ok(resp);
|
||||
}
|
||||
let err: ErrorResponse = serde_json::from_str(response.trim())?;
|
||||
Err(QueryError::Daemon(err.error))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_roundtrips_json_with_tag() {
|
||||
let req = QueryRequest::ListMonads;
|
||||
let s = serde_json::to_string(&req).unwrap();
|
||||
assert_eq!(s, r#"{"kind":"list_monads"}"#);
|
||||
let back: QueryRequest = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(back, req);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_roundtrip_preserves_view() {
|
||||
let m = MonadManifest::new("x/src");
|
||||
let view = MonadView::from_manifest(&m);
|
||||
let resp = ListMonadsResponse {
|
||||
engine: EngineInfo {
|
||||
id: Ulid::new(),
|
||||
label: "brahman.nouser_engine".into(),
|
||||
watching: Some("/tmp/x".into()),
|
||||
},
|
||||
monads: vec![view.clone()],
|
||||
};
|
||||
let s = serde_json::to_string(&resp).unwrap();
|
||||
let back: ListMonadsResponse = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(back.monads.len(), 1);
|
||||
assert_eq!(back.monads[0].label, view.label);
|
||||
assert_eq!(back.engine.label, "brahman.nouser_engine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_is_slim_no_centroid_no_members() {
|
||||
// Construimos una Mónada con centroid + members "pesados",
|
||||
// proyectamos a view, verificamos que esos campos no viajan.
|
||||
let mut m = MonadManifest::new("test");
|
||||
m.centroid = vec![0.1; 384]; // peso "real-fastembed"
|
||||
m.members.insert(Ulid::new());
|
||||
m.members.insert(Ulid::new());
|
||||
m.cardinality = 2;
|
||||
let view = MonadView::from_manifest(&m);
|
||||
let s = serde_json::to_string(&view).unwrap();
|
||||
// Chequeo con `:` para distinguir el field "centroid" del
|
||||
// field "centroid_model" (que sí es metadata liviana y debe ir).
|
||||
assert!(
|
||||
!s.contains("\"centroid\":"),
|
||||
"MonadView no debe serializar el vector centroid: {s}"
|
||||
);
|
||||
assert!(
|
||||
!s.contains("\"members\":"),
|
||||
"MonadView no debe serializar members: {s}"
|
||||
);
|
||||
assert!(s.contains("\"cardinality\":2"), "cardinality sí va: {s}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "akasha-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Nouser — explorador de Mónadas: scanner, clustering determinista, DB en memoria."
|
||||
|
||||
[dependencies]
|
||||
akasha-card = { path = "../card" }
|
||||
akasha-nous = { path = "../nous" }
|
||||
shuma-discern = { path = "../../shuma/shuma-discern" }
|
||||
brahman-card = { path = "../../../protocol/brahman-card" }
|
||||
brahman-handshake = { path = "../../../protocol/brahman-handshake" }
|
||||
brahman-sidecar = { path = "../../../protocol/brahman-sidecar" }
|
||||
blake3 = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sled = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
walkdir = "2"
|
||||
notify = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "akasha"
|
||||
path = "src/bin/nouser.rs"
|
||||
@@ -0,0 +1,795 @@
|
||||
//! `akasha` CLI — explorador de Mónadas.
|
||||
//!
|
||||
//! Subcomandos:
|
||||
//!
|
||||
//! - `scan <dir>` recorre `dir` y muestra las Mónadas detectadas.
|
||||
//! - `show <dir> <id?>` scan + detalles de la Mónada con prefijo de ID.
|
||||
//! - `json <dir>` scan + dump JSON con los manifests.
|
||||
//!
|
||||
//! Phase A: in-memory, sin persistencia, sin brahman sidecar. La
|
||||
//! sesión termina y todo se descarta. Phase B agrega persistencia y
|
||||
//! presencia ante el Init.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use akasha_core::{
|
||||
cluster, db, embed,
|
||||
scanner::{self, ScanConfig},
|
||||
};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let prog = args.first().cloned().unwrap_or_else(|| "akasha".into());
|
||||
let sub = match args.get(1).map(String::as_str) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
print_usage(&prog);
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
let rest = &args[2..];
|
||||
|
||||
let result = match sub {
|
||||
"scan" => cmd_scan(rest),
|
||||
"show" => cmd_show(rest),
|
||||
"json" => cmd_json(rest),
|
||||
"daemon" => cmd_daemon(rest),
|
||||
"attract" => cmd_attract(rest),
|
||||
"--help" | "-h" | "help" => {
|
||||
print_usage(&prog);
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
other => {
|
||||
eprintln!("akasha: comando desconocido '{other}'");
|
||||
print_usage(&prog);
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("akasha: {e}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_usage(prog: &str) {
|
||||
eprintln!("uso: {prog} <comando> [args]");
|
||||
eprintln!();
|
||||
eprintln!("comandos:");
|
||||
eprintln!(" scan <dir> recorre un directorio y lista las Mónadas detectadas");
|
||||
eprintln!(" show <dir> <prefix> scan + detalle de la Mónada cuyo ID empieza con <prefix>");
|
||||
eprintln!(" json <dir> scan + dump JSON de todos los manifests");
|
||||
eprintln!(" daemon <dir> scan + sidecarea cada Mónada al Init brahman");
|
||||
eprintln!(" attract <dir> <file> dado un archivo, qué Mónada del scan lo atrae más");
|
||||
eprintln!();
|
||||
eprintln!("env:");
|
||||
eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
|
||||
eprintln!(" NOUSER_DB_PATH si está set, abre sled en esa ruta (persistencia)");
|
||||
eprintln!(" BRAHMAN_INIT_SOCKET socket del Init (heredado de brahman-handshake)");
|
||||
}
|
||||
|
||||
type Cmd = Result<(), Box<dyn std::error::Error>>;
|
||||
|
||||
fn min_files() -> usize {
|
||||
std::env::var("NOUSER_MIN_FILES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(cluster::DEFAULT_MIN_FILES_PER_MONAD)
|
||||
}
|
||||
|
||||
fn require_dir(args: &[String]) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let dir = args.first().ok_or("falta argumento <dir>")?;
|
||||
Ok(PathBuf::from(dir))
|
||||
}
|
||||
|
||||
fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box<dyn std::error::Error>> {
|
||||
let files = scanner::scan_directory(dir, &ScanConfig::default())?;
|
||||
let n_files = files.len();
|
||||
let monads = cluster::by_directory(&files, min_files());
|
||||
let mut db = open_db()?;
|
||||
db.ingest_files(files);
|
||||
db.replace_monads(monads);
|
||||
Ok((db, n_files))
|
||||
}
|
||||
|
||||
/// Abre el `MonadDb`. Si `NOUSER_DB_PATH` está set, persistencia sled;
|
||||
/// si no, store en memoria.
|
||||
fn open_db() -> Result<db::MonadDb, Box<dyn std::error::Error>> {
|
||||
if let Ok(path) = std::env::var("NOUSER_DB_PATH") {
|
||||
Ok(db::MonadDb::open(&path)?)
|
||||
} else {
|
||||
Ok(db::MonadDb::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_scan(args: &[String]) -> Cmd {
|
||||
let dir = require_dir(args)?;
|
||||
let (db, n_files) = run_scan(&dir)?;
|
||||
|
||||
println!(
|
||||
"scan: {} archivos en {}, {} mónadas (min_files={})",
|
||||
n_files,
|
||||
dir.display(),
|
||||
db.monad_count(),
|
||||
min_files()
|
||||
);
|
||||
if db.monad_count() == 0 {
|
||||
println!(" (ninguna Mónada — bajá NOUSER_MIN_FILES o apuntá a un dir con más archivos)");
|
||||
return Ok(());
|
||||
}
|
||||
println!();
|
||||
for m in db.monads() {
|
||||
let id_short = format!("{}", m.id);
|
||||
let id_short = &id_short[..8];
|
||||
println!(
|
||||
" [{}] {:30} card={} ent={:.2} lens={:?}",
|
||||
id_short, m.label, m.cardinality, m.entropy, m.dominant_lens,
|
||||
);
|
||||
if !m.keywords.is_empty() {
|
||||
println!(" keywords: {}", m.keywords.join(", "));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_show(args: &[String]) -> Cmd {
|
||||
let dir = require_dir(args)?;
|
||||
let prefix = args.get(1).ok_or("falta argumento <prefix>")?;
|
||||
let (db, _) = run_scan(&dir)?;
|
||||
|
||||
let m = db
|
||||
.monads()
|
||||
.find(|m| m.id.to_string().starts_with(prefix))
|
||||
.ok_or_else(|| format!("ninguna Mónada con prefijo '{prefix}'"))?;
|
||||
|
||||
println!("Monad {}", m.id);
|
||||
println!(" label: {}", m.label);
|
||||
println!(" summary: {}", m.summary);
|
||||
println!(" cardinality: {}", m.cardinality);
|
||||
println!(" entropy: {:.4}", m.entropy);
|
||||
println!(" lens: {:?}", m.dominant_lens);
|
||||
println!(" keywords: {}", m.keywords.join(", "));
|
||||
println!(" members ({}):", m.members.len());
|
||||
for f in db.resolve_members(m.id) {
|
||||
println!(
|
||||
" {:>10} bytes {}",
|
||||
f.size,
|
||||
f.path.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_json(args: &[String]) -> Cmd {
|
||||
let dir = require_dir(args)?;
|
||||
let (db, _) = run_scan(&dir)?;
|
||||
let manifests: Vec<_> = db.monads().cloned().collect();
|
||||
println!("{}", serde_json::to_string_pretty(&manifests)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_daemon(args: &[String]) -> Cmd {
|
||||
let dir = require_dir(args)?;
|
||||
|
||||
let pool = std::sync::Arc::new(
|
||||
brahman_sidecar::SidecarPool::new().map_err(|e| format!("crear pool: {e}"))?,
|
||||
);
|
||||
|
||||
// 1. Decidir el path del query socket ANTES de armar el engine
|
||||
// Card (porque viaja como service_socket en la Card).
|
||||
let query_socket = akasha_card::query::transport::default_socket_path();
|
||||
|
||||
// 2. Engine como Ente. Declara service_socket + flow.output para
|
||||
// que el broker pueda emitir MatchEvent::Available a consumers
|
||||
// interesados en `flow.input = monad-list:json`.
|
||||
let engine_card = build_engine_card(query_socket.clone());
|
||||
let engine_id = engine_card.id;
|
||||
let engine_label = engine_card.label.clone();
|
||||
eprintln!(
|
||||
"akasha daemon: publicando engine '{}' (kind=Ente, id={}, socket={})",
|
||||
engine_label,
|
||||
engine_id,
|
||||
query_socket.display()
|
||||
);
|
||||
pool.spawn(engine_card);
|
||||
|
||||
// 2. Hidratación: si NOUSER_DB_PATH apunta a un sled poblado,
|
||||
// publicar lo que ya tenemos ANTES del re-scan. brahman-status
|
||||
// ve mónadas reales en milisegundos, no en segundos.
|
||||
let mut db = open_db()?;
|
||||
let prior_count = db.monad_count();
|
||||
if prior_count > 0 {
|
||||
let mut hydrated = 0usize;
|
||||
let mut skipped_model = 0usize;
|
||||
for monad in db.monads() {
|
||||
// Sólo publicamos centroides del modelo actual; los demás
|
||||
// son data muerta hasta que el re-scan los reemplace.
|
||||
let valid = monad
|
||||
.centroid_model
|
||||
.as_deref()
|
||||
.map(|id| id == embed::MODEL_ID)
|
||||
.unwrap_or(false);
|
||||
if !valid {
|
||||
skipped_model += 1;
|
||||
continue;
|
||||
}
|
||||
let mut card = monad.to_brahman_card();
|
||||
card.references.push(brahman_card::CardReference {
|
||||
kind: brahman_card::RelationshipKind::OwnedBy,
|
||||
target_id: engine_id,
|
||||
target_label: engine_label.clone(),
|
||||
});
|
||||
pool.spawn(card);
|
||||
hydrated += 1;
|
||||
}
|
||||
eprintln!(
|
||||
"akasha daemon: hidratadas {} mónadas previas{} en O(1)",
|
||||
hydrated,
|
||||
if skipped_model > 0 {
|
||||
format!(" ({} dropeadas por centroid_model distinto)", skipped_model)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Re-scan con hidratación: las Mónadas con mismo path_hint
|
||||
// reusan id, así que NO generamos sesiones duplicadas para los
|
||||
// mismos directorios — el sidecar previo ya tiene esa identidad.
|
||||
let files = scanner::scan_directory(&dir, &scanner::ScanConfig::default())?;
|
||||
let n_files = files.len();
|
||||
let monads = cluster::by_directory_hydrated(&files, min_files(), Some(&db));
|
||||
let scanned_count = monads.len();
|
||||
eprintln!(
|
||||
"akasha daemon: re-scan {} archivos en {} → {} mónadas",
|
||||
n_files,
|
||||
dir.display(),
|
||||
scanned_count
|
||||
);
|
||||
|
||||
// Publicamos sólo las Mónadas NUEVAS (las que no estaban en la
|
||||
// hidratación inicial). El criterio: si el id estaba en la DB
|
||||
// previa, el sidecar de la hidratación ya cubre esa identidad.
|
||||
let prior_ids: std::collections::BTreeSet<_> = db.monads().map(|m| m.id).collect();
|
||||
let mut newly_spawned = 0usize;
|
||||
for monad in &monads {
|
||||
if prior_ids.contains(&monad.id) {
|
||||
continue; // ya publicada en hidratación
|
||||
}
|
||||
let mut card = monad.to_brahman_card();
|
||||
card.references.push(brahman_card::CardReference {
|
||||
kind: brahman_card::RelationshipKind::OwnedBy,
|
||||
target_id: engine_id,
|
||||
target_label: engine_label.clone(),
|
||||
});
|
||||
pool.spawn(card);
|
||||
newly_spawned += 1;
|
||||
}
|
||||
|
||||
// Reescribimos la DB con el set actual (idempotente para los
|
||||
// hidratados; reemplazo para los nuevos).
|
||||
db.ingest_files(files);
|
||||
db.replace_monads(monads);
|
||||
|
||||
eprintln!(
|
||||
"akasha daemon: 1 ente + {} mónadas vivas ({} nuevas vs hidratación)",
|
||||
scanned_count, newly_spawned
|
||||
);
|
||||
|
||||
// Engine query socket: bind antes del watcher para que cualquier
|
||||
// consumer descubierto vía broker pueda consultarnos enseguida.
|
||||
// Si el bind falla, seguimos sin él — la UI degrada a "no
|
||||
// alcanzable" pero el daemon sigue procesando cambios.
|
||||
let db_shared = std::sync::Arc::new(std::sync::Mutex::new(db));
|
||||
let _query_listener = match akasha_core::engine_socket::spawn_listener(
|
||||
akasha_core::engine_socket::ListenerConfig {
|
||||
socket_path: query_socket.clone(),
|
||||
engine_id,
|
||||
engine_label: engine_label.clone(),
|
||||
watching: Some(dir.clone()),
|
||||
},
|
||||
db_shared.clone(),
|
||||
) {
|
||||
Ok(h) => {
|
||||
eprintln!(
|
||||
"akasha daemon: query socket activo en {} (proto: akasha_card::query)",
|
||||
query_socket.display()
|
||||
);
|
||||
Some(h)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"akasha daemon: query socket NO disponible ({e}) — explorer no podrá consultar"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Watcher: cada cambio en el árbol — coalescido con debounce de
|
||||
// 150ms — dispara un re-scan + re-cluster del directorio y
|
||||
// re-publica al broker las Mónadas afectadas (drop + spawn por id,
|
||||
// gracias al replace en `SidecarPool::spawn`).
|
||||
let _watcher = match spawn_fs_watcher(
|
||||
dir.clone(),
|
||||
db_shared.clone(),
|
||||
pool.clone(),
|
||||
engine_id,
|
||||
engine_label.clone(),
|
||||
) {
|
||||
Ok(w) => {
|
||||
eprintln!(
|
||||
"akasha daemon: watcher activo en {} (debounce 150ms, re-publish on) — Ctrl-C para terminar.",
|
||||
dir.display()
|
||||
);
|
||||
Some(w)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"akasha daemon: watcher deshabilitado ({e}) — Ctrl-C para terminar."
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
std::thread::park();
|
||||
drop(_watcher);
|
||||
drop(_query_listener);
|
||||
let _ = std::fs::remove_file(&query_socket); // best-effort cleanup
|
||||
drop(pool);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ventana de debounce: notify dispara Create+Modify(+) por cada
|
||||
/// edición; sin coalescer veríamos N reacciones por un solo `:w`.
|
||||
/// 150ms es generoso para editores típicos (vim/code) y mantiene el
|
||||
/// feedback "vivo" para el usuario.
|
||||
const WATCHER_DEBOUNCE_MS: u64 = 150;
|
||||
|
||||
/// Watcher de filesystem con debounce + re-publish al broker.
|
||||
///
|
||||
/// Pipeline:
|
||||
///
|
||||
/// 1. **notify** dispara eventos crudos a un canal interno.
|
||||
/// 2. **dispatcher**: filtra a Create/Modify/Remove de paths bajo
|
||||
/// `dir`, descarta el resto, reenvía al canal de debounce.
|
||||
/// 3. **coordinator**: mantiene un `HashMap<PathBuf, Instant>`.
|
||||
/// Cada vez que el canal queda en silencio durante
|
||||
/// `WATCHER_DEBOUNCE_MS`, agrupa los paths cuya última actividad
|
||||
/// superó la ventana y los procesa en **un solo batch**.
|
||||
/// 4. **process_change_batch**: re-scan + re-cluster hidratado +
|
||||
/// diff vs DB + `pool.drop_session` para Mónadas desaparecidas
|
||||
/// + `pool.spawn` para Mónadas nuevas o con composición distinta.
|
||||
/// `pool.spawn` reemplaza la sesión previa con el mismo `Card.id`,
|
||||
/// así que el broker ve el manifest fresco sin sesiones huérfanas.
|
||||
fn spawn_fs_watcher(
|
||||
dir: std::path::PathBuf,
|
||||
db: std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
|
||||
pool: std::sync::Arc<brahman_sidecar::SidecarPool>,
|
||||
engine_id: brahman_card::ulid::Ulid,
|
||||
engine_label: String,
|
||||
) -> Result<notify::RecommendedWatcher, Box<dyn std::error::Error>> {
|
||||
use notify::{Event, EventKind, RecursiveMode, Watcher};
|
||||
|
||||
let (notify_tx, notify_rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
|
||||
let mut watcher = notify::recommended_watcher(move |res| {
|
||||
let _ = notify_tx.send(res);
|
||||
})?;
|
||||
watcher.watch(&dir, RecursiveMode::Recursive)?;
|
||||
|
||||
let (path_tx, path_rx) = std::sync::mpsc::channel::<std::path::PathBuf>();
|
||||
|
||||
// Dispatcher: notify → filtro → canal de paths.
|
||||
let dispatch_dir = dir.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("akasha-watcher-dispatch".into())
|
||||
.spawn(move || {
|
||||
for res in notify_rx {
|
||||
let event = match res {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("[watcher] error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Create/Modify viven; Remove también nos importa
|
||||
// (puede colapsar Mónadas).
|
||||
let interesting = matches!(
|
||||
event.kind,
|
||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
||||
);
|
||||
if !interesting {
|
||||
continue;
|
||||
}
|
||||
for path in event.paths {
|
||||
if !path.starts_with(&dispatch_dir) {
|
||||
continue;
|
||||
}
|
||||
let _ = path_tx.send(path);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
// Coordinator: debounce + batch dispatch.
|
||||
let coord_dir = dir;
|
||||
std::thread::Builder::new()
|
||||
.name("akasha-watcher-coord".into())
|
||||
.spawn(move || {
|
||||
let debounce = std::time::Duration::from_millis(WATCHER_DEBOUNCE_MS);
|
||||
let mut pending: std::collections::HashMap<
|
||||
std::path::PathBuf,
|
||||
std::time::Instant,
|
||||
> = std::collections::HashMap::new();
|
||||
loop {
|
||||
match path_rx.recv_timeout(debounce) {
|
||||
Ok(path) => {
|
||||
pending.insert(path, std::time::Instant::now());
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
let due: Vec<std::path::PathBuf> = pending
|
||||
.iter()
|
||||
.filter(|(_, t)| now.duration_since(**t) >= debounce)
|
||||
.map(|(p, _)| p.clone())
|
||||
.collect();
|
||||
if due.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for p in &due {
|
||||
pending.remove(p);
|
||||
}
|
||||
process_change_batch(
|
||||
&due,
|
||||
&coord_dir,
|
||||
&db,
|
||||
&pool,
|
||||
engine_id,
|
||||
&engine_label,
|
||||
);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
/// Procesa un batch de paths cambiados: re-scanea el árbol, re-clusteriza
|
||||
/// con hidratación, y propaga el delta de Mónadas al broker.
|
||||
///
|
||||
/// El re-scan global es deliberado: el clustering por directorio es global
|
||||
/// por diseño, así que un cambio en `src/foo.rs` puede mover Mónadas en
|
||||
/// `src/` sin tocar `tests/`. Coste O(N archivos), aceptable para
|
||||
/// directorios típicos (<10k archivos). Optimizar a re-cluster parcial
|
||||
/// cuando duela.
|
||||
fn process_change_batch(
|
||||
paths: &[std::path::PathBuf],
|
||||
dir: &std::path::Path,
|
||||
db: &std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
|
||||
pool: &std::sync::Arc<brahman_sidecar::SidecarPool>,
|
||||
engine_id: brahman_card::ulid::Ulid,
|
||||
engine_label: &str,
|
||||
) {
|
||||
eprintln!(
|
||||
"[watcher] ⚙ batch: {} path(s) coalescidos → re-scan",
|
||||
paths.len()
|
||||
);
|
||||
|
||||
let files = match scanner::scan_directory(dir, &scanner::ScanConfig::default()) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[watcher] re-scan falló: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut db_lock = match db.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
eprintln!("[watcher] mutex envenenado — abortando batch");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let prior_monads: Vec<akasha_card::MonadManifest> = db_lock.monads().cloned().collect();
|
||||
let prior_ref: &db::MonadDb = &db_lock;
|
||||
let monads = cluster::by_directory_hydrated(&files, min_files(), Some(prior_ref));
|
||||
|
||||
let prior_ids: std::collections::BTreeSet<_> =
|
||||
prior_monads.iter().map(|m| m.id).collect();
|
||||
let new_ids: std::collections::BTreeSet<_> = monads.iter().map(|m| m.id).collect();
|
||||
|
||||
// Mónadas que ya no existen (directorio quedó por debajo de
|
||||
// min_files o fue removido): cerramos su sesión en el broker.
|
||||
let mut removed = 0usize;
|
||||
for id in prior_ids.difference(&new_ids) {
|
||||
pool.drop_session(*id);
|
||||
removed += 1;
|
||||
if let Some(prev) = prior_monads.iter().find(|m| &m.id == id) {
|
||||
eprintln!(
|
||||
"[watcher] ✖ {} ({}) desapareció — sesión cerrada",
|
||||
&id.to_string()[..8],
|
||||
prev.label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mónadas nuevas o cuya composición cambió (members/centroid):
|
||||
// (re)spawn — el pool reemplaza la sesión previa con el mismo id.
|
||||
let mut respawned = 0usize;
|
||||
let mut fresh = 0usize;
|
||||
for monad in &monads {
|
||||
let prev = prior_monads.iter().find(|m| m.id == monad.id);
|
||||
let is_new = prev.is_none();
|
||||
let changed = match prev {
|
||||
Some(p) => p.members != monad.members || p.centroid != monad.centroid,
|
||||
None => true,
|
||||
};
|
||||
if !changed {
|
||||
continue;
|
||||
}
|
||||
let mut card = monad.to_brahman_card();
|
||||
card.references.push(brahman_card::CardReference {
|
||||
kind: brahman_card::RelationshipKind::OwnedBy,
|
||||
target_id: engine_id,
|
||||
target_label: engine_label.to_string(),
|
||||
});
|
||||
pool.spawn(card);
|
||||
if is_new {
|
||||
fresh += 1;
|
||||
eprintln!(
|
||||
"[watcher] ✦ {} nace ({} miembros, lens={:?})",
|
||||
monad.label, monad.cardinality, monad.dominant_lens
|
||||
);
|
||||
} else {
|
||||
respawned += 1;
|
||||
let prev = prev.unwrap();
|
||||
let delta_members = monad.members.len() as i64 - prev.members.len() as i64;
|
||||
eprintln!(
|
||||
"[watcher] ↻ {} refresh ({} miembros, Δ={:+})",
|
||||
monad.label, monad.cardinality, delta_members
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if removed == 0 && fresh == 0 && respawned == 0 {
|
||||
eprintln!("[watcher] (sin cambios estructurales tras re-cluster)");
|
||||
} else {
|
||||
eprintln!(
|
||||
"[watcher] ⌃ delta: {} nuevas, {} refrescadas, {} cerradas — {} sesiones vivas",
|
||||
fresh,
|
||||
respawned,
|
||||
removed,
|
||||
pool.live_sessions()
|
||||
);
|
||||
}
|
||||
|
||||
db_lock.ingest_files(files);
|
||||
db_lock.replace_monads(monads);
|
||||
}
|
||||
|
||||
fn cmd_attract(args: &[String]) -> Cmd {
|
||||
let mut remote = false;
|
||||
let mut positional: Vec<&String> = Vec::new();
|
||||
for a in args {
|
||||
if a == "--remote" {
|
||||
remote = true;
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
let dir = positional
|
||||
.first()
|
||||
.map(|s| std::path::PathBuf::from(s.as_str()))
|
||||
.ok_or("falta argumento <dir>")?;
|
||||
let file_path = positional.get(1).ok_or("falta argumento <file>")?;
|
||||
let file_path = std::path::PathBuf::from(file_path.as_str());
|
||||
if !file_path.exists() {
|
||||
return Err(format!("archivo no existe: {}", file_path.display()).into());
|
||||
}
|
||||
|
||||
let (db, _) = run_scan(&dir)?;
|
||||
|
||||
// Construimos un FileEntry para el archivo objetivo.
|
||||
let metadata = std::fs::metadata(&file_path)?;
|
||||
let mtime_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let target = akasha_card::FileEntry {
|
||||
id: akasha_card::FileId::from(akasha_card::ulid::Ulid::new()),
|
||||
path: file_path.clone(),
|
||||
content_hash: None,
|
||||
size: metadata.len(),
|
||||
mtime_ms,
|
||||
extension: file_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_lowercase()),
|
||||
};
|
||||
|
||||
// Embedding del target + identificación del modelo que lo produjo.
|
||||
// Local: pseudo-32d. Remote: lo que devuelva el provider electo
|
||||
// (mock=pseudo-32d, real=fastembed-384d).
|
||||
let (target_vec, target_model, source) = if remote {
|
||||
let (v, model) = remote_embed(&target)?;
|
||||
(v, model, "remote")
|
||||
} else {
|
||||
(
|
||||
embed::embed(&target).to_vec(),
|
||||
embed::MODEL_ID.to_string(),
|
||||
"local",
|
||||
)
|
||||
};
|
||||
|
||||
// Filtramos Mónadas cuyo centroid_model NO matchee. Mezclar
|
||||
// 32-d con 384-d daría scores sin sentido (diferente semántica
|
||||
// y cosine no compara cross-modelo).
|
||||
let mut ranked: Vec<(&akasha_card::MonadManifest, f32)> = db
|
||||
.monads()
|
||||
.filter(|m| !m.centroid.is_empty())
|
||||
.filter(|m| match &m.centroid_model {
|
||||
Some(id) => id == &target_model,
|
||||
None => true, // legacy sin tag — comparamos best-effort
|
||||
})
|
||||
.map(|m| (m, embed::attraction_score(&target_vec, m)))
|
||||
.collect();
|
||||
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let total_monads = db.monads().filter(|m| !m.centroid.is_empty()).count();
|
||||
let skipped = total_monads - ranked.len();
|
||||
|
||||
if ranked.is_empty() {
|
||||
println!("ninguna Mónada con centroide en {}", dir.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("archivo: {}", file_path.display());
|
||||
println!("scan dir: {}", dir.display());
|
||||
println!("embed: {} ({})", source, target_model);
|
||||
if skipped > 0 {
|
||||
println!(
|
||||
"skipped: {} mónada(s) con centroid_model distinto (no comparables)",
|
||||
skipped
|
||||
);
|
||||
}
|
||||
println!("ranking de atracción (cosine similarity):");
|
||||
println!();
|
||||
for (i, (m, score)) in ranked.iter().take(5).enumerate() {
|
||||
let marker = if *score >= embed::DEFAULT_ATTRACTION_THRESHOLD && i == 0 {
|
||||
"🧲"
|
||||
} else if i == 0 {
|
||||
"·"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let id_short = format!("{}", m.id);
|
||||
let id_short = &id_short[..8];
|
||||
println!(
|
||||
" {} {:.4} [{}] {:30} ({})",
|
||||
marker, score, id_short, m.label, m.summary
|
||||
);
|
||||
}
|
||||
if ranked[0].1 < embed::DEFAULT_ATTRACTION_THRESHOLD {
|
||||
println!();
|
||||
println!(
|
||||
" (mejor score {:.4} < umbral {:.4} — el archivo no se 'pega' a ninguna)",
|
||||
ranked[0].1,
|
||||
embed::DEFAULT_ATTRACTION_THRESHOLD
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pipeline completo del modo `--remote`:
|
||||
/// 1. Si `NOUSER_NOUS_SOCKET` está set, lo usa directo (override
|
||||
/// explícito, atajo para tests).
|
||||
/// 2. Si no, delega en `brahman_sidecar::await_provider_blocking` —
|
||||
/// el sidecar se conecta al broker, registra un consumer Card con
|
||||
/// `flow.input = embed-result:json`, espera el primer
|
||||
/// `MatchEvent::Available` y devuelve el socket. Esto activa la
|
||||
/// lógica de `priority_contexts`: bajo `BRAHMAN_BROKER_CONTEXT=test/prod`,
|
||||
/// el proveedor electo cambia sin que este código toque nada.
|
||||
/// 3. Con el socket resuelto, dispara la RPC `EmbedFile`.
|
||||
///
|
||||
/// Devuelve `(embedding, model_id)` — el caller necesita ambos para
|
||||
/// comparar contra centroides taggeados con su mismo `centroid_model`.
|
||||
fn remote_embed(
|
||||
file: &akasha_card::FileEntry,
|
||||
) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
|
||||
if let Ok(explicit) = std::env::var("NOUSER_NOUS_SOCKET") {
|
||||
let sock = std::path::PathBuf::from(explicit);
|
||||
return embed_via(&sock, file);
|
||||
}
|
||||
|
||||
let consumer = brahman_sidecar::build_consumer_card(
|
||||
"akasha.attract-cli",
|
||||
akasha_nous::FLOW_EMBED_RESULT,
|
||||
akasha_nous::FLOW_TYPE_NAME,
|
||||
);
|
||||
let producer_sock = brahman_sidecar::await_provider_blocking(
|
||||
consumer,
|
||||
std::time::Duration::from_secs(3),
|
||||
)?;
|
||||
embed_via(&producer_sock, file)
|
||||
}
|
||||
|
||||
/// RPC blocking contra un socket akasha-nous concreto. Devuelve
|
||||
/// `(embedding, model_id)` — el `model_id` viaja en la response y
|
||||
/// permite al caller saber qué centroides son comparables.
|
||||
fn embed_via(
|
||||
sock_path: &std::path::Path,
|
||||
file: &akasha_card::FileEntry,
|
||||
) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
if !sock_path.exists() {
|
||||
return Err(format!("socket no existe: {}", sock_path.display()).into());
|
||||
}
|
||||
|
||||
let mut stream = UnixStream::connect(sock_path)?;
|
||||
let req = akasha_nous::EmbedRequest {
|
||||
kind: akasha_nous::RequestKind::EmbedFile,
|
||||
payload: serde_json::to_value(akasha_nous::EmbedFilePayload {
|
||||
path: file.path.display().to_string(),
|
||||
extension: file.extension.clone(),
|
||||
size: file.size,
|
||||
mtime_ms: file.mtime_ms,
|
||||
})?,
|
||||
};
|
||||
let line = serde_json::to_string(&req)?;
|
||||
stream.write_all(line.as_bytes())?;
|
||||
stream.write_all(b"\n")?;
|
||||
stream.flush()?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response)?;
|
||||
if response.is_empty() {
|
||||
return Err("akasha-nous cerró sin respuesta".into());
|
||||
}
|
||||
|
||||
if let Ok(resp) = serde_json::from_str::<akasha_nous::EmbedResponse>(&response) {
|
||||
return Ok((resp.embedding, resp.model));
|
||||
}
|
||||
let err: akasha_nous::ErrorResponse = serde_json::from_str(&response)?;
|
||||
Err(format!("akasha-nous: {}", err.error).into())
|
||||
}
|
||||
|
||||
/// Card del propio engine (kind=Ente). Es el "ser" que produce y
|
||||
/// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
|
||||
///
|
||||
/// Declara `service_socket` y `flow.output = monad-list:json` para
|
||||
/// que un consumer (UI, CLI) pueda descubrir al daemon vía broker
|
||||
/// MatchEvent y consultarle por sus Mónadas sin pasar por
|
||||
/// brahman-admin.
|
||||
fn build_engine_card(service_socket: std::path::PathBuf) -> brahman_card::Card {
|
||||
use brahman_card::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef};
|
||||
use akasha_card::query::{FLOW_MONAD_LIST, FLOW_TYPE_NAME};
|
||||
|
||||
Card {
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::Delegate,
|
||||
lifecycle: Lifecycle::Daemon,
|
||||
priority: Priority::Normal,
|
||||
kind: CardKind::Ente,
|
||||
service_socket: Some(service_socket),
|
||||
flow: Flows {
|
||||
input: vec![],
|
||||
output: vec![Flow {
|
||||
name: FLOW_MONAD_LIST.into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: FLOW_TYPE_NAME.into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
},
|
||||
..Card::new("brahman.nouser_engine")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Clustering determinista (Phase A).
|
||||
//!
|
||||
//! Estrategia: agrupar por **directorio padre** + ranking por
|
||||
//! **extensión dominante**. No hay LLM ni embeddings — sólo metadatos.
|
||||
//! Esta capa cubre el 90% de los casos prácticos:
|
||||
//!
|
||||
//! - Un proyecto Rust en `~/dev/foo/src/` → Mónada coherente (.rs).
|
||||
//! - Un dump de fotos en `~/Pictures/2024/` → Mónada con lente Gallery.
|
||||
//! - Notas en `~/notes/` → Mónada con lente Markdown.
|
||||
//!
|
||||
//! Los casos donde esta heurística falla (archivos relacionados pero
|
||||
//! dispersos en el FS) son el dominio de los embeddings (Phase C) y
|
||||
//! del clustering por Nous (Phase D).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use akasha_card::{FileEntry, Lens, MonadManifest};
|
||||
|
||||
use crate::embed;
|
||||
|
||||
/// Mínimo de archivos para que un directorio sea promovido a Mónada.
|
||||
/// Por debajo de eso, los archivos quedan "huérfanos" (no asignados).
|
||||
pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3;
|
||||
|
||||
/// Agrupa archivos en Mónadas por directorio padre.
|
||||
///
|
||||
/// Devuelve un `Vec<MonadManifest>` ordenado por path. Archivos en
|
||||
/// directorios con menos de `min_files` no producen Mónada.
|
||||
pub fn by_directory(files: &[FileEntry], min_files: usize) -> Vec<MonadManifest> {
|
||||
by_directory_hydrated(files, min_files, None)
|
||||
}
|
||||
|
||||
/// Variante con hidratación: si `prior` está presente, busca Mónadas
|
||||
/// previas con el mismo `path_hint` y `centroid_model` válido, y reusa
|
||||
/// su `id` y `lineage`. Esto preserva identidad across re-scans —
|
||||
/// fundamental para que el daemon pueda republicar tras hidratar de
|
||||
/// sled sin generar duplicados en el broker.
|
||||
pub fn by_directory_hydrated(
|
||||
files: &[FileEntry],
|
||||
min_files: usize,
|
||||
prior: Option<&crate::db::MonadDb>,
|
||||
) -> Vec<MonadManifest> {
|
||||
let mut by_parent: BTreeMap<PathBuf, Vec<&FileEntry>> = BTreeMap::new();
|
||||
for f in files {
|
||||
if let Some(parent) = f.path.parent() {
|
||||
by_parent.entry(parent.to_path_buf()).or_default().push(f);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (parent, group) in by_parent {
|
||||
if group.len() < min_files {
|
||||
continue;
|
||||
}
|
||||
let mut m = build_monad(&parent, &group);
|
||||
if let Some(db) = prior {
|
||||
// Reusamos id si encontramos Mónada previa con mismo
|
||||
// path_hint Y mismo centroid_model. Distintas hipótesis
|
||||
// de modelo no comparten identidad — son objetos
|
||||
// semánticos distintos, aunque parecidos.
|
||||
if let Some(existing) = db.monads().find(|prev| {
|
||||
prev.path_hint.as_deref() == m.path_hint.as_deref()
|
||||
&& prev.centroid_model == m.centroid_model
|
||||
}) {
|
||||
m.id = existing.id;
|
||||
m.lineage = existing.lineage;
|
||||
m.created_at_ms = existing.created_at_ms;
|
||||
m.touch();
|
||||
}
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest {
|
||||
let label = label_from_path(parent);
|
||||
|
||||
let keywords = top_extensions(group, 5);
|
||||
let lens = pick_lens(group);
|
||||
let entropy = shannon_entropy_normalized(group);
|
||||
|
||||
let summary = build_summary(parent, group, &keywords);
|
||||
|
||||
// Centroide vectorial: promedio de los embeddings de los miembros.
|
||||
// Esto es lo que permite "atracción" determinista de archivos
|
||||
// nuevos sin tocar Nous.
|
||||
let member_vecs: Vec<Vec<f32>> = group.iter().map(|f| embed::embed(f).to_vec()).collect();
|
||||
let centroid = embed::centroid(&member_vecs);
|
||||
|
||||
let mut m = MonadManifest::new(label);
|
||||
m.summary = summary;
|
||||
m.keywords = keywords;
|
||||
m.dominant_lens = lens;
|
||||
m.entropy = entropy;
|
||||
m.centroid = centroid;
|
||||
// Taggeamos el centroide con su modelo. attract verifica esto
|
||||
// antes de comparar para no mezclar pseudo-32d con real-384d.
|
||||
m.centroid_model = Some(embed::MODEL_ID.to_string());
|
||||
// path_hint = identidad estable across re-scans para
|
||||
// hidratación. Display es lossy con UTF-8 inválido pero los
|
||||
// paths legítimos se imprimen consistentes.
|
||||
m.path_hint = Some(parent.display().to_string());
|
||||
m.members = group.iter().map(|f| f.id).collect();
|
||||
m.touch();
|
||||
m
|
||||
}
|
||||
|
||||
/// Construye un label legible tomando los últimos hasta 2 componentes
|
||||
/// del path. Esto desambigua `src/` repetidos en monorepos: en lugar
|
||||
/// de 5 Mónadas con label "src", quedan "ente-zero/src", "ente-brain/src",
|
||||
/// etc. Para directorios shallow (root o un nivel), cae al
|
||||
/// `file_name()` simple.
|
||||
fn label_from_path(p: &std::path::Path) -> String {
|
||||
let normals: Vec<&str> = p
|
||||
.components()
|
||||
.filter_map(|c| match c {
|
||||
std::path::Component::Normal(s) => s.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if normals.is_empty() {
|
||||
return "unnamed".to_string();
|
||||
}
|
||||
let take = normals.len().min(2);
|
||||
let start = normals.len() - take;
|
||||
normals[start..].join("/")
|
||||
}
|
||||
|
||||
fn build_summary(parent: &std::path::Path, group: &[&FileEntry], keywords: &[String]) -> String {
|
||||
let path_str = parent.display();
|
||||
let n = group.len();
|
||||
let exts = if keywords.is_empty() {
|
||||
"(sin extensiones)".to_string()
|
||||
} else {
|
||||
keywords.join(", ")
|
||||
};
|
||||
format!("{n} archivos en {path_str} (ext: {exts})")
|
||||
}
|
||||
|
||||
/// Top-N extensiones por frecuencia, descendente. Empate por orden alfabético.
|
||||
fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
|
||||
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for f in files {
|
||||
if let Some(ext) = &f.extension {
|
||||
*counts.entry(ext.clone()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut sorted: Vec<_> = counts.into_iter().collect();
|
||||
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
sorted.into_iter().take(n).map(|(k, _)| k).collect()
|
||||
}
|
||||
|
||||
/// Elige el lente dominante según la extensión más frecuente, con
|
||||
/// fallback a `shuma-discern` sobre el head del archivo más
|
||||
/// representativo cuando la extensión no da hint claro (Lens::Grid).
|
||||
fn pick_lens(files: &[&FileEntry]) -> Lens {
|
||||
let dominant = top_extensions(files, 1).into_iter().next();
|
||||
let by_ext = match dominant.as_deref() {
|
||||
Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp"
|
||||
| "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code,
|
||||
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => {
|
||||
Lens::Gallery
|
||||
}
|
||||
Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown,
|
||||
Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database,
|
||||
_ => Lens::Grid,
|
||||
};
|
||||
if by_ext != Lens::Grid {
|
||||
return by_ext;
|
||||
}
|
||||
// Fallback: samplear el primer archivo del grupo con shuma-discern.
|
||||
// Sólo si tiene path real (FileEntry con path absoluto/relativo).
|
||||
if let Some(first) = files.first() {
|
||||
if let Some(lens) = discern_lens(&first.path) {
|
||||
return lens;
|
||||
}
|
||||
}
|
||||
Lens::Grid
|
||||
}
|
||||
|
||||
fn discern_lens(path: &std::path::Path) -> Option<Lens> {
|
||||
use std::io::Read;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut f = std::fs::File::open(path).ok()?;
|
||||
let n = f.read(&mut buf).ok()?;
|
||||
buf.truncate(n);
|
||||
let pipeline = shuma_discern::DiscernPipeline::default_pipeline();
|
||||
let path_str = path.to_str();
|
||||
let d = pipeline.discern(
|
||||
&buf,
|
||||
&shuma_discern::Hint {
|
||||
path: path_str,
|
||||
size_total: None,
|
||||
},
|
||||
)?;
|
||||
match d.lens.as_deref()? {
|
||||
"code" => Some(Lens::Code),
|
||||
"gallery" => Some(Lens::Gallery),
|
||||
"markdown" => Some(Lens::Markdown),
|
||||
"database" => Some(Lens::Database),
|
||||
"tree" => Some(Lens::Tree),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Entropía de Shannon normalizada sobre la distribución de extensiones.
|
||||
/// `0.0` = todos los archivos comparten extensión. `1.0` = uniformly
|
||||
/// distributed entre `n` extensiones (máx información).
|
||||
fn shannon_entropy_normalized(files: &[&FileEntry]) -> f32 {
|
||||
let total = files.len() as f32;
|
||||
if total <= 1.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for f in files {
|
||||
let ext = f.extension.as_deref().unwrap_or("(none)");
|
||||
*counts.entry(ext.to_string()).or_default() += 1;
|
||||
}
|
||||
let entropy: f32 = counts
|
||||
.values()
|
||||
.map(|&c| {
|
||||
let p = c as f32 / total;
|
||||
-p * p.log2()
|
||||
})
|
||||
.sum();
|
||||
let max_entropy = (counts.len() as f32).log2();
|
||||
if max_entropy <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(entropy / max_entropy).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use akasha_card::FileId;
|
||||
use std::path::PathBuf;
|
||||
use ulid::Ulid;
|
||||
|
||||
fn mkfile(path: &str, ext: Option<&str>) -> FileEntry {
|
||||
FileEntry {
|
||||
id: FileId::from(Ulid::new()),
|
||||
path: PathBuf::from(path),
|
||||
content_hash: None,
|
||||
size: 100,
|
||||
mtime_ms: 0,
|
||||
extension: ext.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_by_parent_directory() {
|
||||
let files = vec![
|
||||
mkfile("/proj/src/a.rs", Some("rs")),
|
||||
mkfile("/proj/src/b.rs", Some("rs")),
|
||||
mkfile("/proj/src/c.rs", Some("rs")),
|
||||
mkfile("/proj/docs/readme.md", Some("md")),
|
||||
mkfile("/proj/docs/guide.md", Some("md")),
|
||||
mkfile("/proj/docs/notes.md", Some("md")),
|
||||
];
|
||||
let monads = by_directory(&files, 3);
|
||||
assert_eq!(monads.len(), 2);
|
||||
let labels: std::collections::BTreeSet<_> = monads.iter().map(|m| &m.label).collect();
|
||||
// Phase B: labels usan los últimos 2 componentes del path para
|
||||
// desambiguar (proj/src vs proj/docs en lugar de src vs docs).
|
||||
assert!(labels.iter().any(|l| l.as_str() == "proj/src"));
|
||||
assert!(labels.iter().any(|l| l.as_str() == "proj/docs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_groups_not_promoted() {
|
||||
let files = vec![
|
||||
mkfile("/proj/single.txt", Some("txt")),
|
||||
mkfile("/proj/sub/a.txt", Some("txt")),
|
||||
mkfile("/proj/sub/b.txt", Some("txt")),
|
||||
mkfile("/proj/sub/c.txt", Some("txt")),
|
||||
];
|
||||
// min=3 → /proj/single solo no se promueve, /proj/sub sí.
|
||||
let monads = by_directory(&files, 3);
|
||||
assert_eq!(monads.len(), 1);
|
||||
assert_eq!(monads[0].label, "proj/sub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_from_root_only_one_component() {
|
||||
// Un solo componente normal en el path → no hay "padre" útil.
|
||||
let p = std::path::Path::new("/onlyone");
|
||||
assert_eq!(label_from_path(p), "onlyone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_from_deep_path_takes_last_two() {
|
||||
let p = std::path::Path::new("/a/b/c/d/e");
|
||||
assert_eq!(label_from_path(p), "d/e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lens_picked_by_dominant_extension() {
|
||||
let files = vec![
|
||||
mkfile("/x/a.rs", Some("rs")),
|
||||
mkfile("/x/b.rs", Some("rs")),
|
||||
mkfile("/x/c.rs", Some("rs")),
|
||||
];
|
||||
let monads = by_directory(&files, 3);
|
||||
assert_eq!(monads[0].dominant_lens, Lens::Code);
|
||||
|
||||
let files = vec![
|
||||
mkfile("/y/1.png", Some("png")),
|
||||
mkfile("/y/2.png", Some("png")),
|
||||
mkfile("/y/3.png", Some("png")),
|
||||
];
|
||||
let monads = by_directory(&files, 3);
|
||||
assert_eq!(monads[0].dominant_lens, Lens::Gallery);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_zero_for_homogeneous() {
|
||||
let files = vec![
|
||||
mkfile("/x/a.rs", Some("rs")),
|
||||
mkfile("/x/b.rs", Some("rs")),
|
||||
mkfile("/x/c.rs", Some("rs")),
|
||||
];
|
||||
let monads = by_directory(&files, 3);
|
||||
assert_eq!(monads[0].entropy, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_high_for_diverse() {
|
||||
let files = vec![
|
||||
mkfile("/x/a.rs", Some("rs")),
|
||||
mkfile("/x/b.md", Some("md")),
|
||||
mkfile("/x/c.json", Some("json")),
|
||||
mkfile("/x/d.png", Some("png")),
|
||||
];
|
||||
let monads = by_directory(&files, 3);
|
||||
// 4 extensiones distintas, distribución uniforme → entropy ≈ 1.0
|
||||
assert!(monads[0].entropy > 0.9, "got {}", monads[0].entropy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_extensions_orders_by_freq_then_alpha() {
|
||||
let files = vec![
|
||||
mkfile("/x/a.rs", Some("rs")),
|
||||
mkfile("/x/b.rs", Some("rs")),
|
||||
mkfile("/x/c.md", Some("md")),
|
||||
mkfile("/x/d.py", Some("py")),
|
||||
];
|
||||
let refs: Vec<&FileEntry> = files.iter().collect();
|
||||
let top = top_extensions(&refs, 3);
|
||||
assert_eq!(top, vec!["rs", "md", "py"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! DB de Mónadas y archivos. Backend dual:
|
||||
//!
|
||||
//! - **Memoria** (default, cache): `BTreeMap<Id, T>` para reads O(log n).
|
||||
//! - **Persistencia** (opcional): sled-backed write-through. Si se abre
|
||||
//! con `MonadDb::open(path)`, cada `insert_*` escribe a sled además
|
||||
//! de la cache. Reads siempre vienen de la cache.
|
||||
//!
|
||||
//! Wire format: JSON via serde_json. Los manifestos son chicos y
|
||||
//! ocasionalmente inspeccionables a mano (`sled-cli`); JSON gana sobre
|
||||
//! postcard en debuggability.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use akasha_card::{FileEntry, FileId, MonadId, MonadManifest};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MonadDbError {
|
||||
#[error("sled: {0}")]
|
||||
Sled(#[from] sled::Error),
|
||||
#[error("JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("ULID inválido en clave: {0}")]
|
||||
BadKey(String),
|
||||
}
|
||||
|
||||
const TREE_FILES: &str = "files";
|
||||
const TREE_MONADS: &str = "monads";
|
||||
|
||||
/// Store de Mónadas + archivos. Cache en memoria + persistencia
|
||||
/// opcional sled.
|
||||
pub struct MonadDb {
|
||||
files: BTreeMap<FileId, FileEntry>,
|
||||
monads: BTreeMap<MonadId, MonadManifest>,
|
||||
persistence: Option<sled::Db>,
|
||||
}
|
||||
|
||||
impl Default for MonadDb {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MonadDb {
|
||||
/// Store en memoria pura (sin persistencia). El estado se pierde al salir.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
files: BTreeMap::new(),
|
||||
monads: BTreeMap::new(),
|
||||
persistence: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Abre (o crea) un store sled-backed en `path`. Carga el contenido
|
||||
/// existente a la cache antes de devolver.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, MonadDbError> {
|
||||
let db = sled::open(path)?;
|
||||
let mut files = BTreeMap::new();
|
||||
let mut monads = BTreeMap::new();
|
||||
|
||||
let files_tree = db.open_tree(TREE_FILES)?;
|
||||
for kv in files_tree.iter() {
|
||||
let (k, v) = kv?;
|
||||
let id = decode_key(&k)?;
|
||||
let entry: FileEntry = serde_json::from_slice(&v)?;
|
||||
files.insert(id, entry);
|
||||
}
|
||||
let monads_tree = db.open_tree(TREE_MONADS)?;
|
||||
for kv in monads_tree.iter() {
|
||||
let (k, v) = kv?;
|
||||
let id = decode_key(&k)?;
|
||||
let monad: MonadManifest = serde_json::from_slice(&v)?;
|
||||
monads.insert(id, monad);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
files,
|
||||
monads,
|
||||
persistence: Some(db),
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` si tiene backend persistente.
|
||||
pub fn is_persistent(&self) -> bool {
|
||||
self.persistence.is_some()
|
||||
}
|
||||
|
||||
// ---- Files ----
|
||||
|
||||
pub fn insert_file(&mut self, file: FileEntry) -> Option<FileEntry> {
|
||||
if let Some(db) = &self.persistence {
|
||||
// Write-through: si falla el persist, lo logeamos pero la
|
||||
// memoria queda actualizada. Filosofía: cache nunca miente
|
||||
// sobre el último estado conocido en este proceso.
|
||||
if let Err(e) = persist_file(db, &file) {
|
||||
eprintln!("[MonadDb] persist file falló: {e}");
|
||||
}
|
||||
}
|
||||
self.files.insert(file.id, file)
|
||||
}
|
||||
|
||||
pub fn ingest_files(&mut self, files: Vec<FileEntry>) {
|
||||
for f in files {
|
||||
self.insert_file(f);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file(&self, id: FileId) -> Option<&FileEntry> {
|
||||
self.files.get(&id)
|
||||
}
|
||||
|
||||
pub fn files(&self) -> impl Iterator<Item = &FileEntry> + '_ {
|
||||
self.files.values()
|
||||
}
|
||||
|
||||
pub fn file_count(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
|
||||
// ---- Monads ----
|
||||
|
||||
pub fn insert_monad(&mut self, monad: MonadManifest) -> Option<MonadManifest> {
|
||||
if let Some(db) = &self.persistence {
|
||||
if let Err(e) = persist_monad(db, &monad) {
|
||||
eprintln!("[MonadDb] persist monad falló: {e}");
|
||||
}
|
||||
}
|
||||
self.monads.insert(monad.id, monad)
|
||||
}
|
||||
|
||||
pub fn replace_monads(&mut self, monads: Vec<MonadManifest>) {
|
||||
// Si hay persistencia, limpiar tree antes de insertar.
|
||||
if let Some(db) = &self.persistence {
|
||||
if let Ok(tree) = db.open_tree(TREE_MONADS) {
|
||||
let _ = tree.clear();
|
||||
}
|
||||
}
|
||||
self.monads.clear();
|
||||
for m in monads {
|
||||
self.insert_monad(m);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn monad(&self, id: MonadId) -> Option<&MonadManifest> {
|
||||
self.monads.get(&id)
|
||||
}
|
||||
|
||||
pub fn monads(&self) -> impl Iterator<Item = &MonadManifest> + '_ {
|
||||
self.monads.values()
|
||||
}
|
||||
|
||||
pub fn monad_count(&self) -> usize {
|
||||
self.monads.len()
|
||||
}
|
||||
|
||||
/// Resuelve los archivos miembros de una Mónada como referencias.
|
||||
/// Skipea silenciosamente IDs que ya no estén en la tabla `files`.
|
||||
pub fn resolve_members(&self, monad_id: MonadId) -> Vec<&FileEntry> {
|
||||
match self.monads.get(&monad_id) {
|
||||
Some(m) => m.members.iter().filter_map(|id| self.files.get(id)).collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_file(db: &sled::Db, f: &FileEntry) -> Result<(), MonadDbError> {
|
||||
let tree = db.open_tree(TREE_FILES)?;
|
||||
let key = f.id.to_string();
|
||||
let val = serde_json::to_vec(f)?;
|
||||
tree.insert(key.as_bytes(), val)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_monad(db: &sled::Db, m: &MonadManifest) -> Result<(), MonadDbError> {
|
||||
let tree = db.open_tree(TREE_MONADS)?;
|
||||
let key = m.id.to_string();
|
||||
let val = serde_json::to_vec(m)?;
|
||||
tree.insert(key.as_bytes(), val)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_key(k: &[u8]) -> Result<ulid::Ulid, MonadDbError> {
|
||||
let s = std::str::from_utf8(k).map_err(|_| MonadDbError::BadKey(format!("{:?}", k)))?;
|
||||
ulid::Ulid::from_string(s).map_err(|_| MonadDbError::BadKey(s.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use akasha_card::Lens;
|
||||
use ulid::Ulid;
|
||||
|
||||
fn mk_file(path: &str) -> FileEntry {
|
||||
FileEntry {
|
||||
id: FileId::from(Ulid::new()),
|
||||
path: std::path::PathBuf::from(path),
|
||||
content_hash: None,
|
||||
size: 100,
|
||||
mtime_ms: 0,
|
||||
extension: Some("rs".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_and_lookup() {
|
||||
let mut db = MonadDb::new();
|
||||
let f1 = mk_file("/a/x.rs");
|
||||
let f2 = mk_file("/a/y.rs");
|
||||
let id1 = f1.id;
|
||||
db.ingest_files(vec![f1, f2]);
|
||||
assert_eq!(db.file_count(), 2);
|
||||
assert!(db.file(id1).is_some());
|
||||
assert!(!db.is_persistent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_members_filters_missing() {
|
||||
let mut db = MonadDb::new();
|
||||
let f1 = mk_file("/x/a.rs");
|
||||
let id1 = f1.id;
|
||||
db.insert_file(f1);
|
||||
|
||||
let mut m = MonadManifest::new("test");
|
||||
m.members.insert(id1);
|
||||
m.members.insert(FileId::from(Ulid::new())); // miembro fantasma
|
||||
m.dominant_lens = Lens::Code;
|
||||
m.touch();
|
||||
|
||||
let mid = m.id;
|
||||
db.insert_monad(m);
|
||||
|
||||
let resolved = db.resolve_members(mid);
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].id, id1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_monads_clears_old() {
|
||||
let mut db = MonadDb::new();
|
||||
let mut m1 = MonadManifest::new("a");
|
||||
m1.members.insert(FileId::from(Ulid::new()));
|
||||
m1.touch();
|
||||
db.insert_monad(m1);
|
||||
assert_eq!(db.monad_count(), 1);
|
||||
|
||||
let mut m2 = MonadManifest::new("b");
|
||||
m2.members.insert(FileId::from(Ulid::new()));
|
||||
m2.touch();
|
||||
db.replace_monads(vec![m2]);
|
||||
assert_eq!(db.monad_count(), 1);
|
||||
assert!(db.monads().next().unwrap().label == "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dbpath = tmp.path().join("monads.sled");
|
||||
|
||||
// Escribimos algunos datos
|
||||
{
|
||||
let mut db = MonadDb::open(&dbpath).expect("open");
|
||||
assert!(db.is_persistent());
|
||||
let f = mk_file("/persist/a.rs");
|
||||
let fid = f.id;
|
||||
db.insert_file(f);
|
||||
|
||||
let mut m = MonadManifest::new("persist-test");
|
||||
m.members.insert(fid);
|
||||
m.dominant_lens = Lens::Code;
|
||||
m.touch();
|
||||
db.insert_monad(m);
|
||||
}
|
||||
|
||||
// Reabrimos y verificamos que están
|
||||
let db = MonadDb::open(&dbpath).expect("reopen");
|
||||
assert_eq!(db.file_count(), 1);
|
||||
assert_eq!(db.monad_count(), 1);
|
||||
let m = db.monads().next().unwrap();
|
||||
assert_eq!(m.label, "persist-test");
|
||||
assert_eq!(m.cardinality, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_monads_purges_persistent_tree() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dbpath = tmp.path().join("replace.sled");
|
||||
|
||||
{
|
||||
let mut db = MonadDb::open(&dbpath).unwrap();
|
||||
let mut m1 = MonadManifest::new("old");
|
||||
m1.members.insert(FileId::from(Ulid::new()));
|
||||
m1.touch();
|
||||
db.insert_monad(m1);
|
||||
}
|
||||
|
||||
// Reabrir, replace, verificar
|
||||
{
|
||||
let mut db = MonadDb::open(&dbpath).unwrap();
|
||||
assert_eq!(db.monad_count(), 1);
|
||||
let mut m2 = MonadManifest::new("new");
|
||||
m2.members.insert(FileId::from(Ulid::new()));
|
||||
m2.touch();
|
||||
db.replace_monads(vec![m2]);
|
||||
assert_eq!(db.monad_count(), 1);
|
||||
}
|
||||
|
||||
// Tercera apertura: sólo "new" sobrevive
|
||||
let db = MonadDb::open(&dbpath).unwrap();
|
||||
assert_eq!(db.monad_count(), 1);
|
||||
assert_eq!(db.monads().next().unwrap().label, "new");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Pseudo-embeddings de archivos: vectores deterministas derivados de
|
||||
//! metadatos (sin LLM).
|
||||
//!
|
||||
//! Implementan el "imán semántico" matemático que el diseño de Kairos
|
||||
//! pide: cada archivo tiene un vector, cada Mónada tiene un centroide,
|
||||
//! y un archivo nuevo se "pega" a la Mónada cuyo centroide está más
|
||||
//! cerca (cosine similarity).
|
||||
//!
|
||||
//! No reemplaza embeddings reales (text-embedding de un LLM); sirve para:
|
||||
//! - Bootstrapping sin Nous corriendo.
|
||||
//! - Mock determinístico en `BRAHMAN_BROKER_CONTEXT=test`.
|
||||
//! - Cohesión visual por path/extension (dos `.rs` en `src/` quedan
|
||||
//! muy juntos en el espacio vectorial).
|
||||
//!
|
||||
//! ## Forma del vector ([`EMBED_DIM`]=32, normalizado)
|
||||
//!
|
||||
//! - dims 0..8: `blake3(extension)` → identidad de tipo
|
||||
//! - dims 8..16: `blake3(parent_dir)` → identidad de contenedor
|
||||
//! - dims 16..24: `blake3(file_stem)` → identidad léxica del archivo
|
||||
//! - dims 24..28: tamaño (log scale + flags binarios)
|
||||
//! - dims 28..32: mtime (escala día + features cíclicas)
|
||||
//!
|
||||
//! ## Propiedades empíricas
|
||||
//!
|
||||
//! - Mismo dir + misma ext → similitud > 0.7 (alta cohesión).
|
||||
//! - Mismo dir + ext distinta → similitud ~ 0.5.
|
||||
//! - Dirs distintos + misma ext → similitud ~ 0.5.
|
||||
//! - Sin parecido → similitud < 0.3.
|
||||
|
||||
use akasha_card::{FileEntry, MonadId, MonadManifest};
|
||||
|
||||
/// Dimensión del vector embedding.
|
||||
pub const EMBED_DIM: usize = 32;
|
||||
|
||||
/// Identificador del modelo que produce este embedding. Se usa para
|
||||
/// taggear `MonadManifest.centroid_model`: los consumidores comparan
|
||||
/// este string contra el suyo antes de hacer cosine similarity.
|
||||
/// Mezclar centroides de distinto MODEL_ID corrompe scores
|
||||
/// silenciosamente (dimensiones distintas, semántica distinta).
|
||||
pub const MODEL_ID: &str = "akasha-pseudo-32d";
|
||||
|
||||
/// Computa el embedding de un archivo. Determinístico: misma input
|
||||
/// → mismo vector. El vector queda L2-normalizado.
|
||||
pub fn embed(file: &FileEntry) -> [f32; EMBED_DIM] {
|
||||
let mut v = [0.0f32; EMBED_DIM];
|
||||
|
||||
// dims 0..8: extension hash
|
||||
fill_from_hash(&mut v[0..8], file.extension.as_deref().unwrap_or(""));
|
||||
|
||||
// dims 8..16: parent dir name hash
|
||||
let parent = file
|
||||
.path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
fill_from_hash(&mut v[8..16], parent);
|
||||
|
||||
// dims 16..24: file stem hash (sin extensión)
|
||||
let stem = file
|
||||
.path
|
||||
.file_stem()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
fill_from_hash(&mut v[16..24], stem);
|
||||
|
||||
// dims 24..28: tamaño (centrado en 0 para que dot products entre
|
||||
// archivos de tamaño diferente sumen 0 en expectativa).
|
||||
let log_size = (file.size.max(1) as f32).log10();
|
||||
v[24] = ((log_size / 15.0).clamp(0.0, 1.0) - 0.5) * 2.0; // [-1, 1]
|
||||
v[25] = (log_size.fract() - 0.5) * 2.0;
|
||||
v[26] = if file.size >= 1_048_576 { 1.0 } else { -1.0 }; // ≥1MiB flag
|
||||
v[27] = if file.size <= 256 { 1.0 } else { -1.0 }; // ≤256B flag
|
||||
|
||||
// dims 28..32: mtime — escala día + cíclicas (centradas).
|
||||
let day = file.mtime_ms / (86_400 * 1000);
|
||||
v[28] = (((day as f32) / 30_000.0).clamp(0.0, 1.0) - 0.5) * 2.0;
|
||||
v[29] = ((day % 365) as f32 / 365.0 - 0.5) * 2.0;
|
||||
v[30] = ((day % 30) as f32 / 30.0 - 0.5) * 2.0;
|
||||
v[31] = ((day % 7) as f32 / 7.0 - 0.5) * 2.0;
|
||||
|
||||
normalize(&mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// Fill `out` con bytes del hash blake3 de `input`, centrados en [-1, 1].
|
||||
/// El centrado es crítico: bytes uniformes en [0,1] tienen media 0.5,
|
||||
/// así dos vectores hash distintos (de strings no relacionados) tendrían
|
||||
/// expected cosine similarity ≈ 0.75 (espuriamente alto). Centrarlos en
|
||||
/// [-1, 1] hace que la expectativa sea ≈ 0 — propiedad necesaria para
|
||||
/// que cosine similarity sea una métrica útil de afinidad.
|
||||
fn fill_from_hash(out: &mut [f32], input: &str) {
|
||||
let h = blake3::hash(input.as_bytes());
|
||||
let bytes = h.as_bytes();
|
||||
for (i, slot) in out.iter_mut().enumerate() {
|
||||
*slot = (bytes[i] as f32 - 127.5) / 127.5;
|
||||
}
|
||||
}
|
||||
|
||||
/// L2-normaliza un vector in-place. Vectores con norma 0 quedan en 0.
|
||||
fn normalize(v: &mut [f32]) {
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
for x in v.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cosine similarity entre dos vectores. Asume ambos L2-normalizados
|
||||
/// (en cuyo caso `dot product == cosine similarity`). Si las longitudes
|
||||
/// no coinciden, devuelve 0.
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
/// Centroide de un set de vectores. Promedio dim-por-dim seguido de
|
||||
/// L2-normalización. El resultado es un vector unidad apto para
|
||||
/// comparar con miembros nuevos vía cosine similarity.
|
||||
pub fn centroid(vectors: &[Vec<f32>]) -> Vec<f32> {
|
||||
if vectors.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let dim = vectors[0].len();
|
||||
let mut c = vec![0.0f32; dim];
|
||||
for v in vectors {
|
||||
if v.len() != dim {
|
||||
continue;
|
||||
}
|
||||
for (i, x) in v.iter().enumerate() {
|
||||
c[i] += x;
|
||||
}
|
||||
}
|
||||
let n = vectors.len() as f32;
|
||||
for x in c.iter_mut() {
|
||||
*x /= n;
|
||||
}
|
||||
normalize(&mut c);
|
||||
c
|
||||
}
|
||||
|
||||
/// Cohesión interna: media de cosine similarity de cada miembro contra
|
||||
/// el centroide. Alta cohesión = Mónada compacta. Baja = bifurcable.
|
||||
pub fn cohesion(centroid: &[f32], member_vectors: &[Vec<f32>]) -> f32 {
|
||||
if member_vectors.is_empty() || centroid.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum: f32 = member_vectors
|
||||
.iter()
|
||||
.map(|v| cosine_similarity(centroid, v))
|
||||
.sum();
|
||||
sum / member_vectors.len() as f32
|
||||
}
|
||||
|
||||
/// Score de atracción de un archivo nuevo a una Mónada existente:
|
||||
/// cosine similarity de su embedding contra el centroide de la Mónada.
|
||||
/// Mayor score = mayor afinidad.
|
||||
pub fn attraction_score(file_vec: &[f32], monad: &MonadManifest) -> f32 {
|
||||
if monad.centroid.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
cosine_similarity(file_vec, &monad.centroid)
|
||||
}
|
||||
|
||||
/// Encuentra la Mónada con mayor afinidad a un archivo. Devuelve
|
||||
/// `(MonadId, score)` o `None` si ninguna tiene centroide.
|
||||
pub fn best_attraction<'a, I>(file_vec: &[f32], monads: I) -> Option<(MonadId, f32)>
|
||||
where
|
||||
I: IntoIterator<Item = &'a MonadManifest>,
|
||||
{
|
||||
monads
|
||||
.into_iter()
|
||||
.filter(|m| !m.centroid.is_empty())
|
||||
.map(|m| (m.id, attraction_score(file_vec, m)))
|
||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
}
|
||||
|
||||
/// Umbral por defecto para "se pega": si el score es ≥ esto, el
|
||||
/// archivo se asigna automáticamente. Ajustable por el caller.
|
||||
pub const DEFAULT_ATTRACTION_THRESHOLD: f32 = 0.7;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use akasha_card::FileId;
|
||||
use std::path::PathBuf;
|
||||
use ulid::Ulid;
|
||||
|
||||
fn mk(path: &str, ext: Option<&str>, size: u64) -> FileEntry {
|
||||
FileEntry {
|
||||
id: FileId::from(Ulid::new()),
|
||||
path: PathBuf::from(path),
|
||||
content_hash: None,
|
||||
size,
|
||||
mtime_ms: 1_700_000_000_000, // fixed para que mtime no domine
|
||||
extension: ext.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_is_deterministic() {
|
||||
let a = mk("/x/foo.rs", Some("rs"), 1024);
|
||||
let b = mk("/x/foo.rs", Some("rs"), 1024);
|
||||
let va = embed(&a);
|
||||
let vb = embed(&b);
|
||||
// Mismos metadatos → mismo vector (los IDs no entran al embedding).
|
||||
assert_eq!(va, vb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_is_unit_normalized() {
|
||||
let f = mk("/x/foo.rs", Some("rs"), 1024);
|
||||
let v = embed(&f);
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_dir_same_ext_high_similarity() {
|
||||
let a = embed(&mk("/proj/src/a.rs", Some("rs"), 1000));
|
||||
let b = embed(&mk("/proj/src/b.rs", Some("rs"), 1100));
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!(sim > 0.7, "esperaba sim > 0.7, fue {sim}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_files_low_similarity() {
|
||||
let a = embed(&mk("/proj/src/main.rs", Some("rs"), 1000));
|
||||
let b = embed(&mk("/photos/2024/sunset.jpg", Some("jpg"), 5_000_000));
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!(sim < 0.5, "esperaba sim < 0.5, fue {sim}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centroid_is_unit_and_close_to_members() {
|
||||
let v1 = embed(&mk("/x/a.rs", Some("rs"), 1000));
|
||||
let v2 = embed(&mk("/x/b.rs", Some("rs"), 1100));
|
||||
let v3 = embed(&mk("/x/c.rs", Some("rs"), 1200));
|
||||
let c = centroid(&[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
|
||||
|
||||
// Norma unitaria.
|
||||
let norm: f32 = c.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
|
||||
|
||||
// Cohesión alta porque los miembros son similares.
|
||||
let cohesion = cohesion(&c, &[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
|
||||
assert!(cohesion > 0.9, "cohesion={cohesion}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attraction_picks_correct_monad() {
|
||||
// Construimos dos Mónadas: una de Rust, otra de imágenes.
|
||||
let rust_files = vec![
|
||||
embed(&mk("/proj/src/a.rs", Some("rs"), 1000)).to_vec(),
|
||||
embed(&mk("/proj/src/b.rs", Some("rs"), 1100)).to_vec(),
|
||||
];
|
||||
let img_files = vec![
|
||||
embed(&mk("/photos/p1.jpg", Some("jpg"), 5_000_000)).to_vec(),
|
||||
embed(&mk("/photos/p2.jpg", Some("jpg"), 4_000_000)).to_vec(),
|
||||
];
|
||||
|
||||
let mut rust_monad = MonadManifest::new("rust");
|
||||
rust_monad.members.insert(FileId::from(Ulid::new()));
|
||||
rust_monad.touch();
|
||||
rust_monad.centroid = centroid(&rust_files);
|
||||
|
||||
let mut img_monad = MonadManifest::new("photos");
|
||||
img_monad.members.insert(FileId::from(Ulid::new()));
|
||||
img_monad.touch();
|
||||
img_monad.centroid = centroid(&img_files);
|
||||
|
||||
// Un archivo .rs nuevo en /proj/src debe atraerse a la Mónada Rust.
|
||||
let new_rs = embed(&mk("/proj/src/new.rs", Some("rs"), 1500));
|
||||
let (best_id, _score) = best_attraction(&new_rs, [&rust_monad, &img_monad].into_iter())
|
||||
.expect("best match");
|
||||
assert_eq!(best_id, rust_monad.id);
|
||||
|
||||
// Y al revés.
|
||||
let new_jpg = embed(&mk("/photos/new.jpg", Some("jpg"), 6_000_000));
|
||||
let (best_id, _score) = best_attraction(&new_jpg, [&rust_monad, &img_monad].into_iter())
|
||||
.expect("best match");
|
||||
assert_eq!(best_id, img_monad.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_centroid_skipped_in_attraction() {
|
||||
let mut m = MonadManifest::new("empty");
|
||||
m.members.insert(FileId::from(Ulid::new()));
|
||||
m.touch();
|
||||
// m.centroid queda vacío
|
||||
|
||||
let v = embed(&mk("/x/y.rs", Some("rs"), 100));
|
||||
assert!(best_attraction(&v, [&m].into_iter()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Listener Unix-socket que sirve [`akasha_card::query::QueryRequest`].
|
||||
//!
|
||||
//! El daemon `akasha` lo monta para que cualquier consumer (UI, CLI,
|
||||
//! otro módulo) pueda preguntarle por sus Mónadas sin pasar por
|
||||
//! brahman-admin. El path del socket viaja en el `Card.service_socket`
|
||||
//! del engine; el broker brahman lo enseña vía MatchEvent::Available
|
||||
//! cuando un consumer declara `flow.input = monad-list:json`.
|
||||
//!
|
||||
//! Wire: line-delimited JSON, single-shot por conexión. Mismo patrón
|
||||
//! que `akasha-nous` (mock/real ↔ akasha-core), reutilizado.
|
||||
//!
|
||||
//! Threading: un thread dedicado, blocking I/O. No vale la pena traer
|
||||
//! tokio acá — la frecuencia esperada es muy baja (UI poll cada 2s)
|
||||
//! y el handler es trivial (lock db → snapshot → write).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use akasha_card::query::{
|
||||
EngineInfo, ErrorResponse, ListMonadsResponse, MonadView, QueryRequest,
|
||||
};
|
||||
use akasha_card::ulid::Ulid;
|
||||
|
||||
use crate::db::MonadDb;
|
||||
|
||||
/// Configuración del listener.
|
||||
pub struct ListenerConfig {
|
||||
pub socket_path: PathBuf,
|
||||
pub engine_id: Ulid,
|
||||
pub engine_label: String,
|
||||
/// Path del directorio que el daemon está observando, para incluir
|
||||
/// en `EngineInfo.watching`. `None` si el daemon no observa nada.
|
||||
pub watching: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Bind del socket + spawn de un thread con accept loop. Devuelve el
|
||||
/// path final (útil para confirmar) y un `JoinHandle` para shutdown
|
||||
/// explícito (drop = thread sigue, listener queda).
|
||||
///
|
||||
/// Si el socket ya existe (sesión anterior crasheada), se intenta
|
||||
/// removerlo antes del bind. Errores de bind se propagan al caller.
|
||||
pub fn spawn_listener(
|
||||
config: ListenerConfig,
|
||||
db: Arc<Mutex<MonadDb>>,
|
||||
) -> std::io::Result<std::thread::JoinHandle<()>> {
|
||||
if config.socket_path.exists() {
|
||||
let _ = std::fs::remove_file(&config.socket_path);
|
||||
}
|
||||
if let Some(parent) = config.socket_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let listener = UnixListener::bind(&config.socket_path)?;
|
||||
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("akasha-engine-listener".into())
|
||||
.spawn(move || {
|
||||
for conn in listener.incoming() {
|
||||
match conn {
|
||||
Ok(stream) => {
|
||||
// Handler sincrónico inline. La frecuencia
|
||||
// esperada (UI poll cada N segundos) no
|
||||
// amerita spawn-per-connection; si en el
|
||||
// futuro hay carga, agregar un threadpool.
|
||||
if let Err(e) = handle_conn(stream, &db, &config) {
|
||||
eprintln!("[engine-socket] conn falló: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[engine-socket] accept falló: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn handle_conn(
|
||||
mut stream: UnixStream,
|
||||
db: &Arc<Mutex<MonadDb>>,
|
||||
config: &ListenerConfig,
|
||||
) -> std::io::Result<()> {
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line)?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let resp_bytes = match serde_json::from_str::<QueryRequest>(line.trim()) {
|
||||
Ok(QueryRequest::ListMonads) => match handle_list_monads(db, config) {
|
||||
Ok(json) => json,
|
||||
Err(e) => encode_error(format!("list_monads falló: {e}")),
|
||||
},
|
||||
Err(e) => encode_error(format!("JSON inválido: {e}")),
|
||||
};
|
||||
|
||||
stream.write_all(resp_bytes.as_bytes())?;
|
||||
stream.write_all(b"\n")?;
|
||||
stream.flush()?;
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_list_monads(
|
||||
db: &Arc<Mutex<MonadDb>>,
|
||||
config: &ListenerConfig,
|
||||
) -> Result<String, String> {
|
||||
let db_lock = db.lock().map_err(|_| "mutex envenenado".to_string())?;
|
||||
let monads: Vec<MonadView> = db_lock.monads().map(MonadView::from_manifest).collect();
|
||||
let resp = ListMonadsResponse {
|
||||
engine: EngineInfo {
|
||||
id: config.engine_id,
|
||||
label: config.engine_label.clone(),
|
||||
watching: config.watching.as_ref().map(|p| p.display().to_string()),
|
||||
},
|
||||
monads,
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
fn encode_error(msg: String) -> String {
|
||||
let err = ErrorResponse { error: msg };
|
||||
serde_json::to_string(&err).unwrap_or_else(|_| "{\"error\":\"encode\"}".into())
|
||||
}
|
||||
|
||||
// El cliente blocking vive en `akasha_card::query::client` — junto a
|
||||
// los wire types — para que un consumer pueda hablar con el daemon
|
||||
// importando sólo `akasha-card`, sin arrastrar el peso de
|
||||
// `akasha-core` (scanner / db / sled / notify / walkdir / blake3).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::MonadDb;
|
||||
use akasha_card::query::client as query_client;
|
||||
use akasha_card::MonadManifest;
|
||||
use std::time::Duration;
|
||||
|
||||
fn fresh_socket_path(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir();
|
||||
let unique = format!("{}-{}-{}.sock", name, std::process::id(), Ulid::new());
|
||||
dir.join(unique)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_monads_roundtrip_empty() {
|
||||
let socket = fresh_socket_path("akasha-engine-test");
|
||||
let db = Arc::new(Mutex::new(MonadDb::new()));
|
||||
let engine_id = Ulid::new();
|
||||
let _h = spawn_listener(
|
||||
ListenerConfig {
|
||||
socket_path: socket.clone(),
|
||||
engine_id,
|
||||
engine_label: "test-engine".into(),
|
||||
watching: Some(PathBuf::from("/tmp/x")),
|
||||
},
|
||||
db.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Pequeña espera para que el bind se asiente (en práctica el
|
||||
// socket existe inmediatamente tras el bind, pero algunos FS
|
||||
// necesitan un tick). Si esto resulta flaky, agregar un loop
|
||||
// de wait_for(socket.exists()).
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let resp = query_client::list_monads(&socket, Duration::from_secs(2)).unwrap();
|
||||
assert_eq!(resp.engine.id, engine_id);
|
||||
assert_eq!(resp.engine.label, "test-engine");
|
||||
assert_eq!(resp.engine.watching.as_deref(), Some("/tmp/x"));
|
||||
assert!(resp.monads.is_empty());
|
||||
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_monads_returns_views() {
|
||||
let socket = fresh_socket_path("akasha-engine-test-views");
|
||||
let db = Arc::new(Mutex::new(MonadDb::new()));
|
||||
let m1 = MonadManifest::new("alpha");
|
||||
let m2 = MonadManifest::new("beta");
|
||||
{
|
||||
let mut g = db.lock().unwrap();
|
||||
g.replace_monads(vec![m1.clone(), m2.clone()]);
|
||||
}
|
||||
let _h = spawn_listener(
|
||||
ListenerConfig {
|
||||
socket_path: socket.clone(),
|
||||
engine_id: Ulid::new(),
|
||||
engine_label: "test".into(),
|
||||
watching: None,
|
||||
},
|
||||
db.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let resp = query_client::list_monads(&socket, Duration::from_secs(2)).unwrap();
|
||||
assert_eq!(resp.monads.len(), 2);
|
||||
let labels: Vec<_> = resp.monads.iter().map(|m| m.label.as_str()).collect();
|
||||
assert!(labels.contains(&"alpha"));
|
||||
assert!(labels.contains(&"beta"));
|
||||
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_request_returns_error_response() {
|
||||
let socket = fresh_socket_path("akasha-engine-test-bad");
|
||||
let db = Arc::new(Mutex::new(MonadDb::new()));
|
||||
let _h = spawn_listener(
|
||||
ListenerConfig {
|
||||
socket_path: socket.clone(),
|
||||
engine_id: Ulid::new(),
|
||||
engine_label: "test".into(),
|
||||
watching: None,
|
||||
},
|
||||
db.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Bypass del cliente tipado: mandamos JSON inválido a mano.
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(b"not json\n").unwrap();
|
||||
stream.flush().unwrap();
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
|
||||
assert!(
|
||||
response.contains("\"error\""),
|
||||
"esperaba ErrorResponse, got: {response}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! `akasha-core` — el explorador de Mónadas.
|
||||
//!
|
||||
//! Implementa la pipeline determinista descrita en el diseño de Kairos:
|
||||
//!
|
||||
//! 1. [`scanner`]: recorre directorios y emite [`FileEntry`] (sin tocar
|
||||
//! contenido en Phase 0 — sólo metadatos).
|
||||
//! 2. [`cluster`]: agrupa archivos en [`MonadManifest`] usando
|
||||
//! heurísticas (parent dir + extensión dominante). 0 LLM.
|
||||
//! 3. [`db`]: store en memoria con índices files↔monads.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//! ```text
|
||||
//! scan_directory(path)
|
||||
//! → Vec<FileEntry>
|
||||
//! → cluster::by_directory(min_files=N)
|
||||
//! → Vec<MonadManifest>
|
||||
//! → MonadDb::ingest(...)
|
||||
//! ```
|
||||
//!
|
||||
//! Lo importante: en este crate no hay IA, no hay embeddings. Es la
|
||||
//! capa determinista que cubre el 90% de los casos. Los embeddings
|
||||
//! (`Phase C`) y Nous (`Phase D`) se enchufan después como módulos
|
||||
//! separados que producen flows brahman.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
pub mod cluster;
|
||||
pub mod db;
|
||||
pub mod embed;
|
||||
pub mod engine_socket;
|
||||
pub mod scanner;
|
||||
|
||||
pub use akasha_card::*;
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Recorrido de directorios. Sólo metadatos — no lee contenido.
|
||||
//!
|
||||
//! Usa `walkdir` (sequential). Para árboles muy grandes considerar
|
||||
//! migrar a `jwalk` (paralelo); por ahora la simplicidad gana.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use akasha_card::{FileEntry, FileId};
|
||||
use thiserror::Error;
|
||||
use ulid::Ulid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ScanError {
|
||||
#[error("ruta no existe: {0}")]
|
||||
NotFound(PathBuf),
|
||||
#[error("no se pudo leer: {0}")]
|
||||
Walk(String),
|
||||
}
|
||||
|
||||
/// Configuración del scan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScanConfig {
|
||||
/// Profundidad máxima (None = ilimitada).
|
||||
pub max_depth: Option<usize>,
|
||||
/// Sigue symlinks (default: false, evita ciclos).
|
||||
pub follow_links: bool,
|
||||
/// Ignora archivos ocultos (.dotfiles).
|
||||
pub skip_hidden: bool,
|
||||
}
|
||||
|
||||
impl Default for ScanConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_depth: None,
|
||||
follow_links: false,
|
||||
skip_hidden: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recorre `root` y devuelve un `FileEntry` por cada archivo regular.
|
||||
/// Errores de permisos en sub-paths se ignoran silenciosamente.
|
||||
pub fn scan_directory(root: &Path, config: &ScanConfig) -> Result<Vec<FileEntry>, ScanError> {
|
||||
if !root.exists() {
|
||||
return Err(ScanError::NotFound(root.to_path_buf()));
|
||||
}
|
||||
|
||||
let mut walker = WalkDir::new(root).follow_links(config.follow_links);
|
||||
if let Some(d) = config.max_depth {
|
||||
walker = walker.max_depth(d);
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for entry_result in walker {
|
||||
let entry = match entry_result {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
if config.skip_hidden && is_hidden(entry.path()) {
|
||||
continue;
|
||||
}
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mtime_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let extension = entry
|
||||
.path()
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_lowercase());
|
||||
|
||||
entries.push(FileEntry {
|
||||
id: FileId::from(Ulid::new()),
|
||||
path: entry.path().to_path_buf(),
|
||||
content_hash: None,
|
||||
size: metadata.len(),
|
||||
mtime_ms,
|
||||
extension,
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// `true` si alguno de los componentes del path empieza con `.`.
|
||||
/// Excluye el primer componente (root) para no descartar el directorio raíz
|
||||
/// si el usuario apuntó a un dotfile-dir explícito.
|
||||
fn is_hidden(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn write(path: &Path, content: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scans_basic_tree() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("a.rs"), "fn main(){}");
|
||||
write(&root.join("b.rs"), "fn b(){}");
|
||||
write(&root.join("data/x.json"), "{}");
|
||||
write(&root.join("data/y.json"), "{}");
|
||||
|
||||
let files = scan_directory(root, &ScanConfig::default()).unwrap();
|
||||
assert_eq!(files.len(), 4);
|
||||
let exts: std::collections::BTreeSet<_> = files
|
||||
.iter()
|
||||
.filter_map(|f| f.extension.clone())
|
||||
.collect();
|
||||
assert!(exts.contains("rs"));
|
||||
assert!(exts.contains("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_hidden_by_default() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("visible.txt"), "x");
|
||||
write(&root.join(".hidden"), "x");
|
||||
|
||||
let files = scan_directory(root, &ScanConfig::default()).unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0].path.ends_with("visible.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_root_errors() {
|
||||
let p = std::path::Path::new("/nonexistent-12345-abc");
|
||||
assert!(matches!(
|
||||
scan_directory(p, &ScanConfig::default()),
|
||||
Err(ScanError::NotFound(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_depth_limits() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("top.txt"), "x");
|
||||
write(&root.join("a/b/deep.txt"), "x");
|
||||
|
||||
let cfg = ScanConfig {
|
||||
max_depth: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let files = scan_directory(root, &cfg).unwrap();
|
||||
// max_depth=1 incluye archivos en root pero no anidados profundos.
|
||||
let names: Vec<_> = files
|
||||
.iter()
|
||||
.filter_map(|f| f.path.file_name().and_then(|s| s.to_str()))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
assert!(names.contains(&"top.txt".to_string()));
|
||||
assert!(!names.contains(&"deep.txt".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "akasha-nous-mock"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Nouser — Nous mock determinístico: implementa el contrato nouser-nous con pseudo-embeddings de Phase C. Stand-in para tests y para `BRAHMAN_BROKER_CONTEXT=test`."
|
||||
|
||||
[dependencies]
|
||||
brahman-card = { path = "../../../protocol/brahman-card" }
|
||||
brahman-sidecar = { path = "../../../protocol/brahman-sidecar" }
|
||||
akasha-card = { path = "../card" }
|
||||
akasha-core = { path = "../core" }
|
||||
akasha-nous = { path = "../nous" }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "akasha-nous-mock"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,247 @@
|
||||
//! `akasha-nous-mock` — proveedor de embeddings determinista (sin LLM).
|
||||
//!
|
||||
//! Implementa el contrato `akasha-nous` usando los pseudo-embeddings
|
||||
//! de Phase C (`akasha_core::embed`). Sirve como:
|
||||
//!
|
||||
//! - **Mock para tests**: en `BRAHMAN_BROKER_CONTEXT=test`, el
|
||||
//! `priority_offset` per-contexto declarado en su Card lo prioriza
|
||||
//! sobre cualquier proveedor real.
|
||||
//! - **Bootstrap**: hasta que llegue el LLM real (Phase D futura), el
|
||||
//! sistema funciona end-to-end con embeddings determinísticos.
|
||||
//!
|
||||
//! ## Vida del proceso
|
||||
//!
|
||||
//! 1. Sidecarea a brahman-init declarando una Card con flow output
|
||||
//! `embed-result:json` y flow input `embed-request:json`. Su
|
||||
//! `priority_contexts.test = { priority_offset: +1 }` lo prioriza
|
||||
//! cuando el broker corre bajo contexto test.
|
||||
//! 2. Bind del Unix socket en `$NOUSER_NOUS_SOCKET` (default
|
||||
//! `$XDG_RUNTIME_DIR/akasha-nous.sock`).
|
||||
//! 3. Loop: accept → read line JSON → process → write line JSON → close.
|
||||
//! 4. Cada request se loggea (info) — útil para verificar que el
|
||||
//! consumidor está usando este proveedor.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use brahman_card::{
|
||||
ulid::Ulid, Card, CardKind, ContextBias, Flow, Flows, Lifecycle, Payload, Priority,
|
||||
Supervision, TypeRef,
|
||||
};
|
||||
use akasha_card::FileEntry;
|
||||
use akasha_core::embed;
|
||||
use akasha_nous::{
|
||||
transport, EmbedFilePayload, EmbedRequest, EmbedResponse, EmbedTextPayload, ErrorResponse,
|
||||
PingResponse, RequestKind, FLOW_EMBED_REQUEST, FLOW_EMBED_RESULT, FLOW_TYPE_NAME,
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// El mock implementa el MISMO algoritmo que `akasha_core::embed`,
|
||||
/// así que reportamos el mismo `MODEL_ID` que él. De otro modo el
|
||||
/// consumer filtraría las Mónadas como "modelo distinto" y los
|
||||
/// scores quedarían vacíos.
|
||||
const MODEL_ID: &str = akasha_core::embed::MODEL_ID;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
init_tracing();
|
||||
|
||||
// 1. Resolver socket del data-plane ANTES de armar la Card, para
|
||||
// declararlo en `Card.service_socket` y que los consumidores lo
|
||||
// descubran vía MatchEvent.
|
||||
let sock_path = transport::provider_socket_path("mock");
|
||||
if sock_path.exists() {
|
||||
std::fs::remove_file(&sock_path)?;
|
||||
}
|
||||
if let Some(parent) = sock_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let listener = UnixListener::bind(&sock_path)?;
|
||||
info!(socket = %sock_path.display(), "akasha-nous-mock escuchando");
|
||||
|
||||
// 2. Sidecar al brahman-init con la Card que declara el socket.
|
||||
let card = build_card(sock_path.clone());
|
||||
info!(label = %card.label, "publicando Card al brahman-init");
|
||||
brahman_sidecar::spawn(card);
|
||||
|
||||
// 3. Accept loop.
|
||||
loop {
|
||||
let (stream, _addr) = listener.accept().await?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_conn(stream).await {
|
||||
warn!(error = %e, "conn falló");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
/// Card que el mock anuncia al brahman-init. Es kind=Ente (un proceso),
|
||||
/// con flujos JSON, bias de prioridad para contexto `test`, y el socket
|
||||
/// data-plane declarado en `service_socket` (consumidores lo reciben
|
||||
/// directo en el `MatchEvent::Available`).
|
||||
fn build_card(service_socket: std::path::PathBuf) -> Card {
|
||||
let mut priority_contexts = BTreeMap::new();
|
||||
priority_contexts.insert(
|
||||
"test".into(),
|
||||
ContextBias {
|
||||
pin_to: None,
|
||||
// En contexto test, este mock gana sobre cualquier real-nous.
|
||||
priority_offset: 1,
|
||||
},
|
||||
);
|
||||
|
||||
Card {
|
||||
schema_version: brahman_card::CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
label: "akasha.nous_mock".into(),
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::Delegate,
|
||||
lifecycle: Lifecycle::Daemon,
|
||||
priority: Priority::Normal,
|
||||
kind: CardKind::Ente,
|
||||
service_socket: Some(service_socket),
|
||||
flow: Flows {
|
||||
input: vec![Flow {
|
||||
name: FLOW_EMBED_REQUEST.into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: FLOW_TYPE_NAME.into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
output: vec![Flow {
|
||||
name: FLOW_EMBED_RESULT.into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: FLOW_TYPE_NAME.into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
},
|
||||
priority_contexts,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Procesa una conexión single-shot: lee una línea JSON, despacha,
|
||||
/// escribe una línea JSON, cierra.
|
||||
async fn handle_conn(stream: UnixStream) -> std::io::Result<()> {
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let req: EmbedRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return write_error(reader.into_inner(), format!("JSON inválido: {e}")).await;
|
||||
}
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
let result = match req.kind {
|
||||
RequestKind::EmbedFile => handle_embed_file(req.payload, started),
|
||||
RequestKind::EmbedText => handle_embed_text(req.payload, started),
|
||||
RequestKind::Ping => handle_ping(),
|
||||
};
|
||||
|
||||
let mut stream = reader.into_inner();
|
||||
match result {
|
||||
Ok(payload) => {
|
||||
stream.write_all(payload.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
}
|
||||
Err(msg) => {
|
||||
return write_error(stream, msg).await;
|
||||
}
|
||||
}
|
||||
stream.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_embed_file(payload: serde_json::Value, started: Instant) -> Result<String, String> {
|
||||
let p: EmbedFilePayload =
|
||||
serde_json::from_value(payload).map_err(|e| format!("payload inválido: {e}"))?;
|
||||
info!(path = %p.path, "embed_file");
|
||||
|
||||
let file = FileEntry {
|
||||
id: akasha_card::FileId::from(Ulid::new()),
|
||||
path: PathBuf::from(p.path),
|
||||
content_hash: None,
|
||||
size: p.size,
|
||||
mtime_ms: p.mtime_ms,
|
||||
extension: p.extension,
|
||||
};
|
||||
let v = embed::embed(&file);
|
||||
|
||||
let resp = EmbedResponse {
|
||||
embedding: v.to_vec(),
|
||||
model: MODEL_ID.into(),
|
||||
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
fn handle_embed_text(payload: serde_json::Value, started: Instant) -> Result<String, String> {
|
||||
let p: EmbedTextPayload =
|
||||
serde_json::from_value(payload).map_err(|e| format!("payload inválido: {e}"))?;
|
||||
info!(text_len = p.text.len(), "embed_text");
|
||||
|
||||
// Mock: tratamos el texto como un "stem" sintético y rellenamos el
|
||||
// resto del vector con ceros. No es semánticamente útil, pero respeta
|
||||
// la forma para que el cliente no se rompa.
|
||||
let synthetic = FileEntry {
|
||||
id: akasha_card::FileId::from(Ulid::new()),
|
||||
path: PathBuf::from(format!("synthetic://{}", p.text)),
|
||||
content_hash: None,
|
||||
size: p.text.len() as u64,
|
||||
mtime_ms: now_ms(),
|
||||
extension: Some("text".into()),
|
||||
};
|
||||
let v = embed::embed(&synthetic);
|
||||
|
||||
let resp = EmbedResponse {
|
||||
embedding: v.to_vec(),
|
||||
model: MODEL_ID.into(),
|
||||
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
fn handle_ping() -> Result<String, String> {
|
||||
let resp = PingResponse {
|
||||
model: MODEL_ID.into(),
|
||||
embed_dim: embed::EMBED_DIM as u32,
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
async fn write_error(mut stream: UnixStream, msg: String) -> std::io::Result<()> {
|
||||
warn!(error = %msg, "respuesta de error");
|
||||
let resp = ErrorResponse { error: msg };
|
||||
let json = serde_json::to_string(&resp).unwrap_or_else(|_| "{\"error\":\"encode\"}".into());
|
||||
stream.write_all(json.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
stream.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "akasha-nous-real"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Nouser — proveedor Nous con LLM real (text-embedding via ONNX). El soporte AI vive detrás del feature `embeddings`; sin él, este crate compila como stub mínimo."
|
||||
|
||||
[features]
|
||||
# Sin features = stub que arranca y rechaza requests. Compila en
|
||||
# segundos, sin descargar nada.
|
||||
default = []
|
||||
# Con feature embeddings: pulls fastembed + ONNX Runtime descargado.
|
||||
# Modelo default: all-MiniLM-L6-v2 (384-d, ~80MB descargado al primer
|
||||
# run y cacheado).
|
||||
embeddings = ["dep:fastembed"]
|
||||
|
||||
[dependencies]
|
||||
brahman-card = { path = "../../../protocol/brahman-card" }
|
||||
brahman-sidecar = { path = "../../../protocol/brahman-sidecar" }
|
||||
ente-cas = { path = "../../../runtime/ente-cas" }
|
||||
akasha-nous = { path = "../nous" }
|
||||
serde_json = { workspace = true }
|
||||
sled = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
|
||||
# Opcional: gateado por feature `embeddings`.
|
||||
fastembed = { version = "4", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "akasha-nous-real"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Cache de embeddings keyed por sha256 del contenido + model_id.
|
||||
//!
|
||||
//! Razón de existir: el modelo real (`fastembed-allMiniLML6V2`) es
|
||||
//! caro (1-50 ms por archivo según tamaño y CPU). Cada vez que el
|
||||
//! daemon de akasha re-publica una Mónada o el watcher dispara un
|
||||
//! re-cluster por cambio de FS, todos los archivos pasan otra vez
|
||||
//! por embed. Para árboles de 1000 archivos, eso son segundos
|
||||
//! desperdiciados re-embedidando contenido que no cambió.
|
||||
//!
|
||||
//! ## Diseño
|
||||
//!
|
||||
//! - **Cache key**: `sha256(bytes que el modelo realmente vio)` +
|
||||
//! `MODEL_ID` (string). Usar el sha de los bytes-vistos garantiza
|
||||
//! que la cache no devuelva un embedding de contenido viejo
|
||||
//! simplemente porque el path no cambió.
|
||||
//! - **Cache value**: el `Vec<f32>` serializado como bytes
|
||||
//! little-endian (4 bytes por f32). Compacto, sin overhead de
|
||||
//! bincode/postcard para datos numéricos puros.
|
||||
//! - **Backend**: sled, tree único `embed_cache_v1`. Path:
|
||||
//! `$XDG_CACHE_HOME/brahman/akasha-nous-real-embed-cache.sled`.
|
||||
//!
|
||||
//! ## Versionado
|
||||
//!
|
||||
//! El nombre del tree (`embed_cache_v1`) es el "schema version" del
|
||||
//! formato value. Si bumpeamos a (p. ej.) almacenar también el
|
||||
//! tiempo de cómputo o el ONNX session id, creamos `embed_cache_v2`
|
||||
//! y el viejo queda como dato muerto que sled puede limpiar.
|
||||
//!
|
||||
//! El `MODEL_ID` viaja dentro del key, así que cambiar de modelo
|
||||
//! invalida implícitamente las entradas viejas (no se accede más
|
||||
//! a esos keys; sled las mantiene hasta GC manual).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Wrapper sobre sled::Db con la API justa que necesita `handle_file`.
|
||||
#[derive(Clone)]
|
||||
pub struct EmbedCache {
|
||||
tree: sled::Tree,
|
||||
}
|
||||
|
||||
impl EmbedCache {
|
||||
/// Abre (o crea) la cache en su path canónico. El sled::Db queda
|
||||
/// referenciado por el Tree; mientras `EmbedCache` viva, el DB
|
||||
/// vive.
|
||||
pub fn open() -> Result<Self, sled::Error> {
|
||||
let path = default_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
// best-effort: si no podemos crear el dir, sled falla con
|
||||
// mensaje específico abajo.
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let db = sled::open(&path)?;
|
||||
let tree = db.open_tree("embed_cache_v1")?;
|
||||
Ok(Self { tree })
|
||||
}
|
||||
|
||||
/// Variante para tests: cache efímera bajo `dir`.
|
||||
#[cfg(test)]
|
||||
pub fn open_at(dir: &std::path::Path) -> Result<Self, sled::Error> {
|
||||
let db = sled::open(dir)?;
|
||||
let tree = db.open_tree("embed_cache_v1")?;
|
||||
Ok(Self { tree })
|
||||
}
|
||||
|
||||
/// Lookup. `None` si miss; `Some(vec)` si hit.
|
||||
pub fn get(&self, file_sha: &[u8; 32], model_id: &str) -> Option<Vec<f32>> {
|
||||
let key = build_key(file_sha, model_id);
|
||||
let bytes = self.tree.get(&key).ok()??;
|
||||
decode_embedding(&bytes)
|
||||
}
|
||||
|
||||
/// Almacena. Errores se loggean pero no propagan — cache miss es
|
||||
/// recuperable, no querés tirar el embed válido por fallo de I/O
|
||||
/// de cache.
|
||||
pub fn put(&self, file_sha: &[u8; 32], model_id: &str, embedding: &[f32]) {
|
||||
let key = build_key(file_sha, model_id);
|
||||
let bytes = encode_embedding(embedding);
|
||||
if let Err(e) = self.tree.insert(key, bytes) {
|
||||
tracing::warn!(error = %e, "embed-cache put falló (no-fatal)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Cantidad actual de entradas (best-effort para logs).
|
||||
pub fn len(&self) -> usize {
|
||||
self.tree.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Path default. Honra `XDG_CACHE_HOME`, cae a `$HOME/.cache`, y de
|
||||
/// último recurso a `/tmp` (sin persistencia, pero al menos no
|
||||
/// crashea en entornos minimalistas como CI sin HOME).
|
||||
fn default_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("NOUSER_NOUS_REAL_CACHE") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
let base = std::env::var("XDG_CACHE_HOME")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(|h| PathBuf::from(h).join(".cache"))
|
||||
})
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
base.join("brahman").join("akasha-nous-real-embed-cache.sled")
|
||||
}
|
||||
|
||||
fn build_key(file_sha: &[u8; 32], model_id: &str) -> Vec<u8> {
|
||||
let mut k = Vec::with_capacity(32 + 1 + model_id.len());
|
||||
k.extend_from_slice(file_sha);
|
||||
// separator byte para que prefijos de model_id no choquen con
|
||||
// bytes del sha (improbable pero barato).
|
||||
k.push(0xff);
|
||||
k.extend_from_slice(model_id.as_bytes());
|
||||
k
|
||||
}
|
||||
|
||||
fn encode_embedding(v: &[f32]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(v.len() * 4);
|
||||
for f in v {
|
||||
out.extend_from_slice(&f.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_embedding(bytes: &[u8]) -> Option<Vec<f32>> {
|
||||
if bytes.len() % 4 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() / 4);
|
||||
for chunk in bytes.chunks_exact(4) {
|
||||
out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sha(s: &[u8]) -> [u8; 32] {
|
||||
ente_cas::sha256_of(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_returns_same_vector() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = EmbedCache::open_at(dir.path()).unwrap();
|
||||
let key = sha(b"hello world");
|
||||
let v = vec![0.1f32, -0.5, 1.0, 3.14159];
|
||||
cache.put(&key, "real-fastembed-allMiniLML6V2-384d", &v);
|
||||
let got = cache
|
||||
.get(&key, "real-fastembed-allMiniLML6V2-384d")
|
||||
.expect("hit esperado");
|
||||
assert_eq!(got, v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miss_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = EmbedCache::open_at(dir.path()).unwrap();
|
||||
let key = sha(b"never stored");
|
||||
assert!(cache.get(&key, "real-fastembed-allMiniLML6V2-384d").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_models_do_not_collide() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = EmbedCache::open_at(dir.path()).unwrap();
|
||||
let key = sha(b"same content");
|
||||
cache.put(&key, "model-a", &[1.0, 2.0]);
|
||||
cache.put(&key, "model-b", &[7.0, 8.0]);
|
||||
assert_eq!(cache.get(&key, "model-a").unwrap(), vec![1.0, 2.0]);
|
||||
assert_eq!(cache.get(&key, "model-b").unwrap(), vec![7.0, 8.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_content_different_keys() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = EmbedCache::open_at(dir.path()).unwrap();
|
||||
let k1 = sha(b"abc");
|
||||
let k2 = sha(b"abd");
|
||||
cache.put(&k1, "m", &[1.0]);
|
||||
assert!(cache.get(&k2, "m").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_value_returns_none() {
|
||||
// Si sled devuelve bytes con length no múltiplo de 4, decode
|
||||
// debe fallar limpio (None) en vez de panicar.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = EmbedCache::open_at(dir.path()).unwrap();
|
||||
let key = sha(b"x");
|
||||
// Insertamos manualmente bytes inválidos.
|
||||
let raw_key = build_key(&key, "m");
|
||||
cache.tree.insert(raw_key, &[1u8, 2, 3][..]).unwrap();
|
||||
assert!(cache.get(&key, "m").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Modo embeddings: usa fastembed-rs (ONNX Runtime) para producir
|
||||
//! vectores reales de text-embedding.
|
||||
//!
|
||||
//! Modelo default: `all-MiniLM-L6-v2` (384-d). Se descarga al primer
|
||||
//! arranque a `~/.cache/fastembed` y queda cacheado.
|
||||
//!
|
||||
//! ## Mapeo del contrato
|
||||
//!
|
||||
//! - `EmbedText`: pasa el texto al modelo, devuelve el vector 384-d.
|
||||
//! - `EmbedFile`: lee hasta los primeros 8 KiB del archivo, los
|
||||
//! interpreta como UTF-8 con replacement-char, y los embeda como
|
||||
//! texto. Para archivos binarios el resultado no es semánticamente
|
||||
//! útil — caller decide qué hacer.
|
||||
//! - `Ping`: devuelve `model_id` y `embed_dim` reales.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
|
||||
use akasha_nous::{
|
||||
EmbedFilePayload, EmbedRequest, EmbedResponse, EmbedTextPayload, ErrorResponse, PingResponse,
|
||||
RequestKind,
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::cache::EmbedCache;
|
||||
|
||||
const MAX_FILE_BYTES: usize = 8192;
|
||||
|
||||
/// Backend concreto: posee el modelo cargado.
|
||||
pub struct Backend {
|
||||
model: TextEmbedding,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn init() -> Result<Self, String> {
|
||||
info!("cargando modelo all-MiniLM-L6-v2 (puede descargar ~80MB la primera vez)");
|
||||
let opts = InitOptions::new(EmbeddingModel::AllMiniLML6V2)
|
||||
.with_show_download_progress(true);
|
||||
let model = TextEmbedding::try_new(opts).map_err(|e| format!("fastembed init: {e}"))?;
|
||||
info!("modelo listo");
|
||||
Ok(Self { model })
|
||||
}
|
||||
|
||||
fn embed_one(&self, text: &str) -> Result<Vec<f32>, String> {
|
||||
let out = self
|
||||
.model
|
||||
.embed(vec![text], None)
|
||||
.map_err(|e| format!("embed: {e}"))?;
|
||||
out.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "fastembed devolvió 0 vectores".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_conn(
|
||||
stream: UnixStream,
|
||||
backend: Arc<Backend>,
|
||||
cache: Option<EmbedCache>,
|
||||
) -> std::io::Result<()> {
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let req: EmbedRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return write_error(reader.into_inner(), format!("JSON inválido: {e}")).await;
|
||||
}
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
let result = match req.kind {
|
||||
RequestKind::EmbedFile => handle_file(req.payload, &backend, cache.as_ref(), started),
|
||||
RequestKind::EmbedText => handle_text(req.payload, &backend, started),
|
||||
RequestKind::Ping => handle_ping(),
|
||||
};
|
||||
|
||||
let mut stream = reader.into_inner();
|
||||
match result {
|
||||
Ok(json) => {
|
||||
stream.write_all(json.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
}
|
||||
Err(msg) => return write_error(stream, msg).await,
|
||||
}
|
||||
stream.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_text(
|
||||
payload: serde_json::Value,
|
||||
backend: &Backend,
|
||||
started: Instant,
|
||||
) -> Result<String, String> {
|
||||
let p: EmbedTextPayload =
|
||||
serde_json::from_value(payload).map_err(|e| format!("payload: {e}"))?;
|
||||
info!(text_len = p.text.len(), "embed_text");
|
||||
let v = backend.embed_one(&p.text)?;
|
||||
let resp = EmbedResponse {
|
||||
embedding: v,
|
||||
model: super::model_id().to_string(),
|
||||
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
fn handle_file(
|
||||
payload: serde_json::Value,
|
||||
backend: &Backend,
|
||||
cache: Option<&EmbedCache>,
|
||||
started: Instant,
|
||||
) -> Result<String, String> {
|
||||
let p: EmbedFilePayload =
|
||||
serde_json::from_value(payload).map_err(|e| format!("payload: {e}"))?;
|
||||
|
||||
let path = PathBuf::from(&p.path);
|
||||
let mut file = File::open(&path).map_err(|e| format!("abrir archivo: {e}"))?;
|
||||
let mut buf = vec![0u8; MAX_FILE_BYTES];
|
||||
let n = file.read(&mut buf).map_err(|e| format!("leer archivo: {e}"))?;
|
||||
buf.truncate(n);
|
||||
|
||||
let model_id = super::model_id();
|
||||
// Hash de los bytes que el modelo realmente verá. Si el archivo
|
||||
// crece pasada la ventana MAX_FILE_BYTES sin modificar la cabeza,
|
||||
// el hash NO cambia — el embedding cacheado sigue siendo válido
|
||||
// bajo la semántica del proveedor (el modelo nunca vio los bytes
|
||||
// adicionales). Si la cabeza cambia, el hash cambia y caemos a
|
||||
// re-embed naturalmente.
|
||||
let file_sha = ente_cas::sha256_of(&buf);
|
||||
|
||||
if let Some(cache) = cache {
|
||||
if let Some(cached) = cache.get(&file_sha, model_id) {
|
||||
info!(
|
||||
path = %p.path,
|
||||
sha = %ente_cas::hex(&file_sha),
|
||||
bytes = n,
|
||||
"embed_file: cache HIT"
|
||||
);
|
||||
let resp = EmbedResponse {
|
||||
embedding: cached,
|
||||
model: model_id.to_string(),
|
||||
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||
};
|
||||
return serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
path = %p.path,
|
||||
sha = %ente_cas::hex(&file_sha),
|
||||
bytes = n,
|
||||
"embed_file: cache MISS — invocando modelo"
|
||||
);
|
||||
|
||||
// Write-through al CAS de arje: hacemos la cabeza del archivo
|
||||
// direccionable por contenido. No es la fuente de verdad para
|
||||
// el cache (sled lo es) pero deja un registro consultable por
|
||||
// herramientas como `ente-cas gc` y permite que otros consumers
|
||||
// resuelvan los bytes por hash.
|
||||
if let Err(e) = ente_cas::store(&buf) {
|
||||
// No-fatal: si CAS no escribe, cacheamos el embedding igual.
|
||||
warn!(error = %e, "ente_cas::store falló (no-fatal)");
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&buf).to_string();
|
||||
let v = backend.embed_one(&text)?;
|
||||
|
||||
if let Some(cache) = cache {
|
||||
cache.put(&file_sha, model_id, &v);
|
||||
}
|
||||
|
||||
let resp = EmbedResponse {
|
||||
embedding: v,
|
||||
model: model_id.to_string(),
|
||||
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
fn handle_ping() -> Result<String, String> {
|
||||
let resp = PingResponse {
|
||||
model: super::model_id().to_string(),
|
||||
embed_dim: super::embed_dim(),
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
async fn write_error(mut stream: UnixStream, msg: String) -> std::io::Result<()> {
|
||||
warn!(error = %msg, "respuesta de error");
|
||||
let resp = ErrorResponse { error: msg };
|
||||
let json = serde_json::to_string(&resp).unwrap_or_else(|_| "{\"error\":\"encode\"}".into());
|
||||
stream.write_all(json.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
stream.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! `akasha-nous-real` — proveedor Nous con LLM real (gated por feature).
|
||||
//!
|
||||
//! ## Build modes
|
||||
//!
|
||||
//! - `cargo build -p akasha-nous-real`
|
||||
//! Compila como **stub**: bin que arranca, sidecarea al brahman-init
|
||||
//! pero rechaza toda request con un error explicando que falta la
|
||||
//! feature. Útil para que `cargo build --workspace` no requiera ML
|
||||
//! deps.
|
||||
//!
|
||||
//! - `cargo build -p akasha-nous-real --features embeddings`
|
||||
//! Compila con `fastembed` + ONNX Runtime descargado por Cargo.
|
||||
//! Modelo default: `all-MiniLM-L6-v2` (384-d, ~80 MB descargado al
|
||||
//! primer run y cacheado en `~/.cache/fastembed`).
|
||||
//!
|
||||
//! ## Diseño
|
||||
//!
|
||||
//! Mismo contrato wire que `akasha-nous-mock` (`akasha-nous` crate). La
|
||||
//! diferencia operativa: real produce 384-d con semantic content
|
||||
//! (text-embedding del modelo); mock produce 32-d con metadata-hashing.
|
||||
//! No son intercambiables a media-deployment — los centroides de
|
||||
//! Mónadas calculadas con uno NO matchean con el otro.
|
||||
//!
|
||||
//! La Card declara `priority_contexts.prod = { priority_offset: +1 }`,
|
||||
//! contrapeso del mock que tiene `+1 en test`. Así el broker brahman
|
||||
//! elige automáticamente:
|
||||
//! - `BRAHMAN_BROKER_CONTEXT=test` → mock gana.
|
||||
//! - `BRAHMAN_BROKER_CONTEXT=prod` → real gana.
|
||||
//! - sin contexto → empate por label alfabético.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use brahman_card::{
|
||||
ulid::Ulid, Card, CardKind, ContextBias, Flow, Flows, Lifecycle, Payload, Priority,
|
||||
Supervision, TypeRef,
|
||||
};
|
||||
use akasha_nous::{transport, FLOW_EMBED_REQUEST, FLOW_EMBED_RESULT, FLOW_TYPE_NAME};
|
||||
use tokio::net::UnixListener;
|
||||
use tracing::info;
|
||||
|
||||
#[cfg(feature = "embeddings")]
|
||||
mod cache;
|
||||
#[cfg(feature = "embeddings")]
|
||||
mod embeddings;
|
||||
#[cfg(not(feature = "embeddings"))]
|
||||
mod stub;
|
||||
|
||||
#[cfg(feature = "embeddings")]
|
||||
const MODEL_ID: &str = "real-fastembed-allMiniLML6V2-384d";
|
||||
#[cfg(not(feature = "embeddings"))]
|
||||
const MODEL_ID: &str = "real-stub-no-feature";
|
||||
|
||||
#[cfg(feature = "embeddings")]
|
||||
const EMBED_DIM: u32 = 384;
|
||||
#[cfg(not(feature = "embeddings"))]
|
||||
const EMBED_DIM: u32 = 0;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
init_tracing();
|
||||
|
||||
#[cfg(not(feature = "embeddings"))]
|
||||
info!(
|
||||
"akasha-nous-real corriendo en modo STUB (compilá con \
|
||||
--features embeddings para activar el modelo)"
|
||||
);
|
||||
|
||||
// 1. Resolver socket del data-plane (default `akasha-nous-real.sock`,
|
||||
// distinto del mock para coexistir).
|
||||
let sock_path = transport::provider_socket_path("real");
|
||||
if sock_path.exists() {
|
||||
std::fs::remove_file(&sock_path)?;
|
||||
}
|
||||
if let Some(parent) = sock_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let listener = UnixListener::bind(&sock_path)?;
|
||||
info!(socket = %sock_path.display(), "akasha-nous-real escuchando");
|
||||
|
||||
// 2. Sidecar al brahman-init con Card declarando el socket.
|
||||
let card = build_card(sock_path.clone());
|
||||
info!(label = %card.label, mode = MODEL_ID, "publicando Card al brahman-init");
|
||||
brahman_sidecar::spawn(card);
|
||||
|
||||
// 3. Inicializar el modelo (sólo en modo embeddings).
|
||||
#[cfg(feature = "embeddings")]
|
||||
let backend = embeddings::Backend::init().map_err(|e| {
|
||||
std::io::Error::other(format!("init modelo: {e}"))
|
||||
})?;
|
||||
#[cfg(feature = "embeddings")]
|
||||
let backend = std::sync::Arc::new(backend);
|
||||
|
||||
// 4. Abrir el cache de embeddings (sled local, sha256-keyed).
|
||||
// Si falla, seguimos sin cache — degrada a "siempre embed".
|
||||
#[cfg(feature = "embeddings")]
|
||||
let embed_cache = match cache::EmbedCache::open() {
|
||||
Ok(c) => {
|
||||
info!(entries = c.len(), "embed-cache abierto");
|
||||
Some(c)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "embed-cache no disponible — todas las requests irán al modelo");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Accept loop.
|
||||
loop {
|
||||
let (stream, _addr) = listener.accept().await?;
|
||||
|
||||
#[cfg(feature = "embeddings")]
|
||||
{
|
||||
let backend = backend.clone();
|
||||
let cache = embed_cache.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = embeddings::handle_conn(stream, backend, cache).await {
|
||||
tracing::warn!(error = %e, "conn falló");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "embeddings"))]
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stub::handle_conn(stream).await {
|
||||
tracing::warn!(error = %e, "conn falló");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
/// Card que real-nous anuncia. Idéntica al mock excepto por:
|
||||
/// - label distinto (`akasha.nous_real`) para que coexistan en el broker.
|
||||
/// - `priority_contexts.prod = +1` (gana en contexto prod).
|
||||
/// - `service_socket` propio para que clientes lo descubran directo.
|
||||
fn build_card(service_socket: std::path::PathBuf) -> Card {
|
||||
let mut priority_contexts = BTreeMap::new();
|
||||
priority_contexts.insert(
|
||||
"prod".into(),
|
||||
ContextBias {
|
||||
pin_to: None,
|
||||
priority_offset: 1,
|
||||
},
|
||||
);
|
||||
|
||||
Card {
|
||||
schema_version: brahman_card::CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
label: "akasha.nous_real".into(),
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::Delegate,
|
||||
lifecycle: Lifecycle::Daemon,
|
||||
priority: Priority::Normal,
|
||||
kind: CardKind::Ente,
|
||||
service_socket: Some(service_socket),
|
||||
flow: Flows {
|
||||
input: vec![Flow {
|
||||
name: FLOW_EMBED_REQUEST.into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: FLOW_TYPE_NAME.into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
output: vec![Flow {
|
||||
name: FLOW_EMBED_RESULT.into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: FLOW_TYPE_NAME.into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
},
|
||||
priority_contexts,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers compartidos. Anotados allow(dead_code) porque en stub mode
|
||||
// algunos quedan sin uso pero los queremos disponibles consistentemente.
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn model_id() -> &'static str {
|
||||
MODEL_ID
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn embed_dim() -> u32 {
|
||||
EMBED_DIM
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Modo stub: arranca el bin pero rechaza las requests con un error
|
||||
//! que explica que falta la feature `embeddings`.
|
||||
|
||||
use akasha_nous::{EmbedRequest, ErrorResponse};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tracing::warn;
|
||||
|
||||
pub async fn handle_conn(stream: UnixStream) -> std::io::Result<()> {
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Parseamos para validar la forma; igual rechazamos.
|
||||
let _: Result<EmbedRequest, _> = serde_json::from_str(&line);
|
||||
|
||||
warn!("rechazando request en modo stub (feature `embeddings` ausente)");
|
||||
|
||||
let resp = ErrorResponse {
|
||||
error: format!(
|
||||
"akasha-nous-real compilado sin la feature `embeddings`. \
|
||||
Rebuild con: cargo build -p akasha-nous-real --features embeddings"
|
||||
),
|
||||
};
|
||||
let mut stream = reader.into_inner();
|
||||
let payload = serde_json::to_string(&resp).unwrap_or_else(|_| {
|
||||
"{\"error\":\"stub mode and serialization failed\"}".to_string()
|
||||
});
|
||||
stream.write_all(payload.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
stream.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "akasha-nous"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Nouser — protocolo Nous: contrato JSON line-delimited entre nouser-core y los proveedores de embeddings (mock o LLM real)."
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,196 @@
|
||||
//! `akasha-nous` — el contrato del proveedor de embeddings.
|
||||
//!
|
||||
//! Define el wire-format compartido entre `akasha-core` (consumidor) y
|
||||
//! cualquier implementación de Nous (mock determinista o LLM real). El
|
||||
//! protocolo es **line-delimited JSON** sobre Unix socket: cada conexión
|
||||
//! envía una request, recibe una response, y cierra. Single-shot por
|
||||
//! conexión, igual al admin de brahman.
|
||||
//!
|
||||
//! ## Contrato
|
||||
//!
|
||||
//! ```text
|
||||
//! C → S: {"kind":"embed_file","payload":{...}}\n
|
||||
//! S → C: {"embedding":[...],"model":"mock-pseudo-32d","elapsed_ms":1}\n
|
||||
//! ```
|
||||
//!
|
||||
//! En caso de error:
|
||||
//!
|
||||
//! ```text
|
||||
//! S → C: {"error":"unsupported kind"}\n
|
||||
//! ```
|
||||
//!
|
||||
//! ## Por qué un crate aparte
|
||||
//!
|
||||
//! El consumidor (akasha-core) y el proveedor (akasha-nous-mock,
|
||||
//! akasha-nous-real) deben acordar en types EXACTOS. Tener el contrato
|
||||
//! en su crate evita que cada lado declare structs paralelos que se
|
||||
//! desincronizan. Si bumpeás el wire, bumpeás aquí.
|
||||
//!
|
||||
//! ## Swap por priority_contexts
|
||||
//!
|
||||
//! Cuando existan dos proveedores (mock-nous y real-nous), ambos declaran
|
||||
//! el mismo `flow.output: { name: "embed-result", type: ... }` y
|
||||
//! `flow.input: "embed-request"`. El broker brahman los matchea contra
|
||||
//! los consumidores; el `priority_offset` per-contexto del Card hace que
|
||||
//! mock-nous gane en `test` y real-nous en `prod`. akasha-core sólo
|
||||
//! consume el flow, sin saber cuál implementación corre.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
// =====================================================================
|
||||
// Wire types
|
||||
// =====================================================================
|
||||
|
||||
/// Request al proveedor Nous.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbedRequest {
|
||||
pub kind: RequestKind,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Tipo de request. El payload se interpreta según el kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RequestKind {
|
||||
/// payload = `EmbedFilePayload` (path + metadata mínima).
|
||||
EmbedFile,
|
||||
/// payload = `EmbedTextPayload` (string libre).
|
||||
EmbedText,
|
||||
/// payload = `{}`. Devuelve `PingResponse`.
|
||||
Ping,
|
||||
}
|
||||
|
||||
/// Payload para `EmbedFile`. Es la información mínima que el proveedor
|
||||
/// necesita para producir un embedding de archivo determinista.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbedFilePayload {
|
||||
pub path: String,
|
||||
pub extension: Option<String>,
|
||||
pub size: u64,
|
||||
/// `mtime` en ms desde UNIX_EPOCH.
|
||||
pub mtime_ms: u64,
|
||||
}
|
||||
|
||||
/// Payload para `EmbedText`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbedTextPayload {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Response exitosa con un embedding.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbedResponse {
|
||||
/// Vector. Su longitud depende del modelo (mock=32, llama=384, etc.).
|
||||
pub embedding: Vec<f32>,
|
||||
/// Identificador del modelo que produjo el embedding (útil para logs
|
||||
/// y para invalidar caches al cambiar de proveedor).
|
||||
pub model: String,
|
||||
/// Tiempo de cómputo en ms (proveedor lo reporta).
|
||||
pub elapsed_ms: u64,
|
||||
}
|
||||
|
||||
/// Response a Ping. Útil para health-checks y discovery.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PingResponse {
|
||||
pub model: String,
|
||||
pub embed_dim: u32,
|
||||
}
|
||||
|
||||
/// Error retornado por el proveedor en lugar de una response normal.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
|
||||
#[error("nous: {error}")]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Transport
|
||||
// =====================================================================
|
||||
|
||||
pub mod transport {
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Variable de entorno para sobreescribir la ruta del socket.
|
||||
pub const SOCKET_ENV: &str = "NOUSER_NOUS_SOCKET";
|
||||
|
||||
/// Nombre genérico del socket cuando hay un solo proveedor.
|
||||
pub const SOCKET_NAME: &str = "akasha-nous.sock";
|
||||
|
||||
/// Ruta canónica al socket cuando un único proveedor está activo
|
||||
/// (consumidores que no quieren elegir).
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var(SOCKET_ENV) {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
runtime_base().join(SOCKET_NAME)
|
||||
}
|
||||
|
||||
/// Ruta default para un proveedor identificado (`"mock"`, `"real"`,
|
||||
/// etc). Permite que mock y real coexistan sin clash de socket.
|
||||
/// `NOUSER_NOUS_SOCKET` igual override esta función si está set.
|
||||
pub fn provider_socket_path(provider: &str) -> PathBuf {
|
||||
if let Ok(p) = std::env::var(SOCKET_ENV) {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
runtime_base().join(format!("akasha-nous-{}.sock", provider))
|
||||
}
|
||||
|
||||
fn runtime_base() -> PathBuf {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Names compartidos para el broker brahman
|
||||
// =====================================================================
|
||||
|
||||
/// Nombre del flow output del proveedor (entrada del consumidor).
|
||||
pub const FLOW_EMBED_RESULT: &str = "embed-result";
|
||||
|
||||
/// Nombre del flow input del proveedor (salida del consumidor).
|
||||
pub const FLOW_EMBED_REQUEST: &str = "embed-request";
|
||||
|
||||
/// Tipo del flow: el wire es JSON serializado, así que el TypeRef
|
||||
/// declarado en la Card es `primitive::json`.
|
||||
pub const FLOW_TYPE_NAME: &str = "json";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_roundtrip_json() {
|
||||
let req = EmbedRequest {
|
||||
kind: RequestKind::EmbedFile,
|
||||
payload: serde_json::to_value(EmbedFilePayload {
|
||||
path: "/x/y.rs".into(),
|
||||
extension: Some("rs".into()),
|
||||
size: 1024,
|
||||
mtime_ms: 1_700_000_000_000,
|
||||
})
|
||||
.unwrap(),
|
||||
};
|
||||
let s = serde_json::to_string(&req).unwrap();
|
||||
let parsed: EmbedRequest = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(parsed.kind, RequestKind::EmbedFile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_roundtrip() {
|
||||
let resp = EmbedResponse {
|
||||
embedding: vec![0.1, 0.2, 0.3],
|
||||
model: "mock-pseudo-32d".into(),
|
||||
elapsed_ms: 1,
|
||||
};
|
||||
let s = serde_json::to_string(&resp).unwrap();
|
||||
let parsed: EmbedResponse = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(parsed.model, "mock-pseudo-32d");
|
||||
assert_eq!(parsed.embedding.len(), 3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user