Skip to main content

pounce_algorithm/
ipopt_cq.rs

1//! Lazy-cache layer — port of
2//! `Algorithm/IpIpoptCalculatedQuantities.{hpp,cpp}`.
3//!
4//! Upstream's CQ object exposes ~80 cached quantities (`curr_f`,
5//! `curr_grad_f`, `curr_jac_c`, `curr_grad_lag_x`, `curr_compl_*`,
6//! `curr_nlp_error`, etc.). All of them are pure derivations from
7//! `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` and the NLP function
8//! evaluations.
9//!
10//! Phase 5 ships the priority subset needed by the KKT layer
11//! (Phase 6) and the convergence check / line search (Phase 7).
12//! Caching is intentionally deferred — every accessor recomputes its
13//! value on each call. Tag-based invalidation lands once the inner
14//! loop benchmarks justify the bookkeeping; correctness does not
15//! depend on it.
16//!
17//! All accessors take `&self` and return `Rc<dyn Vector>`. NLP
18//! evaluations require a brief `borrow_mut()` on the Nlp handle;
19//! callers must not hold an outstanding `borrow()` across an
20//! accessor call.
21
22use crate::ipopt_data::IpoptDataHandle;
23use crate::ipopt_nlp::IpoptNlp;
24use crate::iterates_vector::IteratesVector;
25use pounce_common::cached::Cache;
26use pounce_common::types::Number;
27use pounce_linalg::dense_vector::DenseVector;
28use pounce_linalg::{Matrix, SymMatrix, Vector};
29use std::cell::RefCell;
30use std::rc::Rc;
31
32/// Safety factor on the per-row noise floor of
33/// [`IpoptCalculatedQuantities::row_noise_floor`]. The floor prices one
34/// component of `x` at `eps · ‖x‖_∞` and passes it through the row at
35/// `max_j |a_ij|`; the row's residual accumulates that over all of its
36/// nonzeros, and the linear solve's conditioning widens it further, so the
37/// bare product is short by a problem-dependent factor. `64` covers a typical
38/// sparse row without reaching far enough to swallow a declared magnitude a
39/// model could have meant: at the `‖x‖_∞ ~ 1` of a well-posed problem the
40/// floor sits near
41/// `1.4e-14`, still nine orders under `constr_viol_tol`'s default. Rows it
42/// silences fall back on the absolute feasibility test, which is already
43/// scale-invariant on a row whose declared magnitude is numerically zero.
44///
45/// Measured, and not a knife edge: over gh #446's 15 problems plus the
46/// infeasibility-detection suites (`false_local_infeasibility`,
47/// `infeasible_status_tol_invariance`, `issue_390_nonlinear_equality_scale`),
48/// every value from `8` to `1024` gives the same verdicts. `1` is too small —
49/// QSCSD1's rows are wide enough that the missing nonzero-count factor still
50/// leaves its `2^-53` RHS above the bound — so `64` sits an order of magnitude
51/// inside the band from either edge.
52const ROW_NOISE_KAPPA: Number = 64.0;
53
54/// Calculated-quantities object. Holds shared handles on data and the
55/// NLP; per-quantity caches live in `RefCell`s here.
56pub struct IpoptCalculatedQuantities {
57    data: IpoptDataHandle,
58    nlp: Rc<RefCell<dyn IpoptNlp>>,
59
60    /// Optimality scaling cap from `IpOptErrorConvCheck` defaults.
61    pub s_max: Number,
62    /// Damping coefficient for the bound-multiplier complementarity
63    /// term (`kappa_d` in upstream's RegisterOptions).
64    pub kappa_d: Number,
65    /// Correction size for very small slacks (`slack_move` option,
66    /// default `mach_eps^{3/4}`). Drives `calculate_safe_slack`'s
67    /// upper cap on the moved bound — port of upstream's `slack_move_`
68    /// (`IpIpoptCalculatedQuantities.cpp:525`).
69    pub slack_move: Number,
70
71    // Per-iterate caches for the hot accessors used by the KKT solver
72    // dependency-tag check. Without these the PdFullSpaceSolver sees a
73    // fresh tag on every solve (each `curr_slack_*` / `curr_sigma_*`
74    // allocates a new vector with a fresh `TaggedCell`), which forces
75    // an MA57 refactor on every SOC step even though the matrix data
76    // is unchanged. Caches are keyed on the input iterate-vector tag
77    // and survive across calls but are naturally invalidated when the
78    // outer iterate advances (curr.x bump).
79    curr_slack_x_l_cache: RefCell<Cache<Rc<dyn Vector>>>,
80    curr_slack_x_u_cache: RefCell<Cache<Rc<dyn Vector>>>,
81    curr_slack_s_l_cache: RefCell<Cache<Rc<dyn Vector>>>,
82    curr_slack_s_u_cache: RefCell<Cache<Rc<dyn Vector>>>,
83    curr_sigma_x_cache: RefCell<Cache<Rc<dyn Vector>>>,
84    curr_sigma_s_cache: RefCell<Cache<Rc<dyn Vector>>>,
85}
86
87/// Helper: convert `Box<dyn Vector>` to `Rc<dyn Vector>`. Cheap; the
88/// box is unwrapped without copying.
89fn rc_from(v: Box<dyn Vector>) -> Rc<dyn Vector> {
90    Rc::from(v)
91}
92
93/// Max-norm of `v` after dividing each entry by its per-row scale factor
94/// (`max_i |v_i / scale_i|`). `scale == None` means "no row scaling" and
95/// returns the plain `v.amax()`; a zero factor for an entry is treated as
96/// the identity (no divide) so a degenerate scale never yields infinities.
97/// Falls back to `v.amax()` for a non-dense backing — POUNCE is dense-only,
98/// so that branch is defensive.
99///
100/// Public because `pounce-restoration`'s locally-infeasible gates compare a
101/// constraint violation against absolute floors (`1e-4` / `1e-3`) and so must
102/// measure it in the same user-facing units this produces — see
103/// `resto_inner_solver::eval_orig_inf_pr_at_inner_curr`. One definition, so
104/// the two call sites cannot drift.
105pub fn unscaled_block_amax(v: &dyn Vector, scale: Option<&[Number]>) -> Number {
106    let Some(s) = scale else {
107        return v.amax();
108    };
109    match v.as_any().downcast_ref::<DenseVector>() {
110        Some(d) => d
111            .values()
112            .iter()
113            .zip(s.iter())
114            .map(|(&x, &f)| if f == 0.0 { x.abs() } else { (x / f).abs() })
115            .fold(0.0, Number::max),
116        None => v.amax(),
117    }
118}
119
120/// `‖v‖_∞` over the components that clear their own entry of `floor`;
121/// components at or below it contribute `0`. Used by
122/// [`IpoptCalculatedQuantities::curr_primal_infeasibility_above_noise`], which
123/// documents what the floor means.
124///
125/// A vector that is not dense, is uninitialized, or whose length disagrees
126/// with `floor` falls back to the plain `‖v‖_∞` — no floor can be attributed
127/// component-wise, and over-reporting the residual is the safe direction.
128fn amax_above_floor(v: &dyn Vector, floor: &[Number]) -> Number {
129    let Some(d) = v.as_any().downcast_ref::<DenseVector>() else {
130        return v.amax();
131    };
132    if !d.is_initialized() {
133        return v.amax();
134    }
135    let values = d.expanded_values();
136    if values.len() != floor.len() {
137        return v.amax();
138    }
139    values
140        .iter()
141        .zip(floor.iter())
142        .map(|(&x, &f)| if x.abs() > f { x.abs() } else { 0.0 })
143        .fold(0.0, Number::max)
144}
145
146/// Result of [`IpoptCalculatedQuantities::adjusted_trial_bounds`]: the
147/// new `x_L / x_U / d_L / d_U` to install on the NLP when one or more
148/// trial slacks were corrected by the safe-slack mechanism.
149pub struct AdjustedBounds {
150    /// Total number of slack components corrected across all four blocks.
151    pub adjusted: usize,
152    pub x_l: Box<dyn Vector>,
153    pub x_u: Box<dyn Vector>,
154    pub d_l: Box<dyn Vector>,
155    pub d_u: Box<dyn Vector>,
156}
157
158impl IpoptCalculatedQuantities {
159    pub fn new(data: IpoptDataHandle, nlp: Rc<RefCell<dyn IpoptNlp>>) -> Self {
160        Self {
161            data,
162            nlp,
163            s_max: 100.0,
164            kappa_d: 1e-5,
165            slack_move: f64::EPSILON.powf(0.75),
166            curr_slack_x_l_cache: RefCell::new(Cache::new(1)),
167            curr_slack_x_u_cache: RefCell::new(Cache::new(1)),
168            curr_slack_s_l_cache: RefCell::new(Cache::new(1)),
169            curr_slack_s_u_cache: RefCell::new(Cache::new(1)),
170            curr_sigma_x_cache: RefCell::new(Cache::new(1)),
171            curr_sigma_s_cache: RefCell::new(Cache::new(1)),
172        }
173    }
174
175    pub fn data(&self) -> &IpoptDataHandle {
176        &self.data
177    }
178
179    pub fn nlp(&self) -> &Rc<RefCell<dyn IpoptNlp>> {
180        &self.nlp
181    }
182
183    pub(crate) fn curr_iv(&self) -> IteratesVector {
184        let Some(iv) = self.data.borrow().curr.as_ref().cloned() else {
185            unreachable!("IpoptCalculatedQuantities: curr iterate not set");
186        };
187        iv
188    }
189
190    fn trial_iv(&self) -> IteratesVector {
191        let Some(iv) = self.data.borrow().trial.as_ref().cloned() else {
192            unreachable!("IpoptCalculatedQuantities: trial iterate not set");
193        };
194        iv
195    }
196
197    // --------------------------------------------------------------
198    // Slacks: s_L = P_L^T x - x_L,  s_U = x_U - P_U^T x.
199    // Mirror of `CalcSlack_L` / `CalcSlack_U`
200    // (`IpIpoptCalculatedQuantities.cpp:238-266`).
201    // --------------------------------------------------------------
202
203    fn calc_slack_l_box(p: &dyn Matrix, x: &dyn Vector, x_bound: &dyn Vector) -> Box<dyn Vector> {
204        let mut result = x_bound.make_new();
205        result.copy(x_bound);
206        // result = -1*result + 1*P^T x  ⇒  P^T x - x_bound.
207        p.trans_mult_vector(1.0, x, -1.0, &mut *result);
208        result
209    }
210
211    fn calc_slack_u_box(p: &dyn Matrix, x: &dyn Vector, x_bound: &dyn Vector) -> Box<dyn Vector> {
212        let mut result = x_bound.make_new();
213        result.copy(x_bound);
214        // result = 1*result + (-1)*P^T x  ⇒  x_bound - P^T x.
215        p.trans_mult_vector(-1.0, x, 1.0, &mut *result);
216        result
217    }
218
219    /// Floor a freshly computed slack against machine precision and,
220    /// where it falls below `eps*min(1,mu)`, raise it to a representable
221    /// positive value, returning the number of corrected components.
222    /// Faithful port of `IpoptCalculatedQuantities::CalculateSafeSlack`
223    /// (`IpIpoptCalculatedQuantities.cpp:455-537`): the corrected slack
224    /// is `min(max(mu/multiplier, s_min), slack_move*max(1,|bound|)+slack)`.
225    /// `multiplier` and `mu` are taken from the *current* iterate, exactly
226    /// as upstream does even for trial slacks.
227    fn calculate_safe_slack(
228        &self,
229        slack: &mut dyn Vector,
230        bound: &dyn Vector,
231        multiplier: &dyn Vector,
232        mu: Number,
233    ) -> usize {
234        if slack.dim() == 0 {
235            return 0;
236        }
237        let min_slack = slack.min();
238        // s_min = eps * min(1, mu); if mu drove it to 0, keep it strictly
239        // positive (upstream #212) so the strict `slack < s_min` test and
240        // the barrier term stay well-defined.
241        let mut s_min = f64::EPSILON * mu.min(1.0);
242        if s_min == 0.0 {
243            s_min = f64::MIN_POSITIVE;
244        }
245        if min_slack >= s_min {
246            return 0;
247        }
248
249        // t = sign(slack - s_min); then collapse to 1 where slack < s_min,
250        // 0 elsewhere.
251        let mut t = slack.make_new();
252        t.copy(&*slack);
253        t.add_scalar(-s_min);
254        t.element_wise_sgn();
255        let mut zero_vec = t.make_new();
256        zero_vec.set(0.0);
257        t.element_wise_min(&*zero_vec); // -1 if slack < s_min, else 0
258        t.scal(-1.0); //  1 if slack < s_min, else 0
259        let retval = t.asum().round() as usize;
260
261        // Clamp the raw slack to be non-negative before forming the target
262        // (upstream's AW fix for negative slacks producing 0).
263        slack.element_wise_max(&*zero_vec);
264
265        // t2 = max(mu/multiplier, s_min) - slack.
266        let mut t2 = t.make_new();
267        let mut s_min_vec = t2.make_new();
268        s_min_vec.set(s_min);
269        if mu != 0.0 {
270            // mu/0 → +inf here, intentionally capped by t_max below.
271            t2.set(mu);
272            t2.element_wise_divide(multiplier);
273            t2.element_wise_max(&*s_min_vec);
274        } else {
275            // mu == 0: max(0/multiplier, s_min) is s_min everywhere, but a 0/0
276            // (zero multiplier at μ=0) would seed the slack target with NaN and
277            // poison the bound move — pin straight to s_min instead.
278            t2.copy(&*s_min_vec);
279        }
280        t2.axpy(-1.0, &*slack);
281
282        // t = max(mu/multiplier, s_min) where flagged, else slack.
283        t.element_wise_select(&*t2);
284        t.axpy(1.0, &*slack);
285
286        // t_max = slack_move*max(1,|bound|) + slack.
287        let mut t_max = t2; // reuse buffer
288        t_max.set(1.0);
289        let mut abs_bound = bound.make_new();
290        abs_bound.copy(bound);
291        abs_bound.element_wise_abs();
292        t_max.element_wise_max(&*abs_bound);
293        // t_max = 1.0*slack + slack_move*t_max.
294        t_max.add_one_vector(1.0, &*slack, self.slack_move);
295
296        // new slack = min(target, t_max) where flagged, else slack.
297        t.element_wise_min(&*t_max);
298        slack.copy(&*t);
299        retval
300    }
301
302    /// `calc_slack_l` followed by `calculate_safe_slack`, returning the
303    /// (floored) slack plus the number of corrected components. The
304    /// multiplier and `mu` come from the current iterate.
305    fn safe_slack_l(
306        &self,
307        p: &dyn Matrix,
308        x: &dyn Vector,
309        bound: &dyn Vector,
310        multiplier: &dyn Vector,
311    ) -> (Rc<dyn Vector>, usize) {
312        let mu = self.data.borrow().curr_mu;
313        let mut result = Self::calc_slack_l_box(p, x, bound);
314        let n = self.calculate_safe_slack(&mut *result, bound, multiplier, mu);
315        (rc_from(result), n)
316    }
317
318    fn safe_slack_u(
319        &self,
320        p: &dyn Matrix,
321        x: &dyn Vector,
322        bound: &dyn Vector,
323        multiplier: &dyn Vector,
324    ) -> (Rc<dyn Vector>, usize) {
325        let mu = self.data.borrow().curr_mu;
326        let mut result = Self::calc_slack_u_box(p, x, bound);
327        let n = self.calculate_safe_slack(&mut *result, bound, multiplier, mu);
328        (rc_from(result), n)
329    }
330
331    pub fn curr_slack_x_l(&self) -> Rc<dyn Vector> {
332        let iv = self.curr_iv();
333        {
334            let cache = self.curr_slack_x_l_cache.borrow();
335            if let Some(v) = cache.get(&[iv.x.as_tagged()], &[]) {
336                return v;
337            }
338        }
339        let nlp = self.nlp.borrow();
340        let (v, _) = self.safe_slack_l(&*nlp.px_l(), &*iv.x, nlp.x_l(), &*iv.z_l);
341        self.curr_slack_x_l_cache
342            .borrow_mut()
343            .add(v.clone(), &[iv.x.as_tagged()], &[]);
344        v
345    }
346
347    pub fn curr_slack_x_u(&self) -> Rc<dyn Vector> {
348        let iv = self.curr_iv();
349        {
350            let cache = self.curr_slack_x_u_cache.borrow();
351            if let Some(v) = cache.get(&[iv.x.as_tagged()], &[]) {
352                return v;
353            }
354        }
355        let nlp = self.nlp.borrow();
356        let (v, _) = self.safe_slack_u(&*nlp.px_u(), &*iv.x, nlp.x_u(), &*iv.z_u);
357        self.curr_slack_x_u_cache
358            .borrow_mut()
359            .add(v.clone(), &[iv.x.as_tagged()], &[]);
360        v
361    }
362
363    pub fn curr_slack_s_l(&self) -> Rc<dyn Vector> {
364        let iv = self.curr_iv();
365        {
366            let cache = self.curr_slack_s_l_cache.borrow();
367            if let Some(v) = cache.get(&[iv.s.as_tagged()], &[]) {
368                return v;
369            }
370        }
371        let nlp = self.nlp.borrow();
372        let (v, _) = self.safe_slack_l(&*nlp.pd_l(), &*iv.s, nlp.d_l(), &*iv.v_l);
373        self.curr_slack_s_l_cache
374            .borrow_mut()
375            .add(v.clone(), &[iv.s.as_tagged()], &[]);
376        v
377    }
378
379    pub fn curr_slack_s_u(&self) -> Rc<dyn Vector> {
380        let iv = self.curr_iv();
381        {
382            let cache = self.curr_slack_s_u_cache.borrow();
383            if let Some(v) = cache.get(&[iv.s.as_tagged()], &[]) {
384                return v;
385            }
386        }
387        let nlp = self.nlp.borrow();
388        let (v, _) = self.safe_slack_u(&*nlp.pd_u(), &*iv.s, nlp.d_u(), &*iv.v_u);
389        self.curr_slack_s_u_cache
390            .borrow_mut()
391            .add(v.clone(), &[iv.s.as_tagged()], &[]);
392        v
393    }
394
395    pub fn trial_slack_x_l(&self) -> Rc<dyn Vector> {
396        let iv = self.trial_iv();
397        let mult = self.curr_iv();
398        let nlp = self.nlp.borrow();
399        self.safe_slack_l(&*nlp.px_l(), &*iv.x, nlp.x_l(), &*mult.z_l)
400            .0
401    }
402
403    pub fn trial_slack_x_u(&self) -> Rc<dyn Vector> {
404        let iv = self.trial_iv();
405        let mult = self.curr_iv();
406        let nlp = self.nlp.borrow();
407        self.safe_slack_u(&*nlp.px_u(), &*iv.x, nlp.x_u(), &*mult.z_u)
408            .0
409    }
410
411    pub fn trial_slack_s_l(&self) -> Rc<dyn Vector> {
412        let iv = self.trial_iv();
413        let mult = self.curr_iv();
414        let nlp = self.nlp.borrow();
415        self.safe_slack_l(&*nlp.pd_l(), &*iv.s, nlp.d_l(), &*mult.v_l)
416            .0
417    }
418
419    pub fn trial_slack_s_u(&self) -> Rc<dyn Vector> {
420        let iv = self.trial_iv();
421        let mult = self.curr_iv();
422        let nlp = self.nlp.borrow();
423        self.safe_slack_u(&*nlp.pd_u(), &*iv.s, nlp.d_u(), &*mult.v_u)
424            .0
425    }
426
427    /// Compute the four trial slacks with safe-slack flooring and, if any
428    /// component was corrected, the adjusted variable bounds that make the
429    /// trial slacks exactly representable. Port of the bound-adjustment
430    /// block in `IpoptAlgorithm::AcceptTrialPoint`
431    /// (`IpIpoptAlg.cpp:664-706`): `new_x_L = Px_L^T x - safe_slack_x_L`,
432    /// `new_x_U = Px_U^T x + safe_slack_x_U`, likewise for `s`/`d`.
433    /// Returns `None` when no slack needed correcting.
434    pub fn adjusted_trial_bounds(&self) -> Option<AdjustedBounds> {
435        let iv = self.trial_iv();
436        let mult = self.curr_iv();
437        let nlp = self.nlp.borrow();
438
439        let (s_x_l, n_x_l) = self.safe_slack_l(&*nlp.px_l(), &*iv.x, nlp.x_l(), &*mult.z_l);
440        let (s_x_u, n_x_u) = self.safe_slack_u(&*nlp.px_u(), &*iv.x, nlp.x_u(), &*mult.z_u);
441        let (s_s_l, n_s_l) = self.safe_slack_l(&*nlp.pd_l(), &*iv.s, nlp.d_l(), &*mult.v_l);
442        let (s_s_u, n_s_u) = self.safe_slack_u(&*nlp.pd_u(), &*iv.s, nlp.d_u(), &*mult.v_u);
443
444        let adjusted = n_x_l + n_x_u + n_s_l + n_s_u;
445        if adjusted == 0 {
446            return None;
447        }
448
449        // new_x_L = Px_L^T x - safe_slack_x_L
450        let mut new_x_l = nlp.x_l().make_new();
451        nlp.px_l()
452            .trans_mult_vector(1.0, &*iv.x, 0.0, &mut *new_x_l);
453        new_x_l.axpy(-1.0, &*s_x_l);
454        // new_x_U = Px_U^T x + safe_slack_x_U
455        let mut new_x_u = nlp.x_u().make_new();
456        nlp.px_u()
457            .trans_mult_vector(1.0, &*iv.x, 0.0, &mut *new_x_u);
458        new_x_u.axpy(1.0, &*s_x_u);
459        // new_d_L = Pd_L^T s - safe_slack_s_L
460        let mut new_d_l = nlp.d_l().make_new();
461        nlp.pd_l()
462            .trans_mult_vector(1.0, &*iv.s, 0.0, &mut *new_d_l);
463        new_d_l.axpy(-1.0, &*s_s_l);
464        // new_d_U = Pd_U^T s + safe_slack_s_U
465        let mut new_d_u = nlp.d_u().make_new();
466        nlp.pd_u()
467            .trans_mult_vector(1.0, &*iv.s, 0.0, &mut *new_d_u);
468        new_d_u.axpy(1.0, &*s_s_u);
469
470        Some(AdjustedBounds {
471            adjusted,
472            x_l: new_x_l,
473            x_u: new_x_u,
474            d_l: new_d_l,
475            d_u: new_d_u,
476        })
477    }
478
479    // --------------------------------------------------------------
480    // NLP function evaluations.
481    // --------------------------------------------------------------
482
483    pub fn curr_grad_f(&self) -> Rc<dyn Vector> {
484        let iv = self.curr_iv();
485        let mut nlp = self.nlp.borrow_mut();
486        let mut g = iv.x.make_new();
487        nlp.eval_grad_f(&*iv.x, &mut *g);
488        rc_from(g)
489    }
490
491    pub fn trial_grad_f(&self) -> Rc<dyn Vector> {
492        let iv = self.trial_iv();
493        let mut nlp = self.nlp.borrow_mut();
494        let mut g = iv.x.make_new();
495        nlp.eval_grad_f(&*iv.x, &mut *g);
496        rc_from(g)
497    }
498
499    pub fn curr_c(&self) -> Rc<dyn Vector> {
500        let iv = self.curr_iv();
501        let m = self.nlp.borrow().m_eq();
502        let mut nlp = self.nlp.borrow_mut();
503        let mut c = iv.y_c.make_new();
504        debug_assert_eq!(c.dim(), m);
505        nlp.eval_c(&*iv.x, &mut *c);
506        rc_from(c)
507    }
508
509    pub fn trial_c(&self) -> Rc<dyn Vector> {
510        let iv = self.trial_iv();
511        let mut nlp = self.nlp.borrow_mut();
512        let mut c = iv.y_c.make_new();
513        nlp.eval_c(&*iv.x, &mut *c);
514        rc_from(c)
515    }
516
517    pub fn curr_d(&self) -> Rc<dyn Vector> {
518        let iv = self.curr_iv();
519        let mut nlp = self.nlp.borrow_mut();
520        let mut d = iv.s.make_new();
521        nlp.eval_d(&*iv.x, &mut *d);
522        rc_from(d)
523    }
524
525    pub fn trial_d(&self) -> Rc<dyn Vector> {
526        let iv = self.trial_iv();
527        let mut nlp = self.nlp.borrow_mut();
528        let mut d = iv.s.make_new();
529        nlp.eval_d(&*iv.x, &mut *d);
530        rc_from(d)
531    }
532
533    pub fn curr_jac_c(&self) -> Rc<dyn Matrix> {
534        let iv = self.curr_iv();
535        self.nlp.borrow_mut().eval_jac_c(&*iv.x)
536    }
537
538    pub fn curr_jac_d(&self) -> Rc<dyn Matrix> {
539        let iv = self.curr_iv();
540        self.nlp.borrow_mut().eval_jac_d(&*iv.x)
541    }
542
543    pub fn curr_exact_hessian(&self) -> Rc<dyn SymMatrix> {
544        let iv = self.curr_iv();
545        self.nlp
546            .borrow_mut()
547            .eval_h(&*iv.x, 1.0, &*iv.y_c, &*iv.y_d)
548    }
549
550    /// `curr_d - s` — port of `IpIpoptCalculatedQuantities.cpp:1185-1206`.
551    pub fn curr_d_minus_s(&self) -> Rc<dyn Vector> {
552        let iv = self.curr_iv();
553        let d = self.curr_d();
554        let mut tmp = iv.s.make_new();
555        // tmp = 0*tmp + 1*d + (-1)*s
556        tmp.add_two_vectors(1.0, &*d, -1.0, &*iv.s, 0.0);
557        rc_from(tmp)
558    }
559
560    pub fn trial_d_minus_s(&self) -> Rc<dyn Vector> {
561        let iv = self.trial_iv();
562        let d = self.trial_d();
563        let mut tmp = iv.s.make_new();
564        tmp.add_two_vectors(1.0, &*d, -1.0, &*iv.s, 0.0);
565        rc_from(tmp)
566    }
567
568    /// `J_c^T y_c` — for a generic `vec` argument
569    /// (`IpIpoptCalculatedQuantities.cpp:1373-1404`).
570    pub fn curr_jac_c_t_times_vec(&self, vec: &dyn Vector) -> Rc<dyn Vector> {
571        let iv = self.curr_iv();
572        let jac_c = self.curr_jac_c();
573        let mut tmp = iv.x.make_new();
574        jac_c.trans_mult_vector(1.0, vec, 0.0, &mut *tmp);
575        rc_from(tmp)
576    }
577
578    /// `J_d^T y_d` for arbitrary `vec`.
579    pub fn curr_jac_d_t_times_vec(&self, vec: &dyn Vector) -> Rc<dyn Vector> {
580        let iv = self.curr_iv();
581        let jac_d = self.curr_jac_d();
582        let mut tmp = iv.x.make_new();
583        jac_d.trans_mult_vector(1.0, vec, 0.0, &mut *tmp);
584        rc_from(tmp)
585    }
586
587    pub fn curr_jac_c_t_times_curr_y_c(&self) -> Rc<dyn Vector> {
588        let iv = self.curr_iv();
589        self.curr_jac_c_t_times_vec(&*iv.y_c)
590    }
591
592    pub fn curr_jac_d_t_times_curr_y_d(&self) -> Rc<dyn Vector> {
593        let iv = self.curr_iv();
594        self.curr_jac_d_t_times_vec(&*iv.y_d)
595    }
596
597    /// `J_c v` — `IpIpoptCalculatedQuantities.cpp:1303-1321`.
598    pub fn curr_jac_c_times_vec(&self, vec: &dyn Vector) -> Rc<dyn Vector> {
599        let iv = self.curr_iv();
600        let jac_c = self.curr_jac_c();
601        let mut tmp = iv.y_c.make_new();
602        jac_c.mult_vector(1.0, vec, 0.0, &mut *tmp);
603        rc_from(tmp)
604    }
605
606    /// `J_d v` — `IpIpoptCalculatedQuantities.cpp:1323-1343`.
607    pub fn curr_jac_d_times_vec(&self, vec: &dyn Vector) -> Rc<dyn Vector> {
608        let iv = self.curr_iv();
609        let jac_d = self.curr_jac_d();
610        let mut tmp = iv.s.make_new();
611        jac_d.mult_vector(1.0, vec, 0.0, &mut *tmp);
612        rc_from(tmp)
613    }
614
615    // --------------------------------------------------------------
616    // Lagrangian gradients
617    // --------------------------------------------------------------
618
619    /// `∇_x L = ∇f(x) + J_c^T y_c + J_d^T y_d - P_L z_L + P_U z_U`
620    /// per `IpIpoptCalculatedQuantities.cpp:1993-2030`.
621    pub fn curr_grad_lag_x(&self) -> Rc<dyn Vector> {
622        let iv = self.curr_iv();
623        let grad_f = self.curr_grad_f();
624        let jc_t_y_c = self.curr_jac_c_t_times_curr_y_c();
625        let jd_t_y_d = self.curr_jac_d_t_times_curr_y_d();
626
627        let mut tmp = iv.x.make_new();
628        tmp.copy(&*grad_f);
629        tmp.add_two_vectors(1.0, &*jc_t_y_c, 1.0, &*jd_t_y_d, 1.0);
630
631        let nlp = self.nlp.borrow();
632        nlp.px_l().mult_vector(-1.0, &*iv.z_l, 1.0, &mut *tmp);
633        nlp.px_u().mult_vector(1.0, &*iv.z_u, 1.0, &mut *tmp);
634        rc_from(tmp)
635    }
636
637    /// `∇_s L = -y_d - P_L v_L + P_U v_U`
638    /// (`IpIpoptCalculatedQuantities.cpp:2069-2098`).
639    pub fn curr_grad_lag_s(&self) -> Rc<dyn Vector> {
640        let iv = self.curr_iv();
641        let mut tmp = iv.y_d.make_new();
642        let nlp = self.nlp.borrow();
643        // tmp = P_U v_U
644        nlp.pd_u().mult_vector(1.0, &*iv.v_u, 0.0, &mut *tmp);
645        // tmp = tmp - P_L v_L
646        nlp.pd_l().mult_vector(-1.0, &*iv.v_l, 1.0, &mut *tmp);
647        // tmp = tmp - y_d
648        tmp.axpy(-1.0, &*iv.y_d);
649        rc_from(tmp)
650    }
651
652    // --------------------------------------------------------------
653    // Complementarity (slack ⊙ multiplier)
654    // --------------------------------------------------------------
655
656    fn calc_compl(slack: &dyn Vector, mult: &dyn Vector) -> Rc<dyn Vector> {
657        let mut result = slack.make_new();
658        result.copy(slack);
659        result.element_wise_multiply(mult);
660        rc_from(result)
661    }
662
663    pub fn curr_compl_x_l(&self) -> Rc<dyn Vector> {
664        let slack = self.curr_slack_x_l();
665        let z_l = self.curr_iv().z_l;
666        Self::calc_compl(&*slack, &*z_l)
667    }
668
669    pub fn curr_compl_x_u(&self) -> Rc<dyn Vector> {
670        let slack = self.curr_slack_x_u();
671        let z_u = self.curr_iv().z_u;
672        Self::calc_compl(&*slack, &*z_u)
673    }
674
675    pub fn curr_compl_s_l(&self) -> Rc<dyn Vector> {
676        let slack = self.curr_slack_s_l();
677        let v_l = self.curr_iv().v_l;
678        Self::calc_compl(&*slack, &*v_l)
679    }
680
681    pub fn curr_compl_s_u(&self) -> Rc<dyn Vector> {
682        let slack = self.curr_slack_s_u();
683        let v_u = self.curr_iv().v_u;
684        Self::calc_compl(&*slack, &*v_u)
685    }
686
687    /// `s_L .* z_L - mu` — relaxed complementarity used in the KKT
688    /// RHS. `IpIpoptCalculatedQuantities.cpp:2406-2430`.
689    pub fn curr_relaxed_compl_x_l(&self) -> Rc<dyn Vector> {
690        let mu = self.data.borrow().curr_mu;
691        let mut r = self.curr_compl_x_l().make_new();
692        r.copy(&*self.curr_compl_x_l());
693        r.add_scalar(-mu);
694        rc_from(r)
695    }
696
697    pub fn curr_relaxed_compl_x_u(&self) -> Rc<dyn Vector> {
698        let mu = self.data.borrow().curr_mu;
699        let mut r = self.curr_compl_x_u().make_new();
700        r.copy(&*self.curr_compl_x_u());
701        r.add_scalar(-mu);
702        rc_from(r)
703    }
704
705    pub fn curr_relaxed_compl_s_l(&self) -> Rc<dyn Vector> {
706        let mu = self.data.borrow().curr_mu;
707        let mut r = self.curr_compl_s_l().make_new();
708        r.copy(&*self.curr_compl_s_l());
709        r.add_scalar(-mu);
710        rc_from(r)
711    }
712
713    pub fn curr_relaxed_compl_s_u(&self) -> Rc<dyn Vector> {
714        let mu = self.data.borrow().curr_mu;
715        let mut r = self.curr_compl_s_u().make_new();
716        r.copy(&*self.curr_compl_s_u());
717        r.add_scalar(-mu);
718        rc_from(r)
719    }
720
721    // --------------------------------------------------------------
722    // Σ_x / Σ_s (barrier-Hessian diagonals fed to the augmented system)
723    // `IpIpoptCalculatedQuantities.cpp:3501-3551`.
724    //
725    //   Σ_x = P_L · diag(z_L / s_L) · P_L^T + P_U · diag(z_U / s_U) · P_U^T
726    //   Σ_s = P_L · diag(v_L / s_L) · P_L^T + P_U · diag(v_U / s_U) · P_U^T
727    // --------------------------------------------------------------
728
729    pub fn curr_sigma_x(&self) -> Rc<dyn Vector> {
730        let iv = self.curr_iv();
731        {
732            let cache = self.curr_sigma_x_cache.borrow();
733            if let Some(v) = cache.get(
734                &[iv.x.as_tagged(), iv.z_l.as_tagged(), iv.z_u.as_tagged()],
735                &[],
736            ) {
737                return v;
738            }
739        }
740        let slack_l = self.curr_slack_x_l();
741        let slack_u = self.curr_slack_x_u();
742
743        let mut sigma = iv.x.make_new();
744        sigma.set(0.0);
745
746        let nlp = self.nlp.borrow();
747        nlp.px_l()
748            .add_m_sinv_z(1.0, &*slack_l, &*iv.z_l, &mut *sigma);
749        nlp.px_u()
750            .add_m_sinv_z(1.0, &*slack_u, &*iv.z_u, &mut *sigma);
751        let v = rc_from(sigma);
752        self.curr_sigma_x_cache.borrow_mut().add(
753            v.clone(),
754            &[iv.x.as_tagged(), iv.z_l.as_tagged(), iv.z_u.as_tagged()],
755            &[],
756        );
757        v
758    }
759
760    pub fn curr_sigma_s(&self) -> Rc<dyn Vector> {
761        let iv = self.curr_iv();
762        {
763            let cache = self.curr_sigma_s_cache.borrow();
764            if let Some(v) = cache.get(
765                &[iv.s.as_tagged(), iv.v_l.as_tagged(), iv.v_u.as_tagged()],
766                &[],
767            ) {
768                return v;
769            }
770        }
771        let slack_l = self.curr_slack_s_l();
772        let slack_u = self.curr_slack_s_u();
773
774        let mut sigma = iv.s.make_new();
775        sigma.set(0.0);
776
777        let nlp = self.nlp.borrow();
778        nlp.pd_l()
779            .add_m_sinv_z(1.0, &*slack_l, &*iv.v_l, &mut *sigma);
780        nlp.pd_u()
781            .add_m_sinv_z(1.0, &*slack_u, &*iv.v_u, &mut *sigma);
782        let v = rc_from(sigma);
783        self.curr_sigma_s_cache.borrow_mut().add(
784            v.clone(),
785            &[iv.s.as_tagged(), iv.v_l.as_tagged(), iv.v_u.as_tagged()],
786            &[],
787        );
788        v
789    }
790
791    // --------------------------------------------------------------
792    // Objective f and barrier objective phi
793    // (`IpIpoptCalculatedQuantities.cpp:CalcBarrierTerm`,
794    //  lines 870-1042 in upstream).
795    //
796    //   phi(x,s) = f(x)
797    //              − μ · [Σ ln(s_x_L) + Σ ln(s_x_U)
798    //                   + Σ ln(s_s_L) + Σ ln(s_s_U)]
799    //              + κ_d · μ · [s_x_L · 1_singly_x_L
800    //                          + s_x_U · 1_singly_x_U
801    //                          + s_s_L · 1_singly_s_L
802    //                          + s_s_U · 1_singly_s_U]
803    //
804    // The damping piece vanishes when `kappa_d == 0` (default).
805    // --------------------------------------------------------------
806
807    pub fn curr_f(&self) -> Number {
808        let iv = self.curr_iv();
809        let mut nlp = self.nlp.borrow_mut();
810        nlp.eval_f(&*iv.x)
811    }
812
813    /// Unscaled objective at the current iterate. `curr_f` returns the
814    /// internally scaled value (`f · df_`); upstream IPOPT prints the
815    /// unscaled objective in its iteration log, so this divides the
816    /// scaling back out. Mirrors `IpoptCalculatedQuantities::
817    /// unscaled_curr_f`. A zero factor (scaling never determined) is
818    /// treated as the identity.
819    pub fn unscaled_curr_f(&self) -> Number {
820        let scaled = self.curr_f();
821        let factor = self.nlp.borrow().obj_scaling_factor();
822        if factor == 0.0 {
823            scaled
824        } else {
825            scaled / factor
826        }
827    }
828
829    /// Max-norm dual infeasibility in the **unscaled** (user-original)
830    /// space. [`Self::curr_dual_infeasibility_max`] is evaluated in the
831    /// internally-scaled NLP space (objective × `df`, constraints × `dc`);
832    /// because POUNCE applies no variable scaling, every term of the
833    /// Lagrangian gradient `∇f + Jᵀy − z` carries the same objective
834    /// factor `df`, so the unscaling is a single divide by
835    /// `df = obj_scaling_factor`. A zero or unit factor returns the scaled
836    /// value unchanged — the common no-scaling path stays division-free.
837    pub fn curr_unscaled_dual_infeasibility_max(&self) -> Number {
838        let df = self.nlp.borrow().obj_scaling_factor();
839        let scaled = self.curr_dual_infeasibility_max();
840        // `df` is SIGNED — `obj_scaling_factor = -1` is the documented way to
841        // pose a maximization — while `scaled` is a max-norm. Dividing by the
842        // signed factor returned a NEGATIVE "max-norm", which then sailed under
843        // every `<= tol` comparison: it disabled the gh #200 veto on
844        // maximization, and defeated the unscaled residual gate added for
845        // pounce#173 there as well. Magnitude is what the unscaling means.
846        let df = df.abs();
847        if df == 0.0 || df == 1.0 {
848            scaled
849        } else {
850            scaled / df
851        }
852    }
853
854    /// Max-norm complementarity in the **unscaled** space. Each bound block
855    /// `s · z` scales uniformly by `df`: the slack's `dc`/`dd` factor and
856    /// the multiplier's `df/dc` (`df/dd`) factor cancel in the product,
857    /// leaving `df`. So this is the scaled max-norm divided by `df`. See
858    /// [`Self::curr_unscaled_dual_infeasibility_max`].
859    pub fn curr_unscaled_complementarity_max(&self) -> Number {
860        let df = self.nlp.borrow().obj_scaling_factor();
861        let scaled = self.curr_complementarity_max();
862        // `df` is SIGNED — `obj_scaling_factor = -1` is the documented way to
863        // pose a maximization — while `scaled` is a max-norm. Dividing by the
864        // signed factor returned a NEGATIVE "max-norm", which then sailed under
865        // every `<= tol` comparison: it disabled the gh #200 veto on
866        // maximization, and defeated the unscaled residual gate added for
867        // pounce#173 there as well. Magnitude is what the unscaling means.
868        let df = df.abs();
869        if df == 0.0 || df == 1.0 {
870            scaled
871        } else {
872            scaled / df
873        }
874    }
875
876    /// Max-norm primal infeasibility in the **unscaled** space. Unlike the
877    /// dual/complementarity terms the constraint scaling is per-row
878    /// (`c_scaled = dc ⊙ c_user`, `(d−s)_scaled = dd ⊙ (d−s)_user`), so each
879    /// block is unscaled element-by-element before the max-norm. When no row
880    /// scaling is active (`c_scale_vec`/`d_scale_vec` both `None` — the
881    /// common case) this is exactly [`Self::curr_primal_infeasibility_max`].
882    pub fn curr_unscaled_primal_infeasibility_max(&self) -> Number {
883        let (dc, dd) = {
884            let nlp = self.nlp.borrow();
885            (nlp.c_scale_vec(), nlp.d_scale_vec())
886        };
887        if dc.is_none() && dd.is_none() {
888            return self.curr_primal_infeasibility_max();
889        }
890        let c_max = unscaled_block_amax(&*self.curr_c(), dc.as_deref());
891        let dms_max = unscaled_block_amax(&*self.curr_d_minus_s(), dd.as_deref());
892        c_max.max(dms_max)
893    }
894
895    /// Max-norm constraint violation of the **original** NLP, in user units:
896    /// `|c_i|` over the equality block and `max(0, d_l_i − d_i, d_i − d_u_i)`
897    /// over the inequality block. This is what upstream's
898    /// `inf_pr_output = original` — its *default* — prints in the `inf_pr`
899    /// column, and what its end-of-run "Constraint violation" line reports.
900    /// Mirrors `IpIpoptCalculatedQuantities.cpp:unscaled_curr_nlp_constraint_violation`.
901    ///
902    /// Deliberately **not** [`Self::curr_primal_infeasibility_max`], which is
903    /// `max(‖c‖_∞, ‖d − s‖_∞)` — the violation of the *internal* slack
904    /// reformulation. The two diverge whenever the slack drifts from `d(x)`:
905    /// `s` is confined to `[d_l, d_u]`, so `d = s + (d − s)` with `d − s > 0`
906    /// clears a lower bound however large that gap grows. On a model that is
907    /// all inequalities the gap *is* the whole number — on Mittelmann's
908    /// `robot_a` POUNCE reported 2.79e4 at an iterate where Ipopt reported
909    /// `0.00e+00` and every original row was in fact satisfied (pounce#476).
910    ///
911    /// Display only. The filter's `theta`, the barrier-parameter strategies
912    /// and the convergence test all stay on the internal measure — that split
913    /// is upstream's, not a shortcut.
914    ///
915    /// Judged against the **declared** bounds where the NLP tracks them, so
916    /// the `bound_relax_factor` widening cannot forgive a violation the user
917    /// would still see (same reasoning as
918    /// [`Self::relative_d_infeasibility_max`]).
919    pub fn curr_unscaled_nlp_constraint_violation_max(&self) -> Number {
920        let (dc, dd) = {
921            let nlp = self.nlp.borrow();
922            (nlp.c_scale_vec(), nlp.d_scale_vec())
923        };
924        let c_max = unscaled_block_amax(&*self.curr_c(), dc.as_deref());
925
926        let d = self.curr_d();
927        if d.dim() == 0 {
928            return c_max;
929        }
930        let (lo, hi, mask_l, mask_u) = {
931            let nlp = self.nlp.borrow();
932            let (mut cl, mut cu) = (nlp.d_l().make_new(), nlp.d_u().make_new());
933            match nlp.declared_d_bounds() {
934                Some((dl, du)) => {
935                    let (Some(cld), Some(cud)) = (
936                        cl.as_any_mut().downcast_mut::<DenseVector>(),
937                        cu.as_any_mut().downcast_mut::<DenseVector>(),
938                    ) else {
939                        return c_max;
940                    };
941                    cld.set_values(&dl);
942                    cud.set_values(&du);
943                }
944                None => {
945                    cl.copy(nlp.d_l());
946                    cu.copy(nlp.d_u());
947                }
948            }
949            let mut lo = d.make_new();
950            lo.set(0.0);
951            nlp.pd_l().mult_vector(1.0, &*cl, 0.0, &mut *lo);
952            let mut hi = d.make_new();
953            hi.set(0.0);
954            nlp.pd_u().mult_vector(1.0, &*cu, 0.0, &mut *hi);
955            // A projected 0 is ambiguous — "no bound on this side" and "a
956            // declared zero bound" both read 0 — so project an all-ones
957            // vector through the same expansion to get presence masks.
958            let mut ones_l = nlp.d_l().make_new();
959            ones_l.set(1.0);
960            let mut mask_l = d.make_new();
961            mask_l.set(0.0);
962            nlp.pd_l().mult_vector(1.0, &*ones_l, 0.0, &mut *mask_l);
963            let mut ones_u = nlp.d_u().make_new();
964            ones_u.set(1.0);
965            let mut mask_u = d.make_new();
966            mask_u.set(0.0);
967            nlp.pd_u().mult_vector(1.0, &*ones_u, 0.0, &mut *mask_u);
968            (lo, hi, mask_l, mask_u)
969        };
970        let (Some(dv), Some(lo), Some(hi), Some(ml), Some(mu)) = (
971            d.as_any().downcast_ref::<DenseVector>(),
972            lo.as_any().downcast_ref::<DenseVector>(),
973            hi.as_any().downcast_ref::<DenseVector>(),
974            mask_l.as_any().downcast_ref::<DenseVector>(),
975            mask_u.as_any().downcast_ref::<DenseVector>(),
976        ) else {
977            return c_max;
978        };
979        if !(dv.is_initialized()
980            && lo.is_initialized()
981            && hi.is_initialized()
982            && ml.is_initialized()
983            && mu.is_initialized())
984        {
985            return c_max;
986        }
987        let (dv, lov, hiv, mlv, muv) = (
988            dv.expanded_values(),
989            lo.expanded_values(),
990            hi.expanded_values(),
991            ml.expanded_values(),
992            mu.expanded_values(),
993        );
994        let mut worst = c_max;
995        for i in 0..dv.len() {
996            let mut viol = 0.0_f64;
997            if mlv[i] > 0.5 {
998                viol = viol.max(lov[i] - dv[i]);
999            }
1000            if muv[i] > 0.5 {
1001                viol = viol.max(dv[i] - hiv[i]);
1002            }
1003            if viol <= 0.0 || !viol.is_finite() {
1004                continue;
1005            }
1006            // Row scaling is per-row (`d_scaled = dd ⊙ d_user`), so the
1007            // violation unscales by the same factor. A zero factor is treated
1008            // as the identity, matching `unscaled_block_amax`.
1009            let viol = match dd.as_deref() {
1010                Some(s) if s[i] != 0.0 => viol / s[i],
1011                _ => viol,
1012            };
1013            worst = worst.max(viol);
1014        }
1015        worst
1016    }
1017
1018    /// Largest primal infeasibility of a constraint row **relative to that
1019    /// row's own magnitude** — `|c_i| / |b_i|` over the equality block and
1020    /// `dist(d_i, [d_l_i, d_u_i]) / max(|d_l_i|, |d_u_i|)` over the
1021    /// inequality block, whichever is worse.
1022    ///
1023    /// See [`Self::relative_d_infeasibility_max`] and
1024    /// [`Self::relative_c_infeasibility_max`] for the two blocks; both use
1025    /// the row's **declared** magnitude, never a live or relaxed stand-in,
1026    /// and both abstain (contribute nothing) on a row that has no declared
1027    /// magnitude to be relative to.
1028    pub fn curr_relative_primal_infeasibility_max(&self) -> Number {
1029        self.relative_d_infeasibility_max()
1030            .max(self.relative_c_infeasibility_max())
1031    }
1032
1033    /// The equality-block half of
1034    /// [`Self::curr_relative_primal_infeasibility_max`]: `max_i |c_i| / |b_i|`,
1035    /// where `b_i` is the row's declared right-hand side
1036    /// ([`IpoptNlp::declared_c_rhs`]).
1037    ///
1038    /// POUNCE folds `g_i(x) == b_i` into `c_i(x) = 0`, so `|c_i|` *is* the
1039    /// violation and by itself carries no magnitude to be judged against —
1040    /// which is why every runtime feasibility decision on an equality row was
1041    /// an absolute one, and why down-scaling such a row shrank `|c_i|` under
1042    /// the absolute tolerance and flipped a true infeasibility verdict to
1043    /// `Solve_Succeeded` (gh #390, residual of #387). Dividing by the pre-fold
1044    /// RHS restores it: `s·g(x) == s·b` has residual `s·(g(x) − b)` and RHS
1045    /// `s·b`, so the ratio is the same at every `s` — the point.
1046    ///
1047    /// Both numerator and denominator are taken in the internally-scaled
1048    /// space, so the solver's own row scaling `dc_i` cancels too.
1049    ///
1050    /// A **homogeneous** row (`b_i = 0`) contributes nothing. It has no
1051    /// declared magnitude to be relative to, and needs none: `s·g(x) == 0` is
1052    /// the same row at every `s`, so the absolute test is already invariant
1053    /// there. Dividing by zero — or by a fabricated floor — would turn
1054    /// float-noise residuals into 100% "violations" on the single most common
1055    /// equality row there is. Non-finite entries likewise contribute nothing:
1056    /// an unjudgeable row must not fabricate a relative verdict. When the NLP
1057    /// does not track the RHS at all (`declared_c_rhs` is `None` — e.g. the
1058    /// restoration NLP, whose `c` block is not the user's rows), the whole
1059    /// block abstains.
1060    ///
1061    /// "Homogeneous" is judged **numerically**, not by `b_i == 0` exactly: a
1062    /// row abstains once `|b_i|` sinks under its own
1063    /// [noise floor](`Self::row_noise_floor`). An exact-zero test is the
1064    /// right idea measured with the wrong instrument — a converter that emits
1065    /// `2^-53` where the model says `0` (Maros-Mészáros `QSC*`/`QSCFXM*`, and
1066    /// every one of the 15 problems in gh #446, carry equality rows with an
1067    /// RHS at `1e-17`–`1e-16`) declares a magnitude that is pure rounding
1068    /// residue — a target no iterate could be positioned finely enough to hit.
1069    /// `|c_i|` cannot be driven below the same floor either, so the
1070    /// ratio was noise over noise: QSCSD1 read 81× violated at a converged KKT
1071    /// point whose absolute violation was `9.2e-15`, which vetoed its success
1072    /// certificate and then armed the rapid-infeasibility pre-filter — a
1073    /// feasible convex QP reported `Converged to a point of local
1074    /// infeasibility`. Comparing against the row's own noise floor keeps the
1075    /// scale invariance that is the whole point of the measure (both sides
1076    /// carry `dc_i`), which an absolute cutoff on `|b_i|` would have thrown
1077    /// away.
1078    pub fn relative_c_infeasibility_max(&self) -> Number {
1079        let c = self.curr_c();
1080        if c.dim() == 0 {
1081            return 0.0;
1082        }
1083        let Some(rhs) = self.nlp.borrow().declared_c_rhs() else {
1084            return 0.0;
1085        };
1086        let noise = self.row_noise_floor(&*self.curr_jac_c(), &*c);
1087        let Some(c) = c.as_any().downcast_ref::<DenseVector>() else {
1088            return 0.0;
1089        };
1090        if !c.is_initialized() {
1091            return 0.0;
1092        }
1093        let cv = c.expanded_values();
1094        if cv.len() != rhs.len() {
1095            return 0.0;
1096        }
1097        let mut worst = 0.0_f64;
1098        for (i, (&ci, &bi)) in cv.iter().zip(rhs.iter()).enumerate() {
1099            let mag = bi.abs();
1100            // `0.0` when no floor could be computed, which reproduces the
1101            // former `mag > 0.0` gate exactly.
1102            let floor = noise.as_ref().map_or(0.0, |n| n[i]);
1103            if mag > floor && mag.is_finite() && ci.is_finite() {
1104                worst = worst.max(ci.abs() / mag);
1105            }
1106        }
1107        worst
1108    }
1109
1110    /// Per-row noise floor of a constraint block: the finest residual the
1111    /// solver could drive that row to, in the same internally scaled units as
1112    /// the block's residual and declared bounds. `jac` is the block's Jacobian
1113    /// and `template` any vector in the block's space.
1114    ///
1115    /// The quantity being modelled is **how finely the solver can place `x`**,
1116    /// not how accurately a row evaluates. A Newton step comes from a linear
1117    /// solve whose backward error is norm-wise, so every component of `x` is
1118    /// positioned to roughly `eps · ‖x‖_∞` in absolute terms — a variable at
1119    /// `1e-8` inside a vector of norm `2.7` is still only resolved to
1120    /// `~6e-16`, not to `~2e-24`. A row responds to `x` at rate
1121    /// `max_j |∂g_i/∂x_j|`, so the finest residual it can be driven to is
1122    /// `max_j |∂g_i/∂x_j| · eps · ‖x‖_∞`, with [`ROW_NOISE_KAPPA`] covering
1123    /// accumulation across the row's nonzeros and conditioning slop. A
1124    /// declared magnitude under that is a target the solver could not hit even
1125    /// in exact arithmetic on the model as written.
1126    ///
1127    /// `‖x‖_∞` is global, and that is the point rather than a compromise: `x`
1128    /// is one vector solved for jointly, so a large variable anywhere really
1129    /// does coarsen the resolution of every other. The per-row alternative,
1130    /// the exact term sum `Σ_j |a_ij x_j|` via `|J|·|x|`, was implemented and
1131    /// measured, and it is strictly worse — it models the row's *evaluation*
1132    /// error, which is not what limits the residual. It regressed QETAMACR,
1133    /// QSCORPIO and QPILOTNO of gh #446's 15: QSCORPIO's row 93 has all its
1134    /// variables parked near a zero bound at `~1e-8`, giving a term sum of
1135    /// `6e-8` and a floor of `8.5e-22`, so its `−5.6e-17` of rounding residue
1136    /// read as real data again — while the iterate it is judging is only
1137    /// resolved to `~6e-16`. Do not "improve" this to the term sum without
1138    /// re-running those three.
1139    ///
1140    /// Scale-invariant by construction. Under a row scaling `dc_i` the
1141    /// Jacobian row carries `dc_i` exactly as the residual and the declared
1142    /// bounds do (the scaling is applied in `eval_jac_c`/`eval_jac_d`), so the
1143    /// floor moves with the quantities it gates and the abstention verdict is
1144    /// the same at every `s`.
1145    ///
1146    /// A row with an **empty** Jacobian gets an infinite floor, so it always
1147    /// abstains. Every variable it mentions has been fixed and substituted
1148    /// out, which leaves a constant row `0 = b` that no iterate can move: it
1149    /// is a statement about the *model*, and judging the *iterate* by it is a
1150    /// category error. That is presolve's question, answered up front by
1151    /// `presolve_infeasibility_proof` with a certificate, not a residual. The
1152    /// runtime measure abstaining costs no detection that matters — the
1153    /// absolute `constr_viol_tol` arm still sees the row, and an empty row
1154    /// violated by anything a caller would recognise as infeasible is orders
1155    /// above it. QPILOTNO is why: five variables fixed at `0` reduce row 150
1156    /// to `0 = −2.22e-16`, its own rounding residue, and a measure that
1157    /// insists the iterate is 100% in violation of it will never let any
1158    /// iterate succeed (gh #446).
1159    ///
1160    /// `None` — meaning "no floor", i.e. only an exactly-zero magnitude
1161    /// abstains — when the reference cannot be formed: `x = 0` (every term is
1162    /// exactly zero, so the row carries no rounding error to speak of), a
1163    /// non-finite iterate, or a vector type that is not dense.
1164    fn row_noise_floor(&self, jac: &dyn Matrix, template: &dyn Vector) -> Option<Vec<Number>> {
1165        let x_amax = self.curr_iv().x.amax();
1166        if x_amax <= 0.0 || !x_amax.is_finite() {
1167            return None;
1168        }
1169        let mut rows = template.make_new();
1170        jac.compute_row_amax(&mut *rows, true);
1171        let rows = rows.as_any().downcast_ref::<DenseVector>()?;
1172        if !rows.is_initialized() {
1173            return None;
1174        }
1175        Some(
1176            rows.expanded_values()
1177                .iter()
1178                .map(|&a| {
1179                    if a > 0.0 {
1180                        ROW_NOISE_KAPPA * Number::EPSILON * a * x_amax
1181                    } else {
1182                        Number::INFINITY
1183                    }
1184                })
1185                .collect(),
1186        )
1187    }
1188
1189    /// [`Self::curr_primal_infeasibility_max`] — `max(‖c‖_∞, ‖d − s‖_∞)` —
1190    /// counting each row only where its residual rises above the finest value
1191    /// that residual can take in floating point (gh #528).
1192    ///
1193    /// Both residuals are *differences of quantities the row's own size*:
1194    /// `c_i = g_i(x) − b_i` and `d_i − s_i` with `s_i` confined to `d_i`'s
1195    /// bounds. A difference of doubles of magnitude `m` is quantised in units
1196    /// of `eps · m`, so no iterate can place either residual strictly between
1197    /// `0` and `eps · m_i` — it lands on an exact `0` or on a multiple of the
1198    /// quantum, and which of the two is arithmetic luck. Once `eps · m_i`
1199    /// exceeds `tol` that luck decides whether a fully converged solve gets a
1200    /// certificate: on gh #528's LPs (`|b| ~ 1e8`, so one ulp is `1.5e-8`
1201    /// against the default `tol = 1e-8`) the KKT error was pinned one ulp
1202    /// above the tolerance at the exact optimum, the solve kept iterating at a
1203    /// point it could not improve, and it exited
1204    /// `Search_Direction_Becomes_Too_Small` with the right answer in hand.
1205    ///
1206    /// The floor per row is the larger of two irreducible effects, both
1207    /// carrying [`ROW_NOISE_KAPPA`] for the same reason
1208    /// [`Self::row_noise_floor`] does (accumulation over the row's nonzeros
1209    /// and the linear solve's conditioning):
1210    ///
1211    /// * **Placing `x`** — [`Self::row_noise_floor`], `eps · ‖x‖_∞` passed
1212    ///   through the row at `max_j |∂g_i/∂x_j|`. A row whose Jacobian is
1213    ///   empty gets `INFINITY` there, meaning "abstain", which is the safe
1214    ///   direction for the *relative* measures that floor was written for and
1215    ///   the unsafe one here — silencing a constant row `0 = b` would forgive
1216    ///   a genuine infeasibility outright. Non-finite floors are therefore
1217    ///   read as `0`: such a row is judged on its residual alone.
1218    /// * **Forming the residual** — `eps · m_i`, with `m_i` the magnitude of
1219    ///   the quantities subtracted: the declared right-hand side `|b_i|` on
1220    ///   the equality block (the value `c_i` was formed against), and
1221    ///   `max(|d_i|, |s_i|)` on the inequality block. A block with no declared
1222    ///   magnitude to hand (the restoration NLP's `c`, whose rows are not the
1223    ///   user's) contributes nothing here and is left to the placement floor.
1224    ///
1225    /// A row's residual is counted in full or not at all, matching how
1226    /// [`Self::relative_c_infeasibility_max`] and
1227    /// [`Self::relative_d_infeasibility_max`] use their floors: the question
1228    /// is whether the row says anything, not how much of it to subtract.
1229    pub fn curr_primal_infeasibility_above_noise(&self, kappa: Number) -> Number {
1230        let c = self.curr_c();
1231        let dms = self.curr_d_minus_s();
1232
1233        let c_above = if c.dim() == 0 {
1234            0.0
1235        } else {
1236            let mag = self
1237                .nlp
1238                .borrow()
1239                .declared_c_rhs()
1240                .map(|rhs| rhs.iter().map(|b| b.abs()).collect::<Vec<_>>());
1241            let floor = self.primal_residual_noise_floor(
1242                &*self.curr_jac_c(),
1243                &*c,
1244                mag.as_deref(),
1245                c.dim() as usize,
1246                kappa,
1247            );
1248            amax_above_floor(&*c, &floor)
1249        };
1250
1251        let d_above = if dms.dim() == 0 {
1252            0.0
1253        } else {
1254            let d = self.curr_d();
1255            let s = self.curr_iv().s;
1256            let mag = match (
1257                d.as_any().downcast_ref::<DenseVector>(),
1258                s.as_any().downcast_ref::<DenseVector>(),
1259            ) {
1260                (Some(d), Some(s)) if d.is_initialized() && s.is_initialized() => {
1261                    let (dv, sv) = (d.expanded_values(), s.expanded_values());
1262                    (dv.len() == sv.len()).then(|| {
1263                        dv.iter()
1264                            .zip(&sv)
1265                            .map(|(a, b)| a.abs().max(b.abs()))
1266                            .collect::<Vec<_>>()
1267                    })
1268                }
1269                _ => None,
1270            };
1271            let floor = self.primal_residual_noise_floor(
1272                &*self.curr_jac_d(),
1273                &*dms,
1274                mag.as_deref(),
1275                dms.dim() as usize,
1276                kappa,
1277            );
1278            amax_above_floor(&*dms, &floor)
1279        };
1280
1281        c_above.max(d_above)
1282    }
1283
1284    /// Per-row floor for [`Self::curr_primal_infeasibility_above_noise`]:
1285    /// `max(placement floor, ROW_NOISE_KAPPA · eps · magnitude_i)`, with a
1286    /// finite value on every row (`0` where nothing can be said, so that row
1287    /// is judged on its residual alone). See that method for the derivation.
1288    fn primal_residual_noise_floor(
1289        &self,
1290        jac: &dyn Matrix,
1291        residual: &dyn Vector,
1292        magnitude: Option<&[Number]>,
1293        dim: usize,
1294        kappa: Number,
1295    ) -> Vec<Number> {
1296        // `row_noise_floor` bakes in `ROW_NOISE_KAPPA`; rescale it so both
1297        // contributions carry the caller's `kappa` and nothing else changes
1298        // for the relative measures that share that helper.
1299        let rescale = kappa / ROW_NOISE_KAPPA;
1300        let placement = self.row_noise_floor(jac, residual);
1301        let finite_or_zero = |v: Number| if v.is_finite() && v > 0.0 { v } else { 0.0 };
1302        (0..dim)
1303            .map(|i| {
1304                let from_placement = placement
1305                    .as_ref()
1306                    .and_then(|p| p.get(i))
1307                    .copied()
1308                    .unwrap_or(0.0);
1309                let from_placement = from_placement * rescale;
1310                let from_formation = magnitude
1311                    .and_then(|m| m.get(i))
1312                    .map_or(0.0, |&m| kappa * Number::EPSILON * m);
1313                finite_or_zero(from_placement).max(finite_or_zero(from_formation))
1314            })
1315            .collect()
1316    }
1317
1318    /// The inequality-block half of
1319    /// [`Self::curr_relative_primal_infeasibility_max`]:
1320    /// `max_i |d_i − s_i| / max(|d_i|, |d_l_i|, |d_u_i|)`.
1321    ///
1322    /// This is the scale-free companion to
1323    /// [`Self::curr_unscaled_primal_infeasibility_max`]. An absolute violation
1324    /// measure cannot tell "satisfied" from "violated by 10% of everything the
1325    /// row is" once the row's numbers are small — `x >= 0.7` written as
1326    /// `1e-12·x >= 0.7e-12` has an absolute violation of `1e-13` at `x = 0.6`,
1327    /// under every absolute tolerance, while the row is violated by a seventh
1328    /// of its own right-hand side. The ratio is `0.14` at every writing of the
1329    /// row.
1330    ///
1331    /// Computed entirely in the internally-scaled space: numerator and
1332    /// denominator both carry the row scaling `dd_i`, so it cancels and the
1333    /// ratio is invariant under it — which is the point.
1334    ///
1335    /// The magnitude comes from the row's **declared bounds only** — the
1336    /// current value `d_i` is deliberately excluded. On an *active* row the
1337    /// value converges to the bound, so for a zero-bound row (`g(x) >= 0`,
1338    /// ubiquitous) both the violation and `|d_i|` go to zero together and
1339    /// their ratio hovers near 1 at a perfectly converged point — including
1340    /// `|d_i|` made HS13's genuine solution read as 100% violated and vetoed
1341    /// its certificate. A zero bound also needs no relative treatment in the
1342    /// first place: `s·g >= 0` is the same row at every `s`, so the absolute
1343    /// test is already invariant there. Rows whose bounds are all zero or
1344    /// non-finite therefore contribute nothing (the relative measure
1345    /// abstains) — "zero" measured against the row's own
1346    /// [noise floor](`Self::row_noise_floor`) rather than exactly, for the
1347    /// reason spelled out on [`Self::relative_c_infeasibility_max`]: a
1348    /// converter that writes `2^-53` where the model says `0` otherwise hands
1349    /// a bound made of rounding residue to a measure that then reads the row
1350    /// as 100% violated. QPILOTNO carries 43 such inequality bounds at
1351    /// `1e-17`–`1e-15`, and one of them — a row sitting at exactly `d(x) = 0`
1352    /// against a declared bound of `1.1e-16` — pinned `rel_viol` at `1.0` for
1353    /// the whole run and drove the gh #446 local-infeasibility verdict from
1354    /// this block. Equality rows are judged by
1355    /// [`Self::relative_c_infeasibility_max`], which plumbs the pre-fold RHS
1356    /// back to supply the magnitude the fold into `c(x) = 0` erased.
1357    ///
1358    /// The violation judged is the **distance of `d(x)` outside the declared
1359    /// box** — NOT the lifted residual `|d − s|` the absolute measure uses.
1360    /// `|d − s|` only bounds the true violation from above: mid-solve the
1361    /// slack lags `d` while `d` is comfortably inside its bounds, so a
1362    /// slack-lag of 1% of a small row's magnitude read as "violated" at
1363    /// points that are genuinely feasible. That armed the rapid-infeasibility
1364    /// pre-filter at degenerate QP endgames, where the no-descent
1365    /// confirmation is vacuous (the violation is already ~0, so no materially
1366    /// less-violating point exists) — and 18 feasible CUTEr QPs were reported
1367    /// locally infeasible. Measured, not hypothetical.
1368    pub fn relative_d_infeasibility_max(&self) -> Number {
1369        let dms = self.curr_d_minus_s();
1370        if dms.dim() == 0 {
1371            return 0.0;
1372        }
1373        let d = self.curr_d();
1374        let (lo, hi, mask_l, mask_u) = {
1375            let nlp = self.nlp.borrow();
1376            // The *declared* bounds where the NLP tracks them: the live
1377            // `d_l`/`d_u` carry the `bound_relax_factor` widening, under which
1378            // a declared-zero bound reads as `~1e-8` — a fabricated magnitude
1379            // for a row that has none (that misread both vetoed HS13's genuine
1380            // solution and manufactured "relative" verdicts out of thin air).
1381            let (mut cl, mut cu) = (nlp.d_l().make_new(), nlp.d_u().make_new());
1382            match nlp.declared_d_bounds() {
1383                Some((dl, du)) => {
1384                    let (Some(cld), Some(cud)) = (
1385                        cl.as_any_mut().downcast_mut::<DenseVector>(),
1386                        cu.as_any_mut().downcast_mut::<DenseVector>(),
1387                    ) else {
1388                        return 0.0;
1389                    };
1390                    cld.set_values(&dl);
1391                    cud.set_values(&du);
1392                }
1393                None => {
1394                    cl.copy(nlp.d_l());
1395                    cu.copy(nlp.d_u());
1396                }
1397            }
1398            let mut lo = dms.make_new();
1399            lo.set(0.0);
1400            nlp.pd_l().mult_vector(1.0, &*cl, 0.0, &mut *lo);
1401            let mut hi = dms.make_new();
1402            hi.set(0.0);
1403            nlp.pd_u().mult_vector(1.0, &*cu, 0.0, &mut *hi);
1404            // A projected 0 is ambiguous — "no bound on this side" and "a
1405            // declared zero bound" both read 0 — so project an all-ones
1406            // vector through the same expansion to get presence masks.
1407            let mut ones_l = nlp.d_l().make_new();
1408            ones_l.set(1.0);
1409            let mut mask_l = dms.make_new();
1410            mask_l.set(0.0);
1411            nlp.pd_l().mult_vector(1.0, &*ones_l, 0.0, &mut *mask_l);
1412            let mut ones_u = nlp.d_u().make_new();
1413            ones_u.set(1.0);
1414            let mut mask_u = dms.make_new();
1415            mask_u.set(0.0);
1416            nlp.pd_u().mult_vector(1.0, &*ones_u, 0.0, &mut *mask_u);
1417            (lo, hi, mask_l, mask_u)
1418        };
1419        let noise = self.row_noise_floor(&*self.curr_jac_d(), &*dms);
1420        let (Some(d), Some(lo), Some(hi), Some(mask_l), Some(mask_u)) = (
1421            d.as_any().downcast_ref::<DenseVector>(),
1422            lo.as_any().downcast_ref::<DenseVector>(),
1423            hi.as_any().downcast_ref::<DenseVector>(),
1424            mask_l.as_any().downcast_ref::<DenseVector>(),
1425            mask_u.as_any().downcast_ref::<DenseVector>(),
1426        ) else {
1427            return 0.0;
1428        };
1429        if !(d.is_initialized()
1430            && lo.is_initialized()
1431            && hi.is_initialized()
1432            && mask_l.is_initialized()
1433            && mask_u.is_initialized())
1434        {
1435            return 0.0;
1436        }
1437        let dv = d.expanded_values();
1438        let lov = lo.expanded_values();
1439        let hiv = hi.expanded_values();
1440        let mlv = mask_l.expanded_values();
1441        let muv = mask_u.expanded_values();
1442        let mut worst = 0.0_f64;
1443        for i in 0..dv.len() {
1444            let (has_l, has_u) = (mlv[i] > 0.5, muv[i] > 0.5);
1445            let mut viol = 0.0_f64;
1446            let mut mag = 0.0_f64;
1447            if has_l {
1448                viol = viol.max(lov[i] - dv[i]);
1449                mag = mag.max(lov[i].abs());
1450            }
1451            if has_u {
1452                viol = viol.max(dv[i] - hiv[i]);
1453                mag = mag.max(hiv[i].abs());
1454            }
1455            // `0.0` when no floor could be computed, which reproduces the
1456            // former `mag > 0.0` gate exactly.
1457            let floor = noise.as_ref().map_or(0.0, |n| n[i]);
1458            if mag > floor && mag.is_finite() && viol.is_finite() && viol > 0.0 {
1459                worst = worst.max(viol / mag);
1460            }
1461        }
1462        worst
1463    }
1464
1465    /// The objective scaling factor `df` currently in force (`1.0` when no
1466    /// objective scaling is active).
1467    ///
1468    /// Exposed because the termination logic must be able to tell an honest
1469    /// certificate from one an extreme scale has masked (gh #200): the scale
1470    /// factor itself is the discriminating signal, not the error.
1471    pub fn obj_scaling_factor(&self) -> Number {
1472        self.nlp.borrow().obj_scaling_factor()
1473    }
1474
1475    /// The solver-computed part of the objective scale — see
1476    /// [`IpoptNlp::computed_obj_scaling_factor`]. The masked-certificate test
1477    /// keys on this, not on the product, so a user who deliberately scales a
1478    /// well-conditioned objective down is not second-guessed.
1479    pub fn computed_obj_scaling_factor(&self) -> Number {
1480        self.nlp.borrow().computed_obj_scaling_factor()
1481    }
1482
1483    /// Overall **unscaled** max-norm KKT error — `max` of the unscaled dual
1484    /// infeasibility, primal infeasibility, and complementarity. This is the
1485    /// honest "distance from a KKT point in the user's own units", as
1486    /// opposed to [`Self::curr_nlp_error`], which additionally applies the
1487    /// `s_d`/`s_c` optimality scaling. Used by the status-fidelity gate and
1488    /// surfaced to callers that must independently verify a returned
1489    /// certificate (pounce#173).
1490    pub fn curr_unscaled_nlp_error(&self) -> Number {
1491        self.curr_unscaled_dual_infeasibility_max()
1492            .max(self.curr_unscaled_primal_infeasibility_max())
1493            .max(self.curr_unscaled_complementarity_max())
1494    }
1495
1496    pub fn trial_f(&self) -> Number {
1497        let iv = self.trial_iv();
1498        let mut nlp = self.nlp.borrow_mut();
1499        nlp.eval_f(&*iv.x)
1500    }
1501
1502    fn barrier_obj_at(
1503        &self,
1504        f: Number,
1505        s_x_l: &dyn Vector,
1506        s_x_u: &dyn Vector,
1507        s_s_l: &dyn Vector,
1508        s_s_u: &dyn Vector,
1509    ) -> Number {
1510        let mu = self.data.borrow().curr_mu;
1511        let log_sum = s_x_l.sum_logs() + s_x_u.sum_logs() + s_s_l.sum_logs() + s_s_u.sum_logs();
1512        let mut phi = f - mu * log_sum;
1513        if self.kappa_d > 0.0 {
1514            let di = self.damping_indicators();
1515            phi += self.kappa_d * mu * s_x_l.dot(&*di.x_l);
1516            phi += self.kappa_d * mu * s_x_u.dot(&*di.x_u);
1517            phi += self.kappa_d * mu * s_s_l.dot(&*di.s_l);
1518            phi += self.kappa_d * mu * s_s_u.dot(&*di.s_u);
1519        }
1520        phi
1521    }
1522
1523    pub fn curr_barrier_obj(&self) -> Number {
1524        let f = self.curr_f();
1525        let s_x_l = self.curr_slack_x_l();
1526        let s_x_u = self.curr_slack_x_u();
1527        let s_s_l = self.curr_slack_s_l();
1528        let s_s_u = self.curr_slack_s_u();
1529        self.barrier_obj_at(f, &*s_x_l, &*s_x_u, &*s_s_l, &*s_s_u)
1530    }
1531
1532    pub fn trial_barrier_obj(&self) -> Number {
1533        let f = self.trial_f();
1534        let s_x_l = self.trial_slack_x_l();
1535        let s_x_u = self.trial_slack_x_u();
1536        let s_s_l = self.trial_slack_s_l();
1537        let s_s_u = self.trial_slack_s_u();
1538        self.barrier_obj_at(f, &*s_x_l, &*s_x_u, &*s_s_l, &*s_s_u)
1539    }
1540
1541    /// Gradient of the barrier objective wrt `x`:
1542    ///   ∇_x φ = ∇f(x) − μ · [P_L · (1/s_L) − P_U · (1/s_U)] + damping
1543    /// Mirrors `IpIpoptCalculatedQuantities.cpp:CalcGradBarrierObjectiveX`.
1544    pub fn curr_grad_barrier_obj_x(&self) -> Rc<dyn Vector> {
1545        let iv = self.curr_iv();
1546        let mu = self.data.borrow().curr_mu;
1547        let s_l = self.curr_slack_x_l();
1548        let s_u = self.curr_slack_x_u();
1549
1550        let mut inv_s_l = s_l.make_new();
1551        inv_s_l.copy(&*s_l);
1552        inv_s_l.element_wise_reciprocal();
1553        let mut inv_s_u = s_u.make_new();
1554        inv_s_u.copy(&*s_u);
1555        inv_s_u.element_wise_reciprocal();
1556
1557        let grad_f = self.curr_grad_f();
1558        let mut tmp = iv.x.make_new();
1559        tmp.copy(&*grad_f);
1560        let nlp = self.nlp.borrow();
1561        // tmp -= μ · P_L · inv_s_l
1562        nlp.px_l().mult_vector(-mu, &*inv_s_l, 1.0, &mut *tmp);
1563        // tmp += μ · P_U · inv_s_u
1564        nlp.px_u().mult_vector(mu, &*inv_s_u, 1.0, &mut *tmp);
1565
1566        if self.kappa_d > 0.0 {
1567            let di = self.damping_indicators();
1568            // + κ_d μ · P_L · 1_singly_x_L
1569            nlp.px_l()
1570                .mult_vector(self.kappa_d * mu, &*di.x_l, 1.0, &mut *tmp);
1571            // − κ_d μ · P_U · 1_singly_x_U
1572            nlp.px_u()
1573                .mult_vector(-self.kappa_d * mu, &*di.x_u, 1.0, &mut *tmp);
1574        }
1575        rc_from(tmp)
1576    }
1577
1578    /// Gradient of the barrier objective wrt `s`:
1579    ///   ∇_s φ = − μ · [P_L · (1/s_s_L) − P_U · (1/s_s_U)] + damping
1580    pub fn curr_grad_barrier_obj_s(&self) -> Rc<dyn Vector> {
1581        let iv = self.curr_iv();
1582        let mu = self.data.borrow().curr_mu;
1583        let s_l = self.curr_slack_s_l();
1584        let s_u = self.curr_slack_s_u();
1585
1586        let mut inv_s_l = s_l.make_new();
1587        inv_s_l.copy(&*s_l);
1588        inv_s_l.element_wise_reciprocal();
1589        let mut inv_s_u = s_u.make_new();
1590        inv_s_u.copy(&*s_u);
1591        inv_s_u.element_wise_reciprocal();
1592
1593        let mut tmp = iv.s.make_new();
1594        tmp.set(0.0);
1595        let nlp = self.nlp.borrow();
1596        nlp.pd_l().mult_vector(-mu, &*inv_s_l, 1.0, &mut *tmp);
1597        nlp.pd_u().mult_vector(mu, &*inv_s_u, 1.0, &mut *tmp);
1598
1599        if self.kappa_d > 0.0 {
1600            let di = self.damping_indicators();
1601            nlp.pd_l()
1602                .mult_vector(self.kappa_d * mu, &*di.s_l, 1.0, &mut *tmp);
1603            nlp.pd_u()
1604                .mult_vector(-self.kappa_d * mu, &*di.s_u, 1.0, &mut *tmp);
1605        }
1606        rc_from(tmp)
1607    }
1608
1609    // --------------------------------------------------------------
1610    // Step-aware quadratic-model quantities — used by the penalty
1611    // line-search acceptor's pred/ared test and by the quality-
1612    // function mu oracle's q(σ) evaluator.
1613    // --------------------------------------------------------------
1614
1615    /// Directional derivative of the barrier objective along `(δx, δs)`:
1616    /// `gradBarrTDelta = ∇_x φ · δx + ∇_s φ · δs`. Port of
1617    /// `IpIpoptCalculatedQuantities.cpp:CurrGradBarrTDelta` (called
1618    /// `IpCq().curr_gradBarrTDelta()` in upstream after the search dir
1619    /// has been computed).
1620    pub fn curr_grad_barr_t_delta(&self, delta_x: &dyn Vector, delta_s: &dyn Vector) -> Number {
1621        let g_x = self.curr_grad_barrier_obj_x();
1622        let g_s = self.curr_grad_barrier_obj_s();
1623        g_x.dot(delta_x) + g_s.dot(delta_s)
1624    }
1625
1626    /// `δᵀ(W + Σ_x + δ_pert_x I)δ_x + δ_sᵀ(Σ_s + δ_pert_s I)δ_s` —
1627    /// the quadratic-model term used by `IpPenaltyLSAcceptor.cpp:
1628    /// InitThisLineSearch:101-129`. Reads `W` and the active PD
1629    /// perturbations from [`crate::ipopt_data::IpoptData`].
1630    /// Returns 0 if the result would be negative (matching upstream's
1631    /// `if dWd <= 0 then dWd = 0` guard at line 133).
1632    pub fn curr_dwd(&self, delta_x: &dyn Vector, delta_s: &dyn Vector) -> Number {
1633        let mut dwd: Number = 0.0;
1634
1635        // δ_xᵀ W δ_x.
1636        if let Some(w) = self.data.borrow().w.clone() {
1637            let mut wd = delta_x.make_new();
1638            w.mult_vector(1.0, delta_x, 0.0, &mut *wd);
1639            dwd += wd.dot(delta_x);
1640        }
1641
1642        // δ_xᵀ Σ_x δ_x.
1643        let sigma_x = self.curr_sigma_x();
1644        let mut tmp_x = delta_x.make_new();
1645        tmp_x.copy(delta_x);
1646        tmp_x.element_wise_multiply(&*sigma_x);
1647        dwd += tmp_x.dot(delta_x);
1648
1649        // δ_sᵀ Σ_s δ_s.
1650        let sigma_s = self.curr_sigma_s();
1651        let mut tmp_s = delta_s.make_new();
1652        tmp_s.copy(delta_s);
1653        tmp_s.element_wise_multiply(&*sigma_s);
1654        dwd += tmp_s.dot(delta_s);
1655
1656        // PD perturbations.
1657        let pert = self.data.borrow().perturbations;
1658        if pert.delta_x != 0.0 {
1659            let nx = delta_x.nrm2();
1660            dwd += pert.delta_x * nx * nx;
1661        }
1662        if pert.delta_s != 0.0 {
1663            let ns = delta_s.nrm2();
1664            dwd += pert.delta_s * ns * ns;
1665        }
1666
1667        dwd.max(0.0)
1668    }
1669
1670    // --------------------------------------------------------------
1671    // Constraint violation theta — port of
1672    // `IpIpoptCalculatedQuantities.cpp:CurrConstraintViolation`.
1673    // Default norm is 1-norm (option `constraint_violation_norm`,
1674    // default "1-norm" upstream); we hardwire 1-norm in v1.0.
1675    // --------------------------------------------------------------
1676
1677    pub fn curr_constraint_violation(&self) -> Number {
1678        let c = self.curr_c();
1679        let dms = self.curr_d_minus_s();
1680        c.asum() + dms.asum()
1681    }
1682
1683    /// Number of constraint rows backing the 1-norm above, i.e.
1684    /// `dim(c) + dim(d - s)`. Upstream never needs this because it
1685    /// treats `theta` as a bare scalar, but any threshold expressed in
1686    /// `theta` units is a *sum* over this many rows — a `theta` of `T`
1687    /// is a mean per-row residual of `T / rows`. The filter acceptor
1688    /// uses it to floor the `theta_max` reference so the ceiling means
1689    /// the same thing on a 10-row and a 50 000-row model.
1690    pub fn constraint_violation_rows(&self) -> usize {
1691        let c = self.curr_c();
1692        let dms = self.curr_d_minus_s();
1693        (c.dim() as usize) + (dms.dim() as usize)
1694    }
1695
1696    pub fn trial_constraint_violation(&self) -> Number {
1697        let c = self.trial_c();
1698        let dms = self.trial_d_minus_s();
1699        c.asum() + dms.asum()
1700    }
1701
1702    /// Max-norm primal infeasibility — `max(||c||_∞, ||d − s||_∞)`. Used
1703    /// by the iteration output's `inf_pr` column when
1704    /// `inf_pr_output == INTERNAL`. Mirrors
1705    /// `IpIpoptCalculatedQuantities.cpp:CurrPrimalInfeasibility(NORM_MAX)`.
1706    pub fn curr_primal_infeasibility_max(&self) -> Number {
1707        let c = self.curr_c();
1708        let dms = self.curr_d_minus_s();
1709        c.amax().max(dms.amax())
1710    }
1711
1712    /// Max-norm dual infeasibility — `max(||∇_x L||_∞, ||∇_s L||_∞)`.
1713    /// Mirrors `IpIpoptCalculatedQuantities.cpp:CurrDualInfeasibility(NORM_MAX)`.
1714    pub fn curr_dual_infeasibility_max(&self) -> Number {
1715        let glx = self.curr_grad_lag_x();
1716        let gls = self.curr_grad_lag_s();
1717        glx.amax().max(gls.amax())
1718    }
1719
1720    /// Magnitude of the largest **term** the Lagrangian gradient is assembled
1721    /// from — the scale [`Self::curr_dual_infeasibility_max`] is a residual
1722    /// *of* (gh #532).
1723    ///
1724    /// ```text
1725    ///   D = max( ‖∇f‖_∞ , ‖J_cᵀ y_c‖_∞ , ‖J_dᵀ y_d‖_∞ ,
1726    ///            ‖P_L z_L‖_∞ , ‖P_U z_U‖_∞ ,
1727    ///            ‖y_d‖_∞ , ‖P_L v_L‖_∞ , ‖P_U v_U‖_∞ )
1728    /// ```
1729    ///
1730    /// `∇L` is the *sum* of exactly these terms, so `dual_inf / D` is the
1731    /// fraction of them that failed to cancel: `1` at a point where nothing
1732    /// cancelled (`min -exp(x) s.t. x >= 0` running away, `∇f = −8.8e47` with
1733    /// no multiplier to meet it), and `~eps` at a point where the cancellation
1734    /// was as complete as the arithmetic allows. That ratio is the
1735    /// scale-invariant statement of stationarity: it is unchanged by
1736    /// multiplying the objective — and hence every multiplier — by a positive
1737    /// constant, which is the map an absolute bound on `dual_inf` is not
1738    /// invariant under.
1739    ///
1740    /// The projections are applied rather than assumed away: `P_L`/`P_U` are
1741    /// 0/1 expansion matrices in the main NLP, where the scatter leaves the
1742    /// max-norm alone, but the term's own norm is what this measures and the
1743    /// restoration NLP supplies its own operators.
1744    ///
1745    /// No `has_valid_numbers` sweep, unlike [`Self::curr_nlp_error`] (gh #292):
1746    /// `amax` drops NaN, so a NaN gradient reads here as a finite scale. That
1747    /// cannot launder anything, because the only caller pairs this with the
1748    /// aggregate `nlp_err <= tol` test, and `nlp_err` carries that sweep — a
1749    /// NaN anywhere in `∇L` makes it NaN, and `NaN <= tol` is false.
1750    ///
1751    /// Repeats the `∇f` and the two transpose products
1752    /// [`Self::curr_grad_lag_x`] already performs on the same iterate, plus
1753    /// four scatters. The evaluations themselves hit `OrigIpoptNLP`'s
1754    /// per-iterate caches, so the marginal cost is the products — but it is
1755    /// still a second pass, and the caller reads this only where a termination
1756    /// certificate is otherwise on the table. See
1757    /// `OptErrorConvCheck::dual_inf_bound`.
1758    pub fn curr_dual_infeasibility_scale_max(&self) -> Number {
1759        let iv = self.curr_iv();
1760        let mut scale = self
1761            .curr_grad_f()
1762            .amax()
1763            .max(self.curr_jac_c_t_times_curr_y_c().amax())
1764            .max(self.curr_jac_d_t_times_curr_y_d().amax())
1765            .max(iv.y_d.amax());
1766
1767        let nlp = self.nlp.borrow();
1768        let mut tmp_x = iv.x.make_new();
1769        nlp.px_l().mult_vector(1.0, &*iv.z_l, 0.0, &mut *tmp_x);
1770        scale = scale.max(tmp_x.amax());
1771        nlp.px_u().mult_vector(1.0, &*iv.z_u, 0.0, &mut *tmp_x);
1772        scale = scale.max(tmp_x.amax());
1773
1774        let mut tmp_s = iv.y_d.make_new();
1775        nlp.pd_l().mult_vector(1.0, &*iv.v_l, 0.0, &mut *tmp_s);
1776        scale = scale.max(tmp_s.amax());
1777        nlp.pd_u().mult_vector(1.0, &*iv.v_u, 0.0, &mut *tmp_s);
1778        scale.max(tmp_s.amax())
1779    }
1780
1781    /// [`Self::curr_dual_infeasibility_scale_max`] in the **unscaled**
1782    /// (user-original) space. Every term of the scaled Lagrangian gradient is
1783    /// `df` times its user-space counterpart — `∇f_scaled = df·∇f`,
1784    /// `J_cᵀ_scaled y_c_scaled = Jᵀ(dc ⊙ y_c_scaled) = df·Jᵀ y_c` since
1785    /// `dc ⊙ y_scaled = df·y_user`, and likewise for the bound blocks, POUNCE
1786    /// applying no variable scaling — so the unscaling is the single divide by
1787    /// `|df|` that [`Self::curr_unscaled_dual_infeasibility_max`] performs on
1788    /// the residual, term for term and row scaling included. Magnitude, for
1789    /// the reason documented there: `df` is signed, a max-norm is not.
1790    pub fn curr_unscaled_dual_infeasibility_scale_max(&self) -> Number {
1791        let df = self.nlp.borrow().obj_scaling_factor().abs();
1792        let scaled = self.curr_dual_infeasibility_scale_max();
1793        if df == 0.0 || df == 1.0 {
1794            scaled
1795        } else {
1796            scaled / df
1797        }
1798    }
1799
1800    /// Scaled stationarity of the infeasibility measure `½‖(c, d−s)‖²`
1801    /// — `‖J_cᵀ c + J_dᵀ (d−s)‖_∞ / max(1, ‖(c, d−s)‖_∞)`. The
1802    /// numerator is the x-gradient of the squared constraint
1803    /// violation; a value near zero with the violation itself bounded
1804    /// away from zero marks an iterate converging to a stationary
1805    /// point of the infeasibility — i.e. a locally infeasible problem.
1806    /// No linear solve: two transpose-products. Mirrors the gradient
1807    /// term behind Ipopt's `IpRestoConvCheck.cpp` `LOCALLY_INFEASIBLE`
1808    /// test, applied here in the main loop.
1809    /// Does a short step along `−∇θ` actually reduce the constraint violation?
1810    ///
1811    /// `LocalInfeasibility` asserts the iterate has converged to a **stationary
1812    /// point of the constraint violation** — that no local move reduces it. That
1813    /// is a checkable claim, and this checks it directly instead of trusting a
1814    /// threshold on a proxy.
1815    ///
1816    /// Why a probe rather than a better proxy: the detector's surrogate is
1817    /// `‖Jᵀc‖ / max(1, ‖c‖)` against an absolute tolerance, and no variant of it
1818    /// separates the cases. Measured over 800 MINLPLib models plus targeted
1819    /// infeasible problems, the scaled form produces a confirmed false verdict
1820    /// (HS13 from `x₀ = (1e4, 1e4)`, where the constraint scaling `dc ≈ 3.3e-7`
1821    /// drives the surrogate to `5e-14` at a point whose violation is 0.51); the
1822    /// unscaled form needs a tolerance ≥ 1e-2 to fire at all, which introduces
1823    /// new false infeasibility on 3+ corpus models while still losing 2 correct
1824    /// detections; and a scale-invariant `‖Jᵀc‖ / ‖c‖²` is not separable even on
1825    /// the targeted set. A single absolute threshold on a surrogate cannot do
1826    /// this job.
1827    ///
1828    /// Comparing `θ` at two points is scale-free by construction — the row
1829    /// scaling cancels out of the ratio — so this needs no calibration at all.
1830    ///
1831    /// Costs one `eval_c`/`eval_d` pair per probed step, and runs only where the
1832    /// detector was about to fire (both gates already passed for a full streak),
1833    /// which is rare. Steps are clamped to the variable bounds, so descent that
1834    /// only exists outside the box is correctly not counted — that direction
1835    /// would suppress a *correct* infeasibility verdict.
1836    ///
1837    /// Returns `true` when descent is available, i.e. the iterate is **not**
1838    /// stationary and `LocalInfeasibility` must not be declared.
1839    pub fn infeasibility_descent_available(&self) -> bool {
1840        use pounce_linalg::DenseVector;
1841
1842        let theta_curr = self.curr_primal_infeasibility_max();
1843        if theta_curr <= 0.0 {
1844            return false;
1845        }
1846        // -grad of 1/2||(c, d-s)||^2 w.r.t. x.
1847        let c = self.curr_c();
1848        let dms = self.curr_d_minus_s();
1849        let jc_t_c = self.curr_jac_c_t_times_vec(&*c);
1850        let jd_t_dms = self.curr_jac_d_t_times_vec(&*dms);
1851        let mut grad = jc_t_c.make_new();
1852        grad.add_two_vectors(1.0, &*jc_t_c, 1.0, &*jd_t_dms, 0.0);
1853        let gnorm = grad.amax();
1854        if !(gnorm > 0.0) || !gnorm.is_finite() {
1855            // A vanishing gradient is the stationary case this exists to
1856            // confirm; a non-finite one gives us nothing to probe with.
1857            return false;
1858        }
1859
1860        let x = self.curr_iv().x.clone();
1861        let nlp = self.nlp.borrow();
1862
1863        // Full-length bound values and finite-bound indicators, lifted through
1864        // the expansion matrices (same pattern as the divergence guard).
1865        let mut ones_l = nlp.x_l().make_new();
1866        ones_l.set(1.0);
1867        let mut has_lb = x.make_new();
1868        nlp.px_l().mult_vector(1.0, &*ones_l, 0.0, &mut *has_lb);
1869        let mut lb = x.make_new();
1870        nlp.px_l().mult_vector(1.0, nlp.x_l(), 0.0, &mut *lb);
1871
1872        let mut ones_u = nlp.x_u().make_new();
1873        ones_u.set(1.0);
1874        let mut has_ub = x.make_new();
1875        nlp.px_u().mult_vector(1.0, &*ones_u, 0.0, &mut *has_ub);
1876        let mut ub = x.make_new();
1877        nlp.px_u().mult_vector(1.0, nlp.x_u(), 0.0, &mut *ub);
1878        drop(nlp);
1879
1880        let dense = |v: &dyn Vector| -> Option<Vec<Number>> {
1881            v.as_any()
1882                .downcast_ref::<DenseVector>()
1883                .map(|d| d.expanded_values())
1884        };
1885        let (Some(xv), Some(gv), Some(lbv), Some(ubv), Some(hl), Some(hu)) = (
1886            dense(&*x),
1887            dense(&*grad),
1888            dense(&*lb),
1889            dense(&*ub),
1890            dense(&*has_lb),
1891            dense(&*has_ub),
1892        ) else {
1893            // Non-dense backing: no probe possible. Report "no descent" so the
1894            // caller falls back to the surrogate's verdict rather than silently
1895            // suppressing every infeasibility conclusion.
1896            return false;
1897        };
1898
1899        // Relative step lengths, so the probe is independent of problem scale.
1900        let xnorm = xv.iter().fold(0.0_f64, |a, &v| a.max(v.abs())).max(1.0);
1901        let base = xnorm / gnorm;
1902
1903        let mut trial = x.make_new();
1904        for k in 0..Self::INFEAS_PROBE_STEPS {
1905            let alpha = base * 10f64.powi(-(k as i32));
1906            {
1907                let Some(t) = trial.as_any_mut().downcast_mut::<DenseVector>() else {
1908                    return false;
1909                };
1910                for (i, slot) in t.values_mut().iter_mut().enumerate() {
1911                    let mut xi = xv[i] - alpha * gv[i];
1912                    if hl[i] != 0.0 {
1913                        xi = xi.max(lbv[i]);
1914                    }
1915                    if hu[i] != 0.0 {
1916                        xi = xi.min(ubv[i]);
1917                    }
1918                    *slot = xi;
1919                }
1920            }
1921            if let Some(theta) = self.theta_at(&*trial) {
1922                if theta.is_finite() && theta < theta_curr * (1.0 - Self::INFEAS_PROBE_MARGIN) {
1923                    return true;
1924                }
1925            }
1926        }
1927        false
1928    }
1929
1930    /// Number of geometrically decreasing step lengths the descent probe tries.
1931    const INFEAS_PROBE_STEPS: usize = 6;
1932    /// Relative reduction in `θ` a probe step must achieve before it counts as
1933    /// descent and vetoes the verdict.
1934    ///
1935    /// Deliberately coarse. The question is not "is this the exact minimiser of
1936    /// the violation" — an interior-point iterate converging toward one always
1937    /// has some infinitesimal descent left, and a tight margin would veto
1938    /// forever and never let a genuine infeasibility be declared. The question
1939    /// is whether a *materially* less-violating point sits nearby, which is what
1940    /// distinguishes "converging to an infeasible stationary point" from
1941    /// "nowhere near stationary".
1942    ///
1943    /// The two regimes are far apart, so the exact value is not delicate. On the
1944    /// genuinely infeasible `x³+y³ == 1 ∧ == 2`, iterates near the least-squares
1945    /// point have only ~0.07 % descent available. On HS13's false verdict, one
1946    /// step takes `θ` from 0.51 to **zero** — a 100 % reduction. Anything between
1947    /// a few percent and most of the way separates them; 10 % sits in the middle.
1948    const INFEAS_PROBE_MARGIN: Number = 0.1;
1949
1950    /// Max-norm constraint violation at an arbitrary `x`, evaluated on scratch
1951    /// vectors so the algorithm's `curr`/`trial` state is untouched. `None` if
1952    /// the evaluation is unusable.
1953    fn theta_at(&self, x: &dyn Vector) -> Option<Number> {
1954        let iv = self.curr_iv();
1955        let mut nlp = self.nlp.borrow_mut();
1956        let mut c = iv.y_c.make_new();
1957        nlp.eval_c(x, &mut *c);
1958        let mut d = iv.s.make_new();
1959        nlp.eval_d(x, &mut *d);
1960        // `d - s` against the CURRENT slacks, matching how `curr_d_minus_s`
1961        // measures the violation: the probe moves x only.
1962        let mut dms = iv.s.make_new();
1963        dms.add_two_vectors(1.0, &*d, -1.0, &*iv.s, 0.0);
1964        let t = c.amax().max(dms.amax());
1965        t.is_finite().then_some(t)
1966    }
1967
1968    pub fn curr_infeasibility_stationarity(&self) -> Number {
1969        let c = self.curr_c();
1970        let dms = self.curr_d_minus_s();
1971        let jc_t_c = self.curr_jac_c_t_times_vec(&*c);
1972        let jd_t_dms = self.curr_jac_d_t_times_vec(&*dms);
1973        let mut grad = jc_t_c.make_new();
1974        grad.add_two_vectors(1.0, &*jc_t_c, 1.0, &*jd_t_dms, 0.0);
1975        let viol = c.amax().max(dms.amax());
1976        grad.amax() / viol.max(1.0)
1977    }
1978
1979    // --------------------------------------------------------------
1980    // Average / scalar complementarity
1981    // --------------------------------------------------------------
1982
1983    /// `(z_L · s_L + z_U · s_U + v_L · s_L^d + v_U · s_U^d) / N`
1984    /// where `N` is the total number of bound multipliers
1985    /// (`IpIpoptCalculatedQuantities.cpp:3553-3606`).
1986    pub fn curr_avrg_compl(&self) -> Number {
1987        let iv = self.curr_iv();
1988        let n = iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
1989        if n == 0 {
1990            return 0.0;
1991        }
1992        let s_x_l = self.curr_slack_x_l();
1993        let s_x_u = self.curr_slack_x_u();
1994        let s_s_l = self.curr_slack_s_l();
1995        let s_s_u = self.curr_slack_s_u();
1996        let mut acc = iv.z_l.dot(&*s_x_l);
1997        acc += iv.z_u.dot(&*s_x_u);
1998        acc += iv.v_l.dot(&*s_s_l);
1999        acc += iv.v_u.dot(&*s_s_u);
2000        acc / Number::from(n)
2001    }
2002
2003    /// `min_i (s_i · z_i)` over all four bound complementarity blocks.
2004    /// Mirrors `IpIpoptCalculatedQuantities.cpp:CurrComplxMin`
2005    /// (lines 3608-3640) — the smallest pairwise product `s · z`,
2006    /// signalling how close the iterate is to the central path.
2007    /// Empty bound sets contribute `+∞`; returns `0` if no bounds at
2008    /// all.
2009    pub fn curr_complementarity_min(&self) -> Number {
2010        let cxl = self.curr_compl_x_l();
2011        let cxu = self.curr_compl_x_u();
2012        let csl = self.curr_compl_s_l();
2013        let csu = self.curr_compl_s_u();
2014        let m = |v: &Rc<dyn Vector>| {
2015            if v.dim() == 0 {
2016                Number::INFINITY
2017            } else {
2018                v.min()
2019            }
2020        };
2021        let acc = m(&cxl).min(m(&cxu)).min(m(&csl)).min(m(&csu));
2022        if acc.is_infinite() { 0.0 } else { acc }
2023    }
2024
2025    /// Max-norm of the unbarriered complementarity blocks
2026    /// `max_i |s_i · z_i|` across all four `(x_L, x_U, s_L, s_U)`
2027    /// pairs. Mirrors upstream
2028    /// `IpIpoptCalculatedQuantities.cpp:CurrComplementarity(0., NORM_MAX)`
2029    /// — used by `OptimalityErrorConvergenceCheck` to gate the
2030    /// per-component `compl_inf_tol` test independently of the scaled
2031    /// scalar `curr_nlp_error`.
2032    pub fn curr_complementarity_max(&self) -> Number {
2033        self.curr_compl_x_l()
2034            .amax()
2035            .max(self.curr_compl_x_u().amax())
2036            .max(self.curr_compl_s_l().amax())
2037            .max(self.curr_compl_s_u().amax())
2038    }
2039
2040    /// Centrality measure `ξ = min_i(s_i z_i) / avrg(s · z)`. Mirrors
2041    /// `IpIpoptCalculatedQuantities.cpp:CurrCentralityMeasure`. Used
2042    /// by [`crate::mu::oracle::loqo::LoqoMuOracle`] to bias σ toward
2043    /// the central path when the iterate is unbalanced. Returns `1.0`
2044    /// (perfectly central) when there are no bound multipliers.
2045    pub fn curr_centrality_measure(&self) -> Number {
2046        let avrg = self.curr_avrg_compl();
2047        if avrg <= 0.0 {
2048            return 1.0;
2049        }
2050        self.curr_complementarity_min() / avrg
2051    }
2052
2053    /// Barriered KKT error `E_μ(x,y,z)` — port of
2054    /// `IpIpoptCalculatedQuantities.cpp:CurrBarrierError`. Same as
2055    /// [`Self::curr_nlp_error`] but uses the *relaxed* complementarity
2056    /// `s ⊙ z − μ` so the residual is zero when the iterate sits on the
2057    /// μ-perturbed central path. The monotone barrier-update strategy
2058    /// reduces μ only once this error drops below
2059    /// `barrier_tol_factor · μ`.
2060    pub fn curr_barrier_error(&self) -> Number {
2061        let iv = self.curr_iv();
2062        let (s_d, s_c) = self.optimality_error_scaling(&iv);
2063
2064        let glx = self.curr_grad_lag_x();
2065        let gls = self.curr_grad_lag_s();
2066        let dual = glx.amax().max(gls.amax()) / s_d;
2067
2068        let c = self.curr_c();
2069        let dms = self.curr_d_minus_s();
2070        let primal = c.amax().max(dms.amax());
2071
2072        let compl = self
2073            .curr_relaxed_compl_x_l()
2074            .amax()
2075            .max(self.curr_relaxed_compl_x_u().amax())
2076            .max(self.curr_relaxed_compl_s_l().amax())
2077            .max(self.curr_relaxed_compl_s_u().amax())
2078            / s_c;
2079
2080        dual.max(primal).max(compl)
2081    }
2082
2083    /// Optimality-scaled max-norm KKT error — port of
2084    /// `IpIpoptCalculatedQuantities.cpp:3050-3104`.
2085    ///
2086    /// ```text
2087    ///   E = max( ||∇_x L, ∇_s L||_∞ / s_d ,
2088    ///            ||c, d − s||_∞ ,
2089    ///            ||compl||_∞ / s_c )
2090    /// ```
2091    ///
2092    /// where `s_d` / `s_c` are the asum-based scalings from
2093    /// `ComputeOptimalityErrorScaling` (see §4 of `MAIN_LOOP.md`).
2094    /// Uses `mu_target = 0` (the unbarriered KKT residual). The
2095    /// barriered variant is `curr_barrier_error` (TODO in Phase 7).
2096    pub fn curr_nlp_error(&self) -> Number {
2097        self.nlp_error(None)
2098    }
2099
2100    /// [`Self::curr_nlp_error`] with the primal-infeasibility term replaced by
2101    /// [`Self::curr_primal_infeasibility_above_noise`] — i.e. counting a
2102    /// constraint row's residual only where it rises above the finest value
2103    /// that row's residual can take in floating point (gh #528).
2104    ///
2105    /// Never larger than [`Self::curr_nlp_error`], and equal to it whenever no
2106    /// row is at its own resolution limit — which is every problem whose data
2107    /// is `O(1)`, so the common path is unchanged. It exists because the other
2108    /// two terms of the KKT error are already normalised (`s_d`, `s_c`) while
2109    /// the primal one is a bare absolute residual: `‖c‖_∞` and `‖d − s‖_∞` are
2110    /// quantised in units of `eps ·` the rows' own magnitude, so on a model
2111    /// whose constraint values reach `~1e8` the smallest *nonzero* value the
2112    /// term can take already exceeds the default `tol = 1e-8`. Judging that
2113    /// term absolutely there asks for a residual no iterate can represent.
2114    ///
2115    /// Read only by the **strict** convergence gate, which pairs it with the
2116    /// unscaled `constr_viol_tol` test on the full, unfloored residual — so
2117    /// what this admits is bounded by the user's own feasibility tolerance,
2118    /// never by the noise floor alone.
2119    ///
2120    /// `kappa` is the safety factor on the per-row floor —
2121    /// [`ROW_NOISE_KAPPA`] by default, from the `primal_noise_floor_kappa`
2122    /// option. **`0` switches the floor off entirely**, making this identical
2123    /// to [`Self::curr_nlp_error`] and the strict gate bit-for-bit upstream's.
2124    pub fn curr_nlp_error_above_primal_noise(&self, kappa: Number) -> Number {
2125        self.nlp_error(Some(kappa))
2126    }
2127
2128    /// `above_primal_noise` carries the floor's `kappa` when the primal term is
2129    /// to be floored, and is `None` for the plain upstream aggregate.
2130    fn nlp_error(&self, above_primal_noise: Option<Number>) -> Number {
2131        let iv = self.curr_iv();
2132        let (s_d, s_c) = self.optimality_error_scaling(&iv);
2133
2134        // dual infeasibility (max-norm of grad_lag_x and grad_lag_s)
2135        let glx = self.curr_grad_lag_x();
2136        let gls = self.curr_grad_lag_s();
2137
2138        // primal: max(||c||, ||d-s||)
2139        let c = self.curr_c();
2140        let dms = self.curr_d_minus_s();
2141
2142        // unbarriered complementarity (mu_target = 0 → just ||compl||)
2143        let cxl = self.curr_compl_x_l();
2144        let cxu = self.curr_compl_x_u();
2145        let csl = self.curr_compl_s_l();
2146        let csu = self.curr_compl_s_u();
2147
2148        // #292: the max-norm (`amax`/BLAS `iamax`) behind every term below
2149        // silently *drops* NaN — `NaN > m` is `false`, so a NaN component
2150        // leaves the running max untouched and is laundered into a finite
2151        // (typically `0.0`) KKT error. A NaN gradient, NaN constraint Jacobian
2152        // (via `∇_x L`'s `Jᵀy` term), or NaN residual would then read as an
2153        // *optimal* solve and return `Solve_Succeeded`. Detect any non-finite
2154        // component here — through the NaN-propagating `asum` behind
2155        // `has_valid_numbers`, not `amax` — and surface it as a non-finite KKT
2156        // error so the caller's existing `!nlp_err.is_finite()` guard fires
2157        // `Invalid_Number_Detected`. This is confined to the convergence/error
2158        // measure; the general `amax` semantics that step-size selection, the
2159        // line search, and the divergence detectors rely on are untouched.
2160        // (Inf is *not* laundered — `Inf > m` is true — so it already
2161        // propagated; this closes only the NaN hole, and Inf for free.)
2162        for v in [&glx, &gls, &c, &dms, &cxl, &cxu, &csl, &csu] {
2163            if !v.has_valid_numbers() {
2164                return Number::NAN;
2165            }
2166        }
2167
2168        let dual = glx.amax().max(gls.amax()) / s_d;
2169        let primal = match above_primal_noise {
2170            Some(kappa) if kappa > 0.0 => self.curr_primal_infeasibility_above_noise(kappa),
2171            _ => c.amax().max(dms.amax()),
2172        };
2173        let compl = cxl.amax().max(cxu.amax()).max(csl.amax()).max(csu.amax()) / s_c;
2174
2175        dual.max(primal).max(compl)
2176    }
2177
2178    /// `(s_d, s_c)` per `ComputeOptimalityErrorScaling`
2179    /// (`IpIpoptCalculatedQuantities.cpp:3663-3700`).
2180    fn optimality_error_scaling(&self, iv: &IteratesVector) -> (Number, Number) {
2181        let s_max = self.s_max;
2182
2183        // s_c: mean asum of all bound multipliers, capped at s_max,
2184        //      divided by s_max.
2185        let n_c = iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
2186        let s_c = if n_c == 0 {
2187            1.0
2188        } else {
2189            let asum = iv.z_l.asum() + iv.z_u.asum() + iv.v_l.asum() + iv.v_u.asum();
2190            (s_max.max(asum / Number::from(n_c))) / s_max
2191        };
2192
2193        // s_d: mean asum of all dual multipliers, capped, divided.
2194        let n_d =
2195            iv.y_c.dim() + iv.y_d.dim() + iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
2196        let s_d = if n_d == 0 {
2197            1.0
2198        } else {
2199            let asum = iv.y_c.asum()
2200                + iv.y_d.asum()
2201                + iv.z_l.asum()
2202                + iv.z_u.asum()
2203                + iv.v_l.asum()
2204                + iv.v_u.asum();
2205            (s_max.max(asum / Number::from(n_d))) / s_max
2206        };
2207
2208        (s_d, s_c)
2209    }
2210
2211    // --------------------------------------------------------------
2212    // Trial-side Lagrangian gradient / complementarity — needed by
2213    // the soft restoration phase's primal-dual error test. Each is a
2214    // line-for-line analog of the `curr_*` method above, reading the
2215    // `trial` iterate instead of `curr`.
2216    // --------------------------------------------------------------
2217
2218    pub fn trial_jac_c(&self) -> Rc<dyn Matrix> {
2219        let iv = self.trial_iv();
2220        self.nlp.borrow_mut().eval_jac_c(&*iv.x)
2221    }
2222
2223    pub fn trial_jac_d(&self) -> Rc<dyn Matrix> {
2224        let iv = self.trial_iv();
2225        self.nlp.borrow_mut().eval_jac_d(&*iv.x)
2226    }
2227
2228    /// `∇_x L` at the trial iterate — analog of [`Self::curr_grad_lag_x`].
2229    pub fn trial_grad_lag_x(&self) -> Rc<dyn Vector> {
2230        let iv = self.trial_iv();
2231        let grad_f = self.trial_grad_f();
2232        let jac_c = self.trial_jac_c();
2233        let jac_d = self.trial_jac_d();
2234
2235        let mut jc_t = iv.x.make_new();
2236        jac_c.trans_mult_vector(1.0, &*iv.y_c, 0.0, &mut *jc_t);
2237        let mut jd_t = iv.x.make_new();
2238        jac_d.trans_mult_vector(1.0, &*iv.y_d, 0.0, &mut *jd_t);
2239
2240        let mut tmp = iv.x.make_new();
2241        tmp.copy(&*grad_f);
2242        tmp.add_two_vectors(1.0, &*jc_t, 1.0, &*jd_t, 1.0);
2243
2244        let nlp = self.nlp.borrow();
2245        nlp.px_l().mult_vector(-1.0, &*iv.z_l, 1.0, &mut *tmp);
2246        nlp.px_u().mult_vector(1.0, &*iv.z_u, 1.0, &mut *tmp);
2247        rc_from(tmp)
2248    }
2249
2250    /// `∇_s L` at the trial iterate — analog of [`Self::curr_grad_lag_s`].
2251    pub fn trial_grad_lag_s(&self) -> Rc<dyn Vector> {
2252        let iv = self.trial_iv();
2253        let mut tmp = iv.y_d.make_new();
2254        let nlp = self.nlp.borrow();
2255        nlp.pd_u().mult_vector(1.0, &*iv.v_u, 0.0, &mut *tmp);
2256        nlp.pd_l().mult_vector(-1.0, &*iv.v_l, 1.0, &mut *tmp);
2257        tmp.axpy(-1.0, &*iv.y_d);
2258        rc_from(tmp)
2259    }
2260
2261    pub fn trial_compl_x_l(&self) -> Rc<dyn Vector> {
2262        Self::calc_compl(&*self.trial_slack_x_l(), &*self.trial_iv().z_l)
2263    }
2264
2265    pub fn trial_compl_x_u(&self) -> Rc<dyn Vector> {
2266        Self::calc_compl(&*self.trial_slack_x_u(), &*self.trial_iv().z_u)
2267    }
2268
2269    pub fn trial_compl_s_l(&self) -> Rc<dyn Vector> {
2270        Self::calc_compl(&*self.trial_slack_s_l(), &*self.trial_iv().v_l)
2271    }
2272
2273    pub fn trial_compl_s_u(&self) -> Rc<dyn Vector> {
2274        Self::calc_compl(&*self.trial_slack_s_u(), &*self.trial_iv().v_u)
2275    }
2276
2277    /// `||s ⊙ z − μ||₁` summed over the four complementarity blocks.
2278    fn relaxed_compl_asum(blocks: &[Rc<dyn Vector>], mu: Number) -> Number {
2279        let mut acc = 0.0;
2280        for compl in blocks {
2281            if compl.dim() == 0 {
2282                continue;
2283            }
2284            let mut r = compl.make_new();
2285            r.copy(&**compl);
2286            r.add_scalar(-mu);
2287            acc += r.asum();
2288        }
2289        acc
2290    }
2291
2292    /// Unscaled primal-dual KKT system error at the current iterate —
2293    /// port of
2294    /// `IpIpoptCalculatedQuantities.cpp:curr_primal_dual_system_error`.
2295    /// Each block uses the 1-norm scaled by its entry count; the result
2296    /// is the sum of the dual-infeasibility, primal-infeasibility, and
2297    /// complementarity terms. Used by the soft restoration phase's
2298    /// sufficient-reduction test.
2299    pub fn curr_primal_dual_system_error(&self, mu: Number) -> Number {
2300        let iv = self.curr_iv();
2301        let n_dual = iv.x.dim() + iv.s.dim();
2302        let dual_inf =
2303            (self.curr_grad_lag_x().asum() + self.curr_grad_lag_s().asum()) / Number::from(n_dual);
2304
2305        let n_primal = iv.y_c.dim() + iv.y_d.dim();
2306        let primal_inf = if n_primal > 0 {
2307            (self.curr_c().asum() + self.curr_d_minus_s().asum()) / Number::from(n_primal)
2308        } else {
2309            0.0
2310        };
2311
2312        let n_cmpl = iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
2313        let cmpl = if n_cmpl > 0 {
2314            Self::relaxed_compl_asum(
2315                &[
2316                    self.curr_compl_x_l(),
2317                    self.curr_compl_x_u(),
2318                    self.curr_compl_s_l(),
2319                    self.curr_compl_s_u(),
2320                ],
2321                mu,
2322            ) / Number::from(n_cmpl)
2323        } else {
2324            0.0
2325        };
2326
2327        dual_inf + primal_inf + cmpl
2328    }
2329
2330    /// Unscaled primal-dual KKT system error at the trial iterate —
2331    /// trial-side analog of [`Self::curr_primal_dual_system_error`].
2332    pub fn trial_primal_dual_system_error(&self, mu: Number) -> Number {
2333        let iv = self.trial_iv();
2334        let n_dual = iv.x.dim() + iv.s.dim();
2335        let dual_inf = (self.trial_grad_lag_x().asum() + self.trial_grad_lag_s().asum())
2336            / Number::from(n_dual);
2337
2338        let n_primal = iv.y_c.dim() + iv.y_d.dim();
2339        let primal_inf = if n_primal > 0 {
2340            (self.trial_c().asum() + self.trial_d_minus_s().asum()) / Number::from(n_primal)
2341        } else {
2342            0.0
2343        };
2344
2345        let n_cmpl = iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
2346        let cmpl = if n_cmpl > 0 {
2347            Self::relaxed_compl_asum(
2348                &[
2349                    self.trial_compl_x_l(),
2350                    self.trial_compl_x_u(),
2351                    self.trial_compl_s_l(),
2352                    self.trial_compl_s_u(),
2353                ],
2354                mu,
2355            ) / Number::from(n_cmpl)
2356        } else {
2357            0.0
2358        };
2359
2360        dual_inf + primal_inf + cmpl
2361    }
2362
2363    // --------------------------------------------------------------
2364    // Damping indicators — `IpIpoptCalculatedQuantities.cpp:1044-1092`.
2365    //
2366    //   Tmp_x = P_L · 1 − P_U · 1   (per primal: +1 lower-only,
2367    //                                 −1 upper-only, 0 two-sided,
2368    //                                 0 unbounded)
2369    //   dampind_x_L =  P_L^T · Tmp_x   (1 on lower-only bounds)
2370    //   dampind_x_U = −P_U^T · Tmp_x   (1 on upper-only bounds)
2371    // --------------------------------------------------------------
2372
2373    fn damping_indicators(&self) -> DampingIndicators {
2374        let nlp = self.nlp.borrow();
2375
2376        let mut tmp_x_l = nlp.x_l().make_new();
2377        tmp_x_l.set(1.0);
2378        let mut tmp_x_u = nlp.x_u().make_new();
2379        tmp_x_u.set(1.0);
2380        let mut tmp_x = self.curr_iv().x.make_new();
2381        nlp.px_l().mult_vector(1.0, &*tmp_x_l, 0.0, &mut *tmp_x);
2382        nlp.px_u().mult_vector(-1.0, &*tmp_x_u, 1.0, &mut *tmp_x);
2383        let mut d_x_l = nlp.x_l().make_new();
2384        nlp.px_l().trans_mult_vector(1.0, &*tmp_x, 0.0, &mut *d_x_l);
2385        let mut d_x_u = nlp.x_u().make_new();
2386        nlp.px_u()
2387            .trans_mult_vector(-1.0, &*tmp_x, 0.0, &mut *d_x_u);
2388
2389        let mut tmp_s_l = nlp.d_l().make_new();
2390        tmp_s_l.set(1.0);
2391        let mut tmp_s_u = nlp.d_u().make_new();
2392        tmp_s_u.set(1.0);
2393        let mut tmp_s = self.curr_iv().s.make_new();
2394        nlp.pd_l().mult_vector(1.0, &*tmp_s_l, 0.0, &mut *tmp_s);
2395        nlp.pd_u().mult_vector(-1.0, &*tmp_s_u, 1.0, &mut *tmp_s);
2396        let mut d_s_l = nlp.d_l().make_new();
2397        nlp.pd_l().trans_mult_vector(1.0, &*tmp_s, 0.0, &mut *d_s_l);
2398        let mut d_s_u = nlp.d_u().make_new();
2399        nlp.pd_u()
2400            .trans_mult_vector(-1.0, &*tmp_s, 0.0, &mut *d_s_u);
2401
2402        DampingIndicators {
2403            x_l: rc_from(d_x_l),
2404            x_u: rc_from(d_x_u),
2405            s_l: rc_from(d_s_l),
2406            s_u: rc_from(d_s_u),
2407        }
2408    }
2409
2410    /// `curr_grad_lag_x` plus the `kappa_d · μ · (Px_L · 1 − Px_U · 1)`
2411    /// damping term on singly-bounded primals — port of
2412    /// `IpIpoptCalculatedQuantities.cpp:2131-2180`. When `kappa_d == 0`
2413    /// returns the un-damped gradient.
2414    pub fn curr_grad_lag_with_damping_x(&self) -> Rc<dyn Vector> {
2415        if self.kappa_d == 0.0 {
2416            return self.curr_grad_lag_x();
2417        }
2418        let mu = self.data.borrow().curr_mu;
2419        let di = self.damping_indicators();
2420        let (d_x_l, d_x_u) = (di.x_l, di.x_u);
2421        let glx = self.curr_grad_lag_x();
2422        let mut tmp = glx.make_new();
2423        tmp.copy(&*glx);
2424        let nlp = self.nlp.borrow();
2425        nlp.px_l()
2426            .mult_vector(self.kappa_d * mu, &*d_x_l, 1.0, &mut *tmp);
2427        nlp.px_u()
2428            .mult_vector(-self.kappa_d * mu, &*d_x_u, 1.0, &mut *tmp);
2429        rc_from(tmp)
2430    }
2431
2432    pub fn curr_grad_lag_with_damping_s(&self) -> Rc<dyn Vector> {
2433        if self.kappa_d == 0.0 {
2434            return self.curr_grad_lag_s();
2435        }
2436        let mu = self.data.borrow().curr_mu;
2437        let di = self.damping_indicators();
2438        let (d_s_l, d_s_u) = (di.s_l, di.s_u);
2439        let gls = self.curr_grad_lag_s();
2440        let mut tmp = gls.make_new();
2441        tmp.copy(&*gls);
2442        let nlp = self.nlp.borrow();
2443        nlp.pd_l()
2444            .mult_vector(self.kappa_d * mu, &*d_s_l, 1.0, &mut *tmp);
2445        nlp.pd_u()
2446            .mult_vector(-self.kappa_d * mu, &*d_s_u, 1.0, &mut *tmp);
2447        rc_from(tmp)
2448    }
2449
2450    /// `kappa_d · (P_L · damping_l − P_U · damping_u)` in the full x
2451    /// space — port of `IpIpoptCalculatedQuantities.cpp::grad_kappa_times_damping_x`
2452    /// (lines 912-949). Unlike `curr_grad_lag_with_damping_x` this does
2453    /// NOT include `grad_lag_x` and is NOT scaled by `mu`; the centering
2454    /// RHS in the quality-function oracle multiplies the returned vector
2455    /// by `-avrg_compl` per upstream `IpQualityFunctionMuOracle.cpp:229`.
2456    pub fn grad_kappa_times_damping_x(&self) -> Rc<dyn Vector> {
2457        let mut tmp = self.curr_iv().x.make_new();
2458        tmp.set(0.0);
2459        if self.kappa_d > 0.0 {
2460            let di = self.damping_indicators();
2461            let nlp = self.nlp.borrow();
2462            nlp.px_l()
2463                .mult_vector(self.kappa_d, &*di.x_l, 0.0, &mut *tmp);
2464            nlp.px_u()
2465                .mult_vector(-self.kappa_d, &*di.x_u, 1.0, &mut *tmp);
2466        }
2467        rc_from(tmp)
2468    }
2469
2470    pub fn grad_kappa_times_damping_s(&self) -> Rc<dyn Vector> {
2471        let mut tmp = self.curr_iv().s.make_new();
2472        tmp.set(0.0);
2473        if self.kappa_d > 0.0 {
2474            let di = self.damping_indicators();
2475            let nlp = self.nlp.borrow();
2476            nlp.pd_l()
2477                .mult_vector(self.kappa_d, &*di.s_l, 0.0, &mut *tmp);
2478            nlp.pd_u()
2479                .mult_vector(-self.kappa_d, &*di.s_u, 1.0, &mut *tmp);
2480        }
2481        rc_from(tmp)
2482    }
2483
2484    // --------------------------------------------------------------
2485    // Affine (predictor) step helpers — port of upstream
2486    // `IpIpoptCalculatedQuantities.cpp:CurrAvrgCompl`/`AffMaxAlpha…`
2487    // used by the Mehrotra probing oracle and the quality-function
2488    // oracle's σ-search.
2489    // --------------------------------------------------------------
2490
2491    /// Max primal step that keeps `s + α · Δs > 0` for the four slack
2492    /// blocks (x_L, x_U, s_L, s_U), bounded by the fraction-to-the-
2493    /// boundary parameter `τ ∈ (0, 1]`. Mirrors
2494    /// `CalcFracToBound` against the projected step `P_L^T Δx`,
2495    /// `−P_U^T Δx`, `P_L^T Δs`, `−P_U^T Δs`.
2496    pub fn aff_step_alpha_primal_max(&self, delta_aff: &IteratesVector, tau: Number) -> Number {
2497        let nlp = self.nlp.borrow();
2498        let s_x_l = self.curr_slack_x_l();
2499        let s_x_u = self.curr_slack_x_u();
2500        let s_s_l = self.curr_slack_s_l();
2501        let s_s_u = self.curr_slack_s_u();
2502
2503        // Project Δx / Δs onto each bound subspace with the right sign.
2504        let mut step_x_l = s_x_l.make_new();
2505        nlp.px_l()
2506            .trans_mult_vector(1.0, &*delta_aff.x, 0.0, &mut *step_x_l);
2507        let mut step_x_u = s_x_u.make_new();
2508        nlp.px_u()
2509            .trans_mult_vector(-1.0, &*delta_aff.x, 0.0, &mut *step_x_u);
2510        let mut step_s_l = s_s_l.make_new();
2511        nlp.pd_l()
2512            .trans_mult_vector(1.0, &*delta_aff.s, 0.0, &mut *step_s_l);
2513        let mut step_s_u = s_s_u.make_new();
2514        nlp.pd_u()
2515            .trans_mult_vector(-1.0, &*delta_aff.s, 0.0, &mut *step_s_u);
2516
2517        s_x_l
2518            .frac_to_bound(&*step_x_l, tau)
2519            .min(s_x_u.frac_to_bound(&*step_x_u, tau))
2520            .min(s_s_l.frac_to_bound(&*step_s_l, tau))
2521            .min(s_s_u.frac_to_bound(&*step_s_u, tau))
2522    }
2523
2524    /// Max dual step that keeps `z + α · Δz > 0` (and same for v).
2525    pub fn aff_step_alpha_dual_max(&self, delta_aff: &IteratesVector, tau: Number) -> Number {
2526        let iv = self.curr_iv();
2527        iv.z_l
2528            .frac_to_bound(&*delta_aff.z_l, tau)
2529            .min(iv.z_u.frac_to_bound(&*delta_aff.z_u, tau))
2530            .min(iv.v_l.frac_to_bound(&*delta_aff.v_l, tau))
2531            .min(iv.v_u.frac_to_bound(&*delta_aff.v_u, tau))
2532    }
2533
2534    /// Predicted average complementarity after the affine step:
2535    /// `(1/N) · Σ (s + α_pri · Δs) · (z + α_du · Δz)` summed over the
2536    /// four bound blocks. Returns `0` when there are no bounds.
2537    pub fn aff_step_compl_avrg(
2538        &self,
2539        delta_aff: &IteratesVector,
2540        alpha_primal: Number,
2541        alpha_dual: Number,
2542    ) -> Number {
2543        let iv = self.curr_iv();
2544        let n = iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
2545        if n == 0 {
2546            return 0.0;
2547        }
2548        let nlp = self.nlp.borrow();
2549
2550        // s_X_L_aff = s_X_L + α_pri · P_L^T Δx
2551        let s_x_l = self.curr_slack_x_l();
2552        let mut s_x_l_aff = s_x_l.make_new();
2553        s_x_l_aff.copy(&*s_x_l);
2554        let mut tmp = s_x_l.make_new();
2555        nlp.px_l()
2556            .trans_mult_vector(1.0, &*delta_aff.x, 0.0, &mut *tmp);
2557        s_x_l_aff.axpy(alpha_primal, &*tmp);
2558        // z_L_aff = z_L + α_du · Δz_L
2559        let mut z_l_aff = iv.z_l.make_new();
2560        z_l_aff.copy(&*iv.z_l);
2561        z_l_aff.axpy(alpha_dual, &*delta_aff.z_l);
2562        let mut acc = s_x_l_aff.dot(&*z_l_aff);
2563
2564        // s_X_U_aff = s_X_U − α_pri · P_U^T Δx
2565        let s_x_u = self.curr_slack_x_u();
2566        let mut s_x_u_aff = s_x_u.make_new();
2567        s_x_u_aff.copy(&*s_x_u);
2568        let mut tmp = s_x_u.make_new();
2569        nlp.px_u()
2570            .trans_mult_vector(-1.0, &*delta_aff.x, 0.0, &mut *tmp);
2571        s_x_u_aff.axpy(alpha_primal, &*tmp);
2572        let mut z_u_aff = iv.z_u.make_new();
2573        z_u_aff.copy(&*iv.z_u);
2574        z_u_aff.axpy(alpha_dual, &*delta_aff.z_u);
2575        acc += s_x_u_aff.dot(&*z_u_aff);
2576
2577        // s_S_L_aff = s_S_L + α_pri · P_dL^T Δs
2578        let s_s_l = self.curr_slack_s_l();
2579        let mut s_s_l_aff = s_s_l.make_new();
2580        s_s_l_aff.copy(&*s_s_l);
2581        let mut tmp = s_s_l.make_new();
2582        nlp.pd_l()
2583            .trans_mult_vector(1.0, &*delta_aff.s, 0.0, &mut *tmp);
2584        s_s_l_aff.axpy(alpha_primal, &*tmp);
2585        let mut v_l_aff = iv.v_l.make_new();
2586        v_l_aff.copy(&*iv.v_l);
2587        v_l_aff.axpy(alpha_dual, &*delta_aff.v_l);
2588        acc += s_s_l_aff.dot(&*v_l_aff);
2589
2590        // s_S_U_aff = s_S_U − α_pri · P_dU^T Δs
2591        let s_s_u = self.curr_slack_s_u();
2592        let mut s_s_u_aff = s_s_u.make_new();
2593        s_s_u_aff.copy(&*s_s_u);
2594        let mut tmp = s_s_u.make_new();
2595        nlp.pd_u()
2596            .trans_mult_vector(-1.0, &*delta_aff.s, 0.0, &mut *tmp);
2597        s_s_u_aff.axpy(alpha_primal, &*tmp);
2598        let mut v_u_aff = iv.v_u.make_new();
2599        v_u_aff.copy(&*iv.v_u);
2600        v_u_aff.axpy(alpha_dual, &*delta_aff.v_u);
2601        acc += s_s_u_aff.dot(&*v_u_aff);
2602
2603        acc / Number::from(n)
2604    }
2605}
2606
2607/// Convenience handle. Mirrors upstream's `SmartPtr<CQ>` flow.
2608pub type IpoptCqHandle = Rc<RefCell<IpoptCalculatedQuantities>>;
2609
2610/// Bundle of damping indicators for the four bound spaces — kept
2611/// internal because `kappa_d == 0` makes them dead in the default
2612/// configuration.
2613struct DampingIndicators {
2614    x_l: Rc<dyn Vector>,
2615    x_u: Rc<dyn Vector>,
2616    s_l: Rc<dyn Vector>,
2617    s_u: Rc<dyn Vector>,
2618}
2619
2620#[cfg(test)]
2621mod tests {
2622    use super::*;
2623    use crate::ipopt_data::IpoptData;
2624    use crate::iterates_vector::IteratesVector;
2625    use pounce_common::types::Index;
2626    use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
2627    use pounce_linalg::expansion_matrix::{ExpansionMatrix, ExpansionMatrixSpace};
2628    use pounce_linalg::triplet::{GenTMatrix, GenTMatrixSpace};
2629    use std::rc::Rc as StdRc;
2630
2631    fn dvec(values: &[Number]) -> DenseVector {
2632        let space = DenseVectorSpace::new(values.len() as Index);
2633        let mut v = space.make_new_dense();
2634        v.values_mut().copy_from_slice(values);
2635        v
2636    }
2637
2638    fn rcv(values: &[Number]) -> Rc<dyn Vector> {
2639        StdRc::new(dvec(values))
2640    }
2641
2642    /// Mock IpoptNlp covering: 2 vars, 1 equality, 1 inequality.
2643    /// Bounds: x[0] ≥ 0, x[1] ≤ 5, d ≥ 1.
2644    /// f(x) = x[0]^2 + x[1]^2; ∇f = (2x[0], 2x[1])
2645    /// c(x) = x[0] + x[1] - 1
2646    /// d(x) = x[0]
2647    struct MockNlp {
2648        x_l: DenseVector,
2649        x_u: DenseVector,
2650        d_l: DenseVector,
2651        d_u: DenseVector,
2652        px_l: Rc<dyn Matrix>,
2653        px_u: Rc<dyn Matrix>,
2654        pd_l: Rc<dyn Matrix>,
2655        pd_u: Rc<dyn Matrix>,
2656        // NLP scaling factors. Identity by default; `with_scaling`
2657        // installs non-trivial ones to exercise the unscaled accessors.
2658        // (The mock does not actually apply these in `eval_*`; the tests
2659        // verify the unscaling *arithmetic*, not end-to-end scaling.)
2660        obj_scale: Number,
2661        c_scale: Option<Vec<Number>>,
2662        d_scale: Option<Vec<Number>>,
2663        // #292: inject a non-finite component into the gradient / constraint
2664        // Jacobian to exercise the finiteness guard in `curr_nlp_error`.
2665        nan_grad: bool,
2666        nan_jac_c: bool,
2667        empty_jac_c: bool,
2668        // gh#390: the declared equality RHS the c-block relative measure
2669        // divides by. `None` (the default) is the "not tracked" contract.
2670        c_rhs: Option<Vec<Number>>,
2671        // pounce#476: force `c(x)` to a fixed value so a test can isolate the
2672        // inequality block (the default `x0 + x1 - 1` is 4 at the fixture's
2673        // point, which dominates any d-block difference under a max-norm).
2674        c_override: Option<Number>,
2675    }
2676
2677    impl MockNlp {
2678        fn with_c(mut self, v: Number) -> Self {
2679            self.c_override = Some(v);
2680            self
2681        }
2682
2683        fn with_c_rhs(mut self, rhs: Option<Vec<Number>>) -> Self {
2684            self.c_rhs = rhs;
2685            self
2686        }
2687
2688        fn with_nan_grad(mut self) -> Self {
2689            self.nan_grad = true;
2690            self
2691        }
2692
2693        fn with_nan_jac_c(mut self) -> Self {
2694            self.nan_jac_c = true;
2695            self
2696        }
2697
2698        /// Every variable of the equality row fixed and substituted out, so
2699        /// the row reduces to the constant `0 = b` — what
2700        /// `IpoptCalculatedQuantities::row_noise_floor` calls a row no iterate
2701        /// can move.
2702        fn with_empty_jac_c(mut self) -> Self {
2703            self.empty_jac_c = true;
2704            self
2705        }
2706
2707        /// Re-declare the single `d` row as the box `[−mag, +mag]`, so every
2708        /// bound it has is of the chosen magnitude — the default fixture's
2709        /// lower bound of `1` would otherwise supply the magnitude by itself.
2710        /// `d(x) = x0 = 2` sits outside it, violating by `2 − mag`.
2711        fn with_d_box(mut self, mag: Number) -> Self {
2712            self.d_l = dvec(&[-mag]);
2713            self.d_u = dvec(&[mag]);
2714            self.pd_u = StdRc::new(ExpansionMatrix::new(ExpansionMatrixSpace::new(
2715                1,
2716                1,
2717                &[0],
2718                0,
2719            )));
2720            self
2721        }
2722
2723        fn with_scaling(
2724            mut self,
2725            obj_scale: Number,
2726            c_scale: Option<Vec<Number>>,
2727            d_scale: Option<Vec<Number>>,
2728        ) -> Self {
2729            self.obj_scale = obj_scale;
2730            self.c_scale = c_scale;
2731            self.d_scale = d_scale;
2732            self
2733        }
2734
2735        fn new() -> Self {
2736            // x_L holds finite lower bounds; here only x[0] has one (=0).
2737            let x_l = dvec(&[0.0]);
2738            // x_U holds finite upper bounds; here only x[1] has one (=5).
2739            let x_u = dvec(&[5.0]);
2740            // d has one finite lower bound (d ≥ 1) and no finite upper.
2741            let d_l = dvec(&[1.0]);
2742            let d_u = dvec(&[]);
2743
2744            let px_l_space = ExpansionMatrixSpace::new(2, 1, &[0], 0);
2745            let px_u_space = ExpansionMatrixSpace::new(2, 1, &[1], 0);
2746            let pd_l_space = ExpansionMatrixSpace::new(1, 1, &[0], 0);
2747            let pd_u_space = ExpansionMatrixSpace::new(1, 0, &[], 0);
2748
2749            Self {
2750                x_l,
2751                x_u,
2752                d_l,
2753                d_u,
2754                px_l: StdRc::new(ExpansionMatrix::new(px_l_space)),
2755                px_u: StdRc::new(ExpansionMatrix::new(px_u_space)),
2756                pd_l: StdRc::new(ExpansionMatrix::new(pd_l_space)),
2757                pd_u: StdRc::new(ExpansionMatrix::new(pd_u_space)),
2758                obj_scale: 1.0,
2759                c_scale: None,
2760                d_scale: None,
2761                nan_grad: false,
2762                nan_jac_c: false,
2763                empty_jac_c: false,
2764                c_rhs: None,
2765                c_override: None,
2766            }
2767        }
2768    }
2769
2770    impl crate::ipopt_nlp::Nlp for MockNlp {
2771        fn n(&self) -> Index {
2772            2
2773        }
2774        fn m_eq(&self) -> Index {
2775            1
2776        }
2777        fn m_ineq(&self) -> Index {
2778            1
2779        }
2780        fn eval_f(&mut self, x: &dyn Vector) -> Number {
2781            // f(x) = x[0]^2 + x[1]^2
2782            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
2783            xx.values()[0] * xx.values()[0] + xx.values()[1] * xx.values()[1]
2784        }
2785        fn eval_grad_f(&mut self, x: &dyn Vector, g: &mut dyn Vector) {
2786            // grad f = (2 x[0], 2 x[1])
2787            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
2788            let gg = g.as_any_mut().downcast_mut::<DenseVector>().unwrap();
2789            gg.values_mut()[0] = 2.0 * xx.values()[0];
2790            gg.values_mut()[1] = 2.0 * xx.values()[1];
2791            if self.nan_grad {
2792                gg.values_mut()[0] = Number::NAN;
2793            }
2794        }
2795        fn eval_c(&mut self, x: &dyn Vector, c: &mut dyn Vector) {
2796            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
2797            let cc = c.as_any_mut().downcast_mut::<DenseVector>().unwrap();
2798            cc.values_mut()[0] = match self.c_override {
2799                Some(v) => v,
2800                None => xx.values()[0] + xx.values()[1] - 1.0,
2801            };
2802        }
2803        fn eval_d(&mut self, x: &dyn Vector, d: &mut dyn Vector) {
2804            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
2805            let dd = d.as_any_mut().downcast_mut::<DenseVector>().unwrap();
2806            dd.values_mut()[0] = xx.values()[0];
2807        }
2808        fn eval_jac_c(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
2809            if self.empty_jac_c {
2810                // No entries at all: the row carries no variable.
2811                let space = GenTMatrixSpace::new(1, 2, vec![], vec![]);
2812                let mut jac = GenTMatrix::new(space);
2813                jac.set_values(&[]);
2814                return StdRc::new(jac);
2815            }
2816            // c(x) = x0 + x1 - 1 → Jc = [1, 1] (1×2), nonzeros (1,1),(1,2).
2817            let space = GenTMatrixSpace::new(1, 2, vec![1, 1], vec![1, 2]);
2818            let mut jac = GenTMatrix::new(space);
2819            if self.nan_jac_c {
2820                jac.set_values(&[Number::NAN, 1.0]);
2821            } else {
2822                jac.set_values(&[1.0, 1.0]);
2823            }
2824            StdRc::new(jac)
2825        }
2826        fn eval_jac_d(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
2827            // d(x) = x0 → Jd = [1, 0] (1×2), single nonzero (1,1).
2828            let space = GenTMatrixSpace::new(1, 2, vec![1], vec![1]);
2829            let mut jac = GenTMatrix::new(space);
2830            jac.set_values(&[1.0]);
2831            StdRc::new(jac)
2832        }
2833        fn eval_h(
2834            &mut self,
2835            _x: &dyn Vector,
2836            _obj_factor: Number,
2837            _y_c: &dyn Vector,
2838            _y_d: &dyn Vector,
2839        ) -> Rc<dyn SymMatrix> {
2840            unimplemented!()
2841        }
2842    }
2843
2844    impl IpoptNlp for MockNlp {
2845        fn x_l(&self) -> &dyn Vector {
2846            &self.x_l
2847        }
2848        fn x_u(&self) -> &dyn Vector {
2849            &self.x_u
2850        }
2851        fn d_l(&self) -> &dyn Vector {
2852            &self.d_l
2853        }
2854        fn d_u(&self) -> &dyn Vector {
2855            &self.d_u
2856        }
2857        fn px_l(&self) -> Rc<dyn Matrix> {
2858            self.px_l.clone()
2859        }
2860        fn px_u(&self) -> Rc<dyn Matrix> {
2861            self.px_u.clone()
2862        }
2863        fn pd_l(&self) -> Rc<dyn Matrix> {
2864            self.pd_l.clone()
2865        }
2866        fn pd_u(&self) -> Rc<dyn Matrix> {
2867            self.pd_u.clone()
2868        }
2869        fn obj_scaling_factor(&self) -> Number {
2870            self.obj_scale
2871        }
2872        fn c_scale_vec(&self) -> Option<Vec<Number>> {
2873            self.c_scale.clone()
2874        }
2875        fn d_scale_vec(&self) -> Option<Vec<Number>> {
2876            self.d_scale.clone()
2877        }
2878        fn declared_c_rhs(&self) -> Option<Vec<Number>> {
2879            self.c_rhs.clone()
2880        }
2881    }
2882
2883    fn fixture() -> IpoptCalculatedQuantities {
2884        fixture_with(MockNlp::new())
2885    }
2886
2887    fn fixture_with(nlp: MockNlp) -> IpoptCalculatedQuantities {
2888        fixture_with_x(nlp, &[2.0, 3.0])
2889    }
2890
2891    fn fixture_with_x(nlp: MockNlp, x: &[Number]) -> IpoptCalculatedQuantities {
2892        let mut data = IpoptData::new();
2893        data.curr_mu = 0.1;
2894        // Iterate: x as given (2, 3 by default); s = (4); y_c = (1); y_d = (1);
2895        // z_L = (0.5) [bound on x[0]], z_U = (0.7) [bound on x[1]],
2896        // v_L = (0.3), v_U = ().
2897        let iv = IteratesVector::new(
2898            rcv(x),
2899            rcv(&[4.0]),
2900            rcv(&[1.0]),
2901            rcv(&[1.0]),
2902            rcv(&[0.5]),
2903            rcv(&[0.7]),
2904            rcv(&[0.3]),
2905            rcv(&[]),
2906        );
2907        data.set_curr(iv);
2908        let data_handle = StdRc::new(RefCell::new(data));
2909        let nlp: StdRc<RefCell<dyn IpoptNlp>> = StdRc::new(RefCell::new(nlp));
2910        let mut cq = IpoptCalculatedQuantities::new(data_handle, nlp);
2911        // Disable damping for clean unit-test expectations.
2912        cq.kappa_d = 0.0;
2913        cq
2914    }
2915
2916    fn dense_vals(v: &Rc<dyn Vector>) -> Vec<Number> {
2917        v.as_any()
2918            .downcast_ref::<DenseVector>()
2919            .unwrap()
2920            .values()
2921            .to_vec()
2922    }
2923
2924    #[test]
2925    fn slack_x_lower_is_x0_minus_x_l() {
2926        // P_L^T x = [x[0]] = [2]; x_L = [0]; slack = 2 - 0 = 2.
2927        let cq = fixture();
2928        assert_eq!(dense_vals(&cq.curr_slack_x_l()), vec![2.0]);
2929    }
2930
2931    #[test]
2932    fn slack_x_upper_is_x_u_minus_x1() {
2933        // x_U = [5]; P_U^T x = [3]; slack = 5 - 3 = 2.
2934        let cq = fixture();
2935        assert_eq!(dense_vals(&cq.curr_slack_x_u()), vec![2.0]);
2936    }
2937
2938    #[test]
2939    fn slack_s_lower() {
2940        // d_L = [1]; P_L^T s = [4]; slack = 4 - 1 = 3.
2941        let cq = fixture();
2942        assert_eq!(dense_vals(&cq.curr_slack_s_l()), vec![3.0]);
2943    }
2944
2945    #[test]
2946    fn grad_f_is_twice_x() {
2947        let cq = fixture();
2948        assert_eq!(dense_vals(&cq.curr_grad_f()), vec![4.0, 6.0]);
2949    }
2950
2951    #[test]
2952    fn compl_x_l_is_slack_times_z() {
2953        // slack_x_L = [2]; z_L = [0.5]; compl = [1.0]
2954        let cq = fixture();
2955        assert_eq!(dense_vals(&cq.curr_compl_x_l()), vec![1.0]);
2956    }
2957
2958    #[test]
2959    fn relaxed_compl_x_l_subtracts_mu() {
2960        // compl = 1.0; mu = 0.1; relaxed = 0.9.
2961        let cq = fixture();
2962        assert!((dense_vals(&cq.curr_relaxed_compl_x_l())[0] - 0.9).abs() < 1e-15);
2963    }
2964
2965    #[test]
2966    fn sigma_x_routes_z_over_slack_through_p() {
2967        // P_L lifts (z_L/s_L) = (0.5/2 = 0.25) into x[0] slot.
2968        // P_U lifts (z_U/s_U) = (0.7/2 = 0.35) into x[1] slot.
2969        // sigma = (0.25, 0.35)
2970        let cq = fixture();
2971        let s = dense_vals(&cq.curr_sigma_x());
2972        assert!((s[0] - 0.25).abs() < 1e-15);
2973        assert!((s[1] - 0.35).abs() < 1e-15);
2974    }
2975
2976    #[test]
2977    fn sigma_s_lower_only() {
2978        // P_L lifts (v_L/s_L) = (0.3/3 = 0.1).
2979        let cq = fixture();
2980        let s = dense_vals(&cq.curr_sigma_s());
2981        assert!((s[0] - 0.1).abs() < 1e-15);
2982    }
2983
2984    #[test]
2985    fn avrg_compl_averages_over_active_bounds() {
2986        // z_L·s_L + z_U·s_U + v_L·s_s_L + v_U·s_s_U
2987        // = 0.5*2 + 0.7*2 + 0.3*3 + 0
2988        // = 1 + 1.4 + 0.9 = 3.3
2989        // N = 1 + 1 + 1 + 0 = 3 → 1.1
2990        let cq = fixture();
2991        assert!((cq.curr_avrg_compl() - 1.1).abs() < 1e-15);
2992    }
2993
2994    #[test]
2995    fn complementarity_min_takes_min_over_active_pairs() {
2996        // compl entries: z_L·s_L=1.0, z_U·s_U=1.4, v_L·s_s_L=0.9.
2997        // v_U is empty (skipped). Min = 0.9.
2998        let cq = fixture();
2999        assert!((cq.curr_complementarity_min() - 0.9).abs() < 1e-15);
3000    }
3001
3002    #[test]
3003    fn centrality_measure_is_min_over_avrg() {
3004        // min/avrg = 0.9 / 1.1 ≈ 0.81818…
3005        let cq = fixture();
3006        let xi = cq.curr_centrality_measure();
3007        assert!((xi - 0.9 / 1.1).abs() < 1e-15);
3008    }
3009
3010    #[test]
3011    fn curr_f_evaluates_objective() {
3012        // f(x) = x[0]^2 + x[1]^2 at x = (2, 3) → 4 + 9 = 13.
3013        let cq = fixture();
3014        assert!((cq.curr_f() - 13.0).abs() < 1e-15);
3015    }
3016
3017    #[test]
3018    fn curr_barrier_obj_subtracts_mu_log_slacks() {
3019        // f = 13; slacks = (s_x_L=2, s_x_U=2, s_s_L=3, s_s_U=∅).
3020        // log_sum = ln 2 + ln 2 + ln 3 + 0 = 2 ln 2 + ln 3.
3021        // mu = 0.1 → phi = 13 - 0.1*(2 ln 2 + ln 3).
3022        let cq = fixture();
3023        let expected = 13.0 - 0.1 * (2.0 * 2.0_f64.ln() + 3.0_f64.ln());
3024        assert!((cq.curr_barrier_obj() - expected).abs() < 1e-13);
3025    }
3026
3027    /// pounce#476. `inf_pr_output = original` (upstream's default) must report
3028    /// the violation of the **original** rows, not of the internal slack
3029    /// reformulation. The fixture is exactly the case that made the two
3030    /// diverge on Mittelmann's `robot_a`: `d(x) = 2` against `d >= 1` — the
3031    /// original row is *satisfied*, so the original-NLP violation is 0 — while
3032    /// the slack has drifted to `s = 4`, so `|d − s| = 2` and the internal
3033    /// measure reads 2. Reporting the internal number made feasible iterates
3034    /// look badly infeasible (2.79e4 where Ipopt printed 0.00e+00).
3035    ///
3036    /// The equality row is genuinely violated (`c = 4`), and both measures
3037    /// must still see it — the fix must not swallow real infeasibility.
3038    #[test]
3039    fn original_nlp_violation_ignores_slack_drift_but_not_a_violated_row() {
3040        let cq = fixture();
3041        // Internal: max(|c|, |d − s|) = max(4, 2) = 4.
3042        assert_eq!(cq.curr_primal_infeasibility_max(), 4.0);
3043        // Original: max(|c|, dist(d, [d_l, d_u])) = max(4, 0) = 4 — the
3044        // equality violation survives, the slack drift does not contribute.
3045        assert_eq!(cq.curr_unscaled_nlp_constraint_violation_max(), 4.0);
3046    }
3047
3048    /// The other half of pounce#476: with the equality block satisfied, the
3049    /// two measures disagree outright — internal still sees the slack drift,
3050    /// original sees a feasible point.
3051    #[test]
3052    fn original_nlp_violation_is_zero_when_only_the_slack_has_drifted() {
3053        let cq = fixture_with(MockNlp::new().with_c(0.0));
3054        assert_eq!(cq.curr_primal_infeasibility_max(), 2.0);
3055        assert_eq!(cq.curr_unscaled_nlp_constraint_violation_max(), 0.0);
3056    }
3057
3058    /// …and the `inf_pr` column must actually be *wired* to the right one.
3059    /// The two tests above pass whichever accessor `OrigIterationOutput`
3060    /// picks, so without this the exact regression — a one-line match arm
3061    /// reaching for `curr_primal_infeasibility_max` — goes unnoticed.
3062    /// `InfPrTag::Original` is upstream's default, so this is what the column
3063    /// prints unless the user asks for `internal`.
3064    #[test]
3065    fn inf_pr_column_prints_the_original_violation_under_the_default_tag() {
3066        use crate::ipopt_data::IpoptData;
3067        use crate::output::orig::{InfPrTag, OrigIterationOutput};
3068        use crate::output::r#trait::IterationOutput;
3069
3070        // Slack drift only: internal reads 2, original reads 0.
3071        let cq: IpoptCqHandle = Rc::new(RefCell::new(fixture_with(MockNlp::new().with_c(0.0))));
3072        let data: IpoptDataHandle = Rc::new(RefCell::new(IpoptData::default()));
3073
3074        let field = |tag| {
3075            let mut out = OrigIterationOutput::new();
3076            out.inf_pr_output = tag;
3077            // Column 2 of the row is `inf_pr` (iter, objective, inf_pr, …).
3078            out.format_row(&data, &cq)
3079                .split_whitespace()
3080                .nth(2)
3081                .unwrap()
3082                .to_string()
3083        };
3084        assert_eq!(field(InfPrTag::Original), "0.00e+00");
3085        assert_eq!(field(InfPrTag::Internal), "2.00e+00");
3086    }
3087
3088    #[test]
3089    fn curr_constraint_violation_is_one_norm() {
3090        // c(x) = x[0]+x[1]-1 = 4 ⇒ |c| = 4.
3091        // d(x)=x[0]=2; s=4 ⇒ d-s = -2 ⇒ |d-s| = 2.
3092        // theta = 4 + 2 = 6.
3093        let cq = fixture();
3094        assert!((cq.curr_constraint_violation() - 6.0).abs() < 1e-13);
3095    }
3096
3097    /// gh#390. The fixture's equality row is `x0 + x1 == 1` at `x = (2, 3)`,
3098    /// so `c = 4`. Judged against a declared RHS of 2 that is a 200% violation
3099    /// — and it is 200% however the row is written, which is the point.
3100    #[test]
3101    fn relative_c_infeasibility_is_residual_over_declared_rhs() {
3102        let cq = fixture_with(MockNlp::new().with_c_rhs(Some(vec![2.0])));
3103        assert_eq!(cq.relative_c_infeasibility_max(), 2.0);
3104        // The fixture's inequality row (`d = 2` against `d >= 1`) is satisfied,
3105        // so the combined measure is the equality block's verdict.
3106        assert_eq!(cq.relative_d_infeasibility_max(), 0.0);
3107        assert_eq!(cq.curr_relative_primal_infeasibility_max(), 2.0);
3108    }
3109
3110    /// An NLP that does not track the pre-fold RHS (the trait default, e.g.
3111    /// the restoration NLP) must abstain rather than invent a magnitude.
3112    #[test]
3113    fn relative_c_infeasibility_abstains_without_declared_rhs() {
3114        let cq = fixture();
3115        assert_eq!(cq.relative_c_infeasibility_max(), 0.0);
3116        assert_eq!(cq.curr_relative_primal_infeasibility_max(), 0.0);
3117    }
3118
3119    /// A homogeneous row (`g(x) == 0`) has no declared magnitude and needs
3120    /// none — `s·g(x) == 0` is the same row at every `s`. Dividing by its zero
3121    /// RHS would report every float-noise residual as an infinite violation.
3122    #[test]
3123    fn relative_c_infeasibility_abstains_on_homogeneous_row() {
3124        let cq = fixture_with(MockNlp::new().with_c_rhs(Some(vec![0.0])));
3125        assert_eq!(cq.relative_c_infeasibility_max(), 0.0);
3126    }
3127
3128    /// An unjudgeable row must not fabricate a relative verdict.
3129    #[test]
3130    fn relative_c_infeasibility_abstains_on_non_finite_rhs() {
3131        let cq = fixture_with(MockNlp::new().with_c_rhs(Some(vec![Number::INFINITY])));
3132        assert_eq!(cq.relative_c_infeasibility_max(), 0.0);
3133        let cq = fixture_with(MockNlp::new().with_c_rhs(Some(vec![Number::NAN])));
3134        assert_eq!(cq.relative_c_infeasibility_max(), 0.0);
3135    }
3136
3137    /// gh #446. "Homogeneous" has to be judged numerically. The fixture's row
3138    /// is `x0 + x1 == b` at `x = (2, 3)`, so its noise floor is
3139    /// `ROW_NOISE_KAPPA · eps · 1 · 3 ≈ 4.3e-14`: an RHS under that is
3140    /// rounding residue — a converter writing `2^-53` where the model says
3141    /// `0` — and the row must abstain exactly as a declared zero does. Above
3142    /// the floor the RHS is real data and is judged, however small.
3143    #[test]
3144    fn relative_c_infeasibility_abstains_on_rhs_below_the_row_noise_floor() {
3145        let floor = ROW_NOISE_KAPPA * Number::EPSILON * 3.0;
3146        // The QSCSD1 value: an RHS of exactly one machine epsilon.
3147        let cq = fixture_with(MockNlp::new().with_c_rhs(Some(vec![Number::EPSILON])));
3148        assert!(Number::EPSILON < floor);
3149        assert_eq!(cq.relative_c_infeasibility_max(), 0.0);
3150        // Just above the floor the row still carries a magnitude, and a
3151        // residual of 4 against it is judged on its merits.
3152        let rhs = 2.0 * floor;
3153        let cq = fixture_with(MockNlp::new().with_c_rhs(Some(vec![rhs])));
3154        assert_eq!(cq.relative_c_infeasibility_max(), 4.0 / rhs);
3155    }
3156
3157    /// gh #446. Every variable of the row fixed and substituted out leaves
3158    /// `0 = b`, which no iterate can move — a statement about the model, for
3159    /// presolve to certify, not a residual to judge an iterate by. QPILOTNO's
3160    /// row 150 reduces to `0 = −2.22e-16` this way and pinned the relative
3161    /// measure at 100% for the entire run.
3162    #[test]
3163    fn relative_c_infeasibility_abstains_on_a_row_no_iterate_can_move() {
3164        let cq = fixture_with(
3165            MockNlp::new()
3166                .with_empty_jac_c()
3167                .with_c_rhs(Some(vec![Number::EPSILON])),
3168        );
3169        assert_eq!(cq.relative_c_infeasibility_max(), 0.0);
3170        // Not a licence to ignore a real one: the absolute `constr_viol_tol`
3171        // arm still sees the row, and it is what governs here.
3172        assert_eq!(cq.curr_primal_infeasibility_max(), 4.0);
3173    }
3174
3175    /// gh #446. The inequality block draws its magnitude from the declared
3176    /// bounds, and needs the same numeric reading of "zero" — QPILOTNO carries
3177    /// 43 bounds at `1e-17`–`1e-15`. `d(x) = x0 = 2` against an upper bound
3178    /// under the row's noise floor is 2e14 times its magnitude by the old
3179    /// arithmetic, and unjudgeable by the new.
3180    #[test]
3181    fn relative_d_infeasibility_abstains_on_bound_below_the_row_noise_floor() {
3182        let floor = ROW_NOISE_KAPPA * Number::EPSILON * 3.0;
3183        let cq = fixture_with(MockNlp::new().with_d_box(Number::EPSILON));
3184        assert_eq!(cq.relative_d_infeasibility_max(), 0.0);
3185        // A bound above the floor is real, and `d = 2` violates it hugely.
3186        let bound = 2.0 * floor;
3187        let cq = fixture_with(MockNlp::new().with_d_box(bound));
3188        assert_eq!(cq.relative_d_infeasibility_max(), (2.0 - bound) / bound);
3189    }
3190
3191    /// The floor tracks `‖x‖_∞` **deliberately**, and this pins it. `x` is one
3192    /// vector produced by a linear solve with norm-wise backward error, so a
3193    /// large variable anywhere really does coarsen how finely every other
3194    /// component can be placed — and a declared magnitude finer than that is a
3195    /// target no iterate could hit. The per-row alternative, `Σ_j |a_ij x_j|`
3196    /// via `|J|·|x|`, looks more precise and measures the wrong thing (a row's
3197    /// *evaluation* error, not what limits its residual); it was implemented
3198    /// and it regressed QETAMACR, QSCORPIO and QPILOTNO of gh #446's 15. Re-run
3199    /// those three before changing this.
3200    #[test]
3201    fn row_noise_floor_tracks_the_iterate_norm() {
3202        // `d(x) = x0` against a declared box of ±1e-9.
3203        let bound = 1e-9;
3204        // At ‖x‖_∞ = 3 the floor is ~4.3e-14: the bound is real data, judged.
3205        let cq = fixture_with(MockNlp::new().with_d_box(bound));
3206        assert_eq!(cq.relative_d_infeasibility_max(), (2.0 - bound) / bound);
3207        // At ‖x‖_∞ = 1e8 the floor is ~1.4e-6 and the same bound is finer than
3208        // the iterate can be resolved, so the row abstains.
3209        let cq = fixture_with_x(MockNlp::new().with_d_box(bound), &[2.0, 1e8]);
3210        assert_eq!(cq.relative_d_infeasibility_max(), 0.0);
3211    }
3212
3213    /// A row-count mismatch means the RHS does not describe this `c` block;
3214    /// pairing them up anyway would judge rows against other rows' magnitudes.
3215    #[test]
3216    fn relative_c_infeasibility_abstains_on_length_mismatch() {
3217        let cq = fixture_with(MockNlp::new().with_c_rhs(Some(vec![2.0, 2.0])));
3218        assert_eq!(cq.relative_c_infeasibility_max(), 0.0);
3219    }
3220
3221    #[test]
3222    fn grad_barrier_obj_x_subtracts_mu_inv_slack() {
3223        // grad_f = (4, 6).
3224        // P_L lifts -mu*(1/s_x_L) = -0.1*(1/2)=-0.05 into x[0].
3225        // P_U lifts +mu*(1/s_x_U) = +0.1*(1/2)=+0.05 into x[1].
3226        // result = (4 - 0.05, 6 + 0.05) = (3.95, 6.05).
3227        let cq = fixture();
3228        let g = dense_vals(&cq.curr_grad_barrier_obj_x());
3229        assert!((g[0] - 3.95).abs() < 1e-13);
3230        assert!((g[1] - 6.05).abs() < 1e-13);
3231    }
3232
3233    #[test]
3234    fn grad_lag_s_is_minus_y_d_minus_pl_v_l_plus_pu_v_u() {
3235        // tmp = P_U v_U = (zero-dim contrib) → 0
3236        // tmp -= P_L v_L → tmp = -[0.3]
3237        // tmp -= y_d = -[0.3] - [1.0] = [-1.3]
3238        let cq = fixture();
3239        assert!((dense_vals(&cq.curr_grad_lag_s())[0] + 1.3).abs() < 1e-15);
3240    }
3241
3242    fn zero_iv_like(iv: &IteratesVector) -> IteratesVector {
3243        // Materialize explicit zeros for every component so the
3244        // affine-step tests can compose direct-sum updates.
3245        IteratesVector::new(
3246            rcv(&vec![0.0; iv.x.dim() as usize]),
3247            rcv(&vec![0.0; iv.s.dim() as usize]),
3248            rcv(&vec![0.0; iv.y_c.dim() as usize]),
3249            rcv(&vec![0.0; iv.y_d.dim() as usize]),
3250            rcv(&vec![0.0; iv.z_l.dim() as usize]),
3251            rcv(&vec![0.0; iv.z_u.dim() as usize]),
3252            rcv(&vec![0.0; iv.v_l.dim() as usize]),
3253            rcv(&vec![0.0; iv.v_u.dim() as usize]),
3254        )
3255    }
3256
3257    #[test]
3258    fn aff_step_compl_avrg_with_zero_step_matches_curr_avrg_compl() {
3259        // Δ_aff = 0 ⇒ predicted compl ≡ current compl.
3260        // s_X_L · z_L = 2·0.5=1, s_X_U·z_U=2·0.7=1.4, s_S_L·v_L=3·0.3=0.9.
3261        // Total = 3.3; N = 3 (z_l + z_u + v_l, v_u empty); avrg = 1.1.
3262        let cq = fixture();
3263        let iv = cq.curr_iv();
3264        let zero = zero_iv_like(&iv);
3265        let m = cq.aff_step_compl_avrg(&zero, 1.0, 1.0);
3266        assert!((m - 1.1).abs() < 1e-13);
3267        assert!((cq.curr_avrg_compl() - 1.1).abs() < 1e-13);
3268    }
3269
3270    #[test]
3271    fn aff_step_compl_avrg_responds_to_primal_step() {
3272        // Δ_aff.x = (1, 0), α_pri = 1, others = 0.
3273        // s_X_L_aff = 2 + 1·1 = 3; s_X_U_aff = 2 (P_U^T·dx = 0); s_S_L_aff = 3.
3274        // (3·0.5 + 2·0.7 + 3·0.3) / 3 = (1.5 + 1.4 + 0.9) / 3 = 1.2667.
3275        let cq = fixture();
3276        let iv = cq.curr_iv();
3277        let mut z = zero_iv_like(&iv);
3278        z.x = rcv(&[1.0, 0.0]);
3279        let m = cq.aff_step_compl_avrg(&z, 1.0, 1.0);
3280        assert!((m - 1.2666666666666666).abs() < 1e-13);
3281    }
3282
3283    #[test]
3284    fn aff_step_alpha_primal_truncates_to_x_lower_bound() {
3285        // Δ_aff.x = (-3, 0); s_X_L = 2; tau = 1 ⇒ α_max = 2/3.
3286        let cq = fixture();
3287        let iv = cq.curr_iv();
3288        let mut z = zero_iv_like(&iv);
3289        z.x = rcv(&[-3.0, 0.0]);
3290        let a = cq.aff_step_alpha_primal_max(&z, 1.0);
3291        assert!((a - 2.0 / 3.0).abs() < 1e-13);
3292    }
3293
3294    #[test]
3295    fn aff_step_alpha_dual_truncates_to_z_lower_bound() {
3296        // Δ_aff.z_L = (-1); z_L = 0.5; tau = 1 ⇒ α_max = 0.5.
3297        let cq = fixture();
3298        let iv = cq.curr_iv();
3299        let mut z = zero_iv_like(&iv);
3300        z.z_l = rcv(&[-1.0]);
3301        let a = cq.aff_step_alpha_dual_max(&z, 1.0);
3302        assert!((a - 0.5).abs() < 1e-13);
3303    }
3304
3305    #[test]
3306    fn grad_barr_t_delta_dots_barrier_grads_with_step() {
3307        // ∇_x φ = (3.95, 6.05); ∇_s φ = (-mu/s_s_L) = -0.1/3 ≈ -0.03333…
3308        // δx = (1, 2); δs = (3): result = 3.95·1 + 6.05·2 + (-0.0333…)·3
3309        //                              = 3.95 + 12.10 − 0.1 = 15.95.
3310        let cq = fixture();
3311        let dx = dvec(&[1.0, 2.0]);
3312        let ds = dvec(&[3.0]);
3313        let r = cq.curr_grad_barr_t_delta(&dx, &ds);
3314        let expected = 3.95 + 12.10 - 0.1;
3315        assert!((r - expected).abs() < 1e-13, "r = {r}");
3316    }
3317
3318    #[test]
3319    fn dwd_with_no_w_collapses_to_sigma_quadratic() {
3320        // W is None in the fixture (no Hessian seeded), perts default to 0.
3321        // σ_x = (0.25, 0.35); σ_s = (0.1).
3322        // δx = (2, -1); δs = (3) ⇒ dWd = 0.25·4 + 0.35·1 + 0.1·9
3323        //                              = 1.00 + 0.35 + 0.90 = 2.25.
3324        let cq = fixture();
3325        let dx = dvec(&[2.0, -1.0]);
3326        let ds = dvec(&[3.0]);
3327        let r = cq.curr_dwd(&dx, &ds);
3328        assert!((r - 2.25).abs() < 1e-13, "r = {r}");
3329    }
3330
3331    #[test]
3332    fn dwd_includes_pd_perturbations() {
3333        // Without perts: dWd = 0.25·4 + 0.35·1 + 0.1·9 = 2.25.
3334        // δ_pert_x = 0.5, δ_pert_s = 0.25:
3335        //   add δ_pert_x · ‖δx‖² + δ_pert_s · ‖δs‖²
3336        //     = 0.5·(4+1) + 0.25·9 = 2.5 + 2.25 = 4.75.
3337        // Total = 7.00.
3338        let cq = fixture();
3339        {
3340            let mut d = cq.data.borrow_mut();
3341            d.perturbations.delta_x = 0.5;
3342            d.perturbations.delta_s = 0.25;
3343        }
3344        let dx = dvec(&[2.0, -1.0]);
3345        let ds = dvec(&[3.0]);
3346        let r = cq.curr_dwd(&dx, &ds);
3347        assert!((r - 7.00).abs() < 1e-13, "r = {r}");
3348    }
3349
3350    // ---- #292: NaN gradient / Jacobian must not launder to a finite KKT error
3351
3352    #[test]
3353    fn nlp_error_is_finite_for_a_finite_iterate() {
3354        // Baseline: the well-formed fixture produces a finite, positive KKT
3355        // error (this iterate is not a KKT point). The finiteness guard added
3356        // for #292 must not perturb this normal path.
3357        let cq = fixture();
3358        let err = cq.curr_nlp_error();
3359        assert!(err.is_finite() && err > 0.0, "err = {err}");
3360    }
3361
3362    #[test]
3363    fn nlp_error_is_non_finite_when_gradient_has_nan() {
3364        // A NaN gradient component reaches ∇_x L, whose max-norm (`amax`)
3365        // silently drops NaN and would launder the dual infeasibility to a
3366        // finite value → bogus `Solve_Succeeded` (#292). `curr_nlp_error` must
3367        // instead surface a non-finite error so the caller's
3368        // `!nlp_err.is_finite()` guard fires `Invalid_Number_Detected`.
3369        let cq = fixture_with(MockNlp::new().with_nan_grad());
3370        assert!(
3371            !cq.curr_nlp_error().is_finite(),
3372            "NaN gradient laundered to finite KKT error: {}",
3373            cq.curr_nlp_error()
3374        );
3375    }
3376
3377    #[test]
3378    fn nlp_error_is_non_finite_when_constraint_jacobian_has_nan() {
3379        // A NaN in the constraint Jacobian enters ∇_x L through the Jᵀy term
3380        // and is likewise laundered by `amax` on the fixture's nonzero
3381        // multipliers. Must read as a non-finite KKT error, not `Optimal`.
3382        let cq = fixture_with(MockNlp::new().with_nan_jac_c());
3383        assert!(
3384            !cq.curr_nlp_error().is_finite(),
3385            "NaN constraint Jacobian laundered to finite KKT error: {}",
3386            cq.curr_nlp_error()
3387        );
3388    }
3389
3390    // ---- Unscaled (user-space) KKT residuals — pounce#173 -------------
3391
3392    #[test]
3393    fn unscaled_dual_inf_is_scaled_over_df() {
3394        // df = 2: every Lagrangian-gradient term carries the objective
3395        // factor, so the unscaled dual infeasibility is the scaled one
3396        // divided by df.
3397        let cq = fixture_with(MockNlp::new().with_scaling(2.0, None, None));
3398        let scaled = cq.curr_dual_infeasibility_max();
3399        let unscaled = cq.curr_unscaled_dual_infeasibility_max();
3400        assert!(scaled > 0.0, "fixture should have nonzero dual inf");
3401        assert!(
3402            (unscaled - scaled / 2.0).abs() < 1e-12,
3403            "unscaled {unscaled} != scaled/df {}",
3404            scaled / 2.0
3405        );
3406    }
3407
3408    /// gh #532. The dual *scale* is the largest single term `∇L` is assembled
3409    /// from, so the strict gate can ask what fraction of those terms failed to
3410    /// cancel instead of comparing a residual against an absolute constant.
3411    #[test]
3412    fn dual_inf_scale_is_the_largest_lagrangian_term() {
3413        // Fixture at x = (2, 3): ∇f = (4, 6); J_cᵀ y_c = (1, 1); J_dᵀ y_d =
3414        // (1, 0); y_d = 1; P_L z_L = 0.5; P_U z_U = 0.7; P_L v_L = 0.3; v_U is
3415        // empty. The largest is ‖∇f‖_∞ = 6.
3416        let cq = fixture();
3417        assert_eq!(cq.curr_dual_infeasibility_scale_max(), 6.0);
3418        // No scaling → the unscaled accessor is the identity, as for every
3419        // other residual on the common path.
3420        assert_eq!(
3421            cq.curr_unscaled_dual_infeasibility_scale_max(),
3422            cq.curr_dual_infeasibility_scale_max()
3423        );
3424    }
3425
3426    /// The scale unscales exactly as the residual it is the scale of: every
3427    /// term of the scaled Lagrangian gradient carries `df`, so both are the
3428    /// scaled value over `|df|`. If the two ever divided differently the ratio
3429    /// the strict gate tests would silently pick up a factor of `df`.
3430    #[test]
3431    fn unscaled_dual_inf_scale_is_scaled_over_df() {
3432        let cq = fixture_with(MockNlp::new().with_scaling(2.0, None, None));
3433        assert_eq!(cq.curr_unscaled_dual_infeasibility_scale_max(), 3.0);
3434        // A negative factor is the documented way to pose a maximization; a
3435        // max-norm has no business coming back negative (the sign trap that
3436        // defeated the unscaled dual residual gate).
3437        let neg = fixture_with(MockNlp::new().with_scaling(-2.0, None, None));
3438        assert_eq!(neg.curr_unscaled_dual_infeasibility_scale_max(), 3.0);
3439    }
3440
3441    #[test]
3442    fn unscaled_residuals_are_identity_without_scaling() {
3443        // df = 1, no row scaling → unscaled accessors return exactly the
3444        // scaled values (the common no-scaling path).
3445        let cq = fixture();
3446        assert_eq!(
3447            cq.curr_unscaled_dual_infeasibility_max(),
3448            cq.curr_dual_infeasibility_max()
3449        );
3450        assert_eq!(
3451            cq.curr_unscaled_complementarity_max(),
3452            cq.curr_complementarity_max()
3453        );
3454        assert_eq!(
3455            cq.curr_unscaled_primal_infeasibility_max(),
3456            cq.curr_primal_infeasibility_max()
3457        );
3458    }
3459
3460    #[test]
3461    fn unscaled_compl_is_scaled_over_df() {
3462        let cq = fixture_with(MockNlp::new().with_scaling(2.0, None, None));
3463        let scaled = cq.curr_complementarity_max();
3464        let unscaled = cq.curr_unscaled_complementarity_max();
3465        assert!(scaled > 0.0);
3466        assert!((unscaled - scaled / 2.0).abs() < 1e-12);
3467    }
3468
3469    #[test]
3470    fn unscaled_primal_divides_each_row_by_its_factor() {
3471        // Fixture residuals: c = x0+x1-1 = 4; d-s = x0 - s = 2 - 4 = -2.
3472        // Scaled max-norm primal = max(|4|, |-2|) = 4.
3473        // With dc = [4], dd = [2]: unscaled = max(|4/4|, |-2/2|) = 1.
3474        let cq = fixture_with(MockNlp::new().with_scaling(1.0, Some(vec![4.0]), Some(vec![2.0])));
3475        assert!((cq.curr_primal_infeasibility_max() - 4.0).abs() < 1e-12);
3476        assert!(
3477            (cq.curr_unscaled_primal_infeasibility_max() - 1.0).abs() < 1e-12,
3478            "got {}",
3479            cq.curr_unscaled_primal_infeasibility_max()
3480        );
3481    }
3482
3483    #[test]
3484    fn unscaled_nlp_error_is_max_of_unscaled_components() {
3485        let cq = fixture_with(MockNlp::new().with_scaling(2.0, Some(vec![4.0]), Some(vec![2.0])));
3486        let expected = cq
3487            .curr_unscaled_dual_infeasibility_max()
3488            .max(cq.curr_unscaled_primal_infeasibility_max())
3489            .max(cq.curr_unscaled_complementarity_max());
3490        assert_eq!(cq.curr_unscaled_nlp_error(), expected);
3491    }
3492
3493    /// gh #528. A component at or below its own floor drops out; everything
3494    /// above it is counted in full, not net of the floor — the question the
3495    /// floor answers is whether the row says anything at all.
3496    #[test]
3497    fn amax_above_floor_drops_only_sub_floor_components() {
3498        let v = dvec(&[1e-9, -3e-7, 5e-3]);
3499        assert_eq!(amax_above_floor(&v, &[1e-8, 1e-8, 1e-8]), 5e-3);
3500        // The largest component is the only one under its floor: the max comes
3501        // from what remains, not from the vector's own `amax`.
3502        assert_eq!(amax_above_floor(&v, &[1e-8, 1e-8, 1.0]), 3e-7);
3503        // Everything silenced.
3504        assert_eq!(amax_above_floor(&v, &[1.0, 1.0, 1.0]), 0.0);
3505        // Exactly at the floor is silenced (`>`, not `>=`).
3506        assert_eq!(amax_above_floor(&dvec(&[1e-8]), &[1e-8]), 0.0);
3507    }
3508
3509    /// A floor that cannot be attributed component-wise must not silence
3510    /// anything: over-reporting the residual is the safe direction.
3511    #[test]
3512    fn amax_above_floor_falls_back_on_a_length_mismatch() {
3513        let v = dvec(&[1e-9, -3e-7]);
3514        assert_eq!(amax_above_floor(&v, &[1.0]), 3e-7);
3515        assert_eq!(amax_above_floor(&v, &[]), 3e-7);
3516    }
3517
3518    /// The floored aggregate is never larger than the raw one, and on a
3519    /// fixture whose residuals (`c = 4`, `d − s = −2`) are nowhere near any
3520    /// resolution limit the two are identical — the common path is untouched.
3521    #[test]
3522    fn nlp_error_above_primal_noise_matches_on_ordinary_residuals() {
3523        let cq = fixture_with(MockNlp::new());
3524        assert_eq!(
3525            cq.curr_primal_infeasibility_above_noise(ROW_NOISE_KAPPA),
3526            cq.curr_primal_infeasibility_max()
3527        );
3528        assert_eq!(
3529            cq.curr_nlp_error_above_primal_noise(ROW_NOISE_KAPPA),
3530            cq.curr_nlp_error()
3531        );
3532    }
3533
3534    /// gh #528, **equality block**, through the real accessor rather than a
3535    /// hand-supplied floor. The integration LP is all-inequality (`g_u = 2e19`,
3536    /// so `c.dim() == 0`), so this is the only cover the `declared_c_rhs()`
3537    /// branch has.
3538    ///
3539    /// `x = (4, 3)` puts `d = x0 = 4` on top of `s = 4`, so the inequality
3540    /// block's residual is an exact `0` and what the accessor returns is the
3541    /// `c` block alone.
3542    #[test]
3543    fn a_sub_quantum_equality_residual_is_silenced_and_a_coarser_one_is_not() {
3544        let rhs = 1e8;
3545        let floor = ROW_NOISE_KAPPA * Number::EPSILON * rhs;
3546        let cq_for = |c: Number| {
3547            fixture_with_x(
3548                MockNlp::new().with_c_rhs(Some(vec![rhs])).with_c(c),
3549                &[4.0, 3.0],
3550            )
3551        };
3552
3553        // Under the quantum of `g(x) − b` at `|b| = 1e8`: no iterate could
3554        // have placed the residual here, so the row says nothing.
3555        let cq = cq_for(floor * 0.5);
3556        assert_eq!(cq.curr_primal_infeasibility_max(), floor * 0.5);
3557        assert_eq!(
3558            cq.curr_primal_infeasibility_above_noise(ROW_NOISE_KAPPA),
3559            0.0
3560        );
3561
3562        // Above it: counted in full, not net of the floor.
3563        let cq = cq_for(floor * 2.0);
3564        assert_eq!(
3565            cq.curr_primal_infeasibility_above_noise(ROW_NOISE_KAPPA),
3566            floor * 2.0
3567        );
3568
3569        // The placement floor alone would not have silenced anything here —
3570        // at ‖x‖_∞ = 4 through a row of `max_j |∂c/∂x_j| = 1` it is ~5.7e-14,
3571        // eight decades under the formation floor. The `c` branch's own
3572        // magnitude is what does the work.
3573        let cq = fixture_with_x(MockNlp::new().with_c(floor * 0.5), &[4.0, 3.0]);
3574        assert_eq!(
3575            cq.curr_primal_infeasibility_above_noise(ROW_NOISE_KAPPA),
3576            floor * 0.5
3577        );
3578    }
3579
3580    /// The `primal_noise_floor_kappa = 0` escape hatch: every floor collapses
3581    /// to `0`, so every residual is counted and the floored aggregate is the
3582    /// raw one — the strict gate is bit-for-bit upstream Ipopt's again. Pinned
3583    /// on a fixture where the floor otherwise *does* silence the row, so this
3584    /// cannot pass by the two agreeing anyway.
3585    #[test]
3586    fn a_zero_kappa_switches_the_floor_off_completely() {
3587        let rhs = 1e8;
3588        let residual = ROW_NOISE_KAPPA * Number::EPSILON * rhs * 0.5;
3589        let cq = fixture_with_x(
3590            MockNlp::new().with_c_rhs(Some(vec![rhs])).with_c(residual),
3591            &[4.0, 3.0],
3592        );
3593        // The floor is live at the default kappa …
3594        assert_eq!(
3595            cq.curr_primal_infeasibility_above_noise(ROW_NOISE_KAPPA),
3596            0.0
3597        );
3598        // … and gone at zero.
3599        assert_eq!(
3600            cq.curr_primal_infeasibility_above_noise(0.0),
3601            cq.curr_primal_infeasibility_max()
3602        );
3603        assert_eq!(
3604            cq.curr_nlp_error_above_primal_noise(0.0),
3605            cq.curr_nlp_error()
3606        );
3607    }
3608
3609    /// The equality floor rides the row scaling, because both sides of the
3610    /// comparison do: `declared_c_rhs()` reapplies `c_scale` (pinned by
3611    /// `declared_c_rhs_carries_the_row_scaling` in `orig_ipopt_nlp.rs`) and
3612    /// `curr_c()` is the scaled residual `dc · (g(x) − b)`. Scaling a row by
3613    /// `k` scales its residual and its floor together, so the verdict is
3614    /// invariant — which is what makes it legitimate to compare a floor built
3615    /// from the declared RHS against `curr_c()` at all.
3616    #[test]
3617    fn the_equality_floor_rides_the_row_scaling() {
3618        let rhs = 1e8;
3619        let quantum = ROW_NOISE_KAPPA * Number::EPSILON * rhs;
3620        for k in [1.0, 4.0, 0.25] {
3621            let cq_for = |c: Number| {
3622                fixture_with_x(
3623                    MockNlp::new()
3624                        .with_scaling(1.0, Some(vec![k]), None)
3625                        .with_c_rhs(Some(vec![k * rhs]))
3626                        .with_c(k * c),
3627                    &[4.0, 3.0],
3628                )
3629            };
3630            assert_eq!(
3631                cq_for(quantum * 0.5).curr_primal_infeasibility_above_noise(ROW_NOISE_KAPPA),
3632                0.0,
3633                "sub-quantum residual must stay silenced at row scaling {k}",
3634            );
3635            assert_eq!(
3636                cq_for(quantum * 2.0).curr_primal_infeasibility_above_noise(ROW_NOISE_KAPPA),
3637                k * quantum * 2.0,
3638                "above-quantum residual must survive at row scaling {k}",
3639            );
3640        }
3641    }
3642}