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>
This commit is contained in:
@@ -6,6 +6,51 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 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
|
### feat(nouser): centroid_model — versionado de embeddings
|
||||||
Protege contra el bug silencioso de mezclar centroides de modelos
|
Protege contra el bug silencioso de mezclar centroides de modelos
|
||||||
distintos (mock 32-d vs real 384-d), que daba scores sin sentido.
|
distintos (mock 32-d vs real 384-d), que daba scores sin sentido.
|
||||||
|
|||||||
@@ -134,6 +134,16 @@ pub struct MonadManifest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub centroid_model: Option<String>,
|
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.
|
/// Tokens dominantes: extensiones, palabras clave, etc.
|
||||||
/// 5-10 elementos típicamente.
|
/// 5-10 elementos típicamente.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -213,6 +223,7 @@ impl MonadManifest {
|
|||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
centroid: Vec::new(),
|
centroid: Vec::new(),
|
||||||
centroid_model: None,
|
centroid_model: None,
|
||||||
|
path_hint: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
cardinality: 0,
|
cardinality: 0,
|
||||||
entropy: 0.0,
|
entropy: 0.0,
|
||||||
|
|||||||
@@ -175,12 +175,10 @@ fn cmd_json(args: &[String]) -> Cmd {
|
|||||||
fn cmd_daemon(args: &[String]) -> Cmd {
|
fn cmd_daemon(args: &[String]) -> Cmd {
|
||||||
let dir = require_dir(args)?;
|
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()
|
let pool = brahman_sidecar::SidecarPool::new()
|
||||||
.map_err(|e| format!("crear pool: {e}"))?;
|
.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_card = build_engine_card();
|
||||||
let engine_id = engine_card.id;
|
let engine_id = engine_card.id;
|
||||||
let engine_label = engine_card.label.clone();
|
let engine_label = engine_card.label.clone();
|
||||||
@@ -190,20 +188,26 @@ fn cmd_daemon(args: &[String]) -> Cmd {
|
|||||||
);
|
);
|
||||||
pool.spawn(engine_card);
|
pool.spawn(engine_card);
|
||||||
|
|
||||||
// 2. Scan y cluster.
|
// 2. Hidratación: si NOUSER_DB_PATH apunta a un sled poblado,
|
||||||
let (db, n_files) = run_scan(&dir)?;
|
// publicar lo que ya tenemos ANTES del re-scan. brahman-status
|
||||||
eprintln!(
|
// ve mónadas reales en milisegundos, no en segundos.
|
||||||
"nouser daemon: {} archivos en {}, {} mónadas detectadas",
|
let mut db = open_db()?;
|
||||||
n_files,
|
let prior_count = db.monad_count();
|
||||||
dir.display(),
|
if prior_count > 0 {
|
||||||
db.monad_count()
|
let mut hydrated = 0usize;
|
||||||
);
|
let mut skipped_model = 0usize;
|
||||||
|
|
||||||
// 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() {
|
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();
|
let mut card = monad.to_brahman_card();
|
||||||
card.references.push(brahman_card::CardReference {
|
card.references.push(brahman_card::CardReference {
|
||||||
kind: brahman_card::RelationshipKind::OwnedBy,
|
kind: brahman_card::RelationshipKind::OwnedBy,
|
||||||
@@ -211,14 +215,62 @@ fn cmd_daemon(args: &[String]) -> Cmd {
|
|||||||
target_label: engine_label.clone(),
|
target_label: engine_label.clone(),
|
||||||
});
|
});
|
||||||
pool.spawn(card);
|
pool.spawn(card);
|
||||||
count += 1;
|
hydrated += 1;
|
||||||
}
|
}
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"nouser daemon: 1 ente + {} mónadas en pool consolidado. Ctrl-C para terminar.",
|
"nouser daemon: hidratadas {} mónadas previas{} en O(1)",
|
||||||
count
|
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: re-scan {} archivos en {} → {} mónadas",
|
||||||
|
n_files,
|
||||||
|
dir.display(),
|
||||||
|
scanned_count
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
target_id: engine_id,
|
||||||
|
target_label: engine_label.clone(),
|
||||||
|
});
|
||||||
|
pool.spawn(card);
|
||||||
|
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 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();
|
std::thread::park();
|
||||||
drop(pool);
|
drop(pool);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3;
|
|||||||
/// Devuelve un `Vec<MonadManifest>` ordenado por path. Archivos en
|
/// Devuelve un `Vec<MonadManifest>` ordenado por path. Archivos en
|
||||||
/// directorios con menos de `min_files` no producen Mónada.
|
/// directorios con menos de `min_files` no producen Mónada.
|
||||||
pub fn by_directory(files: &[FileEntry], min_files: usize) -> Vec<MonadManifest> {
|
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();
|
let mut by_parent: BTreeMap<PathBuf, Vec<&FileEntry>> = BTreeMap::new();
|
||||||
for f in files {
|
for f in files {
|
||||||
if let Some(parent) = f.path.parent() {
|
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 {
|
if group.len() < min_files {
|
||||||
continue;
|
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
|
out
|
||||||
}
|
}
|
||||||
@@ -69,6 +98,10 @@ fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest
|
|||||||
// Taggeamos el centroide con su modelo. attract verifica esto
|
// Taggeamos el centroide con su modelo. attract verifica esto
|
||||||
// antes de comparar para no mezclar pseudo-32d con real-384d.
|
// antes de comparar para no mezclar pseudo-32d con real-384d.
|
||||||
m.centroid_model = Some(embed::MODEL_ID.to_string());
|
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.members = group.iter().map(|f| f.id).collect();
|
||||||
m.touch();
|
m.touch();
|
||||||
m
|
m
|
||||||
|
|||||||
Reference in New Issue
Block a user