velesdb_core/collection/graph/edge.rs
1//! Graph edge types and storage for knowledge graph relationships.
2//!
3//! This module provides:
4//! - `GraphEdge`: A typed relationship between nodes with properties
5//! - `EdgeStore`: Bidirectional index for efficient edge traversal
6//!
7//! CSR snapshot types are in [`super::csr_snapshot`].
8//!
9//! # Edge Removal Semantics
10//!
11//! During edge removal, the internal indexes may be temporarily inconsistent
12//! while the operation is in progress. The final state is always consistent.
13//! For concurrent access, use `ConcurrentEdgeStore` instead.
14
15use super::csr_snapshot::CsrSnapshot;
16use crate::error::{Error, Result};
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::collections::HashMap;
20
21/// A directed edge (relationship) in the knowledge graph.
22///
23/// Edges connect nodes and can have a label (type) and properties.
24///
25/// # Example
26///
27/// ```rust,ignore
28/// use velesdb_core::collection::graph::GraphEdge;
29/// use serde_json::json;
30/// use std::collections::HashMap;
31///
32/// let mut props = HashMap::new();
33/// props.insert("since".to_string(), json!("2020-01-01"));
34///
35/// let edge = GraphEdge::new(1, 100, 200, "KNOWS")
36/// .with_properties(props);
37/// ```
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct GraphEdge {
40 id: u64,
41 source: u64,
42 target: u64,
43 label: String,
44 properties: HashMap<String, Value>,
45}
46
47impl GraphEdge {
48 /// Creates a new edge with the given ID, endpoints, and label.
49 ///
50 /// # Errors
51 ///
52 /// Returns `Error::InvalidEdgeLabel` if the label is empty or whitespace-only.
53 pub fn new(id: u64, source: u64, target: u64, label: &str) -> Result<Self> {
54 let trimmed = label.trim();
55 if trimmed.is_empty() {
56 return Err(Error::InvalidEdgeLabel(
57 "Edge label cannot be empty or whitespace-only".to_string(),
58 ));
59 }
60 Ok(Self {
61 id,
62 source,
63 target,
64 label: trimmed.to_string(),
65 properties: HashMap::new(),
66 })
67 }
68
69 /// Adds properties to this edge (builder pattern).
70 #[must_use]
71 pub fn with_properties(mut self, properties: HashMap<String, Value>) -> Self {
72 self.properties = properties;
73 self
74 }
75
76 /// Returns the edge ID.
77 #[must_use]
78 pub fn id(&self) -> u64 {
79 self.id
80 }
81
82 /// Returns the source node ID.
83 #[must_use]
84 pub fn source(&self) -> u64 {
85 self.source
86 }
87
88 /// Returns the target node ID.
89 #[must_use]
90 pub fn target(&self) -> u64 {
91 self.target
92 }
93
94 /// Returns the edge label (relationship type).
95 #[must_use]
96 pub fn label(&self) -> &str {
97 &self.label
98 }
99
100 /// Returns all properties of this edge.
101 #[must_use]
102 pub fn properties(&self) -> &HashMap<String, Value> {
103 &self.properties
104 }
105
106 /// Returns a specific property value, if it exists.
107 #[must_use]
108 pub fn property(&self, name: &str) -> Option<&Value> {
109 self.properties.get(name)
110 }
111}
112
113/// Storage for graph edges with bidirectional indexing.
114///
115/// Provides O(1) access to edges by ID and O(degree) access to
116/// outgoing/incoming edges for any node.
117///
118/// # Index Structure (EPIC-019 US-003)
119///
120/// - `by_label`: Secondary index for O(k) label-based queries
121/// - `outgoing_by_label`: Composite index (source, label) for O(k) filtered traversal
122///
123/// # CSR Snapshot (G1)
124///
125/// After loading from disk or after explicit `build_read_snapshot()`, the
126/// `csr_snapshot` field provides zero-copy `&[u64]` access to neighbor
127/// target IDs and edge IDs. Writes invalidate the snapshot automatically.
128#[derive(Debug, Default, Serialize, Deserialize)]
129pub struct EdgeStore {
130 /// All edges indexed by ID
131 pub(super) edges: HashMap<u64, GraphEdge>,
132 /// Outgoing edges: source_id -> Vec<edge_id>
133 pub(super) outgoing: HashMap<u64, Vec<u64>>,
134 /// Incoming edges: target_id -> Vec<edge_id>
135 pub(super) incoming: HashMap<u64, Vec<u64>>,
136 /// Secondary index: label -> Vec<edge_id> for fast label queries
137 pub(super) by_label: HashMap<String, Vec<u64>>,
138 /// Composite index: (source_id, label) -> Vec<edge_id> for fast filtered traversal
139 pub(super) outgoing_by_label: HashMap<(u64, String), Vec<u64>>,
140 /// Composite index: (target_id, label) -> Vec<edge_id> — the incoming
141 /// mirror of `outgoing_by_label`, so `<-[:TYPE]-` patterns stop paying
142 /// O(in-degree) full-edge clones on super-nodes.
143 ///
144 /// `serde(skip)`: the snapshot format is postcard (not self-describing),
145 /// so a new serialized field would break every existing edge_store.bin.
146 /// The index is fully derivable and rebuilt in `load_from_file`.
147 #[serde(skip)]
148 pub(super) incoming_by_label: HashMap<(u64, String), Vec<u64>>,
149 /// Zero-copy CSR snapshot for BFS traversal (G1).
150 /// Built on-demand via `build_read_snapshot()`, invalidated by writes.
151 #[serde(skip)]
152 pub(super) csr_snapshot: Option<CsrSnapshot>,
153}
154
155impl EdgeStore {
156 /// Creates a new empty edge store.
157 #[must_use]
158 pub fn new() -> Self {
159 Self::default()
160 }
161
162 /// Creates an edge store with pre-allocated capacity for better performance.
163 ///
164 /// Pre-allocating reduces memory reallocation overhead when inserting many edges.
165 /// With 10M edges, this can reduce peak memory usage by ~2x and improve insert throughput.
166 ///
167 /// Note: when accessed through the sharded `ConcurrentEdgeStore`, an edge
168 /// whose endpoints hash to different shards is stored in **both** shards
169 /// (outgoing + incoming halves), which offsets part of that saving — with
170 /// many shards this applies to nearly all edges.
171 ///
172 /// # Arguments
173 ///
174 /// * `expected_edges` - Expected number of edges to store
175 /// * `expected_nodes` - Expected number of unique nodes (sources + targets)
176 ///
177 /// # Example
178 ///
179 /// ```rust,ignore
180 /// // For a graph with ~1M edges and ~100K nodes
181 /// let store = EdgeStore::with_capacity(1_000_000, 100_000);
182 /// ```
183 #[must_use]
184 pub fn with_capacity(expected_edges: usize, expected_nodes: usize) -> Self {
185 // Estimate ~10 unique labels typical for knowledge graphs
186 let expected_labels = 10usize;
187 // Use saturating_mul to prevent overflow for extreme inputs
188 let outgoing_by_label_cap = expected_nodes
189 .saturating_mul(expected_labels)
190 .saturating_div(10);
191 Self {
192 edges: HashMap::with_capacity(expected_edges),
193 outgoing: HashMap::with_capacity(expected_nodes),
194 incoming: HashMap::with_capacity(expected_nodes),
195 by_label: HashMap::with_capacity(expected_labels),
196 outgoing_by_label: HashMap::with_capacity(outgoing_by_label_cap),
197 incoming_by_label: HashMap::with_capacity(outgoing_by_label_cap),
198 csr_snapshot: None,
199 }
200 }
201
202 /// Adds an edge to the store.
203 ///
204 /// Creates bidirectional index entries for efficient traversal.
205 /// Also maintains label-based secondary indices (EPIC-019 US-003).
206 ///
207 /// # Errors
208 ///
209 /// Returns `Error::EdgeExists` if an edge with the same ID already exists.
210 pub fn add_edge(&mut self, edge: GraphEdge) -> Result<()> {
211 self.insert_edge(edge, true, true)
212 }
213
214 /// Adds an edge with only the outgoing index (for cross-shard storage).
215 ///
216 /// Used by `ConcurrentEdgeStore` when source and target are in different shards.
217 /// The edge is stored and indexed by source node only.
218 ///
219 /// # Errors
220 ///
221 /// Returns `Error::EdgeExists` if an edge with the same ID already exists.
222 pub fn add_edge_outgoing_only(&mut self, edge: GraphEdge) -> Result<()> {
223 self.insert_edge(edge, true, false)
224 }
225
226 /// Adds an edge with only the incoming index (for cross-shard storage).
227 ///
228 /// Used by `ConcurrentEdgeStore` when source and target are in different shards.
229 /// The edge is stored and indexed by target node only.
230 /// Note: Label indices are maintained by the source shard in `ConcurrentEdgeStore`.
231 ///
232 /// # Errors
233 ///
234 /// Returns `Error::EdgeExists` if an edge with the same ID already exists.
235 pub fn add_edge_incoming_only(&mut self, edge: GraphEdge) -> Result<()> {
236 self.insert_edge(edge, false, true)
237 }
238
239 /// Shared implementation for all `add_edge*` variants.
240 ///
241 /// Validates uniqueness, populates the requested directional indices,
242 /// and stores the edge. Label indices (`by_label`, `outgoing_by_label`)
243 /// are maintained only when `index_outgoing` is `true` (source shard
244 /// owns label indices in the concurrent model).
245 fn insert_edge(
246 &mut self,
247 edge: GraphEdge,
248 index_outgoing: bool,
249 index_incoming: bool,
250 ) -> Result<()> {
251 let id = edge.id();
252 if self.edges.contains_key(&id) {
253 return Err(Error::EdgeExists(id));
254 }
255
256 if index_outgoing {
257 let source = edge.source();
258 let label = edge.label().to_string();
259 self.outgoing.entry(source).or_default().push(id);
260 // Label indices are owned by the source shard (US-003)
261 self.by_label.entry(label.clone()).or_default().push(id);
262 self.outgoing_by_label
263 .entry((source, label))
264 .or_default()
265 .push(id);
266 }
267
268 if index_incoming {
269 let target = edge.target();
270 self.incoming.entry(target).or_default().push(id);
271 self.incoming_by_label
272 .entry((target, edge.label().to_string()))
273 .or_default()
274 .push(id);
275 }
276
277 self.edges.insert(id, edge);
278 // Invalidate CSR snapshot — writes make it stale (G1).
279 self.csr_snapshot = None;
280 Ok(())
281 }
282
283 /// Returns the total number of edges in the store.
284 #[must_use]
285 pub fn edge_count(&self) -> usize {
286 self.edges.len()
287 }
288
289 /// Returns the count of edges where this shard is the source (for accurate cross-shard counting).
290 #[must_use]
291 pub fn outgoing_edge_count(&self) -> usize {
292 self.outgoing.values().map(Vec::len).sum()
293 }
294
295 /// Gets an edge by its ID.
296 #[must_use]
297 pub fn get_edge(&self, id: u64) -> Option<&GraphEdge> {
298 self.edges.get(&id)
299 }
300
301 /// Gets all outgoing edges from a node.
302 #[must_use]
303 pub fn get_outgoing(&self, node_id: u64) -> Vec<&GraphEdge> {
304 self.resolve_edge_ids(self.outgoing.get(&node_id))
305 }
306
307 /// Invokes `f` for each outgoing edge from `node_id` without allocating a `Vec`.
308 ///
309 /// Prefer this over [`get_outgoing`](Self::get_outgoing) in hot loops (e.g. BFS
310 /// frontiers) where the caller processes edges inline rather than collecting them.
311 #[inline]
312 pub fn for_each_outgoing<F: FnMut(&GraphEdge)>(&self, node_id: u64, mut f: F) {
313 if let Some(ids) = self.outgoing.get(&node_id) {
314 for id in ids {
315 if let Some(edge) = self.edges.get(id) {
316 f(edge);
317 }
318 }
319 }
320 }
321
322 /// Returns the number of outgoing edges from `node_id` without materializing them.
323 #[must_use]
324 #[inline]
325 pub fn outgoing_degree(&self, node_id: u64) -> usize {
326 self.outgoing.get(&node_id).map_or(0, Vec::len)
327 }
328
329 /// Returns the number of incoming edges to `node_id` without materializing them.
330 #[must_use]
331 #[inline]
332 pub fn incoming_degree(&self, node_id: u64) -> usize {
333 self.incoming.get(&node_id).map_or(0, Vec::len)
334 }
335
336 /// Gets all incoming edges to a node.
337 #[must_use]
338 pub fn get_incoming(&self, node_id: u64) -> Vec<&GraphEdge> {
339 self.resolve_edge_ids(self.incoming.get(&node_id))
340 }
341
342 /// Gets at most `cap` outgoing edges from a node.
343 ///
344 /// The bound is applied to the index BEFORE any edge is resolved, so the
345 /// work and the allocation are O(cap) — never O(degree). This is what
346 /// makes reading a super-node affordable: [`Self::get_outgoing`] on a
347 /// million-edge node materializes a million entries even when the caller
348 /// keeps only the first 64 (#1820).
349 #[must_use]
350 pub fn get_outgoing_bounded(&self, node_id: u64, cap: usize) -> Vec<&GraphEdge> {
351 self.resolve_edge_ids_bounded(self.outgoing.get(&node_id), cap)
352 }
353
354 /// Gets at most `cap` incoming edges to a node — the mirror of
355 /// [`Self::get_outgoing_bounded`], with the same O(cap) guarantee.
356 #[must_use]
357 pub fn get_incoming_bounded(&self, node_id: u64, cap: usize) -> Vec<&GraphEdge> {
358 self.resolve_edge_ids_bounded(self.incoming.get(&node_id), cap)
359 }
360
361 /// [`Self::resolve_edge_ids`] with the id list truncated FIRST — the cap
362 /// bounds the scan itself, not just the result. A dangling id inside the
363 /// scanned window (defensively skipped, as in the unbounded resolver) is
364 /// not replaced by scanning further: the O(cap) guarantee outranks
365 /// returning exactly `cap` entries.
366 #[inline]
367 fn resolve_edge_ids_bounded(&self, ids: Option<&Vec<u64>>, cap: usize) -> Vec<&GraphEdge> {
368 ids.map(|ids| {
369 ids.iter()
370 .take(cap)
371 .filter_map(|id| self.edges.get(id))
372 .collect()
373 })
374 .unwrap_or_default()
375 }
376
377 /// Gets outgoing edges filtered by label using composite index - O(k) where k = result count.
378 ///
379 /// Uses the `outgoing_by_label` composite index for fast lookup instead of
380 /// iterating through all outgoing edges (EPIC-019 US-003).
381 #[must_use]
382 pub fn get_outgoing_by_label(&self, node_id: u64, label: &str) -> Vec<&GraphEdge> {
383 self.resolve_edge_ids(self.outgoing_by_label.get(&(node_id, label.to_string())))
384 }
385
386 /// Gets all edges with a specific label - O(k) where k = result count.
387 ///
388 /// Uses the `by_label` secondary index for fast lookup (EPIC-019 US-003).
389 #[must_use]
390 pub fn get_edges_by_label(&self, label: &str) -> Vec<&GraphEdge> {
391 self.resolve_edge_ids(self.by_label.get(label))
392 }
393
394 /// Resolves edge IDs from an index entry into edge references.
395 ///
396 /// Shared lookup pattern used by `get_outgoing`, `get_incoming`,
397 /// `get_outgoing_by_label`, and `get_edges_by_label`.
398 #[inline]
399 fn resolve_edge_ids(&self, ids: Option<&Vec<u64>>) -> Vec<&GraphEdge> {
400 ids.map(|ids| ids.iter().filter_map(|id| self.edges.get(id)).collect())
401 .unwrap_or_default()
402 }
403
404 /// Gets incoming edges filtered by label.
405 #[must_use]
406 pub fn get_incoming_by_label(&self, node_id: u64, label: &str) -> Vec<&GraphEdge> {
407 self.resolve_edge_ids(self.incoming_by_label.get(&(node_id, label.to_string())))
408 }
409
410 /// Rebuilds the (unserialized) `incoming_by_label` index from the live
411 /// edges — called after loading a postcard snapshot.
412 pub(super) fn rebuild_incoming_label_index(&mut self) {
413 self.incoming_by_label.clear();
414 for ids in self.incoming.values() {
415 for &id in ids {
416 if let Some(edge) = self.edges.get(&id) {
417 self.incoming_by_label
418 .entry((edge.target(), edge.label().to_string()))
419 .or_default()
420 .push(id);
421 }
422 }
423 }
424 }
425
426 /// Checks if an edge with the given ID exists.
427 #[must_use]
428 pub fn contains_edge(&self, edge_id: u64) -> bool {
429 self.edges.contains_key(&edge_id)
430 }
431
432 /// Returns the number of edges in the store.
433 #[must_use]
434 pub fn len(&self) -> usize {
435 self.edges.len()
436 }
437
438 /// Returns true if the store contains no edges.
439 #[must_use]
440 pub fn is_empty(&self) -> bool {
441 self.edges.is_empty()
442 }
443
444 /// Returns all edges in the store.
445 #[must_use]
446 pub fn all_edges(&self) -> Vec<&GraphEdge> {
447 self.edges.values().collect()
448 }
449
450 /// Returns all outgoing source node IDs (keys of the outgoing index).
451 ///
452 /// Used by [`SnapshotBuilder`](super::csr_snapshot::SnapshotBuilder) to
453 /// enumerate source nodes for CSR construction.
454 #[must_use]
455 pub(crate) fn outgoing_keys(&self) -> Vec<u64> {
456 self.outgoing.keys().copied().collect()
457 }
458
459 /// Returns the total number of outgoing edge entries across all nodes.
460 ///
461 /// Used by [`SnapshotBuilder`](super::csr_snapshot::SnapshotBuilder) for
462 /// pre-allocation.
463 #[must_use]
464 pub(crate) fn total_outgoing_edges(&self) -> usize {
465 self.outgoing.values().map(Vec::len).sum()
466 }
467
468 /// Invokes `f` for each outgoing edge from `node_id` (by edge object).
469 ///
470 /// Used by [`SnapshotBuilder`](super::csr_snapshot::SnapshotBuilder) to
471 /// iterate edges without exposing internal index structure.
472 pub(crate) fn for_each_outgoing_edge<F: FnMut(&GraphEdge)>(&self, node_id: u64, mut f: F) {
473 if let Some(ids) = self.outgoing.get(&node_id) {
474 for id in ids {
475 if let Some(edge) = self.edges.get(id) {
476 f(edge);
477 }
478 }
479 }
480 }
481}
482
483// Edge removal operations are in `edge_removal.rs`.
484// CSR snapshot methods and persistence are in `edge_persistence.rs`.