rivrs_sparse/symmetric/solver.rs
1//! User-facing sparse symmetric indefinite solver API.
2//!
3//! Provides [`SparseLDLT`], the high-level solver struct wrapping the
4//! three-phase analyze → factor → solve pipeline. The symbolic analysis
5//! is reusable across factorizations with the same sparsity pattern.
6//!
7//! # References
8//!
9//! - Duff, Hogg & Lopez (2020), "A New Sparse LDL^T Solver Using A Posteriori
10//! Threshold Pivoting", SIAM J. Sci. Comput. 42(4)
11//! - Liu (1992), "The Multifrontal Method for Sparse Matrix Solution"
12
13// SPRAL Equivalent: `ssids_akeep` (symbolic) + `ssids_fkeep` (numeric)
14// and the `ssids_analyse`, `ssids_factor`, `ssids_solve` subroutines.
15
16use faer::Col;
17use faer::Par;
18use faer::dyn_stack::{MemBuffer, MemStack, StackReq};
19use faer::perm::Perm;
20use faer::sparse::linalg::cholesky::SymmetricOrdering;
21use faer::sparse::{SparseColMat, SymbolicSparseColMatRef};
22
23use super::factor::{AptpFallback, AptpOptions};
24use super::inertia::Inertia;
25use super::numeric::{AptpNumeric, FactorizationStats};
26use super::solve::{aptp_solve, aptp_solve_scratch};
27use super::symbolic::AptpSymbolic;
28use crate::error::SparseError;
29use crate::ordering::{match_order_metis, metis_ordering};
30
31/// Fill-reducing ordering strategy.
32///
33/// The ordering is a user-configurable option rather than an automatic
34/// heuristic. Guidance from Duff, Hogg & Lopez (2020):
35///
36/// - **Easy indefinite** (structural FEM, thermal, acoustic, model reduction,
37/// quantum chemistry): use [`Metis`](Self::Metis) (the default).
38/// - **Hard indefinite** (KKT/saddle-point, optimal control, power networks,
39/// mixed FEM / Stokes): use [`MatchOrderMetis`](Self::MatchOrderMetis).
40// SPRAL Equivalent: `options%ordering` (1=METIS default, 2=matching+METIS).
41#[derive(Debug, Clone)]
42pub enum OrderingStrategy {
43 /// AMD ordering (faer built-in).
44 Amd,
45 /// METIS ordering (via metis-sys). Default.
46 /// Best for easy-indefinite problems with naturally dominant diagonal.
47 Metis,
48 /// MC64 matching + METIS ordering. Produces scaling factors.
49 /// Best for hard-indefinite problems (KKT, saddle-point, zero diagonal blocks).
50 MatchOrderMetis,
51 /// User-supplied ordering permutation.
52 UserSupplied(Perm<usize>),
53}
54
55/// Configuration for the symbolic analysis phase.
56#[derive(Debug, Clone)]
57pub struct AnalyzeOptions {
58 /// Fill-reducing ordering strategy.
59 pub ordering: OrderingStrategy,
60}
61
62impl Default for AnalyzeOptions {
63 fn default() -> Self {
64 Self {
65 ordering: OrderingStrategy::MatchOrderMetis,
66 }
67 }
68}
69
70/// Configuration for the numeric factorization phase.
71#[derive(Debug, Clone)]
72pub struct FactorOptions {
73 /// APTP pivot threshold (u parameter). Default: 0.01.
74 pub threshold: f64,
75 /// Fallback strategy for failed 1x1 pivots. Default: BunchKaufman.
76 pub fallback: AptpFallback,
77 /// Outer block size (nb) for two-level APTP. Default: 256.
78 pub outer_block_size: usize,
79 /// Inner block size (ib) for two-level APTP. Default: 32.
80 pub inner_block_size: usize,
81 /// Parallelism control for dense BLAS operations within supernodes
82 /// and tree-level scheduling. Default: `Par::Seq`.
83 pub par: Par,
84 /// Minimum supernode size for amalgamation. Default: 32.
85 /// Setting `nemin = 1` disables amalgamation.
86 pub nemin: usize,
87 /// Front-size threshold for small-leaf subtree fast path. Supernodes with
88 /// front size strictly less than this value may be grouped into small-leaf
89 /// subtrees and processed via a streamlined code path that reuses a single
90 /// small workspace, avoiding per-supernode dispatch overhead.
91 ///
92 /// Default: 256. Set to 0 to disable the fast path entirely.
93 ///
94 /// # Relationship to `INTRA_NODE_THRESHOLD`
95 ///
96 /// This threshold determines which subtrees use the fast path (workspace
97 /// reuse, sequential processing). `INTRA_NODE_THRESHOLD` (also 256) controls
98 /// whether intra-node BLAS uses parallel or sequential execution. They are
99 /// independent but share the same default because fronts below 256 benefit
100 /// from neither parallel BLAS nor the general-path overhead.
101 pub small_leaf_threshold: usize,
102}
103
104impl Default for FactorOptions {
105 fn default() -> Self {
106 Self {
107 threshold: 0.01,
108 fallback: AptpFallback::BunchKaufman,
109 outer_block_size: 256,
110 inner_block_size: 32,
111 par: Par::Seq,
112 nemin: 32,
113 small_leaf_threshold: 256,
114 }
115 }
116}
117
118/// Configuration for the one-shot solve.
119#[derive(Debug, Clone)]
120pub struct SolverOptions {
121 /// Fill-reducing ordering strategy.
122 pub ordering: OrderingStrategy,
123 /// APTP pivot threshold. Default: 0.01.
124 pub threshold: f64,
125 /// Fallback strategy. Default: BunchKaufman.
126 pub fallback: AptpFallback,
127 /// Parallelism control for factorization and solve. Default: `Par::Seq`.
128 pub par: Par,
129 /// Minimum supernode size for amalgamation. Default: 32.
130 /// Setting `nemin = 1` disables amalgamation.
131 pub nemin: usize,
132 /// Front-size threshold for small-leaf subtree fast path.
133 /// Default: 256. Set to 0 to disable. See [`FactorOptions::small_leaf_threshold`].
134 pub small_leaf_threshold: usize,
135}
136
137impl Default for SolverOptions {
138 fn default() -> Self {
139 Self {
140 ordering: OrderingStrategy::MatchOrderMetis,
141 threshold: 0.01,
142 fallback: AptpFallback::BunchKaufman,
143 par: Par::Seq,
144 nemin: 32,
145 small_leaf_threshold: 256,
146 }
147 }
148}
149
150/// High-level sparse symmetric indefinite solver.
151///
152/// Wraps the APTP multifrontal factorization with a
153/// three-stage API: analyze → factor → solve. The symbolic analysis
154/// is reusable across factorizations with the same sparsity pattern.
155///
156// SPRAL Equivalent: `ssids_akeep` (symbolic) + `ssids_fkeep` (numeric)
157// and `ssids_analyse`, `ssids_factor`, `ssids_solve` subroutines.
158/// # Examples
159///
160/// ```
161/// use faer::sparse::{SparseColMat, Triplet};
162/// use faer::Col;
163/// use rivrs_sparse::symmetric::{SparseLDLT, SolverOptions};
164///
165/// let triplets = vec![
166/// Triplet::new(0, 0, 4.0),
167/// Triplet::new(0, 1, 1.0),
168/// Triplet::new(1, 0, 1.0),
169/// Triplet::new(1, 1, 3.0),
170/// ];
171/// let a = SparseColMat::try_new_from_triplets(2, 2, &triplets).unwrap();
172/// let b = Col::from_fn(2, |i| [5.0, 4.0][i]);
173/// let x = SparseLDLT::solve_full(&a, &b, &SolverOptions::default()).unwrap();
174/// // A = [[4, 1], [1, 3]], b = [5, 4] => x = [1, 1]
175/// assert!((x[0] - 1.0).abs() < 1e-12);
176/// assert!((x[1] - 1.0).abs() < 1e-12);
177/// ```
178pub struct SparseLDLT {
179 symbolic: AptpSymbolic,
180 numeric: Option<AptpNumeric>,
181 scaling: Option<Vec<f64>>,
182 /// Cached forward permutation (perm_fwd[new] = old). Computed once during analyze.
183 perm_fwd: Vec<usize>,
184}
185
186impl SparseLDLT {
187 /// Build a `SparseLDLT` with cached permutation vectors from symbolic analysis.
188 fn new_with_cached_perm(symbolic: AptpSymbolic, scaling: Option<Vec<f64>>) -> Self {
189 let (perm_fwd, _) = symbolic.perm_vecs();
190 SparseLDLT {
191 symbolic,
192 numeric: None,
193 scaling,
194 perm_fwd,
195 }
196 }
197
198 /// Symbolic analysis phase.
199 ///
200 /// Computes the fill-reducing ordering, elimination tree, and
201 /// supernode structure. Optionally computes MC64 scaling factors
202 /// when `MatchOrderMetis` ordering is requested.
203 ///
204 /// Note: `MatchOrderMetis` ordering requires numeric matrix values;
205 /// use [`analyze_with_matrix`](Self::analyze_with_matrix) instead.
206 ///
207 /// # Errors
208 ///
209 /// - `SparseError::AnalysisFailure` if ordering or symbolic analysis fails
210 pub fn analyze(
211 matrix: SymbolicSparseColMatRef<'_, usize>,
212 options: &AnalyzeOptions,
213 ) -> Result<Self, SparseError> {
214 let n = matrix.nrows();
215
216 match &options.ordering {
217 OrderingStrategy::Amd => {
218 let symbolic = AptpSymbolic::analyze(matrix, SymmetricOrdering::Amd)?;
219 Ok(Self::new_with_cached_perm(symbolic, None))
220 }
221 OrderingStrategy::Metis => {
222 // Build a dummy matrix for METIS (needs SymbolicSparseColMatRef)
223 let col_ptrs = matrix.col_ptr();
224 let row_indices = matrix.row_idx();
225 let mut triplets = Vec::new();
226 for j in 0..n {
227 for &i in &row_indices[col_ptrs[j]..col_ptrs[j + 1]] {
228 triplets.push(faer::sparse::Triplet::new(i, j, 1.0f64));
229 }
230 }
231 let dummy_matrix =
232 SparseColMat::try_new_from_triplets(n, n, &triplets).map_err(|e| {
233 SparseError::AnalysisFailure {
234 reason: format!("Failed to construct matrix for METIS ordering: {}", e),
235 }
236 })?;
237
238 let perm = metis_ordering(dummy_matrix.symbolic())?;
239 let symbolic =
240 AptpSymbolic::analyze(matrix, SymmetricOrdering::Custom(perm.as_ref()))?;
241 Ok(Self::new_with_cached_perm(symbolic, None))
242 }
243 OrderingStrategy::MatchOrderMetis => {
244 Err(SparseError::AnalysisFailure {
245 reason: "MatchOrderMetis requires numeric matrix values; use analyze_with_matrix() instead".to_string(),
246 })
247 }
248 OrderingStrategy::UserSupplied(perm) => {
249 let symbolic =
250 AptpSymbolic::analyze(matrix, SymmetricOrdering::Custom(perm.as_ref()))?;
251 Ok(Self::new_with_cached_perm(symbolic, None))
252 }
253 }
254 }
255
256 /// Symbolic analysis with full matrix (required for MatchOrderMetis).
257 ///
258 /// When `MatchOrderMetis` is requested, this method performs MC64
259 /// matching on the numeric matrix to compute scaling factors and
260 /// a fill-reducing ordering. Other orderings delegate to [`analyze`](Self::analyze).
261 pub fn analyze_with_matrix(
262 matrix: &SparseColMat<usize, f64>,
263 options: &AnalyzeOptions,
264 ) -> Result<Self, SparseError> {
265 let n = matrix.nrows();
266
267 match &options.ordering {
268 OrderingStrategy::MatchOrderMetis => {
269 let result = match_order_metis(matrix)?;
270 let ordering_perm = result.ordering;
271 let symbolic = AptpSymbolic::analyze(
272 matrix.symbolic(),
273 SymmetricOrdering::Custom(ordering_perm.as_ref()),
274 )?;
275
276 // Transform scaling to elimination order
277 let (perm_fwd, _) = symbolic.perm_vecs();
278 let elim_scaling: Vec<f64> = (0..n).map(|i| result.scaling[perm_fwd[i]]).collect();
279
280 Ok(Self::new_with_cached_perm(symbolic, Some(elim_scaling)))
281 }
282 // All other orderings don't need numeric values
283 _ => Self::analyze(matrix.symbolic(), options),
284 }
285 }
286
287 /// Numeric factorization phase.
288 ///
289 /// Computes P^T A P = L D L^T using multifrontal APTP.
290 /// If scaling factors are present (from MC64 ordering), entries
291 /// are scaled at assembly time.
292 ///
293 /// # Errors
294 ///
295 /// - `SparseError::DimensionMismatch` if matrix dimensions differ from analysis
296 pub fn factor(
297 &mut self,
298 matrix: &SparseColMat<usize, f64>,
299 options: &FactorOptions,
300 ) -> Result<(), SparseError> {
301 let aptp_options = AptpOptions {
302 threshold: options.threshold,
303 fallback: options.fallback,
304 outer_block_size: options.outer_block_size,
305 inner_block_size: options.inner_block_size,
306 par: options.par,
307 nemin: options.nemin,
308 small_leaf_threshold: options.small_leaf_threshold,
309 ..AptpOptions::default()
310 };
311
312 // Drop old numeric before building new one to avoid holding both
313 // in memory simultaneously. For H2O (max_front=9258), old+new
314 // front_factors can exceed 7 GB.
315 self.numeric = None;
316 let numeric = AptpNumeric::factor(
317 &self.symbolic,
318 matrix,
319 &aptp_options,
320 self.scaling.as_deref(),
321 )?;
322 self.numeric = Some(numeric);
323 Ok(())
324 }
325
326 /// Refactor with same sparsity pattern but different numeric values.
327 ///
328 /// Reuses the symbolic analysis from [`analyze_with_matrix`](Self::analyze_with_matrix),
329 /// avoiding the cost of re-ordering and re-computing the elimination tree.
330 /// This is the inner-loop operation for applications where the sparsity
331 /// pattern is fixed but values change — interior-point iterations,
332 /// time-stepping schemes, and parameter sweeps.
333 ///
334 /// Equivalent to calling [`factor`](Self::factor) again.
335 pub fn refactor(
336 &mut self,
337 matrix: &SparseColMat<usize, f64>,
338 options: &FactorOptions,
339 ) -> Result<(), SparseError> {
340 self.factor(matrix, options)
341 }
342
343 /// Solve Ax = b (allocating).
344 ///
345 /// Returns the solution vector.
346 ///
347 /// # Errors
348 ///
349 /// - `SparseError::SolveBeforeFactor` if `factor()` has not been called
350 /// - `SparseError::DimensionMismatch` if rhs length != matrix dimension
351 pub fn solve(
352 &self,
353 rhs: &Col<f64>,
354 stack: &mut MemStack,
355 par: Par,
356 ) -> Result<Col<f64>, SparseError> {
357 let mut result = rhs.to_owned();
358 self.solve_in_place(&mut result, stack, par)?;
359 Ok(result)
360 }
361
362 /// Solve Ax = b (in-place).
363 ///
364 /// Modifies `rhs` to contain the solution.
365 ///
366 /// # Errors
367 ///
368 /// - `SparseError::SolveBeforeFactor` if `factor()` has not been called
369 /// - `SparseError::DimensionMismatch` if rhs length != matrix dimension
370 pub fn solve_in_place(
371 &self,
372 rhs: &mut Col<f64>,
373 stack: &mut MemStack,
374 par: Par,
375 ) -> Result<(), SparseError> {
376 let numeric = self
377 .numeric
378 .as_ref()
379 .ok_or_else(|| SparseError::SolveBeforeFactor {
380 context: "factor() must be called before solve()".to_string(),
381 })?;
382
383 let n = self.symbolic.nrows();
384 if rhs.nrows() != n {
385 return Err(SparseError::DimensionMismatch {
386 expected: (n, 1),
387 got: (rhs.nrows(), 1),
388 context: "RHS length must match matrix dimension".to_string(),
389 });
390 }
391
392 if n == 0 {
393 return Ok(());
394 }
395
396 let perm_fwd = &self.perm_fwd;
397
398 // 1. Permute: rhs_perm[new] = rhs[perm_fwd[new]]
399 // perm_fwd[new] = old, so this gathers original RHS into permuted order.
400 let mut rhs_perm = vec![0.0f64; n];
401 for new in 0..n {
402 rhs_perm[new] = rhs[perm_fwd[new]];
403 }
404
405 // 2. Scale (if scaling present): rhs_perm[i] *= scaling[i]
406 if let Some(ref scaling) = self.scaling {
407 for i in 0..n {
408 rhs_perm[i] *= scaling[i];
409 }
410 }
411
412 // 3. Core solve: aptp_solve(symbolic, numeric, &mut rhs_perm, stack, par)
413 aptp_solve(&self.symbolic, numeric, &mut rhs_perm, stack, par)?;
414
415 // 4. Unscale: rhs_perm[i] *= scaling[i] (symmetric: same S applied)
416 if let Some(ref scaling) = self.scaling {
417 for i in 0..n {
418 rhs_perm[i] *= scaling[i];
419 }
420 }
421
422 // 5. Unpermute: rhs[perm_fwd[new]] = rhs_perm[new]
423 for new in 0..n {
424 rhs[perm_fwd[new]] = rhs_perm[new];
425 }
426
427 Ok(())
428 }
429
430 /// Workspace requirement for solve.
431 ///
432 /// Returns the [`StackReq`] needed by [`solve`](Self::solve) or
433 /// [`solve_in_place`](Self::solve_in_place). Allocate once with
434 /// [`MemBuffer::new`](faer::dyn_stack::MemBuffer::new) and reuse across
435 /// multiple solves to avoid repeated allocation.
436 ///
437 /// `rhs_ncols` is the number of right-hand side columns (1 for a single
438 /// vector solve).
439 pub fn solve_scratch(&self, rhs_ncols: usize) -> StackReq {
440 if let Some(ref numeric) = self.numeric {
441 aptp_solve_scratch(numeric, rhs_ncols)
442 } else {
443 StackReq::empty()
444 }
445 }
446
447 /// One-shot: analyze + factor + solve.
448 ///
449 /// Convenience method that runs the full pipeline in a single call.
450 /// For repeated solves (multiple RHS or refactorization), use the
451 /// three-phase API instead to amortize the symbolic analysis cost.
452 ///
453 /// # Examples
454 ///
455 /// ```
456 /// use faer::sparse::{SparseColMat, Triplet};
457 /// use faer::Col;
458 /// use rivrs_sparse::symmetric::{SparseLDLT, SolverOptions};
459 ///
460 /// let triplets = vec![
461 /// Triplet::new(0, 0, 4.0),
462 /// Triplet::new(0, 1, 1.0), Triplet::new(1, 0, 1.0),
463 /// Triplet::new(1, 1, 3.0),
464 /// ];
465 /// let a = SparseColMat::try_new_from_triplets(2, 2, &triplets).unwrap();
466 /// let b = Col::from_fn(2, |i| [5.0, 4.0][i]);
467 /// let x = SparseLDLT::solve_full(&a, &b, &SolverOptions::default()).unwrap();
468 /// assert!((x[0] - 1.0).abs() < 1e-12);
469 /// ```
470 pub fn solve_full(
471 matrix: &SparseColMat<usize, f64>,
472 rhs: &Col<f64>,
473 options: &SolverOptions,
474 ) -> Result<Col<f64>, SparseError> {
475 let analyze_opts = AnalyzeOptions {
476 ordering: options.ordering.clone(),
477 };
478 let factor_opts = FactorOptions {
479 threshold: options.threshold,
480 fallback: options.fallback,
481 par: options.par,
482 nemin: options.nemin,
483 small_leaf_threshold: options.small_leaf_threshold,
484 ..FactorOptions::default()
485 };
486
487 let mut solver = Self::analyze_with_matrix(matrix, &analyze_opts)?;
488 solver.factor(matrix, &factor_opts)?;
489
490 let scratch = solver.solve_scratch(1);
491 let mut mem = MemBuffer::new(scratch);
492 let stack = MemStack::new(&mut mem);
493 solver.solve(rhs, stack, options.par)
494 }
495
496 /// Get inertia (eigenvalue sign counts) from the most recent factorization.
497 ///
498 /// Returns the number of positive, negative, and zero eigenvalues of A.
499 /// Useful for checking definiteness, verifying KKT optimality conditions
500 /// (expected inertia `(n, m, 0)` for `n` primal and `m` dual variables),
501 /// and detecting numerical singularity (zero eigenvalues).
502 ///
503 /// Returns `None` if [`factor`](Self::factor) has not been called.
504 pub fn inertia(&self) -> Option<Inertia> {
505 self.numeric.as_ref().map(|numeric| {
506 let mut inertia = Inertia {
507 positive: 0,
508 negative: 0,
509 zero: 0,
510 };
511 for ff in numeric.front_factors() {
512 let local_inertia = ff.d11().compute_inertia();
513 inertia.positive += local_inertia.positive;
514 inertia.negative += local_inertia.negative;
515 inertia.zero += local_inertia.zero;
516 }
517 inertia
518 })
519 }
520
521 /// Get factorization statistics from the most recent factorization.
522 ///
523 /// Returns aggregate statistics including pivot counts (`total_1x1_pivots`,
524 /// `total_2x2_pivots`), delayed pivot count (`total_delayed`), maximum
525 /// front size, and supernode count. With the `diagnostic` feature enabled,
526 /// also includes per-phase timing breakdowns.
527 ///
528 /// Returns `None` if [`factor`](Self::factor) has not been called.
529 pub fn stats(&self) -> Option<&FactorizationStats> {
530 self.numeric.as_ref().map(|n| n.stats())
531 }
532
533 /// Get per-supernode diagnostic statistics from the most recent factorization.
534 ///
535 /// Returns one [`PerSupernodeStats`](super::numeric::PerSupernodeStats) per
536 /// supernode with front size, pivot counts, and delay counts. With the
537 /// `diagnostic` feature enabled, each entry also includes sub-phase timing
538 /// (assembly, kernel, extraction, extend-add).
539 ///
540 /// Returns `None` if [`factor`](Self::factor) has not been called.
541 pub fn per_supernode_stats(&self) -> Option<&[super::numeric::PerSupernodeStats]> {
542 self.numeric.as_ref().map(|n| n.per_supernode_stats())
543 }
544
545 /// Matrix dimension (from symbolic analysis).
546 pub fn n(&self) -> usize {
547 self.symbolic.nrows()
548 }
549}