fix(linalg): refuse non-finite input in dense Cholesky, Eigen and rank-one updates
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -5,6 +5,17 @@ All notable changes to **Tensor** are documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [development]
|
||||
|
||||
### Fixed
|
||||
|
||||
**Linear algebra.**
|
||||
|
||||
- `Cholesky`, `Eigen`, `EigenComplex` and the Cholesky rank-one
|
||||
update refuse a matrix or vector holding a NaN or infinity instead
|
||||
of returning an all-NaN factor or spectrum with a nil error, the
|
||||
refusal the sparse solvers already make.
|
||||
|
||||
## [1.0.0] - 2026-09-03
|
||||
|
||||
The initial release of Tensor, a scientific computing library in pure
|
||||
|
||||
+4
-4
@@ -629,7 +629,7 @@ neighbours are the way in.
|
||||
| `Inv(a)` | returns the inverse of a square matrix, through the same LU kernel. |
|
||||
| `Det(a)` | returns the determinant of a square real matrix; a singular matrix gives 0, not an error. |
|
||||
| `DetComplex(a)` | the same for a square complex matrix, as a `complex128`. |
|
||||
| `Cholesky(a)` | returns the lower factor L with a = L·Lᵀ for a symmetric positive definite a; a non-positive pivot is refused. |
|
||||
| `Cholesky(a)` | returns the lower factor L with a = L·Lᵀ for a symmetric positive definite a; a non-positive pivot and a non-finite entry are refused. |
|
||||
| `CholeskyUpdate(l, x)` | returns the lower factor of A + x·xᵀ from the factor of A, by orthogonal rotations. |
|
||||
| `CholeskyDowndate(l, x)` | returns the lower factor of A − x·xᵀ, by hyperbolic rotations; leaving the positive definite cone is an error. |
|
||||
| `QR(a)` | returns q (m×m orthogonal) and r (m×n upper triangular) with a = q·r, for m ≥ n. |
|
||||
@@ -641,8 +641,8 @@ neighbours are the way in.
|
||||
|
||||
| Call | What it does |
|
||||
|---|---|
|
||||
| `Eigen(a)` | returns the eigenvalues of a real symmetric matrix, ascending, and the orthonormal eigenvectors as columns; asymmetry beyond a scale-relative 1e-12 is refused. |
|
||||
| `EigenComplex(a)` | the same for a complex Hermitian matrix; values are real, vectors complex, and the same 1e-12 Hermitian tolerance applies. |
|
||||
| `Eigen(a)` | returns the eigenvalues of a real symmetric matrix, ascending, and the orthonormal eigenvectors as columns; asymmetry beyond a scale-relative 1e-12 and a non-finite entry are refused. |
|
||||
| `EigenComplex(a)` | the same for a complex Hermitian matrix; values are real, vectors complex, the same 1e-12 Hermitian tolerance applies, and a non-finite entry is refused. |
|
||||
| `EigenGeneral(a)` | the eigenvalues and eigenvectors of a square matrix of any dtype; real and int inputs promote to `complex128`, values descend by magnitude, and a real matrix may carry conjugate pairs. |
|
||||
| `EigenGeneralised(a, b)` | solves A·v = λ·B·v for symmetric a and symmetric positive definite b, through the Cholesky factor of b; values ascend, eigenvectors are B-orthonormal columns. |
|
||||
| `SVD(a)` | the thin decomposition a = U·Σ·Vᵀ for m ≥ n: U is m×n with orthonormal columns, Σ a length-n vector descending, Vᵀ n×n orthogonal; a wide matrix is transposed first and the factors are swapped back. |
|
||||
@@ -898,7 +898,7 @@ The conditions this package reports:
|
||||
- a modification that needs fill the factor does not hold:
|
||||
`SparseCholesky.Update` and `Downdate` refuse it and leave the
|
||||
factor as it was, and `CholeskyUpdate` refuses a rank-1 factor, a
|
||||
mismatched vector or a complex input.
|
||||
mismatched vector, a complex input or a non-finite entry.
|
||||
- a complex or wrong-length right-hand side to a sparse solve, and a
|
||||
preconditioner built for another dimension.
|
||||
- non-finite input to `NewSparseLU`, `NewSparseILU` and the sparse
|
||||
|
||||
+14
-3
@@ -71,14 +71,25 @@ func cholRankOne(l, x *core.Array, name string, sign int) (*core.Array, error) {
|
||||
}
|
||||
// The rotations below assume a lower triangular factor; a nonzero
|
||||
// strict upper triangle would silently corrupt the sweep, so it is
|
||||
// refused up front.
|
||||
// refused up front, and so is a non-finite entry anywhere: the
|
||||
// diagonal test cannot see a NaN, and the hyperbolic square of an
|
||||
// Inf overflows mid-sweep. The sparse twin refuses the same input.
|
||||
for i := range n {
|
||||
for j := i + 1; j < n; j++ {
|
||||
if out.RawFloats()[i*n+j] != 0 {
|
||||
for j := range n {
|
||||
w := out.RawFloats()[i*n+j]
|
||||
if math.IsNaN(w) || math.IsInf(w, 0) {
|
||||
return nil, base.Errf("%s: factor entry [%d,%d] is not finite", name, i, j)
|
||||
}
|
||||
if j > i && w != 0 {
|
||||
return nil, base.Errf("%s: the factor must be lower triangular (nonzero entry at row %d, column %d)", name, i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range n {
|
||||
if math.IsNaN(v[i]) || math.IsInf(v[i], 0) {
|
||||
return nil, base.Errf("%s: entry %d is not finite", name, i)
|
||||
}
|
||||
}
|
||||
// The modified matrix reads [L, x]·J·[L, x]ᵀ with J = diag(I, ±1):
|
||||
// one sweep of (hyperbolic for −1, orthogonal for +1) rotations
|
||||
// eliminates the vector column, and the surviving columns are the
|
||||
|
||||
@@ -5,8 +5,10 @@ package linalg
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// spdSample builds a deterministic symmetric positive definite matrix:
|
||||
@@ -156,3 +158,25 @@ func TestCholeskyRankOneErrors(t *testing.T) {
|
||||
t.Fatal("expected an error for a complex vector")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCholeskyRankOneRefusesNonFinite pins the refusal of a poisoned
|
||||
// factor or vector: the diagonal test cannot see a NaN, so the sweep
|
||||
// would answer an all-NaN factor with a nil error, where the sparse
|
||||
// twin refuses the same modification.
|
||||
func TestCholeskyRankOneRefusesNonFinite(t *testing.T) {
|
||||
l, err := Cholesky(mustFromFloats(t, []float64{4, 0, 0, 1}, 2, 2))
|
||||
if err != nil {
|
||||
t.Fatalf("Cholesky: %v", err)
|
||||
}
|
||||
badVec := floatsToArray([]float64{math.NaN(), 0.1}, []int{2})
|
||||
if _, err := CholeskyUpdate(l, badVec); err == nil || !strings.Contains(err.Error(), "not finite") {
|
||||
t.Fatalf("CholeskyUpdate(NaN vector): %v", err)
|
||||
}
|
||||
if _, err := CholeskyDowndate(l, badVec); err == nil || !strings.Contains(err.Error(), "not finite") {
|
||||
t.Fatalf("CholeskyDowndate(NaN vector): %v", err)
|
||||
}
|
||||
badFac := mustFromFloats(t, []float64{4, 0, math.Inf(1), 1}, 2, 2)
|
||||
if _, err := CholeskyUpdate(badFac, floatsToArray([]float64{0.1, 0.1}, []int{2})); err == nil || !strings.Contains(err.Error(), "not finite") {
|
||||
t.Fatalf("CholeskyUpdate(Inf factor): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -48,7 +48,19 @@ func Cholesky(a *core.Array) (*core.Array, error) {
|
||||
return nil, base.Errf("Cholesky: needs a square 2-D matrix, got shape %s", base.ShapeText(a.Shape()))
|
||||
}
|
||||
n := a.Shape()[0]
|
||||
lMat, err := denseCholFactor(denseFloats(a, n, n), n)
|
||||
aMat := denseFloats(a, n, n)
|
||||
// A non-finite entry is neither positive nor definite, but the pivot
|
||||
// test below cannot see it: NaN fails every comparison, so the sweep
|
||||
// would return an all-NaN factor with a nil error. The sparse twin
|
||||
// refuses the same input, and so does this one.
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
if math.IsNaN(aMat[i*n+j]) || math.IsInf(aMat[i*n+j], 0) {
|
||||
return nil, base.Errf("Cholesky: entry [%d,%d] is not finite", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
lMat, err := denseCholFactor(aMat, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -706,6 +706,18 @@ func Eigen(a *core.Array) (values, vectors *core.Array, err error) {
|
||||
}
|
||||
n := a.Shape()[0]
|
||||
mat := denseFloats(a, n, n)
|
||||
// A non-finite entry slips through the symmetry test below: the
|
||||
// difference of two NaNs never exceeds the tolerance, so a poisoned
|
||||
// matrix reads as symmetric and the sweep hands back a NaN spectrum
|
||||
// with a nil error. The sparse twin refuses the same input, and so
|
||||
// does this one.
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
if math.IsNaN(mat[i*n+j]) || math.IsInf(mat[i*n+j], 0) {
|
||||
return nil, nil, base.Errf("Eigen: entry [%d,%d] is not finite", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A matrix outside the safe window would drive the reflector norms
|
||||
// and the sweep's squared accumulation past the representable range;
|
||||
// the tridiagonalisation and the QR sweep run on it scaled into the
|
||||
|
||||
@@ -55,6 +55,18 @@ func EigenComplex(a *core.Array) (values, vectors *core.Array, err error) {
|
||||
if !hermitianOK(h, n) {
|
||||
return nil, nil, base.Errf("EigenComplex: matrix is not Hermitian within 1e-12 tolerance")
|
||||
}
|
||||
// A non-finite entry slips through the Hermitian test above: the
|
||||
// difference of a NaN and its conjugate never exceeds the tolerance.
|
||||
// The sparse twin refuses it in its own guard, and so does this one,
|
||||
// before the Jacobi sweep burns its passes on a poisoned matrix.
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
z := h[i*n+j]
|
||||
if math.IsNaN(real(z)) || math.IsNaN(imag(z)) || math.IsInf(real(z), 0) || math.IsInf(imag(z), 0) {
|
||||
return nil, nil, base.Errf("EigenComplex: entry [%d,%d] is not finite", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Jacobi sweep's convergence test is a sum of squared
|
||||
// magnitudes: outside the safe window it overflows to +Inf, which
|
||||
// makes every sweep look already converged, or underflows to 0,
|
||||
|
||||
+20
-1
@@ -6,9 +6,11 @@ package linalg
|
||||
import (
|
||||
"math"
|
||||
"math/cmplx"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustComplexes(t *testing.T, vals []complex128, shape ...int) *core.Array {
|
||||
@@ -291,3 +293,20 @@ func subComplex(a, b []complex128) []complex128 {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestEigenComplexRefusesNonFinite pins that a poisoned matrix never
|
||||
// reads as Hermitian: the mirror comparison cannot see a NaN
|
||||
// difference, so the entry is refused outright, the way the sparse
|
||||
// sibling's Hermitian check refuses it.
|
||||
func TestEigenComplexRefusesNonFinite(t *testing.T) {
|
||||
cv := []complex128{complex(math.NaN(), 0), 0, 0, 1}
|
||||
a := mustComplexes(t, cv, 2, 2)
|
||||
if _, _, err := EigenComplex(a); err == nil || !strings.Contains(err.Error(), "not finite") {
|
||||
t.Fatalf("EigenComplex(NaN): %v", err)
|
||||
}
|
||||
cv2 := []complex128{complex(0, math.Inf(1)), 0, 0, 1}
|
||||
b := mustComplexes(t, cv2, 2, 2)
|
||||
if _, _, err := EigenComplex(b); err == nil || !strings.Contains(err.Error(), "not finite") {
|
||||
t.Fatalf("EigenComplex(Inf): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -5,8 +5,10 @@ package linalg
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// TestSVDReconstruction checks that A = U · Σ · Vᵀ reconstructs A
|
||||
@@ -477,3 +479,18 @@ func TestHouseholderVectorIntoLargeScale(t *testing.T) {
|
||||
t.Fatalf("beta = %v for a zero vector, want 0", empty.beta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEigenRefusesNonFinite pins that a poisoned matrix never reads as
|
||||
// symmetric: the guard's comparison cannot see a NaN difference, so the
|
||||
// entry is refused outright, the way the sparse sibling's symmetry
|
||||
// check refuses it.
|
||||
func TestEigenRefusesNonFinite(t *testing.T) {
|
||||
nan := mustFromFloats(t, []float64{math.NaN(), 0, 0, 1}, 2, 2)
|
||||
if _, _, err := Eigen(nan); err == nil || !strings.Contains(err.Error(), "not finite") {
|
||||
t.Fatalf("Eigen(NaN): %v", err)
|
||||
}
|
||||
inf := mustFromFloats(t, []float64{math.Inf(1), 0, 0, 1}, 2, 2)
|
||||
if _, _, err := Eigen(inf); err == nil || !strings.Contains(err.Error(), "not finite") {
|
||||
t.Fatalf("Eigen(Inf): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,3 +691,18 @@ func TestCholeskyBlockedReconstruction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCholeskyRefusesNonFinite pins the refusal of a poisoned matrix:
|
||||
// the pivot test cannot see a NaN (it fails every comparison), so
|
||||
// without the gate the sweep would answer an all-NaN factor with a nil
|
||||
// error, where the sparse sibling refuses the same input.
|
||||
func TestCholeskyRefusesNonFinite(t *testing.T) {
|
||||
nan := mustFromFloats(t, []float64{math.NaN(), 0, 0, 1}, 2, 2)
|
||||
if _, err := Cholesky(nan); err == nil || !strings.Contains(err.Error(), "not finite") {
|
||||
t.Fatalf("Cholesky(NaN): %v", err)
|
||||
}
|
||||
inf := mustFromFloats(t, []float64{0, 0, 0, math.Inf(1)}, 2, 2)
|
||||
if _, err := Cholesky(inf); err == nil || !strings.Contains(err.Error(), "not finite") {
|
||||
t.Fatalf("Cholesky(Inf): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user