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