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::RefCell;
25use std::rc::Rc;
26
27pub struct PdFullSpaceSolver {
28    aug_solver: Box<dyn AugSystemSolver>,
29    perturb: Rc<RefCell<PdPerturbationHandler>>,
30    pub min_refinement_steps: Index,
31    pub max_refinement_steps: Index,
32    pub residual_ratio_max: Number,
33    pub residual_ratio_singular: Number,
34    pub residual_improvement_factor: Number,
35    /// Negative-curvature test tolerance (`neg_curv_test_tol_`). Zero
36    /// disables the heuristic; matches upstream's `RegisterOptions`
37    /// default. The non-zero branch is not exercised in v1.0.
38    pub neg_curv_test_tol: Number,
39    /// Mirrors `augsys_improved_`. Set by quality-escalation; cleared
40    /// each time the cached aug-system data changes.
41    augsys_improved: bool,
42    /// Mirrors upstream's `dummy_cache_` hit/miss. `false` ⇒ the next
43    /// `solve_once` is operating on a *new* augmented matrix and must
44    /// run the `ConsiderNewSystem` + perturbation-escalation path;
45    /// `true` ⇒ the matrix is identical to the previous successful
46    /// `solve_once`, so we can reuse `CurrentPerturbation` and just do
47    /// a single back-solve (the iterative-refinement / quality-retry
48    /// re-call path). Reset to `false` at the start of every outer
49    /// `solve()` invocation since each outer iter delivers a fresh
50    /// matrix from the algorithm's perspective.
51    matrix_considered: bool,
52    /// Tags of the 13 dependencies (W, J_c, J_d, z_L, z_U, v_L, v_U,
53    /// slack_x_L, slack_x_U, slack_s_L, slack_s_U, sigma_x, sigma_s)
54    /// at the time `matrix_considered` was last set to `true`. Mirrors
55    /// upstream's `dummy_cache_` keyed on the same 13 `TaggedObject`s
56    /// (`IpPDFullSpaceSolver.cpp:430-448`). Reset to `None` whenever
57    /// any tag changes.
58    last_dep_tags: Option<[Tag; 13]>,
59    last_status: Option<ESymSolverStatus>,
60    /// Worst-case wall / CPU seconds observed for a single augmented-
61    /// system *factorization* over this solver's lifetime (pounce#254).
62    /// `0` until the first factorization completes. Consumed by
63    /// [`Self::predict_factor_overshoot`] to refuse starting a
64    /// factorization the remaining time budget cannot cover — the
65    /// proactive complement to [`deadline_exceeded`]'s reactive abort.
66    /// Only the true factorization path (`aug_solver.solve`) updates
67    /// these; the cheap cached back-solve / iterative-refinement
68    /// re-solves are excluded so a refinement sweep never inflates the
69    /// estimate.
70    max_factor_wall: Number,
71    max_factor_cpu: Number,
72}
73
74/// Fraction of the total time budget a single factorization must reach
75/// before the predictive guard ([`PdFullSpaceSolver::predict_factor_overshoot`])
76/// will refuse to start another (pounce#254). Below this the guard is a
77/// no-op, so a solve whose factorizations are a small slice of the budget
78/// — and might be one iteration from converging — is never cut short; the
79/// guard engages only in the "one factorization is a large chunk of the
80/// whole budget" regime the issue is about.
81const FACTOR_OVERSHOOT_BUDGET_FRACTION: Number = 0.5;
82
83impl PdFullSpaceSolver {
84    pub fn new(
85        aug_solver: Box<dyn AugSystemSolver>,
86        perturb: Rc<RefCell<PdPerturbationHandler>>,
87    ) -> Self {
88        Self {
89            aug_solver,
90            perturb,
91            // Defaults from `IpPDFullSpaceSolver.cpp:RegisterOptions`.
92            min_refinement_steps: 1,
93            max_refinement_steps: 10,
94            residual_ratio_max: 1e-10,
95            residual_ratio_singular: 1e-5,
96            residual_improvement_factor: 0.999_999_999,
97            neg_curv_test_tol: 0.0,
98            augsys_improved: false,
99            matrix_considered: false,
100            last_dep_tags: None,
101            last_status: None,
102            max_factor_wall: 0.0,
103            max_factor_cpu: 0.0,
104        }
105    }
106
107    pub fn aug_solver(&self) -> &dyn AugSystemSolver {
108        &*self.aug_solver
109    }
110
111    pub fn aug_solver_mut(&mut self) -> &mut dyn AugSystemSolver {
112        &mut *self.aug_solver
113    }
114
115    /// Replace the underlying [`AugSystemSolver`] by passing the
116    /// existing one through the supplied wrapper closure. Used by the
117    /// restoration phase to decorate the inner `StdAugSystemSolver`
118    /// with `AugRestoSystemSolver` (which performs the 8-block →
119    /// 4-block Schur reduction before delegating).
120    pub fn wrap_aug_solver<F>(&mut self, wrap: F)
121    where
122        F: FnOnce(Box<dyn AugSystemSolver>) -> Box<dyn AugSystemSolver>,
123    {
124        // Take the inner aug solver out via a temporary noop, wrap it,
125        // and slot the wrapped one back in. The placeholder is never
126        // observed externally because we replace it before returning.
127        let noop: Box<dyn AugSystemSolver> = Box::new(NoopAugSolver);
128        let inner = std::mem::replace(&mut self.aug_solver, noop);
129        self.aug_solver = wrap(inner);
130    }
131
132    /// Solve the full PD system. `res = α · M⁻¹ · rhs + β · res_in`,
133    /// matching `IpPDFullSpaceSolver::Solve`. Returns `true` on
134    /// success. The iterate fields used to assemble the system are
135    /// pulled from `data` (`W`, `curr`) and `cq` (jacobians, slacks,
136    /// sigmas).
137    #[allow(clippy::too_many_arguments)]
138    pub fn solve(
139        &mut self,
140        data: &IpoptDataHandle,
141        cq: &IpoptCqHandle,
142        nlp: &Rc<RefCell<dyn IpoptNlp>>,
143        alpha: Number,
144        beta: Number,
145        rhs: &IteratesVector,
146        res: &mut IteratesVectorMut,
147        allow_inexact: bool,
148        improve_solution: bool,
149    ) -> bool {
150        debug_assert!(!allow_inexact || !improve_solution);
151        debug_assert!(!improve_solution || beta == 0.0);
152
153        // Snapshot the incoming `res` if β ≠ 0 (we add it back at the
154        // end via `res = α · sol + β · copy_res`).
155        let copy_res: Option<IteratesVector> = if beta != 0.0 {
156            Some(snapshot_mut(res))
157        } else {
158            None
159        };
160
161        // Pull all blocks once. None of these change during the
162        // refinement / escalation loop, so collecting them here
163        // matches upstream's structure (lines 168-189).
164        let w = data
165            .borrow()
166            .w
167            .clone()
168            .unwrap_or_else(|| panic!("PdFullSpaceSolver::solve: IpoptData::w is unset"));
169        let cq_ref = cq.borrow();
170        let j_c = cq_ref.curr_jac_c();
171        let j_d = cq_ref.curr_jac_d();
172        let sigma_x = cq_ref.curr_sigma_x();
173        let sigma_s = cq_ref.curr_sigma_s();
174        let slack_x_l = cq_ref.curr_slack_x_l();
175        let slack_x_u = cq_ref.curr_slack_x_u();
176        let slack_s_l = cq_ref.curr_slack_s_l();
177        let slack_s_u = cq_ref.curr_slack_s_u();
178        drop(cq_ref);
179
180        let nlp_ref = nlp.borrow();
181        let px_l = nlp_ref.px_l();
182        let px_u = nlp_ref.px_u();
183        let pd_l = nlp_ref.pd_l();
184        let pd_u = nlp_ref.pd_u();
185        drop(nlp_ref);
186
187        let curr = {
188            let d = data.borrow();
189            d.curr
190                .clone()
191                .unwrap_or_else(|| panic!("PdFullSpaceSolver::solve: IpoptData::curr is unset"))
192        };
193
194        let blocks = SolveBlocks {
195            w: &*w,
196            j_c: &*j_c,
197            j_d: &*j_d,
198            px_l: &*px_l,
199            px_u: &*px_u,
200            pd_l: &*pd_l,
201            pd_u: &*pd_u,
202            z_l: &*curr.z_l,
203            z_u: &*curr.z_u,
204            v_l: &*curr.v_l,
205            v_u: &*curr.v_u,
206            slack_x_l: &*slack_x_l,
207            slack_x_u: &*slack_x_u,
208            slack_s_l: &*slack_s_l,
209            slack_s_u: &*slack_s_u,
210            sigma_x: &*sigma_x,
211            sigma_s: &*sigma_s,
212        };
213
214        // Mirror upstream's `dummy_cache_` lookup
215        // (`IpPDFullSpaceSolver.cpp:430-450`): if all 13 dependency tags
216        // are unchanged since the last successful `solve()`, the matrix
217        // is "uptodate" — keep `matrix_considered = true` so the
218        // perturbation handler is NOT re-entered, and reuse the
219        // existing `augsys_improved_` state. On a cache miss, reset
220        // both flags.
221        let cur_tags: [Tag; 13] = [
222            blocks.w.as_tagged().get_tag(),
223            blocks.j_c.as_tagged().get_tag(),
224            blocks.j_d.as_tagged().get_tag(),
225            blocks.z_l.as_tagged().get_tag(),
226            blocks.z_u.as_tagged().get_tag(),
227            blocks.v_l.as_tagged().get_tag(),
228            blocks.v_u.as_tagged().get_tag(),
229            blocks.slack_x_l.as_tagged().get_tag(),
230            blocks.slack_x_u.as_tagged().get_tag(),
231            blocks.slack_s_l.as_tagged().get_tag(),
232            blocks.slack_s_u.as_tagged().get_tag(),
233            blocks.sigma_x.as_tagged().get_tag(),
234            blocks.sigma_s.as_tagged().get_tag(),
235        ];
236        let uptodate = self.last_dep_tags.map_or(false, |prev| prev == cur_tags);
237        if !uptodate {
238            if std::env::var_os("POUNCE_DBG_PD_TAGS").is_some() {
239                if let Some(prev) = self.last_dep_tags {
240                    let names = [
241                        "w",
242                        "j_c",
243                        "j_d",
244                        "z_l",
245                        "z_u",
246                        "v_l",
247                        "v_u",
248                        "slack_x_l",
249                        "slack_x_u",
250                        "slack_s_l",
251                        "slack_s_u",
252                        "sigma_x",
253                        "sigma_s",
254                    ];
255                    let mut diffs = String::new();
256                    for i in 0..13 {
257                        if prev[i] != cur_tags[i] {
258                            diffs.push_str(&format!(
259                                " {}({:?}→{:?})",
260                                names[i], prev[i], cur_tags[i]
261                            ));
262                        }
263                    }
264                    tracing::debug!(target: "pounce::linsol", "[PN_PD_TAGS] cache_miss diffs:{}", diffs);
265                } else {
266                    tracing::debug!(target: "pounce::linsol", "[PN_PD_TAGS] cache_miss first_solve");
267                }
268            }
269            self.last_dep_tags = Some(cur_tags);
270            self.matrix_considered = false;
271            self.augsys_improved = false;
272        }
273
274        let mut done = false;
275        let mut resolve_with_better_quality = false;
276        let mut pretend_singular = false;
277        let mut pretend_singular_last_time = false;
278        let mut improve = improve_solution;
279
280        while !done {
281            // pounce#244: bail between major KKT steps when the shared time
282            // budget is crossed (see `deadline_exceeded`). Returning `false`
283            // routes through the caller's post-KKT deadline check, which
284            // terminates the solve with the time-limit status rather than
285            // treating the abort as a step-computation failure.
286            //
287            // pounce#254: also bail *before* a factorization the remaining
288            // budget cannot afford (see `predict_factor_overshoot`), so a
289            // large single factorization does not overshoot before the next
290            // reactive check catches it.
291            if deadline_exceeded(data) || self.predict_factor_overshoot(data) {
292                return false;
293            }
294            let solve_ok = if improve {
295                true
296            } else {
297                let ok = self.solve_once(
298                    data,
299                    &blocks,
300                    1.0,
301                    0.0,
302                    rhs,
303                    res,
304                    resolve_with_better_quality,
305                    pretend_singular,
306                );
307                resolve_with_better_quality = false;
308                pretend_singular = false;
309                ok
310            };
311            improve = false;
312
313            if !solve_ok {
314                return false;
315            }
316
317            if allow_inexact {
318                break;
319            }
320
321            // Initial residual.
322            let mut resid = res.fresh_zeroed();
323            self.compute_residuals(data, &blocks, rhs, res, &mut resid);
324            let mut residual_ratio = self.compute_residual_ratio(rhs, res, &resid);
325            let mut residual_ratio_old = residual_ratio;
326
327            let mut num_iter_ref: Index = 0;
328            let mut quit_refinement = false;
329
330            while !quit_refinement
331                && (num_iter_ref < self.min_refinement_steps
332                    || residual_ratio > self.residual_ratio_max)
333            {
334                // pounce#244: each refinement step drives another back-solve
335                // (and may refactor via the escalation path in `solve_once`);
336                // check the budget before spending one. pounce#254: the
337                // predictive guard additionally refuses a step whose worst-
338                // case factorization would not fit the remaining budget.
339                if deadline_exceeded(data) || self.predict_factor_overshoot(data) {
340                    return false;
341                }
342                let frozen_resid = resid.freeze();
343                let solve_ok = self.solve_once(
344                    data,
345                    &blocks,
346                    -1.0,
347                    1.0,
348                    &frozen_resid,
349                    res,
350                    resolve_with_better_quality,
351                    false,
352                );
353                resid = thaw(frozen_resid);
354                if !solve_ok {
355                    return false;
356                }
357
358                self.compute_residuals(data, &blocks, rhs, res, &mut resid);
359                residual_ratio = self.compute_residual_ratio(rhs, res, &resid);
360                num_iter_ref += 1;
361
362                if residual_ratio > self.residual_ratio_max
363                    && num_iter_ref > self.min_refinement_steps
364                    && (num_iter_ref > self.max_refinement_steps
365                        || residual_ratio > self.residual_improvement_factor * residual_ratio_old)
366                {
367                    quit_refinement = true;
368                    resolve_with_better_quality = false;
369
370                    if !pretend_singular_last_time {
371                        if !self.augsys_improved {
372                            self.augsys_improved = self.aug_solver.increase_quality();
373                            if self.augsys_improved {
374                                data.borrow_mut().append_info_string("q");
375                                resolve_with_better_quality = true;
376                            } else {
377                                pretend_singular = true;
378                            }
379                        } else {
380                            pretend_singular = true;
381                        }
382                        pretend_singular_last_time = pretend_singular;
383                        if pretend_singular {
384                            if residual_ratio < self.residual_ratio_singular {
385                                pretend_singular = false;
386                                data.borrow_mut().append_info_string("S");
387                            } else {
388                                data.borrow_mut().append_info_string("s");
389                            }
390                        }
391                    } else {
392                        pretend_singular = false;
393                    }
394                }
395
396                residual_ratio_old = residual_ratio;
397            }
398
399            done = !resolve_with_better_quality && !pretend_singular;
400        }
401
402        // Final assembly: res = α · res + β · copy_res.
403        if alpha != 0.0 {
404            res.scal(alpha);
405        }
406        if let Some(copy_res) = copy_res {
407            res.axpy(beta, &copy_res);
408        }
409
410        self.last_status = Some(ESymSolverStatus::Success);
411        true
412    }
413
414    /// Predictive time-budget guard for the KKT factorization (pounce#254).
415    ///
416    /// [`deadline_exceeded`] is *reactive*: it aborts only after the shared
417    /// budget has already been crossed. Because a single feral factorization
418    /// is uninterruptible (feral 0.14 exposes no in-factor cancel hook — see
419    /// `dev-notes/feral-factor-interrupt.md`), that reactive check still lets
420    /// one whole factorization overshoot — it passes while still under budget,
421    /// the factorization runs, and only the *next* check trips. #245/#246
422    /// accepted that "bounded to one factorization" overshoot for the
423    /// between-op gaps.
424    ///
425    /// This guard tightens it *proactively*: once a factorization has been
426    /// observed (via [`Self::max_factor_wall`] / [`Self::max_factor_cpu`]) to
427    /// cost at least [`FACTOR_OVERSHOOT_BUDGET_FRACTION`] of the whole budget,
428    /// refuse to *start* another one whose worst observed cost the remaining
429    /// budget cannot cover. On the "large factor, several-factor budget"
430    /// regime (e.g. discopt's ~10 s per-node budgets over multi-second
431    /// factorizations) this bounds the overshoot before the doomed final
432    /// factorization begins, rather than running it to completion first.
433    ///
434    /// It deliberately does nothing until such a large factorization has been
435    /// seen, so an ordinary solve whose factorizations are a small slice of
436    /// the budget — and may be one iteration from converging — is never cut
437    /// short. Returns `false` when no deadline is installed.
438    ///
439    /// Residual gap (#254): a *single* factorization already larger than the
440    /// entire budget — e.g. the first one on a 5 k-variable NLP — cannot be
441    /// bounded here. No estimate exists before it runs, and it cannot be
442    /// interrupted mid-flight. Closing that needs the feral-side cooperative
443    /// cancellation hook specified in `dev-notes/feral-factor-interrupt.md`.
444    fn predict_factor_overshoot(&self, data: &IpoptDataHandle) -> bool {
445        let d = data.borrow();
446        match d.deadline.as_ref() {
447            Some(deadline) => {
448                factor_overshoot_predicted(self.max_factor_wall, self.max_factor_cpu, deadline)
449            }
450            None => false,
451        }
452    }
453
454    /// Batched back-substitution against the cached KKT factor for
455    /// `n_rhs` right-hand sides, sharing one
456    /// `pounce_linsol::TSymLinearSolver::multi_solve` call with
457    /// `nrhs > 1`. Each column k pulls its RHS through `write_rhs(k,
458    /// &mut iv)` and emits its solution through `write_lhs(k, &iv)` —
459    /// closures over the caller's flat / strided buffer keep the
460    /// rhs/sol `IteratesVectorMut` scratch out of the API surface.
461    ///
462    /// Returns:
463    /// - `Some(true)`  — fast path executed against the cached factor.
464    /// - `Some(false)` — fast path was attempted but the linsol
465    ///   reported a back-solve failure.
466    /// - `None`        — fast path not taken. Either the matrix tags
467    ///   differ from the last successful [`Self::solve`] (cache miss),
468    ///   the matrix has not been considered yet, or the underlying
469    ///   `AugSystemSolver` does not implement
470    ///   [`AugSystemSolver::try_resolve_many_flat`]. The caller should
471    ///   fall back to looping [`Self::solve`].
472    ///
473    /// Used by `pounce_sensitivity::PdSensBacksolver::solve_many` for
474    /// the JaxProblem `jacrev` backward path, where every cotangent
475    /// re-solves against the same converged factor (pounce#77 follow-up).
476    pub fn solve_many_cached<F1, F2>(
477        &mut self,
478        data: &IpoptDataHandle,
479        cq: &IpoptCqHandle,
480        nlp: &Rc<RefCell<dyn IpoptNlp>>,
481        n_rhs: usize,
482        mut write_rhs: F1,
483        mut write_lhs: F2,
484    ) -> Option<bool>
485    where
486        F1: FnMut(usize, &mut IteratesVectorMut),
487        F2: FnMut(usize, &IteratesVectorMut),
488    {
489        if n_rhs == 0 {
490            return Some(true);
491        }
492
493        // Pull all blocks (same shape as `solve()`).
494        let w = data.borrow().w.clone()?;
495        let cq_ref = cq.borrow();
496        let j_c = cq_ref.curr_jac_c();
497        let j_d = cq_ref.curr_jac_d();
498        let sigma_x = cq_ref.curr_sigma_x();
499        let sigma_s = cq_ref.curr_sigma_s();
500        let slack_x_l = cq_ref.curr_slack_x_l();
501        let slack_x_u = cq_ref.curr_slack_x_u();
502        let slack_s_l = cq_ref.curr_slack_s_l();
503        let slack_s_u = cq_ref.curr_slack_s_u();
504        drop(cq_ref);
505
506        let nlp_ref = nlp.borrow();
507        let px_l = nlp_ref.px_l();
508        let px_u = nlp_ref.px_u();
509        let pd_l = nlp_ref.pd_l();
510        let pd_u = nlp_ref.pd_u();
511        drop(nlp_ref);
512
513        let curr = data.borrow().curr.clone()?;
514
515        let blocks = SolveBlocks {
516            w: &*w,
517            j_c: &*j_c,
518            j_d: &*j_d,
519            px_l: &*px_l,
520            px_u: &*px_u,
521            pd_l: &*pd_l,
522            pd_u: &*pd_u,
523            z_l: &*curr.z_l,
524            z_u: &*curr.z_u,
525            v_l: &*curr.v_l,
526            v_u: &*curr.v_u,
527            slack_x_l: &*slack_x_l,
528            slack_x_u: &*slack_x_u,
529            slack_s_l: &*slack_s_l,
530            slack_s_u: &*slack_s_u,
531            sigma_x: &*sigma_x,
532            sigma_s: &*sigma_s,
533        };
534
535        // Cache-tag check (same 13 tags as `solve()`). If the matrix
536        // has changed since the last successful solve, or we never
537        // marked it as considered, bail and let the caller take the
538        // per-RHS path.
539        let cur_tags: [Tag; 13] = [
540            blocks.w.as_tagged().get_tag(),
541            blocks.j_c.as_tagged().get_tag(),
542            blocks.j_d.as_tagged().get_tag(),
543            blocks.z_l.as_tagged().get_tag(),
544            blocks.z_u.as_tagged().get_tag(),
545            blocks.v_l.as_tagged().get_tag(),
546            blocks.v_u.as_tagged().get_tag(),
547            blocks.slack_x_l.as_tagged().get_tag(),
548            blocks.slack_x_u.as_tagged().get_tag(),
549            blocks.slack_s_l.as_tagged().get_tag(),
550            blocks.slack_s_u.as_tagged().get_tag(),
551            blocks.sigma_x.as_tagged().get_tag(),
552            blocks.sigma_s.as_tagged().get_tag(),
553        ];
554        if !self.matrix_considered || !self.last_dep_tags.map_or(false, |prev| prev == cur_tags) {
555            return None;
556        }
557
558        // Coeffs reuse the perturbation stashed by the most recent
559        // `solve_once`. `current_perturbation()` returns the same
560        // values that solve_once wrote into `data.perturbations`.
561        let d = self.perturb.borrow().current_perturbation();
562        let coeffs = AugSysCoeffs {
563            w: Some(blocks.w),
564            w_factor: 1.0,
565            d_x: Some(blocks.sigma_x),
566            delta_x: d.delta_x,
567            d_s: Some(blocks.sigma_s),
568            delta_s: d.delta_s,
569            j_c: blocks.j_c,
570            d_c: None,
571            delta_c: d.delta_c,
572            j_d: blocks.j_d,
573            d_d: None,
574            delta_d: d.delta_d,
575        };
576
577        let n_x = curr.x.dim() as usize;
578        let n_s = curr.s.dim() as usize;
579        let n_y_c = curr.y_c.dim() as usize;
580        let n_y_d = curr.y_d.dim() as usize;
581        let aug_dim = n_x + n_s + n_y_c + n_y_d;
582
583        // Scratch — one set of Box allocs, reused across every column.
584        let mut rhs_iv = curr.make_new_zeroed();
585        let mut sol_iv = curr.make_new_zeroed();
586        let mut aug_rhs_x_box: Box<dyn Vector> = curr.x.make_new();
587        let mut aug_rhs_s_box: Box<dyn Vector> = curr.s.make_new();
588
589        // Column-major `(aug_dim, n_rhs)` packed buffer — single
590        // allocation that the linsol's `multi_solve` writes solutions
591        // back into in place.
592        let mut aug_packed = vec![0.0 as Number; aug_dim * n_rhs];
593
594        // Phase 1: populate aug_packed column-by-column. The aug-system
595        // RHS is `[aug_rhs_x | aug_rhs_s | rhs.y_c | rhs.y_d]`, where
596        //   aug_rhs_x = rhs.x + Px_L·S_xL⁻¹·z_L − Px_U·S_xU⁻¹·z_U
597        //   aug_rhs_s = rhs.s + Pd_L·S_sL⁻¹·v_L − Pd_U·S_sU⁻¹·v_U
598        // matching `solve_once`'s aug-RHS build.
599        for k in 0..n_rhs {
600            write_rhs(k, &mut rhs_iv);
601
602            aug_rhs_x_box.copy(&*rhs_iv.x);
603            blocks
604                .px_l
605                .add_m_sinv_z(1.0, blocks.slack_x_l, &*rhs_iv.z_l, &mut *aug_rhs_x_box);
606            blocks
607                .px_u
608                .add_m_sinv_z(-1.0, blocks.slack_x_u, &*rhs_iv.z_u, &mut *aug_rhs_x_box);
609
610            aug_rhs_s_box.copy(&*rhs_iv.s);
611            blocks
612                .pd_l
613                .add_m_sinv_z(1.0, blocks.slack_s_l, &*rhs_iv.v_l, &mut *aug_rhs_s_box);
614            blocks
615                .pd_u
616                .add_m_sinv_z(-1.0, blocks.slack_s_u, &*rhs_iv.v_u, &mut *aug_rhs_s_box);
617
618            let col = &mut aug_packed[k * aug_dim..(k + 1) * aug_dim];
619            copy_vector_to_slice(&*aug_rhs_x_box, &mut col[..n_x]);
620            copy_vector_to_slice(&*aug_rhs_s_box, &mut col[n_x..n_x + n_s]);
621            copy_vector_to_slice(&*rhs_iv.y_c, &mut col[n_x + n_s..n_x + n_s + n_y_c]);
622            copy_vector_to_slice(&*rhs_iv.y_d, &mut col[n_x + n_s + n_y_c..]);
623        }
624
625        // Phase 2: single batched back-substitution.
626        let status = self
627            .aug_solver
628            .try_resolve_many_flat(&coeffs, &mut aug_packed, n_rhs)?;
629        if status != ESymSolverStatus::Success {
630            self.last_status = Some(status);
631            return Some(false);
632        }
633        self.last_status = Some(status);
634
635        // Phase 3: unpack each column into `sol_iv`, run the bound-
636        // multiplier expansion, hand the result to the caller. We have
637        // to re-invoke `write_rhs` because expand_bound_multipliers
638        // reads `rhs.z_l/z_u/v_l/v_u` and we re-used `rhs_iv` across
639        // all columns in phase 1.
640        for k in 0..n_rhs {
641            write_rhs(k, &mut rhs_iv);
642
643            let col = &aug_packed[k * aug_dim..(k + 1) * aug_dim];
644            set_vector_from_slice(&mut *sol_iv.x, &col[..n_x]);
645            set_vector_from_slice(&mut *sol_iv.s, &col[n_x..n_x + n_s]);
646            set_vector_from_slice(&mut *sol_iv.y_c, &col[n_x + n_s..n_x + n_s + n_y_c]);
647            set_vector_from_slice(&mut *sol_iv.y_d, &col[n_x + n_s + n_y_c..]);
648
649            // Inline expand_bound_multipliers — that helper takes
650            // `&IteratesVector` (Rc-backed) but our `rhs_iv` is
651            // `IteratesVectorMut` (Box-backed). The four
652            // `sinv_blrm_zmt_dbr` calls work on `&dyn Vector` either
653            // way.
654            blocks.px_l.sinv_blrm_zmt_dbr(
655                -1.0,
656                blocks.slack_x_l,
657                &*rhs_iv.z_l,
658                blocks.z_l,
659                &*sol_iv.x,
660                &mut *sol_iv.z_l,
661            );
662            blocks.px_u.sinv_blrm_zmt_dbr(
663                1.0,
664                blocks.slack_x_u,
665                &*rhs_iv.z_u,
666                blocks.z_u,
667                &*sol_iv.x,
668                &mut *sol_iv.z_u,
669            );
670            blocks.pd_l.sinv_blrm_zmt_dbr(
671                -1.0,
672                blocks.slack_s_l,
673                &*rhs_iv.v_l,
674                blocks.v_l,
675                &*sol_iv.s,
676                &mut *sol_iv.v_l,
677            );
678            blocks.pd_u.sinv_blrm_zmt_dbr(
679                1.0,
680                blocks.slack_s_u,
681                &*rhs_iv.v_u,
682                blocks.v_u,
683                &*sol_iv.s,
684                &mut *sol_iv.v_u,
685            );
686
687            write_lhs(k, &sol_iv);
688        }
689
690        Some(true)
691    }
692
693    /// Flat-slice cached-factor multi-RHS path. Same cache-check
694    /// semantics as [`Self::solve_many_cached`] but operates on
695    /// row-major `(n_rhs, total)` flat buffers without going through
696    /// `IteratesVectorMut` or any `dyn Vector` / `dyn Matrix` dispatch
697    /// in the per-RHS inner loops — the eight source blocks
698    /// (`slack_{x,s}_{l,u}`, `z_{l,u}`, `v_{l,u}`) get downcast to
699    /// `DenseVector` once at the top, the four bound-expansion matrices
700    /// (`px_l`, `px_u`, `pd_l`, `pd_u`) get downcast to
701    /// `ExpansionMatrix` once, and Phase 1 / Phase 3 then run as raw
702    /// `&[Number]` / `&mut [Number]` arithmetic on the flat buffers.
703    ///
704    /// `total` is the sum of the eight `block_dims` entries (in the
705    /// same `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order that
706    /// `IteratesVector` uses); `rhs_flat.len() == lhs_flat.len() ==
707    /// n_rhs * total`.
708    ///
709    /// Returns `None` (caller should fall back to
710    /// [`Self::solve_many_cached`]) when:
711    /// - the cache check fails (matrix tags differ),
712    /// - any block source vector is not a `DenseVector` or is
713    ///   homogeneous (uniform-scalar) on a non-empty block,
714    /// - any bound-expansion matrix is not an `ExpansionMatrix`,
715    /// - the underlying `AugSystemSolver` doesn't implement
716    ///   [`AugSystemSolver::try_resolve_many_flat`].
717    ///
718    /// Returns `Some(true)` on success, `Some(false)` on linsol back-
719    /// solve failure.
720    ///
721    /// Used by `pounce_sensitivity::PdSensBacksolver::solve_many` as
722    /// the fastest tier of the JaxProblem `jacrev` backward path
723    /// (pounce#77 follow-up).
724    #[allow(clippy::too_many_arguments)]
725    pub fn solve_many_cached_flat(
726        &mut self,
727        data: &IpoptDataHandle,
728        cq: &IpoptCqHandle,
729        nlp: &Rc<RefCell<dyn IpoptNlp>>,
730        n_rhs: usize,
731        rhs_flat: &[Number],
732        lhs_flat: &mut [Number],
733        block_dims: [usize; 8],
734    ) -> Option<bool> {
735        if n_rhs == 0 {
736            return Some(true);
737        }
738        let total: usize = block_dims.iter().sum();
739        if rhs_flat.len() != n_rhs * total || lhs_flat.len() != n_rhs * total {
740            return Some(false);
741        }
742        let mut off = [0usize; 9];
743        for i in 0..8 {
744            off[i + 1] = off[i] + block_dims[i];
745        }
746        let n_x = block_dims[0];
747        let n_s = block_dims[1];
748        let n_y_c = block_dims[2];
749        let n_y_d = block_dims[3];
750
751        // Pull all blocks (same shape as `solve()`).
752        let w = data.borrow().w.clone()?;
753        let cq_ref = cq.borrow();
754        let j_c = cq_ref.curr_jac_c();
755        let j_d = cq_ref.curr_jac_d();
756        let sigma_x = cq_ref.curr_sigma_x();
757        let sigma_s = cq_ref.curr_sigma_s();
758        let slack_x_l = cq_ref.curr_slack_x_l();
759        let slack_x_u = cq_ref.curr_slack_x_u();
760        let slack_s_l = cq_ref.curr_slack_s_l();
761        let slack_s_u = cq_ref.curr_slack_s_u();
762        drop(cq_ref);
763
764        let nlp_ref = nlp.borrow();
765        let px_l = nlp_ref.px_l();
766        let px_u = nlp_ref.px_u();
767        let pd_l = nlp_ref.pd_l();
768        let pd_u = nlp_ref.pd_u();
769        drop(nlp_ref);
770
771        let curr = data.borrow().curr.clone()?;
772
773        // Cache-tag check (same 13 tags as `solve()`).
774        let cur_tags: [Tag; 13] = [
775            w.as_tagged().get_tag(),
776            j_c.as_tagged().get_tag(),
777            j_d.as_tagged().get_tag(),
778            curr.z_l.as_tagged().get_tag(),
779            curr.z_u.as_tagged().get_tag(),
780            curr.v_l.as_tagged().get_tag(),
781            curr.v_u.as_tagged().get_tag(),
782            slack_x_l.as_tagged().get_tag(),
783            slack_x_u.as_tagged().get_tag(),
784            slack_s_l.as_tagged().get_tag(),
785            slack_s_u.as_tagged().get_tag(),
786            sigma_x.as_tagged().get_tag(),
787            sigma_s.as_tagged().get_tag(),
788        ];
789        if !self.matrix_considered || !self.last_dep_tags.map_or(false, |prev| prev == cur_tags) {
790            return None;
791        }
792
793        // Concrete downcasts. Bail to closure-based fallback on any
794        // type mismatch (homogeneous-on-non-empty included — the math
795        // below assumes a real `[Number]` slice for slack / z / v).
796        let slack_x_l_d = dense_slice_or_none(&*slack_x_l, block_dims[4])?;
797        let slack_x_u_d = dense_slice_or_none(&*slack_x_u, block_dims[5])?;
798        let slack_s_l_d = dense_slice_or_none(&*slack_s_l, block_dims[6])?;
799        let slack_s_u_d = dense_slice_or_none(&*slack_s_u, block_dims[7])?;
800        let blocks_z_l_d = dense_slice_or_none(&*curr.z_l, block_dims[4])?;
801        let blocks_z_u_d = dense_slice_or_none(&*curr.z_u, block_dims[5])?;
802        let blocks_v_l_d = dense_slice_or_none(&*curr.v_l, block_dims[6])?;
803        let blocks_v_u_d = dense_slice_or_none(&*curr.v_u, block_dims[7])?;
804
805        let exp_x_l = exp_pos_or_none(&*px_l)?;
806        let exp_x_u = exp_pos_or_none(&*px_u)?;
807        let exp_s_l = exp_pos_or_none(&*pd_l)?;
808        let exp_s_u = exp_pos_or_none(&*pd_u)?;
809
810        // Coeffs reuse the perturbation stashed by the most recent
811        // `solve_once`.
812        let d = self.perturb.borrow().current_perturbation();
813        let coeffs = AugSysCoeffs {
814            w: Some(&*w),
815            w_factor: 1.0,
816            d_x: Some(&*sigma_x),
817            delta_x: d.delta_x,
818            d_s: Some(&*sigma_s),
819            delta_s: d.delta_s,
820            j_c: &*j_c,
821            d_c: None,
822            delta_c: d.delta_c,
823            j_d: &*j_d,
824            d_d: None,
825            delta_d: d.delta_d,
826        };
827
828        let aug_dim = n_x + n_s + n_y_c + n_y_d;
829        // Column-major `(aug_dim, n_rhs)` packed buffer, single alloc.
830        let mut aug_packed = vec![0.0 as Number; aug_dim * n_rhs];
831
832        // ---------------- Phase 1 ----------------
833        // For each k: build the aug-system RHS into column k of
834        // aug_packed, all inline against raw slices.
835        for k in 0..n_rhs {
836            let r_base = k * total;
837            let rhs_x = &rhs_flat[r_base + off[0]..r_base + off[1]];
838            let rhs_s = &rhs_flat[r_base + off[1]..r_base + off[2]];
839            let rhs_y_c = &rhs_flat[r_base + off[2]..r_base + off[3]];
840            let rhs_y_d = &rhs_flat[r_base + off[3]..r_base + off[4]];
841            let rhs_z_l = &rhs_flat[r_base + off[4]..r_base + off[5]];
842            let rhs_z_u = &rhs_flat[r_base + off[5]..r_base + off[6]];
843            let rhs_v_l = &rhs_flat[r_base + off[6]..r_base + off[7]];
844            let rhs_v_u = &rhs_flat[r_base + off[7]..r_base + off[8]];
845
846            let aug_col = &mut aug_packed[k * aug_dim..(k + 1) * aug_dim];
847            let (aug_x, rest) = aug_col.split_at_mut(n_x);
848            let (aug_s, rest) = rest.split_at_mut(n_s);
849            let (aug_y_c, aug_y_d) = rest.split_at_mut(n_y_c);
850
851            // aug_x = rhs_x + Px_L · S_xL⁻¹ · z_L − Px_U · S_xU⁻¹ · z_U
852            aug_x.copy_from_slice(rhs_x);
853            scatter_add_div(aug_x, exp_x_l, rhs_z_l, slack_x_l_d, 1.0);
854            scatter_add_div(aug_x, exp_x_u, rhs_z_u, slack_x_u_d, -1.0);
855            // aug_s = rhs_s + Pd_L · S_sL⁻¹ · v_L − Pd_U · S_sU⁻¹ · v_U
856            aug_s.copy_from_slice(rhs_s);
857            scatter_add_div(aug_s, exp_s_l, rhs_v_l, slack_s_l_d, 1.0);
858            scatter_add_div(aug_s, exp_s_u, rhs_v_u, slack_s_u_d, -1.0);
859            aug_y_c.copy_from_slice(rhs_y_c);
860            aug_y_d.copy_from_slice(rhs_y_d);
861        }
862
863        // ---------------- Phase 2 ----------------
864        let status = self
865            .aug_solver
866            .try_resolve_many_flat(&coeffs, &mut aug_packed, n_rhs)?;
867        if status != ESymSolverStatus::Success {
868            self.last_status = Some(status);
869            return Some(false);
870        }
871        self.last_status = Some(status);
872
873        // ---------------- Phase 3 ----------------
874        // For each k: copy sol_x/s/y_c/y_d into lhs_flat, then build
875        // sol_z_l/z_u/v_l/v_u from the bound-multiplier expansion.
876        for k in 0..n_rhs {
877            let r_base = k * total;
878            let rhs_z_l = &rhs_flat[r_base + off[4]..r_base + off[5]];
879            let rhs_z_u = &rhs_flat[r_base + off[5]..r_base + off[6]];
880            let rhs_v_l = &rhs_flat[r_base + off[6]..r_base + off[7]];
881            let rhs_v_u = &rhs_flat[r_base + off[7]..r_base + off[8]];
882
883            let aug_col = &aug_packed[k * aug_dim..(k + 1) * aug_dim];
884            let sol_x = &aug_col[..n_x];
885            let sol_s = &aug_col[n_x..n_x + n_s];
886            let sol_y_c = &aug_col[n_x + n_s..n_x + n_s + n_y_c];
887            let sol_y_d = &aug_col[n_x + n_s + n_y_c..];
888
889            let l_base = k * total;
890            let (lhs_xs, lhs_zv) = lhs_flat[l_base..l_base + total].split_at_mut(off[4]);
891            let (lhs_x, rest) = lhs_xs.split_at_mut(n_x);
892            let (lhs_s, rest) = rest.split_at_mut(n_s);
893            let (lhs_y_c, lhs_y_d) = rest.split_at_mut(n_y_c);
894            lhs_x.copy_from_slice(sol_x);
895            lhs_s.copy_from_slice(sol_s);
896            lhs_y_c.copy_from_slice(sol_y_c);
897            lhs_y_d.copy_from_slice(sol_y_d);
898
899            let (lhs_z_l, rest) = lhs_zv.split_at_mut(block_dims[4]);
900            let (lhs_z_u, rest) = rest.split_at_mut(block_dims[5]);
901            let (lhs_v_l, lhs_v_u) = rest.split_at_mut(block_dims[6]);
902
903            // sol_z_l[i] = (rhs_z_l[i] − z_l[i] · sol_x[exp_x_l[i]]) / slack_x_l[i]
904            expand_bound_mult(
905                lhs_z_l,
906                rhs_z_l,
907                blocks_z_l_d,
908                sol_x,
909                exp_x_l,
910                slack_x_l_d,
911                -1.0,
912            );
913            // sol_z_u[i] = (rhs_z_u[i] + z_u[i] · sol_x[exp_x_u[i]]) / slack_x_u[i]
914            expand_bound_mult(
915                lhs_z_u,
916                rhs_z_u,
917                blocks_z_u_d,
918                sol_x,
919                exp_x_u,
920                slack_x_u_d,
921                1.0,
922            );
923            expand_bound_mult(
924                lhs_v_l,
925                rhs_v_l,
926                blocks_v_l_d,
927                sol_s,
928                exp_s_l,
929                slack_s_l_d,
930                -1.0,
931            );
932            expand_bound_mult(
933                lhs_v_u,
934                rhs_v_u,
935                blocks_v_u_d,
936                sol_s,
937                exp_s_u,
938                slack_s_u_d,
939                1.0,
940            );
941        }
942
943        Some(true)
944    }
945
946    /// One outer back-solve through the augmented system, including
947    /// the `Px_L · S_xL⁻¹ · z_L` lifts on the RHS and the bound-
948    /// multiplier expansion on the solution side. Mirrors
949    /// `IpPDFullSpaceSolver::SolveOnce`.
950    #[allow(clippy::too_many_arguments)]
951    fn solve_once(
952        &mut self,
953        data: &IpoptDataHandle,
954        b: &SolveBlocks<'_>,
955        alpha: Number,
956        beta: Number,
957        rhs: &IteratesVector,
958        res: &mut IteratesVectorMut,
959        _resolve_with_better_quality: bool,
960        mut pretend_singular: bool,
961    ) -> bool {
962        // Build aug-system primal RHS:
963        //   augRhs_x = rhs.x + Px_L · S_xL⁻¹ · z_L − Px_U · S_xU⁻¹ · z_U
964        let mut aug_rhs_x = rhs.x.make_new_copy();
965        b.px_l
966            .add_m_sinv_z(1.0, b.slack_x_l, &*rhs.z_l, &mut *aug_rhs_x);
967        b.px_u
968            .add_m_sinv_z(-1.0, b.slack_x_u, &*rhs.z_u, &mut *aug_rhs_x);
969
970        let mut aug_rhs_s = rhs.s.make_new_copy();
971        b.pd_l
972            .add_m_sinv_z(1.0, b.slack_s_l, &*rhs.v_l, &mut *aug_rhs_s);
973        b.pd_u
974            .add_m_sinv_z(-1.0, b.slack_s_u, &*rhs.v_u, &mut *aug_rhs_s);
975
976        // Solution slot for the aug-system (dx, ds, dy_c, dy_d).
977        let mut sol = res.fresh_zeroed();
978
979        // Number of negative eigenvalues we expect.
980        let num_neg_evals = rhs.y_c.dim() + rhs.y_d.dim();
981
982        let curr_mu = data.borrow().curr_mu;
983
984        // Upstream's `IpPDFullSpaceSolver::SolveOnce` (cpp:457-482)
985        // splits on `(uptodate && !pretend_singular)`: if the matrix is
986        // unchanged since the last `SolveOnce` and we are not faking a
987        // singularity, reuse the existing perturbation, do a single
988        // back-solve with `check_inertia=false`, and return. Iterative
989        // refinement and the post-`IncreaseQuality` retry both land
990        // here. Calling `ConsiderNewSystem` again on a same-matrix
991        // re-solve would corrupt the perturbation handler's
992        // `delta_x_last` bookkeeping.
993        if self.matrix_considered && !pretend_singular {
994            let d = self.perturb.borrow().current_perturbation();
995            let coeffs = AugSysCoeffs {
996                w: Some(b.w),
997                w_factor: 1.0,
998                d_x: Some(b.sigma_x),
999                delta_x: d.delta_x,
1000                d_s: Some(b.sigma_s),
1001                delta_s: d.delta_s,
1002                j_c: b.j_c,
1003                d_c: None,
1004                delta_c: d.delta_c,
1005                j_d: b.j_d,
1006                d_d: None,
1007                delta_d: d.delta_d,
1008            };
1009            let aug_rhs = AugSysRhs {
1010                rhs_x: &*aug_rhs_x,
1011                rhs_s: &*aug_rhs_s,
1012                rhs_c: &*rhs.y_c,
1013                rhs_d: &*rhs.y_d,
1014            };
1015            let mut aug_sol = AugSysSol {
1016                sol_x: &mut *sol.x,
1017                sol_s: &mut *sol.s,
1018                sol_c: &mut *sol.y_c,
1019                sol_d: &mut *sol.y_d,
1020            };
1021            // Same matrix, same perturbations, inertia already known —
1022            // use the cached factor and avoid the per-call refactor
1023            // that otherwise dominates MA57 wall-time on long iter-ref
1024            // loops (cont5_2_4_l drops 97s → ~30s).
1025            let retval = self.aug_solver.resolve(&coeffs, &aug_rhs, &mut aug_sol);
1026            if retval != ESymSolverStatus::Success {
1027                return false;
1028            }
1029            // Stash perturbations on data, expand bound multipliers,
1030            // assemble final res, and return — skipping the
1031            // escalation loop entirely (matches upstream's `if(uptodate
1032            // && !pretend_singular)` branch in IpPDFullSpaceSolver.cpp).
1033            {
1034                let mut dm = data.borrow_mut();
1035                dm.perturbations.delta_x = d.delta_x;
1036                dm.perturbations.delta_s = d.delta_s;
1037                dm.perturbations.delta_c = d.delta_c;
1038                dm.perturbations.delta_d = d.delta_d;
1039            }
1040            expand_bound_multipliers(b, rhs, &mut sol);
1041            let frozen_sol = sol.freeze();
1042            res.add_one_vector(alpha, &frozen_sol, beta);
1043            return true;
1044        }
1045
1046        let mut deltas = self
1047            .perturb
1048            .borrow_mut()
1049            .consider_new_system(curr_mu, Some(&IpoptDataSink(data)));
1050        let Some(mut d) = deltas.take() else {
1051            return false;
1052        };
1053
1054        let mut count = 0_i32;
1055        let mut retval;
1056        loop {
1057            // pounce#244: the body of this loop is a full KKT factorization
1058            // — the escalation path retries with a larger perturbation until
1059            // the augmented system has the right inertia, and on a hard,
1060            // ill-conditioned system that can be many refactorizations under
1061            // one outer iteration. Abort between factorizations when the
1062            // shared deadline is crossed so the solve cannot overshoot the
1063            // budget by that whole sweep. `data`'s deadline is the caller's
1064            // global budget; `false` unwinds to the outer loop's post-KKT
1065            // time-limit check. A deadline already crossed on entry returns
1066            // before the first factorization; otherwise overshoot is bounded
1067            // to one.
1068            //
1069            // pounce#254: `deadline_exceeded` is reactive — it fires only
1070            // once a factorization has already run the clock past the budget.
1071            // Because a single feral factorization is uninterruptible (feral
1072            // 0.14 exposes no in-factor cancel hook; see
1073            // `dev-notes/feral-factor-interrupt.md`), that still lets one
1074            // whole factorization overshoot. `predict_factor_overshoot` is the
1075            // proactive complement: once a factorization has been observed to
1076            // cost a large fraction of the budget, refuse to start another the
1077            // remaining budget cannot cover, so the doomed factorization never
1078            // begins.
1079            if deadline_exceeded(data) || self.predict_factor_overshoot(data) {
1080                return false;
1081            }
1082            if pretend_singular {
1083                retval = ESymSolverStatus::Singular;
1084                pretend_singular = false;
1085            } else {
1086                count += 1;
1087                let check_inertia = self.neg_curv_test_tol <= 0.0;
1088                let coeffs = AugSysCoeffs {
1089                    w: Some(b.w),
1090                    w_factor: 1.0,
1091                    d_x: Some(b.sigma_x),
1092                    delta_x: d.delta_x,
1093                    d_s: Some(b.sigma_s),
1094                    delta_s: d.delta_s,
1095                    j_c: b.j_c,
1096                    d_c: None,
1097                    delta_c: d.delta_c,
1098                    j_d: b.j_d,
1099                    d_d: None,
1100                    delta_d: d.delta_d,
1101                };
1102                let aug_rhs = AugSysRhs {
1103                    rhs_x: &*aug_rhs_x,
1104                    rhs_s: &*aug_rhs_s,
1105                    rhs_c: &*rhs.y_c,
1106                    rhs_d: &*rhs.y_d,
1107                };
1108                let mut aug_sol = AugSysSol {
1109                    sol_x: &mut *sol.x,
1110                    sol_s: &mut *sol.s,
1111                    sol_c: &mut *sol.y_c,
1112                    sol_d: &mut *sol.y_d,
1113                };
1114                // pounce#254: time this factorization and remember the worst
1115                // single-factorization cost seen so far, which feeds the
1116                // predictive guard above. Only the true factorization path is
1117                // measured — the cheap cached back-solve / iterative-refinement
1118                // re-solves never widen the estimate.
1119                let t_wall = wallclock_time();
1120                let t_cpu = cpu_time();
1121                retval = self.aug_solver.solve(
1122                    &coeffs,
1123                    &aug_rhs,
1124                    &mut aug_sol,
1125                    check_inertia,
1126                    num_neg_evals,
1127                );
1128                let d_wall = wallclock_time() - t_wall;
1129                let d_cpu = cpu_time() - t_cpu;
1130                if d_wall > self.max_factor_wall {
1131                    self.max_factor_wall = d_wall;
1132                }
1133                if d_cpu > self.max_factor_cpu {
1134                    self.max_factor_cpu = d_cpu;
1135                }
1136            }
1137
1138            if retval == ESymSolverStatus::FatalError {
1139                return false;
1140            }
1141
1142            if retval == ESymSolverStatus::Singular && (rhs.y_c.dim() + rhs.y_d.dim() > 0) {
1143                let curr_mu = data.borrow().curr_mu;
1144                let next = self
1145                    .perturb
1146                    .borrow_mut()
1147                    .perturb_for_singular(curr_mu, Some(&IpoptDataSink(data)));
1148                let Some(nd) = next else { return false };
1149                d = nd;
1150            } else if retval == ESymSolverStatus::WrongInertia
1151                && self.aug_solver.number_of_neg_evals() < num_neg_evals
1152            {
1153                let mut assume_singular = true;
1154                if !self.augsys_improved {
1155                    self.augsys_improved = self.aug_solver.increase_quality();
1156                    if self.augsys_improved {
1157                        data.borrow_mut().append_info_string("q");
1158                        assume_singular = false;
1159                    }
1160                }
1161                if assume_singular {
1162                    let curr_mu = data.borrow().curr_mu;
1163                    let next = self
1164                        .perturb
1165                        .borrow_mut()
1166                        .perturb_for_singular(curr_mu, Some(&IpoptDataSink(data)));
1167                    let Some(nd) = next else { return false };
1168                    d = nd;
1169                    data.borrow_mut().append_info_string("a");
1170                }
1171            } else if retval == ESymSolverStatus::WrongInertia
1172                || retval == ESymSolverStatus::Singular
1173            {
1174                let curr_mu = data.borrow().curr_mu;
1175                let next = self
1176                    .perturb
1177                    .borrow_mut()
1178                    .perturb_for_wrong_inertia(curr_mu, Some(&IpoptDataSink(data)));
1179                let Some(nd) = next else { return false };
1180                d = nd;
1181            }
1182
1183            if retval == ESymSolverStatus::Success {
1184                break;
1185            }
1186        }
1187        let _ = count;
1188
1189        // Stash the perturbation on data — upstream calls
1190        // `IpData().setPDPert(...)` here.
1191        {
1192            let mut dm = data.borrow_mut();
1193            dm.perturbations.delta_x = d.delta_x;
1194            dm.perturbations.delta_s = d.delta_s;
1195            dm.perturbations.delta_c = d.delta_c;
1196            dm.perturbations.delta_d = d.delta_d;
1197        }
1198
1199        // Mark this matrix as "considered" so subsequent `solve_once`
1200        // re-calls within the same outer `solve()` (iterative refinement
1201        // / quality retry) take the single-solve path above.
1202        self.matrix_considered = true;
1203
1204        expand_bound_multipliers(b, rhs, &mut sol);
1205
1206        // res = α · sol + β · res
1207        let frozen_sol = sol.freeze();
1208        res.add_one_vector(alpha, &frozen_sol, beta);
1209        true
1210    }
1211
1212    /// `resid = M · res − rhs` per `ComputeResiduals`. Skips terms
1213    /// whose perturbation is exactly zero.
1214    fn compute_residuals(
1215        &self,
1216        _data: &IpoptDataHandle,
1217        b: &SolveBlocks<'_>,
1218        rhs: &IteratesVector,
1219        res: &IteratesVectorMut,
1220        resid: &mut IteratesVectorMut,
1221    ) {
1222        let d = self.perturb.borrow().current_perturbation();
1223
1224        // x: W·res.x + J_c^T·res.y_c + J_d^T·res.y_d
1225        //    − Px_L·res.z_L + Px_U·res.z_U + δ_x·res.x − rhs.x
1226        b.w.mult_vector(1.0, &*res.x, 0.0, &mut *resid.x);
1227        b.j_c.trans_mult_vector(1.0, &*res.y_c, 1.0, &mut *resid.x);
1228        b.j_d.trans_mult_vector(1.0, &*res.y_d, 1.0, &mut *resid.x);
1229        b.px_l.mult_vector(-1.0, &*res.z_l, 1.0, &mut *resid.x);
1230        b.px_u.mult_vector(1.0, &*res.z_u, 1.0, &mut *resid.x);
1231        // resid.x += δ_x·res.x − rhs.x
1232        resid
1233            .x
1234            .add_two_vectors(d.delta_x, &*res.x, -1.0, &*rhs.x, 1.0);
1235
1236        // s: Pd_U·res.v_U − Pd_L·res.v_L − res.y_d − rhs.s + δ_s·res.s
1237        b.pd_u.mult_vector(1.0, &*res.v_u, 0.0, &mut *resid.s);
1238        b.pd_l.mult_vector(-1.0, &*res.v_l, 1.0, &mut *resid.s);
1239        resid.s.add_two_vectors(-1.0, &*res.y_d, -1.0, &*rhs.s, 1.0);
1240        if d.delta_s != 0.0 {
1241            resid.s.axpy(d.delta_s, &*res.s);
1242        }
1243
1244        // c: J_c·res.x − δ_c·res.y_c − rhs.y_c
1245        b.j_c.mult_vector(1.0, &*res.x, 0.0, &mut *resid.y_c);
1246        resid
1247            .y_c
1248            .add_two_vectors(-d.delta_c, &*res.y_c, -1.0, &*rhs.y_c, 1.0);
1249
1250        // d: J_d·res.x − res.s − rhs.y_d − δ_d·res.y_d
1251        b.j_d.mult_vector(1.0, &*res.x, 0.0, &mut *resid.y_d);
1252        resid
1253            .y_d
1254            .add_two_vectors(-1.0, &*res.s, -1.0, &*rhs.y_d, 1.0);
1255        if d.delta_d != 0.0 {
1256            resid.y_d.axpy(-d.delta_d, &*res.y_d);
1257        }
1258
1259        // zL: res.z_L · slack_x_L + (Px_L^T·res.x) · z_L − rhs.z_L
1260        resid.z_l.copy(&*res.z_l);
1261        resid.z_l.element_wise_multiply(b.slack_x_l);
1262        let mut tmp_zl = b.z_l.make_new();
1263        b.px_l.trans_mult_vector(1.0, &*res.x, 0.0, &mut *tmp_zl);
1264        tmp_zl.element_wise_multiply(b.z_l);
1265        resid
1266            .z_l
1267            .add_two_vectors(1.0, &*tmp_zl, -1.0, &*rhs.z_l, 1.0);
1268
1269        // zU: res.z_U · slack_x_U − (Px_U^T·res.x) · z_U − rhs.z_U
1270        resid.z_u.copy(&*res.z_u);
1271        resid.z_u.element_wise_multiply(b.slack_x_u);
1272        let mut tmp_zu = b.z_u.make_new();
1273        b.px_u.trans_mult_vector(1.0, &*res.x, 0.0, &mut *tmp_zu);
1274        tmp_zu.element_wise_multiply(b.z_u);
1275        resid
1276            .z_u
1277            .add_two_vectors(-1.0, &*tmp_zu, -1.0, &*rhs.z_u, 1.0);
1278
1279        // vL: res.v_L · slack_s_L + (Pd_L^T·res.s) · v_L − rhs.v_L
1280        resid.v_l.copy(&*res.v_l);
1281        resid.v_l.element_wise_multiply(b.slack_s_l);
1282        let mut tmp_vl = b.v_l.make_new();
1283        b.pd_l.trans_mult_vector(1.0, &*res.s, 0.0, &mut *tmp_vl);
1284        tmp_vl.element_wise_multiply(b.v_l);
1285        resid
1286            .v_l
1287            .add_two_vectors(1.0, &*tmp_vl, -1.0, &*rhs.v_l, 1.0);
1288
1289        // vU: res.v_U · slack_s_U − (Pd_U^T·res.s) · v_U − rhs.v_U
1290        resid.v_u.copy(&*res.v_u);
1291        resid.v_u.element_wise_multiply(b.slack_s_u);
1292        let mut tmp_vu = b.v_u.make_new();
1293        b.pd_u.trans_mult_vector(1.0, &*res.s, 0.0, &mut *tmp_vu);
1294        tmp_vu.element_wise_multiply(b.v_u);
1295        resid
1296            .v_u
1297            .add_two_vectors(-1.0, &*tmp_vu, -1.0, &*rhs.v_u, 1.0);
1298    }
1299
1300    /// `nrm_resid / (min(nrm_res, max_cond·nrm_rhs) + nrm_rhs)`, with
1301    /// `max_cond = 1e6`. Mirrors `ComputeResidualRatio`.
1302    fn compute_residual_ratio(
1303        &self,
1304        rhs: &IteratesVector,
1305        res: &IteratesVectorMut,
1306        resid: &IteratesVectorMut,
1307    ) -> Number {
1308        let nrm_rhs = rhs.amax();
1309        let nrm_res = res.amax();
1310        let nrm_resid = resid.amax();
1311        if nrm_rhs + nrm_res == 0.0 {
1312            nrm_resid
1313        } else {
1314            let max_cond = 1e6;
1315            nrm_resid / (nrm_res.min(max_cond * nrm_rhs) + nrm_rhs)
1316        }
1317    }
1318}
1319
1320impl PdSystemSolver for PdFullSpaceSolver {
1321    fn solve_status(&self) -> ESymSolverStatus {
1322        self.last_status.unwrap_or(ESymSolverStatus::FatalError)
1323    }
1324}
1325
1326/// Cooperative time-budget check for the KKT solve (pounce#244).
1327///
1328/// Reads the shared per-solve [`Deadline`](pounce_common::timing::Deadline)
1329/// off [`IpoptData`](crate::ipopt_data::IpoptData) — the same instance the
1330/// outer loop, the line search, and the restoration inner IPM consult —
1331/// and reports whether either the wall or CPU budget has been crossed.
1332/// [`PdFullSpaceSolver::solve`] / [`PdFullSpaceSolver::solve_once`] call it
1333/// between their major factorization steps so an over-budget solve aborts
1334/// promptly instead of running a whole inertia-correction /
1335/// iterative-refinement sweep to completion.
1336///
1337/// A single outer iteration of a large, ill-conditioned NLP is dominated
1338/// by the KKT factorization, and the inertia-correction loop can refactor
1339/// several times before the augmented system has the right inertia. #242
1340/// only checked the deadline *after* the search direction was fully
1341/// computed, so that whole multi-factorization sweep overshot the requested
1342/// budget (the reported 2 s → 12.7 s single-NLP probe, "unchanged by #242").
1343/// Checking here bounds the overshoot to roughly one factorization.
1344///
1345/// Returns `false` when no deadline is installed (the direct-driver /
1346/// unit-test paths), leaving those on the coarse `overall_alg`-timer gate
1347/// in [`crate::conv_check`]. Aborting with `false` is safe: `solve` only
1348/// promotes the computed `delta` onto `IpoptData` on a `true` return, so a
1349/// deadline abort leaves `data.curr` / `data.delta` untouched, and the
1350/// caller's post-KKT deadline check then terminates with the time-limit
1351/// status (returning the last accepted iterate) rather than treating the
1352/// abort as a step-computation failure that would enter restoration.
1353fn deadline_exceeded(data: &IpoptDataHandle) -> bool {
1354    data.borrow()
1355        .deadline
1356        .as_ref()
1357        .is_some_and(|d| d.exceeded().is_some())
1358}
1359
1360/// Core decision of [`PdFullSpaceSolver::predict_factor_overshoot`], split
1361/// out as a free function so the guard logic is unit-testable against a
1362/// hand-built [`Deadline`] and synthetic factorization-cost estimates
1363/// without standing up a whole solver (pounce#254).
1364///
1365/// Fires when the worst single factorization observed so far is both a
1366/// large-enough fraction of the whole budget (`>= FACTOR_OVERSHOOT_BUDGET_FRACTION`)
1367/// *and* larger than the budget still remaining on either the wall or the
1368/// CPU clock — i.e. starting one more factorization of that size would
1369/// overshoot. The fraction gate keeps the guard dormant on ordinary solves
1370/// whose factorizations are a small slice of the budget. Zero estimates
1371/// (no factorization measured yet) never fire.
1372fn factor_overshoot_predicted(
1373    max_factor_wall: Number,
1374    max_factor_cpu: Number,
1375    deadline: &pounce_common::timing::Deadline,
1376) -> bool {
1377    let wall_gate = max_factor_wall > 0.0
1378        && max_factor_wall >= FACTOR_OVERSHOOT_BUDGET_FRACTION * deadline.max_wall()
1379        && deadline.remaining_wall() < max_factor_wall;
1380    let cpu_gate = max_factor_cpu > 0.0
1381        && max_factor_cpu >= FACTOR_OVERSHOOT_BUDGET_FRACTION * deadline.max_cpu()
1382        && deadline.remaining_cpu() < max_factor_cpu;
1383    wall_gate || cpu_gate
1384}
1385
1386/// Bag of borrowed blocks used by both `solve_once` and
1387/// `compute_residuals` — keeps argument lists tractable.
1388struct SolveBlocks<'a> {
1389    w: &'a dyn SymMatrix,
1390    j_c: &'a dyn Matrix,
1391    j_d: &'a dyn Matrix,
1392    px_l: &'a dyn Matrix,
1393    px_u: &'a dyn Matrix,
1394    pd_l: &'a dyn Matrix,
1395    pd_u: &'a dyn Matrix,
1396    z_l: &'a dyn Vector,
1397    z_u: &'a dyn Vector,
1398    v_l: &'a dyn Vector,
1399    v_u: &'a dyn Vector,
1400    slack_x_l: &'a dyn Vector,
1401    slack_x_u: &'a dyn Vector,
1402    slack_s_l: &'a dyn Vector,
1403    slack_s_u: &'a dyn Vector,
1404    sigma_x: &'a dyn Vector,
1405    sigma_s: &'a dyn Vector,
1406}
1407
1408/// Helper trait extension on `IteratesVectorMut` for fresh zeroed
1409/// allocations matching the same shape — the shape lives implicitly
1410/// in the existing components' `dim()`.
1411trait FreshZeroed {
1412    fn fresh_zeroed(&self) -> IteratesVectorMut;
1413}
1414
1415impl FreshZeroed for IteratesVectorMut {
1416    fn fresh_zeroed(&self) -> IteratesVectorMut {
1417        IteratesVectorMut {
1418            x: self.x.make_new(),
1419            s: self.s.make_new(),
1420            y_c: self.y_c.make_new(),
1421            y_d: self.y_d.make_new(),
1422            z_l: self.z_l.make_new(),
1423            z_u: self.z_u.make_new(),
1424            v_l: self.v_l.make_new(),
1425            v_u: self.v_u.make_new(),
1426        }
1427    }
1428}
1429
1430/// Snapshot a mutable iterate into a frozen, shareable copy without
1431/// consuming it. Used to remember `res_in` when β ≠ 0.
1432fn snapshot_mut(m: &IteratesVectorMut) -> IteratesVector {
1433    let mut out = m.fresh_zeroed();
1434    out.x.copy(&*m.x);
1435    out.s.copy(&*m.s);
1436    out.y_c.copy(&*m.y_c);
1437    out.y_d.copy(&*m.y_d);
1438    out.z_l.copy(&*m.z_l);
1439    out.z_u.copy(&*m.z_u);
1440    out.v_l.copy(&*m.v_l);
1441    out.v_u.copy(&*m.v_u);
1442    out.freeze()
1443}
1444
1445/// Convert a frozen `IteratesVector` back to a mutable owned form.
1446/// Allocates fresh storage and copies; the iterative-refinement loop
1447/// re-freezes/thaws once per iteration, so a single per-component
1448/// copy is acceptable.
1449/// Expand the four bound-multiplier blocks of `sol` from the just-
1450/// computed primal-step blocks (`sol.x`, `sol.s`):
1451///
1452/// ```text
1453/// sol.z_L = S_xL⁻¹ · (rhs.z_L − z_L · (Px_L^T · sol.x))
1454/// sol.z_U = S_xU⁻¹ · (rhs.z_U + z_U · (Px_U^T · sol.x))
1455/// sol.v_L = S_sL⁻¹ · (rhs.v_L − v_L · (Pd_L^T · sol.s))
1456/// sol.v_U = S_sU⁻¹ · (rhs.v_U + v_U · (Pd_U^T · sol.s))
1457/// ```
1458///
1459/// Encoded via `SinvBlrmZMTdBr` with `α = ±1`. Mirrors the bound-
1460/// multiplier expansion at the bottom of upstream's
1461/// `IpPDFullSpaceSolver::SolveOnce`.
1462fn expand_bound_multipliers(
1463    b: &SolveBlocks<'_>,
1464    rhs: &IteratesVector,
1465    sol: &mut IteratesVectorMut,
1466) {
1467    b.px_l
1468        .sinv_blrm_zmt_dbr(-1.0, b.slack_x_l, &*rhs.z_l, b.z_l, &*sol.x, &mut *sol.z_l);
1469    b.px_u
1470        .sinv_blrm_zmt_dbr(1.0, b.slack_x_u, &*rhs.z_u, b.z_u, &*sol.x, &mut *sol.z_u);
1471    b.pd_l
1472        .sinv_blrm_zmt_dbr(-1.0, b.slack_s_l, &*rhs.v_l, b.v_l, &*sol.s, &mut *sol.v_l);
1473    b.pd_u
1474        .sinv_blrm_zmt_dbr(1.0, b.slack_s_u, &*rhs.v_u, b.v_u, &*sol.s, &mut *sol.v_u);
1475}
1476
1477/// Copy a `DenseVector`'s materialized values into `dst`. Used by
1478/// `solve_many_cached` to pack the aug-system RHS into a column of the
1479/// flat `aug_packed` buffer.
1480fn copy_vector_to_slice(src: &dyn Vector, dst: &mut [Number]) {
1481    if dst.is_empty() {
1482        return;
1483    }
1484    let dv = src
1485        .as_any()
1486        .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
1487        .expect("solve_many_cached requires DenseVector blocks");
1488    if dv.is_homogeneous() {
1489        let v = dv.scalar();
1490        dst.iter_mut().for_each(|x| *x = v);
1491    } else {
1492        dst.copy_from_slice(dv.values());
1493    }
1494}
1495
1496/// Inverse of [`copy_vector_to_slice`]: write `src` into a
1497/// `DenseVector` in place.
1498fn set_vector_from_slice(dst: &mut dyn Vector, src: &[Number]) {
1499    if src.is_empty() {
1500        return;
1501    }
1502    let dv = dst
1503        .as_any_mut()
1504        .downcast_mut::<pounce_linalg::dense_vector::DenseVector>()
1505        .expect("solve_many_cached requires DenseVector blocks");
1506    dv.set_values(src);
1507}
1508
1509/// Downcast a `dyn Vector` block to its concrete `DenseVector` slice.
1510/// Returns `None` if the block is not a `DenseVector`, or if the block
1511/// is non-empty but stored as a homogeneous scalar (the
1512/// `solve_many_cached_flat` fast path needs a real slice for its
1513/// inline scatter loops; the closure-based fallback handles
1514/// homogeneous-on-non-empty correctly via `add_m_sinv_z` / `sinv_blrm_zmt_dbr`).
1515fn dense_slice_or_none(v: &dyn Vector, expected_dim: usize) -> Option<&[Number]> {
1516    if expected_dim == 0 {
1517        // An empty block doesn't need a slice — the scatter loops below
1518        // simply don't iterate when exp_pos is empty. Return an empty
1519        // slice so the caller can pass it through unconditionally.
1520        return Some(&[]);
1521    }
1522    let dv = v.as_any().downcast_ref::<DenseVector>()?;
1523    if dv.is_homogeneous() {
1524        return None;
1525    }
1526    Some(dv.values())
1527}
1528
1529/// Downcast a `dyn Matrix` to its concrete `ExpansionMatrix`'s
1530/// expanded-position index slice. Returns `None` if the matrix is not
1531/// an `ExpansionMatrix`.
1532fn exp_pos_or_none(m: &dyn Matrix) -> Option<&[Index]> {
1533    let em = m.as_any().downcast_ref::<ExpansionMatrix>()?;
1534    Some(em.expanded_pos_indices())
1535}
1536
1537/// Phase-1 inner kernel: `out[exp_pos[i]] += alpha · src[i] / denom[i]`.
1538/// Hot loop in `solve_many_cached_flat`. Specialised on `alpha = ±1`
1539/// to skip the multiply.
1540#[inline]
1541fn scatter_add_div(
1542    out: &mut [Number],
1543    exp_pos: &[Index],
1544    src: &[Number],
1545    denom: &[Number],
1546    alpha: Number,
1547) {
1548    if exp_pos.is_empty() {
1549        return;
1550    }
1551    debug_assert_eq!(src.len(), exp_pos.len());
1552    debug_assert_eq!(denom.len(), exp_pos.len());
1553    if alpha == 1.0 {
1554        for i in 0..exp_pos.len() {
1555            out[exp_pos[i] as usize] += src[i] / denom[i];
1556        }
1557    } else if alpha == -1.0 {
1558        for i in 0..exp_pos.len() {
1559            out[exp_pos[i] as usize] -= src[i] / denom[i];
1560        }
1561    } else {
1562        for i in 0..exp_pos.len() {
1563            out[exp_pos[i] as usize] += alpha * src[i] / denom[i];
1564        }
1565    }
1566}
1567
1568/// Phase-3 inner kernel: bound-multiplier expansion,
1569/// `out[i] = (r[i] + alpha · z[i] · sol[exp_pos[i]]) / s[i]`.
1570/// Mirrors `ExpansionMatrix::sinv_blrm_zmt_dbr_impl` (the non-
1571/// homogeneous specialisation) inlined against raw slices.
1572#[inline]
1573#[allow(clippy::too_many_arguments)]
1574fn expand_bound_mult(
1575    out: &mut [Number],
1576    r: &[Number],
1577    z: &[Number],
1578    sol: &[Number],
1579    exp_pos: &[Index],
1580    s: &[Number],
1581    alpha: Number,
1582) {
1583    if exp_pos.is_empty() {
1584        return;
1585    }
1586    debug_assert_eq!(out.len(), exp_pos.len());
1587    debug_assert_eq!(r.len(), exp_pos.len());
1588    debug_assert_eq!(z.len(), exp_pos.len());
1589    debug_assert_eq!(s.len(), exp_pos.len());
1590    if alpha == 1.0 {
1591        for i in 0..exp_pos.len() {
1592            out[i] = (r[i] + z[i] * sol[exp_pos[i] as usize]) / s[i];
1593        }
1594    } else if alpha == -1.0 {
1595        for i in 0..exp_pos.len() {
1596            out[i] = (r[i] - z[i] * sol[exp_pos[i] as usize]) / s[i];
1597        }
1598    } else {
1599        for i in 0..exp_pos.len() {
1600            out[i] = (r[i] + alpha * z[i] * sol[exp_pos[i] as usize]) / s[i];
1601        }
1602    }
1603}
1604
1605fn thaw(iv: IteratesVector) -> IteratesVectorMut {
1606    fn one(v: Rc<dyn Vector>) -> Box<dyn Vector> {
1607        let mut b = v.make_new();
1608        b.copy(&*v);
1609        b
1610    }
1611    IteratesVectorMut {
1612        x: one(iv.x),
1613        s: one(iv.s),
1614        y_c: one(iv.y_c),
1615        y_d: one(iv.y_d),
1616        z_l: one(iv.z_l),
1617        z_u: one(iv.z_u),
1618        v_l: one(iv.v_l),
1619        v_u: one(iv.v_u),
1620    }
1621}
1622
1623/// Internal placeholder used only inside [`PdFullSpaceSolver::wrap_aug_solver`]
1624/// to satisfy `std::mem::replace`'s requirement for a value of the same
1625/// type while the real boxed solver is being moved through the wrapper
1626/// closure. None of the trait methods are ever invoked.
1627struct NoopAugSolver;
1628
1629impl AugSystemSolver for NoopAugSolver {
1630    fn provides_inertia(&self) -> bool {
1631        unreachable!("NoopAugSolver is a transient placeholder")
1632    }
1633    fn number_of_neg_evals(&self) -> Index {
1634        unreachable!("NoopAugSolver is a transient placeholder")
1635    }
1636    fn increase_quality(&mut self) -> bool {
1637        unreachable!("NoopAugSolver is a transient placeholder")
1638    }
1639    fn last_solve_status(&self) -> ESymSolverStatus {
1640        unreachable!("NoopAugSolver is a transient placeholder")
1641    }
1642    fn solve(
1643        &mut self,
1644        _coeffs: &AugSysCoeffs<'_>,
1645        _rhs: &AugSysRhs<'_>,
1646        _sol: &mut AugSysSol<'_>,
1647        _check_neg_evals: bool,
1648        _num_neg_evals: Index,
1649    ) -> ESymSolverStatus {
1650        unreachable!("NoopAugSolver is a transient placeholder")
1651    }
1652}
1653
1654#[cfg(test)]
1655mod tests {
1656    use super::{deadline_exceeded, factor_overshoot_predicted};
1657    use crate::ipopt_data::IpoptData;
1658    use pounce_common::timing::Deadline;
1659    use std::cell::RefCell;
1660    use std::rc::Rc;
1661
1662    #[test]
1663    fn deadline_exceeded_is_false_without_a_deadline() {
1664        // Direct-driver / unit-test path: no deadline installed, so the KKT
1665        // solve never short-circuits and stays on the coarse timer gate.
1666        let data = Rc::new(RefCell::new(IpoptData::new()));
1667        assert!(!deadline_exceeded(&data));
1668    }
1669
1670    #[test]
1671    fn deadline_exceeded_is_false_when_budget_is_unbounded() {
1672        // The pounce "no budget" defaults (1e6 s each) must never trip inside
1673        // any realistic solve, so the fine-grained KKT check is a no-op.
1674        let data = Rc::new(RefCell::new(IpoptData::new()));
1675        data.borrow_mut().deadline = Some(Deadline::new(1e6, 1e6));
1676        assert!(!deadline_exceeded(&data));
1677    }
1678
1679    #[test]
1680    fn deadline_exceeded_true_once_the_budget_is_crossed() {
1681        // Zero wall budget: once any wall time elapses the KKT loops must see
1682        // the deadline and abort between factorizations (pounce#244). Busy-spin
1683        // until the monotonic clock advances past the start instant so the
1684        // assertion is not racing a coarse-clock zero-duration read — matching
1685        // the `Deadline` unit tests' pattern.
1686        let data = Rc::new(RefCell::new(IpoptData::new()));
1687        data.borrow_mut().deadline = Some(Deadline::new(0.0, 1e6));
1688        for _ in 0..10_000 {
1689            if deadline_exceeded(&data) {
1690                break;
1691            }
1692            std::hint::black_box(0u64);
1693        }
1694        assert!(deadline_exceeded(&data));
1695    }
1696
1697    #[test]
1698    fn predict_no_estimate_never_fires() {
1699        // Before any factorization has been measured (zero estimates) the
1700        // predictive guard must be a no-op, even with a fully-spent budget —
1701        // there is nothing to predict from. The reactive `deadline_exceeded`
1702        // check owns the already-crossed case.
1703        let deadline = Deadline::new(0.0, 0.0);
1704        assert!(!factor_overshoot_predicted(0.0, 0.0, &deadline));
1705    }
1706
1707    #[test]
1708    fn predict_small_factor_relative_to_budget_never_fires() {
1709        // A factorization that is a small slice of the budget must not trip
1710        // the guard even when little budget remains: an ordinary solve one
1711        // iteration from converging is never cut short. Budget 100 s wall,
1712        // observed factor 1 s (1% << the 50% gate).
1713        let deadline = Deadline::new(100.0, 100.0);
1714        assert!(!factor_overshoot_predicted(1.0, 1.0, &deadline));
1715    }
1716
1717    #[test]
1718    fn predict_large_factor_with_insufficient_remaining_fires() {
1719        // A factorization costing more than the whole (tiny) budget, with a
1720        // fresh deadline whose full budget still "remains", must fire: the
1721        // next factorization of that size cannot fit. max_wall budget 0.001 s,
1722        // observed factor 10 s (>= 50% of budget and > remaining).
1723        let deadline = Deadline::new(0.001, 1e6);
1724        // Let a hair of wall time pass so remaining_wall is unambiguously
1725        // below the 10 s estimate (it already is, but keep parity with the
1726        // other clock-sensitive tests).
1727        for _ in 0..1_000 {
1728            std::hint::black_box(0u64);
1729        }
1730        assert!(factor_overshoot_predicted(10.0, 0.0, &deadline));
1731    }
1732
1733    #[test]
1734    fn predict_large_factor_with_ample_remaining_does_not_fire() {
1735        // Even a factor that is a large fraction of the budget must be allowed
1736        // to start while the remaining budget can still cover it — the guard
1737        // bounds overshoot, it does not forbid using the budget. Budget 100 s,
1738        // observed worst factor 60 s (>= 50% gate) but ~100 s still remains.
1739        let deadline = Deadline::new(100.0, 100.0);
1740        assert!(!factor_overshoot_predicted(60.0, 60.0, &deadline));
1741    }
1742
1743    #[test]
1744    fn predict_fires_on_cpu_budget_independently() {
1745        // The CPU clock gates independently of wall: a spent CPU budget with
1746        // a large observed CPU factor cost fires even though the wall estimate
1747        // is zero. Tiny CPU budget, generous wall budget.
1748        let deadline = Deadline::new(1e6, 0.001);
1749        for _ in 0..1_000 {
1750            std::hint::black_box(0u64);
1751        }
1752        assert!(factor_overshoot_predicted(0.0, 10.0, &deadline));
1753    }
1754}