// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package optim import ( "math" "sourcedock.dev/petrbalvin/tensor/internal/base" "sourcedock.dev/petrbalvin/tensor/internal/core" ) // Convex quadratic programming by the primal active-set method on the // house two-sided rows: // // min ½xᵀHx + c·x subject to l ≤ A·x ≤ u. // // The method walks the active sets: each iterate solves the // equality-constrained subproblem that holds the working rows exactly // at their walls, moves along the solution until a new row blocks, and // adds the blocker to the working set. When the step vanishes, the // working rows' multipliers are inspected: a row whose multiplier // turned negative was pushed into the set by an earlier wall and is // released. The subproblem is the KKT system // // [H Ãᵀ] [p] [−g] // [à 0] [ν] = [ 0], // // with g = Hx + c the gradient at the iterate and à the working rows // each carrying the sign of the wall it holds, written as the // constraint's own gradient: a row held at its upper wall enters as // +a, one at its lower wall as −a (the constraint reads l − a·x ≤ 0 // there), and an equality as +a. The ν that come out are then the // multipliers themselves: non-negative on the inequality rows, signed // on the equalities. The system is factored by the same dense LU the // revised simplex in simplex.go uses: one factorisation per iterate. // // Termination is the classic result for the method (Nocedal and // Wright, Numerical Optimization, chapter 16.5): with H positive // definite each subproblem has a unique solution, the objective // strictly decreases on every non-zero step, the objective is the same // on the zero steps that release a row, and the working set never // repeats with a lower objective, so the loop reaches the unique KKT // point in finitely many iterations on a nondegenerate problem. // Degenerate ties (several rows blocking at one step, several rows // tied at the most negative multiplier) are broken by the lowest row // index, in the spirit of Bland's rule; a degenerate configuration // that still cannot progress is stopped by the iteration budget as an // error, never returned as a solution. Without positive definiteness // none of that holds, so the Hessian is factorised at entry and a // matrix that refuses the Cholesky factorisation is refused. // QPOptions tunes MinimiseQP. MaxIterations ≤ 0 means 1000 active-set // rounds, Tolerance ≤ 0 means 1e-10. The tolerance is the threshold on // the KKT step (below it the iterate is stationary on its working set), // on the multiplier that releases a row, and on the symmetry of H; it // is absolute, like the other tolerances in the package. type QPOptions struct { MaxIterations int Tolerance float64 } // MinimiseQP returns the point, the value ½xᵀHx + c·x and the row // multipliers of the minimum of the strictly convex quadratic over the // two-sided rows. The multipliers hold one entry per row of cons: an // equality row carries its signed multiplier, an inequality row the // non-negative multiplier of its active wall and zero when the row is // slack, so complementary slackness reads directly off the slice. // // H must be symmetric and positive definite: anything else is refused // at entry with the failed pivot named, because the termination // guarantee and the uniqueness of the answer both rest on it. The // starting point x0 may be nil, in which case a feasible point is // found by running MinimiseLinearRows on the same rows with a zero // cost, whose phase-1 refusal is the honest answer for an infeasible // set; a supplied x0 must be feasible within 1e-9 and is refused with // the worst violation otherwise. A nil or empty constraint set is the // unconstrained quadratic and solves in one Newton step. func MinimiseQP(h, c *core.Array, cons LinearConstraints, x0 *core.Array, opts QPOptions) (*core.Array, float64, []float64, error) { const name = "MinimiseQP" tol := opts.Tolerance if tol <= 0 { tol = 1e-10 } maxIter := opts.MaxIterations if maxIter <= 0 { maxIter = 1000 } if c.NDim() != 1 || c.Len() == 0 { return nil, 0, nil, base.Errf("%s: c must be a non-empty rank-1 cost vector", name) } if c.Dtype() == core.Complex { return nil, 0, nil, base.Errf("%s: complex costs are not supported", name) } n := c.Len() qc := make([]float64, n) for j := range n { v := c.FloatAt(j) if math.IsNaN(v) || math.IsInf(v, 0) { return nil, 0, nil, base.Errf("%s: the cost carries a non-finite entry at %d", name, j+1) } qc[j] = v } if h == nil { return nil, 0, nil, base.Errf("%s: the Hessian matrix is nil", name) } if err := requireReal(name, "Hessian matrices", h); err != nil { return nil, 0, nil, err } if h.NDim() != 2 || h.Shape()[0] != n || h.Shape()[1] != n { return nil, 0, nil, base.Errf("%s: the Hessian is %s, want %d×%d", name, base.ShapeText(h.Shape()), n, n) } hm := make([]float64, n*n) for i := range n { for j := range n { v := h.FloatAt(i*n + j) if math.IsNaN(v) || math.IsInf(v, 0) { return nil, 0, nil, base.Errf("%s: the Hessian carries a non-finite entry at (%d, %d)", name, i+1, j+1) } hm[i*n+j] = v } } for i := range n { for j := i + 1; j < n; j++ { if d := math.Abs(hm[i*n+j] - hm[j*n+i]); d > tol*math.Max(1, math.Abs(hm[i*n+j])) { return nil, 0, nil, base.Errf("%s: the Hessian is not symmetric at (%d, %d): %g against %g", name, i+1, j+1, hm[i*n+j], hm[j*n+i]) } } } if err := requirePositiveDefinite(hm, n, name); err != nil { return nil, 0, nil, err } rows, lower, upper, equal, err := qpRows(cons, n, name) if err != nil { return nil, 0, nil, err } r := len(rows) // The start: a feasible point from the linear wrapper when none is // supplied, or the caller's own point checked against the rows. x := make([]float64, n) if x0 == nil { if r > 0 { // The zero cost makes the wrapper return any feasible // point; its phase-1 refusal is the honest answer for an // infeasible set. feas, _, err := MinimiseLinearRows(core.New(core.Float, n), cons, LinearProgramOptions{Tolerance: math.Max(tol, 1e-9)}) if err != nil { return nil, 0, nil, base.Errf("%s: %w", name, err) } for i := range n { x[i] = feas.FloatAt(i) } } } else { if x0.Dtype() == core.Complex { return nil, 0, nil, base.Errf("%s: complex starting points are not supported", name) } if x0.Len() != n { return nil, 0, nil, base.Errf("%s: the starting point holds %d elements for %d variables", name, x0.Len(), n) } for i := range n { x[i] = x0.FloatAt(i) } if worst, row := worstViolation(rows, lower, upper, x); worst > 1e-9 { return nil, 0, nil, base.Errf("%s: the starting point violates row %d by %g", name, row+1, worst) } } // The working set: every equality row starts pinned to its wall; // the inequality rows join as the steps meet them. wallSign is the // KKT column sign: +1 on an upper wall or an equality, -1 on a // lower wall. active := make([]int, 0, r) wallSign := make([]float64, 0, r) inW := make([]bool, r) for i := range r { if equal[i] { active = append(active, i) wallSign = append(wallSign, 1) inW[i] = true } } g := make([]float64, n) kkt := make([]float64, (n+r)*(n+r)) rhs := make([]float64, n+r) sol := make([]float64, n+r) // The KKT system is refactored once per iterate, so one factor // serves the whole run: the factorisation workspace is rewritten // from kkt every time. var fac lu for range maxIter { // The gradient at the iterate and the KKT system for the step // that stays stationary on the working set. matVec(hm, x, n, n, g) for j := range n { g[j] += qc[j] } w := len(active) k := n + w clear(rhs[:k]) // Only the working rows' own block survives from the previous // iteration: the Hessian block and the two constraint blocks are // written whole just below, the w×w zero block of the KKT system // is not written at all. for i := n; i < k; i++ { for j := n; j < k; j++ { kkt[i*k+j] = 0 } } for i := range n { for j := range n { kkt[i*k+j] = hm[i*n+j] } rhs[i] = -g[i] } for t := range w { row := rows[active[t]] for i := range n { v := wallSign[t] * row[i] kkt[i*k+n+t] = v kkt[(n+t)*k+i] = v } } if err := fac.factor(kkt[:k*k], k); err != nil { return nil, 0, nil, base.Errf("%s: the working set has lost rank: %w", name, err) } fac.solve(rhs, sol) pNorm := 0.0 for j := range n { pNorm = math.Max(pNorm, math.Abs(sol[j])) } if pNorm <= tol*math.Max(1, maxAbs(x)) { // Stationary on the working set: an inequality row whose // multiplier turned negative belongs off the set. The most // negative multiplier leaves, ties to the lowest row // index; with none left the KKT point is reached. tie, worst := -1, 0.0 for t := range w { if equal[active[t]] { continue } mu := sol[n+t] if mu >= -tol { continue } switch { case tie == -1 || mu < worst-1e-12: worst, tie = mu, t case mu <= worst+1e-12 && active[t] < active[tie]: tie = t } } if tie == -1 { var multipliers []float64 if r > 0 { multipliers = make([]float64, r) for t := range w { multipliers[active[t]] = sol[n+t] } } if worst, row := worstViolation(rows, lower, upper, x); worst > 1e-9 { return nil, 0, nil, base.Errf("%s: the solution violates row %d by %g", name, row+1, worst) } value := 0.5 * dot(x, matVecR(hm, x, n)) for j := range n { value += qc[j] * x[j] } out, fv := packResult(x, value) return out, fv, multipliers, nil } inW[active[tie]] = false active = append(active[:tie], active[tie+1:]...) wallSign = append(wallSign[:tie], wallSign[tie+1:]...) continue } // The largest step before an off-set row blocks: a row can only // be crossed towards a finite wall, and the blocker hit first, // ties to the lowest row index, joins the working set there. alpha := 1.0 blocker, blockSign := -1, 0.0 for i := range r { if inW[i] || equal[i] { continue } d := dot(rows[i], sol[:n]) var ai float64 ai = math.Inf(1) switch { case d > 0 && upper[i] < math.Inf(1): ai = (upper[i] - dot(rows[i], x)) / d case d < 0 && lower[i] > math.Inf(-1): ai = (lower[i] - dot(rows[i], x)) / d } if ai < 0 { ai = 0 } if ai < 1 { if ai < alpha-1e-12 { alpha, blocker, blockSign = ai, i, wallKKTSign(d) } else if ai <= alpha+1e-12 && (blocker == -1 || i < blocker) { if ai < alpha { alpha = ai } blocker, blockSign = i, wallKKTSign(d) } } } for j := range n { x[j] += alpha * sol[j] } if blocker >= 0 && alpha < 1 { active = append(active, blocker) wallSign = append(wallSign, blockSign) inW[blocker] = true } } return nil, 0, nil, base.Errf("%s: the active-set budget of %d rounds ran out without reaching the KKT point", name, maxIter) } // qpRows extracts the two-sided rows into plain slices and validates // them with the same gates MinimiseConstrained applies to its matrix. // A nil or empty set is no rows at all. func qpRows(cons LinearConstraints, n int, name string) (rows [][]float64, lower, upper []float64, equal []bool, err error) { if cons.A == nil { return nil, nil, nil, nil, nil } if err := requireReal(name, "constraint matrices", cons.A); err != nil { return nil, nil, nil, nil, err } if cons.A.NDim() != 2 || cons.A.Shape()[1] != n { return nil, nil, nil, nil, base.Errf("%s: the constraint matrix is %s, want r×%d", name, base.ShapeText(cons.A.Shape()), n) } r := cons.A.Shape()[0] if r == 0 { return nil, nil, nil, nil, nil } if len(cons.Lower) != r || len(cons.Upper) != r { return nil, nil, nil, nil, base.Errf("%s: the bounds hold %d and %d entries for %d rows", name, len(cons.Lower), len(cons.Upper), r) } rows = make([][]float64, r) lower = make([]float64, r) upper = make([]float64, r) equal = make([]bool, r) // One backing block for every row: the rows are read-only after // this build, so one allocation carries the whole block. back := make([]float64, r*n) for i := range r { row := back[i*n : (i+1)*n] for j := range n { v := cons.A.FloatAt(i*n + j) if math.IsNaN(v) || math.IsInf(v, 0) { return nil, nil, nil, nil, base.Errf("%s: row %d carries a non-finite coefficient", name, i+1) } row[j] = v } lo, up := cons.Lower[i], cons.Upper[i] if math.IsNaN(lo) || math.IsNaN(up) || lo > up { return nil, nil, nil, nil, base.Errf("%s: row %d has bounds [%g, %g]", name, i+1, lo, up) } rows[i], lower[i], upper[i], equal[i] = row, lo, up, lo == up } return rows, lower, upper, equal, nil } // worstViolation measures the rows at x and returns the largest // violation with the row that carries it. func worstViolation(rows [][]float64, lower, upper []float64, x []float64) (float64, int) { worst, row := 0.0, 0 for i := range rows { ax := dot(rows[i], x) v := math.Max(ax-upper[i], lower[i]-ax) if v > worst { worst, row = v, i } } return worst, row } // requirePositiveDefinite runs a Cholesky factorisation as the test: // a pivot at or below the relative floor means the matrix is singular // or indefinite, and both refuse the problem. func requirePositiveDefinite(hm []float64, n int, name string) error { scale := 1.0 for i := range n { scale = math.Max(scale, math.Abs(hm[i*n+i])) } work := make([]float64, n*n) copy(work, hm) for k := range n { p := work[k*n+k] for j := range k { p -= work[k*n+j] * work[k*n+j] } if p <= 1e-13*scale { return base.Errf("%s: the Hessian is not positive definite (pivot %g at %d)", name, p, k+1) } p = math.Sqrt(p) work[k*n+k] = p for i := k + 1; i < n; i++ { s := work[i*n+k] for j := range k { s -= work[i*n+j] * work[k*n+j] } work[i*n+k] = s / p } } return nil } // matVec writes A·x into dst for a row-major m×n A. func matVec(a []float64, x []float64, m, n int, dst []float64) { for i := range m { dst[i] = dot(a[i*n:i*n+n], x) } } // matVecR returns A·x as a fresh slice, the value form of matVec. func matVecR(a []float64, x []float64, n int) []float64 { dst := make([]float64, n) matVec(a, x, n, n, dst) return dst } // dot is the plain inner product of two equal-length slices. func dot(a, b []float64) float64 { s := 0.0 for i := range a { s += a[i] * b[i] } return s } // wallKKTSign maps a step's slope along a row to the KKT column sign // of the wall it is heading for: a positive slope meets the upper wall // (the constraint reads a·x − u ≤ 0 there), a negative one the lower // wall (the constraint reads l − a·x ≤ 0). func wallKKTSign(d float64) float64 { if d > 0 { return 1 } return -1 }