pub fn second_opinion_rungs(
avail: SecondOpinionAvailability,
) -> Vec<SecondOpinionRung>Expand description
Build the ladder of second-opinion re-solves for a failing verdict, in the order they should be tried. Each rung varies exactly one knob from the baseline options.
-
feral_scaling=mc64— numerical diversity (feral_infeasibility_scaling_retry, on by default). Some KKT trajectories are chaotic: under two equally backward-stable linear-solver scalings the iterates stay bit-identical for many iterations, then diverge by ~1 ULP and fall into different basins — one optimal, the other a spurious stationary point of the constraint violation (discs.nl: InfNorm → infeasible, MC64/Identity/MA57/IPOPT → optimal). Sensitive dependence, not a bad solve, so the a-priori scaling router cannot tell the two apart and no per-factor residual flags it; the only reliable signal is the whole-solve verdict. -
mu_strategy=adaptive— algorithmic diversity (infeasibility_mu_strategy_retry, on by default). Rung 1 perturbs only the linear algebra, so it is evidence only when the trajectory is ULP-hypersensitive. When it is not, MC64 retraces the same iterates and agrees for the same reason the first solve was wrong — on gh #524 (cresc4, 6 vars / 8 constraints, feasible, Ipopt solves it in 71 iterations) the MC64 re-solve reproduced the original trajectory bit-identically and “corroborated” the false verdict. A different barrier strategy changes the iterate sequence itself, which is what the monotone-µ default gets wrong here: adaptive µ walks to the known optimum. This is also the remedy IPOPT’s own documentation gives a user who gets an infeasibility verdict on a problem they believe is feasible; running it automatically just spares them the round trip. -
start_point_perturbation=1e-2— a different starting point (infeasibility_perturbed_start_retry, on by default). The only rung that moves the point rather than the path, and so the only one that is evidence about anInvalid_Number_Detectedor aRestoration_Failed— seeSecondOpinionTrigger. Those two triggers open this rung and nothing else, so they cost exactly one extra solve. -
feral_increase_quality=no— undo the factorization escalation (feral_increase_quality_retry, on by default; gh#857). The only rung whose gate is a measurement of the failing solve rather than a reading of its options: it opens on aRestoration_Failed, aMaximum_Iterations_Exceededor anInfeasible_Problem_Detectedand only when the solve’squality_escalationscount is at least 1.The infeasibility trigger was added after the other two, and the reason is worth keeping:
square_flowsheet_resto’s lbfgs leg does not exit the same way on every platform. On macOS/arm64 it runs to the 3000-iteration cap and exitsMaximum_Iterations_Exceeded; on linux/x86_64 the same 3000 iterations with the same 25 escalations endInfeasible_Problem_Detectedinstead — a wrong answer on a feasible model, and one the three infeasibility rungs above do not recover. The verdict an escalation-rerouted trajectory produces is not a property of the escalation, so pinning the rung to two of the three shapes left the fix firing on one platform and not the other.Unlike the other two triggers this one is not free, and the difference is worth stating: a
Restoration_Failedor a budget exit is a failure either way, so the rung’s cost lands only on runs that were already going to report one, whereas a genuine infeasibility verdict is a correct answer and the rung can only confirm it. Six fixture-legs pay exactly that — one extra solve, e.g.issue_508_infeasible_gap_1em4982 → 1423 total iterations, with no status, objective, iteration count or engine moving. The>= 1gate is what bounds it: of the eight NLP-arm infeasibility fixture-legs, four escalated and take the rung and four are untouched.feral_increase_qualityis on by default and genuinely two-sided — it buys accuracy and 15–25% of the iterations on several fixture-legs and loses whole solves on others — and its losing direction previously had no automatic recovery at all. Measured onsquare_flowsheet_resto: the lbfgs leg escalates 25 times, hits the 3000-iteration cap, and converges in 178 with the rung off.It is appended, not inserted, so a
Restoration_Failedthat rung 3 already recovers (the gh#815 family, and this same fixture’s exact leg) reaches promotion first and costs nothing new. The>= 1is a gate and not a threshold:deb7escalates exactly as many times assquare_flowsheet_resto’s base solve and gains by it, so a count cannot separate the two — only the verdict can, anddeb7’s isOptimal.Opening this rung also stands the µ-strategy stall retry down (
Application::run_with_mu_strategy_fallback), which is what keeps it from being a third solve. That retry fires unconditionally onMaximum_Iterations_Exceeded, so before gh#857 this fixture’s lbfgs leg paid 3000 capped iterations, then a second full 3000 under the flipped schedule that escalated 25 times again and ended no better, and only then reached this rung’s 178. The flip is blind and the escalation is measured; skipping it takes the run from three solves to two, changing no reported number — which is also why the fixture sweep is byte-identical across that change.
Rungs are not cumulative: the driver restores the baseline before each
rung, so rung 2 runs without rung 1’s scaling and rung 3 without either
earlier knob. That reset is load-bearing, not tidiness: on gh #524’s
cresc4, mu_strategy=adaptive recovers the optimum but
mu_strategy=adaptive with feral_scaling=mc64 still reports local
infeasibility, so a cumulative ladder would have discarded the fix.