// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package linalg import ( "math" "math/rand/v2" "strings" "sourcedock.dev/petrbalvin/tensor/internal/core" "testing" ) func TestQR(t *testing.T) { // A 3×2 matrix with a known QR decomposition. a := mustFromFloats(t, []float64{ 12, -51, 6, 167, -4, 24, }, 3, 2) q, r, err := QR(a) if err != nil { t.Fatal(err) } if q.Shape()[0] != 3 || q.Shape()[1] != 3 { t.Errorf("QR Q shape: %v", q.Shape()) } if r.Shape()[0] != 3 || r.Shape()[1] != 2 { t.Errorf("QR R shape: %v", r.Shape()) } // QᵀQ should be I. for i := range 3 { for j := range 3 { var want float64 if i == j { want = 1 } // QᵀQ: (Qᵀ Q)[i,j] = sum_k Q[k,i] * Q[k,j]. dot := 0.0 for k := range 3 { qi, _ := core.FloatAt(q, k, i) qj, _ := core.FloatAt(q, k, j) dot += qi * qj } if math.Abs(dot-want) > 1e-9 { t.Errorf("QᵀQ[%d,%d]: got %v, want %v", i, j, dot, want) } } } // R should be upper triangular (entries below the diagonal ~0). for i := 1; i < 3; i++ { for j := 0; j < minInt(i, 2); j++ { v, _ := core.FloatAt(r, i, j) if math.Abs(v) > 1e-9 { t.Errorf("R[%d,%d] should be ~0, got %v", i, j, v) } } } // Q * R should reconstruct A. recon := mustFromFloats(t, []float64{0, 0, 0, 0, 0, 0}, 3, 2) for i := range 3 { for j := range 2 { sum := 0.0 for k := range 3 { qik, _ := core.FloatAt(q, i, k) rkj, _ := core.FloatAt(r, k, j) sum += qik * rkj } recon.RawFloats()[i*2+j] = sum } } for i := range 3 { for j := range 2 { got, _ := core.FloatAt(recon, i, j) orig, _ := core.FloatAt(a, i, j) if math.Abs(got-orig) > 1e-9 { t.Errorf("QR reconstruction [%d,%d]: got %v, want %v", i, j, got, orig) } } } // Square matrix case. a2 := mustFromFloats(t, []float64{ 2, 1, 1, 3, }, 2, 2) q2, r2, err := QR(a2) if err != nil { t.Fatal(err) } if q2.Shape()[0] != 2 || q2.Shape()[1] != 2 { t.Errorf("QR square Q shape: %v", q2.Shape()) } if r2.Shape()[0] != 2 || r2.Shape()[1] != 2 { t.Errorf("QR square R shape: %v", r2.Shape()) } // Shape errors. bad, _ := core.FromFloats([]float64{1, 2, 3, 4, 5, 6}, 2, 3) if _, _, err := QR(bad); err == nil { t.Error("QR: expected error for m < n") } _, _, err = QR(mustFromFloats(t, []float64{1, 2, 3, 4}, 4)) // 1-D if err == nil { t.Error("QR: expected error for 1-D input") } } func TestCholesky(t *testing.T) { // A symmetric positive definite matrix. a := mustFromFloats(t, []float64{ 4, 12, -16, 12, 37, -43, -16, -43, 98, }, 3, 3) l, err := Cholesky(a) if err != nil { t.Fatal(err) } if l.Shape()[0] != 3 || l.Shape()[1] != 3 { t.Errorf("Cholesky L shape: %v", l.Shape()) } // L * Lᵀ should reconstruct A. for i := range 3 { for j := range 3 { sum := 0.0 for k := 0; k <= minInt(i, j); k++ { lik, _ := core.FloatAt(l, i, k) ljk, _ := core.FloatAt(l, j, k) sum += lik * ljk } orig, _ := core.FloatAt(a, i, j) if math.Abs(sum-orig) > 1e-9 { t.Errorf("Cholesky reconstruction [%d,%d]: got %v, want %v", i, j, sum, orig) } } } // Non-PD matrix: singular means an error. notPD := mustFromFloats(t, []float64{1, 2, 2, 1}, 2, 2) if _, err := Cholesky(notPD); err == nil { t.Error("Cholesky: expected error for non-PD input") } } func TestSVD(t *testing.T) { // 3×2 rank-2 matrix with known singular values. a := mustFromFloats(t, []float64{ 1, 0, 0, 2, 0, 0, }, 3, 2) u, sigma, vt, err := SVD(a) if err != nil { t.Fatalf("SVD: %v", err) } if u.Shape()[0] != 3 || u.Shape()[1] != 2 { t.Errorf("U shape: %v", u.Shape()) } if sigma.Shape()[0] != 2 { t.Errorf("Σ shape: %v", sigma.Shape()) } if vt.Shape()[0] != 2 || vt.Shape()[1] != 2 { t.Errorf("Vᵀ shape: %v", vt.Shape()) } // Singular values should be {2, 1} in descending order. if math.Abs(sigma.RawFloats()[0]-2) > 1e-9 { t.Errorf("σ₀: got %g want 2", sigma.RawFloats()[0]) } if math.Abs(sigma.RawFloats()[1]-1) > 1e-9 { t.Errorf("σ₁: got %g want 1", sigma.RawFloats()[1]) } if _, _, _, err := SVD(mustFromFloats(t, []float64{1, 2}, 2)); err == nil { t.Error("SVD: expected error for 1-D input") } } func TestEigen(t *testing.T) { // Simple diagonal symmetric matrix. a := mustFromFloats(t, []float64{ 4, 0, 0, 9, }, 2, 2) vals, vecs, err := Eigen(a) if err != nil { t.Fatalf("Eigen: %v", err) } if len(vals.RawFloats()) != 2 { t.Fatalf("vals shape: %v", vals.Shape()) } if math.Abs(vals.RawFloats()[0]-4) > 1e-9 || math.Abs(vals.RawFloats()[1]-9) > 1e-9 { t.Errorf("eigenvalues: got %v want [4, 9]", vals.RawFloats()) } if vecs.Shape()[0] != 2 || vecs.Shape()[1] != 2 { t.Errorf("vecs shape: %v", vecs.Shape()) } // 1-D input errors. if _, _, err := Eigen(mustFromFloats(t, []float64{1, 2}, 2)); err == nil { t.Error("Eigen: expected error for 1-D input") } } func TestLeastSquares(t *testing.T) { // Solve [[3, 1], [1, 2]] x = [9, 8]: solution is x = [2, 3]. a := mustFromFloats(t, []float64{ 3, 1, 1, 2, }, 2, 2) b := mustFromFloats(t, []float64{9, 8}, 2) x, err := LeastSquares(a, b) if err != nil { t.Fatal(err) } if x.NDim() != 1 || x.Len() != 2 { t.Errorf("LeastSquares x shape: %v", x.Shape()) } v0, _ := core.FloatAt(x, 0) v1, _ := core.FloatAt(x, 1) if math.Abs(v0-2) > 1e-9 { t.Errorf("LeastSquares [0]: got %v, want 2", v0) } if math.Abs(v1-3) > 1e-9 { t.Errorf("LeastSquares [1]: got %v, want 3", v1) } // Over-determined system: 3×2 with rank 2. overA := mustFromFloats(t, []float64{ 1, 1, 1, 2, 1, 3, }, 3, 2) overB := mustFromFloats(t, []float64{1, 2, 2}, 3) xOver, err := LeastSquares(overA, overB) if err != nil { t.Fatal(err) } // Solution minimises ||Ax - b||₂. if xOver.Len() != 2 { t.Errorf("LeastSquares over: shape = %v", xOver.Shape()) } // Shape errors. if _, err := LeastSquares(mustFromFloats(t, []float64{1, 2, 3}, 3), overB); err == nil { t.Error("LeastSquares: expected error for 1-D 'a'") } underA := mustFromFloats(t, []float64{ 1, 2, 3, 4, 5, 6, }, 2, 3) if _, err := LeastSquares(underA, overB); err == nil { t.Error("LeastSquares: expected error for m < n") } } func TestMatrixRank(t *testing.T) { // Identity has full rank. id, _ := core.Identity(core.Float, 4) got, err := MatrixRank(id, 0) if err != nil { t.Fatalf("MatrixRank: %v", err) } if got != 4 { t.Errorf("MatrixRank(I_4): got %d want 4", got) } if _, err := MatrixRank(mustFromFloats(t, []float64{1, 2}, 2), 0); err == nil { t.Error("MatrixRank: expected error for 1-D input") } } func TestCond(t *testing.T) { id, _ := core.Identity(core.Float, 3) c, err := Cond(id, 0) if err != nil { t.Fatalf("Cond: %v", err) } if math.Abs(c-1) > 1e-9 { t.Errorf("Cond(I_3): got %g want 1", c) } if _, err := Cond(mustFromFloats(t, []float64{1, 2}, 2), 0); err == nil { t.Error("Cond: expected error for 1-D input") } } func TestEinsumMatmul(t *testing.T) { a := mustFromFloats(t, []float64{1, 2, 3, 4}, 2, 2) b := mustFromFloats(t, []float64{5, 6, 7, 8}, 2, 2) got, err := core.Einsum("ij,jk->ik", a, b) if err != nil { t.Fatal(err) } // Result should be MatMul(a, b) = [[19, 22], [43, 50]]. expect := mustFromFloats(t, []float64{19, 22, 43, 50}, 2, 2) for i := range 4 { v, _ := core.FloatAt(got, i/2, i%2) w, _ := core.FloatAt(expect, i/2, i%2) if math.Abs(v-w) > 1e-9 { t.Errorf("einsum matmul [%d]: got %v, want %v", i, v, w) } } } func TestEinsumDot(t *testing.T) { a := mustFromFloats(t, []float64{1, 2, 3}, 3) b := mustFromFloats(t, []float64{4, 5, 6}, 3) got, err := core.Einsum("i,i->", a, b) if err != nil { t.Fatal(err) } v, _ := core.FloatAt(got, 0) if math.Abs(v-32) > 1e-9 { t.Errorf("einsum dot: got %v, want 32", v) } } func TestEinsumTranspose(t *testing.T) { a := mustFromFloats(t, []float64{1, 2, 3, 4}, 2, 2) got, err := core.Einsum("ij->ji", a) if err != nil { t.Fatal(err) } // Transpose: [[1,2],[3,4]] -> [[1,3],[2,4]] v00, _ := core.FloatAt(got, 0, 0) v01, _ := core.FloatAt(got, 0, 1) v10, _ := core.FloatAt(got, 1, 0) v11, _ := core.FloatAt(got, 1, 1) if v00 != 1 || v01 != 3 || v10 != 2 || v11 != 4 { t.Errorf("einsum transpose: got [[%v,%v],[%v,%v]], want [[1,3],[2,4]]", v00, v01, v10, v11) } } func TestEinsumDiagonal(t *testing.T) { a := mustFromFloats(t, []float64{ 1, 2, 3, 4, 5, 6, 7, 8, 9, }, 3, 3) got, err := core.Einsum("ii->i", a) if err != nil { t.Fatal(err) } v0, _ := core.FloatAt(got, 0) v1, _ := core.FloatAt(got, 1) v2, _ := core.FloatAt(got, 2) if v0 != 1 || v1 != 5 || v2 != 9 { t.Errorf("einsum diagonal: got %v %v %v, want 1 5 9", v0, v1, v2) } } func TestEinsumTrace(t *testing.T) { a := mustFromFloats(t, []float64{ 1, 2, 3, 4, 5, 6, 7, 8, 9, }, 3, 3) got, err := core.Einsum("ii->", a) if err != nil { t.Fatal(err) } v, _ := core.FloatAt(got, 0) if v != 15 { // 1 + 5 + 9 t.Errorf("einsum trace: got %v, want 15", v) } } func TestEinsumSum(t *testing.T) { a := mustFromFloats(t, []float64{1, 2, 3, 4}, 2, 2) got, err := core.Einsum("ij->", a) if err != nil { t.Fatal(err) } v, _ := core.FloatAt(got, 0) if v != 10 { t.Errorf("einsum sum: got %v, want 10", v) } } func TestEinsumElementWiseMul(t *testing.T) { a := mustFromFloats(t, []float64{1, 2, 3, 4}, 2, 2) b := mustFromFloats(t, []float64{5, 6, 7, 8}, 2, 2) got, err := core.Einsum("ij,ij->ij", a, b) if err != nil { t.Fatal(err) } for i, w := range []float64{5, 12, 21, 32} { v, _ := core.FloatAt(got, i/2, i%2) if v != w { t.Errorf("einsum elwise [%d]: got %v, want %v", i, v, w) } } } func TestEinsumOuter(t *testing.T) { a := mustFromFloats(t, []float64{1, 2}, 2) b := mustFromFloats(t, []float64{3, 4, 5}, 3) got, err := core.Einsum("i,j->ij", a, b) if err != nil { t.Fatal(err) } if got.Shape()[0] != 2 || got.Shape()[1] != 3 { t.Errorf("einsum outer shape: %v", got.Shape()) } // [1*3, 1*4, 1*5; 2*3, 2*4, 2*5] = [[3,4,5],[6,8,10]] expect := []float64{3, 4, 5, 6, 8, 10} for i, w := range expect { v, _ := core.FloatAt(got, i/3, i%3) if v != w { t.Errorf("einsum outer [%d]: got %v, want %v", i, v, w) } } } // TestEinsumTransposedInner pins the label alignment of full // contractions: "ij,ji->" must pair a's columns with b's rows. It used // to multiply positionally, computing "ij,ij->" and failing outright // on non-square shapes. func TestEinsumTransposedInner(t *testing.T) { a := mustFromFloats(t, []float64{1, 2, 3, 4}, 2, 2) b := mustFromFloats(t, []float64{5, 6, 7, 8}, 2, 2) got, err := core.Einsum("ij,ji->", a, b) if err != nil { t.Fatal(err) } v, _ := core.FloatAt(got, 0) if v != 69 { // 1*5 + 2*7 + 3*6 + 4*8 t.Errorf("einsum ij,ji->: got %v, want 69", v) } // Non-square operands must work: sum of a * b^T. w := mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6}, 2, 3) x := mustFromFloats(t, []float64{7, 8, 9, 10, 11, 12}, 3, 2) got2, err := core.Einsum("ij,ji->", w, x) if err != nil { t.Fatal(err) } v2, _ := core.FloatAt(got2, 0) if v2 != 212 { // 58 + 154, the sum of w * x^T t.Errorf("einsum ij,ji-> non-square: got %v, want 212", v2) } } // TestEinsumOuterDtypes pins the promotion ladder of the outer product: // int vectors stay int (exact products), complex vectors stay complex. func TestEinsumOuterDtypes(t *testing.T) { ia := mustFromInts(t, []int64{1, 2}, 2) ib := mustFromInts(t, []int64{3, 4}, 2) got, err := core.Einsum("i,j->ij", ia, ib) if err != nil { t.Fatal(err) } if got.Dtype() != core.Int { t.Fatalf("einsum outer int dtype: %s", got.Dtype()) } want := mustFromInts(t, []int64{3, 4, 6, 8}, 2, 2) if !core.Equal(want, got) { t.Errorf("einsum outer int: %s", got) } ca := mustFromComplexes(t, []complex128{1 + 2i}, 1) cb := mustFromComplexes(t, []complex128{3 + 4i}, 1) gotC, err := core.Einsum("i,j->ij", ca, cb) if err != nil { t.Fatal(err) } if gotC.Dtype() != core.Complex { t.Fatalf("einsum outer complex dtype: %s", gotC.Dtype()) } vc, err := core.ComplexAt(gotC, 0, 0) if err != nil { t.Fatal(err) } if vc != -5+10i { // (1+2i)(3+4i) t.Errorf("einsum outer complex: got %v, want (-5+10i)", vc) } } func TestEinsumError(t *testing.T) { a := mustFromFloats(t, []float64{1, 2}, 2) // Wrong operand count. if _, err := core.Einsum("i,j->ij", a); err == nil { t.Error("Einsum: expected error for wrong operand count") } // Unsupported pattern. b := mustFromFloats(t, []float64{3, 4}, 2) if _, err := core.Einsum("ii,jj->ij", a, b); err == nil { t.Error("Einsum: expected error for unsupported pattern") } // Bad spec (no ->). if _, err := core.Einsum("ii", a); err == nil { t.Error("Einsum: expected error for missing ->") } } func minInt(a, b int) int { if a < b { return a } return b } // TestEigenNonDiagonalReconstruction pins the corrected symmetric QR // sweep: eigenpairs of non-diagonal matrices used to come back as // garbage (near-zero eigenvalues for [[2,1],[1,2]]) because the QR step // updated the tridiagonal with incoherent formulas. Eigenpairs are now // checked by the residual ‖A·v − λ·v‖ and eigenvector orthogonality // across a range of ranks. func TestEigenNonDiagonalReconstruction(t *testing.T) { // Golden: eigenvalues of [[2,1],[1,2]] are 1 and 3. em := mustFromFloats(t, []float64{2, 1, 1, 2}, 2, 2) vals, _, err := Eigen(em) if err != nil { t.Fatalf("Eigen golden: %v", err) } if v := vals.FloatAt(0); math.Abs(v-1) > 1e-12 { t.Errorf("Eigen golden [0]: %v, want 1", v) } if v := vals.FloatAt(1); math.Abs(v-3) > 1e-12 { t.Errorf("Eigen golden [1]: %v, want 3", v) } for _, n := range []int{3, 5, 8, 20, 50} { mat := make([]float64, n*n) for i := range n { for j := i; j < n; j++ { v := math.Sin(float64(n)+float64(i*7+j*13)) * 2.0 mat[i*n+j] = v mat[j*n+i] = v } } a := floatsToArray(mat, []int{n, n}) vs, vc, err := Eigen(a) if err != nil { t.Fatalf("Eigen n=%d: %v", n, err) } maxRes, maxOrtho := 0.0, 0.0 for k := range n { for i := range n { av := 0.0 for j := range n { av += mat[i*n+j] * vc.FloatAt(j*n+k) } if d := math.Abs(av - vs.FloatAt(k)*vc.FloatAt(i*n+k)); d > maxRes { maxRes = d } } for k2 := k + 1; k2 < n; k2++ { dot := 0.0 for i := range n { dot += vc.FloatAt(i*n+k) * vc.FloatAt(i*n+k2) } if math.Abs(dot) > maxOrtho { maxOrtho = math.Abs(dot) } } } if maxRes > 1e-11 || maxOrtho > 1e-12 { t.Errorf("Eigen n=%d: residual %v, orthogonality %v", n, maxRes, maxOrtho) } } } // TestSVDGeneralReconstruction pins the corrected Golub-Kahan // pipeline: the old bidiagonalisation zeroed the wrong direction and // dropped the upper triangle, the right singular vectors ignored V₁, // and the wide return path transposed U. General matrices of every // aspect ratio must now reconstruct with orthonormal factors. func TestSVDGeneralReconstruction(t *testing.T) { for _, tc := range []struct{ m, n int }{{4, 4}, {6, 3}, {3, 6}, {8, 5}, {5, 8}, {2, 3}} { m, n := tc.m, tc.n mat := make([]float64, m*n) for i := range mat { mat[i] = math.Sin(float64(i)*0.7)*1.5 + 0.3 } a := floatsToArray(mat, []int{m, n}) u, s, vt, err := SVD(a) if err != nil { t.Fatalf("SVD %dx%d: %v", m, n, err) } if got := s.Shape()[0]; got != min(m, n) { t.Fatalf("SVD %dx%d: %d singular values, want %d", m, n, got, min(m, n)) } ucols := u.Shape()[1] if ucols != min(m, n) || vt.Shape()[0] != min(m, n) { t.Fatalf("SVD %dx%d thin shapes: u=%v vt=%v", m, n, u.Shape(), vt.Shape()) } maxRec := 0.0 for i := range m { for j := range n { sum := 0.0 for k := range s.Len() { sum += u.FloatAt(i*ucols+k) * s.FloatAt(k) * vt.FloatAt(k*n+j) } if d := math.Abs(sum - mat[i*n+j]); d > maxRec { maxRec = d } } } orthU := 0.0 for k1 := range ucols { for k2 := range ucols { dot := 0.0 for i := range m { dot += u.FloatAt(i*ucols+k1) * u.FloatAt(i*ucols+k2) } want := 0.0 if k1 == k2 { want = 1 } if d := math.Abs(dot - want); d > orthU { orthU = d } } } if maxRec > 1e-9 || orthU > 1e-12 { t.Errorf("SVD %dx%d: reconstruction %v, U orthogonality %v", m, n, maxRec, orthU) } // Σ must come back descending. for k := 1; k < s.Len(); k++ { if s.FloatAt(k) > s.FloatAt(k-1) { t.Errorf("SVD %dx%d: σ not descending at %d", m, n, k) } } } } // TestFitPolynomialRefusesExtremeDegree pins the degree guard: degree // MaxInt would wrap degree+1 negative and slip past the sample count // check. func TestFitPolynomialRefusesExtremeDegree(t *testing.T) { x := mustFloats(t, []float64{0, 1, 2}, 3) y := mustFloats(t, []float64{0, 1, 4}, 3) if _, err := FitPolynomial(x, y, math.MaxInt); err == nil || !strings.Contains(err.Error(), "too large") { t.Fatalf("FitPolynomial with degree MaxInt: %v", err) } } // TestCholeskyBlockedReconstruction pins the factorisation past the // width of its column block, the sizes only the benchmarks otherwise // reach: 320 is five whole blocks and 200 three blocks and a partial // one, so the panel update, the diagonal block and the trapezoid divide // all run, the last of them over a block that does not end on a // boundary. A divide that reaches back over the finished columns, or a // panel update that misses them, leaves L·Lᵀ away from A by far more // than rounding, so the reconstruction is checked against the original // matrix rather than against another run of the same sweep. func TestCholeskyBlockedReconstruction(t *testing.T) { for _, n := range []int{320, 200, 96} { rng := rand.New(rand.NewPCG(3, 5)) b := make([]float64, n*n) for i := range b { b[i] = rng.NormFloat64() } // A = B·Bᵀ + n·I is symmetric positive definite, with the // diagonal held well above the rounding floor. a := make([]float64, n*n) for i := range n { for j := range i + 1 { s := 0.0 for k := range n { s += b[i*n+k] * b[j*n+k] } if i == j { s += float64(n) } a[i*n+j], a[j*n+i] = s, s } } l, err := Cholesky(mustFromFloats(t, a, n, n)) if err != nil { t.Fatalf("Cholesky(%d): %v", n, err) } lf := l.RawFloats() worst, scale := 0.0, 0.0 for i := range n { if lf[i*n+i] <= 0 { t.Fatalf("n=%d: the factor's diagonal at %d is %g, want a positive real root", n, i, lf[i*n+i]) } for j := range n { if j > i && lf[i*n+j] != 0 { t.Fatalf("n=%d: the factor holds %g above the diagonal at [%d,%d]", n, lf[i*n+j], i, j) } s := 0.0 for k := range min(i, j) + 1 { s += lf[i*n+k] * lf[j*n+k] } if d := math.Abs(s - a[i*n+j]); d > worst { worst = d } if v := math.Abs(a[i*n+j]); v > scale { scale = v } } } if worst/scale > 1e-12 { t.Fatalf("n=%d: L·Lᵀ misses A by %.6g (scale %.6g), relative %.3g", n, worst, scale, worst/scale) } } } // 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) } }