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//! # I.i.d. vs. burst-structured (correlated) dropout
33//!
34//! The default dropout is **i.i.d.**: each frame is blanked independently with
35//! probability `p`. This is only *partially* memory-hard — at CartPole's
36//! control rate a reactive controller can compensate for isolated blanked
37//! frames, which is why the feedforward baseline does not collapse to chance
38//! (#298).
39//!
40//! Issue #302 adds an opt-in **burst-structured** mode (a `burst_len`
41//! parameter) that closes this reactive-compensation loophole while keeping the
42//! *same* overall blank rate `p`. Instead of drawing each frame independently,
43//! the visibility follows a two-state Markov chain (visible ↔ blank) whose mean
44//! blank-run length is `burst_len` (default [`DEFAULT_BURST_LEN`], in the 3–5
45//! range from the issue) and whose stationary blank fraction is still `p`. The
46//! mean visible-run length is set to `burst_len * (1 - p) / p` so the long-run
47//! blank rate matches the i.i.d. baseline exactly — the *only* difference is
48//! temporal correlation. Now blanks arrive in runs of several consecutive
49//! frames, which a reactive controller cannot bridge (its last real
50//! observation is several steps stale) but a recurrent policy can, by
51//! integrating over the gap. The apples-to-apples comparison (same `p`,
52//! i.i.d. vs. burst) isolates the effect of correlation on the memory
53//! advantage. Enable it with
54//! [`FlickeringCartPole::with_seed_probability_and_burst`]; the default
55//! constructors keep the i.i.d. behavior unchanged.
56//!
57//! # Composition, not inheritance
58//!
59//! Rust has no inheritance, so `FlickeringCartPole` **embeds** a `CartPole` and
60//! delegates every [`Environment`] method to it, intercepting only
61//! [`Environment::reset`] / [`Environment::step`] (to draw the per-frame
62//! flicker decision) and [`Environment::get_observation`] (to blank the
63//! observation when the current frame is flickered). The observation space is
64//! still reported as 4-D — flickering never changes the observation *shape*,
65//! only its contents.
66//!
67//! # Seeding and determinism
68//!
69//! The flicker decisions are drawn from a dedicated seeded [`StdRng`],
70//! independent of the physics simulation. Two `FlickeringCartPole`s constructed
71//! with [`FlickeringCartPole::with_seed`] (same seed and probability) produce
72//! the **identical** flicker pattern given the same action sequence, regardless
73//! of the (thread-RNG-seeded) physics reset. This makes the flicker schedule
74//! reproducible for tests and experiments. Snapshot / restore
75//! ([`Environment::clone_state`] / [`Environment::restore_state`]) captures the
76//! flicker RNG as well as the physics state, so a restored env reproduces the
77//! same flicker stream — a stronger determinism guarantee than the
78//! RNG-consuming envs (Snake, Pong) that snapshot only the simulation step.
79
80use rand::{Rng, SeedableRng, rngs::StdRng};
81
82use crate::env::{
83    Environment, SpaceInfo, SpaceType, StepResult,
84    games::cartpole::{CartPole, CartPoleState},
85};
86
87/// Default flicker probability — the classic Hausknecht & Stone (2015)
88/// value: each frame is blanked with probability 0.5.
89pub const DEFAULT_FLICKER_PROBABILITY: f64 = 0.5;
90
91/// Default mean blank-burst length for the correlated-occlusion mode (issue
92/// #302). Sits in the middle of the 3–5 range suggested by the issue: on
93/// average a blank, once started, lasts four consecutive frames.
94pub const DEFAULT_BURST_LEN: f64 = 4.0;
95
96/// Snapshot of a [`FlickeringCartPole`]: the inner physics state, the current
97/// flicker flag, and the flicker RNG. Because the RNG is captured, restoring a
98/// snapshot reproduces the subsequent flicker stream exactly (in addition to
99/// the deterministic physics inherited from [`CartPole`]).
100#[derive(Debug, Clone)]
101pub struct FlickeringCartPoleState {
102    /// Inner CartPole physics snapshot.
103    inner: CartPoleState,
104    /// Whether the observation for the current frame is blanked.
105    flickered: bool,
106    /// Flicker RNG state at snapshot time.
107    rng: StdRng,
108}
109
110/// Flickering CartPole — a partially-observable variant of [`CartPole`] where
111/// each frame's observation is blanked to zeros with a seeded probability.
112///
113/// The observation is the full 4-D CartPole state
114/// `[x, x_dot, theta, theta_dot]` on a visible frame, or `[0, 0, 0, 0]` on a
115/// flickered frame. All physics, termination, truncation, and reward semantics
116/// are delegated to the inner [`CartPole`] unchanged; flickering affects only
117/// what the agent *observes*, never the underlying dynamics or reward.
118#[derive(Debug)]
119pub struct FlickeringCartPole {
120    /// Inner fully-simulated CartPole; owns the physics.
121    inner: CartPole,
122    /// Probability that any given frame is blanked (in `[0, 1]`).
123    flicker_prob: f64,
124    /// Dedicated flicker RNG, independent of the physics simulation.
125    rng: StdRng,
126    /// Whether the current frame's observation is blanked. Set on every
127    /// [`Environment::reset`] and [`Environment::step`]; read by
128    /// [`Environment::get_observation`].
129    flickered: bool,
130    /// Mean blank-burst length for the correlated-occlusion (Markov) mode. When
131    /// `None`, dropout is i.i.d. per frame (the default / #298 behavior). When
132    /// `Some(l)`, visibility follows a two-state Markov chain whose mean
133    /// blank-run length is `l` and whose stationary blank fraction is
134    /// `flicker_prob`.
135    burst_len: Option<f64>,
136}
137
138impl FlickeringCartPole {
139    /// Create a flickering CartPole with the default probability
140    /// ([`DEFAULT_FLICKER_PROBABILITY`] = 0.5) and a flicker RNG seeded from
141    /// system entropy.
142    ///
143    /// Because the flicker RNG is entropy-seeded, independent instances (e.g.
144    /// the members of an [`EnvPool`](crate::env::pool::EnvPool)) get
145    /// **different** flicker streams, which is desirable for decorrelated
146    /// parallel rollouts. Use [`FlickeringCartPole::with_seed`] when a
147    /// reproducible flicker schedule is required.
148    pub fn new() -> Self {
149        Self::with_probability(DEFAULT_FLICKER_PROBABILITY)
150    }
151
152    /// Create a flickering CartPole with a custom flicker probability and a
153    /// flicker RNG seeded from system entropy.
154    ///
155    /// # Panics
156    ///
157    /// Panics if `flicker_prob` is not in `[0, 1]`.
158    pub fn with_probability(flicker_prob: f64) -> Self {
159        assert!(
160            (0.0..=1.0).contains(&flicker_prob),
161            "flicker probability must be in [0, 1], got {flicker_prob}"
162        );
163        Self {
164            inner: CartPole::new(),
165            flicker_prob,
166            rng: StdRng::from_os_rng(),
167            flickered: false,
168            burst_len: None,
169        }
170    }
171
172    /// Create a flickering CartPole with the default probability
173    /// ([`DEFAULT_FLICKER_PROBABILITY`] = 0.5) and a **seeded** flicker RNG for
174    /// a reproducible flicker schedule.
175    pub fn with_seed(seed: u64) -> Self {
176        Self::with_seed_and_probability(seed, DEFAULT_FLICKER_PROBABILITY)
177    }
178
179    /// Create a flickering CartPole with a custom flicker probability and a
180    /// **seeded** flicker RNG for a reproducible flicker schedule.
181    ///
182    /// Two instances built with the same `seed` and `flicker_prob` blank the
183    /// same frames given the same number of `reset`/`step` calls, independent
184    /// of the physics (whose reset perturbation uses the thread RNG).
185    ///
186    /// # Panics
187    ///
188    /// Panics if `flicker_prob` is not in `[0, 1]`.
189    pub fn with_seed_and_probability(seed: u64, flicker_prob: f64) -> Self {
190        assert!(
191            (0.0..=1.0).contains(&flicker_prob),
192            "flicker probability must be in [0, 1], got {flicker_prob}"
193        );
194        Self {
195            inner: CartPole::new(),
196            flicker_prob,
197            rng: StdRng::seed_from_u64(seed),
198            flickered: false,
199            burst_len: None,
200        }
201    }
202
203    /// Create a **burst-structured** (correlated-occlusion) flickering CartPole
204    /// with a seeded flicker RNG (issue #302).
205    ///
206    /// Visibility follows a two-state Markov chain (visible ↔ blank) with
207    /// stationary blank fraction `flicker_prob` and mean blank-run length
208    /// `burst_len`. The mean visible-run length is derived as
209    /// `burst_len * (1 - flicker_prob) / flicker_prob`, so the long-run blank
210    /// rate equals `flicker_prob` exactly — matching the i.i.d. baseline while
211    /// adding temporal correlation. See the [module docs](self) for the
212    /// rationale.
213    ///
214    /// # Panics
215    ///
216    /// Panics if any of the following hold:
217    /// - `flicker_prob` is not in the **open** interval `(0, 1)` (a Markov
218    ///   burst structure is only meaningful with both states reachable).
219    /// - `burst_len < 1.0` (a burst must last at least one frame).
220    /// - `flicker_prob > burst_len / (burst_len + 1)`. Beyond this limit the
221    ///   derived mean visible-run length `burst_len * (1 - flicker_prob) /
222    ///   flicker_prob` falls below one frame, which is geometrically
223    ///   ill-defined and would silently clamp the stationary blank rate to
224    ///   `burst_len / (burst_len + 1)` instead of `flicker_prob`. To reach a
225    ///   higher blank rate, increase `burst_len` rather than `flicker_prob`.
226    pub fn with_seed_probability_and_burst(seed: u64, flicker_prob: f64, burst_len: f64) -> Self {
227        assert!(
228            flicker_prob > 0.0 && flicker_prob < 1.0,
229            "burst mode requires flicker probability in (0, 1), got {flicker_prob}"
230        );
231        assert!(burst_len >= 1.0, "burst length must be >= 1.0, got {burst_len}");
232        let max_p = burst_len / (burst_len + 1.0);
233        assert!(
234            flicker_prob <= max_p,
235            "burst mode: flicker_prob {flicker_prob} exceeds the achievable maximum \
236             burst_len/(burst_len+1) = {max_p:.4} for burst_len {burst_len}; \
237             the mean visible-run length would be < 1 frame. \
238             Either reduce flicker_prob or increase burst_len."
239        );
240        Self {
241            inner: CartPole::new(),
242            flicker_prob,
243            rng: StdRng::seed_from_u64(seed),
244            flickered: false,
245            burst_len: Some(burst_len),
246        }
247    }
248
249    /// The probability that any given frame is blanked.
250    pub fn flicker_probability(&self) -> f64 {
251        self.flicker_prob
252    }
253
254    /// Whether the observation for the *current* frame is blanked (zeroed).
255    ///
256    /// Reflects the flicker decision made by the most recent
257    /// [`Environment::reset`] or [`Environment::step`]. Primarily useful for
258    /// diagnostics and determinism tests.
259    pub fn is_flickered(&self) -> bool {
260        self.flickered
261    }
262
263    /// The mean blank-burst length for the correlated-occlusion mode, or `None`
264    /// when dropout is i.i.d. per frame (the default).
265    pub fn burst_length(&self) -> Option<f64> {
266        self.burst_len
267    }
268
269    /// Draw a flicker decision from the **stationary** distribution (blank with
270    /// probability `flicker_prob`). Used on [`Environment::reset`] for both the
271    /// i.i.d. and burst modes — in both, the stationary blank fraction is `p`,
272    /// so a fresh episode starts blank with probability `p`.
273    fn draw_flicker(&mut self) -> bool {
274        // `flicker_prob == 0.0` never blanks; `== 1.0` always blanks. Drawing
275        // unconditionally keeps the RNG stream advancing at one draw per frame
276        // regardless of `p`, so the schedule is a pure function of the seed.
277        self.rng.random::<f64>() < self.flicker_prob
278    }
279
280    /// Advance the flicker state by one frame.
281    ///
282    /// - **I.i.d. mode** (`burst_len == None`): identical to [`draw_flicker`] —
283    ///   each frame is blanked independently with probability `flicker_prob`.
284    ///   Consumes exactly one RNG draw, so the schedule is byte-for-byte the
285    ///   #298 behavior.
286    /// - **Burst mode** (`burst_len == Some(l)`): a two-state Markov transition
287    ///   from the *current* `flickered` state. The per-step probability of
288    ///   switching out of a state is one over that state's mean run length
289    ///   (`1/l` out of blank; `1 / (l * (1 - p) / p)` out of visible), which
290    ///   yields geometric run lengths with the desired means and a stationary
291    ///   blank fraction of `p`.
292    ///
293    /// [`draw_flicker`]: Self::draw_flicker
294    fn advance_flicker(&mut self) -> bool {
295        match self.burst_len {
296            None => self.draw_flicker(),
297            Some(mean_blank_run) => {
298                let u = self.rng.random::<f64>();
299                if self.flickered {
300                    // Currently blank: leave the blank run with prob 1/l_b.
301                    let p_switch = (1.0 / mean_blank_run).clamp(0.0, 1.0);
302                    // Stay blank unless we switch to visible.
303                    u >= p_switch
304                } else {
305                    // Currently visible: enter a blank run with prob 1/l_v,
306                    // where l_v = l_b * (1 - p) / p.
307                    let mean_visible_run =
308                        mean_blank_run * (1.0 - self.flicker_prob) / self.flicker_prob;
309                    let p_switch = (1.0 / mean_visible_run).clamp(0.0, 1.0);
310                    u < p_switch
311                }
312            }
313        }
314    }
315
316    /// The dimensionality of the (unflickered) observation.
317    const OBS_DIM: usize = 4;
318}
319
320impl Default for FlickeringCartPole {
321    fn default() -> Self {
322        Self::new()
323    }
324}
325
326impl Environment for FlickeringCartPole {
327    type Action = i64;
328    type State = FlickeringCartPoleState;
329
330    fn reset(&mut self) {
331        self.inner.reset();
332        self.flickered = self.draw_flicker();
333    }
334
335    fn get_observation(&self) -> Vec<f32> {
336        if self.flickered {
337            vec![0.0; Self::OBS_DIM]
338        } else {
339            Environment::get_observation(&self.inner)
340        }
341    }
342
343    fn step(&mut self, action: i64) -> StepResult {
344        let mut result = self.inner.step(action);
345        self.flickered = self.advance_flicker();
346        if self.flickered {
347            // Blank the entire observation — the flicker protocol zeros the
348            // whole frame, not individual coordinates.
349            for v in result.observation.iter_mut() {
350                *v = 0.0;
351            }
352        }
353        result
354    }
355
356    fn observation_space(&self) -> SpaceInfo {
357        // Flickering never changes the observation *shape*, only its contents.
358        SpaceInfo { shape: vec![Self::OBS_DIM], space_type: SpaceType::Box }
359    }
360
361    fn action_space(&self) -> SpaceInfo {
362        self.inner.action_space()
363    }
364
365    fn render(&self) -> Vec<u8> {
366        self.inner.render()
367    }
368
369    fn close(&mut self) {
370        self.inner.close();
371    }
372
373    fn clone_state(&self) -> FlickeringCartPoleState {
374        FlickeringCartPoleState {
375            inner: self.inner.clone_state(),
376            flickered: self.flickered,
377            rng: self.rng.clone(),
378        }
379    }
380
381    fn restore_state(&mut self, state: &FlickeringCartPoleState) {
382        self.inner.restore_state(&state.inner);
383        self.flickered = state.flickered;
384        self.rng = state.rng.clone();
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_observation_space_is_four_dimensional() {
394        let env = FlickeringCartPole::new();
395        let obs_space = env.observation_space();
396        assert_eq!(obs_space.shape, vec![4], "flickering obs keeps CartPole's 4-D shape");
397        assert!(matches!(obs_space.space_type, SpaceType::Box));
398    }
399
400    #[test]
401    fn test_action_space_delegates() {
402        let env = FlickeringCartPole::new();
403        let action_space = env.action_space();
404        assert!(matches!(action_space.space_type, SpaceType::Discrete(2)));
405    }
406
407    #[test]
408    fn test_default_probability() {
409        let env = FlickeringCartPole::new();
410        assert_eq!(env.flicker_probability(), DEFAULT_FLICKER_PROBABILITY);
411        assert_eq!(env.flicker_probability(), 0.5);
412    }
413
414    #[test]
415    fn test_observation_length_is_always_four() {
416        // Whether flickered or not, the observation vector is always 4 long.
417        let mut env = FlickeringCartPole::with_seed_and_probability(7, 0.5);
418        env.reset();
419        assert_eq!(env.get_observation().len(), 4);
420        for i in 0..200 {
421            let result = env.step((i % 2) as i64);
422            assert_eq!(result.observation.len(), 4, "obs length invariant under flicker");
423            if result.terminated || result.truncated {
424                env.reset();
425            }
426        }
427    }
428
429    #[test]
430    fn test_flickered_observation_is_all_zeros() {
431        // With p = 1.0 every frame is blanked: observation must be all zeros.
432        let mut env = FlickeringCartPole::with_seed_and_probability(1, 1.0);
433        env.reset();
434        assert!(env.is_flickered(), "p=1.0 must blank every frame");
435        assert_eq!(env.get_observation(), vec![0.0; 4]);
436        let result = env.step(1);
437        assert!(env.is_flickered());
438        assert_eq!(result.observation, vec![0.0; 4], "stepped obs blanked under p=1.0");
439    }
440
441    #[test]
442    fn test_never_flickers_at_zero_probability() {
443        // With p = 0.0 no frame is ever blanked; obs equals the inner CartPole.
444        let mut env = FlickeringCartPole::with_seed_and_probability(2, 0.0);
445        env.reset();
446        assert!(!env.is_flickered(), "p=0.0 must never blank");
447        for i in 0..300 {
448            let result = env.step((i % 2) as i64);
449            assert!(!env.is_flickered(), "p=0.0 must never blank");
450            // A visible frame is exactly the inner (unmasked) observation.
451            assert_eq!(result.observation, Environment::get_observation(&env.inner));
452            if result.terminated || result.truncated {
453                env.reset();
454            }
455        }
456    }
457
458    #[test]
459    fn test_flicker_schedule_is_deterministic_under_seed() {
460        // Two envs with the same seed + probability blank the same frames given
461        // the same action sequence — independent of the (thread-RNG) physics.
462        let mut a = FlickeringCartPole::with_seed_and_probability(42, 0.5);
463        let mut b = FlickeringCartPole::with_seed_and_probability(42, 0.5);
464        a.reset();
465        b.reset();
466        assert_eq!(a.is_flickered(), b.is_flickered(), "reset flicker decision must match");
467
468        let mut any_flicker = false;
469        let mut any_visible = false;
470        for i in 0..500 {
471            let action = (i % 2) as i64;
472            a.step(action);
473            b.step(action);
474            assert_eq!(a.is_flickered(), b.is_flickered(), "flicker schedule diverged at step {i}");
475            any_flicker |= a.is_flickered();
476            any_visible |= !a.is_flickered();
477        }
478        // Sanity: at p=0.5 over 500 draws we must see both states.
479        assert!(any_flicker, "expected at least one flickered frame at p=0.5");
480        assert!(any_visible, "expected at least one visible frame at p=0.5");
481    }
482
483    #[test]
484    fn test_flicker_rate_is_approximately_p() {
485        // Empirically the blank rate over many frames should track p ≈ 0.5.
486        let mut env = FlickeringCartPole::with_seed_and_probability(123, 0.5);
487        env.reset();
488        let mut blanked = 0usize;
489        let n = 5000;
490        for i in 0..n {
491            env.step((i % 2) as i64);
492            if env.is_flickered() {
493                blanked += 1;
494            }
495            // Keep stepping past episode ends without resetting flicker RNG:
496            // resetting would still keep the schedule seeded, but we just want
497            // a long stream here.
498            if env.get_observation().is_empty() {
499                unreachable!();
500            }
501        }
502        let rate = blanked as f64 / n as f64;
503        assert!((rate - 0.5).abs() < 0.05, "blank rate {rate} should be ≈ 0.5");
504    }
505
506    #[test]
507    fn test_reward_and_done_unaffected_by_flicker() {
508        // Flickering blanks only the observation; reward/termination come from
509        // the inner CartPole and are unchanged.
510        let mut env = FlickeringCartPole::with_seed_and_probability(9, 0.5);
511        env.reset();
512        for i in 0..100 {
513            let result = env.step((i % 2) as i64);
514            assert!(result.reward == 0.0 || result.reward == 1.0, "reward inherited from CartPole");
515            if result.terminated || result.truncated {
516                env.reset();
517            }
518        }
519    }
520
521    #[test]
522    fn test_clone_restore_reproduces_flicker_stream() {
523        // Snapshotting captures the flicker RNG, so restore + step reproduces
524        // the same flicker decisions (and the deterministic physics).
525        let mut env = FlickeringCartPole::with_seed_and_probability(555, 0.5);
526        env.reset();
527        for i in 0..10 {
528            env.step((i % 2) as i64);
529        }
530        let snap = env.clone_state();
531
532        let mut first = Vec::new();
533        for i in 0..20 {
534            let r = env.step((i % 2) as i64);
535            first.push((env.is_flickered(), r.observation.clone(), r.reward));
536        }
537
538        env.restore_state(&snap);
539        let mut second = Vec::new();
540        for i in 0..20 {
541            let r = env.step((i % 2) as i64);
542            second.push((env.is_flickered(), r.observation.clone(), r.reward));
543        }
544
545        assert_eq!(first, second, "restore must reproduce flicker + physics stream");
546    }
547
548    #[test]
549    fn test_hundred_random_steps_no_panic() {
550        let mut env = FlickeringCartPole::with_seed(0);
551        env.reset();
552        for i in 0..100 {
553            let result = env.step((i % 2) as i64);
554            assert_eq!(result.observation.len(), 4);
555            if result.terminated || result.truncated {
556                env.reset();
557            }
558        }
559    }
560
561    #[test]
562    #[should_panic(expected = "flicker probability must be in [0, 1]")]
563    fn test_invalid_probability_panics() {
564        let _ = FlickeringCartPole::with_probability(1.5);
565    }
566
567    // ---- Burst-structured (correlated-occlusion) mode, issue #302 ----------
568
569    /// Collect the blank/visible flicker stream over `n` frames (stepping past
570    /// episode ends without resetting, to sample a long uninterrupted stream).
571    fn collect_flicker_stream(env: &mut FlickeringCartPole, n: usize) -> Vec<bool> {
572        env.reset();
573        let mut stream = Vec::with_capacity(n);
574        for i in 0..n {
575            env.step((i % 2) as i64);
576            stream.push(env.is_flickered());
577        }
578        stream
579    }
580
581    /// Mean length of consecutive `true` (blank) runs in a boolean stream.
582    fn mean_blank_run_length(stream: &[bool]) -> f64 {
583        let mut runs = Vec::new();
584        let mut cur = 0usize;
585        for &b in stream {
586            if b {
587                cur += 1;
588            } else if cur > 0 {
589                runs.push(cur);
590                cur = 0;
591            }
592        }
593        if cur > 0 {
594            runs.push(cur);
595        }
596        if runs.is_empty() {
597            0.0
598        } else {
599            runs.iter().sum::<usize>() as f64 / runs.len() as f64
600        }
601    }
602
603    #[test]
604    fn test_burst_mode_reports_burst_length() {
605        let env = FlickeringCartPole::with_seed_probability_and_burst(1, 0.5, 4.0);
606        assert_eq!(env.burst_length(), Some(4.0));
607        // The default i.i.d. constructor reports no burst length.
608        assert_eq!(FlickeringCartPole::new().burst_length(), None);
609    }
610
611    #[test]
612    fn test_burst_observation_shape_invariant() {
613        // Burst mode never changes the observation shape (still 4-D).
614        let mut env = FlickeringCartPole::with_seed_probability_and_burst(7, 0.5, 4.0);
615        assert_eq!(env.observation_space().shape, vec![4]);
616        env.reset();
617        assert_eq!(env.get_observation().len(), 4);
618        for i in 0..200 {
619            let r = env.step((i % 2) as i64);
620            assert_eq!(r.observation.len(), 4);
621            if r.terminated || r.truncated {
622                env.reset();
623            }
624        }
625    }
626
627    #[test]
628    fn test_burst_mode_blank_rate_matches_p() {
629        // The whole point of the burst construction: the stationary blank rate
630        // still equals p, so it is an apples-to-apples comparison with i.i.d.
631        // `p = 0.8` sits exactly at the `burst_len/(burst_len+1)` limit for
632        // `burst_len = 4.0`, so it is the boundary case the constructor allows.
633        for &p in &[0.3_f64, 0.5, 0.7, 0.8] {
634            let mut env = FlickeringCartPole::with_seed_probability_and_burst(123, p, 4.0);
635            let stream = collect_flicker_stream(&mut env, 20000);
636            let rate = stream.iter().filter(|&&b| b).count() as f64 / stream.len() as f64;
637            assert!(
638                (rate - p).abs() < 0.05,
639                "burst blank rate {rate} should track p={p} (same as i.i.d.)"
640            );
641        }
642    }
643
644    #[test]
645    fn test_burst_mode_produces_longer_runs_than_iid() {
646        // Burst mode must produce meaningfully longer blank runs than i.i.d. at
647        // the same p — that temporal correlation is what closes the reactive-
648        // compensation loophole.
649        let mut burst = FlickeringCartPole::with_seed_probability_and_burst(99, 0.5, 4.0);
650        let mut iid = FlickeringCartPole::with_seed_and_probability(99, 0.5);
651
652        let burst_run = mean_blank_run_length(&collect_flicker_stream(&mut burst, 20000));
653        let iid_run = mean_blank_run_length(&collect_flicker_stream(&mut iid, 20000));
654
655        // i.i.d. at p=0.5 has mean blank-run length ~1/(1-p) = 2.
656        assert!(iid_run < 2.5, "i.i.d. mean blank run {iid_run} should be ~2");
657        // Burst mode targets a mean blank-run length of 4.
658        assert!(
659            (burst_run - 4.0).abs() < 1.0,
660            "burst mean blank run {burst_run} should be ≈ 4.0"
661        );
662        assert!(burst_run > iid_run + 1.0, "burst runs must be longer than i.i.d. runs");
663    }
664
665    #[test]
666    fn test_burst_schedule_is_deterministic_under_seed() {
667        let mut a = FlickeringCartPole::with_seed_probability_and_burst(42, 0.5, 4.0);
668        let mut b = FlickeringCartPole::with_seed_probability_and_burst(42, 0.5, 4.0);
669        let sa = collect_flicker_stream(&mut a, 2000);
670        let sb = collect_flicker_stream(&mut b, 2000);
671        assert_eq!(sa, sb, "burst schedule must be identical under the same seed");
672    }
673
674    #[test]
675    fn test_burst_clone_restore_reproduces_stream() {
676        // The Markov state lives in `flickered` + `rng`, both captured in the
677        // snapshot, so restore reproduces the correlated stream.
678        let mut env = FlickeringCartPole::with_seed_probability_and_burst(555, 0.5, 4.0);
679        env.reset();
680        for i in 0..10 {
681            env.step((i % 2) as i64);
682        }
683        let snap = env.clone_state();
684        let mut first = Vec::new();
685        for i in 0..40 {
686            env.step((i % 2) as i64);
687            first.push(env.is_flickered());
688        }
689        env.restore_state(&snap);
690        let mut second = Vec::new();
691        for i in 0..40 {
692            env.step((i % 2) as i64);
693            second.push(env.is_flickered());
694        }
695        assert_eq!(first, second, "restore must reproduce the burst stream");
696    }
697
698    #[test]
699    #[should_panic(expected = "burst mode requires flicker probability in (0, 1)")]
700    fn test_burst_invalid_probability_panics() {
701        let _ = FlickeringCartPole::with_seed_probability_and_burst(0, 0.0, 4.0);
702    }
703
704    #[test]
705    #[should_panic(expected = "burst length must be >= 1.0")]
706    fn test_burst_invalid_length_panics() {
707        let _ = FlickeringCartPole::with_seed_probability_and_burst(0, 0.5, 0.5);
708    }
709
710    #[test]
711    #[should_panic(expected = "burst mode: flicker_prob")]
712    fn test_burst_prob_above_achievable_max_panics() {
713        // For burst_len = 4.0 the achievable max blank rate is 4/5 = 0.8.
714        // p = 0.9 exceeds it, so the mean visible-run length would fall below
715        // one frame and the stationary blank rate would silently clamp to 0.8.
716        let _ = FlickeringCartPole::with_seed_probability_and_burst(0, 0.9, 4.0);
717    }
718
719    #[test]
720    fn test_burst_prob_at_achievable_max_does_not_panic() {
721        // Exactly at the limit (p = burst_len/(burst_len+1) = 0.8 for
722        // burst_len = 4.0) the mean visible-run length is exactly one frame,
723        // which is valid — the constructor must accept it.
724        let env = FlickeringCartPole::with_seed_probability_and_burst(0, 0.8, 4.0);
725        assert_eq!(env.flicker_probability(), 0.8);
726        assert_eq!(env.burst_length(), Some(4.0));
727    }
728}