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
+304
View File
@@ -0,0 +1,304 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package linalg
import (
"math"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// randomDense builds a deterministic m×n matrix with entries in [−1, 1)
// from the seeded generator, so no test randomness leaks.
func randomDense(t *testing.T, m, n int, seed int64) *core.Array {
t.Helper()
g := core.NewGenerator(seed)
vals := make([]float64, m*n)
for i := range vals {
vals[i] = float64(g.Next()%2000)/1000 - 1
}
return floatsToArray(vals, []int{m, n})
}
func TestRRQRFactorisation(t *testing.T) {
const m, n = 14, 8
a := randomDense(t, m, n, 11)
q, r, perm, rank, err := RRQR(a)
if err != nil {
t.Fatalf("RRQR: %v", err)
}
if rank != n {
t.Fatalf("rank %d for a full-rank %d×%d matrix", rank, m, n)
}
if !rrqrValidPerm(perm) {
t.Fatalf("the permutation %v is not a permutation", perm)
}
// A·P = Q·R to machine precision.
worst, aMax := 0.0, 0.0
for i := range m {
for j := range n {
ap := a.FloatAt(i*n + perm[j])
qr := 0.0
for l := range m {
qr += q.FloatAt(i*m+l) * r.FloatAt(l*n+j)
}
if d := math.Abs(ap - qr); d > worst {
worst = d
}
if v := math.Abs(a.FloatAt(i*n + j)); v > aMax {
aMax = v
}
}
}
if worst > 1e-12*math.Max(1, aMax)*float64(m) {
t.Fatalf("A·P = Q·R off by %.3g", worst)
}
// Q orthogonal.
for i := range m {
for j := range m {
s := 0.0
for l := range m {
s += q.FloatAt(l*m+i) * q.FloatAt(l*m+j)
}
want := 0.0
if i == j {
want = 1
}
if math.Abs(s-want) > 1e-13 {
t.Fatalf("QᵀQ[%d,%d] = %.16g, want %.16g", i, j, s, want)
}
}
}
// R upper triangular.
for i := range m {
for j := range n {
if j < i && math.Abs(r.FloatAt(i*n+j)) > 1e-12 {
t.Fatalf("R[%d,%d] = %.3g below the diagonal", i, j, r.FloatAt(i*n+j))
}
}
}
// The |R| diagonal decays monotonically, the pivoting guarantee.
for i := 1; i < n; i++ {
hi := math.Abs(r.FloatAt(i*n + i))
lo := math.Abs(r.FloatAt((i-1)*n + i - 1))
if hi > lo*(1+1e-12) {
t.Fatalf("|R[%d,%d]| = %.6g exceeds |R[%d,%d]| = %.6g", i, i, hi, i-1, i-1, lo)
}
}
// RRQRRank is the same count without the orthogonal factor.
rank2, err := RRQRRank(a)
if err != nil {
t.Fatalf("RRQRRank: %v", err)
}
if rank2 != n {
t.Fatalf("RRQRRank = %d, want %d", rank2, n)
}
}
func TestRRQRRankDetection(t *testing.T) {
// A hand-built 6×4: three independent columns and a fourth that is
// exactly 2·c0 + c1 in float arithmetic.
const m, n = 6, 4
g := core.NewGenerator(5)
raw := make([]float64, m*n)
for i := range m {
raw[i*n+0] = float64(g.Next()%100)/50 - 1
raw[i*n+1] = float64(g.Next()%100)/50 - 1
raw[i*n+2] = float64(g.Next()%100)/50 - 1
raw[i*n+3] = 2*raw[i*n+0] + raw[i*n+1]
}
a := floatsToArray(raw, []int{m, n})
_, _, perm, rank, err := RRQR(a)
if err != nil {
t.Fatalf("RRQR: %v", err)
}
if rank != 3 {
t.Fatalf("rank %d for a matrix whose fourth column is exactly dependent", rank)
}
if !rrqrValidPerm(perm) {
t.Fatalf("the permutation %v is not a permutation", perm)
}
// The SVD count must agree, independently.
svdRank, err := MatrixRank(a, 0)
if err != nil {
t.Fatalf("MatrixRank: %v", err)
}
if svdRank != 3 {
t.Fatalf("the SVD counts rank %d where the pivoted diagonal shows 3", svdRank)
}
// An exact zero column is the cleanest possible direction.
zero := floatsToArray([]float64{
1, 0, 2, 0,
2, 1, 1, 0,
1, 1, 3, 0,
2, 0, 1, 0,
1, 2, 2, 0,
3, 1, 4, 0,
}, []int{m, n})
rankZero, err := RRQRRank(zero)
if err != nil {
t.Fatalf("RRQRRank: %v", err)
}
if rankZero != 3 {
t.Fatalf("rank %d for a matrix with an exact zero column", rankZero)
}
// The zero matrix has no rank at all.
empty := floatsToArray(make([]float64, 3*2), []int{3, 2})
rankEmpty, err := RRQRRank(empty)
if err != nil {
t.Fatalf("RRQRRank: %v", err)
}
if rankEmpty != 0 {
t.Fatalf("rank %d for the zero matrix", rankEmpty)
}
}
func TestSolveRRQRMinimumNorm(t *testing.T) {
// Rank-deficient and consistent: the minimum-norm answer, checked
// against the SVD's Pinverse and, on a hand-checkable 3×3, against
// the exact (0, 1, 1).
const m, n = 6, 4
g := core.NewGenerator(9)
raw := make([]float64, m*n)
for i := range m {
raw[i*n+0] = float64(g.Next()%100)/50 - 1
raw[i*n+1] = float64(g.Next()%100)/50 - 1
raw[i*n+2] = float64(g.Next()%100)/50 - 1
raw[i*n+3] = 2*raw[i*n+0] + raw[i*n+1]
}
a := floatsToArray(raw, []int{m, n})
xTrue := make([]float64, n)
for i := range n {
xTrue[i] = math.Cos(0.3*float64(i)) + float64(i%3)
}
b := core.New(core.Float, m)
for i := range m {
s := 0.0
for j := range n {
s += raw[i*n+j] * xTrue[j]
}
b.RawFloats()[i] = s
}
x, err := SolveRRQR(a, b)
if err != nil {
t.Fatalf("SolveRRQR: %v", err)
}
pinv, err := Pinverse(a, 0)
if err != nil {
t.Fatalf("Pinverse: %v", err)
}
worst := 0.0
for i := range n {
s := 0.0
for j := range m {
s += pinv.FloatAt(i*m+j) * b.FloatAt(j)
}
if d := math.Abs(x.FloatAt(i) - s); d > worst {
worst = d
}
}
if worst > 1e-8 {
t.Fatalf("the pivoted answer misses the SVD minimum norm by %.3g", worst)
}
// The hand-checkable 3×3 system from the sparse solver's own pin.
tiny := floatsToArray([]float64{1, 0, 1, 0, 1, 1, 1, 1, 2}, []int{3, 3})
tb := mustFloats(t, []float64{1, 2, 3}, 3)
xt, err := SolveRRQR(tiny, tb)
if err != nil {
t.Fatalf("SolveRRQR: %v", err)
}
want := []float64{0, 1, 1}
for i := range want {
if math.Abs(xt.FloatAt(i)-want[i]) > 1e-10 {
t.Fatalf("x[%d] = %.12g, want the minimum-norm %.12g", i, xt.FloatAt(i), want[i])
}
}
// Full rank and inconsistent: the ordinary least-squares answer.
full := randomDense(t, 5, 3, 13)
fb := mustFloats(t, []float64{1, -1, 2, 0.5, 3}, 5)
xf, err := SolveRRQR(full, fb)
if err != nil {
t.Fatalf("SolveRRQR: %v", err)
}
ref, err := LeastSquares(full, fb)
if err != nil {
t.Fatalf("LeastSquares: %v", err)
}
for i := range 3 {
if math.Abs(xf.FloatAt(i)-ref.FloatAt(i)) > 1e-9 {
t.Fatalf("full-rank solve x[%d] = %.12g, want %.12g", i, xf.FloatAt(i), ref.FloatAt(i))
}
}
}
func TestSolveRRQRErrors(t *testing.T) {
wide := floatsToArray([]float64{1, 2, 3, 4, 5, 6}, []int{2, 3})
b2 := mustFloats(t, []float64{1, 2}, 2)
if _, err := SolveRRQR(wide, b2); err == nil {
t.Fatal("an underdetermined system was accepted")
}
complexA, err := core.FromComplexes([]complex128{1, 0, 0, 1}, 2, 2)
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
if _, err := SolveRRQR(complexA, b2); err == nil {
t.Fatal("a complex matrix was accepted")
}
good := randomDense(t, 4, 3, 17)
complexB, err := core.FromComplexes([]complex128{1, 2, 3}, 3)
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
if _, err := SolveRRQR(good, complexB); err == nil {
t.Fatal("a complex right-hand side was accepted")
}
if _, err := SolveRRQR(good, mustFloats(t, []float64{1, 2}, 2)); err == nil {
t.Fatal("a short right-hand side was accepted")
}
if _, err := SolveRRQR(good, core.New(core.Float, 2, 2)); err == nil {
t.Fatal("a rank-2 right-hand side was accepted")
}
}
func TestRRQRErrors(t *testing.T) {
oneD := mustFloats(t, []float64{1, 2, 3}, 3)
if _, _, _, _, err := RRQR(oneD); err == nil {
t.Fatal("a rank-1 input was accepted")
}
complexA, err := core.FromComplexes([]complex128{1, 0, 0, 1}, 2, 2)
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
if _, _, _, _, err := RRQR(complexA); err == nil {
t.Fatal("a complex matrix was accepted")
}
wide := floatsToArray([]float64{1, 2, 3, 4, 5, 6}, []int{2, 3})
if _, _, _, _, err := RRQR(wide); err == nil {
t.Fatal("an underdetermined matrix was accepted")
}
if _, err := RRQRRank(oneD); err == nil {
t.Fatal("RRQRRank accepted a rank-1 input")
}
if _, err := RRQRRank(complexA); err == nil {
t.Fatal("RRQRRank accepted a complex matrix")
}
if _, err := RRQRRank(wide); err == nil {
t.Fatal("RRQRRank accepted an underdetermined matrix")
}
empty := floatsToArray([]float64{1}, []int{1, 0})
if _, _, _, _, err := RRQR(empty); err == nil {
t.Fatal("an empty matrix was accepted")
}
// The permutation helper's contract.
if !rrqrValidPerm([]int{2, 0, 1}) {
t.Fatal("a rotation was rejected as a permutation")
}
if rrqrValidPerm([]int{0, 0, 1}) {
t.Fatal("a repeated index passed as a permutation")
}
if rrqrValidPerm([]int{0, 2}) {
t.Fatal("an out-of-range index passed as a permutation")
}
}