Skip to main content

ares_rag/
cache.rs

1//! Embedding Cache for RAG Pipeline
2//!
3//! This module provides caching for text embeddings to avoid re-computing
4//! vectors for unchanged content. This is especially valuable for:
5//!
6//! - Large document re-indexing
7//! - Frequently accessed documents
8//! - Multi-collection setups with shared documents
9//!
10//! # Cache Key Strategy
11//!
12//! Cache keys are computed as SHA-256 hashes of `text + model_name` to ensure:
13//! - Unique keys for different content
14//! - Model-specific embeddings (different models produce different vectors)
15//! - Consistent keys across restarts
16//!
17//! # Implementation
18//!
19//! Uses the `lru` crate for O(1) get/put operations with proper LRU eviction.
20//! The cache is thread-safe via `parking_lot::Mutex`.
21//!
22//! # Example
23//!
24//! ```ignore
25//! use ares::rag::cache::{EmbeddingCache, LruEmbeddingCache, CacheConfig};
26//!
27//! // Create a cache with 512MB max size
28//! let cache = LruEmbeddingCache::new(CacheConfig {
29//!     max_size_bytes: 512 * 1024 * 1024,
30//!     ..Default::default()
31//! });
32//!
33//! // Check cache before computing embedding
34//! let key = cache.compute_key("hello world", "bge-small-en-v1.5");
35//! if let Some(embedding) = cache.get(&key).await {
36//!     // Use cached embedding
37//! } else {
38//!     // Compute and cache
39//!     let embedding = embed("hello world").await?;
40//!     cache.set(&key, embedding.clone(), None).await?;
41//! }
42//! ```
43
44use std::num::NonZeroUsize;
45use std::sync::atomic::{AtomicU64, Ordering};
46use std::time::{Duration, Instant};
47
48use lru::LruCache;
49use parking_lot::Mutex;
50use serde::{Deserialize, Serialize};
51use sha2::{Digest, Sha256};
52
53use ares_types::types::Result;
54
55// ============================================================================
56// Cache Types
57// ============================================================================
58
59/// Statistics for cache performance monitoring
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61pub struct CacheStats {
62    /// Number of cache hits
63    pub hits: u64,
64    /// Number of cache misses
65    pub misses: u64,
66    /// Current size in bytes (approximate)
67    pub size_bytes: u64,
68    /// Number of entries in cache
69    pub entry_count: usize,
70    /// Number of evictions due to capacity
71    pub evictions: u64,
72}
73
74impl CacheStats {
75    /// Calculate hit rate as a percentage
76    pub fn hit_rate(&self) -> f64 {
77        let total = self.hits + self.misses;
78        if total == 0 {
79            0.0
80        } else {
81            (self.hits as f64 / total as f64) * 100.0
82        }
83    }
84}
85
86/// Configuration for the embedding cache
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct CacheConfig {
89    /// Maximum cache size in bytes (default: 256MB)
90    #[serde(default = "default_max_size_bytes")]
91    pub max_size_bytes: u64,
92
93    /// Default TTL for cache entries (None = no expiry)
94    #[serde(default)]
95    pub default_ttl: Option<Duration>,
96
97    /// Whether the cache is enabled
98    #[serde(default = "default_enabled")]
99    pub enabled: bool,
100}
101
102fn default_max_size_bytes() -> u64 {
103    256 * 1024 * 1024 // 256 MB
104}
105
106fn default_enabled() -> bool {
107    true
108}
109
110impl Default for CacheConfig {
111    fn default() -> Self {
112        Self {
113            max_size_bytes: default_max_size_bytes(),
114            default_ttl: None,
115            enabled: default_enabled(),
116        }
117    }
118}
119
120// ============================================================================
121// Cache Trait
122// ============================================================================
123
124/// Trait for embedding cache implementations
125///
126/// This trait defines the interface for caching embeddings. Implementations
127/// can use different backends (in-memory, Redis, disk, etc.).
128pub trait EmbeddingCache: Send + Sync {
129    /// Get an embedding from the cache
130    fn get(&self, key: &str) -> Option<Vec<f32>>;
131
132    /// Store an embedding in the cache with optional TTL
133    fn set(&self, key: &str, embedding: Vec<f32>, ttl: Option<Duration>) -> Result<()>;
134
135    /// Remove an entry from the cache
136    fn invalidate(&self, key: &str) -> Result<()>;
137
138    /// Clear all entries from the cache
139    fn clear(&self) -> Result<()>;
140
141    /// Get cache statistics
142    fn stats(&self) -> CacheStats;
143
144    /// Compute a cache key for the given text and model
145    fn compute_key(&self, text: &str, model: &str) -> String {
146        let mut hasher = Sha256::new();
147        hasher.update(text.as_bytes());
148        hasher.update(b"|");
149        hasher.update(model.as_bytes());
150        format!("{:x}", hasher.finalize())
151    }
152
153    /// Check if the cache is enabled
154    fn is_enabled(&self) -> bool;
155}
156
157// ============================================================================
158// LRU Cache Entry
159// ============================================================================
160
161/// A cache entry with metadata for expiration
162#[derive(Debug, Clone)]
163struct CacheEntry {
164    /// The cached embedding vector
165    embedding: Vec<f32>,
166    /// Optional expiry time
167    expires_at: Option<Instant>,
168    /// Size in bytes (approximate)
169    size_bytes: usize,
170}
171
172impl CacheEntry {
173    fn new(embedding: Vec<f32>, ttl: Option<Duration>) -> Self {
174        let now = Instant::now();
175        let size_bytes = embedding.len() * std::mem::size_of::<f32>();
176        Self {
177            embedding,
178            expires_at: ttl.map(|d| now + d),
179            size_bytes,
180        }
181    }
182
183    fn is_expired(&self) -> bool {
184        self.expires_at
185            .map(|exp| Instant::now() > exp)
186            .unwrap_or(false)
187    }
188}
189
190// ============================================================================
191// LRU Embedding Cache
192// ============================================================================
193
194/// Default maximum number of entries in the LRU cache
195const DEFAULT_MAX_ENTRIES: usize = 10_000;
196
197/// In-memory LRU cache for embeddings
198///
199/// Uses the `lru` crate for O(1) get/put operations with proper LRU eviction.
200/// Thread-safe via `parking_lot::Mutex`.
201///
202/// # Memory Management
203///
204/// The cache limits entries by count (not bytes) for simplicity and O(1) operations.
205/// The `max_size_bytes` config is used to estimate max entries based on average
206/// embedding size (assuming 384-dimensional embeddings = 1536 bytes each).
207pub struct LruEmbeddingCache {
208    /// The LRU cache storage (key -> CacheEntry)
209    cache: Mutex<LruCache<String, CacheEntry>>,
210    /// Configuration
211    config: CacheConfig,
212    /// Current size in bytes (approximate)
213    current_size: AtomicU64,
214    /// Cache hit counter
215    hits: AtomicU64,
216    /// Cache miss counter
217    misses: AtomicU64,
218    /// Eviction counter
219    evictions: AtomicU64,
220}
221
222impl LruEmbeddingCache {
223    /// Create a new LRU embedding cache with the given configuration
224    pub fn new(config: CacheConfig) -> Self {
225        // Estimate max entries from max_size_bytes
226        // Assume average embedding is 384 dimensions = 1536 bytes
227        let avg_entry_size = 384 * std::mem::size_of::<f32>(); // 1536 bytes
228        let max_entries = (config.max_size_bytes as usize / avg_entry_size).max(100);
229        let capacity = NonZeroUsize::new(max_entries)
230            .unwrap_or(NonZeroUsize::new(DEFAULT_MAX_ENTRIES).unwrap());
231
232        Self {
233            cache: Mutex::new(LruCache::new(capacity)),
234            config,
235            current_size: AtomicU64::new(0),
236            hits: AtomicU64::new(0),
237            misses: AtomicU64::new(0),
238            evictions: AtomicU64::new(0),
239        }
240    }
241
242    /// Create a cache with default configuration
243    pub fn with_defaults() -> Self {
244        Self::new(CacheConfig::default())
245    }
246
247    /// Create a cache with a specific max size in bytes
248    pub fn with_max_size(max_size_bytes: u64) -> Self {
249        Self::new(CacheConfig {
250            max_size_bytes,
251            ..Default::default()
252        })
253    }
254
255    /// Create a cache with a specific max entry count
256    pub fn with_max_entries(max_entries: usize) -> Self {
257        let capacity = NonZeroUsize::new(max_entries)
258            .unwrap_or(NonZeroUsize::new(DEFAULT_MAX_ENTRIES).unwrap());
259        Self {
260            cache: Mutex::new(LruCache::new(capacity)),
261            config: CacheConfig::default(),
262            current_size: AtomicU64::new(0),
263            hits: AtomicU64::new(0),
264            misses: AtomicU64::new(0),
265            evictions: AtomicU64::new(0),
266        }
267    }
268
269    /// Remove expired entries from the cache
270    pub fn cleanup_expired(&self) {
271        let mut cache = self.cache.lock();
272        let mut expired_keys = Vec::new();
273
274        // Collect expired keys (can't remove while iterating)
275        for (key, entry) in cache.iter() {
276            if entry.is_expired() {
277                expired_keys.push(key.clone());
278            }
279        }
280
281        // Remove expired entries
282        for key in expired_keys {
283            if let Some(entry) = cache.pop(&key) {
284                self.current_size
285                    .fetch_sub(entry.size_bytes as u64, Ordering::Relaxed);
286            }
287        }
288    }
289
290    /// Get the current cache size in bytes
291    pub fn size_bytes(&self) -> u64 {
292        self.current_size.load(Ordering::Relaxed)
293    }
294
295    /// Get the number of entries in the cache
296    pub fn len(&self) -> usize {
297        self.cache.lock().len()
298    }
299
300    /// Check if the cache is empty
301    pub fn is_empty(&self) -> bool {
302        self.cache.lock().is_empty()
303    }
304}
305
306impl EmbeddingCache for LruEmbeddingCache {
307    fn get(&self, key: &str) -> Option<Vec<f32>> {
308        if !self.config.enabled {
309            return None;
310        }
311
312        let mut cache = self.cache.lock();
313
314        // get() in lru crate automatically promotes to most recently used
315        if let Some(entry) = cache.get(key) {
316            if entry.is_expired() {
317                // Remove expired entry
318                let entry = cache.pop(key).unwrap();
319                self.current_size
320                    .fetch_sub(entry.size_bytes as u64, Ordering::Relaxed);
321                self.misses.fetch_add(1, Ordering::Relaxed);
322                return None;
323            }
324            self.hits.fetch_add(1, Ordering::Relaxed);
325            Some(entry.embedding.clone())
326        } else {
327            self.misses.fetch_add(1, Ordering::Relaxed);
328            None
329        }
330    }
331
332    fn set(&self, key: &str, embedding: Vec<f32>, ttl: Option<Duration>) -> Result<()> {
333        if !self.config.enabled {
334            return Ok(());
335        }
336
337        let entry = CacheEntry::new(embedding, ttl.or(self.config.default_ttl));
338        let entry_size = entry.size_bytes;
339
340        let mut cache = self.cache.lock();
341
342        // Remove old entry if exists (to update size tracking)
343        if let Some(old_entry) = cache.pop(key) {
344            self.current_size
345                .fetch_sub(old_entry.size_bytes as u64, Ordering::Relaxed);
346        }
347
348        // Check if cache is at capacity before push
349        let was_at_capacity = cache.len() == cache.cap().get();
350
351        // Push new entry (LRU eviction happens automatically if at capacity)
352        if let Some((_, evicted)) = cache.push(key.to_string(), entry) {
353            // An entry was evicted
354            self.current_size
355                .fetch_sub(evicted.size_bytes as u64, Ordering::Relaxed);
356            self.evictions.fetch_add(1, Ordering::Relaxed);
357        } else if was_at_capacity {
358            // We were at capacity but push didn't return evicted (shouldn't happen)
359            // but handle it just in case
360            self.evictions.fetch_add(1, Ordering::Relaxed);
361        }
362
363        // Update size
364        self.current_size
365            .fetch_add(entry_size as u64, Ordering::Relaxed);
366
367        Ok(())
368    }
369
370    fn invalidate(&self, key: &str) -> Result<()> {
371        let mut cache = self.cache.lock();
372        if let Some(entry) = cache.pop(key) {
373            self.current_size
374                .fetch_sub(entry.size_bytes as u64, Ordering::Relaxed);
375        }
376        Ok(())
377    }
378
379    fn clear(&self) -> Result<()> {
380        let mut cache = self.cache.lock();
381        cache.clear();
382        self.current_size.store(0, Ordering::Relaxed);
383        Ok(())
384    }
385
386    fn stats(&self) -> CacheStats {
387        CacheStats {
388            hits: self.hits.load(Ordering::Relaxed),
389            misses: self.misses.load(Ordering::Relaxed),
390            size_bytes: self.current_size.load(Ordering::Relaxed),
391            entry_count: self.cache.lock().len(),
392            evictions: self.evictions.load(Ordering::Relaxed),
393        }
394    }
395
396    fn is_enabled(&self) -> bool {
397        self.config.enabled
398    }
399}
400
401// ============================================================================
402// No-Op Cache
403// ============================================================================
404
405/// A no-op cache that doesn't store anything
406///
407/// Useful for disabling caching without changing the code structure.
408#[derive(Debug, Default)]
409pub struct NoOpCache;
410
411impl NoOpCache {
412    /// Create a new no-op cache
413    pub fn new() -> Self {
414        Self
415    }
416}
417
418impl EmbeddingCache for NoOpCache {
419    fn get(&self, _key: &str) -> Option<Vec<f32>> {
420        None
421    }
422
423    fn set(&self, _key: &str, _embedding: Vec<f32>, _ttl: Option<Duration>) -> Result<()> {
424        Ok(())
425    }
426
427    fn invalidate(&self, _key: &str) -> Result<()> {
428        Ok(())
429    }
430
431    fn clear(&self) -> Result<()> {
432        Ok(())
433    }
434
435    fn stats(&self) -> CacheStats {
436        CacheStats::default()
437    }
438
439    fn is_enabled(&self) -> bool {
440        false
441    }
442}
443
444// ============================================================================
445// Tests
446// ============================================================================
447
448#[cfg(test)]
449mod tests {
450    #[test]
451    fn test_cache_concurrent_reads_and_writes() {
452        use std::sync::Arc;
453        use std::thread;
454
455        let cache = Arc::new(LruEmbeddingCache::with_max_entries(100));
456        let mut handles = Vec::new();
457        for i in 0..8 {
458            let cache = Arc::clone(&cache);
459            handles.push(thread::spawn(move || {
460                let key = format!("key-{i}");
461                let embedding = vec![i as f32; 4];
462                cache.set(&key, embedding, None).unwrap();
463                for _ in 0..10 {
464                    let _ = cache.get(&key);
465                }
466            }));
467        }
468        for h in handles {
469            h.join().unwrap();
470        }
471        assert!(!cache.is_empty());
472    }
473
474    use super::*;
475
476    #[test]
477    fn test_cache_key_computation() {
478        let cache = LruEmbeddingCache::with_defaults();
479
480        let key1 = cache.compute_key("hello world", "bge-small-en-v1.5");
481        let key2 = cache.compute_key("hello world", "bge-small-en-v1.5");
482        let key3 = cache.compute_key("hello world", "bge-base-en-v1.5");
483        let key4 = cache.compute_key("different text", "bge-small-en-v1.5");
484
485        // Same input should produce same key
486        assert_eq!(key1, key2);
487        // Different model should produce different key
488        assert_ne!(key1, key3);
489        // Different text should produce different key
490        assert_ne!(key1, key4);
491    }
492
493    #[test]
494    fn test_cache_set_and_get() {
495        let cache = LruEmbeddingCache::with_defaults();
496        let key = "test_key";
497        let embedding = vec![1.0, 2.0, 3.0, 4.0];
498
499        // Initially empty
500        assert!(cache.get(key).is_none());
501        assert_eq!(cache.stats().misses, 1);
502
503        // Set and get
504        cache.set(key, embedding.clone(), None).unwrap();
505        let retrieved = cache.get(key);
506
507        assert!(retrieved.is_some());
508        assert_eq!(retrieved.unwrap(), embedding);
509        assert_eq!(cache.stats().hits, 1);
510    }
511
512    #[test]
513    fn test_cache_invalidate() {
514        let cache = LruEmbeddingCache::with_defaults();
515        let key = "test_key";
516        let embedding = vec![1.0, 2.0, 3.0];
517
518        cache.set(key, embedding, None).unwrap();
519        assert!(cache.get(key).is_some());
520
521        cache.invalidate(key).unwrap();
522        assert!(cache.get(key).is_none());
523    }
524
525    #[test]
526    fn test_cache_clear() {
527        let cache = LruEmbeddingCache::with_defaults();
528
529        cache.set("key1", vec![1.0, 2.0], None).unwrap();
530        cache.set("key2", vec![3.0, 4.0], None).unwrap();
531
532        assert_eq!(cache.len(), 2);
533        assert!(cache.size_bytes() > 0);
534
535        cache.clear().unwrap();
536
537        assert_eq!(cache.len(), 0);
538        assert_eq!(cache.size_bytes(), 0);
539    }
540
541    #[test]
542    fn test_cache_lru_eviction() {
543        // Create a small cache with exactly 2 entries to test LRU eviction
544        let cache = LruEmbeddingCache::with_max_entries(2);
545
546        let embedding1 = vec![1.0, 2.0, 3.0, 4.0];
547        let embedding2 = vec![5.0, 6.0, 7.0, 8.0];
548        let embedding3 = vec![9.0, 10.0, 11.0, 12.0];
549
550        cache.set("key1", embedding1.clone(), None).unwrap();
551        cache.set("key2", embedding2.clone(), None).unwrap();
552
553        // Both should be present
554        assert!(cache.get("key1").is_some());
555        assert!(cache.get("key2").is_some());
556
557        // Adding a third should evict the LRU (key1, since key2 was accessed more recently)
558        cache.set("key3", embedding3.clone(), None).unwrap();
559
560        // key1 should be evicted
561        assert!(cache.get("key1").is_none());
562        // key2 and key3 should exist
563        assert!(cache.get("key2").is_some());
564        assert!(cache.get("key3").is_some());
565
566        assert!(cache.stats().evictions > 0);
567    }
568
569    #[test]
570    fn test_cache_ttl_expiry() {
571        let cache = LruEmbeddingCache::with_defaults();
572        let key = "test_key";
573        let embedding = vec![1.0, 2.0, 3.0];
574
575        // Set with 0 duration TTL (immediate expiry)
576        cache
577            .set(key, embedding, Some(Duration::from_nanos(1)))
578            .unwrap();
579
580        // Sleep briefly to ensure expiry
581        std::thread::sleep(Duration::from_millis(1));
582
583        // Should be expired
584        assert!(cache.get(key).is_none());
585    }
586
587    #[test]
588    fn test_cache_stats() {
589        let cache = LruEmbeddingCache::with_defaults();
590
591        // Generate some activity
592        cache.set("key1", vec![1.0, 2.0], None).unwrap();
593        let _ = cache.get("key1"); // hit
594        let _ = cache.get("key2"); // miss
595        let _ = cache.get("key3"); // miss
596
597        let stats = cache.stats();
598        assert_eq!(stats.hits, 1);
599        assert_eq!(stats.misses, 2);
600        assert_eq!(stats.entry_count, 1);
601        assert!(stats.size_bytes > 0);
602    }
603
604    #[test]
605    fn test_cache_hit_rate() {
606        let stats = CacheStats {
607            hits: 75,
608            misses: 25,
609            size_bytes: 0,
610            entry_count: 0,
611            evictions: 0,
612        };
613
614        assert!((stats.hit_rate() - 75.0).abs() < 0.001);
615    }
616
617    #[test]
618    fn test_noop_cache() {
619        let cache = NoOpCache::new();
620
621        // Set should succeed but not store
622        cache.set("key", vec![1.0, 2.0], None).unwrap();
623
624        // Get should always return None
625        assert!(cache.get("key").is_none());
626
627        // Stats should be empty
628        let stats = cache.stats();
629        assert_eq!(stats.hits, 0);
630        assert_eq!(stats.misses, 0);
631        assert!(!cache.is_enabled());
632    }
633
634    #[test]
635    fn test_cache_disabled() {
636        let cache = LruEmbeddingCache::new(CacheConfig {
637            enabled: false,
638            ..Default::default()
639        });
640
641        // Set should succeed but not store
642        cache.set("key", vec![1.0, 2.0], None).unwrap();
643
644        // Get should return None when disabled
645        assert!(cache.get("key").is_none());
646        assert!(!cache.is_enabled());
647    }
648
649    #[test]
650    fn test_cache_update_existing() {
651        let cache = LruEmbeddingCache::with_defaults();
652        let key = "test_key";
653
654        cache.set(key, vec![1.0, 2.0], None).unwrap();
655        let size1 = cache.size_bytes();
656
657        // Update with different embedding
658        cache.set(key, vec![3.0, 4.0, 5.0, 6.0], None).unwrap();
659        let size2 = cache.size_bytes();
660
661        // Size should have changed (old removed, new added)
662        assert!(size2 > size1);
663        assert_eq!(cache.len(), 1);
664
665        // Should get the new value
666        let retrieved = cache.get(key).unwrap();
667        assert_eq!(retrieved, vec![3.0, 4.0, 5.0, 6.0]);
668    }
669    #[test]
670    fn test_cache_hit_rate_zero_requests() {
671        let stats = CacheStats::default();
672        assert_eq!(stats.hit_rate(), 0.0);
673    }
674
675    #[test]
676    fn test_cache_cleanup_expired() {
677        let cache = LruEmbeddingCache::with_max_entries(10);
678        cache
679            .set("expired", vec![1.0], Some(Duration::from_nanos(1)))
680            .unwrap();
681        cache.set("fresh", vec![2.0], None).unwrap();
682        std::thread::sleep(Duration::from_millis(2));
683        cache.cleanup_expired();
684        assert!(cache.get("expired").is_none());
685        assert!(cache.get("fresh").is_some());
686    }
687
688    #[test]
689    fn test_cache_with_max_size_constructor() {
690        let cache = LruEmbeddingCache::with_max_size(10_000);
691        assert!(cache.is_empty());
692        cache.set("k", vec![1.0; 8], None).unwrap();
693        assert_eq!(cache.len(), 1);
694    }
695
696    #[test]
697    fn test_cache_is_empty() {
698        let cache = LruEmbeddingCache::with_defaults();
699        assert!(cache.is_empty());
700        cache.set("k", vec![1.0], None).unwrap();
701        assert!(!cache.is_empty());
702    }
703
704    #[test]
705    fn test_cache_config_serde_roundtrip() {
706        let config = CacheConfig {
707            max_size_bytes: 1024,
708            default_ttl: Some(Duration::from_secs(60)),
709            enabled: true,
710        };
711        let parsed: CacheConfig = serde_json::from_str(&serde_json::to_string(&config).unwrap())
712            .unwrap();
713        assert_eq!(parsed.max_size_bytes, 1024);
714        assert!(parsed.enabled);
715        assert_eq!(parsed.default_ttl, Some(Duration::from_secs(60)));
716    }
717
718    #[test]
719    fn test_cache_get_drops_expired_entry() {
720        let cache = LruEmbeddingCache::with_defaults();
721        cache
722            .set("k", vec![1.0, 2.0], Some(Duration::from_nanos(1)))
723            .unwrap();
724        std::thread::sleep(Duration::from_millis(2));
725        assert!(cache.get("k").is_none());
726        assert_eq!(cache.len(), 0);
727    }
728
729    #[test]
730    fn test_noop_cache_invalidate_and_clear() {
731        let cache = NoOpCache::new();
732        cache.invalidate("missing").unwrap();
733        cache.clear().unwrap();
734    }
735
736    #[test]
737    fn test_cache_stats_serde_roundtrip() {
738        let stats = CacheStats {
739            hits: 10,
740            misses: 5,
741            size_bytes: 4096,
742            entry_count: 3,
743            evictions: 2,
744        };
745        let parsed: CacheStats =
746            serde_json::from_str(&serde_json::to_string(&stats).unwrap()).unwrap();
747        assert_eq!(parsed.hits, 10);
748        assert_eq!(parsed.misses, 5);
749        assert_eq!(parsed.size_bytes, 4096);
750        assert_eq!(parsed.entry_count, 3);
751        assert_eq!(parsed.evictions, 2);
752        assert!((parsed.hit_rate() - stats.hit_rate()).abs() < f64::EPSILON);
753    }
754
755    #[test]
756    fn test_cache_config_serde_defaults() {
757        let parsed: CacheConfig = serde_json::from_str("{}").unwrap();
758        assert_eq!(parsed.max_size_bytes, 256 * 1024 * 1024);
759        assert!(parsed.enabled);
760        assert_eq!(parsed.default_ttl, None);
761
762        let partial: CacheConfig =
763            serde_json::from_str(r#"{"enabled":false,"max_size_bytes":512}"#).unwrap();
764        assert!(!partial.enabled);
765        assert_eq!(partial.max_size_bytes, 512);
766        assert_eq!(partial.default_ttl, None);
767    }
768
769    #[test]
770    fn test_cache_key_sha256_properties() {
771        let cache = LruEmbeddingCache::with_defaults();
772
773        let mut hasher = Sha256::new();
774        hasher.update(b"hello");
775        hasher.update(b"|");
776        hasher.update(b"model");
777        let expected = format!("{:x}", hasher.finalize());
778
779        let key = cache.compute_key("hello", "model");
780        assert_eq!(key, expected);
781        assert_eq!(key.len(), 64);
782        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
783
784        assert_eq!(cache.compute_key("", ""), cache.compute_key("", ""));
785        assert_ne!(cache.compute_key("", ""), cache.compute_key("", "x"));
786        // Pipe separator is ambiguous when text or model contain '|'.
787        assert_eq!(
788            cache.compute_key("a|b", "c"),
789            cache.compute_key("a", "b|c")
790        );
791        assert_ne!(
792            cache.compute_key("hello", "model-a"),
793            cache.compute_key("hello", "model-b")
794        );
795        assert_ne!(cache.compute_key("α", "m"), cache.compute_key("a", "m"));
796    }
797
798    #[test]
799    fn test_cache_uses_config_default_ttl() {
800        let cache = LruEmbeddingCache::new(CacheConfig {
801            default_ttl: Some(Duration::from_nanos(1)),
802            ..Default::default()
803        });
804
805        cache.set("k", vec![1.0, 2.0], None).unwrap();
806        std::thread::sleep(Duration::from_millis(2));
807
808        assert!(cache.get("k").is_none());
809        assert_eq!(cache.stats().misses, 1);
810    }
811
812    #[test]
813    fn test_cache_lru_get_promotes_entry() {
814        let cache = LruEmbeddingCache::with_max_entries(2);
815
816        cache.set("key1", vec![1.0], None).unwrap();
817        cache.set("key2", vec![2.0], None).unwrap();
818
819        // Promote key1 so key2 becomes LRU before inserting key3.
820        assert!(cache.get("key1").is_some());
821
822        cache.set("key3", vec![3.0], None).unwrap();
823
824        assert!(cache.get("key1").is_some());
825        assert!(cache.get("key3").is_some());
826        assert!(cache.get("key2").is_none());
827        assert!(cache.stats().evictions >= 1);
828    }
829
830    #[test]
831    fn test_cache_invalidate_missing_key_is_noop() {
832        let cache = LruEmbeddingCache::with_defaults();
833        cache.set("present", vec![1.0, 2.0], None).unwrap();
834
835        let bytes_before = cache.size_bytes();
836        let len_before = cache.len();
837
838        cache.invalidate("absent").unwrap();
839
840        assert_eq!(cache.size_bytes(), bytes_before);
841        assert_eq!(cache.len(), len_before);
842        assert!(cache.get("present").is_some());
843    }
844
845    #[test]
846    fn test_cache_cleanup_expired_noop_when_fresh() {
847        let cache = LruEmbeddingCache::with_max_entries(4);
848        cache.set("fresh", vec![1.0, 2.0], None).unwrap();
849
850        cache.cleanup_expired();
851
852        assert_eq!(cache.len(), 1);
853        assert!(cache.get("fresh").is_some());
854    }
855
856    /// Mock in-memory backend for trait-level integration tests.
857    struct MockEmbeddingCache {
858        store: Mutex<std::collections::HashMap<String, Vec<f32>>>,
859    }
860
861    impl MockEmbeddingCache {
862        fn new() -> Self {
863            Self {
864                store: Mutex::new(std::collections::HashMap::new()),
865            }
866        }
867    }
868
869    impl EmbeddingCache for MockEmbeddingCache {
870        fn get(&self, key: &str) -> Option<Vec<f32>> {
871            self.store.lock().get(key).cloned()
872        }
873
874        fn set(&self, key: &str, embedding: Vec<f32>, _ttl: Option<Duration>) -> Result<()> {
875            self.store.lock().insert(key.to_string(), embedding);
876            Ok(())
877        }
878
879        fn invalidate(&self, key: &str) -> Result<()> {
880            self.store.lock().remove(key);
881            Ok(())
882        }
883
884        fn clear(&self) -> Result<()> {
885            self.store.lock().clear();
886            Ok(())
887        }
888
889        fn stats(&self) -> CacheStats {
890            CacheStats {
891                entry_count: self.store.lock().len(),
892                ..Default::default()
893            }
894        }
895
896        fn is_enabled(&self) -> bool {
897            true
898        }
899    }
900
901    fn exercise_embedding_cache(cache: &dyn EmbeddingCache) {
902        let key = cache.compute_key("integration text", "test-model");
903        assert!(cache.get(&key).is_none());
904
905        let embedding = vec![0.1, 0.2, 0.3];
906        cache.set(&key, embedding.clone(), None).unwrap();
907        assert_eq!(cache.get(&key), Some(embedding));
908
909        cache.invalidate(&key).unwrap();
910        assert!(cache.get(&key).is_none());
911
912        cache.set(&key, vec![9.0], None).unwrap();
913        cache.clear().unwrap();
914        assert!(cache.get(&key).is_none());
915        assert_eq!(cache.stats().entry_count, 0);
916    }
917
918    #[test]
919    fn test_mock_storage_backend_integration() {
920        exercise_embedding_cache(&MockEmbeddingCache::new());
921    }
922
923    #[test]
924    fn test_lru_cache_trait_object_integration() {
925        let cache: Box<dyn EmbeddingCache> = Box::new(LruEmbeddingCache::with_max_entries(8));
926        exercise_embedding_cache(cache.as_ref());
927        assert!(cache.is_enabled());
928    }
929
930
931}
932