Skip to main content

thrust_rl/buffer/replay/
continuous_sampling.rs

1//! Uniform random sampling and tensor conversion for the continuous-action
2//! replay buffer.
3//!
4//! `sample` draws `batch_size` independent uniform indices from the filled
5//! portion of a [`super::continuous_storage::ContinuousReplayBuffer`] and
6//! copies the transitions into flat CPU-side vectors.
7//! [`ContinuousReplayBatch::to_burn_tensors`] then stacks those vectors into
8//! Burn tensors on the requested device so the trainer can run actor/critic
9//! forward passes.
10//!
11//! This mirrors [`super::sampling`] but with continuous `f32` actions of
12//! width `action_dim` (shape `[batch, action_dim]`) rather than discrete
13//! `i64` actions.
14
15use burn::tensor::{Tensor as BurnTensor, TensorData, backend::Backend};
16use rand::Rng;
17
18use super::continuous_storage::ContinuousReplayBuffer;
19
20/// One minibatch sampled from the continuous replay buffer.
21///
22/// All fields are CPU-side primitive vectors; convert to Burn tensors via
23/// [`ContinuousReplayBatch::to_burn_tensors`] when handing them to the
24/// trainer.
25#[derive(Debug, Clone)]
26pub struct ContinuousReplayBatch {
27    /// Flattened current observations, shape `[batch_size * obs_dim]`.
28    pub observations: Vec<f32>,
29    /// Flattened actions taken, shape `[batch_size * action_dim]`.
30    pub actions: Vec<f32>,
31    /// Rewards received, length `batch_size`.
32    pub rewards: Vec<f32>,
33    /// Flattened next observations, shape `[batch_size * obs_dim]`.
34    pub next_observations: Vec<f32>,
35    /// Episode-end mask, length `batch_size`. `true` means the transition
36    /// terminated the episode (so the TD target drops the bootstrap term).
37    pub dones: Vec<bool>,
38    /// Length of one observation slice (so `to_burn_tensors` can reshape).
39    pub obs_dim: usize,
40    /// Length of one action slice (so `to_burn_tensors` can reshape).
41    pub action_dim: usize,
42}
43
44impl ContinuousReplayBatch {
45    /// Number of transitions in the batch.
46    pub fn len(&self) -> usize {
47        self.rewards.len()
48    }
49
50    /// `true` if the batch is empty.
51    pub fn is_empty(&self) -> bool {
52        self.rewards.is_empty()
53    }
54
55    /// Stack the batch into Burn tensors on `device`.
56    ///
57    /// Returns a named [`ContinuousReplayBurnTensors`] struct so trainers can
58    /// pattern-match named fields rather than positional tuple elements.
59    ///
60    /// Shapes (all on `device`):
61    /// - `observations`: `[batch, obs_dim]`, `f32`
62    /// - `actions`: `[batch, action_dim]`, `f32`
63    /// - `rewards`: `[batch]`, `f32`
64    /// - `next_observations`: `[batch, obs_dim]`, `f32`
65    /// - `dones`: `[batch]`, `f32` (0.0 / 1.0; the TD-target formula `(1 -
66    ///   done)` consumes it directly as a float mask).
67    pub fn to_burn_tensors<B: Backend>(
68        &self,
69        device: &B::Device,
70    ) -> ContinuousReplayBurnTensors<B> {
71        let batch = self.len();
72        let obs_dim = self.obs_dim;
73        let action_dim = self.action_dim;
74
75        // Direct rank-2 construction (vs. rank-1 + reshape) to keep the
76        // empty-batch case panic-free; the reshape path trips an internal
77        // shape assertion in cubecl-zspace when both dims are zero.
78        let observations = BurnTensor::<B, 2>::from_data(
79            TensorData::new(self.observations.clone(), [batch, obs_dim]),
80            device,
81        );
82        let next_observations = BurnTensor::<B, 2>::from_data(
83            TensorData::new(self.next_observations.clone(), [batch, obs_dim]),
84            device,
85        );
86        let actions = BurnTensor::<B, 2>::from_data(
87            TensorData::new(self.actions.clone(), [batch, action_dim]),
88            device,
89        );
90        let rewards =
91            BurnTensor::<B, 1>::from_data(TensorData::new(self.rewards.clone(), [batch]), device);
92        let dones_f: Vec<f32> = self.dones.iter().map(|&d| if d { 1.0 } else { 0.0 }).collect();
93        let dones = BurnTensor::<B, 1>::from_data(TensorData::new(dones_f, [batch]), device);
94
95        ContinuousReplayBurnTensors { observations, actions, rewards, next_observations, dones }
96    }
97}
98
99/// Bundle of Burn tensors produced by
100/// [`ContinuousReplayBatch::to_burn_tensors`].
101///
102/// Fields are in the order off-policy continuous-control trainers consume
103/// them: state and action first, then the reward signal, then the bootstrap
104/// state and terminal mask used to build the TD target. Fields are named so
105/// downstream patches that add fields stay source-compatible.
106#[derive(Debug)]
107pub struct ContinuousReplayBurnTensors<B: Backend> {
108    /// Observations, shape `[batch, obs_dim]`, dtype `f32`.
109    pub observations: BurnTensor<B, 2>,
110    /// Continuous actions, shape `[batch, action_dim]`, dtype `f32`.
111    pub actions: BurnTensor<B, 2>,
112    /// Rewards, shape `[batch]`, dtype `f32`.
113    pub rewards: BurnTensor<B, 1>,
114    /// Bootstrap-state observations, shape `[batch, obs_dim]`, dtype `f32`.
115    pub next_observations: BurnTensor<B, 2>,
116    /// Episode-terminal mask (0.0 or 1.0), shape `[batch]`, dtype `f32`.
117    /// Kept as `f32` so the TD-target formula `(1 - done)` works directly.
118    pub dones: BurnTensor<B, 1>,
119}
120
121/// Sample `batch_size` transitions uniformly with replacement from the
122/// filled portion of `buffer`.
123///
124/// Uniform with-replacement matches the canonical off-policy recipe and is
125/// what makes the sampler O(1) per draw. For small batches relative to
126/// buffer size the difference vs without-replacement is negligible.
127///
128/// # Panics
129/// Panics if `buffer.is_empty()` or `batch_size == 0`.
130pub fn sample<R: Rng>(
131    buffer: &ContinuousReplayBuffer,
132    batch_size: usize,
133    rng: &mut R,
134) -> ContinuousReplayBatch {
135    assert!(!buffer.is_empty(), "ContinuousReplayBuffer is empty; cannot sample");
136    assert!(batch_size > 0, "batch_size must be > 0");
137
138    let obs_dim = buffer.obs_dim();
139    let action_dim = buffer.action_dim();
140    let len = buffer.len();
141
142    let mut observations = vec![0.0f32; batch_size * obs_dim];
143    let mut next_observations = vec![0.0f32; batch_size * obs_dim];
144    let mut actions = vec![0.0f32; batch_size * action_dim];
145    let mut rewards = Vec::with_capacity(batch_size);
146    let mut dones = Vec::with_capacity(batch_size);
147
148    for k in 0..batch_size {
149        let idx = rng.random_range(0..len);
150        let obs_slice = &mut observations[k * obs_dim..(k + 1) * obs_dim];
151        let next_slice = &mut next_observations[k * obs_dim..(k + 1) * obs_dim];
152        let action_slice = &mut actions[k * action_dim..(k + 1) * action_dim];
153        let (r, d) = buffer.read_into(idx, obs_slice, action_slice, next_slice);
154        rewards.push(r);
155        dones.push(d);
156    }
157
158    ContinuousReplayBatch {
159        observations,
160        actions,
161        rewards,
162        next_observations,
163        dones,
164        obs_dim,
165        action_dim,
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use rand::{SeedableRng, rngs::StdRng};
172
173    use super::*;
174
175    #[test]
176    fn test_sample_returns_correct_count() {
177        let mut buf = ContinuousReplayBuffer::new(16, 3, 2);
178        for i in 0..10 {
179            buf.push(
180                &[i as f32, i as f32 + 0.1, i as f32 + 0.2],
181                &[i as f32, -(i as f32)],
182                i as f32,
183                &[(i + 1) as f32, (i + 1) as f32 + 0.1, (i + 1) as f32 + 0.2],
184                false,
185            );
186        }
187        let mut rng = StdRng::seed_from_u64(42);
188        let batch = sample(&buf, 5, &mut rng);
189        assert_eq!(batch.len(), 5);
190        assert_eq!(batch.rewards.len(), 5);
191        assert_eq!(batch.dones.len(), 5);
192        assert_eq!(batch.observations.len(), 5 * 3);
193        assert_eq!(batch.next_observations.len(), 5 * 3);
194        assert_eq!(batch.actions.len(), 5 * 2);
195        assert_eq!(batch.obs_dim, 3);
196        assert_eq!(batch.action_dim, 2);
197    }
198
199    #[test]
200    fn test_sampled_values_match_pushed_values() {
201        // Push a single transition; every sample must return it.
202        let mut buf = ContinuousReplayBuffer::new(8, 2, 3);
203        buf.push(&[7.0, 8.0], &[0.1, 0.2, 0.3], 42.0, &[9.0, 10.0], true);
204
205        let mut rng = StdRng::seed_from_u64(0);
206        let batch = sample(&buf, 4, &mut rng);
207        for k in 0..4 {
208            assert_eq!(batch.rewards[k], 42.0);
209            assert!(batch.dones[k]);
210            assert_eq!(&batch.observations[k * 2..(k + 1) * 2], &[7.0, 8.0]);
211            assert_eq!(&batch.next_observations[k * 2..(k + 1) * 2], &[9.0, 10.0]);
212            assert_eq!(&batch.actions[k * 3..(k + 1) * 3], &[0.1, 0.2, 0.3]);
213        }
214    }
215
216    mod burn_tests {
217        use burn::backend::NdArray;
218
219        use super::*;
220
221        type B = NdArray<f32>;
222
223        #[test]
224        fn test_to_burn_tensors_shapes_and_roundtrip() {
225            let mut buf = ContinuousReplayBuffer::new(8, 4, 2);
226            for i in 0..6 {
227                buf.push(
228                    &[i as f32; 4],
229                    &[i as f32, i as f32 + 0.5],
230                    i as f32,
231                    &[i as f32 + 1.0; 4],
232                    i == 5,
233                );
234            }
235            let mut rng = StdRng::seed_from_u64(1);
236            let batch = sample(&buf, 3, &mut rng);
237            let device = crate::utils::cuda::default_burn_device::<B>();
238            let t = batch.to_burn_tensors::<B>(&device);
239
240            // Shapes.
241            assert_eq!(t.observations.dims(), [3, 4]);
242            assert_eq!(t.next_observations.dims(), [3, 4]);
243            assert_eq!(t.actions.dims(), [3, 2]);
244            assert_eq!(t.rewards.dims(), [3]);
245            assert_eq!(t.dones.dims(), [3]);
246
247            // Round-trip: copying out the host data should match the
248            // CPU-side `Vec`s the batch was built from.
249            let obs_flat: Vec<f32> = t.observations.into_data().to_vec().unwrap();
250            assert_eq!(obs_flat, batch.observations);
251            let next_flat: Vec<f32> = t.next_observations.into_data().to_vec().unwrap();
252            assert_eq!(next_flat, batch.next_observations);
253            let acts: Vec<f32> = t.actions.into_data().to_vec().unwrap();
254            assert_eq!(acts, batch.actions);
255            let rews: Vec<f32> = t.rewards.into_data().to_vec().unwrap();
256            assert_eq!(rews, batch.rewards);
257            let dones_f: Vec<f32> = t.dones.into_data().to_vec().unwrap();
258            let expected_dones: Vec<f32> =
259                batch.dones.iter().map(|&d| if d { 1.0 } else { 0.0 }).collect();
260            assert_eq!(dones_f, expected_dones);
261        }
262
263        #[test]
264        fn test_to_burn_tensors_empty_batch_does_not_panic() {
265            // A 0-row batch must produce well-formed zero-element tensors.
266            // The buffer-side `sample` API doesn't accept batch_size == 0,
267            // so build the batch by hand to exercise the edge case.
268            let batch = ContinuousReplayBatch {
269                observations: vec![],
270                actions: vec![],
271                rewards: vec![],
272                next_observations: vec![],
273                dones: vec![],
274                obs_dim: 4,
275                action_dim: 2,
276            };
277            let device = crate::utils::cuda::default_burn_device::<B>();
278            let t = batch.to_burn_tensors::<B>(&device);
279            assert_eq!(t.observations.dims(), [0, 4]);
280            assert_eq!(t.next_observations.dims(), [0, 4]);
281            assert_eq!(t.actions.dims(), [0, 2]);
282            assert_eq!(t.rewards.dims(), [0]);
283            assert_eq!(t.dones.dims(), [0]);
284        }
285    }
286
287    #[test]
288    #[should_panic(expected = "ContinuousReplayBuffer is empty")]
289    fn test_sample_empty_panics() {
290        let buf = ContinuousReplayBuffer::new(4, 2, 1);
291        let mut rng = StdRng::seed_from_u64(0);
292        let _ = sample(&buf, 2, &mut rng);
293    }
294
295    #[test]
296    #[should_panic(expected = "batch_size must be > 0")]
297    fn test_zero_batch_size_panics() {
298        let mut buf = ContinuousReplayBuffer::new(4, 2, 1);
299        buf.push(&[0.0, 0.0], &[0.0], 0.0, &[0.0, 0.0], false);
300        let mut rng = StdRng::seed_from_u64(0);
301        let _ = sample(&buf, 0, &mut rng);
302    }
303}