Skip to main content

leiden_rs/graph/
data.rs

1//! Core graph data representation for the Leiden algorithm.
2//!
3//! [`GraphData`] stores a graph in CSR (Compressed Sparse Row) format with
4//! separate out-edge and in-edge storage. For undirected graphs, only the
5//! out-edge CSR is populated and the in-edge CSR is empty. For directed graphs,
6//! both CSRs are populated.
7
8/// Internal CSR graph representation for the Leiden algorithm.
9///
10/// Each undirected edge is stored twice in the out-edge CSR (once per direction)
11/// so that iterating over all neighbors of a node is O(degree). For directed
12/// graphs, out-edges and in-edges are stored in separate CSRs.
13///
14/// Construction is handled by [`crate::graph::GraphDataBuilder`].
15#[derive(Debug, Clone)]
16pub struct GraphData {
17    pub(crate) n: usize,
18    pub(crate) out_offsets: Vec<usize>,
19    pub(crate) out_targets: Vec<usize>,
20    pub(crate) out_weights: Vec<f64>,
21    pub(crate) total_weight: f64,
22    pub(crate) total_node_weight: f64,
23    pub(crate) out_degree: Vec<f64>,
24    pub(crate) node_weight: Vec<f64>,
25    pub(crate) directed: bool,
26    pub(crate) in_offsets: Vec<usize>,
27    pub(crate) in_targets: Vec<usize>,
28    pub(crate) in_weights: Vec<f64>,
29    pub(crate) in_degree: Vec<f64>,
30}
31
32impl GraphData {
33    /// Number of nodes in the graph.
34    #[inline]
35    pub fn node_count(&self) -> usize {
36        self.n
37    }
38
39    /// Sum of all edge weights (each undirected edge counted once).
40    #[inline]
41    pub fn total_weight(&self) -> f64 {
42        self.total_weight
43    }
44
45    /// Total weight of all nodes (sum of `node_weight`).
46    #[inline]
47    pub fn total_node_weight(&self) -> f64 {
48        self.total_node_weight
49    }
50
51    /// Whether the graph is directed.
52    #[inline]
53    pub fn is_directed(&self) -> bool {
54        self.directed
55    }
56
57    /// Iterate over all `(neighbor, weight)` pairs for a node.
58    ///
59    /// For undirected graphs, this returns all neighbors (out-edge CSR).
60    /// For directed graphs, this returns out-edge neighbors.
61    #[inline]
62    pub fn neighbors(&self, node: usize) -> impl Iterator<Item = (usize, f64)> + '_ {
63        let (targets, weights) = self.neighbor_slices(node);
64        targets
65            .iter()
66            .zip(weights.iter())
67            .map(|(&t, &w)| (t, w))
68    }
69
70    /// Get raw slices of neighbor targets and weights for a node.
71    ///
72    /// For undirected graphs, returns out-edge slices.
73    /// For directed graphs, returns out-edge slices.
74    #[inline]
75    pub fn neighbor_slices(&self, node: usize) -> (&[usize], &[f64]) {
76        if node >= self.n {
77            return (&[], &[]);
78        }
79        let start = self.out_offsets[node];
80        let end = self.out_offsets[node + 1];
81        (&self.out_targets[start..end], &self.out_weights[start..end])
82    }
83
84    /// Get the weighted degree of a node.
85    ///
86    /// For undirected graphs, returns the out-degree (which equals total degree).
87    /// For directed graphs, returns `out_degree + in_degree`.
88    #[inline]
89    pub fn degree_of(&self, node: usize) -> f64 {
90        if node >= self.n {
91            return 0.0;
92        }
93        if self.directed {
94            self.out_degree[node] + self.in_degree[node]
95        } else {
96            self.out_degree[node]
97        }
98    }
99
100    /// Get the weight of a node (1.0 for original nodes, aggregated for super-nodes).
101    #[inline]
102    pub fn node_weight(&self, node: usize) -> f64 {
103        if node >= self.n {
104            return 0.0;
105        }
106        self.node_weight[node]
107    }
108
109    // ── Out-edge accessors ──
110
111    /// Iterate over all out-edges `(target, weight)` for a node.
112    #[inline]
113    pub fn out_neighbors(&self, node: usize) -> impl Iterator<Item = (usize, f64)> + '_ {
114        let (targets, weights) = self.out_neighbor_slices(node);
115        targets
116            .iter()
117            .zip(weights.iter())
118            .map(|(&t, &w)| (t, w))
119    }
120
121    /// Get raw slices of out-edge targets and weights for a node.
122    #[inline]
123    pub fn out_neighbor_slices(&self, node: usize) -> (&[usize], &[f64]) {
124        if node >= self.n {
125            return (&[], &[]);
126        }
127        let start = self.out_offsets[node];
128        let end = self.out_offsets[node + 1];
129        (&self.out_targets[start..end], &self.out_weights[start..end])
130    }
131
132    /// Get the weighted out-degree of a node.
133    #[inline]
134    pub fn out_degree_of(&self, node: usize) -> f64 {
135        if node >= self.n {
136            return 0.0;
137        }
138        self.out_degree[node]
139    }
140
141    // ── In-edge accessors ──
142
143    /// Iterate over all in-edges `(source, weight)` for a node.
144    ///
145    /// Returns an empty iterator for undirected graphs.
146    #[inline]
147    pub fn in_neighbors(&self, node: usize) -> impl Iterator<Item = (usize, f64)> + '_ {
148        let (targets, weights) = self.in_neighbor_slices(node);
149        targets.iter().zip(weights.iter()).map(|(&t, &w)| (t, w))
150    }
151
152    /// Get raw slices of in-edge targets and weights for a node.
153    ///
154    /// Returns empty slices for undirected graphs.
155    #[inline]
156    pub fn in_neighbor_slices(&self, node: usize) -> (&[usize], &[f64]) {
157        if self.directed && node < self.in_offsets.len() - 1 {
158            let start = self.in_offsets[node];
159            let end = self.in_offsets[node + 1];
160            (&self.in_targets[start..end], &self.in_weights[start..end])
161        } else {
162            (&[], &[])
163        }
164    }
165
166    /// Get the weighted in-degree of a node.
167    ///
168    /// Returns `0.0` for undirected graphs.
169    #[inline]
170    pub fn in_degree_of(&self, node: usize) -> f64 {
171        if self.directed && node < self.in_degree.len() {
172            self.in_degree[node]
173        } else {
174            0.0
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::graph::GraphDataBuilder;
183
184    fn make_graph() -> GraphData {
185        let mut b = GraphDataBuilder::new(3);
186        b.add_edge(0, 1, 1.0).unwrap();
187        b.add_edge(1, 2, 2.0).unwrap();
188        b.build().unwrap()
189    }
190
191    /// `in_neighbors(n)` with OOB node — returns empty (already guarded).
192    #[test]
193    fn in_neighbors_ok_on_oob_node() {
194        let g = make_graph();
195        let v: Vec<_> = g.in_neighbors(3).collect();
196        assert!(v.is_empty());
197    }
198
199    /// `in_neighbor_slices(n)` should NOT panic — already guarded.
200    #[test]
201    fn in_neighbor_slices_ok_on_oob_node() {
202        let g = make_graph();
203        let (t, w) = g.in_neighbor_slices(3);
204        assert!(t.is_empty());
205        assert!(w.is_empty());
206    }
207
208    /// `in_degree_of(n)` should NOT panic — already guarded.
209    #[test]
210    fn in_degree_of_ok_on_oob_node() {
211        let g = make_graph();
212        assert_eq!(g.in_degree_of(3), 0.0);
213    }
214
215    // ── GREEN tests: these will pass after fixing ──
216
217    #[test]
218    fn neighbors_returns_empty_for_oob() {
219        let g = make_graph();
220        let v: Vec<_> = g.neighbors(3).collect();
221        assert!(v.is_empty());
222        let v2: Vec<_> = g.neighbors(usize::MAX).collect();
223        assert!(v2.is_empty());
224    }
225
226    #[test]
227    fn neighbor_slices_returns_empty_for_oob() {
228        let g = make_graph();
229        let (t, w) = g.neighbor_slices(3);
230        assert!(t.is_empty());
231        assert!(w.is_empty());
232    }
233
234    #[test]
235    fn degree_of_returns_zero_for_oob() {
236        let g = make_graph();
237        assert_eq!(g.degree_of(3), 0.0);
238    }
239
240    #[test]
241    fn node_weight_returns_zero_for_oob() {
242        let g = make_graph();
243        assert_eq!(g.node_weight(3), 0.0);
244    }
245
246    #[test]
247    fn out_neighbors_returns_empty_for_oob() {
248        let g = make_graph();
249        let v: Vec<_> = g.out_neighbors(3).collect();
250        assert!(v.is_empty());
251    }
252
253    #[test]
254    fn out_neighbor_slices_returns_empty_for_oob() {
255        let g = make_graph();
256        let (t, w) = g.out_neighbor_slices(3);
257        assert!(t.is_empty());
258        assert!(w.is_empty());
259    }
260
261    #[test]
262    fn out_degree_of_returns_zero_for_oob() {
263        let g = make_graph();
264        assert_eq!(g.out_degree_of(3), 0.0);
265    }
266
267    // ── Regression: valid node IDs still work ──
268
269    #[test]
270    fn valid_neighbors_still_work() {
271        let g = make_graph();
272        let v: Vec<_> = g.neighbors(0).collect();
273        assert_eq!(v, vec![(1, 1.0)]);
274        let v2: Vec<_> = g.neighbors(1).collect();
275        // node 1: edge to 0 (via out-edge store) and to 2
276        assert_eq!(v2.len(), 2);
277    }
278
279    #[test]
280    fn valid_neighbor_slices_still_work() {
281        let g = make_graph();
282        let (t, w) = g.neighbor_slices(0);
283        assert_eq!(t.len(), 1);
284        assert_eq!(w.len(), 1);
285    }
286
287    #[test]
288    fn valid_degree_of_still_works() {
289        let g = make_graph();
290        // node 0: degree = 1.0 (weight to node 1)
291        assert_eq!(g.degree_of(0), 1.0);
292    }
293
294    #[test]
295    fn valid_node_weight_still_works() {
296        let g = make_graph();
297        assert_eq!(g.node_weight(0), 1.0);
298    }
299}