pounce_algorithm/mu/adaptive.rs
1//! Adaptive mu update — port of `IpAdaptiveMuUpdate.{hpp,cpp}`.
2//!
3//! Phase 10. The full update reaches into `IpoptCq` for residuals and
4//! into a `MuOracle` for the candidate σ; this file ships:
5//!
6//! * the option struct with upstream defaults from `RegisterOptions`,
7//! * the `lower_mu_safeguard` scalar core (lines 753-786),
8//! * the globalization-mode enum and the FreeMuMode/FixedMuMode state
9//! machine (`UpdateBarrierParameter` lines 252-444),
10//! * the `mu_oracle` selector ([`MuOracleKind`]) — `Loqo` runs the
11//! closed form; `Probing` / `QualityFunction` drive an affine /
12//! centring solve when [`MuUpdate`] is given the search-dir + nlp
13//! handles, otherwise fall through to LOQO (mirrors upstream's
14//! "oracle returned no candidate" branch at lines 402-408).
15
16use crate::ipopt_cq::IpoptCqHandle;
17use crate::ipopt_data::IpoptDataHandle;
18use crate::ipopt_nlp::IpoptNlp;
19use crate::iterates_vector::IteratesVector;
20use crate::kkt::pd_search_dir_calc::PdSearchDirCalc;
21use crate::line_search::filter::Filter;
22use crate::mu::oracle::loqo::LoqoMuOracle;
23use crate::mu::oracle::probing::ProbingMuOracle;
24use crate::mu::oracle::quality_function::QualityFunctionMuOracle;
25use crate::mu::oracle::r#trait::MuOracle;
26use crate::mu::r#trait::MuUpdate;
27use pounce_common::types::Number;
28use std::cell::RefCell;
29use std::collections::VecDeque;
30use std::rc::Rc;
31
32/// `mu_oracle` option from `IpAdaptiveMuUpdate.cpp:RegisterOptions`.
33/// Default `QualityFunction` matches upstream (`"quality-function"`).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum MuOracleKind {
36 /// Closed-form LOQO rule. No predictor solve required.
37 Loqo,
38 /// Mehrotra probing oracle. Needs an affine-step solve.
39 Probing,
40 /// Golden-section minimisation of the q(σ) quality function.
41 /// Needs an affine-step solve plus a centring evaluator.
42 QualityFunction,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum AdaptiveMuGlobalization {
47 KktError,
48 ObjConstrFilter,
49 NeverMonotoneMode,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum AdaptiveMuKktNorm {
54 OneNorm,
55 TwoNormSquared,
56 MaxNorm,
57 TwoNorm,
58}
59
60pub struct AdaptiveMuUpdate {
61 pub mu_oracle: MuOracleKind,
62 pub adaptive_mu_globalization: AdaptiveMuGlobalization,
63 pub adaptive_mu_kkt_norm: AdaptiveMuKktNorm,
64 pub adaptive_mu_safeguard_factor: Number,
65 pub adaptive_mu_kkterror_red_iters: usize,
66 pub adaptive_mu_kkterror_red_fact: Number,
67 pub filter_max_margin: Number,
68 pub filter_margin_fact: Number,
69 pub mu_min: Number,
70 /// Complementarity tolerance — option `compl_inf_tol`, default 1e-4 per
71 /// `IpAlgorithmRegOp.cpp`. Not used directly by the adaptive update;
72 /// enters only through [`Self::certificate_safe_mu_min`], which caps
73 /// `mu_min` so a strongly scaled-down objective can still reach the
74 /// termination certificate (pounce#266) — the μ floor lives in scaled
75 /// space while `compl_inf_tol` is enforced on the *unscaled*
76 /// complementarity.
77 pub compl_inf_tol: Number,
78 /// Upper bound on μ. Sentinel `-1.0` means "not yet computed; init
79 /// lazily on the first `update_barrier_parameter` call to
80 /// `mu_max_fact * curr_avrg_compl()`". Mirrors
81 /// `IpAdaptiveMuUpdate.cpp:160-165` (load step) and
82 /// `IpAdaptiveMuUpdate.cpp:267-274` (lazy init).
83 pub mu_max: Number,
84 /// `mu_max_fact` (default 1e3) — factor for lazy init of `mu_max`.
85 /// Upstream `IpAdaptiveMuUpdate.cpp:RegisterOptions` line 42.
86 /// Ignored if the user explicitly sets `mu_max` to a non-sentinel
87 /// value.
88 pub mu_max_fact: Number,
89 /// `tau_min` from `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Used to
90 /// derive `curr_tau = max(tau_min, 1 - mu)` after each update,
91 /// mirroring upstream's `IpAdaptiveMuUpdate.cpp:UpdateBarrierParameter`
92 /// at the post-oracle update.
93 pub tau_min: Number,
94 /// Initial mu seed — `mu_init` from `IpoptAlgorithm` registered
95 /// options. Used to seed `curr_mu` in `initialize`.
96 pub mu_init: Number,
97 /// `barrier_tol_factor` (default 10) from upstream
98 /// `IpMonotoneMuUpdate::RegisterOptions`. Threshold for fixed-mode
99 /// barrier subproblem completion: reduce μ when
100 /// `curr_barrier_error ≤ barrier_tol_factor · μ`.
101 pub barrier_tol_factor: Number,
102 /// `mu_linear_decrease_factor` (default 0.2) — fixed-mode update
103 /// uses `min(linear · μ, μ^superlinear_power)`.
104 pub mu_linear_decrease_factor: Number,
105 /// `mu_superlinear_decrease_power` (default 1.5).
106 pub mu_superlinear_decrease_power: Number,
107 /// `adaptive_mu_monotone_init_factor` (default 0.8). Used by
108 /// `new_fixed_mu` when no `fix_mu_oracle_` is configured.
109 pub adaptive_mu_monotone_init_factor: Number,
110 /// `adaptive_mu_restore_previous_iterate` (default false).
111 pub restore_accepted_iterate: bool,
112 /// `sigma_max` / `sigma_min` forwarded to `QualityFunctionMuOracle`
113 /// on every free-mode call. `sigma_max` is additionally forwarded to
114 /// `ProbingMuOracle` (upstream `IpProbingMuOracle.cpp` reads the same
115 /// `sigma_max` option to cap its centering parameter — L3). Defaults
116 /// from `IpQualityFunctionMuOracle.cpp:RegisterOptions`.
117 pub sigma_max: Number,
118 pub sigma_min: Number,
119 /// `quality_function_norm_type` (default `2-norm-squared`) —
120 /// norm used to aggregate the three KKT components inside the
121 /// quality function. Forwarded to `QualityFunctionMuOracle` on
122 /// every free-mode call. Mirrors
123 /// `IpQualityFunctionMuOracle.cpp:RegisterOptions`.
124 pub qf_norm_type: crate::mu::oracle::quality_function::NormType,
125 /// `quality_function_centrality` (default `none`) — penalty term
126 /// added to the quality function for centrality deviation.
127 pub qf_centrality_type: crate::mu::oracle::quality_function::CentralityType,
128 /// `quality_function_balancing_term` (default `none`) — penalty
129 /// term added to the quality function when the complementarity
130 /// is far smaller than the infeasibilities.
131 pub qf_balancing_term: crate::mu::oracle::quality_function::BalancingTermType,
132 /// `quality_function_max_section_steps` (default 8) — cap on
133 /// golden-section iterations when picking σ.
134 pub qf_max_section_steps: i32,
135 /// `quality_function_section_sigma_tol` (default 1e-2) — width
136 /// tolerance in σ-space for the golden-section search.
137 pub qf_section_sigma_tol: Number,
138 /// `quality_function_section_qf_tol` (default 0.0) — relative
139 /// flatness tolerance for the golden-section search.
140 pub qf_section_qf_tol: Number,
141
142 /// `probing_iterate_quality_factor` (default 1e4, pounce-specific;
143 /// see pounce#58). When the probing (Mehrotra) μ-oracle is about
144 /// to read `curr_avrg_compl()` for its `mu_curr` input, a single
145 /// imbalanced `(s_i, z_i)` pair can inflate the average 5+ orders
146 /// above the stored `data.curr_mu`. Probing then mathematically
147 /// correctly returns `σ·mu_curr` ≫ previous μ, which throws the
148 /// iterate out of the convergence neighborhood. This guard
149 /// short-circuits that case: when `curr_avrg_compl / curr_mu >
150 /// probing_iterate_quality_factor`, we signal restoration via
151 /// [`IpoptData::request_resto`] and keep μ unchanged. Set to 0 or
152 /// any non-positive value to disable.
153 pub probing_iterate_quality_factor: Number,
154
155 /// Upstream tracks `init_*_inf` lazily — sentinel −1 means
156 /// "not yet captured".
157 init_dual_inf: Number,
158 init_primal_inf: Number,
159
160 /// FreeMuMode/FixedMuMode flag — port of
161 /// `IpoptData::FreeMuMode()`. `true` means "let the oracle drive
162 /// μ"; `false` means "monotone decrease until sufficient progress
163 /// is made". Initialised to `true` in [`MuUpdate::initialize`]
164 /// (matches upstream `InitializeImpl` line 239).
165 free_mu_mode: bool,
166 /// KKT-error history for `KKT_ERROR` globalization. Bounded length
167 /// = `adaptive_mu_kkterror_red_iters`. Mirrors `refs_vals_`.
168 refs_vals: VecDeque<Number>,
169 /// 2-D `(theta, phi)` filter for `OBJ_CONSTR_FILTER` globalization.
170 /// Mirrors `filter_` (constructed with `Filter(2)`).
171 filter: Filter,
172 /// Snapshot of `curr` at the most recent successful free-mode
173 /// iterate; restored when switching to fixed mode if
174 /// `restore_accepted_iterate` is on. Mirrors `accepted_point_`.
175 accepted_point: Option<IteratesVector>,
176 /// `adaptive_mu_max_free_returns` (pounce#749) — cap on how many
177 /// times the strategy may switch back out of fixed-mu mode. `-1`
178 /// is unlimited, reproducing upstream. POUNCE extension: it has no
179 /// counterpart in `IpAdaptiveMuUpdate.cpp`.
180 pub max_free_returns: i32,
181 /// Number of fixed->free transitions taken so far, compared
182 /// against [`Self::max_free_returns`].
183 free_returns_taken: i32,
184 /// `adaptive_mu_budget_pin_fraction` (pounce#753) — once this
185 /// fraction of an explicitly-set CPU or wall-clock budget has been
186 /// spent without converging, stop exploring in free-mu mode and
187 /// finish in the cheap fixed-mu (monotone) endgame. `1.0` disables.
188 /// POUNCE extension: no counterpart in `IpAdaptiveMuUpdate.cpp`.
189 ///
190 /// This is an *in-flight* switch, not a retry, and that is the whole
191 /// point. `mu_strategy_fallback` (pounce#748) deliberately declines
192 /// to retry a `Maximum_CpuTime_Exceeded` exit, because "the budget a
193 /// retry needs is precisely the budget already spent" — a second
194 /// solve starts from x0 and has nothing left to pay with. Switching
195 /// in place keeps the iterate, so the time already spent is not
196 /// wasted; it bought the point the monotone endgame starts from.
197 pub budget_pin_fraction: Number,
198 /// `max_cpu_time` / `max_wall_time` as the convergence check sees
199 /// them, mirrored here so [`Self::budget_spent`] can compute the
200 /// consumed fraction on the direct-driver path, where no shared
201 /// [`pounce_common::timing::Deadline`] is installed.
202 pub max_cpu_time: Number,
203 pub max_wall_time: Number,
204 /// Latched once [`Self::budget_spent`] first fires, so the endgame
205 /// cannot flap back into free mode as the clock keeps running.
206 budget_pinned: bool,
207 /// `no_bounds_` flag — port of `IpAdaptiveMuUpdate.cpp:282-287`.
208 /// Set to `true` on the first `update_barrier_parameter` call when
209 /// the iterate has zero bound multipliers (z_l, z_u, v_l, v_u all
210 /// have dim 0 — e.g. BT3, GENHS28, HS50, equality-only TNLPs).
211 /// Subsequent calls return `mu_min` immediately. Without this,
212 /// `mu_max = mu_max_fact * curr_avrg_compl()` evaluates to 0 (no
213 /// slacks → zero complementarity) and the later `clamp(mu_min,
214 /// mu_max)` panics with `min > max`.
215 no_bounds: bool,
216}
217
218impl Default for AdaptiveMuUpdate {
219 fn default() -> Self {
220 // Defaults from `IpAdaptiveMuUpdate.cpp:RegisterOptions`.
221 Self {
222 mu_oracle: MuOracleKind::QualityFunction,
223 adaptive_mu_globalization: AdaptiveMuGlobalization::ObjConstrFilter,
224 adaptive_mu_kkt_norm: AdaptiveMuKktNorm::TwoNormSquared,
225 adaptive_mu_safeguard_factor: 0.0,
226 adaptive_mu_kkterror_red_iters: 4,
227 adaptive_mu_kkterror_red_fact: 0.9999,
228 filter_max_margin: 1.0,
229 filter_margin_fact: 1e-5,
230 mu_min: 1e-11,
231 compl_inf_tol: 1e-4,
232 // Sentinel; lazy-initialised to `mu_max_fact * avrg_compl`
233 // on the first `update_barrier_parameter` call. Upstream
234 // `IpAdaptiveMuUpdate.cpp:164` sets `mu_max_ = -1.` when
235 // the option is not user-specified.
236 mu_max: -1.0,
237 mu_max_fact: 1e3,
238 tau_min: 0.99,
239 mu_init: 0.1,
240 barrier_tol_factor: 10.0,
241 mu_linear_decrease_factor: 0.2,
242 mu_superlinear_decrease_power: 1.5,
243 adaptive_mu_monotone_init_factor: 0.8,
244 restore_accepted_iterate: false,
245 sigma_max: 1e2,
246 sigma_min: 1e-6,
247 qf_norm_type: crate::mu::oracle::quality_function::NormType::TwoNormSquared,
248 qf_centrality_type: crate::mu::oracle::quality_function::CentralityType::None,
249 qf_balancing_term: crate::mu::oracle::quality_function::BalancingTermType::None,
250 qf_max_section_steps: 8,
251 qf_section_sigma_tol: 1e-2,
252 qf_section_qf_tol: 0.0,
253 probing_iterate_quality_factor: 1e4,
254 init_dual_inf: -1.0,
255 init_primal_inf: -1.0,
256 max_free_returns: -1,
257 budget_pin_fraction: 0.75,
258 max_cpu_time: 1e6,
259 max_wall_time: 1e6,
260 budget_pinned: false,
261 free_returns_taken: 0,
262 free_mu_mode: true,
263 refs_vals: VecDeque::new(),
264 filter: Filter::new(),
265 accepted_point: None,
266 no_bounds: false,
267 }
268 }
269}
270
271impl AdaptiveMuUpdate {
272 pub fn new() -> Self {
273 Self::default()
274 }
275
276 /// Pure-arithmetic predicate behind the probing-oracle iterate-
277 /// quality guard (pounce#58). Returns `true` when the ratio
278 /// `avrg_compl / curr_mu` exceeds `factor`. The two non-strict
279 /// gates (`factor > 0`, `curr_mu > 0`) keep the predicate
280 /// well-defined when the guard is disabled or when an unusual
281 /// μ-strategy zeroes `curr_mu`.
282 pub fn probing_iterate_guard_fires(
283 factor: Number,
284 curr_mu: Number,
285 avrg_compl: Number,
286 ) -> bool {
287 factor > 0.0 && curr_mu > 0.0 && avrg_compl > factor * curr_mu
288 }
289
290 /// Scalar core of the lazy `mu_max` initialization
291 /// (`IpAdaptiveMuUpdate.cpp:267-274`): on the first call, when the
292 /// user did not set `mu_max` explicitly, upstream sets it to
293 /// `mu_max_fact * curr_avrg_compl()`.
294 ///
295 /// A warm start (`warm_start_init_point=yes`) can hand us an iterate
296 /// whose bound multipliers are all zero — pounce does not yet wire
297 /// `warm_start_mult_bound_push`, so `seed_from_nlp` leaves
298 /// `z_l`/`z_u`/`v_l`/`v_u` at 0. Then `curr_avrg_compl()` is 0 even
299 /// though bounds exist, the `no_bounds` short-circuit does NOT fire,
300 /// `mu_max` collapses to 0, and the later `new_mu.clamp(mu_min,
301 /// mu_max)` panics with `min > max` (min = mu_min = 1e-11, max = 0).
302 /// When `avrg` carries no positive complementarity signal (zero, or a
303 /// NaN handed in by a pathological iterate) fall back to `mu_init` as
304 /// the proxy — what a cold start's `avrg_compl` is ~scaled to — so the
305 /// `[mu_min, mu_max]` band stays valid. The final `.max(mu_min)` is a
306 /// belt-and-suspenders floor against pathological options.
307 pub fn lazy_mu_max(
308 mu_max_fact: Number,
309 avrg: Number,
310 mu_init: Number,
311 mu_min: Number,
312 ) -> Number {
313 let avrg = if avrg > 0.0 { avrg } else { mu_init };
314 (mu_max_fact * avrg).max(mu_min)
315 }
316
317 /// Scalar core of `AdaptiveMuUpdate::lower_mu_safeguard`
318 /// (`IpAdaptiveMuUpdate.cpp:753-786`):
319 /// ```text
320 /// init_dual_inf ← max(1, dual_inf) if not yet set
321 /// init_primal_inf ← max(1, primal_inf) if not yet set
322 /// lower = max(safeguard_factor * dual_inf / init_dual_inf,
323 /// safeguard_factor * primal_inf / init_primal_inf)
324 /// if globalization == KKT_ERROR: lower = min(lower, min_ref_val)
325 /// ```
326 pub fn lower_mu_safeguard(
327 &mut self,
328 dual_inf: Number,
329 primal_inf: Number,
330 min_ref_val: Number,
331 ) -> Number {
332 if self.init_dual_inf < 0.0 {
333 self.init_dual_inf = dual_inf.max(1.0);
334 }
335 if self.init_primal_inf < 0.0 {
336 self.init_primal_inf = primal_inf.max(1.0);
337 }
338 let dual_term = self.adaptive_mu_safeguard_factor * (dual_inf / self.init_dual_inf);
339 let prim_term = self.adaptive_mu_safeguard_factor * (primal_inf / self.init_primal_inf);
340 let mut lower = dual_term.max(prim_term);
341 if self.adaptive_mu_globalization == AdaptiveMuGlobalization::KktError {
342 lower = lower.min(min_ref_val);
343 }
344 lower
345 }
346
347 pub fn reset_init_inf(&mut self) {
348 self.init_dual_inf = -1.0;
349 self.init_primal_inf = -1.0;
350 }
351
352 /// Globalization KKT-error proxy — port of
353 /// `AdaptiveMuUpdate::quality_function_pd_system`
354 /// (`IpAdaptiveMuUpdate.cpp:629-744`). v1.0 hardwires the
355 /// max-norm variant (`adaptive_mu_kkt_norm_type=max-norm`,
356 /// upstream "NM_NORM_MAX") because the existing CQ surface
357 /// exposes max-norm primal/dual infeasibility cheaply; the
358 /// other three norm variants follow once `curr_*_infeasibility`
359 /// learns to dispatch on `NormEnum`. The score sums primal +
360 /// dual + complementarity (+ optional centrality / balancing
361 /// — both default off; left as `0`).
362 fn quality_function_pd_system(&self, cq: &IpoptCqHandle) -> Number {
363 let cq_ref = cq.borrow();
364 let primal_inf = cq_ref.curr_primal_infeasibility_max();
365 let dual_inf = cq_ref.curr_dual_infeasibility_max();
366 // Max-norm complementarity ≈ avrg_compl is a cheap proxy.
367 // Upstream's `curr_complementarity(0., NORM_MAX)` would use
368 // `||s ⊙ z||_∞`; absent that accessor, fall through to the
369 // average. For the monotonicity test inside
370 // `check_sufficient_progress` only ratios matter, so the
371 // proxy preserves the convergence criterion.
372 let complty = cq_ref.curr_avrg_compl();
373 primal_inf + dual_inf + complty
374 }
375
376 /// Port of `AdaptiveMuUpdate::CheckSufficientProgress`
377 /// (`IpAdaptiveMuUpdate.cpp:446-490`). Returns `true` if the
378 /// current iterate makes acceptable progress under the active
379 /// globalization rule.
380 fn check_sufficient_progress(&self, cq: &IpoptCqHandle) -> bool {
381 match self.adaptive_mu_globalization {
382 AdaptiveMuGlobalization::KktError => {
383 if self.refs_vals.len() < self.adaptive_mu_kkterror_red_iters.max(1) {
384 // Not enough history yet — accept (matches
385 // upstream's `num_refs >= num_refs_max_` guard).
386 return true;
387 }
388 let curr_error = self.quality_function_pd_system(cq);
389 self.refs_vals
390 .iter()
391 .any(|&r| curr_error <= self.adaptive_mu_kkterror_red_fact * r)
392 }
393 AdaptiveMuGlobalization::ObjConstrFilter => {
394 let cq_ref = cq.borrow();
395 let curr_f = cq_ref.curr_f();
396 let curr_theta = cq_ref.curr_constraint_violation();
397 // `curr_nlp_error` is our analogue of upstream's
398 // global error margin driver.
399 let curr_err = cq_ref.curr_nlp_error();
400 drop(cq_ref);
401 let margin = self.filter_margin_fact * self.filter_max_margin.min(curr_err);
402 !self
403 .filter
404 .dominated_by_any(curr_theta + margin, curr_f + margin)
405 }
406 AdaptiveMuGlobalization::NeverMonotoneMode => true,
407 }
408 }
409
410 /// Port of `AdaptiveMuUpdate::RememberCurrentPointAsAccepted`
411 /// (`IpAdaptiveMuUpdate.cpp:492-546`). Records the iterate state
412 /// for the next sufficient-progress check.
413 fn remember_current_point_as_accepted(&mut self, data: &IpoptDataHandle, cq: &IpoptCqHandle) {
414 match self.adaptive_mu_globalization {
415 AdaptiveMuGlobalization::KktError => {
416 let curr_error = self.quality_function_pd_system(cq);
417 if self.refs_vals.len() >= self.adaptive_mu_kkterror_red_iters.max(1) {
418 self.refs_vals.pop_front();
419 }
420 self.refs_vals.push_back(curr_error);
421 }
422 AdaptiveMuGlobalization::ObjConstrFilter => {
423 let cq_ref = cq.borrow();
424 let f = cq_ref.curr_f();
425 let theta = cq_ref.curr_constraint_violation();
426 let it = data.borrow().iter_count;
427 drop(cq_ref);
428 self.filter.add(theta, f, it);
429 }
430 AdaptiveMuGlobalization::NeverMonotoneMode => {}
431 }
432 if self.restore_accepted_iterate {
433 self.accepted_point = data.borrow().curr.clone();
434 }
435 }
436
437 /// `mu_min` capped so it can never block the termination certificate
438 /// (pounce#266) — the adaptive twin of
439 /// [`crate::mu::monotone::MonotoneMuUpdate::certificate_safe_mu_min`],
440 /// which carries the full story. The raw absolute `mu_min` (default
441 /// `1e-11`) lives in μ's scaled space while `compl_inf_tol` is enforced
442 /// on the *unscaled* complementarity; below
443 /// `|df| ≈ mu_min·(barrier_tol_factor+1)/compl_inf_tol` an uncapped
444 /// floor pins the unscaled complementarity above `compl_inf_tol` and
445 /// the strict certificate is unreachable — in adaptive mode the solve
446 /// then degrades to `Solved_To_Acceptable_Level` (reduced accuracy, on an
447 /// iterate sitting at the optimum).
448 ///
449 /// The restoration sub-builder's `mu_min = 100 · outer_mu_min`
450 /// safeguard is unaffected for the same reason as in monotone mode:
451 /// `RestoIpoptNlp` does not override `obj_scaling_factor`, so the resto
452 /// inner IPM sees `df = 1` and the cap sits far above the safeguard.
453 pub fn certificate_safe_mu_min(&self, obj_scaling_factor: Number) -> Number {
454 crate::mu::certificate_safe_mu_min(
455 self.mu_min,
456 self.compl_inf_tol,
457 self.barrier_tol_factor,
458 obj_scaling_factor,
459 )
460 }
461
462 /// Floor for the **fixed-mode** (monotone-mode) μ decrease — port of
463 /// `IpAdaptiveMuUpdate.cpp:328-329`:
464 ///
465 /// ```cpp
466 /// new_mu = Max(new_mu,
467 /// Min(compl_inf_tol_scaled, IpData().tol()) / (barrier_tol_factor_ + 1.));
468 /// ```
469 ///
470 /// pounce#511: this branch used to floor at `mu_min` instead — `1e-11`
471 /// against upstream's `9.09e-10` at default `tol = 1e-8`, ~91× lower,
472 /// and further with a looser `tol` (at `tol = 1e-6` upstream's floor is
473 /// `9.09e-8`, four orders up). `mu_min` is the *free*-mode clamp; once the
474 /// strategy has switched to fixed mode upstream deliberately uses the
475 /// looser, tolerance-derived floor — that is the point of the switch.
476 /// Driving the Newton system down to `1e-11` past the accuracy the
477 /// termination test asks for buys nothing and invites degenerate search
478 /// directions on an ill-conditioned Jacobian.
479 ///
480 /// Two details mirror the monotone floor
481 /// (`MonotoneMuUpdate::update_barrier_parameter`):
482 ///
483 /// * `compl_inf_tol` is converted into μ's scaled space first
484 /// (pounce#257 — upstream's `apply_obj_scaling`), since it is enforced
485 /// on the *unscaled* complementarity while μ and `tol` are scaled;
486 /// * the result is additionally `max`ed with the certificate-safe
487 /// `mu_min` (pounce#266) so the restoration sub-builder's
488 /// `100 · outer_mu_min` safeguard still applies. Capped that way,
489 /// `mu_min` can only raise the floor, never push it under the
490 /// certificate.
491 pub fn fixed_mode_mu_floor(&self, tol: Number, obj_scaling_factor: Number) -> Number {
492 let dynamic_floor = tol.min(crate::mu::scaled_compl_inf_tol(
493 self.compl_inf_tol,
494 obj_scaling_factor,
495 )) / (self.barrier_tol_factor + 1.0);
496 self.certificate_safe_mu_min(obj_scaling_factor)
497 .max(dynamic_floor)
498 }
499
500 /// Port of `AdaptiveMuUpdate::NewFixedMu`
501 /// (`IpAdaptiveMuUpdate.cpp:583-627`). Selects μ when the state
502 /// machine drops out of free mode. v1.0 always uses the
503 /// "average complementarity" branch (no `fix_mu_oracle_` is
504 /// wired; matches `fixed_mu_oracle = average_compl`).
505 ///
506 /// The lower clamp is the certificate-safe `mu_min` (pounce#266);
507 /// capped ≤ raw `mu_min`, so the `[mu_min, mu_max]` band the lazy
508 /// `mu_max` init guarantees stays valid.
509 fn new_fixed_mu(&self, cq: &IpoptCqHandle, mu_min: Number) -> Number {
510 let avrg = cq.borrow().curr_avrg_compl();
511 let new_mu = self.adaptive_mu_monotone_init_factor * avrg;
512 new_mu.clamp(mu_min, self.mu_max)
513 }
514
515 /// Upstream's tiny-step termination test (pounce#512), shared by the
516 /// two sites that throw `TINY_STEP_DETECTED` in
517 /// `IpAdaptiveMuUpdate.cpp` — `:330-333` in the fixed-mode
518 /// Fiacco-McCormick decrease and `:377-380` on the free→fixed switch.
519 /// Both read `tiny_step_flag && new_mu == mu`: a tiny step was
520 /// detected *and* the update could not move μ, so no further
521 /// progress is available and the honest exit is "problem solved to
522 /// best possible numerical accuracy" (`STOP_AT_TINY_STEP`) rather
523 /// than iterating to the limit.
524 ///
525 /// Exact equality, like upstream. Both callers reach "unchanged" by
526 /// clamping to the same bound, which is bit-exact; an epsilon band
527 /// would instead swallow a genuine — if minute — reduction and stop
528 /// an iteration early.
529 fn tiny_step_is_terminal(tiny_step_flag: bool, new_mu: Number, curr_mu: Number) -> bool {
530 tiny_step_flag && new_mu == curr_mu
531 }
532
533 /// pounce#753 — has the caller's explicit time budget been consumed
534 /// past [`Self::budget_pin_fraction`]?
535 ///
536 /// POUNCE extension; no counterpart upstream. Free-μ mode costs
537 /// roughly 2.3x fixed-μ mode per iteration on nql180 (the oracle's
538 /// affine + centering back-solves, plus the trajectory it steers
539 /// into), and on that problem adaptive spends the whole tail
540 /// oscillating free->fixed->free and never reaches an endgame:
541 /// 444 iterations / 2234 s and a `Maximum_CpuTime_Exceeded` exit,
542 /// against 105 iterations / 258 s to `Optimal` if it is made to stay
543 /// in fixed mode. `mu_strategy_fallback` (pounce#748) already
544 /// recovers the *unbudgeted* form of that failure by retrying the
545 /// whole solve monotone after `Maximum_Iterations_Exceeded`, but it
546 /// deliberately declines to retry a CPU/wall exit — there is no
547 /// budget left to retry with. This is that recovery done in flight:
548 /// keep the iterate, drop the oracle, finish monotone.
549 ///
550 /// Returns `false` unless a budget was actually set. Both the
551 /// builder default (1e6 s) and the registered sentinel (1e20 s)
552 /// leave the consumed fraction indistinguishable from zero, so a
553 /// caller who never asked for a time limit never sees this fire —
554 /// which is also why the fixture sweep, which sets no budget, is
555 /// unaffected by construction.
556 ///
557 /// Latching matters: without [`Self::budget_pinned`] the pin would
558 /// depend on where in the iteration the clock is read, and a
559 /// borderline solve could flap back into free mode after paying for
560 /// the switch.
561 fn budget_spent(&mut self, data: &IpoptDataHandle) -> bool {
562 if self.budget_pinned {
563 return true;
564 }
565 if !(self.budget_pin_fraction < 1.0) {
566 // NaN-safe: only a fraction strictly below 1 can pin.
567 return false;
568 }
569 let d = data.borrow();
570 let frac = if let Some(deadline) = d.deadline.as_ref() {
571 // The shared deadline (pounce#242) measures from a fixed
572 // start instant and is what the convergence check trusts,
573 // so it is what we measure against too.
574 let cpu = fraction_of(
575 deadline.max_cpu(),
576 deadline.max_cpu() - deadline.remaining_cpu(),
577 );
578 let wall = fraction_of(
579 deadline.max_wall(),
580 deadline.max_wall() - deadline.remaining_wall(),
581 );
582 cpu.max(wall)
583 } else {
584 // Direct-driver / unit-test path — no deadline installed;
585 // mirror `conv_check::opt_error`'s fallback to `overall_alg`.
586 let timing = &d.timing;
587 let cpu = fraction_of(self.max_cpu_time, timing.overall_alg.live_cpu_time());
588 let wall = fraction_of(self.max_wall_time, timing.overall_alg.live_wallclock_time());
589 cpu.max(wall)
590 };
591 drop(d);
592 if frac >= self.budget_pin_fraction {
593 self.budget_pinned = true;
594 tracing::debug!(target: "pounce::mu",
595 "[AMU] pinning to fixed-mu mode: {:.0}% of the time budget spent (pounce#753)",
596 frac * 100.0,
597 );
598 return true;
599 }
600 false
601 }
602}
603
604/// `spent / budget`, or 0 when the budget is not a usable positive
605/// number. A non-finite or non-positive budget means "no limit was
606/// expressed", not "the limit is already blown".
607fn fraction_of(budget: Number, spent: Number) -> Number {
608 if budget.is_finite() && budget > 0.0 {
609 (spent / budget).max(0.0)
610 } else {
611 0.0
612 }
613}
614
615impl MuUpdate for AdaptiveMuUpdate {
616 /// Port of `IpAdaptiveMuUpdate.cpp:InitializeImpl`. Seeds
617 /// `curr_mu = mu_init`, `curr_tau = max(tau_min, 1 - mu_init)`,
618 /// resets the globalization state, and starts in free-μ mode
619 /// (`SetFreeMuMode(true)` at line 239).
620 fn initialize(&mut self, data: &IpoptDataHandle) {
621 // Mirror upstream `IpAdaptiveMuUpdate.cpp:246-247`:
622 // IpData().Set_mu(1.);
623 // IpData().Set_tau(0.);
624 // These are placeholder values so `CalculateSafeSlack` and the
625 // first output line have something to work with; the actual μ
626 // is computed by the oracle at iter 0's `update_barrier_parameter`.
627 // Setting curr_mu = mu_init here (as we used to) skipped the
628 // oracle's iter-0 call and locked μ at mu_init for the first
629 // Newton step — diverging from upstream's iter-0 behaviour
630 // (PFIT3: upstream iter 0 oracle picked μ=1.6e-6, pounce was
631 // stuck at μ=0.1, producing different iter-1 trial point).
632 let mut d = data.borrow_mut();
633 d.curr_mu = 1.0;
634 d.curr_tau = 0.0;
635 drop(d);
636 self.free_mu_mode = true;
637 self.refs_vals.clear();
638 self.filter.clear();
639 self.accepted_point = None;
640 self.init_dual_inf = -1.0;
641 self.init_primal_inf = -1.0;
642 // Reset mu_max sentinel so a re-solve re-runs the lazy init
643 // against the fresh starting iterate's curr_avrg_compl.
644 // Upstream re-enters InitializeImpl on each solve which
645 // (lines 160-165) resets `mu_max_ = -1.` when not user-set.
646 self.mu_max = -1.0;
647 // Reset no-bounds detection on re-solve.
648 self.no_bounds = false;
649 // Both mode-pinning mechanisms are per-solve state, and
650 // `initialize` is what a re-solve calls. Carrying either across
651 // would let the first solve's history pin the second one before
652 // it has taken a step: `free_returns_taken` (pounce#749) is a
653 // budget of transitions this solve is allowed, and
654 // `budget_pinned` (pounce#753) is a decision about this solve's
655 // clock.
656 self.free_returns_taken = 0;
657 self.budget_pinned = false;
658 }
659
660 /// Adaptive μ update — port of `UpdateBarrierParameter`
661 /// (`IpAdaptiveMuUpdate.cpp:252-444`). Runs the FreeMuMode /
662 /// FixedMuMode state machine:
663 ///
664 /// * **FreeMuMode**: ask the configured oracle for a candidate
665 /// (LOQO closed-form, Probing predictor solve, or
666 /// QualityFunction golden-section). If progress is sufficient,
667 /// stay in free mode and remember the iterate; otherwise switch
668 /// to fixed mode at `new_fixed_mu`.
669 /// * **FixedMuMode**: monotone Fiacco-McCormick reduction
670 /// (`min(linear · μ, μ^superlinear_power)`). Switch back to
671 /// free mode once the globalization criterion is satisfied
672 /// again.
673 ///
674 /// Probing / QualityFunction silently fall back to LOQO when
675 /// `nlp` / `pd_search_dir` are unavailable (mirrors upstream
676 /// lines 402-408).
677 ///
678 /// Line-search reset: upstream calls `linesearch_->Reset()` at
679 /// three points — line 339 (fixed-mode decrease), line 386
680 /// (free→fixed switch) and line 431 (**every** free-mode
681 /// iteration, whether or not μ moved). The [`MuUpdate`] trait
682 /// surface carries no line-search handle, so we raise
683 /// [`IpoptData::request_ls_reset`] at exactly those three points
684 /// and `IpoptAlgorithm::iterate` performs the reset right after
685 /// this call returns — the same plumbing the pounce#58 probing
686 /// guard uses for [`IpoptData::request_resto`]. See pounce#510:
687 /// the previous "reset when μ changed" proxy in the caller is
688 /// correct for the monotone update but not for this one, and left
689 /// the filter holding pre-restoration entries whenever μ happened
690 /// to stay put.
691 ///
692 /// [`IpoptData::request_ls_reset`]: crate::ipopt_data::IpoptData::request_ls_reset
693 /// [`IpoptData::request_resto`]: crate::ipopt_data::IpoptData::request_resto
694 fn update_barrier_parameter(
695 &mut self,
696 data: &IpoptDataHandle,
697 cq: &IpoptCqHandle,
698 nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
699 pd_search_dir: Option<&mut PdSearchDirCalc>,
700 ) -> Number {
701 // Lazy `mu_max` init — port of `IpAdaptiveMuUpdate.cpp:267-274`.
702 // Upstream computes `mu_max = mu_max_fact * curr_avrg_compl()`
703 // on the first call when the user did not set `mu_max`
704 // explicitly. Pounce previously hard-coded `mu_max = 1e5`,
705 // which let `new_fixed_mu = 0.8 * curr_avrg_compl` cap at 1e5
706 // — on DECONVBNE that allowed μ to jump from 2.5e-3 to ~2000
707 // at iter 198, destabilising the rest of the run.
708 if self.mu_max < 0.0 {
709 let avrg = cq.borrow().curr_avrg_compl();
710 self.mu_max = Self::lazy_mu_max(self.mu_max_fact, avrg, self.mu_init, self.mu_min);
711 }
712
713 // No-bounds short-circuit — port of `IpAdaptiveMuUpdate.cpp:282-296`.
714 // Detect once on the first call whether the iterate has any
715 // bound multipliers (z_l, z_u, v_l, v_u). When all four are
716 // dim-zero (equality-only TNLPs: BT3, GENHS28, HS50, METHANL8,
717 // ...), `curr_avrg_compl()` is 0, hence `mu_max = 0`, and the
718 // later `clamp(mu_min, mu_max)` panics with `min > max`.
719 // Upstream sets `mu = mu_min`, `tau = tau_min`, and short-
720 // circuits all subsequent oracle work; we mirror that.
721 if !self.no_bounds {
722 let n_bounds = {
723 let d = data.borrow();
724 let c = d.curr.as_ref().expect("curr set");
725 c.z_l.dim() + c.z_u.dim() + c.v_l.dim() + c.v_u.dim()
726 };
727 if n_bounds == 0 {
728 self.no_bounds = true;
729 let mut d = data.borrow_mut();
730 d.curr_mu = self.mu_min;
731 d.curr_tau = self.tau_min;
732 return self.mu_min;
733 }
734 }
735 if self.no_bounds {
736 let mut d = data.borrow_mut();
737 d.curr_mu = self.mu_min;
738 d.curr_tau = self.tau_min;
739 return self.mu_min;
740 }
741
742 // Read-and-clear `tiny_step_flag` — mirrors upstream
743 // `IpAdaptiveMuUpdate.cpp:297-298`. The flag is consumed by
744 // this call: without the clear, a single tiny-step detection
745 // would persist forever and suppress `sufficient_progress` on
746 // every later outer iter.
747 let (curr_mu, iter_count, tiny_step_flag) = {
748 let mut d = data.borrow_mut();
749 let out = (d.curr_mu, d.iter_count, d.tiny_step_flag);
750 d.tiny_step_flag = false;
751 out
752 };
753
754 // NB: do NOT short-circuit at iter_count==0. Upstream's
755 // `UpdateBarrierParameter` runs the oracle at iter 0 (the
756 // initialize() above set μ=1.0 as a placeholder only). Skipping
757 // the oracle here locked μ at the placeholder for the first
758 // Newton step. Letting the iter-0 path flow through the
759 // free-μ branch picks up the oracle's choice — the empty
760 // `refs_vals_` makes `check_sufficient_progress` return true,
761 // we remember the iterate, then call the oracle below.
762 // `tiny_step_flag` (and upstream's `CheckSkippedLineSearch()`,
763 // which is only set in non-rigorous resto mode) forces
764 // `sufficient_progress = false` when not in `NEVER_MONOTONE_MODE`
765 // — see `IpAdaptiveMuUpdate.cpp:347-351`. This is what lets a
766 // stalled outer iter drop into fixed-μ and re-seed μ via
767 // `new_fixed_mu` instead of the oracle re-driving μ further down.
768 let force_no_progress = tiny_step_flag
769 && self.adaptive_mu_globalization != AdaptiveMuGlobalization::NeverMonotoneMode;
770
771 // Certificate-safe μ floor (pounce#266): every place below that
772 // stops μ from descending — the fixed-mode reduction, the
773 // fixed-mode re-seed, the oracles' internal clamps, and the final
774 // band clamp — must use `mu_min` capped into the space the
775 // certificate lives in, or a strongly scaled-down objective ends
776 // `Solved_To_Acceptable_Level` on an iterate at the optimum. The
777 // `no_bounds` short-circuit above keeps the raw `mu_min`: with no
778 // bound multipliers there is no complementarity to certify.
779 let obj_scaling_factor = cq.borrow().obj_scaling_factor();
780 let mu_min = self.certificate_safe_mu_min(obj_scaling_factor);
781
782 // pounce#753 — POUNCE extension. Read once per update so the
783 // two mode-transition sites below agree within an iteration.
784 let budget_spent = self.budget_spent(data);
785
786 if !self.free_mu_mode {
787 // Fixed-mu branch — `cpp:299-342`.
788 //
789 // The gate is `sufficient_progress && !tiny_step_flag`
790 // (`cpp:304`) — plain `tiny_step_flag`, *not* the
791 // globalization-conditional `force_no_progress`, which
792 // upstream applies only in the free-mode branch below
793 // (`cpp:347-351`). Reusing `force_no_progress` here let
794 // `adaptive_mu_globalization=never-monotone-mode` switch back
795 // to free mode on a flagged tiny step, which upstream never
796 // does and which routed around the termination at `cpp:330`.
797 // At the default `obj-constr-filter` the two are equal, so
798 // this distinction only moves never-monotone-mode (pounce#512).
799 let sufficient_progress = !tiny_step_flag && self.check_sufficient_progress(cq);
800 // pounce#749 — POUNCE extension. Upstream returns to free
801 // mode every time progress looks sufficient, which on some
802 // problems (nql180) oscillates for the whole tail: the
803 // strategy re-enters fixed mode a handful of iterations
804 // later having paid the oracle's extra affine + centering
805 // solves the entire time, and never runs the cheap
806 // monotone endgame that closes the problem. Once the cap is
807 // reached we stay in fixed mode, which is exactly the
808 // Fiacco-McCormick reduction in the `else` arm below.
809 // `-1` disables the cap and reproduces upstream.
810 let returns_left =
811 self.max_free_returns < 0 || self.free_returns_taken < self.max_free_returns;
812 // pounce#753 — and once the time budget is nearly gone, do
813 // not return to free mode at all, whatever the cap says.
814 if sufficient_progress && returns_left && !budget_spent {
815 // Switch back to free mode and record the iterate —
816 // upstream `cpp:303-311`. Upstream does NOT return
817 // here: after flipping `FreeMuMode` to true the first
818 // if/else ends and control reaches the `if
819 // FreeMuMode()` block at `cpp:391`, which runs the
820 // oracle and picks a fresh μ in the SAME iteration.
821 // Returning `curr_mu` here froze μ on the transition
822 // iter — PALMER4's iter-15 fixed→free transition kept
823 // μ at 2.4e-7 instead of letting the oracle drop it to
824 // mu_min, stalling to Maximum_Iterations_Exceeded.
825 // Fall through to the oracle call below.
826 self.free_mu_mode = true;
827 self.free_returns_taken += 1;
828 self.remember_current_point_as_accepted(data, cq);
829 } else {
830 // Keep reducing μ Fiacco-McCormick style if the
831 // barrier subproblem is solved to within
832 // `barrier_tol_factor · μ`, OR if a tiny step was
833 // just detected (`cpp:320` `|| tiny_step_flag`).
834 let sub_problem_error = cq.borrow().curr_barrier_error();
835 if sub_problem_error <= self.barrier_tol_factor * curr_mu || tiny_step_flag {
836 let lin = self.mu_linear_decrease_factor * curr_mu;
837 let sup = curr_mu.powf(self.mu_superlinear_decrease_power);
838 // Fixed-mode floor is NOT `mu_min` — see
839 // [`Self::fixed_mode_mu_floor`] (pounce#511).
840 let tol = data.borrow().tol;
841 let floor = self.fixed_mode_mu_floor(tol, obj_scaling_factor);
842 let new_mu = lin.min(sup).max(floor).min(self.mu_max);
843 // `cpp:330-333` — a tiny step was flagged and the
844 // decrease left μ where it was (it is pinned at the
845 // floor), so there is nothing left to try. Upstream
846 // throws TINY_STEP_DETECTED *before* `Set_mu`/`Set_tau`;
847 // the flag is unchanged by construction, so returning
848 // it below is the same iterate either way. Pairing it
849 // with the #511 floor is upstream's own pairing: the
850 // termination triggers off the same floor the decrease
851 // stops at, so it now fires at the tolerance-derived
852 // floor instead of at `mu_min`.
853 if Self::tiny_step_is_terminal(tiny_step_flag, new_mu, curr_mu) {
854 data.borrow_mut().request_tiny_step_stop = true;
855 }
856 let new_tau = self.tau_min.max(1.0 - new_mu);
857 let mut d = data.borrow_mut();
858 d.curr_tau = new_tau;
859 // Upstream `cpp:339` — reset inside this branch,
860 // unconditionally, even when the clamps leave μ
861 // where it was (pounce#510).
862 d.request_ls_reset = true;
863 return new_mu;
864 }
865 // Subproblem not yet solved — keep μ. Upstream does NOT
866 // reset the line search on this path (`cpp:335-341`).
867 let new_tau = self.tau_min.max(1.0 - curr_mu);
868 data.borrow_mut().curr_tau = new_tau;
869 return curr_mu;
870 }
871 } else {
872 // Free-mu branch — `cpp:343-389`.
873 // pounce#753 — `!budget_spent` forces the free->fixed
874 // switch below through the *existing* transition path
875 // (accepted-iterate restore, `new_fixed_mu`, line-search
876 // reset) rather than inventing a second one. Combined with
877 // the gate above, the switch is then permanent.
878 let sufficient_progress =
879 !force_no_progress && !budget_spent && self.check_sufficient_progress(cq);
880 if sufficient_progress {
881 self.remember_current_point_as_accepted(data, cq);
882 // Fall through to the oracle call below.
883 } else {
884 if std::env::var("POUNCE_DBG_AMU").is_ok() {
885 let cqr = cq.borrow();
886 let theta = cqr.curr_constraint_violation();
887 let f = cqr.curr_f();
888 let nlp_err = cqr.curr_nlp_error();
889 let avrg = cqr.curr_avrg_compl();
890 drop(cqr);
891 let margin = self.filter_margin_fact * self.filter_max_margin.min(nlp_err);
892 let entries: Vec<(Number, Number, i32)> = self
893 .filter
894 .entries()
895 .iter()
896 .map(|e| (e.theta, e.phi, e.iter))
897 .collect();
898 tracing::debug!(target: "pounce::mu",
899 "[AMU] iter={} free->fixed: curr_mu={:.3e} theta={:.3e} f={:.3e} nlp_err={:.3e} margin={:.3e} avrg_compl={:.3e} new_mu={:.3e} | filter={:?} | force_no_progress={} tiny={}",
900 iter_count,
901 curr_mu,
902 theta,
903 f,
904 nlp_err,
905 margin,
906 avrg,
907 self.adaptive_mu_monotone_init_factor * avrg,
908 entries,
909 force_no_progress,
910 tiny_step_flag,
911 );
912 }
913 // Switch into fixed mode.
914 self.free_mu_mode = false;
915 if self.restore_accepted_iterate {
916 if let Some(prev) = self.accepted_point.clone() {
917 let mut d = data.borrow_mut();
918 d.set_trial(prev);
919 d.accept_trial_point();
920 }
921 }
922 let new_mu = self.new_fixed_mu(cq, mu_min);
923 // `cpp:377-380` — the same termination on the other
924 // throw site: the switch into fixed mode re-seeded μ to
925 // the value it already had, so the tiny step cannot be
926 // walked off by changing μ either. Ordered after the
927 // free-mode flip and the accepted-iterate restore, as
928 // upstream is.
929 if Self::tiny_step_is_terminal(tiny_step_flag, new_mu, curr_mu) {
930 data.borrow_mut().request_tiny_step_stop = true;
931 }
932 let new_tau = self.tau_min.max(1.0 - new_mu);
933 let mut d = data.borrow_mut();
934 d.curr_tau = new_tau;
935 // Upstream `cpp:386` — the free→fixed switch resets the
936 // line search whether or not `new_fixed_mu` differs from
937 // the μ we came in with (pounce#510).
938 d.request_ls_reset = true;
939 return new_mu;
940 }
941 }
942
943 // ----- Free-mu oracle call (cpp:391-436) -----
944 let cq_ref = cq.borrow();
945 let dual_inf = cq_ref.curr_dual_infeasibility_max();
946 let primal_inf = cq_ref.curr_primal_infeasibility_max();
947 let avrg_compl = cq_ref.curr_avrg_compl();
948 let centrality_xi = cq_ref.curr_centrality_measure();
949 let nlp_error = cq_ref.curr_nlp_error();
950 drop(cq_ref);
951
952 // τ = max(tau_min, 1 - curr_nlp_error) — upstream cpp:397.
953 let tau = self.tau_min.max(1.0 - nlp_error);
954 data.borrow_mut().curr_tau = tau;
955
956 let loqo_candidate = || {
957 let mut oracle = LoqoMuOracle {
958 mu_min,
959 mu_max: self.mu_max,
960 avrg_compl,
961 centrality_xi,
962 };
963 oracle.calculate_mu().unwrap_or(curr_mu)
964 };
965
966 let candidate = match self.mu_oracle {
967 MuOracleKind::Loqo => loqo_candidate(),
968 MuOracleKind::Probing => {
969 // Iterate-quality guard (pounce#58). The probing
970 // oracle uses `curr_avrg_compl()` for its `mu_curr`
971 // input (see `mu/oracle/probing.rs:85`). When a single
972 // imbalanced `(s_i, z_i)` pair inflates the average
973 // many orders above the stored `data.curr_mu`,
974 // probing's `σ·mu_curr` correctly returns the inflated
975 // value and the resulting search direction throws the
976 // iterate out of the convergence neighborhood. On
977 // arki0012 this manifests as μ jumping 5 orders at
978 // iter 155 followed by divergence to "Local
979 // Infeasibility" at iter 284. We short-circuit by
980 // signalling restoration and keeping μ unchanged; the
981 // main loop in `ipopt_alg.rs` consumes the flag
982 // before the search-direction step.
983 if Self::probing_iterate_guard_fires(
984 self.probing_iterate_quality_factor,
985 curr_mu,
986 avrg_compl,
987 ) {
988 if std::env::var("POUNCE_DBG_ORACLE").is_ok() {
989 tracing::debug!(target: "pounce::mu",
990 "[PN_PROBE_GUARD] iter={} curr_mu={:.3e} avrg_compl={:.3e} ratio={:.3e} > factor={:.3e} → request_resto",
991 iter_count,
992 curr_mu,
993 avrg_compl,
994 avrg_compl / curr_mu,
995 self.probing_iterate_quality_factor,
996 );
997 }
998 // No `request_ls_reset` here: this early return is a
999 // pounce-specific guard with no upstream counterpart,
1000 // it leaves μ untouched, and the caller hands the
1001 // iterate straight to restoration.
1002 data.borrow_mut().request_resto = true;
1003 return curr_mu;
1004 }
1005 match (nlp, pd_search_dir) {
1006 (Some(nlp), Some(sd)) => {
1007 let mut oracle = ProbingMuOracle {
1008 // Forward the user-set `sigma_max` (default 1e2),
1009 // matching upstream `IpProbingMuOracle.cpp`, which
1010 // reads `options.GetNumericValue("sigma_max", ...)`
1011 // and caps `sigma = Min(sigma, sigma_max_)`. This
1012 // was hard-coded to 100.0, so a user-set `sigma_max`
1013 // reached only the quality-function oracle (L3).
1014 sigma_max: self.sigma_max,
1015 mu_min,
1016 mu_max: self.mu_max,
1017 mu_curr: curr_mu,
1018 mu_aff: curr_mu,
1019 };
1020 oracle
1021 .calculate_mu_with_affine_step(data, cq, nlp, sd, 1.0)
1022 .unwrap_or_else(loqo_candidate)
1023 }
1024 _ => loqo_candidate(),
1025 }
1026 }
1027 MuOracleKind::QualityFunction => match (nlp, pd_search_dir) {
1028 (Some(nlp), Some(sd)) => {
1029 let mut oracle = QualityFunctionMuOracle::new();
1030 oracle.mu_min = mu_min;
1031 oracle.mu_max = self.mu_max;
1032 oracle.sigma_min = self.sigma_min;
1033 oracle.sigma_max = self.sigma_max;
1034 oracle.norm_type = self.qf_norm_type;
1035 oracle.centrality_type = self.qf_centrality_type;
1036 oracle.balancing_term = self.qf_balancing_term;
1037 oracle.max_section_steps = self.qf_max_section_steps;
1038 oracle.section_sigma_tol = self.qf_section_sigma_tol;
1039 oracle.section_qf_tol = self.qf_section_qf_tol;
1040 // Mirrors upstream's `quality_function_search` timer
1041 // around `CalculateMu` in `IpQualityFunctionMuOracle.cpp`.
1042 let timing = data.borrow().timing.clone();
1043 let _qf_guard = timing.quality_function_search.guard();
1044 oracle
1045 .calculate_mu_with_predictor_centering(data, cq, nlp, sd)
1046 .unwrap_or_else(loqo_candidate)
1047 }
1048 _ => loqo_candidate(),
1049 },
1050 };
1051
1052 // Safeguard floor + global band clamp (cpp:410-426).
1053 let lower = self.lower_mu_safeguard(dual_inf, primal_inf, candidate);
1054 let mu = candidate.max(mu_min).max(lower).min(self.mu_max);
1055
1056 // Upstream `cpp:431` — the free-mode block closes with an
1057 // unconditional `linesearch_->Reset()`. This is the point the
1058 // old caller-side "μ changed" proxy missed (pounce#510): it
1059 // fires on every free-mode iteration, including the ones where
1060 // the oracle re-picks the μ we already had, and including the
1061 // fixed→free transition that falls through to here. Filter
1062 // entries are keyed on a barrier parameter *and* an iterate;
1063 // "μ is unchanged" does not make yesterday's entries valid.
1064 data.borrow_mut().request_ls_reset = true;
1065
1066 // NB: upstream `IpAdaptiveMuUpdate.cpp:410-426` does NOT require
1067 // `mu ≤ curr_mu` in free mode — the oracle is allowed to bump
1068 // μ back up. A prior attempt to cap growth here ("HAIFAM
1069 // stability hack") let DECONVBNE's μ plunge from 0.1 to 5e-10
1070 // in ~20 iters and never recover (upstream oscillates μ in
1071 // [-8,-1] for the same range), trapping `inf_du` at 1e13.
1072 // Tiny-step skips are already handled by the
1073 // `tiny_step_flag → force_no_progress → new_fixed_mu` path
1074 // above, which can raise μ via the fixed-mode branch.
1075 mu
1076 }
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082 use crate::mu::test_fixture;
1083
1084 /// pounce#749: `adaptive_mu_max_free_returns` caps how many times
1085 /// the strategy may climb back out of fixed-μ mode. The default
1086 /// (`-1`) must leave upstream's behavior exactly as it was, so the
1087 /// two arms are asserted against the same starting state.
1088 fn returns_to_free_mode(max_free_returns: i32) -> bool {
1089 let mut a = AdaptiveMuUpdate::new();
1090 a.max_free_returns = max_free_returns;
1091 let (data, cq) = test_fixture::fixture(0.1);
1092 // Drive the state machine into fixed mode the same way
1093 // `free_to_fixed_switch_requests_ls_reset` does: the first call
1094 // seeds the filter, the second finds the same (θ, f) dominated.
1095 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1096 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1097 assert!(!a.free_mu_mode, "fixture must reach fixed mode first");
1098 // Clearing the filter makes the next progress check succeed, so
1099 // the only thing that can hold the strategy in fixed mode is the
1100 // cap under test.
1101 a.filter = Filter::new();
1102 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1103 a.free_mu_mode
1104 }
1105
1106 #[test]
1107 fn unlimited_free_returns_is_upstream_behavior() {
1108 assert!(
1109 returns_to_free_mode(-1),
1110 "-1 must not cap the return to free mode"
1111 );
1112 }
1113
1114 #[test]
1115 fn a_zero_cap_pins_the_strategy_in_the_monotone_endgame() {
1116 assert!(
1117 !returns_to_free_mode(0),
1118 "with no returns budgeted the strategy must stay in fixed mode"
1119 );
1120 }
1121
1122 #[test]
1123 fn a_cap_of_one_spends_its_budget_and_then_pins() {
1124 assert!(returns_to_free_mode(1), "the first return is within budget");
1125 }
1126
1127 /// pounce#753: same state machine as [`returns_to_free_mode`], but
1128 /// the thing under test is the time budget rather than the return
1129 /// cap. `budget` is `(max_wall, max_cpu)` for the shared
1130 /// [`pounce_common::timing::Deadline`] the application installs.
1131 fn returns_to_free_mode_under_budget(
1132 budget: (Number, Number),
1133 budget_pin_fraction: Number,
1134 ) -> bool {
1135 let mut a = AdaptiveMuUpdate::new();
1136 a.budget_pin_fraction = budget_pin_fraction;
1137 let (data, cq) = test_fixture::fixture(0.1);
1138 data.borrow_mut().deadline = Some(pounce_common::timing::Deadline::new(budget.0, budget.1));
1139 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1140 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1141 assert!(!a.free_mu_mode, "fixture must reach fixed mode first");
1142 a.filter = Filter::new();
1143 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1144 a.free_mu_mode
1145 }
1146
1147 /// The default 1e6 s budget — what a caller who never asked for a
1148 /// time limit gets — must leave the strategy behaving exactly as it
1149 /// did before pounce#753.
1150 #[test]
1151 fn an_unset_time_budget_does_not_pin() {
1152 assert!(
1153 returns_to_free_mode_under_budget((1e6, 1e6), 0.75),
1154 "the default budget is nowhere near spent, so nothing may change"
1155 );
1156 }
1157
1158 /// A budget already consumed many times over pins the strategy in
1159 /// the monotone endgame instead of paying the oracle again.
1160 #[test]
1161 fn a_spent_time_budget_pins_the_monotone_endgame() {
1162 assert!(
1163 !returns_to_free_mode_under_budget((1e-9, 1e-9), 0.75),
1164 "with the budget spent the strategy must stay in fixed mode"
1165 );
1166 }
1167
1168 /// `adaptive_mu_budget_pin_fraction = 1` is the documented off
1169 /// switch and must restore the pre-pounce#753 trajectory even on a
1170 /// budget that is comprehensively blown.
1171 #[test]
1172 fn a_pin_fraction_of_one_disables_the_mechanism() {
1173 assert!(
1174 returns_to_free_mode_under_budget((1e-9, 1e-9), 1.0),
1175 "a fraction of 1 must disable the pin"
1176 );
1177 }
1178
1179 /// The other half of the mechanism: a solve *already* in free mode
1180 /// with an empty filter would sail on making "sufficient progress"
1181 /// forever. With the budget spent it must be pushed into fixed mode
1182 /// through the ordinary free->fixed path.
1183 #[test]
1184 fn a_spent_time_budget_forces_free_mode_out_of_the_oracle() {
1185 let mut a = AdaptiveMuUpdate::new();
1186 let (data, cq) = test_fixture::fixture(0.1);
1187 // Empty filter + free mode = sufficient progress on every call,
1188 // which is precisely the nql180 tail this issue is about.
1189 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1190 a.filter = Filter::new();
1191 a.free_mu_mode = true;
1192 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1193 assert!(a.free_mu_mode, "control: the oracle keeps free mode");
1194
1195 a.filter = Filter::new();
1196 data.borrow_mut().deadline = Some(pounce_common::timing::Deadline::new(1e-9, 1e-9));
1197 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1198 assert!(
1199 !a.free_mu_mode,
1200 "a spent budget must force the free->fixed switch"
1201 );
1202 assert!(
1203 data.borrow().request_ls_reset,
1204 "the switch must go through the ordinary free->fixed path, \
1205 which resets the line search"
1206 );
1207 }
1208
1209 /// Once pinned, the strategy stays pinned: the latch means the
1210 /// decision does not depend on when in an iteration the clock is
1211 /// read, and a solve cannot flap back after paying for the switch.
1212 #[test]
1213 fn the_pin_latches() {
1214 let mut a = AdaptiveMuUpdate::new();
1215 let (data, _cq) = test_fixture::fixture(0.1);
1216 data.borrow_mut().deadline = Some(pounce_common::timing::Deadline::new(1e-9, 1e-9));
1217 assert!(a.budget_spent(&data));
1218 // Swap in a budget that is not spent at all; the latch holds.
1219 data.borrow_mut().deadline = Some(pounce_common::timing::Deadline::new(1e6, 1e6));
1220 assert!(a.budget_spent(&data), "the pin must not un-fire");
1221 }
1222
1223 /// A nonsensical or absent budget is "no limit expressed", not "the
1224 /// limit is already blown" — otherwise a zero/NaN `max_cpu_time`
1225 /// would silently disable the mu oracle for every solve.
1226 #[test]
1227 fn a_degenerate_budget_is_not_a_spent_budget() {
1228 for (wall, cpu) in [
1229 (0.0, 0.0),
1230 (-1.0, -1.0),
1231 (Number::NAN, Number::NAN),
1232 (Number::INFINITY, Number::INFINITY),
1233 ] {
1234 let mut a = AdaptiveMuUpdate::new();
1235 let (data, _cq) = test_fixture::fixture(0.1);
1236 data.borrow_mut().deadline = Some(pounce_common::timing::Deadline::new(wall, cpu));
1237 assert!(
1238 !a.budget_spent(&data),
1239 "({wall}, {cpu}) expresses no budget and must not pin"
1240 );
1241 }
1242 }
1243
1244 /// pounce#510: upstream resets the line search on **every** free-mode
1245 /// iteration (`IpAdaptiveMuUpdate.cpp:431`), not only when μ moves.
1246 /// The caller used to infer the reset from `next_mu != mu_before`,
1247 /// which silently skipped it whenever the oracle re-picked the μ we
1248 /// already had — leaving the filter holding entries computed against
1249 /// an iterate and a barrier parameter the algorithm had left behind.
1250 #[test]
1251 fn free_mode_requests_ls_reset_even_when_mu_is_unchanged() {
1252 let mut a = AdaptiveMuUpdate::new();
1253 // Never-monotone globalization keeps the state machine in free
1254 // mode across both calls, which is the endgame this issue is
1255 // about; the filter/KKT variants are covered below.
1256 a.adaptive_mu_globalization = AdaptiveMuGlobalization::NeverMonotoneMode;
1257 let (data, cq) = test_fixture::fixture(0.1);
1258 // First pass: free mode with an empty filter ⇒ sufficient
1259 // progress ⇒ the oracle picks μ.
1260 let mu1 = a.update_barrier_parameter(&data, &cq, None, None);
1261 assert!(a.free_mu_mode);
1262 assert!(data.borrow().request_ls_reset);
1263
1264 // Re-enter at exactly the μ the oracle just chose, on the same
1265 // (unchanged) iterate: μ cannot move, and the pre-fix caller
1266 // would therefore never reset.
1267 data.borrow_mut().request_ls_reset = false;
1268 data.borrow_mut().curr_mu = mu1;
1269 let mu2 = a.update_barrier_parameter(&data, &cq, None, None);
1270 assert_eq!(mu2, mu1, "fixture must hold μ still for this test");
1271 assert!(
1272 data.borrow().request_ls_reset,
1273 "free-mode iteration must request a line-search reset with μ unchanged"
1274 );
1275 }
1276
1277 /// pounce#510: the free→fixed switch is upstream's `cpp:386` reset,
1278 /// which likewise does not care whether `new_fixed_mu` differs from
1279 /// the incoming μ.
1280 #[test]
1281 fn free_to_fixed_switch_requests_ls_reset() {
1282 let mut a = AdaptiveMuUpdate::new();
1283 let (data, cq) = test_fixture::fixture(0.1);
1284 // Seed the filter with the current point, then re-run: the same
1285 // (θ, f) is now dominated, so progress is insufficient and the
1286 // update drops into fixed mode.
1287 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1288 data.borrow_mut().request_ls_reset = false;
1289 let _ = a.update_barrier_parameter(&data, &cq, None, None);
1290 assert!(
1291 !a.free_mu_mode,
1292 "fixture must fall out of free mode for this test"
1293 );
1294 assert!(data.borrow().request_ls_reset);
1295 }
1296
1297 /// pounce#510: the fixed-mode μ decrease is upstream's `cpp:339`
1298 /// reset. Note it fires inside the branch, so a decrease that the
1299 /// `mu_min`/`mu_max` clamps flatten still resets.
1300 #[test]
1301 fn fixed_mode_decrease_requests_ls_reset() {
1302 let mut a = AdaptiveMuUpdate::new();
1303 let (data, cq) = test_fixture::fixture(0.1);
1304 a.free_mu_mode = false;
1305 // Force "no sufficient progress" so the update stays in fixed
1306 // mode, and a barrier tolerance loose enough that the decrease
1307 // branch fires on this (far-from-optimal) iterate.
1308 a.adaptive_mu_globalization = AdaptiveMuGlobalization::KktError;
1309 a.adaptive_mu_kkterror_red_iters = 1;
1310 a.adaptive_mu_kkterror_red_fact = 0.0;
1311 a.refs_vals.push_back(1.0);
1312 a.barrier_tol_factor = 1e6;
1313 // Degenerate decrease factors: `min(1·μ, μ^1) = μ`. The branch is
1314 // taken but μ does not move, so the pre-fix `next_mu != mu_before`
1315 // proxy would have skipped the reset here as well.
1316 a.mu_linear_decrease_factor = 1.0;
1317 a.mu_superlinear_decrease_power = 1.0;
1318 let mu = a.update_barrier_parameter(&data, &cq, None, None);
1319 assert!(!a.free_mu_mode, "must stay in fixed mode for this test");
1320 assert_eq!(mu, 0.1, "flat decrease leaves μ where it was");
1321 assert!(data.borrow().request_ls_reset);
1322 }
1323
1324 /// The one fixed-mode path upstream leaves alone (`cpp:335-341`):
1325 /// the barrier subproblem is not solved yet, μ stays, no reset.
1326 #[test]
1327 fn fixed_mode_without_decrease_does_not_request_ls_reset() {
1328 let mut a = AdaptiveMuUpdate::new();
1329 let (data, cq) = test_fixture::fixture(1e-8);
1330 a.free_mu_mode = false;
1331 // A far-from-optimal iterate at a tiny μ: the barrier error is
1332 // way above `barrier_tol_factor · μ`, and the filter is empty so
1333 // `check_sufficient_progress` must be forced to fail.
1334 a.adaptive_mu_globalization = AdaptiveMuGlobalization::KktError;
1335 a.adaptive_mu_kkterror_red_iters = 1;
1336 a.adaptive_mu_kkterror_red_fact = 0.0;
1337 a.refs_vals.push_back(1.0);
1338 let mu = a.update_barrier_parameter(&data, &cq, None, None);
1339 assert!(!a.free_mu_mode);
1340 assert_eq!(mu, 1e-8);
1341 assert!(!data.borrow().request_ls_reset);
1342 }
1343
1344 /// pounce#266, adaptive twin of the monotone test: the raw `mu_min`
1345 /// clamp must yield to `compl_inf_tol·|df|/(barrier_tol_factor+1)` once
1346 /// |df| drops below `df* = mu_min·(barrier_tol_factor+1)/compl_inf_tol`,
1347 /// or the strict certificate is unreachable and the solve degrades to
1348 /// `Solved_To_Acceptable_Level` at the optimum.
1349 #[test]
1350 fn adaptive_mu_min_is_capped_so_certificate_stays_reachable() {
1351 let a = AdaptiveMuUpdate::new();
1352 let df_star = a.mu_min * (a.barrier_tol_factor + 1.0) / a.compl_inf_tol;
1353 assert!((df_star - 1.1e-6).abs() < 1e-21);
1354 for df in [1.0, -1.0, 1e-3, 1e-5, df_star] {
1355 assert_eq!(a.certificate_safe_mu_min(df), a.mu_min);
1356 }
1357 // HS71 × 1e8 computes df = 8.3e-8, under the cliff: the cap engages.
1358 let df = 8.3e-8;
1359 let capped = a.certificate_safe_mu_min(df);
1360 assert!(capped < a.mu_min);
1361 assert!((capped - 1e-4 * 8.3e-8 / 11.0).abs() < 1e-27);
1362 assert_eq!(a.certificate_safe_mu_min(-df), capped);
1363 // Degenerate factors fall back to the unconverted tolerance, whose
1364 // cap (9.09e-6) leaves mu_min alone.
1365 for df in [0.0, Number::NAN, Number::INFINITY] {
1366 assert_eq!(a.certificate_safe_mu_min(df), a.mu_min);
1367 }
1368 // The restoration sub-builder's `mu_min = 100 · outer_mu_min`
1369 // safeguard survives: the resto inner IPM sees df = 1.
1370 let mut resto = AdaptiveMuUpdate::new();
1371 resto.mu_min = 100.0 * a.mu_min;
1372 assert_eq!(resto.certificate_safe_mu_min(1.0), resto.mu_min);
1373 }
1374
1375 /// pounce#511: the fixed-mode decrease must floor at upstream's
1376 /// `Min(compl_inf_tol_scaled, tol)/(barrier_tol_factor+1)`, not at
1377 /// `mu_min`. At default `tol=1e-8`, `compl_inf_tol=1e-4`,
1378 /// `barrier_tol_factor=10` that is `1e-8/11 ≈ 9.09e-10` — ~91× above
1379 /// `mu_min = 1e-11`, and further still at a looser `tol`.
1380 #[test]
1381 fn fixed_mode_floor_matches_upstream_not_mu_min() {
1382 let a = AdaptiveMuUpdate::new();
1383 let floor = a.fixed_mode_mu_floor(1e-8, 1.0);
1384 assert!((floor - 1e-8 / 11.0).abs() < 1e-20, "floor was {floor}");
1385 // ~91× above `mu_min` — the old floor — i.e. nearly two orders.
1386 assert!(floor / a.mu_min > 90.0, "floor was {floor}");
1387 // Looser `tol` raises the floor with it (upstream takes the min of
1388 // `tol` and `compl_inf_tol`, so `tol` binds until it exceeds 1e-4).
1389 assert!((a.fixed_mode_mu_floor(1e-6, 1.0) - 1e-6 / 11.0).abs() < 1e-18);
1390 // Beyond that `compl_inf_tol` binds.
1391 assert!((a.fixed_mode_mu_floor(1e-2, 1.0) - 1e-4 / 11.0).abs() < 1e-18);
1392 }
1393
1394 /// The `compl_inf_tol` half of the floor is converted into μ's scaled
1395 /// space before the `Min` (upstream's `apply_obj_scaling`, pounce#257),
1396 /// so the two disagree whenever objective scaling is active.
1397 #[test]
1398 fn fixed_mode_floor_scales_compl_inf_tol() {
1399 let a = AdaptiveMuUpdate::new();
1400 // df = 1e-6 puts scaled compl_inf_tol at 1e-10, under `tol=1e-8`,
1401 // so it is the binding half: 1e-10/11 ≈ 9.09e-12.
1402 let df = 1e-6;
1403 let floor = a.fixed_mode_mu_floor(1e-8, df);
1404 assert!(
1405 (floor - 1e-4 * df / 11.0).abs() < 1e-24,
1406 "floor was {floor}"
1407 );
1408 // Sign of the scaling factor (maximization poses df < 0) is
1409 // irrelevant — the magnitude is what converts spaces.
1410 assert_eq!(a.fixed_mode_mu_floor(1e-8, -df), floor);
1411 // Degenerate factors fall back to the unconverted tolerance.
1412 for df in [0.0, Number::NAN, Number::INFINITY] {
1413 assert!((a.fixed_mode_mu_floor(1e-8, df) - 1e-8 / 11.0).abs() < 1e-20);
1414 }
1415 }
1416
1417 /// The restoration sub-builder's `mu_min = 100 · outer_mu_min`
1418 /// safeguard still binds when it sits above the tolerance floor: the
1419 /// certificate-safe `mu_min` is `max`ed in, mirroring monotone mode.
1420 #[test]
1421 fn fixed_mode_floor_keeps_resto_mu_min_safeguard() {
1422 let mut resto = AdaptiveMuUpdate::new();
1423 resto.mu_min = 1e-6; // well above tol/(barrier_tol_factor+1) = 9.09e-10
1424 // `RestoIpoptNlp` does not override obj scaling — the resto inner
1425 // IPM sees df = 1, so the cap leaves `mu_min` alone and it wins.
1426 assert_eq!(resto.fixed_mode_mu_floor(1e-8, 1.0), 1e-6);
1427 }
1428
1429 #[test]
1430 fn lower_mu_safeguard_initializes_from_first_call() {
1431 let mut a = AdaptiveMuUpdate::new();
1432 a.adaptive_mu_safeguard_factor = 1e-2;
1433 // First call captures init values.
1434 let _ = a.lower_mu_safeguard(0.5, 2.0, 1.0);
1435 assert_eq!(a.init_dual_inf, 1.0); // max(1, 0.5)
1436 assert_eq!(a.init_primal_inf, 2.0); // max(1, 2.0)
1437 }
1438
1439 #[test]
1440 fn lower_mu_safeguard_takes_max_of_dual_and_primal_terms() {
1441 let mut a = AdaptiveMuUpdate::new();
1442 a.adaptive_mu_safeguard_factor = 1.0;
1443 // Primal term dominates.
1444 let r = a.lower_mu_safeguard(0.1, 5.0, 1e9);
1445 // init_dual = 1, init_primal = 5 → terms: 0.1, 1.0 → max = 1.0.
1446 assert!((r - 1.0).abs() < 1e-15);
1447 }
1448
1449 #[test]
1450 fn kkt_error_globalization_clips_to_min_ref_val() {
1451 let mut a = AdaptiveMuUpdate::new();
1452 a.adaptive_mu_globalization = AdaptiveMuGlobalization::KktError;
1453 a.adaptive_mu_safeguard_factor = 1.0;
1454 // Without clip, safeguard would be 5.0; min_ref_val = 0.1 wins.
1455 let r = a.lower_mu_safeguard(0.1, 5.0, 0.1);
1456 assert!((r - 0.1).abs() < 1e-15);
1457 }
1458
1459 #[test]
1460 fn reset_clears_init_inf() {
1461 let mut a = AdaptiveMuUpdate::new();
1462 a.adaptive_mu_safeguard_factor = 1.0;
1463 let _ = a.lower_mu_safeguard(0.5, 2.0, 1.0);
1464 a.reset_init_inf();
1465 assert_eq!(a.init_dual_inf, -1.0);
1466 assert_eq!(a.init_primal_inf, -1.0);
1467 }
1468
1469 // The trait `update_barrier_parameter` now takes
1470 // `(&IpoptDataHandle, &IpoptCqHandle)`. End-to-end coverage of the
1471 // adaptive path lands alongside the integration test that drives
1472 // `IpoptAlgorithm::optimize` with `mu_strategy=adaptive`; in
1473 // isolation the unit tests above exercise the safeguard
1474 // arithmetic and option defaults.
1475
1476 #[test]
1477 fn default_mu_oracle_is_quality_function() {
1478 let a = AdaptiveMuUpdate::new();
1479 assert_eq!(a.mu_oracle, MuOracleKind::QualityFunction);
1480 }
1481
1482 #[test]
1483 fn mu_oracle_kind_is_distinct() {
1484 assert_ne!(MuOracleKind::Loqo, MuOracleKind::Probing);
1485 assert_ne!(MuOracleKind::Probing, MuOracleKind::QualityFunction);
1486 assert_ne!(MuOracleKind::Loqo, MuOracleKind::QualityFunction);
1487 }
1488
1489 // pounce#58 guard predicate. Numbers below come from the issue
1490 // body's iter 154-155 trace on arki0012.
1491 #[test]
1492 fn probing_iterate_guard_fires_on_arki0012_iter155() {
1493 let curr_mu = 1.98e-11;
1494 let avrg_compl = 8.90e-6;
1495 assert!(AdaptiveMuUpdate::probing_iterate_guard_fires(
1496 1e4, curr_mu, avrg_compl
1497 ));
1498 }
1499
1500 #[test]
1501 fn probing_iterate_guard_quiet_on_healthy_iter() {
1502 // iter 154 in the same trace — ratio ≈ 2.2; ought not fire.
1503 let curr_mu = 1.02e-11;
1504 let avrg_compl = 2.24e-11;
1505 assert!(!AdaptiveMuUpdate::probing_iterate_guard_fires(
1506 1e4, curr_mu, avrg_compl
1507 ));
1508 }
1509
1510 #[test]
1511 fn probing_iterate_guard_disabled_at_zero_factor() {
1512 // factor=0 ⇒ guard off, even with extreme ratio.
1513 assert!(!AdaptiveMuUpdate::probing_iterate_guard_fires(
1514 0.0, 1e-11, 1.0
1515 ));
1516 }
1517
1518 #[test]
1519 fn probing_iterate_guard_disabled_at_negative_factor() {
1520 assert!(!AdaptiveMuUpdate::probing_iterate_guard_fires(
1521 -1.0, 1e-11, 1.0
1522 ));
1523 }
1524
1525 #[test]
1526 fn probing_iterate_guard_quiet_when_curr_mu_zero() {
1527 // Pathological `curr_mu = 0` (no-bounds branch zeroes it out).
1528 // Predicate must stay quiet rather than division-by-zero.
1529 assert!(!AdaptiveMuUpdate::probing_iterate_guard_fires(
1530 1e4, 0.0, 1e-6
1531 ));
1532 }
1533
1534 // Regression: `mu_strategy=adaptive` + `warm_start_init_point=yes`
1535 // used to panic in `new_mu.clamp(mu_min, mu_max)` with
1536 // "min > max ... min = 1e-11, max = 0.0" — the warm start zeroes the
1537 // bound multipliers, so `curr_avrg_compl()` reads 0 even though
1538 // bounds exist, collapsing `mu_max` to 0. `lazy_mu_max` must keep the
1539 // band valid (mu_max >= mu_min) regardless of the `avrg` it is fed.
1540 #[test]
1541 fn lazy_mu_max_keeps_band_valid_on_zero_avrg_compl() {
1542 let a = AdaptiveMuUpdate::new();
1543 // Warm-start pathology: avrg_compl == 0.
1544 let mu_max = AdaptiveMuUpdate::lazy_mu_max(a.mu_max_fact, 0.0, a.mu_init, a.mu_min);
1545 assert!(
1546 mu_max >= a.mu_min,
1547 "mu_max {mu_max} must not fall below mu_min {}",
1548 a.mu_min
1549 );
1550 // Falls back to the mu_init-scaled band: 1e3 * 0.1 = 100.
1551 assert!((mu_max - a.mu_max_fact * a.mu_init).abs() < 1e-12);
1552 }
1553
1554 #[test]
1555 fn lazy_mu_max_unchanged_for_cold_start() {
1556 let a = AdaptiveMuUpdate::new();
1557 // A healthy cold start hands a positive avrg_compl; the band is
1558 // mu_max_fact * avrg, exactly as before the warm-start guard.
1559 let avrg = 2.5e-3;
1560 let mu_max = AdaptiveMuUpdate::lazy_mu_max(a.mu_max_fact, avrg, a.mu_init, a.mu_min);
1561 assert!((mu_max - a.mu_max_fact * avrg).abs() < 1e-15);
1562 }
1563
1564 #[test]
1565 fn lazy_mu_max_survives_nan_avrg_compl() {
1566 let a = AdaptiveMuUpdate::new();
1567 // A NaN avrg (the other half of the original panic message) must
1568 // not propagate: `avrg > 0.0` is false for NaN, so we fall back.
1569 let mu_max = AdaptiveMuUpdate::lazy_mu_max(a.mu_max_fact, f64::NAN, a.mu_init, a.mu_min);
1570 assert!(mu_max.is_finite() && mu_max >= a.mu_min);
1571 }
1572
1573 // pounce#512 — the shared condition behind both of upstream's
1574 // `TINY_STEP_DETECTED` throws (`IpAdaptiveMuUpdate.cpp:330-333`,
1575 // `:377-380`). Both conjuncts are load-bearing in opposite
1576 // directions: without the flag the update is just at its floor and
1577 // must keep iterating, and without the μ test a tiny step that the
1578 // update *can* still respond to would stop the solve early.
1579 #[test]
1580 fn tiny_step_is_terminal_needs_the_flag_and_an_unmoved_mu() {
1581 let mu = 1e-11;
1582 assert!(AdaptiveMuUpdate::tiny_step_is_terminal(true, mu, mu));
1583 // μ moved — the update has something left to try.
1584 assert!(!AdaptiveMuUpdate::tiny_step_is_terminal(true, 0.2 * mu, mu));
1585 // No tiny step: μ pinned at its floor is the ordinary end-game,
1586 // not a reason to stop.
1587 assert!(!AdaptiveMuUpdate::tiny_step_is_terminal(false, mu, mu));
1588 assert!(!AdaptiveMuUpdate::tiny_step_is_terminal(
1589 false,
1590 0.2 * mu,
1591 mu
1592 ));
1593 }
1594
1595 /// Equality is exact, as upstream's `new_mu == mu` is. A reduction of
1596 /// one ulp is a reduction; an epsilon band would call it "unchanged"
1597 /// and terminate an iteration early.
1598 #[test]
1599 fn tiny_step_is_terminal_does_not_round_a_reduction_away() {
1600 let mu = 1e-11;
1601 let nudged = mu - f64::EPSILON * 1e-4;
1602 assert!(nudged < mu, "test setup: the nudge must actually reduce μ");
1603 assert!(!AdaptiveMuUpdate::tiny_step_is_terminal(true, nudged, mu));
1604 }
1605
1606 #[test]
1607 fn probing_iterate_guard_threshold_at_factor_times_mu() {
1608 // Boundary: equality does NOT fire (strict >).
1609 let curr_mu = 1.0e-10;
1610 let factor = 1e4;
1611 assert!(!AdaptiveMuUpdate::probing_iterate_guard_fires(
1612 factor,
1613 curr_mu,
1614 factor * curr_mu
1615 ));
1616 // Just above the boundary fires.
1617 assert!(AdaptiveMuUpdate::probing_iterate_guard_fires(
1618 factor,
1619 curr_mu,
1620 factor * curr_mu * (1.0 + 1e-12)
1621 ));
1622 }
1623}