1use std::collections::HashMap;
39use std::time::Instant;
40
41use thiserror::Error;
42
43#[derive(Debug, Error)]
47pub enum WeightedCacheError {
48 #[error("invalid weight config: {0}")]
50 InvalidConfig(String),
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum CacheMediaType {
60 VideoSegment,
62 AudioSegment,
64 Image,
66 Manifest,
68 Thumbnail,
70 Metadata,
72 Generic,
74}
75
76#[derive(Debug, Clone, Copy)]
84pub struct TypeWeights {
85 pub recency: f64,
88 pub priority: f64,
91 pub size_penalty: f64,
94}
95
96impl TypeWeights {
97 #[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#[derive(Debug, Clone)]
135pub struct WeightConfig {
136 pub default_weights: TypeWeights,
138 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 cfg.set_weights(
150 CacheMediaType::Manifest,
151 TypeWeights {
152 recency: 0.5,
153 priority: 0.45,
154 size_penalty: 0.05,
155 },
156 );
157 cfg.set_weights(
159 CacheMediaType::Thumbnail,
160 TypeWeights {
161 recency: 0.4,
162 priority: 0.4,
163 size_penalty: 0.2,
164 },
165 );
166 cfg.set_weights(
168 CacheMediaType::VideoSegment,
169 TypeWeights {
170 recency: 0.4,
171 priority: 0.25,
172 size_penalty: 0.35,
173 },
174 );
175 cfg.set_weights(
177 CacheMediaType::AudioSegment,
178 TypeWeights {
179 recency: 0.4,
180 priority: 0.3,
181 size_penalty: 0.3,
182 },
183 );
184 cfg.set_weights(
186 CacheMediaType::Image,
187 TypeWeights {
188 recency: 0.35,
189 priority: 0.25,
190 size_penalty: 0.40,
191 },
192 );
193 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 #[must_use]
209 pub fn new() -> Self {
210 Self {
211 default_weights: TypeWeights::default(),
212 overrides: HashMap::new(),
213 }
214 }
215
216 pub fn set_weights(&mut self, media_type: CacheMediaType, weights: TypeWeights) {
218 self.overrides.insert(media_type, weights.normalise());
219 }
220
221 #[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 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
258struct Entry {
261 value: Vec<u8>,
262 media_type: CacheMediaType,
263 priority: u8,
264 last_accessed: Instant,
265 size_bytes: usize,
266}
267
268pub struct WeightedCache {
276 capacity: usize,
278 weights: WeightConfig,
280 entries: HashMap<String, Entry>,
282 hits: u64,
284 misses: u64,
286 evictions: u64,
288}
289
290impl WeightedCache {
291 #[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 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 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 while self.entries.len() > self.capacity {
339 self.evict_one();
340 }
341 }
342
343 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 pub fn remove(&mut self, key: &str) -> Option<Vec<u8>> {
359 self.entries.remove(key).map(|e| e.value)
360 }
361
362 #[must_use]
364 pub fn contains(&self, key: &str) -> bool {
365 self.entries.contains_key(key)
366 }
367
368 #[must_use]
370 pub fn len(&self) -> usize {
371 self.entries.len()
372 }
373
374 #[must_use]
376 pub fn is_empty(&self) -> bool {
377 self.entries.is_empty()
378 }
379
380 #[must_use]
382 pub fn capacity(&self) -> usize {
383 self.capacity
384 }
385
386 #[must_use]
388 pub fn hits(&self) -> u64 {
389 self.hits
390 }
391
392 #[must_use]
394 pub fn misses(&self) -> u64 {
395 self.misses
396 }
397
398 #[must_use]
400 pub fn evictions(&self) -> u64 {
401 self.evictions
402 }
403
404 #[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 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 pub fn clear(&mut self) {
427 self.entries.clear();
428 self.hits = 0;
429 self.misses = 0;
430 self.evictions = 0;
431 }
432
433 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 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 let priority_factor = f64::from(entry.priority) / 255.0;
450
451 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 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 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#[cfg(test)]
498mod tests {
499 use super::*;
500
501 fn default_cache(cap: usize) -> WeightedCache {
502 WeightedCache::new(cap, WeightConfig::default())
503 }
504
505 #[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 #[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 #[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 #[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 #[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 #[test]
554 fn test_high_priority_survives_eviction() {
555 let mut cfg = WeightConfig::new();
556 cfg.set_weights(
558 CacheMediaType::Manifest,
559 TypeWeights {
560 recency: 0.1,
561 priority: 0.85,
562 size_penalty: 0.05,
563 },
564 );
565 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 cache.insert("manifest", vec![0u8; 10], CacheMediaType::Manifest, 255);
578 cache.insert("generic", vec![0u8; 10], CacheMediaType::Generic, 0);
579 cache.insert("third", vec![0u8; 10], CacheMediaType::Generic, 0);
581
582 assert!(
584 cache.contains("manifest"),
585 "Manifest should survive eviction"
586 );
587 }
588
589 #[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 #[test]
601 fn test_remove_absent() {
602 let mut cache = default_cache(4);
603 assert!(cache.remove("nope").is_none());
604 }
605
606 #[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 #[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"); let _ = cache.get("a"); let _ = cache.get("b"); assert!((cache.hit_rate() - 2.0 / 3.0).abs() < 1e-9);
627 }
628
629 #[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 #[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 #[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 #[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 #[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 #[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 cache.insert("c", vec![0], CacheMediaType::Generic, 1);
705 cache.insert("d", vec![0], CacheMediaType::Generic, 1);
707 assert_eq!(cache.evictions(), 2);
708 }
709
710 #[test]
712 fn test_capacity_getter() {
713 let cache = default_cache(42);
714 assert_eq!(cache.capacity(), 42);
715 }
716
717 #[test]
719 fn test_default_fallback_weights() {
720 let cfg = WeightConfig::new(); 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}