Skip to main content

llm_kernel/graph/algo/
csr.rs

1//! Compressed Sparse Row (CSR) representation of the knowledge graph.
2//!
3//! [`CsrGraph`] is a backend-agnostic in-memory snapshot of the directed,
4//! weighted edge set, built once and reused by all graph algorithms. Storing
5//! the graph as CSR — rather than querying SQL per algorithm step — lets the
6//! iterative algorithms (PageRank, label propagation) run in cache-friendly
7//! contiguous sweeps over flat `Vec`s.
8//!
9//! `relation` is intentionally collapsed: every algorithm treats the graph as
10//! a single directed weighted graph, matching the existing `smart_recall`
11//! graph-boost semantics (`recall.rs`). Multi-relational PageRank is out of
12//! scope for the agent-memory workload.
13
14use std::collections::HashMap;
15
16use rusqlite::Connection;
17
18use crate::error::Result;
19use crate::graph::store::{list_node_ids, read_edges};
20use crate::graph::types::GraphEdge;
21
22/// Edges-per-node cap passed to `read_edges` in [`CsrGraph::build_csr`].
23///
24/// `read_edges` hardcodes `LIMIT ?`, so we pass a node-count-proportional
25/// bound instead of the fixed 2000-row snapshot cap used by `build_graph`.
26const CSR_EDGE_CAP_PER_NODE: usize = 64;
27/// Lower bound on the `build_csr` edge cap so tiny graphs still load fully.
28const CSR_EDGE_CAP_MIN: usize = 2000;
29
30/// A directed, weighted graph in Compressed Sparse Row format.
31///
32/// Built once from the edge set and reused by every graph algorithm. Both
33/// forward (out-edges) and reverse (in-edges) CSR arrays are stored: PageRank
34/// and Dijkstra consume out-edges; community detection and similarity use the
35/// undirected view (both directions).
36pub struct CsrGraph {
37    node_count: usize,
38    edge_count: usize,
39    // Out-edges grouped by source. `out_offsets.len() == node_count + 1`;
40    // node `i`'s out-edges occupy `out_offsets[i]..out_offsets[i+1]`.
41    out_offsets: Vec<u32>,
42    out_targets: Vec<u32>,
43    out_weights: Vec<f64>,
44    // In-edges grouped by target (reverse CSR).
45    in_offsets: Vec<u32>,
46    in_sources: Vec<u32>,
47    in_weights: Vec<f64>,
48    // Σ out-weights per node. `<= 0.0` marks a dangling node (PageRank leak).
49    out_weight_sum: Vec<f64>,
50    // ID <-> index mapping, built once and immutable.
51    id_to_idx: HashMap<String, u32>,
52    idx_to_id: Vec<String>,
53}
54
55impl CsrGraph {
56    /// Build a CSR graph from an explicit node-id list and edge list.
57    ///
58    /// Edges whose `source` or `target` is absent from `node_ids` are dropped
59    /// (foreign-reference integrity). Duplicate node IDs collapse to the first
60    /// occurrence. The construction is `O(V + E log E)`.
61    pub fn from_edges(node_ids: &[String], edges: &[GraphEdge]) -> Self {
62        let mut id_to_idx: HashMap<String, u32> = HashMap::with_capacity(node_ids.len());
63        let mut idx_to_id: Vec<String> = Vec::with_capacity(node_ids.len());
64        for id in node_ids {
65            if id_to_idx.contains_key(id) {
66                continue;
67            }
68            id_to_idx.insert(id.clone(), idx_to_id.len() as u32);
69            idx_to_id.push(id.clone());
70        }
71        let node_count = idx_to_id.len();
72
73        // Resolve edges to (source_idx, target_idx, weight), dropping orphans.
74        let mut resolved: Vec<(u32, u32, f64)> = Vec::with_capacity(edges.len());
75        for e in edges {
76            let (Some(&src), Some(&dst)) = (id_to_idx.get(&e.source), id_to_idx.get(&e.target))
77            else {
78                continue;
79            };
80            resolved.push((src, dst, e.weight));
81        }
82
83        // Out CSR: stable sort by source, count, prefix-sum, append targets.
84        resolved.sort_unstable_by_key(|&(s, _, _)| s);
85        let edge_count = resolved.len();
86        let mut out_offsets = vec![0u32; node_count + 1];
87        let mut out_weight_sum = vec![0.0f64; node_count];
88        for &(src, _, w) in &resolved {
89            out_offsets[src as usize + 1] += 1;
90            out_weight_sum[src as usize] += w;
91        }
92        for i in 1..=node_count {
93            out_offsets[i] += out_offsets[i - 1];
94        }
95        let mut out_targets = Vec::with_capacity(edge_count);
96        let mut out_weights = Vec::with_capacity(edge_count);
97        for &(_, dst, w) in &resolved {
98            out_targets.push(dst);
99            out_weights.push(w);
100        }
101
102        // In CSR: reorder to (target, source, weight), sort by target.
103        let mut by_target: Vec<(u32, u32, f64)> =
104            resolved.iter().map(|&(s, t, w)| (t, s, w)).collect();
105        by_target.sort_unstable_by_key(|&(t, _, _)| t);
106        let mut in_offsets = vec![0u32; node_count + 1];
107        for &(tgt, _, _) in &by_target {
108            in_offsets[tgt as usize + 1] += 1;
109        }
110        for i in 1..=node_count {
111            in_offsets[i] += in_offsets[i - 1];
112        }
113        let mut in_sources = Vec::with_capacity(edge_count);
114        let mut in_weights = Vec::with_capacity(edge_count);
115        for &(_, src, w) in &by_target {
116            in_sources.push(src);
117            in_weights.push(w);
118        }
119
120        Self {
121            node_count,
122            edge_count,
123            out_offsets,
124            out_targets,
125            out_weights,
126            in_offsets,
127            in_sources,
128            in_weights,
129            out_weight_sum,
130            id_to_idx,
131            idx_to_id,
132        }
133    }
134
135    /// Build a CSR snapshot from a SQLite connection.
136    ///
137    /// Loads all node IDs (including edge-less dangling nodes) and a
138    /// node-count-proportional edge cap.
139    pub fn build_csr(conn: &Connection) -> Result<Self> {
140        let node_ids = list_node_ids(conn)?;
141        let cap = node_ids
142            .len()
143            .saturating_mul(CSR_EDGE_CAP_PER_NODE)
144            .max(CSR_EDGE_CAP_MIN);
145        let edges = read_edges(conn, cap)?;
146        Ok(Self::from_edges(&node_ids, &edges))
147    }
148
149    /// Number of nodes.
150    pub fn node_count(&self) -> usize {
151        self.node_count
152    }
153
154    /// Number of resolved edges (orphans excluded).
155    pub fn edge_count(&self) -> usize {
156        self.edge_count
157    }
158
159    /// Resolve a node ID to its index, or `None` if absent.
160    pub fn node_index(&self, id: &str) -> Option<u32> {
161        self.id_to_idx.get(id).copied()
162    }
163
164    /// Resolve an index back to its node ID. Panics on out-of-range index.
165    pub fn node_id(&self, idx: u32) -> &str {
166        &self.idx_to_id[idx as usize]
167    }
168
169    /// Iterate `(target_idx, weight)` over the out-edges of `src`.
170    pub fn out_neighbors(&self, src: u32) -> impl Iterator<Item = (u32, f64)> + '_ {
171        let start = self.out_offsets[src as usize] as usize;
172        let end = self.out_offsets[src as usize + 1] as usize;
173        self.out_targets[start..end]
174            .iter()
175            .zip(self.out_weights[start..end].iter())
176            .map(|(&t, &w)| (t, w))
177    }
178
179    /// Iterate `(source_idx, weight)` over the in-edges of `dst`.
180    pub fn in_neighbors(&self, dst: u32) -> impl Iterator<Item = (u32, f64)> + '_ {
181        let start = self.in_offsets[dst as usize] as usize;
182        let end = self.in_offsets[dst as usize + 1] as usize;
183        self.in_sources[start..end]
184            .iter()
185            .zip(self.in_weights[start..end].iter())
186            .map(|(&s, &w)| (s, w))
187    }
188
189    /// Out-degree (number of out-edges) of `idx`.
190    pub fn out_degree(&self, idx: u32) -> u32 {
191        self.out_offsets[idx as usize + 1] - self.out_offsets[idx as usize]
192    }
193
194    /// Sum of out-edge weights for `idx`. `<= 0.0` marks a dangling node.
195    pub fn out_weight_sum(&self, idx: u32) -> f64 {
196        self.out_weight_sum[idx as usize]
197    }
198
199    /// Whether `idx` is a dangling node — no out-edges, or all-zero weights.
200    ///
201    /// PageRank redistributes a dangling node's rank mass globally each
202    /// iteration to prevent rank leakage and convergence failure.
203    pub fn is_dangling(&self, idx: u32) -> bool {
204        self.out_weight_sum[idx as usize] <= 0.0
205    }
206
207    /// Iterate all node indices in `[0, node_count)`.
208    pub fn node_indices(&self) -> std::ops::Range<u32> {
209        0..self.node_count as u32
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::graph::schema::init_graph_schema;
217    use crate::graph::store::{append_edge, upsert_node};
218    use crate::graph::types::GraphNode;
219    use rusqlite::Connection;
220
221    fn mem_db() -> Connection {
222        let conn = Connection::open_in_memory().unwrap();
223        init_graph_schema(&conn).unwrap();
224        conn
225    }
226
227    fn edge(id: &str, s: &str, t: &str, w: f64) -> GraphEdge {
228        GraphEdge {
229            id: id.into(),
230            source: s.into(),
231            target: t.into(),
232            relation: "related".into(),
233            weight: w,
234            ts: "2026-01-01T00:00:00Z".into(),
235        }
236    }
237
238    fn node(id: &str) -> GraphNode {
239        GraphNode {
240            id: id.into(),
241            node_type: "concept".into(),
242            title: id.into(),
243            body: String::new(),
244            tags: vec![],
245            projects: vec![],
246            agents: vec![],
247            created: "2026-01-01T00:00:00Z".into(),
248            updated: "2026-01-01T00:00:00Z".into(),
249            importance: 0.5,
250            access_count: 0,
251            accessed_at: String::new(),
252        }
253    }
254
255    /// Diamond: A→B, A→C, B→D, C→D (all weight 1.0). D is a sink.
256    fn diamond() -> (Vec<String>, Vec<GraphEdge>) {
257        let nodes: Vec<String> = ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect();
258        let edges = vec![
259            edge("e1", "A", "B", 1.0),
260            edge("e2", "A", "C", 1.0),
261            edge("e3", "B", "D", 1.0),
262            edge("e4", "C", "D", 1.0),
263        ];
264        (nodes, edges)
265    }
266
267    #[test]
268    fn diamond_out_neighbors_and_sink() {
269        let (nodes, edges) = diamond();
270        let g = CsrGraph::from_edges(&nodes, &edges);
271        let a = g.node_index("A").unwrap();
272        let b = g.node_index("B").unwrap();
273        let c = g.node_index("C").unwrap();
274        let d = g.node_index("D").unwrap();
275
276        let mut nbrs: Vec<u32> = g.out_neighbors(a).map(|(t, _)| t).collect();
277        nbrs.sort_unstable();
278        assert_eq!(nbrs, vec![b, c]);
279
280        // D is a sink — no out-edges → dangling.
281        assert_eq!(g.out_degree(d), 0);
282        assert!(g.is_dangling(d));
283        assert!(!g.is_dangling(a));
284    }
285
286    #[test]
287    fn reverse_csr_in_neighbors() {
288        let (nodes, edges) = diamond();
289        let g = CsrGraph::from_edges(&nodes, &edges);
290        let b = g.node_index("B").unwrap();
291        let c = g.node_index("C").unwrap();
292        let d = g.node_index("D").unwrap();
293
294        let mut srcs: Vec<u32> = g.in_neighbors(d).map(|(s, _)| s).collect();
295        srcs.sort_unstable();
296        assert_eq!(srcs, vec![b, c]);
297    }
298
299    #[test]
300    fn out_weight_sum_accumulates_parallel_edges() {
301        let nodes: Vec<String> = ["A", "B"].iter().map(|s| s.to_string()).collect();
302        let edges = vec![
303            edge("e1", "A", "B", 0.3),
304            edge("e2", "A", "B", 0.7), // parallel edge A→B
305        ];
306        let g = CsrGraph::from_edges(&nodes, &edges);
307        let a = g.node_index("A").unwrap();
308        assert!((g.out_weight_sum(a) - 1.0).abs() < 1e-12);
309        assert_eq!(g.out_degree(a), 2);
310    }
311
312    #[test]
313    fn orphan_edges_dropped() {
314        let nodes: Vec<String> = ["A", "B"].iter().map(|s| s.to_string()).collect();
315        let edges = vec![
316            edge("e1", "A", "B", 1.0),
317            edge("e2", "A", "Z", 1.0), // target Z absent
318            edge("e3", "X", "B", 1.0), // source X absent
319        ];
320        let g = CsrGraph::from_edges(&nodes, &edges);
321        assert_eq!(g.edge_count(), 1);
322    }
323
324    #[test]
325    fn isolated_node_is_dangling() {
326        let nodes: Vec<String> = ["A", "B", "E"].iter().map(|s| s.to_string()).collect();
327        let edges = vec![edge("e1", "A", "B", 1.0)];
328        let g = CsrGraph::from_edges(&nodes, &edges);
329        let e = g.node_index("E").unwrap();
330        assert!(g.is_dangling(e));
331        assert_eq!(g.node_count(), 3);
332        assert_eq!(g.edge_count(), 1);
333    }
334
335    #[test]
336    fn build_csr_from_sqlite() {
337        let conn = mem_db();
338        for id in ["A", "B", "C", "D"] {
339            upsert_node(&conn, &node(id)).unwrap();
340        }
341        append_edge(&conn, &edge("e1", "A", "B", 1.0)).unwrap();
342        append_edge(&conn, &edge("e2", "B", "C", 1.0)).unwrap();
343
344        let g = CsrGraph::build_csr(&conn).unwrap();
345        assert_eq!(g.node_count(), 4);
346        assert_eq!(g.edge_count(), 2);
347        let a = g.node_index("A").unwrap();
348        assert_eq!(g.out_neighbors(a).count(), 1);
349        // D has no edges in the DB → dangling.
350        assert!(g.is_dangling(g.node_index("D").unwrap()));
351    }
352
353    #[test]
354    fn self_loop_kept() {
355        let nodes: Vec<String> = ["A"].iter().map(|s| s.to_string()).collect();
356        let edges = vec![edge("e1", "A", "A", 1.0)];
357        let g = CsrGraph::from_edges(&nodes, &edges);
358        let a = g.node_index("A").unwrap();
359        assert_eq!(g.out_degree(a), 1);
360        assert_eq!(g.out_neighbors(a).next().unwrap().0, a);
361    }
362
363    #[test]
364    fn node_id_roundtrip() {
365        let (nodes, edges) = diamond();
366        let g = CsrGraph::from_edges(&nodes, &edges);
367        for id in &["A", "B", "C", "D"] {
368            let idx = g.node_index(id).unwrap();
369            assert_eq!(g.node_id(idx), *id);
370        }
371        assert!(g.node_index("missing").is_none());
372    }
373}