Skip to main content

rlevo_core/
action.rs

1//! Action space abstractions for reinforcement learning environments.
2//!
3//! This module provides a flexible type system for representing agent actions in RL environments.
4//! Actions can be discrete (finite choices), multi-discrete (multiple independent discrete choices),
5//! or continuous (real-valued vectors).
6//!
7//! # Design Philosophy
8//!
9//! The action traits follow a layered design:
10//! - [`Action`](crate::base::Action): Base trait providing validation and cloning semantics
11//! - [`DiscreteAction`], [`MultiDiscreteAction`], [`ContinuousAction`]: Type-specific extensions
12//!
13//! # Action Types
14//!
15//! ## Discrete Actions
16//!
17//! Discrete actions represent a finite set of mutually exclusive choices (e.g., "move left",
18//! "move right", "jump"). They are indexed from `0` to `ACTION_COUNT - 1`.
19//!
20//! ## Multi-Discrete Actions
21//!
22//! Multi-discrete actions consist of multiple independent discrete choices, such as selecting
23//! both a direction and an attack type simultaneously.
24//!
25//! ## Continuous Actions
26//!
27//! Continuous actions are real-valued vectors, typically used for motor control or
28//! parametrized actions (e.g., steering angle, throttle).
29//!
30//! # Test Suite
31//!
32//! This module includes a comprehensive test suite covering:
33//!
34//! ## DiscreteAction Tests (10 tests)
35//! - `test_discrete_action_shape`: Verifies shape and dimension constants
36//! - `test_discrete_action_count`: Checks action count constant
37//! - `test_discrete_action_from_index`: Tests index-to-action conversion
38//! - `test_discrete_action_from_index_out_of_bounds`: Validates panic on invalid indices
39//! - `test_discrete_action_to_index`: Tests action-to-index conversion
40//! - `test_discrete_action_roundtrip`: Ensures bidirectional conversion consistency
41//! - `test_discrete_action_enumerate`: Verifies all actions are enumerated correctly
42//! - `test_discrete_action_random`: Tests random action generation
43//! - `test_discrete_action_is_valid`: Validates the `is_valid()` predicate
44//! - `test_discrete_action_clone_and_debug`: Tests Debug and Clone trait implementations
45//!
46//! ## MultiDiscreteAction Tests (11 tests)
47//! - `test_multidiscrete_action_shape`: Verifies multi-dimensional shape
48//! - `test_multidiscrete_action_from_indices`: Tests multi-index conversion
49//! - `test_multidiscrete_action_from_indices_*_out_of_bounds`: Validates panic on invalid indices
50//! - `test_multidiscrete_action_to_indices`: Tests reverse conversion
51//! - `test_multidiscrete_action_roundtrip`: Ensures bidirectional consistency
52//! - `test_multidiscrete_action_enumerate`: Verifies all action combinations are enumerated
53//! - `test_multidiscrete_action_enumerate_large_space`: Tests scalability with large action spaces
54//! - `test_multidiscrete_action_random`: Tests random sampling
55//! - `test_multidiscrete_action_is_valid`: Validates constraints
56//! - `test_multidiscrete_action_clone_and_debug`: Tests trait implementations
57//!
58//! ## ContinuousAction Tests (15 tests)
59//! - `test_continuous_action_shape`: Verifies shape specification
60//! - `test_continuous_action_as_slice`: Tests slice view access
61//! - `test_continuous_action_from_slice`: Tests construction from slice
62//! - `test_continuous_action_from_slice_wrong_size`: Validates dimension checking
63//! - `test_continuous_action_roundtrip`: Ensures slice conversion consistency
64//! - `test_continuous_action_clip_*`: Tests clipping behavior (within, exceeds max/min, mixed, extreme)
65//! - `test_continuous_action_clip_chaining`: Verifies method chaining
66//! - `test_continuous_action_random`: Tests random action generation
67//! - `test_continuous_action_is_valid_*`: Tests validity checking (finite, NaN, Inf)
68//! - `test_continuous_action_with_zero_values`: Tests edge case with zero values
69//! - `test_continuous_action_clone_and_debug`: Tests trait implementations
70//!
71//! ## InvalidActionError Tests (6 tests)
72//! - `test_invalid_action_error_creation`: Tests error instantiation
73//! - `test_invalid_action_error_display`: Tests Display trait formatting
74//! - `test_invalid_action_error_debug`: Tests Debug trait formatting
75//! - `test_invalid_action_error_clone`: Tests Clone implementation
76//! - `test_invalid_action_error_equality`: Tests PartialEq implementation
77//! - `test_invalid_action_error_is_error`: Tests std::error::Error trait compatibility
78//!
79//! ## Integration Tests (4 tests)
80//! - `test_large_discrete_action_space`: Tests with 256 actions
81//! - `test_continuous_action_extreme_clip_bounds`: Tests edge cases in clipping
82//! - Various clone/debug/trait tests across different action types
83
84use crate::base::Action;
85use std::fmt::Debug;
86
87/// Trait for discrete actions with a finite, enumerable set of choices.
88///
89/// Discrete actions represent mutually exclusive options that can be indexed by
90/// integers from `0` to `ACTION_COUNT - 1`. Common examples include:
91/// - Game controls (move left/right/jump)
92/// - Categorical decisions (buy/hold/sell)
93/// - Navigation directions (north/south/east/west)
94///
95/// # Type Safety
96///
97/// Implementations should ensure bidirectional conversion between indices and actions:
98/// ```text
99/// ∀ i ∈ [0, ACTION_COUNT): i == from_index(i).to_index()
100/// ∀ a: Action: a == from_index(a.to_index())
101/// ```
102///
103/// # Performance
104///
105/// For performance-critical code, prefer `from_index()` over `random()` when you
106/// already have an index (e.g., from a neural network's argmax). The `random()`
107/// method allocates a thread-local RNG on each call.
108pub trait DiscreteAction<const R: usize>: Action<R> {
109    /// The total number of distinct actions in this action space.
110    ///
111    /// This constant defines the cardinality of the action space. It must be
112    /// greater than zero and remain constant for the lifetime of the program.
113    const ACTION_COUNT: usize;
114
115    /// Constructs an action from its zero-based index.
116    ///
117    /// This method must be the inverse of [`to_index()`](DiscreteAction::to_index).
118    ///
119    /// # Panics
120    ///
121    /// Implementations should panic if `index >= ACTION_COUNT`, as this indicates
122    /// a programming error (out-of-bounds access).
123    fn from_index(index: usize) -> Self;
124
125    /// Converts this action to its zero-based index.
126    ///
127    /// The returned index must be in the range `[0, ACTION_COUNT)` and must be
128    /// the inverse of [`from_index()`](DiscreteAction::from_index).
129    fn to_index(&self) -> usize;
130
131    /// Samples a uniformly random action from this action space.
132    ///
133    /// This is a convenience method for exploration in reinforcement learning.
134    /// It uses thread-local RNG state, so it's safe to call from multiple threads
135    /// but will produce different sequences per thread.
136    ///
137    /// # Performance
138    ///
139    /// If you already have an index from another source (e.g., a neural network
140    /// output), use `from_index()` directly instead of this method.
141    fn random() -> Self
142    where
143        Self: Sized,
144    {
145        use rand::RngExt;
146        let mut rng = rand::rng();
147        let index = rng.random_range(0..Self::ACTION_COUNT);
148        Self::from_index(index)
149    }
150
151    /// Returns a vector containing all possible actions in index order.
152    ///
153    /// This is useful for tabular RL methods (e.g., Q-learning) that need to
154    /// iterate over the entire action space. The returned vector has length
155    /// `ACTION_COUNT` with actions ordered by their index.
156    ///
157    /// # Performance
158    ///
159    /// This allocates a vector of size `ACTION_COUNT`. For large action spaces,
160    /// consider using an iterator pattern instead (not currently provided).
161    fn enumerate() -> Vec<Self>
162    where
163        Self: Sized,
164    {
165        (0..Self::ACTION_COUNT).map(Self::from_index).collect()
166    }
167}
168
169/// Trait for actions with multiple independent discrete dimensions.
170///
171/// Multi-discrete actions represent scenarios where an agent must make several
172/// independent categorical choices simultaneously. Each dimension can have a
173/// different number of options. This is common in:
174/// - Strategy games (select unit + select action + select target)
175/// - Multi-agent coordination (each agent picks a discrete action)
176/// - Parameterized actions (choose action type + intensity level)
177///
178/// # Dimensionality
179///
180/// The const generic `R` (the rank) specifies the number of axes. Each axis
181/// can have a different cardinality, defined by [`shape()`](Action::shape).
182///
183/// The total number of action combinations is the product of all dimension sizes:
184/// ```text
185/// total_actions = ∏ shape()[i]
186/// ```
187///
188/// # Caution: Combinatorial Explosion
189///
190/// Be careful with [`enumerate()`](MultiDiscreteAction::enumerate) on large action spaces.
191/// A 3D action space with dimensions [10, 10, 10] produces 1000 actions, but
192/// [100, 100, 100] produces 1,000,000!
193pub trait MultiDiscreteAction<const R: usize>: Action<R> {
194    /// Constructs an action from multi-dimensional indices.
195    ///
196    /// Each index must be in the range `[0, shape()[i])` for axis `i`.
197    ///
198    /// # Panics
199    ///
200    /// Implementations should panic if any index is out of bounds for its axis.
201    fn from_indices(indices: [usize; R]) -> Self;
202
203    /// Converts this action to its multi-dimensional index representation.
204    ///
205    /// The returned array must satisfy: each element `i` is in `[0, shape()[i])`.
206    /// This method must be the inverse of [`from_indices()`](MultiDiscreteAction::from_indices).
207    fn to_indices(&self) -> [usize; R];
208
209    /// Samples a uniformly random action from this multi-discrete action space.
210    ///
211    /// Each dimension is sampled independently and uniformly from its valid range.
212    /// This is useful for exploration in reinforcement learning.
213    ///
214    /// # Examples
215    ///
216    /// ```rust,ignore
217    /// let random_action = StrategyAction::random();
218    /// assert!(random_action.is_valid());
219    /// ```
220    fn random() -> Self
221    where
222        Self: Sized,
223    {
224        use rand::RngExt;
225        let mut rng = rand::rng();
226        let space = Self::shape();
227        let indices = space.map(|dim| rng.random_range(0..dim));
228        Self::from_indices(indices)
229    }
230
231    /// Returns all possible action combinations in this space.
232    ///
233    /// # Warning
234    ///
235    /// This method generates **all** combinations across all dimensions. The number
236    /// of actions grows multiplicatively with the product of dimension sizes:
237    ///
238    /// - `[10, 10, 10]` → 1,000 actions (manageable)
239    /// - `[50, 50, 50]` → 125,000 actions (marginal)
240    /// - `[100, 100, 100]` → 1,000,000 actions (likely too large)
241    ///
242    /// Use this method only when you need to iterate over the entire action space
243    /// (e.g., for exact policy evaluation in tabular methods).
244    ///
245    /// # Panics
246    ///
247    /// May panic or run out of memory if the action space is too large.
248    fn enumerate() -> Vec<Self>
249    where
250        Self: Sized,
251    {
252        let space = Self::shape();
253        let total: usize = space.iter().product();
254        let mut actions = Vec::with_capacity(total);
255
256        fn generate<const R: usize, T: MultiDiscreteAction<R>>(
257            space: &[usize; R],
258            current: &mut [usize; R],
259            axis: usize,
260            actions: &mut Vec<T>,
261        ) {
262            if axis == R {
263                actions.push(T::from_indices(*current));
264                return;
265            }
266            for i in 0..space[axis] {
267                current[axis] = i;
268                generate(space, current, axis + 1, actions);
269            }
270        }
271
272        let mut current = [0; R];
273        generate(&space, &mut current, 0, &mut actions);
274        actions
275    }
276}
277
278/// Trait for continuous-valued actions represented as real-valued vectors.
279///
280/// Continuous actions are used when the agent's output is a vector of real numbers
281/// rather than discrete choices. Common applications include:
282/// - Robot motor control (joint angles, torques)
283/// - Vehicle control (steering, throttle, brake)
284/// - Continuous parameter tuning (learning rates, temperatures)
285///
286/// # Value Range
287///
288/// Continuous actions typically have bounded ranges (e.g., `[-1, 1]` or `[0, 1]`).
289/// The [`clip()`](ContinuousAction::clip) method enforces these bounds.
290///
291/// # Neural Network Integration
292///
293/// Continuous actions are typically produced by neural networks with `tanh` or
294/// `sigmoid` activation functions. Use [`clip()`](ContinuousAction::clip) to
295/// ensure outputs stay within valid ranges.
296pub trait ContinuousAction<const R: usize>: Action<R> {
297    /// The number of scalar components in this action's flattened representation —
298    /// i.e. the length of the slice returned by [`as_slice`](ContinuousAction::as_slice)
299    /// and consumed by [`from_slice`](ContinuousAction::from_slice).
300    ///
301    /// This is deliberately distinct from [`Action::RANK`] (the tensor rank) and from
302    /// `shape().iter().product()`: the workspace carries two encodings of component
303    /// count (a rank-1 action with `shape() == [C]`, and a rank-C action with
304    /// `shape() == [1; C]`), and neither derived quantity is universally correct.
305    /// Implementors MUST declare it explicitly; there is intentionally no default
306    /// (a default of `= R` would silently reproduce the `random()` panic for
307    /// multi-component rank-1 actions). See ADR 0038.
308    const COMPONENTS: usize;
309
310    /// Returns a slice view of this action's component values.
311    ///
312    /// The returned slice must have exactly `COMPONENTS` elements. This is used for
313    /// efficient serialization and tensor conversion.
314    fn as_slice(&self) -> &[f32];
315
316    /// Returns a new action with all components clipped to `[min, max]`.
317    ///
318    /// This is essential for ensuring neural network outputs (which may exceed
319    /// valid ranges due to numerical errors or exploration noise) stay within
320    /// acceptable bounds.
321    ///
322    /// # Common Use Cases
323    ///
324    /// - Enforcing action space bounds after neural network output
325    /// - Adding exploration noise while maintaining validity
326    /// - Recovering from numerical instability
327    fn clip(&self, min: f32, max: f32) -> Self;
328
329    /// Samples a random action with components uniformly distributed in `[-1.0, 1.0)`.
330    ///
331    /// The default implementation draws [`COMPONENTS`](ContinuousAction::COMPONENTS)
332    /// uniform random values in the half-open range `[-1.0, 1.0)` using
333    /// `rand::random_range`, then builds the action via
334    /// [`from_slice`](ContinuousAction::from_slice).
335    ///
336    /// # Warning
337    ///
338    /// The `[-1.0, 1.0)` range assumes **symmetric-unit** component bounds. Types
339    /// with asymmetric bounds (e.g. a `[0, 1]` throttle) MUST override this method,
340    /// otherwise it will sample values outside their valid range and fail
341    /// [`is_valid`](Action::is_valid). Override it also for Gaussian noise,
342    /// domain-specific distributions, or bounds derived from [`BoundedAction`].
343    /// See ADR 0038.
344    fn random() -> Self
345    where
346        Self: Sized,
347    {
348        use rand::RngExt;
349        let mut rng = rand::rng();
350        let values: Vec<f32> = (0..Self::COMPONENTS)
351            .map(|_| rng.random_range(-1.0..1.0))
352            .collect();
353        Self::from_slice(&values)
354    }
355
356    /// Constructs an action from a slice of component values.
357    ///
358    /// The input slice must have exactly `RANK` elements. This is the inverse
359    /// operation of [`as_slice()`](ContinuousAction::as_slice).
360    ///
361    /// # Panics
362    ///
363    /// Implementations should panic if `values.len() != RANK`.
364    fn from_slice(values: &[f32]) -> Self;
365}
366
367/// A [`ContinuousAction`] with statically-known `[low, high]` component bounds.
368///
369/// DDPG and other continuous-control algorithms need the per-component action
370/// bounds to scale/shift neural-network outputs and to sample uniform warm-up
371/// actions. Expose them via associated static methods rather than associated
372/// constants so implementors can still derive bounds from a runtime env config
373/// (e.g. a `max_torque` field) while presenting a uniform API.
374///
375/// # Invariants
376///
377/// - `low()[i] < high()[i]` for every component `i`.
378/// - [`ContinuousAction::clip`] must be a no-op on an action whose components
379///   already lie in `[low, high]`.
380pub trait BoundedAction<const R: usize>: ContinuousAction<R> {
381    /// Returns the per-component lower bounds of this action space.
382    ///
383    /// The returned array must satisfy `low()[i] < high()[i]` for every component
384    /// `i`. See the trait-level invariants for the full contract.
385    fn low() -> [f32; R];
386
387    /// Returns the per-component upper bounds of this action space.
388    ///
389    /// The returned array must satisfy `low()[i] < high()[i]` for every component
390    /// `i`. See the trait-level invariants for the full contract.
391    fn high() -> [f32; R];
392}
393
394/// Error indicating an action violated its type's constraints.
395///
396/// Returned when an action fails validation or when invalid conversions are
397/// attempted (e.g., out-of-bounds indices, non-finite float values).
398///
399/// # Examples
400///
401/// ```
402/// use rlevo_core::action::InvalidActionError;
403///
404/// let err = InvalidActionError { message: "index 5 out of bounds for ACTION_COUNT=4".into() };
405/// assert!(err.to_string().contains("index 5"));
406/// ```
407#[derive(Debug, Clone, PartialEq, thiserror::Error)]
408#[error("Invalid action: {message}")]
409pub struct InvalidActionError {
410    /// Human-readable description of the constraint that was violated.
411    pub message: String,
412}
413
414// ============================================================================
415// Tests
416// ============================================================================
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use std::error::Error;
422
423    // ========================================================================
424    // Test Implementations
425    // ========================================================================
426
427    /// Simple discrete action with 4 possible choices.
428    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
429    enum SimpleDiscreteAction {
430        Left,
431        Right,
432        Up,
433        Down,
434    }
435
436    impl Action<1> for SimpleDiscreteAction {
437        fn shape() -> [usize; 1] {
438            [4]
439        }
440
441        fn is_valid(&self) -> bool {
442            true // All variants are always valid
443        }
444    }
445
446    impl DiscreteAction<1> for SimpleDiscreteAction {
447        const ACTION_COUNT: usize = 4;
448
449        fn from_index(index: usize) -> Self {
450            match index {
451                0 => SimpleDiscreteAction::Left,
452                1 => SimpleDiscreteAction::Right,
453                2 => SimpleDiscreteAction::Up,
454                3 => SimpleDiscreteAction::Down,
455                _ => panic!("Index out of bounds: {}", index),
456            }
457        }
458
459        fn to_index(&self) -> usize {
460            match self {
461                SimpleDiscreteAction::Left => 0,
462                SimpleDiscreteAction::Right => 1,
463                SimpleDiscreteAction::Up => 2,
464                SimpleDiscreteAction::Down => 3,
465            }
466        }
467    }
468
469    /// Multi-discrete action with 2 dimensions.
470    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
471    struct MultiActionTest {
472        direction: usize, // 0-3
473        intensity: usize, // 0-2
474    }
475
476    impl Action<2> for MultiActionTest {
477        fn shape() -> [usize; 2] {
478            [4, 3]
479        }
480
481        fn is_valid(&self) -> bool {
482            self.direction < 4 && self.intensity < 3
483        }
484    }
485
486    impl MultiDiscreteAction<2> for MultiActionTest {
487        fn from_indices(indices: [usize; 2]) -> Self {
488            if indices[0] >= 4 {
489                panic!("Direction index out of bounds: {}", indices[0]);
490            }
491            if indices[1] >= 3 {
492                panic!("Intensity index out of bounds: {}", indices[1]);
493            }
494            MultiActionTest {
495                direction: indices[0],
496                intensity: indices[1],
497            }
498        }
499
500        fn to_indices(&self) -> [usize; 2] {
501            [self.direction, self.intensity]
502        }
503    }
504
505    /// Continuous action with 3 dimensions (e.g., 3D velocity).
506    #[derive(Debug, Clone)]
507    struct ContinuousActionTest {
508        values: [f32; 3],
509    }
510
511    impl Action<3> for ContinuousActionTest {
512        fn shape() -> [usize; 3] {
513            [1, 1, 1] // Continuous dimensions typically have size 1
514        }
515
516        fn is_valid(&self) -> bool {
517            self.values.iter().all(|v| v.is_finite())
518        }
519    }
520
521    impl ContinuousAction<3> for ContinuousActionTest {
522        const COMPONENTS: usize = 3;
523
524        fn as_slice(&self) -> &[f32] {
525            &self.values
526        }
527
528        fn clip(&self, min: f32, max: f32) -> Self {
529            let clipped = self
530                .values
531                .iter()
532                .map(|&v| v.max(min).min(max))
533                .collect::<Vec<_>>();
534            ContinuousActionTest {
535                values: [clipped[0], clipped[1], clipped[2]],
536            }
537        }
538
539        fn from_slice(values: &[f32]) -> Self {
540            assert_eq!(values.len(), 3, "Expected 3 values, got {}", values.len());
541            ContinuousActionTest {
542                values: [values[0], values[1], values[2]],
543            }
544        }
545    }
546
547    impl BoundedAction<3> for ContinuousActionTest {
548        fn low() -> [f32; 3] {
549            [-1.0, -1.0, -1.0]
550        }
551
552        fn high() -> [f32; 3] {
553            [1.0, 1.0, 1.0]
554        }
555    }
556
557    // ========================================================================
558    // DiscreteAction Tests
559    // ========================================================================
560
561    #[test]
562    fn test_discrete_action_shape() {
563        assert_eq!(SimpleDiscreteAction::shape(), [4]);
564        assert_eq!(SimpleDiscreteAction::RANK, 1);
565    }
566
567    #[test]
568    fn test_discrete_action_count() {
569        assert_eq!(SimpleDiscreteAction::ACTION_COUNT, 4);
570    }
571
572    #[test]
573    fn test_discrete_action_from_index() {
574        assert_eq!(
575            SimpleDiscreteAction::from_index(0),
576            SimpleDiscreteAction::Left
577        );
578        assert_eq!(
579            SimpleDiscreteAction::from_index(1),
580            SimpleDiscreteAction::Right
581        );
582        assert_eq!(
583            SimpleDiscreteAction::from_index(2),
584            SimpleDiscreteAction::Up
585        );
586        assert_eq!(
587            SimpleDiscreteAction::from_index(3),
588            SimpleDiscreteAction::Down
589        );
590    }
591
592    #[test]
593    #[should_panic(expected = "Index out of bounds")]
594    fn test_discrete_action_from_index_out_of_bounds() {
595        SimpleDiscreteAction::from_index(4);
596    }
597
598    #[test]
599    #[should_panic(expected = "Index out of bounds")]
600    fn test_discrete_action_from_index_negative_like() {
601        // Note: usize can't be negative, but we test the boundary
602        SimpleDiscreteAction::from_index(100);
603    }
604
605    #[test]
606    fn test_discrete_action_to_index() {
607        assert_eq!(SimpleDiscreteAction::Left.to_index(), 0);
608        assert_eq!(SimpleDiscreteAction::Right.to_index(), 1);
609        assert_eq!(SimpleDiscreteAction::Up.to_index(), 2);
610        assert_eq!(SimpleDiscreteAction::Down.to_index(), 3);
611    }
612
613    #[test]
614    fn test_discrete_action_roundtrip() {
615        // Test bidirectional conversion
616        for i in 0..SimpleDiscreteAction::ACTION_COUNT {
617            let action = SimpleDiscreteAction::from_index(i);
618            assert_eq!(action.to_index(), i);
619        }
620    }
621
622    #[test]
623    fn test_discrete_action_enumerate() {
624        let actions = SimpleDiscreteAction::enumerate();
625        assert_eq!(actions.len(), 4);
626        assert_eq!(
627            actions,
628            vec![
629                SimpleDiscreteAction::Left,
630                SimpleDiscreteAction::Right,
631                SimpleDiscreteAction::Up,
632                SimpleDiscreteAction::Down
633            ]
634        );
635    }
636
637    #[test]
638    fn test_discrete_action_random() {
639        for _ in 0..100 {
640            let action = SimpleDiscreteAction::random();
641            let index = action.to_index();
642            assert!(index < SimpleDiscreteAction::ACTION_COUNT);
643        }
644    }
645
646    #[test]
647    fn test_discrete_action_is_valid() {
648        for i in 0..SimpleDiscreteAction::ACTION_COUNT {
649            let action = SimpleDiscreteAction::from_index(i);
650            assert!(action.is_valid());
651        }
652    }
653
654    // ========================================================================
655    // MultiDiscreteAction Tests
656    // ========================================================================
657
658    #[test]
659    fn test_multidiscrete_action_shape() {
660        assert_eq!(MultiActionTest::shape(), [4, 3]);
661        assert_eq!(MultiActionTest::RANK, 2);
662    }
663
664    #[test]
665    fn test_multidiscrete_action_from_indices() {
666        let action = MultiActionTest::from_indices([0, 0]);
667        assert_eq!(action.direction, 0);
668        assert_eq!(action.intensity, 0);
669
670        let action = MultiActionTest::from_indices([3, 2]);
671        assert_eq!(action.direction, 3);
672        assert_eq!(action.intensity, 2);
673    }
674
675    #[test]
676    #[should_panic(expected = "Direction index out of bounds")]
677    fn test_multidiscrete_action_from_indices_direction_out_of_bounds() {
678        MultiActionTest::from_indices([4, 0]);
679    }
680
681    #[test]
682    #[should_panic(expected = "Intensity index out of bounds")]
683    fn test_multidiscrete_action_from_indices_intensity_out_of_bounds() {
684        MultiActionTest::from_indices([0, 3]);
685    }
686
687    #[test]
688    fn test_multidiscrete_action_to_indices() {
689        let action = MultiActionTest::from_indices([2, 1]);
690        assert_eq!(action.to_indices(), [2, 1]);
691    }
692
693    #[test]
694    fn test_multidiscrete_action_roundtrip() {
695        for d in 0..4 {
696            for i in 0..3 {
697                let action = MultiActionTest::from_indices([d, i]);
698                assert_eq!(action.to_indices(), [d, i]);
699            }
700        }
701    }
702
703    #[test]
704    fn test_multidiscrete_action_enumerate() {
705        let actions = MultiActionTest::enumerate();
706        // 4 directions × 3 intensities = 12 total actions
707        assert_eq!(actions.len(), 12);
708
709        // Verify all combinations are present
710        for (idx, action) in actions.iter().enumerate() {
711            let expected_d = idx / 3;
712            let expected_i = idx % 3;
713            assert_eq!(action.direction, expected_d);
714            assert_eq!(action.intensity, expected_i);
715        }
716    }
717
718    #[test]
719    fn test_multidiscrete_action_enumerate_large_space() {
720        // Test with 3D space: [5, 5, 5] = 125 total actions
721        #[derive(Debug, Clone)]
722        struct LargeMultiAction([usize; 3]);
723
724        impl Action<3> for LargeMultiAction {
725            fn shape() -> [usize; 3] {
726                [5, 5, 5]
727            }
728
729            fn is_valid(&self) -> bool {
730                self.0.iter().enumerate().all(|(i, &v)| v < [5, 5, 5][i])
731            }
732        }
733
734        impl MultiDiscreteAction<3> for LargeMultiAction {
735            fn from_indices(indices: [usize; 3]) -> Self {
736                for (i, &idx) in indices.iter().enumerate() {
737                    assert!(idx < 5, "Index {} out of bounds", i);
738                }
739                LargeMultiAction(indices)
740            }
741
742            fn to_indices(&self) -> [usize; 3] {
743                self.0
744            }
745        }
746
747        let actions = LargeMultiAction::enumerate();
748        assert_eq!(actions.len(), 125);
749    }
750
751    #[test]
752    fn test_multidiscrete_action_random() {
753        for _ in 0..100 {
754            let action = MultiActionTest::random();
755            assert!(action.is_valid());
756            let indices = action.to_indices();
757            assert!(indices[0] < 4);
758            assert!(indices[1] < 3);
759        }
760    }
761
762    #[test]
763    fn test_multidiscrete_action_is_valid() {
764        // Valid actions
765        assert!(MultiActionTest::from_indices([0, 0]).is_valid());
766        assert!(MultiActionTest::from_indices([3, 2]).is_valid());
767
768        // Invalid actions created directly
769        let invalid = MultiActionTest {
770            direction: 5,
771            intensity: 0,
772        };
773        assert!(!invalid.is_valid());
774
775        let invalid = MultiActionTest {
776            direction: 0,
777            intensity: 5,
778        };
779        assert!(!invalid.is_valid());
780    }
781
782    // ========================================================================
783    // ContinuousAction Tests
784    // ========================================================================
785
786    #[test]
787    fn test_continuous_action_shape() {
788        assert_eq!(ContinuousActionTest::shape(), [1, 1, 1]);
789        assert_eq!(ContinuousActionTest::RANK, 3);
790    }
791
792    #[test]
793    fn test_continuous_action_as_slice() {
794        let action = ContinuousActionTest {
795            values: [0.5, -0.3, 1.0],
796        };
797        let slice = action.as_slice();
798        assert_eq!(slice.len(), 3);
799        assert_eq!(slice, &[0.5, -0.3, 1.0]);
800    }
801
802    #[test]
803    fn test_continuous_action_from_slice() {
804        let values = [0.1, 0.2, 0.3];
805        let action = ContinuousActionTest::from_slice(&values);
806        assert_eq!(action.values, values);
807    }
808
809    #[test]
810    #[should_panic(expected = "Expected 3 values")]
811    fn test_continuous_action_from_slice_wrong_size() {
812        let values = [0.1, 0.2];
813        ContinuousActionTest::from_slice(&values);
814    }
815
816    #[test]
817    fn test_continuous_action_roundtrip() {
818        let original = [0.5, -0.3, 0.9];
819        let action = ContinuousActionTest::from_slice(&original);
820        assert_eq!(action.as_slice(), &original);
821    }
822
823    #[test]
824    fn test_continuous_action_clip_within_bounds() {
825        let action = ContinuousActionTest {
826            values: [0.0, 0.5, -0.5],
827        };
828        let clipped = action.clip(-1.0, 1.0);
829        assert_eq!(clipped.values, [0.0, 0.5, -0.5]);
830    }
831
832    #[test]
833    fn test_continuous_action_clip_exceeds_max() {
834        let action = ContinuousActionTest {
835            values: [2.0, 1.5, 3.0],
836        };
837        let clipped = action.clip(-1.0, 1.0);
838        assert_eq!(clipped.values, [1.0, 1.0, 1.0]);
839    }
840
841    #[test]
842    fn test_continuous_action_clip_exceeds_min() {
843        let action = ContinuousActionTest {
844            values: [-2.0, -1.5, -3.0],
845        };
846        let clipped = action.clip(-1.0, 1.0);
847        assert_eq!(clipped.values, [-1.0, -1.0, -1.0]);
848    }
849
850    #[test]
851    fn test_continuous_action_clip_mixed() {
852        let action = ContinuousActionTest {
853            values: [2.0, 0.5, -2.0],
854        };
855        let clipped = action.clip(-1.0, 1.0);
856        assert_eq!(clipped.values, [1.0, 0.5, -1.0]);
857    }
858
859    #[test]
860    fn test_continuous_action_random() {
861        for _ in 0..100 {
862            let action = ContinuousActionTest::random();
863            assert!(action.is_valid());
864            for &value in action.as_slice() {
865                assert!((-1.0..=1.0).contains(&value));
866                assert!(value.is_finite());
867            }
868        }
869    }
870
871    #[test]
872    fn test_continuous_action_is_valid_finite() {
873        let action = ContinuousActionTest {
874            values: [0.5, -0.3, 1.0],
875        };
876        assert!(action.is_valid());
877    }
878
879    #[test]
880    fn test_continuous_action_is_invalid_nan() {
881        let action = ContinuousActionTest {
882            values: [f32::NAN, 0.5, 1.0],
883        };
884        assert!(!action.is_valid());
885    }
886
887    #[test]
888    fn test_continuous_action_is_invalid_inf() {
889        let action = ContinuousActionTest {
890            values: [f32::INFINITY, 0.5, 1.0],
891        };
892        assert!(!action.is_valid());
893
894        let action = ContinuousActionTest {
895            values: [f32::NEG_INFINITY, 0.5, 1.0],
896        };
897        assert!(!action.is_valid());
898    }
899
900    // ========================================================================
901    // InvalidActionError Tests
902    // ========================================================================
903
904    #[test]
905    fn test_invalid_action_error_creation() {
906        let error = InvalidActionError {
907            message: String::from("Index out of bounds"),
908        };
909        assert_eq!(error.message, "Index out of bounds");
910    }
911
912    #[test]
913    fn test_invalid_action_error_display() {
914        let error = InvalidActionError {
915            message: String::from("Invalid value"),
916        };
917        let displayed = format!("{}", error);
918        assert_eq!(displayed, "Invalid action: Invalid value");
919    }
920
921    #[test]
922    fn test_invalid_action_error_debug() {
923        let error = InvalidActionError {
924            message: String::from("Test error"),
925        };
926        let debug_str = format!("{:?}", error);
927        assert!(debug_str.contains("Test error"));
928    }
929
930    #[test]
931    fn test_invalid_action_error_clone() {
932        let error = InvalidActionError {
933            message: String::from("Original"),
934        };
935        let cloned = error.clone();
936        assert_eq!(error, cloned);
937    }
938
939    #[test]
940    fn test_invalid_action_error_equality() {
941        let error1 = InvalidActionError {
942            message: String::from("Same error"),
943        };
944        let error2 = InvalidActionError {
945            message: String::from("Same error"),
946        };
947        let error3 = InvalidActionError {
948            message: String::from("Different error"),
949        };
950
951        assert_eq!(error1, error2);
952        assert_ne!(error1, error3);
953    }
954
955    #[test]
956    fn test_invalid_action_error_is_error() {
957        let error: Box<dyn Error> = Box::new(InvalidActionError {
958            message: String::from("Test"),
959        });
960        // Should be able to use as std::error::Error trait object
961        let _msg = error.to_string();
962    }
963
964    // ========================================================================
965    // Integration Tests
966    // ========================================================================
967
968    #[test]
969    fn test_discrete_action_clone_and_debug() {
970        let action = SimpleDiscreteAction::Left;
971        let cloned = action;
972        assert_eq!(action, cloned);
973
974        let debug_str = format!("{:?}", action);
975        assert!(debug_str.contains("Left"));
976    }
977
978    #[test]
979    fn test_multidiscrete_action_clone_and_debug() {
980        let action = MultiActionTest::from_indices([1, 2]);
981        let cloned = action;
982        assert_eq!(action, cloned);
983
984        let debug_str = format!("{:?}", action);
985        assert!(debug_str.contains("direction"));
986    }
987
988    #[test]
989    fn test_continuous_action_clone_and_debug() {
990        let action = ContinuousActionTest {
991            values: [0.1, 0.2, 0.3],
992        };
993        let cloned = action.clone();
994        assert_eq!(action.as_slice(), cloned.as_slice());
995
996        let debug_str = format!("{:?}", action);
997        assert!(debug_str.contains("values"));
998    }
999
1000    #[test]
1001    fn test_continuous_action_clip_chaining() {
1002        let action = ContinuousActionTest {
1003            values: [2.0, -3.0, 0.5],
1004        };
1005        let clipped = action.clip(-2.0, 2.0).clip(-1.0, 1.0);
1006        assert_eq!(clipped.values, [1.0, -1.0, 0.5]);
1007    }
1008
1009    #[test]
1010    fn test_large_discrete_action_space() {
1011        // Test with 256 actions
1012        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1013        struct LargeDiscreteAction(u8);
1014
1015        impl Action<1> for LargeDiscreteAction {
1016            fn shape() -> [usize; 1] {
1017                [256]
1018            }
1019
1020            fn is_valid(&self) -> bool {
1021                true
1022            }
1023        }
1024
1025        impl DiscreteAction<1> for LargeDiscreteAction {
1026            const ACTION_COUNT: usize = 256;
1027
1028            fn from_index(index: usize) -> Self {
1029                assert!(index < 256);
1030                LargeDiscreteAction(index as u8)
1031            }
1032
1033            fn to_index(&self) -> usize {
1034                self.0 as usize
1035            }
1036        }
1037
1038        // Enumerate should produce all 256 actions
1039        let actions = LargeDiscreteAction::enumerate();
1040        assert_eq!(actions.len(), 256);
1041
1042        // Verify roundtrip for a few samples
1043        for i in [0, 1, 127, 255] {
1044            let action = LargeDiscreteAction::from_index(i);
1045            assert_eq!(action.to_index(), i);
1046        }
1047    }
1048
1049    #[test]
1050    fn test_continuous_action_with_zero_values() {
1051        let action = ContinuousActionTest {
1052            values: [0.0, 0.0, 0.0],
1053        };
1054        assert!(action.is_valid());
1055        assert_eq!(action.as_slice(), &[0.0, 0.0, 0.0]);
1056
1057        let clipped = action.clip(-1.0, 1.0);
1058        assert_eq!(clipped.values, [0.0, 0.0, 0.0]);
1059    }
1060
1061    #[test]
1062    fn test_continuous_action_extreme_clip_bounds() {
1063        let action = ContinuousActionTest {
1064            values: [100.0, -100.0, 0.0],
1065        };
1066
1067        let clipped = action.clip(f32::NEG_INFINITY, f32::INFINITY);
1068        assert_eq!(clipped.values, [100.0, -100.0, 0.0]);
1069
1070        let clipped = action.clip(0.0, 0.0);
1071        assert_eq!(clipped.values, [0.0, 0.0, 0.0]);
1072    }
1073
1074    // ========================================================================
1075    // BoundedAction Tests
1076    // ========================================================================
1077
1078    #[test]
1079    fn test_bounded_action_low_strictly_below_high() {
1080        let low = ContinuousActionTest::low();
1081        let high = ContinuousActionTest::high();
1082        for i in 0..3 {
1083            assert!(low[i] < high[i], "bound {i}: low >= high");
1084        }
1085    }
1086
1087    #[test]
1088    fn test_bounded_action_clip_is_noop_inside_bounds() {
1089        // Construct an action at the low/high bounds: clip(low, high) must
1090        // return the same components.
1091        let low = ContinuousActionTest::low();
1092        let high = ContinuousActionTest::high();
1093        let at_low = ContinuousActionTest::from_slice(&low);
1094        let at_high = ContinuousActionTest::from_slice(&high);
1095        assert_eq!(at_low.clip(low[0], high[0]).as_slice(), &low);
1096        assert_eq!(at_high.clip(low[0], high[0]).as_slice(), &high);
1097    }
1098}