gioser

tawasuyu/brahman

Rust 231TOML 70JSON 14HTML 12905 Definiciones

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

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 <noreply@anthropic.com>

d0a175a90a0bfa86088f6dff74c1130e283aa828 · por sergio · (hace 5 meses) · padre 4e27065a1 · ver el árbol en este commit

Delta semántico · 80

crates/modules/badu/badu-core/src/lib.rs

crates/modules/badu/badu-core/src/links.rs

crates/modules/badu/badu-core/src/note.rs

crates/modules/badu/badu-core/src/store.rs

crates/modules/badu/badu-gravity/src/lib.rs

10 archivos · +881 −0

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<String> {
+    let bytes = text.as_bytes();
+    let mut out: Vec<String> = 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<usize> {
+    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::<String>::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::<String>::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<String>,
+    /// 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<String> {
+        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<NoteId, Note>,
+    /// 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<String>,
+        body: impl Into<String>,
+        tags: Vec<String>,
+        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<Item = &Note> {
+        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<String>, 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<Note> {
+        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<NoteId> {
+        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<NoteId> {
+        let Some(note) = self.notes.get(&id) else {
+            return Vec::new();
+        };
+        let mut out: Vec<NoteId> = 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<NoteId> {
+        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<String> {
+        let mut out: Vec<String> = 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<f32>)>,
+}
+
+/// 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<f32> {
+    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::<f32>().sqrt();
+    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().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<f32>) {
+        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<f32> {
+        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<Vec<NoteId>> {
+        let n = self.entries.len();
+        let mut parent: Vec<usize> = (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<usize, Vec<NoteId>> = 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<Vec<NoteId>> = 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<NotePlacement> {
+        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 }]);
+    }
+}