Files

151 lines
5.8 KiB
Go
Raw Permalink Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package optim
import (
"math"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// constrainedBowl returns the separable bowl shifted to the given
// centre, the standard workhorse for verifying constraint handling:
// the unconstrained answer is known and the constrained one is a
// projection of it that admits an analytic check.
func constrainedBowl(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
}
}
// TestConstrainedEquality drives the augmented Lagrangian through an
// equality hyperplane that cuts the bowl's minimum: the constrained
// optimum is the projection of the unconstrained one onto the plane.
func TestConstrainedEquality(t *testing.T) {
// min (x-1)² + (y-1)² subject to x + y = 2: the plane passes
// through the unconstrained minimum, so the answer is (1, 1) with
// value 0 and the multiplier converges to zero.
A, _ := core.FromFloats([]float64{1, 1}, 1, 2)
point, value, err := MinimiseConstrained(constrainedBowl(1, 1), nil,
mustLin(t, []float64{5, -3}), LinearConstraints{A: A, Lower: []float64{2}, Upper: []float64{2}},
LBFGSOptions{Tolerance: 1e-10})
if err != nil {
t.Fatalf("MinimiseConstrained: %v", err)
}
if math.Abs(point.FloatAt(0)-1) > 1e-4 || math.Abs(point.FloatAt(1)-1) > 1e-4 {
t.Fatalf("point = (%.8g, %.8g), want (1, 1)", point.FloatAt(0), point.FloatAt(1))
}
if value > 1e-6 {
t.Fatalf("value = %.8g, want 0", value)
}
}
// TestConstrainedInequality forces an active inequality: the bowl's
// minimum at (2, -1) lies beyond x + y = 0, so the constrained
// optimum sits on the wall at (1.5, -1.5) with value 0.5.
func TestConstrainedInequality(t *testing.T) {
A, _ := core.FromFloats([]float64{1, 1}, 1, 2)
gradFn := func(p *core.Array) (*core.Array, error) {
out := core.New(core.Float, 2)
out.RawFloats()[0] = 2 * (p.FloatAt(0) - 2)
out.RawFloats()[1] = 2 * (p.FloatAt(1) + 1)
return out, nil
}
for _, grad := range []func(*core.Array) (*core.Array, error){nil, gradFn} {
point, value, err := MinimiseConstrained(constrainedBowl(2, -1), grad,
mustLin(t, []float64{4, 4}), LinearConstraints{A: A, Lower: []float64{math.Inf(-1)}, Upper: []float64{0}},
LBFGSOptions{Tolerance: 1e-10})
if err != nil {
t.Fatalf("MinimiseConstrained: %v", err)
}
if math.Abs(point.FloatAt(0)-1.5) > 1e-4 || math.Abs(point.FloatAt(1)+1.5) > 1e-4 {
t.Fatalf("point = (%.8g, %.8g), want (1.5, -1.5)", point.FloatAt(0), point.FloatAt(1))
}
if math.Abs(value-0.5) > 1e-6 {
t.Fatalf("value = %.8g, want 0.5", value)
}
}
}
// TestConstrainedTwoSided clamps the bowl between two parallel walls:
// 1 ≤ x ≤ 2 drags the first coordinate to the upper wall and leaves
// the second coordinate free.
func TestConstrainedTwoSided(t *testing.T) {
A, _ := core.FromFloats([]float64{1, 0}, 1, 2)
point, _, err := MinimiseConstrained(constrainedBowl(3, 1), nil,
mustLin(t, []float64{0, 0}), LinearConstraints{A: A, Lower: []float64{1}, Upper: []float64{2}},
LBFGSOptions{})
if err != nil {
t.Fatalf("MinimiseConstrained: %v", err)
}
if math.Abs(point.FloatAt(0)-2) > 1e-4 {
t.Fatalf("first coordinate = %.8g, want 2 on the upper wall", point.FloatAt(0))
}
if math.Abs(point.FloatAt(1)-1) > 1e-4 {
t.Fatalf("second coordinate = %.8g, want 1", point.FloatAt(1))
}
}
// TestConstrainedWithBox composes linear rows with the box walls from
// the options: x + y = 0 pins the pair to a line and y ≥ -0.5 cuts
// the line at x = 0.5. The bowl around (1, 0) then bottoms out at
// (0.5, -0.5) with value 0.5, both terms contributing equally.
func TestConstrainedWithBox(t *testing.T) {
A, _ := core.FromFloats([]float64{1, 1}, 1, 2)
point, value, err := MinimiseConstrained(constrainedBowl(1, 0), nil,
mustLin(t, []float64{2, 2}),
LinearConstraints{A: A, Lower: []float64{0}, Upper: []float64{0}},
LBFGSOptions{Tolerance: 1e-10, Lower: []float64{math.Inf(-1), -0.5}})
if err != nil {
t.Fatalf("MinimiseConstrained: %v", err)
}
if math.Abs(point.FloatAt(0)-0.5) > 1e-4 || math.Abs(point.FloatAt(1)+0.5) > 1e-4 {
t.Fatalf("point = (%.8g, %.8g), want (0.5, -0.5)", point.FloatAt(0), point.FloatAt(1))
}
if math.Abs(value-0.5) > 1e-5 {
t.Fatalf("value = %.8g, want 0.5", value)
}
}
// TestConstrainedRefusals checks the loud rejections: nil matrix,
// wrong shape, bound or coefficient nonsense.
func TestConstrainedRefusals(t *testing.T) {
start := mustLin(t, []float64{0, 0})
A, _ := core.FromFloats([]float64{1, 1}, 1, 2)
cases := []struct {
name string
cons LinearConstraints
}{
{"nil matrix", LinearConstraints{Lower: []float64{0}, Upper: []float64{1}}},
{"wrong shape", LinearConstraints{A: mustLin(t, []float64{1, 1}), Lower: []float64{0}, Upper: []float64{1}}},
{"short bounds", LinearConstraints{A: A, Lower: []float64{0}, Upper: []float64{}}},
{"crossed bounds", LinearConstraints{A: A, Lower: []float64{1}, Upper: []float64{0}}},
}
for _, c := range cases {
if _, _, err := MinimiseConstrained(constrainedBowl(0, 0), nil, start, c.cons, LBFGSOptions{}); err == nil {
t.Fatalf("%s accepted", c.name)
}
}
nan, _ := core.FromFloats([]float64{math.NaN(), 1}, 1, 2)
if _, _, err := MinimiseConstrained(constrainedBowl(0, 0), nil, start,
LinearConstraints{A: nan, Lower: []float64{0}, Upper: []float64{1}}, LBFGSOptions{}); err == nil {
t.Fatal("NaN coefficient accepted")
}
}
// mustLin builds a float vector for the constraint tests.
func mustLin(t *testing.T, vals []float64, shape ...int) *core.Array {
t.Helper()
if len(shape) == 0 {
shape = []int{len(vals)}
}
a, err := core.FromFloats(vals, shape...)
if err != nil {
t.Fatalf("FromFloats(%v, %v): %v", vals, shape, err)
}
return a
}