refactor(naming): A1 — ente→arje, vista→revista, pluma→fana
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}