Files
tensor/optim/simplex_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

451 lines
17 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"
)
// TestLUFactorSolve pins the shared factorisation machinery on a
// non-symmetric matrix: both triangle directions are load-bearing,
// solve for the primal ratios and solveT for the dual prices and the
// redundant-row scan.
func TestLUFactorSolve(t *testing.T) {
// A = [[2, 1], [4, 3]], det = 2: A(1, 3) = (5, 13) and
// Aᵀ(2, 1) = (8, 5).
f, err := factorLU([]float64{2, 1, 4, 3}, 2)
if err != nil {
t.Fatalf("factorLU: %v", err)
}
x := make([]float64, 2)
f.solve([]float64{5, 13}, x)
if math.Abs(x[0]-1) > 1e-12 || math.Abs(x[1]-3) > 1e-12 {
t.Fatalf("solve = (%g, %g), want (1, 3)", x[0], x[1])
}
f.solveT([]float64{8, 5}, x)
if math.Abs(x[0]-2) > 1e-12 || math.Abs(x[1]-1) > 1e-12 {
t.Fatalf("solveT = (%g, %g), want (2, 1)", x[0], x[1])
}
// A singular matrix is refused, not divided through.
if _, err := factorLU([]float64{1, 2, 2, 4}, 2); err == nil {
t.Fatal("a singular matrix was factored")
}
// So is a zero matrix, named as such rather than a pivot.
if _, err := factorLU(make([]float64, 4), 2); err == nil {
t.Fatal("a zero matrix was factored")
}
if _, err := factorLU(nil, 0); err != nil {
t.Fatalf("a zero-sized factorisation was refused: %v", err)
}
}
// TestMinimiseLinearStandardVertex pins a hand-built optimum at a
// known vertex: min −(2x + 3y) over x + y ≤ 4, 2x + y ≤ 6 with the
// slacks carried explicitly. The vertices price out at 0, 6, 10 and
// 12, so the answer is the vertex (0, 4, 0, 2) with value −12.
func TestMinimiseLinearStandardVertex(t *testing.T) {
c := mustFloats(t, []float64{-2, -3, 0, 0})
a := mustFloats(t, []float64{1, 1, 1, 0, 2, 1, 0, 1}, 2, 4)
b := mustFloats(t, []float64{4, 6})
x, value, err := MinimiseLinear(c, a, b, LinearProgramOptions{})
if err != nil {
t.Fatalf("MinimiseLinear: %v", err)
}
want := []float64{0, 4, 0, 2}
for i, w := range want {
if math.Abs(x.FloatAt(i)-w) > 1e-9 {
t.Fatalf("x[%d] = %.12g, want %g", i, x.FloatAt(i), w)
}
}
if math.Abs(value+12) > 1e-9 {
t.Fatalf("value = %.12g, want −12", value)
}
}
// TestMinimiseLinearRowsVertex drives the two-sided wrapper over the
// same polytope expressed as house rows with free variables: max
// x + 2y on x + y ≤ 4, x + 3y ≤ 6, x, y ≥ 0. The vertex prices are 0,
// 4, 5 and 4, so the optimum is the vertex (3, 1) with value 5.
func TestMinimiseLinearRowsVertex(t *testing.T) {
c := mustFloats(t, []float64{-1, -2})
cons := LinearConstraints{
A: mustFloats(t, []float64{1, 1, 1, 3, 1, 0, 0, 1}, 4, 2),
Lower: []float64{math.Inf(-1), math.Inf(-1), 0, 0},
Upper: []float64{4, 6, math.Inf(1), math.Inf(1)},
}
x, value, err := MinimiseLinearRows(c, cons, LinearProgramOptions{})
if err != nil {
t.Fatalf("MinimiseLinearRows: %v", err)
}
if math.Abs(x.FloatAt(0)-3) > 1e-9 || math.Abs(x.FloatAt(1)-1) > 1e-9 {
t.Fatalf("x = (%.12g, %.12g), want the vertex (3, 1)", x.FloatAt(0), x.FloatAt(1))
}
if math.Abs(value+5) > 1e-9 {
t.Fatalf("value = %.12g, want −5", value)
}
}
// TestMinimiseLinearNegativeRightHandSide pins the row negation: a
// standard-form row arrives with b < 0 and must come out of the
// artificial phase feasible all the same.
func TestMinimiseLinearNegativeRightHandSide(t *testing.T) {
c := mustFloats(t, []float64{1, 1})
a := mustFloats(t, []float64{-1, -1}, 1, 2)
b := mustFloats(t, []float64{-2})
x, value, err := MinimiseLinear(c, a, b, LinearProgramOptions{})
if err != nil {
t.Fatalf("MinimiseLinear: %v", err)
}
if math.Abs(value-2) > 1e-9 {
t.Fatalf("value = %.12g, want 2", value)
}
if math.Abs(x.FloatAt(0)+x.FloatAt(1)-2) > 1e-9 {
t.Fatalf("x + y = %.12g, want 2", x.FloatAt(0)+x.FloatAt(1))
}
}
// TestMinimiseLinearBudget pins the pivot-budget refusal: one pivot
// cannot carry the vertex problem to its optimum, and an exhausted
// budget is an error, never a silent answer.
func TestMinimiseLinearBudget(t *testing.T) {
c := mustFloats(t, []float64{-2, -3, 0, 0})
a := mustFloats(t, []float64{1, 1, 1, 0, 2, 1, 0, 1}, 2, 4)
b := mustFloats(t, []float64{4, 6})
_, _, err := MinimiseLinear(c, a, b, LinearProgramOptions{MaxIterations: 1})
if err == nil || !strings.Contains(err.Error(), "budget") {
t.Fatalf("error = %v, want the pivot-budget refusal", err)
}
}
// TestMinimiseLinearDegenerateTerminates pins the anti-cycling
// guarantee where it earns its keep: two identical equality rows plus
// a third at twice the scale leave the problem degenerate and
// redundant at once, the classic rules can pivot forever on such a
// vertex, and Bland's rule must terminate, dropping the redundant rows
// and their artificials, with a point on the feasible segment.
func TestMinimiseLinearDegenerateTerminates(t *testing.T) {
c := mustFloats(t, []float64{-1, -1})
cons := LinearConstraints{
A: mustFloats(t, []float64{1, 1, 1, 1, 2, 2}, 3, 2),
Lower: []float64{1, 1, 2},
Upper: []float64{1, 1, 2},
}
x, value, err := MinimiseLinearRows(c, cons, LinearProgramOptions{})
if err != nil {
t.Fatalf("MinimiseLinearRows on a degenerate problem: %v", err)
}
if math.Abs(value+1) > 1e-9 {
t.Fatalf("value = %.12g, want −1", value)
}
if x.FloatAt(0) < -1e-9 || x.FloatAt(1) < -1e-9 {
t.Fatalf("x = (%g, %g) left the non-negative quadrant", x.FloatAt(0), x.FloatAt(1))
}
if math.Abs(x.FloatAt(0)+x.FloatAt(1)-1) > 1e-9 {
t.Fatalf("x + y = %.12g, want 1", x.FloatAt(0)+x.FloatAt(1))
}
}
// TestMinimiseLinearDegenerateOrigin pins the other degenerate shape:
// a zero right-hand side keeps the artificials basic at the phase-1
// optimum, so the phase transition must pivot them out with degenerate
// steps before phase 2. The feasible set of x + y = 0, x − y = 0 over
// x, y ≥ 0 is the origin alone.
func TestMinimiseLinearDegenerateOrigin(t *testing.T) {
c := mustFloats(t, []float64{-1, -1})
a := mustFloats(t, []float64{1, 1, 1, -1}, 2, 2)
b := mustFloats(t, []float64{0, 0})
x, value, err := MinimiseLinear(c, a, b, LinearProgramOptions{})
if err != nil {
t.Fatalf("MinimiseLinear: %v", err)
}
if value != 0 {
t.Fatalf("value = %g, want 0", value)
}
for i := range 2 {
if x.FloatAt(i) != 0 {
t.Fatalf("x[%d] = %g, want 0", i, x.FloatAt(i))
}
}
}
// TestMinimiseLinearBeale pins Beale's cycling example, the classic
// demonstration that Dantzig's rule can pivot forever (Beale, 1955;
// the form in Chvátal's Linear Programming, chapter 3). The optimum is
// 0.05 at (1/25, 0, 1, 0) with the first slack carrying the 0.03 the
// first row leaves loose, so minimising the negated objective returns
// −0.05 there.
func TestMinimiseLinearBeale(t *testing.T) {
c := mustFloats(t, []float64{-0.75, 150, -0.02, 6, 0, 0, 0})
a := mustFloats(t, []float64{
0.25, -60, -0.04, 9, 1, 0, 0,
0.5, -90, -0.02, 3, 0, 1, 0,
0, 0, 1, 0, 0, 0, 1,
}, 3, 7)
b := mustFloats(t, []float64{0, 0, 1})
x, value, err := MinimiseLinear(c, a, b, LinearProgramOptions{})
if err != nil {
t.Fatalf("MinimiseLinear on Beale's example: %v", err)
}
want := []float64{1.0 / 25.0, 0, 1, 0, 0.03, 0, 0}
for i, w := range want {
if math.Abs(x.FloatAt(i)-w) > 1e-9 {
t.Fatalf("x[%d] = %.12g, want %.12g", i, x.FloatAt(i), w)
}
}
if math.Abs(value+0.05) > 1e-9 {
t.Fatalf("value = %.12g, want −0.05", value)
}
}
// TestMinimiseLinearInfeasible pins the phase-1 refusal: x ≥ 1 and
// x ≤ 0 share no feasible point, and the error must carry the
// infeasibility the artificial phase ended with.
func TestMinimiseLinearInfeasible(t *testing.T) {
c := mustFloats(t, []float64{1})
cons := LinearConstraints{
A: mustFloats(t, []float64{1, 1}, 2, 1),
Lower: []float64{math.Inf(-1), 1},
Upper: []float64{0, math.Inf(1)},
}
_, _, err := MinimiseLinearRows(c, cons, LinearProgramOptions{})
if err == nil {
t.Fatal("an infeasible problem returned a solution")
}
if !strings.Contains(err.Error(), "infeasible") {
t.Fatalf("error = %v, want the phase-1 infeasibility evidence", err)
}
// The named row must be the one that carries the worst figure, not
// whichever artificial the scan touched after row 1: two empty rows
// leave their artificials at 1 and 0.5, and the zero-initialised
// worst-row sentinel once let the smaller overwrite the evidence.
_, _, err = MinimiseLinear(mustFloats(t, []float64{0}),
mustFloats(t, []float64{0, 0}, 2, 1), mustFloats(t, []float64{1, 0.5}, 2), LinearProgramOptions{})
if err == nil || !strings.Contains(err.Error(), "row 1 still carries 1") {
t.Fatalf("worst row misreported: %v", err)
}
}
// TestMinimiseLinearUnbounded pins the ray refusal in both entries.
func TestMinimiseLinearUnbounded(t *testing.T) {
// Standard form: x = y with min −x runs along the ray (t, t).
_, _, err := MinimiseLinear(mustFloats(t, []float64{-1, 0}),
mustFloats(t, []float64{1, -1}, 1, 2), mustFloats(t, []float64{0}), LinearProgramOptions{})
if err == nil || !strings.Contains(err.Error(), "unbounded") {
t.Fatalf("standard form: error = %v, want an unbounded refusal", err)
}
// Rows: x ≥ 0 with min −x.
_, _, err = MinimiseLinearRows(mustFloats(t, []float64{-1}),
LinearConstraints{A: mustFloats(t, []float64{1}, 1, 1),
Lower: []float64{0}, Upper: []float64{math.Inf(1)}}, LinearProgramOptions{})
if err == nil || !strings.Contains(err.Error(), "unbounded") {
t.Fatalf("rows: error = %v, want an unbounded refusal", err)
}
}
// TestMinimiseLinearNoRows pins the row-free standard form: over x ≥ 0
// alone a non-negative cost bottoms out at the origin and a negative
// cost is unbounded.
func TestMinimiseLinearNoRows(t *testing.T) {
x, value, err := MinimiseLinear(mustFloats(t, []float64{1, 2}), core.New(core.Float, 0, 2),
core.New(core.Float, 0), LinearProgramOptions{})
if err != nil {
t.Fatalf("MinimiseLinear: %v", err)
}
if value != 0 {
t.Fatalf("value = %g, want 0", value)
}
for i := range 2 {
if x.FloatAt(i) != 0 {
t.Fatalf("x[%d] = %g, want 0", i, x.FloatAt(i))
}
}
if _, _, err := MinimiseLinear(mustFloats(t, []float64{-1, 2}), core.New(core.Float, 0, 2),
core.New(core.Float, 0), LinearProgramOptions{}); err == nil {
t.Fatal("a negative cost without rows was not refused as unbounded")
}
}
// TestMinimiseLinearRefusals checks every input gate of both entries.
func TestMinimiseLinearRefusals(t *testing.T) {
empty := core.New(core.Float, 0)
c2 := mustFloats(t, []float64{1, 2})
a22 := mustFloats(t, []float64{1, 1, 0, 1}, 2, 2)
b2 := mustFloats(t, []float64{1, 1})
cases := []struct {
name string
run func() error
}{
{"empty cost", func() error {
_, _, err := MinimiseLinear(empty, a22, b2, LinearProgramOptions{})
return err
}},
{"complex cost", func() error {
_, _, err := MinimiseLinear(mustComplexPoint(t), a22, b2, LinearProgramOptions{})
return err
}},
{"nil matrix", func() error {
_, _, err := MinimiseLinear(c2, nil, b2, LinearProgramOptions{})
return err
}},
{"matrix shape", func() error {
_, _, err := MinimiseLinear(c2, mustFloats(t, []float64{1, 1}), b2, LinearProgramOptions{})
return err
}},
{"right-hand side length", func() error {
_, _, err := MinimiseLinear(c2, a22, mustFloats(t, []float64{1}), LinearProgramOptions{})
return err
}},
{"NaN coefficient", func() error {
_, _, err := MinimiseLinear(c2, mustFloats(t, []float64{math.NaN(), 1, 0, 1}, 2, 2), b2, LinearProgramOptions{})
return err
}},
{"NaN cost", func() error {
_, _, err := MinimiseLinear(mustFloats(t, []float64{math.NaN(), 1}), a22, b2, LinearProgramOptions{})
return err
}},
{"complex constraint matrix", func() error {
ca, _ := core.FromComplexes([]complex128{1, 0, 0, 1}, 2, 2)
_, _, err := MinimiseLinear(c2, ca, b2, LinearProgramOptions{})
return err
}},
{"complex right-hand side", func() error {
cb, _ := core.FromComplexes([]complex128{1, 1}, 2)
_, _, err := MinimiseLinear(c2, a22, cb, LinearProgramOptions{})
return err
}},
{"NaN right-hand side", func() error {
_, _, err := MinimiseLinear(c2, a22, mustFloats(t, []float64{math.NaN(), 1}), LinearProgramOptions{})
return err
}},
}
for _, c := range cases {
if err := c.run(); err == nil {
t.Fatalf("MinimiseLinear: %s accepted", c.name)
}
}
rows := []struct {
name string
cons LinearConstraints
}{
{"nil matrix", LinearConstraints{Lower: []float64{0}, Upper: []float64{1}}},
{"matrix shape", LinearConstraints{A: mustFloats(t, []float64{1, 1}), Lower: []float64{0}, Upper: []float64{1}}},
{"no rows", LinearConstraints{A: core.New(core.Float, 0, 2), Lower: nil, Upper: nil}},
{"short bounds", LinearConstraints{A: a22, Lower: []float64{0}, Upper: []float64{}}},
{"crossed bounds", LinearConstraints{A: a22, Lower: []float64{1, 0}, Upper: []float64{0, 1}}},
{"equality at infinity", LinearConstraints{A: a22,
Lower: []float64{math.Inf(1), 0}, Upper: []float64{math.Inf(1), 1}}},
{"NaN bound", LinearConstraints{A: a22, Lower: []float64{math.NaN(), 0}, Upper: []float64{1, 1}}},
{"NaN coefficient", LinearConstraints{A: mustFloats(t, []float64{math.NaN(), 1, 0, 1}, 2, 2),
Lower: []float64{0, 0}, Upper: []float64{1, 1}}},
}
for _, r := range rows {
if _, _, err := MinimiseLinearRows(c2, r.cons, LinearProgramOptions{}); err == nil {
t.Fatalf("MinimiseLinearRows: %s accepted", r.name)
}
}
// The rows entry's own cost gates.
rowCosts := []struct {
name string
c *core.Array
}{
{"empty cost", core.New(core.Float, 0)},
{"complex cost", mustComplexPoint(t)},
{"NaN cost", mustFloats(t, []float64{math.NaN(), 1})},
}
for _, rc := range rowCosts {
if _, _, err := MinimiseLinearRows(rc.c, LinearConstraints{A: a22, Lower: []float64{0, 0}, Upper: []float64{1, 1}},
LinearProgramOptions{}); err == nil {
t.Fatalf("MinimiseLinearRows: %s accepted", rc.name)
}
}
// Complex constraint matrices are refused here too.
ca, _ := core.FromComplexes([]complex128{1, 0, 0, 1}, 2, 2)
if _, _, err := MinimiseLinearRows(c2, LinearConstraints{A: ca, Lower: []float64{0, 0}, Upper: []float64{1, 1}},
LinearProgramOptions{}); err == nil {
t.Fatal("MinimiseLinearRows: a complex constraint matrix was accepted")
}
}
// TestMinimiseLinearRowsNegativeBounds pins the negation of a
// right-hand side the artificial basis needs: a two-sided model whose
// bound rows come out negative (a lower bound below zero, a bare
// negative equality) builds standard rows with b >= 0 and solves,
// where an unnegated row lost primal feasibility at pivot 0.
func TestMinimiseLinearRowsNegativeBounds(t *testing.T) {
a, err := core.FromFloats([]float64{1, 0, 0, 1}, 2, 2)
if err != nil {
t.Fatal(err)
}
cons := LinearConstraints{
A: a,
Lower: []float64{-1, 2},
Upper: []float64{math.Inf(1), math.Inf(1)},
}
x, v, err := MinimiseLinearRows(mustFloats(t, []float64{1, 1}), cons, LinearProgramOptions{})
if err != nil {
t.Fatalf("negative lower bound refused: %v", err)
}
if v != 1 || x.FloatAt(0) != -1 || x.FloatAt(1) != 2 {
t.Fatalf("x = (%g, %g) value %g, want (-1, 2) at 1", x.FloatAt(0), x.FloatAt(1), v)
}
// The bare negative equality rides the same negation through the
// QP entry's feasibility path.
h, herr := core.FromFloats([]float64{2, 0, 0, 2}, 2, 2)
if herr != nil {
t.Fatal(herr)
}
eq, eerr := core.FromFloats([]float64{1, 1}, 1, 2)
if eerr != nil {
t.Fatal(eerr)
}
eqCons := LinearConstraints{A: eq, Lower: []float64{-1}, Upper: []float64{-1}}
q, qv, _, qerr := MinimiseQP(h, mustFloats(t, []float64{4, 0}), eqCons, nil, QPOptions{})
if qerr != nil {
t.Fatalf("negative equality refused: %v", qerr)
}
if math.Abs(q.FloatAt(0)-(-1.5)) > 1e-9 || math.Abs(q.FloatAt(1)-0.5) > 1e-9 || math.Abs(qv+3.5) > 1e-9 {
t.Fatalf("x = (%g, %g) value %g, want (-1.5, 0.5) at -3.5", q.FloatAt(0), q.FloatAt(1), qv)
}
}
// TestMinimiseLinearExpelsTwoArtificials pins the reused unit vector in
// the artificial expulsion. The two rows below are exact negatives, so
// the phase-1 reduced costs cancel for every column: the artificial
// phase ends with both artificials still basic at zero and the
// expulsion runs twice, the second round reading a freshly cleared unit
// vector. A stale one scores the columns through the sum of two rows of
// B⁻¹ and swaps in a column that leaves the basis singular, so a solve
// that must answer the origin fails instead.
func TestMinimiseLinearExpelsTwoArtificials(t *testing.T) {
a := mustFloats(t, []float64{1, 2, -1, -2}, 2, 2)
b := mustFloats(t, []float64{0, 0})
c := mustFloats(t, []float64{1, 1})
x, value, err := MinimiseLinear(c, a, b, LinearProgramOptions{})
if err != nil {
t.Fatalf("MinimiseLinear: %v", err)
}
if value != 0 {
t.Fatalf("value = %.12g, want 0", value)
}
for i := range x.Len() {
if x.FloatAt(i) != 0 {
t.Fatalf("x[%d] = %.12g, want 0", i, x.FloatAt(i))
}
}
// The same two-round expulsion over a scaled right-hand side: the
// origin is the only feasible point whatever the rows' scale.
scaled := mustFloats(t, []float64{3, 6, -3, -6}, 2, 2)
x, value, err = MinimiseLinear(c, scaled, b, LinearProgramOptions{})
if err != nil {
t.Fatalf("MinimiseLinear over the scaled rows: %v", err)
}
if value != 0 || x.FloatAt(0) != 0 || x.FloatAt(1) != 0 {
t.Fatalf("scaled rows: x = (%g, %g), value %g, want the origin", x.FloatAt(0), x.FloatAt(1), value)
}
}