Skip to main content

thrust_rl/env/games/
masked_cartpole.rs

1//! Partially-observable CartPole (velocity-masked).
2//!
3//! Phase 3 of the recurrent-policy epic (#262). [`MaskedCartPole`] wraps the
4//! fully-simulated [`CartPole`] and drops the two velocity coordinates
5//! (`x_dot`, `theta_dot`) from the observation, exposing only the positional
6//! pair `[cart_position, pole_angle]`. The underlying physics, termination /
7//! truncation thresholds, reward, and `max_steps = 500` are all inherited
8//! from `CartPole` unchanged — only the observation projection differs.
9//!
10//! # Why this is a POMDP
11//!
12//! CartPole's 4-D state `[x, x_dot, theta, theta_dot]` is Markov: a
13//! feedforward policy can act optimally from a single observation. Masking
14//! the velocities leaves `[x, theta]`, which is **not** Markov — the optimal
15//! action depends on how fast the pole is falling, information no single
16//! frame carries. A memoryless policy must therefore plateau well below the
17//! CartPole-v1 "solved" bar (500), while a recurrent policy can integrate the
18//! positional stream over time to recover the hidden velocities and balance.
19//! This contrast is the load-bearing learning signal for the recurrent PPO
20//! stack (see `docs/RECURRENT_POLICY_DESIGN.md`).
21//!
22//! # Composition, not inheritance
23//!
24//! Rust has no inheritance, so `MaskedCartPole` **embeds** a `CartPole` and
25//! delegates every [`Environment`] method to it, intercepting only
26//! [`Environment::get_observation`], [`Environment::step`] (to project the
27//! returned observation), and [`Environment::observation_space`] (to report a
28//! 2-D shape).
29
30use crate::env::{
31    Environment, SpaceInfo, SpaceType, StepResult,
32    games::cartpole::{CartPole, CartPoleState},
33};
34
35/// Velocity-masked CartPole — a partially-observable variant of
36/// [`CartPole`].
37///
38/// The observation is projected from the full 4-D state
39/// `[x, x_dot, theta, theta_dot]` down to the 2-D positional pair
40/// `[x, theta]`; the cart/pole velocities are hidden. All physics,
41/// termination, truncation, and reward semantics are delegated to the inner
42/// [`CartPole`] unchanged.
43///
44/// # Snapshot semantics
45///
46/// [`Environment::clone_state`] / [`Environment::restore_state`] delegate to
47/// the inner `CartPole`, inheriting its fully-deterministic snapshot
48/// contract: restoring a snapshot and calling `step(action)` reproduces the
49/// same [`StepResult`] (with the masked observation).
50#[derive(Debug, Default)]
51pub struct MaskedCartPole {
52    inner: CartPole,
53}
54
55impl MaskedCartPole {
56    /// Create a new velocity-masked CartPole with default physics.
57    pub fn new() -> Self {
58        Self { inner: CartPole::new() }
59    }
60
61    /// Project a full 4-D CartPole observation `[x, x_dot, theta, theta_dot]`
62    /// down to the observable 2-D pair `[x, theta]`, dropping the velocity
63    /// coordinates at indices 1 and 3.
64    fn mask(full: &[f32]) -> Vec<f32> {
65        vec![full[0], full[2]]
66    }
67}
68
69impl Environment for MaskedCartPole {
70    type Action = i64;
71    type State = CartPoleState;
72
73    fn reset(&mut self) {
74        self.inner.reset();
75    }
76
77    fn get_observation(&self) -> Vec<f32> {
78        Self::mask(&Environment::get_observation(&self.inner))
79    }
80
81    fn step(&mut self, action: i64) -> StepResult {
82        let mut result = self.inner.step(action);
83        result.observation = Self::mask(&result.observation);
84        result
85    }
86
87    fn observation_space(&self) -> SpaceInfo {
88        SpaceInfo { shape: vec![2], space_type: SpaceType::Box }
89    }
90
91    fn action_space(&self) -> SpaceInfo {
92        self.inner.action_space()
93    }
94
95    fn render(&self) -> Vec<u8> {
96        self.inner.render()
97    }
98
99    fn close(&mut self) {
100        self.inner.close();
101    }
102
103    fn clone_state(&self) -> CartPoleState {
104        self.inner.clone_state()
105    }
106
107    fn restore_state(&mut self, state: &CartPoleState) {
108        self.inner.restore_state(state);
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_observation_is_two_dimensional() {
118        let env = MaskedCartPole::new();
119        let obs_space = env.observation_space();
120        assert_eq!(obs_space.shape, vec![2], "masked obs must be 2-D");
121        assert!(matches!(obs_space.space_type, SpaceType::Box));
122    }
123
124    #[test]
125    fn test_get_observation_drops_velocities() {
126        let mut env = MaskedCartPole::new();
127        env.reset();
128        let obs = env.get_observation();
129        assert_eq!(obs.len(), 2, "masked observation should have 2 elements");
130    }
131
132    #[test]
133    fn test_step_returns_masked_observation() {
134        let mut env = MaskedCartPole::new();
135        env.reset();
136        let result = env.step(1);
137        assert_eq!(result.observation.len(), 2, "stepped observation should be masked to 2-D");
138        assert!(result.reward == 0.0 || result.reward == 1.0, "reward inherited from CartPole");
139    }
140
141    #[test]
142    fn test_action_space_delegates() {
143        let env = MaskedCartPole::new();
144        let action_space = env.action_space();
145        assert!(matches!(action_space.space_type, SpaceType::Discrete(2)));
146    }
147
148    #[test]
149    fn test_masked_value_matches_full_positions() {
150        // The two exposed coordinates must be exactly the cart position and
151        // pole angle from the underlying full observation (indices 0 and 2).
152        let mut env = MaskedCartPole::new();
153        env.reset();
154        let full = Environment::get_observation(&env.inner);
155        let masked = env.get_observation();
156        assert_eq!(masked[0], full[0], "index 0 = cart position");
157        assert_eq!(masked[1], full[2], "index 1 = pole angle");
158    }
159
160    #[test]
161    fn test_hundred_random_steps_no_panic() {
162        let mut env = MaskedCartPole::new();
163        env.reset();
164        for i in 0..100 {
165            let result = env.step((i % 2) as i64);
166            assert_eq!(result.observation.len(), 2);
167            if result.terminated || result.truncated {
168                env.reset();
169            }
170        }
171    }
172}