use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AdmissionError {
#[error("invalid admission config: {0}")]
InvalidConfig(String),
}
#[derive(Debug, Clone)]
pub struct AdmissionConfig {
pub admission_threshold: f64,
pub target_hit_rate: f64,
pub decay_factor: f64,
pub adjust_interval: u64,
pub threshold_step: f64,
pub min_threshold: f64,
pub max_threshold: f64,
}
impl Default for AdmissionConfig {
fn default() -> Self {
Self {
admission_threshold: 5.0,
target_hit_rate: 0.80,
decay_factor: 0.95,
adjust_interval: 100,
threshold_step: 0.5,
min_threshold: 1.0,
max_threshold: 50.0,
}
}
}
impl AdmissionConfig {
pub fn validate(&self) -> Result<(), AdmissionError> {
if self.admission_threshold < 1.0 {
return Err(AdmissionError::InvalidConfig(
"admission_threshold must be >= 1.0".into(),
));
}
if self.target_hit_rate <= 0.0 || self.target_hit_rate > 1.0 {
return Err(AdmissionError::InvalidConfig(
"target_hit_rate must be in (0.0, 1.0]".into(),
));
}
if self.decay_factor <= 0.0 || self.decay_factor > 1.0 {
return Err(AdmissionError::InvalidConfig(
"decay_factor must be in (0.0, 1.0]".into(),
));
}
if self.adjust_interval == 0 {
return Err(AdmissionError::InvalidConfig(
"adjust_interval must be > 0".into(),
));
}
if self.min_threshold < 1.0 {
return Err(AdmissionError::InvalidConfig(
"min_threshold must be >= 1.0".into(),
));
}
if self.max_threshold < self.min_threshold {
return Err(AdmissionError::InvalidConfig(
"max_threshold must be >= min_threshold".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdmissionDecision {
Admit,
Deny,
}
pub struct AdmissionFilter {
config: AdmissionConfig,
counters: HashMap<String, f64>,
threshold: f64,
decisions_since_adjust: u64,
hot_hits: u64,
hot_misses: u64,
total_accesses: u64,
total_admits: u64,
total_denies: u64,
}
impl AdmissionFilter {
pub fn new(config: AdmissionConfig) -> Result<Self, AdmissionError> {
config.validate()?;
let threshold = config.admission_threshold;
Ok(Self {
config,
counters: HashMap::new(),
threshold,
decisions_since_adjust: 0,
hot_hits: 0,
hot_misses: 0,
total_accesses: 0,
total_admits: 0,
total_denies: 0,
})
}
pub fn record_access(&mut self, key: &str) {
self.total_accesses += 1;
let factor = self.config.decay_factor;
for v in self.counters.values_mut() {
*v *= factor;
}
let counter = self.counters.entry(key.to_string()).or_insert(0.0);
*counter += 1.0;
self.counters.retain(|_, v| *v > 0.001);
}
#[must_use]
pub fn should_admit(&self, key: &str) -> bool {
let freq = self.counters.get(key).copied().unwrap_or(0.0);
freq >= self.threshold
}
pub fn admit(&mut self, key: &str) -> AdmissionDecision {
let freq = self.counters.get(key).copied().unwrap_or(0.0);
let decision = if freq >= self.threshold {
self.total_admits += 1;
AdmissionDecision::Admit
} else {
self.total_denies += 1;
AdmissionDecision::Deny
};
self.decisions_since_adjust += 1;
if self.decisions_since_adjust >= self.config.adjust_interval {
self.adjust_threshold();
self.decisions_since_adjust = 0;
self.hot_hits = 0;
self.hot_misses = 0;
}
decision
}
pub fn record_hot_hit(&mut self) {
self.hot_hits += 1;
}
pub fn record_hot_miss(&mut self) {
self.hot_misses += 1;
}
#[must_use]
pub fn threshold(&self) -> f64 {
self.threshold
}
#[must_use]
pub fn frequency(&self, key: &str) -> f64 {
self.counters.get(key).copied().unwrap_or(0.0)
}
#[must_use]
pub fn total_admits(&self) -> u64 {
self.total_admits
}
#[must_use]
pub fn total_denies(&self) -> u64 {
self.total_denies
}
#[must_use]
pub fn total_accesses(&self) -> u64 {
self.total_accesses
}
#[must_use]
pub fn tracked_keys(&self) -> usize {
self.counters.len()
}
#[must_use]
pub fn hot_tier_hit_rate(&self) -> f64 {
let total = self.hot_hits + self.hot_misses;
if total == 0 {
0.0
} else {
self.hot_hits as f64 / total as f64
}
}
#[must_use]
pub fn config(&self) -> &AdmissionConfig {
&self.config
}
pub fn reset(&mut self) {
self.counters.clear();
self.threshold = self.config.admission_threshold;
self.decisions_since_adjust = 0;
self.hot_hits = 0;
self.hot_misses = 0;
self.total_accesses = 0;
self.total_admits = 0;
self.total_denies = 0;
}
fn adjust_threshold(&mut self) {
let rate = self.hot_tier_hit_rate();
let step = self.config.threshold_step;
if rate < self.config.target_hit_rate {
self.threshold = (self.threshold + step).min(self.config.max_threshold);
} else if rate > self.config.target_hit_rate {
self.threshold = (self.threshold - step).max(self.config.min_threshold);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_filter() -> AdmissionFilter {
let cfg = AdmissionConfig {
admission_threshold: 3.0,
target_hit_rate: 0.80,
decay_factor: 0.90,
adjust_interval: 10,
threshold_step: 0.5,
min_threshold: 1.0,
max_threshold: 20.0,
};
AdmissionFilter::new(cfg).expect("valid config")
}
#[test]
fn test_new_filter_admits_nothing() {
let filter = default_filter();
assert!(!filter.should_admit("any-key"));
assert_eq!(filter.tracked_keys(), 0);
}
#[test]
fn test_key_admitted_after_accesses() {
let mut filter = default_filter(); for _ in 0..5 {
filter.record_access("hot");
}
assert!(filter.should_admit("hot"), "hot-key should be admitted");
}
#[test]
fn test_cold_key_denied() {
let mut filter = default_filter();
filter.record_access("cold"); assert!(!filter.should_admit("cold"));
}
#[test]
fn test_admit_decision_admit() {
let mut filter = default_filter();
for _ in 0..5 {
filter.record_access("k");
}
assert_eq!(filter.admit("k"), AdmissionDecision::Admit);
}
#[test]
fn test_admit_decision_deny() {
let mut filter = default_filter();
assert_eq!(filter.admit("unknown"), AdmissionDecision::Deny);
}
#[test]
fn test_decay_reduces_frequency() {
let mut filter = default_filter(); for _ in 0..5 {
filter.record_access("k");
}
let freq_after = filter.frequency("k");
for i in 0..50 {
filter.record_access(&format!("other-{i}"));
}
let freq_decayed = filter.frequency("k");
assert!(
freq_decayed < freq_after,
"frequency should decay: {freq_after} → {freq_decayed}"
);
}
#[test]
fn test_total_accesses_counter() {
let mut filter = default_filter();
for _ in 0..7 {
filter.record_access("k");
}
assert_eq!(filter.total_accesses(), 7);
}
#[test]
fn test_admits_denies_counters() {
let mut filter = default_filter();
for _ in 0..5 {
filter.record_access("hot");
}
let _ = filter.admit("hot"); let _ = filter.admit("cold"); assert_eq!(filter.total_admits(), 1);
assert_eq!(filter.total_denies(), 1);
}
#[test]
fn test_threshold_tightens_on_low_hit_rate() {
let mut filter = default_filter(); let initial_threshold = filter.threshold();
for _ in 0..10 {
filter.record_hot_miss();
}
for _ in 0..10 {
let _ = filter.admit("x");
}
assert!(
filter.threshold() > initial_threshold,
"threshold should tighten: {} → {}",
initial_threshold,
filter.threshold()
);
}
#[test]
fn test_threshold_relaxes_on_high_hit_rate() {
let mut filter = default_filter();
let initial_threshold = filter.threshold();
for _ in 0..20 {
filter.record_hot_hit();
}
for _ in 0..10 {
let _ = filter.admit("x");
}
assert!(
filter.threshold() < initial_threshold,
"threshold should relax: {} → {}",
initial_threshold,
filter.threshold()
);
}
#[test]
fn test_threshold_capped_at_max() {
let cfg = AdmissionConfig {
admission_threshold: 19.5,
max_threshold: 20.0,
threshold_step: 1.0,
adjust_interval: 5,
..AdmissionConfig::default()
};
let mut filter = AdmissionFilter::new(cfg).expect("valid");
for _ in 0..5 {
filter.record_hot_miss();
}
for _ in 0..5 {
let _ = filter.admit("x");
}
assert!(filter.threshold() <= 20.0);
}
#[test]
fn test_threshold_floored_at_min() {
let cfg = AdmissionConfig {
admission_threshold: 1.5,
min_threshold: 1.0,
threshold_step: 1.0,
adjust_interval: 5,
..AdmissionConfig::default()
};
let mut filter = AdmissionFilter::new(cfg).expect("valid");
for _ in 0..10 {
filter.record_hot_hit();
}
for _ in 0..5 {
let _ = filter.admit("x");
}
assert!(filter.threshold() >= 1.0);
}
#[test]
fn test_reset_clears_state() {
let mut filter = default_filter();
for _ in 0..5 {
filter.record_access("k");
}
let _ = filter.admit("k");
filter.reset();
assert_eq!(filter.total_accesses(), 0);
assert_eq!(filter.total_admits(), 0);
assert_eq!(filter.total_denies(), 0);
assert_eq!(filter.tracked_keys(), 0);
assert_eq!(filter.threshold(), 3.0); assert!(!filter.should_admit("k"));
}
#[test]
fn test_invalid_config_rejected() {
let mut cfg = AdmissionConfig::default();
cfg.admission_threshold = 0.5; assert!(AdmissionFilter::new(cfg).is_err());
}
#[test]
fn test_hot_tier_hit_rate_zero_initially() {
let filter = default_filter();
assert_eq!(filter.hit_rate_zero_check(), 0.0);
}
#[test]
fn test_tracked_keys_pruned_after_decay() {
let cfg = AdmissionConfig {
decay_factor: 0.01, admission_threshold: 3.0,
adjust_interval: 1000,
..AdmissionConfig::default()
};
let mut filter = AdmissionFilter::new(cfg).expect("valid");
filter.record_access("ephemeral");
let before = filter.tracked_keys();
for _ in 0..100 {
filter.record_access("other");
}
let after = filter.tracked_keys();
assert!(
after <= before,
"tracked keys should not grow: {before} → {after}"
);
}
#[test]
fn test_frequency_unknown_key() {
let filter = default_filter();
assert_eq!(filter.frequency("ghost"), 0.0);
}
#[test]
fn test_multiple_keys_independent() {
let mut filter = default_filter();
for _ in 0..5 {
filter.record_access("a");
}
filter.record_access("b");
assert!(filter.should_admit("a"));
assert!(!filter.should_admit("b"));
}
}
#[cfg(test)]
impl AdmissionFilter {
fn hit_rate_zero_check(&self) -> f64 {
self.hot_tier_hit_rate()
}
}