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 pub fn contains_topic(&self, topic: &str) -> bool {
106 self.entries.iter().any(|e| e.topic == topic)
107 }
108
109 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 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 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#[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 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 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 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 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 cache.try_hit("hot topic", None);
411
412 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}