tawasuyu

tawasuyu/brahman

Rust 463TOML 180Markdown 49JSON 176353 Definitions

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 daría scores sin sentido.

- MonadManifest.centroid_model: Option<String>. None = legacy.
- nouser_core::embed::MODEL_ID = "nouser-pseudo-32d". 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, reportar el mismo ID es honesto.
- nouser-nous-real ya reportaba "real-fastembed-allMiniLML6V2-384d";
  el filter ahora lo descarta automáticamente cuando los centroides
  cacheados son del mock.
- cmd_attract:
  - Captura el model_id del embedding del target.
  - Filtra Mónadas cuyo centroid_model no matchee.
  - Reporta "embed: <source> (<model>)" y "skipped: N" cuando
    descarta.

Resultado: 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.

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

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

820a1a33bfcbc7f502f6282a3202181ab709de5b · by Sergio · (5 months ago) · parent 9c371ee43 · browse the tree at this commit

Semantic delta · 8

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

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

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

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

crates/modules/nouser/nous-mock/src/main.rs

6 files · +89 −17

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 <sha>`.
 
+## 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<String>` 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: <source> (<model>)` 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<f32>,
 
+    /// 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<String>,
+
     /// 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<Vec<f32>, Box<dyn std::error::Error>> {
+///
+/// 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<f32>, String), Box<dyn std::error::Error>> {
     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<Vec<f32>, Box<dyn std::
     embed_via(&producer_sock, file)
 }
 
-/// RPC blocking contra un socket nouser-nous concreto.
+/// RPC blocking contra un socket nouser-nous concreto. Devuelve
+/// `(embedding, model_id)` — el `model_id` viaja en la response y
+/// permite al caller saber qué centroides son comparables.
 fn embed_via(
     sock_path: &std::path::Path,
     file: &nouser_card::FileEntry,
-) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
+) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
     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::<nouser_nous::EmbedResponse>(&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<()> {