136 lines
4.3 KiB
Go
136 lines
4.3 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package linalg
|
|
|
|
import (
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
|
)
|
|
|
|
// Regression pins: NaN states that read as converged solves, and
|
|
// contract gaps the sibling solvers had already closed.
|
|
|
|
func cooFrom(t *testing.T, idx []int64, vals []float64, 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.FromFloats(vals, len(vals))
|
|
if err != nil {
|
|
t.Fatalf("FromFloats: %v", err)
|
|
}
|
|
sp, err := core.NewSparseCOO(i, v, shape)
|
|
if err != nil {
|
|
t.Fatalf("NewSparseCOO: %v", err)
|
|
}
|
|
return sp
|
|
}
|
|
|
|
// TestSpSolveOverflowCurvature: with every input finite, the Jacobi
|
|
// preconditioner overflowed the curvature to +Inf, Inf/Inf gave a NaN
|
|
// alpha, and the all-NaN residual read as a converged solve through
|
|
// the NaN-skipping norm.
|
|
func TestSpSolveOverflowCurvature(t *testing.T) {
|
|
sp := cooFrom(t, []int64{0, 0, 1, 1}, []float64{1e-155, 1e-155}, []int{2, 2})
|
|
b, err := core.FromFloats([]float64{1e155, 1e155}, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
x, err := SpSolve(sp, b, 0, 0)
|
|
if err == nil {
|
|
t.Fatalf("an overflowed solve returned %v with no error", x.FloatAt(0))
|
|
}
|
|
if x != nil {
|
|
t.Fatalf("SpSolve returned a result beside the error")
|
|
}
|
|
}
|
|
|
|
// TestSpSolveBiCGSTABOverflowOmega: a tiny diagonal entry overflowed
|
|
// the preconditioned stabiliser to Inf and its dots to NaN/Inf, omega
|
|
// became NaN, and the finiteness guard sat behind the convergence
|
|
// return it was written to protect.
|
|
func TestSpSolveBiCGSTABOverflowOmega(t *testing.T) {
|
|
// A = [[1e-155, 1], [1, 1]]: the Jacobi preconditioner divides the
|
|
// first residual by 1e-155 and the stabiliser leg overflows.
|
|
sp := cooFrom(t, []int64{0, 0, 0, 1, 1, 0, 1, 1},
|
|
[]float64{1e-155, 1, 1, 1}, []int{2, 2})
|
|
b, err := core.FromFloats([]float64{1, 0}, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := SpSolveBiCGSTAB(sp, b, 0, 0); err == nil {
|
|
t.Fatal("an overflowed stabiliser returned a solution with no error")
|
|
}
|
|
}
|
|
|
|
// TestGMRESRejectsRank2: every sibling entry point refuses a non-vector
|
|
// right-hand side; GMRES flattened it silently.
|
|
func TestGMRESRejectsRank2(t *testing.T) {
|
|
b, err := core.FromFloats([]float64{1, 2, 3, 4}, 2, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ident := func(v *core.Array) (*core.Array, error) { return core.Copy(v), nil }
|
|
if _, err := GMRES(ident, b, 0, 0, 0); err == nil || !strings.Contains(err.Error(), "rank-1") {
|
|
t.Fatalf("GMRES on a rank-2 b: err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestGMRESRejectsNonFiniteOp: an operator answering NaN must be an
|
|
// error, not a zero-norm residual that reads as convergence.
|
|
func TestGMRESRejectsNonFiniteOp(t *testing.T) {
|
|
b, err := core.FromFloats([]float64{1}, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nan := math.NaN()
|
|
broken := func(v *core.Array) (*core.Array, error) { return core.FromFloats([]float64{nan}, 1) }
|
|
if _, err := GMRES(broken, b, 0, 0, 0); err == nil || !strings.Contains(err.Error(), "non-finite") {
|
|
t.Fatalf("GMRES with a NaN op: err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestGMRESTinyOperator: the breakdown floor was relative to ‖b‖, so a
|
|
// legitimate system with ‖A‖ ≪ ‖b‖ collapsed every cycle at its first
|
|
// column and ran out of cycles without converging.
|
|
func TestGMRESTinyOperator(t *testing.T) {
|
|
b, err := core.FromFloats([]float64{1, 1}, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
scale := 1e-20
|
|
tiny := func(v *core.Array) (*core.Array, error) {
|
|
x := v.FloatAt(0) * scale
|
|
y := v.FloatAt(1) * scale
|
|
return core.FromFloats([]float64{x, y}, 2)
|
|
}
|
|
x, err := GMRES(tiny, b, 0, 0, 1e-8)
|
|
if err != nil {
|
|
t.Fatalf("GMRES on a tiny-norm operator: %v", err)
|
|
}
|
|
if math.Abs(x.FloatAt(0)-1/scale) > 1e-3/scale {
|
|
t.Fatalf("x[0] = %g, want %g", x.FloatAt(0), 1/scale)
|
|
}
|
|
}
|
|
|
|
// TestNewCubicSplineRejectsRank2: a matrix input was silently
|
|
// reinterpreted as a flattened vector.
|
|
func TestNewCubicSplineRejectsRank2(t *testing.T) {
|
|
xs, err := core.FromFloats([]float64{1, 2, 3, 4, 5, 6}, 2, 3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ys, err := core.FromFloats([]float64{1, 4, 9, 16, 25, 36}, 2, 3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := NewCubicSpline(xs, ys); err == nil || !strings.Contains(err.Error(), "1-D") {
|
|
t.Fatalf("NewCubicSpline on rank-2 inputs: err = %v", err)
|
|
}
|
|
}
|