feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user