Skip to main content

mentedb_cognitive/
speculative.rs

1use mentedb_core::types::{MemoryId, Timestamp};
2use serde::{Deserialize, Serialize};
3use std::io;
4use std::path::Path;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct CacheEntry {
8    pub topic: String,
9    pub topic_embedding: Option<Vec<f32>>,
10    pub context_text: String,
11    pub memory_ids: Vec<MemoryId>,
12    pub created_at: Timestamp,
13    pub hit_count: u32,
14    last_accessed: Timestamp,
15}
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct CacheStats {
19    pub hits: u64,
20    pub misses: u64,
21    pub evictions: u64,
22    pub cache_size: usize,
23}
24
25pub struct SpeculativeCache {
26    entries: Vec<CacheEntry>,
27    stats: CacheStats,
28    max_size: usize,
29    keyword_threshold: f32,
30    embedding_threshold: f32,
31}
32
33fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
34    if a.len() != b.len() || a.is_empty() {
35        return 0.0;
36    }
37    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
38    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
39    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
40    if norm_a == 0.0 || norm_b == 0.0 {
41        return 0.0;
42    }
43    dot / (norm_a * norm_b)
44}
45
46fn keyword_overlap_score(query: &str, topic: &str) -> f32 {
47    let query_words: ahash::AHashSet<String> = query
48        .to_lowercase()
49        .split_whitespace()
50        .filter(|w| w.len() >= 2)
51        .map(String::from)
52        .collect();
53    let topic_words: ahash::AHashSet<String> = topic
54        .to_lowercase()
55        .split_whitespace()
56        .filter(|w| w.len() >= 2)
57        .map(String::from)
58        .collect();
59
60    if query_words.is_empty() || topic_words.is_empty() {
61        return 0.0;
62    }
63
64    let intersection = query_words.intersection(&topic_words).count();
65    let union = query_words.union(&topic_words).count();
66    if union == 0 {
67        0.0
68    } else {
69        intersection as f32 / union as f32
70    }
71}
72
73impl SpeculativeCache {
74    pub fn new(max_size: usize, keyword_threshold: f32, embedding_threshold: f32) -> Self {
75        Self {
76            entries: Vec::new(),
77            stats: CacheStats::default(),
78            max_size,
79            keyword_threshold,
80            embedding_threshold,
81        }
82    }
83
84    pub fn pre_assemble(
85        &mut self,
86        predictions: Vec<String>,
87        builder: impl Fn(&str) -> Option<(String, Vec<MemoryId>, Option<Vec<f32>>)>,
88    ) {
89        let now = std::time::SystemTime::now()
90            .duration_since(std::time::UNIX_EPOCH)
91            .unwrap_or_default()
92            .as_micros() as u64;
93
94        for topic in predictions {
95            if self.contains_topic(&topic) {
96                continue;
97            }
98            if let Some(built) = builder(&topic) {
99                self.insert_built(topic, built, now);
100            }
101        }
102    }
103
104    /// Whether an entry for `topic` is already cached.
105    pub fn contains_topic(&self, topic: &str) -> bool {
106        self.entries.iter().any(|e| e.topic == topic)
107    }
108
109    /// Insert an entry built elsewhere, evicting the least recently used one
110    /// when full. A topic already cached is left as is. Lets callers run the
111    /// (slow, possibly networked) build without holding the cache lock.
112    pub fn insert_built(
113        &mut self,
114        topic: String,
115        (context_text, memory_ids, embedding): (String, Vec<MemoryId>, Option<Vec<f32>>),
116        now: u64,
117    ) {
118        if self.contains_topic(&topic) {
119            return;
120        }
121        if self.entries.len() >= self.max_size {
122            self.evict_lru();
123        }
124        self.entries.push(CacheEntry {
125            topic,
126            topic_embedding: embedding,
127            context_text,
128            memory_ids,
129            created_at: now,
130            hit_count: 0,
131            last_accessed: now,
132        });
133    }
134
135    /// Try to find a cached context for this query. Uses cosine similarity on
136    /// embeddings when available, falls back to keyword overlap only when
137    /// no embeddings exist.
138    pub fn try_hit(&mut self, query: &str, query_embedding: Option<&[f32]>) -> Option<CacheEntry> {
139        let now = std::time::SystemTime::now()
140            .duration_since(std::time::UNIX_EPOCH)
141            .unwrap_or_default()
142            .as_micros() as u64;
143
144        let mut best_idx = None;
145        let mut best_score = 0.0f32;
146        let mut used_embeddings = false;
147
148        for (i, entry) in self.entries.iter().enumerate() {
149            let score = match (query_embedding, &entry.topic_embedding) {
150                (Some(qe), Some(te)) => {
151                    used_embeddings = true;
152                    cosine_similarity(qe, te)
153                }
154                _ => keyword_overlap_score(query, &entry.topic),
155            };
156
157            if score > best_score {
158                best_score = score;
159                best_idx = Some(i);
160            }
161        }
162
163        let threshold = if used_embeddings {
164            self.embedding_threshold
165        } else {
166            self.keyword_threshold
167        };
168
169        if best_score > threshold
170            && let Some(idx) = best_idx
171        {
172            self.entries[idx].hit_count += 1;
173            self.entries[idx].last_accessed = now;
174            self.stats.hits += 1;
175            return Some(self.entries[idx].clone());
176        }
177
178        self.stats.misses += 1;
179        None
180    }
181
182    pub fn evict_stale(&mut self, max_age_us: u64, now: Timestamp) {
183        let before = self.entries.len();
184        self.entries.retain(|e| now - e.created_at <= max_age_us);
185        let evicted = before - self.entries.len();
186        self.stats.evictions += evicted as u64;
187    }
188
189    pub fn stats(&self) -> CacheStats {
190        CacheStats {
191            cache_size: self.entries.len(),
192            ..self.stats.clone()
193        }
194    }
195
196    /// The current cache entries (predicted topic, pre-assembled context,
197    /// source memories, hit count, age), for introspection and display.
198    pub fn entries(&self) -> &[CacheEntry] {
199        &self.entries
200    }
201
202    fn evict_lru(&mut self) {
203        if self.entries.is_empty() {
204            return;
205        }
206        let lru_idx = self
207            .entries
208            .iter()
209            .enumerate()
210            .min_by_key(|(_, e)| e.last_accessed)
211            .map(|(i, _)| i)
212            .unwrap();
213        self.entries.remove(lru_idx);
214        self.stats.evictions += 1;
215    }
216}
217
218impl Default for SpeculativeCache {
219    fn default() -> Self {
220        Self::new(10, 0.5, 0.4)
221    }
222}
223
224/// On-disk format for the speculative cache. Only entries that have been
225/// hit at least once are worth persisting.
226#[derive(Serialize, Deserialize)]
227struct CacheSnapshot {
228    version: u32,
229    entries: Vec<CacheEntry>,
230    stats: CacheStats,
231}
232
233const CACHE_SNAPSHOT_VERSION: u32 = 1;
234
235impl SpeculativeCache {
236    /// Save cache entries with at least `min_hits` to a JSON file.
237    /// Entries below the threshold are considered stale and dropped.
238    /// Uses atomic write (temp file + rename) to avoid corruption.
239    pub fn save(&self, path: &Path, min_hits: u32) -> io::Result<()> {
240        let entries: Vec<CacheEntry> = self
241            .entries
242            .iter()
243            .filter(|e| e.hit_count >= min_hits)
244            .cloned()
245            .collect();
246        let snapshot = CacheSnapshot {
247            version: CACHE_SNAPSHOT_VERSION,
248            entries,
249            stats: self.stats.clone(),
250        };
251        let json = serde_json::to_string(&snapshot)
252            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
253        let tmp = path.with_extension("tmp");
254        std::fs::write(&tmp, json)?;
255        std::fs::rename(&tmp, path)
256    }
257
258    /// Load cache entries from a JSON file, merging into the current cache.
259    /// Preserves the configured max_size and thresholds. Resets last_accessed
260    /// timestamps so LRU eviction works correctly after reload.
261    pub fn load(&mut self, path: &Path) -> io::Result<()> {
262        let json = std::fs::read_to_string(path)?;
263        let snapshot: CacheSnapshot = serde_json::from_str(&json)
264            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
265
266        if snapshot.version != CACHE_SNAPSHOT_VERSION {
267            return Err(io::Error::new(
268                io::ErrorKind::InvalidData,
269                format!(
270                    "unsupported cache snapshot version: {} (expected {})",
271                    snapshot.version, CACHE_SNAPSHOT_VERSION
272                ),
273            ));
274        }
275
276        let now = std::time::SystemTime::now()
277            .duration_since(std::time::UNIX_EPOCH)
278            .unwrap_or_default()
279            .as_micros() as u64;
280
281        for mut entry in snapshot.entries {
282            if self.entries.len() >= self.max_size {
283                self.evict_lru();
284            }
285            if !self.entries.iter().any(|e| e.topic == entry.topic) {
286                entry.last_accessed = now;
287                self.entries.push(entry);
288            }
289        }
290        self.stats.hits += snapshot.stats.hits;
291        self.stats.misses += snapshot.stats.misses;
292        self.stats.evictions += snapshot.stats.evictions;
293        Ok(())
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_pre_assemble_and_hit() {
303        let mut cache = SpeculativeCache::default();
304        cache.pre_assemble(
305            vec![
306                "database schema design".to_string(),
307                "API authentication".to_string(),
308            ],
309            |topic| {
310                Some((
311                    format!("Context for {}", topic),
312                    vec![MemoryId::new()],
313                    None,
314                ))
315            },
316        );
317
318        assert_eq!(cache.stats().cache_size, 2);
319
320        let hit = cache.try_hit("database schema", None);
321        assert!(hit.is_some());
322        assert!(hit.unwrap().context_text.contains("database schema design"));
323    }
324
325    #[test]
326    fn test_cache_miss() {
327        let mut cache = SpeculativeCache::default();
328        cache.pre_assemble(vec!["database schema".to_string()], |topic| {
329            Some((format!("Context for {}", topic), vec![], None))
330        });
331
332        let hit = cache.try_hit("cooking recipes", None);
333        assert!(hit.is_none());
334        assert_eq!(cache.stats().misses, 1);
335    }
336
337    #[test]
338    fn test_embedding_hit() {
339        let mut cache = SpeculativeCache::default();
340        cache.pre_assemble(vec!["rust ownership".to_string()], |_topic| {
341            Some((
342                "Context about ownership".to_string(),
343                vec![],
344                Some(vec![1.0, 0.0, 0.0, 0.0]),
345            ))
346        });
347
348        // Query with similar embedding but different words
349        let query_emb = vec![0.95, 0.1, 0.0, 0.0];
350        let hit = cache.try_hit("memory safety borrow checker", Some(&query_emb));
351        assert!(hit.is_some());
352    }
353
354    #[test]
355    fn test_embedding_miss() {
356        let mut cache = SpeculativeCache::default();
357        cache.pre_assemble(vec!["rust ownership".to_string()], |_topic| {
358            Some((
359                "Context about ownership".to_string(),
360                vec![],
361                Some(vec![1.0, 0.0, 0.0, 0.0]),
362            ))
363        });
364
365        // Orthogonal embedding should miss
366        let query_emb = vec![0.0, 0.0, 0.0, 1.0];
367        let hit = cache.try_hit("cooking recipes", Some(&query_emb));
368        assert!(hit.is_none());
369    }
370
371    #[test]
372    fn test_lru_eviction() {
373        let mut cache = SpeculativeCache::new(10, 0.5, 0.4);
374        for i in 0..12 {
375            cache.pre_assemble(vec![format!("topic {}", i)], |topic| {
376                Some((format!("Context for {}", topic), vec![], None))
377            });
378        }
379        assert!(cache.stats().cache_size <= 10);
380        assert!(cache.stats().evictions > 0);
381    }
382
383    #[test]
384    fn test_evict_stale() {
385        let mut cache = SpeculativeCache::default();
386        cache.pre_assemble(vec!["old topic".to_string()], |topic| {
387            Some((format!("Context for {}", topic), vec![], None))
388        });
389        cache.evict_stale(0, u64::MAX);
390        assert_eq!(cache.stats().cache_size, 0);
391    }
392
393    #[test]
394    fn test_save_and_load() {
395        let dir = tempfile::tempdir().unwrap();
396        let path = dir.path().join("cache.json");
397
398        let mut cache = SpeculativeCache::default();
399        cache.pre_assemble(
400            vec!["hot topic".to_string(), "cold topic".to_string()],
401            |topic| {
402                Some((
403                    format!("Context for {}", topic),
404                    vec![MemoryId::new()],
405                    None,
406                ))
407            },
408        );
409        // Hit the hot topic so it has hit_count >= 1
410        cache.try_hit("hot topic", None);
411
412        // Save with min_hits=1 — only "hot topic" should persist
413        cache.save(&path, 1).unwrap();
414
415        let mut loaded = SpeculativeCache::default();
416        loaded.load(&path).unwrap();
417        assert_eq!(loaded.stats().cache_size, 1);
418
419        let hit = loaded.try_hit("hot topic", None);
420        assert!(hit.is_some());
421        let miss = loaded.try_hit("cold topic", None);
422        assert!(miss.is_none());
423    }
424
425    #[test]
426    fn test_save_empty_cache() {
427        let dir = tempfile::tempdir().unwrap();
428        let path = dir.path().join("empty.json");
429
430        let cache = SpeculativeCache::default();
431        cache.save(&path, 1).unwrap();
432
433        let mut loaded = SpeculativeCache::default();
434        loaded.load(&path).unwrap();
435        assert_eq!(loaded.stats().cache_size, 0);
436    }
437
438    #[test]
439    fn test_load_missing_file() {
440        let mut cache = SpeculativeCache::default();
441        let result = cache.load(Path::new("/nonexistent/cache.json"));
442        assert!(result.is_err());
443    }
444}