Skip to main content

plugmem_core/memory/
persist.rs

1//! Snapshot composition: the engine's state as container sections and the
2//! validated load path.
3//!
4//! Saving concatenates every structure's canonical dump into the
5//! [`snapshot`](crate::snapshot) container. Loading is the untrusted-input
6//! side: after the container's structural validation (checksums are checked
7//! on demand via [`Snapshot::scrub`](crate::snapshot::Snapshot::scrub), not
8//! at load), every structure validates its own image, chunk chains are walked with shared
9//! visited maps (cycles, double-claims, orphans), posting lists are fully
10//! decoded (well-formed varints, ascending ids, counts and last-id
11//! agreement), text and term pools are UTF-8-checked, and **every stored
12//! id is range-checked** — facts' blob/entity/revision references, edge
13//! endpoints, temporal and by-name entries. That last pass is what keeps
14//! the engine's panicking accessors (`get`, `resolve` — contract-violation
15//! panics by design) sound on arbitrary input: after a successful load no
16//! persisted id can violate a contract.
17
18use alloc::vec::Vec;
19
20use plugmem_arena::{
21    Arena, ArenaCfg, BlobHeap, BlobHeapCfg, ChunkPool, ChunkPoolCfg, Interner, ShardMode, Slot,
22};
23
24use crate::config::Config;
25use crate::error::Error;
26use crate::id::{FactId, NONE_U32};
27use crate::index::IdListIndex;
28use crate::index::bm25::Bm25Index;
29use crate::index::hnsw::HnswGraph;
30use crate::index::postings::PostingStore;
31use crate::index::varint::decode_u32;
32use crate::index::vecpool::VecPool;
33use crate::memory::FactFault;
34use crate::memory::migrations::{self, STATE_LEN};
35use crate::memory::shards::ShardLayout;
36use crate::model::{
37    EdgeHistorySlot, EdgeSlot, EntityByName, EntityRecord, FactAux, FactRecord, TemporalSlot,
38    VALID_TO_OPEN, edge_history_key, edge_key,
39};
40use crate::snapshot::{Prefix, SectionMeta, Snapshot, SnapshotSink, build_prefix, pad_len};
41use xxhash_rust::xxh3::Xxh3;
42
43use super::Memory;
44
45/// Section kinds of the engine snapshot (`meta`/`index` before `pool` —
46/// readers want the small section first).
47mod kind {
48    pub const FACTS_META: u16 = 1;
49    pub const FACTS_POOL: u16 = 2;
50    pub const AUX_META: u16 = 3;
51    pub const AUX_POOL: u16 = 4;
52    pub const ENTITIES_META: u16 = 5;
53    pub const ENTITIES_POOL: u16 = 6;
54    pub const BY_NAME_META: u16 = 7;
55    pub const BY_NAME_POOL: u16 = 8;
56    // 9..=12 and 46..=49 were the edge sections before the time-ordered
57    // history layout; see `migrations::legacy_kind`.
58    pub const TEMPORAL_META: u16 = 13;
59    pub const TEMPORAL_POOL: u16 = 14;
60    pub const TEXTS_INDEX: u16 = 15;
61    pub const TEXTS_POOL: u16 = 16;
62    pub const TERMS_INDEX: u16 = 17;
63    pub const TERMS_POOL: u16 = 18;
64    pub const TERMS_TABLE: u16 = 19;
65    pub const TAG_LISTS_META: u16 = 20;
66    pub const TAG_LISTS_POOL: u16 = 21;
67    pub const BM25_HANDLES_META: u16 = 22;
68    pub const BM25_HANDLES_POOL: u16 = 23;
69    pub const BM25_CHUNKS_META: u16 = 24;
70    pub const BM25_CHUNKS_POOL: u16 = 25;
71    // 26..=27 were the per-document BM25 records before they carried the
72    // term-set summary; see `migrations::legacy_kind`.
73    pub const TAGS_HANDLES_META: u16 = 28;
74    pub const TAGS_HANDLES_POOL: u16 = 29;
75    pub const TAGS_CHUNKS_META: u16 = 30;
76    pub const TAGS_CHUNKS_POOL: u16 = 31;
77    pub const ENTFACTS_HANDLES_META: u16 = 32;
78    pub const ENTFACTS_HANDLES_POOL: u16 = 33;
79    pub const ENTFACTS_CHUNKS_META: u16 = 34;
80    pub const ENTFACTS_CHUNKS_POOL: u16 = 35;
81    pub const ENGINE_STATE: u16 = 36;
82    pub const VEC_POOL: u16 = 37;
83    pub const HNSW_META: u16 = 38;
84    pub const HNSW_LEVEL0: u16 = 39;
85    pub const HNSW_UPPER_META: u16 = 40;
86    pub const HNSW_UPPER_POOL: u16 = 41;
87    pub const HNSW_LISTS_META: u16 = 42;
88    pub const HNSW_LISTS_POOL: u16 = 43;
89    pub const METAS_INDEX: u16 = 44;
90    pub const METAS_POOL: u16 = 45;
91    /// Current edges carrying their open version's identity.
92    pub const EDGES_OUT_META: u16 = 50;
93    pub const EDGES_OUT_POOL: u16 = 51;
94    pub const EDGES_IN_META: u16 = 52;
95    pub const EDGES_IN_POOL: u16 = 53;
96    /// Edge history keyed `[a | valid_from | edge]`.
97    pub const EDGE_HIST_OUT_META: u16 = 54;
98    pub const EDGE_HIST_OUT_POOL: u16 = 55;
99    pub const EDGE_HIST_IN_META: u16 = 56;
100    pub const EDGE_HIST_IN_POOL: u16 = 57;
101    /// Per-document BM25 records carrying the term-set summary.
102    pub const BM25_DOCLEN_META: u16 = 58;
103    pub const BM25_DOCLEN_POOL: u16 = 59;
104}
105
106/// The callback [`Memory::emit_sections_from`] drives once per snapshot
107/// section: the section `kind` and the byte pieces whose concatenation is its
108/// body.
109type SectionFn<'f> = dyn FnMut(u16, &[&[u8]]) -> Result<(), Error> + 'f;
110
111/// The engine structures a snapshot emit reads for the *rebuildable* sections —
112/// everything `maintain` recompacts. Bundled behind references so one emit path
113/// serves both the live engine (`self`'s own structures, [`Memory::sections`])
114/// and the disk-first rebuild (freshly rebuilt metadata + graph, with the two
115/// big pools borrowing a `Scratch`). The genuinely ride-through structures —
116/// the interner, the by-name index and the id counters — are read straight
117/// from `self` in [`Memory::emit_sections_from`]; `maintain` never touches
118/// them, so they are the same on both paths.
119///
120/// The edge arenas are here rather than read from `self` because
121/// [`MaintenanceMode::Full`](super::MaintenanceMode::Full) repacks them: the
122/// disk-first path has to emit the rebuilt ones, and an ordinary snapshot the
123/// engine's own.
124pub(crate) struct Sections<'r, 'a> {
125    pub(crate) facts: &'r Arena<'a, FactRecord>,
126    pub(crate) fact_aux: &'r Arena<'a, FactAux>,
127    pub(crate) entities: &'r Arena<'a, EntityRecord>,
128    pub(crate) by_name: &'r Arena<'a, EntityByName>,
129    pub(crate) temporal: &'r Arena<'a, TemporalSlot>,
130    pub(crate) texts: &'r BlobHeap<'a>,
131    pub(crate) metas: &'r BlobHeap<'a>,
132    pub(crate) tag_lists: &'r ChunkPool<'a>,
133    pub(crate) bm25: &'r Bm25Index<'a>,
134    pub(crate) tags_idx: &'r IdListIndex<'a>,
135    pub(crate) entity_facts: &'r IdListIndex<'a>,
136    pub(crate) vecs: &'r VecPool<'a>,
137    pub(crate) hnsw: &'r HnswGraph<'a>,
138    pub(crate) edges_out: &'r Arena<'a, EdgeSlot>,
139    pub(crate) edges_in: &'r Arena<'a, EdgeSlot>,
140    pub(crate) edges_hist_out: &'r Arena<'a, EdgeHistorySlot>,
141    pub(crate) edges_hist_in: &'r Arena<'a, EdgeHistorySlot>,
142    /// How the arenas above are sharded.
143    ///
144    /// Carried with the sections rather than read from `self.cfg`, because the
145    /// disk-first path writes arenas it has just rebuilt while the engine it
146    /// borrows still records the old layout. The config in the file has to
147    /// describe the arenas in the same file.
148    pub(crate) layout: ShardLayout,
149}
150
151/// Dumps an arena as its `(meta, pool)` section pair.
152fn arena_sections<T: Slot>(a: &Arena<'_, T>) -> (Vec<u8>, Vec<u8>) {
153    let (mut meta, mut pool) = (Vec::new(), Vec::new());
154    a.dump_meta(&mut meta);
155    a.dump_pool(&mut pool);
156    (meta, pool)
157}
158
159/// Fetches a required section.
160fn section<'a>(snap: &Snapshot<'a>, kind: u16) -> Result<&'a [u8], Error> {
161    snap.section(kind)
162        .ok_or(Error::Corrupt("snapshot is missing a required section"))
163}
164
165/// The eight current-format edge sections.
166struct EdgeSections<'a> {
167    out_meta: &'a [u8],
168    out_pool: &'a [u8],
169    in_meta: &'a [u8],
170    in_pool: &'a [u8],
171    hist_out_meta: &'a [u8],
172    hist_out_pool: &'a [u8],
173    hist_in_meta: &'a [u8],
174    hist_in_pool: &'a [u8],
175}
176
177/// Collects the current-format edge sections, or `None` when the image has
178/// none of them — an empty database, or one written before the time-ordered
179/// history layout, which [`Memory::migrate_edges`] then rebuilds. Present but
180/// incomplete is corruption: the eight sections are written together.
181fn edge_sections<'a>(snap: &Snapshot<'a>) -> Result<Option<EdgeSections<'a>>, Error> {
182    const KINDS: [u16; 8] = [
183        kind::EDGES_OUT_META,
184        kind::EDGES_OUT_POOL,
185        kind::EDGES_IN_META,
186        kind::EDGES_IN_POOL,
187        kind::EDGE_HIST_OUT_META,
188        kind::EDGE_HIST_OUT_POOL,
189        kind::EDGE_HIST_IN_META,
190        kind::EDGE_HIST_IN_POOL,
191    ];
192    let found = KINDS.map(|k| snap.section(k));
193    if found.iter().all(Option::is_none) {
194        return Ok(None);
195    }
196    let [
197        out_meta,
198        out_pool,
199        in_meta,
200        in_pool,
201        hist_out_meta,
202        hist_out_pool,
203        hist_in_meta,
204        hist_in_pool,
205    ] = found.map(|s| s.ok_or(Error::Corrupt("snapshot has incomplete edge sections")));
206    Ok(Some(EdgeSections {
207        out_meta: out_meta?,
208        out_pool: out_pool?,
209        in_meta: in_meta?,
210        in_pool: in_pool?,
211        hist_out_meta: hist_out_meta?,
212        hist_out_pool: hist_out_pool?,
213        hist_in_meta: hist_in_meta?,
214        hist_in_pool: hist_in_pool?,
215    }))
216}
217
218impl<'a, const TF: bool> PostingStore<'a, TF> {
219    /// Dumps the store's four sections.
220    pub(crate) fn dump_sections(&self) -> [Vec<u8>; 4] {
221        let (hm, hp) = (self.handles_meta(), self.handles_pool());
222        let (cm, cp) = (self.chunks_meta(), self.chunks_pool());
223        [hm, hp, cm, cp]
224    }
225
226    /// Rebuilds a store from its sections and validates every list: chain
227    /// walks over a shared visited map, full entry decode (well-formed
228    /// varints, strictly ascending ids without overflow), `count`/`last`
229    /// agreement, and no orphan chunks. Owned path — the parts are copied
230    /// (`'static`); see [`PostingStore::load_sections_borrowed`] for the
231    /// zero-copy sibling.
232    pub(crate) fn load_sections(
233        shards: usize,
234        max_bytes: usize,
235        hm: &[u8],
236        hp: &[u8],
237        cm: &[u8],
238        cp: &[u8],
239    ) -> Result<Self, Error> {
240        let handles = Arena::<crate::index::postings::IdListSlot>::load(
241            ArenaCfg::new(shards, ShardMode::Uniform).with_max_bytes(max_bytes),
242            hm,
243            hp,
244        )?;
245        let pool = ChunkPool::load(ChunkPoolCfg::new().with_max_bytes(max_bytes), cm, cp)?;
246        Self::validate_lists(&handles, &pool)?;
247        Ok(Self::from_parts(handles, pool))
248    }
249
250    /// Zero-copy sibling of [`PostingStore::load_sections`]: the handle
251    /// arena pool and the chunk pool borrow their mmap'd sections
252    /// Same validation; the lifetime ties the store to `hp`
253    /// and `cp`.
254    pub(crate) fn load_sections_borrowed(
255        shards: usize,
256        max_bytes: usize,
257        hm: &[u8],
258        hp: &'a [u8],
259        cm: &[u8],
260        cp: &'a [u8],
261    ) -> Result<Self, Error> {
262        let handles = Arena::<crate::index::postings::IdListSlot>::load_borrowed(
263            ArenaCfg::new(shards, ShardMode::Uniform).with_max_bytes(max_bytes),
264            hm,
265            hp,
266        )?;
267        let pool = ChunkPool::load_borrowed(ChunkPoolCfg::new().with_max_bytes(max_bytes), cm, cp)?;
268        Self::validate_lists(&handles, &pool)?;
269        Ok(Self::from_parts(handles, pool))
270    }
271
272    /// The shared list validation, over already-built parts: chain walks,
273    /// full entry decode, `count`/`last` agreement, no orphan chunks. The
274    /// only difference between the owned and borrowed load paths is how
275    /// `handles`/`pool` were constructed, so both funnel through here.
276    fn validate_lists(
277        handles: &Arena<'_, crate::index::postings::IdListSlot>,
278        pool: &ChunkPool<'_>,
279    ) -> Result<(), Error> {
280        let mut visited = alloc::vec![false; pool.chunks()];
281        for slot in handles.iter() {
282            pool.validate_chain(&slot.handle, &mut visited)?;
283            let mut count = 0u32;
284            let mut last = 0u32;
285            let mut first = true;
286            for chunk in pool.iter(&slot.handle) {
287                let mut cur = chunk;
288                while !cur.is_empty() {
289                    let Some((delta, used)) = decode_u32(cur) else {
290                        return Err(Error::Corrupt("posting entry is malformed"));
291                    };
292                    let mut entry_len = used;
293                    if TF {
294                        if cur.len() < used + 1 {
295                            return Err(Error::Corrupt("posting entry is malformed"));
296                        }
297                        entry_len += 1;
298                    }
299                    cur = &cur[entry_len..];
300                    let id = if first {
301                        first = false;
302                        delta
303                    } else {
304                        if delta == 0 {
305                            return Err(Error::Corrupt("posting ids are not ascending"));
306                        }
307                        last.checked_add(delta)
308                            .ok_or(Error::Corrupt("posting id overflows"))?
309                    };
310                    last = id;
311                    count += 1;
312                }
313            }
314            if count != slot.count || (count > 0 && last != slot.last) {
315                return Err(Error::Corrupt("posting list disagrees with its handle"));
316            }
317        }
318        if pool.orphan_count(&visited) != 0 {
319            return Err(Error::Corrupt("posting pool has orphan chunks"));
320        }
321        Ok(())
322    }
323}
324
325impl<'a> Bm25Index<'a> {
326    /// The six BM25 sections as `(kind, bytes)` pairs, in canonical order.
327    fn dump_pairs(&self) -> [(u16, Vec<u8>); 6] {
328        let [hm, hp, cm, cp] = self.postings().dump_sections();
329        let (dm, dp) = arena_sections(self.doc_len_arena());
330        [
331            (kind::BM25_HANDLES_META, hm),
332            (kind::BM25_HANDLES_POOL, hp),
333            (kind::BM25_CHUNKS_META, cm),
334            (kind::BM25_CHUNKS_POOL, cp),
335            (kind::BM25_DOCLEN_META, dm),
336            (kind::BM25_DOCLEN_POOL, dp),
337        ]
338    }
339
340    /// Owned load: postings and per-document lengths are copied
341    /// (`'static`).
342    fn load_from(snap: &Snapshot<'_>, cfg: &Config) -> Result<Self, Error> {
343        let postings = PostingStore::<true>::load_sections(
344            cfg.shards_postings,
345            cfg.max_bytes,
346            section(snap, kind::BM25_HANDLES_META)?,
347            section(snap, kind::BM25_HANDLES_POOL)?,
348            section(snap, kind::BM25_CHUNKS_META)?,
349            section(snap, kind::BM25_CHUNKS_POOL)?,
350        )?;
351        let (doc_len, migrated) = match migrations::legacy_doc_len(snap, cfg)? {
352            Some(upgraded) => (upgraded, true),
353            None => (
354                Arena::load(
355                    migrations::doc_len_cfg(cfg),
356                    section(snap, kind::BM25_DOCLEN_META)?,
357                    section(snap, kind::BM25_DOCLEN_POOL)?,
358                )?,
359                false,
360            ),
361        };
362        Self::assemble(postings, doc_len, snap, migrated)
363    }
364
365    /// Zero-copy sibling of [`Bm25Index::load_from`]: the postings and
366    /// doc-length pools borrow their mmap'd sections.
367    fn load_from_borrowed(snap: &Snapshot<'a>, cfg: &Config) -> Result<Self, Error> {
368        let postings = PostingStore::<true>::load_sections_borrowed(
369            cfg.shards_postings,
370            cfg.max_bytes,
371            section(snap, kind::BM25_HANDLES_META)?,
372            section(snap, kind::BM25_HANDLES_POOL)?,
373            section(snap, kind::BM25_CHUNKS_META)?,
374            section(snap, kind::BM25_CHUNKS_POOL)?,
375        )?;
376        // A pre-signature image cannot be aliased: the slot widened, so the
377        // migration hands back an owned arena even here.
378        let (doc_len, migrated) = match migrations::legacy_doc_len(snap, cfg)? {
379            Some(upgraded) => (upgraded, true),
380            None => (
381                Arena::load_borrowed(
382                    migrations::doc_len_cfg(cfg),
383                    section(snap, kind::BM25_DOCLEN_META)?,
384                    section(snap, kind::BM25_DOCLEN_POOL)?,
385                )?,
386                false,
387            ),
388        };
389        Self::assemble(postings, doc_len, snap, migrated)
390    }
391
392    /// Reconciles the corpus totals from the engine-state section and
393    /// assembles the index. Shared by both load paths (reads only the tiny
394    /// state section, so it ties nothing).
395    fn assemble(
396        postings: PostingStore<'a, true>,
397        doc_len: Arena<'a, crate::index::bm25::DocLenSlot>,
398        snap: &Snapshot<'_>,
399        migrated: bool,
400    ) -> Result<Self, Error> {
401        let state = section(snap, kind::ENGINE_STATE)?;
402        // Validates the width for every layout this crate has written; the
403        // corpus totals sit in the prefix all of them share.
404        migrations::decode_engine_state(state)?;
405        let total_docs = u64::from_le_bytes(state[8..16].try_into().unwrap());
406        let total_len = u64::from_le_bytes(state[16..24].try_into().unwrap());
407        if total_docs != doc_len.len() as u64 {
408            return Err(Error::Corrupt("bm25 document total disagrees with doc_len"));
409        }
410        let mut index = Self::from_parts(postings, doc_len, total_docs, total_len);
411        if migrated {
412            index.mark_unsummarized();
413        }
414        Ok(index)
415    }
416}
417
418impl<'a> Memory<'a> {
419    /// A [`Sections`] view over this engine's own structures — the source for
420    /// an ordinary snapshot.
421    pub(super) fn sections(&self) -> Sections<'_, 'a> {
422        Sections {
423            facts: &self.facts,
424            fact_aux: &self.fact_aux,
425            entities: &self.entities,
426            by_name: &self.by_name,
427            temporal: &self.temporal,
428            texts: &self.texts,
429            metas: &self.metas,
430            tag_lists: &self.tag_lists,
431            bm25: &self.bm25,
432            tags_idx: &self.tags_idx,
433            entity_facts: &self.entity_facts,
434            vecs: &self.vecs,
435            hnsw: &self.hnsw,
436            edges_out: &self.edges_out,
437            edges_in: &self.edges_in,
438            edges_hist_out: &self.edges_hist_out,
439            edges_hist_in: &self.edges_hist_in,
440            layout: ShardLayout::of_config(&self.cfg),
441        }
442    }
443
444    /// Emits every snapshot section in canonical order, handing each to `f`
445    /// as its `kind` and one-or-more byte pieces (concatenated = the section
446    /// body). The rebuildable sections come from `s`; the ride-through ones
447    /// (by-name, edges, interner, id counters — untouched by `maintain`) come
448    /// from `self`. Most sections are a single owned buffer produced on the fly
449    /// and dropped after `f` returns; the dominant vector pool is handed as its
450    /// two borrowed pieces (`base`, `tail`) with no owned copy. Called twice by
451    /// the writer (size/hash pass, then write pass), so it must be deterministic
452    /// and side-effect free.
453    fn emit_sections_from(&self, s: &Sections<'_, '_>, f: &mut SectionFn<'_>) -> Result<(), Error> {
454        for (mk, pk, arena) in [
455            (kind::FACTS_META, kind::FACTS_POOL, arena_sections(s.facts)),
456            (kind::AUX_META, kind::AUX_POOL, arena_sections(s.fact_aux)),
457            (
458                kind::ENTITIES_META,
459                kind::ENTITIES_POOL,
460                arena_sections(s.entities),
461            ),
462            (
463                kind::BY_NAME_META,
464                kind::BY_NAME_POOL,
465                arena_sections(s.by_name),
466            ),
467            (
468                kind::EDGES_OUT_META,
469                kind::EDGES_OUT_POOL,
470                arena_sections(s.edges_out),
471            ),
472            (
473                kind::EDGES_IN_META,
474                kind::EDGES_IN_POOL,
475                arena_sections(s.edges_in),
476            ),
477            (
478                kind::EDGE_HIST_OUT_META,
479                kind::EDGE_HIST_OUT_POOL,
480                arena_sections(s.edges_hist_out),
481            ),
482            (
483                kind::EDGE_HIST_IN_META,
484                kind::EDGE_HIST_IN_POOL,
485                arena_sections(s.edges_hist_in),
486            ),
487            (
488                kind::TEMPORAL_META,
489                kind::TEMPORAL_POOL,
490                arena_sections(s.temporal),
491            ),
492        ] {
493            let (m, p) = arena;
494            f(mk, &[&m])?;
495            f(pk, &[&p])?;
496        }
497        let (mut i, mut p) = (Vec::new(), Vec::new());
498        s.texts.dump_index(&mut i);
499        s.texts.dump_pool(&mut p);
500        f(kind::TEXTS_INDEX, &[&i])?;
501        f(kind::TEXTS_POOL, &[&p])?;
502        let (mut i, mut p) = (Vec::new(), Vec::new());
503        s.metas.dump_index(&mut i);
504        s.metas.dump_pool(&mut p);
505        f(kind::METAS_INDEX, &[&i])?;
506        f(kind::METAS_POOL, &[&p])?;
507        let (mut i, mut p, mut t) = (Vec::new(), Vec::new(), Vec::new());
508        self.terms.dump_index(&mut i);
509        self.terms.dump_pool(&mut p);
510        self.terms.dump_table(&mut t);
511        f(kind::TERMS_INDEX, &[&i])?;
512        f(kind::TERMS_POOL, &[&p])?;
513        f(kind::TERMS_TABLE, &[&t])?;
514        let (mut m, mut p) = (Vec::new(), Vec::new());
515        s.tag_lists.dump_meta(&mut m);
516        s.tag_lists.dump_pool(&mut p);
517        f(kind::TAG_LISTS_META, &[&m])?;
518        f(kind::TAG_LISTS_POOL, &[&p])?;
519        for (k, bytes) in s.bm25.dump_pairs() {
520            f(k, &[&bytes])?;
521        }
522        let [hm, hp, cm, cp] = s.tags_idx.dump_sections();
523        f(kind::TAGS_HANDLES_META, &[&hm])?;
524        f(kind::TAGS_HANDLES_POOL, &[&hp])?;
525        f(kind::TAGS_CHUNKS_META, &[&cm])?;
526        f(kind::TAGS_CHUNKS_POOL, &[&cp])?;
527        let [hm, hp, cm, cp] = s.entity_facts.dump_sections();
528        f(kind::ENTFACTS_HANDLES_META, &[&hm])?;
529        f(kind::ENTFACTS_HANDLES_POOL, &[&hp])?;
530        f(kind::ENTFACTS_CHUNKS_META, &[&cm])?;
531        f(kind::ENTFACTS_CHUNKS_POOL, &[&cp])?;
532        let mut state = Vec::with_capacity(STATE_LEN);
533        state.extend_from_slice(&self.next_fact.to_le_bytes());
534        state.extend_from_slice(&self.next_entity.to_le_bytes());
535        state.extend_from_slice(&s.bm25.docs().to_le_bytes());
536        state.extend_from_slice(&s.bm25.total_len().to_le_bytes());
537        state.extend_from_slice(&self.bm25_tokenizer_version.to_le_bytes());
538        state.extend_from_slice(&0u32.to_le_bytes());
539        state.extend_from_slice(&self.next_edge.to_le_bytes());
540        state.extend_from_slice(&0u32.to_le_bytes());
541        f(kind::ENGINE_STATE, &[&state])?;
542        // The vector pool is one flat section (empty when dim is 0), streamed
543        // as its two borrowed pieces so the dominant pool needs no owned copy.
544        f(kind::VEC_POOL, &s.vecs.pieces())?;
545        // The HNSW graph: header, flat level-0 blocks, and the upper-level
546        // arena + list pool (all empty in the flat regime).
547        f(kind::HNSW_META, &[&s.hnsw.dump_meta()])?;
548        f(kind::HNSW_LEVEL0, &[&s.hnsw.dump_level0()])?;
549        let [um, up, lm, lp] = s.hnsw.dump_upper();
550        f(kind::HNSW_UPPER_META, &[&um])?;
551        f(kind::HNSW_UPPER_POOL, &[&up])?;
552        f(kind::HNSW_LISTS_META, &[&lm])?;
553        f(kind::HNSW_LISTS_POOL, &[&lp])?;
554        Ok(())
555    }
556
557    /// Streams the whole engine into snapshot-container bytes through `sink`,
558    /// never materializing the full image: a first pass computes
559    /// each section's length and checksum, the header+table prefix is written,
560    /// then a second pass streams the section bodies (the dominant vector pool
561    /// straight from its borrowed pieces) while a running hash accumulates the
562    /// file checksum, patched into the header at the end. Deterministic and
563    /// canonical — byte-identical to [`Memory::snapshot_bytes`].
564    ///
565    /// # Errors
566    ///
567    /// Propagates whatever `sink` reports (e.g. an I/O error from a file sink).
568    pub fn write_snapshot_to(&self, created_at: u64, sink: impl SnapshotSink) -> Result<(), Error> {
569        self.write_snapshot_with(&self.sections(), created_at, sink)
570    }
571
572    /// The snapshot writer over an explicit [`Sections`] source — the shared
573    /// core of [`Memory::write_snapshot_to`] (which passes `self`'s own
574    /// sections) and the disk-first rebuild (which passes freshly rebuilt
575    /// metadata with the big pools borrowing a `Scratch`). Since
576    /// both drive the *same* emit, the disk-first output is byte-identical to a
577    /// snapshot taken after an in-RAM `maintain`.
578    pub(crate) fn write_snapshot_with(
579        &self,
580        s: &Sections<'_, '_>,
581        created_at: u64,
582        mut sink: impl SnapshotSink,
583    ) -> Result<(), Error> {
584        let mut cfg_bytes = Vec::new();
585        let mut cfg = self.cfg.clone();
586        s.layout.apply(&mut cfg);
587        cfg.encode(&mut cfg_bytes);
588        let flags = if self.cfg.dim > 0 {
589            crate::snapshot::FLAG_VECTORS
590        } else {
591            0
592        };
593
594        // Pass 1: (kind, len, hash) for every section — small and bounded.
595        let mut metas: Vec<SectionMeta> = Vec::new();
596        self.emit_sections_from(s, &mut |kind, pieces| {
597            let mut h = Xxh3::new();
598            let mut len = 0u64;
599            for p in pieces {
600                h.update(p);
601                len += p.len() as u64;
602            }
603            metas.push(SectionMeta {
604                kind,
605                len,
606                hash: h.digest(),
607            });
608            Ok(())
609        })?;
610
611        let Prefix {
612            bytes: prefix,
613            offsets,
614            file_len: _,
615        } = build_prefix(
616            &cfg_bytes,
617            flags,
618            created_at,
619            env!("CARGO_PKG_VERSION"),
620            &metas,
621        );
622        sink.write(&prefix)?;
623        let mut file_hash = Xxh3::new();
624        file_hash.update(&prefix);
625
626        // Pass 2: section bodies + alignment padding, into sink and hash.
627        let zero = [0u8; 64]; // ALIGN — padding is always shorter than this.
628        let mut idx = 0usize;
629        self.emit_sections_from(s, &mut |_, pieces| {
630            for p in pieces {
631                sink.write(p)?;
632                file_hash.update(p);
633            }
634            let n = pad_len(offsets[idx], metas[idx].len);
635            sink.write(&zero[..n])?;
636            file_hash.update(&zero[..n]);
637            idx += 1;
638            Ok(())
639        })?;
640
641        sink.patch(
642            crate::snapshot::FILE_HASH_OFFSET,
643            &file_hash.digest().to_le_bytes(),
644        )
645    }
646
647    /// Serializes the whole engine into snapshot-container bytes.
648    /// Deterministic and canonical: save → load → save is byte-identical.
649    /// A thin wrapper over [`Memory::write_snapshot_to`] into a `Vec`; large
650    /// databases should prefer streaming into a file sink.
651    pub fn snapshot_bytes(&self, created_at: u64) -> Vec<u8> {
652        let mut out = Vec::new();
653        self.write_snapshot_to(created_at, &mut out)
654            .expect("writing a snapshot into a Vec is infallible");
655        out
656    }
657
658    /// Writes a full snapshot and clears the journal.
659    pub fn snapshot<S: crate::storage::Storage>(
660        &mut self,
661        store: &mut S,
662        now: u64,
663    ) -> Result<(), Error> {
664        let bytes = self.snapshot_bytes(now);
665        store
666            .write_snapshot(&bytes)
667            .map_err(|e| Error::Storage(alloc::format!("{e:?}")))?;
668        store
669            .clear_journal()
670            .map_err(|e| Error::Storage(alloc::format!("{e:?}")))?;
671        Ok(())
672    }
673
674    /// Loads an engine from snapshot bytes (the untrusted path — see the
675    /// module docs for the validation inventory). Owned path: every
676    /// section is copied into the arenas, so the returned engine borrows
677    /// nothing from `bytes` and is a `Memory<'static>`.
678    pub(super) fn load_snapshot(bytes: &[u8], cfg: Config) -> Result<Self, Error> {
679        cfg.validate()?;
680        let snap = Snapshot::parse(bytes)?;
681        let cfg = Self::reconcile_config(&snap, cfg)?;
682        let mut mem = Self::new(cfg)?;
683        let cfg = &mem.cfg;
684        let uni =
685            |shards: usize| ArenaCfg::new(shards, ShardMode::Uniform).with_max_bytes(cfg.max_bytes);
686        let ord =
687            |shards: usize| ArenaCfg::new(shards, ShardMode::Ordered).with_max_bytes(cfg.max_bytes);
688        let blob = BlobHeapCfg::new()
689            .with_max_bytes(cfg.max_bytes)
690            .with_max_blob(cfg.max_blob);
691        mem.facts = Arena::load(
692            uni(cfg.shards_facts),
693            section(&snap, kind::FACTS_META)?,
694            section(&snap, kind::FACTS_POOL)?,
695        )?;
696        mem.fact_aux = Arena::load(
697            uni(cfg.shards_facts),
698            section(&snap, kind::AUX_META)?,
699            section(&snap, kind::AUX_POOL)?,
700        )?;
701        mem.entities = Arena::load(
702            uni(cfg.shards_entities),
703            section(&snap, kind::ENTITIES_META)?,
704            section(&snap, kind::ENTITIES_POOL)?,
705        )?;
706        mem.by_name = Arena::load(
707            ord(cfg.shards_entities),
708            section(&snap, kind::BY_NAME_META)?,
709            section(&snap, kind::BY_NAME_POOL)?,
710        )?;
711        // Absent edge sections mean an image older than the time-ordered
712        // layout (or an empty database); `finish_load` migrates it.
713        if let Some(edges) = edge_sections(&snap)? {
714            mem.edges_out = Arena::load(ord(cfg.shards_edges), edges.out_meta, edges.out_pool)?;
715            mem.edges_in = Arena::load(ord(cfg.shards_edges), edges.in_meta, edges.in_pool)?;
716            mem.edges_hist_out = Arena::load(
717                ord(cfg.shards_edges),
718                edges.hist_out_meta,
719                edges.hist_out_pool,
720            )?;
721            mem.edges_hist_in = Arena::load(
722                ord(cfg.shards_edges),
723                edges.hist_in_meta,
724                edges.hist_in_pool,
725            )?;
726        }
727        mem.temporal = Arena::load(
728            ord(cfg.shards_temporal),
729            section(&snap, kind::TEMPORAL_META)?,
730            section(&snap, kind::TEMPORAL_POOL)?,
731        )?;
732        mem.texts = BlobHeap::load(
733            blob,
734            section(&snap, kind::TEXTS_INDEX)?,
735            section(&snap, kind::TEXTS_POOL)?,
736        )?;
737        mem.metas = BlobHeap::load(
738            blob,
739            section(&snap, kind::METAS_INDEX)?,
740            section(&snap, kind::METAS_POOL)?,
741        )?;
742        mem.terms = Interner::load(
743            blob,
744            section(&snap, kind::TERMS_INDEX)?,
745            section(&snap, kind::TERMS_POOL)?,
746            section(&snap, kind::TERMS_TABLE)?,
747        )?;
748        mem.tag_lists = ChunkPool::load(
749            ChunkPoolCfg::new().with_max_bytes(cfg.max_bytes),
750            section(&snap, kind::TAG_LISTS_META)?,
751            section(&snap, kind::TAG_LISTS_POOL)?,
752        )?;
753        mem.bm25 = Bm25Index::load_from(&snap, cfg)?;
754        mem.tags_idx = IdListIndex::load_sections(
755            cfg.shards_postings,
756            cfg.max_bytes,
757            section(&snap, kind::TAGS_HANDLES_META)?,
758            section(&snap, kind::TAGS_HANDLES_POOL)?,
759            section(&snap, kind::TAGS_CHUNKS_META)?,
760            section(&snap, kind::TAGS_CHUNKS_POOL)?,
761        )?;
762        mem.entity_facts = IdListIndex::load_sections(
763            cfg.shards_entities,
764            cfg.max_bytes,
765            section(&snap, kind::ENTFACTS_HANDLES_META)?,
766            section(&snap, kind::ENTFACTS_HANDLES_POOL)?,
767            section(&snap, kind::ENTFACTS_CHUNKS_META)?,
768            section(&snap, kind::ENTFACTS_CHUNKS_POOL)?,
769        )?;
770        mem.vecs = VecPool::from_parts(cfg.dim, cfg.max_bytes, section(&snap, kind::VEC_POOL)?)?;
771        mem.hnsw = crate::index::hnsw::HnswGraph::from_parts(
772            cfg.hnsw_m,
773            cfg.hnsw_m0,
774            cfg.max_bytes,
775            section(&snap, kind::HNSW_META)?,
776            section(&snap, kind::HNSW_LEVEL0)?,
777            section(&snap, kind::HNSW_UPPER_META)?,
778            section(&snap, kind::HNSW_UPPER_POOL)?,
779            section(&snap, kind::HNSW_LISTS_META)?,
780            section(&snap, kind::HNSW_LISTS_POOL)?,
781        )?;
782        Self::finish_load(mem, &snap)
783    }
784
785    /// Zero-copy sibling of [`Memory::load_snapshot`]: the large byte
786    /// pools (arenas, blob heaps, chunk pools, term dictionary, vectors,
787    /// upper HNSW lists) *borrow* their sections straight out of `bytes`
788    /// (an mmap'd snapshot), so opening an 8 GiB database residents only
789    /// the pages actually touched. Small metadata is still
790    /// rebuilt owned. The lifetime ties the engine to `bytes`; the handle
791    /// is read-only, so copy-on-write never fires.
792    pub(super) fn load_snapshot_borrowed(bytes: &'a [u8], cfg: Config) -> Result<Self, Error> {
793        cfg.validate()?;
794        let snap = Snapshot::parse(bytes)?;
795        let cfg = Self::reconcile_config(&snap, cfg)?;
796        let mut mem = Self::new(cfg)?;
797        let cfg = &mem.cfg;
798        let uni =
799            |shards: usize| ArenaCfg::new(shards, ShardMode::Uniform).with_max_bytes(cfg.max_bytes);
800        let ord =
801            |shards: usize| ArenaCfg::new(shards, ShardMode::Ordered).with_max_bytes(cfg.max_bytes);
802        let blob = BlobHeapCfg::new()
803            .with_max_bytes(cfg.max_bytes)
804            .with_max_blob(cfg.max_blob);
805        mem.facts = Arena::load_borrowed(
806            uni(cfg.shards_facts),
807            section(&snap, kind::FACTS_META)?,
808            section(&snap, kind::FACTS_POOL)?,
809        )?;
810        mem.fact_aux = Arena::load_borrowed(
811            uni(cfg.shards_facts),
812            section(&snap, kind::AUX_META)?,
813            section(&snap, kind::AUX_POOL)?,
814        )?;
815        mem.entities = Arena::load_borrowed(
816            uni(cfg.shards_entities),
817            section(&snap, kind::ENTITIES_META)?,
818            section(&snap, kind::ENTITIES_POOL)?,
819        )?;
820        mem.by_name = Arena::load_borrowed(
821            ord(cfg.shards_entities),
822            section(&snap, kind::BY_NAME_META)?,
823            section(&snap, kind::BY_NAME_POOL)?,
824        )?;
825        // A legacy image's edges are rebuilt owned by `finish_load` rather
826        // than borrowed: their bytes have to be re-keyed, so there is nothing
827        // to map. Everything else still borrows.
828        if let Some(edges) = edge_sections(&snap)? {
829            mem.edges_out =
830                Arena::load_borrowed(ord(cfg.shards_edges), edges.out_meta, edges.out_pool)?;
831            mem.edges_in =
832                Arena::load_borrowed(ord(cfg.shards_edges), edges.in_meta, edges.in_pool)?;
833            mem.edges_hist_out = Arena::load_borrowed(
834                ord(cfg.shards_edges),
835                edges.hist_out_meta,
836                edges.hist_out_pool,
837            )?;
838            mem.edges_hist_in = Arena::load_borrowed(
839                ord(cfg.shards_edges),
840                edges.hist_in_meta,
841                edges.hist_in_pool,
842            )?;
843        }
844        mem.temporal = Arena::load_borrowed(
845            ord(cfg.shards_temporal),
846            section(&snap, kind::TEMPORAL_META)?,
847            section(&snap, kind::TEMPORAL_POOL)?,
848        )?;
849        mem.texts = BlobHeap::load_borrowed(
850            blob,
851            section(&snap, kind::TEXTS_INDEX)?,
852            section(&snap, kind::TEXTS_POOL)?,
853        )?;
854        mem.metas = BlobHeap::load_borrowed(
855            blob,
856            section(&snap, kind::METAS_INDEX)?,
857            section(&snap, kind::METAS_POOL)?,
858        )?;
859        mem.terms = Interner::load_borrowed(
860            blob,
861            section(&snap, kind::TERMS_INDEX)?,
862            section(&snap, kind::TERMS_POOL)?,
863            section(&snap, kind::TERMS_TABLE)?,
864        )?;
865        mem.tag_lists = ChunkPool::load_borrowed(
866            ChunkPoolCfg::new().with_max_bytes(cfg.max_bytes),
867            section(&snap, kind::TAG_LISTS_META)?,
868            section(&snap, kind::TAG_LISTS_POOL)?,
869        )?;
870        mem.bm25 = Bm25Index::load_from_borrowed(&snap, cfg)?;
871        mem.tags_idx = IdListIndex::load_sections_borrowed(
872            cfg.shards_postings,
873            cfg.max_bytes,
874            section(&snap, kind::TAGS_HANDLES_META)?,
875            section(&snap, kind::TAGS_HANDLES_POOL)?,
876            section(&snap, kind::TAGS_CHUNKS_META)?,
877            section(&snap, kind::TAGS_CHUNKS_POOL)?,
878        )?;
879        mem.entity_facts = IdListIndex::load_sections_borrowed(
880            cfg.shards_entities,
881            cfg.max_bytes,
882            section(&snap, kind::ENTFACTS_HANDLES_META)?,
883            section(&snap, kind::ENTFACTS_HANDLES_POOL)?,
884            section(&snap, kind::ENTFACTS_CHUNKS_META)?,
885            section(&snap, kind::ENTFACTS_CHUNKS_POOL)?,
886        )?;
887        mem.vecs =
888            VecPool::from_parts_borrowed(cfg.dim, cfg.max_bytes, section(&snap, kind::VEC_POOL)?)?;
889        mem.hnsw = crate::index::hnsw::HnswGraph::from_parts_borrowed(
890            cfg.hnsw_m,
891            cfg.hnsw_m0,
892            cfg.max_bytes,
893            section(&snap, kind::HNSW_META)?,
894            section(&snap, kind::HNSW_LEVEL0)?,
895            section(&snap, kind::HNSW_UPPER_META)?,
896            section(&snap, kind::HNSW_UPPER_POOL)?,
897            section(&snap, kind::HNSW_LISTS_META)?,
898            section(&snap, kind::HNSW_LISTS_POOL)?,
899        )?;
900        Self::finish_load(mem, &snap)
901    }
902
903    /// Checks the stored config against the caller's (structural fields
904    /// must match; tuning fields follow the caller) and adopts the
905    /// snapshot's lineage identity and shard layout. Shared by both load
906    /// paths.
907    fn reconcile_config(snap: &Snapshot<'_>, mut cfg: Config) -> Result<Config, Error> {
908        let stored = Config::decode(snap.config())?;
909        if stored.dim != cfg.dim {
910            return Err(Error::ConfigMismatch("stored dim differs"));
911        }
912        // The shard counts are how this file is laid out, not something the
913        // caller gets a say in: the loader needs the stored ones to read the
914        // arena metadata at all, and the caller's are irrelevant to that. So
915        // they are adopted rather than compared — the same treatment `db_uuid`
916        // gets below, and what lets a database re-shard itself without every
917        // caller having to learn the new numbers. `Config::decode` has already
918        // bounded them by `MAX_SHARDS`, which is the only check that matters
919        // here: these become allocation sizes.
920        ShardLayout::of_config(&stored).apply(&mut cfg);
921        if stored.max_bytes != cfg.max_bytes
922            || stored.max_text != cfg.max_text
923            || stored.max_blob != cfg.max_blob
924        {
925            return Err(Error::ConfigMismatch("stored size limits differ"));
926        }
927        // The lineage identity is the snapshot's, not the caller's: a
928        // caller passing 0 adopts the stored uuid; a nonzero caller value
929        // is an assertion "this must be that database" and must match.
930        if cfg.db_uuid != 0 && stored.db_uuid != cfg.db_uuid {
931            return Err(Error::ConfigMismatch("stored db_uuid differs"));
932        }
933        cfg.db_uuid = stored.db_uuid;
934        Ok(cfg)
935    }
936
937    /// Finishes a load once every section is in place: reads the id
938    /// counters, checks they cover the record counts, and range-validates
939    /// references. Deliberately does **not** scan the large byte pools —
940    /// stored-text UTF-8 and the vector fact↔slot bijection are deferred to
941    /// [`Memory::verify`], so an overlay/read-only open faults
942    /// in only the metadata, not the text or vector pools. The accessors stay
943    /// panic-free on any bytes regardless (checked `from_utf8`, bounds-checked
944    /// vector reads). Shared by both load paths.
945    fn finish_load(mut mem: Self, snap: &Snapshot<'_>) -> Result<Self, Error> {
946        let state = migrations::decode_engine_state(section(snap, kind::ENGINE_STATE)?)?;
947        mem.next_fact = state.next_fact;
948        mem.next_entity = state.next_entity;
949        mem.bm25_tokenizer_version = state.bm25_tokenizer_version;
950        mem.next_edge = state.next_edge;
951        // Current-format edge sections were absent: either the image predates
952        // the time-ordered layout, or it has no edges at all.
953        let cfg = mem.cfg.clone();
954        if mem.edges_hist_out.is_empty() && mem.edges_out.is_empty() {
955            mem.migrate_edges(snap, &cfg)?;
956        }
957        let derived_next_edge = mem
958            .edges_hist_out
959            .iter()
960            .map(|edge| edge.edge.0)
961            .max()
962            .map(|edge| edge.saturating_add(1))
963            .unwrap_or(0);
964        if mem.next_edge < derived_next_edge {
965            if !state.predates_edge_versions {
966                return Err(Error::Corrupt("engine edge id counter below record count"));
967            }
968            mem.next_edge = derived_next_edge;
969        }
970        if (mem.next_fact as usize) < mem.facts.len()
971            || (mem.next_entity as usize) < mem.entities.len()
972        {
973            return Err(Error::Corrupt("engine id counters below record counts"));
974        }
975        mem.tombstones = mem.facts.iter().filter(|fact| fact.is_tombstone()).count();
976        mem.validate_references()?;
977        Ok(mem)
978    }
979
980    /// Range-checks every stored id so the engine's panicking accessors
981    /// are sound on loaded data (module docs). O(records) — the price of
982    /// panic-freedom on hostile input, linear and cache-friendly. Does **not**
983    /// touch the large text or vector byte pools: stored-text UTF-8 and the
984    /// vector fact↔slot bijection are deferred to [`Memory::verify`]
985    /// so an overlay/read-only open faults in only the
986    /// metadata. The accessors that read those pools are panic-free on any
987    /// bytes on their own (checked `from_utf8`, bounds-checked slot reads).
988    fn validate_references(&self) -> Result<(), Error> {
989        let texts = self.texts.len() as u32;
990        let terms = self.terms.len() as u32;
991        // The HNSW graph is validated against the pool length it indexes
992        // (owned level0 + small upper lists; it does not read vector slots),
993        // so this stays cheap and eager.
994        self.hnsw.validate(&self.vecs)?;
995        for fact in self.facts.iter() {
996            if fact.id.0 >= self.next_fact
997                || fact.text.0 >= texts
998                || (fact.entity.0 != NONE_U32 && fact.entity.0 >= self.next_entity)
999                || (fact.revises.0 != NONE_U32 && fact.revises.0 >= self.next_fact)
1000                || fact.kind != 0
1001            {
1002                return Err(Error::Corrupt("fact record references out of range"));
1003            }
1004            // The has-vector bijection touches the vector pool and is deferred
1005            // to `verify()`; the cheap direction stays — a fact without the
1006            // flag must carry no slot.
1007            if !fact.has_vector() && fact.vector != NONE_U32 {
1008                return Err(Error::Corrupt("fact without a vector flag carries a slot"));
1009            }
1010        }
1011        let metas = self.metas.len() as u32;
1012        let mut visited = alloc::vec![false; self.tag_lists.chunks()];
1013        for aux in self.fact_aux.iter() {
1014            if aux.id.0 >= self.next_fact || (aux.meta.0 != NONE_U32 && aux.meta.0 >= metas) {
1015                return Err(Error::Corrupt("aux record references out of range"));
1016            }
1017            self.tag_lists.validate_chain(&aux.tags, &mut visited)?;
1018            for chunk in self.tag_lists.iter(&aux.tags) {
1019                if !chunk.len().is_multiple_of(4) {
1020                    return Err(Error::Corrupt("tag list is not a term-id sequence"));
1021                }
1022                for raw in chunk.chunks_exact(4) {
1023                    if u32::from_be_bytes(raw.try_into().unwrap()) >= terms {
1024                        return Err(Error::Corrupt("tag term out of range"));
1025                    }
1026                }
1027            }
1028        }
1029        if self.tag_lists.orphan_count(&visited) != 0 {
1030            return Err(Error::Corrupt("tag pool has orphan chunks"));
1031        }
1032        for entity in self.entities.iter() {
1033            if entity.id.0 >= self.next_entity
1034                || entity.name.0 >= texts
1035                || entity.name_term.0 >= terms
1036            {
1037                return Err(Error::Corrupt("entity record references out of range"));
1038            }
1039        }
1040        for by_name in self.by_name.iter() {
1041            if by_name.name_term.0 >= terms || !self.entities.contains(&by_name.id.0.to_be_bytes())
1042            {
1043                return Err(Error::Corrupt("by-name record references out of range"));
1044            }
1045        }
1046        // Edges are range-checked, and only range-checked. Whether the two
1047        // mirrors agree, and whether an open version is reachable as a current
1048        // edge, are *consistency* properties: nothing an accessor indexes with
1049        // depends on them, so a disagreement makes the graph wrong rather than
1050        // unsafe. They cost a random lookup per edge, which on a
1051        // million-record graph is most of an open, so they are checked by
1052        // [`Memory::verify`] instead — with the rest of the deferred half.
1053        for arena in [&self.edges_out, &self.edges_in] {
1054            for edge in arena.iter() {
1055                if edge.a.0 >= self.next_entity
1056                    || edge.b.0 >= self.next_entity
1057                    || edge.rel.0 >= terms
1058                    || edge.edge.0 >= self.next_edge
1059                    || (edge.fact.0 != NONE_U32 && edge.fact.0 >= self.next_fact)
1060                {
1061                    return Err(Error::Corrupt("edge record references out of range"));
1062                }
1063            }
1064        }
1065        if self.edges_out.len() != self.edges_in.len()
1066            || self.edges_hist_out.len() != self.edges_hist_in.len()
1067        {
1068            return Err(Error::Corrupt("edge mirrors disagree"));
1069        }
1070        for edge in self.edges_hist_out.iter() {
1071            if edge.a.0 >= self.next_entity
1072                || edge.b.0 >= self.next_entity
1073                || edge.edge.0 >= self.next_edge
1074                || edge.rel.0 >= terms
1075                || edge.kind != 0
1076                || edge.valid_from > edge.valid_to
1077                || (edge.fact.0 != NONE_U32 && edge.fact.0 >= self.next_fact)
1078            {
1079                return Err(Error::Corrupt("edge history references out of range"));
1080            }
1081        }
1082        for slot in self.temporal.iter() {
1083            if slot.fact.0 >= self.next_fact {
1084                return Err(Error::Corrupt("temporal record references out of range"));
1085            }
1086        }
1087        Ok(())
1088    }
1089
1090    /// Runs the integrity checks that `open` **defers** for speed and memory
1091    /// — the on-demand equivalent of SQLite's `integrity_check`.
1092    ///
1093    /// A load (owned, overlay or read-only) validates only what an accessor
1094    /// could be unsafe without: every stored id is in range, so nothing can
1095    /// index past its structure. Two further classes are left to this method.
1096    ///
1097    /// The large byte pools stay untouched at open, so an mmap'd base faults
1098    /// in only what it must; here every stored text is confirmed valid UTF-8,
1099    /// every metadata blob confirmed well-formed, and facts flagged with a
1100    /// vector confirmed to map one-to-one onto pool slots that name them back.
1101    ///
1102    /// The graph's *consistency* is checked here too: that the two edge
1103    /// mirrors hold the same edges, that a current edge agrees with its open
1104    /// history version, and that every open version is reachable as a current
1105    /// edge. Those are cross-references between structures, not bounds — each
1106    /// costs a random lookup per edge, which on a million-record graph is most
1107    /// of the cost of opening the database, and being wrong about them makes
1108    /// recall return a wrong graph rather than makes anything unsafe.
1109    ///
1110    /// Skipping it is safe: the accessors that read these pools tolerate bad
1111    /// bytes on their own (invalid text hides the fact, vector reads are
1112    /// bounds-checked, an edge naming an unknown entity is skipped when
1113    /// rendered), so a corrupt image never panics — `verify` only turns that
1114    /// latent corruption into an explicit [`Error::Corrupt`].
1115    ///
1116    /// # Errors
1117    ///
1118    /// [`Error::Corrupt`] for the first inconsistency found.
1119    pub fn verify(&self) -> Result<(), Error> {
1120        self.verify_graph()?;
1121        // Text: every stored blob is valid UTF-8. Accessors already tolerate
1122        // invalid text gracefully; this is the eager confirmation.
1123        for (_, text) in self.texts.iter() {
1124            if core::str::from_utf8(text).is_err() {
1125                return Err(Error::Corrupt("stored text is not valid UTF-8"));
1126            }
1127        }
1128        // Metadata: every referenced blob decodes to a well-formed key→value
1129        // map (bounds, UTF-8, strictly ascending unique keys). Ranges were
1130        // validated at load; this is the content confirmation, like the text
1131        // pass above.
1132        let mut pairs = Vec::new();
1133        for aux in self.fact_aux.iter() {
1134            if aux.meta.0 != NONE_U32 {
1135                crate::metadata::decode(self.metas.get(aux.meta), &mut pairs)?;
1136            }
1137        }
1138        // Vectors: structural self-check, then the fact↔slot bijection (each
1139        // HAS_VECTOR fact points at a slot that names it back; no slot is
1140        // orphaned).
1141        self.vecs.validate()?;
1142        let vslots = self.vecs.len() as u32;
1143        let mut with_vec = 0u32;
1144        for fact in self.facts.iter() {
1145            if fact.has_vector() {
1146                if fact.vector >= vslots || self.vecs.slot_fact(fact.vector as usize) != fact.id.0 {
1147                    return Err(Error::Corrupt(
1148                        "fact vector slot is out of range or mismatched",
1149                    ));
1150                }
1151                with_vec += 1;
1152            }
1153        }
1154        if with_vec != vslots {
1155            return Err(Error::Corrupt("vector pool has orphan slots"));
1156        }
1157        Ok(())
1158    }
1159
1160    /// The graph half of [`Memory::verify`]: cross-references between the four
1161    /// edge structures, each a random lookup per edge.
1162    fn verify_graph(&self) -> Result<(), Error> {
1163        for arena in [&self.edges_out, &self.edges_in] {
1164            for edge in arena.iter() {
1165                if !self.entities.contains(&edge.a.0.to_be_bytes())
1166                    || !self.entities.contains(&edge.b.0.to_be_bytes())
1167                {
1168                    return Err(Error::Corrupt("edge names an entity that does not exist"));
1169                }
1170            }
1171        }
1172        for edge in self.edges_out.iter() {
1173            if !self.edges_in.contains(&edge_key(edge.b, edge.rel, edge.a)) {
1174                return Err(Error::Corrupt("edge mirrors disagree"));
1175            }
1176            // The current slot names its open version directly, so this is a
1177            // point lookup rather than a search through the triple's history.
1178            let version = self
1179                .edges_hist_out
1180                .get(&edge_history_key(edge.a, edge.valid_from, edge.edge))
1181                .ok_or(Error::Corrupt("current edge has no history record"))?;
1182            if version.valid_to != VALID_TO_OPEN
1183                || version.rel != edge.rel
1184                || version.b != edge.b
1185                || version.fact != edge.fact
1186            {
1187                return Err(Error::Corrupt("current edge disagrees with its history"));
1188            }
1189        }
1190        for edge in self.edges_hist_out.iter() {
1191            if !self.entities.contains(&edge.a.0.to_be_bytes())
1192                || !self.entities.contains(&edge.b.0.to_be_bytes())
1193            {
1194                return Err(Error::Corrupt(
1195                    "edge history names an entity that does not exist",
1196                ));
1197            }
1198            if !self
1199                .edges_hist_in
1200                .contains(&edge_history_key(edge.b, edge.valid_from, edge.edge))
1201            {
1202                return Err(Error::Corrupt("edge history mirrors disagree"));
1203            }
1204            // An open version must be reachable as a current edge: the two
1205            // structures are one fact stored twice, and recall trusts the
1206            // current graph to be exactly the open versions.
1207            if edge.valid_to == VALID_TO_OPEN
1208                && !self.edges_out.contains(&edge_key(edge.a, edge.rel, edge.b))
1209            {
1210                return Err(Error::Corrupt("open edge version is not a current edge"));
1211            }
1212        }
1213        Ok(())
1214    }
1215
1216    /// Attributes [`Memory::verify`]'s content checks to individual facts — the
1217    /// salvage predicate for `recover`. Walks every live
1218    /// (non-tombstone) fact and returns those whose stored text is not valid
1219    /// UTF-8, that are flagged with a vector whose slot is out of range or does
1220    /// not name the fact back, or whose metadata blob does not decode to a
1221    /// well-formed key→value map. It reads the text, vector and metadata pools
1222    /// (like `verify`), so it residents them; the accessors it uses are
1223    /// panic-free on any bytes. Unlike `verify`, it does not fail on the first
1224    /// problem — it
1225    /// reports each faulty fact so the caller can `forget` it and rebuild a
1226    /// clean image from the survivors.
1227    pub fn faulty_facts(&self) -> Vec<(FactId, FactFault)> {
1228        let vslots = self.vecs.len() as u32;
1229        let metas = self.metas.len() as u32;
1230        let mut pairs = Vec::new();
1231        let mut out = Vec::new();
1232        for i in self.fact_ids_ascending() {
1233            let id = FactId(i);
1234            let Some(record) = self.fact(id) else {
1235                continue; // unknown or tombstoned
1236            };
1237            if record.is_tombstone() {
1238                continue;
1239            }
1240            if core::str::from_utf8(self.texts.get(record.text)).is_err() {
1241                out.push((id, FactFault::Text));
1242                continue;
1243            }
1244            if record.has_vector()
1245                && (record.vector >= vslots || self.vecs.slot_fact(record.vector as usize) != id.0)
1246            {
1247                out.push((id, FactFault::Vector));
1248                continue;
1249            }
1250            // Metadata: a referenced blob that is out of range or does not decode
1251            // to a well-formed key→value map. `metadata_of` hides such a fact's
1252            // metadata gracefully; here it becomes an explicit salvage fault.
1253            if let Some(aux) = self.fact_aux.get(&id.0.to_be_bytes())
1254                && aux.meta.0 != NONE_U32
1255                && (aux.meta.0 >= metas
1256                    || crate::metadata::decode(self.metas.get(aux.meta), &mut pairs).is_err())
1257            {
1258                out.push((id, FactFault::Metadata));
1259            }
1260        }
1261        out
1262    }
1263}