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    /// Record the Schur backend's factorizations into a shared summary sink
79    /// (see [`pounce_linsol::summary::LinearSolverSummary::schur`]). A fallback
80    /// to `inner` records through `inner`'s own backend instead, so
81    /// `summary.schur` stays absent and says which path ran.
82    pub fn with_summary_sink(
83        mut self,
84        sink: std::sync::Arc<std::sync::Mutex<pounce_linsol::summary::LinearSolverSummary>>,
85    ) -> Self {
86        self.schur = self.schur.with_summary_sink(sink);
87        self
88    }
89
90    /// Decide (once per KKT dimension) whether the Schur path is usable and, if
91    /// so, pin its structure. `irn/jcn` are the assembled lower-triangle
92    /// triplet from `inner`.
93    fn decide(&mut self, dim: Index) {
94        if self.decided_for_dim == Some(dim) {
95            return;
96        }
97        self.decided_for_dim = Some(dim);
98        self.use_schur = false;
99        let n_s = self.schur_indices.len();
100        let d = dim as usize;
101        if n_s == 0 || n_s >= d {
102            return;
103        }
104        if (n_s as f64) / (d as f64) > self.max_schur_frac {
105            tracing::warn!(
106                target: "pounce::kkt",
107                n_schur = n_s, dim = d, max_frac = self.max_schur_frac,
108                "Schur block too large relative to the KKT; using the standard solver"
109            );
110            return;
111        }
112        // Copy the triplet out of `inner` before touching `self.schur`
113        // (disjoint fields, but the borrow checker only sees whole-`self`).
114        let (irn, jcn) = {
115            let (a, b, _v) = self.inner.assembled_triplet();
116            (a.to_vec(), b.to_vec())
117        };
118        let st = self
119            .schur
120            .initialize_structure(dim, &irn, &jcn, &self.schur_indices);
121        if st == ESymSolverStatus::Success {
122            self.use_schur = true;
123        } else {
124            tracing::warn!(
125                target: "pounce::kkt",
126                "Schur partition rejected by the backend; using the standard solver"
127            );
128        }
129    }
130
131    /// Run the Schur factor + block backsolve for one RHS. Assumes `inner` has
132    /// already assembled and `use_schur` is set. Returns the factor status;
133    /// on `Success` the solution is written to `sol`.
134    fn schur_solve_one(
135        &mut self,
136        rhs: &AugSysRhs<'_>,
137        sol: &mut AugSysSol<'_>,
138        check_neg_evals: bool,
139        num_neg_evals: Index,
140    ) -> ESymSolverStatus {
141        let dim = self.inner.assembled_dim() as usize;
142        // Refill the Schur backend's values from the freshly assembled KKT.
143        let vals = self.inner.assembled_triplet().2.to_vec();
144        self.schur.values_array_mut().copy_from_slice(&vals);
145
146        let status = {
147            let _g = self
148                .timing
149                .as_deref()
150                .map(|t| t.linear_system_factorization.guard());
151            self.schur.factor(check_neg_evals, num_neg_evals)
152        };
153        self.last_status = status;
154        match status {
155            ESymSolverStatus::Success => {
156                self.negevals = self.schur.number_of_neg_evals();
157                let mut packed = vec![0.0; dim];
158                self.inner.pack_rhs(rhs, &mut packed);
159                let bstat = {
160                    let _g = self
161                        .timing
162                        .as_deref()
163                        .map(|t| t.linear_system_back_solve.guard());
164                    self.schur.backsolve(1, &mut packed)
165                };
166                if bstat != ESymSolverStatus::Success {
167                    self.have_factor = false;
168                    self.last_status = bstat;
169                    return bstat;
170                }
171                self.inner.unpack_sol(&packed, sol);
172                self.have_factor = true;
173                ESymSolverStatus::Success
174            }
175            ESymSolverStatus::WrongInertia => {
176                // Both diagonal blocks factored, but the combined (Sylvester)
177                // inertia is wrong: surface the count so the IPM's δ-perturbation
178                // loop reacts, exactly as the monolithic path. The perturbation
179                // reaches both blocks (δ_x/δ_s → A_FF, δ_c/δ_d → A_SS), so the
180                // next re-factor can correct it.
181                self.negevals = self.schur.number_of_neg_evals();
182                self.have_factor = false;
183                status
184            }
185            // Singular (a diagonal block itself is rank-deficient — the Schur
186            // precondition is violated for this iterate) or FatalError/CallAgain:
187            // return the sentinel and let `solve` fall back to the monolithic
188            // solver, which regularizes the *full* system correctly. Bumping
189            // δ_c (where `perturb_for_singular` routes a `Singular`) would not
190            // fix a singular A_FF, so we do not surface `Singular` upward.
191            other => {
192                self.have_factor = false;
193                other
194            }
195        }
196    }
197}
198
199impl AugSystemSolver for SchurAugSystemSolver {
200    fn provides_inertia(&self) -> bool {
201        // Both the Schur (feral) path and the fallback backend report inertia.
202        self.inner.provides_inertia()
203    }
204
205    fn singularity_certified(&self) -> bool {
206        // The Schur path does not yet classify its `Singular` reports;
207        // `false` keeps the pre-gh#981 handler behaviour there.
208        if self.use_schur {
209            false
210        } else {
211            self.inner.singularity_certified()
212        }
213    }
214
215    fn number_of_neg_evals(&self) -> Index {
216        if self.use_schur {
217            self.negevals
218        } else {
219            self.inner.number_of_neg_evals()
220        }
221    }
222
223    fn system_dim(&self) -> Index {
224        self.inner.system_dim()
225    }
226
227    fn kkt_triplets(&self) -> Option<(Index, Vec<Index>, Vec<Index>, Vec<Number>)> {
228        self.inner.kkt_triplets()
229    }
230
231    fn l_factor(&self, want_values: bool) -> Option<FactorPattern> {
232        // The Schur path has no single monolithic L factor; only the fallback
233        // (monolithic) path can expose one.
234        if self.use_schur {
235            None
236        } else {
237            self.inner.l_factor(want_values)
238        }
239    }
240
241    fn increase_quality(&mut self) -> bool {
242        self.have_factor = false;
243        if self.use_schur {
244            self.schur.increase_quality()
245        } else {
246            self.inner.increase_quality()
247        }
248    }
249
250    fn last_solve_status(&self) -> ESymSolverStatus {
251        if self.use_schur {
252            self.last_status
253        } else {
254            self.inner.last_solve_status()
255        }
256    }
257
258    fn set_timing_stats(&mut self, timing: Rc<TimingStatistics>) {
259        self.timing = Some(Rc::clone(&timing));
260        self.inner.set_timing_stats(timing);
261    }
262
263    fn set_diagnostics(&mut self, diag: Rc<DiagnosticsState>) {
264        self.inner.set_diagnostics(diag);
265    }
266
267    fn solve(
268        &mut self,
269        coeffs: &AugSysCoeffs<'_>,
270        rhs: &AugSysRhs<'_>,
271        sol: &mut AugSysSol<'_>,
272        check_neg_evals: bool,
273        num_neg_evals: Index,
274    ) -> ESymSolverStatus {
275        // Assemble the KKT once (reused by whichever path runs).
276        let s = self.inner.assemble(coeffs);
277        if s != ESymSolverStatus::Success {
278            self.last_status = s;
279            return s;
280        }
281        let dim = self.inner.assembled_dim();
282        self.decide(dim);
283
284        if self.use_schur {
285            let st = self.schur_solve_one(rhs, sol, check_neg_evals, num_neg_evals);
286            match st {
287                ESymSolverStatus::Success | ESymSolverStatus::WrongInertia => return st,
288                // Singular block / FatalError / CallAgain → permanent fallback.
289                // Re-run this solve through the monolithic path so the IPM never
290                // sees a spurious failure and gets correct full-system
291                // regularization for the rest of the run.
292                _ => {
293                    tracing::warn!(
294                        target: "pounce::kkt",
295                        status = ?st,
296                        "Schur backend could not factor this KKT; falling back to the standard solver"
297                    );
298                    self.use_schur = false;
299                    return self
300                        .inner
301                        .solve(coeffs, rhs, sol, check_neg_evals, num_neg_evals);
302                }
303            }
304        }
305        self.inner
306            .solve(coeffs, rhs, sol, check_neg_evals, num_neg_evals)
307    }
308
309    fn resolve(
310        &mut self,
311        coeffs: &AugSysCoeffs<'_>,
312        rhs: &AugSysRhs<'_>,
313        sol: &mut AugSysSol<'_>,
314    ) -> ESymSolverStatus {
315        if self.use_schur {
316            if self.have_factor {
317                // Back-substitution only, against the cached Schur factor.
318                let dim = self.inner.assembled_dim() as usize;
319                let mut packed = vec![0.0; dim];
320                self.inner.pack_rhs(rhs, &mut packed);
321                let bstat = {
322                    let _g = self
323                        .timing
324                        .as_deref()
325                        .map(|t| t.linear_system_back_solve.guard());
326                    self.schur.backsolve(1, &mut packed)
327                };
328                if bstat == ESymSolverStatus::Success {
329                    self.inner.unpack_sol(&packed, sol);
330                }
331                return bstat;
332            }
333            // No cached factor — do a full solve.
334            return self.solve(coeffs, rhs, sol, false, 0);
335        }
336        self.inner.resolve(coeffs, rhs, sol)
337    }
338}