ontomark_ngg1/lib.rs
1//! `ontomark-ngg1`: the NGG1 binary graph-tier format, in one place.
2//!
3//! NGG1 is the compact, memory-mappable graph encoding that a publishing
4//! pipeline ships to a browser explorer: a node table, a CSR adjacency, one
5//! type byte per edge and a string table, all little-endian. This crate owns
6//! the format end to end: the constants, the record types, the [`encode`]r and
7//! the zero-copy [`Ngg1`] reader. It has no dependencies, uses only `std` and
8//! contains no `unsafe` code.
9//!
10//! # Provenance
11//!
12//! - The reader, the `NGG1_*` constants and [`worked_example`] come from
13//! `vowl-wasm` 0.1.2 (`src/ngg1.rs`, MIT, Copyright (c) 2025-2026
14//! DreamLab-AI). Every public name and signature of that module is kept, so
15//! `vowl-wasm` can replace it with `pub use ontomark_ngg1::*;`.
16//! - The encoder, [`Csr`], [`build_csr`] and the short constant names
17//! ([`MAGIC`], [`VERSION`], ...) come from VisionClaw's
18//! `crates/vault/src/build/ngg1.rs` at `1044b0722`, via `ontomark-compile`.
19//!
20//! See the `NOTICE` file shipped with the crate.
21//!
22//! # Byte layout
23//!
24//! All integers and floats are little-endian. Every section starts on a
25//! 4-byte boundary, so a consumer may view `row_ptr`, `col_idx` and the string
26//! offsets as `u32` arrays over a 4-aligned buffer. `N` is the node count, `E`
27//! the edge count and `S = 2 * N` the string count.
28//!
29//! | Section | Starts at | Size (bytes) | Content |
30//! |---|---|---|---|
31//! | header | 0 | 32 | see the header table below |
32//! | nodes | `off_nodes` (32) | `24 * N` | `N` node records |
33//! | adjacency | `off_adjacency` | `4 * (N + 1) + 4 * E` | `row_ptr[N + 1]: u32`, then `col_idx[E]: u32` |
34//! | edge types | `off_edge_types` | `E` plus 0 to 3 zero bytes | `edge_type[E]: u8`, padded to a multiple of 4 |
35//! | strings | `off_strings` | `8 + 4 * S + blob_len` | `count: u32`, `blob_len: u32`, `offsets[S]: u32`, blob |
36//!
37//! The offsets are `off_nodes = 32`, `off_adjacency = 32 + 24 * N`,
38//! `off_edge_types = off_adjacency + 4 * (N + 1) + 4 * E` and
39//! `off_strings = off_edge_types + E + pad4(E)`, where `pad4(E)` is
40//! `(4 - E % 4) % 4`. The file ends with the last byte of the blob.
41//!
42//! Header (32 bytes):
43//!
44//! | Offset | Type | Field |
45//! |---|---|---|
46//! | 0 | `[u8; 4]` | magic, ASCII `NGG1` (`0x3147_474E` as a little-endian `u32`) |
47//! | 4 | `u16` | version, currently 1 |
48//! | 6 | `u16` | padding, zero |
49//! | 8 | `u32` | node count `N` |
50//! | 12 | `u32` | edge count `E` |
51//! | 16 | `u32` | `off_nodes` |
52//! | 20 | `u32` | `off_adjacency` |
53//! | 24 | `u32` | `off_edge_types` |
54//! | 28 | `u32` | `off_strings` |
55//!
56//! Node record (24-byte stride; the original brief's "20" is arithmetically
57//! impossible once `degree` is 4-aligned):
58//!
59//! | Offset | Type | Field |
60//! |---|---|---|
61//! | 0 | `u32` | stable external id, shared across tiers |
62//! | 4 | `f32` | seed x position |
63//! | 8 | `f32` | seed y position |
64//! | 12 | `u16` | domain id, or [`DOMAIN_NONE`] |
65//! | 14 | `u16` | category id, or [`CATEGORY_NONE`] |
66//! | 16 | `u8` | flags (`FLAG_*`) |
67//! | 17 | `[u8; 3]` | padding, zero |
68//! | 20 | `u32` | degree in the full graph (ranking key) |
69//!
70//! Strings: the blob is the concatenation of every string's UTF-8 bytes with
71//! no separators. `offsets[s]` is the start of string `s`; string `s` ends at
72//! `offsets[s + 1]`, and the last string ends at `blob_len`. Node `i` owns
73//! strings `2 * i` (label) and `2 * i + 1` (IRI).
74//!
75//! Domains and categories are only identifiers here: how many exist, and what
76//! they are called, is a property of the producing taxonomy, not of the
77//! format. A graph with no domains or no categories is valid.
78//!
79//! # Encoding and decoding
80//!
81//! ```
82//! use ontomark_ngg1::{build_csr, encode, GraphNode, Ngg1, CATEGORY_NONE, EDGE_SUBCLASS};
83//!
84//! let node = |id: u32, label: &str| GraphNode {
85//! id,
86//! x: id as f32,
87//! y: 0.0,
88//! domain: 0,
89//! category: CATEGORY_NONE,
90//! flags: 0,
91//! degree: 1,
92//! label: label.to_owned(),
93//! iri: format!("https://example.org/{label}"),
94//! };
95//! let nodes = vec![node(0, "Animal"), node(1, "Dog")];
96//! let csr = build_csr(2, &[(1, 0, EDGE_SUBCLASS)]);
97//! let bytes = encode(&nodes, &csr)?;
98//!
99//! let graph = Ngg1::parse(&bytes)?;
100//! assert_eq!((graph.node_count(), graph.edge_count()), (2, 1));
101//! assert_eq!(graph.label(1), "Dog");
102//! assert_eq!(graph.edges(), (vec![1], vec![0], vec![EDGE_SUBCLASS]));
103//! # Ok::<(), Box<dyn std::error::Error>>(())
104//! ```
105//!
106//! # Malformed input
107//!
108//! [`Ngg1::try_parse`] validates the whole buffer once (magic, version,
109//! alignment, every section against the buffer length, CSR monotonicity, edge
110//! targets, string offsets) and returns a typed [`Ngg1Fault`]. Every accessor
111//! on a successfully parsed reader is then panic-free for in-range indices.
112
113mod encode;
114mod error;
115mod example;
116mod reader;
117
118pub use encode::{build_csr, encode, Csr, EncodeError, Graph, GraphNode};
119pub use error::{Ngg1Error, Ngg1Fault};
120pub use example::worked_example;
121pub use reader::{Ngg1, NodeRecord};
122
123/// `NGG1` as a little-endian `u32`: `0x3147_474E` (bytes `4E 47 47 31`).
124pub const NGG1_MAGIC: u32 = 0x3147_474E;
125/// Format version this crate reads and writes.
126pub const NGG1_VERSION: u16 = 1;
127/// Fixed header size in bytes.
128pub const NGG1_HEADER_SIZE: usize = 32;
129/// Node record stride in bytes. **24, not 20.**
130pub const NGG1_NODE_STRIDE: usize = 24;
131
132/// The magic as the four bytes at the start of every file, `NGG1`.
133pub const MAGIC: &[u8; 4] = b"NGG1";
134/// Format version; the same value as [`NGG1_VERSION`].
135pub const VERSION: u16 = NGG1_VERSION;
136/// Header length in bytes; the same value as [`NGG1_HEADER_SIZE`].
137pub const HEADER_SIZE: usize = NGG1_HEADER_SIZE;
138/// Node record stride in bytes; the same value as [`NGG1_NODE_STRIDE`].
139pub const NODE_STRIDE: usize = NGG1_NODE_STRIDE;
140
141/// `edge_type` value for a `subClassOf` edge, the taxonomy backbone.
142pub const EDGE_SUBCLASS: u8 = 0;
143/// `edge_type` value for an `objectProperty` relation.
144pub const EDGE_RELATION: u8 = 1;
145
146/// Node flag: the node is a declared domain root.
147pub const FLAG_DOMAIN_ROOT: u8 = 0x01;
148/// Node flag: the node is a taxonomy category root.
149pub const FLAG_CATEGORY_ROOT: u8 = 0x02;
150/// Node flag: the node has an authored page behind it.
151pub const FLAG_HAS_PAGE: u8 = 0x04;
152/// Node flag: the node ships at least one cross-domain `bridges-to` edge.
153pub const FLAG_BRIDGE: u8 = 0x08;
154/// Node flag: the node is an OWL individual, not a class.
155pub const FLAG_INDIVIDUAL: u8 = 0x10;
156/// Node flag: a client-minted collapsed-category metanode. Never written by
157/// a producer; the TypeScript explorer sets it on synthetic nodes.
158pub const FLAG_META: u8 = 0x40;
159
160/// Domain id for a node with no resolvable domain.
161pub const DOMAIN_NONE: u16 = 0xFFFF;
162/// Category id for an uncategorised node.
163pub const CATEGORY_NONE: u16 = 0xFFFF;