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 try_entries_to_evict(entries, capacity, current_time_s).unwrap_or_default()
71}
72
73pub fn try_entries_to_evict(
80 entries: &[CacheEntryStats],
81 capacity: usize,
82 current_time_s: f64,
83) -> Result<Vec<u64>, String> {
84 if entries.len() <= capacity {
85 return Ok(Vec::new());
86 }
87 let mut ranked: Vec<(usize, &CacheEntryStats, f64)> = Vec::new();
88 crate::allocation::try_reserve_vec_to_capacity(&mut ranked, entries.len()).map_err(|error| {
89 format!(
90 "cache eviction heat ranking could not reserve {} entry slot(s): {error}. Fix: shard the pipeline cache eviction batch.",
91 entries.len()
92 )
93 })?;
94 ranked.extend(
95 entries
96 .iter()
97 .enumerate()
98 .map(|(idx, e)| (idx, e, e.heat(current_time_s))),
99 );
100 let compare = |a: &(usize, &CacheEntryStats, f64), b: &(usize, &CacheEntryStats, f64)| {
101 a.2.total_cmp(&b.2).then_with(|| a.0.cmp(&b.0))
102 };
103 let evict_count = entries.len() - capacity;
104 if evict_count < ranked.len() {
105 ranked.select_nth_unstable_by(evict_count, compare);
106 }
107 ranked[..evict_count].sort_by(compare);
108 let mut evicted = Vec::new();
109 crate::allocation::try_reserve_vec_to_capacity(&mut evicted, evict_count).map_err(|error| {
110 format!(
111 "cache eviction heat result could not reserve {evict_count} entry id slot(s): {error}. Fix: shard the pipeline cache eviction batch."
112 )
113 })?;
114 evicted.extend(ranked.into_iter().take(evict_count).map(|(_, e, _)| e.id));
115 Ok(evicted)
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 fn entry(id: u64, hits: u32, last_hit: f64) -> CacheEntryStats {
123 CacheEntryStats {
124 id,
125 hit_count: hits,
126 last_hit_time_s: last_hit,
127 }
128 }
129
130 #[test]
131 fn under_capacity_evicts_nothing() {
132 let entries = vec![entry(1, 10, 100.0), entry(2, 5, 200.0)];
133 assert!(entries_to_evict(&entries, 10, 300.0).is_empty());
134 }
135
136 #[test]
137 fn cold_entry_evicted_before_hot_one() {
138 let entries = vec![
139 entry(1, 100, 290.0), entry(2, 1, 0.0), ];
142 let evict = entries_to_evict(&entries, 1, 300.0);
143 assert_eq!(evict, vec![2], "ancient cold entry evicted first");
144 }
145
146 #[test]
147 fn equal_heat_evicts_in_input_order() {
148 let entries = vec![
149 entry(1, 10, 100.0),
150 entry(2, 10, 100.0),
151 entry(3, 10, 100.0),
152 ];
153 let evict = entries_to_evict(&entries, 1, 200.0);
154 assert_eq!(evict, vec![1, 2], "tied heat → first two by input order");
155 }
156
157 #[test]
158 fn frequency_dominates_recency_at_equal_age() {
159 let entries = vec![
160 entry(1, 1000, 100.0), entry(2, 1, 100.0), ];
163 let evict = entries_to_evict(&entries, 1, 1000.0);
164 assert_eq!(evict, vec![2]);
165 }
166
167 #[test]
168 fn recency_dominates_frequency_at_equal_hits() {
169 let entries = vec![
171 entry(1, 10, 0.0), entry(2, 10, 3300.0), ];
174 let evict = entries_to_evict(&entries, 1, 3600.0);
175 assert_eq!(evict, vec![1], "older entry of same hit-count evicts first");
176 }
177
178 #[test]
179 fn heat_decays_with_age() {
180 let e = entry(0, 100, 0.0);
181 let fresh = e.heat(0.0);
182 let half_life = e.heat(DECAY_HALF_LIFE_S);
183 let two_half_lives = e.heat(2.0 * DECAY_HALF_LIFE_S);
184 assert!((fresh - 100.0).abs() < 1e-9);
185 assert!((half_life - 50.0).abs() < 1e-9);
186 assert!((two_half_lives - 25.0).abs() < 1e-9);
187 }
188
189 #[test]
190 fn non_finite_timestamps_never_become_sticky() {
191 let entries = vec![
192 entry(1, u32::MAX, f64::NAN),
193 entry(2, 1, 300.0),
194 entry(3, u32::MAX, f64::INFINITY),
195 ];
196 let evict = entries_to_evict(&entries, 1, 300.0);
197 assert_eq!(
198 evict,
199 vec![1, 3],
200 "malformed cache metadata must lose to a finite live entry"
201 );
202 }
203
204 #[test]
205 fn non_finite_current_time_is_total_and_deterministic() {
206 let entries = vec![
207 entry(1, 10, 100.0),
208 entry(2, 10, 100.0),
209 entry(3, 10, 100.0),
210 ];
211 let evict = entries_to_evict(&entries, 1, f64::NAN);
212 assert_eq!(
213 evict,
214 vec![1, 2],
215 "invalid clock samples must preserve deterministic eviction order"
216 );
217 }
218
219 #[test]
220 fn try_entries_to_evict_matches_legacy_order() {
221 let entries = vec![entry(1, 1, 0.0), entry(2, 10, 10.0), entry(3, 0, 20.0)];
222
223 assert_eq!(
224 try_entries_to_evict(&entries, 1, 20.0).unwrap(),
225 entries_to_evict(&entries, 1, 20.0)
226 );
227 }
228}