⎇ d3649bfd1a80153652c2a028315369bb4b184024
crates/modules/nouser/core/src/cluster.rs
Historia
Quién tocó qué
crudo
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 by_directory_hydrated(files, min_files, None)
32}
33
34/// Variante con hidratación: si `prior` está presente, busca Mónadas
35/// previas con el mismo `path_hint` y `centroid_model` válido, y reusa
36/// su `id` y `lineage`. Esto preserva identidad across re-scans —
37/// fundamental para que el daemon pueda republicar tras hidratar de
38/// sled sin generar duplicados en el broker.
39pub fn by_directory_hydrated(
40 files: &[FileEntry],
41 min_files: usize,
42 prior: Option<&crate::db::MonadDb>,
43) -> Vec<MonadManifest> {
44 let mut by_parent: BTreeMap<PathBuf, Vec<&FileEntry>> = BTreeMap::new();
45 for f in files {
46 if let Some(parent) = f.path.parent() {
47 by_parent.entry(parent.to_path_buf()).or_default().push(f);
48 }
49 }
50
51 let mut out = Vec::new();
52 for (parent, group) in by_parent {
53 if group.len() < min_files {
54 continue;
55 }
56 let mut m = build_monad(&parent, &group);
57 if let Some(db) = prior {
58 // Reusamos id si encontramos Mónada previa con mismo
59 // path_hint Y mismo centroid_model. Distintas hipótesis
60 // de modelo no comparten identidad — son objetos
61 // semánticos distintos, aunque parecidos.
62 if let Some(existing) = db.monads().find(|prev| {
63 prev.path_hint.as_deref() == m.path_hint.as_deref()
64 && prev.centroid_model == m.centroid_model
65 }) {
66 m.id = existing.id;
67 m.lineage = existing.lineage;
68 m.created_at_ms = existing.created_at_ms;
69 m.touch();
70 }
71 }
72 out.push(m);
73 }
74 out
75}
76
77fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest {
78 let label = label_from_path(parent);
79
80 let keywords = top_extensions(group, 5);
81 let lens = pick_lens(group);
82 let entropy = shannon_entropy_normalized(group);
83
84 let summary = build_summary(parent, group, &keywords);
85
86 // Centroide vectorial: promedio de los embeddings de los miembros.
87 // Esto es lo que permite "atracción" determinista de archivos
88 // nuevos sin tocar Nous.
89 let member_vecs: Vec<Vec<f32>> = group.iter().map(|f| embed::embed(f).to_vec()).collect();
90 let centroid = embed::centroid(&member_vecs);
91
92 let mut m = MonadManifest::new(label);
93 m.summary = summary;
94 m.keywords = keywords;
95 m.dominant_lens = lens;
96 m.entropy = entropy;
97 m.centroid = centroid;
98 // Taggeamos el centroide con su modelo. attract verifica esto
99 // antes de comparar para no mezclar pseudo-32d con real-384d.
100 m.centroid_model = Some(embed::MODEL_ID.to_string());
101 // path_hint = identidad estable across re-scans para
102 // hidratación. Display es lossy con UTF-8 inválido pero los
103 // paths legítimos se imprimen consistentes.
104 m.path_hint = Some(parent.display().to_string());
105 m.members = group.iter().map(|f| f.id).collect();
106 m.touch();
107 m
108}
109
110/// Construye un label legible tomando los últimos hasta 2 componentes
111/// del path. Esto desambigua `src/` repetidos en monorepos: en lugar
112/// de 5 Mónadas con label "src", quedan "ente-zero/src", "ente-brain/src",
113/// etc. Para directorios shallow (root o un nivel), cae al
114/// `file_name()` simple.
115fn label_from_path(p: &std::path::Path) -> String {
116 let normals: Vec<&str> = p
117 .components()
118 .filter_map(|c| match c {
119 std::path::Component::Normal(s) => s.to_str(),
120 _ => None,
121 })
122 .collect();
123 if normals.is_empty() {
124 return "unnamed".to_string();
125 }
126 let take = normals.len().min(2);
127 let start = normals.len() - take;
128 normals[start..].join("/")
129}
130
131fn build_summary(parent: &std::path::Path, group: &[&FileEntry], keywords: &[String]) -> String {
132 let path_str = parent.display();
133 let n = group.len();
134 let exts = if keywords.is_empty() {
135 "(sin extensiones)".to_string()
136 } else {
137 keywords.join(", ")
138 };
139 format!("{n} archivos en {path_str} (ext: {exts})")
140}
141
142/// Top-N extensiones por frecuencia, descendente. Empate por orden alfabético.
143fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
144 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
145 for f in files {
146 if let Some(ext) = &f.extension {
147 *counts.entry(ext.clone()).or_default() += 1;
148 }
149 }
150 let mut sorted: Vec<_> = counts.into_iter().collect();
151 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
152 sorted.into_iter().take(n).map(|(k, _)| k).collect()
153}
154
155/// Elige el lente dominante según la extensión más frecuente, con
156/// fallback a `shipote-discern` sobre el head del archivo más
157/// representativo cuando la extensión no da hint claro (Lens::Grid).
158fn pick_lens(files: &[&FileEntry]) -> Lens {
159 let dominant = top_extensions(files, 1).into_iter().next();
160 let by_ext = match dominant.as_deref() {
161 Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp"
162 | "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code,
163 Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => {
164 Lens::Gallery
165 }
166 Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown,
167 Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database,
168 _ => Lens::Grid,
169 };
170 if by_ext != Lens::Grid {
171 return by_ext;
172 }
173 // Fallback: samplear el primer archivo del grupo con shipote-discern.
174 // Sólo si tiene path real (FileEntry con path absoluto/relativo).
175 if let Some(first) = files.first() {
176 if let Some(lens) = discern_lens(&first.path) {
177 return lens;
178 }
179 }
180 Lens::Grid
181}
182
183fn discern_lens(path: &std::path::Path) -> Option<Lens> {
184 use std::io::Read;
185 let mut buf = vec![0u8; 4096];
186 let mut f = std::fs::File::open(path).ok()?;
187 let n = f.read(&mut buf).ok()?;
188 buf.truncate(n);
189 let pipeline = shipote_discern::DiscernPipeline::default_pipeline();
190 let path_str = path.to_str();
191 let d = pipeline.discern(
192 &buf,
193 &shipote_discern::Hint {
194 path: path_str,
195 size_total: None,
196 },
197 )?;
198 match d.lens.as_deref()? {
199 "code" => Some(Lens::Code),
200 "gallery" => Some(Lens::Gallery),
201 "markdown" => Some(Lens::Markdown),
202 "database" => Some(Lens::Database),
203 "tree" => Some(Lens::Tree),
204 _ => None,
205 }
206}
207
208/// Entropía de Shannon normalizada sobre la distribución de extensiones.
209/// `0.0` = todos los archivos comparten extensión. `1.0` = uniformly
210/// distributed entre `n` extensiones (máx información).
211fn shannon_entropy_normalized(files: &[&FileEntry]) -> f32 {
212 let total = files.len() as f32;
213 if total <= 1.0 {
214 return 0.0;
215 }
216 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
217 for f in files {
218 let ext = f.extension.as_deref().unwrap_or("(none)");
219 *counts.entry(ext.to_string()).or_default() += 1;
220 }
221 let entropy: f32 = counts
222 .values()
223 .map(|&c| {
224 let p = c as f32 / total;
225 -p * p.log2()
226 })
227 .sum();
228 let max_entropy = (counts.len() as f32).log2();
229 if max_entropy <= 0.0 {
230 0.0
231 } else {
232 (entropy / max_entropy).clamp(0.0, 1.0)
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use nouser_card::FileId;
240 use std::path::PathBuf;
241 use ulid::Ulid;
242
243 fn mkfile(path: &str, ext: Option<&str>) -> FileEntry {
244 FileEntry {
245 id: FileId::from(Ulid::new()),
246 path: PathBuf::from(path),
247 content_hash: None,
248 size: 100,
249 mtime_ms: 0,
250 extension: ext.map(String::from),
251 }
252 }
253
254 #[test]
255 fn groups_by_parent_directory() {
256 let files = vec![
257 mkfile("/proj/src/a.rs", Some("rs")),
258 mkfile("/proj/src/b.rs", Some("rs")),
259 mkfile("/proj/src/c.rs", Some("rs")),
260 mkfile("/proj/docs/readme.md", Some("md")),
261 mkfile("/proj/docs/guide.md", Some("md")),
262 mkfile("/proj/docs/notes.md", Some("md")),
263 ];
264 let monads = by_directory(&files, 3);
265 assert_eq!(monads.len(), 2);
266 let labels: std::collections::BTreeSet<_> = monads.iter().map(|m| &m.label).collect();
267 // Phase B: labels usan los últimos 2 componentes del path para
268 // desambiguar (proj/src vs proj/docs en lugar de src vs docs).
269 assert!(labels.iter().any(|l| l.as_str() == "proj/src"));
270 assert!(labels.iter().any(|l| l.as_str() == "proj/docs"));
271 }
272
273 #[test]
274 fn small_groups_not_promoted() {
275 let files = vec![
276 mkfile("/proj/single.txt", Some("txt")),
277 mkfile("/proj/sub/a.txt", Some("txt")),
278 mkfile("/proj/sub/b.txt", Some("txt")),
279 mkfile("/proj/sub/c.txt", Some("txt")),
280 ];
281 // min=3 → /proj/single solo no se promueve, /proj/sub sí.
282 let monads = by_directory(&files, 3);
283 assert_eq!(monads.len(), 1);
284 assert_eq!(monads[0].label, "proj/sub");
285 }
286
287 #[test]
288 fn label_from_root_only_one_component() {
289 // Un solo componente normal en el path → no hay "padre" útil.
290 let p = std::path::Path::new("/onlyone");
291 assert_eq!(label_from_path(p), "onlyone");
292 }
293
294 #[test]
295 fn label_from_deep_path_takes_last_two() {
296 let p = std::path::Path::new("/a/b/c/d/e");
297 assert_eq!(label_from_path(p), "d/e");
298 }
299
300 #[test]
301 fn lens_picked_by_dominant_extension() {
302 let files = vec![
303 mkfile("/x/a.rs", Some("rs")),
304 mkfile("/x/b.rs", Some("rs")),
305 mkfile("/x/c.rs", Some("rs")),
306 ];
307 let monads = by_directory(&files, 3);
308 assert_eq!(monads[0].dominant_lens, Lens::Code);
309
310 let files = vec![
311 mkfile("/y/1.png", Some("png")),
312 mkfile("/y/2.png", Some("png")),
313 mkfile("/y/3.png", Some("png")),
314 ];
315 let monads = by_directory(&files, 3);
316 assert_eq!(monads[0].dominant_lens, Lens::Gallery);
317 }
318
319 #[test]
320 fn entropy_zero_for_homogeneous() {
321 let files = vec![
322 mkfile("/x/a.rs", Some("rs")),
323 mkfile("/x/b.rs", Some("rs")),
324 mkfile("/x/c.rs", Some("rs")),
325 ];
326 let monads = by_directory(&files, 3);
327 assert_eq!(monads[0].entropy, 0.0);
328 }
329
330 #[test]
331 fn entropy_high_for_diverse() {
332 let files = vec![
333 mkfile("/x/a.rs", Some("rs")),
334 mkfile("/x/b.md", Some("md")),
335 mkfile("/x/c.json", Some("json")),
336 mkfile("/x/d.png", Some("png")),
337 ];
338 let monads = by_directory(&files, 3);
339 // 4 extensiones distintas, distribución uniforme → entropy ≈ 1.0
340 assert!(monads[0].entropy > 0.9, "got {}", monads[0].entropy);
341 }
342
343 #[test]
344 fn top_extensions_orders_by_freq_then_alpha() {
345 let files = vec![
346 mkfile("/x/a.rs", Some("rs")),
347 mkfile("/x/b.rs", Some("rs")),
348 mkfile("/x/c.md", Some("md")),
349 mkfile("/x/d.py", Some("py")),
350 ];
351 let refs: Vec<&FileEntry> = files.iter().collect();
352 let top = top_extensions(&refs, 3);
353 assert_eq!(top, vec!["rs", "md", "py"]);
354 }
355}