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,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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user