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}