158 lines
6.0 KiB
Go
158 lines
6.0 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/core"
|
|
)
|
|
|
|
// TestLBFGSRejectsNonFiniteObjectiveAndGradient pins that a
|
|
// non-finite objective value on the finite-difference path is an
|
|
// error: the NaN used to slip past the projected-gradient max and read
|
|
// as converged.
|
|
func TestLBFGSRejectsNonFiniteObjectiveAndGradient(t *testing.T) {
|
|
f := func(x *core.Array) (float64, error) {
|
|
if x.FloatAt(0) < 0 {
|
|
return math.NaN(), nil
|
|
}
|
|
return (x.FloatAt(0) - 3) * (x.FloatAt(0) - 3), nil
|
|
}
|
|
x0 := mustFloats(t, []float64{0}, 1)
|
|
if _, _, err := MinimiseLBFGS(f, nil, x0, LBFGSOptions{}); err == nil {
|
|
t.Fatal("expected an error for a NaN objective on the FD path")
|
|
}
|
|
// A NaN objective at the start is refused before any step.
|
|
f2 := func(*core.Array) (float64, error) { return math.Inf(1), nil }
|
|
if _, _, err := MinimiseLBFGS(f2, nil, x0, LBFGSOptions{}); err == nil {
|
|
t.Fatal("expected an error for an Inf objective at the start")
|
|
}
|
|
}
|
|
|
|
// TestLBFGSConvergesOnTheLastIteration pins that a tolerance
|
|
// met exactly on the final permitted iteration is a success, not a
|
|
// budget error.
|
|
func TestLBFGSConvergesOnTheLastIteration(t *testing.T) {
|
|
f := func(x *core.Array) (float64, error) {
|
|
return (x.FloatAt(0) - 1.5) * (x.FloatAt(0) - 1.5), nil
|
|
}
|
|
g := func(x *core.Array) (*core.Array, error) {
|
|
return core.FromFloats([]float64{2 * (x.FloatAt(0) - 1.5)}, 1)
|
|
}
|
|
x0 := mustFloats(t, []float64{0}, 1)
|
|
out, _, err := MinimiseLBFGS(f, g, x0, LBFGSOptions{MaxIterations: 1, Tolerance: 1e-10})
|
|
if err != nil {
|
|
t.Fatalf("MinimiseLBFGS 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))
|
|
}
|
|
}
|
|
|
|
// TestFindRootSystemRejectsNonFiniteResidual pins that a NaN
|
|
// residual at an adopted state is an error: the infinity norm used to
|
|
// read it as a converged root with residual zero.
|
|
func TestFindRootSystemRejectsNonFiniteResidual(t *testing.T) {
|
|
f := func(*core.Array) (*core.Array, error) {
|
|
return mustFloats(t, []float64{math.NaN(), math.NaN()}), nil
|
|
}
|
|
x0 := mustFloats(t, []float64{1, 1}, 2)
|
|
_, _, err := FindRootSystem(f, x0, RootSystemOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "non-finite") {
|
|
t.Fatalf("err = %v, want the non-finite refusal", err)
|
|
}
|
|
// One prefix only: the message carries the entry point once.
|
|
if strings.Count(err.Error(), "FindRootSystem") != 1 {
|
|
t.Fatalf("err = %v, want exactly one entry-point prefix", err)
|
|
}
|
|
}
|
|
|
|
// TestFindRootSystemSurvivesOverflowingTrials pins the trial
|
|
// softening: log(x) is NaN on the negative half-line, the first
|
|
// Newton step from afar overshoots straight into it, and the damping
|
|
// halves past the rejected trial instead of dying. The start itself
|
|
// stays finite, so only the trials ever see NaN.
|
|
func TestFindRootSystemSurvivesOverflowingTrials(t *testing.T) {
|
|
f := func(x *core.Array) (*core.Array, error) {
|
|
return mustFloats(t, []float64{math.Log(x.FloatAt(0)) - 1}), nil
|
|
}
|
|
x0 := mustFloats(t, []float64{1000}, 1)
|
|
sol, _, err := FindRootSystem(f, x0, RootSystemOptions{})
|
|
if err != nil {
|
|
t.Fatalf("FindRootSystem through NaN trials: %v", err)
|
|
}
|
|
if math.Abs(sol.FloatAt(0)-math.E) > 1e-8 {
|
|
t.Fatalf("root = %.12g, want e", sol.FloatAt(0))
|
|
}
|
|
}
|
|
|
|
// TestFindRootSystemVanishedStepIsNotARoot pins that a step
|
|
// collapsing to zero under a numerically zero Jacobian is a stall the
|
|
// iteration reports, never a root published beside a live residual.
|
|
func TestFindRootSystemVanishedStepIsNotARoot(t *testing.T) {
|
|
f := func(x *core.Array) (*core.Array, error) {
|
|
return mustFloats(t, []float64{1e-320*x.FloatAt(0)*x.FloatAt(0) - 10}), nil
|
|
}
|
|
x0 := mustFloats(t, []float64{0}, 1)
|
|
sol, res, err := FindRootSystem(f, x0, RootSystemOptions{})
|
|
if err == nil {
|
|
t.Fatalf("a vanished step published root %g with residual %g", sol.FloatAt(0), res)
|
|
}
|
|
}
|
|
|
|
// TestFindRootSystemConvergesOnTheLastIteration pins the
|
|
// exact-final-step convergence of the root solver.
|
|
func TestFindRootSystemConvergesOnTheLastIteration(t *testing.T) {
|
|
f := func(x *core.Array) (*core.Array, error) {
|
|
return mustFloats(t, []float64{x.FloatAt(0) - 2, x.FloatAt(1) + 1}), nil
|
|
}
|
|
x0 := mustFloats(t, []float64{0, 0}, 2)
|
|
out, _, err := FindRootSystem(f, x0, RootSystemOptions{MaxIterations: 1, Tolerance: 1e-10})
|
|
if err != nil {
|
|
t.Fatalf("FindRootSystem on a linear system with one exact step: %v", err)
|
|
}
|
|
if out.FloatAt(0) != 2 || out.FloatAt(1) != -1 {
|
|
t.Fatalf("root = (%g, %g), want (2, -1)", out.FloatAt(0), out.FloatAt(1))
|
|
}
|
|
}
|
|
|
|
// TestLevenbergMarquardtPerfectStart pins that a start whose
|
|
// residual cancels exactly is the answer, not the damping collapse the
|
|
// strict improvement test used to die in.
|
|
func TestLevenbergMarquardtPerfectStart(t *testing.T) {
|
|
f := func(p *core.Array) (*core.Array, error) {
|
|
return mustFloats(t, []float64{p.FloatAt(0) - 2}), nil
|
|
}
|
|
p0 := mustFloats(t, []float64{2}, 1)
|
|
out, chi2, err := LevenbergMarquardt(f, p0, LMOptions{})
|
|
if err != nil {
|
|
t.Fatalf("LevenbergMarquardt at the exact solution: %v", err)
|
|
}
|
|
if chi2 != 0 || out.FloatAt(0) != 2 {
|
|
t.Fatalf("fit = (%g, %g), want (2, 0)", out.FloatAt(0), chi2)
|
|
}
|
|
}
|
|
|
|
// TestMinimiseConvergesOnTheLastIteration exercises the simplex
|
|
// re-test after the final move; the convergence here lands well inside
|
|
// the budget, so the case is a smoke of the returned pair, not a pin of
|
|
// the exact final move (a deterministic last-move budget is not stable
|
|
// across the portable and vector builds).
|
|
func TestMinimiseConvergesOnTheLastIteration(t *testing.T) {
|
|
f := func(x *core.Array) (float64, error) {
|
|
return math.Abs(x.FloatAt(0)-1) + 0.5*math.Abs(x.FloatAt(1)+1), nil
|
|
}
|
|
x0 := mustFloats(t, []float64{0, 0}, 2)
|
|
out, _, err := Minimise(f, x0, MinimiseOptions{MaxIterations: 200, Tolerance: 1e-8})
|
|
if err != nil {
|
|
t.Fatalf("Minimise: %v", err)
|
|
}
|
|
if math.Abs(out.FloatAt(0)-1) > 1e-4 || math.Abs(out.FloatAt(1)+1) > 1e-4 {
|
|
t.Fatalf("minimum = (%g, %g), want (1, -1)", out.FloatAt(0), out.FloatAt(1))
|
|
}
|
|
}
|