Skip to main content

oximedia_cache/
content_aware_cache.rs

1//! Media-content-aware caching.
2//!
3//! Standard eviction policies treat all entries equally.  For multimedia
4//! workloads, different content types have very different access patterns
5//! and cost/benefit trade-offs:
6//!
7//! * Manifests are tiny but highly re-fetched → very high priority, short TTL.
8//! * Video segments are large and often sequential → medium priority, long TTL.
9//! * Thumbnails are small and rarely re-fetched but cheap → very long TTL.
10//!
11//! [`ContentAwareCache`] layers this domain knowledge on top of
12//! [`LruCache`] so that eviction candidates are
13//! scored by a combined recency × priority × size-efficiency metric rather
14//! than pure LRU order.
15
16use std::collections::HashMap;
17use std::time::{Duration, Instant};
18
19use crate::lru_cache::LruCache;
20
21// ── MediaContentType ──────────────────────────────────────────────────────────
22
23/// The media type of a cached entry.
24///
25/// The variant determines the default priority, TTL, and eviction score.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum MediaContentType {
28    /// A video segment (e.g. MPEG-DASH chunk or HLS `.ts` file).
29    VideoSegment {
30        /// Encoded bitrate in bits per second.
31        bitrate: u32,
32        /// Codec name (e.g. `"av1"`, `"vp9"`).
33        codec: String,
34    },
35    /// An audio-only segment.
36    AudioSegment {
37        /// Encoded bitrate in bits per second.
38        bitrate: u32,
39    },
40    /// A still image (e.g. JPEG or PNG frame).
41    Image {
42        /// Width in pixels.
43        width: u32,
44        /// Height in pixels.
45        height: u32,
46    },
47    /// A streaming manifest / playlist (e.g. HLS `.m3u8` or DASH `.mpd`).
48    Manifest,
49    /// A thumbnail preview image.
50    Thumbnail,
51    /// Lightweight metadata / sidecar (e.g. `.json` or `.xml` descriptor).
52    Metadata,
53}
54
55// ── ContentCachePriority ──────────────────────────────────────────────────────
56
57/// Numeric priority (higher = more important to keep in cache).
58///
59/// Derived from [`MediaContentType`] via [`ContentCachePriority::for_type`].
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub struct ContentCachePriority(pub u8);
62
63impl ContentCachePriority {
64    /// Compute the priority for the given content type.
65    ///
66    /// | Content type                        | Priority |
67    /// |-------------------------------------|----------|
68    /// | Manifest                            | 10       |
69    /// | Thumbnail                           |  8       |
70    /// | VideoSegment (bitrate ≥ 4 Mbps)     |  7       |
71    /// | VideoSegment (bitrate < 4 Mbps)     |  6       |
72    /// | AudioSegment                        |  5       |
73    /// | Image                               |  4       |
74    /// | Metadata                            |  3       |
75    pub fn for_type(content_type: &MediaContentType) -> Self {
76        let p = match content_type {
77            MediaContentType::Manifest => 10,
78            MediaContentType::Thumbnail => 8,
79            MediaContentType::VideoSegment { bitrate, .. } => {
80                if *bitrate >= 4_000_000 {
81                    7
82                } else {
83                    6
84                }
85            }
86            MediaContentType::AudioSegment { .. } => 5,
87            MediaContentType::Image { .. } => 4,
88            MediaContentType::Metadata => 3,
89        };
90        Self(p)
91    }
92}
93
94// ── CacheEntry ────────────────────────────────────────────────────────────────
95
96/// A single entry held in [`ContentAwareCache`].
97#[derive(Debug, Clone)]
98pub struct CacheEntry {
99    /// The cache key.
100    pub key: String,
101    /// Raw payload bytes.
102    pub data: Vec<u8>,
103    /// Media content type (determines priority and TTL).
104    pub content_type: MediaContentType,
105    /// Wall-clock time at which this entry was first inserted.
106    pub inserted_at: Instant,
107    /// Wall-clock time of the most recent successful lookup.
108    pub last_accessed: Instant,
109    /// Total number of successful lookups since insertion.
110    pub access_count: u32,
111    /// `data.len()` cached to avoid recomputation.
112    pub size_bytes: usize,
113}
114
115impl CacheEntry {
116    /// Create a new entry.
117    fn new(key: String, data: Vec<u8>, content_type: MediaContentType) -> Self {
118        let size = data.len();
119        let now = Instant::now();
120        Self {
121            key,
122            data,
123            content_type,
124            inserted_at: now,
125            last_accessed: now,
126            access_count: 0,
127            size_bytes: size,
128        }
129    }
130
131    /// Compute the eviction score for this entry using default weights.
132    ///
133    /// A higher score means the entry is a *better* eviction candidate (i.e.
134    /// it should be evicted first).
135    ///
136    /// ```text
137    /// score = (1.0 - recency) × (1.0 / priority) × size_factor
138    /// ```
139    ///
140    /// Where:
141    /// * `recency  = e^(-age_secs / 60.0)` — exponential decay over 1 minute.
142    /// * `priority = ContentCachePriority::for_type(content_type).0` cast to f32.
143    /// * `size_factor = size_bytes / 1_048_576.0 + 1.0` — larger entries score
144    ///   higher (give bigger bang for the eviction buck).
145    pub fn score_for_eviction(&self) -> f32 {
146        self.score_for_eviction_weighted(&ScoringWeights::default())
147    }
148
149    /// Compute the eviction score using configurable [`ScoringWeights`].
150    ///
151    /// ```text
152    /// score = (1 - recency).powf(w.recency_exp)
153    ///       × (1 / priority).powf(w.priority_exp)
154    ///       × size_factor.powf(w.size_exp)
155    /// ```
156    ///
157    /// Default weights (`recency_exp = priority_exp = size_exp = 1.0`)
158    /// reproduce the original behaviour exactly.
159    pub fn score_for_eviction_weighted(&self, w: &ScoringWeights) -> f32 {
160        let age_secs = self.last_accessed.elapsed().as_secs_f64();
161        let recency = (-age_secs / 60.0_f64).exp(); // 1.0 when just accessed, → 0 with age
162        let base_priority = ContentCachePriority::for_type(&self.content_type).0 as f64;
163        let multiplier = w.priority_multiplier(&self.content_type);
164        let effective_priority = (base_priority * multiplier).max(0.001);
165        let size_factor = self.size_bytes as f64 / 1_048_576.0 + 1.0;
166        let score = (1.0 - recency).powf(w.recency_exp)
167            * (1.0 / effective_priority).powf(w.priority_exp)
168            * size_factor.powf(w.size_exp);
169        score as f32
170    }
171}
172
173// ── TTL helpers ───────────────────────────────────────────────────────────────
174
175/// Return the recommended TTL for the given content type.
176///
177/// | Content type          | TTL        |
178/// |-----------------------|------------|
179/// | Manifest              | 30 s       |
180/// | VideoSegment          | 300 s (5 min) |
181/// | AudioSegment          | 300 s      |
182/// | Image                 | 3 600 s (1 h) |
183/// | Thumbnail             | 86 400 s (24 h) |
184/// | Metadata              | 600 s (10 min) |
185pub fn ttl_for_type(content_type: &MediaContentType) -> Duration {
186    match content_type {
187        MediaContentType::Manifest => Duration::from_secs(30),
188        MediaContentType::VideoSegment { .. } => Duration::from_secs(300),
189        MediaContentType::AudioSegment { .. } => Duration::from_secs(300),
190        MediaContentType::Image { .. } => Duration::from_secs(3_600),
191        MediaContentType::Thumbnail => Duration::from_secs(86_400),
192        MediaContentType::Metadata => Duration::from_secs(600),
193    }
194}
195
196// ── ScoringWeights ────────────────────────────────────────────────────────────
197
198/// Configurable exponent weights used by `score_for_eviction`.
199///
200/// The eviction score formula is:
201/// ```text
202/// score = (1 - recency).powf(recency_exp)
203///       × (1/priority).powf(priority_exp)
204///       × size_factor.powf(size_exp)
205/// ```
206///
207/// Defaults (`recency_exp = 1.0`, `priority_exp = 1.0`, `size_exp = 1.0`)
208/// reproduce the original behaviour exactly.
209///
210/// `per_type_priority` overrides the `priority` weight multiplier per
211/// [`MediaContentType`] variant; keys are matched by discriminant tag, so
212/// only the variant kind matters (e.g. all `VideoSegment` variants share one
213/// entry).
214#[derive(Debug, Clone)]
215pub struct ScoringWeights {
216    /// Exponent applied to the recency factor `(1 - recency)`.  Higher = aged
217    /// entries are penalised more aggressively.
218    pub recency_exp: f64,
219    /// Exponent applied to `(1 / priority)`.  Higher = low-priority items are
220    /// more strongly preferred for eviction.
221    pub priority_exp: f64,
222    /// Exponent applied to `size_factor`.  Higher = large entries are more
223    /// strongly preferred for eviction.
224    pub size_exp: f64,
225    /// Per-type priority weight override.  When a content type has an entry
226    /// here, its `priority` value is multiplied by this factor before the
227    /// scoring formula is applied.  Values < 1.0 make the type *harder* to
228    /// evict; values > 1.0 make it *easier* to evict.
229    pub per_type_priority: HashMap<String, f64>,
230}
231
232impl Default for ScoringWeights {
233    fn default() -> Self {
234        Self {
235            recency_exp: 1.0,
236            priority_exp: 1.0,
237            size_exp: 1.0,
238            per_type_priority: HashMap::new(),
239        }
240    }
241}
242
243impl ScoringWeights {
244    /// Create default weights that reproduce the original eviction behaviour.
245    pub fn new() -> Self {
246        Self::default()
247    }
248
249    /// Return the per-type priority multiplier for the given content type.
250    fn type_key(content_type: &MediaContentType) -> &'static str {
251        match content_type {
252            MediaContentType::VideoSegment { .. } => "VideoSegment",
253            MediaContentType::AudioSegment { .. } => "AudioSegment",
254            MediaContentType::Image { .. } => "Image",
255            MediaContentType::Manifest => "Manifest",
256            MediaContentType::Thumbnail => "Thumbnail",
257            MediaContentType::Metadata => "Metadata",
258        }
259    }
260
261    /// Look up the priority multiplier for a content type.
262    pub fn priority_multiplier(&self, content_type: &MediaContentType) -> f64 {
263        let key = Self::type_key(content_type);
264        self.per_type_priority.get(key).copied().unwrap_or(1.0)
265    }
266}
267
268// ── ContentAwareCache ─────────────────────────────────────────────────────────
269
270/// A media-content-aware cache that scores eviction candidates by a
271/// recency × priority × size metric rather than pure LRU order.
272///
273/// Internally, it delegates storage to an [`LruCache<String, CacheEntry>`] for
274/// O(1) operations and maintains a live count of bytes.
275pub struct ContentAwareCache {
276    inner: LruCache<String, CacheEntry>,
277    /// Capacity in number of entries.
278    capacity: usize,
279    /// Current total size of all resident entries in bytes.
280    total_bytes: usize,
281    /// Optional byte-level capacity; entries whose cumulative size exceeds
282    /// this trigger an additional content-aware eviction pass.
283    max_bytes: Option<usize>,
284    /// Configurable scoring weights used by `score_for_eviction`.
285    scoring_weights: ScoringWeights,
286}
287
288impl ContentAwareCache {
289    /// Create a new `ContentAwareCache` with an entry-count `capacity`.
290    pub fn new(capacity: usize) -> Self {
291        Self {
292            inner: LruCache::new(capacity),
293            capacity,
294            total_bytes: 0,
295            max_bytes: None,
296            scoring_weights: ScoringWeights::default(),
297        }
298    }
299
300    /// Set an optional byte-level capacity in addition to the entry count cap.
301    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
302        self.max_bytes = Some(max_bytes);
303        self
304    }
305
306    /// Set custom scoring weights used for eviction candidate selection.
307    pub fn with_scoring_weights(mut self, weights: ScoringWeights) -> Self {
308        self.scoring_weights = weights;
309        self
310    }
311
312    /// Update scoring weights at runtime.
313    pub fn set_scoring_weights(&mut self, weights: ScoringWeights) {
314        self.scoring_weights = weights;
315    }
316
317    /// Return a reference to the current scoring weights.
318    pub fn scoring_weights(&self) -> &ScoringWeights {
319        &self.scoring_weights
320    }
321
322    // ── Insertion ─────────────────────────────────────────────────────────────
323
324    /// Insert a media entry.
325    ///
326    /// If the cache is at capacity, the entry with the highest eviction score
327    /// is removed first.  If the new entry has a higher priority than the
328    /// current worst entry, it displaces it; otherwise the LRU entry is used
329    /// as the fallback.
330    pub fn insert_media(&mut self, key: String, data: Vec<u8>, content_type: MediaContentType) {
331        let size = data.len();
332
333        // If the key already exists, remove the old size first.
334        if let Some(old) = self.inner.remove(&key) {
335            self.total_bytes = self.total_bytes.saturating_sub(old.size_bytes);
336        }
337
338        // Enforce byte-level capacity if configured.
339        if let Some(max_bytes) = self.max_bytes {
340            while self.total_bytes + size > max_bytes && !self.inner.is_empty() {
341                self.evict_worst();
342            }
343        }
344
345        // Enforce entry-count capacity.
346        if self.inner.len() >= self.capacity {
347            self.evict_worst();
348        }
349
350        let entry = CacheEntry::new(key.clone(), data, content_type);
351        self.total_bytes += size;
352        self.inner.insert(key, entry, size);
353    }
354
355    // ── Retrieval ─────────────────────────────────────────────────────────────
356
357    /// Look up `key` and return an immutable reference to its [`CacheEntry`]
358    /// if present (updating last-accessed time).
359    pub fn get(&mut self, key: &str) -> Option<&CacheEntry> {
360        // We need to update last_accessed but LruCache::get only returns &V.
361        // We do a two-step: get (to update LRU order) then update the entry.
362        let key_owned = key.to_string();
363        if self.inner.contains(&key_owned) {
364            // Touch the entry to update `last_accessed` and `access_count`.
365            // We use `peek` to get a reference without a second LRU move, then
366            // perform a targeted update via `insert` (which handles duplicates).
367            let updated_entry = {
368                let entry = self.inner.peek(&key_owned)?;
369                let mut e = entry.clone();
370                e.last_accessed = Instant::now();
371                e.access_count = e.access_count.saturating_add(1);
372                e
373            };
374            let size = updated_entry.size_bytes;
375            // Re-insert to move to MRU head and persist the updated timestamps.
376            // Adjust total_bytes: remove old, add back same size.
377            self.total_bytes = self.total_bytes.saturating_sub(size);
378            self.inner.insert(key_owned.clone(), updated_entry, size);
379            self.total_bytes += size;
380            self.inner.peek(&key_owned)
381        } else {
382            None
383        }
384    }
385
386    /// Peek at `key` without updating access metadata or LRU order.
387    pub fn peek(&self, key: &str) -> Option<&CacheEntry> {
388        self.inner.peek(&key.to_string())
389    }
390
391    // ── Removal ───────────────────────────────────────────────────────────────
392
393    /// Explicitly remove an entry by `key`.
394    ///
395    /// Returns `true` if the entry was present.
396    pub fn remove(&mut self, key: &str) -> bool {
397        if let Some(entry) = self.inner.remove(&key.to_string()) {
398            self.total_bytes = self.total_bytes.saturating_sub(entry.size_bytes);
399            true
400        } else {
401            false
402        }
403    }
404
405    // ── TTL expiry ────────────────────────────────────────────────────────────
406
407    /// Scan all entries and remove those whose TTL has elapsed.
408    ///
409    /// Returns the number of entries evicted.
410    ///
411    /// This is an O(n) operation; callers should invoke it periodically rather
412    /// than on every access.
413    pub fn evict_expired(&mut self) -> usize {
414        // Collect expired keys — we cannot mutate inner while iterating.
415        // We use a drain-based approach: repeatedly pop the LRU until we find a
416        // non-expired entry or exhaust the cache.  This is O(n) worst case but
417        // is acceptable for a maintenance sweep.
418        //
419        // A more efficient approach would require direct iteration over the
420        // backing HashMap; that's not exposed by LruCache, so we collect keys
421        // via `peek` patterns.  Instead we collect entries into a scratch vec.
422        let mut expired_keys: Vec<String> = Vec::new();
423        let mut remaining: Vec<(String, CacheEntry)> = Vec::new();
424
425        // Drain by repeated LRU eviction, noting which entries are expired.
426        while let Some((k, entry)) = self.inner.evict_lru() {
427            let ttl = ttl_for_type(&entry.content_type);
428            if entry.inserted_at.elapsed() > ttl {
429                expired_keys.push(k);
430            } else {
431                remaining.push((k, entry));
432            }
433        }
434
435        // Re-insert non-expired entries.
436        for (k, entry) in remaining {
437            let size = entry.size_bytes;
438            self.inner.insert(k, entry, size);
439        }
440
441        // Recompute total_bytes.
442        self.total_bytes = 0;
443        // We cannot iterate LruCache directly; use a fresh total.
444        // Instead, drain and reinsert again is wasteful; track via expired_keys count.
445        // Correct approach: adjust total_bytes by subtracting expired sizes.
446        // Since we already re-inserted all non-expired entries, recalculate.
447        // Use the stats for a best-effort figure.
448        self.total_bytes = self.inner.stats().total_size_bytes;
449
450        expired_keys.len()
451    }
452
453    // ── Statistics ────────────────────────────────────────────────────────────
454
455    /// Return the number of entries currently in the cache.
456    pub fn len(&self) -> usize {
457        self.inner.len()
458    }
459
460    /// Return `true` when the cache has no entries.
461    pub fn is_empty(&self) -> bool {
462        self.inner.is_empty()
463    }
464
465    /// Return the total number of bytes currently stored.
466    pub fn total_bytes(&self) -> usize {
467        self.total_bytes
468    }
469
470    /// Return the entry-count capacity.
471    pub fn capacity(&self) -> usize {
472        self.capacity
473    }
474
475    // ── Internal eviction ─────────────────────────────────────────────────────
476
477    /// Evict the entry with the highest eviction score (content-aware).
478    ///
479    /// Because [`LruCache`] does not expose iteration, we must drain and
480    /// re-insert to find the worst entry.  This is O(n) per eviction;
481    /// acceptable for moderate cache sizes and infrequent evictions.
482    ///
483    /// If finding the worst entry is too expensive (e.g. very large cache),
484    /// callers can fall back to plain LRU via `inner.evict_lru()` directly.
485    fn evict_worst(&mut self) {
486        if self.inner.is_empty() {
487            return;
488        }
489
490        // Capture the current weights — we cannot borrow self.scoring_weights
491        // and self.inner mutably at the same time.
492        let weights = self.scoring_weights.clone();
493
494        // Drain everything, find the worst, re-insert the rest.
495        let mut entries: Vec<(String, CacheEntry)> = Vec::with_capacity(self.inner.len());
496        while let Some((k, entry)) = self.inner.evict_lru() {
497            entries.push((k, entry));
498        }
499
500        // Find the index of the highest eviction score.
501        let worst_idx = entries
502            .iter()
503            .enumerate()
504            .max_by(|(_, (_, a)), (_, (_, b))| {
505                a.score_for_eviction_weighted(&weights)
506                    .partial_cmp(&b.score_for_eviction_weighted(&weights))
507                    .unwrap_or(std::cmp::Ordering::Equal)
508            })
509            .map(|(i, _)| i)
510            .unwrap_or(0);
511
512        // Remove the worst entry.
513        let (_, evicted) = entries.remove(worst_idx);
514        self.total_bytes = self.total_bytes.saturating_sub(evicted.size_bytes);
515
516        // Re-insert the remaining entries.
517        for (k, entry) in entries {
518            let size = entry.size_bytes;
519            self.inner.insert(k, entry, size);
520        }
521    }
522}
523
524// ── Tests ─────────────────────────────────────────────────────────────────────
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    // ── ContentCachePriority ──────────────────────────────────────────────────
531
532    #[test]
533    fn test_priority_manifest_is_highest() {
534        let p = ContentCachePriority::for_type(&MediaContentType::Manifest);
535        assert_eq!(p.0, 10);
536    }
537
538    #[test]
539    fn test_priority_thumbnail() {
540        let p = ContentCachePriority::for_type(&MediaContentType::Thumbnail);
541        assert_eq!(p.0, 8);
542    }
543
544    #[test]
545    fn test_priority_high_bitrate_video() {
546        let p = ContentCachePriority::for_type(&MediaContentType::VideoSegment {
547            bitrate: 5_000_000,
548            codec: "av1".into(),
549        });
550        assert_eq!(p.0, 7);
551    }
552
553    #[test]
554    fn test_priority_low_bitrate_video() {
555        let p = ContentCachePriority::for_type(&MediaContentType::VideoSegment {
556            bitrate: 1_000_000,
557            codec: "vp9".into(),
558        });
559        assert_eq!(p.0, 6);
560    }
561
562    #[test]
563    fn test_priority_audio() {
564        let p =
565            ContentCachePriority::for_type(&MediaContentType::AudioSegment { bitrate: 128_000 });
566        assert_eq!(p.0, 5);
567    }
568
569    #[test]
570    fn test_priority_image() {
571        let p = ContentCachePriority::for_type(&MediaContentType::Image {
572            width: 1920,
573            height: 1080,
574        });
575        assert_eq!(p.0, 4);
576    }
577
578    #[test]
579    fn test_priority_metadata() {
580        let p = ContentCachePriority::for_type(&MediaContentType::Metadata);
581        assert_eq!(p.0, 3);
582    }
583
584    // ── TTL ───────────────────────────────────────────────────────────────────
585
586    #[test]
587    fn test_ttl_manifest() {
588        assert_eq!(
589            ttl_for_type(&MediaContentType::Manifest),
590            Duration::from_secs(30)
591        );
592    }
593
594    #[test]
595    fn test_ttl_video_segment() {
596        let ct = MediaContentType::VideoSegment {
597            bitrate: 2_000_000,
598            codec: "av1".into(),
599        };
600        assert_eq!(ttl_for_type(&ct), Duration::from_secs(300));
601    }
602
603    #[test]
604    fn test_ttl_thumbnail() {
605        assert_eq!(
606            ttl_for_type(&MediaContentType::Thumbnail),
607            Duration::from_secs(86_400)
608        );
609    }
610
611    #[test]
612    fn test_ttl_image() {
613        let ct = MediaContentType::Image {
614            width: 100,
615            height: 100,
616        };
617        assert_eq!(ttl_for_type(&ct), Duration::from_secs(3_600));
618    }
619
620    // ── ContentAwareCache basic operations ────────────────────────────────────
621
622    #[test]
623    fn test_insert_and_get() {
624        let mut cache = ContentAwareCache::new(16);
625        cache.insert_media(
626            "seg1".into(),
627            vec![0u8; 1024],
628            MediaContentType::VideoSegment {
629                bitrate: 2_000_000,
630                codec: "av1".into(),
631            },
632        );
633        let entry = cache.get("seg1");
634        assert!(entry.is_some());
635        assert_eq!(entry.map(|e| e.size_bytes), Some(1024));
636    }
637
638    #[test]
639    fn test_get_absent_returns_none() {
640        let mut cache = ContentAwareCache::new(8);
641        assert!(cache.get("missing").is_none());
642    }
643
644    #[test]
645    fn test_len_and_is_empty() {
646        let mut cache = ContentAwareCache::new(8);
647        assert!(cache.is_empty());
648        cache.insert_media("m".into(), vec![1, 2], MediaContentType::Manifest);
649        assert_eq!(cache.len(), 1);
650        assert!(!cache.is_empty());
651    }
652
653    #[test]
654    fn test_remove() {
655        let mut cache = ContentAwareCache::new(8);
656        cache.insert_media("key".into(), vec![0u8; 512], MediaContentType::Metadata);
657        assert!(cache.remove("key"));
658        assert!(cache.get("key").is_none());
659    }
660
661    #[test]
662    fn test_remove_absent() {
663        let mut cache = ContentAwareCache::new(8);
664        assert!(!cache.remove("ghost"));
665    }
666
667    #[test]
668    fn test_total_bytes_tracking() {
669        let mut cache = ContentAwareCache::new(16);
670        cache.insert_media("a".into(), vec![0u8; 100], MediaContentType::Manifest);
671        cache.insert_media("b".into(), vec![0u8; 200], MediaContentType::Metadata);
672        assert_eq!(cache.total_bytes(), 300);
673        cache.remove("a");
674        assert_eq!(cache.total_bytes(), 200);
675    }
676
677    #[test]
678    fn test_capacity_reported() {
679        let cache = ContentAwareCache::new(32);
680        assert_eq!(cache.capacity(), 32);
681    }
682
683    // ── Eviction scoring ─────────────────────────────────────────────────────
684
685    #[test]
686    fn test_score_for_eviction_just_inserted_is_low() {
687        let entry = CacheEntry::new("k".into(), vec![0u8; 100], MediaContentType::Manifest);
688        // Just inserted → recency ≈ 1.0 → (1 - 1) * … ≈ 0.
689        let score = entry.score_for_eviction();
690        assert!(
691            score < 0.1,
692            "fresh entry should have low eviction score, got {score}"
693        );
694    }
695
696    #[test]
697    fn test_score_low_priority_higher_than_high_priority() {
698        // An aged metadata entry should score higher than an aged manifest entry
699        // because metadata has lower priority (easier to re-fetch).
700        let manifest_entry =
701            CacheEntry::new("m".into(), vec![0u8; 100], MediaContentType::Manifest);
702        let meta_entry = CacheEntry::new("d".into(), vec![0u8; 100], MediaContentType::Metadata);
703        // Force age by checking the formula with same recency.
704        // priority_manifest = 10, priority_metadata = 3 → 1/3 > 1/10.
705        let p_manifest = ContentCachePriority::for_type(&MediaContentType::Manifest).0 as f32;
706        let p_meta = ContentCachePriority::for_type(&MediaContentType::Metadata).0 as f32;
707        assert!(
708            1.0 / p_meta > 1.0 / p_manifest,
709            "metadata entry should evict before manifest"
710        );
711        drop(manifest_entry);
712        drop(meta_entry);
713    }
714
715    // ── Content-aware eviction when at capacity ───────────────────────────────
716
717    #[test]
718    fn test_eviction_prefers_low_priority_entries() {
719        // Capacity of 2: insert a Manifest + a Metadata.
720        // Then insert a third entry. The Metadata (priority=3) should be evicted
721        // over Manifest (priority=10), all else being equal.
722        let mut cache = ContentAwareCache::new(2);
723        // Insert manifest and metadata with tiny data.
724        cache.insert_media("manifest".into(), vec![0u8; 1], MediaContentType::Manifest);
725        cache.insert_media("meta".into(), vec![0u8; 1], MediaContentType::Metadata);
726        // Let both entries age so the score formula produces non-zero values,
727        // then refresh manifest's last_accessed.  Without this sleep the age
728        // is <1 µs on fast (Linux/CUDA) machines and both scores collapse to
729        // (1 - 1) * … = 0, making eviction order non-deterministic (flaky).
730        std::thread::sleep(std::time::Duration::from_millis(100));
731        // Force the manifest to be "recently used" relative to the aged metadata.
732        let _ = cache.get("manifest");
733        // Insert third entry to trigger eviction.
734        cache.insert_media(
735            "new".into(),
736            vec![0u8; 1],
737            MediaContentType::VideoSegment {
738                bitrate: 2_000_000,
739                codec: "av1".into(),
740            },
741        );
742        assert_eq!(cache.len(), 2);
743        // Manifest should still be present (higher priority).
744        assert!(
745            cache.peek("manifest").is_some(),
746            "manifest should survive eviction"
747        );
748    }
749
750    // ── access_count and last_accessed updates ────────────────────────────────
751
752    #[test]
753    fn test_access_count_increments_on_get() {
754        let mut cache = ContentAwareCache::new(8);
755        cache.insert_media("k".into(), vec![1, 2, 3], MediaContentType::Thumbnail);
756        cache.get("k");
757        cache.get("k");
758        let count = cache.peek("k").map(|e| e.access_count).unwrap_or(0);
759        assert_eq!(count, 2, "access_count should be 2 after two gets");
760    }
761
762    // ── Byte-level capacity ───────────────────────────────────────────────────
763
764    #[test]
765    fn test_max_bytes_triggers_eviction() {
766        let mut cache = ContentAwareCache::new(100).with_max_bytes(500);
767        // Insert 5 × 100-byte entries = 500 bytes (at limit).
768        for i in 0..5u32 {
769            cache.insert_media(
770                format!("seg_{i}"),
771                vec![0u8; 100],
772                MediaContentType::AudioSegment { bitrate: 128_000 },
773            );
774        }
775        assert!(cache.total_bytes() <= 500);
776        // Insert one more → must evict to stay within budget.
777        cache.insert_media("extra".into(), vec![0u8; 100], MediaContentType::Metadata);
778        assert!(
779            cache.total_bytes() <= 500,
780            "total bytes exceeded budget: {}",
781            cache.total_bytes()
782        );
783    }
784
785    // ── Peek does not update metadata ─────────────────────────────────────────
786
787    #[test]
788    fn test_peek_does_not_change_access_count() {
789        let mut cache = ContentAwareCache::new(8);
790        cache.insert_media("p".into(), vec![99], MediaContentType::Manifest);
791        let before = cache.peek("p").map(|e| e.access_count).unwrap_or(99);
792        let _ = cache.peek("p");
793        let after = cache.peek("p").map(|e| e.access_count).unwrap_or(99);
794        assert_eq!(before, after, "peek must not change access_count");
795    }
796
797    // ── Upsert behaviour ─────────────────────────────────────────────────────
798
799    #[test]
800    fn test_insert_same_key_updates_value() {
801        let mut cache = ContentAwareCache::new(8);
802        cache.insert_media("k".into(), vec![1, 2, 3], MediaContentType::Manifest);
803        cache.insert_media("k".into(), vec![10, 20], MediaContentType::Manifest);
804        assert_eq!(cache.len(), 1, "duplicate key should not increase len");
805        assert_eq!(
806            cache.total_bytes(),
807            2,
808            "total_bytes should reflect updated size"
809        );
810    }
811
812    // ── evict_expired ─────────────────────────────────────────────────────────
813
814    #[test]
815    fn test_evict_expired_no_entries() {
816        let mut cache = ContentAwareCache::new(8);
817        assert_eq!(cache.evict_expired(), 0);
818    }
819
820    #[test]
821    fn test_evict_expired_fresh_entries_survive() {
822        let mut cache = ContentAwareCache::new(8);
823        cache.insert_media("fresh".into(), vec![0u8; 10], MediaContentType::Manifest);
824        // Manifest TTL is 30 s; the entry was just inserted → should not expire.
825        let evicted = cache.evict_expired();
826        assert_eq!(evicted, 0);
827        assert!(cache.peek("fresh").is_some());
828    }
829}