sync_engine/eviction/
tan_curve.rs

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