Skip to main content

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 super::label_table::{LabelId, LabelTable};
17use crate::error::{Error, Result};
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::collections::HashMap;
21
22/// A directed edge (relationship) in the knowledge graph.
23///
24/// Edges connect nodes and can have a label (type) and properties.
25///
26/// # Example
27///
28/// ```rust,ignore
29/// use velesdb_core::collection::graph::GraphEdge;
30/// use serde_json::json;
31/// use std::collections::HashMap;
32///
33/// let mut props = HashMap::new();
34/// props.insert("since".to_string(), json!("2020-01-01"));
35///
36/// let edge = GraphEdge::new(1, 100, 200, "KNOWS")
37///     .with_properties(props);
38/// ```
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct GraphEdge {
41    id: u64,
42    source: u64,
43    target: u64,
44    label: String,
45    properties: HashMap<String, Value>,
46}
47
48impl GraphEdge {
49    /// Creates a new edge with the given ID, endpoints, and label.
50    ///
51    /// # Errors
52    ///
53    /// Returns `Error::InvalidEdgeLabel` if the label is empty or whitespace-only.
54    pub fn new(id: u64, source: u64, target: u64, label: &str) -> Result<Self> {
55        let trimmed = label.trim();
56        if trimmed.is_empty() {
57            return Err(Error::InvalidEdgeLabel(
58                "Edge label cannot be empty or whitespace-only".to_string(),
59            ));
60        }
61        Ok(Self {
62            id,
63            source,
64            target,
65            label: trimmed.to_string(),
66            properties: HashMap::new(),
67        })
68    }
69
70    /// Adds properties to this edge (builder pattern).
71    #[must_use]
72    pub fn with_properties(mut self, properties: HashMap<String, Value>) -> Self {
73        self.properties = properties;
74        self
75    }
76
77    /// Returns the edge ID.
78    #[must_use]
79    pub fn id(&self) -> u64 {
80        self.id
81    }
82
83    /// Returns the source node ID.
84    #[must_use]
85    pub fn source(&self) -> u64 {
86        self.source
87    }
88
89    /// Returns the target node ID.
90    #[must_use]
91    pub fn target(&self) -> u64 {
92        self.target
93    }
94
95    /// Returns the edge label (relationship type).
96    #[must_use]
97    pub fn label(&self) -> &str {
98        &self.label
99    }
100
101    /// Returns all properties of this edge.
102    #[must_use]
103    pub fn properties(&self) -> &HashMap<String, Value> {
104        &self.properties
105    }
106
107    /// Returns a specific property value, if it exists.
108    #[must_use]
109    pub fn property(&self, name: &str) -> Option<&Value> {
110        self.properties.get(name)
111    }
112}
113
114/// Storage for graph edges with bidirectional indexing.
115///
116/// Provides O(1) access to edges by ID and O(degree) access to
117/// outgoing/incoming edges for any node.
118///
119/// # Index Structure (EPIC-019 US-003)
120///
121/// - `by_label`: Secondary index for O(k) label-based queries
122/// - `outgoing_by_label`: Composite index (source, label) for O(k) filtered traversal
123///
124/// # CSR Snapshot (G1)
125///
126/// After loading from disk or after explicit `build_read_snapshot()`, the
127/// `csr_snapshot` field provides zero-copy `&[u64]` access to neighbor
128/// target IDs and edge IDs. Writes invalidate the snapshot automatically.
129#[derive(Debug, Default, Serialize, Deserialize)]
130pub struct EdgeStore {
131    /// All edges indexed by ID
132    pub(super) edges: HashMap<u64, GraphEdge>,
133    /// Outgoing edges: source_id -> Vec<edge_id>
134    pub(super) outgoing: HashMap<u64, Vec<u64>>,
135    /// Incoming edges: target_id -> Vec<edge_id>
136    pub(super) incoming: HashMap<u64, Vec<u64>>,
137    /// Secondary index: interned label -> Vec<edge_id> for fast label queries.
138    ///
139    /// `serde(skip)`: keyed by [`LabelId`], which is only meaningful against
140    /// this store's `label_table` — both are rebuilt in `load_from_file`
141    /// (#2089). Old snapshots that still serialize the String-keyed maps
142    /// load fine: postcard's `from_bytes` ignores trailing bytes.
143    #[serde(skip)]
144    pub(super) by_label: HashMap<LabelId, Vec<u64>>,
145    /// Composite index: (source_id, label) -> Vec<edge_id> for fast filtered
146    /// traversal. `serde(skip)`: same rebuild contract as `by_label`.
147    #[serde(skip)]
148    pub(super) outgoing_by_label: HashMap<(u64, LabelId), Vec<u64>>,
149    /// Composite index: (target_id, label) -> Vec<edge_id> — the incoming
150    /// mirror of `outgoing_by_label`, so `<-[:TYPE]-` patterns stop paying
151    /// O(in-degree) full-edge clones on super-nodes.
152    ///
153    /// `serde(skip)`: the snapshot format is postcard (not self-describing),
154    /// so a new serialized field would break every existing edge_store.bin.
155    /// The index is fully derivable and rebuilt in `load_from_file`.
156    #[serde(skip)]
157    pub(super) incoming_by_label: HashMap<(u64, LabelId), Vec<u64>>,
158    /// Interning table mapping label strings to the [`LabelId`]s that key the
159    /// three label indices above. Populated by `insert_edge`, rebuilt with
160    /// the indices in `load_from_file` (#2089 — wires the table the doc
161    /// comment always promised; labels are no longer stored per edge entry).
162    #[serde(skip)]
163    pub(super) label_table: LabelTable,
164    /// Zero-copy CSR snapshot for BFS traversal (G1).
165    /// Built on-demand via `build_read_snapshot()`, invalidated by writes.
166    #[serde(skip)]
167    pub(super) csr_snapshot: Option<CsrSnapshot>,
168}
169
170impl EdgeStore {
171    /// Creates a new empty edge store.
172    #[must_use]
173    pub fn new() -> Self {
174        Self::default()
175    }
176
177    /// Creates an edge store with pre-allocated capacity for better performance.
178    ///
179    /// Pre-allocating reduces memory reallocation overhead when inserting many edges.
180    /// With 10M edges, this can reduce peak memory usage by ~2x and improve insert throughput.
181    ///
182    /// Note: when accessed through the sharded `ConcurrentEdgeStore`, an edge
183    /// whose endpoints hash to different shards is stored in **both** shards
184    /// (outgoing + incoming halves), which offsets part of that saving — with
185    /// many shards this applies to nearly all edges.
186    ///
187    /// # Arguments
188    ///
189    /// * `expected_edges` - Expected number of edges to store
190    /// * `expected_nodes` - Expected number of unique nodes (sources + targets)
191    ///
192    /// # Example
193    ///
194    /// ```rust,ignore
195    /// // For a graph with ~1M edges and ~100K nodes
196    /// let store = EdgeStore::with_capacity(1_000_000, 100_000);
197    /// ```
198    #[must_use]
199    pub fn with_capacity(expected_edges: usize, expected_nodes: usize) -> Self {
200        // Estimate ~10 unique labels typical for knowledge graphs
201        let expected_labels = 10usize;
202        // Use saturating_mul to prevent overflow for extreme inputs
203        let outgoing_by_label_cap = expected_nodes
204            .saturating_mul(expected_labels)
205            .saturating_div(10);
206        Self {
207            edges: HashMap::with_capacity(expected_edges),
208            outgoing: HashMap::with_capacity(expected_nodes),
209            incoming: HashMap::with_capacity(expected_nodes),
210            by_label: HashMap::with_capacity(expected_labels),
211            outgoing_by_label: HashMap::with_capacity(outgoing_by_label_cap),
212            incoming_by_label: HashMap::with_capacity(outgoing_by_label_cap),
213            label_table: LabelTable::with_capacity(expected_labels),
214            csr_snapshot: None,
215        }
216    }
217
218    /// Adds an edge to the store.
219    ///
220    /// Creates bidirectional index entries for efficient traversal.
221    /// Also maintains label-based secondary indices (EPIC-019 US-003).
222    ///
223    /// # Errors
224    ///
225    /// Returns `Error::EdgeExists` if an edge with the same ID already exists.
226    pub fn add_edge(&mut self, edge: GraphEdge) -> Result<()> {
227        self.insert_edge(edge, true, true)
228    }
229
230    /// Adds an edge with only the outgoing index (for cross-shard storage).
231    ///
232    /// Used by `ConcurrentEdgeStore` when source and target are in different shards.
233    /// The edge is stored and indexed by source node only.
234    ///
235    /// # Errors
236    ///
237    /// Returns `Error::EdgeExists` if an edge with the same ID already exists.
238    pub fn add_edge_outgoing_only(&mut self, edge: GraphEdge) -> Result<()> {
239        self.insert_edge(edge, true, false)
240    }
241
242    /// Adds an edge with only the incoming index (for cross-shard storage).
243    ///
244    /// Used by `ConcurrentEdgeStore` when source and target are in different shards.
245    /// The edge is stored and indexed by target node only.
246    /// Note: Label indices are maintained by the source shard in `ConcurrentEdgeStore`.
247    ///
248    /// # Errors
249    ///
250    /// Returns `Error::EdgeExists` if an edge with the same ID already exists.
251    pub fn add_edge_incoming_only(&mut self, edge: GraphEdge) -> Result<()> {
252        self.insert_edge(edge, false, true)
253    }
254
255    /// Shared implementation for all `add_edge*` variants.
256    ///
257    /// Validates uniqueness, populates the requested directional indices,
258    /// and stores the edge. Label indices (`by_label`, `outgoing_by_label`)
259    /// are maintained only when `index_outgoing` is `true` (source shard
260    /// owns label indices in the concurrent model).
261    fn insert_edge(
262        &mut self,
263        edge: GraphEdge,
264        index_outgoing: bool,
265        index_incoming: bool,
266    ) -> Result<()> {
267        let id = edge.id();
268        if self.edges.contains_key(&id) {
269            return Err(Error::EdgeExists(id));
270        }
271
272        // One interning lookup per insert; no per-edge label allocation (#2089).
273        let label_id = self.intern_label(edge.label())?;
274
275        if index_outgoing {
276            let source = edge.source();
277            self.outgoing.entry(source).or_default().push(id);
278            // Label indices are owned by the source shard (US-003)
279            self.by_label.entry(label_id).or_default().push(id);
280            self.outgoing_by_label
281                .entry((source, label_id))
282                .or_default()
283                .push(id);
284        }
285
286        if index_incoming {
287            let target = edge.target();
288            self.incoming.entry(target).or_default().push(id);
289            self.incoming_by_label
290                .entry((target, label_id))
291                .or_default()
292                .push(id);
293        }
294
295        self.edges.insert(id, edge);
296        // Invalidate CSR snapshot — writes make it stale (G1).
297        self.csr_snapshot = None;
298        Ok(())
299    }
300
301    /// Returns the total number of edges in the store.
302    #[must_use]
303    pub fn edge_count(&self) -> usize {
304        self.edges.len()
305    }
306
307    /// Returns the count of edges where this shard is the source (for accurate cross-shard counting).
308    #[must_use]
309    pub fn outgoing_edge_count(&self) -> usize {
310        self.outgoing.values().map(Vec::len).sum()
311    }
312
313    /// Gets an edge by its ID.
314    #[must_use]
315    pub fn get_edge(&self, id: u64) -> Option<&GraphEdge> {
316        self.edges.get(&id)
317    }
318
319    /// Gets all outgoing edges from a node.
320    #[must_use]
321    pub fn get_outgoing(&self, node_id: u64) -> Vec<&GraphEdge> {
322        self.resolve_edge_ids(self.outgoing.get(&node_id))
323    }
324
325    /// Invokes `f` for each outgoing edge from `node_id` without allocating a `Vec`.
326    ///
327    /// Prefer this over [`get_outgoing`](Self::get_outgoing) in hot loops (e.g. BFS
328    /// frontiers) where the caller processes edges inline rather than collecting them.
329    #[inline]
330    pub fn for_each_outgoing<F: FnMut(&GraphEdge)>(&self, node_id: u64, mut f: F) {
331        if let Some(ids) = self.outgoing.get(&node_id) {
332            for id in ids {
333                if let Some(edge) = self.edges.get(id) {
334                    f(edge);
335                }
336            }
337        }
338    }
339
340    /// Returns the number of outgoing edges from `node_id` without materializing them.
341    #[must_use]
342    #[inline]
343    pub fn outgoing_degree(&self, node_id: u64) -> usize {
344        self.outgoing.get(&node_id).map_or(0, Vec::len)
345    }
346
347    /// Returns the number of incoming edges to `node_id` without materializing them.
348    #[must_use]
349    #[inline]
350    pub fn incoming_degree(&self, node_id: u64) -> usize {
351        self.incoming.get(&node_id).map_or(0, Vec::len)
352    }
353
354    /// Gets all incoming edges to a node.
355    #[must_use]
356    pub fn get_incoming(&self, node_id: u64) -> Vec<&GraphEdge> {
357        self.resolve_edge_ids(self.incoming.get(&node_id))
358    }
359
360    /// Gets at most `cap` outgoing edges from a node.
361    ///
362    /// The bound is applied to the index BEFORE any edge is resolved, so the
363    /// work and the allocation are O(cap) — never O(degree). This is what
364    /// makes reading a super-node affordable: [`Self::get_outgoing`] on a
365    /// million-edge node materializes a million entries even when the caller
366    /// keeps only the first 64 (#1820).
367    #[must_use]
368    pub fn get_outgoing_bounded(&self, node_id: u64, cap: usize) -> Vec<&GraphEdge> {
369        self.resolve_edge_ids_bounded(self.outgoing.get(&node_id), cap)
370    }
371
372    /// Gets at most `cap` incoming edges to a node — the mirror of
373    /// [`Self::get_outgoing_bounded`], with the same O(cap) guarantee.
374    #[must_use]
375    pub fn get_incoming_bounded(&self, node_id: u64, cap: usize) -> Vec<&GraphEdge> {
376        self.resolve_edge_ids_bounded(self.incoming.get(&node_id), cap)
377    }
378
379    /// [`Self::resolve_edge_ids`] with the id list truncated FIRST — the cap
380    /// bounds the scan itself, not just the result. A dangling id inside the
381    /// scanned window (defensively skipped, as in the unbounded resolver) is
382    /// not replaced by scanning further: the O(cap) guarantee outranks
383    /// returning exactly `cap` entries.
384    #[inline]
385    fn resolve_edge_ids_bounded(&self, ids: Option<&Vec<u64>>, cap: usize) -> Vec<&GraphEdge> {
386        ids.map(|ids| {
387            ids.iter()
388                .take(cap)
389                .filter_map(|id| self.edges.get(id))
390                .collect()
391        })
392        .unwrap_or_default()
393    }
394
395    /// Gets outgoing edges filtered by label using composite index - O(k) where k = result count.
396    ///
397    /// Uses the `outgoing_by_label` composite index for fast lookup instead of
398    /// iterating through all outgoing edges (EPIC-019 US-003).
399    #[must_use]
400    pub fn get_outgoing_by_label(&self, node_id: u64, label: &str) -> Vec<&GraphEdge> {
401        let Some(label_id) = self.label_table.get_id(label) else {
402            return Vec::new();
403        };
404        self.resolve_edge_ids(self.outgoing_by_label.get(&(node_id, label_id)))
405    }
406
407    /// Gets all edges with a specific label - O(k) where k = result count.
408    ///
409    /// Uses the `by_label` secondary index for fast lookup (EPIC-019 US-003).
410    #[must_use]
411    pub fn get_edges_by_label(&self, label: &str) -> Vec<&GraphEdge> {
412        let Some(label_id) = self.label_table.get_id(label) else {
413            return Vec::new();
414        };
415        self.resolve_edge_ids(self.by_label.get(&label_id))
416    }
417
418    /// Resolves edge IDs from an index entry into edge references.
419    ///
420    /// Shared lookup pattern used by `get_outgoing`, `get_incoming`,
421    /// `get_outgoing_by_label`, and `get_edges_by_label`.
422    #[inline]
423    fn resolve_edge_ids(&self, ids: Option<&Vec<u64>>) -> Vec<&GraphEdge> {
424        ids.map(|ids| ids.iter().filter_map(|id| self.edges.get(id)).collect())
425            .unwrap_or_default()
426    }
427
428    /// Gets incoming edges filtered by label.
429    #[must_use]
430    pub fn get_incoming_by_label(&self, node_id: u64, label: &str) -> Vec<&GraphEdge> {
431        let Some(label_id) = self.label_table.get_id(label) else {
432            return Vec::new();
433        };
434        self.resolve_edge_ids(self.incoming_by_label.get(&(node_id, label_id)))
435    }
436
437    /// Interns `label` in this store's table, mapping the (practically
438    /// unreachable) `u32::MAX`-distinct-labels overflow to [`Error::Overflow`].
439    fn intern_label(&mut self, label: &str) -> Result<LabelId> {
440        intern_or_overflow(&mut self.label_table, label)
441    }
442
443    /// Rebuilds the (unserialized) label table and the three label indices
444    /// from the live edges — called after loading a postcard snapshot.
445    ///
446    /// Interning cannot overflow here: every label was already interned once
447    /// when its edge was first inserted, so the vocabulary fits `u32` by
448    /// construction; an overflow is reported all the same rather than
449    /// silently dropping index entries.
450    ///
451    /// # Errors
452    ///
453    /// Returns [`Error::Overflow`] if the number of distinct labels exceeds
454    /// the `u32` domain of [`LabelId`].
455    pub(super) fn rebuild_label_indexes(&mut self) -> Result<()> {
456        // Destructured so the borrow checker sees the disjoint fields: the
457        // edge/index maps are read while the table and label maps are written.
458        let Self {
459            edges,
460            outgoing,
461            incoming,
462            by_label,
463            outgoing_by_label,
464            incoming_by_label,
465            label_table,
466            ..
467        } = self;
468        by_label.clear();
469        outgoing_by_label.clear();
470        incoming_by_label.clear();
471
472        // Iterate nodes in sorted order (same discipline as SnapshotBuilder):
473        // HashMap iteration order is randomized per instance, so an unsorted
474        // walk would hand every reload a different by-label result order.
475        for &node in &sorted_keys(outgoing) {
476            for &id in &outgoing[&node] {
477                if let Some(edge) = edges.get(&id) {
478                    let label_id = intern_or_overflow(label_table, edge.label())?;
479                    by_label.entry(label_id).or_default().push(id);
480                    outgoing_by_label
481                        .entry((edge.source(), label_id))
482                        .or_default()
483                        .push(id);
484                }
485            }
486        }
487        rebuild_incoming_labels(edges, incoming, incoming_by_label, label_table)
488    }
489
490    /// Checks if an edge with the given ID exists.
491    #[must_use]
492    pub fn contains_edge(&self, edge_id: u64) -> bool {
493        self.edges.contains_key(&edge_id)
494    }
495
496    /// Returns the number of edges in the store.
497    #[must_use]
498    pub fn len(&self) -> usize {
499        self.edges.len()
500    }
501
502    /// Returns true if the store contains no edges.
503    #[must_use]
504    pub fn is_empty(&self) -> bool {
505        self.edges.is_empty()
506    }
507
508    /// Returns all edges in the store.
509    #[must_use]
510    pub fn all_edges(&self) -> Vec<&GraphEdge> {
511        self.edges.values().collect()
512    }
513
514    /// Returns all outgoing source node IDs (keys of the outgoing index).
515    ///
516    /// Used by [`SnapshotBuilder`](super::csr_snapshot::SnapshotBuilder) to
517    /// enumerate source nodes for CSR construction.
518    #[must_use]
519    pub(crate) fn outgoing_keys(&self) -> Vec<u64> {
520        self.outgoing.keys().copied().collect()
521    }
522
523    /// Returns the total number of outgoing edge entries across all nodes.
524    ///
525    /// Used by [`SnapshotBuilder`](super::csr_snapshot::SnapshotBuilder) for
526    /// pre-allocation.
527    #[must_use]
528    pub(crate) fn total_outgoing_edges(&self) -> usize {
529        self.outgoing.values().map(Vec::len).sum()
530    }
531
532    /// Invokes `f` for each outgoing edge from `node_id` (by edge object).
533    ///
534    /// Used by [`SnapshotBuilder`](super::csr_snapshot::SnapshotBuilder) to
535    /// iterate edges without exposing internal index structure.
536    pub(crate) fn for_each_outgoing_edge<F: FnMut(&GraphEdge)>(&self, node_id: u64, mut f: F) {
537        if let Some(ids) = self.outgoing.get(&node_id) {
538            for id in ids {
539                if let Some(edge) = self.edges.get(id) {
540                    f(edge);
541                }
542            }
543        }
544    }
545}
546
547/// Maps [`LabelTable::intern`]'s overflow (more than `u32::MAX` distinct
548/// labels — unreachable in practice) to [`Error::Overflow`].
549fn intern_or_overflow(table: &mut LabelTable, label: &str) -> Result<LabelId> {
550    table
551        .intern(label)
552        .map_err(|e| Error::Overflow(e.to_string()))
553}
554
555/// The incoming half of [`EdgeStore::rebuild_label_indexes`], split out to
556/// keep both halves under the complexity budget. Takes the already-borrowed
557/// disjoint fields rather than `&mut self` so the caller's destructuring
558/// still satisfies the borrow checker.
559fn rebuild_incoming_labels(
560    edges: &HashMap<u64, GraphEdge>,
561    incoming: &HashMap<u64, Vec<u64>>,
562    incoming_by_label: &mut HashMap<(u64, LabelId), Vec<u64>>,
563    label_table: &mut LabelTable,
564) -> Result<()> {
565    // Sorted for the same determinism reason as the outgoing half.
566    for &node in &sorted_keys(incoming) {
567        for &id in &incoming[&node] {
568            if let Some(edge) = edges.get(&id) {
569                let label_id = intern_or_overflow(label_table, edge.label())?;
570                incoming_by_label
571                    .entry((edge.target(), label_id))
572                    .or_default()
573                    .push(id);
574            }
575        }
576    }
577    Ok(())
578}
579
580/// The map's keys, sorted — so index rebuilds walk nodes in a stable order
581/// instead of the per-instance-random `HashMap` iteration order.
582fn sorted_keys<V>(map: &HashMap<u64, V>) -> Vec<u64> {
583    let mut keys: Vec<u64> = map.keys().copied().collect();
584    keys.sort_unstable();
585    keys
586}
587
588// Edge removal operations are in `edge_removal.rs`.
589// CSR snapshot methods and persistence are in `edge_persistence.rs`.