Skip to main content

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}