Skip to main content

rlevo_core/
base.rs

1//! Core traits for reinforcement learning abstractions.
2//!
3//! This module defines the foundational vocabulary used throughout `rlevo-core`:
4//! rewards, observations, states, actions, transition dynamics, and tensor
5//! conversion. All other modules depend on these primitives.
6
7use burn::tensor::Tensor;
8use burn::tensor::TensorData;
9use burn::tensor::backend::Backend;
10use serde::{Deserialize, Serialize};
11use std::fmt::Debug;
12
13/// Generic update function: how something evolves over time.
14///
15/// Parameterized over the input stimulus and the output type it transforms.
16pub trait UpdateFunction<Input, Output> {
17    /// Computes the next value given the current value and an input.
18    fn update(&self, current: &Output, input: &Input) -> Output;
19}
20
21/// A scalar reward signal emitted by an environment each step.
22pub trait Reward: Clone + std::ops::Add<Output = Self> + Into<f32> + Debug {
23    /// Returns the additive identity for this reward type (typically `0.0`).
24    fn zero() -> Self;
25}
26
27/// The `Observation` trait defines how an agent perceives the world. It
28/// represents something that can be observed from the environment.
29/// Implements `Serialize` and `Deserialize` for storage in a replay buffer.
30pub trait Observation<const R: usize>:
31    Debug + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de>
32{
33    /// The rank of this observation space — i.e. the number of axes (tensor
34    /// order), *not* the size of any axis.
35    ///
36    /// "Rank" here means the count of indices needed to address an element
37    /// (NumPy `ndim`, Burn's `Tensor<B, R>`), not matrix rank or CP-decomposition
38    /// rank. This is automatically set to match the const generic parameter `R`.
39    const RANK: usize = R;
40
41    /// Returns the size of each axis in this observation space.
42    ///
43    /// The returned array has length `R` (the rank), where each element is the
44    /// cardinality of that axis — the number of possible values along it. All
45    /// values must be greater than zero.
46    fn shape() -> [usize; R];
47}
48
49/// The complete state of an environment (Markov property)
50pub trait State<const R: usize>: Debug + Clone + Send + Sync {
51    /// The rank of this state space — i.e. the number of axes (tensor order),
52    /// *not* the size of any axis.
53    ///
54    /// "Rank" here means the count of indices needed to address an element
55    /// (NumPy `ndim`, Burn's `Tensor<B, R>`), not matrix rank or CP-decomposition
56    /// rank. This is automatically set to match the const generic parameter `R`.
57    const RANK: usize = R;
58
59    type Observation: Observation<R>;
60
61    /// Returns the size of each axis in this state space.
62    ///
63    /// The returned array has length `R` (the rank), where each element is the
64    /// cardinality of that axis — the number of possible values along it. All
65    /// values must be greater than zero.
66    fn shape() -> [usize; R];
67
68    /// Generate an observation from this state (may be partial)
69    fn observe(&self) -> Self::Observation;
70
71    /// Validates whether this state satisfies all constraints.
72    ///
73    /// This method checks if the state is legal according to its type's invariants.
74    /// It does **not** check environment-specific legality - that's the environment's responsibility.
75    ///
76    /// # Returns
77    ///
78    /// Returns `true` if the state satisfies all structural constraints, `false` otherwise.
79    fn is_valid(&self) -> bool;
80
81    /// Returns the total number of scalar elements in this state's representation.
82    ///
83    /// This value is critical for:
84    /// - Allocating buffers for state serialization
85    /// - Determining neural network input layer dimensions
86    /// - Validating state transformations (e.g., flattening/unflattening)
87    ///
88    /// # Relationship to Shape
89    ///
90    /// For consistency, `numel()` must equal the product of all dimensions returned by
91    /// [`shape()`](State::shape). The default implementation enforces this by computing
92    /// the product directly. Override only if the state uses a non-product layout.
93    ///
94    /// # Returns
95    ///
96    /// The total number of scalar elements needed to represent this state.
97    fn numel(&self) -> usize {
98        Self::shape().iter().product()
99    }
100}
101
102/// Base trait for all action types in reinforcement learning environments.
103///
104/// This trait defines the minimal interface that all actions must implement, regardless
105/// of their underlying representation (discrete, continuous, or hybrid). It ensures actions
106/// are debuggable, clonable, and can validate themselves.
107///
108/// # Design Rationale
109///
110/// The `Action` trait is intentionally minimal and framework-agnostic:
111/// - `Debug`: Required for logging and debugging agents
112/// - `Clone`: Actions may be stored in replay buffers or used multiple times
113/// - `Sized`: Enables efficient stack allocation and compile-time optimization
114/// - `is_valid()`: Allows runtime validation of action constraints
115///
116/// # Implementing Action
117///
118/// When implementing this trait, ensure `is_valid()` checks all constraints:
119/// - Range bounds for numeric values
120/// - Finiteness for floating-point values
121/// - Structural invariants (e.g., array dimensions)
122/// - Environment-specific rules (e.g., available moves in a game state)
123pub trait Action<const R: usize>: Debug + Clone + Sized {
124    /// The rank of this action space — i.e. the number of axes (tensor order),
125    /// *not* the size of any axis.
126    ///
127    /// "Rank" here means the count of indices needed to address an element
128    /// (NumPy `ndim`, Burn's `Tensor<B, R>`), not matrix rank or CP-decomposition
129    /// rank. This is automatically set to match the const generic parameter `R`.
130    const RANK: usize = R;
131
132    /// Returns the size of each axis in this action space.
133    ///
134    /// The returned array has length `R` (the rank), where each element is the
135    /// cardinality of that axis — the number of possible values along it. All
136    /// values must be greater than zero.
137    fn shape() -> [usize; R];
138
139    /// Validates whether this action satisfies all constraints.
140    ///
141    /// This method checks if the action is legal according to its type's invariants.
142    /// It does **not** check environment-specific legality (e.g., whether a move
143    /// is valid in the current game state)—that's the environment's responsibility.
144    ///
145    /// # Returns
146    ///
147    /// Returns `true` if the action satisfies all structural constraints, `false` otherwise.
148    fn is_valid(&self) -> bool;
149}
150
151/// Deterministic environment transition dynamics.
152///
153/// ```math
154/// s_{t+1} = f(s_t, a_t)
155/// ```
156///
157/// This trait covers only **deterministic** transitions. Stochastic dynamics
158/// (where the successor state is drawn from a distribution) are not modeled
159/// here; environments with stochastic transitions implement that logic internally
160/// inside [`crate::environment::Environment::step`].
161pub trait TransitionDynamics<const SR: usize, const AR: usize, S: State<SR>, A: Action<AR>> {
162    /// Returns the successor state after applying `action` to `state`.
163    fn transition(&self, state: &S, action: &A) -> S;
164}
165
166/// Error returned when a tensor cannot be converted to or from a domain type.
167#[derive(Debug, Clone, PartialEq, thiserror::Error)]
168#[error("Invalid tensor conversion: {message}")]
169pub struct TensorConversionError {
170    /// Human-readable description of why the conversion failed.
171    pub message: String,
172}
173
174/// Bidirectional conversion between a domain type and a Burn tensor.
175///
176/// Implementors must round-trip: `from_tensor(x.to_tensor(device))` equals
177/// `Ok(x)` for any valid `x`. Strategies and replay buffers rely on this
178/// invariant.
179///
180/// # Type Parameters
181///
182/// - `R`: Rank of the tensor produced.
183/// - `B`: Burn backend.
184///
185/// # Errors
186///
187/// `from_tensor` returns [`TensorConversionError`] when the tensor's shape,
188/// dtype, or contents violate the domain type's invariants (see
189/// [`State::is_valid`] / [`Action::is_valid`]).
190pub trait TensorConvertible<const R: usize, B: Backend>: Sized {
191    /// Returns the per-item ("row") shape of the tensor this type serializes to.
192    ///
193    /// This is the shape of a **single** value — rank `R`, with each axis size
194    /// fixed by the domain type (e.g. `[8]` for an 8-feature observation, or
195    /// `[H, W, C]` for an image). It is the layout that [`write_host_row`] must
196    /// fill, and the shape [`to_tensor`] wraps around the written buffer.
197    ///
198    /// The product of the returned axes is the number of `f32` scalars a single
199    /// row occupies, which is exactly how many values [`write_host_row`] must
200    /// push.
201    ///
202    /// [`write_host_row`]: TensorConvertible::write_host_row
203    /// [`to_tensor`]: TensorConvertible::to_tensor
204    fn row_shape() -> [usize; R];
205
206    /// Appends the row-major `f32` payload of `self` to `buf`.
207    ///
208    /// This is the primitive from which both single-item conversion
209    /// ([`to_tensor`]) and whole-batch staging ([`stack_to_tensor`]) are
210    /// derived, guaranteeing the two can never disagree on element order.
211    ///
212    /// # Contract
213    ///
214    /// - Push **exactly** `row_shape().iter().product()` values, in **row-major**
215    ///   order matching [`row_shape`].
216    /// - Push **plain `f32`** — do *not* pre-convert to `B::FloatElem`.
217    ///   [`TensorData::new`] performs the element-type conversion at upload time.
218    /// - **Append**; never clear or truncate `buf`. Batch staging relies on
219    ///   successive rows being concatenated into one contiguous buffer.
220    ///
221    /// [`row_shape`]: TensorConvertible::row_shape
222    /// [`to_tensor`]: TensorConvertible::to_tensor
223    /// [`stack_to_tensor`]: crate::base::stack_to_tensor
224    fn write_host_row(&self, buf: &mut Vec<f32>);
225
226    /// Converts `self` into a tensor on `device`.
227    ///
228    /// # Do not override
229    ///
230    /// This method has a default body derived from [`row_shape`] and
231    /// [`write_host_row`]: it stages one row into a host `Vec<f32>` and uploads
232    /// it with a single [`Tensor::from_data`]. Implementors **must not** provide
233    /// their own `to_tensor` — doing so would let the single-item layout drift
234    /// from the batched layout produced by [`stack_to_tensor`], defeating the
235    /// whole point of the shared row-writer primitive.
236    ///
237    /// [`row_shape`]: TensorConvertible::row_shape
238    /// [`write_host_row`]: TensorConvertible::write_host_row
239    /// [`stack_to_tensor`]: crate::base::stack_to_tensor
240    fn to_tensor(
241        &self,
242        device: &<B as burn::tensor::backend::BackendTypes>::Device,
243    ) -> Tensor<B, R> {
244        let row: [usize; R] = Self::row_shape();
245        let mut buf: Vec<f32> = Vec::with_capacity(row.iter().product());
246        self.write_host_row(&mut buf);
247        debug_assert_eq!(buf.len(), row.iter().product::<usize>());
248        Tensor::from_data(TensorData::new(buf, row), device)
249    }
250
251    /// Reconstructs a value from a tensor.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`TensorConversionError`] if the tensor's shape or contents
256    /// do not describe a valid instance of `Self`.
257    fn from_tensor(tensor: Tensor<B, R>) -> Result<Self, TensorConversionError>;
258}
259
260/// Stages a whole batch of rows into one host buffer and uploads it as a single
261/// tensor.
262///
263/// Each item's [`write_host_row`] payload is concatenated into one contiguous
264/// `Vec<f32>`, which is then uploaded with a **single** [`Tensor::from_data`]
265/// call. This is materially cheaper than converting each item to its own tensor
266/// and calling [`Tensor::stack`], which incurs one host→device upload *per item*
267/// plus a concatenation kernel. Because both this function and the derived
268/// [`TensorConvertible::to_tensor`] draw from the same
269/// [`write_host_row`]/[`row_shape`] primitives, the batched layout is guaranteed
270/// to match `stack`-ing the individual rows.
271///
272/// The produced tensor has rank `BR = R + 1` and shape `[items.len(), ..row]`,
273/// i.e. a leading batch axis followed by the per-item [`row_shape`].
274///
275/// # Type Parameters
276///
277/// - `R`: rank of a single row.
278/// - `BR`: rank of the batched tensor; must equal `R + 1`.
279/// - `T`: the row type, [`TensorConvertible<R, B>`].
280/// - `B`: Burn backend.
281///
282/// # The `BR = R + 1` contract
283///
284/// Stable Rust cannot express `R + 1` in a const-generic position, so `BR` is a
285/// separate parameter checked at runtime. This function is the **single
286/// chokepoint** for that invariant: the leading `assert_eq!` runs before the
287/// shape array is assembled, which is what makes the subsequent
288/// `shape[1..].copy_from_slice(&row)` sound (it would panic on a length
289/// mismatch otherwise).
290///
291/// # Panics
292///
293/// Panics if `BR != R + 1`.
294///
295/// # Examples
296///
297/// ```
298/// use burn::backend::Flex;
299/// use burn::tensor::Tensor;
300/// use rlevo_core::base::{stack_to_tensor, TensorConversionError, TensorConvertible};
301///
302/// #[derive(Clone)]
303/// struct Point {
304///     x: f32,
305///     y: f32,
306/// }
307///
308/// impl<B: burn::tensor::backend::Backend> TensorConvertible<1, B> for Point {
309///     fn row_shape() -> [usize; 1] {
310///         [2]
311///     }
312///     fn write_host_row(&self, buf: &mut Vec<f32>) {
313///         buf.push(self.x);
314///         buf.push(self.y);
315///     }
316///     fn from_tensor(_tensor: Tensor<B, 1>) -> Result<Self, TensorConversionError> {
317///         unimplemented!()
318///     }
319/// }
320///
321/// type B = Flex;
322/// let device = Default::default();
323/// let items: Vec<Point> = vec![Point { x: 1.0, y: 2.0 }, Point { x: 3.0, y: 4.0 }];
324/// let batched: Tensor<B, 2> = stack_to_tensor::<1, 2, Point, B>(&items, &device);
325/// assert_eq!(batched.dims(), [2, 2]);
326/// ```
327///
328/// [`write_host_row`]: TensorConvertible::write_host_row
329/// [`row_shape`]: TensorConvertible::row_shape
330pub fn stack_to_tensor<const R: usize, const BR: usize, T, B>(
331    items: &[T],
332    device: &<B as burn::tensor::backend::BackendTypes>::Device,
333) -> Tensor<B, BR>
334where
335    T: TensorConvertible<R, B>,
336    B: Backend,
337{
338    assert_eq!(BR, R + 1, "batched rank BR must equal row rank R + 1");
339    let row: [usize; R] = T::row_shape();
340    let row_len: usize = row.iter().product();
341    let mut buf: Vec<f32> = Vec::with_capacity(items.len() * row_len);
342    for item in items {
343        item.write_host_row(&mut buf);
344    }
345    debug_assert_eq!(buf.len(), items.len() * row_len);
346    let mut shape: [usize; BR] = [0usize; BR];
347    shape[0] = items.len();
348    shape[1..].copy_from_slice(&row); // sound only because the BR == R + 1 assert above ran first — keep the ordering
349    Tensor::from_data(TensorData::new(buf, shape), device)
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    /// Simple scalar reward implementation for testing
357    #[derive(Clone, Debug, PartialEq)]
358    struct TestReward(f32);
359
360    impl Reward for TestReward {
361        fn zero() -> Self {
362            TestReward(0.0)
363        }
364    }
365
366    impl std::ops::Add for TestReward {
367        type Output = Self;
368
369        fn add(self, other: Self) -> Self {
370            TestReward(self.0 + other.0)
371        }
372    }
373
374    impl From<TestReward> for f32 {
375        fn from(reward: TestReward) -> f32 {
376            reward.0
377        }
378    }
379
380    // ===== Basic Reward Trait Tests =====
381
382    /// Test that zero() creates a neutral element for addition
383    #[test]
384    fn test_reward_zero_is_additive_identity() {
385        let zero = TestReward::zero();
386        let reward = TestReward(42.5);
387
388        // zero + reward should equal reward
389        let result = zero.clone() + reward.clone();
390        assert_eq!(result, reward);
391
392        // reward + zero should equal reward
393        let result = reward.clone() + zero.clone();
394        assert_eq!(result, reward);
395    }
396
397    /// Test that rewards can be added together
398    #[test]
399    fn test_reward_addition() {
400        let reward1 = TestReward(10.0);
401        let reward2 = TestReward(25.5);
402        let result = reward1 + reward2;
403
404        assert_eq!(result, TestReward(35.5));
405    }
406
407    /// Test that negative rewards can be added
408    #[test]
409    fn test_reward_negative_addition() {
410        let positive = TestReward(100.0);
411        let negative = TestReward(-30.0);
412        let result = positive + negative;
413
414        assert_eq!(result, TestReward(70.0));
415    }
416
417    /// Test that rewards can be converted to f32
418    #[test]
419    fn test_reward_into_f32() {
420        let reward = TestReward(42.5);
421        let as_f32: f32 = reward.into();
422
423        assert_eq!(as_f32, 42.5);
424    }
425
426    /// Test that zero reward converts to 0.0
427    #[test]
428    fn test_reward_zero_into_f32() {
429        let zero = TestReward::zero();
430        let as_f32: f32 = zero.into();
431
432        assert_eq!(as_f32, 0.0);
433    }
434
435    /// Test that rewards are cloneable
436    #[test]
437    fn test_reward_clone() {
438        let original = TestReward(123.456);
439        let cloned = original.clone();
440
441        assert_eq!(original, cloned);
442    }
443
444    /// Test that rewards implement Debug
445    #[test]
446    fn test_reward_debug() {
447        let reward = TestReward(42.0);
448        let debug_str = format!("{:?}", reward);
449
450        assert!(!debug_str.is_empty());
451        assert!(debug_str.contains("TestReward"));
452    }
453
454    // ===== Arithmetic Properties Tests =====
455
456    /// Test accumulated reward through chained additions
457    #[test]
458    fn test_reward_accumulation() {
459        let mut accumulated = TestReward::zero();
460        let rewards = vec![TestReward(10.0), TestReward(20.0), TestReward(15.0)];
461
462        for reward in rewards {
463            accumulated = accumulated + reward;
464        }
465
466        assert_eq!(accumulated, TestReward(45.0));
467    }
468
469    /// Test reward trait with floating point precision
470    #[test]
471    fn test_reward_floating_point_precision() {
472        let r1 = TestReward(0.1);
473        let r2 = TestReward(0.2);
474        let result = r1 + r2;
475
476        // Account for floating point imprecision
477        let expected = 0.3;
478        let as_f32: f32 = result.into();
479        assert!((as_f32 - expected).abs() < 1e-6);
480    }
481
482    /// Test addition associativity: (a + b) + c == a + (b + c)
483    #[test]
484    fn test_reward_addition_associativity() {
485        let r1 = TestReward(5.0);
486        let r2 = TestReward(10.0);
487        let r3 = TestReward(15.0);
488
489        let left = (r1.clone() + r2.clone()) + r3.clone();
490        let right = r1 + (r2 + r3);
491
492        assert_eq!(left, right);
493    }
494
495    /// Test addition commutativity: a + b == b + a
496    #[test]
497    fn test_reward_addition_commutativity() {
498        let r1 = TestReward(7.5);
499        let r2 = TestReward(12.5);
500
501        let left = r1.clone() + r2.clone();
502        let right = r2 + r1;
503
504        assert_eq!(left, right);
505    }
506
507    // ===== Special Values Tests =====
508
509    /// Test reward arithmetic with large values
510    #[test]
511    fn test_reward_large_values() {
512        let large1 = TestReward(1e6);
513        let large2 = TestReward(1e6);
514
515        let result = large1 + large2;
516        let result_f32: f32 = result.into();
517
518        assert_eq!(result_f32, 2e6);
519    }
520
521    /// Test reward arithmetic with small values
522    #[test]
523    fn test_reward_small_values() {
524        let small1 = TestReward(1e-6);
525        let small2 = TestReward(1e-6);
526
527        let result = small1 + small2;
528        let result_f32: f32 = result.into();
529
530        assert!((result_f32 - 2e-6).abs() < 1e-7);
531    }
532
533    /// Test mixed positive and negative rewards
534    #[test]
535    fn test_reward_mixed_signs() {
536        let positive = TestReward(10.0);
537        let negative = TestReward(-5.0);
538
539        let pos_then_neg = positive.clone() + negative.clone();
540        let pos_then_neg_f32: f32 = pos_then_neg.into();
541
542        let neg_then_pos = negative.clone() + positive.clone();
543        let neg_then_pos_f32: f32 = neg_then_pos.into();
544
545        assert_eq!(pos_then_neg_f32, 5.0);
546        assert_eq!(neg_then_pos_f32, 5.0);
547    }
548
549    /// ========================================================================
550    /// GameState example to test the State trait implementation
551    /// ========================================================================
552    #[derive(Debug, Clone, Serialize, Deserialize)]
553    struct GameStateObservation {
554        state_id: u8,
555        level: u8,
556        score: u32,
557    }
558
559    impl Observation<1> for GameStateObservation {
560        fn shape() -> [usize; 1] {
561            [3] // 3 features: state_id, level, score
562        }
563    }
564
565    #[derive(Debug, Clone, PartialEq)]
566    enum GameState {
567        Menu,
568        Playing { level: u8 },
569        GameOver { score: u32 },
570    }
571
572    impl State<1> for GameState {
573        type Observation = GameStateObservation;
574
575        fn observe(&self) -> Self::Observation {
576            match self {
577                GameState::Menu => GameStateObservation {
578                    state_id: 0,
579                    level: 0,
580                    score: 0,
581                },
582                GameState::Playing { level } => GameStateObservation {
583                    state_id: 1,
584                    level: *level,
585                    score: 0,
586                },
587                GameState::GameOver { score } => GameStateObservation {
588                    state_id: 2,
589                    level: 0,
590                    score: *score,
591                },
592            }
593        }
594
595        fn shape() -> [usize; 1] {
596            [3] // 3 features: state_id, level, score
597        }
598
599        fn is_valid(&self) -> bool {
600            match self {
601                GameState::Playing { level } => *level > 0 && *level <= 10,
602                _ => true,
603            }
604        }
605
606        fn numel(&self) -> usize {
607            // Encode as 3 features: [state_id, level, score]
608            3
609        }
610    }
611
612    /// Test state validation for each state variant
613    #[test]
614    fn test_game_state_validation() {
615        // Menu state should always be valid
616        let menu_state = GameState::Menu;
617        assert!(menu_state.is_valid(), "Menu state should always be valid");
618
619        // GameOver state should always be valid
620        let game_over_state = GameState::GameOver { score: 1000 };
621        assert!(
622            game_over_state.is_valid(),
623            "GameOver state should always be valid"
624        );
625
626        // Playing state with valid levels should be valid
627        for level in 1..=10 {
628            let playing_state = GameState::Playing { level };
629            assert!(
630                playing_state.is_valid(),
631                "Playing state with level {} should be valid",
632                level
633            );
634        }
635
636        // Playing state with invalid levels should be invalid
637        let invalid_levels = [0, 11, 255];
638        for level in invalid_levels {
639            let invalid_state = GameState::Playing { level };
640            assert!(
641                !invalid_state.is_valid(),
642                "Playing state with level {} should be invalid",
643                level
644            );
645        }
646    }
647
648    /// Test that numel returns 3 for all state variants
649    #[test]
650    fn test_game_state_numel() {
651        let test_states = [
652            GameState::Menu,
653            GameState::Playing { level: 5 },
654            GameState::GameOver { score: 1000 },
655        ];
656
657        for state in test_states {
658            assert_eq!(
659                state.numel(),
660                3,
661                "Number of elements should be 3 for all states"
662            );
663        }
664    }
665
666    /// Test that shape returns [3] for all state variants
667    #[test]
668    fn test_game_state_shape() {
669        let test_states = [
670            GameState::Menu,
671            GameState::Playing { level: 5 },
672            GameState::GameOver { score: 1000 },
673        ];
674
675        for _state in test_states {
676            assert_eq!(
677                GameState::shape(),
678                [3],
679                "Shape should be [3] for all states"
680            );
681        }
682    }
683
684    /// Test the invariant: numel() should equal product of shape()
685    #[test]
686    fn test_game_state_consistency() {
687        let test_states = [
688            GameState::Menu,
689            GameState::Playing { level: 5 },
690            GameState::GameOver { score: 1000 },
691        ];
692
693        for state in test_states {
694            let numel = state.numel();
695            let shape_product: usize = GameState::shape().iter().product();
696            assert_eq!(
697                numel, shape_product,
698                "numel({}) should equal shape product({})",
699                numel, shape_product
700            );
701        }
702    }
703
704    /// Test that filtering states by validity works correctly
705    #[test]
706    fn test_game_state_filtering() {
707        let states = vec![
708            GameState::Menu,
709            GameState::Playing { level: 5 },
710            GameState::Playing { level: 0 }, // Invalid
711            GameState::GameOver { score: 1000 },
712        ];
713
714        let valid_states: Vec<_> = states.into_iter().filter(|s| s.is_valid()).collect();
715
716        assert_eq!(
717            valid_states.len(),
718            3,
719            "Should have 3 valid states out of 4 total"
720        );
721        assert!(
722            valid_states.iter().all(|s| s.is_valid()),
723            "All filtered states should be valid"
724        );
725
726        // Verify the invalid state was filtered out
727        assert!(
728            !valid_states.contains(&GameState::Playing { level: 0 }),
729            "Invalid playing state should be filtered out"
730        );
731    }
732
733    /// Test edge cases for Playing state level bounds
734    #[test]
735    fn test_playing_state_edge_cases() {
736        // Test boundary values
737        let min_valid_level = GameState::Playing { level: 1 };
738        assert!(
739            min_valid_level.is_valid(),
740            "Level 1 should be valid (minimum valid)"
741        );
742
743        let max_valid_level = GameState::Playing { level: 10 };
744        assert!(
745            max_valid_level.is_valid(),
746            "Level 10 should be valid (maximum valid)"
747        );
748
749        let below_min = GameState::Playing { level: 0 };
750        assert!(
751            !below_min.is_valid(),
752            "Level 0 should be invalid (below minimum)"
753        );
754
755        let above_max = GameState::Playing { level: 11 };
756        assert!(
757            !above_max.is_valid(),
758            "Level 11 should be invalid (above maximum)"
759        );
760    }
761
762    /// Test that observe() generates correct observations for each state variant
763    #[test]
764    fn test_game_state_observe() {
765        // Test Menu state observation
766        let menu_state = GameState::Menu;
767        let menu_obs = menu_state.observe();
768        assert_eq!(menu_obs.state_id, 0, "Menu state should have state_id 0");
769        assert_eq!(menu_obs.level, 0, "Menu state should have level 0");
770        assert_eq!(menu_obs.score, 0, "Menu state should have score 0");
771
772        // Test Playing state observation
773        let playing_state = GameState::Playing { level: 5 };
774        let playing_obs = playing_state.observe();
775        assert_eq!(
776            playing_obs.state_id, 1,
777            "Playing state should have state_id 1"
778        );
779        assert_eq!(playing_obs.level, 5, "Playing state should preserve level");
780        assert_eq!(playing_obs.score, 0, "Playing state should have score 0");
781
782        // Test GameOver state observation
783        let game_over_state = GameState::GameOver { score: 1000 };
784        let game_over_obs = game_over_state.observe();
785        assert_eq!(
786            game_over_obs.state_id, 2,
787            "GameOver state should have state_id 2"
788        );
789        assert_eq!(game_over_obs.level, 0, "GameOver state should have level 0");
790        assert_eq!(
791            game_over_obs.score, 1000,
792            "GameOver state should preserve score"
793        );
794    }
795
796    /// Test GameStateObservation shape
797    #[test]
798    fn test_game_state_observation_shape() {
799        assert_eq!(
800            GameStateObservation::shape(),
801            [3],
802            "GameStateObservation should have shape [3]"
803        );
804        assert_eq!(
805            GameStateObservation::RANK,
806            1,
807            "GameStateObservation should have rank 1"
808        );
809    }
810
811    /// ========================================================================
812    /// GridPosition example to test the State trait implementation
813    /// ========================================================================
814    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
815    struct GridObservation {
816        x: i32,
817        y: i32,
818    }
819
820    impl Observation<1> for GridObservation {
821        fn shape() -> [usize; 1] {
822            [2] // 2 coordinates: x, y
823        }
824    }
825
826    #[derive(Debug, Clone, Serialize, Deserialize)]
827    struct GridPosition {
828        x: i32,
829        y: i32,
830        max_x: i32,
831        max_y: i32,
832    }
833
834    impl State<1> for GridPosition {
835        type Observation = GridObservation;
836
837        fn observe(&self) -> Self::Observation {
838            GridObservation {
839                x: self.x,
840                y: self.y,
841            }
842        }
843
844        fn shape() -> [usize; 1] {
845            [2] // 2 coordinates: x, y
846        }
847
848        fn is_valid(&self) -> bool {
849            self.x >= 0 && self.y >= 0 && self.x < self.max_x && self.y < self.max_y
850        }
851
852        fn numel(&self) -> usize {
853            2 // x and y coordinates
854        }
855    }
856
857    impl GridPosition {
858        /// Flatten the grid position to a vector of f32 values
859        fn flatten(&self) -> Vec<f32> {
860            vec![
861                self.x as f32,
862                self.y as f32,
863                self.max_x as f32,
864                self.max_y as f32,
865            ]
866        }
867    }
868
869    /// Test GridPosition validation
870    #[test]
871    fn test_grid_position_validation() {
872        let valid = GridPosition {
873            x: 5,
874            y: 3,
875            max_x: 10,
876            max_y: 10,
877        };
878        assert!(valid.is_valid(), "x, y should be valid.");
879        //
880        let invalid = GridPosition {
881            x: 15,
882            y: 3,
883            max_x: 10,
884            max_y: 10,
885        };
886        assert!(
887            !invalid.is_valid(),
888            "x is larger than max_x and therefore invalid."
889        );
890    }
891
892    /// Test GridPosition flatten
893    #[test]
894    fn test_grid_position_flattening() {
895        let pos1 = GridPosition {
896            x: 3,
897            y: 7,
898            max_x: 10,
899            max_y: 10,
900        };
901        let pos2 = GridPosition {
902            x: 0,
903            y: 0,
904            max_x: 10,
905            max_y: 10,
906        };
907        let pos3 = GridPosition {
908            x: 9,
909            y: 9,
910            max_x: 10,
911            max_y: 10,
912        };
913        let flat1 = pos1.flatten();
914        let flat2 = pos2.flatten();
915        let flat3 = pos3.flatten();
916
917        assert_eq!(flat1, vec![3.0, 7.0, 10.0, 10.0]);
918        assert_eq!(flat2, vec![0.0, 0.0, 10.0, 10.0]);
919        assert_eq!(flat3, vec![9.0, 9.0, 10.0, 10.0]);
920    }
921
922    /// Test that observe() generates correct observations for GridPosition
923    #[test]
924    fn test_grid_position_observe() {
925        let pos = GridPosition {
926            x: 5,
927            y: 3,
928            max_x: 10,
929            max_y: 10,
930        };
931        let obs = pos.observe();
932        assert_eq!(obs.x, 5, "Observation should preserve x coordinate");
933        assert_eq!(obs.y, 3, "Observation should preserve y coordinate");
934
935        // Test with different positions
936        let origin = GridPosition {
937            x: 0,
938            y: 0,
939            max_x: 10,
940            max_y: 10,
941        };
942        let origin_obs = origin.observe();
943        assert_eq!(origin_obs.x, 0, "Origin observation should have x = 0");
944        assert_eq!(origin_obs.y, 0, "Origin observation should have y = 0");
945
946        // Test with edge position
947        let edge = GridPosition {
948            x: 9,
949            y: 9,
950            max_x: 10,
951            max_y: 10,
952        };
953        let edge_obs = edge.observe();
954        assert_eq!(edge_obs.x, 9, "Edge observation should have x = 9");
955        assert_eq!(edge_obs.y, 9, "Edge observation should have y = 9");
956    }
957
958    /// Test GridObservation shape
959    #[test]
960    fn test_grid_observation_shape() {
961        assert_eq!(
962            GridObservation::shape(),
963            [2],
964            "GridObservation should have shape [2]"
965        );
966        assert_eq!(
967            GridObservation::RANK,
968            1,
969            "GridObservation should have rank 1"
970        );
971    }
972
973    /// Test that GridPosition numel matches shape product
974    #[test]
975    fn test_grid_position_consistency() {
976        let pos = GridPosition {
977            x: 5,
978            y: 3,
979            max_x: 10,
980            max_y: 10,
981        };
982        let numel = pos.numel();
983        let shape_product: usize = GridPosition::shape().iter().product();
984        assert_eq!(
985            numel, shape_product,
986            "numel should equal shape product for GridPosition"
987        );
988        assert_eq!(numel, 2, "GridPosition should have numel of 2");
989    }
990
991    /// Test State trait const RANK value
992    #[test]
993    fn test_state_rank_constant() {
994        assert_eq!(
995            <GameState as State<1>>::RANK,
996            1,
997            "GameState should have RANK = 1"
998        );
999        assert_eq!(
1000            <GridPosition as State<1>>::RANK,
1001            1,
1002            "GridPosition should have RANK = 1"
1003        );
1004    }
1005
1006    // ========================================================================
1007    // TensorConvertible: derived `to_tensor` + `stack_to_tensor`
1008    // ========================================================================
1009
1010    use burn::backend::Flex;
1011
1012    /// Backend used by the tensor-conversion tests.
1013    type TcB = Flex;
1014
1015    /// Rank-1 test row: three scalar features, shape `[3]`.
1016    #[derive(Clone, Debug)]
1017    struct Vec3(f32, f32, f32);
1018
1019    impl<B: Backend> TensorConvertible<1, B> for Vec3 {
1020        fn row_shape() -> [usize; 1] {
1021            [3]
1022        }
1023        fn write_host_row(&self, buf: &mut Vec<f32>) {
1024            buf.extend_from_slice(&[self.0, self.1, self.2]);
1025        }
1026        fn from_tensor(_tensor: Tensor<B, 1>) -> Result<Self, TensorConversionError> {
1027            unimplemented!("not exercised by these tests")
1028        }
1029    }
1030
1031    /// Rank-3 test row: a `[2, 2, 1]` image-like payload.
1032    #[derive(Clone, Debug)]
1033    struct Img([f32; 4]);
1034
1035    impl<B: Backend> TensorConvertible<3, B> for Img {
1036        fn row_shape() -> [usize; 3] {
1037            [2, 2, 1]
1038        }
1039        fn write_host_row(&self, buf: &mut Vec<f32>) {
1040            buf.extend_from_slice(&self.0);
1041        }
1042        fn from_tensor(_tensor: Tensor<B, 3>) -> Result<Self, TensorConversionError> {
1043            unimplemented!("not exercised by these tests")
1044        }
1045    }
1046
1047    /// `stack_to_tensor` must produce exactly what `Tensor::stack` of the
1048    /// individually-converted rows produces — bit-identical data and shape.
1049    #[test]
1050    fn test_stack_to_tensor_matches_manual_stack() {
1051        let device: <TcB as burn::tensor::backend::BackendTypes>::Device = Default::default();
1052        let items: Vec<Vec3> = vec![
1053            Vec3(1.0, 2.0, 3.0),
1054            Vec3(4.0, 5.0, 6.0),
1055            Vec3(7.0, 8.0, 9.0),
1056        ];
1057
1058        let batched: Tensor<TcB, 2> = stack_to_tensor::<1, 2, Vec3, TcB>(&items, &device);
1059
1060        let per_item: Vec<Tensor<TcB, 1>> = items
1061            .iter()
1062            .map(|i| <Vec3 as TensorConvertible<1, TcB>>::to_tensor(i, &device))
1063            .collect();
1064        let manual: Tensor<TcB, 2> = Tensor::stack(per_item, 0);
1065
1066        assert_eq!(batched.dims(), manual.dims());
1067        let batched_v: Vec<f32> = batched
1068            .into_data()
1069            .into_vec::<f32>()
1070            .expect("f32 host read of a tensor this test just built");
1071        let manual_v: Vec<f32> = manual
1072            .into_data()
1073            .into_vec::<f32>()
1074            .expect("f32 host read of a tensor this test just built");
1075        assert_eq!(batched_v, manual_v);
1076    }
1077
1078    /// `stack_to_tensor` panics when the batched rank does not equal `R + 1`.
1079    #[test]
1080    #[should_panic(expected = "batched rank BR must equal row rank R + 1")]
1081    fn test_stack_to_tensor_wrong_rank_panics() {
1082        let device: <TcB as burn::tensor::backend::BackendTypes>::Device = Default::default();
1083        let items: Vec<Vec3> = vec![Vec3(1.0, 2.0, 3.0)];
1084        // BR = 3, but R + 1 = 2 → must panic.
1085        let _bad: Tensor<TcB, 3> = stack_to_tensor::<1, 3, Vec3, TcB>(&items, &device);
1086    }
1087
1088    /// The derived `to_tensor` produces the same data/shape as the old manual
1089    /// `Tensor::from_floats` path for a rank-1 row.
1090    #[test]
1091    fn test_derived_to_tensor_rank1_matches_manual() {
1092        let device: <TcB as burn::tensor::backend::BackendTypes>::Device = Default::default();
1093        let item: Vec3 = Vec3(1.5, -2.5, 3.5);
1094
1095        let derived: Tensor<TcB, 1> =
1096            <Vec3 as TensorConvertible<1, TcB>>::to_tensor(&item, &device);
1097        let manual: Tensor<TcB, 1> = Tensor::from_floats([1.5_f32, -2.5, 3.5], &device);
1098
1099        assert_eq!(derived.dims(), manual.dims());
1100        let derived_v: Vec<f32> = derived
1101            .into_data()
1102            .into_vec::<f32>()
1103            .expect("f32 host read of a tensor this test just built");
1104        let manual_v: Vec<f32> = manual
1105            .into_data()
1106            .into_vec::<f32>()
1107            .expect("f32 host read of a tensor this test just built");
1108        assert_eq!(derived_v, manual_v);
1109    }
1110
1111    /// The derived `to_tensor` produces the same data/shape as the old manual
1112    /// `TensorData::new` path for a rank-3 row.
1113    #[test]
1114    fn test_derived_to_tensor_rank3_matches_manual() {
1115        let device: <TcB as burn::tensor::backend::BackendTypes>::Device = Default::default();
1116        let item: Img = Img([0.1, 0.2, 0.3, 0.4]);
1117
1118        let derived: Tensor<TcB, 3> = <Img as TensorConvertible<3, TcB>>::to_tensor(&item, &device);
1119        let manual: Tensor<TcB, 3> = Tensor::from_data(
1120            TensorData::new(vec![0.1_f32, 0.2, 0.3, 0.4], [2, 2, 1]),
1121            &device,
1122        );
1123
1124        assert_eq!(derived.dims(), manual.dims());
1125        let derived_v: Vec<f32> = derived
1126            .into_data()
1127            .into_vec::<f32>()
1128            .expect("f32 host read of a tensor this test just built");
1129        let manual_v: Vec<f32> = manual
1130            .into_data()
1131            .into_vec::<f32>()
1132            .expect("f32 host read of a tensor this test just built");
1133        assert_eq!(derived_v, manual_v);
1134    }
1135}