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>
This commit is contained in:
@@ -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;
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user