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:
sergio
2026-05-20 16:42:28 +00:00
parent 4e27065a15
commit d0a175a90a
10 changed files with 881 additions and 0 deletions
+78
View File
@@ -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"));
}
}