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}