1pub const DECAY_HALF_LIFE_S: f64 = 300.0;
20
21#[derive(Debug, Clone, Copy)]
25pub struct CacheEntryStats {
26 pub id: u64,
30 pub hit_count: u32,
32 pub last_hit_time_s: f64,
36}
37
38impl CacheEntryStats {
39 #[must_use]
42 pub fn heat(&self, current_time_s: f64) -> f64 {
43 if !current_time_s.is_finite() || !self.last_hit_time_s.is_finite() {
44 return 0.0;
45 }
46 let age = (current_time_s - self.last_hit_time_s).max(0.0);
47 let decay_factor = 0.5_f64.powf(age / DECAY_HALF_LIFE_S);
48 let heat = f64::from(self.hit_count) * decay_factor;
49 if heat.is_finite() {
50 heat
51 } else {
52 0.0
53 }
54 }
55}
56
57#[must_use]
65pub fn entries_to_evict(
66 entries: &[CacheEntryStats],
67 capacity: usize,
68 current_time_s: f64,
69) -> Vec<u64> {
70 match try_entries_to_evict(entries, capacity, current_time_s) {
71 Ok(evicted) => evicted,
72 Err(_error) => Vec::new(),
73 }
74}
75
76pub fn try_entries_to_evict(
83 entries: &[CacheEntryStats],
84 capacity: usize,
85 current_time_s: f64,
86) -> Result<Vec<u64>, String> {
87 if entries.len() <= capacity {
88 return Ok(Vec::new());
89 }
90 let mut ranked: Vec<(usize, &CacheEntryStats, f64)> = Vec::new();
91 crate::allocation::try_reserve_vec_to_capacity(&mut ranked, entries.len()).map_err(|error| {
92 format!(
93 "cache eviction heat ranking could not reserve {} entry slot(s): {error}. Fix: shard the pipeline cache eviction batch.",
94 entries.len()
95 )
96 })?;
97 ranked.extend(
98 entries
99 .iter()
100 .enumerate()
101 .map(|(idx, e)| (idx, e, e.heat(current_time_s))),
102 );
103 let compare = |a: &(usize, &CacheEntryStats, f64), b: &(usize, &CacheEntryStats, f64)| {
104 a.2.total_cmp(&b.2).then_with(|| a.0.cmp(&b.0))
105 };
106 let evict_count = entries.len() - capacity;
107 if evict_count < ranked.len() {
108 ranked.select_nth_unstable_by(evict_count, compare);
109 }
110 ranked[..evict_count].sort_by(compare);
111 let mut evicted = Vec::new();
112 crate::allocation::try_reserve_vec_to_capacity(&mut evicted, evict_count).map_err(|error| {
113 format!(
114 "cache eviction heat result could not reserve {evict_count} entry id slot(s): {error}. Fix: shard the pipeline cache eviction batch."
115 )
116 })?;
117 evicted.extend(ranked.into_iter().take(evict_count).map(|(_, e, _)| e.id));
118 Ok(evicted)
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn entry(id: u64, hits: u32, last_hit: f64) -> CacheEntryStats {
126 CacheEntryStats {
127 id,
128 hit_count: hits,
129 last_hit_time_s: last_hit,
130 }
131 }
132
133 #[test]
134 fn under_capacity_evicts_nothing() {
135 let entries = vec![entry(1, 10, 100.0), entry(2, 5, 200.0)];
136 assert!(entries_to_evict(&entries, 10, 300.0).is_empty());
137 }
138
139 #[test]
140 fn cold_entry_evicted_before_hot_one() {
141 let entries = vec![
142 entry(1, 100, 290.0), entry(2, 1, 0.0), ];
145 let evict = entries_to_evict(&entries, 1, 300.0);
146 assert_eq!(evict, vec![2], "ancient cold entry evicted first");
147 }
148
149 #[test]
150 fn equal_heat_evicts_in_input_order() {
151 let entries = vec![
152 entry(1, 10, 100.0),
153 entry(2, 10, 100.0),
154 entry(3, 10, 100.0),
155 ];
156 let evict = entries_to_evict(&entries, 1, 200.0);
157 assert_eq!(evict, vec![1, 2], "tied heat → first two by input order");
158 }
159
160 #[test]
161 fn frequency_dominates_recency_at_equal_age() {
162 let entries = vec![
163 entry(1, 1000, 100.0), entry(2, 1, 100.0), ];
166 let evict = entries_to_evict(&entries, 1, 1000.0);
167 assert_eq!(evict, vec![2]);
168 }
169
170 #[test]
171 fn recency_dominates_frequency_at_equal_hits() {
172 let entries = vec![
174 entry(1, 10, 0.0), entry(2, 10, 3300.0), ];
177 let evict = entries_to_evict(&entries, 1, 3600.0);
178 assert_eq!(evict, vec![1], "older entry of same hit-count evicts first");
179 }
180
181 #[test]
182 fn heat_decays_with_age() {
183 let e = entry(0, 100, 0.0);
184 let fresh = e.heat(0.0);
185 let half_life = e.heat(DECAY_HALF_LIFE_S);
186 let two_half_lives = e.heat(2.0 * DECAY_HALF_LIFE_S);
187 assert!((fresh - 100.0).abs() < 1e-9);
188 assert!((half_life - 50.0).abs() < 1e-9);
189 assert!((two_half_lives - 25.0).abs() < 1e-9);
190 }
191
192 #[test]
193 fn non_finite_timestamps_never_become_sticky() {
194 let entries = vec![
195 entry(1, u32::MAX, f64::NAN),
196 entry(2, 1, 300.0),
197 entry(3, u32::MAX, f64::INFINITY),
198 ];
199 let evict = entries_to_evict(&entries, 1, 300.0);
200 assert_eq!(
201 evict,
202 vec![1, 3],
203 "malformed cache metadata must lose to a finite live entry"
204 );
205 }
206
207 #[test]
208 fn non_finite_current_time_is_total_and_deterministic() {
209 let entries = vec![
210 entry(1, 10, 100.0),
211 entry(2, 10, 100.0),
212 entry(3, 10, 100.0),
213 ];
214 let evict = entries_to_evict(&entries, 1, f64::NAN);
215 assert_eq!(
216 evict,
217 vec![1, 2],
218 "invalid clock samples must preserve deterministic eviction order"
219 );
220 }
221
222 #[test]
223 fn try_entries_to_evict_matches_legacy_order() {
224 let entries = vec![entry(1, 1, 0.0), entry(2, 10, 10.0), entry(3, 0, 20.0)];
225
226 assert_eq!(
227 try_entries_to_evict(&entries, 1, 20.0).unwrap(),
228 entries_to_evict(&entries, 1, 20.0)
229 );
230 }
231}