Skip to main content

pounce_algorithm/
ipopt_cq.rs

1//! Lazy-cache layer — port of
2//! `Algorithm/IpIpoptCalculatedQuantities.{hpp,cpp}`.
3//!
4//! Upstream's CQ object exposes ~80 cached quantities (`curr_f`,
5//! `curr_grad_f`, `curr_jac_c`, `curr_grad_lag_x`, `curr_compl_*`,
6//! `curr_nlp_error`, etc.). All of them are pure derivations from
7//! `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` and the NLP function
8//! evaluations.
9//!
10//! Phase 5 ships the priority subset needed by the KKT layer
11//! (Phase 6) and the convergence check / line search (Phase 7).
12//! Caching is intentionally deferred — every accessor recomputes its
13//! value on each call. Tag-based invalidation lands once the inner
14//! loop benchmarks justify the bookkeeping; correctness does not
15//! depend on it.
16//!
17//! All accessors take `&self` and return `Rc<dyn Vector>`. NLP
18//! evaluations require a brief `borrow_mut()` on the Nlp handle;
19//! callers must not hold an outstanding `borrow()` across an
20//! accessor call.
21
22use crate::ipopt_data::IpoptDataHandle;
23use crate::ipopt_nlp::IpoptNlp;
24use crate::iterates_vector::IteratesVector;
25use pounce_common::cached::Cache;
26use pounce_common::types::Number;
27use pounce_linalg::dense_vector::DenseVector;
28use pounce_linalg::{Matrix, SymMatrix, Vector};
29use std::cell::RefCell;
30use std::rc::Rc;
31
32/// Calculated-quantities object. Holds shared handles on data and the
33/// NLP; per-quantity caches live in `RefCell`s here.
34pub struct IpoptCalculatedQuantities {
35    data: IpoptDataHandle,
36    nlp: Rc<RefCell<dyn IpoptNlp>>,
37
38    /// Optimality scaling cap from `IpOptErrorConvCheck` defaults.
39    pub s_max: Number,
40    /// Damping coefficient for the bound-multiplier complementarity
41    /// term (`kappa_d` in upstream's RegisterOptions).
42    pub kappa_d: Number,
43    /// Correction size for very small slacks (`slack_move` option,
44    /// default `mach_eps^{3/4}`). Drives `calculate_safe_slack`'s
45    /// upper cap on the moved bound — port of upstream's `slack_move_`
46    /// (`IpIpoptCalculatedQuantities.cpp:525`).
47    pub slack_move: Number,
48
49    // Per-iterate caches for the hot accessors used by the KKT solver
50    // dependency-tag check. Without these the PdFullSpaceSolver sees a
51    // fresh tag on every solve (each `curr_slack_*` / `curr_sigma_*`
52    // allocates a new vector with a fresh `TaggedCell`), which forces
53    // an MA57 refactor on every SOC step even though the matrix data
54    // is unchanged. Caches are keyed on the input iterate-vector tag
55    // and survive across calls but are naturally invalidated when the
56    // outer iterate advances (curr.x bump).
57    curr_slack_x_l_cache: RefCell<Cache<Rc<dyn Vector>>>,
58    curr_slack_x_u_cache: RefCell<Cache<Rc<dyn Vector>>>,
59    curr_slack_s_l_cache: RefCell<Cache<Rc<dyn Vector>>>,
60    curr_slack_s_u_cache: RefCell<Cache<Rc<dyn Vector>>>,
61    curr_sigma_x_cache: RefCell<Cache<Rc<dyn Vector>>>,
62    curr_sigma_s_cache: RefCell<Cache<Rc<dyn Vector>>>,
63}
64
65/// Helper: convert `Box<dyn Vector>` to `Rc<dyn Vector>`. Cheap; the
66/// box is unwrapped without copying.
67fn rc_from(v: Box<dyn Vector>) -> Rc<dyn Vector> {
68    Rc::from(v)
69}
70
71/// Max-norm of `v` after dividing each entry by its per-row scale factor
72/// (`max_i |v_i / scale_i|`). `scale == None` means "no row scaling" and
73/// returns the plain `v.amax()`; a zero factor for an entry is treated as
74/// the identity (no divide) so a degenerate scale never yields infinities.
75/// Falls back to `v.amax()` for a non-dense backing — POUNCE is dense-only,
76/// so that branch is defensive.
77fn unscaled_block_amax(v: &dyn Vector, scale: Option<&[Number]>) -> Number {
78    let Some(s) = scale else {
79        return v.amax();
80    };
81    match v.as_any().downcast_ref::<DenseVector>() {
82        Some(d) => d
83            .values()
84            .iter()
85            .zip(s.iter())
86            .map(|(&x, &f)| if f == 0.0 { x.abs() } else { (x / f).abs() })
87            .fold(0.0, Number::max),
88        None => v.amax(),
89    }
90}
91
92/// Result of [`IpoptCalculatedQuantities::adjusted_trial_bounds`]: the
93/// new `x_L / x_U / d_L / d_U` to install on the NLP when one or more
94/// trial slacks were corrected by the safe-slack mechanism.
95pub struct AdjustedBounds {
96    /// Total number of slack components corrected across all four blocks.
97    pub adjusted: usize,
98    pub x_l: Box<dyn Vector>,
99    pub x_u: Box<dyn Vector>,
100    pub d_l: Box<dyn Vector>,
101    pub d_u: Box<dyn Vector>,
102}
103
104impl IpoptCalculatedQuantities {
105    pub fn new(data: IpoptDataHandle, nlp: Rc<RefCell<dyn IpoptNlp>>) -> Self {
106        Self {
107            data,
108            nlp,
109            s_max: 100.0,
110            kappa_d: 1e-5,
111            slack_move: f64::EPSILON.powf(0.75),
112            curr_slack_x_l_cache: RefCell::new(Cache::new(1)),
113            curr_slack_x_u_cache: RefCell::new(Cache::new(1)),
114            curr_slack_s_l_cache: RefCell::new(Cache::new(1)),
115            curr_slack_s_u_cache: RefCell::new(Cache::new(1)),
116            curr_sigma_x_cache: RefCell::new(Cache::new(1)),
117            curr_sigma_s_cache: RefCell::new(Cache::new(1)),
118        }
119    }
120
121    pub fn data(&self) -> &IpoptDataHandle {
122        &self.data
123    }
124
125    pub fn nlp(&self) -> &Rc<RefCell<dyn IpoptNlp>> {
126        &self.nlp
127    }
128
129    pub(crate) fn curr_iv(&self) -> IteratesVector {
130        let Some(iv) = self.data.borrow().curr.as_ref().cloned() else {
131            unreachable!("IpoptCalculatedQuantities: curr iterate not set");
132        };
133        iv
134    }
135
136    fn trial_iv(&self) -> IteratesVector {
137        let Some(iv) = self.data.borrow().trial.as_ref().cloned() else {
138            unreachable!("IpoptCalculatedQuantities: trial iterate not set");
139        };
140        iv
141    }
142
143    // --------------------------------------------------------------
144    // Slacks: s_L = P_L^T x - x_L,  s_U = x_U - P_U^T x.
145    // Mirror of `CalcSlack_L` / `CalcSlack_U`
146    // (`IpIpoptCalculatedQuantities.cpp:238-266`).
147    // --------------------------------------------------------------
148
149    fn calc_slack_l_box(p: &dyn Matrix, x: &dyn Vector, x_bound: &dyn Vector) -> Box<dyn Vector> {
150        let mut result = x_bound.make_new();
151        result.copy(x_bound);
152        // result = -1*result + 1*P^T x  ⇒  P^T x - x_bound.
153        p.trans_mult_vector(1.0, x, -1.0, &mut *result);
154        result
155    }
156
157    fn calc_slack_u_box(p: &dyn Matrix, x: &dyn Vector, x_bound: &dyn Vector) -> Box<dyn Vector> {
158        let mut result = x_bound.make_new();
159        result.copy(x_bound);
160        // result = 1*result + (-1)*P^T x  ⇒  x_bound - P^T x.
161        p.trans_mult_vector(-1.0, x, 1.0, &mut *result);
162        result
163    }
164
165    /// Floor a freshly computed slack against machine precision and,
166    /// where it falls below `eps*min(1,mu)`, raise it to a representable
167    /// positive value, returning the number of corrected components.
168    /// Faithful port of `IpoptCalculatedQuantities::CalculateSafeSlack`
169    /// (`IpIpoptCalculatedQuantities.cpp:455-537`): the corrected slack
170    /// is `min(max(mu/multiplier, s_min), slack_move*max(1,|bound|)+slack)`.
171    /// `multiplier` and `mu` are taken from the *current* iterate, exactly
172    /// as upstream does even for trial slacks.
173    fn calculate_safe_slack(
174        &self,
175        slack: &mut dyn Vector,
176        bound: &dyn Vector,
177        multiplier: &dyn Vector,
178        mu: Number,
179    ) -> usize {
180        if slack.dim() == 0 {
181            return 0;
182        }
183        let min_slack = slack.min();
184        // s_min = eps * min(1, mu); if mu drove it to 0, keep it strictly
185        // positive (upstream #212) so the strict `slack < s_min` test and
186        // the barrier term stay well-defined.
187        let mut s_min = f64::EPSILON * mu.min(1.0);
188        if s_min == 0.0 {
189            s_min = f64::MIN_POSITIVE;
190        }
191        if min_slack >= s_min {
192            return 0;
193        }
194
195        // t = sign(slack - s_min); then collapse to 1 where slack < s_min,
196        // 0 elsewhere.
197        let mut t = slack.make_new();
198        t.copy(&*slack);
199        t.add_scalar(-s_min);
200        t.element_wise_sgn();
201        let mut zero_vec = t.make_new();
202        zero_vec.set(0.0);
203        t.element_wise_min(&*zero_vec); // -1 if slack < s_min, else 0
204        t.scal(-1.0); //  1 if slack < s_min, else 0
205        let retval = t.asum().round() as usize;
206
207        // Clamp the raw slack to be non-negative before forming the target
208        // (upstream's AW fix for negative slacks producing 0).
209        slack.element_wise_max(&*zero_vec);
210
211        // t2 = max(mu/multiplier, s_min) - slack.
212        let mut t2 = t.make_new();
213        let mut s_min_vec = t2.make_new();
214        s_min_vec.set(s_min);
215        if mu != 0.0 {
216            // mu/0 → +inf here, intentionally capped by t_max below.
217            t2.set(mu);
218            t2.element_wise_divide(multiplier);
219            t2.element_wise_max(&*s_min_vec);
220        } else {
221            // mu == 0: max(0/multiplier, s_min) is s_min everywhere, but a 0/0
222            // (zero multiplier at μ=0) would seed the slack target with NaN and
223            // poison the bound move — pin straight to s_min instead.
224            t2.copy(&*s_min_vec);
225        }
226        t2.axpy(-1.0, &*slack);
227
228        // t = max(mu/multiplier, s_min) where flagged, else slack.
229        t.element_wise_select(&*t2);
230        t.axpy(1.0, &*slack);
231
232        // t_max = slack_move*max(1,|bound|) + slack.
233        let mut t_max = t2; // reuse buffer
234        t_max.set(1.0);
235        let mut abs_bound = bound.make_new();
236        abs_bound.copy(bound);
237        abs_bound.element_wise_abs();
238        t_max.element_wise_max(&*abs_bound);
239        // t_max = 1.0*slack + slack_move*t_max.
240        t_max.add_one_vector(1.0, &*slack, self.slack_move);
241
242        // new slack = min(target, t_max) where flagged, else slack.
243        t.element_wise_min(&*t_max);
244        slack.copy(&*t);
245        retval
246    }
247
248    /// `calc_slack_l` followed by `calculate_safe_slack`, returning the
249    /// (floored) slack plus the number of corrected components. The
250    /// multiplier and `mu` come from the current iterate.
251    fn safe_slack_l(
252        &self,
253        p: &dyn Matrix,
254        x: &dyn Vector,
255        bound: &dyn Vector,
256        multiplier: &dyn Vector,
257    ) -> (Rc<dyn Vector>, usize) {
258        let mu = self.data.borrow().curr_mu;
259        let mut result = Self::calc_slack_l_box(p, x, bound);
260        let n = self.calculate_safe_slack(&mut *result, bound, multiplier, mu);
261        (rc_from(result), n)
262    }
263
264    fn safe_slack_u(
265        &self,
266        p: &dyn Matrix,
267        x: &dyn Vector,
268        bound: &dyn Vector,
269        multiplier: &dyn Vector,
270    ) -> (Rc<dyn Vector>, usize) {
271        let mu = self.data.borrow().curr_mu;
272        let mut result = Self::calc_slack_u_box(p, x, bound);
273        let n = self.calculate_safe_slack(&mut *result, bound, multiplier, mu);
274        (rc_from(result), n)
275    }
276
277    pub fn curr_slack_x_l(&self) -> Rc<dyn Vector> {
278        let iv = self.curr_iv();
279        {
280            let cache = self.curr_slack_x_l_cache.borrow();
281            if let Some(v) = cache.get(&[iv.x.as_tagged()], &[]) {
282                return v;
283            }
284        }
285        let nlp = self.nlp.borrow();
286        let (v, _) = self.safe_slack_l(&*nlp.px_l(), &*iv.x, nlp.x_l(), &*iv.z_l);
287        self.curr_slack_x_l_cache
288            .borrow_mut()
289            .add(v.clone(), &[iv.x.as_tagged()], &[]);
290        v
291    }
292
293    pub fn curr_slack_x_u(&self) -> Rc<dyn Vector> {
294        let iv = self.curr_iv();
295        {
296            let cache = self.curr_slack_x_u_cache.borrow();
297            if let Some(v) = cache.get(&[iv.x.as_tagged()], &[]) {
298                return v;
299            }
300        }
301        let nlp = self.nlp.borrow();
302        let (v, _) = self.safe_slack_u(&*nlp.px_u(), &*iv.x, nlp.x_u(), &*iv.z_u);
303        self.curr_slack_x_u_cache
304            .borrow_mut()
305            .add(v.clone(), &[iv.x.as_tagged()], &[]);
306        v
307    }
308
309    pub fn curr_slack_s_l(&self) -> Rc<dyn Vector> {
310        let iv = self.curr_iv();
311        {
312            let cache = self.curr_slack_s_l_cache.borrow();
313            if let Some(v) = cache.get(&[iv.s.as_tagged()], &[]) {
314                return v;
315            }
316        }
317        let nlp = self.nlp.borrow();
318        let (v, _) = self.safe_slack_l(&*nlp.pd_l(), &*iv.s, nlp.d_l(), &*iv.v_l);
319        self.curr_slack_s_l_cache
320            .borrow_mut()
321            .add(v.clone(), &[iv.s.as_tagged()], &[]);
322        v
323    }
324
325    pub fn curr_slack_s_u(&self) -> Rc<dyn Vector> {
326        let iv = self.curr_iv();
327        {
328            let cache = self.curr_slack_s_u_cache.borrow();
329            if let Some(v) = cache.get(&[iv.s.as_tagged()], &[]) {
330                return v;
331            }
332        }
333        let nlp = self.nlp.borrow();
334        let (v, _) = self.safe_slack_u(&*nlp.pd_u(), &*iv.s, nlp.d_u(), &*iv.v_u);
335        self.curr_slack_s_u_cache
336            .borrow_mut()
337            .add(v.clone(), &[iv.s.as_tagged()], &[]);
338        v
339    }
340
341    pub fn trial_slack_x_l(&self) -> Rc<dyn Vector> {
342        let iv = self.trial_iv();
343        let mult = self.curr_iv();
344        let nlp = self.nlp.borrow();
345        self.safe_slack_l(&*nlp.px_l(), &*iv.x, nlp.x_l(), &*mult.z_l)
346            .0
347    }
348
349    pub fn trial_slack_x_u(&self) -> Rc<dyn Vector> {
350        let iv = self.trial_iv();
351        let mult = self.curr_iv();
352        let nlp = self.nlp.borrow();
353        self.safe_slack_u(&*nlp.px_u(), &*iv.x, nlp.x_u(), &*mult.z_u)
354            .0
355    }
356
357    pub fn trial_slack_s_l(&self) -> Rc<dyn Vector> {
358        let iv = self.trial_iv();
359        let mult = self.curr_iv();
360        let nlp = self.nlp.borrow();
361        self.safe_slack_l(&*nlp.pd_l(), &*iv.s, nlp.d_l(), &*mult.v_l)
362            .0
363    }
364
365    pub fn trial_slack_s_u(&self) -> Rc<dyn Vector> {
366        let iv = self.trial_iv();
367        let mult = self.curr_iv();
368        let nlp = self.nlp.borrow();
369        self.safe_slack_u(&*nlp.pd_u(), &*iv.s, nlp.d_u(), &*mult.v_u)
370            .0
371    }
372
373    /// Compute the four trial slacks with safe-slack flooring and, if any
374    /// component was corrected, the adjusted variable bounds that make the
375    /// trial slacks exactly representable. Port of the bound-adjustment
376    /// block in `IpoptAlgorithm::AcceptTrialPoint`
377    /// (`IpIpoptAlg.cpp:664-706`): `new_x_L = Px_L^T x - safe_slack_x_L`,
378    /// `new_x_U = Px_U^T x + safe_slack_x_U`, likewise for `s`/`d`.
379    /// Returns `None` when no slack needed correcting.
380    pub fn adjusted_trial_bounds(&self) -> Option<AdjustedBounds> {
381        let iv = self.trial_iv();
382        let mult = self.curr_iv();
383        let nlp = self.nlp.borrow();
384
385        let (s_x_l, n_x_l) = self.safe_slack_l(&*nlp.px_l(), &*iv.x, nlp.x_l(), &*mult.z_l);
386        let (s_x_u, n_x_u) = self.safe_slack_u(&*nlp.px_u(), &*iv.x, nlp.x_u(), &*mult.z_u);
387        let (s_s_l, n_s_l) = self.safe_slack_l(&*nlp.pd_l(), &*iv.s, nlp.d_l(), &*mult.v_l);
388        let (s_s_u, n_s_u) = self.safe_slack_u(&*nlp.pd_u(), &*iv.s, nlp.d_u(), &*mult.v_u);
389
390        let adjusted = n_x_l + n_x_u + n_s_l + n_s_u;
391        if adjusted == 0 {
392            return None;
393        }
394
395        // new_x_L = Px_L^T x - safe_slack_x_L
396        let mut new_x_l = nlp.x_l().make_new();
397        nlp.px_l()
398            .trans_mult_vector(1.0, &*iv.x, 0.0, &mut *new_x_l);
399        new_x_l.axpy(-1.0, &*s_x_l);
400        // new_x_U = Px_U^T x + safe_slack_x_U
401        let mut new_x_u = nlp.x_u().make_new();
402        nlp.px_u()
403            .trans_mult_vector(1.0, &*iv.x, 0.0, &mut *new_x_u);
404        new_x_u.axpy(1.0, &*s_x_u);
405        // new_d_L = Pd_L^T s - safe_slack_s_L
406        let mut new_d_l = nlp.d_l().make_new();
407        nlp.pd_l()
408            .trans_mult_vector(1.0, &*iv.s, 0.0, &mut *new_d_l);
409        new_d_l.axpy(-1.0, &*s_s_l);
410        // new_d_U = Pd_U^T s + safe_slack_s_U
411        let mut new_d_u = nlp.d_u().make_new();
412        nlp.pd_u()
413            .trans_mult_vector(1.0, &*iv.s, 0.0, &mut *new_d_u);
414        new_d_u.axpy(1.0, &*s_s_u);
415
416        Some(AdjustedBounds {
417            adjusted,
418            x_l: new_x_l,
419            x_u: new_x_u,
420            d_l: new_d_l,
421            d_u: new_d_u,
422        })
423    }
424
425    // --------------------------------------------------------------
426    // NLP function evaluations.
427    // --------------------------------------------------------------
428
429    pub fn curr_grad_f(&self) -> Rc<dyn Vector> {
430        let iv = self.curr_iv();
431        let mut nlp = self.nlp.borrow_mut();
432        let mut g = iv.x.make_new();
433        nlp.eval_grad_f(&*iv.x, &mut *g);
434        rc_from(g)
435    }
436
437    pub fn trial_grad_f(&self) -> Rc<dyn Vector> {
438        let iv = self.trial_iv();
439        let mut nlp = self.nlp.borrow_mut();
440        let mut g = iv.x.make_new();
441        nlp.eval_grad_f(&*iv.x, &mut *g);
442        rc_from(g)
443    }
444
445    pub fn curr_c(&self) -> Rc<dyn Vector> {
446        let iv = self.curr_iv();
447        let m = self.nlp.borrow().m_eq();
448        let mut nlp = self.nlp.borrow_mut();
449        let mut c = iv.y_c.make_new();
450        debug_assert_eq!(c.dim(), m);
451        nlp.eval_c(&*iv.x, &mut *c);
452        rc_from(c)
453    }
454
455    pub fn trial_c(&self) -> Rc<dyn Vector> {
456        let iv = self.trial_iv();
457        let mut nlp = self.nlp.borrow_mut();
458        let mut c = iv.y_c.make_new();
459        nlp.eval_c(&*iv.x, &mut *c);
460        rc_from(c)
461    }
462
463    pub fn curr_d(&self) -> Rc<dyn Vector> {
464        let iv = self.curr_iv();
465        let mut nlp = self.nlp.borrow_mut();
466        let mut d = iv.s.make_new();
467        nlp.eval_d(&*iv.x, &mut *d);
468        rc_from(d)
469    }
470
471    pub fn trial_d(&self) -> Rc<dyn Vector> {
472        let iv = self.trial_iv();
473        let mut nlp = self.nlp.borrow_mut();
474        let mut d = iv.s.make_new();
475        nlp.eval_d(&*iv.x, &mut *d);
476        rc_from(d)
477    }
478
479    pub fn curr_jac_c(&self) -> Rc<dyn Matrix> {
480        let iv = self.curr_iv();
481        self.nlp.borrow_mut().eval_jac_c(&*iv.x)
482    }
483
484    pub fn curr_jac_d(&self) -> Rc<dyn Matrix> {
485        let iv = self.curr_iv();
486        self.nlp.borrow_mut().eval_jac_d(&*iv.x)
487    }
488
489    pub fn curr_exact_hessian(&self) -> Rc<dyn SymMatrix> {
490        let iv = self.curr_iv();
491        self.nlp
492            .borrow_mut()
493            .eval_h(&*iv.x, 1.0, &*iv.y_c, &*iv.y_d)
494    }
495
496    /// `curr_d - s` — port of `IpIpoptCalculatedQuantities.cpp:1185-1206`.
497    pub fn curr_d_minus_s(&self) -> Rc<dyn Vector> {
498        let iv = self.curr_iv();
499        let d = self.curr_d();
500        let mut tmp = iv.s.make_new();
501        // tmp = 0*tmp + 1*d + (-1)*s
502        tmp.add_two_vectors(1.0, &*d, -1.0, &*iv.s, 0.0);
503        rc_from(tmp)
504    }
505
506    pub fn trial_d_minus_s(&self) -> Rc<dyn Vector> {
507        let iv = self.trial_iv();
508        let d = self.trial_d();
509        let mut tmp = iv.s.make_new();
510        tmp.add_two_vectors(1.0, &*d, -1.0, &*iv.s, 0.0);
511        rc_from(tmp)
512    }
513
514    /// `J_c^T y_c` — for a generic `vec` argument
515    /// (`IpIpoptCalculatedQuantities.cpp:1373-1404`).
516    pub fn curr_jac_c_t_times_vec(&self, vec: &dyn Vector) -> Rc<dyn Vector> {
517        let iv = self.curr_iv();
518        let jac_c = self.curr_jac_c();
519        let mut tmp = iv.x.make_new();
520        jac_c.trans_mult_vector(1.0, vec, 0.0, &mut *tmp);
521        rc_from(tmp)
522    }
523
524    /// `J_d^T y_d` for arbitrary `vec`.
525    pub fn curr_jac_d_t_times_vec(&self, vec: &dyn Vector) -> Rc<dyn Vector> {
526        let iv = self.curr_iv();
527        let jac_d = self.curr_jac_d();
528        let mut tmp = iv.x.make_new();
529        jac_d.trans_mult_vector(1.0, vec, 0.0, &mut *tmp);
530        rc_from(tmp)
531    }
532
533    pub fn curr_jac_c_t_times_curr_y_c(&self) -> Rc<dyn Vector> {
534        let iv = self.curr_iv();
535        self.curr_jac_c_t_times_vec(&*iv.y_c)
536    }
537
538    pub fn curr_jac_d_t_times_curr_y_d(&self) -> Rc<dyn Vector> {
539        let iv = self.curr_iv();
540        self.curr_jac_d_t_times_vec(&*iv.y_d)
541    }
542
543    /// `J_c v` — `IpIpoptCalculatedQuantities.cpp:1303-1321`.
544    pub fn curr_jac_c_times_vec(&self, vec: &dyn Vector) -> Rc<dyn Vector> {
545        let iv = self.curr_iv();
546        let jac_c = self.curr_jac_c();
547        let mut tmp = iv.y_c.make_new();
548        jac_c.mult_vector(1.0, vec, 0.0, &mut *tmp);
549        rc_from(tmp)
550    }
551
552    /// `J_d v` — `IpIpoptCalculatedQuantities.cpp:1323-1343`.
553    pub fn curr_jac_d_times_vec(&self, vec: &dyn Vector) -> Rc<dyn Vector> {
554        let iv = self.curr_iv();
555        let jac_d = self.curr_jac_d();
556        let mut tmp = iv.s.make_new();
557        jac_d.mult_vector(1.0, vec, 0.0, &mut *tmp);
558        rc_from(tmp)
559    }
560
561    // --------------------------------------------------------------
562    // Lagrangian gradients
563    // --------------------------------------------------------------
564
565    /// `∇_x L = ∇f(x) + J_c^T y_c + J_d^T y_d - P_L z_L + P_U z_U`
566    /// per `IpIpoptCalculatedQuantities.cpp:1993-2030`.
567    pub fn curr_grad_lag_x(&self) -> Rc<dyn Vector> {
568        let iv = self.curr_iv();
569        let grad_f = self.curr_grad_f();
570        let jc_t_y_c = self.curr_jac_c_t_times_curr_y_c();
571        let jd_t_y_d = self.curr_jac_d_t_times_curr_y_d();
572
573        let mut tmp = iv.x.make_new();
574        tmp.copy(&*grad_f);
575        tmp.add_two_vectors(1.0, &*jc_t_y_c, 1.0, &*jd_t_y_d, 1.0);
576
577        let nlp = self.nlp.borrow();
578        nlp.px_l().mult_vector(-1.0, &*iv.z_l, 1.0, &mut *tmp);
579        nlp.px_u().mult_vector(1.0, &*iv.z_u, 1.0, &mut *tmp);
580        rc_from(tmp)
581    }
582
583    /// `∇_s L = -y_d - P_L v_L + P_U v_U`
584    /// (`IpIpoptCalculatedQuantities.cpp:2069-2098`).
585    pub fn curr_grad_lag_s(&self) -> Rc<dyn Vector> {
586        let iv = self.curr_iv();
587        let mut tmp = iv.y_d.make_new();
588        let nlp = self.nlp.borrow();
589        // tmp = P_U v_U
590        nlp.pd_u().mult_vector(1.0, &*iv.v_u, 0.0, &mut *tmp);
591        // tmp = tmp - P_L v_L
592        nlp.pd_l().mult_vector(-1.0, &*iv.v_l, 1.0, &mut *tmp);
593        // tmp = tmp - y_d
594        tmp.axpy(-1.0, &*iv.y_d);
595        rc_from(tmp)
596    }
597
598    // --------------------------------------------------------------
599    // Complementarity (slack ⊙ multiplier)
600    // --------------------------------------------------------------
601
602    fn calc_compl(slack: &dyn Vector, mult: &dyn Vector) -> Rc<dyn Vector> {
603        let mut result = slack.make_new();
604        result.copy(slack);
605        result.element_wise_multiply(mult);
606        rc_from(result)
607    }
608
609    pub fn curr_compl_x_l(&self) -> Rc<dyn Vector> {
610        let slack = self.curr_slack_x_l();
611        let z_l = self.curr_iv().z_l;
612        Self::calc_compl(&*slack, &*z_l)
613    }
614
615    pub fn curr_compl_x_u(&self) -> Rc<dyn Vector> {
616        let slack = self.curr_slack_x_u();
617        let z_u = self.curr_iv().z_u;
618        Self::calc_compl(&*slack, &*z_u)
619    }
620
621    pub fn curr_compl_s_l(&self) -> Rc<dyn Vector> {
622        let slack = self.curr_slack_s_l();
623        let v_l = self.curr_iv().v_l;
624        Self::calc_compl(&*slack, &*v_l)
625    }
626
627    pub fn curr_compl_s_u(&self) -> Rc<dyn Vector> {
628        let slack = self.curr_slack_s_u();
629        let v_u = self.curr_iv().v_u;
630        Self::calc_compl(&*slack, &*v_u)
631    }
632
633    /// `s_L .* z_L - mu` — relaxed complementarity used in the KKT
634    /// RHS. `IpIpoptCalculatedQuantities.cpp:2406-2430`.
635    pub fn curr_relaxed_compl_x_l(&self) -> Rc<dyn Vector> {
636        let mu = self.data.borrow().curr_mu;
637        let mut r = self.curr_compl_x_l().make_new();
638        r.copy(&*self.curr_compl_x_l());
639        r.add_scalar(-mu);
640        rc_from(r)
641    }
642
643    pub fn curr_relaxed_compl_x_u(&self) -> Rc<dyn Vector> {
644        let mu = self.data.borrow().curr_mu;
645        let mut r = self.curr_compl_x_u().make_new();
646        r.copy(&*self.curr_compl_x_u());
647        r.add_scalar(-mu);
648        rc_from(r)
649    }
650
651    pub fn curr_relaxed_compl_s_l(&self) -> Rc<dyn Vector> {
652        let mu = self.data.borrow().curr_mu;
653        let mut r = self.curr_compl_s_l().make_new();
654        r.copy(&*self.curr_compl_s_l());
655        r.add_scalar(-mu);
656        rc_from(r)
657    }
658
659    pub fn curr_relaxed_compl_s_u(&self) -> Rc<dyn Vector> {
660        let mu = self.data.borrow().curr_mu;
661        let mut r = self.curr_compl_s_u().make_new();
662        r.copy(&*self.curr_compl_s_u());
663        r.add_scalar(-mu);
664        rc_from(r)
665    }
666
667    // --------------------------------------------------------------
668    // Σ_x / Σ_s (barrier-Hessian diagonals fed to the augmented system)
669    // `IpIpoptCalculatedQuantities.cpp:3501-3551`.
670    //
671    //   Σ_x = P_L · diag(z_L / s_L) · P_L^T + P_U · diag(z_U / s_U) · P_U^T
672    //   Σ_s = P_L · diag(v_L / s_L) · P_L^T + P_U · diag(v_U / s_U) · P_U^T
673    // --------------------------------------------------------------
674
675    pub fn curr_sigma_x(&self) -> Rc<dyn Vector> {
676        let iv = self.curr_iv();
677        {
678            let cache = self.curr_sigma_x_cache.borrow();
679            if let Some(v) = cache.get(
680                &[iv.x.as_tagged(), iv.z_l.as_tagged(), iv.z_u.as_tagged()],
681                &[],
682            ) {
683                return v;
684            }
685        }
686        let slack_l = self.curr_slack_x_l();
687        let slack_u = self.curr_slack_x_u();
688
689        let mut sigma = iv.x.make_new();
690        sigma.set(0.0);
691
692        let nlp = self.nlp.borrow();
693        nlp.px_l()
694            .add_m_sinv_z(1.0, &*slack_l, &*iv.z_l, &mut *sigma);
695        nlp.px_u()
696            .add_m_sinv_z(1.0, &*slack_u, &*iv.z_u, &mut *sigma);
697        let v = rc_from(sigma);
698        self.curr_sigma_x_cache.borrow_mut().add(
699            v.clone(),
700            &[iv.x.as_tagged(), iv.z_l.as_tagged(), iv.z_u.as_tagged()],
701            &[],
702        );
703        v
704    }
705
706    pub fn curr_sigma_s(&self) -> Rc<dyn Vector> {
707        let iv = self.curr_iv();
708        {
709            let cache = self.curr_sigma_s_cache.borrow();
710            if let Some(v) = cache.get(
711                &[iv.s.as_tagged(), iv.v_l.as_tagged(), iv.v_u.as_tagged()],
712                &[],
713            ) {
714                return v;
715            }
716        }
717        let slack_l = self.curr_slack_s_l();
718        let slack_u = self.curr_slack_s_u();
719
720        let mut sigma = iv.s.make_new();
721        sigma.set(0.0);
722
723        let nlp = self.nlp.borrow();
724        nlp.pd_l()
725            .add_m_sinv_z(1.0, &*slack_l, &*iv.v_l, &mut *sigma);
726        nlp.pd_u()
727            .add_m_sinv_z(1.0, &*slack_u, &*iv.v_u, &mut *sigma);
728        let v = rc_from(sigma);
729        self.curr_sigma_s_cache.borrow_mut().add(
730            v.clone(),
731            &[iv.s.as_tagged(), iv.v_l.as_tagged(), iv.v_u.as_tagged()],
732            &[],
733        );
734        v
735    }
736
737    // --------------------------------------------------------------
738    // Objective f and barrier objective phi
739    // (`IpIpoptCalculatedQuantities.cpp:CalcBarrierTerm`,
740    //  lines 870-1042 in upstream).
741    //
742    //   phi(x,s) = f(x)
743    //              − μ · [Σ ln(s_x_L) + Σ ln(s_x_U)
744    //                   + Σ ln(s_s_L) + Σ ln(s_s_U)]
745    //              + κ_d · μ · [s_x_L · 1_singly_x_L
746    //                          + s_x_U · 1_singly_x_U
747    //                          + s_s_L · 1_singly_s_L
748    //                          + s_s_U · 1_singly_s_U]
749    //
750    // The damping piece vanishes when `kappa_d == 0` (default).
751    // --------------------------------------------------------------
752
753    pub fn curr_f(&self) -> Number {
754        let iv = self.curr_iv();
755        let mut nlp = self.nlp.borrow_mut();
756        nlp.eval_f(&*iv.x)
757    }
758
759    /// Unscaled objective at the current iterate. `curr_f` returns the
760    /// internally scaled value (`f · df_`); upstream IPOPT prints the
761    /// unscaled objective in its iteration log, so this divides the
762    /// scaling back out. Mirrors `IpoptCalculatedQuantities::
763    /// unscaled_curr_f`. A zero factor (scaling never determined) is
764    /// treated as the identity.
765    pub fn unscaled_curr_f(&self) -> Number {
766        let scaled = self.curr_f();
767        let factor = self.nlp.borrow().obj_scaling_factor();
768        if factor == 0.0 {
769            scaled
770        } else {
771            scaled / factor
772        }
773    }
774
775    /// Max-norm dual infeasibility in the **unscaled** (user-original)
776    /// space. [`Self::curr_dual_infeasibility_max`] is evaluated in the
777    /// internally-scaled NLP space (objective × `df`, constraints × `dc`);
778    /// because POUNCE applies no variable scaling, every term of the
779    /// Lagrangian gradient `∇f + Jᵀy − z` carries the same objective
780    /// factor `df`, so the unscaling is a single divide by
781    /// `df = obj_scaling_factor`. A zero or unit factor returns the scaled
782    /// value unchanged — the common no-scaling path stays division-free.
783    pub fn curr_unscaled_dual_infeasibility_max(&self) -> Number {
784        let df = self.nlp.borrow().obj_scaling_factor();
785        let scaled = self.curr_dual_infeasibility_max();
786        if df == 0.0 || df == 1.0 {
787            scaled
788        } else {
789            scaled / df
790        }
791    }
792
793    /// Max-norm complementarity in the **unscaled** space. Each bound block
794    /// `s · z` scales uniformly by `df`: the slack's `dc`/`dd` factor and
795    /// the multiplier's `df/dc` (`df/dd`) factor cancel in the product,
796    /// leaving `df`. So this is the scaled max-norm divided by `df`. See
797    /// [`Self::curr_unscaled_dual_infeasibility_max`].
798    pub fn curr_unscaled_complementarity_max(&self) -> Number {
799        let df = self.nlp.borrow().obj_scaling_factor();
800        let scaled = self.curr_complementarity_max();
801        if df == 0.0 || df == 1.0 {
802            scaled
803        } else {
804            scaled / df
805        }
806    }
807
808    /// Max-norm primal infeasibility in the **unscaled** space. Unlike the
809    /// dual/complementarity terms the constraint scaling is per-row
810    /// (`c_scaled = dc ⊙ c_user`, `(d−s)_scaled = dd ⊙ (d−s)_user`), so each
811    /// block is unscaled element-by-element before the max-norm. When no row
812    /// scaling is active (`c_scale_vec`/`d_scale_vec` both `None` — the
813    /// common case) this is exactly [`Self::curr_primal_infeasibility_max`].
814    pub fn curr_unscaled_primal_infeasibility_max(&self) -> Number {
815        let (dc, dd) = {
816            let nlp = self.nlp.borrow();
817            (nlp.c_scale_vec(), nlp.d_scale_vec())
818        };
819        if dc.is_none() && dd.is_none() {
820            return self.curr_primal_infeasibility_max();
821        }
822        let c_max = unscaled_block_amax(&*self.curr_c(), dc.as_deref());
823        let dms_max = unscaled_block_amax(&*self.curr_d_minus_s(), dd.as_deref());
824        c_max.max(dms_max)
825    }
826
827    /// Overall **unscaled** max-norm KKT error — `max` of the unscaled dual
828    /// infeasibility, primal infeasibility, and complementarity. This is the
829    /// honest "distance from a KKT point in the user's own units", as
830    /// opposed to [`Self::curr_nlp_error`], which additionally applies the
831    /// `s_d`/`s_c` optimality scaling. Used by the status-fidelity gate and
832    /// surfaced to callers that must independently verify a returned
833    /// certificate (pounce#173).
834    pub fn curr_unscaled_nlp_error(&self) -> Number {
835        self.curr_unscaled_dual_infeasibility_max()
836            .max(self.curr_unscaled_primal_infeasibility_max())
837            .max(self.curr_unscaled_complementarity_max())
838    }
839
840    pub fn trial_f(&self) -> Number {
841        let iv = self.trial_iv();
842        let mut nlp = self.nlp.borrow_mut();
843        nlp.eval_f(&*iv.x)
844    }
845
846    fn barrier_obj_at(
847        &self,
848        f: Number,
849        s_x_l: &dyn Vector,
850        s_x_u: &dyn Vector,
851        s_s_l: &dyn Vector,
852        s_s_u: &dyn Vector,
853    ) -> Number {
854        let mu = self.data.borrow().curr_mu;
855        let log_sum = s_x_l.sum_logs() + s_x_u.sum_logs() + s_s_l.sum_logs() + s_s_u.sum_logs();
856        let mut phi = f - mu * log_sum;
857        if self.kappa_d > 0.0 {
858            let di = self.damping_indicators();
859            phi += self.kappa_d * mu * s_x_l.dot(&*di.x_l);
860            phi += self.kappa_d * mu * s_x_u.dot(&*di.x_u);
861            phi += self.kappa_d * mu * s_s_l.dot(&*di.s_l);
862            phi += self.kappa_d * mu * s_s_u.dot(&*di.s_u);
863        }
864        phi
865    }
866
867    pub fn curr_barrier_obj(&self) -> Number {
868        let f = self.curr_f();
869        let s_x_l = self.curr_slack_x_l();
870        let s_x_u = self.curr_slack_x_u();
871        let s_s_l = self.curr_slack_s_l();
872        let s_s_u = self.curr_slack_s_u();
873        self.barrier_obj_at(f, &*s_x_l, &*s_x_u, &*s_s_l, &*s_s_u)
874    }
875
876    pub fn trial_barrier_obj(&self) -> Number {
877        let f = self.trial_f();
878        let s_x_l = self.trial_slack_x_l();
879        let s_x_u = self.trial_slack_x_u();
880        let s_s_l = self.trial_slack_s_l();
881        let s_s_u = self.trial_slack_s_u();
882        self.barrier_obj_at(f, &*s_x_l, &*s_x_u, &*s_s_l, &*s_s_u)
883    }
884
885    /// Gradient of the barrier objective wrt `x`:
886    ///   ∇_x φ = ∇f(x) − μ · [P_L · (1/s_L) − P_U · (1/s_U)] + damping
887    /// Mirrors `IpIpoptCalculatedQuantities.cpp:CalcGradBarrierObjectiveX`.
888    pub fn curr_grad_barrier_obj_x(&self) -> Rc<dyn Vector> {
889        let iv = self.curr_iv();
890        let mu = self.data.borrow().curr_mu;
891        let s_l = self.curr_slack_x_l();
892        let s_u = self.curr_slack_x_u();
893
894        let mut inv_s_l = s_l.make_new();
895        inv_s_l.copy(&*s_l);
896        inv_s_l.element_wise_reciprocal();
897        let mut inv_s_u = s_u.make_new();
898        inv_s_u.copy(&*s_u);
899        inv_s_u.element_wise_reciprocal();
900
901        let grad_f = self.curr_grad_f();
902        let mut tmp = iv.x.make_new();
903        tmp.copy(&*grad_f);
904        let nlp = self.nlp.borrow();
905        // tmp -= μ · P_L · inv_s_l
906        nlp.px_l().mult_vector(-mu, &*inv_s_l, 1.0, &mut *tmp);
907        // tmp += μ · P_U · inv_s_u
908        nlp.px_u().mult_vector(mu, &*inv_s_u, 1.0, &mut *tmp);
909
910        if self.kappa_d > 0.0 {
911            let di = self.damping_indicators();
912            // + κ_d μ · P_L · 1_singly_x_L
913            nlp.px_l()
914                .mult_vector(self.kappa_d * mu, &*di.x_l, 1.0, &mut *tmp);
915            // − κ_d μ · P_U · 1_singly_x_U
916            nlp.px_u()
917                .mult_vector(-self.kappa_d * mu, &*di.x_u, 1.0, &mut *tmp);
918        }
919        rc_from(tmp)
920    }
921
922    /// Gradient of the barrier objective wrt `s`:
923    ///   ∇_s φ = − μ · [P_L · (1/s_s_L) − P_U · (1/s_s_U)] + damping
924    pub fn curr_grad_barrier_obj_s(&self) -> Rc<dyn Vector> {
925        let iv = self.curr_iv();
926        let mu = self.data.borrow().curr_mu;
927        let s_l = self.curr_slack_s_l();
928        let s_u = self.curr_slack_s_u();
929
930        let mut inv_s_l = s_l.make_new();
931        inv_s_l.copy(&*s_l);
932        inv_s_l.element_wise_reciprocal();
933        let mut inv_s_u = s_u.make_new();
934        inv_s_u.copy(&*s_u);
935        inv_s_u.element_wise_reciprocal();
936
937        let mut tmp = iv.s.make_new();
938        tmp.set(0.0);
939        let nlp = self.nlp.borrow();
940        nlp.pd_l().mult_vector(-mu, &*inv_s_l, 1.0, &mut *tmp);
941        nlp.pd_u().mult_vector(mu, &*inv_s_u, 1.0, &mut *tmp);
942
943        if self.kappa_d > 0.0 {
944            let di = self.damping_indicators();
945            nlp.pd_l()
946                .mult_vector(self.kappa_d * mu, &*di.s_l, 1.0, &mut *tmp);
947            nlp.pd_u()
948                .mult_vector(-self.kappa_d * mu, &*di.s_u, 1.0, &mut *tmp);
949        }
950        rc_from(tmp)
951    }
952
953    // --------------------------------------------------------------
954    // Step-aware quadratic-model quantities — used by the penalty
955    // line-search acceptor's pred/ared test and by the quality-
956    // function mu oracle's q(σ) evaluator.
957    // --------------------------------------------------------------
958
959    /// Directional derivative of the barrier objective along `(δx, δs)`:
960    /// `gradBarrTDelta = ∇_x φ · δx + ∇_s φ · δs`. Port of
961    /// `IpIpoptCalculatedQuantities.cpp:CurrGradBarrTDelta` (called
962    /// `IpCq().curr_gradBarrTDelta()` in upstream after the search dir
963    /// has been computed).
964    pub fn curr_grad_barr_t_delta(&self, delta_x: &dyn Vector, delta_s: &dyn Vector) -> Number {
965        let g_x = self.curr_grad_barrier_obj_x();
966        let g_s = self.curr_grad_barrier_obj_s();
967        g_x.dot(delta_x) + g_s.dot(delta_s)
968    }
969
970    /// `δᵀ(W + Σ_x + δ_pert_x I)δ_x + δ_sᵀ(Σ_s + δ_pert_s I)δ_s` —
971    /// the quadratic-model term used by `IpPenaltyLSAcceptor.cpp:
972    /// InitThisLineSearch:101-129`. Reads `W` and the active PD
973    /// perturbations from [`crate::ipopt_data::IpoptData`].
974    /// Returns 0 if the result would be negative (matching upstream's
975    /// `if dWd <= 0 then dWd = 0` guard at line 133).
976    pub fn curr_dwd(&self, delta_x: &dyn Vector, delta_s: &dyn Vector) -> Number {
977        let mut dwd: Number = 0.0;
978
979        // δ_xᵀ W δ_x.
980        if let Some(w) = self.data.borrow().w.clone() {
981            let mut wd = delta_x.make_new();
982            w.mult_vector(1.0, delta_x, 0.0, &mut *wd);
983            dwd += wd.dot(delta_x);
984        }
985
986        // δ_xᵀ Σ_x δ_x.
987        let sigma_x = self.curr_sigma_x();
988        let mut tmp_x = delta_x.make_new();
989        tmp_x.copy(delta_x);
990        tmp_x.element_wise_multiply(&*sigma_x);
991        dwd += tmp_x.dot(delta_x);
992
993        // δ_sᵀ Σ_s δ_s.
994        let sigma_s = self.curr_sigma_s();
995        let mut tmp_s = delta_s.make_new();
996        tmp_s.copy(delta_s);
997        tmp_s.element_wise_multiply(&*sigma_s);
998        dwd += tmp_s.dot(delta_s);
999
1000        // PD perturbations.
1001        let pert = self.data.borrow().perturbations;
1002        if pert.delta_x != 0.0 {
1003            let nx = delta_x.nrm2();
1004            dwd += pert.delta_x * nx * nx;
1005        }
1006        if pert.delta_s != 0.0 {
1007            let ns = delta_s.nrm2();
1008            dwd += pert.delta_s * ns * ns;
1009        }
1010
1011        dwd.max(0.0)
1012    }
1013
1014    // --------------------------------------------------------------
1015    // Constraint violation theta — port of
1016    // `IpIpoptCalculatedQuantities.cpp:CurrConstraintViolation`.
1017    // Default norm is 1-norm (option `constraint_violation_norm`,
1018    // default "1-norm" upstream); we hardwire 1-norm in v1.0.
1019    // --------------------------------------------------------------
1020
1021    pub fn curr_constraint_violation(&self) -> Number {
1022        let c = self.curr_c();
1023        let dms = self.curr_d_minus_s();
1024        c.asum() + dms.asum()
1025    }
1026
1027    pub fn trial_constraint_violation(&self) -> Number {
1028        let c = self.trial_c();
1029        let dms = self.trial_d_minus_s();
1030        c.asum() + dms.asum()
1031    }
1032
1033    /// Max-norm primal infeasibility — `max(||c||_∞, ||d − s||_∞)`. Used
1034    /// by the iteration output's `inf_pr` column when
1035    /// `inf_pr_output == INTERNAL`. Mirrors
1036    /// `IpIpoptCalculatedQuantities.cpp:CurrPrimalInfeasibility(NORM_MAX)`.
1037    pub fn curr_primal_infeasibility_max(&self) -> Number {
1038        let c = self.curr_c();
1039        let dms = self.curr_d_minus_s();
1040        c.amax().max(dms.amax())
1041    }
1042
1043    /// Max-norm dual infeasibility — `max(||∇_x L||_∞, ||∇_s L||_∞)`.
1044    /// Mirrors `IpIpoptCalculatedQuantities.cpp:CurrDualInfeasibility(NORM_MAX)`.
1045    pub fn curr_dual_infeasibility_max(&self) -> Number {
1046        let glx = self.curr_grad_lag_x();
1047        let gls = self.curr_grad_lag_s();
1048        glx.amax().max(gls.amax())
1049    }
1050
1051    /// Scaled stationarity of the infeasibility measure `½‖(c, d−s)‖²`
1052    /// — `‖J_cᵀ c + J_dᵀ (d−s)‖_∞ / max(1, ‖(c, d−s)‖_∞)`. The
1053    /// numerator is the x-gradient of the squared constraint
1054    /// violation; a value near zero with the violation itself bounded
1055    /// away from zero marks an iterate converging to a stationary
1056    /// point of the infeasibility — i.e. a locally infeasible problem.
1057    /// No linear solve: two transpose-products. Mirrors the gradient
1058    /// term behind Ipopt's `IpRestoConvCheck.cpp` `LOCALLY_INFEASIBLE`
1059    /// test, applied here in the main loop.
1060    pub fn curr_infeasibility_stationarity(&self) -> Number {
1061        let c = self.curr_c();
1062        let dms = self.curr_d_minus_s();
1063        let jc_t_c = self.curr_jac_c_t_times_vec(&*c);
1064        let jd_t_dms = self.curr_jac_d_t_times_vec(&*dms);
1065        let mut grad = jc_t_c.make_new();
1066        grad.add_two_vectors(1.0, &*jc_t_c, 1.0, &*jd_t_dms, 0.0);
1067        let viol = c.amax().max(dms.amax());
1068        grad.amax() / viol.max(1.0)
1069    }
1070
1071    // --------------------------------------------------------------
1072    // Average / scalar complementarity
1073    // --------------------------------------------------------------
1074
1075    /// `(z_L · s_L + z_U · s_U + v_L · s_L^d + v_U · s_U^d) / N`
1076    /// where `N` is the total number of bound multipliers
1077    /// (`IpIpoptCalculatedQuantities.cpp:3553-3606`).
1078    pub fn curr_avrg_compl(&self) -> Number {
1079        let iv = self.curr_iv();
1080        let n = iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
1081        if n == 0 {
1082            return 0.0;
1083        }
1084        let s_x_l = self.curr_slack_x_l();
1085        let s_x_u = self.curr_slack_x_u();
1086        let s_s_l = self.curr_slack_s_l();
1087        let s_s_u = self.curr_slack_s_u();
1088        let mut acc = iv.z_l.dot(&*s_x_l);
1089        acc += iv.z_u.dot(&*s_x_u);
1090        acc += iv.v_l.dot(&*s_s_l);
1091        acc += iv.v_u.dot(&*s_s_u);
1092        acc / Number::from(n)
1093    }
1094
1095    /// `min_i (s_i · z_i)` over all four bound complementarity blocks.
1096    /// Mirrors `IpIpoptCalculatedQuantities.cpp:CurrComplxMin`
1097    /// (lines 3608-3640) — the smallest pairwise product `s · z`,
1098    /// signalling how close the iterate is to the central path.
1099    /// Empty bound sets contribute `+∞`; returns `0` if no bounds at
1100    /// all.
1101    pub fn curr_complementarity_min(&self) -> Number {
1102        let cxl = self.curr_compl_x_l();
1103        let cxu = self.curr_compl_x_u();
1104        let csl = self.curr_compl_s_l();
1105        let csu = self.curr_compl_s_u();
1106        let m = |v: &Rc<dyn Vector>| {
1107            if v.dim() == 0 {
1108                Number::INFINITY
1109            } else {
1110                v.min()
1111            }
1112        };
1113        let acc = m(&cxl).min(m(&cxu)).min(m(&csl)).min(m(&csu));
1114        if acc.is_infinite() {
1115            0.0
1116        } else {
1117            acc
1118        }
1119    }
1120
1121    /// Max-norm of the unbarriered complementarity blocks
1122    /// `max_i |s_i · z_i|` across all four `(x_L, x_U, s_L, s_U)`
1123    /// pairs. Mirrors upstream
1124    /// `IpIpoptCalculatedQuantities.cpp:CurrComplementarity(0., NORM_MAX)`
1125    /// — used by `OptimalityErrorConvergenceCheck` to gate the
1126    /// per-component `compl_inf_tol` test independently of the scaled
1127    /// scalar `curr_nlp_error`.
1128    pub fn curr_complementarity_max(&self) -> Number {
1129        self.curr_compl_x_l()
1130            .amax()
1131            .max(self.curr_compl_x_u().amax())
1132            .max(self.curr_compl_s_l().amax())
1133            .max(self.curr_compl_s_u().amax())
1134    }
1135
1136    /// Centrality measure `ξ = min_i(s_i z_i) / avrg(s · z)`. Mirrors
1137    /// `IpIpoptCalculatedQuantities.cpp:CurrCentralityMeasure`. Used
1138    /// by [`crate::mu::oracle::loqo::LoqoMuOracle`] to bias σ toward
1139    /// the central path when the iterate is unbalanced. Returns `1.0`
1140    /// (perfectly central) when there are no bound multipliers.
1141    pub fn curr_centrality_measure(&self) -> Number {
1142        let avrg = self.curr_avrg_compl();
1143        if avrg <= 0.0 {
1144            return 1.0;
1145        }
1146        self.curr_complementarity_min() / avrg
1147    }
1148
1149    /// Barriered KKT error `E_μ(x,y,z)` — port of
1150    /// `IpIpoptCalculatedQuantities.cpp:CurrBarrierError`. Same as
1151    /// [`Self::curr_nlp_error`] but uses the *relaxed* complementarity
1152    /// `s ⊙ z − μ` so the residual is zero when the iterate sits on the
1153    /// μ-perturbed central path. The monotone barrier-update strategy
1154    /// reduces μ only once this error drops below
1155    /// `barrier_tol_factor · μ`.
1156    pub fn curr_barrier_error(&self) -> Number {
1157        let iv = self.curr_iv();
1158        let (s_d, s_c) = self.optimality_error_scaling(&iv);
1159
1160        let glx = self.curr_grad_lag_x();
1161        let gls = self.curr_grad_lag_s();
1162        let dual = glx.amax().max(gls.amax()) / s_d;
1163
1164        let c = self.curr_c();
1165        let dms = self.curr_d_minus_s();
1166        let primal = c.amax().max(dms.amax());
1167
1168        let compl = self
1169            .curr_relaxed_compl_x_l()
1170            .amax()
1171            .max(self.curr_relaxed_compl_x_u().amax())
1172            .max(self.curr_relaxed_compl_s_l().amax())
1173            .max(self.curr_relaxed_compl_s_u().amax())
1174            / s_c;
1175
1176        dual.max(primal).max(compl)
1177    }
1178
1179    /// Optimality-scaled max-norm KKT error — port of
1180    /// `IpIpoptCalculatedQuantities.cpp:3050-3104`.
1181    ///
1182    /// ```text
1183    ///   E = max( ||∇_x L, ∇_s L||_∞ / s_d ,
1184    ///            ||c, d − s||_∞ ,
1185    ///            ||compl||_∞ / s_c )
1186    /// ```
1187    ///
1188    /// where `s_d` / `s_c` are the asum-based scalings from
1189    /// `ComputeOptimalityErrorScaling` (see §4 of `MAIN_LOOP.md`).
1190    /// Uses `mu_target = 0` (the unbarriered KKT residual). The
1191    /// barriered variant is `curr_barrier_error` (TODO in Phase 7).
1192    pub fn curr_nlp_error(&self) -> Number {
1193        let iv = self.curr_iv();
1194        let (s_d, s_c) = self.optimality_error_scaling(&iv);
1195
1196        // dual infeasibility (max-norm of grad_lag_x and grad_lag_s)
1197        let glx = self.curr_grad_lag_x();
1198        let gls = self.curr_grad_lag_s();
1199        let dual = glx.amax().max(gls.amax()) / s_d;
1200
1201        // primal: max(||c||, ||d-s||)
1202        let c = self.curr_c();
1203        let dms = self.curr_d_minus_s();
1204        let primal = c.amax().max(dms.amax());
1205
1206        // unbarriered complementarity (mu_target = 0 → just ||compl||)
1207        let compl = self
1208            .curr_compl_x_l()
1209            .amax()
1210            .max(self.curr_compl_x_u().amax())
1211            .max(self.curr_compl_s_l().amax())
1212            .max(self.curr_compl_s_u().amax())
1213            / s_c;
1214
1215        dual.max(primal).max(compl)
1216    }
1217
1218    /// `(s_d, s_c)` per `ComputeOptimalityErrorScaling`
1219    /// (`IpIpoptCalculatedQuantities.cpp:3663-3700`).
1220    fn optimality_error_scaling(&self, iv: &IteratesVector) -> (Number, Number) {
1221        let s_max = self.s_max;
1222
1223        // s_c: mean asum of all bound multipliers, capped at s_max,
1224        //      divided by s_max.
1225        let n_c = iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
1226        let s_c = if n_c == 0 {
1227            1.0
1228        } else {
1229            let asum = iv.z_l.asum() + iv.z_u.asum() + iv.v_l.asum() + iv.v_u.asum();
1230            (s_max.max(asum / Number::from(n_c))) / s_max
1231        };
1232
1233        // s_d: mean asum of all dual multipliers, capped, divided.
1234        let n_d =
1235            iv.y_c.dim() + iv.y_d.dim() + iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
1236        let s_d = if n_d == 0 {
1237            1.0
1238        } else {
1239            let asum = iv.y_c.asum()
1240                + iv.y_d.asum()
1241                + iv.z_l.asum()
1242                + iv.z_u.asum()
1243                + iv.v_l.asum()
1244                + iv.v_u.asum();
1245            (s_max.max(asum / Number::from(n_d))) / s_max
1246        };
1247
1248        (s_d, s_c)
1249    }
1250
1251    // --------------------------------------------------------------
1252    // Trial-side Lagrangian gradient / complementarity — needed by
1253    // the soft restoration phase's primal-dual error test. Each is a
1254    // line-for-line analog of the `curr_*` method above, reading the
1255    // `trial` iterate instead of `curr`.
1256    // --------------------------------------------------------------
1257
1258    pub fn trial_jac_c(&self) -> Rc<dyn Matrix> {
1259        let iv = self.trial_iv();
1260        self.nlp.borrow_mut().eval_jac_c(&*iv.x)
1261    }
1262
1263    pub fn trial_jac_d(&self) -> Rc<dyn Matrix> {
1264        let iv = self.trial_iv();
1265        self.nlp.borrow_mut().eval_jac_d(&*iv.x)
1266    }
1267
1268    /// `∇_x L` at the trial iterate — analog of [`Self::curr_grad_lag_x`].
1269    pub fn trial_grad_lag_x(&self) -> Rc<dyn Vector> {
1270        let iv = self.trial_iv();
1271        let grad_f = self.trial_grad_f();
1272        let jac_c = self.trial_jac_c();
1273        let jac_d = self.trial_jac_d();
1274
1275        let mut jc_t = iv.x.make_new();
1276        jac_c.trans_mult_vector(1.0, &*iv.y_c, 0.0, &mut *jc_t);
1277        let mut jd_t = iv.x.make_new();
1278        jac_d.trans_mult_vector(1.0, &*iv.y_d, 0.0, &mut *jd_t);
1279
1280        let mut tmp = iv.x.make_new();
1281        tmp.copy(&*grad_f);
1282        tmp.add_two_vectors(1.0, &*jc_t, 1.0, &*jd_t, 1.0);
1283
1284        let nlp = self.nlp.borrow();
1285        nlp.px_l().mult_vector(-1.0, &*iv.z_l, 1.0, &mut *tmp);
1286        nlp.px_u().mult_vector(1.0, &*iv.z_u, 1.0, &mut *tmp);
1287        rc_from(tmp)
1288    }
1289
1290    /// `∇_s L` at the trial iterate — analog of [`Self::curr_grad_lag_s`].
1291    pub fn trial_grad_lag_s(&self) -> Rc<dyn Vector> {
1292        let iv = self.trial_iv();
1293        let mut tmp = iv.y_d.make_new();
1294        let nlp = self.nlp.borrow();
1295        nlp.pd_u().mult_vector(1.0, &*iv.v_u, 0.0, &mut *tmp);
1296        nlp.pd_l().mult_vector(-1.0, &*iv.v_l, 1.0, &mut *tmp);
1297        tmp.axpy(-1.0, &*iv.y_d);
1298        rc_from(tmp)
1299    }
1300
1301    pub fn trial_compl_x_l(&self) -> Rc<dyn Vector> {
1302        Self::calc_compl(&*self.trial_slack_x_l(), &*self.trial_iv().z_l)
1303    }
1304
1305    pub fn trial_compl_x_u(&self) -> Rc<dyn Vector> {
1306        Self::calc_compl(&*self.trial_slack_x_u(), &*self.trial_iv().z_u)
1307    }
1308
1309    pub fn trial_compl_s_l(&self) -> Rc<dyn Vector> {
1310        Self::calc_compl(&*self.trial_slack_s_l(), &*self.trial_iv().v_l)
1311    }
1312
1313    pub fn trial_compl_s_u(&self) -> Rc<dyn Vector> {
1314        Self::calc_compl(&*self.trial_slack_s_u(), &*self.trial_iv().v_u)
1315    }
1316
1317    /// `||s ⊙ z − μ||₁` summed over the four complementarity blocks.
1318    fn relaxed_compl_asum(blocks: &[Rc<dyn Vector>], mu: Number) -> Number {
1319        let mut acc = 0.0;
1320        for compl in blocks {
1321            if compl.dim() == 0 {
1322                continue;
1323            }
1324            let mut r = compl.make_new();
1325            r.copy(&**compl);
1326            r.add_scalar(-mu);
1327            acc += r.asum();
1328        }
1329        acc
1330    }
1331
1332    /// Unscaled primal-dual KKT system error at the current iterate —
1333    /// port of
1334    /// `IpIpoptCalculatedQuantities.cpp:curr_primal_dual_system_error`.
1335    /// Each block uses the 1-norm scaled by its entry count; the result
1336    /// is the sum of the dual-infeasibility, primal-infeasibility, and
1337    /// complementarity terms. Used by the soft restoration phase's
1338    /// sufficient-reduction test.
1339    pub fn curr_primal_dual_system_error(&self, mu: Number) -> Number {
1340        let iv = self.curr_iv();
1341        let n_dual = iv.x.dim() + iv.s.dim();
1342        let dual_inf =
1343            (self.curr_grad_lag_x().asum() + self.curr_grad_lag_s().asum()) / Number::from(n_dual);
1344
1345        let n_primal = iv.y_c.dim() + iv.y_d.dim();
1346        let primal_inf = if n_primal > 0 {
1347            (self.curr_c().asum() + self.curr_d_minus_s().asum()) / Number::from(n_primal)
1348        } else {
1349            0.0
1350        };
1351
1352        let n_cmpl = iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
1353        let cmpl = if n_cmpl > 0 {
1354            Self::relaxed_compl_asum(
1355                &[
1356                    self.curr_compl_x_l(),
1357                    self.curr_compl_x_u(),
1358                    self.curr_compl_s_l(),
1359                    self.curr_compl_s_u(),
1360                ],
1361                mu,
1362            ) / Number::from(n_cmpl)
1363        } else {
1364            0.0
1365        };
1366
1367        dual_inf + primal_inf + cmpl
1368    }
1369
1370    /// Unscaled primal-dual KKT system error at the trial iterate —
1371    /// trial-side analog of [`Self::curr_primal_dual_system_error`].
1372    pub fn trial_primal_dual_system_error(&self, mu: Number) -> Number {
1373        let iv = self.trial_iv();
1374        let n_dual = iv.x.dim() + iv.s.dim();
1375        let dual_inf = (self.trial_grad_lag_x().asum() + self.trial_grad_lag_s().asum())
1376            / Number::from(n_dual);
1377
1378        let n_primal = iv.y_c.dim() + iv.y_d.dim();
1379        let primal_inf = if n_primal > 0 {
1380            (self.trial_c().asum() + self.trial_d_minus_s().asum()) / Number::from(n_primal)
1381        } else {
1382            0.0
1383        };
1384
1385        let n_cmpl = iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
1386        let cmpl = if n_cmpl > 0 {
1387            Self::relaxed_compl_asum(
1388                &[
1389                    self.trial_compl_x_l(),
1390                    self.trial_compl_x_u(),
1391                    self.trial_compl_s_l(),
1392                    self.trial_compl_s_u(),
1393                ],
1394                mu,
1395            ) / Number::from(n_cmpl)
1396        } else {
1397            0.0
1398        };
1399
1400        dual_inf + primal_inf + cmpl
1401    }
1402
1403    // --------------------------------------------------------------
1404    // Damping indicators — `IpIpoptCalculatedQuantities.cpp:1044-1092`.
1405    //
1406    //   Tmp_x = P_L · 1 − P_U · 1   (per primal: +1 lower-only,
1407    //                                 −1 upper-only, 0 two-sided,
1408    //                                 0 unbounded)
1409    //   dampind_x_L =  P_L^T · Tmp_x   (1 on lower-only bounds)
1410    //   dampind_x_U = −P_U^T · Tmp_x   (1 on upper-only bounds)
1411    // --------------------------------------------------------------
1412
1413    fn damping_indicators(&self) -> DampingIndicators {
1414        let nlp = self.nlp.borrow();
1415
1416        let mut tmp_x_l = nlp.x_l().make_new();
1417        tmp_x_l.set(1.0);
1418        let mut tmp_x_u = nlp.x_u().make_new();
1419        tmp_x_u.set(1.0);
1420        let mut tmp_x = self.curr_iv().x.make_new();
1421        nlp.px_l().mult_vector(1.0, &*tmp_x_l, 0.0, &mut *tmp_x);
1422        nlp.px_u().mult_vector(-1.0, &*tmp_x_u, 1.0, &mut *tmp_x);
1423        let mut d_x_l = nlp.x_l().make_new();
1424        nlp.px_l().trans_mult_vector(1.0, &*tmp_x, 0.0, &mut *d_x_l);
1425        let mut d_x_u = nlp.x_u().make_new();
1426        nlp.px_u()
1427            .trans_mult_vector(-1.0, &*tmp_x, 0.0, &mut *d_x_u);
1428
1429        let mut tmp_s_l = nlp.d_l().make_new();
1430        tmp_s_l.set(1.0);
1431        let mut tmp_s_u = nlp.d_u().make_new();
1432        tmp_s_u.set(1.0);
1433        let mut tmp_s = self.curr_iv().s.make_new();
1434        nlp.pd_l().mult_vector(1.0, &*tmp_s_l, 0.0, &mut *tmp_s);
1435        nlp.pd_u().mult_vector(-1.0, &*tmp_s_u, 1.0, &mut *tmp_s);
1436        let mut d_s_l = nlp.d_l().make_new();
1437        nlp.pd_l().trans_mult_vector(1.0, &*tmp_s, 0.0, &mut *d_s_l);
1438        let mut d_s_u = nlp.d_u().make_new();
1439        nlp.pd_u()
1440            .trans_mult_vector(-1.0, &*tmp_s, 0.0, &mut *d_s_u);
1441
1442        DampingIndicators {
1443            x_l: rc_from(d_x_l),
1444            x_u: rc_from(d_x_u),
1445            s_l: rc_from(d_s_l),
1446            s_u: rc_from(d_s_u),
1447        }
1448    }
1449
1450    /// `curr_grad_lag_x` plus the `kappa_d · μ · (Px_L · 1 − Px_U · 1)`
1451    /// damping term on singly-bounded primals — port of
1452    /// `IpIpoptCalculatedQuantities.cpp:2131-2180`. When `kappa_d == 0`
1453    /// returns the un-damped gradient.
1454    pub fn curr_grad_lag_with_damping_x(&self) -> Rc<dyn Vector> {
1455        if self.kappa_d == 0.0 {
1456            return self.curr_grad_lag_x();
1457        }
1458        let mu = self.data.borrow().curr_mu;
1459        let di = self.damping_indicators();
1460        let (d_x_l, d_x_u) = (di.x_l, di.x_u);
1461        let glx = self.curr_grad_lag_x();
1462        let mut tmp = glx.make_new();
1463        tmp.copy(&*glx);
1464        let nlp = self.nlp.borrow();
1465        nlp.px_l()
1466            .mult_vector(self.kappa_d * mu, &*d_x_l, 1.0, &mut *tmp);
1467        nlp.px_u()
1468            .mult_vector(-self.kappa_d * mu, &*d_x_u, 1.0, &mut *tmp);
1469        rc_from(tmp)
1470    }
1471
1472    pub fn curr_grad_lag_with_damping_s(&self) -> Rc<dyn Vector> {
1473        if self.kappa_d == 0.0 {
1474            return self.curr_grad_lag_s();
1475        }
1476        let mu = self.data.borrow().curr_mu;
1477        let di = self.damping_indicators();
1478        let (d_s_l, d_s_u) = (di.s_l, di.s_u);
1479        let gls = self.curr_grad_lag_s();
1480        let mut tmp = gls.make_new();
1481        tmp.copy(&*gls);
1482        let nlp = self.nlp.borrow();
1483        nlp.pd_l()
1484            .mult_vector(self.kappa_d * mu, &*d_s_l, 1.0, &mut *tmp);
1485        nlp.pd_u()
1486            .mult_vector(-self.kappa_d * mu, &*d_s_u, 1.0, &mut *tmp);
1487        rc_from(tmp)
1488    }
1489
1490    /// `kappa_d · (P_L · damping_l − P_U · damping_u)` in the full x
1491    /// space — port of `IpIpoptCalculatedQuantities.cpp::grad_kappa_times_damping_x`
1492    /// (lines 912-949). Unlike `curr_grad_lag_with_damping_x` this does
1493    /// NOT include `grad_lag_x` and is NOT scaled by `mu`; the centering
1494    /// RHS in the quality-function oracle multiplies the returned vector
1495    /// by `-avrg_compl` per upstream `IpQualityFunctionMuOracle.cpp:229`.
1496    pub fn grad_kappa_times_damping_x(&self) -> Rc<dyn Vector> {
1497        let mut tmp = self.curr_iv().x.make_new();
1498        tmp.set(0.0);
1499        if self.kappa_d > 0.0 {
1500            let di = self.damping_indicators();
1501            let nlp = self.nlp.borrow();
1502            nlp.px_l()
1503                .mult_vector(self.kappa_d, &*di.x_l, 0.0, &mut *tmp);
1504            nlp.px_u()
1505                .mult_vector(-self.kappa_d, &*di.x_u, 1.0, &mut *tmp);
1506        }
1507        rc_from(tmp)
1508    }
1509
1510    pub fn grad_kappa_times_damping_s(&self) -> Rc<dyn Vector> {
1511        let mut tmp = self.curr_iv().s.make_new();
1512        tmp.set(0.0);
1513        if self.kappa_d > 0.0 {
1514            let di = self.damping_indicators();
1515            let nlp = self.nlp.borrow();
1516            nlp.pd_l()
1517                .mult_vector(self.kappa_d, &*di.s_l, 0.0, &mut *tmp);
1518            nlp.pd_u()
1519                .mult_vector(-self.kappa_d, &*di.s_u, 1.0, &mut *tmp);
1520        }
1521        rc_from(tmp)
1522    }
1523
1524    // --------------------------------------------------------------
1525    // Affine (predictor) step helpers — port of upstream
1526    // `IpIpoptCalculatedQuantities.cpp:CurrAvrgCompl`/`AffMaxAlpha…`
1527    // used by the Mehrotra probing oracle and the quality-function
1528    // oracle's σ-search.
1529    // --------------------------------------------------------------
1530
1531    /// Max primal step that keeps `s + α · Δs > 0` for the four slack
1532    /// blocks (x_L, x_U, s_L, s_U), bounded by the fraction-to-the-
1533    /// boundary parameter `τ ∈ (0, 1]`. Mirrors
1534    /// `CalcFracToBound` against the projected step `P_L^T Δx`,
1535    /// `−P_U^T Δx`, `P_L^T Δs`, `−P_U^T Δs`.
1536    pub fn aff_step_alpha_primal_max(&self, delta_aff: &IteratesVector, tau: Number) -> Number {
1537        let nlp = self.nlp.borrow();
1538        let s_x_l = self.curr_slack_x_l();
1539        let s_x_u = self.curr_slack_x_u();
1540        let s_s_l = self.curr_slack_s_l();
1541        let s_s_u = self.curr_slack_s_u();
1542
1543        // Project Δx / Δs onto each bound subspace with the right sign.
1544        let mut step_x_l = s_x_l.make_new();
1545        nlp.px_l()
1546            .trans_mult_vector(1.0, &*delta_aff.x, 0.0, &mut *step_x_l);
1547        let mut step_x_u = s_x_u.make_new();
1548        nlp.px_u()
1549            .trans_mult_vector(-1.0, &*delta_aff.x, 0.0, &mut *step_x_u);
1550        let mut step_s_l = s_s_l.make_new();
1551        nlp.pd_l()
1552            .trans_mult_vector(1.0, &*delta_aff.s, 0.0, &mut *step_s_l);
1553        let mut step_s_u = s_s_u.make_new();
1554        nlp.pd_u()
1555            .trans_mult_vector(-1.0, &*delta_aff.s, 0.0, &mut *step_s_u);
1556
1557        s_x_l
1558            .frac_to_bound(&*step_x_l, tau)
1559            .min(s_x_u.frac_to_bound(&*step_x_u, tau))
1560            .min(s_s_l.frac_to_bound(&*step_s_l, tau))
1561            .min(s_s_u.frac_to_bound(&*step_s_u, tau))
1562    }
1563
1564    /// Max dual step that keeps `z + α · Δz > 0` (and same for v).
1565    pub fn aff_step_alpha_dual_max(&self, delta_aff: &IteratesVector, tau: Number) -> Number {
1566        let iv = self.curr_iv();
1567        iv.z_l
1568            .frac_to_bound(&*delta_aff.z_l, tau)
1569            .min(iv.z_u.frac_to_bound(&*delta_aff.z_u, tau))
1570            .min(iv.v_l.frac_to_bound(&*delta_aff.v_l, tau))
1571            .min(iv.v_u.frac_to_bound(&*delta_aff.v_u, tau))
1572    }
1573
1574    /// Predicted average complementarity after the affine step:
1575    /// `(1/N) · Σ (s + α_pri · Δs) · (z + α_du · Δz)` summed over the
1576    /// four bound blocks. Returns `0` when there are no bounds.
1577    pub fn aff_step_compl_avrg(
1578        &self,
1579        delta_aff: &IteratesVector,
1580        alpha_primal: Number,
1581        alpha_dual: Number,
1582    ) -> Number {
1583        let iv = self.curr_iv();
1584        let n = iv.z_l.dim() + iv.z_u.dim() + iv.v_l.dim() + iv.v_u.dim();
1585        if n == 0 {
1586            return 0.0;
1587        }
1588        let nlp = self.nlp.borrow();
1589
1590        // s_X_L_aff = s_X_L + α_pri · P_L^T Δx
1591        let s_x_l = self.curr_slack_x_l();
1592        let mut s_x_l_aff = s_x_l.make_new();
1593        s_x_l_aff.copy(&*s_x_l);
1594        let mut tmp = s_x_l.make_new();
1595        nlp.px_l()
1596            .trans_mult_vector(1.0, &*delta_aff.x, 0.0, &mut *tmp);
1597        s_x_l_aff.axpy(alpha_primal, &*tmp);
1598        // z_L_aff = z_L + α_du · Δz_L
1599        let mut z_l_aff = iv.z_l.make_new();
1600        z_l_aff.copy(&*iv.z_l);
1601        z_l_aff.axpy(alpha_dual, &*delta_aff.z_l);
1602        let mut acc = s_x_l_aff.dot(&*z_l_aff);
1603
1604        // s_X_U_aff = s_X_U − α_pri · P_U^T Δx
1605        let s_x_u = self.curr_slack_x_u();
1606        let mut s_x_u_aff = s_x_u.make_new();
1607        s_x_u_aff.copy(&*s_x_u);
1608        let mut tmp = s_x_u.make_new();
1609        nlp.px_u()
1610            .trans_mult_vector(-1.0, &*delta_aff.x, 0.0, &mut *tmp);
1611        s_x_u_aff.axpy(alpha_primal, &*tmp);
1612        let mut z_u_aff = iv.z_u.make_new();
1613        z_u_aff.copy(&*iv.z_u);
1614        z_u_aff.axpy(alpha_dual, &*delta_aff.z_u);
1615        acc += s_x_u_aff.dot(&*z_u_aff);
1616
1617        // s_S_L_aff = s_S_L + α_pri · P_dL^T Δs
1618        let s_s_l = self.curr_slack_s_l();
1619        let mut s_s_l_aff = s_s_l.make_new();
1620        s_s_l_aff.copy(&*s_s_l);
1621        let mut tmp = s_s_l.make_new();
1622        nlp.pd_l()
1623            .trans_mult_vector(1.0, &*delta_aff.s, 0.0, &mut *tmp);
1624        s_s_l_aff.axpy(alpha_primal, &*tmp);
1625        let mut v_l_aff = iv.v_l.make_new();
1626        v_l_aff.copy(&*iv.v_l);
1627        v_l_aff.axpy(alpha_dual, &*delta_aff.v_l);
1628        acc += s_s_l_aff.dot(&*v_l_aff);
1629
1630        // s_S_U_aff = s_S_U − α_pri · P_dU^T Δs
1631        let s_s_u = self.curr_slack_s_u();
1632        let mut s_s_u_aff = s_s_u.make_new();
1633        s_s_u_aff.copy(&*s_s_u);
1634        let mut tmp = s_s_u.make_new();
1635        nlp.pd_u()
1636            .trans_mult_vector(-1.0, &*delta_aff.s, 0.0, &mut *tmp);
1637        s_s_u_aff.axpy(alpha_primal, &*tmp);
1638        let mut v_u_aff = iv.v_u.make_new();
1639        v_u_aff.copy(&*iv.v_u);
1640        v_u_aff.axpy(alpha_dual, &*delta_aff.v_u);
1641        acc += s_s_u_aff.dot(&*v_u_aff);
1642
1643        acc / Number::from(n)
1644    }
1645}
1646
1647/// Convenience handle. Mirrors upstream's `SmartPtr<CQ>` flow.
1648pub type IpoptCqHandle = Rc<RefCell<IpoptCalculatedQuantities>>;
1649
1650/// Bundle of damping indicators for the four bound spaces — kept
1651/// internal because `kappa_d == 0` makes them dead in the default
1652/// configuration.
1653struct DampingIndicators {
1654    x_l: Rc<dyn Vector>,
1655    x_u: Rc<dyn Vector>,
1656    s_l: Rc<dyn Vector>,
1657    s_u: Rc<dyn Vector>,
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662    use super::*;
1663    use crate::ipopt_data::IpoptData;
1664    use crate::iterates_vector::IteratesVector;
1665    use pounce_common::types::Index;
1666    use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
1667    use pounce_linalg::expansion_matrix::{ExpansionMatrix, ExpansionMatrixSpace};
1668    use pounce_linalg::triplet::{GenTMatrix, GenTMatrixSpace};
1669    use std::rc::Rc as StdRc;
1670
1671    fn dvec(values: &[Number]) -> DenseVector {
1672        let space = DenseVectorSpace::new(values.len() as Index);
1673        let mut v = space.make_new_dense();
1674        v.values_mut().copy_from_slice(values);
1675        v
1676    }
1677
1678    fn rcv(values: &[Number]) -> Rc<dyn Vector> {
1679        StdRc::new(dvec(values))
1680    }
1681
1682    /// Mock IpoptNlp covering: 2 vars, 1 equality, 1 inequality.
1683    /// Bounds: x[0] ≥ 0, x[1] ≤ 5, d ≥ 1.
1684    /// f(x) = x[0]^2 + x[1]^2; ∇f = (2x[0], 2x[1])
1685    /// c(x) = x[0] + x[1] - 1
1686    /// d(x) = x[0]
1687    struct MockNlp {
1688        x_l: DenseVector,
1689        x_u: DenseVector,
1690        d_l: DenseVector,
1691        d_u: DenseVector,
1692        px_l: Rc<dyn Matrix>,
1693        px_u: Rc<dyn Matrix>,
1694        pd_l: Rc<dyn Matrix>,
1695        pd_u: Rc<dyn Matrix>,
1696        // NLP scaling factors. Identity by default; `with_scaling`
1697        // installs non-trivial ones to exercise the unscaled accessors.
1698        // (The mock does not actually apply these in `eval_*`; the tests
1699        // verify the unscaling *arithmetic*, not end-to-end scaling.)
1700        obj_scale: Number,
1701        c_scale: Option<Vec<Number>>,
1702        d_scale: Option<Vec<Number>>,
1703    }
1704
1705    impl MockNlp {
1706        fn with_scaling(
1707            mut self,
1708            obj_scale: Number,
1709            c_scale: Option<Vec<Number>>,
1710            d_scale: Option<Vec<Number>>,
1711        ) -> Self {
1712            self.obj_scale = obj_scale;
1713            self.c_scale = c_scale;
1714            self.d_scale = d_scale;
1715            self
1716        }
1717
1718        fn new() -> Self {
1719            // x_L holds finite lower bounds; here only x[0] has one (=0).
1720            let x_l = dvec(&[0.0]);
1721            // x_U holds finite upper bounds; here only x[1] has one (=5).
1722            let x_u = dvec(&[5.0]);
1723            // d has one finite lower bound (d ≥ 1) and no finite upper.
1724            let d_l = dvec(&[1.0]);
1725            let d_u = dvec(&[]);
1726
1727            let px_l_space = ExpansionMatrixSpace::new(2, 1, &[0], 0);
1728            let px_u_space = ExpansionMatrixSpace::new(2, 1, &[1], 0);
1729            let pd_l_space = ExpansionMatrixSpace::new(1, 1, &[0], 0);
1730            let pd_u_space = ExpansionMatrixSpace::new(1, 0, &[], 0);
1731
1732            Self {
1733                x_l,
1734                x_u,
1735                d_l,
1736                d_u,
1737                px_l: StdRc::new(ExpansionMatrix::new(px_l_space)),
1738                px_u: StdRc::new(ExpansionMatrix::new(px_u_space)),
1739                pd_l: StdRc::new(ExpansionMatrix::new(pd_l_space)),
1740                pd_u: StdRc::new(ExpansionMatrix::new(pd_u_space)),
1741                obj_scale: 1.0,
1742                c_scale: None,
1743                d_scale: None,
1744            }
1745        }
1746    }
1747
1748    impl crate::ipopt_nlp::Nlp for MockNlp {
1749        fn n(&self) -> Index {
1750            2
1751        }
1752        fn m_eq(&self) -> Index {
1753            1
1754        }
1755        fn m_ineq(&self) -> Index {
1756            1
1757        }
1758        fn eval_f(&mut self, x: &dyn Vector) -> Number {
1759            // f(x) = x[0]^2 + x[1]^2
1760            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
1761            xx.values()[0] * xx.values()[0] + xx.values()[1] * xx.values()[1]
1762        }
1763        fn eval_grad_f(&mut self, x: &dyn Vector, g: &mut dyn Vector) {
1764            // grad f = (2 x[0], 2 x[1])
1765            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
1766            let gg = g.as_any_mut().downcast_mut::<DenseVector>().unwrap();
1767            gg.values_mut()[0] = 2.0 * xx.values()[0];
1768            gg.values_mut()[1] = 2.0 * xx.values()[1];
1769        }
1770        fn eval_c(&mut self, x: &dyn Vector, c: &mut dyn Vector) {
1771            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
1772            let cc = c.as_any_mut().downcast_mut::<DenseVector>().unwrap();
1773            cc.values_mut()[0] = xx.values()[0] + xx.values()[1] - 1.0;
1774        }
1775        fn eval_d(&mut self, x: &dyn Vector, d: &mut dyn Vector) {
1776            let xx = x.as_any().downcast_ref::<DenseVector>().unwrap();
1777            let dd = d.as_any_mut().downcast_mut::<DenseVector>().unwrap();
1778            dd.values_mut()[0] = xx.values()[0];
1779        }
1780        fn eval_jac_c(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
1781            // c(x) = x0 + x1 - 1 → Jc = [1, 1] (1×2), nonzeros (1,1),(1,2).
1782            let space = GenTMatrixSpace::new(1, 2, vec![1, 1], vec![1, 2]);
1783            let mut jac = GenTMatrix::new(space);
1784            jac.set_values(&[1.0, 1.0]);
1785            StdRc::new(jac)
1786        }
1787        fn eval_jac_d(&mut self, _x: &dyn Vector) -> Rc<dyn Matrix> {
1788            // d(x) = x0 → Jd = [1, 0] (1×2), single nonzero (1,1).
1789            let space = GenTMatrixSpace::new(1, 2, vec![1], vec![1]);
1790            let mut jac = GenTMatrix::new(space);
1791            jac.set_values(&[1.0]);
1792            StdRc::new(jac)
1793        }
1794        fn eval_h(
1795            &mut self,
1796            _x: &dyn Vector,
1797            _obj_factor: Number,
1798            _y_c: &dyn Vector,
1799            _y_d: &dyn Vector,
1800        ) -> Rc<dyn SymMatrix> {
1801            unimplemented!()
1802        }
1803    }
1804
1805    impl IpoptNlp for MockNlp {
1806        fn x_l(&self) -> &dyn Vector {
1807            &self.x_l
1808        }
1809        fn x_u(&self) -> &dyn Vector {
1810            &self.x_u
1811        }
1812        fn d_l(&self) -> &dyn Vector {
1813            &self.d_l
1814        }
1815        fn d_u(&self) -> &dyn Vector {
1816            &self.d_u
1817        }
1818        fn px_l(&self) -> Rc<dyn Matrix> {
1819            self.px_l.clone()
1820        }
1821        fn px_u(&self) -> Rc<dyn Matrix> {
1822            self.px_u.clone()
1823        }
1824        fn pd_l(&self) -> Rc<dyn Matrix> {
1825            self.pd_l.clone()
1826        }
1827        fn pd_u(&self) -> Rc<dyn Matrix> {
1828            self.pd_u.clone()
1829        }
1830        fn obj_scaling_factor(&self) -> Number {
1831            self.obj_scale
1832        }
1833        fn c_scale_vec(&self) -> Option<Vec<Number>> {
1834            self.c_scale.clone()
1835        }
1836        fn d_scale_vec(&self) -> Option<Vec<Number>> {
1837            self.d_scale.clone()
1838        }
1839    }
1840
1841    fn fixture() -> IpoptCalculatedQuantities {
1842        fixture_with(MockNlp::new())
1843    }
1844
1845    fn fixture_with(nlp: MockNlp) -> IpoptCalculatedQuantities {
1846        let mut data = IpoptData::new();
1847        data.curr_mu = 0.1;
1848        // Iterate: x = (2, 3); s = (4); y_c = (1); y_d = (1);
1849        // z_L = (0.5) [bound on x[0]], z_U = (0.7) [bound on x[1]],
1850        // v_L = (0.3), v_U = ().
1851        let iv = IteratesVector::new(
1852            rcv(&[2.0, 3.0]),
1853            rcv(&[4.0]),
1854            rcv(&[1.0]),
1855            rcv(&[1.0]),
1856            rcv(&[0.5]),
1857            rcv(&[0.7]),
1858            rcv(&[0.3]),
1859            rcv(&[]),
1860        );
1861        data.set_curr(iv);
1862        let data_handle = StdRc::new(RefCell::new(data));
1863        let nlp: StdRc<RefCell<dyn IpoptNlp>> = StdRc::new(RefCell::new(nlp));
1864        let mut cq = IpoptCalculatedQuantities::new(data_handle, nlp);
1865        // Disable damping for clean unit-test expectations.
1866        cq.kappa_d = 0.0;
1867        cq
1868    }
1869
1870    fn dense_vals(v: &Rc<dyn Vector>) -> Vec<Number> {
1871        v.as_any()
1872            .downcast_ref::<DenseVector>()
1873            .unwrap()
1874            .values()
1875            .to_vec()
1876    }
1877
1878    #[test]
1879    fn slack_x_lower_is_x0_minus_x_l() {
1880        // P_L^T x = [x[0]] = [2]; x_L = [0]; slack = 2 - 0 = 2.
1881        let cq = fixture();
1882        assert_eq!(dense_vals(&cq.curr_slack_x_l()), vec![2.0]);
1883    }
1884
1885    #[test]
1886    fn slack_x_upper_is_x_u_minus_x1() {
1887        // x_U = [5]; P_U^T x = [3]; slack = 5 - 3 = 2.
1888        let cq = fixture();
1889        assert_eq!(dense_vals(&cq.curr_slack_x_u()), vec![2.0]);
1890    }
1891
1892    #[test]
1893    fn slack_s_lower() {
1894        // d_L = [1]; P_L^T s = [4]; slack = 4 - 1 = 3.
1895        let cq = fixture();
1896        assert_eq!(dense_vals(&cq.curr_slack_s_l()), vec![3.0]);
1897    }
1898
1899    #[test]
1900    fn grad_f_is_twice_x() {
1901        let cq = fixture();
1902        assert_eq!(dense_vals(&cq.curr_grad_f()), vec![4.0, 6.0]);
1903    }
1904
1905    #[test]
1906    fn compl_x_l_is_slack_times_z() {
1907        // slack_x_L = [2]; z_L = [0.5]; compl = [1.0]
1908        let cq = fixture();
1909        assert_eq!(dense_vals(&cq.curr_compl_x_l()), vec![1.0]);
1910    }
1911
1912    #[test]
1913    fn relaxed_compl_x_l_subtracts_mu() {
1914        // compl = 1.0; mu = 0.1; relaxed = 0.9.
1915        let cq = fixture();
1916        assert!((dense_vals(&cq.curr_relaxed_compl_x_l())[0] - 0.9).abs() < 1e-15);
1917    }
1918
1919    #[test]
1920    fn sigma_x_routes_z_over_slack_through_p() {
1921        // P_L lifts (z_L/s_L) = (0.5/2 = 0.25) into x[0] slot.
1922        // P_U lifts (z_U/s_U) = (0.7/2 = 0.35) into x[1] slot.
1923        // sigma = (0.25, 0.35)
1924        let cq = fixture();
1925        let s = dense_vals(&cq.curr_sigma_x());
1926        assert!((s[0] - 0.25).abs() < 1e-15);
1927        assert!((s[1] - 0.35).abs() < 1e-15);
1928    }
1929
1930    #[test]
1931    fn sigma_s_lower_only() {
1932        // P_L lifts (v_L/s_L) = (0.3/3 = 0.1).
1933        let cq = fixture();
1934        let s = dense_vals(&cq.curr_sigma_s());
1935        assert!((s[0] - 0.1).abs() < 1e-15);
1936    }
1937
1938    #[test]
1939    fn avrg_compl_averages_over_active_bounds() {
1940        // z_L·s_L + z_U·s_U + v_L·s_s_L + v_U·s_s_U
1941        // = 0.5*2 + 0.7*2 + 0.3*3 + 0
1942        // = 1 + 1.4 + 0.9 = 3.3
1943        // N = 1 + 1 + 1 + 0 = 3 → 1.1
1944        let cq = fixture();
1945        assert!((cq.curr_avrg_compl() - 1.1).abs() < 1e-15);
1946    }
1947
1948    #[test]
1949    fn complementarity_min_takes_min_over_active_pairs() {
1950        // compl entries: z_L·s_L=1.0, z_U·s_U=1.4, v_L·s_s_L=0.9.
1951        // v_U is empty (skipped). Min = 0.9.
1952        let cq = fixture();
1953        assert!((cq.curr_complementarity_min() - 0.9).abs() < 1e-15);
1954    }
1955
1956    #[test]
1957    fn centrality_measure_is_min_over_avrg() {
1958        // min/avrg = 0.9 / 1.1 ≈ 0.81818…
1959        let cq = fixture();
1960        let xi = cq.curr_centrality_measure();
1961        assert!((xi - 0.9 / 1.1).abs() < 1e-15);
1962    }
1963
1964    #[test]
1965    fn curr_f_evaluates_objective() {
1966        // f(x) = x[0]^2 + x[1]^2 at x = (2, 3) → 4 + 9 = 13.
1967        let cq = fixture();
1968        assert!((cq.curr_f() - 13.0).abs() < 1e-15);
1969    }
1970
1971    #[test]
1972    fn curr_barrier_obj_subtracts_mu_log_slacks() {
1973        // f = 13; slacks = (s_x_L=2, s_x_U=2, s_s_L=3, s_s_U=∅).
1974        // log_sum = ln 2 + ln 2 + ln 3 + 0 = 2 ln 2 + ln 3.
1975        // mu = 0.1 → phi = 13 - 0.1*(2 ln 2 + ln 3).
1976        let cq = fixture();
1977        let expected = 13.0 - 0.1 * (2.0 * 2.0_f64.ln() + 3.0_f64.ln());
1978        assert!((cq.curr_barrier_obj() - expected).abs() < 1e-13);
1979    }
1980
1981    #[test]
1982    fn curr_constraint_violation_is_one_norm() {
1983        // c(x) = x[0]+x[1]-1 = 4 ⇒ |c| = 4.
1984        // d(x)=x[0]=2; s=4 ⇒ d-s = -2 ⇒ |d-s| = 2.
1985        // theta = 4 + 2 = 6.
1986        let cq = fixture();
1987        assert!((cq.curr_constraint_violation() - 6.0).abs() < 1e-13);
1988    }
1989
1990    #[test]
1991    fn grad_barrier_obj_x_subtracts_mu_inv_slack() {
1992        // grad_f = (4, 6).
1993        // P_L lifts -mu*(1/s_x_L) = -0.1*(1/2)=-0.05 into x[0].
1994        // P_U lifts +mu*(1/s_x_U) = +0.1*(1/2)=+0.05 into x[1].
1995        // result = (4 - 0.05, 6 + 0.05) = (3.95, 6.05).
1996        let cq = fixture();
1997        let g = dense_vals(&cq.curr_grad_barrier_obj_x());
1998        assert!((g[0] - 3.95).abs() < 1e-13);
1999        assert!((g[1] - 6.05).abs() < 1e-13);
2000    }
2001
2002    #[test]
2003    fn grad_lag_s_is_minus_y_d_minus_pl_v_l_plus_pu_v_u() {
2004        // tmp = P_U v_U = (zero-dim contrib) → 0
2005        // tmp -= P_L v_L → tmp = -[0.3]
2006        // tmp -= y_d = -[0.3] - [1.0] = [-1.3]
2007        let cq = fixture();
2008        assert!((dense_vals(&cq.curr_grad_lag_s())[0] + 1.3).abs() < 1e-15);
2009    }
2010
2011    fn zero_iv_like(iv: &IteratesVector) -> IteratesVector {
2012        // Materialize explicit zeros for every component so the
2013        // affine-step tests can compose direct-sum updates.
2014        IteratesVector::new(
2015            rcv(&vec![0.0; iv.x.dim() as usize]),
2016            rcv(&vec![0.0; iv.s.dim() as usize]),
2017            rcv(&vec![0.0; iv.y_c.dim() as usize]),
2018            rcv(&vec![0.0; iv.y_d.dim() as usize]),
2019            rcv(&vec![0.0; iv.z_l.dim() as usize]),
2020            rcv(&vec![0.0; iv.z_u.dim() as usize]),
2021            rcv(&vec![0.0; iv.v_l.dim() as usize]),
2022            rcv(&vec![0.0; iv.v_u.dim() as usize]),
2023        )
2024    }
2025
2026    #[test]
2027    fn aff_step_compl_avrg_with_zero_step_matches_curr_avrg_compl() {
2028        // Δ_aff = 0 ⇒ predicted compl ≡ current compl.
2029        // 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.
2030        // Total = 3.3; N = 3 (z_l + z_u + v_l, v_u empty); avrg = 1.1.
2031        let cq = fixture();
2032        let iv = cq.curr_iv();
2033        let zero = zero_iv_like(&iv);
2034        let m = cq.aff_step_compl_avrg(&zero, 1.0, 1.0);
2035        assert!((m - 1.1).abs() < 1e-13);
2036        assert!((cq.curr_avrg_compl() - 1.1).abs() < 1e-13);
2037    }
2038
2039    #[test]
2040    fn aff_step_compl_avrg_responds_to_primal_step() {
2041        // Δ_aff.x = (1, 0), α_pri = 1, others = 0.
2042        // s_X_L_aff = 2 + 1·1 = 3; s_X_U_aff = 2 (P_U^T·dx = 0); s_S_L_aff = 3.
2043        // (3·0.5 + 2·0.7 + 3·0.3) / 3 = (1.5 + 1.4 + 0.9) / 3 = 1.2667.
2044        let cq = fixture();
2045        let iv = cq.curr_iv();
2046        let mut z = zero_iv_like(&iv);
2047        z.x = rcv(&[1.0, 0.0]);
2048        let m = cq.aff_step_compl_avrg(&z, 1.0, 1.0);
2049        assert!((m - 1.2666666666666666).abs() < 1e-13);
2050    }
2051
2052    #[test]
2053    fn aff_step_alpha_primal_truncates_to_x_lower_bound() {
2054        // Δ_aff.x = (-3, 0); s_X_L = 2; tau = 1 ⇒ α_max = 2/3.
2055        let cq = fixture();
2056        let iv = cq.curr_iv();
2057        let mut z = zero_iv_like(&iv);
2058        z.x = rcv(&[-3.0, 0.0]);
2059        let a = cq.aff_step_alpha_primal_max(&z, 1.0);
2060        assert!((a - 2.0 / 3.0).abs() < 1e-13);
2061    }
2062
2063    #[test]
2064    fn aff_step_alpha_dual_truncates_to_z_lower_bound() {
2065        // Δ_aff.z_L = (-1); z_L = 0.5; tau = 1 ⇒ α_max = 0.5.
2066        let cq = fixture();
2067        let iv = cq.curr_iv();
2068        let mut z = zero_iv_like(&iv);
2069        z.z_l = rcv(&[-1.0]);
2070        let a = cq.aff_step_alpha_dual_max(&z, 1.0);
2071        assert!((a - 0.5).abs() < 1e-13);
2072    }
2073
2074    #[test]
2075    fn grad_barr_t_delta_dots_barrier_grads_with_step() {
2076        // ∇_x φ = (3.95, 6.05); ∇_s φ = (-mu/s_s_L) = -0.1/3 ≈ -0.03333…
2077        // δx = (1, 2); δs = (3): result = 3.95·1 + 6.05·2 + (-0.0333…)·3
2078        //                              = 3.95 + 12.10 − 0.1 = 15.95.
2079        let cq = fixture();
2080        let dx = dvec(&[1.0, 2.0]);
2081        let ds = dvec(&[3.0]);
2082        let r = cq.curr_grad_barr_t_delta(&dx, &ds);
2083        let expected = 3.95 + 12.10 - 0.1;
2084        assert!((r - expected).abs() < 1e-13, "r = {r}");
2085    }
2086
2087    #[test]
2088    fn dwd_with_no_w_collapses_to_sigma_quadratic() {
2089        // W is None in the fixture (no Hessian seeded), perts default to 0.
2090        // σ_x = (0.25, 0.35); σ_s = (0.1).
2091        // δx = (2, -1); δs = (3) ⇒ dWd = 0.25·4 + 0.35·1 + 0.1·9
2092        //                              = 1.00 + 0.35 + 0.90 = 2.25.
2093        let cq = fixture();
2094        let dx = dvec(&[2.0, -1.0]);
2095        let ds = dvec(&[3.0]);
2096        let r = cq.curr_dwd(&dx, &ds);
2097        assert!((r - 2.25).abs() < 1e-13, "r = {r}");
2098    }
2099
2100    #[test]
2101    fn dwd_includes_pd_perturbations() {
2102        // Without perts: dWd = 0.25·4 + 0.35·1 + 0.1·9 = 2.25.
2103        // δ_pert_x = 0.5, δ_pert_s = 0.25:
2104        //   add δ_pert_x · ‖δx‖² + δ_pert_s · ‖δs‖²
2105        //     = 0.5·(4+1) + 0.25·9 = 2.5 + 2.25 = 4.75.
2106        // Total = 7.00.
2107        let cq = fixture();
2108        {
2109            let mut d = cq.data.borrow_mut();
2110            d.perturbations.delta_x = 0.5;
2111            d.perturbations.delta_s = 0.25;
2112        }
2113        let dx = dvec(&[2.0, -1.0]);
2114        let ds = dvec(&[3.0]);
2115        let r = cq.curr_dwd(&dx, &ds);
2116        assert!((r - 7.00).abs() < 1e-13, "r = {r}");
2117    }
2118
2119    // ---- Unscaled (user-space) KKT residuals — pounce#173 -------------
2120
2121    #[test]
2122    fn unscaled_dual_inf_is_scaled_over_df() {
2123        // df = 2: every Lagrangian-gradient term carries the objective
2124        // factor, so the unscaled dual infeasibility is the scaled one
2125        // divided by df.
2126        let cq = fixture_with(MockNlp::new().with_scaling(2.0, None, None));
2127        let scaled = cq.curr_dual_infeasibility_max();
2128        let unscaled = cq.curr_unscaled_dual_infeasibility_max();
2129        assert!(scaled > 0.0, "fixture should have nonzero dual inf");
2130        assert!(
2131            (unscaled - scaled / 2.0).abs() < 1e-12,
2132            "unscaled {unscaled} != scaled/df {}",
2133            scaled / 2.0
2134        );
2135    }
2136
2137    #[test]
2138    fn unscaled_residuals_are_identity_without_scaling() {
2139        // df = 1, no row scaling → unscaled accessors return exactly the
2140        // scaled values (the common no-scaling path).
2141        let cq = fixture();
2142        assert_eq!(
2143            cq.curr_unscaled_dual_infeasibility_max(),
2144            cq.curr_dual_infeasibility_max()
2145        );
2146        assert_eq!(
2147            cq.curr_unscaled_complementarity_max(),
2148            cq.curr_complementarity_max()
2149        );
2150        assert_eq!(
2151            cq.curr_unscaled_primal_infeasibility_max(),
2152            cq.curr_primal_infeasibility_max()
2153        );
2154    }
2155
2156    #[test]
2157    fn unscaled_compl_is_scaled_over_df() {
2158        let cq = fixture_with(MockNlp::new().with_scaling(2.0, None, None));
2159        let scaled = cq.curr_complementarity_max();
2160        let unscaled = cq.curr_unscaled_complementarity_max();
2161        assert!(scaled > 0.0);
2162        assert!((unscaled - scaled / 2.0).abs() < 1e-12);
2163    }
2164
2165    #[test]
2166    fn unscaled_primal_divides_each_row_by_its_factor() {
2167        // Fixture residuals: c = x0+x1-1 = 4; d-s = x0 - s = 2 - 4 = -2.
2168        // Scaled max-norm primal = max(|4|, |-2|) = 4.
2169        // With dc = [4], dd = [2]: unscaled = max(|4/4|, |-2/2|) = 1.
2170        let cq = fixture_with(MockNlp::new().with_scaling(1.0, Some(vec![4.0]), Some(vec![2.0])));
2171        assert!((cq.curr_primal_infeasibility_max() - 4.0).abs() < 1e-12);
2172        assert!(
2173            (cq.curr_unscaled_primal_infeasibility_max() - 1.0).abs() < 1e-12,
2174            "got {}",
2175            cq.curr_unscaled_primal_infeasibility_max()
2176        );
2177    }
2178
2179    #[test]
2180    fn unscaled_nlp_error_is_max_of_unscaled_components() {
2181        let cq = fixture_with(MockNlp::new().with_scaling(2.0, Some(vec![4.0]), Some(vec![2.0])));
2182        let expected = cq
2183            .curr_unscaled_dual_infeasibility_max()
2184            .max(cq.curr_unscaled_primal_infeasibility_max())
2185            .max(cq.curr_unscaled_complementarity_max());
2186        assert_eq!(cq.curr_unscaled_nlp_error(), expected);
2187    }
2188}