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}