Skip to main content

ipfrs_storage/
content_addressed_cache_v2.rs

1//! Content-Addressed Cache V2 — multi-tier eviction with admission control.
2//!
3//! Provides a production-grade, purely in-memory cache keyed by 32-byte CIDs with:
4//! - **Hot tier**: LRU eviction, short TTL, fastest access
5//! - **Warm tier**: LFU eviction, longer TTL, larger capacity
6//! - **Bloom-filter admission gate**: three FNV-1a hash functions prevent one-hit wonders
7//! - **Eviction log**: bounded ring of the last 500 eviction events
8//! - **TTL sweep**: single-pass expiry across both tiers
9//! - **Warm drain**: simulate write-back of least-frequently-used 25% to slower storage
10
11use std::collections::{HashMap, VecDeque};
12
13// ─── Primitive helpers ────────────────────────────────────────────────────────
14
15/// FNV-1a 64-bit hash.
16#[inline(always)]
17fn fnv1a_64(data: &[u8]) -> u64 {
18    let mut h: u64 = 14_695_981_039_346_656_037;
19    for &b in data {
20        h ^= b as u64;
21        h = h.wrapping_mul(1_099_511_628_211);
22    }
23    h
24}
25
26/// XorShift64 PRNG — mutates `state` in place and returns the new value.
27#[inline(always)]
28fn xorshift64(state: &mut u64) -> u64 {
29    let mut x = *state;
30    x ^= x << 13;
31    x ^= x >> 7;
32    x ^= x << 17;
33    *state = x;
34    x
35}
36
37/// Current UNIX timestamp in seconds (falls back to 0 if the clock is unavailable).
38fn now_secs() -> u64 {
39    std::time::SystemTime::now()
40        .duration_since(std::time::UNIX_EPOCH)
41        .map(|d| d.as_secs())
42        .unwrap_or(0)
43}
44
45// ─── Public types ─────────────────────────────────────────────────────────────
46
47/// 32-byte content identifier — a fixed-size, copy-friendly newtype.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub struct Cac2Cid(pub [u8; 32]);
50
51impl Cac2Cid {
52    /// Construct a CID from a 32-byte slice; returns `None` if the slice is too short.
53    pub fn from_slice(s: &[u8]) -> Option<Self> {
54        if s.len() < 32 {
55            return None;
56        }
57        let mut arr = [0u8; 32];
58        arr.copy_from_slice(&s[..32]);
59        Some(Self(arr))
60    }
61
62    /// Derive a deterministic CID from arbitrary bytes via FNV-1a.
63    pub fn from_bytes(data: &[u8]) -> Self {
64        let h1 = fnv1a_64(data);
65        let h2 = fnv1a_64(&h1.to_le_bytes());
66        let h3 = fnv1a_64(&h2.to_le_bytes());
67        let h4 = fnv1a_64(&h3.to_le_bytes());
68        let mut arr = [0u8; 32];
69        arr[0..8].copy_from_slice(&h1.to_le_bytes());
70        arr[8..16].copy_from_slice(&h2.to_le_bytes());
71        arr[16..24].copy_from_slice(&h3.to_le_bytes());
72        arr[24..32].copy_from_slice(&h4.to_le_bytes());
73        Self(arr)
74    }
75}
76
77/// Cache tier designation.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Cac2Tier {
80    /// In the fast, small, LRU-evicted hot tier.
81    Hot,
82    /// In the larger, LFU-evicted warm tier.
83    Warm,
84    /// Entry has been evicted (used inside `Cac2EvictionRecord`).
85    Evicted,
86}
87
88/// Why an entry was removed from the cache.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Cac2EvictionReason {
91    /// LRU victim in the hot tier.
92    LruEviction,
93    /// Lowest-frequency victim in the warm tier.
94    LfuEviction,
95    /// TTL deadline passed.
96    TtlExpiry,
97    /// Caller explicitly evicted the entry.
98    ManualEviction,
99    /// Evicted to relieve capacity pressure when both tiers are full.
100    CapacityPressure,
101}
102
103/// Single entry stored in the hot or warm tier.
104#[derive(Debug, Clone)]
105pub struct Cac2Entry {
106    /// Content identifier.
107    pub cid: Cac2Cid,
108    /// Raw payload.
109    pub data: Vec<u8>,
110    /// Number of times this entry has been accessed (reads only).
111    pub access_count: u32,
112    /// UNIX timestamp (seconds) when the entry was first inserted.
113    pub inserted_at: u64,
114    /// UNIX timestamp (seconds) of the most-recent access.
115    pub last_accessed: u64,
116    /// Current tier.
117    pub tier: Cac2Tier,
118}
119
120/// A record of a single eviction event.
121#[derive(Debug, Clone)]
122pub struct Cac2EvictionRecord {
123    /// UNIX timestamp (seconds) when the eviction occurred.
124    pub ts: u64,
125    /// Which entry was evicted.
126    pub cid: Cac2Cid,
127    /// Which tier the entry was evicted from.
128    pub tier: Cac2Tier,
129    /// Why it was evicted.
130    pub reason: Cac2EvictionReason,
131}
132
133/// Configuration knobs for `ContentAddressedCacheV2`.
134#[derive(Debug, Clone)]
135pub struct Cac2CacheConfig {
136    /// Maximum number of entries in the hot tier.
137    pub hot_capacity: usize,
138    /// Maximum number of entries in the warm tier.
139    pub warm_capacity: usize,
140    /// Number of bits in the Bloom filter backing array.
141    pub bloom_size: usize,
142    /// Hot-tier TTL in seconds (0 = no TTL).
143    pub hot_ttl_secs: u64,
144    /// Warm-tier TTL in seconds (0 = no TTL).
145    pub warm_ttl_secs: u64,
146    /// Minimum access count before an entry is eligible for hot-tier admission via promotion.
147    pub admission_threshold: u32,
148}
149
150impl Default for Cac2CacheConfig {
151    fn default() -> Self {
152        Self {
153            hot_capacity: 512,
154            warm_capacity: 4096,
155            bloom_size: 1 << 16, // 64 KiB of bits → 8 KiB of bytes
156            hot_ttl_secs: 300,
157            warm_ttl_secs: 3600,
158            admission_threshold: 2,
159        }
160    }
161}
162
163/// Snapshot of cache statistics.
164#[derive(Debug, Clone, Default)]
165pub struct Cac2CacheStats {
166    /// Number of entries currently in the hot tier.
167    pub hot_count: usize,
168    /// Number of entries currently in the warm tier.
169    pub warm_count: usize,
170    /// Cumulative hit rate (0.0–1.0).
171    pub hit_rate: f64,
172    /// Cumulative miss rate (0.0–1.0).
173    pub miss_rate: f64,
174    /// Total number of eviction events recorded.
175    pub eviction_count: u64,
176    /// Estimated Bloom-filter false-positive probability.
177    pub bloom_false_positive_est: f64,
178}
179
180// ─── Type aliases (as required by spec) ──────────────────────────────────────
181
182/// Alias for the main cache type.
183pub type Cac2ContentAddressedCacheV2 = ContentAddressedCacheV2;
184
185// ─── Internal Bloom helper ────────────────────────────────────────────────────
186
187/// Minimal Bloom filter backed by a `Vec<u64>` word array.
188///
189/// Uses three independent FNV-1a probes (with seed mixing) to test membership.
190struct BloomFilter {
191    bits: Vec<u64>,
192    /// Total number of addressable bits.
193    num_bits: u64,
194    /// Count of set bits (approximate — never decremented after clear).
195    set_count: u64,
196}
197
198impl BloomFilter {
199    fn new(num_bits: usize) -> Self {
200        let num_bits = num_bits.max(64);
201        let words = num_bits.div_ceil(64);
202        Self {
203            bits: vec![0u64; words],
204            num_bits: (words * 64) as u64,
205            set_count: 0,
206        }
207    }
208
209    /// Three probe positions derived from the CID bytes via seeded FNV-1a.
210    fn probe_positions(&self, cid: &Cac2Cid) -> [u64; 3] {
211        let h0 = fnv1a_64(&cid.0);
212        // Mix the seed into the data for independent probes.
213        let mut seed1 = h0 ^ 0xdeadbeef_cafebabe;
214        let h1 = xorshift64(&mut seed1);
215        let mut seed2 = h1 ^ 0x0123456789abcdef;
216        let h2 = xorshift64(&mut seed2);
217        [h0 % self.num_bits, h1 % self.num_bits, h2 % self.num_bits]
218    }
219
220    fn insert(&mut self, cid: &Cac2Cid) {
221        for pos in self.probe_positions(cid) {
222            let word = (pos / 64) as usize;
223            let bit = pos % 64;
224            if self.bits[word] & (1u64 << bit) == 0 {
225                self.bits[word] |= 1u64 << bit;
226                self.set_count += 1;
227            }
228        }
229    }
230
231    fn probably_contains(&self, cid: &Cac2Cid) -> bool {
232        for pos in self.probe_positions(cid) {
233            let word = (pos / 64) as usize;
234            let bit = pos % 64;
235            if self.bits[word] & (1u64 << bit) == 0 {
236                return false;
237            }
238        }
239        true
240    }
241
242    /// Estimate false-positive probability using the standard Bloom formula.
243    /// p ≈ (1 – e^(–k·n/m))^k  where k=3, m = num_bits, n ≈ set_count/3
244    fn false_positive_estimate(&self) -> f64 {
245        let m = self.num_bits as f64;
246        let n = (self.set_count / 3) as f64; // each insertion sets ~3 bits
247        let k = 3.0_f64;
248        let inner = 1.0 - ((-k * n) / m).exp();
249        inner.powf(k)
250    }
251}
252
253// ─── LRU tracking helper ──────────────────────────────────────────────────────
254
255/// Minimal doubly-linked-list LRU tracker that stores CIDs in order from
256/// most-recently-used (front) to least-recently-used (back).
257///
258/// We implement it using a `VecDeque` because the hot tier is bounded to a
259/// small capacity; O(n) scans are fine in practice for sizes ≤ 1024.
260struct LruList {
261    order: VecDeque<Cac2Cid>,
262}
263
264impl LruList {
265    fn new() -> Self {
266        Self {
267            order: VecDeque::new(),
268        }
269    }
270
271    /// Mark `cid` as most-recently used.  Insert at front if not present.
272    fn touch(&mut self, cid: Cac2Cid) {
273        self.order.retain(|c| *c != cid);
274        self.order.push_front(cid);
275    }
276
277    /// Remove and return the LRU (least-recently-used) CID, if any.
278    fn evict_lru(&mut self) -> Option<Cac2Cid> {
279        self.order.pop_back()
280    }
281
282    /// Remove a specific CID from the tracking list.
283    fn remove(&mut self, cid: &Cac2Cid) {
284        self.order.retain(|c| c != cid);
285    }
286
287    #[allow(dead_code)]
288    fn len(&self) -> usize {
289        self.order.len()
290    }
291}
292
293// ─── Main cache struct ────────────────────────────────────────────────────────
294
295/// Production-grade content-addressed cache with hot/warm tiering,
296/// Bloom-filter admission control, and rich eviction accounting.
297pub struct ContentAddressedCacheV2 {
298    /// Fast, small tier — LRU eviction policy.
299    hot: HashMap<Cac2Cid, Cac2Entry>,
300    /// Larger tier — LFU eviction policy.
301    warm: HashMap<Cac2Cid, Cac2Entry>,
302    /// Bloom filter used as an admission gate.
303    bloom: BloomFilter,
304    /// Bounded ring of the last 500 eviction events.
305    eviction_log: VecDeque<Cac2EvictionRecord>,
306    /// LRU order tracker for the hot tier.
307    hot_lru: LruList,
308    /// Configuration.
309    config: Cac2CacheConfig,
310    /// Cumulative hit counter.
311    hits: u64,
312    /// Cumulative miss counter.
313    misses: u64,
314    /// Total evictions ever performed (may exceed the log's 500-record window).
315    total_evictions: u64,
316    /// PRNG state for tie-breaking and internal randomness.
317    #[allow(dead_code)]
318    rng_state: u64,
319}
320
321impl ContentAddressedCacheV2 {
322    /// Construct a new cache with the given configuration.
323    pub fn new(config: Cac2CacheConfig) -> Self {
324        let bloom_size = config.bloom_size;
325        Self {
326            hot: HashMap::new(),
327            warm: HashMap::new(),
328            bloom: BloomFilter::new(bloom_size),
329            eviction_log: VecDeque::new(),
330            hot_lru: LruList::new(),
331            config,
332            hits: 0,
333            misses: 0,
334            total_evictions: 0,
335            rng_state: 0xcafe_babe_dead_beef,
336        }
337    }
338
339    /// Construct with default configuration.
340    pub fn default_config() -> Self {
341        Self::new(Cac2CacheConfig::default())
342    }
343
344    // ─── Bloom-filter facade ──────────────────────────────────────────────
345
346    /// Test whether `cid` *probably* exists in the admission filter.
347    pub fn bloom_probably_contains(&self, cid: &Cac2Cid) -> bool {
348        self.bloom.probably_contains(cid)
349    }
350
351    // ─── Core insert ─────────────────────────────────────────────────────
352
353    /// Insert `data` under `cid`.
354    ///
355    /// Admission control: if the Bloom filter does not contain `cid` yet
356    /// (i.e., this is a cold first-time access), the entry is written only to
357    /// the warm tier — bypassing the hot tier to protect it from one-hit
358    /// wonders.  If the Bloom filter *does* contain the CID (repeat access),
359    /// the entry is routed to the hot tier directly.
360    ///
361    /// Eviction cascade:
362    /// 1. Hot full → demote the LRU hot entry to warm.
363    /// 2. Warm full → evict the entry with the lowest `access_count` (LFU).
364    pub fn insert(&mut self, cid: Cac2Cid, data: Vec<u8>) {
365        let now = now_secs();
366
367        // If the CID already lives in hot or warm, update in-place and return.
368        if let Some(entry) = self.hot.get_mut(&cid) {
369            entry.data = data;
370            entry.last_accessed = now;
371            entry.access_count = entry.access_count.saturating_add(1);
372            self.hot_lru.touch(cid);
373            self.bloom.insert(&cid);
374            return;
375        }
376        if let Some(entry) = self.warm.get_mut(&cid) {
377            entry.data = data;
378            entry.last_accessed = now;
379            entry.access_count = entry.access_count.saturating_add(1);
380            self.bloom.insert(&cid);
381            return;
382        }
383
384        // Admission gate: has this CID been seen before?
385        let seen_before = self.bloom.probably_contains(&cid);
386        self.bloom.insert(&cid);
387
388        if seen_before {
389            // Route to hot tier.
390            self.ensure_hot_capacity(now);
391            let entry = Cac2Entry {
392                cid,
393                data,
394                access_count: 1,
395                inserted_at: now,
396                last_accessed: now,
397                tier: Cac2Tier::Hot,
398            };
399            self.hot.insert(cid, entry);
400            self.hot_lru.touch(cid);
401        } else {
402            // First-time CID → route to warm tier (avoid polluting hot).
403            self.ensure_warm_capacity(now);
404            let entry = Cac2Entry {
405                cid,
406                data,
407                access_count: 0,
408                inserted_at: now,
409                last_accessed: now,
410                tier: Cac2Tier::Warm,
411            };
412            self.warm.insert(cid, entry);
413        }
414    }
415
416    // ─── Core get ────────────────────────────────────────────────────────
417
418    /// Retrieve a cached payload.
419    ///
420    /// Look-up order: hot tier first, then warm tier.  A warm hit with
421    /// `access_count > admission_threshold` triggers promotion to the hot tier.
422    pub fn get(&mut self, cid: &Cac2Cid) -> Option<&[u8]> {
423        let now = now_secs();
424
425        // Hot hit.
426        if let Some(entry) = self.hot.get_mut(cid) {
427            entry.access_count = entry.access_count.saturating_add(1);
428            entry.last_accessed = now;
429            self.hits += 1;
430            self.hot_lru.touch(*cid);
431            // Need to re-borrow immutably for the return.
432            return self.hot.get(cid).map(|e| e.data.as_slice());
433        }
434
435        // Warm hit — check for promotion eligibility.
436        if self.warm.contains_key(cid) {
437            let threshold = self.config.admission_threshold;
438            // Mutate first, then decide.
439            if let Some(entry) = self.warm.get_mut(cid) {
440                entry.access_count = entry.access_count.saturating_add(1);
441                entry.last_accessed = now;
442                self.hits += 1;
443            }
444            let promote = self
445                .warm
446                .get(cid)
447                .map(|e| e.access_count > threshold)
448                .unwrap_or(false);
449            if promote {
450                self.promote_warm_to_hot(cid, now);
451                return self.hot.get(cid).map(|e| e.data.as_slice());
452            }
453            return self.warm.get(cid).map(|e| e.data.as_slice());
454        }
455
456        self.misses += 1;
457        None
458    }
459
460    // ─── Manual eviction ─────────────────────────────────────────────────
461
462    /// Forcibly remove `cid` from all tiers.
463    pub fn evict(&mut self, cid: &Cac2Cid) {
464        let now = now_secs();
465        if self.hot.remove(cid).is_some() {
466            self.hot_lru.remove(cid);
467            self.log_eviction(now, *cid, Cac2Tier::Hot, Cac2EvictionReason::ManualEviction);
468        }
469        if self.warm.remove(cid).is_some() {
470            self.log_eviction(
471                now,
472                *cid,
473                Cac2Tier::Warm,
474                Cac2EvictionReason::ManualEviction,
475            );
476        }
477    }
478
479    // ─── TTL sweep ────────────────────────────────────────────────────────
480
481    /// Remove all entries whose TTL has elapsed as of `now_ts`.
482    /// Pass `now_secs()` for the current wall-clock time.
483    pub fn expire_stale(&mut self, now_ts: u64) {
484        let hot_ttl = self.config.hot_ttl_secs;
485        let warm_ttl = self.config.warm_ttl_secs;
486
487        if hot_ttl > 0 {
488            let expired_hot: Vec<Cac2Cid> = self
489                .hot
490                .values()
491                .filter(|e| now_ts.saturating_sub(e.inserted_at) >= hot_ttl)
492                .map(|e| e.cid)
493                .collect();
494            for cid in expired_hot {
495                self.hot.remove(&cid);
496                self.hot_lru.remove(&cid);
497                self.log_eviction(now_ts, cid, Cac2Tier::Hot, Cac2EvictionReason::TtlExpiry);
498            }
499        }
500
501        if warm_ttl > 0 {
502            let expired_warm: Vec<Cac2Cid> = self
503                .warm
504                .values()
505                .filter(|e| now_ts.saturating_sub(e.inserted_at) >= warm_ttl)
506                .map(|e| e.cid)
507                .collect();
508            for cid in expired_warm {
509                self.warm.remove(&cid);
510                self.log_eviction(now_ts, cid, Cac2Tier::Warm, Cac2EvictionReason::TtlExpiry);
511            }
512        }
513    }
514
515    // ─── Warm drain ───────────────────────────────────────────────────────
516
517    /// Drain the lowest-frequency 25% of warm-tier entries, returning them
518    /// as `(Cac2Cid, Vec<u8>)` pairs so the caller can write them to disk.
519    ///
520    /// This simulates a write-back flush in a hierarchical storage system.
521    pub fn drain_warm_to_disk_simulation(&mut self) -> Vec<(Cac2Cid, Vec<u8>)> {
522        if self.warm.is_empty() {
523            return Vec::new();
524        }
525
526        let drain_count = ((self.warm.len() as f64) * 0.25).ceil() as usize;
527        if drain_count == 0 {
528            return Vec::new();
529        }
530
531        let now = now_secs();
532
533        // Collect and sort by access_count ascending (lowest frequency first).
534        let mut candidates: Vec<(u32, Cac2Cid)> = self
535            .warm
536            .values()
537            .map(|e| (e.access_count, e.cid))
538            .collect();
539        candidates.sort_unstable_by_key(|(count, _)| *count);
540
541        let to_drain: Vec<Cac2Cid> = candidates
542            .into_iter()
543            .take(drain_count)
544            .map(|(_, cid)| cid)
545            .collect();
546
547        let mut result = Vec::with_capacity(to_drain.len());
548        for cid in to_drain {
549            if let Some(entry) = self.warm.remove(&cid) {
550                self.log_eviction(now, cid, Cac2Tier::Warm, Cac2EvictionReason::LfuEviction);
551                result.push((cid, entry.data));
552            }
553        }
554        result
555    }
556
557    // ─── Stats ────────────────────────────────────────────────────────────
558
559    /// Return a snapshot of the current cache statistics.
560    pub fn cache_stats(&self) -> Cac2CacheStats {
561        let total = self.hits + self.misses;
562        let (hit_rate, miss_rate) = if total == 0 {
563            (0.0, 0.0)
564        } else {
565            (
566                self.hits as f64 / total as f64,
567                self.misses as f64 / total as f64,
568            )
569        };
570        Cac2CacheStats {
571            hot_count: self.hot.len(),
572            warm_count: self.warm.len(),
573            hit_rate,
574            miss_rate,
575            eviction_count: self.total_evictions,
576            bloom_false_positive_est: self.bloom.false_positive_estimate(),
577        }
578    }
579
580    /// Expose a reference to the eviction log.
581    pub fn eviction_log(&self) -> &VecDeque<Cac2EvictionRecord> {
582        &self.eviction_log
583    }
584
585    /// Return the current configuration.
586    pub fn config(&self) -> &Cac2CacheConfig {
587        &self.config
588    }
589
590    /// Number of entries in the hot tier.
591    pub fn hot_len(&self) -> usize {
592        self.hot.len()
593    }
594
595    /// Number of entries in the warm tier.
596    pub fn warm_len(&self) -> usize {
597        self.warm.len()
598    }
599
600    /// Total number of cached entries across both tiers.
601    pub fn total_len(&self) -> usize {
602        self.hot.len() + self.warm.len()
603    }
604
605    /// Return `true` if neither tier contains any entry.
606    pub fn is_empty(&self) -> bool {
607        self.hot.is_empty() && self.warm.is_empty()
608    }
609
610    /// Check whether `cid` is present in either tier without updating stats.
611    pub fn contains(&self, cid: &Cac2Cid) -> bool {
612        self.hot.contains_key(cid) || self.warm.contains_key(cid)
613    }
614
615    // ─── Private helpers ──────────────────────────────────────────────────
616
617    /// Make room in the hot tier by demoting the LRU entry to warm.
618    fn ensure_hot_capacity(&mut self, now: u64) {
619        while self.hot.len() >= self.config.hot_capacity {
620            if let Some(lru_cid) = self.hot_lru.evict_lru() {
621                if let Some(mut entry) = self.hot.remove(&lru_cid) {
622                    self.log_eviction(now, lru_cid, Cac2Tier::Hot, Cac2EvictionReason::LruEviction);
623                    // Demote to warm (make room first if needed).
624                    self.ensure_warm_capacity(now);
625                    entry.tier = Cac2Tier::Warm;
626                    self.warm.insert(lru_cid, entry);
627                }
628            } else {
629                break;
630            }
631        }
632    }
633
634    /// Make room in the warm tier by evicting the entry with the lowest access count (LFU).
635    fn ensure_warm_capacity(&mut self, now: u64) {
636        while self.warm.len() >= self.config.warm_capacity {
637            // Find the CID with the minimum access_count; break ties with LRU (last_accessed).
638            let victim_cid = self
639                .warm
640                .values()
641                .min_by(|a, b| {
642                    a.access_count
643                        .cmp(&b.access_count)
644                        .then_with(|| a.last_accessed.cmp(&b.last_accessed))
645                })
646                .map(|e| e.cid);
647
648            if let Some(cid) = victim_cid {
649                self.warm.remove(&cid);
650                self.log_eviction(now, cid, Cac2Tier::Warm, Cac2EvictionReason::LfuEviction);
651            } else {
652                break;
653            }
654        }
655    }
656
657    /// Promote a warm entry to the hot tier.
658    fn promote_warm_to_hot(&mut self, cid: &Cac2Cid, now: u64) {
659        if let Some(mut entry) = self.warm.remove(cid) {
660            self.ensure_hot_capacity(now);
661            entry.tier = Cac2Tier::Hot;
662            entry.last_accessed = now;
663            self.hot.insert(*cid, entry);
664            self.hot_lru.touch(*cid);
665        }
666    }
667
668    /// Append an eviction record to the bounded log.
669    fn log_eviction(&mut self, ts: u64, cid: Cac2Cid, tier: Cac2Tier, reason: Cac2EvictionReason) {
670        const MAX_EVICTION_LOG: usize = 500;
671        if self.eviction_log.len() >= MAX_EVICTION_LOG {
672            self.eviction_log.pop_front();
673        }
674        self.eviction_log.push_back(Cac2EvictionRecord {
675            ts,
676            cid,
677            tier: Cac2Tier::Evicted,
678            reason,
679        });
680        // Suppress the unused warning on the `tier` param — we store `Evicted`
681        // in the record per the spec, but we still accept the source tier for
682        // future logging extensions.
683        let _ = tier;
684        self.total_evictions += 1;
685    }
686
687    /// Expose the raw PRNG state accessor for testing.
688    #[cfg(test)]
689    fn next_rand(&mut self) -> u64 {
690        xorshift64(&mut self.rng_state)
691    }
692}
693
694// ─── Tests ────────────────────────────────────────────────────────────────────
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    // ── helpers ──────────────────────────────────────────────────────────────
701
702    fn make_cid(seed: u8) -> Cac2Cid {
703        Cac2Cid([seed; 32])
704    }
705
706    fn make_cid_distinct(n: u64) -> Cac2Cid {
707        Cac2Cid::from_bytes(&n.to_le_bytes())
708    }
709
710    fn default_cache() -> ContentAddressedCacheV2 {
711        ContentAddressedCacheV2::new(Cac2CacheConfig {
712            hot_capacity: 4,
713            warm_capacity: 8,
714            bloom_size: 256,
715            hot_ttl_secs: 10,
716            warm_ttl_secs: 60,
717            admission_threshold: 2,
718        })
719    }
720
721    // ── Cac2Cid ──────────────────────────────────────────────────────────────
722
723    #[test]
724    fn test_cid_from_slice_ok() {
725        let data = [7u8; 32];
726        let cid = Cac2Cid::from_slice(&data);
727        assert!(cid.is_some());
728        assert_eq!(cid.unwrap().0, data);
729    }
730
731    #[test]
732    fn test_cid_from_slice_too_short() {
733        let short = [1u8; 10];
734        assert!(Cac2Cid::from_slice(&short).is_none());
735    }
736
737    #[test]
738    fn test_cid_from_bytes_deterministic() {
739        let a = Cac2Cid::from_bytes(b"hello");
740        let b = Cac2Cid::from_bytes(b"hello");
741        assert_eq!(a, b);
742    }
743
744    #[test]
745    fn test_cid_from_bytes_different_inputs() {
746        let a = Cac2Cid::from_bytes(b"foo");
747        let b = Cac2Cid::from_bytes(b"bar");
748        assert_ne!(a, b);
749    }
750
751    #[test]
752    fn test_cid_copy_semantics() {
753        let a = make_cid(1);
754        let b = a; // copy
755        assert_eq!(a, b);
756    }
757
758    #[test]
759    fn test_cid_hash_equality() {
760        let mut set = std::collections::HashSet::new();
761        set.insert(make_cid(42));
762        assert!(set.contains(&make_cid(42)));
763        assert!(!set.contains(&make_cid(43)));
764    }
765
766    // ── Bloom filter ─────────────────────────────────────────────────────────
767
768    #[test]
769    fn test_bloom_new_empty() {
770        let bf = BloomFilter::new(1024);
771        let cid = make_cid(1);
772        assert!(!bf.probably_contains(&cid));
773    }
774
775    #[test]
776    fn test_bloom_insert_then_contains() {
777        let mut bf = BloomFilter::new(1024);
778        let cid = make_cid(99);
779        bf.insert(&cid);
780        assert!(bf.probably_contains(&cid));
781    }
782
783    #[test]
784    fn test_bloom_multiple_cids() {
785        let mut bf = BloomFilter::new(8192);
786        for i in 0u8..50 {
787            let cid = make_cid(i);
788            bf.insert(&cid);
789        }
790        for i in 0u8..50 {
791            let cid = make_cid(i);
792            assert!(bf.probably_contains(&cid), "false negative for cid {i}");
793        }
794    }
795
796    #[test]
797    fn test_bloom_false_positive_estimate_increases_with_load() {
798        let mut bf = BloomFilter::new(256);
799        let fp0 = bf.false_positive_estimate();
800        for i in 0u64..20 {
801            bf.insert(&make_cid_distinct(i));
802        }
803        let fp20 = bf.false_positive_estimate();
804        assert!(fp20 > fp0, "FP rate should increase with insertions");
805    }
806
807    #[test]
808    fn test_bloom_no_false_negatives() {
809        let mut bf = BloomFilter::new(65536);
810        for i in 0u64..200 {
811            let cid = make_cid_distinct(i);
812            bf.insert(&cid);
813            assert!(bf.probably_contains(&cid), "false negative at i={i}");
814        }
815    }
816
817    // ── LRU list ─────────────────────────────────────────────────────────────
818
819    #[test]
820    fn test_lru_touch_and_evict() {
821        let mut lru = LruList::new();
822        let a = make_cid(1);
823        let b = make_cid(2);
824        let c = make_cid(3);
825        lru.touch(a);
826        lru.touch(b);
827        lru.touch(c);
828        // LRU should be `a`
829        assert_eq!(lru.evict_lru(), Some(a));
830    }
831
832    #[test]
833    fn test_lru_touch_promotes_existing() {
834        let mut lru = LruList::new();
835        let a = make_cid(1);
836        let b = make_cid(2);
837        lru.touch(a);
838        lru.touch(b);
839        lru.touch(a); // promote a to front
840        assert_eq!(lru.evict_lru(), Some(b));
841    }
842
843    #[test]
844    fn test_lru_remove_specific() {
845        let mut lru = LruList::new();
846        let a = make_cid(1);
847        let b = make_cid(2);
848        lru.touch(a);
849        lru.touch(b);
850        lru.remove(&a);
851        assert_eq!(lru.len(), 1);
852        assert_eq!(lru.evict_lru(), Some(b));
853    }
854
855    #[test]
856    fn test_lru_evict_from_empty() {
857        let mut lru = LruList::new();
858        assert_eq!(lru.evict_lru(), None);
859    }
860
861    // ── Cache construction ────────────────────────────────────────────────────
862
863    #[test]
864    fn test_cache_new_empty() {
865        let cache = default_cache();
866        assert!(cache.is_empty());
867        assert_eq!(cache.total_len(), 0);
868    }
869
870    #[test]
871    fn test_cache_default_config() {
872        let cache = ContentAddressedCacheV2::default_config();
873        assert_eq!(cache.config().hot_capacity, 512);
874    }
875
876    // ── Insert / get basic ────────────────────────────────────────────────────
877
878    #[test]
879    fn test_insert_and_get_warm() {
880        let mut cache = default_cache();
881        let cid = make_cid(1);
882        // First insert goes to warm (bloom miss).
883        cache.insert(cid, b"hello".to_vec());
884        assert_eq!(cache.warm_len(), 1);
885        assert_eq!(cache.hot_len(), 0);
886        let val = cache.get(&cid);
887        assert_eq!(val, Some(b"hello".as_ref()));
888    }
889
890    #[test]
891    fn test_insert_twice_routes_to_hot() {
892        let mut cache = default_cache();
893        let cid = make_cid(10);
894        // First insert → bloom miss → warm tier
895        cache.insert(cid, b"v1".to_vec());
896        // Evict from warm so the CID is absent, but bloom remembers it.
897        cache.warm.remove(&cid);
898        // Second insert → bloom hit → hot tier
899        cache.insert(cid, b"v2".to_vec());
900        assert_eq!(cache.hot_len(), 1);
901    }
902
903    #[test]
904    fn test_get_miss_returns_none() {
905        let mut cache = default_cache();
906        let cid = make_cid(200);
907        assert_eq!(cache.get(&cid), None);
908    }
909
910    #[test]
911    fn test_get_increments_access_count() {
912        let mut cache = default_cache();
913        let cid = make_cid(5);
914        cache.insert(cid, b"data".to_vec());
915        // Insert to warm; first get → warm
916        let _ = cache.get(&cid);
917        let _ = cache.get(&cid);
918        let entry = cache.warm.get(&cid).or_else(|| cache.hot.get(&cid));
919        assert!(entry.map(|e| e.access_count).unwrap_or(0) >= 1);
920    }
921
922    #[test]
923    fn test_get_updates_last_accessed() {
924        let mut cache = default_cache();
925        let cid = make_cid(7);
926        cache.insert(cid, b"x".to_vec());
927        let before = cache.warm.get(&cid).map(|e| e.last_accessed).unwrap_or(0);
928        // Even a small pause won't guarantee the clock advanced in a fast test,
929        // so we just check the field is non-zero.
930        assert!(before > 0);
931        let _ = cache.get(&cid);
932    }
933
934    // ── Admission control ─────────────────────────────────────────────────────
935
936    #[test]
937    fn test_bloom_admission_cold_goes_to_warm() {
938        let mut cache = default_cache();
939        let cid = Cac2Cid::from_bytes(b"unique_cold_key_abc");
940        cache.insert(cid, b"payload".to_vec());
941        assert_eq!(cache.warm_len(), 1);
942        assert_eq!(cache.hot_len(), 0);
943    }
944
945    #[test]
946    fn test_bloom_admission_warm_hit_goes_to_hot() {
947        let mut cache = default_cache();
948        let cid = Cac2Cid::from_bytes(b"repeated_key_xyz");
949        // First insert: bloom miss → warm tier.
950        cache.insert(cid, b"first".to_vec());
951        assert_eq!(cache.warm_len(), 1, "should be in warm after cold insert");
952        // Manually evict from warm so the CID is no longer cached, but the
953        // bloom filter still knows about it.
954        cache.warm.remove(&cid);
955        // Second insert: bloom already contains this CID → routed to hot.
956        cache.insert(cid, b"second".to_vec());
957        assert_eq!(cache.hot_len(), 1, "repeated CID should route to hot tier");
958    }
959
960    #[test]
961    fn test_bloom_probably_contains_after_insert() {
962        let mut cache = default_cache();
963        let cid = make_cid(33);
964        assert!(!cache.bloom_probably_contains(&cid));
965        cache.insert(cid, b"z".to_vec());
966        assert!(cache.bloom_probably_contains(&cid));
967    }
968
969    // ── Promotion warm → hot ──────────────────────────────────────────────────
970
971    #[test]
972    fn test_promotion_on_repeated_get() {
973        // admission_threshold = 2; after 3 gets the entry should be promoted.
974        let mut cache = default_cache();
975        let cid = Cac2Cid::from_bytes(b"promo_test");
976        cache.insert(cid, b"data".to_vec()); // warm (cold insert)
977                                             // Gets increment access_count; promotion fires when count > threshold (2).
978        let _ = cache.get(&cid); // count = 1
979        let _ = cache.get(&cid); // count = 2
980        let _ = cache.get(&cid); // count = 3 → promote
981        assert_eq!(cache.hot_len(), 1, "entry should have been promoted to hot");
982    }
983
984    #[test]
985    fn test_no_promotion_below_threshold() {
986        let mut cache = default_cache(); // threshold = 2
987        let cid = Cac2Cid::from_bytes(b"no_promo");
988        cache.insert(cid, b"d".to_vec()); // warm
989        let _ = cache.get(&cid); // count = 1 (not > 2 yet)
990        assert_eq!(cache.hot_len(), 0, "should not yet be promoted");
991    }
992
993    // ── Hot-tier LRU eviction ─────────────────────────────────────────────────
994
995    #[test]
996    fn test_hot_lru_eviction_demotes_to_warm() {
997        // hot_capacity = 4; insert 5 hot entries, the LRU should be demoted.
998        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
999            hot_capacity: 3,
1000            warm_capacity: 10,
1001            bloom_size: 512,
1002            hot_ttl_secs: 0,
1003            warm_ttl_secs: 0,
1004            admission_threshold: 0,
1005        });
1006        // With admission_threshold = 0 every insert goes to hot (bloom sees it as seen-before
1007        // after a single insertion; but the first insert is always a bloom miss). We force all
1008        // to warm first, then re-insert to route to hot.
1009        for i in 0u8..4 {
1010            let cid = make_cid(i);
1011            cache.insert(cid, vec![i]); // warm (bloom miss)
1012            cache.insert(cid, vec![i, i]); // hot (bloom hit)
1013        }
1014        // hot should be capped at 3, the 4th should have demoted LRU to warm.
1015        assert!(cache.hot_len() <= 3);
1016        assert!(cache.warm_len() >= 1);
1017    }
1018
1019    // ── Warm-tier LFU eviction ────────────────────────────────────────────────
1020
1021    #[test]
1022    fn test_warm_lfu_eviction() {
1023        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1024            hot_capacity: 100,
1025            warm_capacity: 3,
1026            bloom_size: 512,
1027            hot_ttl_secs: 0,
1028            warm_ttl_secs: 0,
1029            admission_threshold: 100, // very high threshold → no promotion
1030        });
1031
1032        let cid_a = make_cid(1);
1033        let cid_b = make_cid(2);
1034        let cid_c = make_cid(3);
1035        let cid_d = make_cid(4);
1036
1037        // Fill warm tier (all are bloom-misses so go to warm).
1038        cache.insert(cid_a, b"A".to_vec());
1039        cache.insert(cid_b, b"B".to_vec());
1040        cache.insert(cid_c, b"C".to_vec());
1041
1042        // Access A and B to raise their frequency.
1043        let _ = cache.get(&cid_a);
1044        let _ = cache.get(&cid_a);
1045        let _ = cache.get(&cid_b);
1046
1047        // Insert D — warm is full; C (access_count = 0) should be evicted.
1048        cache.insert(cid_d, b"D".to_vec());
1049
1050        assert!(!cache.contains(&cid_c), "C should have been LFU-evicted");
1051    }
1052
1053    // ── Manual eviction ───────────────────────────────────────────────────────
1054
1055    #[test]
1056    fn test_evict_removes_hot_entry() {
1057        let mut cache = default_cache();
1058        let cid = make_cid(20);
1059        cache.insert(cid, b"hot".to_vec()); // warm first
1060        cache.insert(cid, b"hot2".to_vec()); // now hot
1061        cache.evict(&cid);
1062        assert!(!cache.contains(&cid));
1063    }
1064
1065    #[test]
1066    fn test_evict_removes_warm_entry() {
1067        let mut cache = default_cache();
1068        let cid = make_cid(21);
1069        cache.insert(cid, b"warm".to_vec());
1070        cache.evict(&cid);
1071        assert!(!cache.contains(&cid));
1072    }
1073
1074    #[test]
1075    fn test_evict_nonexistent_is_noop() {
1076        let mut cache = default_cache();
1077        cache.evict(&make_cid(255)); // should not panic
1078    }
1079
1080    // ── TTL expiry ────────────────────────────────────────────────────────────
1081
1082    #[test]
1083    fn test_expire_stale_removes_old_hot_entries() {
1084        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1085            hot_capacity: 10,
1086            warm_capacity: 10,
1087            bloom_size: 256,
1088            hot_ttl_secs: 5,
1089            warm_ttl_secs: 0,
1090            admission_threshold: 0,
1091        });
1092
1093        let cid = make_cid(50);
1094        cache.insert(cid, b"ttl".to_vec()); // warm
1095        cache.insert(cid, b"ttl2".to_vec()); // hot (bloom hit on second insert)
1096
1097        // Manually force insertion time far into the past.
1098        if let Some(entry) = cache.hot.get_mut(&cid) {
1099            entry.inserted_at = 0; // epoch → definitely stale
1100        }
1101
1102        let future_ts = 1_000_000u64;
1103        cache.expire_stale(future_ts);
1104        assert!(
1105            !cache.hot.contains_key(&cid),
1106            "hot entry should have expired"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_expire_stale_removes_old_warm_entries() {
1112        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1113            hot_capacity: 10,
1114            warm_capacity: 10,
1115            bloom_size: 256,
1116            hot_ttl_secs: 0,
1117            warm_ttl_secs: 30,
1118            admission_threshold: 100,
1119        });
1120
1121        let cid = make_cid(51);
1122        cache.insert(cid, b"warm_old".to_vec());
1123
1124        if let Some(entry) = cache.warm.get_mut(&cid) {
1125            entry.inserted_at = 0;
1126        }
1127
1128        cache.expire_stale(1_000_000);
1129        assert!(!cache.warm.contains_key(&cid));
1130    }
1131
1132    #[test]
1133    fn test_expire_stale_keeps_fresh_entries() {
1134        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1135            hot_capacity: 10,
1136            warm_capacity: 10,
1137            bloom_size: 256,
1138            hot_ttl_secs: 300,
1139            warm_ttl_secs: 300,
1140            admission_threshold: 100,
1141        });
1142
1143        let cid = make_cid(52);
1144        cache.insert(cid, b"fresh".to_vec());
1145
1146        // Do not modify inserted_at; call expire with now = inserted_at (no elapsed time).
1147        let now = cache
1148            .warm
1149            .get(&cid)
1150            .map(|e| e.inserted_at)
1151            .unwrap_or(now_secs());
1152        cache.expire_stale(now);
1153        assert!(cache.contains(&cid), "fresh entry should survive TTL sweep");
1154    }
1155
1156    #[test]
1157    fn test_expire_stale_zero_ttl_skips() {
1158        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1159            hot_ttl_secs: 0,
1160            warm_ttl_secs: 0,
1161            ..Cac2CacheConfig::default()
1162        });
1163        let cid = make_cid(53);
1164        cache.insert(cid, b"forever".to_vec());
1165        if let Some(e) = cache.warm.get_mut(&cid) {
1166            e.inserted_at = 0;
1167        }
1168        cache.expire_stale(u64::MAX);
1169        assert!(cache.contains(&cid));
1170    }
1171
1172    // ── Drain warm ────────────────────────────────────────────────────────────
1173
1174    #[test]
1175    fn test_drain_warm_empty_returns_empty() {
1176        let mut cache = default_cache();
1177        let drained = cache.drain_warm_to_disk_simulation();
1178        assert!(drained.is_empty());
1179    }
1180
1181    #[test]
1182    fn test_drain_warm_drains_25_percent() {
1183        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1184            hot_capacity: 100,
1185            warm_capacity: 1000,
1186            bloom_size: 8192,
1187            hot_ttl_secs: 0,
1188            warm_ttl_secs: 0,
1189            admission_threshold: 1000,
1190        });
1191        for i in 0u64..20 {
1192            cache.insert(make_cid_distinct(i), vec![i as u8]);
1193        }
1194        let before = cache.warm_len();
1195        let drained = cache.drain_warm_to_disk_simulation();
1196        let expected = ((before as f64) * 0.25).ceil() as usize;
1197        assert_eq!(drained.len(), expected);
1198        assert_eq!(cache.warm_len(), before - expected);
1199    }
1200
1201    #[test]
1202    fn test_drain_warm_returns_lowest_frequency() {
1203        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1204            hot_capacity: 100,
1205            warm_capacity: 1000,
1206            bloom_size: 8192,
1207            hot_ttl_secs: 0,
1208            warm_ttl_secs: 0,
1209            admission_threshold: 1000,
1210        });
1211        let high_freq = make_cid(101);
1212        let low_freq = make_cid(102);
1213
1214        cache.insert(high_freq, b"hf".to_vec());
1215        cache.insert(low_freq, b"lf".to_vec());
1216
1217        // Give high_freq many accesses.
1218        for _ in 0..10 {
1219            let _ = cache.get(&high_freq);
1220        }
1221
1222        let drained = cache.drain_warm_to_disk_simulation();
1223        let drained_cids: Vec<Cac2Cid> = drained.iter().map(|(c, _)| *c).collect();
1224        assert!(
1225            drained_cids.contains(&low_freq),
1226            "low_freq should be drained first"
1227        );
1228    }
1229
1230    // ── Stats ─────────────────────────────────────────────────────────────────
1231
1232    #[test]
1233    fn test_stats_initial_zero() {
1234        let cache = default_cache();
1235        let stats = cache.cache_stats();
1236        assert_eq!(stats.hot_count, 0);
1237        assert_eq!(stats.warm_count, 0);
1238        assert_eq!(stats.hit_rate, 0.0);
1239        assert_eq!(stats.miss_rate, 0.0);
1240        assert_eq!(stats.eviction_count, 0);
1241    }
1242
1243    #[test]
1244    fn test_stats_hit_rate() {
1245        let mut cache = default_cache();
1246        let cid = make_cid(60);
1247        cache.insert(cid, b"stat".to_vec());
1248        let _ = cache.get(&cid); // hit
1249        let _ = cache.get(&make_cid(61)); // miss
1250        let stats = cache.cache_stats();
1251        assert!((stats.hit_rate - 0.5).abs() < 1e-9);
1252        assert!((stats.miss_rate - 0.5).abs() < 1e-9);
1253    }
1254
1255    #[test]
1256    fn test_stats_eviction_count() {
1257        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1258            hot_capacity: 2,
1259            warm_capacity: 2,
1260            bloom_size: 256,
1261            hot_ttl_secs: 0,
1262            warm_ttl_secs: 0,
1263            admission_threshold: 0,
1264        });
1265        // Fill tiers enough to trigger evictions.
1266        for i in 0u8..10 {
1267            let cid = make_cid(i);
1268            cache.insert(cid, vec![i]);
1269            cache.insert(cid, vec![i]); // second → hot
1270        }
1271        assert!(cache.cache_stats().eviction_count > 0);
1272    }
1273
1274    #[test]
1275    fn test_stats_bloom_fpe_non_negative() {
1276        let cache = default_cache();
1277        assert!(cache.cache_stats().bloom_false_positive_est >= 0.0);
1278    }
1279
1280    // ── Eviction log ──────────────────────────────────────────────────────────
1281
1282    #[test]
1283    fn test_eviction_log_bounded_500() {
1284        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1285            hot_capacity: 1,
1286            warm_capacity: 1,
1287            bloom_size: 256,
1288            hot_ttl_secs: 0,
1289            warm_ttl_secs: 0,
1290            admission_threshold: 0,
1291        });
1292        // Each pair of inserts causes at least one eviction.
1293        for i in 0u64..600 {
1294            let cid = make_cid_distinct(i);
1295            cache.insert(cid, vec![0u8]);
1296            cache.insert(cid, vec![1u8]);
1297        }
1298        assert!(
1299            cache.eviction_log().len() <= 500,
1300            "eviction log must be bounded at 500"
1301        );
1302    }
1303
1304    #[test]
1305    fn test_eviction_log_records_reason() {
1306        let mut cache = default_cache();
1307        let cid = make_cid(80);
1308        cache.insert(cid, b"manual".to_vec());
1309        cache.evict(&cid);
1310        let found = cache
1311            .eviction_log()
1312            .iter()
1313            .any(|r| r.reason == Cac2EvictionReason::ManualEviction);
1314        assert!(found, "manual eviction should appear in log");
1315    }
1316
1317    #[test]
1318    fn test_eviction_log_ttl_reason() {
1319        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1320            hot_capacity: 10,
1321            warm_capacity: 10,
1322            bloom_size: 256,
1323            hot_ttl_secs: 1,
1324            warm_ttl_secs: 1,
1325            admission_threshold: 100,
1326        });
1327        let cid = make_cid(81);
1328        cache.insert(cid, b"stale".to_vec());
1329        if let Some(e) = cache.warm.get_mut(&cid) {
1330            e.inserted_at = 0;
1331        }
1332        cache.expire_stale(1_000_000);
1333        let found = cache
1334            .eviction_log()
1335            .iter()
1336            .any(|r| r.reason == Cac2EvictionReason::TtlExpiry);
1337        assert!(found);
1338    }
1339
1340    // ── contains / is_empty ───────────────────────────────────────────────────
1341
1342    #[test]
1343    fn test_contains_hot() {
1344        let mut cache = default_cache();
1345        let cid = make_cid(90);
1346        cache.insert(cid, b"h".to_vec());
1347        cache.insert(cid, b"h2".to_vec()); // to hot
1348        assert!(cache.contains(&cid));
1349    }
1350
1351    #[test]
1352    fn test_contains_warm() {
1353        let mut cache = default_cache();
1354        let cid = make_cid(91);
1355        cache.insert(cid, b"w".to_vec());
1356        assert!(cache.contains(&cid));
1357    }
1358
1359    #[test]
1360    fn test_is_empty_after_evict_all() {
1361        let mut cache = default_cache();
1362        let c1 = make_cid(92);
1363        let c2 = make_cid(93);
1364        cache.insert(c1, b"a".to_vec());
1365        cache.insert(c2, b"b".to_vec());
1366        cache.evict(&c1);
1367        cache.evict(&c2);
1368        assert!(cache.is_empty());
1369    }
1370
1371    // ── PRNG ─────────────────────────────────────────────────────────────────
1372
1373    #[test]
1374    fn test_xorshift64_non_zero() {
1375        let mut state = 12345u64;
1376        let val = xorshift64(&mut state);
1377        assert_ne!(val, 0);
1378        assert_ne!(state, 12345);
1379    }
1380
1381    #[test]
1382    fn test_cache_prng_accessor() {
1383        let mut cache = default_cache();
1384        let a = cache.next_rand();
1385        let b = cache.next_rand();
1386        assert_ne!(a, b, "consecutive PRNG values should differ");
1387    }
1388
1389    // ── FNV-1a ───────────────────────────────────────────────────────────────
1390
1391    #[test]
1392    fn test_fnv1a_deterministic() {
1393        assert_eq!(fnv1a_64(b"hello"), fnv1a_64(b"hello"));
1394    }
1395
1396    #[test]
1397    fn test_fnv1a_distinct_inputs() {
1398        assert_ne!(fnv1a_64(b"a"), fnv1a_64(b"b"));
1399    }
1400
1401    #[test]
1402    fn test_fnv1a_empty_input() {
1403        // FNV offset basis
1404        assert_eq!(fnv1a_64(b""), 14_695_981_039_346_656_037);
1405    }
1406
1407    // ── In-place update ───────────────────────────────────────────────────────
1408
1409    #[test]
1410    fn test_insert_updates_data_in_place_warm() {
1411        let mut cache = default_cache();
1412        let cid = make_cid(110);
1413        cache.insert(cid, b"v1".to_vec());
1414        cache.insert(cid, b"v2".to_vec()); // → hot (bloom hit)
1415                                           // Could be hot or warm depending on bloom state; data should be v2.
1416        let val = cache.get(&cid);
1417        assert_eq!(val, Some(b"v2".as_ref()));
1418    }
1419
1420    // ── Tier counts after operations ──────────────────────────────────────────
1421
1422    #[test]
1423    fn test_hot_len_warm_len_total_len() {
1424        let mut cache = default_cache();
1425        let cid_a = make_cid(120);
1426        let cid_b = make_cid(121);
1427        cache.insert(cid_a, b"a".to_vec()); // warm
1428        cache.insert(cid_b, b"b".to_vec()); // warm
1429        assert_eq!(cache.total_len(), 2);
1430        assert_eq!(cache.warm_len(), 2);
1431        assert_eq!(cache.hot_len(), 0);
1432    }
1433
1434    #[test]
1435    fn test_total_len_after_eviction() {
1436        let mut cache = default_cache();
1437        let cid = make_cid(122);
1438        cache.insert(cid, b"z".to_vec());
1439        assert_eq!(cache.total_len(), 1);
1440        cache.evict(&cid);
1441        assert_eq!(cache.total_len(), 0);
1442    }
1443
1444    // ── Edge cases ────────────────────────────────────────────────────────────
1445
1446    #[test]
1447    fn test_zero_capacity_warm_still_works() {
1448        // Warm capacity of 1: every insert evicts the previous warm entry.
1449        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1450            hot_capacity: 1,
1451            warm_capacity: 1,
1452            bloom_size: 128,
1453            hot_ttl_secs: 0,
1454            warm_ttl_secs: 0,
1455            admission_threshold: 999,
1456        });
1457        let c1 = make_cid(0);
1458        let c2 = make_cid(1);
1459        cache.insert(c1, b"first".to_vec());
1460        cache.insert(c2, b"second".to_vec()); // evicts c1 from warm
1461        assert!(cache.warm_len() <= 1);
1462    }
1463
1464    #[test]
1465    fn test_large_data_payload() {
1466        let mut cache = default_cache();
1467        let cid = make_cid(130);
1468        let big_data = vec![0xABu8; 1024 * 1024]; // 1 MiB
1469        cache.insert(cid, big_data.clone());
1470        let retrieved = cache.get(&cid);
1471        assert_eq!(retrieved.map(|d| d.len()), Some(1024 * 1024));
1472    }
1473
1474    #[test]
1475    fn test_get_miss_increments_miss_counter() {
1476        let mut cache = default_cache();
1477        let _ = cache.get(&make_cid(200));
1478        let _ = cache.get(&make_cid(201));
1479        let stats = cache.cache_stats();
1480        assert!((stats.miss_rate - 1.0).abs() < 1e-9);
1481    }
1482
1483    #[test]
1484    fn test_expire_stale_multiple_entries() {
1485        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1486            hot_capacity: 100,
1487            warm_capacity: 100,
1488            bloom_size: 1024,
1489            hot_ttl_secs: 10,
1490            warm_ttl_secs: 10,
1491            admission_threshold: 100,
1492        });
1493        for i in 0u8..10 {
1494            let cid = make_cid(i);
1495            cache.insert(cid, vec![i]);
1496        }
1497        // Age all entries.
1498        for entry in cache.warm.values_mut() {
1499            entry.inserted_at = 0;
1500        }
1501        cache.expire_stale(1_000_000);
1502        assert_eq!(cache.warm_len(), 0);
1503    }
1504
1505    #[test]
1506    fn test_drain_warm_one_entry() {
1507        let mut cache = ContentAddressedCacheV2::new(Cac2CacheConfig {
1508            hot_capacity: 100,
1509            warm_capacity: 100,
1510            bloom_size: 256,
1511            hot_ttl_secs: 0,
1512            warm_ttl_secs: 0,
1513            admission_threshold: 1000,
1514        });
1515        let cid = make_cid(140);
1516        cache.insert(cid, b"only".to_vec());
1517        let drained = cache.drain_warm_to_disk_simulation();
1518        // 25% of 1 = 0.25, ceil = 1
1519        assert_eq!(drained.len(), 1);
1520        assert!(cache.warm.is_empty());
1521    }
1522
1523    #[test]
1524    fn test_cid_equality_and_inequality() {
1525        let a = make_cid(0);
1526        let b = make_cid(0);
1527        let c = make_cid(1);
1528        assert_eq!(a, b);
1529        assert_ne!(a, c);
1530    }
1531
1532    #[test]
1533    fn test_tier_enum_debug() {
1534        let t = Cac2Tier::Hot;
1535        assert!(format!("{t:?}").contains("Hot"));
1536    }
1537
1538    #[test]
1539    fn test_eviction_reason_debug() {
1540        let r = Cac2EvictionReason::CapacityPressure;
1541        assert!(format!("{r:?}").contains("CapacityPressure"));
1542    }
1543}