Skip to main content

mentedb_graph/
csr.rs

1//! Compressed Sparse Row/Column graph storage with delta log for incremental updates.
2
3use ahash::HashMap;
4use mentedb_core::edge::{EdgeType, MemoryEdge};
5use mentedb_core::error::{MenteError, MenteResult};
6use mentedb_core::types::{MemoryId, Timestamp};
7use serde::{Deserialize, Serialize};
8
9/// Compact edge data stored in CSR/CSC arrays.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct StoredEdge {
12    /// The relationship type.
13    pub edge_type: EdgeType,
14    /// Edge weight (0.0 to 1.0).
15    pub weight: f32,
16    /// When this edge was created.
17    pub created_at: Timestamp,
18    /// When this relationship became valid. None = since creation.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub valid_from: Option<Timestamp>,
21    /// When this relationship stopped being valid. None = still valid.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub valid_until: Option<Timestamp>,
24    /// Semantic label for the relationship (e.g. "owns", "attends").
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub label: Option<String>,
27}
28
29impl StoredEdge {
30    /// Converts a [`MemoryEdge`] into a compact stored representation.
31    pub fn from_memory_edge(edge: &MemoryEdge) -> Self {
32        Self {
33            edge_type: edge.edge_type,
34            weight: edge.weight,
35            created_at: edge.created_at,
36            valid_from: edge.valid_from,
37            valid_until: edge.valid_until,
38            label: edge.label.clone(),
39        }
40    }
41
42    /// Returns true if this edge is temporally valid at the given timestamp.
43    pub fn is_valid_at(&self, at: Timestamp) -> bool {
44        let from = self.valid_from.unwrap_or(0);
45        match self.valid_until {
46            Some(until) => at >= from && at < until,
47            None => at >= from,
48        }
49    }
50
51    /// Returns true if this edge has been invalidated.
52    pub fn is_invalidated(&self) -> bool {
53        self.valid_until.is_some()
54    }
55}
56
57/// A pending edge in the delta log before compaction into CSR.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59struct DeltaEdge {
60    source_idx: u32,
61    target_idx: u32,
62    data: StoredEdge,
63}
64
65/// Compressed Sparse Row storage for one direction (outgoing or incoming).
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67struct CompressedStorage {
68    /// Length = num_nodes + 1. row_offsets[i]..row_offsets[i+1] gives the range in col_indices/edge_data.
69    row_offsets: Vec<u32>,
70    /// Column indices (target node for CSR, source node for CSC).
71    col_indices: Vec<u32>,
72    /// Edge metadata parallel to col_indices.
73    edge_data: Vec<StoredEdge>,
74}
75
76impl CompressedStorage {
77    #[allow(dead_code)]
78    fn new(num_nodes: usize) -> Self {
79        Self {
80            row_offsets: vec![0; num_nodes + 1],
81            col_indices: Vec::new(),
82            edge_data: Vec::new(),
83        }
84    }
85
86    /// Get neighbors and edge data for a given row index.
87    fn neighbors(&self, row: u32) -> &[u32] {
88        let row = row as usize;
89        if row + 1 >= self.row_offsets.len() {
90            return &[];
91        }
92        let start = self.row_offsets[row] as usize;
93        let end = self.row_offsets[row + 1] as usize;
94        &self.col_indices[start..end]
95    }
96
97    fn edge_data_for(&self, row: u32) -> &[StoredEdge] {
98        let row = row as usize;
99        if row + 1 >= self.row_offsets.len() {
100            return &[];
101        }
102        let start = self.row_offsets[row] as usize;
103        let end = self.row_offsets[row + 1] as usize;
104        &self.edge_data[start..end]
105    }
106}
107
108/// Bidirectional graph with CSR (outgoing) and CSC (incoming) plus a delta log.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct CsrGraph {
111    /// Maps MemoryId -> internal u32 index.
112    id_to_idx: HashMap<MemoryId, u32>,
113    /// Maps internal u32 index -> MemoryId.
114    idx_to_id: Vec<MemoryId>,
115
116    /// CSR for outgoing edges.
117    csr: CompressedStorage,
118    /// CSC for incoming edges.
119    csc: CompressedStorage,
120
121    /// Recent edges not yet merged into the compressed storage.
122    delta_edges: Vec<DeltaEdge>,
123    /// Edges marked for removal (source_idx, target_idx).
124    removed_edges: Vec<(u32, u32)>,
125
126    /// Live edge count per type, maintained incrementally so counts (e.g. the
127    /// dashboard conflict badge) are O(1) instead of a walk over every node ever
128    /// created. The detectors store one directed edge per conflict pair, so a
129    /// directed count equals the pair count. Grounded authoritatively on
130    /// `compact` and after load; adjusted on add and per-node removal.
131    #[serde(default)]
132    edge_type_counts: HashMap<EdgeType, usize>,
133}
134
135impl CsrGraph {
136    /// Creates a new empty CSR graph.
137    pub fn new() -> Self {
138        Self {
139            id_to_idx: HashMap::default(),
140            idx_to_id: Vec::new(),
141            csr: CompressedStorage::default(),
142            csc: CompressedStorage::default(),
143            delta_edges: Vec::new(),
144            removed_edges: Vec::new(),
145            edge_type_counts: HashMap::default(),
146        }
147    }
148
149    /// Register a node. Returns its internal index.
150    pub fn add_node(&mut self, id: MemoryId) -> u32 {
151        if let Some(&idx) = self.id_to_idx.get(&id) {
152            return idx;
153        }
154        let idx = self.idx_to_id.len() as u32;
155        self.id_to_idx.insert(id, idx);
156        self.idx_to_id.push(id);
157        idx
158    }
159
160    /// Remove a node and all its edges.
161    pub fn remove_node(&mut self, id: MemoryId) {
162        let Some(&idx) = self.id_to_idx.get(&id) else {
163            return;
164        };
165
166        // Collect the types of this node's still-live edges so the running
167        // per-type counts can be decremented. `is_removed` guards against
168        // decrementing an edge twice when both of its endpoints are removed.
169        let mut decrements: Vec<EdgeType> = Vec::new();
170        {
171            let out_neighbors = self.csr.neighbors(idx).to_vec();
172            let out_types: Vec<EdgeType> = self
173                .csr
174                .edge_data_for(idx)
175                .iter()
176                .map(|e| e.edge_type)
177                .collect();
178            for (i, &neighbor) in out_neighbors.iter().enumerate() {
179                if !self.is_removed(idx, neighbor) {
180                    decrements.push(out_types[i]);
181                }
182            }
183            let in_neighbors = self.csc.neighbors(idx).to_vec();
184            let in_types: Vec<EdgeType> = self
185                .csc
186                .edge_data_for(idx)
187                .iter()
188                .map(|e| e.edge_type)
189                .collect();
190            for (i, &neighbor) in in_neighbors.iter().enumerate() {
191                if !self.is_removed(neighbor, idx) {
192                    decrements.push(in_types[i]);
193                }
194            }
195            for e in &self.delta_edges {
196                if e.source_idx == idx || e.target_idx == idx {
197                    decrements.push(e.data.edge_type);
198                }
199            }
200        }
201        for et in decrements {
202            if let Some(c) = self.edge_type_counts.get_mut(&et) {
203                *c = c.saturating_sub(1);
204            }
205        }
206
207        // Mark all outgoing and incoming edges for removal
208        for &neighbor in self.csr.neighbors(idx) {
209            self.removed_edges.push((idx, neighbor));
210        }
211        for &neighbor in self.csc.neighbors(idx) {
212            self.removed_edges.push((neighbor, idx));
213        }
214        // Also remove from delta
215        self.delta_edges
216            .retain(|e| e.source_idx != idx && e.target_idx != idx);
217        self.id_to_idx.remove(&id);
218    }
219
220    /// Add an edge to the delta log.
221    ///
222    /// Deduplicates: if a currently-valid edge with the same source, target,
223    /// and type already exists (in CSR or the delta log), the graph is left
224    /// unchanged. Write inference and pipeline passes may infer the same
225    /// relationship more than once; parallel duplicates carry no information.
226    pub fn add_edge(&mut self, edge: &MemoryEdge) {
227        let source_idx = self.add_node(edge.source);
228        let target_idx = self.add_node(edge.target);
229
230        let duplicate = self.outgoing_by_idx(source_idx).into_iter().any(|(t, e)| {
231            t == edge.target && e.edge_type == edge.edge_type && e.valid_until.is_none()
232        });
233        if duplicate {
234            return;
235        }
236
237        self.delta_edges.push(DeltaEdge {
238            source_idx,
239            target_idx,
240            data: StoredEdge::from_memory_edge(edge),
241        });
242        *self.edge_type_counts.entry(edge.edge_type).or_default() += 1;
243    }
244
245    /// Strengthen an edge by incrementing its weight (Hebbian learning).
246    ///
247    /// Updates the existing edge rather than appending a parallel duplicate:
248    /// a delta edge is bumped in place; a compressed (CSR) edge is marked
249    /// removed and replaced with a delta override, so compaction keeps
250    /// exactly one copy.
251    pub fn strengthen_edge(&mut self, source: MemoryId, target: MemoryId, delta: f32) {
252        let (Some(&source_idx), Some(&target_idx)) =
253            (self.id_to_idx.get(&source), self.id_to_idx.get(&target))
254        else {
255            return;
256        };
257
258        // Bump an existing delta edge in place.
259        if let Some(existing) = self
260            .delta_edges
261            .iter_mut()
262            .find(|e| e.source_idx == source_idx && e.target_idx == target_idx)
263        {
264            existing.data.weight = (existing.data.weight + delta).min(1.0);
265            return;
266        }
267
268        // Edge lives in compressed storage: suppress the CSR copy and push a
269        // delta override. Removed-edge filtering only applies to compressed
270        // edges, so the override remains visible.
271        if let Some((_, stored)) = self
272            .outgoing_by_idx(source_idx)
273            .into_iter()
274            .find(|(id, _)| *id == target)
275        {
276            let new_weight = (stored.weight + delta).min(1.0);
277            self.removed_edges.push((source_idx, target_idx));
278            self.delta_edges.push(DeltaEdge {
279                source_idx,
280                target_idx,
281                data: StoredEdge {
282                    weight: new_weight,
283                    ..stored
284                },
285            });
286        }
287    }
288
289    /// Mark an edge for removal.
290    pub fn remove_edge(&mut self, source: MemoryId, target: MemoryId) {
291        let (Some(&src_idx), Some(&tgt_idx)) =
292            (self.id_to_idx.get(&source), self.id_to_idx.get(&target))
293        else {
294            return;
295        };
296        self.removed_edges.push((src_idx, tgt_idx));
297        self.delta_edges
298            .retain(|e| !(e.source_idx == src_idx && e.target_idx == tgt_idx));
299    }
300
301    /// Remove every edge whose type is in `types`, preserving all other edges,
302    /// including edges of a different type between the same pair. Returns the
303    /// number of edges removed.
304    ///
305    /// The removal model suppresses a compressed edge at pair granularity, so
306    /// when a suppressed pair also carries a non-matching edge, that sibling is
307    /// re-added to the delta log to keep it. Callers should `compact()` after to
308    /// materialize the result into both CSR and CSC. This is a one-time cleanup
309    /// primitive (used to purge conflict edges the old write-time heuristic
310    /// created at ~0% precision), not a hot path.
311    pub fn remove_edges_of_types(&mut self, types: &[EdgeType]) -> usize {
312        let matches = |et: EdgeType| types.contains(&et);
313        let mut removed = 0usize;
314
315        // Delta edges: drop matching ones directly.
316        let before = self.delta_edges.len();
317        self.delta_edges.retain(|e| !matches(e.data.edge_type));
318        removed += before - self.delta_edges.len();
319
320        // Compressed edges: suppress matching ones. Suppression is pair level,
321        // so restore any non-matching sibling of a suppressed pair via delta.
322        let mut restore: Vec<DeltaEdge> = Vec::new();
323        let n = self.idx_to_id.len() as u32;
324        for src_idx in 0..n {
325            let row: Vec<(u32, StoredEdge)> = {
326                let neighbors = self.csr.neighbors(src_idx);
327                let edges = self.csr.edge_data_for(src_idx);
328                let mut v = Vec::new();
329                for (i, &t) in neighbors.iter().enumerate() {
330                    if !self.is_removed(src_idx, t) {
331                        v.push((t, edges[i].clone()));
332                    }
333                }
334                v
335            };
336            let matched_pairs: std::collections::HashSet<u32> = row
337                .iter()
338                .filter(|(_, e)| matches(e.edge_type))
339                .map(|(t, _)| *t)
340                .collect();
341            if matched_pairs.is_empty() {
342                continue;
343            }
344            for &tgt_idx in &matched_pairs {
345                self.removed_edges.push((src_idx, tgt_idx));
346            }
347            for (tgt_idx, e) in row {
348                if !matched_pairs.contains(&tgt_idx) {
349                    continue;
350                }
351                if matches(e.edge_type) {
352                    removed += 1;
353                } else {
354                    restore.push(DeltaEdge {
355                        source_idx: src_idx,
356                        target_idx: tgt_idx,
357                        data: e,
358                    });
359                }
360            }
361        }
362        self.delta_edges.extend(restore);
363        // Bulk type removal touches many edges via suppression and restore; just
364        // recount from the resulting live set rather than track each delta.
365        self.recompute_edge_counts();
366        removed
367    }
368
369    /// Get all outgoing edges from a node (CSR + delta, minus removed).
370    pub fn outgoing(&self, id: MemoryId) -> Vec<(MemoryId, StoredEdge)> {
371        let Some(&idx) = self.id_to_idx.get(&id) else {
372            return Vec::new();
373        };
374        self.outgoing_by_idx(idx)
375    }
376
377    /// Get outgoing edges that are temporally valid at the given timestamp.
378    pub fn outgoing_valid_at(&self, id: MemoryId, at: Timestamp) -> Vec<(MemoryId, StoredEdge)> {
379        self.outgoing(id)
380            .into_iter()
381            .filter(|(_, e)| e.is_valid_at(at))
382            .collect()
383    }
384
385    pub(crate) fn outgoing_by_idx(&self, idx: u32) -> Vec<(MemoryId, StoredEdge)> {
386        let mut results = Vec::new();
387
388        // From compressed storage
389        let neighbors = self.csr.neighbors(idx);
390        let edges = self.csr.edge_data_for(idx);
391        for (i, &neighbor) in neighbors.iter().enumerate() {
392            if !self.is_removed(idx, neighbor)
393                && let Some(&id) = self.idx_to_id.get(neighbor as usize)
394            {
395                results.push((id, edges[i].clone()));
396            }
397        }
398
399        // From delta
400        for delta in &self.delta_edges {
401            if delta.source_idx == idx
402                && let Some(&id) = self.idx_to_id.get(delta.target_idx as usize)
403            {
404                results.push((id, delta.data.clone()));
405            }
406        }
407
408        results
409    }
410
411    /// `(contradicts, supersedes)` live conflict-edge counts, read O(1) from the
412    /// running per-type counts. The detectors store one directed edge per
413    /// conflict pair, so a directed count is the pair count.
414    pub fn conflict_edge_counts(&self) -> (usize, usize) {
415        (
416            self.edge_type_counts
417                .get(&EdgeType::Contradicts)
418                .copied()
419                .unwrap_or(0),
420            self.edge_type_counts
421                .get(&EdgeType::Supersedes)
422                .copied()
423                .unwrap_or(0),
424        )
425    }
426
427    /// Recount live edges by type from scratch. Authoritative but O(nodes +
428    /// edges); called only when the graph is already being walked wholesale
429    /// (`compact`, bulk type removal) or grounded after load, never on a read.
430    pub fn recompute_edge_counts(&mut self) {
431        let mut counts: HashMap<EdgeType, usize> = HashMap::default();
432        let node_count = self.idx_to_id.len() as u32;
433        for idx in 0..node_count {
434            let neighbors = self.csr.neighbors(idx);
435            let data = self.csr.edge_data_for(idx);
436            for (i, &nbr) in neighbors.iter().enumerate() {
437                if !self.is_removed(idx, nbr) {
438                    *counts.entry(data[i].edge_type).or_default() += 1;
439                }
440            }
441        }
442        for d in &self.delta_edges {
443            if !self.is_removed(d.source_idx, d.target_idx) {
444                *counts.entry(d.data.edge_type).or_default() += 1;
445            }
446        }
447        self.edge_type_counts = counts;
448    }
449
450    /// Get all incoming edges to a node (CSC + delta, minus removed).
451    pub fn incoming(&self, id: MemoryId) -> Vec<(MemoryId, StoredEdge)> {
452        let Some(&idx) = self.id_to_idx.get(&id) else {
453            return Vec::new();
454        };
455        self.incoming_by_idx(idx)
456    }
457
458    /// Get incoming edges that are temporally valid at the given timestamp.
459    pub fn incoming_valid_at(&self, id: MemoryId, at: Timestamp) -> Vec<(MemoryId, StoredEdge)> {
460        self.incoming(id)
461            .into_iter()
462            .filter(|(_, e)| e.is_valid_at(at))
463            .collect()
464    }
465
466    pub(crate) fn incoming_by_idx(&self, idx: u32) -> Vec<(MemoryId, StoredEdge)> {
467        let mut results = Vec::new();
468
469        // From compressed storage (CSC)
470        let neighbors = self.csc.neighbors(idx);
471        let edges = self.csc.edge_data_for(idx);
472        for (i, &neighbor) in neighbors.iter().enumerate() {
473            if !self.is_removed(neighbor, idx)
474                && let Some(&id) = self.idx_to_id.get(neighbor as usize)
475            {
476                results.push((id, edges[i].clone()));
477            }
478        }
479
480        // From delta
481        for delta in &self.delta_edges {
482            if delta.target_idx == idx
483                && let Some(&id) = self.idx_to_id.get(delta.source_idx as usize)
484            {
485                results.push((id, delta.data.clone()));
486            }
487        }
488
489        results
490    }
491
492    /// Check if a node exists in the graph.
493    pub fn contains_node(&self, id: MemoryId) -> bool {
494        self.id_to_idx.contains_key(&id)
495    }
496
497    /// Number of registered nodes.
498    pub fn node_count(&self) -> usize {
499        self.idx_to_id.len()
500    }
501
502    /// Resolve a MemoryId to its internal index.
503    pub(crate) fn get_idx(&self, id: MemoryId) -> Option<u32> {
504        self.id_to_idx.get(&id).copied()
505    }
506
507    /// Resolve an internal index to its MemoryId.
508    #[allow(dead_code)]
509    pub(crate) fn get_id(&self, idx: u32) -> Option<MemoryId> {
510        self.idx_to_id.get(idx as usize).copied()
511    }
512
513    /// All registered node IDs.
514    pub fn node_ids(&self) -> &[MemoryId] {
515        &self.idx_to_id
516    }
517
518    fn is_removed(&self, source: u32, target: u32) -> bool {
519        self.removed_edges
520            .iter()
521            .any(|&(s, t)| s == source && t == target)
522    }
523
524    /// Merge all delta edges and removals into the compressed CSR/CSC storage.
525    pub fn compact(&mut self) {
526        let num_nodes = self.idx_to_id.len();
527
528        // Collect all edges: existing (minus removed) + delta
529        let mut all_edges: Vec<(u32, u32, StoredEdge)> = Vec::new();
530
531        // Existing CSR edges
532        for row in 0..num_nodes {
533            let row = row as u32;
534            let neighbors = self.csr.neighbors(row);
535            let edges = self.csr.edge_data_for(row);
536            for (i, &col) in neighbors.iter().enumerate() {
537                if !self.is_removed(row, col) {
538                    all_edges.push((row, col, edges[i].clone()));
539                }
540            }
541        }
542
543        // Delta edges
544        for delta in &self.delta_edges {
545            all_edges.push((delta.source_idx, delta.target_idx, delta.data.clone()));
546        }
547
548        // Build CSR (sorted by source)
549        self.csr = Self::build_compressed(&all_edges, num_nodes, false);
550
551        // Build CSC (sorted by target)
552        self.csc = Self::build_compressed(&all_edges, num_nodes, true);
553
554        self.delta_edges.clear();
555        self.removed_edges.clear();
556
557        // Ground the running per-type counts against the freshly compacted
558        // edge set, so any incremental drift since the last compaction is reset.
559        self.recompute_edge_counts();
560    }
561
562    fn build_compressed(
563        edges: &[(u32, u32, StoredEdge)],
564        num_nodes: usize,
565        transpose: bool,
566    ) -> CompressedStorage {
567        // Count edges per row
568        let mut counts = vec![0u32; num_nodes];
569        for &(src, tgt, ref _data) in edges {
570            let row = if transpose { tgt } else { src };
571            if (row as usize) < num_nodes {
572                counts[row as usize] += 1;
573            }
574        }
575
576        // Build offsets via prefix sum
577        let mut row_offsets = vec![0u32; num_nodes + 1];
578        for i in 0..num_nodes {
579            row_offsets[i + 1] = row_offsets[i] + counts[i];
580        }
581
582        let total = row_offsets[num_nodes] as usize;
583        let mut col_indices = vec![0u32; total];
584        let mut edge_data = vec![
585            StoredEdge {
586                edge_type: EdgeType::Related,
587                weight: 0.0,
588                created_at: 0,
589                valid_from: None,
590                valid_until: None,
591                label: None,
592            };
593            total
594        ];
595
596        // Fill using write cursors
597        let mut cursors = row_offsets[..num_nodes].to_vec();
598        for &(src, tgt, ref data) in edges {
599            let (row, col) = if transpose { (tgt, src) } else { (src, tgt) };
600            if (row as usize) < num_nodes {
601                let pos = cursors[row as usize] as usize;
602                col_indices[pos] = col;
603                edge_data[pos] = data.clone();
604                cursors[row as usize] += 1;
605            }
606        }
607
608        CompressedStorage {
609            row_offsets,
610            col_indices,
611            edge_data,
612        }
613    }
614    /// Save the graph snapshot to a file.
615    pub fn save(&self, path: &std::path::Path) -> MenteResult<()> {
616        let data =
617            serde_json::to_vec(self).map_err(|e| MenteError::Serialization(e.to_string()))?;
618        // Atomic snapshot: write to a temp file, fsync, then rename over the
619        // old snapshot so a crash mid-save never leaves a truncated graph.
620        let tmp_path = path.with_extension("json.tmp");
621        {
622            use std::io::Write;
623            let mut file = std::fs::File::create(&tmp_path)?;
624            file.write_all(&data)?;
625            file.sync_data()?;
626        }
627        std::fs::rename(&tmp_path, path)?;
628        Ok(())
629    }
630
631    /// Load the graph from a file.
632    pub fn load(path: &std::path::Path) -> MenteResult<Self> {
633        let data = std::fs::read(path)?;
634        let mut graph: Self =
635            serde_json::from_slice(&data).map_err(|e| MenteError::Serialization(e.to_string()))?;
636        // Ground the per-type counts from the loaded edges. Snapshots written
637        // before this field existed carry an empty map; recomputing makes the
638        // counts correct regardless, and the edge-log replay in
639        // `GraphManager::open` then adjusts for changes since the snapshot.
640        graph.recompute_edge_counts();
641        Ok(graph)
642    }
643}
644
645impl Default for CsrGraph {
646    fn default() -> Self {
647        Self::new()
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    fn make_edge(src: MemoryId, tgt: MemoryId, etype: EdgeType) -> MemoryEdge {
656        MemoryEdge {
657            source: src,
658            target: tgt,
659            edge_type: etype,
660            weight: 0.8,
661            created_at: 1000,
662            valid_from: None,
663            valid_until: None,
664            label: None,
665        }
666    }
667
668    #[test]
669    fn test_add_node_idempotent() {
670        let mut g = CsrGraph::new();
671        let id = MemoryId::new();
672        let idx1 = g.add_node(id);
673        let idx2 = g.add_node(id);
674        assert_eq!(idx1, idx2);
675        assert_eq!(g.node_count(), 1);
676    }
677
678    #[test]
679    fn edge_type_counts_track_add_compact_and_remove() {
680        let mut g = CsrGraph::new();
681        let (a, b, c, d) = (
682            MemoryId::new(),
683            MemoryId::new(),
684            MemoryId::new(),
685            MemoryId::new(),
686        );
687
688        // Adds land in the delta log and bump the running counts. Related edges
689        // are not conflicts and must not be counted.
690        g.add_edge(&make_edge(a, b, EdgeType::Contradicts));
691        g.add_edge(&make_edge(c, d, EdgeType::Supersedes));
692        g.add_edge(&make_edge(a, c, EdgeType::Related));
693        assert_eq!(g.conflict_edge_counts(), (1, 1));
694        // An exact-duplicate add is a no-op and must not double count.
695        g.add_edge(&make_edge(a, b, EdgeType::Contradicts));
696        assert_eq!(g.conflict_edge_counts(), (1, 1));
697
698        // Counts survive compaction (the CSR is rebuilt and the counts are
699        // reground from the compacted edge set).
700        g.compact();
701        assert_eq!(g.conflict_edge_counts(), (1, 1));
702
703        // A new edge after compaction lands in the delta log again.
704        g.add_edge(&make_edge(a, d, EdgeType::Supersedes));
705        assert_eq!(g.conflict_edge_counts(), (1, 2));
706
707        // Removing a node drops its edges from the counts: a->b (contradicts,
708        // now compacted) and a->d (supersedes, in delta). c->d supersedes stays.
709        g.remove_node(a);
710        assert_eq!(g.conflict_edge_counts(), (0, 1));
711    }
712
713    #[test]
714    fn test_add_and_query_edges() {
715        let mut g = CsrGraph::new();
716        let a = MemoryId::new();
717        let b = MemoryId::new();
718        let c = MemoryId::new();
719
720        g.add_edge(&make_edge(a, b, EdgeType::Caused));
721        g.add_edge(&make_edge(a, c, EdgeType::Related));
722
723        let out = g.outgoing(a);
724        assert_eq!(out.len(), 2);
725
726        let inc_b = g.incoming(b);
727        assert_eq!(inc_b.len(), 1);
728        assert_eq!(inc_b[0].0, a);
729    }
730
731    #[test]
732    fn test_remove_edge() {
733        let mut g = CsrGraph::new();
734        let a = MemoryId::new();
735        let b = MemoryId::new();
736
737        g.add_edge(&make_edge(a, b, EdgeType::Caused));
738        assert_eq!(g.outgoing(a).len(), 1);
739
740        g.remove_edge(a, b);
741        assert_eq!(g.outgoing(a).len(), 0);
742    }
743
744    #[test]
745    fn test_remove_edges_of_types_preserves_siblings() {
746        let mut g = CsrGraph::new();
747        let a = MemoryId::new();
748        let b = MemoryId::new();
749        let c = MemoryId::new();
750
751        // a->b carries BOTH a Supersedes and a Related edge (the collateral
752        // case: pair-level suppression must not drop the Related sibling).
753        g.add_edge(&make_edge(a, b, EdgeType::Supersedes));
754        g.add_edge(&make_edge(a, b, EdgeType::Related));
755        // a->c is a lone Contradicts edge.
756        g.add_edge(&make_edge(a, c, EdgeType::Contradicts));
757        // b->c is unrelated and must survive.
758        g.add_edge(&make_edge(b, c, EdgeType::Caused));
759
760        // Compact first so the targets live in compressed storage, exercising
761        // the suppress-pair + restore-sibling path rather than delta retain.
762        g.compact();
763
764        let removed = g.remove_edges_of_types(&[EdgeType::Contradicts, EdgeType::Supersedes]);
765        g.compact();
766        assert_eq!(removed, 2, "one Supersedes + one Contradicts");
767
768        // a->b: Supersedes gone, Related preserved.
769        let ab: Vec<_> = g.outgoing(a).into_iter().filter(|(t, _)| *t == b).collect();
770        assert_eq!(ab.len(), 1);
771        assert_eq!(ab[0].1.edge_type, EdgeType::Related);
772
773        // a->c contradiction gone entirely.
774        assert!(g.outgoing(a).into_iter().all(|(t, _)| t != c));
775
776        // Unrelated edge intact; incoming reflects the cleanup (only b->c left).
777        assert_eq!(g.outgoing(b).len(), 1);
778        let inc_c = g.incoming(c);
779        assert_eq!(inc_c.len(), 1);
780        assert_eq!(inc_c[0].0, b);
781    }
782
783    #[test]
784    fn test_compact() {
785        let mut g = CsrGraph::new();
786        let a = MemoryId::new();
787        let b = MemoryId::new();
788        let c = MemoryId::new();
789
790        g.add_edge(&make_edge(a, b, EdgeType::Caused));
791        g.add_edge(&make_edge(b, c, EdgeType::Before));
792        g.compact();
793
794        let out_a = g.outgoing(a);
795        assert_eq!(out_a.len(), 1);
796        assert_eq!(out_a[0].0, b);
797
798        let inc_c = g.incoming(c);
799        assert_eq!(inc_c.len(), 1);
800        assert_eq!(inc_c[0].0, b);
801    }
802
803    #[test]
804    fn test_compact_with_removals() {
805        let mut g = CsrGraph::new();
806        let a = MemoryId::new();
807        let b = MemoryId::new();
808        let c = MemoryId::new();
809
810        g.add_edge(&make_edge(a, b, EdgeType::Caused));
811        g.add_edge(&make_edge(a, c, EdgeType::Related));
812        g.compact();
813
814        g.remove_edge(a, b);
815        g.compact();
816
817        let out = g.outgoing(a);
818        assert_eq!(out.len(), 1);
819        assert_eq!(out[0].0, c);
820    }
821
822    #[test]
823    fn test_remove_node_cleans_id_to_idx() {
824        let mut g = CsrGraph::new();
825        let a = MemoryId::new();
826        let b = MemoryId::new();
827
828        g.add_edge(&make_edge(a, b, EdgeType::Caused));
829        assert!(g.contains_node(a));
830        assert!(g.contains_node(b));
831
832        g.remove_node(a);
833        assert!(
834            !g.contains_node(a),
835            "removed node should not be in id_to_idx"
836        );
837        assert!(g.contains_node(b), "unrelated node should still exist");
838
839        // Edges involving the removed node should be gone
840        assert!(g.outgoing(a).is_empty());
841        assert!(g.incoming(b).is_empty());
842    }
843
844    #[test]
845    fn test_remove_node_then_readd() {
846        let mut g = CsrGraph::new();
847        let a = MemoryId::new();
848        let b = MemoryId::new();
849        let c = MemoryId::new();
850
851        g.add_edge(&make_edge(a, b, EdgeType::Caused));
852        g.remove_node(a);
853
854        // Re-adding the same ID should get a fresh index
855        g.add_edge(&make_edge(a, c, EdgeType::Related));
856        assert!(g.contains_node(a));
857        let out = g.outgoing(a);
858        assert_eq!(out.len(), 1);
859        assert_eq!(out[0].0, c);
860    }
861}