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}