1use std::time::Instant;
2use std::f64::consts::PI;
3
4#[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
38pub struct TanCurveMemoryPressure {
41 pub center_percent: f64,
43 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 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
75pub struct TanCurvePolicy {
77 pub recency_half_life: f64,
79 pub max_access_count: u64,
81 pub baseline_size_bytes: usize,
83 pub weights: (f64, f64, f64),
85 pub memory_pressure: TanCurveMemoryPressure,
87}
88
89impl Default for TanCurvePolicy {
90 fn default() -> Self {
91 Self {
92 recency_half_life: 3600.0, max_access_count: 1000,
94 baseline_size_bytes: 1024 * 1024, weights: (0.4, 0.4, 0.2), memory_pressure: TanCurveMemoryPressure::default(),
97 }
98 }
99}
100
101impl TanCurvePolicy {
102 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 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) .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 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 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 let old_unused = make_entry("old_unused", 3600.0, 0, 1024, false);
190 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 let small = make_entry("small", 100.0, 10, 1024, false);
205 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), make_entry("clean2", 200.0, 0, 1024, false),
222 make_entry("dirty2", 300.0, 0, 1024, true), ];
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 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}