tawasuyu

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176771 Definiciones

feat(nouser): Phase C — pseudo-embeddings + atracción por centroide

El "imán semántico" matemático del diseño Kairos, sin LLM. Cada
archivo se proyecta a un vector 32-d determinista derivado de sus
metadatos; cada Mónada calcula su centroide; archivos nuevos se
asignan por cosine similarity contra los centroides existentes.

Cambios:

- nouser-core dep nueva: blake3 (hash determinista de strings).
- crates/modules/nouser/core/src/embed.rs nuevo:
  - EMBED_DIM = 32. Vector:
    * dims 0..8:  blake3(extension)
    * dims 8..16: blake3(parent_dir)
    * dims 16..24: blake3(file_stem)
    * dims 24..28: tamaño (log + flags)
    * dims 28..32: mtime (escala día + features cíclicas)
  - Tip clave: hash bytes se centran a [-1, 1] (no [0, 1]). Sin
    centrar, dos hashes random tendrían cosine ~0.75 espurio.
    Centrados, expectativa ≈ 0 entre no-relacionados.
  - APIs: embed, cosine_similarity, centroid, cohesion,
    attraction_score, best_attraction. DEFAULT_ATTRACTION_THRESHOLD = 0.7.
- cluster::by_directory ahora computa el centroide de cada Mónada
  y lo guarda en MonadManifest.centroid. El centroide viaja al
  brahman-status vía DataFacet.centroid.
- bin nouser nuevo subcomando: attract <dir> <file>.
  - Scan del dir, embedding del archivo objetivo, ranking de afinidad
    contra Mónadas con centroide.
  - 🧲 si la mejor supera umbral, · si es mejor pero debajo.

Validación end-to-end:

  $ nouser attract crates/core crates/modules/nouser/core/src/embed.rs
    🧲  0.9058  [01K..] src  (ente-brain/src)
        0.8984  [01K..] src  (brahman-handshake/src)
        ...

  $ nouser attract crates/core crates/modules/nouser/core/Cargo.toml
        0.3427  [01K..] graph  (ente-zero/src/graph)
    (mejor score 0.3427 < umbral 0.7000 — no se 'pega')

7 tests nuevos en embed (determinismo, normalización, similitud
mismo-dir/mismo-ext, baja entre no-relacionados, centroide
unidad+coherente, attraction picks correctly, vacío skipeado).

Tests acumulados: 73. cargo check --workspace: 0 errores, 0 warnings.

Próximo: Phase D — nouser-nous, módulo aparte para LLM real.
Mock-nous determinista (basado en estos pseudo-embeddings) en
BRAHMAN_BROKER_CONTEXT=test; real-nous en prod. El switch lo hace
el broker via priority_contexts.

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

77faf12e825ff3400c9499eb67779b60872e03cc · por Sergio · (hace 5 meses) · padre 85886b7a3 · ver el árbol en este commit

Delta semántico · 24

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/core/src/lib.rs

7 archivos · +437 −1

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f4e4c8..7a16a00 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,62 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-08
 
