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 singularity_certified(&self) -> bool {
929        self.inner.singularity_certified()
930    }
931
932    fn number_of_neg_evals(&self) -> Index {
933        if self.inner.provides_inertia() {
934            self.inner.number_of_neg_evals()
935        } else {
936            self.num_neg_evals
937        }
938    }
939
940    fn increase_quality(&mut self) -> bool {
941        // The inner solver drops its cached factor here (it re-pivots at
942        // a tighter tolerance), so ours is stale too.
943        self.inner_has_factor = false;
944        self.inner_factor_neg_evals = None;
945        // Escalate both: a caller asking for tighter pivoting wants it on
946        // the next solve whichever path that takes, and the two solvers
947        // hold independent backend state.
948        let inner = self.inner.increase_quality();
949        let bypass = self
950            .bypass
951            .as_deref_mut()
952            .map(|b| b.increase_quality())
953            .unwrap_or(false);
954        inner || bypass
955    }
956
957    fn last_solve_status(&self) -> ESymSolverStatus {
958        // Whichever solver actually ran last — otherwise a bypass solve
959        // reports the stale status of the previous main solve.
960        match (self.last_solve_took_bypass, self.bypass.as_deref()) {
961            (true, Some(bypass)) => bypass.last_solve_status(),
962            _ => self.inner.last_solve_status(),
963        }
964    }
965
966    fn set_timing_stats(&mut self, timing: Rc<TimingStatistics>) {
967        // Both, or the bypass solver's symbolic and factorization time
968        // lands in no phase at all — which is precisely the row gh#730
969        // is about.
970        if let Some(bypass) = self.bypass.as_deref_mut() {
971            bypass.set_timing_stats(Rc::clone(&timing));
972        }
973        self.inner.set_timing_stats(timing);
974    }
975
976    fn set_slack_scaling(&mut self, nx: Index, s_scale: &[Number]) {
977        // Both: the bypass solver assembles the same (2,2) slack block
978        // and would otherwise scale it differently from the main path.
979        if let Some(bypass) = self.bypass.as_deref_mut() {
980            bypass.set_slack_scaling(nx, s_scale);
981        }
982        self.inner.set_slack_scaling(nx, s_scale);
983    }
984
985    fn handles_low_rank_w(&self) -> bool {
986        true
987    }
988
989    fn solve(
990        &mut self,
991        coeffs: &AugSysCoeffs<'_>,
992        rhs: &AugSysRhs<'_>,
993        sol: &mut AugSysSol<'_>,
994        check_neg_evals: bool,
995        num_neg_evals: Index,
996    ) -> ESymSolverStatus {
997        // Skip inertia checks when the inner solver doesn't provide
998        // them — mirrors `IpLowRankAugSystemSolver.cpp:102-105`.
999        let mut check_neg_evals = check_neg_evals;
1000        if !self.inner.provides_inertia() {
1001            check_neg_evals = false;
1002        }
1003
1004        // Hessian-free / non-low-rank W: the least-square-multiplier
1005        // initialization (`init`) and the equality-multiplier estimates
1006        // (`eq_mult`) drive this same solver with their own zero W block
1007        // and `w_factor = 0` — there is no low-rank update to apply, so
1008        // bypass the SMW machinery and solve directly through the inner
1009        // augmented-system solver with the original coefficients.
1010        // `data.w` only carries a `LowRankUpdateSymMatrix` for the main
1011        // primal-dual solves (always `w_factor = 1`).
1012        let lr_w_opt = coeffs
1013            .w
1014            .and_then(|w| w.as_any().downcast_ref::<LowRankUpdateSymMatrix>());
1015        let Some(lr_w) = lr_w_opt else {
1016            // The inner solver is about to factor a *different* matrix
1017            // (the caller's own W, not our `Wdiag` substitution), so any
1018            // cached SMW factor state no longer describes what it holds.
1019            // Only invalidate the shared-solver cache when the solve is
1020            // actually about to land on `self.inner`. With a dedicated
1021            // bypass solver `self.inner` keeps holding a valid factor of
1022            // the `Wdiag`-substituted system, and dropping it here would
1023            // force a needless refactorization on the next main solve.
1024            self.last_solve_took_bypass = true;
1025            let target = match self.bypass.as_deref_mut() {
1026                Some(bypass) => bypass,
1027                None => {
1028                    // The inner solver is about to factor a *different*
1029                    // matrix (the caller's own W, not our `Wdiag`
1030                    // substitution), so any cached SMW factor state no
1031                    // longer describes what it holds.
1032                    self.inner_has_factor = false;
1033                    self.inner_factor_neg_evals = None;
1034                    self.inner.as_mut()
1035                }
1036            };
1037            let status = target.solve(coeffs, rhs, sol, check_neg_evals, num_neg_evals);
1038            if target.provides_inertia() {
1039                self.num_neg_evals = target.number_of_neg_evals();
1040            }
1041            return status;
1042        };
1043
1044        self.last_solve_took_bypass = false;
1045
1046        let needs_rebuild = self.first_call || self.augmented_system_requires_change(coeffs);
1047        if needs_rebuild {
1048            let status =
1049                self.update_factorization(lr_w, coeffs, rhs, check_neg_evals, num_neg_evals);
1050            if status != ESymSolverStatus::Success {
1051                return status;
1052            }
1053            self.store_cache(coeffs);
1054            self.first_call = false;
1055        }
1056
1057        // 1. Diagonal solve through the inner aug-system solver. When we
1058        //    already hold a factor of this exact matrix — the rebuild
1059        //    above just produced one while solving for the SMW columns,
1060        //    or nothing has changed since the last call — this is a
1061        //    back-substitution rather than a fresh factorization.
1062        //
1063        //    Skipping the factorization also skips the inertia check that
1064        //    goes with it, so only take the fast path when the cached
1065        //    factor was already validated against the same target. In
1066        //    practice it always has been: a rebuild runs its first column
1067        //    with this call's own `check_neg_evals`/`num_neg_evals` and
1068        //    bails on `WrongInertia` before reaching here.
1069        let reuse = self.inner_has_factor
1070            && (!check_neg_evals || self.inner_factor_neg_evals == Some(num_neg_evals));
1071        let ic = inner_coeffs(&self.factor, coeffs);
1072        let status = if reuse {
1073            self.inner.resolve(&ic, rhs, sol)
1074        } else {
1075            self.inner
1076                .solve(&ic, rhs, sol, check_neg_evals, num_neg_evals)
1077        };
1078        if self.inner.provides_inertia() {
1079            self.num_neg_evals = self.inner.number_of_neg_evals();
1080        }
1081        if status != ESymSolverStatus::Success {
1082            self.inner_has_factor = false;
1083            self.inner_factor_neg_evals = None;
1084            return status;
1085        }
1086        if !reuse {
1087            self.inner_has_factor = true;
1088            self.inner_factor_neg_evals = check_neg_evals.then_some(num_neg_evals);
1089        }
1090
1091        // 2. SMW correction terms — mirror upstream's order:
1092        //    apply Utilde2 first, then Vtilde1 (cpp:210-227).
1093        if self.factor.utilde2_x.is_some() {
1094            self.apply_smw(/*sign=*/ 1.0, /*use_u=*/ true, rhs, sol);
1095        }
1096        if self.factor.vtilde1_x.is_some() {
1097            self.apply_smw(/*sign=*/ -1.0, /*use_u=*/ false, rhs, sol);
1098        }
1099
1100        ESymSolverStatus::Success
1101    }
1102
1103    /// Back-substitution against the cached factor, plus the same SMW
1104    /// corrections `solve` applies.
1105    ///
1106    /// Without this override the trait default falls through to `solve`,
1107    /// which re-factorizes — and because `PdFullSpaceSolver`'s iterative
1108    /// refinement and its same-matrix fast path both come in through
1109    /// `resolve`, that made the majority of the augmented-system solves
1110    /// on the limited-memory path re-factorize a matrix that had not
1111    /// changed. It also left `LinearSystemBackSolve` reading 0.000 s for
1112    /// a whole run, since the only back-solve timer guard lives on the
1113    /// path nothing reached (gh#698).
1114    ///
1115    /// Every condition that cannot be served falls back to `solve`, so
1116    /// this can lose an optimization but cannot change an answer — the
1117    /// same defensive shape as `StdAugSystemSolver::resolve`'s own
1118    /// `have_factor` fallback.
1119    fn resolve(
1120        &mut self,
1121        coeffs: &AugSysCoeffs<'_>,
1122        rhs: &AugSysRhs<'_>,
1123        sol: &mut AugSysSol<'_>,
1124    ) -> ESymSolverStatus {
1125        // The fast path is only valid for a low-rank W whose SMW
1126        // factorization we hold and whose coefficients have not moved
1127        // since we built it. `first_call` additionally guarantees
1128        // `self.factor.wdiag` is populated for `inner_coeffs`.
1129        //
1130        // The non-low-rank bypass deliberately falls through to `solve`
1131        // rather than forwarding to `inner.resolve`: on that path the
1132        // inner solver's factor is of the caller's own W, which our
1133        // tag cache does not track, so we cannot certify it here.
1134        let is_low_rank = coeffs
1135            .w
1136            .and_then(|w| w.as_any().downcast_ref::<LowRankUpdateSymMatrix>())
1137            .is_some();
1138        if !is_low_rank
1139            || self.first_call
1140            || !self.inner_has_factor
1141            || self.augmented_system_requires_change(coeffs)
1142        {
1143            return self.solve(coeffs, rhs, sol, false, 0);
1144        }
1145
1146        let ic = inner_coeffs(&self.factor, coeffs);
1147        let status = self.inner.resolve(&ic, rhs, sol);
1148        if status != ESymSolverStatus::Success {
1149            self.inner_has_factor = false;
1150            self.inner_factor_neg_evals = None;
1151            return status;
1152        }
1153
1154        // Same correction order as `solve` (cpp:210-227).
1155        if self.factor.utilde2_x.is_some() {
1156            self.apply_smw(/*sign=*/ 1.0, /*use_u=*/ true, rhs, sol);
1157        }
1158        if self.factor.vtilde1_x.is_some() {
1159            self.apply_smw(/*sign=*/ -1.0, /*use_u=*/ false, rhs, sol);
1160        }
1161
1162        ESymSolverStatus::Success
1163    }
1164
1165    // `try_resolve_many_flat` is deliberately **not** overridden here.
1166    //
1167    // It hands back a raw `K⁻¹`-applied result that the caller
1168    // (`PdFullSpaceSolver::solve_many_cached`) unpacks and uses directly,
1169    // with no hook to apply the SMW correction afterwards. Our operator
1170    // is `(K + low-rank)⁻¹`, so forwarding the flat path to the inner
1171    // solver would silently drop the correction on every column and
1172    // return a plausible, wrong solution under a `Success` status.
1173    //
1174    // The trait default returns `None`, which the caller documents as
1175    // "fast path not taken, fall back to looping `solve`" — correct, and
1176    // the only safe answer for this wrapper unless the packed path grows
1177    // a way to post-process each column.
1178}
1179
1180impl LowRankAugSystemSolver {
1181    /// Apply one SMW correction step:
1182    ///   `b = U_or_Vᵀ · rhs;  J⁻¹J⁻ᵀ b;  sol += sign · U_or_V · b`
1183    ///
1184    /// `use_u = true` selects `(Utilde2, J2, +1)`; `false` selects
1185    /// `(Vtilde1, J1, −1)` (sign passed in by caller).
1186    fn apply_smw(&self, sign: Number, use_u: bool, rhs: &AugSysRhs<'_>, sol: &mut AugSysSol<'_>) {
1187        let (mvx, mvs, mvc, mvd, j) = if use_u {
1188            (
1189                self.factor.utilde2_x.as_ref().unwrap(),
1190                self.factor.utilde2_s.as_ref().unwrap(),
1191                self.factor.utilde2_c.as_ref().unwrap(),
1192                self.factor.utilde2_d.as_ref().unwrap(),
1193                self.factor.j2.as_ref().unwrap(),
1194            )
1195        } else {
1196            (
1197                self.factor.vtilde1_x.as_ref().unwrap(),
1198                self.factor.vtilde1_s.as_ref().unwrap(),
1199                self.factor.vtilde1_c.as_ref().unwrap(),
1200                self.factor.vtilde1_d.as_ref().unwrap(),
1201                self.factor.j1.as_ref().unwrap(),
1202            )
1203        };
1204        let n = mvx.n_cols();
1205        // Build `b = M^T · crhs` from the four blocks. Reduction order
1206        // matches upstream's CompoundVector dot, which iterates blocks
1207        // in the order x, s, c, d (`IpCompoundVector.cpp::Dot`).
1208        let mut b_vec: Vec<Number> = Vec::with_capacity(n as usize);
1209        for k in 0..n {
1210            let dot = mvx.get_vector(k).dot(rhs.rhs_x)
1211                + mvs.get_vector(k).dot(rhs.rhs_s)
1212                + mvc.get_vector(k).dot(rhs.rhs_c)
1213                + mvd.get_vector(k).dot(rhs.rhs_d);
1214            b_vec.push(dot);
1215        }
1216        let space_b = DenseVectorSpace::new(n);
1217        let mut b = space_b.make_new_dense();
1218        b.set_values(&b_vec);
1219        // Apply J⁻¹ J⁻ᵀ in-place.
1220        j.cholesky_solve_vector(&mut b);
1221        // sol += sign · M · b  per block.
1222        mvx.mult_vector(sign, &b, 1.0, sol.sol_x);
1223        mvs.mult_vector(sign, &b, 1.0, sol.sol_s);
1224        mvc.mult_vector(sign, &b, 1.0, sol.sol_c);
1225        mvd.mult_vector(sign, &b, 1.0, sol.sol_d);
1226    }
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231    use super::*;
1232    use pounce_linalg::dense_vector::DenseVectorSpace;
1233    use pounce_linalg::low_rank_update_sym_matrix::LowRankUpdateSymMatrixSpace;
1234    use std::cell::{Cell, RefCell};
1235
1236    /// Diagonal-solve stub: pretends the augmented system is just
1237    /// `(W + δ_x I) · sol_x = rhs_x` with `m_c = m_d = n_s = 0`. Reads
1238    /// `coeffs.w` as a `DiagMatrix` (i.e. the wdiag we built) and does
1239    /// a per-element divide. Plenty for the SMW test fixture.
1240    struct DiagInner {
1241        calls: Cell<usize>,
1242    }
1243    impl AugSystemSolver for DiagInner {
1244        fn provides_inertia(&self) -> bool {
1245            true
1246        }
1247        fn number_of_neg_evals(&self) -> Index {
1248            0
1249        }
1250        fn increase_quality(&mut self) -> bool {
1251            true
1252        }
1253        fn last_solve_status(&self) -> ESymSolverStatus {
1254            ESymSolverStatus::Success
1255        }
1256        fn solve(
1257            &mut self,
1258            coeffs: &AugSysCoeffs<'_>,
1259            rhs: &AugSysRhs<'_>,
1260            sol: &mut AugSysSol<'_>,
1261            _check_neg_evals: bool,
1262            _num_neg_evals: Index,
1263        ) -> ESymSolverStatus {
1264            self.calls.set(self.calls.get() + 1);
1265            let wdiag = coeffs
1266                .w
1267                .expect("DiagInner requires W")
1268                .as_any()
1269                .downcast_ref::<DiagMatrix>()
1270                .expect("DiagInner requires W to be a DiagMatrix");
1271            let diag_rc = wdiag.get_diag().expect("Wdiag has no diag set").clone();
1272            let diag = downcast_dense(diag_rc.as_ref()).expanded_values();
1273            let rhs_x = downcast_dense(rhs.rhs_x).expanded_values();
1274            let dx_vals: Option<Vec<Number>> =
1275                coeffs.d_x.map(|d| downcast_dense(d).expanded_values());
1276            let mut out = vec![0.0; rhs_x.len()];
1277            for i in 0..rhs_x.len() {
1278                let dx_i = match &dx_vals {
1279                    Some(v) => v[i],
1280                    None => 0.0,
1281                };
1282                let denom = diag[i] + dx_i + coeffs.delta_x;
1283                out[i] = rhs_x[i] / denom;
1284            }
1285            let sol_x_dv = sol
1286                .sol_x
1287                .as_any_mut()
1288                .downcast_mut::<DenseVector>()
1289                .unwrap();
1290            sol_x_dv.set_values(&out);
1291            // Other blocks stay zero — fixture has m_c = m_d = n_s = 0.
1292            ESymSolverStatus::Success
1293        }
1294    }
1295
1296    /// Inner mock that models `StdAugSystemSolver`'s factor / back-solve
1297    /// split, which `DiagInner` above does not: `solve` "factorizes" by
1298    /// capturing the diagonal it was handed, and `resolve` back-solves
1299    /// against whatever was captured last.
1300    ///
1301    /// `resolve` deliberately ignores the coefficients it is passed and
1302    /// uses the cached ones. That is what a real cached factorization
1303    /// does, and it means a wrapper that back-solves against a stale
1304    /// factor produces a visibly *wrong answer* here, not merely a wrong
1305    /// count.
1306    #[derive(Default)]
1307    struct InnerStats {
1308        factorizations: Cell<usize>,
1309        backsolves: Cell<usize>,
1310        batched_calls: Cell<usize>,
1311        batched_cols: Cell<usize>,
1312        /// Calls to the *factorizing* multi-RHS path, and the columns
1313        /// they carried. Separate from `batched_calls` so a test can
1314        /// tell "one merged call" from "a factorization plus a batch".
1315        factor_batched_calls: Cell<usize>,
1316        factor_batched_cols: Cell<usize>,
1317        factored_diag: RefCell<Vec<Number>>,
1318        factored_delta_x: Cell<Number>,
1319    }
1320
1321    struct CountingInner {
1322        stats: Rc<InnerStats>,
1323        /// When set, the mock exposes `system_dim` / `try_resolve_many_flat`
1324        /// the way `StdAugSystemSolver` does, so the batched arm of
1325        /// `multi_solve_block` is reachable. Off by default: the trait
1326        /// default `system_dim() == 0` is what a mock without the packed
1327        /// path looks like, and that is the arm the other tests exercise.
1328        packed: bool,
1329    }
1330
1331    impl CountingInner {
1332        /// Returns the mock and a handle on its counters, so the test can
1333        /// read them after the solver has taken ownership of the box.
1334        fn new() -> (Self, Rc<InnerStats>) {
1335            let stats = Rc::new(InnerStats::default());
1336            (
1337                Self {
1338                    stats: Rc::clone(&stats),
1339                    packed: false,
1340                },
1341                stats,
1342            )
1343        }
1344
1345        /// Same mock, but advertising the packed multi-RHS back-solve.
1346        fn with_packed_path() -> (Self, Rc<InnerStats>) {
1347            let stats = Rc::new(InnerStats::default());
1348            (
1349                Self {
1350                    stats: Rc::clone(&stats),
1351                    packed: true,
1352                },
1353                stats,
1354            )
1355        }
1356
1357        /// `sol_x = rhs_x / (diag + delta_x)`, from the captured factor.
1358        fn apply_cached(&self, rhs: &AugSysRhs<'_>, sol: &mut AugSysSol<'_>) {
1359            let rhs_x = downcast_dense(rhs.rhs_x).expanded_values();
1360            let diag = self.stats.factored_diag.borrow();
1361            let delta_x = self.stats.factored_delta_x.get();
1362            let out: Vec<Number> = (0..rhs_x.len())
1363                .map(|i| rhs_x[i] / (diag[i] + delta_x))
1364                .collect();
1365            sol.sol_x
1366                .as_any_mut()
1367                .downcast_mut::<DenseVector>()
1368                .unwrap()
1369                .set_values(&out);
1370        }
1371    }
1372
1373    impl AugSystemSolver for CountingInner {
1374        fn provides_inertia(&self) -> bool {
1375            false
1376        }
1377        fn number_of_neg_evals(&self) -> Index {
1378            0
1379        }
1380        fn increase_quality(&mut self) -> bool {
1381            false
1382        }
1383        fn last_solve_status(&self) -> ESymSolverStatus {
1384            ESymSolverStatus::Success
1385        }
1386        fn solve(
1387            &mut self,
1388            coeffs: &AugSysCoeffs<'_>,
1389            rhs: &AugSysRhs<'_>,
1390            sol: &mut AugSysSol<'_>,
1391            _check_neg_evals: bool,
1392            _num_neg_evals: Index,
1393        ) -> ESymSolverStatus {
1394            self.stats
1395                .factorizations
1396                .set(self.stats.factorizations.get() + 1);
1397            let wdiag = coeffs
1398                .w
1399                .expect("CountingInner requires W")
1400                .as_any()
1401                .downcast_ref::<DiagMatrix>()
1402                .expect("CountingInner requires W to be a DiagMatrix");
1403            let diag_rc = wdiag.get_diag().expect("Wdiag has no diag set").clone();
1404            *self.stats.factored_diag.borrow_mut() =
1405                downcast_dense(diag_rc.as_ref()).expanded_values();
1406            self.stats.factored_delta_x.set(coeffs.delta_x);
1407            self.apply_cached(rhs, sol);
1408            ESymSolverStatus::Success
1409        }
1410        fn resolve(
1411            &mut self,
1412            _coeffs: &AugSysCoeffs<'_>,
1413            rhs: &AugSysRhs<'_>,
1414            sol: &mut AugSysSol<'_>,
1415        ) -> ESymSolverStatus {
1416            assert!(
1417                !self.stats.factored_diag.borrow().is_empty(),
1418                "resolve reached with no cached factor"
1419            );
1420            self.stats.backsolves.set(self.stats.backsolves.get() + 1);
1421            self.apply_cached(rhs, sol);
1422            ESymSolverStatus::Success
1423        }
1424        fn system_dim(&self) -> Index {
1425            if self.packed {
1426                self.stats.factored_diag.borrow().len() as Index
1427            } else {
1428                0
1429            }
1430        }
1431        /// Mirrors `StdAugSystemSolver`: declines when cold, otherwise
1432        /// applies the cached factor to every packed column in place.
1433        /// Like the real one it ignores the coefficients it is handed and
1434        /// uses the cached ones, so back-solving against a stale factor
1435        /// shows up as a wrong answer rather than a wrong count.
1436        fn multi_solve_matches_single_solve(&self, _nrhs: usize) -> bool {
1437            self.packed
1438        }
1439
1440        /// The factorizing counterpart: captures the factor like
1441        /// `solve` does, then applies it to every packed column. One
1442        /// call, one factorization, all columns — which is the whole
1443        /// point of the path.
1444        fn try_solve_many_flat(
1445            &mut self,
1446            coeffs: &AugSysCoeffs<'_>,
1447            packed_rhs: &mut [Number],
1448            nrhs: usize,
1449            _check_neg_evals: bool,
1450            _num_neg_evals: Index,
1451        ) -> Option<ESymSolverStatus> {
1452            if !self.packed {
1453                return None;
1454            }
1455            let wdiag = coeffs
1456                .w
1457                .expect("CountingInner requires W")
1458                .as_any()
1459                .downcast_ref::<DiagMatrix>()
1460                .expect("CountingInner requires W to be a DiagMatrix");
1461            let diag_rc = wdiag.get_diag().expect("Wdiag has no diag set").clone();
1462            let diag = downcast_dense(diag_rc.as_ref()).expanded_values();
1463            let dim = diag.len();
1464            if packed_rhs.len() != dim * nrhs {
1465                return None;
1466            }
1467            *self.stats.factored_diag.borrow_mut() = diag.clone();
1468            self.stats.factored_delta_x.set(coeffs.delta_x);
1469            self.stats
1470                .factorizations
1471                .set(self.stats.factorizations.get() + 1);
1472            self.stats
1473                .factor_batched_calls
1474                .set(self.stats.factor_batched_calls.get() + 1);
1475            self.stats
1476                .factor_batched_cols
1477                .set(self.stats.factor_batched_cols.get() + nrhs);
1478            let delta_x = coeffs.delta_x;
1479            for col in packed_rhs.chunks_mut(dim) {
1480                for (i, x) in col.iter_mut().enumerate() {
1481                    *x /= diag[i] + delta_x;
1482                }
1483            }
1484            Some(ESymSolverStatus::Success)
1485        }
1486
1487        fn try_resolve_many_flat(
1488            &mut self,
1489            _coeffs: &AugSysCoeffs<'_>,
1490            packed_rhs: &mut [Number],
1491            nrhs: usize,
1492        ) -> Option<ESymSolverStatus> {
1493            if !self.packed {
1494                return None;
1495            }
1496            let diag = self.stats.factored_diag.borrow();
1497            if diag.is_empty() {
1498                return None;
1499            }
1500            let dim = diag.len();
1501            if packed_rhs.len() != dim * nrhs {
1502                return Some(ESymSolverStatus::FatalError);
1503            }
1504            self.stats
1505                .batched_calls
1506                .set(self.stats.batched_calls.get() + 1);
1507            self.stats
1508                .batched_cols
1509                .set(self.stats.batched_cols.get() + nrhs);
1510            let delta_x = self.stats.factored_delta_x.get();
1511            for col in packed_rhs.chunks_mut(dim) {
1512                for (i, x) in col.iter_mut().enumerate() {
1513                    *x /= diag[i] + delta_x;
1514                }
1515            }
1516            Some(ESymSolverStatus::Success)
1517        }
1518    }
1519
1520    fn dvec(space: &Rc<DenseVectorSpace>, vals: &[Number]) -> DenseVector {
1521        let mut v = space.make_new_dense();
1522        v.set_values(vals);
1523        v
1524    }
1525
1526    fn dvec_rc(space: &Rc<DenseVectorSpace>, vals: &[Number]) -> Rc<DenseVector> {
1527        Rc::new(dvec(space, vals))
1528    }
1529
1530    #[test]
1531    fn smw_recovers_low_rank_inverse() {
1532        // 1×1 system: W = b0 + v² (v ≠ 0); δ_x = 0.
1533        // Direct: sol = rhs / (b0 + v²).
1534        // SMW:    inner solves with diag b0 → sol_diag = rhs/b0;
1535        //         correction recovers rhs/(b0 + v²).
1536        let space_x = DenseVectorSpace::new(1);
1537        let space_zero = DenseVectorSpace::new(0);
1538        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
1539        let mut lr = lr_space.make_new_low_rank();
1540        let b0_rc: Rc<dyn Vector> = dvec_rc(&space_x, &[2.0]);
1541        lr.set_diag(b0_rc);
1542        let v_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
1543        let mut v_mvm = v_space.make_new_multi_vector();
1544        v_mvm.set_vector(0, dvec_rc(&space_x, &[3.0]) as Rc<dyn Vector>);
1545        lr.set_v(Rc::new(v_mvm));
1546        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1547
1548        let mut solver = LowRankAugSystemSolver::new(Box::new(DiagInner {
1549            calls: Cell::new(0),
1550        }));
1551
1552        // Empty Jacobians.
1553        let j_c_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1554        let j_d_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1555        let j_c = j_c_space.make_new_dense_gen();
1556        let j_d = j_d_space.make_new_dense_gen();
1557
1558        let coeffs = AugSysCoeffs {
1559            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1560            w_factor: 1.0,
1561            d_x: None,
1562            delta_x: 0.0,
1563            d_s: None,
1564            delta_s: 0.0,
1565            j_c: &j_c as &dyn Matrix,
1566            d_c: None,
1567            delta_c: 0.0,
1568            j_d: &j_d as &dyn Matrix,
1569            d_d: None,
1570            delta_d: 0.0,
1571        };
1572
1573        let rhs_x = dvec(&space_x, &[5.0]);
1574        let rhs_s = dvec(&space_zero, &[]);
1575        let rhs_c = dvec(&space_zero, &[]);
1576        let rhs_d = dvec(&space_zero, &[]);
1577        let rhs = AugSysRhs {
1578            rhs_x: &rhs_x,
1579            rhs_s: &rhs_s,
1580            rhs_c: &rhs_c,
1581            rhs_d: &rhs_d,
1582        };
1583        let mut sol_x = dvec(&space_x, &[0.0]);
1584        let mut sol_s = dvec(&space_zero, &[]);
1585        let mut sol_c = dvec(&space_zero, &[]);
1586        let mut sol_d = dvec(&space_zero, &[]);
1587        let mut sol = AugSysSol {
1588            sol_x: &mut sol_x,
1589            sol_s: &mut sol_s,
1590            sol_c: &mut sol_c,
1591            sol_d: &mut sol_d,
1592        };
1593        let status = solver.solve(&coeffs, &rhs, &mut sol, false, 0);
1594        assert_eq!(status, ESymSolverStatus::Success);
1595        // Expected: 5 / (2 + 9) = 5/11.
1596        let got = sol_x.expanded_values()[0];
1597        let want = 5.0 / 11.0;
1598        assert!((got - want).abs() < 1e-12, "got {} want {}", got, want);
1599    }
1600
1601    #[test]
1602    fn smw_with_u_only_applies_positive_correction() {
1603        // 1×1 system: W = b0 − u² (low-rank *negative* update).
1604        // Direct: sol = rhs / (b0 − u²).
1605        let space_x = DenseVectorSpace::new(1);
1606        let space_zero = DenseVectorSpace::new(0);
1607        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
1608        let mut lr = lr_space.make_new_low_rank();
1609        lr.set_diag(dvec_rc(&space_x, &[5.0]));
1610        let u_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
1611        let mut u_mvm = u_space.make_new_multi_vector();
1612        u_mvm.set_vector(0, dvec_rc(&space_x, &[1.5]) as Rc<dyn Vector>);
1613        lr.set_u(Rc::new(u_mvm));
1614        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1615
1616        let mut solver = LowRankAugSystemSolver::new(Box::new(DiagInner {
1617            calls: Cell::new(0),
1618        }));
1619
1620        let j_c_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1621        let j_d_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1622        let j_c = j_c_space.make_new_dense_gen();
1623        let j_d = j_d_space.make_new_dense_gen();
1624
1625        let coeffs = AugSysCoeffs {
1626            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1627            w_factor: 1.0,
1628            d_x: None,
1629            delta_x: 0.0,
1630            d_s: None,
1631            delta_s: 0.0,
1632            j_c: &j_c as &dyn Matrix,
1633            d_c: None,
1634            delta_c: 0.0,
1635            j_d: &j_d as &dyn Matrix,
1636            d_d: None,
1637            delta_d: 0.0,
1638        };
1639
1640        let rhs_x = dvec(&space_x, &[7.0]);
1641        let rhs_s = dvec(&space_zero, &[]);
1642        let rhs_c = dvec(&space_zero, &[]);
1643        let rhs_d = dvec(&space_zero, &[]);
1644        let rhs = AugSysRhs {
1645            rhs_x: &rhs_x,
1646            rhs_s: &rhs_s,
1647            rhs_c: &rhs_c,
1648            rhs_d: &rhs_d,
1649        };
1650        let mut sol_x = dvec(&space_x, &[0.0]);
1651        let mut sol_s = dvec(&space_zero, &[]);
1652        let mut sol_c = dvec(&space_zero, &[]);
1653        let mut sol_d = dvec(&space_zero, &[]);
1654        let mut sol = AugSysSol {
1655            sol_x: &mut sol_x,
1656            sol_s: &mut sol_s,
1657            sol_c: &mut sol_c,
1658            sol_d: &mut sol_d,
1659        };
1660        let status = solver.solve(&coeffs, &rhs, &mut sol, false, 0);
1661        assert_eq!(status, ESymSolverStatus::Success);
1662        // Expected: 7 / (5 − 2.25) = 7 / 2.75.
1663        let got = sol_x.expanded_values()[0];
1664        let want = 7.0 / 2.75;
1665        assert!((got - want).abs() < 1e-12, "got {} want {}", got, want);
1666    }
1667
1668    #[test]
1669    fn smw_reports_wrong_inertia_on_indefinite_negative_update() {
1670        // 1×1 system: W = b0 − u² with u² > b0, so B = 2 − 4 = −2 is
1671        // genuinely indefinite — the SR1 negative-curvature regime. The
1672        // SMW middle matrix M2 = 1 − Utilde2ᵀU = 1 − u²/b0 = −1 is then
1673        // not positive definite, so its Cholesky must fail and the solver
1674        // must report `WrongInertia` — the signal the perturbation handler
1675        // keys on to correct the step — rather than silently returning a
1676        // garbage solve. (`number_of_neg_evals` is not asserted here: with
1677        // a real inertia-providing inner solver it delegates to the inner;
1678        // the mock reports 0.)
1679        let space_x = DenseVectorSpace::new(1);
1680        let space_zero = DenseVectorSpace::new(0);
1681        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
1682        let mut lr = lr_space.make_new_low_rank();
1683        lr.set_diag(dvec_rc(&space_x, &[2.0]));
1684        let u_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
1685        let mut u_mvm = u_space.make_new_multi_vector();
1686        u_mvm.set_vector(0, dvec_rc(&space_x, &[2.0]) as Rc<dyn Vector>);
1687        lr.set_u(Rc::new(u_mvm));
1688        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1689
1690        let mut solver = LowRankAugSystemSolver::new(Box::new(DiagInner {
1691            calls: Cell::new(0),
1692        }));
1693
1694        let j_c_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1695        let j_d_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1696        let j_c = j_c_space.make_new_dense_gen();
1697        let j_d = j_d_space.make_new_dense_gen();
1698
1699        let coeffs = AugSysCoeffs {
1700            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1701            w_factor: 1.0,
1702            d_x: None,
1703            delta_x: 0.0,
1704            d_s: None,
1705            delta_s: 0.0,
1706            j_c: &j_c as &dyn Matrix,
1707            d_c: None,
1708            delta_c: 0.0,
1709            j_d: &j_d as &dyn Matrix,
1710            d_d: None,
1711            delta_d: 0.0,
1712        };
1713
1714        let rhs_x = dvec(&space_x, &[1.0]);
1715        let rhs_s = dvec(&space_zero, &[]);
1716        let rhs_c = dvec(&space_zero, &[]);
1717        let rhs_d = dvec(&space_zero, &[]);
1718        let rhs = AugSysRhs {
1719            rhs_x: &rhs_x,
1720            rhs_s: &rhs_s,
1721            rhs_c: &rhs_c,
1722            rhs_d: &rhs_d,
1723        };
1724        let mut sol_x = dvec(&space_x, &[0.0]);
1725        let mut sol_s = dvec(&space_zero, &[]);
1726        let mut sol_c = dvec(&space_zero, &[]);
1727        let mut sol_d = dvec(&space_zero, &[]);
1728        let mut sol = AugSysSol {
1729            sol_x: &mut sol_x,
1730            sol_s: &mut sol_s,
1731            sol_c: &mut sol_c,
1732            sol_d: &mut sol_d,
1733        };
1734        let status = solver.solve(&coeffs, &rhs, &mut sol, false, 0);
1735        assert_eq!(status, ESymSolverStatus::WrongInertia);
1736    }
1737
1738    #[test]
1739    fn smw_with_v_and_u_combines_corrections() {
1740        // 1×1 system: W = b0 + v² − u² (rank-2 update). Solve checks
1741        // both correction passes compose correctly.
1742        let space_x = DenseVectorSpace::new(1);
1743        let space_zero = DenseVectorSpace::new(0);
1744        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
1745        let mut lr = lr_space.make_new_low_rank();
1746        lr.set_diag(dvec_rc(&space_x, &[10.0]));
1747        let v_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
1748        let mut v_mvm = v_space.make_new_multi_vector();
1749        v_mvm.set_vector(0, dvec_rc(&space_x, &[2.0]) as Rc<dyn Vector>);
1750        lr.set_v(Rc::new(v_mvm));
1751        let u_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
1752        let mut u_mvm = u_space.make_new_multi_vector();
1753        u_mvm.set_vector(0, dvec_rc(&space_x, &[1.0]) as Rc<dyn Vector>);
1754        lr.set_u(Rc::new(u_mvm));
1755        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1756
1757        let mut solver = LowRankAugSystemSolver::new(Box::new(DiagInner {
1758            calls: Cell::new(0),
1759        }));
1760
1761        let j_c_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1762        let j_d_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1763        let j_c = j_c_space.make_new_dense_gen();
1764        let j_d = j_d_space.make_new_dense_gen();
1765
1766        let coeffs = AugSysCoeffs {
1767            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1768            w_factor: 1.0,
1769            d_x: None,
1770            delta_x: 0.0,
1771            d_s: None,
1772            delta_s: 0.0,
1773            j_c: &j_c as &dyn Matrix,
1774            d_c: None,
1775            delta_c: 0.0,
1776            j_d: &j_d as &dyn Matrix,
1777            d_d: None,
1778            delta_d: 0.0,
1779        };
1780
1781        let rhs_x = dvec(&space_x, &[1.0]);
1782        let rhs_s = dvec(&space_zero, &[]);
1783        let rhs_c = dvec(&space_zero, &[]);
1784        let rhs_d = dvec(&space_zero, &[]);
1785        let rhs = AugSysRhs {
1786            rhs_x: &rhs_x,
1787            rhs_s: &rhs_s,
1788            rhs_c: &rhs_c,
1789            rhs_d: &rhs_d,
1790        };
1791        let mut sol_x = dvec(&space_x, &[0.0]);
1792        let mut sol_s = dvec(&space_zero, &[]);
1793        let mut sol_c = dvec(&space_zero, &[]);
1794        let mut sol_d = dvec(&space_zero, &[]);
1795        let mut sol = AugSysSol {
1796            sol_x: &mut sol_x,
1797            sol_s: &mut sol_s,
1798            sol_c: &mut sol_c,
1799            sol_d: &mut sol_d,
1800        };
1801        let status = solver.solve(&coeffs, &rhs, &mut sol, false, 0);
1802        assert_eq!(status, ESymSolverStatus::Success);
1803        // Expected: 1 / (10 + 4 − 1) = 1/13.
1804        let got = sol_x.expanded_values()[0];
1805        let want = 1.0 / 13.0;
1806        assert!((got - want).abs() < 1e-12, "got {} want {}", got, want);
1807    }
1808
1809    #[test]
1810    fn unchanged_coeffs_skip_rebuild_after_first_call() {
1811        let mut lr_solver = LowRankAugSystemSolver::new(Box::new(DiagInner {
1812            calls: Cell::new(0),
1813        }));
1814        let space_x = DenseVectorSpace::new(1);
1815        let space_zero = DenseVectorSpace::new(0);
1816        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
1817        let mut lr = lr_space.make_new_low_rank();
1818        lr.set_diag(dvec_rc(&space_x, &[2.0]));
1819        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1820        let j_c_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1821        let j_d_space = pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, 1);
1822        let j_c = j_c_space.make_new_dense_gen();
1823        let j_d = j_d_space.make_new_dense_gen();
1824        let coeffs = AugSysCoeffs {
1825            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1826            w_factor: 1.0,
1827            d_x: None,
1828            delta_x: 0.001,
1829            d_s: None,
1830            delta_s: 0.0,
1831            j_c: &j_c as &dyn Matrix,
1832            d_c: None,
1833            delta_c: 0.0,
1834            j_d: &j_d as &dyn Matrix,
1835            d_d: None,
1836            delta_d: 0.0,
1837        };
1838        let rhs_x = dvec(&space_x, &[1.0]);
1839        let rhs_zero = dvec(&space_zero, &[]);
1840        let rhs = AugSysRhs {
1841            rhs_x: &rhs_x,
1842            rhs_s: &rhs_zero,
1843            rhs_c: &rhs_zero,
1844            rhs_d: &rhs_zero,
1845        };
1846        let mut sol_x = dvec(&space_x, &[0.0]);
1847        let mut sol_z1 = dvec(&space_zero, &[]);
1848        let mut sol_z2 = dvec(&space_zero, &[]);
1849        let mut sol_z3 = dvec(&space_zero, &[]);
1850        {
1851            let mut sol = AugSysSol {
1852                sol_x: &mut sol_x,
1853                sol_s: &mut sol_z1,
1854                sol_c: &mut sol_z2,
1855                sol_d: &mut sol_z3,
1856            };
1857            lr_solver.solve(&coeffs, &rhs, &mut sol, false, 0);
1858        }
1859        // Same coeffs → cache reports no change.
1860        assert!(!lr_solver.augmented_system_requires_change(&coeffs));
1861    }
1862
1863    // ---- gh#698: factorization reuse ----
1864
1865    /// The empty `m_c = m_d = 0` Jacobians every fixture in this section
1866    /// uses, over `n_x` variables.
1867    fn empty_jacobians(
1868        n_x: Index,
1869    ) -> (
1870        pounce_linalg::dense_gen_matrix::DenseGenMatrix,
1871        pounce_linalg::dense_gen_matrix::DenseGenMatrix,
1872    ) {
1873        (
1874            pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, n_x).make_new_dense_gen(),
1875            pounce_linalg::dense_gen_matrix::DenseGenMatrixSpace::new(0, n_x).make_new_dense_gen(),
1876        )
1877    }
1878
1879    #[test]
1880    fn smw_columns_share_one_factorization() {
1881        // W = diag(2) + v vᵀ with a 3-column V. Upstream issues a single
1882        // `MultiSolve` for all columns and factorizes once; we must do
1883        // the same: 1 factorization, then back-solves for the remaining
1884        // two columns and for the diagonal solve.
1885        let space_x = DenseVectorSpace::new(3);
1886        let space_zero = DenseVectorSpace::new(0);
1887        let lr_space = LowRankUpdateSymMatrixSpace::new(3, None, false);
1888        let mut lr = lr_space.make_new_low_rank();
1889        lr.set_diag(dvec_rc(&space_x, &[2.0, 3.0, 4.0]));
1890        let v_space = MultiVectorMatrixSpace::new(3, Rc::clone(&space_x));
1891        let mut v = v_space.make_new_multi_vector();
1892        v.set_vector(0, dvec_rc(&space_x, &[0.5, 0.0, 0.0]));
1893        v.set_vector(1, dvec_rc(&space_x, &[0.0, 0.5, 0.0]));
1894        v.set_vector(2, dvec_rc(&space_x, &[0.0, 0.0, 0.5]));
1895        lr.set_v(Rc::new(v));
1896        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1897        let (j_c, j_d) = empty_jacobians(3);
1898        let delta_x = 0.0;
1899
1900        let (inner, stats) = CountingInner::new();
1901        let mut solver = LowRankAugSystemSolver::new(Box::new(inner));
1902
1903        let coeffs = AugSysCoeffs {
1904            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1905            w_factor: 1.0,
1906            d_x: None,
1907            delta_x,
1908            d_s: None,
1909            delta_s: 0.0,
1910            j_c: &j_c as &dyn Matrix,
1911            d_c: None,
1912            delta_c: 0.0,
1913            j_d: &j_d as &dyn Matrix,
1914            d_d: None,
1915            delta_d: 0.0,
1916        };
1917        let rhs_x = dvec(&space_x, &[1.0, 1.0, 1.0]);
1918        let rhs_zero = dvec(&space_zero, &[]);
1919        let rhs = AugSysRhs {
1920            rhs_x: &rhs_x,
1921            rhs_s: &rhs_zero,
1922            rhs_c: &rhs_zero,
1923            rhs_d: &rhs_zero,
1924        };
1925        let (mut sx, mut z1, mut z2, mut z3) = (
1926            dvec(&space_x, &[0.0, 0.0, 0.0]),
1927            dvec(&space_zero, &[]),
1928            dvec(&space_zero, &[]),
1929            dvec(&space_zero, &[]),
1930        );
1931        {
1932            let mut sol = AugSysSol {
1933                sol_x: &mut sx,
1934                sol_s: &mut z1,
1935                sol_c: &mut z2,
1936                sol_d: &mut z3,
1937            };
1938            assert_eq!(
1939                solver.solve(&coeffs, &rhs, &mut sol, false, 0),
1940                ESymSolverStatus::Success
1941            );
1942        }
1943        assert_eq!(
1944            stats.factorizations.get(),
1945            1,
1946            "three SMW columns plus the diagonal solve must share one factorization"
1947        );
1948        assert_eq!(
1949            stats.backsolves.get(),
1950            3,
1951            "2 remaining V columns + diagonal solve"
1952        );
1953    }
1954
1955    #[test]
1956    fn batched_smw_columns_match_the_per_column_path() {
1957        // The batched arm of `multi_solve_block` is unreachable from a
1958        // mock that leaves `system_dim()` at its 0 default, so every
1959        // other test in this file exercises the per-column fallback and
1960        // a green suite says nothing about the packed path (gh#729).
1961        // Drive the identical problem down both arms and require the
1962        // solutions to agree bit-for-bit: the batched call must be work
1963        // removed, not work re-associated.
1964        fn run(packed: bool) -> (Vec<Number>, Rc<InnerStats>) {
1965            let space_x = DenseVectorSpace::new(3);
1966            let space_zero = DenseVectorSpace::new(0);
1967            let lr_space = LowRankUpdateSymMatrixSpace::new(3, None, false);
1968            let mut lr = lr_space.make_new_low_rank();
1969            lr.set_diag(dvec_rc(&space_x, &[2.0, 3.0, 4.0]));
1970            let v_space = MultiVectorMatrixSpace::new(3, Rc::clone(&space_x));
1971            let mut v = v_space.make_new_multi_vector();
1972            v.set_vector(0, dvec_rc(&space_x, &[0.5, 0.25, 0.0]));
1973            v.set_vector(1, dvec_rc(&space_x, &[0.0, 0.5, 0.125]));
1974            v.set_vector(2, dvec_rc(&space_x, &[0.25, 0.0, 0.5]));
1975            lr.set_v(Rc::new(v));
1976            let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
1977            let (j_c, j_d) = empty_jacobians(3);
1978
1979            let (inner, stats) = if packed {
1980                CountingInner::with_packed_path()
1981            } else {
1982                CountingInner::new()
1983            };
1984            let mut solver = LowRankAugSystemSolver::new(Box::new(inner));
1985            let coeffs = AugSysCoeffs {
1986                w: Some(lr_rc.as_ref() as &dyn SymMatrix),
1987                w_factor: 1.0,
1988                d_x: None,
1989                delta_x: 0.0,
1990                d_s: None,
1991                delta_s: 0.0,
1992                j_c: &j_c as &dyn Matrix,
1993                d_c: None,
1994                delta_c: 0.0,
1995                j_d: &j_d as &dyn Matrix,
1996                d_d: None,
1997                delta_d: 0.0,
1998            };
1999            let rhs_x = dvec(&space_x, &[1.0, -2.0, 3.5]);
2000            let rhs_zero = dvec(&space_zero, &[]);
2001            let rhs = AugSysRhs {
2002                rhs_x: &rhs_x,
2003                rhs_s: &rhs_zero,
2004                rhs_c: &rhs_zero,
2005                rhs_d: &rhs_zero,
2006            };
2007            let (mut sx, mut z1, mut z2, mut z3) = (
2008                dvec(&space_x, &[0.0, 0.0, 0.0]),
2009                dvec(&space_zero, &[]),
2010                dvec(&space_zero, &[]),
2011                dvec(&space_zero, &[]),
2012            );
2013            {
2014                let mut sol = AugSysSol {
2015                    sol_x: &mut sx,
2016                    sol_s: &mut z1,
2017                    sol_c: &mut z2,
2018                    sol_d: &mut z3,
2019                };
2020                assert_eq!(
2021                    solver.solve(&coeffs, &rhs, &mut sol, false, 0),
2022                    ESymSolverStatus::Success
2023                );
2024            }
2025            (sx.expanded_values(), stats)
2026        }
2027
2028        let (sol_loop, stats_loop) = run(false);
2029        let (sol_batch, stats_batch) = run(true);
2030
2031        assert_eq!(
2032            stats_loop.batched_calls.get(),
2033            0,
2034            "a mock without the packed path must take the per-column arm"
2035        );
2036        assert!(
2037            stats_batch.factor_batched_calls.get() >= 1,
2038            "the packed mock must actually reach the merged arm — a mock \
2039             whose `system_dim()` is 0 when cold silently would not, and \
2040             then this test would be green about code it never ran"
2041        );
2042        assert_eq!(
2043            stats_batch.factorizations.get(),
2044            1,
2045            "batching must not cost an extra factorization"
2046        );
2047        assert_eq!(
2048            sol_loop, sol_batch,
2049            "batched and per-column arms must agree bit-for-bit"
2050        );
2051
2052        // All three V columns ride along with the factorization in a
2053        // single backend call. Before the merge this read
2054        // `factor_batched_calls == 0`, `batched_cols == 2` — column 0
2055        // through the single-RHS `solve`, then a second call carrying
2056        // the other two. That second call streams the whole factor
2057        // again, which is the cost this removes.
2058        assert_eq!(
2059            stats_batch.factor_batched_calls.get(),
2060            1,
2061            "the cold SMW block must be ONE factorizing multi-RHS call"
2062        );
2063        assert_eq!(
2064            stats_batch.factor_batched_cols.get(),
2065            3,
2066            "all 3 V columns must ride along with the factorization"
2067        );
2068        assert_eq!(
2069            stats_batch.batched_calls.get(),
2070            0,
2071            "no separate back-solve pass may remain over the same factor"
2072        );
2073        // One `resolve` remains, and must: it is the actual RHS being
2074        // solved against the same factor after the V columns have built
2075        // the SMW correction. Upstream pays it too. What the merge
2076        // removes is the second pass over the factor that used to carry
2077        // the V columns.
2078        assert_eq!(
2079            stats_batch.backsolves.get(),
2080            1,
2081            "only the main RHS solve may remain over the cached factor"
2082        );
2083    }
2084
2085    #[test]
2086    fn resolve_reuses_the_factor_instead_of_refactorizing() {
2087        // `PdFullSpaceSolver`'s refinement loop and its same-matrix fast
2088        // path both come in through `resolve`. Against an unchanged
2089        // matrix that must be a pure back-substitution.
2090        let space_x = DenseVectorSpace::new(1);
2091        let space_zero = DenseVectorSpace::new(0);
2092        let lr_space = LowRankUpdateSymMatrixSpace::new(1, None, false);
2093        let mut lr = lr_space.make_new_low_rank();
2094        lr.set_diag(dvec_rc(&space_x, &[2.0]));
2095        let lr_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr);
2096        let (j_c, j_d) = empty_jacobians(1);
2097        let delta_x = 0.001;
2098
2099        let (inner, stats) = CountingInner::new();
2100        let mut solver = LowRankAugSystemSolver::new(Box::new(inner));
2101
2102        let coeffs = AugSysCoeffs {
2103            w: Some(lr_rc.as_ref() as &dyn SymMatrix),
2104            w_factor: 1.0,
2105            d_x: None,
2106            delta_x,
2107            d_s: None,
2108            delta_s: 0.0,
2109            j_c: &j_c as &dyn Matrix,
2110            d_c: None,
2111            delta_c: 0.0,
2112            j_d: &j_d as &dyn Matrix,
2113            d_d: None,
2114            delta_d: 0.0,
2115        };
2116        let rhs_x = dvec(&space_x, &[1.0]);
2117        let rhs_zero = dvec(&space_zero, &[]);
2118        let rhs = AugSysRhs {
2119            rhs_x: &rhs_x,
2120            rhs_s: &rhs_zero,
2121            rhs_c: &rhs_zero,
2122            rhs_d: &rhs_zero,
2123        };
2124        let expected = 1.0 / (2.0 + delta_x);
2125
2126        for round in 0..4 {
2127            let (mut sx, mut z1, mut z2, mut z3) = (
2128                dvec(&space_x, &[0.0]),
2129                dvec(&space_zero, &[]),
2130                dvec(&space_zero, &[]),
2131                dvec(&space_zero, &[]),
2132            );
2133            // Scoped so the mutable borrow of `sx` ends before it is read,
2134            // matching the other tests here.
2135            {
2136                let mut sol = AugSysSol {
2137                    sol_x: &mut sx,
2138                    sol_s: &mut z1,
2139                    sol_c: &mut z2,
2140                    sol_d: &mut z3,
2141                };
2142                let status = if round == 0 {
2143                    solver.solve(&coeffs, &rhs, &mut sol, false, 0)
2144                } else {
2145                    solver.resolve(&coeffs, &rhs, &mut sol)
2146                };
2147                assert_eq!(status, ESymSolverStatus::Success);
2148            }
2149            assert!(
2150                (sx.expanded_values()[0] - expected).abs() < 1e-12,
2151                "round {round} gave {:?}",
2152                sx.expanded_values()
2153            );
2154        }
2155        assert_eq!(
2156            stats.factorizations.get(),
2157            1,
2158            "three refinement re-solves must not re-factorize"
2159        );
2160    }
2161
2162    #[test]
2163    fn rebuild_with_empty_history_still_factorizes() {
2164        // Regression guard for the sharp edge in this optimization.
2165        //
2166        // `update_factorization` performs *zero* inner solves when the
2167        // L-BFGS history is empty (`get_v()` and `get_u()` both `None`).
2168        // That happens on the first iteration and again whenever
2169        // `limited_memory_max_skipping` clears the history mid-solve
2170        // (gh#686, the `Wr` info string) — so it is reachable with the
2171        // inner solver warm, holding the *previous* iterate's factor.
2172        //
2173        // If `inner_has_factor` were set as a consequence of the rebuild
2174        // rather than of an actual inner solve, the diagonal solve would
2175        // back-substitute against that stale factor: wrong direction, no
2176        // error, and `StdAugSystemSolver::have_factor` would not catch it
2177        // because it is not cold. `CountingInner::resolve` reproduces
2178        // exactly that by answering from its cached diagonal, so this
2179        // asserts on the value as well as the count.
2180        let space_x = DenseVectorSpace::new(1);
2181        let space_zero = DenseVectorSpace::new(0);
2182        let space_lr = LowRankUpdateSymMatrixSpace::new(1, None, false);
2183
2184        // Round 1: history present, σ = 2.
2185        let mut lr1 = space_lr.make_new_low_rank();
2186        lr1.set_diag(dvec_rc(&space_x, &[2.0]));
2187        let v_space = MultiVectorMatrixSpace::new(1, Rc::clone(&space_x));
2188        let mut v = v_space.make_new_multi_vector();
2189        v.set_vector(0, dvec_rc(&space_x, &[0.5]));
2190        lr1.set_v(Rc::new(v));
2191        let lr1_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr1);
2192
2193        // Round 2: history cleared, σ moved to 7. No V, no U.
2194        let mut lr2 = LowRankUpdateSymMatrixSpace::new(1, None, false).make_new_low_rank();
2195        lr2.set_diag(dvec_rc(&space_x, &[7.0]));
2196        let lr2_rc: Rc<LowRankUpdateSymMatrix> = Rc::new(lr2);
2197
2198        let (j_c, j_d) = empty_jacobians(1);
2199
2200        let (inner, stats) = CountingInner::new();
2201        let mut solver = LowRankAugSystemSolver::new(Box::new(inner));
2202
2203        let rhs_x = dvec(&space_x, &[1.0]);
2204        let rhs_zero = dvec(&space_zero, &[]);
2205        let rhs = AugSysRhs {
2206            rhs_x: &rhs_x,
2207            rhs_s: &rhs_zero,
2208            rhs_c: &rhs_zero,
2209            rhs_d: &rhs_zero,
2210        };
2211
2212        let run = |lr: &Rc<LowRankUpdateSymMatrix>, solver: &mut LowRankAugSystemSolver| {
2213            let coeffs = AugSysCoeffs {
2214                w: Some(lr.as_ref() as &dyn SymMatrix),
2215                w_factor: 1.0,
2216                d_x: None,
2217                delta_x: 0.0,
2218                d_s: None,
2219                delta_s: 0.0,
2220                j_c: &j_c as &dyn Matrix,
2221                d_c: None,
2222                delta_c: 0.0,
2223                j_d: &j_d as &dyn Matrix,
2224                d_d: None,
2225                delta_d: 0.0,
2226            };
2227            let (mut sx, mut z1, mut z2, mut z3) = (
2228                dvec(&space_x, &[0.0]),
2229                dvec(&space_zero, &[]),
2230                dvec(&space_zero, &[]),
2231                dvec(&space_zero, &[]),
2232            );
2233            {
2234                let mut sol = AugSysSol {
2235                    sol_x: &mut sx,
2236                    sol_s: &mut z1,
2237                    sol_c: &mut z2,
2238                    sol_d: &mut z3,
2239                };
2240                assert_eq!(
2241                    solver.solve(&coeffs, &rhs, &mut sol, false, 0),
2242                    ESymSolverStatus::Success
2243                );
2244            }
2245            sx.expanded_values()[0]
2246        };
2247
2248        run(&lr1_rc, &mut solver);
2249        let after_first = stats.factorizations.get();
2250
2251        // Preconditions, asserted rather than assumed. Without these the
2252        // test still passes if the fixtures drift out from under it, but
2253        // stops testing the empty-history path -- it would be checking an
2254        // ordinary rebuild and reporting a pass. (The same vacuity trap
2255        // feral#179 hit building its full-budget refinement oracle: nothing
2256        // merely ill-conditioned reaches the budget, so an oracle that is
2257        // not pinned proves nothing.)
2258        //
2259        // 1. Round 1 must have left the hazard live: a factor cached and
2260        //    `inner_has_factor` set. That flag is private, so assert its
2261        //    observable consequence -- an identical re-solve reuses it.
2262        let repeat = run(&lr1_rc, &mut solver);
2263        assert_eq!(
2264            stats.factorizations.get(),
2265            after_first,
2266            "round 1 must leave a reusable factor, else round 2 has no \
2267             stale factor to wrongly reuse and this test is vacuous"
2268        );
2269        // 1/(2 + 0.5^2): the inner solve answers 1/2 from the cached
2270        // Wdiag factor and the SMW correction for V then applies on top.
2271        // Round 2 has no V, which is why its expected value below is the
2272        // bare 1/7 and why a stale factor there shows up as 1/2.
2273        assert!(
2274            (repeat - 4.0 / 9.0).abs() < 1e-12,
2275            "round 1 re-solve should answer 4/9 from the cached factor, \
2276             got {repeat}"
2277        );
2278
2279        // 2. Round 2's matrix must genuinely carry no history, which is what
2280        //    makes `update_factorization` perform zero inner solves.
2281        assert!(
2282            lr2_rc.get_v().is_none() && lr2_rc.get_u().is_none(),
2283            "round 2 fixture must have empty L-BFGS history"
2284        );
2285
2286        let got = run(&lr2_rc, &mut solver);
2287
2288        assert_eq!(
2289            stats.factorizations.get(),
2290            after_first + 1,
2291            "a rebuild that performs no inner solve of its own must leave \
2292             the diagonal solve to factorize the new Wdiag"
2293        );
2294        // No V and no U, so the SMW correction is empty and the answer is
2295        // just the diagonal solve against the *new* σ. Back-solving
2296        // against round 1's factor would give 1/2, not 1/7.
2297        assert!(
2298            (got - 1.0 / 7.0).abs() < 1e-12,
2299            "expected the new Wdiag (1/7), got {got} — stale factor reused"
2300        );
2301    }
2302}