Files
tensor/optim/brent_test.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

204 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
)
// TestFindRootBrentTranscendentalRoots pins the documented contract on
// known transcendental roots: the answer sits inside the bracket and
// carries |f| at or below the default tolerance of 1e-10.
func TestFindRootBrentTranscendentalRoots(t *testing.T) {
cases := []struct {
name string
f func(float64) float64
a, b float64
root float64
}{
{"sin", math.Sin, 1, 4, math.Pi},
{"cos x = x", func(x float64) float64 { return math.Cos(x) - x }, 0, 1, 0.7390851332151607},
{"exp(x) = 10", func(x float64) float64 { return math.Exp(x) - 10 }, 0, 3, math.Log(10)},
{"x³ = 2x + 5", func(x float64) float64 { return x*x*x - 2*x - 5 }, 1, 3, 2.0945514815423265},
}
for _, tc := range cases {
root, err := FindRootBrent(tc.f, tc.a, tc.b, BrentOptions{})
if err != nil {
t.Fatalf("%s: FindRootBrent: %v", tc.name, err)
}
if root <= tc.a || root >= tc.b {
t.Fatalf("%s: root %g outside the open bracket (%g, %g)", tc.name, root, tc.a, tc.b)
}
if math.Abs(root-tc.root) > 1e-9 {
t.Fatalf("%s: root = %.16g, want %.16g", tc.name, root, tc.root)
}
if fx := math.Abs(tc.f(root)); fx > 1e-10 {
t.Fatalf("%s: |f(root)| = %g, want ≤ 1e-10", tc.name, fx)
}
}
}
// TestFindRootBrentRefusesSameSignBracket pins the bracket refusal: a
// pair of endpoint values of one sign carries no guarantee, and the
// solver answers with the package's error style instead of a guess.
func TestFindRootBrentRefusesSameSignBracket(t *testing.T) {
f := func(x float64) float64 { return x*x - 1 }
_, err := FindRootBrent(f, 2, 3, BrentOptions{})
if err == nil || !strings.Contains(err.Error(), "does not change sign") {
t.Fatalf("err = %v, want the sign-change refusal", err)
}
if strings.Count(err.Error(), "FindRootBrent") != 1 {
t.Fatalf("err = %v, want exactly one entry-point prefix", err)
}
// The reversed orientation refuses for the same reason.
if _, err := FindRootBrent(f, 3, 2, BrentOptions{}); err == nil {
t.Fatal("reversed same-sign bracket: want an error")
}
// A non-finite endpoint value is refused before anything moves.
nan := func(float64) float64 { return math.NaN() }
if _, err := FindRootBrent(nan, 1, 2, BrentOptions{}); err == nil || !strings.Contains(err.Error(), "finite values") {
t.Fatalf("err = %v, want the finite-value refusal", err)
}
}
// TestFindRootBrentAnswersEndpointRoot pins that a root sitting
// exactly on a bracket end is answered as it stands, in either
// orientation, and that an exact zero hit mid-run comes straight back.
func TestFindRootBrentAnswersEndpointRoot(t *testing.T) {
f := func(x float64) float64 { return x - 2 }
for _, ends := range [][2]float64{{2, 5}, {5, 2}} {
root, err := FindRootBrent(f, ends[0], ends[1], BrentOptions{})
if err != nil {
t.Fatalf("FindRootBrent([%g, %g]): %v", ends[0], ends[1], err)
}
if root != 2 {
t.Fatalf("FindRootBrent([%g, %g]) = %g, want 2", ends[0], ends[1], root)
}
}
// A bisection whose midpoint is the root exactly.
root, err := FindRootBrent(f, 0, 4, BrentOptions{})
if err != nil || root != 2 {
t.Fatalf("FindRootBrent([0, 4]) = (%g, %v), want (2, nil)", root, err)
}
}
// TestFindRootBrentSteepSlopeRefuses pins the honest failure at the
// resolution limit: a sign change carried by a jump has no point where
// |f| can sit below the tolerance, and once the bracket closes around
// the jump the solver reports the collapse instead of a point that
// misses the documented contract.
func TestFindRootBrentSteepSlopeRefuses(t *testing.T) {
f := func(x float64) float64 {
if x < 0.5 {
return -1
}
return 1e6
}
_, err := FindRootBrent(f, 0, 1, BrentOptions{})
if err == nil || !strings.Contains(err.Error(), "collapsed to machine width") {
t.Fatalf("err = %v, want the collapsed-bracket refusal", err)
}
}
// TestFindRootBrentStaysInsideAndBeatsBisection pins two properties on
// the classic cubic x³ = 2x + 5: every evaluation lands inside the
// initial bracket, and the superlinear convergence shows in the
// evaluation count against pure bisection under the same residual
// stop.
func TestFindRootBrentStaysInsideAndBeatsBisection(t *testing.T) {
const a, b, tol = 1.0, 3.0, 1e-10
fc := func(x float64) float64 { return x*x*x - 2*x - 5 }
var evals []float64
root, err := FindRootBrent(func(x float64) float64 {
evals = append(evals, x)
return fc(x)
}, a, b, BrentOptions{Tolerance: tol})
if err != nil {
t.Fatalf("FindRootBrent: %v", err)
}
if math.Abs(fc(root)) > tol {
t.Fatalf("|f(root)| = %g, want ≤ %g", math.Abs(fc(root)), tol)
}
for _, x := range evals {
if x < a || x > b {
t.Fatalf("evaluated f at %g, outside the bracket [%g, %g]", x, a, b)
}
}
// The documented bound: two endpoint evaluations plus one per
// iteration of the default budget.
if len(evals) > 100+2 {
t.Fatalf("%d evaluations, want at most MaxIterations + 2", len(evals))
}
// Pure bisection under the same residual stop, counted the same
// way. The cubic's slope near the root makes |f| ≤ 1e-10 need a
// bracket about 1e-11 wide, which bisection reaches only after a
// long halving chain.
bisectionEvals := 0
bisect := func(x float64) float64 {
bisectionEvals++
return fc(x)
}
xl, xr := a, b
fxl := bisect(xl)
for {
mid := 0.5 * (xl + xr)
fm := bisect(mid)
if math.Abs(fm) <= tol || math.Abs(xr-xl) <= 2*base.EpsF*math.Abs(mid) {
break
}
if (fxl < 0) == (fm < 0) {
xl, fxl = mid, fm
} else {
xr = mid
}
}
if len(evals)*2 >= bisectionEvals {
t.Fatalf("Brent used %d evaluations against bisection's %d, want less than half", len(evals), bisectionEvals)
}
t.Logf("Brent %d evaluations, bisection %d for the same tolerance", len(evals), bisectionEvals)
}
// TestFindRootBrentBudgetError pins the bounded iteration count: a run
// that exhausts MaxIterations reports the budget, never a guess.
func TestFindRootBrentBudgetError(t *testing.T) {
f := func(x float64) float64 { return x*x*x - 2*x - 5 }
_, err := FindRootBrent(f, 1, 3, BrentOptions{MaxIterations: 2})
if err == nil || !strings.Contains(err.Error(), "no convergence in 2 iterations") {
t.Fatalf("err = %v, want the budget refusal", err)
}
}
// TestFindRootBrentPoleInsideBracket pins the mid-run guard: a pole
// between the endpoints mimics a sign change, and the first bisection
// of [1, 3] for 1/(x − 2) lands exactly on the pole, whose infinite
// value is an error naming the point.
func TestFindRootBrentPoleInsideBracket(t *testing.T) {
f := func(x float64) float64 { return 1 / (x - 2) }
_, err := FindRootBrent(f, 1, 3, BrentOptions{})
if err == nil || !strings.Contains(err.Error(), "left the real numbers") {
t.Fatalf("err = %v, want the non-finite refusal", err)
}
}
// TestFindRootBrentNoAllocationsInLoop pins the allocation discipline:
// the iteration is scalar bookkeeping over the callback, and a
// converged run allocates nothing.
func TestFindRootBrentNoAllocationsInLoop(t *testing.T) {
f := func(x float64) float64 { return x*x*x - 2*x - 5 }
var err error
allocs := testing.AllocsPerRun(20, func() {
_, err = FindRootBrent(f, 1, 3, BrentOptions{})
})
if err != nil {
t.Fatalf("FindRootBrent: %v", err)
}
if allocs != 0 {
t.Fatalf("%g allocations per run, want 0", allocs)
}
}