Skip to main content

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}