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    /// `no_bounds_` flag — port of `IpAdaptiveMuUpdate.cpp:282-287`.
177    /// Set to `true` on the first `update_barrier_parameter` call when
178    /// the iterate has zero bound multipliers (z_l, z_u, v_l, v_u all
179    /// have dim 0 — e.g. BT3, GENHS28, HS50, equality-only TNLPs).
180    /// Subsequent calls return `mu_min` immediately. Without this,
181    /// `mu_max = mu_max_fact * curr_avrg_compl()` evaluates to 0 (no
182    /// slacks → zero complementarity) and the later `clamp(mu_min,
183    /// mu_max)` panics with `min > max`.
184    no_bounds: bool,
185}
186
187impl Default for AdaptiveMuUpdate {
188    fn default() -> Self {
189        // Defaults from `IpAdaptiveMuUpdate.cpp:RegisterOptions`.
190        Self {
191            mu_oracle: MuOracleKind::QualityFunction,
192            adaptive_mu_globalization: AdaptiveMuGlobalization::ObjConstrFilter,
193            adaptive_mu_kkt_norm: AdaptiveMuKktNorm::TwoNormSquared,
194            adaptive_mu_safeguard_factor: 0.0,
195            adaptive_mu_kkterror_red_iters: 4,
196            adaptive_mu_kkterror_red_fact: 0.9999,
197            filter_max_margin: 1.0,
198            filter_margin_fact: 1e-5,
199            mu_min: 1e-11,
200            compl_inf_tol: 1e-4,
201            // Sentinel; lazy-initialised to `mu_max_fact * avrg_compl`
202            // on the first `update_barrier_parameter` call. Upstream
203            // `IpAdaptiveMuUpdate.cpp:164` sets `mu_max_ = -1.` when
204            // the option is not user-specified.
205            mu_max: -1.0,
206            mu_max_fact: 1e3,
207            tau_min: 0.99,
208            mu_init: 0.1,
209            barrier_tol_factor: 10.0,
210            mu_linear_decrease_factor: 0.2,
211            mu_superlinear_decrease_power: 1.5,
212            adaptive_mu_monotone_init_factor: 0.8,
213            restore_accepted_iterate: false,
214            sigma_max: 1e2,
215            sigma_min: 1e-6,
216            qf_norm_type: crate::mu::oracle::quality_function::NormType::TwoNormSquared,
217            qf_centrality_type: crate::mu::oracle::quality_function::CentralityType::None,
218            qf_balancing_term: crate::mu::oracle::quality_function::BalancingTermType::None,
219            qf_max_section_steps: 8,
220            qf_section_sigma_tol: 1e-2,
221            qf_section_qf_tol: 0.0,
222            probing_iterate_quality_factor: 1e4,
223            init_dual_inf: -1.0,
224            init_primal_inf: -1.0,
225            free_mu_mode: true,
226            refs_vals: VecDeque::new(),
227            filter: Filter::new(),
228            accepted_point: None,
229            no_bounds: false,
230        }
231    }
232}
233
234impl AdaptiveMuUpdate {
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    /// Pure-arithmetic predicate behind the probing-oracle iterate-
240    /// quality guard (pounce#58). Returns `true` when the ratio
241    /// `avrg_compl / curr_mu` exceeds `factor`. The two non-strict
242    /// gates (`factor > 0`, `curr_mu > 0`) keep the predicate
243    /// well-defined when the guard is disabled or when an unusual
244    /// μ-strategy zeroes `curr_mu`.
245    pub fn probing_iterate_guard_fires(
246        factor: Number,
247        curr_mu: Number,
248        avrg_compl: Number,
249    ) -> bool {
250        factor > 0.0 && curr_mu > 0.0 && avrg_compl > factor * curr_mu
251    }
252
253    /// Scalar core of the lazy `mu_max` initialization
254    /// (`IpAdaptiveMuUpdate.cpp:267-274`): on the first call, when the
255    /// user did not set `mu_max` explicitly, upstream sets it to
256    /// `mu_max_fact * curr_avrg_compl()`.
257    ///
258    /// A warm start (`warm_start_init_point=yes`) can hand us an iterate
259    /// whose bound multipliers are all zero — pounce does not yet wire
260    /// `warm_start_mult_bound_push`, so `seed_from_nlp` leaves
261    /// `z_l`/`z_u`/`v_l`/`v_u` at 0. Then `curr_avrg_compl()` is 0 even
262    /// though bounds exist, the `no_bounds` short-circuit does NOT fire,
263    /// `mu_max` collapses to 0, and the later `new_mu.clamp(mu_min,
264    /// mu_max)` panics with `min > max` (min = mu_min = 1e-11, max = 0).
265    /// When `avrg` carries no positive complementarity signal (zero, or a
266    /// NaN handed in by a pathological iterate) fall back to `mu_init` as
267    /// the proxy — what a cold start's `avrg_compl` is ~scaled to — so the
268    /// `[mu_min, mu_max]` band stays valid. The final `.max(mu_min)` is a
269    /// belt-and-suspenders floor against pathological options.
270    pub fn lazy_mu_max(
271        mu_max_fact: Number,
272        avrg: Number,
273        mu_init: Number,
274        mu_min: Number,
275    ) -> Number {
276        let avrg = if avrg > 0.0 { avrg } else { mu_init };
277        (mu_max_fact * avrg).max(mu_min)
278    }
279
280    /// Scalar core of `AdaptiveMuUpdate::lower_mu_safeguard`
281    /// (`IpAdaptiveMuUpdate.cpp:753-786`):
282    /// ```text
283    ///   init_dual_inf   ← max(1, dual_inf)   if not yet set
284    ///   init_primal_inf ← max(1, primal_inf) if not yet set
285    ///   lower = max(safeguard_factor * dual_inf / init_dual_inf,
286    ///               safeguard_factor * primal_inf / init_primal_inf)
287    ///   if globalization == KKT_ERROR: lower = min(lower, min_ref_val)
288    /// ```
289    pub fn lower_mu_safeguard(
290        &mut self,
291        dual_inf: Number,
292        primal_inf: Number,
293        min_ref_val: Number,
294    ) -> Number {
295        if self.init_dual_inf < 0.0 {
296            self.init_dual_inf = dual_inf.max(1.0);
297        }
298        if self.init_primal_inf < 0.0 {
299            self.init_primal_inf = primal_inf.max(1.0);
300        }
301        let dual_term = self.adaptive_mu_safeguard_factor * (dual_inf / self.init_dual_inf);
302        let prim_term = self.adaptive_mu_safeguard_factor * (primal_inf / self.init_primal_inf);
303        let mut lower = dual_term.max(prim_term);
304        if self.adaptive_mu_globalization == AdaptiveMuGlobalization::KktError {
305            lower = lower.min(min_ref_val);
306        }
307        lower
308    }
309
310    pub fn reset_init_inf(&mut self) {
311        self.init_dual_inf = -1.0;
312        self.init_primal_inf = -1.0;
313    }
314
315    /// Globalization KKT-error proxy — port of
316    /// `AdaptiveMuUpdate::quality_function_pd_system`
317    /// (`IpAdaptiveMuUpdate.cpp:629-744`). v1.0 hardwires the
318    /// max-norm variant (`adaptive_mu_kkt_norm_type=max-norm`,
319    /// upstream "NM_NORM_MAX") because the existing CQ surface
320    /// exposes max-norm primal/dual infeasibility cheaply; the
321    /// other three norm variants follow once `curr_*_infeasibility`
322    /// learns to dispatch on `NormEnum`. The score sums primal +
323    /// dual + complementarity (+ optional centrality / balancing
324    /// — both default off; left as `0`).
325    fn quality_function_pd_system(&self, cq: &IpoptCqHandle) -> Number {
326        let cq_ref = cq.borrow();
327        let primal_inf = cq_ref.curr_primal_infeasibility_max();
328        let dual_inf = cq_ref.curr_dual_infeasibility_max();
329        // Max-norm complementarity ≈ avrg_compl is a cheap proxy.
330        // Upstream's `curr_complementarity(0., NORM_MAX)` would use
331        // `||s ⊙ z||_∞`; absent that accessor, fall through to the
332        // average. For the monotonicity test inside
333        // `check_sufficient_progress` only ratios matter, so the
334        // proxy preserves the convergence criterion.
335        let complty = cq_ref.curr_avrg_compl();
336        primal_inf + dual_inf + complty
337    }
338
339    /// Port of `AdaptiveMuUpdate::CheckSufficientProgress`
340    /// (`IpAdaptiveMuUpdate.cpp:446-490`). Returns `true` if the
341    /// current iterate makes acceptable progress under the active
342    /// globalization rule.
343    fn check_sufficient_progress(&self, cq: &IpoptCqHandle) -> bool {
344        match self.adaptive_mu_globalization {
345            AdaptiveMuGlobalization::KktError => {
346                if self.refs_vals.len() < self.adaptive_mu_kkterror_red_iters.max(1) {
347                    // Not enough history yet — accept (matches
348                    // upstream's `num_refs >= num_refs_max_` guard).
349                    return true;
350                }
351                let curr_error = self.quality_function_pd_system(cq);
352                self.refs_vals
353                    .iter()
354                    .any(|&r| curr_error <= self.adaptive_mu_kkterror_red_fact * r)
355            }
356            AdaptiveMuGlobalization::ObjConstrFilter => {
357                let cq_ref = cq.borrow();
358                let curr_f = cq_ref.curr_f();
359                let curr_theta = cq_ref.curr_constraint_violation();
360                // `curr_nlp_error` is our analogue of upstream's
361                // global error margin driver.
362                let curr_err = cq_ref.curr_nlp_error();
363                drop(cq_ref);
364                let margin = self.filter_margin_fact * self.filter_max_margin.min(curr_err);
365                !self
366                    .filter
367                    .dominated_by_any(curr_theta + margin, curr_f + margin)
368            }
369            AdaptiveMuGlobalization::NeverMonotoneMode => true,
370        }
371    }
372
373    /// Port of `AdaptiveMuUpdate::RememberCurrentPointAsAccepted`
374    /// (`IpAdaptiveMuUpdate.cpp:492-546`). Records the iterate state
375    /// for the next sufficient-progress check.
376    fn remember_current_point_as_accepted(&mut self, data: &IpoptDataHandle, cq: &IpoptCqHandle) {
377        match self.adaptive_mu_globalization {
378            AdaptiveMuGlobalization::KktError => {
379                let curr_error = self.quality_function_pd_system(cq);
380                if self.refs_vals.len() >= self.adaptive_mu_kkterror_red_iters.max(1) {
381                    self.refs_vals.pop_front();
382                }
383                self.refs_vals.push_back(curr_error);
384            }
385            AdaptiveMuGlobalization::ObjConstrFilter => {
386                let cq_ref = cq.borrow();
387                let f = cq_ref.curr_f();
388                let theta = cq_ref.curr_constraint_violation();
389                let it = data.borrow().iter_count;
390                drop(cq_ref);
391                self.filter.add(theta, f, it);
392            }
393            AdaptiveMuGlobalization::NeverMonotoneMode => {}
394        }
395        if self.restore_accepted_iterate {
396            self.accepted_point = data.borrow().curr.clone();
397        }
398    }
399
400    /// `mu_min` capped so it can never block the termination certificate
401    /// (pounce#266) — the adaptive twin of
402    /// [`crate::mu::monotone::MonotoneMuUpdate::certificate_safe_mu_min`],
403    /// which carries the full story. The raw absolute `mu_min` (default
404    /// `1e-11`) lives in μ's scaled space while `compl_inf_tol` is enforced
405    /// on the *unscaled* complementarity; below
406    /// `|df| ≈ mu_min·(barrier_tol_factor+1)/compl_inf_tol` an uncapped
407    /// floor pins the unscaled complementarity above `compl_inf_tol` and
408    /// the strict certificate is unreachable — in adaptive mode the solve
409    /// then degrades to `Solved_To_Acceptable_Level` (code 100, outside
410    /// AMPL's 0..99 solved band) on an iterate sitting at the optimum.
411    ///
412    /// The restoration sub-builder's `mu_min = 100 · outer_mu_min`
413    /// safeguard is unaffected for the same reason as in monotone mode:
414    /// `RestoIpoptNlp` does not override `obj_scaling_factor`, so the resto
415    /// inner IPM sees `df = 1` and the cap sits far above the safeguard.
416    pub fn certificate_safe_mu_min(&self, obj_scaling_factor: Number) -> Number {
417        crate::mu::certificate_safe_mu_min(
418            self.mu_min,
419            self.compl_inf_tol,
420            self.barrier_tol_factor,
421            obj_scaling_factor,
422        )
423    }
424
425    /// Floor for the **fixed-mode** (monotone-mode) μ decrease — port of
426    /// `IpAdaptiveMuUpdate.cpp:328-329`:
427    ///
428    /// ```cpp
429    /// new_mu = Max(new_mu,
430    ///     Min(compl_inf_tol_scaled, IpData().tol()) / (barrier_tol_factor_ + 1.));
431    /// ```
432    ///
433    /// pounce#511: this branch used to floor at `mu_min` instead — `1e-11`
434    /// against upstream's `9.09e-10` at default `tol = 1e-8`, ~91× lower,
435    /// and further with a looser `tol` (at `tol = 1e-6` upstream's floor is
436    /// `9.09e-8`, four orders up). `mu_min` is the *free*-mode clamp; once the
437    /// strategy has switched to fixed mode upstream deliberately uses the
438    /// looser, tolerance-derived floor — that is the point of the switch.
439    /// Driving the Newton system down to `1e-11` past the accuracy the
440    /// termination test asks for buys nothing and invites degenerate search
441    /// directions on an ill-conditioned Jacobian.
442    ///
443    /// Two details mirror the monotone floor
444    /// (`MonotoneMuUpdate::update_barrier_parameter`):
445    ///
446    /// * `compl_inf_tol` is converted into μ's scaled space first
447    ///   (pounce#257 — upstream's `apply_obj_scaling`), since it is enforced
448    ///   on the *unscaled* complementarity while μ and `tol` are scaled;
449    /// * the result is additionally `max`ed with the certificate-safe
450    ///   `mu_min` (pounce#266) so the restoration sub-builder's
451    ///   `100 · outer_mu_min` safeguard still applies. Capped that way,
452    ///   `mu_min` can only raise the floor, never push it under the
453    ///   certificate.
454    pub fn fixed_mode_mu_floor(&self, tol: Number, obj_scaling_factor: Number) -> Number {
455        let dynamic_floor = tol.min(crate::mu::scaled_compl_inf_tol(
456            self.compl_inf_tol,
457            obj_scaling_factor,
458        )) / (self.barrier_tol_factor + 1.0);
459        self.certificate_safe_mu_min(obj_scaling_factor)
460            .max(dynamic_floor)
461    }
462
463    /// Port of `AdaptiveMuUpdate::NewFixedMu`
464    /// (`IpAdaptiveMuUpdate.cpp:583-627`). Selects μ when the state
465    /// machine drops out of free mode. v1.0 always uses the
466    /// "average complementarity" branch (no `fix_mu_oracle_` is
467    /// wired; matches `fixed_mu_oracle = average_compl`).
468    ///
469    /// The lower clamp is the certificate-safe `mu_min` (pounce#266);
470    /// capped ≤ raw `mu_min`, so the `[mu_min, mu_max]` band the lazy
471    /// `mu_max` init guarantees stays valid.
472    fn new_fixed_mu(&self, cq: &IpoptCqHandle, mu_min: Number) -> Number {
473        let avrg = cq.borrow().curr_avrg_compl();
474        let new_mu = self.adaptive_mu_monotone_init_factor * avrg;
475        new_mu.clamp(mu_min, self.mu_max)
476    }
477
478    /// Upstream's tiny-step termination test (pounce#512), shared by the
479    /// two sites that throw `TINY_STEP_DETECTED` in
480    /// `IpAdaptiveMuUpdate.cpp` — `:330-333` in the fixed-mode
481    /// Fiacco-McCormick decrease and `:377-380` on the free→fixed switch.
482    /// Both read `tiny_step_flag && new_mu == mu`: a tiny step was
483    /// detected *and* the update could not move μ, so no further
484    /// progress is available and the honest exit is "problem solved to
485    /// best possible numerical accuracy" (`STOP_AT_TINY_STEP`) rather
486    /// than iterating to the limit.
487    ///
488    /// Exact equality, like upstream. Both callers reach "unchanged" by
489    /// clamping to the same bound, which is bit-exact; an epsilon band
490    /// would instead swallow a genuine — if minute — reduction and stop
491    /// an iteration early.
492    fn tiny_step_is_terminal(tiny_step_flag: bool, new_mu: Number, curr_mu: Number) -> bool {
493        tiny_step_flag && new_mu == curr_mu
494    }
495}
496
497impl MuUpdate for AdaptiveMuUpdate {
498    /// Port of `IpAdaptiveMuUpdate.cpp:InitializeImpl`. Seeds
499    /// `curr_mu = mu_init`, `curr_tau = max(tau_min, 1 - mu_init)`,
500    /// resets the globalization state, and starts in free-μ mode
501    /// (`SetFreeMuMode(true)` at line 239).
502    fn initialize(&mut self, data: &IpoptDataHandle) {
503        // Mirror upstream `IpAdaptiveMuUpdate.cpp:246-247`:
504        //   IpData().Set_mu(1.);
505        //   IpData().Set_tau(0.);
506        // These are placeholder values so `CalculateSafeSlack` and the
507        // first output line have something to work with; the actual μ
508        // is computed by the oracle at iter 0's `update_barrier_parameter`.
509        // Setting curr_mu = mu_init here (as we used to) skipped the
510        // oracle's iter-0 call and locked μ at mu_init for the first
511        // Newton step — diverging from upstream's iter-0 behaviour
512        // (PFIT3: upstream iter 0 oracle picked μ=1.6e-6, pounce was
513        // stuck at μ=0.1, producing different iter-1 trial point).
514        let mut d = data.borrow_mut();
515        d.curr_mu = 1.0;
516        d.curr_tau = 0.0;
517        drop(d);
518        self.free_mu_mode = true;
519        self.refs_vals.clear();
520        self.filter.clear();
521        self.accepted_point = None;
522        self.init_dual_inf = -1.0;
523        self.init_primal_inf = -1.0;
524        // Reset mu_max sentinel so a re-solve re-runs the lazy init
525        // against the fresh starting iterate's curr_avrg_compl.
526        // Upstream re-enters InitializeImpl on each solve which
527        // (lines 160-165) resets `mu_max_ = -1.` when not user-set.
528        self.mu_max = -1.0;
529        // Reset no-bounds detection on re-solve.
530        self.no_bounds = false;
531    }
532
533    /// Adaptive μ update — port of `UpdateBarrierParameter`
534    /// (`IpAdaptiveMuUpdate.cpp:252-444`). Runs the FreeMuMode /
535    /// FixedMuMode state machine:
536    ///
537    /// * **FreeMuMode**: ask the configured oracle for a candidate
538    ///   (LOQO closed-form, Probing predictor solve, or
539    ///   QualityFunction golden-section). If progress is sufficient,
540    ///   stay in free mode and remember the iterate; otherwise switch
541    ///   to fixed mode at `new_fixed_mu`.
542    /// * **FixedMuMode**: monotone Fiacco-McCormick reduction
543    ///   (`min(linear · μ, μ^superlinear_power)`). Switch back to
544    ///   free mode once the globalization criterion is satisfied
545    ///   again.
546    ///
547    /// Probing / QualityFunction silently fall back to LOQO when
548    /// `nlp` / `pd_search_dir` are unavailable (mirrors upstream
549    /// lines 402-408).
550    ///
551    /// Line-search reset: upstream calls `linesearch_->Reset()` at
552    /// three points — line 339 (fixed-mode decrease), line 386
553    /// (free→fixed switch) and line 431 (**every** free-mode
554    /// iteration, whether or not μ moved). The [`MuUpdate`] trait
555    /// surface carries no line-search handle, so we raise
556    /// [`IpoptData::request_ls_reset`] at exactly those three points
557    /// and `IpoptAlgorithm::iterate` performs the reset right after
558    /// this call returns — the same plumbing the pounce#58 probing
559    /// guard uses for [`IpoptData::request_resto`]. See pounce#510:
560    /// the previous "reset when μ changed" proxy in the caller is
561    /// correct for the monotone update but not for this one, and left
562    /// the filter holding pre-restoration entries whenever μ happened
563    /// to stay put.
564    ///
565    /// [`IpoptData::request_ls_reset`]: crate::ipopt_data::IpoptData::request_ls_reset
566    /// [`IpoptData::request_resto`]: crate::ipopt_data::IpoptData::request_resto
567    fn update_barrier_parameter(
568        &mut self,
569        data: &IpoptDataHandle,
570        cq: &IpoptCqHandle,
571        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
572        pd_search_dir: Option<&mut PdSearchDirCalc>,
573    ) -> Number {
574        // Lazy `mu_max` init — port of `IpAdaptiveMuUpdate.cpp:267-274`.
575        // Upstream computes `mu_max = mu_max_fact * curr_avrg_compl()`
576        // on the first call when the user did not set `mu_max`
577        // explicitly. Pounce previously hard-coded `mu_max = 1e5`,
578        // which let `new_fixed_mu = 0.8 * curr_avrg_compl` cap at 1e5
579        // — on DECONVBNE that allowed μ to jump from 2.5e-3 to ~2000
580        // at iter 198, destabilising the rest of the run.
581        if self.mu_max < 0.0 {
582            let avrg = cq.borrow().curr_avrg_compl();
583            self.mu_max = Self::lazy_mu_max(self.mu_max_fact, avrg, self.mu_init, self.mu_min);
584        }
585
586        // No-bounds short-circuit — port of `IpAdaptiveMuUpdate.cpp:282-296`.
587        // Detect once on the first call whether the iterate has any
588        // bound multipliers (z_l, z_u, v_l, v_u). When all four are
589        // dim-zero (equality-only TNLPs: BT3, GENHS28, HS50, METHANL8,
590        // ...), `curr_avrg_compl()` is 0, hence `mu_max = 0`, and the
591        // later `clamp(mu_min, mu_max)` panics with `min > max`.
592        // Upstream sets `mu = mu_min`, `tau = tau_min`, and short-
593        // circuits all subsequent oracle work; we mirror that.
594        if !self.no_bounds {
595            let n_bounds = {
596                let d = data.borrow();
597                let c = d.curr.as_ref().expect("curr set");
598                c.z_l.dim() + c.z_u.dim() + c.v_l.dim() + c.v_u.dim()
599            };
600            if n_bounds == 0 {
601                self.no_bounds = true;
602                let mut d = data.borrow_mut();
603                d.curr_mu = self.mu_min;
604                d.curr_tau = self.tau_min;
605                return self.mu_min;
606            }
607        }
608        if self.no_bounds {
609            let mut d = data.borrow_mut();
610            d.curr_mu = self.mu_min;
611            d.curr_tau = self.tau_min;
612            return self.mu_min;
613        }
614
615        // Read-and-clear `tiny_step_flag` — mirrors upstream
616        // `IpAdaptiveMuUpdate.cpp:297-298`. The flag is consumed by
617        // this call: without the clear, a single tiny-step detection
618        // would persist forever and suppress `sufficient_progress` on
619        // every later outer iter.
620        let (curr_mu, iter_count, tiny_step_flag) = {
621            let mut d = data.borrow_mut();
622            let out = (d.curr_mu, d.iter_count, d.tiny_step_flag);
623            d.tiny_step_flag = false;
624            out
625        };
626
627        // NB: do NOT short-circuit at iter_count==0. Upstream's
628        // `UpdateBarrierParameter` runs the oracle at iter 0 (the
629        // initialize() above set μ=1.0 as a placeholder only). Skipping
630        // the oracle here locked μ at the placeholder for the first
631        // Newton step. Letting the iter-0 path flow through the
632        // free-μ branch picks up the oracle's choice — the empty
633        // `refs_vals_` makes `check_sufficient_progress` return true,
634        // we remember the iterate, then call the oracle below.
635        // `tiny_step_flag` (and upstream's `CheckSkippedLineSearch()`,
636        // which is only set in non-rigorous resto mode) forces
637        // `sufficient_progress = false` when not in `NEVER_MONOTONE_MODE`
638        // — see `IpAdaptiveMuUpdate.cpp:347-351`. This is what lets a
639        // stalled outer iter drop into fixed-μ and re-seed μ via
640        // `new_fixed_mu` instead of the oracle re-driving μ further down.
641        let force_no_progress = tiny_step_flag
642            && self.adaptive_mu_globalization != AdaptiveMuGlobalization::NeverMonotoneMode;
643
644        // Certificate-safe μ floor (pounce#266): every place below that
645        // stops μ from descending — the fixed-mode reduction, the
646        // fixed-mode re-seed, the oracles' internal clamps, and the final
647        // band clamp — must use `mu_min` capped into the space the
648        // certificate lives in, or a strongly scaled-down objective ends
649        // `Solved_To_Acceptable_Level` on an iterate at the optimum. The
650        // `no_bounds` short-circuit above keeps the raw `mu_min`: with no
651        // bound multipliers there is no complementarity to certify.
652        let obj_scaling_factor = cq.borrow().obj_scaling_factor();
653        let mu_min = self.certificate_safe_mu_min(obj_scaling_factor);
654
655        if !self.free_mu_mode {
656            // Fixed-mu branch — `cpp:299-342`.
657            //
658            // The gate is `sufficient_progress && !tiny_step_flag`
659            // (`cpp:304`) — plain `tiny_step_flag`, *not* the
660            // globalization-conditional `force_no_progress`, which
661            // upstream applies only in the free-mode branch below
662            // (`cpp:347-351`). Reusing `force_no_progress` here let
663            // `adaptive_mu_globalization=never-monotone-mode` switch back
664            // to free mode on a flagged tiny step, which upstream never
665            // does and which routed around the termination at `cpp:330`.
666            // At the default `obj-constr-filter` the two are equal, so
667            // this distinction only moves never-monotone-mode (pounce#512).
668            let sufficient_progress = !tiny_step_flag && self.check_sufficient_progress(cq);
669            if sufficient_progress {
670                // Switch back to free mode and record the iterate —
671                // upstream `cpp:303-311`. Upstream does NOT return
672                // here: after flipping `FreeMuMode` to true the first
673                // if/else ends and control reaches the `if
674                // FreeMuMode()` block at `cpp:391`, which runs the
675                // oracle and picks a fresh μ in the SAME iteration.
676                // Returning `curr_mu` here froze μ on the transition
677                // iter — PALMER4's iter-15 fixed→free transition kept
678                // μ at 2.4e-7 instead of letting the oracle drop it to
679                // mu_min, stalling to Maximum_Iterations_Exceeded.
680                // Fall through to the oracle call below.
681                self.free_mu_mode = true;
682                self.remember_current_point_as_accepted(data, cq);
683            } else {
684                // Keep reducing μ Fiacco-McCormick style if the
685                // barrier subproblem is solved to within
686                // `barrier_tol_factor · μ`, OR if a tiny step was
687                // just detected (`cpp:320` `|| tiny_step_flag`).
688                let sub_problem_error = cq.borrow().curr_barrier_error();
689                if sub_problem_error <= self.barrier_tol_factor * curr_mu || tiny_step_flag {
690                    let lin = self.mu_linear_decrease_factor * curr_mu;
691                    let sup = curr_mu.powf(self.mu_superlinear_decrease_power);
692                    // Fixed-mode floor is NOT `mu_min` — see
693                    // [`Self::fixed_mode_mu_floor`] (pounce#511).
694                    let tol = data.borrow().tol;
695                    let floor = self.fixed_mode_mu_floor(tol, obj_scaling_factor);
696                    let new_mu = lin.min(sup).max(floor).min(self.mu_max);
697                    // `cpp:330-333` — a tiny step was flagged and the
698                    // decrease left μ where it was (it is pinned at the
699                    // floor), so there is nothing left to try. Upstream
700                    // throws TINY_STEP_DETECTED *before* `Set_mu`/`Set_tau`;
701                    // the flag is unchanged by construction, so returning
702                    // it below is the same iterate either way. Pairing it
703                    // with the #511 floor is upstream's own pairing: the
704                    // termination triggers off the same floor the decrease
705                    // stops at, so it now fires at the tolerance-derived
706                    // floor instead of at `mu_min`.
707                    if Self::tiny_step_is_terminal(tiny_step_flag, new_mu, curr_mu) {
708                        data.borrow_mut().request_tiny_step_stop = true;
709                    }
710                    let new_tau = self.tau_min.max(1.0 - new_mu);
711                    let mut d = data.borrow_mut();
712                    d.curr_tau = new_tau;
713                    // Upstream `cpp:339` — reset inside this branch,
714                    // unconditionally, even when the clamps leave μ
715                    // where it was (pounce#510).
716                    d.request_ls_reset = true;
717                    return new_mu;
718                }
719                // Subproblem not yet solved — keep μ. Upstream does NOT
720                // reset the line search on this path (`cpp:335-341`).
721                let new_tau = self.tau_min.max(1.0 - curr_mu);
722                data.borrow_mut().curr_tau = new_tau;
723                return curr_mu;
724            }
725        } else {
726            // Free-mu branch — `cpp:343-389`.
727            let sufficient_progress = !force_no_progress && self.check_sufficient_progress(cq);
728            if sufficient_progress {
729                self.remember_current_point_as_accepted(data, cq);
730                // Fall through to the oracle call below.
731            } else {
732                if std::env::var("POUNCE_DBG_AMU").is_ok() {
733                    let cqr = cq.borrow();
734                    let theta = cqr.curr_constraint_violation();
735                    let f = cqr.curr_f();
736                    let nlp_err = cqr.curr_nlp_error();
737                    let avrg = cqr.curr_avrg_compl();
738                    drop(cqr);
739                    let margin = self.filter_margin_fact * self.filter_max_margin.min(nlp_err);
740                    let entries: Vec<(Number, Number, i32)> = self
741                        .filter
742                        .entries()
743                        .iter()
744                        .map(|e| (e.theta, e.phi, e.iter))
745                        .collect();
746                    tracing::debug!(target: "pounce::mu",
747                        "[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={}",
748                        iter_count,
749                        curr_mu,
750                        theta,
751                        f,
752                        nlp_err,
753                        margin,
754                        avrg,
755                        self.adaptive_mu_monotone_init_factor * avrg,
756                        entries,
757                        force_no_progress,
758                        tiny_step_flag,
759                    );
760                }
761                // Switch into fixed mode.
762                self.free_mu_mode = false;
763                if self.restore_accepted_iterate {
764                    if let Some(prev) = self.accepted_point.clone() {
765                        let mut d = data.borrow_mut();
766                        d.set_trial(prev);
767                        d.accept_trial_point();
768                    }
769                }
770                let new_mu = self.new_fixed_mu(cq, mu_min);
771                // `cpp:377-380` — the same termination on the other
772                // throw site: the switch into fixed mode re-seeded μ to
773                // the value it already had, so the tiny step cannot be
774                // walked off by changing μ either. Ordered after the
775                // free-mode flip and the accepted-iterate restore, as
776                // upstream is.
777                if Self::tiny_step_is_terminal(tiny_step_flag, new_mu, curr_mu) {
778                    data.borrow_mut().request_tiny_step_stop = true;
779                }
780                let new_tau = self.tau_min.max(1.0 - new_mu);
781                let mut d = data.borrow_mut();
782                d.curr_tau = new_tau;
783                // Upstream `cpp:386` — the free→fixed switch resets the
784                // line search whether or not `new_fixed_mu` differs from
785                // the μ we came in with (pounce#510).
786                d.request_ls_reset = true;
787                return new_mu;
788            }
789        }
790
791        // ----- Free-mu oracle call (cpp:391-436) -----
792        let cq_ref = cq.borrow();
793        let dual_inf = cq_ref.curr_dual_infeasibility_max();
794        let primal_inf = cq_ref.curr_primal_infeasibility_max();
795        let avrg_compl = cq_ref.curr_avrg_compl();
796        let centrality_xi = cq_ref.curr_centrality_measure();
797        let nlp_error = cq_ref.curr_nlp_error();
798        drop(cq_ref);
799
800        // τ = max(tau_min, 1 - curr_nlp_error) — upstream cpp:397.
801        let tau = self.tau_min.max(1.0 - nlp_error);
802        data.borrow_mut().curr_tau = tau;
803
804        let loqo_candidate = || {
805            let mut oracle = LoqoMuOracle {
806                mu_min,
807                mu_max: self.mu_max,
808                avrg_compl,
809                centrality_xi,
810            };
811            oracle.calculate_mu().unwrap_or(curr_mu)
812        };
813
814        let candidate = match self.mu_oracle {
815            MuOracleKind::Loqo => loqo_candidate(),
816            MuOracleKind::Probing => {
817                // Iterate-quality guard (pounce#58). The probing
818                // oracle uses `curr_avrg_compl()` for its `mu_curr`
819                // input (see `mu/oracle/probing.rs:85`). When a single
820                // imbalanced `(s_i, z_i)` pair inflates the average
821                // many orders above the stored `data.curr_mu`,
822                // probing's `σ·mu_curr` correctly returns the inflated
823                // value and the resulting search direction throws the
824                // iterate out of the convergence neighborhood. On
825                // arki0012 this manifests as μ jumping 5 orders at
826                // iter 155 followed by divergence to "Local
827                // Infeasibility" at iter 284. We short-circuit by
828                // signalling restoration and keeping μ unchanged; the
829                // main loop in `ipopt_alg.rs` consumes the flag
830                // before the search-direction step.
831                if Self::probing_iterate_guard_fires(
832                    self.probing_iterate_quality_factor,
833                    curr_mu,
834                    avrg_compl,
835                ) {
836                    if std::env::var("POUNCE_DBG_ORACLE").is_ok() {
837                        tracing::debug!(target: "pounce::mu",
838                            "[PN_PROBE_GUARD] iter={} curr_mu={:.3e} avrg_compl={:.3e} ratio={:.3e} > factor={:.3e} → request_resto",
839                            iter_count,
840                            curr_mu,
841                            avrg_compl,
842                            avrg_compl / curr_mu,
843                            self.probing_iterate_quality_factor,
844                        );
845                    }
846                    // No `request_ls_reset` here: this early return is a
847                    // pounce-specific guard with no upstream counterpart,
848                    // it leaves μ untouched, and the caller hands the
849                    // iterate straight to restoration.
850                    data.borrow_mut().request_resto = true;
851                    return curr_mu;
852                }
853                match (nlp, pd_search_dir) {
854                    (Some(nlp), Some(sd)) => {
855                        let mut oracle = ProbingMuOracle {
856                            // Forward the user-set `sigma_max` (default 1e2),
857                            // matching upstream `IpProbingMuOracle.cpp`, which
858                            // reads `options.GetNumericValue("sigma_max", ...)`
859                            // and caps `sigma = Min(sigma, sigma_max_)`. This
860                            // was hard-coded to 100.0, so a user-set `sigma_max`
861                            // reached only the quality-function oracle (L3).
862                            sigma_max: self.sigma_max,
863                            mu_min,
864                            mu_max: self.mu_max,
865                            mu_curr: curr_mu,
866                            mu_aff: curr_mu,
867                        };
868                        oracle
869                            .calculate_mu_with_affine_step(data, cq, nlp, sd, 1.0)
870                            .unwrap_or_else(loqo_candidate)
871                    }
872                    _ => loqo_candidate(),
873                }
874            }
875            MuOracleKind::QualityFunction => match (nlp, pd_search_dir) {
876                (Some(nlp), Some(sd)) => {
877                    let mut oracle = QualityFunctionMuOracle::new();
878                    oracle.mu_min = mu_min;
879                    oracle.mu_max = self.mu_max;
880                    oracle.sigma_min = self.sigma_min;
881                    oracle.sigma_max = self.sigma_max;
882                    oracle.norm_type = self.qf_norm_type;
883                    oracle.centrality_type = self.qf_centrality_type;
884                    oracle.balancing_term = self.qf_balancing_term;
885                    oracle.max_section_steps = self.qf_max_section_steps;
886                    oracle.section_sigma_tol = self.qf_section_sigma_tol;
887                    oracle.section_qf_tol = self.qf_section_qf_tol;
888                    // Mirrors upstream's `quality_function_search` timer
889                    // around `CalculateMu` in `IpQualityFunctionMuOracle.cpp`.
890                    let timing = data.borrow().timing.clone();
891                    let _qf_guard = timing.quality_function_search.guard();
892                    oracle
893                        .calculate_mu_with_predictor_centering(data, cq, nlp, sd)
894                        .unwrap_or_else(loqo_candidate)
895                }
896                _ => loqo_candidate(),
897            },
898        };
899
900        // Safeguard floor + global band clamp (cpp:410-426).
901        let lower = self.lower_mu_safeguard(dual_inf, primal_inf, candidate);
902        let mu = candidate.max(mu_min).max(lower).min(self.mu_max);
903
904        // Upstream `cpp:431` — the free-mode block closes with an
905        // unconditional `linesearch_->Reset()`. This is the point the
906        // old caller-side "μ changed" proxy missed (pounce#510): it
907        // fires on every free-mode iteration, including the ones where
908        // the oracle re-picks the μ we already had, and including the
909        // fixed→free transition that falls through to here. Filter
910        // entries are keyed on a barrier parameter *and* an iterate;
911        // "μ is unchanged" does not make yesterday's entries valid.
912        data.borrow_mut().request_ls_reset = true;
913
914        // NB: upstream `IpAdaptiveMuUpdate.cpp:410-426` does NOT require
915        // `mu ≤ curr_mu` in free mode — the oracle is allowed to bump
916        // μ back up. A prior attempt to cap growth here ("HAIFAM
917        // stability hack") let DECONVBNE's μ plunge from 0.1 to 5e-10
918        // in ~20 iters and never recover (upstream oscillates μ in
919        // [-8,-1] for the same range), trapping `inf_du` at 1e13.
920        // Tiny-step skips are already handled by the
921        // `tiny_step_flag → force_no_progress → new_fixed_mu` path
922        // above, which can raise μ via the fixed-mode branch.
923        mu
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use crate::mu::test_fixture;
931
932    /// pounce#510: upstream resets the line search on **every** free-mode
933    /// iteration (`IpAdaptiveMuUpdate.cpp:431`), not only when μ moves.
934    /// The caller used to infer the reset from `next_mu != mu_before`,
935    /// which silently skipped it whenever the oracle re-picked the μ we
936    /// already had — leaving the filter holding entries computed against
937    /// an iterate and a barrier parameter the algorithm had left behind.
938    #[test]
939    fn free_mode_requests_ls_reset_even_when_mu_is_unchanged() {
940        let mut a = AdaptiveMuUpdate::new();
941        // Never-monotone globalization keeps the state machine in free
942        // mode across both calls, which is the endgame this issue is
943        // about; the filter/KKT variants are covered below.
944        a.adaptive_mu_globalization = AdaptiveMuGlobalization::NeverMonotoneMode;
945        let (data, cq) = test_fixture::fixture(0.1);
946        // First pass: free mode with an empty filter ⇒ sufficient
947        // progress ⇒ the oracle picks μ.
948        let mu1 = a.update_barrier_parameter(&data, &cq, None, None);
949        assert!(a.free_mu_mode);
950        assert!(data.borrow().request_ls_reset);
951
952        // Re-enter at exactly the μ the oracle just chose, on the same
953        // (unchanged) iterate: μ cannot move, and the pre-fix caller
954        // would therefore never reset.
955        data.borrow_mut().request_ls_reset = false;
956        data.borrow_mut().curr_mu = mu1;
957        let mu2 = a.update_barrier_parameter(&data, &cq, None, None);
958        assert_eq!(mu2, mu1, "fixture must hold μ still for this test");
959        assert!(
960            data.borrow().request_ls_reset,
961            "free-mode iteration must request a line-search reset with μ unchanged"
962        );
963    }
964
965    /// pounce#510: the free→fixed switch is upstream's `cpp:386` reset,
966    /// which likewise does not care whether `new_fixed_mu` differs from
967    /// the incoming μ.
968    #[test]
969    fn free_to_fixed_switch_requests_ls_reset() {
970        let mut a = AdaptiveMuUpdate::new();
971        let (data, cq) = test_fixture::fixture(0.1);
972        // Seed the filter with the current point, then re-run: the same
973        // (θ, f) is now dominated, so progress is insufficient and the
974        // update drops into fixed mode.
975        let _ = a.update_barrier_parameter(&data, &cq, None, None);
976        data.borrow_mut().request_ls_reset = false;
977        let _ = a.update_barrier_parameter(&data, &cq, None, None);
978        assert!(
979            !a.free_mu_mode,
980            "fixture must fall out of free mode for this test"
981        );
982        assert!(data.borrow().request_ls_reset);
983    }
984
985    /// pounce#510: the fixed-mode μ decrease is upstream's `cpp:339`
986    /// reset. Note it fires inside the branch, so a decrease that the
987    /// `mu_min`/`mu_max` clamps flatten still resets.
988    #[test]
989    fn fixed_mode_decrease_requests_ls_reset() {
990        let mut a = AdaptiveMuUpdate::new();
991        let (data, cq) = test_fixture::fixture(0.1);
992        a.free_mu_mode = false;
993        // Force "no sufficient progress" so the update stays in fixed
994        // mode, and a barrier tolerance loose enough that the decrease
995        // branch fires on this (far-from-optimal) iterate.
996        a.adaptive_mu_globalization = AdaptiveMuGlobalization::KktError;
997        a.adaptive_mu_kkterror_red_iters = 1;
998        a.adaptive_mu_kkterror_red_fact = 0.0;
999        a.refs_vals.push_back(1.0);
1000        a.barrier_tol_factor = 1e6;
1001        // Degenerate decrease factors: `min(1·μ, μ^1) = μ`. The branch is
1002        // taken but μ does not move, so the pre-fix `next_mu != mu_before`
1003        // proxy would have skipped the reset here as well.
1004        a.mu_linear_decrease_factor = 1.0;
1005        a.mu_superlinear_decrease_power = 1.0;
1006        let mu = a.update_barrier_parameter(&data, &cq, None, None);
1007        assert!(!a.free_mu_mode, "must stay in fixed mode for this test");
1008        assert_eq!(mu, 0.1, "flat decrease leaves μ where it was");
1009        assert!(data.borrow().request_ls_reset);
1010    }
1011
1012    /// The one fixed-mode path upstream leaves alone (`cpp:335-341`):
1013    /// the barrier subproblem is not solved yet, μ stays, no reset.
1014    #[test]
1015    fn fixed_mode_without_decrease_does_not_request_ls_reset() {
1016        let mut a = AdaptiveMuUpdate::new();
1017        let (data, cq) = test_fixture::fixture(1e-8);
1018        a.free_mu_mode = false;
1019        // A far-from-optimal iterate at a tiny μ: the barrier error is
1020        // way above `barrier_tol_factor · μ`, and the filter is empty so
1021        // `check_sufficient_progress` must be forced to fail.
1022        a.adaptive_mu_globalization = AdaptiveMuGlobalization::KktError;
1023        a.adaptive_mu_kkterror_red_iters = 1;
1024        a.adaptive_mu_kkterror_red_fact = 0.0;
1025        a.refs_vals.push_back(1.0);
1026        let mu = a.update_barrier_parameter(&data, &cq, None, None);
1027        assert!(!a.free_mu_mode);
1028        assert_eq!(mu, 1e-8);
1029        assert!(!data.borrow().request_ls_reset);
1030    }
1031
1032    /// pounce#266, adaptive twin of the monotone test: the raw `mu_min`
1033    /// clamp must yield to `compl_inf_tol·|df|/(barrier_tol_factor+1)` once
1034    /// |df| drops below `df* = mu_min·(barrier_tol_factor+1)/compl_inf_tol`,
1035    /// or the strict certificate is unreachable and the solve degrades to
1036    /// `Solved_To_Acceptable_Level` (code 100) at the optimum.
1037    #[test]
1038    fn adaptive_mu_min_is_capped_so_certificate_stays_reachable() {
1039        let a = AdaptiveMuUpdate::new();
1040        let df_star = a.mu_min * (a.barrier_tol_factor + 1.0) / a.compl_inf_tol;
1041        assert!((df_star - 1.1e-6).abs() < 1e-21);
1042        for df in [1.0, -1.0, 1e-3, 1e-5, df_star] {
1043            assert_eq!(a.certificate_safe_mu_min(df), a.mu_min);
1044        }
1045        // HS71 × 1e8 computes df = 8.3e-8, under the cliff: the cap engages.
1046        let df = 8.3e-8;
1047        let capped = a.certificate_safe_mu_min(df);
1048        assert!(capped < a.mu_min);
1049        assert!((capped - 1e-4 * 8.3e-8 / 11.0).abs() < 1e-27);
1050        assert_eq!(a.certificate_safe_mu_min(-df), capped);
1051        // Degenerate factors fall back to the unconverted tolerance, whose
1052        // cap (9.09e-6) leaves mu_min alone.
1053        for df in [0.0, Number::NAN, Number::INFINITY] {
1054            assert_eq!(a.certificate_safe_mu_min(df), a.mu_min);
1055        }
1056        // The restoration sub-builder's `mu_min = 100 · outer_mu_min`
1057        // safeguard survives: the resto inner IPM sees df = 1.
1058        let mut resto = AdaptiveMuUpdate::new();
1059        resto.mu_min = 100.0 * a.mu_min;
1060        assert_eq!(resto.certificate_safe_mu_min(1.0), resto.mu_min);
1061    }
1062
1063    /// pounce#511: the fixed-mode decrease must floor at upstream's
1064    /// `Min(compl_inf_tol_scaled, tol)/(barrier_tol_factor+1)`, not at
1065    /// `mu_min`. At default `tol=1e-8`, `compl_inf_tol=1e-4`,
1066    /// `barrier_tol_factor=10` that is `1e-8/11 ≈ 9.09e-10` — ~91× above
1067    /// `mu_min = 1e-11`, and further still at a looser `tol`.
1068    #[test]
1069    fn fixed_mode_floor_matches_upstream_not_mu_min() {
1070        let a = AdaptiveMuUpdate::new();
1071        let floor = a.fixed_mode_mu_floor(1e-8, 1.0);
1072        assert!((floor - 1e-8 / 11.0).abs() < 1e-20, "floor was {floor}");
1073        // ~91× above `mu_min` — the old floor — i.e. nearly two orders.
1074        assert!(floor / a.mu_min > 90.0, "floor was {floor}");
1075        // Looser `tol` raises the floor with it (upstream takes the min of
1076        // `tol` and `compl_inf_tol`, so `tol` binds until it exceeds 1e-4).
1077        assert!((a.fixed_mode_mu_floor(1e-6, 1.0) - 1e-6 / 11.0).abs() < 1e-18);
1078        // Beyond that `compl_inf_tol` binds.
1079        assert!((a.fixed_mode_mu_floor(1e-2, 1.0) - 1e-4 / 11.0).abs() < 1e-18);
1080    }
1081
1082    /// The `compl_inf_tol` half of the floor is converted into μ's scaled
1083    /// space before the `Min` (upstream's `apply_obj_scaling`, pounce#257),
1084    /// so the two disagree whenever objective scaling is active.
1085    #[test]
1086    fn fixed_mode_floor_scales_compl_inf_tol() {
1087        let a = AdaptiveMuUpdate::new();
1088        // df = 1e-6 puts scaled compl_inf_tol at 1e-10, under `tol=1e-8`,
1089        // so it is the binding half: 1e-10/11 ≈ 9.09e-12.
1090        let df = 1e-6;
1091        let floor = a.fixed_mode_mu_floor(1e-8, df);
1092        assert!(
1093            (floor - 1e-4 * df / 11.0).abs() < 1e-24,
1094            "floor was {floor}"
1095        );
1096        // Sign of the scaling factor (maximization poses df < 0) is
1097        // irrelevant — the magnitude is what converts spaces.
1098        assert_eq!(a.fixed_mode_mu_floor(1e-8, -df), floor);
1099        // Degenerate factors fall back to the unconverted tolerance.
1100        for df in [0.0, Number::NAN, Number::INFINITY] {
1101            assert!((a.fixed_mode_mu_floor(1e-8, df) - 1e-8 / 11.0).abs() < 1e-20);
1102        }
1103    }
1104
1105    /// The restoration sub-builder's `mu_min = 100 · outer_mu_min`
1106    /// safeguard still binds when it sits above the tolerance floor: the
1107    /// certificate-safe `mu_min` is `max`ed in, mirroring monotone mode.
1108    #[test]
1109    fn fixed_mode_floor_keeps_resto_mu_min_safeguard() {
1110        let mut resto = AdaptiveMuUpdate::new();
1111        resto.mu_min = 1e-6; // well above tol/(barrier_tol_factor+1) = 9.09e-10
1112        // `RestoIpoptNlp` does not override obj scaling — the resto inner
1113        // IPM sees df = 1, so the cap leaves `mu_min` alone and it wins.
1114        assert_eq!(resto.fixed_mode_mu_floor(1e-8, 1.0), 1e-6);
1115    }
1116
1117    #[test]
1118    fn lower_mu_safeguard_initializes_from_first_call() {
1119        let mut a = AdaptiveMuUpdate::new();
1120        a.adaptive_mu_safeguard_factor = 1e-2;
1121        // First call captures init values.
1122        let _ = a.lower_mu_safeguard(0.5, 2.0, 1.0);
1123        assert_eq!(a.init_dual_inf, 1.0); // max(1, 0.5)
1124        assert_eq!(a.init_primal_inf, 2.0); // max(1, 2.0)
1125    }
1126
1127    #[test]
1128    fn lower_mu_safeguard_takes_max_of_dual_and_primal_terms() {
1129        let mut a = AdaptiveMuUpdate::new();
1130        a.adaptive_mu_safeguard_factor = 1.0;
1131        // Primal term dominates.
1132        let r = a.lower_mu_safeguard(0.1, 5.0, 1e9);
1133        // init_dual = 1, init_primal = 5 → terms: 0.1, 1.0 → max = 1.0.
1134        assert!((r - 1.0).abs() < 1e-15);
1135    }
1136
1137    #[test]
1138    fn kkt_error_globalization_clips_to_min_ref_val() {
1139        let mut a = AdaptiveMuUpdate::new();
1140        a.adaptive_mu_globalization = AdaptiveMuGlobalization::KktError;
1141        a.adaptive_mu_safeguard_factor = 1.0;
1142        // Without clip, safeguard would be 5.0; min_ref_val = 0.1 wins.
1143        let r = a.lower_mu_safeguard(0.1, 5.0, 0.1);
1144        assert!((r - 0.1).abs() < 1e-15);
1145    }
1146
1147    #[test]
1148    fn reset_clears_init_inf() {
1149        let mut a = AdaptiveMuUpdate::new();
1150        a.adaptive_mu_safeguard_factor = 1.0;
1151        let _ = a.lower_mu_safeguard(0.5, 2.0, 1.0);
1152        a.reset_init_inf();
1153        assert_eq!(a.init_dual_inf, -1.0);
1154        assert_eq!(a.init_primal_inf, -1.0);
1155    }
1156
1157    // The trait `update_barrier_parameter` now takes
1158    // `(&IpoptDataHandle, &IpoptCqHandle)`. End-to-end coverage of the
1159    // adaptive path lands alongside the integration test that drives
1160    // `IpoptAlgorithm::optimize` with `mu_strategy=adaptive`; in
1161    // isolation the unit tests above exercise the safeguard
1162    // arithmetic and option defaults.
1163
1164    #[test]
1165    fn default_mu_oracle_is_quality_function() {
1166        let a = AdaptiveMuUpdate::new();
1167        assert_eq!(a.mu_oracle, MuOracleKind::QualityFunction);
1168    }
1169
1170    #[test]
1171    fn mu_oracle_kind_is_distinct() {
1172        assert_ne!(MuOracleKind::Loqo, MuOracleKind::Probing);
1173        assert_ne!(MuOracleKind::Probing, MuOracleKind::QualityFunction);
1174        assert_ne!(MuOracleKind::Loqo, MuOracleKind::QualityFunction);
1175    }
1176
1177    // pounce#58 guard predicate. Numbers below come from the issue
1178    // body's iter 154-155 trace on arki0012.
1179    #[test]
1180    fn probing_iterate_guard_fires_on_arki0012_iter155() {
1181        let curr_mu = 1.98e-11;
1182        let avrg_compl = 8.90e-6;
1183        assert!(AdaptiveMuUpdate::probing_iterate_guard_fires(
1184            1e4, curr_mu, avrg_compl
1185        ));
1186    }
1187
1188    #[test]
1189    fn probing_iterate_guard_quiet_on_healthy_iter() {
1190        // iter 154 in the same trace — ratio ≈ 2.2; ought not fire.
1191        let curr_mu = 1.02e-11;
1192        let avrg_compl = 2.24e-11;
1193        assert!(!AdaptiveMuUpdate::probing_iterate_guard_fires(
1194            1e4, curr_mu, avrg_compl
1195        ));
1196    }
1197
1198    #[test]
1199    fn probing_iterate_guard_disabled_at_zero_factor() {
1200        // factor=0 ⇒ guard off, even with extreme ratio.
1201        assert!(!AdaptiveMuUpdate::probing_iterate_guard_fires(
1202            0.0, 1e-11, 1.0
1203        ));
1204    }
1205
1206    #[test]
1207    fn probing_iterate_guard_disabled_at_negative_factor() {
1208        assert!(!AdaptiveMuUpdate::probing_iterate_guard_fires(
1209            -1.0, 1e-11, 1.0
1210        ));
1211    }
1212
1213    #[test]
1214    fn probing_iterate_guard_quiet_when_curr_mu_zero() {
1215        // Pathological `curr_mu = 0` (no-bounds branch zeroes it out).
1216        // Predicate must stay quiet rather than division-by-zero.
1217        assert!(!AdaptiveMuUpdate::probing_iterate_guard_fires(
1218            1e4, 0.0, 1e-6
1219        ));
1220    }
1221
1222    // Regression: `mu_strategy=adaptive` + `warm_start_init_point=yes`
1223    // used to panic in `new_mu.clamp(mu_min, mu_max)` with
1224    // "min > max ... min = 1e-11, max = 0.0" — the warm start zeroes the
1225    // bound multipliers, so `curr_avrg_compl()` reads 0 even though
1226    // bounds exist, collapsing `mu_max` to 0. `lazy_mu_max` must keep the
1227    // band valid (mu_max >= mu_min) regardless of the `avrg` it is fed.
1228    #[test]
1229    fn lazy_mu_max_keeps_band_valid_on_zero_avrg_compl() {
1230        let a = AdaptiveMuUpdate::new();
1231        // Warm-start pathology: avrg_compl == 0.
1232        let mu_max = AdaptiveMuUpdate::lazy_mu_max(a.mu_max_fact, 0.0, a.mu_init, a.mu_min);
1233        assert!(
1234            mu_max >= a.mu_min,
1235            "mu_max {mu_max} must not fall below mu_min {}",
1236            a.mu_min
1237        );
1238        // Falls back to the mu_init-scaled band: 1e3 * 0.1 = 100.
1239        assert!((mu_max - a.mu_max_fact * a.mu_init).abs() < 1e-12);
1240    }
1241
1242    #[test]
1243    fn lazy_mu_max_unchanged_for_cold_start() {
1244        let a = AdaptiveMuUpdate::new();
1245        // A healthy cold start hands a positive avrg_compl; the band is
1246        // mu_max_fact * avrg, exactly as before the warm-start guard.
1247        let avrg = 2.5e-3;
1248        let mu_max = AdaptiveMuUpdate::lazy_mu_max(a.mu_max_fact, avrg, a.mu_init, a.mu_min);
1249        assert!((mu_max - a.mu_max_fact * avrg).abs() < 1e-15);
1250    }
1251
1252    #[test]
1253    fn lazy_mu_max_survives_nan_avrg_compl() {
1254        let a = AdaptiveMuUpdate::new();
1255        // A NaN avrg (the other half of the original panic message) must
1256        // not propagate: `avrg > 0.0` is false for NaN, so we fall back.
1257        let mu_max = AdaptiveMuUpdate::lazy_mu_max(a.mu_max_fact, f64::NAN, a.mu_init, a.mu_min);
1258        assert!(mu_max.is_finite() && mu_max >= a.mu_min);
1259    }
1260
1261    // pounce#512 — the shared condition behind both of upstream's
1262    // `TINY_STEP_DETECTED` throws (`IpAdaptiveMuUpdate.cpp:330-333`,
1263    // `:377-380`). Both conjuncts are load-bearing in opposite
1264    // directions: without the flag the update is just at its floor and
1265    // must keep iterating, and without the μ test a tiny step that the
1266    // update *can* still respond to would stop the solve early.
1267    #[test]
1268    fn tiny_step_is_terminal_needs_the_flag_and_an_unmoved_mu() {
1269        let mu = 1e-11;
1270        assert!(AdaptiveMuUpdate::tiny_step_is_terminal(true, mu, mu));
1271        // μ moved — the update has something left to try.
1272        assert!(!AdaptiveMuUpdate::tiny_step_is_terminal(true, 0.2 * mu, mu));
1273        // No tiny step: μ pinned at its floor is the ordinary end-game,
1274        // not a reason to stop.
1275        assert!(!AdaptiveMuUpdate::tiny_step_is_terminal(false, mu, mu));
1276        assert!(!AdaptiveMuUpdate::tiny_step_is_terminal(
1277            false,
1278            0.2 * mu,
1279            mu
1280        ));
1281    }
1282
1283    /// Equality is exact, as upstream's `new_mu == mu` is. A reduction of
1284    /// one ulp is a reduction; an epsilon band would call it "unchanged"
1285    /// and terminate an iteration early.
1286    #[test]
1287    fn tiny_step_is_terminal_does_not_round_a_reduction_away() {
1288        let mu = 1e-11;
1289        let nudged = mu - f64::EPSILON * 1e-4;
1290        assert!(nudged < mu, "test setup: the nudge must actually reduce μ");
1291        assert!(!AdaptiveMuUpdate::tiny_step_is_terminal(true, nudged, mu));
1292    }
1293
1294    #[test]
1295    fn probing_iterate_guard_threshold_at_factor_times_mu() {
1296        // Boundary: equality does NOT fire (strict >).
1297        let curr_mu = 1.0e-10;
1298        let factor = 1e4;
1299        assert!(!AdaptiveMuUpdate::probing_iterate_guard_fires(
1300            factor,
1301            curr_mu,
1302            factor * curr_mu
1303        ));
1304        // Just above the boundary fires.
1305        assert!(AdaptiveMuUpdate::probing_iterate_guard_fires(
1306            factor,
1307            curr_mu,
1308            factor * curr_mu * (1.0 + 1e-12)
1309        ));
1310    }
1311}