gridoxide_sparse/klu_native/types.rs
1//! Sentinel-index encoding and fixed solver configuration shared across
2//! every `klu_native` submodule — mirrors `vendor/suitesparse/KLU/Include/
3//! klu_internal.h`'s `EMPTY`/`FLIP`/`UNFLIP` (identical, by inspection, to
4//! `AMD/Include/amd_internal.h`'s and `BTF/Include/btf.h`'s own
5//! `BTF_FLIP`/`BTF_UNFLIP`/`BTF_ISFLIPPED`) and `KLU/Source/klu_defaults.c`'s
6//! `KLU_common` defaults.
7//!
8//! Ported deliberately literally, not "idiomatized" into an `Option`/`enum`
9//! at this layer — `lpivot`'s diagonal-preference bookkeeping (`kernel.rs`,
10//! landing in a later phase) reads `Pinv[diagrow] < 0` directly and recovers
11//! `FLIP(Pinv[pivrow])` to reroute it, and BTF's `Q` uses the same encoding
12//! for unmatched columns. This is exactly the kind of index bookkeeping
13//! where `block_sparse.rs`'s `adj.row`/`adj.col` bug happened — silently
14//! wrong only on inputs a casual test wouldn't cover. Conversion to
15//! `Option<usize>`/checked `Vec<usize>` happens only at each phase's own
16//! public API boundary, once the sentinel-form algorithm is done with it.
17
18/// Sentinel for "not yet assigned" — matches every one of the three ported
19/// packages' own `#define EMPTY (-1)` (confirmed identical across
20/// `KLU/Include/klu_internal.h:77`, `AMD/Include/amd_internal.h:71,93`, and
21/// implicitly in `BTF/Include/btf.h`'s `BTF_ISFLIPPED`/`BTF_UNFLIP`, which
22/// treat `-1` the same way).
23pub const EMPTY: i64 = -1;
24
25/// "Negation about -1": marks an otherwise-non-negative index as "flipped"
26/// (BTF uses this for unmatched columns; KLU's `lpivot` uses it to encode a
27/// row's earlier diagonal preference once that row has been claimed by a
28/// different column). `flip(flip(j)) == j` for all `j`, and
29/// `flip(EMPTY) == EMPTY`. Matches `klu_internal.h:78`'s `FLIP` /
30/// `amd_internal.h:72`'s `FLIP` / `btf.h`'s `BTF_FLIP` exactly (all three
31/// upstream definitions are identical: `-(j)-2`).
32pub const fn flip(i: i64) -> i64 {
33 -i - 2
34}
35
36/// `true` iff `i` is currently in "flipped" form (`i < EMPTY`). Matches
37/// `klu_internal.h`'s implicit `UNFLIP` condition / `btf.h`'s
38/// `BTF_ISFLIPPED` exactly.
39pub const fn is_flipped(i: i64) -> bool {
40 i < EMPTY
41}
42
43/// Recovers the original non-negative index whether or not `i` is currently
44/// flipped — a no-op if it wasn't. Matches `klu_internal.h:79`'s `UNFLIP` /
45/// `amd_internal.h:73`'s `UNFLIP` / `btf.h`'s `BTF_UNFLIP` exactly.
46pub const fn unflip(i: i64) -> i64 {
47 if is_flipped(i) { flip(i) } else { i }
48}
49
50/// Fixed solver configuration, mirroring the specific `KLU_common` fields
51/// `sparse_klu.rs::new_common()` (via `klu_defaults`) leaves at their
52/// defaults — confirmed directly from `KLU/Source/klu_defaults.c` — and that
53/// this port's algorithm actually consumes. Kept as a struct (not bare
54/// constants) so differential tests (`#[cfg(all(test, feature = "klu"))]`,
55/// comparing against the vendored C oracle) can vary `tol`/`scale` without
56/// touching any production code path, which always uses `Options::default()`.
57///
58/// Two `KLU_common` fields are deliberately **absent** here, not just
59/// defaulted, because gridoxide's call pattern makes the alternative
60/// branches they'd select genuinely unreachable (confirmed dead, not merely
61/// unused):
62/// - `ordering`: real KLU supports AMD (0), COLAMD (1), user-supplied P/Q
63/// (2), or a user callback (3). `klu_defaults.c` defaults to `0` (AMD) and
64/// `sparse_klu.rs` never overrides it, so this port only ever implements
65/// the AMD path — no `ordering` field exists to accidentally select
66/// another one.
67/// - `maxwork`: bounds the work `btf_maxtrans` may spend before giving up
68/// with a possibly-incomplete matching. Defaults to `0` ("no limit") and
69/// is never overridden, so this port's BTF implementation always runs
70/// `btf_maxtrans` to completion — the work-limit bookkeeping threaded
71/// through the upstream C is dropped outright, not merely defaulted off.
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct Options {
74 /// Partial-pivoting threshold: a candidate pivot is accepted only if
75 /// its magnitude is at least `tol` times the largest candidate
76 /// available in that column (otherwise the largest is forced instead).
77 /// `klu_defaults.c`: `Common->tol = 0.001`.
78 pub tol: f64,
79 /// Row-scaling strategy: `1` = sum of absolute values per row, `2` =
80 /// max absolute value per row. `klu_defaults.c`: `Common->scale = 2`.
81 pub scale: i32,
82 /// Whether to BTF-preorder before per-block AMD ordering.
83 /// `klu_defaults.c`: `Common->btf = TRUE`. gridoxide never disables
84 /// this, but it's kept as a field (rather than assumed) so `analyze`
85 /// phase tests can exercise the single-block (`btf: false`) path too.
86 pub btf: bool,
87 /// Abort immediately (and report singular) on a pivot that's still zero
88 /// after the tolerance-based search, rather than substituting a tiny
89 /// nonzero value and continuing. `klu_defaults.c`:
90 /// `Common->halt_if_singular = TRUE`.
91 pub halt_if_singular: bool,
92}
93
94impl Default for Options {
95 fn default() -> Self {
96 // Matches KLU_defaults exactly for every field this port consumes.
97 Self {
98 tol: 0.001,
99 scale: 2,
100 btf: true,
101 halt_if_singular: true,
102 }
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn flip_is_its_own_inverse() {
112 for j in [-1i64, 0, 1, 5, -5, 1000, -1000] {
113 assert_eq!(flip(flip(j)), j, "flip(flip({j})) should equal {j}");
114 }
115 }
116
117 #[test]
118 fn flip_of_empty_is_empty() {
119 assert_eq!(flip(EMPTY), EMPTY);
120 }
121
122 #[test]
123 fn is_flipped_matches_upstream_condition() {
124 assert!(!is_flipped(EMPTY)); // -1 is not "flipped"
125 assert!(!is_flipped(0));
126 assert!(!is_flipped(5));
127 assert!(is_flipped(flip(0)));
128 assert!(is_flipped(flip(5)));
129 }
130
131 #[test]
132 fn unflip_recovers_original_regardless_of_flip_state() {
133 for j in [0i64, 1, 5, 1000] {
134 assert_eq!(unflip(j), j, "unflip of an already-plain index is a no-op");
135 assert_eq!(
136 unflip(flip(j)),
137 j,
138 "unflip should recover the original index"
139 );
140 }
141 }
142
143 #[test]
144 fn options_default_matches_klu_defaults_c() {
145 let opts = Options::default();
146 assert_eq!(opts.tol, 0.001);
147 assert_eq!(opts.scale, 2);
148 assert!(opts.btf);
149 assert!(opts.halt_if_singular);
150 }
151}