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>
This commit is contained in:
Sergio
2026-05-09 00:24:38 +00:00
parent 9c371ee43e
commit 820a1a33bf
6 changed files with 89 additions and 17 deletions
+39 -16
View File
@@ -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())