Skip to main content

thrust_rl/env/games/
simple_bandit.rs

1//! Simple Contextual Bandit Environment
2//!
3//! This is a trivial environment for testing PPO implementation correctness:
4//! - State: Single binary value (0 or 1)
5//! - Actions: Two choices (0 or 1)
6//! - Optimal policy: Always choose action = state
7//! - Reward: +1.0 if action == state, 0.0 otherwise
8//! - Episodes: Fixed length of 100 steps
9//!
10//! This environment should reach 100% success rate (reward=1.0 every step)
11//! if PPO is implemented correctly. If it doesn't converge reliably, there's a
12//! bug.
13
14use rand::{Rng, SeedableRng};
15
16use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
17
18/// Snapshot of SimpleBandit's simulation state.
19///
20/// Captures the visible state (current context and step counter) but **not**
21/// the internal RNG. After `restore_state`, the next `step` will compute the
22/// reward using the restored `state`, and `terminated` matches the original
23/// trajectory. However, the *next sampled state* (drawn from the bandit's
24/// internal RNG inside `step`) is **not** reproduced: see the per-env
25/// snapshot semantics in [`SimpleBandit`].
26#[derive(Debug, Clone)]
27pub struct SimpleBanditState {
28    /// Current context (0.0 or 1.0)
29    pub state: f32,
30    /// Step counter
31    pub steps: usize,
32}
33
34/// Simple contextual bandit for testing PPO correctness.
35///
36/// # Snapshot semantics
37///
38/// `clone_state` / `restore_state` capture the current context and step
39/// counter, so the next `step(action)` produces the same `reward` and
40/// `terminated` flag it would have at snapshot time. The bandit's internal
41/// `StdRng` is **not** captured: the *next* state sampled inside `step` will
42/// differ between the snapshotted run and the restored run, so the
43/// `observation` returned by `step` is not reproduced bit-for-bit. The reward
44/// for the action taken at the snapshot point is still deterministic.
45#[derive(Debug)]
46pub struct SimpleBandit {
47    state: f32,
48    steps: usize,
49    max_steps: usize,
50    rng: rand::rngs::StdRng,
51}
52
53impl SimpleBandit {
54    /// Create a new simple bandit environment
55    pub fn new() -> Self {
56        Self { state: 0.0, steps: 0, max_steps: 100, rng: rand::rngs::StdRng::from_os_rng() }
57    }
58}
59
60impl Default for SimpleBandit {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl Environment for SimpleBandit {
67    type Action = i64;
68    type State = SimpleBanditState;
69
70    fn reset(&mut self) {
71        // Random start state (0 or 1)
72        self.state = self.rng.random_range(0..2) as f32;
73        self.steps = 0;
74    }
75
76    fn get_observation(&self) -> Vec<f32> {
77        vec![self.state]
78    }
79
80    fn step(&mut self, action: i64) -> StepResult {
81        // Reward +1 if action matches state, 0 otherwise
82        let reward = if action == self.state as i64 {
83            1.0
84        } else {
85            0.0
86        };
87
88        self.steps += 1;
89        let terminated = self.steps >= self.max_steps;
90
91        // Random next state
92        self.state = self.rng.random_range(0..2) as f32;
93
94        StepResult {
95            observation: self.get_observation(),
96            reward,
97            terminated,
98            truncated: false,
99            info: StepInfo::default(),
100        }
101    }
102
103    fn observation_space(&self) -> SpaceInfo {
104        SpaceInfo {
105            shape: vec![1], // Single value: 0 or 1
106            space_type: SpaceType::Box,
107        }
108    }
109
110    fn action_space(&self) -> SpaceInfo {
111        SpaceInfo {
112            shape: vec![],
113            space_type: SpaceType::Discrete(2), // Two actions: 0 or 1
114        }
115    }
116
117    fn render(&self) -> Vec<u8> {
118        Vec::new()
119    }
120
121    fn close(&mut self) {
122        // Nothing to clean up
123    }
124
125    fn clone_state(&self) -> SimpleBanditState {
126        SimpleBanditState { state: self.state, steps: self.steps }
127    }
128
129    fn restore_state(&mut self, state: &SimpleBanditState) {
130        self.state = state.state;
131        self.steps = state.steps;
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_simple_bandit_rewards() {
141        let mut env = SimpleBandit::new();
142        env.reset();
143
144        // Test correct action gives reward 1.0
145        let initial_state = env.state as i64;
146        let result = env.step(initial_state);
147        assert_eq!(result.reward, 1.0);
148
149        // Test incorrect action gives reward 0.0
150        env.state = 0.0;
151        let result = env.step(1);
152        assert_eq!(result.reward, 0.0);
153
154        env.state = 1.0;
155        let result = env.step(0);
156        assert_eq!(result.reward, 0.0);
157    }
158
159    #[test]
160    fn clone_restore_round_trips() {
161        let mut env = SimpleBandit::new();
162        env.reset();
163
164        // Step a few times to a non-initial state.
165        for _ in 0..5 {
166            env.step(0);
167        }
168        let snap = env.clone_state();
169        let pre_state = env.state;
170        let pre_steps = env.steps;
171
172        // Take an experimental step.
173        let r1 = env.step(pre_state as i64);
174
175        // Restore and take the same step again.
176        env.restore_state(&snap);
177        assert_eq!(env.state, pre_state, "state must round-trip");
178        assert_eq!(env.steps, pre_steps, "steps must round-trip");
179
180        let r2 = env.step(pre_state as i64);
181
182        // Reward depends only on (state, action) and is fully deterministic.
183        assert_eq!(r1.reward, r2.reward);
184        assert_eq!(r1.terminated, r2.terminated);
185        // observation reflects the *next* random state, which is not
186        // reproduced by restore_state (RNG is not snapshotted).
187    }
188
189    #[test]
190    fn test_episode_length() {
191        let mut env = SimpleBandit::new();
192        env.reset();
193
194        // Episode should terminate after 100 steps
195        for i in 0..99 {
196            let result = env.step(0);
197            assert!(!result.terminated, "Episode terminated early at step {}", i);
198        }
199
200        let result = env.step(0);
201        assert!(result.terminated, "Episode should terminate at step 100");
202    }
203}