diff --git a/CHANGELOG.md b/CHANGELOG.md index 330cebf..06ef9b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ Registro cronológico de cambios sustantivos en el monorepo Brahman. Cada entrada lista las acciones concretas tras un commit; para detalles de ratio/diff ver `git show `. +## 2026-05-09 + +### 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. + +- `MonadManifest.centroid_model: Option` taggea qué modelo + produjo el `centroid`. `None` = legacy pre-versioning. +- `nouser_core::embed::MODEL_ID = "nouser-pseudo-32d"`. El cluster lo + setea en cada Mónada que genera. +- `nouser-nous-mock` reusa la misma constante (`use + nouser_core::embed::MODEL_ID`); produce vectores idénticos al + cluster local, así que reportar el mismo ID es honesto. +- `nouser-nous-real` reporta `"real-fastembed-allMiniLML6V2-384d"` + (dim distinta, semántica distinta). +- `cmd_attract` ahora: + - Captura el `model_id` del embedding del target (local o remote). + - Filtra Mónadas cuyo `centroid_model` no matchee. + - Reporta `embed: ()` y `skipped: N mónadas con + centroid_model distinto` cuando descarta. + +Resultado operativo: cambiar de mock a real (vía +`BRAHMAN_BROKER_CONTEXT=prod`) hace que `attract` filtre las Mónadas +viejas con cero score en lugar de fingir que las puede comparar. + ## 2026-05-08 ### chore: profile.dev slim — target/ ~50% más liviano diff --git a/crates/modules/nouser/card/src/lib.rs b/crates/modules/nouser/card/src/lib.rs index 887651b..8096673 100644 --- a/crates/modules/nouser/card/src/lib.rs +++ b/crates/modules/nouser/card/src/lib.rs @@ -125,6 +125,15 @@ pub struct MonadManifest { #[serde(default)] pub centroid: Vec, + /// Identificador del modelo que produjo `centroid`. Si está set, los + /// consumidores deben verificar coincidencia antes de comparar vía + /// cosine similarity con embeddings recientes; al cambiar de modelo + /// (mock-pseudo-32d → real-fastembed-384d, etc.) los centroides + /// previos quedan inválidos por dimensión y semántica. + /// `None` = legacy (centroides sin tag, pre-versioning). + #[serde(default)] + pub centroid_model: Option, + /// Tokens dominantes: extensiones, palabras clave, etc. /// 5-10 elementos típicamente. #[serde(default)] @@ -203,6 +212,7 @@ impl MonadManifest { label: label.into(), summary: String::new(), centroid: Vec::new(), + centroid_model: 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 0fd1257..504fbf1 100644 --- a/crates/modules/nouser/core/src/bin/nouser.rs +++ b/crates/modules/nouser/core/src/bin/nouser.rs @@ -266,25 +266,37 @@ fn cmd_attract(args: &[String]) -> Cmd { .map(|s| s.to_lowercase()), }; - // Embedding: --remote consulta al socket de nouser-nous; sin flag, - // se computa localmente. El resultado debe ser idéntico mientras - // el proveedor sea el mock determinista. - let (target_vec, source) = if remote { - let v = remote_embed(&target)?; - (v, "remote") + // Embedding del target + identificación del modelo que lo produjo. + // Local: pseudo-32d. Remote: lo que devuelva el provider electo + // (mock=pseudo-32d, real=fastembed-384d). + let (target_vec, target_model, source) = if remote { + let (v, model) = remote_embed(&target)?; + (v, model, "remote") } else { - (embed::embed(&target).to_vec(), "local") + ( + embed::embed(&target).to_vec(), + embed::MODEL_ID.to_string(), + "local", + ) }; - // Ranking completo, no sólo el ganador — útil para entender qué - // Mónadas son secundarias. + // Filtramos Mónadas cuyo centroid_model NO matchee. Mezclar + // 32-d con 384-d daría scores sin sentido (diferente semántica + // y cosine no compara cross-modelo). let mut ranked: Vec<(&nouser_card::MonadManifest, f32)> = db .monads() .filter(|m| !m.centroid.is_empty()) + .filter(|m| match &m.centroid_model { + Some(id) => id == &target_model, + None => true, // legacy sin tag — comparamos best-effort + }) .map(|m| (m, embed::attraction_score(&target_vec, m))) .collect(); ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + let total_monads = db.monads().filter(|m| !m.centroid.is_empty()).count(); + let skipped = total_monads - ranked.len(); + if ranked.is_empty() { println!("ninguna Mónada con centroide en {}", dir.display()); return Ok(()); @@ -292,7 +304,13 @@ fn cmd_attract(args: &[String]) -> Cmd { println!("archivo: {}", file_path.display()); println!("scan dir: {}", dir.display()); - println!("embed: {}", source); + println!("embed: {} ({})", source, target_model); + if skipped > 0 { + println!( + "skipped: {} mónada(s) con centroid_model distinto (no comparables)", + skipped + ); + } println!("ranking de atracción (cosine similarity):"); println!(); for (i, (m, score)) in ranked.iter().take(5).enumerate() { @@ -331,14 +349,17 @@ fn cmd_attract(args: &[String]) -> Cmd { /// el broker corre bajo `BRAHMAN_BROKER_CONTEXT=test/prod`, el /// proveedor electo cambia sin que este consumer toque su código. /// 3. Con el socket resuelto, dispara la RPC `EmbedFile`. -fn remote_embed(file: &nouser_card::FileEntry) -> Result, Box> { +/// +/// Devuelve `(embedding, model_id)` — el caller necesita ambos para +/// comparar contra centroides taggeados con su mismo `centroid_model`. +fn remote_embed( + file: &nouser_card::FileEntry, +) -> Result<(Vec, String), Box> { if let Ok(explicit) = std::env::var("NOUSER_NOUS_SOCKET") { let sock = std::path::PathBuf::from(explicit); return embed_via(&sock, file); } - // Discovery vía broker: el consumer se conecta al brahman-init y - // aprende qué proveedor matchea su input. let rt = tokio::runtime::Builder::new_current_thread() .enable_io() .enable_time() @@ -347,11 +368,13 @@ fn remote_embed(file: &nouser_card::FileEntry) -> Result, Box Result, Box> { +) -> Result<(Vec, String), Box> { use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; @@ -382,7 +405,7 @@ fn embed_via( } if let Ok(resp) = serde_json::from_str::(&response) { - return Ok(resp.embedding); + return Ok((resp.embedding, resp.model)); } let err: nouser_nous::ErrorResponse = serde_json::from_str(&response)?; Err(format!("nouser-nous: {}", err.error).into()) diff --git a/crates/modules/nouser/core/src/cluster.rs b/crates/modules/nouser/core/src/cluster.rs index 2ddc432..a9ded07 100644 --- a/crates/modules/nouser/core/src/cluster.rs +++ b/crates/modules/nouser/core/src/cluster.rs @@ -66,6 +66,9 @@ fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest m.dominant_lens = lens; m.entropy = entropy; m.centroid = centroid; + // 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()); m.members = group.iter().map(|f| f.id).collect(); m.touch(); m diff --git a/crates/modules/nouser/core/src/embed.rs b/crates/modules/nouser/core/src/embed.rs index 785f0e9..19bfb13 100644 --- a/crates/modules/nouser/core/src/embed.rs +++ b/crates/modules/nouser/core/src/embed.rs @@ -32,6 +32,13 @@ use nouser_card::{FileEntry, MonadId, MonadManifest}; /// Dimensión del vector embedding. pub const EMBED_DIM: usize = 32; +/// Identificador del modelo que produce este embedding. Se usa para +/// taggear `MonadManifest.centroid_model`: los consumidores comparan +/// este string contra el suyo antes de hacer cosine similarity. +/// Mezclar centroides de distinto MODEL_ID corrompe scores +/// silenciosamente (dimensiones distintas, semántica distinta). +pub const MODEL_ID: &str = "nouser-pseudo-32d"; + /// Computa el embedding de un archivo. Determinístico: misma input /// → mismo vector. El vector queda L2-normalizado. pub fn embed(file: &FileEntry) -> [f32; EMBED_DIM] { diff --git a/crates/modules/nouser/nous-mock/src/main.rs b/crates/modules/nouser/nous-mock/src/main.rs index 184acdb..4d1e3a5 100644 --- a/crates/modules/nouser/nous-mock/src/main.rs +++ b/crates/modules/nouser/nous-mock/src/main.rs @@ -39,7 +39,11 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tracing::{info, warn}; -const MODEL_ID: &str = "mock-pseudo-32d"; +/// El mock implementa el MISMO algoritmo que `nouser_core::embed`, +/// así que reportamos el mismo `MODEL_ID` que él. De otro modo el +/// consumer filtraría las Mónadas como "modelo distinto" y los +/// scores quedarían vacíos. +const MODEL_ID: &str = nouser_core::embed::MODEL_ID; #[tokio::main(flavor = "current_thread")] async fn main() -> std::io::Result<()> {