// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package linalg import ( "math" "strings" "testing" "sourcedock.dev/petrbalvin/tensor/internal/core" ) // unitVectorCOO builds a coordinate vector with a single non-zero. func unitVector(t *testing.T, n, pos int, val float64) *core.Array { t.Helper() vals := make([]float64, n) vals[pos] = val return floatsToArray(vals, []int{n}) } // denseSparseNorm returns the largest absolute difference of two // factor states: the diagonal and the stored values in order. func factorDelta(f, g *SparseCholesky) float64 { worst := 0.0 for i := range f.n { if d := math.Abs(f.diag[i] - g.diag[i]); d > worst { worst = d } } for i := range f.values { if d := math.Abs(f.values[i] - g.values[i]); d > worst { worst = d } } return worst } // addRankOneCOO returns the coordinate matrix of A + x·xᵀ, x given in // the stored coordinates, by summing the modified entries on top of // the original ones. func addRankOneCOO(t *testing.T, a *core.SparseCOO, x *core.Array) *core.SparseCOO { t.Helper() nnz := a.Indices.Shape()[0] entries := make(map[[2]int]float64) for i := range nnz { r := int(a.Indices.RawInts()[i*2]) c := int(a.Indices.RawInts()[i*2+1]) entries[[2]int{r, c}] += a.Values.FloatAt(i) } for i := range x.Len() { if x.FloatAt(i) == 0 { continue } for j := range x.Len() { if x.FloatAt(j) == 0 { continue } entries[[2]int{i, j}] += x.FloatAt(i) * x.FloatAt(j) } } idx := make([]int64, 0, 2*len(entries)) vals := make([]float64, 0, len(entries)) for k, v := range entries { idx = append(idx, int64(k[0]), int64(k[1])) vals = append(vals, v) } 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)}), a.Shape) if err != nil { t.Fatalf("NewSparseCOO: %v", err) } return coo } // TestSparseCholeskyUpdateDowndateRoundTrip pins the round trip on the // banded case the contract names: a path-graph Laplacian, whose // natural and reverse Cuthill-McKee factors are both banded, with // supported updates whose sweeps stay on the stored pattern. Update // then downdate must restore the factor, and the updated factor must // solve like a fresh factorisation of the modified matrix. func TestSparseCholeskyUpdateDowndateRoundTrip(t *testing.T) { coo := gridLaplacianCOO(t, 12, 1) n := 12 csr, err := CSRFromCOO(coo) if err != nil { t.Fatalf("CSRFromCOO: %v", err) } b, err := csr.MatVec(floatsToArray([]float64{1, -2, 3, -4, 5, -1, 2, -3, 4, -5, 1, 0}, []int{n})) if err != nil { t.Fatalf("MatVec: %v", err) } for _, ordering := range []SparseOrdering{SparseOrderingNatural, SparseOrderingReverseCuthillMcKee} { for name, x := range map[string]*core.Array{ "single": unitVector(t, n, 3, 0.5), "adjacent": floatsToArray([]float64{0, 0, 0, 0, 0, 0, 0, 0.5, 0.25, 0, 0, 0}, []int{n}), } { f, err := NewSparseCholesky(coo, ordering) if err != nil { t.Fatalf("NewSparseCholesky: %v", err) } diagBefore := append([]float64(nil), f.diag...) valsBefore := append([]float64(nil), f.values...) if err := f.Update(x); err != nil { t.Fatalf("%s ordering %d: update: %v", name, ordering, err) } // The updated factor solves like the refactored matrix. refactor, err := NewSparseCholesky(addRankOneCOO(t, coo, x), ordering) if err != nil { t.Fatalf("refactor: %v", err) } sModified, err := f.Solve(b) if err != nil { t.Fatalf("solve on the updated factor: %v", err) } sRefactor, err := refactor.Solve(b) if err != nil { t.Fatalf("solve on the refactored matrix: %v", err) } scale := 0.0 for i := range n { if v := math.Abs(sRefactor.FloatAt(i)); v > scale { scale = v } } for i := range n { if math.Abs(sModified.FloatAt(i)-sRefactor.FloatAt(i)) > 1e-9*scale { t.Fatalf("%s ordering %d: updated solve disagrees with the refactored one at %d", name, ordering, i) } } // Downdate restores the factor to round-off. if err := f.Downdate(x); err != nil { t.Fatalf("%s ordering %d: downdate: %v", name, ordering, err) } diagAfter := append([]float64(nil), f.diag...) valsAfter := append([]float64(nil), f.values...) worst := 0.0 for i := range diagBefore { worst = math.Max(worst, math.Abs(diagAfter[i]-diagBefore[i])) } for i := range valsBefore { worst = math.Max(worst, math.Abs(valsAfter[i]-valsBefore[i])) } if worst > 1e-12 { t.Fatalf("%s ordering %d: round trip lost %.3g on the stored factor", name, ordering, worst) } } } } // TestSparseCholeskyUpdateMatchesRefactorisation pins the numeric // truth of the update: on the two-dimensional grid with a // single-coordinate update the sweep stays on the pattern, and every // stored entry must equal the fresh factorisation of the explicitly // modified matrix, whose pattern the diagonal change cannot alter. func TestSparseCholeskyUpdateMatchesRefactorisation(t *testing.T) { coo := gridLaplacianCOO(t, 8, 8) n := 64 x := unitVector(t, n, 30, 0.5) for _, ordering := range []SparseOrdering{SparseOrderingNatural, SparseOrderingReverseCuthillMcKee} { f, err := NewSparseCholesky(coo, ordering) if err != nil { t.Fatalf("NewSparseCholesky: %v", err) } if err := f.Update(x); err != nil { t.Fatalf("update: %v", err) } refactor, err := NewSparseCholesky(addRankOneCOO(t, coo, x), ordering) if err != nil { t.Fatalf("refactor: %v", err) } if f.NNZ() != refactor.NNZ() { t.Fatalf("the update changed the fill: %d vs %d", f.NNZ(), refactor.NNZ()) } if d := factorDelta(f, refactor); d > 1e-9 { t.Fatalf("the updated factor differs from the refactored one by %.3g", d) } } } // TestSparseCholeskyUpdateDowndateExact pins bit-for-bit restoration // on the engineered pattern-stable example: a banded matrix whose // stored factor carries the integers 3 and 5, updated by the // Pythagorean vector 4·e₀, where hypot(3, 4) = 5 and the rotations // round exactly. Update turns every 3 into a 5 and every 5 into a 3; // the downdate turns them back without losing a bit. func TestSparseCholeskyUpdateDowndateExact(t *testing.T) { const n = 6 idx := make([]int64, 0, 3*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 { if i == 0 { add(0, 0, 9) } else { add(i, i, 34) } if i+1 < n { add(i, i+1, 15) add(i+1, i, 15) } } 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 := NewSparseCholesky(coo, SparseOrderingNatural) if err != nil { t.Fatalf("NewSparseCholesky: %v", err) } diagBefore := append([]float64(nil), f.diag...) valsBefore := append([]float64(nil), f.values...) for i := range diagBefore { if diagBefore[i] != 3 { t.Fatalf("the engineered factor's diagonal is %.4g, want 3", diagBefore[i]) } } x := unitVector(t, n, 0, 4) if err := f.Update(x); err != nil { t.Fatalf("update: %v", err) } for i := range f.diag { if f.diag[i] != 5 { t.Fatalf("after the update diag[%d] = %.17g, want exactly 5", i, f.diag[i]) } } for i := range f.values { if f.values[i] != 3 { t.Fatalf("after the update values[%d] = %.17g, want exactly 3", i, f.values[i]) } } if err := f.Downdate(x); err != nil { t.Fatalf("downdate: %v", err) } for i := range f.diag { if f.diag[i] != diagBefore[i] { t.Fatalf("the downdate lost diag[%d]: %.17g vs %.17g", i, f.diag[i], diagBefore[i]) } } for i := range f.values { if f.values[i] != valsBefore[i] { t.Fatalf("the downdate lost values[%d]: %.17g vs %.17g", i, f.values[i], valsBefore[i]) } } } func TestSparseCholeskyUpdateRefusals(t *testing.T) { coo := gridLaplacianCOO(t, 12, 1) // The update whose support spreads beyond a stored column needs // fill the pattern does not hold: refused, factor untouched. f, err := NewSparseCholesky(coo, SparseOrderingNatural) if err != nil { t.Fatalf("NewSparseCholesky: %v", err) } before := append([]float64(nil), f.values...) spread := unitVector(t, 12, 0, 1) spread.RawFloats()[5] = 1 err = f.Update(spread) if err == nil || !strings.Contains(err.Error(), "pattern does not hold") { t.Fatalf("a pattern-violating update was accepted: %v", err) } for i := range f.values { if f.values[i] != before[i] { t.Fatalf("the refused update moved values[%d]", i) } } // The downdate that loses positive dominance: 3·e₀ against a unit // diagonal. if err := f.Downdate(unitVector(t, 12, 0, 3)); err == nil || !strings.Contains(err.Error(), "positive definite") { t.Fatalf("a downdate outside the cone was accepted: %v", err) } // A downdate whose square leaves the float64 range. huge, err := core.NewSparseCOO(mustInts(t, []int64{0, 0}, 1, 2), floatsToArray([]float64{1e308}, []int{1}), []int{1, 1}) if err != nil { t.Fatalf("NewSparseCOO: %v", err) } hf, err := NewSparseCholesky(huge, SparseOrderingNatural) if err != nil { t.Fatalf("NewSparseCholesky: %v", err) } if err := hf.Update(unitVector(t, 1, 0, 1e154)); err != nil { t.Fatalf("huge update: %v", err) } if err := hf.Downdate(unitVector(t, 1, 0, 1e154)); err == nil || !strings.Contains(err.Error(), "range") { t.Fatalf("an out-of-range downdate was accepted: %v", err) } // Validation. if err := f.Update(unitVector(t, 11, 0, 1)); err == nil { t.Fatal("a short vector was accepted") } if err := f.Downdate(core.New(core.Float, 3, 4)); err == nil { t.Fatal("a rank-2 vector was accepted") } if err := f.Update(core.New(core.Complex, 12)); err == nil { t.Fatal("a complex vector was accepted") } // A non-finite entry. nan := unitVector(t, 12, 2, 1) nan.RawFloats()[2] = math.NaN() if err := f.Update(nan); err == nil || !strings.Contains(err.Error(), "finite") { t.Fatalf("a NaN entry was accepted: %v", err) } }