// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package grad import ( "math" "testing" "sourcedock.dev/petrbalvin/tensor/internal/core" ) // Regression pins for the MatMul adjoints: the 1-D × 2-D branch against // central differences, real and complex, and the Newton-CG loop's last // iteration. // TestMatMulVectorByMatrixGradient pins the 1-D × 2-D branch of // the MatMul adjoint (da = g·Bᵀ, db = outer(a, g)), real and complex, // against central differences. The branch had no gradient test at all. func TestMatMulVectorByMatrixGradient(t *testing.T) { avec := []float64{1.5, -0.5, 2, 0.25} bvec := []float64{ 0.5, -1, 2, 1.5, 0.25, -0.75, -2, 1, 0.5, 1, -0.5, 1.25, } // A weighted linear loss, so every output slot carries its own // coefficient and a wrong routing cannot cancel against another. w := []float64{0.7, -1.3, 2.1} lossOf := func(a, b *core.Array) float64 { out, err := core.MatMul2D(a, b) if err != nil { t.Fatalf("MatMul2D: %v", err) } s := 0.0 for i := range out.Len() { s += w[i] * out.FloatAt(i) } return s } cloneWith := func(a *core.Array, i int, v float64) *core.Array { vals := make([]float64, a.Len()) for k := range a.Len() { vals[k] = a.FloatAt(k) } vals[i] = v out, err := core.FromFloats(vals, a.Shape()...) if err != nil { t.Fatalf("FromFloats: %v", err) } return out } a, _ := FromFloat64s(avec, true, 4) b, _ := FromFloat64s(bvec, true, 4, 3) if err := backwardWeightedMatMul(t, a, b, w); err != nil { t.Fatalf("Backward: %v", err) } baseA, _ := core.FromFloats(avec, 4) baseB, _ := core.FromFloats(bvec, 4, 3) for i := range 4 { eps := 1e-6 want := (lossOf(cloneWith(baseA, i, avec[i]+eps), baseB) - lossOf(cloneWith(baseA, i, avec[i]-eps), baseB)) / (2 * eps) if math.Abs(a.Grad().FloatAt(i)-want) > 1e-5*(1+math.Abs(want)) { t.Fatalf("da[%d] = %g, want %g", i, a.Grad().FloatAt(i), want) } } for i := range 12 { eps := 1e-6 want := (lossOf(baseA, cloneWith(baseB, i, bvec[i]+eps)) - lossOf(baseA, cloneWith(baseB, i, bvec[i]-eps))) / (2 * eps) if math.Abs(b.Grad().FloatAt(i)-want) > 1e-5*(1+math.Abs(want)) { t.Fatalf("db[%d] = %g, want %g", i, b.Grad().FloatAt(i), want) } } // The complex 1-D × 2-D branch against numericComplexGrad, under // the same weighted fold backwardComplex builds: L = Σ Re(w̄·y) + // Σ|y|²/n with the helper's own deterministic weights. cb := FromArray(mustComplexes([]complex128{ 0.5 + 0.5i, -1, 1.5, 0.25 - 0.75i, -2 + 1i, 1, }, 3, 2), true) op := func(x *Tensor) (*Tensor, error) { return x.MatMul(cb) } vals := []complex128{1 + 0.5i, -0.25 - 1i, 0.75 + 0.25i} xt := backwardComplex(t, op, vals, 3) closs := complexLossOf(t, op, spectralWeights(2, 11)) checkAgainstNumeric(t, xt.Grad(), numericComplexGrad(closs, xt.Data()), 1e-6) } // backwardWeightedMatMul builds L = w·(a·B) over fresh leaves and runs // one backward pass. func backwardWeightedMatMul(t *testing.T, a, b *Tensor, w []float64) error { t.Helper() out, err := a.MatMul(b) if err != nil { return err } wv, err := FromFloat64s(w, false, len(w)) if err != nil { return err } loss, err := out.Mul(wv) if err != nil { return err } sum, err := loss.Sum() if err != nil { return err } sum.Backward() return nil } // TestNewtonCGConvergesOnTheLastIteration pins that a tolerance // met exactly on the final permitted iteration is a success, not a // budget error whose message prints a gradient already under the // tolerance. func TestNewtonCGConvergesOnTheLastIteration(t *testing.T) { f := func(x *Tensor) (*Tensor, error) { d, err := x.Sub(mustTensorF64(1.5)) if err != nil { return nil, err } return d.Mul(d) } x0, _ := core.FromFloats([]float64{0}, 1) // One truncated-CG step lands within the Hessian-product rounding // of the minimiser (about 1e-11 here); a tolerance of 1e-9 is met // by exactly that step, on the final permitted iteration. out, _, err := MinimiseNewtonCG(f, x0, NewtonCGOptions{MaxIterations: 1, Tolerance: 1e-9}) if err != nil { t.Fatalf("MinimiseNewtonCG on a quadratic with one exact step: %v", err) } if math.Abs(out.FloatAt(0)-1.5) > 1e-9 { t.Fatalf("minimum = %g, want 1.5", out.FloatAt(0)) } } // mustTensorF64 wraps one float as a no-grad tensor. func mustTensorF64(v float64) *Tensor { t, err := FromFloat64s([]float64{v}, false, 1) if err != nil { panic(err) } return t } // mustComplexes builds a complex array or fails the test. func mustComplexes(vals []complex128, shape ...int) *core.Array { a, err := core.FromComplexes(vals, shape...) if err != nil { panic(err) } return a }