Skip to main content

gridoxide_sparse/klu_native/
mod.rs

1//! A from-scratch Rust port of the vendored SuiteSparse KLU solver
2//! (`vendor/suitesparse/{BTF,AMD,KLU}/`, v7.12.2 — see
3//! `vendor/suitesparse/PROVENANCE.md` for the upstream tag/commit this was
4//! ported from) — a fourth, always-built `JacobianBackend` alongside
5//! `Scalar`, `Block`, and the FFI-backed `Klu` (`sparse_klu.rs`).
6//!
7//! **Why this exists, separately from `sparse_klu.rs`**: `sparse_klu.rs`
8//! links the vendored C directly via FFI, which needs a C compiler and
9//! `libclang` (for `bindgen`) at build time and is opt-in behind the `klu`
10//! Cargo feature specifically to isolate that burden and the LGPL-2.1-or-
11//! later license `KLU`/`BTF` carry (see `vendor/suitesparse/PROVENANCE.md`).
12//! This module is a faithful *translation* of the same algorithm into Rust
13//! instead — no C toolchain needed to build it.
14//!
15//! **Licensing**: a close translation of licensed C source is reasonably a
16//! derivative work regardless of language, so this module carries forward
17//! its upstream licenses — **not uniformly one license**: `amd/` translates
18//! `AMD`, which is BSD-3-Clause upstream (confirmed directly from its
19//! source headers' `SPDX-License-Identifier`, same as
20//! `vendor/suitesparse/PROVENANCE.md`'s own table), while everything else
21//! here (`btf/`, `analyze.rs`, `kernel.rs`, `factor.rs`, `scale.rs`,
22//! `refactor.rs`, `solve.rs`) translates `BTF`/`KLU`, both
23//! LGPL-2.1-or-later. See `PROVENANCE.md` (this directory) for the full
24//! per-file mapping. This whole module is **always built** (no feature
25//! gate), per a deliberate, confirmed choice — unlike the FFI `klu` feature,
26//! there's no C-toolchain burden left to gate against — so a default
27//! `cargo build` now always bundles this BSD/LGPL content alongside
28//! gridoxide's own Apache-2.0 code. See `Cargo.toml`'s `license` field and
29//! `docs/src/reference/provenance.md` for that crate-wide consequence.
30//!
31//! **Scope**: real (`f64`) matrices only, single right-hand side, `int32`-
32//! range indices — gridoxide's own Newton-Raphson Jacobian solve never needs
33//! anything else (confirmed against every call site in `sparse_klu.rs`).
34//! Faithfully ports what real KLU actually does for gridoxide's one fixed
35//! configuration (`Options::default()`, mirroring `klu_defaults.c` exactly):
36//! BTF block-triangular preprocessing, per-block AMD ordering, a
37//! partial-pivoting Gilbert-Peierls left-looking LU kernel with
38//! Eisenstat-Liu symmetric pruning, and cheap numeric-only refactorization.
39//! Explicitly out of scope (confirmed dead — no gridoxide call path reaches
40//! them): COLAMD/user-supplied ordering (`Options`/`klu_common`'s `ordering`
41//! is always AMD here), multi-RHS solve, complex arithmetic, and int64
42//! (`DLONG`) indices.
43//!
44//! **Rows are scaled by default**, as SuiteSparse's KLU scales them
45//! (`KluNativeSystem::new`; `with_scale(.., 0)` turns it off). Scaling divides
46//! every row by its largest magnitude before each factorization; it decides
47//! which pivots partial pivoting sees as large and how the arithmetic rounds,
48//! never what the solution is. Scaled, the port solves as SuiteSparse's KLU
49//! does — the same pivots and, on most matrices, the same bits
50//! (`tests::scaled_matches_real_klu`).
51//!
52//! Power-flow Jacobians do not need it: `examples/klu_scaling_stress.rs`
53//! runs the worst-scaled networks at hand, with rows spanning up to 24
54//! orders of magnitude, and unscaled the backward error stays at machine
55//! precision and every Newton solve takes the iterations SuiteSparse's KLU
56//! takes. State estimation does: its system weights every measurement by
57//! 1/σ², and on the three-phase transmission case with one sensor fifteen σ
58//! off (`se_three_phase_test::bad_data_detection_finds_the_corrupted_measurement`)
59//! the unscaled port's steps stall at 6e-8 and Gauss-Newton runs out of
60//! iterations, where scaled — like SuiteSparse's KLU and faer — it converges
61//! in 8. Scaling costs 11–15% of a power-flow solve; a caller that knows its
62//! matrices are well scaled can switch it off.
63//!
64//! **Module layout** (mirrors the phased port plan; each submodule's doc
65//! comment names the specific upstream `.c` file(s) it was translated from):
66//! `types` (shared sentinel/`Options` plumbing) → `btf` → `amd` → `kernel`/
67//! `factor` → `scale` → `refactor` → `solve`, with the public API
68//! (`KluNativeSystem`) assembled here.
69
70pub mod types;
71
72mod amd;
73mod analyze;
74mod btf;
75mod factor;
76mod kernel;
77mod refactor;
78mod scale;
79mod solve;
80
81#[cfg(all(test, feature = "klu"))]
82mod ffi_oracle;
83
84/// Builds a KLU-ready CSC structure (column pointers + sorted row indices)
85/// from a set of `(row, col)` index pairs, merging duplicates — the `i64`
86/// twin of `sparse_klu.rs::build_csc_structure` (that function's own `i32`
87/// arrays are FFI ABI, not reusable here: this module must build without
88/// `sparse_klu`/the `klu` feature ever being enabled, per this file's own
89/// "always built" licensing posture — see the module doc comment above).
90/// Returns `(col_ptr, row_idx, groups)`, where `groups[k]` lists the
91/// original `entries` indices contributing to the `k`-th CSC position.
92fn build_csc_structure(
93    n: usize,
94    pairs: &[(usize, usize)],
95) -> (Vec<i64>, Vec<i64>, Vec<Vec<usize>>) {
96    let mut order: Vec<usize> = (0..pairs.len()).collect();
97    order.sort_by_key(|&i| (pairs[i].1, pairs[i].0));
98
99    let mut col_ptr = vec![0i64; n + 1];
100    let mut row_idx: Vec<i64> = Vec::new();
101    let mut groups: Vec<Vec<usize>> = Vec::new();
102
103    let mut idx = 0;
104    while idx < order.len() {
105        let (row, col) = pairs[order[idx]];
106        let mut group = vec![order[idx]];
107        let mut k = idx + 1;
108        while k < order.len() && pairs[order[k]] == (row, col) {
109            group.push(order[k]);
110            k += 1;
111        }
112        row_idx.push(row as i64);
113        groups.push(group);
114        col_ptr[col + 1] += 1;
115        idx = k;
116    }
117    for c in 0..n {
118        col_ptr[c + 1] += col_ptr[c];
119    }
120    (col_ptr, row_idx, groups)
121}
122
123fn pack_values(entries: &[(usize, usize, f64)], groups: &[Vec<usize>]) -> Vec<f64> {
124    groups
125        .iter()
126        .map(|g| g.iter().map(|&i| entries[i].2).sum())
127        .collect()
128}
129
130/// `pack_values` for callers that already hold just the values, positionally
131/// matching the triplets `new` was given — see
132/// `sparse::LinearSolver::factor_and_solve_values`.
133fn pack_values_slice(values: &[f64], groups: &[Vec<usize>]) -> Vec<f64> {
134    groups
135        .iter()
136        .map(|g| g.iter().map(|&i| values[i]).sum())
137        .collect()
138}
139
140/// A real sparse system whose sparsity *pattern* is fixed across repeated
141/// solves — the pure-Rust twin of `sparse_klu::KluRealSystem`, same public
142/// shape (`new`/`factor_and_solve`) so `solver.rs` can dispatch to either
143/// backend mechanically. Caches the symbolic analysis (`analyze::analyze`)
144/// and numeric factorization (`factor::Numeric`) across calls, using
145/// `refactor::refactor` for cheap numeric-only re-factorization on repeat
146/// calls — mirrors `KluRealSystem`'s own doc comment and the same pattern
147/// validated for `RealSparseSystem`/`BlockLu::refactor` elsewhere in this
148/// crate.
149pub struct KluNativeSystem {
150    n: usize,
151    col_ptr: Vec<i64>,
152    row_idx: Vec<i64>,
153    groups: Vec<Vec<usize>>,
154    /// Set by [`adopt_storage_order`](Self::adopt_storage_order): values
155    /// arrive in CSC order and are refactored as they are.
156    in_order: bool,
157    sym: analyze::Symbolic,
158    num: factor::Numeric,
159    /// KLU's `Common->scale`: 2 divides every row by its largest magnitude
160    /// before each factorization (KLU's default), 1 by its sum, 0 not at all.
161    scale: i32,
162    /// The row scale factors of the last factorization, in pivot order
163    /// (`rs[k]` for row `pnum[k]`), as `klu_solve.c` reads `Numeric->Rs`;
164    /// empty when not scaling.
165    rs: Vec<f64>,
166    /// The row-indexed scale factors of the last call, kept so the next
167    /// reuses the allocation.
168    raw: Vec<f64>,
169    /// Reused across every `factor_and_solve` call (see
170    /// `refactor::RefactorScratch`'s own doc comment) — keeping this alive
171    /// for the system's whole lifetime, instead of creating one per call,
172    /// is most of what makes repeat solves allocation-light.
173    scratch: refactor::RefactorScratch,
174}
175
176impl KluNativeSystem {
177    /// Builds the symbolic factorization from an initial sparsity pattern
178    /// and factors it once. Subsequent calls to `factor_and_solve` must
179    /// supply entries with the exact same `(row, col)` pairs in the exact
180    /// same order (values may differ) — same precondition as
181    /// `KluRealSystem::new`, for the same reason (the cached CSC position
182    /// mapping in `groups` assumes positional correspondence, not just set
183    /// equality). Scales rows as SuiteSparse's KLU does by default (see the
184    /// module documentation). Returns `None` if the matrix is structurally or
185    /// numerically singular.
186    pub fn new(n: usize, entries: &[(usize, usize, f64)]) -> Option<Self> {
187        Self::with_scale(n, entries, types::Options::default().scale)
188    }
189
190    /// [`new`](Self::new) with KLU's row scaling: `scale` is
191    /// `Common->scale` (2 divides each row by its largest magnitude, KLU's
192    /// default; 1 by its sum; 0 does not scale). As in `klu_factor.c` and
193    /// `klu_refactor.c`, the factors are recomputed from every new set of
194    /// values, each value is divided by its row's, and the solve divides the
195    /// right-hand side by the same, so the solution is unchanged: scaling
196    /// only decides which pivots partial pivoting sees as large, and how the
197    /// arithmetic rounds.
198    pub fn with_scale(n: usize, entries: &[(usize, usize, f64)], scale: i32) -> Option<Self> {
199        let pairs: Vec<(usize, usize)> = entries.iter().map(|&(r, c, _)| (r, c)).collect();
200        let (col_ptr, row_idx, groups) = build_csc_structure(n, &pairs);
201        let values = pack_values(entries, &groups);
202
203        let sym = analyze::analyze(n, &col_ptr, &row_idx);
204        let mut system = Self {
205            n,
206            col_ptr,
207            row_idx,
208            groups,
209            in_order: false,
210            sym,
211            num: factor::Numeric::default(),
212            scale,
213            rs: Vec::new(),
214            raw: Vec::new(),
215            scratch: refactor::RefactorScratch::new(n),
216        };
217        // The first factorization validates the pattern as `klu_scale`
218        // does; every refactorization after reuses it unchecked.
219        let scaled: Vec<f64> = if scale > 0 {
220            system.raw = scale::scale(scale, n, &system.col_ptr, &system.row_idx, &values, false)?;
221            values
222                .iter()
223                .zip(&system.row_idx)
224                .map(|(&v, &row)| v / system.raw[row as usize])
225                .collect()
226        } else {
227            values
228        };
229        system.num = factor::factor(
230            n,
231            &system.col_ptr,
232            &system.row_idx,
233            &scaled,
234            &system.sym,
235            types::Options::default().tol,
236        )?;
237        system.permute_scale();
238        Some(system)
239    }
240
241    /// Puts the row-indexed factors of the last factorization in pivot
242    /// order, `rs[k] = raw[pnum[k]]`, as `klu_factor.c` does at its end.
243    fn permute_scale(&mut self) {
244        self.rs.clear();
245        if self.scale > 0 {
246            let raw = &self.raw;
247            self.rs
248                .extend(self.num.pnum.iter().map(|&row| raw[row as usize]));
249        }
250    }
251
252    /// Numeric-only refactorization against the cached symbolic pattern,
253    /// then solves `A x = b`. Returns `None` if the matrix is singular (or
254    /// the solve produces a non-finite result, matching `KluRealSystem`'s
255    /// own finite-check — a pure-Rust factorization has no
256    /// `common.status`/`KLU_SINGULAR` to check directly, so a non-finite
257    /// entry is this port's only symptom of an ill-conditioned refactor
258    /// that a fixed pattern still let through).
259    pub fn factor_and_solve(
260        &mut self,
261        entries: &[(usize, usize, f64)],
262        rhs: &[f64],
263    ) -> Option<Vec<f64>> {
264        let values = pack_values(entries, &self.groups);
265        self.solve_packed(&values, rhs)
266    }
267
268    /// From now on takes values in this system's own storage order and
269    /// refactors them without copying: returns `order`, where `order[k]` is
270    /// the input index (in the order `new` was given) that position `k`
271    /// holds. The caller reorders its values to match, once, and keeps them
272    /// so. `None`, changing nothing, if some position sums several inputs.
273    pub fn adopt_storage_order(&mut self) -> Option<Vec<usize>> {
274        let order: Vec<usize> = self
275            .groups
276            .iter()
277            .map(|g| match g[..] {
278                [only] => Some(only),
279                _ => None,
280            })
281            .collect::<Option<_>>()?;
282        self.groups = (0..order.len()).map(|k| vec![k]).collect();
283        self.in_order = true;
284        Some(order)
285    }
286
287    /// Shared tail of both `factor_and_solve` paths, taking already-packed
288    /// CSC values.
289    ///
290    /// A refactor keeps the pivot order of the last factorization, and new
291    /// values can put an exact zero on one of those pivots — a fault switched
292    /// on or off in a dynamic simulation does. Then the matrix is factored
293    /// afresh, with new pivots, on the same symbolic analysis, as
294    /// lightsim2grid falls back from `klu_refactor` to `klu_factor`; only if
295    /// that fails too is the matrix singular.
296    fn solve_packed(&mut self, values: &[f64], rhs: &[f64]) -> Option<Vec<f64>> {
297        if self.scale > 0 {
298            scale::row_factors_into(self.scale, self.n, &self.row_idx, values, &mut self.raw);
299        }
300        let ok = refactor::refactor(
301            self.n,
302            &self.col_ptr,
303            &self.row_idx,
304            values,
305            &self.sym,
306            &mut self.num,
307            &mut self.scratch,
308            (self.scale > 0).then_some(&self.raw[..]),
309        );
310        if ok {
311            self.permute_scale();
312            if let Some(x) = self.solve_factored(rhs) {
313                return Some(x);
314            }
315        }
316
317        let scaled: Vec<f64>;
318        let to_factor = if self.scale > 0 {
319            scaled = values
320                .iter()
321                .zip(&self.row_idx)
322                .map(|(&v, &row)| v / self.raw[row as usize])
323                .collect();
324            &scaled
325        } else {
326            values
327        };
328        self.num = factor::factor(
329            self.n,
330            &self.col_ptr,
331            &self.row_idx,
332            to_factor,
333            &self.sym,
334            types::Options::default().tol,
335        )?;
336        // The scatter targets follow the pivot order, which just changed.
337        self.scratch.forget_targets();
338        self.permute_scale();
339        self.solve_factored(rhs)
340    }
341
342    /// Solves with the current factorization; `None` if the result is not
343    /// finite.
344    fn solve_factored(&self, rhs: &[f64]) -> Option<Vec<f64>> {
345        let rs = (!self.rs.is_empty()).then_some(&self.rs[..]);
346        let x = solve::solve(&self.sym, &self.num, rs, rhs);
347        x.iter().all(|v| v.is_finite()).then_some(x)
348    }
349}
350
351impl crate::sparse::LinearSolver for KluNativeSystem {
352    fn new(n: usize, entries: &[(usize, usize, f64)]) -> Option<Self> {
353        KluNativeSystem::new(n, entries)
354    }
355
356    fn factor_and_solve(
357        &mut self,
358        entries: &[(usize, usize, f64)],
359        rhs: &[f64],
360    ) -> Option<Vec<f64>> {
361        KluNativeSystem::factor_and_solve(self, entries, rhs)
362    }
363
364    fn factor_and_solve_values(&mut self, values: &[f64], rhs: &[f64]) -> Option<Vec<f64>> {
365        if self.in_order {
366            return self.solve_packed(values, rhs);
367        }
368        self.solve_packed(&pack_values_slice(values, &self.groups), rhs)
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn real_sparse_system_refactor_reuses_symbolic() {
378        // Mirrors sparse_klu.rs's own test of the same name -- same
379        // fixture, confirming KluNativeSystem's public contract matches
380        // KluRealSystem's exactly.
381        let entries_a = vec![(0, 0, 2.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 3.0)];
382        let mut sys = KluNativeSystem::new(2, &entries_a).unwrap();
383        let x = sys.factor_and_solve(&entries_a, &[5.0, 10.0]).unwrap();
384        assert!((x[0] - 1.0).abs() < 1e-10);
385        assert!((x[1] - 3.0).abs() < 1e-10);
386
387        let entries_b = vec![(0, 0, 4.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 2.0)];
388        // [[4,1],[1,2]] * x = [6, 5] => x0=1, x1=2
389        let x2 = sys.factor_and_solve(&entries_b, &[6.0, 5.0]).unwrap();
390        assert!((x2[0] - 1.0).abs() < 1e-10);
391        assert!((x2[1] - 2.0).abs() < 1e-10);
392    }
393
394    #[test]
395    fn singular_matrix_returns_none() {
396        let entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 2.0), (1, 1, 2.0)];
397        assert!(KluNativeSystem::new(2, &entries).is_none());
398    }
399
400    /// Random matrices whose rows span twelve orders of magnitude and whose
401    /// diagonals do not dominate, so partial pivoting has real choices and
402    /// row scaling changes them.
403    #[cfg(feature = "klu")]
404    fn badly_scaled(
405        trial: usize,
406        next_f64: &mut impl FnMut() -> f64,
407    ) -> (usize, Vec<(usize, usize, f64)>) {
408        let n = 5 + (trial % 20);
409        let row_scale: Vec<f64> = (0..n)
410            .map(|_| 10f64.powf(next_f64() * 12.0 - 6.0))
411            .collect();
412        let mut entries = Vec::new();
413        for i in 0..n {
414            let mut js: std::collections::BTreeSet<usize> = [i, (i + 1) % n].into();
415            for _ in 0..3 {
416                js.insert((next_f64() * n as f64) as usize);
417            }
418            for j in js {
419                entries.push((i, j, (next_f64() * 2.0 - 1.0) * row_scale[i]));
420            }
421        }
422        (n, entries)
423    }
424
425    /// With KLU's default row scaling the port solves as KLU does, on
426    /// matrices where scaling changes the pivots: to rounding (most trials
427    /// agree to the last bit; the rest differ in the order a sum is taken).
428    #[cfg(feature = "klu")]
429    #[test]
430    fn scaled_matches_real_klu() {
431        let mut seed: u64 = 0x5DEECE66D2F1A3B7;
432        let mut next_f64 = move || {
433            seed ^= seed << 13;
434            seed ^= seed >> 7;
435            seed ^= seed << 17;
436            (seed >> 11) as f64 / (1u64 << 53) as f64
437        };
438        let mut compared = 0;
439        for trial in 0..60 {
440            let (n, entries) = badly_scaled(trial, &mut next_f64);
441            let (Some(mut real), Some(mut native)) = (
442                crate::sparse_klu::KluRealSystem::new(n, &entries),
443                KluNativeSystem::with_scale(n, &entries, 2),
444            ) else {
445                continue;
446            };
447            for pass in 0..2 {
448                let b: Vec<f64> = (0..n).map(|i| 1.0 + i as f64 * 0.3 + pass as f64).collect();
449                let (Some(real_x), Some(native_x)) = (
450                    real.factor_and_solve(&entries, &b),
451                    native.factor_and_solve(&entries, &b),
452                ) else {
453                    continue;
454                };
455                let size = real_x.iter().fold(0.0f64, |m, x| m.max(x.abs()));
456                for (i, (a, b)) in native_x.iter().zip(&real_x).enumerate() {
457                    assert!(
458                        (a - b).abs() <= 1e-12 * size,
459                        "trial {trial} pass {pass} (n={n}), index {i}: native={a} real={b}"
460                    );
461                }
462                compared += 1;
463            }
464        }
465        assert!(compared > 60, "only {compared} solves compared");
466    }
467
468    #[cfg(feature = "klu")]
469    #[test]
470    fn matches_real_klu_end_to_end() {
471        let mut seed: u64 = 0x9F1D4A2E7C6B0835;
472        let mut next_f64 = move || {
473            seed ^= seed << 13;
474            seed ^= seed >> 7;
475            seed ^= seed << 17;
476            (seed >> 11) as f64 / (1u64 << 53) as f64
477        };
478
479        for trial in 0..40 {
480            let n = 4 + (trial % 14);
481            let mut entries: Vec<(usize, usize, f64)> = Vec::new();
482            let mut row_sums = vec![0.0f64; n];
483            let mut off_entries: Vec<(usize, usize, f64)> = Vec::new();
484            #[allow(clippy::needless_range_loop)]
485            for i in 0..n {
486                let mut js: std::collections::BTreeSet<usize> = Default::default();
487                let degree = 1 + (next_f64() * 3.0) as usize;
488                for _ in 0..degree {
489                    let j = (next_f64() * n as f64) as usize;
490                    if j != i {
491                        js.insert(j);
492                    }
493                }
494                for j in js {
495                    let v = next_f64() * 2.0 - 1.0;
496                    off_entries.push((i, j, v));
497                    row_sums[i] += v.abs();
498                }
499            }
500            for (i, &rs) in row_sums.iter().enumerate() {
501                entries.push((i, i, rs + 1.0 + next_f64()));
502            }
503            entries.extend(off_entries);
504
505            let mut native = KluNativeSystem::new(n, &entries)
506                .unwrap_or_else(|| panic!("trial {trial} (n={n}): unexpectedly singular"));
507            let mut real = crate::sparse_klu::KluRealSystem::new(n, &entries).unwrap();
508
509            for pass in 0..3 {
510                let b: Vec<f64> = (0..n).map(|i| 1.0 + i as f64 * 0.3 + pass as f64).collect();
511                let native_x = native.factor_and_solve(&entries, &b).unwrap();
512                let real_x = real.factor_and_solve(&entries, &b).unwrap();
513                for i in 0..n {
514                    assert!(
515                        (native_x[i] - real_x[i]).abs() < 1e-8,
516                        "trial {trial} pass {pass} (n={n}), index {i}: native={} real={}",
517                        native_x[i],
518                        real_x[i]
519                    );
520                }
521            }
522        }
523    }
524}