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