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