Skip to main content

scirs2_graph/
spectral_graph.rs

1//! Spectral graph theory — adjacency-matrix-based API.
2//!
3//! This module exposes pure-matrix versions of spectral algorithms that accept
4//! `Array2<f64>` weighted adjacency matrices directly.  It complements the
5//! typed-graph spectral module (`spectral.rs`) which operates on `Graph<N,E,Ix>`.
6//!
7//! ## Algorithms
8//! - **Graph Laplacian** L = D − A
9//! - **Normalized Laplacian** L̃ = D^{−1/2} L D^{−1/2}
10//! - **Fiedler vector** (spectral bisection via inverse iteration)
11//! - **Spectral clustering** (k smallest eigenvectors + Lloyd k-means)
12//! - **Graph Fourier Transform** (project signal onto Laplacian eigenvectors)
13//! - **Effective resistance** and resistance matrix (via pseudo-inverse)
14//!
15//! ## Example
16//! ```rust,no_run
17//! use scirs2_core::ndarray::Array2;
18//! use scirs2_graph::spectral_graph::{graph_laplacian, fiedler_vector};
19//!
20//! let adj = Array2::<f64>::from_shape_vec((4,4), vec![
21//!     0.,1.,0.,1., 1.,0.,1.,0., 0.,1.,0.,1., 1.,0.,1.,0.,
22//! ]).unwrap();
23//! let l = graph_laplacian(&adj);
24//! let (lambda2, fv) = fiedler_vector(&adj).unwrap();
25//! println!("Fiedler value = {lambda2}");
26//! ```
27
28use scirs2_core::ndarray::Array2;
29use scirs2_core::random::{Rng, RngExt, SeedableRng, StdRng};
30
31use crate::error::{GraphError, Result};
32
33// ─────────────────────────────────────────────────────────────────────────────
34// Graph Laplacian  L = D − A
35// ─────────────────────────────────────────────────────────────────────────────
36
37/// Compute the combinatorial graph Laplacian L = D − A.
38///
39/// `D` is the diagonal degree matrix with `D_{ii} = sum_j A_{ij}`.
40/// The Laplacian is symmetric positive semi-definite; its smallest eigenvalue
41/// is 0 (constant eigenvector) and the second-smallest (Fiedler value) measures
42/// graph connectivity.
43pub fn graph_laplacian(adj: &Array2<f64>) -> Array2<f64> {
44    let n = adj.nrows();
45    let mut lap = Array2::zeros((n, n));
46    for i in 0..n {
47        let deg: f64 = adj.row(i).sum();
48        lap[[i, i]] = deg;
49        for j in 0..n {
50            if i != j {
51                lap[[i, j]] = -adj[[i, j]];
52            }
53        }
54    }
55    lap
56}
57
58// ─────────────────────────────────────────────────────────────────────────────
59// Normalized Laplacian  L̃ = D^{−1/2} L D^{−1/2}
60// ─────────────────────────────────────────────────────────────────────────────
61
62/// Compute the symmetric normalized Laplacian L̃ = D^{−1/2} (D − A) D^{−1/2}.
63///
64/// Isolated nodes (zero degree) are left as zero rows/columns.
65/// Eigenvalues lie in [0, 2]; the largest is exactly 2 iff the graph is bipartite.
66pub fn normalized_laplacian(adj: &Array2<f64>) -> Result<Array2<f64>> {
67    let n = adj.nrows();
68    if n == 0 {
69        return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
70    }
71    if adj.ncols() != n {
72        return Err(GraphError::InvalidGraph(
73            "adjacency matrix must be square".into(),
74        ));
75    }
76
77    // D^{-1/2} diagonal
78    let d_inv_sqrt: Vec<f64> = (0..n)
79        .map(|i| {
80            let deg = adj.row(i).sum();
81            if deg > 0.0 {
82                1.0 / deg.sqrt()
83            } else {
84                0.0
85            }
86        })
87        .collect();
88
89    let l = graph_laplacian(adj);
90    let mut l_norm = Array2::zeros((n, n));
91    for i in 0..n {
92        for j in 0..n {
93            l_norm[[i, j]] = d_inv_sqrt[i] * l[[i, j]] * d_inv_sqrt[j];
94        }
95    }
96    Ok(l_norm)
97}
98
99// ─────────────────────────────────────────────────────────────────────────────
100// Eigenvalue helpers (power iteration / inverse iteration)
101// ─────────────────────────────────────────────────────────────────────────────
102
103/// Matrix-vector product: result_i = sum_j M[i,j] * v[j].
104fn matvec(m: &Array2<f64>, v: &[f64]) -> Vec<f64> {
105    let n = m.nrows();
106    let mut out = vec![0.0f64; n];
107    for i in 0..n {
108        for j in 0..n {
109            out[i] += m[[i, j]] * v[j];
110        }
111    }
112    out
113}
114
115/// L2 norm of a slice.
116fn vec_norm(v: &[f64]) -> f64 {
117    v.iter().map(|x| x * x).sum::<f64>().sqrt()
118}
119
120/// Normalize a slice in-place; returns the norm before normalization.
121fn normalize_inplace(v: &mut [f64]) -> f64 {
122    let n = vec_norm(v);
123    if n > 1e-300 {
124        v.iter_mut().for_each(|x| *x /= n);
125    }
126    n
127}
128
129/// Project `v` away from `basis` vectors (modified Gram-Schmidt deflation).
130fn deflate(v: &mut [f64], basis: &[Vec<f64>]) {
131    for b in basis {
132        let dot: f64 = v.iter().zip(b.iter()).map(|(a, c)| a * c).sum();
133        for (vi, bi) in v.iter_mut().zip(b.iter()) {
134            *vi -= dot * bi;
135        }
136    }
137}
138
139/// Power iteration to find the largest eigenvalue/vector of a symmetric matrix.
140/// Returns `(eigenvalue, eigenvector)`.
141fn power_iteration(m: &Array2<f64>, max_iter: usize, tol: f64, seed: u64) -> (f64, Vec<f64>) {
142    let n = m.nrows();
143    let mut rng = StdRng::seed_from_u64(seed);
144    let mut v: Vec<f64> = (0..n).map(|_| rng.random::<f64>() - 0.5).collect();
145    normalize_inplace(&mut v);
146
147    let mut lambda = 0.0f64;
148    for _ in 0..max_iter {
149        let mv = matvec(m, &v);
150        let new_lambda: f64 = mv.iter().zip(v.iter()).map(|(a, b)| a * b).sum();
151        let mut new_v = mv;
152        normalize_inplace(&mut new_v);
153        if (new_lambda - lambda).abs() < tol {
154            return (new_lambda, new_v);
155        }
156        lambda = new_lambda;
157        v = new_v;
158    }
159    (lambda, v)
160}
161
162/// Inverse iteration with shift `sigma` to find the eigenvalue/vector closest
163/// to `sigma`.  Solves `(M - sigma I) v_{k+1} = v_k` using Gaussian elimination.
164///
165/// Returns `(eigenvalue, eigenvector)`.
166fn inverse_iteration(
167    m: &Array2<f64>,
168    sigma: f64,
169    prev_evecs: &[Vec<f64>],
170    max_iter: usize,
171    tol: f64,
172    seed: u64,
173) -> std::result::Result<(f64, Vec<f64>), String> {
174    let n = m.nrows();
175    if n == 0 {
176        return Err("empty matrix".into());
177    }
178
179    // Build shifted matrix A = M - sigma * I
180    let mut a = m.to_owned();
181    for i in 0..n {
182        a[[i, i]] -= sigma;
183    }
184
185    // LU decomposition (partial pivoting) of A
186    let (lu, piv) = lu_decompose(&a)?;
187
188    let mut rng = StdRng::seed_from_u64(seed);
189    let mut v: Vec<f64> = (0..n).map(|_| rng.random::<f64>() - 0.5).collect();
190    deflate(&mut v, prev_evecs);
191    normalize_inplace(&mut v);
192
193    let mut eigenvalue = sigma;
194
195    for _ in 0..max_iter {
196        // Solve A w = v  (LU solve)
197        let mut w = lu_solve(&lu, &piv, &v)?;
198        deflate(&mut w, prev_evecs);
199        let norm = normalize_inplace(&mut w);
200        if norm < 1e-300 {
201            break;
202        }
203        // Rayleigh quotient with original matrix
204        let mv = matvec(m, &w);
205        let new_eigenvalue: f64 = mv.iter().zip(w.iter()).map(|(a, b)| a * b).sum();
206        let diff: f64 = w
207            .iter()
208            .zip(v.iter())
209            .map(|(a, b)| (a - b).powi(2))
210            .sum::<f64>()
211            .sqrt();
212        v = w;
213        if (new_eigenvalue - eigenvalue).abs() < tol && diff < tol {
214            eigenvalue = new_eigenvalue;
215            break;
216        }
217        eigenvalue = new_eigenvalue;
218    }
219
220    Ok((eigenvalue, v))
221}
222
223/// LU decomposition with partial pivoting.
224/// Returns `(LU, pivot_indices)` where `LU` stores L in the lower triangle
225/// (unit diagonal) and U in the upper triangle.
226fn lu_decompose(a: &Array2<f64>) -> std::result::Result<(Vec<Vec<f64>>, Vec<usize>), String> {
227    let n = a.nrows();
228    let mut lu: Vec<Vec<f64>> = (0..n).map(|i| a.row(i).to_vec()).collect();
229    let mut piv: Vec<usize> = (0..n).collect();
230
231    for k in 0..n {
232        // Find pivot
233        let mut max_val = lu[k][k].abs();
234        let mut max_row = k;
235        for i in (k + 1)..n {
236            if lu[i][k].abs() > max_val {
237                max_val = lu[i][k].abs();
238                max_row = i;
239            }
240        }
241        if max_val < 1e-300 {
242            return Err(format!("singular matrix at column {k}"));
243        }
244        lu.swap(k, max_row);
245        piv.swap(k, max_row);
246
247        for i in (k + 1)..n {
248            lu[i][k] /= lu[k][k];
249            for j in (k + 1)..n {
250                let factor = lu[i][k] * lu[k][j];
251                lu[i][j] -= factor;
252            }
253        }
254    }
255    Ok((lu, piv))
256}
257
258/// Solve LU x = b using the factorization from `lu_decompose`.
259fn lu_solve(lu: &[Vec<f64>], piv: &[usize], b: &[f64]) -> std::result::Result<Vec<f64>, String> {
260    let n = lu.len();
261    // Apply row permutation to b
262    let mut x: Vec<f64> = vec![0.0; n];
263    for i in 0..n {
264        x[i] = b[piv[i]];
265    }
266    // Forward substitution (L unit lower)
267    for i in 1..n {
268        for j in 0..i {
269            x[i] -= lu[i][j] * x[j];
270        }
271    }
272    // Back substitution (U upper)
273    for i in (0..n).rev() {
274        for j in (i + 1)..n {
275            x[i] -= lu[i][j] * x[j];
276        }
277        if lu[i][i].abs() < 1e-300 {
278            return Err(format!("zero pivot at {i}"));
279        }
280        x[i] /= lu[i][i];
281    }
282    Ok(x)
283}
284
285/// Compute the `k` smallest eigenvalues and eigenvectors of a symmetric matrix
286/// using inverse iteration with progressive deflation.
287///
288/// Returns `(eigenvalues, eigenvectors)` sorted by eigenvalue (ascending).
289fn smallest_k_eigen(
290    m: &Array2<f64>,
291    k: usize,
292    seed: u64,
293) -> std::result::Result<(Vec<f64>, Vec<Vec<f64>>), String> {
294    let n = m.nrows();
295    if k == 0 {
296        return Ok((vec![], vec![]));
297    }
298    if k > n {
299        return Err(format!("k={k} > n={n}"));
300    }
301
302    // Estimate largest eigenvalue via power iteration for shift selection
303    let (lambda_max, _) = power_iteration(m, 200, 1e-8, seed);
304
305    let mut eigenvalues: Vec<f64> = Vec::with_capacity(k);
306    let mut eigenvectors: Vec<Vec<f64>> = Vec::with_capacity(k);
307
308    // Start slightly below 0 to capture the zero eigenvalue of Laplacians
309    let mut shift = -1e-4;
310
311    for idx in 0..k {
312        // Pick a shift below the expected next eigenvalue
313        // For Laplacian, eigenvalues are 0, lambda2, lambda3, ...
314        let trial_shift = if idx == 0 {
315            -1e-4 // just below 0
316        } else {
317            // Use a shift below the previous eigenvalue + small gap
318            eigenvalues[idx - 1] - 1e-3
319        };
320        shift = trial_shift.min(shift);
321
322        // Try inverse iteration; if singular (eigen = 0 causes singular shift), nudge
323        let result = inverse_iteration(m, shift, &eigenvectors, 150, 1e-8, seed + idx as u64);
324        let (eval, evec) = match result {
325            Ok(r) => r,
326            Err(_) => {
327                // Try a small offset
328                inverse_iteration(
329                    m,
330                    shift + 1e-6,
331                    &eigenvectors,
332                    150,
333                    1e-8,
334                    seed + idx as u64 + 1000,
335                )?
336            }
337        };
338
339        // Clamp negative near-zero eigenvalues of Laplacians to 0
340        let clamped = if eval.abs() < 1e-6 { 0.0 } else { eval };
341        eigenvalues.push(clamped);
342        eigenvectors.push(evec);
343        // For next shift, start well above the current eigenvalue
344        shift = clamped + (lambda_max - clamped) * 0.01;
345    }
346
347    Ok((eigenvalues, eigenvectors))
348}
349
350// ─────────────────────────────────────────────────────────────────────────────
351// Fiedler vector
352// ─────────────────────────────────────────────────────────────────────────────
353
354/// Compute the Fiedler value (λ₂) and Fiedler vector of a graph.
355///
356/// The Fiedler value is the **second-smallest** eigenvalue of the combinatorial
357/// Laplacian L = D − A.  A positive Fiedler value indicates a connected graph.
358/// The Fiedler vector provides a spectral bisection: sign of each component gives
359/// the two-way partition.
360///
361/// # Returns
362/// `(fiedler_value, fiedler_vector)` where the vector has unit L2-norm.
363pub fn fiedler_vector(adj: &Array2<f64>) -> Result<(f64, Vec<f64>)> {
364    let n = adj.nrows();
365    if n < 2 {
366        return Err(GraphError::InvalidGraph(
367            "need at least 2 nodes for Fiedler vector".into(),
368        ));
369    }
370
371    let lap = graph_laplacian(adj);
372    let (evals, evecs) = smallest_k_eigen(&lap, 2, 12345).map_err(|e| GraphError::LinAlgError {
373        operation: "fiedler_vector".into(),
374        details: e,
375    })?;
376
377    if evals.len() < 2 {
378        return Err(GraphError::LinAlgError {
379            operation: "fiedler_vector".into(),
380            details: "could not compute second eigenvalue".into(),
381        });
382    }
383
384    Ok((evals[1], evecs[1].clone()))
385}
386
387// ─────────────────────────────────────────────────────────────────────────────
388// Spectral clustering
389// ─────────────────────────────────────────────────────────────────────────────
390
391/// Result of spectral clustering.
392#[derive(Debug, Clone)]
393pub struct SpectralClusterResult {
394    /// Cluster assignment (0-indexed) for each node.
395    pub assignments: Vec<usize>,
396    /// Eigenvalues of the normalized Laplacian used for embedding.
397    pub eigenvalues: Vec<f64>,
398    /// n × k embedding matrix (one row per node).
399    pub embedding: Array2<f64>,
400}
401
402/// Spectral clustering: embed via k smallest eigenvectors of L̃, then k-means.
403///
404/// Uses the normalized Laplacian `L̃ = D^{-1/2} L D^{-1/2}` for the embedding.
405/// After computing the k-dimensional spectral embedding, Lloyd's algorithm
406/// (k-means) groups the nodes into `k` clusters.
407///
408/// # Arguments
409/// * `adj`  – Symmetric weighted adjacency matrix (n × n).
410/// * `k`    – Number of clusters.
411/// * `seed` – RNG seed for k-means initialization.
412pub fn spectral_clustering(
413    adj: &Array2<f64>,
414    k: usize,
415    seed: u64,
416) -> Result<SpectralClusterResult> {
417    let n = adj.nrows();
418    if n == 0 {
419        return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
420    }
421    if k == 0 || k > n {
422        return Err(GraphError::InvalidParameter {
423            param: "k".into(),
424            value: k.to_string(),
425            expected: "1..=n".into(),
426            context: "spectral_clustering".into(),
427        });
428    }
429
430    let l_norm = normalized_laplacian(adj)?;
431    let (evals, evecs) =
432        smallest_k_eigen(&l_norm, k, seed).map_err(|e| GraphError::LinAlgError {
433            operation: "spectral_clustering".into(),
434            details: e,
435        })?;
436
437    // Build n×k embedding matrix (rows = nodes, cols = eigenvectors)
438    let mut embedding = Array2::zeros((n, k));
439    for col in 0..k {
440        if col < evecs.len() {
441            for row in 0..n {
442                embedding[[row, col]] = evecs[col][row];
443            }
444        }
445    }
446
447    // Lloyd's k-means on the embedding
448    let assignments = kmeans_lloyd(&embedding, k, 300, seed)?;
449
450    Ok(SpectralClusterResult {
451        assignments,
452        eigenvalues: evals,
453        embedding,
454    })
455}
456
457/// Lloyd's k-means algorithm on rows of an n×d matrix.
458/// Returns cluster assignments of length n.
459fn kmeans_lloyd(data: &Array2<f64>, k: usize, max_iter: usize, seed: u64) -> Result<Vec<usize>> {
460    let n = data.nrows();
461    let d = data.ncols();
462    if n == 0 || k == 0 {
463        return Ok(vec![]);
464    }
465
466    // k-means++ style initialization: pick k distinct rows as initial centroids
467    let mut rng = StdRng::seed_from_u64(seed);
468
469    // Pick first centroid uniformly
470    let mut centroids: Vec<Vec<f64>> = Vec::with_capacity(k);
471    let first_idx = rng.random_range(0..n);
472    centroids.push(data.row(first_idx).to_vec());
473
474    // Pick remaining centroids proportional to squared distance from nearest centroid
475    for _ in 1..k {
476        let dists: Vec<f64> = (0..n)
477            .map(|i| {
478                let row = data.row(i);
479                centroids
480                    .iter()
481                    .map(|c| {
482                        row.iter()
483                            .zip(c.iter())
484                            .map(|(a, b)| (a - b).powi(2))
485                            .sum::<f64>()
486                    })
487                    .fold(f64::INFINITY, f64::min)
488            })
489            .collect();
490
491        let total: f64 = dists.iter().sum();
492        let mut r = rng.random::<f64>() * total;
493        let mut chosen = n - 1;
494        for (i, &d_val) in dists.iter().enumerate() {
495            r -= d_val;
496            if r <= 0.0 {
497                chosen = i;
498                break;
499            }
500        }
501        centroids.push(data.row(chosen).to_vec());
502    }
503
504    let mut assignments = vec![0usize; n];
505
506    for _iter in 0..max_iter {
507        // Assignment step
508        let mut changed = false;
509        for i in 0..n {
510            let row = data.row(i);
511            let mut best_dist = f64::INFINITY;
512            let mut best_c = 0;
513            for (ci, centroid) in centroids.iter().enumerate() {
514                let dist: f64 = row
515                    .iter()
516                    .zip(centroid.iter())
517                    .map(|(a, b)| (a - b).powi(2))
518                    .sum();
519                if dist < best_dist {
520                    best_dist = dist;
521                    best_c = ci;
522                }
523            }
524            if assignments[i] != best_c {
525                changed = true;
526                assignments[i] = best_c;
527            }
528        }
529
530        if !changed {
531            break;
532        }
533
534        // Update step: recompute centroids
535        let mut sums = vec![vec![0.0f64; d]; k];
536        let mut counts = vec![0usize; k];
537        for i in 0..n {
538            let c = assignments[i];
539            counts[c] += 1;
540            for j in 0..d {
541                sums[c][j] += data[[i, j]];
542            }
543        }
544        for c in 0..k {
545            if counts[c] > 0 {
546                for j in 0..d {
547                    centroids[c][j] = sums[c][j] / counts[c] as f64;
548                }
549            }
550        }
551    }
552
553    Ok(assignments)
554}
555
556// ─────────────────────────────────────────────────────────────────────────────
557// Graph Fourier Transform
558// ─────────────────────────────────────────────────────────────────────────────
559
560/// Graph Fourier Transform: project a nodal signal onto eigenvectors of L.
561///
562/// Returns the frequency-domain coefficients `x̂ = U^T x` where `U` is the
563/// matrix of eigenvectors of the combinatorial Laplacian, sorted by eigenvalue.
564/// The coefficient `x̂[k]` represents the amplitude of the k-th graph frequency.
565///
566/// # Arguments
567/// * `adj`    – Symmetric weighted adjacency matrix (n × n).
568/// * `signal` – Nodal signal of length n.
569pub fn graph_fourier_transform(adj: &Array2<f64>, signal: &[f64]) -> Result<Vec<f64>> {
570    let n = adj.nrows();
571    if n == 0 {
572        return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
573    }
574    if signal.len() != n {
575        return Err(GraphError::InvalidParameter {
576            param: "signal".into(),
577            value: signal.len().to_string(),
578            expected: format!("{n}"),
579            context: "graph_fourier_transform".into(),
580        });
581    }
582
583    let lap = graph_laplacian(adj);
584    let (_, evecs) = smallest_k_eigen(&lap, n, 42).map_err(|e| GraphError::LinAlgError {
585        operation: "graph_fourier_transform".into(),
586        details: e,
587    })?;
588
589    // x̂[k] = <u_k, x>
590    let coeffs: Vec<f64> = evecs
591        .iter()
592        .map(|uk| uk.iter().zip(signal.iter()).map(|(a, b)| a * b).sum())
593        .collect();
594
595    Ok(coeffs)
596}
597
598// ─────────────────────────────────────────────────────────────────────────────
599// Effective resistance
600// ─────────────────────────────────────────────────────────────────────────────
601
602/// Compute the Moore-Penrose pseudo-inverse of a symmetric PSD matrix.
603///
604/// Uses the eigendecomposition: L^+ = sum_{k: λ_k > tol} (1/λ_k) u_k u_k^T.
605fn pinv_symmetric(m: &Array2<f64>, tol: f64) -> std::result::Result<Array2<f64>, String> {
606    let n = m.nrows();
607    if n == 0 {
608        return Ok(Array2::zeros((0, 0)));
609    }
610    let (evals, evecs) = smallest_k_eigen(m, n, 999)?;
611
612    let mut pinv = Array2::zeros((n, n));
613    for (k, &lambda) in evals.iter().enumerate() {
614        if lambda.abs() <= tol {
615            continue;
616        }
617        let uk = &evecs[k];
618        for i in 0..n {
619            for j in 0..n {
620                pinv[[i, j]] += uk[i] * uk[j] / lambda;
621            }
622        }
623    }
624    Ok(pinv)
625}
626
627/// Effective resistance R_{ij} = (e_i − e_j)^T L^+ (e_i − e_j)
628///                              = L^+_{ii} + L^+_{jj} − 2 L^+_{ij}.
629///
630/// Requires a connected graph; isolated nodes yield infinite resistance to
631/// all other nodes.
632///
633/// # Arguments
634/// * `adj` – Symmetric weighted adjacency matrix.
635/// * `i`   – Source node index.
636/// * `j`   – Target node index.
637pub fn effective_resistance(adj: &Array2<f64>, i: usize, j: usize) -> Result<f64> {
638    let n = adj.nrows();
639    if n == 0 {
640        return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
641    }
642    if i >= n || j >= n {
643        return Err(GraphError::InvalidParameter {
644            param: "i or j".into(),
645            value: format!("({i},{j})"),
646            expected: format!("< {n}"),
647            context: "effective_resistance".into(),
648        });
649    }
650    if i == j {
651        return Ok(0.0);
652    }
653
654    let lap = graph_laplacian(adj);
655    let lp = pinv_symmetric(&lap, 1e-9).map_err(|e| GraphError::LinAlgError {
656        operation: "effective_resistance".into(),
657        details: e,
658    })?;
659
660    let r = lp[[i, i]] + lp[[j, j]] - 2.0 * lp[[i, j]];
661    Ok(r.max(0.0))
662}
663
664/// Compute the all-pairs effective resistance matrix R where R_{ij} is the
665/// effective resistance between nodes i and j.
666///
667/// Diagonal entries are 0.  Uses a single pseudo-inverse computation.
668pub fn resistance_matrix(adj: &Array2<f64>) -> Result<Array2<f64>> {
669    let n = adj.nrows();
670    if n == 0 {
671        return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
672    }
673    if adj.ncols() != n {
674        return Err(GraphError::InvalidGraph(
675            "adjacency matrix must be square".into(),
676        ));
677    }
678
679    let lap = graph_laplacian(adj);
680    let lp = pinv_symmetric(&lap, 1e-9).map_err(|e| GraphError::LinAlgError {
681        operation: "resistance_matrix".into(),
682        details: e,
683    })?;
684
685    let mut r_mat = Array2::zeros((n, n));
686    for i in 0..n {
687        for j in 0..n {
688            if i != j {
689                let r = lp[[i, i]] + lp[[j, j]] - 2.0 * lp[[i, j]];
690                r_mat[[i, j]] = r.max(0.0);
691            }
692        }
693    }
694    Ok(r_mat)
695}
696
697// ─────────────────────────────────────────────────────────────────────────────
698// Tests
699// ─────────────────────────────────────────────────────────────────────────────
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use scirs2_core::ndarray::Array2;
705
706    fn path_adj(n: usize) -> Array2<f64> {
707        let mut adj = Array2::zeros((n, n));
708        for i in 0..(n - 1) {
709            adj[[i, i + 1]] = 1.0;
710            adj[[i + 1, i]] = 1.0;
711        }
712        adj
713    }
714
715    fn complete_adj(n: usize) -> Array2<f64> {
716        let mut adj = Array2::ones((n, n));
717        for i in 0..n {
718            adj[[i, i]] = 0.0;
719        }
720        adj
721    }
722
723    /// Complete bipartite graph K_{m,n}.
724    fn complete_bipartite(m: usize, n: usize) -> Array2<f64> {
725        let total = m + n;
726        let mut adj = Array2::zeros((total, total));
727        for i in 0..m {
728            for j in m..total {
729                adj[[i, j]] = 1.0;
730                adj[[j, i]] = 1.0;
731            }
732        }
733        adj
734    }
735
736    // ── graph_laplacian ──────────────────────────────────────────────────────
737
738    #[test]
739    fn test_laplacian_path3() {
740        // P3: 0-1-2
741        let adj = path_adj(3);
742        let l = graph_laplacian(&adj);
743        // Row sums must be zero (Laplacian property)
744        for i in 0..3 {
745            let row_sum: f64 = l.row(i).sum();
746            assert!(row_sum.abs() < 1e-12, "row {i} sum = {row_sum}");
747        }
748        // Diagonal = degree
749        assert!((l[[0, 0]] - 1.0).abs() < 1e-12);
750        assert!((l[[1, 1]] - 2.0).abs() < 1e-12);
751        assert!((l[[2, 2]] - 1.0).abs() < 1e-12);
752    }
753
754    #[test]
755    fn test_laplacian_complete4() {
756        let adj = complete_adj(4);
757        let l = graph_laplacian(&adj);
758        for i in 0..4 {
759            let row_sum: f64 = l.row(i).sum();
760            assert!(row_sum.abs() < 1e-12, "row {i} sum = {row_sum}");
761            assert!((l[[i, i]] - 3.0).abs() < 1e-12);
762        }
763    }
764
765    // ── normalized_laplacian ─────────────────────────────────────────────────
766
767    #[test]
768    fn test_normalized_laplacian_diagonal_ones() {
769        // For regular graphs (all nodes same degree), diagonal of L̃ = 1
770        let adj = complete_adj(4); // all have degree 3
771        let l_norm = normalized_laplacian(&adj).expect("norm lap");
772        for i in 0..4 {
773            assert!(
774                (l_norm[[i, i]] - 1.0).abs() < 1e-10,
775                "diagonal[{i}] = {}",
776                l_norm[[i, i]]
777            );
778        }
779    }
780
781    #[test]
782    fn test_normalized_laplacian_symmetric() {
783        let adj = path_adj(5);
784        let l_norm = normalized_laplacian(&adj).expect("norm lap");
785        for i in 0..5 {
786            for j in 0..5 {
787                assert!(
788                    (l_norm[[i, j]] - l_norm[[j, i]]).abs() < 1e-12,
789                    "not symmetric at ({i},{j})"
790                );
791            }
792        }
793    }
794
795    // ── fiedler_vector ───────────────────────────────────────────────────────
796
797    #[test]
798    fn test_fiedler_value_positive_connected() {
799        // A connected graph must have a positive Fiedler value
800        let adj = path_adj(6);
801        let (lambda2, fv) = fiedler_vector(&adj).expect("fiedler");
802        assert!(
803            lambda2 > 1e-6,
804            "Fiedler value should be positive: {lambda2}"
805        );
806        assert_eq!(fv.len(), 6);
807        // Verify the vector is a unit vector
808        let norm: f64 = fv.iter().map(|x| x * x).sum::<f64>().sqrt();
809        assert!(
810            (norm - 1.0).abs() < 1e-6,
811            "Fiedler vector should be unit norm: {norm}"
812        );
813    }
814
815    #[test]
816    fn test_fiedler_bisection() {
817        // For a path graph P4, the Fiedler vector has opposite signs on the two halves
818        let adj = path_adj(4);
819        let (_lambda2, fv) = fiedler_vector(&adj).expect("fiedler");
820        // The Fiedler vector of P4 should split the path into two halves
821        // (the two groups should have opposite mean sign)
822        let left_mean = (fv[0] + fv[1]) / 2.0;
823        let right_mean = (fv[2] + fv[3]) / 2.0;
824        assert!(
825            left_mean * right_mean < 0.0,
826            "Fiedler vector should bisect: left={left_mean}, right={right_mean}"
827        );
828    }
829
830    #[test]
831    fn test_fiedler_error_single_node() {
832        let adj = Array2::<f64>::zeros((1, 1));
833        assert!(fiedler_vector(&adj).is_err());
834    }
835
836    // ── spectral_clustering ──────────────────────────────────────────────────
837
838    #[test]
839    fn test_spectral_clustering_shapes() {
840        let adj = path_adj(6);
841        let result = spectral_clustering(&adj, 2, 42).expect("spectral clustering");
842        assert_eq!(result.assignments.len(), 6);
843        assert_eq!(result.eigenvalues.len(), 2);
844        assert_eq!(result.embedding.nrows(), 6);
845        assert_eq!(result.embedding.ncols(), 2);
846    }
847
848    #[test]
849    fn test_spectral_clustering_two_components() {
850        // Two disjoint paths: nodes 0-1-2 and nodes 3-4-5
851        let mut adj = Array2::zeros((6, 6));
852        adj[[0, 1]] = 1.0;
853        adj[[1, 0]] = 1.0;
854        adj[[1, 2]] = 1.0;
855        adj[[2, 1]] = 1.0;
856        adj[[3, 4]] = 1.0;
857        adj[[4, 3]] = 1.0;
858        adj[[4, 5]] = 1.0;
859        adj[[5, 4]] = 1.0;
860
861        let result = spectral_clustering(&adj, 2, 7).expect("spectral clustering");
862        // The two components must be in different clusters
863        let c0 = result.assignments[0];
864        let c3 = result.assignments[3];
865        assert_ne!(
866            c0, c3,
867            "disconnected components should be in different clusters"
868        );
869        // All nodes in the same component should be in the same cluster
870        assert_eq!(result.assignments[0], result.assignments[1]);
871        assert_eq!(result.assignments[1], result.assignments[2]);
872        assert_eq!(result.assignments[3], result.assignments[4]);
873        assert_eq!(result.assignments[4], result.assignments[5]);
874    }
875
876    #[test]
877    fn test_spectral_clustering_invalid_k() {
878        let adj = path_adj(4);
879        assert!(spectral_clustering(&adj, 0, 0).is_err());
880        assert!(spectral_clustering(&adj, 5, 0).is_err());
881    }
882
883    // ── graph_fourier_transform ──────────────────────────────────────────────
884
885    #[test]
886    fn test_gft_length() {
887        let adj = path_adj(4);
888        let signal = vec![1.0, 0.0, 0.0, 0.0];
889        let coeffs = graph_fourier_transform(&adj, &signal).expect("gft");
890        assert_eq!(coeffs.len(), 4);
891    }
892
893    #[test]
894    fn test_gft_constant_signal() {
895        // A constant signal = DC component, only zeroth frequency has nonzero coefficient
896        let adj = complete_adj(4);
897        let signal = vec![1.0; 4];
898        let coeffs = graph_fourier_transform(&adj, &signal).expect("gft");
899        assert_eq!(coeffs.len(), 4);
900        // The DC component (k=0) should be large; others near 0
901        let max_coeff = coeffs.iter().map(|x| x.abs()).fold(0.0f64, f64::max);
902        assert!(max_coeff > 0.5, "DC component should dominate: {coeffs:?}");
903    }
904
905    // ── effective_resistance ─────────────────────────────────────────────────
906
907    #[test]
908    fn test_effective_resistance_self() {
909        let adj = path_adj(4);
910        let r = effective_resistance(&adj, 1, 1).expect("eff res");
911        assert_eq!(r, 0.0);
912    }
913
914    #[test]
915    fn test_effective_resistance_path3() {
916        // For a path P3: 0-1-2
917        // R(0,1) = 1, R(1,2) = 1, R(0,2) = 2 (series)
918        let adj = path_adj(3);
919        let r01 = effective_resistance(&adj, 0, 1).expect("r01");
920        let r12 = effective_resistance(&adj, 1, 2).expect("r12");
921        let r02 = effective_resistance(&adj, 0, 2).expect("r02");
922        assert!((r01 - 1.0).abs() < 1e-4, "R(0,1) = {r01}");
923        assert!((r12 - 1.0).abs() < 1e-4, "R(1,2) = {r12}");
924        assert!((r02 - 2.0).abs() < 1e-4, "R(0,2) = {r02}");
925    }
926
927    #[test]
928    fn test_effective_resistance_complete_graph() {
929        // For K_n with unit weights, R(i,j) = 2/n for all i≠j
930        let n = 4;
931        let adj = complete_adj(n);
932        let r = effective_resistance(&adj, 0, 1).expect("eff res");
933        let expected = 2.0 / n as f64;
934        assert!(
935            (r - expected).abs() < 1e-4,
936            "K{n} effective resistance = {r}, expected {expected}"
937        );
938    }
939
940    // ── resistance_matrix ────────────────────────────────────────────────────
941
942    #[test]
943    fn test_resistance_matrix_shape() {
944        let adj = path_adj(4);
945        let r_mat = resistance_matrix(&adj).expect("res mat");
946        assert_eq!(r_mat.nrows(), 4);
947        assert_eq!(r_mat.ncols(), 4);
948    }
949
950    #[test]
951    fn test_resistance_matrix_symmetric() {
952        let adj = path_adj(5);
953        let r_mat = resistance_matrix(&adj).expect("res mat");
954        for i in 0..5 {
955            for j in 0..5 {
956                assert!(
957                    (r_mat[[i, j]] - r_mat[[j, i]]).abs() < 1e-8,
958                    "not symmetric at ({i},{j})"
959                );
960            }
961        }
962    }
963
964    #[test]
965    fn test_resistance_matrix_zero_diagonal() {
966        let adj = complete_adj(4);
967        let r_mat = resistance_matrix(&adj).expect("res mat");
968        for i in 0..4 {
969            assert!(
970                r_mat[[i, i]].abs() < 1e-10,
971                "diagonal[{i}] = {}",
972                r_mat[[i, i]]
973            );
974        }
975    }
976
977    #[test]
978    fn test_resistance_matrix_path3_values() {
979        let adj = path_adj(3);
980        let r_mat = resistance_matrix(&adj).expect("res mat");
981        // R(0,1)=1, R(1,2)=1, R(0,2)=2
982        assert!(
983            (r_mat[[0, 1]] - 1.0).abs() < 1e-4,
984            "R(0,1) = {}",
985            r_mat[[0, 1]]
986        );
987        assert!(
988            (r_mat[[1, 2]] - 1.0).abs() < 1e-4,
989            "R(1,2) = {}",
990            r_mat[[1, 2]]
991        );
992        assert!(
993            (r_mat[[0, 2]] - 2.0).abs() < 1e-4,
994            "R(0,2) = {}",
995            r_mat[[0, 2]]
996        );
997    }
998
999    #[test]
1000    fn test_bipartite_laplacian_max_eigenvalue() {
1001        // For K_{2,2} (complete bipartite), largest eigenvalue of L_norm = 2.0
1002        let adj = complete_bipartite(2, 2);
1003        let l_norm = normalized_laplacian(&adj).expect("norm lap");
1004        // Largest eigenvalue via power iteration
1005        let (lambda_max, _) = power_iteration(&l_norm, 500, 1e-10, 77);
1006        assert!(
1007            (lambda_max - 2.0).abs() < 0.05,
1008            "K_{{2,2}} max eigenvalue of L_norm = {lambda_max}, expected 2.0"
1009        );
1010    }
1011}