#![cfg(feature = "evalue-eviction")]
use std::sync::atomic::{AtomicU64, Ordering};
use dashmap::DashMap;
use fsqlite_types::PageNumber;
pub const DEFAULT_R_HIT: f64 = 1.5;
pub const DEFAULT_R_TICK: f64 = 0.95;
pub const DEFAULT_INITIAL_E: f64 = 1.0;
pub const E_VALUE_FLOOR: f64 = 1e-30;
pub const E_VALUE_CEIL: f64 = 1e30;
pub const DEFAULT_TICK_INTERVAL: u64 = 1024;
#[derive(Debug)]
struct AtomicF64(AtomicU64);
impl AtomicF64 {
fn new(value: f64) -> Self {
Self(AtomicU64::new(value.to_bits()))
}
#[inline]
fn load(&self, ordering: Ordering) -> f64 {
f64::from_bits(self.0.load(ordering))
}
fn fetch_update<F: FnMut(f64) -> f64>(&self, mut f: F) -> f64 {
let mut current = self.0.load(Ordering::Acquire);
loop {
let next = f(f64::from_bits(current));
let next_bits = next.to_bits();
match self.0.compare_exchange_weak(
current,
next_bits,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => return next,
Err(observed) => current = observed,
}
}
}
}
pub struct EValueEvictor {
pages: DashMap<PageNumber, AtomicF64>,
r_hit: f64,
r_tick: f64,
initial_e: f64,
}
impl std::fmt::Debug for EValueEvictor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EValueEvictor")
.field("pages", &self.pages.len())
.field("r_hit", &self.r_hit)
.field("r_tick", &self.r_tick)
.field("initial_e", &self.initial_e)
.finish()
}
}
impl Default for EValueEvictor {
fn default() -> Self {
Self::new()
}
}
impl EValueEvictor {
#[must_use]
pub fn new() -> Self {
Self::with_rates(DEFAULT_R_HIT, DEFAULT_R_TICK)
}
#[must_use]
pub fn with_rates(r_hit: f64, r_tick: f64) -> Self {
debug_assert!(r_hit > 1.0, "r_hit must be > 1 (got {r_hit})");
debug_assert!(
(0.0..1.0).contains(&r_tick),
"r_tick must be in (0, 1) (got {r_tick})"
);
let r_hit = r_hit.max(1.0 + f64::EPSILON);
let r_tick = r_tick.clamp(f64::EPSILON, 1.0 - f64::EPSILON);
Self {
pages: DashMap::new(),
r_hit,
r_tick,
initial_e: DEFAULT_INITIAL_E,
}
}
#[must_use]
#[inline]
pub fn r_hit(&self) -> f64 {
self.r_hit
}
#[must_use]
#[inline]
pub fn r_tick(&self) -> f64 {
self.r_tick
}
#[must_use]
pub fn tracked(&self) -> usize {
self.pages.len()
}
#[must_use]
pub fn e_value(&self, page: PageNumber) -> Option<f64> {
self.pages
.get(&page)
.map(|entry| entry.value().load(Ordering::Acquire))
}
pub fn record_access(&self, page: PageNumber) {
if let Some(entry) = self.pages.get(&page) {
let _ = entry.value().fetch_update(|current| {
let scaled = current * self.r_hit;
clamp_e(scaled)
});
return;
}
self.pages
.entry(page)
.and_modify(|cell| {
let _ = cell.fetch_update(|current| clamp_e(current * self.r_hit));
})
.or_insert_with(|| AtomicF64::new(clamp_e(self.initial_e * self.r_hit)));
}
pub fn tick(&self) {
for entry in &self.pages {
let cell = entry.value();
let _ = cell.fetch_update(|current| clamp_e(current * self.r_tick));
}
}
pub fn tick_n(&self, n: u32) {
if n == 0 {
return;
}
let factor = self.r_tick.powi(n as i32);
for entry in &self.pages {
let cell = entry.value();
let _ = cell.fetch_update(|current| clamp_e(current * factor));
}
}
pub fn forget(&self, page: PageNumber) {
self.pages.remove(&page);
}
pub fn clear(&self) {
self.pages.clear();
}
#[must_use]
pub fn choose_victim(&self, candidates: &[PageNumber]) -> Option<PageNumber> {
let mut best: Option<(PageNumber, f64)> = None;
for &page in candidates {
let e = self.e_value(page).unwrap_or(self.initial_e);
match best {
None => best = Some((page, e)),
Some((_, best_e)) if e < best_e => best = Some((page, e)),
_ => {}
}
}
best.map(|(page, _)| page)
}
#[must_use]
pub fn ville_pvalue(&self, page: PageNumber) -> f64 {
let e = self.e_value(page).unwrap_or(self.initial_e);
if e <= 0.0 {
return 1.0;
}
(1.0 / e).min(1.0)
}
}
#[inline]
fn clamp_e(value: f64) -> f64 {
if !value.is_finite() || value <= 0.0 {
return E_VALUE_FLOOR;
}
value.clamp(E_VALUE_FLOOR, E_VALUE_CEIL)
}
#[cfg(test)]
#[allow(
clippy::suboptimal_flops,
clippy::float_cmp,
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
mod tests {
use super::*;
fn pn(n: u32) -> PageNumber {
PageNumber::new(n).expect("nonzero page number")
}
#[test]
fn record_access_creates_and_grows_entry() {
let ev = EValueEvictor::new();
let p = pn(1);
assert_eq!(ev.e_value(p), None);
ev.record_access(p);
let after_first = ev.e_value(p).expect("tracked");
assert!((after_first - (DEFAULT_INITIAL_E * DEFAULT_R_HIT)).abs() < 1e-9);
ev.record_access(p);
let after_second = ev.e_value(p).expect("tracked");
assert!((after_second - (DEFAULT_INITIAL_E * DEFAULT_R_HIT.powi(2))).abs() < 1e-9);
}
#[test]
fn tick_decays_unaccessed_pages_toward_zero() {
let ev = EValueEvictor::with_rates(1.5, 0.5);
let p = pn(1);
ev.record_access(p);
let start = ev.e_value(p).unwrap();
assert!(start > 1.0);
for _ in 0..100 {
ev.tick();
}
let end = ev.e_value(p).unwrap();
assert!(end <= 1e-10, "expected decay to near zero, got {end}");
assert!(end >= E_VALUE_FLOOR, "must not underflow below floor");
}
#[test]
fn hot_page_grows_as_r_hit_pow_t() {
let ev = EValueEvictor::with_rates(2.0, 0.5);
let p = pn(42);
for _ in 0..10 {
ev.record_access(p);
ev.tick();
}
let observed = ev.e_value(p).unwrap();
assert!(
(observed - 1.0).abs() < 1e-6,
"balanced hot page should sit at null boundary, got {observed}"
);
let q = pn(43);
for _ in 0..10 {
ev.record_access(q);
}
let pure_grow = ev.e_value(q).unwrap();
assert!(
(pure_grow - 2.0_f64.powi(10)).abs() < 1e-6,
"pure growth should be r_hit^t = 1024, got {pure_grow}"
);
}
#[test]
fn mixture_property_accessed_half_the_time_stays_above_alpha() {
let ev = EValueEvictor::with_rates(1.5, 0.95);
let p = pn(7);
for tick_i in 0..100 {
if tick_i % 2 == 0 {
ev.record_access(p);
}
ev.tick();
}
let e = ev.e_value(p).unwrap();
let pval = ev.ville_pvalue(p);
assert!(
e > 20.0,
"page accessed 50% of ticks should have e_P > 20 (α=0.05), got {e}"
);
assert!(
pval < 0.05,
"ville_pvalue should reject null at α=0.05, got {pval}"
);
}
#[test]
fn choose_victim_picks_minimum_e_value() {
let a = pn(1);
let b = pn(2);
let c = pn(3);
let ev = EValueEvictor::with_rates(2.0, 0.5);
ev.record_access(a);
ev.tick();
ev.tick();
let ea = ev.e_value(a).unwrap();
assert!((ea - 0.5).abs() < 1e-9, "a should be 0.5, got {ea}");
ev.record_access(b);
let eb = ev.e_value(b).unwrap();
assert!((eb - 2.0).abs() < 1e-9, "b should be 2.0, got {eb}");
ev.record_access(c);
ev.record_access(c);
ev.record_access(c);
let ec = ev.e_value(c).unwrap();
assert!(ec > eb, "c should exceed b");
let victim = ev.choose_victim(&[a, b, c]).expect("some victim");
assert_eq!(victim, a, "should pick the minimum-e page");
let victim2 = ev.choose_victim(&[c, b, a]).expect("some victim");
assert_eq!(victim2, a);
}
#[test]
fn ville_pvalue_is_one_over_e() {
let ev = EValueEvictor::with_rates(2.0, 0.5);
let p = pn(99);
ev.record_access(p);
ev.record_access(p);
ev.record_access(p);
let e = ev.e_value(p).unwrap();
let pv = ev.ville_pvalue(p);
assert!((e - 8.0).abs() < 1e-9);
assert!((pv - 0.125).abs() < 1e-9, "1/8 = 0.125, got {pv}");
assert!((ev.ville_pvalue(pn(12345)) - 1.0).abs() < 1e-9);
}
#[test]
fn forget_removes_tracking() {
let ev = EValueEvictor::new();
let p = pn(55);
ev.record_access(p);
assert!(ev.e_value(p).is_some());
ev.forget(p);
assert!(ev.e_value(p).is_none());
}
#[test]
fn choose_victim_handles_untracked_candidates() {
let ev = EValueEvictor::with_rates(2.0, 0.5);
let tracked = pn(1);
let untracked = pn(2);
for _ in 0..5 {
ev.record_access(tracked);
}
let victim = ev.choose_victim(&[tracked, untracked]).unwrap();
assert_eq!(
victim, untracked,
"untracked page (e = initial = 1) should win against grown page"
);
}
#[test]
fn tick_n_matches_repeated_tick() {
let ev_a = EValueEvictor::with_rates(1.5, 0.9);
let ev_b = EValueEvictor::with_rates(1.5, 0.9);
let p = pn(10);
ev_a.record_access(p);
ev_b.record_access(p);
for _ in 0..25 {
ev_a.tick();
}
ev_b.tick_n(25);
let ea = ev_a.e_value(p).unwrap();
let eb = ev_b.e_value(p).unwrap();
assert!(
(ea - eb).abs() < 1e-9,
"tick_n(25) should match 25 ticks: {ea} vs {eb}"
);
}
#[test]
fn clamp_prevents_overflow_and_underflow() {
let ev = EValueEvictor::with_rates(1.5, 0.9);
let p = pn(1);
for _ in 0..1000 {
ev.record_access(p);
}
let e = ev.e_value(p).unwrap();
assert!(e <= E_VALUE_CEIL, "e={e} must not exceed CEIL");
assert!(e.is_finite());
let q = pn(2);
ev.record_access(q);
for _ in 0..5000 {
ev.tick();
}
let eq = ev.e_value(q).unwrap();
assert!(eq >= E_VALUE_FLOOR, "e={eq} must not underflow below FLOOR");
assert!(eq.is_finite());
}
#[test]
fn concurrent_record_access_preserves_growth() {
use std::sync::Arc;
use std::thread;
let ev = Arc::new(EValueEvictor::with_rates(1.5, 0.9));
let p = pn(1);
let mut handles = Vec::new();
for _ in 0..8 {
let ev_c = Arc::clone(&ev);
handles.push(thread::spawn(move || {
for _ in 0..100 {
ev_c.record_access(p);
}
}));
}
for h in handles {
h.join().unwrap();
}
let e = ev.e_value(p).unwrap();
assert!(e >= E_VALUE_CEIL * 0.99, "expected clamped CEIL, got {e}");
}
#[test]
fn clear_removes_all_tracked_pages() {
let ev = EValueEvictor::new();
for i in 1..=5 {
ev.record_access(pn(i));
}
assert_eq!(ev.tracked(), 5);
ev.clear();
assert_eq!(ev.tracked(), 0);
for i in 1..=5 {
assert!(ev.e_value(pn(i)).is_none());
}
}
#[test]
fn tick_n_zero_is_noop() {
let ev = EValueEvictor::with_rates(2.0, 0.5);
let p = pn(1);
ev.record_access(p);
let before = ev.e_value(p).unwrap();
ev.tick_n(0);
let after = ev.e_value(p).unwrap();
assert!((before - after).abs() < 1e-12);
}
#[test]
fn default_equals_new() {
let d = EValueEvictor::default();
let n = EValueEvictor::new();
assert!((d.r_hit() - n.r_hit()).abs() < 1e-12);
assert!((d.r_tick() - n.r_tick()).abs() < 1e-12);
assert_eq!(d.tracked(), n.tracked());
}
#[test]
fn choose_victim_empty_candidates_returns_none() {
let ev = EValueEvictor::new();
assert!(ev.choose_victim(&[]).is_none());
}
#[test]
fn debug_format_contains_expected_fields() {
let ev = EValueEvictor::with_rates(2.0, 0.5);
ev.record_access(pn(1));
ev.record_access(pn(2));
let dbg = format!("{ev:?}");
assert!(dbg.contains("EValueEvictor"));
assert!(dbg.contains("r_hit"));
assert!(dbg.contains("r_tick"));
assert!(dbg.contains("initial_e"));
assert!(dbg.contains("pages"), "should show page count");
}
#[test]
fn clamp_e_handles_nan_negative_infinity() {
assert_eq!(clamp_e(f64::NAN), E_VALUE_FLOOR);
assert_eq!(clamp_e(f64::NEG_INFINITY), E_VALUE_FLOOR);
assert_eq!(clamp_e(f64::INFINITY), E_VALUE_FLOOR);
assert_eq!(clamp_e(-1.0), E_VALUE_FLOOR);
assert_eq!(clamp_e(0.0), E_VALUE_FLOOR);
assert_eq!(clamp_e(E_VALUE_FLOOR), E_VALUE_FLOOR);
assert_eq!(clamp_e(E_VALUE_CEIL), E_VALUE_CEIL);
assert_eq!(clamp_e(E_VALUE_CEIL + 1.0), E_VALUE_CEIL);
assert_eq!(clamp_e(42.0), 42.0);
}
#[test]
fn choose_victim_single_candidate_returns_it() {
let ev = EValueEvictor::new();
let p = pn(7);
ev.record_access(p);
let victim = ev.choose_victim(&[p]);
assert_eq!(victim, Some(p));
}
#[test]
fn forget_is_idempotent_and_updates_tracked() {
let ev = EValueEvictor::new();
ev.record_access(pn(1));
ev.record_access(pn(2));
assert_eq!(ev.tracked(), 2);
ev.forget(pn(1));
assert_eq!(ev.tracked(), 1);
ev.forget(pn(1));
assert_eq!(ev.tracked(), 1, "second forget must be no-op");
assert!(ev.e_value(pn(2)).is_some(), "other page unaffected");
}
#[test]
fn with_rates_custom_and_accessors() {
let ev = EValueEvictor::with_rates(2.0, 0.5);
assert!((ev.r_hit() - 2.0).abs() < f64::EPSILON);
assert!((ev.r_tick() - 0.5).abs() < f64::EPSILON);
assert_eq!(ev.tracked(), 0);
}
#[test]
fn constants_sanity() {
assert!(DEFAULT_R_HIT > 1.0);
assert!(DEFAULT_R_TICK > 0.0 && DEFAULT_R_TICK < 1.0);
assert!(DEFAULT_INITIAL_E > 0.0);
assert!(E_VALUE_FLOOR > 0.0);
assert!(E_VALUE_CEIL > E_VALUE_FLOOR);
assert!(DEFAULT_TICK_INTERVAL > 0);
}
#[test]
fn ville_pvalue_untracked_returns_one() {
let ev = EValueEvictor::new();
let pv = ev.ville_pvalue(pn(99));
assert!((pv - 1.0).abs() < f64::EPSILON);
}
#[test]
fn tick_n_large_drives_toward_floor() {
let ev = EValueEvictor::new();
ev.record_access(pn(1));
ev.tick_n(10_000);
let e = ev.e_value(pn(1)).expect("still tracked");
assert!(e <= E_VALUE_FLOOR + f64::EPSILON);
}
}