Skip to main content

oximedia_cache/
weighted_cache.rs

1//! Weighted cache scoring with configurable per-media-type weight factors.
2//!
3//! Standard LRU eviction treats all entries equally. For multimedia workloads
4//! different content types benefit from different retention policies. This
5//! module provides [`WeightConfig`] — a table of per-type weights (recency,
6//! size-efficiency, priority) — and [`WeightedCache`], an LRU-ordered cache
7//! that uses the blended score to decide which entry to evict next.
8//!
9//! # Scoring formula
10//!
11//! For each candidate entry the eviction score is:
12//!
13//! ```text
14//! score = recency_weight * recency_factor
15//!       + priority_weight * priority_factor
16//!       - size_weight * size_factor
17//! ```
18//!
19//! where
20//!
21//! - `recency_factor` is `1.0 - (age_ns / max_age_ns)` clamped to `[0, 1]`
22//! - `priority_factor` is the entry's priority normalised to `[0, 1]`
23//! - `size_factor` is `entry_bytes / max_entry_bytes` normalised to `[0, 1]`
24//!
25//! The entry with the **lowest** score is the best eviction candidate.
26//!
27//! # Example
28//!
29//! ```rust
30//! use oximedia_cache::weighted_cache::{WeightConfig, WeightedCache, CacheMediaType};
31//!
32//! let weights = WeightConfig::default();
33//! let mut cache = WeightedCache::new(256, weights);
34//! cache.insert("seg-001", vec![0u8; 64], CacheMediaType::VideoSegment, 7);
35//! assert!(cache.get("seg-001").is_some());
36//! ```
37
38use std::collections::HashMap;
39use std::time::Instant;
40
41use thiserror::Error;
42
43// ── Errors ────────────────────────────────────────────────────────────────────
44
45/// Errors produced by [`WeightedCache`] operations.
46#[derive(Debug, Error)]
47pub enum WeightedCacheError {
48    /// The supplied `WeightConfig` failed validation.
49    #[error("invalid weight config: {0}")]
50    InvalidConfig(String),
51}
52
53// ── CacheMediaType ────────────────────────────────────────────────────────────
54
55/// The media type label attached to a cached entry.
56///
57/// Used by [`WeightConfig`] to look up per-type weight overrides.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum CacheMediaType {
60    /// HLS/DASH video segment.
61    VideoSegment,
62    /// Audio-only segment.
63    AudioSegment,
64    /// Still image or frame.
65    Image,
66    /// Streaming manifest / playlist.
67    Manifest,
68    /// Thumbnail preview.
69    Thumbnail,
70    /// Metadata sidecar.
71    Metadata,
72    /// Generic / unclassified entry.
73    Generic,
74}
75
76// ── TypeWeights ───────────────────────────────────────────────────────────────
77
78/// Weight factors for one media type.
79///
80/// Each factor scales the corresponding component of the eviction score.
81/// All weights should be non-negative; they are automatically normalised
82/// so their sum is 1.0 before scoring.
83#[derive(Debug, Clone, Copy)]
84pub struct TypeWeights {
85    /// How much to value recency (freshness) of the entry.  Higher = keep
86    /// recently-accessed entries longer.
87    pub recency: f64,
88    /// How much to value the entry's priority label.  Higher = keep
89    /// high-priority entries longer.
90    pub priority: f64,
91    /// How much to penalise large entries.  Higher = prefer to evict large
92    /// entries first.
93    pub size_penalty: f64,
94}
95
96impl TypeWeights {
97    /// Normalise the three weights so they sum to 1.0.
98    ///
99    /// If all weights are zero the result is `(1/3, 1/3, 1/3)`.
100    #[must_use]
101    fn normalise(self) -> Self {
102        let sum = self.recency + self.priority + self.size_penalty;
103        if sum == 0.0 {
104            let third = 1.0 / 3.0;
105            return Self {
106                recency: third,
107                priority: third,
108                size_penalty: third,
109            };
110        }
111        Self {
112            recency: self.recency / sum,
113            priority: self.priority / sum,
114            size_penalty: self.size_penalty / sum,
115        }
116    }
117}
118
119impl Default for TypeWeights {
120    fn default() -> Self {
121        Self {
122            recency: 0.5,
123            priority: 0.3,
124            size_penalty: 0.2,
125        }
126    }
127}
128
129// ── WeightConfig ──────────────────────────────────────────────────────────────
130
131/// Table of per-media-type weight overrides.
132///
133/// Any type not present in the table falls back to `default_weights`.
134#[derive(Debug, Clone)]
135pub struct WeightConfig {
136    /// Default weights applied when the entry type has no specific override.
137    pub default_weights: TypeWeights,
138    /// Per-type weight overrides.  Inserted values are normalised automatically.
139    overrides: HashMap<CacheMediaType, TypeWeights>,
140}
141
142impl Default for WeightConfig {
143    fn default() -> Self {
144        let mut cfg = Self {
145            default_weights: TypeWeights::default(),
146            overrides: HashMap::new(),
147        };
148        // Manifests: very high recency and priority, size is negligible.
149        cfg.set_weights(
150            CacheMediaType::Manifest,
151            TypeWeights {
152                recency: 0.5,
153                priority: 0.45,
154                size_penalty: 0.05,
155            },
156        );
157        // Thumbnails: moderate priority, low size penalty (they're small).
158        cfg.set_weights(
159            CacheMediaType::Thumbnail,
160            TypeWeights {
161                recency: 0.4,
162                priority: 0.4,
163                size_penalty: 0.2,
164            },
165        );
166        // Video segments: balanced but with a higher size penalty.
167        cfg.set_weights(
168            CacheMediaType::VideoSegment,
169            TypeWeights {
170                recency: 0.4,
171                priority: 0.25,
172                size_penalty: 0.35,
173            },
174        );
175        // Audio segments: similar to video but smaller.
176        cfg.set_weights(
177            CacheMediaType::AudioSegment,
178            TypeWeights {
179                recency: 0.4,
180                priority: 0.3,
181                size_penalty: 0.3,
182            },
183        );
184        // Images: size penalty matters more.
185        cfg.set_weights(
186            CacheMediaType::Image,
187            TypeWeights {
188                recency: 0.35,
189                priority: 0.25,
190                size_penalty: 0.40,
191            },
192        );
193        // Metadata: tiny; keep for a long time.
194        cfg.set_weights(
195            CacheMediaType::Metadata,
196            TypeWeights {
197                recency: 0.5,
198                priority: 0.35,
199                size_penalty: 0.15,
200            },
201        );
202        cfg
203    }
204}
205
206impl WeightConfig {
207    /// Create a blank config (all types use `default_weights`).
208    #[must_use]
209    pub fn new() -> Self {
210        Self {
211            default_weights: TypeWeights::default(),
212            overrides: HashMap::new(),
213        }
214    }
215
216    /// Insert or replace the weight overrides for `media_type`.
217    pub fn set_weights(&mut self, media_type: CacheMediaType, weights: TypeWeights) {
218        self.overrides.insert(media_type, weights.normalise());
219    }
220
221    /// Look up the weights for `media_type`, falling back to `default_weights`.
222    #[must_use]
223    pub fn weights_for(&self, media_type: CacheMediaType) -> TypeWeights {
224        self.overrides
225            .get(&media_type)
226            .copied()
227            .unwrap_or_else(|| self.default_weights.normalise())
228    }
229
230    /// Validate that all weights are finite and non-negative.
231    pub fn validate(&self) -> Result<(), WeightedCacheError> {
232        let check = |w: TypeWeights, label: &str| {
233            if w.recency < 0.0 || !w.recency.is_finite() {
234                return Err(WeightedCacheError::InvalidConfig(format!(
235                    "{label}.recency must be finite and >= 0"
236                )));
237            }
238            if w.priority < 0.0 || !w.priority.is_finite() {
239                return Err(WeightedCacheError::InvalidConfig(format!(
240                    "{label}.priority must be finite and >= 0"
241                )));
242            }
243            if w.size_penalty < 0.0 || !w.size_penalty.is_finite() {
244                return Err(WeightedCacheError::InvalidConfig(format!(
245                    "{label}.size_penalty must be finite and >= 0"
246                )));
247            }
248            Ok(())
249        };
250        check(self.default_weights, "default_weights")?;
251        for (mt, w) in &self.overrides {
252            check(*w, &format!("{mt:?}"))?;
253        }
254        Ok(())
255    }
256}
257
258// ── CacheEntry (internal) ─────────────────────────────────────────────────────
259
260struct Entry {
261    value: Vec<u8>,
262    media_type: CacheMediaType,
263    priority: u8,
264    last_accessed: Instant,
265    size_bytes: usize,
266}
267
268// ── WeightedCache ─────────────────────────────────────────────────────────────
269
270/// Capacity-bounded cache that uses per-media-type weight factors to choose
271/// eviction candidates.
272///
273/// Entries are scored on each eviction pass; the entry with the lowest score
274/// (least worth keeping) is removed first.
275pub struct WeightedCache {
276    /// Maximum number of entries the cache will hold.
277    capacity: usize,
278    /// Weight configuration table.
279    weights: WeightConfig,
280    /// Key → entry storage.
281    entries: HashMap<String, Entry>,
282    /// Hit counter.
283    hits: u64,
284    /// Miss counter.
285    misses: u64,
286    /// Eviction counter.
287    evictions: u64,
288}
289
290impl WeightedCache {
291    /// Create a new `WeightedCache` with the given capacity and weight config.
292    ///
293    /// # Panics
294    ///
295    /// Panics if `capacity` is 0.
296    #[must_use]
297    pub fn new(capacity: usize, weights: WeightConfig) -> Self {
298        assert!(capacity > 0, "WeightedCache: capacity must be > 0");
299        Self {
300            capacity,
301            weights,
302            entries: HashMap::with_capacity(capacity),
303            hits: 0,
304            misses: 0,
305            evictions: 0,
306        }
307    }
308
309    /// Insert `(key, value)` into the cache with the given `media_type` and
310    /// `priority` label (0–255, higher = more important).
311    ///
312    /// If the key already exists it is overwritten.  If the cache is at
313    /// capacity, the lowest-scored entry is evicted first.
314    pub fn insert(
315        &mut self,
316        key: impl Into<String>,
317        value: Vec<u8>,
318        media_type: CacheMediaType,
319        priority: u8,
320    ) {
321        let key = key.into();
322        let size_bytes = value.len();
323        let now = Instant::now();
324
325        // Overwrite existing entry without eviction.
326        self.entries.insert(
327            key,
328            Entry {
329                value,
330                media_type,
331                priority,
332                last_accessed: now,
333                size_bytes,
334            },
335        );
336
337        // Evict if over capacity.
338        while self.entries.len() > self.capacity {
339            self.evict_one();
340        }
341    }
342
343    /// Look up `key` and return a reference to its value.
344    ///
345    /// Records a hit or miss and updates `last_accessed` on hit.
346    pub fn get(&mut self, key: &str) -> Option<&[u8]> {
347        if let Some(entry) = self.entries.get_mut(key) {
348            self.hits += 1;
349            entry.last_accessed = Instant::now();
350            Some(&entry.value)
351        } else {
352            self.misses += 1;
353            None
354        }
355    }
356
357    /// Remove the entry for `key` and return it (if present).
358    pub fn remove(&mut self, key: &str) -> Option<Vec<u8>> {
359        self.entries.remove(key).map(|e| e.value)
360    }
361
362    /// Return `true` when `key` is present.
363    #[must_use]
364    pub fn contains(&self, key: &str) -> bool {
365        self.entries.contains_key(key)
366    }
367
368    /// Number of entries currently stored.
369    #[must_use]
370    pub fn len(&self) -> usize {
371        self.entries.len()
372    }
373
374    /// Return `true` when no entries are stored.
375    #[must_use]
376    pub fn is_empty(&self) -> bool {
377        self.entries.is_empty()
378    }
379
380    /// Maximum number of entries the cache will hold.
381    #[must_use]
382    pub fn capacity(&self) -> usize {
383        self.capacity
384    }
385
386    /// Total cache hits recorded.
387    #[must_use]
388    pub fn hits(&self) -> u64 {
389        self.hits
390    }
391
392    /// Total cache misses recorded.
393    #[must_use]
394    pub fn misses(&self) -> u64 {
395        self.misses
396    }
397
398    /// Total evictions performed.
399    #[must_use]
400    pub fn evictions(&self) -> u64 {
401        self.evictions
402    }
403
404    /// Hit rate as a fraction `[0.0, 1.0]`.
405    #[must_use]
406    pub fn hit_rate(&self) -> f64 {
407        let total = self.hits + self.misses;
408        if total == 0 {
409            0.0
410        } else {
411            self.hits as f64 / total as f64
412        }
413    }
414
415    /// Resize the cache.  If `new_capacity` is smaller than the current entry
416    /// count, entries are evicted until the count fits.
417    pub fn resize(&mut self, new_capacity: usize) {
418        assert!(new_capacity > 0, "WeightedCache: capacity must be > 0");
419        self.capacity = new_capacity;
420        while self.entries.len() > self.capacity {
421            self.evict_one();
422        }
423    }
424
425    /// Clear all entries and reset statistics.
426    pub fn clear(&mut self) {
427        self.entries.clear();
428        self.hits = 0;
429        self.misses = 0;
430        self.evictions = 0;
431    }
432
433    /// Compute the eviction score for an entry.
434    ///
435    /// **Lower** scores → better eviction candidates.
436    fn score(&self, entry: &Entry, max_age_ns: u64, max_size: usize) -> f64 {
437        let w = self.weights.weights_for(entry.media_type);
438
439        // Recency factor: recently accessed entries score higher (harder to evict).
440        let age_ns = entry.last_accessed.elapsed().as_nanos() as f64;
441        let max_age = max_age_ns as f64;
442        let recency_factor = if max_age == 0.0 {
443            1.0
444        } else {
445            (1.0 - (age_ns / max_age)).clamp(0.0, 1.0)
446        };
447
448        // Priority factor: higher priority → harder to evict.
449        let priority_factor = f64::from(entry.priority) / 255.0;
450
451        // Size factor: larger entries → easier to evict (free more space).
452        let size_factor = if max_size == 0 {
453            0.0
454        } else {
455            (entry.size_bytes as f64 / max_size as f64).clamp(0.0, 1.0)
456        };
457
458        w.recency * recency_factor + w.priority * priority_factor - w.size_penalty * size_factor
459    }
460
461    fn evict_one(&mut self) {
462        if self.entries.is_empty() {
463            return;
464        }
465
466        // Compute score context: max age and max size across all entries.
467        let max_age_ns = self
468            .entries
469            .values()
470            .map(|e| e.last_accessed.elapsed().as_nanos() as u64)
471            .max()
472            .unwrap_or(1);
473        let max_size = self
474            .entries
475            .values()
476            .map(|e| e.size_bytes)
477            .max()
478            .unwrap_or(1);
479
480        // Find the key with the minimum score (best eviction candidate).
481        let victim_key = self
482            .entries
483            .iter()
484            .map(|(k, e)| (k.clone(), self.score(e, max_age_ns, max_size)))
485            .min_by(|(_, s1), (_, s2)| s1.partial_cmp(s2).unwrap_or(std::cmp::Ordering::Equal))
486            .map(|(k, _)| k);
487
488        if let Some(key) = victim_key {
489            self.entries.remove(&key);
490            self.evictions += 1;
491        }
492    }
493}
494
495// ── Tests ─────────────────────────────────────────────────────────────────────
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    fn default_cache(cap: usize) -> WeightedCache {
502        WeightedCache::new(cap, WeightConfig::default())
503    }
504
505    // 1. New cache is empty
506    #[test]
507    fn test_new_cache_is_empty() {
508        let cache = default_cache(8);
509        assert!(cache.is_empty());
510        assert_eq!(cache.len(), 0);
511    }
512
513    // 2. insert + get returns value
514    #[test]
515    fn test_insert_and_get() {
516        let mut cache = default_cache(4);
517        cache.insert("key1", vec![1, 2, 3], CacheMediaType::Generic, 5);
518        let val = cache.get("key1").expect("should find key1");
519        assert_eq!(val, &[1u8, 2, 3]);
520    }
521
522    // 3. get on absent key returns None
523    #[test]
524    fn test_get_absent_returns_none() {
525        let mut cache = default_cache(4);
526        assert!(cache.get("absent").is_none());
527    }
528
529    // 4. hit/miss counters
530    #[test]
531    fn test_hit_miss_counters() {
532        let mut cache = default_cache(4);
533        cache.insert("k", vec![0], CacheMediaType::Generic, 1);
534        let _ = cache.get("k");
535        let _ = cache.get("missing");
536        assert_eq!(cache.hits(), 1);
537        assert_eq!(cache.misses(), 1);
538    }
539
540    // 5. capacity is respected — eviction happens
541    #[test]
542    fn test_capacity_eviction() {
543        let mut cache = default_cache(3);
544        cache.insert("a", vec![0; 100], CacheMediaType::VideoSegment, 3);
545        cache.insert("b", vec![0; 100], CacheMediaType::VideoSegment, 3);
546        cache.insert("c", vec![0; 100], CacheMediaType::VideoSegment, 3);
547        cache.insert("d", vec![0; 100], CacheMediaType::VideoSegment, 3);
548        assert_eq!(cache.len(), 3, "cache should still be at capacity");
549        assert!(cache.evictions() > 0);
550    }
551
552    // 6. high-priority Manifest is preferred over low-priority Generic
553    #[test]
554    fn test_high_priority_survives_eviction() {
555        let mut cfg = WeightConfig::new();
556        // Manifest: heavily prioritised, low size penalty
557        cfg.set_weights(
558            CacheMediaType::Manifest,
559            TypeWeights {
560                recency: 0.1,
561                priority: 0.85,
562                size_penalty: 0.05,
563            },
564        );
565        // Generic: low priority weight
566        cfg.set_weights(
567            CacheMediaType::Generic,
568            TypeWeights {
569                recency: 0.5,
570                priority: 0.05,
571                size_penalty: 0.45,
572            },
573        );
574        let mut cache = WeightedCache::new(2, cfg);
575
576        // Insert manifest with priority 255 and generic with priority 0.
577        cache.insert("manifest", vec![0u8; 10], CacheMediaType::Manifest, 255);
578        cache.insert("generic", vec![0u8; 10], CacheMediaType::Generic, 0);
579        // Trigger eviction by inserting a third entry.
580        cache.insert("third", vec![0u8; 10], CacheMediaType::Generic, 0);
581
582        // The manifest should survive because it has the highest priority weight.
583        assert!(
584            cache.contains("manifest"),
585            "Manifest should survive eviction"
586        );
587    }
588
589    // 7. remove() works
590    #[test]
591    fn test_remove() {
592        let mut cache = default_cache(4);
593        cache.insert("k", vec![9], CacheMediaType::Metadata, 1);
594        let removed = cache.remove("k");
595        assert_eq!(removed, Some(vec![9]));
596        assert!(!cache.contains("k"));
597    }
598
599    // 8. remove() on absent key returns None
600    #[test]
601    fn test_remove_absent() {
602        let mut cache = default_cache(4);
603        assert!(cache.remove("nope").is_none());
604    }
605
606    // 9. overwrite existing key
607    #[test]
608    fn test_overwrite_key() {
609        let mut cache = default_cache(4);
610        cache.insert("k", vec![1], CacheMediaType::Generic, 1);
611        cache.insert("k", vec![2, 3], CacheMediaType::Generic, 5);
612        assert_eq!(cache.len(), 1, "overwrite should not duplicate");
613        let val = cache.get("k").expect("should exist");
614        assert_eq!(val, &[2u8, 3]);
615    }
616
617    // 10. hit_rate calculation
618    #[test]
619    fn test_hit_rate() {
620        let mut cache = default_cache(4);
621        cache.insert("a", vec![0], CacheMediaType::Generic, 1);
622        let _ = cache.get("a"); // hit
623        let _ = cache.get("a"); // hit
624        let _ = cache.get("b"); // miss
625                                // 2 hits / 3 total = 0.666…
626        assert!((cache.hit_rate() - 2.0 / 3.0).abs() < 1e-9);
627    }
628
629    // 11. resize() shrinks cache via eviction
630    #[test]
631    fn test_resize_shrinks() {
632        let mut cache = default_cache(5);
633        for i in 0..5u8 {
634            cache.insert(format!("k{i}"), vec![i], CacheMediaType::Generic, i);
635        }
636        assert_eq!(cache.len(), 5);
637        cache.resize(3);
638        assert_eq!(cache.len(), 3);
639    }
640
641    // 12. WeightConfig::validate() catches negative weights
642    #[test]
643    fn test_validate_rejects_negative_weights() {
644        let mut cfg = WeightConfig::new();
645        cfg.default_weights = TypeWeights {
646            recency: -0.1,
647            priority: 0.5,
648            size_penalty: 0.5,
649        };
650        assert!(cfg.validate().is_err());
651    }
652
653    // 13. WeightConfig::set_weights normalises to sum 1.0
654    #[test]
655    fn test_set_weights_normalises() {
656        let mut cfg = WeightConfig::new();
657        cfg.set_weights(
658            CacheMediaType::Image,
659            TypeWeights {
660                recency: 2.0,
661                priority: 2.0,
662                size_penalty: 6.0,
663            },
664        );
665        let w = cfg.weights_for(CacheMediaType::Image);
666        let sum = w.recency + w.priority + w.size_penalty;
667        assert!(
668            (sum - 1.0).abs() < 1e-9,
669            "weights should normalise to 1.0, got {sum}"
670        );
671    }
672
673    // 14. clear() resets everything
674    #[test]
675    fn test_clear() {
676        let mut cache = default_cache(4);
677        cache.insert("x", vec![1], CacheMediaType::Image, 3);
678        let _ = cache.get("x");
679        cache.clear();
680        assert!(cache.is_empty());
681        assert_eq!(cache.hits(), 0);
682        assert_eq!(cache.misses(), 0);
683        assert_eq!(cache.evictions(), 0);
684    }
685
686    // 15. multiple media types coexist
687    #[test]
688    fn test_multiple_media_types() {
689        let mut cache = default_cache(10);
690        cache.insert("m", vec![0; 5], CacheMediaType::Manifest, 10);
691        cache.insert("v", vec![0; 200], CacheMediaType::VideoSegment, 5);
692        cache.insert("t", vec![0; 8], CacheMediaType::Thumbnail, 8);
693        cache.insert("a", vec![0; 50], CacheMediaType::AudioSegment, 4);
694        assert_eq!(cache.len(), 4);
695    }
696
697    // 16. evictions counter tracks total evictions
698    #[test]
699    fn test_evictions_counter() {
700        let mut cache = default_cache(2);
701        cache.insert("a", vec![0], CacheMediaType::Generic, 1);
702        cache.insert("b", vec![0], CacheMediaType::Generic, 1);
703        // c causes first eviction
704        cache.insert("c", vec![0], CacheMediaType::Generic, 1);
705        // d causes second eviction
706        cache.insert("d", vec![0], CacheMediaType::Generic, 1);
707        assert_eq!(cache.evictions(), 2);
708    }
709
710    // 17. capacity() returns configured value
711    #[test]
712    fn test_capacity_getter() {
713        let cache = default_cache(42);
714        assert_eq!(cache.capacity(), 42);
715    }
716
717    // 18. WeightConfig fallback to default for unknown types
718    #[test]
719    fn test_default_fallback_weights() {
720        let cfg = WeightConfig::new(); // no overrides
721        let w = cfg.weights_for(CacheMediaType::VideoSegment);
722        let sum = w.recency + w.priority + w.size_penalty;
723        assert!((sum - 1.0).abs() < 1e-9);
724    }
725}