use rand::{Rng, SeedableRng, rngs::StdRng};
use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
pub const DEFAULT_CORRIDOR_LENGTH: usize = 10;
pub const DEFAULT_SEED: u64 = 0;
pub const NUM_ACTIONS: usize = 2;
pub const OBS_DIM: usize = 3;
pub const REWARD_CORRECT: f32 = 1.0;
pub const REWARD_WRONG: f32 = -1.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cue {
Up,
Down,
}
impl Cue {
pub fn correct_action(self) -> i64 {
match self {
Cue::Up => 0,
Cue::Down => 1,
}
}
fn signal(self) -> f32 {
match self {
Cue::Up => 1.0,
Cue::Down => -1.0,
}
}
}
#[derive(Debug, Clone)]
pub struct TMazeState {
position: usize,
cue: Cue,
rng: StdRng,
}
#[derive(Debug)]
pub struct TMaze {
corridor_length: usize,
position: usize,
cue: Cue,
rng: StdRng,
}
impl TMaze {
pub fn new() -> Self {
Self::with_seed_and_corridor_length(DEFAULT_SEED, DEFAULT_CORRIDOR_LENGTH)
}
pub fn with_corridor_length(corridor_length: usize) -> Self {
Self::with_seed_and_corridor_length(DEFAULT_SEED, corridor_length)
}
pub fn with_seed(seed: u64) -> Self {
Self::with_seed_and_corridor_length(seed, DEFAULT_CORRIDOR_LENGTH)
}
pub fn with_seed_and_corridor_length(seed: u64, corridor_length: usize) -> Self {
assert!(
corridor_length >= 1,
"corridor_length must be >= 1 (a zero-length corridor leaks the cue into the junction observation), got {corridor_length}"
);
let mut env =
Self { corridor_length, position: 0, cue: Cue::Up, rng: StdRng::seed_from_u64(seed) };
env.reset();
env
}
pub fn corridor_length(&self) -> usize {
self.corridor_length
}
pub fn cue(&self) -> Cue {
self.cue
}
pub fn position(&self) -> usize {
self.position
}
pub fn at_junction(&self) -> bool {
self.position == self.corridor_length
}
}
impl Default for TMaze {
fn default() -> Self {
Self::new()
}
}
impl Environment for TMaze {
type Action = i64;
type State = TMazeState;
fn reset(&mut self) {
self.position = 0;
self.cue = if self.rng.random::<bool>() {
Cue::Up
} else {
Cue::Down
};
}
fn get_observation(&self) -> Vec<f32> {
let cue = if self.position == 0 {
self.cue.signal()
} else {
0.0
};
let at_junction = self.position == self.corridor_length;
let corridor = if at_junction { 0.0 } else { 1.0 };
let junction = if at_junction { 1.0 } else { 0.0 };
vec![cue, corridor, junction]
}
fn step(&mut self, action: i64) -> StepResult {
if self.position < self.corridor_length {
self.position += 1;
StepResult {
observation: self.get_observation(),
reward: 0.0,
terminated: false,
truncated: false,
info: StepInfo::default(),
}
} else {
let reward = if action == self.cue.correct_action() {
REWARD_CORRECT
} else {
REWARD_WRONG
};
StepResult {
observation: self.get_observation(),
reward,
terminated: true,
truncated: false,
info: StepInfo::default(),
}
}
}
fn observation_space(&self) -> SpaceInfo {
SpaceInfo { shape: vec![OBS_DIM], space_type: SpaceType::Box }
}
fn action_space(&self) -> SpaceInfo {
SpaceInfo { shape: vec![NUM_ACTIONS], space_type: SpaceType::Discrete(NUM_ACTIONS) }
}
fn render(&self) -> Vec<u8> {
Vec::new()
}
fn close(&mut self) {}
fn clone_state(&self) -> TMazeState {
TMazeState { position: self.position, cue: self.cue, rng: self.rng.clone() }
}
fn restore_state(&mut self, state: &TMazeState) {
self.position = state.position;
self.cue = state.cue;
self.rng = state.rng.clone();
}
}
#[cfg(test)]
mod tests {
use super::*;
const UP: i64 = 0;
const DOWN: i64 = 1;
fn walk_to_junction(env: &mut TMaze) -> Vec<Vec<f32>> {
env.reset();
let mut obs = vec![env.get_observation()];
for _ in 0..env.corridor_length() {
let r = env.step(UP);
obs.push(r.observation);
}
obs
}
#[test]
fn observation_space_is_three_dimensional_box() {
let env = TMaze::new();
let space = env.observation_space();
assert_eq!(space.shape, vec![OBS_DIM]);
assert!(matches!(space.space_type, SpaceType::Box));
}
#[test]
fn action_space_is_discrete_two() {
let env = TMaze::new();
let space = env.action_space();
assert_eq!(space.shape, vec![NUM_ACTIONS]);
assert!(matches!(space.space_type, SpaceType::Discrete(NUM_ACTIONS)));
}
#[test]
fn cue_is_only_visible_at_step_zero() {
for seed in 0..16u64 {
let mut env = TMaze::with_seed_and_corridor_length(seed, 10);
let obs = walk_to_junction(&mut env);
assert_ne!(obs[0][0], 0.0, "cue must be visible at step 0 (seed {seed})");
for (i, o) in obs.iter().enumerate().skip(1) {
assert_eq!(o[0], 0.0, "cue leaked at step {i} (seed {seed}): {o:?}");
}
}
}
#[test]
fn junction_observation_is_identical_across_cues() {
let mut up_env = None;
let mut down_env = None;
for seed in 0..64u64 {
let env = TMaze::with_seed_and_corridor_length(seed, 8);
match env.cue() {
Cue::Up if up_env.is_none() => up_env = Some(env),
Cue::Down if down_env.is_none() => down_env = Some(env),
_ => {}
}
if up_env.is_some() && down_env.is_some() {
break;
}
}
let mut up_env = up_env.expect("some seed yields an up cue");
let mut down_env = down_env.expect("some seed yields a down cue");
assert_eq!(up_env.cue(), Cue::Up);
assert_eq!(down_env.cue(), Cue::Down);
let up_obs = walk_to_junction(&mut up_env);
let down_obs = walk_to_junction(&mut down_env);
assert_ne!(up_obs[0], down_obs[0], "cue must differ at step 0");
let junction_up = up_obs.last().unwrap();
let junction_down = down_obs.last().unwrap();
assert_eq!(
junction_up, junction_down,
"junction observation must be identical across cues (no leak)"
);
assert_eq!(junction_up, &vec![0.0, 0.0, 1.0], "junction obs is [0,0,1]");
}
#[test]
fn corridor_and_junction_channels_track_position() {
let mut env = TMaze::with_seed_and_corridor_length(3, 5);
let obs = walk_to_junction(&mut env);
for (i, o) in obs.iter().enumerate() {
if i < env.corridor_length() {
assert_eq!(o[1], 1.0, "corridor channel should be 1 at step {i}");
assert_eq!(o[2], 0.0, "junction channel should be 0 at step {i}");
} else {
assert_eq!(o[1], 0.0, "corridor channel should be 0 at junction");
assert_eq!(o[2], 1.0, "junction channel should be 1 at junction");
}
}
}
#[test]
fn episode_length_is_corridor_length_plus_one() {
for n in [1usize, 5, 10, 20] {
let mut env = TMaze::with_seed_and_corridor_length(1, n);
env.reset();
let mut steps = 0;
loop {
let r = env.step(UP);
steps += 1;
if r.terminated || r.truncated {
break;
}
assert!(steps <= n + 1, "episode overran for N={n}");
}
assert_eq!(steps, n + 1, "episode length must be N+1 for N={n}");
}
}
#[test]
fn correct_turn_rewards_plus_one_and_terminates() {
for seed in 0..32u64 {
let mut env = TMaze::with_seed_and_corridor_length(seed, 6);
env.reset();
let cue = env.cue();
for _ in 0..env.corridor_length() {
let r = env.step(UP);
assert_eq!(r.reward, 0.0, "corridor steps yield zero reward");
assert!(!r.terminated);
}
let r = env.step(cue.correct_action());
assert_eq!(r.reward, REWARD_CORRECT, "correct turn rewards +1");
assert!(r.terminated, "junction decision terminates the episode");
assert!(!r.truncated);
}
}
#[test]
fn wrong_turn_rewards_minus_one_and_terminates() {
for seed in 0..32u64 {
let mut env = TMaze::with_seed_and_corridor_length(seed, 6);
env.reset();
let cue = env.cue();
for _ in 0..env.corridor_length() {
env.step(UP);
}
let wrong = 1 - cue.correct_action();
let r = env.step(wrong);
assert_eq!(r.reward, REWARD_WRONG, "wrong turn rewards -1");
assert!(r.terminated);
}
}
#[test]
fn corridor_actions_are_noops() {
let mut a = TMaze::with_seed_and_corridor_length(11, 7);
let mut b = TMaze::with_seed_and_corridor_length(11, 7);
a.reset();
b.reset();
assert_eq!(a.cue(), b.cue());
let cue = a.cue();
for _ in 0..a.corridor_length() {
let ra = a.step(UP);
let rb = b.step(DOWN);
assert_eq!(ra.observation, rb.observation, "corridor obs independent of action");
assert_eq!(ra.reward, rb.reward);
assert_eq!(a.position(), b.position());
}
assert!(a.at_junction() && b.at_junction());
assert_eq!(a.step(cue.correct_action()).reward, REWARD_CORRECT);
assert_eq!(b.step(cue.correct_action()).reward, REWARD_CORRECT);
}
#[test]
fn cue_sequence_is_deterministic_under_seed() {
let mut a = TMaze::with_seed_and_corridor_length(42, 4);
let mut b = TMaze::with_seed_and_corridor_length(42, 4);
for _ in 0..100 {
a.reset();
b.reset();
assert_eq!(a.cue(), b.cue(), "cue sequence diverged under identical seeds");
}
}
#[test]
fn cue_is_approximately_balanced() {
let mut env = TMaze::with_seed_and_corridor_length(123, 4);
let mut up = 0usize;
let n = 5000;
for _ in 0..n {
env.reset();
if env.cue() == Cue::Up {
up += 1;
}
}
let rate = up as f64 / n as f64;
assert!((rate - 0.5).abs() < 0.05, "up-cue rate {rate} should be ≈ 0.5");
}
#[test]
fn clone_restore_reproduces_cue_stream() {
let mut env = TMaze::with_seed_and_corridor_length(555, 5);
for _ in 0..5 {
env.reset();
}
let snap = env.clone_state();
let mut first = Vec::new();
for _ in 0..20 {
env.reset();
first.push(env.cue());
}
env.restore_state(&snap);
let mut second = Vec::new();
for _ in 0..20 {
env.reset();
second.push(env.cue());
}
assert_eq!(first, second, "restore must reproduce the cue stream");
}
#[test]
#[should_panic(expected = "corridor_length must be >= 1")]
fn zero_corridor_length_panics() {
let _ = TMaze::with_corridor_length(0);
}
#[test]
fn correct_action_mapping() {
assert_eq!(Cue::Up.correct_action(), UP);
assert_eq!(Cue::Down.correct_action(), DOWN);
}
}