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}