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