feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
// 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"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// rotEllipsoid builds the test landscape: f(x) = Σ i·(Rx)ᵢ² for a
|
||||
// fixed orthonormal R (the Householder reflection through the
|
||||
// normalised all-ones vector), a rotated ellipsoid with condition 6ⁿ
|
||||
// along axes the coordinate system does not see. It returns the
|
||||
// objective and the apply function so tests can check coordinates.
|
||||
func rotEllipsoid(t *testing.T, n int) (func(*core.Array) (float64, error), func(x []float64) []float64) {
|
||||
t.Helper()
|
||||
v := make([]float64, n)
|
||||
for i := range n {
|
||||
v[i] = float64(i + 1)
|
||||
}
|
||||
norm := 0.0
|
||||
for _, a := range v {
|
||||
norm += a * a
|
||||
}
|
||||
norm = math.Sqrt(norm)
|
||||
apply := func(x []float64) []float64 {
|
||||
dot := 0.0
|
||||
for i := range n {
|
||||
dot += v[i] * x[i]
|
||||
}
|
||||
dot *= 2 / (norm * norm)
|
||||
rx := make([]float64, n)
|
||||
for i := range n {
|
||||
rx[i] = x[i] - dot*v[i]
|
||||
}
|
||||
return rx
|
||||
}
|
||||
f := func(a *core.Array) (float64, error) {
|
||||
total := 0.0
|
||||
for i, rx := range apply(floatsOf(a)) {
|
||||
total += float64(i+1) * rx * rx
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
return f, apply
|
||||
}
|
||||
|
||||
// TestJacobiEigen pins the eigendecomposition the CMA-ES loop leans
|
||||
// on: a known symmetric matrix with irrational eigenvalues, verified
|
||||
// through the reconstruction C = B·D·Bᵀ and the orthogonality of B.
|
||||
func TestJacobiEigen(t *testing.T) {
|
||||
// [[2, 1], [1, 3]]: eigenvalues (5 ± √5)/2.
|
||||
vals, vecs := jacobiEigen([]float64{2, 1, 1, 3}, 2)
|
||||
want1 := (5 + math.Sqrt(5)) / 2
|
||||
want2 := (5 - math.Sqrt(5)) / 2
|
||||
if math.Abs(vals[0]-want1) > 1e-12 || math.Abs(vals[1]-want2) > 1e-12 {
|
||||
t.Fatalf("eigenvalues = (%g, %g), want (%g, %g)", vals[0], vals[1], want1, want2)
|
||||
}
|
||||
// Orthonormality: B·Bᵀ = I.
|
||||
for i := range 2 {
|
||||
for j := range 2 {
|
||||
s := 0.0
|
||||
for k := range 2 {
|
||||
s += vecs[k*2+i] * vecs[k*2+j]
|
||||
}
|
||||
want := 0.0
|
||||
if i == j {
|
||||
want = 1
|
||||
}
|
||||
if math.Abs(s-want) > 1e-12 {
|
||||
t.Fatalf("B·Bᵀ[%d][%d] = %g, want %g", i, j, s, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reconstruction: Σ_j vals[j]·vec_j·vec_jᵀ = C.
|
||||
for i := range 2 {
|
||||
for j := range 2 {
|
||||
s := 0.0
|
||||
for k := range 2 {
|
||||
s += vals[k] * vecs[k*2+i] * vecs[k*2+j]
|
||||
}
|
||||
want := 1.0
|
||||
if i == j {
|
||||
want = float64(2 + i)
|
||||
}
|
||||
if math.Abs(s-want) > 1e-12 {
|
||||
t.Fatalf("reconstruction[%d][%d] = %g, want %g", i, j, s, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinimiseCMAESRotatedEllipsoid pins the strategy on a rotated
|
||||
// ellipsoid, the landscape its covariance adaptation exists for: seed
|
||||
// 7, sigma0 0.5 and a budget of 300 generations carry the run to the
|
||||
// origin within 1e−6 per coordinate and 1e−12 in value.
|
||||
func TestMinimiseCMAESRotatedEllipsoid(t *testing.T) {
|
||||
f, _ := rotEllipsoid(t, 6)
|
||||
start := mustFloats(t, []float64{1, -1, 0.5, 2, 0, -0.5})
|
||||
x, fv, err := MinimiseCMAES(f, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 300})
|
||||
if err != nil {
|
||||
t.Fatalf("MinimiseCMAES: %v", err)
|
||||
}
|
||||
if fv > 1e-12 {
|
||||
t.Fatalf("value = %.3e, want <= 1e-12", fv)
|
||||
}
|
||||
for i := range 6 {
|
||||
if math.Abs(x.FloatAt(i)) > 1e-5 {
|
||||
t.Fatalf("x[%d] = %.3e, want within 1e-5 of the origin", i, x.FloatAt(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinimiseCMAESDeterministic pins reproducibility: two runs on one
|
||||
// seed walk the same landscape with the same draws and must return
|
||||
// bit-identical trajectories.
|
||||
func TestMinimiseCMAESDeterministic(t *testing.T) {
|
||||
f, _ := rotEllipsoid(t, 4)
|
||||
start := mustFloats(t, []float64{0.5, -0.5, 1, -1})
|
||||
// Bit-identical trajectories under one seed: 80 generations do not
|
||||
// exhaust to convergence, so the runs use the escape hatch and the
|
||||
// pin is on the trajectories themselves.
|
||||
xa, fa, errA := MinimiseCMAES(f, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 80, AllowBudgetExit: true})
|
||||
xb, fb, errB := MinimiseCMAES(f, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 80, AllowBudgetExit: true})
|
||||
if errA != nil || errB != nil {
|
||||
t.Fatalf("MinimiseCMAES: %v, %v", errA, errB)
|
||||
}
|
||||
if math.Float64bits(fa) != math.Float64bits(fb) {
|
||||
t.Fatalf("values differ across identical runs: %.20g vs %.20g", fa, fb)
|
||||
}
|
||||
for i := range 4 {
|
||||
if math.Float64bits(xa.FloatAt(i)) != math.Float64bits(xb.FloatAt(i)) {
|
||||
t.Fatalf("x[%d] differs across identical runs", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinimiseCMAESBudgetAndGates pins the honest budget refusal, the
|
||||
// escape hatch, the divergence refusal on an unbounded-below
|
||||
// objective, and the input and objective gates.
|
||||
func TestMinimiseCMAESBudgetAndGates(t *testing.T) {
|
||||
f, _ := rotEllipsoid(t, 4)
|
||||
start := mustFloats(t, []float64{0.5, -0.5, 1, -1})
|
||||
// Two generations cannot collapse the distribution: the budget
|
||||
// stop is refused with the evidence, not reported as an answer.
|
||||
_, _, err := MinimiseCMAES(f, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 2})
|
||||
if err == nil || !strings.Contains(err.Error(), "budget") {
|
||||
t.Fatalf("error = %v, want the budget refusal", err)
|
||||
}
|
||||
// The escape hatch returns the best point with no error.
|
||||
xb, _, err := MinimiseCMAES(f, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 2, AllowBudgetExit: true})
|
||||
if err != nil || xb == nil {
|
||||
t.Fatalf("AllowBudgetExit: err = %v, x = %v", err, xb)
|
||||
}
|
||||
// An objective unbounded below drives sigma past the guard.
|
||||
_, _, err = MinimiseCMAES(func(a *core.Array) (float64, error) {
|
||||
s := 0.0
|
||||
for i := range a.Len() {
|
||||
s += a.FloatAt(i) * a.FloatAt(i)
|
||||
}
|
||||
return -math.Sqrt(s), nil
|
||||
}, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 200})
|
||||
if err == nil || !strings.Contains(err.Error(), "diverged") {
|
||||
t.Fatalf("error = %v, want the divergence refusal", err)
|
||||
}
|
||||
// Empty and complex starts are refused.
|
||||
if _, _, err := MinimiseCMAES(f, core.New(core.Float, 0), CMAESOptions{}); err == nil {
|
||||
t.Fatal("an empty starting point was accepted")
|
||||
}
|
||||
if _, _, err := MinimiseCMAES(f, mustComplexPoint(t), CMAESOptions{}); err == nil {
|
||||
t.Fatal("a complex starting point was accepted")
|
||||
}
|
||||
// A non-finite objective is fatal.
|
||||
if _, _, err := MinimiseCMAES(func(*core.Array) (float64, error) { return math.NaN(), nil },
|
||||
start, CMAESOptions{Seed: 7, Generations: 5}); err == nil {
|
||||
t.Fatal("a NaN objective was accepted")
|
||||
}
|
||||
// An objective's own error propagates.
|
||||
if _, _, err := MinimiseCMAES(func(*core.Array) (float64, error) {
|
||||
return 0, base.Errf("the model exploded")
|
||||
}, start, CMAESOptions{Seed: 7, Generations: 5}); err == nil {
|
||||
t.Fatal("the objective's error did not propagate")
|
||||
}
|
||||
// The defaults carry the run when the caller passes nothing but
|
||||
// the escape hatch: sigma0 0.3, 500 generations, tolerance
|
||||
// 1e-12 and seed 42.
|
||||
_, _, err = MinimiseCMAES(f, start, CMAESOptions{AllowBudgetExit: true})
|
||||
if err != nil {
|
||||
t.Fatalf("MinimiseCMAES with defaults: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinimiseAnnealRotatedEllipsoid pins simulated annealing on the
|
||||
// same rotated ellipsoid at basin precision: 30000 proposals reach the
|
||||
// origin's basin, which for this landscape means every coordinate
|
||||
// within 0.5 and a value below 6.
|
||||
func TestMinimiseAnnealRotatedEllipsoid(t *testing.T) {
|
||||
f, _ := rotEllipsoid(t, 6)
|
||||
start := mustFloats(t, []float64{1, -1, 0.5, 2, 0, -0.5})
|
||||
x, fv, err := MinimiseSimulatedAnnealing(f, start, SimulatedAnnealingOptions{Seed: 7, Steps: 30000})
|
||||
if err != nil {
|
||||
t.Fatalf("MinimiseSimulatedAnnealing: %v", err)
|
||||
}
|
||||
if fv > 6 {
|
||||
t.Fatalf("value = %.3e, want <= 6 (the basin of the origin)", fv)
|
||||
}
|
||||
for i := range 6 {
|
||||
if math.Abs(x.FloatAt(i)) > 0.5 {
|
||||
t.Fatalf("x[%d] = %.3e, want within the unit basin", i, x.FloatAt(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinimiseAnnealDeterministicAndBudget pins reproducibility, the
|
||||
// budget refusal of a chain that is still improving, the escape hatch
|
||||
// and the input gates.
|
||||
func TestMinimiseAnnealDeterministicAndBudget(t *testing.T) {
|
||||
f, _ := rotEllipsoid(t, 4)
|
||||
start := mustFloats(t, []float64{0.5, -0.5, 1, -1})
|
||||
// Bit-identical trajectories under one seed: 500 proposals end
|
||||
// mid-polish, so the runs use the escape hatch and the pin is on
|
||||
// the trajectories themselves.
|
||||
xa, fa, errA := MinimiseSimulatedAnnealing(f, start, SimulatedAnnealingOptions{Seed: 7, Steps: 500, AllowBudgetExit: true})
|
||||
xb, fb, errB := MinimiseSimulatedAnnealing(f, start, SimulatedAnnealingOptions{Seed: 7, Steps: 500, AllowBudgetExit: true})
|
||||
if errA != nil || errB != nil {
|
||||
t.Fatalf("MinimiseSimulatedAnnealing: %v, %v", errA, errB)
|
||||
}
|
||||
if math.Float64bits(fa) != math.Float64bits(fb) {
|
||||
t.Fatalf("values differ across identical runs: %.20g vs %.20g", fa, fb)
|
||||
}
|
||||
for i := range 4 {
|
||||
if math.Float64bits(xa.FloatAt(i)) != math.Float64bits(xb.FloatAt(i)) {
|
||||
t.Fatalf("x[%d] differs across identical runs", i)
|
||||
}
|
||||
}
|
||||
// A linear objective keeps producing new bests to the last
|
||||
// proposal: the schedule ends unfinished and is refused.
|
||||
line := func(a *core.Array) (float64, error) {
|
||||
s := 0.0
|
||||
for i := range a.Len() {
|
||||
s -= a.FloatAt(i)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
_, _, err := MinimiseSimulatedAnnealing(line, start, SimulatedAnnealingOptions{Seed: 7, Steps: 2000, Tolerance: 1e-300})
|
||||
if err == nil || !strings.Contains(err.Error(), "still improving") {
|
||||
t.Fatalf("error = %v, want the unfinished-schedule refusal", err)
|
||||
}
|
||||
// The escape hatch reports the best point anyway.
|
||||
xbest, fv, err := MinimiseSimulatedAnnealing(line, start, SimulatedAnnealingOptions{Seed: 7, Steps: 2000, Tolerance: 1e-300, AllowBudgetExit: true})
|
||||
if err != nil || xbest == nil {
|
||||
t.Fatalf("AllowBudgetExit: err = %v, x = %v", err, xbest)
|
||||
}
|
||||
if fv >= 0 {
|
||||
t.Fatalf("value = %.3e, want the linear objective's negative value", fv)
|
||||
}
|
||||
// Empty and complex starts are refused.
|
||||
if _, _, err := MinimiseSimulatedAnnealing(f, core.New(core.Float, 0), SimulatedAnnealingOptions{}); err == nil {
|
||||
t.Fatal("an empty starting point was accepted")
|
||||
}
|
||||
if _, _, err := MinimiseSimulatedAnnealing(f, mustComplexPoint(t), SimulatedAnnealingOptions{}); err == nil {
|
||||
t.Fatal("a complex starting point was accepted")
|
||||
}
|
||||
// A non-finite objective at the start is refused.
|
||||
if _, _, err := MinimiseSimulatedAnnealing(func(*core.Array) (float64, error) { return math.Inf(-1), nil },
|
||||
start, SimulatedAnnealingOptions{}); err == nil {
|
||||
t.Fatal("a non-finite start value was accepted")
|
||||
}
|
||||
// An objective's own error propagates.
|
||||
if _, _, err := MinimiseSimulatedAnnealing(func(*core.Array) (float64, error) {
|
||||
return 0, base.Errf("the model exploded")
|
||||
}, start, SimulatedAnnealingOptions{}); err == nil {
|
||||
t.Fatal("the objective's error did not propagate")
|
||||
}
|
||||
// A proposal that wanders where the objective is undefined is
|
||||
// fatal, and so is one that errors: the chain climbs a linear
|
||||
// slope until it crosses the model's domain.
|
||||
climb := func(limit float64, verdict func() (float64, error)) func(*core.Array) (float64, error) {
|
||||
return func(a *core.Array) (float64, error) {
|
||||
s := 0.0
|
||||
for i := range a.Len() {
|
||||
s -= a.FloatAt(i)
|
||||
}
|
||||
if s < limit {
|
||||
return verdict()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
if _, _, err := MinimiseSimulatedAnnealing(climb(-30, func() (float64, error) { return math.NaN(), nil }),
|
||||
start, SimulatedAnnealingOptions{Seed: 7, Steps: 4000}); err == nil {
|
||||
t.Fatal("a NaN proposal value was accepted")
|
||||
}
|
||||
if _, _, err := MinimiseSimulatedAnnealing(climb(-30, func() (float64, error) {
|
||||
return 0, base.Errf("the proposal exploded")
|
||||
}), start, SimulatedAnnealingOptions{Seed: 7, Steps: 4000}); err == nil {
|
||||
t.Fatal("the proposal's error did not propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCMADrawSamplesTheAdaptedCovariance pins the sampling equation:
|
||||
// the candidates are B·diag(sd)·z with one unit normal per principal
|
||||
// direction, so the empirical covariance of many draws reproduces the
|
||||
// adapted C even after a rotation that leaves no axis aligned. A
|
||||
// shared scalar across the directions sampled a diagonal distribution
|
||||
// instead and failed this probe loudly.
|
||||
func TestCMADrawSamplesTheAdaptedCovariance(t *testing.T) {
|
||||
n := 4
|
||||
// C = Q·D·Qᵀ for a Householder reflection through (1, 2, 3, 4) and
|
||||
// a spread of eigenvalues, so no axis survives the rotation.
|
||||
q := make([]float64, n*n)
|
||||
{
|
||||
v := []float64{1, 2, 3, 4}
|
||||
norm := 0.0
|
||||
for _, x := range v {
|
||||
norm += x * x
|
||||
}
|
||||
norm = math.Sqrt(norm)
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
h := 0.0
|
||||
if i == j {
|
||||
h = 1
|
||||
}
|
||||
q[i*n+j] = h - 2*v[i]*v[j]/(norm*norm)
|
||||
}
|
||||
}
|
||||
}
|
||||
eigen := []float64{4, 1, 0.5, 0.25}
|
||||
sd := make([]float64, n)
|
||||
for i := range n {
|
||||
sd[i] = math.Sqrt(eigen[i])
|
||||
}
|
||||
c := make([]float64, n*n)
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
for k := range n {
|
||||
c[i*n+j] += q[k*n+i] * eigen[k] * q[k*n+j]
|
||||
}
|
||||
}
|
||||
}
|
||||
g := core.NewGenerator(9)
|
||||
draws := 300000
|
||||
z := make([]float64, n)
|
||||
var y []float64
|
||||
sum := make([]float64, n)
|
||||
cov := make([]float64, n*n)
|
||||
for range draws {
|
||||
for i := range n {
|
||||
z[i] = g.NormalUnit()
|
||||
}
|
||||
y = make([]float64, n)
|
||||
cmaDraw(q, sd, z, y)
|
||||
for i := range n {
|
||||
sum[i] += y[i]
|
||||
for j := range n {
|
||||
cov[i*n+j] += y[i] * y[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
froC, froErr := 0.0, 0.0
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
cov[i*n+j] = cov[i*n+j]/float64(draws) - sum[i]*sum[j]/(float64(draws)*float64(draws))
|
||||
d := cov[i*n+j] - c[i*n+j]
|
||||
froErr += d * d
|
||||
froC += c[i*n+j] * c[i*n+j]
|
||||
}
|
||||
}
|
||||
if math.Sqrt(froErr/froC) > 0.02 {
|
||||
t.Fatalf("empirical covariance off the adapted C by %.3g relative Frobenius", math.Sqrt(froErr/froC))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user