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}