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    try_entries_to_evict(entries, capacity, current_time_s).unwrap_or_default()
71}
72
73/// Fallible variant of [`entries_to_evict`] for daemon/cache paths that must
74/// report allocator pressure instead of panicking.
75///
76/// # Errors
77///
78/// Returns an actionable error when ranking/result staging cannot reserve.
79pub 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), // very recent, very hot
140            entry(2, 1, 0.0),     // ancient, cold
141        ];
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), // ancient but very hit
161            entry(2, 1, 100.0),    // ancient and rarely hit
162        ];
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        // Both have 10 hits; one was 5 minutes ago, one was 1 hour ago.
170        let entries = vec![
171            entry(1, 10, 0.0),    // 1 hour ago
172            entry(2, 10, 3300.0), // 5 minutes ago
173        ];
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}