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    /// Upper bound on μ. Sentinel `-1.0` means "not yet computed; init
71    /// lazily on the first `update_barrier_parameter` call to
72    /// `mu_max_fact * curr_avrg_compl()`". Mirrors
73    /// `IpAdaptiveMuUpdate.cpp:160-165` (load step) and
74    /// `IpAdaptiveMuUpdate.cpp:267-274` (lazy init).
75    pub mu_max: Number,
76    /// `mu_max_fact` (default 1e3) — factor for lazy init of `mu_max`.
77    /// Upstream `IpAdaptiveMuUpdate.cpp:RegisterOptions` line 42.
78    /// Ignored if the user explicitly sets `mu_max` to a non-sentinel
79    /// value.
80    pub mu_max_fact: Number,
81    /// `tau_min` from `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Used to
82    /// derive `curr_tau = max(tau_min, 1 - mu)` after each update,
83    /// mirroring upstream's `IpAdaptiveMuUpdate.cpp:UpdateBarrierParameter`
84    /// at the post-oracle update.
85    pub tau_min: Number,
86    /// Initial mu seed — `mu_init` from `IpoptAlgorithm` registered
87    /// options. Used to seed `curr_mu` in `initialize`.
88    pub mu_init: Number,
89    /// `barrier_tol_factor` (default 10) from upstream
90    /// `IpMonotoneMuUpdate::RegisterOptions`. Threshold for fixed-mode
91    /// barrier subproblem completion: reduce μ when
92    /// `curr_barrier_error ≤ barrier_tol_factor · μ`.
93    pub barrier_tol_factor: Number,
94    /// `mu_linear_decrease_factor` (default 0.2) — fixed-mode update
95    /// uses `min(linear · μ, μ^superlinear_power)`.
96    pub mu_linear_decrease_factor: Number,
97    /// `mu_superlinear_decrease_power` (default 1.5).
98    pub mu_superlinear_decrease_power: Number,
99    /// `adaptive_mu_monotone_init_factor` (default 0.8). Used by
100    /// `new_fixed_mu` when no `fix_mu_oracle_` is configured.
101    pub adaptive_mu_monotone_init_factor: Number,
102    /// `adaptive_mu_restore_previous_iterate` (default false).
103    pub restore_accepted_iterate: bool,
104    /// `sigma_max` / `sigma_min` forwarded to
105    /// `QualityFunctionMuOracle` on every free-mode call. Defaults
106    /// from `IpQualityFunctionMuOracle.cpp:RegisterOptions`.
107    pub sigma_max: Number,
108    pub sigma_min: Number,
109
110    /// Upstream tracks `init_*_inf` lazily — sentinel −1 means
111    /// "not yet captured".
112    init_dual_inf: Number,
113    init_primal_inf: Number,
114
115    /// FreeMuMode/FixedMuMode flag — port of
116    /// `IpoptData::FreeMuMode()`. `true` means "let the oracle drive
117    /// μ"; `false` means "monotone decrease until sufficient progress
118    /// is made". Initialised to `true` in [`MuUpdate::initialize`]
119    /// (matches upstream `InitializeImpl` line 239).
120    free_mu_mode: bool,
121    /// KKT-error history for `KKT_ERROR` globalization. Bounded length
122    /// = `adaptive_mu_kkterror_red_iters`. Mirrors `refs_vals_`.
123    refs_vals: VecDeque<Number>,
124    /// 2-D `(theta, phi)` filter for `OBJ_CONSTR_FILTER` globalization.
125    /// Mirrors `filter_` (constructed with `Filter(2)`).
126    filter: Filter,
127    /// Snapshot of `curr` at the most recent successful free-mode
128    /// iterate; restored when switching to fixed mode if
129    /// `restore_accepted_iterate` is on. Mirrors `accepted_point_`.
130    accepted_point: Option<IteratesVector>,
131    /// `no_bounds_` flag — port of `IpAdaptiveMuUpdate.cpp:282-287`.
132    /// Set to `true` on the first `update_barrier_parameter` call when
133    /// the iterate has zero bound multipliers (z_l, z_u, v_l, v_u all
134    /// have dim 0 — e.g. BT3, GENHS28, HS50, equality-only TNLPs).
135    /// Subsequent calls return `mu_min` immediately. Without this,
136    /// `mu_max = mu_max_fact * curr_avrg_compl()` evaluates to 0 (no
137    /// slacks → zero complementarity) and the later `clamp(mu_min,
138    /// mu_max)` panics with `min > max`.
139    no_bounds: bool,
140}
141
142impl Default for AdaptiveMuUpdate {
143    fn default() -> Self {
144        // Defaults from `IpAdaptiveMuUpdate.cpp:RegisterOptions`.
145        Self {
146            mu_oracle: MuOracleKind::QualityFunction,
147            adaptive_mu_globalization: AdaptiveMuGlobalization::ObjConstrFilter,
148            adaptive_mu_kkt_norm: AdaptiveMuKktNorm::TwoNormSquared,
149            adaptive_mu_safeguard_factor: 0.0,
150            adaptive_mu_kkterror_red_iters: 4,
151            adaptive_mu_kkterror_red_fact: 0.9999,
152            filter_max_margin: 1.0,
153            filter_margin_fact: 1e-5,
154            mu_min: 1e-11,
155            // Sentinel; lazy-initialised to `mu_max_fact * avrg_compl`
156            // on the first `update_barrier_parameter` call. Upstream
157            // `IpAdaptiveMuUpdate.cpp:164` sets `mu_max_ = -1.` when
158            // the option is not user-specified.
159            mu_max: -1.0,
160            mu_max_fact: 1e3,
161            tau_min: 0.99,
162            mu_init: 0.1,
163            barrier_tol_factor: 10.0,
164            mu_linear_decrease_factor: 0.2,
165            mu_superlinear_decrease_power: 1.5,
166            adaptive_mu_monotone_init_factor: 0.8,
167            restore_accepted_iterate: false,
168            sigma_max: 1e2,
169            sigma_min: 1e-6,
170            init_dual_inf: -1.0,
171            init_primal_inf: -1.0,
172            free_mu_mode: true,
173            refs_vals: VecDeque::new(),
174            filter: Filter::new(),
175            accepted_point: None,
176            no_bounds: false,
177        }
178    }
179}
180
181impl AdaptiveMuUpdate {
182    pub fn new() -> Self {
183        Self::default()
184    }
185
186    /// Scalar core of `AdaptiveMuUpdate::lower_mu_safeguard`
187    /// (`IpAdaptiveMuUpdate.cpp:753-786`):
188    /// ```text
189    ///   init_dual_inf   ← max(1, dual_inf)   if not yet set
190    ///   init_primal_inf ← max(1, primal_inf) if not yet set
191    ///   lower = max(safeguard_factor * dual_inf / init_dual_inf,
192    ///               safeguard_factor * primal_inf / init_primal_inf)
193    ///   if globalization == KKT_ERROR: lower = min(lower, min_ref_val)
194    /// ```
195    pub fn lower_mu_safeguard(
196        &mut self,
197        dual_inf: Number,
198        primal_inf: Number,
199        min_ref_val: Number,
200    ) -> Number {
201        if self.init_dual_inf < 0.0 {
202            self.init_dual_inf = dual_inf.max(1.0);
203        }
204        if self.init_primal_inf < 0.0 {
205            self.init_primal_inf = primal_inf.max(1.0);
206        }
207        let dual_term = self.adaptive_mu_safeguard_factor * (dual_inf / self.init_dual_inf);
208        let prim_term = self.adaptive_mu_safeguard_factor * (primal_inf / self.init_primal_inf);
209        let mut lower = dual_term.max(prim_term);
210        if self.adaptive_mu_globalization == AdaptiveMuGlobalization::KktError {
211            lower = lower.min(min_ref_val);
212        }
213        lower
214    }
215
216    pub fn reset_init_inf(&mut self) {
217        self.init_dual_inf = -1.0;
218        self.init_primal_inf = -1.0;
219    }
220
221    /// Globalization KKT-error proxy — port of
222    /// `AdaptiveMuUpdate::quality_function_pd_system`
223    /// (`IpAdaptiveMuUpdate.cpp:629-744`). v1.0 hardwires the
224    /// max-norm variant (`adaptive_mu_kkt_norm_type=max-norm`,
225    /// upstream "NM_NORM_MAX") because the existing CQ surface
226    /// exposes max-norm primal/dual infeasibility cheaply; the
227    /// other three norm variants follow once `curr_*_infeasibility`
228    /// learns to dispatch on `NormEnum`. The score sums primal +
229    /// dual + complementarity (+ optional centrality / balancing
230    /// — both default off; left as `0`).
231    fn quality_function_pd_system(&self, cq: &IpoptCqHandle) -> Number {
232        let cq_ref = cq.borrow();
233        let primal_inf = cq_ref.curr_primal_infeasibility_max();
234        let dual_inf = cq_ref.curr_dual_infeasibility_max();
235        // Max-norm complementarity ≈ avrg_compl is a cheap proxy.
236        // Upstream's `curr_complementarity(0., NORM_MAX)` would use
237        // `||s ⊙ z||_∞`; absent that accessor, fall through to the
238        // average. For the monotonicity test inside
239        // `check_sufficient_progress` only ratios matter, so the
240        // proxy preserves the convergence criterion.
241        let complty = cq_ref.curr_avrg_compl();
242        primal_inf + dual_inf + complty
243    }
244
245    /// Port of `AdaptiveMuUpdate::CheckSufficientProgress`
246    /// (`IpAdaptiveMuUpdate.cpp:446-490`). Returns `true` if the
247    /// current iterate makes acceptable progress under the active
248    /// globalization rule.
249    fn check_sufficient_progress(&self, cq: &IpoptCqHandle) -> bool {
250        match self.adaptive_mu_globalization {
251            AdaptiveMuGlobalization::KktError => {
252                if self.refs_vals.len() < self.adaptive_mu_kkterror_red_iters.max(1) {
253                    // Not enough history yet — accept (matches
254                    // upstream's `num_refs >= num_refs_max_` guard).
255                    return true;
256                }
257                let curr_error = self.quality_function_pd_system(cq);
258                self.refs_vals
259                    .iter()
260                    .any(|&r| curr_error <= self.adaptive_mu_kkterror_red_fact * r)
261            }
262            AdaptiveMuGlobalization::ObjConstrFilter => {
263                let cq_ref = cq.borrow();
264                let curr_f = cq_ref.curr_f();
265                let curr_theta = cq_ref.curr_constraint_violation();
266                // `curr_nlp_error` is our analogue of upstream's
267                // global error margin driver.
268                let curr_err = cq_ref.curr_nlp_error();
269                drop(cq_ref);
270                let margin = self.filter_margin_fact * self.filter_max_margin.min(curr_err);
271                !self
272                    .filter
273                    .dominated_by_any(curr_theta + margin, curr_f + margin)
274            }
275            AdaptiveMuGlobalization::NeverMonotoneMode => true,
276        }
277    }
278
279    /// Port of `AdaptiveMuUpdate::RememberCurrentPointAsAccepted`
280    /// (`IpAdaptiveMuUpdate.cpp:492-546`). Records the iterate state
281    /// for the next sufficient-progress check.
282    fn remember_current_point_as_accepted(&mut self, data: &IpoptDataHandle, cq: &IpoptCqHandle) {
283        match self.adaptive_mu_globalization {
284            AdaptiveMuGlobalization::KktError => {
285                let curr_error = self.quality_function_pd_system(cq);
286                if self.refs_vals.len() >= self.adaptive_mu_kkterror_red_iters.max(1) {
287                    self.refs_vals.pop_front();
288                }
289                self.refs_vals.push_back(curr_error);
290            }
291            AdaptiveMuGlobalization::ObjConstrFilter => {
292                let cq_ref = cq.borrow();
293                let f = cq_ref.curr_f();
294                let theta = cq_ref.curr_constraint_violation();
295                let it = data.borrow().iter_count;
296                drop(cq_ref);
297                self.filter.add(theta, f, it);
298            }
299            AdaptiveMuGlobalization::NeverMonotoneMode => {}
300        }
301        if self.restore_accepted_iterate {
302            self.accepted_point = data.borrow().curr.clone();
303        }
304    }
305
306    /// Port of `AdaptiveMuUpdate::NewFixedMu`
307    /// (`IpAdaptiveMuUpdate.cpp:583-627`). Selects μ when the state
308    /// machine drops out of free mode. v1.0 always uses the
309    /// "average complementarity" branch (no `fix_mu_oracle_` is
310    /// wired; matches `fixed_mu_oracle = average_compl`).
311    fn new_fixed_mu(&self, cq: &IpoptCqHandle) -> Number {
312        let avrg = cq.borrow().curr_avrg_compl();
313        let new_mu = self.adaptive_mu_monotone_init_factor * avrg;
314        new_mu.clamp(self.mu_min, self.mu_max)
315    }
316}
317
318impl MuUpdate for AdaptiveMuUpdate {
319    /// Port of `IpAdaptiveMuUpdate.cpp:InitializeImpl`. Seeds
320    /// `curr_mu = mu_init`, `curr_tau = max(tau_min, 1 - mu_init)`,
321    /// resets the globalization state, and starts in free-μ mode
322    /// (`SetFreeMuMode(true)` at line 239).
323    fn initialize(&mut self, data: &IpoptDataHandle) {
324        // Mirror upstream `IpAdaptiveMuUpdate.cpp:246-247`:
325        //   IpData().Set_mu(1.);
326        //   IpData().Set_tau(0.);
327        // These are placeholder values so `CalculateSafeSlack` and the
328        // first output line have something to work with; the actual μ
329        // is computed by the oracle at iter 0's `update_barrier_parameter`.
330        // Setting curr_mu = mu_init here (as we used to) skipped the
331        // oracle's iter-0 call and locked μ at mu_init for the first
332        // Newton step — diverging from upstream's iter-0 behaviour
333        // (PFIT3: upstream iter 0 oracle picked μ=1.6e-6, pounce was
334        // stuck at μ=0.1, producing different iter-1 trial point).
335        let mut d = data.borrow_mut();
336        d.curr_mu = 1.0;
337        d.curr_tau = 0.0;
338        drop(d);
339        self.free_mu_mode = true;
340        self.refs_vals.clear();
341        self.filter.clear();
342        self.accepted_point = None;
343        self.init_dual_inf = -1.0;
344        self.init_primal_inf = -1.0;
345        // Reset mu_max sentinel so a re-solve re-runs the lazy init
346        // against the fresh starting iterate's curr_avrg_compl.
347        // Upstream re-enters InitializeImpl on each solve which
348        // (lines 160-165) resets `mu_max_ = -1.` when not user-set.
349        self.mu_max = -1.0;
350        // Reset no-bounds detection on re-solve.
351        self.no_bounds = false;
352    }
353
354    /// Adaptive μ update — port of `UpdateBarrierParameter`
355    /// (`IpAdaptiveMuUpdate.cpp:252-444`). Runs the FreeMuMode /
356    /// FixedMuMode state machine:
357    ///
358    /// * **FreeMuMode**: ask the configured oracle for a candidate
359    ///   (LOQO closed-form, Probing predictor solve, or
360    ///   QualityFunction golden-section). If progress is sufficient,
361    ///   stay in free mode and remember the iterate; otherwise switch
362    ///   to fixed mode at `new_fixed_mu`.
363    /// * **FixedMuMode**: monotone Fiacco-McCormick reduction
364    ///   (`min(linear · μ, μ^superlinear_power)`). Switch back to
365    ///   free mode once the globalization criterion is satisfied
366    ///   again.
367    ///
368    /// Probing / QualityFunction silently fall back to LOQO when
369    /// `nlp` / `pd_search_dir` are unavailable (mirrors upstream
370    /// lines 402-408).
371    ///
372    /// Note: line-search reset (upstream's `linesearch_->Reset()` at
373    /// lines 339, 386, 431) is not yet wired here — that handle is
374    /// not part of the [`MuUpdate`] trait surface. This is a
375    /// deliberate v1.0 deviation; it primarily affects the watchdog
376    /// counter, not convergence.
377    fn update_barrier_parameter(
378        &mut self,
379        data: &IpoptDataHandle,
380        cq: &IpoptCqHandle,
381        nlp: Option<&Rc<RefCell<dyn IpoptNlp>>>,
382        pd_search_dir: Option<&mut PdSearchDirCalc>,
383    ) -> Number {
384        // Lazy `mu_max` init — port of `IpAdaptiveMuUpdate.cpp:267-274`.
385        // Upstream computes `mu_max = mu_max_fact * curr_avrg_compl()`
386        // on the first call when the user did not set `mu_max`
387        // explicitly. Pounce previously hard-coded `mu_max = 1e5`,
388        // which let `new_fixed_mu = 0.8 * curr_avrg_compl` cap at 1e5
389        // — on DECONVBNE that allowed μ to jump from 2.5e-3 to ~2000
390        // at iter 198, destabilising the rest of the run.
391        if self.mu_max < 0.0 {
392            let avrg = cq.borrow().curr_avrg_compl();
393            self.mu_max = self.mu_max_fact * avrg;
394        }
395
396        // No-bounds short-circuit — port of `IpAdaptiveMuUpdate.cpp:282-296`.
397        // Detect once on the first call whether the iterate has any
398        // bound multipliers (z_l, z_u, v_l, v_u). When all four are
399        // dim-zero (equality-only TNLPs: BT3, GENHS28, HS50, METHANL8,
400        // ...), `curr_avrg_compl()` is 0, hence `mu_max = 0`, and the
401        // later `clamp(mu_min, mu_max)` panics with `min > max`.
402        // Upstream sets `mu = mu_min`, `tau = tau_min`, and short-
403        // circuits all subsequent oracle work; we mirror that.
404        if !self.no_bounds {
405            let n_bounds = {
406                let d = data.borrow();
407                let c = d.curr.as_ref().expect("curr set");
408                c.z_l.dim() + c.z_u.dim() + c.v_l.dim() + c.v_u.dim()
409            };
410            if n_bounds == 0 {
411                self.no_bounds = true;
412                let mut d = data.borrow_mut();
413                d.curr_mu = self.mu_min;
414                d.curr_tau = self.tau_min;
415                return self.mu_min;
416            }
417        }
418        if self.no_bounds {
419            let mut d = data.borrow_mut();
420            d.curr_mu = self.mu_min;
421            d.curr_tau = self.tau_min;
422            return self.mu_min;
423        }
424
425        // Read-and-clear `tiny_step_flag` — mirrors upstream
426        // `IpAdaptiveMuUpdate.cpp:297-298`. The flag is consumed by
427        // this call: without the clear, a single tiny-step detection
428        // would persist forever and suppress `sufficient_progress` on
429        // every later outer iter.
430        let (curr_mu, iter_count, tiny_step_flag) = {
431            let mut d = data.borrow_mut();
432            let out = (d.curr_mu, d.iter_count, d.tiny_step_flag);
433            d.tiny_step_flag = false;
434            out
435        };
436
437        // NB: do NOT short-circuit at iter_count==0. Upstream's
438        // `UpdateBarrierParameter` runs the oracle at iter 0 (the
439        // initialize() above set μ=1.0 as a placeholder only). Skipping
440        // the oracle here locked μ at the placeholder for the first
441        // Newton step. Letting the iter-0 path flow through the
442        // free-μ branch picks up the oracle's choice — the empty
443        // `refs_vals_` makes `check_sufficient_progress` return true,
444        // we remember the iterate, then call the oracle below.
445        // `tiny_step_flag` (and upstream's `CheckSkippedLineSearch()`,
446        // which is only set in non-rigorous resto mode) forces
447        // `sufficient_progress = false` when not in `NEVER_MONOTONE_MODE`
448        // — see `IpAdaptiveMuUpdate.cpp:347-351`. This is what lets a
449        // stalled outer iter drop into fixed-μ and re-seed μ via
450        // `new_fixed_mu` instead of the oracle re-driving μ further down.
451        let force_no_progress = tiny_step_flag
452            && self.adaptive_mu_globalization != AdaptiveMuGlobalization::NeverMonotoneMode;
453
454        if !self.free_mu_mode {
455            // Fixed-mu branch — `cpp:299-342`.
456            let sufficient_progress = !force_no_progress && self.check_sufficient_progress(cq);
457            if sufficient_progress {
458                // Switch back to free mode and record the iterate —
459                // upstream `cpp:303-311`. Upstream does NOT return
460                // here: after flipping `FreeMuMode` to true the first
461                // if/else ends and control reaches the `if
462                // FreeMuMode()` block at `cpp:391`, which runs the
463                // oracle and picks a fresh μ in the SAME iteration.
464                // Returning `curr_mu` here froze μ on the transition
465                // iter — PALMER4's iter-15 fixed→free transition kept
466                // μ at 2.4e-7 instead of letting the oracle drop it to
467                // mu_min, stalling to Maximum_Iterations_Exceeded.
468                // Fall through to the oracle call below.
469                self.free_mu_mode = true;
470                self.remember_current_point_as_accepted(data, cq);
471            } else {
472                // Keep reducing μ Fiacco-McCormick style if the
473                // barrier subproblem is solved to within
474                // `barrier_tol_factor · μ`, OR if a tiny step was
475                // just detected (`cpp:320` `|| tiny_step_flag`).
476                let sub_problem_error = cq.borrow().curr_barrier_error();
477                if sub_problem_error <= self.barrier_tol_factor * curr_mu || tiny_step_flag {
478                    let lin = self.mu_linear_decrease_factor * curr_mu;
479                    let sup = curr_mu.powf(self.mu_superlinear_decrease_power);
480                    let new_mu = lin.min(sup).max(self.mu_min).min(self.mu_max);
481                    let new_tau = self.tau_min.max(1.0 - new_mu);
482                    data.borrow_mut().curr_tau = new_tau;
483                    return new_mu;
484                }
485                // Subproblem not yet solved — keep μ.
486                let new_tau = self.tau_min.max(1.0 - curr_mu);
487                data.borrow_mut().curr_tau = new_tau;
488                return curr_mu;
489            }
490        } else {
491            // Free-mu branch — `cpp:343-389`.
492            let sufficient_progress = !force_no_progress && self.check_sufficient_progress(cq);
493            if sufficient_progress {
494                self.remember_current_point_as_accepted(data, cq);
495                // Fall through to the oracle call below.
496            } else {
497                if std::env::var("POUNCE_DBG_AMU").is_ok() {
498                    let cqr = cq.borrow();
499                    let theta = cqr.curr_constraint_violation();
500                    let f = cqr.curr_f();
501                    let nlp_err = cqr.curr_nlp_error();
502                    let avrg = cqr.curr_avrg_compl();
503                    drop(cqr);
504                    let margin = self.filter_margin_fact * self.filter_max_margin.min(nlp_err);
505                    let entries: Vec<(Number, Number, i32)> = self
506                        .filter
507                        .entries()
508                        .iter()
509                        .map(|e| (e.theta, e.phi, e.iter))
510                        .collect();
511                    eprintln!(
512                        "[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={}",
513                        iter_count,
514                        curr_mu,
515                        theta,
516                        f,
517                        nlp_err,
518                        margin,
519                        avrg,
520                        self.adaptive_mu_monotone_init_factor * avrg,
521                        entries,
522                        force_no_progress,
523                        tiny_step_flag,
524                    );
525                }
526                // Switch into fixed mode.
527                self.free_mu_mode = false;
528                if self.restore_accepted_iterate {
529                    if let Some(prev) = self.accepted_point.clone() {
530                        let mut d = data.borrow_mut();
531                        d.set_trial(prev);
532                        d.accept_trial_point();
533                    }
534                }
535                let new_mu = self.new_fixed_mu(cq);
536                let new_tau = self.tau_min.max(1.0 - new_mu);
537                data.borrow_mut().curr_tau = new_tau;
538                return new_mu;
539            }
540        }
541
542        // ----- Free-mu oracle call (cpp:391-436) -----
543        let cq_ref = cq.borrow();
544        let dual_inf = cq_ref.curr_dual_infeasibility_max();
545        let primal_inf = cq_ref.curr_primal_infeasibility_max();
546        let avrg_compl = cq_ref.curr_avrg_compl();
547        let centrality_xi = cq_ref.curr_centrality_measure();
548        let nlp_error = cq_ref.curr_nlp_error();
549        drop(cq_ref);
550
551        // τ = max(tau_min, 1 - curr_nlp_error) — upstream cpp:397.
552        let tau = self.tau_min.max(1.0 - nlp_error);
553        data.borrow_mut().curr_tau = tau;
554
555        let loqo_candidate = || {
556            let mut oracle = LoqoMuOracle {
557                mu_min: self.mu_min,
558                mu_max: self.mu_max,
559                avrg_compl,
560                centrality_xi,
561            };
562            oracle.calculate_mu().unwrap_or(curr_mu)
563        };
564
565        let candidate = match self.mu_oracle {
566            MuOracleKind::Loqo => loqo_candidate(),
567            MuOracleKind::Probing => match (nlp, pd_search_dir) {
568                (Some(nlp), Some(sd)) => {
569                    let mut oracle = ProbingMuOracle {
570                        sigma_max: 100.0,
571                        mu_min: self.mu_min,
572                        mu_max: self.mu_max,
573                        mu_curr: curr_mu,
574                        mu_aff: curr_mu,
575                    };
576                    oracle
577                        .calculate_mu_with_affine_step(data, cq, nlp, sd, 1.0)
578                        .unwrap_or_else(loqo_candidate)
579                }
580                _ => loqo_candidate(),
581            },
582            MuOracleKind::QualityFunction => match (nlp, pd_search_dir) {
583                (Some(nlp), Some(sd)) => {
584                    let mut oracle = QualityFunctionMuOracle::new();
585                    oracle.mu_min = self.mu_min;
586                    oracle.mu_max = self.mu_max;
587                    oracle.sigma_min = self.sigma_min;
588                    oracle.sigma_max = self.sigma_max;
589                    // Mirrors upstream's `quality_function_search` timer
590                    // around `CalculateMu` in `IpQualityFunctionMuOracle.cpp`.
591                    let timing = data.borrow().timing.clone();
592                    let _qf_guard = timing.quality_function_search.guard();
593                    oracle
594                        .calculate_mu_with_predictor_centering(data, cq, nlp, sd)
595                        .unwrap_or_else(loqo_candidate)
596                }
597                _ => loqo_candidate(),
598            },
599        };
600
601        // Safeguard floor + global band clamp (cpp:410-426).
602        let lower = self.lower_mu_safeguard(dual_inf, primal_inf, candidate);
603        let mu = candidate.max(self.mu_min).max(lower).min(self.mu_max);
604
605        // NB: upstream `IpAdaptiveMuUpdate.cpp:410-426` does NOT require
606        // `mu ≤ curr_mu` in free mode — the oracle is allowed to bump
607        // μ back up. A prior attempt to cap growth here ("HAIFAM
608        // stability hack") let DECONVBNE's μ plunge from 0.1 to 5e-10
609        // in ~20 iters and never recover (upstream oscillates μ in
610        // [-8,-1] for the same range), trapping `inf_du` at 1e13.
611        // Tiny-step skips are already handled by the
612        // `tiny_step_flag → force_no_progress → new_fixed_mu` path
613        // above, which can raise μ via the fixed-mode branch.
614        mu
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn lower_mu_safeguard_initializes_from_first_call() {
624        let mut a = AdaptiveMuUpdate::new();
625        a.adaptive_mu_safeguard_factor = 1e-2;
626        // First call captures init values.
627        let _ = a.lower_mu_safeguard(0.5, 2.0, 1.0);
628        assert_eq!(a.init_dual_inf, 1.0); // max(1, 0.5)
629        assert_eq!(a.init_primal_inf, 2.0); // max(1, 2.0)
630    }
631
632    #[test]
633    fn lower_mu_safeguard_takes_max_of_dual_and_primal_terms() {
634        let mut a = AdaptiveMuUpdate::new();
635        a.adaptive_mu_safeguard_factor = 1.0;
636        // Primal term dominates.
637        let r = a.lower_mu_safeguard(0.1, 5.0, 1e9);
638        // init_dual = 1, init_primal = 5 → terms: 0.1, 1.0 → max = 1.0.
639        assert!((r - 1.0).abs() < 1e-15);
640    }
641
642    #[test]
643    fn kkt_error_globalization_clips_to_min_ref_val() {
644        let mut a = AdaptiveMuUpdate::new();
645        a.adaptive_mu_globalization = AdaptiveMuGlobalization::KktError;
646        a.adaptive_mu_safeguard_factor = 1.0;
647        // Without clip, safeguard would be 5.0; min_ref_val = 0.1 wins.
648        let r = a.lower_mu_safeguard(0.1, 5.0, 0.1);
649        assert!((r - 0.1).abs() < 1e-15);
650    }
651
652    #[test]
653    fn reset_clears_init_inf() {
654        let mut a = AdaptiveMuUpdate::new();
655        a.adaptive_mu_safeguard_factor = 1.0;
656        let _ = a.lower_mu_safeguard(0.5, 2.0, 1.0);
657        a.reset_init_inf();
658        assert_eq!(a.init_dual_inf, -1.0);
659        assert_eq!(a.init_primal_inf, -1.0);
660    }
661
662    // The trait `update_barrier_parameter` now takes
663    // `(&IpoptDataHandle, &IpoptCqHandle)`. End-to-end coverage of the
664    // adaptive path lands alongside the integration test that drives
665    // `IpoptAlgorithm::optimize` with `mu_strategy=adaptive`; in
666    // isolation the unit tests above exercise the safeguard
667    // arithmetic and option defaults.
668
669    #[test]
670    fn default_mu_oracle_is_quality_function() {
671        let a = AdaptiveMuUpdate::new();
672        assert_eq!(a.mu_oracle, MuOracleKind::QualityFunction);
673    }
674
675    #[test]
676    fn mu_oracle_kind_is_distinct() {
677        assert_ne!(MuOracleKind::Loqo, MuOracleKind::Probing);
678        assert_ne!(MuOracleKind::Probing, MuOracleKind::QualityFunction);
679        assert_ne!(MuOracleKind::Loqo, MuOracleKind::QualityFunction);
680    }
681}