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}