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