283 lines
10 KiB
Go
283 lines
10 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package optim
|
|
|
|
import (
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
|
)
|
|
|
|
// jacobianBuilds counts the central-difference Jacobian builds in a
|
|
// recorded residual trace. A build is n consecutive ± pairs, column j
|
|
// perturbed first, each pair one stencil width √ε·max(1, |xⱼ|) about
|
|
// its base point: the exact pattern FindRootSystem's sweep produces,
|
|
// which backtracking trials (single points, several moving
|
|
// coordinates at once) never match.
|
|
func jacobianBuilds(points [][]float64) int {
|
|
n := len(points[0])
|
|
stencil := math.Sqrt(base.EpsF)
|
|
matchPair := func(p, q []float64, col int) bool {
|
|
diff := -1
|
|
for k := range n {
|
|
if p[k] != q[k] {
|
|
if diff != -1 {
|
|
return false
|
|
}
|
|
diff = k
|
|
}
|
|
}
|
|
if diff != col {
|
|
return false
|
|
}
|
|
mid := (p[col] + q[col]) / 2
|
|
eps := math.Abs(p[col]-q[col]) / 2
|
|
want := stencil * math.Max(1, math.Abs(mid))
|
|
return math.Abs(eps-want) <= 1e-6*want
|
|
}
|
|
count := 0
|
|
i := 0
|
|
for i+2*n <= len(points) {
|
|
built := true
|
|
for col := range n {
|
|
if !matchPair(points[i+2*col], points[i+2*col+1], col) {
|
|
built = false
|
|
break
|
|
}
|
|
}
|
|
if built {
|
|
count++
|
|
i += 2 * n
|
|
continue
|
|
}
|
|
i++
|
|
}
|
|
return count
|
|
}
|
|
|
|
// traceResidual wraps a residual so every evaluation's point is
|
|
// recorded, for the stencil counter to walk.
|
|
func traceResidual(t *testing.T, trace *[][]float64, n int, r func(x []float64) []float64) func(*core.Array) (*core.Array, error) {
|
|
return func(x *core.Array) (*core.Array, error) {
|
|
v := make([]float64, n)
|
|
for i := range n {
|
|
v[i] = x.FloatAt(i)
|
|
}
|
|
*trace = append(*trace, v)
|
|
return mustFloats(t, r(v)), nil
|
|
}
|
|
}
|
|
|
|
// TestFindRootSystemBroydenOneJacobian pins the option's promise on
|
|
// the analytic systems: with UseBroyden the same roots are reached
|
|
// within tolerance and exactly one numerical Jacobian is built, the
|
|
// one at the start.
|
|
func TestFindRootSystemBroydenOneJacobian(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
n int
|
|
start []float64
|
|
r func(x []float64) []float64
|
|
want []float64
|
|
}{
|
|
{"circle-line", 2, []float64{0.5, 0.5},
|
|
func(x []float64) []float64 { return []float64{x[0]*x[0] + x[1]*x[1] - 4, x[0] - x[1]} },
|
|
[]float64{math.Sqrt2, math.Sqrt2}},
|
|
{"circle-hyperbola", 2, []float64{0.4, 2.2},
|
|
func(x []float64) []float64 { return []float64{x[0]*x[0] + x[1]*x[1] - 5, x[0]*x[1] - 2} },
|
|
nil},
|
|
{"trig", 2, []float64{0.3, 0.1},
|
|
func(x []float64) []float64 { return []float64{math.Cos(x[0]) - x[1], math.Sin(x[0]) - x[1]} },
|
|
[]float64{math.Pi / 4, math.Sqrt2 / 2}},
|
|
}
|
|
for _, tc := range cases {
|
|
var trace [][]float64
|
|
residual := traceResidual(t, &trace, tc.n, tc.r)
|
|
x, res, err := FindRootSystem(residual, mustFloats(t, tc.start), RootSystemOptions{UseBroyden: true})
|
|
if err != nil {
|
|
t.Fatalf("%s: FindRootSystem(UseBroyden): %v", tc.name, err)
|
|
}
|
|
if res > 1e-10 {
|
|
t.Fatalf("%s: residual %g, want ≤ 1e-10", tc.name, res)
|
|
}
|
|
if tc.want != nil {
|
|
for i := range tc.n {
|
|
if math.Abs(x.FloatAt(i)-tc.want[i]) > 1e-9 {
|
|
t.Fatalf("%s: x[%d] = %.12g, want %.12g", tc.name, i, x.FloatAt(i), tc.want[i])
|
|
}
|
|
}
|
|
} else {
|
|
// The hyperbola's two roots are (1, 2) and (2, 1).
|
|
s1 := math.Abs(x.FloatAt(0)-1) < 1e-9 && math.Abs(x.FloatAt(1)-2) < 1e-9
|
|
s2 := math.Abs(x.FloatAt(0)-2) < 1e-9 && math.Abs(x.FloatAt(1)-1) < 1e-9
|
|
if !s1 && !s2 {
|
|
t.Fatalf("%s: solution = (%.12g, %.12g), want (1, 2) or (2, 1)",
|
|
tc.name, x.FloatAt(0), x.FloatAt(1))
|
|
}
|
|
}
|
|
if got := jacobianBuilds(trace); got != 1 {
|
|
t.Fatalf("%s: %d numerical Jacobian builds, want 1", tc.name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFindRootSystemBroydenEightUnknowns pins the option on a harder
|
|
// system: eight coupled nonlinear equations with the known root
|
|
// xᵢ = i+1, converged under the default budget with the single
|
|
// starting Jacobian.
|
|
func TestFindRootSystemBroydenEightUnknowns(t *testing.T) {
|
|
const n = 8
|
|
var trace [][]float64
|
|
residual := traceResidual(t, &trace, n, func(x []float64) []float64 {
|
|
r := make([]float64, n)
|
|
for i := range n {
|
|
r[i] = x[i]*x[i] - float64(i+1)*float64(i+1)
|
|
for j := range n {
|
|
if j != i {
|
|
r[i] += 0.05 * (x[j] - float64(j+1))
|
|
}
|
|
}
|
|
}
|
|
return r
|
|
})
|
|
start := make([]float64, n)
|
|
for i := range n {
|
|
start[i] = 0.5 * float64(i+1)
|
|
}
|
|
x, res, err := FindRootSystem(residual, mustFloats(t, start), RootSystemOptions{UseBroyden: true})
|
|
if err != nil {
|
|
t.Fatalf("FindRootSystem(UseBroyden, 8 unknowns): %v", err)
|
|
}
|
|
if res > 1e-10 {
|
|
t.Fatalf("residual %g, want ≤ 1e-10", res)
|
|
}
|
|
for i := range n {
|
|
if math.Abs(x.FloatAt(i)-float64(i+1)) > 1e-9 {
|
|
t.Fatalf("x[%d] = %.12g, want %d", i, x.FloatAt(i), i+1)
|
|
}
|
|
}
|
|
if got := jacobianBuilds(trace); got != 1 {
|
|
t.Fatalf("%d numerical Jacobian builds, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestFindRootSystemBroydenSingularJacobianDescends pins the singular
|
|
// escape under the option. The duplicated equation x² = 1 has a rank-
|
|
// one Jacobian at every point, so no Newton solve ever succeeds and
|
|
// the steepest-descent fallback carries the iteration. From (2, 2) the
|
|
// damped descent lands on a root at once; from (1.5, 1.5) the descent
|
|
// cannot reach one within the budget, and the run refuses with the
|
|
// budget error while the trace shows the Jacobian was rebuilt every
|
|
// single round, the same restart path a degraded update takes.
|
|
func TestFindRootSystemBroydenSingularJacobianDescends(t *testing.T) {
|
|
rankOne := func(x []float64) []float64 {
|
|
return []float64{x[0]*x[0] - 1, x[0]*x[0] - 1}
|
|
}
|
|
var trace [][]float64
|
|
x, res, err := FindRootSystem(traceResidual(t, &trace, 2, rankOne),
|
|
mustFloats(t, []float64{2, 2}), RootSystemOptions{UseBroyden: true})
|
|
if err != nil {
|
|
t.Fatalf("FindRootSystem(UseBroyden, singular Jacobian): %v", err)
|
|
}
|
|
if res > 1e-10 {
|
|
t.Fatalf("residual %g, want ≤ 1e-10", res)
|
|
}
|
|
if math.Abs(math.Abs(x.FloatAt(0))-1) > 1e-8 {
|
|
t.Fatalf("x[0] = %.12g, want a root of x² = 1", x.FloatAt(0))
|
|
}
|
|
// The hopeless start: the refusal is honest and the rebuilds are
|
|
// visible in the trace, one per round while the inverse stays
|
|
// unfit.
|
|
trace = nil
|
|
if _, _, err := FindRootSystem(traceResidual(t, &trace, 2, rankOne),
|
|
mustFloats(t, []float64{1.5, 1.5}), RootSystemOptions{UseBroyden: true}); err == nil {
|
|
t.Fatal("a stalling singular system: want the budget refusal")
|
|
}
|
|
if got := jacobianBuilds(trace); got < 50 {
|
|
t.Fatalf("%d numerical Jacobian builds, want one per round: a singular inverse rebuilds every time", got)
|
|
}
|
|
}
|
|
|
|
// TestFindRootSystemBroydenBudgetStillRefused pins that the shared
|
|
// error contract survives the option: an impossible tolerance under
|
|
// UseBroyden is a budget refusal, not a silent answer.
|
|
func TestFindRootSystemBroydenBudgetStillRefused(t *testing.T) {
|
|
residual := func(x *core.Array) (*core.Array, error) {
|
|
cx, cy := x.FloatAt(0), x.FloatAt(1)
|
|
return mustFloats(t, []float64{cx*cx + cy*cy - 4, cx - cy}), nil
|
|
}
|
|
_, _, err := FindRootSystem(residual, mustFloats(t, []float64{1, 1}),
|
|
RootSystemOptions{UseBroyden: true, MaxIterations: 1, Tolerance: 1e-20})
|
|
if err == nil || !strings.Contains(err.Error(), "MaxIterations=1") {
|
|
t.Fatalf("err = %v, want the budget refusal", err)
|
|
}
|
|
}
|
|
|
|
// TestBroydenMaintainSecantCondition pins the update formula itself:
|
|
// after the rank-one correction the maintained inverse satisfies the
|
|
// secant equation H·y = s exactly to rounding, which is what makes the
|
|
// following steps quasi-Newton at all.
|
|
func TestBroydenMaintainSecantCondition(t *testing.T) {
|
|
h := [][]float64{{2, 0.5}, {-1, 3}}
|
|
s := []float64{0.3, -0.7}
|
|
y := []float64{1.1, 0.4}
|
|
r := []float64{0.9, -0.2}
|
|
ok, stalled := broydenMaintain(h, s, y, r, 0.1, 1.0, 0)
|
|
if !ok || stalled != 0 {
|
|
t.Fatalf("healthy update rejected: ok = %v, stalled = %d", ok, stalled)
|
|
}
|
|
for i := range 2 {
|
|
hy := h[i][0]*y[0] + h[i][1]*y[1]
|
|
if math.Abs(hy-s[i]) > 1e-12 {
|
|
t.Fatalf("secant equation violated: (H·y)[%d] = %.17g, want %.17g", i, hy, s[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBroydenMaintainRestartTriggers pins the documented restart
|
|
// triggers of the rank-one maintenance: a degenerate denominator
|
|
// orders a rebuild at once, a rounding-level residual change likewise,
|
|
// and two consecutive steps without a fall of the residual infinity
|
|
// norm do what one cannot. A refused update leaves the inverse
|
|
// untouched, so the rebuild starts from a Jacobian and not from a
|
|
// half-updated one.
|
|
func TestBroydenMaintainRestartTriggers(t *testing.T) {
|
|
h := [][]float64{{1, 0}, {0, 1}}
|
|
// y all zero: the denominator trigger.
|
|
if ok, _ := broydenMaintain(h, []float64{1, 1}, []float64{0, 0}, []float64{1, 1}, 1, 2, 0); ok {
|
|
t.Fatal("a zero residual change was folded into the inverse")
|
|
}
|
|
// y at rounding level against the residual's own scale.
|
|
if ok, _ := broydenMaintain(h, []float64{1, 1}, []float64{1e-20, 0}, []float64{1, 1}, 1, 2, 0); ok {
|
|
t.Fatal("a rounding-level residual change was folded into the inverse")
|
|
}
|
|
// One stalled step keeps the inverse fit but counts the stall.
|
|
ok, stalled := broydenMaintain(h, []float64{0.1, 0}, []float64{0.5, 0.5}, []float64{1, 1}, 2, 2, 0)
|
|
if !ok || stalled != 1 {
|
|
t.Fatalf("first stall: ok = %v, stalled = %d, want the inverse kept and the stall counted", ok, stalled)
|
|
}
|
|
// A falling step resets the count.
|
|
ok, stalled = broydenMaintain(h, []float64{0.1, 0}, []float64{0.5, 0.5}, []float64{1, 1}, 1, 2, stalled)
|
|
if !ok || stalled != 0 {
|
|
t.Fatalf("falling step: ok = %v, stalled = %d, want the count reset", ok, stalled)
|
|
}
|
|
// The second consecutive stall orders a rebuild and leaves the
|
|
// inverse untouched.
|
|
before := [2][2]float64{{h[0][0], h[0][1]}, {h[1][0], h[1][1]}}
|
|
ok, stalled = broydenMaintain(h, []float64{0.1, 0}, []float64{0.5, 0.5}, []float64{1, 1}, 2, 2, 1)
|
|
if ok || stalled != 0 {
|
|
t.Fatalf("second stall: ok = %v, stalled = %d, want a rebuild ordered", ok, stalled)
|
|
}
|
|
for i := range 2 {
|
|
for k := range 2 {
|
|
if h[i][k] != before[i][k] {
|
|
t.Fatalf("a refused update moved h[%d][%d] from %g to %g", i, k, before[i][k], h[i][k])
|
|
}
|
|
}
|
|
}
|
|
}
|