gioser

tawasuyu/brahman

Rust 521TOML 202Markdown 57JSON 197505 Definitions

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<String> — identidad estable
  derivada del origen (para by_directory, el parent dir canónico).
  Permite reusar ULID across re-scans.

Cluster:
- Nueva fn cluster::by_directory_hydrated(files, min_files, prior).
  Con 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 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 NUEVO. Los path_hints
   existentes preservan identidad, evitando duplicados en el broker.
5. Persiste el set actualizado.

Validación:
  $ NOUSER_DB_PATH=/tmp/h.sled nouser daemon crates/core
  # arranque 1: re-scan 102 archivos → 5 mónadas (5 nuevas)
  $ NOUSER_DB_PATH=/tmp/h.sled nouser daemon crates/core
  # arranque 2: hidratadas 5 mónadas en O(1)
  #             re-scan → 5 mónadas (0 nuevas vs hidratación)

Costo del arranque 2: ~0.06s user CPU.

Tests: 7 (card) + 24 (core) verdes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

65af98da134286e378419d2988ab05adc6abc4f2 · by Sergio · (5 months ago) · parent 820a1a33b · browse the tree at this commit

Semantic delta · 6

crates/modules/nouser/card/src/lib.rs

crates/modules/nouser/core/src/bin/nouser.rs

crates/modules/nouser/core/src/cluster.rs

4 files · +158 −17

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 <sha>`.
 
 ## 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<String>` — 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<String>,
 
+    /// 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<String>,
+
     /// 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<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> {
+    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<MonadManifest> {
     let mut by_parent: BTreeMap<PathBuf, Vec<&FileEntry>> = 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<MonadManifest>
         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