Skip to main content

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}