1use std::collections::HashMap;
8
9#[derive(Debug, Clone)]
15pub struct CachedEmbedding {
16 pub key: String,
18 pub embedding: Vec<f64>,
20 pub computed_tick: u64,
22 pub access_count: u64,
24 pub ttl_ticks: u64,
26 pub insertion_seq: u64,
28}
29
30#[derive(Debug, Clone)]
32pub struct EmbeddingCacheConfig {
33 pub max_entries: usize,
35 pub default_ttl_ticks: u64,
37 pub eviction_batch_size: usize,
39}
40
41impl Default for EmbeddingCacheConfig {
42 fn default() -> Self {
43 Self {
44 max_entries: 10_000,
45 default_ttl_ticks: 500,
46 eviction_batch_size: 100,
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct EmbeddingCacheStats {
54 pub entry_count: usize,
56 pub hits: u64,
58 pub misses: u64,
60 pub evictions: u64,
62 pub hit_rate: f64,
64 pub memory_bytes_estimate: usize,
66}
67
68pub struct SemanticEmbeddingCache {
74 config: EmbeddingCacheConfig,
75 entries: HashMap<String, CachedEmbedding>,
76 current_tick: u64,
77 next_insertion_seq: u64,
78 hits: u64,
79 misses: u64,
80 evictions: u64,
81}
82
83impl SemanticEmbeddingCache {
84 pub fn new(config: EmbeddingCacheConfig) -> Self {
86 Self {
87 entries: HashMap::with_capacity(config.max_entries),
88 config,
89 current_tick: 0,
90 next_insertion_seq: 0,
91 hits: 0,
92 misses: 0,
93 evictions: 0,
94 }
95 }
96
97 pub fn put(&mut self, key: &str, embedding: Vec<f64>, ttl: Option<u64>) {
104 let ttl_ticks = ttl.unwrap_or(self.config.default_ttl_ticks);
105 let insertion_seq = self.next_insertion_seq;
106 self.next_insertion_seq += 1;
107 let entry = CachedEmbedding {
108 key: key.to_string(),
109 embedding,
110 computed_tick: self.current_tick,
111 access_count: 0,
112 ttl_ticks,
113 insertion_seq,
114 };
115 self.entries.insert(key.to_string(), entry);
116
117 if self.entries.len() > self.config.max_entries {
118 self.evict_batch();
119 }
120 }
121
122 pub fn get(&mut self, key: &str) -> Option<&[f64]> {
127 let expired = self
129 .entries
130 .get(key)
131 .map(|e| self.current_tick >= e.computed_tick + e.ttl_ticks)
132 .unwrap_or(true);
133
134 if expired {
135 self.entries.remove(key);
137 self.misses += 1;
138 return None;
139 }
140
141 self.hits += 1;
142 let entry = self
144 .entries
145 .get_mut(key)
146 .expect("entry must exist after non-expired check");
147 entry.access_count += 1;
148 Some(&entry.embedding)
149 }
150
151 pub fn contains(&self, key: &str) -> bool {
154 self.entries
155 .get(key)
156 .is_some_and(|e| self.current_tick < e.computed_tick + e.ttl_ticks)
157 }
158
159 pub fn invalidate(&mut self, key: &str) -> bool {
161 self.entries.remove(key).is_some()
162 }
163
164 pub fn invalidate_prefix(&mut self, prefix: &str) -> usize {
167 let keys_to_remove: Vec<String> = self
168 .entries
169 .keys()
170 .filter(|k| k.starts_with(prefix))
171 .cloned()
172 .collect();
173 let count = keys_to_remove.len();
174 for k in keys_to_remove {
175 self.entries.remove(&k);
176 }
177 count
178 }
179
180 pub fn tick_cleanup(&mut self) {
182 self.current_tick += 1;
183 self.entries
184 .retain(|_, e| self.current_tick < e.computed_tick + e.ttl_ticks);
185 }
186
187 pub fn entry_count(&self) -> usize {
189 self.entries.len()
190 }
191
192 pub fn hit_rate(&self) -> f64 {
196 let total = self.hits + self.misses;
197 if total == 0 {
198 0.0
199 } else {
200 self.hits as f64 / total as f64
201 }
202 }
203
204 pub fn memory_estimate(&self) -> usize {
209 self.entries
210 .values()
211 .map(|e| e.key.len() + e.embedding.len() * 8)
212 .sum()
213 }
214
215 pub fn stats(&self) -> EmbeddingCacheStats {
217 EmbeddingCacheStats {
218 entry_count: self.entry_count(),
219 hits: self.hits,
220 misses: self.misses,
221 evictions: self.evictions,
222 hit_rate: self.hit_rate(),
223 memory_bytes_estimate: self.memory_estimate(),
224 }
225 }
226
227 fn evict_batch(&mut self) {
236 let batch = self.config.eviction_batch_size;
237 if batch == 0 || self.entries.is_empty() {
238 return;
239 }
240
241 let mut candidates: Vec<(String, u64, u64, u64)> = self
243 .entries
244 .iter()
245 .map(|(k, e)| (k.clone(), e.access_count, e.computed_tick, e.insertion_seq))
246 .collect();
247
248 candidates.sort_by(|a, b| {
250 a.1.cmp(&b.1)
251 .then_with(|| a.2.cmp(&b.2))
252 .then_with(|| a.3.cmp(&b.3))
253 });
254
255 let to_remove = batch.min(candidates.len());
256 for (key, _, _, _) in candidates.into_iter().take(to_remove) {
257 self.entries.remove(&key);
258 self.evictions += 1;
259 }
260 }
261}
262
263#[cfg(test)]
268mod tests {
269 use super::*;
270
271 fn default_cache() -> SemanticEmbeddingCache {
272 SemanticEmbeddingCache::new(EmbeddingCacheConfig::default())
273 }
274
275 fn small_cache(max: usize, ttl: u64, batch: usize) -> SemanticEmbeddingCache {
276 SemanticEmbeddingCache::new(EmbeddingCacheConfig {
277 max_entries: max,
278 default_ttl_ticks: ttl,
279 eviction_batch_size: batch,
280 })
281 }
282
283 #[test]
285 fn test_put_get_roundtrip() {
286 let mut c = default_cache();
287 c.put("a", vec![1.0, 2.0, 3.0], None);
288 let v = c.get("a");
289 assert!(v.is_some());
290 assert_eq!(v.map(|s| s.to_vec()), Some(vec![1.0, 2.0, 3.0]));
291 }
292
293 #[test]
295 fn test_get_missing() {
296 let mut c = default_cache();
297 assert!(c.get("no_such_key").is_none());
298 }
299
300 #[test]
302 fn test_ttl_expiry() {
303 let mut c = small_cache(100, 2, 10);
304 c.put("x", vec![1.0], None); assert!(c.get("x").is_some()); c.tick_cleanup(); assert!(c.get("x").is_some());
308 c.tick_cleanup(); assert!(c.get("x").is_none());
310 }
311
312 #[test]
314 fn test_custom_ttl() {
315 let mut c = small_cache(100, 100, 10);
316 c.put("short", vec![1.0], Some(1));
317 c.tick_cleanup(); assert!(c.get("short").is_none());
319 }
320
321 #[test]
323 fn test_lru_eviction_triggers() {
324 let mut c = small_cache(5, 1000, 2);
325 for i in 0..5 {
326 c.put(&format!("k{i}"), vec![i as f64], None);
327 }
328 assert_eq!(c.entry_count(), 5);
329 let _ = c.get("k3");
331 let _ = c.get("k4");
332 c.put("k5", vec![5.0], None);
334 assert!(c.entry_count() <= 5);
335 assert!(c.contains("k3"));
337 assert!(c.contains("k4"));
338 }
339
340 #[test]
342 fn test_eviction_counter() {
343 let mut c = small_cache(3, 1000, 2);
344 for i in 0..4 {
345 c.put(&format!("e{i}"), vec![0.0], None);
346 }
347 assert_eq!(c.evictions, 2);
348 }
349
350 #[test]
352 fn test_hit_counting() {
353 let mut c = default_cache();
354 c.put("h", vec![1.0], None);
355 let _ = c.get("h");
356 let _ = c.get("h");
357 assert_eq!(c.hits, 2);
358 }
359
360 #[test]
362 fn test_miss_counting() {
363 let mut c = default_cache();
364 let _ = c.get("nope");
365 let _ = c.get("nope2");
366 assert_eq!(c.misses, 2);
367 }
368
369 #[test]
371 fn test_hit_rate() {
372 let mut c = default_cache();
373 c.put("r", vec![1.0], None);
374 let _ = c.get("r"); let _ = c.get("miss"); let rate = c.hit_rate();
377 assert!((rate - 0.5).abs() < 1e-10);
378 }
379
380 #[test]
382 fn test_hit_rate_empty() {
383 let c = default_cache();
384 assert!((c.hit_rate() - 0.0).abs() < 1e-10);
385 }
386
387 #[test]
389 fn test_invalidate_existing() {
390 let mut c = default_cache();
391 c.put("del", vec![1.0], None);
392 assert!(c.invalidate("del"));
393 assert!(!c.contains("del"));
394 }
395
396 #[test]
398 fn test_invalidate_missing() {
399 let mut c = default_cache();
400 assert!(!c.invalidate("ghost"));
401 }
402
403 #[test]
405 fn test_invalidate_prefix() {
406 let mut c = default_cache();
407 c.put("img:a", vec![1.0], None);
408 c.put("img:b", vec![2.0], None);
409 c.put("txt:c", vec![3.0], None);
410 let removed = c.invalidate_prefix("img:");
411 assert_eq!(removed, 2);
412 assert_eq!(c.entry_count(), 1);
413 assert!(c.contains("txt:c"));
414 }
415
416 #[test]
418 fn test_invalidate_prefix_none() {
419 let mut c = default_cache();
420 c.put("abc", vec![1.0], None);
421 assert_eq!(c.invalidate_prefix("xyz"), 0);
422 }
423
424 #[test]
426 fn test_contains_no_access_update() {
427 let mut c = default_cache();
428 c.put("c", vec![1.0], None);
429 assert!(c.contains("c"));
430 assert!(c.contains("c"));
431 let entry = c.entries.get("c");
432 assert!(entry.is_some());
433 assert_eq!(entry.map(|e| e.access_count), Some(0));
434 }
435
436 #[test]
438 fn test_contains_expired() {
439 let mut c = small_cache(100, 1, 10);
440 c.put("e", vec![1.0], None);
441 c.tick_cleanup(); assert!(!c.contains("e"));
443 }
444
445 #[test]
447 fn test_tick_cleanup() {
448 let mut c = small_cache(100, 2, 10);
449 c.put("a", vec![1.0], None);
450 c.put("b", vec![2.0], Some(5));
451 c.tick_cleanup(); c.tick_cleanup(); assert_eq!(c.entry_count(), 1);
454 assert!(c.contains("b"));
455 }
456
457 #[test]
459 fn test_memory_estimate() {
460 let mut c = default_cache();
461 c.put("ab", vec![1.0, 2.0, 3.0], None);
463 assert_eq!(c.memory_estimate(), 26);
464 }
465
466 #[test]
468 fn test_memory_estimate_multiple() {
469 let mut c = default_cache();
470 c.put("a", vec![1.0], None); c.put("bb", vec![1.0, 2.0], None); assert_eq!(c.memory_estimate(), 27);
473 }
474
475 #[test]
477 fn test_stats_accuracy() {
478 let mut c = small_cache(5, 1000, 2);
479 c.put("s1", vec![1.0], None);
480 c.put("s2", vec![2.0], None);
481 let _ = c.get("s1"); let _ = c.get("nope"); let s = c.stats();
484 assert_eq!(s.entry_count, 2);
485 assert_eq!(s.hits, 1);
486 assert_eq!(s.misses, 1);
487 assert!((s.hit_rate - 0.5).abs() < 1e-10);
488 assert!(s.memory_bytes_estimate > 0);
489 }
490
491 #[test]
493 fn test_empty_cache() {
494 let c = default_cache();
495 assert_eq!(c.entry_count(), 0);
496 assert_eq!(c.memory_estimate(), 0);
497 assert_eq!(c.hits, 0);
498 assert_eq!(c.misses, 0);
499 }
500
501 #[test]
503 fn test_batch_eviction_size() {
504 let mut c = small_cache(10, 1000, 5);
505 for i in 0..11 {
506 c.put(&format!("b{i}"), vec![0.0], None);
507 }
508 assert_eq!(c.entry_count(), 6);
510 assert_eq!(c.evictions, 5);
511 }
512
513 #[test]
515 fn test_overwrite_key() {
516 let mut c = default_cache();
517 c.put("ow", vec![1.0], None);
518 let _ = c.get("ow"); c.put("ow", vec![9.0], None);
520 let v = c.get("ow");
521 assert_eq!(v.map(|s| s.to_vec()), Some(vec![9.0]));
522 assert_eq!(c.entries.get("ow").map(|e| e.access_count), Some(1));
524 }
525
526 #[test]
528 fn test_default_config() {
529 let cfg = EmbeddingCacheConfig::default();
530 assert_eq!(cfg.max_entries, 10_000);
531 assert_eq!(cfg.default_ttl_ticks, 500);
532 assert_eq!(cfg.eviction_batch_size, 100);
533 }
534
535 #[test]
537 fn test_large_embedding() {
538 let mut c = default_cache();
539 let big = vec![0.42; 768];
540 c.put("big", big.clone(), None);
541 let v = c.get("big");
542 assert_eq!(v.map(|s| s.len()), Some(768));
543 assert_eq!(v.map(|s| s[0]), Some(0.42));
544 }
545
546 #[test]
548 fn test_multiple_tick_cleanups() {
549 let mut c = small_cache(100, 3, 10);
550 c.put("t1", vec![1.0], Some(1)); c.put("t2", vec![2.0], Some(2)); c.put("t3", vec![3.0], Some(3)); c.tick_cleanup(); assert_eq!(c.entry_count(), 2);
555 c.tick_cleanup(); assert_eq!(c.entry_count(), 1);
557 c.tick_cleanup(); assert_eq!(c.entry_count(), 0);
559 }
560
561 #[test]
563 fn test_eviction_prefers_lowest_access() {
564 let mut c = small_cache(4, 1000, 1);
565 c.put("lo1", vec![0.0], None);
566 c.put("lo2", vec![0.0], None);
567 c.put("hi1", vec![0.0], None);
568 c.put("hi2", vec![0.0], None);
569
570 for _ in 0..5 {
572 let _ = c.get("hi1");
573 let _ = c.get("hi2");
574 let _ = c.get("lo2");
575 }
576
577 c.put("new", vec![0.0], None);
579 assert!(!c.contains("lo1"));
581 assert!(c.contains("hi1"));
582 assert!(c.contains("hi2"));
583 assert!(c.contains("lo2"));
584 }
585
586 #[test]
588 fn test_stats_evictions() {
589 let mut c = small_cache(3, 1000, 1);
590 c.put("a", vec![0.0], None);
591 c.put("b", vec![0.0], None);
592 c.put("c", vec![0.0], None);
593 c.put("d", vec![0.0], None); let s = c.stats();
595 assert_eq!(s.evictions, 1);
596 }
597
598 #[test]
600 fn test_invalidate_prefix_empty() {
601 let mut c = default_cache();
602 c.put("a", vec![1.0], None);
603 c.put("b", vec![2.0], None);
604 let removed = c.invalidate_prefix("");
605 assert_eq!(removed, 2);
606 assert_eq!(c.entry_count(), 0);
607 }
608
609 #[test]
611 fn test_get_expired_records_miss() {
612 let mut c = small_cache(100, 1, 10);
613 c.put("exp", vec![1.0], None);
614 c.tick_cleanup(); let _ = c.get("exp");
616 assert_eq!(c.misses, 1);
617 assert_eq!(c.hits, 0);
618 }
619}