use rand::{Rng, SeedableRng, rngs::StdRng};
use crate::env::{
Environment, SpaceInfo, SpaceType, StepResult,
games::cartpole::{CartPole, CartPoleState},
};
pub const DEFAULT_FLICKER_PROBABILITY: f64 = 0.5;
#[derive(Debug, Clone)]
pub struct FlickeringCartPoleState {
inner: CartPoleState,
flickered: bool,
rng: StdRng,
}
#[derive(Debug)]
pub struct FlickeringCartPole {
inner: CartPole,
flicker_prob: f64,
rng: StdRng,
flickered: bool,
}
impl FlickeringCartPole {
pub fn new() -> Self {
Self::with_probability(DEFAULT_FLICKER_PROBABILITY)
}
pub fn with_probability(flicker_prob: f64) -> Self {
assert!(
(0.0..=1.0).contains(&flicker_prob),
"flicker probability must be in [0, 1], got {flicker_prob}"
);
Self {
inner: CartPole::new(),
flicker_prob,
rng: StdRng::from_os_rng(),
flickered: false,
}
}
pub fn with_seed(seed: u64) -> Self {
Self::with_seed_and_probability(seed, DEFAULT_FLICKER_PROBABILITY)
}
pub fn with_seed_and_probability(seed: u64, flicker_prob: f64) -> Self {
assert!(
(0.0..=1.0).contains(&flicker_prob),
"flicker probability must be in [0, 1], got {flicker_prob}"
);
Self {
inner: CartPole::new(),
flicker_prob,
rng: StdRng::seed_from_u64(seed),
flickered: false,
}
}
pub fn flicker_probability(&self) -> f64 {
self.flicker_prob
}
pub fn is_flickered(&self) -> bool {
self.flickered
}
fn draw_flicker(&mut self) -> bool {
self.rng.random::<f64>() < self.flicker_prob
}
const OBS_DIM: usize = 4;
}
impl Default for FlickeringCartPole {
fn default() -> Self {
Self::new()
}
}
impl Environment for FlickeringCartPole {
type Action = i64;
type State = FlickeringCartPoleState;
fn reset(&mut self) {
self.inner.reset();
self.flickered = self.draw_flicker();
}
fn get_observation(&self) -> Vec<f32> {
if self.flickered {
vec![0.0; Self::OBS_DIM]
} else {
Environment::get_observation(&self.inner)
}
}
fn step(&mut self, action: i64) -> StepResult {
let mut result = self.inner.step(action);
self.flickered = self.draw_flicker();
if self.flickered {
for v in result.observation.iter_mut() {
*v = 0.0;
}
}
result
}
fn observation_space(&self) -> SpaceInfo {
SpaceInfo { shape: vec![Self::OBS_DIM], space_type: SpaceType::Box }
}
fn action_space(&self) -> SpaceInfo {
self.inner.action_space()
}
fn render(&self) -> Vec<u8> {
self.inner.render()
}
fn close(&mut self) {
self.inner.close();
}
fn clone_state(&self) -> FlickeringCartPoleState {
FlickeringCartPoleState {
inner: self.inner.clone_state(),
flickered: self.flickered,
rng: self.rng.clone(),
}
}
fn restore_state(&mut self, state: &FlickeringCartPoleState) {
self.inner.restore_state(&state.inner);
self.flickered = state.flickered;
self.rng = state.rng.clone();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_observation_space_is_four_dimensional() {
let env = FlickeringCartPole::new();
let obs_space = env.observation_space();
assert_eq!(obs_space.shape, vec![4], "flickering obs keeps CartPole's 4-D shape");
assert!(matches!(obs_space.space_type, SpaceType::Box));
}
#[test]
fn test_action_space_delegates() {
let env = FlickeringCartPole::new();
let action_space = env.action_space();
assert!(matches!(action_space.space_type, SpaceType::Discrete(2)));
}
#[test]
fn test_default_probability() {
let env = FlickeringCartPole::new();
assert_eq!(env.flicker_probability(), DEFAULT_FLICKER_PROBABILITY);
assert_eq!(env.flicker_probability(), 0.5);
}
#[test]
fn test_observation_length_is_always_four() {
let mut env = FlickeringCartPole::with_seed_and_probability(7, 0.5);
env.reset();
assert_eq!(env.get_observation().len(), 4);
for i in 0..200 {
let result = env.step((i % 2) as i64);
assert_eq!(result.observation.len(), 4, "obs length invariant under flicker");
if result.terminated || result.truncated {
env.reset();
}
}
}
#[test]
fn test_flickered_observation_is_all_zeros() {
let mut env = FlickeringCartPole::with_seed_and_probability(1, 1.0);
env.reset();
assert!(env.is_flickered(), "p=1.0 must blank every frame");
assert_eq!(env.get_observation(), vec![0.0; 4]);
let result = env.step(1);
assert!(env.is_flickered());
assert_eq!(result.observation, vec![0.0; 4], "stepped obs blanked under p=1.0");
}
#[test]
fn test_never_flickers_at_zero_probability() {
let mut env = FlickeringCartPole::with_seed_and_probability(2, 0.0);
env.reset();
assert!(!env.is_flickered(), "p=0.0 must never blank");
for i in 0..300 {
let result = env.step((i % 2) as i64);
assert!(!env.is_flickered(), "p=0.0 must never blank");
assert_eq!(result.observation, Environment::get_observation(&env.inner));
if result.terminated || result.truncated {
env.reset();
}
}
}
#[test]
fn test_flicker_schedule_is_deterministic_under_seed() {
let mut a = FlickeringCartPole::with_seed_and_probability(42, 0.5);
let mut b = FlickeringCartPole::with_seed_and_probability(42, 0.5);
a.reset();
b.reset();
assert_eq!(a.is_flickered(), b.is_flickered(), "reset flicker decision must match");
let mut any_flicker = false;
let mut any_visible = false;
for i in 0..500 {
let action = (i % 2) as i64;
a.step(action);
b.step(action);
assert_eq!(a.is_flickered(), b.is_flickered(), "flicker schedule diverged at step {i}");
any_flicker |= a.is_flickered();
any_visible |= !a.is_flickered();
}
assert!(any_flicker, "expected at least one flickered frame at p=0.5");
assert!(any_visible, "expected at least one visible frame at p=0.5");
}
#[test]
fn test_flicker_rate_is_approximately_p() {
let mut env = FlickeringCartPole::with_seed_and_probability(123, 0.5);
env.reset();
let mut blanked = 0usize;
let n = 5000;
for i in 0..n {
env.step((i % 2) as i64);
if env.is_flickered() {
blanked += 1;
}
if env.get_observation().is_empty() {
unreachable!();
}
}
let rate = blanked as f64 / n as f64;
assert!((rate - 0.5).abs() < 0.05, "blank rate {rate} should be ≈ 0.5");
}
#[test]
fn test_reward_and_done_unaffected_by_flicker() {
let mut env = FlickeringCartPole::with_seed_and_probability(9, 0.5);
env.reset();
for i in 0..100 {
let result = env.step((i % 2) as i64);
assert!(result.reward == 0.0 || result.reward == 1.0, "reward inherited from CartPole");
if result.terminated || result.truncated {
env.reset();
}
}
}
#[test]
fn test_clone_restore_reproduces_flicker_stream() {
let mut env = FlickeringCartPole::with_seed_and_probability(555, 0.5);
env.reset();
for i in 0..10 {
env.step((i % 2) as i64);
}
let snap = env.clone_state();
let mut first = Vec::new();
for i in 0..20 {
let r = env.step((i % 2) as i64);
first.push((env.is_flickered(), r.observation.clone(), r.reward));
}
env.restore_state(&snap);
let mut second = Vec::new();
for i in 0..20 {
let r = env.step((i % 2) as i64);
second.push((env.is_flickered(), r.observation.clone(), r.reward));
}
assert_eq!(first, second, "restore must reproduce flicker + physics stream");
}
#[test]
fn test_hundred_random_steps_no_panic() {
let mut env = FlickeringCartPole::with_seed(0);
env.reset();
for i in 0..100 {
let result = env.step((i % 2) as i64);
assert_eq!(result.observation.len(), 4);
if result.terminated || result.truncated {
env.reset();
}
}
}
#[test]
#[should_panic(expected = "flicker probability must be in [0, 1]")]
fn test_invalid_probability_panics() {
let _ = FlickeringCartPole::with_probability(1.5);
}
}