solver: return the best iterate seen when terminating unsuccessfully - #7
solver: return the best iterate seen when terminating unsuccessfully#7batterseapower wants to merge 2 commits into
Conversation
On ill-conditioned problems the primal residual can reach its attainable floor and then diverge as mu is driven further down, while the duality gap is still converging. The solver then oscillates for several more iterations and gives up -- returning an iterate materially worse than ones it had already computed, and reporting a hard failure where an AlmostSolved exit was available. Observed on a portfolio-rebalance SOCP: pres bottoms at 1.6e-12 (iter 24), gap meets tolerance around iter 28 by which point pres has diverged to 1e-5, InsufficientProgress declared at iter 43 with pres ~1e-4. This change checkpoints the best iterate seen so far, where "best" is distance from satisfying the full-accuracy termination test: each quantity normalized by its tolerance and combined exactly as in is_solved, i.e. max(res_p/tol_feas, res_d/tol_feas, min(gap_abs/ tol_gap_abs, gap_rel/tol_gap_rel)). Only iterates on the optimality branch (kappa/tau <= 1) are candidates. On unsuccessful exits (InsufficientProgress, NumericalError, MaxIterations, MaxTime) the checkpointed variables and their convergence scalars are restored before the reduced-tolerance "almost" checks run, so both the reported status and the returned solution describe the best point encountered. The restore is skipped when the final iterate has kappa/tau > 1, so an emerging infeasibility certificate is never masked. This replaces the previous-iterate restore, which the best iterate dominates by construction; the previous-iteration scalars used by the poor-progress tests in check_termination are retained unchanged. Returning the best iterate on reduced-accuracy exits is established practice: ECOS does exactly this (Domahidi, Chu & Boyd, "ECOS: An SOCP solver for embedded systems", ECC 2013), as does MOSEK's stall handling. Successful solves are unaffected: checkpointing is passive, and measured statuses and objectives are bit-identical on 223/224 corpus problems (the 224th differs 5.8e-8 in an AlmostSolved objective, returning its better iterate). On the motivating problem the solver now exits AlmostSolved with the merit-minimizing iterate (pres 1.4e-10, gap_rel 6.6e-7) instead of InsufficientProgress with pres 1e-4, and the returned objective moves closer to the independent reference (2.8e-7 vs 3.9e-7 relative). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CqGdm2vZZ6HA5HT8DsRePa
Out-of-sample testing on MIPLIB 2017 LP relaxations found two problems
(brazil3, comp07_2idx) where returning the "best" iterate reported
NumericalError where the previous behaviour reported AlmostSolved, and
returned a worse objective with it.
The cause is that the iteration loop can exit *before* it checkpoints.
An undersized step or a failed KKT solve breaks out of the loop body
ahead of the checkpoint call, so the iterate held at exit has usually
never been offered as a candidate -- and on these problems it was better
than everything stored. Restoring unconditionally therefore discarded
the best answer instead of recovering it, which is the opposite of the
intent.
Two changes:
* The restore is skipped unless the checkpointed iterate actually beats
the iterate currently held, by the same merit. This makes the whole
mechanism never-worse by construction.
* The merit is normalized by the *reduced* tolerances rather than the
full ones. These are the tolerances that `check_convergence_almost`
applies to the restored iterate on an unsuccessful exit, so ranking
candidates by them means the selected iterate passes that check
whenever any candidate would have.
With both, brazil3 and comp07_2idx return objectives identical to the
previous behaviour, and the motivating case still improves
(InsufficientProgress -> AlmostSolved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqGdm2vZZ6HA5HT8DsRePa
Out-of-sample testing found a bug here — now fixed (commit above)I validated this series against two public suites that had not been used as a gate: the MIPLIB 2017 benchmark set as LP relaxations (45 instances) and CBLIB (47 SOCP instances). The 224-problem corpus in the original description had become a tuning gate, so it no longer counted as out-of-sample. On MIPLIB, two problems — Cause. The iteration loop can exit before it checkpoints: an undersized step or a failed KKT solve breaks out of the loop body ahead of the checkpoint call. So the iterate held at exit has usually never been offered as a candidate — and on these problems it was better than everything stored. The unconditional restore therefore discarded the best answer rather than recovering it, the exact opposite of the intent. Previously this was masked because Fix, two parts:
After the fix:
Worth noting for reviewers: this class of bug is invisible to a "does it improve the hard cases" test and only shows up when you check that the easy cases did not get worse. |
|
Closing: this is the same feature as upstream oxfordcontrol#229, which is already in flight and gets the design right in a way this branch did not — it checkpoints at the top of the iteration loop, so the iterate held at exit is always a candidate, whereas this branch checkpointed near the end of the loop body and needed a never-worse guard to compensate. The two refinements worth keeping from here (tolerance-normalised merit, and covering MaxIterations/MaxTime) have been suggested on oxfordcontrol#229 directly: oxfordcontrol#229 (comment) |
Problem
On ill-conditioned problems the primal residual reaches its attainable floor and then diverges as μ is driven further down, while the duality gap is still converging. The solver oscillates for more iterations and gives up — reporting a hard failure where a reduced-accuracy exit was available, and returning an iterate materially worse than ones it had already computed.
Motivating case (portfolio-rebalance SOCP, n=18,421, m=43,266, one SOC of dim 6,270, default tolerances):
The divergence is the double-precision conditioning floor at small μ, not regularization: it is unchanged with
static_regularization_constant∈ {1e-8, 1e-10, 1e-12, 0}. The solver had its best answer in hand at iter 25 and walked away from it. Downstream, a caller running a tolerance-descent ladder re-solves the whole problem at looser tolerances (~287s in the production trace that motivated this) when the first solve already contained an AlmostSolved-quality iterate.Change
Checkpoint the best iterate seen so far and restore it on unsuccessful exits (
InsufficientProgress,NumericalError,MaxIterations,MaxTime), before the reduced-tolerance checks run — so both the reported status and the returned solution describe the best point encountered.Three rules, each of which exists because of a specific failure mode:
is_solvedat the reduced tolerances, i.e. each quantity normalized by its tolerance and combined exactly as in that test:max(res_p/rtol_feas, res_d/rtol_feas, min(gap_abs/rtol_gap_abs, gap_rel/rtol_gap_rel)). Those are the tolerancescheck_convergence_almostapplies to the restored iterate, so ranking by them gives a guarantee: if any candidate would have passed that check, the selected one passes it too. Normalizing by the full tolerances instead can rank an iterate that fails the reduced check above one that passes it.This replaces the previous-iterate restore (
save_prev_iterate/reset_to_prev_iterate→checkpoint_iterate/reset_to_best_iterate;Solver.prev_vars→Solver.best_vars): with rule 3 the best iterate dominates the previous one, and restoring scalars together with variables also fixes a latent inconsistency where the almost-convergence check previously evaluated restored variables against the final iterate's κ/τ. The previous-iteration scalars used by the poor-progress tests incheck_terminationare retained unchanged.No numeric constants introduced — the merit is built entirely from existing settings.
Established practice: ECOS returns its best iterate on reduced-accuracy exits (Domahidi, Chu & Boyd, ECOS: An SOCP solver for embedded systems, ECC 2013 — the
ECOS_OPTIMAL + ECOS_INACC_OFFSETpath); MOSEK's stall handling likewise returns the best near-optimal point.Validation suites used throughout this series
All timing is on one Apple M-series core, single-threaded,
--releasewithdebug symbols, and every comparison is interleaved: the two binaries are
run alternately problem-by-problem within each round, so thermal drift and
machine noise affect both sides equally. Reported times are the minimum over
rounds. (Session-to-session noise on this machine is large — up to 20% between
identical binaries at different times — so non-interleaved comparisons are not
trustworthy and none are quoted.)
1. Unit / integration tests.
cargo test— 20 test binaries, all passing,plus the specific new tests listed per PR below.
cargo clippyclean on thetouched files.
2. Portfolio-rebalance set (9 problems, the motivating workload).
Real conic problems from a production portfolio-rebalance backtest:
n = 9,448–18,424, m = 22,329–43,273, nnz(A) = 271k–538k, diagonal
P, twolarge nonnegative cones and one second-order cone of dimension 3,279–6,271
(a factor-model risk constraint: ~156 dense-ish factor rows plus a diagonal
idiosyncratic block). Used for the headline timings.
3. In-sample public corpus (224 problems). Every tiny/small/medium
non-PSD problem in a locally converted corpus: 101 Maros–Mészáros QPs,
85 Netlib LPs, 24 structured conic problems (SOCP/EXP/POW built from UCI
data), 12 Netlib-Kennington LPs, 2 Mittelmann LPs. Each has an
independently verified reference objective. Used as the status/objective
regression gate for every change.
4. Out-of-sample suites (92 problems, fetched fresh from the internet).
Because suite 3 became a tuning gate, two further suites were added that
were never used to guide any decision:
MIPLIB 2017 benchmark set as LP relaxations — 45 instances. Downloaded
from
miplib.zib.de; the MPS reader ignores INTORG/INTEND markers, soreading a MIPLIB file yields exactly the continuous relaxation. These are
substantially harder for an interior-point method than the portfolio
problems. 90s time limit per solve.
CBLIB (Conic Benchmark Library) — 47 instances, sampled across families
from
cblib.zib.de, including the DIMACS classicsnb,nb_L1,nb_L2,nql30/60/180,qssp30/60,sched_*. Converted with a new CBF reader;integer instances are taken as continuous relaxations, and rotated
quadratic cones are mapped to second-order cones by the orthogonal
rotation
u=(x1+x2)/√2, v=(x1−x2)/√2.Conversion validated independently:
nbsolves to −5.0703094644e-2,matching its published DIMACS optimum (−0.05070309), and HiGHS (installed
for the purpose) agrees on the LP-only conversions, e.g.
gen_ip0546765.209042728 vs Clarabel 6765.2090428.
Tests specific to this PR
test_best_iterate_checkpoint_and_restore— checkpoint selection (better iterate replaces, worse does not), the κ/τ > 1 exclusion on both checkpoint and restore, and that a restore brings back variables and scalars.test_best_iterate_never_restores_something_worse— the rule-3 regression test: a better-but-uncheckpointed current iterate is kept, a genuinely worse one is replaced.test_best_iterate_no_checkpoint_no_restore— no candidate ⇒ no restore.Performance: before → after
This PR is not a performance change; checkpointing is an O(n+m) copy on improving iterations only, and no measurable timing difference was observed on any suite. What changes is what the solver returns when it fails:
20260105_fullInsufficientProgress → AlmostSolvedmm_yao(already AlmostSolved in both) returns its better iterate, objective shifts 5.8e-8Successful solves are unaffected by construction: checkpointing is passive and no restore happens unless an unsuccessful status was set.
Out-of-sample testing found a bug in the first version of this PR
The first version had only rules 1 (with full tolerances) and 2. It passed the 224-problem in-sample corpus with zero status changes. On the MIPLIB LP relaxations two problems then regressed:
mainbrazil3comp07_2idxBisecting across the whole branch series showed every performance PR was clean and this PR was the culprit — the cause being the missing rule 3 described above. After adding it (and switching rule 1 to the reduced tolerances):
mainbrazil3comp07_2idxwith the motivating improvement preserved. Worth stating plainly for reviewers: this class of bug is invisible to a test that asks "did the hard cases improve", and only surfaces when you check that the easy cases did not get worse.
API note
Infotrait:save_prev_iterate/reset_to_prev_iterateare replaced bycheckpoint_iterate(now takessettings, for the tolerances in the merit) andreset_to_best_iterate(takessettings, returns whether the restore happened);Solver.prev_varsis renamedbest_vars. External implementors ofInfo— rare — need the mechanical rename. This diverges from Clarabel.jl until synced.🤖 Generated with Claude Code
https://claude.ai/code/session_01CqGdm2vZZ6HA5HT8DsRePa