Skip to main content

core_storage/v8/
encode.rs

1//! `encode_v8` — write a V8 mmap-able snapshot, and helpers to reconstruct
2//! owned types from the archived sections.
3//!
4//! Wire format (all integers LE):
5//!
6//! ```text
7//! [0..4]       MAGIC "GDB1"
8//! [4..6]       VERSION = 9 (u16 LE); V8 wrote 8 into the same container
9//! [6..8]       section_count (u16 LE)
10//! [8..8+16*N]  directory: {id:u8, _pad:[u8;3], offset:u32, len:u32, crc32:u32} * N
11//! [8+16*N..+4] whole-header crc32 (over bytes 0..8+16*N)
12//! [..4096]     zero padding to complete the header page
13//! sections at 8-byte aligned offsets from file start (>= 4096)
14//! ```
15//!
16//! Section ids (T5 layout):
17//!   0 = CSR topology (rkyv)
18//!   1 = columns (rkyv)
19//!   2 = id map (rkyv)
20//!   3 = interner (rkyv)
21//!   4 = META (bincode V8Meta — only labels + wal_truncated; large fields have own sections)
22//!   5 = EDGE_PROPS (rkyv; overlay merged with base at snapshot time)
23//!   6 = HNSW (rkyv blobs)
24//!   7 = PROVENANCE (rkyv; retained undecoded at open)
25//!   8 = RULES_META (rkyv)
26//!   9 = VIEWS (rkyv)
27//!  10 = IVF_STATE (bincode BTreeMap<String,PerRuleIvfState>; retained undecoded at open)
28//!  11 = LAST_CHANGE (bincode HashMap<u32,u64>)
29//!  12 = STRINGS (rkyv StringTableData — the one table every Str column indexes; V9 only)
30
31use crate::columns::ColumnStore;
32use crate::edge_props::EdgeProps;
33use crate::idmap::IdMap;
34use crate::interner::Interner;
35use crate::snapshot::PerRuleIvfState;
36use crate::topology::Topology;
37use crate::types::{GraphError, Result, Value};
38use crate::v8::layout::{
39    ColumnData, ColumnsData, CsrAdjMap, CsrData, CsrEtype, CsrRow, EdgePropEntry, EdgePropsData,
40    FieldEntry, HnswRuleEntry, HnswSectionData, IdMapData, InternerData, ProvenanceEntry,
41    ProvenanceSectionData, RuleFireEntry, RuleTripEntry, RulesMetaData, StringTableData, Triple,
42    ViewsSectionData,
43};
44use crate::v8::{
45    HEADER_SIZE, SECTION_COLUMNS, SECTION_EDGE_PROPS, SECTION_HNSW, SECTION_IDS, SECTION_IVF_STATE,
46    SECTION_LAST_CHANGE, SECTION_META, SECTION_PROVENANCE, SECTION_RULES_META, SECTION_STRINGS,
47    SECTION_SYMS, SECTION_TOPOLOGY, SECTION_VIEWS,
48};
49use serde::{Deserialize, Serialize};
50use std::collections::{BTreeMap, BTreeSet, HashMap};
51use std::io::Write;
52
53// ---------------------------------------------------------------------------
54// V8Meta — bincode-serialized section 4
55// ---------------------------------------------------------------------------
56
57/// Metadata serialized as bincode into section 4.
58///
59/// T5 lean layout: only `labels` and `wal_truncated` live here.
60/// All large fields (edge_props, provenance, hnsw, rule_defs, rule_tripped,
61/// rule_fires, view_defs) have dedicated rkyv sections (5–9) and are encoded
62/// directly from the corresponding fields in `encode_v8`.  `ivf_state` is in
63/// section 10.  The `edge_props`, `hnsw`, `rule_defs`, `rule_tripped`,
64/// `rule_fires`, `view_defs`, and `provenance` fields below are kept in
65/// `V8Meta` solely as a convenient carrier passed to `encode_v8` so callers
66/// don't need to pass a dozen separate arguments; they are NOT written to the
67/// bincode META section (marked `#[serde(skip)]`).
68#[derive(Serialize, Deserialize, Default)]
69pub struct V8Meta {
70    /// Node-id → label-symbol mapping.
71    pub labels: Vec<u32>,
72    /// Snapshot-truncated-WAL flag.
73    pub wal_truncated: bool,
74    // -- Fields below are encode-path only; NOT in the bincode META section. --
75    #[serde(skip)]
76    pub edge_props: EdgeProps,
77    #[serde(skip)]
78    pub rule_defs: Vec<Vec<u8>>,
79    #[serde(skip)]
80    pub provenance: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
81    #[serde(skip)]
82    pub rule_tripped: BTreeMap<String, bool>,
83    #[serde(skip)]
84    pub rule_fires: BTreeMap<String, u64>,
85    #[serde(skip)]
86    pub view_defs: Vec<Vec<u8>>,
87    #[serde(skip)]
88    pub hnsw: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
89    /// Raw bincode bytes of `BTreeMap<String, PerRuleIvfState>`.
90    /// Written as section 10.  Retained undecoded at open for lazy init.
91    /// An empty Vec means no approximate rules exist.
92    #[serde(skip)]
93    pub ivf_bytes: Vec<u8>,
94    /// Per-node last-change commit sequence map: node_id → commit_seq.
95    /// Written as section 11 (bincode `HashMap<u32, u64>`).
96    /// Empty for legacy or freshly-opened stores with no mutations.
97    #[serde(skip)]
98    pub last_change: HashMap<u32, u64>,
99}
100
101// ---------------------------------------------------------------------------
102// encode_v8
103// ---------------------------------------------------------------------------
104
105/// Encode an in-memory graph state as a V8 snapshot, writing to `out`.
106///
107/// `base_topo` is the archived CSR from an existing mmap'd V8 snapshot.
108/// When `Some`, the encoded topology section is the sorted-unique merge of the
109/// base CSR and the overlay topology (with tombstone subtraction applied).
110/// When `None`, the overlay topology is encoded standalone (initial snapshot,
111/// no prior base).
112///
113/// `base_cols` is the archived columns section from the same mmap'd V8 snapshot.
114/// When `Some`, the encoded columns section is the merge of the base columns
115/// (minus prop tombstones recorded in `cols.prop_tombstones`) with the overlay
116/// `cols`.  When `None`, `cols` is encoded directly (initial snapshot or
117/// V5–V7 legacy path).
118///
119/// `base_strings` is the shared string table (section 12) of that same
120/// snapshot, or `None` when the base predates it (V8 and earlier) and every
121/// base string column therefore carries its own copy.  It is only read when
122/// `base_cols` is `Some`; passing a table from a different snapshot would
123/// resolve the base's string ids against the wrong vocabulary.
124///
125/// `base_edge_props` controls section 5 (EDGE_PROPS):
126///   - `None`: encode from `meta.edge_props` overlay alone (initial snapshot or V5-V7 path).
127///   - `Some((archived, raw))` with `meta.edge_props.is_clean()`: passthrough `raw` bytes
128///     byte-identical (no decode + re-encode of the 500 MiB section).
129///   - `Some((archived, _))` with dirty overlay: merge archived base + overlay.
130///
131/// `base_provenance_raw` controls section 7 (PROVENANCE):
132///   - `Some(raw)` and provenance unchanged: passthrough raw bytes.
133///   - Otherwise: encode from `meta.provenance`.
134#[allow(clippy::too_many_arguments)]
135pub fn encode_v8<W: Write>(
136    base_topo: Option<&crate::v8::layout::ArchivedCsr>,
137    base_cols: Option<&crate::v8::layout::ArchivedColumns>,
138    base_strings: Option<&crate::v8::layout::ArchivedStringTable>,
139    base_edge_props: Option<(&crate::v8::layout::ArchivedEdgeProps, &[u8])>,
140    base_provenance_raw: Option<&[u8]>,
141    topo: &Topology,
142    cols: &ColumnStore,
143    ids: &IdMap,
144    syms: &Interner,
145    meta: &V8Meta,
146    out: &mut W,
147) -> Result<()> {
148    // 1. Serialise each section to bytes.
149    let topo_bytes = match base_topo {
150        Some(archived_csr) => rkyv_encode(&topology_merge_to_csr(archived_csr, topo))?,
151        None => rkyv_encode(&topology_to_csr(topo))?,
152    };
153
154    // Sections 1 and 12 are produced together: the columns hold string ids, the
155    // one shared table holds the vocabulary they index.
156    let (cols_data, strings_data) = match base_cols {
157        Some(archived) => columns_merge_to_data(archived, base_strings, cols)?,
158        None => columnstore_to_data(cols)?,
159    };
160    let cols_bytes = rkyv_encode(&cols_data)?;
161    let strings_bytes = rkyv_encode(&strings_data)?;
162    drop(cols_data);
163    drop(strings_data);
164    let ids_bytes = rkyv_encode(&idmap_to_data(ids))?;
165    let syms_bytes = rkyv_encode(&interner_to_data(syms))?;
166    let meta_bytes = bincode::serialize(meta).map_err(|e| GraphError::Corrupt {
167        detail: format!("v8: meta bincode serialize: {e}"),
168    })?;
169
170    // Section 5: EDGE_PROPS — passthrough, merge, or fresh encode.
171    let edge_props_bytes_owned: Vec<u8>;
172    let edge_props_bytes: &[u8] = match base_edge_props {
173        Some((_archived, raw)) if meta.edge_props.is_clean() => {
174            // No overlay changes → passthrough base section bytes byte-identical.
175            raw
176        }
177        Some((archived, _)) => {
178            // Overlay has changes or tombstones → merge base + overlay.
179            edge_props_bytes_owned =
180                rkyv_encode(&edge_props_merge_to_data(archived, &meta.edge_props))?;
181            &edge_props_bytes_owned
182        }
183        None => {
184            // No base (initial snapshot or V5–V7 migration path).
185            edge_props_bytes_owned = rkyv_encode(&edge_props_to_data(&meta.edge_props))?;
186            &edge_props_bytes_owned
187        }
188    };
189
190    let hnsw_bytes = rkyv_encode(&hnsw_to_data(&meta.hnsw))?;
191
192    // Section 7: PROVENANCE — passthrough or fresh encode.
193    let prov_bytes_owned: Vec<u8>;
194    let prov_bytes: &[u8] = match base_provenance_raw {
195        Some(raw) if meta.provenance.is_empty() => {
196            // Provenance was never loaded/changed → passthrough base bytes.
197            // A truly empty provenance (no rules) also passes through correctly.
198            raw
199        }
200        _ => {
201            prov_bytes_owned = rkyv_encode(&provenance_to_data(&meta.provenance))?;
202            &prov_bytes_owned
203        }
204    };
205
206    let rules_meta_bytes = rkyv_encode(&rules_meta_to_data(
207        &meta.rule_defs,
208        &meta.rule_tripped,
209        &meta.rule_fires,
210    ))?;
211    let views_bytes = rkyv_encode(&ViewsSectionData {
212        view_defs: meta.view_defs.clone(),
213    })?;
214    // Section 10: IVF_STATE — raw bincode bytes (may be empty for stores with no
215    // approximate rules; empty bytes decode as an empty map).
216    let ivf_bytes: &[u8] = &meta.ivf_bytes;
217
218    // Section 11: LAST_CHANGE — bincode HashMap<u32, u64> (node_id → commit_seq).
219    // Always encoded from the live map (small section; not passed through from base).
220    let last_change_bytes_owned: Vec<u8> =
221        bincode::serialize(&meta.last_change).map_err(|e| GraphError::Corrupt {
222            detail: format!("v8: last_change bincode serialize: {e}"),
223        })?;
224    let last_change_bytes: &[u8] = &last_change_bytes_owned;
225
226    let sections: &[(u8, &[u8])] = &[
227        (SECTION_TOPOLOGY, &topo_bytes),
228        (SECTION_COLUMNS, &cols_bytes),
229        // Next to the columns it belongs to, not appended at the end: the last
230        // section in the file is deliberately a small, eagerly-CRC'd one so a
231        // flipped trailing byte is still caught at open.
232        (SECTION_STRINGS, &strings_bytes),
233        (SECTION_IDS, &ids_bytes),
234        (SECTION_SYMS, &syms_bytes),
235        (SECTION_META, &meta_bytes),
236        (SECTION_EDGE_PROPS, edge_props_bytes),
237        (SECTION_HNSW, &hnsw_bytes),
238        (SECTION_PROVENANCE, prov_bytes),
239        (SECTION_RULES_META, &rules_meta_bytes),
240        (SECTION_VIEWS, &views_bytes),
241        (SECTION_IVF_STATE, ivf_bytes),
242        (SECTION_LAST_CHANGE, last_change_bytes),
243    ];
244    let n = sections.len();
245
246    // 2. Compute section offsets (sections start after the 4 KB header page).
247    let mut offsets = Vec::with_capacity(n);
248    let mut cur: u64 = HEADER_SIZE as u64;
249    for (_, bytes) in sections {
250        offsets.push(cur);
251        cur = align8(cur + bytes.len() as u64);
252    }
253
254    // Verify that all offsets and lengths fit in u32.
255    for (i, ((_, bytes), &offset)) in sections.iter().zip(offsets.iter()).enumerate() {
256        if offset > u32::MAX as u64 {
257            return Err(GraphError::Corrupt {
258                detail: format!("v8: section {i} offset {offset} exceeds u32"),
259            });
260        }
261        if bytes.len() > u32::MAX as usize {
262            return Err(GraphError::Corrupt {
263                detail: format!("v8: section {i} length {} exceeds u32", bytes.len()),
264            });
265        }
266    }
267
268    // 3. Build the 4 KB header page.
269    let mut header = vec![0u8; HEADER_SIZE];
270    header[0..4].copy_from_slice(b"GDB1");
271    header[4..6].copy_from_slice(&crate::snapshot::VERSION_9.to_le_bytes());
272    header[6..8].copy_from_slice(&(n as u16).to_le_bytes());
273
274    let mut pos = 8usize;
275    for ((section_id, bytes), &offset) in sections.iter().zip(offsets.iter()) {
276        let crc32 = crc32fast::hash(bytes);
277        header[pos] = *section_id;
278        header[pos + 1] = 0;
279        header[pos + 2] = 0;
280        header[pos + 3] = 0;
281        header[pos + 4..pos + 8].copy_from_slice(&(offset as u32).to_le_bytes());
282        header[pos + 8..pos + 12].copy_from_slice(&(bytes.len() as u32).to_le_bytes());
283        header[pos + 12..pos + 16].copy_from_slice(&crc32.to_le_bytes());
284        pos += 16;
285    }
286    // Whole-header CRC over magic..last directory entry.
287    let header_crc = crc32fast::hash(&header[0..pos]);
288    header[pos..pos + 4].copy_from_slice(&header_crc.to_le_bytes());
289    // Remaining bytes are already zero.
290
291    out.write_all(&header).map_err(GraphError::Io)?;
292
293    // 4. Write sections with 8-byte alignment padding between sections.
294    // Exactly what is CRC-covered:
295    //   • The 4 KB header page bytes [0 .. dir_end] are covered by the
296    //     whole-header CRC32 stored at header[dir_end..dir_end+4].
297    //   • Each section payload [offset .. offset+len] is covered by the
298    //     per-section CRC32 in its directory entry.
299    //   • The zero-pad bytes written between sections for 8-byte alignment
300    //     are NOT part of any section's [offset+len] range and are therefore
301    //     NOT covered by any CRC; they are always zero by construction.
302    //   • The last section is NOT padded: the file ends exactly at the last
303    //     section's final byte so there are no unchecked trailing bytes.
304    let zero_pad = [0u8; 8];
305    let last_idx = sections.len().saturating_sub(1);
306    for (i, ((_, bytes), &offset)) in sections.iter().zip(offsets.iter()).enumerate() {
307        out.write_all(bytes).map_err(GraphError::Io)?;
308        if i < last_idx {
309            let end = offset + bytes.len() as u64;
310            let next = align8(end);
311            let pad = (next - end) as usize;
312            if pad > 0 {
313                out.write_all(&zero_pad[..pad]).map_err(GraphError::Io)?;
314            }
315        }
316    }
317
318    Ok(())
319}
320
321fn align8(n: u64) -> u64 {
322    (n + 7) & !7
323}
324
325// ---------------------------------------------------------------------------
326// rkyv serialization helper
327// ---------------------------------------------------------------------------
328
329fn rkyv_encode<T>(value: &T) -> Result<Vec<u8>>
330where
331    T: for<'a> rkyv::Serialize<
332        rkyv::api::high::HighSerializer<
333            rkyv::util::AlignedVec,
334            rkyv::ser::allocator::ArenaHandle<'a>,
335            rkyv::rancor::Error,
336        >,
337    >,
338{
339    rkyv::api::high::to_bytes::<rkyv::rancor::Error>(value)
340        .map(|av| av.to_vec())
341        .map_err(|e| GraphError::Corrupt {
342            detail: format!("v8: rkyv encode: {e}"),
343        })
344}
345
346// ---------------------------------------------------------------------------
347// Build rkyv types from owned graph types
348// ---------------------------------------------------------------------------
349
350/// Build a `CsrData` directly from a `Topology` without going through the
351/// pack-format intermediate.  Replaces the previous `topology_to_csr` →
352/// `topology_from_pack` round-trip (pack-coupling fix, Task 3).
353fn topology_to_csr(topo: &Topology) -> CsrData {
354    let mut etype_ids: Vec<u32> = topo.by_type.keys().copied().collect();
355    etype_ids.sort_unstable();
356
357    let etypes = etype_ids
358        .into_iter()
359        .map(|et| {
360            let adj = &topo.by_type[&et];
361            CsrEtype {
362                etype: et,
363                out_adj: adj_map_to_csr(&adj.out),
364                in_adj: adj_map_to_csr(&adj.inn),
365            }
366        })
367        .collect();
368
369    CsrData {
370        etypes,
371        edge_count: topo.edge_count(),
372    }
373}
374
375/// Build a `CsrAdjMap` from a `HashMap<u32, AdjList>`.
376/// Rows are sorted by vertex ascending so binary search in the archived form works.
377fn adj_map_to_csr(adj: &std::collections::HashMap<u32, crate::topology::AdjList>) -> CsrAdjMap {
378    let mut vertices: Vec<u32> = adj.keys().copied().collect();
379    vertices.sort_unstable();
380
381    let rows = vertices
382        .into_iter()
383        .filter_map(|v| {
384            let al = &adj[&v];
385            let neighbors = al.merged().into_owned();
386            if neighbors.is_empty() {
387                None
388            } else {
389                Some(CsrRow {
390                    vertex: v,
391                    neighbors,
392                })
393            }
394        })
395        .collect();
396
397    CsrAdjMap { rows }
398}
399
400/// Build a merged `CsrData` from an archived base CSR and an overlay `Topology`.
401///
402/// The merged result contains:
403///   - all base edges minus tombstoned edges (recorded via `overlay.remove_edge`),
404///   - plus all overlay edges.
405///
406/// This is the encode path for `snapshot()` when a non-None base is present.
407fn topology_merge_to_csr(base: &crate::v8::layout::ArchivedCsr, overlay: &Topology) -> CsrData {
408    use std::collections::BTreeSet;
409
410    // Collect all etype ids from both base and overlay.
411    let mut etype_set: BTreeSet<u32> = overlay.by_type.keys().copied().collect();
412    for et in base.etypes.iter() {
413        etype_set.insert(u32::from(et.etype));
414    }
415
416    let etypes: Vec<CsrEtype> = etype_set
417        .into_iter()
418        .map(|et| {
419            let overlay_adj = overlay.by_type.get(&et);
420            let base_entry = base
421                .etypes
422                .binary_search_by_key(&et, |e| u32::from(e.etype))
423                .ok()
424                .map(|i| &base.etypes[i]);
425
426            let out_tombstones = overlay.out_tombstones.get(&et);
427            let in_tombstones = overlay.in_tombstones.get(&et);
428
429            let out_adj = merge_adj_map(
430                overlay_adj.map(|a| &a.out),
431                base_entry.map(|e| &e.out_adj),
432                out_tombstones,
433            );
434            let in_adj = merge_adj_map(
435                overlay_adj.map(|a| &a.inn),
436                base_entry.map(|e| &e.in_adj),
437                in_tombstones,
438            );
439
440            CsrEtype {
441                etype: et,
442                out_adj,
443                in_adj,
444            }
445        })
446        .collect();
447
448    // Merged edge count = base - tombstones + overlay.
449    let base_count = u64::from(base.edge_count);
450    let overlay_count = overlay.edge_count();
451    // Count unique tombstones (out-direction is the canonical set).
452    let tombstone_count: u64 = overlay
453        .out_tombstones
454        .values()
455        .flat_map(|m| m.values())
456        .map(|s| s.len() as u64)
457        .sum();
458    let edge_count = base_count.saturating_sub(tombstone_count) + overlay_count;
459
460    CsrData { etypes, edge_count }
461}
462
463/// Merge one direction's adjacency maps from overlay and base, applying tombstones.
464///
465/// `overlay_adj`: The overlay's `HashMap<u32, AdjList>` for one direction (may be None).
466/// `base_adj`:    The archived base `CsrAdjMap` for the same direction (may be None).
467/// `tombstones`:  Per-src tombstone sets for this etype+direction (may be None).
468fn merge_adj_map(
469    overlay_adj: Option<&std::collections::HashMap<u32, crate::topology::AdjList>>,
470    base_adj: Option<&crate::v8::layout::ArchivedCsrAdjMap>,
471    tombstones: Option<&std::collections::HashMap<u32, std::collections::BTreeSet<u32>>>,
472) -> CsrAdjMap {
473    use std::collections::BTreeSet;
474
475    let mut vertex_set: BTreeSet<u32> = BTreeSet::new();
476    if let Some(o) = overlay_adj {
477        vertex_set.extend(o.keys().copied());
478    }
479    if let Some(b) = base_adj {
480        for row in b.rows.iter() {
481            vertex_set.insert(u32::from(row.vertex));
482        }
483    }
484
485    let rows: Vec<CsrRow> = vertex_set
486        .into_iter()
487        .filter_map(|v| {
488            let overlay_nbrs: Vec<u32> = overlay_adj
489                .and_then(|o| o.get(&v))
490                .map(|al| al.merged().into_owned())
491                .unwrap_or_default();
492
493            let base_nbrs: Vec<u32> = base_adj
494                .and_then(|b| {
495                    b.rows
496                        .binary_search_by_key(&v, |r| u32::from(r.vertex))
497                        .ok()
498                        .map(|i| &b.rows[i])
499                })
500                .map(|row| row.neighbors.iter().map(|n| u32::from(*n)).collect())
501                .unwrap_or_default();
502
503            // Apply tombstones: remove tombstoned endpoints from base neighbors.
504            let filtered_base_nbrs: Vec<u32> = match tombstones.and_then(|t| t.get(&v)) {
505                None => base_nbrs,
506                Some(t) => base_nbrs.into_iter().filter(|n| !t.contains(n)).collect(),
507            };
508
509            // Merge overlay + filtered base (both sorted-unique).
510            let merged = merge_sorted_unique_vecs(&overlay_nbrs, &filtered_base_nbrs);
511            if merged.is_empty() {
512                None
513            } else {
514                Some(CsrRow {
515                    vertex: v,
516                    neighbors: merged,
517                })
518            }
519        })
520        .collect();
521
522    CsrAdjMap { rows }
523}
524
525/// Merge two sorted-unique `Vec<u32>` slices into a sorted-unique `Vec<u32>`.
526fn merge_sorted_unique_vecs(a: &[u32], b: &[u32]) -> Vec<u32> {
527    let mut out = Vec::with_capacity(a.len() + b.len());
528    let mut ai = 0;
529    let mut bi = 0;
530    while ai < a.len() && bi < b.len() {
531        match a[ai].cmp(&b[bi]) {
532            std::cmp::Ordering::Less => {
533                out.push(a[ai]);
534                ai += 1;
535            }
536            std::cmp::Ordering::Greater => {
537                out.push(b[bi]);
538                bi += 1;
539            }
540            std::cmp::Ordering::Equal => {
541                out.push(a[ai]);
542                ai += 1;
543                bi += 1;
544            }
545        }
546    }
547    out.extend_from_slice(&a[ai..]);
548    out.extend_from_slice(&b[bi..]);
549    out
550}
551
552/// Merge a V8 base columns section with an in-memory overlay, producing the
553/// `ColumnsData` for the next snapshot.
554///
555/// Algorithm:
556/// 1. Materialize the archived base into an owned `ColumnStore`.
557/// 2. Apply prop tombstones recorded in `overlay.prop_tombstones` (base-only
558///    values that were subsequently deleted via the WAL).
559/// 3. Apply all overlay values (last writer wins per field/node).
560/// 4. Encode the merged store via `columnstore_to_data`.
561///
562/// Called at V8 snapshot merge time.  The O(base) materialization cost is
563/// acceptable here (merge is periodic) — what we avoid is the same cost at
564/// every V8 open (the main C1 win).
565fn columns_merge_to_data(
566    base: &crate::v8::layout::ArchivedColumns,
567    base_strings: Option<&crate::v8::layout::ArchivedStringTable>,
568    overlay: &ColumnStore,
569) -> Result<(ColumnsData, StringTableData)> {
570    // 1. Materialise base.  `base_strings` is `None` for a pre-V9 base, whose
571    //    columns still carry their own tables — that is the migration path.
572    let mut merged = archived_to_columnstore(base, base_strings);
573    // 2. Apply tombstones (base-only props deleted since last snapshot).
574    for (node, fields) in &overlay.prop_tombstones {
575        for field in fields {
576            merged.remove(*node, field);
577        }
578    }
579    // 3. Apply overlay values via to_wire() which gives us the full (field, node, value) map.
580    for (field_name, node_map) in overlay.to_wire() {
581        for (node, value) in node_map {
582            merged.set(node, &field_name, value);
583        }
584    }
585    // 4. Encode.
586    columnstore_to_data(&merged)
587}
588
589/// Pack a `ColumnStore` into the archived column layout plus the one string
590/// table every `Str` column in it indexes.  The two are always written as a
591/// pair (columns section 1, strings section 12) and are only meaningful
592/// together: the columns carry ids, the table carries the vocabulary.
593fn columnstore_to_data(store: &ColumnStore) -> Result<(ColumnsData, StringTableData)> {
594    let mut buf = Vec::new();
595    store.pack(&mut buf);
596    let (mut data, strings) = decode_all_columns(&buf)?;
597    // Post-process: promote Mixed columns that are pure all-float lists of equal
598    // dimension to ColumnData::Vector for zero-copy raw-f64 access (B2).
599    for field in data.fields.iter_mut() {
600        if let ColumnData::Mixed(ref blob) = field.col {
601            let map: HashMap<u32, Value> =
602                bincode::deserialize(blob.as_slice()).unwrap_or_default();
603            if let Some(vec_col) = try_promote_to_vector(&map) {
604                field.col = vec_col;
605            }
606        }
607    }
608    Ok((data, strings))
609}
610
611/// Try to promote a Mixed column map to `ColumnData::Vector` if all values are
612/// `Value::List([Value::Float, ...])` with the same non-zero dimension.
613///
614/// Returns `None` if the column cannot be promoted (empty map, mixed types,
615/// non-float list elements, or inconsistent dimension).
616fn try_promote_to_vector(map: &HashMap<u32, Value>) -> Option<ColumnData> {
617    if map.is_empty() {
618        return None;
619    }
620    // Determine dimension from the first entry.
621    let dim = map.values().next().and_then(|v| match v {
622        Value::List(items) => {
623            if items.iter().all(|i| matches!(i, Value::Float(_))) {
624                Some(items.len())
625            } else {
626                None
627            }
628        }
629        _ => None,
630    })?;
631    if dim == 0 {
632        return None;
633    }
634    // Validate all entries: must be float lists of the same dimension.
635    let max_id = *map.keys().max().unwrap_or(&0) as usize;
636    for v in map.values() {
637        match v {
638            Value::List(items)
639                if items.len() == dim && items.iter().all(|i| matches!(i, Value::Float(_))) => {}
640            _ => return None,
641        }
642    }
643    // Build the dense vector array.
644    let n_nodes = max_id + 1;
645    let mut data = vec![0.0f64; n_nodes * dim];
646    let mut present_words = vec![0u64; n_nodes.div_ceil(64)];
647    for (&id, v) in map {
648        let items = match v {
649            Value::List(items) => items,
650            _ => unreachable!(),
651        };
652        let start = id as usize * dim;
653        for (i, item) in items.iter().enumerate() {
654            if let Value::Float(f) = item {
655                data[start + i] = *f;
656            }
657        }
658        let word = id as usize / 64;
659        let bit = id as usize % 64;
660        present_words[word] |= 1u64 << bit;
661    }
662    Some(ColumnData::Vector {
663        dim: dim as u32,
664        data,
665        present: present_words,
666    })
667}
668
669/// Decode the pack format into the archived column layout and the shared
670/// string table.  The pack format has always written the intern table exactly
671/// once; up to V8 every `Str` column got a `clone()` of it, which is the whole
672/// bloat this returns separately instead.
673fn decode_all_columns(buf: &[u8]) -> Result<(ColumnsData, StringTableData)> {
674    use crate::pack::{read_exact, read_f64s, read_i64s, read_str, read_u32, read_u32s};
675    let mut pos = 0usize;
676
677    // StrIntern table: string count, then each string.
678    let n_intern = read_u32(buf, &mut pos).map_err(|_| GraphError::Corrupt {
679        detail: "v8: columns pack: truncated intern-string count".into(),
680    })? as usize;
681    let mut intern_strings: Vec<String> = Vec::with_capacity(n_intern);
682    for i in 0..n_intern {
683        intern_strings.push(read_str(buf, &mut pos).map_err(|_| GraphError::Corrupt {
684            detail: format!("v8: columns pack: truncated intern-string[{i}]"),
685        })?);
686    }
687
688    let n_fields = read_u32(buf, &mut pos).map_err(|_| GraphError::Corrupt {
689        detail: "v8: columns pack: truncated field count".into(),
690    })? as usize;
691    let mut fields = Vec::with_capacity(n_fields);
692
693    for field_idx in 0..n_fields {
694        let fname = read_str(buf, &mut pos).map_err(|_| GraphError::Corrupt {
695            detail: format!("v8: columns pack: truncated field name at index {field_idx}"),
696        })?;
697        let tag = read_exact(buf, &mut pos, 1).map_err(|_| GraphError::Corrupt {
698            detail: format!("v8: column '{fname}' pack decode: truncated tag byte"),
699        })?[0];
700        let col = match tag {
701            0 => {
702                let data = read_i64s(buf, &mut pos).map_err(|_| GraphError::Corrupt {
703                    detail: format!("v8: column '{fname}' pack decode: truncated Int data"),
704                })?;
705                let present = unpack_bitmap(buf, &mut pos).ok_or_else(|| GraphError::Corrupt {
706                    detail: format!("v8: column '{fname}' pack decode: truncated Int bitmap"),
707                })?;
708                ColumnData::Int { data, present }
709            }
710            1 => {
711                let data = read_f64s(buf, &mut pos).map_err(|_| GraphError::Corrupt {
712                    detail: format!("v8: column '{fname}' pack decode: truncated Float data"),
713                })?;
714                let present = unpack_bitmap(buf, &mut pos).ok_or_else(|| GraphError::Corrupt {
715                    detail: format!("v8: column '{fname}' pack decode: truncated Float bitmap"),
716                })?;
717                ColumnData::Float { data, present }
718            }
719            2 => {
720                let n = read_u32(buf, &mut pos).map_err(|_| GraphError::Corrupt {
721                    detail: format!("v8: column '{fname}' pack decode: truncated Bool length"),
722                })? as usize;
723                let raw = read_exact(buf, &mut pos, n)
724                    .map_err(|_| GraphError::Corrupt {
725                        detail: format!("v8: column '{fname}' pack decode: truncated Bool data"),
726                    })?
727                    .to_vec();
728                let present = unpack_bitmap(buf, &mut pos).ok_or_else(|| GraphError::Corrupt {
729                    detail: format!("v8: column '{fname}' pack decode: truncated Bool bitmap"),
730                })?;
731                ColumnData::Bool { data: raw, present }
732            }
733            3 => {
734                let ids = read_u32s(buf, &mut pos).map_err(|_| GraphError::Corrupt {
735                    detail: format!("v8: column '{fname}' pack decode: truncated Str ids"),
736                })?;
737                let present = unpack_bitmap(buf, &mut pos).ok_or_else(|| GraphError::Corrupt {
738                    detail: format!("v8: column '{fname}' pack decode: truncated Str bitmap"),
739                })?;
740                // Empty on purpose: the table lives once in section 12 and the
741                // reader resolves through it.  A pre-V9 reader that finds these
742                // empty is exactly why the format version moves to 9.
743                ColumnData::Str {
744                    ids,
745                    present,
746                    strings: Vec::new(),
747                }
748            }
749            4 => {
750                let blob_len = read_u32(buf, &mut pos).map_err(|_| GraphError::Corrupt {
751                    detail: format!("v8: column '{fname}' pack decode: truncated Mixed length"),
752                })? as usize;
753                let blob = read_exact(buf, &mut pos, blob_len)
754                    .map_err(|_| GraphError::Corrupt {
755                        detail: format!("v8: column '{fname}' pack decode: truncated Mixed data"),
756                    })?
757                    .to_vec();
758                ColumnData::Mixed(blob)
759            }
760            other => {
761                return Err(GraphError::Corrupt {
762                    detail: format!(
763                        "v8: column '{fname}' pack decode: unknown tag byte {other} at field \
764                         index {field_idx}"
765                    ),
766                });
767            }
768        };
769        fields.push(FieldEntry { name: fname, col });
770    }
771
772    fields.sort_by(|a, b| a.name.cmp(&b.name));
773    Ok((
774        ColumnsData { fields },
775        StringTableData {
776            strings: intern_strings,
777        },
778    ))
779}
780
781fn unpack_bitmap(buf: &[u8], pos: &mut usize) -> Option<Vec<u64>> {
782    use crate::pack::{read_exact, read_u32};
783    let n = read_u32(buf, pos).ok()? as usize;
784    let bytes = read_exact(buf, pos, n.saturating_mul(8)).ok()?;
785    Some(
786        bytes
787            .chunks_exact(8)
788            .map(|c| u64::from_le_bytes(c.try_into().unwrap())) // infallible: chunks_exact(8) yields 8-byte slices
789            .collect(),
790    )
791}
792
793fn idmap_to_data(ids: &IdMap) -> IdMapData {
794    let to_key: Vec<String> = ids.all_keys().to_vec();
795    let tombstones: Vec<u32> = ids
796        .all_keys()
797        .iter()
798        .enumerate()
799        .filter_map(|(i, _)| {
800            let id = i as u32;
801            if ids.is_tombstoned(id) {
802                Some(id)
803            } else {
804                None
805            }
806        })
807        .collect();
808    IdMapData { to_key, tombstones }
809}
810
811fn interner_to_data(syms: &Interner) -> InternerData {
812    let n = syms.len();
813    let mut to_str = Vec::with_capacity(n);
814    for i in 0u32..n as u32 {
815        to_str.push(syms.resolve(i).unwrap_or("").to_string());
816    }
817    InternerData { to_str }
818}
819
820// ---------------------------------------------------------------------------
821// Reconstruct owned types from archived sections
822// ---------------------------------------------------------------------------
823
824/// Reconstruct a `Topology` from an archived CSR section.
825pub fn csr_to_topology(archived: &crate::v8::layout::ArchivedCsr) -> Topology {
826    let mut topo = Topology::new();
827    for et_entry in archived.etypes.iter() {
828        let et = u32::from(et_entry.etype);
829        for row in et_entry.out_adj.rows.iter() {
830            let src = u32::from(row.vertex);
831            for &nbr in row.neighbors.iter() {
832                let dst = u32::from(nbr);
833                topo.add_edge(et, src, dst);
834            }
835        }
836    }
837    topo
838}
839
840/// Reconstruct a `ColumnStore` from archived columns.
841///
842/// `shared` is the snapshot's section-12 string table when it has one.  The
843/// resolution rule is the same one the seam uses: if the shared table is
844/// present it is the table; otherwise the column's own `strings` is.  Pass
845/// `None` for a pre-V9 base.
846pub fn archived_to_columnstore(
847    archived: &crate::v8::layout::ArchivedColumns,
848    shared: Option<&crate::v8::layout::ArchivedStringTable>,
849) -> ColumnStore {
850    let mut store = ColumnStore::new();
851    for field in archived.fields.iter() {
852        let name: &str = field.name.as_str();
853        match &field.col {
854            crate::v8::layout::ArchivedColumnData::Int { data, present } => {
855                bitmap_for_each(present.as_slice(), |node| {
856                    let idx = node as usize;
857                    if idx < data.len() {
858                        store.set(node, name, Value::Int(i64::from(data[idx])));
859                    }
860                });
861            }
862            crate::v8::layout::ArchivedColumnData::Float { data, present } => {
863                bitmap_for_each(present.as_slice(), |node| {
864                    let idx = node as usize;
865                    if idx < data.len() {
866                        store.set(node, name, Value::Float(f64::from(data[idx])));
867                    }
868                });
869            }
870            crate::v8::layout::ArchivedColumnData::Bool { data, present } => {
871                bitmap_for_each(present.as_slice(), |node| {
872                    let idx = node as usize;
873                    if idx < data.len() {
874                        store.set(node, name, Value::Bool(data[idx] != 0));
875                    }
876                });
877            }
878            crate::v8::layout::ArchivedColumnData::Str {
879                ids,
880                present,
881                strings,
882            } => {
883                let table = match shared {
884                    Some(t) => &t.strings,
885                    None => strings,
886                };
887                let strings_vec: Vec<String> =
888                    table.iter().map(|s| s.as_str().to_string()).collect();
889                bitmap_for_each(present.as_slice(), |node| {
890                    let idx = node as usize;
891                    if idx < ids.len() {
892                        let sid = u32::from(ids[idx]) as usize;
893                        if sid < strings_vec.len() {
894                            store.set(node, name, Value::Str(strings_vec[sid].clone()));
895                        }
896                    }
897                });
898            }
899            crate::v8::layout::ArchivedColumnData::Mixed(blob) => {
900                let map: HashMap<u32, Value> =
901                    bincode::deserialize(blob.as_slice()).unwrap_or_default();
902                for (node, v) in map {
903                    store.set(node, name, v);
904                }
905            }
906            crate::v8::layout::ArchivedColumnData::Vector { dim, data, present } => {
907                let dim_val = u32::from(*dim) as usize;
908                bitmap_for_each(present.as_slice(), |node| {
909                    let start = node as usize * dim_val;
910                    let end = start + dim_val;
911                    if end <= data.len() {
912                        let floats: Vec<Value> = data[start..end]
913                            .iter()
914                            .map(|f| Value::Float(f64::from(*f)))
915                            .collect();
916                        store.set(node, name, Value::List(floats));
917                    }
918                });
919            }
920        }
921    }
922    store
923}
924
925fn bitmap_for_each<F: FnMut(u32)>(words: &[rkyv::Archived<u64>], mut f: F) {
926    for (wi, word) in words.iter().enumerate() {
927        let mut w: u64 = u64::from(*word);
928        while w != 0 {
929            let bit = w.trailing_zeros();
930            f(wi as u32 * 64 + bit);
931            w &= w - 1;
932        }
933    }
934}
935
936/// Reconstruct an `IdMap` from archived data.
937pub fn archived_to_idmap(archived: &crate::v8::layout::ArchivedIdMap) -> IdMap {
938    let mut ids = IdMap::new();
939    let tombstoned: BTreeSet<u32> = archived.tombstones.iter().map(|t| u32::from(*t)).collect();
940    for (i, key) in archived.to_key.iter().enumerate() {
941        let s = key.as_str();
942        if tombstoned.contains(&(i as u32)) {
943            ids.get_or_insert(s);
944            ids.delete(s);
945        } else if !s.is_empty() {
946            ids.get_or_insert(s);
947        }
948    }
949    ids
950}
951
952/// Reconstruct an `Interner` from archived data.
953pub fn archived_to_interner(archived: &crate::v8::layout::ArchivedInterner) -> Interner {
954    let mut syms = Interner::new();
955    for s in archived.to_str.iter() {
956        syms.intern(s.as_str());
957    }
958    syms
959}
960
961/// Decode the `V8Meta` from the bincode meta section bytes.
962pub fn decode_meta(bytes: &[u8]) -> Result<V8Meta> {
963    bincode::deserialize(bytes).map_err(|e| GraphError::Corrupt {
964        detail: format!("v8: meta bincode deserialize: {e}"),
965    })
966}
967
968/// Decode the last-change map from raw section-11 bytes.
969///
970/// Returns an empty map for a zero-length slice (pre-Task-3 snapshots and fresh
971/// stores have no LAST_CHANGE section).  Corrupt bytes are treated the same as
972/// an absent map (non-fatal: the map is rebuilt from WAL replay on next open).
973pub fn decode_last_change_bytes(bytes: &[u8]) -> HashMap<u32, u64> {
974    if bytes.is_empty() {
975        return HashMap::new();
976    }
977    bincode::deserialize(bytes).unwrap_or_default()
978}
979
980/// Decode IVF state from raw section-10 bytes.
981///
982/// Returns an empty map for a zero-length slice (stores with no approximate
983/// rules write an empty IVF section).  Corrupt bytes are treated the same as
984/// an absent map (non-fatal: the engine will re-fit clusters on first use).
985pub fn decode_ivf_bytes(bytes: &[u8]) -> BTreeMap<String, PerRuleIvfState> {
986    if bytes.is_empty() {
987        return BTreeMap::new();
988    }
989    bincode::deserialize(bytes).unwrap_or_default()
990}
991
992// ---------------------------------------------------------------------------
993// Task-2 section encoders
994// ---------------------------------------------------------------------------
995
996/// Build `EdgePropsData` from owned `EdgeProps`.
997/// Entries are sorted by (etype, src, dst) for binary search on read.
998fn edge_props_to_data(ep: &EdgeProps) -> EdgePropsData {
999    let mut entries: Vec<EdgePropEntry> = ep
1000        .sorted_entries()
1001        .into_iter()
1002        .map(|(etype, src, dst, props)| {
1003            let props_blob = bincode::serialize(&props).unwrap_or_default();
1004            EdgePropEntry {
1005                etype,
1006                src,
1007                dst,
1008                props_blob,
1009            }
1010        })
1011        .collect();
1012    entries.sort_by_key(|e| (e.etype, e.src, e.dst));
1013    EdgePropsData { entries }
1014}
1015
1016/// Merge an archived base `EdgePropsData` with an in-memory overlay, producing
1017/// the `EdgePropsData` for the next snapshot.
1018///
1019/// Algorithm:
1020/// 1. Emit all base entries whose key `(etype, src, dst)` is NOT tombstoned in
1021///    the overlay and NOT superseded by an overlay entry (base entry wins only
1022///    when absent from overlay map).
1023/// 2. Emit all overlay entries (overlay wins over base for the same key).
1024///
1025/// Entries in the result are sorted by (etype, src, dst) ascending.
1026fn edge_props_merge_to_data(
1027    base: &crate::v8::layout::ArchivedEdgeProps,
1028    overlay: &EdgeProps,
1029) -> EdgePropsData {
1030    use std::collections::BTreeSet as BSet;
1031
1032    // Collect overlay keys for supersedure check.
1033    let overlay_keys: BSet<(u32, u32, u32)> = overlay
1034        .sorted_entries()
1035        .iter()
1036        .map(|&(et, s, d, _)| (et, s, d))
1037        .collect();
1038    let tombstoned_keys: BSet<(u32, u32, u32)> = overlay.tombstoned_keys().collect();
1039
1040    let mut entries: Vec<EdgePropEntry> = Vec::new();
1041
1042    // Base entries not tombstoned and not superseded by overlay.
1043    for entry in base.entries.iter() {
1044        let et = u32::from(entry.etype);
1045        let s = u32::from(entry.src);
1046        let d = u32::from(entry.dst);
1047        let key = (et, s, d);
1048        if tombstoned_keys.contains(&key) || overlay_keys.contains(&key) {
1049            continue;
1050        }
1051        entries.push(EdgePropEntry {
1052            etype: et,
1053            src: s,
1054            dst: d,
1055            props_blob: entry.props_blob.as_slice().to_vec(),
1056        });
1057    }
1058
1059    // Overlay entries (these take priority over any matching base entry).
1060    for (et, s, d, props) in overlay.sorted_entries() {
1061        let props_blob = bincode::serialize(props).unwrap_or_default();
1062        entries.push(EdgePropEntry {
1063            etype: et,
1064            src: s,
1065            dst: d,
1066            props_blob,
1067        });
1068    }
1069
1070    entries.sort_by_key(|e| (e.etype, e.src, e.dst));
1071    EdgePropsData { entries }
1072}
1073
1074/// Build `HnswSectionData` from the blobs map in V8Meta.
1075/// Rules are sorted by name.
1076fn hnsw_to_data(hnsw: &BTreeMap<String, (Vec<u8>, Vec<u8>)>) -> HnswSectionData {
1077    let mut rules: Vec<HnswRuleEntry> = hnsw
1078        .iter()
1079        .map(|(name, (src, dst))| HnswRuleEntry {
1080            name: name.clone(),
1081            src_blob: src.clone(),
1082            dst_blob: dst.clone(),
1083        })
1084        .collect();
1085    rules.sort_by(|a, b| a.name.cmp(&b.name));
1086    HnswSectionData { rules }
1087}
1088
1089/// Build `ProvenanceSectionData` from owned provenance map.
1090/// Triples within each rule are sorted by (etype, src, dst).
1091fn provenance_to_data(prov: &BTreeMap<String, BTreeSet<(u32, u32, u32)>>) -> ProvenanceSectionData {
1092    let mut entries: Vec<ProvenanceEntry> = prov
1093        .iter()
1094        .map(|(rule, triples)| {
1095            let mut sorted: Vec<Triple> = triples
1096                .iter()
1097                .map(|&(etype, src, dst)| Triple { etype, src, dst })
1098                .collect();
1099            // BTreeSet is already sorted, but make explicit for clarity.
1100            sorted.sort_by_key(|t| (t.etype, t.src, t.dst));
1101            ProvenanceEntry {
1102                rule: rule.clone(),
1103                triples: sorted,
1104            }
1105        })
1106        .collect();
1107    entries.sort_by(|a, b| a.rule.cmp(&b.rule));
1108    ProvenanceSectionData { entries }
1109}
1110
1111/// Build `RulesMetaData` from owned rule metadata.
1112/// Entries sorted by rule name within each sub-list.
1113fn rules_meta_to_data(
1114    rule_defs: &[Vec<u8>],
1115    tripped: &BTreeMap<String, bool>,
1116    fires: &BTreeMap<String, u64>,
1117) -> RulesMetaData {
1118    let tripped_vec: Vec<RuleTripEntry> = tripped
1119        .iter()
1120        .map(|(rule, &t)| RuleTripEntry {
1121            rule: rule.clone(),
1122            tripped: t,
1123        })
1124        .collect();
1125    let fires_vec: Vec<RuleFireEntry> = fires
1126        .iter()
1127        .map(|(rule, &f)| RuleFireEntry {
1128            rule: rule.clone(),
1129            fires: f,
1130        })
1131        .collect();
1132    RulesMetaData {
1133        rule_defs: rule_defs.to_vec(),
1134        tripped: tripped_vec,
1135        fires: fires_vec,
1136    }
1137}
1138
1139// ---------------------------------------------------------------------------
1140// Decode helpers for Task-2 sections
1141// ---------------------------------------------------------------------------
1142
1143/// Decode `EdgePropsData` back into `EdgeProps`.
1144pub fn archived_edge_props_to_owned(archived: &crate::v8::layout::ArchivedEdgeProps) -> EdgeProps {
1145    let mut ep = EdgeProps::new();
1146    for entry in archived.entries.iter() {
1147        let etype = u32::from(entry.etype);
1148        let src = u32::from(entry.src);
1149        let dst = u32::from(entry.dst);
1150        let props: std::collections::BTreeMap<String, Value> =
1151            bincode::deserialize(entry.props_blob.as_slice()).unwrap_or_default();
1152        for (field, value) in props {
1153            ep.set(etype, src, dst, &field, value);
1154        }
1155    }
1156    ep
1157}
1158
1159/// Decode `ProvenanceSectionData` back into a `BTreeMap<String, BTreeSet<(u32,u32,u32)>>`.
1160pub fn archived_provenance_to_owned(
1161    archived: &crate::v8::layout::ArchivedProvenance,
1162) -> BTreeMap<String, BTreeSet<(u32, u32, u32)>> {
1163    let mut map = BTreeMap::new();
1164    for entry in archived.entries.iter() {
1165        let rule = entry.rule.as_str().to_string();
1166        let triples: BTreeSet<(u32, u32, u32)> = entry
1167            .triples
1168            .iter()
1169            .map(|t| (u32::from(t.etype), u32::from(t.src), u32::from(t.dst)))
1170            .collect();
1171        map.insert(rule, triples);
1172    }
1173    map
1174}
1175
1176/// Decode raw provenance section bytes (rkyv) into a map.
1177///
1178/// Returns an empty map for a zero-length slice (stores with no rules write
1179/// an empty provenance section). Corrupt bytes are treated as absent
1180/// (non-fatal: the engine will start from empty provenance).
1181pub fn decode_provenance_bytes(
1182    bytes: &[u8],
1183) -> BTreeMap<String, std::collections::BTreeSet<(u32, u32, u32)>> {
1184    if bytes.is_empty() {
1185        return BTreeMap::new();
1186    }
1187    match rkyv::access::<crate::v8::layout::ArchivedProvenanceSectionData, rkyv::rancor::Error>(
1188        bytes,
1189    ) {
1190        Ok(archived) => archived_provenance_to_owned(archived),
1191        Err(_) => BTreeMap::new(),
1192    }
1193}
1194
1195/// Return type for `archived_rules_meta_to_owned`.
1196pub type RulesMetaOwned = (Vec<Vec<u8>>, BTreeMap<String, bool>, BTreeMap<String, u64>);
1197
1198/// Decode `RulesMetaData` back into separate collections.
1199pub fn archived_rules_meta_to_owned(
1200    archived: &crate::v8::layout::ArchivedRulesMeta,
1201) -> RulesMetaOwned {
1202    let rule_defs: Vec<Vec<u8>> = archived
1203        .rule_defs
1204        .iter()
1205        .map(|b| b.as_slice().to_vec())
1206        .collect();
1207    let tripped: BTreeMap<String, bool> = archived
1208        .tripped
1209        .iter()
1210        .map(|e| (e.rule.as_str().to_string(), e.tripped))
1211        .collect();
1212    let fires: BTreeMap<String, u64> = archived
1213        .fires
1214        .iter()
1215        .map(|e| (e.rule.as_str().to_string(), u64::from(e.fires)))
1216        .collect();
1217    (rule_defs, tripped, fires)
1218}
1219
1220/// Decode `ViewsSectionData` back into a `Vec<Vec<u8>>`.
1221pub fn archived_views_to_owned(archived: &crate::v8::layout::ArchivedViews) -> Vec<Vec<u8>> {
1222    archived
1223        .view_defs
1224        .iter()
1225        .map(|b| b.as_slice().to_vec())
1226        .collect()
1227}
1228
1229/// Binary-search the archived provenance section for a specific triple.
1230///
1231/// Triples within each rule's entry are stored sorted by `(etype, src, dst)`,
1232/// so containment is O(log n) without materialising a `BTreeSet`.
1233///
1234/// Returns `true` if `(etype, src, dst)` is recorded for `rule`.
1235pub fn archived_provenance_contains(
1236    archived: &crate::v8::layout::ArchivedProvenance,
1237    rule: &str,
1238    etype: u32,
1239    src: u32,
1240    dst: u32,
1241) -> bool {
1242    let entry = archived.entries.iter().find(|e| e.rule.as_str() == rule);
1243    let Some(entry) = entry else {
1244        return false;
1245    };
1246    // Triples are sorted; use binary search.
1247    entry
1248        .triples
1249        .binary_search_by(|t| {
1250            let te = u32::from(t.etype);
1251            let ts = u32::from(t.src);
1252            let td = u32::from(t.dst);
1253            (te, ts, td).cmp(&(etype, src, dst))
1254        })
1255        .is_ok()
1256}
1257
1258/// Decode `HnswSectionData` back into the `BTreeMap<String, (Vec<u8>, Vec<u8>)>` format.
1259pub fn archived_hnsw_to_owned(
1260    archived: &crate::v8::layout::ArchivedHnsw,
1261) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
1262    archived
1263        .rules
1264        .iter()
1265        .map(|e| {
1266            (
1267                e.name.as_str().to_string(),
1268                (
1269                    e.src_blob.as_slice().to_vec(),
1270                    e.dst_blob.as_slice().to_vec(),
1271                ),
1272            )
1273        })
1274        .collect()
1275}