Skip to main content

ai_model_vault/rag/
cache.rs

1//! Cache management for retrieval optimization.
2
3use crate::error::Result;
4use std::collections::HashMap;
5
6use super::documents::Document;
7
8/// Cache for retrieval optimization
9pub struct RetrievalCache {
10    cache: HashMap<String, CachedResult>,
11    max_size: usize,
12    current_size: usize,
13    /// Strictly increasing stamp handed out on every insert.
14    next_seq: u64,
15}
16
17#[derive(Debug, Clone)]
18struct CachedResult {
19    results: Vec<Document>,
20    timestamp: std::time::SystemTime,
21    access_count: usize,
22    /// Final tie-breaker for eviction. Two entries inserted within the same
23    /// clock tick carry *equal* `timestamp`s, and with equal `access_count`
24    /// the scan below then picked whichever the randomized `HashMap`
25    /// iteration order reached first. This counter makes the choice
26    /// deterministic and survives the non-monotonicity of `SystemTime`.
27    seq: u64,
28}
29
30impl RetrievalCache {
31    /// Create a new retrieval cache
32    pub fn new(max_size: usize) -> Self {
33        Self {
34            cache: HashMap::new(),
35            max_size,
36            current_size: 0,
37            next_seq: 0,
38        }
39    }
40
41    /// Generate hash for query
42    fn hash_query(&self, query: &str) -> String {
43        use sha2::{Digest, Sha256};
44        let mut hasher = Sha256::new();
45        hasher.update(query.as_bytes());
46        hex::encode(hasher.finalize())
47    }
48
49    /// Cache retrieval results
50    pub fn cache_results(&mut self, query: &str, results: Vec<Document>) -> Result<()> {
51        let query_hash = self.hash_query(query);
52
53        let result_size = results.iter().map(|d| d.content.len()).sum::<usize>();
54
55        // Evict if necessary
56        while self.current_size + result_size > self.max_size && !self.cache.is_empty() {
57            self.evict_lru();
58        }
59
60        if result_size <= self.max_size {
61            let seq = self.next_seq;
62            self.next_seq += 1;
63            self.cache.insert(
64                query_hash,
65                CachedResult {
66                    results,
67                    timestamp: std::time::SystemTime::now(),
68                    access_count: 0,
69                    seq,
70                },
71            );
72            self.current_size += result_size;
73        }
74
75        Ok(())
76    }
77
78    /// Get cached results
79    pub fn get_cached(&mut self, query: &str) -> Option<Vec<Document>> {
80        let query_hash = self.hash_query(query);
81
82        if let Some(cached) = self.cache.get_mut(&query_hash) {
83            cached.access_count += 1;
84            Some(cached.results.clone())
85        } else {
86            None
87        }
88    }
89
90    /// Evict least recently used entry (lowest access_count; oldest timestamp
91    /// breaks ties; insertion order breaks those).
92    fn evict_lru(&mut self) {
93        if let Some((key_to_remove, size)) = self
94            .cache
95            .iter()
96            .min_by(|(_, a), (_, b)| {
97                a.access_count
98                    .cmp(&b.access_count)
99                    .then_with(|| a.timestamp.cmp(&b.timestamp))
100                    .then_with(|| a.seq.cmp(&b.seq))
101            })
102            .map(|(k, v)| {
103                let size = v.results.iter().map(|d| d.content.len()).sum::<usize>();
104                (k.clone(), size)
105            })
106        {
107            self.cache.remove(&key_to_remove);
108            self.current_size = self.current_size.saturating_sub(size);
109        }
110    }
111
112    /// Clear cache
113    pub fn clear(&mut self) {
114        self.cache.clear();
115        self.current_size = 0;
116    }
117
118    /// Get cache statistics
119    pub fn stats(&self) -> CacheStats {
120        CacheStats {
121            entries: self.cache.len(),
122            size_bytes: self.current_size,
123            max_size_bytes: self.max_size,
124            hit_rate: 0.0, // Would need to track hits/misses
125        }
126    }
127}
128
129/// Cache statistics
130#[derive(Debug, Clone)]
131pub struct CacheStats {
132    pub entries: usize,
133    pub size_bytes: usize,
134    pub max_size_bytes: usize,
135    pub hit_rate: f64,
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::rag::documents::Document;
142
143    fn make_doc(id: &str, content: &str) -> Document {
144        Document {
145            id: id.to_string(),
146            content: content.to_string(),
147            metadata: HashMap::new(),
148            embedding: None,
149            chunk_info: None,
150        }
151    }
152
153    #[test]
154    fn test_cache_store_and_retrieve() {
155        let mut cache = RetrievalCache::new(10_000);
156        let docs = vec![make_doc("d1", "hello world")];
157        cache.cache_results("query1", docs.clone()).unwrap();
158
159        let cached = cache.get_cached("query1");
160        assert!(cached.is_some());
161        assert_eq!(cached.unwrap().len(), 1);
162
163        // Miss
164        assert!(cache.get_cached("nonexistent").is_none());
165    }
166
167    #[test]
168    fn test_cache_eviction() {
169        // Max size = 20 bytes
170        let mut cache = RetrievalCache::new(20);
171        let doc1 = make_doc("d1", "twelve chars"); // 12 bytes
172        cache.cache_results("q1", vec![doc1]).unwrap();
173
174        // This should trigger eviction of q1
175        let doc2 = make_doc("d2", "another twelve"); // 14 bytes
176        cache.cache_results("q2", vec![doc2]).unwrap();
177
178        // q1 should have been evicted
179        assert!(cache.get_cached("q1").is_none());
180        assert!(cache.get_cached("q2").is_some());
181    }
182
183    #[test]
184    fn test_cache_clear() {
185        let mut cache = RetrievalCache::new(10_000);
186        cache
187            .cache_results("q1", vec![make_doc("d1", "x")])
188            .unwrap();
189        cache.clear();
190        assert!(cache.get_cached("q1").is_none());
191    }
192
193    #[test]
194    fn test_cache_stats() {
195        let mut cache = RetrievalCache::new(1000);
196        cache
197            .cache_results("q1", vec![make_doc("d1", "data")])
198            .unwrap();
199        let stats = cache.stats();
200        assert_eq!(stats.entries, 1);
201        assert_eq!(stats.max_size_bytes, 1000);
202        assert!(stats.size_bytes > 0);
203    }
204
205    #[test]
206    fn test_cache_oversized_item_rejected() {
207        // Max size = 5, doc with 10-byte content won't fit
208        let mut cache = RetrievalCache::new(5);
209        cache
210            .cache_results("q", vec![make_doc("d", "0123456789")])
211            .unwrap();
212        // Item too large, should not be cached
213        assert!(cache.get_cached("q").is_none());
214    }
215
216    #[test]
217    fn test_evict_lru_tiebreaker_by_timestamp() {
218        // Covers L84-86 — LRU eviction tiebreaker using timestamp
219        use crate::rag::documents::Document;
220        use std::collections::HashMap;
221
222        // Create cache with capacity for only 2 entries
223        let mut cache = RetrievalCache::new(8);
224
225        // Insert two entries so they have the same access_count (1)
226        let doc1 = Document {
227            id: "doc1".to_string(),
228            content: "aaaa".to_string(), // 4 bytes
229            metadata: HashMap::new(),
230            embedding: None,
231            chunk_info: None,
232        };
233        let doc2 = Document {
234            id: "doc2".to_string(),
235            content: "bbbb".to_string(), // 4 bytes
236            metadata: HashMap::new(),
237            embedding: None,
238            chunk_info: None,
239        };
240        let doc3 = Document {
241            id: "doc3".to_string(),
242            content: "cccc".to_string(), // 4 bytes
243            metadata: HashMap::new(),
244            embedding: None,
245            chunk_info: None,
246        };
247
248        // Set results — first entry gets older timestamp
249        let _ = cache.cache_results("query1", vec![doc1]);
250        // Tiny sleep to ensure different timestamp
251        std::thread::sleep(std::time::Duration::from_millis(10));
252        let _ = cache.cache_results("query2", vec![doc2]);
253        std::thread::sleep(std::time::Duration::from_millis(10));
254
255        // Both have access_count = 0 after set (same). Adding a third should evict the oldest.
256        let _ = cache.cache_results("query3", vec![doc3]);
257
258        // query1 (oldest timestamp) should have been evicted
259        assert!(
260            cache.get_cached("query1").is_none(),
261            "Oldest entry should be evicted"
262        );
263        // query2 and query3 should still be present
264        assert!(cache.get_cached("query3").is_some());
265    }
266}