saorsa_core/adaptive/
eviction.rs

1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is dual-licensed under:
4// - GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
5// - Commercial License
6//
7// For AGPL-3.0 license, see LICENSE-AGPL-3.0
8// For commercial licensing, contact: david@saorsalabs.com
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under these licenses is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
14//! # Cache Eviction Strategies for Adaptive Learning
15//!
16//! This module provides various cache eviction strategies that integrate with
17//! the Q-learning cache management system. Strategies include:
18//! - LRU (Least Recently Used)
19//! - LFU (Least Frequently Used)
20//! - FIFO (First In First Out)
21//! - Adaptive (Q-value based eviction)
22
23// use super::*; // Removed unused import
24use super::ContentHash;
25use super::q_learning_cache::{AccessInfo, CacheAction, StateVector};
26// use crate::Result; // Removed unused import
27use std::collections::{HashMap, VecDeque};
28use std::sync::Arc;
29use std::time::{SystemTime, UNIX_EPOCH};
30use tokio::sync::RwLock;
31
32/// Trait for cache eviction strategies
33pub trait EvictionStrategy: Send + Sync + std::fmt::Debug {
34    /// Select a content hash to evict from the cache
35    fn select_victim(
36        &self,
37        cache_state: &CacheState,
38        access_info: &HashMap<ContentHash, AccessInfo>,
39    ) -> Option<ContentHash>;
40
41    /// Update strategy state when content is accessed
42    fn on_access(&mut self, content_hash: &ContentHash);
43
44    /// Update strategy state when new content is inserted
45    fn on_insert(&mut self, content_hash: &ContentHash);
46
47    /// Get strategy name for logging/metrics
48    fn name(&self) -> &str;
49}
50
51/// Cache state information for eviction decisions
52#[derive(Debug, Clone)]
53pub struct CacheState {
54    /// Current cache size in bytes
55    pub current_size: u64,
56    /// Maximum cache size in bytes
57    pub max_size: u64,
58    /// Number of items in cache
59    pub item_count: usize,
60    /// Average access frequency
61    pub avg_access_frequency: f64,
62}
63
64/// LRU (Least Recently Used) eviction strategy
65#[derive(Debug)]
66pub struct LRUStrategy {
67    /// Access order tracking
68    access_order: VecDeque<ContentHash>,
69    /// Map for O(1) position lookup
70    position_map: HashMap<ContentHash, usize>,
71}
72
73impl Default for LRUStrategy {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl LRUStrategy {
80    pub fn new() -> Self {
81        Self {
82            access_order: VecDeque::new(),
83            position_map: HashMap::new(),
84        }
85    }
86}
87
88impl EvictionStrategy for LRUStrategy {
89    fn select_victim(
90        &self,
91        _cache_state: &CacheState,
92        access_info: &HashMap<ContentHash, AccessInfo>,
93    ) -> Option<ContentHash> {
94        // Find least recently used item that exists in cache
95        self.access_order
96            .iter()
97            .find(|&hash| access_info.contains_key(hash))
98            .cloned()
99    }
100
101    fn on_access(&mut self, content_hash: &ContentHash) {
102        // Remove from current position if exists
103        if let Some(&pos) = self.position_map.get(content_hash) {
104            self.access_order.remove(pos);
105            // Update positions for items after removed one
106            for (i, hash) in self.access_order.iter().enumerate().skip(pos) {
107                self.position_map.insert(*hash, i);
108            }
109        }
110
111        // Add to end (most recently used)
112        self.access_order.push_back(*content_hash);
113        self.position_map
114            .insert(*content_hash, self.access_order.len() - 1);
115    }
116
117    fn on_insert(&mut self, content_hash: &ContentHash) {
118        self.on_access(content_hash);
119    }
120
121    fn name(&self) -> &str {
122        "LRU"
123    }
124}
125
126/// LFU (Least Frequently Used) eviction strategy
127#[derive(Debug)]
128pub struct LFUStrategy {
129    /// Frequency counts
130    frequency_map: HashMap<ContentHash, u64>,
131}
132
133impl Default for LFUStrategy {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl LFUStrategy {
140    pub fn new() -> Self {
141        Self {
142            frequency_map: HashMap::new(),
143        }
144    }
145}
146
147impl EvictionStrategy for LFUStrategy {
148    fn select_victim(
149        &self,
150        _cache_state: &CacheState,
151        access_info: &HashMap<ContentHash, AccessInfo>,
152    ) -> Option<ContentHash> {
153        // Find item with lowest frequency
154        access_info
155            .keys()
156            .min_by_key(|&hash| self.frequency_map.get(hash).unwrap_or(&0))
157            .cloned()
158    }
159
160    fn on_access(&mut self, content_hash: &ContentHash) {
161        *self.frequency_map.entry(*content_hash).or_insert(0) += 1;
162    }
163
164    fn on_insert(&mut self, content_hash: &ContentHash) {
165        self.frequency_map.insert(*content_hash, 1);
166    }
167
168    fn name(&self) -> &str {
169        "LFU"
170    }
171}
172
173/// FIFO (First In First Out) eviction strategy
174#[derive(Debug)]
175pub struct FIFOStrategy {
176    /// Insertion order
177    insertion_order: VecDeque<ContentHash>,
178}
179
180impl Default for FIFOStrategy {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186impl FIFOStrategy {
187    pub fn new() -> Self {
188        Self {
189            insertion_order: VecDeque::new(),
190        }
191    }
192}
193
194impl EvictionStrategy for FIFOStrategy {
195    fn select_victim(
196        &self,
197        _cache_state: &CacheState,
198        access_info: &HashMap<ContentHash, AccessInfo>,
199    ) -> Option<ContentHash> {
200        // Find oldest item that still exists in cache
201        self.insertion_order
202            .iter()
203            .find(|&hash| access_info.contains_key(hash))
204            .cloned()
205    }
206
207    fn on_access(&mut self, _content_hash: &ContentHash) {
208        // FIFO doesn't care about access patterns
209    }
210
211    fn on_insert(&mut self, content_hash: &ContentHash) {
212        self.insertion_order.push_back(*content_hash);
213    }
214
215    fn name(&self) -> &str {
216        "FIFO"
217    }
218}
219
220/// Q-value type for adaptive strategy
221#[derive(Debug, Clone, Default)]
222pub struct QValue {
223    pub value: f64,
224    pub updates: u32,
225}
226
227/// Adaptive eviction strategy based on Q-learning values
228#[derive(Debug)]
229pub struct AdaptiveStrategy {
230    /// Q-table reference for value-based decisions
231    _q_table: Arc<RwLock<HashMap<(StateVector, CacheAction), QValue>>>,
232    /// Recent performance history
233    _performance_history: VecDeque<f64>,
234    /// Maximum history size
235    _max_history: usize,
236}
237
238#[allow(dead_code)]
239impl AdaptiveStrategy {
240    /// Get current timestamp in seconds since UNIX_EPOCH
241    fn current_timestamp_secs() -> u64 {
242        SystemTime::now()
243            .duration_since(UNIX_EPOCH)
244            .map(|d| d.as_secs())
245            .unwrap_or(0)
246    }
247
248    pub fn new(q_table: Arc<RwLock<HashMap<(StateVector, CacheAction), QValue>>>) -> Self {
249        Self {
250            _q_table: q_table,
251            _performance_history: VecDeque::new(),
252            _max_history: 100,
253        }
254    }
255
256    /// Calculate expected value of keeping content in cache
257    async fn calculate_retention_value(
258        &self,
259        content_hash: &ContentHash,
260        access_info: &AccessInfo,
261        cache_state: &CacheState,
262    ) -> f64 {
263        // Create state vector for this content
264        let state = StateVector {
265            utilization_bucket: ((cache_state.current_size as f64 / cache_state.max_size as f64)
266                * 10.0) as u8,
267            frequency_bucket: self.frequency_bucket(access_info.count),
268            recency_bucket: self.recency_bucket(access_info.last_access_secs),
269            content_size_bucket: self.size_bucket(access_info.size),
270        };
271
272        // Get Q-values for keeping vs evicting
273        let q_table = self._q_table.read().await;
274        let keep_value = q_table
275            .get(&(state, CacheAction::DoNothing))
276            .map(|qv| qv.value)
277            .unwrap_or(0.0);
278        let evict_value = q_table
279            .get(&(state, CacheAction::Evict(*content_hash)))
280            .map(|qv| qv.value)
281            .unwrap_or(0.0);
282
283        keep_value - evict_value
284    }
285
286    fn frequency_bucket(&self, count: u64) -> u8 {
287        match count {
288            0..=10 => 0,
289            11..=50 => 1,
290            51..=100 => 2,
291            101..=500 => 3,
292            501..=1000 => 4,
293            _ => 5,
294        }
295    }
296
297    fn recency_bucket(&self, last_access_secs: u64) -> u8 {
298        let now_secs = Self::current_timestamp_secs();
299        let age_secs = now_secs.saturating_sub(last_access_secs);
300
301        match age_secs {
302            0..=60 => 0,           // Last minute
303            61..=3_600 => 1,       // Last hour
304            3_601..=86_400 => 2,   // Last day
305            86_401..=604_800 => 3, // Last week
306            _ => 4,                // Older
307        }
308    }
309
310    fn size_bucket(&self, size: u64) -> u8 {
311        match size {
312            0..=1_024 => 0,              // <= 1KB
313            1_025..=10_240 => 1,         // <= 10KB
314            10_241..=102_400 => 2,       // <= 100KB
315            102_401..=1_048_576 => 3,    // <= 1MB
316            1_048_577..=10_485_760 => 4, // <= 10MB
317            _ => 5,                      // > 10MB
318        }
319    }
320}
321
322impl EvictionStrategy for AdaptiveStrategy {
323    fn select_victim(
324        &self,
325        _cache_state: &CacheState,
326        access_info: &HashMap<ContentHash, AccessInfo>,
327    ) -> Option<ContentHash> {
328        // For now, use a simple heuristic instead of async Q-values
329        // In production, consider making this trait async
330
331        // Find the content with lowest value score
332        access_info
333            .iter()
334            .map(|(hash, info)| {
335                // Simple heuristic: combine frequency, recency, and size
336                let frequency_score = (info.count as f64).log2() + 1.0;
337                let now_secs = Self::current_timestamp_secs();
338                let age_secs = now_secs.saturating_sub(info.last_access_secs) as f64;
339                let recency_score = 1.0 / (age_secs / 3600.0 + 1.0); // Hours
340                let size_penalty = (info.size as f64 / 1_048_576.0).sqrt(); // MB
341
342                let score = frequency_score * recency_score / (size_penalty + 1.0);
343                (hash, score)
344            })
345            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
346            .map(|(hash, _)| *hash)
347    }
348
349    fn on_access(&mut self, _content_hash: &ContentHash) {
350        // Adaptive strategy learns from Q-values, not direct access patterns
351    }
352
353    fn on_insert(&mut self, _content_hash: &ContentHash) {
354        // Adaptive strategy learns from Q-values, not direct insertions
355    }
356
357    fn name(&self) -> &str {
358        "Adaptive"
359    }
360}
361
362/// Factory for creating eviction strategies
363#[derive(Debug, Clone)]
364pub enum EvictionStrategyType {
365    LRU,
366    LFU,
367    FIFO,
368    Adaptive(Arc<RwLock<HashMap<(StateVector, CacheAction), QValue>>>),
369}
370
371impl EvictionStrategyType {
372    pub fn create(self) -> Box<dyn EvictionStrategy> {
373        match self {
374            EvictionStrategyType::LRU => Box::new(LRUStrategy::new()),
375            EvictionStrategyType::LFU => Box::new(LFUStrategy::new()),
376            EvictionStrategyType::FIFO => Box::new(FIFOStrategy::new()),
377            EvictionStrategyType::Adaptive(q_table) => Box::new(AdaptiveStrategy::new(q_table)),
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    fn create_test_access_info() -> HashMap<ContentHash, AccessInfo> {
387        let mut info = HashMap::new();
388
389        let now_secs = SystemTime::now()
390            .duration_since(UNIX_EPOCH)
391            .map(|d| d.as_secs())
392            .unwrap_or(0);
393
394        // Add test data
395        info.insert(
396            ContentHash::from("content1".as_bytes()),
397            AccessInfo {
398                count: 10,
399                last_access_secs: now_secs.saturating_sub(60),
400                size: 1024,
401            },
402        );
403
404        info.insert(
405            ContentHash::from("content2".as_bytes()),
406            AccessInfo {
407                count: 5,
408                last_access_secs: now_secs.saturating_sub(120),
409                size: 2048,
410            },
411        );
412
413        info.insert(
414            ContentHash::from("content3".as_bytes()),
415            AccessInfo {
416                count: 20,
417                last_access_secs: now_secs.saturating_sub(30),
418                size: 512,
419            },
420        );
421
422        info
423    }
424
425    #[test]
426    fn test_lru_eviction_removes_least_recently_used() {
427        let mut strategy = LRUStrategy::new();
428        let access_info = create_test_access_info();
429        let cache_state = CacheState {
430            current_size: 3584,
431            max_size: 4096,
432            item_count: 3,
433            avg_access_frequency: 11.67,
434        };
435
436        // Set up access order
437        let hash1 = ContentHash::from("content1".as_bytes());
438        let hash2 = ContentHash::from("content2".as_bytes());
439        let hash3 = ContentHash::from("content3".as_bytes());
440
441        strategy.on_insert(&hash2); // Oldest
442        strategy.on_insert(&hash1);
443        strategy.on_insert(&hash3); // Newest
444
445        // Access hash1 to make it more recent than hash2
446        strategy.on_access(&hash1);
447
448        // Should evict content2 (least recently used)
449        let victim = strategy.select_victim(&cache_state, &access_info);
450        assert_eq!(victim, Some(hash2));
451    }
452
453    #[test]
454    fn test_lfu_eviction_removes_least_frequently_used() {
455        let mut strategy = LFUStrategy::new();
456        let access_info = create_test_access_info();
457        let cache_state = CacheState {
458            current_size: 3584,
459            max_size: 4096,
460            item_count: 3,
461            avg_access_frequency: 11.67,
462        };
463
464        // Set up frequencies
465        let hash1 = ContentHash::from("content1".as_bytes());
466        let hash2 = ContentHash::from("content2".as_bytes());
467        let hash3 = ContentHash::from("content3".as_bytes());
468
469        // Access patterns
470        for _ in 0..10 {
471            strategy.on_access(&hash1);
472        }
473        for _ in 0..5 {
474            strategy.on_access(&hash2);
475        }
476        for _ in 0..20 {
477            strategy.on_access(&hash3);
478        }
479
480        // Should evict content2 (least frequently used)
481        let victim = strategy.select_victim(&cache_state, &access_info);
482        assert_eq!(victim, Some(hash2));
483    }
484
485    #[test]
486    fn test_fifo_eviction_removes_oldest() {
487        let mut strategy = FIFOStrategy::new();
488        let access_info = create_test_access_info();
489        let cache_state = CacheState {
490            current_size: 3584,
491            max_size: 4096,
492            item_count: 3,
493            avg_access_frequency: 11.67,
494        };
495
496        // Insert in order
497        let hash1 = ContentHash::from("content1".as_bytes());
498        let hash2 = ContentHash::from("content2".as_bytes());
499        let hash3 = ContentHash::from("content3".as_bytes());
500
501        strategy.on_insert(&hash2); // First
502        strategy.on_insert(&hash1); // Second
503        strategy.on_insert(&hash3); // Third
504
505        // Access shouldn't affect FIFO order
506        strategy.on_access(&hash2);
507        strategy.on_access(&hash3);
508
509        // Should evict content2 (first in)
510        let victim = strategy.select_victim(&cache_state, &access_info);
511        assert_eq!(victim, Some(hash2));
512    }
513
514    #[tokio::test]
515    async fn test_adaptive_eviction_uses_q_values() {
516        let q_table = Arc::new(RwLock::new(HashMap::new()));
517        let strategy = AdaptiveStrategy::new(q_table.clone());
518        let access_info = create_test_access_info();
519        let cache_state = CacheState {
520            current_size: 3584,
521            max_size: 4096,
522            item_count: 3,
523            avg_access_frequency: 11.67,
524        };
525
526        // Set up Q-values for different states
527        let state1 = StateVector {
528            utilization_bucket: 8,
529            frequency_bucket: 1,
530            recency_bucket: 1,
531            content_size_bucket: 1,
532        };
533
534        let hash1 = ContentHash::from("content1".as_bytes());
535        let hash2 = ContentHash::from("content2".as_bytes());
536
537        // Make content1 more valuable to keep
538        q_table.write().await.insert(
539            (state1, CacheAction::DoNothing),
540            QValue {
541                value: 10.0,
542                updates: 1,
543            },
544        );
545        q_table.write().await.insert(
546            (state1, CacheAction::Evict(hash1)),
547            QValue {
548                value: 5.0,
549                updates: 1,
550            },
551        );
552
553        // Make content2 less valuable
554        q_table.write().await.insert(
555            (state1, CacheAction::DoNothing),
556            QValue {
557                value: 3.0,
558                updates: 1,
559            },
560        );
561        q_table.write().await.insert(
562            (state1, CacheAction::Evict(hash2)),
563            QValue {
564                value: 8.0,
565                updates: 1,
566            },
567        );
568
569        // Adaptive strategy should consider Q-values
570        let victim = strategy.select_victim(&cache_state, &access_info);
571        assert!(victim.is_some());
572    }
573
574    #[test]
575    fn test_strategy_factory() {
576        let lru = EvictionStrategyType::LRU.create();
577        assert_eq!(lru.name(), "LRU");
578
579        let lfu = EvictionStrategyType::LFU.create();
580        assert_eq!(lfu.name(), "LFU");
581
582        let fifo = EvictionStrategyType::FIFO.create();
583        assert_eq!(fifo.name(), "FIFO");
584
585        let q_table = Arc::new(RwLock::new(HashMap::new()));
586        let adaptive = EvictionStrategyType::Adaptive(q_table).create();
587        assert_eq!(adaptive.name(), "Adaptive");
588    }
589}