Skip to main content

oximedia_cache/
adaptive.rs

1//! Adaptive cache threshold management.
2//!
3//! Dynamically adjusts cache eviction thresholds based on rolling hit-rate
4//! statistics.  When the observed hit rate drops below a configurable target
5//! the policy widens the cache (increases capacity or extends TTL); when the
6//! hit rate exceeds the target by a comfortable margin the policy contracts
7//! the cache to free resources.
8//!
9//! # Design
10//!
11//! [`AdaptivePolicy`] maintains a circular ring buffer of recent hit/miss
12//! observations.  Every `adjustment_interval` operations it computes the
13//! rolling hit rate over the window and compares it against the configured
14//! `target_hit_rate`.
15//!
16//! * **Hit rate too low** → capacity is increased by `growth_factor` (capped
17//!   at `max_capacity`) and TTL is extended by `ttl_extension`.
18//! * **Hit rate comfortably high** → capacity is decreased by `shrink_factor`
19//!   (floored at `min_capacity`) and TTL is shortened by `ttl_reduction`.
20//! * **Hit rate within band** → no change.
21//!
22//! The policy itself does **not** own a cache; it emits [`Adjustment`]
23//! recommendations that the caller applies to their cache of choice.
24//!
25//! # Example
26//!
27//! ```rust
28//! use oximedia_cache::adaptive::{AdaptiveConfig, AdaptivePolicy};
29//! use std::time::Duration;
30//!
31//! let cfg = AdaptiveConfig {
32//!     target_hit_rate: 0.80,
33//!     tolerance: 0.05,
34//!     adjustment_interval: 20,
35//!     min_capacity: 16,
36//!     max_capacity: 4096,
37//!     growth_factor: 1.5,
38//!     shrink_factor: 0.75,
39//!     ttl_extension: Duration::from_secs(10),
40//!     ttl_reduction: Duration::from_secs(5),
41//!     window_size: 100,
42//! };
43//! let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
44//!
45//! // Simulate 20 cache hits.
46//! for _ in 0..20 {
47//!     let _ = policy.record_hit();
48//! }
49//! ```
50
51use std::collections::VecDeque;
52use std::time::Duration;
53
54use thiserror::Error;
55
56// ── Errors ───────────────────────────────────────────────────────────────────
57
58/// Errors returned by [`AdaptivePolicy`] operations.
59#[derive(Debug, Error)]
60pub enum AdaptiveError {
61    /// The supplied configuration is invalid.
62    #[error("invalid adaptive config: {0}")]
63    InvalidConfig(String),
64}
65
66// ── AdaptiveConfig ───────────────────────────────────────────────────────────
67
68/// Configuration for [`AdaptivePolicy`].
69#[derive(Debug, Clone)]
70pub struct AdaptiveConfig {
71    /// Desired cache hit rate as a fraction in `(0.0, 1.0]`.
72    pub target_hit_rate: f64,
73    /// Half-width of the "good enough" band around `target_hit_rate`.
74    ///
75    /// If the observed rate is within `[target - tolerance, target + tolerance]`
76    /// no adjustment is made.
77    pub tolerance: f64,
78    /// Number of operations between adjustment evaluations.
79    pub adjustment_interval: u64,
80    /// Minimum cache capacity the policy will ever recommend.
81    pub min_capacity: usize,
82    /// Maximum cache capacity the policy will ever recommend.
83    pub max_capacity: usize,
84    /// Multiplicative factor for capacity growth (e.g. `1.5` → +50%).
85    pub growth_factor: f64,
86    /// Multiplicative factor for capacity shrink (e.g. `0.75` → −25%).
87    pub shrink_factor: f64,
88    /// Duration added to TTL when the cache is under-performing.
89    pub ttl_extension: Duration,
90    /// Duration removed from TTL when the cache is over-provisioned.
91    pub ttl_reduction: Duration,
92    /// Number of recent observations kept in the rolling window.
93    pub window_size: usize,
94}
95
96impl Default for AdaptiveConfig {
97    fn default() -> Self {
98        Self {
99            target_hit_rate: 0.80,
100            tolerance: 0.05,
101            adjustment_interval: 100,
102            min_capacity: 16,
103            max_capacity: 65536,
104            growth_factor: 1.5,
105            shrink_factor: 0.75,
106            ttl_extension: Duration::from_secs(30),
107            ttl_reduction: Duration::from_secs(10),
108            window_size: 200,
109        }
110    }
111}
112
113impl AdaptiveConfig {
114    /// Validate the configuration, returning an error if any field is
115    /// out of range.
116    pub fn validate(&self) -> Result<(), AdaptiveError> {
117        if self.target_hit_rate <= 0.0 || self.target_hit_rate > 1.0 {
118            return Err(AdaptiveError::InvalidConfig(
119                "target_hit_rate must be in (0.0, 1.0]".into(),
120            ));
121        }
122        if self.tolerance < 0.0 || self.tolerance >= 1.0 {
123            return Err(AdaptiveError::InvalidConfig(
124                "tolerance must be in [0.0, 1.0)".into(),
125            ));
126        }
127        if self.adjustment_interval == 0 {
128            return Err(AdaptiveError::InvalidConfig(
129                "adjustment_interval must be > 0".into(),
130            ));
131        }
132        if self.min_capacity == 0 {
133            return Err(AdaptiveError::InvalidConfig(
134                "min_capacity must be > 0".into(),
135            ));
136        }
137        if self.max_capacity < self.min_capacity {
138            return Err(AdaptiveError::InvalidConfig(
139                "max_capacity must be >= min_capacity".into(),
140            ));
141        }
142        if self.growth_factor <= 1.0 {
143            return Err(AdaptiveError::InvalidConfig(
144                "growth_factor must be > 1.0".into(),
145            ));
146        }
147        if self.shrink_factor <= 0.0 || self.shrink_factor >= 1.0 {
148            return Err(AdaptiveError::InvalidConfig(
149                "shrink_factor must be in (0.0, 1.0)".into(),
150            ));
151        }
152        if self.window_size == 0 {
153            return Err(AdaptiveError::InvalidConfig(
154                "window_size must be > 0".into(),
155            ));
156        }
157        Ok(())
158    }
159}
160
161// ── Observation ──────────────────────────────────────────────────────────────
162
163/// A single cache operation observation.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165enum Observation {
166    Hit,
167    Miss,
168}
169
170// ── Adjustment ───────────────────────────────────────────────────────────────
171
172/// The direction of an adaptive adjustment.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum AdjustmentKind {
175    /// The cache should grow (hit rate is too low).
176    Grow,
177    /// The cache should shrink (hit rate is comfortably above target).
178    Shrink,
179    /// No change needed (hit rate is within tolerance band).
180    NoChange,
181}
182
183/// A recommended adjustment emitted by [`AdaptivePolicy::evaluate`].
184#[derive(Debug, Clone)]
185pub struct Adjustment {
186    /// What kind of change is recommended.
187    pub kind: AdjustmentKind,
188    /// Recommended new cache capacity.
189    pub recommended_capacity: usize,
190    /// Recommended TTL delta (positive = extend, negative = reduce).
191    ///
192    /// For [`AdjustmentKind::Grow`] this is `+ ttl_extension`.
193    /// For [`AdjustmentKind::Shrink`] this is `- ttl_reduction` (represented
194    /// as a negative duration is awkward, so we split into two fields).
195    pub ttl_extend: Duration,
196    /// TTL reduction (only non-zero for [`AdjustmentKind::Shrink`]).
197    pub ttl_reduce: Duration,
198    /// The rolling hit rate that triggered this adjustment.
199    pub observed_hit_rate: f64,
200}
201
202// ── RollingWindow ────────────────────────────────────────────────────────────
203
204/// Fixed-capacity circular buffer of observations for computing a rolling
205/// hit rate.
206struct RollingWindow {
207    buffer: VecDeque<Observation>,
208    capacity: usize,
209    hits_in_window: u64,
210}
211
212impl RollingWindow {
213    fn new(capacity: usize) -> Self {
214        Self {
215            buffer: VecDeque::with_capacity(capacity),
216            capacity: capacity.max(1),
217            hits_in_window: 0,
218        }
219    }
220
221    fn push(&mut self, obs: Observation) {
222        if self.buffer.len() == self.capacity {
223            if let Some(old) = self.buffer.pop_front() {
224                if old == Observation::Hit {
225                    self.hits_in_window = self.hits_in_window.saturating_sub(1);
226                }
227            }
228        }
229        if obs == Observation::Hit {
230            self.hits_in_window += 1;
231        }
232        self.buffer.push_back(obs);
233    }
234
235    fn hit_rate(&self) -> f64 {
236        if self.buffer.is_empty() {
237            return 0.0;
238        }
239        self.hits_in_window as f64 / self.buffer.len() as f64
240    }
241
242    fn len(&self) -> usize {
243        self.buffer.len()
244    }
245
246    fn clear(&mut self) {
247        self.buffer.clear();
248        self.hits_in_window = 0;
249    }
250}
251
252// ── AdaptivePolicy ───────────────────────────────────────────────────────────
253
254/// Adaptive cache threshold policy.
255///
256/// Tracks rolling hit/miss observations and periodically evaluates whether
257/// the cache should grow or shrink.  The policy itself does **not** mutate
258/// any cache; it produces [`Adjustment`] recommendations.
259pub struct AdaptivePolicy {
260    config: AdaptiveConfig,
261    window: RollingWindow,
262    /// Current recommended capacity (starts at the midpoint of min/max).
263    current_capacity: usize,
264    /// Counter of operations since the last evaluation.
265    ops_since_eval: u64,
266    /// Total hits recorded since creation / last reset.
267    total_hits: u64,
268    /// Total misses recorded since creation / last reset.
269    total_misses: u64,
270    /// Number of adjustments made.
271    adjustments_made: u64,
272    /// History of the last N adjustment snapshots.
273    history: Vec<AdjustmentRecord>,
274    /// Maximum history length.
275    max_history: usize,
276}
277
278/// A record stored in the adjustment history.
279#[derive(Debug, Clone)]
280pub struct AdjustmentRecord {
281    /// The adjustment that was made.
282    pub adjustment: Adjustment,
283    /// Cumulative operation count at the time of this adjustment.
284    pub at_operation: u64,
285}
286
287impl AdaptivePolicy {
288    /// Create a new policy from the given configuration.
289    ///
290    /// Returns an error if the configuration is invalid.
291    pub fn new(config: AdaptiveConfig) -> Result<AdaptivePolicy, AdaptiveError> {
292        config.validate()?;
293        let initial_capacity =
294            config.min_capacity + (config.max_capacity - config.min_capacity) / 2;
295        let window = RollingWindow::new(config.window_size);
296        Ok(Self {
297            config,
298            window,
299            current_capacity: initial_capacity,
300            ops_since_eval: 0,
301            total_hits: 0,
302            total_misses: 0,
303            adjustments_made: 0,
304            history: Vec::new(),
305            max_history: 64,
306        })
307    }
308
309    /// Create a policy with a specific initial capacity.
310    pub fn with_initial_capacity(
311        config: AdaptiveConfig,
312        initial_capacity: usize,
313    ) -> Result<AdaptivePolicy, AdaptiveError> {
314        config.validate()?;
315        let clamped = initial_capacity
316            .max(config.min_capacity)
317            .min(config.max_capacity);
318        let window = RollingWindow::new(config.window_size);
319        Ok(Self {
320            config,
321            window,
322            current_capacity: clamped,
323            ops_since_eval: 0,
324            total_hits: 0,
325            total_misses: 0,
326            adjustments_made: 0,
327            history: Vec::new(),
328            max_history: 64,
329        })
330    }
331
332    // ── Recording ────────────────────────────────────────────────────────────
333
334    /// Record a cache hit.
335    ///
336    /// Returns `Some(Adjustment)` when this observation triggers an
337    /// evaluation (every `adjustment_interval` operations), `None` otherwise.
338    pub fn record_hit(&mut self) -> Option<Adjustment> {
339        self.total_hits += 1;
340        self.window.push(Observation::Hit);
341        self.ops_since_eval += 1;
342        self.maybe_evaluate()
343    }
344
345    /// Record a cache miss.
346    ///
347    /// Returns `Some(Adjustment)` when this observation triggers an
348    /// evaluation, `None` otherwise.
349    pub fn record_miss(&mut self) -> Option<Adjustment> {
350        self.total_misses += 1;
351        self.window.push(Observation::Miss);
352        self.ops_since_eval += 1;
353        self.maybe_evaluate()
354    }
355
356    /// Force an evaluation regardless of the operation counter.
357    pub fn evaluate_now(&mut self) -> Adjustment {
358        self.ops_since_eval = 0;
359        self.compute_adjustment()
360    }
361
362    // ── Accessors ────────────────────────────────────────────────────────────
363
364    /// Current rolling hit rate.
365    pub fn rolling_hit_rate(&self) -> f64 {
366        self.window.hit_rate()
367    }
368
369    /// Total hits since creation / last reset.
370    pub fn total_hits(&self) -> u64 {
371        self.total_hits
372    }
373
374    /// Total misses since creation / last reset.
375    pub fn total_misses(&self) -> u64 {
376        self.total_misses
377    }
378
379    /// Lifetime hit rate (not rolling-windowed).
380    pub fn lifetime_hit_rate(&self) -> f64 {
381        let total = self.total_hits + self.total_misses;
382        if total == 0 {
383            0.0
384        } else {
385            self.total_hits as f64 / total as f64
386        }
387    }
388
389    /// Currently recommended capacity.
390    pub fn current_capacity(&self) -> usize {
391        self.current_capacity
392    }
393
394    /// Number of adjustments made so far.
395    pub fn adjustments_made(&self) -> u64 {
396        self.adjustments_made
397    }
398
399    /// Read-only access to the adjustment history.
400    pub fn history(&self) -> &[AdjustmentRecord] {
401        &self.history
402    }
403
404    /// The active configuration.
405    pub fn config(&self) -> &AdaptiveConfig {
406        &self.config
407    }
408
409    /// Total number of observations in the rolling window.
410    pub fn window_fill(&self) -> usize {
411        self.window.len()
412    }
413
414    // ── Reset ────────────────────────────────────────────────────────────────
415
416    /// Reset all counters and the rolling window, keeping the current
417    /// configuration.
418    pub fn reset(&mut self) {
419        self.window.clear();
420        self.ops_since_eval = 0;
421        self.total_hits = 0;
422        self.total_misses = 0;
423        self.adjustments_made = 0;
424        self.history.clear();
425        self.current_capacity =
426            self.config.min_capacity + (self.config.max_capacity - self.config.min_capacity) / 2;
427    }
428
429    // ── Internals ────────────────────────────────────────────────────────────
430
431    fn maybe_evaluate(&mut self) -> Option<Adjustment> {
432        if self.ops_since_eval >= self.config.adjustment_interval {
433            self.ops_since_eval = 0;
434            let adj = self.compute_adjustment();
435            Some(adj)
436        } else {
437            None
438        }
439    }
440
441    fn compute_adjustment(&mut self) -> Adjustment {
442        let rate = self.window.hit_rate();
443        let target = self.config.target_hit_rate;
444        let tol = self.config.tolerance;
445
446        let (kind, new_cap, ttl_ext, ttl_red) = if rate < target - tol {
447            // Under-performing → grow.
448            let raw = (self.current_capacity as f64 * self.config.growth_factor).ceil() as usize;
449            let capped = raw.min(self.config.max_capacity);
450            (
451                AdjustmentKind::Grow,
452                capped,
453                self.config.ttl_extension,
454                Duration::ZERO,
455            )
456        } else if rate > target + tol {
457            // Over-provisioned → shrink.
458            let raw = (self.current_capacity as f64 * self.config.shrink_factor).floor() as usize;
459            let floored = raw.max(self.config.min_capacity);
460            (
461                AdjustmentKind::Shrink,
462                floored,
463                Duration::ZERO,
464                self.config.ttl_reduction,
465            )
466        } else {
467            // Within tolerance band → no change.
468            (
469                AdjustmentKind::NoChange,
470                self.current_capacity,
471                Duration::ZERO,
472                Duration::ZERO,
473            )
474        };
475
476        self.current_capacity = new_cap;
477        if kind != AdjustmentKind::NoChange {
478            self.adjustments_made += 1;
479        }
480
481        let adj = Adjustment {
482            kind,
483            recommended_capacity: new_cap,
484            ttl_extend: ttl_ext,
485            ttl_reduce: ttl_red,
486            observed_hit_rate: rate,
487        };
488
489        // Record in history, capping at max_history.
490        let total_ops = self.total_hits + self.total_misses;
491        self.history.push(AdjustmentRecord {
492            adjustment: adj.clone(),
493            at_operation: total_ops,
494        });
495        if self.history.len() > self.max_history {
496            self.history.remove(0);
497        }
498
499        adj
500    }
501}
502
503// ── Tests ────────────────────────────────────────────────────────────────────
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    fn default_config() -> AdaptiveConfig {
510        AdaptiveConfig {
511            target_hit_rate: 0.80,
512            tolerance: 0.05,
513            adjustment_interval: 10,
514            min_capacity: 8,
515            max_capacity: 256,
516            growth_factor: 2.0,
517            shrink_factor: 0.5,
518            ttl_extension: Duration::from_secs(10),
519            ttl_reduction: Duration::from_secs(5),
520            window_size: 50,
521        }
522    }
523
524    // 1. Valid config creates policy successfully
525    #[test]
526    fn test_valid_config_creates_policy() {
527        let cfg = default_config();
528        let policy = AdaptivePolicy::new(cfg);
529        assert!(policy.is_ok());
530    }
531
532    // 2. Invalid target_hit_rate is rejected
533    #[test]
534    fn test_invalid_target_hit_rate() {
535        let mut cfg = default_config();
536        cfg.target_hit_rate = 0.0;
537        assert!(AdaptivePolicy::new(cfg.clone()).is_err());
538
539        cfg.target_hit_rate = 1.5;
540        assert!(AdaptivePolicy::new(cfg).is_err());
541    }
542
543    // 3. Invalid growth_factor is rejected
544    #[test]
545    fn test_invalid_growth_factor() {
546        let mut cfg = default_config();
547        cfg.growth_factor = 0.5;
548        assert!(AdaptivePolicy::new(cfg).is_err());
549    }
550
551    // 4. Invalid shrink_factor is rejected
552    #[test]
553    fn test_invalid_shrink_factor() {
554        let mut cfg = default_config();
555        cfg.shrink_factor = 1.0;
556        assert!(AdaptivePolicy::new(cfg.clone()).is_err());
557
558        cfg.shrink_factor = 0.0;
559        assert!(AdaptivePolicy::new(cfg).is_err());
560    }
561
562    // 5. All hits → capacity shrinks (hit rate above target + tolerance)
563    #[test]
564    fn test_all_hits_triggers_shrink() {
565        let cfg = default_config();
566        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
567        let initial_cap = policy.current_capacity();
568
569        // Record 10 hits → triggers evaluation
570        let mut adj = None;
571        for _ in 0..10 {
572            adj = policy.record_hit();
573        }
574        let adjustment = adj.expect("should trigger after 10 ops");
575        assert_eq!(adjustment.kind, AdjustmentKind::Shrink);
576        assert!(
577            policy.current_capacity() < initial_cap,
578            "capacity should have decreased"
579        );
580    }
581
582    // 6. All misses → capacity grows (hit rate below target - tolerance)
583    #[test]
584    fn test_all_misses_triggers_grow() {
585        let cfg = default_config();
586        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
587        let initial_cap = policy.current_capacity();
588
589        let mut adj = None;
590        for _ in 0..10 {
591            adj = policy.record_miss();
592        }
593        let adjustment = adj.expect("should trigger after 10 ops");
594        assert_eq!(adjustment.kind, AdjustmentKind::Grow);
595        assert!(
596            policy.current_capacity() > initial_cap,
597            "capacity should have increased"
598        );
599    }
600
601    // 7. Hit rate within tolerance band → no change
602    #[test]
603    fn test_within_tolerance_no_change() {
604        let cfg = default_config(); // target 0.80, tolerance 0.05
605        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
606
607        // 8 hits + 2 misses = 0.80 hit rate → within [0.75, 0.85]
608        for _ in 0..8 {
609            let _ = policy.record_hit();
610        }
611        let mut adj = None;
612        for _ in 0..2 {
613            adj = policy.record_miss();
614        }
615        let adjustment = adj.expect("should trigger after 10 ops");
616        assert_eq!(adjustment.kind, AdjustmentKind::NoChange);
617    }
618
619    // 8. Capacity never exceeds max_capacity
620    #[test]
621    fn test_capacity_capped_at_max() {
622        let mut cfg = default_config();
623        cfg.max_capacity = 300;
624        cfg.growth_factor = 100.0; // aggressive growth
625        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
626
627        // All misses → grows aggressively
628        for _ in 0..10 {
629            let _ = policy.record_miss();
630        }
631        assert!(
632            policy.current_capacity() <= 300,
633            "capacity {} should not exceed max 300",
634            policy.current_capacity()
635        );
636    }
637
638    // 9. Capacity never drops below min_capacity
639    #[test]
640    fn test_capacity_floored_at_min() {
641        let mut cfg = default_config();
642        cfg.min_capacity = 4;
643        cfg.shrink_factor = 0.01; // aggressive shrink
644        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
645
646        // All hits → shrinks aggressively
647        for _ in 0..10 {
648            let _ = policy.record_hit();
649        }
650        assert!(
651            policy.current_capacity() >= 4,
652            "capacity {} should not drop below min 4",
653            policy.current_capacity()
654        );
655    }
656
657    // 10. Rolling window only considers recent observations
658    #[test]
659    fn test_rolling_window_discards_old() {
660        let mut cfg = default_config();
661        cfg.window_size = 10;
662        cfg.adjustment_interval = 10;
663        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
664
665        // Fill window with misses (hit rate = 0)
666        for _ in 0..10 {
667            let _ = policy.record_miss();
668        }
669        // Now fill window with hits (old misses get evicted)
670        for _ in 0..10 {
671            let _ = policy.record_hit();
672        }
673        // Rolling window should now be all hits → hit rate = 1.0
674        let rate = policy.rolling_hit_rate();
675        assert!(
676            (rate - 1.0).abs() < 1e-9,
677            "rolling hit rate should be 1.0, got {rate}"
678        );
679    }
680
681    // 11. evaluate_now forces an immediate evaluation
682    #[test]
683    fn test_evaluate_now() {
684        let cfg = default_config();
685        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
686
687        // Record fewer ops than adjustment_interval
688        for _ in 0..3 {
689            let _ = policy.record_hit();
690        }
691        // Force evaluation
692        let adj = policy.evaluate_now();
693        // With only 3 hits → hit rate 1.0 → shrink expected
694        assert_eq!(adj.kind, AdjustmentKind::Shrink);
695    }
696
697    // 12. reset clears all state
698    #[test]
699    fn test_reset_clears_state() {
700        let cfg = default_config();
701        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
702
703        for _ in 0..20 {
704            let _ = policy.record_hit();
705        }
706        assert!(policy.total_hits() > 0);
707        assert!(!policy.history().is_empty());
708
709        policy.reset();
710        assert_eq!(policy.total_hits(), 0);
711        assert_eq!(policy.total_misses(), 0);
712        assert_eq!(policy.adjustments_made(), 0);
713        assert!(policy.history().is_empty());
714        assert_eq!(policy.window_fill(), 0);
715    }
716
717    // 13. TTL extension is set on grow
718    #[test]
719    fn test_ttl_extension_on_grow() {
720        let cfg = default_config();
721        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
722
723        let mut adj = None;
724        for _ in 0..10 {
725            adj = policy.record_miss();
726        }
727        let adjustment = adj.expect("should trigger");
728        assert_eq!(adjustment.kind, AdjustmentKind::Grow);
729        assert_eq!(adjustment.ttl_extend, Duration::from_secs(10));
730        assert_eq!(adjustment.ttl_reduce, Duration::ZERO);
731    }
732
733    // 14. TTL reduction is set on shrink
734    #[test]
735    fn test_ttl_reduction_on_shrink() {
736        let cfg = default_config();
737        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
738
739        let mut adj = None;
740        for _ in 0..10 {
741            adj = policy.record_hit();
742        }
743        let adjustment = adj.expect("should trigger");
744        assert_eq!(adjustment.kind, AdjustmentKind::Shrink);
745        assert_eq!(adjustment.ttl_extend, Duration::ZERO);
746        assert_eq!(adjustment.ttl_reduce, Duration::from_secs(5));
747    }
748
749    // 15. lifetime_hit_rate covers all observations
750    #[test]
751    fn test_lifetime_hit_rate() {
752        let cfg = default_config();
753        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
754
755        for _ in 0..7 {
756            let _ = policy.record_hit();
757        }
758        for _ in 0..3 {
759            let _ = policy.record_miss();
760        }
761        let rate = policy.lifetime_hit_rate();
762        assert!((rate - 0.7).abs() < 1e-9, "expected 0.7, got {rate}");
763    }
764
765    // 16. adjustment history records entries
766    #[test]
767    fn test_history_records() {
768        let cfg = default_config(); // interval = 10
769        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
770
771        // Trigger two evaluations
772        for _ in 0..20 {
773            let _ = policy.record_hit();
774        }
775        assert!(
776            policy.history().len() >= 2,
777            "expected at least 2 history records, got {}",
778            policy.history().len()
779        );
780    }
781
782    // 17. with_initial_capacity clamps to min/max
783    #[test]
784    fn test_with_initial_capacity_clamps() {
785        let cfg = default_config(); // min=8, max=256
786        let p1 = AdaptivePolicy::with_initial_capacity(cfg.clone(), 2).expect("valid");
787        assert_eq!(p1.current_capacity(), 8, "should clamp to min");
788
789        let p2 = AdaptivePolicy::with_initial_capacity(cfg, 9999).expect("valid");
790        assert_eq!(p2.current_capacity(), 256, "should clamp to max");
791    }
792
793    // 18. max_capacity == min_capacity is valid
794    #[test]
795    fn test_min_equals_max_capacity() {
796        let mut cfg = default_config();
797        cfg.min_capacity = 64;
798        cfg.max_capacity = 64;
799        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
800
801        // Even with all misses, capacity cannot grow beyond 64
802        for _ in 0..10 {
803            let _ = policy.record_miss();
804        }
805        assert_eq!(policy.current_capacity(), 64);
806
807        // Even with all hits, capacity cannot shrink below 64
808        for _ in 0..10 {
809            let _ = policy.record_hit();
810        }
811        assert_eq!(policy.current_capacity(), 64);
812    }
813
814    // 19. adjustment_interval 0 is rejected
815    #[test]
816    fn test_zero_adjustment_interval_rejected() {
817        let mut cfg = default_config();
818        cfg.adjustment_interval = 0;
819        assert!(AdaptivePolicy::new(cfg).is_err());
820    }
821
822    // 20. window_size 0 is rejected
823    #[test]
824    fn test_zero_window_size_rejected() {
825        let mut cfg = default_config();
826        cfg.window_size = 0;
827        assert!(AdaptivePolicy::new(cfg).is_err());
828    }
829
830    // 21. Successive grow adjustments increase capacity monotonically
831    #[test]
832    fn test_successive_grow_monotonic() {
833        let mut cfg = default_config();
834        cfg.max_capacity = 100_000;
835        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
836
837        let mut prev_cap = policy.current_capacity();
838        for round in 0..5 {
839            for _ in 0..10 {
840                let _ = policy.record_miss();
841            }
842            let cap = policy.current_capacity();
843            assert!(
844                cap >= prev_cap,
845                "round {round}: capacity should not decrease on grow ({prev_cap} → {cap})"
846            );
847            prev_cap = cap;
848        }
849    }
850
851    // 22. observed_hit_rate is populated in the adjustment
852    #[test]
853    fn test_observed_hit_rate_in_adjustment() {
854        let cfg = default_config();
855        let mut policy = AdaptivePolicy::new(cfg).expect("valid config");
856
857        for _ in 0..5 {
858            let _ = policy.record_hit();
859        }
860        for _ in 0..5 {
861            let _ = policy.record_miss();
862        }
863        // Last record_miss triggers eval at op 10
864        let last_hist = policy.history().last().expect("should have history");
865        let rate = last_hist.adjustment.observed_hit_rate;
866        assert!(
867            (rate - 0.5).abs() < 1e-9,
868            "expected 0.5 hit rate, got {rate}"
869        );
870    }
871}