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
10use crate::base::{Action, Observation, Reward, State};
11use std::collections::BTreeMap;
12use std::fmt::Debug;
13
14/// Describes the lifecycle status of an episode at a given step.
15///
16/// Separating `Terminated` from `Truncated` allows RL algorithms to correctly
17/// bootstrap the value function: a truncated episode still has future value,
18/// whereas a terminated one does not.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum EpisodeStatus {
21    /// The episode is still in progress.
22    Running,
23    /// The episode ended by reaching a terminal MDP state (goal, failure, etc.).
24    Terminated,
25    /// The episode ended because an external step limit was reached.
26    Truncated,
27}
28
29impl EpisodeStatus {
30    /// `true` when the episode loop should stop (`Terminated` or `Truncated`).
31    pub const fn is_done(self) -> bool {
32        matches!(self, Self::Terminated | Self::Truncated)
33    }
34
35    /// `true` only for intrinsic MDP termination.
36    pub const fn is_terminated(self) -> bool {
37        matches!(self, Self::Terminated)
38    }
39
40    /// `true` only for extrinsic step-limit truncation.
41    pub const fn is_truncated(self) -> bool {
42        matches!(self, Self::Truncated)
43    }
44}
45
46/// Named metadata emitted alongside a snapshot.
47///
48/// Used for shaped / multi-component reward logging. Keys are `&'static str`
49/// constants defined in each per-environment module to avoid magic strings at
50/// call sites.
51#[derive(Debug, Clone, Default)]
52pub struct SnapshotMetadata {
53    /// Named reward components (e.g. `"ctrl"`, `"goal"`, `"healthy"`).
54    pub components: BTreeMap<&'static str, f32>,
55    /// Named 3D positions for analysis (e.g. `"torso"`, `"com"`, `"main_body"`).
56    pub positions: BTreeMap<&'static str, [f32; 3]>,
57}
58
59impl SnapshotMetadata {
60    /// Creates an empty `SnapshotMetadata`.
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Builder-style insert for a named reward component.
66    pub fn with(mut self, key: &'static str, value: f32) -> Self {
67        self.components.insert(key, value);
68        self
69    }
70
71    /// Builder-style insert for a named 3D position.
72    pub fn with_position(mut self, key: &'static str, xyz: [f32; 3]) -> Self {
73        self.positions.insert(key, xyz);
74        self
75    }
76}
77
78/// Error type for environment operations.
79///
80/// `EnvironmentError` captures failures that can occur during environment
81/// initialization, reset, or stepping. It provides detailed error messages
82/// and supports error chaining via the standard `Error` trait.
83///
84/// # Variants
85///
86/// * `InvalidAction` - The provided action is not valid in the current state
87/// * `RenderFailed` - Rendering/display operation failed
88/// * `IoError` - An I/O operation failed (wrapped std::io::Error)
89#[derive(Debug)]
90pub enum EnvironmentError {
91    /// An invalid or out-of-bounds action was provided.
92    InvalidAction(String),
93    /// Rendering or display failed.
94    RenderFailed(String),
95    /// An I/O operation failed (wraps std::io::Error).
96    IoError(std::io::Error),
97}
98
99impl std::error::Error for EnvironmentError {
100    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
101        match self {
102            EnvironmentError::IoError(io_err) => Some(io_err),
103            _ => None,
104        }
105    }
106}
107
108impl std::fmt::Display for EnvironmentError {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            EnvironmentError::InvalidAction(action_error) => {
112                write!(f, "Invalid action: {}", action_error)
113            }
114            EnvironmentError::RenderFailed(render_error) => {
115                write!(f, "Render failed: {}", render_error)
116            }
117            EnvironmentError::IoError(io_err) => {
118                write!(f, "IO operation failed: {}", io_err)
119            }
120        }
121    }
122}
123
124impl From<std::io::Error> for EnvironmentError {
125    fn from(error: std::io::Error) -> Self {
126        EnvironmentError::IoError(error)
127    }
128}
129
130/// Snapshot trait defines the interface for environment state observations.
131///
132/// A snapshot captures the state of the environment at a single point in time,
133/// including the observed state, reward received, and episode status.
134/// The required method is `status()`; `is_done`, `is_terminated`, `is_truncated`,
135/// and `metadata` are provided as defaults.
136pub trait Snapshot<const D: usize>: Debug {
137    type ObservationType: Observation<D>;
138
139    /// The type of reward contained in this snapshot.
140    type RewardType: Reward;
141
142    /// Access the observed state.
143    fn observation(&self) -> &Self::ObservationType;
144
145    /// Access the reward received.
146    fn reward(&self) -> &Self::RewardType;
147
148    /// Episode lifecycle status for this step.
149    fn status(&self) -> EpisodeStatus;
150
151    /// `true` when the episode loop should stop.
152    fn is_done(&self) -> bool {
153        self.status().is_done()
154    }
155
156    /// `true` only for intrinsic MDP termination.
157    fn is_terminated(&self) -> bool {
158        self.status().is_terminated()
159    }
160
161    /// `true` only for extrinsic step-limit truncation.
162    fn is_truncated(&self) -> bool {
163        self.status().is_truncated()
164    }
165
166    /// Optional named reward components and position data.
167    fn metadata(&self) -> Option<&SnapshotMetadata> {
168        None
169    }
170}
171
172/// Default snapshot implementation for standard reinforcement learning observations.
173///
174/// `SnapshotBase` stores an observation, reward, and [`EpisodeStatus`].
175/// Construct via the named constructors [`running`](Self::running),
176/// [`terminated`](Self::terminated), or [`truncated`](Self::truncated).
177///
178/// # Type Parameters
179///
180/// * `D` - The observation tensor rank
181/// * `ObservationType` - The type of observation (must implement `Observation<D>`)
182/// * `RewardType` - The type of reward (must implement `Reward`)
183#[derive(Debug, Clone)]
184pub struct SnapshotBase<const D: usize, ObservationType: Observation<D>, RewardType: Reward> {
185    /// The observation derived from the state.
186    pub observation: ObservationType,
187    /// The reward received from the last action.
188    pub reward: RewardType,
189    /// Episode lifecycle status.
190    pub status: EpisodeStatus,
191}
192
193impl<const D: usize, ObservationType: Observation<D>, RewardType: Reward>
194    SnapshotBase<D, ObservationType, RewardType>
195{
196    /// Snapshot for a step where the episode is still running.
197    pub fn running(observation: ObservationType, reward: RewardType) -> Self {
198        Self {
199            observation,
200            reward,
201            status: EpisodeStatus::Running,
202        }
203    }
204
205    /// Snapshot for the step on which the MDP reached a terminal state.
206    pub fn terminated(observation: ObservationType, reward: RewardType) -> Self {
207        Self {
208            observation,
209            reward,
210            status: EpisodeStatus::Terminated,
211        }
212    }
213
214    /// Snapshot for the step on which an external step limit was reached.
215    pub fn truncated(observation: ObservationType, reward: RewardType) -> Self {
216        Self {
217            observation,
218            reward,
219            status: EpisodeStatus::Truncated,
220        }
221    }
222}
223
224impl<const D: usize, ObservationType: Observation<D>, RewardType: Reward> Snapshot<D>
225    for SnapshotBase<D, ObservationType, RewardType>
226{
227    type ObservationType = ObservationType;
228    type RewardType = RewardType;
229
230    fn observation(&self) -> &Self::ObservationType {
231        &self.observation
232    }
233
234    fn reward(&self) -> &Self::RewardType {
235        &self.reward
236    }
237
238    fn status(&self) -> EpisodeStatus {
239        self.status
240    }
241}
242
243/// Interaction protocol between an agent and a problem domain.
244///
245/// An environment encapsulates the dynamics of a problem, processing actions and
246/// returning observations (snapshots) along with rewards. Environments are responsible
247/// for managing state, computing rewards, and determining episode termination.
248///
249/// # Type Parameters
250///
251/// * `D`  - Rank of the observation tensor (matches `Observation<D>` and `Snapshot<D>`).
252/// * `SD` - Rank of the state tensor (matches `State<SD>`).
253/// * `AD` - Rank of the action tensor (matches `Action<AD>`).
254///
255/// # Associated Types
256///
257/// * `StateType`       - The concrete state type for this environment.
258/// * `ObservationType` - The observation type exposed to the agent.
259/// * `ActionType`      - The action type this environment accepts.
260/// * `RewardType`      - The reward scalar type returned each step.
261/// * `SnapshotType`    - The snapshot type returned by `reset` and `step`.
262pub trait Environment<const D: usize, const SD: usize, const AD: usize> {
263    /// The concrete state type for this environment.
264    type StateType: State<SD>;
265
266    /// The observation type exposed to the agent.
267    type ObservationType: Observation<D>;
268
269    /// The concrete action type this environment accepts.
270    type ActionType: Action<AD>;
271
272    /// The reward scalar type returned by this environment.
273    type RewardType: Reward;
274
275    /// The snapshot type returned by reset and step operations.
276    type SnapshotType: Snapshot<D, ObservationType = Self::ObservationType, RewardType = Self::RewardType>;
277
278    /// Create a new environment instance.
279    ///
280    /// # Arguments
281    ///
282    /// * `render` - Whether to render/display the environment (if supported)
283    ///
284    /// # Returns
285    ///
286    /// A new instance of this environment.
287    fn new(render: bool) -> Self;
288
289    /// Reset the environment to its initial state.
290    ///
291    /// This method should reset all state and return an initial observation (snapshot)
292    /// of the environment. This is typically called at the start of each episode.
293    ///
294    /// # Returns
295    ///
296    /// A snapshot containing the initial state, reward (typically 0), and done=false,
297    /// or an error if reset fails.
298    fn reset(&mut self) -> Result<Self::SnapshotType, EnvironmentError>;
299
300    /// Execute one step of the environment with the given action.
301    ///
302    /// This method processes the action, updates internal state, and returns
303    /// an observation of the new state along with the reward received.
304    ///
305    /// # Arguments
306    ///
307    /// * `action` - The action to execute in the current state
308    ///
309    /// # Returns
310    ///
311    /// A snapshot containing the next state, reward, and done flag,
312    /// or an error if the step fails.
313    fn step(&mut self, action: Self::ActionType) -> Result<Self::SnapshotType, EnvironmentError>;
314}
315
316#[cfg(test)]
317mod tests {
318    use serde::{Deserialize, Serialize};
319
320    use super::*;
321    use crate::action::DiscreteAction;
322
323    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
324    pub struct MockObservation {
325        /// The agent's current position in the range [0, 6]
326        position: i32,
327    }
328
329    impl Observation<1> for MockObservation {
330        fn shape() -> [usize; 1] {
331            [1]
332        }
333    }
334
335    // Mock types for testing using Random Walk (1D) environment with 7 states
336    // States: 0, 1, 2, 3, 4, 5, 6 (representing positions on a 1D line)
337    // Actions: 0 = move left, 1 = move right
338    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
339    pub struct MockState {
340        /// The agent's current position in the range [0, 6]
341        position: i32,
342    }
343
344    impl MockState {
345        fn new(position: i32) -> Self {
346            Self { position }
347        }
348
349        /// Check if position is within valid bounds
350        fn is_in_bounds(position: i32) -> bool {
351            (0..=6).contains(&position)
352        }
353    }
354
355    impl State<1> for MockState {
356        type Observation = MockObservation;
357        fn numel(&self) -> usize {
358            7
359        }
360
361        fn shape() -> [usize; 1] {
362            [7]
363        }
364
365        fn is_valid(&self) -> bool {
366            Self::is_in_bounds(self.position)
367        }
368
369        fn observe(&self) -> Self::Observation {
370            MockObservation {
371                position: self.position,
372            }
373        }
374    }
375
376    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
377    enum MockAction {
378        MoveLeft,  // position -= 1
379        MoveRight, // position +=1
380    }
381
382    impl Action<1> for MockAction {
383        fn is_valid(&self) -> bool {
384            true // any instance of the enum is a valid action
385        }
386
387        fn shape() -> [usize; 1] {
388            [1]
389        }
390    }
391
392    impl DiscreteAction<1> for MockAction {
393        const ACTION_COUNT: usize = 2;
394        fn from_index(index: usize) -> Self {
395            match index {
396                0 => MockAction::MoveLeft,
397                1 => MockAction::MoveRight,
398                _ => panic!("Unknown action index: {}", index),
399            }
400        }
401
402        fn to_index(&self) -> usize {
403            match self {
404                MockAction::MoveLeft => 0,
405                MockAction::MoveRight => 1,
406            }
407        }
408    }
409
410    use crate::reward::ScalarReward;
411
412    // Mock environment for testing: 1D random walk with 7 states
413    // The agent starts at position 3 (middle) and can move left or right.
414    // Episode terminates after 20 steps or when reaching boundaries (state 0 or 6).
415    // Reward: +1.0 for reaching the goal (state 6), -1.0 for falling off left (state < 0), 0.0 otherwise.
416    struct MockEnvironment {
417        current_state: MockState,
418        step_count: usize,
419        max_steps: usize,
420    }
421
422    impl MockEnvironment {
423        const START_STATE: i32 = 3;
424        const MAX_STEPS: usize = 20;
425        const GOAL_STATE: i32 = 6;
426
427        fn with_defaults(_render: bool) -> Self {
428            Self {
429                current_state: MockState::new(Self::START_STATE),
430                step_count: 0,
431                max_steps: Self::MAX_STEPS,
432            }
433        }
434    }
435
436    impl Environment<1, 1, 1> for MockEnvironment {
437        type StateType = MockState;
438        type ObservationType = MockObservation;
439        type ActionType = MockAction;
440        type RewardType = ScalarReward;
441        type SnapshotType = SnapshotBase<1, MockObservation, ScalarReward>;
442
443        fn new(render: bool) -> Self {
444            Self::with_defaults(render)
445        }
446
447        fn reset(&mut self) -> Result<Self::SnapshotType, EnvironmentError> {
448            self.current_state = MockState::new(Self::START_STATE);
449            self.step_count = 0;
450            Ok(SnapshotBase::running(
451                self.current_state.observe(),
452                ScalarReward(0.0),
453            ))
454        }
455
456        fn step(
457            &mut self,
458            action: Self::ActionType,
459        ) -> Result<Self::SnapshotType, EnvironmentError> {
460            if !action.is_valid() {
461                return Err(EnvironmentError::InvalidAction(format!(
462                    "Invalid action: {:?}.",
463                    action
464                )));
465            }
466
467            // Update state based on action
468            let next_position = if action == MockAction::MoveLeft {
469                self.current_state.position - 1 // move left one step
470            } else {
471                self.current_state.position + 1 // move right one step
472            };
473
474            // Check boundaries: valid positions are [0, 6]
475            let (new_state, reward, terminated) = if next_position < 0 {
476                (MockState::new(0), -1.0, true)
477            } else if next_position > 6 {
478                (MockState::new(6), -1.0, true)
479            } else {
480                let new_state = MockState::new(next_position);
481                let reward = if next_position == Self::GOAL_STATE {
482                    1.0
483                } else {
484                    0.0
485                };
486                let done = next_position == Self::GOAL_STATE;
487                (new_state, reward, done)
488            };
489
490            self.current_state = new_state;
491            self.step_count += 1;
492
493            let status = if terminated {
494                EpisodeStatus::Terminated
495            } else if self.step_count >= self.max_steps {
496                EpisodeStatus::Truncated
497            } else {
498                EpisodeStatus::Running
499            };
500
501            Ok(SnapshotBase {
502                observation: new_state.observe(),
503                reward: ScalarReward(reward),
504                status,
505            })
506        }
507    }
508
509    // Custom snapshot implementation for advanced testing
510    #[derive(Debug, Clone)]
511    pub struct CustomSnapshot {
512        observation: MockObservation,
513        reward: ScalarReward,
514        status: EpisodeStatus,
515        step_count: usize,
516        cumulative_reward: f32,
517    }
518
519    impl Snapshot<1> for CustomSnapshot {
520        type ObservationType = MockObservation;
521        type RewardType = ScalarReward;
522
523        fn observation(&self) -> &MockObservation {
524            &self.observation
525        }
526
527        fn reward(&self) -> &ScalarReward {
528            &self.reward
529        }
530
531        fn status(&self) -> EpisodeStatus {
532            self.status
533        }
534    }
535
536    // Tests for Snapshot trait
537    #[test]
538    fn test_snapshot_base_creation() {
539        let obs = MockObservation { position: 42 };
540        let snapshot = SnapshotBase::running(obs, ScalarReward(1.5));
541
542        assert_eq!(snapshot.observation(), &obs);
543        assert_eq!(snapshot.reward(), &ScalarReward(1.5));
544        assert!(!snapshot.is_done());
545        assert_eq!(snapshot.status(), EpisodeStatus::Running);
546    }
547
548    #[test]
549    fn test_snapshot_base_terminal() {
550        let obs = MockObservation { position: 0 };
551        let snapshot = SnapshotBase::terminated(obs, ScalarReward(-1.0));
552
553        assert!(snapshot.is_done());
554        assert!(snapshot.is_terminated());
555        assert!(!snapshot.is_truncated());
556        assert_eq!(snapshot.reward(), &ScalarReward(-1.0));
557    }
558
559    #[test]
560    fn test_snapshot_base_clone() {
561        let obs = MockObservation { position: 10 };
562        let snapshot1 = SnapshotBase::running(obs, ScalarReward(0.5));
563        let snapshot2 = snapshot1.clone();
564
565        assert_eq!(snapshot1.observation(), snapshot2.observation());
566        assert_eq!(snapshot1.reward(), snapshot2.reward());
567        assert_eq!(snapshot1.is_done(), snapshot2.is_done());
568    }
569
570    #[test]
571    fn test_snapshot_debug() {
572        let obs = MockObservation { position: 5 };
573        let snapshot = SnapshotBase::terminated(obs, ScalarReward(2.0));
574        let debug_str = format!("{:?}", snapshot);
575
576        assert!(debug_str.contains("SnapshotBase"));
577        assert!(debug_str.contains("position: 5"));
578        assert!(debug_str.contains("reward: ScalarReward(2.0)"));
579        assert!(debug_str.contains("Terminated"));
580    }
581
582    // Tests for custom Snapshot implementations
583    #[test]
584    fn test_custom_snapshot_trait_impl() {
585        let snapshot = CustomSnapshot {
586            observation: MockObservation { position: 1 },
587            reward: ScalarReward(10.0),
588            status: EpisodeStatus::Running,
589            step_count: 5,
590            cumulative_reward: 25.0,
591        };
592
593        // Verify trait method access
594        assert_eq!(snapshot.observation().position, 1);
595        assert_eq!(snapshot.reward(), &ScalarReward(10.0));
596        assert!(!snapshot.is_done());
597
598        // Verify custom fields are accessible
599        assert_eq!(snapshot.step_count, 5);
600        assert_eq!(snapshot.cumulative_reward, 25.0);
601    }
602
603    // Tests for Environment trait
604    #[test]
605    fn test_environment_creation() {
606        let env = MockEnvironment::new(false);
607        assert_eq!(env.step_count, 0);
608    }
609
610    #[test]
611    fn test_environment_reset() {
612        let mut env = MockEnvironment::new(false);
613        let snapshot = env.reset().expect("Reset should succeed");
614
615        assert_eq!(snapshot.observation().position, 3);
616        assert_eq!(snapshot.reward(), &ScalarReward(0.0));
617        assert!(!snapshot.is_done());
618    }
619
620    #[test]
621    fn test_environment_step_valid_action() {
622        let mut env = MockEnvironment::new(false);
623        env.reset().expect("Reset should succeed");
624
625        let action = MockAction::MoveRight;
626        let snapshot = env
627            .step(action)
628            .expect("Step with valid action should succeed");
629
630        assert_eq!(snapshot.observation().position, 4);
631        assert_eq!(snapshot.reward(), &ScalarReward(0.0));
632    }
633
634    #[test]
635    fn test_environment_episode_termination() {
636        let mut env = MockEnvironment::new(false);
637        env.reset().expect("Reset should succeed");
638        env.current_state.position = 0;
639
640        // Move right toward the goal (state 6)
641        for i in 0..6 {
642            let action = MockAction::MoveRight;
643            let snapshot = env.step(action).expect("Step should succeed");
644
645            if i < 5 {
646                assert!(
647                    !snapshot.is_done(),
648                    "Episode should not be done before reaching goal"
649                );
650            } else {
651                assert!(
652                    snapshot.is_done(),
653                    "Episode should be done upon reaching goal"
654                );
655            }
656        }
657    }
658
659    #[test]
660    fn test_environment_reset_clears_state() {
661        let mut env = MockEnvironment::new(false);
662
663        // Run for 5 steps
664        env.reset().expect("Reset should succeed");
665        for _ in 0..5 {
666            let action = MockAction::MoveRight;
667            let _ = env.step(action);
668        }
669
670        // Reset and verify state is cleared
671        let snapshot = env.reset().expect("Second reset should succeed");
672        assert_eq!(snapshot.observation().position, 3);
673        assert!(!snapshot.is_done());
674    }
675
676    #[test]
677    fn test_environment_error_display() {
678        let error = EnvironmentError::InvalidAction("test action".to_string());
679        let display_str = format!("{}", error);
680        assert!(display_str.contains("Invalid action"));
681        assert!(display_str.contains("test action"));
682    }
683
684    #[test]
685    fn test_environment_error_io_conversion() {
686        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
687        let env_error = EnvironmentError::from(io_error);
688
689        match env_error {
690            EnvironmentError::IoError(_) => {
691                // Expected
692            }
693            _ => panic!("Expected IoError variant"),
694        }
695    }
696
697    #[test]
698    fn test_environment_error_source() {
699        let io_error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
700        let env_error = EnvironmentError::IoError(io_error);
701
702        use std::error::Error;
703        assert!(env_error.source().is_some());
704    }
705
706    #[test]
707    fn test_environment_multiple_episodes() {
708        let mut env = MockEnvironment::new(false);
709
710        for _episode in 0..3 {
711            let mut snapshot = env.reset().expect("Reset should succeed");
712            let mut step = 0;
713
714            while !snapshot.is_done() && step < 5 {
715                let action = MockAction::MoveRight;
716                snapshot = env.step(action).expect("Step should succeed");
717                step += 1;
718            }
719        }
720    }
721
722    #[test]
723    fn test_snapshot_reward_conversion() {
724        let observation = MockObservation { position: 1 };
725        let snapshot = SnapshotBase::running(observation, ScalarReward(42.5));
726
727        // RewardType implements Into<f32>
728        let reward_as_f32: f32 = (*snapshot.reward()).into();
729        assert_eq!(reward_as_f32, 42.5);
730    }
731
732    #[test]
733    fn test_metadata_default_is_empty() {
734        let meta = SnapshotMetadata::default();
735        assert!(meta.components.is_empty());
736        assert!(meta.positions.is_empty());
737    }
738
739    #[test]
740    fn test_metadata_builder_components_and_positions() {
741        let meta = SnapshotMetadata::new()
742            .with("forward", 1.25)
743            .with("ctrl", -0.1)
744            .with_position("torso", [0.5, 0.0, 1.1])
745            .with_position("com", [0.4, 0.0, 0.9]);
746
747        assert_eq!(meta.components.len(), 2);
748        assert_eq!(meta.components.get("forward"), Some(&1.25));
749        assert_eq!(meta.positions.len(), 2);
750        assert_eq!(meta.positions.get("torso"), Some(&[0.5, 0.0, 1.1]));
751    }
752}