Files
tensor/linalg/extreme_scale_pins_test.go
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

1057 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package linalg
import (
"math"
"math/cmplx"
"os"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Regression pins at the extremes of the float64 range: the dense and
// sparse decompositions, solvers and eigensolvers must keep their
// contracts at magnitudes from 1e-300 to 1e300, where a raw square
// overflows or vanishes.
// scales sweeps the magnitudes the decompositions have to survive:
// ordinary 1 as the control that pins the untouched arithmetic, the two
// edges of the squared-arithmetic window (a raw square leaves the normal
// range at about 1.3e154 and 1.5e-162), and the extremes of the float64
// range in both directions.
var scales = []float64{1, 1e150, 1e155, 1e200, 1e300, 1e-150, 1e-160, 1e-200, 1e-300}
// scale multiplies every entry of a flat matrix by s.
func scale(vals []float64, s float64) []float64 {
out := make([]float64, len(vals))
for i, v := range vals {
out[i] = v * s
}
return out
}
// scaleC multiplies every complex entry by the real s.
func scaleC(vals []complex128, s float64) []complex128 {
out := make([]complex128, len(vals))
for i, v := range vals {
out[i] = complex(real(v)*s, imag(v)*s)
}
return out
}
// iJ is the 4x4 matrix I+J: 2 on the diagonal, 1 elsewhere. Its
// spectrum is closed form, 1 three times and 5 once, which is the
// reference every 1e+-200 eigen test below checks against.
func iJ(n int) []float64 {
out := make([]float64, n*n)
for i := range n {
for j := range n {
if i == j {
out[i*n+j] = 2
} else {
out[i*n+j] = 1
}
}
}
return out
}
// pinDenseSolve solves a·x = b by Gaussian elimination with partial
// pivoting. It is the independent brute-force reference the sparse
// solvers are checked against, deliberately written without any of the
// library's own machinery.
func pinDenseSolve(t *testing.T, a, b []complex128, n int) []complex128 {
t.Helper()
m := append([]complex128(nil), a...)
x := append([]complex128(nil), b...)
for k := range n {
piv := k
for i := k + 1; i < n; i++ {
if cmplx.Abs(m[i*n+k]) > cmplx.Abs(m[piv*n+k]) {
piv = i
}
}
if m[piv*n+k] == 0 {
t.Fatalf("dense reference: singular matrix at column %d", k)
}
if piv != k {
for j := range n {
m[k*n+j], m[piv*n+j] = m[piv*n+j], m[k*n+j]
}
x[k], x[piv] = x[piv], x[k]
}
for i := k + 1; i < n; i++ {
f := m[i*n+k] / m[k*n+k]
for j := k; j < n; j++ {
m[i*n+j] -= f * m[k*n+j]
}
x[i] -= f * x[k]
}
}
for k := n - 1; k >= 0; k-- {
for j := k + 1; j < n; j++ {
x[k] -= m[k*n+j] * x[j]
}
x[k] /= m[k*n+k]
}
return x
}
// hermitianStencil builds an n×n Hermitian tridiagonal stencil:
// 2 on the diagonal and conjugate mirrored imaginary couplings. It is
// the mirrored-stencil shape the complex sparse solvers are exercised on.
func hermitianStencil(n int) []complex128 {
a := make([]complex128, n*n)
for i := range n {
a[i*n+i] = 2
if i+1 < n {
a[i*n+i+1] = complex(0, 0.5)
a[(i+1)*n+i] = complex(0, -0.5)
}
}
return a
}
// cSRFromDense builds a complex SparseCOO from a dense flat matrix,
// dropping the exact zeros the way a caller's assembly would.
func cSRFromDense(t *testing.T, a []complex128, n int) *core.SparseCOO {
t.Helper()
var idx []int64
var vals []complex128
for i := range n {
for j := range n {
if a[i*n+j] == 0 {
continue
}
idx = append(idx, int64(i), int64(j))
vals = append(vals, a[i*n+j])
}
}
ind, err := core.FromInts(idx, len(vals), 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
val, err := core.FromComplexes(vals, len(vals))
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
coo, err := core.NewSparseCOO(ind, val, []int{n, n})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
return coo
}
// TestHouseholderVectorReflectsAtExtremeScale pins the reflector itself
// (report T1/F7): for a non-zero x, beta must be positive and finite at
// every magnitude, not +0 (which callers read as "no reflection") or
// +Inf (whose product with a zero dot is NaN), and H must still map x to
// -sign(x0)·‖x‖·e1. The reflection is applied to x/s, so the check's own
// arithmetic stays in range at every scale.
func TestHouseholderVectorReflectsAtExtremeScale(t *testing.T) {
for _, s := range scales {
for _, x0 := range []float64{s, -s} {
x := []float64{x0, s, s}
dst := make([]float64, 3)
hh := householderVectorInto(dst, x)
if !(hh.beta > 0) || math.IsInf(hh.beta, 0) {
t.Fatalf("scale %g, x0 %g: beta = %v, want a positive finite reflector", s, x0, hh.beta)
}
for i, v := range hh.v {
if math.IsInf(v, 0) || math.IsNaN(v) {
t.Fatalf("scale %g, x0 %g: v[%d] = %v, want finite", s, x0, i, v)
}
}
xs := []float64{x0 / s, 1, 1}
dot := 0.0
for i := range xs {
dot += hh.v[i] * xs[i]
}
w := hh.beta * dot
sign := 1.0
if x0 < 0 {
sign = -1
}
for i := range xs {
want := 0.0
if i == 0 {
want = -sign * math.Sqrt(3)
}
if got := xs[i] - hh.v[i]*w; math.Abs(got-want) > 1e-13 {
t.Fatalf("scale %g, x0 %g: (H x)[%d] = %g, want %g", s, x0, i, got, want)
}
}
}
// The zero vector still answers the identity reflector: beta == 0
// is the legitimate "nothing to reflect" signal and must stay
// distinguishable from the overflow the fix removes.
if hh := householderVectorInto(make([]float64, 3), []float64{0, 0, 0}); hh.beta != 0 {
t.Fatalf("scale %g: zero vector beta = %v, want 0", s, hh.beta)
}
}
}
// TestEigenSpectrumAtExtremeScale pins the closed-form spectrum of
// (I+J)·s: 1,1,1,5 times s, with the eigenvectors orthonormal and the
// residual at rounding level. Before the fix, 1e155 returned the raw
// diagonal {2,2,2,2}e155 (the reflector's beta came out as +0 and was
// skipped) and 1e-200 returned all NaN (beta came out as +Inf).
func TestEigenSpectrumAtExtremeScale(t *testing.T) {
const n = 4
want := []float64{1, 1, 1, 5}
for _, s := range scales {
a := mustFloats(t, scale(iJ(n), s), n, n)
vals, vecs, err := Eigen(a)
if err != nil {
t.Fatalf("Eigen at scale %g: %v", s, err)
}
for i := range n {
if got := vals.FloatAt(i) / s; math.Abs(got-want[i]) > 1e-12 {
t.Fatalf("scale %g: eigenvalue %d = %g, want %g", s, i, got, want[i])
}
}
// Residual of the eigenpair on the O(1) shift: ‖(I+J)v - (λ/s)v‖,
// which is the residual of the original problem divided by s.
base := iJ(n)
for k := range n {
lam := vals.FloatAt(k) / s
worst := 0.0
for i := range n {
acc := 0.0
for j := range n {
acc += base[i*n+j] * vecs.FloatAt(j*n+k)
}
worst = math.Max(worst, math.Abs(acc-lam*vecs.FloatAt(i*n+k)))
}
if worst > 1e-12 {
t.Fatalf("scale %g: residual of eigenpair %d = %g, want <= 1e-12", s, k, worst)
}
}
// VᵀV = I: a scaling mistake that dropped the factor would still
// leave orthonormal columns, so this is a cheap second net.
for j := range n {
for k := range n {
acc := 0.0
for i := range n {
acc += vecs.FloatAt(i*n+j) * vecs.FloatAt(i*n+k)
}
want := 0.0
if j == k {
want = 1
}
if math.Abs(acc-want) > 1e-12 {
t.Fatalf("scale %g: (VᵀV)[%d,%d] = %g, want %g", s, j, k, acc, want)
}
}
}
}
}
// TestSVDReconstructsAtExtremeScale pins A = U·Σ·Vᵀ against the
// hand-built 2×2 ([[0,s],[s,0]] has both singular values s) and against
// a dense 4x4 with a closed-form scale-free reconstruction. Before the
// fix, 1e155 returned sigma = (+Inf, +Inf) and the dense case all NaN.
func TestSVDReconstructsAtExtremeScale(t *testing.T) {
for _, s := range scales {
a := mustFloats(t, []float64{0, s, s, 0}, 2, 2)
u, sigma, vt, err := SVD(a)
if err != nil {
t.Fatalf("SVD at scale %g: %v", s, err)
}
for i := range 2 {
if got := sigma.FloatAt(i) / s; math.Abs(got-1) > 1e-13 {
t.Fatalf("scale %g: sigma[%d] = %g, want 1 (in units of s)", s, i, got)
}
}
// [[0,1],[1,0]] = U·(Σ/s)·Vᵀ in scaled units.
for i := range 2 {
for j := range 2 {
acc := 0.0
for k := range 2 {
acc += u.FloatAt(i*2+k) * (sigma.FloatAt(k) / s) * vt.FloatAt(k*2+j)
}
want := 0.0
if i != j {
want = 1
}
if math.Abs(acc-want) > 1e-13 {
t.Fatalf("scale %g: (UΣVᵀ)[%d,%d] = %g, want %g", s, i, j, acc, want)
}
}
}
}
// The dense case the report saw as all NaN at 1e155.
const n = 4
base := []float64{
4, 1, 2, 0.5,
1, 3, 0.25, 1,
2, 0.25, 5, 1,
0.5, 1, 1, 2,
}
for _, s := range []float64{1, 1e155, 1e-155, 1e200} {
a := mustFloats(t, scale(base, s), n, n)
u, sigma, vt, err := SVD(a)
if err != nil {
t.Fatalf("SVD dense at scale %g: %v", s, err)
}
worst := 0.0
for i := range n {
for j := range n {
// A/s = U·(Σ/s)·Vᵀ.
acc := 0.0
for k := range n {
acc += u.FloatAt(i*n+k) * (sigma.FloatAt(k) / s) * vt.FloatAt(k*n+j)
}
worst = math.Max(worst, math.Abs(acc-base[i*n+j]))
}
}
if worst > 1e-13 {
t.Fatalf("scale %g: dense reconstruction error %g, want <= 1e-13", s, worst)
}
}
}
// TestEigenGeneralSpectrumAtExtremeScale pins EigenGeneral on (I+J)·s,
// where the spectrum is closed form and the matrix is far from
// defective. Before the fix, 1e-200 silently returned {2,2,2,2}e-200 and
// 1e160 failed with a spurious "QR iteration failed to converge".
func TestEigenGeneralSpectrumAtExtremeScale(t *testing.T) {
const n = 4
want := []float64{1, 1, 1, 5}
for _, s := range scales {
a := mustFloats(t, scale(iJ(n), s), n, n)
vals, vecs, err := EigenGeneral(a)
if err != nil {
t.Fatalf("EigenGeneral at scale %g: %v", s, err)
}
// Values come back descending by magnitude: 5 then 1,1,1.
got := make([]float64, n)
for i := range n {
got[i] = cmplx.Abs(vals.ComplexAt(i)) / s
}
if math.Abs(got[0]-5) > 1e-12 {
t.Fatalf("scale %g: |λ| max = %g, want 5", s, got[0])
}
for i := 1; i < n; i++ {
if math.Abs(got[i]-1) > 1e-12 {
t.Fatalf("scale %g: |λ| %d = %g, want %g", s, i, got[i], want[i])
}
}
// Residual of every eigenpair on the O(1) shift.
base := iJ(n)
for k := range n {
lam := vals.ComplexAt(k) / complex(s, 0)
worst := 0.0
for i := range n {
acc := complex(0, 0)
for j := range n {
acc += complex(base[i*n+j], 0) * vecs.ComplexAt(j*n+k)
}
worst = math.Max(worst, cmplx.Abs(acc-lam*vecs.ComplexAt(i*n+k)))
}
if worst > 1e-12 {
t.Fatalf("scale %g: residual of eigenpair %d = %g, want <= 1e-12", s, k, worst)
}
}
}
// A mixed-scale matrix: a huge diagonal with couplings 1e-260 of it.
// The k = 1 reflector's column is then about 1e-200 while the matrix
// max is 1e60, so vMax*vMax underflows to zero, beta comes out as
// +Inf and the update turns the reduction into NaN, even though the
// matrix itself is inside the safe window. The spectrum is that of
// the huge diagonal to rounding.
const big, rel = 1e60, 1e-260
mixed := []float64{
big, 0, 0, 0,
0, big, big * rel, big * rel,
0, big * rel, big, big * rel,
0, big * rel, big * rel, big,
}
valsM, _, err := EigenGeneral(mustFloats(t, mixed, n, n))
if err != nil {
t.Fatalf("EigenGeneral of the mixed-scale matrix: %v", err)
}
for i := range n {
got := valsM.ComplexAt(i)
if math.IsNaN(real(got)) || math.IsNaN(imag(got)) {
t.Fatalf("mixed-scale eigenvalue %d = %v, want a finite value", i, got)
}
if scale := cmplx.Abs(got) / big; math.Abs(scale-1) > 1e-12 {
t.Fatalf("mixed-scale eigenvalue %d = %v, want magnitude %g", i, got, big)
}
}
// The reflector-side face of the same defect, reached with an
// ordinary matrix: a unit diagonal with a column at 1e-160. That
// column is above the magnitude floor, so the reflector is built, but
// its raw squares are subnormal, beta overflows to +Inf and the whole
// reduction comes back NaN where the matrix is perfectly valid. Only
// the reflector's rescaling survives it, so this case pins that
// mechanism on its own.
const tiny = 1e-160
near := []float64{
1, 0, 0, 0,
0, 1, tiny, tiny,
0, tiny, 1, tiny,
0, tiny, tiny, 1,
}
valsN, _, err := EigenGeneral(mustFloats(t, near, n, n))
if err != nil {
t.Fatalf("EigenGeneral of the subnormal-column matrix: %v", err)
}
for i := range n {
got := valsN.ComplexAt(i)
if math.IsNaN(real(got)) || math.IsNaN(imag(got)) {
t.Fatalf("subnormal-column eigenvalue %d = %v, want a finite value", i, got)
}
if math.Abs(cmplx.Abs(got)-1) > 1e-12 {
t.Fatalf("subnormal-column eigenvalue %d = %v, want magnitude 1", i, got)
}
}
}
// TestSchurComplexAndMatrixSqrtAtExtremeScale pins the Schur contract
// A = Q·T·Qᴴ with T upper triangular and the diagonal of T the closed-form
// circulant spectrum, plus the defining property of the principal square
// root, r·r = A. Before the fix, SchurComplex, MatrixSqrt and MatrixLog
// all failed with "the Schur iteration did not converge" at 1e-200 and
// 1e160 on perfectly valid input.
func TestSchurComplexAndMatrixSqrtAtExtremeScale(t *testing.T) {
const n = 4
base := []float64{
4, 1, 2, 3,
3, 4, 1, 2,
2, 3, 4, 1,
1, 2, 3, 4,
}
// Eigenvalues of the symmetric circulant with first row [4,1,2,3]:
// 10, 2, 2+2i, 2-2i, computed from the closed form Σ c_j ω^{jk}.
want := []complex128{10, 2, complex(2, 2), complex(2, -2)}
for _, s := range scales {
a := mustFloats(t, scale(base, s), n, n)
tm, q, err := SchurComplex(a)
if err != nil {
t.Fatalf("SchurComplex at scale %g: %v", s, err)
}
// A/s = Q·(T/s)·Qᴴ.
worst := 0.0
for i := range n {
for j := range n {
acc := complex(0, 0)
for k := range n {
for l := range n {
acc += q.ComplexAt(i*n+k) * (tm.ComplexAt(k*n+l) / complex(s, 0)) * cmplx.Conj(q.ComplexAt(j*n+l))
}
}
worst = math.Max(worst, cmplx.Abs(acc-complex(base[i*n+j], 0)))
}
}
if worst > 1e-13 {
t.Fatalf("scale %g: Schur reconstruction error %g, want <= 1e-13", s, worst)
}
// T upper triangular, and its diagonal the closed-form spectrum.
for i := 1; i < n; i++ {
for j := 0; j < i; j++ {
if v := cmplx.Abs(tm.ComplexAt(i*n+j)) / s; v > 1e-13 {
t.Fatalf("scale %g: T[%d,%d] = %g, want upper triangular", s, i, j, v)
}
}
}
got := make([]complex128, n)
for i := range n {
got[i] = tm.ComplexAt(i*n+i) / complex(s, 0)
}
if !spectrumMatches(got, want, 1e-12) {
t.Fatalf("scale %g: Schur diagonal %v, want %v", s, got, want)
}
// The principal square root of [[7,10],[15,22]]·s satisfies
// r·r = A; the closed form of the O(1) matrix is [[1,2],[3,4]]²,
// so the check is (r/√s)² = [[7,10],[15,22]].
ms, err := MatrixSqrt(mustFloats(t, scale([]float64{7, 10, 15, 22}, s), 2, 2))
if err != nil {
t.Fatalf("MatrixSqrt at scale %g: %v", s, err)
}
r := math.Sqrt(s)
rr := []float64{
ms.FloatAt(0)*ms.FloatAt(0) + ms.FloatAt(1)*ms.FloatAt(2),
ms.FloatAt(0)*ms.FloatAt(1) + ms.FloatAt(1)*ms.FloatAt(3),
ms.FloatAt(2)*ms.FloatAt(0) + ms.FloatAt(3)*ms.FloatAt(2),
ms.FloatAt(2)*ms.FloatAt(1) + ms.FloatAt(3)*ms.FloatAt(3),
}
sq := []float64{7, 10, 15, 22}
for i := range 4 {
if got := rr[i] / (r * r); math.Abs(got-sq[i]) > 1e-12 {
t.Fatalf("scale %g: (r·r)[%d] = %g, want %g", s, i, got, sq[i])
}
}
}
}
// spectrumMatches reports whether every want finds a distinct got
// within tol: a spectrum is a set, and the order of near-equal complex
// values is not part of the contract.
func spectrumMatches(got, want []complex128, tol float64) bool {
used := make([]bool, len(got))
for _, w := range want {
found := false
for i, g := range got {
if !used[i] && cmplx.Abs(g-w) <= tol {
used[i] = true
found = true
break
}
}
if !found {
return false
}
}
return true
}
// TestSVDComplexReconstructsAtExtremeScale pins A = U·Σ·Vᴴ on the
// hand-built [[0,t],[t,0]], whose singular values are both t, and on
// diag(1e160, 1e-160). Before the fix, t = 1e-200 silently returned
// sigma = (0,0) (the reflector norm underflowed to zero and the
// below-diagonal mass was zeroed with it) and t = 1e160 returned NaN.
func TestSVDComplexReconstructsAtExtremeScale(t *testing.T) {
for _, t0 := range scales {
a := mustFromComplexes(t, []complex128{
0, complex(t0, 0),
complex(t0, 0), 0,
}, 2, 2)
u, sigma, vh, err := SVDComplex(a)
if err != nil {
t.Fatalf("SVDComplex at scale %g: %v", t0, err)
}
for i := range 2 {
if got := sigma.FloatAt(i) / t0; math.Abs(got-1) > 1e-13 {
t.Fatalf("scale %g: sigma[%d] = %g, want 1 (in units of t)", t0, i, got)
}
}
for i := range 2 {
for j := range 2 {
acc := complex(0, 0)
for k := range 2 {
acc += u.ComplexAt(i*2+k) * complex(sigma.FloatAt(k)/t0, 0) * vh.ComplexAt(k*2+j)
}
want := complex(0, 0)
if i != j {
want = 1
}
if d := cmplx.Abs(acc - want); d > 1e-13 {
t.Fatalf("scale %g: (UΣVᴴ)[%d,%d] error %g, want 0", t0, i, j, d)
}
}
}
}
// The mixed-spectrum case the report saw as NaN, NaN.
a := mustFromComplexes(t, []complex128{complex(1e160, 0), 0, 0, complex(1e-160, 0)}, 2, 2)
_, sigma, _, err := SVDComplex(a)
if err != nil {
t.Fatalf("SVDComplex diag(1e160, 1e-160): %v", err)
}
if got := sigma.FloatAt(0) / 1e160; math.Abs(got-1) > 1e-13 {
t.Fatalf("diag(1e160, 1e-160): sigma[0] = %g, want 1e160", sigma.FloatAt(0))
}
if got := sigma.FloatAt(1) / 1e-160; math.Abs(got-1) > 1e-13 {
t.Fatalf("diag(1e160, 1e-160): sigma[1] = %g, want 1e-160", sigma.FloatAt(1))
}
}
// TestEigenComplexSpectrumAtExtremeScale pins the closed-form spectrum of
// the Hermitian [[2s,is],[-is,2s]], which is {s, 3s}. Before the fix the
// Jacobi convergence test's raw sum of squares overflowed to +Inf at
// 1e200 (every sweep looked converged) and underflowed to 0 at 1e-200,
// so both returned the unrotated diagonal {2s, 2s} silently.
func TestEigenComplexSpectrumAtExtremeScale(t *testing.T) {
for _, s := range scales {
a := mustFromComplexes(t, []complex128{
complex(2*s, 0), complex(0, s),
complex(0, -s), complex(2*s, 0),
}, 2, 2)
vals, vecs, err := EigenComplex(a)
if err != nil {
t.Fatalf("EigenComplex at scale %g: %v", s, err)
}
if got := vals.FloatAt(0) / s; math.Abs(got-1) > 1e-13 {
t.Fatalf("scale %g: eigenvalue 0 = %g, want 1 (in units of s)", s, got)
}
if got := vals.FloatAt(1) / s; math.Abs(got-3) > 1e-13 {
t.Fatalf("scale %g: eigenvalue 1 = %g, want 3 (in units of s)", s, got)
}
// Residual of both eigenpairs on the O(1) shift [[2,i],[-i,2]].
base := []complex128{2, complex(0, 1), complex(0, -1), 2}
for k := range 2 {
lam := complex(vals.FloatAt(k)/s, 0)
worst := 0.0
for i := range 2 {
acc := complex(0, 0)
for j := range 2 {
acc += base[i*2+j] * vecs.ComplexAt(j*2+k)
}
worst = math.Max(worst, cmplx.Abs(acc-lam*vecs.ComplexAt(i*2+k)))
}
if worst > 1e-13 {
t.Fatalf("scale %g: residual of eigenpair %d = %g, want <= 1e-13", s, k, worst)
}
}
}
}
// TestSpEigenComplexAtExtremeScale pins the Hermitian Lanczos on the
// mirrored stencil against the closed-form spectrum of the ordinary
// matrix: 2 + cos(k·π/(n+1)) for k = 1..n, whose two largest values are
// the top Ritz pairs. Before the scaling the recurrence collapsed at
// 1e200 (the projected tridiagonal's squared magnitudes overflowed) and
// returned NaN, and at 1e-200 the values came back as zero.
func TestSpEigenComplexAtExtremeScale(t *testing.T) {
const n = 8
want := []float64{
2 + math.Cos(math.Pi/float64(n+1)),
2 + math.Cos(2*math.Pi/float64(n+1)),
}
base := hermitianStencil(n)
for _, s := range scales {
coo := cSRFromDense(t, scaleC(base, s), n)
vals, vecs, err := SpEigenComplex(coo, 2, core.NewGenerator(7))
if err != nil {
t.Fatalf("SpEigenComplex at scale %g: %v", s, err)
}
for i := range 2 {
got := vals.FloatAt(i)
if math.IsNaN(got) {
t.Fatalf("scale %g: Ritz value %d = NaN", s, i)
}
if rel := got / s; math.Abs(rel-want[i]) > 1e-12 {
t.Fatalf("scale %g: Ritz value %d = %g, want %g (in units of s)", s, i, rel, want[i])
}
}
// The Ritz vectors solve the O(1) stencil to rounding.
for k := range 2 {
lam := complex(vals.FloatAt(k)/s, 0)
worst := 0.0
for i := range n {
acc := complex(0, 0)
for j := range n {
acc += base[i*n+j] * vecs.ComplexAt(j*2+k)
}
worst = math.Max(worst, cmplx.Abs(acc-lam*vecs.ComplexAt(i*2+k)))
}
if worst > 1e-12 {
t.Fatalf("scale %g: Ritz residual %d = %g, want <= 1e-12", s, k, worst)
}
}
}
}
// TestSpSolveComplexAtExtremeScale pins both complex sparse solvers
// against a brute-force dense elimination of the mirrored-stencil system,
// at ordinary scale (the control the oracle pins) and at the extremes.
// Before the fix, a huge b made bNorm +Inf so every convergence check
// passed and both solvers returned after one step (residual 0.5s), and a
// tiny b made bNorm 0 so the zero vector was returned as the exact
// solution.
func TestSpSolveComplexAtExtremeScale(t *testing.T) {
const n = 8
// Reference: the ordinary-scale system solved densely.
base := hermitianStencil(n)
rhs := make([]complex128, n)
for i := range n {
rhs[i] = complex(1, 0.25)
}
want := pinDenseSolve(t, base, rhs, n)
for _, s := range scales {
coo := cSRFromDense(t, scaleC(base, s), n)
b := mustFromComplexes(t, scaleC(rhs, s), n)
x, err := SpSolveComplexCG(coo, b, 1e-12, 400)
if err != nil {
t.Fatalf("SpSolveComplexCG at scale %g: %v", s, err)
}
// (sA)x = s·b has exactly the ordinary-scale solution, so the
// returned x must equal the dense reference as it stands: the
// factor cancels and must not be applied again.
worst := 0.0
for i := range n {
worst = math.Max(worst, cmplx.Abs(x.ComplexAt(i)-want[i]))
}
if worst > 1e-9 {
t.Fatalf("scale %g: CG solution differs from the dense reference by %g", s, worst)
}
// The same system through BiCGSTAB (Hermitian input is a valid
// special case of its contract).
coo2 := cSRFromDense(t, scaleC(base, s), n)
xb, err := SpSolveComplexBiCGSTAB(coo2, b, 1e-12, 400)
if err != nil {
t.Fatalf("SpSolveComplexBiCGSTAB at scale %g: %v", s, err)
}
worst = 0.0
for i := range n {
worst = math.Max(worst, cmplx.Abs(xb.ComplexAt(i)-want[i]))
}
if worst > 1e-9 {
t.Fatalf("scale %g: BiCGSTAB solution differs from the dense reference by %g", s, worst)
}
}
// The non-Hermitian shape BiCGSTAB exists for, same reference method.
nonHerm := make([]complex128, n*n)
for i := range n {
nonHerm[i*n+i] = complex(2, 1)
if i+1 < n {
nonHerm[i*n+i+1] = complex(1, 0.3)
nonHerm[(i+1)*n+i] = complex(0, -0.25)
}
}
wantNH := pinDenseSolve(t, nonHerm, rhs, n)
for _, s := range scales {
coo := cSRFromDense(t, scaleC(nonHerm, s), n)
b := mustFromComplexes(t, scaleC(rhs, s), n)
x, err := SpSolveComplexBiCGSTAB(coo, b, 1e-12, 400)
if err != nil {
t.Fatalf("non-Hermitian BiCGSTAB at scale %g: %v", s, err)
}
worst := 0.0
for i := range n {
worst = math.Max(worst, cmplx.Abs(x.ComplexAt(i)-wantNH[i]))
}
if worst > 1e-9 {
t.Fatalf("non-Hermitian scale %g: solution differs from the dense reference by %g", s, worst)
}
}
}
// TestSpEigenComplexErrorNamesK pins the malformed diagnostic (report
// F14): the k-range error had three verbs and two arguments and rendered
// as "%!s(int=2): k must be in [1, 5], got %!d(MISSING)".
func TestSpEigenComplexErrorNamesK(t *testing.T) {
coo := cSRFromDense(t, hermitianStencil(5), 5)
_, _, err := SpEigenComplex(coo, 6, nil)
if err == nil {
t.Fatal("SpEigenComplex with k > n: want an error")
}
msg := err.Error()
if strings.Contains(msg, "%!") {
t.Fatalf("malformed error message: %q", msg)
}
if !strings.Contains(msg, "SpEigenComplex: k must be in [1, 5], got 6") {
t.Fatalf("error message = %q, want it to name the entry point and the range", msg)
}
if _, _, err := SpEigenComplex(coo, 0, nil); err == nil || strings.Contains(err.Error(), "%!") {
t.Fatalf("k = 0 error = %v, want a well-formed range error", err)
}
}
// TestSymmetryGuardsAreRelativeAtSmallScale pins the guards of Eigen,
// EigenComplex and the complex sparse Hermitian check on a matrix whose
// asymmetry is a tenth of its own scale: an absolute floor of 1e-15
// approved such a matrix and the symmetric path then answered for a
// matrix that is neither A nor Aᵀ, while the wording of the refusal is
// unchanged. The exactly symmetric matrix of the same scale must still
// be accepted, so the rule is relative and not simply stricter.
func TestSymmetryGuardsAreRelativeAtSmallScale(t *testing.T) {
const s = 1e-14
// Relative asymmetry 1e-1.
asym := mustFloats(t, []float64{s, 0, 1e-15, s}, 2, 2)
if _, _, err := Eigen(asym); err == nil {
t.Fatal("Eigen: asymmetric at 10% of scale 1e-14 was accepted, want a refusal")
} else if !strings.Contains(err.Error(), "Eigen: matrix is not symmetric within 1e-12 tolerance") {
t.Fatalf("Eigen refusal = %q, want the unchanged wording", err)
}
// A genuinely symmetric matrix of the same tiny scale still passes
// the guard: if it did not, the test would pin a stricter rule than
// the contract asks for (the tiny p.d. case must reach the solver).
sym := mustFloats(t, []float64{s, 1e-15, 1e-15, s}, 2, 2)
if _, _, err := Eigen(sym); err != nil {
t.Fatalf("Eigen of a symmetric matrix at scale %g: %v", s, err)
}
nonHerm := mustFromComplexes(t, []complex128{
complex(s, 0), complex(1e-15, 0),
0, complex(s, 0),
}, 2, 2)
if _, _, err := EigenComplex(nonHerm); err == nil {
t.Fatal("EigenComplex: non-Hermitian at 10% of scale 1e-14 was accepted, want a refusal")
} else if !strings.Contains(err.Error(), "EigenComplex: matrix is not Hermitian within 1e-12 tolerance") {
t.Fatalf("EigenComplex refusal = %q, want the unchanged wording", err)
}
herm := mustFromComplexes(t, []complex128{
complex(s, 0), complex(0, 1e-15),
complex(0, -1e-15), complex(s, 0),
}, 2, 2)
if _, _, err := EigenComplex(herm); err != nil {
t.Fatalf("EigenComplex of a Hermitian matrix at scale %g: %v", s, err)
}
// The sparse complex twin: both mirrored entries are stored, and the
// asymmetry between i·1e-16 and i·1e-17 is about a percent of the
// 1e-14 scale yet below the 1e-15 absolute floor, so the pre-fix guard
// approved it and Lanczos ran on a matrix that is not Hermitian. The
// refusal wording is pinned as well.
coo := cSRFromDense(t, []complex128{
complex(s, 0), complex(0, 1e-16),
complex(0, 1e-17), complex(s, 0),
}, 2)
if _, _, err := SpEigenComplex(coo, 1, core.NewGenerator(3)); err == nil {
t.Fatal("SpEigenComplex: non-Hermitian at 10% of scale 1e-14 was accepted, want a refusal")
} else if !strings.Contains(err.Error(), "SpEigenComplex: matrix is not Hermitian within 1e-12 tolerance") {
t.Fatalf("SpEigenComplex refusal = %q, want the unchanged wording", err)
}
// The exactly Hermitian matrix of the same scale must still be
// accepted: the rule is relative, not simply stricter.
hermSparse := cSRFromDense(t, []complex128{
complex(s, 0), complex(0, 1e-16),
complex(0, -1e-16), complex(s, 0),
}, 2)
if _, _, err := SpEigenComplex(hermSparse, 1, core.NewGenerator(3)); err != nil {
t.Fatalf("SpEigenComplex of a Hermitian matrix at scale %g: %v", s, err)
}
}
// TestNoArrowSymbolsInSource pins the source convention: the arrow
// code points must not appear as assignment notation in comments,
// which the house style forbids.
func TestNoArrowSymbolsInSource(t *testing.T) {
for _, name := range []string{
"decomp2.go", "decomp3.go", "eigenreal.go",
"schur.go", "csvd.go", "sparsecomplex.go",
} {
src, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for i, line := range strings.Split(string(src), "\n") {
for _, r := range []rune{'←', '→', '↑', '↓', '⇒'} {
if strings.ContainsRune(line, r) {
t.Fatalf("%s:%d contains U+%04X: %s", name, i+1, r, line)
}
}
}
}
}
// stencil builds the n×n tridiagonal 2 / 0.5 stencil. Its spectrum
// is closed form, 2 + cos(k·π/(n+1)) for k = 1..n, which is the reference
// the sparse eigensolvers below are checked against.
func stencil(n int) []float64 {
out := make([]float64, n*n)
for i := range n {
out[i*n+i] = 2
if i+1 < n {
out[i*n+i+1] = 0.5
out[(i+1)*n+i] = 0.5
}
}
return out
}
// topStencilValues is the two largest eigenvalues of an n×n 2 / 0.5
// stencil, derived from that closed form.
func topStencilValues(n int) []float64 {
return []float64{
2 + math.Cos(math.Pi/float64(n+1)),
2 + math.Cos(2*math.Pi/float64(n+1)),
}
}
// realCOO builds a real SparseCOO from a dense flat matrix,
// dropping the exact zeros the way a caller's assembly would.
func realCOO(t *testing.T, a []float64, n int) *core.SparseCOO {
t.Helper()
var idx []int64
var vals []float64
for i := range n {
for j := range n {
if a[i*n+j] == 0 {
continue
}
idx = append(idx, int64(i), int64(j))
vals = append(vals, a[i*n+j])
}
}
ind, err := core.FromInts(idx, len(vals), 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
val, err := core.FromFloats(vals, len(vals))
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
coo, err := core.NewSparseCOO(ind, val, []int{n, n})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
return coo
}
// ritzResidual returns max_k ‖(A/s)·u_k − (λ_k/s)·u_k‖ for the k
// Ritz pairs of a sparse matrix held as a COO, with A/s formed from the
// stored values so nothing overflows at either extreme of the scale
// sweep. vals and vecs are the returned values and the (n, k) vectors.
func ritzResidual(a *core.SparseCOO, vals, vecs []complex128, n, k int, s float64) float64 {
idx := a.Indices.RawInts()
nnz := a.Indices.Shape()[0]
worst := 0.0
for kk := range k {
lam := vals[kk] / complex(s, 0)
for i := range n {
acc := complex(0, 0)
for p := range nnz {
if int(idx[p*2]) != i {
continue
}
v := complex(0, 0)
if a.Values.Dtype() == core.Complex {
v = a.Values.ComplexAt(p)
} else {
v = complex(a.Values.FloatAt(p), 0)
}
acc += (v / complex(s, 0)) * vecs[int(idx[p*2+1])*k+kk]
}
if d := cmplx.Abs(acc - lam*vecs[i*k+kk]); d > worst {
worst = d
}
}
}
return worst
}
// ritzResidualReal is ritzResidual for a real Ritz pair set.
func ritzResidualReal(a *core.SparseCOO, vals []float64, vecs *core.Array, n, k int, s float64) float64 {
cv := make([]complex128, n*k)
for i := range n {
for j := range k {
cv[i*k+j] = complex(vecs.FloatAt(i*k+j), 0)
}
}
cvals := make([]complex128, k)
for j := range k {
cvals[j] = complex(vals[j], 0)
}
return ritzResidual(a, cvals, cv, n, k, s)
}
// TestSparseSymmetryGuardIsRelativeAtSmallScale pins the fourth absolute
// floor (report F16, sparseigen.go checkSymmetric, the guard behind
// SpEigen, SpSolve and SpExpApply): an asymmetry that is a percent of a
// 1e-14 matrix, yet below the old 1e-15 floor, must be refused with the
// same wording as its dense and complex siblings, and the exactly
// symmetric matrix of that scale must still be accepted.
func TestSparseSymmetryGuardIsRelativeAtSmallScale(t *testing.T) {
const s = 1e-14
// Both mirrored entries are stored; the difference is 9e-17, below
// the removed 1e-15 floor and above the purely relative 1e-26.
asym := realCOO(t, []float64{s, 1e-16, 1e-17, s}, 2)
b := mustFloats(t, []float64{s, s}, 2)
if _, _, err := SpEigen(asym, 2, core.NewGenerator(7)); err == nil {
t.Fatal("SpEigen: asymmetric at a percent of scale 1e-14 was accepted, want a refusal")
} else if !strings.Contains(err.Error(), "SpEigen: matrix is not symmetric within 1e-12 tolerance") {
t.Fatalf("SpEigen refusal = %q, want the sibling wording", err)
}
if _, err := SpSolve(asym, b, 1e-12, 100); err == nil {
t.Fatal("SpSolve: asymmetric at a percent of scale 1e-14 was accepted, want a refusal")
} else if !strings.Contains(err.Error(), "SpSolve: matrix is not symmetric within 1e-12 tolerance") {
t.Fatalf("SpSolve refusal = %q, want the sibling wording", err)
}
if _, err := SpExpApply(asym, b, 0); err == nil {
t.Fatal("SpExpApply: asymmetric at a percent of scale 1e-14 was accepted, want a refusal")
} else if !strings.Contains(err.Error(), "SpExpApply: matrix is not symmetric within 1e-12 tolerance") {
t.Fatalf("SpExpApply refusal = %q, want the sibling wording", err)
}
// The exactly symmetric matrix of the same scale passes all three.
sym := realCOO(t, []float64{s, 1e-16, 1e-16, s}, 2)
if _, _, err := SpEigen(sym, 2, core.NewGenerator(7)); err != nil {
t.Fatalf("SpEigen of a symmetric matrix at scale %g: %v", s, err)
}
if _, err := SpSolve(sym, b, 1e-12, 100); err != nil {
t.Fatalf("SpSolve of a symmetric matrix at scale %g: %v", s, err)
}
if _, err := SpExpApply(sym, b, 0); err != nil {
t.Fatalf("SpExpApply of a symmetric matrix at scale %g: %v", s, err)
}
}
// TestSpEigenSpectrumAtExtremeScale pins the real Hermitian Lanczos on the
// 2 / 0.5 stencil against the closed-form spectrum 2+cos(k·π/(n+1)). The
// projected tridiagonal's squared accumulation overflows in the symmetric
// sweep above about 1.3e154, which deflates the whole block at once and
// returns the raw Rayleigh quotients as the spectrum.
func TestSpEigenSpectrumAtExtremeScale(t *testing.T) {
const n = 8
want := topStencilValues(n)
for _, s := range scales {
coo := realCOO(t, scale(stencil(n), s), n)
vals, vecs, err := SpEigen(coo, 2, core.NewGenerator(7))
if err != nil {
t.Fatalf("SpEigen at scale %g: %v", s, err)
}
for i := range 2 {
got := vals.FloatAt(i) / s
if math.IsNaN(got) || math.IsInf(got, 0) {
t.Fatalf("scale %g: Ritz value %d = %v, want %g", s, i, vals.FloatAt(i), want[i])
}
if math.Abs(got-want[i]) > 1e-12 {
t.Fatalf("scale %g: Ritz value %d = %g, want %g (in units of s)", s, i, got, want[i])
}
}
if res := ritzResidualReal(coo, vals.RawFloats(), vecs, n, 2, s); res > 1e-12 {
t.Fatalf("scale %g: Ritz residual %g, want <= 1e-12", s, res)
}
}
}
// TestSpEigenGeneralSpectrumAtExtremeScale pins both general sparse
// eigensolvers on the same closed-form spectrum. The Arnoldi recurrence
// computes its norms as a raw sum of squares on the unscaled operator, so
// above about 1.3e154 the projected coupling became +Inf and below about
// 1.5e-162 it became zero, which the exhaustion test read as a collapsed
// Krylov block: both extremes returned wrong Ritz values silently.
func TestSpEigenGeneralSpectrumAtExtremeScale(t *testing.T) {
const n = 8
want := topStencilValues(n)
// The Hermitian stencil for the complex entry: 2 on the diagonal,
// conjugate mirrored imaginary couplings, same closed-form spectrum.
cb := hermitianStencil(n)
// A real symmetric matrix is a valid, though not typical, input to
// the general entry, and its real spectrum makes the reference
// unambiguous.
cScales := []float64{1, 1e150, 1e200, 1e-150, 1e-200}
for _, s := range cScales {
coo := realCOO(t, scale(stencil(n), s), n)
vals, vecs, err := SpEigenGeneral(coo, 2, core.NewGenerator(5))
if err != nil {
t.Fatalf("SpEigenGeneral at scale %g: %v", s, err)
}
cv := vals.RawComplexes()
for i := range 2 {
got := cmplx.Abs(cv[i]) / s
if math.Abs(got-want[i]) > 1e-12 {
t.Fatalf("SpEigenGeneral scale %g: |λ| %d = %g, want %g", s, i, got, want[i])
}
}
if res := ritzResidual(coo, cv, vecs.RawComplexes(), n, 2, s); res > 1e-12 {
t.Fatalf("SpEigenGeneral scale %g: Ritz residual %g, want <= 1e-12", s, res)
}
ccoo := cSRFromDense(t, scaleC(cb, s), n)
cvals, cvecs, err := SpEigenGeneralComplex(ccoo, 2, core.NewGenerator(5))
if err != nil {
t.Fatalf("SpEigenGeneralComplex at scale %g: %v", s, err)
}
ccv := cvals.RawComplexes()
for i := range 2 {
got := cmplx.Abs(ccv[i]) / s
if math.Abs(got-want[i]) > 1e-12 {
t.Fatalf("SpEigenGeneralComplex scale %g: |λ| %d = %g, want %g", s, i, got, want[i])
}
}
if res := ritzResidual(ccoo, ccv, cvecs.RawComplexes(), n, 2, s); res > 1e-12 {
t.Fatalf("SpEigenGeneralComplex scale %g: Ritz residual %g, want <= 1e-12", s, res)
}
}
}