Files
brahman/crates/modules/chasqui/nous-real/src/cache.rs
T
sergio b83d40a833 refactor(naming): A1 — ente→arje, vista→revista, pluma→fana
Rename batch de la Fase A del PLAN_MACRO:
- 25 crates ente-* → arje-* (protocol/init/runtime/compat). El linaje
  arje (init Linux) queda con prefijo coherente.
- vista → revista (revista-core + revista-web).
- pluma → fana (fana-md + fana-md-reader-web). fana absorbe el linaje
  markdown de pluma; será el writer DAG editor (prioridad alta).

Cambios:
- git mv de 29 crate dirs + 2 SDDs
- package/lib/bin names + path refs + imports .rs reescritos
- workspace Cargo.toml + comentarios de sección
- SDDs de init/runtime/compat/protocol actualizados a arje-
- SDD de revista + SDD de fana (reescrito: writer DAG editor)
- docs/STATUS.md, ROADMAP.md, PLAN_MACRO.md, arje-boot.md,
  arje-replace-systemd.md actualizados
- docs/changelog/akasha.md → chasqui.md

scripts/rename-fase-a.py idempotente (--dry-run soportado).
cargo check --workspace verde.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:10:14 +00:00

200 lines
7.0 KiB
Rust

//! Cache de embeddings keyed por sha256 del contenido + model_id.
//!
//! Razón de existir: el modelo real (`fastembed-allMiniLML6V2`) es
//! caro (1-50 ms por archivo según tamaño y CPU). Cada vez que el
//! daemon de chasqui re-publica una Mónada o el watcher dispara un
//! re-cluster por cambio de FS, todos los archivos pasan otra vez
//! por embed. Para árboles de 1000 archivos, eso son segundos
//! desperdiciados re-embedidando contenido que no cambió.
//!
//! ## Diseño
//!
//! - **Cache key**: `sha256(bytes que el modelo realmente vio)` +
//! `MODEL_ID` (string). Usar el sha de los bytes-vistos garantiza
//! que la cache no devuelva un embedding de contenido viejo
//! simplemente porque el path no cambió.
//! - **Cache value**: el `Vec<f32>` serializado como bytes
//! little-endian (4 bytes por f32). Compacto, sin overhead de
//! bincode/postcard para datos numéricos puros.
//! - **Backend**: sled, tree único `embed_cache_v1`. Path:
//! `$XDG_CACHE_HOME/brahman/chasqui-nous-real-embed-cache.sled`.
//!
//! ## Versionado
//!
//! El nombre del tree (`embed_cache_v1`) es el "schema version" del
//! formato value. Si bumpeamos a (p. ej.) almacenar también el
//! tiempo de cómputo o el ONNX session id, creamos `embed_cache_v2`
//! y el viejo queda como dato muerto que sled puede limpiar.
//!
//! El `MODEL_ID` viaja dentro del key, así que cambiar de modelo
//! invalida implícitamente las entradas viejas (no se accede más
//! a esos keys; sled las mantiene hasta GC manual).
use std::path::PathBuf;
/// Wrapper sobre sled::Db con la API justa que necesita `handle_file`.
#[derive(Clone)]
pub struct EmbedCache {
tree: sled::Tree,
}
impl EmbedCache {
/// Abre (o crea) la cache en su path canónico. El sled::Db queda
/// referenciado por el Tree; mientras `EmbedCache` viva, el DB
/// vive.
pub fn open() -> Result<Self, sled::Error> {
let path = default_path();
if let Some(parent) = path.parent() {
// best-effort: si no podemos crear el dir, sled falla con
// mensaje específico abajo.
let _ = std::fs::create_dir_all(parent);
}
let db = sled::open(&path)?;
let tree = db.open_tree("embed_cache_v1")?;
Ok(Self { tree })
}
/// Variante para tests: cache efímera bajo `dir`.
#[cfg(test)]
pub fn open_at(dir: &std::path::Path) -> Result<Self, sled::Error> {
let db = sled::open(dir)?;
let tree = db.open_tree("embed_cache_v1")?;
Ok(Self { tree })
}
/// Lookup. `None` si miss; `Some(vec)` si hit.
pub fn get(&self, file_sha: &[u8; 32], model_id: &str) -> Option<Vec<f32>> {
let key = build_key(file_sha, model_id);
let bytes = self.tree.get(&key).ok()??;
decode_embedding(&bytes)
}
/// Almacena. Errores se loggean pero no propagan — cache miss es
/// recuperable, no querés tirar el embed válido por fallo de I/O
/// de cache.
pub fn put(&self, file_sha: &[u8; 32], model_id: &str, embedding: &[f32]) {
let key = build_key(file_sha, model_id);
let bytes = encode_embedding(embedding);
if let Err(e) = self.tree.insert(key, bytes) {
tracing::warn!(error = %e, "embed-cache put falló (no-fatal)");
}
}
/// Cantidad actual de entradas (best-effort para logs).
pub fn len(&self) -> usize {
self.tree.len()
}
}
/// Path default. Honra `XDG_CACHE_HOME`, cae a `$HOME/.cache`, y de
/// último recurso a `/tmp` (sin persistencia, pero al menos no
/// crashea en entornos minimalistas como CI sin HOME).
fn default_path() -> PathBuf {
if let Ok(p) = std::env::var("NOUSER_NOUS_REAL_CACHE") {
return PathBuf::from(p);
}
let base = std::env::var("XDG_CACHE_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".cache"))
})
.unwrap_or_else(std::env::temp_dir);
base.join("brahman").join("chasqui-nous-real-embed-cache.sled")
}
fn build_key(file_sha: &[u8; 32], model_id: &str) -> Vec<u8> {
let mut k = Vec::with_capacity(32 + 1 + model_id.len());
k.extend_from_slice(file_sha);
// separator byte para que prefijos de model_id no choquen con
// bytes del sha (improbable pero barato).
k.push(0xff);
k.extend_from_slice(model_id.as_bytes());
k
}
fn encode_embedding(v: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(v.len() * 4);
for f in v {
out.extend_from_slice(&f.to_le_bytes());
}
out
}
fn decode_embedding(bytes: &[u8]) -> Option<Vec<f32>> {
if bytes.len() % 4 != 0 {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 4);
for chunk in bytes.chunks_exact(4) {
out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn sha(s: &[u8]) -> [u8; 32] {
arje_cas::sha256_of(s)
}
#[test]
fn roundtrip_returns_same_vector() {
let dir = tempfile::tempdir().unwrap();
let cache = EmbedCache::open_at(dir.path()).unwrap();
let key = sha(b"hello world");
let v = vec![0.1f32, -0.5, 1.0, 3.14159];
cache.put(&key, "real-fastembed-allMiniLML6V2-384d", &v);
let got = cache
.get(&key, "real-fastembed-allMiniLML6V2-384d")
.expect("hit esperado");
assert_eq!(got, v);
}
#[test]
fn miss_returns_none() {
let dir = tempfile::tempdir().unwrap();
let cache = EmbedCache::open_at(dir.path()).unwrap();
let key = sha(b"never stored");
assert!(cache.get(&key, "real-fastembed-allMiniLML6V2-384d").is_none());
}
#[test]
fn different_models_do_not_collide() {
let dir = tempfile::tempdir().unwrap();
let cache = EmbedCache::open_at(dir.path()).unwrap();
let key = sha(b"same content");
cache.put(&key, "model-a", &[1.0, 2.0]);
cache.put(&key, "model-b", &[7.0, 8.0]);
assert_eq!(cache.get(&key, "model-a").unwrap(), vec![1.0, 2.0]);
assert_eq!(cache.get(&key, "model-b").unwrap(), vec![7.0, 8.0]);
}
#[test]
fn different_content_different_keys() {
let dir = tempfile::tempdir().unwrap();
let cache = EmbedCache::open_at(dir.path()).unwrap();
let k1 = sha(b"abc");
let k2 = sha(b"abd");
cache.put(&k1, "m", &[1.0]);
assert!(cache.get(&k2, "m").is_none());
}
#[test]
fn corrupted_value_returns_none() {
// Si sled devuelve bytes con length no múltiplo de 4, decode
// debe fallar limpio (None) en vez de panicar.
let dir = tempfile::tempdir().unwrap();
let cache = EmbedCache::open_at(dir.path()).unwrap();
let key = sha(b"x");
// Insertamos manualmente bytes inválidos.
let raw_key = build_key(&key, "m");
cache.tree.insert(raw_key, &[1u8, 2, 3][..]).unwrap();
assert!(cache.get(&key, "m").is_none());
}
}