gioser

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Definiciones

⎇ d7b4886164df5ef048a7c8c0d735fb83555c7990 crates/modules/nouser/core/src/cluster.rs Rust · 281 líneas · 9.6 KB Historia Quién tocó qué crudo

refactor(nouser): labels de Mónada con 2 componentes del path 794884a90 · Sergio ·

1//! Clustering determinista (Phase A).
2//!
3//! Estrategia: agrupar por **directorio padre** + ranking por
4//! **extensión dominante**. No hay LLM ni embeddings — sólo metadatos.
5//! Esta capa cubre el 90% de los casos prácticos:
6//!
7//! - Un proyecto Rust en `~/dev/foo/src/` → Mónada coherente (.rs).
8//! - Un dump de fotos en `~/Pictures/2024/` → Mónada con lente Gallery.
9//! - Notas en `~/notes/` → Mónada con lente Markdown.
10//!
11//! Los casos donde esta heurística falla (archivos relacionados pero
12//! dispersos en el FS) son el dominio de los embeddings (Phase C) y
13//! del clustering por Nous (Phase D).
14
15use std::collections::BTreeMap;
16use std::path::PathBuf;
17
18use nouser_card::{FileEntry, Lens, MonadManifest};
19
20use crate::embed;
21
22/// Mínimo de archivos para que un directorio sea promovido a Mónada.
23/// Por debajo de eso, los archivos quedan "huérfanos" (no asignados).
24pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3;
25
26/// Agrupa archivos en Mónadas por directorio padre.
27///
28/// Devuelve un `Vec<MonadManifest>` ordenado por path. Archivos en
29/// directorios con menos de `min_files` no producen Mónada.
30pub fn by_directory(files: &[FileEntry], min_files: usize) -> Vec<MonadManifest> {
31    let mut by_parent: BTreeMap<PathBuf, Vec<&FileEntry>> = BTreeMap::new();
32    for f in files {
33        if let Some(parent) = f.path.parent() {
34            by_parent.entry(parent.to_path_buf()).or_default().push(f);
35        }
36    }
37
38    let mut out = Vec::new();
39    for (parent, group) in by_parent {
40        if group.len() < min_files {
41            continue;
42        }
43        out.push(build_monad(&parent, &group));
44    }
45    out
46}
47
48fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest {
49    let label = label_from_path(parent);
50
51    let keywords = top_extensions(group, 5);
52    let lens = pick_lens(group);
53    let entropy = shannon_entropy_normalized(group);
54
55    let summary = build_summary(parent, group, &keywords);
56
57    // Centroide vectorial: promedio de los embeddings de los miembros.
58    // Esto es lo que permite "atracción" determinista de archivos
59    // nuevos sin tocar Nous.
60    let member_vecs: Vec<Vec<f32>> = group.iter().map(|f| embed::embed(f).to_vec()).collect();
61    let centroid = embed::centroid(&member_vecs);
62
63    let mut m = MonadManifest::new(label);
64    m.summary = summary;
65    m.keywords = keywords;
66    m.dominant_lens = lens;
67    m.entropy = entropy;
68    m.centroid = centroid;
69    m.members = group.iter().map(|f| f.id).collect();
70    m.touch();
71    m
72}
73
74/// Construye un label legible tomando los últimos hasta 2 componentes
75/// del path. Esto desambigua `src/` repetidos en monorepos: en lugar
76/// de 5 Mónadas con label "src", quedan "ente-zero/src", "ente-brain/src",
77/// etc. Para directorios shallow (root o un nivel), cae al
78/// `file_name()` simple.
79fn label_from_path(p: &std::path::Path) -> String {
80    let normals: Vec<&str> = p
81        .components()
82        .filter_map(|c| match c {
83            std::path::Component::Normal(s) => s.to_str(),
84            _ => None,
85        })
86        .collect();
87    if normals.is_empty() {
88        return "unnamed".to_string();
89    }
90    let take = normals.len().min(2);
91    let start = normals.len() - take;
92    normals[start..].join("/")
93}
94
95fn build_summary(parent: &std::path::Path, group: &[&FileEntry], keywords: &[String]) -> String {
96    let path_str = parent.display();
97    let n = group.len();
98    let exts = if keywords.is_empty() {
99        "(sin extensiones)".to_string()
100    } else {
101        keywords.join(", ")
102    };
103    format!("{n} archivos en {path_str} (ext: {exts})")
104}
105
106/// Top-N extensiones por frecuencia, descendente. Empate por orden alfabético.
107fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
108    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
109    for f in files {
110        if let Some(ext) = &f.extension {
111            *counts.entry(ext.clone()).or_default() += 1;
112        }
113    }
114    let mut sorted: Vec<_> = counts.into_iter().collect();
115    sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
116    sorted.into_iter().take(n).map(|(k, _)| k).collect()
117}
118
119/// Elige el lente dominante según la extensión más frecuente.
120fn pick_lens(files: &[&FileEntry]) -> Lens {
121    let dominant = top_extensions(files, 1).into_iter().next();
122    match dominant.as_deref() {
123        Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp"
124        | "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code,
125        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => {
126            Lens::Gallery
127        }
128        Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown,
129        Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database,
130        _ => Lens::Grid,
131    }
132}
133
134/// Entropía de Shannon normalizada sobre la distribución de extensiones.
135/// `0.0` = todos los archivos comparten extensión. `1.0` = uniformly
136/// distributed entre `n` extensiones (máx información).
137fn shannon_entropy_normalized(files: &[&FileEntry]) -> f32 {
138    let total = files.len() as f32;
139    if total <= 1.0 {
140        return 0.0;
141    }
142    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
143    for f in files {
144        let ext = f.extension.as_deref().unwrap_or("(none)");
145        *counts.entry(ext.to_string()).or_default() += 1;
146    }
147    let entropy: f32 = counts
148        .values()
149        .map(|&c| {
150            let p = c as f32 / total;
151            -p * p.log2()
152        })
153        .sum();
154    let max_entropy = (counts.len() as f32).log2();
155    if max_entropy <= 0.0 {
156        0.0
157    } else {
158        (entropy / max_entropy).clamp(0.0, 1.0)
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use nouser_card::FileId;
166    use std::path::PathBuf;
167    use ulid::Ulid;
168
169    fn mkfile(path: &str, ext: Option<&str>) -> FileEntry {
170        FileEntry {
171            id: FileId::from(Ulid::new()),
172            path: PathBuf::from(path),
173            content_hash: None,
174            size: 100,
175            mtime_ms: 0,
176            extension: ext.map(String::from),
177        }
178    }
179
180    #[test]
181    fn groups_by_parent_directory() {
182        let files = vec![
183            mkfile("/proj/src/a.rs", Some("rs")),
184            mkfile("/proj/src/b.rs", Some("rs")),
185            mkfile("/proj/src/c.rs", Some("rs")),
186            mkfile("/proj/docs/readme.md", Some("md")),
187            mkfile("/proj/docs/guide.md", Some("md")),
188            mkfile("/proj/docs/notes.md", Some("md")),
189        ];
190        let monads = by_directory(&files, 3);
191        assert_eq!(monads.len(), 2);
192        let labels: std::collections::BTreeSet<_> = monads.iter().map(|m| &m.label).collect();
193        // Phase B: labels usan los últimos 2 componentes del path para
194        // desambiguar (proj/src vs proj/docs en lugar de src vs docs).
195        assert!(labels.iter().any(|l| l.as_str() == "proj/src"));
196        assert!(labels.iter().any(|l| l.as_str() == "proj/docs"));
197    }
198
199    #[test]
200    fn small_groups_not_promoted() {
201        let files = vec![
202            mkfile("/proj/single.txt", Some("txt")),
203            mkfile("/proj/sub/a.txt", Some("txt")),
204            mkfile("/proj/sub/b.txt", Some("txt")),
205            mkfile("/proj/sub/c.txt", Some("txt")),
206        ];
207        // min=3 → /proj/single solo no se promueve, /proj/sub sí.
208        let monads = by_directory(&files, 3);
209        assert_eq!(monads.len(), 1);
210        assert_eq!(monads[0].label, "proj/sub");
211    }
212
213    #[test]
214    fn label_from_root_only_one_component() {
215        // Un solo componente normal en el path → no hay "padre" útil.
216        let p = std::path::Path::new("/onlyone");
217        assert_eq!(label_from_path(p), "onlyone");
218    }
219
220    #[test]
221    fn label_from_deep_path_takes_last_two() {
222        let p = std::path::Path::new("/a/b/c/d/e");
223        assert_eq!(label_from_path(p), "d/e");
224    }
225
226    #[test]
227    fn lens_picked_by_dominant_extension() {
228        let files = vec![
229            mkfile("/x/a.rs", Some("rs")),
230            mkfile("/x/b.rs", Some("rs")),
231            mkfile("/x/c.rs", Some("rs")),
232        ];
233        let monads = by_directory(&files, 3);
234        assert_eq!(monads[0].dominant_lens, Lens::Code);
235
236        let files = vec![
237            mkfile("/y/1.png", Some("png")),
238            mkfile("/y/2.png", Some("png")),
239            mkfile("/y/3.png", Some("png")),
240        ];
241        let monads = by_directory(&files, 3);
242        assert_eq!(monads[0].dominant_lens, Lens::Gallery);
243    }
244
245    #[test]
246    fn entropy_zero_for_homogeneous() {
247        let files = vec![
248            mkfile("/x/a.rs", Some("rs")),
249            mkfile("/x/b.rs", Some("rs")),
250            mkfile("/x/c.rs", Some("rs")),
251        ];
252        let monads = by_directory(&files, 3);
253        assert_eq!(monads[0].entropy, 0.0);
254    }
255
256    #[test]
257    fn entropy_high_for_diverse() {
258        let files = vec![
259            mkfile("/x/a.rs", Some("rs")),
260            mkfile("/x/b.md", Some("md")),
261            mkfile("/x/c.json", Some("json")),
262            mkfile("/x/d.png", Some("png")),
263        ];
264        let monads = by_directory(&files, 3);
265        // 4 extensiones distintas, distribución uniforme → entropy ≈ 1.0
266        assert!(monads[0].entropy > 0.9, "got {}", monads[0].entropy);
267    }
268
269    #[test]
270    fn top_extensions_orders_by_freq_then_alpha() {
271        let files = vec![
272            mkfile("/x/a.rs", Some("rs")),
273            mkfile("/x/b.rs", Some("rs")),
274            mkfile("/x/c.md", Some("md")),
275            mkfile("/x/d.py", Some("py")),
276        ];
277        let refs: Vec<&FileEntry> = files.iter().collect();
278        let top = top_extensions(&refs, 3);
279        assert_eq!(top, vec!["rs", "md", "py"]);
280    }
281}