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}