1use crate::base::{Action, Observation, Reward, State};
13use std::collections::BTreeMap;
14use std::fmt::Debug;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum EpisodeStatus {
23 Running,
25 Terminated,
27 Truncated,
29}
30
31impl EpisodeStatus {
32 pub const fn is_done(self) -> bool {
34 matches!(self, Self::Terminated | Self::Truncated)
35 }
36
37 pub const fn is_terminated(self) -> bool {
39 matches!(self, Self::Terminated)
40 }
41
42 pub const fn is_truncated(self) -> bool {
44 matches!(self, Self::Truncated)
45 }
46}
47
48#[derive(Debug, Clone, Default)]
54pub struct SnapshotMetadata {
55 pub components: BTreeMap<&'static str, f32>,
57 pub positions: BTreeMap<&'static str, [f32; 3]>,
59}
60
61impl SnapshotMetadata {
62 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn with(mut self, key: &'static str, value: f32) -> Self {
69 self.components.insert(key, value);
70 self
71 }
72
73 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#[derive(Debug, thiserror::Error)]
89#[non_exhaustive]
90pub enum EnvironmentError {
91 #[error("Invalid action: {0}")]
93 InvalidAction(String),
94 #[error("Render failed: {0}")]
96 RenderFailed(String),
97 #[error("IO operation failed: {0}")]
99 IoError(#[from] std::io::Error),
100 #[error("Configuration error: {0}")]
111 Config(#[from] crate::config::ConfigError),
112 #[error(
126 "step() called after the episode ended ({status:?}); call reset() before stepping again"
127 )]
128 StepAfterEpisodeEnd {
129 status: EpisodeStatus,
131 },
132}
133
134pub trait Snapshot<const R: usize>: Debug {
141 type ObservationType: Observation<R>;
143
144 type RewardType: Reward;
146
147 fn observation(&self) -> &Self::ObservationType;
149
150 fn reward(&self) -> &Self::RewardType;
152
153 fn status(&self) -> EpisodeStatus;
155
156 fn is_done(&self) -> bool {
158 self.status().is_done()
159 }
160
161 fn is_terminated(&self) -> bool {
163 self.status().is_terminated()
164 }
165
166 fn is_truncated(&self) -> bool {
168 self.status().is_truncated()
169 }
170
171 fn metadata(&self) -> Option<&SnapshotMetadata> {
173 None
174 }
175}
176
177#[derive(Debug, Clone)]
207pub struct SnapshotBase<const R: usize, ObservationType: Observation<R>, RewardType: Reward> {
208 pub observation: ObservationType,
210 pub reward: RewardType,
212 pub status: EpisodeStatus,
214 pub metadata: Option<SnapshotMetadata>,
216}
217
218impl<const R: usize, ObservationType: Observation<R>, RewardType: Reward>
219 SnapshotBase<R, ObservationType, RewardType>
220{
221 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 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 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 #[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
285pub trait Environment<const R: usize, const SR: usize, const AR: usize> {
305 type StateType: State<SR>;
307
308 type ObservationType: Observation<R>;
310
311 type ActionType: Action<AR>;
313
314 type RewardType: Reward;
316
317 type SnapshotType: Snapshot<R, ObservationType = Self::ObservationType, RewardType = Self::RewardType>;
319
320 fn reset(&mut self) -> Result<Self::SnapshotType, EnvironmentError>;
331
332 fn step(&mut self, action: Self::ActionType) -> Result<Self::SnapshotType, EnvironmentError>;
364}
365
366pub trait ConstructableEnv {
380 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 position: i32,
400 }
401
402 impl Observation<1> for MockObservation {
403 fn shape() -> [usize; 1] {
404 [1]
405 }
406 }
407
408 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
412 pub struct MockState {
413 position: i32,
415 }
416
417 impl MockState {
418 fn new(position: i32) -> Self {
419 Self { position }
420 }
421
422 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, MoveRight, }
454
455 impl Action<1> for MockAction {
456 fn is_valid(&self) -> bool {
457 true }
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 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 let next_position = if action == MockAction::MoveLeft {
544 self.current_state.position - 1 } else {
546 self.current_state.position + 1 };
548
549 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 #[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 #[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 #[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 assert_eq!(snapshot.observation().position, 1);
671 assert_eq!(snapshot.reward(), &ScalarReward(10.0));
672 assert!(!snapshot.is_done());
673
674 assert_eq!(snapshot.step_count, 5);
676 assert_eq!(snapshot.cumulative_reward, 25.0);
677 }
678
679 #[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 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 env.reset().expect("Reset should succeed");
741 for _ in 0..5 {
742 let action = MockAction::MoveRight;
743 let _ = env.step(action);
744 }
745
746 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 }
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 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}