Skip to main content

pounce_algorithm/
second_opinion.rs

1//! The second-opinion ladder: re-solving a failure along a different
2//! trajectory before believing it.
3//!
4//! A local-infeasibility verdict on a nonconvex problem is a *local*
5//! statement, and an `Invalid_Number_Detected` is a statement about the
6//! callbacks at one point. Both are frequently artifacts of the trajectory
7//! the solve happened to take, or of the point it started from, rather than
8//! facts about the model. The ladder re-solves along up to three deliberately
9//! different trajectories and promotes a re-solve only if it converges.
10//!
11//! This module holds the **policy** — which rungs apply to which failure, what
12//! each rung changes, and how a ladder's outcome resolves. It reads options
13//! and returns descriptions; it runs no solves. The **driver** that applies a
14//! rung and calls back into the solver lives in `pounce-restoration`, because
15//! each rung has to rebuild the restoration sub-IPM's factory provider and
16//! that provider is defined one crate up the dependency graph.
17//!
18//! Split out of `pounce-cli`'s `main.rs` so the Python, C and Rust embedding
19//! surfaces get the same ladder rather than each re-deriving it — the
20//! asymmetry mattered, because a caller driving POUNCE from a modelling layer
21//! is precisely the one most likely to hand over an uninitialized (and so
22//! all-zero, and so possibly rank-deficient) starting point.
23
24use pounce_common::options_list::OptionsList;
25use pounce_nlp::SolveStatistics;
26use pounce_nlp::return_codes::ApplicationReturnStatus;
27
28/// One rung of the local-infeasibility second-opinion ladder: a label for the
29/// console plus the option assignments that define this re-solve's trajectory.
30///
31/// Assignments are applied on top of the *baseline* options, not on top of the
32/// previous rung — see `second_opinion_rungs`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SecondOpinionRung {
35    pub label: &'static str,
36    /// The knob this rung varies, and *only* that knob — one
37    /// `read_from_str` line per assignment.
38    ///
39    /// A rung does not carry lines undoing the earlier rungs. It used to,
40    /// and that was a defect: the undo was written as the baseline's
41    /// *resolved* value, so a knob the caller never set came back **set**.
42    /// `mu_strategy` is the one that bites — `is_mu_strategy_fallback_enabled`
43    /// is default-on only while `mu_strategy` is unset, so rung 3 writing
44    /// back a resolved `monotone` silently switched off pounce's own
45    /// μ-strategy stall retry for the duration of the rung. Measured on
46    /// KRONOS `a18_ackley1`: the displaced solve stalls at `max_iter`, the
47    /// flip that would have certified it in 237 iterations never fires, and
48    /// the ladder reports no recovery. Restoring by *value* is not the same
49    /// as restoring by *set-ness*.
50    ///
51    /// The driver restores the baseline with
52    /// `OptionSnapshot::apply` before every rung, which does honour
53    /// set-ness, so a rung starts from the true baseline by construction.
54    pub assignments: Vec<String>,
55}
56
57/// Which failure opened the ladder. Not every rung is evidence about every
58/// failure: an `Invalid_Number_Detected` is a statement about the *callbacks*
59/// at a point, and re-running the same callbacks at the same point under a
60/// different linear-solver scaling or a different barrier strategy evaluates
61/// the same non-finite quantity again. Only the rung that moves the point
62/// applies there.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum SecondOpinionTrigger {
65    /// `Infeasible_Problem_Detected` — a *local* statement about a nonconvex
66    /// problem, which every rung is evidence against.
67    LocalInfeasibility,
68    /// `Invalid_Number_Detected` — a NaN or infinity out of the model.
69    InvalidNumber,
70    /// `Restoration_Failed` — the restoration phase could not find a point
71    /// the filter would accept. Like the two above and unlike a budget exit,
72    /// this is a statement about the trajectory the solve happened to take,
73    /// not about the model; see [`SecondOpinionTrigger::for_status`].
74    RestorationFailure,
75    /// `Error_In_Step_Computation` — the step computation broke down: the
76    /// KKT system could not be made to yield a usable step, even after
77    /// regularization. Like a restoration failure and unlike a budget exit,
78    /// this is a statement about the trajectory the solve happened to take,
79    /// so a different barrier schedule is evidence against it.
80    ///
81    /// Measured on `deb7` (found via gh#960): the wasm32 build, whose factorization
82    /// rounds differently from the native one, follows the native trajectory
83    /// for 83 iterations, diverges by one ulp in `inf_du` at iteration 84,
84    /// and ends `Error_In_Step_Computation` at 160 where native solves in
85    /// 147. Under `mu_strategy=adaptive` the same binary and the same model
86    /// reach the same optimum as native — `Solve_Succeeded`, 131 iterations,
87    /// objective 97.559938 — so the rescue was already in the ladder and
88    /// only the trigger was missing. This opens **only** that rung: on the
89    /// same case `feral_scaling=mc64` still fails at 154 iterations,
90    /// `feral_increase_quality=no` at 201, and `start_point_perturbation`
91    /// spends 324 iterations to fail anyway.
92    StepComputationError,
93    /// `Maximum_Iterations_Exceeded` — **and only when the solve escalated
94    /// the linear solver's factorization quality at least once** (gh#857).
95    ///
96    /// This is the one trigger that is not a property of the verdict alone,
97    /// and the exception it carves out of the paragraph on
98    /// [`SecondOpinionTrigger::for_status`] is narrow on purpose. A budget
99    /// exit normally wants a bigger budget, not a different trajectory —
100    /// but a `feral_increase_quality` escalation *changes which pivots are
101    /// taken and never steps back down*, so when one fired, the wall the
102    /// solve hit may be the escalated trajectory's rather than the model's,
103    /// and a bigger budget re-runs the same wall. On
104    /// `square_flowsheet_resto`'s lbfgs leg the escalated path reaches 3000
105    /// iterations and the un-escalated one converges in 178.
106    ///
107    /// The escalation count is what keeps this from opening a ladder on
108    /// every budget exit; it comes from the `quality_escalations` statistic
109    /// and is checked in [`second_opinion_rungs`], not here — `for_status`
110    /// only sees a status. A solve that escalated zero times produces an
111    /// empty rung list and the driver returns before it narrates anything.
112    IterationLimit,
113}
114
115/// What the baseline options already provide, so a rung that would be a no-op
116/// can be dropped instead of burning a solve to re-derive the same answer.
117#[derive(Debug, Clone, Copy)]
118pub struct SecondOpinionAvailability {
119    pub trigger: SecondOpinionTrigger,
120    pub scaling_retry_enabled: bool,
121    pub mu_retry_enabled: bool,
122    pub perturbed_start_retry_enabled: bool,
123    pub already_mc64: bool,
124    pub already_adaptive: bool,
125    /// The baseline already displaces the start, so there is no displacement
126    /// left for the third rung to add that the failing solve did not have.
127    pub already_perturbed: bool,
128    /// `feral_increase_quality_retry` (gh#857), rung 4's own enable.
129    pub increase_quality_retry_enabled: bool,
130    /// The baseline already ran with `feral_increase_quality=no`, so rung 4
131    /// would re-run the solve that just failed.
132    pub already_no_increase_quality: bool,
133    /// How many times the failing solve's linear solver actually accepted an
134    /// `increase_quality` escalation — the `quality_escalations` statistic.
135    ///
136    /// The only entry here that is a *measurement of the solve* rather than
137    /// a reading of its options, and rung 4 is unimplementable without it:
138    /// an escalation moves no field a report carries, so "did this solve
139    /// take the rung that has a documented losing direction" cannot be
140    /// answered from the verdict. `0` means provably not a candidate, and
141    /// rung 4 is dropped — which is what stops a budget exit on a
142    /// never-escalating model from paying for an extra solve.
143    ///
144    /// It is a gate at `>= 1`, deliberately **not** a threshold. `deb7` and
145    /// `square_flowsheet_resto`'s base solve escalate exactly twice each on
146    /// their exact legs, one gaining the solve and one losing it, so no
147    /// count separates them; the verdict does.
148    pub baseline_quality_escalations: u64,
149    /// `feral_scaling` tag naming the baseline's *resolved* scaling strategy.
150    /// `None` under `ScalingStrategy::External`, which drops rungs 2 and 3.
151    ///
152    /// Now purely a gate. It was the tag those rungs wrote back to undo rung
153    /// 1, and `None` meant there was nothing to write; the driver's snapshot
154    /// restores by set-ness instead, so no tag is needed and the External
155    /// case would in fact be safe to run. The gate is kept because dropping
156    /// it adds two rungs on externally-scaled models — a trajectory change,
157    /// and a separate decision from this bug fix.
158    pub baseline_scaling: Option<&'static str>,
159}
160
161/// Build the ladder of second-opinion re-solves for a failing verdict, in the
162/// order they should be tried. Each rung varies exactly one knob from the
163/// baseline options.
164///
165/// 1. **`feral_scaling=mc64` — numerical diversity**
166///    (`feral_infeasibility_scaling_retry`, on by default). Some KKT
167///    trajectories are chaotic: under two equally backward-stable
168///    linear-solver scalings the iterates stay bit-identical for many
169///    iterations, then diverge by ~1 ULP and fall into different basins — one
170///    optimal, the other a spurious stationary point of the constraint
171///    violation (`discs.nl`: InfNorm → infeasible, MC64/Identity/MA57/IPOPT →
172///    optimal). Sensitive dependence, not a bad solve, so the a-priori
173///    scaling router cannot tell the two apart and no per-factor residual
174///    flags it; the only reliable signal is the whole-solve verdict.
175///
176/// 2. **`mu_strategy=adaptive` — algorithmic diversity**
177///    (`infeasibility_mu_strategy_retry`, on by default). Rung 1 perturbs only
178///    the linear algebra, so it is evidence *only* when the trajectory is
179///    ULP-hypersensitive. When it is not, MC64 retraces the same iterates and
180///    agrees for the same reason the first solve was wrong — on gh #524
181///    (`cresc4`, 6 vars / 8 constraints, feasible, Ipopt solves it in 71
182///    iterations) the MC64 re-solve reproduced the original trajectory
183///    bit-identically and "corroborated" the false verdict. A different
184///    barrier strategy changes the iterate sequence itself, which is what the
185///    monotone-µ default gets wrong here: adaptive µ walks to the known
186///    optimum. This is also the remedy IPOPT's own documentation gives a user
187///    who gets an infeasibility verdict on a problem they believe is feasible;
188///    running it automatically just spares them the round trip.
189///
190/// 3. **`start_point_perturbation=1e-2` — a different starting point**
191///    (`infeasibility_perturbed_start_retry`, on by default). The only rung
192///    that moves the point rather than the path, and so the only one that is
193///    evidence about an `Invalid_Number_Detected` or a `Restoration_Failed`
194///    — see [`SecondOpinionTrigger`]. Those two triggers open this rung and
195///    nothing else, so they cost exactly one extra solve.
196///
197/// 4. **`feral_increase_quality=no` — undo the factorization escalation**
198///    (`feral_increase_quality_retry`, on by default; gh#857). The only rung
199///    whose gate is a *measurement of the failing solve* rather than a
200///    reading of its options: it opens on a `Restoration_Failed`, a
201///    `Maximum_Iterations_Exceeded` or an `Infeasible_Problem_Detected`
202///    **and** only when the solve's `quality_escalations` count is at
203///    least 1.
204///
205///    The infeasibility trigger was added after the other two, and the
206///    reason is worth keeping: `square_flowsheet_resto`'s lbfgs leg does
207///    **not** exit the same way on every platform. On macOS/arm64 it runs
208///    to the 3000-iteration cap and exits `Maximum_Iterations_Exceeded`;
209///    on linux/x86_64 the same 3000 iterations with the same 25
210///    escalations end `Infeasible_Problem_Detected` instead — a *wrong
211///    answer* on a feasible model, and one the three infeasibility rungs
212///    above do not recover. The verdict an escalation-rerouted trajectory
213///    produces is not a property of the escalation, so pinning the rung to
214///    two of the three shapes left the fix firing on one platform and not
215///    the other.
216///
217///    Unlike the other two triggers this one is **not** free, and the
218///    difference is worth stating: a `Restoration_Failed` or a budget exit
219///    is a failure either way, so the rung's cost lands only on runs that
220///    were already going to report one, whereas a *genuine* infeasibility
221///    verdict is a correct answer and the rung can only confirm it. Six
222///    fixture-legs pay exactly that — one extra solve, e.g.
223///    `issue_508_infeasible_gap_1em4` 982 → 1423 total iterations, with no
224///    status, objective, iteration count or engine moving. The `>= 1` gate
225///    is what bounds it: of the eight NLP-arm infeasibility fixture-legs,
226///    four escalated and take the rung and four are untouched.
227///    `feral_increase_quality` is
228///    on by default and genuinely two-sided — it buys accuracy and 15–25% of
229///    the iterations on several fixture-legs and loses whole solves on others
230///    — and its losing direction previously had no automatic recovery at all.
231///    Measured on `square_flowsheet_resto` under feral 0.20: the lbfgs leg
232///    escalates 25 times, hits the 3000-iteration cap, and converges in 178
233///    with the rung off. Under feral 0.21 that leg hits the cap with the rung
234///    off as well (the `square_system_lm` stage recovers it), and no corpus
235///    fixture reaches this rung at default options; the tests' witness is the
236///    exact leg at `max_iter=75` (base MaxIter after 3 escalations, this rung
237///    Optimal at 59). The other 178s and 131s in this file are 0.20 figures.
238///
239///    It is **appended**, not inserted, so a `Restoration_Failed` that rung 3
240///    already recovers (the gh#815 family, and this same fixture's exact leg)
241///    reaches promotion first and costs nothing new. The `>= 1` is a gate and
242///    not a threshold: `deb7` escalates exactly as many times as
243///    `square_flowsheet_resto`'s base solve and *gains* by it, so a count
244///    cannot separate the two — only the verdict can, and `deb7`'s is
245///    `Optimal`.
246///
247///    Opening this rung also stands the µ-strategy stall retry down
248///    (`Application::run_with_mu_strategy_fallback`), which is what keeps it
249///    from being a third solve. That retry fires unconditionally on
250///    `Maximum_Iterations_Exceeded`, so before gh#857 this fixture's lbfgs leg
251///    paid 3000 capped iterations, then a second full 3000 under the flipped
252///    schedule that escalated 25 times again and ended no better, and only
253///    then reached this rung's 178. The flip is blind and the escalation is
254///    measured; skipping it takes the run from three solves to two, changing
255///    no reported number — which is also why the fixture sweep is
256///    byte-identical across that change.
257///
258/// Rungs are **not** cumulative: the driver restores the baseline before each
259/// rung, so rung 2 runs without rung 1's scaling and rung 3 without either
260/// earlier knob. That reset is load-bearing, not tidiness: on gh #524's
261/// `cresc4`, `mu_strategy=adaptive` recovers the optimum but
262/// `mu_strategy=adaptive` with `feral_scaling=mc64` still reports local
263/// infeasibility, so a cumulative ladder would have discarded the fix.
264pub fn second_opinion_rungs(avail: SecondOpinionAvailability) -> Vec<SecondOpinionRung> {
265    let mut rungs = Vec::new();
266    let infeasible = avail.trigger == SecondOpinionTrigger::LocalInfeasibility;
267    if infeasible && avail.scaling_retry_enabled && !avail.already_mc64 {
268        rungs.push(SecondOpinionRung {
269            label: "feral_scaling=mc64",
270            assignments: vec!["feral_scaling mc64\n".to_string()],
271        });
272    }
273    // The barrier-schedule rung is the one rung a step-computation
274    // breakdown opens, and the only one measured to recover one: the
275    // trajectory walked into a KKT system no regularization could make
276    // usable, and a different μ schedule is a different trajectory. See
277    // `SecondOpinionTrigger::StepComputationError` for the deb7 numbers.
278    if avail.baseline_scaling.is_some()
279        && (infeasible || avail.trigger == SecondOpinionTrigger::StepComputationError)
280        && avail.mu_retry_enabled
281        && !avail.already_adaptive
282    {
283        rungs.push(SecondOpinionRung {
284            label: "mu_strategy=adaptive",
285            assignments: vec!["mu_strategy adaptive\n".to_string()],
286        });
287    }
288    // Rung 3 varies exactly one knob from the *baseline*, so both earlier
289    // rungs' knobs must be undone first, not inherited — gh #524 is the case
290    // where stacking two of them threw the fix away. That undo is the
291    // driver's job (`OptionSnapshot::apply` before each rung), not an
292    // assignment here; see the note on `assignments`.
293    //
294    // Not on the iteration-limit trigger. That trigger is gated on a
295    // measurement (gh#857, rung 4 below) and exists to test one hypothesis —
296    // that the escalation is what walked the solve into the wall. Displacing
297    // the start of a solve that ran out of budget tests nothing: it starts a
298    // fresh trajectory with the same budget and the same escalating ladder
299    // waiting for it. Opening it here would put an extra solve on every
300    // escalating budget exit for no reason anyone has measured.
301    // Not on the step-computation trigger either, and for a measured
302    // reason rather than the reasoning above: on deb7 it spends 324
303    // iterations and fails anyway, where the rung above solves in 131.
304    if avail.baseline_scaling.is_some()
305        && !matches!(
306            avail.trigger,
307            SecondOpinionTrigger::IterationLimit | SecondOpinionTrigger::StepComputationError
308        )
309        && avail.perturbed_start_retry_enabled
310        && !avail.already_perturbed
311    {
312        rungs.push(SecondOpinionRung {
313            label: "start_point_perturbation=1e-2",
314            assignments: vec!["start_point_perturbation 1e-2\n".to_string()],
315        });
316    }
317    // 4. `feral_increase_quality=no` — undo the escalation (gh#857).
318    //
319    // Appended, never prepended. On a `Restoration_Failed` the rung above
320    // already recovers the gh#815 family *and* `square_flowsheet_resto`'s own
321    // exact leg, and it promotes and breaks before this one runs, so those
322    // solves cost nothing new. This rung is what is left when that fails, and
323    // it is the whole ladder on an `Maximum_Iterations_Exceeded`.
324    //
325    // The `>= 1` is the gate the trigger's doc describes: a solve that never
326    // escalated cannot have been rerouted by an escalation, so there is
327    // nothing here to test and no solve to spend.
328    if matches!(
329        avail.trigger,
330        SecondOpinionTrigger::RestorationFailure
331            | SecondOpinionTrigger::IterationLimit
332            | SecondOpinionTrigger::LocalInfeasibility
333    ) && avail.increase_quality_retry_enabled
334        && !avail.already_no_increase_quality
335        && avail.baseline_quality_escalations >= 1
336    {
337        rungs.push(SecondOpinionRung {
338            label: "feral_increase_quality=no",
339            assignments: vec!["feral_increase_quality no\n".to_string()],
340        });
341    }
342    rungs
343}
344
345/// Whether the ladder's narration should reach the console.
346///
347/// `print_level 0` is a request for silence, and the ladder's running
348/// commentary is no more exempt from it than the `EXIT:` block — the C
349/// interface is an Ipopt drop-in, where `print_level=0 sb=yes` is the
350/// documented way to get a quiet solve, and up to five unexpected `pounce:`
351/// lines on a failing one is exactly what that asks not to happen. The ladder
352/// still *runs*; only the console is quiet.
353///
354/// Lives here rather than at each call site because the CLI and the C
355/// interface both need it and a duplicated branch is a branch that can drift
356/// — one of them silently losing the gate would look identical to the other
357/// keeping it. `pounce-rs` and Python do not call this: they never print,
358/// they hand the narration back to the caller.
359///
360/// Only an *explicit* `print_level` silences: a level nobody set narrates,
361/// and so does an unregistered or unreadable one. That is the pre-gate
362/// behaviour and the safe direction — too much on a failing solve, never too
363/// little.
364pub fn narration_is_wanted(options: &OptionsList) -> bool {
365    options
366        .get_integer_value("print_level", "")
367        .map(|(level, found)| !found || level >= 1)
368        .unwrap_or(true)
369}
370
371/// Did a second-opinion re-solve converge well enough to overturn the original
372/// local-infeasibility verdict? Only a clean or acceptable-level solve
373/// promotes; everything else (including a second infeasibility verdict) leaves
374/// the original verdict standing.
375pub fn scaling_retry_promoted(retry_status: ApplicationReturnStatus) -> bool {
376    matches!(
377        retry_status,
378        ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::SolvedToAcceptableLevel
379    )
380}
381
382/// Resolve the final `(status, statistics)` after an MC64 hypersensitivity
383/// re-solve (code review L23).
384///
385/// On promotion the retry is the authoritative solve, so its status **and** its
386/// statistics are reported together. Otherwise the original local-infeasibility
387/// verdict is kept — and so are the *original* solve's statistics, so the
388/// summary / JSON report never pair the original verdict with the failed
389/// retry's iteration count or objective. The pre-fix code reverted `status` to
390/// `InfeasibleProblemDetected` but read `app.statistics()` *after* the retry,
391/// leaking the retry solve's stats into a report labeled with the original
392/// verdict.
393pub fn resolve_scaling_retry_outcome(
394    original_status: ApplicationReturnStatus,
395    retry_status: ApplicationReturnStatus,
396    original_stats: SolveStatistics,
397    retry_stats: SolveStatistics,
398) -> (ApplicationReturnStatus, SolveStatistics) {
399    if scaling_retry_promoted(retry_status) {
400        (retry_status, retry_stats)
401    } else {
402        (original_status, original_stats)
403    }
404}
405
406impl SecondOpinionTrigger {
407    /// Which ladder, if any, a finished solve's verdict opens.
408    ///
409    /// Only these three statuses open one. In particular an iteration- or
410    /// time-limit exit does not: the answer there is a bigger budget, and a
411    /// re-solve from a different trajectory would burn the same budget again
412    /// to reach the same wall.
413    ///
414    /// `Restoration_Failed` is on the list for the same reason the other two
415    /// are (gh#815): it is a report about the *path*, not about the model.
416    /// The restoration phase failing to find a filter-acceptable point says
417    /// the iterate reached somewhere the sub-problem could not work from, and
418    /// a different starting point is a different sub-problem. It is not a
419    /// budget exit — pounce stops far short of `max_iter` — so "give it more
420    /// iterations" is not the available answer, which is precisely the
421    /// distinction the paragraph above draws. Measured on the gh#815 square
422    /// flowsheet family: both failing members exit `Restoration_Failed`, no
423    /// ladder ran, and rung 3 alone recovers both to `Optimal Solution
424    /// Found` — one of them (`f100`) to an optimum Ipopt itself misses.
425    ///
426    /// Only rung 3 opens on this trigger; rungs 1 and 2 stay gated on
427    /// [`SecondOpinionTrigger::LocalInfeasibility`], so a restoration failure
428    /// costs exactly one extra solve. That is the measured ordering, not
429    /// caution for its own sake: over the KRONOS corpus the displaced start
430    /// recovered 13 of 15 where `mu_strategy=adaptive` recovered 4 (see
431    /// `start_point_retry`'s option text).
432    pub fn for_status(status: ApplicationReturnStatus) -> Option<Self> {
433        match status {
434            ApplicationReturnStatus::InfeasibleProblemDetected => {
435                Some(SecondOpinionTrigger::LocalInfeasibility)
436            }
437            ApplicationReturnStatus::InvalidNumberDetected => {
438                Some(SecondOpinionTrigger::InvalidNumber)
439            }
440            ApplicationReturnStatus::RestorationFailed => {
441                Some(SecondOpinionTrigger::RestorationFailure)
442            }
443            // The exception to the paragraph above, and it is an exception
444            // to the *reason* rather than a change of mind about it
445            // (gh#857). "A re-solve would burn the same budget to reach the
446            // same wall" is true of a budget exit whose trajectory the
447            // ladder cannot change — and a `feral_increase_quality`
448            // escalation is precisely a trajectory the ladder *can* change,
449            // because it persists across every later factorization and one
450            // option removes it. Measured: `square_flowsheet_resto`'s lbfgs
451            // leg hits the 3000-iteration cap having escalated 25 times, and
452            // converges in 178 with the rung off.
453            //
454            // This returns `Some` for every budget exit; the escalation
455            // count is not visible here. `second_opinion_rungs` drops the
456            // rung when the count is zero, so a non-escalating budget exit
457            // opens no *option* rung — `a_budget_exit_that_never_escalated_opens_no_rung`
458            // is the pin. It can still open the ladder's last stage: on a
459            // model `square_system::probe` admits (square, equality-only,
460            // `∇f` exactly zero at the start) the driver runs the
461            // square-system LM on any trigger this returns, escalation or
462            // not, because that stage replaces the method rather than
463            // re-running its trajectory. On every other model a
464            // non-escalating budget exit is unchanged — the driver returns
465            // before narrating, with the same status, statistics and
466            // console.
467            ApplicationReturnStatus::MaximumIterationsExceeded => {
468                Some(SecondOpinionTrigger::IterationLimit)
469            }
470            ApplicationReturnStatus::ErrorInStepComputation => {
471                Some(SecondOpinionTrigger::StepComputationError)
472            }
473            _ => None,
474        }
475    }
476
477    /// The verdict alone, with no claim about how the solve got there —
478    /// for a console line that does not depend on an escalation having
479    /// happened (the square-system stage opens on a budget exit that never
480    /// escalated, where [`Self::describe`]'s wording would be false).
481    pub fn verdict(self) -> &'static str {
482        match self {
483            SecondOpinionTrigger::IterationLimit => "iteration limit",
484            other => other.describe(),
485        }
486    }
487
488    /// The word for this trigger in the option rungs' console line. For
489    /// [`SecondOpinionTrigger::IterationLimit`] it names the escalation,
490    /// which is true there: no option rung opens on a budget exit that did
491    /// not escalate.
492    pub fn describe(self) -> &'static str {
493        match self {
494            SecondOpinionTrigger::LocalInfeasibility => "local infeasibility",
495            SecondOpinionTrigger::InvalidNumber => "invalid number",
496            SecondOpinionTrigger::RestorationFailure => "restoration failure",
497            SecondOpinionTrigger::StepComputationError => "step computation breakdown",
498            SecondOpinionTrigger::IterationLimit => {
499                "iteration limit after a factorization escalation"
500            }
501        }
502    }
503}
504
505impl SecondOpinionAvailability {
506    /// Read everything the ladder needs to know about the baseline solve out
507    /// of the options it ran under.
508    ///
509    /// The scaling and barrier tags are read from the **resolved** strategy,
510    /// not from the option strings. `feral_scaling` is applied only when set
511    /// explicitly and otherwise `FeralConfig::from_env()` governs via
512    /// `POUNCE_FERAL_SCALING`, so the option string reads `auto` for an
513    /// env-configured run; writing that back would silently override the
514    /// environment on the retry instead of restoring it. `External` is
515    /// unreachable from the string option, and if it ever arrives here there
516    /// is no tag to write, so the rungs that need one are dropped rather than
517    /// guessed at.
518    /// `baseline_quality_escalations` is the failing solve's
519    /// `SolveStatistics::quality_escalations`, and it is a **required
520    /// parameter** rather than a defaulted setter for the reason gh#857
521    /// exists at all: the quantity is invisible everywhere else, so a caller
522    /// that forgot it would silently pass `0`, rung 4 would never open, and
523    /// the recovery would look like a rung that simply does not fire. A
524    /// missing argument is a compile error; a forgotten setter is a
525    /// regression nobody can see.
526    pub fn from_options(
527        options: &OptionsList,
528        trigger: SecondOpinionTrigger,
529        baseline_quality_escalations: u64,
530    ) -> Self {
531        // Each of the three `*_retry` flags is read with its tag written out
532        // as a literal rather than through a shared closure: `init_options_wiring`
533        // proves every registered Initialization option is actually consumed by
534        // scanning the source for `get_*_value("<tag>"`, and a closure taking
535        // the tag as a parameter is invisible to that scan — which would leave
536        // `infeasibility_perturbed_start_retry` looking like a knob that
537        // validates, accepts a value and does nothing.
538        let scaling = crate::application::feral_config_from_options(options).scaling;
539        let baseline_scaling = match scaling {
540            pounce_feral::ScalingStrategy::Auto => Some("auto"),
541            pounce_feral::ScalingStrategy::InfNorm => Some("infnorm"),
542            pounce_feral::ScalingStrategy::Mc64Symmetric => Some("mc64"),
543            pounce_feral::ScalingStrategy::Identity => Some("identity"),
544            pounce_feral::ScalingStrategy::External(_) => None,
545        };
546        let already_adaptive = options
547            .get_string_value("mu_strategy", "")
548            .map(|(v, _found)| v == "adaptive")
549            .unwrap_or(false);
550        Self {
551            trigger,
552            scaling_retry_enabled: options
553                .get_bool_value("feral_infeasibility_scaling_retry", "")
554                .map(|(v, _found)| v)
555                .unwrap_or(true),
556            mu_retry_enabled: options
557                .get_bool_value("infeasibility_mu_strategy_retry", "")
558                .map(|(v, _found)| v)
559                .unwrap_or(true),
560            perturbed_start_retry_enabled: options
561                .get_bool_value("infeasibility_perturbed_start_retry", "")
562                .map(|(v, _found)| v)
563                .unwrap_or(true),
564            already_mc64: matches!(scaling, pounce_feral::ScalingStrategy::Mc64Symmetric),
565            already_adaptive,
566            already_perturbed: options
567                .get_numeric_value("start_point_perturbation", "")
568                .map(|(v, _found)| v > 0.0)
569                .unwrap_or(false),
570            increase_quality_retry_enabled: options
571                .get_bool_value("feral_increase_quality_retry", "")
572                .map(|(v, _found)| v)
573                .unwrap_or(true),
574            // The rung would set `feral_increase_quality` to the value the
575            // failing solve already ran under, so it is a no-op re-solve.
576            // Read as a *value*, not as set-ness: the default is `yes`, so
577            // an unset option is not already-no.
578            already_no_increase_quality: options
579                .get_bool_value("feral_increase_quality", "")
580                .map(|(v, _found)| !v)
581                .unwrap_or(false),
582            baseline_quality_escalations,
583            baseline_scaling,
584            // `mu_strategy` has exactly two registered values, so "not
585            // adaptive" is "monotone" and there is no third case to guess at.
586        }
587    }
588}
589
590#[cfg(test)]
591mod scaling_retry_tests {
592    use super::{
593        SecondOpinionAvailability, SecondOpinionTrigger, narration_is_wanted,
594        resolve_scaling_retry_outcome, scaling_retry_promoted, second_opinion_rungs,
595    };
596    use pounce_common::options_list::OptionsList;
597    use pounce_nlp::SolveStatistics;
598    use pounce_nlp::return_codes::ApplicationReturnStatus;
599
600    fn avail() -> SecondOpinionAvailability {
601        SecondOpinionAvailability {
602            trigger: SecondOpinionTrigger::LocalInfeasibility,
603            scaling_retry_enabled: true,
604            mu_retry_enabled: true,
605            perturbed_start_retry_enabled: true,
606            already_mc64: false,
607            already_adaptive: false,
608            already_perturbed: false,
609            increase_quality_retry_enabled: true,
610            already_no_increase_quality: false,
611            // Zero, so that every test written before gh#857 keeps asserting
612            // exactly the ladder it asserted then: rung 4's gate is a count,
613            // and a solve that did not escalate cannot reach it. The tests
614            // that are about rung 4 raise it explicitly, which is also how
615            // they document that the count is the thing doing the work.
616            baseline_quality_escalations: 0,
617            baseline_scaling: Some("auto"),
618        }
619    }
620
621    /// The default ladder is three rungs, in increasing order of how much
622    /// they change: linear algebra, then barrier trajectory, then the point
623    /// the trajectory starts from.
624    #[test]
625    fn default_ladder_is_scaling_then_barrier_strategy_then_start() {
626        let rungs = second_opinion_rungs(avail());
627        let labels: Vec<_> = rungs.iter().map(|r| r.label).collect();
628        assert_eq!(
629            labels,
630            [
631                "feral_scaling=mc64",
632                "mu_strategy=adaptive",
633                "start_point_perturbation=1e-2"
634            ]
635        );
636    }
637
638    /// gh #524: the rungs are applied to the *baseline*, not stacked, because
639    /// on `cresc4` `mu_strategy=adaptive` recovers the optimum while
640    /// `mu_strategy=adaptive` together with `feral_scaling=mc64` still reports
641    /// local infeasibility — a cumulative ladder would throw the fix away.
642    ///
643    /// The barrier rung therefore carries `mu_strategy` and nothing else;
644    /// rung 1's scaling is undone by the driver re-applying its snapshot, and
645    /// `second_opinion_driver::tests::each_rung_starts_from_the_baseline`
646    /// is where the undo itself is pinned.
647    #[test]
648    fn barrier_rung_varies_only_the_barrier_strategy() {
649        for baseline in ["auto", "infnorm"] {
650            let rungs = second_opinion_rungs(SecondOpinionAvailability {
651                baseline_scaling: Some(baseline),
652                ..avail()
653            });
654            let barrier = rungs
655                .iter()
656                .find(|r| r.label == "mu_strategy=adaptive")
657                .expect("barrier rung present");
658            let assigned: Vec<_> = barrier.assignments.iter().map(|a| a.trim()).collect();
659            assert_eq!(assigned, ["mu_strategy adaptive"]);
660        }
661    }
662
663    /// A rung that cannot change anything is dropped rather than burning a
664    /// whole solve to re-derive the same answer.
665    #[test]
666    fn rungs_already_satisfied_at_baseline_are_dropped() {
667        let only_barrier = second_opinion_rungs(SecondOpinionAvailability {
668            already_mc64: true,
669            ..avail()
670        });
671        assert_eq!(
672            only_barrier.iter().map(|r| r.label).collect::<Vec<_>>(),
673            ["mu_strategy=adaptive", "start_point_perturbation=1e-2"],
674        );
675
676        let only_scaling = second_opinion_rungs(SecondOpinionAvailability {
677            already_adaptive: true,
678            ..avail()
679        });
680        assert_eq!(
681            only_scaling.iter().map(|r| r.label).collect::<Vec<_>>(),
682            ["feral_scaling=mc64", "start_point_perturbation=1e-2"],
683        );
684
685        assert!(
686            second_opinion_rungs(SecondOpinionAvailability {
687                already_mc64: true,
688                already_adaptive: true,
689                already_perturbed: true,
690                ..avail()
691            })
692            .is_empty(),
693            "nothing left to vary means no ladder at all",
694        );
695    }
696
697    /// A resolved scaling with no `feral_scaling` tag
698    /// (`ScalingStrategy::External`) drops the barrier rung; the scaling rung
699    /// is unaffected. The gate is now conservatism rather than necessity —
700    /// the driver restores by set-ness, so a missing tag no longer strands
701    /// the rung under rung 1's scaling — and it is kept because lifting it
702    /// would add rungs on externally-scaled models, which is a trajectory
703    /// change. See the note on `baseline_scaling`.
704    #[test]
705    fn barrier_rung_is_dropped_when_the_baseline_scaling_has_no_tag() {
706        let rungs = second_opinion_rungs(SecondOpinionAvailability {
707            baseline_scaling: None,
708            ..avail()
709        });
710        assert_eq!(
711            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
712            ["feral_scaling=mc64"],
713        );
714    }
715
716    /// Each rung has its own opt-out, and turning both off restores upstream
717    /// IPOPT's behaviour of shipping the first verdict.
718    #[test]
719    fn each_rung_can_be_disabled_independently() {
720        assert_eq!(
721            second_opinion_rungs(SecondOpinionAvailability {
722                scaling_retry_enabled: false,
723                ..avail()
724            })
725            .iter()
726            .map(|r| r.label)
727            .collect::<Vec<_>>(),
728            ["mu_strategy=adaptive", "start_point_perturbation=1e-2"],
729        );
730        assert_eq!(
731            second_opinion_rungs(SecondOpinionAvailability {
732                mu_retry_enabled: false,
733                ..avail()
734            })
735            .iter()
736            .map(|r| r.label)
737            .collect::<Vec<_>>(),
738            ["feral_scaling=mc64", "start_point_perturbation=1e-2"],
739        );
740        assert_eq!(
741            second_opinion_rungs(SecondOpinionAvailability {
742                perturbed_start_retry_enabled: false,
743                ..avail()
744            })
745            .iter()
746            .map(|r| r.label)
747            .collect::<Vec<_>>(),
748            ["feral_scaling=mc64", "mu_strategy=adaptive"],
749        );
750        assert!(
751            second_opinion_rungs(SecondOpinionAvailability {
752                scaling_retry_enabled: false,
753                mu_retry_enabled: false,
754                perturbed_start_retry_enabled: false,
755                ..avail()
756            })
757            .is_empty(),
758        );
759    }
760
761    /// gh #524's lesson applied to the third rung: it varies exactly one thing
762    /// from the *baseline*. It undoes the earlier rungs by having the driver
763    /// re-apply the snapshot, so its own assignment list is the displacement
764    /// and nothing else.
765    #[test]
766    fn start_rung_assigns_only_the_displacement() {
767        for baseline_scaling in ["auto", "infnorm"] {
768            let rungs = second_opinion_rungs(SecondOpinionAvailability {
769                baseline_scaling: Some(baseline_scaling),
770                ..avail()
771            });
772            let start = rungs
773                .iter()
774                .find(|r| r.label == "start_point_perturbation=1e-2")
775                .expect("start rung present");
776            let assigned: Vec<_> = start.assignments.iter().map(|a| a.trim()).collect();
777            assert_eq!(assigned, ["start_point_perturbation 1e-2"]);
778        }
779    }
780
781    /// The regression this file exists to prevent a second time.
782    ///
783    /// Rungs 2 and 3 used to undo their predecessors by writing the
784    /// baseline's *resolved* value back. For a knob the caller never set that
785    /// is a no-op by value and a change by set-ness, and
786    /// `is_mu_strategy_fallback_enabled` reads set-ness: it is default-on
787    /// only while `mu_strategy` is unset. So rung 3 re-asserting a resolved
788    /// `monotone` turned pounce's own μ-strategy stall retry off for the
789    /// length of the rung. On KRONOS `a18_ackley1` that is the difference
790    /// between `Solve_Succeeded` in 237 iterations and
791    /// `Maximum_Iterations_Exceeded` at 3000.
792    ///
793    /// No rung may name a knob it is not there to vary — restoring is the
794    /// driver's job, because only the snapshot knows set-ness.
795    #[test]
796    fn no_rung_writes_back_a_knob_it_does_not_vary() {
797        for avail in [
798            avail(),
799            SecondOpinionAvailability {
800                already_adaptive: true,
801                ..avail()
802            },
803            SecondOpinionAvailability {
804                trigger: SecondOpinionTrigger::InvalidNumber,
805                ..avail()
806            },
807        ] {
808            for rung in second_opinion_rungs(avail) {
809                let varies = rung.label.split('=').next().expect("label has a tag");
810                for a in &rung.assignments {
811                    let tag = a.trim().split_whitespace().next().expect("tag");
812                    assert_eq!(
813                        tag,
814                        varies,
815                        "rung `{}` writes `{}`, which it does not vary",
816                        rung.label,
817                        a.trim(),
818                    );
819                }
820            }
821        }
822    }
823
824    /// Rung 3 sits behind the same `baseline_scaling` gate as rung 2 and is
825    /// dropped with it, for the same reason: kept as conservatism about a
826    /// trajectory change on externally-scaled models, not because the rung
827    /// needs a tag to put back.
828    #[test]
829    fn start_rung_is_dropped_when_the_baseline_scaling_has_no_tag() {
830        let rungs = second_opinion_rungs(SecondOpinionAvailability {
831            baseline_scaling: None,
832            ..avail()
833        });
834        assert!(
835            !rungs
836                .iter()
837                .any(|r| r.label == "start_point_perturbation=1e-2"),
838            "{:?}",
839            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
840        );
841    }
842
843    /// The console gate, which both printing surfaces share. An explicit `0`
844    /// is silence; anything above it, or no setting at all, stays loud — a
845    /// caller who did not ask for quiet is not asking for less.
846    #[test]
847    fn narration_follows_print_level() {
848        let mut opts = OptionsList::new();
849        // Unset: narrate, the pre-gate behaviour.
850        assert!(narration_is_wanted(&opts));
851        // …and an unset level narrates whatever the registered default reads
852        // as, which is what distinguishes "nobody asked" from "asked for 0".
853        for (level, want) in [(0, false), (1, true), (5, true), (12, true)] {
854            opts.set_integer_value("print_level", level, true, true)
855                .unwrap();
856            assert_eq!(narration_is_wanted(&opts), want, "print_level={level}");
857        }
858    }
859
860    /// An `Invalid_Number_Detected` reaches only the rung that moves the
861    /// point. Re-running the same callbacks at the same point under a
862    /// different linear-solver scaling or a different barrier strategy
863    /// evaluates the same non-finite quantity again, so those two rungs are
864    /// not evidence about this failure and would only burn solves.
865    #[test]
866    fn an_invalid_number_reaches_only_the_start_rung() {
867        let rungs = second_opinion_rungs(SecondOpinionAvailability {
868            trigger: SecondOpinionTrigger::InvalidNumber,
869            ..avail()
870        });
871        assert_eq!(
872            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
873            ["start_point_perturbation=1e-2"],
874        );
875    }
876
877    /// gh#815. A restoration failure opens the ladder, and opens exactly the
878    /// one rung that is evidence about it. Rungs 1 and 2 vary the *path* from
879    /// the same starting point; the restoration sub-problem failed because of
880    /// where the iterate got to, and a different path can arrive somewhere
881    /// just as bad. Rung 3 moves the point, which makes it a different
882    /// sub-problem — and it is the rung the KRONOS measurement ranks first
883    /// (13 of 15 against `mu_strategy=adaptive`'s 4).
884    #[test]
885    fn a_restoration_failure_reaches_only_the_start_rung() {
886        let rungs = second_opinion_rungs(SecondOpinionAvailability {
887            trigger: SecondOpinionTrigger::RestorationFailure,
888            ..avail()
889        });
890        assert_eq!(
891            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
892            ["start_point_perturbation=1e-2"],
893        );
894    }
895
896    /// gh#857 rung 4 on a restoration failure, and the ordering claim that
897    /// makes it cheap: it is **appended**, so the gh#815 rung still runs
898    /// first and still promotes first. `square_flowsheet_resto`'s exact leg
899    /// is exactly that case, and is why this rung costs it nothing.
900    #[test]
901    fn an_escalating_restoration_failure_appends_the_quality_rung() {
902        let rungs = second_opinion_rungs(SecondOpinionAvailability {
903            trigger: SecondOpinionTrigger::RestorationFailure,
904            baseline_quality_escalations: 2,
905            ..avail()
906        });
907        assert_eq!(
908            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
909            ["start_point_perturbation=1e-2", "feral_increase_quality=no",],
910        );
911    }
912
913    /// The whole ladder on a budget exit is rung 4, and it is there only
914    /// because the solve escalated.
915    #[test]
916    fn an_escalating_budget_exit_reaches_only_the_quality_rung() {
917        let rungs = second_opinion_rungs(SecondOpinionAvailability {
918            trigger: SecondOpinionTrigger::IterationLimit,
919            baseline_quality_escalations: 25,
920            ..avail()
921        });
922        assert_eq!(
923            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
924            ["feral_increase_quality=no"],
925        );
926        assert_eq!(rungs[0].assignments, ["feral_increase_quality no\n"]);
927    }
928
929    /// The third shape an escalation-rerouted trajectory takes, and the one
930    /// that is platform-dependent.
931    ///
932    /// `square_flowsheet_resto`'s lbfgs leg runs 3000 iterations and escalates
933    /// 25 times on both macOS/arm64 and linux/x86_64, and then exits
934    /// `Maximum_Iterations_Exceeded` on the first and
935    /// `Infeasible_Problem_Detected` on the second — a wrong answer on a
936    /// feasible model that the un-escalated solve reaches in 178 iterations.
937    /// Rung 4 opened on two of the three shapes until that divergence turned
938    /// up in CI, which meant the gh#857 fix recovered the model on one
939    /// platform and not the other.
940    ///
941    /// Appended here too, so the three infeasibility rungs still run and still
942    /// promote first: this is what is left when they do not.
943    #[test]
944    fn an_escalating_infeasibility_verdict_appends_the_quality_rung() {
945        let rungs = second_opinion_rungs(SecondOpinionAvailability {
946            trigger: SecondOpinionTrigger::LocalInfeasibility,
947            baseline_quality_escalations: 25,
948            ..avail()
949        });
950        assert_eq!(
951            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
952            [
953                "feral_scaling=mc64",
954                "mu_strategy=adaptive",
955                "start_point_perturbation=1e-2",
956                "feral_increase_quality=no",
957            ],
958        );
959    }
960
961    /// And the gate holds on that trigger too: a local-infeasibility verdict
962    /// from a solve that never escalated opens the same three rungs it opened
963    /// before gh#857, and pays for no fourth.
964    #[test]
965    fn an_infeasibility_verdict_that_never_escalated_gets_no_quality_rung() {
966        let rungs = second_opinion_rungs(SecondOpinionAvailability {
967            trigger: SecondOpinionTrigger::LocalInfeasibility,
968            baseline_quality_escalations: 0,
969            ..avail()
970        });
971        assert_eq!(
972            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
973            [
974                "feral_scaling=mc64",
975                "mu_strategy=adaptive",
976                "start_point_perturbation=1e-2",
977            ],
978        );
979    }
980
981    /// The other branch of rung 4's gate, and the one that keeps `for_status`
982    /// naming a trigger for every budget exit from costing anything. Without
983    /// this the change would put an extra solve on every
984    /// `Maximum_Iterations_Exceeded` in the corpus.
985    ///
986    /// An empty ladder is not merely "no rung ran": the driver returns
987    /// `unchanged` on an empty list *before* it narrates, so such a solve is
988    /// byte-identical to its pre-gh#857 self.
989    #[test]
990    fn a_budget_exit_that_never_escalated_opens_no_rung() {
991        let rungs = second_opinion_rungs(SecondOpinionAvailability {
992            trigger: SecondOpinionTrigger::IterationLimit,
993            baseline_quality_escalations: 0,
994            ..avail()
995        });
996        assert!(
997            rungs.is_empty(),
998            "a budget exit with no escalation has nothing for the ladder to \
999             test, and must not pay for a solve: {:?}",
1000            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
1001        );
1002    }
1003
1004    /// The displacement rung does **not** open on a budget exit, even an
1005    /// escalating one. It is evidence about a failed *path*, not about a
1006    /// budget, and adding it here would double the cost of the recovery
1007    /// while testing a hypothesis nobody has measured.
1008    #[test]
1009    fn a_budget_exit_does_not_reach_the_displacement_rung() {
1010        let rungs = second_opinion_rungs(SecondOpinionAvailability {
1011            trigger: SecondOpinionTrigger::IterationLimit,
1012            baseline_quality_escalations: 7,
1013            ..avail()
1014        });
1015        assert!(
1016            !rungs
1017                .iter()
1018                .any(|r| r.label == "start_point_perturbation=1e-2"),
1019            "{:?}",
1020            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
1021        );
1022    }
1023
1024    /// Rung 4 turns off with its own option, like every other rung, and is
1025    /// dropped when the baseline already ran with the escalation disabled —
1026    /// where it would re-run the solve that just failed.
1027    #[test]
1028    fn the_quality_rung_is_droppable_and_never_a_no_op() {
1029        let escalating = SecondOpinionAvailability {
1030            trigger: SecondOpinionTrigger::IterationLimit,
1031            baseline_quality_escalations: 3,
1032            ..avail()
1033        };
1034        assert!(
1035            second_opinion_rungs(SecondOpinionAvailability {
1036                increase_quality_retry_enabled: false,
1037                ..escalating
1038            })
1039            .is_empty()
1040        );
1041        assert!(
1042            second_opinion_rungs(SecondOpinionAvailability {
1043                already_no_increase_quality: true,
1044                ..escalating
1045            })
1046            .is_empty()
1047        );
1048    }
1049
1050    /// The status → trigger map is the whole opt-in surface, so pin both
1051    /// halves: the verdicts that open a ladder, and a representative exit
1052    /// that must not. `MaximumIterationsExceeded` is the case the
1053    /// doc comment argues about — a bigger budget is the answer there, and a
1054    /// re-solve would burn the same budget to reach the same wall.
1055    #[test]
1056    fn only_path_verdicts_open_a_ladder() {
1057        use ApplicationReturnStatus as A;
1058        for (status, want) in [
1059            (
1060                A::InfeasibleProblemDetected,
1061                Some(SecondOpinionTrigger::LocalInfeasibility),
1062            ),
1063            (
1064                A::InvalidNumberDetected,
1065                Some(SecondOpinionTrigger::InvalidNumber),
1066            ),
1067            (
1068                A::RestorationFailed,
1069                Some(SecondOpinionTrigger::RestorationFailure),
1070            ),
1071            // gh#857: a budget exit now names a trigger, but naming one is
1072            // not opening a ladder — the rung it names is gated on the
1073            // escalation count, which `for_status` cannot see. The pair of
1074            // tests below is what says the distinction holds.
1075            (
1076                A::MaximumIterationsExceeded,
1077                Some(SecondOpinionTrigger::IterationLimit),
1078            ),
1079            // The wasm32 case from gh#960. This row read `None` until deb7 under the wasm32
1080            // build produced a step-computation breakdown that
1081            // `mu_strategy=adaptive` recovers outright — the rescue was in
1082            // the ladder already and only the mapping was missing. Unlike
1083            // a budget exit, a breakdown is not a request for more of
1084            // anything: the trajectory reached a KKT system no
1085            // regularization could make usable, and only a different
1086            // trajectory answers it.
1087            (
1088                A::ErrorInStepComputation,
1089                Some(SecondOpinionTrigger::StepComputationError),
1090            ),
1091            (A::MaximumCpuTimeExceeded, None),
1092            (A::SolveSucceeded, None),
1093            (A::SolvedToAcceptableLevel, None),
1094        ] {
1095            assert_eq!(
1096                SecondOpinionTrigger::for_status(status),
1097                want,
1098                "{status:?} opened the wrong ladder"
1099            );
1100        }
1101    }
1102
1103    /// The wasm32 case from gh#960. A step-computation breakdown opens exactly one rung, and it
1104    /// is the barrier-schedule one.
1105    ///
1106    /// The other three are excluded on measurement, not on principle: on
1107    /// `deb7` under the wasm32 build — the case that found this — `mc64`
1108    /// still fails at 154 iterations, `feral_increase_quality=no` at 201,
1109    /// and `start_point_perturbation` burns 324 to fail anyway, while this
1110    /// rung solves in 131. A perturbed-start rung here would have doubled
1111    /// the cost of a failure it cannot fix.
1112    #[test]
1113    fn a_step_computation_breakdown_opens_only_the_barrier_rung() {
1114        let base = SecondOpinionAvailability {
1115            trigger: SecondOpinionTrigger::StepComputationError,
1116            // Rung 4's own gate is open, so only the trigger keeps it out;
1117            // otherwise this would pass for the wrong reason.
1118            baseline_quality_escalations: 2,
1119            ..avail()
1120        };
1121        assert_eq!(
1122            second_opinion_rungs(base)
1123                .iter()
1124                .map(|r| r.label)
1125                .collect::<Vec<_>>(),
1126            vec!["mu_strategy=adaptive"]
1127        );
1128
1129        // The option still governs it, and a solve already on the adaptive
1130        // schedule has nothing left to try.
1131        for off in [
1132            SecondOpinionAvailability {
1133                mu_retry_enabled: false,
1134                ..base
1135            },
1136            SecondOpinionAvailability {
1137                already_adaptive: true,
1138                ..base
1139            },
1140        ] {
1141            let rungs = second_opinion_rungs(off);
1142            assert!(
1143                rungs.is_empty(),
1144                "{:?}",
1145                rungs.iter().map(|r| r.label).collect::<Vec<_>>()
1146            );
1147        }
1148    }
1149
1150    /// …and disabling that rung leaves an invalid-number run with no ladder at
1151    /// all, rather than falling back to the two rungs that cannot help.
1152    #[test]
1153    fn an_invalid_number_with_the_start_rung_off_has_no_ladder() {
1154        assert!(
1155            second_opinion_rungs(SecondOpinionAvailability {
1156                trigger: SecondOpinionTrigger::InvalidNumber,
1157                perturbed_start_retry_enabled: false,
1158                ..avail()
1159            })
1160            .is_empty(),
1161        );
1162    }
1163
1164    /// A baseline that already displaces the start has nothing left for rung 3
1165    /// to add: re-running with the same displacement reproduces the failing
1166    /// solve.
1167    #[test]
1168    fn a_baseline_that_already_perturbs_drops_the_start_rung() {
1169        let rungs = second_opinion_rungs(SecondOpinionAvailability {
1170            already_perturbed: true,
1171            ..avail()
1172        });
1173        assert_eq!(
1174            rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
1175            ["feral_scaling=mc64", "mu_strategy=adaptive"],
1176        );
1177    }
1178
1179    /// The verdict a failed ladder keeps is the one the solve actually
1180    /// shipped. Before the ladder took `Invalid_Number_Detected` as a trigger
1181    /// this function hard-coded `Infeasible_Problem_Detected`, which for the
1182    /// new trigger would have reported the wrong failure.
1183    #[test]
1184    fn a_failed_ladder_keeps_whichever_verdict_opened_it() {
1185        for original in [
1186            ApplicationReturnStatus::InfeasibleProblemDetected,
1187            ApplicationReturnStatus::InvalidNumberDetected,
1188        ] {
1189            let (status, stats) = resolve_scaling_retry_outcome(
1190                original,
1191                ApplicationReturnStatus::MaximumIterationsExceeded,
1192                stats_with_iters(7),
1193                stats_with_iters(42),
1194            );
1195            assert_eq!(status, original);
1196            assert_eq!(stats.iteration_count, 7);
1197        }
1198    }
1199
1200    fn stats_with_iters(n: i32) -> SolveStatistics {
1201        SolveStatistics {
1202            iteration_count: n,
1203            final_objective: n as f64,
1204            ..SolveStatistics::default()
1205        }
1206    }
1207
1208    /// Code review L23: when the MC64 hypersensitivity re-solve does **not**
1209    /// recover, the verdict reverts to the original local-infeasibility status
1210    /// — and the reported statistics must revert with it, not leak the failed
1211    /// retry's iteration count / objective.
1212    #[test]
1213    fn failed_retry_keeps_original_status_and_stats() {
1214        let original = stats_with_iters(7);
1215        let retry = stats_with_iters(42);
1216        for retry_status in [
1217            ApplicationReturnStatus::InfeasibleProblemDetected,
1218            ApplicationReturnStatus::MaximumIterationsExceeded,
1219            ApplicationReturnStatus::RestorationFailed,
1220        ] {
1221            assert!(!scaling_retry_promoted(retry_status));
1222            let (status, stats) = resolve_scaling_retry_outcome(
1223                ApplicationReturnStatus::InfeasibleProblemDetected,
1224                retry_status,
1225                original.clone(),
1226                retry.clone(),
1227            );
1228            assert_eq!(
1229                status,
1230                ApplicationReturnStatus::InfeasibleProblemDetected,
1231                "a non-promoting retry ({retry_status:?}) keeps the original verdict"
1232            );
1233            assert_eq!(
1234                stats.iteration_count, 7,
1235                "stats must stay the original solve's, not the failed retry's"
1236            );
1237            assert_eq!(stats.final_objective, 7.0);
1238        }
1239    }
1240
1241    /// On promotion the retry is authoritative: its status AND its statistics
1242    /// are reported together.
1243    #[test]
1244    fn promoted_retry_adopts_retry_status_and_stats() {
1245        let original = stats_with_iters(7);
1246        let retry = stats_with_iters(42);
1247        for retry_status in [
1248            ApplicationReturnStatus::SolveSucceeded,
1249            ApplicationReturnStatus::SolvedToAcceptableLevel,
1250        ] {
1251            assert!(scaling_retry_promoted(retry_status));
1252            let (status, stats) = resolve_scaling_retry_outcome(
1253                ApplicationReturnStatus::InfeasibleProblemDetected,
1254                retry_status,
1255                original.clone(),
1256                retry.clone(),
1257            );
1258            assert_eq!(status, retry_status, "a promoting retry adopts its verdict");
1259            assert_eq!(
1260                stats.iteration_count, 42,
1261                "promoted: stats must be the retry solve's"
1262            );
1263            assert_eq!(stats.final_objective, 42.0);
1264        }
1265    }
1266}