thrust_rl/buffer/rollout/recurrent.rs
1//! Recurrent rollout buffer and full-sequence (Strategy A) sampler.
2//!
3//! Phase 2 of the recurrent-policy epic (#262). Adds a **new**
4//! [`RecurrentRolloutBuffer`] alongside the feedforward
5//! [`RolloutBuffer`](super::storage::RolloutBuffer); the feedforward buffer
6//! and all its consumers are left byte-for-byte untouched (see the design
7//! note `docs/RECURRENT_POLICY_DESIGN.md`, Q1). The feedforward buffer's
8//! whole contract flattens time and env into a rank-2 batch, structurally
9//! erasing the temporal order recurrence needs — so recurrence gets its own
10//! type that preserves the `[num_steps, num_envs]` grid and materializes it
11//! as **rank-3** `[N_env, T, obs_dim]` sequences.
12//!
13//! # Composition over duplication
14//!
15//! [`RecurrentRolloutBuffer`] embeds a private
16//! [`RolloutBuffer`](super::storage::RolloutBuffer) for the nine
17//! `[num_steps, num_envs]` transition arrays (observations, actions,
18//! rewards, values, log-probs, terminated, truncated, advantages, returns)
19//! and adds two `[num_steps, num_envs, hidden_dim]` arrays, `hidden` and
20//! `cell`, for the rollout-time recurrent state. GAE is delegated straight
21//! through to
22//! [`compute_advantages`](super::gae::compute_advantages) /
23//! [`compute_advantages_partial`](super::gae::compute_advantages_partial),
24//! so advantage/return math is **byte-identical** to the feedforward path.
25//!
26//! # The GAE / state-reset asymmetry (do not homogenize)
27//!
28//! GAE bootstraps on `terminated` **only**, at the *same* step — a
29//! truncation is a time-limit cut, not an MDP terminal, so the value target
30//! should still bootstrap from the next state. The hidden-state reset mask
31//! ([`RecurrentRolloutBatch::episode_starts`]) differs on **two** axes:
32//!
33//! 1. It combines `terminated || truncated` — a truncated step still ends the
34//! episode, so its recurrent state must reset.
35//! 2. It is **shifted one step later** than the done stream.
36//! `episode_starts[t]` means "`obs[t]` is the *first* step of a new
37//! episode," which the merged
38//! [`LstmBurnPolicy::evaluate_sequences`](crate::policy::lstm::LstmBurnPolicy::evaluate_sequences)
39//! consumes by zeroing the incoming `(h, c)` **before** step `t`. Given the
40//! collector layout (`obs[t]` is the pre-action observation, `done[t]`
41//! results from step `t`'s action, and the env resets so the fresh
42//! observation lands in the *next* slot `obs[t+1]`), the reset following a
43//! done at step `t-1` must land on `obs[t]`. So `episode_starts[t] =
44//! terminated[t-1] || truncated[t-1]` for `t >= 1`, and `episode_starts[0]`
45//! is the cross-iteration carry-in flag (whether this env's episode ended at
46//! the *end* of the previous rollout iteration; `1.0` for a fresh buffer's
47//! first-ever iteration).
48//!
49//! GAE keeps consuming the same-step `terminated`; the two masks stay
50//! distinct (mirroring SB3's separate `episode_starts` / terminal arrays).
51//! Do not "fix" one to match the other (design note Q2).
52//!
53//! # Warm-start (Strategy A), not Strategy B
54//!
55//! The stored `(h, c)` serve rollout-time warm-starting only: at the start
56//! of the next rollout iteration, an env that did **not** end its episode
57//! (`terminated || truncated`) on the last step carries its final recurrent
58//! state into step 0; an env that did is seeded with zeros. See
59//! [`RecurrentRolloutBuffer::seed_warm_start`]. The `to_sequence_batch`
60//! training forward always passes `initial_state: None` (zeros) because
61//! episode boundaries inside the sequence are handled by `episode_starts`
62//! masking in
63//! [`LstmBurnPolicy::evaluate_sequences`](crate::policy::lstm::LstmBurnPolicy::evaluate_sequences).
64//! Strategy B (fixed-length subsequences with stored boundary states) is
65//! deferred; the `(h, c)` storage here is the hook it would reuse.
66
67use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
68
69use super::storage::RolloutBuffer;
70
71/// Rollout buffer that preserves temporal order for a recurrent policy.
72///
73/// Stores the same nine `[num_steps, num_envs]` transition arrays as
74/// [`RolloutBuffer`](super::storage::RolloutBuffer) (via composition) plus
75/// per-step recurrent state `hidden` / `cell`, each shaped
76/// `[num_steps, num_envs, hidden_dim]`.
77#[derive(Debug, Clone)]
78pub struct RecurrentRolloutBuffer {
79 /// Feedforward transition storage + GAE. Kept private so the recurrent
80 /// buffer's own accessors are the only supported surface.
81 inner: RolloutBuffer,
82
83 /// Recurrent hidden state entering each step
84 /// `[num_steps, num_envs, hidden_dim]`.
85 hidden: Vec<Vec<Vec<f32>>>,
86
87 /// Recurrent cell state entering each step
88 /// `[num_steps, num_envs, hidden_dim]`.
89 cell: Vec<Vec<Vec<f32>>>,
90
91 /// Per-env cross-iteration carry-in flag, `[num_envs]`, supplying
92 /// `episode_starts[0]` for the next materialized batch: `1.0` if this
93 /// env's episode ended at the *end* of the previous rollout iteration
94 /// (so step 0 begins a fresh episode and its `(h, c)` must reset),
95 /// `0.0` if the episode continued across the iteration boundary.
96 /// Initialized to `1.0` for every env — a fresh buffer's first-ever
97 /// iteration starts an episode — and updated by
98 /// [`Self::seed_warm_start`] in lockstep with the `(h, c)` carry.
99 episode_start_carry: Vec<f32>,
100
101 /// Width of the recurrent `(h, c)` state.
102 hidden_dim: usize,
103}
104
105impl RecurrentRolloutBuffer {
106 /// Create a new recurrent rollout buffer.
107 ///
108 /// # Arguments
109 /// * `num_steps` - Number of timesteps per rollout
110 /// * `num_envs` - Number of parallel environments
111 /// * `obs_dim` - Dimensionality of observations
112 /// * `hidden_dim` - Width of the LSTM `(h, c)` state
113 pub fn new(num_steps: usize, num_envs: usize, obs_dim: usize, hidden_dim: usize) -> Self {
114 let inner = RolloutBuffer::new(num_steps, num_envs, obs_dim);
115 let hidden = vec![vec![vec![0.0; hidden_dim]; num_envs]; num_steps];
116 let cell = vec![vec![vec![0.0; hidden_dim]; num_envs]; num_steps];
117 // A fresh buffer's first-ever rollout iteration starts an episode for
118 // every env, so `episode_starts[0]` is `1.0` until `seed_warm_start`
119 // overwrites it from the previous iteration's terminal state.
120 let episode_start_carry = vec![1.0; num_envs];
121 Self { inner, hidden, cell, episode_start_carry, hidden_dim }
122 }
123
124 /// Add a transition to the buffer.
125 ///
126 /// Delegates verbatim to
127 /// [`RolloutBuffer::add`](super::storage::RolloutBuffer::add); see that
128 /// method for argument semantics. The recurrent `(h, c)` for this step
129 /// is recorded separately via [`Self::add_recurrent_state`].
130 // Each argument is a distinct transition field; bundling them into a
131 // struct would add boilerplate at every call site without improving
132 // clarity (mirrors the feedforward `RolloutBuffer::add`).
133 #[allow(clippy::too_many_arguments)]
134 pub fn add(
135 &mut self,
136 step: usize,
137 env_id: usize,
138 observation: &[f32],
139 action: i64,
140 reward: f32,
141 value: f32,
142 log_prob: f32,
143 terminated: bool,
144 truncated: bool,
145 ) {
146 self.inner.add(
147 step,
148 env_id,
149 observation,
150 action,
151 reward,
152 value,
153 log_prob,
154 terminated,
155 truncated,
156 );
157 }
158
159 /// Record the recurrent state `(h, c)` **entering** step `step` for
160 /// env `env_id`.
161 ///
162 /// The rollout loop calls this *before* the env step, so the stored
163 /// state is the one that entered the step — used for warm-start
164 /// verification (via [`Self::seed_warm_start`]) and reserved as the
165 /// hook a future Strategy B trainer would reuse. The training forward
166 /// itself does not read these back (it recomputes states from a zeroed
167 /// `initial_state`).
168 ///
169 /// # Panics
170 /// Panics (debug builds) if `step`/`env_id` are out of range or if
171 /// `h`/`c` do not have length `hidden_dim`.
172 pub fn add_recurrent_state(&mut self, step: usize, env_id: usize, h: &[f32], c: &[f32]) {
173 debug_assert!(step < self.hidden.len(), "step {} out of range", step);
174 debug_assert!(env_id < self.hidden[step].len(), "env_id {} out of range", env_id);
175 debug_assert_eq!(h.len(), self.hidden_dim, "hidden state dimension mismatch");
176 debug_assert_eq!(c.len(), self.hidden_dim, "cell state dimension mismatch");
177 self.hidden[step][env_id].copy_from_slice(h);
178 self.cell[step][env_id].copy_from_slice(c);
179 }
180
181 /// Seed the step-0 recurrent state for the **next** rollout iteration,
182 /// in place.
183 ///
184 /// For each env, if it did **not** end its episode
185 /// (`terminated || truncated`) on step `last_step`, its final recurrent
186 /// state `final_hidden[env]` / `final_cell[env]` (the state exiting the
187 /// last collected step) is carried into `hidden[0][env]` /
188 /// `cell[0][env]`. If it **did** end its episode, step 0 is seeded with
189 /// zeros — the episode already ended, so no memory should survive into
190 /// the fresh one. This is the warm-start half of the GAE/state-reset
191 /// asymmetry: the carry decision follows `terminated || truncated`, not
192 /// `terminated` alone.
193 ///
194 /// `final_hidden` / `final_cell` are indexed `[env][hidden_dim]` and
195 /// must have `num_envs` rows.
196 ///
197 /// This method also records the per-env cross-iteration carry-in flag
198 /// that becomes `episode_starts[0]` of the next materialized batch: an
199 /// env that ended its episode gets flag `1.0` (its step-0 state resets),
200 /// a live env gets `0.0` (its state continues). The flag is set in
201 /// lockstep with the `(h, c)` carry so the reset mask and the seeded
202 /// state always agree.
203 ///
204 /// # Strategy A note (the `(h, c)` carry is a Strategy-B hook)
205 /// The `(h, c)` seeded into `hidden[0]` / `cell[0]` here is **not**
206 /// consumed by Strategy A's [`Self::to_sequence_batch`], which always
207 /// passes `initial_state: None` (zeros) to the forward — an intentional
208 /// BPTT truncation at the iteration boundary. Cross-iteration continuity
209 /// is instead approximated only through the carry-in flag above (which
210 /// controls the *reset*, not the *value*, of step 0's state). The seeded
211 /// `(h, c)` storage is a forward-looking hook a future Strategy B trainer
212 /// (fixed-length subsequences with stored boundary states) would feed in;
213 /// under Strategy A it is deliberately left unread.
214 ///
215 /// # Panics
216 /// Panics if `last_step >= num_steps`, if `final_hidden` / `final_cell`
217 /// do not have `num_envs` rows, or (debug builds) if any row is not
218 /// `hidden_dim` wide.
219 pub fn seed_warm_start(
220 &mut self,
221 last_step: usize,
222 final_hidden: &[Vec<f32>],
223 final_cell: &[Vec<f32>],
224 ) {
225 let (num_steps, num_envs, _) = self.inner.shape();
226 assert!(
227 last_step < num_steps,
228 "last_step ({}) must be < num_steps ({})",
229 last_step,
230 num_steps
231 );
232 assert_eq!(final_hidden.len(), num_envs, "final_hidden must have num_envs rows");
233 assert_eq!(final_cell.len(), num_envs, "final_cell must have num_envs rows");
234
235 let terminated = self.inner.terminated();
236 let truncated = self.inner.truncated();
237 for env in 0..num_envs {
238 let ended = terminated[last_step][env] || truncated[last_step][env];
239 // Record the carry-in flag that becomes `episode_starts[0]` next
240 // iteration: `1.0` when the episode ended (step 0 begins fresh),
241 // `0.0` when it continues across the boundary.
242 self.episode_start_carry[env] = if ended { 1.0 } else { 0.0 };
243 if ended {
244 // Episode ended on the last step — start the next iteration
245 // from a zeroed state.
246 self.hidden[0][env].iter_mut().for_each(|x| *x = 0.0);
247 self.cell[0][env].iter_mut().for_each(|x| *x = 0.0);
248 } else {
249 debug_assert_eq!(final_hidden[env].len(), self.hidden_dim, "hidden row width");
250 debug_assert_eq!(final_cell[env].len(), self.hidden_dim, "cell row width");
251 self.hidden[0][env].copy_from_slice(&final_hidden[env]);
252 self.cell[0][env].copy_from_slice(&final_cell[env]);
253 }
254 }
255 }
256
257 /// Reset the buffer's advantages/returns for a new rollout.
258 ///
259 /// Delegates to
260 /// [`RolloutBuffer::reset`](super::storage::RolloutBuffer::reset).
261 /// The recurrent `(h, c)` arrays and the `episode_start_carry` flag are
262 /// intentionally left in place — `(h, c)` is overwritten by
263 /// [`Self::add_recurrent_state`] during the next collection, and both the
264 /// step-0 state and the carry flag are seeded by
265 /// [`Self::seed_warm_start`] so they must survive the reset to bridge the
266 /// iteration boundary.
267 pub fn reset(&mut self) {
268 self.inner.reset();
269 }
270
271 /// Compute GAE over the full `[num_steps, num_envs]` capacity.
272 ///
273 /// Delegates unchanged to
274 /// [`compute_advantages`](super::gae::compute_advantages) — the
275 /// `[step][env]` advantage/return grid is byte-identical to the
276 /// feedforward path (GAE bootstraps on `terminated` only).
277 pub fn compute_advantages(&mut self, last_values: &[f32], gamma: f32, gae_lambda: f32) {
278 self.inner.compute_advantages(last_values, gamma, gae_lambda);
279 }
280
281 /// Compute GAE over the first `valid_steps` rows.
282 ///
283 /// Delegates unchanged to
284 /// [`compute_advantages_partial`](super::gae::compute_advantages_partial).
285 pub fn compute_advantages_partial(
286 &mut self,
287 valid_steps: usize,
288 last_values: &[f32],
289 gamma: f32,
290 gae_lambda: f32,
291 ) {
292 self.inner
293 .compute_advantages_partial(valid_steps, last_values, gamma, gae_lambda);
294 }
295
296 /// Buffer shape `(num_steps, num_envs, obs_dim)`.
297 pub fn shape(&self) -> (usize, usize, usize) {
298 self.inner.shape()
299 }
300
301 /// Width of the recurrent `(h, c)` state.
302 pub fn hidden_dim(&self) -> usize {
303 self.hidden_dim
304 }
305
306 // ---- Delegating getters (read-only views into the inner buffer) ----
307
308 /// Per-step observations, indexed `[step][env]` then by obs dimension.
309 pub fn observations(&self) -> &[Vec<Vec<f32>>] {
310 self.inner.observations()
311 }
312 /// Per-step discrete actions, indexed `[step][env]`.
313 pub fn actions(&self) -> &[Vec<i64>] {
314 self.inner.actions()
315 }
316 /// Per-step value estimates, indexed `[step][env]`.
317 pub fn values(&self) -> &[Vec<f32>] {
318 self.inner.values()
319 }
320 /// Per-step behavior-policy log-probs, indexed `[step][env]`.
321 pub fn log_probs(&self) -> &[Vec<f32>] {
322 self.inner.log_probs()
323 }
324 /// Per-step terminal flags, indexed `[step][env]`.
325 pub fn terminated(&self) -> &[Vec<bool>] {
326 self.inner.terminated()
327 }
328 /// Per-step truncation flags, indexed `[step][env]`.
329 pub fn truncated(&self) -> &[Vec<bool>] {
330 self.inner.truncated()
331 }
332 /// Per-step GAE advantages, indexed `[step][env]`.
333 pub fn advantages(&self) -> &[Vec<f32>] {
334 self.inner.advantages()
335 }
336 /// Per-step value-function targets, indexed `[step][env]`.
337 pub fn returns(&self) -> &[Vec<f32>] {
338 self.inner.returns()
339 }
340
341 /// Recurrent hidden state entering each step, indexed
342 /// `[step][env]` then by hidden dimension.
343 pub fn hidden(&self) -> &[Vec<Vec<f32>>] {
344 &self.hidden
345 }
346 /// Recurrent cell state entering each step, indexed `[step][env]`
347 /// then by hidden dimension.
348 pub fn cell(&self) -> &[Vec<Vec<f32>>] {
349 &self.cell
350 }
351
352 /// Per-env cross-iteration carry-in flag (`episode_starts[0]` source),
353 /// indexed `[env]`. `1.0` marks that the env's episode ended at the end
354 /// of the previous rollout iteration (step 0 resets); `0.0` marks a
355 /// carried-over episode.
356 pub fn episode_start_carry(&self) -> &[f32] {
357 &self.episode_start_carry
358 }
359
360 /// Materialize the full buffer as a rank-3 sequence batch (`T =
361 /// num_steps`).
362 ///
363 /// Convenience wrapper over [`Self::to_sequence_batch_partial`] with
364 /// `valid_steps == num_steps`.
365 pub fn to_sequence_batch<B: Backend>(&self, device: &B::Device) -> RecurrentRolloutBatch<B> {
366 let num_steps = self.inner.shape().0;
367 self.to_sequence_batch_partial::<B>(num_steps, device)
368 }
369
370 /// Materialize the first `valid_steps` rows as a rank-3 sequence batch
371 /// (`T = valid_steps`).
372 ///
373 /// Selects **all** envs. See [`RecurrentRolloutBatch`] for the exact
374 /// field shapes. Env-major layout: each env-trajectory is one
375 /// contiguous row of length `T`.
376 ///
377 /// # Panics
378 /// Panics if `valid_steps > num_steps`.
379 pub fn to_sequence_batch_partial<B: Backend>(
380 &self,
381 valid_steps: usize,
382 device: &B::Device,
383 ) -> RecurrentRolloutBatch<B> {
384 let num_envs = self.inner.shape().1;
385 let env_ids: Vec<usize> = (0..num_envs).collect();
386 self.sequence_batch_for_envs::<B>(&env_ids, valid_steps, device)
387 }
388
389 /// Build an env-major minibatch iterator over full trajectories
390 /// (`T = num_steps`).
391 ///
392 /// Shuffles **environment** indices (not global timesteps) and chunks
393 /// them by `envs_per_minibatch` — the recurrent analogue of the
394 /// feedforward `batch_size`, counting whole env-trajectories rather
395 /// than loose timesteps. Over one epoch every env index appears in
396 /// exactly one minibatch.
397 ///
398 /// When `shuffle` is `false` the env order is `0..num_envs` (useful for
399 /// deterministic tests / reproducible evaluation).
400 pub fn to_minibatches<'a, B: Backend>(
401 &'a self,
402 envs_per_minibatch: usize,
403 shuffle: bool,
404 device: &B::Device,
405 ) -> RecurrentMinibatchIterator<'a, B> {
406 let num_steps = self.inner.shape().0;
407 RecurrentMinibatchIterator::new(self, envs_per_minibatch, num_steps, shuffle, device)
408 }
409
410 /// Core materialization: build a [`RecurrentRolloutBatch`] from the
411 /// selected `env_ids` over the first `valid_steps` rows.
412 ///
413 /// Rows are laid out in the order of `env_ids` (env-major); each field
414 /// is `[env_ids.len(), valid_steps, ..]`.
415 fn sequence_batch_for_envs<B: Backend>(
416 &self,
417 env_ids: &[usize],
418 valid_steps: usize,
419 device: &B::Device,
420 ) -> RecurrentRolloutBatch<B> {
421 let (num_steps, _num_envs, obs_dim) = self.inner.shape();
422 assert!(
423 valid_steps <= num_steps,
424 "valid_steps ({}) must not exceed num_steps ({})",
425 valid_steps,
426 num_steps
427 );
428
429 let n_env = env_ids.len();
430 let t = valid_steps;
431
432 let observations = self.inner.observations();
433 let actions_grid = self.inner.actions();
434 let values_grid = self.inner.values();
435 let log_probs_grid = self.inner.log_probs();
436 let terminated_grid = self.inner.terminated();
437 let truncated_grid = self.inner.truncated();
438 let advantages_grid = self.inner.advantages();
439 let returns_grid = self.inner.returns();
440
441 let mut obs_flat = Vec::with_capacity(n_env * t * obs_dim);
442 let mut actions_flat = Vec::with_capacity(n_env * t);
443 let mut starts_flat = Vec::with_capacity(n_env * t);
444 let mut log_probs_flat = Vec::with_capacity(n_env * t);
445 let mut values_flat = Vec::with_capacity(n_env * t);
446 let mut advantages_flat = Vec::with_capacity(n_env * t);
447 let mut returns_flat = Vec::with_capacity(n_env * t);
448
449 // Env-major, step-minor: one env-trajectory per contiguous block.
450 for &env in env_ids {
451 for step in 0..t {
452 obs_flat.extend_from_slice(&observations[step][env]);
453 actions_flat.push(actions_grid[step][env]);
454 // Episode-start (hidden-state reset) mask, shifted one step
455 // later than the done stream: `episode_starts[t]` marks that
456 // `obs[t]` is the FIRST step of a new episode, which
457 // `evaluate_sequences` consumes by zeroing `(h, c)` *before*
458 // step `t`. Because the collector stores `obs[t]` pre-action,
459 // `done[t]` as the result of step `t`, and the post-reset
460 // observation in the next slot `obs[t+1]`, the reset after a
461 // done at step `t-1` must land on `obs[t]`. So for `t >= 1`
462 // the flag is the *previous* step's `terminated || truncated`;
463 // at `t == 0` it is the cross-iteration carry-in flag. GAE
464 // still reads the same-step, terminated-only flag — the two
465 // masks stay distinct.
466 let start = if step == 0 {
467 self.episode_start_carry[env]
468 } else {
469 let prev_done = terminated_grid[step - 1][env] || truncated_grid[step - 1][env];
470 if prev_done { 1.0_f32 } else { 0.0_f32 }
471 };
472 starts_flat.push(start);
473 log_probs_flat.push(log_probs_grid[step][env]);
474 values_flat.push(values_grid[step][env]);
475 advantages_flat.push(advantages_grid[step][env]);
476 returns_flat.push(returns_grid[step][env]);
477 }
478 }
479
480 let obs_seq =
481 Tensor::<B, 3>::from_data(TensorData::new(obs_flat, [n_env, t, obs_dim]), device);
482 let actions =
483 Tensor::<B, 2, Int>::from_data(TensorData::new(actions_flat, [n_env, t]), device);
484 let episode_starts =
485 Tensor::<B, 2>::from_data(TensorData::new(starts_flat, [n_env, t]), device);
486 let old_log_probs =
487 Tensor::<B, 2>::from_data(TensorData::new(log_probs_flat, [n_env, t]), device);
488 let old_values =
489 Tensor::<B, 2>::from_data(TensorData::new(values_flat, [n_env, t]), device);
490 let advantages =
491 Tensor::<B, 2>::from_data(TensorData::new(advantages_flat, [n_env, t]), device);
492 let returns = Tensor::<B, 2>::from_data(TensorData::new(returns_flat, [n_env, t]), device);
493
494 RecurrentRolloutBatch {
495 obs_seq,
496 actions,
497 episode_starts,
498 old_log_probs,
499 old_values,
500 advantages,
501 returns,
502 }
503 }
504}
505
506/// A rank-3 batch of recurrent rollout data, ready to feed
507/// [`LstmBurnPolicy::evaluate_sequences`](crate::policy::lstm::LstmBurnPolicy::evaluate_sequences)
508/// with no shape adapters.
509///
510/// Every field is per-`(env, step)`: `obs_seq` is rank-3
511/// `[N_env, T, obs_dim]`, the rest are rank-2 `[N_env, T]`. The recurrent
512/// PPO surrogate needs these per-step quantities, not a flattened rank-1
513/// batch. `initial_state` is intentionally absent — the training forward
514/// always starts from a zeroed `(h, c)` and relies on `episode_starts` for
515/// in-sequence resets (design note Q2, Strategy A).
516#[derive(Debug)]
517pub struct RecurrentRolloutBatch<B: Backend> {
518 /// Observations, `[N_env, T, obs_dim]` — feeds `obs_seq`.
519 pub obs_seq: Tensor<B, 3>,
520 /// Discrete actions, `[N_env, T]` — feeds `actions`.
521 pub actions: Tensor<B, 2, Int>,
522 /// Episode-start (state-reset) mask, `[N_env, T]`: `1.0` where `obs[t]`
523 /// is the **first** step of a new episode, else `0.0`. This is the
524 /// done-flag stream shifted one step later —
525 /// `terminated[t-1] || truncated[t-1]` for `t >= 1`, and the
526 /// cross-iteration carry-in flag at `t == 0`. Feeds `episode_starts`,
527 /// the hidden-state reset mask consumed by `evaluate_sequences`; it is
528 /// distinct from (and one step ahead of) the terminated-only GAE flag.
529 pub episode_starts: Tensor<B, 2>,
530 /// Behavior-policy log-probs `[N_env, T]` for the PPO ratio.
531 pub old_log_probs: Tensor<B, 2>,
532 /// Behavior-policy value estimates `V(s_t)`, `[N_env, T]`.
533 pub old_values: Tensor<B, 2>,
534 /// GAE advantages `[N_env, T]` (terminated-only bootstrap, from GAE).
535 pub advantages: Tensor<B, 2>,
536 /// Value-function targets `[N_env, T]` (advantages + values).
537 pub returns: Tensor<B, 2>,
538}
539
540impl<B: Backend> RecurrentRolloutBatch<B> {
541 /// Number of env-trajectories (`N_env`) in the batch.
542 pub fn num_envs(&self) -> usize {
543 self.obs_seq.dims()[0]
544 }
545
546 /// Sequence length (`T`) of each trajectory.
547 pub fn seq_len(&self) -> usize {
548 self.obs_seq.dims()[1]
549 }
550}
551
552/// Env-major minibatch iterator over whole env-trajectories
553/// (full-sequence, Strategy A).
554///
555/// Yields one [`RecurrentRolloutBatch`] per chunk of `envs_per_minibatch`
556/// shuffled env indices. Unlike the feedforward sampler, which shuffles
557/// loose timesteps, this shuffles only the env dimension so each
558/// trajectory stays temporally intact — episode boundaries are handled by
559/// `episode_starts` masking inside the forward, never by cutting
560/// sequences. Over one full pass every env appears in exactly one
561/// minibatch.
562pub struct RecurrentMinibatchIterator<'a, B: Backend> {
563 buffer: &'a RecurrentRolloutBuffer,
564 device: B::Device,
565 /// Shuffled env-id chunks, one per minibatch.
566 chunks: Vec<Vec<usize>>,
567 valid_steps: usize,
568 current: usize,
569}
570
571impl<'a, B: Backend> RecurrentMinibatchIterator<'a, B> {
572 /// Create a new env-major minibatch iterator.
573 ///
574 /// * `buffer` - Source recurrent rollout buffer
575 /// * `envs_per_minibatch` - Whole env-trajectories per minibatch
576 /// * `valid_steps` - Sequence length `T` (rows to materialize)
577 /// * `shuffle` - Shuffle env indices (else natural `0..num_envs` order)
578 /// * `device` - Device the batch tensors are built on
579 pub fn new(
580 buffer: &'a RecurrentRolloutBuffer,
581 envs_per_minibatch: usize,
582 valid_steps: usize,
583 shuffle: bool,
584 device: &B::Device,
585 ) -> Self {
586 let num_envs = buffer.shape().1;
587 let mut env_ids: Vec<usize> = (0..num_envs).collect();
588 if shuffle {
589 use rand::seq::SliceRandom;
590 env_ids.shuffle(&mut rand::rng());
591 }
592 // `envs_per_minibatch == 0` would make `chunks` panic; clamp to at
593 // least one whole trajectory per minibatch.
594 let chunk_len = envs_per_minibatch.max(1);
595 let chunks: Vec<Vec<usize>> =
596 env_ids.chunks(chunk_len).map(|chunk| chunk.to_vec()).collect();
597
598 Self { buffer, device: device.clone(), chunks, valid_steps, current: 0 }
599 }
600}
601
602impl<B: Backend> Iterator for RecurrentMinibatchIterator<'_, B> {
603 type Item = RecurrentRolloutBatch<B>;
604
605 fn next(&mut self) -> Option<Self::Item> {
606 if self.current >= self.chunks.len() {
607 return None;
608 }
609 let env_ids = &self.chunks[self.current];
610 self.current += 1;
611 Some(
612 self.buffer
613 .sequence_batch_for_envs::<B>(env_ids, self.valid_steps, &self.device),
614 )
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use burn::backend::NdArray;
621
622 use super::*;
623 use crate::buffer::rollout::storage::RolloutBuffer;
624
625 type B = NdArray<f32>;
626
627 fn device() -> <B as burn::tensor::backend::BackendTypes>::Device {
628 crate::utils::cuda::default_burn_device::<B>()
629 }
630
631 /// Fill a recurrent buffer with deterministic per-`(step, env)` data so
632 /// shape/value assertions are exact. Observations encode `(step, env,
633 /// dim)` so the env-major flatten order can be verified.
634 fn fill_buffer(num_steps: usize, num_envs: usize, obs_dim: usize) -> RecurrentRolloutBuffer {
635 let hidden_dim = 3;
636 let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
637 for step in 0..num_steps {
638 for env in 0..num_envs {
639 let obs: Vec<f32> =
640 (0..obs_dim).map(|d| (step * 100 + env * 10 + d) as f32).collect();
641 buf.add(
642 step,
643 env,
644 &obs,
645 (step + env) as i64,
646 step as f32, // reward
647 (env as f32) * 0.5, // value
648 -(step as f32) * 0.1, // log_prob
649 false,
650 false,
651 );
652 }
653 }
654 buf
655 }
656
657 /// `to_sequence_batch` produces rank-3 `[N_env, T, obs_dim]` obs and
658 /// rank-2 `[N_env, T]` everything else, for a 4-env × 8-step buffer.
659 #[test]
660 fn test_to_sequence_batch_shapes() {
661 let buf = fill_buffer(8, 4, 5);
662 let dev = device();
663 let batch = buf.to_sequence_batch::<B>(&dev);
664
665 assert_eq!(batch.obs_seq.dims(), [4, 8, 5]);
666 assert_eq!(batch.actions.dims(), [4, 8]);
667 assert_eq!(batch.episode_starts.dims(), [4, 8]);
668 assert_eq!(batch.old_log_probs.dims(), [4, 8]);
669 assert_eq!(batch.old_values.dims(), [4, 8]);
670 assert_eq!(batch.advantages.dims(), [4, 8]);
671 assert_eq!(batch.returns.dims(), [4, 8]);
672 assert_eq!(batch.num_envs(), 4);
673 assert_eq!(batch.seq_len(), 8);
674 }
675
676 /// Env-major flatten order: `obs_seq[env, step, dim]` must equal the
677 /// value stored at `[step][env][dim]`.
678 #[test]
679 fn test_to_sequence_batch_env_major_layout() {
680 let (num_steps, num_envs, obs_dim) = (3, 2, 4);
681 let buf = fill_buffer(num_steps, num_envs, obs_dim);
682 let dev = device();
683 let batch = buf.to_sequence_batch::<B>(&dev);
684
685 let obs: Vec<f32> = batch.obs_seq.into_data().to_vec().unwrap();
686 for env in 0..num_envs {
687 for step in 0..num_steps {
688 for d in 0..obs_dim {
689 let idx = (env * num_steps + step) * obs_dim + d;
690 let expected = (step * 100 + env * 10 + d) as f32;
691 assert_eq!(obs[idx], expected, "env {} step {} dim {}", env, step, d);
692 }
693 }
694 }
695 }
696
697 /// `episode_starts[t]` is the done stream (`terminated || truncated`)
698 /// shifted one step later: `episode_starts[t] = done[t-1]` for `t >= 1`,
699 /// and `episode_starts[0]` is the fresh-buffer carry-in flag (`1.0`).
700 /// Exercises truncated-only, terminated-only, both, and neither in the
701 /// donor positions.
702 #[test]
703 fn test_episode_starts_flag_correctness() {
704 let (num_steps, num_envs, obs_dim) = (4, 1, 2);
705 let hidden_dim = 2;
706 let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
707 // done at step 0: neither, step 1: terminated only, step 2: truncated
708 // only, step 3: both.
709 let flags = [(false, false), (true, false), (false, true), (true, true)];
710 for (step, &(term, trunc)) in flags.iter().enumerate() {
711 buf.add(step, 0, &[0.0, 0.0], 0, 0.0, 0.0, 0.0, term, trunc);
712 }
713
714 let dev = device();
715 let batch = buf.to_sequence_batch::<B>(&dev);
716 let starts: Vec<f32> = batch.episode_starts.into_data().to_vec().unwrap();
717 // Single env, so flat order is just step order. Step 0 is the
718 // fresh-buffer carry-in (1.0); each later step mirrors the PREVIOUS
719 // step's done. The `done` at the final step (both) has no successor
720 // in this window, so it does not appear here (it would seed the next
721 // iteration's `episode_starts[0]` via `seed_warm_start`).
722 assert_eq!(starts, vec![1.0, 0.0, 1.0, 1.0]);
723 }
724
725 /// GAE delegation is byte-identical to the feedforward buffer: build a
726 /// recurrent and a feedforward buffer with identical rewards/values/
727 /// terminated/truncated, run `compute_advantages_partial` on both, and
728 /// assert advantages/returns agree element-wise.
729 #[test]
730 fn test_gae_input_parity_with_feedforward() {
731 let (num_steps, num_envs, obs_dim) = (6, 3, 2);
732 let hidden_dim = 4;
733 let mut rec = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
734 let mut ff = RolloutBuffer::new(num_steps, num_envs, obs_dim);
735
736 for step in 0..num_steps {
737 for env in 0..num_envs {
738 let obs = [step as f32, env as f32];
739 let action = (step + env) as i64;
740 let reward = ((step * 2 + env) as f32).sin();
741 let value = ((step + env) as f32) * 0.3;
742 let log_prob = -0.1 * (step as f32);
743 // Terminate env 0 at step 2, truncate env 1 at step 4. GAE
744 // must react to `terminated` only; both buffers see the
745 // same flags so results must still match.
746 let term = env == 0 && step == 2;
747 let trunc = env == 1 && step == 4;
748 rec.add(step, env, &obs, action, reward, value, log_prob, term, trunc);
749 ff.add(step, env, &obs, action, reward, value, log_prob, term, trunc);
750 }
751 }
752
753 let last_values = vec![0.7_f32, -0.2, 0.4];
754 let (gamma, lam, valid) = (0.99_f32, 0.95_f32, num_steps);
755 rec.compute_advantages_partial(valid, &last_values, gamma, lam);
756 ff.compute_advantages_partial(valid, &last_values, gamma, lam);
757
758 for step in 0..num_steps {
759 for env in 0..num_envs {
760 assert!(
761 (rec.advantages()[step][env] - ff.advantages()[step][env]).abs() < 1e-6,
762 "advantage mismatch at [{}][{}]",
763 step,
764 env
765 );
766 assert!(
767 (rec.returns()[step][env] - ff.returns()[step][env]).abs() < 1e-6,
768 "return mismatch at [{}][{}]",
769 step,
770 env
771 );
772 }
773 }
774 }
775
776 /// `add_recurrent_state` round-trips `(h, c)` for every `(step, env)`.
777 #[test]
778 fn test_add_recurrent_state_round_trip() {
779 let (num_steps, num_envs, obs_dim, hidden_dim) = (3, 2, 2, 4);
780 let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
781 for step in 0..num_steps {
782 for env in 0..num_envs {
783 let h: Vec<f32> =
784 (0..hidden_dim).map(|k| (step * 1000 + env * 100 + k) as f32).collect();
785 let c: Vec<f32> =
786 (0..hidden_dim).map(|k| -((step * 1000 + env * 100 + k) as f32)).collect();
787 buf.add_recurrent_state(step, env, &h, &c);
788 }
789 }
790 for step in 0..num_steps {
791 for env in 0..num_envs {
792 for k in 0..hidden_dim {
793 let expected = (step * 1000 + env * 100 + k) as f32;
794 assert_eq!(buf.hidden()[step][env][k], expected);
795 assert_eq!(buf.cell()[step][env][k], -expected);
796 }
797 }
798 }
799 }
800
801 /// Warm-start: an env ended (`terminated || truncated`) on the last
802 /// step gets a zeroed step-0 state; an env that did neither carries its
803 /// non-zero final state.
804 #[test]
805 fn test_seed_warm_start_zeros_for_ended_envs() {
806 let (num_steps, num_envs, obs_dim, hidden_dim) = (4, 3, 2, 3);
807 let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
808 let last_step = num_steps - 1;
809 // env 0: terminated, env 1: truncated, env 2: neither.
810 buf.add(last_step, 0, &[0.0, 0.0], 0, 0.0, 0.0, 0.0, true, false);
811 buf.add(last_step, 1, &[0.0, 0.0], 0, 0.0, 0.0, 0.0, false, true);
812 buf.add(last_step, 2, &[0.0, 0.0], 0, 0.0, 0.0, 0.0, false, false);
813
814 let final_hidden = vec![vec![1.0, 2.0, 3.0]; num_envs];
815 let final_cell = vec![vec![-1.0, -2.0, -3.0]; num_envs];
816 buf.seed_warm_start(last_step, &final_hidden, &final_cell);
817
818 // Ended envs -> zeros.
819 assert_eq!(buf.hidden()[0][0], vec![0.0, 0.0, 0.0]);
820 assert_eq!(buf.cell()[0][0], vec![0.0, 0.0, 0.0]);
821 assert_eq!(buf.hidden()[0][1], vec![0.0, 0.0, 0.0]);
822 assert_eq!(buf.cell()[0][1], vec![0.0, 0.0, 0.0]);
823 // Live env -> carries the final state.
824 assert_eq!(buf.hidden()[0][2], vec![1.0, 2.0, 3.0]);
825 assert_eq!(buf.cell()[0][2], vec![-1.0, -2.0, -3.0]);
826 // Carry-in flag tracks the `(h, c)` seeding in lockstep: ended envs
827 // (0, 1) reset at step 0, the live env (2) carries over.
828 assert_eq!(buf.episode_start_carry(), &[1.0, 1.0, 0.0]);
829 }
830
831 /// Semantic alignment against realistic done placement: an episode that
832 /// ends at step `k` must set `episode_starts[k+1] == 1` (the reset lands
833 /// on the *first* step of the new episode) and `episode_starts[k] == 0`
834 /// (the ending episode's final step keeps its history). We prove no
835 /// stale state leaks across the boundary by feeding the materialized
836 /// batch through
837 /// [`LstmBurnPolicy::evaluate_sequences`](crate::policy::lstm::LstmBurnPolicy::evaluate_sequences)
838 /// and checking the value at `k+1` equals a fresh zero-state forward on
839 /// `obs[k+1]` — mirroring the policy's own boundary-reset test — while
840 /// the value at `k` differs from a fresh forward, confirming the final
841 /// pre-boundary step still carries the ending episode's context.
842 #[test]
843 fn test_episode_starts_semantic_alignment_no_state_leak() {
844 use crate::policy::lstm::{LstmBurnConfig, LstmBurnPolicy};
845 type AB = burn::backend::Autodiff<NdArray<f32>>;
846
847 let (num_steps, num_envs, obs_dim, action_dim) = (5, 1, 4, 2);
848 let dev = crate::utils::cuda::default_burn_device::<AB>();
849 let hidden_dim = 8;
850
851 // Episode boundary at step k = 2 (terminated). obs[k] = obs[2] is the
852 // ending episode's last acted-from state; obs[k+1] = obs[3] is the
853 // first state of the fresh episode.
854 let k = 2usize;
855 let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
856 // Distinct nonzero observations so recurrent state actually evolves.
857 let obs_by_step: Vec<Vec<f32>> = (0..num_steps)
858 .map(|s| (0..obs_dim).map(|d| 0.2 * (s as f32 + 1.0) - 0.05 * d as f32).collect())
859 .collect();
860 for (step, obs) in obs_by_step.iter().enumerate() {
861 let terminated = step == k;
862 buf.add(step, 0, obs, 0, 0.0, 0.0, 0.0, terminated, false);
863 }
864
865 let batch = buf.to_sequence_batch::<AB>(&dev);
866 let starts: Vec<f32> = batch.episode_starts.clone().into_data().to_vec().unwrap();
867 // Fresh buffer carry-in at step 0, then the done stream shifted one
868 // step: only step k+1 = 3 is flagged (done at k = 2), step k = 2 is 0.
869 assert_eq!(starts, vec![1.0, 0.0, 0.0, 1.0, 0.0]);
870 assert_eq!(starts[k], 0.0, "ending episode's last step must NOT reset");
871 assert_eq!(starts[k + 1], 1.0, "new episode's first step must reset");
872
873 let cfg = LstmBurnConfig { hidden_dim, ..Default::default() }.with_seed(23);
874 let policy = LstmBurnPolicy::<AB>::with_config(obs_dim, action_dim, cfg, &dev);
875 let (_, _, values) = policy.evaluate_sequences(
876 batch.obs_seq.clone(),
877 batch.actions.clone(),
878 None,
879 batch.episode_starts.clone(),
880 );
881 let v: Vec<f32> = values.into_data().to_vec().unwrap();
882
883 // obs[k+1] under the batch must equal a fresh zero-state forward: the
884 // reset at k+1 zeroed the incoming state, so the ending episode's
885 // memory did NOT leak into the new episode's first step.
886 let obs_kp1 = Tensor::<AB, 2>::from_data(
887 TensorData::new(obs_by_step[k + 1].clone(), [1, obs_dim]),
888 &dev,
889 );
890 let (_, value_fresh_kp1, _) = policy.forward_step(obs_kp1, None);
891 let vf_kp1: Vec<f32> = value_fresh_kp1.into_data().to_vec().unwrap();
892 assert!(
893 (v[k + 1] - vf_kp1[0]).abs() < 1e-5,
894 "step k+1 value {} must match fresh zero-state value {} (no leak)",
895 v[k + 1],
896 vf_kp1[0]
897 );
898
899 // obs[k], by contrast, is NOT a reset step: it must carry the state
900 // accumulated over steps 0..k, so its value differs from a fresh
901 // zero-state forward — the ending episode keeps its own history.
902 let obs_k =
903 Tensor::<AB, 2>::from_data(TensorData::new(obs_by_step[k].clone(), [1, obs_dim]), &dev);
904 let (_, value_fresh_k, _) = policy.forward_step(obs_k, None);
905 let vf_k: Vec<f32> = value_fresh_k.into_data().to_vec().unwrap();
906 assert!(
907 (v[k] - vf_k[0]).abs() > 1e-6,
908 "step k value {} should differ from fresh value {} (history retained)",
909 v[k],
910 vf_k[0]
911 );
912 }
913
914 /// Env-major sampler: over one epoch every env index appears exactly
915 /// once across all minibatches (no dup, full coverage), with the last
916 /// chunk shorter when `num_envs % envs_per_minibatch != 0`.
917 #[test]
918 fn test_env_major_sampler_coverage() {
919 let (num_steps, num_envs, obs_dim) = (5, 7, 2);
920 let buf = fill_buffer(num_steps, num_envs, obs_dim);
921 let dev = device();
922 let envs_per_minibatch = 3;
923
924 let mut seen = std::collections::HashSet::new();
925 let mut total = 0usize;
926 let mut n_batches = 0usize;
927 for batch in buf.to_minibatches::<B>(envs_per_minibatch, true, &dev) {
928 n_batches += 1;
929 assert!(batch.num_envs() <= envs_per_minibatch);
930 assert_eq!(batch.seq_len(), num_steps);
931 // Recover which envs are in this batch from the obs' step-0
932 // dim-1 encoding: obs[env,0,1] = 0*100 + env*10 + 1.
933 let obs: Vec<f32> = batch.obs_seq.clone().into_data().to_vec().unwrap();
934 for e in 0..batch.num_envs() {
935 // Step 0, dim 1: obs[env,0,1] = env*10 + 1.
936 let idx = (e * num_steps) * obs_dim + 1;
937 let env = ((obs[idx] as usize) - 1) / 10;
938 assert!(seen.insert(env), "env {} appeared twice", env);
939 total += 1;
940 }
941 }
942 assert_eq!(total, num_envs, "every env covered exactly once");
943 assert_eq!(seen.len(), num_envs);
944 // ceil(7 / 3) == 3 minibatches.
945 assert_eq!(n_batches, 3);
946 }
947
948 /// Sampler minibatch shapes: `obs_seq` is `[envs_per_minibatch, T,
949 /// obs_dim]` and `episode_starts` is `[envs_per_minibatch, T]` for a
950 /// full chunk.
951 #[test]
952 fn test_sampler_minibatch_shape() {
953 let (num_steps, num_envs, obs_dim) = (6, 4, 3);
954 let buf = fill_buffer(num_steps, num_envs, obs_dim);
955 let dev = device();
956 // 4 envs / 2 per minibatch => exactly 2 full chunks.
957 let batches: Vec<_> = buf.to_minibatches::<B>(2, false, &dev).collect();
958 assert_eq!(batches.len(), 2);
959 for batch in &batches {
960 assert_eq!(batch.obs_seq.dims(), [2, num_steps, obs_dim]);
961 assert_eq!(batch.episode_starts.dims(), [2, num_steps]);
962 assert_eq!(batch.actions.dims(), [2, num_steps]);
963 }
964 // Unshuffled order: first chunk = envs {0,1}, second = {2,3}.
965 let obs0: Vec<f32> = batches[0].obs_seq.clone().into_data().to_vec().unwrap();
966 // obs[env=0, step=0, dim=0] == 0.
967 assert_eq!(obs0[0], 0.0);
968 }
969
970 /// End-to-end: the batch fields feed
971 /// `LstmBurnPolicy::evaluate_sequences` with no shape adapters, and the
972 /// three outputs come back `[N_env, T]`.
973 #[test]
974 fn test_evaluate_sequences_integration() {
975 use crate::policy::lstm::{LstmBurnConfig, LstmBurnPolicy};
976 type AB = burn::backend::Autodiff<NdArray<f32>>;
977
978 let (num_steps, num_envs, obs_dim, action_dim) = (5, 3, 4, 2);
979 let dev = crate::utils::cuda::default_burn_device::<AB>();
980
981 let hidden_dim = 8;
982 let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
983 for step in 0..num_steps {
984 for env in 0..num_envs {
985 let obs: Vec<f32> = (0..obs_dim).map(|d| 0.1 * (step + env + d) as f32).collect();
986 let term = env == 0 && step == 3;
987 buf.add(step, env, &obs, (env % action_dim) as i64, 0.0, 0.0, 0.0, term, false);
988 }
989 }
990
991 let batch = buf.to_sequence_batch::<AB>(&dev);
992 let cfg = LstmBurnConfig { hidden_dim, ..Default::default() }.with_seed(11);
993 let policy = LstmBurnPolicy::<AB>::with_config(obs_dim, action_dim, cfg, &dev);
994
995 // No adapters: pass the batch fields straight through. `None`
996 // initial state — Strategy A resets internally via episode_starts.
997 let (log_probs, entropy, values) =
998 policy.evaluate_sequences(batch.obs_seq, batch.actions, None, batch.episode_starts);
999 assert_eq!(log_probs.dims(), [num_envs, num_steps]);
1000 assert_eq!(entropy.dims(), [num_envs, num_steps]);
1001 assert_eq!(values.dims(), [num_envs, num_steps]);
1002 }
1003}