⎇ 8d107fd2c11290217f1ac439fccab4ebf1a2871f
00_unanchay/khipu/khipu-core/src/nombre.rs
History
Who touched what
raw
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
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}