Persistence
GRW graphs can be saved to and loaded from binary .grw files (format
version 3), with layout validation and an integrity check over the whole
file.
Save and Load
use std::path::Path;
// save
g.save(Path::new("my_graph.grw")).unwrap();
// load
let g2: graph::MUndir0 = MGraph::load(Path::new("my_graph.grw")).unwrap();
VGraph::save/load write and read exactly the same format — there is one
.grw layout, not two; see Versioned Graphs for what
crosses that door and what doesn’t.
Binary Format (v3)
A file is a fixed header, a section table, six content sections, and a trailer:
- Header — magic
GRW\0,version: u16(= 3),graph_kind: u8(0 =MGraph, 1 =VGraph),edge_kind: u8, node/edge counts, layout hashes forNVandEV, then the type name strings (for error messages), then asection_count: u16and that many(tag, offset, len)entries — always all seven, even when a section is empty. - Nodes / Edges — fixed-width rows for live nodes/edges only, sorted
by slot ascending. Every row carries a
gen: u64field; it is real for aVGraph-sourced file and always0for anMGraph-sourced one — the byte layout never branches ongraph_kind, only its meaning does. - Adjacency —
(neighbor, edge)pairs, contiguous per node in node order; a node’s row carries a byte offset into this section and a pair count. - Values — one heap of bincode bytes, node values first (in node order) then edge values (in edge order), each addressed by byte offset and length from the node/edge rows.
- Free — one bincode blob:
node_free/edge_free(each the holes belownext_node/next_edge, not the full allocator state),next_node: u32,next_edge: u32, and a realversion: u64(always0for anMGraph-sourced file). - Indices — see below.
- Trailer — 32 bytes, the SHA-256 of every byte before it (header through the indices section, including the section table itself). Verified before any section body is parsed, so a flipped byte inside a section surfaces as a trailer-mismatch error, never a downstream parse error. The fixed header and the section table are read before the check runs, so corruption there surfaces first as a structural error — “invalid magic”, “unsupported version”, “unknown section tag”, or a truncated/out-of-range section range — rather than as a trailer mismatch. Every one of those errors names the file it came from.
use grw::graph::persist;
let header = persist::read_header(Path::new("my_graph.grw")).unwrap();
assert_eq!(header.version, 3);
assert_eq!(header.sections.len(), 7);
read_header reads only the header and the section table (and, inside the
indices section, only the catalogue — never a table body), so it can inspect
a file’s shape and catalogue without loading the graph at all. Being
header-only, it does not verify the trailer: what it reports is what the
bytes claim. load/load_with verify the trailer before parsing any
section body.
Indices
If a graph carries indices, save writes the whole catalogue and every
table into the indices section: a catalogue header (each declared index’s
name, cardinality, and key tag) followed, in the same order, by one table
per index. load refuses such a file, naming every index that needs a
declaration — a plain load has nothing to check the file against — so use
load_with to both verify and restore them:
use grw::graph::index::{Cardinality, IndexDecl, IndexName};
use grw::graph::{MGraph, edge};
const BY_VAL: IndexName = IndexName("by_val");
let g: MGraph<u32, edge::Undir<()>> = grw::mgraph![N(0).val(10u32)].unwrap();
let g = g.with_indices(vec![
IndexDecl::new(BY_VAL, Cardinality::Unique, |v: &u32| Some(*v)),
]).unwrap();
g.save(Path::new("indexed.grw")).unwrap();
let g2: MGraph<u32, edge::Undir<()>> = MGraph::load_with(Path::new("indexed.grw"), vec![
IndexDecl::new(BY_VAL, Cardinality::Unique, |v: &u32| Some(*v)),
]).unwrap();
assert_eq!(g2.catalogue().len(), 1);
load_with checks the file’s catalogue against the declarations you pass —
by name, order-independent — before restoring the tables directly from the
parsed bytes, with no rescan of node values: a missing declaration, an extra
one, or a key-type mismatch (KeyTag) are each a distinct, named error.
Plain load on such a file is itself an error, naming every index that
needs a declaration. See
Indices for the declaration API itself.
Layout Validation
When loading, GRW validates that the types match:
- Edge kind must match (can’t load a directed graph as undirected)
- Layout hash for node values must match
- Layout hash for edge values must match
// this will fail: type mismatch
let result = MGraph::<i64, edge::Undir<i64>>::load(Path::new("u32_graph.grw"));
// Error: "node value layout mismatch: file has type `u32`, expected `i64`"
The layout hash is computed from the type’s field names, types, and structure using FNV hashing. This catches:
- Wrong types (
u32vsi64) - Reordered fields
- Added/removed fields
Errors
Every save, load*, read_header and convert answers a
persist::Error { path, reason }: the file it read or wrote, and a typed
Reason — Io, Format (magic, version, section tag or range, truncation,
trailer hash, cardinality, UTF-8), EdgeKind, Layout { side, .. },
Catalogue (an undeclared, unheld or mismatched index), Value { site, source }
for a node value, edge value, free section or index set bincode refused,
Promotion and V2.
use grw::graph::persist::{Layout, Reason, Side};
let Err(err) = MGraph::<i64, edge::Undir<i64>>::load(Path::new("u32_graph.grw")) else { panic!("a u32 file is refused as i64") };
assert!(matches!(err.reason, Reason::Layout(Layout { side: Side::Node, .. })));
Reads are strict: every value, the free section and every index set must be
consumed exactly by its decode, so a slice with bytes left over is a
Reason::Value naming its site. Writes are the plain bincode::serialize
output, so every file grw has written still reads.
Composite Edge Values
A composite edge value such as TypeSet<P> is saved as its parts in kind
order, under its own layout hash, so the format is still v3. A file written
while each link held a plain P fails the strict edge layout check; it loads
into a TypeSet<P> graph only through the explicit
load_promoting/load_promoting_with (on MGraph and VGraph), each old
link becoming a one-part link. See
Composite Values.
Loading an old file: convert
A file written by the pre-v3 format is rejected by load/load_with/
read_header with a message naming convert:
use grw::graph::persist;
let report = persist::convert::<(), edge::Dir<()>>(
Path::new("old_v2.grw"),
Path::new("upgraded_v3.grw"),
).unwrap();
let g: graph::MDir0 = MGraph::load(Path::new("upgraded_v3.grw")).unwrap();
assert_eq!(report.node_count, g.node_count() as u64);
convert reads the old file in full and writes it back out in the current
format at the destination path; the source is untouched.
Memory-Mapped Loading
load() uses memmap2 for memory-mapped file access: the header and
section table are parsed first, then each section is read directly from the
mmap.
The Val Trait
save() and load() require both NV and EV to implement the Val
trait (for layout hash computation). Primitive types ((), bool, u8–u64, i8–i64, f32, f64, String) implement Val automatically. For custom structs, use #[derive(Val)]:
use grw::Val;
#[derive(Val, serde::Serialize, serde::Deserialize)]
struct MyNode {
x: f64,
y: f64,
label: String,
}