use std::collections::VecDeque;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AdaptiveError {
#[error("invalid adaptive config: {0}")]
InvalidConfig(String),
}
#[derive(Debug, Clone)]
pub struct AdaptiveConfig {
pub target_hit_rate: f64,
pub tolerance: f64,
pub adjustment_interval: u64,
pub min_capacity: usize,
pub max_capacity: usize,
pub growth_factor: f64,
pub shrink_factor: f64,
pub ttl_extension: Duration,
pub ttl_reduction: Duration,
pub window_size: usize,
}
impl Default for AdaptiveConfig {
fn default() -> Self {
Self {
target_hit_rate: 0.80,
tolerance: 0.05,
adjustment_interval: 100,
min_capacity: 16,
max_capacity: 65536,
growth_factor: 1.5,
shrink_factor: 0.75,
ttl_extension: Duration::from_secs(30),
ttl_reduction: Duration::from_secs(10),
window_size: 200,
}
}
}
impl AdaptiveConfig {
pub fn validate(&self) -> Result<(), AdaptiveError> {
if self.target_hit_rate <= 0.0 || self.target_hit_rate > 1.0 {
return Err(AdaptiveError::InvalidConfig(
"target_hit_rate must be in (0.0, 1.0]".into(),
));
}
if self.tolerance < 0.0 || self.tolerance >= 1.0 {
return Err(AdaptiveError::InvalidConfig(
"tolerance must be in [0.0, 1.0)".into(),
));
}
if self.adjustment_interval == 0 {
return Err(AdaptiveError::InvalidConfig(
"adjustment_interval must be > 0".into(),
));
}
if self.min_capacity == 0 {
return Err(AdaptiveError::InvalidConfig(
"min_capacity must be > 0".into(),
));
}
if self.max_capacity < self.min_capacity {
return Err(AdaptiveError::InvalidConfig(
"max_capacity must be >= min_capacity".into(),
));
}
if self.growth_factor <= 1.0 {
return Err(AdaptiveError::InvalidConfig(
"growth_factor must be > 1.0".into(),
));
}
if self.shrink_factor <= 0.0 || self.shrink_factor >= 1.0 {
return Err(AdaptiveError::InvalidConfig(
"shrink_factor must be in (0.0, 1.0)".into(),
));
}
if self.window_size == 0 {
return Err(AdaptiveError::InvalidConfig(
"window_size must be > 0".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Observation {
Hit,
Miss,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdjustmentKind {
Grow,
Shrink,
NoChange,
}
#[derive(Debug, Clone)]
pub struct Adjustment {
pub kind: AdjustmentKind,
pub recommended_capacity: usize,
pub ttl_extend: Duration,
pub ttl_reduce: Duration,
pub observed_hit_rate: f64,
}
struct RollingWindow {
buffer: VecDeque<Observation>,
capacity: usize,
hits_in_window: u64,
}
impl RollingWindow {
fn new(capacity: usize) -> Self {
Self {
buffer: VecDeque::with_capacity(capacity),
capacity: capacity.max(1),
hits_in_window: 0,
}
}
fn push(&mut self, obs: Observation) {
if self.buffer.len() == self.capacity {
if let Some(old) = self.buffer.pop_front() {
if old == Observation::Hit {
self.hits_in_window = self.hits_in_window.saturating_sub(1);
}
}
}
if obs == Observation::Hit {
self.hits_in_window += 1;
}
self.buffer.push_back(obs);
}
fn hit_rate(&self) -> f64 {
if self.buffer.is_empty() {
return 0.0;
}
self.hits_in_window as f64 / self.buffer.len() as f64
}
fn len(&self) -> usize {
self.buffer.len()
}
fn clear(&mut self) {
self.buffer.clear();
self.hits_in_window = 0;
}
}
pub struct AdaptivePolicy {
config: AdaptiveConfig,
window: RollingWindow,
current_capacity: usize,
ops_since_eval: u64,
total_hits: u64,
total_misses: u64,
adjustments_made: u64,
history: Vec<AdjustmentRecord>,
max_history: usize,
}
#[derive(Debug, Clone)]
pub struct AdjustmentRecord {
pub adjustment: Adjustment,
pub at_operation: u64,
}
impl AdaptivePolicy {
pub fn new(config: AdaptiveConfig) -> Result<AdaptivePolicy, AdaptiveError> {
config.validate()?;
let initial_capacity =
config.min_capacity + (config.max_capacity - config.min_capacity) / 2;
let window = RollingWindow::new(config.window_size);
Ok(Self {
config,
window,
current_capacity: initial_capacity,
ops_since_eval: 0,
total_hits: 0,
total_misses: 0,
adjustments_made: 0,
history: Vec::new(),
max_history: 64,
})
}
pub fn with_initial_capacity(
config: AdaptiveConfig,
initial_capacity: usize,
) -> Result<AdaptivePolicy, AdaptiveError> {
config.validate()?;
let clamped = initial_capacity
.max(config.min_capacity)
.min(config.max_capacity);
let window = RollingWindow::new(config.window_size);
Ok(Self {
config,
window,
current_capacity: clamped,
ops_since_eval: 0,
total_hits: 0,
total_misses: 0,
adjustments_made: 0,
history: Vec::new(),
max_history: 64,
})
}
pub fn record_hit(&mut self) -> Option<Adjustment> {
self.total_hits += 1;
self.window.push(Observation::Hit);
self.ops_since_eval += 1;
self.maybe_evaluate()
}
pub fn record_miss(&mut self) -> Option<Adjustment> {
self.total_misses += 1;
self.window.push(Observation::Miss);
self.ops_since_eval += 1;
self.maybe_evaluate()
}
pub fn evaluate_now(&mut self) -> Adjustment {
self.ops_since_eval = 0;
self.compute_adjustment()
}
pub fn rolling_hit_rate(&self) -> f64 {
self.window.hit_rate()
}
pub fn total_hits(&self) -> u64 {
self.total_hits
}
pub fn total_misses(&self) -> u64 {
self.total_misses
}
pub fn lifetime_hit_rate(&self) -> f64 {
let total = self.total_hits + self.total_misses;
if total == 0 {
0.0
} else {
self.total_hits as f64 / total as f64
}
}
pub fn current_capacity(&self) -> usize {
self.current_capacity
}
pub fn adjustments_made(&self) -> u64 {
self.adjustments_made
}
pub fn history(&self) -> &[AdjustmentRecord] {
&self.history
}
pub fn config(&self) -> &AdaptiveConfig {
&self.config
}
pub fn window_fill(&self) -> usize {
self.window.len()
}
pub fn reset(&mut self) {
self.window.clear();
self.ops_since_eval = 0;
self.total_hits = 0;
self.total_misses = 0;
self.adjustments_made = 0;
self.history.clear();
self.current_capacity =
self.config.min_capacity + (self.config.max_capacity - self.config.min_capacity) / 2;
}
fn maybe_evaluate(&mut self) -> Option<Adjustment> {
if self.ops_since_eval >= self.config.adjustment_interval {
self.ops_since_eval = 0;
let adj = self.compute_adjustment();
Some(adj)
} else {
None
}
}
fn compute_adjustment(&mut self) -> Adjustment {
let rate = self.window.hit_rate();
let target = self.config.target_hit_rate;
let tol = self.config.tolerance;
let (kind, new_cap, ttl_ext, ttl_red) = if rate < target - tol {
let raw = (self.current_capacity as f64 * self.config.growth_factor).ceil() as usize;
let capped = raw.min(self.config.max_capacity);
(
AdjustmentKind::Grow,
capped,
self.config.ttl_extension,
Duration::ZERO,
)
} else if rate > target + tol {
let raw = (self.current_capacity as f64 * self.config.shrink_factor).floor() as usize;
let floored = raw.max(self.config.min_capacity);
(
AdjustmentKind::Shrink,
floored,
Duration::ZERO,
self.config.ttl_reduction,
)
} else {
(
AdjustmentKind::NoChange,
self.current_capacity,
Duration::ZERO,
Duration::ZERO,
)
};
self.current_capacity = new_cap;
if kind != AdjustmentKind::NoChange {
self.adjustments_made += 1;
}
let adj = Adjustment {
kind,
recommended_capacity: new_cap,
ttl_extend: ttl_ext,
ttl_reduce: ttl_red,
observed_hit_rate: rate,
};
let total_ops = self.total_hits + self.total_misses;
self.history.push(AdjustmentRecord {
adjustment: adj.clone(),
at_operation: total_ops,
});
if self.history.len() > self.max_history {
self.history.remove(0);
}
adj
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> AdaptiveConfig {
AdaptiveConfig {
target_hit_rate: 0.80,
tolerance: 0.05,
adjustment_interval: 10,
min_capacity: 8,
max_capacity: 256,
growth_factor: 2.0,
shrink_factor: 0.5,
ttl_extension: Duration::from_secs(10),
ttl_reduction: Duration::from_secs(5),
window_size: 50,
}
}
#[test]
fn test_valid_config_creates_policy() {
let cfg = default_config();
let policy = AdaptivePolicy::new(cfg);
assert!(policy.is_ok());
}
#[test]
fn test_invalid_target_hit_rate() {
let mut cfg = default_config();
cfg.target_hit_rate = 0.0;
assert!(AdaptivePolicy::new(cfg.clone()).is_err());
cfg.target_hit_rate = 1.5;
assert!(AdaptivePolicy::new(cfg).is_err());
}
#[test]
fn test_invalid_growth_factor() {
let mut cfg = default_config();
cfg.growth_factor = 0.5;
assert!(AdaptivePolicy::new(cfg).is_err());
}
#[test]
fn test_invalid_shrink_factor() {
let mut cfg = default_config();
cfg.shrink_factor = 1.0;
assert!(AdaptivePolicy::new(cfg.clone()).is_err());
cfg.shrink_factor = 0.0;
assert!(AdaptivePolicy::new(cfg).is_err());
}
#[test]
fn test_all_hits_triggers_shrink() {
let cfg = default_config();
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
let initial_cap = policy.current_capacity();
let mut adj = None;
for _ in 0..10 {
adj = policy.record_hit();
}
let adjustment = adj.expect("should trigger after 10 ops");
assert_eq!(adjustment.kind, AdjustmentKind::Shrink);
assert!(
policy.current_capacity() < initial_cap,
"capacity should have decreased"
);
}
#[test]
fn test_all_misses_triggers_grow() {
let cfg = default_config();
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
let initial_cap = policy.current_capacity();
let mut adj = None;
for _ in 0..10 {
adj = policy.record_miss();
}
let adjustment = adj.expect("should trigger after 10 ops");
assert_eq!(adjustment.kind, AdjustmentKind::Grow);
assert!(
policy.current_capacity() > initial_cap,
"capacity should have increased"
);
}
#[test]
fn test_within_tolerance_no_change() {
let cfg = default_config(); let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
for _ in 0..8 {
let _ = policy.record_hit();
}
let mut adj = None;
for _ in 0..2 {
adj = policy.record_miss();
}
let adjustment = adj.expect("should trigger after 10 ops");
assert_eq!(adjustment.kind, AdjustmentKind::NoChange);
}
#[test]
fn test_capacity_capped_at_max() {
let mut cfg = default_config();
cfg.max_capacity = 300;
cfg.growth_factor = 100.0; let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
for _ in 0..10 {
let _ = policy.record_miss();
}
assert!(
policy.current_capacity() <= 300,
"capacity {} should not exceed max 300",
policy.current_capacity()
);
}
#[test]
fn test_capacity_floored_at_min() {
let mut cfg = default_config();
cfg.min_capacity = 4;
cfg.shrink_factor = 0.01; let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
for _ in 0..10 {
let _ = policy.record_hit();
}
assert!(
policy.current_capacity() >= 4,
"capacity {} should not drop below min 4",
policy.current_capacity()
);
}
#[test]
fn test_rolling_window_discards_old() {
let mut cfg = default_config();
cfg.window_size = 10;
cfg.adjustment_interval = 10;
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
for _ in 0..10 {
let _ = policy.record_miss();
}
for _ in 0..10 {
let _ = policy.record_hit();
}
let rate = policy.rolling_hit_rate();
assert!(
(rate - 1.0).abs() < 1e-9,
"rolling hit rate should be 1.0, got {rate}"
);
}
#[test]
fn test_evaluate_now() {
let cfg = default_config();
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
for _ in 0..3 {
let _ = policy.record_hit();
}
let adj = policy.evaluate_now();
assert_eq!(adj.kind, AdjustmentKind::Shrink);
}
#[test]
fn test_reset_clears_state() {
let cfg = default_config();
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
for _ in 0..20 {
let _ = policy.record_hit();
}
assert!(policy.total_hits() > 0);
assert!(!policy.history().is_empty());
policy.reset();
assert_eq!(policy.total_hits(), 0);
assert_eq!(policy.total_misses(), 0);
assert_eq!(policy.adjustments_made(), 0);
assert!(policy.history().is_empty());
assert_eq!(policy.window_fill(), 0);
}
#[test]
fn test_ttl_extension_on_grow() {
let cfg = default_config();
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
let mut adj = None;
for _ in 0..10 {
adj = policy.record_miss();
}
let adjustment = adj.expect("should trigger");
assert_eq!(adjustment.kind, AdjustmentKind::Grow);
assert_eq!(adjustment.ttl_extend, Duration::from_secs(10));
assert_eq!(adjustment.ttl_reduce, Duration::ZERO);
}
#[test]
fn test_ttl_reduction_on_shrink() {
let cfg = default_config();
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
let mut adj = None;
for _ in 0..10 {
adj = policy.record_hit();
}
let adjustment = adj.expect("should trigger");
assert_eq!(adjustment.kind, AdjustmentKind::Shrink);
assert_eq!(adjustment.ttl_extend, Duration::ZERO);
assert_eq!(adjustment.ttl_reduce, Duration::from_secs(5));
}
#[test]
fn test_lifetime_hit_rate() {
let cfg = default_config();
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
for _ in 0..7 {
let _ = policy.record_hit();
}
for _ in 0..3 {
let _ = policy.record_miss();
}
let rate = policy.lifetime_hit_rate();
assert!((rate - 0.7).abs() < 1e-9, "expected 0.7, got {rate}");
}
#[test]
fn test_history_records() {
let cfg = default_config(); let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
for _ in 0..20 {
let _ = policy.record_hit();
}
assert!(
policy.history().len() >= 2,
"expected at least 2 history records, got {}",
policy.history().len()
);
}
#[test]
fn test_with_initial_capacity_clamps() {
let cfg = default_config(); let p1 = AdaptivePolicy::with_initial_capacity(cfg.clone(), 2).expect("valid");
assert_eq!(p1.current_capacity(), 8, "should clamp to min");
let p2 = AdaptivePolicy::with_initial_capacity(cfg, 9999).expect("valid");
assert_eq!(p2.current_capacity(), 256, "should clamp to max");
}
#[test]
fn test_min_equals_max_capacity() {
let mut cfg = default_config();
cfg.min_capacity = 64;
cfg.max_capacity = 64;
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
for _ in 0..10 {
let _ = policy.record_miss();
}
assert_eq!(policy.current_capacity(), 64);
for _ in 0..10 {
let _ = policy.record_hit();
}
assert_eq!(policy.current_capacity(), 64);
}
#[test]
fn test_zero_adjustment_interval_rejected() {
let mut cfg = default_config();
cfg.adjustment_interval = 0;
assert!(AdaptivePolicy::new(cfg).is_err());
}
#[test]
fn test_zero_window_size_rejected() {
let mut cfg = default_config();
cfg.window_size = 0;
assert!(AdaptivePolicy::new(cfg).is_err());
}
#[test]
fn test_successive_grow_monotonic() {
let mut cfg = default_config();
cfg.max_capacity = 100_000;
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
let mut prev_cap = policy.current_capacity();
for round in 0..5 {
for _ in 0..10 {
let _ = policy.record_miss();
}
let cap = policy.current_capacity();
assert!(
cap >= prev_cap,
"round {round}: capacity should not decrease on grow ({prev_cap} → {cap})"
);
prev_cap = cap;
}
}
#[test]
fn test_observed_hit_rate_in_adjustment() {
let cfg = default_config();
let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
for _ in 0..5 {
let _ = policy.record_hit();
}
for _ in 0..5 {
let _ = policy.record_miss();
}
let last_hist = policy.history().last().expect("should have history");
let rate = last_hist.adjustment.observed_hit_rate;
assert!(
(rate - 0.5).abs() < 1e-9,
"expected 0.5 hit rate, got {rate}"
);
}
}