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 core::mem::size_of;
16
17use plugmem_arena::{BlobId, ListHandle, Slot, TermId, key};
18
19use crate::id::{EdgeId, EntityId, FactId, NONE_U32};
20
21/// `valid_to` value of an open fact ("true now").
22pub const VALID_TO_OPEN: u64 = u64::MAX;
23
24/// Bit flags of [`FactRecord::flags`].
25pub mod fact_flags {
26    /// The fact is deleted; recall never returns it, `maintain` purges it.
27    pub const TOMBSTONE: u16 = 1;
28    /// The validity interval is closed (`valid_to < u64::MAX`) — the fact
29    /// was revised.
30    pub const CLOSED: u16 = 1 << 1;
31    /// A vector slot is attached ([`crate::model::FactRecord::vector`]
32    /// is meaningful).
33    pub const HAS_VECTOR: u16 = 1 << 2;
34}
35
36/// The unit of memory: one fact (48-byte slot, Uniform arena).
37///
38/// | off | size | field |
39/// |---|---|---|
40/// | 0 | 4 | `id` (key) |
41/// | 4 | 4 | `entity` |
42/// | 8 | 2 | `flags` |
43/// | 10 | 2 | `kind` (reserved, 0 in v1) |
44/// | 12 | 4 | `text` |
45/// | 16 | 4 | `vector` |
46/// | 20 | 4 | `revises` |
47/// | 24 | 8 | `recorded_at` |
48/// | 32 | 8 | `valid_from` |
49/// | 40 | 8 | `valid_to` |
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52pub struct FactRecord {
53    /// Fact id — the key.
54    pub id: FactId,
55    /// Subject entity, or [`EntityId::NONE`].
56    pub entity: EntityId,
57    /// Bit set from [`fact_flags`].
58    pub flags: u16,
59    /// Reserved in v1; must be `0` (fact typing is a tag convention).
60    pub kind: u16,
61    /// Fact text (UTF-8) in the blob heap.
62    pub text: BlobId,
63    /// Slot index in the vector arena, or [`NONE_U32`]; meaningful only
64    /// with [`fact_flags::HAS_VECTOR`].
65    pub vector: u32,
66    /// Predecessor in the revision chain, or [`FactId::NONE`]. May name a
67    /// *burned* id — a predecessor that was forgotten and physically
68    /// purged by `maintain`; resolving it then yields `None`, the same
69    /// answer a tombstoned record gives.
70    pub revises: FactId,
71    /// Knowledge axis: when the memory learned this. Immutable.
72    pub recorded_at: u64,
73    /// Truth axis: start of the validity interval.
74    pub valid_from: u64,
75    /// Truth axis: end of the validity interval; [`VALID_TO_OPEN`] = open.
76    pub valid_to: u64,
77}
78
79impl FactRecord {
80    /// `true` when the tombstone flag is set.
81    pub fn is_tombstone(&self) -> bool {
82        self.flags & fact_flags::TOMBSTONE != 0
83    }
84
85    /// `true` when the validity interval is closed.
86    pub fn is_closed(&self) -> bool {
87        self.flags & fact_flags::CLOSED != 0
88    }
89
90    /// `true` when a vector slot is attached.
91    pub fn has_vector(&self) -> bool {
92        self.flags & fact_flags::HAS_VECTOR != 0
93    }
94
95    /// The `as_of(t)` liveness rule: not a tombstone, already
96    /// recorded at `t`, and `t` inside `[valid_from, valid_to)`.
97    pub fn is_live_at(&self, t: u64) -> bool {
98        !self.is_tombstone() && self.recorded_at <= t && self.valid_from <= t && t < self.valid_to
99    }
100}
101
102impl Slot for FactRecord {
103    const SIZE: usize = 48;
104    const KEY_LEN: usize = 4;
105
106    fn write(&self, out: &mut [u8]) {
107        key::write_u32(out, self.id.0);
108        key::write_u32(&mut out[4..], self.entity.0);
109        out[8..10].copy_from_slice(&self.flags.to_be_bytes());
110        out[10..12].copy_from_slice(&self.kind.to_be_bytes());
111        key::write_u32(&mut out[12..], self.text.0);
112        key::write_u32(&mut out[16..], self.vector);
113        key::write_u32(&mut out[20..], self.revises.0);
114        key::write_u64(&mut out[24..], self.recorded_at);
115        key::write_u64(&mut out[32..], self.valid_from);
116        key::write_u64(&mut out[40..], self.valid_to);
117    }
118
119    fn read(bytes: &[u8]) -> Self {
120        Self {
121            id: FactId(key::read_u32(bytes)),
122            entity: EntityId(key::read_u32(&bytes[4..])),
123            flags: u16::from_be_bytes(bytes[8..10].try_into().unwrap()),
124            kind: u16::from_be_bytes(bytes[10..12].try_into().unwrap()),
125            text: BlobId(key::read_u32(&bytes[12..])),
126            vector: key::read_u32(&bytes[16..]),
127            revises: FactId(key::read_u32(&bytes[20..])),
128            recorded_at: key::read_u64(&bytes[24..]),
129            valid_from: key::read_u64(&bytes[32..]),
130            valid_to: key::read_u64(&bytes[40..]),
131        }
132    }
133}
134
135/// Per-fact auxiliary record: the tag-list handle and the optional metadata
136/// blob (20-byte slot, Uniform arena; layout
137/// `[id 4 | ListHandle 12 | meta 4]`).
138///
139/// Split from [`FactRecord`] so the hot 48-byte record stays hot: tags and
140/// metadata are touched only by tag-filtered queries, `show`/`export` and
141/// `maintain`.
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
144pub struct FactAux {
145    /// Fact id — the key.
146    pub id: FactId,
147    /// The fact's tag list (`TermId` values) in the tag `ChunkPool`.
148    pub tags: ListHandle,
149    /// The fact's metadata blob in the `metas` heap (a canonical key→value
150    /// encoding, see `crate::metadata`), or [`BlobId`]`(`[`NONE_U32`]`)` when
151    /// the fact carries no metadata. The engine never interprets the bytes.
152    pub meta: BlobId,
153}
154
155impl Slot for FactAux {
156    const SIZE: usize = 20;
157    const KEY_LEN: usize = 4;
158
159    fn write(&self, out: &mut [u8]) {
160        key::write_u32(out, self.id.0);
161        out[4..16].copy_from_slice(&self.tags.to_bytes());
162        key::write_u32(&mut out[16..], self.meta.0);
163    }
164
165    fn read(bytes: &[u8]) -> Self {
166        Self {
167            id: FactId(key::read_u32(bytes)),
168            tags: ListHandle::from_bytes(bytes[4..16].try_into().unwrap()),
169            meta: BlobId(key::read_u32(&bytes[16..])),
170        }
171    }
172}
173
174/// A graph node (24-byte slot, Uniform arena).
175///
176/// | off | size | field |
177/// |---|---|---|
178/// | 0 | 4 | `id` (key) |
179/// | 4 | 4 | `name` |
180/// | 8 | 4 | `name_term` |
181/// | 12 | 8 | `created_at` |
182/// | 20 | 4 | `flags` (reserved, 0 in v1) |
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
185pub struct EntityRecord {
186    /// Entity id — the key.
187    pub id: EntityId,
188    /// Canonical name as first entered (blob heap, UTF-8).
189    pub name: BlobId,
190    /// Interned *normalized* name — the lookup key for name resolution.
191    pub name_term: TermId,
192    /// When the entity was first mentioned.
193    pub created_at: u64,
194    /// Reserved in v1; must be `0`.
195    pub flags: u32,
196}
197
198impl Slot for EntityRecord {
199    const SIZE: usize = 24;
200    const KEY_LEN: usize = 4;
201
202    fn write(&self, out: &mut [u8]) {
203        key::write_u32(out, self.id.0);
204        key::write_u32(&mut out[4..], self.name.0);
205        key::write_u32(&mut out[8..], self.name_term.0);
206        key::write_u64(&mut out[12..], self.created_at);
207        key::write_u32(&mut out[20..], self.flags);
208    }
209
210    fn read(bytes: &[u8]) -> Self {
211        Self {
212            id: EntityId(key::read_u32(bytes)),
213            name: BlobId(key::read_u32(&bytes[4..])),
214            name_term: TermId(key::read_u32(&bytes[8..])),
215            created_at: key::read_u64(&bytes[12..]),
216            flags: key::read_u32(&bytes[20..]),
217        }
218    }
219}
220
221/// Name → entity resolution record (8-byte slot, Ordered arena,
222/// the whole slot is the key: `[name_term BE | id BE]`).
223///
224/// The normalized name is unique (lookup-or-create), so a prefix scan on
225/// `name_term` yields at most one record; the full pair keeps the slot
226/// unique and self-describing.
227#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
229pub struct EntityByName {
230    /// Interned normalized name.
231    pub name_term: TermId,
232    /// The entity carrying that name.
233    pub id: EntityId,
234}
235
236impl Slot for EntityByName {
237    const SIZE: usize = 8;
238    const KEY_LEN: usize = 8;
239
240    fn write(&self, out: &mut [u8]) {
241        key::write_u32(out, self.name_term.0);
242        key::write_u32(&mut out[4..], self.id.0);
243    }
244
245    fn read(bytes: &[u8]) -> Self {
246        Self {
247            name_term: TermId(key::read_u32(bytes)),
248            id: EntityId(key::read_u32(&bytes[4..])),
249        }
250    }
251}
252
253/// Byte layout of [`EdgeSlot`]. Every offset is the previous field's offset
254/// plus its width, so a field cannot be moved by editing one number: the
255/// layout is a chain, and `SIZE`/`KEY_LEN` fall out of it.
256mod edge_at {
257    use core::mem::size_of;
258
259    pub(super) const A: usize = 0;
260    pub(super) const REL: usize = A + size_of::<u32>();
261    pub(super) const B: usize = REL + size_of::<u32>();
262    /// End of the key: `(a, rel, b)` identifies a current edge.
263    pub(super) const KEY_LEN: usize = B + size_of::<u32>();
264    pub(super) const FACT: usize = KEY_LEN;
265    pub(super) const EDGE: usize = FACT + size_of::<u32>();
266    pub(super) const VALID_FROM: usize = EDGE + size_of::<u32>();
267    pub(super) const SIZE: usize = VALID_FROM + size_of::<u64>();
268}
269
270/// A typed graph edge, currently open (28-byte slot, Ordered arena, key
271/// `[a BE | rel BE | b BE]`, payload `fact | edge | valid_from`).
272///
273/// Stored twice, in two mirrored arenas: the out-arena keys by
274/// `(src, rel, dst)`, the in-arena by `(dst, rel, src)` — `a`/`b` are
275/// whichever end comes first in that arena's key. Neighbor traversal is a
276/// prefix range scan. An edge is unique per `(src, rel, dst)`; re-linking
277/// closes this version and opens a new one.
278///
279/// The slot carries the identity of its open [`EdgeHistorySlot`] version —
280/// `edge` and `valid_from` are exactly that record's key tail — so closing an
281/// edge addresses its history record directly instead of searching for the
282/// open version among the triple's other versions.
283#[derive(Clone, Copy, Debug, PartialEq, Eq)]
284#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
285pub struct EdgeSlot {
286    /// First key component (out-arena: source; in-arena: destination).
287    pub a: EntityId,
288    /// Interned relation term (`"works_at"`, `"owns"`, …).
289    pub rel: TermId,
290    /// Second key component (out-arena: destination; in-arena: source).
291    pub b: EntityId,
292    /// Provenance fact, or [`FactId::NONE`]. Like
293    /// [`FactRecord::revises`], it may name a burned id once the
294    /// provenance fact has been forgotten and purged.
295    pub fact: FactId,
296    /// The open edge version this slot mirrors.
297    pub edge: EdgeId,
298    /// Start of the open version's validity — with `edge`, the key tail of
299    /// its [`EdgeHistorySlot`].
300    pub valid_from: u64,
301}
302
303impl Slot for EdgeSlot {
304    const SIZE: usize = edge_at::SIZE;
305    const KEY_LEN: usize = edge_at::KEY_LEN;
306
307    fn write(&self, out: &mut [u8]) {
308        key::write_u32(&mut out[edge_at::A..], self.a.0);
309        key::write_u32(&mut out[edge_at::REL..], self.rel.0);
310        key::write_u32(&mut out[edge_at::B..], self.b.0);
311        key::write_u32(&mut out[edge_at::FACT..], self.fact.0);
312        key::write_u32(&mut out[edge_at::EDGE..], self.edge.0);
313        key::write_u64(&mut out[edge_at::VALID_FROM..], self.valid_from);
314    }
315
316    fn read(bytes: &[u8]) -> Self {
317        Self {
318            a: EntityId(key::read_u32(&bytes[edge_at::A..])),
319            rel: TermId(key::read_u32(&bytes[edge_at::REL..])),
320            b: EntityId(key::read_u32(&bytes[edge_at::B..])),
321            fact: FactId(key::read_u32(&bytes[edge_at::FACT..])),
322            edge: EdgeId(key::read_u32(&bytes[edge_at::EDGE..])),
323            valid_from: key::read_u64(&bytes[edge_at::VALID_FROM..]),
324        }
325    }
326}
327
328/// The key of a current edge: `[a | rel | b]`.
329pub(crate) fn edge_key(a: EntityId, rel: TermId, b: EntityId) -> [u8; edge_at::KEY_LEN] {
330    let mut out = [0u8; edge_at::KEY_LEN];
331    key::write_u32(&mut out[edge_at::A..], a.0);
332    key::write_u32(&mut out[edge_at::REL..], rel.0);
333    key::write_u32(&mut out[edge_at::B..], b.0);
334    out
335}
336
337/// Byte layout of [`EdgeHistorySlot`], derived field by field like
338/// [`edge_at`].
339mod edge_hist_at {
340    use core::mem::size_of;
341
342    pub(super) const A: usize = 0;
343    pub(super) const VALID_FROM: usize = A + size_of::<u32>();
344    pub(super) const EDGE: usize = VALID_FROM + size_of::<u64>();
345    /// End of the key: `(a, valid_from, edge)` orders an entity's versions by
346    /// the instant they became true, `edge` breaking ties.
347    pub(super) const KEY_LEN: usize = EDGE + size_of::<u32>();
348    pub(super) const REL: usize = KEY_LEN;
349    pub(super) const B: usize = REL + size_of::<u32>();
350    pub(super) const FACT: usize = B + size_of::<u32>();
351    pub(super) const FLAGS: usize = FACT + size_of::<u32>();
352    pub(super) const KIND: usize = FLAGS + size_of::<u16>();
353    pub(super) const RECORDED_AT: usize = KIND + size_of::<u16>();
354    pub(super) const VALID_TO: usize = RECORDED_AT + size_of::<u64>();
355    pub(super) const SIZE: usize = VALID_TO + size_of::<u64>();
356}
357
358/// A temporal typed graph edge version (48-byte slot, Ordered arena, key
359/// `[a BE | valid_from BE | edge BE]`).
360///
361/// Stored twice, in two mirrored history arenas with the same orientation as
362/// [`EdgeSlot`]. The hot current graph still uses [`EdgeSlot`]; this record is
363/// the source of truth for historical `as_of` traversal.
364///
365/// The key is **time-ordered per entity**, not grouped by relation. An
366/// `as_of(t)` traversal wants the versions valid at one instant, and at most
367/// one version of a `(a, rel, b)` triple is valid at any instant, so grouping
368/// by triple forces a walk through every version of every triple to find the
369/// few that answer. Ordering by `valid_from` instead lets the traversal start
370/// at `t` and walk backwards through the versions that most recently became
371/// true — the candidates — and stop when it has enough.
372#[derive(Clone, Copy, Debug, PartialEq, Eq)]
373#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
374pub struct EdgeHistorySlot {
375    /// First key component (out-arena: source; in-arena: destination).
376    pub a: EntityId,
377    /// Interned relation term.
378    pub rel: TermId,
379    /// Second key component (out-arena: destination; in-arena: source).
380    pub b: EntityId,
381    /// Edge-version id.
382    pub edge: EdgeId,
383    /// Provenance fact, or [`FactId::NONE`].
384    pub fact: FactId,
385    /// Edge flags; see [`edge_flags`].
386    pub flags: u16,
387    /// Reserved for future lifecycle states.
388    pub kind: u16,
389    /// Knowledge axis: when the edge version was recorded.
390    pub recorded_at: u64,
391    /// Truth axis: start of the edge validity interval.
392    pub valid_from: u64,
393    /// Truth axis: end of the edge validity interval; [`VALID_TO_OPEN`] =
394    /// current.
395    pub valid_to: u64,
396}
397
398impl EdgeHistorySlot {
399    /// `true` when the edge version is active at `t`.
400    pub fn active_at(&self, t: u64) -> bool {
401        self.valid_from <= t && t < self.valid_to
402    }
403
404    /// `true` when this version is the current open edge.
405    pub fn is_open(&self) -> bool {
406        self.valid_to == VALID_TO_OPEN
407    }
408}
409
410/// Bit flags of [`EdgeHistorySlot::flags`].
411pub mod edge_flags {
412    /// The edge validity interval is closed (`valid_to < u64::MAX`).
413    pub const CLOSED: u16 = 1;
414}
415
416impl Slot for EdgeHistorySlot {
417    const SIZE: usize = edge_hist_at::SIZE;
418    const KEY_LEN: usize = edge_hist_at::KEY_LEN;
419
420    fn write(&self, out: &mut [u8]) {
421        key::write_u32(&mut out[edge_hist_at::A..], self.a.0);
422        key::write_u64(&mut out[edge_hist_at::VALID_FROM..], self.valid_from);
423        key::write_u32(&mut out[edge_hist_at::EDGE..], self.edge.0);
424        key::write_u32(&mut out[edge_hist_at::REL..], self.rel.0);
425        key::write_u32(&mut out[edge_hist_at::B..], self.b.0);
426        key::write_u32(&mut out[edge_hist_at::FACT..], self.fact.0);
427        let flags = edge_hist_at::FLAGS;
428        out[flags..flags + size_of::<u16>()].copy_from_slice(&self.flags.to_be_bytes());
429        let kind = edge_hist_at::KIND;
430        out[kind..kind + size_of::<u16>()].copy_from_slice(&self.kind.to_be_bytes());
431        key::write_u64(&mut out[edge_hist_at::RECORDED_AT..], self.recorded_at);
432        key::write_u64(&mut out[edge_hist_at::VALID_TO..], self.valid_to);
433    }
434
435    fn read(bytes: &[u8]) -> Self {
436        let flags = edge_hist_at::FLAGS;
437        let kind = edge_hist_at::KIND;
438        Self {
439            a: EntityId(key::read_u32(&bytes[edge_hist_at::A..])),
440            rel: TermId(key::read_u32(&bytes[edge_hist_at::REL..])),
441            b: EntityId(key::read_u32(&bytes[edge_hist_at::B..])),
442            edge: EdgeId(key::read_u32(&bytes[edge_hist_at::EDGE..])),
443            fact: FactId(key::read_u32(&bytes[edge_hist_at::FACT..])),
444            flags: u16::from_be_bytes(bytes[flags..flags + size_of::<u16>()].try_into().unwrap()),
445            kind: u16::from_be_bytes(bytes[kind..kind + size_of::<u16>()].try_into().unwrap()),
446            recorded_at: key::read_u64(&bytes[edge_hist_at::RECORDED_AT..]),
447            valid_from: key::read_u64(&bytes[edge_hist_at::VALID_FROM..]),
448            valid_to: key::read_u64(&bytes[edge_hist_at::VALID_TO..]),
449        }
450    }
451}
452
453/// The lowest key of `a`'s current-edge run — the inclusive lower bound of a
454/// neighbor scan.
455pub(crate) fn edge_floor(a: EntityId) -> [u8; edge_at::KEY_LEN] {
456    edge_key(a, TermId(0), EntityId(0))
457}
458
459/// The exclusive upper bound of `a`'s current-edge run. Saturating leaves the
460/// range empty for [`EntityId::NONE`], which is a sentinel and never names a
461/// stored entity.
462pub(crate) fn edge_end(a: EntityId) -> [u8; edge_at::KEY_LEN] {
463    edge_key(EntityId(a.0.saturating_add(1)), TermId(0), EntityId(0))
464}
465
466/// The key of one edge version: `[a | valid_from | edge]`.
467pub(crate) fn edge_history_key(
468    a: EntityId,
469    valid_from: u64,
470    edge: EdgeId,
471) -> [u8; edge_hist_at::KEY_LEN] {
472    let mut out = [0u8; edge_hist_at::KEY_LEN];
473    key::write_u32(&mut out[edge_hist_at::A..], a.0);
474    key::write_u64(&mut out[edge_hist_at::VALID_FROM..], valid_from);
475    key::write_u32(&mut out[edge_hist_at::EDGE..], edge.0);
476    out
477}
478
479/// The lowest key of `a`'s version run — the inclusive lower bound of a
480/// per-entity history scan.
481pub(crate) fn edge_history_floor(a: EntityId) -> [u8; edge_hist_at::KEY_LEN] {
482    edge_history_key(a, 0, EdgeId(0))
483}
484
485/// The exclusive upper bound of `a`'s versions that had already become true at
486/// `as_of`, i.e. everything with `valid_from <= as_of`.
487///
488/// Saturating past `u64::MAX` excludes only a version with
489/// `valid_from == u64::MAX`, which can never be valid at any instant: validity
490/// needs `t < valid_to <= u64::MAX` and `valid_from <= t`.
491pub(crate) fn edge_history_ceiling(a: EntityId, as_of: u64) -> [u8; edge_hist_at::KEY_LEN] {
492    edge_history_key(a, as_of.saturating_add(1), EdgeId(0))
493}
494
495/// Closes an edge version in place: sets [`edge_flags::CLOSED`] and
496/// `valid_to`. `payload` is the slot bytes *after* the key, exactly as
497/// [`Arena::payload_mut`](plugmem_arena::Arena::payload_mut) hands them over,
498/// so every offset is shifted by the key length here rather than at the call
499/// site.
500pub(crate) fn close_edge_history_payload(payload: &mut [u8], valid_to: u64) {
501    const KEY: usize = edge_hist_at::KEY_LEN;
502    const FLAGS: usize = edge_hist_at::FLAGS - KEY;
503    const VALID_TO: usize = edge_hist_at::VALID_TO - KEY;
504    let flags = u16::from_be_bytes(payload[FLAGS..FLAGS + size_of::<u16>()].try_into().unwrap())
505        | edge_flags::CLOSED;
506    payload[FLAGS..FLAGS + size_of::<u16>()].copy_from_slice(&flags.to_be_bytes());
507    key::write_u64(&mut payload[VALID_TO..], valid_to);
508}
509
510/// Temporal index record (12-byte slot, Ordered arena, the whole
511/// slot is the key: `[recorded_at BE | fact BE]`, no payload).
512///
513/// Range scans answer "what was recorded in this window"; validity
514/// filtering happens per candidate on its [`FactRecord`].
515#[derive(Clone, Copy, Debug, PartialEq, Eq)]
516#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
517pub struct TemporalSlot {
518    /// When the fact was recorded (knowledge axis).
519    pub recorded_at: u64,
520    /// The fact recorded at that moment.
521    pub fact: FactId,
522}
523
524impl Slot for TemporalSlot {
525    const SIZE: usize = 12;
526    const KEY_LEN: usize = 12;
527
528    fn write(&self, out: &mut [u8]) {
529        key::write_pair(out, self.recorded_at, self.fact.0);
530    }
531
532    fn read(bytes: &[u8]) -> Self {
533        let (recorded_at, fact) = key::read_pair(bytes);
534        Self {
535            recorded_at,
536            fact: FactId(fact),
537        }
538    }
539}
540
541/// Compile-time layout self-checks: a slot size that drifts is a format
542/// break, catch it before any test runs.
543const _: () = {
544    assert!(FactRecord::SIZE == 48 && FactRecord::KEY_LEN == 4);
545    assert!(FactAux::SIZE == 20 && FactAux::KEY_LEN == 4);
546    assert!(EntityRecord::SIZE == 24 && EntityRecord::KEY_LEN == 4);
547    assert!(EntityByName::SIZE == 8 && EntityByName::KEY_LEN == 8);
548    assert!(EdgeSlot::SIZE == 28 && EdgeSlot::KEY_LEN == 12);
549    assert!(EdgeHistorySlot::SIZE == 48 && EdgeHistorySlot::KEY_LEN == 16);
550    assert!(TemporalSlot::SIZE == 12 && TemporalSlot::KEY_LEN == 12);
551    // NONE sentinels must agree across the id kinds and the raw fields.
552    assert!(NONE_U32 == u32::MAX);
553};