From d0a175a90a0bfa86088f6dff74c1130e283aa828 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 16:42:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(badu):=20toma=20de=20notas=20=E2=80=94=20n?= =?UTF-8?q?=C3=BAcleo=20+=20gravedad=20sem=C3=A1ntica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit badu-core: modelo Note + NoteStore (etiquetas, búsqueda) + grafo de wiki-links [[...]] derivado del cuerpo (forward/backlinks, huérfanas, enlaces colgantes; resolución case-insensitive). badu-gravity: SemanticField sobre vectores semánticos — afinidad coseno, vecinos más cercanos, clústeres por umbral (union-find) y layout 2D dirigido por fuerzas (notas afines se atraen, todas se repelen; determinista, sin RNG). 29 tests. Cero red, #![forbid(unsafe_code)]. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 15 + Cargo.toml | 6 + crates/modules/badu/SDD.md | 41 +++ crates/modules/badu/badu-core/Cargo.toml | 11 + crates/modules/badu/badu-core/src/lib.rs | 23 ++ crates/modules/badu/badu-core/src/links.rs | 93 ++++++ crates/modules/badu/badu-core/src/note.rs | 78 +++++ crates/modules/badu/badu-core/src/store.rs | 261 +++++++++++++++ crates/modules/badu/badu-gravity/Cargo.toml | 12 + crates/modules/badu/badu-gravity/src/lib.rs | 341 ++++++++++++++++++++ 10 files changed, 881 insertions(+) create mode 100644 crates/modules/badu/SDD.md create mode 100644 crates/modules/badu/badu-core/Cargo.toml create mode 100644 crates/modules/badu/badu-core/src/lib.rs create mode 100644 crates/modules/badu/badu-core/src/links.rs create mode 100644 crates/modules/badu/badu-core/src/note.rs create mode 100644 crates/modules/badu/badu-core/src/store.rs create mode 100644 crates/modules/badu/badu-gravity/Cargo.toml create mode 100644 crates/modules/badu/badu-gravity/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 69a8e35..97768db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1363,6 +1363,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "badu-core" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "badu-gravity" +version = "0.1.0" +dependencies = [ + "badu-core", + "serde", +] + [[package]] name = "barra-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f815254..bf7d424 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,6 +125,12 @@ members = [ "crates/modules/agorapura/agorapura-core", "crates/modules/agorapura/agorapura-graph", + # ============================================================ + # modules/badu/ — Toma de notas con gravedad semántica + # ============================================================ + "crates/modules/badu/badu-core", + "crates/modules/badu/badu-gravity", + # ============================================================ # modules/nakui/ — ERP matemático (categórico) # ============================================================ diff --git a/crates/modules/badu/SDD.md b/crates/modules/badu/SDD.md new file mode 100644 index 0000000..2a9a5e9 --- /dev/null +++ b/crates/modules/badu/SDD.md @@ -0,0 +1,41 @@ +# modules/badu/ — Toma de notas con gravedad semántica + +**Propósito.** Notas como texto enlazado: cada nota tiene título, +etiquetas y wiki-links `[[...]]`; el conjunto forma un grafo navegable. +Sobre ese grafo, una capa de *gravedad semántica* agrupa y posiciona las +notas por afinidad de significado. + +## Crates + +| crate | tipo | rol | +| -------------- | ---- | ------------------------------------------------------------ | +| `badu-core` | lib | `Note` + `NoteStore` (etiquetas, búsqueda) + grafo de wiki-links (forward/backlinks, huérfanas, colgantes) | +| `badu-gravity` | lib | `SemanticField`: afinidad coseno, vecinos, clústeres por umbral y layout 2D dirigido por fuerzas | + +## Modelo + +```text + Note ([[links]] en el cuerpo) ──► NoteStore ──► grafo de enlaces + │ │ + vector semántico (verbo) ──► SemanticField ──► clústeres + layout 2D +``` + +- **Enlaces**: el grafo se *deriva* del cuerpo, no se guarda aparte. + `[[cocina]]` y `[[Cocina]]` resuelven a la misma nota. +- **Gravedad**: las notas afines se atraen, todas se repelen — un layout + force-directed determinista (posiciones iniciales fijas, sin RNG). +- **Clústeres**: union-find sobre las afinidades que superan un umbral. + +## Dependencias + +- `core` ← sólo `serde`. `gravity` ← `badu-core` + `serde`. +- `gravity` recibe los vectores ya calculados — no se acopla a `verbo`; + el frontend embebe las notas con un `verbo::Provider` y los inyecta. +- Ambos `#![forbid(unsafe_code)]` y deterministas. + +## Estado + +`core` + `gravity` implementados y verdes (29 tests). **Pendiente**: las +4 lentes visuales (lista, grafo, gravedad espacial, línea de tiempo), +los «Susurros» (sugerencias vía `verbo`) y el frontend GPUI — +separabilidad UI estricta, el núcleo ya es agnóstico. diff --git a/crates/modules/badu/badu-core/Cargo.toml b/crates/modules/badu/badu-core/Cargo.toml new file mode 100644 index 0000000..bed2a80 --- /dev/null +++ b/crates/modules/badu/badu-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "badu-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "badu — núcleo de toma de notas: el modelo Note, el almacén con etiquetas y búsqueda, y el grafo de wiki-links [[...]] con backlinks." + +[dependencies] +serde = { workspace = true } diff --git a/crates/modules/badu/badu-core/src/lib.rs b/crates/modules/badu/badu-core/src/lib.rs new file mode 100644 index 0000000..99bfbd9 --- /dev/null +++ b/crates/modules/badu/badu-core/src/lib.rs @@ -0,0 +1,23 @@ +//! `badu-core` — el núcleo agnóstico de la toma de notas. +//! +//! Una nota es texto con título, etiquetas y enlaces `[[...]]`. El +//! [`NoteStore`] las guarda y deriva el grafo: forward-links, backlinks, +//! huérfanas y enlaces colgantes. Sin UI, sin storage en disco, sin red +//! — tipos puros y deterministas. +//! +//! - [`note`] — el modelo [`Note`]. +//! - [`links`] — el parser de wiki-links `[[...]]`. +//! - [`store`] — el [`NoteStore`] y el grafo de enlaces. +//! +//! La gravedad semántica (clustering por afinidad de embeddings) vive en +//! `badu-gravity`; las lentes visuales, en los crates de frontend. + +#![forbid(unsafe_code)] + +pub mod links; +pub mod note; +pub mod store; + +pub use links::parse_links; +pub use note::{Note, NoteId}; +pub use store::NoteStore; diff --git a/crates/modules/badu/badu-core/src/links.rs b/crates/modules/badu/badu-core/src/links.rs new file mode 100644 index 0000000..2f79430 --- /dev/null +++ b/crates/modules/badu/badu-core/src/links.rs @@ -0,0 +1,93 @@ +//! Parser de wiki-links — los destinos `[[...]]` dentro de una nota. +//! +//! Un solo formato: dobles corchetes con el título adentro. El texto se +//! recorta; los enlaces vacíos se descartan; el orden de aparición se +//! conserva y se deduplica (un mismo destino enlazado dos veces cuenta +//! una sola vez como arista). + +/// Extrae los destinos `[[...]]` de `text`, recortados y deduplicados, +/// en orden de aparición. +pub fn parse_links(text: &str) -> Vec { + let bytes = text.as_bytes(); + let mut out: Vec = Vec::new(); + let mut i = 0; + while i + 3 < bytes.len() { + if bytes[i] == b'[' && bytes[i + 1] == b'[' { + if let Some(close) = find_close(text, i + 2) { + let inner = text[i + 2..close].trim(); + if !inner.is_empty() && !out.iter().any(|l| l == inner) { + out.push(inner.to_string()); + } + i = close + 2; + continue; + } + } + // Avanza un carácter UTF-8 completo, no un byte. + i += utf8_len(bytes[i]); + } + out +} + +/// Posición del `]]` que cierra a partir de `from`, si existe. +fn find_close(text: &str, from: usize) -> Option { + let bytes = text.as_bytes(); + let mut i = from; + while i + 1 < bytes.len() { + if bytes[i] == b']' && bytes[i + 1] == b']' { + return Some(i); + } + // Un `[[` antes del cierre aborta: enlaces anidados no son válidos. + if bytes[i] == b'[' && bytes[i + 1] == b'[' { + return None; + } + i += 1; + } + None +} + +/// Largo en bytes del carácter UTF-8 que empieza en `b`. +fn utf8_len(b: u8) -> usize { + match b { + 0x00..=0x7F => 1, + 0xC0..=0xDF => 2, + 0xE0..=0xEF => 3, + _ => 4, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_simple_links() { + let links = parse_links("ver [[Cocina]] y también [[Jardín]]."); + assert_eq!(links, vec!["Cocina", "Jardín"]); + } + + #[test] + fn trims_inner_whitespace() { + assert_eq!(parse_links("[[ Taller ]]"), vec!["Taller"]); + } + + #[test] + fn empty_links_are_dropped() { + assert_eq!(parse_links("[[]] y [[ ]]"), Vec::::new()); + } + + #[test] + fn duplicates_collapse_to_one() { + assert_eq!(parse_links("[[A]] [[B]] [[A]]"), vec!["A", "B"]); + } + + #[test] + fn unclosed_bracket_is_ignored() { + assert_eq!(parse_links("texto [[sin cerrar"), Vec::::new()); + } + + #[test] + fn handles_unicode_content_around_links() { + let links = parse_links("café ☕ con [[Niños]] — añoño"); + assert_eq!(links, vec!["Niños"]); + } +} diff --git a/crates/modules/badu/badu-core/src/note.rs b/crates/modules/badu/badu-core/src/note.rs new file mode 100644 index 0000000..180b43d --- /dev/null +++ b/crates/modules/badu/badu-core/src/note.rs @@ -0,0 +1,78 @@ +//! El modelo `Note` — la unidad de badu. + +use serde::{Deserialize, Serialize}; + +use crate::links::parse_links; + +/// Identificador de una nota. Lo asigna el almacén, monótono y estable. +pub type NoteId = u64; + +/// Una nota: título, cuerpo, etiquetas y marcas de tiempo. Los enlaces +/// no se guardan aparte — se derivan del cuerpo bajo demanda. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Note { + pub id: NoteId, + pub title: String, + pub body: String, + pub tags: Vec, + /// Segundo Unix de creación. + pub created_at: u64, + /// Segundo Unix de la última edición. + pub updated_at: u64, +} + +impl Note { + /// Destinos `[[...]]` que el cuerpo de la nota referencia. + pub fn outgoing_links(&self) -> Vec { + parse_links(&self.body) + } + + /// `true` si la nota lleva la etiqueta `tag` (sin distinguir mayúsculas). + pub fn has_tag(&self, tag: &str) -> bool { + self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) + } + + /// `true` si `query` aparece en el título o el cuerpo (sin distinguir + /// mayúsculas). + pub fn matches(&self, query: &str) -> bool { + let q = query.to_lowercase(); + self.title.to_lowercase().contains(&q) || self.body.to_lowercase().contains(&q) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn note(title: &str, body: &str) -> Note { + Note { + id: 1, + title: title.into(), + body: body.into(), + tags: vec!["casa".into()], + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn outgoing_links_reads_the_body() { + let n = note("Cocina", "preparar con [[Horno]] y [[Cuchillos]]"); + assert_eq!(n.outgoing_links(), vec!["Horno", "Cuchillos"]); + } + + #[test] + fn has_tag_is_case_insensitive() { + let n = note("x", "y"); + assert!(n.has_tag("CASA")); + assert!(!n.has_tag("trabajo")); + } + + #[test] + fn matches_searches_title_and_body() { + let n = note("Lista de mercado", "comprar pan"); + assert!(n.matches("MERCADO")); + assert!(n.matches("pan")); + assert!(!n.matches("ausente")); + } +} diff --git a/crates/modules/badu/badu-core/src/store.rs b/crates/modules/badu/badu-core/src/store.rs new file mode 100644 index 0000000..00788c4 --- /dev/null +++ b/crates/modules/badu/badu-core/src/store.rs @@ -0,0 +1,261 @@ +//! `NoteStore` — el almacén de notas y su grafo de enlaces. +//! +//! Guarda las notas en un `BTreeMap` para que toda iteración sea +//! determinista (ordenada por id). Los enlaces se resuelven por título +//! sin distinguir mayúsculas: `[[cocina]]` y `[[Cocina]]` apuntan a la +//! misma nota. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::note::{Note, NoteId}; + +/// El almacén de notas de badu. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NoteStore { + notes: BTreeMap, + /// Siguiente id a asignar — monótono, nunca reutiliza huecos. + next_id: NoteId, +} + +impl NoteStore { + pub fn new() -> Self { + Self { notes: BTreeMap::new(), next_id: 1 } + } + + /// Crea una nota y devuelve su id. + pub fn create( + &mut self, + title: impl Into, + body: impl Into, + tags: Vec, + now: u64, + ) -> NoteId { + let id = self.next_id; + self.next_id += 1; + self.notes.insert( + id, + Note { + id, + title: title.into(), + body: body.into(), + tags, + created_at: now, + updated_at: now, + }, + ); + id + } + + pub fn len(&self) -> usize { + self.notes.len() + } + + pub fn is_empty(&self) -> bool { + self.notes.is_empty() + } + + pub fn get(&self, id: NoteId) -> Option<&Note> { + self.notes.get(&id) + } + + pub fn get_mut(&mut self, id: NoteId) -> Option<&mut Note> { + self.notes.get_mut(&id) + } + + /// Itera las notas en orden de id (determinista). + pub fn iter(&self) -> impl Iterator { + self.notes.values() + } + + /// Reemplaza el cuerpo de una nota y actualiza su marca de tiempo. + /// `false` si la nota no existe. + pub fn update_body(&mut self, id: NoteId, body: impl Into, now: u64) -> bool { + match self.notes.get_mut(&id) { + Some(n) => { + n.body = body.into(); + n.updated_at = now; + true + } + None => false, + } + } + + /// Elimina una nota y la devuelve. + pub fn remove(&mut self, id: NoteId) -> Option { + self.notes.remove(&id) + } + + /// Notas que llevan la etiqueta `tag`, en orden de id. + pub fn by_tag(&self, tag: &str) -> Vec<&Note> { + self.notes.values().filter(|n| n.has_tag(tag)).collect() + } + + /// Notas cuyo título o cuerpo contienen `query`, en orden de id. + pub fn search(&self, query: &str) -> Vec<&Note> { + self.notes.values().filter(|n| n.matches(query)).collect() + } + + /// Ids de las notas cuyo título es `title` (sin distinguir + /// mayúsculas). Pueden ser varias: los títulos no son únicos. + pub fn resolve_title(&self, title: &str) -> Vec { + self.notes + .values() + .filter(|n| n.title.eq_ignore_ascii_case(title)) + .map(|n| n.id) + .collect() + } + + /// Ids de las notas a las que `id` enlaza por `[[...]]`, resueltas y + /// deduplicadas. Un enlace a un título inexistente simplemente no + /// aporta ningún id (enlace colgante). + pub fn forward_links(&self, id: NoteId) -> Vec { + let Some(note) = self.notes.get(&id) else { + return Vec::new(); + }; + let mut out: Vec = Vec::new(); + for title in note.outgoing_links() { + for target in self.resolve_title(&title) { + if !out.contains(&target) { + out.push(target); + } + } + } + out.sort_unstable(); + out + } + + /// Ids de las notas que enlazan hacia `id` (backlinks), en orden de id. + pub fn backlinks(&self, id: NoteId) -> Vec { + let Some(target) = self.notes.get(&id) else { + return Vec::new(); + }; + let title = &target.title; + self.notes + .values() + .filter(|n| { + n.id != id + && n.outgoing_links() + .iter() + .any(|l| l.eq_ignore_ascii_case(title)) + }) + .map(|n| n.id) + .collect() + } + + /// Notas sin ningún backlink — las islas del grafo. + pub fn orphans(&self) -> Vec<&Note> { + self.notes + .values() + .filter(|n| self.backlinks(n.id).is_empty()) + .collect() + } + + /// Destinos `[[...]]` que ninguna nota satisface — enlaces colgantes. + pub fn dangling_links(&self) -> Vec { + let mut out: Vec = Vec::new(); + for note in self.notes.values() { + for title in note.outgoing_links() { + if self.resolve_title(&title).is_empty() + && !out.iter().any(|t| t.eq_ignore_ascii_case(&title)) + { + out.push(title); + } + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Almacén con tres notas enlazadas: Índice → Cocina, Índice → Jardín. + fn seeded() -> (NoteStore, NoteId, NoteId, NoteId) { + let mut s = NoteStore::new(); + let indice = s.create("Índice", "ver [[Cocina]] y [[Jardín]]", vec!["meta".into()], 100); + let cocina = s.create("Cocina", "recetas; vuelve al [[Índice]]", vec!["casa".into()], 100); + let jardin = s.create("Jardín", "plantas y riego", vec!["casa".into()], 100); + (s, indice, cocina, jardin) + } + + #[test] + fn create_assigns_monotonic_ids() { + let (s, indice, cocina, jardin) = seeded(); + assert_eq!((indice, cocina, jardin), (1, 2, 3)); + assert_eq!(s.len(), 3); + } + + #[test] + fn forward_links_resolve_by_title() { + let (s, indice, cocina, jardin) = seeded(); + assert_eq!(s.forward_links(indice), vec![cocina, jardin]); + } + + #[test] + fn backlinks_find_incoming_references() { + let (s, indice, cocina, _) = seeded(); + // Cocina enlaza al Índice → el Índice tiene a Cocina como backlink. + assert_eq!(s.backlinks(indice), vec![cocina]); + } + + #[test] + fn link_resolution_is_case_insensitive() { + let mut s = NoteStore::new(); + let a = s.create("Taller", "trabajo", vec![], 0); + let b = s.create("Notas", "voy al [[taller]]", vec![], 0); + assert_eq!(s.forward_links(b), vec![a]); + assert_eq!(s.backlinks(a), vec![b]); + } + + #[test] + fn by_tag_filters() { + let (s, _, cocina, jardin) = seeded(); + let casa: Vec<_> = s.by_tag("casa").iter().map(|n| n.id).collect(); + assert_eq!(casa, vec![cocina, jardin]); + } + + #[test] + fn search_scans_title_and_body() { + let (s, _, cocina, _) = seeded(); + let hits: Vec<_> = s.search("recetas").iter().map(|n| n.id).collect(); + assert_eq!(hits, vec![cocina]); + } + + #[test] + fn update_body_changes_links_and_timestamp() { + let (mut s, indice, _, jardin) = seeded(); + assert!(s.update_body(indice, "ahora sólo [[Jardín]]", 200)); + assert_eq!(s.forward_links(indice), vec![jardin]); + assert_eq!(s.get(indice).unwrap().updated_at, 200); + } + + #[test] + fn orphans_have_no_backlinks() { + let (s, _, _, jardin) = seeded(); + // Jardín no recibe enlaces de vuelta... pero el Índice sí lo enlaza. + // El único huérfano real sería una nota aislada. + let mut s2 = s; + let aislada = s2.create("Aislada", "sin conexiones", vec![], 0); + let orphan_ids: Vec<_> = s2.orphans().iter().map(|n| n.id).collect(); + assert!(orphan_ids.contains(&aislada)); + assert!(!orphan_ids.contains(&jardin)); + } + + #[test] + fn dangling_links_report_missing_targets() { + let mut s = NoteStore::new(); + s.create("Nota", "apunta a [[Inexistente]]", vec![], 0); + assert_eq!(s.dangling_links(), vec!["Inexistente"]); + } + + #[test] + fn remove_drops_the_note() { + let (mut s, indice, ..) = seeded(); + assert!(s.remove(indice).is_some()); + assert_eq!(s.len(), 2); + assert!(s.get(indice).is_none()); + } +} diff --git a/crates/modules/badu/badu-gravity/Cargo.toml b/crates/modules/badu/badu-gravity/Cargo.toml new file mode 100644 index 0000000..2d4a57a --- /dev/null +++ b/crates/modules/badu/badu-gravity/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "badu-gravity" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "badu — gravedad semántica: afinidad coseno entre notas, vecinos más cercanos, clústeres por umbral y layout 2D dirigido por fuerzas." + +[dependencies] +badu-core = { path = "../badu-core" } +serde = { workspace = true } diff --git a/crates/modules/badu/badu-gravity/src/lib.rs b/crates/modules/badu/badu-gravity/src/lib.rs new file mode 100644 index 0000000..a48234f --- /dev/null +++ b/crates/modules/badu/badu-gravity/src/lib.rs @@ -0,0 +1,341 @@ +//! `badu-gravity` — la gravedad semántica de las notas. +//! +//! Cada nota tiene un vector semántico (lo produce `verbo`; aquí entra +//! ya calculado, sin acoplar a ningún backend). La afinidad entre dos +//! notas es la similitud coseno de sus vectores; con eso, este crate: +//! +//! - encuentra los **vecinos** más afines de una nota; +//! - agrupa las notas en **clústeres** por encima de un umbral; +//! - calcula un **layout 2D** donde las notas afines se atraen y todas +//! se repelen — la «gravedad» literal de la lente espacial de badu. +//! +//! Todo es determinista: posiciones iniciales fijas, sin RNG, iteración +//! en orden estable. + +#![forbid(unsafe_code)] + +use badu_core::NoteId; +use serde::{Deserialize, Serialize}; + +/// Una nube de notas con su vector semántico — el dominio de la gravedad. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SemanticField { + /// `(id, vector)` en orden de inserción. + entries: Vec<(NoteId, Vec)>, +} + +/// Posición 2D resultante de una nota tras el layout por gravedad. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct NotePlacement { + pub id: NoteId, + pub x: f32, + pub y: f32, +} + +/// Parámetros del layout dirigido por fuerzas. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct GravityConfig { + /// Pasos de relajación. + pub iterations: usize, + /// Fuerza de atracción entre notas afines. + pub attraction: f32, + /// Fuerza de repulsión entre todo par de notas. + pub repulsion: f32, + /// Radio del círculo de posiciones iniciales. + pub radius: f32, + /// Fracción de la fuerza neta que se aplica por paso (amortiguación). + pub step: f32, +} + +impl Default for GravityConfig { + fn default() -> Self { + Self { + iterations: 120, + attraction: 0.02, + repulsion: 800.0, + radius: 240.0, + step: 0.85, + } + } +} + +/// Similitud coseno de dos vectores. `None` si difieren de largo. +fn cosine(a: &[f32], b: &[f32]) -> Option { + if a.len() != b.len() { + return None; + } + let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let na: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let nb: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + if na == 0.0 || nb == 0.0 { + return Some(0.0); + } + Some((dot / (na * nb)).clamp(-1.0, 1.0)) +} + +impl SemanticField { + pub fn new() -> Self { + Self::default() + } + + /// Cantidad de notas en el campo. + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Inserta o reemplaza el vector de una nota. + pub fn insert(&mut self, id: NoteId, vector: Vec) { + if let Some(slot) = self.entries.iter_mut().find(|(eid, _)| *eid == id) { + slot.1 = vector; + } else { + self.entries.push((id, vector)); + } + } + + fn vector_of(&self, id: NoteId) -> Option<&[f32]> { + self.entries + .iter() + .find(|(eid, _)| *eid == id) + .map(|(_, v)| v.as_slice()) + } + + /// Afinidad (similitud coseno) entre dos notas. `None` si alguna no + /// existe o los vectores difieren de largo. + pub fn affinity(&self, a: NoteId, b: NoteId) -> Option { + cosine(self.vector_of(a)?, self.vector_of(b)?) + } + + /// Las `k` notas más afines a `id`, de mayor a menor afinidad. + /// Empata por id ascendente para que el orden sea determinista. + pub fn nearest(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> { + let Some(base) = self.vector_of(id) else { + return Vec::new(); + }; + let mut scored: Vec<(NoteId, f32)> = self + .entries + .iter() + .filter(|(eid, _)| *eid != id) + .filter_map(|(eid, v)| cosine(base, v).map(|s| (*eid, s))) + .collect(); + scored.sort_by(|a, b| { + b.1.partial_cmp(&a.1) + .unwrap_or(core::cmp::Ordering::Equal) + .then(a.0.cmp(&b.0)) + }); + scored.truncate(k); + scored + } + + /// Agrupa las notas en clústeres: dos notas quedan en el mismo grupo + /// si su afinidad alcanza `threshold` (transitivamente). Cada + /// clúster viene ordenado por id, y la lista de clústeres también. + pub fn clusters(&self, threshold: f32) -> Vec> { + let n = self.entries.len(); + let mut parent: Vec = (0..n).collect(); + + fn find(parent: &mut [usize], i: usize) -> usize { + let mut root = i; + while parent[root] != root { + root = parent[root]; + } + let mut cur = i; + while parent[cur] != root { + let next = parent[cur]; + parent[cur] = root; + cur = next; + } + root + } + + for i in 0..n { + for j in (i + 1)..n { + let sim = cosine(&self.entries[i].1, &self.entries[j].1).unwrap_or(0.0); + if sim >= threshold { + let (ri, rj) = (find(&mut parent, i), find(&mut parent, j)); + if ri != rj { + parent[ri] = rj; + } + } + } + } + + let mut groups: std::collections::BTreeMap> = Default::default(); + for i in 0..n { + let root = find(&mut parent, i); + groups.entry(root).or_default().push(self.entries[i].0); + } + let mut out: Vec> = groups.into_values().collect(); + for c in &mut out { + c.sort_unstable(); + } + out.sort_by(|a, b| a.first().cmp(&b.first())); + out + } + + /// Layout 2D por gravedad: las notas afines se atraen, todas se + /// repelen. Determinista — posiciones iniciales en círculo, sin RNG. + pub fn gravity_layout(&self, cfg: &GravityConfig) -> Vec { + let n = self.entries.len(); + if n == 0 { + return Vec::new(); + } + if n == 1 { + return vec![NotePlacement { id: self.entries[0].0, x: 0.0, y: 0.0 }]; + } + + // Posiciones iniciales repartidas en un círculo. + let mut pos: Vec<(f32, f32)> = (0..n) + .map(|i| { + let a = core::f32::consts::TAU * i as f32 / n as f32; + (cfg.radius * a.cos(), cfg.radius * a.sin()) + }) + .collect(); + + // Afinidades precomputadas (no cambian entre pasos). + let mut aff = vec![0.0f32; n * n]; + for i in 0..n { + for j in (i + 1)..n { + let s = cosine(&self.entries[i].1, &self.entries[j].1) + .unwrap_or(0.0) + .max(0.0); + aff[i * n + j] = s; + aff[j * n + i] = s; + } + } + + const EPS: f32 = 0.001; + for _ in 0..cfg.iterations { + let mut force = vec![(0.0f32, 0.0f32); n]; + for i in 0..n { + for j in (i + 1)..n { + let dx = pos[j].0 - pos[i].0; + let dy = pos[j].1 - pos[i].1; + let dist = (dx * dx + dy * dy).sqrt().max(EPS); + let (ux, uy) = (dx / dist, dy / dist); + // Atracción crece con la distancia y la afinidad; + // repulsión cae con el cuadrado de la distancia. + let attract = cfg.attraction * aff[i * n + j] * dist; + let repel = cfg.repulsion / (dist * dist); + let net = attract - repel; // >0 → acercar + force[i].0 += net * ux; + force[i].1 += net * uy; + force[j].0 -= net * ux; + force[j].1 -= net * uy; + } + } + for i in 0..n { + pos[i].0 += force[i].0 * cfg.step; + pos[i].1 += force[i].1 * cfg.step; + } + } + + self.entries + .iter() + .zip(pos) + .map(|((id, _), (x, y))| NotePlacement { id: *id, x, y }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Tres vectores: 1 y 2 casi paralelos, 3 ortogonal. + fn field() -> SemanticField { + let mut f = SemanticField::new(); + f.insert(1, vec![1.0, 0.0, 0.0]); + f.insert(2, vec![0.9, 0.1, 0.0]); + f.insert(3, vec![0.0, 0.0, 1.0]); + f + } + + #[test] + fn affinity_is_high_for_aligned_vectors() { + let f = field(); + let near = f.affinity(1, 2).unwrap(); + let far = f.affinity(1, 3).unwrap(); + assert!(near > 0.95); + assert!(far.abs() < 1e-6); + assert!(near > far); + } + + #[test] + fn affinity_missing_note_is_none() { + assert!(field().affinity(1, 99).is_none()); + } + + #[test] + fn nearest_ranks_by_affinity() { + let f = field(); + let near = f.nearest(1, 2); + assert_eq!(near[0].0, 2); // el más afín a 1 + assert_eq!(near.len(), 2); + assert!(near[0].1 > near[1].1); + } + + #[test] + fn insert_replaces_existing_vector() { + let mut f = SemanticField::new(); + f.insert(1, vec![1.0, 0.0]); + f.insert(1, vec![0.0, 1.0]); + assert_eq!(f.len(), 1); + assert_eq!(f.vector_of(1), Some([0.0, 1.0].as_slice())); + } + + #[test] + fn clusters_group_affine_notes() { + let f = field(); + // Umbral alto: 1 y 2 juntos, 3 solo. + let cs = f.clusters(0.8); + assert_eq!(cs, vec![vec![1, 2], vec![3]]); + } + + #[test] + fn low_threshold_merges_everything() { + let cs = field().clusters(-1.0); + assert_eq!(cs, vec![vec![1, 2, 3]]); + } + + #[test] + fn gravity_layout_places_every_note() { + let placements = field().gravity_layout(&GravityConfig::default()); + assert_eq!(placements.len(), 3); + let ids: Vec<_> = placements.iter().map(|p| p.id).collect(); + assert_eq!(ids, vec![1, 2, 3]); + } + + #[test] + fn gravity_pulls_affine_notes_closer() { + let f = field(); + let p = f.gravity_layout(&GravityConfig::default()); + let dist = |a: NoteId, b: NoteId| { + let pa = p.iter().find(|x| x.id == a).unwrap(); + let pb = p.iter().find(|x| x.id == b).unwrap(); + ((pa.x - pb.x).powi(2) + (pa.y - pb.y).powi(2)).sqrt() + }; + // Las notas afines (1,2) terminan más cerca que las disímiles (1,3). + assert!(dist(1, 2) < dist(1, 3)); + } + + #[test] + fn gravity_layout_is_deterministic() { + let f = field(); + let a = f.gravity_layout(&GravityConfig::default()); + let b = f.gravity_layout(&GravityConfig::default()); + assert_eq!(a, b); + } + + #[test] + fn empty_and_single_fields_are_handled() { + assert!(SemanticField::new().gravity_layout(&GravityConfig::default()).is_empty()); + let mut one = SemanticField::new(); + one.insert(7, vec![1.0, 1.0]); + let p = one.gravity_layout(&GravityConfig::default()); + assert_eq!(p, vec![NotePlacement { id: 7, x: 0.0, y: 0.0 }]); + } +}