Skip to main content

rlevo_core/
state.rs

1//! Advanced state abstraction traits for non-Markovian and latent representations.
2//!
3//! This module extends the base [`State`] contract with higher-level abstractions
4//! needed for POMDPs, recurrent policies, and world-model-based agents:
5//! - [`MarkovState`] — verifies the Markov property holds for a representation
6//! - [`BeliefState`] — probability distribution over possible states (POMDP)
7//! - [`HiddenState`] — recurrent agent memory (e.g., RNN hidden state)
8//! - [`LatentState`] — learned compact representation with encode/predict/decode
9//! - [`StateAggregation`] — maps concrete states to abstract representatives
10//! - [`Observable`] — modality-changing state→observation projection (`OR != SR`)
11//!
12//! [`State`]: crate::base::State
13
14use crate::base::{Action, Observation, State};
15
16/// Error type for state validation failures.
17///
18/// Returned by validation logic when a state's shape, contents, or element
19/// count do not match the expectations of the calling code.
20///
21/// # Examples
22///
23/// ```
24/// use rlevo_core::state::StateError;
25///
26/// let err = StateError::InvalidShape {
27///     expected: vec![4, 4],
28///     got: vec![4, 3],
29/// };
30/// assert!(err.to_string().contains("Invalid shape"));
31///
32/// let err = StateError::InvalidData("NaN in position field".into());
33/// assert!(err.to_string().contains("NaN in position field"));
34///
35/// let err = StateError::InvalidSize { expected: 16, got: 12 };
36/// assert!(err.to_string().contains("Invalid size"));
37/// ```
38#[derive(Debug, Clone, PartialEq, thiserror::Error)]
39pub enum StateError {
40    /// Shape dimensions do not match expectations.
41    #[error("Invalid shape: expected {expected:?}, got {got:?}")]
42    InvalidShape {
43        expected: Vec<usize>,
44        got: Vec<usize>,
45    },
46    /// Data contents violate invariants.
47    #[error("Invalid data: {0}")]
48    InvalidData(String),
49    /// Total element count does not match expectations.
50    #[error("Invalid size: expected {expected}, got {got}")]
51    InvalidSize { expected: usize, got: usize },
52}
53
54/// Verifies that a state representation satisfies the Markov property.
55///
56/// A representation is Markov when the future is conditionally independent of
57/// the past given the present. Tabular and neural Q-learning both assume this.
58pub trait MarkovState {
59    /// Returns `true` if this representation satisfies the Markov property.
60    ///
61    /// The default implementation returns `true`, which is correct for most
62    /// fully-observable environments. Override to return `false` for raw pixel
63    /// or partially-observable representations that require history stacking.
64    fn is_markov() -> bool {
65        true
66    }
67}
68
69/// A probability distribution over possible environment states (POMDP belief).
70///
71/// Belief states are used in partially-observable settings where the agent
72/// cannot observe the true state directly. The belief is updated via Bayes'
73/// rule as the most recent action and new observation arrive.
74///
75/// # Type Parameters
76///
77/// - `SR`: Rank of the state space tensor (number of axes).
78/// - `AR`: Rank of the action space tensor (number of axes).
79/// - `S`: The underlying environment [`State`] type.
80/// - `A`: The [`Action`] type taken by the agent.
81pub trait BeliefState<const SR: usize, const AR: usize, S: State<SR>, A: Action<AR>>:
82    Clone
83{
84    /// Updates the belief distribution given the last action taken and the
85    /// newly received observation.
86    fn update(&self, action: &A, observation: &S::Observation) -> Self;
87
88    /// Draws a state sample from the current belief distribution.
89    fn sample(&self) -> S;
90
91    /// Returns the probability (or unnormalized weight) assigned to `state`.
92    fn probability(&self, state: &S) -> f64;
93}
94
95/// Recurrent agent memory analogous to an RNN hidden state.
96///
97/// Implementations hold the internal summary of past observations (e.g., the
98/// `h_t` vector of a GRU or LSTM). The hidden state is updated at each step
99/// with the latest [`Observation`] and reset to a
100/// zero vector at episode start.
101///
102/// # Type Parameters
103///
104/// - `R`: Rank of the observation space tensor used to update this state.
105pub trait HiddenState<const R: usize>: Clone {
106    /// The observation type used to update this hidden state.
107    type Observation: Observation<R>;
108
109    /// Incorporates `observation` into the hidden state in-place.
110    fn update(&mut self, observation: &Self::Observation);
111
112    /// Resets the hidden state to its initial value at episode start.
113    fn reset(&mut self);
114}
115
116/// Learned compact representation with encode, predict, and decode steps.
117///
118/// Used by world-model agents (e.g., DreamerV3) that operate in a learned
119/// latent space rather than the raw observation space.
120///
121/// # Type Parameters
122///
123/// - `R`: Rank of the observation space tensor this latent state is derived from.
124/// - `AR`: Rank of the action space tensor used in the transition prediction step.
125pub trait LatentState<const R: usize, const AR: usize>: Clone {
126    /// The observation type this latent state is derived from.
127    type Observation: Observation<R>;
128
129    /// Projects `observation` into the latent space.
130    fn encode(observation: &Self::Observation) -> Self;
131
132    /// Rolls the latent state forward by one step given `action`.
133    fn predict_next<A: Action<AR>>(&self, action: &A) -> Self;
134
135    /// Reconstructs an observation from the latent representation.
136    fn decode(&self) -> Self::Observation;
137}
138
139/// Maps concrete states to abstract representatives for state aggregation.
140///
141/// State aggregation is used in function approximation and hierarchical RL to
142/// group similar states under a shared abstract representation.
143///
144/// # Type Parameters
145///
146/// - `SR`: Rank of the concrete state space tensor.
147/// - `S`: The concrete [`State`] type being aggregated.
148pub trait StateAggregation<const SR: usize, S: State<SR>> {
149    /// The abstract state type produced by aggregation.
150    type AbstractState: Clone + Eq;
151
152    /// Returns the abstract representative for `state`.
153    fn aggregate(&self, state: &S) -> Self::AbstractState;
154
155    /// Returns `true` when `state1` and `state2` map to the same abstract state.
156    fn same_aggregate(&self, state1: &S, state2: &S) -> bool {
157        self.aggregate(state1) == self.aggregate(state2)
158    }
159}
160
161/// Projects a state into an observation whose tensor order may differ from the
162/// state's own.
163///
164/// [`State::observe`] welds its observation to the state's tensor order
165/// (`type Observation: Observation<SR>`), so it can never change rank. That is
166/// the right model for *information*-reducing partial observability (e.g.
167/// dropping velocities from CartPole: a smaller-`shape`, same-order
168/// observation). It cannot express a **modality change** — a compact, low-order
169/// latent state observed through a higher-order sensor, such as an
170/// emulator-RAM byte vector (rank 1) presented to the agent as a pixel image
171/// (rank 2 or 3).
172///
173/// `Observable` is the typed home for that rank-changing projection. It is a
174/// **standalone** trait (not a supertrait of [`State`]): a modality-changing
175/// environment's state implements [`State<SR>`](State) for its full
176/// representation *and* `Observable<OR>` for the projected observation, then
177/// builds its snapshots from [`project`](Observable::project) instead of
178/// [`observe`](State::observe). [`crate::environment::Environment`] already
179/// permits `R != SR` (its observation and state types are independent), so no
180/// change to the environment contract is required.
181///
182/// # Type Parameters
183///
184/// - `OR`: Tensor order (rank) of the projected observation. Named `OR` rather
185///   than `R` — as on the sibling [`HiddenState`]/[`LatentState`] seams — to
186///   make the state→observation rank decoupling explicit at every impl site
187///   (`impl Observable<2> for Ram`) and to distinguish it from the state order
188///   `SR`. This naming is deliberate; do not normalise it to `R`.
189///
190/// # Invariants
191///
192/// - **Total over valid states.** `project` is infallible: for any state the
193///   environment considers valid, it returns a well-formed observation. The
194///   output shape is the compile-time constant `Self::Observation::shape()`,
195///   and the projection performs no I/O, so there is no runtime failure mode.
196///   (A future emulator that can fail to read a framebuffer models that failure
197///   at the [`Environment::step`](crate::environment::Environment::step)
198///   boundary via [`EnvironmentError`](crate::environment::EnvironmentError),
199///   not here.)
200/// - **`OR` is independent of any `State<SR>` order** the type also implements;
201///   `OR == SR`, `OR < SR`, and `OR > SR` are all permitted.
202///
203/// # Examples
204///
205/// A compact rank-1 state projected into a rank-2 pixel observation:
206///
207/// ```
208/// use rlevo_core::base::Observation;
209/// use rlevo_core::state::Observable;
210/// use serde::{Deserialize, Serialize};
211///
212/// #[derive(Debug, Clone)]
213/// struct Ram {
214///     byte: u8,
215/// }
216///
217/// #[derive(Debug, Clone, Serialize, Deserialize)]
218/// struct Pixels([[u8; 2]; 2]);
219///
220/// impl Observation<2> for Pixels {
221///     fn shape() -> [usize; 2] {
222///         [2, 2]
223///     }
224/// }
225///
226/// impl Observable<2> for Ram {
227///     type Observation = Pixels;
228///
229///     fn project(&self) -> Pixels {
230///         let b = self.byte;
231///         Pixels([[b & 1, (b >> 1) & 1], [(b >> 2) & 1, (b >> 3) & 1]])
232///     }
233/// }
234///
235/// let obs = Ram { byte: 0b1011 }.project();
236/// assert_eq!(Pixels::shape(), [2, 2]);
237/// assert_eq!(<Pixels as Observation<2>>::RANK, 2);
238/// assert_eq!(obs.0, [[1, 1], [0, 1]]);
239/// ```
240pub trait Observable<const OR: usize> {
241    /// The observation produced by this projection, at tensor order `OR`.
242    type Observation: Observation<OR>;
243
244    /// Projects `self` into an observation whose order `OR` may differ from any
245    /// [`State<SR>`](State) order the type also implements.
246    ///
247    /// This is the rank-changing analogue of [`State::observe`]: where `observe`
248    /// reads out a same-order perception, `project` maps the state into a
249    /// possibly different-order observation modality. It is total over valid
250    /// states (see the trait [Invariants](Observable#invariants)).
251    fn project(&self) -> Self::Observation;
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use serde::{Deserialize, Serialize};
258
259    /// A rank-2 pixel observation: a 2x2 grid of bits.
260    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261    struct MockRamObservation {
262        pixels: [[u8; 2]; 2],
263    }
264
265    impl Observation<2> for MockRamObservation {
266        fn shape() -> [usize; 2] {
267            [2, 2]
268        }
269    }
270
271    /// A trivial rank-1 observation: the raw RAM byte, fully observable.
272    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
273    struct MockRamByte {
274        byte: u8,
275    }
276
277    impl Observation<1> for MockRamByte {
278        fn shape() -> [usize; 1] {
279            [1]
280        }
281    }
282
283    /// A compact rank-1 state (one byte of emulator RAM) that is observed two
284    /// ways: fully via [`State::observe`] (rank 1), and as a pixel image via
285    /// [`Observable::project`] (rank 2) — the modality change `OR != SR`.
286    #[derive(Debug, Clone)]
287    struct MockRamState {
288        byte: u8,
289    }
290
291    impl State<1> for MockRamState {
292        type Observation = MockRamByte;
293
294        fn shape() -> [usize; 1] {
295            [1]
296        }
297
298        fn observe(&self) -> Self::Observation {
299            MockRamByte { byte: self.byte }
300        }
301
302        fn is_valid(&self) -> bool {
303            true
304        }
305
306        fn numel(&self) -> usize {
307            1
308        }
309    }
310
311    impl Observable<2> for MockRamState {
312        type Observation = MockRamObservation;
313
314        fn project(&self) -> Self::Observation {
315            let b = self.byte;
316            MockRamObservation {
317                pixels: [[b & 1, (b >> 1) & 1], [(b >> 2) & 1, (b >> 3) & 1]],
318            }
319        }
320    }
321
322    /// The projected observation is a strictly higher tensor order than the
323    /// state — the whole point of the trait.
324    #[test]
325    fn test_observable_changes_tensor_order() {
326        assert_eq!(
327            <MockRamState as State<1>>::shape().len(),
328            1,
329            "state is rank 1"
330        );
331        assert_eq!(
332            <MockRamState as Observable<2>>::Observation::RANK,
333            2,
334            "projected observation is rank 2"
335        );
336        assert_ne!(
337            <MockRamState as State<1>>::shape().len(),
338            <MockRamState as Observable<2>>::Observation::shape().len(),
339            "modality change: observation order differs from state order"
340        );
341    }
342
343    /// `observe()` and `project()` coexist on one type and produce different
344    /// modalities from the same state.
345    #[test]
346    fn test_observable_coexists_with_observe() {
347        let state = MockRamState { byte: 0b1011 };
348
349        let full: MockRamByte = state.observe();
350        assert_eq!(
351            full,
352            MockRamByte { byte: 0b1011 },
353            "observe is identity here"
354        );
355
356        let pixels = state.project();
357        assert_eq!(
358            pixels.pixels,
359            [[1, 1], [0, 1]],
360            "project unpacks the byte into a 2x2 pixel grid"
361        );
362    }
363
364    /// The projected observation's declared shape matches its rank-2 contents.
365    #[test]
366    fn test_observable_projection_shape() {
367        assert_eq!(
368            MockRamObservation::shape(),
369            [2, 2],
370            "projected observation has shape [2, 2]"
371        );
372        let pixels = MockRamState { byte: 0 }.project();
373        assert_eq!(pixels.pixels.len(), 2, "outer axis matches shape[0]");
374        assert_eq!(pixels.pixels[0].len(), 2, "inner axis matches shape[1]");
375    }
376}