feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+375
View File
@@ -0,0 +1,375 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package linalg
import (
"math"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"testing"
)
// spdTridiagonal builds a symmetric diagonally dominant tridiagonal
// matrix, which is positive-definite by the Gershgorin bound, so the
// conjugate gradient is guaranteed to apply. The diagonal is kept
// above the off-diagonal sum in absolute value.
func spdTridiagonal(n int, diag, off float64) []float64 {
vals := make([]float64, n*n)
for i := range n {
vals[i*n+i] = diag
if i+1 < n {
vals[i*n+i+1] = off
vals[(i+1)*n+i] = off
}
}
return vals
}
// denseSolve solves A·x = b through the dense LU path, the reference
// the sparse solver is compared against.
func denseSolve(t *testing.T, vals []float64, n int, rhs []float64) []float64 {
t.Helper()
a, err := core.FromFloats(vals, n, n)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
b, err := core.FromFloats(rhs, n)
if err != nil {
t.Fatalf("FromFloats rhs: %v", err)
}
x, err := Solve(a, b)
if err != nil {
t.Fatalf("Solve: %v", err)
}
out := make([]float64, n)
for i := range n {
out[i] = x.FloatAt(i)
}
return out
}
// residualNorm norms ‖b − A·x‖₂, the direct check that a returned x
// solves the system rather than merely being returned.
func residualNorm(t *testing.T, vals []float64, x, rhs []float64, n int) float64 {
t.Helper()
sum := 0.0
for i := range n {
ax := 0.0
for j := range n {
ax += vals[i*n+j] * x[j]
}
d := rhs[i] - ax
sum += d * d
}
return math.Sqrt(sum)
}
// TestSpSolveMatchesDense compares the sparse solver against the dense
// `Solve` on symmetric positive-definite systems, which is the
// contract the two share.
func TestSpSolveMatchesDense(t *testing.T) {
cases := []struct {
name string
n int
diag float64
off float64
}{
{name: "identity_like", n: 3, diag: 4, off: 0},
{name: "weakly_coupled", n: 5, diag: 4, off: -1},
{name: "strongly_coupled", n: 8, diag: 10, off: 3},
{name: "larger_system", n: 40, diag: 5, off: 1.5},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
vals := spdTridiagonal(tt.n, tt.diag, tt.off)
sp := sparseFromDense(t, vals, tt.n)
rhs := make([]float64, tt.n)
for i := range tt.n {
rhs[i] = float64(i+1) / float64(tt.n)
}
b, err := core.FromFloats(rhs, tt.n)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
got, err := SpSolve(sp, b, 0, 0)
if err != nil {
t.Fatalf("SpSolve: %v", err)
}
want := denseSolve(t, vals, tt.n, rhs)
x := make([]float64, tt.n)
for i := range tt.n {
x[i] = got.FloatAt(i)
}
for i := range tt.n {
if math.Abs(x[i]-want[i]) > 1e-8*(1+math.Abs(want[i])) {
t.Fatalf("x[%d] = %.12g, want %.12g", i, x[i], want[i])
}
}
if r := residualNorm(t, vals, x, rhs, tt.n); r > 1e-8 {
t.Fatalf("residual ‖b-Ax‖ = %.3g, want <= 1e-8", r)
}
})
}
}
// TestSpSolveNonDiagonalMatrix exercises a symmetric
// positive-definite matrix that is not tridiagonal, so the sparse
// structure carries a genuinely two-dimensional sparsity pattern.
func TestSpSolveNonDiagonalMatrix(t *testing.T) {
const n = 6
// A = L·Lᵀ for a lower-triangular L with a positive diagonal,
// which is symmetric positive-definite by construction.
l := []float64{
2, 0, 0, 0, 0, 0,
1, 3, 0, 0, 0, 0,
0, 1, 2, 0, 0, 0,
1, 0, 1, 4, 0, 0,
0, 0, 0, 1, 2, 0,
0, 1, 0, 0, 1, 3,
}
vals := make([]float64, n*n)
for i := range n {
for j := range n {
s := 0.0
for k := range n {
if k <= i && k <= j {
s += l[i*n+k] * l[j*n+k]
}
}
vals[i*n+j] = s
}
}
sp := sparseFromDense(t, vals, n)
rhs := []float64{1, 2, 3, 4, 5, 6}
b, err := core.FromFloats(rhs, n)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
got, err := SpSolve(sp, b, 0, 0)
if err != nil {
t.Fatalf("SpSolve: %v", err)
}
x := make([]float64, n)
for i := range n {
x[i] = got.FloatAt(i)
}
want := denseSolve(t, vals, n, rhs)
for i := range n {
if math.Abs(x[i]-want[i]) > 1e-8*(1+math.Abs(want[i])) {
t.Fatalf("x[%d] = %.12g, want %.12g", i, x[i], want[i])
}
}
}
// TestSpSolveZeroRightHandSide pins the degenerate contract: the
// solution of A·x = 0 is the zero vector, returned without dividing
// by a zero residual norm.
func TestSpSolveZeroRightHandSide(t *testing.T) {
const n = 4
vals := spdTridiagonal(n, 4, -1)
sp := sparseFromDense(t, vals, n)
b, err := core.Zeros(core.Float, n)
if err != nil {
t.Fatalf("Zeros: %v", err)
}
got, err := SpSolve(sp, b, 0, 0)
if err != nil {
t.Fatalf("SpSolve: %v", err)
}
for i := range n {
if got.FloatAt(i) != 0 {
t.Fatalf("x[%d] = %.12g, want 0 for a zero right-hand side", i, got.FloatAt(i))
}
}
}
// TestSpSolveDeterminism checks that the solve is reproducible: the
// iteration starts from a fixed zero guess and draws nothing random.
func TestSpSolveDeterminism(t *testing.T) {
const n = 12
vals := spdTridiagonal(n, 6, -1)
sp := sparseFromDense(t, vals, n)
rhs := make([]float64, n)
for i := range n {
rhs[i] = math.Sin(float64(i + 1))
}
b, err := core.FromFloats(rhs, n)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
x1, err := SpSolve(sp, b, 0, 0)
if err != nil {
t.Fatalf("SpSolve #1: %v", err)
}
x2, err := SpSolve(sp, b, 0, 0)
if err != nil {
t.Fatalf("SpSolve #2: %v", err)
}
for i := range n {
if x1.FloatAt(i) != x2.FloatAt(i) {
t.Fatalf("x[%d] = %.12g vs %.12g across runs", i, x1.FloatAt(i), x2.FloatAt(i))
}
}
}
// TestSpSolveTolerance drives the stopping rule explicitly: a loose
// tolerance stops early with a larger residual, a tight one converges
// further, and both are reported honestly.
func TestSpSolveTolerance(t *testing.T) {
const n = 60
vals := spdTridiagonal(n, 4, -1)
sp := sparseFromDense(t, vals, n)
rhs := make([]float64, n)
for i := range n {
rhs[i] = 1
}
b, err := core.FromFloats(rhs, n)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
loose, err := SpSolve(sp, b, 1e-2, 0)
if err != nil {
t.Fatalf("SpSolve loose: %v", err)
}
tight, err := SpSolve(sp, b, 1e-12, 0)
if err != nil {
t.Fatalf("SpSolve tight: %v", err)
}
xl := make([]float64, n)
xt := make([]float64, n)
for i := range n {
xl[i] = loose.FloatAt(i)
xt[i] = tight.FloatAt(i)
}
rl := residualNorm(t, vals, xl, rhs, n)
rt := residualNorm(t, vals, xt, rhs, n)
if rl < rt {
t.Fatalf("loose tolerance gave residual %.3g, tighter than the tight run's %.3g", rl, rt)
}
if rt > 1e-9 {
t.Fatalf("tight tolerance gave residual %.3g, want <= 1e-9", rt)
}
}
// TestSpSolveNoConvergence pins the unconverged contract: the budget
// runs out and the solve reports the residual instead of returning a
// silent approximation.
func TestSpSolveNoConvergence(t *testing.T) {
const n = 200
vals := spdTridiagonal(n, 2, -0.999)
sp := sparseFromDense(t, vals, n)
rhs := make([]float64, n)
for i := range n {
rhs[i] = 1
}
b, err := core.FromFloats(rhs, n)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
// A nearly singular system needs far more than two steps.
if _, err := SpSolve(sp, b, 1e-14, 2); err == nil {
t.Fatal("expected an error when the iteration budget is exhausted")
}
}
// TestSpSolveRejectsInvalid pins the error contract for every input
// the solver cannot honestly answer.
func TestSpSolveRejectsInvalid(t *testing.T) {
t.Run("complex_sparse", func(t *testing.T) {
idx, err := core.FromInts([]int64{0, 0}, 1, 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
vals, err := core.FromComplexes([]complex128{1}, 1)
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
sp, err := core.NewSparseCOO(idx, vals, []int{1, 1})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
b, _ := core.FromFloats([]float64{1}, 1)
if _, err := SpSolve(sp, b, 0, 0); err == nil {
t.Fatal("expected an error for a complex sparse matrix")
}
})
t.Run("not_square", func(t *testing.T) {
idx, err := core.FromInts([]int64{0, 0}, 1, 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
vals, err := core.FromFloats([]float64{1}, 1)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
sp, err := core.NewSparseCOO(idx, vals, []int{1, 2})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
b, _ := core.FromFloats([]float64{1}, 1)
if _, err := SpSolve(sp, b, 0, 0); err == nil {
t.Fatal("expected an error for a non-square matrix")
}
})
t.Run("zero_sized", func(t *testing.T) {
idx, _ := core.FromInts(nil, 0, 2)
vals, _ := core.FromFloats(nil, 0)
sp := &core.SparseCOO{Indices: idx, Values: vals, Shape: []int{0, 0}}
b, _ := core.FromFloats(nil, 0)
if _, err := SpSolve(sp, b, 0, 0); err == nil {
t.Fatal("expected an error for a zero-sized matrix")
}
})
t.Run("rhs_wrong_length", func(t *testing.T) {
vals := spdTridiagonal(3, 4, -1)
sp := sparseFromDense(t, vals, 3)
b, _ := core.FromFloats([]float64{1, 2}, 2)
if _, err := SpSolve(sp, b, 0, 0); err == nil {
t.Fatal("expected an error for a right-hand side of the wrong length")
}
})
t.Run("rhs_not_vector", func(t *testing.T) {
vals := spdTridiagonal(2, 4, -1)
sp := sparseFromDense(t, vals, 2)
b, _ := core.FromFloats([]float64{1, 2, 3, 4}, 2, 2)
if _, err := SpSolve(sp, b, 0, 0); err == nil {
t.Fatal("expected an error for a rank-2 right-hand side")
}
})
t.Run("complex_rhs", func(t *testing.T) {
vals := spdTridiagonal(2, 4, -1)
sp := sparseFromDense(t, vals, 2)
b, _ := core.FromComplexes([]complex128{1, 1}, 2)
if _, err := SpSolve(sp, b, 0, 0); err == nil {
t.Fatal("expected an error for a complex right-hand side")
}
})
t.Run("zero_diagonal", func(t *testing.T) {
// Symmetric but singular, and the Jacobi preconditioner
// cannot divide by a zero diagonal.
sp := sparseFromDense(t, []float64{0, 1, 1, 0}, 2)
b, _ := core.FromFloats([]float64{1, 1}, 2)
if _, err := SpSolve(sp, b, 0, 0); err == nil {
t.Fatal("expected an error for a zero diagonal entry")
}
})
t.Run("asymmetric", func(t *testing.T) {
sp := sparseFromDense(t, []float64{4, 1, 2, 4}, 2)
b, _ := core.FromFloats([]float64{1, 1}, 2)
if _, err := SpSolve(sp, b, 0, 0); err == nil {
t.Fatal("expected an error for an asymmetric matrix")
}
})
t.Run("indefinite", func(t *testing.T) {
// Symmetric with eigenvalues 3 and -1, so not
// positive-definite. The right-hand side must excite the
// negative eigendirection [1,-1]: with b = [1,1] the system
// still has the exact solution [1/3,1/3] and the curvature
// stays positive, so the matrix would not be caught.
sp := sparseFromDense(t, []float64{1, 2, 2, 1}, 2)
b, _ := core.FromFloats([]float64{1, -1}, 2)
if _, err := SpSolve(sp, b, 0, 0); err == nil {
t.Fatal("expected an error for an indefinite matrix")
}
})
}