Skip to main content

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}