Files
tensor/optim/linesearch_pins_test.go
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

651 lines
24 KiB
Go
Raw Permalink 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/core"
)
// Line-search and convergence-exit pins for the optimisers. Each test
// names the defect it pins; the numeric fixtures are hand-derived
// optima.
// stiffQuadratic is f = k·(x−3)², whose minimum is x = 3 with value 0.
func stiffQuadratic(k float64) func(*core.Array) (float64, error) {
return func(p *core.Array) (float64, error) {
d := p.FloatAt(0) - 3
return k * d * d, nil
}
}
// TestLBFGSLineSearchStiffQuadraticConverges pins the line-search
// budget: the step that reduces a quadratic is ≈ 1/L for a curvature
// L, so with 20 halvings from a unit step the first trial is never
// acceptable once L ≳ 1e6 and L-BFGS used to return the start point as
// a converged answer (x = 0, value 9k).
func TestLBFGSLineSearchStiffQuadraticConverges(t *testing.T) {
for _, k := range []float64{1e6, 1e8, 1e12} {
point, value, err := MinimiseLBFGS(stiffQuadratic(k), nil, mustFloats(t, []float64{0}), LBFGSOptions{})
if err != nil {
t.Errorf("k=%g: MinimiseLBFGS: %v", k, err)
continue
}
if got := point.FloatAt(0); math.Abs(got-3) > 1e-6 {
t.Errorf("k=%g: x = %.12g, want 3", k, got)
}
if value > 1e-6 {
t.Errorf("k=%g: value = %.12g, want 0", k, value)
}
}
}
// TestLBFGSLineSearchMixedUnitsConverges is the same failure through an
// ordinary two-parameter fit: the y coordinate's curvature sets the
// first step and takes x down with it when the step is capped at a
// unit. The optimum of (x−3)² + K·(y−5)² is (3, 5) with value 0.
func TestLBFGSLineSearchMixedUnitsConverges(t *testing.T) {
for _, k := range []float64{1e6, 1e8} {
f := func(p *core.Array) (float64, error) {
dx, dy := p.FloatAt(0)-3, p.FloatAt(1)-5
return dx*dx + k*dy*dy, nil
}
grad := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{2 * (p.FloatAt(0) - 3), 2 * k * (p.FloatAt(1) - 5)}, 2)
}
for _, g := range []struct {
name string
fn func(*core.Array) (*core.Array, error)
}{{"finite differences", nil}, {"analytic gradient", grad}} {
point, value, err := MinimiseLBFGS(f, g.fn, mustFloats(t, []float64{0, 0}, 2), LBFGSOptions{})
if err != nil {
t.Errorf("K=%g (%s): %v", k, g.name, err)
continue
}
if math.Abs(point.FloatAt(0)-3) > 1e-6 || math.Abs(point.FloatAt(1)-5) > 1e-6 {
t.Errorf("K=%g (%s): point = (%.12g, %.12g), want (3, 5)",
k, g.name, point.FloatAt(0), point.FloatAt(1))
}
if value > 1e-6 {
t.Errorf("K=%g (%s): value = %.12g, want 0", k, g.name, value)
}
}
}
}
// TestLBFGSLineSearchStallIsAnError pins the silence: an exhausted
// line search used to break out of the iteration and hand the start
// point back as converged. The objective here is flat below zero and
// jumps at it, so the finite-difference stencil just below the jump
// reports a large gradient while no step along it can reduce the
// value: the search stalls and must say so.
func TestLBFGSLineSearchStallIsAnError(t *testing.T) {
f := func(p *core.Array) (float64, error) {
if p.FloatAt(0) >= 0 {
return 1, nil
}
return 0, nil
}
start := mustFloats(t, []float64{-1e-9})
point, value, err := MinimiseLBFGS(f, nil, start, LBFGSOptions{})
if err == nil {
t.Fatalf("a stalled line search was reported as convergence: point = %v, value = %g",
floatsOf(point), value)
}
if !strings.Contains(err.Error(), "line search") {
t.Fatalf("error = %v, want a line-search refusal", err)
}
}
// TestLBFGSBoxOptimaFixtures checks the box-constrained answers against
// hand-derived optima: (x−3)²+(y+2)² on −1 ≤ x ≤ 1, y free reaches the
// upper wall at (1, −2) with value 4; (x+5)²+(y−5)² on 0 ≤ x, y ≤ 2
// pins both coordinates at (0, 2) with value 34; (x+y−3)²+x² on
// x, y ≥ 1 bottoms out at the corner (1, 2) with value 1.
func TestLBFGSBoxOptimaFixtures(t *testing.T) {
inf := math.Inf(1)
cases := []struct {
name string
f func(*core.Array) (float64, error)
lower []float64
upper []float64
wantX []float64
wantValue float64
}{
{
name: "upper wall, y free",
f: func(p *core.Array) (float64, error) {
return (p.FloatAt(0)-3)*(p.FloatAt(0)-3) + (p.FloatAt(1)+2)*(p.FloatAt(1)+2), nil
},
lower: []float64{-1, -inf}, upper: []float64{1, inf},
wantX: []float64{1, -2}, wantValue: 4,
},
{
name: "both coordinates pinned",
f: func(p *core.Array) (float64, error) {
return (p.FloatAt(0)+5)*(p.FloatAt(0)+5) + (p.FloatAt(1)-5)*(p.FloatAt(1)-5), nil
},
lower: []float64{0, 0}, upper: []float64{inf, 2},
wantX: []float64{0, 2}, wantValue: 34,
},
{
name: "coupled bowl at the corner",
f: func(p *core.Array) (float64, error) {
x, y := p.FloatAt(0), p.FloatAt(1)
return (x+y-3)*(x+y-3) + x*x, nil
},
lower: []float64{1, 1}, upper: []float64{inf, inf},
wantX: []float64{1, 2}, wantValue: 1,
},
}
for _, c := range cases {
point, value, err := MinimiseLBFGS(c.f, nil, mustFloats(t, []float64{0, 0}, 2),
LBFGSOptions{Lower: c.lower, Upper: c.upper})
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
for i, want := range c.wantX {
if math.Abs(point.FloatAt(i)-want) > 1e-4 {
t.Errorf("%s: x[%d] = %.12g, want %.12g", c.name, i, point.FloatAt(i), want)
}
}
if math.Abs(value-c.wantValue) > 1e-6 {
t.Errorf("%s: value = %.12g, want %.12g", c.name, value, c.wantValue)
}
}
}
// TestLBFGSProjectionOntoBindingWall pins the box projection with a
// box that actually binds: (x−3)² on x ≤ 1 has its constrained minimum
// on the wall at x = 1 with value 4, and a start above the wall is
// projected onto it rather than refused.
func TestLBFGSProjectionOntoBindingWall(t *testing.T) {
f := func(p *core.Array) (float64, error) {
d := p.FloatAt(0) - 3
return d * d, nil
}
for _, start := range []float64{0, 5, -10} {
point, value, err := MinimiseLBFGS(f, nil, mustFloats(t, []float64{start}),
LBFGSOptions{Upper: []float64{1}})
if err != nil {
t.Fatalf("start %g: %v", start, err)
}
if math.Abs(point.FloatAt(0)-1) > 1e-6 {
t.Errorf("start %g: x = %.12g, want 1 on the wall", start, point.FloatAt(0))
}
if math.Abs(value-4) > 1e-6 {
t.Errorf("start %g: value = %.12g, want 4", start, value)
}
}
}
// TestMinimiseLevelSetStallIsNotConvergence pins the level-set trap: the
// value spread was the only convergence test, so a simplex whose
// vertices happened to lie on one level set stopped while spanning the
// space.
// For (x−1)² + (y−2)² from (0, 0) all three vertices of the stalled
// simplex sit on the circle of radius √0.5 about (1, 2), so the
// spread is zero and the answer used to be (1.5, 1.5) with value 0.5.
func TestMinimiseLevelSetStallIsNotConvergence(t *testing.T) {
f := func(p *core.Array) (float64, error) {
dx, dy := p.FloatAt(0)-1, p.FloatAt(1)-2
return dx*dx + dy*dy, nil
}
start := mustFloats(t, []float64{0, 0}, 2)
point, value, err := Minimise(f, start, MinimiseOptions{})
if err != nil {
t.Fatalf("Minimise: %v", err)
}
if math.Abs(point.FloatAt(0)-1) > 1e-4 || math.Abs(point.FloatAt(1)-2) > 1e-4 {
t.Fatalf("point = (%v), want (1, 2)", floatsOf(point))
}
if value > 1e-8 {
t.Fatalf("value = %g, want 0", value)
}
// Neither a larger budget nor a tighter tolerance may rescue the
// stall: the loop exits at the top-of-loop test either way.
for _, opts := range []MinimiseOptions{
{MaxIterations: 20000},
{Tolerance: 1e-20},
} {
point, value, err := Minimise(f, start, opts)
if err != nil {
t.Fatalf("%+v: %v", opts, err)
}
if value > 1e-8 {
t.Fatalf("%+v: value = %g at (%v), want 0 at (1, 2)", opts, value, floatsOf(point))
}
}
}
// TestMinimiseHitRateOnShiftedBowls sweeps starts over a grid: every
// one of them must find the minimum of the axis-aligned bowl, the
// rotated bowl and the 3-D sphere. The level-set stall used to return
// a non-minimal point for 5 of 49 axis-bowl starts and 1 of 49 rotated
// starts with the default options.
func TestMinimiseHitRateOnShiftedBowls(t *testing.T) {
bowls := []struct {
name string
f func(*core.Array) (float64, error)
dim int
}{
{"axis bowl", func(p *core.Array) (float64, error) {
dx, dy := p.FloatAt(0)-1, p.FloatAt(1)-2
return dx*dx + dy*dy, nil
}, 2},
{"rotated bowl", func(p *core.Array) (float64, error) {
u := (p.FloatAt(0) - 1) + (p.FloatAt(1) - 2)
v := (p.FloatAt(0) - 1) - (p.FloatAt(1) - 2)
return u*u + 3*v*v, nil
}, 2},
{"3-D sphere", func(p *core.Array) (float64, error) {
s := 0.0
for i, c := range []float64{1, 2, 3} {
d := p.FloatAt(i) - c
s += d * d
}
return s, nil
}, 3},
}
for _, b := range bowls {
for x := -3.0; x <= 3; x++ {
for y := -3.0; y <= 3; y++ {
start := []float64{x, y}
if b.dim == 3 {
start = []float64{x, y, x - y}
}
point, value, err := Minimise(b.f, mustFloats(t, start, b.dim), MinimiseOptions{})
if err != nil {
t.Fatalf("%s from %v: %v", b.name, start, err)
}
if value > 1e-6 {
t.Errorf("%s from %v: value = %g at (%v), want 0", b.name, start, value, floatsOf(point))
}
}
}
}
}
// TestMinimiseToleranceScaleIsDocumented pins the documented absolute
// tolerance: an objective whose values are ~1e-14 already counts as
// flat (the default spread test is 1e-10·max(1, |f|)), so Minimise
// reports the start point and a nil error, and rescaling the objective
// to O(1), the documented remedy, resolves the minimum (1, 2).
func TestMinimiseToleranceScaleIsDocumented(t *testing.T) {
const scale = 1e-14
tiny := func(p *core.Array) (float64, error) {
dx, dy := p.FloatAt(0)-1, p.FloatAt(1)-2
return scale * (dx*dx + dy*dy), nil
}
start := mustFloats(t, []float64{0, 0}, 2)
point, value, err := Minimise(tiny, start, MinimiseOptions{})
if err != nil {
t.Fatalf("Minimise: %v", err)
}
// The start's own value is 5e-14; the run stops on the value spread
// long before the minimum is reached, which the documentation now
// warns about.
if math.Abs(point.FloatAt(0)-1) <= 0.1 || math.Abs(point.FloatAt(1)-2) <= 0.1 {
t.Fatalf("tiny objective: point = (%v), the documented absolute tolerance claims (1, 2) is left unfound",
floatsOf(point))
}
if value > 5e-14 {
t.Fatalf("tiny objective: value = %g, want a value no larger than the start's 5e-14", value)
}
unit := func(p *core.Array) (float64, error) {
dx, dy := p.FloatAt(0)-1, p.FloatAt(1)-2
return dx*dx + dy*dy, nil
}
point, value, err = Minimise(unit, start, MinimiseOptions{})
if err != nil {
t.Fatalf("Minimise rescaled: %v", err)
}
if math.Abs(point.FloatAt(0)-1) > 1e-4 || math.Abs(point.FloatAt(1)-2) > 1e-4 || value > 1e-8 {
t.Fatalf("rescaled objective: point = (%v), value = %g, want (1, 2) and 0", floatsOf(point), value)
}
}
// TestMinimiseConstrainedComplexMatrixRefused pins the dtype guard on
// the constraint matrix: a complex A used to reach FloatAt and panic
// with an indexing error instead of the family's dtype refusal.
func TestMinimiseConstrainedComplexMatrixRefused(t *testing.T) {
A, err := core.FromComplexes([]complex128{1, 1}, 1, 2)
if err != nil {
t.Fatal(err)
}
start := mustFloats(t, []float64{5, -3}, 2)
if _, _, err := MinimiseConstrained(bowlAt(1, 2), nil, start,
LinearConstraints{A: A, Lower: []float64{3}, Upper: []float64{3}}, LBFGSOptions{}); err == nil {
t.Fatal("expected a complex constraint matrix to be refused")
} else if !strings.Contains(err.Error(), "complex") {
t.Fatalf("error = %v, want a complex-input refusal", err)
}
}
// TestMinimiseConstrainedComplexGradientRefused pins the callback guard
// in the constrained wrapper: a complex gradient used to be sliced with
// RawFloats (nil for a complex payload) and panic.
func TestMinimiseConstrainedComplexGradientRefused(t *testing.T) {
grad := func(*core.Array) (*core.Array, error) {
g, err := core.FromComplexes([]complex128{1, 1}, 2)
return g, err
}
A, err := core.FromFloats([]float64{1, 1}, 1, 2)
if err != nil {
t.Fatal(err)
}
start := mustFloats(t, []float64{5, -3}, 2)
if _, _, err := MinimiseConstrained(bowlAt(1, 2), grad, start,
LinearConstraints{A: A, Lower: []float64{3}, Upper: []float64{3}}, LBFGSOptions{}); err == nil {
t.Fatal("expected a complex gradient to be refused")
} else if !strings.Contains(err.Error(), "complex") {
t.Fatalf("error = %v, want a complex-input refusal", err)
}
}
// TestMinimiseConstrainedShortGradientRefused pins the length contract
// in the constrained wrapper: a gradient one element short used to
// panic in the slice before MinimiseLBFGS could report it.
func TestMinimiseConstrainedShortGradientRefused(t *testing.T) {
grad := func(*core.Array) (*core.Array, error) { return mustFloats(t, []float64{1}), nil }
A, err := core.FromFloats([]float64{1, 1}, 1, 2)
if err != nil {
t.Fatal(err)
}
start := mustFloats(t, []float64{5, -3}, 2)
if _, _, err := MinimiseConstrained(bowlAt(1, 2), grad, start,
LinearConstraints{A: A, Lower: []float64{3}, Upper: []float64{3}}, LBFGSOptions{}); err == nil {
t.Fatal("expected a short gradient to be refused")
} else if !strings.Contains(err.Error(), "gradient callback") {
t.Fatalf("error = %v, want a callback-length refusal", err)
}
}
// TestLBFGSComplexGradientRefused pins the dtype guard on the L-BFGS
// gradient callback, which used to dereference the nil int payload of a
// complex array.
func TestLBFGSComplexGradientRefused(t *testing.T) {
grad := func(*core.Array) (*core.Array, error) {
g, err := core.FromComplexes([]complex128{1, 1}, 2)
return g, err
}
if _, _, err := MinimiseLBFGS(bowlAt(1, 2), grad, mustFloats(t, []float64{5, -3}, 2), LBFGSOptions{}); err == nil {
t.Fatal("expected a complex gradient to be refused")
} else if !strings.Contains(err.Error(), "complex") {
t.Fatalf("error = %v, want a complex-input refusal", err)
}
}
// TestLevenbergMarquardtComplexPayloadRefused pins both callback guards
// of the LM fitter: a complex residual and a complex analytic Jacobian
// used to panic in FloatAt.
func TestLevenbergMarquardtComplexPayloadRefused(t *testing.T) {
p0 := mustFloats(t, []float64{0, 0}, 2)
complexResidual := func(*core.Array) (*core.Array, error) {
r, err := core.FromComplexes([]complex128{1, 2, 3, 4}, 4)
return r, err
}
if _, _, err := LevenbergMarquardt(complexResidual, p0, LMOptions{}); err == nil {
t.Fatal("expected a complex residual to be refused")
} else if !strings.Contains(err.Error(), "complex") {
t.Fatalf("residual: error = %v, want a complex-input refusal", err)
}
residual := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{p.FloatAt(0) - 1, p.FloatAt(1) - 2}, 2)
}
complexJacobian := func(*core.Array) (*core.Array, error) {
j, err := core.FromComplexes([]complex128{1, 0, 0, 1}, 2, 2)
return j, err
}
if _, _, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: complexJacobian}); err == nil {
t.Fatal("expected a complex Jacobian to be refused")
} else if !strings.Contains(err.Error(), "complex") {
t.Fatalf("Jacobian: error = %v, want a complex-input refusal", err)
}
}
// TestFindRootSystemComplexResidualRefused pins the dtype guard on the
// root-system residual, which used to dereference the nil int payload
// of a complex array.
func TestFindRootSystemComplexResidualRefused(t *testing.T) {
f := func(*core.Array) (*core.Array, error) {
r, err := core.FromComplexes([]complex128{1, 2}, 2)
return r, err
}
if _, _, err := FindRootSystem(f, mustFloats(t, []float64{1, 1}, 2), RootSystemOptions{}); err == nil {
t.Fatal("expected a complex residual to be refused")
} else if !strings.Contains(err.Error(), "complex") {
t.Fatalf("error = %v, want a complex-input refusal", err)
}
}
// TestLevenbergMarquardtChi2MatchesReturnedPoint pins the reported fit
// quality: the relative-improvement break published the new, lower χ²
// while the parameters were still the old ones, so the answer looked
// 99.9 % better than the point that came back.
func TestLevenbergMarquardtChi2MatchesReturnedPoint(t *testing.T) {
xs := []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
ys := make([]float64, len(xs))
for i, x := range xs {
ys[i] = 3*math.Exp(-0.5*x) + 0.5 + 0.001*math.Sin(7*x)
}
residual := func(p *core.Array) (*core.Array, error) {
a, b, c := p.FloatAt(0), p.FloatAt(1), p.FloatAt(2)
out := core.New(core.Float, len(xs))
for i := range xs {
out.RawFloats()[i] = ys[i] - (a*math.Exp(-b*xs[i]) + c)
}
return out, nil
}
point, chi2, err := LevenbergMarquardt(residual, mustFloats(t, []float64{2, 0.3, 0.1}, 3),
LMOptions{Tolerance: 0.05})
if err != nil {
t.Fatalf("LevenbergMarquardt: %v", err)
}
r, err := residual(point)
if err != nil {
t.Fatal(err)
}
actual := 0.0
for i := range r.Len() {
actual += r.FloatAt(i) * r.FloatAt(i)
}
if math.Abs(chi2-actual) > 1e-9*math.Max(1, actual) {
t.Fatalf("reported χ² = %.14g, χ² at the returned point = %.14g", chi2, actual)
}
}
// TestMinimiseConstrainedExactFixtures checks the augmented Lagrangian
// against hand-derived optima: min (x−1)² + (y−2)² subject to x+y = 5
// projects to (2, 3) with value 2; min (x−10)² + (y−10)² subject to
// x+y = 1 and x ≤ −4 bottoms out at (−4, 5) with value 221; and the
// degenerate row min x+3 subject to x = 1 reaches 4.
func TestMinimiseConstrainedExactFixtures(t *testing.T) {
bowlPeak, err := core.FromFloats([]float64{1, 1}, 1, 2)
if err != nil {
t.Fatal(err)
}
boxCut, err := core.FromFloats([]float64{1, 1}, 1, 2)
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
f func(*core.Array) (float64, error)
A *core.Array
lower []float64
upper []float64
boxUpper []float64
start []float64
wantX []float64
wantValue float64
}{
{
name: "equality cuts the unconstrained minimum",
f: func(p *core.Array) (float64, error) {
dx, dy := p.FloatAt(0)-1, p.FloatAt(1)-2
return dx*dx + dy*dy, nil
},
A: bowlPeak,
lower: []float64{5}, upper: []float64{5},
start: []float64{0, 0}, wantX: []float64{2, 3}, wantValue: 2,
},
{
name: "row cut by a box wall",
f: func(p *core.Array) (float64, error) {
dx, dy := p.FloatAt(0)-10, p.FloatAt(1)-10
return dx*dx + dy*dy, nil
},
A: boxCut,
lower: []float64{1}, upper: []float64{1},
boxUpper: []float64{-4, math.Inf(1)},
start: []float64{0, 0}, wantX: []float64{-4, 5}, wantValue: 221,
},
}
for _, c := range cases {
point, value, err := MinimiseConstrained(c.f, nil, mustFloats(t, c.start, len(c.start)),
LinearConstraints{A: c.A, Lower: c.lower, Upper: c.upper},
LBFGSOptions{Tolerance: 1e-10, Upper: c.boxUpper})
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
for i, want := range c.wantX {
if math.Abs(point.FloatAt(i)-want) > 1e-4 {
t.Errorf("%s: x[%d] = %.12g, want %.12g", c.name, i, point.FloatAt(i), want)
}
}
if math.Abs(value-c.wantValue) > 1e-4*math.Max(1, c.wantValue) {
t.Errorf("%s: value = %.12g, want %.12g", c.name, value, c.wantValue)
}
}
}
// TestMinimiseConstrainedBadlyScaledRow pins the badly scaled row: min
// x² + y² subject to a·x + y = 1 has the closed form x = a/(a²+1),
// y = 1/(a²+1) with value 1/(a²+1). The row keeps the caller's scale,
// so the penalty's gradient at the start is mu·a and its curvature
// mu·a², which put the step that reduces the augmented Lagrangian
// below the old 20-halving line search for every a ≥ 1000: the inner
// solve took the silent stall exit and the outer loop reported "40
// rounds left the worst row violation at 1" without moving. The rows
// the long line search can reach must now reach the closed form; rows
// beyond its reach must be refused with the stall diagnostic, never
// returned as a converged answer.
func TestMinimiseConstrainedBadlyScaledRow(t *testing.T) {
f := func(p *core.Array) (float64, error) {
return p.FloatAt(0)*p.FloatAt(0) + p.FloatAt(1)*p.FloatAt(1), nil
}
rowResidual := func(a float64, point *core.Array) float64 {
return math.Abs(a*point.FloatAt(0) + point.FloatAt(1) - 1)
}
for _, a := range []float64{1e3, 1e4, 1e6, 1e8} {
A, err := core.FromFloats([]float64{a, 1}, 1, 2)
if err != nil {
t.Fatal(err)
}
point, value, err := MinimiseConstrained(f, nil, mustFloats(t, []float64{0, 0}, 2),
LinearConstraints{A: A, Lower: []float64{1}, Upper: []float64{1}}, LBFGSOptions{})
if err != nil {
t.Errorf("a=%g: %v", a, err)
continue
}
wantX, wantY, wantValue := a/(a*a+1), 1/(a*a+1), 1/(a*a+1)
if got := point.FloatAt(0); math.Abs(got-wantX) > 1e-6*wantX {
t.Errorf("a=%g: x = %.12g, want %.12g", a, got, wantX)
}
if got := point.FloatAt(1); math.Abs(got-wantY) > 1e-6*wantY {
t.Errorf("a=%g: y = %.12g, want %.12g", a, got, wantY)
}
if math.Abs(value-wantValue) > 1e-6*wantValue {
t.Errorf("a=%g: value = %.12g, want %.12g", a, value, wantValue)
}
if res := rowResidual(a, point); res > 1e-6 {
t.Errorf("a=%g: row residual = %g, want ≤ 1e-6", a, res)
}
}
// Past the line search's reach the answer is an error, not the start
// point dressed as convergence: a returned point must satisfy the
// row it claims to solve.
for _, a := range []float64{1e9, 1e12} {
A, err := core.FromFloats([]float64{a, 1}, 1, 2)
if err != nil {
t.Fatal(err)
}
point, _, err := MinimiseConstrained(f, nil, mustFloats(t, []float64{0, 0}, 2),
LinearConstraints{A: A, Lower: []float64{1}, Upper: []float64{1}}, LBFGSOptions{})
if err != nil {
if !strings.Contains(err.Error(), "line search") {
t.Errorf("a=%g: error = %v, want the inner line search's stall diagnostic", a, err)
}
continue
}
if res := rowResidual(a, point); res > 1e-6 {
t.Errorf("a=%g: returned (%v) as converged with a row residual of %g, want the stall reported",
a, floatsOf(point), res)
}
}
}
// TestMinimiseConstrainedToleranceIsNotTheFeasibilityThreshold pins the
// decoupling: opts.Tolerance is the inner solver's projected-gradient
// tolerance and used to double as the absolute row-feasibility
// threshold (feasibleAt = max(Tolerance, 1e-10)), so a looser inner
// solve bought a looser row. For min (x−3)² + y² subject to 1 ≤ x ≤ 2
// (answer (2, 0), value 1) the old coupling returned (2.0033, 0) at
// Tolerance = 1e-2, a row violation of 3.3e-3, and a tolerance below
// 1e-10 hard-failed the solve with "40 rounds left the worst row
// violation at 1.3e-9". The row is now judged against the fixed 1e-10
// at every inner tolerance.
func TestMinimiseConstrainedToleranceIsNotTheFeasibilityThreshold(t *testing.T) {
f := func(p *core.Array) (float64, error) {
dx := p.FloatAt(0) - 3
return dx*dx + p.FloatAt(1)*p.FloatAt(1), nil
}
A, err := core.FromFloats([]float64{1, 0}, 1, 2)
if err != nil {
t.Fatal(err)
}
for _, tol := range []float64{0, 1e-8, 1e-10, 1e-12, 1e-4, 1e-2} {
point, value, err := MinimiseConstrained(f, nil, mustFloats(t, []float64{0, 0}, 2),
LinearConstraints{A: A, Lower: []float64{1}, Upper: []float64{2}},
LBFGSOptions{Tolerance: tol})
if err != nil {
t.Errorf("Tolerance=%g: %v", tol, err)
continue
}
if math.Abs(point.FloatAt(0)-2) > 1e-6 || math.Abs(point.FloatAt(1)) > 1e-6 {
t.Errorf("Tolerance=%g: point = (%v), want (2, 0)", tol, floatsOf(point))
}
if violation := math.Abs(point.FloatAt(0) - 2); violation > 1e-8 {
t.Errorf("Tolerance=%g: row violation = %g, want ≤ 1e-8", tol, violation)
}
if math.Abs(value-1) > 1e-6 {
t.Errorf("Tolerance=%g: value = %.12g, want 1", tol, value)
}
}
}
// bowlAt returns the separable bowl centred at (cx, cy), the fixture
// the constrained tests share.
func bowlAt(cx, cy float64) func(*core.Array) (float64, error) {
return func(p *core.Array) (float64, error) {
dx, dy := p.FloatAt(0)-cx, p.FloatAt(1)-cy
return dx*dx + dy*dy, nil
}
}
// floatsOf copies an array's values out for readable failure messages.
func floatsOf(a *core.Array) []float64 {
out := make([]float64, a.Len())
for i := range out {
out[i] = a.FloatAt(i)
}
return out
}