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}