mutable design
Design goal
mutable is the execution-oriented half of the repository. It stores a
matrix in one flat row-major array, lets callers update it in place and work
through live views, and implements the floating-point numerical routines:
determinant, inverse, rank, row reduction, Cholesky factorization, symmetric
eigenvalues and the power method. The public API still reads like value
operations wherever possible; mutation is confined to methods that return
Unit and to the views, so callers can tell from a signature whether a call
changes its receiver.
Mathematical background
Throughout, is the unit roundoff ( for Double, for
Float), with
, and . Inequalities between
matrices hold entry-wise, and is the matrix of absolute values.
Matrix product
costs multiply-adds. The kernels sum in different orders (four partial products per step in the unrolled kernels; a packed copy of the columns of for products of at least ), but every order satisfies
so results on different targets agree to that accuracy, not bit for bit.
LU factorization with partial pivoting
For square , Gaussian elimination with partial pivoting computes a permutation , a unit lower triangular and an upper triangular with
At step it chooses the row with the largest , swaps it into position , and for stores the multiplier and updates . Pivoting guarantees . The cost is flops.
Determinant. Taking determinants of with and for exchanges,
Solving. becomes (forward substitution) and (back substitution), each flops per right-hand side. The inverse is the solution for the columns of : flops.
Stability. The computed factors satisfy the backward error bound (Wilkinson; Higham, Theorem 9.3)11 N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM, 2002, chapters 9 (LU), 10 (Cholesky) and 8 (triangular systems).
With this gives , where is the growth factor. Partial pivoting bounds ; the bound is attained only by contrived matrices, and in practice is small, so the method is backward stable in practice. The forward error of a solve is then governed by the condition number: .
Closed forms for small determinants
For the determinant is evaluated by formulas instead of LU: the rule , cofactor expansion along the first row for , and for Laplace’s expansion along the first two rows into six products of complementary minors,
They use no division and no pivoting decision, which avoids the tolerance test for tiny matrices. They are not backward stable in the LU sense: for ill-conditioned input the cancellation in can lose all relative accuracy, exactly as the determinant itself is ill-conditioned there.
Rank and reduced row echelon form
rank runs elimination with partial pivoting on a copy and counts pivots
whose magnitude is at least the tolerance. This is the numerical rank with
an absolute threshold : the number of pivots . It is exact for
matrices whose pivots are well separated from and otherwise depends on
scaling. (The singular value decomposition gives the reliable numerical rank,
; it is not implemented.)
reduce_row_elimination is Gauss–Jordan elimination: each pivot row is scaled
to make the pivot one, and the pivot column is cleared above and below. It
costs about flops and works in place.
Cholesky factorization
A symmetric positive definite (SPD) matrix has a unique factorization with lower triangular and . Comparing entries of for ,
The code evaluates these row by row (the Cholesky–Banachiewicz order),
flops. The radicand at step equals the pivot of Gaussian elimination
without pivoting, which is the ratio of leading principal minors
. By Sylvester’s criterion, is SPD exactly when
all leading principal minors are positive, so the factorization succeeds
exactly for SPD matrices; this is why is_positive_definite is implemented
by attempting it. Cholesky needs no pivoting: from
every , so entries cannot
grow, and the computed factor satisfies
with
(Higham,
Theorem 10.3).
Symmetric eigenvalue problem
For real symmetric the spectral theorem gives
with orthogonal and real diagonal. eigen computes it in two
phases.
Householder tridiagonalization. A Householder reflector is symmetric and orthogonal and can map a vector onto a multiple of a coordinate vector. Applying reflectors from both sides,
where is symmetric tridiagonal with diagonal and off-diagonal . The code accumulates explicitly; together about flops. Each row is scaled by the sum of its absolute values before the reflector is formed, which avoids overflow and underflow in .
Implicit QL with Wilkinson shifts. The tridiagonal is diagonalized by plane rotations. Before each sweep on the unreduced block starting at , the shift is the eigenvalue of the leading block closer to . With that block has eigenvalues
and the one closer to is
the second form, used by the code, avoids cancellation. A sweep applies Givens rotations that chase the resulting bulge and update with the same rotations. An off-diagonal entry is treated as zero when
a test that is relative for large diagonal entries and absolute near zero. Convergence with the Wilkinson shift is cubic for symmetric tridiagonal matrices in practice and is never observed to fail on finite input; the code nevertheless aborts after 60 sweeps for one eigenvalue. The whole procedure is a product of orthogonal transformations and is backward stable: the computed eigenvalues are exact for with , so by Weyl’s inequality
Eigenvectors are accurate in proportion to , where the gap is the distance to the nearest other eigenvalue.
The case. For the characteristic polynomial gives, with ,
For the vector is an eigenvector:
since by the characteristic equation. The code returns these vectors without normalizing them. When , the subtraction cancels and loses relative accuracy; would be the stable alternative.
Power method
Starting from (or a coordinate vector if ), the method iterates
and stops when . If is diagonalizable with eigenvalues and has a component along the eigenvector , then
so the direction converges linearly with ratio , and
for symmetric the Rayleigh quotient converges with ratio
. When with
(for example ) the direction oscillates and
the residual test never passes, and when is nilpotent the iterate reaches
zero; both cases return None.
Statistics
variance is the population variance computed in two passes: first the mean
, then . The one-pass formula
subtracts two nearly equal numbers when the
data have a large mean and a small spread, and can even return a negative
value; the two-pass form sums non-negative terms whose rounding error is
relative to the variance itself (Chan, Golub and LeVeque, 1983). The count
is accumulated as a sum of ones in T, exact up to entries for
Double and for Float.
Transpose views and products
Transpose::mul computes as by
reusing the matrix kernel on the wrapped matrices. Entry by entry,
which agree when the scalars commute. All scalar types with Tolerance
(Float, Double) commute, but Transpose::mul only requires
AddMonoid + Mul; for a non-commutative scalar type the result is the
product in the wrong order (see the algebra design).
Design decisions
Flat row-major storage
Options. An array of row arrays, a persistent structure, or one flat
array. Decision. One Array[T] with entry at , shared by
all four targets. Why. It gives access with one bounds check per
coordinate, contiguous rows for the inner loops of elimination and products,
and zero-cost row and column views. from_array adopts the caller’s array so
that large inputs need not be copied; the price is aliasing, which the API
documents.
Views instead of copies
row_view, col_view and to_transpose return live views in . A
view is a pair (matrix, index) or a wrapper, so writes go to the shared storage
and no synchronization is needed. Materializing is always explicit
(to_vector, materialize, transpose).
Target-specific kernels with shared semantics
The package has one source file per target for the matrix, LU, view and transpose code. They differ only in loop structure (unrolling, packing, avoidance of division in index computations) chosen for each backend; the public semantics, including bounds and error behaviour, are identical, and the tests run on all four targets. Floating-point results may differ in the last bits because summation orders differ.
Tolerance-based decisions
Elimination must decide when a computed pivot “is zero”. The package uses one
absolute threshold = Tolerance::tolerance() = for Double
and Float, applied as follows:
| Routine | Test |
|---|---|
LU (determinant for , inverse, is_invertible) | pivot means singular |
rank | largest remaining means no pivot |
reduce_row_elimination | entries are set to zero |
cholesky_decomposition | radicand means not positive definite |
is_symmetric, fast-path detection (identity, diagonal, permutation, triangular) | |
eigen deflation | |
power_method | residual |
An absolute threshold is simple and predictable, but it is not scale
invariant. Scaling by makes every pivot fall below , so a
perfectly conditioned matrix is reported singular; scaling by lets a
numerically singular matrix pass. Scale data to order one before calling
these routines. For Float, is far below the unit roundoff
, so the tests effectively check for exact zeros, and
nearly singular Float matrices are not detected. The trait is closed
(pub, not pub(open)), so these two instances are the only ones; a
scale-aware tolerance policy is future work and would be a breaking change.
Fast paths
inverse recognizes the identity (returns a copy), diagonal matrices
(inverts the diagonal) and permutation matrices (returns the transpose, since
for a matrix whose columns are distinct coordinate
vectors). determinant recognizes triangular matrices for and
multiplies the diagonal. Each test costs and saves an
factorization. The tests use the tolerance, so a matrix that is diagonal up to
is treated as exactly diagonal.
Checked forms without changing the kernels
Every checked method validates (squareness, exponent sign, non-emptiness,
lengths) and then calls its unchecked partner, which keeps the original
aborting or Option behaviour. There is no checked matrix product here: *
validates and aborts, and unchecked_matmul does not validate at all. This
asymmetry with @immut.Matrix::matmul is known; a checked matmul would be
an addition, not a change.
Symmetric eigenvalues only
General real matrices can have complex eigenvalues, which a function
returning Vector[T] cannot represent for real T. eigen therefore accepts
only symmetric matrices, for which all eigenvalues are real and an orthonormal
eigenbasis exists, and aborts otherwise.
Correctness and invariants
- Storage.
data.length() == row * colat all times; every public accessor checks the row and column separately. - Value-returning methods do not mutate. Only
Unit-returning methods, view writes andreduce_row_eliminationchange a matrix. - Checked/unchecked law.
x.f() == Ok(x.unchecked_f())whenever the precondition holds;inversereturnsErr(SingularMatrix)exactly whenunchecked_inversereturnsNone. - Determinant consistency. For and a matrix that is not
triangular within ,
determinantreturns0exactly when the LU factorization reports a pivot below , which is also whenis_invertiblereturnsfalseandinversefails. For the closed formulas are used fordeterminant, whileis_invertiblestill uses LU, so a matrix with a tiny non-zero determinant can be “not invertible” with a non-zerodeterminant. - Residual guarantees.
cholesky_decompositionandeigenreturn factors whose reconstruction error is ;power_methodreturns only pairs with residual at most . - Complexity.
*: ;determinant,inverse: ;rank,reduce_row_elimination: ;cholesky_decomposition: ;eigen: ;power_method: per iteration; statistics: .
Alternatives rejected
- A relative or norm-scaled tolerance. More robust, but it would change the results of existing callers; deferred until a tolerance policy can be passed explicitly.
- Returning complex eigenvalues. Would make this package depend on a complex number type and change the signature for real symmetric input.
- Copy-on-write views. Would hide the cost of writes and break the in-place contract that views exist for.
- A single portable kernel. Measured to be slower on some targets; the per-target files trade code size for speed while sharing one specification.
Boundaries
mutable does not provide linear solves for a right-hand side as a public
method (only the inverse), QR, SVD or least-squares solvers, eigenvalues of
non-symmetric matrices, sparse storage, condition number estimates, or
scale-aware tolerances. It does not implement the algebra
traits itself; backends/default wraps it for that.
Domain workflows such as regression or optimization belong in downstream
packages.
Footnotes
-
N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM, 2002, chapters 9 (LU), 10 (Cholesky) and 8 (triangular systems). ↩