1use rand::{SeedableRng, rngs::StdRng};
55
56use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
57
58pub const GRID_ROWS: usize = 4;
60
61pub const GRID_COLS: usize = 4;
63
64pub const NUM_ACTIONS: usize = 4;
66
67pub const STEP_PENALTY: f32 = -0.01;
69
70pub const GOAL_REWARD: f32 = 1.0;
72
73pub const HOLE_REWARD: f32 = -1.0;
75
76pub const DEFAULT_MAX_STEPS: usize = 100;
78
79pub const DEFAULT_SEED: u64 = 0;
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum Cell {
85 Start,
87 Empty,
89 Hole,
91 Goal,
93}
94
95const 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
113const START_ROW: usize = 0;
115const START_COL: usize = 0;
116
117#[derive(Debug, Clone)]
125pub struct GridWorldState {
126 pub agent_row: usize,
128 pub agent_col: usize,
130 pub steps: usize,
132}
133
134#[derive(Debug, Clone)]
140pub struct GridWorld {
141 agent_row: usize,
143
144 agent_col: usize,
146
147 steps: usize,
149
150 max_steps: usize,
152
153 #[allow(dead_code)]
156 rng: StdRng,
157}
158
159impl GridWorld {
160 pub fn new() -> Self {
162 Self::with_seed(DEFAULT_SEED)
163 }
164
165 pub fn with_seed(seed: u64) -> Self {
172 Self::with_seed_and_max_steps(seed, DEFAULT_MAX_STEPS)
173 }
174
175 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 pub fn agent_row(&self) -> usize {
190 self.agent_row
191 }
192
193 pub fn agent_col(&self) -> usize {
195 self.agent_col
196 }
197
198 pub fn agent_index(&self) -> usize {
200 self.agent_row * GRID_COLS + self.agent_col
201 }
202
203 fn cell_at(row: usize, col: usize) -> Cell {
205 DEFAULT_LAYOUT[row][col]
206 }
207
208 fn action_delta(action: i64) -> (i64, i64) {
211 match action {
212 0 => (-1, 0), 1 => (0, 1), 2 => (1, 0), 3 => (0, -1), _ => (0, 0), }
218 }
219}
220
221impl Default for GridWorld {
222 fn default() -> Self {
223 Self::new()
224 }
225}
226
227impl Environment for GridWorld {
228 type Action = i64;
231
232 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(a.get_observation(), b.get_observation(), "default layout is seed-independent");
498
499 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 env.step(RIGHT);
519 env.step(DOWN); 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 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 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}