//! 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` 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 { 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 { 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> { 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 { 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 { 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> { 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()); } }