1use 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
22const CSR_EDGE_CAP_PER_NODE: usize = 64;
27const CSR_EDGE_CAP_MIN: usize = 2000;
29
30pub struct CsrGraph {
37 node_count: usize,
38 edge_count: usize,
39 out_offsets: Vec<u32>,
42 out_targets: Vec<u32>,
43 out_weights: Vec<f64>,
44 in_offsets: Vec<u32>,
46 in_sources: Vec<u32>,
47 in_weights: Vec<f64>,
48 out_weight_sum: Vec<f64>,
50 id_to_idx: HashMap<String, u32>,
52 idx_to_id: Vec<String>,
53}
54
55impl CsrGraph {
56 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 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 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 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 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 pub fn node_count(&self) -> usize {
151 self.node_count
152 }
153
154 pub fn edge_count(&self) -> usize {
156 self.edge_count
157 }
158
159 pub fn node_index(&self, id: &str) -> Option<u32> {
161 self.id_to_idx.get(id).copied()
162 }
163
164 pub fn node_id(&self, idx: u32) -> &str {
166 &self.idx_to_id[idx as usize]
167 }
168
169 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 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 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 pub fn out_weight_sum(&self, idx: u32) -> f64 {
196 self.out_weight_sum[idx as usize]
197 }
198
199 pub fn is_dangling(&self, idx: u32) -> bool {
204 self.out_weight_sum[idx as usize] <= 0.0
205 }
206
207 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 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 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), ];
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), edge("e3", "X", "B", 1.0), ];
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 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}