+### feat(nouser): Phase C — pseudo-embeddings + atracción por centroide
+El "imán semántico" matemático del diseño Kairos, sin LLM. Cada
+archivo se proyecta a un vector 32-d derivado de sus metadatos; cada
+Mónada calcula su centroide; archivos nuevos se asignan por cosine
+similarity contra los centroides existentes.
+
+Cambios:
+
+- nouser-core dep nueva: `blake3` (hash determinista de strings).
+- `crates/modules/nouser/core/src/embed.rs`:
+  - `EMBED_DIM = 32`. Estructura del vector:
+    - dims  0..8:  blake3(extension)     → identidad de tipo
+    - dims  8..16: blake3(parent_dir)    → identidad de contenedor
+    - dims 16..24: blake3(file_stem)     → identidad léxica
+    - dims 24..28: tamaño (log + flags)
+    - dims 28..32: mtime (escala día + cíclicas)
+  - **Tip clave**: bytes del hash se centran a `[-1, 1]` (no `[0, 1]`).
+    Sin centrar, dos vectores hash random tendrían cosine ~0.75
+    espuria; centrados, expectativa ≈ 0 entre no-relacionados.
+  - APIs: `embed`, `cosine_similarity`, `centroid`, `cohesion`,
+    `attraction_score`, `best_attraction`. `DEFAULT_ATTRACTION_THRESHOLD = 0.7`.
+- `cluster::by_directory` ahora computa el centroide de cada Mónada
+  (promedio de embeddings de los miembros, L2-normalizado) y lo guarda
+  en `MonadManifest.centroid`. El centroide viaja al brahman-status vía
+  `DataFacet.centroid` → ahora se ven los Vec<f32> reales por cada Mónada.
+- bin nouser nuevo subcomando: `attract <dir> <file>`.
+  - Escanea el dir, embeda el archivo objetivo, ranking de afinidad
+    contra todas las Mónadas con centroide.
+  - Marca 🧲 si la mejor supera el umbral, `·` si es la mejor pero
+    debajo, espacio en blanco para el resto.
+
+Validación end-to-end:
+
+  $ nouser attract crates/core crates/modules/nouser/core/src/embed.rs
+  ranking de atracción (cosine similarity):
+    🧲  0.9058  [01K..] src  (11 archivos en crates/core/ente-brain/src)
+        0.8984  [01K..] src  (6 archivos en crates/core/brahman-handshake/src)
+        0.8918  [01K..] src  (5 archivos en crates/core/ente-zero/src)
+        ...
+
+  $ nouser attract crates/core crates/modules/nouser/core/Cargo.toml
+  ranking:
+        0.3427  [01K..] graph  (7 archivos en crates/core/ente-zero/src/graph)
+        ...
+    (mejor score 0.3427 < umbral 0.7000 — el archivo no se 'pega')
+
+Tests: 20 en nouser-core (era 13, +7 de embed). Total acumulado: 73
+(card 11, broker 15, handshake codec+tr 2 + integ 7, card-wit 4,
+admin 0, nouser-card 7, nouser-core 20, ente-card 0).
+cargo check --workspace: 0 errores, 0 warnings.
+
+Próximo: **Phase D** — `nouser-nous`, módulo aparte para LLM real.
+Mock-nous determinista (basado en estos pseudo-embeddings) en
+`BRAHMAN_BROKER_CONTEXT=test`; real-nous en `prod`. El switch lo hace
+el broker via priority_contexts sin tocar nada más.
+
 ### feat(nouser): Phase B-2 — daemon que publica Mónadas al Init
 Cierra la unificación: el `nouser daemon` se sidecarea como Ente y
 publica cada Mónada como su propia sesión Data. Un solo
