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
+195
View File
@@ -0,0 +1,195 @@
// 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"
)
// TestSpSolveBiCGSTABNonsymmetric checks the solver against the dense
// Solve on a 4×4 system with genuinely asymmetric entries.
func TestSpSolveBiCGSTABNonsymmetric(t *testing.T) {
dense := [][]float64{
{10, 1, 0, 2},
{-1, 12, 3, 0},
{0, -2, 15, 1},
{2, 0, -1, 8},
}
entries := make([][3]float64, 0, 16)
for i := range 4 {
for j := range 4 {
if dense[i][j] != 0 {
entries = append(entries, [3]float64{float64(i), float64(j), dense[i][j]})
}
}
}
idx := make([]int64, 0, len(entries))
vals := make([]float64, 0, len(entries))
for _, e := range entries {
idx = append(idx, int64(e[0]), int64(e[1]))
vals = append(vals, e[2])
}
indices, err := core.FromInts(idx, len(entries), 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
coo, err := core.NewSparseCOO(indices, floatsToArray(vals, []int{len(entries)}), []int{4, 4})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
b := mustFloats(t, []float64{1, 2, 3, 4})
x, err := SpSolveBiCGSTAB(coo, b, 1e-12, 0)
if err != nil {
t.Fatalf("SpSolveBiCGSTAB: %v", err)
}
ref, err := Solve(mustFloats(t, []float64{
10, 1, 0, 2,
-1, 12, 3, 0,
0, -2, 15, 1,
2, 0, -1, 8,
}, 4, 4), b)
if err != nil {
t.Fatalf("Solve: %v", err)
}
for i := range 4 {
if math.Abs(x.FloatAt(i)-ref.FloatAt(i)) > 1e-9 {
t.Fatalf("x[%d] = %.14g, want %.14g", i, x.FloatAt(i), ref.FloatAt(i))
}
}
}
// TestSpSolveBiCGSTABConvective builds a 50×50 shifted Laplacian with
// an asymmetric convection term, the shape transport discretisations
// produce, and checks the residual the solver promises.
func TestSpSolveBiCGSTABConvective(t *testing.T) {
const n = 50
idx := make([]int64, 0, 4*n)
vals := make([]float64, 0, 4*n)
add := func(r, c int, v float64) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, v)
}
for i := range n {
add(i, i, 4)
if i+1 < n {
add(i, i+1, -1)
add(i+1, i, -1+0.5) // asymmetric neighbour coupling
}
}
indices, err := core.FromInts(idx, len(vals), 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
coo, err := core.NewSparseCOO(indices, floatsToArray(vals, []int{len(vals)}), []int{n, n})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
bv := make([]float64, n)
for i := range n {
bv[i] = float64(i+1) / float64(n+1)
}
b := mustFloats(t, bv)
x, err := SpSolveBiCGSTAB(coo, b, 1e-10, 0)
if err != nil {
t.Fatalf("SpSolveBiCGSTAB: %v", err)
}
// Residual check against the sparse product path.
ax, err := cooToCSRForTest(t, coo).MatVec(x)
if err != nil {
t.Fatalf("MatVec: %v", err)
}
res := 0.0
for i := range n {
d := b.FloatAt(i) - ax.FloatAt(i)
res += d * d
}
if math.Sqrt(res) > 1e-9*math.Sqrt(dotF64(bv, bv)) {
t.Fatalf("relative residual %g exceeds 1e-9", math.Sqrt(res)/math.Sqrt(dotF64(bv, bv)))
}
}
// cooToCSRForTest converts a COO to the public CSR for residual checks.
func cooToCSRForTest(t *testing.T, coo *core.SparseCOO) *SparseCSR {
t.Helper()
csr, err := CSRFromCOO(coo)
if err != nil {
t.Fatalf("CSRFromCOO: %v", err)
}
return csr
}
// TestSpSolveBiCGSTABConvergenceFailure reports an honest failure when
// the budget cannot reach the tolerance.
func TestSpSolveBiCGSTABConvergenceFailure(t *testing.T) {
const n = 30
idx := make([]int64, 0, 3*n)
vals := make([]float64, 0, 3*n)
add := func(r, c int, v float64) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, v)
}
for i := range n {
add(i, i, 4)
if i+1 < n {
add(i, i+1, -1)
add(i+1, i, -0.5)
}
}
indices, _ := core.FromInts(idx, len(vals), 2)
coo, err := core.NewSparseCOO(indices, floatsToArray(vals, []int{len(vals)}), []int{n, n})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
b := mustFloats(t, make([]float64, n))
for i := range n {
b.RawFloats()[i] = 1
}
if _, err := SpSolveBiCGSTAB(coo, b, 1e-14, 2); err == nil {
t.Fatal("tight tolerance with a two-step budget: want an error")
}
}
// TestSpSolveBiCGSTABErrors pins the validation surface.
func TestSpSolveBiCGSTABErrors(t *testing.T) {
// Build the COO inputs directly for the error paths.
mkCOO := func(t *testing.T, rows, cols int, entries [][3]float64) *core.SparseCOO {
t.Helper()
idx := make([]int64, 0, len(entries)*2)
vals := make([]float64, 0, len(entries))
for _, e := range entries {
idx = append(idx, int64(e[0]), int64(e[1]))
vals = append(vals, e[2])
}
indices, err := core.FromInts(idx, len(entries), 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
coo, err := core.NewSparseCOO(indices, floatsToArray(vals, []int{len(entries)}), []int{rows, cols})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
return coo
}
b2 := mustFloats(t, []float64{1, 1})
if _, err := SpSolveBiCGSTAB(mkCOO(t, 2, 3, [][3]float64{{0, 0, 2}}), b2, 0, 0); err == nil {
t.Fatal("non-square matrix: want an error")
}
// A missing diagonal entry defeats the Jacobi preconditioner.
gap := mkCOO(t, 2, 2, [][3]float64{{0, 1, 1}, {1, 1, 3}})
if _, err := SpSolveBiCGSTAB(gap, b2, 0, 0); err == nil {
t.Fatal("missing diagonal: want an error")
}
bad := mkCOO(t, 2, 2, [][3]float64{{0, 0, 2}, {1, 1, 3}})
if _, err := SpSolveBiCGSTAB(bad, mustFloats(t, []float64{1, 2, 3}), 0, 0); err == nil {
t.Fatal("wrong right-hand side length: want an error")
}
complexVals := core.New(core.Complex, 2)
cx := mkCOO(t, 2, 2, [][3]float64{{0, 0, 1}, {1, 1, 1}})
cx.Values = complexVals
if _, err := SpSolveBiCGSTAB(cx, b2, 0, 0); err == nil {
t.Fatal("complex values: want an error")
}
}