Files
tensor/linalg/sparse_guard_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

520 lines
18 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 (
"fmt"
"math"
"slices"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Regression pins: a depth-first seed that ignored the live row
// permutation in the sparse LU, overflow states that slipped past the
// pivot and downdate guards, non-finite entries that sailed through
// the ILU intake and the symmetry screens, a minimum-degree
// absorption that handed a vertex its own index back, a zero
// hypotenuse in the complex SVD bulge chase, and a MatrixLog screen
// that refused positive definite matrices with small positive
// eigenvalues and a false "non-positive" verdict.
// TestSparseLUPermutedSeedFactorisation pins the seed fix: once an
// earlier column has swapped rows, the depth-first search over column
// k must enter through the rows' current positions in the factor, not
// through the labels they were stored under. The 4×4 matrix below
// swaps original row 2 to position 0 and row 3 to position 1 before
// column 1 is eliminated, so column 1's stored rows 2 and 3 now sit
// at positions 0 and 3.
func TestSparseLUPermutedSeedFactorisation(t *testing.T) {
trips := [][3]float64{
{0, 0, 1}, {0, 2, 1}, {0, 3, 1},
{1, 0, 2}, {1, 2, 1}, {1, 3, 1},
{2, 0, 5}, {2, 1, 7}, {2, 2, 8}, {2, 3, 1},
{3, 1, 4}, {3, 2, 1}, {3, 3, 9},
}
idx := make([]int64, 0, 2*len(trips))
vals := make([]float64, 0, len(trips))
for _, e := range trips {
idx = append(idx, int64(e[0]), int64(e[1]))
vals = append(vals, e[2])
}
coo, err := core.NewSparseCOO(mustInts(t, idx, len(trips), 2),
floatsToArray(vals, []int{len(trips)}), []int{4, 4})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
f, err := NewSparseLU(coo)
if err != nil {
t.Fatalf("NewSparseLU: %v", err)
}
if f.piv[0] != 2 || f.piv[1] != 3 {
t.Fatalf("permutation %v, want it to start [2 3 ...]", f.piv)
}
// The swapped row 2 (now position 0) carries A[2,1] = 7 into
// U row 0: the seed walk must have found its column.
found := false
for p, c := range f.rowCols[0] {
if c == 1 {
found = true
if math.Abs(f.rowVals[0][p]-7) > 1e-12 {
t.Fatalf("U[0,1] = %g, want 7", f.rowVals[0][p])
}
}
}
if !found {
t.Fatal("U row 0 has no column-1 entry; the seed walk ignored the permutation")
}
// The factor must answer what the dense elimination answers.
x, err := f.Solve(mustFloats(t, []float64{8, 9, 47, 47}))
if err != nil {
t.Fatalf("Solve: %v", err)
}
want := []float64{1, 2, 3, 4}
for i := range 4 {
if math.Abs(x.FloatAt(i)-want[i]) > 1e-12 {
t.Fatalf("solve[%d] = %.15g, want %.15g", i, x.FloatAt(i), want[i])
}
}
}
// TestSparseLUOverflowPivotRefused pins the pivot guard: an
// elimination that squares the range (the update 1e308 − 1·1e308
// reads −2e308, which rounds to −Inf) used to slide past the NaN and
// zero tests and store an infinite pivot without a word.
func TestSparseLUOverflowPivotRefused(t *testing.T) {
coo := cooFrom(t, []int64{0, 0, 0, 1, 1, 0, 1, 1},
[]float64{1e308, 1e308, 1e308, -1e308}, []int{2, 2})
f, err := NewSparseLU(coo)
if err == nil {
t.Fatalf("NewSparseLU stored an infinite pivot (diag = [%g, %g]) with no error", f.diag[0], f.diag[1])
}
if !strings.Contains(err.Error(), "overflow") {
t.Fatalf("error = %v, want an overflow refusal", err)
}
}
// TestSparseCholeskyOverflowPivotRefused pins the same guard on the
// Cholesky side: L[1,0] = 1e100/1e-100 = 1e200 and the pivot update
// squares it to 1e400, which overflows. The matrix is indefinite, but
// the rounded arithmetic cannot honestly deliver a
// positive-definiteness verdict, and the report is the overflow.
func TestSparseCholeskyOverflowPivotRefused(t *testing.T) {
coo := cooFrom(t, []int64{0, 0, 0, 1, 1, 0, 1, 1},
[]float64{1e-200, 1e100, 1e100, 1e200}, []int{2, 2})
_, err := NewSparseCholesky(coo, SparseOrderingNatural)
if err == nil {
t.Fatal("NewSparseCholesky stored an infinite pivot with no error")
}
if !strings.Contains(err.Error(), "overflow") {
t.Fatalf("error = %v, want an overflow refusal", err)
}
}
// TestSparseILURefusesNonFinite pins the intake the complete sparse
// factorisations already have: a NaN or infinite entry is refused
// before the elimination turns it into a poisoned preconditioner.
func TestSparseILURefusesNonFinite(t *testing.T) {
idx := []int64{0, 0, 0, 1, 1, 0, 1, 1, 1, 2, 2, 1, 2, 2}
for name, bad := range map[string]float64{"NaN": math.NaN(), "Inf": math.Inf(1)} {
t.Run(name, func(t *testing.T) {
coo := cooFrom(t, idx, []float64{4, bad, -1, 4, -1, -1, 4}, []int{3, 3})
if _, err := NewSparseILU(coo); err == nil {
t.Fatal("NewSparseILU accepted a non-finite entry")
} else if !strings.Contains(err.Error(), "not finite") {
t.Fatalf("error = %v, want a non-finite refusal", err)
}
})
}
t.Run("finite", func(t *testing.T) {
coo := cooFrom(t, idx, []float64{4, 1, -1, 4, -1, -1, 4}, []int{3, 3})
if _, err := NewSparseILU(coo); err != nil {
t.Fatalf("NewSparseILU refused a finite matrix: %v", err)
}
})
}
// TestMinimumDegreeAbsorptionDropsSelf pins the absorption rule:
// eliminating a vertex merges its adjacency into each surviving
// neighbour as adj(j) ∪ adj(p) \ {j}. The plain union hands j its own
// index back (it sits on p's list), which inflates the degree the
// selection scan reads and warps the order.
func TestMinimumDegreeAbsorptionDropsSelf(t *testing.T) {
// Path 0 - 1 - 2: adj = [[1], [0, 2], [1]].
coo := cooFrom(t, []int64{0, 1, 1, 0, 1, 2, 2, 1, 0, 0, 1, 1, 2, 2},
[]float64{-1, -1, -1, -1, 4, 4, 4}, []int{3, 3})
csc, err := CSCFromCOO(coo)
if err != nil {
t.Fatalf("CSCFromCOO: %v", err)
}
adj, err := symmetrisedAdjacency(csc)
if err != nil {
t.Fatalf("symmetrisedAdjacency: %v", err)
}
eliminated := []bool{true, false, false}
degree := []int{1, 2, 1}
frontier := &degreeFrontier{}
absorbElement(adj, degree, eliminated, 0, frontier, &intArena{})
for _, u := range adj[1] {
if u == 1 {
t.Fatalf("adj[1] = %v lists 1 itself after absorbing 0's element", adj[1])
}
}
if degree[1] != 1 {
t.Fatalf("vertex 1 keeps degree %d after the absorption, want 1", degree[1])
}
deg := 0
for _, u := range adj[1] {
if !eliminated[u] {
deg++
}
}
if deg != 1 {
t.Fatalf("vertex 1 reads degree %d after the absorption, want 1", deg)
}
if !slices.IsSorted(adj[1]) {
t.Fatalf("adj[1] = %v is not the sorted form the union expects", adj[1])
}
// With honest degrees, vertices 1 and 2 tie at degree 1 and the
// tie breaks to the smaller index.
order, err := minimumDegree(csc)
if err != nil {
t.Fatalf("minimumDegree: %v", err)
}
if !slices.Equal(order, []int{0, 1, 2}) {
t.Fatalf("minimum degree order %v, want [0 1 2]", order)
}
}
// TestSVDComplexRankOneRectangular pins the bulge chase's zero step:
// when a deflated diagonal meets a deflated bulge the rotation is the
// identity, where d[k]/0 raised NaNs that spilled through every
// factor. The rank-1 rectangular matrices below reach exactly that
// step.
func TestSVDComplexRankOneRectangular(t *testing.T) {
t.Run("2x3explicit", func(t *testing.T) {
raw := []complex128{1, 2, 3, 2, 4, 6}
a := mustComplex(t, raw, 2, 3)
u, sigma, vh, err := SVDComplex(a)
if err != nil {
t.Fatalf("SVDComplex: %v", err)
}
want := math.Sqrt(70.0)
if rel := math.Abs(sigma.FloatAt(0)-want) / want; rel > 1e-12 {
t.Fatalf("sigma[0] = %.15g, want %.15g (relative error %.3g)", sigma.FloatAt(0), want, rel)
}
if rel := sigma.FloatAt(1) / want; rel > 1e-12 {
t.Fatalf("sigma[1] = %.15g, want 0 (relative %.3g)", sigma.FloatAt(1), rel)
}
checkUnitaryRows(t, u, sigma, vh, raw, 2, 3)
})
for _, shape := range [][2]int{{2, 3}, {2, 4}, {2, 5}, {3, 2}, {3, 4}, {4, 2}, {5, 2}} {
t.Run(fmt.Sprintf("%dx%d", shape[0], shape[1]), func(t *testing.T) {
m, n := shape[0], shape[1]
a := make([]complex128, m*n)
for i := range m {
for j := range n {
ui := complex(float64(i+1), float64(i))
vj := complex(float64(-j), float64(j+1))
a[i*n+j] = ui * vj
}
}
aa, err := core.FromComplexes(a, m, n)
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
u, sigma, vh, err := SVDComplex(aa)
if err != nil {
t.Fatalf("SVDComplex: %v", err)
}
// A rank-1 matrix carries its whole Frobenius norm in the
// leading singular value and nothing in the rest.
fro := 0.0
for _, z := range a {
fro += real(z)*real(z) + imag(z)*imag(z)
}
fro = math.Sqrt(fro)
if rel := math.Abs(sigma.FloatAt(0)-fro) / fro; rel > 1e-12 {
t.Fatalf("sigma[0] = %.15g, want %.15g (relative error %.3g)", sigma.FloatAt(0), fro, rel)
}
if rel := sigma.FloatAt(1) / fro; rel > 1e-12 {
t.Fatalf("sigma[1] = %.15g, want 0 (relative %.3g)", sigma.FloatAt(1), rel)
}
for k := 1; k < sigma.Len(); k++ {
if math.IsNaN(sigma.FloatAt(k)) || math.IsInf(sigma.FloatAt(k), 0) || sigma.FloatAt(k) < 0 {
t.Fatalf("sigma[%d] = %g is not a plain non-negative number", k, sigma.FloatAt(k))
}
}
checkUnitaryRows(t, u, sigma, vh, a, m, n)
})
}
}
// checkUnitaryRows verifies the factors a rank-decomposed SVD must
// return: Uᴴ·U = I on the thin U, orthonormal rows of Vᴴ, and the
// reconstruction U·diag(σ)·Vᴴ back to a.
func checkUnitaryRows(t *testing.T, u, sigma, vh *core.Array, a []complex128, m, n int) {
t.Helper()
r := min(m, n)
uc := u.RawComplexes()
ucols := u.Shape()[1]
urows := u.Shape()[0]
for j := range r {
for i := range r {
s := complex(0, 0)
for k := range urows {
s += cmplxConjTest(uc[k*ucols+i]) * uc[k*ucols+j]
}
want := 0.0
if i == j {
want = 1
}
if d := cmplxAbsTest(s - complex(want, 0)); d > 1e-12 {
t.Fatalf("(Uᴴ·U)[%d,%d] = %v, want %v", i, j, s, want)
}
}
}
vc := vh.RawComplexes()
vcols := vh.Shape()[1]
for j := range r {
for i := range r {
s := complex(0, 0)
for k := range vcols {
s += vc[i*vcols+k] * cmplxConjTest(vc[j*vcols+k])
}
want := 0.0
if i == j {
want = 1
}
if d := cmplxAbsTest(s - complex(want, 0)); d > 1e-12 {
t.Fatalf("(Vᴴ·(Vᴴ)ᴴ)[%d,%d] = %v, want %v", i, j, s, want)
}
}
}
worst := 0.0
for i := range m {
for j := range n {
s := complex(0, 0)
for k := range r {
s += uc[i*ucols+k] * complex(sigma.FloatAt(k), 0) * vc[k*vcols+j]
}
if d := cmplxAbsTest(s - a[i*n+j]); d > worst {
worst = d
}
}
}
if worst > 1e-12*sigma.FloatAt(0) {
t.Fatalf("reconstruction error %g exceeds %g", worst, 1e-12*sigma.FloatAt(0))
}
}
func cmplxConjTest(z complex128) complex128 { return complex(real(z), -imag(z)) }
func cmplxAbsTest(z complex128) float64 { return math.Hypot(real(z), imag(z)) }
// TestMatrixLogSymmetricSmallSpectrum pins the symmetric route's
// negativity floor: a positive eigenvalue is logged whatever its size
// against the spectrum, where the old 1e-10 relative screen refused
// fine positive eigenvalues with a false "non-positive" verdict.
func TestMatrixLogSymmetricSmallSpectrum(t *testing.T) {
tiny := mustFloats(t, []float64{1, 0, 0, 1e-12}, 2, 2)
lg, err := MatrixLog(tiny)
if err != nil {
t.Fatalf("MatrixLog(diag(1, 1e-12)): %v", err)
}
for i, w := range []float64{0, math.Log(1e-12)} {
if math.Abs(lg.FloatAt(i*2+i)-w) > 1e-9 {
t.Fatalf("ln A[%d][%d] = %.16g, want %.16g", i, i, lg.FloatAt(i*2+i), w)
}
}
// The smaller eigenvalue is 1e-16 of the larger, far below any
// relative screen, and still logs honestly.
big := mustFloats(t, []float64{1e20, 0, 0, 1e4}, 2, 2)
lg, err = MatrixLog(big)
if err != nil {
t.Fatalf("MatrixLog(diag(1e20, 1e4)): %v", err)
}
for i, w := range []float64{math.Log(1e20), math.Log(1e4)} {
if rel := math.Abs(lg.FloatAt(i*2+i)-w) / math.Abs(w); rel > 1e-12 {
t.Fatalf("ln A[%d][%d] = %.16g, want %.16g", i, i, lg.FloatAt(i*2+i), w)
}
}
for _, i := range []int{1, 2} {
if math.Abs(lg.FloatAt(i)) > 1e-6 {
t.Fatalf("ln A off-diagonal [%d] = %g, want 0", i, lg.FloatAt(i))
}
}
// A genuinely negative eigenvalue is still refused.
neg := mustFloats(t, []float64{0, 1, 1, 0}, 2, 2)
if _, err := MatrixLog(neg); err == nil {
t.Fatal("MatrixLog([[0,1],[1,0]]): want an error for the negative eigenvalue")
}
// MatrixSqrt keeps its own screen: positive spectra root, negative
// spectra refuse, nothing moved.
sq, err := MatrixSqrt(tiny)
if err != nil {
t.Fatalf("MatrixSqrt(diag(1, 1e-12)): %v", err)
}
for i, w := range []float64{1, 1e-6} {
if math.Abs(sq.FloatAt(i*2+i)-w) > 1e-12 {
t.Fatalf("√A[%d][%d] = %.16g, want %.16g", i, i, sq.FloatAt(i*2+i), w)
}
}
sq, err = MatrixSqrt(big)
if err != nil {
t.Fatalf("MatrixSqrt(diag(1e20, 1e4)): %v", err)
}
for i, w := range []float64{1e10, 100} {
if rel := math.Abs(sq.FloatAt(i*2+i)-w) / w; rel > 1e-12 {
t.Fatalf("√A[%d][%d] = %.16g, want %.16g", i, i, sq.FloatAt(i*2+i), w)
}
}
if _, err := MatrixSqrt(neg); err == nil {
t.Fatal("MatrixSqrt of an indefinite matrix: want an error")
}
}
// TestCholeskyDowndateOverflowRefused pins the downdate's range test:
// with a diagonal at 1e160 the squared terms overflow, and r2 reads
// NaN (Inf − Inf) or +Inf depending on the vector, both of which used
// to slide past the cone test into a silent NaN or infinite factor.
func TestCholeskyDowndateOverflowRefused(t *testing.T) {
l := mustFloats(t, []float64{1e160}, 1, 1)
for name, xv := range map[string]float64{"NaN": 1e160, "Inf": 1} {
t.Run(name, func(t *testing.T) {
out, err := CholeskyDowndate(l, mustFloats(t, []float64{xv}))
if err == nil {
t.Fatalf("downdate returned a factor with diagonal %g and no error", out.FloatAt(0))
}
if !strings.Contains(err.Error(), "overflow") {
t.Fatalf("error = %v, want an overflow refusal", err)
}
})
}
// A rank-one update and a downdate inside the range are untouched.
small := mustFloats(t, []float64{4}, 1, 1)
up, err := CholeskyUpdate(small, mustFloats(t, []float64{1}))
if err != nil {
t.Fatalf("CholeskyUpdate: %v", err)
}
if math.Abs(up.FloatAt(0)-math.Sqrt(17)) > 1e-12 {
t.Fatalf("update diagonal %g, want %.15g", up.FloatAt(0), math.Sqrt(17))
}
down, err := CholeskyDowndate(small, mustFloats(t, []float64{1}))
if err != nil {
t.Fatalf("CholeskyDowndate: %v", err)
}
if math.Abs(down.FloatAt(0)-math.Sqrt(15)) > 1e-12 {
t.Fatalf("downdate diagonal %g, want %.15g", down.FloatAt(0), math.Sqrt(15))
}
}
// TestSparseSymmetryChecksRefuseNonFinite pins the symmetry and
// Hermitian screens: a NaN compares unequal to everything, so the
// mirror test alone waved non-finite entries through as symmetric.
func TestSparseSymmetryChecksRefuseNonFinite(t *testing.T) {
idx := []int64{0, 0, 0, 1, 1, 0, 1, 1}
t.Run("symmetricNaN", func(t *testing.T) {
coo := cooFrom(t, idx, []float64{4, math.NaN(), math.NaN(), 4}, []int{2, 2})
c, err := cooToCSR(coo, "test")
if err != nil {
t.Fatalf("cooToCSR: %v", err)
}
if err := c.checkSymmetric("SpSolve"); err == nil {
t.Fatal("checkSymmetric accepted a NaN entry")
} else if !strings.Contains(err.Error(), "not finite") {
t.Fatalf("error = %v, want a non-finite refusal", err)
}
})
t.Run("symmetricInf", func(t *testing.T) {
coo := cooFrom(t, idx, []float64{4, math.Inf(1), math.Inf(1), 4}, []int{2, 2})
c, err := cooToCSR(coo, "test")
if err != nil {
t.Fatalf("cooToCSR: %v", err)
}
if err := c.checkSymmetric("SpSolve"); err == nil {
t.Fatal("checkSymmetric accepted an infinite entry")
} else if !strings.Contains(err.Error(), "not finite") {
t.Fatalf("error = %v, want a non-finite refusal", err)
}
})
t.Run("hermitianNaN", func(t *testing.T) {
coo := cooComplexFrom(t, idx, []complex128{4, cmplxNaN(), cmplxNaN(), 4}, []int{2, 2})
c, err := cooToComplexCSR(coo, "test")
if err != nil {
t.Fatalf("cooToComplexCSR: %v", err)
}
if err := c.checkHermitian("SpSolveComplexCG"); err == nil {
t.Fatal("checkHermitian accepted a NaN entry")
} else if !strings.Contains(err.Error(), "not finite") {
t.Fatalf("error = %v, want a non-finite refusal", err)
}
})
t.Run("hermitianInf", func(t *testing.T) {
inf := complex(math.Inf(1), 0)
coo := cooComplexFrom(t, idx, []complex128{4, inf, inf, 4}, []int{2, 2})
c, err := cooToComplexCSR(coo, "test")
if err != nil {
t.Fatalf("cooToComplexCSR: %v", err)
}
if err := c.checkHermitian("SpSolveComplexCG"); err == nil {
t.Fatal("checkHermitian accepted an infinite entry")
} else if !strings.Contains(err.Error(), "not finite") {
t.Fatalf("error = %v, want a non-finite refusal", err)
}
})
t.Run("legalInputs", func(t *testing.T) {
coo := cooFrom(t, idx, []float64{4, 1, 1, 4}, []int{2, 2})
c, err := cooToCSR(coo, "test")
if err != nil {
t.Fatalf("cooToCSR: %v", err)
}
if err := c.checkSymmetric("SpSolve"); err != nil {
t.Fatalf("checkSymmetric refused a finite symmetric matrix: %v", err)
}
hcoo := cooComplexFrom(t, idx, []complex128{4, 1 + 2i, 1 - 2i, 4}, []int{2, 2})
hc, err := cooToComplexCSR(hcoo, "test")
if err != nil {
t.Fatalf("cooToComplexCSR: %v", err)
}
if err := hc.checkHermitian("SpSolveComplexCG"); err != nil {
t.Fatalf("checkHermitian refused a finite Hermitian matrix: %v", err)
}
})
t.Run("publicEntries", func(t *testing.T) {
sp := cooFrom(t, idx, []float64{4, math.NaN(), math.NaN(), 4}, []int{2, 2})
if _, err := SpSolve(sp, mustFloats(t, []float64{1, 1}), 0, 0); err == nil {
t.Fatal("SpSolve accepted a NaN entry")
}
hsp := cooComplexFrom(t, idx, []complex128{4, cmplxNaN(), cmplxNaN(), 4}, []int{2, 2})
b := mustComplex(t, []complex128{1, 1}, 2)
if _, err := SpSolveComplexCG(hsp, b, 0, 0); err == nil {
t.Fatal("SpSolveComplexCG accepted a NaN entry")
}
})
}
// cooComplexFrom builds a complex-valued sparse COO, failing the test
// on a bad shape.
func cooComplexFrom(t *testing.T, idx []int64, vals []complex128, shape []int) *core.SparseCOO {
t.Helper()
i, err := core.FromInts(idx, len(idx)/len(shape), len(shape))
if err != nil {
t.Fatalf("FromInts: %v", err)
}
v, err := core.FromComplexes(vals, len(vals))
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
sp, err := core.NewSparseCOO(i, v, shape)
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
return sp
}
func cmplxNaN() complex128 { return complex(math.NaN(), math.NaN()) }