feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+314
View File
@@ -0,0 +1,314 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
}
}