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}