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