tawasuyu

tawasuyu/brahman

Rust 226TOML 67JSON 14HTML 12860 Definiciones

⎇ main crates/modules/badu/badu-gravity/src/lib.rs Rust · 341 líneas · 11.0 KB Historia Quién tocó qué crudo

feat(badu): toma de notas — núcleo + gravedad semántica d0a175a90 · sergio ·

1//! `badu-gravity` — la gravedad semántica de las notas.
2//!
3//! Cada nota tiene un vector semántico (lo produce `verbo`; aquí entra
4//! ya calculado, sin acoplar a ningún backend). La afinidad entre dos
5//! notas es la similitud coseno de sus vectores; con eso, este crate:
6//!
7//! - encuentra los **vecinos** más afines de una nota;
8//! - agrupa las notas en **clústeres** por encima de un umbral;
9//! - calcula un **layout 2D** donde las notas afines se atraen y todas
10//!   se repelen — la «gravedad» literal de la lente espacial de badu.
11//!
12//! Todo es determinista: posiciones iniciales fijas, sin RNG, iteración
13//! en orden estable.
14
15#![forbid(unsafe_code)]
16
17use badu_core::NoteId;
18use serde::{Deserialize, Serialize};
19
20/// Una nube de notas con su vector semántico — el dominio de la gravedad.
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22pub struct SemanticField {
23    /// `(id, vector)` en orden de inserción.
24    entries: Vec<(NoteId, Vec<f32>)>,
25}
26
27/// Posición 2D resultante de una nota tras el layout por gravedad.
28#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
29pub struct NotePlacement {
30    pub id: NoteId,
31    pub x: f32,
32    pub y: f32,
33}
34
35/// Parámetros del layout dirigido por fuerzas.
36#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
37pub struct GravityConfig {
38    /// Pasos de relajación.
39    pub iterations: usize,
40    /// Fuerza de atracción entre notas afines.
41    pub attraction: f32,
42    /// Fuerza de repulsión entre todo par de notas.
43    pub repulsion: f32,
44    /// Radio del círculo de posiciones iniciales.
45    pub radius: f32,
46    /// Fracción de la fuerza neta que se aplica por paso (amortiguación).
47    pub step: f32,
48}
49
50impl Default for GravityConfig {
51    fn default() -> Self {
52        Self {
53            iterations: 120,
54            attraction: 0.02,
55            repulsion: 800.0,
56            radius: 240.0,
57            step: 0.85,
58        }
59    }
60}
61
62/// Similitud coseno de dos vectores. `None` si difieren de largo.
63fn cosine(a: &[f32], b: &[f32]) -> Option<f32> {
64    if a.len() != b.len() {
65        return None;
66    }
67    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
68    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
69    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
70    if na == 0.0 || nb == 0.0 {
71        return Some(0.0);
72    }
73    Some((dot / (na * nb)).clamp(-1.0, 1.0))
74}
75
76impl SemanticField {
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Cantidad de notas en el campo.
82    pub fn len(&self) -> usize {
83        self.entries.len()
84    }
85
86    pub fn is_empty(&self) -> bool {
87        self.entries.is_empty()
88    }
89
90    /// Inserta o reemplaza el vector de una nota.
91    pub fn insert(&mut self, id: NoteId, vector: Vec<f32>) {
92        if let Some(slot) = self.entries.iter_mut().find(|(eid, _)| *eid == id) {
93            slot.1 = vector;
94        } else {
95            self.entries.push((id, vector));
96        }
97    }
98
99    fn vector_of(&self, id: NoteId) -> Option<&[f32]> {
100        self.entries
101            .iter()
102            .find(|(eid, _)| *eid == id)
103            .map(|(_, v)| v.as_slice())
104    }
105
106    /// Afinidad (similitud coseno) entre dos notas. `None` si alguna no
107    /// existe o los vectores difieren de largo.
108    pub fn affinity(&self, a: NoteId, b: NoteId) -> Option<f32> {
109        cosine(self.vector_of(a)?, self.vector_of(b)?)
110    }
111
112    /// Las `k` notas más afines a `id`, de mayor a menor afinidad.
113    /// Empata por id ascendente para que el orden sea determinista.
114    pub fn nearest(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> {
115        let Some(base) = self.vector_of(id) else {
116            return Vec::new();
117        };
118        let mut scored: Vec<(NoteId, f32)> = self
119            .entries
120            .iter()
121            .filter(|(eid, _)| *eid != id)
122            .filter_map(|(eid, v)| cosine(base, v).map(|s| (*eid, s)))
123            .collect();
124        scored.sort_by(|a, b| {
125            b.1.partial_cmp(&a.1)
126                .unwrap_or(core::cmp::Ordering::Equal)
127                .then(a.0.cmp(&b.0))
128        });
129        scored.truncate(k);
130        scored
131    }
132
133    /// Agrupa las notas en clústeres: dos notas quedan en el mismo grupo
134    /// si su afinidad alcanza `threshold` (transitivamente). Cada
135    /// clúster viene ordenado por id, y la lista de clústeres también.
136    pub fn clusters(&self, threshold: f32) -> Vec<Vec<NoteId>> {
137        let n = self.entries.len();
138        let mut parent: Vec<usize> = (0..n).collect();
139
140        fn find(parent: &mut [usize], i: usize) -> usize {
141            let mut root = i;
142            while parent[root] != root {
143                root = parent[root];
144            }
145            let mut cur = i;
146            while parent[cur] != root {
147                let next = parent[cur];
148                parent[cur] = root;
149                cur = next;
150            }
151            root
152        }
153
154        for i in 0..n {
155            for j in (i + 1)..n {
156                let sim = cosine(&self.entries[i].1, &self.entries[j].1).unwrap_or(0.0);
157                if sim >= threshold {
158                    let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
159                    if ri != rj {
160                        parent[ri] = rj;
161                    }
162                }
163            }
164        }
165
166        let mut groups: std::collections::BTreeMap<usize, Vec<NoteId>> = Default::default();
167        for i in 0..n {
168            let root = find(&mut parent, i);
169            groups.entry(root).or_default().push(self.entries[i].0);
170        }
171        let mut out: Vec<Vec<NoteId>> = groups.into_values().collect();
172        for c in &mut out {
173            c.sort_unstable();
174        }
175        out.sort_by(|a, b| a.first().cmp(&b.first()));
176        out
177    }
178
179    /// Layout 2D por gravedad: las notas afines se atraen, todas se
180    /// repelen. Determinista — posiciones iniciales en círculo, sin RNG.
181    pub fn gravity_layout(&self, cfg: &GravityConfig) -> Vec<NotePlacement> {
182        let n = self.entries.len();
183        if n == 0 {
184            return Vec::new();
185        }
186        if n == 1 {
187            return vec![NotePlacement { id: self.entries[0].0, x: 0.0, y: 0.0 }];
188        }
189
190        // Posiciones iniciales repartidas en un círculo.
191        let mut pos: Vec<(f32, f32)> = (0..n)
192            .map(|i| {
193                let a = core::f32::consts::TAU * i as f32 / n as f32;
194                (cfg.radius * a.cos(), cfg.radius * a.sin())
195            })
196            .collect();
197
198        // Afinidades precomputadas (no cambian entre pasos).
199        let mut aff = vec![0.0f32; n * n];
200        for i in 0..n {
201            for j in (i + 1)..n {
202                let s = cosine(&self.entries[i].1, &self.entries[j].1)
203                    .unwrap_or(0.0)
204                    .max(0.0);
205                aff[i * n + j] = s;
206                aff[j * n + i] = s;
207            }
208        }
209
210        const EPS: f32 = 0.001;
211        for _ in 0..cfg.iterations {
212            let mut force = vec![(0.0f32, 0.0f32); n];
213            for i in 0..n {
214                for j in (i + 1)..n {
215                    let dx = pos[j].0 - pos[i].0;
216                    let dy = pos[j].1 - pos[i].1;
217                    let dist = (dx * dx + dy * dy).sqrt().max(EPS);
218                    let (ux, uy) = (dx / dist, dy / dist);
219                    // Atracción crece con la distancia y la afinidad;
220                    // repulsión cae con el cuadrado de la distancia.
221                    let attract = cfg.attraction * aff[i * n + j] * dist;
222                    let repel = cfg.repulsion / (dist * dist);
223                    let net = attract - repel; // >0 → acercar
224                    force[i].0 += net * ux;
225                    force[i].1 += net * uy;
226                    force[j].0 -= net * ux;
227                    force[j].1 -= net * uy;
228                }
229            }
230            for i in 0..n {
231                pos[i].0 += force[i].0 * cfg.step;
232                pos[i].1 += force[i].1 * cfg.step;
233            }
234        }
235
236        self.entries
237            .iter()
238            .zip(pos)
239            .map(|((id, _), (x, y))| NotePlacement { id: *id, x, y })
240            .collect()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    /// Tres vectores: 1 y 2 casi paralelos, 3 ortogonal.
249    fn field() -> SemanticField {
250        let mut f = SemanticField::new();
251        f.insert(1, vec![1.0, 0.0, 0.0]);
252        f.insert(2, vec![0.9, 0.1, 0.0]);
253        f.insert(3, vec![0.0, 0.0, 1.0]);
254        f
255    }
256
257    #[test]
258    fn affinity_is_high_for_aligned_vectors() {
259        let f = field();
260        let near = f.affinity(1, 2).unwrap();
261        let far = f.affinity(1, 3).unwrap();
262        assert!(near > 0.95);
263        assert!(far.abs() < 1e-6);
264        assert!(near > far);
265    }
266
267    #[test]
268    fn affinity_missing_note_is_none() {
269        assert!(field().affinity(1, 99).is_none());
270    }
271
272    #[test]
273    fn nearest_ranks_by_affinity() {
274        let f = field();
275        let near = f.nearest(1, 2);
276        assert_eq!(near[0].0, 2); // el más afín a 1
277        assert_eq!(near.len(), 2);
278        assert!(near[0].1 > near[1].1);
279    }
280
281    #[test]
282    fn insert_replaces_existing_vector() {
283        let mut f = SemanticField::new();
284        f.insert(1, vec![1.0, 0.0]);
285        f.insert(1, vec![0.0, 1.0]);
286        assert_eq!(f.len(), 1);
287        assert_eq!(f.vector_of(1), Some([0.0, 1.0].as_slice()));
288    }
289
290    #[test]
291    fn clusters_group_affine_notes() {
292        let f = field();
293        // Umbral alto: 1 y 2 juntos, 3 solo.
294        let cs = f.clusters(0.8);
295        assert_eq!(cs, vec![vec![1, 2], vec![3]]);
296    }
297
298    #[test]
299    fn low_threshold_merges_everything() {
300        let cs = field().clusters(-1.0);
301        assert_eq!(cs, vec![vec![1, 2, 3]]);
302    }
303
304    #[test]
305    fn gravity_layout_places_every_note() {
306        let placements = field().gravity_layout(&GravityConfig::default());
307        assert_eq!(placements.len(), 3);
308        let ids: Vec<_> = placements.iter().map(|p| p.id).collect();
309        assert_eq!(ids, vec![1, 2, 3]);
310    }
311
312    #[test]
313    fn gravity_pulls_affine_notes_closer() {
314        let f = field();
315        let p = f.gravity_layout(&GravityConfig::default());
316        let dist = |a: NoteId, b: NoteId| {
317            let pa = p.iter().find(|x| x.id == a).unwrap();
318            let pb = p.iter().find(|x| x.id == b).unwrap();
319            ((pa.x - pb.x).powi(2) + (pa.y - pb.y).powi(2)).sqrt()
320        };
321        // Las notas afines (1,2) terminan más cerca que las disímiles (1,3).
322        assert!(dist(1, 2) < dist(1, 3));
323    }
324
325    #[test]
326    fn gravity_layout_is_deterministic() {
327        let f = field();
328        let a = f.gravity_layout(&GravityConfig::default());
329        let b = f.gravity_layout(&GravityConfig::default());
330        assert_eq!(a, b);
331    }
332
333    #[test]
334    fn empty_and_single_fields_are_handled() {
335        assert!(SemanticField::new().gravity_layout(&GravityConfig::default()).is_empty());
336        let mut one = SemanticField::new();
337        one.insert(7, vec![1.0, 1.0]);
338        let p = one.gravity_layout(&GravityConfig::default());
339        assert_eq!(p, vec![NotePlacement { id: 7, x: 0.0, y: 0.0 }]);
340    }
341}