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