rlevo-core 0.2.0

Core traits and types for rlevo (internal crate — use `rlevo` for the full API)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
//! Per-family structured-rendering surfaces for the rich report tier.
//!
//! The library and live (TUI) tiers consume [`AsciiRenderable`] /
//! `StyledFrame`; the report tier consumes a richer per-family payload
//! when an env opts in. This module defines:
//!
//! 1. Shared geometry primitives ([`Point2`]).
//! 2. Three per-family **snapshot** types — pure data, owned by the
//!    producer side, free of any wire-format concerns:
//!    - [`Landscape2DSnapshot`] for `landscapes` envs.
//!    - [`Box2dSnapshot`] (with [`RigidBody2D`] / [`BodyKind`]) for
//!      `box2d` envs.
//!    - [`Locomotion2DSnapshot`] for `locomotion` envs (their **canonical
//!      view** — locomotion has no ASCII path).
//! 3. Three opt-in **payload-source** traits — one per family — that an
//!    env implements when it wants the recording layer to capture the
//!    richer payload. Each trait has a single method; envs that do not
//!    implement them fall back to the default `FamilyPayload::Ascii`.
//!
//! Wire-format conversion (snapshot → `FamilyPayload`) lives in
//! `rlevo-benchmarks::record` so the wire layer stays owned by the
//! benchmarks crate. `rlevo-core` knows nothing about bincode.
//!
//! [`AsciiRenderable`]: super::AsciiRenderable

use serde::{Deserialize, Serialize};

/// 2D point in the family's natural coordinate frame.
///
/// Each family interprets the frame differently:
/// - landscapes: `(x, y)` in the search domain.
/// - box2d: world-space metres.
/// - locomotion: sagittal-plane projection, `x = forward`, `y = up`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Point2 {
    pub x: f32,
    pub y: f32,
}

impl Point2 {
    /// Constructs a new [`Point2`] from the given `x` and `y` coordinates.
    #[must_use]
    pub const fn new(x: f32, y: f32) -> Self {
        Self { x, y }
    }
}

// ---------------------------------------------------------------------------
// Landscape2D
// ---------------------------------------------------------------------------

/// A snapshot of the landscape state at one captured frame.
///
/// The landscape itself (the function evaluated at every grid point) is
/// identified by `label` so the report-tier renderer can reach for a
/// shared, precomputed heatmap rather than embedding one per frame.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Landscape2DSnapshot {
    /// Search domain along the x axis.
    pub bounds_x: (f32, f32),
    /// Search domain along the y axis.
    pub bounds_y: (f32, f32),
    /// Current candidate position.
    pub current: Point2,
    /// Best candidate seen so far, if tracked.
    pub best: Option<Point2>,
    /// Recent history of `current`, oldest first. Capped by the producer.
    pub trail: Vec<Point2>,
    /// Identifier for the underlying landscape (e.g. `"sphere"`,
    /// `"ackley"`, `"rastrigin"`). The renderer uses this to look up a
    /// shared heatmap; unknown labels fall back to a plain background.
    pub label: String,
}

/// Producer-side trait. An env implements this when it wants its
/// recording to ship a `FamilyPayload::Landscape2D` instead of `Ascii`.
pub trait Landscape2DPayloadSource {
    /// Returns a [`Landscape2DSnapshot`] capturing the current frame.
    fn landscape2d_snapshot(&self) -> Landscape2DSnapshot;
}

// ---------------------------------------------------------------------------
// Box2d
// ---------------------------------------------------------------------------

/// Semantic class of a [`RigidBody2D`] — drives the client-side CSS
/// class so colour / stroke / fill choices stay accessible and consistent
/// across all box2d envs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum BodyKind {
    Hull,
    Wheel,
    Leg,
    Wing,
    Ground,
    Goal,
    Other,
}

/// One rigid body's polygon + pose, captured at one frame.
///
/// `vertices` are expressed in the body's local frame; the renderer
/// transforms them via `position` + `rotation_rad` so the wire payload
/// stays compact when a body moves but does not deform.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RigidBody2D {
    /// Polygon corners in the body's **local** frame, counter-clockwise.
    pub vertices: Vec<Point2>,
    /// World-space position of the body's local origin.
    pub position: Point2,
    /// Rotation of the body about its local origin, in radians.
    pub rotation_rad: f32,
    /// Semantic class used by the renderer to choose colour / stroke / fill.
    pub kind: BodyKind,
}

/// All bodies + contact points + world bounds, captured at one frame.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Box2dSnapshot {
    /// World-space rectangle the renderer fits its viewport to.
    /// `(min, max)` corners.
    pub world_bounds: (Point2, Point2),
    /// All rigid bodies in the scene, in paint order.
    pub bodies: Vec<RigidBody2D>,
    /// Active contact points between bodies this frame.
    pub contacts: Vec<Point2>,
}

