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:
+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