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}