tawasuyu

tawasuyu/brahman

Rust 463TOML 180Markdown 49JSON 176353 Definiciones

⎇ main crates/modules/chasqui/core/src/embed.rs Rust · 298 líneas · 10.8 KB Historia Quién tocó qué crudo

refactor(naming): A1 — ente→arje, vista→revista, pluma→fana b83d40a83 · sergio ·

1//! Pseudo-embeddings de archivos: vectores deterministas derivados de
2//! metadatos (sin LLM).
3//!
4//! Implementan el "imán semántico" matemático que el diseño de Kairos
5//! pide: cada archivo tiene un vector, cada Mónada tiene un centroide,
6//! y un archivo nuevo se "pega" a la Mónada cuyo centroide está más
7//! cerca (cosine similarity).
8//!
9//! No reemplaza embeddings reales (text-embedding de un LLM); sirve para:
10//! - Bootstrapping sin Nous corriendo.
11//! - Mock determinístico en `BRAHMAN_BROKER_CONTEXT=test`.
12//! - Cohesión visual por path/extension (dos `.rs` en `src/` quedan
13//!   muy juntos en el espacio vectorial).
14//!
15//! ## Forma del vector ([`EMBED_DIM`]=32, normalizado)
16//!
17//! - dims  0..8:  `blake3(extension)`     → identidad de tipo
18//! - dims  8..16: `blake3(parent_dir)`    → identidad de contenedor
19//! - dims 16..24: `blake3(file_stem)`     → identidad léxica del archivo
20//! - dims 24..28: tamaño (log scale + flags binarios)
21//! - dims 28..32: mtime (escala día + features cíclicas)
22//!
23//! ## Propiedades empíricas
24//!
25//! - Mismo dir + misma ext       → similitud > 0.7 (alta cohesión).
26//! - Mismo dir + ext distinta    → similitud ~ 0.5.
27//! - Dirs distintos + misma ext  → similitud ~ 0.5.
28//! - Sin parecido                → similitud < 0.3.
29
30use chasqui_card::{FileEntry, MonadId, MonadManifest};
31
32/// Dimensión del vector embedding.
33pub const EMBED_DIM: usize = 32;
34
35/// Identificador del modelo que produce este embedding. Se usa para
36/// taggear `MonadManifest.centroid_model`: los consumidores comparan
37/// este string contra el suyo antes de hacer cosine similarity.
38/// Mezclar centroides de distinto MODEL_ID corrompe scores
39/// silenciosamente (dimensiones distintas, semántica distinta).
40pub const MODEL_ID: &str = "chasqui-pseudo-32d";
41
42/// Computa el embedding de un archivo. Determinístico: misma input
43/// → mismo vector. El vector queda L2-normalizado.
44pub fn embed(file: &FileEntry) -> [f32; EMBED_DIM] {
45    let mut v = [0.0f32; EMBED_DIM];
46
47    // dims 0..8: extension hash
48    fill_from_hash(&mut v[0..8], file.extension.as_deref().unwrap_or(""));
49
50    // dims 8..16: parent dir name hash
51    let parent = file
52        .path
53        .parent()
54        .and_then(|p| p.file_name())
55        .and_then(|n| n.to_str())
56        .unwrap_or("");
57    fill_from_hash(&mut v[8..16], parent);
58
59    // dims 16..24: file stem hash (sin extensión)
60    let stem = file
61        .path
62        .file_stem()
63        .and_then(|n| n.to_str())
64        .unwrap_or("");
65    fill_from_hash(&mut v[16..24], stem);
66
67    // dims 24..28: tamaño (centrado en 0 para que dot products entre
68    // archivos de tamaño diferente sumen 0 en expectativa).
69    let log_size = (file.size.max(1) as f32).log10();
70    v[24] = ((log_size / 15.0).clamp(0.0, 1.0) - 0.5) * 2.0; // [-1, 1]
71    v[25] = (log_size.fract() - 0.5) * 2.0;
72    v[26] = if file.size >= 1_048_576 { 1.0 } else { -1.0 }; // ≥1MiB flag
73    v[27] = if file.size <= 256 { 1.0 } else { -1.0 }; // ≤256B flag
74
75    // dims 28..32: mtime — escala día + cíclicas (centradas).
76    let day = file.mtime_ms / (86_400 * 1000);
77    v[28] = (((day as f32) / 30_000.0).clamp(0.0, 1.0) - 0.5) * 2.0;
78    v[29] = ((day % 365) as f32 / 365.0 - 0.5) * 2.0;
79    v[30] = ((day % 30) as f32 / 30.0 - 0.5) * 2.0;
80    v[31] = ((day % 7) as f32 / 7.0 - 0.5) * 2.0;
81
82    normalize(&mut v);
83    v
84}
85
86/// Fill `out` con bytes del hash blake3 de `input`, centrados en [-1, 1].
87/// El centrado es crítico: bytes uniformes en [0,1] tienen media 0.5,
88/// así dos vectores hash distintos (de strings no relacionados) tendrían
89/// expected cosine similarity ≈ 0.75 (espuriamente alto). Centrarlos en
90/// [-1, 1] hace que la expectativa sea ≈ 0 — propiedad necesaria para
91/// que cosine similarity sea una métrica útil de afinidad.
92fn fill_from_hash(out: &mut [f32], input: &str) {
93    let h = blake3::hash(input.as_bytes());
94    let bytes = h.as_bytes();
95    for (i, slot) in out.iter_mut().enumerate() {
96        *slot = (bytes[i] as f32 - 127.5) / 127.5;
97    }
98}
99
100/// L2-normaliza un vector in-place. Vectores con norma 0 quedan en 0.
101fn normalize(v: &mut [f32]) {
102    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
103    if norm > 0.0 {
104        for x in v.iter_mut() {
105            *x /= norm;
106        }
107    }
108}
109
110/// Cosine similarity entre dos vectores. Asume ambos L2-normalizados
111/// (en cuyo caso `dot product == cosine similarity`). Si las longitudes
112/// no coinciden, devuelve 0.
113pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
114    if a.len() != b.len() || a.is_empty() {
115        return 0.0;
116    }
117    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
118}
119
120/// Centroide de un set de vectores. Promedio dim-por-dim seguido de
121/// L2-normalización. El resultado es un vector unidad apto para
122/// comparar con miembros nuevos vía cosine similarity.
123pub fn centroid(vectors: &[Vec<f32>]) -> Vec<f32> {
124    if vectors.is_empty() {
125        return Vec::new();
126    }
127    let dim = vectors[0].len();
128    let mut c = vec![0.0f32; dim];
129    for v in vectors {
130        if v.len() != dim {
131            continue;
132        }
133        for (i, x) in v.iter().enumerate() {
134            c[i] += x;
135        }
136    }
137    let n = vectors.len() as f32;
138    for x in c.iter_mut() {
139        *x /= n;
140    }
141    normalize(&mut c);
142    c
143}
144
145/// Cohesión interna: media de cosine similarity de cada miembro contra
146/// el centroide. Alta cohesión = Mónada compacta. Baja = bifurcable.
147pub fn cohesion(centroid: &[f32], member_vectors: &[Vec<f32>]) -> f32 {
148    if member_vectors.is_empty() || centroid.is_empty() {
149        return 0.0;
150    }
151    let sum: f32 = member_vectors
152        .iter()
153        .map(|v| cosine_similarity(centroid, v))
154        .sum();
155    sum / member_vectors.len() as f32
156}
157
158/// Score de atracción de un archivo nuevo a una Mónada existente:
159/// cosine similarity de su embedding contra el centroide de la Mónada.
160/// Mayor score = mayor afinidad.
161pub fn attraction_score(file_vec: &[f32], monad: &MonadManifest) -> f32 {
162    if monad.centroid.is_empty() {
163        return 0.0;
164    }
165    cosine_similarity(file_vec, &monad.centroid)
166}
167
168/// Encuentra la Mónada con mayor afinidad a un archivo. Devuelve
169/// `(MonadId, score)` o `None` si ninguna tiene centroide.
170pub fn best_attraction<'a, I>(file_vec: &[f32], monads: I) -> Option<(MonadId, f32)>
171where
172    I: IntoIterator<Item = &'a MonadManifest>,
173{
174    monads
175        .into_iter()
176        .filter(|m| !m.centroid.is_empty())
177        .map(|m| (m.id, attraction_score(file_vec, m)))
178        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
179}
180
181/// Umbral por defecto para "se pega": si el score es ≥ esto, el
182/// archivo se asigna automáticamente. Ajustable por el caller.
183pub const DEFAULT_ATTRACTION_THRESHOLD: f32 = 0.7;
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use chasqui_card::FileId;
189    use std::path::PathBuf;
190    use ulid::Ulid;
191
192    fn mk(path: &str, ext: Option<&str>, size: u64) -> FileEntry {
193        FileEntry {
194            id: FileId::from(Ulid::new()),
195            path: PathBuf::from(path),
196            content_hash: None,
197            size,
198            mtime_ms: 1_700_000_000_000, // fixed para que mtime no domine
199            extension: ext.map(String::from),
200        }
201    }
202
203    #[test]
204    fn embed_is_deterministic() {
205        let a = mk("/x/foo.rs", Some("rs"), 1024);
206        let b = mk("/x/foo.rs", Some("rs"), 1024);
207        let va = embed(&a);
208        let vb = embed(&b);
209        // Mismos metadatos → mismo vector (los IDs no entran al embedding).
210        assert_eq!(va, vb);
211    }
212
213    #[test]
214    fn embed_is_unit_normalized() {
215        let f = mk("/x/foo.rs", Some("rs"), 1024);
216        let v = embed(&f);
217        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
218        assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
219    }
220
221    #[test]
222    fn same_dir_same_ext_high_similarity() {
223        let a = embed(&mk("/proj/src/a.rs", Some("rs"), 1000));
224        let b = embed(&mk("/proj/src/b.rs", Some("rs"), 1100));
225        let sim = cosine_similarity(&a, &b);
226        assert!(sim > 0.7, "esperaba sim > 0.7, fue {sim}");
227    }
228
229    #[test]
230    fn unrelated_files_low_similarity() {
231        let a = embed(&mk("/proj/src/main.rs", Some("rs"), 1000));
232        let b = embed(&mk("/photos/2024/sunset.jpg", Some("jpg"), 5_000_000));
233        let sim = cosine_similarity(&a, &b);
234        assert!(sim < 0.5, "esperaba sim < 0.5, fue {sim}");
235    }
236
237    #[test]
238    fn centroid_is_unit_and_close_to_members() {
239        let v1 = embed(&mk("/x/a.rs", Some("rs"), 1000));
240        let v2 = embed(&mk("/x/b.rs", Some("rs"), 1100));
241        let v3 = embed(&mk("/x/c.rs", Some("rs"), 1200));
242        let c = centroid(&[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
243
244        // Norma unitaria.
245        let norm: f32 = c.iter().map(|x| x * x).sum::<f32>().sqrt();
246        assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
247
248        // Cohesión alta porque los miembros son similares.
249        let cohesion = cohesion(&c, &[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
250        assert!(cohesion > 0.9, "cohesion={cohesion}");
251    }
252
253    #[test]
254    fn attraction_picks_correct_monad() {
255        // Construimos dos Mónadas: una de Rust, otra de imágenes.
256        let rust_files = vec![
257            embed(&mk("/proj/src/a.rs", Some("rs"), 1000)).to_vec(),
258            embed(&mk("/proj/src/b.rs", Some("rs"), 1100)).to_vec(),
259        ];
260        let img_files = vec![
261            embed(&mk("/photos/p1.jpg", Some("jpg"), 5_000_000)).to_vec(),
262            embed(&mk("/photos/p2.jpg", Some("jpg"), 4_000_000)).to_vec(),
263        ];
264
265        let mut rust_monad = MonadManifest::new("rust");
266        rust_monad.members.insert(FileId::from(Ulid::new()));
267        rust_monad.touch();
268        rust_monad.centroid = centroid(&rust_files);
269
270        let mut img_monad = MonadManifest::new("photos");
271        img_monad.members.insert(FileId::from(Ulid::new()));
272        img_monad.touch();
273        img_monad.centroid = centroid(&img_files);
274
275        // Un archivo .rs nuevo en /proj/src debe atraerse a la Mónada Rust.
276        let new_rs = embed(&mk("/proj/src/new.rs", Some("rs"), 1500));
277        let (best_id, _score) = best_attraction(&new_rs, [&rust_monad, &img_monad].into_iter())
278            .expect("best match");
279        assert_eq!(best_id, rust_monad.id);
280
281        // Y al revés.
282        let new_jpg = embed(&mk("/photos/new.jpg", Some("jpg"), 6_000_000));
283        let (best_id, _score) = best_attraction(&new_jpg, [&rust_monad, &img_monad].into_iter())
284            .expect("best match");
285        assert_eq!(best_id, img_monad.id);
286    }
287
288    #[test]
289    fn empty_centroid_skipped_in_attraction() {
290        let mut m = MonadManifest::new("empty");
291        m.members.insert(FileId::from(Ulid::new()));
292        m.touch();
293        // m.centroid queda vacío
294
295        let v = embed(&mk("/x/y.rs", Some("rs"), 100));
296        assert!(best_attraction(&v, [&m].into_iter()).is_none());
297    }
298}