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 `. ## 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 reales por cada Mónada. +- bin nouser nuevo subcomando: `attract `. + - 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 scan + detalle de la Mónada cuyo ID empieza con "); eprintln!(" json scan + dump JSON de todos los manifests"); eprintln!(" daemon scan + sidecarea cada Mónada al Init brahman"); + eprintln!(" attract 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 ")?; + 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> = 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::().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]) -> Vec { + 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 { + 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, +{ + 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::().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::().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::*;