Skip to main content

sqry_core/cache/
policy.rs

1// RKG: CODE:SQRY-CORE implements REQ:SQRY-P2-6-CACHE-EVICTION-POLICY
2//! Cache eviction policy types and metrics.
3//!
4//! This module encapsulates the eviction/admission policies that drive
5//! `CacheStorage`. Policies are responsible for tracking access patterns,
6//! deciding whether new entries should be admitted, surfacing eviction events,
7//! and exposing telemetry counters for CLI/debug tooling.
8
9use crossbeam_queue::SegQueue;
10use dashmap::DashMap;
11use moka::policy::EvictionPolicy;
12use moka::sync::Cache;
13use parking_lot::Mutex;
14use std::collections::VecDeque;
15use std::fmt;
16use std::hash::Hash;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
19
20/// Available cache eviction policies.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum CachePolicyKind {
23    /// Default least-recently-used (current behaviour).
24    #[default]
25    Lru,
26    /// Windowed `TinyLFU` admission with protected hot set.
27    TinyLfu,
28    /// Hybrid policy (LRU window + `TinyLFU` protected region).
29    Hybrid,
30}
31
32impl CachePolicyKind {
33    /// Parse a policy kind from a string (env/config value).
34    #[must_use]
35    pub fn parse(value: &str) -> Option<Self> {
36        match value.trim().to_ascii_lowercase().as_str() {
37            "lru" => Some(Self::Lru),
38            "tiny_lfu" | "tinylfu" | "lfu" => Some(Self::TinyLfu),
39            "hybrid" | "window_lfu" | "windowed_lfu" => Some(Self::Hybrid),
40            _ => None,
41        }
42    }
43
44    /// Human-readable representation (used in debug output).
45    #[must_use]
46    pub const fn as_str(self) -> &'static str {
47        match self {
48            Self::Lru => "lru",
49            Self::TinyLfu => "tiny_lfu",
50            Self::Hybrid => "hybrid",
51        }
52    }
53}
54
55impl fmt::Display for CachePolicyKind {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        f.write_str(self.as_str())
58    }
59}
60
61/// Telemetry counters for eviction policies.
62#[derive(Debug, Clone, Copy)]
63pub struct CachePolicyMetrics {
64    /// Policy used for the measurement.
65    pub kind: CachePolicyKind,
66    /// Number of insertions rejected by the policy (e.g., `TinyLFU` drop).
67    pub lfu_rejects: usize,
68    /// Number of evictions that removed a hot/protected entry.
69    pub hot_evictions: usize,
70    /// Number of cold LRU evictions.
71    pub cold_evictions: usize,
72    /// Hits served from the protected (hot) region.
73    pub protected_hits: usize,
74}
75
76impl CachePolicyMetrics {
77    /// Create metrics with the provided policy kind.
78    #[must_use]
79    pub const fn with_kind(kind: CachePolicyKind) -> Self {
80        Self {
81            kind,
82            lfu_rejects: 0,
83            hot_evictions: 0,
84            cold_evictions: 0,
85            protected_hits: 0,
86        }
87    }
88}
89
90impl Default for CachePolicyMetrics {
91    fn default() -> Self {
92        Self::with_kind(CachePolicyKind::Lru)
93    }
94}
95
96/// Outcome of an admission attempt.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum CacheAdmission {
99    /// Entry was accepted and should be inserted into the storage layer.
100    Accepted,
101    /// Entry was rejected by the policy (e.g., `TinyLFU` frequency too low).
102    Rejected,
103}
104
105/// High-level description of an eviction performed by the policy.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum CacheEvictionKind {
108    /// Protected entry (considered hot) was evicted.
109    Hot,
110    /// Regular entry was evicted.
111    Cold,
112}
113
114/// Eviction data returned by policies.
115#[derive(Debug, Clone)]
116pub struct CachePolicyEviction<K> {
117    /// Key that must be removed from the cache storage.
118    pub key: K,
119    /// Whether the eviction targeted a hot/protected entry.
120    pub kind: CacheEvictionKind,
121}
122
123/// Policy abstraction consumed by caches.
124pub trait CachePolicy<K>: Send + Sync
125where
126    K: Eq + Hash + Clone + Send + Sync + 'static,
127{
128    /// Policy kind (for telemetry + configuration reflection).
129    fn kind(&self) -> CachePolicyKind;
130
131    /// Decide whether an entry should be admitted.
132    fn admit(&self, key: &K, weight_bytes: u64) -> CacheAdmission;
133
134    /// Record a cache hit (returns whether the hit was served from the protected set).
135    fn record_hit(&self, key: &K) -> bool;
136
137    /// Remove policy metadata for a key (called when entries are explicitly cleared).
138    fn invalidate(&self, key: &K);
139
140    /// Drain pending eviction events generated by the policy implementation.
141    fn drain_evictions(&self) -> Vec<CachePolicyEviction<K>>;
142
143    /// Reset policy state (used when wiping the cache).
144    fn reset(&self);
145
146    /// Snapshot policy metrics.
147    fn stats(&self) -> CachePolicyMetrics;
148}
149
150/// Configuration data required to build a policy implementation.
151#[derive(Debug, Clone, Copy)]
152pub struct CachePolicyConfig {
153    /// Policy variant to instantiate.
154    pub kind: CachePolicyKind,
155    /// Maximum number of bytes available to the cache.
156    pub max_bytes: u64,
157    /// Fraction of the budget reserved for the protected window.
158    pub window_ratio: f32,
159}
160
161impl CachePolicyConfig {
162    /// Create a new config descriptor.
163    #[must_use]
164    pub fn new(kind: CachePolicyKind, max_bytes: u64, window_ratio: f32) -> Self {
165        Self {
166            kind,
167            max_bytes,
168            window_ratio,
169        }
170    }
171}
172
173/// Build the appropriate policy implementation for the supplied configuration.
174#[must_use]
175pub fn build_cache_policy<K>(config: &CachePolicyConfig) -> Arc<dyn CachePolicy<K>>
176where
177    K: Eq + Hash + Clone + Send + Sync + 'static,
178{
179    match config.kind {
180        CachePolicyKind::Lru => Arc::new(LruPolicy::new()),
181        CachePolicyKind::TinyLfu | CachePolicyKind::Hybrid => Arc::new(TinyLfuPolicy::new(config)),
182    }
183}
184
185/// No-op policy for classic LRU behaviour.
186struct LruPolicy;
187
188impl LruPolicy {
189    fn new() -> Self {
190        Self
191    }
192}
193
194impl<K> CachePolicy<K> for LruPolicy
195where
196    K: Eq + Hash + Clone + Send + Sync + 'static,
197{
198    fn kind(&self) -> CachePolicyKind {
199        CachePolicyKind::Lru
200    }
201
202    fn admit(&self, _key: &K, _weight_bytes: u64) -> CacheAdmission {
203        CacheAdmission::Accepted
204    }
205
206    fn record_hit(&self, _key: &K) -> bool {
207        false
208    }
209
210    fn invalidate(&self, _key: &K) {}
211
212    fn drain_evictions(&self) -> Vec<CachePolicyEviction<K>> {
213        Vec::new()
214    }
215
216    fn reset(&self) {}
217
218    fn stats(&self) -> CachePolicyMetrics {
219        CachePolicyMetrics::with_kind(CachePolicyKind::Lru)
220    }
221}
222
223/// Event emitted by the `TinyLFU` metadata cache when an entry is removed.
224#[derive(Debug)]
225struct PolicyVictim<K> {
226    key: K,
227}
228
229#[derive(Debug)]
230struct ProtectedEntry {
231    weight: u32,
232    hits: AtomicU32,
233    protected: AtomicBool,
234}
235
236impl ProtectedEntry {
237    fn new(weight: u32) -> Self {
238        Self {
239            weight,
240            hits: AtomicU32::new(0),
241            protected: AtomicBool::new(false),
242        }
243    }
244}
245
246#[derive(Default)]
247struct PolicyMetricCounters {
248    lfu_rejects: AtomicU64,
249    hot_evictions: AtomicU64,
250    cold_evictions: AtomicU64,
251    protected_hits: AtomicU64,
252}
253
254impl PolicyMetricCounters {
255    fn snapshot(&self, kind: CachePolicyKind) -> CachePolicyMetrics {
256        fn u64_to_usize(value: u64) -> usize {
257            usize::try_from(value).unwrap_or(usize::MAX)
258        }
259
260        CachePolicyMetrics {
261            kind,
262            lfu_rejects: u64_to_usize(self.lfu_rejects.load(Ordering::Relaxed)),
263            hot_evictions: u64_to_usize(self.hot_evictions.load(Ordering::Relaxed)),
264            cold_evictions: u64_to_usize(self.cold_evictions.load(Ordering::Relaxed)),
265            protected_hits: u64_to_usize(self.protected_hits.load(Ordering::Relaxed)),
266        }
267    }
268
269    fn reset(&self) {
270        self.lfu_rejects.store(0, Ordering::Relaxed);
271        self.hot_evictions.store(0, Ordering::Relaxed);
272        self.cold_evictions.store(0, Ordering::Relaxed);
273        self.protected_hits.store(0, Ordering::Relaxed);
274    }
275}
276
277struct TinyLfuPolicy<K>
278where
279    K: Eq + Hash + Clone + Send + Sync + 'static,
280{
281    kind: CachePolicyKind,
282    cache: Cache<K, u32>,
283    victims: Arc<SegQueue<PolicyVictim<K>>>,
284    protected: DashMap<K, Arc<ProtectedEntry>>,
285    protected_order: Mutex<VecDeque<K>>,
286    protected_budget: u64,
287    protected_bytes: AtomicU64,
288    metrics: PolicyMetricCounters,
289    promotion_threshold: u32,
290}
291
292impl<K> TinyLfuPolicy<K>
293where
294    K: Eq + Hash + Clone + Send + Sync + 'static,
295{
296    fn new(config: &CachePolicyConfig) -> Self {
297        let victims = Arc::new(SegQueue::<PolicyVictim<K>>::new());
298        let victims_clone = Arc::clone(&victims);
299        let max_bytes = config.max_bytes.max(1);
300        let eviction_policy = match config.kind {
301            CachePolicyKind::Lru => EvictionPolicy::lru(),
302            CachePolicyKind::TinyLfu | CachePolicyKind::Hybrid => EvictionPolicy::tiny_lfu(),
303        };
304
305        let cache = Cache::builder()
306            .max_capacity(max_bytes)
307            .eviction_policy(eviction_policy)
308            .weigher(|_, weight: &u32| *weight)
309            .eviction_listener(move |key: Arc<K>, _weight: u32, cause| {
310                if cause.was_evicted() {
311                    victims_clone.push(PolicyVictim {
312                        key: (*key).clone(),
313                    });
314                }
315            })
316            .build();
317
318        let window_ratio = clamp_ratio(config.window_ratio);
319        let protected_budget = scale_u64_by_ratio(max_bytes, window_ratio).max(1);
320
321        Self {
322            kind: config.kind,
323            cache,
324            victims,
325            protected: DashMap::new(),
326            protected_order: Mutex::new(VecDeque::new()),
327            protected_budget,
328            protected_bytes: AtomicU64::new(0),
329            metrics: PolicyMetricCounters::default(),
330            promotion_threshold: 3,
331        }
332    }
333
334    fn clamp_weight(weight_bytes: u64) -> u32 {
335        u32::try_from(weight_bytes).unwrap_or(u32::MAX).max(1)
336    }
337
338    fn promote(&self, key: K, entry: &ProtectedEntry) {
339        if entry.protected.swap(true, Ordering::Relaxed) {
340            return;
341        }
342
343        self.protected_bytes
344            .fetch_add(u64::from(entry.weight), Ordering::Relaxed);
345        self.protected_order.lock().push_back(key);
346        self.rebalance_protected_budget();
347    }
348
349    fn demote_key(&self, key: &K) {
350        if let Some(entry) = self.protected.get(key)
351            && entry.protected.swap(false, Ordering::Relaxed)
352        {
353            self.protected_bytes
354                .fetch_sub(u64::from(entry.weight), Ordering::Relaxed);
355        }
356    }
357
358    fn rebalance_protected_budget(&self) {
359        while self.protected_bytes.load(Ordering::Relaxed) > self.protected_budget {
360            let Some(next) = self.protected_order.lock().pop_front() else {
361                break;
362            };
363            self.demote_key(&next);
364        }
365    }
366
367    fn handle_eviction(&self, victim: PolicyVictim<K>) -> CachePolicyEviction<K> {
368        if let Some((_, entry)) = self.protected.remove(&victim.key) {
369            if entry.protected.swap(false, Ordering::Relaxed) {
370                self.metrics.hot_evictions.fetch_add(1, Ordering::Relaxed);
371                self.protected_bytes
372                    .fetch_sub(u64::from(entry.weight), Ordering::Relaxed);
373                CachePolicyEviction {
374                    key: victim.key,
375                    kind: CacheEvictionKind::Hot,
376                }
377            } else {
378                self.metrics.cold_evictions.fetch_add(1, Ordering::Relaxed);
379                CachePolicyEviction {
380                    key: victim.key,
381                    kind: CacheEvictionKind::Cold,
382                }
383            }
384        } else {
385            self.metrics.cold_evictions.fetch_add(1, Ordering::Relaxed);
386            CachePolicyEviction {
387                key: victim.key,
388                kind: CacheEvictionKind::Cold,
389            }
390        }
391    }
392}
393
394impl<K> CachePolicy<K> for TinyLfuPolicy<K>
395where
396    K: Eq + Hash + Clone + Send + Sync + 'static,
397{
398    fn kind(&self) -> CachePolicyKind {
399        self.kind
400    }
401
402    fn admit(&self, key: &K, weight_bytes: u64) -> CacheAdmission {
403        let weight = Self::clamp_weight(weight_bytes);
404        self.cache.insert(key.clone(), weight);
405        self.cache.run_pending_tasks();
406
407        self.protected
408            .entry(key.clone())
409            .or_insert_with(|| Arc::new(ProtectedEntry::new(weight)));
410
411        if self.cache.contains_key(key) {
412            CacheAdmission::Accepted
413        } else {
414            self.metrics.lfu_rejects.fetch_add(1, Ordering::Relaxed);
415            self.protected.remove(key);
416            CacheAdmission::Rejected
417        }
418    }
419
420    fn record_hit(&self, key: &K) -> bool {
421        let _ = self.cache.get(key);
422        if let Some(entry) = self.protected.get(key) {
423            let hits = entry.hits.fetch_add(1, Ordering::Relaxed) + 1;
424            if hits >= self.promotion_threshold {
425                self.promote(key.clone(), &entry);
426            }
427            if entry.protected.load(Ordering::Relaxed) {
428                self.metrics.protected_hits.fetch_add(1, Ordering::Relaxed);
429                return true;
430            }
431        }
432        false
433    }
434
435    fn invalidate(&self, key: &K) {
436        if let Some((_, entry)) = self.protected.remove(key)
437            && entry.protected.swap(false, Ordering::Relaxed)
438        {
439            self.protected_bytes
440                .fetch_sub(u64::from(entry.weight), Ordering::Relaxed);
441        }
442        self.cache.invalidate(key);
443        self.cache.run_pending_tasks();
444    }
445
446    fn drain_evictions(&self) -> Vec<CachePolicyEviction<K>> {
447        self.cache.run_pending_tasks();
448        let mut victims = Vec::new();
449        while let Some(event) = self.victims.pop() {
450            victims.push(self.handle_eviction(event));
451        }
452        victims
453    }
454
455    fn reset(&self) {
456        self.cache.invalidate_all();
457        self.cache.run_pending_tasks();
458        while self.victims.pop().is_some() {}
459        self.protected.clear();
460        self.protected_order.lock().clear();
461        self.protected_bytes.store(0, Ordering::Relaxed);
462        self.metrics.reset();
463    }
464
465    fn stats(&self) -> CachePolicyMetrics {
466        self.metrics.snapshot(self.kind())
467    }
468}
469
470fn clamp_ratio(ratio: f32) -> f32 {
471    if ratio.is_nan() || !ratio.is_finite() {
472        0.20
473    } else {
474        ratio.clamp(0.05, 0.95)
475    }
476}
477
478fn scale_u64_by_ratio(value: u64, ratio: f32) -> u64 {
479    let scaled = {
480        #[allow(clippy::cast_precision_loss)]
481        {
482            (value as f64) * f64::from(ratio)
483        }
484    };
485    if !scaled.is_finite() || scaled <= 0.0 {
486        return 0;
487    }
488    let capped = {
489        #[allow(clippy::cast_precision_loss)]
490        {
491            scaled.min(u64::MAX as f64)
492        }
493    };
494    #[allow(
495        clippy::cast_possible_truncation,
496        clippy::cast_precision_loss,
497        clippy::cast_sign_loss
498    )]
499    {
500        capped.round() as u64
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    // CachePolicyKind tests
509    #[test]
510    fn test_cache_policy_kind_parse_lru() {
511        assert_eq!(CachePolicyKind::parse("lru"), Some(CachePolicyKind::Lru));
512        assert_eq!(CachePolicyKind::parse("LRU"), Some(CachePolicyKind::Lru));
513        assert_eq!(
514            CachePolicyKind::parse("  lru  "),
515            Some(CachePolicyKind::Lru)
516        );
517    }
518
519    #[test]
520    fn test_cache_policy_kind_parse_tiny_lfu() {
521        assert_eq!(
522            CachePolicyKind::parse("tiny_lfu"),
523            Some(CachePolicyKind::TinyLfu)
524        );
525        assert_eq!(
526            CachePolicyKind::parse("tinylfu"),
527            Some(CachePolicyKind::TinyLfu)
528        );
529        assert_eq!(
530            CachePolicyKind::parse("lfu"),
531            Some(CachePolicyKind::TinyLfu)
532        );
533        assert_eq!(
534            CachePolicyKind::parse("LFU"),
535            Some(CachePolicyKind::TinyLfu)
536        );
537    }
538
539    #[test]
540    fn test_cache_policy_kind_parse_hybrid() {
541        assert_eq!(
542            CachePolicyKind::parse("hybrid"),
543            Some(CachePolicyKind::Hybrid)
544        );
545        assert_eq!(
546            CachePolicyKind::parse("window_lfu"),
547            Some(CachePolicyKind::Hybrid)
548        );
549        assert_eq!(
550            CachePolicyKind::parse("windowed_lfu"),
551            Some(CachePolicyKind::Hybrid)
552        );
553        assert_eq!(
554            CachePolicyKind::parse("HYBRID"),
555            Some(CachePolicyKind::Hybrid)
556        );
557    }
558
559    #[test]
560    fn test_cache_policy_kind_parse_invalid() {
561        assert_eq!(CachePolicyKind::parse("unknown"), None);
562        assert_eq!(CachePolicyKind::parse(""), None);
563        assert_eq!(CachePolicyKind::parse("fifo"), None);
564    }
565
566    #[test]
567    fn test_cache_policy_kind_as_str() {
568        assert_eq!(CachePolicyKind::Lru.as_str(), "lru");
569        assert_eq!(CachePolicyKind::TinyLfu.as_str(), "tiny_lfu");
570        assert_eq!(CachePolicyKind::Hybrid.as_str(), "hybrid");
571    }
572
573    #[test]
574    fn test_cache_policy_kind_default() {
575        assert_eq!(CachePolicyKind::default(), CachePolicyKind::Lru);
576    }
577
578    #[test]
579    fn test_cache_policy_kind_display() {
580        assert_eq!(format!("{}", CachePolicyKind::Lru), "lru");
581        assert_eq!(format!("{}", CachePolicyKind::TinyLfu), "tiny_lfu");
582        assert_eq!(format!("{}", CachePolicyKind::Hybrid), "hybrid");
583    }
584
585    #[test]
586    fn test_cache_policy_kind_eq() {
587        assert_eq!(CachePolicyKind::Lru, CachePolicyKind::Lru);
588        assert_ne!(CachePolicyKind::Lru, CachePolicyKind::TinyLfu);
589    }
590
591    #[test]
592    fn test_cache_policy_kind_clone() {
593        let kind = CachePolicyKind::TinyLfu;
594        let cloned = kind;
595        assert_eq!(kind, cloned);
596    }
597
598    // CachePolicyMetrics tests
599    #[test]
600    fn test_cache_policy_metrics_with_kind() {
601        let metrics = CachePolicyMetrics::with_kind(CachePolicyKind::TinyLfu);
602        assert_eq!(metrics.kind, CachePolicyKind::TinyLfu);
603        assert_eq!(metrics.lfu_rejects, 0);
604        assert_eq!(metrics.hot_evictions, 0);
605        assert_eq!(metrics.cold_evictions, 0);
606        assert_eq!(metrics.protected_hits, 0);
607    }
608
609    #[test]
610    fn test_cache_policy_metrics_default() {
611        let metrics = CachePolicyMetrics::default();
612        assert_eq!(metrics.kind, CachePolicyKind::Lru);
613        assert_eq!(metrics.lfu_rejects, 0);
614    }
615
616    // CacheAdmission tests
617    #[test]
618    fn test_cache_admission_eq() {
619        assert_eq!(CacheAdmission::Accepted, CacheAdmission::Accepted);
620        assert_ne!(CacheAdmission::Accepted, CacheAdmission::Rejected);
621    }
622
623    // CacheEvictionKind tests
624    #[test]
625    fn test_cache_eviction_kind_eq() {
626        assert_eq!(CacheEvictionKind::Hot, CacheEvictionKind::Hot);
627        assert_ne!(CacheEvictionKind::Hot, CacheEvictionKind::Cold);
628    }
629
630    // CachePolicyConfig tests
631    #[test]
632    fn test_cache_policy_config_new() {
633        let config = CachePolicyConfig::new(CachePolicyKind::TinyLfu, 1024 * 1024, 0.2);
634        assert_eq!(config.kind, CachePolicyKind::TinyLfu);
635        assert_eq!(config.max_bytes, 1024 * 1024);
636        assert!((config.window_ratio - 0.2).abs() < f32::EPSILON);
637    }
638
639    // clamp_ratio tests
640    #[test]
641    fn test_clamp_ratio_normal() {
642        assert!((clamp_ratio(0.5) - 0.5).abs() < f32::EPSILON);
643        assert!((clamp_ratio(0.2) - 0.2).abs() < f32::EPSILON);
644    }
645
646    #[test]
647    fn test_clamp_ratio_too_low() {
648        // Below 0.05 clamps to 0.05
649        assert!((clamp_ratio(0.01) - 0.05).abs() < f32::EPSILON);
650        assert!((clamp_ratio(0.0) - 0.05).abs() < f32::EPSILON);
651    }
652
653    #[test]
654    fn test_clamp_ratio_too_high() {
655        // Above 0.95 clamps to 0.95
656        assert!((clamp_ratio(0.99) - 0.95).abs() < f32::EPSILON);
657        assert!((clamp_ratio(1.0) - 0.95).abs() < f32::EPSILON);
658    }
659
660    #[test]
661    fn test_clamp_ratio_nan() {
662        // NaN defaults to 0.20
663        assert!((clamp_ratio(f32::NAN) - 0.20).abs() < f32::EPSILON);
664    }
665
666    #[test]
667    fn test_clamp_ratio_infinity() {
668        // Infinity defaults to 0.20
669        assert!((clamp_ratio(f32::INFINITY) - 0.20).abs() < f32::EPSILON);
670        assert!((clamp_ratio(f32::NEG_INFINITY) - 0.20).abs() < f32::EPSILON);
671    }
672
673    // scale_u64_by_ratio tests
674    #[test]
675    fn test_scale_u64_by_ratio_normal() {
676        assert_eq!(scale_u64_by_ratio(1000, 0.5), 500);
677        assert_eq!(scale_u64_by_ratio(100, 0.1), 10);
678    }
679
680    #[test]
681    fn test_scale_u64_by_ratio_zero_value() {
682        assert_eq!(scale_u64_by_ratio(0, 0.5), 0);
683    }
684
685    #[test]
686    fn test_scale_u64_by_ratio_zero_ratio() {
687        assert_eq!(scale_u64_by_ratio(1000, 0.0), 0);
688    }
689
690    #[test]
691    fn test_scale_u64_by_ratio_negative_ratio() {
692        // Negative ratio returns 0
693        assert_eq!(scale_u64_by_ratio(1000, -0.5), 0);
694    }
695
696    #[test]
697    fn test_scale_u64_by_ratio_nan() {
698        // NaN ratio returns 0
699        assert_eq!(scale_u64_by_ratio(1000, f32::NAN), 0);
700    }
701
702    #[test]
703    fn test_scale_u64_by_ratio_rounding() {
704        // 1000 * 0.33 = 330.0 (rounds to 330)
705        assert_eq!(scale_u64_by_ratio(1000, 0.33), 330);
706        // 1000 * 0.335 = 335.0 (rounds to 335)
707        assert_eq!(scale_u64_by_ratio(1000, 0.335), 335);
708    }
709
710    // LruPolicy tests
711    #[test]
712    fn test_lru_policy_kind() {
713        let policy: Arc<dyn CachePolicy<String>> =
714            build_cache_policy(&CachePolicyConfig::new(CachePolicyKind::Lru, 1024, 0.2));
715        assert_eq!(policy.kind(), CachePolicyKind::Lru);
716    }
717
718    #[test]
719    fn test_lru_policy_always_admits() {
720        let policy: Arc<dyn CachePolicy<String>> =
721            build_cache_policy(&CachePolicyConfig::new(CachePolicyKind::Lru, 1024, 0.2));
722        assert_eq!(
723            policy.admit(&"key".to_string(), 100),
724            CacheAdmission::Accepted
725        );
726    }
727
728    #[test]
729    fn test_lru_policy_record_hit_returns_false() {
730        let policy: Arc<dyn CachePolicy<String>> =
731            build_cache_policy(&CachePolicyConfig::new(CachePolicyKind::Lru, 1024, 0.2));
732        // LRU policy doesn't track protected hits
733        assert!(!policy.record_hit(&"key".to_string()));
734    }
735
736    #[test]
737    fn test_lru_policy_drain_evictions_empty() {
738        let policy: Arc<dyn CachePolicy<String>> =
739            build_cache_policy(&CachePolicyConfig::new(CachePolicyKind::Lru, 1024, 0.2));
740        assert!(policy.drain_evictions().is_empty());
741    }
742
743    #[test]
744    fn test_lru_policy_stats() {
745        let policy: Arc<dyn CachePolicy<String>> =
746            build_cache_policy(&CachePolicyConfig::new(CachePolicyKind::Lru, 1024, 0.2));
747        let stats = policy.stats();
748        assert_eq!(stats.kind, CachePolicyKind::Lru);
749        assert_eq!(stats.lfu_rejects, 0);
750    }
751
752    // TinyLfuPolicy tests
753    #[test]
754    fn test_tiny_lfu_policy_kind() {
755        let policy: Arc<dyn CachePolicy<String>> =
756            build_cache_policy(&CachePolicyConfig::new(CachePolicyKind::TinyLfu, 1024, 0.2));
757        assert_eq!(policy.kind(), CachePolicyKind::TinyLfu);
758    }
759
760    #[test]
761    fn test_hybrid_policy_kind() {
762        let policy: Arc<dyn CachePolicy<String>> =
763            build_cache_policy(&CachePolicyConfig::new(CachePolicyKind::Hybrid, 1024, 0.2));
764        assert_eq!(policy.kind(), CachePolicyKind::Hybrid);
765    }
766
767    #[test]
768    fn test_tiny_lfu_policy_admit_and_stats() {
769        let policy: Arc<dyn CachePolicy<String>> = build_cache_policy(&CachePolicyConfig::new(
770            CachePolicyKind::TinyLfu,
771            10240,
772            0.2,
773        ));
774
775        // Admit a key
776        let result = policy.admit(&"test_key".to_string(), 100);
777        // Result depends on internal TinyLFU admission decision
778        assert!(result == CacheAdmission::Accepted || result == CacheAdmission::Rejected);
779
780        // Stats should track the operation
781        let stats = policy.stats();
782        assert_eq!(stats.kind, CachePolicyKind::TinyLfu);
783    }
784
785    #[test]
786    fn test_tiny_lfu_policy_reset() {
787        let policy: Arc<dyn CachePolicy<String>> = build_cache_policy(&CachePolicyConfig::new(
788            CachePolicyKind::TinyLfu,
789            10240,
790            0.2,
791        ));
792
793        policy.admit(&"key1".to_string(), 100);
794        policy.admit(&"key2".to_string(), 100);
795
796        // Reset clears everything
797        policy.reset();
798
799        let stats = policy.stats();
800        assert_eq!(stats.lfu_rejects, 0);
801        assert_eq!(stats.hot_evictions, 0);
802        assert_eq!(stats.cold_evictions, 0);
803        assert_eq!(stats.protected_hits, 0);
804    }
805
806    #[test]
807    fn test_tiny_lfu_policy_invalidate() {
808        let policy: Arc<dyn CachePolicy<String>> = build_cache_policy(&CachePolicyConfig::new(
809            CachePolicyKind::TinyLfu,
810            10240,
811            0.2,
812        ));
813
814        let key = "test_key".to_string();
815        policy.admit(&key, 100);
816        policy.invalidate(&key);
817        // Should not panic and key should be removed
818    }
819
820    // build_cache_policy tests
821    #[test]
822    fn test_build_cache_policy_lru() {
823        let config = CachePolicyConfig::new(CachePolicyKind::Lru, 1024, 0.2);
824        let policy: Arc<dyn CachePolicy<String>> = build_cache_policy(&config);
825        assert_eq!(policy.kind(), CachePolicyKind::Lru);
826    }
827
828    #[test]
829    fn test_build_cache_policy_tiny_lfu() {
830        let config = CachePolicyConfig::new(CachePolicyKind::TinyLfu, 1024, 0.2);
831        let policy: Arc<dyn CachePolicy<String>> = build_cache_policy(&config);
832        assert_eq!(policy.kind(), CachePolicyKind::TinyLfu);
833    }
834
835    #[test]
836    fn test_build_cache_policy_hybrid() {
837        let config = CachePolicyConfig::new(CachePolicyKind::Hybrid, 1024, 0.2);
838        let policy: Arc<dyn CachePolicy<String>> = build_cache_policy(&config);
839        assert_eq!(policy.kind(), CachePolicyKind::Hybrid);
840    }
841
842    // PolicyMetricCounters tests (via snapshot)
843    #[test]
844    fn test_policy_metric_counters_snapshot() {
845        let counters = PolicyMetricCounters::default();
846        counters.lfu_rejects.store(5, Ordering::Relaxed);
847        counters.hot_evictions.store(3, Ordering::Relaxed);
848        counters.cold_evictions.store(10, Ordering::Relaxed);
849        counters.protected_hits.store(100, Ordering::Relaxed);
850
851        let snapshot = counters.snapshot(CachePolicyKind::TinyLfu);
852        assert_eq!(snapshot.kind, CachePolicyKind::TinyLfu);
853        assert_eq!(snapshot.lfu_rejects, 5);
854        assert_eq!(snapshot.hot_evictions, 3);
855        assert_eq!(snapshot.cold_evictions, 10);
856        assert_eq!(snapshot.protected_hits, 100);
857    }
858
859    #[test]
860    fn test_policy_metric_counters_reset() {
861        let counters = PolicyMetricCounters::default();
862        counters.lfu_rejects.store(5, Ordering::Relaxed);
863        counters.reset();
864        assert_eq!(counters.lfu_rejects.load(Ordering::Relaxed), 0);
865    }
866
867    // CachePolicyEviction tests
868    #[test]
869    fn test_cache_policy_eviction_clone() {
870        let eviction = CachePolicyEviction {
871            key: "test".to_string(),
872            kind: CacheEvictionKind::Hot,
873        };
874        let cloned = eviction.clone();
875        assert_eq!(cloned.key, "test");
876        assert_eq!(cloned.kind, CacheEvictionKind::Hot);
877    }
878
879    // ProtectedEntry tests
880    #[test]
881    fn test_protected_entry_new() {
882        let entry = ProtectedEntry::new(100);
883        assert_eq!(entry.weight, 100);
884        assert_eq!(entry.hits.load(Ordering::Relaxed), 0);
885        assert!(!entry.protected.load(Ordering::Relaxed));
886    }
887}