Skip to main content

reddb_server/storage/ml/
semantic_cache.rs

1//! Semantic cache for LLM responses (ML Feature 3, MVP).
2//!
3//! Caches `(prompt, response)` pairs keyed by the prompt's embedding
4//! vector. A lookup returns the cached response when any entry has
5//! cosine similarity to the query embedding ≥ the caller's threshold
6//! **and** has not expired.
7//!
8//! This is a linear-scan implementation — fine for caches up to
9//! ~10k entries. A future sprint swaps the scan for the existing
10//! HNSW index when entry counts make that worth the added
11//! complexity. The external surface stays the same.
12//!
13//! Eviction is lazy: `lookup` drops any expired entry it skips
14//! over, and [`Self::evict_expired`] sweeps the whole set on
15//! demand. Bounded size is enforced on insert via LRU-by-age
16//! (oldest entries dropped first once `max_entries` is reached).
17
18use std::sync::{Arc, Mutex};
19
20use super::jobs::now_ms;
21use super::persist::{MlPersistence, MlPersistenceResult};
22use crate::json::{Map, Value as JsonValue};
23
24/// One cached entry.
25#[derive(Debug, Clone)]
26pub struct SemanticCacheEntry {
27    pub prompt: String,
28    pub response: String,
29    pub embedding: Vec<f32>,
30    /// Epoch millis; `0` means "never expires".
31    pub expires_at_ms: u64,
32    /// Epoch millis of last read hit — used for LRU eviction.
33    pub last_hit_ms: u64,
34    /// Epoch millis the entry landed in the cache.
35    pub inserted_at_ms: u64,
36}
37
38impl SemanticCacheEntry {
39    pub fn is_expired_at(&self, now_ms_val: u64) -> bool {
40        self.expires_at_ms != 0 && now_ms_val >= self.expires_at_ms
41    }
42}
43
44/// Compile-time tuneables for a cache instance.
45#[derive(Debug, Clone)]
46pub struct SemanticCacheConfig {
47    /// Cosine similarity threshold above which a candidate counts
48    /// as a hit. Typical values: 0.90–0.98.
49    pub similarity_threshold: f32,
50    /// Default TTL applied to freshly-inserted entries. `0` =
51    /// entries never expire. Callers can still pass a per-insert
52    /// override.
53    pub default_ttl_ms: u64,
54    /// Maximum live entries. Oldest inserted entry is evicted once
55    /// this limit is reached. `0` = unbounded.
56    pub max_entries: usize,
57    /// Persistence namespace suffix. Allows multiple named caches
58    /// to share one `MlPersistence` backend without colliding.
59    pub namespace: String,
60}
61
62impl Default for SemanticCacheConfig {
63    fn default() -> Self {
64        Self {
65            similarity_threshold: 0.95,
66            default_ttl_ms: 24 * 60 * 60 * 1000,
67            max_entries: 10_000,
68            namespace: "default".to_string(),
69        }
70    }
71}
72
73/// Runtime statistics — exposed via `SELECT * FROM ML_CACHE_STATS`
74/// later, and useful in tests now.
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct SemanticCacheStats {
77    pub entries: usize,
78    pub hits: u64,
79    pub misses: u64,
80    pub expired_evictions: u64,
81    pub capacity_evictions: u64,
82}
83
84struct Inner {
85    entries: Vec<SemanticCacheEntry>,
86    stats: SemanticCacheStats,
87}
88
89/// The cache itself. Cloning shares state via the inner `Arc`.
90#[derive(Clone)]
91pub struct SemanticCache {
92    inner: Arc<Mutex<Inner>>,
93    config: SemanticCacheConfig,
94    backend: Option<Arc<dyn MlPersistence>>,
95}
96
97impl std::fmt::Debug for SemanticCache {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("SemanticCache")
100            .field("namespace", &self.config.namespace)
101            .field("similarity_threshold", &self.config.similarity_threshold)
102            .field("max_entries", &self.config.max_entries)
103            .field("persistent", &self.backend.is_some())
104            .finish()
105    }
106}
107
108impl SemanticCache {
109    /// Build an in-process cache with no durable backend.
110    pub fn new(config: SemanticCacheConfig) -> Self {
111        Self {
112            inner: Arc::new(Mutex::new(Inner {
113                entries: Vec::new(),
114                stats: SemanticCacheStats::default(),
115            })),
116            config,
117            backend: None,
118        }
119    }
120
121    /// Build a cache that persists every mutation to `backend` under
122    /// `cache:{namespace}`. Entries rehydrate on construction.
123    pub fn with_backend(config: SemanticCacheConfig, backend: Arc<dyn MlPersistence>) -> Self {
124        let cache = Self {
125            inner: Arc::new(Mutex::new(Inner {
126                entries: Vec::new(),
127                stats: SemanticCacheStats::default(),
128            })),
129            config,
130            backend: Some(backend),
131        };
132        let _ = cache.load_from_backend();
133        cache
134    }
135
136    fn backend_namespace(&self) -> String {
137        format!("cache:{}", self.config.namespace)
138    }
139
140    fn persist_entry(&self, key: &str, entry: &SemanticCacheEntry) {
141        if let Some(backend) = self.backend.as_ref() {
142            let _ = backend.put(&self.backend_namespace(), key, &encode_entry(entry));
143        }
144    }
145
146    fn forget_entry(&self, key: &str) {
147        if let Some(backend) = self.backend.as_ref() {
148            let _ = backend.delete(&self.backend_namespace(), key);
149        }
150    }
151
152    /// Re-read persisted entries into memory. Malformed rows are
153    /// skipped rather than crashing startup.
154    pub fn load_from_backend(&self) -> MlPersistenceResult<usize> {
155        let Some(backend) = self.backend.as_ref() else {
156            return Ok(0);
157        };
158        let rows = backend.list(&self.backend_namespace())?;
159        let mut loaded = 0usize;
160        let now = now_ms();
161        let mut guard = match self.inner.lock() {
162            Ok(g) => g,
163            Err(p) => p.into_inner(),
164        };
165        guard.entries.clear();
166        for (_, raw) in rows {
167            let Some(entry) = decode_entry(&raw) else {
168                continue;
169            };
170            if entry.is_expired_at(now) {
171                // Skip stale entries rather than loading-then-evicting —
172                // saves a pass. Their rows are left in the backend
173                // and removed on next insert of the same prompt or
174                // on explicit evict.
175                continue;
176            }
177            guard.entries.push(entry);
178            loaded += 1;
179        }
180        guard.stats.entries = guard.entries.len();
181        Ok(loaded)
182    }
183
184    /// Look up by embedding. Returns the cached response on hit;
185    /// updates `last_hit_ms` and hit counter as a side-effect.
186    pub fn lookup(&self, embedding: &[f32]) -> Option<String> {
187        if embedding.is_empty() {
188            return None;
189        }
190        let now = now_ms();
191        let mut guard = match self.inner.lock() {
192            Ok(g) => g,
193            Err(p) => p.into_inner(),
194        };
195        // Drop expired entries in-place so the scan cost stays low.
196        let before = guard.entries.len();
197        guard.entries.retain(|e| !e.is_expired_at(now));
198        let evicted = before - guard.entries.len();
199        guard.stats.expired_evictions += evicted as u64;
200
201        let mut best: Option<(usize, f32)> = None;
202        for (idx, entry) in guard.entries.iter().enumerate() {
203            let score = cosine_similarity(embedding, &entry.embedding);
204            if score >= self.config.similarity_threshold {
205                match best {
206                    Some((_, best_score)) if best_score >= score => {}
207                    _ => best = Some((idx, score)),
208                }
209            }
210        }
211        match best {
212            Some((idx, _)) => {
213                let entry = &mut guard.entries[idx];
214                entry.last_hit_ms = now;
215                let response = entry.response.clone();
216                let persisted = entry.clone();
217                guard.stats.hits += 1;
218                guard.stats.entries = guard.entries.len();
219                drop(guard);
220                // Persist the updated last_hit so LRU eviction
221                // respects read traffic across restarts.
222                let key = cache_key(&persisted);
223                self.persist_entry(&key, &persisted);
224                Some(response)
225            }
226            None => {
227                guard.stats.misses += 1;
228                guard.stats.entries = guard.entries.len();
229                None
230            }
231        }
232    }
233
234    /// Insert `(prompt, response)` keyed by `embedding`.
235    /// If `ttl_ms_override` is `None` the config default applies.
236    pub fn insert(
237        &self,
238        prompt: impl Into<String>,
239        response: impl Into<String>,
240        embedding: Vec<f32>,
241        ttl_ms_override: Option<u64>,
242    ) {
243        if embedding.is_empty() {
244            return;
245        }
246        let now = now_ms();
247        let ttl = ttl_ms_override.unwrap_or(self.config.default_ttl_ms);
248        let expires_at_ms = if ttl == 0 { 0 } else { now.saturating_add(ttl) };
249        let entry = SemanticCacheEntry {
250            prompt: prompt.into(),
251            response: response.into(),
252            embedding,
253            expires_at_ms,
254            last_hit_ms: now,
255            inserted_at_ms: now,
256        };
257        let evicted_keys: Vec<String>;
258        let stored_key: String;
259        let persist_entry: SemanticCacheEntry;
260        {
261            let mut guard = match self.inner.lock() {
262                Ok(g) => g,
263                Err(p) => p.into_inner(),
264            };
265            // Enforce capacity first. Oldest `inserted_at` loses —
266            // simple and deterministic; swap for LRU-on-read later
267            // if needed.
268            let mut pruned: Vec<String> = Vec::new();
269            if self.config.max_entries > 0 {
270                while guard.entries.len() >= self.config.max_entries {
271                    if let Some((oldest_idx, _)) = guard
272                        .entries
273                        .iter()
274                        .enumerate()
275                        .min_by_key(|(_, e)| e.inserted_at_ms)
276                    {
277                        // Victim is chosen by the min_by_key scan above, so the
278                        // vector's order is irrelevant: swap_remove is O(1).
279                        let gone = guard.entries.swap_remove(oldest_idx);
280                        guard.stats.capacity_evictions += 1;
281                        pruned.push(cache_key(&gone));
282                    } else {
283                        break;
284                    }
285                }
286            }
287            guard.entries.push(entry.clone());
288            guard.stats.entries = guard.entries.len();
289            evicted_keys = pruned;
290            stored_key = cache_key(&entry);
291            persist_entry = entry;
292        }
293        for k in &evicted_keys {
294            self.forget_entry(k);
295        }
296        self.persist_entry(&stored_key, &persist_entry);
297    }
298
299    /// Manually force a sweep. Returns number of entries dropped.
300    pub fn evict_expired(&self) -> usize {
301        let now = now_ms();
302        let evicted_keys: Vec<String>;
303        let count;
304        {
305            let mut guard = match self.inner.lock() {
306                Ok(g) => g,
307                Err(p) => p.into_inner(),
308            };
309            let mut keep = Vec::with_capacity(guard.entries.len());
310            let mut dropped = Vec::new();
311            for entry in guard.entries.drain(..) {
312                if entry.is_expired_at(now) {
313                    dropped.push(cache_key(&entry));
314                } else {
315                    keep.push(entry);
316                }
317            }
318            count = dropped.len();
319            guard.entries = keep;
320            guard.stats.expired_evictions += count as u64;
321            guard.stats.entries = guard.entries.len();
322            evicted_keys = dropped;
323        }
324        for k in &evicted_keys {
325            self.forget_entry(k);
326        }
327        count
328    }
329
330    /// Snapshot of counters.
331    pub fn stats(&self) -> SemanticCacheStats {
332        let guard = match self.inner.lock() {
333            Ok(g) => g,
334            Err(p) => p.into_inner(),
335        };
336        SemanticCacheStats {
337            entries: guard.entries.len(),
338            ..guard.stats.clone()
339        }
340    }
341
342    pub fn config(&self) -> &SemanticCacheConfig {
343        &self.config
344    }
345}
346
347/// Deterministic key per entry — the inserted_at timestamp plus the
348/// first 16 bytes of the prompt gives a collision-resistant, sortable
349/// identifier without pulling sha2 into the ML module.
350fn cache_key(entry: &SemanticCacheEntry) -> String {
351    // Hash the prompt with a small FNV-1a so the key is stable across
352    // processes. Avoids depending on `sha2` here; collisions between
353    // two distinct prompts inserted in the exact same millisecond are
354    // acceptable (the second insert overwrites the first).
355    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
356    const FNV_PRIME: u64 = 0x100000001b3;
357    let mut h = FNV_OFFSET;
358    for b in entry.prompt.as_bytes() {
359        h ^= *b as u64;
360        h = h.wrapping_mul(FNV_PRIME);
361    }
362    format!("{:020}-{:016x}", entry.inserted_at_ms, h)
363}
364
365fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
366    if a.len() != b.len() || a.is_empty() {
367        return 0.0;
368    }
369    let mut dot = 0.0f32;
370    let mut na = 0.0f32;
371    let mut nb = 0.0f32;
372    for i in 0..a.len() {
373        dot += a[i] * b[i];
374        na += a[i] * a[i];
375        nb += b[i] * b[i];
376    }
377    if na == 0.0 || nb == 0.0 {
378        return 0.0;
379    }
380    dot / (na.sqrt() * nb.sqrt())
381}
382
383// ---- JSON (en|de)coding of a single entry -------------------------------
384
385fn encode_entry(entry: &SemanticCacheEntry) -> String {
386    let mut obj = Map::new();
387    obj.insert(
388        "prompt".to_string(),
389        JsonValue::String(entry.prompt.clone()),
390    );
391    obj.insert(
392        "response".to_string(),
393        JsonValue::String(entry.response.clone()),
394    );
395    obj.insert(
396        "embedding".to_string(),
397        JsonValue::Array(
398            entry
399                .embedding
400                .iter()
401                .map(|f| JsonValue::Number(*f as f64))
402                .collect(),
403        ),
404    );
405    obj.insert(
406        "expires_at".to_string(),
407        JsonValue::Number(entry.expires_at_ms as f64),
408    );
409    obj.insert(
410        "last_hit".to_string(),
411        JsonValue::Number(entry.last_hit_ms as f64),
412    );
413    obj.insert(
414        "inserted_at".to_string(),
415        JsonValue::Number(entry.inserted_at_ms as f64),
416    );
417    JsonValue::Object(obj).to_string_compact()
418}
419
420fn decode_entry(raw: &str) -> Option<SemanticCacheEntry> {
421    let parsed = crate::json::parse_json(raw).ok()?;
422    let value = JsonValue::from(parsed);
423    let obj = value.as_object()?;
424    let prompt = obj.get("prompt")?.as_str()?.to_string();
425    let response = obj.get("response")?.as_str()?.to_string();
426    let embedding = obj
427        .get("embedding")?
428        .as_array()?
429        .iter()
430        .filter_map(|v| v.as_f64().map(|f| f as f32))
431        .collect::<Vec<f32>>();
432    let expires_at_ms = obj.get("expires_at")?.as_i64()? as u64;
433    let last_hit_ms = obj.get("last_hit")?.as_i64()? as u64;
434    let inserted_at_ms = obj.get("inserted_at")?.as_i64()? as u64;
435    Some(SemanticCacheEntry {
436        prompt,
437        response,
438        embedding,
439        expires_at_ms,
440        last_hit_ms,
441        inserted_at_ms,
442    })
443}
444
445#[cfg(test)]
446mod tests {
447    use super::super::persist::InMemoryMlPersistence;
448    use super::*;
449
450    fn cfg(threshold: f32, max: usize, ttl: u64) -> SemanticCacheConfig {
451        SemanticCacheConfig {
452            similarity_threshold: threshold,
453            default_ttl_ms: ttl,
454            max_entries: max,
455            namespace: "t".to_string(),
456        }
457    }
458
459    #[test]
460    fn cosine_similarity_is_symmetric_and_bounded() {
461        let a = [1.0, 0.0, 0.0];
462        let b = [0.0, 1.0, 0.0];
463        let c = [1.0, 0.0, 0.0];
464        assert!((cosine_similarity(&a, &c) - 1.0).abs() < 1e-6);
465        assert!(cosine_similarity(&a, &b).abs() < 1e-6);
466        assert!((cosine_similarity(&a, &b) - cosine_similarity(&b, &a)).abs() < 1e-6);
467    }
468
469    #[test]
470    fn cosine_zero_on_mismatched_dims_or_zero_vec() {
471        assert_eq!(cosine_similarity(&[1.0], &[1.0, 0.0]), 0.0);
472        assert_eq!(cosine_similarity(&[0.0, 0.0], &[0.0, 0.0]), 0.0);
473    }
474
475    #[test]
476    fn miss_returns_none_and_increments_miss_counter() {
477        let c = SemanticCache::new(cfg(0.9, 100, 0));
478        assert!(c.lookup(&[1.0, 0.0]).is_none());
479        assert_eq!(c.stats().misses, 1);
480        assert_eq!(c.stats().hits, 0);
481    }
482
483    #[test]
484    fn inserted_entry_is_found_on_identical_vector() {
485        let c = SemanticCache::new(cfg(0.9, 100, 0));
486        c.insert("p", "hello world", vec![1.0, 0.0, 0.0], None);
487        let got = c.lookup(&[1.0, 0.0, 0.0]).unwrap();
488        assert_eq!(got, "hello world");
489        assert_eq!(c.stats().hits, 1);
490    }
491
492    #[test]
493    fn below_threshold_is_a_miss() {
494        let c = SemanticCache::new(cfg(0.99, 100, 0));
495        c.insert("p", "r", vec![1.0, 0.0, 0.0], None);
496        // Cosine of [1,0,0] vs [0.8, 0.6, 0] = 0.8 < 0.99
497        assert!(c.lookup(&[0.8, 0.6, 0.0]).is_none());
498    }
499
500    #[test]
501    fn expired_entries_are_skipped_and_evicted() {
502        let c = SemanticCache::new(cfg(0.9, 100, 1));
503        c.insert("p", "r", vec![1.0, 0.0], None);
504        std::thread::sleep(std::time::Duration::from_millis(5));
505        assert!(c.lookup(&[1.0, 0.0]).is_none());
506        let stats = c.stats();
507        assert_eq!(stats.entries, 0);
508        assert!(stats.expired_evictions >= 1);
509    }
510
511    #[test]
512    fn capacity_limit_evicts_oldest_inserted() {
513        let c = SemanticCache::new(cfg(0.9, 2, 0));
514        c.insert("first", "r1", vec![1.0, 0.0], None);
515        std::thread::sleep(std::time::Duration::from_millis(2));
516        c.insert("second", "r2", vec![0.0, 1.0], None);
517        std::thread::sleep(std::time::Duration::from_millis(2));
518        c.insert("third", "r3", vec![1.0, 1.0], None);
519        assert_eq!(c.stats().entries, 2);
520        assert!(c.stats().capacity_evictions >= 1);
521        // first should have been evicted
522        assert!(c.lookup(&[1.0, 0.0]).is_none() || c.lookup(&[1.0, 0.0]) != Some("r1".to_string()));
523    }
524
525    #[test]
526    fn best_candidate_wins_when_multiple_match() {
527        let c = SemanticCache::new(cfg(0.5, 100, 0));
528        c.insert("lo", "LO", vec![0.7, 0.7, 0.1], None);
529        c.insert("hi", "HI", vec![1.0, 0.0, 0.0], None);
530        let got = c.lookup(&[1.0, 0.0, 0.0]).unwrap();
531        assert_eq!(got, "HI");
532    }
533
534    #[test]
535    fn backend_round_trips_entry() {
536        let backend: Arc<dyn MlPersistence> = Arc::new(InMemoryMlPersistence::new());
537        let c1 = SemanticCache::with_backend(cfg(0.9, 100, 0), Arc::clone(&backend));
538        c1.insert("prompt one", "response one", vec![1.0, 0.0], None);
539        let c2 = SemanticCache::with_backend(cfg(0.9, 100, 0), backend);
540        let got = c2.lookup(&[1.0, 0.0]).unwrap();
541        assert_eq!(got, "response one");
542    }
543
544    #[test]
545    fn encode_decode_entry_round_trips() {
546        let e = SemanticCacheEntry {
547            prompt: "why".to_string(),
548            response: "because".to_string(),
549            embedding: vec![0.1, 0.2, -0.3],
550            expires_at_ms: 100,
551            last_hit_ms: 50,
552            inserted_at_ms: 10,
553        };
554        let back = decode_entry(&encode_entry(&e)).unwrap();
555        assert_eq!(back.prompt, e.prompt);
556        assert_eq!(back.response, e.response);
557        assert_eq!(back.embedding.len(), e.embedding.len());
558        for (a, b) in back.embedding.iter().zip(e.embedding.iter()) {
559            assert!((a - b).abs() < 1e-6);
560        }
561        assert_eq!(back.expires_at_ms, e.expires_at_ms);
562    }
563
564    #[test]
565    fn stats_entries_reflect_live_set() {
566        let c = SemanticCache::new(cfg(0.9, 100, 0));
567        c.insert("a", "1", vec![1.0, 0.0], None);
568        c.insert("b", "2", vec![0.0, 1.0], None);
569        assert_eq!(c.stats().entries, 2);
570    }
571}