feat(nouser): Phase A — mecanismo determinista de Mónadas
Primer trozo de Nouser/Kairos: explorador de Mónadas como agrupaciones
semánticas sobre el filesystem, sin tocar IA todavía. Cubre el 90% de
los casos con heurísticas puras.
Crates nuevos:
crates/modules/nouser/card:
- MonadManifest: la Tarjeta de Presentación de una Mónada. Espejo
conceptual de brahman::Card pero para datos: id (Ulid), label,
summary, centroid (vacío en Phase A), keywords, cardinality, entropy
[0,1], dominant_lens (Grid|Code|Gallery|Database|Markdown|Tree),
pins, members, timestamps, extensions (forward-compat).
- Diferencia explícita en docs: brahman::Card describe entidades
runtime con payload/soma/supervision; MonadManifest describe una
agrupación de datos sin proceso atrás.
- Validación: schema_version, label no vacío, entropy en rango,
cardinality consistente con members.len().
- 6 tests (validación + JSON roundtrip).
crates/modules/nouser/core:
- scanner::scan_directory: walkdir → Vec<FileEntry> con metadatos.
Skipea hidden por default; configurable max_depth y follow_links.
- cluster::by_directory: agrupa archivos por parent dir, mínimo 3
para promover a Mónada (configurable). Computa keywords (top-N
extensiones por freq + alfabético), elige Lens dominante por
extensión más frecuente, entropía de Shannon normalizada.
- db::MonadDb: store en memoria con índices BTreeMap.
resolve_members filtra IDs huérfanos.
- bin nouser con subcomandos scan, show, json. Env var
NOUSER_MIN_FILES para el threshold.
- 13 tests (4 scanner + 6 cluster + 3 db).
Demo end-to-end:
$ nouser scan crates
scan: 255 archivos en crates, 19 mónadas (min_files=3)
[01KR4C13] src card=12 ent=0.00 lens=Code keywords: rs
[01KR4C13] tests card=14 ent=0.00 lens=Code keywords: rs
[01KR4C13] fixtures card=5 ent=0.00 lens=Grid keywords: rhai
Pendientes (anotados en CHANGELOG, no urgentes):
- Phase B: bin nouser daemon que sidecarea a brahman-init.
- Phase C: pseudo-embeddings de metadatos + atracción por centroide.
- Phase D: módulo nouser-nous para el LLM real, swappable por
priority_contexts (mock-nous en test, real-nous en prod).
- Polish: labels con 2-3 componentes del path.
cargo check --workspace: 0 errores, 0 warnings.
Tests acumulados: 58.
CHANGELOG.md actualizado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
//! `nouser-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 | nouser::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 `nouser-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;
|
||||
|
||||
/// 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 (yahweh) 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>,
|
||||
|
||||
/// 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(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user