pounce_algorithm/kkt/pd_full_space_solver.rs
1//! Full-space PD system solver — port of
2//! `Algorithm/IpPDFullSpaceSolver.{hpp,cpp}`.
3//!
4//! Iterative refinement on the FULL 8-block primal-dual KKT system,
5//! driving the augmented-system solver repeatedly. See
6//! `KKT_SYSTEM.md` §5 for the refinement-quit criteria. The outer
7//! loop alternates between back-solves and quality escalation
8//! (`AugSystemSolver::increase_quality()` and `pretend_singular`).
9
10use crate::ipopt_cq::IpoptCqHandle;
11use crate::ipopt_data::IpoptDataHandle;
12use crate::ipopt_nlp::IpoptNlp;
13use crate::iterates_vector::{IteratesVector, IteratesVectorMut};
14use crate::kkt::aug_system_solver::{AugSysCoeffs, AugSysRhs, AugSysSol, AugSystemSolver};
15use crate::kkt::pd_system_solver::PdSystemSolver;
16use crate::kkt::perturbation_handler::{IpoptDataSink, PdPerturbationHandler};
17use pounce_common::tagged::Tag;
18use pounce_common::types::{Index, Number};
19use pounce_common::utils::{cpu_time, wallclock_time};
20use pounce_linalg::dense_vector::DenseVector;
21use pounce_linalg::expansion_matrix::ExpansionMatrix;
22use pounce_linalg::{Matrix, SymMatrix, Vector};
23use pounce_linsol::ESymSolverStatus;
24use std::cell::{Cell, RefCell};
25use std::rc::Rc;
26
27/// Barrier-diagonal replacements for one
28/// [`PdFullSpaceSolver::solve_with_sigma`] call. `None` on a side means
29/// "use the calculated quantity", so [`Self::default`] is exactly
30/// [`PdFullSpaceSolver::solve`].
31///
32/// The two blocks travel together because they answer the same question
33/// about the same iterate — how stiffly the barrier holds each active
34/// bound — and a caller that corrects one and not the other leaves the
35/// factor describing a point that is half in one frame and half in the
36/// other.
37#[derive(Default, Clone)]
38pub struct SigmaOverride {
39 /// Replacement for `cq.curr_sigma_x()`: the variable-bound
40 /// contribution to the `x` diagonal.
41 pub x: Option<Rc<dyn Vector>>,
42 /// Replacement for `cq.curr_sigma_s()`: the inequality-row-bound
43 /// contribution to the `s` diagonal.
44 pub s: Option<Rc<dyn Vector>>,
45}
46
47pub struct PdFullSpaceSolver {
48 aug_solver: Box<dyn AugSystemSolver>,
49 perturb: Rc<RefCell<PdPerturbationHandler>>,
50 pub min_refinement_steps: Index,
51 pub max_refinement_steps: Index,
52 pub residual_ratio_max: Number,
53 pub residual_ratio_singular: Number,
54 pub residual_improvement_factor: Number,
55 /// Negative-curvature test tolerance (`neg_curv_test_tol_`, α_n in
56 /// Zavala & Chiang 2014). Zero — upstream's `RegisterOptions`
57 /// default — keeps the inertia check and disables the heuristic.
58 /// Positive turns the inertia check off and instead accepts a
59 /// factorization whose inertia is wrong only when the computed
60 /// direction passes the curvature test in [`Self::solve_once`]
61 /// (`IpPDFullSpaceSolver.cpp:592-634`).
62 pub neg_curv_test_tol: Number,
63 /// `neg_curv_test_reg_` — include the primal regularization
64 /// δ_x‖dx‖² + δ_s‖ds‖² in the curvature test. Upstream's
65 /// `RegisterOptions` default is `yes`; `no` reproduces the original
66 /// Ipopt form that ignores it. Only read when
67 /// `neg_curv_test_tol > 0`.
68 pub neg_curv_test_reg: bool,
69 /// Mirrors `augsys_improved_`. Set by quality-escalation; cleared
70 /// each time the cached aug-system data changes.
71 augsys_improved: bool,
72 /// Count of *successful* `AugSystemSolver::increase_quality()` calls
73 /// — escalations the backend accepted, i.e. exactly the ones that
74 /// print a `q` in the info-string column. Shared with the owning
75 /// [`crate::application::IpoptApplication`] and with the restoration
76 /// sub-solve's own `PdFullSpaceSolver`, so the tally spans one whole
77 /// solve rather than one algorithm instance (gh#857): the exact leg
78 /// of `square_flowsheet_resto` escalates once in the main loop and
79 /// once inside restoration, and a main-loop-only count would report
80 /// half of what rerouted the solve.
81 ///
82 /// Counts *pounce-side decisions*, not backend escalations. The two
83 /// differ: `LowRankAugSystemSolver::increase_quality` escalates its
84 /// inner and bypass backends per call by design, so a FERAL-side
85 /// tally reads about double this one. The decision is the number a
86 /// reader — and the second-opinion gate — is here for.
87 quality_escalations: Rc<Cell<u64>>,
88 /// Mirrors upstream's `dummy_cache_` hit/miss. `false` ⇒ the next
89 /// `solve_once` is operating on a *new* augmented matrix and must
90 /// run the `ConsiderNewSystem` + perturbation-escalation path;
91 /// `true` ⇒ the matrix is identical to the previous successful
92 /// `solve_once`, so we can reuse `CurrentPerturbation` and just do
93 /// a single back-solve (the iterative-refinement / quality-retry
94 /// re-call path). Reset to `false` at the start of every outer
95 /// `solve()` invocation since each outer iter delivers a fresh
96 /// matrix from the algorithm's perspective.
97 matrix_considered: bool,
98 /// Tags of the 13 dependencies (W, J_c, J_d, z_L, z_U, v_L, v_U,
99 /// slack_x_L, slack_x_U, slack_s_L, slack_s_U, sigma_x, sigma_s)
100 /// at the time `matrix_considered` was last set to `true`. Mirrors
101 /// upstream's `dummy_cache_` keyed on the same 13 `TaggedObject`s
102 /// (`IpPDFullSpaceSolver.cpp:430-448`). Reset to `None` whenever
103 /// any tag changes.
104 last_dep_tags: Option<[Tag; 13]>,
105 last_status: Option<ESymSolverStatus>,
106 /// Worst-case wall / CPU seconds observed for a single augmented-
107 /// system *factorization* over this solver's lifetime (pounce#254).
108 /// `0` until the first factorization completes. Consumed by
109 /// [`Self::predict_factor_overshoot`] to refuse starting a
110 /// factorization the remaining time budget cannot cover — the
111 /// proactive complement to [`deadline_exceeded`]'s reactive abort.
112 /// Only the true factorization path (`aug_solver.solve`) updates
113 /// these; the cheap cached back-solve / iterative-refinement
114 /// re-solves are excluded so a refinement sweep never inflates the
115 /// estimate.
116 max_factor_wall: Number,
117 max_factor_cpu: Number,
118}
119
120/// Fraction of the total time budget a single factorization must reach
121/// before the predictive guard ([`PdFullSpaceSolver::predict_factor_overshoot`])
122/// will refuse to start another (pounce#254). Below this the guard is a
123/// no-op, so a solve whose factorizations are a small slice of the budget
124/// — and might be one iteration from converging — is never cut short; the
125/// guard engages only in the "one factorization is a large chunk of the
126/// whole budget" regime the issue is about.
127const FACTOR_OVERSHOOT_BUDGET_FRACTION: Number = 0.5;
128
129impl PdFullSpaceSolver {
130 pub fn new(
131 aug_solver: Box<dyn AugSystemSolver>,
132 perturb: Rc<RefCell<PdPerturbationHandler>>,
133 ) -> Self {
134 Self {
135 aug_solver,
136 perturb,
137 // Defaults from `IpPDFullSpaceSolver.cpp:RegisterOptions`.
138 min_refinement_steps: 1,
139 max_refinement_steps: 10,
140 residual_ratio_max: 1e-10,
141 residual_ratio_singular: 1e-5,
142 residual_improvement_factor: 0.999_999_999,
143 neg_curv_test_tol: 0.0,
144 neg_curv_test_reg: true,
145 augsys_improved: false,
146 quality_escalations: Rc::new(Cell::new(0)),
147 matrix_considered: false,
148 last_dep_tags: None,
149 last_status: None,
150 max_factor_wall: 0.0,
151 max_factor_cpu: 0.0,
152 }
153 }
154
155 pub fn aug_solver(&self) -> &dyn AugSystemSolver {
156 &*self.aug_solver
157 }
158
159 pub fn aug_solver_mut(&mut self) -> &mut dyn AugSystemSolver {
160 &mut *self.aug_solver
161 }
162
163 /// Ask the backend to escalate, recording the answer.
164 ///
165 /// The single place `increase_quality()` is called from, so the
166 /// tally cannot drift from the `augsys_improved` flag or from the
167 /// `q` info-string the two historical call sites both emit.
168 /// Returns what the backend returned.
169 fn escalate_aug_quality(&mut self) -> bool {
170 let improved = self.aug_solver.increase_quality();
171 if improved {
172 self.quality_escalations
173 .set(self.quality_escalations.get().saturating_add(1));
174 }
175 self.augsys_improved = improved;
176 improved
177 }
178
179 /// Successful quality escalations recorded so far — see
180 /// [`Self::quality_escalations`](#structfield.quality_escalations)
181 /// for what is and is not counted.
182 pub fn quality_escalations(&self) -> u64 {
183 self.quality_escalations.get()
184 }
185
186 /// Share this solver's escalation tally with `counter`, so a
187 /// restoration sub-solve's escalations land in the same total as the
188 /// main loop's. Any count already recorded here is folded in, which
189 /// makes the call order-independent; in practice it is made at build
190 /// time, before the first solve.
191 pub fn set_quality_escalation_counter(&mut self, counter: Rc<Cell<u64>>) {
192 counter.set(counter.get().saturating_add(self.quality_escalations.get()));
193 self.quality_escalations = counter;
194 }
195
196 /// Replace the underlying [`AugSystemSolver`] by passing the
197 /// existing one through the supplied wrapper closure. Used by the
198 /// restoration phase to decorate the inner `StdAugSystemSolver`
199 /// with `AugRestoSystemSolver` (which performs the 8-block →
200 /// 4-block Schur reduction before delegating).
201 pub fn wrap_aug_solver<F>(&mut self, wrap: F)
202 where
203 F: FnOnce(Box<dyn AugSystemSolver>) -> Box<dyn AugSystemSolver>,
204 {
205 // Take the inner aug solver out via a temporary noop, wrap it,
206 // and slot the wrapped one back in. The placeholder is never
207 // observed externally because we replace it before returning.
208 let noop: Box<dyn AugSystemSolver> = Box::new(NoopAugSolver);
209 let inner = std::mem::replace(&mut self.aug_solver, noop);
210 self.aug_solver = wrap(inner);
211 }
212
213 /// Look for a direction of negative curvature in the null space of the
214 /// constraint Jacobian at the current iterate (gh #797).
215 ///
216 /// The filter line-search IPM certifies *first-order* stationarity. On a
217 /// nonconvex model that is not the same as a local minimum: a point where
218 /// the reduced Hessian on `null(A)` is negative definite is a constrained
219 /// *maximum*, and every first-order residual at it is zero, so the
220 /// convergence check has nothing to object to and the Newton step is
221 /// exactly zero — inertia correction included, since `δ_x I` is symmetric
222 /// and cannot break a symmetry the iterates already have. `nonconvex_qp.nl`
223 /// (`min x₀x₁ s.t. x₀+x₁ = 2, 0 ≤ x ≤ 4`) is the reported case: from the
224 /// symmetric bound-pushed start the first Newton step lands on `(1,1)`,
225 /// `f = 1`, the maximum of the concave `x₀(2-x₀)` along the feasible
226 /// segment, and the solve reports `Solve_Succeeded` there.
227 ///
228 /// This is the second-order information the step computation throws away.
229 /// It runs only where a stationary point is about to be certified, and it
230 /// answers two questions with the machinery already in place:
231 ///
232 /// 1. **Is the point second-order suspect?** Factor the augmented system
233 /// *unperturbed* with the inertia check on. Correct inertia is exactly
234 /// the statement that `W + Σ` is positive definite on `null(A)`, so a
235 /// `Success` at `δ_x = 0` ends the probe with `None` and costs one
236 /// factorization. Only a `WrongInertia` continues.
237 /// 2. **Which way is down?** Escalate `δ_x` on the ladder below until the
238 /// inertia *is* correct. The `δ_x` that first works is within a factor
239 /// of [`NEG_CURV_DELTA_FACTOR`] of `-λ_min` of the reduced Hessian, so
240 /// `(W + Σ + δ_x I)⁻¹` restricted to `null(A)` has its largest
241 /// amplification precisely along the eigenvector of `λ_min`. A few
242 /// inverse-iteration back-solves against that factor therefore converge
243 /// to the most-negative-curvature direction, and each one is a
244 /// back-solve against the cached factor rather than a refactorization.
245 ///
246 /// The returned direction is never trusted on the strength of that
247 /// argument: `dᵀ(W + Σ)d` is *measured* for each candidate and the probe
248 /// returns `None` unless the best one is strictly negative. It also
249 /// satisfies `J_c d_x = 0` and `J_d d_x - d_s = 0` to the accuracy of the
250 /// factorization (both dual perturbations are held at zero), so stepping
251 /// along it does not move the linearised constraints.
252 ///
253 /// Returns `None` — never an error — for every shape it cannot answer for:
254 /// a backend that reports no inertia, a non-dense iterate (the restoration
255 /// inner IPM's `CompoundVector`), a singular or breaking-down
256 /// factorization, or a ladder that reaches [`NEG_CURV_DELTA_MAX`] without
257 /// fixing the inertia. Declining is always safe here: the caller's
258 /// fallback is the pre-#797 behaviour of reporting the stationary point.
259 ///
260 /// The augmented-system cache is invalidated on every exit, because the
261 /// factor left behind describes a perturbed matrix that no ordinary solve
262 /// asked for.
263 pub fn negative_curvature_direction(
264 &mut self,
265 data: &IpoptDataHandle,
266 cq: &IpoptCqHandle,
267 nlp: &Rc<RefCell<dyn IpoptNlp>>,
268 w_at_curr: Option<Rc<dyn SymMatrix>>,
269 ) -> Option<NegativeCurvature> {
270 if !self.aug_solver.provides_inertia() {
271 return None;
272 }
273
274 // Same thirteen blocks `solve_with_sigma` assembles, with no sigma
275 // substitution — this is the system the *step* was computed from,
276 // except that the caller may hand in a Hessian for the *current*
277 // iterate. `data.w` is one iterate behind wherever this is called
278 // from, and re-running the Hessian updater to catch it up is not
279 // free of consequence for the limited-memory updater; see
280 // `HessianUpdater::provides_exact_hessian`.
281 let w = match w_at_curr {
282 Some(w) => w,
283 None => data.borrow().w.clone()?,
284 };
285 let cq_ref = cq.borrow();
286 let j_c = cq_ref.curr_jac_c();
287 let j_d = cq_ref.curr_jac_d();
288 let sigma_x = cq_ref.curr_sigma_x();
289 let sigma_s = cq_ref.curr_sigma_s();
290 let slack_x_l = cq_ref.curr_slack_x_l();
291 let slack_x_u = cq_ref.curr_slack_x_u();
292 let slack_s_l = cq_ref.curr_slack_s_l();
293 let slack_s_u = cq_ref.curr_slack_s_u();
294 drop(cq_ref);
295
296 let nlp_ref = nlp.borrow();
297 let px_l = nlp_ref.px_l();
298 let px_u = nlp_ref.px_u();
299 let pd_l = nlp_ref.pd_l();
300 let pd_u = nlp_ref.pd_u();
301 drop(nlp_ref);
302
303 let curr = data.borrow().curr.clone()?;
304
305 let b = SolveBlocks {
306 w: &*w,
307 j_c: &*j_c,
308 j_d: &*j_d,
309 px_l: &*px_l,
310 px_u: &*px_u,
311 pd_l: &*pd_l,
312 pd_u: &*pd_u,
313 z_l: &*curr.z_l,
314 z_u: &*curr.z_u,
315 v_l: &*curr.v_l,
316 v_u: &*curr.v_u,
317 slack_x_l: &*slack_x_l,
318 slack_x_u: &*slack_x_u,
319 slack_s_l: &*slack_s_l,
320 slack_s_u: &*slack_s_u,
321 sigma_x: &*sigma_x,
322 sigma_s: &*sigma_s,
323 };
324
325 let num_neg_evals = curr.y_c.dim() + curr.y_d.dim();
326
327 let mut seed = curr.make_new_zeroed();
328 let n_x = seed.x.dim() as usize;
329 if !fill_probe_seed(&mut *seed.x, 0) || !fill_probe_seed(&mut *seed.s, n_x) {
330 return None;
331 }
332 let seed_scale = seed.x.amax().max(seed.s.amax());
333 if !(seed_scale > 0.0) {
334 return None;
335 }
336 seed.x.scal(1.0 / seed_scale);
337 seed.s.scal(1.0 / seed_scale);
338
339 // `make_new` leaves a `DenseVector` *uninitialized* — neither
340 // materialized nor homogeneous — which reads as a zero-length slice
341 // when the backend packs it. That is invisible for a solution slot
342 // (only ever written) but not for a right-hand side, so the two
343 // constraint blocks are set explicitly.
344 let mut zero_c = curr.y_c.make_new();
345 zero_c.set(0.0);
346 let mut zero_d = curr.y_d.make_new();
347 zero_d.set(0.0);
348 let mut sol = curr.make_new_zeroed();
349
350 // Step 1 + 2: the smallest ladder rung whose inertia is correct.
351 let mut delta_x = 0.0;
352 let mut have_factor = false;
353 for _ in 0..NEG_CURV_MAX_FACTORIZATIONS {
354 if deadline_exceeded(data) {
355 break;
356 }
357 let coeffs = neg_curv_coeffs(&b, delta_x);
358 let rhs = AugSysRhs {
359 rhs_x: &*seed.x,
360 rhs_s: &*seed.s,
361 rhs_c: &*zero_c,
362 rhs_d: &*zero_d,
363 };
364 let mut aug_sol = AugSysSol {
365 sol_x: &mut *sol.x,
366 sol_s: &mut *sol.s,
367 sol_c: &mut *sol.y_c,
368 sol_d: &mut *sol.y_d,
369 };
370 let status = self
371 .aug_solver
372 .solve(&coeffs, &rhs, &mut aug_sol, true, num_neg_evals);
373 match status {
374 ESymSolverStatus::Success => {
375 if delta_x == 0.0 {
376 // `W + Σ` is positive definite on `null(A)`: the point
377 // satisfies the second-order *sufficient* condition for
378 // the barrier subproblem and there is nothing to escape.
379 tracing::debug!(target: "pounce::kkt",
380 "negative-curvature probe: correct inertia unperturbed, \
381 the reduced Hessian is positive definite here (gh#797)");
382 self.invalidate_aug_cache();
383 return None;
384 }
385 have_factor = true;
386 break;
387 }
388 ESymSolverStatus::WrongInertia | ESymSolverStatus::Singular => {
389 delta_x = if delta_x == 0.0 {
390 NEG_CURV_DELTA_MIN
391 } else {
392 delta_x * NEG_CURV_DELTA_FACTOR
393 };
394 if delta_x > NEG_CURV_DELTA_MAX {
395 break;
396 }
397 }
398 _ => break,
399 }
400 }
401 if !have_factor {
402 self.invalidate_aug_cache();
403 return None;
404 }
405
406 // Step 2b: tighten the shift by bisecting the bracket the ladder just
407 // produced. `delta_x` factored and `delta_x / NEG_CURV_DELTA_FACTOR`
408 // did not, so `|λ_min|` lies between them — but the ladder climbs by
409 // a factor of ten, so the rung it lands on can overshoot `|λ_min|` by
410 // up to that much, and the shifted spectrum the inverse iteration
411 // below runs against is then barely separated.
412 //
413 // On `min ½(x₀² − 1.05·x₁²)` over `[−2, 2]²` from the origin the ladder
414 // rejects `δ = 1` and takes `δ = 10`, giving eigenvalues `(11, 8.95)`;
415 // three back-solves amplify the negative direction by `(11/8.95)³ ≈ 1.9`,
416 // which does not make the Rayleigh quotient negative from a seed whose
417 // component along it is small. The probe then declines and the solve
418 // certifies a saddle as `Solve_Succeeded`. That is gh#797's own defect,
419 // and it reached 28–45% of diagonal indefinite models — with the answer
420 // depending on *which coordinate* carried the negative curvature, since
421 // that is what decides the fixed seed's overlap with the eigenvector.
422 //
423 // `crates/pounce-qp/src/negcurv.rs` (gh#848) already brackets and
424 // bisects for exactly this reason; this is the same treatment on the
425 // NLP arm's own ladder.
426 if delta_x > NEG_CURV_DELTA_MIN {
427 let mut lo = delta_x / NEG_CURV_DELTA_FACTOR;
428 let mut hi = delta_x;
429 for _ in 0..NEG_CURV_SHIFT_REFINEMENTS {
430 if deadline_exceeded(data) {
431 break;
432 }
433 let mid = (lo * hi).sqrt();
434 if !(mid > lo && mid < hi) {
435 break;
436 }
437 let coeffs = neg_curv_coeffs(&b, mid);
438 let rhs = AugSysRhs {
439 rhs_x: &*seed.x,
440 rhs_s: &*seed.s,
441 rhs_c: &*zero_c,
442 rhs_d: &*zero_d,
443 };
444 let mut aug_sol = AugSysSol {
445 sol_x: &mut *sol.x,
446 sol_s: &mut *sol.s,
447 sol_c: &mut *sol.y_c,
448 sol_d: &mut *sol.y_d,
449 };
450 match self
451 .aug_solver
452 .solve(&coeffs, &rhs, &mut aug_sol, true, num_neg_evals)
453 {
454 ESymSolverStatus::Success => hi = mid,
455 ESymSolverStatus::WrongInertia | ESymSolverStatus::Singular => lo = mid,
456 _ => break,
457 }
458 }
459 // Land on the tightest shift known to factor, so the cached factor
460 // the inverse iteration re-solves against is that one and `sol`
461 // holds its first iterate.
462 if hi < delta_x {
463 delta_x = hi;
464 let coeffs = neg_curv_coeffs(&b, delta_x);
465 let rhs = AugSysRhs {
466 rhs_x: &*seed.x,
467 rhs_s: &*seed.s,
468 rhs_c: &*zero_c,
469 rhs_d: &*zero_d,
470 };
471 let mut aug_sol = AugSysSol {
472 sol_x: &mut *sol.x,
473 sol_s: &mut *sol.s,
474 sol_c: &mut *sol.y_c,
475 sol_d: &mut *sol.y_d,
476 };
477 if self
478 .aug_solver
479 .solve(&coeffs, &rhs, &mut aug_sol, true, num_neg_evals)
480 != ESymSolverStatus::Success
481 {
482 self.invalidate_aug_cache();
483 return None;
484 }
485 }
486 }
487
488 // Step 3: inverse iteration against that factor. Every candidate is
489 // measured; the best Rayleigh quotient wins.
490 // `make_new_zeroed` allocates but does not initialize (see the note on
491 // the zero right-hand sides above), and the caller reads this as an
492 // ordinary direction — dual blocks included, which the probe never
493 // writes — so every block is zeroed explicitly.
494 let mut best = curr.make_new_zeroed();
495 best.x.set(0.0);
496 best.s.set(0.0);
497 best.y_c.set(0.0);
498 best.y_d.set(0.0);
499 best.z_l.set(0.0);
500 best.z_u.set(0.0);
501 best.v_l.set(0.0);
502 best.v_u.set(0.0);
503 let mut best_quotient = 0.0;
504 let mut best_curvature = 0.0;
505 for step in 0..NEG_CURV_INVERSE_ITERS {
506 let scale = sol.x.amax().max(sol.s.amax());
507 if !(scale > 0.0) || !scale.is_finite() {
508 break;
509 }
510 sol.x.scal(1.0 / scale);
511 sol.s.scal(1.0 / scale);
512 let nrmsq = sol.x.nrm2().powi(2) + sol.s.nrm2().powi(2);
513 if !(nrmsq > 0.0) || !nrmsq.is_finite() {
514 break;
515 }
516 let curvature = Self::curvature_measure(&b, &sol, false, 0.0, 0.0);
517 if !curvature.is_finite() {
518 break;
519 }
520 let quotient = curvature / nrmsq;
521 if quotient < best_quotient {
522 best_quotient = quotient;
523 best_curvature = curvature;
524 best.x.copy(&*sol.x);
525 best.s.copy(&*sol.s);
526 }
527 if step + 1 == NEG_CURV_INVERSE_ITERS {
528 break;
529 }
530 seed.x.copy(&*sol.x);
531 seed.s.copy(&*sol.s);
532 let coeffs = neg_curv_coeffs(&b, delta_x);
533 let rhs = AugSysRhs {
534 rhs_x: &*seed.x,
535 rhs_s: &*seed.s,
536 rhs_c: &*zero_c,
537 rhs_d: &*zero_d,
538 };
539 let mut aug_sol = AugSysSol {
540 sol_x: &mut *sol.x,
541 sol_s: &mut *sol.s,
542 sol_c: &mut *sol.y_c,
543 sol_d: &mut *sol.y_d,
544 };
545 if self.aug_solver.resolve(&coeffs, &rhs, &mut aug_sol) != ESymSolverStatus::Success {
546 break;
547 }
548 }
549 self.invalidate_aug_cache();
550
551 if !(best_curvature < 0.0) {
552 // Not the same statement as "the reduced Hessian is positive
553 // definite here", which is the `delta_x == 0.0` branch above and
554 // says so. Reaching this line means a shift WAS needed — the
555 // reduced Hessian is indefinite, singular, or the constraint block
556 // is rank deficient — and the iteration simply did not produce a
557 // witness. The caller cannot tell those apart, and until gh#797's
558 // follow-up this declined without a word, so a solve that
559 // certified a saddle left no trace of having tried.
560 tracing::debug!(target: "pounce::kkt",
561 "negative-curvature probe: a shift of {:.3e} was needed, so the \
562 reduced Hessian is NOT positive definite here, but {} inverse \
563 iterations produced no direction of negative curvature \
564 (best Rayleigh quotient {:.3e}); declining the escape and \
565 reporting the stationary point uncertified (gh#797)",
566 delta_x, NEG_CURV_INVERSE_ITERS, best_quotient);
567 return None;
568 }
569 // Rescale to unit inf-norm so the caller's step length is expressed in
570 // the iterate's own units, and rescale the curvature with it.
571 let scale = best.x.amax().max(best.s.amax());
572 if !(scale > 0.0) || !scale.is_finite() {
573 return None;
574 }
575 best.x.scal(1.0 / scale);
576 best.s.scal(1.0 / scale);
577 tracing::debug!(target: "pounce::kkt",
578 "negative-curvature probe: delta_x = {:e}, dᵀ(W+Σ)d = {:e} (gh#797)",
579 delta_x, best_curvature / (scale * scale));
580 Some(NegativeCurvature {
581 curvature: best_curvature / (scale * scale),
582 delta: best.freeze(),
583 })
584 }
585
586 /// Drop the augmented-system factorization cache. The probe leaves a factor
587 /// of a matrix nobody asked for behind it, so the next ordinary solve must
588 /// miss the `dummy_cache_` lookup and re-consider the system from scratch.
589 fn invalidate_aug_cache(&mut self) {
590 self.matrix_considered = false;
591 self.last_dep_tags = None;
592 self.augsys_improved = false;
593 }
594
595 /// Solve the full PD system. `res = α · M⁻¹ · rhs + β · res_in`,
596 /// matching `IpPDFullSpaceSolver::Solve`. Returns `true` on
597 /// success. The iterate fields used to assemble the system are
598 /// pulled from `data` (`W`, `curr`) and `cq` (jacobians, slacks,
599 /// sigmas).
600 #[allow(clippy::too_many_arguments)]
601 pub fn solve(
602 &mut self,
603 data: &IpoptDataHandle,
604 cq: &IpoptCqHandle,
605 nlp: &Rc<RefCell<dyn IpoptNlp>>,
606 alpha: Number,
607 beta: Number,
608 rhs: &IteratesVector,
609 res: &mut IteratesVectorMut,
610 allow_inexact: bool,
611 improve_solution: bool,
612 ) -> bool {
613 self.solve_with_sigma(
614 data,
615 cq,
616 nlp,
617 alpha,
618 beta,
619 rhs,
620 res,
621 allow_inexact,
622 improve_solution,
623 SigmaOverride::default(),
624 )
625 }
626
627 /// [`Self::solve`] against the same system with the barrier
628 /// diagonals `sigma_x` / `sigma_s` replaced.
629 ///
630 /// `sigma` is the barrier term the active bounds contribute to the
631 /// `x` (variable bounds) and `s` (inequality-row bounds) diagonals,
632 /// `z / s` per bound. Two callers want it substituted, for
633 /// different reasons:
634 ///
635 /// * **Release.** Zeroing an entry takes that bound back out of the
636 /// active set, which is what a *released* bound means, so
637 /// factoring the result gives the released system directly. The
638 /// released system cannot be recovered from the converged factor:
639 /// reaching it by a rank-1 downdate through a Schur complement
640 /// asks for the difference of two quantities that agree to about
641 /// `eps * sigma`, and on a tightly converged bound `sigma` is
642 /// large enough that the difference is noise -- measured, the
643 /// released answer degrades in proportion to how well the solve
644 /// converged. Factoring is what buys those digits back, and it is
645 /// still one factorization against the twenty to a hundred a
646 /// re-solve would run.
647 /// * **Crossover (gh#654).** A crossed-over iterate sits on the
648 /// *declared* bounds, so its live slacks read `bound_relax_factor`
649 /// rather than the barrier's `mu/z`, and `sigma` comes out of the
650 /// cache describing a looser pin than the point actually has. The
651 /// sensitivity path substitutes the declared-frame diagonal here.
652 ///
653 /// Only `pounce-sensitivity` calls this; the algorithm's own step
654 /// computation goes through [`Self::solve`] and is unaffected, so no
655 /// solver trajectory moves. Both sigmas are among the thirteen
656 /// dependency tags, so passing a different vector misses the
657 /// factorization cache and re-factors, and the next ordinary solve
658 /// misses it back -- correctness needs no extra bookkeeping here.
659 #[allow(clippy::too_many_arguments)]
660 pub fn solve_with_sigma(
661 &mut self,
662 data: &IpoptDataHandle,
663 cq: &IpoptCqHandle,
664 nlp: &Rc<RefCell<dyn IpoptNlp>>,
665 alpha: Number,
666 beta: Number,
667 rhs: &IteratesVector,
668 res: &mut IteratesVectorMut,
669 allow_inexact: bool,
670 improve_solution: bool,
671 sigma_override: SigmaOverride,
672 ) -> bool {
673 debug_assert!(!allow_inexact || !improve_solution);
674 debug_assert!(!improve_solution || beta == 0.0);
675
676 // Snapshot the incoming `res` if β ≠ 0 (we add it back at the
677 // end via `res = α · sol + β · copy_res`).
678 let copy_res: Option<IteratesVector> = if beta != 0.0 {
679 Some(snapshot_mut(res))
680 } else {
681 None
682 };
683
684 // Pull all blocks once. None of these change during the
685 // refinement / escalation loop, so collecting them here
686 // matches upstream's structure (lines 168-189).
687 let w = data
688 .borrow()
689 .w
690 .clone()
691 .unwrap_or_else(|| panic!("PdFullSpaceSolver::solve: IpoptData::w is unset"));
692 let cq_ref = cq.borrow();
693 let j_c = cq_ref.curr_jac_c();
694 let j_d = cq_ref.curr_jac_d();
695 let sigma_x = sigma_override.x.unwrap_or_else(|| cq_ref.curr_sigma_x());
696 let sigma_s = sigma_override.s.unwrap_or_else(|| cq_ref.curr_sigma_s());
697 let slack_x_l = cq_ref.curr_slack_x_l();
698 let slack_x_u = cq_ref.curr_slack_x_u();
699 let slack_s_l = cq_ref.curr_slack_s_l();
700 let slack_s_u = cq_ref.curr_slack_s_u();
701 drop(cq_ref);
702
703 let nlp_ref = nlp.borrow();
704 let px_l = nlp_ref.px_l();
705 let px_u = nlp_ref.px_u();
706 let pd_l = nlp_ref.pd_l();
707 let pd_u = nlp_ref.pd_u();
708 drop(nlp_ref);
709
710 let curr = {
711 let d = data.borrow();
712 d.curr
713 .clone()
714 .unwrap_or_else(|| panic!("PdFullSpaceSolver::solve: IpoptData::curr is unset"))
715 };
716
717 let blocks = SolveBlocks {
718 w: &*w,
719 j_c: &*j_c,
720 j_d: &*j_d,
721 px_l: &*px_l,
722 px_u: &*px_u,
723 pd_l: &*pd_l,
724 pd_u: &*pd_u,
725 z_l: &*curr.z_l,
726 z_u: &*curr.z_u,
727 v_l: &*curr.v_l,
728 v_u: &*curr.v_u,
729 slack_x_l: &*slack_x_l,
730 slack_x_u: &*slack_x_u,
731 slack_s_l: &*slack_s_l,
732 slack_s_u: &*slack_s_u,
733 sigma_x: &*sigma_x,
734 sigma_s: &*sigma_s,
735 };
736
737 // Mirror upstream's `dummy_cache_` lookup
738 // (`IpPDFullSpaceSolver.cpp:430-450`): if all 13 dependency tags
739 // are unchanged since the last successful `solve()`, the matrix
740 // is "uptodate" — keep `matrix_considered = true` so the
741 // perturbation handler is NOT re-entered, and reuse the
742 // existing `augsys_improved_` state. On a cache miss, reset
743 // both flags.
744 let cur_tags: [Tag; 13] = [
745 blocks.w.as_tagged().get_tag(),
746 blocks.j_c.as_tagged().get_tag(),
747 blocks.j_d.as_tagged().get_tag(),
748 blocks.z_l.as_tagged().get_tag(),
749 blocks.z_u.as_tagged().get_tag(),
750 blocks.v_l.as_tagged().get_tag(),
751 blocks.v_u.as_tagged().get_tag(),
752 blocks.slack_x_l.as_tagged().get_tag(),
753 blocks.slack_x_u.as_tagged().get_tag(),
754 blocks.slack_s_l.as_tagged().get_tag(),
755 blocks.slack_s_u.as_tagged().get_tag(),
756 blocks.sigma_x.as_tagged().get_tag(),
757 blocks.sigma_s.as_tagged().get_tag(),
758 ];
759 let uptodate = self.last_dep_tags.map_or(false, |prev| prev == cur_tags);
760 if !uptodate {
761 if std::env::var_os("POUNCE_DBG_PD_TAGS").is_some() {
762 if let Some(prev) = self.last_dep_tags {
763 let names = [
764 "w",
765 "j_c",
766 "j_d",
767 "z_l",
768 "z_u",
769 "v_l",
770 "v_u",
771 "slack_x_l",
772 "slack_x_u",
773 "slack_s_l",
774 "slack_s_u",
775 "sigma_x",
776 "sigma_s",
777 ];
778 let mut diffs = String::new();
779 for i in 0..13 {
780 if prev[i] != cur_tags[i] {
781 diffs.push_str(&format!(
782 " {}({:?}→{:?})",
783 names[i], prev[i], cur_tags[i]
784 ));
785 }
786 }
787 tracing::debug!(target: "pounce::linsol", "[PN_PD_TAGS] cache_miss diffs:{}", diffs);
788 } else {
789 tracing::debug!(target: "pounce::linsol", "[PN_PD_TAGS] cache_miss first_solve");
790 }
791 }
792 self.last_dep_tags = Some(cur_tags);
793 self.matrix_considered = false;
794 self.augsys_improved = false;
795 }
796
797 let mut done = false;
798 let mut resolve_with_better_quality = false;
799 let mut pretend_singular = false;
800 let mut pretend_singular_last_time = false;
801 let mut improve = improve_solution;
802
803 while !done {
804 // pounce#244: bail between major KKT steps when the shared time
805 // budget is crossed (see `deadline_exceeded`). Returning `false`
806 // routes through the caller's post-KKT deadline check, which
807 // terminates the solve with the time-limit status rather than
808 // treating the abort as a step-computation failure.
809 //
810 // pounce#254: also bail *before* a factorization the remaining
811 // budget cannot afford (see `predict_factor_overshoot`), so a
812 // large single factorization does not overshoot before the next
813 // reactive check catches it.
814 if deadline_exceeded(data) || self.predict_factor_overshoot(data) {
815 return false;
816 }
817 let solve_ok = if improve {
818 true
819 } else {
820 let ok = self.solve_once(
821 data,
822 &blocks,
823 1.0,
824 0.0,
825 rhs,
826 res,
827 resolve_with_better_quality,
828 pretend_singular,
829 );
830 resolve_with_better_quality = false;
831 pretend_singular = false;
832 ok
833 };
834 improve = false;
835
836 if !solve_ok {
837 return false;
838 }
839
840 if allow_inexact {
841 break;
842 }
843
844 // Initial residual.
845 let mut resid = res.fresh_zeroed();
846 self.compute_residuals(data, &blocks, rhs, res, &mut resid);
847 let mut residual_ratio = self.compute_residual_ratio(rhs, res, &resid);
848 let mut residual_ratio_old = residual_ratio;
849
850 let mut num_iter_ref: Index = 0;
851 let mut quit_refinement = false;
852
853 while !quit_refinement
854 && (num_iter_ref < self.min_refinement_steps
855 || residual_ratio > self.residual_ratio_max)
856 {
857 // pounce#244: each refinement step drives another back-solve
858 // (and may refactor via the escalation path in `solve_once`);
859 // check the budget before spending one. pounce#254: the
860 // predictive guard additionally refuses a step whose worst-
861 // case factorization would not fit the remaining budget.
862 if deadline_exceeded(data) || self.predict_factor_overshoot(data) {
863 return false;
864 }
865 let frozen_resid = resid.freeze();
866 let solve_ok = self.solve_once(
867 data,
868 &blocks,
869 -1.0,
870 1.0,
871 &frozen_resid,
872 res,
873 resolve_with_better_quality,
874 false,
875 );
876 resid = thaw(frozen_resid);
877 if !solve_ok {
878 return false;
879 }
880
881 self.compute_residuals(data, &blocks, rhs, res, &mut resid);
882 residual_ratio = self.compute_residual_ratio(rhs, res, &resid);
883 num_iter_ref += 1;
884
885 if residual_ratio > self.residual_ratio_max
886 && num_iter_ref > self.min_refinement_steps
887 && (num_iter_ref > self.max_refinement_steps
888 || residual_ratio > self.residual_improvement_factor * residual_ratio_old)
889 {
890 quit_refinement = true;
891 resolve_with_better_quality = false;
892
893 if !pretend_singular_last_time {
894 if !self.augsys_improved {
895 self.escalate_aug_quality();
896 if self.augsys_improved {
897 data.borrow_mut().append_info_string("q");
898 resolve_with_better_quality = true;
899 } else {
900 pretend_singular = true;
901 }
902 } else {
903 pretend_singular = true;
904 }
905 pretend_singular_last_time = pretend_singular;
906 if pretend_singular {
907 if residual_ratio < self.residual_ratio_singular {
908 pretend_singular = false;
909 data.borrow_mut().append_info_string("S");
910 } else {
911 data.borrow_mut().append_info_string("s");
912 }
913 }
914 } else {
915 pretend_singular = false;
916 }
917 }
918
919 residual_ratio_old = residual_ratio;
920 }
921
922 done = !resolve_with_better_quality && !pretend_singular;
923 }
924
925 // Final assembly: res = α · res + β · copy_res.
926 if alpha != 0.0 {
927 res.scal(alpha);
928 }
929 if let Some(copy_res) = copy_res {
930 res.axpy(beta, ©_res);
931 }
932
933 self.last_status = Some(ESymSolverStatus::Success);
934 true
935 }
936
937 /// Predictive time-budget guard for the KKT factorization (pounce#254).
938 ///
939 /// [`deadline_exceeded`] is *reactive*: it aborts only after the shared
940 /// budget has already been crossed. Because a single feral factorization
941 /// is uninterruptible (feral 0.14 exposes no in-factor cancel hook — see
942 /// `dev-notes/feral-factor-interrupt.md`), that reactive check still lets
943 /// one whole factorization overshoot — it passes while still under budget,
944 /// the factorization runs, and only the *next* check trips. #245/#246
945 /// accepted that "bounded to one factorization" overshoot for the
946 /// between-op gaps.
947 ///
948 /// This guard tightens it *proactively*: once a factorization has been
949 /// observed (via [`Self::max_factor_wall`] / [`Self::max_factor_cpu`]) to
950 /// cost at least [`FACTOR_OVERSHOOT_BUDGET_FRACTION`] of the whole budget,
951 /// refuse to *start* another one whose worst observed cost the remaining
952 /// budget cannot cover. On the "large factor, several-factor budget"
953 /// regime (e.g. discopt's ~10 s per-node budgets over multi-second
954 /// factorizations) this bounds the overshoot before the doomed final
955 /// factorization begins, rather than running it to completion first.
956 ///
957 /// It deliberately does nothing until such a large factorization has been
958 /// seen, so an ordinary solve whose factorizations are a small slice of
959 /// the budget — and may be one iteration from converging — is never cut
960 /// short. Returns `false` when no deadline is installed.
961 ///
962 /// Residual gap (#254): a *single* factorization already larger than the
963 /// entire budget — e.g. the first one on a 5 k-variable NLP — cannot be
964 /// bounded here. No estimate exists before it runs, and it cannot be
965 /// interrupted mid-flight. Closing that needs the feral-side cooperative
966 /// cancellation hook specified in `dev-notes/feral-factor-interrupt.md`.
967 fn predict_factor_overshoot(&self, data: &IpoptDataHandle) -> bool {
968 let d = data.borrow();
969 match d.deadline.as_ref() {
970 Some(deadline) => {
971 factor_overshoot_predicted(self.max_factor_wall, self.max_factor_cpu, deadline)
972 }
973 None => false,
974 }
975 }
976
977 /// Batched back-substitution against the cached KKT factor for
978 /// `n_rhs` right-hand sides, sharing one
979 /// `pounce_linsol::TSymLinearSolver::multi_solve` call with
980 /// `nrhs > 1`. Each column k pulls its RHS through `write_rhs(k,
981 /// &mut iv)` and emits its solution through `write_lhs(k, &iv)` —
982 /// closures over the caller's flat / strided buffer keep the
983 /// rhs/sol `IteratesVectorMut` scratch out of the API surface.
984 ///
985 /// Returns:
986 /// - `Some(true)` — fast path executed against the cached factor.
987 /// - `Some(false)` — fast path was attempted but the linsol
988 /// reported a back-solve failure.
989 /// - `None` — fast path not taken. Either the matrix tags
990 /// differ from the last successful [`Self::solve`] (cache miss),
991 /// the matrix has not been considered yet, or the underlying
992 /// `AugSystemSolver` does not implement
993 /// [`AugSystemSolver::try_resolve_many_flat`]. The caller should
994 /// fall back to looping [`Self::solve`].
995 ///
996 /// Used by `pounce_sensitivity::PdSensBacksolver::solve_many` for
997 /// the JaxProblem `jacrev` backward path, where every cotangent
998 /// re-solves against the same converged factor (pounce#77 follow-up).
999 pub fn solve_many_cached<F1, F2>(
1000 &mut self,
1001 data: &IpoptDataHandle,
1002 cq: &IpoptCqHandle,
1003 nlp: &Rc<RefCell<dyn IpoptNlp>>,
1004 n_rhs: usize,
1005 mut write_rhs: F1,
1006 mut write_lhs: F2,
1007 ) -> Option<bool>
1008 where
1009 F1: FnMut(usize, &mut IteratesVectorMut),
1010 F2: FnMut(usize, &IteratesVectorMut),
1011 {
1012 if n_rhs == 0 {
1013 return Some(true);
1014 }
1015
1016 // Pull all blocks (same shape as `solve()`).
1017 let w = data.borrow().w.clone()?;
1018 let cq_ref = cq.borrow();
1019 let j_c = cq_ref.curr_jac_c();
1020 let j_d = cq_ref.curr_jac_d();
1021 let sigma_x = cq_ref.curr_sigma_x();
1022 let sigma_s = cq_ref.curr_sigma_s();
1023 let slack_x_l = cq_ref.curr_slack_x_l();
1024 let slack_x_u = cq_ref.curr_slack_x_u();
1025 let slack_s_l = cq_ref.curr_slack_s_l();
1026 let slack_s_u = cq_ref.curr_slack_s_u();
1027 drop(cq_ref);
1028
1029 let nlp_ref = nlp.borrow();
1030 let px_l = nlp_ref.px_l();
1031 let px_u = nlp_ref.px_u();
1032 let pd_l = nlp_ref.pd_l();
1033 let pd_u = nlp_ref.pd_u();
1034 drop(nlp_ref);
1035
1036 let curr = data.borrow().curr.clone()?;
1037
1038 let blocks = SolveBlocks {
1039 w: &*w,
1040 j_c: &*j_c,
1041 j_d: &*j_d,
1042 px_l: &*px_l,
1043 px_u: &*px_u,
1044 pd_l: &*pd_l,
1045 pd_u: &*pd_u,
1046 z_l: &*curr.z_l,
1047 z_u: &*curr.z_u,
1048 v_l: &*curr.v_l,
1049 v_u: &*curr.v_u,
1050 slack_x_l: &*slack_x_l,
1051 slack_x_u: &*slack_x_u,
1052 slack_s_l: &*slack_s_l,
1053 slack_s_u: &*slack_s_u,
1054 sigma_x: &*sigma_x,
1055 sigma_s: &*sigma_s,
1056 };
1057
1058 // Cache-tag check (same 13 tags as `solve()`). If the matrix
1059 // has changed since the last successful solve, or we never
1060 // marked it as considered, bail and let the caller take the
1061 // per-RHS path.
1062 let cur_tags: [Tag; 13] = [
1063 blocks.w.as_tagged().get_tag(),
1064 blocks.j_c.as_tagged().get_tag(),
1065 blocks.j_d.as_tagged().get_tag(),
1066 blocks.z_l.as_tagged().get_tag(),
1067 blocks.z_u.as_tagged().get_tag(),
1068 blocks.v_l.as_tagged().get_tag(),
1069 blocks.v_u.as_tagged().get_tag(),
1070 blocks.slack_x_l.as_tagged().get_tag(),
1071 blocks.slack_x_u.as_tagged().get_tag(),
1072 blocks.slack_s_l.as_tagged().get_tag(),
1073 blocks.slack_s_u.as_tagged().get_tag(),
1074 blocks.sigma_x.as_tagged().get_tag(),
1075 blocks.sigma_s.as_tagged().get_tag(),
1076 ];
1077 if !self.matrix_considered || !self.last_dep_tags.map_or(false, |prev| prev == cur_tags) {
1078 return None;
1079 }
1080
1081 // Coeffs reuse the perturbation stashed by the most recent
1082 // `solve_once`. `current_perturbation()` returns the same
1083 // values that solve_once wrote into `data.perturbations`.
1084 let d = self.perturb.borrow().current_perturbation();
1085 let coeffs = AugSysCoeffs {
1086 w: Some(blocks.w),
1087 w_factor: 1.0,
1088 d_x: Some(blocks.sigma_x),
1089 delta_x: d.delta_x,
1090 d_s: Some(blocks.sigma_s),
1091 delta_s: d.delta_s,
1092 j_c: blocks.j_c,
1093 d_c: None,
1094 delta_c: d.delta_c,
1095 j_d: blocks.j_d,
1096 d_d: None,
1097 delta_d: d.delta_d,
1098 };
1099
1100 let n_x = curr.x.dim() as usize;
1101 let n_s = curr.s.dim() as usize;
1102 let n_y_c = curr.y_c.dim() as usize;
1103 let n_y_d = curr.y_d.dim() as usize;
1104 let aug_dim = n_x + n_s + n_y_c + n_y_d;
1105
1106 // Scratch — one set of Box allocs, reused across every column.
1107 let mut rhs_iv = curr.make_new_zeroed();
1108 let mut sol_iv = curr.make_new_zeroed();
1109 let mut aug_rhs_x_box: Box<dyn Vector> = curr.x.make_new();
1110 let mut aug_rhs_s_box: Box<dyn Vector> = curr.s.make_new();
1111
1112 // Column-major `(aug_dim, n_rhs)` packed buffer — single
1113 // allocation that the linsol's `multi_solve` writes solutions
1114 // back into in place.
1115 let mut aug_packed = vec![0.0 as Number; aug_dim * n_rhs];
1116
1117 // Phase 1: populate aug_packed column-by-column. The aug-system
1118 // RHS is `[aug_rhs_x | aug_rhs_s | rhs.y_c | rhs.y_d]`, where
1119 // aug_rhs_x = rhs.x + Px_L·S_xL⁻¹·z_L − Px_U·S_xU⁻¹·z_U
1120 // aug_rhs_s = rhs.s + Pd_L·S_sL⁻¹·v_L − Pd_U·S_sU⁻¹·v_U
1121 // matching `solve_once`'s aug-RHS build.
1122 for k in 0..n_rhs {
1123 write_rhs(k, &mut rhs_iv);
1124
1125 aug_rhs_x_box.copy(&*rhs_iv.x);
1126 blocks
1127 .px_l
1128 .add_m_sinv_z(1.0, blocks.slack_x_l, &*rhs_iv.z_l, &mut *aug_rhs_x_box);
1129 blocks
1130 .px_u
1131 .add_m_sinv_z(-1.0, blocks.slack_x_u, &*rhs_iv.z_u, &mut *aug_rhs_x_box);
1132
1133 aug_rhs_s_box.copy(&*rhs_iv.s);
1134 blocks
1135 .pd_l
1136 .add_m_sinv_z(1.0, blocks.slack_s_l, &*rhs_iv.v_l, &mut *aug_rhs_s_box);
1137 blocks
1138 .pd_u
1139 .add_m_sinv_z(-1.0, blocks.slack_s_u, &*rhs_iv.v_u, &mut *aug_rhs_s_box);
1140
1141 let col = &mut aug_packed[k * aug_dim..(k + 1) * aug_dim];
1142 copy_vector_to_slice(&*aug_rhs_x_box, &mut col[..n_x]);
1143 copy_vector_to_slice(&*aug_rhs_s_box, &mut col[n_x..n_x + n_s]);
1144 copy_vector_to_slice(&*rhs_iv.y_c, &mut col[n_x + n_s..n_x + n_s + n_y_c]);
1145 copy_vector_to_slice(&*rhs_iv.y_d, &mut col[n_x + n_s + n_y_c..]);
1146 }
1147
1148 // Phase 2: single batched back-substitution.
1149 let status = self
1150 .aug_solver
1151 .try_resolve_many_flat(&coeffs, &mut aug_packed, n_rhs)?;
1152 if status != ESymSolverStatus::Success {
1153 self.last_status = Some(status);
1154 return Some(false);
1155 }
1156 self.last_status = Some(status);
1157
1158 // Phase 3: unpack each column into `sol_iv`, run the bound-
1159 // multiplier expansion, hand the result to the caller. We have
1160 // to re-invoke `write_rhs` because expand_bound_multipliers
1161 // reads `rhs.z_l/z_u/v_l/v_u` and we re-used `rhs_iv` across
1162 // all columns in phase 1.
1163 for k in 0..n_rhs {
1164 write_rhs(k, &mut rhs_iv);
1165
1166 let col = &aug_packed[k * aug_dim..(k + 1) * aug_dim];
1167 set_vector_from_slice(&mut *sol_iv.x, &col[..n_x]);
1168 set_vector_from_slice(&mut *sol_iv.s, &col[n_x..n_x + n_s]);
1169 set_vector_from_slice(&mut *sol_iv.y_c, &col[n_x + n_s..n_x + n_s + n_y_c]);
1170 set_vector_from_slice(&mut *sol_iv.y_d, &col[n_x + n_s + n_y_c..]);
1171
1172 // Inline expand_bound_multipliers — that helper takes
1173 // `&IteratesVector` (Rc-backed) but our `rhs_iv` is
1174 // `IteratesVectorMut` (Box-backed). The four
1175 // `sinv_blrm_zmt_dbr` calls work on `&dyn Vector` either
1176 // way.
1177 blocks.px_l.sinv_blrm_zmt_dbr(
1178 -1.0,
1179 blocks.slack_x_l,
1180 &*rhs_iv.z_l,
1181 blocks.z_l,
1182 &*sol_iv.x,
1183 &mut *sol_iv.z_l,
1184 );
1185 blocks.px_u.sinv_blrm_zmt_dbr(
1186 1.0,
1187 blocks.slack_x_u,
1188 &*rhs_iv.z_u,
1189 blocks.z_u,
1190 &*sol_iv.x,
1191 &mut *sol_iv.z_u,
1192 );
1193 blocks.pd_l.sinv_blrm_zmt_dbr(
1194 -1.0,
1195 blocks.slack_s_l,
1196 &*rhs_iv.v_l,
1197 blocks.v_l,
1198 &*sol_iv.s,
1199 &mut *sol_iv.v_l,
1200 );
1201 blocks.pd_u.sinv_blrm_zmt_dbr(
1202 1.0,
1203 blocks.slack_s_u,
1204 &*rhs_iv.v_u,
1205 blocks.v_u,
1206 &*sol_iv.s,
1207 &mut *sol_iv.v_u,
1208 );
1209
1210 write_lhs(k, &sol_iv);
1211 }
1212
1213 Some(true)
1214 }
1215
1216 /// Flat-slice cached-factor multi-RHS path. Same cache-check
1217 /// semantics as [`Self::solve_many_cached`] but operates on
1218 /// row-major `(n_rhs, total)` flat buffers without going through
1219 /// `IteratesVectorMut` or any `dyn Vector` / `dyn Matrix` dispatch
1220 /// in the per-RHS inner loops — the eight source blocks
1221 /// (`slack_{x,s}_{l,u}`, `z_{l,u}`, `v_{l,u}`) get downcast to
1222 /// `DenseVector` once at the top, the four bound-expansion matrices
1223 /// (`px_l`, `px_u`, `pd_l`, `pd_u`) get downcast to
1224 /// `ExpansionMatrix` once, and Phase 1 / Phase 3 then run as raw
1225 /// `&[Number]` / `&mut [Number]` arithmetic on the flat buffers.
1226 ///
1227 /// `total` is the sum of the eight `block_dims` entries (in the
1228 /// same `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order that
1229 /// `IteratesVector` uses); `rhs_flat.len() == lhs_flat.len() ==
1230 /// n_rhs * total`.
1231 ///
1232 /// Returns `None` (caller should fall back to
1233 /// [`Self::solve_many_cached`]) when:
1234 /// - the cache check fails (matrix tags differ),
1235 /// - any block source vector is not a `DenseVector` or is
1236 /// homogeneous (uniform-scalar) on a non-empty block,
1237 /// - any bound-expansion matrix is not an `ExpansionMatrix`,
1238 /// - the underlying `AugSystemSolver` doesn't implement
1239 /// [`AugSystemSolver::try_resolve_many_flat`].
1240 ///
1241 /// Returns `Some(true)` on success, `Some(false)` on linsol back-
1242 /// solve failure.
1243 ///
1244 /// Used by `pounce_sensitivity::PdSensBacksolver::solve_many` as
1245 /// the fastest tier of the JaxProblem `jacrev` backward path
1246 /// (pounce#77 follow-up).
1247 #[allow(clippy::too_many_arguments)]
1248 pub fn solve_many_cached_flat(
1249 &mut self,
1250 data: &IpoptDataHandle,
1251 cq: &IpoptCqHandle,
1252 nlp: &Rc<RefCell<dyn IpoptNlp>>,
1253 n_rhs: usize,
1254 rhs_flat: &[Number],
1255 lhs_flat: &mut [Number],
1256 block_dims: [usize; 8],
1257 ) -> Option<bool> {
1258 if n_rhs == 0 {
1259 return Some(true);
1260 }
1261 let total: usize = block_dims.iter().sum();
1262 if rhs_flat.len() != n_rhs * total || lhs_flat.len() != n_rhs * total {
1263 return Some(false);
1264 }
1265 let mut off = [0usize; 9];
1266 for i in 0..8 {
1267 off[i + 1] = off[i] + block_dims[i];
1268 }
1269 let n_x = block_dims[0];
1270 let n_s = block_dims[1];
1271 let n_y_c = block_dims[2];
1272 let n_y_d = block_dims[3];
1273
1274 // Pull all blocks (same shape as `solve()`).
1275 let w = data.borrow().w.clone()?;
1276 let cq_ref = cq.borrow();
1277 let j_c = cq_ref.curr_jac_c();
1278 let j_d = cq_ref.curr_jac_d();
1279 let sigma_x = cq_ref.curr_sigma_x();
1280 let sigma_s = cq_ref.curr_sigma_s();
1281 let slack_x_l = cq_ref.curr_slack_x_l();
1282 let slack_x_u = cq_ref.curr_slack_x_u();
1283 let slack_s_l = cq_ref.curr_slack_s_l();
1284 let slack_s_u = cq_ref.curr_slack_s_u();
1285 drop(cq_ref);
1286
1287 let nlp_ref = nlp.borrow();
1288 let px_l = nlp_ref.px_l();
1289 let px_u = nlp_ref.px_u();
1290 let pd_l = nlp_ref.pd_l();
1291 let pd_u = nlp_ref.pd_u();
1292 drop(nlp_ref);
1293
1294 let curr = data.borrow().curr.clone()?;
1295
1296 // Cache-tag check (same 13 tags as `solve()`).
1297 let cur_tags: [Tag; 13] = [
1298 w.as_tagged().get_tag(),
1299 j_c.as_tagged().get_tag(),
1300 j_d.as_tagged().get_tag(),
1301 curr.z_l.as_tagged().get_tag(),
1302 curr.z_u.as_tagged().get_tag(),
1303 curr.v_l.as_tagged().get_tag(),
1304 curr.v_u.as_tagged().get_tag(),
1305 slack_x_l.as_tagged().get_tag(),
1306 slack_x_u.as_tagged().get_tag(),
1307 slack_s_l.as_tagged().get_tag(),
1308 slack_s_u.as_tagged().get_tag(),
1309 sigma_x.as_tagged().get_tag(),
1310 sigma_s.as_tagged().get_tag(),
1311 ];
1312 if !self.matrix_considered || !self.last_dep_tags.map_or(false, |prev| prev == cur_tags) {
1313 return None;
1314 }
1315
1316 // Concrete downcasts. Bail to closure-based fallback on any
1317 // type mismatch (homogeneous-on-non-empty included — the math
1318 // below assumes a real `[Number]` slice for slack / z / v).
1319 let slack_x_l_d = dense_slice_or_none(&*slack_x_l, block_dims[4])?;
1320 let slack_x_u_d = dense_slice_or_none(&*slack_x_u, block_dims[5])?;
1321 let slack_s_l_d = dense_slice_or_none(&*slack_s_l, block_dims[6])?;
1322 let slack_s_u_d = dense_slice_or_none(&*slack_s_u, block_dims[7])?;
1323 let blocks_z_l_d = dense_slice_or_none(&*curr.z_l, block_dims[4])?;
1324 let blocks_z_u_d = dense_slice_or_none(&*curr.z_u, block_dims[5])?;
1325 let blocks_v_l_d = dense_slice_or_none(&*curr.v_l, block_dims[6])?;
1326 let blocks_v_u_d = dense_slice_or_none(&*curr.v_u, block_dims[7])?;
1327
1328 let exp_x_l = exp_pos_or_none(&*px_l)?;
1329 let exp_x_u = exp_pos_or_none(&*px_u)?;
1330 let exp_s_l = exp_pos_or_none(&*pd_l)?;
1331 let exp_s_u = exp_pos_or_none(&*pd_u)?;
1332
1333 // Coeffs reuse the perturbation stashed by the most recent
1334 // `solve_once`.
1335 let d = self.perturb.borrow().current_perturbation();
1336 let coeffs = AugSysCoeffs {
1337 w: Some(&*w),
1338 w_factor: 1.0,
1339 d_x: Some(&*sigma_x),
1340 delta_x: d.delta_x,
1341 d_s: Some(&*sigma_s),
1342 delta_s: d.delta_s,
1343 j_c: &*j_c,
1344 d_c: None,
1345 delta_c: d.delta_c,
1346 j_d: &*j_d,
1347 d_d: None,
1348 delta_d: d.delta_d,
1349 };
1350
1351 let aug_dim = n_x + n_s + n_y_c + n_y_d;
1352 // Column-major `(aug_dim, n_rhs)` packed buffer, single alloc.
1353 let mut aug_packed = vec![0.0 as Number; aug_dim * n_rhs];
1354
1355 // ---------------- Phase 1 ----------------
1356 // For each k: build the aug-system RHS into column k of
1357 // aug_packed, all inline against raw slices.
1358 for k in 0..n_rhs {
1359 let r_base = k * total;
1360 let rhs_x = &rhs_flat[r_base + off[0]..r_base + off[1]];
1361 let rhs_s = &rhs_flat[r_base + off[1]..r_base + off[2]];
1362 let rhs_y_c = &rhs_flat[r_base + off[2]..r_base + off[3]];
1363 let rhs_y_d = &rhs_flat[r_base + off[3]..r_base + off[4]];
1364 let rhs_z_l = &rhs_flat[r_base + off[4]..r_base + off[5]];
1365 let rhs_z_u = &rhs_flat[r_base + off[5]..r_base + off[6]];
1366 let rhs_v_l = &rhs_flat[r_base + off[6]..r_base + off[7]];
1367 let rhs_v_u = &rhs_flat[r_base + off[7]..r_base + off[8]];
1368
1369 let aug_col = &mut aug_packed[k * aug_dim..(k + 1) * aug_dim];
1370 let (aug_x, rest) = aug_col.split_at_mut(n_x);
1371 let (aug_s, rest) = rest.split_at_mut(n_s);
1372 let (aug_y_c, aug_y_d) = rest.split_at_mut(n_y_c);
1373
1374 // aug_x = rhs_x + Px_L · S_xL⁻¹ · z_L − Px_U · S_xU⁻¹ · z_U
1375 aug_x.copy_from_slice(rhs_x);
1376 scatter_add_div(aug_x, exp_x_l, rhs_z_l, slack_x_l_d, 1.0);
1377 scatter_add_div(aug_x, exp_x_u, rhs_z_u, slack_x_u_d, -1.0);
1378 // aug_s = rhs_s + Pd_L · S_sL⁻¹ · v_L − Pd_U · S_sU⁻¹ · v_U
1379 aug_s.copy_from_slice(rhs_s);
1380 scatter_add_div(aug_s, exp_s_l, rhs_v_l, slack_s_l_d, 1.0);
1381 scatter_add_div(aug_s, exp_s_u, rhs_v_u, slack_s_u_d, -1.0);
1382 aug_y_c.copy_from_slice(rhs_y_c);
1383 aug_y_d.copy_from_slice(rhs_y_d);
1384 }
1385
1386 // ---------------- Phase 2 ----------------
1387 let status = self
1388 .aug_solver
1389 .try_resolve_many_flat(&coeffs, &mut aug_packed, n_rhs)?;
1390 if status != ESymSolverStatus::Success {
1391 self.last_status = Some(status);
1392 return Some(false);
1393 }
1394 self.last_status = Some(status);
1395
1396 // ---------------- Phase 3 ----------------
1397 // For each k: copy sol_x/s/y_c/y_d into lhs_flat, then build
1398 // sol_z_l/z_u/v_l/v_u from the bound-multiplier expansion.
1399 for k in 0..n_rhs {
1400 let r_base = k * total;
1401 let rhs_z_l = &rhs_flat[r_base + off[4]..r_base + off[5]];
1402 let rhs_z_u = &rhs_flat[r_base + off[5]..r_base + off[6]];
1403 let rhs_v_l = &rhs_flat[r_base + off[6]..r_base + off[7]];
1404 let rhs_v_u = &rhs_flat[r_base + off[7]..r_base + off[8]];
1405
1406 let aug_col = &aug_packed[k * aug_dim..(k + 1) * aug_dim];
1407 let sol_x = &aug_col[..n_x];
1408 let sol_s = &aug_col[n_x..n_x + n_s];
1409 let sol_y_c = &aug_col[n_x + n_s..n_x + n_s + n_y_c];
1410 let sol_y_d = &aug_col[n_x + n_s + n_y_c..];
1411
1412 let l_base = k * total;
1413 let (lhs_xs, lhs_zv) = lhs_flat[l_base..l_base + total].split_at_mut(off[4]);
1414 let (lhs_x, rest) = lhs_xs.split_at_mut(n_x);
1415 let (lhs_s, rest) = rest.split_at_mut(n_s);
1416 let (lhs_y_c, lhs_y_d) = rest.split_at_mut(n_y_c);
1417 lhs_x.copy_from_slice(sol_x);
1418 lhs_s.copy_from_slice(sol_s);
1419 lhs_y_c.copy_from_slice(sol_y_c);
1420 lhs_y_d.copy_from_slice(sol_y_d);
1421
1422 let (lhs_z_l, rest) = lhs_zv.split_at_mut(block_dims[4]);
1423 let (lhs_z_u, rest) = rest.split_at_mut(block_dims[5]);
1424 let (lhs_v_l, lhs_v_u) = rest.split_at_mut(block_dims[6]);
1425
1426 // sol_z_l[i] = (rhs_z_l[i] − z_l[i] · sol_x[exp_x_l[i]]) / slack_x_l[i]
1427 expand_bound_mult(
1428 lhs_z_l,
1429 rhs_z_l,
1430 blocks_z_l_d,
1431 sol_x,
1432 exp_x_l,
1433 slack_x_l_d,
1434 -1.0,
1435 );
1436 // sol_z_u[i] = (rhs_z_u[i] + z_u[i] · sol_x[exp_x_u[i]]) / slack_x_u[i]
1437 expand_bound_mult(
1438 lhs_z_u,
1439 rhs_z_u,
1440 blocks_z_u_d,
1441 sol_x,
1442 exp_x_u,
1443 slack_x_u_d,
1444 1.0,
1445 );
1446 expand_bound_mult(
1447 lhs_v_l,
1448 rhs_v_l,
1449 blocks_v_l_d,
1450 sol_s,
1451 exp_s_l,
1452 slack_s_l_d,
1453 -1.0,
1454 );
1455 expand_bound_mult(
1456 lhs_v_u,
1457 rhs_v_u,
1458 blocks_v_u_d,
1459 sol_s,
1460 exp_s_u,
1461 slack_s_u_d,
1462 1.0,
1463 );
1464 }
1465
1466 Some(true)
1467 }
1468
1469 /// One outer back-solve through the augmented system, including
1470 /// the `Px_L · S_xL⁻¹ · z_L` lifts on the RHS and the bound-
1471 /// multiplier expansion on the solution side. Mirrors
1472 /// `IpPDFullSpaceSolver::SolveOnce`.
1473 #[allow(clippy::too_many_arguments)]
1474 fn solve_once(
1475 &mut self,
1476 data: &IpoptDataHandle,
1477 b: &SolveBlocks<'_>,
1478 alpha: Number,
1479 beta: Number,
1480 rhs: &IteratesVector,
1481 res: &mut IteratesVectorMut,
1482 _resolve_with_better_quality: bool,
1483 mut pretend_singular: bool,
1484 ) -> bool {
1485 // Build aug-system primal RHS:
1486 // augRhs_x = rhs.x + Px_L · S_xL⁻¹ · z_L − Px_U · S_xU⁻¹ · z_U
1487 let mut aug_rhs_x = rhs.x.make_new_copy();
1488 b.px_l
1489 .add_m_sinv_z(1.0, b.slack_x_l, &*rhs.z_l, &mut *aug_rhs_x);
1490 b.px_u
1491 .add_m_sinv_z(-1.0, b.slack_x_u, &*rhs.z_u, &mut *aug_rhs_x);
1492
1493 let mut aug_rhs_s = rhs.s.make_new_copy();
1494 b.pd_l
1495 .add_m_sinv_z(1.0, b.slack_s_l, &*rhs.v_l, &mut *aug_rhs_s);
1496 b.pd_u
1497 .add_m_sinv_z(-1.0, b.slack_s_u, &*rhs.v_u, &mut *aug_rhs_s);
1498
1499 // Solution slot for the aug-system (dx, ds, dy_c, dy_d).
1500 let mut sol = res.fresh_zeroed();
1501
1502 // Number of negative eigenvalues we expect.
1503 let num_neg_evals = rhs.y_c.dim() + rhs.y_d.dim();
1504
1505 let curr_mu = data.borrow().curr_mu;
1506
1507 // Upstream's `IpPDFullSpaceSolver::SolveOnce` (cpp:457-482)
1508 // splits on `(uptodate && !pretend_singular)`: if the matrix is
1509 // unchanged since the last `SolveOnce` and we are not faking a
1510 // singularity, reuse the existing perturbation, do a single
1511 // back-solve with `check_inertia=false`, and return. Iterative
1512 // refinement and the post-`IncreaseQuality` retry both land
1513 // here. Calling `ConsiderNewSystem` again on a same-matrix
1514 // re-solve would corrupt the perturbation handler's
1515 // `delta_x_last` bookkeeping.
1516 if self.matrix_considered && !pretend_singular {
1517 let d = self.perturb.borrow().current_perturbation();
1518 let coeffs = AugSysCoeffs {
1519 w: Some(b.w),
1520 w_factor: 1.0,
1521 d_x: Some(b.sigma_x),
1522 delta_x: d.delta_x,
1523 d_s: Some(b.sigma_s),
1524 delta_s: d.delta_s,
1525 j_c: b.j_c,
1526 d_c: None,
1527 delta_c: d.delta_c,
1528 j_d: b.j_d,
1529 d_d: None,
1530 delta_d: d.delta_d,
1531 };
1532 let aug_rhs = AugSysRhs {
1533 rhs_x: &*aug_rhs_x,
1534 rhs_s: &*aug_rhs_s,
1535 rhs_c: &*rhs.y_c,
1536 rhs_d: &*rhs.y_d,
1537 };
1538 let mut aug_sol = AugSysSol {
1539 sol_x: &mut *sol.x,
1540 sol_s: &mut *sol.s,
1541 sol_c: &mut *sol.y_c,
1542 sol_d: &mut *sol.y_d,
1543 };
1544 // Same matrix, same perturbations, inertia already known —
1545 // use the cached factor and avoid the per-call refactor
1546 // that otherwise dominates MA57 wall-time on long iter-ref
1547 // loops (cont5_2_4_l drops 97s → ~30s).
1548 let retval = self.aug_solver.resolve(&coeffs, &aug_rhs, &mut aug_sol);
1549 if retval != ESymSolverStatus::Success {
1550 return false;
1551 }
1552 // Stash perturbations on data, expand bound multipliers,
1553 // assemble final res, and return — skipping the
1554 // escalation loop entirely (matches upstream's `if(uptodate
1555 // && !pretend_singular)` branch in IpPDFullSpaceSolver.cpp).
1556 {
1557 let mut dm = data.borrow_mut();
1558 dm.perturbations.delta_x = d.delta_x;
1559 dm.perturbations.delta_s = d.delta_s;
1560 dm.perturbations.delta_c = d.delta_c;
1561 dm.perturbations.delta_d = d.delta_d;
1562 }
1563 expand_bound_multipliers(b, rhs, &mut sol);
1564 let frozen_sol = sol.freeze();
1565 res.add_one_vector(alpha, &frozen_sol, beta);
1566 return true;
1567 }
1568
1569 let mut deltas = self
1570 .perturb
1571 .borrow_mut()
1572 .consider_new_system(curr_mu, Some(&IpoptDataSink(data)));
1573 let Some(mut d) = deltas.take() else {
1574 return false;
1575 };
1576
1577 let mut count = 0_i32;
1578 let mut retval;
1579 loop {
1580 // pounce#244: the body of this loop is a full KKT factorization
1581 // — the escalation path retries with a larger perturbation until
1582 // the augmented system has the right inertia, and on a hard,
1583 // ill-conditioned system that can be many refactorizations under
1584 // one outer iteration. Abort between factorizations when the
1585 // shared deadline is crossed so the solve cannot overshoot the
1586 // budget by that whole sweep. `data`'s deadline is the caller's
1587 // global budget; `false` unwinds to the outer loop's post-KKT
1588 // time-limit check. A deadline already crossed on entry returns
1589 // before the first factorization; otherwise overshoot is bounded
1590 // to one.
1591 //
1592 // pounce#254: `deadline_exceeded` is reactive — it fires only
1593 // once a factorization has already run the clock past the budget.
1594 // Because a single feral factorization is uninterruptible (feral
1595 // 0.14 exposes no in-factor cancel hook; see
1596 // `dev-notes/feral-factor-interrupt.md`), that still lets one
1597 // whole factorization overshoot. `predict_factor_overshoot` is the
1598 // proactive complement: once a factorization has been observed to
1599 // cost a large fraction of the budget, refuse to start another the
1600 // remaining budget cannot cover, so the doomed factorization never
1601 // begins.
1602 if deadline_exceeded(data) || self.predict_factor_overshoot(data) {
1603 return false;
1604 }
1605 if pretend_singular {
1606 retval = ESymSolverStatus::Singular;
1607 pretend_singular = false;
1608 } else {
1609 count += 1;
1610 // Stand the inertia check down only when the curvature
1611 // test can actually take its place: that test reads the
1612 // backend's negative-eigenvalue count, so a backend
1613 // without an inertia keeps the check rather than ending
1614 // up with neither (`IpPDFullSpaceSolver.cpp:515-518`,
1615 // whose DBG_ASSERT states the same requirement).
1616 let check_inertia =
1617 self.neg_curv_test_tol <= 0.0 || !self.aug_solver.provides_inertia();
1618 let coeffs = AugSysCoeffs {
1619 w: Some(b.w),
1620 w_factor: 1.0,
1621 d_x: Some(b.sigma_x),
1622 delta_x: d.delta_x,
1623 d_s: Some(b.sigma_s),
1624 delta_s: d.delta_s,
1625 j_c: b.j_c,
1626 d_c: None,
1627 delta_c: d.delta_c,
1628 j_d: b.j_d,
1629 d_d: None,
1630 delta_d: d.delta_d,
1631 };
1632 let aug_rhs = AugSysRhs {
1633 rhs_x: &*aug_rhs_x,
1634 rhs_s: &*aug_rhs_s,
1635 rhs_c: &*rhs.y_c,
1636 rhs_d: &*rhs.y_d,
1637 };
1638 let mut aug_sol = AugSysSol {
1639 sol_x: &mut *sol.x,
1640 sol_s: &mut *sol.s,
1641 sol_c: &mut *sol.y_c,
1642 sol_d: &mut *sol.y_d,
1643 };
1644 // pounce#254: time this factorization and remember the worst
1645 // single-factorization cost seen so far, which feeds the
1646 // predictive guard above. Only the true factorization path is
1647 // measured — the cheap cached back-solve / iterative-refinement
1648 // re-solves never widen the estimate.
1649 let t_wall = wallclock_time();
1650 let t_cpu = cpu_time();
1651 retval = self.aug_solver.solve(
1652 &coeffs,
1653 &aug_rhs,
1654 &mut aug_sol,
1655 check_inertia,
1656 num_neg_evals,
1657 );
1658 let d_wall = wallclock_time() - t_wall;
1659 let d_cpu = cpu_time() - t_cpu;
1660 if d_wall > self.max_factor_wall {
1661 self.max_factor_wall = d_wall;
1662 }
1663 if d_cpu > self.max_factor_cpu {
1664 self.max_factor_cpu = d_cpu;
1665 }
1666 }
1667
1668 if retval == ESymSolverStatus::FatalError {
1669 return false;
1670 }
1671
1672 if retval == ESymSolverStatus::Singular && (rhs.y_c.dim() + rhs.y_d.dim() > 0) {
1673 let curr_mu = data.borrow().curr_mu;
1674 let next = self
1675 .perturb
1676 .borrow_mut()
1677 .perturb_for_singular(curr_mu, Some(&IpoptDataSink(data)));
1678 let Some(nd) = next else { return false };
1679 d = nd;
1680 } else if retval == ESymSolverStatus::WrongInertia
1681 && self.aug_solver.number_of_neg_evals() < num_neg_evals
1682 {
1683 let mut assume_singular = true;
1684 if !self.augsys_improved {
1685 self.escalate_aug_quality();
1686 if self.augsys_improved {
1687 data.borrow_mut().append_info_string("q");
1688 assume_singular = false;
1689 }
1690 }
1691 if assume_singular {
1692 let curr_mu = data.borrow().curr_mu;
1693 let next = self
1694 .perturb
1695 .borrow_mut()
1696 .perturb_for_singular(curr_mu, Some(&IpoptDataSink(data)));
1697 let Some(nd) = next else { return false };
1698 d = nd;
1699 data.borrow_mut().append_info_string("a");
1700 }
1701 } else if retval == ESymSolverStatus::WrongInertia
1702 || retval == ESymSolverStatus::Singular
1703 {
1704 let curr_mu = data.borrow().curr_mu;
1705 let next = self
1706 .perturb
1707 .borrow_mut()
1708 .perturb_for_wrong_inertia(curr_mu, Some(&IpoptDataSink(data)));
1709 let Some(nd) = next else { return false };
1710 d = nd;
1711 } else if retval == ESymSolverStatus::Success
1712 && self.neg_curv_test_tol > 0.0
1713 && self.aug_solver.provides_inertia()
1714 {
1715 // Inertia-free curvature test — `IpPDFullSpaceSolver.cpp:592-634`
1716 // (Zavala & Chiang 2014). Reached only on `Success`: the
1717 // arms above cover every other status, and the factorization
1718 // above ran with `check_inertia = false` precisely because
1719 // this tolerance is positive, so a wrong inertia arrives here
1720 // as a *successful* solve. Instead of trusting the inertia we
1721 // ask whether the direction the system produced actually has
1722 // sufficient positive curvature; if it does not, escalate the
1723 // primal regularization exactly as a WrongInertia would and
1724 // refactor.
1725 let neg_values = self.aug_solver.number_of_neg_evals();
1726 if neg_values != num_neg_evals {
1727 let x_w_x = Self::curvature_measure(
1728 b,
1729 &sol,
1730 self.neg_curv_test_reg,
1731 d.delta_x,
1732 d.delta_s,
1733 );
1734 let xs_nrmsq = sol.x.nrm2().powi(2) + sol.s.nrm2().powi(2);
1735 tracing::debug!(target: "pounce::kkt",
1736 "inertia heuristic: xWx = {:e} xx = {:e}", x_w_x, xs_nrmsq);
1737 if x_w_x < self.neg_curv_test_tol * xs_nrmsq {
1738 let curr_mu = data.borrow().curr_mu;
1739 let next = self
1740 .perturb
1741 .borrow_mut()
1742 .perturb_for_wrong_inertia(curr_mu, Some(&IpoptDataSink(data)));
1743 let Some(nd) = next else { return false };
1744 d = nd;
1745 retval = ESymSolverStatus::WrongInertia;
1746 }
1747 }
1748 }
1749
1750 if retval == ESymSolverStatus::Success {
1751 break;
1752 }
1753 }
1754 let _ = count;
1755
1756 // Stash the perturbation on data — upstream calls
1757 // `IpData().setPDPert(...)` here.
1758 {
1759 let mut dm = data.borrow_mut();
1760 dm.perturbations.delta_x = d.delta_x;
1761 dm.perturbations.delta_s = d.delta_s;
1762 dm.perturbations.delta_c = d.delta_c;
1763 dm.perturbations.delta_d = d.delta_d;
1764 }
1765
1766 // Mark this matrix as "considered" so subsequent `solve_once`
1767 // re-calls within the same outer `solve()` (iterative refinement
1768 // / quality retry) take the single-solve path above.
1769 self.matrix_considered = true;
1770
1771 expand_bound_multipliers(b, rhs, &mut sol);
1772
1773 // res = α · sol + β · res
1774 let frozen_sol = sol.freeze();
1775 res.add_one_vector(alpha, &frozen_sol, beta);
1776 true
1777 }
1778
1779 /// Curvature of the computed direction in the primal block —
1780 /// `xWx` in `IpPDFullSpaceSolver.cpp:600-621`:
1781 ///
1782 /// ```text
1783 /// dxᵀ W dx + dxᵀ Σ_x dx + dsᵀ Σ_s ds [+ δ_x dxᵀdx + δ_s dsᵀds]
1784 /// ```
1785 ///
1786 /// The bracketed primal-regularization term is included only when
1787 /// `neg_curv_test_reg` is on (upstream's default). The operation
1788 /// order mirrors upstream's — copy, scale, dot — so the result is
1789 /// bit-comparable rather than merely algebraically equal.
1790 fn curvature_measure(
1791 b: &SolveBlocks<'_>,
1792 sol: &IteratesVectorMut,
1793 with_regularization: bool,
1794 delta_x: Number,
1795 delta_s: Number,
1796 ) -> Number {
1797 let mut x_tmp = sol.x.make_new();
1798 b.w.mult_vector(1.0, &*sol.x, 0.0, &mut *x_tmp);
1799 let mut x_w_x = x_tmp.dot(&*sol.x);
1800
1801 x_tmp.copy(&*sol.x);
1802 x_tmp.element_wise_multiply(b.sigma_x);
1803 x_w_x += x_tmp.dot(&*sol.x);
1804
1805 let mut s_tmp = sol.s.make_new_copy();
1806 s_tmp.element_wise_multiply(b.sigma_s);
1807 x_w_x += s_tmp.dot(&*sol.s);
1808
1809 if with_regularization {
1810 x_tmp.copy(&*sol.x);
1811 x_tmp.scal(delta_x);
1812 x_w_x += x_tmp.dot(&*sol.x);
1813
1814 s_tmp.copy(&*sol.s);
1815 s_tmp.scal(delta_s);
1816 x_w_x += s_tmp.dot(&*sol.s);
1817 }
1818
1819 x_w_x
1820 }
1821
1822 /// `resid = M · res − rhs` per `ComputeResiduals`. Skips terms
1823 /// whose perturbation is exactly zero.
1824 fn compute_residuals(
1825 &self,
1826 _data: &IpoptDataHandle,
1827 b: &SolveBlocks<'_>,
1828 rhs: &IteratesVector,
1829 res: &IteratesVectorMut,
1830 resid: &mut IteratesVectorMut,
1831 ) {
1832 let d = self.perturb.borrow().current_perturbation();
1833
1834 // x: W·res.x + J_c^T·res.y_c + J_d^T·res.y_d
1835 // − Px_L·res.z_L + Px_U·res.z_U + δ_x·res.x − rhs.x
1836 b.w.mult_vector(1.0, &*res.x, 0.0, &mut *resid.x);
1837 b.j_c.trans_mult_vector(1.0, &*res.y_c, 1.0, &mut *resid.x);
1838 b.j_d.trans_mult_vector(1.0, &*res.y_d, 1.0, &mut *resid.x);
1839 b.px_l.mult_vector(-1.0, &*res.z_l, 1.0, &mut *resid.x);
1840 b.px_u.mult_vector(1.0, &*res.z_u, 1.0, &mut *resid.x);
1841 // resid.x += δ_x·res.x − rhs.x
1842 resid
1843 .x
1844 .add_two_vectors(d.delta_x, &*res.x, -1.0, &*rhs.x, 1.0);
1845
1846 // s: Pd_U·res.v_U − Pd_L·res.v_L − res.y_d − rhs.s + δ_s·res.s
1847 b.pd_u.mult_vector(1.0, &*res.v_u, 0.0, &mut *resid.s);
1848 b.pd_l.mult_vector(-1.0, &*res.v_l, 1.0, &mut *resid.s);
1849 resid.s.add_two_vectors(-1.0, &*res.y_d, -1.0, &*rhs.s, 1.0);
1850 if d.delta_s != 0.0 {
1851 resid.s.axpy(d.delta_s, &*res.s);
1852 }
1853
1854 // c: J_c·res.x − δ_c·res.y_c − rhs.y_c
1855 b.j_c.mult_vector(1.0, &*res.x, 0.0, &mut *resid.y_c);
1856 resid
1857 .y_c
1858 .add_two_vectors(-d.delta_c, &*res.y_c, -1.0, &*rhs.y_c, 1.0);
1859
1860 // d: J_d·res.x − res.s − rhs.y_d − δ_d·res.y_d
1861 b.j_d.mult_vector(1.0, &*res.x, 0.0, &mut *resid.y_d);
1862 resid
1863 .y_d
1864 .add_two_vectors(-1.0, &*res.s, -1.0, &*rhs.y_d, 1.0);
1865 if d.delta_d != 0.0 {
1866 resid.y_d.axpy(-d.delta_d, &*res.y_d);
1867 }
1868
1869 // zL: res.z_L · slack_x_L + (Px_L^T·res.x) · z_L − rhs.z_L
1870 resid.z_l.copy(&*res.z_l);
1871 resid.z_l.element_wise_multiply(b.slack_x_l);
1872 let mut tmp_zl = b.z_l.make_new();
1873 b.px_l.trans_mult_vector(1.0, &*res.x, 0.0, &mut *tmp_zl);
1874 tmp_zl.element_wise_multiply(b.z_l);
1875 resid
1876 .z_l
1877 .add_two_vectors(1.0, &*tmp_zl, -1.0, &*rhs.z_l, 1.0);
1878
1879 // zU: res.z_U · slack_x_U − (Px_U^T·res.x) · z_U − rhs.z_U
1880 resid.z_u.copy(&*res.z_u);
1881 resid.z_u.element_wise_multiply(b.slack_x_u);
1882 let mut tmp_zu = b.z_u.make_new();
1883 b.px_u.trans_mult_vector(1.0, &*res.x, 0.0, &mut *tmp_zu);
1884 tmp_zu.element_wise_multiply(b.z_u);
1885 resid
1886 .z_u
1887 .add_two_vectors(-1.0, &*tmp_zu, -1.0, &*rhs.z_u, 1.0);
1888
1889 // vL: res.v_L · slack_s_L + (Pd_L^T·res.s) · v_L − rhs.v_L
1890 resid.v_l.copy(&*res.v_l);
1891 resid.v_l.element_wise_multiply(b.slack_s_l);
1892 let mut tmp_vl = b.v_l.make_new();
1893 b.pd_l.trans_mult_vector(1.0, &*res.s, 0.0, &mut *tmp_vl);
1894 tmp_vl.element_wise_multiply(b.v_l);
1895 resid
1896 .v_l
1897 .add_two_vectors(1.0, &*tmp_vl, -1.0, &*rhs.v_l, 1.0);
1898
1899 // vU: res.v_U · slack_s_U − (Pd_U^T·res.s) · v_U − rhs.v_U
1900 resid.v_u.copy(&*res.v_u);
1901 resid.v_u.element_wise_multiply(b.slack_s_u);
1902 let mut tmp_vu = b.v_u.make_new();
1903 b.pd_u.trans_mult_vector(1.0, &*res.s, 0.0, &mut *tmp_vu);
1904 tmp_vu.element_wise_multiply(b.v_u);
1905 resid
1906 .v_u
1907 .add_two_vectors(-1.0, &*tmp_vu, -1.0, &*rhs.v_u, 1.0);
1908 }
1909
1910 /// `nrm_resid / (min(nrm_res, max_cond·nrm_rhs) + nrm_rhs)`, with
1911 /// `max_cond = 1e6`. Mirrors `ComputeResidualRatio`.
1912 fn compute_residual_ratio(
1913 &self,
1914 rhs: &IteratesVector,
1915 res: &IteratesVectorMut,
1916 resid: &IteratesVectorMut,
1917 ) -> Number {
1918 let nrm_rhs = rhs.amax();
1919 let nrm_res = res.amax();
1920 let nrm_resid = resid.amax();
1921 if nrm_rhs + nrm_res == 0.0 {
1922 nrm_resid
1923 } else {
1924 let max_cond = 1e6;
1925 nrm_resid / (nrm_res.min(max_cond * nrm_rhs) + nrm_rhs)
1926 }
1927 }
1928}
1929
1930impl PdSystemSolver for PdFullSpaceSolver {
1931 fn solve_status(&self) -> ESymSolverStatus {
1932 self.last_status.unwrap_or(ESymSolverStatus::FatalError)
1933 }
1934}
1935
1936/// Cooperative time-budget check for the KKT solve (pounce#244).
1937///
1938/// Reads the shared per-solve [`Deadline`](pounce_common::timing::Deadline)
1939/// off [`IpoptData`](crate::ipopt_data::IpoptData) — the same instance the
1940/// outer loop, the line search, and the restoration inner IPM consult —
1941/// and reports whether either the wall or CPU budget has been crossed.
1942/// [`PdFullSpaceSolver::solve`] / [`PdFullSpaceSolver::solve_once`] call it
1943/// between their major factorization steps so an over-budget solve aborts
1944/// promptly instead of running a whole inertia-correction /
1945/// iterative-refinement sweep to completion.
1946///
1947/// A single outer iteration of a large, ill-conditioned NLP is dominated
1948/// by the KKT factorization, and the inertia-correction loop can refactor
1949/// several times before the augmented system has the right inertia. #242
1950/// only checked the deadline *after* the search direction was fully
1951/// computed, so that whole multi-factorization sweep overshot the requested
1952/// budget (the reported 2 s → 12.7 s single-NLP probe, "unchanged by #242").
1953/// Checking here bounds the overshoot to roughly one factorization.
1954///
1955/// Returns `false` when no deadline is installed (the direct-driver /
1956/// unit-test paths), leaving those on the coarse `overall_alg`-timer gate
1957/// in [`crate::conv_check`]. Aborting with `false` is safe: `solve` only
1958/// promotes the computed `delta` onto `IpoptData` on a `true` return, so a
1959/// deadline abort leaves `data.curr` / `data.delta` untouched, and the
1960/// caller's post-KKT deadline check then terminates with the time-limit
1961/// status (returning the last accepted iterate) rather than treating the
1962/// abort as a step-computation failure that would enter restoration.
1963fn deadline_exceeded(data: &IpoptDataHandle) -> bool {
1964 data.borrow()
1965 .deadline
1966 .as_ref()
1967 .is_some_and(|d| d.exceeded().is_some())
1968}
1969
1970/// Core decision of [`PdFullSpaceSolver::predict_factor_overshoot`], split
1971/// out as a free function so the guard logic is unit-testable against a
1972/// hand-built [`Deadline`] and synthetic factorization-cost estimates
1973/// without standing up a whole solver (pounce#254).
1974///
1975/// Fires when the worst single factorization observed so far is both a
1976/// large-enough fraction of the whole budget (`>= FACTOR_OVERSHOOT_BUDGET_FRACTION`)
1977/// *and* larger than the budget still remaining on either the wall or the
1978/// CPU clock — i.e. starting one more factorization of that size would
1979/// overshoot. The fraction gate keeps the guard dormant on ordinary solves
1980/// whose factorizations are a small slice of the budget. Zero estimates
1981/// (no factorization measured yet) never fire.
1982fn factor_overshoot_predicted(
1983 max_factor_wall: Number,
1984 max_factor_cpu: Number,
1985 deadline: &pounce_common::timing::Deadline,
1986) -> bool {
1987 let wall_gate = max_factor_wall > 0.0
1988 && max_factor_wall >= FACTOR_OVERSHOOT_BUDGET_FRACTION * deadline.max_wall()
1989 && deadline.remaining_wall() < max_factor_wall;
1990 let cpu_gate = max_factor_cpu > 0.0
1991 && max_factor_cpu >= FACTOR_OVERSHOOT_BUDGET_FRACTION * deadline.max_cpu()
1992 && deadline.remaining_cpu() < max_factor_cpu;
1993 wall_gate || cpu_gate
1994}
1995
1996/// A direction of negative curvature at the current iterate, as returned by
1997/// [`PdFullSpaceSolver::negative_curvature_direction`] (gh #797).
1998pub struct NegativeCurvature {
1999 /// The direction. Only the `x` and `s` blocks are populated — every dual
2000 /// block is zero — and the primal part has unit infinity-norm, so a step
2001 /// length multiplying it is in the iterate's own units.
2002 pub delta: crate::iterates_vector::IteratesVector,
2003 /// The measured `dᵀ(W + Σ)d` for [`Self::delta`]. Strictly negative
2004 /// whenever a value is returned: it is what makes the direction one the
2005 /// barrier objective *decreases* along to second order, and the caller
2006 /// sizes its sufficient-decrease test from it.
2007 pub curvature: Number,
2008}
2009
2010/// First rung of the probe's `δ_x` ladder (gh #797). Deliberately below the
2011/// perturbation handler's own first trial (`delta_xs_init`, `1e-4`): the
2012/// ladder's *first successful* rung is what brackets `-λ_min` of the reduced
2013/// Hessian, and a coarser start brackets it more loosely and weakens the
2014/// inverse iteration that follows. It does not go all the way down to
2015/// `delta_xs_min` (`1e-20`) for the mirror-image reason — twelve more rungs
2016/// buys a sharper bracket only on a model whose reduced Hessian is barely
2017/// indefinite, and every rung is a factorization.
2018const NEG_CURV_DELTA_MIN: Number = 1e-8;
2019/// Ceiling of that ladder. Matches the perturbation handler's own
2020/// `delta_xs_max` (`max_hessian_perturbation`, `1e20`): past it the shifted
2021/// matrix no longer describes the model, and a reduced Hessian that indefinite
2022/// is not something one escape step is going to fix.
2023const NEG_CURV_DELTA_MAX: Number = 1e20;
2024/// Ladder ratio. The rung that first fixes the inertia then brackets `-λ_min`
2025/// of the reduced Hessian within this factor, which is what makes the
2026/// subsequent inverse iteration converge quickly.
2027const NEG_CURV_DELTA_FACTOR: Number = 10.0;
2028/// Hard cap on factorizations the probe may spend. The ladder from
2029/// [`NEG_CURV_DELTA_MIN`] to [`NEG_CURV_DELTA_MAX`] is 29 rungs plus the
2030/// unperturbed one it starts at, so the cap does not truncate it; it is the
2031/// backstop that bounds the cost if the ladder is ever widened, and it is
2032/// spent at most `neg_curv_escapes` times per solve. Every converged solve
2033/// whose reduced Hessian is positive definite — which is all of them but the
2034/// gh #797 shape — pays exactly one factorization and stops.
2035const NEG_CURV_MAX_FACTORIZATIONS: usize = 30;
2036/// Inverse-iteration steps (the first is the factorization's own solve, the
2037/// rest are back-solves against the cached factor). Every candidate is
2038/// measured, so an extra step can only improve the answer, never invalidate
2039/// it.
2040///
2041/// This was three, on the reasoning that three "is enough to separate `λ_min`
2042/// from `λ_2` at the ladder's resolution". That is only true when the shift is
2043/// close to `|λ_min|`, and the bare ×10 ladder does not deliver that: landing a
2044/// decade high leaves a spectral ratio near one, where each back-solve buys
2045/// almost no separation. Measured on `min ½(x₀² − 1.05·x₁²)` over `[−2, 2]²`,
2046/// three steps amplify the negative direction by 1.9× and the probe declines;
2047/// the solve then reports `Solve_Succeeded` at the saddle. The bracket
2048/// bisection in [`PdFullSpaceSolver::negative_curvature_direction`] fixes the
2049/// shift, and this raises the iteration budget so a merely *awkward* spectrum
2050/// is not fatal either. `crates/pounce-qp/src/negcurv.rs` uses 20 for the same
2051/// search, and the cost is one back-solve per step against a factor that has
2052/// already been computed.
2053const NEG_CURV_INVERSE_ITERS: usize = 20;
2054/// Geometric bisections of the `[δ/factor, δ]` bracket the ladder leaves, to
2055/// stop a decade-wide overshoot from starving the inverse iteration above.
2056/// Mirrors `neg_curv_shift_refinements` on the QP arm (gh#848); each costs one
2057/// factorization, and eight takes a decade-wide bracket to about 2%.
2058const NEG_CURV_SHIFT_REFINEMENTS: usize = 8;
2059
2060/// Write a deterministic, index-dependent seed into `v`, returning `false`
2061/// when the vector is not dense (the restoration inner IPM's compound
2062/// iterate, which this probe declines to answer for).
2063///
2064/// The values come from SplitMix64 on the *global* index — `offset` is the
2065/// count of entries already written into earlier blocks — so the `x` and `s`
2066/// blocks of one seed never repeat each other, and the same model always
2067/// probes with the same vector on every platform.
2068///
2069/// A structured seed is what does not work here. The all-ones vector is the
2070/// obvious choice and it is orthogonal to the negative-curvature direction of
2071/// the gh #797 reproducer: `nonconvex_qp`'s reduced Hessian is negative along
2072/// `(1,-1)`, which is exactly the direction a symmetric seed cannot see. A
2073/// symmetric model is the case this probe exists for, so the seed has to
2074/// break symmetry by construction.
2075fn fill_probe_seed(v: &mut dyn Vector, offset: usize) -> bool {
2076 let Some(dense) = v.as_any_mut().downcast_mut::<DenseVector>() else {
2077 return false;
2078 };
2079 for (i, slot) in dense.values_mut().iter_mut().enumerate() {
2080 let mut z = ((offset + i) as u64).wrapping_add(0x9E37_79B9_7F4A_7C15);
2081 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
2082 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
2083 z ^= z >> 31;
2084 // Uniform on [-1, 1) from the top 53 bits.
2085 *slot = ((z >> 11) as Number) / ((1u64 << 53) as Number) * 2.0 - 1.0;
2086 }
2087 true
2088}
2089
2090/// The augmented system the probe factors: the step computation's own
2091/// matrix with both primal blocks shifted by `delta` and **both dual
2092/// perturbations pinned to zero**, so the direction it produces stays in
2093/// `null(J_c)` and on `J_d d_x = d_s`.
2094fn neg_curv_coeffs<'a>(b: &SolveBlocks<'a>, delta: Number) -> AugSysCoeffs<'a> {
2095 AugSysCoeffs {
2096 w: Some(b.w),
2097 w_factor: 1.0,
2098 d_x: Some(b.sigma_x),
2099 delta_x: delta,
2100 d_s: Some(b.sigma_s),
2101 delta_s: delta,
2102 j_c: b.j_c,
2103 d_c: None,
2104 delta_c: 0.0,
2105 j_d: b.j_d,
2106 d_d: None,
2107 delta_d: 0.0,
2108 }
2109}
2110
2111/// Bag of borrowed blocks used by both `solve_once` and
2112/// `compute_residuals` — keeps argument lists tractable.
2113struct SolveBlocks<'a> {
2114 w: &'a dyn SymMatrix,
2115 j_c: &'a dyn Matrix,
2116 j_d: &'a dyn Matrix,
2117 px_l: &'a dyn Matrix,
2118 px_u: &'a dyn Matrix,
2119 pd_l: &'a dyn Matrix,
2120 pd_u: &'a dyn Matrix,
2121 z_l: &'a dyn Vector,
2122 z_u: &'a dyn Vector,
2123 v_l: &'a dyn Vector,
2124 v_u: &'a dyn Vector,
2125 slack_x_l: &'a dyn Vector,
2126 slack_x_u: &'a dyn Vector,
2127 slack_s_l: &'a dyn Vector,
2128 slack_s_u: &'a dyn Vector,
2129 sigma_x: &'a dyn Vector,
2130 sigma_s: &'a dyn Vector,
2131}
2132
2133/// Helper trait extension on `IteratesVectorMut` for fresh zeroed
2134/// allocations matching the same shape — the shape lives implicitly
2135/// in the existing components' `dim()`.
2136trait FreshZeroed {
2137 fn fresh_zeroed(&self) -> IteratesVectorMut;
2138}
2139
2140impl FreshZeroed for IteratesVectorMut {
2141 fn fresh_zeroed(&self) -> IteratesVectorMut {
2142 IteratesVectorMut {
2143 x: self.x.make_new(),
2144 s: self.s.make_new(),
2145 y_c: self.y_c.make_new(),
2146 y_d: self.y_d.make_new(),
2147 z_l: self.z_l.make_new(),
2148 z_u: self.z_u.make_new(),
2149 v_l: self.v_l.make_new(),
2150 v_u: self.v_u.make_new(),
2151 }
2152 }
2153}
2154
2155/// Snapshot a mutable iterate into a frozen, shareable copy without
2156/// consuming it. Used to remember `res_in` when β ≠ 0.
2157fn snapshot_mut(m: &IteratesVectorMut) -> IteratesVector {
2158 let mut out = m.fresh_zeroed();
2159 out.x.copy(&*m.x);
2160 out.s.copy(&*m.s);
2161 out.y_c.copy(&*m.y_c);
2162 out.y_d.copy(&*m.y_d);
2163 out.z_l.copy(&*m.z_l);
2164 out.z_u.copy(&*m.z_u);
2165 out.v_l.copy(&*m.v_l);
2166 out.v_u.copy(&*m.v_u);
2167 out.freeze()
2168}
2169
2170/// Convert a frozen `IteratesVector` back to a mutable owned form.
2171/// Allocates fresh storage and copies; the iterative-refinement loop
2172/// re-freezes/thaws once per iteration, so a single per-component
2173/// copy is acceptable.
2174/// Expand the four bound-multiplier blocks of `sol` from the just-
2175/// computed primal-step blocks (`sol.x`, `sol.s`):
2176///
2177/// ```text
2178/// sol.z_L = S_xL⁻¹ · (rhs.z_L − z_L · (Px_L^T · sol.x))
2179/// sol.z_U = S_xU⁻¹ · (rhs.z_U + z_U · (Px_U^T · sol.x))
2180/// sol.v_L = S_sL⁻¹ · (rhs.v_L − v_L · (Pd_L^T · sol.s))
2181/// sol.v_U = S_sU⁻¹ · (rhs.v_U + v_U · (Pd_U^T · sol.s))
2182/// ```
2183///
2184/// Encoded via `SinvBlrmZMTdBr` with `α = ±1`. Mirrors the bound-
2185/// multiplier expansion at the bottom of upstream's
2186/// `IpPDFullSpaceSolver::SolveOnce`.
2187fn expand_bound_multipliers(
2188 b: &SolveBlocks<'_>,
2189 rhs: &IteratesVector,
2190 sol: &mut IteratesVectorMut,
2191) {
2192 b.px_l
2193 .sinv_blrm_zmt_dbr(-1.0, b.slack_x_l, &*rhs.z_l, b.z_l, &*sol.x, &mut *sol.z_l);
2194 b.px_u
2195 .sinv_blrm_zmt_dbr(1.0, b.slack_x_u, &*rhs.z_u, b.z_u, &*sol.x, &mut *sol.z_u);
2196 b.pd_l
2197 .sinv_blrm_zmt_dbr(-1.0, b.slack_s_l, &*rhs.v_l, b.v_l, &*sol.s, &mut *sol.v_l);
2198 b.pd_u
2199 .sinv_blrm_zmt_dbr(1.0, b.slack_s_u, &*rhs.v_u, b.v_u, &*sol.s, &mut *sol.v_u);
2200}
2201
2202/// Copy a `DenseVector`'s materialized values into `dst`. Used by
2203/// `solve_many_cached` to pack the aug-system RHS into a column of the
2204/// flat `aug_packed` buffer.
2205fn copy_vector_to_slice(src: &dyn Vector, dst: &mut [Number]) {
2206 if dst.is_empty() {
2207 return;
2208 }
2209 let dv = src
2210 .as_any()
2211 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
2212 .expect("solve_many_cached requires DenseVector blocks");
2213 if dv.is_homogeneous() {
2214 let v = dv.scalar();
2215 dst.iter_mut().for_each(|x| *x = v);
2216 } else {
2217 dst.copy_from_slice(dv.values());
2218 }
2219}
2220
2221/// Inverse of [`copy_vector_to_slice`]: write `src` into a
2222/// `DenseVector` in place.
2223fn set_vector_from_slice(dst: &mut dyn Vector, src: &[Number]) {
2224 if src.is_empty() {
2225 return;
2226 }
2227 let dv = dst
2228 .as_any_mut()
2229 .downcast_mut::<pounce_linalg::dense_vector::DenseVector>()
2230 .expect("solve_many_cached requires DenseVector blocks");
2231 dv.set_values(src);
2232}
2233
2234/// Downcast a `dyn Vector` block to its concrete `DenseVector` slice.
2235/// Returns `None` if the block is not a `DenseVector`, or if the block
2236/// is non-empty but stored as a homogeneous scalar (the
2237/// `solve_many_cached_flat` fast path needs a real slice for its
2238/// inline scatter loops; the closure-based fallback handles
2239/// homogeneous-on-non-empty correctly via `add_m_sinv_z` / `sinv_blrm_zmt_dbr`).
2240fn dense_slice_or_none(v: &dyn Vector, expected_dim: usize) -> Option<&[Number]> {
2241 if expected_dim == 0 {
2242 // An empty block doesn't need a slice — the scatter loops below
2243 // simply don't iterate when exp_pos is empty. Return an empty
2244 // slice so the caller can pass it through unconditionally.
2245 return Some(&[]);
2246 }
2247 let dv = v.as_any().downcast_ref::<DenseVector>()?;
2248 if dv.is_homogeneous() {
2249 return None;
2250 }
2251 Some(dv.values())
2252}
2253
2254/// Downcast a `dyn Matrix` to its concrete `ExpansionMatrix`'s
2255/// expanded-position index slice. Returns `None` if the matrix is not
2256/// an `ExpansionMatrix`.
2257fn exp_pos_or_none(m: &dyn Matrix) -> Option<&[Index]> {
2258 let em = m.as_any().downcast_ref::<ExpansionMatrix>()?;
2259 Some(em.expanded_pos_indices())
2260}
2261
2262/// Phase-1 inner kernel: `out[exp_pos[i]] += alpha · src[i] / denom[i]`.
2263/// Hot loop in `solve_many_cached_flat`. Specialised on `alpha = ±1`
2264/// to skip the multiply.
2265#[inline]
2266fn scatter_add_div(
2267 out: &mut [Number],
2268 exp_pos: &[Index],
2269 src: &[Number],
2270 denom: &[Number],
2271 alpha: Number,
2272) {
2273 if exp_pos.is_empty() {
2274 return;
2275 }
2276 debug_assert_eq!(src.len(), exp_pos.len());
2277 debug_assert_eq!(denom.len(), exp_pos.len());
2278 if alpha == 1.0 {
2279 for i in 0..exp_pos.len() {
2280 out[exp_pos[i] as usize] += src[i] / denom[i];
2281 }
2282 } else if alpha == -1.0 {
2283 for i in 0..exp_pos.len() {
2284 out[exp_pos[i] as usize] -= src[i] / denom[i];
2285 }
2286 } else {
2287 for i in 0..exp_pos.len() {
2288 out[exp_pos[i] as usize] += alpha * src[i] / denom[i];
2289 }
2290 }
2291}
2292
2293/// Phase-3 inner kernel: bound-multiplier expansion,
2294/// `out[i] = (r[i] + alpha · z[i] · sol[exp_pos[i]]) / s[i]`.
2295/// Mirrors `ExpansionMatrix::sinv_blrm_zmt_dbr_impl` (the non-
2296/// homogeneous specialisation) inlined against raw slices.
2297#[inline]
2298#[allow(clippy::too_many_arguments)]
2299fn expand_bound_mult(
2300 out: &mut [Number],
2301 r: &[Number],
2302 z: &[Number],
2303 sol: &[Number],
2304 exp_pos: &[Index],
2305 s: &[Number],
2306 alpha: Number,
2307) {
2308 if exp_pos.is_empty() {
2309 return;
2310 }
2311 debug_assert_eq!(out.len(), exp_pos.len());
2312 debug_assert_eq!(r.len(), exp_pos.len());
2313 debug_assert_eq!(z.len(), exp_pos.len());
2314 debug_assert_eq!(s.len(), exp_pos.len());
2315 if alpha == 1.0 {
2316 for i in 0..exp_pos.len() {
2317 out[i] = (r[i] + z[i] * sol[exp_pos[i] as usize]) / s[i];
2318 }
2319 } else if alpha == -1.0 {
2320 for i in 0..exp_pos.len() {
2321 out[i] = (r[i] - z[i] * sol[exp_pos[i] as usize]) / s[i];
2322 }
2323 } else {
2324 for i in 0..exp_pos.len() {
2325 out[i] = (r[i] + alpha * z[i] * sol[exp_pos[i] as usize]) / s[i];
2326 }
2327 }
2328}
2329
2330fn thaw(iv: IteratesVector) -> IteratesVectorMut {
2331 fn one(v: Rc<dyn Vector>) -> Box<dyn Vector> {
2332 let mut b = v.make_new();
2333 b.copy(&*v);
2334 b
2335 }
2336 IteratesVectorMut {
2337 x: one(iv.x),
2338 s: one(iv.s),
2339 y_c: one(iv.y_c),
2340 y_d: one(iv.y_d),
2341 z_l: one(iv.z_l),
2342 z_u: one(iv.z_u),
2343 v_l: one(iv.v_l),
2344 v_u: one(iv.v_u),
2345 }
2346}
2347
2348/// Internal placeholder used only inside [`PdFullSpaceSolver::wrap_aug_solver`]
2349/// to satisfy `std::mem::replace`'s requirement for a value of the same
2350/// type while the real boxed solver is being moved through the wrapper
2351/// closure. None of the trait methods are ever invoked.
2352struct NoopAugSolver;
2353
2354impl AugSystemSolver for NoopAugSolver {
2355 fn provides_inertia(&self) -> bool {
2356 unreachable!("NoopAugSolver is a transient placeholder")
2357 }
2358 fn number_of_neg_evals(&self) -> Index {
2359 unreachable!("NoopAugSolver is a transient placeholder")
2360 }
2361 fn increase_quality(&mut self) -> bool {
2362 unreachable!("NoopAugSolver is a transient placeholder")
2363 }
2364 fn last_solve_status(&self) -> ESymSolverStatus {
2365 unreachable!("NoopAugSolver is a transient placeholder")
2366 }
2367 fn solve(
2368 &mut self,
2369 _coeffs: &AugSysCoeffs<'_>,
2370 _rhs: &AugSysRhs<'_>,
2371 _sol: &mut AugSysSol<'_>,
2372 _check_neg_evals: bool,
2373 _num_neg_evals: Index,
2374 ) -> ESymSolverStatus {
2375 unreachable!("NoopAugSolver is a transient placeholder")
2376 }
2377}
2378
2379#[cfg(test)]
2380mod tests {
2381 use super::{deadline_exceeded, factor_overshoot_predicted};
2382 use crate::ipopt_data::IpoptData;
2383 use pounce_common::timing::Deadline;
2384 use std::cell::RefCell;
2385 use std::rc::Rc;
2386
2387 #[test]
2388 fn deadline_exceeded_is_false_without_a_deadline() {
2389 // Direct-driver / unit-test path: no deadline installed, so the KKT
2390 // solve never short-circuits and stays on the coarse timer gate.
2391 let data = Rc::new(RefCell::new(IpoptData::new()));
2392 assert!(!deadline_exceeded(&data));
2393 }
2394
2395 #[test]
2396 fn deadline_exceeded_is_false_when_budget_is_unbounded() {
2397 // The pounce "no budget" defaults (1e6 s each) must never trip inside
2398 // any realistic solve, so the fine-grained KKT check is a no-op.
2399 let data = Rc::new(RefCell::new(IpoptData::new()));
2400 data.borrow_mut().deadline = Some(Deadline::new(1e6, 1e6));
2401 assert!(!deadline_exceeded(&data));
2402 }
2403
2404 #[test]
2405 fn deadline_exceeded_true_once_the_budget_is_crossed() {
2406 // Zero wall budget: once any wall time elapses the KKT loops must see
2407 // the deadline and abort between factorizations (pounce#244). Busy-spin
2408 // until the monotonic clock advances past the start instant so the
2409 // assertion is not racing a coarse-clock zero-duration read — matching
2410 // the `Deadline` unit tests' pattern.
2411 let data = Rc::new(RefCell::new(IpoptData::new()));
2412 data.borrow_mut().deadline = Some(Deadline::new(0.0, 1e6));
2413 for _ in 0..10_000 {
2414 if deadline_exceeded(&data) {
2415 break;
2416 }
2417 std::hint::black_box(0u64);
2418 }
2419 assert!(deadline_exceeded(&data));
2420 }
2421
2422 #[test]
2423 fn predict_no_estimate_never_fires() {
2424 // Before any factorization has been measured (zero estimates) the
2425 // predictive guard must be a no-op, even with a fully-spent budget —
2426 // there is nothing to predict from. The reactive `deadline_exceeded`
2427 // check owns the already-crossed case.
2428 let deadline = Deadline::new(0.0, 0.0);
2429 assert!(!factor_overshoot_predicted(0.0, 0.0, &deadline));
2430 }
2431
2432 #[test]
2433 fn predict_small_factor_relative_to_budget_never_fires() {
2434 // A factorization that is a small slice of the budget must not trip
2435 // the guard even when little budget remains: an ordinary solve one
2436 // iteration from converging is never cut short. Budget 100 s wall,
2437 // observed factor 1 s (1% << the 50% gate).
2438 let deadline = Deadline::new(100.0, 100.0);
2439 assert!(!factor_overshoot_predicted(1.0, 1.0, &deadline));
2440 }
2441
2442 #[test]
2443 fn predict_large_factor_with_insufficient_remaining_fires() {
2444 // A factorization costing more than the whole (tiny) budget, with a
2445 // fresh deadline whose full budget still "remains", must fire: the
2446 // next factorization of that size cannot fit. max_wall budget 0.001 s,
2447 // observed factor 10 s (>= 50% of budget and > remaining).
2448 let deadline = Deadline::new(0.001, 1e6);
2449 // Let a hair of wall time pass so remaining_wall is unambiguously
2450 // below the 10 s estimate (it already is, but keep parity with the
2451 // other clock-sensitive tests).
2452 for _ in 0..1_000 {
2453 std::hint::black_box(0u64);
2454 }
2455 assert!(factor_overshoot_predicted(10.0, 0.0, &deadline));
2456 }
2457
2458 #[test]
2459 fn predict_large_factor_with_ample_remaining_does_not_fire() {
2460 // Even a factor that is a large fraction of the budget must be allowed
2461 // to start while the remaining budget can still cover it — the guard
2462 // bounds overshoot, it does not forbid using the budget. Budget 100 s,
2463 // observed worst factor 60 s (>= 50% gate) but ~100 s still remains.
2464 let deadline = Deadline::new(100.0, 100.0);
2465 assert!(!factor_overshoot_predicted(60.0, 60.0, &deadline));
2466 }
2467
2468 #[test]
2469 fn predict_fires_on_cpu_budget_independently() {
2470 // The CPU clock gates independently of wall: a spent CPU budget with
2471 // a large observed CPU factor cost fires even though the wall estimate
2472 // is zero. Tiny CPU budget, generous wall budget.
2473 let deadline = Deadline::new(1e6, 0.001);
2474 for _ in 0..1_000 {
2475 std::hint::black_box(0u64);
2476 }
2477 assert!(factor_overshoot_predicted(0.0, 10.0, &deadline));
2478 }
2479}