Skip to main content

thrust_rl/buffer/replay/
continuous_storage.rs

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