dwbase_core/
lib.rs

1//! Core immutable data model for DWBase.
2//!
3//! Atoms are immutable records; updates are represented by creating new atoms that
4//! link to predecessors rather than mutating in place.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// Identifier for an atom (stringly typed for WASM friendliness).
10#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub struct AtomId(pub String);
12
13impl AtomId {
14    pub fn new(id: impl Into<String>) -> Self {
15        Self(id.into())
16    }
17}
18
19impl fmt::Display for AtomId {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        self.0.fmt(f)
22    }
23}
24
25/// Identifier for the world/space an atom belongs to.
26#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct WorldKey(pub String);
28
29impl WorldKey {
30    pub fn new(key: impl Into<String>) -> Self {
31        Self(key.into())
32    }
33}
34
35impl fmt::Display for WorldKey {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        self.0.fmt(f)
38    }
39}
40
41/// Identifier for the worker/agent that produced the atom.
42#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub struct WorkerKey(pub String);
44
45impl WorkerKey {
46    pub fn new(key: impl Into<String>) -> Self {
47        Self(key.into())
48    }
49}
50
51impl fmt::Display for WorkerKey {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        self.0.fmt(f)
54    }
55}
56
57/// Kind of atom within DWBase.
58#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum AtomKind {
61    Observation,
62    Reflection,
63    Plan,
64    Action,
65    Message,
66}
67
68/// Relationship between atoms.
69#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum LinkKind {
72    Supersedes,
73    References,
74    Confirms,
75    Contradicts,
76}
77
78/// A typed link to another atom.
79#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
80pub struct Link {
81    pub target: AtomId,
82    pub kind: LinkKind,
83}
84
85impl Link {
86    pub fn new(target: AtomId, kind: LinkKind) -> Self {
87        Self { target, kind }
88    }
89}
90
91/// ISO-8601 UTC timestamp wrapper.
92#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
93pub struct Timestamp(pub String);
94
95impl Timestamp {
96    pub fn new(ts: impl Into<String>) -> Self {
97        Self(ts.into())
98    }
99}
100
101impl fmt::Display for Timestamp {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        self.0.fmt(f)
104    }
105}
106
107/// Importance score in the closed range [0.0, 1.0].
108#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
109pub struct Importance(f32);
110
111#[derive(Debug, thiserror::Error, PartialEq)]
112pub enum ImportanceError {
113    #[error("importance must be between 0.0 and 1.0 inclusive, got {0}")]
114    OutOfRange(f32),
115    #[error("importance cannot be NaN")]
116    NotANumber,
117}
118
119impl Importance {
120    /// Validates the provided value is finite and within [0.0, 1.0].
121    pub fn new(value: f32) -> Result<Self, ImportanceError> {
122        if value.is_nan() {
123            return Err(ImportanceError::NotANumber);
124        }
125        if !(0.0..=1.0).contains(&value) {
126            return Err(ImportanceError::OutOfRange(value));
127        }
128        Ok(Self(value))
129    }
130
131    /// Clamps the provided value into the valid range; NaN becomes 0.0.
132    pub fn clamped(value: f32) -> Self {
133        if value.is_nan() {
134            return Self(0.0);
135        }
136        Self(value.clamp(0.0, 1.0))
137    }
138
139    pub fn get(self) -> f32 {
140        self.0
141    }
142}
143
144impl fmt::Display for Importance {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        self.0.fmt(f)
147    }
148}
149
150/// Immutable atom record.
151///
152/// Atoms are constructed once and never mutated; updates are represented by new atoms
153/// referencing older atoms through `links`.
154#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
155pub struct Atom {
156    id: AtomId,
157    world: WorldKey,
158    worker: WorkerKey,
159    kind: AtomKind,
160    timestamp: Timestamp,
161    importance: Importance,
162    payload_json: String,
163    vector: Option<Vec<f32>>,
164    flags: Vec<String>,
165    labels: Vec<String>,
166    links: Vec<Link>,
167}
168
169impl Atom {
170    #[allow(clippy::too_many_arguments)]
171    pub fn new(
172        id: AtomId,
173        world: WorldKey,
174        worker: WorkerKey,
175        kind: AtomKind,
176        timestamp: Timestamp,
177        importance: Importance,
178        payload_json: impl Into<String>,
179        vector: Option<Vec<f32>>,
180        flags: Vec<String>,
181        labels: Vec<String>,
182        links: Vec<Link>,
183    ) -> Self {
184        Self {
185            id,
186            world,
187            worker,
188            kind,
189            timestamp,
190            importance,
191            payload_json: payload_json.into(),
192            vector,
193            flags,
194            labels,
195            links,
196        }
197    }
198
199    pub fn builder(
200        id: AtomId,
201        world: WorldKey,
202        worker: WorkerKey,
203        kind: AtomKind,
204        timestamp: Timestamp,
205        importance: Importance,
206        payload_json: impl Into<String>,
207    ) -> AtomBuilder {
208        AtomBuilder {
209            id,
210            world,
211            worker,
212            kind,
213            timestamp,
214            importance,
215            payload_json: payload_json.into(),
216            vector: None,
217            flags: Vec::new(),
218            labels: Vec::new(),
219            links: Vec::new(),
220        }
221    }
222
223    pub fn id(&self) -> &AtomId {
224        &self.id
225    }
226
227    pub fn world(&self) -> &WorldKey {
228        &self.world
229    }
230
231    pub fn worker(&self) -> &WorkerKey {
232        &self.worker
233    }
234
235    pub fn kind(&self) -> &AtomKind {
236        &self.kind
237    }
238
239    pub fn timestamp(&self) -> &Timestamp {
240        &self.timestamp
241    }
242
243    pub fn importance(&self) -> Importance {
244        self.importance
245    }
246
247    pub fn payload_json(&self) -> &str {
248        &self.payload_json
249    }
250
251    pub fn vector(&self) -> Option<&[f32]> {
252        self.vector.as_deref()
253    }
254
255    pub fn flags(&self) -> &[String] {
256        &self.flags
257    }
258
259    pub fn labels(&self) -> &[String] {
260        &self.labels
261    }
262
263    pub fn links(&self) -> &[Link] {
264        &self.links
265    }
266}
267
268/// Builder for immutable atoms.
269pub struct AtomBuilder {
270    id: AtomId,
271    world: WorldKey,
272    worker: WorkerKey,
273    kind: AtomKind,
274    timestamp: Timestamp,
275    importance: Importance,
276    payload_json: String,
277    vector: Option<Vec<f32>>,
278    flags: Vec<String>,
279    labels: Vec<String>,
280    links: Vec<Link>,
281}
282
283impl AtomBuilder {
284    pub fn vector(mut self, vector: Option<Vec<f32>>) -> Self {
285        self.vector = vector;
286        self
287    }
288
289    pub fn add_flag(mut self, flag: impl Into<String>) -> Self {
290        self.flags.push(flag.into());
291        self
292    }
293
294    pub fn add_label(mut self, label: impl Into<String>) -> Self {
295        self.labels.push(label.into());
296        self
297    }
298
299    pub fn add_link(mut self, link: AtomId) -> Self {
300        self.links.push(Link::new(link, LinkKind::References));
301        self
302    }
303
304    pub fn add_typed_link(mut self, target: AtomId, kind: LinkKind) -> Self {
305        self.links.push(Link::new(target, kind));
306        self
307    }
308
309    pub fn build(self) -> Atom {
310        Atom {
311            id: self.id,
312            world: self.world,
313            worker: self.worker,
314            kind: self.kind,
315            timestamp: self.timestamp,
316            importance: self.importance,
317            payload_json: self.payload_json,
318            vector: self.vector,
319            flags: self.flags,
320            labels: self.labels,
321            links: self.links,
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    fn sample_atom() -> Atom {
331        Atom::builder(
332            AtomId::new("a1"),
333            WorldKey::new("world-1"),
334            WorkerKey::new("worker-1"),
335            AtomKind::Observation,
336            Timestamp::new("2024-01-01T00:00:00Z"),
337            Importance::new(0.5).expect("importance"),
338            r#"{"foo":"bar"}"#,
339        )
340        .vector(Some(vec![1.0, 2.0, 3.0]))
341        .add_flag("immutable")
342        .add_label("test")
343        .add_link(AtomId::new("previous"))
344        .build()
345    }
346
347    #[test]
348    fn importance_validation() {
349        assert!(Importance::new(0.0).is_ok());
350        assert!(Importance::new(1.0).is_ok());
351        assert!(Importance::new(1.1).is_err());
352        assert!(Importance::new(f32::NAN).is_err());
353        assert_eq!(Importance::clamped(1.5).get(), 1.0);
354        assert_eq!(Importance::clamped(-1.0).get(), 0.0);
355        assert_eq!(Importance::clamped(f32::NAN).get(), 0.0);
356    }
357
358    #[test]
359    fn atom_kind_serde_names_are_stable() {
360        let kinds = [
361            (AtomKind::Observation, "observation"),
362            (AtomKind::Reflection, "reflection"),
363            (AtomKind::Plan, "plan"),
364            (AtomKind::Action, "action"),
365            (AtomKind::Message, "message"),
366        ];
367
368        for (kind, expected) in kinds {
369            let json = serde_json::to_string(&kind).unwrap();
370            assert_eq!(json, format!("\"{expected}\""));
371        }
372    }
373
374    #[cfg(feature = "bincode")]
375    #[test]
376    fn atom_roundtrip_bincode() {
377        let atom = sample_atom();
378        let bytes =
379            bincode::serde::encode_to_vec(&atom, bincode::config::standard()).expect("serialize");
380        let decoded: Atom = bincode::serde::decode_from_slice(&bytes, bincode::config::standard())
381            .map(|(v, _)| v)
382            .expect("deserialize");
383        assert_eq!(atom, decoded);
384    }
385
386    #[cfg(feature = "rmp-serde")]
387    #[test]
388    fn atom_roundtrip_rmp() {
389        let atom = sample_atom();
390        let bytes = rmp_serde::to_vec(&atom).expect("serialize");
391        let decoded: Atom = rmp_serde::from_slice(&bytes).expect("deserialize");
392        assert_eq!(atom, decoded);
393    }
394}