feat(nouser): Phase A — mecanismo determinista de Mónadas
Primer trozo de Nouser/Kairos: explorador de Mónadas como agrupaciones
semánticas sobre el filesystem, sin tocar IA todavía. Cubre el 90% de
los casos con heurísticas puras.
Crates nuevos:
crates/modules/nouser/card:
- MonadManifest: la Tarjeta de Presentación de una Mónada. Espejo
conceptual de brahman::Card pero para datos: id (Ulid), label,
summary, centroid (vacío en Phase A), keywords, cardinality, entropy
[0,1], dominant_lens (Grid|Code|Gallery|Database|Markdown|Tree),
pins, members, timestamps, extensions (forward-compat).
- Diferencia explícita en docs: brahman::Card describe entidades
runtime con payload/soma/supervision; MonadManifest describe una
agrupación de datos sin proceso atrás.
- Validación: schema_version, label no vacío, entropy en rango,
cardinality consistente con members.len().
- 6 tests (validación + JSON roundtrip).
crates/modules/nouser/core:
- scanner::scan_directory: walkdir → Vec<FileEntry> con metadatos.
Skipea hidden por default; configurable max_depth y follow_links.
- cluster::by_directory: agrupa archivos por parent dir, mínimo 3
para promover a Mónada (configurable). Computa keywords (top-N
extensiones por freq + alfabético), elige Lens dominante por
extensión más frecuente, entropía de Shannon normalizada.
- db::MonadDb: store en memoria con índices BTreeMap.
resolve_members filtra IDs huérfanos.
- bin nouser con subcomandos scan, show, json. Env var
NOUSER_MIN_FILES para el threshold.
- 13 tests (4 scanner + 6 cluster + 3 db).
Demo end-to-end:
$ nouser scan crates
scan: 255 archivos en crates, 19 mónadas (min_files=3)
[01KR4C13] src card=12 ent=0.00 lens=Code keywords: rs
[01KR4C13] tests card=14 ent=0.00 lens=Code keywords: rs
[01KR4C13] fixtures card=5 ent=0.00 lens=Grid keywords: rhai
Pendientes (anotados en CHANGELOG, no urgentes):
- Phase B: bin nouser daemon que sidecarea a brahman-init.
- Phase C: pseudo-embeddings de metadatos + atracción por centroide.
- Phase D: módulo nouser-nous para el LLM real, swappable por
priority_contexts (mock-nous en test, real-nous en prod).
- Polish: labels con 2-3 componentes del path.
cargo check --workspace: 0 errores, 0 warnings.
Tests acumulados: 58.
CHANGELOG.md actualizado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
//! Clustering determinista (Phase A).
|
||||
//!
|
||||
//! Estrategia: agrupar por **directorio padre** + ranking por
|
||||
//! **extensión dominante**. No hay LLM ni embeddings — sólo metadatos.
|
||||
//! Esta capa cubre el 90% de los casos prácticos:
|
||||
//!
|
||||
//! - Un proyecto Rust en `~/dev/foo/src/` → Mónada coherente (.rs).
|
||||
//! - Un dump de fotos en `~/Pictures/2024/` → Mónada con lente Gallery.
|
||||
//! - Notas en `~/notes/` → Mónada con lente Markdown.
|
||||
//!
|
||||
//! Los casos donde esta heurística falla (archivos relacionados pero
|
||||
//! dispersos en el FS) son el dominio de los embeddings (Phase C) y
|
||||
//! del clustering por Nous (Phase D).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nouser_card::{FileEntry, Lens, MonadManifest};
|
||||
|
||||
/// Mínimo de archivos para que un directorio sea promovido a Mónada.
|
||||
/// Por debajo de eso, los archivos quedan "huérfanos" (no asignados).
|
||||
pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3;
|
||||
|
||||
/// Agrupa archivos en Mónadas por directorio padre.
|
||||
///
|
||||
/// Devuelve un `Vec<MonadManifest>` ordenado por path. Archivos en
|
||||
/// directorios con menos de `min_files` no producen Mónada.
|
||||
pub fn by_directory(files: &[FileEntry], min_files: usize) -> Vec<MonadManifest> {
|
||||
let mut by_parent: BTreeMap<PathBuf, Vec<&FileEntry>> = BTreeMap::new();
|
||||
for f in files {
|
||||
if let Some(parent) = f.path.parent() {
|
||||
by_parent.entry(parent.to_path_buf()).or_default().push(f);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (parent, group) in by_parent {
|
||||
if group.len() < min_files {
|
||||
continue;
|
||||
}
|
||||
out.push(build_monad(&parent, &group));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest {
|
||||
let label = parent
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unnamed")
|
||||
.to_string();
|
||||
|
||||
let keywords = top_extensions(group, 5);
|
||||
let lens = pick_lens(group);
|
||||
let entropy = shannon_entropy_normalized(group);
|
||||
|
||||
let summary = build_summary(parent, group, &keywords);
|
||||
|
||||
let mut m = MonadManifest::new(label);
|
||||
m.summary = summary;
|
||||
m.keywords = keywords;
|
||||
m.dominant_lens = lens;
|
||||
m.entropy = entropy;
|
||||
m.members = group.iter().map(|f| f.id).collect();
|
||||
m.touch();
|
||||
m
|
||||
}
|
||||
|
||||
fn build_summary(parent: &std::path::Path, group: &[&FileEntry], keywords: &[String]) -> String {
|
||||
let path_str = parent.display();
|
||||
let n = group.len();
|
||||
let exts = if keywords.is_empty() {
|
||||
"(sin extensiones)".to_string()
|
||||
} else {
|
||||
keywords.join(", ")
|
||||
};
|
||||
format!("{n} archivos en {path_str} (ext: {exts})")
|
||||
}
|
||||
|
||||
/// Top-N extensiones por frecuencia, descendente. Empate por orden alfabético.
|
||||
fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
|
||||
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for f in files {
|
||||
if let Some(ext) = &f.extension {
|
||||
*counts.entry(ext.clone()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut sorted: Vec<_> = counts.into_iter().collect();
|
||||
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
sorted.into_iter().take(n).map(|(k, _)| k).collect()
|
||||
}
|
||||
|
||||
/// Elige el lente dominante según la extensión más frecuente.
|
||||
fn pick_lens(files: &[&FileEntry]) -> Lens {
|
||||
let dominant = top_extensions(files, 1).into_iter().next();
|
||||
match dominant.as_deref() {
|
||||
Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp"
|
||||
| "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code,
|
||||
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => {
|
||||
Lens::Gallery
|
||||
}
|
||||
Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown,
|
||||
Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database,
|
||||
_ => Lens::Grid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Entropía de Shannon normalizada sobre la distribución de extensiones.
|
||||
/// `0.0` = todos los archivos comparten extensión. `1.0` = uniformly
|
||||
/// distributed entre `n` extensiones (máx información).
|
||||
fn shannon_entropy_normalized(files: &[&FileEntry]) -> f32 {
|
||||
let total = files.len() as f32;
|
||||
if total <= 1.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for f in files {
|
||||
let ext = f.extension.as_deref().unwrap_or("(none)");
|
||||
*counts.entry(ext.to_string()).or_default() += 1;
|
||||
}
|
||||
let entropy: f32 = counts
|
||||
.values()
|
||||
.map(|&c| {
|
||||
let p = c as f32 / total;
|
||||
-p * p.log2()
|
||||
})
|
||||
.sum();
|
||||
let max_entropy = (counts.len() as f32).log2();
|
||||
if max_entropy <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(entropy / max_entropy).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nouser_card::FileId;
|
||||
use std::path::PathBuf;
|
||||
use ulid::Ulid;
|
||||
|
||||
fn mkfile(path: &str, ext: Option<&str>) -> FileEntry {
|
||||
FileEntry {
|
||||
id: FileId::from(Ulid::new()),
|
||||
path: PathBuf::from(path),
|
||||
content_hash: None,
|
||||
size: 100,
|
||||
mtime_ms: 0,
|
||||
extension: ext.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_by_parent_directory() {
|
||||
let files = vec![
|
||||
mkfile("/proj/src/a.rs", Some("rs")),
|
||||
mkfile("/proj/src/b.rs", Some("rs")),
|
||||
mkfile("/proj/src/c.rs", Some("rs")),
|
||||
mkfile("/proj/docs/readme.md", Some("md")),
|
||||
mkfile("/proj/docs/guide.md", Some("md")),
|
||||
mkfile("/proj/docs/notes.md", Some("md")),
|
||||
];
|
||||
let monads = by_directory(&files, 3);
|
||||
assert_eq!(monads.len(), 2);
|
||||
let labels: std::collections::BTreeSet<_> = monads.iter().map(|m| &m.label).collect();
|
||||
assert!(labels.iter().any(|l| l.as_str() == "src"));
|
||||
assert!(labels.iter().any(|l| l.as_str() == "docs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_groups_not_promoted() {
|
||||
let files = vec![
|
||||
mkfile("/proj/single.txt", Some("txt")),
|
||||
mkfile("/proj/sub/a.txt", Some("txt")),
|
||||
mkfile("/proj/sub/b.txt", Some("txt")),
|
||||
mkfile("/proj/sub/c.txt", Some("txt")),
|
||||
];
|
||||
// min=3 → /proj/single solo no se promueve, /proj/sub sí.
|
||||
let monads = by_directory(&files, 3);
|
||||
assert_eq!(monads.len(), 1);
|
||||
assert_eq!(monads[0].label, "sub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lens_picked_by_dominant_extension() {
|
||||
let files = vec![
|
||||
mkfile("/x/a.rs", Some("rs")),
|
||||
mkfile("/x/b.rs", Some("rs")),
|
||||
mkfile("/x/c.rs", Some("rs")),
|
||||
];
|
||||
let monads = by_directory(&files, 3);
|
||||
assert_eq!(monads[0].dominant_lens, Lens::Code);
|
||||
|
||||
let files = vec![
|
||||
mkfile("/y/1.png", Some("png")),
|
||||
mkfile("/y/2.png", Some("png")),
|
||||
mkfile("/y/3.png", Some("png")),
|
||||
];
|
||||
let monads = by_directory(&files, 3);
|
||||
assert_eq!(monads[0].dominant_lens, Lens::Gallery);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_zero_for_homogeneous() {
|
||||
let files = vec![
|
||||
mkfile("/x/a.rs", Some("rs")),
|
||||
mkfile("/x/b.rs", Some("rs")),
|
||||
mkfile("/x/c.rs", Some("rs")),
|
||||
];
|
||||
let monads = by_directory(&files, 3);
|
||||
assert_eq!(monads[0].entropy, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_high_for_diverse() {
|
||||
let files = vec![
|
||||
mkfile("/x/a.rs", Some("rs")),
|
||||
mkfile("/x/b.md", Some("md")),
|
||||
mkfile("/x/c.json", Some("json")),
|
||||
mkfile("/x/d.png", Some("png")),
|
||||
];
|
||||
let monads = by_directory(&files, 3);
|
||||
// 4 extensiones distintas, distribución uniforme → entropy ≈ 1.0
|
||||
assert!(monads[0].entropy > 0.9, "got {}", monads[0].entropy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_extensions_orders_by_freq_then_alpha() {
|
||||
let files = vec![
|
||||
mkfile("/x/a.rs", Some("rs")),
|
||||
mkfile("/x/b.rs", Some("rs")),
|
||||
mkfile("/x/c.md", Some("md")),
|
||||
mkfile("/x/d.py", Some("py")),
|
||||
];
|
||||
let refs: Vec<&FileEntry> = files.iter().collect();
|
||||
let top = top_extensions(&refs, 3);
|
||||
assert_eq!(top, vec!["rs", "md", "py"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user