Skip to main content

murk_core/
id.rs

1//! Strongly-typed identifiers and the [`Coord`] type alias.
2
3use smallvec::SmallVec;
4use std::fmt;
5use std::sync::atomic::{AtomicU64, Ordering};
6
7/// Identifies a field within a simulation world.
8///
9/// Fields are registered at world creation and assigned sequential IDs.
10/// `FieldId(n)` corresponds to the n-th field in the world configuration.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct FieldId(pub u32);
13
14impl fmt::Display for FieldId {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        write!(f, "{}", self.0)
17    }
18}
19
20impl From<u32> for FieldId {
21    fn from(v: u32) -> Self {
22        Self(v)
23    }
24}
25
26/// Identifies a space (spatial topology) within a simulation world.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
28pub struct SpaceId(pub u32);
29
30impl fmt::Display for SpaceId {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        write!(f, "{}", self.0)
33    }
34}
35
36impl From<u32> for SpaceId {
37    fn from(v: u32) -> Self {
38        Self(v)
39    }
40}
41
42/// Counter for unique [`SpaceInstanceId`] allocation.
43static SPACE_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(1);
44
45/// Unique per-instance identifier for a `Space` object.
46///
47/// Allocated from a monotonic atomic counter via [`SpaceInstanceId::next`].
48/// Two distinct space instances always have different IDs, even if they
49/// have identical topology. Used by observation plan caching to avoid
50/// ABA reuse when a space is dropped and a new one is allocated at the
51/// same address.
52///
53/// Cloning a space preserves its instance ID, which is correct because
54/// immutable spaces with the same ID have the same topology.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
56pub struct SpaceInstanceId(u64);
57
58impl SpaceInstanceId {
59    /// Allocate a fresh, unique instance ID.
60    ///
61    /// Each call returns a new ID that has never been returned before
62    /// within this process. Thread-safe.
63    #[must_use]
64    pub fn next() -> Self {
65        Self(SPACE_INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed))
66    }
67}
68
69impl fmt::Display for SpaceInstanceId {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "{}", self.0)
72    }
73}
74
75const ENTITY_SLOT_BITS: u32 = 20;
76const ENTITY_SLOT_MASK: u32 = (1 << ENTITY_SLOT_BITS) - 1;
77const ENTITY_GEN_MAX: u32 = (1 << (32 - ENTITY_SLOT_BITS)) - 1;
78
79/// Identifies an entity within a simulation world.
80///
81/// Packs a 20-bit slot index and 12-bit generation counter into a `u32`.
82/// Entity stores validate the generation on lookup so stale IDs from recycled
83/// slots fail instead of addressing a later occupant.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
85pub struct EntityId(u32);
86
87impl EntityId {
88    /// Create an entity ID from a slot index and generation counter.
89    ///
90    /// # Panics
91    ///
92    /// Panics if `slot` exceeds 20 bits or `generation` exceeds 12 bits.
93    #[must_use]
94    pub fn new(slot: u32, generation: u32) -> Self {
95        assert!(
96            slot <= ENTITY_SLOT_MASK,
97            "slot {slot} exceeds maximum {ENTITY_SLOT_MASK}"
98        );
99        assert!(
100            generation <= ENTITY_GEN_MAX,
101            "generation {generation} exceeds maximum {ENTITY_GEN_MAX}"
102        );
103        Self((generation << ENTITY_SLOT_BITS) | slot)
104    }
105
106    /// Slot index encoded in the low 20 bits.
107    #[inline]
108    #[must_use]
109    pub fn slot(self) -> u32 {
110        self.0 & ENTITY_SLOT_MASK
111    }
112
113    /// Generation counter encoded in the high 12 bits.
114    #[inline]
115    #[must_use]
116    pub fn generation(self) -> u32 {
117        self.0 >> ENTITY_SLOT_BITS
118    }
119
120    /// Raw packed `u32` representation.
121    #[inline]
122    #[must_use]
123    pub fn as_u32(self) -> u32 {
124        self.0
125    }
126
127    /// Reconstruct an ID from its raw packed representation.
128    #[inline]
129    #[must_use]
130    pub fn from_u32(raw: u32) -> Self {
131        Self(raw)
132    }
133}
134
135impl fmt::Display for EntityId {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(f, "entity(slot={}, gen={})", self.slot(), self.generation())
138    }
139}
140
141/// Indexes into an entity's property array.
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
143pub struct PropertyIndex(pub u32);
144
145impl fmt::Display for PropertyIndex {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        write!(f, "{}", self.0)
148    }
149}
150
151impl From<u32> for PropertyIndex {
152    fn from(value: u32) -> Self {
153        Self(value)
154    }
155}
156
157/// Monotonically increasing tick counter.
158///
159/// Incremented each time the simulation advances one step.
160#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
161pub struct TickId(pub u64);
162
163impl fmt::Display for TickId {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        write!(f, "{}", self.0)
166    }
167}
168
169impl From<u64> for TickId {
170    fn from(v: u64) -> Self {
171        Self(v)
172    }
173}
174
175/// Tracks arena generation for snapshot identity.
176///
177/// Incremented each time a new snapshot is published, enabling
178/// ObsPlan invalidation detection.
179#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
180pub struct WorldGenerationId(pub u64);
181
182impl fmt::Display for WorldGenerationId {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        write!(f, "{}", self.0)
185    }
186}
187
188impl From<u64> for WorldGenerationId {
189    fn from(v: u64) -> Self {
190        Self(v)
191    }
192}
193
194/// Tracks the version of global simulation parameters.
195///
196/// Incremented when any `SetParameter` or `SetParameterBatch` command
197/// is applied, enabling stale-parameter detection.
198#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
199pub struct ParameterVersion(pub u64);
200
201impl fmt::Display for ParameterVersion {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        write!(f, "{}", self.0)
204    }
205}
206
207impl From<u64> for ParameterVersion {
208    fn from(v: u64) -> Self {
209        Self(v)
210    }
211}
212
213/// Key for a global simulation parameter (e.g., learning rate, reward scale).
214///
215/// Parameters are registered at world creation; invalid keys are rejected
216/// at ingress.
217#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
218pub struct ParameterKey(pub u32);
219
220impl fmt::Display for ParameterKey {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        write!(f, "{}", self.0)
223    }
224}
225
226impl From<u32> for ParameterKey {
227    fn from(v: u32) -> Self {
228        Self(v)
229    }
230}
231
232/// A coordinate in simulation space.
233///
234/// Uses `SmallVec<[i32; 4]>` to avoid heap allocation for spaces
235/// up to 4 dimensions, covering all v1 topologies (1D, 2D, hex).
236/// Higher-dimensional spaces spill to the heap transparently.
237pub type Coord = SmallVec<[i32; 4]>;
238
239#[cfg(test)]
240mod entity_id_tests {
241    use super::*;
242    use proptest::prelude::*;
243
244    #[test]
245    fn new_packs_slot_and_generation() {
246        let id = EntityId::new(42, 7);
247        assert_eq!(id.slot(), 42);
248        assert_eq!(id.generation(), 7);
249    }
250
251    #[test]
252    fn max_slot_value() {
253        let id = EntityId::new(1_048_575, 0);
254        assert_eq!(id.slot(), 1_048_575);
255        assert_eq!(id.generation(), 0);
256    }
257
258    #[test]
259    fn max_generation_value() {
260        let id = EntityId::new(0, 4095);
261        assert_eq!(id.slot(), 0);
262        assert_eq!(id.generation(), 4095);
263    }
264
265    #[test]
266    fn raw_round_trip_preserves_bits() {
267        let id = EntityId::new(1, 1);
268        let raw = id.as_u32();
269        assert_eq!(EntityId::from_u32(raw), id);
270        assert_eq!(raw, (1 << 20) | 1);
271    }
272
273    #[test]
274    #[should_panic(expected = "slot")]
275    fn slot_overflow_panics() {
276        let _ = EntityId::new(1_048_576, 0);
277    }
278
279    #[test]
280    #[should_panic(expected = "generation")]
281    fn generation_overflow_panics() {
282        let _ = EntityId::new(0, 4096);
283    }
284
285    #[test]
286    fn equality_requires_both_slot_and_generation() {
287        assert_ne!(EntityId::new(1, 0), EntityId::new(1, 1));
288    }
289
290    #[test]
291    fn property_index_displays_inner_value() {
292        assert_eq!(PropertyIndex::from(17).to_string(), "17");
293    }
294
295    proptest! {
296        #[test]
297        fn entity_id_round_trip_preserves_slot_generation(
298            slot in 0_u32..=1_048_575,
299            generation in 0_u32..=4_095,
300        ) {
301            let id = EntityId::new(slot, generation);
302
303            prop_assert_eq!(id.slot(), slot);
304            prop_assert_eq!(id.generation(), generation);
305            prop_assert_eq!(EntityId::from_u32(id.as_u32()), id);
306        }
307    }
308}