diff --git a/CHANGELOG.md b/CHANGELOG.md index a110b54..1e34e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/API.md b/docs/API.md index 2259fb0..40ec571 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/linalg/cholupdate.go b/linalg/cholupdate.go index 9808250..dbac60b 100644 --- a/linalg/cholupdate.go +++ b/linalg/cholupdate.go @@ -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 diff --git a/linalg/cholupdate_test.go b/linalg/cholupdate_test.go index 762e6a4..0fd8aa2 100644 --- a/linalg/cholupdate_test.go +++ b/linalg/cholupdate_test.go @@ -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) + } +} diff --git a/linalg/decomp.go b/linalg/decomp.go index d84c61c..3fdf99e 100644 --- a/linalg/decomp.go +++ b/linalg/decomp.go @@ -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 } diff --git a/linalg/decomp2.go b/linalg/decomp2.go index 125d8a2..58999b6 100644 --- a/linalg/decomp2.go +++ b/linalg/decomp2.go @@ -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 diff --git a/linalg/decomp3.go b/linalg/decomp3.go index 1c8197e..3714860 100644 --- a/linalg/decomp3.go +++ b/linalg/decomp3.go @@ -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, diff --git a/linalg/decomp3_test.go b/linalg/decomp3_test.go index 76b3c08..e9e4ed6 100644 --- a/linalg/decomp3_test.go +++ b/linalg/decomp3_test.go @@ -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) + } +} diff --git a/linalg/decomp_test.go b/linalg/decomp_test.go index 0effd7b..3d99af2 100644 --- a/linalg/decomp_test.go +++ b/linalg/decomp_test.go @@ -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) + } +} diff --git a/linalg/decompositions_test.go b/linalg/decompositions_test.go index ff35cab..cd85e80 100644 --- a/linalg/decompositions_test.go +++ b/linalg/decompositions_test.go @@ -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) + } +}