Skip to main content

pounce_algorithm/kkt/
schur_aug_system_solver.rs

1//! Block-triangular / Schur augmented-system solver (pounce#180 item 2, Phase 2).
2//!
3//! Wraps the standard [`StdAugSystemSolver`] — reusing its exact KKT assembly
4//! and RHS packing — but routes the assembled system through a
5//! [`FeralSchurSolver`] over a caller-supplied F/S partition (see
6//! `crates/pounce-feral/src/schur.rs`). The partition is given as KKT-space
7//! indices (`0..dim` in the `x, s, c, d` block order `StdAugSystemSolver`
8//! assembles); the `S` block is Schur-complemented out and only the two
9//! diagonal blocks are factorized, with inertia recovered a priori via
10//! Sylvester's law.
11//!
12//! **Gate + fallback (first-class, per the scope doc).** The Schur path helps
13//! only when `n_schur ≪ n_f`; and it is feral-specific. This wrapper falls back
14//! to the plain `StdAugSystemSolver` — transparently, preserving every solve —
15//! whenever: the Schur fraction exceeds `max_schur_frac`, the partition is
16//! malformed for the current KKT dimension, or the Schur backend reports a
17//! `FatalError`. A fallback is permanent for the rest of the solve (the KKT
18//! pattern is fixed across IPM iterations, so re-deciding every iterate is
19//! pointless).
20
21use std::rc::Rc;
22
23use crate::kkt::aug_system_solver::{AugSysCoeffs, AugSysRhs, AugSysSol, AugSystemSolver};
24use crate::kkt::std_aug_system_solver::StdAugSystemSolver;
25use pounce_common::diagnostics::DiagnosticsState;
26use pounce_common::timing::TimingStatistics;
27use pounce_common::types::{Index, Number};
28use pounce_feral::{FeralConfig, FeralSchurSolver};
29use pounce_linsol::{ESymSolverStatus, FactorPattern};
30
31/// Default upper bound on `n_schur / dim`. Beyond this the dense `S`
32/// (`O(n_s²)` store, `O(n_s³)` factor) makes the Schur path lose to a
33/// monolithic factorization, so we fall back. Lenient by default — the caller
34/// opts in by supplying the block, and knows its structure — but guards against
35/// a pathological "Schur block is most of the matrix" request.
36const DEFAULT_MAX_SCHUR_FRAC: f64 = 0.5;
37
38pub struct SchurAugSystemSolver {
39    /// KKT assembly + the fallback solver.
40    inner: StdAugSystemSolver,
41    schur: FeralSchurSolver,
42    /// Caller-supplied Schur block, KKT-space indices.
43    schur_indices: Vec<usize>,
44    max_schur_frac: f64,
45
46    /// `None` until the partition has been validated against a concrete KKT
47    /// dimension; `Some(dim)` records what it was pinned for (re-decide only if
48    /// the dimension changes, which it does not within one solve).
49    decided_for_dim: Option<Index>,
50    /// After [`Self::decided_for_dim`] is set: whether the Schur path is active.
51    /// `false` means permanent fallback to `inner`.
52    use_schur: bool,
53    have_factor: bool,
54    negevals: Index,
55    last_status: ESymSolverStatus,
56    timing: Option<Rc<TimingStatistics>>,
57}
58
59impl SchurAugSystemSolver {
60    /// Wrap `inner` with a Schur backend over `schur_indices` (KKT-space).
61    /// The Schur block's per-block feral solvers are configured from `cfg`
62    /// (same knobs as the monolithic feral backend).
63    pub fn new(inner: StdAugSystemSolver, schur_indices: Vec<usize>, cfg: FeralConfig) -> Self {
64        Self {
65            inner,
66            schur: FeralSchurSolver::new(cfg),
67            schur_indices,
68            max_schur_frac: DEFAULT_MAX_SCHUR_FRAC,
69            decided_for_dim: None,
70            use_schur: false,
71            have_factor: false,
72            negevals: 0,
73            last_status: ESymSolverStatus::Success,
74            timing: None,
75        }
76    }
77
78    /// Decide (once per KKT dimension) whether the Schur path is usable and, if
79    /// so, pin its structure. `irn/jcn` are the assembled lower-triangle
80    /// triplet from `inner`.
81    fn decide(&mut self, dim: Index) {
82        if self.decided_for_dim == Some(dim) {
83            return;
84        }
85        self.decided_for_dim = Some(dim);
86        self.use_schur = false;
87        let n_s = self.schur_indices.len();
88        let d = dim as usize;
89        if n_s == 0 || n_s >= d {
90            return;
91        }
92        if (n_s as f64) / (d as f64) > self.max_schur_frac {
93            tracing::warn!(
94                target: "pounce::kkt",
95                n_schur = n_s, dim = d, max_frac = self.max_schur_frac,
96                "Schur block too large relative to the KKT; using the standard solver"
97            );
98            return;
99        }
100        // Copy the triplet out of `inner` before touching `self.schur`
101        // (disjoint fields, but the borrow checker only sees whole-`self`).
102        let (irn, jcn) = {
103            let (a, b, _v) = self.inner.assembled_triplet();
104            (a.to_vec(), b.to_vec())
105        };
106        let st = self
107            .schur
108            .initialize_structure(dim, &irn, &jcn, &self.schur_indices);
109        if st == ESymSolverStatus::Success {
110            self.use_schur = true;
111        } else {
112            tracing::warn!(
113                target: "pounce::kkt",
114                "Schur partition rejected by the backend; using the standard solver"
115            );
116        }
117    }
118
119    /// Run the Schur factor + block backsolve for one RHS. Assumes `inner` has
120    /// already assembled and `use_schur` is set. Returns the factor status;
121    /// on `Success` the solution is written to `sol`.
122    fn schur_solve_one(
123        &mut self,
124        rhs: &AugSysRhs<'_>,
125        sol: &mut AugSysSol<'_>,
126        check_neg_evals: bool,
127        num_neg_evals: Index,
128    ) -> ESymSolverStatus {
129        let dim = self.inner.assembled_dim() as usize;
130        // Refill the Schur backend's values from the freshly assembled KKT.
131        let vals = self.inner.assembled_triplet().2.to_vec();
132        self.schur.values_array_mut().copy_from_slice(&vals);
133
134        let status = {
135            let _g = self
136                .timing
137                .as_deref()
138                .map(|t| t.linear_system_factorization.guard());
139            self.schur.factor(check_neg_evals, num_neg_evals)
140        };
141        self.last_status = status;
142        match status {
143            ESymSolverStatus::Success => {
144                self.negevals = self.schur.number_of_neg_evals();
145                let mut packed = vec![0.0; dim];
146                self.inner.pack_rhs(rhs, &mut packed);
147                let bstat = {
148                    let _g = self
149                        .timing
150                        .as_deref()
151                        .map(|t| t.linear_system_back_solve.guard());
152                    self.schur.backsolve(1, &mut packed)
153                };
154                if bstat != ESymSolverStatus::Success {
155                    self.have_factor = false;
156                    self.last_status = bstat;
157                    return bstat;
158                }
159                self.inner.unpack_sol(&packed, sol);
160                self.have_factor = true;
161                ESymSolverStatus::Success
162            }
163            ESymSolverStatus::WrongInertia => {
164                // Both diagonal blocks factored, but the combined (Sylvester)
165                // inertia is wrong: surface the count so the IPM's δ-perturbation
166                // loop reacts, exactly as the monolithic path. The perturbation
167                // reaches both blocks (δ_x/δ_s → A_FF, δ_c/δ_d → A_SS), so the
168                // next re-factor can correct it.
169                self.negevals = self.schur.number_of_neg_evals();
170                self.have_factor = false;
171                status
172            }
173            // Singular (a diagonal block itself is rank-deficient — the Schur
174            // precondition is violated for this iterate) or FatalError/CallAgain:
175            // return the sentinel and let `solve` fall back to the monolithic
176            // solver, which regularizes the *full* system correctly. Bumping
177            // δ_c (where `perturb_for_singular` routes a `Singular`) would not
178            // fix a singular A_FF, so we do not surface `Singular` upward.
179            other => {
180                self.have_factor = false;
181                other
182            }
183        }
184    }
185}
186
187impl AugSystemSolver for SchurAugSystemSolver {
188    fn provides_inertia(&self) -> bool {
189        // Both the Schur (feral) path and the fallback backend report inertia.
190        self.inner.provides_inertia()
191    }
192
193    fn number_of_neg_evals(&self) -> Index {
194        if self.use_schur {
195            self.negevals
196        } else {
197            self.inner.number_of_neg_evals()
198        }
199    }
200
201    fn system_dim(&self) -> Index {
202        self.inner.system_dim()
203    }
204
205    fn kkt_triplets(&self) -> Option<(Index, Vec<Index>, Vec<Index>, Vec<Number>)> {
206        self.inner.kkt_triplets()
207    }
208
209    fn l_factor(&self, want_values: bool) -> Option<FactorPattern> {
210        // The Schur path has no single monolithic L factor; only the fallback
211        // (monolithic) path can expose one.
212        if self.use_schur {
213            None
214        } else {
215            self.inner.l_factor(want_values)
216        }
217    }
218
219    fn increase_quality(&mut self) -> bool {
220        self.have_factor = false;
221        if self.use_schur {
222            self.schur.increase_quality()
223        } else {
224            self.inner.increase_quality()
225        }
226    }
227
228    fn last_solve_status(&self) -> ESymSolverStatus {
229        if self.use_schur {
230            self.last_status
231        } else {
232            self.inner.last_solve_status()
233        }
234    }
235
236    fn set_timing_stats(&mut self, timing: Rc<TimingStatistics>) {
237        self.timing = Some(Rc::clone(&timing));
238        self.inner.set_timing_stats(timing);
239    }
240
241    fn set_diagnostics(&mut self, diag: Rc<DiagnosticsState>) {
242        self.inner.set_diagnostics(diag);
243    }
244
245    fn solve(
246        &mut self,
247        coeffs: &AugSysCoeffs<'_>,
248        rhs: &AugSysRhs<'_>,
249        sol: &mut AugSysSol<'_>,
250        check_neg_evals: bool,
251        num_neg_evals: Index,
252    ) -> ESymSolverStatus {
253        // Assemble the KKT once (reused by whichever path runs).
254        let s = self.inner.assemble(coeffs);
255        if s != ESymSolverStatus::Success {
256            self.last_status = s;
257            return s;
258        }
259        let dim = self.inner.assembled_dim();
260        self.decide(dim);
261
262        if self.use_schur {
263            let st = self.schur_solve_one(rhs, sol, check_neg_evals, num_neg_evals);
264            match st {
265                ESymSolverStatus::Success | ESymSolverStatus::WrongInertia => return st,
266                // Singular block / FatalError / CallAgain → permanent fallback.
267                // Re-run this solve through the monolithic path so the IPM never
268                // sees a spurious failure and gets correct full-system
269                // regularization for the rest of the run.
270                _ => {
271                    tracing::warn!(
272                        target: "pounce::kkt",
273                        status = ?st,
274                        "Schur backend could not factor this KKT; falling back to the standard solver"
275                    );
276                    self.use_schur = false;
277                    return self
278                        .inner
279                        .solve(coeffs, rhs, sol, check_neg_evals, num_neg_evals);
280                }
281            }
282        }
283        self.inner
284            .solve(coeffs, rhs, sol, check_neg_evals, num_neg_evals)
285    }
286
287    fn resolve(
288        &mut self,
289        coeffs: &AugSysCoeffs<'_>,
290        rhs: &AugSysRhs<'_>,
291        sol: &mut AugSysSol<'_>,
292    ) -> ESymSolverStatus {
293        if self.use_schur {
294            if self.have_factor {
295                // Back-substitution only, against the cached Schur factor.
296                let dim = self.inner.assembled_dim() as usize;
297                let mut packed = vec![0.0; dim];
298                self.inner.pack_rhs(rhs, &mut packed);
299                let bstat = {
300                    let _g = self
301                        .timing
302                        .as_deref()
303                        .map(|t| t.linear_system_back_solve.guard());
304                    self.schur.backsolve(1, &mut packed)
305                };
306                if bstat == ESymSolverStatus::Success {
307                    self.inner.unpack_sol(&packed, sol);
308                }
309                return bstat;
310            }
311            // No cached factor — do a full solve.
312            return self.solve(coeffs, rhs, sol, false, 0);
313        }
314        self.inner.resolve(coeffs, rhs, sol)
315    }
316}