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:
Sergio
2026-05-08 18:03:49 +00:00
parent bbb9a9d2f5
commit 7bdc26e61a
11 changed files with 1226 additions and 0 deletions
+68
View File
@@ -6,6 +6,74 @@ ratio/diff ver `git show <sha>`.
## 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<FileEntry>` 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 <dir>`, `show <dir> <prefix>`,
`json <dir>`. 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<String>, priority_offset: i8 }`
declara un override per-contexto.
Generated
+23
View File
@@ -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"
+6
View File
@@ -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)
# ============================================================
+15
View File
@@ -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 }
+334
View File
@@ -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);
}
}
+24
View File
@@ -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"
@@ -0,0 +1,157 @@
//! `nouser` 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 nouser_core::{
cluster, db,
scanner::{self, ScanConfig},
};
fn main() -> ExitCode {
let args: Vec<String> = 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} <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!();
eprintln!("env:");
eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
}
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 = 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 <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(())
}
+240
View File
@@ -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<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> {
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;
}
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<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.
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<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 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"]);
}
}
+149
View File
@@ -0,0 +1,149 @@
//! DB en memoria de Mónadas y archivos.
//!
//! Phase A: store volátil con índices `BTreeMap<Id, Manifest>` para
//! ambos lados. Phase B traerá persistencia (sled o sqlite) y un
//! índice `file_id → Vec<monad_id>` (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<FileId, FileEntry>,
monads: BTreeMap<MonadId, MonadManifest>,
}
impl MonadDb {
pub fn new() -> Self {
Self::default()
}
// ---- Files ----
pub fn insert_file(&mut self, file: FileEntry) -> Option<FileEntry> {
self.files.insert(file.id, file)
}
pub fn ingest_files(&mut self, files: Vec<FileEntry>) {
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<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> {
self.monads.insert(monad.id, monad)
}
pub fn replace_monads(&mut self, monads: Vec<MonadManifest>) {
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<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(),
}
}
}
#[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");
}
}
+32
View File
@@ -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<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 scanner;
pub use nouser_card::*;
+178
View File
@@ -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<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()));
}
}