Skip to main content

pounce_algorithm/kkt/
low_rank_aug_system_solver.rs

1//! Low-rank augmented system solver — port of
2//! `Algorithm/IpLowRankAugSystemSolver.{hpp,cpp}`.
3//!
4//! Wraps another [`AugSystemSolver`] and exploits a [`LowRankUpdateSymMatrix`]
5//! Hessian via the Sherman-Morrison-Woodbury identity. The wrapped
6//! solver factorizes the diagonal part `B0`; this solver applies the
7//! rank-`(nV + nU)` correction using cached
8//! `Vtilde1 = K⁻¹ V` and `Utilde2 = K⁻¹ U − Vtilde1·(J1^{-T}J1^{-1}·Vtilde1ᵀU)`
9//! plus their dense Cholesky factors `J1 = chol(I + Vtilde1ᵀ V)` and
10//! `J2 = chol(I − Utilde2ᵀ U)`.
11//!
12//! The augmented-system solution comes from upstream's recipe
13//! (`IpLowRankAugSystemSolver.cpp:179-228`):
14//!
15//! 1. inner solver factors `K` (the aug system with `Wdiag` in place
16//!    of `W`) and back-substitutes for `csol_diag = K⁻¹ rhs`.
17//! 2. If `Utilde2_` is set, apply  `csol += Utilde2 · J2⁻¹ J2⁻ᵀ · Utilde2ᵀ rhs`.
18//! 3. If `Vtilde1_` is set, apply  `csol −= Vtilde1 · J1⁻¹ J1⁻ᵀ · Vtilde1ᵀ rhs`.
19//!
20//! `Vtilde1` and `Utilde2` are stored as four separate per-block
21//! [`MultiVectorMatrix`]es (x, s, c, d) — the same data that upstream
22//! packs into a 4-component `CompoundVector` of dense columns. This
23//! keeps the SMW arithmetic in dense linalg without needing a
24//! compound-vector storage class.
25
26use crate::kkt::aug_system_solver::{AugSysCoeffs, AugSysRhs, AugSysSol, AugSystemSolver};
27use pounce_common::tagged::Tag;
28use pounce_common::timing::TimingStatistics;
29use pounce_common::types::{Index, Number};
30use pounce_linalg::dense_gen_matrix::{DenseGenMatrix, DenseGenMatrixSpace};
31use pounce_linalg::dense_sym_matrix::DenseSymMatrixSpace;
32use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
33use pounce_linalg::diag_matrix::DiagMatrix;
34use pounce_linalg::low_rank_update_sym_matrix::LowRankUpdateSymMatrix;
35use pounce_linalg::multi_vector_matrix::{MultiVectorMatrix, MultiVectorMatrixSpace};
36use pounce_linalg::{Matrix, SymMatrix, Vector};
37use pounce_linsol::ESymSolverStatus;
38use std::rc::Rc;
39
40pub struct LowRankAugSystemSolver {
41    /// Inner solver that owns the diagonal factorization.
42    inner: Box<dyn AugSystemSolver>,
43    /// Whether `solve` has been called yet.
44    first_call: bool,
45    /// Cached negative-eigenvalue count.
46    num_neg_evals: Index,
47    /// Tag/scalar cache mirroring upstream's per-coefficient state.
48    cache: AugSysCache,
49    /// SMW factorization state (cleared on each rebuild).
50    factor: Factorization,
51    /// Whether `inner` currently holds a numeric factorization of the
52    /// matrix `inner_coeffs(&self.factor, coeffs)` describes — i.e. the
53    /// `Wdiag`-substituted augmented system for the coefficients in
54    /// `self.cache`.
55    ///
56    /// Set **only** immediately after an `inner.solve` returns `Success`
57    /// against exactly that matrix, never as a side effect of a rebuild:
58    /// `update_factorization` can legitimately perform zero inner solves
59    /// (empty L-BFGS history — `get_v()` and `get_u()` both `None`, which
60    /// happens on the first iteration and again whenever
61    /// `limited_memory_max_skipping` clears the history mid-solve), and
62    /// in that case the inner solver is still holding the *previous*
63    /// iterate's factor of a different `Wdiag`.
64    inner_has_factor: bool,
65    /// The `num_neg_evals` target the cached factor was validated
66    /// against, or `None` if it was produced without an inertia check.
67    /// A fast path that skips re-factorizing must not also skip an
68    /// inertia check the caller asked for against a different target.
69    inner_factor_neg_evals: Option<Index>,
70    /// Separate inner solver dedicated to the Hessian-free solves that
71    /// take the bypass in [`LowRankAugSystemSolver::solve`], so that
72    /// neither solver ever sees more than one W sparsity (gh#730).
73    ///
74    /// `None` restores the single-solver arrangement, in which both
75    /// shapes share one inner solver and each alternation re-runs the
76    /// backend's symbolic factorization. Unit tests that drive a stub
77    /// inner solver directly use that arrangement; the builder always
78    /// supplies a bypass solver on the limited-memory path.
79    bypass: Option<Box<dyn AugSystemSolver>>,
80    /// Whether the most recent `solve` took the Hessian-free bypass, so
81    /// `last_solve_status` reports the solver that actually ran.
82    last_solve_took_bypass: bool,
83}
84
85#[derive(Debug, Clone)]
86pub struct AugSysCache {
87    pub w_tag: Tag,
88    pub w_factor: Number,
89    pub d_x_tag: Tag,
90    pub delta_x: Number,
91    pub d_s_tag: Tag,
92    pub delta_s: Number,
93    pub j_c_tag: Tag,
94    pub d_c_tag: Tag,
95    pub delta_c: Number,
96    pub j_d_tag: Tag,
97    pub d_d_tag: Tag,
98    pub delta_d: Number,
99}
100
101impl Default for AugSysCache {
102    fn default() -> Self {
103        Self {
104            w_tag: Tag::NONE,
105            w_factor: 0.0,
106            d_x_tag: Tag::NONE,
107            delta_x: 0.0,
108            d_s_tag: Tag::NONE,
109            delta_s: 0.0,
110            j_c_tag: Tag::NONE,
111            d_c_tag: Tag::NONE,
112            delta_c: 0.0,
113            j_d_tag: Tag::NONE,
114            d_d_tag: Tag::NONE,
115            delta_d: 0.0,
116        }
117    }
118}
119
120#[derive(Default)]
121struct Factorization {
122    /// `Wdiag` substituted for `W` in every inner-solver call. Mirrors
123    /// upstream `Wdiag_`. Held mutably so we can call
124    /// [`DiagMatrix::set_diag`] on rebuild.
125    wdiag: Option<Box<DiagMatrix>>,
126    /// Dense Cholesky `J1 = chol(I + Vtilde1ᵀ · V)`. None when V is empty.
127    j1: Option<DenseGenMatrix>,
128    /// Dense Cholesky `J2 = chol(I − Utilde2ᵀ · U)`. None when U is empty.
129    j2: Option<DenseGenMatrix>,
130    /// Per-block `Vtilde1` storage (rank `nV`).
131    vtilde1_x: Option<MultiVectorMatrix>,
132    vtilde1_s: Option<MultiVectorMatrix>,
133    vtilde1_c: Option<MultiVectorMatrix>,
134    vtilde1_d: Option<MultiVectorMatrix>,
135    /// Per-block `Utilde2` storage (rank `nU`).
136    utilde2_x: Option<MultiVectorMatrix>,
137    utilde2_s: Option<MultiVectorMatrix>,
138    utilde2_c: Option<MultiVectorMatrix>,
139    utilde2_d: Option<MultiVectorMatrix>,
140}
141
142impl LowRankAugSystemSolver {
143    pub fn new(inner: Box<dyn AugSystemSolver>) -> Self {
144        Self {
145            inner,
146            first_call: true,
147            num_neg_evals: 0,
148            cache: AugSysCache::default(),
149            factor: Factorization::default(),
150            inner_has_factor: false,
151            inner_factor_neg_evals: None,
152            bypass: None,
153            last_solve_took_bypass: false,
154        }
155    }
156
157    /// Same as [`Self::new`] but routes the Hessian-free solves through
158    /// their own inner solver.
159    ///
160    /// Under limited-memory this solver drives its inner solver with two
161    /// different (1,1) shapes: an empty W for the least-square multiplier
162    /// initialization and the equality-multiplier estimates, and an
163    /// `n`-diagonal `B0` for the main primal-dual solves.
164    /// `StdAugSystemSolver` keys its structure signature on W's nonzero
165    /// count, so the two alternate and every alternation re-runs the
166    /// backend's symbolic factorization — for MA57 with
167    /// `ma57_pivot_order = 5` a full MeTiS nested dissection of the whole
168    /// KKT system, ~0.4 s per call on a 118 276-row model (gh#730).
169    ///
170    /// Ipopt never pays this because its low-rank layer owns those
171    /// solves, so its inner solver sees one shape for its whole life —
172    /// `IpTSymLinearSolver.cpp:182` asserts exactly that. Giving the
173    /// bypass its own solver reproduces that invariant on both sides
174    /// instead of on neither.
175    ///
176    /// Deliberately **not** done by making the two shapes agree — e.g.
177    /// substituting a zeroed `n`-diagonal for the empty W, which is what
178    /// the shape proposed in gh#730 does. That is numerically exact, but
179    /// it changes the pattern the backend orders, and a different
180    /// ordering rounds differently: swept, it takes `pooling_rt2stp`
181    /// under MA57 from `obj = -4391.83` to `-3273.95` (both "Optimal",
182    /// 25% apart) and `cresc4` from 110 to 267 iterations. Two solvers
183    /// hand each path exactly the matrix and the ordering it already
184    /// had, so the win is free of trajectory movement rather than paid
185    /// for with it.
186    ///
187    /// The cost is one additional numeric factorization resident at
188    /// once. That is the deliberate trade: memory is measurable and
189    /// boundable, and a trajectory regression is what has repeatedly
190    /// shipped here undetected (`dev-notes/trajectory-regressions-and-\
191    /// the-fixture-sweep.md`).
192    pub fn with_bypass_solver(
193        inner: Box<dyn AugSystemSolver>,
194        bypass: Box<dyn AugSystemSolver>,
195    ) -> Self {
196        Self {
197            bypass: Some(bypass),
198            ..Self::new(inner)
199        }
200    }
201
202    /// Pure tag/scalar comparison — port of upstream
203    /// `AugmentedSystemRequiresChange` (`IpLowRankAugSystemSolver.cpp:531-599`).
204    pub fn augmented_system_requires_change(&self, coeffs: &AugSysCoeffs<'_>) -> bool {
205        let cache = &self.cache;
206        let zero_tag: Tag = Tag::NONE;
207
208        let w_changed = match coeffs.w {
209            Some(w) => w.as_tagged().get_tag() != cache.w_tag,
210            None => cache.w_tag != zero_tag,
211        };
212        if w_changed || coeffs.w_factor != cache.w_factor {
213            return true;
214        }
215        let dx_changed = match coeffs.d_x {
216            Some(d) => d.as_tagged().get_tag() != cache.d_x_tag,
217            None => cache.d_x_tag != zero_tag,
218        };
219        if dx_changed || coeffs.delta_x != cache.delta_x {
220            return true;
221        }
222        let ds_changed = match coeffs.d_s {
223            Some(d) => d.as_tagged().get_tag() != cache.d_s_tag,
224            None => cache.d_s_tag != zero_tag,
225        };
226        if ds_changed || coeffs.delta_s != cache.delta_s {
227            return true;
228        }
229        if coeffs.j_c.as_tagged().get_tag() != cache.j_c_tag {
230            return true;
231        }
232        let dc_changed = match coeffs.d_c {
233            Some(d) => d.as_tagged().get_tag() != cache.d_c_tag,
234            None => cache.d_c_tag != zero_tag,
235        };
236        if dc_changed || coeffs.delta_c != cache.delta_c {
237            return true;
238        }
239        if coeffs.j_d.as_tagged().get_tag() != cache.j_d_tag {
240            return true;
241        }
242        let dd_changed = match coeffs.d_d {
243            Some(d) => d.as_tagged().get_tag() != cache.d_d_tag,
244            None => cache.d_d_tag != zero_tag,
245        };
246        if dd_changed || coeffs.delta_d != cache.delta_d {
247            return true;
248        }
249        false
250    }
251
252    fn store_cache(&mut self, coeffs: &AugSysCoeffs<'_>) {
253        let zero_tag = Tag::NONE;
254        self.cache.w_tag = coeffs
255            .w
256            .map(|w| w.as_tagged().get_tag())
257            .unwrap_or(zero_tag);
258        self.cache.w_factor = coeffs.w_factor;
259        self.cache.d_x_tag = coeffs
260            .d_x
261            .map(|d| d.as_tagged().get_tag())
262            .unwrap_or(zero_tag);
263        self.cache.delta_x = coeffs.delta_x;
264        self.cache.d_s_tag = coeffs
265            .d_s
266            .map(|d| d.as_tagged().get_tag())
267            .unwrap_or(zero_tag);
268        self.cache.delta_s = coeffs.delta_s;
269        self.cache.j_c_tag = coeffs.j_c.as_tagged().get_tag();
270        self.cache.d_c_tag = coeffs
271            .d_c
272            .map(|d| d.as_tagged().get_tag())
273            .unwrap_or(zero_tag);
274        self.cache.delta_c = coeffs.delta_c;
275        self.cache.j_d_tag = coeffs.j_d.as_tagged().get_tag();
276        self.cache.d_d_tag = coeffs
277            .d_d
278            .map(|d| d.as_tagged().get_tag())
279            .unwrap_or(zero_tag);
280        self.cache.delta_d = coeffs.delta_d;
281    }
282
283    pub fn first_call(&self) -> bool {
284        self.first_call
285    }
286
287    pub fn cache(&self) -> &AugSysCache {
288        &self.cache
289    }
290
291    /// Rebuild `Wdiag`, `Vtilde1`, `Utilde2`, `J1`, `J2` from a fresh
292    /// LR Hessian. Matches `IpLowRankAugSystemSolver.cpp::UpdateFactorization`
293    /// (lines 233-404). Returns the inner-solver's status — on
294    /// `WrongInertia` from a Cholesky failure, increments
295    /// `num_neg_evals` so the upper layer (PerturbationHandler) sees a
296    /// distinct retry target.
297    fn update_factorization(
298        &mut self,
299        lr_w: &LowRankUpdateSymMatrix,
300        coeffs: &AugSysCoeffs<'_>,
301        proto: &AugSysRhs<'_>,
302        check_neg_evals: bool,
303        num_neg_evals: Index,
304    ) -> ESymSolverStatus {
305        // `Wdiag` is about to be replaced, so whatever the inner solver
306        // holds is a factor of the *old* matrix from here on. Clearing
307        // first (rather than setting the flag at the end) is what makes
308        // the zero-column rebuild safe: when the L-BFGS history is empty
309        // both `get_v()` and `get_u()` are `None`, this function performs
310        // no inner solve at all, and the flag must stay false so the
311        // diagonal solve in `solve` factorizes instead of back-solving
312        // against the previous iterate's factor.
313        self.inner_has_factor = false;
314        self.inner_factor_neg_evals = None;
315
316        let proto_x = downcast_dense(proto.rhs_x);
317        let proto_s = downcast_dense(proto.rhs_s);
318        let proto_c = downcast_dense(proto.rhs_c);
319        let proto_d = downcast_dense(proto.rhs_d);
320        let space_x = Rc::clone(proto_x.space());
321        let space_s = Rc::clone(proto_s.space());
322        let space_c = Rc::clone(proto_c.space());
323        let space_d = Rc::clone(proto_d.space());
324
325        // 1. Build Wdiag from B0 (with optional P_LM expansion when
326        //    `reduced_diag` is set). When w_factor != 1.0, B0 is treated
327        //    as zero per upstream `IpLowRankAugSystemSolver.cpp:268-272`.
328        let b0_dense: DenseVector = if coeffs.w_factor == 1.0 {
329            match lr_w.get_diag() {
330                Some(d) => clone_dense(downcast_dense(d.as_ref())),
331                None => zero_x_for(&space_x, lr_w),
332            }
333        } else {
334            zero_x_for(&space_x, lr_w)
335        };
336
337        let wdiag_diag: Rc<dyn Vector> = match (lr_w.p_lowrank(), lr_w.reduced_diag()) {
338            (Some(p_lm), true) => {
339                // fullx = P_LM · B0
340                let mut fullx = space_x.make_new_dense();
341                p_lm.mult_vector(1.0, &b0_dense, 0.0, &mut fullx);
342                Rc::new(fullx) as Rc<dyn Vector>
343            }
344            _ => Rc::new(clone_dense(&b0_dense)) as Rc<dyn Vector>,
345        };
346        let mut wdiag = Box::new(DiagMatrix::new(space_x.dim()));
347        wdiag.set_diag(wdiag_diag);
348        self.factor.wdiag = Some(wdiag);
349
350        // 2. SolveMultiVector for V → Vtilde1 = K⁻¹ V (per-block).
351        if coeffs.w_factor == 1.0 && lr_w.get_v().is_some() {
352            let v = Rc::clone(lr_w.get_v().unwrap());
353            let n_v = v.n_cols();
354
355            // Build V_x: each column is either V[:,k] directly (no P_LM)
356            // or P_LM · V[:,k]. We need V_x for the M1 update; we keep
357            // it on the stack here.
358            let v_x_space = MultiVectorMatrixSpace::new(n_v, Rc::clone(&space_x));
359            let mut v_x = v_x_space.make_new_multi_vector();
360            for k in 0..n_v {
361                let vk = Rc::clone(v.get_vector(k));
362                let rhs_x_k: Rc<dyn Vector> = match lr_w.p_lowrank() {
363                    Some(p_lm) => {
364                        let mut fullx = space_x.make_new_dense();
365                        p_lm.mult_vector(1.0, vk.as_ref(), 0.0, &mut fullx);
366                        Rc::new(fullx) as Rc<dyn Vector>
367                    }
368                    None => vk,
369                };
370                v_x.set_vector(k, rhs_x_k);
371            }
372
373            let (vt_x, vt_s, vt_c, vt_d) = self.multi_solve_block(
374                &v_x,
375                coeffs,
376                &space_x,
377                &space_s,
378                &space_c,
379                &space_d,
380                check_neg_evals,
381                num_neg_evals,
382            );
383
384            let vt_x = match vt_x {
385                Ok(x) => x,
386                Err(status) => return status,
387            };
388
389            // 3. M1 = I + Vtilde1_x^T · V_x; J1 = chol(M1).
390            let m1_space = DenseSymMatrixSpace::new(n_v);
391            let mut m1 = m1_space.make_new_dense_sym();
392            m1.fill_identity(1.0);
393            m1.high_rank_update_transpose(1.0, &vt_x, &v_x, 1.0);
394            let j1_space = DenseGenMatrixSpace::new(n_v, n_v);
395            let mut j1 = j1_space.make_new_dense_gen();
396            if !j1.compute_cholesky_factor(&m1) {
397                self.num_neg_evals += 1;
398                return ESymSolverStatus::WrongInertia;
399            }
400            self.factor.vtilde1_x = Some(vt_x);
401            self.factor.vtilde1_s = Some(vt_s);
402            self.factor.vtilde1_c = Some(vt_c);
403            self.factor.vtilde1_d = Some(vt_d);
404            self.factor.j1 = Some(j1);
405        } else {
406            self.factor.vtilde1_x = None;
407            self.factor.vtilde1_s = None;
408            self.factor.vtilde1_c = None;
409            self.factor.vtilde1_d = None;
410            self.factor.j1 = None;
411        }
412
413        // 4. SolveMultiVector for U → Utilde1 = K⁻¹ U; orthogonalize
414        //    against Vtilde1 (if present) to get Utilde2.
415        if coeffs.w_factor == 1.0 && lr_w.get_u().is_some() {
416            let u = Rc::clone(lr_w.get_u().unwrap());
417            let n_u = u.n_cols();
418
419            let u_x_space = MultiVectorMatrixSpace::new(n_u, Rc::clone(&space_x));
420            let mut u_x = u_x_space.make_new_multi_vector();
421            for k in 0..n_u {
422                let uk = Rc::clone(u.get_vector(k));
423                let rhs_x_k: Rc<dyn Vector> = match lr_w.p_lowrank() {
424                    Some(p_lm) => {
425                        let mut fullx = space_x.make_new_dense();
426                        p_lm.mult_vector(1.0, uk.as_ref(), 0.0, &mut fullx);
427                        Rc::new(fullx) as Rc<dyn Vector>
428                    }
429                    None => uk,
430                };
431                u_x.set_vector(k, rhs_x_k);
432            }
433
434            let (mut ut_x, mut ut_s, mut ut_c, mut ut_d) = match self.multi_solve_block(
435                &u_x,
436                coeffs,
437                &space_x,
438                &space_s,
439                &space_c,
440                &space_d,
441                check_neg_evals,
442                num_neg_evals,
443            ) {
444                (Ok(x), s, c, d) => (x, s, c, d),
445                (Err(status), _, _, _) => return status,
446            };
447
448            // 5. If Vtilde1 is present: Utilde2 = Utilde1 − Vtilde1 · (J1⁻¹J1⁻ᵀ · Vtilde1ᵀU).
449            if self.factor.vtilde1_x.is_some() {
450                let vt1_x = self.factor.vtilde1_x.as_ref().unwrap();
451                let vt1_s = self.factor.vtilde1_s.as_ref().unwrap();
452                let vt1_c = self.factor.vtilde1_c.as_ref().unwrap();
453                let vt1_d = self.factor.vtilde1_d.as_ref().unwrap();
454                let n_v = vt1_x.n_cols();
455                // C = Vtilde1_x^T · U_x  (n_v × n_u; HighRankUpdateTranspose's
456                // generic-matrix variant — we synthesize via column dot products
457                // since DenseGenMatrix doesn't expose a high_rank_update_transpose).
458                let c_space = DenseGenMatrixSpace::new(n_v, n_u);
459                let mut c_mat = c_space.make_new_dense_gen();
460                {
461                    let cv = c_mat.values_mut();
462                    for j in 0..n_u as usize {
463                        let uj = u_x.get_vector(j as Index).as_ref();
464                        for i in 0..n_v as usize {
465                            let vi = vt1_x.get_vector(i as Index).as_ref();
466                            cv[i + j * n_v as usize] = vi.dot(uj);
467                        }
468                    }
469                }
470                self.factor
471                    .j1
472                    .as_ref()
473                    .unwrap()
474                    .cholesky_solve_matrix(&mut c_mat);
475                ut_x.add_right_mult_matrix(-1.0, vt1_x, &c_mat, 1.0);
476                ut_s.add_right_mult_matrix(-1.0, vt1_s, &c_mat, 1.0);
477                ut_c.add_right_mult_matrix(-1.0, vt1_c, &c_mat, 1.0);
478                ut_d.add_right_mult_matrix(-1.0, vt1_d, &c_mat, 1.0);
479            }
480
481            // 6. M2 = I − Utilde2_x^T · U_x; J2 = chol(M2). A non-positive
482            //    pivot means the `−UUᵀ` correction drove the reduced
483            //    Hessian indefinite: a genuine wrong-inertia signal that
484            //    the perturbation handler should act on.
485            let m2_space = DenseSymMatrixSpace::new(n_u);
486            let mut m2 = m2_space.make_new_dense_sym();
487            m2.fill_identity(1.0);
488            m2.high_rank_update_transpose(-1.0, &ut_x, &u_x, 1.0);
489            let j2_space = DenseGenMatrixSpace::new(n_u, n_u);
490            let mut j2 = j2_space.make_new_dense_gen();
491            if !j2.compute_cholesky_factor(&m2) {
492                self.num_neg_evals += 1;
493                return ESymSolverStatus::WrongInertia;
494            }
495            self.factor.utilde2_x = Some(ut_x);
496            self.factor.utilde2_s = Some(ut_s);
497            self.factor.utilde2_c = Some(ut_c);
498            self.factor.utilde2_d = Some(ut_d);
499            self.factor.j2 = Some(j2);
500        } else {
501            self.factor.utilde2_x = None;
502            self.factor.utilde2_s = None;
503            self.factor.utilde2_c = None;
504            self.factor.utilde2_d = None;
505            self.factor.j2 = None;
506        }
507
508        ESymSolverStatus::Success
509    }
510
511    /// Solve `K · Vtilde = [V_x; 0; 0; 0]` for one block of right-hand
512    /// sides packed in `v_x` (dense column-by-column). Returns the four
513    /// per-block columns of `Vtilde`. Mirrors the inner loop of
514    /// upstream `SolveMultiVector` (`IpLowRankAugSystemSolver.cpp:406-528`).
515    #[allow(clippy::too_many_arguments)]
516    fn multi_solve_block(
517        &mut self,
518        v_x: &MultiVectorMatrix,
519        coeffs: &AugSysCoeffs<'_>,
520        space_x: &Rc<DenseVectorSpace>,
521        space_s: &Rc<DenseVectorSpace>,
522        space_c: &Rc<DenseVectorSpace>,
523        space_d: &Rc<DenseVectorSpace>,
524        check_neg_evals: bool,
525        num_neg_evals: Index,
526    ) -> (
527        Result<MultiVectorMatrix, ESymSolverStatus>,
528        MultiVectorMatrix,
529        MultiVectorMatrix,
530        MultiVectorMatrix,
531    ) {
532        let n_cols = v_x.n_cols();
533        let n_cols_us = n_cols as usize;
534
535        // Allocate four per-block result MVMs.
536        let mut out_x =
537            MultiVectorMatrixSpace::new(n_cols, Rc::clone(space_x)).make_new_multi_vector();
538        let mut out_s =
539            MultiVectorMatrixSpace::new(n_cols, Rc::clone(space_s)).make_new_multi_vector();
540        let mut out_c =
541            MultiVectorMatrixSpace::new(n_cols, Rc::clone(space_c)).make_new_multi_vector();
542        let mut out_d =
543            MultiVectorMatrixSpace::new(n_cols, Rc::clone(space_d)).make_new_multi_vector();
544        out_x.fill_with_new_vectors();
545        out_s.fill_with_new_vectors();
546        out_c.fill_with_new_vectors();
547        out_d.fill_with_new_vectors();
548
549        // Allocate zero RHS slots once; the four columns are reused
550        // because we re-zero per call.
551        let mut rhs_s = space_s.make_new_dense();
552        rhs_s.set(0.0);
553        let mut rhs_c = space_c.make_new_dense();
554        rhs_c.set(0.0);
555        let mut rhs_d = space_d.make_new_dense();
556        rhs_d.set(0.0);
557
558        // Every column here shares one matrix, so only the first one
559        // needs a factorization; the rest are back-substitutions
560        // against it. Batching is the only way `nrhs > 1` reaches the
561        // backend at all — both FERAL (`solve_many_into`) and MA57
562        // (`ma57cd_` with `nrhs`) block the triangular solves, and
563        // neither can do so one column at a time (gh#729).
564        //
565        // Three paths, in preference order:
566        //
567        //  1. cold inner solver, backend affirms bit-identity at
568        //     `n_cols`: one `try_solve_many_flat` — factorize and
569        //     substitute every column together, which is upstream's
570        //     single `MultiSolve` (`IpLowRankAugSystemSolver.cpp:487`);
571        //  2. otherwise: factorize on column 0 through the single-RHS
572        //     path, then batch the remaining columns. Correct, but it
573        //     streams the factor twice;
574        //  3. backend declines the packed path entirely: one
575        //     single-RHS solve per column.
576        //
577        // Who answers the bit-identity gate matters as much as where it
578        // is asked. FERAL answers from a measured width ceiling. MA57
579        // blocks at every width and so declines by default; it takes
580        // paths 1 and 2 only when `ma57_batched_backsolve` is on, which
581        // is a permission the user grants and not a measurement anyone
582        // has made — see `dev-notes/ma57-batched-backsolve.md`.
583        let n_x = space_x.dim() as usize;
584        let n_s = space_s.dim() as usize;
585        let n_c = space_c.dim() as usize;
586        let n_d = space_d.dim() as usize;
587        let dim = n_x + n_s + n_c + n_d;
588
589        // Cold inner solver: factorize and back-substitute every column
590        // in ONE backend call, which is what upstream's single
591        // `MultiSolve` does (`IpLowRankAugSystemSolver.cpp:487`).
592        // Paying the factorization through the single-RHS path and then
593        // batching the rest streams the factor twice — a sparse
594        // triangular solve costs `F + nrhs*W` with `F` several times
595        // `W` on a KKT this size, so the split throws away one `F` per
596        // SMW update. The same bit-identity gate as the warm batch
597        // below applies, at the wider `n_cols_us`.
598        //
599        // Note there is deliberately no `dim == inner.system_dim()`
600        // precondition here, unlike the warm batch below. Cold, the
601        // inner solver has not assembled yet, so its `system_dim()` is
602        // still 0 and that check would reject every first factorization
603        // — silently, leaving the merged path unexercised by any mock
604        // whose `system_dim()` is 0 when cold. `try_solve_many_flat`
605        // assembles first and then declines if the packed length
606        // disagrees, which is the same guard applied where the answer
607        // is actually known.
608        if !self.inner_has_factor
609            && n_cols_us > 1
610            && dim > 0
611            && self.inner.multi_solve_matches_single_solve(n_cols_us)
612        {
613            let mut packed = vec![0.0; dim * n_cols_us];
614            let mut packed_ok = true;
615            for k in 0..n_cols_us {
616                let col = &mut packed[k * dim..k * dim + n_x];
617                if !copy_dense_into(v_x.get_vector(k as Index).as_ref(), col) {
618                    packed_ok = false;
619                    break;
620                }
621                // s/c/d blocks are zero by construction; `packed`
622                // starts zeroed.
623            }
624            if packed_ok {
625                let ic = inner_coeffs(&self.factor, coeffs);
626                if let Some(status) = self.inner.try_solve_many_flat(
627                    &ic,
628                    &mut packed,
629                    n_cols_us,
630                    check_neg_evals,
631                    num_neg_evals,
632                ) {
633                    if self.inner.provides_inertia() {
634                        self.num_neg_evals = self.inner.number_of_neg_evals();
635                    }
636                    if status != ESymSolverStatus::Success {
637                        self.inner_has_factor = false;
638                        self.inner_factor_neg_evals = None;
639                        return (Err(status), out_s, out_c, out_d);
640                    }
641                    self.inner_has_factor = true;
642                    self.inner_factor_neg_evals = check_neg_evals.then_some(num_neg_evals);
643                    for k in 0..n_cols_us {
644                        let col = &packed[k * dim..(k + 1) * dim];
645                        let mut sol_x = space_x.make_new_dense();
646                        let mut sol_s = space_s.make_new_dense();
647                        let mut sol_c = space_c.make_new_dense();
648                        let mut sol_d = space_d.make_new_dense();
649                        sol_x.set_values(&col[..n_x]);
650                        sol_s.set_values(&col[n_x..n_x + n_s]);
651                        sol_c.set_values(&col[n_x + n_s..n_x + n_s + n_c]);
652                        sol_d.set_values(&col[n_x + n_s + n_c..]);
653                        out_x.set_vector(k as Index, Rc::new(sol_x) as Rc<dyn Vector>);
654                        out_s.set_vector(k as Index, Rc::new(sol_s) as Rc<dyn Vector>);
655                        out_c.set_vector(k as Index, Rc::new(sol_c) as Rc<dyn Vector>);
656                        out_d.set_vector(k as Index, Rc::new(sol_d) as Rc<dyn Vector>);
657                    }
658                    return (Ok(out_x), out_s, out_c, out_d);
659                }
660            }
661        }
662
663        let mut k0 = 0usize;
664        if !self.inner_has_factor && n_cols_us > 0 {
665            let rhs_x_dyn: &dyn Vector = v_x.get_vector(0).as_ref();
666            match self.solve_one_column(
667                rhs_x_dyn,
668                &rhs_s,
669                &rhs_c,
670                &rhs_d,
671                coeffs,
672                space_x,
673                space_s,
674                space_c,
675                space_d,
676                check_neg_evals,
677                num_neg_evals,
678            ) {
679                Ok((sol_x, sol_s, sol_c, sol_d)) => {
680                    out_x.set_vector(0, Rc::new(sol_x) as Rc<dyn Vector>);
681                    out_s.set_vector(0, Rc::new(sol_s) as Rc<dyn Vector>);
682                    out_c.set_vector(0, Rc::new(sol_c) as Rc<dyn Vector>);
683                    out_d.set_vector(0, Rc::new(sol_d) as Rc<dyn Vector>);
684                }
685                Err(status) => return (Err(status), out_s, out_c, out_d),
686            }
687            k0 = 1;
688        }
689
690        // Batched back-substitution for columns `k0..n_cols`. Declines
691        // (leaving `k0` untouched for the loop below) when the inner
692        // solver does not expose the packed path, does not report a
693        // dimension, or hands us a column we cannot read as a dense
694        // slice.
695        if k0 < n_cols_us {
696            let nrhs = n_cols_us - k0;
697            // The batch is a pure time optimization: these columns feed
698            // the SMW correction of an iterate whose trajectory must not
699            // move. A backend whose blocked substitution reassociates
700            // returns a tolerance-equal but different answer, and on a
701            // nonconvex problem that is enough to select a different local
702            // optimum — MA57 takes `pooling_rt2stp` to an objective 25%
703            // worse while still reporting `Optimal Solution Found` (gh#729).
704            // So the backend has to affirm bit-identity at this width, and
705            // the default answer is no.
706            if nrhs > 1
707                && dim > 0
708                && dim == self.inner.system_dim() as usize
709                && self.inner.multi_solve_matches_single_solve(nrhs)
710            {
711                let mut packed = vec![0.0; dim * nrhs];
712                let mut packed_ok = true;
713                for (j, k) in (k0..n_cols_us).enumerate() {
714                    let col = &mut packed[j * dim..j * dim + n_x];
715                    if !copy_dense_into(v_x.get_vector(k as Index).as_ref(), col) {
716                        packed_ok = false;
717                        break;
718                    }
719                    // The s/c/d blocks of the RHS are zero by
720                    // construction, and `packed` starts zeroed.
721                }
722                if packed_ok {
723                    let ic = inner_coeffs(&self.factor, coeffs);
724                    if let Some(status) = self.inner.try_resolve_many_flat(&ic, &mut packed, nrhs) {
725                        if self.inner.provides_inertia() {
726                            self.num_neg_evals = self.inner.number_of_neg_evals();
727                        }
728                        if status != ESymSolverStatus::Success {
729                            self.inner_has_factor = false;
730                            self.inner_factor_neg_evals = None;
731                            return (Err(status), out_s, out_c, out_d);
732                        }
733                        for (j, k) in (k0..n_cols_us).enumerate() {
734                            let col = &packed[j * dim..(j + 1) * dim];
735                            let mut sol_x = space_x.make_new_dense();
736                            let mut sol_s = space_s.make_new_dense();
737                            let mut sol_c = space_c.make_new_dense();
738                            let mut sol_d = space_d.make_new_dense();
739                            sol_x.set_values(&col[..n_x]);
740                            sol_s.set_values(&col[n_x..n_x + n_s]);
741                            sol_c.set_values(&col[n_x + n_s..n_x + n_s + n_c]);
742                            sol_d.set_values(&col[n_x + n_s + n_c..]);
743                            out_x.set_vector(k as Index, Rc::new(sol_x) as Rc<dyn Vector>);
744                            out_s.set_vector(k as Index, Rc::new(sol_s) as Rc<dyn Vector>);
745                            out_c.set_vector(k as Index, Rc::new(sol_c) as Rc<dyn Vector>);
746                            out_d.set_vector(k as Index, Rc::new(sol_d) as Rc<dyn Vector>);
747                        }
748                        return (Ok(out_x), out_s, out_c, out_d);
749                    }
750                }
751            }
752        }
753
754        // Fallback: one single-RHS back-substitution per column.
755        for k in k0..n_cols_us {
756            let rhs_x_dyn: &dyn Vector = v_x.get_vector(k as Index).as_ref();
757            match self.solve_one_column(
758                rhs_x_dyn,
759                &rhs_s,
760                &rhs_c,
761                &rhs_d,
762                coeffs,
763                space_x,
764                space_s,
765                space_c,
766                space_d,
767                check_neg_evals,
768                num_neg_evals,
769            ) {
770                Ok((sol_x, sol_s, sol_c, sol_d)) => {
771                    out_x.set_vector(k as Index, Rc::new(sol_x) as Rc<dyn Vector>);
772                    out_s.set_vector(k as Index, Rc::new(sol_s) as Rc<dyn Vector>);
773                    out_c.set_vector(k as Index, Rc::new(sol_c) as Rc<dyn Vector>);
774                    out_d.set_vector(k as Index, Rc::new(sol_d) as Rc<dyn Vector>);
775                }
776                Err(status) => return (Err(status), out_s, out_c, out_d),
777            }
778        }
779        (Ok(out_x), out_s, out_c, out_d)
780    }
781
782    /// One column of [`Self::multi_solve_block`] through the inner
783    /// solver's single-RHS path: `solve` (factorize) when the inner
784    /// solver is cold, `resolve` (back-substitute) when it is not.
785    /// Carries the inertia and factor bookkeeping either way, so the
786    /// batched path and the fallback loop agree on solver state.
787    #[allow(clippy::too_many_arguments)]
788    fn solve_one_column(
789        &mut self,
790        rhs_x: &dyn Vector,
791        rhs_s: &DenseVector,
792        rhs_c: &DenseVector,
793        rhs_d: &DenseVector,
794        coeffs: &AugSysCoeffs<'_>,
795        space_x: &Rc<DenseVectorSpace>,
796        space_s: &Rc<DenseVectorSpace>,
797        space_c: &Rc<DenseVectorSpace>,
798        space_d: &Rc<DenseVectorSpace>,
799        check_neg_evals: bool,
800        num_neg_evals: Index,
801    ) -> Result<(DenseVector, DenseVector, DenseVector, DenseVector), ESymSolverStatus> {
802        let inner_rhs = AugSysRhs {
803            rhs_x,
804            rhs_s: rhs_s.as_dyn_vector(),
805            rhs_c: rhs_c.as_dyn_vector(),
806            rhs_d: rhs_d.as_dyn_vector(),
807        };
808        // Build solution slots (fresh each iteration).
809        let mut sol_x = space_x.make_new_dense();
810        let mut sol_s = space_s.make_new_dense();
811        let mut sol_c = space_c.make_new_dense();
812        let mut sol_d = space_d.make_new_dense();
813        sol_x.set(0.0);
814        sol_s.set(0.0);
815        sol_c.set(0.0);
816        sol_d.set(0.0);
817        let ic = inner_coeffs(&self.factor, coeffs);
818        let reuse = self.inner_has_factor;
819        let status = {
820            let mut sol = AugSysSol {
821                sol_x: &mut sol_x,
822                sol_s: &mut sol_s,
823                sol_c: &mut sol_c,
824                sol_d: &mut sol_d,
825            };
826            if reuse {
827                self.inner.resolve(&ic, &inner_rhs, &mut sol)
828            } else {
829                self.inner
830                    .solve(&ic, &inner_rhs, &mut sol, check_neg_evals, num_neg_evals)
831            }
832        };
833        if self.inner.provides_inertia() {
834            self.num_neg_evals = self.inner.number_of_neg_evals();
835        }
836        if status != ESymSolverStatus::Success {
837            self.inner_has_factor = false;
838            self.inner_factor_neg_evals = None;
839            return Err(status);
840        }
841        if !reuse {
842            self.inner_has_factor = true;
843            self.inner_factor_neg_evals = check_neg_evals.then_some(num_neg_evals);
844        }
845        Ok((sol_x, sol_s, sol_c, sol_d))
846    }
847}
848
849/// Copy a `dyn Vector` block into `dst`, expanding the homogeneous
850/// (single-scalar) representation. Returns `false` — leaving `dst`
851/// untouched — when the block is not a [`DenseVector`] or its length
852/// disagrees, which the batched path in [`multi_solve_block`] treats as
853/// "decline and take the per-column loop" rather than panicking.
854///
855/// [`multi_solve_block`]: LowRankAugSystemSolver::multi_solve_block
856fn copy_dense_into(src: &dyn Vector, dst: &mut [Number]) -> bool {
857    if dst.is_empty() {
858        return true;
859    }
860    let Some(dv) = src.as_any().downcast_ref::<DenseVector>() else {
861        return false;
862    };
863    if dv.dim() as usize != dst.len() {
864        return false;
865    }
866    if dv.is_homogeneous() {
867        let v = dv.scalar();
868        dst.iter_mut().for_each(|x| *x = v);
869    } else {
870        dst.copy_from_slice(dv.values());
871    }
872    true
873}
874
875/// Build inner-solver coefficients that substitute `Wdiag` for `W`.
876/// Free function (rather than method on `LowRankAugSystemSolver`) so
877/// the borrow is on `&Factorization` only — leaving `self.inner`
878/// available for `&mut`.
879fn inner_coeffs<'b>(factor: &'b Factorization, coeffs: &AugSysCoeffs<'b>) -> AugSysCoeffs<'b> {
880    let wdiag: &DiagMatrix = factor.wdiag.as_ref().expect("Wdiag unset").as_ref();
881    AugSysCoeffs {
882        w: Some(wdiag as &dyn SymMatrix),
883        w_factor: 1.0,
884        d_x: coeffs.d_x,
885        delta_x: coeffs.delta_x,
886        d_s: coeffs.d_s,
887        delta_s: coeffs.delta_s,
888        j_c: coeffs.j_c,
889        d_c: coeffs.d_c,
890        delta_c: coeffs.delta_c,
891        j_d: coeffs.j_d,
892        d_d: coeffs.d_d,
893        delta_d: coeffs.delta_d,
894    }
895}
896
897fn downcast_dense(v: &dyn Vector) -> &DenseVector {
898    v.as_any()
899        .downcast_ref::<DenseVector>()
900        .expect("LowRankAugSystemSolver currently requires DenseVector RHS/solutions")
901}
902
903/// `DenseVector` doesn't implement `Clone`; this builds a fresh dense
904/// vector in the same space populated with the same expanded values.
905/// Cheap when the source is homogeneous.
906fn clone_dense(src: &DenseVector) -> DenseVector {
907    let mut out = src.space().make_new_dense();
908    out.set_values(&src.expanded_values());
909    out
910}
911
912fn zero_x_for(space_x: &Rc<DenseVectorSpace>, lr_w: &LowRankUpdateSymMatrix) -> DenseVector {
913    // `MakeNew` either from the LR vector space (when reduced_diag is
914    // active) or from the proto x-space. We don't have the LR vector
915    // space surfaced directly, but B0 lives in either space; passing
916    // None always means "no diag" so we just return a zero in space_x.
917    let _ = lr_w;
918    let mut z = space_x.make_new_dense();
919    z.set(0.0);
920    z
921}
922
923impl AugSystemSolver for LowRankAugSystemSolver {
924    fn provides_inertia(&self) -> bool {
925        self.inner.provides_inertia()
926    }
927
928    fn number_of_neg_evals(&self) -> Index {
929        if self.inner.provides_inertia() {
930            self.inner.number_of_neg_evals()
931        } else {
932            self.num_neg_evals
933        }
934    }
935
936    fn increase_quality(&mut self) -> bool {
937        // The inner solver drops its cached factor here (it re-pivots at
938        // a tighter tolerance), so ours is stale too.
939        self.inner_has_factor = false;
940        self.inner_factor_neg_evals = None;
941        // Escalate both: a caller asking for tighter pivoting wants it on
942        // the next solve whichever path that takes, and the two solvers
943        // hold independent backend state.
944        let inner = self.inner.increase_quality();
945        let bypass = self
946            .bypass
947            .as_deref_mut()
948            .map(|b| b.increase_quality())
949            .unwrap_or(false);
950        inner || bypass
951    }
952
953    fn last_solve_status(&self) -> ESymSolverStatus {
954        // Whichever solver actually ran last — otherwise a bypass solve
955        // reports the stale status of the previous main solve.
956        match (self.last_solve_took_bypass, self.bypass.as_deref()) {
957            (true, Some(bypass)) => bypass.last_solve_status(),
958            _ => self.inner.last_solve_status(),
959        }
960    }
961
962    fn set_timing_stats(&mut self, timing: Rc<TimingStatistics>) {
963        // Both, or the bypass solver's symbolic and factorization time
964        // lands in no phase at all — which is precisely the row gh#730
965        // is about.
966        if let Some(bypass) = self.bypass.as_deref_mut() {
967            bypass.set_timing_stats(Rc::clone(&timing));
968        }
969        self.inner.set_timing_stats(timing);
970    }
971
972    fn set_slack_scaling(&mut self, nx: Index, s_scale: &[Number]) {
973        // Both: the bypass solver assembles the same (2,2) slack block
974        // and would otherwise scale it differently from the main path.
975        if let Some(bypass) = self.bypass.as_deref_mut() {
976            bypass.set_slack_scaling(nx, s_scale);
977        }
978        self.inner.set_slack_scaling(nx, s_scale);
979    }
980
981    fn handles_low_rank_w(&self) -> bool {
982        true
983    }
984
985    fn solve(
986        &mut self,
987        coeffs: &AugSysCoeffs<'_>,
988        rhs: &AugSysRhs<'_>,
989        sol: &mut AugSysSol<'_>,
990        check_neg_evals: bool,
991        num_neg_evals: Index,
992    ) -> ESymSolverStatus {
993        // Skip inertia checks when the inner solver doesn't provide
994        // them — mirrors `IpLowRankAugSystemSolver.cpp:102-105`.
995        let mut check_neg_evals = check_neg_evals;
996        if !self.inner.provides_inertia() {
997            check_neg_evals = false;
998        }
999
1000        // Hessian-free / non-low-rank W: the least-square-multiplier
1001        // initialization (`init`) and the equality-multiplier estimates
1002        // (`eq_mult`) drive this same solver with their own zero W block
1003        // and `w_factor = 0` — there is no low-rank update to apply, so
1004        // bypass the SMW machinery and solve directly through the inner
1005        // augmented-system solver with the original coefficients.
1006        // `data.w` only carries a `LowRankUpdateSymMatrix` for the main
1007        // primal-dual solves (always `w_factor = 1`).
1008        let lr_w_opt = coeffs
1009            .w
1010            .and_then(|w| w.as_any().downcast_ref::<LowRankUpdateSymMatrix>());
1011        let Some(lr_w) = lr_w_opt else {
1012            // The inner solver is about to factor a *different* matrix
1013            // (the caller's own W, not our `Wdiag` substitution), so any
1014            // cached SMW factor state no longer describes what it holds.
1015            // Only invalidate the shared-solver cache when the solve is
1016            // actually about to land on `self.inner`. With a dedicated
1017            // bypass solver `self.inner` keeps holding a valid factor of
1018            // the `Wdiag`-substituted system, and dropping it here would
1019            // force a needless refactorization on the next main solve.
1020            self.last_solve_took_bypass = true;
1021            let target = match self.bypass.as_deref_mut() {
1022                Some(bypass) => bypass,
1023                None => {
1024                    // The inner solver is about to factor a *different*
1025                    // matrix (the caller's own W, not our `Wdiag`
1026                    // substitution), so any cached SMW factor state no
1027                    // longer describes what it holds.
1028                    self.inner_has_factor = false;
1029                    self.inner_factor_neg_evals = None;
1030                    self.inner.as_mut()
1031                }
1032            };
1033            let status = target.solve(coeffs, rhs, sol, check_neg_evals, num_neg_evals);
1034            if target.provides_inertia() {
1035                self.num_neg_evals = target.number_of_neg_evals();
1036            }
1037            return status;
1038        };
1039
1040        self.last_solve_took_bypass = false;
1041
1042        let needs_rebuild = self.first_call || self.augmented_system_requires_change(coeffs);
1043        if needs_rebuild {
1044            let status =
1045                self.update_factorization(lr_w, coeffs, rhs, check_neg_evals, num_neg_evals);
1046            if status != ESymSolverStatus::Success {
1047                return status;
1048            }
1049            self.store_cache(coeffs);
1050            self.first_call = false;
1051        }
1052
1053        // 1. Diagonal solve through the inner aug-system solver. When we
1054        //    already hold a factor of this exact matrix — the rebuild
1055        //    above just produced one while solving for the SMW columns,
1056        //    or nothing has changed since the last call — this is a
1057        //    back-substitution rather than a fresh factorization.
1058        //
1059        //    Skipping the factorization also skips the inertia check that
1060        //    goes with it, so only take the fast path when the cached
1061        //    factor was already validated against the same target. In
1062        //    practice it always has been: a rebuild runs its first column
1063        //    with this call's own `check_neg_evals`/`num_neg_evals` and
1064        //    bails on `WrongInertia` before reaching here.
1065        let reuse = self.inner_has_factor
1066            && (!check_neg_evals || self.inner_factor_neg_evals == Some(num_neg_evals));
1067        let ic = inner_coeffs(&self.factor, coeffs);
1068        let status = if reuse {
1069            self.inner.resolve(&ic, rhs, sol)
1070        } else {
1071            self.inner
1072                .solve(&ic, rhs, sol, check_neg_evals, num_neg_evals)
1073        };
1074        if self.inner.provides_inertia() {
1075            self.num_neg_evals = self.inner.number_of_neg_evals();
1076        }
1077        if status != ESymSolverStatus::Success {
1078            self.inner_has_factor = false;
1079            self.inner_factor_neg_evals = None;
1080            return status;
1081        }
1082        if !reuse {
1083            self.inner_has_factor = true;
1084            self.inner_factor_neg_evals = check_neg_evals.then_some(num_neg_evals);
1085        }
1086
1087        // 2. SMW correction terms — mirror upstream's order:
1088        //    apply Utilde2 first, then Vtilde1 (cpp:210-227).
1089        if self.factor.utilde2_x.is_some() {
1090            self.apply_smw(/*sign=*/ 1.0, /*use_u=*/ true, rhs, sol);
1091        }
1092        if self.factor.vtilde1_x.is_some() {
1093            self.apply_smw(/*sign=*/ -1.0, /*use_u=*/ false, rhs, sol);
1094        }
1095
1096        ESymSolverStatus::Success
1097    }
1098
1099    /// Back-substitution against the cached factor, plus the same SMW
1100    /// corrections `solve` applies.
1101    ///
1102    /// Without this override the trait default falls through to `solve`,
1103    /// which re-factorizes — and because `PdFullSpaceSolver`'s iterative
1104    /// refinement and its same-matrix fast path both come in through
1105    /// `resolve`, that made the majority of the augmented-system solves
1106    /// on the limited-memory path re-factorize a matrix that had not
1107    /// changed. It also left `LinearSystemBackSolve` reading 0.000 s for
1108    /// a whole run, since the only back-solve timer guard lives on the
1109    /// path nothing reached (gh#698).
1110    ///
1111    /// Every condition that cannot be served falls back to `solve`, so
1112    /// this can lose an optimization but cannot change an answer — the
1113    /// same defensive shape as `StdAugSystemSolver::resolve`'s own
1114    /// `have_factor` fallback.
1115    fn resolve(
1116        &mut self,
1117        coeffs: &AugSysCoeffs<'_>,
1118        rhs: &AugSysRhs<'_>,
1119        sol: &mut AugSysSol<'_>,
1120    ) -> ESymSolverStatus {
1121        // The fast path is only valid for a low-rank W whose SMW
1122        // factorization we hold and whose coefficients have not moved
1123        // since we built it. `first_call` additionally guarantees
1124        // `self.factor.wdiag` is populated for `inner_coeffs`.
1125        //
1126        // The non-low-rank bypass deliberately falls through to `solve`
1127        // rather than forwarding to `inner.resolve`: on that path the
1128        // inner solver's factor is of the caller's own W, which our
1129        // tag cache does not track, so we cannot certify it here.
1130        let is_low_rank = coeffs
1131            .w
1132            .and_then(|w| w.as_any().downcast_ref::<LowRankUpdateSymMatrix>())
1133            .is_some();
1134        if !is_low_rank
1135            || self.first_call
1136            || !self.inner_has_factor
1137            || self.augmented_system_requires_change(coeffs)
1138        {
1139            return self.solve(coeffs, rhs, sol, false, 0);
1140        }
1141
1142        let ic = inner_coeffs(&self.factor, coeffs);
1143        let status = self.inner.resolve(&ic, rhs, sol);
1144        if status != ESymSolverStatus::Success {
1145            self.inner_has_factor = false;
1146            self.inner_factor_neg_evals = None;
1147            return status;
1148        }
1149
1150        // Same correction order as `solve` (cpp:210-227).
1151        if self.factor.utilde2_x.is_some() {
1152            self.apply_smw(/*sign=*/ 1.0, /*use_u=*/ true, rhs, sol);
1153        }
1154        if self.factor.vtilde1_x.is_some() {
1155            self.apply_smw(/*sign=*/ -1.0, /*use_u=*/ false, rhs, sol);
1156        }
1157
1158        ESymSolverStatus::Success
1159    }
1160
1161    // `try_resolve_many_flat` is deliberately **not** overridden here.
1162    //
1163    // It hands back a raw `K⁻¹`-applied result that the caller
1164    // (`PdFullSpaceSolver::solve_many_cached`) unpacks and uses directly,
1165    // with no hook to apply the SMW correction afterwards. Our operator
1166    // is `(K + low-rank)⁻¹`, so forwarding the flat path to the inner
1167    // solver would silently drop the correction on every column and
1168    // return a plausible, wrong solution under a `Success` status.
1169    //
1170    // The trait default returns `None`, which the caller documents as
1171    // "fast path not taken, fall back to looping `solve`" — correct, and
1172    // the only safe answer for this wrapper unless the packed path grows
1173    // a way to post-process each column.
1174}
1175
1176impl LowRankAugSystemSolver {
1177    /// Apply one SMW correction step:
1178    ///   `b = U_or_Vᵀ · rhs;  J⁻¹J⁻ᵀ b;  sol += sign · U_or_V · b`
1179    ///
1180    /// `use_u = true` selects `(Utilde2, J2, +1)`; `false` selects
1181    /// `(Vtilde1, J1, −1)` (sign passed in by caller).
1182    fn apply_smw(&self, sign: Number, use_u: bool, rhs: &AugSysRhs<'_>, sol: &mut AugSysSol<'_>) {
1183        let (mvx, mvs, mvc, mvd, j) = if use_u {
1184            (
1185                self.factor.utilde2_x.as_ref().unwrap(),
1186                self.factor.utilde2_s.as_ref().unwrap(),
1187                self.factor.utilde2_c.as_ref().unwrap(),
1188                self.factor.utilde2_d.as_ref().unwrap(),
1189                self.factor.j2.as_ref().unwrap(),
1190            )
1191        } else {
1192            (
1193                self.factor.vtilde1_x.as_ref().unwrap(),
1194                self.factor.vtilde1_s.as_ref().unwrap(),
1195                self.factor.vtilde1_c.as_ref().unwrap(),
1196                self.factor.vtilde1_d.as_ref().unwrap(),
1197                self.factor.j1.as_ref().unwrap(),
1198            )
1199        };
1200        let n = mvx.n_cols();
1201        // Build `b = M^T · crhs` from the four blocks. Reduction order
1202        // matches upstream's CompoundVector dot, which iterates blocks
1203        // in the order x, s, c, d (`IpCompoundVector.cpp::Dot`).
1204        let mut b_vec: Vec<Number> = Vec::with_capacity(n as usize);
1205        for k in 0..n {
1206            let dot = mvx.get_vector(k).dot(rhs.rhs_x)
1207                + mvs.get_vector(k).dot(rhs.rhs_s)
1208                + mvc.get_vector(k).dot(rhs.rhs_c)
1209                + mvd.get_vector(k).dot(rhs.rhs_d);
1210            b_vec.push(dot);
1211        }
1212        let space_b = DenseVectorSpace::new(n);
1213        let mut b = space_b.make_new_dense();
1214        b.set_values(&b_vec);
1215        // Apply J⁻¹ J⁻ᵀ in-place.
1216        j.cholesky_solve_vector(&mut b);
1217        // sol += sign · M · b  per block.
1218        mvx.mult_vector(sign, &b, 1.0, sol.sol_x);
1219        mvs.mult_vector(sign, &b, 1.0, sol.sol_s);
1220        mvc.mult_vector(sign, &b, 1.0, sol.sol_c);
1221        mvd.mult_vector(sign, &b, 1.0, sol.sol_d);
1222    }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228    use pounce_linalg::dense_vector::DenseVectorSpace;
1229    use pounce_linalg::low_rank_update_sym_matrix::LowRankUpdateSymMatrixSpace;
1230    use std::cell::{Cell, RefCell};
1231
1232    /// Diagonal-solve stub: pretends the augmented system is just
1233    /// `(W + δ_x I) · sol_x = rhs_x` with `m_c = m_d = n_s = 0`. Reads
1234    /// `coeffs.w` as a `DiagMatrix` (i.e. the wdiag we built) and does
1235    /// a per-element divide. Plenty for the SMW test fixture.
1236    struct DiagInner {
1237        calls: Cell<usize>,
1238    }
1239    impl AugSystemSolver for DiagInner {
1240        fn provides_inertia(&self) -> bool {
1241            true
1242        }
1243        fn number_of_neg_evals(&self) -> Index {
1244            0
1245        }
1246        fn increase_quality(&mut self) -> bool {
1247            true
1248        }
1249        fn last_solve_status(&self) -> ESymSolverStatus {
1250            ESymSolverStatus::Success
1251        }
1252        fn solve(
1253            &mut self,
1254            coeffs: &AugSysCoeffs<'_>,
1255            rhs: &AugSysRhs<'_>,
1256            sol: &mut AugSysSol<'_>,
1257            _check_neg_evals: bool,
1258            _num_neg_evals: Index,
1259        ) -> ESymSolverStatus {
1260            self.calls.set(self.calls.get() + 1);
1261            let wdiag = coeffs
1262                .w
1263                .expect("DiagInner requires W")
1264                .as_any()
1265                .downcast_ref::<DiagMatrix>()
1266                .expect("DiagInner requires W to be a DiagMatrix");
1267            let diag_rc = wdiag.get_diag().expect("Wdiag has no diag set").clone();
1268            let diag = downcast_dense(diag_rc.as_ref()).expanded_values();
1269            let rhs_x = downcast_dense(rhs.rhs_x).expanded_values();
1270            let dx_vals: Option<Vec<Number>> =
1271                coeffs.d_x.map(|d| downcast_dense(d).expanded_values());
1272            let mut out = vec![0.0; rhs_x.len()];
1273            for i in 0..rhs_x.len() {
1274                let dx_i = match &dx_vals {
1275                    Some(v) => v[i],
1276                    None => 0.0,
1277                };
1278                let denom = diag[i] + dx_i + coeffs.delta_x;
1279                out[i] = rhs_x[i] / denom;
1280            }
1281            let sol_x_dv = sol
1282                .sol_x
1283                .as_any_mut()
1284                .downcast_mut::<DenseVector>()
1285                .unwrap();
1286            sol_x_dv.set_values(&out);
1287            // Other blocks stay zero — fixture has m_c = m_d = n_s = 0.
1288            ESymSolverStatus::Success
1289        }
1290    }
1291
1292    /// Inner mock that models `StdAugSystemSolver`'s factor / back-solve
1293    /// split, which `DiagInner` above does not: `solve` "factorizes" by
1294    /// capturing the diagonal it was handed, and `resolve` back-solves
1295    /// against whatever was captured last.
1296    ///
1297    /// `resolve` deliberately ignores the coefficients it is passed and
1298    /// uses the cached ones. That is what a real cached factorization
1299    /// does, and it means a wrapper that back-solves against a stale
1300    /// factor produces a visibly *wrong answer* here, not merely a wrong
1301    /// count.
1302    #[derive(Default)]
1303    struct InnerStats {
1304        factorizations: Cell<usize>,
1305        backsolves: Cell<usize>,
1306        batched_calls: Cell<usize>,
1307        batched_cols: Cell<usize>,
1308        /// Calls to the *factorizing* multi-RHS path, and the columns
1309        /// they carried. Separate from `batched_calls` so a test can
1310        /// tell "one merged call" from "a factorization plus a batch".
1311        factor_batched_calls: Cell<usize>,
1312        factor_batched_cols: Cell<usize>,
1313        factored_diag: RefCell<Vec<Number>>,
1314        factored_delta_x: Cell<Number>,
1315    }
1316
1317    struct CountingInner {
1318        stats: Rc<InnerStats>,
1319        /// When set, the mock exposes `system_dim` / `try_resolve_many_flat`
1320        /// the way `StdAugSystemSolver` does, so the batched arm of
1321        /// `multi_solve_block` is reachable. Off by default: the trait
1322        /// default `system_dim() == 0` is what a mock without the packed
1323        /// path looks like, and that is the arm the other tests exercise.
1324        packed: bool,
1325    }
1326
1327    impl CountingInner {
1328        /// Returns the mock and a handle on its counters, so the test can
1329        /// read them after the solver has taken ownership of the box.
1330        fn new() -> (Self, Rc<InnerStats>) {
1331            let stats = Rc::new(InnerStats::default());
1332            (
1333                Self {
1334                    stats: Rc::clone(&stats),
1335                    packed: false,
1336                },
1337                stats,
1338            )
1339        }
1340
1341        /// Same mock, but advertising the packed multi-RHS back-solve.
1342        fn with_packed_path() -> (Self, Rc<InnerStats>) {
1343            let stats = Rc::new(InnerStats::default());
1344            (
1345                Self {
1346                    stats: Rc::clone(&stats),
1347                    packed: true,
1348                },
1349                stats,
1350            )
1351        }
1352
1353        /// `sol_x = rhs_x / (diag + delta_x)`, from the captured factor.
1354        fn apply_cached(&self, rhs: &AugSysRhs<'_>, sol: &mut AugSysSol<'_>) {
1355            let rhs_x = downcast_dense(rhs.rhs_x).expanded_values();
1356            let diag = self.stats.factored_diag.borrow();
1357            let delta_x = self.stats.factored_delta_x.get();
1358            let out: Vec<Number> = (0..rhs_x.len())
1359                .map(|i| rhs_x[i] / (diag[i] + delta_x))
1360                .collect();
1361            sol.sol_x
1362                .as_any_mut()
1363                .downcast_mut::<DenseVector>()
1364                .unwrap()
1365                .set_values(&out);
1366        }
1367    }
1368
1369    impl AugSystemSolver for CountingInner {
1370        fn provides_inertia(&self) -> bool {
1371            false
1372        }
1373        fn number_of_neg_evals(&self) -> Index {
1374            0
1375        }
1376        fn increase_quality(&mut self) -> bool {
1377            false
1378        }
1379        fn last_solve_status(&self) -> ESymSolverStatus {
1380            ESymSolverStatus::Success
1381        }
1382        fn solve(
1383            &mut self,
1384            coeffs: &AugSysCoeffs<'_>,
1385            rhs: &AugSysRhs<'_>,
1386            sol: &mut AugSysSol<'_>,
1387            _check_neg_evals: bool,
1388            _num_neg_evals: Index,
1389        ) -> ESymSolverStatus {
1390            self.stats
1391                .factorizations
1392                .set(self.stats.factorizations.get() + 1);
1393            let wdiag = coeffs
1394                .w
1395                .expect("CountingInner requires W")
1396                .as_any()
1397                .downcast_ref::<DiagMatrix>()
1398                .expect("CountingInner requires W to be a DiagMatrix");
1399            let diag_rc = wdiag.get_diag().expect("Wdiag has no diag set").clone();
1400            *self.stats.factored_diag.borrow_mut() =
1401                downcast_dense(diag_rc.as_ref()).expanded_values();
1402            self.stats.factored_delta_x.set(coeffs.delta_x);
1403            self.apply_cached(rhs, sol);
1404            ESymSolverStatus::Success
1405        }
1406        fn resolve(
1407            &mut self,
1408            _coeffs: &AugSysCoeffs<'_>,
1409            rhs: &AugSysRhs<'_>,
1410            sol: &mut AugSysSol<'_>,
1411        ) -> ESymSolverStatus {
1412            assert!(
1413                !self.stats.factored_diag.borrow().is_empty(),
1414                "resolve reached with no cached factor"
1415            );
1416            self.stats.backsolves.set(self.stats.backsolves.get() + 1);
1417            self.apply_cached(rhs, sol);
1418            ESymSolverStatus::Success
1419        }
1420        fn system_dim(&self) -> Index {
1421            if self.packed {
1422                self.stats.factored_diag.borrow().len() as Index
1423            } else {
1424                0
1425            }
1426        }
1427        /// Mirrors `StdAugSystemSolver`: declines when cold, otherwise
1428        /// applies the cached factor to every packed column in place.
1429        /// Like the real one it ignores the coefficients it is handed and
1430        /// uses the cached ones, so back-solving against a stale factor
1431        /// shows up as a wrong answer rather than a wrong count.
1432        fn multi_solve_matches_single_solve(&self, _nrhs: usize) -> bool {
1433            self.packed
1434        }
1435
1436        /// The factorizing counterpart: captures the factor like
1437        /// `solve` does, then applies it to every packed column. One
1438        /// call, one factorization, all columns — which is the whole
1439        /// point of the path.
1440        fn try_solve_many_flat(
1441            &mut self,
1442            coeffs: &AugSysCoeffs<'_>,
1443            packed_rhs: &mut [Number],
1444            nrhs: usize,
1445            _check_neg_evals: bool,
1446            _num_neg_evals: Index,
1447        ) -> Option<ESymSolverStatus> {
1448            if !self.packed {
1449                return None;
1450            }
1451            let wdiag = coeffs
1452                .w
1453                .expect("CountingInner requires W")
1454                .as_any()
1455                .downcast_ref::<DiagMatrix>()
1456                .expect("CountingInner requires W to be a DiagMatrix");
1457            let diag_rc = wdiag.get_diag().expect("Wdiag has no diag set").clone();
1458            let diag = downcast_dense(diag_rc.as_ref()).expanded_values();
1459            let dim = diag.len();
1460            if packed_rhs.len() != dim * nrhs {
1461                return None;
1462            }
1463            *self.stats.factored_diag.borrow_mut() = diag.clone();
1464            self.stats.factored_delta_x.set(coeffs.delta_x);
1465            self.stats
1466                .factorizations
1467                .set(self.stats.factorizations.get() + 1);
1468            self.stats
1469                .factor_batched_calls
1470                .set(self.stats.factor_batched_calls.get() + 1);
1471            self.stats
1472                .factor_batched_cols
1473                .set(self.stats.factor_batched_cols.get() + nrhs);
1474            let delta_x = coeffs.delta_x;
1475            for col in packed_rhs.chunks_mut(dim) {
1476                for (i, x) in col.iter_mut().enumerate() {
1477                    *x /= diag[i] + delta_x;
1478                }
1479            }
1480            Some(ESymSolverStatus::Success)
1481        }
1482
1483        fn try_resolve_many_flat(
1484            &mut self,
1485            _coeffs: &AugSysCoeffs<'_>,
1486            packed_rhs: &mut [Number],
1487            nrhs: usize,
1488        ) -> Option<ESymSolverStatus> {
1489            if !self.packed {
1490                return None;
1491            }
1492            let diag = self.stats.factored_diag.borrow();
1493            if diag.is_empty() {
1494                return None;
1495            }
1496            let dim = diag.len();
1497            if packed_rhs.len() != dim * nrhs {
1498                return Some(ESymSolverStatus::FatalError);
1499            }
1500            self.stats
1501                .batched_calls
1502                .set(self.stats.batched_calls.get() + 1);
1503            self.stats
1504                .batched_cols
1505                .set(self.stats.batched_cols.get() + nrhs);
1506            let delta_x = self.stats.factored_delta_x.get();
1507            for col in packed_rhs.chunks_mut(dim) {
1508                for (i, x) in col.iter_mut().enumerate() {
1509                    *x /= diag[i] + delta_x;
1510                }
1511            }
1512            Some(ESymSolverStatus::Success)
1513        }
1514    }
1515
1516    fn dvec(space: &Rc<DenseVectorSpace>, vals: &[Number]) -> DenseVector {
1517        let mut v = space.make_new_dense();
1518        v.set_values(vals);
1519        v
1520    }
1521
1522    fn dvec_rc(space: &Rc<DenseVectorSpace>, vals: &[Number]) -> Rc<DenseVector> {
1523        Rc::new(dvec(space, vals))
1524    }
1525
1526    #[test]
1527    fn smw_recovers_low_rank_inverse() {
1528        // 1×1 system: W = b0 + v² (v ≠ 0); δ_x = 0.
1529        // Direct: sol = rhs / (b0 + v²).
1530        // SMW:    inner solves with diag b0 → sol_diag = rhs/b0;
1531        //         correction recovers rhs/(b0 + v²).
1532        let space_x = DenseVectorSpace::new(1);
1533        let space_zero = DenseVectorSpace::new(0);
1534        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
1535        let mut lr = lr_space.make_new_low_rank();
1536        let b0_rc: Rc<dyn Vector> = dvec_rc(&space_x, &[2.0]);
1537        lr.set_diag(b0_rc);
1538        let v_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
1539        let mut v_mvm = v_space.make_new_multi_vector();
1540        v_mvm.set_vector(0, dvec_rc(&space_x, &[3.0]) as Rc<dyn Vector>);
1541        lr.set_v(Rc::new(v_mvm));
1542        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1543
1544        let mut solver = LowRankAugSystemSolver::new(Box::new(DiagInner {
1545            calls: Cell::new(0),
1546        }));
1547
1548        // Empty Jacobians.
1549        let j_c_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1550        let j_d_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1551        let j_c = j_c_space.make_new_dense_gen();
1552        let j_d = j_d_space.make_new_dense_gen();
1553
1554        let coeffs = AugSysCoeffs {
1555            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1556            w_factor: 1.0,
1557            d_x: None,
1558            delta_x: 0.0,
1559            d_s: None,
1560            delta_s: 0.0,
1561            j_c: &j_c as &dyn Matrix,
1562            d_c: None,
1563            delta_c: 0.0,
1564            j_d: &j_d as &dyn Matrix,
1565            d_d: None,
1566            delta_d: 0.0,
1567        };
1568
1569        let rhs_x = dvec(&space_x, &[5.0]);
1570        let rhs_s = dvec(&space_zero, &[]);
1571        let rhs_c = dvec(&space_zero, &[]);
1572        let rhs_d = dvec(&space_zero, &[]);
1573        let rhs = AugSysRhs {
1574            rhs_x: &rhs_x,
1575            rhs_s: &rhs_s,
1576            rhs_c: &rhs_c,
1577            rhs_d: &rhs_d,
1578        };
1579        let mut sol_x = dvec(&space_x, &[0.0]);
1580        let mut sol_s = dvec(&space_zero, &[]);
1581        let mut sol_c = dvec(&space_zero, &[]);
1582        let mut sol_d = dvec(&space_zero, &[]);
1583        let mut sol = AugSysSol {
1584            sol_x: &mut sol_x,
1585            sol_s: &mut sol_s,
1586            sol_c: &mut sol_c,
1587            sol_d: &mut sol_d,
1588        };
1589        let status = solver.solve(&coeffs, &rhs, &mut sol, false, 0);
1590        assert_eq!(status, ESymSolverStatus::Success);
1591        // Expected: 5 / (2 + 9) = 5/11.
1592        let got = sol_x.expanded_values()[0];
1593        let want = 5.0 / 11.0;
1594        assert!((got - want).abs() < 1e-12, "got {} want {}", got, want);
1595    }
1596
1597    #[test]
1598    fn smw_with_u_only_applies_positive_correction() {
1599        // 1×1 system: W = b0 − u² (low-rank *negative* update).
1600        // Direct: sol = rhs / (b0 − u²).
1601        let space_x = DenseVectorSpace::new(1);
1602        let space_zero = DenseVectorSpace::new(0);
1603        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
1604        let mut lr = lr_space.make_new_low_rank();
1605        lr.set_diag(dvec_rc(&space_x, &[5.0]));
1606        let u_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
1607        let mut u_mvm = u_space.make_new_multi_vector();
1608        u_mvm.set_vector(0, dvec_rc(&space_x, &[1.5]) as Rc<dyn Vector>);
1609        lr.set_u(Rc::new(u_mvm));
1610        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1611
1612        let mut solver = LowRankAugSystemSolver::new(Box::new(DiagInner {
1613            calls: Cell::new(0),
1614        }));
1615
1616        let j_c_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1617        let j_d_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1618        let j_c = j_c_space.make_new_dense_gen();
1619        let j_d = j_d_space.make_new_dense_gen();
1620
1621        let coeffs = AugSysCoeffs {
1622            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1623            w_factor: 1.0,
1624            d_x: None,
1625            delta_x: 0.0,
1626            d_s: None,
1627            delta_s: 0.0,
1628            j_c: &j_c as &dyn Matrix,
1629            d_c: None,
1630            delta_c: 0.0,
1631            j_d: &j_d as &dyn Matrix,
1632            d_d: None,
1633            delta_d: 0.0,
1634        };
1635
1636        let rhs_x = dvec(&space_x, &[7.0]);
1637        let rhs_s = dvec(&space_zero, &[]);
1638        let rhs_c = dvec(&space_zero, &[]);
1639        let rhs_d = dvec(&space_zero, &[]);
1640        let rhs = AugSysRhs {
1641            rhs_x: &rhs_x,
1642            rhs_s: &rhs_s,
1643            rhs_c: &rhs_c,
1644            rhs_d: &rhs_d,
1645        };
1646        let mut sol_x = dvec(&space_x, &[0.0]);
1647        let mut sol_s = dvec(&space_zero, &[]);
1648        let mut sol_c = dvec(&space_zero, &[]);
1649        let mut sol_d = dvec(&space_zero, &[]);
1650        let mut sol = AugSysSol {
1651            sol_x: &mut sol_x,
1652            sol_s: &mut sol_s,
1653            sol_c: &mut sol_c,
1654            sol_d: &mut sol_d,
1655        };
1656        let status = solver.solve(&coeffs, &rhs, &mut sol, false, 0);
1657        assert_eq!(status, ESymSolverStatus::Success);
1658        // Expected: 7 / (5 − 2.25) = 7 / 2.75.
1659        let got = sol_x.expanded_values()[0];
1660        let want = 7.0 / 2.75;
1661        assert!((got - want).abs() < 1e-12, "got {} want {}", got, want);
1662    }
1663
1664    #[test]
1665    fn smw_reports_wrong_inertia_on_indefinite_negative_update() {
1666        // 1×1 system: W = b0 − u² with u² > b0, so B = 2 − 4 = −2 is
1667        // genuinely indefinite — the SR1 negative-curvature regime. The
1668        // SMW middle matrix M2 = 1 − Utilde2ᵀU = 1 − u²/b0 = −1 is then
1669        // not positive definite, so its Cholesky must fail and the solver
1670        // must report `WrongInertia` — the signal the perturbation handler
1671        // keys on to correct the step — rather than silently returning a
1672        // garbage solve. (`number_of_neg_evals` is not asserted here: with
1673        // a real inertia-providing inner solver it delegates to the inner;
1674        // the mock reports 0.)
1675        let space_x = DenseVectorSpace::new(1);
1676        let space_zero = DenseVectorSpace::new(0);
1677        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
1678        let mut lr = lr_space.make_new_low_rank();
1679        lr.set_diag(dvec_rc(&space_x, &[2.0]));
1680        let u_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
1681        let mut u_mvm = u_space.make_new_multi_vector();
1682        u_mvm.set_vector(0, dvec_rc(&space_x, &[2.0]) as Rc<dyn Vector>);
1683        lr.set_u(Rc::new(u_mvm));
1684        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1685
1686        let mut solver = LowRankAugSystemSolver::new(Box::new(DiagInner {
1687            calls: Cell::new(0),
1688        }));
1689
1690        let j_c_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1691        let j_d_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1692        let j_c = j_c_space.make_new_dense_gen();
1693        let j_d = j_d_space.make_new_dense_gen();
1694
1695        let coeffs = AugSysCoeffs {
1696            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1697            w_factor: 1.0,
1698            d_x: None,
1699            delta_x: 0.0,
1700            d_s: None,
1701            delta_s: 0.0,
1702            j_c: &j_c as &dyn Matrix,
1703            d_c: None,
1704            delta_c: 0.0,
1705            j_d: &j_d as &dyn Matrix,
1706            d_d: None,
1707            delta_d: 0.0,
1708        };
1709
1710        let rhs_x = dvec(&space_x, &[1.0]);
1711        let rhs_s = dvec(&space_zero, &[]);
1712        let rhs_c = dvec(&space_zero, &[]);
1713        let rhs_d = dvec(&space_zero, &[]);
1714        let rhs = AugSysRhs {
1715            rhs_x: &rhs_x,
1716            rhs_s: &rhs_s,
1717            rhs_c: &rhs_c,
1718            rhs_d: &rhs_d,
1719        };
1720        let mut sol_x = dvec(&space_x, &[0.0]);
1721        let mut sol_s = dvec(&space_zero, &[]);
1722        let mut sol_c = dvec(&space_zero, &[]);
1723        let mut sol_d = dvec(&space_zero, &[]);
1724        let mut sol = AugSysSol {
1725            sol_x: &mut sol_x,
1726            sol_s: &mut sol_s,
1727            sol_c: &mut sol_c,
1728            sol_d: &mut sol_d,
1729        };
1730        let status = solver.solve(&coeffs, &rhs, &mut sol, false, 0);
1731        assert_eq!(status, ESymSolverStatus::WrongInertia);
1732    }
1733
1734    #[test]
1735    fn smw_with_v_and_u_combines_corrections() {
1736        // 1×1 system: W = b0 + v² − u² (rank-2 update). Solve checks
1737        // both correction passes compose correctly.
1738        let space_x = DenseVectorSpace::new(1);
1739        let space_zero = DenseVectorSpace::new(0);
1740        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
1741        let mut lr = lr_space.make_new_low_rank();
1742        lr.set_diag(dvec_rc(&space_x, &[10.0]));
1743        let v_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
1744        let mut v_mvm = v_space.make_new_multi_vector();
1745        v_mvm.set_vector(0, dvec_rc(&space_x, &[2.0]) as Rc<dyn Vector>);
1746        lr.set_v(Rc::new(v_mvm));
1747        let u_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
1748        let mut u_mvm = u_space.make_new_multi_vector();
1749        u_mvm.set_vector(0, dvec_rc(&space_x, &[1.0]) as Rc<dyn Vector>);
1750        lr.set_u(Rc::new(u_mvm));
1751        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1752
1753        let mut solver = LowRankAugSystemSolver::new(Box::new(DiagInner {
1754            calls: Cell::new(0),
1755        }));
1756
1757        let j_c_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1758        let j_d_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1759        let j_c = j_c_space.make_new_dense_gen();
1760        let j_d = j_d_space.make_new_dense_gen();
1761
1762        let coeffs = AugSysCoeffs {
1763            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1764            w_factor: 1.0,
1765            d_x: None,
1766            delta_x: 0.0,
1767            d_s: None,
1768            delta_s: 0.0,
1769            j_c: &j_c as &dyn Matrix,
1770            d_c: None,
1771            delta_c: 0.0,
1772            j_d: &j_d as &dyn Matrix,
1773            d_d: None,
1774            delta_d: 0.0,
1775        };
1776
1777        let rhs_x = dvec(&space_x, &[1.0]);
1778        let rhs_s = dvec(&space_zero, &[]);
1779        let rhs_c = dvec(&space_zero, &[]);
1780        let rhs_d = dvec(&space_zero, &[]);
1781        let rhs = AugSysRhs {
1782            rhs_x: &rhs_x,
1783            rhs_s: &rhs_s,
1784            rhs_c: &rhs_c,
1785            rhs_d: &rhs_d,
1786        };
1787        let mut sol_x = dvec(&space_x, &[0.0]);
1788        let mut sol_s = dvec(&space_zero, &[]);
1789        let mut sol_c = dvec(&space_zero, &[]);
1790        let mut sol_d = dvec(&space_zero, &[]);
1791        let mut sol = AugSysSol {
1792            sol_x: &mut sol_x,
1793            sol_s: &mut sol_s,
1794            sol_c: &mut sol_c,
1795            sol_d: &mut sol_d,
1796        };
1797        let status = solver.solve(&coeffs, &rhs, &mut sol, false, 0);
1798        assert_eq!(status, ESymSolverStatus::Success);
1799        // Expected: 1 / (10 + 4 − 1) = 1/13.
1800        let got = sol_x.expanded_values()[0];
1801        let want = 1.0 / 13.0;
1802        assert!((got - want).abs() < 1e-12, "got {} want {}", got, want);
1803    }
1804
1805    #[test]
1806    fn unchanged_coeffs_skip_rebuild_after_first_call() {
1807        let mut lr_solver = LowRankAugSystemSolver::new(Box::new(DiagInner {
1808            calls: Cell::new(0),
1809        }));
1810        let space_x = DenseVectorSpace::new(1);
1811        let space_zero = DenseVectorSpace::new(0);
1812        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
1813        let mut lr = lr_space.make_new_low_rank();
1814        lr.set_diag(dvec_rc(&space_x, &[2.0]));
1815        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1816        let j_c_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1817        let j_d_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1818        let j_c = j_c_space.make_new_dense_gen();
1819        let j_d = j_d_space.make_new_dense_gen();
1820        let coeffs = AugSysCoeffs {
1821            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1822            w_factor: 1.0,
1823            d_x: None,
1824            delta_x: 0.001,
1825            d_s: None,
1826            delta_s: 0.0,
1827            j_c: &j_c as &dyn Matrix,
1828            d_c: None,
1829            delta_c: 0.0,
1830            j_d: &j_d as &dyn Matrix,
1831            d_d: None,
1832            delta_d: 0.0,
1833        };
1834        let rhs_x = dvec(&space_x, &[1.0]);
1835        let rhs_zero = dvec(&space_zero, &[]);
1836        let rhs = AugSysRhs {
1837            rhs_x: &rhs_x,
1838            rhs_s: &rhs_zero,
1839            rhs_c: &rhs_zero,
1840            rhs_d: &rhs_zero,
1841        };
1842        let mut sol_x = dvec(&space_x, &[0.0]);
1843        let mut sol_z1 = dvec(&space_zero, &[]);
1844        let mut sol_z2 = dvec(&space_zero, &[]);
1845        let mut sol_z3 = dvec(&space_zero, &[]);
1846        {
1847            let mut sol = AugSysSol {
1848                sol_x: &mut sol_x,
1849                sol_s: &mut sol_z1,
1850                sol_c: &mut sol_z2,
1851                sol_d: &mut sol_z3,
1852            };
1853            lr_solver.solve(&coeffs, &rhs, &mut sol, false, 0);
1854        }
1855        // Same coeffs → cache reports no change.
1856        assert!(!lr_solver.augmented_system_requires_change(&coeffs));
1857    }
1858
1859    // ---- gh#698: factorization reuse ----
1860
1861    /// The empty `m_c = m_d = 0` Jacobians every fixture in this section
1862    /// uses, over `n_x` variables.
1863    fn empty_jacobians(
1864        n_x: Index,
1865    ) -> (
1866        pounce_linalg::dense_gen_matrix::DenseGenMatrix,
1867        pounce_linalg::dense_gen_matrix::DenseGenMatrix,
1868    ) {
1869        (
1870            pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, n_x).make_new_dense_gen(),
1871            pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, n_x).make_new_dense_gen(),
1872        )
1873    }
1874
1875    #[test]
1876    fn smw_columns_share_one_factorization() {
1877        // W = diag(2) + v vᵀ with a 3-column V. Upstream issues a single
1878        // `MultiSolve` for all columns and factorizes once; we must do
1879        // the same: 1 factorization, then back-solves for the remaining
1880        // two columns and for the diagonal solve.
1881        let space_x = DenseVectorSpace::new(3);
1882        let space_zero = DenseVectorSpace::new(0);
1883        let lr_space = LowRankUpdateSymMatrixSpace::new(3, None, false);
1884        let mut lr = lr_space.make_new_low_rank();
1885        lr.set_diag(dvec_rc(&space_x, &[2.0, 3.0, 4.0]));
1886        let v_space = MultiVectorMatrixSpace::new(3, Rc::clone(&space_x));
1887        let mut v = v_space.make_new_multi_vector();
1888        v.set_vector(0, dvec_rc(&space_x, &[0.5, 0.0, 0.0]));
1889        v.set_vector(1, dvec_rc(&space_x, &[0.0, 0.5, 0.0]));
1890        v.set_vector(2, dvec_rc(&space_x, &[0.0, 0.0, 0.5]));
1891        lr.set_v(Rc::new(v));
1892        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1893        let (j_c, j_d) = empty_jacobians(3);
1894        let delta_x = 0.0;
1895
1896        let (inner, stats) = CountingInner::new();
1897        let mut solver = LowRankAugSystemSolver::new(Box::new(inner));
1898
1899        let coeffs = AugSysCoeffs {
1900            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1901            w_factor: 1.0,
1902            d_x: None,
1903            delta_x,
1904            d_s: None,
1905            delta_s: 0.0,
1906            j_c: &j_c as &dyn Matrix,
1907            d_c: None,
1908            delta_c: 0.0,
1909            j_d: &j_d as &dyn Matrix,
1910            d_d: None,
1911            delta_d: 0.0,
1912        };
1913        let rhs_x = dvec(&space_x, &[1.0, 1.0, 1.0]);
1914        let rhs_zero = dvec(&space_zero, &[]);
1915        let rhs = AugSysRhs {
1916            rhs_x: &rhs_x,
1917            rhs_s: &rhs_zero,
1918            rhs_c: &rhs_zero,
1919            rhs_d: &rhs_zero,
1920        };
1921        let (mut sx, mut z1, mut z2, mut z3) = (
1922            dvec(&space_x, &[0.0, 0.0, 0.0]),
1923            dvec(&space_zero, &[]),
1924            dvec(&space_zero, &[]),
1925            dvec(&space_zero, &[]),
1926        );
1927        {
1928            let mut sol = AugSysSol {
1929                sol_x: &mut sx,
1930                sol_s: &mut z1,
1931                sol_c: &mut z2,
1932                sol_d: &mut z3,
1933            };
1934            assert_eq!(
1935                solver.solve(&coeffs, &rhs, &mut sol, false, 0),
1936                ESymSolverStatus::Success
1937            );
1938        }
1939        assert_eq!(
1940            stats.factorizations.get(),
1941            1,
1942            "three SMW columns plus the diagonal solve must share one factorization"
1943        );
1944        assert_eq!(
1945            stats.backsolves.get(),
1946            3,
1947            "2 remaining V columns + diagonal solve"
1948        );
1949    }
1950
1951    #[test]
1952    fn batched_smw_columns_match_the_per_column_path() {
1953        // The batched arm of `multi_solve_block` is unreachable from a
1954        // mock that leaves `system_dim()` at its 0 default, so every
1955        // other test in this file exercises the per-column fallback and
1956        // a green suite says nothing about the packed path (gh#729).
1957        // Drive the identical problem down both arms and require the
1958        // solutions to agree bit-for-bit: the batched call must be work
1959        // removed, not work re-associated.
1960        fn run(packed: bool) -> (Vec<Number>, Rc<InnerStats>) {
1961            let space_x = DenseVectorSpace::new(3);
1962            let space_zero = DenseVectorSpace::new(0);
1963            let lr_space = LowRankUpdateSymMatrixSpace::new(3, None, false);
1964            let mut lr = lr_space.make_new_low_rank();
1965            lr.set_diag(dvec_rc(&space_x, &[2.0, 3.0, 4.0]));
1966            let v_space = MultiVectorMatrixSpace::new(3, Rc::clone(&space_x));
1967            let mut v = v_space.make_new_multi_vector();
1968            v.set_vector(0, dvec_rc(&space_x, &[0.5, 0.25, 0.0]));
1969            v.set_vector(1, dvec_rc(&space_x, &[0.0, 0.5, 0.125]));
1970            v.set_vector(2, dvec_rc(&space_x, &[0.25, 0.0, 0.5]));
1971            lr.set_v(Rc::new(v));
1972            let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1973            let (j_c, j_d) = empty_jacobians(3);
1974
1975            let (inner, stats) = if packed {
1976                CountingInner::with_packed_path()
1977            } else {
1978                CountingInner::new()
1979            };
1980            let mut solver = LowRankAugSystemSolver::new(Box::new(inner));
1981            let coeffs = AugSysCoeffs {
1982                w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1983                w_factor: 1.0,
1984                d_x: None,
1985                delta_x: 0.0,
1986                d_s: None,
1987                delta_s: 0.0,
1988                j_c: &j_c as &dyn Matrix,
1989                d_c: None,
1990                delta_c: 0.0,
1991                j_d: &j_d as &dyn Matrix,
1992                d_d: None,
1993                delta_d: 0.0,
1994            };
1995            let rhs_x = dvec(&space_x, &[1.0, -2.0, 3.5]);
1996            let rhs_zero = dvec(&space_zero, &[]);
1997            let rhs = AugSysRhs {
1998                rhs_x: &rhs_x,
1999                rhs_s: &rhs_zero,
2000                rhs_c: &rhs_zero,
2001                rhs_d: &rhs_zero,
2002            };
2003            let (mut sx, mut z1, mut z2, mut z3) = (
2004                dvec(&space_x, &[0.0, 0.0, 0.0]),
2005                dvec(&space_zero, &[]),
2006                dvec(&space_zero, &[]),
2007                dvec(&space_zero, &[]),
2008            );
2009            {
2010                let mut sol = AugSysSol {
2011                    sol_x: &mut sx,
2012                    sol_s: &mut z1,
2013                    sol_c: &mut z2,
2014                    sol_d: &mut z3,
2015                };
2016                assert_eq!(
2017                    solver.solve(&coeffs, &rhs, &mut sol, false, 0),
2018                    ESymSolverStatus::Success
2019                );
2020            }
2021            (sx.expanded_values(), stats)
2022        }
2023
2024        let (sol_loop, stats_loop) = run(false);
2025        let (sol_batch, stats_batch) = run(true);
2026
2027        assert_eq!(
2028            stats_loop.batched_calls.get(),
2029            0,
2030            "a mock without the packed path must take the per-column arm"
2031        );
2032        assert!(
2033            stats_batch.factor_batched_calls.get() >= 1,
2034            "the packed mock must actually reach the merged arm — a mock \
2035             whose `system_dim()` is 0 when cold silently would not, and \
2036             then this test would be green about code it never ran"
2037        );
2038        assert_eq!(
2039            stats_batch.factorizations.get(),
2040            1,
2041            "batching must not cost an extra factorization"
2042        );
2043        assert_eq!(
2044            sol_loop, sol_batch,
2045            "batched and per-column arms must agree bit-for-bit"
2046        );
2047
2048        // All three V columns ride along with the factorization in a
2049        // single backend call. Before the merge this read
2050        // `factor_batched_calls == 0`, `batched_cols == 2` — column 0
2051        // through the single-RHS `solve`, then a second call carrying
2052        // the other two. That second call streams the whole factor
2053        // again, which is the cost this removes.
2054        assert_eq!(
2055            stats_batch.factor_batched_calls.get(),
2056            1,
2057            "the cold SMW block must be ONE factorizing multi-RHS call"
2058        );
2059        assert_eq!(
2060            stats_batch.factor_batched_cols.get(),
2061            3,
2062            "all 3 V columns must ride along with the factorization"
2063        );
2064        assert_eq!(
2065            stats_batch.batched_calls.get(),
2066            0,
2067            "no separate back-solve pass may remain over the same factor"
2068        );
2069        // One `resolve` remains, and must: it is the actual RHS being
2070        // solved against the same factor after the V columns have built
2071        // the SMW correction. Upstream pays it too. What the merge
2072        // removes is the second pass over the factor that used to carry
2073        // the V columns.
2074        assert_eq!(
2075            stats_batch.backsolves.get(),
2076            1,
2077            "only the main RHS solve may remain over the cached factor"
2078        );
2079    }
2080
2081    #[test]
2082    fn resolve_reuses_the_factor_instead_of_refactorizing() {
2083        // `PdFullSpaceSolver`'s refinement loop and its same-matrix fast
2084        // path both come in through `resolve`. Against an unchanged
2085        // matrix that must be a pure back-substitution.
2086        let space_x = DenseVectorSpace::new(1);
2087        let space_zero = DenseVectorSpace::new(0);
2088        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
2089        let mut lr = lr_space.make_new_low_rank();
2090        lr.set_diag(dvec_rc(&space_x, &[2.0]));
2091        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
2092        let (j_c, j_d) = empty_jacobians(1);
2093        let delta_x = 0.001;
2094
2095        let (inner, stats) = CountingInner::new();
2096        let mut solver = LowRankAugSystemSolver::new(Box::new(inner));
2097
2098        let coeffs = AugSysCoeffs {
2099            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
2100            w_factor: 1.0,
2101            d_x: None,
2102            delta_x,
2103            d_s: None,
2104            delta_s: 0.0,
2105            j_c: &j_c as &dyn Matrix,
2106            d_c: None,
2107            delta_c: 0.0,
2108            j_d: &j_d as &dyn Matrix,
2109            d_d: None,
2110            delta_d: 0.0,
2111        };
2112        let rhs_x = dvec(&space_x, &[1.0]);
2113        let rhs_zero = dvec(&space_zero, &[]);
2114        let rhs = AugSysRhs {
2115            rhs_x: &rhs_x,
2116            rhs_s: &rhs_zero,
2117            rhs_c: &rhs_zero,
2118            rhs_d: &rhs_zero,
2119        };
2120        let expected = 1.0 / (2.0 + delta_x);
2121
2122        for round in 0..4 {
2123            let (mut sx, mut z1, mut z2, mut z3) = (
2124                dvec(&space_x, &[0.0]),
2125                dvec(&space_zero, &[]),
2126                dvec(&space_zero, &[]),
2127                dvec(&space_zero, &[]),
2128            );
2129            // Scoped so the mutable borrow of `sx` ends before it is read,
2130            // matching the other tests here.
2131            {
2132                let mut sol = AugSysSol {
2133                    sol_x: &mut sx,
2134                    sol_s: &mut z1,
2135                    sol_c: &mut z2,
2136                    sol_d: &mut z3,
2137                };
2138                let status = if round == 0 {
2139                    solver.solve(&coeffs, &rhs, &mut sol, false, 0)
2140                } else {
2141                    solver.resolve(&coeffs, &rhs, &mut sol)
2142                };
2143                assert_eq!(status, ESymSolverStatus::Success);
2144            }
2145            assert!(
2146                (sx.expanded_values()[0] - expected).abs() < 1e-12,
2147                "round {round} gave {:?}",
2148                sx.expanded_values()
2149            );
2150        }
2151        assert_eq!(
2152            stats.factorizations.get(),
2153            1,
2154            "three refinement re-solves must not re-factorize"
2155        );
2156    }
2157
2158    #[test]
2159    fn rebuild_with_empty_history_still_factorizes() {
2160        // Regression guard for the sharp edge in this optimization.
2161        //
2162        // `update_factorization` performs *zero* inner solves when the
2163        // L-BFGS history is empty (`get_v()` and `get_u()` both `None`).
2164        // That happens on the first iteration and again whenever
2165        // `limited_memory_max_skipping` clears the history mid-solve
2166        // (gh#686, the `Wr` info string) — so it is reachable with the
2167        // inner solver warm, holding the *previous* iterate's factor.
2168        //
2169        // If `inner_has_factor` were set as a consequence of the rebuild
2170        // rather than of an actual inner solve, the diagonal solve would
2171        // back-substitute against that stale factor: wrong direction, no
2172        // error, and `StdAugSystemSolver::have_factor` would not catch it
2173        // because it is not cold. `CountingInner::resolve` reproduces
2174        // exactly that by answering from its cached diagonal, so this
2175        // asserts on the value as well as the count.
2176        let space_x = DenseVectorSpace::new(1);
2177        let space_zero = DenseVectorSpace::new(0);
2178        let space_lr = LowRankUpdateSymMatrixSpace::new(1, None, false);
2179
2180        // Round 1: history present, σ = 2.
2181        let mut lr1 = space_lr.make_new_low_rank();
2182        lr1.set_diag(dvec_rc(&space_x, &[2.0]));
2183        let v_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
2184        let mut v = v_space.make_new_multi_vector();
2185        v.set_vector(0, dvec_rc(&space_x, &[0.5]));
2186        lr1.set_v(Rc::new(v));
2187        let lr1_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr1);
2188
2189        // Round 2: history cleared, σ moved to 7. No V, no U.
2190        let mut lr2 = LowRankUpdateSymMatrixSpace::new(1, None, false).make_new_low_rank();
2191        lr2.set_diag(dvec_rc(&space_x, &[7.0]));
2192        let lr2_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr2);
2193
2194        let (j_c, j_d) = empty_jacobians(1);
2195
2196        let (inner, stats) = CountingInner::new();
2197        let mut solver = LowRankAugSystemSolver::new(Box::new(inner));
2198
2199        let rhs_x = dvec(&space_x, &[1.0]);
2200        let rhs_zero = dvec(&space_zero, &[]);
2201        let rhs = AugSysRhs {
2202            rhs_x: &rhs_x,
2203            rhs_s: &rhs_zero,
2204            rhs_c: &rhs_zero,
2205            rhs_d: &rhs_zero,
2206        };
2207
2208        let run = |lr: &Rc<LowRankUpdateSymMatrix>, solver: &mut LowRankAugSystemSolver| {
2209            let coeffs = AugSysCoeffs {
2210                w: Some(lr.as_ref() as &dyn SymMatrix),
2211                w_factor: 1.0,
2212                d_x: None,
2213                delta_x: 0.0,
2214                d_s: None,
2215                delta_s: 0.0,
2216                j_c: &j_c as &dyn Matrix,
2217                d_c: None,
2218                delta_c: 0.0,
2219                j_d: &j_d as &dyn Matrix,
2220                d_d: None,
2221                delta_d: 0.0,
2222            };
2223            let (mut sx, mut z1, mut z2, mut z3) = (
2224                dvec(&space_x, &[0.0]),
2225                dvec(&space_zero, &[]),
2226                dvec(&space_zero, &[]),
2227                dvec(&space_zero, &[]),
2228            );
2229            {
2230                let mut sol = AugSysSol {
2231                    sol_x: &mut sx,
2232                    sol_s: &mut z1,
2233                    sol_c: &mut z2,
2234                    sol_d: &mut z3,
2235                };
2236                assert_eq!(
2237                    solver.solve(&coeffs, &rhs, &mut sol, false, 0),
2238                    ESymSolverStatus::Success
2239                );
2240            }
2241            sx.expanded_values()[0]
2242        };
2243
2244        run(&lr1_rc, &mut solver);
2245        let after_first = stats.factorizations.get();
2246
2247        // Preconditions, asserted rather than assumed. Without these the
2248        // test still passes if the fixtures drift out from under it, but
2249        // stops testing the empty-history path -- it would be checking an
2250        // ordinary rebuild and reporting a pass. (The same vacuity trap
2251        // feral#179 hit building its full-budget refinement oracle: nothing
2252        // merely ill-conditioned reaches the budget, so an oracle that is
2253        // not pinned proves nothing.)
2254        //
2255        // 1. Round 1 must have left the hazard live: a factor cached and
2256        //    `inner_has_factor` set. That flag is private, so assert its
2257        //    observable consequence -- an identical re-solve reuses it.
2258        let repeat = run(&lr1_rc, &mut solver);
2259        assert_eq!(
2260            stats.factorizations.get(),
2261            after_first,
2262            "round 1 must leave a reusable factor, else round 2 has no \
2263             stale factor to wrongly reuse and this test is vacuous"
2264        );
2265        // 1/(2 + 0.5^2): the inner solve answers 1/2 from the cached
2266        // Wdiag factor and the SMW correction for V then applies on top.
2267        // Round 2 has no V, which is why its expected value below is the
2268        // bare 1/7 and why a stale factor there shows up as 1/2.
2269        assert!(
2270            (repeat - 4.0 / 9.0).abs() < 1e-12,
2271            "round 1 re-solve should answer 4/9 from the cached factor, \
2272             got {repeat}"
2273        );
2274
2275        // 2. Round 2's matrix must genuinely carry no history, which is what
2276        //    makes `update_factorization` perform zero inner solves.
2277        assert!(
2278            lr2_rc.get_v().is_none() && lr2_rc.get_u().is_none(),
2279            "round 2 fixture must have empty L-BFGS history"
2280        );
2281
2282        let got = run(&lr2_rc, &mut solver);
2283
2284        assert_eq!(
2285            stats.factorizations.get(),
2286            after_first + 1,
2287            "a rebuild that performs no inner solve of its own must leave \
2288             the diagonal solve to factorize the new Wdiag"
2289        );
2290        // No V and no U, so the SMW correction is empty and the answer is
2291        // just the diagonal solve against the *new* σ. Back-solving
2292        // against round 1's factor would give 1/2, not 1/7.
2293        assert!(
2294            (got - 1.0 / 7.0).abs() < 1e-12,
2295            "expected the new Wdiag (1/7), got {got} — stale factor reused"
2296        );
2297    }
2298}