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/),
|
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).
|
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
|
## [1.0.0] - 2026-09-03
|
||||||
|
|
||||||
The initial release of Tensor, a scientific computing library in pure
|
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. |
|
| `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. |
|
| `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`. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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 |
|
| 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. |
|
| `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, and the same 1e-12 Hermitian tolerance applies. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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:
|
- a modification that needs fill the factor does not hold:
|
||||||
`SparseCholesky.Update` and `Downdate` refuse it and leave the
|
`SparseCholesky.Update` and `Downdate` refuse it and leave the
|
||||||
factor as it was, and `CholeskyUpdate` refuses a rank-1 factor, a
|
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
|
- a complex or wrong-length right-hand side to a sparse solve, and a
|
||||||
preconditioner built for another dimension.
|
preconditioner built for another dimension.
|
||||||
- non-finite input to `NewSparseLU`, `NewSparseILU` and the sparse
|
- 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
|
// The rotations below assume a lower triangular factor; a nonzero
|
||||||
// strict upper triangle would silently corrupt the sweep, so it is
|
// 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 i := range n {
|
||||||
for j := i + 1; j < n; j++ {
|
for j := range n {
|
||||||
if out.RawFloats()[i*n+j] != 0 {
|
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)
|
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):
|
// The modified matrix reads [L, x]·J·[L, x]ᵀ with J = diag(I, ±1):
|
||||||
// one sweep of (hyperbolic for −1, orthogonal for +1) rotations
|
// one sweep of (hyperbolic for −1, orthogonal for +1) rotations
|
||||||
// eliminates the vector column, and the surviving columns are the
|
// eliminates the vector column, and the surviving columns are the
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ package linalg
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// spdSample builds a deterministic symmetric positive definite matrix:
|
// 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")
|
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()))
|
return nil, base.Errf("Cholesky: needs a square 2-D matrix, got shape %s", base.ShapeText(a.Shape()))
|
||||||
}
|
}
|
||||||
n := a.Shape()[0]
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -706,6 +706,18 @@ func Eigen(a *core.Array) (values, vectors *core.Array, err error) {
|
|||||||
}
|
}
|
||||||
n := a.Shape()[0]
|
n := a.Shape()[0]
|
||||||
mat := denseFloats(a, n, n)
|
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
|
// A matrix outside the safe window would drive the reflector norms
|
||||||
// and the sweep's squared accumulation past the representable range;
|
// and the sweep's squared accumulation past the representable range;
|
||||||
// the tridiagonalisation and the QR sweep run on it scaled into the
|
// 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) {
|
if !hermitianOK(h, n) {
|
||||||
return nil, nil, base.Errf("EigenComplex: matrix is not Hermitian within 1e-12 tolerance")
|
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
|
// The Jacobi sweep's convergence test is a sum of squared
|
||||||
// magnitudes: outside the safe window it overflows to +Inf, which
|
// magnitudes: outside the safe window it overflows to +Inf, which
|
||||||
// makes every sweep look already converged, or underflows to 0,
|
// makes every sweep look already converged, or underflows to 0,
|
||||||
|
|||||||
+20
-1
@@ -6,9 +6,11 @@ package linalg
|
|||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
"math/cmplx"
|
"math/cmplx"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||||
"testing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func mustComplexes(t *testing.T, vals []complex128, shape ...int) *core.Array {
|
func mustComplexes(t *testing.T, vals []complex128, shape ...int) *core.Array {
|
||||||
@@ -291,3 +293,20 @@ func subComplex(a, b []complex128) []complex128 {
|
|||||||
}
|
}
|
||||||
return out
|
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 (
|
import (
|
||||||
"math"
|
"math"
|
||||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSVDReconstruction checks that A = U · Σ · Vᵀ reconstructs A
|
// 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)
|
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