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}