Skip to main content

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, &copy_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}