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.entries.iter().any(|e| e.topic == topic) {
96 continue;
97 }
98
99 if let Some((context_text, memory_ids, embedding)) = builder(&topic) {
100 if self.entries.len() >= self.max_size {
101 self.evict_lru();
102 }
103
104 self.entries.push(CacheEntry {
105 topic,
106 topic_embedding: embedding,
107 context_text,
108 memory_ids,
109 created_at: now,
110 hit_count: 0,
111 last_accessed: now,
112 });
113 }
114 }
115 }
116
117 pub fn try_hit(&mut self, query: &str, query_embedding: Option<&[f32]>) -> Option<CacheEntry> {
121 let now = std::time::SystemTime::now()
122 .duration_since(std::time::UNIX_EPOCH)
123 .unwrap_or_default()
124 .as_micros() as u64;
125
126 let mut best_idx = None;
127 let mut best_score = 0.0f32;
128 let mut used_embeddings = false;
129
130 for (i, entry) in self.entries.iter().enumerate() {
131 let score = match (query_embedding, &entry.topic_embedding) {
132 (Some(qe), Some(te)) => {
133 used_embeddings = true;
134 cosine_similarity(qe, te)
135 }
136 _ => keyword_overlap_score(query, &entry.topic),
137 };
138
139 if score > best_score {
140 best_score = score;
141 best_idx = Some(i);
142 }
143 }
144
145 let threshold = if used_embeddings {
146 self.embedding_threshold
147 } else {
148 self.keyword_threshold
149 };
150
151 if best_score > threshold
152 && let Some(idx) = best_idx
153 {
154 self.entries[idx].hit_count += 1;
155 self.entries[idx].last_accessed = now;
156 self.stats.hits += 1;
157 return Some(self.entries[idx].clone());
158 }
159
160 self.stats.misses += 1;
161 None
162 }
163
164 pub fn evict_stale(&mut self, max_age_us: u64, now: Timestamp) {
165 let before = self.entries.len();
166 self.entries.retain(|e| now - e.created_at <= max_age_us);
167 let evicted = before - self.entries.len();
168 self.stats.evictions += evicted as u64;
169 }
170
171 pub fn stats(&self) -> CacheStats {
172 CacheStats {
173 cache_size: self.entries.len(),
174 ..self.stats.clone()
175 }
176 }
177
178 fn evict_lru(&mut self) {
179 if self.entries.is_empty() {
180 return;
181 }
182 let lru_idx = self
183 .entries
184 .iter()
185 .enumerate()
186 .min_by_key(|(_, e)| e.last_accessed)
187 .map(|(i, _)| i)
188 .unwrap();
189 self.entries.remove(lru_idx);
190 self.stats.evictions += 1;
191 }
192}
193
194impl Default for SpeculativeCache {
195 fn default() -> Self {
196 Self::new(10, 0.5, 0.4)
197 }
198}
199
200#[derive(Serialize, Deserialize)]
203struct CacheSnapshot {
204 version: u32,
205 entries: Vec<CacheEntry>,
206 stats: CacheStats,
207}
208
209const CACHE_SNAPSHOT_VERSION: u32 = 1;
210
211impl SpeculativeCache {
212 pub fn save(&self, path: &Path, min_hits: u32) -> io::Result<()> {
216 let entries: Vec<CacheEntry> = self
217 .entries
218 .iter()
219 .filter(|e| e.hit_count >= min_hits)
220 .cloned()
221 .collect();
222 let snapshot = CacheSnapshot {
223 version: CACHE_SNAPSHOT_VERSION,
224 entries,
225 stats: self.stats.clone(),
226 };
227 let json = serde_json::to_string(&snapshot)
228 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
229 let tmp = path.with_extension("tmp");
230 std::fs::write(&tmp, json)?;
231 std::fs::rename(&tmp, path)
232 }
233
234 pub fn load(&mut self, path: &Path) -> io::Result<()> {
238 let json = std::fs::read_to_string(path)?;
239 let snapshot: CacheSnapshot = serde_json::from_str(&json)
240 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
241
242 if snapshot.version != CACHE_SNAPSHOT_VERSION {
243 return Err(io::Error::new(
244 io::ErrorKind::InvalidData,
245 format!(
246 "unsupported cache snapshot version: {} (expected {})",
247 snapshot.version, CACHE_SNAPSHOT_VERSION
248 ),
249 ));
250 }
251
252 let now = std::time::SystemTime::now()
253 .duration_since(std::time::UNIX_EPOCH)
254 .unwrap_or_default()
255 .as_micros() as u64;
256
257 for mut entry in snapshot.entries {
258 if self.entries.len() >= self.max_size {
259 self.evict_lru();
260 }
261 if !self.entries.iter().any(|e| e.topic == entry.topic) {
262 entry.last_accessed = now;
263 self.entries.push(entry);
264 }
265 }
266 self.stats.hits += snapshot.stats.hits;
267 self.stats.misses += snapshot.stats.misses;
268 self.stats.evictions += snapshot.stats.evictions;
269 Ok(())
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_pre_assemble_and_hit() {
279 let mut cache = SpeculativeCache::default();
280 cache.pre_assemble(
281 vec![
282 "database schema design".to_string(),
283 "API authentication".to_string(),
284 ],
285 |topic| {
286 Some((
287 format!("Context for {}", topic),
288 vec![MemoryId::new()],
289 None,
290 ))
291 },
292 );
293
294 assert_eq!(cache.stats().cache_size, 2);
295
296 let hit = cache.try_hit("database schema", None);
297 assert!(hit.is_some());
298 assert!(hit.unwrap().context_text.contains("database schema design"));
299 }
300
301 #[test]
302 fn test_cache_miss() {
303 let mut cache = SpeculativeCache::default();
304 cache.pre_assemble(vec!["database schema".to_string()], |topic| {
305 Some((format!("Context for {}", topic), vec![], None))
306 });
307
308 let hit = cache.try_hit("cooking recipes", None);
309 assert!(hit.is_none());
310 assert_eq!(cache.stats().misses, 1);
311 }
312
313 #[test]
314 fn test_embedding_hit() {
315 let mut cache = SpeculativeCache::default();
316 cache.pre_assemble(vec!["rust ownership".to_string()], |_topic| {
317 Some((
318 "Context about ownership".to_string(),
319 vec![],
320 Some(vec![1.0, 0.0, 0.0, 0.0]),
321 ))
322 });
323
324 let query_emb = vec![0.95, 0.1, 0.0, 0.0];
326 let hit = cache.try_hit("memory safety borrow checker", Some(&query_emb));
327 assert!(hit.is_some());
328 }
329
330 #[test]
331 fn test_embedding_miss() {
332 let mut cache = SpeculativeCache::default();
333 cache.pre_assemble(vec!["rust ownership".to_string()], |_topic| {
334 Some((
335 "Context about ownership".to_string(),
336 vec![],
337 Some(vec![1.0, 0.0, 0.0, 0.0]),
338 ))
339 });
340
341 let query_emb = vec![0.0, 0.0, 0.0, 1.0];
343 let hit = cache.try_hit("cooking recipes", Some(&query_emb));
344 assert!(hit.is_none());
345 }
346
347 #[test]
348 fn test_lru_eviction() {
349 let mut cache = SpeculativeCache::new(10, 0.5, 0.4);
350 for i in 0..12 {
351 cache.pre_assemble(vec![format!("topic {}", i)], |topic| {
352 Some((format!("Context for {}", topic), vec![], None))
353 });
354 }
355 assert!(cache.stats().cache_size <= 10);
356 assert!(cache.stats().evictions > 0);
357 }
358
359 #[test]
360 fn test_evict_stale() {
361 let mut cache = SpeculativeCache::default();
362 cache.pre_assemble(vec!["old topic".to_string()], |topic| {
363 Some((format!("Context for {}", topic), vec![], None))
364 });
365 cache.evict_stale(0, u64::MAX);
366 assert_eq!(cache.stats().cache_size, 0);
367 }
368
369 #[test]
370 fn test_save_and_load() {
371 let dir = tempfile::tempdir().unwrap();
372 let path = dir.path().join("cache.json");
373
374 let mut cache = SpeculativeCache::default();
375 cache.pre_assemble(
376 vec!["hot topic".to_string(), "cold topic".to_string()],
377 |topic| {
378 Some((
379 format!("Context for {}", topic),
380 vec![MemoryId::new()],
381 None,
382 ))
383 },
384 );
385 cache.try_hit("hot topic", None);
387
388 cache.save(&path, 1).unwrap();
390
391 let mut loaded = SpeculativeCache::default();
392 loaded.load(&path).unwrap();
393 assert_eq!(loaded.stats().cache_size, 1);
394
395 let hit = loaded.try_hit("hot topic", None);
396 assert!(hit.is_some());
397 let miss = loaded.try_hit("cold topic", None);
398 assert!(miss.is_none());
399 }
400
401 #[test]
402 fn test_save_empty_cache() {
403 let dir = tempfile::tempdir().unwrap();
404 let path = dir.path().join("empty.json");
405
406 let cache = SpeculativeCache::default();
407 cache.save(&path, 1).unwrap();
408
409 let mut loaded = SpeculativeCache::default();
410 loaded.load(&path).unwrap();
411 assert_eq!(loaded.stats().cache_size, 0);
412 }
413
414 #[test]
415 fn test_load_missing_file() {
416 let mut cache = SpeculativeCache::default();
417 let result = cache.load(Path::new("/nonexistent/cache.json"));
418 assert!(result.is_err());
419 }
420}