thrust_rl/env/games/t_maze.rs
1//! T-maze — the canonical memory-hard POMDP benchmark (Bakker 2001).
2//!
3//! Part of the recurrent-policy epic (#262), extending the memory-hard
4//! environment suite alongside
5//! [`FlickeringCartPole`](super::FlickeringCartPole) (#298). Where flickering
6//! CartPole is only *approximately* memory-hard — a reactive controller
7//! partially compensates at CartPole's control rate — the T-maze is provably
8//! memory-hard: a memoryless policy is at chance (50%) at the junction
9//! regardless of capacity. This is the clean qualitative contrast that
10//! `MaskedCartPole` failed to provide (#287's negative result).
11//!
12//! # The task
13//!
14//! The agent traverses a straight corridor of length `N` and, at the far end,
15//! reaches a T-junction where it must turn **up** or **down**. Which turn is
16//! rewarded is signalled by a **cue** shown *only at the start* (step 0). To
17//! act correctly at the junction the agent must carry the cue across the entire
18//! corridor in memory. From Bakker, *"Reinforcement Learning with Long
19//! Short-Term Memory"* (NeurIPS 2001).
20//!
21//! - **Corridor length `N`** (configurable, default [`DEFAULT_CORRIDOR_LENGTH`]
22//! = 10). The episode is exactly `N + 1` steps long: the agent observes the
23//! cue at step 0, walks the corridor over steps `1..N`, and makes the
24//! junction decision on the observation seen at step `N`. The memory span the
25//! policy must bridge is therefore `N` steps.
26//! - **Action:** `i64`, two discrete choices: `0 = up`, `1 = down`. The action
27//! is only consequential at the junction; while in the corridor the agent
28//! auto-advances and the action is ignored (a no-op). This isolates the
29//! memory variable — corridor navigation is trivial and identical for every
30//! policy, so the *only* thing that separates a memoryful from a memoryless
31//! policy is recalling the cue.
32//! - **Observation:** a fixed 3-D `Vec<f32>` `[cue, corridor, junction]`:
33//! - `cue` is `+1.0` (turn up) or `-1.0` (turn down) **only at step 0**; it
34//! is `0.0` at every subsequent step. This is the memory-load-bearing
35//! channel.
36//! - `corridor` is `1.0` while the agent is in the corridor (steps `0..N-1`)
37//! and `0.0` at the junction.
38//! - `junction` is `1.0` at the junction (step `N`) and `0.0` otherwise.
39//! - **Reward:** `0.0` for every corridor step; at the junction,
40//! [`REWARD_CORRECT`] (`+1.0`) if the chosen turn matches the cue, else
41//! [`REWARD_WRONG`] (`-1.0`). Episode return is therefore exactly `+1`
42//! (correct) or `-1` (wrong), so the mean return maps directly to junction
43//! accuracy: `acc = (mean_return + 1) / 2`.
44//! - **Termination:** the episode `terminated`s the moment the junction
45//! decision is taken (step `N`). There is no truncation in the default
46//! configuration — the fixed-length corridor always ends at the junction.
47//!
48//! # Why a memoryless policy is provably at chance
49//!
50//! The junction observation is `[0, 0, 1]` — **identical for both cue values**.
51//! The cue appears in the observation stream exactly once, at step 0, and the
52//! cue is drawn independently and uniformly (50/50) each episode. A memoryless
53//! policy conditions its junction action only on the current observation
54//! `[0, 0, 1]`, which is a constant independent of the cue; therefore
55//! `P(correct) = P(cue = chosen turn) = 0.5` for *any* memoryless policy,
56//! regardless of network capacity. This is a hard information-theoretic bound,
57//! not an optimization artefact.
58//!
59//! **This is also a self-test on the environment.** If a feedforward baseline
60//! trained on this env beats 50% junction accuracy by a statistically
61//! meaningful margin, the environment has an information leak (e.g. the cue
62//! bleeding into a later observation) and that is a bug in the env, not a
63//! finding about the policy. The unit tests below assert the no-leak property
64//! directly.
65//!
66//! # Seeding and determinism
67//!
68//! The cue is drawn from a dedicated seeded [`StdRng`]. Two `TMaze`s built with
69//! [`TMaze::with_seed`] (same seed, same corridor length) produce the identical
70//! cue sequence across resets, independent of the actions taken. The
71//! [`TMazeState`] snapshot captures the position, cue, step counter, **and**
72//! the cue RNG, so [`Environment::restore_state`] followed by further resets
73//! reproduces the same cue stream — the same strong determinism guarantee as
74//! [`FlickeringCartPole`](super::FlickeringCartPole).
75
76use rand::{Rng, SeedableRng, rngs::StdRng};
77
78use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
79
80/// Default corridor length. At `N = 10` the memory span is 10 steps — the
81/// canonical Bakker setting where an LSTM clears >90% junction accuracy while a
82/// memoryless policy is pinned at chance.
83pub const DEFAULT_CORRIDOR_LENGTH: usize = 10;
84
85/// Default seed used by [`TMaze::new`].
86pub const DEFAULT_SEED: u64 = 0;
87
88/// Number of discrete actions (`0 = up`, `1 = down`).
89pub const NUM_ACTIONS: usize = 2;
90
91/// Observation dimensionality: `[cue, corridor, junction]`.
92pub const OBS_DIM: usize = 3;
93
94/// Reward for taking the junction turn that matches the cue.
95pub const REWARD_CORRECT: f32 = 1.0;
96
97/// Reward for taking the junction turn that does not match the cue.
98pub const REWARD_WRONG: f32 = -1.0;
99
100/// The cue shown at the start of the corridor: which way to turn at the
101/// junction.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Cue {
104 /// Turn up at the junction (rewarded action is `0`).
105 Up,
106 /// Turn down at the junction (rewarded action is `1`).
107 Down,
108}
109
110impl Cue {
111 /// The action index that is rewarded for this cue (`0 = up`, `1 = down`).
112 pub fn correct_action(self) -> i64 {
113 match self {
114 Cue::Up => 0,
115 Cue::Down => 1,
116 }
117 }
118
119 /// The value written into the `cue` observation channel at step 0
120 /// (`+1.0` for up, `-1.0` for down).
121 fn signal(self) -> f32 {
122 match self {
123 Cue::Up => 1.0,
124 Cue::Down => -1.0,
125 }
126 }
127}
128
129/// Snapshot of a [`TMaze`]: position, cue, step counter, and the cue RNG.
130///
131/// Because the RNG is captured, restoring a snapshot and then calling
132/// [`Environment::reset`] reproduces the subsequent cue stream exactly, in
133/// addition to the fully deterministic corridor dynamics.
134#[derive(Debug, Clone)]
135pub struct TMazeState {
136 /// Position along the corridor in `0..=N` (`N` is the junction).
137 position: usize,
138 /// The cue for the current episode.
139 cue: Cue,
140 /// Cue RNG state at snapshot time.
141 rng: StdRng,
142}
143
144/// T-maze — the provably memory-hard POMDP benchmark (Bakker 2001).
145///
146/// See the [module docs](self) for the full task, observation, reward, and
147/// determinism specification.
148#[derive(Debug)]
149pub struct TMaze {
150 /// Corridor length `N`. The junction is at position `N`.
151 corridor_length: usize,
152 /// Current position along the corridor in `0..=N`.
153 position: usize,
154 /// The cue for the current episode, drawn on [`Environment::reset`].
155 cue: Cue,
156 /// Dedicated cue RNG for reproducible cue sequences.
157 rng: StdRng,
158}
159
160impl TMaze {
161 /// Create a T-maze with the default corridor length
162 /// ([`DEFAULT_CORRIDOR_LENGTH`] = 10) and a **seeded** cue RNG for a
163 /// reproducible cue sequence.
164 pub fn new() -> Self {
165 Self::with_seed_and_corridor_length(DEFAULT_SEED, DEFAULT_CORRIDOR_LENGTH)
166 }
167
168 /// Create a T-maze with a custom corridor length and the default seed.
169 ///
170 /// # Panics
171 ///
172 /// Panics if `corridor_length` is zero. A zero-length corridor would place
173 /// the cue and the junction at the same step, leaking the cue into the
174 /// junction observation and destroying the memory task.
175 pub fn with_corridor_length(corridor_length: usize) -> Self {
176 Self::with_seed_and_corridor_length(DEFAULT_SEED, corridor_length)
177 }
178
179 /// Create a T-maze with the default corridor length and a custom seed.
180 pub fn with_seed(seed: u64) -> Self {
181 Self::with_seed_and_corridor_length(seed, DEFAULT_CORRIDOR_LENGTH)
182 }
183
184 /// Create a T-maze with a custom seed and corridor length.
185 ///
186 /// Two instances built with the same `seed` and `corridor_length` draw the
187 /// same cue on every reset, independent of the actions taken.
188 ///
189 /// # Panics
190 ///
191 /// Panics if `corridor_length` is zero (see
192 /// [`TMaze::with_corridor_length`]).
193 pub fn with_seed_and_corridor_length(seed: u64, corridor_length: usize) -> Self {
194 assert!(
195 corridor_length >= 1,
196 "corridor_length must be >= 1 (a zero-length corridor leaks the cue into the junction observation), got {corridor_length}"
197 );
198 let mut env =
199 Self { corridor_length, position: 0, cue: Cue::Up, rng: StdRng::seed_from_u64(seed) };
200 env.reset();
201 env
202 }
203
204 /// The corridor length `N` (the junction is at position `N`).
205 pub fn corridor_length(&self) -> usize {
206 self.corridor_length
207 }
208
209 /// The cue for the current episode (primarily for diagnostics and tests).
210 pub fn cue(&self) -> Cue {
211 self.cue
212 }
213
214 /// The current position along the corridor in `0..=N`.
215 pub fn position(&self) -> usize {
216 self.position
217 }
218
219 /// Whether the agent is currently at the junction (`position == N`).
220 pub fn at_junction(&self) -> bool {
221 self.position == self.corridor_length
222 }
223}
224
225impl Default for TMaze {
226 fn default() -> Self {
227 Self::new()
228 }
229}
230
231impl Environment for TMaze {
232 /// Discrete junction action. `0 = up`, `1 = down`; only consequential at
233 /// the junction (ignored, as a no-op, while in the corridor).
234 type Action = i64;
235
236 /// Snapshot type capturing position, cue, and the cue RNG. See
237 /// [`TMazeState`].
238 type State = TMazeState;
239
240 fn reset(&mut self) {
241 self.position = 0;
242 // Draw the cue uniformly at random from the seeded RNG (exactly one draw
243 // per episode, so the cue sequence is a pure function of the seed).
244 self.cue = if self.rng.random::<bool>() {
245 Cue::Up
246 } else {
247 Cue::Down
248 };
249 }
250
251 fn get_observation(&self) -> Vec<f32> {
252 // [cue, corridor, junction].
253 // cue : nonzero ONLY at step 0 (position 0) — the memory channel.
254 // corridor : 1.0 while in the corridor (positions 0..N-1).
255 // junction : 1.0 at the junction (position N); constant across cues.
256 let cue = if self.position == 0 {
257 self.cue.signal()
258 } else {
259 0.0
260 };
261 let at_junction = self.position == self.corridor_length;
262 let corridor = if at_junction { 0.0 } else { 1.0 };
263 let junction = if at_junction { 1.0 } else { 0.0 };
264 vec![cue, corridor, junction]
265 }
266
267 fn step(&mut self, action: i64) -> StepResult {
268 if self.position < self.corridor_length {
269 // In the corridor: auto-advance one cell; the action is a no-op.
270 // Reward is zero and the episode continues.
271 self.position += 1;
272 StepResult {
273 observation: self.get_observation(),
274 reward: 0.0,
275 terminated: false,
276 truncated: false,
277 info: StepInfo::default(),
278 }
279 } else {
280 // At the junction: the action is the decision. Reward +1 if it
281 // matches the cue, -1 otherwise, and the episode terminates.
282 let reward = if action == self.cue.correct_action() {
283 REWARD_CORRECT
284 } else {
285 REWARD_WRONG
286 };
287 StepResult {
288 observation: self.get_observation(),
289 reward,
290 terminated: true,
291 truncated: false,
292 info: StepInfo::default(),
293 }
294 }
295 }
296
297 fn observation_space(&self) -> SpaceInfo {
298 SpaceInfo { shape: vec![OBS_DIM], space_type: SpaceType::Box }
299 }
300
301 fn action_space(&self) -> SpaceInfo {
302 SpaceInfo { shape: vec![NUM_ACTIONS], space_type: SpaceType::Discrete(NUM_ACTIONS) }
303 }
304
305 fn render(&self) -> Vec<u8> {
306 Vec::new()
307 }
308
309 fn close(&mut self) {}
310
311 fn clone_state(&self) -> TMazeState {
312 TMazeState { position: self.position, cue: self.cue, rng: self.rng.clone() }
313 }
314
315 fn restore_state(&mut self, state: &TMazeState) {
316 self.position = state.position;
317 self.cue = state.cue;
318 self.rng = state.rng.clone();
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 const UP: i64 = 0;
327 const DOWN: i64 = 1;
328
329 /// Walk the corridor from a fresh reset to the junction, returning the
330 /// observation seen at each step (including the post-reset observation) and
331 /// leaving the env poised at the junction.
332 fn walk_to_junction(env: &mut TMaze) -> Vec<Vec<f32>> {
333 env.reset();
334 let mut obs = vec![env.get_observation()];
335 for _ in 0..env.corridor_length() {
336 let r = env.step(UP);
337 obs.push(r.observation);
338 }
339 obs
340 }
341
342 #[test]
343 fn observation_space_is_three_dimensional_box() {
344 let env = TMaze::new();
345 let space = env.observation_space();
346 assert_eq!(space.shape, vec![OBS_DIM]);
347 assert!(matches!(space.space_type, SpaceType::Box));
348 }
349
350 #[test]
351 fn action_space_is_discrete_two() {
352 let env = TMaze::new();
353 let space = env.action_space();
354 assert_eq!(space.shape, vec![NUM_ACTIONS]);
355 assert!(matches!(space.space_type, SpaceType::Discrete(NUM_ACTIONS)));
356 }
357
358 #[test]
359 fn cue_is_only_visible_at_step_zero() {
360 // The cue channel (obs[0]) must be nonzero at step 0 and zero at every
361 // subsequent step, all the way to and including the junction.
362 for seed in 0..16u64 {
363 let mut env = TMaze::with_seed_and_corridor_length(seed, 10);
364 let obs = walk_to_junction(&mut env);
365 assert_ne!(obs[0][0], 0.0, "cue must be visible at step 0 (seed {seed})");
366 for (i, o) in obs.iter().enumerate().skip(1) {
367 assert_eq!(o[0], 0.0, "cue leaked at step {i} (seed {seed}): {o:?}");
368 }
369 }
370 }
371
372 #[test]
373 fn junction_observation_is_identical_across_cues() {
374 // The no-leak property: the junction observation must be the SAME for
375 // an up-cue episode and a down-cue episode. This is what makes a
376 // memoryless policy provably at chance.
377 let mut up_env = None;
378 let mut down_env = None;
379 // Find one seed producing each cue.
380 for seed in 0..64u64 {
381 let env = TMaze::with_seed_and_corridor_length(seed, 8);
382 match env.cue() {
383 Cue::Up if up_env.is_none() => up_env = Some(env),
384 Cue::Down if down_env.is_none() => down_env = Some(env),
385 _ => {}
386 }
387 if up_env.is_some() && down_env.is_some() {
388 break;
389 }
390 }
391 let mut up_env = up_env.expect("some seed yields an up cue");
392 let mut down_env = down_env.expect("some seed yields a down cue");
393 assert_eq!(up_env.cue(), Cue::Up);
394 assert_eq!(down_env.cue(), Cue::Down);
395
396 let up_obs = walk_to_junction(&mut up_env);
397 let down_obs = walk_to_junction(&mut down_env);
398 // Step 0 differs (the cue), every later step (incl. junction) matches.
399 assert_ne!(up_obs[0], down_obs[0], "cue must differ at step 0");
400 let junction_up = up_obs.last().unwrap();
401 let junction_down = down_obs.last().unwrap();
402 assert_eq!(
403 junction_up, junction_down,
404 "junction observation must be identical across cues (no leak)"
405 );
406 assert_eq!(junction_up, &vec![0.0, 0.0, 1.0], "junction obs is [0,0,1]");
407 }
408
409 #[test]
410 fn corridor_and_junction_channels_track_position() {
411 let mut env = TMaze::with_seed_and_corridor_length(3, 5);
412 let obs = walk_to_junction(&mut env);
413 // obs has N+1 entries (steps 0..=N). Corridor channel is 1 for the
414 // first N, then 0 at the junction; junction channel is the complement.
415 for (i, o) in obs.iter().enumerate() {
416 if i < env.corridor_length() {
417 assert_eq!(o[1], 1.0, "corridor channel should be 1 at step {i}");
418 assert_eq!(o[2], 0.0, "junction channel should be 0 at step {i}");
419 } else {
420 assert_eq!(o[1], 0.0, "corridor channel should be 0 at junction");
421 assert_eq!(o[2], 1.0, "junction channel should be 1 at junction");
422 }
423 }
424 }
425
426 #[test]
427 fn episode_length_is_corridor_length_plus_one() {
428 for n in [1usize, 5, 10, 20] {
429 let mut env = TMaze::with_seed_and_corridor_length(1, n);
430 env.reset();
431 let mut steps = 0;
432 loop {
433 let r = env.step(UP);
434 steps += 1;
435 if r.terminated || r.truncated {
436 break;
437 }
438 assert!(steps <= n + 1, "episode overran for N={n}");
439 }
440 // The junction decision is the (N+1)-th step (positions advance N
441 // times through the corridor, then one decision step).
442 assert_eq!(steps, n + 1, "episode length must be N+1 for N={n}");
443 }
444 }
445
446 #[test]
447 fn correct_turn_rewards_plus_one_and_terminates() {
448 // Drive both cue values and check the junction reward semantics.
449 for seed in 0..32u64 {
450 let mut env = TMaze::with_seed_and_corridor_length(seed, 6);
451 env.reset();
452 let cue = env.cue();
453 for _ in 0..env.corridor_length() {
454 let r = env.step(UP);
455 assert_eq!(r.reward, 0.0, "corridor steps yield zero reward");
456 assert!(!r.terminated);
457 }
458 // Now at the junction: the correct action rewards +1.
459 let r = env.step(cue.correct_action());
460 assert_eq!(r.reward, REWARD_CORRECT, "correct turn rewards +1");
461 assert!(r.terminated, "junction decision terminates the episode");
462 assert!(!r.truncated);
463 }
464 }
465
466 #[test]
467 fn wrong_turn_rewards_minus_one_and_terminates() {
468 for seed in 0..32u64 {
469 let mut env = TMaze::with_seed_and_corridor_length(seed, 6);
470 env.reset();
471 let cue = env.cue();
472 for _ in 0..env.corridor_length() {
473 env.step(UP);
474 }
475 let wrong = 1 - cue.correct_action();
476 let r = env.step(wrong);
477 assert_eq!(r.reward, REWARD_WRONG, "wrong turn rewards -1");
478 assert!(r.terminated);
479 }
480 }
481
482 #[test]
483 fn corridor_actions_are_noops() {
484 // The action taken in the corridor must not affect position, reward, or
485 // the eventual junction outcome — only the junction action matters.
486 let mut a = TMaze::with_seed_and_corridor_length(11, 7);
487 let mut b = TMaze::with_seed_and_corridor_length(11, 7);
488 a.reset();
489 b.reset();
490 assert_eq!(a.cue(), b.cue());
491 let cue = a.cue();
492 // a walks with UP actions, b walks with DOWN actions.
493 for _ in 0..a.corridor_length() {
494 let ra = a.step(UP);
495 let rb = b.step(DOWN);
496 assert_eq!(ra.observation, rb.observation, "corridor obs independent of action");
497 assert_eq!(ra.reward, rb.reward);
498 assert_eq!(a.position(), b.position());
499 }
500 // Both reach the junction identically; the correct turn still pays +1.
501 assert!(a.at_junction() && b.at_junction());
502 assert_eq!(a.step(cue.correct_action()).reward, REWARD_CORRECT);
503 assert_eq!(b.step(cue.correct_action()).reward, REWARD_CORRECT);
504 }
505
506 #[test]
507 fn cue_sequence_is_deterministic_under_seed() {
508 // Two envs with the same seed draw the same cue on every reset.
509 let mut a = TMaze::with_seed_and_corridor_length(42, 4);
510 let mut b = TMaze::with_seed_and_corridor_length(42, 4);
511 for _ in 0..100 {
512 a.reset();
513 b.reset();
514 assert_eq!(a.cue(), b.cue(), "cue sequence diverged under identical seeds");
515 }
516 }
517
518 #[test]
519 fn cue_is_approximately_balanced() {
520 // Over many resets the cue should be ~50/50 up/down (uniform draw).
521 let mut env = TMaze::with_seed_and_corridor_length(123, 4);
522 let mut up = 0usize;
523 let n = 5000;
524 for _ in 0..n {
525 env.reset();
526 if env.cue() == Cue::Up {
527 up += 1;
528 }
529 }
530 let rate = up as f64 / n as f64;
531 assert!((rate - 0.5).abs() < 0.05, "up-cue rate {rate} should be ≈ 0.5");
532 }
533
534 #[test]
535 fn clone_restore_reproduces_cue_stream() {
536 // Snapshot captures the cue RNG, so restore + reset reproduces the same
537 // subsequent cue sequence.
538 let mut env = TMaze::with_seed_and_corridor_length(555, 5);
539 for _ in 0..5 {
540 env.reset();
541 }
542 let snap = env.clone_state();
543
544 let mut first = Vec::new();
545 for _ in 0..20 {
546 env.reset();
547 first.push(env.cue());
548 }
549
550 env.restore_state(&snap);
551 let mut second = Vec::new();
552 for _ in 0..20 {
553 env.reset();
554 second.push(env.cue());
555 }
556 assert_eq!(first, second, "restore must reproduce the cue stream");
557 }
558
559 #[test]
560 #[should_panic(expected = "corridor_length must be >= 1")]
561 fn zero_corridor_length_panics() {
562 let _ = TMaze::with_corridor_length(0);
563 }
564
565 #[test]
566 fn correct_action_mapping() {
567 assert_eq!(Cue::Up.correct_action(), UP);
568 assert_eq!(Cue::Down.correct_action(), DOWN);
569 }
570}