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:
@@ -6,6 +6,74 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-08
|
## 2026-05-08
|
||||||
|
|
||||||
|
### feat(nouser): Phase A — mecanismo determinista de Mónadas
|
||||||
|
Primer trozo del módulo Nouser (Kairos): explorador de Mónadas como
|
||||||
|
"imanes semánticos" sobre el filesystem. Phase A cubre el 90% de los
|
||||||
|
casos sin tocar IA — sólo metadatos y heurísticas.
|
||||||
|
|
||||||
|
Crates nuevos:
|
||||||
|
|
||||||
|
- `crates/modules/nouser/card`: `MonadManifest` (la Tarjeta de
|
||||||
|
Presentación de una Mónada — espejo conceptual de `brahman::Card`
|
||||||
|
pero para datos, no para procesos runtime). Campos: id (Ulid),
|
||||||
|
label, summary, centroid (vacío en Phase A), keywords, cardinality,
|
||||||
|
entropy [0,1], dominant_lens, pins, members, timestamps,
|
||||||
|
extensions (forward-compat). 6 tests de validación + JSON roundtrip.
|
||||||
|
- `crates/modules/nouser/core`: pipeline determinista.
|
||||||
|
- `scanner`: walkdir → `Vec<FileEntry>` con metadatos (path, size,
|
||||||
|
mtime, extension). Skipea hidden por default. Configurable max
|
||||||
|
depth y follow_links.
|
||||||
|
- `cluster::by_directory`: agrupa por parent dir, mínimo 3 archivos
|
||||||
|
para promover a Mónada (configurable). Calcula keywords (top-N
|
||||||
|
extensiones por frecuencia + alfabético), elige `Lens` dominante
|
||||||
|
(Code/Gallery/Markdown/Database/Grid) según extensión más
|
||||||
|
frecuente, computa entropía de Shannon normalizada [0,1].
|
||||||
|
- `db`: `MonadDb` en memoria con índices BTreeMap files/monads y
|
||||||
|
`resolve_members(monad_id)` que filtra IDs huérfanos. Phase B
|
||||||
|
traerá persistencia.
|
||||||
|
- bin `nouser`: subcomandos `scan <dir>`, `show <dir> <prefix>`,
|
||||||
|
`json <dir>`. Env var `NOUSER_MIN_FILES` para tunear 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
|
||||||
|
...
|
||||||
|
|
||||||
|
$ nouser show crates 01KR4C
|
||||||
|
Monad 01KR4C1370DVF6NMTW6SECNXAF
|
||||||
|
label: src
|
||||||
|
summary: 4 archivos en crates/modules/nouser/core/src (ext: rs)
|
||||||
|
cardinality: 4
|
||||||
|
entropy: 0.0000
|
||||||
|
lens: Code
|
||||||
|
members (4):
|
||||||
|
4132 bytes crates/modules/nouser/core/src/db.rs
|
||||||
|
...
|
||||||
|
|
||||||
|
Pendientes para próximas fases (anotados, no urgentes):
|
||||||
|
- **Phase B**: bin `nouser daemon` que sidecarea a brahman-init
|
||||||
|
declarando flows (`scan-request:json` → `monad-update:json`).
|
||||||
|
- **Phase C**: pseudo-embeddings deterministas (hash de path/ext/size
|
||||||
|
a 32-d) + atracción por centroide via cosine similarity. Implementa
|
||||||
|
el "imán" sin LLM.
|
||||||
|
- **Phase D**: módulo `nouser-nous` aparte para el LLM real
|
||||||
|
(Llama/ONNX). En `priority_contexts.test` el Init pinea a
|
||||||
|
`mock-nous` (embeddings determinísticos); en `prod` a `real-nous`.
|
||||||
|
- **Polish**: labels de Mónada incluir 2-3 componentes del path para
|
||||||
|
desambiguar `src/` repetidos en monorepo.
|
||||||
|
|
||||||
|
Workspace: 0 errores, 0 warnings. Tests acumulados: 58
|
||||||
|
(card 11, broker 15, handshake codec+transport 2 + integ 7,
|
||||||
|
card-wit 4, admin 0, nouser-card 6, nouser-core 13).
|
||||||
|
|
||||||
### feat(broker): priority contexts — biases per-contexto operativo
|
### feat(broker): priority contexts — biases per-contexto operativo
|
||||||
- `brahman-card::ContextBias { pin_to: Option<String>, priority_offset: i8 }`
|
- `brahman-card::ContextBias { pin_to: Option<String>, priority_offset: i8 }`
|
||||||
declara un override per-contexto.
|
declara un override per-contexto.
|
||||||
|
|||||||
Generated
+23
@@ -6045,6 +6045,29 @@ dependencies = [
|
|||||||
"windows-sys 0.48.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nouser-card"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"ulid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nouser-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"nouser-card",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"ulid",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ntapi"
|
name = "ntapi"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ members = [
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
"crates/modules/nakui/core",
|
"crates/modules/nakui/core",
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# modules/nouser/ — explorador de Mónadas (nuevo)
|
||||||
|
# ============================================================
|
||||||
|
"crates/modules/nouser/card",
|
||||||
|
"crates/modules/nouser/core",
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# apps/ — apps que consumen el protocolo (yahweh modules+shell)
|
# apps/ — apps que consumen el protocolo (yahweh modules+shell)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "nouser-card"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "Nouser — manifiesto de Mónada (agrupación semántica de archivos). Espejo de brahman-card pero para datos."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
ulid = { workspace = true }
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
//! `nouser-card` — manifiesto de Mónada.
|
||||||
|
//!
|
||||||
|
//! Una **Mónada** es una agrupación semántica de archivos: el archivo
|
||||||
|
//! físico no se mueve, pero su pertenencia se modela por un objeto
|
||||||
|
//! ([`MonadManifest`]) con identidad propia, métricas y un "lente" de
|
||||||
|
//! visualización. La idea hereda el espíritu de la Tarjeta de
|
||||||
|
//! Presentación de Brahman (`brahman-card::Card`): un manifiesto
|
||||||
|
//! tipado, validado y serializable que define qué es la entidad y
|
||||||
|
//! cómo el sistema debe interactuar con ella.
|
||||||
|
//!
|
||||||
|
//! Diferencia con `brahman-card::Card`:
|
||||||
|
//!
|
||||||
|
//! | brahman::Card | nouser::MonadManifest |
|
||||||
|
//! |-------------------------------------|-------------------------------|
|
||||||
|
//! | Describe una **entidad runtime** | Describe una **agrupación** |
|
||||||
|
//! | Tiene `payload`/`soma`/`supervision`| No tiene proceso detrás |
|
||||||
|
//! | Vive durante una sesión | Vive en una DB persistente |
|
||||||
|
//! | Fluye por handshake/postcard | Fluye por queries del backend |
|
||||||
|
//!
|
||||||
|
//! Este crate sólo define los tipos. La lógica de scan, cluster,
|
||||||
|
//! attraction vive en `nouser-core`.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(rust_2018_idioms)]
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
use ulid::Ulid;
|
||||||
|
|
||||||
|
// Re-export para consumidores
|
||||||
|
pub use ::ulid;
|
||||||
|
|
||||||
|
/// Versión del esquema del manifiesto. Bump al cambiar el schema.
|
||||||
|
pub const MONAD_SCHEMA_VERSION: u16 = 1;
|
||||||
|
|
||||||
|
/// Identificador opaco de un archivo registrado en la DB.
|
||||||
|
pub type FileId = Ulid;
|
||||||
|
|
||||||
|
/// Identificador opaco de una Mónada.
|
||||||
|
pub type MonadId = Ulid;
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// FileEntry — el archivo como dato indexado
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Registro físico de un archivo en la DB. Es la unidad atómica que
|
||||||
|
/// pertenece a (potencialmente varias) Mónadas.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub id: FileId,
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// Hash de contenido (blake3) — sólo se computa si el archivo es
|
||||||
|
/// chico o el usuario lo pidió. `None` por default en Phase 0.
|
||||||
|
#[serde(default)]
|
||||||
|
pub content_hash: Option<[u8; 32]>,
|
||||||
|
/// Tamaño en bytes.
|
||||||
|
pub size: u64,
|
||||||
|
/// `mtime` como ms desde UNIX_EPOCH.
|
||||||
|
pub mtime_ms: u64,
|
||||||
|
/// Extensión normalizada en lowercase, sin punto. `None` si no tiene.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Lens — la "vista" preferida de una Mónada
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Lente de visualización dominante. La UI (yahweh) elige cómo renderizar
|
||||||
|
/// los miembros de una Mónada según este hint.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Lens {
|
||||||
|
/// Grid genérico: thumbnail + nombre + meta.
|
||||||
|
#[default]
|
||||||
|
Grid,
|
||||||
|
/// Editor de código con highlighting (rs, py, ts, ...).
|
||||||
|
Code,
|
||||||
|
/// Galería de imágenes (png, jpg, svg, ...).
|
||||||
|
Gallery,
|
||||||
|
/// Vista tabular (csv, sqlite, ...).
|
||||||
|
Database,
|
||||||
|
/// Texto renderizado (md, rst, txt).
|
||||||
|
Markdown,
|
||||||
|
/// Árbol jerárquico (cuando la Mónada es estructural).
|
||||||
|
Tree,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// MonadManifest — la Tarjeta de Presentación de la Mónada
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Manifiesto de una Mónada. Equivalente conceptual a la Tarjeta de
|
||||||
|
/// Presentación de Brahman, pero para una agrupación de datos.
|
||||||
|
///
|
||||||
|
/// Se serializa a JSON/TOML para persistencia y debugging; es el
|
||||||
|
/// "ADN" que la UI lee para saber cómo presentar la Mónada sin tocar
|
||||||
|
/// el disco.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MonadManifest {
|
||||||
|
/// Versión del esquema. Bump = romper compatibilidad de DB.
|
||||||
|
pub schema_version: u16,
|
||||||
|
|
||||||
|
/// Identificador opaco. ULID — orderable por tiempo de creación.
|
||||||
|
pub id: MonadId,
|
||||||
|
|
||||||
|
/// Mónada de la que ésta fue derivada (split, merge), si aplica.
|
||||||
|
#[serde(default)]
|
||||||
|
pub lineage: Option<MonadId>,
|
||||||
|
|
||||||
|
/// Nombre humano corto (1-4 palabras, generado por reglas o por Nous).
|
||||||
|
pub label: String,
|
||||||
|
|
||||||
|
/// Resumen de propósito (1-2 oraciones). Generado por Nous cuando
|
||||||
|
/// la masa de la Mónada justifica la consulta.
|
||||||
|
#[serde(default)]
|
||||||
|
pub summary: String,
|
||||||
|
|
||||||
|
/// Centroide vectorial (embedding promedio de los miembros). Vacío
|
||||||
|
/// en Phase 0 (sin embeddings); se llena cuando entran las
|
||||||
|
/// pseudo-embeddings o el modelo real.
|
||||||
|
#[serde(default)]
|
||||||
|
pub centroid: Vec<f32>,
|
||||||
|
|
||||||
|
/// Tokens dominantes: extensiones, palabras clave, etc.
|
||||||
|
/// 5-10 elementos típicamente.
|
||||||
|
#[serde(default)]
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
|
||||||
|
/// Cantidad de miembros (== `members.len()`). Cacheado para evitar
|
||||||
|
/// el cost de leer la lista cada vez.
|
||||||
|
pub cardinality: u32,
|
||||||
|
|
||||||
|
/// Métrica de dispersión interna [0.0, 1.0]:
|
||||||
|
/// - 0.0: todos los miembros son muy similares (Mónada coherente).
|
||||||
|
/// - 1.0: miembros muy heterogéneos (sugerencia: bifurcar).
|
||||||
|
///
|
||||||
|
/// Calculada como entropía de Shannon normalizada sobre las
|
||||||
|
/// extensiones de los miembros.
|
||||||
|
#[serde(default)]
|
||||||
|
pub entropy: f32,
|
||||||
|
|
||||||
|
/// Lente preferido para visualización en la UI.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dominant_lens: Lens,
|
||||||
|
|
||||||
|
/// Archivos anclados manualmente: NO se mueven en re-clustering
|
||||||
|
/// automático. El usuario "fija" estos miembros.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pins: BTreeSet<FileId>,
|
||||||
|
|
||||||
|
/// IDs de archivos miembros (incluye pins).
|
||||||
|
pub members: BTreeSet<FileId>,
|
||||||
|
|
||||||
|
/// Unix ms de creación de la Mónada.
|
||||||
|
pub created_at_ms: u64,
|
||||||
|
|
||||||
|
/// Unix ms de la última actualización (re-cluster, re-name, ...).
|
||||||
|
pub updated_at_ms: u64,
|
||||||
|
|
||||||
|
/// Forward-compat: campos JSON desconocidos preservados.
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub extensions: BTreeMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Errores y validación
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum MonadError {
|
||||||
|
#[error("schema mismatch: got {got}, expected {expected}")]
|
||||||
|
SchemaMismatch { got: u16, expected: u16 },
|
||||||
|
#[error("label vacío")]
|
||||||
|
EmptyLabel,
|
||||||
|
#[error("label demasiado largo: {0} bytes (max 256)")]
|
||||||
|
LabelTooLong(usize),
|
||||||
|
#[error("entropía fuera de [0,1]: {0}")]
|
||||||
|
InvalidEntropy(f32),
|
||||||
|
#[error("Monad sin miembros y sin pins")]
|
||||||
|
Empty,
|
||||||
|
#[error("cardinalidad declarada {declared} ≠ members.len() {actual}")]
|
||||||
|
CardinalityMismatch { declared: u32, actual: u32 },
|
||||||
|
#[error("JSON inválido: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MonadManifest {
|
||||||
|
/// Constructor con defaults razonables. `id` y timestamps se
|
||||||
|
/// generan; resto vacío.
|
||||||
|
pub fn new(label: impl Into<String>) -> Self {
|
||||||
|
let now_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
Self {
|
||||||
|
schema_version: MONAD_SCHEMA_VERSION,
|
||||||
|
id: Ulid::new(),
|
||||||
|
lineage: None,
|
||||||
|
label: label.into(),
|
||||||
|
summary: String::new(),
|
||||||
|
centroid: Vec::new(),
|
||||||
|
keywords: Vec::new(),
|
||||||
|
cardinality: 0,
|
||||||
|
entropy: 0.0,
|
||||||
|
dominant_lens: Lens::default(),
|
||||||
|
pins: BTreeSet::new(),
|
||||||
|
members: BTreeSet::new(),
|
||||||
|
created_at_ms: now_ms,
|
||||||
|
updated_at_ms: now_ms,
|
||||||
|
extensions: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validación semántica.
|
||||||
|
pub fn validate(&self) -> Result<(), MonadError> {
|
||||||
|
if self.schema_version != MONAD_SCHEMA_VERSION {
|
||||||
|
return Err(MonadError::SchemaMismatch {
|
||||||
|
got: self.schema_version,
|
||||||
|
expected: MONAD_SCHEMA_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.label.trim().is_empty() {
|
||||||
|
return Err(MonadError::EmptyLabel);
|
||||||
|
}
|
||||||
|
if self.label.len() > 256 {
|
||||||
|
return Err(MonadError::LabelTooLong(self.label.len()));
|
||||||
|
}
|
||||||
|
if !(0.0..=1.0).contains(&self.entropy) {
|
||||||
|
return Err(MonadError::InvalidEntropy(self.entropy));
|
||||||
|
}
|
||||||
|
if self.members.is_empty() && self.pins.is_empty() {
|
||||||
|
return Err(MonadError::Empty);
|
||||||
|
}
|
||||||
|
let actual = self.members.len() as u32;
|
||||||
|
if self.cardinality != actual {
|
||||||
|
return Err(MonadError::CardinalityMismatch {
|
||||||
|
declared: self.cardinality,
|
||||||
|
actual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializa a JSON pretty.
|
||||||
|
pub fn to_json_pretty(&self) -> Result<String, MonadError> {
|
||||||
|
Ok(serde_json::to_string_pretty(self)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserializa desde JSON y valida.
|
||||||
|
pub fn from_json(src: &str) -> Result<Self, MonadError> {
|
||||||
|
let m: Self = serde_json::from_str(src)?;
|
||||||
|
m.validate()?;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recalcula `cardinality` y `updated_at_ms` desde `members`.
|
||||||
|
/// Usar tras mutaciones del set de miembros.
|
||||||
|
pub fn touch(&mut self) {
|
||||||
|
self.cardinality = self.members.len() as u32;
|
||||||
|
self.updated_at_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_minimal() {
|
||||||
|
let mut m = MonadManifest::new("test");
|
||||||
|
m.members.insert(Ulid::new());
|
||||||
|
m.touch();
|
||||||
|
m.validate().expect("debe validar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_label_rejected() {
|
||||||
|
let mut m = MonadManifest::new("x");
|
||||||
|
m.label = String::new();
|
||||||
|
m.members.insert(Ulid::new());
|
||||||
|
m.touch();
|
||||||
|
assert!(matches!(m.validate(), Err(MonadError::EmptyLabel)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entropy_out_of_range_rejected() {
|
||||||
|
let mut m = MonadManifest::new("x");
|
||||||
|
m.members.insert(Ulid::new());
|
||||||
|
m.entropy = 1.5;
|
||||||
|
m.touch();
|
||||||
|
assert!(matches!(m.validate(), Err(MonadError::InvalidEntropy(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_members_rejected() {
|
||||||
|
let m = MonadManifest::new("x");
|
||||||
|
assert!(matches!(m.validate(), Err(MonadError::Empty)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cardinality_mismatch_caught() {
|
||||||
|
let mut m = MonadManifest::new("x");
|
||||||
|
m.members.insert(Ulid::new());
|
||||||
|
// No llamamos touch — cardinality queda en 0 con 1 miembro.
|
||||||
|
assert!(matches!(
|
||||||
|
m.validate(),
|
||||||
|
Err(MonadError::CardinalityMismatch { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_roundtrip() {
|
||||||
|
let mut m = MonadManifest::new("test-monad");
|
||||||
|
m.members.insert(Ulid::new());
|
||||||
|
m.members.insert(Ulid::new());
|
||||||
|
m.keywords = vec!["rs".into(), "toml".into()];
|
||||||
|
m.summary = "test summary".into();
|
||||||
|
m.dominant_lens = Lens::Code;
|
||||||
|
m.touch();
|
||||||
|
let s = m.to_json_pretty().unwrap();
|
||||||
|
let m2 = MonadManifest::from_json(&s).unwrap();
|
||||||
|
assert_eq!(m2.label, m.label);
|
||||||
|
assert_eq!(m2.cardinality, 2);
|
||||||
|
assert_eq!(m2.dominant_lens, Lens::Code);
|
||||||
|
assert_eq!(m2.keywords, m.keywords);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "nouser-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "Nouser — explorador de Mónadas: scanner, clustering determinista, DB en memoria."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nouser-card = { path = "../card" }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
ulid = { workspace = true }
|
||||||
|
walkdir = "2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "nouser"
|
||||||
|
path = "src/bin/nouser.rs"
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
//! `nouser` CLI — explorador de Mónadas.
|
||||||
|
//!
|
||||||
|
//! Subcomandos:
|
||||||
|
//!
|
||||||
|
//! - `scan <dir>` recorre `dir` y muestra las Mónadas detectadas.
|
||||||
|
//! - `show <dir> <id?>` scan + detalles de la Mónada con prefijo de ID.
|
||||||
|
//! - `json <dir>` scan + dump JSON con los manifests.
|
||||||
|
//!
|
||||||
|
//! Phase A: in-memory, sin persistencia, sin brahman sidecar. La
|
||||||
|
//! sesión termina y todo se descarta. Phase B agrega persistencia y
|
||||||
|
//! presencia ante el Init.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use nouser_core::{
|
||||||
|
cluster, db,
|
||||||
|
scanner::{self, ScanConfig},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let prog = args.first().cloned().unwrap_or_else(|| "nouser".into());
|
||||||
|
let sub = match args.get(1).map(String::as_str) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
print_usage(&prog);
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rest = &args[2..];
|
||||||
|
|
||||||
|
let result = match sub {
|
||||||
|
"scan" => cmd_scan(rest),
|
||||||
|
"show" => cmd_show(rest),
|
||||||
|
"json" => cmd_json(rest),
|
||||||
|
"--help" | "-h" | "help" => {
|
||||||
|
print_usage(&prog);
|
||||||
|
return ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("nouser: comando desconocido '{other}'");
|
||||||
|
print_usage(&prog);
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("nouser: {e}");
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_usage(prog: &str) {
|
||||||
|
eprintln!("uso: {prog} <comando> [args]");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("comandos:");
|
||||||
|
eprintln!(" scan <dir> recorre un directorio y lista las Mónadas detectadas");
|
||||||
|
eprintln!(" show <dir> <prefix> scan + detalle de la Mónada cuyo ID empieza con <prefix>");
|
||||||
|
eprintln!(" json <dir> scan + dump JSON de todos los manifests");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("env:");
|
||||||
|
eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cmd = Result<(), Box<dyn std::error::Error>>;
|
||||||
|
|
||||||
|
fn min_files() -> usize {
|
||||||
|
std::env::var("NOUSER_MIN_FILES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(cluster::DEFAULT_MIN_FILES_PER_MONAD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_dir(args: &[String]) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||||
|
let dir = args.first().ok_or("falta argumento <dir>")?;
|
||||||
|
Ok(PathBuf::from(dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box<dyn std::error::Error>> {
|
||||||
|
let files = scanner::scan_directory(dir, &ScanConfig::default())?;
|
||||||
|
let n_files = files.len();
|
||||||
|
let monads = cluster::by_directory(&files, min_files());
|
||||||
|
let mut db = db::MonadDb::new();
|
||||||
|
db.ingest_files(files);
|
||||||
|
db.replace_monads(monads);
|
||||||
|
Ok((db, n_files))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_scan(args: &[String]) -> Cmd {
|
||||||
|
let dir = require_dir(args)?;
|
||||||
|
let (db, n_files) = run_scan(&dir)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"scan: {} archivos en {}, {} mónadas (min_files={})",
|
||||||
|
n_files,
|
||||||
|
dir.display(),
|
||||||
|
db.monad_count(),
|
||||||
|
min_files()
|
||||||
|
);
|
||||||
|
if db.monad_count() == 0 {
|
||||||
|
println!(" (ninguna Mónada — bajá NOUSER_MIN_FILES o apuntá a un dir con más archivos)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
for m in db.monads() {
|
||||||
|
let id_short = format!("{}", m.id);
|
||||||
|
let id_short = &id_short[..8];
|
||||||
|
println!(
|
||||||
|
" [{}] {:30} card={} ent={:.2} lens={:?}",
|
||||||
|
id_short, m.label, m.cardinality, m.entropy, m.dominant_lens,
|
||||||
|
);
|
||||||
|
if !m.keywords.is_empty() {
|
||||||
|
println!(" keywords: {}", m.keywords.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_show(args: &[String]) -> Cmd {
|
||||||
|
let dir = require_dir(args)?;
|
||||||
|
let prefix = args.get(1).ok_or("falta argumento <prefix>")?;
|
||||||
|
let (db, _) = run_scan(&dir)?;
|
||||||
|
|
||||||
|
let m = db
|
||||||
|
.monads()
|
||||||
|
.find(|m| m.id.to_string().starts_with(prefix))
|
||||||
|
.ok_or_else(|| format!("ninguna Mónada con prefijo '{prefix}'"))?;
|
||||||
|
|
||||||
|
println!("Monad {}", m.id);
|
||||||
|
println!(" label: {}", m.label);
|
||||||
|
println!(" summary: {}", m.summary);
|
||||||
|
println!(" cardinality: {}", m.cardinality);
|
||||||
|
println!(" entropy: {:.4}", m.entropy);
|
||||||
|
println!(" lens: {:?}", m.dominant_lens);
|
||||||
|
println!(" keywords: {}", m.keywords.join(", "));
|
||||||
|
println!(" members ({}):", m.members.len());
|
||||||
|
for f in db.resolve_members(m.id) {
|
||||||
|
println!(
|
||||||
|
" {:>10} bytes {}",
|
||||||
|
f.size,
|
||||||
|
f.path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_json(args: &[String]) -> Cmd {
|
||||||
|
let dir = require_dir(args)?;
|
||||||
|
let (db, _) = run_scan(&dir)?;
|
||||||
|
let manifests: Vec<_> = db.monads().cloned().collect();
|
||||||
|
println!("{}", serde_json::to_string_pretty(&manifests)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! DB en memoria de Mónadas y archivos.
|
||||||
|
//!
|
||||||
|
//! Phase A: store volátil con índices `BTreeMap<Id, Manifest>` para
|
||||||
|
//! ambos lados. Phase B traerá persistencia (sled o sqlite) y un
|
||||||
|
//! índice `file_id → Vec<monad_id>` (membership).
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use nouser_card::{FileEntry, FileId, MonadId, MonadManifest};
|
||||||
|
|
||||||
|
/// Store de Mónadas + archivos. Operaciones lock-free (mut & por
|
||||||
|
/// usuario externo). Para uso multi-thread, envolvé en `Mutex/RwLock`.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct MonadDb {
|
||||||
|
files: BTreeMap<FileId, FileEntry>,
|
||||||
|
monads: BTreeMap<MonadId, MonadManifest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MonadDb {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Files ----
|
||||||
|
|
||||||
|
pub fn insert_file(&mut self, file: FileEntry) -> Option<FileEntry> {
|
||||||
|
self.files.insert(file.id, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ingest_files(&mut self, files: Vec<FileEntry>) {
|
||||||
|
for f in files {
|
||||||
|
self.files.insert(f.id, f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file(&self, id: FileId) -> Option<&FileEntry> {
|
||||||
|
self.files.get(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn files(&self) -> impl Iterator<Item = &FileEntry> + '_ {
|
||||||
|
self.files.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_count(&self) -> usize {
|
||||||
|
self.files.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Monads ----
|
||||||
|
|
||||||
|
pub fn insert_monad(&mut self, monad: MonadManifest) -> Option<MonadManifest> {
|
||||||
|
self.monads.insert(monad.id, monad)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_monads(&mut self, monads: Vec<MonadManifest>) {
|
||||||
|
self.monads.clear();
|
||||||
|
for m in monads {
|
||||||
|
self.monads.insert(m.id, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn monad(&self, id: MonadId) -> Option<&MonadManifest> {
|
||||||
|
self.monads.get(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn monads(&self) -> impl Iterator<Item = &MonadManifest> + '_ {
|
||||||
|
self.monads.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn monad_count(&self) -> usize {
|
||||||
|
self.monads.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resuelve los archivos miembros de una Mónada como referencias.
|
||||||
|
/// Skipea silenciosamente IDs que ya no estén en la tabla `files`.
|
||||||
|
pub fn resolve_members(&self, monad_id: MonadId) -> Vec<&FileEntry> {
|
||||||
|
match self.monads.get(&monad_id) {
|
||||||
|
Some(m) => m.members.iter().filter_map(|id| self.files.get(id)).collect(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use nouser_card::Lens;
|
||||||
|
use ulid::Ulid;
|
||||||
|
|
||||||
|
fn mk_file(path: &str) -> FileEntry {
|
||||||
|
FileEntry {
|
||||||
|
id: FileId::from(Ulid::new()),
|
||||||
|
path: std::path::PathBuf::from(path),
|
||||||
|
content_hash: None,
|
||||||
|
size: 100,
|
||||||
|
mtime_ms: 0,
|
||||||
|
extension: Some("rs".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ingest_and_lookup() {
|
||||||
|
let mut db = MonadDb::new();
|
||||||
|
let f1 = mk_file("/a/x.rs");
|
||||||
|
let f2 = mk_file("/a/y.rs");
|
||||||
|
let id1 = f1.id;
|
||||||
|
db.ingest_files(vec![f1, f2]);
|
||||||
|
assert_eq!(db.file_count(), 2);
|
||||||
|
assert!(db.file(id1).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_members_filters_missing() {
|
||||||
|
let mut db = MonadDb::new();
|
||||||
|
let f1 = mk_file("/x/a.rs");
|
||||||
|
let id1 = f1.id;
|
||||||
|
db.insert_file(f1);
|
||||||
|
|
||||||
|
let mut m = MonadManifest::new("test");
|
||||||
|
m.members.insert(id1);
|
||||||
|
m.members.insert(FileId::from(Ulid::new())); // miembro fantasma
|
||||||
|
m.dominant_lens = Lens::Code;
|
||||||
|
m.touch();
|
||||||
|
|
||||||
|
let mid = m.id;
|
||||||
|
db.insert_monad(m);
|
||||||
|
|
||||||
|
let resolved = db.resolve_members(mid);
|
||||||
|
// Sólo el archivo realmente presente en files.
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert_eq!(resolved[0].id, id1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_monads_clears_old() {
|
||||||
|
let mut db = MonadDb::new();
|
||||||
|
let mut m1 = MonadManifest::new("a");
|
||||||
|
m1.members.insert(FileId::from(Ulid::new()));
|
||||||
|
m1.touch();
|
||||||
|
db.insert_monad(m1);
|
||||||
|
assert_eq!(db.monad_count(), 1);
|
||||||
|
|
||||||
|
let mut m2 = MonadManifest::new("b");
|
||||||
|
m2.members.insert(FileId::from(Ulid::new()));
|
||||||
|
m2.touch();
|
||||||
|
db.replace_monads(vec![m2]);
|
||||||
|
assert_eq!(db.monad_count(), 1);
|
||||||
|
assert!(db.monads().next().unwrap().label == "b");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//! `nouser-core` — el explorador de Mónadas.
|
||||||
|
//!
|
||||||
|
//! Implementa la pipeline determinista descrita en el diseño de Kairos:
|
||||||
|
//!
|
||||||
|
//! 1. [`scanner`]: recorre directorios y emite [`FileEntry`] (sin tocar
|
||||||
|
//! contenido en Phase 0 — sólo metadatos).
|
||||||
|
//! 2. [`cluster`]: agrupa archivos en [`MonadManifest`] usando
|
||||||
|
//! heurísticas (parent dir + extensión dominante). 0 LLM.
|
||||||
|
//! 3. [`db`]: store en memoria con índices files↔monads.
|
||||||
|
//!
|
||||||
|
//! Pipeline:
|
||||||
|
//! ```text
|
||||||
|
//! scan_directory(path)
|
||||||
|
//! → Vec<FileEntry>
|
||||||
|
//! → cluster::by_directory(min_files=N)
|
||||||
|
//! → Vec<MonadManifest>
|
||||||
|
//! → MonadDb::ingest(...)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Lo importante: en este crate no hay IA, no hay embeddings. Es la
|
||||||
|
//! capa determinista que cubre el 90% de los casos. Los embeddings
|
||||||
|
//! (`Phase C`) y Nous (`Phase D`) se enchufan después como módulos
|
||||||
|
//! separados que producen flows brahman.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(rust_2018_idioms)]
|
||||||
|
|
||||||
|
pub mod cluster;
|
||||||
|
pub mod db;
|
||||||
|
pub mod scanner;
|
||||||
|
|
||||||
|
pub use nouser_card::*;
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! Recorrido de directorios. Sólo metadatos — no lee contenido.
|
||||||
|
//!
|
||||||
|
//! Usa `walkdir` (sequential). Para árboles muy grandes considerar
|
||||||
|
//! migrar a `jwalk` (paralelo); por ahora la simplicidad gana.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
|
use nouser_card::{FileEntry, FileId};
|
||||||
|
use thiserror::Error;
|
||||||
|
use ulid::Ulid;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ScanError {
|
||||||
|
#[error("ruta no existe: {0}")]
|
||||||
|
NotFound(PathBuf),
|
||||||
|
#[error("no se pudo leer: {0}")]
|
||||||
|
Walk(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuración del scan.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScanConfig {
|
||||||
|
/// Profundidad máxima (None = ilimitada).
|
||||||
|
pub max_depth: Option<usize>,
|
||||||
|
/// Sigue symlinks (default: false, evita ciclos).
|
||||||
|
pub follow_links: bool,
|
||||||
|
/// Ignora archivos ocultos (.dotfiles).
|
||||||
|
pub skip_hidden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScanConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_depth: None,
|
||||||
|
follow_links: false,
|
||||||
|
skip_hidden: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recorre `root` y devuelve un `FileEntry` por cada archivo regular.
|
||||||
|
/// Errores de permisos en sub-paths se ignoran silenciosamente.
|
||||||
|
pub fn scan_directory(root: &Path, config: &ScanConfig) -> Result<Vec<FileEntry>, ScanError> {
|
||||||
|
if !root.exists() {
|
||||||
|
return Err(ScanError::NotFound(root.to_path_buf()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut walker = WalkDir::new(root).follow_links(config.follow_links);
|
||||||
|
if let Some(d) = config.max_depth {
|
||||||
|
walker = walker.max_depth(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry_result in walker {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !entry.file_type().is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if config.skip_hidden && is_hidden(entry.path()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metadata = match entry.metadata() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let mtime_ms = metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let extension = entry
|
||||||
|
.path()
|
||||||
|
.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|s| s.to_lowercase());
|
||||||
|
|
||||||
|
entries.push(FileEntry {
|
||||||
|
id: FileId::from(Ulid::new()),
|
||||||
|
path: entry.path().to_path_buf(),
|
||||||
|
content_hash: None,
|
||||||
|
size: metadata.len(),
|
||||||
|
mtime_ms,
|
||||||
|
extension,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` si alguno de los componentes del path empieza con `.`.
|
||||||
|
/// Excluye el primer componente (root) para no descartar el directorio raíz
|
||||||
|
/// si el usuario apuntó a un dotfile-dir explícito.
|
||||||
|
fn is_hidden(path: &Path) -> bool {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(|n| n.starts_with('.'))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fn write(path: &Path, content: &str) {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).unwrap();
|
||||||
|
}
|
||||||
|
fs::write(path, content).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scans_basic_tree() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
write(&root.join("a.rs"), "fn main(){}");
|
||||||
|
write(&root.join("b.rs"), "fn b(){}");
|
||||||
|
write(&root.join("data/x.json"), "{}");
|
||||||
|
write(&root.join("data/y.json"), "{}");
|
||||||
|
|
||||||
|
let files = scan_directory(root, &ScanConfig::default()).unwrap();
|
||||||
|
assert_eq!(files.len(), 4);
|
||||||
|
let exts: std::collections::BTreeSet<_> = files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| f.extension.clone())
|
||||||
|
.collect();
|
||||||
|
assert!(exts.contains("rs"));
|
||||||
|
assert!(exts.contains("json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_hidden_by_default() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
write(&root.join("visible.txt"), "x");
|
||||||
|
write(&root.join(".hidden"), "x");
|
||||||
|
|
||||||
|
let files = scan_directory(root, &ScanConfig::default()).unwrap();
|
||||||
|
assert_eq!(files.len(), 1);
|
||||||
|
assert!(files[0].path.ends_with("visible.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_root_errors() {
|
||||||
|
let p = std::path::Path::new("/nonexistent-12345-abc");
|
||||||
|
assert!(matches!(
|
||||||
|
scan_directory(p, &ScanConfig::default()),
|
||||||
|
Err(ScanError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn max_depth_limits() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
write(&root.join("top.txt"), "x");
|
||||||
|
write(&root.join("a/b/deep.txt"), "x");
|
||||||
|
|
||||||
|
let cfg = ScanConfig {
|
||||||
|
max_depth: Some(1),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let files = scan_directory(root, &cfg).unwrap();
|
||||||
|
// max_depth=1 incluye archivos en root pero no anidados profundos.
|
||||||
|
let names: Vec<_> = files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| f.path.file_name().and_then(|s| s.to_str()))
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
assert!(names.contains(&"top.txt".to_string()));
|
||||||
|
assert!(!names.contains(&"deep.txt".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user