sync_engine/eviction/
tan_curve.rs

1use std::time::Instant;
2use std::f64::consts::PI;
3
4/// Cache entry metadata for eviction scoring
5#[derive(Debug, Clone)]
6pub struct CacheEntry {
7    pub id: String,
8    pub size_bytes: usize,
9    pub created_at: Instant,
10    pub last_access: Instant,
11    pub access_count: u64,
12    pub is_dirty: bool,
13}
14
15impl CacheEntry {
16    pub fn new(id: String, size_bytes: usize) -> Self {
17        let now = Instant::now();
18        Self {
19            id,
20            size_bytes,
21            created_at: now,
22            last_access: now,
23            access_count: 0,
24            is_dirty: true,
25        }
26    }
27
28    pub fn record_access(&mut self) {
29        self.last_access = Instant::now();
30        self.access_count = self.access_count.saturating_add(1);
31    }
32
33    pub fn idle_secs(&self) -> f64 {
34        self.last_access.elapsed().as_secs_f64()
35    }
36}
37
38/// Tan curve memory pressure calculator.
39/// Maps memory usage to a smooth multiplier using tan curve.
40pub struct TanCurveMemoryPressure {
41    /// Memory % where pressure starts (default: 75%)
42    pub center_percent: f64,
43    /// Memory % where pressure is critical (default: 95%)
44    pub critical_percent: f64,
45}
46
47impl Default for TanCurveMemoryPressure {
48    fn default() -> Self {
49        Self {
50            center_percent: 0.75,
51            critical_percent: 0.95,
52        }
53    }
54}
55
56impl TanCurveMemoryPressure {
57    /// Calculate score multiplier based on memory pressure.
58    /// Returns 1.0 at low pressure, smoothly decreasing to 0.0 at critical.
59    pub fn multiplier(&self, used_percent: f64) -> f64 {
60        if used_percent <= self.center_percent {
61            return 1.0;
62        }
63        if used_percent >= self.critical_percent {
64            return 0.0;
65        }
66
67        let range = self.critical_percent - self.center_percent;
68        let normalized = (used_percent - self.center_percent) / range;
69        let angle = (normalized - 0.5) * PI / 1.2;
70        let tan_value = angle.tan();
71        (1.0 - (tan_value + 2.0) / 4.0).clamp(0.0, 1.0)
72    }
73}
74
75/// Tan curve eviction policy combining recency, frequency, and size.
76pub struct TanCurvePolicy {
77    /// Half-life for recency decay (seconds)
78    pub recency_half_life: f64,
79    /// Max access count for normalization
80    pub max_access_count: u64,
81    /// Baseline size in bytes for size scoring
82    pub baseline_size_bytes: usize,
83    /// Weights for each component (recency, frequency, size)
84    pub weights: (f64, f64, f64),
85    /// Memory pressure calculator
86    pub memory_pressure: TanCurveMemoryPressure,
87}
88
89impl Default for TanCurvePolicy {
90    fn default() -> Self {
91        Self {
92            recency_half_life: 3600.0, // 1 hour
93            max_access_count: 1000,
94            baseline_size_bytes: 1024 * 1024, // 1 MB
95            weights: (0.4, 0.4, 0.2), // recency, frequency, size
96            memory_pressure: TanCurveMemoryPressure::default(),
97        }
98    }
99}
100
101impl TanCurvePolicy {
102    /// Calculate eviction score (0.0 = evict first, 1.0 = keep)
103    pub fn calculate_score(&self, entry: &CacheEntry, memory_used_percent: f64) -> f64 {
104        let recency = (-entry.idle_secs() / self.recency_half_life).exp();
105        
106        let frequency = if entry.access_count == 0 {
107            0.0
108        } else {
109            let count = entry.access_count.min(self.max_access_count) as f64;
110            (1.0 + count).ln() / (1.0 + self.max_access_count as f64).ln()
111        };
112        
113        let size_mb = entry.size_bytes as f64 / self.baseline_size_bytes as f64;
114        let size_score = 1.0 / (1.0 + size_mb);
115
116        let base_score = recency * self.weights.0 
117            + frequency * self.weights.1 
118            + size_score * self.weights.2;
119
120        let multiplier = self.memory_pressure.multiplier(memory_used_percent);
121        base_score * multiplier
122    }
123
124    /// Select victims for eviction (returns IDs sorted by score, lowest first)
125    pub fn select_victims(&self, entries: &[CacheEntry], count: usize, memory_used_percent: f64) -> Vec<String> {
126        let mut scored: Vec<_> = entries
127            .iter()
128            .filter(|e| !e.is_dirty) // Only evict clean entries
129            .map(|e| (e.id.clone(), self.calculate_score(e, memory_used_percent)))
130            .collect();
131
132        scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
133        scored.into_iter().take(count).map(|(id, _)| id).collect()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use std::time::{Duration, Instant};
141
142    fn make_entry(id: &str, idle_secs: f64, access_count: u64, size_bytes: usize, is_dirty: bool) -> CacheEntry {
143        let now = Instant::now();
144        CacheEntry {
145            id: id.to_string(),
146            size_bytes,
147            created_at: now - Duration::from_secs_f64(idle_secs + 100.0),
148            last_access: now - Duration::from_secs_f64(idle_secs),
149            access_count,
150            is_dirty,
151        }
152    }
153
154    #[test]
155    fn test_memory_pressure_multiplier_low_pressure() {
156        let pressure = TanCurveMemoryPressure::default();
157        assert_eq!(pressure.multiplier(0.0), 1.0);
158        assert_eq!(pressure.multiplier(0.5), 1.0);
159        assert_eq!(pressure.multiplier(0.74), 1.0);
160    }
161
162    #[test]
163    fn test_memory_pressure_multiplier_high_pressure() {
164        let pressure = TanCurveMemoryPressure::default();
165        // At 95%+ pressure, multiplier should be 0 (force evict)
166        assert_eq!(pressure.multiplier(0.95), 0.0);
167        assert_eq!(pressure.multiplier(1.0), 0.0);
168    }
169
170    #[test]
171    fn test_memory_pressure_multiplier_transition() {
172        let pressure = TanCurveMemoryPressure::default();
173        // Between 75% and 95%, multiplier decreases
174        let m1 = pressure.multiplier(0.80);
175        let m2 = pressure.multiplier(0.85);
176        let m3 = pressure.multiplier(0.90);
177        
178        assert!(m1 > m2, "multiplier should decrease as pressure increases");
179        assert!(m2 > m3, "multiplier should decrease as pressure increases");
180        assert!(m1 < 1.0, "multiplier should be less than 1.0 above center");
181        assert!(m3 > 0.0, "multiplier should be above 0 below critical");
182    }
183
184    #[test]
185    fn test_eviction_score_prefers_old_unused_items() {
186        let policy = TanCurvePolicy::default();
187        
188        // Old, never-accessed item
189        let old_unused = make_entry("old_unused", 3600.0, 0, 1024, false);
190        // Recently accessed, frequently used item
191        let hot = make_entry("hot", 1.0, 100, 1024, false);
192        
193        let score_old = policy.calculate_score(&old_unused, 0.5);
194        let score_hot = policy.calculate_score(&hot, 0.5);
195        
196        assert!(score_old < score_hot, "old unused item should have lower score (evict first)");
197    }
198
199    #[test]
200    fn test_eviction_score_prefers_large_items() {
201        let policy = TanCurvePolicy::default();
202        
203        // Small item
204        let small = make_entry("small", 100.0, 10, 1024, false);
205        // Large item (same age/access pattern)
206        let large = make_entry("large", 100.0, 10, 10 * 1024 * 1024, false);
207        
208        let score_small = policy.calculate_score(&small, 0.5);
209        let score_large = policy.calculate_score(&large, 0.5);
210        
211        assert!(score_large < score_small, "large item should have lower score (evict first)");
212    }
213
214    #[test]
215    fn test_select_victims_skips_dirty() {
216        let policy = TanCurvePolicy::default();
217        
218        let entries = vec![
219            make_entry("clean1", 100.0, 0, 1024, false),
220            make_entry("dirty1", 100.0, 0, 1024, true),  // Should be skipped
221            make_entry("clean2", 200.0, 0, 1024, false),
222            make_entry("dirty2", 300.0, 0, 1024, true),  // Should be skipped
223        ];
224        
225        let victims = policy.select_victims(&entries, 10, 0.5);
226        
227        assert_eq!(victims.len(), 2, "should only return clean entries");
228        assert!(!victims.contains(&"dirty1".to_string()));
229        assert!(!victims.contains(&"dirty2".to_string()));
230    }
231
232    #[test]
233    fn test_select_victims_respects_count_limit() {
234        let policy = TanCurvePolicy::default();
235        
236        let entries: Vec<_> = (0..100)
237            .map(|i| make_entry(&format!("item{}", i), i as f64, 0, 1024, false))
238            .collect();
239        
240        let victims = policy.select_victims(&entries, 5, 0.5);
241        
242        assert_eq!(victims.len(), 5, "should return exactly requested count");
243    }
244
245    #[test]
246    fn test_high_pressure_forces_eviction() {
247        let policy = TanCurvePolicy::default();
248        
249        // Hot item that would normally have high score
250        let hot = make_entry("hot", 1.0, 1000, 1024, false);
251        
252        let score_low_pressure = policy.calculate_score(&hot, 0.5);
253        let score_high_pressure = policy.calculate_score(&hot, 0.95);
254        
255        assert!(score_high_pressure < score_low_pressure, 
256            "high pressure should reduce scores to force eviction");
257        assert_eq!(score_high_pressure, 0.0, 
258            "at critical pressure, all scores should be 0");
259    }
260}