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
+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");
}
}