Files
tensor/linalg/nonfinite_refusal_pins_test.go
T

198 lines
5.4 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package linalg
import (
"math"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Regression pins for non-finite and counting guards: the matrix
// exponential refuses a non-finite entry, the minimum-degree ordering
// matches its brute-force reference, the sparse LU non-zero counts
// follow the unit triangle, and the ILU intake refuses overflow.
// TestMatrixExpRejectsNonFinite pins the loud refusal for a
// non-finite entry, which the theta ladder used to read through an
// implementation-defined conversion into an all-NaN answer.
func TestMatrixExpRejectsNonFinite(t *testing.T) {
a := mustF(t, []float64{math.Inf(1), 0, 0, 1}, 2, 2)
if _, err := MatrixExp(a); err == nil {
t.Fatal("expected an error for an Inf entry")
}
b := mustF(t, []float64{math.NaN(), 0, 0, 1}, 2, 2)
if _, err := MatrixExp(b); err == nil {
t.Fatal("expected an error for a NaN entry")
}
c, _ := core.FromComplexes([]complex128{complex(math.Inf(1), 0), 0, 0, 1}, 2, 2)
if _, err := MatrixExp(c); err == nil {
t.Fatal("expected an error for an Inf complex entry")
}
}
// TestMinimumDegreeMixedPattern pins the ordering against a
// brute-force minimum-degree reference on a mixed-degree pattern,
// where the degree-sorted adjacency lists and the index-sorted set
// union used to disagree and corrupt the elimination. The sample is
// the measured failing case: the pre-fix code eliminated vertex 10
// before 1 and swapped the tail of the order.
func TestMinimumDegreeMixedPattern(t *testing.T) {
rows := []int{0, 0, 0, 0, 1, 1, 1, 1, 2, 4, 5, 5, 6, 6, 7}
cols := []int{1, 2, 5, 8, 3, 5, 6, 10, 6, 6, 9, 10, 7, 10, 10}
const n = 11
coo := edgesCOO(t, rows, cols, n)
csc, err := CSCFromCOO(coo)
if err != nil {
t.Fatalf("CSCFromCOO: %v", err)
}
got, err := minimumDegree(csc)
if err != nil {
t.Fatalf("minimumDegree: %v", err)
}
// Brute-force reference: repeatedly eliminate the uneliminated
// vertex with the fewest uneliminated neighbours (ties to the
// smaller index), unioning neighbourhoods exactly.
adj := map[int]map[int]bool{}
addEdge := func(i, j int) {
if adj[i] == nil {
adj[i] = map[int]bool{}
}
if adj[j] == nil {
adj[j] = map[int]bool{}
}
adj[i][j], adj[j][i] = true, true
}
for e := range rows {
addEdge(rows[e], cols[e])
}
eliminated := map[int]bool{}
var want []int
for range n {
best, bestDeg := -1, math.MaxInt
for v := range n {
if eliminated[v] {
continue
}
d := 0
for u := range adj[v] {
if !eliminated[u] {
d++
}
}
if d < bestDeg {
best, bestDeg = v, d
}
}
want = append(want, best)
eliminated[best] = true
nb := map[int]bool{}
for u := range adj[best] {
if !eliminated[u] {
nb[u] = true
}
}
for u := range nb {
for w := range nb {
if u != w {
adj[u][w] = true
}
}
delete(adj[u], best)
}
}
for i := range n {
if got[i] != want[i] {
t.Fatalf("order[%d] = %d, want %d (full %v vs %v)", i, got[i], want[i], got, want)
}
}
}
// TestSparseLUNNZCountsUTriangle pins that NNZ includes U's
// strict triangle, which the column walk used to miss.
func TestSparseLUNNZCountsUTriangle(t *testing.T) {
// A tridiagonal matrix: L holds the subdiagonal, U the diagonal and
// the superdiagonal, so the factor stores exactly 3n - 2 entries.
const n = 8
idx := make([]int64, 0, 6*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, 2)
if i+1 < n {
add(i, i+1, -1)
add(i+1, i, -1)
}
}
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)
}
f, err := NewSparseLU(coo)
if err != nil {
t.Fatalf("NewSparseLU: %v", err)
}
if want := 3*n - 2; f.NNZ() != want {
t.Fatalf("NNZ = %d, want %d (L strict + U strict + diagonal)", f.NNZ(), want)
}
}
// TestSparseILUOverflowRefused pins the overflow refusal on
// finite input, mirroring the LU and Cholesky guards.
func TestSparseILUOverflowRefused(t *testing.T) {
idx := make([]int64, 0, 6)
vals := make([]float64, 0, 3)
add := func(r, c int, v float64) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, v)
}
add(0, 0, 1e-200)
add(0, 1, 1e100)
add(1, 0, 1e100)
add(1, 1, 1e200)
indices, err := core.FromInts(idx, 4, 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
coo, err := core.NewSparseCOO(indices, floatsToArray(vals, []int{4}), []int{2, 2})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
if _, err := NewSparseILU(coo); err == nil {
t.Fatal("expected an overflow error from the elimination")
}
}
// edgesCOO builds a symmetric-pattern COO from edge lists.
func edgesCOO(t *testing.T, rows, cols []int, n int) *core.SparseCOO {
t.Helper()
idx := make([]int64, 0, 2*len(rows))
vals := make([]float64, 0, 2*len(rows))
add := func(r, c int) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, 1)
}
for e := range rows {
add(rows[e], cols[e])
add(cols[e], rows[e])
}
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)
}
return coo
}