1use std::time::Instant;
5use std::f64::consts::PI;
6
7#[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
41pub struct TanCurveMemoryPressure {
44 pub center_percent: f64,
46 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 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
78pub struct TanCurvePolicy {
80 pub recency_half_life: f64,
82 pub max_access_count: u64,
84 pub baseline_size_bytes: usize,
86 pub weights: (f64, f64, f64),
88 pub memory_pressure: TanCurveMemoryPressure,
90}
91
92impl Default for TanCurvePolicy {
93 fn default() -> Self {
94 Self {
95 recency_half_life: 3600.0, max_access_count: 1000,
97 baseline_size_bytes: 1024 * 1024, weights: (0.4, 0.4, 0.2), memory_pressure: TanCurveMemoryPressure::default(),
100 }
101 }
102}
103
104impl TanCurvePolicy {
105 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 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) .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 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 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 let old_unused = make_entry("old_unused", 3600.0, 0, 1024, false);
193 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 let small = make_entry("small", 100.0, 10, 1024, false);
208 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), make_entry("clean2", 200.0, 0, 1024, false),
225 make_entry("dirty2", 300.0, 0, 1024, true), ];
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 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}