Skip to main content

plugmem_core/
model.rs

1//! Record layouts of the data model.
2//!
3//! Every record is a fixed-size [`Slot`] living in an [`Arena`]; the byte
4//! layouts below are **format contracts** — the snapshot is a memcpy of
5//! the arenas, so changing an offset here changes the file format. The
6//! layout tests compare against hand-written reference buffers: breaking a
7//! layout breaks a test.
8//!
9//! All integer fields are big-endian — mandatory for key prefixes (the
10//! arena sorts by raw bytes) and kept for payloads too, so a slot has one
11//! endianness throughout.
12//!
13//! [`Arena`]: plugmem_arena::Arena
14
15use plugmem_arena::{BlobId, ListHandle, Slot, TermId, key};
16
17use crate::id::{EntityId, FactId, NONE_U32};
18
19/// `valid_to` value of an open fact ("true now").
20pub const VALID_TO_OPEN: u64 = u64::MAX;
21
22/// Bit flags of [`FactRecord::flags`].
23pub mod fact_flags {
24    /// The fact is deleted; recall never returns it, `maintain` purges it.
25    pub const TOMBSTONE: u16 = 1;
26    /// The validity interval is closed (`valid_to < u64::MAX`) — the fact
27    /// was revised.
28    pub const CLOSED: u16 = 1 << 1;
29    /// A vector slot is attached ([`crate::model::FactRecord::vector`]
30    /// is meaningful).
31    pub const HAS_VECTOR: u16 = 1 << 2;
32}
33
34/// The unit of memory: one fact (48-byte slot, Uniform arena).
35///
36/// | off | size | field |
37/// |---|---|---|
38/// | 0 | 4 | `id` (key) |
39/// | 4 | 4 | `entity` |
40/// | 8 | 2 | `flags` |
41/// | 10 | 2 | `kind` (reserved, 0 in v1) |
42/// | 12 | 4 | `text` |
43/// | 16 | 4 | `vector` |
44/// | 20 | 4 | `revises` |
45/// | 24 | 8 | `recorded_at` |
46/// | 32 | 8 | `valid_from` |
47/// | 40 | 8 | `valid_to` |
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50pub struct FactRecord {
51    /// Fact id — the key.
52    pub id: FactId,
53    /// Subject entity, or [`EntityId::NONE`].
54    pub entity: EntityId,
55    /// Bit set from [`fact_flags`].
56    pub flags: u16,
57    /// Reserved in v1; must be `0` (fact typing is a tag convention).
58    pub kind: u16,
59    /// Fact text (UTF-8) in the blob heap.
60    pub text: BlobId,
61    /// Slot index in the vector arena, or [`NONE_U32`]; meaningful only
62    /// with [`fact_flags::HAS_VECTOR`].
63    pub vector: u32,
64    /// Predecessor in the revision chain, or [`FactId::NONE`]. May name a
65    /// *burned* id — a predecessor that was forgotten and physically
66    /// purged by `maintain`; resolving it then yields `None`, the same
67    /// answer a tombstoned record gives.
68    pub revises: FactId,
69    /// Knowledge axis: when the memory learned this. Immutable.
70    pub recorded_at: u64,
71    /// Truth axis: start of the validity interval.
72    pub valid_from: u64,
73    /// Truth axis: end of the validity interval; [`VALID_TO_OPEN`] = open.
74    pub valid_to: u64,
75}
76
77impl FactRecord {
78    /// `true` when the tombstone flag is set.
79    pub fn is_tombstone(&self) -> bool {
80        self.flags & fact_flags::TOMBSTONE != 0
81    }
82
83    /// `true` when the validity interval is closed.
84    pub fn is_closed(&self) -> bool {
85        self.flags & fact_flags::CLOSED != 0
86    }
87
88    /// `true` when a vector slot is attached.
89    pub fn has_vector(&self) -> bool {
90        self.flags & fact_flags::HAS_VECTOR != 0
91    }
92
93    /// The `as_of(t)` liveness rule: not a tombstone, already
94    /// recorded at `t`, and `t` inside `[valid_from, valid_to)`.
95    pub fn is_live_at(&self, t: u64) -> bool {
96        !self.is_tombstone() && self.recorded_at <= t && self.valid_from <= t && t < self.valid_to
97    }
98}
99
100impl Slot for FactRecord {
101    const SIZE: usize = 48;
102    const KEY_LEN: usize = 4;
103
104    fn write(&self, out: &mut [u8]) {
105        key::write_u32(out, self.id.0);
106        key::write_u32(&mut out[4..], self.entity.0);
107        out[8..10].copy_from_slice(&self.flags.to_be_bytes());
108        out[10..12].copy_from_slice(&self.kind.to_be_bytes());
109        key::write_u32(&mut out[12..], self.text.0);
110        key::write_u32(&mut out[16..], self.vector);
111        key::write_u32(&mut out[20..], self.revises.0);
112        key::write_u64(&mut out[24..], self.recorded_at);
113        key::write_u64(&mut out[32..], self.valid_from);
114        key::write_u64(&mut out[40..], self.valid_to);
115    }
116
117    fn read(bytes: &[u8]) -> Self {
118        Self {
119            id: FactId(key::read_u32(bytes)),
120            entity: EntityId(key::read_u32(&bytes[4..])),
121            flags: u16::from_be_bytes(bytes[8..10].try_into().unwrap()),
122            kind: u16::from_be_bytes(bytes[10..12].try_into().unwrap()),
123            text: BlobId(key::read_u32(&bytes[12..])),
124            vector: key::read_u32(&bytes[16..]),
125            revises: FactId(key::read_u32(&bytes[20..])),
126            recorded_at: key::read_u64(&bytes[24..]),
127            valid_from: key::read_u64(&bytes[32..]),
128            valid_to: key::read_u64(&bytes[40..]),
129        }
130    }
131}
132
133/// Per-fact auxiliary record: the tag-list handle and the optional metadata
134/// blob (20-byte slot, Uniform arena; layout
135/// `[id 4 | ListHandle 12 | meta 4]`).
136///
137/// Split from [`FactRecord`] so the hot 48-byte record stays hot: tags and
138/// metadata are touched only by tag-filtered queries, `show`/`export` and
139/// `maintain`.
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
142pub struct FactAux {
143    /// Fact id — the key.
144    pub id: FactId,
145    /// The fact's tag list (`TermId` values) in the tag `ChunkPool`.
146    pub tags: ListHandle,
147    /// The fact's metadata blob in the `metas` heap (a canonical key→value
148    /// encoding, see [`crate::metadata`]), or [`BlobId`]`(`[`NONE_U32`]`)` when
149    /// the fact carries no metadata. The engine never interprets the bytes.
150    pub meta: BlobId,
151}
152
153impl Slot for FactAux {
154    const SIZE: usize = 20;
155    const KEY_LEN: usize = 4;
156
157    fn write(&self, out: &mut [u8]) {
158        key::write_u32(out, self.id.0);
159        out[4..16].copy_from_slice(&self.tags.to_bytes());
160        key::write_u32(&mut out[16..], self.meta.0);
161    }
162
163    fn read(bytes: &[u8]) -> Self {
164        Self {
165            id: FactId(key::read_u32(bytes)),
166            tags: ListHandle::from_bytes(bytes[4..16].try_into().unwrap()),
167            meta: BlobId(key::read_u32(&bytes[16..])),
168        }
169    }
170}
171
172/// A graph node (24-byte slot, Uniform arena).
173///
174/// | off | size | field |
175/// |---|---|---|
176/// | 0 | 4 | `id` (key) |
177/// | 4 | 4 | `name` |
178/// | 8 | 4 | `name_term` |
179/// | 12 | 8 | `created_at` |
180/// | 20 | 4 | `flags` (reserved, 0 in v1) |
181#[derive(Clone, Copy, Debug, PartialEq, Eq)]
182#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
183pub struct EntityRecord {
184    /// Entity id — the key.
185    pub id: EntityId,
186    /// Canonical name as first entered (blob heap, UTF-8).
187    pub name: BlobId,
188    /// Interned *normalized* name — the lookup key for name resolution.
189    pub name_term: TermId,
190    /// When the entity was first mentioned.
191    pub created_at: u64,
192    /// Reserved in v1; must be `0`.
193    pub flags: u32,
194}
195
196impl Slot for EntityRecord {
197    const SIZE: usize = 24;
198    const KEY_LEN: usize = 4;
199
200    fn write(&self, out: &mut [u8]) {
201        key::write_u32(out, self.id.0);
202        key::write_u32(&mut out[4..], self.name.0);
203        key::write_u32(&mut out[8..], self.name_term.0);
204        key::write_u64(&mut out[12..], self.created_at);
205        key::write_u32(&mut out[20..], self.flags);
206    }
207
208    fn read(bytes: &[u8]) -> Self {
209        Self {
210            id: EntityId(key::read_u32(bytes)),
211            name: BlobId(key::read_u32(&bytes[4..])),
212            name_term: TermId(key::read_u32(&bytes[8..])),
213            created_at: key::read_u64(&bytes[12..]),
214            flags: key::read_u32(&bytes[20..]),
215        }
216    }
217}
218
219/// Name → entity resolution record (8-byte slot, Ordered arena,
220/// the whole slot is the key: `[name_term BE | id BE]`).
221///
222/// The normalized name is unique (lookup-or-create), so a prefix scan on
223/// `name_term` yields at most one record; the full pair keeps the slot
224/// unique and self-describing.
225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
226#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
227pub struct EntityByName {
228    /// Interned normalized name.
229    pub name_term: TermId,
230    /// The entity carrying that name.
231    pub id: EntityId,
232}
233
234impl Slot for EntityByName {
235    const SIZE: usize = 8;
236    const KEY_LEN: usize = 8;
237
238    fn write(&self, out: &mut [u8]) {
239        key::write_u32(out, self.name_term.0);
240        key::write_u32(&mut out[4..], self.id.0);
241    }
242
243    fn read(bytes: &[u8]) -> Self {
244        Self {
245            name_term: TermId(key::read_u32(bytes)),
246            id: EntityId(key::read_u32(&bytes[4..])),
247        }
248    }
249}
250
251/// A typed graph edge (16-byte slot, Ordered arena, key
252/// `[a BE | rel BE | b BE]`, payload `fact`).
253///
254/// Stored twice, in two mirrored arenas: the out-arena keys by
255/// `(src, rel, dst)`, the in-arena by `(dst, rel, src)` — `a`/`b` are
256/// whichever end comes first in that arena's key. Neighbor traversal is a
257/// prefix range scan. An edge is unique per `(src, rel, dst)`; re-linking
258/// updates the provenance payload.
259#[derive(Clone, Copy, Debug, PartialEq, Eq)]
260#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
261pub struct EdgeSlot {
262    /// First key component (out-arena: source; in-arena: destination).
263    pub a: EntityId,
264    /// Interned relation term (`"works_at"`, `"owns"`, …).
265    pub rel: TermId,
266    /// Second key component (out-arena: destination; in-arena: source).
267    pub b: EntityId,
268    /// Provenance fact, or [`FactId::NONE`]. Like
269    /// [`FactRecord::revises`], it may name a burned id once the
270    /// provenance fact has been forgotten and purged.
271    pub fact: FactId,
272}
273
274impl Slot for EdgeSlot {
275    const SIZE: usize = 16;
276    const KEY_LEN: usize = 12;
277
278    fn write(&self, out: &mut [u8]) {
279        key::write_u32(out, self.a.0);
280        key::write_u32(&mut out[4..], self.rel.0);
281        key::write_u32(&mut out[8..], self.b.0);
282        key::write_u32(&mut out[12..], self.fact.0);
283    }
284
285    fn read(bytes: &[u8]) -> Self {
286        Self {
287            a: EntityId(key::read_u32(bytes)),
288            rel: TermId(key::read_u32(&bytes[4..])),
289            b: EntityId(key::read_u32(&bytes[8..])),
290            fact: FactId(key::read_u32(&bytes[12..])),
291        }
292    }
293}
294
295/// Temporal index record (12-byte slot, Ordered arena, the whole
296/// slot is the key: `[recorded_at BE | fact BE]`, no payload).
297///
298/// Range scans answer "what was recorded in this window"; validity
299/// filtering happens per candidate on its [`FactRecord`].
300#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
302pub struct TemporalSlot {
303    /// When the fact was recorded (knowledge axis).
304    pub recorded_at: u64,
305    /// The fact recorded at that moment.
306    pub fact: FactId,
307}
308
309impl Slot for TemporalSlot {
310    const SIZE: usize = 12;
311    const KEY_LEN: usize = 12;
312
313    fn write(&self, out: &mut [u8]) {
314        key::write_pair(out, self.recorded_at, self.fact.0);
315    }
316
317    fn read(bytes: &[u8]) -> Self {
318        let (recorded_at, fact) = key::read_pair(bytes);
319        Self {
320            recorded_at,
321            fact: FactId(fact),
322        }
323    }
324}
325
326/// Compile-time layout self-checks: a slot size that drifts is a format
327/// break, catch it before any test runs.
328const _: () = {
329    assert!(FactRecord::SIZE == 48 && FactRecord::KEY_LEN == 4);
330    assert!(FactAux::SIZE == 20 && FactAux::KEY_LEN == 4);
331    assert!(EntityRecord::SIZE == 24 && EntityRecord::KEY_LEN == 4);
332    assert!(EntityByName::SIZE == 8 && EntityByName::KEY_LEN == 8);
333    assert!(EdgeSlot::SIZE == 16 && EdgeSlot::KEY_LEN == 12);
334    assert!(TemporalSlot::SIZE == 12 && TemporalSlot::KEY_LEN == 12);
335    // NONE sentinels must agree across the id kinds and the raw fields.
336    assert!(NONE_U32 == u32::MAX);
337};