Skip to main content

rust_igraph/algorithms/properties/
signal_smoothness.rs

1//! Graph signal smoothness metrics (ALGO-TR-019).
2//!
3//! Measures how smoothly a real-valued signal varies over a graph,
4//! central to graph signal processing (GSP), GNN over-smoothing
5//! analysis, and semi-supervised learning.
6//!
7//! - **Dirichlet energy**: `Σ_{(u,v)∈E} w(u,v) · (f(u) - f(v))²`
8//! - **Total variation**: `Σ_{(u,v)∈E} w(u,v) · |f(u) - f(v)|`
9//! - **Normalized Dirichlet energy**: Dirichlet energy divided by
10//!   `Σ_v deg(v) · f(v)²` (the "Rayleigh quotient" form)
11//! - **Smoothness ratio**: `1 - Dirichlet / (2 · Σ_{(u,v)} w · (f(u)² + f(v)²))`
12
13#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
14
15use crate::core::{Graph, IgraphError, IgraphResult};
16
17/// Compute the Dirichlet energy of a signal on a graph.
18///
19/// `E_D(f) = Σ_{(u,v)∈E} w(u,v) · (f(u) - f(v))²`
20///
21/// Measures the total squared variation of the signal across edges.
22/// A constant signal has Dirichlet energy zero; a signal that varies
23/// wildly across edges has high energy.
24///
25/// # Parameters
26///
27/// - `graph` — The input graph (directed or undirected).
28/// - `signal` — Signal values, one per vertex.
29/// - `weights` — Optional edge weights. If `None`, each edge has weight 1.
30///
31/// # Examples
32///
33/// ```
34/// use rust_igraph::{Graph, dirichlet_energy};
35///
36/// let g = Graph::from_edges(&[(0,1),(1,2),(2,3)], false, Some(4)).unwrap();
37/// // Constant signal → energy = 0
38/// let e = dirichlet_energy(&g, &[5.0, 5.0, 5.0, 5.0], None).unwrap();
39/// assert!(e.abs() < 1e-10);
40/// // Step signal → energy > 0
41/// let e2 = dirichlet_energy(&g, &[0.0, 0.0, 1.0, 1.0], None).unwrap();
42/// assert!((e2 - 1.0).abs() < 1e-10);
43/// ```
44pub fn dirichlet_energy(
45    graph: &Graph,
46    signal: &[f64],
47    weights: Option<&[f64]>,
48) -> IgraphResult<f64> {
49    validate_inputs(graph, signal, weights)?;
50
51    let mut energy = 0.0_f64;
52    for (eid, (u, v)) in graph.edges().enumerate() {
53        let w = weights.map_or(1.0, |ws| ws[eid]);
54        let diff = signal[u as usize] - signal[v as usize];
55        energy += w * diff * diff;
56    }
57
58    Ok(energy)
59}
60
61/// Compute the total variation of a signal on a graph.
62///
63/// `TV(f) = Σ_{(u,v)∈E} w(u,v) · |f(u) - f(v)|`
64///
65/// The L1 analog of Dirichlet energy, less sensitive to outlier edges.
66///
67/// # Examples
68///
69/// ```
70/// use rust_igraph::{Graph, total_variation};
71///
72/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
73/// let tv = total_variation(&g, &[0.0, 1.0, 3.0], None).unwrap();
74/// assert!((tv - 3.0).abs() < 1e-10); // |0-1| + |1-3| = 1 + 2
75/// ```
76pub fn total_variation(
77    graph: &Graph,
78    signal: &[f64],
79    weights: Option<&[f64]>,
80) -> IgraphResult<f64> {
81    validate_inputs(graph, signal, weights)?;
82
83    let mut tv = 0.0_f64;
84    for (eid, (u, v)) in graph.edges().enumerate() {
85        let w = weights.map_or(1.0, |ws| ws[eid]);
86        tv += w * (signal[u as usize] - signal[v as usize]).abs();
87    }
88
89    Ok(tv)
90}
91
92/// Compute the normalized Dirichlet energy (Rayleigh quotient form).
93///
94/// `E_norm = Σ_{(u,v)∈E} w(u,v)·(f(u)-f(v))² / Σ_v deg(v)·f(v)²`
95///
96/// This equals the Rayleigh quotient `f^T L f / f^T D f` of the graph
97/// Laplacian, bounded in `[0, 2]` for undirected graphs. Values near 0
98/// indicate smooth signals; values near 2 indicate maximally varying signals.
99///
100/// Returns `0.0` if the denominator is zero (all-zero signal or isolated
101/// vertices).
102///
103/// # Examples
104///
105/// ```
106/// use rust_igraph::{Graph, normalized_dirichlet_energy};
107///
108/// // Constant non-zero signal → 0
109/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
110/// let e = normalized_dirichlet_energy(&g, &[3.0, 3.0, 3.0], None).unwrap();
111/// assert!(e.abs() < 1e-10);
112/// ```
113pub fn normalized_dirichlet_energy(
114    graph: &Graph,
115    signal: &[f64],
116    weights: Option<&[f64]>,
117) -> IgraphResult<f64> {
118    validate_inputs(graph, signal, weights)?;
119
120    let nv = graph.vcount() as usize;
121
122    let mut numerator = 0.0_f64;
123    let mut deg_weighted = vec![0.0_f64; nv];
124
125    for (eid, (u, v)) in graph.edges().enumerate() {
126        let w = weights.map_or(1.0, |ws| ws[eid]);
127        let diff = signal[u as usize] - signal[v as usize];
128        numerator += w * diff * diff;
129        deg_weighted[u as usize] += w;
130        deg_weighted[v as usize] += w;
131    }
132
133    let mut denominator = 0.0_f64;
134    for v in 0..nv {
135        denominator += deg_weighted[v] * signal[v] * signal[v];
136    }
137
138    if denominator.abs() < 1e-300 {
139        return Ok(0.0);
140    }
141
142    Ok(numerator / denominator)
143}
144
145/// Compute the smoothness ratio of a signal on a graph.
146///
147/// `SR = 1 - E_D / (2 · Σ_{(u,v)} w · (f(u)² + f(v)²))`
148///
149/// Bounded in `[0, 1]` for non-negative weights. A value of 1 means the
150/// signal is perfectly smooth (constant on each connected component);
151/// a value of 0 means maximally rough.
152///
153/// Returns `1.0` if the denominator is zero (no edges or all-zero signal).
154///
155/// # Examples
156///
157/// ```
158/// use rust_igraph::{Graph, smoothness_ratio};
159///
160/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
161/// // Constant signal → perfectly smooth
162/// let sr = smoothness_ratio(&g, &[2.0, 2.0, 2.0], None).unwrap();
163/// assert!((sr - 1.0).abs() < 1e-10);
164/// ```
165pub fn smoothness_ratio(
166    graph: &Graph,
167    signal: &[f64],
168    weights: Option<&[f64]>,
169) -> IgraphResult<f64> {
170    validate_inputs(graph, signal, weights)?;
171
172    let mut dirichlet = 0.0_f64;
173    let mut sum_sq = 0.0_f64;
174
175    for (eid, (u, v)) in graph.edges().enumerate() {
176        let w = weights.map_or(1.0, |ws| ws[eid]);
177        let fu = signal[u as usize];
178        let fv = signal[v as usize];
179        dirichlet += w * (fu - fv) * (fu - fv);
180        sum_sq += w * (fu * fu + fv * fv);
181    }
182
183    let denom = 2.0 * sum_sq;
184    if denom.abs() < 1e-300 {
185        return Ok(1.0);
186    }
187
188    Ok(1.0 - dirichlet / denom)
189}
190
191// --- Internal helpers ---
192
193fn validate_inputs(graph: &Graph, signal: &[f64], weights: Option<&[f64]>) -> IgraphResult<()> {
194    let nv = graph.vcount() as usize;
195    let ne = graph.ecount();
196
197    if signal.len() != nv {
198        return Err(IgraphError::InvalidArgument(format!(
199            "signal length {} does not match vcount {nv}",
200            signal.len()
201        )));
202    }
203
204    if let Some(w) = weights {
205        if w.len() != ne {
206            return Err(IgraphError::InvalidArgument(format!(
207                "weights length {} does not match ecount {ne}",
208                w.len()
209            )));
210        }
211    }
212
213    Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn path4() -> Graph {
221        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
222    }
223
224    fn triangle() -> Graph {
225        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
226    }
227
228    fn cycle4() -> Graph {
229        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
230    }
231
232    // --- dirichlet_energy tests ---
233
234    #[test]
235    fn de_constant_signal() {
236        let g = path4();
237        let e = dirichlet_energy(&g, &[3.0, 3.0, 3.0, 3.0], None).unwrap();
238        assert!(e.abs() < 1e-10);
239    }
240
241    #[test]
242    fn de_step_signal() {
243        let g = path4();
244        // Signal: [0, 0, 1, 1] → only edge 1-2 contributes: (0-1)² = 1
245        let e = dirichlet_energy(&g, &[0.0, 0.0, 1.0, 1.0], None).unwrap();
246        assert!((e - 1.0).abs() < 1e-10);
247    }
248
249    #[test]
250    fn de_linear_signal() {
251        let g = path4();
252        // Signal: [0, 1, 2, 3] → each edge contributes 1² = 1, total = 3
253        let e = dirichlet_energy(&g, &[0.0, 1.0, 2.0, 3.0], None).unwrap();
254        assert!((e - 3.0).abs() < 1e-10);
255    }
256
257    #[test]
258    fn de_weighted() {
259        let g = path4();
260        let w = vec![2.0, 1.0, 3.0];
261        // Signal: [0, 1, 0, 1]
262        // Edge 0-1: 2*(0-1)² = 2, Edge 1-2: 1*(1-0)² = 1, Edge 2-3: 3*(0-1)² = 3
263        let e = dirichlet_energy(&g, &[0.0, 1.0, 0.0, 1.0], Some(&w)).unwrap();
264        assert!((e - 6.0).abs() < 1e-10);
265    }
266
267    #[test]
268    fn de_empty_graph() {
269        let g = Graph::with_vertices(3);
270        let e = dirichlet_energy(&g, &[1.0, 2.0, 3.0], None).unwrap();
271        assert!(e.abs() < 1e-10);
272    }
273
274    #[test]
275    fn de_invalid_signal() {
276        let g = path4();
277        assert!(dirichlet_energy(&g, &[1.0], None).is_err());
278    }
279
280    #[test]
281    fn de_invalid_weights() {
282        let g = path4();
283        assert!(dirichlet_energy(&g, &[0.0; 4], Some(&[1.0])).is_err());
284    }
285
286    #[test]
287    fn de_directed() {
288        let g = Graph::from_edges(&[(0, 1), (1, 2)], true, Some(3)).unwrap();
289        let e = dirichlet_energy(&g, &[0.0, 1.0, 3.0], None).unwrap();
290        // Edge 0→1: (0-1)²=1, Edge 1→2: (1-3)²=4
291        assert!((e - 5.0).abs() < 1e-10);
292    }
293
294    // --- total_variation tests ---
295
296    #[test]
297    fn tv_constant() {
298        let g = triangle();
299        let tv = total_variation(&g, &[5.0, 5.0, 5.0], None).unwrap();
300        assert!(tv.abs() < 1e-10);
301    }
302
303    #[test]
304    fn tv_step() {
305        let g = path4();
306        // [0, 0, 1, 1]: only |0-1| = 1
307        let tv = total_variation(&g, &[0.0, 0.0, 1.0, 1.0], None).unwrap();
308        assert!((tv - 1.0).abs() < 1e-10);
309    }
310
311    #[test]
312    fn tv_linear() {
313        let g = path4();
314        // [0, 1, 2, 3]: each |diff| = 1, total = 3
315        let tv = total_variation(&g, &[0.0, 1.0, 2.0, 3.0], None).unwrap();
316        assert!((tv - 3.0).abs() < 1e-10);
317    }
318
319    #[test]
320    fn tv_nonneg() {
321        let g = cycle4();
322        let tv = total_variation(&g, &[1.0, -1.0, 2.0, -2.0], None).unwrap();
323        assert!(tv >= 0.0);
324    }
325
326    // --- normalized_dirichlet_energy tests ---
327
328    #[test]
329    fn nde_constant() {
330        let g = triangle();
331        let e = normalized_dirichlet_energy(&g, &[3.0, 3.0, 3.0], None).unwrap();
332        assert!(e.abs() < 1e-10);
333    }
334
335    #[test]
336    fn nde_zero_signal() {
337        let g = triangle();
338        let e = normalized_dirichlet_energy(&g, &[0.0, 0.0, 0.0], None).unwrap();
339        assert!(e.abs() < 1e-10);
340    }
341
342    #[test]
343    fn nde_bounded() {
344        let g = cycle4();
345        // Alternating signal [1, -1, 1, -1] should give high energy
346        let e = normalized_dirichlet_energy(&g, &[1.0, -1.0, 1.0, -1.0], None).unwrap();
347        assert!(e >= -1e-10);
348        assert!(e <= 2.0 + 1e-10);
349    }
350
351    #[test]
352    fn nde_empty_graph() {
353        let g = Graph::with_vertices(3);
354        let e = normalized_dirichlet_energy(&g, &[1.0, 2.0, 3.0], None).unwrap();
355        assert!(e.abs() < 1e-10);
356    }
357
358    // --- smoothness_ratio tests ---
359
360    #[test]
361    fn sr_constant() {
362        let g = triangle();
363        let sr = smoothness_ratio(&g, &[2.0, 2.0, 2.0], None).unwrap();
364        assert!((sr - 1.0).abs() < 1e-10);
365    }
366
367    #[test]
368    fn sr_bounded() {
369        let g = cycle4();
370        let sr = smoothness_ratio(&g, &[1.0, -1.0, 1.0, -1.0], None).unwrap();
371        assert!(sr >= -1e-10);
372        assert!(sr <= 1.0 + 1e-10);
373    }
374
375    #[test]
376    fn sr_zero_signal() {
377        let g = triangle();
378        let sr = smoothness_ratio(&g, &[0.0, 0.0, 0.0], None).unwrap();
379        assert!((sr - 1.0).abs() < 1e-10);
380    }
381
382    #[test]
383    fn sr_empty_graph() {
384        let g = Graph::with_vertices(3);
385        let sr = smoothness_ratio(&g, &[1.0, 2.0, 3.0], None).unwrap();
386        assert!((sr - 1.0).abs() < 1e-10);
387    }
388
389    #[test]
390    fn sr_alternating_on_path() {
391        let g = path4();
392        // [1, -1, 1, -1]: maximally rough on path
393        let sr = smoothness_ratio(&g, &[1.0, -1.0, 1.0, -1.0], None).unwrap();
394        // Dirichlet = 3 * 4 = 12, sum_sq = 3 * 2 = 6, denom = 12
395        // sr = 1 - 12/12 = 0
396        assert!(sr.abs() < 1e-10);
397    }
398
399    // --- cross-consistency tests ---
400
401    #[test]
402    fn de_equals_tv_squared_for_unit_step() {
403        let g = path4();
404        let signal = [0.0, 0.0, 1.0, 1.0];
405        let de = dirichlet_energy(&g, &signal, None).unwrap();
406        let tv = total_variation(&g, &signal, None).unwrap();
407        // For a signal with diffs in {0, ±1}, DE = TV
408        assert!((de - tv).abs() < 1e-10);
409    }
410
411    #[test]
412    fn de_geq_zero() {
413        let g = cycle4();
414        for signal in &[
415            vec![1.0, 2.0, 3.0, 4.0],
416            vec![-1.0, 0.5, -0.3, 2.0],
417            vec![0.0, 0.0, 0.0, 0.0],
418        ] {
419            let de = dirichlet_energy(&g, signal, None).unwrap();
420            assert!(de >= -1e-15);
421        }
422    }
423}