Unsymmetric LU basis engine
Most of feral is a symmetric indefinite LDLᵀ solver with certified inertia.
The feral::lu module is a separate factorization family: an unsymmetric
LU built to drive a revised-simplex basis. It is additive — the LDLᵀ solver
and every one of its code paths are untouched — and it deliberately does not
compute inertia (an unsymmetric basis has no symmetric eigenvalue structure to
certify).
The distinguishing requirement is not the one-shot factor/solve but the rank-1
update: a simplex iteration replaces one basic column, and the engine folds
that change into the existing factors in O(nnz) rather than refactoring.
The factorization
For a square nonsingular basis B (m × m), feral computes
P B Q = L U
where P is a row permutation (threshold partial pivoting, for stability), Q
is a fill-reducing column permutation (sparse path only; Q = I on the dense
path), L is unit lower triangular, and U is upper triangular. The two hot
operations of revised simplex follow directly:
ftran: solve B x = a (forward transformation, B⁻¹a)
btran: solve Bᵀ x = a (backward transformation, B⁻ᵀa)
Dense path
For the small bases that dominate (e.g. OBBT bases on a handful of variables),
DenseLu factors a general column-major matrix with right-looking
LU and threshold partial pivoting:
use feral::{DenseLu, LuParams};
// `cols[j]` is column j of the m×m basis (length m each).
let m = 3;
let cols = vec![
vec![2.0, 4.0, 8.0],
vec![1.0, 3.0, 7.0],
vec![1.0, 3.0, 9.0],
];
let mut lu = DenseLu::factor(&cols, m, LuParams::default())?;
// ftran / btran overwrite the right-hand side in place.
let mut x = vec![1.0, 2.0, 3.0];
lu.ftran(&mut x)?; // x ← B⁻¹ x
let mut y = vec![1.0, 0.0, 0.0];
lu.btran(&mut y)?; // y ← B⁻ᵀ y
Sparse path
For larger bases, SparseLu is a left-looking Gilbert–Peierls LU
with an output-sensitive depth-first reach (so the factor cost is O(flops),
not O(n²)). The fill-reducing column order is a reusable symbolic handle
computed by SparseLuSymbolic — feral's in-tree AMD run on the
AᵀA (column-intersection) pattern, a stand-in for COLAMD. Because the pattern
is invariant under the row permutation and scaling, the same order is valid for
numerically different but structurally identical bases:
use feral::{SparseColMatrix, SparseLu, SparseLuSymbolic, LuParams};
let b: SparseColMatrix = /* general CSC basis */;
let symbolic = SparseLuSymbolic::analyze(&b)?; // reusable across refactors
let mut lu = SparseLu::factor(&b, &symbolic, LuParams::default())?;
let mut x = vec![/* … length m … */];
lu.ftran(&mut x)?;
should_use_dense_lu(m, nnz, ¶ms) mirrors the symmetric router: tiny bases
go dense unconditionally, small dense-enough bases go dense by a density gate,
and the rest go sparse.
Rank-1 column-replacement update
This is the reason the engine exists. update (and update_sparse, which takes
the entering column already in sparse form) replaces one basic column and folds
the change into the factors in place:
let leaving_slot = 2; // basis column to evict
let entering = vec![0.5, 0.0, 1.5]; // new column aₙₑw
match lu.update(leaving_slot, &entering) {
Ok(()) => { /* factors now reflect the new basis */ }
Err(feral::FeralError::NeedsRefactor) => {
// Budget or stability limit reached; `lu` is unchanged.
lu.refactor(&b, &symbolic)?; // (DenseLu::refactor takes the new columns)
}
Err(e) => return Err(e),
}
- Dense updates use Bartels–Golub re-triangularization (spike → cyclic
column shift to upper-Hessenberg → Gauss sweep, folded into
L/U). - Sparse updates use a Forrest–Tomlin / Bartels–Golub–Reid scheme: the bump
is re-triangularized by sparse Gaussian elimination with partial pivoting,
recorded as a replayable eta and applied between the
L- andU-solves. The work is bump-local, so a warm solve after a localized update stays sparse instead of degrading toward a full re-solve.
An update returns NeedsRefactor — leaving the factorization
unchanged — when the update count (max_updates) or growth monitor
(max_growth) trips, so the caller can refactor rather than accept an unstable
factor. A vanished bump pivot returns
SingularBasis so the simplex can repair the basis instead of
receiving a garbage solve.
Robustness: scaling and refinement
The wrong-answer bugs that historically bit downstream simplex code were all
scaling/tolerance, never the update math — so the robustness layer is
load-bearing. LuParams::scaling selects a two-sided strategy:
LuScaling::InfNorm— Knight–Ruiz ∞-norm equilibration (separate row/column scalings).LuScaling::Mc64— unsymmetric MC64 (max-weight bipartite matching) that places large entries on the diagonal, with a partial-matching fall back toInfNorm.
Scaling wraps the core solve; the factorization factors the scaled matrix
D_row Π B D_col. For ill-conditioned bases, ftran_refined / btran_refined
run residual-based iterative refinement against the original basis:
let params = LuParams {
scaling: feral::LuScaling::Mc64,
refine_steps: 2,
refine_tol: 1e-14,
..LuParams::default()
};
let mut lu = SparseLu::factor(&b, &symbolic, params)?;
let mut x = a.clone();
lu.ftran_refined(&b, &mut x)?; // drives the true residual ‖Bx − a‖ down
Scope
The LU engine is a Rust API today; it is not exposed through the
Python bindings yet (no inertia, different update-centric
surface). The downstream BasisEngine integration and reference (UMFPACK/KLU)
benchmarks are tracked as later phases — see dev/plans/unsymmetric-lu-epic.md
in the repository.