Skip to main content

rlevo_core/
environment.rs

1//! Environment interaction protocol and snapshot types.
2//!
3//! This module defines the contract between an agent and a problem domain:
4//! - [`Environment`] — core trait with `reset` / `step` methods
5//! - [`Snapshot`] — per-step result carrying observation, reward, and status
6//! - [`SnapshotBase`] — default `Snapshot` implementation for most environments
7//! - [`EpisodeStatus`] — distinguishes running, terminated, and truncated episodes
8//! - [`SnapshotMetadata`] — optional named reward components and 3D positions
9//!
10//! [`SnapshotMetadata`]: crate::environment::SnapshotMetadata
11
12use crate::base::{Action, Observation, Reward, State};
13use std::collections::BTreeMap;
14use std::fmt::Debug;
15
16/// Describes the lifecycle status of an episode at a given step.
17///
18/// Separating `Terminated` from `Truncated` allows RL algorithms to correctly
19/// bootstrap the value function: a truncated episode still has future value,
20/// whereas a terminated one does not.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum EpisodeStatus {
23    /// The episode is still in progress.
24    Running,
25    /// The episode ended by reaching a terminal MDP state (goal, failure, etc.).
26    Terminated,
27    /// The episode ended because an external step limit was reached.
28    Truncated,
29}
30
31impl EpisodeStatus {
32    /// `true` when the episode loop should stop (`Terminated` or `Truncated`).
33    pub const fn is_done(self) -> bool {
34        matches!(self, Self::Terminated | Self::Truncated)
35    }
36
37    /// `true` only for intrinsic MDP termination.
38    pub const fn is_terminated(self) -> bool {
39        matches!(self, Self::Terminated)
40    }
41
42    /// `true` only for extrinsic step-limit truncation.
43    pub const fn is_truncated(self) -> bool {
44        matches!(self, Self::Truncated)
45    }
46}
47
48/// Named metadata emitted alongside a snapshot.
49///
50/// Used for shaped / multi-component reward logging. Keys are `&'static str`
51/// constants defined in each per-environment module to avoid magic strings at
52/// call sites.
53#[derive(Debug, Clone, Default)]
54pub struct SnapshotMetadata {
55    /// Named reward components (e.g. `"ctrl"`, `"goal"`, `"healthy"`).
56    pub components: BTreeMap<&'static str, f32>,
57    /// Named 3D positions for analysis (e.g. `"torso"`, `"com"`, `"main_body"`).
58    pub positions: BTreeMap<&'static str, [f32; 3]>,
59}
60
61impl SnapshotMetadata {
62    /// Creates an empty `SnapshotMetadata`.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Builder-style insert for a named reward component.
68    pub fn with(mut self, key: &'static str, value: f32) -> Self {
69        self.components.insert(key, value);
70        self
71    }
72
73    /// Builder-style insert for a named 3D position.
74    pub fn with_position(mut self, key: &'static str, xyz: [f32; 3]) -> Self {
75        self.positions.insert(key, xyz);
76        self
77    }
78}
79
80/// Error type for environment operations.
81///
82/// `EnvironmentError` captures failures that can occur during environment
83/// initialization, reset, or stepping. It provides detailed error messages
84/// and supports error chaining via the standard [`std::error::Error`] trait.
85///
86/// The enum is `#[non_exhaustive]`: downstream `match` expressions must carry a
87/// wildcard arm, so a future variant is not a breaking change.
88#[derive(Debug, thiserror::Error)]
89#[non_exhaustive]
90pub enum EnvironmentError {
91    /// An invalid or out-of-bounds action was provided.
92    #[error("Invalid action: {0}")]
93    InvalidAction(String),
94    /// Rendering or display failed.
95    #[error("Render failed: {0}")]
96    RenderFailed(String),
97    /// An I/O operation failed (wraps std::io::Error).
98    #[error("IO operation failed: {0}")]
99    IoError(#[from] std::io::Error),
100    /// A configuration-domain invariant failed during a lifecycle operation.
101    ///
102    /// A `reset()` may re-run construction-time work (e.g. rebuilding a
103    /// procedural world), so a config-domain invariant — a [`ConfigError`] —
104    /// can surface at reset, not only at construction. This variant is kept
105    /// **generic** (not tied to any one environment) so any lifecycle method
106    /// that re-validates config-domain state can propagate the failure with
107    /// `?`, avoiding a stringly-typed re-wrap.
108    ///
109    /// [`ConfigError`]: crate::config::ConfigError
110    #[error("Configuration error: {0}")]
111    Config(#[from] crate::config::ConfigError),
112    /// `step()` was called after the episode already ended.
113    ///
114    /// The action itself was legal; the *call sequence* was not. An episode that
115    /// has emitted a snapshot with [`Snapshot::is_done`] `== true` is over — the
116    /// only valid next lifecycle call is [`Environment::reset`]. Stepping again
117    /// would silently resurrect a finished episode (re-entering the MDP from a
118    /// terminal state, emitting rewards on a `Running` snapshot), so it is an
119    /// error rather than a no-op.
120    ///
121    /// The variant carries the [`EpisodeStatus`] that ended the episode, so the
122    /// caller can distinguish an intrinsic MDP termination
123    /// ([`EpisodeStatus::Terminated`]) from a wrapper-imposed truncation
124    /// ([`EpisodeStatus::Truncated`]).
125    #[error(
126        "step() called after the episode ended ({status:?}); call reset() before stepping again"
127    )]
128    StepAfterEpisodeEnd {
129        /// The status that ended the episode (`Terminated` or `Truncated`).
130        status: EpisodeStatus,
131    },
132}
133
134/// Snapshot trait defines the interface for environment state observations.
135///
136/// A snapshot captures the state of the environment at a single point in time,
137/// including the observed state, reward received, and episode status.
138/// The required method is `status()`; `is_done`, `is_terminated`, `is_truncated`,
139/// and `metadata` are provided as defaults.
140pub trait Snapshot<const R: usize>: Debug {
141    /// The observation type exposed to the agent at each step.
142    type ObservationType: Observation<R>;
143
144    /// The type of reward contained in this snapshot.
145    type RewardType: Reward;
146
147    /// Access the observed state.
148    fn observation(&self) -> &Self::ObservationType;
149
150    /// Access the reward received.
151    fn reward(&self) -> &Self::RewardType;
152
153    /// Episode lifecycle status for this step.
154    fn status(&self) -> EpisodeStatus;
155
156    /// `true` when the episode loop should stop.
157    fn is_done(&self) -> bool {
158        self.status().is_done()
159    }
160
161    /// `true` only for intrinsic MDP termination.
162    fn is_terminated(&self) -> bool {
163        self.status().is_terminated()
164    }
165
166    /// `true` only for extrinsic step-limit truncation.
167    fn is_truncated(&self) -> bool {
168        self.status().is_truncated()
169    }
170
171    /// Optional named reward components and position data.
172    fn metadata(&self) -> Option<&SnapshotMetadata> {
173        None
174    }
175}
176
177/// Default snapshot implementation for standard reinforcement learning observations.
178///
179/// `SnapshotBase` stores an observation, reward, [`EpisodeStatus`], and an
180/// optional [`SnapshotMetadata`]. Construct via the named constructors
181/// [`running`](Self::running), [`terminated`](Self::terminated), or
182/// [`truncated`](Self::truncated) — each of which leaves `metadata` as `None` —
183/// and attach metadata with the fluent [`with_metadata`](Self::with_metadata):
184///
185/// ```
186/// # use rlevo_core::environment::{Snapshot, SnapshotBase, SnapshotMetadata};
187/// # use rlevo_core::reward::ScalarReward;
188/// # use rlevo_core::base::Observation;
189/// # use serde::{Deserialize, Serialize};
190/// # #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
191/// # struct Obs(f32);
192/// # impl Observation<1> for Obs { fn shape() -> [usize; 1] { [1] } }
193/// let snap = SnapshotBase::running(Obs(0.0), ScalarReward(1.0))
194///     .with_metadata(SnapshotMetadata::new().with("ctrl", -0.25));
195/// assert_eq!(snap.metadata().unwrap().components["ctrl"], -0.25);
196/// ```
197///
198/// Because environments that emit metadata still return a `SnapshotBase`, they
199/// compose with wrappers such as `TimeLimit` that are bound to this type.
200///
201/// # Type Parameters
202///
203/// * `R` - The observation tensor rank
204/// * `ObservationType` - The type of observation (must implement `Observation<R>`)
205/// * `RewardType` - The type of reward (must implement `Reward`)
206#[derive(Debug, Clone)]
207pub struct SnapshotBase<const R: usize, ObservationType: Observation<R>, RewardType: Reward> {
208    /// The observation derived from the state.
209    pub observation: ObservationType,
210    /// The reward received from the last action.
211    pub reward: RewardType,
212    /// Episode lifecycle status.
213    pub status: EpisodeStatus,
214    /// Optional named reward components and positions for this step.
215    pub metadata: Option<SnapshotMetadata>,
216}
217
218impl<const R: usize, ObservationType: Observation<R>, RewardType: Reward>
219    SnapshotBase<R, ObservationType, RewardType>
220{
221    /// Snapshot for a step where the episode is still running.
222    pub fn running(observation: ObservationType, reward: RewardType) -> Self {
223        Self {
224            observation,
225            reward,
226            status: EpisodeStatus::Running,
227            metadata: None,
228        }
229    }
230
231    /// Snapshot for the step on which the MDP reached a terminal state.
232    pub fn terminated(observation: ObservationType, reward: RewardType) -> Self {
233        Self {
234            observation,
235            reward,
236            status: EpisodeStatus::Terminated,
237            metadata: None,
238        }
239    }
240
241    /// Snapshot for the step on which an external step limit was reached.
242    pub fn truncated(observation: ObservationType, reward: RewardType) -> Self {
243        Self {
244            observation,
245            reward,
246            status: EpisodeStatus::Truncated,
247            metadata: None,
248        }
249    }
250
251    /// Builder-style attachment of [`SnapshotMetadata`] to a snapshot.
252    ///
253    /// Chains off any of the named constructors; replaces any metadata already
254    /// present.
255    #[must_use]
256    pub fn with_metadata(mut self, metadata: SnapshotMetadata) -> Self {
257        self.metadata = Some(metadata);
258        self
259    }
260}
261
262impl<const R: usize, ObservationType: Observation<R>, RewardType: Reward> Snapshot<R>
263    for SnapshotBase<R, ObservationType, RewardType>
264{
265    type ObservationType = ObservationType;
266    type RewardType = RewardType;
267
268    fn observation(&self) -> &Self::ObservationType {
269        &self.observation
270    }
271
272    fn reward(&self) -> &Self::RewardType {
273        &self.reward
274    }
275
276    fn status(&self) -> EpisodeStatus {
277        self.status
278    }
279
280    fn metadata(&self) -> Option<&SnapshotMetadata> {
281        self.metadata.as_ref()
282    }
283}
284
285/// Interaction protocol between an agent and a problem domain.
286///
287/// An environment encapsulates the dynamics of a problem, processing actions and
288/// returning observations (snapshots) along with rewards. Environments are responsible
289/// for managing state, computing rewards, and determining episode termination.
290///
291/// # Type Parameters
292///
293/// * `R`  - Rank of the observation tensor (matches `Observation<R>` and `Snapshot<R>`).
294/// * `SR` - Rank of the state tensor (matches `State<SR>`).
295/// * `AR` - Rank of the action tensor (matches `Action<AR>`).
296///
297/// # Associated Types
298///
299/// * `StateType`       - The concrete state type for this environment.
300/// * `ObservationType` - The observation type exposed to the agent.
301/// * `ActionType`      - The action type this environment accepts.
302/// * `RewardType`      - The reward scalar type returned each step.
303/// * `SnapshotType`    - The snapshot type returned by `reset` and `step`.
304pub trait Environment<const R: usize, const SR: usize, const AR: usize> {
305    /// The concrete state type for this environment.
306    type StateType: State<SR>;
307
308    /// The observation type exposed to the agent.
309    type ObservationType: Observation<R>;
310
311    /// The concrete action type this environment accepts.
312    type ActionType: Action<AR>;
313
314    /// The reward scalar type returned by this environment.
315    type RewardType: Reward;
316
317    /// The snapshot type returned by reset and step operations.
318    type SnapshotType: Snapshot<R, ObservationType = Self::ObservationType, RewardType = Self::RewardType>;
319
320    /// Reset the environment to its initial state and return the first snapshot.
321    ///
322    /// The returned snapshot carries the initial observation, a reward of zero, and
323    /// [`EpisodeStatus::Running`]. Call this at the start of every episode before
324    /// calling [`step`](Self::step).
325    ///
326    /// # Errors
327    ///
328    /// Returns [`EnvironmentError`] if the environment cannot be initialised (e.g.
329    /// an I/O failure when loading level data).
330    fn reset(&mut self) -> Result<Self::SnapshotType, EnvironmentError>;
331
332    /// Execute one transition of the environment with the given action.
333    ///
334    /// Applies `action` to the current state, updates internal state, and
335    /// returns a snapshot containing the next observation, the immediate reward,
336    /// and the new [`EpisodeStatus`]. When [`Snapshot::is_done`] returns `true`
337    /// the episode is over; call [`reset`](Self::reset) to begin a new one.
338    ///
339    /// # Post-terminal contract
340    ///
341    /// Implementations **must** return [`EnvironmentError::StepAfterEpisodeEnd`]
342    /// when `step()` is called after a snapshot whose [`Snapshot::is_done`] is
343    /// `true`. A finished episode is not silently resumed, and the terminal
344    /// snapshot is not silently repeated: the call sequence is the caller's bug
345    /// and is reported as one. Call [`reset`](Self::reset) to begin a new
346    /// episode.
347    ///
348    /// The check belongs at the **top** of `step()`, before any state mutation,
349    /// so a rejected call leaves the environment untouched.
350    ///
351    /// **Migration note (alpha).** Only the `toy_text` family and the `TimeLimit`
352    /// wrapper currently enforce this. The behaviour of every other environment
353    /// after a terminal snapshot is **undefined** — see issue #289 for the
354    /// family-by-family rollout. Callers must not rely on it.
355    ///
356    /// # Errors
357    ///
358    /// Returns [`EnvironmentError::InvalidAction`] if the action is not legal in
359    /// the current state, [`EnvironmentError::StepAfterEpisodeEnd`] if the
360    /// episode has already ended (see the post-terminal contract above), or
361    /// another [`EnvironmentError`] variant if the step cannot complete (e.g. a
362    /// physics simulation failure).
363    fn step(&mut self, action: Self::ActionType) -> Result<Self::SnapshotType, EnvironmentError>;
364}
365
366/// Default-construction factory for environments, lifted off [`Environment`]
367/// (ADR-0011).
368///
369/// Construction is a separate concern from the behavioural [`Environment`]
370/// contract (`reset`/`step`). Keeping `new` here means transparent decorators
371/// — `RecordingTap`, `TuiEnvTap`, `TimeLimit` — implement only the behaviour
372/// they actually forward and are never forced to synthesise a degenerate
373/// standalone constructor just to satisfy a trait bound. They are always
374/// built from an existing inner environment instead.
375///
376/// Concrete environments implement this alongside [`Environment`]; generic
377/// code that needs to build an environment from nothing (rather than from a
378/// caller-supplied factory closure) bounds on `E: ConstructableEnv`.
379pub trait ConstructableEnv {
380    /// Create a new environment instance.
381    ///
382    /// `render` controls whether the environment emits display output on each
383    /// step; pass `false` for training runs where rendering overhead is
384    /// unwanted. Implementations that do not support rendering may ignore the
385    /// flag.
386    fn new(render: bool) -> Self;
387}
388
389#[cfg(test)]
390mod tests {
391    use serde::{Deserialize, Serialize};
392
393    use super::*;
394    use crate::action::DiscreteAction;
395
396    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
397    pub struct MockObservation {
398        /// The agent's current position in the range [0, 6]
399        position: i32,
400    }
401
402    impl Observation<1> for MockObservation {
403        fn shape() -> [usize; 1] {
404            [1]
405        }
406    }
407
408    // Mock types for testing using Random Walk (1D) environment with 7 states
409    // States: 0, 1, 2, 3, 4, 5, 6 (representing positions on a 1D line)
410    // Actions: 0 = move left, 1 = move right
411    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
412    pub struct MockState {
413        /// The agent's current position in the range [0, 6]
414        position: i32,
415    }
416
417    impl MockState {
418        fn new(position: i32) -> Self {
419            Self { position }
420        }
421
422        /// Check if position is within valid bounds
423        fn is_in_bounds(position: i32) -> bool {
424            (0..=6).contains(&position)
425        }
426    }
427
428    impl State<1> for MockState {
429        type Observation = MockObservation;
430        fn numel(&self) -> usize {
431            7
432        }
433
434        fn shape() -> [usize; 1] {
435            [7]
436        }
437
438        fn is_valid(&self) -> bool {
439            Self::is_in_bounds(self.position)
440        }
441
442        fn observe(&self) -> Self::Observation {
443            MockObservation {
444                position: self.position,
445            }
446        }
447    }
448
449    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
450    enum MockAction {
451        MoveLeft,  // position -= 1
452        MoveRight, // position +=1
453    }
454
455    impl Action<1> for MockAction {
456        fn is_valid(&self) -> bool {
457            true // any instance of the enum is a valid action
458        }
459
460        fn shape() -> [usize; 1] {
461            [1]
462        }
463    }
464
465    impl DiscreteAction<1> for MockAction {
466        const ACTION_COUNT: usize = 2;
467        fn from_index(index: usize) -> Self {
468            match index {
469                0 => MockAction::MoveLeft,
470                1 => MockAction::MoveRight,
471                _ => panic!("Unknown action index: {}", index),
472            }
473        }
474
475        fn to_index(&self) -> usize {
476            match self {
477                MockAction::MoveLeft => 0,
478                MockAction::MoveRight => 1,
479            }
480        }
481    }
482
483    use crate::reward::ScalarReward;
484
485    // Mock environment for testing: 1D random walk with 7 states
486    // The agent starts at position 3 (middle) and can move left or right.
487    // Episode terminates after 20 steps or when reaching boundaries (state 0 or 6).
488    // Reward: +1.0 for reaching the goal (state 6), -1.0 for falling off left (state < 0), 0.0 otherwise.
489    struct MockEnvironment {
490        current_state: MockState,
491        step_count: usize,
492        max_steps: usize,
493    }
494
495    impl MockEnvironment {
496        const START_STATE: i32 = 3;
497        const MAX_STEPS: usize = 20;
498        const GOAL_STATE: i32 = 6;
499
500        fn with_defaults(_render: bool) -> Self {
501            Self {
502                current_state: MockState::new(Self::START_STATE),
503                step_count: 0,
504                max_steps: Self::MAX_STEPS,
505            }
506        }
507    }
508
509    impl ConstructableEnv for MockEnvironment {
510        fn new(render: bool) -> Self {
511            Self::with_defaults(render)
512        }
513    }
514
515    impl Environment<1, 1, 1> for MockEnvironment {
516        type StateType = MockState;
517        type ObservationType = MockObservation;
518        type ActionType = MockAction;
519        type RewardType = ScalarReward;
520        type SnapshotType = SnapshotBase<1, MockObservation, ScalarReward>;
521
522        fn reset(&mut self) -> Result<Self::SnapshotType, EnvironmentError> {
523            self.current_state = MockState::new(Self::START_STATE);
524            self.step_count = 0;
525            Ok(SnapshotBase::running(
526                self.current_state.observe(),
527                ScalarReward(0.0),
528            ))
529        }
530
531        fn step(
532            &mut self,
533            action: Self::ActionType,
534        ) -> Result<Self::SnapshotType, EnvironmentError> {
535            if !action.is_valid() {
536                return Err(EnvironmentError::InvalidAction(format!(
537                    "Invalid action: {:?}.",
538                    action
539                )));
540            }
541
542            // Update state based on action
543            let next_position = if action == MockAction::MoveLeft {
544                self.current_state.position - 1 // move left one step
545            } else {
546                self.current_state.position + 1 // move right one step
547            };
548
549            // Check boundaries: valid positions are [0, 6]
550            let (new_state, reward, terminated) = if next_position < 0 {
551                (MockState::new(0), -1.0, true)
552            } else if next_position > 6 {
553                (MockState::new(6), -1.0, true)
554            } else {
555                let new_state = MockState::new(next_position);
556                let reward = if next_position == Self::GOAL_STATE {
557                    1.0
558                } else {
559                    0.0
560                };
561                let done = next_position == Self::GOAL_STATE;
562                (new_state, reward, done)
563            };
564
565            self.current_state = new_state;
566            self.step_count += 1;
567
568            let status = if terminated {
569                EpisodeStatus::Terminated
570            } else if self.step_count >= self.max_steps {
571                EpisodeStatus::Truncated
572            } else {
573                EpisodeStatus::Running
574            };
575
576            Ok(SnapshotBase {
577                observation: new_state.observe(),
578                reward: ScalarReward(reward),
579                status,
580                metadata: None,
581            })
582        }
583    }
584
585    // Custom snapshot implementation for advanced testing
586    #[derive(Debug, Clone)]
587    pub struct CustomSnapshot {
588        observation: MockObservation,
589        reward: ScalarReward,
590        status: EpisodeStatus,
591        step_count: usize,
592        cumulative_reward: f32,
593    }
594
595    impl Snapshot<1> for CustomSnapshot {
596        type ObservationType = MockObservation;
597        type RewardType = ScalarReward;
598
599        fn observation(&self) -> &MockObservation {
600            &self.observation
601        }
602
603        fn reward(&self) -> &ScalarReward {
604            &self.reward
605        }
606
607        fn status(&self) -> EpisodeStatus {
608            self.status
609        }
610    }
611
612    // Tests for Snapshot trait
613    #[test]
614    fn test_snapshot_base_creation() {
615        let obs = MockObservation { position: 42 };
616        let snapshot = SnapshotBase::running(obs, ScalarReward(1.5));
617
618        assert_eq!(snapshot.observation(), &obs);
619        assert_eq!(snapshot.reward(), &ScalarReward(1.5));
620        assert!(!snapshot.is_done());
621        assert_eq!(snapshot.status(), EpisodeStatus::Running);
622    }
623
624    #[test]
625    fn test_snapshot_base_terminal() {
626        let obs = MockObservation { position: 0 };
627        let snapshot = SnapshotBase::terminated(obs, ScalarReward(-1.0));
628
629        assert!(snapshot.is_done());
630        assert!(snapshot.is_terminated());
631        assert!(!snapshot.is_truncated());
632        assert_eq!(snapshot.reward(), &ScalarReward(-1.0));
633    }
634
635    #[test]
636    fn test_snapshot_base_clone() {
637        let obs = MockObservation { position: 10 };
638        let snapshot1 = SnapshotBase::running(obs, ScalarReward(0.5));
639        let snapshot2 = snapshot1.clone();
640
641        assert_eq!(snapshot1.observation(), snapshot2.observation());
642        assert_eq!(snapshot1.reward(), snapshot2.reward());
643        assert_eq!(snapshot1.is_done(), snapshot2.is_done());
644    }
645
646    #[test]
647    fn test_snapshot_debug() {
648        let obs = MockObservation { position: 5 };
649        let snapshot = SnapshotBase::terminated(obs, ScalarReward(2.0));
650        let debug_str = format!("{:?}", snapshot);
651
652        assert!(debug_str.contains("SnapshotBase"));
653        assert!(debug_str.contains("position: 5"));
654        assert!(debug_str.contains("reward: ScalarReward(2.0)"));
655        assert!(debug_str.contains("Terminated"));
656    }
657
658    // Tests for custom Snapshot implementations
659    #[test]
660    fn test_custom_snapshot_trait_impl() {
661        let snapshot = CustomSnapshot {
662            observation: MockObservation { position: 1 },
663            reward: ScalarReward(10.0),
664            status: EpisodeStatus::Running,
665            step_count: 5,
666            cumulative_reward: 25.0,
667        };
668
669        // Verify trait method access
670        assert_eq!(snapshot.observation().position, 1);
671        assert_eq!(snapshot.reward(), &ScalarReward(10.0));
672        assert!(!snapshot.is_done());
673
674        // Verify custom fields are accessible
675        assert_eq!(snapshot.step_count, 5);
676        assert_eq!(snapshot.cumulative_reward, 25.0);
677    }
678
679    // Tests for Environment trait
680    #[test]
681    fn test_environment_creation() {
682        let env = MockEnvironment::new(false);
683        assert_eq!(env.step_count, 0);
684    }
685
686    #[test]
687    fn test_environment_reset() {
688        let mut env = MockEnvironment::new(false);
689        let snapshot = env.reset().expect("Reset should succeed");
690
691        assert_eq!(snapshot.observation().position, 3);
692        assert_eq!(snapshot.reward(), &ScalarReward(0.0));
693        assert!(!snapshot.is_done());
694    }
695
696    #[test]
697    fn test_environment_step_valid_action() {
698        let mut env = MockEnvironment::new(false);
699        env.reset().expect("Reset should succeed");
700
701        let action = MockAction::MoveRight;
702        let snapshot = env
703            .step(action)
704            .expect("Step with valid action should succeed");
705
706        assert_eq!(snapshot.observation().position, 4);
707        assert_eq!(snapshot.reward(), &ScalarReward(0.0));
708    }
709
710    #[test]
711    fn test_environment_episode_termination() {
712        let mut env = MockEnvironment::new(false);
713        env.reset().expect("Reset should succeed");
714        env.current_state.position = 0;
715
716        // Move right toward the goal (state 6)
717        for i in 0..6 {
718            let action = MockAction::MoveRight;
719            let snapshot = env.step(action).expect("Step should succeed");
720
721            if i < 5 {
722                assert!(
723                    !snapshot.is_done(),
724                    "Episode should not be done before reaching goal"
725                );
726            } else {
727                assert!(
728                    snapshot.is_done(),
729                    "Episode should be done upon reaching goal"
730                );
731            }
732        }
733    }
734
735    #[test]
736    fn test_environment_reset_clears_state() {
737        let mut env = MockEnvironment::new(false);
738
739        // Run for 5 steps
740        env.reset().expect("Reset should succeed");
741        for _ in 0..5 {
742            let action = MockAction::MoveRight;
743            let _ = env.step(action);
744        }
745
746        // Reset and verify state is cleared
747        let snapshot = env.reset().expect("Second reset should succeed");
748        assert_eq!(snapshot.observation().position, 3);
749        assert!(!snapshot.is_done());
750    }
751
752    #[test]
753    fn test_environment_error_display() {
754        let error = EnvironmentError::InvalidAction("test action".to_string());
755        let display_str = format!("{}", error);
756        assert!(display_str.contains("Invalid action"));
757        assert!(display_str.contains("test action"));
758    }
759
760    #[test]
761    fn test_environment_error_io_conversion() {
762        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
763        let env_error = EnvironmentError::from(io_error);
764
765        match env_error {
766            EnvironmentError::IoError(_) => {
767                // Expected
768            }
769            _ => panic!("Expected IoError variant"),
770        }
771    }
772
773    #[test]
774    fn test_environment_error_step_after_episode_end_carries_status() {
775        for status in [EpisodeStatus::Terminated, EpisodeStatus::Truncated] {
776            let error = EnvironmentError::StepAfterEpisodeEnd { status };
777            match error {
778                EnvironmentError::StepAfterEpisodeEnd { status: carried } => {
779                    assert_eq!(
780                        carried, status,
781                        "StepAfterEpisodeEnd must carry the status that ended the episode"
782                    );
783                }
784                _ => panic!("Expected StepAfterEpisodeEnd variant"),
785            }
786        }
787    }
788
789    #[test]
790    fn test_environment_error_step_after_episode_end_display() {
791        let error = EnvironmentError::StepAfterEpisodeEnd {
792            status: EpisodeStatus::Terminated,
793        };
794        let display_str = format!("{error}");
795        assert!(
796            display_str.contains("after the episode ended"),
797            "Display must state that the episode had already ended, got: {display_str}"
798        );
799        assert!(
800            display_str.contains("Terminated"),
801            "Display must name the ending status, got: {display_str}"
802        );
803        assert!(
804            display_str.contains("reset()"),
805            "Display must point the caller at reset(), got: {display_str}"
806        );
807    }
808
809    #[test]
810    fn test_environment_error_source() {
811        let io_error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
812        let env_error = EnvironmentError::IoError(io_error);
813
814        use std::error::Error;
815        assert!(env_error.source().is_some());
816    }
817
818    #[test]
819    fn test_environment_multiple_episodes() {
820        let mut env = MockEnvironment::new(false);
821
822        for _episode in 0..3 {
823            let mut snapshot = env.reset().expect("Reset should succeed");
824            let mut step = 0;
825
826            while !snapshot.is_done() && step < 5 {
827                let action = MockAction::MoveRight;
828                snapshot = env.step(action).expect("Step should succeed");
829                step += 1;
830            }
831        }
832    }
833
834    #[test]
835    fn test_snapshot_reward_conversion() {
836        let observation = MockObservation { position: 1 };
837        let snapshot = SnapshotBase::running(observation, ScalarReward(42.5));
838
839        // RewardType implements Into<f32>
840        let reward_as_f32: f32 = (*snapshot.reward()).into();
841        assert_eq!(reward_as_f32, 42.5);
842    }
843
844    #[test]
845    fn test_snapshot_base_metadata_defaults_to_none() {
846        let obs = MockObservation { position: 1 };
847        let snapshot = SnapshotBase::running(obs, ScalarReward(0.0));
848        assert!(
849            snapshot.metadata().is_none(),
850            "named constructors must leave metadata unset"
851        );
852    }
853
854    #[test]
855    fn test_snapshot_base_with_metadata_on_every_status() {
856        let obs = MockObservation { position: 2 };
857        let meta = || SnapshotMetadata::new().with("shaping", -1.5);
858
859        for snapshot in [
860            SnapshotBase::running(obs, ScalarReward(0.0)).with_metadata(meta()),
861            SnapshotBase::terminated(obs, ScalarReward(0.0)).with_metadata(meta()),
862            SnapshotBase::truncated(obs, ScalarReward(0.0)).with_metadata(meta()),
863        ] {
864            let m = snapshot.metadata().expect("metadata must be Some");
865            assert_eq!(m.components.get("shaping"), Some(&-1.5));
866        }
867    }
868
869    #[test]
870    fn test_metadata_default_is_empty() {
871        let meta = SnapshotMetadata::default();
872        assert!(meta.components.is_empty());
873        assert!(meta.positions.is_empty());
874    }
875
876    #[test]
877    fn test_metadata_builder_components_and_positions() {
878        let meta = SnapshotMetadata::new()
879            .with("forward", 1.25)
880            .with("ctrl", -0.1)
881            .with_position("torso", [0.5, 0.0, 1.1])
882            .with_position("com", [0.4, 0.0, 0.9]);
883
884        assert_eq!(meta.components.len(), 2);
885        assert_eq!(meta.components.get("forward"), Some(&1.25));
886        assert_eq!(meta.positions.len(), 2);
887        assert_eq!(meta.positions.get("torso"), Some(&[0.5, 0.0, 1.1]));
888    }
889}