// Copyright (c) 2026 Petr Balvín (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) } }