From 7bdc26e61a68bc0a878122ce6659f0020a56af4e Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 8 May 2026 18:03:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(nouser):=20Phase=20A=20=E2=80=94=20mecanis?= =?UTF-8?q?mo=20determinista=20de=20M=C3=B3nadas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- CHANGELOG.md | 68 ++++ Cargo.lock | 23 ++ Cargo.toml | 6 + crates/modules/nouser/card/Cargo.toml | 15 + crates/modules/nouser/card/src/lib.rs | 334 +++++++++++++++++++ crates/modules/nouser/core/Cargo.toml | 24 ++ crates/modules/nouser/core/src/bin/nouser.rs | 157 +++++++++ crates/modules/nouser/core/src/cluster.rs | 240 +++++++++++++ crates/modules/nouser/core/src/db.rs | 149 +++++++++ crates/modules/nouser/core/src/lib.rs | 32 ++ crates/modules/nouser/core/src/scanner.rs | 178 ++++++++++ 11 files changed, 1226 insertions(+) create mode 100644 crates/modules/nouser/card/Cargo.toml create mode 100644 crates/modules/nouser/card/src/lib.rs create mode 100644 crates/modules/nouser/core/Cargo.toml create mode 100644 crates/modules/nouser/core/src/bin/nouser.rs create mode 100644 crates/modules/nouser/core/src/cluster.rs create mode 100644 crates/modules/nouser/core/src/db.rs create mode 100644 crates/modules/nouser/core/src/lib.rs create mode 100644 crates/modules/nouser/core/src/scanner.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4189e..cd16c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,74 @@ ratio/diff ver `git show `. ## 2026-05-08 +### feat(nouser): Phase A — mecanismo determinista de Mónadas +Primer trozo del módulo Nouser (Kairos): explorador de Mónadas como +"imanes semánticos" sobre el filesystem. Phase A cubre el 90% de los +casos sin tocar IA — sólo metadatos y heurísticas. + +Crates nuevos: + +- `crates/modules/nouser/card`: `MonadManifest` (la Tarjeta de + Presentación de una Mónada — espejo conceptual de `brahman::Card` + pero para datos, no para procesos runtime). Campos: id (Ulid), + label, summary, centroid (vacío en Phase A), keywords, cardinality, + entropy [0,1], dominant_lens, pins, members, timestamps, + extensions (forward-compat). 6 tests de validación + JSON roundtrip. +- `crates/modules/nouser/core`: pipeline determinista. + - `scanner`: walkdir → `Vec` con metadatos (path, size, + mtime, extension). Skipea hidden por default. Configurable max + depth y follow_links. + - `cluster::by_directory`: agrupa por parent dir, mínimo 3 archivos + para promover a Mónada (configurable). Calcula keywords (top-N + extensiones por frecuencia + alfabético), elige `Lens` dominante + (Code/Gallery/Markdown/Database/Grid) según extensión más + frecuente, computa entropía de Shannon normalizada [0,1]. + - `db`: `MonadDb` en memoria con índices BTreeMap files/monads y + `resolve_members(monad_id)` que filtra IDs huérfanos. Phase B + traerá persistencia. + - bin `nouser`: subcomandos `scan `, `show `, + `json `. Env var `NOUSER_MIN_FILES` para tunear 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 + ... + + $ nouser show crates 01KR4C + Monad 01KR4C1370DVF6NMTW6SECNXAF + label: src + summary: 4 archivos en crates/modules/nouser/core/src (ext: rs) + cardinality: 4 + entropy: 0.0000 + lens: Code + members (4): + 4132 bytes crates/modules/nouser/core/src/db.rs + ... + +Pendientes para próximas fases (anotados, no urgentes): +- **Phase B**: bin `nouser daemon` que sidecarea a brahman-init + declarando flows (`scan-request:json` → `monad-update:json`). +- **Phase C**: pseudo-embeddings deterministas (hash de path/ext/size + a 32-d) + atracción por centroide via cosine similarity. Implementa + el "imán" sin LLM. +- **Phase D**: módulo `nouser-nous` aparte para el LLM real + (Llama/ONNX). En `priority_contexts.test` el Init pinea a + `mock-nous` (embeddings determinísticos); en `prod` a `real-nous`. +- **Polish**: labels de Mónada incluir 2-3 componentes del path para + desambiguar `src/` repetidos en monorepo. + +Workspace: 0 errores, 0 warnings. Tests acumulados: 58 +(card 11, broker 15, handshake codec+transport 2 + integ 7, +card-wit 4, admin 0, nouser-card 6, nouser-core 13). + ### feat(broker): priority contexts — biases per-contexto operativo - `brahman-card::ContextBias { pin_to: Option, priority_offset: i8 }` declara un override per-contexto. diff --git a/Cargo.lock b/Cargo.lock index 066f436..34ded5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6045,6 +6045,29 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nouser-card" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "ulid", +] + +[[package]] +name = "nouser-core" +version = "0.1.0" +dependencies = [ + "nouser-card", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "ulid", + "walkdir", +] + [[package]] name = "ntapi" version = "0.4.3" diff --git a/Cargo.toml b/Cargo.toml index a894516..3246b48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,12 @@ members = [ # ============================================================ "crates/modules/nakui/core", + # ============================================================ + # modules/nouser/ — explorador de Mónadas (nuevo) + # ============================================================ + "crates/modules/nouser/card", + "crates/modules/nouser/core", + # ============================================================ # apps/ — apps que consumen el protocolo (yahweh modules+shell) # ============================================================ diff --git a/crates/modules/nouser/card/Cargo.toml b/crates/modules/nouser/card/Cargo.toml new file mode 100644 index 0000000..4409b30 --- /dev/null +++ b/crates/modules/nouser/card/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nouser-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] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +ulid = { workspace = true } diff --git a/crates/modules/nouser/card/src/lib.rs b/crates/modules/nouser/card/src/lib.rs new file mode 100644 index 0000000..1a55a6d --- /dev/null +++ b/crates/modules/nouser/card/src/lib.rs @@ -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, +} + +// ===================================================================== +// 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, + + /// 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, + + /// Tokens dominantes: extensiones, palabras clave, etc. + /// 5-10 elementos típicamente. + #[serde(default)] + pub keywords: Vec, + + /// 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, + + /// IDs de archivos miembros (incluye pins). + pub members: BTreeSet, + + /// 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, +} + +// ===================================================================== +// 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) -> 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 { + Ok(serde_json::to_string_pretty(self)?) + } + + /// Deserializa desde JSON y valida. + pub fn from_json(src: &str) -> Result { + 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); + } +} diff --git a/crates/modules/nouser/core/Cargo.toml b/crates/modules/nouser/core/Cargo.toml new file mode 100644 index 0000000..023d764 --- /dev/null +++ b/crates/modules/nouser/core/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "nouser-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] +nouser-card = { path = "../card" } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +ulid = { workspace = true } +walkdir = "2" + +[dev-dependencies] +tempfile = { workspace = true } + +[[bin]] +name = "nouser" +path = "src/bin/nouser.rs" diff --git a/crates/modules/nouser/core/src/bin/nouser.rs b/crates/modules/nouser/core/src/bin/nouser.rs new file mode 100644 index 0000000..03fa85d --- /dev/null +++ b/crates/modules/nouser/core/src/bin/nouser.rs @@ -0,0 +1,157 @@ +//! `nouser` CLI — explorador de Mónadas. +//! +//! Subcomandos: +//! +//! - `scan ` recorre `dir` y muestra las Mónadas detectadas. +//! - `show ` scan + detalles de la Mónada con prefijo de ID. +//! - `json ` 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 nouser_core::{ + cluster, db, + scanner::{self, ScanConfig}, +}; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + let prog = args.first().cloned().unwrap_or_else(|| "nouser".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), + "--help" | "-h" | "help" => { + print_usage(&prog); + return ExitCode::SUCCESS; + } + other => { + eprintln!("nouser: comando desconocido '{other}'"); + print_usage(&prog); + return ExitCode::from(2); + } + }; + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("nouser: {e}"); + ExitCode::from(1) + } + } +} + +fn print_usage(prog: &str) { + eprintln!("uso: {prog} [args]"); + eprintln!(); + eprintln!("comandos:"); + eprintln!(" scan recorre un directorio y lista las Mónadas detectadas"); + eprintln!(" show scan + detalle de la Mónada cuyo ID empieza con "); + eprintln!(" json scan + dump JSON de todos los manifests"); + eprintln!(); + eprintln!("env:"); + eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)"); +} + +type Cmd = Result<(), Box>; + +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> { + let dir = args.first().ok_or("falta argumento ")?; + Ok(PathBuf::from(dir)) +} + +fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box> { + let files = scanner::scan_directory(dir, &ScanConfig::default())?; + let n_files = files.len(); + let monads = cluster::by_directory(&files, min_files()); + let mut db = db::MonadDb::new(); + db.ingest_files(files); + db.replace_monads(monads); + Ok((db, n_files)) +} + +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 ")?; + 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(()) +} diff --git a/crates/modules/nouser/core/src/cluster.rs b/crates/modules/nouser/core/src/cluster.rs new file mode 100644 index 0000000..b8deac0 --- /dev/null +++ b/crates/modules/nouser/core/src/cluster.rs @@ -0,0 +1,240 @@ +//! 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 nouser_card::{FileEntry, Lens, MonadManifest}; + +/// 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` 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 { + let mut by_parent: BTreeMap> = 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; + } + out.push(build_monad(&parent, &group)); + } + out +} + +fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest { + let label = parent + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("unnamed") + .to_string(); + + let keywords = top_extensions(group, 5); + let lens = pick_lens(group); + let entropy = shannon_entropy_normalized(group); + + let summary = build_summary(parent, group, &keywords); + + let mut m = MonadManifest::new(label); + m.summary = summary; + m.keywords = keywords; + m.dominant_lens = lens; + m.entropy = entropy; + m.members = group.iter().map(|f| f.id).collect(); + m.touch(); + m +} + +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 { + let mut counts: BTreeMap = 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. +fn pick_lens(files: &[&FileEntry]) -> Lens { + let dominant = top_extensions(files, 1).into_iter().next(); + 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, + } +} + +/// 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 = 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 nouser_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(); + assert!(labels.iter().any(|l| l.as_str() == "src")); + assert!(labels.iter().any(|l| l.as_str() == "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, "sub"); + } + + #[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"]); + } +} diff --git a/crates/modules/nouser/core/src/db.rs b/crates/modules/nouser/core/src/db.rs new file mode 100644 index 0000000..1d76023 --- /dev/null +++ b/crates/modules/nouser/core/src/db.rs @@ -0,0 +1,149 @@ +//! DB en memoria de Mónadas y archivos. +//! +//! Phase A: store volátil con índices `BTreeMap` para +//! ambos lados. Phase B traerá persistencia (sled o sqlite) y un +//! índice `file_id → Vec` (membership). + +use std::collections::BTreeMap; + +use nouser_card::{FileEntry, FileId, MonadId, MonadManifest}; + +/// Store de Mónadas + archivos. Operaciones lock-free (mut & por +/// usuario externo). Para uso multi-thread, envolvé en `Mutex/RwLock`. +#[derive(Debug, Default)] +pub struct MonadDb { + files: BTreeMap, + monads: BTreeMap, +} + +impl MonadDb { + pub fn new() -> Self { + Self::default() + } + + // ---- Files ---- + + pub fn insert_file(&mut self, file: FileEntry) -> Option { + self.files.insert(file.id, file) + } + + pub fn ingest_files(&mut self, files: Vec) { + for f in files { + self.files.insert(f.id, f); + } + } + + pub fn file(&self, id: FileId) -> Option<&FileEntry> { + self.files.get(&id) + } + + pub fn files(&self) -> impl Iterator + '_ { + self.files.values() + } + + pub fn file_count(&self) -> usize { + self.files.len() + } + + // ---- Monads ---- + + pub fn insert_monad(&mut self, monad: MonadManifest) -> Option { + self.monads.insert(monad.id, monad) + } + + pub fn replace_monads(&mut self, monads: Vec) { + self.monads.clear(); + for m in monads { + self.monads.insert(m.id, m); + } + } + + pub fn monad(&self, id: MonadId) -> Option<&MonadManifest> { + self.monads.get(&id) + } + + pub fn monads(&self) -> impl Iterator + '_ { + 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(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nouser_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()); + } + + #[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); + // Sólo el archivo realmente presente en files. + 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"); + } +} diff --git a/crates/modules/nouser/core/src/lib.rs b/crates/modules/nouser/core/src/lib.rs new file mode 100644 index 0000000..b713690 --- /dev/null +++ b/crates/modules/nouser/core/src/lib.rs @@ -0,0 +1,32 @@ +//! `nouser-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 +//! → cluster::by_directory(min_files=N) +//! → Vec +//! → 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 scanner; + +pub use nouser_card::*; diff --git a/crates/modules/nouser/core/src/scanner.rs b/crates/modules/nouser/core/src/scanner.rs new file mode 100644 index 0000000..ed4581e --- /dev/null +++ b/crates/modules/nouser/core/src/scanner.rs @@ -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 nouser_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, + /// 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, 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())); + } +}