b23ddf2980
Cierra el ciclo del feedback: el modelo real (fastembed-allMiniLML6V2, ~1-50ms por archivo) era invocado ciegamente en cada re-cluster del watcher. Ahora se cachea por sha256(bytes-vistos) + model_id, con write-through al CAS de arje. Pipeline en handle_file: 1. Lee primeros 8 KiB del archivo (igual que antes). 2. file_sha = ente_cas::sha256_of(buf) — hash de los bytes que el modelo *realmente* verá. Garantiza que un archivo creciendo mas alla de la ventana sin tocar la cabeza siga sirviendo cache hits. 3. Cache lookup -> HIT: respuesta en us, sin invocar fastembed. 4. MISS: ente_cas::store(&buf) (write-through, no-fatal si falla) -> backend.embed_one(text) -> cache.put(...). Backend de cache: sled local en $XDG_CACHE_HOME/brahman/nouser-nous-real-embed-cache.sled. Tree versionado embed_cache_v1; el MODEL_ID viaja en la key, asi que cambiar de modelo invalida el cache implicitamente. Override por env NOUSER_NOUS_REAL_CACHE. Encoding compacto: cada Vec<f32> se serializa como bytes little-endian (4B por f32, sin overhead). Para 384-d son 1.5 KiB por entry. Decode tolera bytes corruptos (longitud no-multiplo de 4 -> None, no panic). Por que sled y no ente-cas directo: el CAS de arje es flat sha256-keyed; la cache necesita un mapeo (file_sha, model_id) -> embedding, no expresable como entry CAS. El write-through a CAS queda como registro consultable + futura GC. Mock NO se modifica — su embedding pseudo-32d es metadata-hashing puro, sin costo. Cachearlo seria overhead. Tests: 5 unitarios verdes (roundtrip, miss, model collision, content collision, corrupted value). Stub mode (sin feature) sigue compilando sin tocar cache.
200 lines
7.0 KiB
Rust
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 nouser 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/nouser-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("nouser-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] {
|
|
ente_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());
|
|
}
|
|
}
|