use std::cmp::Ordering;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub(crate) struct EntryFeatures {
pub key: String,
pub size: i64,
pub hit_count: i64,
pub idle_hours: f64,
pub content_hash: Option<String>,
pub committed: bool,
pub compile_time_ms: i64,
}
pub(crate) trait EvictionPolicy {
fn name(&self) -> &'static str;
fn select(&self, candidates: &[EntryFeatures]) -> Vec<String>;
}
pub(crate) fn size_pressure_score(e: &EntryFeatures) -> f64 {
let idle = e.idle_hours.max(0.01);
let size_mb = (e.size as f64 / 1_048_576.0).max(0.001);
(e.hit_count as f64 + 1.0) / (idle * size_mb)
}
pub(crate) struct SizePressurePolicy;
impl EvictionPolicy for SizePressurePolicy {
fn name(&self) -> &'static str {
"size-pressure"
}
fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
let mut ranked: Vec<&EntryFeatures> = candidates.iter().collect();
ranked.sort_by(|a, b| {
size_pressure_score(a)
.partial_cmp(&size_pressure_score(b))
.unwrap_or(Ordering::Equal)
});
ranked.into_iter().map(|e| e.key.clone()).collect()
}
}
pub(crate) struct OlderThanPolicy {
pub hours: u64,
}
impl EvictionPolicy for OlderThanPolicy {
fn name(&self) -> &'static str {
"older-than"
}
fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
let cutoff = self.hours as f64;
candidates
.iter()
.filter(|e| e.idle_hours > cutoff)
.map(|e| e.key.clone())
.collect()
}
}
pub(crate) struct DuplicatePolicy;
impl EvictionPolicy for DuplicatePolicy {
fn name(&self) -> &'static str {
"duplicate"
}
fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
let mut newest: HashMap<&str, f64> = HashMap::new();
let mut counts: HashMap<&str, usize> = HashMap::new();
for e in candidates {
if !e.committed {
continue;
}
let Some(hash) = e.content_hash.as_deref() else {
continue;
};
*counts.entry(hash).or_insert(0) += 1;
newest
.entry(hash)
.and_modify(|m| *m = m.min(e.idle_hours))
.or_insert(e.idle_hours);
}
candidates
.iter()
.filter(|e| {
if !e.committed {
return false;
}
let Some(hash) = e.content_hash.as_deref() else {
return false;
};
counts.get(hash).copied().unwrap_or(0) > 1
&& newest
.get(hash)
.is_some_and(|newest_idle| e.idle_hours > *newest_idle)
})
.map(|e| e.key.clone())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feat(key: &str, size: i64, hits: i64, idle: f64) -> EntryFeatures {
EntryFeatures {
key: key.into(),
size,
hit_count: hits,
idle_hours: idle,
content_hash: None,
committed: true,
compile_time_ms: 0,
}
}
#[test]
fn size_pressure_ranks_large_stale_unused_first() {
let big_stale = feat("big", 600 * 1024 * 1024, 0, 15.0);
let small_hot = feat("small", 14 * 1024, 9, 0.1);
assert!(size_pressure_score(&big_stale) < size_pressure_score(&small_hot));
let order = SizePressurePolicy.select(&[small_hot, big_stale]);
assert_eq!(order, vec!["big", "small"]);
}
#[test]
fn size_pressure_score_is_finite_at_the_clamps() {
assert!(size_pressure_score(&feat("fresh", 1024, 0, 0.0)).is_finite());
assert!(size_pressure_score(&feat("empty", 0, 0, 5.0)).is_finite());
assert!(size_pressure_score(&feat("skewed", 1024, 0, -3.0)).is_finite());
}
#[test]
fn older_than_selects_strictly_older_entries() {
let c = vec![
feat("old", 100, 0, 48.5),
feat("exactly", 100, 0, 24.0),
feat("fresh", 100, 0, 1.0),
];
let picked = OlderThanPolicy { hours: 24 }.select(&c);
assert_eq!(
picked,
vec!["old"],
"boundary entry must be kept (strict >)"
);
}
#[test]
fn duplicate_keeps_newest_and_ignores_singletons() {
let mut a = feat("dup_new", 100, 0, 1.0);
let mut b = feat("dup_old", 100, 0, 9.0);
let mut c = feat("dup_older", 100, 0, 20.0);
let mut lone = feat("lone", 100, 0, 99.0);
a.content_hash = Some("h1".into());
b.content_hash = Some("h1".into());
c.content_hash = Some("h1".into());
lone.content_hash = Some("h2".into());
let picked = DuplicatePolicy.select(&[a, b, c, lone]);
assert_eq!(picked, vec!["dup_old", "dup_older"]);
}
#[test]
fn duplicate_ignores_uncommitted_and_hashless() {
let mut committed = feat("committed", 100, 0, 1.0);
let mut uncommitted = feat("uncommitted", 100, 0, 9.0);
committed.content_hash = Some("h".into());
uncommitted.content_hash = Some("h".into());
uncommitted.committed = false;
assert!(DuplicatePolicy.select(&[committed, uncommitted]).is_empty());
}
#[test]
fn duplicate_keeps_all_entries_tied_at_newest() {
let mut a = feat("tie_a", 100, 0, 5.0);
let mut b = feat("tie_b", 100, 0, 5.0);
a.content_hash = Some("h".into());
b.content_hash = Some("h".into());
assert!(DuplicatePolicy.select(&[a, b]).is_empty());
}
}