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