use rand::{SeedableRng, rngs::StdRng};
use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
pub const GRID_ROWS: usize = 4;
pub const GRID_COLS: usize = 4;
pub const NUM_ACTIONS: usize = 4;
pub const STEP_PENALTY: f32 = -0.01;
pub const GOAL_REWARD: f32 = 1.0;
pub const HOLE_REWARD: f32 = -1.0;
pub const DEFAULT_MAX_STEPS: usize = 100;
pub const DEFAULT_SEED: u64 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Cell {
Start,
Empty,
Hole,
Goal,
}
const DEFAULT_LAYOUT: [[Cell; GRID_COLS]; GRID_ROWS] = {
use Cell::*;
[
[Start, Empty, Empty, Empty],
[Empty, Hole, Empty, Hole],
[Empty, Empty, Empty, Hole],
[Hole, Empty, Empty, Goal],
]
};
const START_ROW: usize = 0;
const START_COL: usize = 0;
#[derive(Debug, Clone)]
pub struct GridWorldState {
pub agent_row: usize,
pub agent_col: usize,
pub steps: usize,
}
#[derive(Debug, Clone)]
pub struct GridWorld {
agent_row: usize,
agent_col: usize,
steps: usize,
max_steps: usize,
#[allow(dead_code)]
rng: StdRng,
}
impl GridWorld {
pub fn new() -> Self {
Self::with_seed(DEFAULT_SEED)
}
pub fn with_seed(seed: u64) -> Self {
Self::with_seed_and_max_steps(seed, DEFAULT_MAX_STEPS)
}
pub fn with_seed_and_max_steps(seed: u64, max_steps: usize) -> Self {
let mut env = Self {
agent_row: START_ROW,
agent_col: START_COL,
steps: 0,
max_steps,
rng: StdRng::seed_from_u64(seed),
};
env.reset();
env
}
pub fn agent_row(&self) -> usize {
self.agent_row
}
pub fn agent_col(&self) -> usize {
self.agent_col
}
pub fn agent_index(&self) -> usize {
self.agent_row * GRID_COLS + self.agent_col
}
fn cell_at(row: usize, col: usize) -> Cell {
DEFAULT_LAYOUT[row][col]
}
fn action_delta(action: i64) -> (i64, i64) {
match action {
0 => (-1, 0), 1 => (0, 1), 2 => (1, 0), 3 => (0, -1), _ => (0, 0), }
}
}
impl Default for GridWorld {
fn default() -> Self {
Self::new()
}
}
impl Environment for GridWorld {
type Action = i64;
type State = GridWorldState;
fn reset(&mut self) {
self.agent_row = START_ROW;
self.agent_col = START_COL;
self.steps = 0;
}
fn get_observation(&self) -> Vec<f32> {
let mut obs = vec![0.0; GRID_ROWS * GRID_COLS];
obs[self.agent_index()] = 1.0;
obs
}
fn step(&mut self, action: i64) -> StepResult {
let (drow, dcol) = Self::action_delta(action);
let new_row = self.agent_row as i64 + drow;
let new_col = self.agent_col as i64 + dcol;
if new_row >= 0 && new_row < GRID_ROWS as i64 && new_col >= 0 && new_col < GRID_COLS as i64
{
self.agent_row = new_row as usize;
self.agent_col = new_col as usize;
}
self.steps += 1;
let (reward, terminated) = match Self::cell_at(self.agent_row, self.agent_col) {
Cell::Goal => (GOAL_REWARD, true),
Cell::Hole => (HOLE_REWARD, true),
Cell::Start | Cell::Empty => (STEP_PENALTY, false),
};
let truncated = !terminated && self.steps >= self.max_steps;
StepResult {
observation: self.get_observation(),
reward,
terminated,
truncated,
info: StepInfo::default(),
}
}
fn observation_space(&self) -> SpaceInfo {
SpaceInfo { shape: vec![GRID_ROWS * GRID_COLS], 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) -> GridWorldState {
GridWorldState { agent_row: self.agent_row, agent_col: self.agent_col, steps: self.steps }
}
fn restore_state(&mut self, state: &GridWorldState) {
self.agent_row = state.agent_row;
self.agent_col = state.agent_col;
self.steps = state.steps;
}
}
#[cfg(test)]
mod tests {
use super::*;
const UP: i64 = 0;
const RIGHT: i64 = 1;
const DOWN: i64 = 2;
const LEFT: i64 = 3;
#[test]
fn observation_is_one_hot_of_length_sixteen() {
let mut env = GridWorld::new();
env.reset();
let obs = env.get_observation();
assert_eq!(obs.len(), GRID_ROWS * GRID_COLS, "observation must be 16-dimensional");
let ones = obs.iter().filter(|&&v| v == 1.0).count();
let zeros = obs.iter().filter(|&&v| v == 0.0).count();
assert_eq!(ones, 1, "exactly one cell should be hot");
assert_eq!(zeros, GRID_ROWS * GRID_COLS - 1, "every other cell should be zero");
assert_eq!(obs[0], 1.0, "agent starts at cell index 0");
assert_eq!(env.agent_index(), 0);
}
#[test]
fn reset_places_agent_at_start() {
let mut env = GridWorld::new();
env.step(RIGHT);
env.step(DOWN);
env.reset();
assert_eq!(env.agent_row(), 0);
assert_eq!(env.agent_col(), 0);
assert_eq!(env.agent_index(), 0);
}
#[test]
fn each_action_moves_in_the_right_direction() {
let interior = GridWorldState { agent_row: 2, agent_col: 1, steps: 0 };
let mut env = GridWorld::new();
env.restore_state(&interior);
env.step(UP);
assert_eq!((env.agent_row(), env.agent_col()), (1, 1), "Up decrements row");
env.restore_state(&interior);
env.step(DOWN);
assert_eq!((env.agent_row(), env.agent_col()), (3, 1), "Down increments row");
env.restore_state(&interior);
env.step(LEFT);
assert_eq!((env.agent_row(), env.agent_col()), (2, 0), "Left decrements col");
env.restore_state(&interior);
env.step(RIGHT);
assert_eq!((env.agent_row(), env.agent_col()), (2, 2), "Right increments col");
}
#[test]
fn walls_clamp_the_agent_in_place() {
let mut env = GridWorld::new();
env.restore_state(&GridWorldState { agent_row: 0, agent_col: 0, steps: 0 });
env.step(UP);
assert_eq!((env.agent_row(), env.agent_col()), (0, 0), "Up at top edge stays put");
env.step(LEFT);
assert_eq!((env.agent_row(), env.agent_col()), (0, 0), "Left at left edge stays put");
env.restore_state(&GridWorldState { agent_row: 0, agent_col: 3, steps: 0 });
env.step(RIGHT);
assert_eq!((env.agent_row(), env.agent_col()), (0, 3), "Right at right edge stays put");
env.restore_state(&GridWorldState { agent_row: 3, agent_col: 1, steps: 0 });
env.step(DOWN);
assert_eq!((env.agent_row(), env.agent_col()), (3, 1), "Down at bottom edge stays put");
}
#[test]
fn normal_move_yields_step_penalty_and_no_termination() {
let mut env = GridWorld::new();
env.reset();
let r = env.step(RIGHT);
assert_eq!(r.reward, STEP_PENALTY, "normal move incurs the step penalty");
assert!(!r.terminated, "empty cell is not terminal");
assert!(!r.truncated, "no truncation early");
}
#[test]
fn wall_bump_yields_step_penalty_not_termination() {
let mut env = GridWorld::new();
env.reset();
let r = env.step(UP);
assert_eq!(r.reward, STEP_PENALTY);
assert!(!r.terminated);
assert_eq!((env.agent_row(), env.agent_col()), (0, 0));
}
#[test]
fn reaching_goal_terminates_with_goal_reward() {
let mut env = GridWorld::new();
env.restore_state(&GridWorldState { agent_row: 3, agent_col: 2, steps: 0 });
let r = env.step(RIGHT);
assert_eq!((env.agent_row(), env.agent_col()), (3, 3));
assert_eq!(r.reward, GOAL_REWARD, "goal yields the goal reward");
assert!(r.terminated, "goal is absorbing / terminal");
assert!(!r.truncated, "termination is not truncation");
}
#[test]
fn falling_into_hole_terminates_with_hole_reward() {
let mut env = GridWorld::new();
env.restore_state(&GridWorldState { agent_row: 0, agent_col: 1, steps: 0 });
let r = env.step(DOWN);
assert_eq!((env.agent_row(), env.agent_col()), (1, 1));
assert_eq!(r.reward, HOLE_REWARD, "hole yields the hole reward");
assert!(r.terminated, "hole is absorbing / terminal");
assert!(!r.truncated, "termination is not truncation");
}
#[test]
fn truncates_after_max_steps_without_termination() {
let mut env = GridWorld::with_seed_and_max_steps(0, 5);
env.reset();
for i in 0..4 {
let r = env.step(UP);
assert!(!r.truncated, "should not truncate before max_steps (step {i})");
assert!(!r.terminated, "wall bump never terminates");
}
let r = env.step(UP);
assert!(r.truncated, "episode should truncate at max_steps");
assert!(!r.terminated, "truncation is not termination");
}
#[test]
fn truncation_does_not_fire_when_terminating_on_the_cap_step() {
let mut env = GridWorld::with_seed_and_max_steps(0, 1);
env.restore_state(&GridWorldState { agent_row: 3, agent_col: 2, steps: 0 });
let r = env.step(RIGHT);
assert!(r.terminated, "goal reached on the cap step terminates");
assert!(!r.truncated, "truncation must not also fire when terminated");
}
#[test]
fn out_of_range_and_invalid_actions_are_noops() {
let mut env = GridWorld::new();
env.restore_state(&GridWorldState { agent_row: 2, agent_col: 1, steps: 0 });
for &bad in &[-1_i64, 4, 99, i64::MIN, i64::MAX] {
env.restore_state(&GridWorldState { agent_row: 2, agent_col: 1, steps: 0 });
let r = env.step(bad);
assert_eq!(
(env.agent_row(), env.agent_col()),
(2, 1),
"invalid action {bad} should be a no-op (stay)"
);
assert_eq!(r.reward, STEP_PENALTY, "a no-op still counts as a penalised step");
assert!(!r.terminated);
}
}
#[test]
fn seeded_reset_is_reproducible() {
let mut a = GridWorld::with_seed(42);
let mut b = GridWorld::with_seed(7);
a.reset();
b.reset();
assert_eq!(a.get_observation(), b.get_observation(), "default layout is seed-independent");
for action in [RIGHT, DOWN, RIGHT, DOWN, RIGHT] {
let ra = a.step(action);
let rb = b.step(action);
assert_eq!(ra.observation, rb.observation);
assert_eq!(ra.reward, rb.reward);
assert_eq!(ra.terminated, rb.terminated);
assert_eq!(ra.truncated, rb.truncated);
if ra.terminated || ra.truncated {
break;
}
}
}
#[test]
fn clone_restore_round_trips_next_step() {
let mut env = GridWorld::new();
env.reset();
env.step(RIGHT);
env.step(DOWN);
env.restore_state(&GridWorldState { agent_row: 2, agent_col: 1, steps: 3 });
let snapshot = env.clone_state();
let result_a = env.step(RIGHT);
env.restore_state(&snapshot);
let result_b = env.step(RIGHT);
assert_eq!(result_a.observation, result_b.observation, "obs must reproduce bit-for-bit");
assert_eq!(result_a.reward, result_b.reward, "reward must reproduce bit-for-bit");
assert_eq!(result_a.terminated, result_b.terminated);
assert_eq!(result_a.truncated, result_b.truncated);
}
#[test]
fn action_space_is_discrete_four() {
let env = GridWorld::new();
let space = env.action_space();
assert_eq!(space.shape, vec![NUM_ACTIONS]);
assert!(matches!(space.space_type, SpaceType::Discrete(NUM_ACTIONS)));
}
#[test]
fn observation_space_is_box_sixteen() {
let env = GridWorld::new();
let space = env.observation_space();
assert_eq!(space.shape, vec![GRID_ROWS * GRID_COLS]);
assert!(matches!(space.space_type, SpaceType::Box));
}
#[test]
fn goal_reward_dominates_so_optimal_path_has_highest_return() {
let max_path_cost = STEP_PENALTY * (DEFAULT_MAX_STEPS as f32);
assert!(GOAL_REWARD + max_path_cost > HOLE_REWARD, "goal must dominate the hole");
let mut env = GridWorld::new();
env.restore_state(&GridWorldState { agent_row: 3, agent_col: 3, steps: 0 });
let manhattan = (env.agent_row() - START_ROW) + (env.agent_col() - START_COL);
let short_path_return = GOAL_REWARD + STEP_PENALTY * (manhattan as f32);
assert!(short_path_return > 0.0, "a short goal path has positive return");
}
}