use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
use super::storage::RolloutBuffer;
#[derive(Debug, Clone)]
pub struct RecurrentRolloutBuffer {
inner: RolloutBuffer,
hidden: Vec<Vec<Vec<f32>>>,
cell: Vec<Vec<Vec<f32>>>,
episode_start_carry: Vec<f32>,
hidden_dim: usize,
}
impl RecurrentRolloutBuffer {
pub fn new(num_steps: usize, num_envs: usize, obs_dim: usize, hidden_dim: usize) -> Self {
let inner = RolloutBuffer::new(num_steps, num_envs, obs_dim);
let hidden = vec![vec![vec![0.0; hidden_dim]; num_envs]; num_steps];
let cell = vec![vec![vec![0.0; hidden_dim]; num_envs]; num_steps];
let episode_start_carry = vec![1.0; num_envs];
Self { inner, hidden, cell, episode_start_carry, hidden_dim }
}
#[allow(clippy::too_many_arguments)]
pub fn add(
&mut self,
step: usize,
env_id: usize,
observation: &[f32],
action: i64,
reward: f32,
value: f32,
log_prob: f32,
terminated: bool,
truncated: bool,
) {
self.inner.add(
step,
env_id,
observation,
action,
reward,
value,
log_prob,
terminated,
truncated,
);
}
pub fn add_recurrent_state(&mut self, step: usize, env_id: usize, h: &[f32], c: &[f32]) {
debug_assert!(step < self.hidden.len(), "step {} out of range", step);
debug_assert!(env_id < self.hidden[step].len(), "env_id {} out of range", env_id);
debug_assert_eq!(h.len(), self.hidden_dim, "hidden state dimension mismatch");
debug_assert_eq!(c.len(), self.hidden_dim, "cell state dimension mismatch");
self.hidden[step][env_id].copy_from_slice(h);
self.cell[step][env_id].copy_from_slice(c);
}
pub fn seed_warm_start(
&mut self,
last_step: usize,
final_hidden: &[Vec<f32>],
final_cell: &[Vec<f32>],
) {
let (num_steps, num_envs, _) = self.inner.shape();
assert!(
last_step < num_steps,
"last_step ({}) must be < num_steps ({})",
last_step,
num_steps
);
assert_eq!(final_hidden.len(), num_envs, "final_hidden must have num_envs rows");
assert_eq!(final_cell.len(), num_envs, "final_cell must have num_envs rows");
let terminated = self.inner.terminated();
let truncated = self.inner.truncated();
for env in 0..num_envs {
let ended = terminated[last_step][env] || truncated[last_step][env];
self.episode_start_carry[env] = if ended { 1.0 } else { 0.0 };
if ended {
self.hidden[0][env].iter_mut().for_each(|x| *x = 0.0);
self.cell[0][env].iter_mut().for_each(|x| *x = 0.0);
} else {
debug_assert_eq!(final_hidden[env].len(), self.hidden_dim, "hidden row width");
debug_assert_eq!(final_cell[env].len(), self.hidden_dim, "cell row width");
self.hidden[0][env].copy_from_slice(&final_hidden[env]);
self.cell[0][env].copy_from_slice(&final_cell[env]);
}
}
}
pub fn reset(&mut self) {
self.inner.reset();
}
pub fn compute_advantages(&mut self, last_values: &[f32], gamma: f32, gae_lambda: f32) {
self.inner.compute_advantages(last_values, gamma, gae_lambda);
}
pub fn compute_advantages_partial(
&mut self,
valid_steps: usize,
last_values: &[f32],
gamma: f32,
gae_lambda: f32,
) {
self.inner
.compute_advantages_partial(valid_steps, last_values, gamma, gae_lambda);
}
pub fn shape(&self) -> (usize, usize, usize) {
self.inner.shape()
}
pub fn hidden_dim(&self) -> usize {
self.hidden_dim
}
pub fn observations(&self) -> &[Vec<Vec<f32>>] {
self.inner.observations()
}
pub fn actions(&self) -> &[Vec<i64>] {
self.inner.actions()
}
pub fn values(&self) -> &[Vec<f32>] {
self.inner.values()
}
pub fn log_probs(&self) -> &[Vec<f32>] {
self.inner.log_probs()
}
pub fn terminated(&self) -> &[Vec<bool>] {
self.inner.terminated()
}
pub fn truncated(&self) -> &[Vec<bool>] {
self.inner.truncated()
}
pub fn advantages(&self) -> &[Vec<f32>] {
self.inner.advantages()
}
pub fn returns(&self) -> &[Vec<f32>] {
self.inner.returns()
}
pub fn hidden(&self) -> &[Vec<Vec<f32>>] {
&self.hidden
}
pub fn cell(&self) -> &[Vec<Vec<f32>>] {
&self.cell
}
pub fn episode_start_carry(&self) -> &[f32] {
&self.episode_start_carry
}
pub fn to_sequence_batch<B: Backend>(&self, device: &B::Device) -> RecurrentRolloutBatch<B> {
let num_steps = self.inner.shape().0;
self.to_sequence_batch_partial::<B>(num_steps, device)
}
pub fn to_sequence_batch_partial<B: Backend>(
&self,
valid_steps: usize,
device: &B::Device,
) -> RecurrentRolloutBatch<B> {
let num_envs = self.inner.shape().1;
let env_ids: Vec<usize> = (0..num_envs).collect();
self.sequence_batch_for_envs::<B>(&env_ids, valid_steps, device)
}
pub fn to_minibatches<'a, B: Backend>(
&'a self,
envs_per_minibatch: usize,
shuffle: bool,
device: &B::Device,
) -> RecurrentMinibatchIterator<'a, B> {
let num_steps = self.inner.shape().0;
RecurrentMinibatchIterator::new(self, envs_per_minibatch, num_steps, shuffle, device)
}
fn sequence_batch_for_envs<B: Backend>(
&self,
env_ids: &[usize],
valid_steps: usize,
device: &B::Device,
) -> RecurrentRolloutBatch<B> {
let (num_steps, _num_envs, obs_dim) = self.inner.shape();
assert!(
valid_steps <= num_steps,
"valid_steps ({}) must not exceed num_steps ({})",
valid_steps,
num_steps
);
let n_env = env_ids.len();
let t = valid_steps;
let observations = self.inner.observations();
let actions_grid = self.inner.actions();
let values_grid = self.inner.values();
let log_probs_grid = self.inner.log_probs();
let terminated_grid = self.inner.terminated();
let truncated_grid = self.inner.truncated();
let advantages_grid = self.inner.advantages();
let returns_grid = self.inner.returns();
let mut obs_flat = Vec::with_capacity(n_env * t * obs_dim);
let mut actions_flat = Vec::with_capacity(n_env * t);
let mut starts_flat = Vec::with_capacity(n_env * t);
let mut log_probs_flat = Vec::with_capacity(n_env * t);
let mut values_flat = Vec::with_capacity(n_env * t);
let mut advantages_flat = Vec::with_capacity(n_env * t);
let mut returns_flat = Vec::with_capacity(n_env * t);
for &env in env_ids {
for step in 0..t {
obs_flat.extend_from_slice(&observations[step][env]);
actions_flat.push(actions_grid[step][env]);
let start = if step == 0 {
self.episode_start_carry[env]
} else {
let prev_done = terminated_grid[step - 1][env] || truncated_grid[step - 1][env];
if prev_done { 1.0_f32 } else { 0.0_f32 }
};
starts_flat.push(start);
log_probs_flat.push(log_probs_grid[step][env]);
values_flat.push(values_grid[step][env]);
advantages_flat.push(advantages_grid[step][env]);
returns_flat.push(returns_grid[step][env]);
}
}
let obs_seq =
Tensor::<B, 3>::from_data(TensorData::new(obs_flat, [n_env, t, obs_dim]), device);
let actions =
Tensor::<B, 2, Int>::from_data(TensorData::new(actions_flat, [n_env, t]), device);
let episode_starts =
Tensor::<B, 2>::from_data(TensorData::new(starts_flat, [n_env, t]), device);
let old_log_probs =
Tensor::<B, 2>::from_data(TensorData::new(log_probs_flat, [n_env, t]), device);
let old_values =
Tensor::<B, 2>::from_data(TensorData::new(values_flat, [n_env, t]), device);
let advantages =
Tensor::<B, 2>::from_data(TensorData::new(advantages_flat, [n_env, t]), device);
let returns = Tensor::<B, 2>::from_data(TensorData::new(returns_flat, [n_env, t]), device);
RecurrentRolloutBatch {
obs_seq,
actions,
episode_starts,
old_log_probs,
old_values,
advantages,
returns,
}
}
}
#[derive(Debug)]
pub struct RecurrentRolloutBatch<B: Backend> {
pub obs_seq: Tensor<B, 3>,
pub actions: Tensor<B, 2, Int>,
pub episode_starts: Tensor<B, 2>,
pub old_log_probs: Tensor<B, 2>,
pub old_values: Tensor<B, 2>,
pub advantages: Tensor<B, 2>,
pub returns: Tensor<B, 2>,
}
impl<B: Backend> RecurrentRolloutBatch<B> {
pub fn num_envs(&self) -> usize {
self.obs_seq.dims()[0]
}
pub fn seq_len(&self) -> usize {
self.obs_seq.dims()[1]
}
}
pub struct RecurrentMinibatchIterator<'a, B: Backend> {
buffer: &'a RecurrentRolloutBuffer,
device: B::Device,
chunks: Vec<Vec<usize>>,
valid_steps: usize,
current: usize,
}
impl<'a, B: Backend> RecurrentMinibatchIterator<'a, B> {
pub fn new(
buffer: &'a RecurrentRolloutBuffer,
envs_per_minibatch: usize,
valid_steps: usize,
shuffle: bool,
device: &B::Device,
) -> Self {
let num_envs = buffer.shape().1;
let mut env_ids: Vec<usize> = (0..num_envs).collect();
if shuffle {
use rand::seq::SliceRandom;
env_ids.shuffle(&mut rand::rng());
}
let chunk_len = envs_per_minibatch.max(1);
let chunks: Vec<Vec<usize>> =
env_ids.chunks(chunk_len).map(|chunk| chunk.to_vec()).collect();
Self { buffer, device: device.clone(), chunks, valid_steps, current: 0 }
}
}
impl<B: Backend> Iterator for RecurrentMinibatchIterator<'_, B> {
type Item = RecurrentRolloutBatch<B>;
fn next(&mut self) -> Option<Self::Item> {
if self.current >= self.chunks.len() {
return None;
}
let env_ids = &self.chunks[self.current];
self.current += 1;
Some(
self.buffer
.sequence_batch_for_envs::<B>(env_ids, self.valid_steps, &self.device),
)
}
}
#[cfg(test)]
mod tests {
use burn::backend::NdArray;
use super::*;
use crate::buffer::rollout::storage::RolloutBuffer;
type B = NdArray<f32>;
fn device() -> <B as burn::tensor::backend::BackendTypes>::Device {
crate::utils::cuda::default_burn_device::<B>()
}
fn fill_buffer(num_steps: usize, num_envs: usize, obs_dim: usize) -> RecurrentRolloutBuffer {
let hidden_dim = 3;
let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
for step in 0..num_steps {
for env in 0..num_envs {
let obs: Vec<f32> =
(0..obs_dim).map(|d| (step * 100 + env * 10 + d) as f32).collect();
buf.add(
step,
env,
&obs,
(step + env) as i64,
step as f32, (env as f32) * 0.5, -(step as f32) * 0.1, false,
false,
);
}
}
buf
}
#[test]
fn test_to_sequence_batch_shapes() {
let buf = fill_buffer(8, 4, 5);
let dev = device();
let batch = buf.to_sequence_batch::<B>(&dev);
assert_eq!(batch.obs_seq.dims(), [4, 8, 5]);
assert_eq!(batch.actions.dims(), [4, 8]);
assert_eq!(batch.episode_starts.dims(), [4, 8]);
assert_eq!(batch.old_log_probs.dims(), [4, 8]);
assert_eq!(batch.old_values.dims(), [4, 8]);
assert_eq!(batch.advantages.dims(), [4, 8]);
assert_eq!(batch.returns.dims(), [4, 8]);
assert_eq!(batch.num_envs(), 4);
assert_eq!(batch.seq_len(), 8);
}
#[test]
fn test_to_sequence_batch_env_major_layout() {
let (num_steps, num_envs, obs_dim) = (3, 2, 4);
let buf = fill_buffer(num_steps, num_envs, obs_dim);
let dev = device();
let batch = buf.to_sequence_batch::<B>(&dev);
let obs: Vec<f32> = batch.obs_seq.into_data().to_vec().unwrap();
for env in 0..num_envs {
for step in 0..num_steps {
for d in 0..obs_dim {
let idx = (env * num_steps + step) * obs_dim + d;
let expected = (step * 100 + env * 10 + d) as f32;
assert_eq!(obs[idx], expected, "env {} step {} dim {}", env, step, d);
}
}
}
}
#[test]
fn test_episode_starts_flag_correctness() {
let (num_steps, num_envs, obs_dim) = (4, 1, 2);
let hidden_dim = 2;
let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
let flags = [(false, false), (true, false), (false, true), (true, true)];
for (step, &(term, trunc)) in flags.iter().enumerate() {
buf.add(step, 0, &[0.0, 0.0], 0, 0.0, 0.0, 0.0, term, trunc);
}
let dev = device();
let batch = buf.to_sequence_batch::<B>(&dev);
let starts: Vec<f32> = batch.episode_starts.into_data().to_vec().unwrap();
assert_eq!(starts, vec![1.0, 0.0, 1.0, 1.0]);
}
#[test]
fn test_gae_input_parity_with_feedforward() {
let (num_steps, num_envs, obs_dim) = (6, 3, 2);
let hidden_dim = 4;
let mut rec = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
let mut ff = RolloutBuffer::new(num_steps, num_envs, obs_dim);
for step in 0..num_steps {
for env in 0..num_envs {
let obs = [step as f32, env as f32];
let action = (step + env) as i64;
let reward = ((step * 2 + env) as f32).sin();
let value = ((step + env) as f32) * 0.3;
let log_prob = -0.1 * (step as f32);
let term = env == 0 && step == 2;
let trunc = env == 1 && step == 4;
rec.add(step, env, &obs, action, reward, value, log_prob, term, trunc);
ff.add(step, env, &obs, action, reward, value, log_prob, term, trunc);
}
}
let last_values = vec![0.7_f32, -0.2, 0.4];
let (gamma, lam, valid) = (0.99_f32, 0.95_f32, num_steps);
rec.compute_advantages_partial(valid, &last_values, gamma, lam);
ff.compute_advantages_partial(valid, &last_values, gamma, lam);
for step in 0..num_steps {
for env in 0..num_envs {
assert!(
(rec.advantages()[step][env] - ff.advantages()[step][env]).abs() < 1e-6,
"advantage mismatch at [{}][{}]",
step,
env
);
assert!(
(rec.returns()[step][env] - ff.returns()[step][env]).abs() < 1e-6,
"return mismatch at [{}][{}]",
step,
env
);
}
}
}
#[test]
fn test_add_recurrent_state_round_trip() {
let (num_steps, num_envs, obs_dim, hidden_dim) = (3, 2, 2, 4);
let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
for step in 0..num_steps {
for env in 0..num_envs {
let h: Vec<f32> =
(0..hidden_dim).map(|k| (step * 1000 + env * 100 + k) as f32).collect();
let c: Vec<f32> =
(0..hidden_dim).map(|k| -((step * 1000 + env * 100 + k) as f32)).collect();
buf.add_recurrent_state(step, env, &h, &c);
}
}
for step in 0..num_steps {
for env in 0..num_envs {
for k in 0..hidden_dim {
let expected = (step * 1000 + env * 100 + k) as f32;
assert_eq!(buf.hidden()[step][env][k], expected);
assert_eq!(buf.cell()[step][env][k], -expected);
}
}
}
}
#[test]
fn test_seed_warm_start_zeros_for_ended_envs() {
let (num_steps, num_envs, obs_dim, hidden_dim) = (4, 3, 2, 3);
let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
let last_step = num_steps - 1;
buf.add(last_step, 0, &[0.0, 0.0], 0, 0.0, 0.0, 0.0, true, false);
buf.add(last_step, 1, &[0.0, 0.0], 0, 0.0, 0.0, 0.0, false, true);
buf.add(last_step, 2, &[0.0, 0.0], 0, 0.0, 0.0, 0.0, false, false);
let final_hidden = vec![vec![1.0, 2.0, 3.0]; num_envs];
let final_cell = vec![vec![-1.0, -2.0, -3.0]; num_envs];
buf.seed_warm_start(last_step, &final_hidden, &final_cell);
assert_eq!(buf.hidden()[0][0], vec![0.0, 0.0, 0.0]);
assert_eq!(buf.cell()[0][0], vec![0.0, 0.0, 0.0]);
assert_eq!(buf.hidden()[0][1], vec![0.0, 0.0, 0.0]);
assert_eq!(buf.cell()[0][1], vec![0.0, 0.0, 0.0]);
assert_eq!(buf.hidden()[0][2], vec![1.0, 2.0, 3.0]);
assert_eq!(buf.cell()[0][2], vec![-1.0, -2.0, -3.0]);
assert_eq!(buf.episode_start_carry(), &[1.0, 1.0, 0.0]);
}
#[test]
fn test_episode_starts_semantic_alignment_no_state_leak() {
use crate::policy::lstm::{LstmBurnConfig, LstmBurnPolicy};
type AB = burn::backend::Autodiff<NdArray<f32>>;
let (num_steps, num_envs, obs_dim, action_dim) = (5, 1, 4, 2);
let dev = crate::utils::cuda::default_burn_device::<AB>();
let hidden_dim = 8;
let k = 2usize;
let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
let obs_by_step: Vec<Vec<f32>> = (0..num_steps)
.map(|s| (0..obs_dim).map(|d| 0.2 * (s as f32 + 1.0) - 0.05 * d as f32).collect())
.collect();
for (step, obs) in obs_by_step.iter().enumerate() {
let terminated = step == k;
buf.add(step, 0, obs, 0, 0.0, 0.0, 0.0, terminated, false);
}
let batch = buf.to_sequence_batch::<AB>(&dev);
let starts: Vec<f32> = batch.episode_starts.clone().into_data().to_vec().unwrap();
assert_eq!(starts, vec![1.0, 0.0, 0.0, 1.0, 0.0]);
assert_eq!(starts[k], 0.0, "ending episode's last step must NOT reset");
assert_eq!(starts[k + 1], 1.0, "new episode's first step must reset");
let cfg = LstmBurnConfig { hidden_dim, ..Default::default() }.with_seed(23);
let policy = LstmBurnPolicy::<AB>::with_config(obs_dim, action_dim, cfg, &dev);
let (_, _, values) = policy.evaluate_sequences(
batch.obs_seq.clone(),
batch.actions.clone(),
None,
batch.episode_starts.clone(),
);
let v: Vec<f32> = values.into_data().to_vec().unwrap();
let obs_kp1 = Tensor::<AB, 2>::from_data(
TensorData::new(obs_by_step[k + 1].clone(), [1, obs_dim]),
&dev,
);
let (_, value_fresh_kp1, _) = policy.forward_step(obs_kp1, None);
let vf_kp1: Vec<f32> = value_fresh_kp1.into_data().to_vec().unwrap();
assert!(
(v[k + 1] - vf_kp1[0]).abs() < 1e-5,
"step k+1 value {} must match fresh zero-state value {} (no leak)",
v[k + 1],
vf_kp1[0]
);
let obs_k =
Tensor::<AB, 2>::from_data(TensorData::new(obs_by_step[k].clone(), [1, obs_dim]), &dev);
let (_, value_fresh_k, _) = policy.forward_step(obs_k, None);
let vf_k: Vec<f32> = value_fresh_k.into_data().to_vec().unwrap();
assert!(
(v[k] - vf_k[0]).abs() > 1e-6,
"step k value {} should differ from fresh value {} (history retained)",
v[k],
vf_k[0]
);
}
#[test]
fn test_env_major_sampler_coverage() {
let (num_steps, num_envs, obs_dim) = (5, 7, 2);
let buf = fill_buffer(num_steps, num_envs, obs_dim);
let dev = device();
let envs_per_minibatch = 3;
let mut seen = std::collections::HashSet::new();
let mut total = 0usize;
let mut n_batches = 0usize;
for batch in buf.to_minibatches::<B>(envs_per_minibatch, true, &dev) {
n_batches += 1;
assert!(batch.num_envs() <= envs_per_minibatch);
assert_eq!(batch.seq_len(), num_steps);
let obs: Vec<f32> = batch.obs_seq.clone().into_data().to_vec().unwrap();
for e in 0..batch.num_envs() {
let idx = (e * num_steps) * obs_dim + 1;
let env = ((obs[idx] as usize) - 1) / 10;
assert!(seen.insert(env), "env {} appeared twice", env);
total += 1;
}
}
assert_eq!(total, num_envs, "every env covered exactly once");
assert_eq!(seen.len(), num_envs);
assert_eq!(n_batches, 3);
}
#[test]
fn test_sampler_minibatch_shape() {
let (num_steps, num_envs, obs_dim) = (6, 4, 3);
let buf = fill_buffer(num_steps, num_envs, obs_dim);
let dev = device();
let batches: Vec<_> = buf.to_minibatches::<B>(2, false, &dev).collect();
assert_eq!(batches.len(), 2);
for batch in &batches {
assert_eq!(batch.obs_seq.dims(), [2, num_steps, obs_dim]);
assert_eq!(batch.episode_starts.dims(), [2, num_steps]);
assert_eq!(batch.actions.dims(), [2, num_steps]);
}
let obs0: Vec<f32> = batches[0].obs_seq.clone().into_data().to_vec().unwrap();
assert_eq!(obs0[0], 0.0);
}
#[test]
fn test_evaluate_sequences_integration() {
use crate::policy::lstm::{LstmBurnConfig, LstmBurnPolicy};
type AB = burn::backend::Autodiff<NdArray<f32>>;
let (num_steps, num_envs, obs_dim, action_dim) = (5, 3, 4, 2);
let dev = crate::utils::cuda::default_burn_device::<AB>();
let hidden_dim = 8;
let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
for step in 0..num_steps {
for env in 0..num_envs {
let obs: Vec<f32> = (0..obs_dim).map(|d| 0.1 * (step + env + d) as f32).collect();
let term = env == 0 && step == 3;
buf.add(step, env, &obs, (env % action_dim) as i64, 0.0, 0.0, 0.0, term, false);
}
}
let batch = buf.to_sequence_batch::<AB>(&dev);
let cfg = LstmBurnConfig { hidden_dim, ..Default::default() }.with_seed(11);
let policy = LstmBurnPolicy::<AB>::with_config(obs_dim, action_dim, cfg, &dev);
let (log_probs, entropy, values) =
policy.evaluate_sequences(batch.obs_seq, batch.actions, None, batch.episode_starts);
assert_eq!(log_probs.dims(), [num_envs, num_steps]);
assert_eq!(entropy.dims(), [num_envs, num_steps]);
assert_eq!(values.dims(), [num_envs, num_steps]);
}
}