Skip to main content

optirs_nas/evaluation/
cache.rs

1//! Evaluation cache for storing and retrieving evaluation results
2//!
3//! Provides caching mechanisms to avoid redundant evaluations.
4
5use scirs2_core::numeric::Float;
6use std::collections::HashMap;
7use std::fmt::Debug;
8use std::time::SystemTime;
9
10use super::types::*;
11use crate::nas_engine::results::EvaluationResults;
12
13/// Default maximum number of entries retained by an [`EvaluationCache`].
14///
15/// A NAS run evaluates thousands of architectures; without a bound the cache
16/// grows for the lifetime of the search. The default keeps a generous working
17/// set while making memory use predictable.
18pub const DEFAULT_CACHE_CAPACITY: usize = 4096;
19
20/// Evaluation cache for storing results
21///
22/// Entries are evicted according to [`CacheEvictionPolicy`] once the cache
23/// exceeds its capacity. The default policy is LRU: the entry whose last access
24/// is oldest is dropped first.
25#[derive(Debug)]
26pub struct EvaluationCache<T: Float + Debug + Send + Sync + 'static> {
27    /// Cached evaluations
28    evaluations: HashMap<String, CachedEvaluation<T>>,
29
30    /// Cache metadata
31    metadata: CacheMetadata,
32
33    /// Maximum number of retained entries.
34    capacity: usize,
35
36    /// Eviction policy applied when `capacity` is exceeded.
37    eviction_policy: CacheEvictionPolicy,
38
39    /// Monotonic access counter used as a logical clock for recency ordering.
40    /// Avoids depending on wall-clock resolution, which is too coarse to order
41    /// accesses that happen within the same instant.
42    access_clock: u64,
43}
44
45/// Cached evaluation result
46#[derive(Debug, Clone)]
47pub struct CachedEvaluation<T: Float + Debug + Send + Sync + 'static> {
48    /// Evaluation results
49    pub results: EvaluationResults<T>,
50
51    /// Cache timestamp (insertion time)
52    pub timestamp: SystemTime,
53
54    /// Number of times this entry has been read out of the cache.
55    ///
56    /// Starts at zero on insertion and is incremented on every successful
57    /// lookup; it therefore reflects real reuse and drives the LFU policy.
58    pub access_count: usize,
59
60    /// Logical clock value of the most recent access, used for LRU ordering.
61    pub last_access_tick: u64,
62
63    /// Validity flag
64    pub is_valid: bool,
65}
66
67/// Cache metadata
68#[derive(Debug, Clone)]
69pub struct CacheMetadata {
70    /// Total entries
71    pub total_entries: usize,
72
73    /// Cache size (bytes)
74    pub cache_size_bytes: usize,
75
76    /// Last cleanup time
77    pub last_cleanup: SystemTime,
78
79    /// Cache version
80    pub version: String,
81}
82
83impl<T: Float + Debug + Default + Send + Sync> EvaluationCache<T> {
84    pub(crate) fn new() -> Self {
85        Self::with_capacity(DEFAULT_CACHE_CAPACITY, CacheEvictionPolicy::LRU)
86    }
87
88    /// Create a cache with an explicit capacity and eviction policy.
89    ///
90    /// A capacity of zero is promoted to one so the cache always retains the
91    /// most recent insertion.
92    pub fn with_capacity(capacity: usize, eviction_policy: CacheEvictionPolicy) -> Self {
93        Self {
94            evaluations: HashMap::new(),
95            metadata: CacheMetadata {
96                total_entries: 0,
97                cache_size_bytes: 0,
98                last_cleanup: SystemTime::now(),
99                version: env!("CARGO_PKG_VERSION").to_string(),
100            },
101            capacity: capacity.max(1),
102            eviction_policy,
103            access_clock: 0,
104        }
105    }
106
107    /// Look up a cached evaluation, recording the access.
108    ///
109    /// Takes `&mut self` because a hit updates the entry's access count and
110    /// recency stamp, which is what makes the eviction policies meaningful.
111    pub(crate) fn get(&mut self, key: &str) -> Option<&CachedEvaluation<T>> {
112        self.access_clock = self.access_clock.wrapping_add(1);
113        let tick = self.access_clock;
114
115        let entry = self.evaluations.get_mut(key)?;
116        if !entry.is_valid {
117            return None;
118        }
119        entry.access_count += 1;
120        entry.last_access_tick = tick;
121
122        self.evaluations.get(key)
123    }
124
125    pub(crate) fn insert(&mut self, key: String, results: EvaluationResults<T>) {
126        self.access_clock = self.access_clock.wrapping_add(1);
127
128        let cached_eval = CachedEvaluation {
129            results,
130            timestamp: SystemTime::now(),
131            access_count: 0,
132            last_access_tick: self.access_clock,
133            is_valid: true,
134        };
135
136        self.evaluations.insert(key, cached_eval);
137        self.evict_if_needed();
138        self.metadata.total_entries = self.evaluations.len();
139    }
140
141    /// Drop entries until the cache is back inside its capacity, honouring the
142    /// configured [`CacheEvictionPolicy`].
143    fn evict_if_needed(&mut self) {
144        while self.evaluations.len() > self.capacity {
145            let victim = match self.eviction_policy {
146                // Least recently used: smallest access tick.
147                CacheEvictionPolicy::LRU => self
148                    .evaluations
149                    .iter()
150                    .min_by_key(|(_, v)| v.last_access_tick)
151                    .map(|(k, _)| k.clone()),
152                // Least frequently used: fewest reads, ties broken by recency.
153                CacheEvictionPolicy::LFU => self
154                    .evaluations
155                    .iter()
156                    .min_by_key(|(_, v)| (v.access_count, v.last_access_tick))
157                    .map(|(k, _)| k.clone()),
158                // First in, first out: oldest insertion timestamp.
159                CacheEvictionPolicy::FIFO => self
160                    .evaluations
161                    .iter()
162                    .min_by_key(|(_, v)| v.timestamp)
163                    .map(|(k, _)| k.clone()),
164                // Deterministic stand-in for random selection: the smallest key
165                // by ordering. Avoids pulling an RNG into the cache while still
166                // being independent of access history.
167                CacheEvictionPolicy::Random => self.evaluations.keys().min().cloned(),
168            };
169
170            match victim {
171                Some(key) => {
172                    self.evaluations.remove(&key);
173                }
174                None => break,
175            }
176        }
177
178        self.metadata.last_cleanup = SystemTime::now();
179    }
180
181    /// Get cache statistics
182    pub fn stats(&self) -> &CacheMetadata {
183        &self.metadata
184    }
185
186    /// Maximum number of entries this cache retains.
187    pub fn capacity(&self) -> usize {
188        self.capacity
189    }
190
191    /// Total number of cache reads recorded across all live entries.
192    pub fn total_accesses(&self) -> usize {
193        self.evaluations.values().map(|v| v.access_count).sum()
194    }
195
196    /// Number of times `key` has been read out of the cache, or `None` if it is
197    /// not resident.
198    ///
199    /// The access frequency used to be tracked a second time, in an
200    /// `AccessPatterns.frequency_distribution` map that nothing ever read; the
201    /// per-entry counter that already drives LFU eviction is the single source of
202    /// truth now. `AccessPatterns` also carried `temporal_patterns` and
203    /// `correlation_patterns` — a `Vec` and a `HashMap` that were constructed
204    /// empty, never written and never read, describing an access-pattern analysis
205    /// this cache does not perform. Both are gone.
206    pub fn access_frequency(&self, key: &str) -> Option<usize> {
207        self.evaluations.get(key).map(|entry| entry.access_count)
208    }
209
210    /// Key of the most-read resident entry, ties broken by key so the answer is
211    /// deterministic. `None` when the cache is empty.
212    pub fn hottest_key(&self) -> Option<&str> {
213        self.evaluations
214            .iter()
215            .max_by(|(left_key, left), (right_key, right)| {
216                left.access_count
217                    .cmp(&right.access_count)
218                    .then_with(|| right_key.cmp(left_key))
219            })
220            .map(|(key, _)| key.as_str())
221    }
222
223    /// Clear the cache
224    pub fn clear(&mut self) {
225        self.evaluations.clear();
226        self.metadata.total_entries = 0;
227        self.metadata.cache_size_bytes = 0;
228        self.metadata.last_cleanup = SystemTime::now();
229    }
230
231    /// Check if cache contains a key
232    pub fn contains(&self, key: &str) -> bool {
233        self.evaluations.contains_key(key)
234    }
235
236    /// Get number of entries
237    pub fn len(&self) -> usize {
238        self.evaluations.len()
239    }
240
241    /// Check if cache is empty
242    pub fn is_empty(&self) -> bool {
243        self.evaluations.is_empty()
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn results(score: f64) -> EvaluationResults<f64> {
252        EvaluationResults {
253            metric_scores: std::collections::HashMap::new(),
254            overall_score: score,
255            confidence_intervals: std::collections::HashMap::new(),
256            evaluation_time: std::time::Duration::from_secs(100),
257            success: true,
258            error_message: None,
259            cv_results: None,
260            benchmark_results: std::collections::HashMap::new(),
261            training_trajectory: vec![],
262        }
263    }
264
265    #[test]
266    fn test_evaluation_cache() {
267        let mut cache = EvaluationCache::<f64>::new();
268        assert_eq!(cache.metadata.total_entries, 0);
269
270        cache.insert("test_key".to_string(), results(0.95));
271        assert_eq!(cache.metadata.total_entries, 1);
272        assert!(cache.contains("test_key"));
273    }
274
275    #[test]
276    fn test_access_count_is_incremented_on_hits() {
277        let mut cache = EvaluationCache::<f64>::new();
278        cache.insert("k".to_string(), results(0.5));
279
280        // Freshly inserted entries have never been read.
281        assert_eq!(cache.total_accesses(), 0);
282
283        for expected in 1..=3 {
284            let hit = cache.get("k").expect("hit");
285            assert_eq!(hit.access_count, expected);
286        }
287        assert_eq!(cache.total_accesses(), 3);
288        assert!(cache.get("missing").is_none());
289    }
290
291    #[test]
292    fn test_lru_eviction_bounds_the_cache() {
293        let mut cache = EvaluationCache::<f64>::with_capacity(2, CacheEvictionPolicy::LRU);
294        cache.insert("a".to_string(), results(0.1));
295        cache.insert("b".to_string(), results(0.2));
296
297        // Touch "a" so "b" becomes the least recently used entry.
298        assert!(cache.get("a").is_some());
299
300        cache.insert("c".to_string(), results(0.3));
301
302        assert_eq!(cache.len(), 2, "cache must stay within capacity");
303        assert!(cache.contains("a"));
304        assert!(cache.contains("c"));
305        assert!(!cache.contains("b"), "LRU victim must be evicted");
306    }
307
308    #[test]
309    fn test_lfu_eviction_keeps_hot_entries() {
310        let mut cache = EvaluationCache::<f64>::with_capacity(2, CacheEvictionPolicy::LFU);
311        cache.insert("hot".to_string(), results(0.1));
312        cache.insert("cold".to_string(), results(0.2));
313
314        for _ in 0..5 {
315            assert!(cache.get("hot").is_some());
316        }
317
318        cache.insert("new".to_string(), results(0.3));
319
320        assert_eq!(cache.len(), 2);
321        assert!(cache.contains("hot"));
322        assert!(!cache.contains("cold"));
323    }
324
325    #[test]
326    fn test_access_frequency_and_hottest_key_report_real_reuse() {
327        let mut cache = EvaluationCache::<f64>::with_capacity(4, CacheEvictionPolicy::LRU);
328        assert!(cache.hottest_key().is_none());
329
330        cache.insert("cold".to_string(), results(0.1));
331        cache.insert("warm".to_string(), results(0.2));
332        cache.insert("hot".to_string(), results(0.3));
333
334        assert_eq!(cache.access_frequency("hot"), Some(0));
335        assert_eq!(cache.access_frequency("absent"), None);
336
337        for _ in 0..5 {
338            assert!(cache.get("hot").is_some());
339        }
340        assert!(cache.get("warm").is_some());
341
342        assert_eq!(cache.access_frequency("hot"), Some(5));
343        assert_eq!(cache.access_frequency("warm"), Some(1));
344        assert_eq!(cache.access_frequency("cold"), Some(0));
345        assert_eq!(cache.hottest_key(), Some("hot"));
346
347        // Eviction must take the counter with it, not leave a stale reading.
348        cache.clear();
349        assert_eq!(cache.access_frequency("hot"), None);
350        assert!(cache.hottest_key().is_none());
351    }
352
353    #[test]
354    fn test_cache_never_exceeds_capacity_under_load() {
355        let mut cache = EvaluationCache::<f64>::with_capacity(8, CacheEvictionPolicy::LRU);
356        for i in 0..1000 {
357            cache.insert(format!("key_{}", i), results(i as f64));
358            assert!(cache.len() <= 8);
359        }
360        assert_eq!(cache.len(), 8);
361        assert_eq!(cache.stats().total_entries, 8);
362    }
363}