Skip to main content

pounce_algorithm/mu/oracle/
quality_function.rs

1//! Quality-function mu oracle — port of
2//! `IpQualityFunctionMuOracle.{hpp,cpp}`. Phase 10.
3//!
4//! The oracle picks `μ_new = σ * avrg_compl` by minimizing a 1-D
5//! quality function `q(σ)` over `σ ∈ [σ_lo, σ_up]` via golden section.
6//! The full vector-valued evaluator (which builds the trial slack /
7//! multiplier vectors at a candidate σ and reduces them to a scalar
8//! norm) is split into two pieces:
9//!
10//! * `evaluate_quality_function` — a *pure-scalar* reducer that takes
11//!   already-computed `‖·‖` aggregates and combines them per the
12//!   `(norm, centrality, balancing)` triple per
13//!   `IpQualityFunctionMuOracle.cpp:566-658`. The vector→aggregate
14//!   step is the caller's responsibility.
15//! * `pick_sigma` — orchestrator that mirrors
16//!   `IpQualityFunctionMuOracle.cpp::CalculateMu` lines 329-385: picks
17//!   the σ-bracket, evaluates `q(1)` and `q(1−ε)` to decide whether
18//!   to search above or below 1, then drives `golden_section`.
19//!
20//! Wiring `pick_sigma` to a fully populated `q(σ)` evaluator —
21//! including the centering predictor solve — is the remaining scope.
22
23use crate::ipopt_cq::IpoptCqHandle;
24use crate::ipopt_data::IpoptDataHandle;
25use crate::ipopt_nlp::IpoptNlp;
26use crate::iterates_vector::IteratesVector;
27use crate::kkt::pd_search_dir_calc::PdSearchDirCalc;
28use crate::mu::oracle::r#trait::MuOracle;
29use pounce_common::types::Number;
30use pounce_linalg::Vector;
31use std::cell::RefCell;
32use std::rc::Rc;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum NormType {
36    OneNorm,
37    /// Squared 2-norm — upstream `NM_NORM_2_SQUARED` (default).
38    /// Aggregates are `||·||²` (no sqrt) and `(1−α)²` weighting.
39    TwoNormSquared,
40    TwoNorm,
41    MaxNorm,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum CentralityType {
46    None,
47    LogCenter,
48    ReciprocalCenter,
49    CubedReciprocalCenter,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum BalancingTermType {
54    None,
55    CubicTerm,
56}
57
58pub struct QualityFunctionMuOracle {
59    pub norm_type: NormType,
60    pub centrality_type: CentralityType,
61    pub balancing_term: BalancingTermType,
62    pub max_section_steps: i32,
63    pub section_sigma_tol: Number,
64    pub section_qf_tol: Number,
65    pub sigma_max: Number,
66    pub sigma_min: Number,
67    pub mu_min: Number,
68    pub mu_max: Number,
69}
70
71impl Default for QualityFunctionMuOracle {
72    fn default() -> Self {
73        // Defaults from `IpQualityFunctionMuOracle.cpp:RegisterOptions`.
74        Self {
75            norm_type: NormType::TwoNormSquared,
76            centrality_type: CentralityType::None,
77            balancing_term: BalancingTermType::None,
78            max_section_steps: 8,
79            section_sigma_tol: 1e-2,
80            section_qf_tol: 0.0,
81            sigma_max: 100.0,
82            // Upstream `IpQualityFunctionMuOracle.cpp:62-69`
83            // `RegisterOptions` default is 1e-6, not 1e-9. Setting it
84            // too low lets golden-section collapse σ all the way to
85            // the floor on outer iterations where q(σ) is nearly
86            // flat over the bracket — which then drives μ to ~1e-11
87            // in a single step and triggers a kappa_sigma blow-up
88            // that pushes the algorithm into restoration. (HS1NE
89            // and ~50 other CUTEst problems exhibited this.)
90            sigma_min: 1e-6,
91            mu_min: 1e-11,
92            mu_max: 1e5,
93        }
94    }
95}
96
97impl QualityFunctionMuOracle {
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Drive the predictor + centring solves through `pd_search_dir`,
103    /// project the results onto the four bound-mask subspaces, then
104    /// run [`pick_sigma`] over a `q(σ)` closure that builds the σ-step
105    /// and reduces it to [`QualityFunctionAggregates`] before invoking
106    /// [`evaluate_quality_function`]. Mirrors upstream
107    /// `IpQualityFunctionMuOracle.cpp::CalculateMu` lines 188-485.
108    ///
109    /// Returns `None` if either linear solve fails (caller falls back
110    /// to LOQO, matching upstream's
111    /// `IpAdaptiveMuUpdate.cpp::CalculateMuFromOracle:330-340`).
112    #[allow(clippy::too_many_lines)]
113    pub fn calculate_mu_with_predictor_centering(
114        &mut self,
115        data: &IpoptDataHandle,
116        cq: &IpoptCqHandle,
117        nlp: &Rc<RefCell<dyn IpoptNlp>>,
118        pd_search_dir: &mut PdSearchDirCalc,
119    ) -> Option<Number> {
120        if !pd_search_dir.compute_affine_step(data, cq, nlp) {
121            return None;
122        }
123        if !pd_search_dir.compute_centering_step(data, cq, nlp) {
124            return None;
125        }
126
127        let delta_aff: IteratesVector = data.borrow().delta_aff.clone()?;
128        let delta_cen: IteratesVector = data.borrow().delta_cen.clone()?;
129
130        // Project step.x onto the bound subspaces. Each block matches
131        // the `step_aff_x_L = P_L^T·δ_aff_x` setup in
132        // `IpQualityFunctionMuOracle.cpp:308-323`.
133        let nlp_ref = nlp.borrow();
134        let cq_ref = cq.borrow();
135        let curr_iv = cq_ref.curr_iv();
136        let curr_slack_x_l = cq_ref.curr_slack_x_l();
137        let curr_slack_x_u = cq_ref.curr_slack_x_u();
138        let curr_slack_s_l = cq_ref.curr_slack_s_l();
139        let curr_slack_s_u = cq_ref.curr_slack_s_u();
140        let avrg_compl = cq_ref.curr_avrg_compl();
141
142        let project = |sign_l_x: Number,
143                       sign_u_x: Number,
144                       step_x: &dyn Vector,
145                       step_s: &dyn Vector|
146         -> [Rc<dyn Vector>; 4] {
147            let mut x_l = curr_slack_x_l.make_new();
148            nlp_ref
149                .px_l()
150                .trans_mult_vector(sign_l_x, step_x, 0.0, &mut *x_l);
151            let mut x_u = curr_slack_x_u.make_new();
152            nlp_ref
153                .px_u()
154                .trans_mult_vector(sign_u_x, step_x, 0.0, &mut *x_u);
155            let mut s_l = curr_slack_s_l.make_new();
156            nlp_ref
157                .pd_l()
158                .trans_mult_vector(sign_l_x, step_s, 0.0, &mut *s_l);
159            let mut s_u = curr_slack_s_u.make_new();
160            nlp_ref
161                .pd_u()
162                .trans_mult_vector(sign_u_x, step_s, 0.0, &mut *s_u);
163            [Rc::from(x_l), Rc::from(x_u), Rc::from(s_l), Rc::from(s_u)]
164        };
165
166        let [step_aff_x_l, step_aff_x_u, step_aff_s_l, step_aff_s_u] =
167            project(1.0, -1.0, &*delta_aff.x, &*delta_aff.s);
168        let [step_cen_x_l, step_cen_x_u, step_cen_s_l, step_cen_s_u] =
169            project(1.0, -1.0, &*delta_cen.x, &*delta_cen.s);
170
171        // The z/v step blocks are stored directly on the iterate — no
172        // projection needed (upstream lines 318-323 use the raw blocks).
173        let step_aff_z_l = delta_aff.z_l.clone();
174        let step_aff_z_u = delta_aff.z_u.clone();
175        let step_aff_v_l = delta_aff.v_l.clone();
176        let step_aff_v_u = delta_aff.v_u.clone();
177        let step_cen_z_l = delta_cen.z_l.clone();
178        let step_cen_z_u = delta_cen.z_u.clone();
179        let step_cen_v_l = delta_cen.v_l.clone();
180        let step_cen_v_u = delta_cen.v_u.clone();
181
182        // Drop the immutable nlp borrow before invoking CQ accessors
183        // that may take a `nlp.borrow_mut()` (e.g. `curr_grad_lag_x` →
184        // `curr_grad_f` → `nlp.eval_grad_f`).
185        drop(nlp_ref);
186
187        // Constant-in-σ aggregates: `dual_aggr` from ‖∇L_x‖, ‖∇L_s‖;
188        // `primal_aggr` from ‖c‖, ‖d−s‖. Norm choice driven by
189        // `self.norm_type`. Upstream `cpp:283-303`.
190        let grad_lag_x = cq_ref.curr_grad_lag_x();
191        let grad_lag_s = cq_ref.curr_grad_lag_s();
192        let c = cq_ref.curr_c();
193        let d_minus_s = cq_ref.curr_d_minus_s();
194        let dual_aggr = match self.norm_type {
195            NormType::OneNorm => grad_lag_x.asum() + grad_lag_s.asum(),
196            NormType::TwoNormSquared => {
197                let nx = grad_lag_x.nrm2();
198                let ns = grad_lag_s.nrm2();
199                nx * nx + ns * ns
200            }
201            NormType::TwoNorm => {
202                let nx = grad_lag_x.nrm2();
203                let ns = grad_lag_s.nrm2();
204                (nx * nx + ns * ns).sqrt()
205            }
206            NormType::MaxNorm => grad_lag_x.amax().max(grad_lag_s.amax()),
207        };
208        let primal_aggr = match self.norm_type {
209            NormType::OneNorm => c.asum() + d_minus_s.asum(),
210            NormType::TwoNormSquared => {
211                let nc = c.nrm2();
212                let nd = d_minus_s.nrm2();
213                nc * nc + nd * nd
214            }
215            NormType::TwoNorm => {
216                let nc = c.nrm2();
217                let nd = d_minus_s.nrm2();
218                (nc * nc + nd * nd).sqrt()
219            }
220            NormType::MaxNorm => c.amax().max(d_minus_s.amax()),
221        };
222
223        let n_dual = curr_iv.x.dim() + curr_iv.s.dim();
224        let n_pri = curr_iv.y_c.dim() + curr_iv.y_d.dim();
225        let n_comp = curr_iv.z_l.dim() + curr_iv.z_u.dim() + curr_iv.v_l.dim() + curr_iv.v_u.dim();
226        let tau = data.borrow().curr_tau;
227
228        let curr_z_l = curr_iv.z_l.clone();
229        let curr_z_u = curr_iv.z_u.clone();
230        let curr_v_l = curr_iv.v_l.clone();
231        let curr_v_u = curr_iv.v_u.clone();
232
233        drop(cq_ref);
234
235        let norm_type = self.norm_type;
236        let centrality = self.centrality_type;
237        let balancing = self.balancing_term;
238
239        // q(σ) closure. Captures the eight aff/cen step projections,
240        // the four current slacks, the four current bound multipliers,
241        // and the constant aggregates; pure scalar work per call.
242        // Scratch for the sigma sweep, allocated once per barrier
243        // update rather than once per trial sigma. `pick_sigma`
244        // evaluates `q` up to `quality_function_max_section_steps + 2`
245        // times, and each evaluation used to build sixteen fresh
246        // vectors: `Vector::set` frees the dense storage
247        // (`shrink_to_fit`) and the `add_two_vectors` that follows
248        // reallocates and zero-fills it via `ensure_storage`, only to
249        // overwrite every entry. On a model the size of mittelmann
250        // `nql180` that was ~190 megabyte-scale malloc/free pairs and
251        // ~190 dead zero-fills per iteration (pounce#749).
252        //
253        // Reuse is exact, not approximate: `add_two_vectors` with
254        // `c == 0` writes every element of the destination, so nothing
255        // carries over between trials and the arithmetic is unchanged
256        // bit for bit. The one-time `set` below is what marks each
257        // buffer initialized; after the first trial `ensure_storage`
258        // finds the buffer already at full length and does nothing.
259        let mut stp_x_l = step_aff_x_l.make_new();
260        let mut stp_x_u = step_aff_x_u.make_new();
261        let mut stp_s_l = step_aff_s_l.make_new();
262        let mut stp_s_u = step_aff_s_u.make_new();
263        let mut stp_z_l = step_aff_z_l.make_new();
264        let mut stp_z_u = step_aff_z_u.make_new();
265        let mut stp_v_l = step_aff_v_l.make_new();
266        let mut stp_v_u = step_aff_v_u.make_new();
267        let mut trial_s_x_l = curr_slack_x_l.make_new();
268        let mut trial_s_x_u = curr_slack_x_u.make_new();
269        let mut trial_s_s_l = curr_slack_s_l.make_new();
270        let mut trial_s_s_u = curr_slack_s_u.make_new();
271        let mut trial_z_l = curr_z_l.make_new();
272        let mut trial_z_u = curr_z_u.make_new();
273        let mut trial_v_l = curr_v_l.make_new();
274        let mut trial_v_u = curr_v_u.make_new();
275        for b in [
276            &mut stp_x_l,
277            &mut stp_x_u,
278            &mut stp_s_l,
279            &mut stp_s_u,
280            &mut stp_z_l,
281            &mut stp_z_u,
282            &mut stp_v_l,
283            &mut stp_v_u,
284            &mut trial_s_x_l,
285            &mut trial_s_x_u,
286            &mut trial_s_s_l,
287            &mut trial_s_s_u,
288            &mut trial_z_l,
289            &mut trial_z_u,
290            &mut trial_v_l,
291            &mut trial_v_u,
292        ] {
293            b.set(0.0);
294        }
295
296        // Hoisted out of the closure: this used to allocate a `String`
297        // and scan the environment on every trial sigma.
298        let dbg_aggr = std::env::var_os("POUNCE_DBG_QF_AGGR").is_some();
299
300        let mut eval_q = |sigma: Number| -> Number {
301            // step_σ = step_aff + σ · step_cen, projected blocks.
302            stp_x_l.add_two_vectors(1.0, &*step_aff_x_l, sigma, &*step_cen_x_l, 0.0);
303            stp_x_u.add_two_vectors(1.0, &*step_aff_x_u, sigma, &*step_cen_x_u, 0.0);
304            stp_s_l.add_two_vectors(1.0, &*step_aff_s_l, sigma, &*step_cen_s_l, 0.0);
305            stp_s_u.add_two_vectors(1.0, &*step_aff_s_u, sigma, &*step_cen_s_u, 0.0);
306            stp_z_l.add_two_vectors(1.0, &*step_aff_z_l, sigma, &*step_cen_z_l, 0.0);
307            stp_z_u.add_two_vectors(1.0, &*step_aff_z_u, sigma, &*step_cen_z_u, 0.0);
308            stp_v_l.add_two_vectors(1.0, &*step_aff_v_l, sigma, &*step_cen_v_l, 0.0);
309            stp_v_u.add_two_vectors(1.0, &*step_aff_v_u, sigma, &*step_cen_v_u, 0.0);
310
311            // α_pri = min over slacks of frac_to_bound(curr_slack, step, τ).
312            let alpha_pri = curr_slack_x_l
313                .frac_to_bound(&*stp_x_l, tau)
314                .min(curr_slack_x_u.frac_to_bound(&*stp_x_u, tau))
315                .min(curr_slack_s_l.frac_to_bound(&*stp_s_l, tau))
316                .min(curr_slack_s_u.frac_to_bound(&*stp_s_u, tau));
317            let alpha_du = curr_z_l
318                .frac_to_bound(&*stp_z_l, tau)
319                .min(curr_z_u.frac_to_bound(&*stp_z_u, tau))
320                .min(curr_v_l.frac_to_bound(&*stp_v_l, tau))
321                .min(curr_v_u.frac_to_bound(&*stp_v_u, tau));
322
323            // Build σ-step trial slacks/duals: trial = curr + α·step.
324            trial_s_x_l.add_two_vectors(1.0, &*curr_slack_x_l, alpha_pri, &*stp_x_l, 0.0);
325            trial_s_x_u.add_two_vectors(1.0, &*curr_slack_x_u, alpha_pri, &*stp_x_u, 0.0);
326            trial_s_s_l.add_two_vectors(1.0, &*curr_slack_s_l, alpha_pri, &*stp_s_l, 0.0);
327            trial_s_s_u.add_two_vectors(1.0, &*curr_slack_s_u, alpha_pri, &*stp_s_u, 0.0);
328
329            trial_z_l.add_two_vectors(1.0, &*curr_z_l, alpha_du, &*stp_z_l, 0.0);
330            trial_z_u.add_two_vectors(1.0, &*curr_z_u, alpha_du, &*stp_z_u, 0.0);
331            trial_v_l.add_two_vectors(1.0, &*curr_v_l, alpha_du, &*stp_v_l, 0.0);
332            trial_v_u.add_two_vectors(1.0, &*curr_v_u, alpha_du, &*stp_v_u, 0.0);
333
334            // Complementarity products at the σ-trial point.
335            trial_s_x_l.element_wise_multiply(&*trial_z_l);
336            trial_s_x_u.element_wise_multiply(&*trial_z_u);
337            trial_s_s_l.element_wise_multiply(&*trial_v_l);
338            trial_s_s_u.element_wise_multiply(&*trial_v_u);
339
340            let compl_aggr = match norm_type {
341                NormType::OneNorm => {
342                    trial_s_x_l.asum()
343                        + trial_s_x_u.asum()
344                        + trial_s_s_l.asum()
345                        + trial_s_s_u.asum()
346                }
347                NormType::TwoNormSquared => {
348                    let a = trial_s_x_l.nrm2();
349                    let b = trial_s_x_u.nrm2();
350                    let c = trial_s_s_l.nrm2();
351                    let d = trial_s_s_u.nrm2();
352                    a * a + b * b + c * c + d * d
353                }
354                NormType::TwoNorm => {
355                    let a = trial_s_x_l.nrm2();
356                    let b = trial_s_x_u.nrm2();
357                    let c = trial_s_s_l.nrm2();
358                    let d = trial_s_s_u.nrm2();
359                    (a * a + b * b + c * c + d * d).sqrt()
360                }
361                NormType::MaxNorm => trial_s_x_l
362                    .amax()
363                    .max(trial_s_x_u.amax())
364                    .max(trial_s_s_l.amax())
365                    .max(trial_s_s_u.amax()),
366            };
367
368            let xi = if matches!(centrality, CentralityType::None) {
369                1.0
370            } else {
371                // Centrality: min(s_i z_i) / avg(s_i z_i). Cheap proxy
372                // when centrality != None — upstream computes the same
373                // ratio at line 612 onward.
374                let total = trial_s_x_l.asum()
375                    + trial_s_x_u.asum()
376                    + trial_s_s_l.asum()
377                    + trial_s_s_u.asum();
378                let avg = if n_comp > 0 {
379                    total / n_comp as Number
380                } else {
381                    1.0
382                };
383                let mn = trial_s_x_l
384                    .min()
385                    .min(trial_s_x_u.min())
386                    .min(trial_s_s_l.min())
387                    .min(trial_s_s_u.min());
388                if avg > 0.0 { mn / avg } else { 1.0 }
389            };
390
391            let aggr = QualityFunctionAggregates {
392                dual_aggr,
393                primal_aggr,
394                compl_aggr,
395                n_dual,
396                n_pri,
397                n_comp,
398            };
399
400            if dbg_aggr {
401                tracing::debug!(target: "pounce::mu",
402                    "[QF_AGGR] σ={:.6e} α_pri={:.6e} α_du={:.6e} xi={:.6e} dual_aggr={:.6e} primal_aggr={:.6e} compl_aggr={:.6e} n_dual={} n_pri={} n_comp={}",
403                    sigma, alpha_pri, alpha_du, xi,
404                    dual_aggr, primal_aggr, compl_aggr,
405                    n_dual, n_pri, n_comp
406                );
407            }
408
409            evaluate_quality_function(
410                norm_type, centrality, balancing, alpha_pri, alpha_du, xi, aggr,
411            )
412        };
413
414        // One-shot σ-sweep dump for iter==N: emits q(σ) at 21 σ values
415        // spanning [σ_min, σ_max] log-uniform. Enable with
416        // `POUNCE_DBG_QF_SWEEP=<iter>` (matches `data.iter_count`).
417        if let Ok(s) = std::env::var("POUNCE_DBG_QF_SWEEP") {
418            if let Ok(target_iter) = s.parse::<i32>() {
419                if data.borrow().iter_count == target_iter {
420                    let lo = self.sigma_min.max(self.mu_min / avrg_compl);
421                    let hi = self.sigma_max.min(self.mu_max / avrg_compl).max(lo * 10.0);
422                    let log_lo = lo.ln();
423                    let log_hi = hi.ln();
424                    tracing::debug!(target: "pounce::mu", "[QF_SWEEP] iter={} avrg_compl={:.6e} σ_range=[{:.3e},{:.3e}] sigma_min={:.3e} sigma_max={:.3e} mu_min={:.3e} mu_max={:.3e}",
425                        target_iter, avrg_compl, lo, hi,
426                        self.sigma_min, self.sigma_max, self.mu_min, self.mu_max);
427                    let n = 21;
428                    for i in 0..n {
429                        let frac = i as f64 / (n - 1) as f64;
430                        let sig = (log_lo + frac * (log_hi - log_lo)).exp();
431                        let q = eval_q(sig);
432                        tracing::debug!(target: "pounce::mu", "[QF_SWEEP] σ={:.6e} q={:.10e}", sig, q);
433                    }
434                    let q1 = eval_q(1.0);
435                    let s1m = 1.0 - self.section_sigma_tol.max(1e-4);
436                    let q1m = eval_q(s1m);
437                    tracing::debug!(target: "pounce::mu",
438                        "[QF_SWEEP] σ=1.0 q={:.10e}  σ={:.6e} q={:.10e}  (q_1minus>q_1: {})",
439                        q1,
440                        s1m,
441                        q1m,
442                        q1m > q1
443                    );
444                }
445            }
446        }
447
448        let sigma = pick_sigma(
449            self.sigma_min,
450            self.sigma_max,
451            self.mu_min,
452            self.mu_max,
453            avrg_compl,
454            self.section_sigma_tol,
455            self.section_qf_tol,
456            self.max_section_steps,
457            &mut eval_q,
458        );
459
460        let mu_new = sigma * avrg_compl;
461        let mu_clamped = mu_new.clamp(self.mu_min, self.mu_max);
462        if std::env::var("POUNCE_DBG_QF").is_ok() {
463            let iter_count = data.borrow().iter_count;
464            let curr_mu = data.borrow().curr_mu;
465            let sigma_floor = self.sigma_min.max(self.mu_min / avrg_compl);
466            let sigma_up_dn = sigma_floor
467                .max(1.0 - self.section_sigma_tol.max(1e-4))
468                .min(self.mu_max / avrg_compl);
469            tracing::debug!(target: "pounce::mu",
470                "[QF] iter={} curr_mu={:.3e} avrg_compl={:.3e} sigma={:.3e} mu_new={:.3e} mu_clamped={:.3e} | sigma_min={:.3e} mu_min={:.3e} sigma_lo_dn={:.3e} sigma_up_dn={:.3e} mu_min/avrg={:.3e}",
471                iter_count, curr_mu, avrg_compl, sigma, mu_new, mu_clamped,
472                self.sigma_min, self.mu_min, sigma_floor, sigma_up_dn,
473                self.mu_min / avrg_compl,
474            );
475        }
476        Some(mu_clamped)
477    }
478}
479
480impl MuOracle for QualityFunctionMuOracle {
481    fn calculate_mu(&mut self) -> Option<Number> {
482        // The full oracle needs the affine and centering steps; until
483        // the iterate plumbing is finalized, return None so the
484        // adaptive μ update falls through to the LOQO fallback as
485        // upstream does at `IpAdaptiveMuUpdate.cpp:CheckSufficientProgress`.
486        None
487    }
488}
489
490/// Pure-scalar golden-section minimizer used by
491/// `QualityFunctionMuOracle::PerformGoldenSection`
492/// (`IpQualityFunctionMuOracle.cpp:668-790`).
493///
494/// Searches for `argmin_{σ ∈ [σ_lo, σ_up]} q(σ)` via golden-section.
495/// Stops when *either*:
496/// * `(σ_up − σ_lo) < σ_tol · σ_up` (relative width), or
497/// * `1 − min(q_corners) / max(q_corners) < qf_tol` (function flat),
498/// * `nsections ≥ max_steps`.
499///
500/// `q_lo` / `q_up` are the function values at the bracket endpoints,
501/// as in upstream where they're often pre-evaluated and a sentinel
502/// `-100.0` is passed when the value isn't yet known.
503pub fn golden_section(
504    sigma_lo_in: Number,
505    sigma_up_in: Number,
506    q_lo_in: Number,
507    q_up_in: Number,
508    sigma_tol: Number,
509    qf_tol: Number,
510    max_steps: i32,
511    mut q: impl FnMut(Number) -> Number,
512) -> Number {
513    let mut sigma_lo = sigma_lo_in;
514    let mut sigma_up = sigma_up_in;
515    let mut q_lo = q_lo_in;
516    let mut q_up = q_up_in;
517
518    let gfac = (3.0 - 5.0_f64.sqrt()) / 2.0;
519    let mut sigma_mid1 = sigma_lo + gfac * (sigma_up - sigma_lo);
520    let mut sigma_mid2 = sigma_lo + (1.0 - gfac) * (sigma_up - sigma_lo);
521    let mut qmid1 = q(sigma_mid1);
522    let mut qmid2 = q(sigma_mid2);
523
524    let mut nsections = 0;
525    let mut width_ok;
526    let mut qf_ok;
527    loop {
528        width_ok = (sigma_up - sigma_lo) >= sigma_tol * sigma_up;
529        let qmin = q_lo.min(q_up).min(qmid1).min(qmid2);
530        let qmax = q_lo.max(q_up).max(qmid1).max(qmid2);
531        qf_ok = qmax > 0.0 && (1.0 - qmin / qmax) >= qf_tol;
532        if !(width_ok && qf_ok && nsections < max_steps) {
533            break;
534        }
535        nsections += 1;
536        if qmid1 > qmid2 {
537            sigma_lo = sigma_mid1;
538            q_lo = qmid1;
539            sigma_mid1 = sigma_mid2;
540            qmid1 = qmid2;
541            sigma_mid2 = sigma_lo + (1.0 - gfac) * (sigma_up - sigma_lo);
542            qmid2 = q(sigma_mid2);
543        } else {
544            sigma_up = sigma_mid2;
545            q_up = qmid2;
546            sigma_mid2 = sigma_mid1;
547            qmid2 = qmid1;
548            sigma_mid1 = sigma_lo + gfac * (sigma_up - sigma_lo);
549            qmid1 = q(sigma_mid1);
550        }
551    }
552
553    // Post-loop selection — mirrors `IpQualityFunctionMuOracle.cpp:749-826`.
554    //
555    // Two distinct cases:
556    //  * **qf_tol stop** (`width_ok && !qf_ok`): the four sampled values
557    //    have converged to within `qf_tol`. Pick whichever of the four
558    //    has the smallest q. Upstream reaches this branch only with real
559    //    values — its loop condition `(1 - qmin/qmax) >= qf_tol` keeps a
560    //    sentinel state alive (sentinel `-100.0` yields a large positive
561    //    ratio) until the slot is overwritten, so `DBG_ASSERT(qf_min > -100.)`
562    //    holds. pounce, however, adds a `qmax > 0.0` guard to `qf_ok`
563    //    (line 499) to avoid a divide-by-zero when every sample is ≤ 0; that
564    //    guard can force `qf_ok = false` while an endpoint still holds the
565    //    sentinel, routing it here. So this branch must re-evaluate an unmoved
566    //    sentinel endpoint first (below), exactly like the else-branch (L4).
567    //  * **Else** (`!width_ok || nsections == max_steps`): pick min of
568    //    the two midpoints, then check whether either endpoint *never
569    //    moved during the loop*. If an unmoved endpoint was passed in
570    //    with the `-100.0` sentinel, it has not been evaluated yet —
571    //    compute its q now and compare. Without this, callers that
572    //    pass a sentinel endpoint (every `pick_sigma` call does — one
573    //    of `q_lo`/`q_up` is always `-100.0`) can have the routine
574    //    return that *unevaluated* endpoint as the minimum, which is
575    //    how DECONVBNE used to land on `sigma = sigma_min`.
576    if width_ok && !qf_ok {
577        // Re-evaluate any endpoint that *never moved during the loop* and is
578        // still carrying the `-100.0` sentinel, before selecting the minimum.
579        // Upstream only reaches this branch with real values (its loop keeps a
580        // sentinel state alive because it lacks the `qmax > 0.0` guard); the
581        // guard pounce adds at line 499 can route a sentinel-containing state
582        // here, so we must mirror the else-branch / upstream re-evaluation or
583        // we would return an unevaluated endpoint as the spurious minimum (L4).
584        if sigma_lo == sigma_lo_in && q_lo < 0.0 {
585            q_lo = q(sigma_lo);
586        }
587        if sigma_up == sigma_up_in && q_up < 0.0 {
588            q_up = q(sigma_up);
589        }
590        let mut best_s = sigma_lo;
591        let mut best_q = q_lo;
592        if q_up < best_q {
593            best_s = sigma_up;
594            best_q = q_up;
595        }
596        if qmid1 < best_q {
597            best_s = sigma_mid1;
598            best_q = qmid1;
599        }
600        if qmid2 < best_q {
601            best_s = sigma_mid2;
602        }
603        return best_s;
604    }
605    let (mut sigma, mut qval) = if qmid1 < qmid2 {
606        (sigma_mid1, qmid1)
607    } else {
608        (sigma_mid2, qmid2)
609    };
610    if sigma_up == sigma_up_in {
611        let qtmp = if q_up < 0.0 { q(sigma_up) } else { q_up };
612        if qtmp < qval {
613            sigma = sigma_up;
614            qval = qtmp;
615        }
616    } else if sigma_lo == sigma_lo_in {
617        let qtmp = if q_lo < 0.0 { q(sigma_lo) } else { q_lo };
618        if qtmp < qval {
619            sigma = sigma_lo;
620        }
621    }
622    let _ = qval;
623    sigma
624}
625
626/// Per-norm aggregates feeding [`evaluate_quality_function`].
627///
628/// All four arrays of pre-reduced complementarity infeasibilities are
629/// caller-provided so the evaluator stays pure-scalar:
630///
631/// * `dual_aggr` — norm of `(grad_lag_x, grad_lag_s)` *before* scaling
632///   by `(1 − α_du)`.
633/// * `primal_aggr` — norm of `(c, d − s)` before `(1 − α_pri)` scaling.
634/// * `compl_aggr` — norm of the four trial-complementarity products
635///   `(s_L · z_L, s_U · z_U, σ_L · v_L, σ_U · v_U)` after the σ-step
636///   has been applied.
637/// * `n_dual`, `n_pri`, `n_comp` — block dimensions used by the
638///   `1`-norm and `2`-norm averaging (the `2_squared` and `max`
639///   variants do not divide).
640#[derive(Debug, Clone, Copy)]
641pub struct QualityFunctionAggregates {
642    pub dual_aggr: Number,
643    pub primal_aggr: Number,
644    pub compl_aggr: Number,
645    pub n_dual: i32,
646    pub n_pri: i32,
647    pub n_comp: i32,
648}
649
650/// Pure-scalar reducer corresponding to
651/// `IpQualityFunctionMuOracle.cpp::CalculateQualityFunction`
652/// lines 566-658 minus the vector→aggregate reduction. Combines the
653/// caller-provided norm aggregates per the configured `(norm,
654/// centrality, balancing)` triple.
655///
656/// `xi` is the centrality measure of the trial complementarity
657/// products; ignored when `centrality == None`.
658pub fn evaluate_quality_function(
659    norm: NormType,
660    centrality: CentralityType,
661    balancing: BalancingTermType,
662    alpha_primal: Number,
663    alpha_dual: Number,
664    xi: Number,
665    aggr: QualityFunctionAggregates,
666) -> Number {
667    let (mut dual_inf, mut primal_inf, mut compl_inf) = match norm {
668        NormType::OneNorm => {
669            let mut d = (1.0 - alpha_dual) * aggr.dual_aggr;
670            let mut p = (1.0 - alpha_primal) * aggr.primal_aggr;
671            let mut c = aggr.compl_aggr;
672            d /= aggr.n_dual as Number;
673            if aggr.n_pri > 0 {
674                p /= aggr.n_pri as Number;
675            }
676            debug_assert!(aggr.n_comp > 0);
677            c /= aggr.n_comp as Number;
678            (d, p, c)
679        }
680        NormType::TwoNormSquared => {
681            // Upstream `IpQualityFunctionMuOracle.cpp:584-595`. The
682            // (1−α)² weight and per-n averaging differ from the plain
683            // 2-norm branch — and this is the upstream default.
684            let mut d = (1.0 - alpha_dual).powi(2) * aggr.dual_aggr;
685            let mut p = (1.0 - alpha_primal).powi(2) * aggr.primal_aggr;
686            let mut c = aggr.compl_aggr;
687            d /= aggr.n_dual as Number;
688            if aggr.n_pri > 0 {
689                p /= aggr.n_pri as Number;
690            }
691            debug_assert!(aggr.n_comp > 0);
692            c /= aggr.n_comp as Number;
693            (d, p, c)
694        }
695        NormType::MaxNorm => (
696            (1.0 - alpha_dual) * aggr.dual_aggr,
697            (1.0 - alpha_primal) * aggr.primal_aggr,
698            aggr.compl_aggr,
699        ),
700        NormType::TwoNorm => {
701            let mut d = (1.0 - alpha_dual) * aggr.dual_aggr;
702            let mut p = (1.0 - alpha_primal) * aggr.primal_aggr;
703            let mut c = aggr.compl_aggr;
704            d /= (aggr.n_dual as Number).sqrt();
705            if aggr.n_pri > 0 {
706                p /= (aggr.n_pri as Number).sqrt();
707            }
708            debug_assert!(aggr.n_comp > 0);
709            c /= (aggr.n_comp as Number).sqrt();
710            (d, p, c)
711        }
712    };
713
714    // Repair fp damage from the divisions when the input was already 0.
715    if dual_inf.is_nan() {
716        dual_inf = 0.0;
717    }
718    if primal_inf.is_nan() {
719        primal_inf = 0.0;
720    }
721    if compl_inf.is_nan() {
722        compl_inf = 0.0;
723    }
724
725    let mut q = dual_inf + primal_inf + compl_inf;
726
727    match centrality {
728        CentralityType::None => {}
729        CentralityType::LogCenter => q -= compl_inf * xi.ln(),
730        CentralityType::ReciprocalCenter => q += compl_inf / xi,
731        CentralityType::CubedReciprocalCenter => q += compl_inf / xi.powi(3),
732    }
733
734    match balancing {
735        BalancingTermType::None => {}
736        BalancingTermType::CubicTerm => {
737            let dom = dual_inf.max(primal_inf) - compl_inf;
738            q += dom.max(0.0).powi(3);
739        }
740    }
741
742    q
743}
744
745/// Sigma-bracket selection + golden-section orchestrator. Mirrors
746/// `IpQualityFunctionMuOracle.cpp::CalculateMu` lines 329-385.
747///
748/// `q` is a black-box `q(σ)` evaluator (typically constructed by
749/// composing the affine + σ·centering step into a trial point and
750/// calling [`evaluate_quality_function`]).
751///
752/// Returns the σ that approximately minimizes `q` on the picked
753/// bracket; the caller then sets `μ_new = σ · avrg_compl` and clamps
754/// to `[mu_min, mu_max]`.
755#[allow(clippy::too_many_arguments)]
756pub fn pick_sigma(
757    sigma_min: Number,
758    sigma_max: Number,
759    mu_min: Number,
760    mu_max: Number,
761    avrg_compl: Number,
762    sigma_tol: Number,
763    qf_tol: Number,
764    max_steps: i32,
765    mut q: impl FnMut(Number) -> Number,
766) -> Number {
767    let qf_1 = q(1.0);
768    let sigma_1minus = 1.0 - sigma_tol.max(1e-4);
769    let qf_1minus = q(sigma_1minus);
770
771    if qf_1minus > qf_1 {
772        // q decreases for σ > 1 — search up.
773        let sigma_up = sigma_max.min(mu_max / avrg_compl);
774        let sigma_lo = 1.0;
775        if sigma_lo >= sigma_up {
776            sigma_up
777        } else {
778            golden_section(
779                sigma_lo, sigma_up, qf_1, -100.0, sigma_tol, qf_tol, max_steps, q,
780            )
781        }
782    } else {
783        // q decreases for σ < 1 — search down.
784        let sigma_lo = sigma_min.max(mu_min / avrg_compl);
785        let sigma_up = sigma_lo.max(sigma_1minus).min(mu_max / avrg_compl);
786        if sigma_lo >= sigma_up {
787            sigma_lo
788        } else {
789            golden_section(
790                sigma_lo, sigma_up, -100.0, qf_1minus, sigma_tol, qf_tol, max_steps, q,
791            )
792        }
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799
800    #[test]
801    fn golden_section_minimizes_parabola() {
802        // q(σ) = (σ − 0.3)²; minimum at σ = 0.3.
803        let f = |s: f64| (s - 0.3).powi(2);
804        let s = golden_section(0.0, 1.0, f(0.0), f(1.0), 1e-6, 0.0, 50, f);
805        assert!((s - 0.3).abs() < 1e-3);
806    }
807
808    #[test]
809    fn golden_section_respects_max_steps() {
810        // Heavy max-step cap should still produce a reasonable σ.
811        let f = |s: f64| (s - 0.5).powi(2);
812        let s = golden_section(0.0, 1.0, f(0.0), f(1.0), 1e-12, 0.0, 5, f);
813        assert!((s - 0.5).abs() < 0.2);
814    }
815
816    #[test]
817    fn golden_section_handles_monotone() {
818        // q monotone increasing → minimum at lo end.
819        let f = |s: f64| s;
820        let s = golden_section(0.1, 2.0, 0.1, 2.0, 1e-6, 0.0, 50, f);
821        assert!(s < 0.2, "got s = {}", s);
822    }
823
824    #[test]
825    fn golden_section_never_returns_unevaluated_sentinel() {
826        // Regression for L4. `pick_sigma` always passes one endpoint with the
827        // `-100.0` sentinel as its q-value (search-up → q_up = -100,
828        // search-down → q_lo = -100). When every *evaluated* sample is ≤ 0,
829        // pounce's added `qmax > 0.0` guard forces `qf_ok = false` on the
830        // first pass and drops into the `width_ok && !qf_ok` branch. Before
831        // the fix that branch compared the raw q values — including the
832        // unevaluated `-100.0` — and returned the sentinel endpoint as the
833        // spurious minimum, even though its true quality value is the *worst*
834        // of the bracket. The fix re-evaluates any unmoved sentinel endpoint
835        // first, mirroring the else-branch and upstream's `if( q_up < 0. )`.
836        let sigma_lo = 1.0_f64;
837        let sigma_up = 3.0_f64;
838        // Negative on the interior/lo points (so qmax ≤ 0) but large and
839        // positive exactly at the upper endpoint — the worst place to land.
840        let q = move |s: f64| if s == sigma_up { 50.0 } else { -s };
841        // search-up style: the upper endpoint carries the -100 sentinel.
842        let s = golden_section(sigma_lo, sigma_up, q(sigma_lo), -100.0, 1e-3, 0.0, 50, q);
843        assert!(
844            s < sigma_up,
845            "golden_section returned the unevaluated sentinel endpoint σ = {} \
846             (true q there = {}, the bracket maximum); it must re-evaluate the \
847             sentinel before selecting a minimum",
848            s,
849            q(s)
850        );
851    }
852
853    #[test]
854    fn calculate_mu_returns_none_until_plumbed() {
855        let mut o = QualityFunctionMuOracle::new();
856        assert!(o.calculate_mu().is_none());
857    }
858
859    fn aggr(
860        d: Number,
861        p: Number,
862        c: Number,
863        nd: i32,
864        np: i32,
865        nc: i32,
866    ) -> QualityFunctionAggregates {
867        QualityFunctionAggregates {
868            dual_aggr: d,
869            primal_aggr: p,
870            compl_aggr: c,
871            n_dual: nd,
872            n_pri: np,
873            n_comp: nc,
874        }
875    }
876
877    #[test]
878    fn evaluate_one_norm_averages_by_n() {
879        // (1−α_du)*d/n_d + (1−α_pri)*p/n_p + c/n_c.
880        let q = evaluate_quality_function(
881            NormType::OneNorm,
882            CentralityType::None,
883            BalancingTermType::None,
884            0.5,  // α_pri
885            0.25, // α_du
886            1.0,
887            aggr(8.0, 4.0, 6.0, 4, 2, 3),
888        );
889        // d = 0.75 * 8 / 4 = 1.5; p = 0.5 * 4 / 2 = 1.0; c = 6/3 = 2.0; total = 4.5
890        assert!((q - 4.5).abs() < 1e-12, "got {}", q);
891    }
892
893    #[test]
894    fn evaluate_max_norm_does_not_divide() {
895        let q = evaluate_quality_function(
896            NormType::MaxNorm,
897            CentralityType::None,
898            BalancingTermType::None,
899            0.0,
900            0.0,
901            1.0,
902            aggr(2.0, 3.0, 5.0, 10, 10, 10),
903        );
904        assert!((q - 10.0).abs() < 1e-12);
905    }
906
907    #[test]
908    fn evaluate_two_norm_divides_by_sqrt_n() {
909        let q = evaluate_quality_function(
910            NormType::TwoNorm,
911            CentralityType::None,
912            BalancingTermType::None,
913            0.0,
914            0.0,
915            1.0,
916            aggr(2.0, 0.0, 4.0, 4, 0, 16),
917        );
918        // d = 2/2 = 1.0; p stays 0 (n_pri = 0 → no divide); c = 4/4 = 1.0
919        assert!((q - 2.0).abs() < 1e-12, "got {}", q);
920    }
921
922    #[test]
923    fn evaluate_one_norm_handles_zero_pri_dim() {
924        // n_pri = 0 ⇒ primal must not be divided.
925        let q = evaluate_quality_function(
926            NormType::OneNorm,
927            CentralityType::None,
928            BalancingTermType::None,
929            0.0,
930            0.0,
931            1.0,
932            aggr(0.0, 0.0, 1.0, 1, 0, 1),
933        );
934        assert!(q.is_finite() && (q - 1.0).abs() < 1e-12);
935    }
936
937    #[test]
938    fn evaluate_log_centrality_subtracts_compl_log_xi() {
939        let base = evaluate_quality_function(
940            NormType::MaxNorm,
941            CentralityType::None,
942            BalancingTermType::None,
943            0.0,
944            0.0,
945            std::f64::consts::E,
946            aggr(0.0, 0.0, 4.0, 1, 1, 1),
947        );
948        let logc = evaluate_quality_function(
949            NormType::MaxNorm,
950            CentralityType::LogCenter,
951            BalancingTermType::None,
952            0.0,
953            0.0,
954            std::f64::consts::E,
955            aggr(0.0, 0.0, 4.0, 1, 1, 1),
956        );
957        // Difference is −compl_inf · ln(xi) = −4 · 1 = −4.
958        assert!((base - logc - 4.0).abs() < 1e-12, "base={base} logc={logc}");
959    }
960
961    #[test]
962    fn evaluate_reciprocal_centrality_adds_c_over_xi() {
963        let q = evaluate_quality_function(
964            NormType::MaxNorm,
965            CentralityType::ReciprocalCenter,
966            BalancingTermType::None,
967            0.0,
968            0.0,
969            0.5,
970            aggr(0.0, 0.0, 1.0, 1, 1, 1),
971        );
972        // 1.0 + 1.0/0.5 = 3.0.
973        assert!((q - 3.0).abs() < 1e-12);
974    }
975
976    #[test]
977    fn evaluate_cubed_reciprocal_centrality_adds_c_over_xi3() {
978        let q = evaluate_quality_function(
979            NormType::MaxNorm,
980            CentralityType::CubedReciprocalCenter,
981            BalancingTermType::None,
982            0.0,
983            0.0,
984            0.5,
985            aggr(0.0, 0.0, 1.0, 1, 1, 1),
986        );
987        // 1.0 + 1.0/0.125 = 9.0.
988        assert!((q - 9.0).abs() < 1e-12);
989    }
990
991    #[test]
992    fn evaluate_cubic_balancing_adds_when_dual_dominates() {
993        let q = evaluate_quality_function(
994            NormType::MaxNorm,
995            CentralityType::None,
996            BalancingTermType::CubicTerm,
997            0.0,
998            0.0,
999            1.0,
1000            aggr(5.0, 1.0, 2.0, 1, 1, 1),
1001        );
1002        // base = 5+1+2 = 8; dom = max(5,1) − 2 = 3; +27 → 35.
1003        assert!((q - 35.0).abs() < 1e-12, "got {}", q);
1004    }
1005
1006    #[test]
1007    fn evaluate_cubic_balancing_zero_when_compl_dominates() {
1008        let q = evaluate_quality_function(
1009            NormType::MaxNorm,
1010            CentralityType::None,
1011            BalancingTermType::CubicTerm,
1012            0.0,
1013            0.0,
1014            1.0,
1015            aggr(1.0, 1.0, 5.0, 1, 1, 1),
1016        );
1017        // dom = max(1,1) − 5 = −4 → clamped to 0; total = 7.
1018        assert!((q - 7.0).abs() < 1e-12);
1019    }
1020
1021    #[test]
1022    fn pick_sigma_searches_below_one_for_decreasing_q() {
1023        // Parabola minimum at σ = 0.4 (well below 1).
1024        let f = |s: f64| (s - 0.4).powi(2);
1025        let s = pick_sigma(1e-9, 100.0, 1e-11, 1e5, 1.0, 1e-6, 0.0, 50, f);
1026        assert!((s - 0.4).abs() < 1e-2, "got s = {}", s);
1027    }
1028
1029    #[test]
1030    fn pick_sigma_searches_above_one_for_q_decreasing_in_sigma() {
1031        // q decreases as σ grows ⇒ minimum at top of bracket.
1032        let f = |s: f64| -s;
1033        let s = pick_sigma(1e-9, 10.0, 1e-11, 1e5, 1.0, 1e-6, 0.0, 50, f);
1034        // bracket up-end is min(sigma_max=10, mu_max/avrg=1e5) = 10.
1035        assert!(s > 5.0, "got s = {}", s);
1036    }
1037
1038    #[test]
1039    fn pick_sigma_clamps_to_mu_max_over_avrg_in_up_search() {
1040        // mu_max/avrg = 2.0 should cap σ_up below sigma_max = 100.
1041        let f = |s: f64| -s;
1042        let s = pick_sigma(1e-9, 100.0, 1e-11, 2.0, 1.0, 1e-6, 0.0, 50, f);
1043        assert!(s <= 2.0 + 1e-9 && s >= 1.0, "got s = {}", s);
1044    }
1045
1046    #[test]
1047    fn pick_sigma_clamps_to_mu_min_over_avrg_in_down_search() {
1048        // mu_min/avrg = 0.5 must dominate σ_min = 1e-9.
1049        // q monotone-decreasing toward 0 → search picks low end of bracket.
1050        let f = |s: f64| s;
1051        let s = pick_sigma(1e-9, 100.0, 0.5, 1e5, 1.0, 1e-6, 0.0, 50, f);
1052        assert!(s >= 0.5 - 1e-9 && s <= 1.0, "got s = {}", s);
1053    }
1054}