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, ©_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}