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:
@@ -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
|
entrada lista las acciones concretas tras un commit; para detalles de
|
||||||
ratio/diff ver `git show <sha>`.
|
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
|
## 2026-05-08
|
||||||
|
|
||||||
### chore: profile.dev slim — target/ ~50% más liviano
|
### chore: profile.dev slim — target/ ~50% más liviano
|
||||||
|
|||||||
@@ -125,6 +125,15 @@ pub struct MonadManifest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub centroid: Vec<f32>,
|
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.
|
/// Tokens dominantes: extensiones, palabras clave, etc.
|
||||||
/// 5-10 elementos típicamente.
|
/// 5-10 elementos típicamente.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -203,6 +212,7 @@ impl MonadManifest {
|
|||||||
label: label.into(),
|
label: label.into(),
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
centroid: Vec::new(),
|
centroid: Vec::new(),
|
||||||
|
centroid_model: None,
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
cardinality: 0,
|
cardinality: 0,
|
||||||
entropy: 0.0,
|
entropy: 0.0,
|
||||||
|
|||||||
@@ -266,25 +266,37 @@ fn cmd_attract(args: &[String]) -> Cmd {
|
|||||||
.map(|s| s.to_lowercase()),
|
.map(|s| s.to_lowercase()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Embedding: --remote consulta al socket de nouser-nous; sin flag,
|
// Embedding del target + identificación del modelo que lo produjo.
|
||||||
// se computa localmente. El resultado debe ser idéntico mientras
|
// Local: pseudo-32d. Remote: lo que devuelva el provider electo
|
||||||
// el proveedor sea el mock determinista.
|
// (mock=pseudo-32d, real=fastembed-384d).
|
||||||
let (target_vec, source) = if remote {
|
let (target_vec, target_model, source) = if remote {
|
||||||
let v = remote_embed(&target)?;
|
let (v, model) = remote_embed(&target)?;
|
||||||
(v, "remote")
|
(v, model, "remote")
|
||||||
} else {
|
} 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é
|
// Filtramos Mónadas cuyo centroid_model NO matchee. Mezclar
|
||||||
// Mónadas son secundarias.
|
// 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
|
let mut ranked: Vec<(&nouser_card::MonadManifest, f32)> = db
|
||||||
.monads()
|
.monads()
|
||||||
.filter(|m| !m.centroid.is_empty())
|
.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)))
|
.map(|m| (m, embed::attraction_score(&target_vec, m)))
|
||||||
.collect();
|
.collect();
|
||||||
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
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() {
|
if ranked.is_empty() {
|
||||||
println!("ninguna Mónada con centroide en {}", dir.display());
|
println!("ninguna Mónada con centroide en {}", dir.display());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -292,7 +304,13 @@ fn cmd_attract(args: &[String]) -> Cmd {
|
|||||||
|
|
||||||
println!("archivo: {}", file_path.display());
|
println!("archivo: {}", file_path.display());
|
||||||
println!("scan dir: {}", dir.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!("ranking de atracción (cosine similarity):");
|
||||||
println!();
|
println!();
|
||||||
for (i, (m, score)) in ranked.iter().take(5).enumerate() {
|
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
|
/// el broker corre bajo `BRAHMAN_BROKER_CONTEXT=test/prod`, el
|
||||||
/// proveedor electo cambia sin que este consumer toque su código.
|
/// proveedor electo cambia sin que este consumer toque su código.
|
||||||
/// 3. Con el socket resuelto, dispara la RPC `EmbedFile`.
|
/// 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") {
|
if let Ok(explicit) = std::env::var("NOUSER_NOUS_SOCKET") {
|
||||||
let sock = std::path::PathBuf::from(explicit);
|
let sock = std::path::PathBuf::from(explicit);
|
||||||
return embed_via(&sock, file);
|
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()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_io()
|
.enable_io()
|
||||||
.enable_time()
|
.enable_time()
|
||||||
@@ -347,11 +368,13 @@ fn remote_embed(file: &nouser_card::FileEntry) -> Result<Vec<f32>, Box<dyn std::
|
|||||||
embed_via(&producer_sock, file)
|
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(
|
fn embed_via(
|
||||||
sock_path: &std::path::Path,
|
sock_path: &std::path::Path,
|
||||||
file: &nouser_card::FileEntry,
|
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::io::{BufRead, BufReader, Write};
|
||||||
use std::os::unix::net::UnixStream;
|
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) {
|
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)?;
|
let err: nouser_nous::ErrorResponse = serde_json::from_str(&response)?;
|
||||||
Err(format!("nouser-nous: {}", err.error).into())
|
Err(format!("nouser-nous: {}", err.error).into())
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest
|
|||||||
m.dominant_lens = lens;
|
m.dominant_lens = lens;
|
||||||
m.entropy = entropy;
|
m.entropy = entropy;
|
||||||
m.centroid = centroid;
|
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.members = group.iter().map(|f| f.id).collect();
|
||||||
m.touch();
|
m.touch();
|
||||||
m
|
m
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ use nouser_card::{FileEntry, MonadId, MonadManifest};
|
|||||||
/// Dimensión del vector embedding.
|
/// Dimensión del vector embedding.
|
||||||
pub const EMBED_DIM: usize = 32;
|
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
|
/// Computa el embedding de un archivo. Determinístico: misma input
|
||||||
/// → mismo vector. El vector queda L2-normalizado.
|
/// → mismo vector. El vector queda L2-normalizado.
|
||||||
pub fn embed(file: &FileEntry) -> [f32; EMBED_DIM] {
|
pub fn embed(file: &FileEntry) -> [f32; EMBED_DIM] {
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|||||||
use tokio::net::{UnixListener, UnixStream};
|
use tokio::net::{UnixListener, UnixStream};
|
||||||
use tracing::{info, warn};
|
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")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
|
|||||||
Reference in New Issue
Block a user