Skip to main content

vyre_driver/
cache_eviction_heat.rs

1//! N5 substrate: spec-cache eviction policy with frequency × recency
2//! heat decay.
3//!
4//! F1/F3 cache compiled pipelines by `SpecCacheKey` but never evict.
5//! Long-running daemons that scan many repositories in sequence
6//! accumulate dead entries that pin VRAM-resident
7//! pipelines. This module owns the *decision*: given a list of
8//! cache entry stats and a capacity, return which entries to drop.
9//!
10//! The score is `hit_count / (1 + age_seconds / DECAY_HALF_LIFE_S)`  -
11//! a hot, recent entry stays; a cold, old entry leaves. Pure
12//! arithmetic; the actual cache surgery lives in the F1/F3 cache
13//! modules and is the consumer's responsibility.
14
15/// Half-life (seconds) for the heat decay term. Entries older than
16/// this lose half their hit-count weight; doubled, lose three
17/// quarters; etc. Tuned for scan workloads where a "warm" entry is
18/// one used in the last few minutes of a long sweep.
19pub const DECAY_HALF_LIFE_S: f64 = 300.0;
20
21/// Per-entry stats the eviction policy needs. Caller (the F1/F3
22/// cache layer) keeps these alongside each entry and passes a
23/// snapshot when capacity pressure triggers.
24#[derive(Debug, Clone, Copy)]
25pub struct CacheEntryStats {
26    /// Stable identifier for the entry (cache slot index, hash,
27    /// SpecCacheKey index, etc). Pure pass-through  -  the policy
28    /// only uses it to name which entries to evict.
29    pub id: u64,
30    /// Total hits since the entry was inserted.
31    pub hit_count: u32,
32    /// Wall-clock time (seconds since epoch or any monotonic clock)
33    /// the entry was last hit. Same clock reference as
34    /// `current_time_s`.
35    pub last_hit_time_s: f64,
36}
37
38impl CacheEntryStats {
39    /// Heat score: high = keep, low = evict. Combines frequency
40    /// (hit_count) with recency via exponential half-life decay.
41    #[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/// Decide which entry IDs to evict given a fixed capacity. Returns
58/// the IDs in eviction order (lowest heat first); caller drops
59/// until under capacity.
60///
61/// Entries with identical heat (e.g. two cold entries with the same
62/// `hit_count` and `last_hit_time_s`) are evicted in input order
63/// for determinism  -  bench reproducibility matters here.
64#[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
76/// Fallible variant of [`entries_to_evict`] for daemon/cache paths that must
77/// report allocator pressure instead of panicking.
78///
79/// # Errors
80///
81/// Returns an actionable error when ranking/result staging cannot reserve.
82pub 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), // very recent, very hot
143            entry(2, 1, 0.0),     // ancient, cold
144        ];
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), // ancient but very hit
164            entry(2, 1, 100.0),    // ancient and rarely hit
165        ];
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        // Both have 10 hits; one was 5 minutes ago, one was 1 hour ago.
173        let entries = vec![
174            entry(1, 10, 0.0),    // 1 hour ago
175            entry(2, 10, 3300.0), // 5 minutes ago
176        ];
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}