Skip to main content

thrust_rl/env/games/
grid_world.rs

1//! GridWorld sparse-reward discrete navigation environment.
2//!
3//! [`GridWorld`] is a small, pure-Rust, zero-external-dependency
4//! FrozenLake-style navigation task (issue #182, part of the
5//! more-environments epic #180). It fills the gap in Thrust's
6//! discrete-action catalog (`CartPole`, `Snake`, `Pong`, ...) for a
7//! **sparse-reward navigation** problem: the agent must reach a goal on a
8//! grid under a per-step penalty, with absorbing terminal states
9//! (goal/hazard) that are distinct from step-cap truncation.
10//!
11//! The agent occupies one cell of a fixed `4x4` grid and moves between
12//! cells. The default layout mirrors the classic `FrozenLake-v1` `4x4`
13//! map (without slip): `S` start, `F` frozen/empty, `H` hole/hazard, `G`
14//! goal.
15//!
16//! ```text
17//! S F F F
18//! F H F H
19//! F F F H
20//! H F F G
21//! ```
22//!
23//! - **Action:** `i64`, four discrete moves: `0=Up`, `1=Right`, `2=Down`,
24//!   `3=Left`. Out-of-range / invalid actions are treated as a no-op (the agent
25//!   stays in place).
26//! - **Observation:** one-hot over cells, a `Vec<f32>` of length `GRID_ROWS *
27//!   GRID_COLS` (= 16). The entry at the agent's flattened index (`row *
28//!   GRID_COLS + col`) is `1.0`; every other entry is `0.0`. This keeps the
29//!   Q-network input fixed-width and discrete.
30//! - **Reward:** reaching the goal yields [`GOAL_REWARD`] (`+1.0`); falling
31//!   into a hole yields [`HOLE_REWARD`] (`-1.0`); any other move (including
32//!   bumping into a wall) yields [`STEP_PENALTY`] (`-0.01`). The goal reward
33//!   dominates so an optimal shortest path is the highest-return policy.
34//! - **Termination:** the goal and hole cells are absorbing — `terminated =
35//!   true`. Otherwise the episode runs until the step cap.
36//! - **Truncation:** if not already terminated, `truncated = true` once `steps`
37//!   reaches `max_steps` (default [`DEFAULT_MAX_STEPS`] = 100). Truncation is
38//!   *not* termination.
39//!
40//! # Determinism contract
41//!
42//! The default layout has no stochastic dynamics: [`Environment::step`]
43//! is fully deterministic given the current cell and action.
44//! [`Environment::reset`] always returns the agent to the fixed start
45//! cell. A seeded [`StdRng`] is held to match the surface of the other
46//! envs (and is reserved for an optional later slip/random-layout mode),
47//! but it is not consulted on the default layout. Consequently
48//! [`Environment::restore_state`] followed by [`Environment::step`]
49//! reproduces every subsequent [`StepResult`] bit-for-bit, and two envs
50//! constructed with the same seed produce identical episodes. The
51//! [`GridWorldState`] snapshot captures the agent position and step
52//! counter (`agent_row`, `agent_col`, `steps`) but not the RNG.
53
54use rand::{SeedableRng, rngs::StdRng};
55
56use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
57
58/// Number of rows in the grid.
59pub const GRID_ROWS: usize = 4;
60
61/// Number of columns in the grid.
62pub const GRID_COLS: usize = 4;
63
64/// Number of discrete actions (`Up`, `Right`, `Down`, `Left`).
65pub const NUM_ACTIONS: usize = 4;
66
67/// Per-step penalty for any non-terminal move (encourages short paths).
68pub const STEP_PENALTY: f32 = -0.01;
69
70/// Reward for reaching the goal cell.
71pub const GOAL_REWARD: f32 = 1.0;
72
73/// Reward for falling into a hole cell.
74pub const HOLE_REWARD: f32 = -1.0;
75
76/// Default episode length cap before truncation.
77pub const DEFAULT_MAX_STEPS: usize = 100;
78
79/// Default seed used by [`GridWorld::new`].
80pub const DEFAULT_SEED: u64 = 0;
81
82/// The kind of a grid cell in the default layout.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum Cell {
85    /// Start cell (where the agent spawns on reset).
86    Start,
87    /// Empty / frozen cell the agent can stand on.
88    Empty,
89    /// Hole / hazard cell: absorbing, negative reward.
90    Hole,
91    /// Goal cell: absorbing, positive reward.
92    Goal,
93}
94
95/// Default `4x4` FrozenLake-style layout (no slip).
96///
97/// ```text
98/// S F F F
99/// F H F H
100/// F F F H
101/// H F F G
102/// ```
103const DEFAULT_LAYOUT: [[Cell; GRID_COLS]; GRID_ROWS] = {
104    use Cell::*;
105    [
106        [Start, Empty, Empty, Empty],
107        [Empty, Hole, Empty, Hole],
108        [Empty, Empty, Empty, Hole],
109        [Hole, Empty, Empty, Goal],
110    ]
111};
112
113/// Flattened index of the start cell in the default layout.
114const START_ROW: usize = 0;
115const START_COL: usize = 0;
116
117/// Snapshot of [`GridWorld`]'s simulation state.
118///
119/// The default layout's dynamics are fully deterministic (no RNG), so
120/// [`Environment::restore_state`] followed by [`Environment::step`]
121/// reproduces every subsequent [`StepResult`] bit-for-bit. The snapshot
122/// does *not* capture the RNG; restoring then calling
123/// [`Environment::reset`] simply returns the agent to the start cell.
124#[derive(Debug, Clone)]
125pub struct GridWorldState {
126    /// Agent row in `0..GRID_ROWS`.
127    pub agent_row: usize,
128    /// Agent column in `0..GRID_COLS`.
129    pub agent_col: usize,
130    /// Step counter for the current episode.
131    pub steps: usize,
132}
133
134/// Sparse-reward discrete navigation task on a fixed `4x4` grid.
135///
136/// See the [module docs](self) for the full layout / action /
137/// observation / reward / termination specification and the determinism
138/// contract.
139#[derive(Debug, Clone)]
140pub struct GridWorld {
141    /// Agent row in `0..GRID_ROWS`.
142    agent_row: usize,
143
144    /// Agent column in `0..GRID_COLS`.
145    agent_col: usize,
146
147    /// Step counter for the current episode.
148    steps: usize,
149
150    /// Maximum number of steps before truncation.
151    max_steps: usize,
152
153    /// Seeded RNG. Unused on the default layout; held to match the other
154    /// envs' surface and reserved for a future slip/random-layout mode.
155    #[allow(dead_code)]
156    rng: StdRng,
157}
158
159impl GridWorld {
160    /// Create a new env with the default seed, layout, and episode length.
161    pub fn new() -> Self {
162        Self::with_seed(DEFAULT_SEED)
163    }
164
165    /// Create a new env with a custom seed (default layout / episode
166    /// length).
167    ///
168    /// On the default layout the seed has no observable effect; two envs
169    /// constructed with any seeds produce identical episodes. The seed is
170    /// reserved for a future slip/random-layout mode.
171    pub fn with_seed(seed: u64) -> Self {
172        Self::with_seed_and_max_steps(seed, DEFAULT_MAX_STEPS)
173    }
174
175    /// Create a new env with a custom seed and episode length.
176    pub fn with_seed_and_max_steps(seed: u64, max_steps: usize) -> Self {
177        let mut env = Self {
178            agent_row: START_ROW,
179            agent_col: START_COL,
180            steps: 0,
181            max_steps,
182            rng: StdRng::seed_from_u64(seed),
183        };
184        env.reset();
185        env
186    }
187
188    /// Current agent row (for inspection / tests).
189    pub fn agent_row(&self) -> usize {
190        self.agent_row
191    }
192
193    /// Current agent column (for inspection / tests).
194    pub fn agent_col(&self) -> usize {
195        self.agent_col
196    }
197
198    /// Current agent flattened cell index (`row * GRID_COLS + col`).
199    pub fn agent_index(&self) -> usize {
200        self.agent_row * GRID_COLS + self.agent_col
201    }
202
203    /// Look up the kind of the cell at `(row, col)`.
204    fn cell_at(row: usize, col: usize) -> Cell {
205        DEFAULT_LAYOUT[row][col]
206    }
207
208    /// Decode an action into a `(drow, dcol)` movement delta. Out-of-range
209    /// or invalid actions decode to `(0, 0)` (a no-op / stay).
210    fn action_delta(action: i64) -> (i64, i64) {
211        match action {
212            0 => (-1, 0), // Up
213            1 => (0, 1),  // Right
214            2 => (1, 0),  // Down
215            3 => (0, -1), // Left
216            _ => (0, 0),  // invalid / out-of-range -> stay
217        }
218    }
219}
220
221impl Default for GridWorld {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl Environment for GridWorld {
228    /// Discrete navigation action. One of `0=Up`, `1=Right`, `2=Down`,
229    /// `3=Left`; any other value is a no-op (the agent stays put).
230    type Action = i64;
231
232    /// Snapshot type. The default layout is deterministic (no RNG in
233    /// step), so restore + step reproduces subsequent results exactly.
234    /// The RNG is not captured; see [`GridWorldState`].
235    type State = GridWorldState;
236
237    fn reset(&mut self) {
238        self.agent_row = START_ROW;
239        self.agent_col = START_COL;
240        self.steps = 0;
241    }
242
243    fn get_observation(&self) -> Vec<f32> {
244        // One-hot over cells: 1.0 at the agent's flattened index.
245        let mut obs = vec![0.0; GRID_ROWS * GRID_COLS];
246        obs[self.agent_index()] = 1.0;
247        obs
248    }
249
250    fn step(&mut self, action: i64) -> StepResult {
251        // Decode the move and clamp into bounds: moving into a wall keeps
252        // the agent in place.
253        let (drow, dcol) = Self::action_delta(action);
254        let new_row = self.agent_row as i64 + drow;
255        let new_col = self.agent_col as i64 + dcol;
256        if new_row >= 0 && new_row < GRID_ROWS as i64 && new_col >= 0 && new_col < GRID_COLS as i64
257        {
258            self.agent_row = new_row as usize;
259            self.agent_col = new_col as usize;
260        }
261
262        self.steps += 1;
263
264        let (reward, terminated) = match Self::cell_at(self.agent_row, self.agent_col) {
265            Cell::Goal => (GOAL_REWARD, true),
266            Cell::Hole => (HOLE_REWARD, true),
267            Cell::Start | Cell::Empty => (STEP_PENALTY, false),
268        };
269
270        // Truncation only applies when the episode did not already end at
271        // an absorbing cell.
272        let truncated = !terminated && self.steps >= self.max_steps;
273
274        StepResult {
275            observation: self.get_observation(),
276            reward,
277            terminated,
278            truncated,
279            info: StepInfo::default(),
280        }
281    }
282
283    fn observation_space(&self) -> SpaceInfo {
284        // One-hot floats over the 16 cells. Box-shaped even though the
285        // underlying state is discrete (mirrors CartPole reporting a Box
286        // obs with Discrete actions).
287        SpaceInfo { shape: vec![GRID_ROWS * GRID_COLS], space_type: SpaceType::Box }
288    }
289
290    fn action_space(&self) -> SpaceInfo {
291        SpaceInfo { shape: vec![NUM_ACTIONS], space_type: SpaceType::Discrete(NUM_ACTIONS) }
292    }
293
294    fn render(&self) -> Vec<u8> {
295        Vec::new()
296    }
297
298    fn close(&mut self) {}
299
300    fn clone_state(&self) -> GridWorldState {
301        GridWorldState { agent_row: self.agent_row, agent_col: self.agent_col, steps: self.steps }
302    }
303
304    fn restore_state(&mut self, state: &GridWorldState) {
305        self.agent_row = state.agent_row;
306        self.agent_col = state.agent_col;
307        self.steps = state.steps;
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    // Action constants for readability.
316    const UP: i64 = 0;
317    const RIGHT: i64 = 1;
318    const DOWN: i64 = 2;
319    const LEFT: i64 = 3;
320
321    #[test]
322    fn observation_is_one_hot_of_length_sixteen() {
323        let mut env = GridWorld::new();
324        env.reset();
325
326        let obs = env.get_observation();
327        assert_eq!(obs.len(), GRID_ROWS * GRID_COLS, "observation must be 16-dimensional");
328
329        // Exactly one entry is 1.0; the rest are 0.0.
330        let ones = obs.iter().filter(|&&v| v == 1.0).count();
331        let zeros = obs.iter().filter(|&&v| v == 0.0).count();
332        assert_eq!(ones, 1, "exactly one cell should be hot");
333        assert_eq!(zeros, GRID_ROWS * GRID_COLS - 1, "every other cell should be zero");
334
335        // The hot index is the agent's flattened index (start = 0).
336        assert_eq!(obs[0], 1.0, "agent starts at cell index 0");
337        assert_eq!(env.agent_index(), 0);
338    }
339
340    #[test]
341    fn reset_places_agent_at_start() {
342        let mut env = GridWorld::new();
343        // Move away from start, then reset.
344        env.step(RIGHT);
345        env.step(DOWN);
346        env.reset();
347        assert_eq!(env.agent_row(), 0);
348        assert_eq!(env.agent_col(), 0);
349        assert_eq!(env.agent_index(), 0);
350    }
351
352    #[test]
353    fn each_action_moves_in_the_right_direction() {
354        // Start from an interior empty cell (2, 1) so all four moves are
355        // valid and land on non-terminal cells.
356        let interior = GridWorldState { agent_row: 2, agent_col: 1, steps: 0 };
357
358        let mut env = GridWorld::new();
359
360        env.restore_state(&interior);
361        env.step(UP);
362        assert_eq!((env.agent_row(), env.agent_col()), (1, 1), "Up decrements row");
363
364        env.restore_state(&interior);
365        env.step(DOWN);
366        assert_eq!((env.agent_row(), env.agent_col()), (3, 1), "Down increments row");
367
368        env.restore_state(&interior);
369        env.step(LEFT);
370        assert_eq!((env.agent_row(), env.agent_col()), (2, 0), "Left decrements col");
371
372        env.restore_state(&interior);
373        env.step(RIGHT);
374        assert_eq!((env.agent_row(), env.agent_col()), (2, 2), "Right increments col");
375    }
376
377    #[test]
378    fn walls_clamp_the_agent_in_place() {
379        let mut env = GridWorld::new();
380
381        // Top-left corner: Up and Left both bump walls and stay put.
382        env.restore_state(&GridWorldState { agent_row: 0, agent_col: 0, steps: 0 });
383        env.step(UP);
384        assert_eq!((env.agent_row(), env.agent_col()), (0, 0), "Up at top edge stays put");
385        env.step(LEFT);
386        assert_eq!((env.agent_row(), env.agent_col()), (0, 0), "Left at left edge stays put");
387
388        // Bottom-right corner of an empty cell: Down and Right bump walls.
389        env.restore_state(&GridWorldState { agent_row: 0, agent_col: 3, steps: 0 });
390        env.step(RIGHT);
391        assert_eq!((env.agent_row(), env.agent_col()), (0, 3), "Right at right edge stays put");
392        env.restore_state(&GridWorldState { agent_row: 3, agent_col: 1, steps: 0 });
393        env.step(DOWN);
394        assert_eq!((env.agent_row(), env.agent_col()), (3, 1), "Down at bottom edge stays put");
395    }
396
397    #[test]
398    fn normal_move_yields_step_penalty_and_no_termination() {
399        let mut env = GridWorld::new();
400        env.reset();
401        // Right from start lands on an empty cell (0, 1).
402        let r = env.step(RIGHT);
403        assert_eq!(r.reward, STEP_PENALTY, "normal move incurs the step penalty");
404        assert!(!r.terminated, "empty cell is not terminal");
405        assert!(!r.truncated, "no truncation early");
406    }
407
408    #[test]
409    fn wall_bump_yields_step_penalty_not_termination() {
410        let mut env = GridWorld::new();
411        env.reset();
412        // Up at the top edge: stay at start, still a normal (penalised) step.
413        let r = env.step(UP);
414        assert_eq!(r.reward, STEP_PENALTY);
415        assert!(!r.terminated);
416        assert_eq!((env.agent_row(), env.agent_col()), (0, 0));
417    }
418
419    #[test]
420    fn reaching_goal_terminates_with_goal_reward() {
421        let mut env = GridWorld::new();
422        // Sit just left of the goal at (3, 2); move Right onto goal (3, 3).
423        env.restore_state(&GridWorldState { agent_row: 3, agent_col: 2, steps: 0 });
424        let r = env.step(RIGHT);
425        assert_eq!((env.agent_row(), env.agent_col()), (3, 3));
426        assert_eq!(r.reward, GOAL_REWARD, "goal yields the goal reward");
427        assert!(r.terminated, "goal is absorbing / terminal");
428        assert!(!r.truncated, "termination is not truncation");
429    }
430
431    #[test]
432    fn falling_into_hole_terminates_with_hole_reward() {
433        let mut env = GridWorld::new();
434        // Sit at (0, 1); move Down into the hole at (1, 1).
435        env.restore_state(&GridWorldState { agent_row: 0, agent_col: 1, steps: 0 });
436        let r = env.step(DOWN);
437        assert_eq!((env.agent_row(), env.agent_col()), (1, 1));
438        assert_eq!(r.reward, HOLE_REWARD, "hole yields the hole reward");
439        assert!(r.terminated, "hole is absorbing / terminal");
440        assert!(!r.truncated, "termination is not truncation");
441    }
442
443    #[test]
444    fn truncates_after_max_steps_without_termination() {
445        // Small cap so we can hit it quickly. Bounce against the top wall
446        // (Up at start) so we never reach goal or hole.
447        let mut env = GridWorld::with_seed_and_max_steps(0, 5);
448        env.reset();
449
450        for i in 0..4 {
451            let r = env.step(UP);
452            assert!(!r.truncated, "should not truncate before max_steps (step {i})");
453            assert!(!r.terminated, "wall bump never terminates");
454        }
455
456        let r = env.step(UP);
457        assert!(r.truncated, "episode should truncate at max_steps");
458        assert!(!r.terminated, "truncation is not termination");
459    }
460
461    #[test]
462    fn truncation_does_not_fire_when_terminating_on_the_cap_step() {
463        // max_steps = 1: a single step that lands on the goal must report
464        // terminated (not truncated), proving termination takes priority.
465        let mut env = GridWorld::with_seed_and_max_steps(0, 1);
466        env.restore_state(&GridWorldState { agent_row: 3, agent_col: 2, steps: 0 });
467        let r = env.step(RIGHT);
468        assert!(r.terminated, "goal reached on the cap step terminates");
469        assert!(!r.truncated, "truncation must not also fire when terminated");
470    }
471
472    #[test]
473    fn out_of_range_and_invalid_actions_are_noops() {
474        let mut env = GridWorld::new();
475        env.restore_state(&GridWorldState { agent_row: 2, agent_col: 1, steps: 0 });
476
477        for &bad in &[-1_i64, 4, 99, i64::MIN, i64::MAX] {
478            env.restore_state(&GridWorldState { agent_row: 2, agent_col: 1, steps: 0 });
479            let r = env.step(bad);
480            assert_eq!(
481                (env.agent_row(), env.agent_col()),
482                (2, 1),
483                "invalid action {bad} should be a no-op (stay)"
484            );
485            assert_eq!(r.reward, STEP_PENALTY, "a no-op still counts as a penalised step");
486            assert!(!r.terminated);
487        }
488    }
489
490    #[test]
491    fn seeded_reset_is_reproducible() {
492        let mut a = GridWorld::with_seed(42);
493        let mut b = GridWorld::with_seed(7);
494        a.reset();
495        b.reset();
496        // The default layout is deterministic, so any seeds agree.
497        assert_eq!(a.get_observation(), b.get_observation(), "default layout is seed-independent");
498
499        // Determinism holds across a full rollout.
500        for action in [RIGHT, DOWN, RIGHT, DOWN, RIGHT] {
501            let ra = a.step(action);
502            let rb = b.step(action);
503            assert_eq!(ra.observation, rb.observation);
504            assert_eq!(ra.reward, rb.reward);
505            assert_eq!(ra.terminated, rb.terminated);
506            assert_eq!(ra.truncated, rb.truncated);
507            if ra.terminated || ra.truncated {
508                break;
509            }
510        }
511    }
512
513    #[test]
514    fn clone_restore_round_trips_next_step() {
515        let mut env = GridWorld::new();
516        env.reset();
517        // Advance a few steps so the snapshot is non-trivial.
518        env.step(RIGHT);
519        env.step(DOWN); // (1, 1) is a hole; restore to a safe interior instead.
520
521        env.restore_state(&GridWorldState { agent_row: 2, agent_col: 1, steps: 3 });
522        let snapshot = env.clone_state();
523        let result_a = env.step(RIGHT);
524
525        env.restore_state(&snapshot);
526        let result_b = env.step(RIGHT);
527
528        assert_eq!(result_a.observation, result_b.observation, "obs must reproduce bit-for-bit");
529        assert_eq!(result_a.reward, result_b.reward, "reward must reproduce bit-for-bit");
530        assert_eq!(result_a.terminated, result_b.terminated);
531        assert_eq!(result_a.truncated, result_b.truncated);
532    }
533
534    #[test]
535    fn action_space_is_discrete_four() {
536        let env = GridWorld::new();
537        let space = env.action_space();
538        assert_eq!(space.shape, vec![NUM_ACTIONS]);
539        assert!(matches!(space.space_type, SpaceType::Discrete(NUM_ACTIONS)));
540    }
541
542    #[test]
543    fn observation_space_is_box_sixteen() {
544        let env = GridWorld::new();
545        let space = env.observation_space();
546        assert_eq!(space.shape, vec![GRID_ROWS * GRID_COLS]);
547        assert!(matches!(space.space_type, SpaceType::Box));
548    }
549
550    #[test]
551    fn goal_reward_dominates_so_optimal_path_has_highest_return() {
552        // The shortest start->goal path costs a handful of step penalties
553        // and earns +1.0; its return must stay positive and exceed any
554        // hole outcome.
555        let max_path_cost = STEP_PENALTY * (DEFAULT_MAX_STEPS as f32);
556        assert!(GOAL_REWARD + max_path_cost > HOLE_REWARD, "goal must dominate the hole");
557        // The Manhattan distance from start to goal bounds the shortest
558        // path; that path's return must stay positive so the goal path
559        // beats any non-goal outcome.
560        let mut env = GridWorld::new();
561        env.restore_state(&GridWorldState { agent_row: 3, agent_col: 3, steps: 0 });
562        let manhattan = (env.agent_row() - START_ROW) + (env.agent_col() - START_COL);
563        let short_path_return = GOAL_REWARD + STEP_PENALTY * (manhattan as f32);
564        assert!(short_path_return > 0.0, "a short goal path has positive return");
565    }
566}