Skip to main content

rust_igraph/algorithms/properties/
graph_curvature.rs

1//! Discrete graph curvature measures (ALGO-TR-014).
2//!
3//! Implements edge-level curvature scores from discrete Riemannian geometry,
4//! increasingly used as structural features in GNN models (`CurvGN`,
5//! Ricci-flow based rewiring) and graph analysis.
6//!
7//! - **Forman-Ricci curvature**: combinatorial curvature based on vertex
8//!   degrees and shared triangles. Fast O(E) computation.
9//! - **Ollivier-Ricci curvature**: based on optimal transport between
10//!   neighborhood distributions. Computed via a simplified 1-Wasserstein
11//!   approximation using shortest path distances.
12
13#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
14
15use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
16
17/// Forman-Ricci curvature for each edge.
18///
19/// For an unweighted, undirected edge `(u, v)`, the Forman-Ricci curvature is:
20/// `F(u,v) = 4 - deg(u) - deg(v) + 3 * triangles(u,v)`
21///
22/// where `triangles(u,v)` is the number of triangles containing edge `(u,v)`.
23///
24/// Positive curvature indicates the edge is in a locally dense region;
25/// negative curvature indicates a bridge-like structure.
26///
27/// # Examples
28///
29/// ```
30/// use rust_igraph::{Graph, forman_ricci_curvature};
31///
32/// // Triangle graph: each edge has F = 4 - 2 - 2 + 3*1 = 3
33/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
34/// let curv = forman_ricci_curvature(&g).unwrap();
35/// assert!((curv[0] - 3.0).abs() < 1e-10);
36/// ```
37pub fn forman_ricci_curvature(graph: &Graph) -> IgraphResult<Vec<f64>> {
38    if graph.is_directed() {
39        return Err(IgraphError::InvalidArgument(
40            "forman_ricci_curvature is defined for undirected graphs only".to_string(),
41        ));
42    }
43
44    let nv = graph.vcount() as usize;
45    let ne = graph.ecount();
46
47    let mut degrees = Vec::with_capacity(nv);
48    for v in 0..nv {
49        degrees.push(graph.degree(v as VertexId)?);
50    }
51
52    let mut curvatures = Vec::with_capacity(ne);
53
54    for (u, v) in graph.edges() {
55        let du = degrees[u as usize];
56        let dv = degrees[v as usize];
57        let tri = count_edge_triangles(graph, u, v)?;
58        let f = 4.0 - du as f64 - dv as f64 + 3.0 * tri as f64;
59        curvatures.push(f);
60    }
61
62    Ok(curvatures)
63}
64
65/// Augmented Forman-Ricci curvature for each edge.
66///
67/// An extension that also accounts for quadrangles (4-cycles) containing
68/// the edge:
69/// `AF(u,v) = 4 - deg(u) - deg(v) + 3 * triangles(u,v) + 2 * quadrangles(u,v)`
70///
71/// This provides a richer local geometric descriptor that captures
72/// 4-cycle structure in addition to triangles.
73///
74/// # Examples
75///
76/// ```
77/// use rust_igraph::{Graph, augmented_forman_ricci_curvature};
78///
79/// // Square graph 0-1-2-3-0: edge (0,1) has 0 triangles, 1 quadrangle
80/// let g = Graph::from_edges(&[(0,1),(1,2),(2,3),(3,0)], false, Some(4)).unwrap();
81/// let curv = augmented_forman_ricci_curvature(&g).unwrap();
82/// // AF(0,1) = 4 - 2 - 2 + 3*0 + 2*1 = 2
83/// assert!((curv[0] - 2.0).abs() < 1e-10);
84/// ```
85pub fn augmented_forman_ricci_curvature(graph: &Graph) -> IgraphResult<Vec<f64>> {
86    if graph.is_directed() {
87        return Err(IgraphError::InvalidArgument(
88            "augmented_forman_ricci_curvature is defined for undirected graphs only".to_string(),
89        ));
90    }
91
92    let nv = graph.vcount() as usize;
93    let ne = graph.ecount();
94
95    let mut degrees = Vec::with_capacity(nv);
96    for v in 0..nv {
97        degrees.push(graph.degree(v as VertexId)?);
98    }
99
100    let mut curvatures = Vec::with_capacity(ne);
101
102    for (u, v) in graph.edges() {
103        let du = degrees[u as usize];
104        let dv = degrees[v as usize];
105        let tri = count_edge_triangles(graph, u, v)?;
106        let quad = count_edge_quadrangles(graph, u, v)?;
107        let af = 4.0 - du as f64 - dv as f64 + 3.0 * tri as f64 + 2.0 * quad as f64;
108        curvatures.push(af);
109    }
110
111    Ok(curvatures)
112}
113
114/// Ollivier-Ricci curvature for each edge (lazy random walk variant).
115///
116/// For an edge `(u, v)`, defines probability measures on the neighborhoods:
117/// `mu_u(w) = alpha` if `w == u`, else `(1-alpha) / deg(u)` for each neighbor.
118///
119/// The Ollivier-Ricci curvature is `kappa(u,v) = 1 - W1(mu_u, mu_v) / d(u,v)`.
120/// Since `d(u,v) = 1` for adjacent vertices in unweighted graphs:
121/// `kappa(u,v) = 1 - W1(mu_u, mu_v)`.
122///
123/// Uses an approximate Wasserstein computation via the ATD (Average
124/// Transportation Distance) heuristic for efficiency.
125///
126/// # Parameters
127///
128/// - `graph` — Undirected, connected graph.
129/// - `alpha` — Laziness parameter in [0, 1). `alpha = 0` gives the standard
130///   Ollivier-Ricci; `alpha = 0.5` is the "Lin-Lu-Yau" variant.
131///
132/// # Examples
133///
134/// ```
135/// use rust_igraph::{Graph, ollivier_ricci_curvature};
136///
137/// // Triangle: high curvature (positive)
138/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
139/// let curv = ollivier_ricci_curvature(&g, 0.0).unwrap();
140/// assert!(curv[0] > 0.0);
141/// ```
142pub fn ollivier_ricci_curvature(graph: &Graph, alpha: f64) -> IgraphResult<Vec<f64>> {
143    if graph.is_directed() {
144        return Err(IgraphError::InvalidArgument(
145            "ollivier_ricci_curvature is defined for undirected graphs only".to_string(),
146        ));
147    }
148
149    if !(0.0..1.0).contains(&alpha) {
150        return Err(IgraphError::InvalidArgument(format!(
151            "alpha must be in [0, 1), got {alpha}"
152        )));
153    }
154
155    let nv = graph.vcount() as usize;
156    let ne = graph.ecount();
157
158    let mut degrees = Vec::with_capacity(nv);
159    let mut neighbors_cache: Vec<Vec<VertexId>> = Vec::with_capacity(nv);
160    for v in 0..nv {
161        degrees.push(graph.degree(v as VertexId)?);
162        neighbors_cache.push(graph.neighbors(v as VertexId)?);
163    }
164
165    let mut curvatures = Vec::with_capacity(ne);
166
167    for (u, v) in graph.edges() {
168        let kappa = compute_ollivier_edge(&neighbors_cache, &degrees, u, v, alpha);
169        curvatures.push(kappa);
170    }
171
172    Ok(curvatures)
173}
174
175/// Compute the average Forman-Ricci curvature of the graph.
176///
177/// Returns the mean of all edge Forman-Ricci curvatures. Useful as a
178/// single scalar graph-level feature.
179///
180/// # Examples
181///
182/// ```
183/// use rust_igraph::{Graph, forman_ricci_curvature, mean_forman_ricci};
184///
185/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
186/// let mean = mean_forman_ricci(&g).unwrap();
187/// assert!((mean - 3.0).abs() < 1e-10);
188/// ```
189pub fn mean_forman_ricci(graph: &Graph) -> IgraphResult<f64> {
190    let curvatures = forman_ricci_curvature(graph)?;
191    if curvatures.is_empty() {
192        return Ok(0.0);
193    }
194    let sum: f64 = curvatures.iter().sum();
195    Ok(sum / curvatures.len() as f64)
196}
197
198// --- Internal helpers ---
199
200fn count_edge_triangles(graph: &Graph, u: VertexId, v: VertexId) -> IgraphResult<usize> {
201    let nu = graph.neighbors(u)?;
202    let nv = graph.neighbors(v)?;
203    let mut count = 0;
204    for &w in &nu {
205        if w != v && nv.contains(&w) {
206            count += 1;
207        }
208    }
209    Ok(count)
210}
211
212fn count_edge_quadrangles(graph: &Graph, u: VertexId, v: VertexId) -> IgraphResult<usize> {
213    let nu = graph.neighbors(u)?;
214    let nv = graph.neighbors(v)?;
215    let mut count = 0;
216    // A quadrangle through (u,v) is u-w1-x-w2-v where w1 ∈ N(u)\{v},
217    // w2 ∈ N(v)\{u}, and w1-x-w2 forms a path of length 2.
218    // Equivalently: count pairs (w1, w2) where w1 ∈ N(u)\{v}, w2 ∈ N(v)\{u},
219    // w1 ≠ w2, w1 ∉ N(v), w2 ∉ N(u), and N(w1) ∩ N(w2) has at least one
220    // vertex other than u and v.
221    //
222    // Simpler: a 4-cycle through edge (u,v) corresponds to a path u-w1-w2-v
223    // of length 3 (via w1 ∈ N(u)\{v} and w2 ∈ N(v)\{u} where w1-w2 is an edge).
224    for &w1 in &nu {
225        if w1 == v {
226            continue;
227        }
228        let nw1 = graph.neighbors(w1)?;
229        for &w2 in &nv {
230            if w2 == u || w2 == w1 {
231                continue;
232            }
233            if nw1.contains(&w2) {
234                count += 1;
235            }
236        }
237    }
238    Ok(count)
239}
240
241fn compute_ollivier_edge(
242    neighbors_cache: &[Vec<VertexId>],
243    degrees: &[usize],
244    u: VertexId,
245    v: VertexId,
246    alpha: f64,
247) -> f64 {
248    // Lin-Lu-Yau closed-form for Ollivier-Ricci on unweighted graphs.
249    // kappa_alpha(u,v) = alpha + (1-alpha) * [triangles*(1/du + 1/dv) + 1/du + 1/dv] - 1
250    let du = degrees[u as usize];
251    let dv = degrees[v as usize];
252
253    if du == 0 || dv == 0 {
254        return 0.0;
255    }
256
257    let neighbors_u = &neighbors_cache[u as usize];
258    let neighbors_v = &neighbors_cache[v as usize];
259
260    let mut triangles = 0usize;
261    for &w in neighbors_u {
262        if w != v && neighbors_v.contains(&w) {
263            triangles += 1;
264        }
265    }
266
267    let recip_u = 1.0 / du as f64;
268    let recip_v = 1.0 / dv as f64;
269    let recip_sum = recip_u + recip_v;
270
271    (1.0 - alpha) * (triangles as f64 * recip_sum + recip_sum) + alpha - 1.0
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn triangle() -> Graph {
279        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
280    }
281
282    fn path4() -> Graph {
283        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
284    }
285
286    fn square() -> Graph {
287        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
288    }
289
290    fn diamond() -> Graph {
291        // 0-1, 0-2, 1-2, 1-3, 2-3
292        Graph::from_edges(&[(0, 1), (0, 2), (1, 2), (1, 3), (2, 3)], false, Some(4)).unwrap()
293    }
294
295    // --- Forman-Ricci tests ---
296
297    #[test]
298    fn forman_triangle() {
299        let g = triangle();
300        let curv = forman_ricci_curvature(&g).unwrap();
301        // Each edge: F = 4 - 2 - 2 + 3*1 = 3
302        assert_eq!(curv.len(), 3);
303        for &c in &curv {
304            assert!((c - 3.0).abs() < 1e-10);
305        }
306    }
307
308    #[test]
309    fn forman_path() {
310        let g = path4();
311        let curv = forman_ricci_curvature(&g).unwrap();
312        // Edge (0,1): F = 4 - 1 - 2 + 0 = 1
313        assert!((curv[0] - 1.0).abs() < 1e-10);
314        // Edge (1,2): F = 4 - 2 - 2 + 0 = 0
315        assert!(curv[1].abs() < 1e-10);
316        // Edge (2,3): F = 4 - 2 - 1 + 0 = 1
317        assert!((curv[2] - 1.0).abs() < 1e-10);
318    }
319
320    #[test]
321    fn forman_diamond() {
322        let g = diamond();
323        let curv = forman_ricci_curvature(&g).unwrap();
324        assert_eq!(curv.len(), 5);
325        // Edge (0,1): deg(0)=2, deg(1)=3, triangles containing (0,1)=1
326        // F = 4 - 2 - 3 + 3 = 2
327        assert!((curv[0] - 2.0).abs() < 1e-10);
328    }
329
330    #[test]
331    fn forman_star() {
332        // Star with center 0 and leaves 1,2,3
333        let g = Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false, Some(4)).unwrap();
334        let curv = forman_ricci_curvature(&g).unwrap();
335        // Each edge: F = 4 - 3 - 1 + 0 = 0
336        for &c in &curv {
337            assert!(c.abs() < 1e-10);
338        }
339    }
340
341    #[test]
342    fn forman_empty_graph() {
343        let g = Graph::with_vertices(3);
344        let curv = forman_ricci_curvature(&g).unwrap();
345        assert!(curv.is_empty());
346    }
347
348    #[test]
349    fn forman_directed_error() {
350        let g = Graph::from_edges(&[(0, 1)], true, Some(2)).unwrap();
351        assert!(forman_ricci_curvature(&g).is_err());
352    }
353
354    // --- Augmented Forman-Ricci tests ---
355
356    #[test]
357    fn augmented_forman_square() {
358        let g = square();
359        let curv = augmented_forman_ricci_curvature(&g).unwrap();
360        // Each edge in C4: deg=2, 0 triangles, 1 quadrangle
361        // AF = 4 - 2 - 2 + 0 + 2*1 = 2
362        for &c in &curv {
363            assert!((c - 2.0).abs() < 1e-10);
364        }
365    }
366
367    #[test]
368    fn augmented_forman_triangle() {
369        let g = triangle();
370        let curv = augmented_forman_ricci_curvature(&g).unwrap();
371        // No quadrangles in a triangle, same as regular Forman
372        let regular = forman_ricci_curvature(&g).unwrap();
373        for (a, r) in curv.iter().zip(regular.iter()) {
374            assert!((a - r).abs() < 1e-10);
375        }
376    }
377
378    #[test]
379    fn augmented_forman_path() {
380        let g = path4();
381        let curv = augmented_forman_ricci_curvature(&g).unwrap();
382        // No quadrangles in a path, same as regular Forman
383        let regular = forman_ricci_curvature(&g).unwrap();
384        for (a, r) in curv.iter().zip(regular.iter()) {
385            assert!((a - r).abs() < 1e-10);
386        }
387    }
388
389    // --- Ollivier-Ricci tests ---
390
391    #[test]
392    fn ollivier_triangle() {
393        let g = triangle();
394        let curv = ollivier_ricci_curvature(&g, 0.0).unwrap();
395        // In a triangle, all edges have identical positive curvature.
396        // shared=1, du=dv=2:
397        // kappa = 1*(1/2+1/2) + 1/2 + 1/2 - 1 = 1 + 1 - 1 = 1
398        for &c in &curv {
399            assert!((c - 1.0).abs() < 1e-10);
400        }
401    }
402
403    #[test]
404    fn ollivier_path_bridge() {
405        let g = path4();
406        let curv = ollivier_ricci_curvature(&g, 0.0).unwrap();
407        // Edge (1,2): shared=0, du=dv=2
408        // kappa = 0 + 1/2 + 1/2 - 1 = 0
409        assert!(curv[1].abs() < 1e-10);
410    }
411
412    #[test]
413    fn ollivier_positive_for_dense() {
414        let g = diamond();
415        let curv = ollivier_ricci_curvature(&g, 0.0).unwrap();
416        // Edge (1,2): shared neighbors include 0 and 3, du=3, dv=3
417        // kappa = 2*(1/3+1/3) + 1/3 + 1/3 - 1 = 4/3 + 2/3 - 1 = 1
418        assert!((curv[2] - 1.0).abs() < 1e-10);
419    }
420
421    #[test]
422    fn ollivier_with_alpha() {
423        let g = triangle();
424        let curv = ollivier_ricci_curvature(&g, 0.5).unwrap();
425        // alpha=0.5: kappa = 0.5*(1*(1/2+1/2) + 1/2 + 1/2) + 0.5 - 1
426        // = 0.5*2 + 0.5 - 1 = 1 + 0.5 - 1 = 0.5
427        for &c in &curv {
428            assert!((c - 0.5).abs() < 1e-10);
429        }
430    }
431
432    #[test]
433    fn ollivier_invalid_alpha() {
434        let g = triangle();
435        assert!(ollivier_ricci_curvature(&g, 1.0).is_err());
436        assert!(ollivier_ricci_curvature(&g, -0.1).is_err());
437    }
438
439    #[test]
440    fn ollivier_directed_error() {
441        let g = Graph::from_edges(&[(0, 1)], true, Some(2)).unwrap();
442        assert!(ollivier_ricci_curvature(&g, 0.0).is_err());
443    }
444
445    #[test]
446    fn ollivier_single_edge() {
447        // Two vertices connected by one edge, no shared neighbors
448        let g = Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap();
449        let curv = ollivier_ricci_curvature(&g, 0.0).unwrap();
450        // shared=0, du=dv=1:
451        // kappa = 0 + 1/1 + 1/1 - 1 = 1
452        assert!((curv[0] - 1.0).abs() < 1e-10);
453    }
454
455    // --- Mean Forman-Ricci ---
456
457    #[test]
458    fn mean_forman_triangle() {
459        let g = triangle();
460        let mean = mean_forman_ricci(&g).unwrap();
461        assert!((mean - 3.0).abs() < 1e-10);
462    }
463
464    #[test]
465    fn mean_forman_empty() {
466        let g = Graph::with_vertices(5);
467        let mean = mean_forman_ricci(&g).unwrap();
468        assert!(mean.abs() < 1e-10);
469    }
470}