bubbles/saliency/blrv.rs
1//! [`BestLeastRecentlyViewed`] - favour content not shown for the longest time.
2
3use std::collections::HashMap;
4
5use super::candidate::{Candidate, SaliencyStrategy};
6
7/// Picks the available candidate that was least recently selected.
8///
9/// Candidates that have never been shown are treated as if they were last shown at turn 0,
10/// which gives them priority over candidates that have already been shown. Among candidates
11/// with equal "last seen" turns the one with the lower index wins.
12///
13/// This strategy is ideal for NPC barks and variation lines where you want maximum
14/// variety before repeating content.
15///
16/// # Example
17///
18/// ```rust
19/// use bubbles::saliency::{BestLeastRecentlyViewed, Candidate, SaliencyStrategy};
20///
21/// let mut s = BestLeastRecentlyViewed::default();
22/// let candidates = vec![
23/// Candidate { id: "a", available: true },
24/// Candidate { id: "b", available: true },
25/// Candidate { id: "c", available: true },
26/// ];
27///
28/// // First call - all unseen, picks index 0.
29/// assert_eq!(s.select(&candidates), Some(0));
30/// // Second call - "a" was just seen, picks "b" at index 1.
31/// assert_eq!(s.select(&candidates), Some(1));
32/// // Third call - picks "c" at index 2.
33/// assert_eq!(s.select(&candidates), Some(2));
34/// // Fourth call - all seen, wraps back to "a" (oldest).
35/// assert_eq!(s.select(&candidates), Some(0));
36/// ```
37#[derive(Debug, Clone, Default)]
38pub struct BestLeastRecentlyViewed {
39 /// Maps candidate id → the turn on which it was last selected.
40 last_seen: HashMap<String, u64>,
41 /// Monotonically increasing counter incremented on every selection.
42 turn: u64,
43}
44
45impl BestLeastRecentlyViewed {
46 /// Creates a fresh strategy with no history.
47 #[must_use]
48 pub fn new() -> Self {
49 Self::default()
50 }
51}
52
53impl SaliencyStrategy for BestLeastRecentlyViewed {
54 fn select(&mut self, candidates: &[Candidate<'_>]) -> Option<usize> {
55 let idx = candidates
56 .iter()
57 .enumerate()
58 .filter(|(_, c)| c.available)
59 .min_by_key(|(_, c)| self.last_seen.get(c.id).copied().unwrap_or(0))
60 .map(|(i, _)| i)?;
61
62 self.turn += 1;
63 self.last_seen
64 .insert(candidates[idx].id.to_owned(), self.turn);
65 Some(idx)
66 }
67}