/// Producer-side trait. A box2d env implements this when it wants its
/// recording to ship a `FamilyPayload::Box2D` instead of `Ascii`.
pub trait Box2dPayloadSource {
    /// Returns a [`Box2dSnapshot`] capturing the current frame.
    fn box2d_snapshot(&self) -> Box2dSnapshot;
}

// ---------------------------------------------------------------------------
// Locomotion2D
// ---------------------------------------------------------------------------

/// Sagittal-plane projection of a locomotion env, captured at one frame.
///
/// **This is locomotion's canonical view** — locomotion envs do not
/// implement [`AsciiRenderable`], so this payload is the only
/// rendering pathway in the whole stack.
///
/// `joints[i]` is the i-th joint position; `bones[k] = (a, b)` means
/// joint `a` connects to joint `b` with a rigid bone. `ground_y` is the
/// y-coordinate of the ground line in the same frame. `com` is the
/// projected centre of mass (optional — not every env tracks it).
/// `contacts` are footstep contact points the report tier may sprinkle
/// as small open rings.
///
/// [`AsciiRenderable`]: super::AsciiRenderable
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Locomotion2DSnapshot {
    /// Positions of each joint in the sagittal-plane frame.
    pub joints: Vec<Point2>,
    /// Rigid-bone connectivity: each `(a, b)` pair connects `joints[a]` to
    /// `joints[b]`.
    pub bones: Vec<(u32, u32)>,
    /// Y-coordinate of the ground line in the same frame as the joints.
    pub ground_y: f32,
    /// Projected centre of mass. `None` when the env does not track it.
    pub com: Option<Point2>,
    /// Footstep contact points; rendered as small open rings on the report
    /// tier.
    pub contacts: Vec<Point2>,
}

/// Producer-side trait. A locomotion env implements this to supply the only
/// rendering pathway in the stack — locomotion envs do not implement
/// [`AsciiRenderable`], so this payload is the canonical view.
///
/// [`AsciiRenderable`]: super::AsciiRenderable
pub trait Locomotion2DPayloadSource {
    /// Returns a [`Locomotion2DSnapshot`] capturing the current frame.
    fn locomotion2d_snapshot(&self) -> Locomotion2DSnapshot;
}

// ---------------------------------------------------------------------------
// Grid
// ---------------------------------------------------------------------------

/// Cardinal facing of the grid agent. Mirrors the env-side `Direction`
/// (`+x` East, `+y` South); the renderer rotates the agent triangle to match.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GridDir {
    East,
    South,
    West,
    North,
}

/// The six Minigrid colours, paired with a redundant non-colour signal
/// (glyph/label) on the report tier per the accessibility contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GridColor {
    Red,
    Green,
    Blue,
    Purple,
    Yellow,
    Grey,
}

/// Open / closed / locked state of a [`GridTile::Door`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GridDoorState {
    Open,
    Closed,
    Locked,
}

/// One grid cell's contents, projected from the env-side `Entity`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GridTile {
    /// Empty walkable cell.
    Empty,
    /// Walkable floor (drawn distinctly from `Empty`).
    Floor,
    /// Impassable wall.
    Wall,
    /// Terminal goal cell.
    Goal,
    /// Hazard cell (ends the episode in failure).
    Lava,
    /// Door of the given colour and state.
    Door(GridColor, GridDoorState),
    /// Colored key.
    Key(GridColor),
    /// Colored ball.
    Ball(GridColor),
    /// Colored box.
    Box(GridColor),
}

/// The agent marker: cell position, facing, and any carried item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct GridAgentMarker {
    /// Column (0-based, left to right).
    pub x: u16,
    /// Row (0-based, top to bottom).
    pub y: u16,
    /// Direction the agent faces.
    pub dir: GridDir,
    /// Item the agent is holding, if any.
    pub carrying: Option<GridTile>,
}

/// A snapshot of a grid (Minigrid-style) environment at one frame.
///
/// `tiles` is row-major with `tiles.len() == width * height`; cell
/// `(x, y)` is `tiles[y * width + x]`. The renderer draws one `<rect>`
/// per tile, the agent as a rotated triangle, and pickable objects as
/// shape-distinct glyphs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GridSnapshot {
    /// Grid width in cells.
    pub width: u16,
    /// Grid height in cells.
    pub height: u16,
    /// Row-major tiles, `len == width * height`.
    pub tiles: Vec<GridTile>,
    /// The agent marker.
    pub agent: GridAgentMarker,
}

