refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG
Reorganización física de crates/: - core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/ - shared/ (3 crates) se redistribuye en protocol/ e init/ - lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/ Renames de proyectos: - shipote → shuma (runtime de sandboxes) - nouser → akasha (explorador de Mónadas) - yahweh → nahual (motor GPUI, antes ui_engine/) - lapaloma → pineal (data-viz agnóstica) Fraccionamiento UI → core agnóstico: - vista-core (DeckState + snap, 175 LOC, 5 tests verdes) - barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes) - vista-web y barra-web ahora son thin DOM bindings Documentación nueva: - 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat + 10 módulos + apps/ - docs/STATUS.md con cifras reales por proyecto - docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas) - CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets) Automatización: - scripts/reorg.py — script idempotente que: git mv directorios, renombra package names, recomputa path = refs, reescribe imports rust, actualiza workspace Cargo.toml. Soporta --dry-run. - scripts/split-changelog.py — particiona CHANGELOG por componente. Validación: - cargo check --workspace pasa (124 crates + 2 nuevos cores). - 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "akasha-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]
|
||||
akasha-card = { path = "../card" }
|
||||
akasha-nous = { path = "../nous" }
|
||||
shuma-discern = { path = "../../shuma/shuma-discern" }
|
||||
brahman-card = { path = "../../../protocol/brahman-card" }
|
||||
brahman-handshake = { path = "../../../protocol/brahman-handshake" }
|
||||
brahman-sidecar = { path = "../../../protocol/brahman-sidecar" }
|
||||
blake3 = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sled = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
walkdir = "2"
|
||||
notify = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "akasha"
|
||||
path = "src/bin/nouser.rs"
|
||||
@@ -0,0 +1,795 @@
|
||||
//! `akasha` 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 akasha_core::{
|
||||
cluster, db, embed,
|
||||
scanner::{self, ScanConfig},
|
||||
};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let prog = args.first().cloned().unwrap_or_else(|| "akasha".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),
|
||||
"daemon" => cmd_daemon(rest),
|
||||
"attract" => cmd_attract(rest),
|
||||
"--help" | "-h" | "help" => {
|
||||
print_usage(&prog);
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
other => {
|
||||
eprintln!("akasha: comando desconocido '{other}'");
|
||||
print_usage(&prog);
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("akasha: {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!(" daemon <dir> scan + sidecarea cada Mónada al Init brahman");
|
||||
eprintln!(" attract <dir> <file> dado un archivo, qué Mónada del scan lo atrae más");
|
||||
eprintln!();
|
||||
eprintln!("env:");
|
||||
eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
|
||||
eprintln!(" NOUSER_DB_PATH si está set, abre sled en esa ruta (persistencia)");
|
||||
eprintln!(" BRAHMAN_INIT_SOCKET socket del Init (heredado de brahman-handshake)");
|
||||
}
|
||||
|
||||
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 = open_db()?;
|
||||
db.ingest_files(files);
|
||||
db.replace_monads(monads);
|
||||
Ok((db, n_files))
|
||||
}
|
||||
|
||||
/// Abre el `MonadDb`. Si `NOUSER_DB_PATH` está set, persistencia sled;
|
||||
/// si no, store en memoria.
|
||||
fn open_db() -> Result<db::MonadDb, Box<dyn std::error::Error>> {
|
||||
if let Ok(path) = std::env::var("NOUSER_DB_PATH") {
|
||||
Ok(db::MonadDb::open(&path)?)
|
||||
} else {
|
||||
Ok(db::MonadDb::new())
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn cmd_daemon(args: &[String]) -> Cmd {
|
||||
let dir = require_dir(args)?;
|
||||
|
||||
let pool = std::sync::Arc::new(
|
||||
brahman_sidecar::SidecarPool::new().map_err(|e| format!("crear pool: {e}"))?,
|
||||
);
|
||||
|
||||
// 1. Decidir el path del query socket ANTES de armar el engine
|
||||
// Card (porque viaja como service_socket en la Card).
|
||||
let query_socket = akasha_card::query::transport::default_socket_path();
|
||||
|
||||
// 2. Engine como Ente. Declara service_socket + flow.output para
|
||||
// que el broker pueda emitir MatchEvent::Available a consumers
|
||||
// interesados en `flow.input = monad-list:json`.
|
||||
let engine_card = build_engine_card(query_socket.clone());
|
||||
let engine_id = engine_card.id;
|
||||
let engine_label = engine_card.label.clone();
|
||||
eprintln!(
|
||||
"akasha daemon: publicando engine '{}' (kind=Ente, id={}, socket={})",
|
||||
engine_label,
|
||||
engine_id,
|
||||
query_socket.display()
|
||||
);
|
||||
pool.spawn(engine_card);
|
||||
|
||||
// 2. Hidratación: si NOUSER_DB_PATH apunta a un sled poblado,
|
||||
// publicar lo que ya tenemos ANTES del re-scan. brahman-status
|
||||
// ve mónadas reales en milisegundos, no en segundos.
|
||||
let mut db = open_db()?;
|
||||
let prior_count = db.monad_count();
|
||||
if prior_count > 0 {
|
||||
let mut hydrated = 0usize;
|
||||
let mut skipped_model = 0usize;
|
||||
for monad in db.monads() {
|
||||
// Sólo publicamos centroides del modelo actual; los demás
|
||||
// son data muerta hasta que el re-scan los reemplace.
|
||||
let valid = monad
|
||||
.centroid_model
|
||||
.as_deref()
|
||||
.map(|id| id == embed::MODEL_ID)
|
||||
.unwrap_or(false);
|
||||
if !valid {
|
||||
skipped_model += 1;
|
||||
continue;
|
||||
}
|
||||
let mut card = monad.to_brahman_card();
|
||||
card.references.push(brahman_card::CardReference {
|
||||
kind: brahman_card::RelationshipKind::OwnedBy,
|
||||
target_id: engine_id,
|
||||
target_label: engine_label.clone(),
|
||||
});
|
||||
pool.spawn(card);
|
||||
hydrated += 1;
|
||||
}
|
||||
eprintln!(
|
||||
"akasha daemon: hidratadas {} mónadas previas{} en O(1)",
|
||||
hydrated,
|
||||
if skipped_model > 0 {
|
||||
format!(" ({} dropeadas por centroid_model distinto)", skipped_model)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Re-scan con hidratación: las Mónadas con mismo path_hint
|
||||
// reusan id, así que NO generamos sesiones duplicadas para los
|
||||
// mismos directorios — el sidecar previo ya tiene esa identidad.
|
||||
let files = scanner::scan_directory(&dir, &scanner::ScanConfig::default())?;
|
||||
let n_files = files.len();
|
||||
let monads = cluster::by_directory_hydrated(&files, min_files(), Some(&db));
|
||||
let scanned_count = monads.len();
|
||||
eprintln!(
|
||||
"akasha daemon: re-scan {} archivos en {} → {} mónadas",
|
||||
n_files,
|
||||
dir.display(),
|
||||
scanned_count
|
||||
);
|
||||
|
||||
// Publicamos sólo las Mónadas NUEVAS (las que no estaban en la
|
||||
// hidratación inicial). El criterio: si el id estaba en la DB
|
||||
// previa, el sidecar de la hidratación ya cubre esa identidad.
|
||||
let prior_ids: std::collections::BTreeSet<_> = db.monads().map(|m| m.id).collect();
|
||||
let mut newly_spawned = 0usize;
|
||||
for monad in &monads {
|
||||
if prior_ids.contains(&monad.id) {
|
||||
continue; // ya publicada en hidratación
|
||||
}
|
||||
let mut card = monad.to_brahman_card();
|
||||
card.references.push(brahman_card::CardReference {
|
||||
kind: brahman_card::RelationshipKind::OwnedBy,
|
||||
target_id: engine_id,
|
||||
target_label: engine_label.clone(),
|
||||
});
|
||||
pool.spawn(card);
|
||||
newly_spawned += 1;
|
||||
}
|
||||
|
||||
// Reescribimos la DB con el set actual (idempotente para los
|
||||
// hidratados; reemplazo para los nuevos).
|
||||
db.ingest_files(files);
|
||||
db.replace_monads(monads);
|
||||
|
||||
eprintln!(
|
||||
"akasha daemon: 1 ente + {} mónadas vivas ({} nuevas vs hidratación)",
|
||||
scanned_count, newly_spawned
|
||||
);
|
||||
|
||||
// Engine query socket: bind antes del watcher para que cualquier
|
||||
// consumer descubierto vía broker pueda consultarnos enseguida.
|
||||
// Si el bind falla, seguimos sin él — la UI degrada a "no
|
||||
// alcanzable" pero el daemon sigue procesando cambios.
|
||||
let db_shared = std::sync::Arc::new(std::sync::Mutex::new(db));
|
||||
let _query_listener = match akasha_core::engine_socket::spawn_listener(
|
||||
akasha_core::engine_socket::ListenerConfig {
|
||||
socket_path: query_socket.clone(),
|
||||
engine_id,
|
||||
engine_label: engine_label.clone(),
|
||||
watching: Some(dir.clone()),
|
||||
},
|
||||
db_shared.clone(),
|
||||
) {
|
||||
Ok(h) => {
|
||||
eprintln!(
|
||||
"akasha daemon: query socket activo en {} (proto: akasha_card::query)",
|
||||
query_socket.display()
|
||||
);
|
||||
Some(h)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"akasha daemon: query socket NO disponible ({e}) — explorer no podrá consultar"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Watcher: cada cambio en el árbol — coalescido con debounce de
|
||||
// 150ms — dispara un re-scan + re-cluster del directorio y
|
||||
// re-publica al broker las Mónadas afectadas (drop + spawn por id,
|
||||
// gracias al replace en `SidecarPool::spawn`).
|
||||
let _watcher = match spawn_fs_watcher(
|
||||
dir.clone(),
|
||||
db_shared.clone(),
|
||||
pool.clone(),
|
||||
engine_id,
|
||||
engine_label.clone(),
|
||||
) {
|
||||
Ok(w) => {
|
||||
eprintln!(
|
||||
"akasha daemon: watcher activo en {} (debounce 150ms, re-publish on) — Ctrl-C para terminar.",
|
||||
dir.display()
|
||||
);
|
||||
Some(w)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"akasha daemon: watcher deshabilitado ({e}) — Ctrl-C para terminar."
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
std::thread::park();
|
||||
drop(_watcher);
|
||||
drop(_query_listener);
|
||||
let _ = std::fs::remove_file(&query_socket); // best-effort cleanup
|
||||
drop(pool);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ventana de debounce: notify dispara Create+Modify(+) por cada
|
||||
/// edición; sin coalescer veríamos N reacciones por un solo `:w`.
|
||||
/// 150ms es generoso para editores típicos (vim/code) y mantiene el
|
||||
/// feedback "vivo" para el usuario.
|
||||
const WATCHER_DEBOUNCE_MS: u64 = 150;
|
||||
|
||||
/// Watcher de filesystem con debounce + re-publish al broker.
|
||||
///
|
||||
/// Pipeline:
|
||||
///
|
||||
/// 1. **notify** dispara eventos crudos a un canal interno.
|
||||
/// 2. **dispatcher**: filtra a Create/Modify/Remove de paths bajo
|
||||
/// `dir`, descarta el resto, reenvía al canal de debounce.
|
||||
/// 3. **coordinator**: mantiene un `HashMap<PathBuf, Instant>`.
|
||||
/// Cada vez que el canal queda en silencio durante
|
||||
/// `WATCHER_DEBOUNCE_MS`, agrupa los paths cuya última actividad
|
||||
/// superó la ventana y los procesa en **un solo batch**.
|
||||
/// 4. **process_change_batch**: re-scan + re-cluster hidratado +
|
||||
/// diff vs DB + `pool.drop_session` para Mónadas desaparecidas
|
||||
/// + `pool.spawn` para Mónadas nuevas o con composición distinta.
|
||||
/// `pool.spawn` reemplaza la sesión previa con el mismo `Card.id`,
|
||||
/// así que el broker ve el manifest fresco sin sesiones huérfanas.
|
||||
fn spawn_fs_watcher(
|
||||
dir: std::path::PathBuf,
|
||||
db: std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
|
||||
pool: std::sync::Arc<brahman_sidecar::SidecarPool>,
|
||||
engine_id: brahman_card::ulid::Ulid,
|
||||
engine_label: String,
|
||||
) -> Result<notify::RecommendedWatcher, Box<dyn std::error::Error>> {
|
||||
use notify::{Event, EventKind, RecursiveMode, Watcher};
|
||||
|
||||
let (notify_tx, notify_rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
|
||||
let mut watcher = notify::recommended_watcher(move |res| {
|
||||
let _ = notify_tx.send(res);
|
||||
})?;
|
||||
watcher.watch(&dir, RecursiveMode::Recursive)?;
|
||||
|
||||
let (path_tx, path_rx) = std::sync::mpsc::channel::<std::path::PathBuf>();
|
||||
|
||||
// Dispatcher: notify → filtro → canal de paths.
|
||||
let dispatch_dir = dir.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("akasha-watcher-dispatch".into())
|
||||
.spawn(move || {
|
||||
for res in notify_rx {
|
||||
let event = match res {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("[watcher] error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Create/Modify viven; Remove también nos importa
|
||||
// (puede colapsar Mónadas).
|
||||
let interesting = matches!(
|
||||
event.kind,
|
||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
||||
);
|
||||
if !interesting {
|
||||
continue;
|
||||
}
|
||||
for path in event.paths {
|
||||
if !path.starts_with(&dispatch_dir) {
|
||||
continue;
|
||||
}
|
||||
let _ = path_tx.send(path);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
// Coordinator: debounce + batch dispatch.
|
||||
let coord_dir = dir;
|
||||
std::thread::Builder::new()
|
||||
.name("akasha-watcher-coord".into())
|
||||
.spawn(move || {
|
||||
let debounce = std::time::Duration::from_millis(WATCHER_DEBOUNCE_MS);
|
||||
let mut pending: std::collections::HashMap<
|
||||
std::path::PathBuf,
|
||||
std::time::Instant,
|
||||
> = std::collections::HashMap::new();
|
||||
loop {
|
||||
match path_rx.recv_timeout(debounce) {
|
||||
Ok(path) => {
|
||||
pending.insert(path, std::time::Instant::now());
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
let due: Vec<std::path::PathBuf> = pending
|
||||
.iter()
|
||||
.filter(|(_, t)| now.duration_since(**t) >= debounce)
|
||||
.map(|(p, _)| p.clone())
|
||||
.collect();
|
||||
if due.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for p in &due {
|
||||
pending.remove(p);
|
||||
}
|
||||
process_change_batch(
|
||||
&due,
|
||||
&coord_dir,
|
||||
&db,
|
||||
&pool,
|
||||
engine_id,
|
||||
&engine_label,
|
||||
);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
/// Procesa un batch de paths cambiados: re-scanea el árbol, re-clusteriza
|
||||
/// con hidratación, y propaga el delta de Mónadas al broker.
|
||||
///
|
||||
/// El re-scan global es deliberado: el clustering por directorio es global
|
||||
/// por diseño, así que un cambio en `src/foo.rs` puede mover Mónadas en
|
||||
/// `src/` sin tocar `tests/`. Coste O(N archivos), aceptable para
|
||||
/// directorios típicos (<10k archivos). Optimizar a re-cluster parcial
|
||||
/// cuando duela.
|
||||
fn process_change_batch(
|
||||
paths: &[std::path::PathBuf],
|
||||
dir: &std::path::Path,
|
||||
db: &std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
|
||||
pool: &std::sync::Arc<brahman_sidecar::SidecarPool>,
|
||||
engine_id: brahman_card::ulid::Ulid,
|
||||
engine_label: &str,
|
||||
) {
|
||||
eprintln!(
|
||||
"[watcher] ⚙ batch: {} path(s) coalescidos → re-scan",
|
||||
paths.len()
|
||||
);
|
||||
|
||||
let files = match scanner::scan_directory(dir, &scanner::ScanConfig::default()) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[watcher] re-scan falló: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut db_lock = match db.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
eprintln!("[watcher] mutex envenenado — abortando batch");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let prior_monads: Vec<akasha_card::MonadManifest> = db_lock.monads().cloned().collect();
|
||||
let prior_ref: &db::MonadDb = &db_lock;
|
||||
let monads = cluster::by_directory_hydrated(&files, min_files(), Some(prior_ref));
|
||||
|
||||
let prior_ids: std::collections::BTreeSet<_> =
|
||||
prior_monads.iter().map(|m| m.id).collect();
|
||||
let new_ids: std::collections::BTreeSet<_> = monads.iter().map(|m| m.id).collect();
|
||||
|
||||
// Mónadas que ya no existen (directorio quedó por debajo de
|
||||
// min_files o fue removido): cerramos su sesión en el broker.
|
||||
let mut removed = 0usize;
|
||||
for id in prior_ids.difference(&new_ids) {
|
||||
pool.drop_session(*id);
|
||||
removed += 1;
|
||||
if let Some(prev) = prior_monads.iter().find(|m| &m.id == id) {
|
||||
eprintln!(
|
||||
"[watcher] ✖ {} ({}) desapareció — sesión cerrada",
|
||||
&id.to_string()[..8],
|
||||
prev.label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mónadas nuevas o cuya composición cambió (members/centroid):
|
||||
// (re)spawn — el pool reemplaza la sesión previa con el mismo id.
|
||||
let mut respawned = 0usize;
|
||||
let mut fresh = 0usize;
|
||||
for monad in &monads {
|
||||
let prev = prior_monads.iter().find(|m| m.id == monad.id);
|
||||
let is_new = prev.is_none();
|
||||
let changed = match prev {
|
||||
Some(p) => p.members != monad.members || p.centroid != monad.centroid,
|
||||
None => true,
|
||||
};
|
||||
if !changed {
|
||||
continue;
|
||||
}
|
||||
let mut card = monad.to_brahman_card();
|
||||
card.references.push(brahman_card::CardReference {
|
||||
kind: brahman_card::RelationshipKind::OwnedBy,
|
||||
target_id: engine_id,
|
||||
target_label: engine_label.to_string(),
|
||||
});
|
||||
pool.spawn(card);
|
||||
if is_new {
|
||||
fresh += 1;
|
||||
eprintln!(
|
||||
"[watcher] ✦ {} nace ({} miembros, lens={:?})",
|
||||
monad.label, monad.cardinality, monad.dominant_lens
|
||||
);
|
||||
} else {
|
||||
respawned += 1;
|
||||
let prev = prev.unwrap();
|
||||
let delta_members = monad.members.len() as i64 - prev.members.len() as i64;
|
||||
eprintln!(
|
||||
"[watcher] ↻ {} refresh ({} miembros, Δ={:+})",
|
||||
monad.label, monad.cardinality, delta_members
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if removed == 0 && fresh == 0 && respawned == 0 {
|
||||
eprintln!("[watcher] (sin cambios estructurales tras re-cluster)");
|
||||
} else {
|
||||
eprintln!(
|
||||
"[watcher] ⌃ delta: {} nuevas, {} refrescadas, {} cerradas — {} sesiones vivas",
|
||||
fresh,
|
||||
respawned,
|
||||
removed,
|
||||
pool.live_sessions()
|
||||
);
|
||||
}
|
||||
|
||||
db_lock.ingest_files(files);
|
||||
db_lock.replace_monads(monads);
|
||||
}
|
||||
|
||||
fn cmd_attract(args: &[String]) -> Cmd {
|
||||
let mut remote = false;
|
||||
let mut positional: Vec<&String> = Vec::new();
|
||||
for a in args {
|
||||
if a == "--remote" {
|
||||
remote = true;
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
let dir = positional
|
||||
.first()
|
||||
.map(|s| std::path::PathBuf::from(s.as_str()))
|
||||
.ok_or("falta argumento <dir>")?;
|
||||
let file_path = positional.get(1).ok_or("falta argumento <file>")?;
|
||||
let file_path = std::path::PathBuf::from(file_path.as_str());
|
||||
if !file_path.exists() {
|
||||
return Err(format!("archivo no existe: {}", file_path.display()).into());
|
||||
}
|
||||
|
||||
let (db, _) = run_scan(&dir)?;
|
||||
|
||||
// Construimos un FileEntry para el archivo objetivo.
|
||||
let metadata = std::fs::metadata(&file_path)?;
|
||||
let mtime_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let target = akasha_card::FileEntry {
|
||||
id: akasha_card::FileId::from(akasha_card::ulid::Ulid::new()),
|
||||
path: file_path.clone(),
|
||||
content_hash: None,
|
||||
size: metadata.len(),
|
||||
mtime_ms,
|
||||
extension: file_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_lowercase()),
|
||||
};
|
||||
|
||||
// Embedding del target + identificación del modelo que lo produjo.
|
||||
// Local: pseudo-32d. Remote: lo que devuelva el provider electo
|
||||
// (mock=pseudo-32d, real=fastembed-384d).
|
||||
let (target_vec, target_model, source) = if remote {
|
||||
let (v, model) = remote_embed(&target)?;
|
||||
(v, model, "remote")
|
||||
} else {
|
||||
(
|
||||
embed::embed(&target).to_vec(),
|
||||
embed::MODEL_ID.to_string(),
|
||||
"local",
|
||||
)
|
||||
};
|
||||
|
||||
// Filtramos Mónadas cuyo centroid_model NO matchee. Mezclar
|
||||
// 32-d con 384-d daría scores sin sentido (diferente semántica
|
||||
// y cosine no compara cross-modelo).
|
||||
let mut ranked: Vec<(&akasha_card::MonadManifest, f32)> = db
|
||||
.monads()
|
||||
.filter(|m| !m.centroid.is_empty())
|
||||
.filter(|m| match &m.centroid_model {
|
||||
Some(id) => id == &target_model,
|
||||
None => true, // legacy sin tag — comparamos best-effort
|
||||
})
|
||||
.map(|m| (m, embed::attraction_score(&target_vec, m)))
|
||||
.collect();
|
||||
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let total_monads = db.monads().filter(|m| !m.centroid.is_empty()).count();
|
||||
let skipped = total_monads - ranked.len();
|
||||
|
||||
if ranked.is_empty() {
|
||||
println!("ninguna Mónada con centroide en {}", dir.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("archivo: {}", file_path.display());
|
||||
println!("scan dir: {}", dir.display());
|
||||
println!("embed: {} ({})", source, target_model);
|
||||
if skipped > 0 {
|
||||
println!(
|
||||
"skipped: {} mónada(s) con centroid_model distinto (no comparables)",
|
||||
skipped
|
||||
);
|
||||
}
|
||||
println!("ranking de atracción (cosine similarity):");
|
||||
println!();
|
||||
for (i, (m, score)) in ranked.iter().take(5).enumerate() {
|
||||
let marker = if *score >= embed::DEFAULT_ATTRACTION_THRESHOLD && i == 0 {
|
||||
"🧲"
|
||||
} else if i == 0 {
|
||||
"·"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let id_short = format!("{}", m.id);
|
||||
let id_short = &id_short[..8];
|
||||
println!(
|
||||
" {} {:.4} [{}] {:30} ({})",
|
||||
marker, score, id_short, m.label, m.summary
|
||||
);
|
||||
}
|
||||
if ranked[0].1 < embed::DEFAULT_ATTRACTION_THRESHOLD {
|
||||
println!();
|
||||
println!(
|
||||
" (mejor score {:.4} < umbral {:.4} — el archivo no se 'pega' a ninguna)",
|
||||
ranked[0].1,
|
||||
embed::DEFAULT_ATTRACTION_THRESHOLD
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pipeline completo del modo `--remote`:
|
||||
/// 1. Si `NOUSER_NOUS_SOCKET` está set, lo usa directo (override
|
||||
/// explícito, atajo para tests).
|
||||
/// 2. Si no, delega en `brahman_sidecar::await_provider_blocking` —
|
||||
/// el sidecar se conecta al broker, registra un consumer Card con
|
||||
/// `flow.input = embed-result:json`, espera el primer
|
||||
/// `MatchEvent::Available` y devuelve el socket. Esto activa la
|
||||
/// lógica de `priority_contexts`: bajo `BRAHMAN_BROKER_CONTEXT=test/prod`,
|
||||
/// el proveedor electo cambia sin que este código toque nada.
|
||||
/// 3. Con el socket resuelto, dispara la RPC `EmbedFile`.
|
||||
///
|
||||
/// Devuelve `(embedding, model_id)` — el caller necesita ambos para
|
||||
/// comparar contra centroides taggeados con su mismo `centroid_model`.
|
||||
fn remote_embed(
|
||||
file: &akasha_card::FileEntry,
|
||||
) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
|
||||
if let Ok(explicit) = std::env::var("NOUSER_NOUS_SOCKET") {
|
||||
let sock = std::path::PathBuf::from(explicit);
|
||||
return embed_via(&sock, file);
|
||||
}
|
||||
|
||||
let consumer = brahman_sidecar::build_consumer_card(
|
||||
"akasha.attract-cli",
|
||||
akasha_nous::FLOW_EMBED_RESULT,
|
||||
akasha_nous::FLOW_TYPE_NAME,
|
||||
);
|
||||
let producer_sock = brahman_sidecar::await_provider_blocking(
|
||||
consumer,
|
||||
std::time::Duration::from_secs(3),
|
||||
)?;
|
||||
embed_via(&producer_sock, file)
|
||||
}
|
||||
|
||||
/// RPC blocking contra un socket akasha-nous concreto. Devuelve
|
||||
/// `(embedding, model_id)` — el `model_id` viaja en la response y
|
||||
/// permite al caller saber qué centroides son comparables.
|
||||
fn embed_via(
|
||||
sock_path: &std::path::Path,
|
||||
file: &akasha_card::FileEntry,
|
||||
) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
if !sock_path.exists() {
|
||||
return Err(format!("socket no existe: {}", sock_path.display()).into());
|
||||
}
|
||||
|
||||
let mut stream = UnixStream::connect(sock_path)?;
|
||||
let req = akasha_nous::EmbedRequest {
|
||||
kind: akasha_nous::RequestKind::EmbedFile,
|
||||
payload: serde_json::to_value(akasha_nous::EmbedFilePayload {
|
||||
path: file.path.display().to_string(),
|
||||
extension: file.extension.clone(),
|
||||
size: file.size,
|
||||
mtime_ms: file.mtime_ms,
|
||||
})?,
|
||||
};
|
||||
let line = serde_json::to_string(&req)?;
|
||||
stream.write_all(line.as_bytes())?;
|
||||
stream.write_all(b"\n")?;
|
||||
stream.flush()?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response)?;
|
||||
if response.is_empty() {
|
||||
return Err("akasha-nous cerró sin respuesta".into());
|
||||
}
|
||||
|
||||
if let Ok(resp) = serde_json::from_str::<akasha_nous::EmbedResponse>(&response) {
|
||||
return Ok((resp.embedding, resp.model));
|
||||
}
|
||||
let err: akasha_nous::ErrorResponse = serde_json::from_str(&response)?;
|
||||
Err(format!("akasha-nous: {}", err.error).into())
|
||||
}
|
||||
|
||||
/// Card del propio engine (kind=Ente). Es el "ser" que produce y
|
||||
/// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
|
||||
///
|
||||
/// Declara `service_socket` y `flow.output = monad-list:json` para
|
||||
/// que un consumer (UI, CLI) pueda descubrir al daemon vía broker
|
||||
/// MatchEvent y consultarle por sus Mónadas sin pasar por
|
||||
/// brahman-admin.
|
||||
fn build_engine_card(service_socket: std::path::PathBuf) -> brahman_card::Card {
|
||||
use brahman_card::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef};
|
||||
use akasha_card::query::{FLOW_MONAD_LIST, FLOW_TYPE_NAME};
|
||||
|
||||
Card {
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::Delegate,
|
||||
lifecycle: Lifecycle::Daemon,
|
||||
priority: Priority::Normal,
|
||||
kind: CardKind::Ente,
|
||||
service_socket: Some(service_socket),
|
||||
flow: Flows {
|
||||
input: vec![],
|
||||
output: vec![Flow {
|
||||
name: FLOW_MONAD_LIST.into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: FLOW_TYPE_NAME.into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
},
|
||||
..Card::new("brahman.nouser_engine")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
//! 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 akasha_card::{FileEntry, Lens, MonadManifest};
|
||||
|
||||
use crate::embed;
|
||||
|
||||
/// 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> {
|
||||
by_directory_hydrated(files, min_files, None)
|
||||
}
|
||||
|
||||
/// Variante con hidratación: si `prior` está presente, busca Mónadas
|
||||
/// previas con el mismo `path_hint` y `centroid_model` válido, y reusa
|
||||
/// su `id` y `lineage`. Esto preserva identidad across re-scans —
|
||||
/// fundamental para que el daemon pueda republicar tras hidratar de
|
||||
/// sled sin generar duplicados en el broker.
|
||||
pub fn by_directory_hydrated(
|
||||
files: &[FileEntry],
|
||||
min_files: usize,
|
||||
prior: Option<&crate::db::MonadDb>,
|
||||
) -> 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;
|
||||
}
|
||||
let mut m = build_monad(&parent, &group);
|
||||
if let Some(db) = prior {
|
||||
// Reusamos id si encontramos Mónada previa con mismo
|
||||
// path_hint Y mismo centroid_model. Distintas hipótesis
|
||||
// de modelo no comparten identidad — son objetos
|
||||
// semánticos distintos, aunque parecidos.
|
||||
if let Some(existing) = db.monads().find(|prev| {
|
||||
prev.path_hint.as_deref() == m.path_hint.as_deref()
|
||||
&& prev.centroid_model == m.centroid_model
|
||||
}) {
|
||||
m.id = existing.id;
|
||||
m.lineage = existing.lineage;
|
||||
m.created_at_ms = existing.created_at_ms;
|
||||
m.touch();
|
||||
}
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest {
|
||||
let label = label_from_path(parent);
|
||||
|
||||
let keywords = top_extensions(group, 5);
|
||||
let lens = pick_lens(group);
|
||||
let entropy = shannon_entropy_normalized(group);
|
||||
|
||||
let summary = build_summary(parent, group, &keywords);
|
||||
|
||||
// Centroide vectorial: promedio de los embeddings de los miembros.
|
||||
// Esto es lo que permite "atracción" determinista de archivos
|
||||
// nuevos sin tocar Nous.
|
||||
let member_vecs: Vec<Vec<f32>> = group.iter().map(|f| embed::embed(f).to_vec()).collect();
|
||||
let centroid = embed::centroid(&member_vecs);
|
||||
|
||||
let mut m = MonadManifest::new(label);
|
||||
m.summary = summary;
|
||||
m.keywords = keywords;
|
||||
m.dominant_lens = lens;
|
||||
m.entropy = entropy;
|
||||
m.centroid = centroid;
|
||||
// Taggeamos el centroide con su modelo. attract verifica esto
|
||||
// antes de comparar para no mezclar pseudo-32d con real-384d.
|
||||
m.centroid_model = Some(embed::MODEL_ID.to_string());
|
||||
// path_hint = identidad estable across re-scans para
|
||||
// hidratación. Display es lossy con UTF-8 inválido pero los
|
||||
// paths legítimos se imprimen consistentes.
|
||||
m.path_hint = Some(parent.display().to_string());
|
||||
m.members = group.iter().map(|f| f.id).collect();
|
||||
m.touch();
|
||||
m
|
||||
}
|
||||
|
||||
/// Construye un label legible tomando los últimos hasta 2 componentes
|
||||
/// del path. Esto desambigua `src/` repetidos en monorepos: en lugar
|
||||
/// de 5 Mónadas con label "src", quedan "ente-zero/src", "ente-brain/src",
|
||||
/// etc. Para directorios shallow (root o un nivel), cae al
|
||||
/// `file_name()` simple.
|
||||
fn label_from_path(p: &std::path::Path) -> String {
|
||||
let normals: Vec<&str> = p
|
||||
.components()
|
||||
.filter_map(|c| match c {
|
||||
std::path::Component::Normal(s) => s.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if normals.is_empty() {
|
||||
return "unnamed".to_string();
|
||||
}
|
||||
let take = normals.len().min(2);
|
||||
let start = normals.len() - take;
|
||||
normals[start..].join("/")
|
||||
}
|
||||
|
||||
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, con
|
||||
/// fallback a `shuma-discern` sobre el head del archivo más
|
||||
/// representativo cuando la extensión no da hint claro (Lens::Grid).
|
||||
fn pick_lens(files: &[&FileEntry]) -> Lens {
|
||||
let dominant = top_extensions(files, 1).into_iter().next();
|
||||
let by_ext = 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,
|
||||
};
|
||||
if by_ext != Lens::Grid {
|
||||
return by_ext;
|
||||
}
|
||||
// Fallback: samplear el primer archivo del grupo con shuma-discern.
|
||||
// Sólo si tiene path real (FileEntry con path absoluto/relativo).
|
||||
if let Some(first) = files.first() {
|
||||
if let Some(lens) = discern_lens(&first.path) {
|
||||
return lens;
|
||||
}
|
||||
}
|
||||
Lens::Grid
|
||||
}
|
||||
|
||||
fn discern_lens(path: &std::path::Path) -> Option<Lens> {
|
||||
use std::io::Read;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut f = std::fs::File::open(path).ok()?;
|
||||
let n = f.read(&mut buf).ok()?;
|
||||
buf.truncate(n);
|
||||
let pipeline = shuma_discern::DiscernPipeline::default_pipeline();
|
||||
let path_str = path.to_str();
|
||||
let d = pipeline.discern(
|
||||
&buf,
|
||||
&shuma_discern::Hint {
|
||||
path: path_str,
|
||||
size_total: None,
|
||||
},
|
||||
)?;
|
||||
match d.lens.as_deref()? {
|
||||
"code" => Some(Lens::Code),
|
||||
"gallery" => Some(Lens::Gallery),
|
||||
"markdown" => Some(Lens::Markdown),
|
||||
"database" => Some(Lens::Database),
|
||||
"tree" => Some(Lens::Tree),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 akasha_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();
|
||||
// Phase B: labels usan los últimos 2 componentes del path para
|
||||
// desambiguar (proj/src vs proj/docs en lugar de src vs docs).
|
||||
assert!(labels.iter().any(|l| l.as_str() == "proj/src"));
|
||||
assert!(labels.iter().any(|l| l.as_str() == "proj/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, "proj/sub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_from_root_only_one_component() {
|
||||
// Un solo componente normal en el path → no hay "padre" útil.
|
||||
let p = std::path::Path::new("/onlyone");
|
||||
assert_eq!(label_from_path(p), "onlyone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_from_deep_path_takes_last_two() {
|
||||
let p = std::path::Path::new("/a/b/c/d/e");
|
||||
assert_eq!(label_from_path(p), "d/e");
|
||||
}
|
||||
|
||||
#[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,313 @@
|
||||
//! DB de Mónadas y archivos. Backend dual:
|
||||
//!
|
||||
//! - **Memoria** (default, cache): `BTreeMap<Id, T>` para reads O(log n).
|
||||
//! - **Persistencia** (opcional): sled-backed write-through. Si se abre
|
||||
//! con `MonadDb::open(path)`, cada `insert_*` escribe a sled además
|
||||
//! de la cache. Reads siempre vienen de la cache.
|
||||
//!
|
||||
//! Wire format: JSON via serde_json. Los manifestos son chicos y
|
||||
//! ocasionalmente inspeccionables a mano (`sled-cli`); JSON gana sobre
|
||||
//! postcard en debuggability.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use akasha_card::{FileEntry, FileId, MonadId, MonadManifest};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MonadDbError {
|
||||
#[error("sled: {0}")]
|
||||
Sled(#[from] sled::Error),
|
||||
#[error("JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("ULID inválido en clave: {0}")]
|
||||
BadKey(String),
|
||||
}
|
||||
|
||||
const TREE_FILES: &str = "files";
|
||||
const TREE_MONADS: &str = "monads";
|
||||
|
||||
/// Store de Mónadas + archivos. Cache en memoria + persistencia
|
||||
/// opcional sled.
|
||||
pub struct MonadDb {
|
||||
files: BTreeMap<FileId, FileEntry>,
|
||||
monads: BTreeMap<MonadId, MonadManifest>,
|
||||
persistence: Option<sled::Db>,
|
||||
}
|
||||
|
||||
impl Default for MonadDb {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MonadDb {
|
||||
/// Store en memoria pura (sin persistencia). El estado se pierde al salir.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
files: BTreeMap::new(),
|
||||
monads: BTreeMap::new(),
|
||||
persistence: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Abre (o crea) un store sled-backed en `path`. Carga el contenido
|
||||
/// existente a la cache antes de devolver.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, MonadDbError> {
|
||||
let db = sled::open(path)?;
|
||||
let mut files = BTreeMap::new();
|
||||
let mut monads = BTreeMap::new();
|
||||
|
||||
let files_tree = db.open_tree(TREE_FILES)?;
|
||||
for kv in files_tree.iter() {
|
||||
let (k, v) = kv?;
|
||||
let id = decode_key(&k)?;
|
||||
let entry: FileEntry = serde_json::from_slice(&v)?;
|
||||
files.insert(id, entry);
|
||||
}
|
||||
let monads_tree = db.open_tree(TREE_MONADS)?;
|
||||
for kv in monads_tree.iter() {
|
||||
let (k, v) = kv?;
|
||||
let id = decode_key(&k)?;
|
||||
let monad: MonadManifest = serde_json::from_slice(&v)?;
|
||||
monads.insert(id, monad);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
files,
|
||||
monads,
|
||||
persistence: Some(db),
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` si tiene backend persistente.
|
||||
pub fn is_persistent(&self) -> bool {
|
||||
self.persistence.is_some()
|
||||
}
|
||||
|
||||
// ---- Files ----
|
||||
|
||||
pub fn insert_file(&mut self, file: FileEntry) -> Option<FileEntry> {
|
||||
if let Some(db) = &self.persistence {
|
||||
// Write-through: si falla el persist, lo logeamos pero la
|
||||
// memoria queda actualizada. Filosofía: cache nunca miente
|
||||
// sobre el último estado conocido en este proceso.
|
||||
if let Err(e) = persist_file(db, &file) {
|
||||
eprintln!("[MonadDb] persist file falló: {e}");
|
||||
}
|
||||
}
|
||||
self.files.insert(file.id, file)
|
||||
}
|
||||
|
||||
pub fn ingest_files(&mut self, files: Vec<FileEntry>) {
|
||||
for f in files {
|
||||
self.insert_file(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> {
|
||||
if let Some(db) = &self.persistence {
|
||||
if let Err(e) = persist_monad(db, &monad) {
|
||||
eprintln!("[MonadDb] persist monad falló: {e}");
|
||||
}
|
||||
}
|
||||
self.monads.insert(monad.id, monad)
|
||||
}
|
||||
|
||||
pub fn replace_monads(&mut self, monads: Vec<MonadManifest>) {
|
||||
// Si hay persistencia, limpiar tree antes de insertar.
|
||||
if let Some(db) = &self.persistence {
|
||||
if let Ok(tree) = db.open_tree(TREE_MONADS) {
|
||||
let _ = tree.clear();
|
||||
}
|
||||
}
|
||||
self.monads.clear();
|
||||
for m in monads {
|
||||
self.insert_monad(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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_file(db: &sled::Db, f: &FileEntry) -> Result<(), MonadDbError> {
|
||||
let tree = db.open_tree(TREE_FILES)?;
|
||||
let key = f.id.to_string();
|
||||
let val = serde_json::to_vec(f)?;
|
||||
tree.insert(key.as_bytes(), val)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_monad(db: &sled::Db, m: &MonadManifest) -> Result<(), MonadDbError> {
|
||||
let tree = db.open_tree(TREE_MONADS)?;
|
||||
let key = m.id.to_string();
|
||||
let val = serde_json::to_vec(m)?;
|
||||
tree.insert(key.as_bytes(), val)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_key(k: &[u8]) -> Result<ulid::Ulid, MonadDbError> {
|
||||
let s = std::str::from_utf8(k).map_err(|_| MonadDbError::BadKey(format!("{:?}", k)))?;
|
||||
ulid::Ulid::from_string(s).map_err(|_| MonadDbError::BadKey(s.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use akasha_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());
|
||||
assert!(!db.is_persistent());
|
||||
}
|
||||
|
||||
#[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);
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dbpath = tmp.path().join("monads.sled");
|
||||
|
||||
// Escribimos algunos datos
|
||||
{
|
||||
let mut db = MonadDb::open(&dbpath).expect("open");
|
||||
assert!(db.is_persistent());
|
||||
let f = mk_file("/persist/a.rs");
|
||||
let fid = f.id;
|
||||
db.insert_file(f);
|
||||
|
||||
let mut m = MonadManifest::new("persist-test");
|
||||
m.members.insert(fid);
|
||||
m.dominant_lens = Lens::Code;
|
||||
m.touch();
|
||||
db.insert_monad(m);
|
||||
}
|
||||
|
||||
// Reabrimos y verificamos que están
|
||||
let db = MonadDb::open(&dbpath).expect("reopen");
|
||||
assert_eq!(db.file_count(), 1);
|
||||
assert_eq!(db.monad_count(), 1);
|
||||
let m = db.monads().next().unwrap();
|
||||
assert_eq!(m.label, "persist-test");
|
||||
assert_eq!(m.cardinality, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_monads_purges_persistent_tree() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dbpath = tmp.path().join("replace.sled");
|
||||
|
||||
{
|
||||
let mut db = MonadDb::open(&dbpath).unwrap();
|
||||
let mut m1 = MonadManifest::new("old");
|
||||
m1.members.insert(FileId::from(Ulid::new()));
|
||||
m1.touch();
|
||||
db.insert_monad(m1);
|
||||
}
|
||||
|
||||
// Reabrir, replace, verificar
|
||||
{
|
||||
let mut db = MonadDb::open(&dbpath).unwrap();
|
||||
assert_eq!(db.monad_count(), 1);
|
||||
let mut m2 = MonadManifest::new("new");
|
||||
m2.members.insert(FileId::from(Ulid::new()));
|
||||
m2.touch();
|
||||
db.replace_monads(vec![m2]);
|
||||
assert_eq!(db.monad_count(), 1);
|
||||
}
|
||||
|
||||
// Tercera apertura: sólo "new" sobrevive
|
||||
let db = MonadDb::open(&dbpath).unwrap();
|
||||
assert_eq!(db.monad_count(), 1);
|
||||
assert_eq!(db.monads().next().unwrap().label, "new");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Pseudo-embeddings de archivos: vectores deterministas derivados de
|
||||
//! metadatos (sin LLM).
|
||||
//!
|
||||
//! Implementan el "imán semántico" matemático que el diseño de Kairos
|
||||
//! pide: cada archivo tiene un vector, cada Mónada tiene un centroide,
|
||||
//! y un archivo nuevo se "pega" a la Mónada cuyo centroide está más
|
||||
//! cerca (cosine similarity).
|
||||
//!
|
||||
//! No reemplaza embeddings reales (text-embedding de un LLM); sirve para:
|
||||
//! - Bootstrapping sin Nous corriendo.
|
||||
//! - Mock determinístico en `BRAHMAN_BROKER_CONTEXT=test`.
|
||||
//! - Cohesión visual por path/extension (dos `.rs` en `src/` quedan
|
||||
//! muy juntos en el espacio vectorial).
|
||||
//!
|
||||
//! ## Forma del vector ([`EMBED_DIM`]=32, normalizado)
|
||||
//!
|
||||
//! - dims 0..8: `blake3(extension)` → identidad de tipo
|
||||
//! - dims 8..16: `blake3(parent_dir)` → identidad de contenedor
|
||||
//! - dims 16..24: `blake3(file_stem)` → identidad léxica del archivo
|
||||
//! - dims 24..28: tamaño (log scale + flags binarios)
|
||||
//! - dims 28..32: mtime (escala día + features cíclicas)
|
||||
//!
|
||||
//! ## Propiedades empíricas
|
||||
//!
|
||||
//! - Mismo dir + misma ext → similitud > 0.7 (alta cohesión).
|
||||
//! - Mismo dir + ext distinta → similitud ~ 0.5.
|
||||
//! - Dirs distintos + misma ext → similitud ~ 0.5.
|
||||
//! - Sin parecido → similitud < 0.3.
|
||||
|
||||
use akasha_card::{FileEntry, MonadId, MonadManifest};
|
||||
|
||||
/// Dimensión del vector embedding.
|
||||
pub const EMBED_DIM: usize = 32;
|
||||
|
||||
/// Identificador del modelo que produce este embedding. Se usa para
|
||||
/// taggear `MonadManifest.centroid_model`: los consumidores comparan
|
||||
/// este string contra el suyo antes de hacer cosine similarity.
|
||||
/// Mezclar centroides de distinto MODEL_ID corrompe scores
|
||||
/// silenciosamente (dimensiones distintas, semántica distinta).
|
||||
pub const MODEL_ID: &str = "akasha-pseudo-32d";
|
||||
|
||||
/// Computa el embedding de un archivo. Determinístico: misma input
|
||||
/// → mismo vector. El vector queda L2-normalizado.
|
||||
pub fn embed(file: &FileEntry) -> [f32; EMBED_DIM] {
|
||||
let mut v = [0.0f32; EMBED_DIM];
|
||||
|
||||
// dims 0..8: extension hash
|
||||
fill_from_hash(&mut v[0..8], file.extension.as_deref().unwrap_or(""));
|
||||
|
||||
// dims 8..16: parent dir name hash
|
||||
let parent = file
|
||||
.path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
fill_from_hash(&mut v[8..16], parent);
|
||||
|
||||
// dims 16..24: file stem hash (sin extensión)
|
||||
let stem = file
|
||||
.path
|
||||
.file_stem()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
fill_from_hash(&mut v[16..24], stem);
|
||||
|
||||
// dims 24..28: tamaño (centrado en 0 para que dot products entre
|
||||
// archivos de tamaño diferente sumen 0 en expectativa).
|
||||
let log_size = (file.size.max(1) as f32).log10();
|
||||
v[24] = ((log_size / 15.0).clamp(0.0, 1.0) - 0.5) * 2.0; // [-1, 1]
|
||||
v[25] = (log_size.fract() - 0.5) * 2.0;
|
||||
v[26] = if file.size >= 1_048_576 { 1.0 } else { -1.0 }; // ≥1MiB flag
|
||||
v[27] = if file.size <= 256 { 1.0 } else { -1.0 }; // ≤256B flag
|
||||
|
||||
// dims 28..32: mtime — escala día + cíclicas (centradas).
|
||||
let day = file.mtime_ms / (86_400 * 1000);
|
||||
v[28] = (((day as f32) / 30_000.0).clamp(0.0, 1.0) - 0.5) * 2.0;
|
||||
v[29] = ((day % 365) as f32 / 365.0 - 0.5) * 2.0;
|
||||
v[30] = ((day % 30) as f32 / 30.0 - 0.5) * 2.0;
|
||||
v[31] = ((day % 7) as f32 / 7.0 - 0.5) * 2.0;
|
||||
|
||||
normalize(&mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// Fill `out` con bytes del hash blake3 de `input`, centrados en [-1, 1].
|
||||
/// El centrado es crítico: bytes uniformes en [0,1] tienen media 0.5,
|
||||
/// así dos vectores hash distintos (de strings no relacionados) tendrían
|
||||
/// expected cosine similarity ≈ 0.75 (espuriamente alto). Centrarlos en
|
||||
/// [-1, 1] hace que la expectativa sea ≈ 0 — propiedad necesaria para
|
||||
/// que cosine similarity sea una métrica útil de afinidad.
|
||||
fn fill_from_hash(out: &mut [f32], input: &str) {
|
||||
let h = blake3::hash(input.as_bytes());
|
||||
let bytes = h.as_bytes();
|
||||
for (i, slot) in out.iter_mut().enumerate() {
|
||||
*slot = (bytes[i] as f32 - 127.5) / 127.5;
|
||||
}
|
||||
}
|
||||
|
||||
/// L2-normaliza un vector in-place. Vectores con norma 0 quedan en 0.
|
||||
fn normalize(v: &mut [f32]) {
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
for x in v.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cosine similarity entre dos vectores. Asume ambos L2-normalizados
|
||||
/// (en cuyo caso `dot product == cosine similarity`). Si las longitudes
|
||||
/// no coinciden, devuelve 0.
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
/// Centroide de un set de vectores. Promedio dim-por-dim seguido de
|
||||
/// L2-normalización. El resultado es un vector unidad apto para
|
||||
/// comparar con miembros nuevos vía cosine similarity.
|
||||
pub fn centroid(vectors: &[Vec<f32>]) -> Vec<f32> {
|
||||
if vectors.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let dim = vectors[0].len();
|
||||
let mut c = vec![0.0f32; dim];
|
||||
for v in vectors {
|
||||
if v.len() != dim {
|
||||
continue;
|
||||
}
|
||||
for (i, x) in v.iter().enumerate() {
|
||||
c[i] += x;
|
||||
}
|
||||
}
|
||||
let n = vectors.len() as f32;
|
||||
for x in c.iter_mut() {
|
||||
*x /= n;
|
||||
}
|
||||
normalize(&mut c);
|
||||
c
|
||||
}
|
||||
|
||||
/// Cohesión interna: media de cosine similarity de cada miembro contra
|
||||
/// el centroide. Alta cohesión = Mónada compacta. Baja = bifurcable.
|
||||
pub fn cohesion(centroid: &[f32], member_vectors: &[Vec<f32>]) -> f32 {
|
||||
if member_vectors.is_empty() || centroid.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum: f32 = member_vectors
|
||||
.iter()
|
||||
.map(|v| cosine_similarity(centroid, v))
|
||||
.sum();
|
||||
sum / member_vectors.len() as f32
|
||||
}
|
||||
|
||||
/// Score de atracción de un archivo nuevo a una Mónada existente:
|
||||
/// cosine similarity de su embedding contra el centroide de la Mónada.
|
||||
/// Mayor score = mayor afinidad.
|
||||
pub fn attraction_score(file_vec: &[f32], monad: &MonadManifest) -> f32 {
|
||||
if monad.centroid.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
cosine_similarity(file_vec, &monad.centroid)
|
||||
}
|
||||
|
||||
/// Encuentra la Mónada con mayor afinidad a un archivo. Devuelve
|
||||
/// `(MonadId, score)` o `None` si ninguna tiene centroide.
|
||||
pub fn best_attraction<'a, I>(file_vec: &[f32], monads: I) -> Option<(MonadId, f32)>
|
||||
where
|
||||
I: IntoIterator<Item = &'a MonadManifest>,
|
||||
{
|
||||
monads
|
||||
.into_iter()
|
||||
.filter(|m| !m.centroid.is_empty())
|
||||
.map(|m| (m.id, attraction_score(file_vec, m)))
|
||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
}
|
||||
|
||||
/// Umbral por defecto para "se pega": si el score es ≥ esto, el
|
||||
/// archivo se asigna automáticamente. Ajustable por el caller.
|
||||
pub const DEFAULT_ATTRACTION_THRESHOLD: f32 = 0.7;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use akasha_card::FileId;
|
||||
use std::path::PathBuf;
|
||||
use ulid::Ulid;
|
||||
|
||||
fn mk(path: &str, ext: Option<&str>, size: u64) -> FileEntry {
|
||||
FileEntry {
|
||||
id: FileId::from(Ulid::new()),
|
||||
path: PathBuf::from(path),
|
||||
content_hash: None,
|
||||
size,
|
||||
mtime_ms: 1_700_000_000_000, // fixed para que mtime no domine
|
||||
extension: ext.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_is_deterministic() {
|
||||
let a = mk("/x/foo.rs", Some("rs"), 1024);
|
||||
let b = mk("/x/foo.rs", Some("rs"), 1024);
|
||||
let va = embed(&a);
|
||||
let vb = embed(&b);
|
||||
// Mismos metadatos → mismo vector (los IDs no entran al embedding).
|
||||
assert_eq!(va, vb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_is_unit_normalized() {
|
||||
let f = mk("/x/foo.rs", Some("rs"), 1024);
|
||||
let v = embed(&f);
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_dir_same_ext_high_similarity() {
|
||||
let a = embed(&mk("/proj/src/a.rs", Some("rs"), 1000));
|
||||
let b = embed(&mk("/proj/src/b.rs", Some("rs"), 1100));
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!(sim > 0.7, "esperaba sim > 0.7, fue {sim}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_files_low_similarity() {
|
||||
let a = embed(&mk("/proj/src/main.rs", Some("rs"), 1000));
|
||||
let b = embed(&mk("/photos/2024/sunset.jpg", Some("jpg"), 5_000_000));
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!(sim < 0.5, "esperaba sim < 0.5, fue {sim}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centroid_is_unit_and_close_to_members() {
|
||||
let v1 = embed(&mk("/x/a.rs", Some("rs"), 1000));
|
||||
let v2 = embed(&mk("/x/b.rs", Some("rs"), 1100));
|
||||
let v3 = embed(&mk("/x/c.rs", Some("rs"), 1200));
|
||||
let c = centroid(&[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
|
||||
|
||||
// Norma unitaria.
|
||||
let norm: f32 = c.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
|
||||
|
||||
// Cohesión alta porque los miembros son similares.
|
||||
let cohesion = cohesion(&c, &[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
|
||||
assert!(cohesion > 0.9, "cohesion={cohesion}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attraction_picks_correct_monad() {
|
||||
// Construimos dos Mónadas: una de Rust, otra de imágenes.
|
||||
let rust_files = vec![
|
||||
embed(&mk("/proj/src/a.rs", Some("rs"), 1000)).to_vec(),
|
||||
embed(&mk("/proj/src/b.rs", Some("rs"), 1100)).to_vec(),
|
||||
];
|
||||
let img_files = vec![
|
||||
embed(&mk("/photos/p1.jpg", Some("jpg"), 5_000_000)).to_vec(),
|
||||
embed(&mk("/photos/p2.jpg", Some("jpg"), 4_000_000)).to_vec(),
|
||||
];
|
||||
|
||||
let mut rust_monad = MonadManifest::new("rust");
|
||||
rust_monad.members.insert(FileId::from(Ulid::new()));
|
||||
rust_monad.touch();
|
||||
rust_monad.centroid = centroid(&rust_files);
|
||||
|
||||
let mut img_monad = MonadManifest::new("photos");
|
||||
img_monad.members.insert(FileId::from(Ulid::new()));
|
||||
img_monad.touch();
|
||||
img_monad.centroid = centroid(&img_files);
|
||||
|
||||
// Un archivo .rs nuevo en /proj/src debe atraerse a la Mónada Rust.
|
||||
let new_rs = embed(&mk("/proj/src/new.rs", Some("rs"), 1500));
|
||||
let (best_id, _score) = best_attraction(&new_rs, [&rust_monad, &img_monad].into_iter())
|
||||
.expect("best match");
|
||||
assert_eq!(best_id, rust_monad.id);
|
||||
|
||||
// Y al revés.
|
||||
let new_jpg = embed(&mk("/photos/new.jpg", Some("jpg"), 6_000_000));
|
||||
let (best_id, _score) = best_attraction(&new_jpg, [&rust_monad, &img_monad].into_iter())
|
||||
.expect("best match");
|
||||
assert_eq!(best_id, img_monad.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_centroid_skipped_in_attraction() {
|
||||
let mut m = MonadManifest::new("empty");
|
||||
m.members.insert(FileId::from(Ulid::new()));
|
||||
m.touch();
|
||||
// m.centroid queda vacío
|
||||
|
||||
let v = embed(&mk("/x/y.rs", Some("rs"), 100));
|
||||
assert!(best_attraction(&v, [&m].into_iter()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Listener Unix-socket que sirve [`akasha_card::query::QueryRequest`].
|
||||
//!
|
||||
//! El daemon `akasha` lo monta para que cualquier consumer (UI, CLI,
|
||||
//! otro módulo) pueda preguntarle por sus Mónadas sin pasar por
|
||||
//! brahman-admin. El path del socket viaja en el `Card.service_socket`
|
||||
//! del engine; el broker brahman lo enseña vía MatchEvent::Available
|
||||
//! cuando un consumer declara `flow.input = monad-list:json`.
|
||||
//!
|
||||
//! Wire: line-delimited JSON, single-shot por conexión. Mismo patrón
|
||||
//! que `akasha-nous` (mock/real ↔ akasha-core), reutilizado.
|
||||
//!
|
||||
//! Threading: un thread dedicado, blocking I/O. No vale la pena traer
|
||||
//! tokio acá — la frecuencia esperada es muy baja (UI poll cada 2s)
|
||||
//! y el handler es trivial (lock db → snapshot → write).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use akasha_card::query::{
|
||||
EngineInfo, ErrorResponse, ListMonadsResponse, MonadView, QueryRequest,
|
||||
};
|
||||
use akasha_card::ulid::Ulid;
|
||||
|
||||
use crate::db::MonadDb;
|
||||
|
||||
/// Configuración del listener.
|
||||
pub struct ListenerConfig {
|
||||
pub socket_path: PathBuf,
|
||||
pub engine_id: Ulid,
|
||||
pub engine_label: String,
|
||||
/// Path del directorio que el daemon está observando, para incluir
|
||||
/// en `EngineInfo.watching`. `None` si el daemon no observa nada.
|
||||
pub watching: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Bind del socket + spawn de un thread con accept loop. Devuelve el
|
||||
/// path final (útil para confirmar) y un `JoinHandle` para shutdown
|
||||
/// explícito (drop = thread sigue, listener queda).
|
||||
///
|
||||
/// Si el socket ya existe (sesión anterior crasheada), se intenta
|
||||
/// removerlo antes del bind. Errores de bind se propagan al caller.
|
||||
pub fn spawn_listener(
|
||||
config: ListenerConfig,
|
||||
db: Arc<Mutex<MonadDb>>,
|
||||
) -> std::io::Result<std::thread::JoinHandle<()>> {
|
||||
if config.socket_path.exists() {
|
||||
let _ = std::fs::remove_file(&config.socket_path);
|
||||
}
|
||||
if let Some(parent) = config.socket_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let listener = UnixListener::bind(&config.socket_path)?;
|
||||
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("akasha-engine-listener".into())
|
||||
.spawn(move || {
|
||||
for conn in listener.incoming() {
|
||||
match conn {
|
||||
Ok(stream) => {
|
||||
// Handler sincrónico inline. La frecuencia
|
||||
// esperada (UI poll cada N segundos) no
|
||||
// amerita spawn-per-connection; si en el
|
||||
// futuro hay carga, agregar un threadpool.
|
||||
if let Err(e) = handle_conn(stream, &db, &config) {
|
||||
eprintln!("[engine-socket] conn falló: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[engine-socket] accept falló: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn handle_conn(
|
||||
mut stream: UnixStream,
|
||||
db: &Arc<Mutex<MonadDb>>,
|
||||
config: &ListenerConfig,
|
||||
) -> std::io::Result<()> {
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line)?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let resp_bytes = match serde_json::from_str::<QueryRequest>(line.trim()) {
|
||||
Ok(QueryRequest::ListMonads) => match handle_list_monads(db, config) {
|
||||
Ok(json) => json,
|
||||
Err(e) => encode_error(format!("list_monads falló: {e}")),
|
||||
},
|
||||
Err(e) => encode_error(format!("JSON inválido: {e}")),
|
||||
};
|
||||
|
||||
stream.write_all(resp_bytes.as_bytes())?;
|
||||
stream.write_all(b"\n")?;
|
||||
stream.flush()?;
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_list_monads(
|
||||
db: &Arc<Mutex<MonadDb>>,
|
||||
config: &ListenerConfig,
|
||||
) -> Result<String, String> {
|
||||
let db_lock = db.lock().map_err(|_| "mutex envenenado".to_string())?;
|
||||
let monads: Vec<MonadView> = db_lock.monads().map(MonadView::from_manifest).collect();
|
||||
let resp = ListMonadsResponse {
|
||||
engine: EngineInfo {
|
||||
id: config.engine_id,
|
||||
label: config.engine_label.clone(),
|
||||
watching: config.watching.as_ref().map(|p| p.display().to_string()),
|
||||
},
|
||||
monads,
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
fn encode_error(msg: String) -> String {
|
||||
let err = ErrorResponse { error: msg };
|
||||
serde_json::to_string(&err).unwrap_or_else(|_| "{\"error\":\"encode\"}".into())
|
||||
}
|
||||
|
||||
// El cliente blocking vive en `akasha_card::query::client` — junto a
|
||||
// los wire types — para que un consumer pueda hablar con el daemon
|
||||
// importando sólo `akasha-card`, sin arrastrar el peso de
|
||||
// `akasha-core` (scanner / db / sled / notify / walkdir / blake3).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::MonadDb;
|
||||
use akasha_card::query::client as query_client;
|
||||
use akasha_card::MonadManifest;
|
||||
use std::time::Duration;
|
||||
|
||||
fn fresh_socket_path(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir();
|
||||
let unique = format!("{}-{}-{}.sock", name, std::process::id(), Ulid::new());
|
||||
dir.join(unique)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_monads_roundtrip_empty() {
|
||||
let socket = fresh_socket_path("akasha-engine-test");
|
||||
let db = Arc::new(Mutex::new(MonadDb::new()));
|
||||
let engine_id = Ulid::new();
|
||||
let _h = spawn_listener(
|
||||
ListenerConfig {
|
||||
socket_path: socket.clone(),
|
||||
engine_id,
|
||||
engine_label: "test-engine".into(),
|
||||
watching: Some(PathBuf::from("/tmp/x")),
|
||||
},
|
||||
db.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Pequeña espera para que el bind se asiente (en práctica el
|
||||
// socket existe inmediatamente tras el bind, pero algunos FS
|
||||
// necesitan un tick). Si esto resulta flaky, agregar un loop
|
||||
// de wait_for(socket.exists()).
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let resp = query_client::list_monads(&socket, Duration::from_secs(2)).unwrap();
|
||||
assert_eq!(resp.engine.id, engine_id);
|
||||
assert_eq!(resp.engine.label, "test-engine");
|
||||
assert_eq!(resp.engine.watching.as_deref(), Some("/tmp/x"));
|
||||
assert!(resp.monads.is_empty());
|
||||
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_monads_returns_views() {
|
||||
let socket = fresh_socket_path("akasha-engine-test-views");
|
||||
let db = Arc::new(Mutex::new(MonadDb::new()));
|
||||
let m1 = MonadManifest::new("alpha");
|
||||
let m2 = MonadManifest::new("beta");
|
||||
{
|
||||
let mut g = db.lock().unwrap();
|
||||
g.replace_monads(vec![m1.clone(), m2.clone()]);
|
||||
}
|
||||
let _h = spawn_listener(
|
||||
ListenerConfig {
|
||||
socket_path: socket.clone(),
|
||||
engine_id: Ulid::new(),
|
||||
engine_label: "test".into(),
|
||||
watching: None,
|
||||
},
|
||||
db.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let resp = query_client::list_monads(&socket, Duration::from_secs(2)).unwrap();
|
||||
assert_eq!(resp.monads.len(), 2);
|
||||
let labels: Vec<_> = resp.monads.iter().map(|m| m.label.as_str()).collect();
|
||||
assert!(labels.contains(&"alpha"));
|
||||
assert!(labels.contains(&"beta"));
|
||||
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_request_returns_error_response() {
|
||||
let socket = fresh_socket_path("akasha-engine-test-bad");
|
||||
let db = Arc::new(Mutex::new(MonadDb::new()));
|
||||
let _h = spawn_listener(
|
||||
ListenerConfig {
|
||||
socket_path: socket.clone(),
|
||||
engine_id: Ulid::new(),
|
||||
engine_label: "test".into(),
|
||||
watching: None,
|
||||
},
|
||||
db.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Bypass del cliente tipado: mandamos JSON inválido a mano.
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||
stream.write_all(b"not json\n").unwrap();
|
||||
stream.flush().unwrap();
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
|
||||
assert!(
|
||||
response.contains("\"error\""),
|
||||
"esperaba ErrorResponse, got: {response}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! `akasha-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 embed;
|
||||
pub mod engine_socket;
|
||||
pub mod scanner;
|
||||
|
||||
pub use akasha_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 akasha_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