Skip to main content

pounce_algorithm/mu/
monotone.rs

1//! Monotone Fiacco-McCormick mu update — port of
2//! `Algorithm/IpMonotoneMuUpdate.{hpp,cpp}`.
3//!
4//! Reduces mu by either `mu_linear_decrease_factor` or
5//! `pow(mu, mu_superlinear_decrease_power)`, taking the smaller value
6//! and clamping to `mu_min`. Bit-exact with upstream.
7
8use crate::ipopt_cq::IpoptCqHandle;
9use crate::ipopt_data::IpoptDataHandle;
10use crate::mu::r#trait::MuUpdate;
11use pounce_common::types::Number;
12
13pub struct MonotoneMuUpdate {
14    pub mu_init: Number,
15    pub mu_min: Number,
16    /// Upper bound on μ from `IpMonotoneMuUpdate.cpp:RegisterOptions`.
17    /// Used to clamp `mu_init` at [`MuUpdate::initialize`] so the
18    /// barrier doesn't start above the registered ceiling regardless
19    /// of what the user set. Default `1e5` mirrors upstream.
20    pub mu_max: Number,
21    pub mu_linear_decrease_factor: Number,
22    pub mu_superlinear_decrease_power: Number,
23    pub tau_min: Number,
24    /// `barrier_tol_factor` from `IpMonotoneMuUpdate.cpp:RegisterOptions`.
25    /// μ only decreases when the barrier subproblem error drops below
26    /// `barrier_tol_factor · μ`.
27    pub barrier_tol_factor: Number,
28    /// `mu_target` floor — μ never goes below this regardless of the
29    /// reduction formula. Defaults to 0 (the floor is `mu_min`).
30    pub mu_target: Number,
31    /// `mu_allow_fast_monotone_decrease` from
32    /// `IpMonotoneMuUpdate.cpp:RegisterOptions`. When `true` (the
33    /// upstream default), the reduction loop keeps iterating while
34    /// the sub-error stays below `barrier_tol_factor · μ`, allowing
35    /// multiple consecutive μ reductions in one outer call. When
36    /// `false`, the loop exits after the first successful reduction —
37    /// useful on stiff problems where a runaway μ collapse destroys
38    /// the line search.
39    pub mu_allow_fast_monotone_decrease: bool,
40    /// Complementarity tolerance — option `compl_inf_tol`, default 1e-4
41    /// per `IpAlgorithmRegOp.cpp`. Enters the dynamic μ floor via
42    /// `min(tol, compl_inf_tol) / (barrier_tol_factor + 1)` per
43    /// `IpMonotoneMuUpdate.cpp:CalcNewMuAndTau:215`. Without this floor,
44    /// μ can collapse to the absolute floor (`mu_min`) while primal
45    /// infeasibility is still large — observed on SSINE/DECONVBNE.
46    pub compl_inf_tol: Number,
47    /// `first_iter_resto_` flag from
48    /// `Algorithm/IpMonotoneMuUpdate.cpp:118-121,144,196`. When set,
49    /// the very next call to [`Self::update_barrier_parameter`] skips
50    /// the μ-reduction loop entirely and clears the flag. Wired by
51    /// the restoration sub-builder for the inner IPM (prefix
52    /// `"resto."`) so the inner doesn't immediately collapse μ on
53    /// iteration 0 — it must use the `resto_mu` value that
54    /// [`crate::resto::init::RestoIterateInitializer::SetInitialIterates`]
55    /// seeded into `data.curr_mu`.
56    pub first_iter_resto: bool,
57}
58
59impl Default for MonotoneMuUpdate {
60    fn default() -> Self {
61        // Defaults from `IpMonotoneMuUpdate.cpp:RegisterOptions`.
62        Self {
63            mu_init: 0.1,
64            mu_min: 1e-11,
65            mu_max: 1e5,
66            mu_linear_decrease_factor: 0.2,
67            mu_superlinear_decrease_power: 1.5,
68            tau_min: 0.99,
69            barrier_tol_factor: 10.0,
70            mu_target: 0.0,
71            mu_allow_fast_monotone_decrease: true,
72            compl_inf_tol: 1e-4,
73            first_iter_resto: false,
74        }
75    }
76}
77
78impl MonotoneMuUpdate {
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Builder helper for the `first_iter_resto_` flag. Mirrors the
84    /// upstream `prefix == "resto."` branch in
85    /// `IpMonotoneMuUpdate.cpp:InitializeImpl`.
86    pub fn with_first_iter_resto(mut self, b: bool) -> Self {
87        self.first_iter_resto = b;
88        self
89    }
90
91    /// Builder for the `mu_min` floor. The restoration inner IPM uses
92    /// `100 * outer_mu_min` per upstream `IpAdaptiveMuUpdate.cpp:206-211`
93    /// (and the analogous monotone path); without the conservative
94    /// floor, near-feasible iterates collapse μ to the absolute floor
95    /// in a single step and the next direction is dominated by the
96    /// penalty/proximity terms instead of the barrier, which destroys
97    /// near-feasibility (DECONVBNE).
98    pub fn with_mu_min(mut self, mu_min: Number) -> Self {
99        self.mu_min = mu_min;
100        self
101    }
102
103    /// Fraction-to-the-bound parameter `tau` from upstream
104    /// `IpMonotoneMuUpdate.cpp:Update`:
105    ///
106    /// ```text
107    ///   tau = max(tau_min, 1 - mu)
108    /// ```
109    ///
110    /// Returns a value in `[tau_min, 1)`.
111    pub fn compute_tau(&self, mu: Number) -> Number {
112        self.tau_min.max(1.0 - mu)
113    }
114
115    /// `compl_inf_tol` expressed in the **internally scaled** space that μ
116    /// lives in (pounce#257).
117    ///
118    /// The dynamic μ floor exists so the barrier stops just below the accuracy
119    /// the convergence test demands, and its two terms are enforced in
120    /// *different spaces*. `tol` is compared against the scaled NLP error, so
121    /// it needs no conversion. `compl_inf_tol` is compared against the
122    /// **unscaled** complementarity (`IpOptErrorConvCheck.cpp`; pounce#173),
123    /// which is the scaled complementarity divided by the objective scaling
124    /// factor — so `compl_inf_tol` in *scaled* units is
125    /// `compl_inf_tol · |obj_scaling_factor|`.
126    ///
127    /// Taking the raw value put the floor `1/|df|` too high whenever the
128    /// objective was scaled down. On jit1's branch-and-bound node subproblems
129    /// (`df = 1e-5`, `tol = 1e-7`) μ bottomed out at `9.09e-9`, leaving an
130    /// unscaled complementarity of `9.09e-4` — a hard 9× over `compl_inf_tol`
131    /// that no further iteration could clear, since μ was already at its floor.
132    /// The iterate sat *at* the optimum with a scaled NLP error 10× under
133    /// `tol`, yet the strict certificate was unreachable; μ-at-floor plus the
134    /// vanishing step then exited `STOP_AT_TINY_STEP`
135    /// (`Search_Direction_Becomes_Too_Small`), which callers read as
136    /// unboundedness. Converting the tolerance into μ's own space lets the
137    /// barrier descend far enough for the certificate to be issued.
138    ///
139    /// The factor is signed (`obj_scaling_factor = -1` poses a maximization),
140    /// so take its magnitude, and fall back to the unconverted tolerance when
141    /// it is absent or degenerate — a floor that is too low only costs
142    /// iterations, whereas one that is too high costs the certificate.
143    pub fn scaled_compl_inf_tol(&self, obj_scaling_factor: Number) -> Number {
144        crate::mu::scaled_compl_inf_tol(self.compl_inf_tol, obj_scaling_factor)
145    }
146
147    /// `mu_min` capped so it can never block the termination certificate
148    /// (pounce#266) — the companion of [`Self::scaled_compl_inf_tol`].
149    ///
150    /// #258 converted the *dynamic* term of the barrier floor into μ's scaled
151    /// space, but the floor has a second, independent term: `mu_min`, a raw
152    /// absolute constant (default `1e-11`) that also lives in scaled space.
153    /// Once `compl_inf_tol·|df|/(barrier_tol_factor+1) < mu_min` — i.e.
154    /// `|df|` below `≈ mu_min·(barrier_tol_factor+1)/compl_inf_tol` — the
155    /// converted term stops mattering, μ bottoms out at `mu_min`, and the
156    /// unscaled complementarity is pinned at `mu_min/|df| > compl_inf_tol`:
157    /// the certificate is unreachable no matter how long the solve runs, and
158    /// μ-at-floor plus the vanishing step exits `STOP_AT_TINY_STEP` on an
159    /// iterate that is *at* the optimum (HS71 × 1e8, `df = 8.3e-8`).
160    ///
161    /// Upstream's monotone floor (`IpMonotoneMuUpdate.cpp:CalcNewMuAndTau`)
162    /// has no `mu_min` term at all — pounce added it so the restoration
163    /// sub-builder's `with_mu_min(100 * outer_mu_min)` safeguard applies —
164    /// which is why Ipopt certifies these files even with `mu_min=1e-11`
165    /// forced. Capping at `scaled_compl_inf_tol / (barrier_tol_factor + 1)`
166    /// keeps `mu_min` inert exactly when it would cost the certificate, with
167    /// the same headroom the dynamic floor reserves (μ then bottoms out where
168    /// Ipopt's does: `7.58e-13` on HS71 × 1e8). A floor that is too low only
169    /// costs iterations; one that is too high costs the certificate.
170    ///
171    /// The restoration safeguard is unaffected: `RestoIpoptNlp` does not
172    /// override `obj_scaling_factor`, so the inner IPM sees `df = 1` and the
173    /// cap (`compl_inf_tol/(barrier_tol_factor+1) ≈ 9e-6` at defaults) sits
174    /// far above `100 · mu_min`.
175    pub fn certificate_safe_mu_min(&self, obj_scaling_factor: Number) -> Number {
176        crate::mu::certificate_safe_mu_min(
177            self.mu_min,
178            self.compl_inf_tol,
179            self.barrier_tol_factor,
180            obj_scaling_factor,
181        )
182    }
183
184    /// Pure scalar reduction used by the trait impl. Exposed so unit
185    /// tests can drive the formula without standing up an
186    /// `IpoptData`/`IpoptCq` fixture.
187    pub fn compute_next_mu(&self, curr_mu: Number) -> Number {
188        let linear = self.mu_linear_decrease_factor * curr_mu;
189        let superlinear = curr_mu.powf(self.mu_superlinear_decrease_power);
190        linear.min(superlinear).max(self.mu_min)
191    }
192}
193
194impl MuUpdate for MonotoneMuUpdate {
195    /// Monotone μ throws `TINY_STEP_DETECTED` when a tiny step is
196    /// flagged and μ is already at its floor — see
197    /// `IpMonotoneMuUpdate.cpp`. The main loop realises that throw as a
198    /// `STOP_AT_TINY_STEP` termination.
199    fn terminates_on_tiny_step(&self) -> bool {
200        true
201    }
202
203    /// Port of `IpMonotoneMuUpdate.cpp:InitializeImpl`. Seeds
204    /// `curr_mu = min(mu_init, mu_max)`,
205    /// `curr_tau = max(tau_min, 1 - curr_mu)`.
206    fn initialize(&mut self, data: &IpoptDataHandle) {
207        let init_mu = self.mu_init.min(self.mu_max);
208        let mut d = data.borrow_mut();
209        d.curr_mu = init_mu;
210        d.curr_tau = self.compute_tau(init_mu);
211    }
212
213    /// Port of `IpMonotoneMuUpdate.cpp:UpdateBarrierParameter`.
214    /// Reduces μ only while the barrier-subproblem error is below
215    /// `barrier_tol_factor · μ` (or a tiny step was just detected).
216    /// Each successful reduction also refreshes `curr_tau` and the new
217    /// μ in `data`. Returns the post-update μ.
218    ///
219    /// A reduction also raises [`IpoptData::request_ls_reset`], which the
220    /// main loop turns into the `linesearch_->Reset()` upstream issues at
221    /// `IpMonotoneMuUpdate.cpp:165`. This is the same behaviour the
222    /// caller previously inferred from "μ changed" — the loop below only
223    /// ever exits with a strictly smaller μ — but the flag is now the
224    /// single source of truth for both μ strategies (pounce#510).
225    ///
226    /// [`IpoptData::request_ls_reset`]: crate::ipopt_data::IpoptData::request_ls_reset
227    fn update_barrier_parameter(
228        &mut self,
229        data: &IpoptDataHandle,
230        cq: &IpoptCqHandle,
231        _nlp: Option<&std::rc::Rc<std::cell::RefCell<dyn crate::ipopt_nlp::IpoptNlp>>>,
232        _pd_search_dir: Option<&mut crate::kkt::pd_search_dir_calc::PdSearchDirCalc>,
233    ) -> Number {
234        let mut mu = data.borrow().curr_mu;
235        let mut tau = data.borrow().curr_tau;
236        let tiny_step = data.borrow().tiny_step_flag;
237        let mu_at_entry = mu;
238
239        // `first_iter_resto_` (upstream `IpMonotoneMuUpdate.cpp:144`):
240        // on the first inner iteration of restoration, skip the μ
241        // reduction loop entirely so the inner uses the `resto_mu`
242        // seeded by `RestoIterateInitializer`. Cleared after this
243        // call so subsequent inner iterations behave normally.
244        if self.first_iter_resto {
245            self.first_iter_resto = false;
246            let mut d = data.borrow_mut();
247            d.curr_mu = mu;
248            d.curr_tau = tau;
249            return mu;
250        }
251
252        // Dynamic floor from `IpMonotoneMuUpdate.cpp:CalcNewMuAndTau:215`:
253        //     floor = max(mu_target, min(tol, compl_inf_tol) / (barrier_tol_factor + 1))
254        // Without this, μ collapses to `mu_min` (1e-11) while primal
255        // infeasibility is still large — observed on SSINE/DECONVBNE,
256        // where the next direction is dominated by ill-conditioned
257        // barrier terms and the line search stalls.
258        // We also `max` with `mu_min` so the restoration sub-builder's
259        // `with_mu_min(100 * outer_mu_min)` safeguard still applies —
260        // but capped by `certificate_safe_mu_min` (pounce#266): both
261        // floor terms live in μ's scaled space, and an uncapped
262        // absolute `mu_min` re-creates exactly the unreachable
263        // certificate that `scaled_compl_inf_tol` (pounce#257) removed
264        // from the dynamic term, once |df| drops below
265        // `mu_min·(barrier_tol_factor+1)/compl_inf_tol ≈ 1e-7`.
266        let tol = data.borrow().tol;
267        let df = cq.borrow().obj_scaling_factor();
268        let dynamic_floor =
269            tol.min(self.scaled_compl_inf_tol(df)) / (self.barrier_tol_factor + 1.0);
270        let floor = self
271            .mu_target
272            .max(self.certificate_safe_mu_min(df))
273            .max(dynamic_floor);
274
275        // The barrier error depends on μ via the relaxed
276        // complementarity. Read it once per μ value.
277        loop {
278            let sub_err = cq.borrow().curr_barrier_error();
279            let kappa_eps_mu = self.barrier_tol_factor * mu;
280            if !(sub_err <= kappa_eps_mu || tiny_step) {
281                break;
282            }
283            let mut new_mu = self
284                .mu_linear_decrease_factor
285                .min(mu.powf(self.mu_superlinear_decrease_power - 1.0))
286                * mu;
287            if new_mu < floor {
288                new_mu = floor;
289            }
290            if new_mu >= mu {
291                // No further progress (already at floor).
292                break;
293            }
294            mu = new_mu;
295            tau = self.compute_tau(mu);
296            // Mirror upstream `IpData().Set_mu(mu)` *inside* the loop
297            // (`IpMonotoneMuUpdate.cpp:CalcNewMuAndTau`): the next
298            // `curr_barrier_error()` must see the reduced μ. The relaxed
299            // complementarity `s⊙z − μ` is keyed on `data.curr_mu`
300            // (see `ipopt_cq.rs::curr_relaxed_compl_*`), so without this
301            // write the re-tested `sub_err` stays pinned to the *old* μ
302            // while `kappa_eps_mu = barrier_tol_factor·mu` shrinks, and
303            // the loop over-drops μ in a single outer iteration. Writing
304            // it here makes the residual grow as μ falls (toward the
305            // current `s⊙z`), so the loop exits after one effective
306            // reduction — matching IPOPT.
307            data.borrow_mut().curr_mu = mu;
308            // Stop after one reduction in the tiny_step branch (matches
309            // upstream which clears tiny_step_flag once consumed).
310            if tiny_step {
311                data.borrow_mut().tiny_step_flag = false;
312                break;
313            }
314            // `mu_allow_fast_monotone_decrease=false` caps the loop at
315            // a single reduction. Mirrors upstream
316            // `IpMonotoneMuUpdate.cpp:CalcNewMuAndTau` when the option
317            // is off.
318            if !self.mu_allow_fast_monotone_decrease {
319                break;
320            }
321        }
322
323        let mut d = data.borrow_mut();
324        d.curr_mu = mu;
325        d.curr_tau = tau;
326        // Upstream `IpMonotoneMuUpdate.cpp:165` resets the line search
327        // once the reduction loop has moved μ, and not otherwise.
328        if mu < mu_at_entry {
329            d.request_ls_reset = true;
330        }
331        mu
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::mu::test_fixture;
339
340    /// pounce#510: the monotone update raises `request_ls_reset` at
341    /// upstream's `IpMonotoneMuUpdate.cpp:165` — after the reduction
342    /// loop moved μ, and only then. This is the behaviour the caller
343    /// used to infer from `next_mu != mu_before`, so monotone runs are
344    /// unaffected by moving the decision onto the flag.
345    #[test]
346    fn requests_ls_reset_on_a_reduction() {
347        let mut m = MonotoneMuUpdate::new();
348        // A barrier tolerance loose enough that the far-from-optimal
349        // fixture counts as "subproblem solved" and μ is reduced.
350        m.barrier_tol_factor = 1e6;
351        let (data, cq) = test_fixture::fixture(0.1);
352        let mu = m.update_barrier_parameter(&data, &cq, None, None);
353        assert!(mu < 0.1, "fixture must reduce μ for this test");
354        assert!(data.borrow().request_ls_reset);
355    }
356
357    #[test]
358    fn no_ls_reset_when_mu_stands_still() {
359        let mut m = MonotoneMuUpdate::new();
360        let (data, cq) = test_fixture::fixture(0.1);
361        // Default `barrier_tol_factor`: the fixture's barrier error is
362        // far above `10 · μ`, so the loop never runs.
363        let mu = m.update_barrier_parameter(&data, &cq, None, None);
364        assert_eq!(mu, 0.1);
365        assert!(!data.borrow().request_ls_reset);
366    }
367
368    #[test]
369    fn picks_smaller_of_linear_and_superlinear() {
370        let m = MonotoneMuUpdate::new();
371        // mu = 0.1 → linear = 0.02, superlinear = 0.1^1.5 ≈ 0.0316.
372        // The smaller is `linear`.
373        let next = m.compute_next_mu(0.1);
374        assert!((next - 0.02).abs() < 1e-15);
375    }
376
377    #[test]
378    fn tau_at_small_mu_is_tau_min() {
379        let m = MonotoneMuUpdate::new();
380        // mu small → 1 - mu ~ 1; tau_min=0.99 → max → 1.0 (since 1-mu=0.999...).
381        // Actually 1 - 1e-3 = 0.999 > 0.99 → tau = 0.999.
382        assert!((m.compute_tau(1e-3) - 0.999).abs() < 1e-15);
383    }
384
385    #[test]
386    fn tau_floor_at_tau_min() {
387        let m = MonotoneMuUpdate::new();
388        // mu=0.5 → 1 - 0.5 = 0.5; floor at tau_min=0.99 → 0.99.
389        assert!((m.compute_tau(0.5) - 0.99).abs() < 1e-15);
390    }
391
392    #[test]
393    fn clamps_to_mu_min() {
394        let m = MonotoneMuUpdate {
395            mu_min: 1e-3,
396            ..Default::default()
397        };
398        let next = m.compute_next_mu(1e-10);
399        assert!((next - 1e-3).abs() < 1e-15);
400    }
401
402    #[test]
403    fn dynamic_floor_matches_upstream_calcnewmuandtau() {
404        // Replicate `IpMonotoneMuUpdate.cpp:CalcNewMuAndTau:215`:
405        //   floor = max(mu_target, min(tol, compl_inf_tol) / (barrier_tol_factor + 1))
406        // With default `tol=1e-8`, `compl_inf_tol=1e-4`, `barrier_tol_factor=10`,
407        // `mu_target=0`: floor ≈ 1e-8 / 11 ≈ 9.09e-10.
408        let m = MonotoneMuUpdate::default();
409        let tol: Number = 1e-8;
410        let expected_floor = tol.min(m.compl_inf_tol) / (m.barrier_tol_factor + 1.0);
411        assert!((expected_floor - 1e-8 / 11.0).abs() < 1e-20);
412        // The hardcoded `mu_min = 1e-11` is well below the dynamic floor
413        // with default tols — the runtime `floor = max(...)` picks the
414        // dynamic one. (Verified in `update_barrier_parameter`.)
415        assert!(m.mu_min < expected_floor);
416    }
417
418    /// pounce#257: `compl_inf_tol` is enforced on the *unscaled*
419    /// complementarity, so the floor must convert it into μ's scaled space.
420    #[test]
421    fn dynamic_floor_converts_compl_inf_tol_into_scaled_space() {
422        let m = MonotoneMuUpdate::default();
423        // Unscaled problem: nothing to convert.
424        assert_eq!(m.scaled_compl_inf_tol(1.0), m.compl_inf_tol);
425        // jit1's B&B node: df = 1e-5 deflates the objective, so a `1e-4`
426        // unscaled tolerance is `1e-9` in the space μ lives in. Taking the raw
427        // value left the floor at 9.09e-10 — an unscaled complementarity of
428        // 9.09e-5 at best, and 9.09e-4 at the `tol=1e-7` the driver requested.
429        let floor = |ct: Number| (1e-7 as Number).min(ct) / (m.barrier_tol_factor + 1.0);
430        assert!((m.scaled_compl_inf_tol(1e-5) - 1e-9).abs() < 1e-24);
431        assert!(floor(m.scaled_compl_inf_tol(1e-5)) < floor(m.compl_inf_tol));
432        // Signed factor: `obj_scaling_factor = -1` poses a maximization, and
433        // magnitude is what the unscaling means. A negative floor would sail
434        // under every comparison.
435        assert_eq!(m.scaled_compl_inf_tol(-1e-5), m.scaled_compl_inf_tol(1e-5));
436        // Degenerate factors fall back to the unconverted tolerance rather
437        // than producing a zero or NaN floor.
438        for df in [0.0, Number::NAN, Number::INFINITY] {
439            assert_eq!(m.scaled_compl_inf_tol(df), m.compl_inf_tol);
440        }
441    }
442
443    /// pounce#266: `mu_min` is the *other* floor term in scaled space, and
444    /// unconverted it blocks the certificate below `df ≈ 1e-7` exactly as the
445    /// raw `compl_inf_tol` did in #257.
446    #[test]
447    fn mu_min_is_capped_so_certificate_stays_reachable() {
448        let m = MonotoneMuUpdate::default();
449        // The cap engages once `compl_inf_tol·df/(barrier_tol_factor+1)`
450        // drops under `mu_min`, i.e. below
451        // `df* = mu_min·(barrier_tol_factor+1)/compl_inf_tol = 1.1e-6`.
452        // Unscaled and mildly scaled problems are untouched.
453        let df_star = m.mu_min * (m.barrier_tol_factor + 1.0) / m.compl_inf_tol;
454        assert!((df_star - 1.1e-6).abs() < 1e-21);
455        for df in [1.0, -1.0, 1e-3, 1e-5, df_star] {
456            assert_eq!(m.certificate_safe_mu_min(df), m.mu_min);
457        }
458        // HS71 × 1e8 computes df = 8.3e-8. The certificate needs
459        // μ ≤ compl_inf_tol·df ≈ 8.3e-12 < mu_min, so the cap must engage —
460        // with the dynamic floor's own headroom, landing at ≈ 7.55e-13
461        // (which is where Ipopt's μ bottoms out on the same file).
462        let df = 8.3e-8;
463        let capped = m.certificate_safe_mu_min(df);
464        assert!(capped < m.mu_min);
465        assert!(
466            capped <= m.scaled_compl_inf_tol(df),
467            "floor {capped} still exceeds the scaled certificate bound",
468        );
469        assert!((capped - 1e-4 * 8.3e-8 / 11.0).abs() < 1e-27);
470        // Signed factor, same as `scaled_compl_inf_tol`.
471        assert_eq!(m.certificate_safe_mu_min(-df), capped);
472        // Degenerate factors fall back to the unconverted tolerance inside
473        // `scaled_compl_inf_tol`, whose cap (9.09e-6) leaves mu_min alone.
474        for df in [0.0, Number::NAN, Number::INFINITY] {
475            assert_eq!(m.certificate_safe_mu_min(df), m.mu_min);
476        }
477    }
478
479    /// The restoration sub-builder raises the floor to `100 · outer_mu_min`
480    /// (DECONVBNE safeguard). `RestoIpoptNlp` does not override
481    /// `obj_scaling_factor`, so the resto inner IPM sees `df = 1` — the cap
482    /// must leave that safeguard fully intact.
483    #[test]
484    fn resto_mu_min_safeguard_survives_the_cap() {
485        let outer = MonotoneMuUpdate::default();
486        let resto = MonotoneMuUpdate::new().with_mu_min(100.0 * outer.mu_min);
487        assert_eq!(resto.certificate_safe_mu_min(1.0), 100.0 * outer.mu_min);
488    }
489}