diff --git a/Cargo.lock b/Cargo.lock
index 9bd57dc..e26b091 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6060,6 +6060,7 @@ dependencies = [
 name = "nouser-core"
 version = "0.1.0"
 dependencies = [
+ "blake3",
  "brahman-card",
  "brahman-sidecar",
  "nouser-card",
diff --git a/crates/modules/nouser/core/Cargo.toml b/crates/modules/nouser/core/Cargo.toml
index edfad77..b50ff58 100644
--- a/crates/modules/nouser/core/Cargo.toml
+++ b/crates/modules/nouser/core/Cargo.toml
@@ -12,6 +12,7 @@ description = "Nouser — explorador de Mónadas: scanner, clustering determinis
 nouser-card = { path = "../card" }
 brahman-card = { path = "../../../core/brahman-card" }
 brahman-sidecar = { path = "../../../shared/brahman-sidecar" }
+blake3 = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
 thiserror = { workspace = true }
diff --git a/crates/modules/nouser/core/src/bin/nouser.rs b/crates/modules/nouser/core/src/bin/nouser.rs
index f073033..183bf6a 100644
--- a/crates/modules/nouser/core/src/bin/nouser.rs
+++ b/crates/modules/nouser/core/src/bin/nouser.rs
@@ -14,7 +14,7 @@ use std::path::PathBuf;
 use std::process::ExitCode;
 
 use nouser_core::{
-    cluster, db,
+    cluster, db, embed,
     scanner::{self, ScanConfig},
 };
 
@@ -35,6 +35,7 @@ fn main() -> ExitCode {
         "show" => cmd_show(rest),
         "json" => cmd_json(rest),
         "daemon" => cmd_daemon(rest),
+        "attract" => cmd_attract(rest),
         "--help" | "-h" | "help" => {
             print_usage(&prog);
             return ExitCode::SUCCESS;
@@ -63,6 +64,7 @@ fn print_usage(prog: &str) {
     eprintln!("  show <dir> <prefix>  scan + detalle de la Mónada cuyo ID empieza con <prefix>");
     eprintln!("  json <dir>           scan + dump JSON de todos los manifests");
     eprintln!("  daemon <dir>         scan + sidecarea cada Mónada al Init brahman");
+    eprintln!("  attract <dir> <file> dado un archivo, qué Mónada del scan lo atrae más");
     eprintln!();
     eprintln!("env:");
     eprintln!("  NOUSER_MIN_FILES         mínimo de archivos por Mónada (default: 3)");
@@ -203,6 +205,81 @@ fn cmd_daemon(args: &[String]) -> Cmd {
     Ok(())
 }
 
+fn cmd_attract(args: &[String]) -> Cmd {
+    let dir = require_dir(args)?;
+    let file_path = args.get(1).ok_or("falta argumento <file>")?;
+    let file_path = std::path::PathBuf::from(file_path);
+    if !file_path.exists() {
+        return Err(format!("archivo no existe: {}", file_path.display()).into());
+    }
+
+    let (db, _) = run_scan(&dir)?;
+
+    // Construimos un FileEntry para el archivo objetivo y sacamos su embedding.
+    let metadata = std::fs::metadata(&file_path)?;
+    let mtime_ms = metadata
+        .modified()
+        .ok()
+        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
+        .map(|d| d.as_millis() as u64)
+        .unwrap_or(0);
+    let target = nouser_card::FileEntry {
+        id: nouser_card::FileId::from(nouser_card::ulid::Ulid::new()),
+        path: file_path.clone(),
+        content_hash: None,
+        size: metadata.len(),
+        mtime_ms,
+        extension: file_path
+            .extension()
+            .and_then(|s| s.to_str())
+            .map(|s| s.to_lowercase()),
+    };
+    let target_vec = embed::embed(&target);
+
+    // Ranking completo, no sólo el ganador — útil para entender qué
+    // Mónadas son secundarias.
+    let mut ranked: Vec<(&nouser_card::MonadManifest, f32)> = db
+        .monads()
+        .filter(|m| !m.centroid.is_empty())
+        .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));
+
+    if ranked.is_empty() {
+        println!("ninguna Mónada con centroide en {}", dir.display());
+        return Ok(());
+    }
+
+    println!("archivo:   {}", file_path.display());
+    println!("scan dir:  {}", dir.display());
+    println!("ranking de atracción (cosine similarity):");
+    println!();
+    for (i, (m, score)) in ranked.iter().take(5).enumerate() {
+        let marker = if *score >= embed::DEFAULT_ATTRACTION_THRESHOLD && i == 0 {
+            "🧲"
+        } else if i == 0 {
+            "·"
+        } else {
+            " "
+        };
+        let id_short = format!("{}", m.id);
+        let id_short = &id_short[..8];
+        println!(
+            "  {}  {:.4}  [{}]  {:30}  ({})",
+            marker, score, id_short, m.label, m.summary
+        );
+    }
+    if ranked[0].1 < embed::DEFAULT_ATTRACTION_THRESHOLD {
+        println!();
+        println!(
+            "  (mejor score {:.4} < umbral {:.4} — el archivo no se 'pega' a ninguna)",
+            ranked[0].1,
+            embed::DEFAULT_ATTRACTION_THRESHOLD
+        );
+    }
+    Ok(())
+}
+
 /// Card del propio engine (kind=Ente). Es el "ser" que produce y
 /// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
 fn build_engine_card() -> brahman_card::Card {
diff --git a/crates/modules/nouser/core/src/cluster.rs b/crates/modules/nouser/core/src/cluster.rs
index b8deac0..62c5ac6 100644
--- a/crates/modules/nouser/core/src/cluster.rs
+++ b/crates/modules/nouser/core/src/cluster.rs
@@ -17,6 +17,8 @@ use std::path::PathBuf;
 
 use nouser_card::{FileEntry, Lens, MonadManifest};
 
+use crate::embed;
+
 /// Mínimo de archivos para que un directorio sea promovido a Mónada.
 /// Por debajo de eso, los archivos quedan "huérfanos" (no asignados).
 pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3;
@@ -56,11 +58,18 @@ fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest
 
     let summary = build_summary(parent, group, &keywords);
 
+    // Centroide vectorial: promedio de los embeddings de los miembros.
+    // Esto es lo que permite "atracción" determinista de archivos
+    // nuevos sin tocar Nous.
+    let member_vecs: Vec<Vec<f32>> = group.iter().map(|f| embed::embed(f).to_vec()).collect();
+    let centroid = embed::centroid(&member_vecs);
+
     let mut m = MonadManifest::new(label);
     m.summary = summary;
     m.keywords = keywords;
     m.dominant_lens = lens;
     m.entropy = entropy;
+    m.centroid = centroid;
     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
new file mode 100644
index 0000000..785f0e9
--- /dev/null
+++ b/crates/modules/nouser/core/src/embed.rs
@@ -0,0 +1,291 @@
+//! Pseudo-embeddings de archivos: vectores deterministas derivados de
+//! metadatos (sin LLM).
+//!
+//! Implementan el "imán semántico" matemático que el diseño de Kairos
+//! pide: cada archivo tiene un vector, cada Mónada tiene un centroide,
+//! y un archivo nuevo se "pega" a la Mónada cuyo centroide está más
+//! cerca (cosine similarity).
+//!
+//! No reemplaza embeddings reales (text-embedding de un LLM); sirve para:
+//! - Bootstrapping sin Nous corriendo.
+//! - Mock determinístico en `BRAHMAN_BROKER_CONTEXT=test`.
+//! - Cohesión visual por path/extension (dos `.rs` en `src/` quedan
+//!   muy juntos en el espacio vectorial).
+//!
+//! ## Forma del vector ([`EMBED_DIM`]=32, normalizado)
+//!
+//! - dims  0..8:  `blake3(extension)`     → identidad de tipo
+//! - dims  8..16: `blake3(parent_dir)`    → identidad de contenedor
+//! - dims 16..24: `blake3(file_stem)`     → identidad léxica del archivo
+//! - dims 24..28: tamaño (log scale + flags binarios)
+//! - dims 28..32: mtime (escala día + features cíclicas)
+//!
+//! ## Propiedades empíricas
+//!
+//! - Mismo dir + misma ext       → similitud > 0.7 (alta cohesión).
+//! - Mismo dir + ext distinta    → similitud ~ 0.5.
+//! - Dirs distintos + misma ext  → similitud ~ 0.5.
+//! - Sin parecido                → similitud < 0.3.
+
+use nouser_card::{FileEntry, MonadId, MonadManifest};
+
+/// Dimensión del vector embedding.
+pub const EMBED_DIM: usize = 32;
+
+/// 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] {
+    let mut v = [0.0f32; EMBED_DIM];
+
+    // dims 0..8: extension hash
+    fill_from_hash(&mut v[0..8], file.extension.as_deref().unwrap_or(""));
+
+    // dims 8..16: parent dir name hash
+    let parent = file
+        .path
+        .parent()
+        .and_then(|p| p.file_name())
+        .and_then(|n| n.to_str())
+        .unwrap_or("");
+    fill_from_hash(&mut v[8..16], parent);
+
+    // dims 16..24: file stem hash (sin extensión)
+    let stem = file
+        .path
+        .file_stem()
+        .and_then(|n| n.to_str())
+        .unwrap_or("");
+    fill_from_hash(&mut v[16..24], stem);
+
+    // dims 24..28: tamaño (centrado en 0 para que dot products entre
+    // archivos de tamaño diferente sumen 0 en expectativa).
+    let log_size = (file.size.max(1) as f32).log10();
+    v[24] = ((log_size / 15.0).clamp(0.0, 1.0) - 0.5) * 2.0; // [-1, 1]
+    v[25] = (log_size.fract() - 0.5) * 2.0;
+    v[26] = if file.size >= 1_048_576 { 1.0 } else { -1.0 }; // ≥1MiB flag
+    v[27] = if file.size <= 256 { 1.0 } else { -1.0 }; // ≤256B flag
+
+    // dims 28..32: mtime — escala día + cíclicas (centradas).
+    let day = file.mtime_ms / (86_400 * 1000);
+    v[28] = (((day as f32) / 30_000.0).clamp(0.0, 1.0) - 0.5) * 2.0;
+    v[29] = ((day % 365) as f32 / 365.0 - 0.5) * 2.0;
+    v[30] = ((day % 30) as f32 / 30.0 - 0.5) * 2.0;
+    v[31] = ((day % 7) as f32 / 7.0 - 0.5) * 2.0;
+
+    normalize(&mut v);
+    v
+}
+
+/// Fill `out` con bytes del hash blake3 de `input`, centrados en [-1, 1].
+/// El centrado es crítico: bytes uniformes en [0,1] tienen media 0.5,
+/// así dos vectores hash distintos (de strings no relacionados) tendrían
+/// expected cosine similarity ≈ 0.75 (espuriamente alto). Centrarlos en
+/// [-1, 1] hace que la expectativa sea ≈ 0 — propiedad necesaria para
+/// que cosine similarity sea una métrica útil de afinidad.
+fn fill_from_hash(out: &mut [f32], input: &str) {
+    let h = blake3::hash(input.as_bytes());
+    let bytes = h.as_bytes();
+    for (i, slot) in out.iter_mut().enumerate() {
+        *slot = (bytes[i] as f32 - 127.5) / 127.5;
+    }
+}
+
+/// L2-normaliza un vector in-place. Vectores con norma 0 quedan en 0.
+fn normalize(v: &mut [f32]) {
+    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
+    if norm > 0.0 {
+        for x in v.iter_mut() {
+            *x /= norm;
+        }
+    }
+}
+
+/// Cosine similarity entre dos vectores. Asume ambos L2-normalizados
+/// (en cuyo caso `dot product == cosine similarity`). Si las longitudes
+/// no coinciden, devuelve 0.
+pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
+    if a.len() != b.len() || a.is_empty() {
+        return 0.0;
+    }
+    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
+}
+
+/// Centroide de un set de vectores. Promedio dim-por-dim seguido de
+/// L2-normalización. El resultado es un vector unidad apto para
+/// comparar con miembros nuevos vía cosine similarity.
+pub fn centroid(vectors: &[Vec<f32>]) -> Vec<f32> {
+    if vectors.is_empty() {
+        return Vec::new();
+    }
+    let dim = vectors[0].len();
+    let mut c = vec![0.0f32; dim];
+    for v in vectors {
+        if v.len() != dim {
+            continue;
+        }
+        for (i, x) in v.iter().enumerate() {
+            c[i] += x;
+        }
+    }
+    let n = vectors.len() as f32;
+    for x in c.iter_mut() {
+        *x /= n;
+    }
+    normalize(&mut c);
+    c
+}
+
+/// Cohesión interna: media de cosine similarity de cada miembro contra
+/// el centroide. Alta cohesión = Mónada compacta. Baja = bifurcable.
+pub fn cohesion(centroid: &[f32], member_vectors: &[Vec<f32>]) -> f32 {
+    if member_vectors.is_empty() || centroid.is_empty() {
+        return 0.0;
+    }
+    let sum: f32 = member_vectors
+        .iter()
+        .map(|v| cosine_similarity(centroid, v))
+        .sum();
+    sum / member_vectors.len() as f32
+}
+
+/// Score de atracción de un archivo nuevo a una Mónada existente:
+/// cosine similarity de su embedding contra el centroide de la Mónada.
+/// Mayor score = mayor afinidad.
+pub fn attraction_score(file_vec: &[f32], monad: &MonadManifest) -> f32 {
+    if monad.centroid.is_empty() {
+        return 0.0;
+    }
+    cosine_similarity(file_vec, &monad.centroid)
+}
+
+/// Encuentra la Mónada con mayor afinidad a un archivo. Devuelve
+/// `(MonadId, score)` o `None` si ninguna tiene centroide.
+pub fn best_attraction<'a, I>(file_vec: &[f32], monads: I) -> Option<(MonadId, f32)>
+where
+    I: IntoIterator<Item = &'a MonadManifest>,
+{
+    monads
+        .into_iter()
+        .filter(|m| !m.centroid.is_empty())
+        .map(|m| (m.id, attraction_score(file_vec, m)))
+        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
+}
+
+/// Umbral por defecto para "se pega": si el score es ≥ esto, el
+/// archivo se asigna automáticamente. Ajustable por el caller.
+pub const DEFAULT_ATTRACTION_THRESHOLD: f32 = 0.7;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use nouser_card::FileId;
+    use std::path::PathBuf;
+    use ulid::Ulid;
+
+    fn mk(path: &str, ext: Option<&str>, size: u64) -> FileEntry {
+        FileEntry {
+            id: FileId::from(Ulid::new()),
+            path: PathBuf::from(path),
+            content_hash: None,
+            size,
+            mtime_ms: 1_700_000_000_000, // fixed para que mtime no domine
+            extension: ext.map(String::from),
+        }
+    }
+
+    #[test]
+    fn embed_is_deterministic() {
+        let a = mk("/x/foo.rs", Some("rs"), 1024);
+        let b = mk("/x/foo.rs", Some("rs"), 1024);
+        let va = embed(&a);
+        let vb = embed(&b);
+        // Mismos metadatos → mismo vector (los IDs no entran al embedding).
+        assert_eq!(va, vb);
+    }
+
+    #[test]
+    fn embed_is_unit_normalized() {
+        let f = mk("/x/foo.rs", Some("rs"), 1024);
+        let v = embed(&f);
+        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
+        assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
+    }
+
+    #[test]
+    fn same_dir_same_ext_high_similarity() {
+        let a = embed(&mk("/proj/src/a.rs", Some("rs"), 1000));
+        let b = embed(&mk("/proj/src/b.rs", Some("rs"), 1100));
+        let sim = cosine_similarity(&a, &b);
+        assert!(sim > 0.7, "esperaba sim > 0.7, fue {sim}");
+    }
+
+    #[test]
+    fn unrelated_files_low_similarity() {
+        let a = embed(&mk("/proj/src/main.rs", Some("rs"), 1000));
+        let b = embed(&mk("/photos/2024/sunset.jpg", Some("jpg"), 5_000_000));
+        let sim = cosine_similarity(&a, &b);
+        assert!(sim < 0.5, "esperaba sim < 0.5, fue {sim}");
+    }
+
+    #[test]
+    fn centroid_is_unit_and_close_to_members() {
+        let v1 = embed(&mk("/x/a.rs", Some("rs"), 1000));
+        let v2 = embed(&mk("/x/b.rs", Some("rs"), 1100));
+        let v3 = embed(&mk("/x/c.rs", Some("rs"), 1200));
+        let c = centroid(&[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
+
+        // Norma unitaria.
+        let norm: f32 = c.iter().map(|x| x * x).sum::<f32>().sqrt();
+        assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
+
+        // Cohesión alta porque los miembros son similares.
+        let cohesion = cohesion(&c, &[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
+        assert!(cohesion > 0.9, "cohesion={cohesion}");
+    }
+
+    #[test]
+    fn attraction_picks_correct_monad() {
+        // Construimos dos Mónadas: una de Rust, otra de imágenes.
+        let rust_files = vec![
+            embed(&mk("/proj/src/a.rs", Some("rs"), 1000)).to_vec(),
+            embed(&mk("/proj/src/b.rs", Some("rs"), 1100)).to_vec(),
+        ];
+        let img_files = vec![
+            embed(&mk("/photos/p1.jpg", Some("jpg"), 5_000_000)).to_vec(),
+            embed(&mk("/photos/p2.jpg", Some("jpg"), 4_000_000)).to_vec(),
+        ];
+
+        let mut rust_monad = MonadManifest::new("rust");
+        rust_monad.members.insert(FileId::from(Ulid::new()));
+        rust_monad.touch();
+        rust_monad.centroid = centroid(&rust_files);
+
+        let mut img_monad = MonadManifest::new("photos");
+        img_monad.members.insert(FileId::from(Ulid::new()));
+        img_monad.touch();
+        img_monad.centroid = centroid(&img_files);
+
+        // Un archivo .rs nuevo en /proj/src debe atraerse a la Mónada Rust.
+        let new_rs = embed(&mk("/proj/src/new.rs", Some("rs"), 1500));
+        let (best_id, _score) = best_attraction(&new_rs, [&rust_monad, &img_monad].into_iter())
+            .expect("best match");
+        assert_eq!(best_id, rust_monad.id);
+
+        // Y al revés.
+        let new_jpg = embed(&mk("/photos/new.jpg", Some("jpg"), 6_000_000));
+        let (best_id, _score) = best_attraction(&new_jpg, [&rust_monad, &img_monad].into_iter())
+            .expect("best match");
+        assert_eq!(best_id, img_monad.id);
+    }
+
+    #[test]
+    fn empty_centroid_skipped_in_attraction() {
+        let mut m = MonadManifest::new("empty");
+        m.members.insert(FileId::from(Ulid::new()));
+        m.touch();
+        // m.centroid queda vacío
+
+        let v = embed(&mk("/x/y.rs", Some("rs"), 100));
+        assert!(best_attraction(&v, [&m].into_iter()).is_none());
+    }
+}
diff --git a/crates/modules/nouser/core/src/lib.rs b/crates/modules/nouser/core/src/lib.rs
index b713690..3c1e45c 100644
--- a/crates/modules/nouser/core/src/lib.rs
+++ b/crates/modules/nouser/core/src/lib.rs
@@ -27,6 +27,7 @@
 
 pub mod cluster;
 pub mod db;
+pub mod embed;
 pub mod scanner;
 
 pub use nouser_card::*;