Skip to main content

velesdb_core/collection/
graph_collection.rs

1//! `GraphCollection`: knowledge graph with optional node embeddings.
2//!
3//! # Design
4//!
5//! `GraphCollection` is a pure newtype over `Collection` (C-02).
6//! All graph state (edge store, property/range indexes, node payloads, optional
7//! HNSW for node embeddings) lives inside the single `inner: Collection`.
8//! The graph schema and embedding dimension are persisted in `config.json`.
9//! There are no separate engine fields — no dual-storage risk.
10
11use std::path::PathBuf;
12
13use crate::collection::graph::{
14    EdgeRemoval, GraphEdge, GraphSchema, TraversalConfig, TraversalResult,
15};
16use crate::collection::types::Collection;
17use crate::distance::DistanceMetric;
18use crate::error::Result;
19use crate::point::{Point, SearchResult};
20
21/// A graph collection storing typed relationships between nodes.
22///
23/// Node embeddings are optional: if `dimension` is `None`, no vector index is created.
24///
25/// # Examples
26///
27/// ```rust,no_run
28/// use velesdb_core::{GraphCollection, GraphSchema, GraphEdge, DistanceMetric};
29///
30/// let coll = GraphCollection::create(
31///     "./data/kg".into(),
32///     "knowledge",
33///     None,                    // no embeddings
34///     DistanceMetric::Cosine,  // unused when no embeddings
35///     GraphSchema::schemaless(),
36/// )?;
37///
38/// let edge = GraphEdge::new(1, 100, 200, "KNOWS")?;
39/// coll.add_edge(edge)?;
40/// # Ok::<(), velesdb_core::Error>(())
41/// ```
42#[derive(Clone)]
43pub struct GraphCollection {
44    /// Single source of truth — all graph state lives here (C-02 pure newtype).
45    pub(crate) inner: Collection,
46}
47
48impl GraphCollection {
49    // -------------------------------------------------------------------------
50    // Lifecycle
51    // -------------------------------------------------------------------------
52
53    /// Creates a new `GraphCollection`.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the directory cannot be created or storage fails.
58    pub fn create(
59        path: PathBuf,
60        name: &str,
61        dimension: Option<usize>,
62        metric: DistanceMetric,
63        schema: GraphSchema,
64    ) -> Result<Self> {
65        Ok(Self {
66            inner: Collection::create_graph_collection(path, name, schema, dimension, metric)?,
67        })
68    }
69
70    /// Opens an existing `GraphCollection` from disk.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if config or storage cannot be opened.
75    pub fn open(path: PathBuf) -> Result<Self> {
76        Ok(Self {
77            inner: Collection::open(path)?,
78        })
79    }
80
81    /// Consumes `self` and returns a [`VectorCollection`](super::VectorCollection)
82    /// **structural view** over this graph collection's shared `inner` store.
83    ///
84    /// This is a purely structural re-wrap of the identical `inner: Collection`
85    /// backing store into the `VectorCollection` newtype — an ordinary value
86    /// move, **not** a `transmute` and not memory-unsafe. It does **not** assert
87    /// that the collection is vector-kind: invoking vector-specific methods on
88    /// the result returns empty or misleading state.
89    ///
90    /// It exists solely for the **Python binding**, whose single user-facing
91    /// `Collection` type is backed by a `VectorCollection`: the binding holds a
92    /// graph collection behind that type while gating vector-only operations on
93    /// the real kind it tracks separately (the Python
94    /// `Collection::ensure_vector` guard). The Mobile and Tauri bindings do
95    /// *not* use this view — they go through the variant-checked
96    /// [`AnyCollection::into_vector`](super::AnyCollection::into_vector) and
97    /// reject non-vector collections. Callers that need the graph surface must
98    /// use the graph API, not this view.
99    ///
100    /// [`MetadataCollection::into_vector_view`](super::MetadataCollection::into_vector_view)
101    /// is the exact mirror for metadata collections.
102    #[must_use]
103    pub fn into_vector_view(self) -> super::VectorCollection {
104        super::VectorCollection { inner: self.inner }
105    }
106
107    /// Flushes all state to disk.
108    ///
109    /// Issue #423: This fast-path flush skips `vectors.idx` serialization.
110    /// The WAL provides crash recovery for the vector index.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if any flush operation fails.
115    pub fn flush(&self) -> Result<()> {
116        self.inner.flush()
117    }
118
119    /// Full durability flush including `vectors.idx` serialization.
120    ///
121    /// Issue #423: Use on graceful shutdown to avoid a full WAL replay
122    /// on the next startup.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if any flush operation fails.
127    pub fn flush_full(&self) -> Result<()> {
128        self.inner.flush_full()
129    }
130
131    // -------------------------------------------------------------------------
132    // Metadata
133    // -------------------------------------------------------------------------
134
135    /// Returns the collection name.
136    #[must_use]
137    pub fn name(&self) -> String {
138        self.inner.config().name
139    }
140
141    /// Returns the graph schema stored in config.
142    ///
143    /// Returns `GraphSchema::schemaless()` for collections that have no schema set.
144    #[must_use]
145    pub fn schema(&self) -> GraphSchema {
146        self.inner
147            .graph_schema()
148            .unwrap_or_else(GraphSchema::schemaless)
149    }
150
151    /// Returns `true` if this collection stores node embeddings.
152    #[must_use]
153    pub fn has_embeddings(&self) -> bool {
154        self.inner.has_embeddings()
155    }
156
157    // -------------------------------------------------------------------------
158    // Graph operations — delegate to Collection graph API
159    // -------------------------------------------------------------------------
160
161    /// Adds an edge between two nodes.
162    ///
163    /// # Errors
164    ///
165    /// - Returns `Error::EdgeExists` if an edge with the same ID already exists.
166    ///
167    /// # Examples
168    ///
169    /// ```rust,no_run
170    /// # use velesdb_core::{GraphCollection, GraphSchema, GraphEdge, DistanceMetric};
171    /// # let coll = GraphCollection::create("./data/kg".into(), "kg", None, DistanceMetric::Cosine, GraphSchema::schemaless())?;
172    /// let edge = GraphEdge::new(1, 100, 200, "KNOWS")?;
173    /// coll.add_edge(edge)?;
174    /// # Ok::<(), velesdb_core::Error>(())
175    /// ```
176    pub fn add_edge(&self, edge: GraphEdge) -> Result<()> {
177        self.inner.add_edge(edge)
178    }
179
180    /// Adds multiple edges in batch (much faster than calling add_edge in a loop).
181    ///
182    /// Acquires locks once for the entire batch and rebuilds the CSR snapshot
183    /// once at the end. Duplicate edge IDs are silently skipped.
184    ///
185    /// # Returns
186    ///
187    /// Number of edges successfully added.
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if WAL durability logging fails for graph
192    /// collections (fail-closed: the in-memory store is not mutated).
193    pub fn add_edges_batch(&self, edges: Vec<GraphEdge>) -> Result<usize> {
194        self.inner.add_edges_batch(edges)
195    }
196
197    /// Returns edges, optionally filtered by label.
198    #[must_use]
199    pub fn get_edges(&self, label: Option<&str>) -> Vec<GraphEdge> {
200        match label {
201            Some(lbl) => self.inner.get_edges_by_label(lbl),
202            None => self.inner.get_all_edges(),
203        }
204    }
205
206    /// Returns all outgoing edges from a node.
207    #[must_use]
208    pub fn get_outgoing(&self, node_id: u64) -> Vec<GraphEdge> {
209        self.inner.get_outgoing_edges(node_id)
210    }
211
212    /// Returns all incoming edges to a node.
213    #[must_use]
214    pub fn get_incoming(&self, node_id: u64) -> Vec<GraphEdge> {
215        self.inner.get_incoming_edges(node_id)
216    }
217
218    /// Returns the total number of edges in the graph without materializing them.
219    #[must_use]
220    pub fn edge_count(&self) -> usize {
221        self.inner.edge_count()
222    }
223
224    /// Returns `(in_degree, out_degree)` for a node.
225    #[must_use]
226    pub fn node_degree(&self, node_id: u64) -> (usize, usize) {
227        self.inner.get_node_degree(node_id)
228    }
229
230    /// Returns the IDs of all nodes that have a stored payload.
231    ///
232    /// Nodes that appear only as edge endpoints without a stored payload
233    /// are not included. Use [`GraphCollection::get_edges`] to discover
234    /// all referenced node IDs.
235    #[must_use]
236    pub fn all_node_ids(&self) -> Vec<u64> {
237        self.inner.all_ids()
238    }
239
240    /// Returns the next batch of points for scroll iteration.
241    ///
242    /// Delegates to the inner collection's `scroll_batch` (parallel
243    /// implementation to [`VectorCollection::scroll_batch`](crate::VectorCollection::scroll_batch)).
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if `batch_size` is 0.
248    pub fn scroll_batch(
249        &self,
250        cursor: Option<u64>,
251        batch_size: usize,
252        filter: Option<&crate::filter::Filter>,
253    ) -> Result<crate::collection::ScrollBatch> {
254        self.inner.scroll_batch(cursor, batch_size, filter)
255    }
256
257    /// Returns the number of nodes (points) stored in this collection.
258    #[must_use]
259    pub fn len(&self) -> usize {
260        self.inner.len()
261    }
262
263    /// Returns `true` if the collection contains no nodes.
264    #[must_use]
265    pub fn is_empty(&self) -> bool {
266        self.inner.is_empty()
267    }
268
269    /// Retrieves nodes by IDs, returning `None` for missing entries.
270    #[must_use]
271    pub fn get(&self, ids: &[u64]) -> Vec<Option<Point>> {
272        self.inner.get(ids)
273    }
274
275    /// Deletes nodes by IDs.
276    ///
277    /// Missing IDs are silently ignored.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if storage operations fail.
282    pub fn delete(&self, ids: &[u64]) -> Result<()> {
283        self.inner.delete(ids)
284    }
285
286    /// Removes an edge from the graph by ID.
287    ///
288    /// Returns `true` if the edge existed and was removed, `false` otherwise —
289    /// including on the genuine failure paths. Use
290    /// [`Self::remove_edge_detailed`] when a failure must not pass for
291    /// "already gone".
292    #[must_use]
293    pub fn remove_edge(&self, edge_id: u64) -> bool {
294        self.inner.remove_edge(edge_id)
295    }
296
297    /// Removes an edge from the graph by ID, reporting WHY when it does not
298    /// happen.
299    pub(crate) fn remove_edge_detailed(&self, edge_id: u64) -> EdgeRemoval {
300        self.inner.remove_edge_detailed(edge_id)
301    }
302
303    /// Test-only fault injection — makes the edge write-ahead log unwritable so
304    /// edge removals fail while the edges themselves stay healthy.
305    #[cfg(all(test, feature = "persistence"))]
306    pub(crate) fn break_edge_wal_for_test(&self) -> std::io::Result<()> {
307        self.inner.break_edge_wal_for_test()
308    }
309
310    /// Returns `true` if an edge with `edge_id` exists in the graph.
311    #[must_use]
312    pub fn has_edge(&self, edge_id: u64) -> bool {
313        self.inner.edge_exists(edge_id)
314    }
315
316    /// Performs BFS traversal from a source node.
317    ///
318    /// # Examples
319    ///
320    /// ```rust,no_run
321    /// # use velesdb_core::{GraphCollection, GraphSchema, GraphEdge, DistanceMetric};
322    /// # use velesdb_core::collection::graph::TraversalConfig;
323    /// # let coll = GraphCollection::create("./data/kg".into(), "kg", None, DistanceMetric::Cosine, GraphSchema::schemaless())?;
324    /// let config = TraversalConfig { max_depth: 3, ..TraversalConfig::default() };
325    /// let results = coll.traverse_bfs(100, &config);
326    /// for r in &results {
327    ///     println!("node={} depth={}", r.target_id, r.depth);
328    /// }
329    /// # Ok::<(), velesdb_core::Error>(())
330    /// ```
331    #[must_use]
332    pub fn traverse_bfs(&self, source_id: u64, config: &TraversalConfig) -> Vec<TraversalResult> {
333        self.inner.traverse_bfs_config(source_id, config)
334    }
335
336    /// Performs DFS traversal from a source node.
337    #[must_use]
338    pub fn traverse_dfs(&self, source_id: u64, config: &TraversalConfig) -> Vec<TraversalResult> {
339        self.inner.traverse_dfs_config(source_id, config)
340    }
341
342    /// Performs parallel BFS traversal from multiple start nodes.
343    ///
344    /// When `start_nodes` exceeds the parallel threshold (100 nodes), rayon
345    /// distributes independent per-start-node BFS traversals across CPU cores.
346    /// Results are deduplicated by path signature and truncated to `config.limit`.
347    ///
348    /// # Examples
349    ///
350    /// ```rust,no_run
351    /// # use velesdb_core::{GraphCollection, GraphSchema, DistanceMetric};
352    /// # use velesdb_core::collection::graph::TraversalConfig;
353    /// # let coll = GraphCollection::create("./data/kg".into(), "kg", None, DistanceMetric::Cosine, GraphSchema::schemaless())?;
354    /// let config = TraversalConfig { max_depth: 3, ..TraversalConfig::default() };
355    /// let results = coll.traverse_bfs_parallel(&[100, 200, 300], &config);
356    /// for r in &results {
357    ///     println!("node={} depth={}", r.target_id, r.depth);
358    /// }
359    /// # Ok::<(), velesdb_core::Error>(())
360    /// ```
361    #[must_use]
362    pub fn traverse_bfs_parallel(
363        &self,
364        start_nodes: &[u64],
365        config: &TraversalConfig,
366    ) -> Vec<TraversalResult> {
367        self.inner.traverse_bfs_parallel(start_nodes, config)
368    }
369
370    // -------------------------------------------------------------------------
371    // Payload / node properties
372    // -------------------------------------------------------------------------
373
374    /// Inserts or updates node payload (properties).
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if storage fails.
379    pub fn upsert_node_payload(&self, node_id: u64, payload: &serde_json::Value) -> Result<()> {
380        self.inner.store_node_payload(node_id, payload)
381    }
382
383    /// Inserts or updates a node payload, optionally with an embedding vector.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error if storage fails, the vector dimension is invalid, or
388    /// an embedding is supplied for a graph collection without embeddings.
389    pub fn upsert_node(
390        &self,
391        node_id: u64,
392        payload: &serde_json::Value,
393        vector: Option<Vec<f32>>,
394    ) -> Result<()> {
395        match vector {
396            Some(vector) => self
397                .inner
398                .upsert([Point::new(node_id, vector, Some(payload.clone()))]),
399            None => self.upsert_node_payload(node_id, payload),
400        }
401    }
402
403    /// Inserts or updates node payload (properties).
404    ///
405    /// # Errors
406    ///
407    /// Returns an error if storage fails.
408    #[deprecated(since = "1.6.0", note = "Use upsert_node_payload() instead")]
409    pub fn store_node_payload(&self, node_id: u64, payload: &serde_json::Value) -> Result<()> {
410        self.upsert_node_payload(node_id, payload)
411    }
412
413    /// Retrieves node payload.
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if retrieval fails.
418    pub fn get_node_payload(&self, node_id: u64) -> Result<Option<serde_json::Value>> {
419        self.inner.get_node_payload(node_id)
420    }
421
422    // -------------------------------------------------------------------------
423    // Optional embedding search
424    // -------------------------------------------------------------------------
425
426    /// Searches for similar nodes by embedding (only available if `has_embeddings()`).
427    ///
428    /// # Errors
429    ///
430    /// Returns `Error::VectorNotAllowed` if this collection has no embeddings,
431    /// or `Error::DimensionMismatch` if the query dimension is wrong.
432    pub fn search_by_embedding(&self, query: &[f32], k: usize) -> Result<Vec<SearchResult>> {
433        self.inner.search_by_embedding(query, k)
434    }
435
436    /// Alias for [`search_by_embedding`](Self::search_by_embedding).
437    ///
438    /// Provided for API parity with [`crate::VectorCollection::search`].
439    ///
440    /// # Errors
441    ///
442    /// Returns `Error::VectorNotAllowed` if this collection has no embeddings,
443    /// or `Error::DimensionMismatch` if the query dimension is wrong.
444    pub fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchResult>> {
445        self.search_by_embedding(query, k)
446    }
447}
448
449#[cfg(test)]
450#[path = "graph_collection_tests.rs"]
451mod tests;