agnosai 1.1.0

Provider-agnostic AI orchestration framework
Documentation
//! Prioritized experience replay buffer.

use rand::RngExt;

/// A single experience tuple stored in the replay buffer.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Experience {
    /// State before the action was taken.
    pub state: String,
    /// Action that was taken.
    pub action: String,
    /// Reward received after the action.
    pub reward: f64,
    /// State after the action was taken.
    pub next_state: String,
    /// Sampling priority (higher = more likely to be replayed).
    pub priority: f64,
}

impl Experience {
    /// Create a new experience tuple.
    pub fn new(
        state: impl Into<String>,
        action: impl Into<String>,
        reward: f64,
        next_state: impl Into<String>,
        priority: f64,
    ) -> Self {
        Self {
            state: state.into(),
            action: action.into(),
            reward,
            next_state: next_state.into(),
            priority,
        }
    }
}

/// Fixed-capacity replay buffer that evicts lowest-priority experiences when full.
#[non_exhaustive]
pub struct ReplayBuffer {
    experiences: Vec<Experience>,
    max_size: usize,
}

impl ReplayBuffer {
    /// Create a new replay buffer with the given maximum capacity.
    pub fn new(max_size: usize) -> Self {
        Self {
            experiences: Vec::with_capacity(max_size),
            max_size,
        }
    }

    /// Push an experience. If at capacity, evicts the experience with the lowest priority.
    pub fn push(&mut self, exp: Experience) {
        if self.experiences.len() >= self.max_size {
            // Find the index of the lowest-priority experience.
            if let Some((min_idx, min_exp)) = self.experiences.iter().enumerate().min_by(|a, b| {
                a.1.priority
                    .partial_cmp(&b.1.priority)
                    .unwrap_or(std::cmp::Ordering::Equal)
            }) {
                // Only evict if the new experience has higher priority.
                if exp.priority > min_exp.priority {
                    self.experiences.swap_remove(min_idx);
                    self.experiences.push(exp);
                }
            }
        } else {
            self.experiences.push(exp);
        }
    }

    /// Sample a batch of experiences, weighted by priority.
    /// Returns up to `batch_size` references (may return fewer if buffer is smaller).
    #[must_use]
    pub fn sample(&self, batch_size: usize) -> Vec<&Experience> {
        if self.experiences.is_empty() {
            return Vec::new();
        }

        let n = batch_size.min(self.experiences.len());
        let mut rng = rand::rng();
        let mut result = Vec::with_capacity(n);
        let mut selected = vec![false; self.experiences.len()];

        for _ in 0..n {
            // Recompute remaining priority each round to avoid bias.
            let remaining_priority: f64 = self
                .experiences
                .iter()
                .enumerate()
                .filter(|(i, _)| !selected[*i])
                .map(|(_, e)| e.priority.abs())
                .sum();

            if remaining_priority <= 0.0 || remaining_priority.is_nan() {
                // Fall back: fill from unselected in order.
                for (i, exp) in self.experiences.iter().enumerate() {
                    if result.len() >= n {
                        break;
                    }
                    if !selected[i] {
                        selected[i] = true;
                        result.push(exp);
                    }
                }
                break;
            }

            let mut r = rng.random_range(0.0..1.0_f64) * remaining_priority;
            let mut chosen = 0;
            for (i, exp) in self.experiences.iter().enumerate() {
                if selected[i] {
                    continue;
                }
                r -= exp.priority.abs();
                if r <= 0.0 {
                    chosen = i;
                    break;
                }
                chosen = i;
            }
            selected[chosen] = true;
            result.push(&self.experiences[chosen]);
        }

        result
    }

    /// Number of experiences currently in the buffer.
    #[must_use]
    pub fn len(&self) -> usize {
        self.experiences.len()
    }

    /// Whether the buffer contains no experiences.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.experiences.is_empty()
    }

    /// Update the priority of an experience at the given index.
    pub fn update_priority(&mut self, index: usize, new_priority: f64) {
        if let Some(exp) = self.experiences.get_mut(index) {
            exp.priority = new_priority;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_exp(state: &str, priority: f64) -> Experience {
        Experience {
            state: state.to_string(),
            action: "act".to_string(),
            reward: 1.0,
            next_state: "next".to_string(),
            priority,
        }
    }

    #[test]
    fn push_and_sample() {
        let mut buf = ReplayBuffer::new(10);
        buf.push(make_exp("s1", 1.0));
        buf.push(make_exp("s2", 2.0));
        buf.push(make_exp("s3", 3.0));

        assert_eq!(buf.len(), 3);
        assert!(!buf.is_empty());

        let batch = buf.sample(2);
        assert_eq!(batch.len(), 2);
    }

    #[test]
    fn max_size_eviction() {
        let mut buf = ReplayBuffer::new(3);
        buf.push(make_exp("s1", 1.0));
        buf.push(make_exp("s2", 2.0));
        buf.push(make_exp("s3", 3.0));
        assert_eq!(buf.len(), 3);

        // Push higher priority — should evict lowest (s1, priority 1.0).
        buf.push(make_exp("s4", 5.0));
        assert_eq!(buf.len(), 3);

        // All remaining should have priority >= 2.0.
        for exp in &buf.experiences {
            assert!(exp.priority >= 2.0);
        }
    }

    #[test]
    fn eviction_skips_lower_priority() {
        let mut buf = ReplayBuffer::new(2);
        buf.push(make_exp("s1", 5.0));
        buf.push(make_exp("s2", 3.0));

        // Try to push a very low priority experience — should not evict anything.
        buf.push(make_exp("s3", 1.0));
        assert_eq!(buf.len(), 2);
        // s3 should not be in the buffer.
        assert!(buf.experiences.iter().all(|e| e.state != "s3"));
    }

    #[test]
    fn priority_ordering_in_sample() {
        let mut buf = ReplayBuffer::new(100);
        // Push one very high priority and many low-priority experiences.
        buf.push(make_exp("high", 100.0));
        for i in 0..20 {
            buf.push(make_exp(&format!("low-{i}"), 0.01));
        }

        // Sample many times — the high-priority experience should appear frequently.
        let mut high_count = 0;
        for _ in 0..50 {
            let batch = buf.sample(5);
            if batch.iter().any(|e| e.state == "high") {
                high_count += 1;
            }
        }
        // Should appear in the vast majority of samples.
        assert!(
            high_count > 30,
            "high-priority item appeared {high_count}/50 times"
        );
    }

    #[test]
    fn update_priority() {
        let mut buf = ReplayBuffer::new(10);
        buf.push(make_exp("s1", 1.0));
        buf.update_priority(0, 99.0);
        assert!((buf.experiences[0].priority - 99.0).abs() < 1e-9);
    }

    #[test]
    fn empty_buffer() {
        let buf = ReplayBuffer::new(10);
        assert!(buf.is_empty());
        assert_eq!(buf.len(), 0);
        assert!(buf.sample(5).is_empty());
    }

    #[test]
    fn sample_more_than_buffer() {
        let mut buf = ReplayBuffer::new(10);
        buf.push(make_exp("s1", 1.0));
        buf.push(make_exp("s2", 2.0));

        let batch = buf.sample(10);
        assert_eq!(batch.len(), 2);
    }
}