/// Producer-side trait. A grid env implements this so its recording ships
/// a `FamilyPayload::Grid` rendered from structured tile state instead of
/// `Ascii` text.
pub trait GridPayloadSource {
    /// Returns a [`GridSnapshot`] capturing the current frame.
    fn grid_snapshot(&self) -> GridSnapshot;
}

// ---------------------------------------------------------------------------
// TabularText
// ---------------------------------------------------------------------------

/// Background class of a [`TabularGrid`] cell — the union of cell semantics
/// across the grid-shaped toy-text envs (FrozenLake / CliffWalking / Taxi).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TabularCell {
    /// Plain walkable cell.
    Empty,
    /// Frozen safe surface (FrozenLake).
    Frozen,
    /// Episode start cell.
    Start,
    /// Terminal goal cell.
    Goal,
    /// Hazard cell — falling in a hole / stepping off the cliff.
    Hazard,
}

/// Semantic class of a [`TabularMarker`] overlaid on a [`TabularGrid`] cell.
/// Each maps to a shape-distinct glyph on the report tier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TabularMarkerKind {
    /// The controllable agent (elf / taxi).
    Agent,
    /// Passenger waiting to be picked up (Taxi).
    Passenger,
    /// Drop-off destination (Taxi).
    Destination,
    /// A named pickup/drop location (Taxi's R/G/Y/B corners).
    Location,
}

/// A point-of-interest overlaid on a grid cell.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabularMarker {
    /// Column (0-based, left to right).
    pub x: u16,
    /// Row (0-based, top to bottom).
    pub y: u16,
    /// Semantic role that determines the glyph the renderer draws.
    pub kind: TabularMarkerKind,
}

/// Grid layout for the grid-shaped toy-text envs. `cells` is row-major,
/// `len == width * height`; `markers` overlay agent / passenger / destination.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TabularGrid {
    /// Grid width in cells.
    pub width: u16,
    /// Grid height in cells.
    pub height: u16,
    /// Row-major cells, `len == width * height`; cell `(x, y)` is
    /// `cells[y * width + x]`.
    pub cells: Vec<TabularCell>,
    /// Points-of-interest overlaid on top of the background cells.
    pub markers: Vec<TabularMarker>,
}

/// Card-table layout for Blackjack. Card values are blackjack face values
/// (`1` = ace, `2..=10`, `10` for face cards). `dealer_showing` is the
/// dealer's single up-card while the hole card is concealed during play;
/// `dealer_cards` carries the full hand for post-episode review.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CardTable {
    pub player_cards: Vec<u8>,
    pub player_total: u8,
    pub usable_ace: bool,
    pub dealer_cards: Vec<u8>,
    pub dealer_showing: u8,
}

/// Layout discriminant for [`TabularSnapshot`] — grid-shaped envs vs the
/// Blackjack card table.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TabularLayout {
    Grid(TabularGrid),
    Cards(CardTable),
}

/// A snapshot of a tabular (toy-text) environment at one frame.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TabularSnapshot {
    /// The layout discriminant, carrying either a grid or a card-table view.
    pub layout: TabularLayout,
}

/// Producer-side trait. A toy-text env implements this so its recording
/// ships a `FamilyPayload::TabularText` rendered from structured layout
/// state instead of `Ascii` text.
pub trait TabularPayloadSource {
    /// Returns a [`TabularSnapshot`] capturing the current frame.
    fn tabular_snapshot(&self) -> TabularSnapshot;
}

// ---------------------------------------------------------------------------
// Classic2D
// ---------------------------------------------------------------------------

/// Semantic role of a [`Classic2DBody`], driving the report tier's CSS
/// (colour / stroke / fill) so the parts of each classic-control mechanism
/// stay visually distinct and accessible.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Classic2DRole {
    /// The ground line / track / terrain profile.
    Track,
    /// The cart (CartPole).
    Cart,
    /// A balancing pole (CartPole / Pendulum).
    Pole,
    /// A rigid link of a multi-link arm (Acrobot).
    Link,
    /// The car (MountainCar).
    Car,
    /// A pivot / hinge point (drawn as a small marker).
    Hinge,
}

/// One body of a classic-control mechanism, expressed as a **world-space**
/// polyline (already transformed — no separate pose). A single-point body is
/// a marker (e.g. a hinge); `closed = true` makes it a filled polygon.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Classic2DBody {
    /// World-space points in the env's natural frame (`+y` up).
    pub points: Vec<Point2>,
    /// What this body is, for styling.
    pub role: Classic2DRole,
    /// `true` → render as a closed filled polygon; `false` → open polyline.
    pub closed: bool,
}

