Skip to main content

genegraph_storage/
graph.rs

1//! First-class graph storage types (RFC #81-P3).
2//!
3//! The canonical on-disk layout is an edge-list Lance dataset:
4//! `src: UInt32|UInt64`, `dst: UInt32|UInt64` and an optional
5//! `weight: Float32|Float64` column (all non-null). Node-id and weight
6//! widths are schema-chosen — `u32` node-id and `f64` weight defaults,
7//! `u64`/`f32` opt-ins (#106) — and declare the storage limits
8//! explicitly rather than narrowing silently (consistent with #51).
9//! Weights are persisted faithfully — the storage layer makes no domain
10//! assumptions, so normalization transforms belong to the producer; the
11//! opt-in [`GraphWriteOptions::weight_range`] asserts compliance with a
12//! declared interval instead. `CsMat` CSR conversion stays at the API
13//! boundary ([`StoredGraph::to_csr`]); sprs never reaches the format
14//! layer.
15
16use std::collections::BTreeMap;
17
18use sprs::{CsMat, TriMat};
19
20use crate::{StorageError, StorageResult};
21
22/// Node-id storage width of a graph collection.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum NodeIdWidth {
25    /// `UInt32` ids (default); caps the graph at ~4.29 billion nodes.
26    U32,
27    /// `UInt64` ids (opt-in via schema).
28    U64,
29}
30
31impl NodeIdWidth {
32    pub const fn as_str(&self) -> &'static str {
33        match self {
34            NodeIdWidth::U32 => "u32",
35            NodeIdWidth::U64 => "u64",
36        }
37    }
38
39    pub fn parse(s: &str) -> StorageResult<Self> {
40        match s {
41            "u32" => Ok(NodeIdWidth::U32),
42            "u64" => Ok(NodeIdWidth::U64),
43            other => Err(StorageError::Invalid(format!(
44                "unknown node-id width '{other}' (expected u32 or u64)"
45            ))),
46        }
47    }
48
49    /// The `DataType` used for `src`/`dst` columns at this width.
50    pub(crate) const fn data_type(self) -> arrow::datatypes::DataType {
51        match self {
52            NodeIdWidth::U32 => arrow::datatypes::DataType::UInt32,
53            NodeIdWidth::U64 => arrow::datatypes::DataType::UInt64,
54        }
55    }
56}
57
58/// Edge-weight storage width of a graph collection, mirroring
59/// [`NodeIdWidth`] (#106): the width is schema-declared through
60/// [`GraphWriteOptions::weight_type`], resolving the RFC #81 open
61/// question ("Float64 only, or allow Float32?") with "both,
62/// schema-declared".
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum WeightType {
65    /// `Float64` weights (default): the same width and exactness as the
66    /// `value` column of the legacy sparse-matrix artifacts, so an f64
67    /// CSR (laplacian, adjacency) round-trips bit-identically through
68    /// the graph API just like through `save_sparse`.
69    F64,
70    /// `Float32` weights (opt-in): half the storage bytes for
71    /// memory-bound consumers. Weights that cannot be represented exactly
72    /// at this width are rejected on save instead of being silently
73    /// narrowed; finite values beyond the f32 range surface
74    /// [`StorageError::Overflow`] (#51). Infinities narrow bit-exactly;
75    /// NaN is preserved as NaN (classification semantics only — the
76    /// payload is not guaranteed bit-exact, unlike the
77    /// [`WeightType::F64`] width).
78    F32,
79}
80
81impl WeightType {
82    pub const fn as_str(&self) -> &'static str {
83        match self {
84            WeightType::F64 => "f64",
85            WeightType::F32 => "f32",
86        }
87    }
88
89    pub fn parse(s: &str) -> StorageResult<Self> {
90        match s {
91            "f64" => Ok(WeightType::F64),
92            "f32" => Ok(WeightType::F32),
93            other => Err(StorageError::Invalid(format!(
94                "unknown weight width '{other}' (expected f32 or f64)"
95            ))),
96        }
97    }
98
99    /// The `DataType` used for the `weight` column at this width.
100    pub(crate) const fn data_type(self) -> arrow::datatypes::DataType {
101        match self {
102            WeightType::F64 => arrow::datatypes::DataType::Float64,
103            WeightType::F32 => arrow::datatypes::DataType::Float32,
104        }
105    }
106}
107
108/// A directed edge with an optional weight (RFC #81-P3).
109///
110/// `weight` is `f64` at the API boundary (the widest width, like node ids
111/// are `u64`) and is persisted faithfully: the storage layer makes no
112/// domain assumptions, so normalization transforms belong to the producer
113/// (declare [`GraphWriteOptions::weight_range`] for an opt-in bounds
114/// assertion on already-normalized values). The storage width is
115/// schema-declared via [`GraphWriteOptions::weight_type`] — `Float64` by
116/// default, mirroring the `value` column of the legacy sparse-matrix
117/// artifacts — and weights that cannot be stored exactly at a declared
118/// `Float32` width are rejected, never silently narrowed. A collection is
119/// either fully weighted or topology-only; mixed edges are rejected.
120#[derive(Debug, Clone, Copy, PartialEq)]
121pub struct GraphEdge {
122    pub src: u64,
123    pub dst: u64,
124    pub weight: Option<f64>,
125}
126
127impl GraphEdge {
128    pub fn weighted(src: u64, dst: u64, weight: f64) -> Self {
129        Self {
130            src,
131            dst,
132            weight: Some(weight),
133        }
134    }
135
136    pub fn unweighted(src: u64, dst: u64) -> Self {
137        Self {
138            src,
139            dst,
140            weight: None,
141        }
142    }
143}
144
145/// Declared source lineage of a graph collection (#140): the name of the
146/// vector-space collection the graph was built from and its row count at
147/// build time. Resolve it through `Catalog::graph_source` — never hand-
148/// build rows from stale facts.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct GraphSource {
151    /// Name of the source vector-space collection (registry key).
152    pub name: String,
153    /// Row count of the source collection at build time.
154    pub rows: usize,
155}
156
157/// Options for [`crate::traits::backend::StorageBackend::save_graph_with`].
158#[derive(Debug, Clone)]
159pub struct GraphWriteOptions {
160    /// Storage width for node ids (default `U32`); ids above the limit
161    /// surface [`StorageError::Overflow`] instead of being truncated.
162    pub node_id_width: NodeIdWidth,
163    /// Storage width for edge weights (default `F64`, mirroring
164    /// `node_id_width`, #106): weights are `f64` at the API boundary.
165    /// The default matches the sparse-matrix `value` column (bit-exact
166    /// f64 round trips); an explicit `F32` declaration halves the storage
167    /// bytes but rejects values that cannot be stored exactly instead of
168    /// silently narrowing them.
169    pub weight_type: WeightType,
170    /// Opt-in weight-range compliance check (#106): when `Some((low,
171    /// high))`, every weight must already lie in the closed interval — a
172    /// bounds assertion that catches a forgotten producer transform,
173    /// never a transform the storage layer performs (normalization
174    /// belongs upstream; values are persisted faithfully). NaN lies in
175    /// no interval and is rejected; an inverted interval is a rejected
176    /// configuration.
177    pub weight_range: Option<(f64, f64)>,
178    /// Explicit node count (e.g. to keep isolated vertices). Must be at
179    /// least `max(src, dst) + 1` over all edges; defaults to exactly that.
180    pub num_nodes: Option<u64>,
181    /// Declared source lineage (#140): the vector-space collection this
182    /// graph was built from and its row count at build time. Resolve it
183    /// through `Catalog::graph_source`; the write paths stamp it as the
184    /// reserved `source`/`source_rows` metadata without reading the
185    /// registry (the catalog owns that reasoning, the storage layer only
186    /// persists declared facts). `Catalog::describe_vector_space` turns
187    /// the stamp into a staleness signal.
188    pub source: Option<GraphSource>,
189    /// User properties stored on the collection (registry level) and
190    /// stamped into the dataset schema metadata (dataset level).
191    pub properties: BTreeMap<String, String>,
192}
193
194impl Default for GraphWriteOptions {
195    fn default() -> Self {
196        Self {
197            node_id_width: NodeIdWidth::U32,
198            weight_type: WeightType::F64,
199            weight_range: None,
200            num_nodes: None,
201            source: None,
202            properties: BTreeMap::new(),
203        }
204    }
205}
206
207impl GraphWriteOptions {
208    pub fn with_width(node_id_width: NodeIdWidth) -> Self {
209        Self {
210            node_id_width,
211            ..Default::default()
212        }
213    }
214}
215
216/// Options for the graph read path (#108).
217///
218/// The tolerant default is the compat shim: an unstamped dataset loads with
219/// `num_nodes = max_id + 1` and columns are read positionally (name-agnostic),
220/// so pre-collections triplet artifacts still load. Strict mode makes the
221/// hazard detectable at the API instead of silently resizing.
222#[derive(Debug, Clone, Copy, Default)]
223pub struct GraphReadOptions {
224    /// When `true`, reject datasets without a `num_nodes` stamp with a typed
225    /// [`StorageError::Invalid`] instead of silently resizing to `max_id + 1`
226    /// (a graph whose trailing vertices are isolated would otherwise load
227    /// with a smaller node count than was written). Also validates the
228    /// `src`/`dst`/`weight` column names, positively identifying
229    /// pre-collections triplet artifacts (`row`/`col`/`value`) rather than
230    /// positionally coercing them.
231    pub strict: bool,
232}
233
234impl GraphReadOptions {
235    /// Strict read mode: reject unstamped `num_nodes` and validate column
236    /// names (#108).
237    pub fn strict() -> Self {
238        Self { strict: true }
239    }
240}
241
242/// A graph collection loaded back from storage (RFC #81-P3).
243#[derive(Debug, Clone, PartialEq)]
244pub struct StoredGraph {
245    /// Edges in stored order; `weight` is `None` for topology-only
246    /// collections. `f32`-stored weights are upcast to `f64` losslessly
247    /// at load time.
248    pub edges: Vec<GraphEdge>,
249    /// Storage width used for node ids.
250    pub node_id_width: NodeIdWidth,
251    /// Storage width used for the `weight` column (mirrors
252    /// `node_id_width`, #106); for topology-only collections it reports
253    /// the declared width from the dataset metadata (the `F64` default
254    /// when unstamped) and is meaningful only when `weighted`.
255    pub weight_type: WeightType,
256    /// Node count (from the dataset metadata; isolated vertices included).
257    pub num_nodes: u64,
258    /// Whether the edge list carries weights.
259    pub weighted: bool,
260}
261
262impl StoredGraph {
263    /// Converts the edge list to a CSR matrix at the API boundary.
264    ///
265    /// Topology-only graphs get weight `1.0` (unweighted convention); the
266    /// result is `CsMat<f64>` — weights are `f64` at the API boundary, so
267    /// `WeightType::F64` collections round-trip exactly.
268    pub fn to_csr(&self) -> StorageResult<CsMat<f64>> {
269        let n = self.num_nodes;
270        if n > usize::MAX as u64 {
271            return Err(StorageError::Overflow(format!(
272                "node count {n} exceeds the addressable size"
273            )));
274        }
275        let n = n as usize;
276        let mut trimat = TriMat::new((n, n));
277        trimat.reserve(self.edges.len());
278        for edge in &self.edges {
279            let (src, dst) = (edge.src as usize, edge.dst as usize);
280            if src >= n || dst >= n {
281                return Err(StorageError::Invalid(format!(
282                    "edge ({}, {}) out of bounds for {}-node graph",
283                    edge.src, edge.dst, n
284                )));
285            }
286            let weight = edge.weight.unwrap_or(1.0);
287            trimat.add_triplet(src, dst, weight);
288        }
289        let csr = trimat.to_csr();
290        if csr.rows() != n || csr.cols() != n {
291            return Err(StorageError::Invalid(format!(
292                "dimension mismatch after CSR conversion: expected {n}x{n}, got {}x{}",
293                csr.rows(),
294                csr.cols()
295            )));
296        }
297        Ok(csr)
298    }
299}
300
301/// Dataset-level schema-metadata keys stamped by the `save_graph` /
302/// `save_vectors` writers (RFC #81-P1: dataset-level kinds alongside
303/// registry-level kinds). User properties may not shadow these.
304pub(crate) const RESERVED_METADATA_KEYS: [&str; 7] = [
305    "kind",
306    "node_id_width",
307    "weighted",
308    "num_nodes",
309    "weight_type",
310    "source",
311    "source_rows",
312];