// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package core import ( "errors" "math" "strings" "testing" ) // TestJacobianAnalytic pins the Jacobian of f(x, y) = (x·y, x+y), // whose exact derivative [[y, x], [1, 1]] the central differences // must reproduce to 1e-8, at points that exercise the step scaling. func TestJacobianAnalytic(t *testing.T) { f := func(x *Array) (*Array, error) { xv, yv := x.FloatAt(0), x.FloatAt(1) return FromFloats([]float64{xv * yv, xv + yv}, 2) } cases := []struct { name string x, y float64 want []float64 }{ {"first quadrant", 2, 3, []float64{3, 2, 1, 1}}, {"negative coordinate", -1.5, 4, []float64{4, -1.5, 1, 1}}, {"origin", 0, 0, []float64{0, 0, 1, 1}}, } for _, c := range cases { j, err := Jacobian(f, mustFloats(t, []float64{c.x, c.y}), JacobianOptions{}) if err != nil { t.Fatalf("%s: %v", c.name, err) } if shape := j.Shape(); len(shape) != 2 || shape[0] != 2 || shape[1] != 2 { t.Fatalf("%s: shape %v, want (2, 2)", c.name, shape) } for i, want := range c.want { if got := j.FloatAt(i); math.Abs(got-want) > 1e-8 { t.Errorf("%s: entry %d = %v, want %v", c.name, i, got, want) } } } } // TestJacobianNonlinear pins a nonlinear scalar-input case, // f(x) = (x², x³) at x = 1.7, whose Jacobian is the column // (2x, 3x²) = (3.4, 8.67). func TestJacobianNonlinear(t *testing.T) { f := func(x *Array) (*Array, error) { xv := x.FloatAt(0) return FromFloats([]float64{xv * xv, xv * xv * xv}, 2) } j, err := Jacobian(f, mustFloats(t, []float64{1.7}), JacobianOptions{}) if err != nil { t.Fatalf("Jacobian: %v", err) } // The x³ column sits at the default step's rounding floor // (~2·10⁻⁸ of cancellation noise), so the pin is 2e-8 there. tols := []float64{1e-8, 2e-8} for i, want := range []float64{3.4, 8.67} { if got := j.FloatAt(i); math.Abs(got-want) > tols[i] { t.Errorf("entry %d = %v, want %v", i, got, want) } } } // TestJacobianCustomStep checks that a caller-supplied step is // honoured: on a linear field any step is exact, so the result pins // the matrix rather than the step heuristic. func TestJacobianCustomStep(t *testing.T) { f := func(x *Array) (*Array, error) { return FromFloats([]float64{2*x.FloatAt(0) - x.FloatAt(1)}, 1) } j, err := Jacobian(f, mustFloats(t, []float64{5, -7}), JacobianOptions{Step: 1e-6}) if err != nil { t.Fatalf("Jacobian: %v", err) } if shape := j.Shape(); len(shape) != 2 || shape[0] != 1 || shape[1] != 2 { t.Fatalf("shape %v, want (1, 2)", shape) } for i, want := range []float64{2, -1} { // The wide 1e-6 step leaves ~2e-9 of cancellation noise on // values of size ~17; the pin still says which matrix it is. if got := j.FloatAt(i); math.Abs(got-want) > 1e-8 { t.Errorf("entry %d = %v, want %v", i, got, want) } } } // TestJacobianRejects pins the contracts: complex points, empty // points, non-vector outputs, outputs whose length changes between // columns, and errors from f all come back as errors. func TestJacobianRejects(t *testing.T) { complexPoint, _ := FromComplexes([]complex128{1, 2}, 2) if _, err := Jacobian(vectorIdentity, complexPoint, JacobianOptions{}); err == nil { t.Error("expected an error for a complex point") } else if !strings.Contains(err.Error(), "complex") { t.Errorf("complex point: error %q lacks \"complex\"", err) } if _, err := Jacobian(vectorIdentity, mustFloats(t, nil), JacobianOptions{}); err == nil { t.Error("expected an error for an empty point") } matrixOut := func(x *Array) (*Array, error) { return FromFloats([]float64{1, 0, 0, 1}, 2, 2) } if _, err := Jacobian(matrixOut, mustFloats(t, []float64{1}), JacobianOptions{}); err == nil { t.Error("expected an error for a matrix-shaped output") } else if !strings.Contains(err.Error(), "vector") { t.Errorf("matrix output: error %q lacks \"vector\"", err) } emptyOut := func(x *Array) (*Array, error) { return FromFloats([]float64{}, 0) } if _, err := Jacobian(emptyOut, mustFloats(t, []float64{1}), JacobianOptions{}); err == nil { t.Error("expected an error for an empty output") } calls := 0 changingLength := func(x *Array) (*Array, error) { calls++ if calls == 1 { return FromFloats([]float64{1, 2}, 2) } return FromFloats([]float64{1, 2, 3}, 3) } if _, err := Jacobian(changingLength, mustFloats(t, []float64{1, 2}), JacobianOptions{}); err == nil { t.Error("expected an error for an output length that changes between columns") } sentinel := errf("f blew up") failing := func(x *Array) (*Array, error) { if x.FloatAt(0) != 1 { return nil, sentinel } return FromFloats([]float64{1}, 1) } _, err := Jacobian(failing, mustFloats(t, []float64{1}), JacobianOptions{}) if err == nil { t.Error("expected the probe failure to propagate") } else if !strings.Contains(err.Error(), "blew up") { t.Errorf("probe failure: error %q lacks the cause", err) } else if !errors.Is(err, sentinel) { // The wrap keeps the chain open, so the sentinel stays reachable // through the entry point's context: a revert to a plain %v // formatting fails exactly here. t.Errorf("probe failure: error %q does not unwrap to the cause", err) } } // vectorIdentity is a well-behaved f for the input-side rejections. func vectorIdentity(x *Array) (*Array, error) { return FromFloats([]float64{x.FloatAt(0), x.FloatAt(1)}, 2) }