/// A snapshot of a classic-control env (CartPole / Pendulum / MountainCar /
/// Acrobot) at one frame: a set of world-space bodies plus the viewport the
/// renderer fits to.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Classic2DSnapshot {
    /// Bodies in paint order (track first, moving parts last).
    pub bodies: Vec<Classic2DBody>,
    /// Viewport rectangle the renderer fits to: `(min, max)` corners.
    pub bounds: (Point2, Point2),
}

/// Producer-side trait. A classic-control env implements this so its
/// recording ships a `FamilyPayload::Classic2D` rendered as SVG line-art
/// instead of `Ascii` text.
pub trait Classic2DPayloadSource {
    /// Returns a [`Classic2DSnapshot`] capturing the current frame.
    fn classic2d_snapshot(&self) -> Classic2DSnapshot;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn point2_const_constructor() {
        const P: Point2 = Point2::new(1.5, -2.5);
        assert!((P.x - 1.5).abs() < f32::EPSILON);
        assert!((P.y + 2.5).abs() < f32::EPSILON);
    }

    #[test]
    fn landscape_snapshot_constructs_and_compares() {
        let snap = Landscape2DSnapshot {
            bounds_x: (-5.0, 5.0),
            bounds_y: (-5.0, 5.0),
            current: Point2::new(0.5, -0.25),
            best: Some(Point2::new(0.0, 0.0)),
            trail: vec![Point2::new(0.1, 0.2), Point2::new(0.3, 0.4)],
            label: "sphere".into(),
        };
        assert_eq!(snap.trail.len(), 2);
        assert_eq!(snap.label, "sphere");
        assert_eq!(snap.clone(), snap);
    }

    #[test]
    fn box2d_snapshot_carries_typed_body_kinds() {
        let snap = Box2dSnapshot {
            world_bounds: (Point2::new(-10.0, -1.0), Point2::new(10.0, 8.0)),
            bodies: vec![
                RigidBody2D {
                    vertices: vec![
                        Point2::new(-0.5, -0.5),
                        Point2::new(0.5, -0.5),
                        Point2::new(0.5, 0.5),
                        Point2::new(-0.5, 0.5),
                    ],
                    position: Point2::new(1.0, 2.0),
                    rotation_rad: 0.25,
                    kind: BodyKind::Hull,
                },
                RigidBody2D {
                    vertices: vec![Point2::new(0.0, 0.0)],
                    position: Point2::new(0.0, 0.0),
                    rotation_rad: 0.0,
                    kind: BodyKind::Ground,
                },
            ],
            contacts: vec![Point2::new(0.0, 0.0)],
        };
        assert_eq!(snap.bodies.len(), 2);
        assert_eq!(snap.bodies[0].kind, BodyKind::Hull);
        assert_eq!(snap.bodies[1].kind, BodyKind::Ground);
    }

    #[test]
    fn locomotion_snapshot_default_ground_and_optional_com() {
        let snap = Locomotion2DSnapshot {
            joints: vec![Point2::new(0.0, 1.0), Point2::new(0.5, 1.5)],
            bones: vec![(0, 1)],
            ground_y: 0.0,
            com: None,
            contacts: vec![],
        };
        assert_eq!(snap.bones, vec![(0u32, 1u32)]);
        assert!(snap.com.is_none());
    }

    /// Sanity: each per-family trait is a "default-free" surface — an
    /// implementor must supply a non-trivial snapshot. Sticking a stub
    /// impl here also guards against unintentional accidental renames.
    struct Stub;
    impl Landscape2DPayloadSource for Stub {
        fn landscape2d_snapshot(&self) -> Landscape2DSnapshot {
            Landscape2DSnapshot {
                bounds_x: (0.0, 1.0),
                bounds_y: (0.0, 1.0),
                current: Point2::default(),
                best: None,
                trail: vec![],
                label: "stub".into(),
            }
        }
    }
    impl Box2dPayloadSource for Stub {
        fn box2d_snapshot(&self) -> Box2dSnapshot {
            Box2dSnapshot {
                world_bounds: (Point2::default(), Point2::new(1.0, 1.0)),
                bodies: vec![],
                contacts: vec![],
            }
        }
    }
    impl Locomotion2DPayloadSource for Stub {
        fn locomotion2d_snapshot(&self) -> Locomotion2DSnapshot {
            Locomotion2DSnapshot {
                joints: vec![],
                bones: vec![],
                ground_y: 0.0,
                com: None,
                contacts: vec![],
            }
        }
    }

    #[test]
    fn payload_source_traits_compose_via_stub() {
        let stub = Stub;
        assert_eq!(stub.landscape2d_snapshot().label, "stub");
        assert_eq!(stub.box2d_snapshot().bodies.len(), 0);
        assert_eq!(stub.locomotion2d_snapshot().joints.len(), 0);
    }
}