1057 lines
36 KiB
Go
1057 lines
36 KiB
Go
// 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)
|
||
}
|
||
}
|
||
}
|