Skip to main content

thrust_rl/policy/
lstm.rs

1//! Burn-backend LSTM (recurrent) actor-critic policy.
2//!
3//! Phase 1 of the recurrent-policy epic (#262). Implements
4//! [`LstmBurnPolicy`](crate::policy::lstm::LstmBurnPolicy), a concrete
5//! `#[derive(Module)]` struct that mirrors
6//! [`crate::policy::mlp::MlpBurnPolicy`] but replaces the feedforward
7//! trunk with a Burn 0.21 [`Lstm`](burn::nn::Lstm) so the policy can carry
8//! memory across timesteps. As with the MLP policy there is **no** formal
9//! `Policy` trait — each policy is a standalone Burn module.
10//!
11//! # Architecture
12//!
13//! ```text
14//! obs [.., seq, obs_dim] → Lstm → [.., seq, hidden] ┬─ policy_head → logits
15//!                                                    └─ value_head  → V(s)
16//! ```
17//!
18//! # Entry points
19//!
20//! - [`LstmBurnPolicy::forward_step`](crate::policy::lstm::LstmBurnPolicy::forward_step) — single-step inference for rollout
21//!   collection. Threads the recurrent state `(h, c)` in and out so the caller
22//!   can carry it across environment steps.
23//! - [`LstmBurnPolicy::evaluate_sequences`](crate::policy::lstm::LstmBurnPolicy::evaluate_sequences) — the rank-3 training forward. Runs
24//!   the LSTM over an entire `[n_env, T, obs_dim]` batch of trajectories,
25//!   resetting the hidden/cell state to zero at **episode boundaries**
26//!   (`episode_starts = terminated || truncated`).
27//!
28//! # Seeded initialization
29//!
30//! Burn's [`LstmConfig`](burn::nn::LstmConfig) initializer takes no seed, so
31//! two constructions draw different weights. To honor the reproducibility
32//! contract shared with [`crate::policy::mlp::MlpBurnConfig::seed`], the seeded
33//! path overrides all **eight** gate `Linear` layers (four
34//! [`burn::nn::GateController`]s × `{input_transform, hidden_transform}`)
35//! plus the two heads from deterministic, `StdRng`-driven weight buffers
36//! via the existing `seeded_layer_weights` / `derive_layer_seed`
37//! helpers. `GateController`'s fields are `pub`, so the swap is plain
38//! post-`init` field assignment — no `unsafe`, no new seeded-init
39//! primitive (see `docs/RECURRENT_POLICY_DESIGN.md`, Q4).
40
41use burn::{
42    module::Module,
43    nn::{Initializer, Linear, Lstm, LstmConfig, LstmState},
44    tensor::{Int, Tensor, activation, backend::Backend},
45};
46
47use crate::policy::mlp::{
48    derive_layer_seed, linear_from_weights, linear_with_init, seeded_layer_weights,
49};
50
51/// Configuration for [`LstmBurnPolicy`].
52///
53/// Mirrors [`crate::policy::mlp::MlpBurnConfig`]'s knobs that carry over
54/// to a recurrent trunk. The LSTM has no depth knob (a single recurrent
55/// layer for v1) and no activation knob (the gate/cell/hidden
56/// activations are fixed by the LSTM cell equations).
57#[derive(Debug, Clone, Copy)]
58pub struct LstmBurnConfig {
59    /// Width of the LSTM hidden/cell state (and therefore the feature
60    /// vector fed to both heads).
61    pub hidden_dim: usize,
62    /// If `true`, use the PPO orthogonal recipe (gain `sqrt(2)` on the
63    /// gate weights, `0.01` on the output heads). If `false`, fall back
64    /// to Kaiming-uniform (seeded path) or Burn's default `XavierNormal`
65    /// (unseeded path). Only affects the *distribution* the weights are
66    /// drawn from, never the reproducibility guarantee.
67    pub use_orthogonal_init: bool,
68    /// Optional construction seed. When `Some`, `with_config` builds
69    /// every gate `Linear` and both heads from a deterministic,
70    /// [`StdRng`](rand::rngs::StdRng)-driven weight buffer instead of
71    /// Burn's unseedable [`Initializer`], so two constructions with the
72    /// same seed produce **bit-identical** policies. When `None` the
73    /// LSTM uses Burn's default `XavierNormal` gate init (unseeded).
74    pub seed: Option<u64>,
75}
76
77impl Default for LstmBurnConfig {
78    fn default() -> Self {
79        Self { hidden_dim: 64, use_orthogonal_init: true, seed: None }
80    }
81}
82
83impl LstmBurnConfig {
84    /// Set the construction seed, enabling the deterministic host-side
85    /// init path in [`LstmBurnPolicy::with_config`].
86    ///
87    /// Builder-style; returns `self` for chaining:
88    /// `LstmBurnConfig::default().with_seed(42)`.
89    pub fn with_seed(mut self, seed: u64) -> Self {
90        self.seed = Some(seed);
91        self
92    }
93}
94
95/// Single-layer LSTM actor-critic for **discrete** action spaces.
96///
97/// Both heads share the recurrent trunk — standard recurrent PPO
98/// actor-critic. Derives Burn [`Module`] (and, transitively, the
99/// `AutodiffModule` impl the training path needs) so gradients flow back
100/// through the LSTM gates.
101#[derive(Module, Debug)]
102pub struct LstmBurnPolicy<B: Backend> {
103    lstm: Lstm<B>,
104    policy_head: Linear<B>,
105    value_head: Linear<B>,
106}
107
108impl<B: Backend> LstmBurnPolicy<B> {
109    /// Convenience constructor with the default configuration
110    /// (`hidden_dim = 64`, orthogonal init, unseeded).
111    pub fn new(obs_dim: usize, action_dim: usize, hidden_dim: usize, device: &B::Device) -> Self {
112        let config = LstmBurnConfig { hidden_dim, ..Default::default() };
113        Self::with_config(obs_dim, action_dim, config, device)
114    }
115
116    /// Build a fresh recurrent policy on `device` with the given
117    /// configuration.
118    ///
119    /// When `config.seed` is `Some`, every gate `Linear` (eight total)
120    /// and both heads are built from deterministic `StdRng`-driven weight
121    /// buffers so two constructions with the same seed are bit-identical.
122    /// Gate `Linear` layer indices are fixed so distinct-but-same-shape
123    /// layers (e.g. every `hidden_transform`) draw decorrelated streams:
124    ///
125    /// ```text
126    /// 0 input_gate.input     1 input_gate.hidden
127    /// 2 forget_gate.input    3 forget_gate.hidden
128    /// 4 cell_gate.input      5 cell_gate.hidden
129    /// 6 output_gate.input    7 output_gate.hidden
130    /// 8 policy_head          9 value_head
131    /// ```
132    pub fn with_config(
133        obs_dim: usize,
134        action_dim: usize,
135        config: LstmBurnConfig,
136        device: &B::Device,
137    ) -> Self {
138        let hidden = config.hidden_dim;
139        // Build the LSTM with bias enabled. On the seeded path the gate
140        // weights are overwritten below; on the unseeded path Burn's
141        // default `XavierNormal` gate init is used verbatim (it handles
142        // the rank-1 bias, unlike `Orthogonal`, which is why we don't
143        // route orthogonal gate init through `LstmConfig`).
144        let mut lstm = LstmConfig::new(obs_dim, hidden, true).init::<B>(device);
145
146        if let Some(seed) = config.seed {
147            let orth = config.use_orthogonal_init;
148            // Overwrite all eight gate `Linear`s from seeded buffers.
149            // `GateController`'s fields are `pub`, so this is plain
150            // field assignment (see design note Q4). `is_head = false`
151            // for gate layers.
152            let mk = |idx: u64, d_in: usize, d_out: usize| -> Linear<B> {
153                let s = derive_layer_seed(seed, idx);
154                let w = seeded_layer_weights(s, d_in, d_out, orth, false);
155                linear_from_weights::<B>(d_in, d_out, &w, device)
156            };
157            lstm.input_gate.input_transform = mk(0, obs_dim, hidden);
158            lstm.input_gate.hidden_transform = mk(1, hidden, hidden);
159            lstm.forget_gate.input_transform = mk(2, obs_dim, hidden);
160            lstm.forget_gate.hidden_transform = mk(3, hidden, hidden);
161            lstm.cell_gate.input_transform = mk(4, obs_dim, hidden);
162            lstm.cell_gate.hidden_transform = mk(5, hidden, hidden);
163            lstm.output_gate.input_transform = mk(6, obs_dim, hidden);
164            lstm.output_gate.hidden_transform = mk(7, hidden, hidden);
165
166            let mk_head = |idx: u64, d_in: usize, d_out: usize| -> Linear<B> {
167                let s = derive_layer_seed(seed, idx);
168                let w = seeded_layer_weights(s, d_in, d_out, orth, true);
169                linear_from_weights::<B>(d_in, d_out, &w, device)
170            };
171            let policy_head = mk_head(8, hidden, action_dim);
172            let value_head = mk_head(9, hidden, 1);
173            return Self { lstm, policy_head, value_head };
174        }
175
176        // Unseeded path: Burn default gate init (already applied above),
177        // orthogonal (or Kaiming) heads via the shared `linear_with_init`
178        // helper, which zeroes the bias and thus tolerates `Orthogonal`.
179        let head_init = if config.use_orthogonal_init {
180            Initializer::Orthogonal { gain: 0.01 }
181        } else {
182            Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
183        };
184        let policy_head = linear_with_init::<B>(hidden, action_dim, head_init.clone(), device);
185        let value_head = linear_with_init::<B>(hidden, 1, head_init, device);
186        Self { lstm, policy_head, value_head }
187    }
188
189    /// Hidden/cell state width (`config.hidden_dim`).
190    pub fn hidden_dim(&self) -> usize {
191        self.lstm.d_hidden
192    }
193
194    /// Action-head output dimensionality (number of discrete actions).
195    ///
196    /// Reads the `policy_head` weight shape (`[hidden, action_dim]`), the
197    /// same trick [`crate::policy::mlp::MlpBurnPolicy::policy_head_action_dim`]
198    /// uses to size buffers without consuming RNG.
199    pub fn policy_head_action_dim(&self) -> usize {
200        self.policy_head.weight.val().dims()[1]
201    }
202
203    /// Single-step forward for rollout collection.
204    ///
205    /// * `obs` — one timestep, shape `[batch, obs_dim]`.
206    /// * `state` — the incoming recurrent state; `None` starts from a zeroed
207    ///   `(h, c)`.
208    ///
209    /// Returns `(logits, value, new_state)`:
210    /// * `logits` — `[batch, action_dim]` (pre-softmax).
211    /// * `value` — `[batch]` (squeezed from `[batch, 1]`).
212    /// * `new_state` — [`LstmState`] with `cell`/`hidden` each `[batch,
213    ///   hidden_dim]`, to thread into the next step.
214    pub fn forward_step(
215        &self,
216        obs: Tensor<B, 2>,
217        state: Option<LstmState<B, 2>>,
218    ) -> (Tensor<B, 2>, Tensor<B, 1>, LstmState<B, 2>) {
219        // Add a length-1 sequence axis: [batch, 1, obs_dim].
220        let obs_seq = obs.unsqueeze_dim::<3>(1);
221        let (out, new_state) = self.lstm.forward(obs_seq, state);
222        // [batch, 1, hidden] → [batch, hidden].
223        let feats = out.squeeze_dim::<2>(1);
224        let logits = self.policy_head.forward(feats.clone());
225        let value = self.value_head.forward(feats).squeeze_dim::<1>(1);
226        (logits, value, new_state)
227    }
228
229    /// Rank-3 training forward over full trajectories.
230    ///
231    /// Runs the LSTM step-by-step across the `T` axis, resetting the
232    /// hidden/cell state to zero at **episode boundaries** so stale memory
233    /// never leaks across the boundary into a fresh episode.
234    ///
235    /// * `obs_seq` — `[n_env, T, obs_dim]`. One env-trajectory per row,
236    ///   temporally intact (Strategy A, see design note Q2).
237    /// * `actions` — `[n_env, T]` discrete actions actually taken, used to
238    ///   gather their log-probabilities.
239    /// * `initial_state` — recurrent state entering step 0; `None` starts from
240    ///   a zeroed `(h, c)`.
241    /// * `episode_starts` — `[n_env, T]` per-step episode-boundary flags (`1.0`
242    ///   = reset, `0.0` = carry). These encode `terminated || truncated`,
243    ///   **not** terminated alone. Thrust's rollout resets envs on either flag
244    ///   and CartPole/MaskedCartPole truncate at `max_steps = 500`, so a
245    ///   truncated step still ends the episode and its state must reset. This
246    ///   is the deliberate asymmetry with GAE, which bootstraps on `terminated`
247    ///   only (see `docs/RECURRENT_POLICY_DESIGN.md`, Q2 "GAE bootstrapping vs.
248    ///   hidden-state reset"). At each step `t`, if `episode_starts[:, t]` is
249    ///   `1.0` the incoming `(h, c)` is zeroed *before* the step is processed.
250    ///
251    /// Returns `(action_log_probs, entropy, values)`, each `[n_env, T]`:
252    /// the per-step quantities the recurrent PPO surrogate needs. Entropy
253    /// is per-step (not reduced); the trainer decides how to aggregate.
254    pub fn evaluate_sequences(
255        &self,
256        obs_seq: Tensor<B, 3>,
257        actions: Tensor<B, 2, Int>,
258        initial_state: Option<LstmState<B, 2>>,
259        episode_starts: Tensor<B, 2>,
260    ) -> (Tensor<B, 2>, Tensor<B, 2>, Tensor<B, 2>) {
261        let device = obs_seq.device();
262        let [n_env, seq_len, obs_dim] = obs_seq.dims();
263        let hidden = self.hidden_dim();
264
265        // Recurrent state carried across the T loop. `None` ⇒ zeros.
266        let (mut cell, mut hidden_state) = match initial_state {
267            Some(s) => (s.cell, s.hidden),
268            None => (
269                Tensor::<B, 2>::zeros([n_env, hidden], &device),
270                Tensor::<B, 2>::zeros([n_env, hidden], &device),
271            ),
272        };
273
274        let mut feats: Vec<Tensor<B, 3>> = Vec::with_capacity(seq_len);
275        for t in 0..seq_len {
276            // `keep = 1 - episode_starts[:, t]`, shape [n_env, 1]. Zeroing
277            // the state per-env (rather than resetting the whole batch)
278            // is what lets one minibatch hold envs at different episode
279            // phases; the [n_env, 1] factor broadcasts over `hidden`.
280            let flag = episode_starts.clone().slice([0..n_env, t..(t + 1)]);
281            let keep = flag.neg().add_scalar(1.0);
282            cell = cell.mul(keep.clone());
283            hidden_state = hidden_state.mul(keep);
284
285            // Single-step LSTM: [n_env, 1, obs_dim] → [n_env, 1, hidden].
286            let step_in = obs_seq.clone().slice([0..n_env, t..(t + 1), 0..obs_dim]);
287            let (out, new_state) =
288                self.lstm.forward(step_in, Some(LstmState::new(cell, hidden_state)));
289            feats.push(out);
290            cell = new_state.cell;
291            hidden_state = new_state.hidden;
292        }
293
294        // [n_env, T, hidden].
295        let features = Tensor::cat(feats, 1);
296        let logits = self.policy_head.forward(features.clone());
297        // [n_env, T, 1] → [n_env, T].
298        let values = self.value_head.forward(features).squeeze_dim::<2>(2);
299
300        // Categorical log-probs over the action axis (dim 2).
301        let log_probs_all = activation::log_softmax(logits, 2);
302        let probs = log_probs_all.clone().exp();
303        let action_log_probs = log_probs_all
304            .clone()
305            .gather(2, actions.unsqueeze_dim::<3>(2))
306            .squeeze_dim::<2>(2);
307        // H = -Σ p·log p over the action axis.
308        let entropy = -(probs * log_probs_all).sum_dim(2).squeeze_dim::<2>(2);
309
310        (action_log_probs, entropy, values)
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use burn::backend::{Autodiff, NdArray};
317
318    use super::*;
319
320    type B = Autodiff<NdArray<f32>>;
321
322    /// Unseeded construction succeeds and a single-step forward produces
323    /// output tensors of the correct rank/shape.
324    #[test]
325    fn test_constructs_unseeded() {
326        let device = Default::default();
327        let policy = LstmBurnPolicy::<B>::with_config(4, 2, LstmBurnConfig::default(), &device);
328        assert_eq!(policy.hidden_dim(), 64);
329        assert_eq!(policy.policy_head_action_dim(), 2);
330
331        let obs = Tensor::<B, 2>::zeros([3, 4], &device);
332        let (logits, value, _state) = policy.forward_step(obs, None);
333        assert_eq!(logits.dims(), [3, 2]);
334        assert_eq!(value.dims(), [3]);
335    }
336
337    /// `forward_step(obs, None)` returns logits `[1, action_dim]`, value
338    /// `[1]`, and a new state whose `cell`/`hidden` are each
339    /// `[1, hidden_dim]` for a single observation.
340    #[test]
341    fn test_forward_step_shapes() {
342        let device = Default::default();
343        let cfg = LstmBurnConfig { hidden_dim: 8, ..Default::default() };
344        let policy = LstmBurnPolicy::<B>::with_config(4, 3, cfg, &device);
345
346        let obs = Tensor::<B, 2>::from_data(
347            burn::tensor::TensorData::new(vec![0.1_f32, 0.2, 0.3, 0.4], [1, 4]),
348            &device,
349        );
350        let (logits, value, state) = policy.forward_step(obs, None);
351        assert_eq!(logits.dims(), [1, 3]);
352        assert_eq!(value.dims(), [1]);
353        assert_eq!(state.cell.dims(), [1, 8]);
354        assert_eq!(state.hidden.dims(), [1, 8]);
355    }
356
357    /// `evaluate_sequences` returns the three per-step quantities with the
358    /// expected `[n_env, T]` shape.
359    #[test]
360    fn test_evaluate_sequences_shapes() {
361        let device = Default::default();
362        let cfg = LstmBurnConfig { hidden_dim: 8, ..Default::default() };
363        let policy = LstmBurnPolicy::<B>::with_config(4, 2, cfg, &device);
364
365        let obs_seq = Tensor::<B, 3>::zeros([2, 3, 4], &device); // [n_env=2, T=3, obs=4]
366        let actions = Tensor::<B, 2, Int>::from_data(
367            burn::tensor::TensorData::new(vec![0i64, 1, 0, 1, 0, 1], [2, 3]),
368            &device,
369        );
370        let episode_starts = Tensor::<B, 2>::zeros([2, 3], &device);
371        let (log_probs, entropy, values) =
372            policy.evaluate_sequences(obs_seq, actions, None, episode_starts);
373        assert_eq!(log_probs.dims(), [2, 3]);
374        assert_eq!(entropy.dims(), [2, 3]);
375        assert_eq!(values.dims(), [2, 3]);
376    }
377
378    /// Flatten every gate `Linear` (8) + both heads (2) weight and bias
379    /// into one comparison vector (test helper for the bit-identity
380    /// assertions below).
381    fn collect_params(p: &LstmBurnPolicy<B>) -> Vec<f32> {
382        let mut out = Vec::new();
383        let mut push = |lin: &Linear<B>| {
384            out.extend::<Vec<f32>>(lin.weight.val().into_data().to_vec().unwrap());
385            if let Some(b) = &lin.bias {
386                out.extend::<Vec<f32>>(b.val().into_data().to_vec().unwrap());
387            }
388        };
389        push(&p.lstm.input_gate.input_transform);
390        push(&p.lstm.input_gate.hidden_transform);
391        push(&p.lstm.forget_gate.input_transform);
392        push(&p.lstm.forget_gate.hidden_transform);
393        push(&p.lstm.cell_gate.input_transform);
394        push(&p.lstm.cell_gate.hidden_transform);
395        push(&p.lstm.output_gate.input_transform);
396        push(&p.lstm.output_gate.hidden_transform);
397        push(&p.policy_head);
398        push(&p.value_head);
399        out
400    }
401
402    /// Two seeded constructions with the same seed produce bit-identical
403    /// weights across all 8 gate `Linear`s and both heads; a different
404    /// seed differs. Mirrors `test_with_seed_is_bit_identical_orthogonal`
405    /// in `mlp.rs` — the reproducibility contract Phase 2/3 depend on.
406    #[test]
407    fn test_with_seed_is_bit_identical() {
408        let device = Default::default();
409        let cfg = LstmBurnConfig { hidden_dim: 8, ..Default::default() }.with_seed(42);
410        let a = LstmBurnPolicy::<B>::with_config(4, 2, cfg, &device);
411        let b = LstmBurnPolicy::<B>::with_config(4, 2, cfg, &device);
412        assert_eq!(collect_params(&a), collect_params(&b), "same seed must be bit-identical");
413
414        let cfg_diff = LstmBurnConfig { hidden_dim: 8, ..Default::default() }.with_seed(43);
415        let c = LstmBurnPolicy::<B>::with_config(4, 2, cfg_diff, &device);
416        assert_ne!(collect_params(&a), collect_params(&c), "different seed must differ");
417    }
418
419    /// Same-shape gate layers must draw decorrelated seeded streams: the
420    /// four `hidden_transform`s (all `[hidden, hidden]`) must not be
421    /// identical. Guards against a per-layer-seed derivation bug.
422    #[test]
423    fn test_seeded_gates_are_decorrelated() {
424        let device = Default::default();
425        let cfg = LstmBurnConfig { hidden_dim: 6, ..Default::default() }.with_seed(1);
426        let p = LstmBurnPolicy::<B>::with_config(4, 2, cfg, &device);
427        let i: Vec<f32> =
428            p.lstm.input_gate.hidden_transform.weight.val().into_data().to_vec().unwrap();
429        let f: Vec<f32> =
430            p.lstm.forget_gate.hidden_transform.weight.val().into_data().to_vec().unwrap();
431        assert_ne!(i, f, "same-shape gate layers must get distinct seeded weights");
432    }
433
434    /// Hidden state resets to zeros at an episode boundary
435    /// (`episode_starts[:, 1] = 1`) and carries forward otherwise
436    /// (`episode_starts[:, 1] = 0`).
437    ///
438    /// Construction:
439    /// - A 2-step sequence with a nonzero step-0 observation (so step 0
440    ///   produces a nonzero recurrent state).
441    /// - When step 1 is flagged as an episode start, its output must equal a
442    ///   fresh single-step forward from a zeroed state (`forward_step(o1,
443    ///   None)`), proving the carried state was zeroed.
444    /// - When step 1 is *not* flagged, its output must differ (the step-0 state
445    ///   was carried in), proving reset is conditional.
446    #[test]
447    fn test_evaluate_sequences_episode_boundary_reset() {
448        let device = Default::default();
449        // Seeded so weights are fixed across the three forwards compared.
450        let cfg = LstmBurnConfig { hidden_dim: 8, ..Default::default() }.with_seed(7);
451        let policy = LstmBurnPolicy::<B>::with_config(4, 2, cfg, &device);
452
453        // obs_seq [1, 2, 4]: distinct nonzero steps.
454        let o0 = [0.9_f32, -0.4, 0.6, 0.2];
455        let o1 = [0.1_f32, 0.5, -0.3, 0.8];
456        let seq_data: Vec<f32> = o0.iter().chain(o1.iter()).copied().collect();
457        let obs_seq =
458            Tensor::<B, 3>::from_data(burn::tensor::TensorData::new(seq_data, [1, 2, 4]), &device);
459        let actions = Tensor::<B, 2, Int>::from_data(
460            burn::tensor::TensorData::new(vec![0i64, 0], [1, 2]),
461            &device,
462        );
463
464        // Reset at step 1: episode_starts = [0, 1].
465        let starts_reset = Tensor::<B, 2>::from_data(
466            burn::tensor::TensorData::new(vec![0.0_f32, 1.0], [1, 2]),
467            &device,
468        );
469        let (_, _, values_reset) =
470            policy.evaluate_sequences(obs_seq.clone(), actions.clone(), None, starts_reset);
471        let v_reset: Vec<f32> = values_reset.into_data().to_vec().unwrap();
472
473        // Fresh single-step forward from zeroed state on o1.
474        let o1_tensor =
475            Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(o1.to_vec(), [1, 4]), &device);
476        let (_, value_fresh, _) = policy.forward_step(o1_tensor, None);
477        let v_fresh: Vec<f32> = value_fresh.into_data().to_vec().unwrap();
478
479        // Step-1 value under reset must equal the fresh (zero-state) value.
480        assert!(
481            (v_reset[1] - v_fresh[0]).abs() < 1e-5,
482            "reset step-1 value {} should match fresh zero-state value {}",
483            v_reset[1],
484            v_fresh[0]
485        );
486
487        // No reset at step 1: episode_starts = [0, 0]. The step-0 state is
488        // carried in, so the step-1 value must differ from the reset case.
489        let starts_carry = Tensor::<B, 2>::zeros([1, 2], &device);
490        let (_, _, values_carry) = policy.evaluate_sequences(obs_seq, actions, None, starts_carry);
491        let v_carry: Vec<f32> = values_carry.into_data().to_vec().unwrap();
492        assert!(
493            (v_carry[1] - v_reset[1]).abs() > 1e-6,
494            "carried step-1 value {} should differ from reset value {} (stale state must flow)",
495            v_carry[1],
496            v_reset[1]
497        );
498    }
499}