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}