tawasuyu

tawasuyu/khipu

Sovereign local-first P2P notes, content-addressed, in Rust.

Rust 31Markdown 18TOML 8YAML 1517 Definitions

⎇ 8d107fd2c11290217f1ac439fccab4ebf1a2871f 00_unanchay/khipu/khipu-core/src/nombre.rs Rust · 144 lines · 5.1 KB History Who touched what raw

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) 8d107fd2c · Sergio ·

1//! Propuesta de nombre para una cuerda naciente.
2//!
3//! Cuando dos o más nudos resuenan y se anudan, la cuerda necesita un
4//! nombre. No se le pide al usuario que lo invente en frío: se propone el
5//! término más recurrente y saliente de los títulos y etiquetas de sus
6//! nudos, y el bautizo arranca con esa sugerencia ya cargada — se acepta
7//! o se edita. Puro y determinista.
8
9use crate::note::Note;
10
11/// Propone un nombre para un grupo de nudos: el término más recurrente y
12/// saliente de sus títulos y etiquetas. Prefiere lo **compartido** (lo
13/// que aparece en más nudos es lo que los vuelve una idea), luego lo más
14/// frecuente, y desempata alfabético para ser determinista. `None` si no
15/// hay ninguna palabra con señal (todo vacío o palabras vacías).
16pub fn propose_cuerda_name(notes: &[&Note]) -> Option<String> {
17    use std::collections::{BTreeMap, BTreeSet};
18
19    // Por término: (en cuántos nudos aparece, frecuencia total).
20    let mut stats: BTreeMap<String, (u32, u32)> = BTreeMap::new();
21    for n in notes {
22        let mut seen: BTreeSet<String> = BTreeSet::new();
23        for tok in note_tokens(n) {
24            let e = stats.entry(tok.clone()).or_insert((0, 0));
25            e.1 += 1;
26            if seen.insert(tok) {
27                e.0 += 1;
28            }
29        }
30    }
31    if stats.is_empty() {
32        return None;
33    }
34    // max_by: gana doc-freq, luego freq total; en empate, el alfabético
35    // menor (lo invertimos con `kb.cmp(ka)` para que el menor sea "mayor").
36    let best = stats
37        .iter()
38        .max_by(|(ka, va), (kb, vb)| {
39            va.0.cmp(&vb.0).then(va.1.cmp(&vb.1)).then(kb.cmp(ka))
40        })
41        .map(|(k, _)| k.clone())?;
42    Some(capitalize(&best))
43}
44
45/// Tokens significativos de un nudo: palabras del título + etiquetas,
46/// en minúsculas, sin palabras vacías ni fragmentos cortos.
47fn note_tokens(n: &Note) -> Vec<String> {
48    let mut out = Vec::new();
49    let push = |raw: &str, out: &mut Vec<String>| {
50        let t = raw.to_lowercase();
51        if t.chars().count() >= 3 && t.chars().any(|c| c.is_alphabetic()) && !is_stopword(&t) {
52            out.push(t);
53        }
54    };
55    for raw in n.title.split(|c: char| !c.is_alphanumeric()) {
56        push(raw, &mut out);
57    }
58    for tag in &n.tags {
59        for raw in tag.split(|c: char| !c.is_alphanumeric()) {
60            push(raw, &mut out);
61        }
62    }
63    out
64}
65
66/// Mayúscula inicial respetando unicode (para mostrar el nombre).
67fn capitalize(s: &str) -> String {
68    let mut chars = s.chars();
69    match chars.next() {
70        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
71        None => String::new(),
72    }
73}
74
75/// Palabras vacías (español, con algunas de inglés) que no sirven de
76/// nombre aunque sean frecuentes.
77fn is_stopword(t: &str) -> bool {
78    const STOP: &[&str] = &[
79        "los", "las", "una", "unos", "unas", "del", "por", "para", "con", "sin", "que",
80        "como", "más", "mas", "pero", "sus", "este", "esta", "esto", "estos", "estas",
81        "ese", "esa", "eso", "esos", "esas", "the", "and", "for", "with", "you", "este",
82        "muy", "son", "fue", "han", "hay", "sobre", "entre", "cuando", "donde", "porque",
83    ];
84    STOP.contains(&t)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::note::{EstadoNudo, NoteId};
91
92    fn note(id: NoteId, title: &str, tags: &[&str]) -> Note {
93        Note {
94            id,
95            title: title.into(),
96            body: String::new(),
97            tags: tags.iter().map(|s| s.to_string()).collect(),
98            created_at: 0,
99            updated_at: 0,
100            last_access: 0,
101            mass: 1.0,
102            cuerda: None,
103            estado: EstadoNudo::Normal,
104            origen: None,
105            dibujo: None,
106        }
107    }
108
109    #[test]
110    fn propose_picks_the_shared_term() {
111        let a = note(1, "Receta de pan", &["cocina"]);
112        let b = note(2, "Horno y pan", &["cocina"]);
113        let c = note(3, "Masa madre", &["cocina"]);
114        // "cocina" está en los tres nudos → gana por doc-freq.
115        let name = propose_cuerda_name(&[&a, &b, &c]).unwrap();
116        assert_eq!(name, "Cocina");
117    }
118
119    #[test]
120    fn propose_prefers_shared_over_merely_frequent() {
121        // "pan" aparece 2 veces pero en un solo nudo; "viaje" en dos.
122        let a = note(1, "Pan pan pan", &[]);
123        let b = note(2, "Viaje a la costa", &[]);
124        let c = note(3, "Viaje largo", &[]);
125        let name = propose_cuerda_name(&[&a, &b, &c]).unwrap();
126        assert_eq!(name, "Viaje");
127    }
128
129    #[test]
130    fn propose_ignores_stopwords_and_short_tokens() {
131        let a = note(1, "El de la y", &[]);
132        let b = note(2, "Por que con", &[]);
133        // Todo son palabras vacías o de <3 letras → sin señal.
134        assert!(propose_cuerda_name(&[&a, &b]).is_none());
135    }
136
137    #[test]
138    fn propose_is_deterministic_on_ties() {
139        // "alpha" y "omega" empatan en doc-freq y freq → gana el alfabético.
140        let a = note(1, "alpha omega", &[]);
141        let b = note(2, "omega alpha", &[]);
142        assert_eq!(propose_cuerda_name(&[&a, &b]).unwrap(), "Alpha");
143    }
144}