Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit c023fa2

Browse files
fix(#169): replace broken QR with Colt EigenvalueDecomposition
The hand-rolled Householder+implicit-QR routine in GraphSpectrumAnalyzer.computeEigenvalues() threw ArrayIndexOutOfBoundsException at qrTridiagonal:342 on every non-trivial graph (K_3, K_4, P_3, S_4, two disjoint edges), making the entire spectral feature dead code. The bug is a one-off in the inner sweep (offdiag[i + 2] = r) combined with an unpadded length-n offdiag array. Even after padding it produced mathematically wrong eigenvalues on K_3 (returned {2, -1, -3} instead of {2, -1, -1}). Colt 1.2.0 is already on the classpath (Gvisual/lib/colt-1.2.0.jar) and ships a well-tested EigenvalueDecomposition. Delegate to it for both adjacency and Laplacian spectra. The hand-rolled tridiagonalize / qrTridiagonal helpers and the now-unused MAX_ITERATIONS constant are removed. Adds 12 JUnit tests in GraphSpectrumAnalyzerTest covering the four issue-#169 reproductions (with closed-form expected spectra), derived metrics, and edge cases. All 12 pass locally. Closes #169.
1 parent e92bbc2 commit c023fa2

2 files changed

Lines changed: 212 additions & 112 deletions

File tree

Gvisual/src/gvisual/GraphSpectrumAnalyzer.java

Lines changed: 23 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import edu.uci.ics.jung.graph.Graph;
44
import java.util.*;
5+
import cern.colt.matrix.DoubleMatrix2D;
6+
import cern.colt.matrix.impl.DenseDoubleMatrix2D;
7+
import cern.colt.matrix.linalg.EigenvalueDecomposition;
58

69
/**
710
* Computes the eigenvalue spectrum of the adjacency and Laplacian matrices
@@ -37,7 +40,6 @@
3740
public class GraphSpectrumAnalyzer {
3841

3942
private static final double EPSILON = 1e-10;
40-
private static final int MAX_ITERATIONS = 500;
4143

4244
private final Graph<String, Edge> graph;
4345
private double[] adjacencyEigenvalues;
@@ -238,126 +240,35 @@ public String getSummary() {
238240
return sb.toString();
239241
}
240242

241-
// ---- QR Algorithm for symmetric matrix eigenvalues ----
243+
// ---- Eigenvalue computation (Colt EigenvalueDecomposition) ----
242244

245+
/**
246+
* Computes the eigenvalues of a real symmetric matrix using Colt's
247+
* {@link EigenvalueDecomposition}.
248+
*
249+
* <p>Previously this class shipped a hand-rolled Householder+implicit-QR
250+
* routine that crashed with {@code ArrayIndexOutOfBoundsException} on
251+
* every non-trivial graph (issue #169) and produced incorrect eigenvalues
252+
* even when it did not crash. Colt is already on the classpath
253+
* ({@code lib/colt-1.2.0.jar}), so we delegate to its well-tested
254+
* symmetric-matrix path.</p>
255+
*/
243256
private double[] computeEigenvalues(double[][] matrix) {
244257
int n = matrix.length;
245258
if (n == 0) return new double[0];
246259
if (n == 1) return new double[]{matrix[0][0]};
247260

248-
// Copy matrix
249-
double[][] M = new double[n][n];
261+
DoubleMatrix2D m = new DenseDoubleMatrix2D(n, n);
250262
for (int i = 0; i < n; i++) {
251-
M[i] = Arrays.copyOf(matrix[i], n);
252-
}
253-
254-
// Reduce to tridiagonal form using Householder reflections
255-
double[] diag = new double[n];
256-
double[] offdiag = new double[n];
257-
tridiagonalize(M, diag, offdiag);
258-
259-
// Apply implicit QR shifts to tridiagonal matrix
260-
qrTridiagonal(diag, offdiag, n);
261-
262-
return diag;
263-
}
264-
265-
/**
266-
* Householder tridiagonalization for symmetric matrix.
267-
* After this, diag contains diagonal and offdiag contains sub-diagonal.
268-
*/
269-
private void tridiagonalize(double[][] M, double[] diag, double[] offdiag) {
270-
int n = M.length;
271-
for (int i = n - 1; i > 0; i--) {
272-
double scale = 0;
273-
for (int k = 0; k < i; k++) scale += Math.abs(M[i][k]);
274-
275-
if (scale < EPSILON) {
276-
offdiag[i] = M[i][i - 1];
277-
} else {
278-
double h = 0;
279-
for (int k = 0; k < i; k++) {
280-
M[i][k] /= scale;
281-
h += M[i][k] * M[i][k];
282-
}
283-
double f = M[i][i - 1];
284-
double g = f >= 0 ? -Math.sqrt(h) : Math.sqrt(h);
285-
offdiag[i] = scale * g;
286-
h -= f * g;
287-
M[i][i - 1] = f - g;
288-
289-
double[] u = new double[i];
290-
for (int j = 0; j < i; j++) {
291-
u[j] = 0;
292-
for (int k = 0; k <= j; k++) u[j] += M[j][k] * M[i][k];
293-
for (int k = j + 1; k < i; k++) u[j] += M[k][j] * M[i][k];
294-
u[j] /= h;
295-
}
296-
297-
double k2 = 0;
298-
for (int j = 0; j < i; j++) k2 += M[i][j] * u[j];
299-
k2 /= (2.0 * h);
300-
301-
for (int j = 0; j < i; j++) u[j] -= k2 * M[i][j];
302-
303-
for (int j = 0; j < i; j++) {
304-
for (int k2b = 0; k2b <= j; k2b++) {
305-
M[j][k2b] -= M[i][j] * u[k2b] + u[j] * M[i][k2b];
306-
}
307-
}
308-
}
309-
diag[i] = M[i][i];
310-
}
311-
diag[0] = M[0][0];
312-
offdiag[0] = 0;
313-
}
314-
315-
/**
316-
* Implicit QR algorithm with Wilkinson shifts for tridiagonal symmetric matrix.
317-
*/
318-
private void qrTridiagonal(double[] diag, double[] offdiag, int n) {
319-
for (int l = 0; l < n; l++) {
320-
int iter = 0;
321-
while (true) {
322-
int m = l;
323-
while (m < n - 1) {
324-
double dd = Math.abs(diag[m]) + Math.abs(diag[m + 1]);
325-
if (Math.abs(offdiag[m + 1]) + dd == dd) break;
326-
m++;
327-
}
328-
if (m == l) break;
329-
if (++iter > MAX_ITERATIONS) break;
330-
331-
// Wilkinson shift
332-
double g = (diag[l + 1] - diag[l]) / (2.0 * offdiag[l + 1]);
333-
double r = Math.sqrt(g * g + 1.0);
334-
double shift = diag[m] - diag[l]
335-
+ offdiag[l + 1] / (g + (g >= 0 ? r : -r));
336-
337-
double s = 1.0, c = 1.0, p = 0.0;
338-
for (int i = m - 1; i >= l; i--) {
339-
double f = s * offdiag[i + 1];
340-
double b = c * offdiag[i + 1];
341-
r = Math.sqrt(f * f + shift * shift);
342-
offdiag[i + 2] = r;
343-
if (Math.abs(r) < EPSILON) {
344-
diag[i + 1] -= p;
345-
offdiag[m + 1 < n ? m + 1 : m] = 0;
346-
break;
347-
}
348-
s = f / r;
349-
c = shift / r;
350-
g = diag[i + 1] - p;
351-
r = (diag[i] - g) * s + 2.0 * c * b;
352-
p = s * r;
353-
diag[i + 1] = g + p;
354-
shift = c * r - b;
355-
}
356-
diag[l] -= p;
357-
offdiag[l + 1] = shift;
358-
if (m + 1 < n) offdiag[m + 1] = 0;
263+
double[] row = matrix[i];
264+
for (int j = 0; j < n; j++) {
265+
m.setQuick(i, j, row[j]);
359266
}
360267
}
268+
EigenvalueDecomposition eig = new EigenvalueDecomposition(m);
269+
double[] real = eig.getRealEigenvalues().toArray();
270+
// Imaginary parts must be ~0 for symmetric input; we trust Colt here.
271+
return real;
361272
}
362273

363274
private void reverseArray(double[] arr) {
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package gvisual;
2+
3+
import edu.uci.ics.jung.graph.Graph;
4+
import edu.uci.ics.jung.graph.UndirectedSparseGraph;
5+
import org.junit.Test;
6+
7+
import java.util.Arrays;
8+
9+
import static org.junit.Assert.*;
10+
11+
/**
12+
* Tests for {@link GraphSpectrumAnalyzer}.
13+
*
14+
* <p>Verifies the fix for issue #169: the previous hand-rolled QR routine
15+
* threw {@code ArrayIndexOutOfBoundsException} on every non-trivial graph
16+
* and returned mathematically wrong eigenvalues even when it did not crash.
17+
* After delegating to Colt's {@code EigenvalueDecomposition}, the analyzer
18+
* matches closed-form spectra for canonical graph families.</p>
19+
*/
20+
public class GraphSpectrumAnalyzerTest {
21+
22+
private static final double TOL = 1e-6;
23+
24+
private static Edge addEdge(Graph<String, Edge> g, String u, String v) {
25+
if (!g.containsVertex(u)) g.addVertex(u);
26+
if (!g.containsVertex(v)) g.addVertex(v);
27+
Edge e = new Edge("f", u, v);
28+
e.setWeight(1f);
29+
g.addEdge(e, u, v);
30+
return e;
31+
}
32+
33+
private static Graph<String, Edge> emptyGraph() {
34+
return new UndirectedSparseGraph<String, Edge>();
35+
}
36+
37+
private static Graph<String, Edge> completeGraph(int n) {
38+
Graph<String, Edge> g = emptyGraph();
39+
for (int i = 0; i < n; i++) g.addVertex("v" + i);
40+
for (int i = 0; i < n; i++) {
41+
for (int j = i + 1; j < n; j++) {
42+
addEdge(g, "v" + i, "v" + j);
43+
}
44+
}
45+
return g;
46+
}
47+
48+
/** Path graph on n vertices: v0 - v1 - ... - v(n-1). */
49+
private static Graph<String, Edge> pathGraph(int n) {
50+
Graph<String, Edge> g = emptyGraph();
51+
for (int i = 0; i < n; i++) g.addVertex("v" + i);
52+
for (int i = 0; i + 1 < n; i++) addEdge(g, "v" + i, "v" + (i + 1));
53+
return g;
54+
}
55+
56+
/** Star graph K_{1, n-1}: hub v0 connected to v1..v(n-1). */
57+
private static Graph<String, Edge> starGraph(int n) {
58+
Graph<String, Edge> g = emptyGraph();
59+
g.addVertex("hub");
60+
for (int i = 1; i < n; i++) addEdge(g, "hub", "leaf" + i);
61+
return g;
62+
}
63+
64+
// ─── Issue #169 reproductions: these used to throw ──────────────
65+
66+
/** Triangle K_3. Old code: ArrayIndexOutOfBoundsException at line 342. */
67+
@Test
68+
public void triangleSpectrumDoesNotCrash() {
69+
GraphSpectrumAnalyzer a = new GraphSpectrumAnalyzer(completeGraph(3));
70+
a.compute();
71+
double[] eig = a.getAdjacencyEigenvalues();
72+
assertEquals(3, eig.length);
73+
// K_3 adjacency spectrum: {2, -1, -1} (descending).
74+
assertEquals(2.0, eig[0], TOL);
75+
assertEquals(-1.0, eig[1], TOL);
76+
assertEquals(-1.0, eig[2], TOL);
77+
}
78+
79+
@Test
80+
public void completeK4Spectrum() {
81+
GraphSpectrumAnalyzer a = new GraphSpectrumAnalyzer(completeGraph(4));
82+
a.compute();
83+
double[] eig = a.getAdjacencyEigenvalues();
84+
// K_n adjacency spectrum: {n-1, -1, -1, ..., -1}.
85+
assertEquals(4, eig.length);
86+
assertEquals(3.0, eig[0], TOL);
87+
for (int i = 1; i < 4; i++) assertEquals(-1.0, eig[i], TOL);
88+
}
89+
90+
@Test
91+
public void path3Spectrum() {
92+
GraphSpectrumAnalyzer a = new GraphSpectrumAnalyzer(pathGraph(3));
93+
a.compute();
94+
double[] eig = a.getAdjacencyEigenvalues();
95+
// P_3 spectrum: { sqrt(2), 0, -sqrt(2) }.
96+
assertEquals(3, eig.length);
97+
assertEquals(Math.sqrt(2.0), eig[0], TOL);
98+
assertEquals(0.0, eig[1], TOL);
99+
assertEquals(-Math.sqrt(2.0), eig[2], TOL);
100+
}
101+
102+
@Test
103+
public void star4Spectrum() {
104+
GraphSpectrumAnalyzer a = new GraphSpectrumAnalyzer(starGraph(4));
105+
a.compute();
106+
double[] eig = a.getAdjacencyEigenvalues();
107+
// K_{1,3} spectrum: { sqrt(3), 0, 0, -sqrt(3) }.
108+
assertEquals(4, eig.length);
109+
assertEquals(Math.sqrt(3.0), eig[0], TOL);
110+
assertEquals(0.0, eig[1], TOL);
111+
assertEquals(0.0, eig[2], TOL);
112+
assertEquals(-Math.sqrt(3.0), eig[3], TOL);
113+
}
114+
115+
// ─── Derived metrics ────────────────────────────────────────────
116+
117+
@Test
118+
public void spectralRadiusMatchesLargestEigenvalue() {
119+
GraphSpectrumAnalyzer a = new GraphSpectrumAnalyzer(completeGraph(5));
120+
a.compute();
121+
// K_5 spectral radius = 4.
122+
assertEquals(4.0, a.getSpectralRadius(), TOL);
123+
}
124+
125+
@Test
126+
public void algebraicConnectivityOfDisconnectedGraphIsZero() {
127+
// Two disjoint edges: {a-b, c-d}.
128+
Graph<String, Edge> g = emptyGraph();
129+
addEdge(g, "a", "b");
130+
addEdge(g, "c", "d");
131+
GraphSpectrumAnalyzer a = new GraphSpectrumAnalyzer(g);
132+
a.compute();
133+
// Fiedler value (second-smallest Laplacian eigenvalue) is 0 for a
134+
// disconnected graph.
135+
assertEquals(0.0, a.getAlgebraicConnectivity(), TOL);
136+
assertEquals(2, a.getComponentCount());
137+
}
138+
139+
@Test
140+
public void algebraicConnectivityOfTriangleIsThree() {
141+
GraphSpectrumAnalyzer a = new GraphSpectrumAnalyzer(completeGraph(3));
142+
a.compute();
143+
// L(K_3) spectrum = {0, 3, 3}. Fiedler value = 3.
144+
assertEquals(3.0, a.getAlgebraicConnectivity(), TOL);
145+
assertEquals(1, a.getComponentCount());
146+
}
147+
148+
@Test
149+
public void graphEnergyOfTriangle() {
150+
GraphSpectrumAnalyzer a = new GraphSpectrumAnalyzer(completeGraph(3));
151+
a.compute();
152+
// Energy(K_3) = |2| + |-1| + |-1| = 4.
153+
assertEquals(4.0, a.getGraphEnergy(), TOL);
154+
}
155+
156+
// ─── Edge cases ─────────────────────────────────────────────────
157+
158+
@Test
159+
public void emptyGraphIsHandled() {
160+
GraphSpectrumAnalyzer a = new GraphSpectrumAnalyzer(emptyGraph());
161+
a.compute();
162+
assertEquals(0, a.getAdjacencyEigenvalues().length);
163+
assertEquals(0, a.getLaplacianEigenvalues().length);
164+
assertEquals(0.0, a.getSpectralRadius(), TOL);
165+
assertEquals(0, a.getComponentCount());
166+
}
167+
168+
@Test
169+
public void singleVertexGraphIsHandled() {
170+
Graph<String, Edge> g = emptyGraph();
171+
g.addVertex("only");
172+
GraphSpectrumAnalyzer a = new GraphSpectrumAnalyzer(g);
173+
a.compute();
174+
// Single vertex: adjacency = [0], Laplacian = [0].
175+
assertArrayEquals(new double[]{0.0}, a.getAdjacencyEigenvalues(), TOL);
176+
assertArrayEquals(new double[]{0.0}, a.getLaplacianEigenvalues(), TOL);
177+
assertEquals(1, a.getComponentCount());
178+
}
179+
180+
@Test(expected = IllegalArgumentException.class)
181+
public void nullGraphRejected() {
182+
new GraphSpectrumAnalyzer(null);
183+
}
184+
185+
@Test(expected = IllegalStateException.class)
186+
public void queryBeforeComputeRejected() {
187+
new GraphSpectrumAnalyzer(completeGraph(3)).getSpectralRadius();
188+
}
189+
}

0 commit comments

Comments
 (0)