Skip to main content

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}