thrust_rl/env/games/flickering_cartpole.rs
1//! Flickering CartPole — a partial-observability variant of [`CartPole`].
2//!
3//! Phase 3 of the recurrent-policy epic (#262). [`FlickeringCartPole`] wraps
4//! the fully-simulated [`CartPole`] and, on every observation, with a seeded
5//! probability `p` (default 0.5) replaces the **entire** observation with
6//! zeros ("flicker"). The underlying physics, termination / truncation
7//! thresholds, reward, and `max_steps = 500` are all inherited from `CartPole`
8//! unchanged — only the *visibility* of the observation is intermittently
9//! blanked. When the frame is not flickered, the full 4-D observation
10//! `[x, x_dot, theta, theta_dot]` is exposed intact.
11//!
12//! # Why this is a POMDP (and why velocity-masking was not)
13//!
14//! An earlier attempt at a memory-load-bearing CartPole simply dropped the two
15//! velocity coordinates (`MaskedCartPole`, kept in this crate for the record).
16//! A real 500k-step training run **disproved** that as a POMDP: a memoryless
17//! reactive controller on `[x, theta]` balances the pole for hundreds of steps
18//! (measured MLP mean return ~324 vs LSTM ~222 — the feedforward policy won).
19//! Masking velocities does not make memory load-bearing, because a reactive
20//! angle/position feedback loop is sufficient to balance CartPole.
21//!
22//! Flickering closes that loophole. This is the canonical Atari-POMDP protocol
23//! from Hausknecht & Stone, *"Deep Recurrent Q-Learning for Partially
24//! Observable MDPs"* (2015): with probability `p` the observed frame is
25//! entirely blanked. A feedforward policy cannot act on a zeroed frame — it has
26//! no state to fall back on and must emit an action from `[0, 0, 0, 0]`, which
27//! is uninformative. A recurrent policy carries its hidden state across the
28//! blanked gap and integrates the intermittent stream over time, so memory
29//! becomes load-bearing **by construction**: the only way to act sensibly on a
30//! flickered frame is to remember the last visible one.
31//!
32//! # Composition, not inheritance
33//!
34//! Rust has no inheritance, so `FlickeringCartPole` **embeds** a `CartPole` and
35//! delegates every [`Environment`] method to it, intercepting only
36//! [`Environment::reset`] / [`Environment::step`] (to draw the per-frame
37//! flicker decision) and [`Environment::get_observation`] (to blank the
38//! observation when the current frame is flickered). The observation space is
39//! still reported as 4-D — flickering never changes the observation *shape*,
40//! only its contents.
41//!
42//! # Seeding and determinism
43//!
44//! The flicker decisions are drawn from a dedicated seeded [`StdRng`],
45//! independent of the physics simulation. Two `FlickeringCartPole`s constructed
46//! with [`FlickeringCartPole::with_seed`] (same seed and probability) produce
47//! the **identical** flicker pattern given the same action sequence, regardless
48//! of the (thread-RNG-seeded) physics reset. This makes the flicker schedule
49//! reproducible for tests and experiments. Snapshot / restore
50//! ([`Environment::clone_state`] / [`Environment::restore_state`]) captures the
51//! flicker RNG as well as the physics state, so a restored env reproduces the
52//! same flicker stream — a stronger determinism guarantee than the
53//! RNG-consuming envs (Snake, Pong) that snapshot only the simulation step.
54
55use rand::{Rng, SeedableRng, rngs::StdRng};
56
57use crate::env::{
58 Environment, SpaceInfo, SpaceType, StepResult,
59 games::cartpole::{CartPole, CartPoleState},
60};
61
62/// Default flicker probability — the classic Hausknecht & Stone (2015)
63/// value: each frame is blanked with probability 0.5.
64pub const DEFAULT_FLICKER_PROBABILITY: f64 = 0.5;
65
66/// Snapshot of a [`FlickeringCartPole`]: the inner physics state, the current
67/// flicker flag, and the flicker RNG. Because the RNG is captured, restoring a
68/// snapshot reproduces the subsequent flicker stream exactly (in addition to
69/// the deterministic physics inherited from [`CartPole`]).
70#[derive(Debug, Clone)]
71pub struct FlickeringCartPoleState {
72 /// Inner CartPole physics snapshot.
73 inner: CartPoleState,
74 /// Whether the observation for the current frame is blanked.
75 flickered: bool,
76 /// Flicker RNG state at snapshot time.
77 rng: StdRng,
78}
79
80/// Flickering CartPole — a partially-observable variant of [`CartPole`] where
81/// each frame's observation is blanked to zeros with a seeded probability.
82///
83/// The observation is the full 4-D CartPole state
84/// `[x, x_dot, theta, theta_dot]` on a visible frame, or `[0, 0, 0, 0]` on a
85/// flickered frame. All physics, termination, truncation, and reward semantics
86/// are delegated to the inner [`CartPole`] unchanged; flickering affects only
87/// what the agent *observes*, never the underlying dynamics or reward.
88#[derive(Debug)]
89pub struct FlickeringCartPole {
90 /// Inner fully-simulated CartPole; owns the physics.
91 inner: CartPole,
92 /// Probability that any given frame is blanked (in `[0, 1]`).
93 flicker_prob: f64,
94 /// Dedicated flicker RNG, independent of the physics simulation.
95 rng: StdRng,
96 /// Whether the current frame's observation is blanked. Set on every
97 /// [`Environment::reset`] and [`Environment::step`]; read by
98 /// [`Environment::get_observation`].
99 flickered: bool,
100}
101
102impl FlickeringCartPole {
103 /// Create a flickering CartPole with the default probability
104 /// ([`DEFAULT_FLICKER_PROBABILITY`] = 0.5) and a flicker RNG seeded from
105 /// system entropy.
106 ///
107 /// Because the flicker RNG is entropy-seeded, independent instances (e.g.
108 /// the members of an [`EnvPool`](crate::env::pool::EnvPool)) get
109 /// **different** flicker streams, which is desirable for decorrelated
110 /// parallel rollouts. Use [`FlickeringCartPole::with_seed`] when a
111 /// reproducible flicker schedule is required.
112 pub fn new() -> Self {
113 Self::with_probability(DEFAULT_FLICKER_PROBABILITY)
114 }
115
116 /// Create a flickering CartPole with a custom flicker probability and a
117 /// flicker RNG seeded from system entropy.
118 ///
119 /// # Panics
120 ///
121 /// Panics if `flicker_prob` is not in `[0, 1]`.
122 pub fn with_probability(flicker_prob: f64) -> Self {
123 assert!(
124 (0.0..=1.0).contains(&flicker_prob),
125 "flicker probability must be in [0, 1], got {flicker_prob}"
126 );
127 Self {
128 inner: CartPole::new(),
129 flicker_prob,
130 rng: StdRng::from_os_rng(),
131 flickered: false,
132 }
133 }
134
135 /// Create a flickering CartPole with the default probability
136 /// ([`DEFAULT_FLICKER_PROBABILITY`] = 0.5) and a **seeded** flicker RNG for
137 /// a reproducible flicker schedule.
138 pub fn with_seed(seed: u64) -> Self {
139 Self::with_seed_and_probability(seed, DEFAULT_FLICKER_PROBABILITY)
140 }
141
142 /// Create a flickering CartPole with a custom flicker probability and a
143 /// **seeded** flicker RNG for a reproducible flicker schedule.
144 ///
145 /// Two instances built with the same `seed` and `flicker_prob` blank the
146 /// same frames given the same number of `reset`/`step` calls, independent
147 /// of the physics (whose reset perturbation uses the thread RNG).
148 ///
149 /// # Panics
150 ///
151 /// Panics if `flicker_prob` is not in `[0, 1]`.
152 pub fn with_seed_and_probability(seed: u64, flicker_prob: f64) -> Self {
153 assert!(
154 (0.0..=1.0).contains(&flicker_prob),
155 "flicker probability must be in [0, 1], got {flicker_prob}"
156 );
157 Self {
158 inner: CartPole::new(),
159 flicker_prob,
160 rng: StdRng::seed_from_u64(seed),
161 flickered: false,
162 }
163 }
164
165 /// The probability that any given frame is blanked.
166 pub fn flicker_probability(&self) -> f64 {
167 self.flicker_prob
168 }
169
170 /// Whether the observation for the *current* frame is blanked (zeroed).
171 ///
172 /// Reflects the flicker decision made by the most recent
173 /// [`Environment::reset`] or [`Environment::step`]. Primarily useful for
174 /// diagnostics and determinism tests.
175 pub fn is_flickered(&self) -> bool {
176 self.flickered
177 }
178
179 /// Draw a fresh flicker decision from the seeded RNG.
180 fn draw_flicker(&mut self) -> bool {
181 // `flicker_prob == 0.0` never blanks; `== 1.0` always blanks. Drawing
182 // unconditionally keeps the RNG stream advancing at one draw per frame
183 // regardless of `p`, so the schedule is a pure function of the seed.
184 self.rng.random::<f64>() < self.flicker_prob
185 }
186
187 /// The dimensionality of the (unflickered) observation.
188 const OBS_DIM: usize = 4;
189}
190
191impl Default for FlickeringCartPole {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197impl Environment for FlickeringCartPole {
198 type Action = i64;
199 type State = FlickeringCartPoleState;
200
201 fn reset(&mut self) {
202 self.inner.reset();
203 self.flickered = self.draw_flicker();
204 }
205
206 fn get_observation(&self) -> Vec<f32> {
207 if self.flickered {
208 vec![0.0; Self::OBS_DIM]
209 } else {
210 Environment::get_observation(&self.inner)
211 }
212 }
213
214 fn step(&mut self, action: i64) -> StepResult {
215 let mut result = self.inner.step(action);
216 self.flickered = self.draw_flicker();
217 if self.flickered {
218 // Blank the entire observation — the flicker protocol zeros the
219 // whole frame, not individual coordinates.
220 for v in result.observation.iter_mut() {
221 *v = 0.0;
222 }
223 }
224 result
225 }
226
227 fn observation_space(&self) -> SpaceInfo {
228 // Flickering never changes the observation *shape*, only its contents.
229 SpaceInfo { shape: vec![Self::OBS_DIM], space_type: SpaceType::Box }
230 }
231
232 fn action_space(&self) -> SpaceInfo {
233 self.inner.action_space()
234 }
235
236 fn render(&self) -> Vec<u8> {
237 self.inner.render()
238 }
239
240 fn close(&mut self) {
241 self.inner.close();
242 }
243
244 fn clone_state(&self) -> FlickeringCartPoleState {
245 FlickeringCartPoleState {
246 inner: self.inner.clone_state(),
247 flickered: self.flickered,
248 rng: self.rng.clone(),
249 }
250 }
251
252 fn restore_state(&mut self, state: &FlickeringCartPoleState) {
253 self.inner.restore_state(&state.inner);
254 self.flickered = state.flickered;
255 self.rng = state.rng.clone();
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn test_observation_space_is_four_dimensional() {
265 let env = FlickeringCartPole::new();
266 let obs_space = env.observation_space();
267 assert_eq!(obs_space.shape, vec![4], "flickering obs keeps CartPole's 4-D shape");
268 assert!(matches!(obs_space.space_type, SpaceType::Box));
269 }
270
271 #[test]
272 fn test_action_space_delegates() {
273 let env = FlickeringCartPole::new();
274 let action_space = env.action_space();
275 assert!(matches!(action_space.space_type, SpaceType::Discrete(2)));
276 }
277
278 #[test]
279 fn test_default_probability() {
280 let env = FlickeringCartPole::new();
281 assert_eq!(env.flicker_probability(), DEFAULT_FLICKER_PROBABILITY);
282 assert_eq!(env.flicker_probability(), 0.5);
283 }
284
285 #[test]
286 fn test_observation_length_is_always_four() {
287 // Whether flickered or not, the observation vector is always 4 long.
288 let mut env = FlickeringCartPole::with_seed_and_probability(7, 0.5);
289 env.reset();
290 assert_eq!(env.get_observation().len(), 4);
291 for i in 0..200 {
292 let result = env.step((i % 2) as i64);
293 assert_eq!(result.observation.len(), 4, "obs length invariant under flicker");
294 if result.terminated || result.truncated {
295 env.reset();
296 }
297 }
298 }
299
300 #[test]
301 fn test_flickered_observation_is_all_zeros() {
302 // With p = 1.0 every frame is blanked: observation must be all zeros.
303 let mut env = FlickeringCartPole::with_seed_and_probability(1, 1.0);
304 env.reset();
305 assert!(env.is_flickered(), "p=1.0 must blank every frame");
306 assert_eq!(env.get_observation(), vec![0.0; 4]);
307 let result = env.step(1);
308 assert!(env.is_flickered());
309 assert_eq!(result.observation, vec![0.0; 4], "stepped obs blanked under p=1.0");
310 }
311
312 #[test]
313 fn test_never_flickers_at_zero_probability() {
314 // With p = 0.0 no frame is ever blanked; obs equals the inner CartPole.
315 let mut env = FlickeringCartPole::with_seed_and_probability(2, 0.0);
316 env.reset();
317 assert!(!env.is_flickered(), "p=0.0 must never blank");
318 for i in 0..300 {
319 let result = env.step((i % 2) as i64);
320 assert!(!env.is_flickered(), "p=0.0 must never blank");
321 // A visible frame is exactly the inner (unmasked) observation.
322 assert_eq!(result.observation, Environment::get_observation(&env.inner));
323 if result.terminated || result.truncated {
324 env.reset();
325 }
326 }
327 }
328
329 #[test]
330 fn test_flicker_schedule_is_deterministic_under_seed() {
331 // Two envs with the same seed + probability blank the same frames given
332 // the same action sequence — independent of the (thread-RNG) physics.
333 let mut a = FlickeringCartPole::with_seed_and_probability(42, 0.5);
334 let mut b = FlickeringCartPole::with_seed_and_probability(42, 0.5);
335 a.reset();
336 b.reset();
337 assert_eq!(a.is_flickered(), b.is_flickered(), "reset flicker decision must match");
338
339 let mut any_flicker = false;
340 let mut any_visible = false;
341 for i in 0..500 {
342 let action = (i % 2) as i64;
343 a.step(action);
344 b.step(action);
345 assert_eq!(a.is_flickered(), b.is_flickered(), "flicker schedule diverged at step {i}");
346 any_flicker |= a.is_flickered();
347 any_visible |= !a.is_flickered();
348 }
349 // Sanity: at p=0.5 over 500 draws we must see both states.
350 assert!(any_flicker, "expected at least one flickered frame at p=0.5");
351 assert!(any_visible, "expected at least one visible frame at p=0.5");
352 }
353
354 #[test]
355 fn test_flicker_rate_is_approximately_p() {
356 // Empirically the blank rate over many frames should track p ≈ 0.5.
357 let mut env = FlickeringCartPole::with_seed_and_probability(123, 0.5);
358 env.reset();
359 let mut blanked = 0usize;
360 let n = 5000;
361 for i in 0..n {
362 env.step((i % 2) as i64);
363 if env.is_flickered() {
364 blanked += 1;
365 }
366 // Keep stepping past episode ends without resetting flicker RNG:
367 // resetting would still keep the schedule seeded, but we just want
368 // a long stream here.
369 if env.get_observation().is_empty() {
370 unreachable!();
371 }
372 }
373 let rate = blanked as f64 / n as f64;
374 assert!((rate - 0.5).abs() < 0.05, "blank rate {rate} should be ≈ 0.5");
375 }
376
377 #[test]
378 fn test_reward_and_done_unaffected_by_flicker() {
379 // Flickering blanks only the observation; reward/termination come from
380 // the inner CartPole and are unchanged.
381 let mut env = FlickeringCartPole::with_seed_and_probability(9, 0.5);
382 env.reset();
383 for i in 0..100 {
384 let result = env.step((i % 2) as i64);
385 assert!(result.reward == 0.0 || result.reward == 1.0, "reward inherited from CartPole");
386 if result.terminated || result.truncated {
387 env.reset();
388 }
389 }
390 }
391
392 #[test]
393 fn test_clone_restore_reproduces_flicker_stream() {
394 // Snapshotting captures the flicker RNG, so restore + step reproduces
395 // the same flicker decisions (and the deterministic physics).
396 let mut env = FlickeringCartPole::with_seed_and_probability(555, 0.5);
397 env.reset();
398 for i in 0..10 {
399 env.step((i % 2) as i64);
400 }
401 let snap = env.clone_state();
402
403 let mut first = Vec::new();
404 for i in 0..20 {
405 let r = env.step((i % 2) as i64);
406 first.push((env.is_flickered(), r.observation.clone(), r.reward));
407 }
408
409 env.restore_state(&snap);
410 let mut second = Vec::new();
411 for i in 0..20 {
412 let r = env.step((i % 2) as i64);
413 second.push((env.is_flickered(), r.observation.clone(), r.reward));
414 }
415
416 assert_eq!(first, second, "restore must reproduce flicker + physics stream");
417 }
418
419 #[test]
420 fn test_hundred_random_steps_no_panic() {
421 let mut env = FlickeringCartPole::with_seed(0);
422 env.reset();
423 for i in 0..100 {
424 let result = env.step((i % 2) as i64);
425 assert_eq!(result.observation.len(), 4);
426 if result.terminated || result.truncated {
427 env.reset();
428 }
429 }
430 }
431
432 #[test]
433 #[should_panic(expected = "flicker probability must be in [0, 1]")]
434 fn test_invalid_probability_panics() {
435 let _ = FlickeringCartPole::with_probability(1.5);
436 }
437}