Skip to main content

thrust_rl/buffer/replay/
storage.rs

1//! Replay buffer storage for off-policy training
2//!
3//! This module implements a fixed-capacity ring buffer over flat `Vec`s
4//! suitable for vanilla DQN-style replay. Transitions are stored as
5//! plain CPU-side primitive vectors rather than Burn tensors so the
6//! buffer stays WASM-compatible if it's ever exposed there, and so the
7//! same code path can serve `--no-default-features` builds.
8//!
9//! # Layout
10//!
11//! For a buffer of capacity `C` and observation dimension `D`:
12//! - `observations`: `Vec<f32>` of length `C * D` (flattened)
13//! - `actions`: `Vec<i64>` of length `C`
14//! - `rewards`: `Vec<f32>` of length `C`
15//! - `next_observations`: `Vec<f32>` of length `C * D` (flattened)
16//! - `dones`: `Vec<bool>` of length `C`
17//!
18//! Position `i` in the buffer is the slice
19//! `observations[i*D .. (i+1)*D]` paired with `actions[i]`, `rewards[i]`,
20//! `next_observations[i*D .. (i+1)*D]`, and `dones[i]`.
21//!
22//! # Ring semantics
23//!
24//! `push` writes at `write_idx`, increments `write_idx` modulo `capacity`,
25//! and grows `len` up to (but not past) `capacity`. Once the buffer is
26//! full, additional pushes overwrite the oldest transition (FIFO eviction).
27
28/// Fixed-capacity FIFO replay buffer for off-policy training.
29///
30/// Stores `(obs, action, reward, next_obs, done)` transitions and supports
31/// uniform random sampling via `sampling::sample`. The buffer is allocated
32/// once at construction (`vec![0.0; capacity * obs_dim]` etc.); no further
33/// allocations occur during `push`.
34#[derive(Debug, Clone)]
35pub struct ReplayBuffer {
36    obs_dim: usize,
37    capacity: usize,
38    len: usize,
39    write_idx: usize,
40
41    observations: Vec<f32>,
42    actions: Vec<i64>,
43    rewards: Vec<f32>,
44    next_observations: Vec<f32>,
45    dones: Vec<bool>,
46}
47
48impl ReplayBuffer {
49    /// Create a new replay buffer with the given capacity and observation
50    /// dimensionality.
51    ///
52    /// # Arguments
53    /// * `capacity` - Maximum number of transitions stored. Must be > 0.
54    /// * `obs_dim` - Length of one observation vector. Must be > 0.
55    ///
56    /// # Panics
57    /// Panics if `capacity == 0` or `obs_dim == 0`.
58    pub fn new(capacity: usize, obs_dim: usize) -> Self {
59        assert!(capacity > 0, "ReplayBuffer capacity must be > 0");
60        assert!(obs_dim > 0, "ReplayBuffer obs_dim must be > 0");
61
62        Self {
63            obs_dim,
64            capacity,
65            len: 0,
66            write_idx: 0,
67            observations: vec![0.0; capacity * obs_dim],
68            actions: vec![0; capacity],
69            rewards: vec![0.0; capacity],
70            next_observations: vec![0.0; capacity * obs_dim],
71            dones: vec![false; capacity],
72        }
73    }
74
75    /// Push a single transition into the buffer.
76    ///
77    /// When the buffer is at capacity, this overwrites the oldest
78    /// transition (FIFO eviction).
79    ///
80    /// # Arguments
81    /// * `obs` - Current observation; must have length `obs_dim`.
82    /// * `action` - Discrete action taken.
83    /// * `reward` - Reward received.
84    /// * `next_obs` - Resulting observation; must have length `obs_dim`.
85    /// * `done` - `true` if the episode terminated (used to mask the bootstrap
86    ///   term in the TD target).
87    ///
88    /// # Panics
89    /// Panics in debug builds if `obs.len() != obs_dim` or
90    /// `next_obs.len() != obs_dim`.
91    pub fn push(&mut self, obs: &[f32], action: i64, reward: f32, next_obs: &[f32], done: bool) {
92        debug_assert_eq!(
93            obs.len(),
94            self.obs_dim,
95            "ReplayBuffer::push: obs.len() ({}) != obs_dim ({})",
96            obs.len(),
97            self.obs_dim
98        );
99        debug_assert_eq!(
100            next_obs.len(),
101            self.obs_dim,
102            "ReplayBuffer::push: next_obs.len() ({}) != obs_dim ({})",
103            next_obs.len(),
104            self.obs_dim
105        );
106
107        let start = self.write_idx * self.obs_dim;
108        let end = start + self.obs_dim;
109        self.observations[start..end].copy_from_slice(obs);
110        self.next_observations[start..end].copy_from_slice(next_obs);
111        self.actions[self.write_idx] = action;
112        self.rewards[self.write_idx] = reward;
113        self.dones[self.write_idx] = done;
114
115        self.write_idx = (self.write_idx + 1) % self.capacity;
116        if self.len < self.capacity {
117            self.len += 1;
118        }
119    }
120
121    /// Number of transitions currently in the buffer.
122    pub fn len(&self) -> usize {
123        self.len
124    }
125
126    /// `true` if the buffer holds no transitions.
127    pub fn is_empty(&self) -> bool {
128        self.len == 0
129    }
130
131    /// Maximum number of transitions the buffer can hold.
132    pub fn capacity(&self) -> usize {
133        self.capacity
134    }
135
136    /// Observation dimension (length of one obs slice).
137    pub fn obs_dim(&self) -> usize {
138        self.obs_dim
139    }
140
141    /// `true` if the buffer holds at least `min_size` transitions.
142    ///
143    /// Used by the trainer to decide whether to start sampling /
144    /// performing gradient updates: vanilla DQN normally collects a
145    /// few thousand transitions before the first update.
146    pub fn is_ready(&self, min_size: usize) -> bool {
147        self.len >= min_size
148    }
149
150    /// Read the transition at logical index `i` (0..len) into the provided
151    /// scratch slices. Used by [`super::sampling`] to build batches.
152    ///
153    /// # Panics
154    /// Panics if `i >= len`, or if `obs_out`/`next_obs_out` aren't sized
155    /// `obs_dim`.
156    pub(super) fn read_into(
157        &self,
158        i: usize,
159        obs_out: &mut [f32],
160        next_obs_out: &mut [f32],
161    ) -> (i64, f32, bool) {
162        assert!(i < self.len, "ReplayBuffer::read_into: i ({}) >= len ({})", i, self.len);
163        assert_eq!(obs_out.len(), self.obs_dim);
164        assert_eq!(next_obs_out.len(), self.obs_dim);
165
166        let start = i * self.obs_dim;
167        let end = start + self.obs_dim;
168        obs_out.copy_from_slice(&self.observations[start..end]);
169        next_obs_out.copy_from_slice(&self.next_observations[start..end]);
170        (self.actions[i], self.rewards[i], self.dones[i])
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn test_push_and_len() {
180        let mut buf = ReplayBuffer::new(8, 3);
181        assert!(buf.is_empty());
182        assert_eq!(buf.capacity(), 8);
183        assert_eq!(buf.obs_dim(), 3);
184
185        buf.push(&[1.0, 2.0, 3.0], 1, 0.5, &[4.0, 5.0, 6.0], false);
186        assert_eq!(buf.len(), 1);
187        assert!(!buf.is_empty());
188
189        for i in 0..5 {
190            buf.push(&[i as f32; 3], i as i64, i as f32, &[(i + 1) as f32; 3], false);
191        }
192        assert_eq!(buf.len(), 6);
193    }
194
195    #[test]
196    fn test_roundtrip_values() {
197        // Push known values, then read them back through read_into.
198        let mut buf = ReplayBuffer::new(4, 2);
199        buf.push(&[1.0, 2.0], 0, 10.0, &[3.0, 4.0], false);
200        buf.push(&[5.0, 6.0], 1, 20.0, &[7.0, 8.0], true);
201
202        let mut obs = [0.0f32; 2];
203        let mut next_obs = [0.0f32; 2];
204        let (a, r, d) = buf.read_into(0, &mut obs, &mut next_obs);
205        assert_eq!(obs, [1.0, 2.0]);
206        assert_eq!(next_obs, [3.0, 4.0]);
207        assert_eq!(a, 0);
208        assert_eq!(r, 10.0);
209        assert!(!d);
210
211        let (a, r, d) = buf.read_into(1, &mut obs, &mut next_obs);
212        assert_eq!(obs, [5.0, 6.0]);
213        assert_eq!(next_obs, [7.0, 8.0]);
214        assert_eq!(a, 1);
215        assert_eq!(r, 20.0);
216        assert!(d);
217    }
218
219    #[test]
220    fn test_ring_wraparound_evicts_oldest() {
221        // Capacity 4: push 6 → first 2 should be gone, slots 0..4 should
222        // hold transitions 2,3,4,5 (in write order: 4,5,2,3 since write_idx
223        // wrapped).
224        let mut buf = ReplayBuffer::new(4, 1);
225        for i in 0..6 {
226            buf.push(&[i as f32], i as i64, i as f32, &[(i + 100) as f32], false);
227        }
228        assert_eq!(buf.len(), 4, "len must saturate at capacity");
229
230        // Collect all transitions in slot order.
231        let mut found_actions = std::collections::HashSet::new();
232        let mut obs_scratch = [0.0f32; 1];
233        let mut next_scratch = [0.0f32; 1];
234        for i in 0..buf.len() {
235            let (a, _, _) = buf.read_into(i, &mut obs_scratch, &mut next_scratch);
236            found_actions.insert(a);
237        }
238
239        // After 6 pushes into capacity 4, transitions 0 and 1 must be gone
240        // and transitions 2,3,4,5 must remain.
241        assert!(!found_actions.contains(&0), "oldest transition 0 not evicted");
242        assert!(!found_actions.contains(&1), "oldest transition 1 not evicted");
243        assert!(found_actions.contains(&2));
244        assert!(found_actions.contains(&3));
245        assert!(found_actions.contains(&4));
246        assert!(found_actions.contains(&5));
247    }
248
249    #[test]
250    fn test_is_ready() {
251        let mut buf = ReplayBuffer::new(8, 2);
252        assert!(!buf.is_ready(1));
253        for _ in 0..4 {
254            buf.push(&[0.0, 0.0], 0, 0.0, &[0.0, 0.0], false);
255        }
256        assert!(buf.is_ready(4));
257        assert!(!buf.is_ready(5));
258    }
259
260    #[test]
261    #[should_panic(expected = "capacity must be > 0")]
262    fn test_zero_capacity_panics() {
263        let _ = ReplayBuffer::new(0, 4);
264    }
265
266    #[test]
267    #[should_panic(expected = "obs_dim must be > 0")]
268    fn test_zero_obs_dim_panics() {
269        let _ = ReplayBuffer::new(4, 0);
270    }
271}