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}