diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ef9b6..563d5db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,51 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(nouser): hidratación del daemon vía sled + path_hint +El daemon ya no recomputa ciegamente al arrancar. Si la DB tiene +Mónadas previas con `centroid_model` válido, las publica instantáneo +y el re-scan reusa sus IDs vía `path_hint`. + +Schema: +- `MonadManifest.path_hint: Option` — identidad estable + derivada del origen (para `by_directory`, el parent dir + canónico). Permite reusar ULID across re-scans. + +Algoritmo (cluster): +- Nueva fn `cluster::by_directory_hydrated(files, min_files, + prior: Option<&MonadDb>)`. Cuando hay `prior`, busca Mónada con + mismo `path_hint` Y mismo `centroid_model`; si la encuentra, + reusa `id`, `lineage` y `created_at_ms`. +- `by_directory` queda como wrapper sin hidratación (back-compat). + +Daemon (cmd_daemon): +1. Open sled si NOUSER_DB_PATH existe. +2. Publica las Mónadas previas con `centroid_model` válido (las + inválidas se descartan con log explícito). +3. Re-scan + `by_directory_hydrated(prior=&db)`. +4. Sólo spawnea sidecars para Mónadas con id que NO estaba en la + hidratación inicial. Los path_hints existentes preservan identidad, + evitando duplicados en el broker. +5. Persiste el set actualizado. + +Validación end-to-end: + + $ NOUSER_DB_PATH=/tmp/h.sled nouser daemon crates/core + # arranque 1: DB vacía + re-scan 102 archivos → 5 mónadas + 1 ente + 5 mónadas vivas (5 nuevas vs hidratación) + + $ NOUSER_DB_PATH=/tmp/h.sled nouser daemon crates/core + # arranque 2: DB poblada + hidratadas 5 mónadas previas en O(1) + re-scan 102 archivos → 5 mónadas + 1 ente + 5 mónadas vivas (0 nuevas vs hidratación) + +Costo del arranque 2: ~0.06s user CPU. Antes (sin hidratación) era +re-scan + cluster + spawn x N — segundos enteros para árboles grandes. + +Tests: 7 (card) + 24 (core) verdes. + ### feat(nouser): centroid_model — versionado de embeddings Protege contra el bug silencioso de mezclar centroides de modelos distintos (mock 32-d vs real 384-d), que daba scores sin sentido. diff --git a/crates/modules/nouser/card/src/lib.rs b/crates/modules/nouser/card/src/lib.rs index 8096673..b448fb9 100644 --- a/crates/modules/nouser/card/src/lib.rs +++ b/crates/modules/nouser/card/src/lib.rs @@ -134,6 +134,16 @@ pub struct MonadManifest { #[serde(default)] pub centroid_model: Option, + /// Identidad estable derivada del origen de los miembros. Para + /// Mónadas creadas por `cluster::by_directory`, es el path + /// canónico del directorio padre. Permite que la hidratación + /// reuse el mismo ULID across re-scans (mismo path_hint = misma + /// identidad, aunque cambien los miembros internamente). + /// `None` para Mónadas creadas por estrategias que no se anclan a + /// un origen físico. + #[serde(default)] + pub path_hint: Option, + /// Tokens dominantes: extensiones, palabras clave, etc. /// 5-10 elementos típicamente. #[serde(default)] @@ -213,6 +223,7 @@ impl MonadManifest { summary: String::new(), centroid: Vec::new(), centroid_model: None, + path_hint: None, keywords: Vec::new(), cardinality: 0, entropy: 0.0, diff --git a/crates/modules/nouser/core/src/bin/nouser.rs b/crates/modules/nouser/core/src/bin/nouser.rs index 504fbf1..8c21658 100644 --- a/crates/modules/nouser/core/src/bin/nouser.rs +++ b/crates/modules/nouser/core/src/bin/nouser.rs @@ -175,12 +175,10 @@ fn cmd_json(args: &[String]) -> Cmd { fn cmd_daemon(args: &[String]) -> Cmd { let dir = require_dir(args)?; - // Pool consolidado: 1 thread + 1 tokio runtime para TODAS las - // sesiones (engine + N mónadas). Antes era 1 thread por sesión. let pool = brahman_sidecar::SidecarPool::new() .map_err(|e| format!("crear pool: {e}"))?; - // 1. El propio engine se presenta como Ente. + // 1. Engine como Ente. let engine_card = build_engine_card(); let engine_id = engine_card.id; let engine_label = engine_card.label.clone(); @@ -190,20 +188,69 @@ fn cmd_daemon(args: &[String]) -> Cmd { ); pool.spawn(engine_card); - // 2. Scan y cluster. - let (db, n_files) = run_scan(&dir)?; + // 2. Hidratación: si NOUSER_DB_PATH apunta a un sled poblado, + // publicar lo que ya tenemos ANTES del re-scan. brahman-status + // ve mónadas reales en milisegundos, no en segundos. + let mut db = open_db()?; + let prior_count = db.monad_count(); + if prior_count > 0 { + let mut hydrated = 0usize; + let mut skipped_model = 0usize; + for monad in db.monads() { + // Sólo publicamos centroides del modelo actual; los demás + // son data muerta hasta que el re-scan los reemplace. + let valid = monad + .centroid_model + .as_deref() + .map(|id| id == embed::MODEL_ID) + .unwrap_or(false); + if !valid { + skipped_model += 1; + continue; + } + let mut card = monad.to_brahman_card(); + card.references.push(brahman_card::CardReference { + kind: brahman_card::RelationshipKind::OwnedBy, + target_id: engine_id, + target_label: engine_label.clone(), + }); + pool.spawn(card); + hydrated += 1; + } + eprintln!( + "nouser daemon: hidratadas {} mónadas previas{} en O(1)", + hydrated, + if skipped_model > 0 { + format!(" ({} dropeadas por centroid_model distinto)", skipped_model) + } else { + String::new() + } + ); + } + + // 3. Re-scan con hidratación: las Mónadas con mismo path_hint + // reusan id, así que NO generamos sesiones duplicadas para los + // mismos directorios — el sidecar previo ya tiene esa identidad. + let files = scanner::scan_directory(&dir, &scanner::ScanConfig::default())?; + let n_files = files.len(); + let monads = cluster::by_directory_hydrated(&files, min_files(), Some(&db)); + let scanned_count = monads.len(); eprintln!( - "nouser daemon: {} archivos en {}, {} mónadas detectadas", + "nouser daemon: re-scan {} archivos en {} → {} mónadas", n_files, dir.display(), - db.monad_count() + scanned_count ); - // 3. Cada Mónada se presenta como Card de tipo Data, declarando - // su relación OwnedBy con el engine. Todas comparten el runtime - // del pool — sin overhead de N threads. - let mut count = 0usize; - for monad in db.monads() { + // Publicamos sólo las Mónadas NUEVAS (las que no estaban en la + // hidratación inicial). El criterio: si el id estaba en la DB + // previa, el sidecar de la hidratación ya cubre esa identidad. + let prior_ids: std::collections::BTreeSet<_> = db.monads().map(|m| m.id).collect(); + let mut newly_spawned = 0usize; + for monad in &monads { + if prior_ids.contains(&monad.id) { + continue; // ya publicada en hidratación + } let mut card = monad.to_brahman_card(); card.references.push(brahman_card::CardReference { kind: brahman_card::RelationshipKind::OwnedBy, @@ -211,14 +258,19 @@ fn cmd_daemon(args: &[String]) -> Cmd { target_label: engine_label.clone(), }); pool.spawn(card); - count += 1; + newly_spawned += 1; } + + // Reescribimos la DB con el set actual (idempotente para los + // hidratados; reemplazo para los nuevos). + db.ingest_files(files); + db.replace_monads(monads); + eprintln!( - "nouser daemon: 1 ente + {} mónadas en pool consolidado. Ctrl-C para terminar.", - count + "nouser daemon: 1 ente + {} mónadas vivas ({} nuevas vs hidratación). Ctrl-C para terminar.", + scanned_count, newly_spawned ); - // 4. Park: el thread del pool sigue vivo mientras `pool` exista. std::thread::park(); drop(pool); Ok(()) diff --git a/crates/modules/nouser/core/src/cluster.rs b/crates/modules/nouser/core/src/cluster.rs index a9ded07..7091ee3 100644 --- a/crates/modules/nouser/core/src/cluster.rs +++ b/crates/modules/nouser/core/src/cluster.rs @@ -28,6 +28,19 @@ pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3; /// 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 { + by_directory_hydrated(files, min_files, None) +} + +/// Variante con hidratación: si `prior` está presente, busca Mónadas +/// previas con el mismo `path_hint` y `centroid_model` válido, y reusa +/// su `id` y `lineage`. Esto preserva identidad across re-scans — +/// fundamental para que el daemon pueda republicar tras hidratar de +/// sled sin generar duplicados en el broker. +pub fn by_directory_hydrated( + files: &[FileEntry], + min_files: usize, + prior: Option<&crate::db::MonadDb>, +) -> Vec { let mut by_parent: BTreeMap> = BTreeMap::new(); for f in files { if let Some(parent) = f.path.parent() { @@ -40,7 +53,23 @@ pub fn by_directory(files: &[FileEntry], min_files: usize) -> Vec if group.len() < min_files { continue; } - out.push(build_monad(&parent, &group)); + let mut m = build_monad(&parent, &group); + if let Some(db) = prior { + // Reusamos id si encontramos Mónada previa con mismo + // path_hint Y mismo centroid_model. Distintas hipótesis + // de modelo no comparten identidad — son objetos + // semánticos distintos, aunque parecidos. + if let Some(existing) = db.monads().find(|prev| { + prev.path_hint.as_deref() == m.path_hint.as_deref() + && prev.centroid_model == m.centroid_model + }) { + m.id = existing.id; + m.lineage = existing.lineage; + m.created_at_ms = existing.created_at_ms; + m.touch(); + } + } + out.push(m); } out } @@ -69,6 +98,10 @@ fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest // Taggeamos el centroide con su modelo. attract verifica esto // antes de comparar para no mezclar pseudo-32d con real-384d. m.centroid_model = Some(embed::MODEL_ID.to_string()); + // path_hint = identidad estable across re-scans para + // hidratación. Display es lossy con UTF-8 inválido pero los + // paths legítimos se imprimen consistentes. + m.path_hint = Some(parent.display().to_string()); m.members = group.iter().map(|f| f.id).collect(); m.touch(); m