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::PdPerturbationHandler;
17use pounce_common::tagged::Tag;
18use pounce_common::types::{Index, Number};
19use pounce_linalg::{Matrix, SymMatrix, Vector};
20use pounce_linsol::ESymSolverStatus;
21use std::cell::RefCell;
22use std::rc::Rc;
23
24pub struct PdFullSpaceSolver {
25    aug_solver: Box<dyn AugSystemSolver>,
26    perturb: Rc<RefCell<PdPerturbationHandler>>,
27    pub min_refinement_steps: Index,
28    pub max_refinement_steps: Index,
29    pub residual_ratio_max: Number,
30    pub residual_ratio_singular: Number,
31    pub residual_improvement_factor: Number,
32    /// Negative-curvature test tolerance (`neg_curv_test_tol_`). Zero
33    /// disables the heuristic; matches upstream's `RegisterOptions`
34    /// default. The non-zero branch is not exercised in v1.0.
35    pub neg_curv_test_tol: Number,
36    /// Mirrors `augsys_improved_`. Set by quality-escalation; cleared
37    /// each time the cached aug-system data changes.
38    augsys_improved: bool,
39    /// Mirrors upstream's `dummy_cache_` hit/miss. `false` ⇒ the next
40    /// `solve_once` is operating on a *new* augmented matrix and must
41    /// run the `ConsiderNewSystem` + perturbation-escalation path;
42    /// `true` ⇒ the matrix is identical to the previous successful
43    /// `solve_once`, so we can reuse `CurrentPerturbation` and just do
44    /// a single back-solve (the iterative-refinement / quality-retry
45    /// re-call path). Reset to `false` at the start of every outer
46    /// `solve()` invocation since each outer iter delivers a fresh
47    /// matrix from the algorithm's perspective.
48    matrix_considered: bool,
49    /// Tags of the 13 dependencies (W, J_c, J_d, z_L, z_U, v_L, v_U,
50    /// slack_x_L, slack_x_U, slack_s_L, slack_s_U, sigma_x, sigma_s)
51    /// at the time `matrix_considered` was last set to `true`. Mirrors
52    /// upstream's `dummy_cache_` keyed on the same 13 `TaggedObject`s
53    /// (`IpPDFullSpaceSolver.cpp:430-448`). Reset to `None` whenever
54    /// any tag changes.
55    last_dep_tags: Option<[Tag; 13]>,
56    last_status: Option<ESymSolverStatus>,
57}
58
59impl PdFullSpaceSolver {
60    pub fn new(
61        aug_solver: Box<dyn AugSystemSolver>,
62        perturb: Rc<RefCell<PdPerturbationHandler>>,
63    ) -> Self {
64        Self {
65            aug_solver,
66            perturb,
67            // Defaults from `IpPDFullSpaceSolver.cpp:RegisterOptions`.
68            min_refinement_steps: 1,
69            max_refinement_steps: 10,
70            residual_ratio_max: 1e-10,
71            residual_ratio_singular: 1e-5,
72            residual_improvement_factor: 0.999_999_999,
73            neg_curv_test_tol: 0.0,
74            augsys_improved: false,
75            matrix_considered: false,
76            last_dep_tags: None,
77            last_status: None,
78        }
79    }
80
81    pub fn aug_solver(&self) -> &dyn AugSystemSolver {
82        &*self.aug_solver
83    }
84
85    pub fn aug_solver_mut(&mut self) -> &mut dyn AugSystemSolver {
86        &mut *self.aug_solver
87    }
88
89    /// Replace the underlying [`AugSystemSolver`] by passing the
90    /// existing one through the supplied wrapper closure. Used by the
91    /// restoration phase to decorate the inner `StdAugSystemSolver`
92    /// with `AugRestoSystemSolver` (which performs the 8-block →
93    /// 4-block Schur reduction before delegating).
94    pub fn wrap_aug_solver<F>(&mut self, wrap: F)
95    where
96        F: FnOnce(Box<dyn AugSystemSolver>) -> Box<dyn AugSystemSolver>,
97    {
98        // Take the inner aug solver out via a temporary noop, wrap it,
99        // and slot the wrapped one back in. The placeholder is never
100        // observed externally because we replace it before returning.
101        let noop: Box<dyn AugSystemSolver> = Box::new(NoopAugSolver);
102        let inner = std::mem::replace(&mut self.aug_solver, noop);
103        self.aug_solver = wrap(inner);
104    }
105
106    /// Solve the full PD system. `res = α · M⁻¹ · rhs + β · res_in`,
107    /// matching `IpPDFullSpaceSolver::Solve`. Returns `true` on
108    /// success. The iterate fields used to assemble the system are
109    /// pulled from `data` (`W`, `curr`) and `cq` (jacobians, slacks,
110    /// sigmas).
111    #[allow(clippy::too_many_arguments)]
112    pub fn solve(
113        &mut self,
114        data: &IpoptDataHandle,
115        cq: &IpoptCqHandle,
116        nlp: &Rc<RefCell<dyn IpoptNlp>>,
117        alpha: Number,
118        beta: Number,
119        rhs: &IteratesVector,
120        res: &mut IteratesVectorMut,
121        allow_inexact: bool,
122        improve_solution: bool,
123    ) -> bool {
124        debug_assert!(!allow_inexact || !improve_solution);
125        debug_assert!(!improve_solution || beta == 0.0);
126
127        // Snapshot the incoming `res` if β ≠ 0 (we add it back at the
128        // end via `res = α · sol + β · copy_res`).
129        let copy_res: Option<IteratesVector> = if beta != 0.0 {
130            Some(snapshot_mut(res))
131        } else {
132            None
133        };
134
135        // Pull all blocks once. None of these change during the
136        // refinement / escalation loop, so collecting them here
137        // matches upstream's structure (lines 168-189).
138        let w = data
139            .borrow()
140            .w
141            .clone()
142            .unwrap_or_else(|| panic!("PdFullSpaceSolver::solve: IpoptData::w is unset"));
143        let cq_ref = cq.borrow();
144        let j_c = cq_ref.curr_jac_c();
145        let j_d = cq_ref.curr_jac_d();
146        let sigma_x = cq_ref.curr_sigma_x();
147        let sigma_s = cq_ref.curr_sigma_s();
148        let slack_x_l = cq_ref.curr_slack_x_l();
149        let slack_x_u = cq_ref.curr_slack_x_u();
150        let slack_s_l = cq_ref.curr_slack_s_l();
151        let slack_s_u = cq_ref.curr_slack_s_u();
152        drop(cq_ref);
153
154        let nlp_ref = nlp.borrow();
155        let px_l = nlp_ref.px_l();
156        let px_u = nlp_ref.px_u();
157        let pd_l = nlp_ref.pd_l();
158        let pd_u = nlp_ref.pd_u();
159        drop(nlp_ref);
160
161        let curr = {
162            let d = data.borrow();
163            d.curr
164                .clone()
165                .unwrap_or_else(|| panic!("PdFullSpaceSolver::solve: IpoptData::curr is unset"))
166        };
167
168        let blocks = SolveBlocks {
169            w: &*w,
170            j_c: &*j_c,
171            j_d: &*j_d,
172            px_l: &*px_l,
173            px_u: &*px_u,
174            pd_l: &*pd_l,
175            pd_u: &*pd_u,
176            z_l: &*curr.z_l,
177            z_u: &*curr.z_u,
178            v_l: &*curr.v_l,
179            v_u: &*curr.v_u,
180            slack_x_l: &*slack_x_l,
181            slack_x_u: &*slack_x_u,
182            slack_s_l: &*slack_s_l,
183            slack_s_u: &*slack_s_u,
184            sigma_x: &*sigma_x,
185            sigma_s: &*sigma_s,
186        };
187
188        // Mirror upstream's `dummy_cache_` lookup
189        // (`IpPDFullSpaceSolver.cpp:430-450`): if all 13 dependency tags
190        // are unchanged since the last successful `solve()`, the matrix
191        // is "uptodate" — keep `matrix_considered = true` so the
192        // perturbation handler is NOT re-entered, and reuse the
193        // existing `augsys_improved_` state. On a cache miss, reset
194        // both flags.
195        let cur_tags: [Tag; 13] = [
196            blocks.w.as_tagged().get_tag(),
197            blocks.j_c.as_tagged().get_tag(),
198            blocks.j_d.as_tagged().get_tag(),
199            blocks.z_l.as_tagged().get_tag(),
200            blocks.z_u.as_tagged().get_tag(),
201            blocks.v_l.as_tagged().get_tag(),
202            blocks.v_u.as_tagged().get_tag(),
203            blocks.slack_x_l.as_tagged().get_tag(),
204            blocks.slack_x_u.as_tagged().get_tag(),
205            blocks.slack_s_l.as_tagged().get_tag(),
206            blocks.slack_s_u.as_tagged().get_tag(),
207            blocks.sigma_x.as_tagged().get_tag(),
208            blocks.sigma_s.as_tagged().get_tag(),
209        ];
210        let uptodate = self.last_dep_tags.map_or(false, |prev| prev == cur_tags);
211        if !uptodate {
212            if std::env::var_os("POUNCE_DBG_PD_TAGS").is_some() {
213                if let Some(prev) = self.last_dep_tags {
214                    let names = [
215                        "w",
216                        "j_c",
217                        "j_d",
218                        "z_l",
219                        "z_u",
220                        "v_l",
221                        "v_u",
222                        "slack_x_l",
223                        "slack_x_u",
224                        "slack_s_l",
225                        "slack_s_u",
226                        "sigma_x",
227                        "sigma_s",
228                    ];
229                    let mut diffs = String::new();
230                    for i in 0..13 {
231                        if prev[i] != cur_tags[i] {
232                            diffs.push_str(&format!(
233                                " {}({:?}→{:?})",
234                                names[i], prev[i], cur_tags[i]
235                            ));
236                        }
237                    }
238                    eprintln!("[PN_PD_TAGS] cache_miss diffs:{}", diffs);
239                } else {
240                    eprintln!("[PN_PD_TAGS] cache_miss first_solve");
241                }
242            }
243            self.last_dep_tags = Some(cur_tags);
244            self.matrix_considered = false;
245            self.augsys_improved = false;
246        }
247
248        let mut done = false;
249        let mut resolve_with_better_quality = false;
250        let mut pretend_singular = false;
251        let mut pretend_singular_last_time = false;
252        let mut improve = improve_solution;
253
254        while !done {
255            let solve_ok = if improve {
256                true
257            } else {
258                let ok = self.solve_once(
259                    data,
260                    &blocks,
261                    1.0,
262                    0.0,
263                    rhs,
264                    res,
265                    resolve_with_better_quality,
266                    pretend_singular,
267                );
268                resolve_with_better_quality = false;
269                pretend_singular = false;
270                ok
271            };
272            improve = false;
273
274            if !solve_ok {
275                return false;
276            }
277
278            if allow_inexact {
279                break;
280            }
281
282            // Initial residual.
283            let mut resid = res.fresh_zeroed();
284            self.compute_residuals(data, &blocks, rhs, res, &mut resid);
285            let mut residual_ratio = self.compute_residual_ratio(rhs, res, &resid);
286            let mut residual_ratio_old = residual_ratio;
287
288            let mut num_iter_ref: Index = 0;
289            let mut quit_refinement = false;
290
291            while !quit_refinement
292                && (num_iter_ref < self.min_refinement_steps
293                    || residual_ratio > self.residual_ratio_max)
294            {
295                let frozen_resid = resid.freeze();
296                let solve_ok = self.solve_once(
297                    data,
298                    &blocks,
299                    -1.0,
300                    1.0,
301                    &frozen_resid,
302                    res,
303                    resolve_with_better_quality,
304                    false,
305                );
306                resid = thaw(frozen_resid);
307                if !solve_ok {
308                    return false;
309                }
310
311                self.compute_residuals(data, &blocks, rhs, res, &mut resid);
312                residual_ratio = self.compute_residual_ratio(rhs, res, &resid);
313                num_iter_ref += 1;
314
315                if residual_ratio > self.residual_ratio_max
316                    && num_iter_ref > self.min_refinement_steps
317                    && (num_iter_ref > self.max_refinement_steps
318                        || residual_ratio > self.residual_improvement_factor * residual_ratio_old)
319                {
320                    quit_refinement = true;
321                    resolve_with_better_quality = false;
322
323                    if !pretend_singular_last_time {
324                        if !self.augsys_improved {
325                            self.augsys_improved = self.aug_solver.increase_quality();
326                            if self.augsys_improved {
327                                data.borrow_mut().append_info_string("q");
328                                resolve_with_better_quality = true;
329                            } else {
330                                pretend_singular = true;
331                            }
332                        } else {
333                            pretend_singular = true;
334                        }
335                        pretend_singular_last_time = pretend_singular;
336                        if pretend_singular {
337                            if residual_ratio < self.residual_ratio_singular {
338                                pretend_singular = false;
339                                data.borrow_mut().append_info_string("S");
340                            } else {
341                                data.borrow_mut().append_info_string("s");
342                            }
343                        }
344                    } else {
345                        pretend_singular = false;
346                    }
347                }
348
349                residual_ratio_old = residual_ratio;
350            }
351
352            done = !resolve_with_better_quality && !pretend_singular;
353        }
354
355        // Final assembly: res = α · res + β · copy_res.
356        if alpha != 0.0 {
357            res.scal(alpha);
358        }
359        if let Some(copy_res) = copy_res {
360            res.axpy(beta, &copy_res);
361        }
362
363        self.last_status = Some(ESymSolverStatus::Success);
364        true
365    }
366
367    /// One outer back-solve through the augmented system, including
368    /// the `Px_L · S_xL⁻¹ · z_L` lifts on the RHS and the bound-
369    /// multiplier expansion on the solution side. Mirrors
370    /// `IpPDFullSpaceSolver::SolveOnce`.
371    #[allow(clippy::too_many_arguments)]
372    fn solve_once(
373        &mut self,
374        data: &IpoptDataHandle,
375        b: &SolveBlocks<'_>,
376        alpha: Number,
377        beta: Number,
378        rhs: &IteratesVector,
379        res: &mut IteratesVectorMut,
380        _resolve_with_better_quality: bool,
381        mut pretend_singular: bool,
382    ) -> bool {
383        // Build aug-system primal RHS:
384        //   augRhs_x = rhs.x + Px_L · S_xL⁻¹ · z_L − Px_U · S_xU⁻¹ · z_U
385        let mut aug_rhs_x = rhs.x.make_new_copy();
386        b.px_l
387            .add_m_sinv_z(1.0, b.slack_x_l, &*rhs.z_l, &mut *aug_rhs_x);
388        b.px_u
389            .add_m_sinv_z(-1.0, b.slack_x_u, &*rhs.z_u, &mut *aug_rhs_x);
390
391        let mut aug_rhs_s = rhs.s.make_new_copy();
392        b.pd_l
393            .add_m_sinv_z(1.0, b.slack_s_l, &*rhs.v_l, &mut *aug_rhs_s);
394        b.pd_u
395            .add_m_sinv_z(-1.0, b.slack_s_u, &*rhs.v_u, &mut *aug_rhs_s);
396
397        // Solution slot for the aug-system (dx, ds, dy_c, dy_d).
398        let mut sol = res.fresh_zeroed();
399
400        // Number of negative eigenvalues we expect.
401        let num_neg_evals = rhs.y_c.dim() + rhs.y_d.dim();
402
403        let curr_mu = data.borrow().curr_mu;
404
405        // Upstream's `IpPDFullSpaceSolver::SolveOnce` (cpp:457-482)
406        // splits on `(uptodate && !pretend_singular)`: if the matrix is
407        // unchanged since the last `SolveOnce` and we are not faking a
408        // singularity, reuse the existing perturbation, do a single
409        // back-solve with `check_inertia=false`, and return. Iterative
410        // refinement and the post-`IncreaseQuality` retry both land
411        // here. Calling `ConsiderNewSystem` again on a same-matrix
412        // re-solve would corrupt the perturbation handler's
413        // `delta_x_last` bookkeeping.
414        if self.matrix_considered && !pretend_singular {
415            let d = self.perturb.borrow().current_perturbation();
416            let coeffs = AugSysCoeffs {
417                w: Some(b.w),
418                w_factor: 1.0,
419                d_x: Some(b.sigma_x),
420                delta_x: d.delta_x,
421                d_s: Some(b.sigma_s),
422                delta_s: d.delta_s,
423                j_c: b.j_c,
424                d_c: None,
425                delta_c: d.delta_c,
426                j_d: b.j_d,
427                d_d: None,
428                delta_d: d.delta_d,
429            };
430            let aug_rhs = AugSysRhs {
431                rhs_x: &*aug_rhs_x,
432                rhs_s: &*aug_rhs_s,
433                rhs_c: &*rhs.y_c,
434                rhs_d: &*rhs.y_d,
435            };
436            let mut aug_sol = AugSysSol {
437                sol_x: &mut *sol.x,
438                sol_s: &mut *sol.s,
439                sol_c: &mut *sol.y_c,
440                sol_d: &mut *sol.y_d,
441            };
442            // Same matrix, same perturbations, inertia already known —
443            // use the cached factor and avoid the per-call refactor
444            // that otherwise dominates MA57 wall-time on long iter-ref
445            // loops (cont5_2_4_l drops 97s → ~30s).
446            let retval = self.aug_solver.resolve(&coeffs, &aug_rhs, &mut aug_sol);
447            if retval != ESymSolverStatus::Success {
448                return false;
449            }
450            // Stash perturbations on data, expand bound multipliers,
451            // assemble final res, and return — skipping the
452            // escalation loop entirely (matches upstream's `if(uptodate
453            // && !pretend_singular)` branch in IpPDFullSpaceSolver.cpp).
454            {
455                let mut dm = data.borrow_mut();
456                dm.perturbations.delta_x = d.delta_x;
457                dm.perturbations.delta_s = d.delta_s;
458                dm.perturbations.delta_c = d.delta_c;
459                dm.perturbations.delta_d = d.delta_d;
460            }
461            expand_bound_multipliers(b, rhs, &mut sol);
462            let frozen_sol = sol.freeze();
463            res.add_one_vector(alpha, &frozen_sol, beta);
464            return true;
465        }
466
467        let mut deltas = self
468            .perturb
469            .borrow_mut()
470            .consider_new_system(curr_mu, Some(data));
471        let Some(mut d) = deltas.take() else {
472            return false;
473        };
474
475        let mut count = 0_i32;
476        let mut retval;
477        loop {
478            if pretend_singular {
479                retval = ESymSolverStatus::Singular;
480                pretend_singular = false;
481            } else {
482                count += 1;
483                let check_inertia = self.neg_curv_test_tol <= 0.0;
484                let coeffs = AugSysCoeffs {
485                    w: Some(b.w),
486                    w_factor: 1.0,
487                    d_x: Some(b.sigma_x),
488                    delta_x: d.delta_x,
489                    d_s: Some(b.sigma_s),
490                    delta_s: d.delta_s,
491                    j_c: b.j_c,
492                    d_c: None,
493                    delta_c: d.delta_c,
494                    j_d: b.j_d,
495                    d_d: None,
496                    delta_d: d.delta_d,
497                };
498                let aug_rhs = AugSysRhs {
499                    rhs_x: &*aug_rhs_x,
500                    rhs_s: &*aug_rhs_s,
501                    rhs_c: &*rhs.y_c,
502                    rhs_d: &*rhs.y_d,
503                };
504                let mut aug_sol = AugSysSol {
505                    sol_x: &mut *sol.x,
506                    sol_s: &mut *sol.s,
507                    sol_c: &mut *sol.y_c,
508                    sol_d: &mut *sol.y_d,
509                };
510                retval = self.aug_solver.solve(
511                    &coeffs,
512                    &aug_rhs,
513                    &mut aug_sol,
514                    check_inertia,
515                    num_neg_evals,
516                );
517            }
518
519            if retval == ESymSolverStatus::FatalError {
520                return false;
521            }
522
523            if retval == ESymSolverStatus::Singular && (rhs.y_c.dim() + rhs.y_d.dim() > 0) {
524                let curr_mu = data.borrow().curr_mu;
525                let next = self
526                    .perturb
527                    .borrow_mut()
528                    .perturb_for_singular(curr_mu, Some(data));
529                let Some(nd) = next else { return false };
530                d = nd;
531            } else if retval == ESymSolverStatus::WrongInertia
532                && self.aug_solver.number_of_neg_evals() < num_neg_evals
533            {
534                let mut assume_singular = true;
535                if !self.augsys_improved {
536                    self.augsys_improved = self.aug_solver.increase_quality();
537                    if self.augsys_improved {
538                        data.borrow_mut().append_info_string("q");
539                        assume_singular = false;
540                    }
541                }
542                if assume_singular {
543                    let curr_mu = data.borrow().curr_mu;
544                    let next = self
545                        .perturb
546                        .borrow_mut()
547                        .perturb_for_singular(curr_mu, Some(data));
548                    let Some(nd) = next else { return false };
549                    d = nd;
550                    data.borrow_mut().append_info_string("a");
551                }
552            } else if retval == ESymSolverStatus::WrongInertia
553                || retval == ESymSolverStatus::Singular
554            {
555                let curr_mu = data.borrow().curr_mu;
556                let next = self
557                    .perturb
558                    .borrow_mut()
559                    .perturb_for_wrong_inertia(curr_mu, Some(data));
560                let Some(nd) = next else { return false };
561                d = nd;
562            }
563
564            if retval == ESymSolverStatus::Success {
565                break;
566            }
567        }
568        let _ = count;
569
570        // Stash the perturbation on data — upstream calls
571        // `IpData().setPDPert(...)` here.
572        {
573            let mut dm = data.borrow_mut();
574            dm.perturbations.delta_x = d.delta_x;
575            dm.perturbations.delta_s = d.delta_s;
576            dm.perturbations.delta_c = d.delta_c;
577            dm.perturbations.delta_d = d.delta_d;
578        }
579
580        // Mark this matrix as "considered" so subsequent `solve_once`
581        // re-calls within the same outer `solve()` (iterative refinement
582        // / quality retry) take the single-solve path above.
583        self.matrix_considered = true;
584
585        expand_bound_multipliers(b, rhs, &mut sol);
586
587        // res = α · sol + β · res
588        let frozen_sol = sol.freeze();
589        res.add_one_vector(alpha, &frozen_sol, beta);
590        true
591    }
592
593    /// `resid = M · res − rhs` per `ComputeResiduals`. Skips terms
594    /// whose perturbation is exactly zero.
595    fn compute_residuals(
596        &self,
597        _data: &IpoptDataHandle,
598        b: &SolveBlocks<'_>,
599        rhs: &IteratesVector,
600        res: &IteratesVectorMut,
601        resid: &mut IteratesVectorMut,
602    ) {
603        let d = self.perturb.borrow().current_perturbation();
604
605        // x: W·res.x + J_c^T·res.y_c + J_d^T·res.y_d
606        //    − Px_L·res.z_L + Px_U·res.z_U + δ_x·res.x − rhs.x
607        b.w.mult_vector(1.0, &*res.x, 0.0, &mut *resid.x);
608        b.j_c.trans_mult_vector(1.0, &*res.y_c, 1.0, &mut *resid.x);
609        b.j_d.trans_mult_vector(1.0, &*res.y_d, 1.0, &mut *resid.x);
610        b.px_l.mult_vector(-1.0, &*res.z_l, 1.0, &mut *resid.x);
611        b.px_u.mult_vector(1.0, &*res.z_u, 1.0, &mut *resid.x);
612        // resid.x += δ_x·res.x − rhs.x
613        resid
614            .x
615            .add_two_vectors(d.delta_x, &*res.x, -1.0, &*rhs.x, 1.0);
616
617        // s: Pd_U·res.v_U − Pd_L·res.v_L − res.y_d − rhs.s + δ_s·res.s
618        b.pd_u.mult_vector(1.0, &*res.v_u, 0.0, &mut *resid.s);
619        b.pd_l.mult_vector(-1.0, &*res.v_l, 1.0, &mut *resid.s);
620        resid.s.add_two_vectors(-1.0, &*res.y_d, -1.0, &*rhs.s, 1.0);
621        if d.delta_s != 0.0 {
622            resid.s.axpy(d.delta_s, &*res.s);
623        }
624
625        // c: J_c·res.x − δ_c·res.y_c − rhs.y_c
626        b.j_c.mult_vector(1.0, &*res.x, 0.0, &mut *resid.y_c);
627        resid
628            .y_c
629            .add_two_vectors(-d.delta_c, &*res.y_c, -1.0, &*rhs.y_c, 1.0);
630
631        // d: J_d·res.x − res.s − rhs.y_d − δ_d·res.y_d
632        b.j_d.mult_vector(1.0, &*res.x, 0.0, &mut *resid.y_d);
633        resid
634            .y_d
635            .add_two_vectors(-1.0, &*res.s, -1.0, &*rhs.y_d, 1.0);
636        if d.delta_d != 0.0 {
637            resid.y_d.axpy(-d.delta_d, &*res.y_d);
638        }
639
640        // zL: res.z_L · slack_x_L + (Px_L^T·res.x) · z_L − rhs.z_L
641        resid.z_l.copy(&*res.z_l);
642        resid.z_l.element_wise_multiply(b.slack_x_l);
643        let mut tmp_zl = b.z_l.make_new();
644        b.px_l.trans_mult_vector(1.0, &*res.x, 0.0, &mut *tmp_zl);
645        tmp_zl.element_wise_multiply(b.z_l);
646        resid
647            .z_l
648            .add_two_vectors(1.0, &*tmp_zl, -1.0, &*rhs.z_l, 1.0);
649
650        // zU: res.z_U · slack_x_U − (Px_U^T·res.x) · z_U − rhs.z_U
651        resid.z_u.copy(&*res.z_u);
652        resid.z_u.element_wise_multiply(b.slack_x_u);
653        let mut tmp_zu = b.z_u.make_new();
654        b.px_u.trans_mult_vector(1.0, &*res.x, 0.0, &mut *tmp_zu);
655        tmp_zu.element_wise_multiply(b.z_u);
656        resid
657            .z_u
658            .add_two_vectors(-1.0, &*tmp_zu, -1.0, &*rhs.z_u, 1.0);
659
660        // vL: res.v_L · slack_s_L + (Pd_L^T·res.s) · v_L − rhs.v_L
661        resid.v_l.copy(&*res.v_l);
662        resid.v_l.element_wise_multiply(b.slack_s_l);
663        let mut tmp_vl = b.v_l.make_new();
664        b.pd_l.trans_mult_vector(1.0, &*res.s, 0.0, &mut *tmp_vl);
665        tmp_vl.element_wise_multiply(b.v_l);
666        resid
667            .v_l
668            .add_two_vectors(1.0, &*tmp_vl, -1.0, &*rhs.v_l, 1.0);
669
670        // vU: res.v_U · slack_s_U − (Pd_U^T·res.s) · v_U − rhs.v_U
671        resid.v_u.copy(&*res.v_u);
672        resid.v_u.element_wise_multiply(b.slack_s_u);
673        let mut tmp_vu = b.v_u.make_new();
674        b.pd_u.trans_mult_vector(1.0, &*res.s, 0.0, &mut *tmp_vu);
675        tmp_vu.element_wise_multiply(b.v_u);
676        resid
677            .v_u
678            .add_two_vectors(-1.0, &*tmp_vu, -1.0, &*rhs.v_u, 1.0);
679    }
680
681    /// `nrm_resid / (min(nrm_res, max_cond·nrm_rhs) + nrm_rhs)`, with
682    /// `max_cond = 1e6`. Mirrors `ComputeResidualRatio`.
683    fn compute_residual_ratio(
684        &self,
685        rhs: &IteratesVector,
686        res: &IteratesVectorMut,
687        resid: &IteratesVectorMut,
688    ) -> Number {
689        let nrm_rhs = rhs.amax();
690        let nrm_res = res.amax();
691        let nrm_resid = resid.amax();
692        if nrm_rhs + nrm_res == 0.0 {
693            nrm_resid
694        } else {
695            let max_cond = 1e6;
696            nrm_resid / (nrm_res.min(max_cond * nrm_rhs) + nrm_rhs)
697        }
698    }
699}
700
701impl PdSystemSolver for PdFullSpaceSolver {
702    fn solve_status(&self) -> ESymSolverStatus {
703        self.last_status.unwrap_or(ESymSolverStatus::FatalError)
704    }
705}
706
707/// Bag of borrowed blocks used by both `solve_once` and
708/// `compute_residuals` — keeps argument lists tractable.
709struct SolveBlocks<'a> {
710    w: &'a dyn SymMatrix,
711    j_c: &'a dyn Matrix,
712    j_d: &'a dyn Matrix,
713    px_l: &'a dyn Matrix,
714    px_u: &'a dyn Matrix,
715    pd_l: &'a dyn Matrix,
716    pd_u: &'a dyn Matrix,
717    z_l: &'a dyn Vector,
718    z_u: &'a dyn Vector,
719    v_l: &'a dyn Vector,
720    v_u: &'a dyn Vector,
721    slack_x_l: &'a dyn Vector,
722    slack_x_u: &'a dyn Vector,
723    slack_s_l: &'a dyn Vector,
724    slack_s_u: &'a dyn Vector,
725    sigma_x: &'a dyn Vector,
726    sigma_s: &'a dyn Vector,
727}
728
729/// Helper trait extension on `IteratesVectorMut` for fresh zeroed
730/// allocations matching the same shape — the shape lives implicitly
731/// in the existing components' `dim()`.
732trait FreshZeroed {
733    fn fresh_zeroed(&self) -> IteratesVectorMut;
734}
735
736impl FreshZeroed for IteratesVectorMut {
737    fn fresh_zeroed(&self) -> IteratesVectorMut {
738        IteratesVectorMut {
739            x: self.x.make_new(),
740            s: self.s.make_new(),
741            y_c: self.y_c.make_new(),
742            y_d: self.y_d.make_new(),
743            z_l: self.z_l.make_new(),
744            z_u: self.z_u.make_new(),
745            v_l: self.v_l.make_new(),
746            v_u: self.v_u.make_new(),
747        }
748    }
749}
750
751/// Snapshot a mutable iterate into a frozen, shareable copy without
752/// consuming it. Used to remember `res_in` when β ≠ 0.
753fn snapshot_mut(m: &IteratesVectorMut) -> IteratesVector {
754    let mut out = m.fresh_zeroed();
755    out.x.copy(&*m.x);
756    out.s.copy(&*m.s);
757    out.y_c.copy(&*m.y_c);
758    out.y_d.copy(&*m.y_d);
759    out.z_l.copy(&*m.z_l);
760    out.z_u.copy(&*m.z_u);
761    out.v_l.copy(&*m.v_l);
762    out.v_u.copy(&*m.v_u);
763    out.freeze()
764}
765
766/// Convert a frozen `IteratesVector` back to a mutable owned form.
767/// Allocates fresh storage and copies; the iterative-refinement loop
768/// re-freezes/thaws once per iteration, so a single per-component
769/// copy is acceptable.
770/// Expand the four bound-multiplier blocks of `sol` from the just-
771/// computed primal-step blocks (`sol.x`, `sol.s`):
772///
773/// ```text
774/// sol.z_L = S_xL⁻¹ · (rhs.z_L − z_L · (Px_L^T · sol.x))
775/// sol.z_U = S_xU⁻¹ · (rhs.z_U + z_U · (Px_U^T · sol.x))
776/// sol.v_L = S_sL⁻¹ · (rhs.v_L − v_L · (Pd_L^T · sol.s))
777/// sol.v_U = S_sU⁻¹ · (rhs.v_U + v_U · (Pd_U^T · sol.s))
778/// ```
779///
780/// Encoded via `SinvBlrmZMTdBr` with `α = ±1`. Mirrors the bound-
781/// multiplier expansion at the bottom of upstream's
782/// `IpPDFullSpaceSolver::SolveOnce`.
783fn expand_bound_multipliers(
784    b: &SolveBlocks<'_>,
785    rhs: &IteratesVector,
786    sol: &mut IteratesVectorMut,
787) {
788    b.px_l
789        .sinv_blrm_zmt_dbr(-1.0, b.slack_x_l, &*rhs.z_l, b.z_l, &*sol.x, &mut *sol.z_l);
790    b.px_u
791        .sinv_blrm_zmt_dbr(1.0, b.slack_x_u, &*rhs.z_u, b.z_u, &*sol.x, &mut *sol.z_u);
792    b.pd_l
793        .sinv_blrm_zmt_dbr(-1.0, b.slack_s_l, &*rhs.v_l, b.v_l, &*sol.s, &mut *sol.v_l);
794    b.pd_u
795        .sinv_blrm_zmt_dbr(1.0, b.slack_s_u, &*rhs.v_u, b.v_u, &*sol.s, &mut *sol.v_u);
796}
797
798fn thaw(iv: IteratesVector) -> IteratesVectorMut {
799    fn one(v: Rc<dyn Vector>) -> Box<dyn Vector> {
800        let mut b = v.make_new();
801        b.copy(&*v);
802        b
803    }
804    IteratesVectorMut {
805        x: one(iv.x),
806        s: one(iv.s),
807        y_c: one(iv.y_c),
808        y_d: one(iv.y_d),
809        z_l: one(iv.z_l),
810        z_u: one(iv.z_u),
811        v_l: one(iv.v_l),
812        v_u: one(iv.v_u),
813    }
814}
815
816/// Internal placeholder used only inside [`PdFullSpaceSolver::wrap_aug_solver`]
817/// to satisfy `std::mem::replace`'s requirement for a value of the same
818/// type while the real boxed solver is being moved through the wrapper
819/// closure. None of the trait methods are ever invoked.
820struct NoopAugSolver;
821
822impl AugSystemSolver for NoopAugSolver {
823    fn provides_inertia(&self) -> bool {
824        unreachable!("NoopAugSolver is a transient placeholder")
825    }
826    fn number_of_neg_evals(&self) -> Index {
827        unreachable!("NoopAugSolver is a transient placeholder")
828    }
829    fn increase_quality(&mut self) -> bool {
830        unreachable!("NoopAugSolver is a transient placeholder")
831    }
832    fn last_solve_status(&self) -> ESymSolverStatus {
833        unreachable!("NoopAugSolver is a transient placeholder")
834    }
835    fn solve(
836        &mut self,
837        _coeffs: &AugSysCoeffs<'_>,
838        _rhs: &AugSysRhs<'_>,
839        _sol: &mut AugSysSol<'_>,
840        _check_neg_evals: bool,
841        _num_neg_evals: Index,
842    ) -> ESymSolverStatus {
843        unreachable!("NoopAugSolver is a transient placeholder")
844    }
845}