Skip to main content

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}