Skip to main content

rust_igraph/algorithms/properties/
diffusion.rs

1//! Graph signal diffusion operators (ALGO-TR-011).
2//!
3//! Core primitives for propagating signals on graphs, widely used in GNNs
4//! (GCN, APPNP, GPR-GNN), graph semi-supervised learning, and graph signal
5//! processing.
6//!
7//! - **Heat kernel diffusion**: approximates `exp(-t·L)·signal` via truncated
8//!   Taylor series, where `L = I - D⁻¹A` is the normalized Laplacian.
9//! - **PPR diffusion**: computes the Personalized `PageRank` propagation
10//!   `α·(I - (1-α)·D⁻¹A)⁻¹·signal` via power iteration.
11
12#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
13
14use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
15
16/// Diffuse a signal on the graph using the heat kernel.
17///
18/// Approximates `exp(-t·L)·signal` where `L = I - D⁻¹A` is the random-walk
19/// normalized Laplacian. Uses a truncated Taylor expansion:
20/// `exp(-t·L) ≈ Σ_{k=0}^{K} (-t)^k / k! · L^k`
21///
22/// Equivalently this iterates: `s_{k+1} = D⁻¹A · s_k` and accumulates
23/// the weighted sum `Σ (t^k · e^{-t} / k!) · s_k` (Poisson weights).
24///
25/// # Parameters
26///
27/// - `graph` — Undirected graph.
28/// - `signal` — Input signal of length `vcount`.
29/// - `t` — Diffusion time (heat parameter, t > 0). Larger = more smoothing.
30/// - `max_terms` — Maximum number of Taylor terms (default: 20).
31///
32/// # Returns
33///
34/// The diffused signal as `Vec<f64>` of length `vcount`.
35///
36/// # Examples
37///
38/// ```
39/// use rust_igraph::{Graph, heat_kernel_diffuse};
40///
41/// // Path graph: signal on first vertex spreads to neighbors
42/// let g = Graph::from_edges(&[(0,1),(1,2),(2,3)], false, Some(4)).unwrap();
43/// let signal = vec![1.0, 0.0, 0.0, 0.0];
44/// let diffused = heat_kernel_diffuse(&g, &signal, 1.0, 20).unwrap();
45/// // Signal should have spread: vertex 0 < 1.0, vertex 1 > 0.0
46/// assert!(diffused[0] < 1.0);
47/// assert!(diffused[1] > 0.0);
48/// // Total signal is approximately preserved (heat kernel is doubly stochastic
49/// // on regular graphs, approximately preserved on irregular ones)
50/// ```
51pub fn heat_kernel_diffuse(
52    graph: &Graph,
53    signal: &[f64],
54    t: f64,
55    max_terms: usize,
56) -> IgraphResult<Vec<f64>> {
57    let nv = graph.vcount() as usize;
58
59    if signal.len() != nv {
60        return Err(IgraphError::InvalidArgument(format!(
61            "signal length {} does not match vcount {}",
62            signal.len(),
63            nv
64        )));
65    }
66
67    if t <= 0.0 {
68        return Err(IgraphError::InvalidArgument(
69            "diffusion time t must be positive".to_string(),
70        ));
71    }
72
73    if graph.is_directed() {
74        return Err(IgraphError::InvalidArgument(
75            "heat_kernel_diffuse is defined for undirected graphs only".to_string(),
76        ));
77    }
78
79    let degrees = compute_degrees(graph, nv)?;
80
81    // Poisson-weighted accumulation: result = Σ_{k=0}^{K} w_k · s_k
82    // where w_k = t^k · e^{-t} / k! and s_k = (D⁻¹A)^k · signal
83    let mut current = signal.to_vec();
84    let mut result = vec![0.0_f64; nv];
85
86    let mut poisson_weight = (-t).exp(); // w_0 = e^{-t}
87    add_scaled(&mut result, &current, poisson_weight);
88
89    for k in 1..=max_terms {
90        current = apply_transition(graph, &current, &degrees, nv)?;
91        poisson_weight *= t / k as f64;
92        add_scaled(&mut result, &current, poisson_weight);
93
94        if poisson_weight.abs() < 1e-15 {
95            break;
96        }
97    }
98
99    Ok(result)
100}
101
102/// Diffuse a signal using Personalized `PageRank` propagation.
103///
104/// Computes the APPNP-style propagation:
105/// `output = α·signal + (1-α)·D⁻¹A·output` (solved by power iteration).
106///
107/// This converges to `α·(I - (1-α)·D⁻¹A)⁻¹·signal`, the Personalized
108/// `PageRank` vector with `signal` as the teleportation distribution.
109///
110/// # Parameters
111///
112/// - `graph` — Undirected graph.
113/// - `signal` — Input signal of length `vcount`.
114/// - `alpha` — Teleportation probability (0 < alpha ≤ 1). Typical: 0.1–0.2.
115/// - `max_iter` — Maximum power iterations (default: 50).
116/// - `tol` — Convergence tolerance on L∞ change (default: 1e-6).
117///
118/// # Returns
119///
120/// The diffused signal as `Vec<f64>` of length `vcount`.
121///
122/// # Examples
123///
124/// ```
125/// use rust_igraph::{Graph, ppr_diffuse};
126///
127/// // Complete graph: PPR with uniform signal stays uniform
128/// let g = Graph::from_edges(
129///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
130/// ).unwrap();
131/// let signal = vec![0.25, 0.25, 0.25, 0.25];
132/// let diffused = ppr_diffuse(&g, &signal, 0.15, 50, 1e-6).unwrap();
133/// for &v in &diffused {
134///     assert!((v - 0.25).abs() < 1e-6);
135/// }
136/// ```
137pub fn ppr_diffuse(
138    graph: &Graph,
139    signal: &[f64],
140    alpha: f64,
141    max_iter: usize,
142    tol: f64,
143) -> IgraphResult<Vec<f64>> {
144    let nv = graph.vcount() as usize;
145
146    if signal.len() != nv {
147        return Err(IgraphError::InvalidArgument(format!(
148            "signal length {} does not match vcount {}",
149            signal.len(),
150            nv
151        )));
152    }
153
154    if alpha <= 0.0 || alpha > 1.0 {
155        return Err(IgraphError::InvalidArgument(format!(
156            "alpha must be in (0.0, 1.0], got {alpha}"
157        )));
158    }
159
160    if graph.is_directed() {
161        return Err(IgraphError::InvalidArgument(
162            "ppr_diffuse is defined for undirected graphs only".to_string(),
163        ));
164    }
165
166    let degrees = compute_degrees(graph, nv)?;
167    let one_minus_alpha = 1.0 - alpha;
168
169    // Power iteration: z_{k+1} = α·signal + (1-α)·D⁻¹A·z_k
170    let mut z = signal.to_vec();
171
172    for _ in 0..max_iter {
173        let propagated = apply_transition(graph, &z, &degrees, nv)?;
174
175        let mut max_diff = 0.0_f64;
176        for i in 0..nv {
177            let new_val = alpha * signal[i] + one_minus_alpha * propagated[i];
178            let diff = (new_val - z[i]).abs();
179            if diff > max_diff {
180                max_diff = diff;
181            }
182            z[i] = new_val;
183        }
184
185        if max_diff < tol {
186            break;
187        }
188    }
189
190    Ok(z)
191}
192
193/// Diffuse a signal using symmetric normalized propagation.
194///
195/// Computes `D^{-1/2} A D^{-1/2} · signal`, the GCN-style propagation
196/// operator (Kipf & Welling, 2017). Can be iterated `k` times for
197/// multi-hop smoothing.
198///
199/// # Parameters
200///
201/// - `graph` — Undirected graph.
202/// - `signal` — Input signal of length `vcount`.
203/// - `k` — Number of propagation steps.
204///
205/// # Returns
206///
207/// The propagated signal after `k` steps.
208///
209/// # Examples
210///
211/// ```
212/// use rust_igraph::{Graph, symmetric_diffuse};
213///
214/// // Triangle: uniform signal is an eigenvector, stays unchanged
215/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
216/// let signal = vec![1.0, 1.0, 1.0];
217/// let out = symmetric_diffuse(&g, &signal, 1).unwrap();
218/// for &v in &out {
219///     assert!((v - 1.0).abs() < 1e-10);
220/// }
221/// ```
222pub fn symmetric_diffuse(graph: &Graph, signal: &[f64], k: usize) -> IgraphResult<Vec<f64>> {
223    let nv = graph.vcount() as usize;
224
225    if signal.len() != nv {
226        return Err(IgraphError::InvalidArgument(format!(
227            "signal length {} does not match vcount {}",
228            signal.len(),
229            nv
230        )));
231    }
232
233    if graph.is_directed() {
234        return Err(IgraphError::InvalidArgument(
235            "symmetric_diffuse is defined for undirected graphs only".to_string(),
236        ));
237    }
238
239    let degrees = compute_degrees(graph, nv)?;
240
241    // Precompute D^{-1/2}
242    let inv_sqrt_deg: Vec<f64> = degrees
243        .iter()
244        .map(|&d| if d == 0 { 0.0 } else { 1.0 / (d as f64).sqrt() })
245        .collect();
246
247    let mut current = signal.to_vec();
248
249    for _ in 0..k {
250        // Apply D^{-1/2} A D^{-1/2}:
251        // For vertex v: result[v] = Σ_{u∈N(v)} inv_sqrt_deg[v] * inv_sqrt_deg[u] * current[u]
252        let mut next = vec![0.0_f64; nv];
253
254        for v in 0..nv {
255            if degrees[v] == 0 {
256                continue;
257            }
258            let neighbors = graph.neighbors(v as VertexId)?;
259            let mut sum = 0.0;
260            for &u in &neighbors {
261                let u_idx = u as usize;
262                sum += inv_sqrt_deg[u_idx] * current[u_idx];
263            }
264            next[v] = inv_sqrt_deg[v] * sum;
265        }
266
267        current = next;
268    }
269
270    Ok(current)
271}
272
273// --- Internal helpers ---
274
275fn compute_degrees(graph: &Graph, nv: usize) -> IgraphResult<Vec<usize>> {
276    let mut degrees = Vec::with_capacity(nv);
277    for v in 0..nv {
278        degrees.push(graph.degree(v as VertexId)?);
279    }
280    Ok(degrees)
281}
282
283/// Apply the random walk transition matrix D⁻¹A to a vector.
284fn apply_transition(
285    graph: &Graph,
286    signal: &[f64],
287    degrees: &[usize],
288    nv: usize,
289) -> IgraphResult<Vec<f64>> {
290    let mut result = vec![0.0_f64; nv];
291
292    for v in 0..nv {
293        if signal[v] == 0.0 {
294            continue;
295        }
296        let deg = degrees[v];
297        if deg == 0 {
298            result[v] += signal[v];
299            continue;
300        }
301        let weight = signal[v] / deg as f64;
302        let neighbors = graph.neighbors(v as VertexId)?;
303        for &u in &neighbors {
304            result[u as usize] += weight;
305        }
306    }
307
308    Ok(result)
309}
310
311fn add_scaled(target: &mut [f64], source: &[f64], scale: f64) {
312    for (t, &s) in target.iter_mut().zip(source.iter()) {
313        *t += s * scale;
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    fn path4() -> Graph {
322        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
323    }
324
325    fn triangle() -> Graph {
326        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
327    }
328
329    fn complete4() -> Graph {
330        Graph::from_edges(
331            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
332            false,
333            Some(4),
334        )
335        .unwrap()
336    }
337
338    // --- heat_kernel_diffuse tests ---
339
340    #[test]
341    fn heat_zero_signal() {
342        let g = path4();
343        let signal = vec![0.0; 4];
344        let result = heat_kernel_diffuse(&g, &signal, 1.0, 20).unwrap();
345        for &v in &result {
346            assert!((v).abs() < 1e-15);
347        }
348    }
349
350    #[test]
351    fn heat_uniform_signal_regular() {
352        let g = triangle();
353        let signal = vec![1.0, 1.0, 1.0];
354        let result = heat_kernel_diffuse(&g, &signal, 2.0, 30).unwrap();
355        for &v in &result {
356            assert!((v - 1.0).abs() < 1e-10);
357        }
358    }
359
360    #[test]
361    fn heat_signal_spreads() {
362        let g = path4();
363        let signal = vec![1.0, 0.0, 0.0, 0.0];
364        let result = heat_kernel_diffuse(&g, &signal, 1.0, 20).unwrap();
365        assert!(result[0] < 1.0);
366        assert!(result[1] > 0.0);
367        assert!(result[2] > 0.0);
368    }
369
370    #[test]
371    fn heat_nonnegative() {
372        let g = path4();
373        let signal = vec![1.0, 0.0, 0.0, 0.0];
374        let result = heat_kernel_diffuse(&g, &signal, 0.5, 20).unwrap();
375        for &v in &result {
376            assert!(v >= -1e-15);
377        }
378    }
379
380    #[test]
381    fn heat_invalid_signal_length() {
382        let g = path4();
383        assert!(heat_kernel_diffuse(&g, &[1.0, 2.0], 1.0, 20).is_err());
384    }
385
386    #[test]
387    fn heat_invalid_t() {
388        let g = path4();
389        assert!(heat_kernel_diffuse(&g, &[0.0; 4], 0.0, 20).is_err());
390        assert!(heat_kernel_diffuse(&g, &[0.0; 4], -1.0, 20).is_err());
391    }
392
393    #[test]
394    fn heat_directed_error() {
395        let g = Graph::from_edges(&[(0, 1), (1, 2)], true, Some(3)).unwrap();
396        assert!(heat_kernel_diffuse(&g, &[1.0; 3], 1.0, 20).is_err());
397    }
398
399    #[test]
400    fn heat_isolated_vertex() {
401        let g = Graph::with_vertices(3);
402        let signal = vec![1.0, 2.0, 3.0];
403        let result = heat_kernel_diffuse(&g, &signal, 1.0, 20).unwrap();
404        for i in 0..3 {
405            assert!((result[i] - signal[i]).abs() < 1e-10);
406        }
407    }
408
409    // --- ppr_diffuse tests ---
410
411    #[test]
412    fn ppr_uniform_on_regular() {
413        let g = complete4();
414        let signal = vec![0.25, 0.25, 0.25, 0.25];
415        let result = ppr_diffuse(&g, &signal, 0.15, 50, 1e-10).unwrap();
416        for &v in &result {
417            assert!((v - 0.25).abs() < 1e-8);
418        }
419    }
420
421    #[test]
422    fn ppr_signal_spreads() {
423        let g = path4();
424        let signal = vec![1.0, 0.0, 0.0, 0.0];
425        let result = ppr_diffuse(&g, &signal, 0.15, 50, 1e-10).unwrap();
426        // Signal concentrated at vertex 0 spreads to neighbors
427        assert!(result[0] > 0.0);
428        assert!(result[1] > 0.0);
429        // Distant vertices get less signal
430        assert!(result[1] > result[3]);
431        assert!(result[2] > result[3]);
432    }
433
434    #[test]
435    fn ppr_alpha_one_is_identity() {
436        let g = path4();
437        let signal = vec![1.0, 2.0, 3.0, 4.0];
438        let result = ppr_diffuse(&g, &signal, 1.0, 50, 1e-10).unwrap();
439        for i in 0..4 {
440            assert!((result[i] - signal[i]).abs() < 1e-10);
441        }
442    }
443
444    #[test]
445    fn ppr_nonnegative() {
446        let g = path4();
447        let signal = vec![1.0, 0.0, 0.0, 0.0];
448        let result = ppr_diffuse(&g, &signal, 0.2, 50, 1e-10).unwrap();
449        for &v in &result {
450            assert!(v >= -1e-15);
451        }
452    }
453
454    #[test]
455    fn ppr_invalid_alpha() {
456        let g = path4();
457        assert!(ppr_diffuse(&g, &[0.0; 4], 0.0, 50, 1e-6).is_err());
458        assert!(ppr_diffuse(&g, &[0.0; 4], 1.5, 50, 1e-6).is_err());
459        assert!(ppr_diffuse(&g, &[0.0; 4], -0.1, 50, 1e-6).is_err());
460    }
461
462    #[test]
463    fn ppr_invalid_signal_length() {
464        let g = path4();
465        assert!(ppr_diffuse(&g, &[1.0], 0.15, 50, 1e-6).is_err());
466    }
467
468    #[test]
469    fn ppr_directed_error() {
470        let g = Graph::from_edges(&[(0, 1), (1, 2)], true, Some(3)).unwrap();
471        assert!(ppr_diffuse(&g, &[1.0; 3], 0.15, 50, 1e-6).is_err());
472    }
473
474    #[test]
475    fn ppr_isolated_vertex() {
476        let g = Graph::with_vertices(3);
477        let signal = vec![1.0, 2.0, 3.0];
478        let result = ppr_diffuse(&g, &signal, 0.15, 50, 1e-10).unwrap();
479        for i in 0..3 {
480            assert!((result[i] - signal[i]).abs() < 1e-10);
481        }
482    }
483
484    // --- symmetric_diffuse tests ---
485
486    #[test]
487    fn symmetric_uniform_regular() {
488        let g = triangle();
489        let signal = vec![1.0, 1.0, 1.0];
490        let result = symmetric_diffuse(&g, &signal, 3).unwrap();
491        for &v in &result {
492            assert!((v - 1.0).abs() < 1e-10);
493        }
494    }
495
496    #[test]
497    fn symmetric_zero_steps() {
498        let g = path4();
499        let signal = vec![1.0, 2.0, 3.0, 4.0];
500        let result = symmetric_diffuse(&g, &signal, 0).unwrap();
501        for i in 0..4 {
502            assert!((result[i] - signal[i]).abs() < 1e-15);
503        }
504    }
505
506    #[test]
507    fn symmetric_signal_smooths() {
508        let g = path4();
509        let signal = vec![4.0, 0.0, 0.0, 0.0];
510        let result = symmetric_diffuse(&g, &signal, 1).unwrap();
511        // After one step, signal should have spread
512        assert!(result[0] < 4.0);
513        assert!(result[1] > 0.0);
514    }
515
516    #[test]
517    fn symmetric_invalid_signal_length() {
518        let g = path4();
519        assert!(symmetric_diffuse(&g, &[1.0], 1).is_err());
520    }
521
522    #[test]
523    fn symmetric_directed_error() {
524        let g = Graph::from_edges(&[(0, 1)], true, Some(2)).unwrap();
525        assert!(symmetric_diffuse(&g, &[1.0; 2], 1).is_err());
526    }
527
528    #[test]
529    fn symmetric_isolated_vertex() {
530        let g = Graph::with_vertices(2);
531        let signal = vec![5.0, 3.0];
532        let result = symmetric_diffuse(&g, &signal, 5).unwrap();
533        // Isolated vertices: signal stays (no neighbors)
534        assert!((result[0]).abs() < 1e-15);
535        assert!((result[1]).abs() < 1e-15);
536    }
537}