Files
tensor/linalg/rrqr_test.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

305 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
}
}