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;