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, uu is the unit roundoff (2−532^{-53} for Double, 2−242^{-24} for Float), fl(x∘y)=(x∘y)(1+δ)\mathrm{fl}(x \circ y) = (x \circ y)(1 + \delta) with ∣δ∣≤u|\delta| \le u, and γn=nu/(1−nu)\gamma_n = n u / (1 - n u). Inequalities between matrices hold entry-wise, and ∣A∣|A| is the matrix of absolute values.

Matrix product

(AB)ik=∑jaijbjk(AB)_{ik} = \sum_j a_{ij} b_{jk} costs rcnrcn multiply-adds. The kernels sum in different orders (four partial products per step in the unrolled kernels; a packed copy of the columns of BB for products of at least 4×16×164 \times 16 \times 16), but every order satisfies

∣fl(AB)−AB∣≤γn∣A∣ ∣B∣,\big|\mathrm{fl}(AB) - AB\big| \le \gamma_n |A|\,|B| ,

so results on different targets agree to that accuracy, not bit for bit.

LU factorization with partial pivoting

For square AA, Gaussian elimination with partial pivoting computes a permutation PP, a unit lower triangular LL and an upper triangular UU with

PA=LU.PA = LU .

At step kk it chooses the row p≥kp \ge k with the largest ∣apk(k)∣|a^{(k)}_{pk}|, swaps it into position kk, and for i>ki > k stores the multiplier lik=aik(k)/akk(k)l_{ik} = a^{(k)}_{ik} / a^{(k)}_{kk} and updates aij(k+1)=aij(k)−likakj(k)a^{(k+1)}_{ij} = a^{(k)}_{ij} - l_{ik} a^{(k)}_{kj}. Pivoting guarantees ∣lik∣≤1|l_{ik}| \le 1. The cost is 23n3\tfrac23 n^3 flops.

Determinant. Taking determinants of PA=LUPA = LU with det⁡L=1\det L = 1 and det⁡P=(−1)s\det P = (-1)^{s} for ss exchanges,

det⁡A=(−1)s∏kukk.\det A = (-1)^{s} \prod_{k} u_{kk} .

Solving. Ax=bAx = b becomes Ly=PbL y = P b (forward substitution) and Ux=yU x = y (back substitution), each n2n^2 flops per right-hand side. The inverse is the solution for the nn columns of II: 23n3+n⋅2n2=83n3\tfrac23 n^3 + n \cdot 2n^2 = \tfrac83 n^3 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).

L^U^=P(A+ΔA),∣ΔA∣≤γn∣L^∣ ∣U^∣.\hat L \hat U = P(A + \Delta A), \qquad |\Delta A| \le \gamma_n |\hat L|\,|\hat U| .

With ∣lik∣≤1|l_{ik}| \le 1 this gives ∥ΔA∥∞≤nγnρn∥A∥∞\lVert \Delta A \rVert_\infty \le n \gamma_n \rho_n \lVert A \rVert_\infty, where ρn=max⁡i,j,k∣aij(k)∣/max⁡i,j∣aij∣\rho_n = \max_{i,j,k} |a^{(k)}_{ij}| / \max_{i,j} |a_{ij}| is the growth factor. Partial pivoting bounds ρn≤2n−1\rho_n \le 2^{n-1}; the bound is attained only by contrived matrices, and in practice ρn\rho_n is small, so the method is backward stable in practice. The forward error of a solve is then governed by the condition number: ∥x^−x∥/∥x∥≲κ(A) nγnρn\lVert \hat x - x \rVert / \lVert x \rVert \lesssim \kappa(A)\, n \gamma_n \rho_n.

Closed forms for small determinants

For n≤4n \le 4 the determinant is evaluated by formulas instead of LU: the rule ad−bcad - bc, cofactor expansion along the first row for n=3n = 3, and for n=4n = 4 Laplace’s expansion along the first two rows into six products of complementary 2×22 \times 2 minors,

det⁡A=∑p<q(−1)p+q+1det⁡A{0,1},{p,q}det⁡A{2,3},{p,q}‾.\det A = \sum_{p<q} (-1)^{p+q+1} \det A_{\{0,1\},\{p,q\}} \det A_{\{2,3\},\overline{\{p,q\}}} .

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 ad−bcad - bc 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 τ\tau: the number of pivots ≥τ\ge \tau. It is exact for matrices whose pivots are well separated from τ\tau and otherwise depends on scaling. (The singular value decomposition gives the reliable numerical rank, #{σi>τ}\#\{\sigma_i > \tau\}; 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 rcmin⁡(r,c)r c \min(r, c) flops and works in place.

Cholesky factorization

A symmetric positive definite (SPD) matrix has a unique factorization A=LLTA = L L^{\mathsf T} with LL lower triangular and ljj>0l_{jj} > 0. Comparing entries of A=LLTA = L L^{\mathsf T} for i≥ji \ge j,

aij=∑k=0jlikljk⟹ljj=ajj−∑k<jljk2,lij=1ljj(aij−∑k<jlikljk).a_{ij} = \sum_{k=0}^{j} l_{ik} l_{jk} \quad\Longrightarrow\quad l_{jj} = \sqrt{a_{jj} - \sum_{k<j} l_{jk}^2}, \qquad l_{ij} = \frac{1}{l_{jj}} \Big(a_{ij} - \sum_{k<j} l_{ik} l_{jk}\Big) .

The code evaluates these row by row (the Cholesky–Banachiewicz order), 13n3\tfrac13 n^3 flops. The radicand at step jj equals the pivot of Gaussian elimination without pivoting, which is the ratio of leading principal minors det⁡Mj+1/det⁡Mj\det M_{j+1} / \det M_j. By Sylvester’s criterion, AA 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 ajj=∑kljk2a_{jj} = \sum_k l_{jk}^2 every ∣ljk∣≤ajj|l_{jk}| \le \sqrt{a_{jj}}, so entries cannot grow, and the computed factor satisfies L^L^T=A+ΔA\hat L \hat L^{\mathsf T} = A + \Delta A with ∣ΔA∣≤γn+1∣L^∣ ∣L^T∣|\Delta A| \le \gamma_{n+1} |\hat L|\,|\hat L^{\mathsf T}| (Higham, Theorem 10.3).

Symmetric eigenvalue problem

For real symmetric AA the spectral theorem gives A=QΛQTA = Q \Lambda Q^{\mathsf T} with QQ orthogonal and Λ\Lambda real diagonal. eigen computes it in two phases.

Householder tridiagonalization. A Householder reflector H=I−2vvT/vTvH = I - 2 v v^{\mathsf T} / v^{\mathsf T} v is symmetric and orthogonal and can map a vector onto a multiple of a coordinate vector. Applying n−2n - 2 reflectors from both sides,

Q1TAQ1=T,Q1=H1H2⋯Hn−2,Q_1^{\mathsf T} A Q_1 = T, \qquad Q_1 = H_1 H_2 \cdots H_{n-2},

where TT is symmetric tridiagonal with diagonal dd and off-diagonal ee. The code accumulates Q1Q_1 explicitly; together about 83n3\tfrac83 n^3 flops. Each row is scaled by the sum of its absolute values before the reflector is formed, which avoids overflow and underflow in ∥x∥2\lVert x \rVert_2.

Implicit QL with Wilkinson shifts. The tridiagonal TT is diagonalized by plane rotations. Before each sweep on the unreduced block starting at ll, the shift σ\sigma is the eigenvalue of the leading 2×22 \times 2 block (dleleldl+1)\begin{pmatrix} d_l & e_l \\ e_l & d_{l+1} \end{pmatrix} closer to dld_l. With g=(dl+1−dl)/(2el)g = (d_{l+1} - d_l)/(2 e_l) that block has eigenvalues

λ±=dl+el(g±g2+1),\lambda_\pm = d_l + e_l \big(g \pm \sqrt{g^2 + 1}\big),

and the one closer to dld_l is

σ=dl+el(g−sgn⁡(g)g2+1)=dl−elg+sgn⁡(g)g2+1,\sigma = d_l + e_l\big(g - \operatorname{sgn}(g)\sqrt{g^2+1}\big) = d_l - \frac{e_l}{g + \operatorname{sgn}(g)\sqrt{g^2 + 1}} ,

the second form, used by the code, avoids cancellation. A sweep applies Givens rotations that chase the resulting bulge and update QQ with the same rotations. An off-diagonal entry is treated as zero when

∣em∣≤τ (∣dm∣+∣dm+1∣+1),|e_m| \le \tau\,\big(|d_m| + |d_{m+1}| + 1\big),

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 A+ΔAA + \Delta A with ∥ΔA∥2=O(u)∥A∥2\lVert \Delta A \rVert_2 = O(u)\lVert A \rVert_2, so by Weyl’s inequality

∣λ^i−λi∣≤∥ΔA∥2=O(u) ∥A∥2.|\hat\lambda_i - \lambda_i| \le \lVert \Delta A \rVert_2 = O(u)\,\lVert A \rVert_2 .

Eigenvectors are accurate in proportion to u∥A∥/gapu\lVert A \rVert / \text{gap}, where the gap is the distance to the nearest other eigenvalue.

The 2×22 \times 2 case. For A=(abcd)A = \begin{pmatrix} a & b \\ c & d \end{pmatrix} the characteristic polynomial λ2−(a+d)λ+(ad−bc)\lambda^2 - (a + d)\lambda + (ad - bc) gives, with m=(a+d)/2m = (a + d)/2,

λ1,2=m±m2−(ad−bc).\lambda_{1,2} = m \pm \sqrt{m^2 - (ad - bc)} .

For b≠0b \ne 0 the vector (b,λ−a)T(b, \lambda - a)^{\mathsf T} is an eigenvector:

(a−λbcd−λ)(bλ−a)=(0bc−(λ−a)(λ−d))=0,\begin{pmatrix} a - \lambda & b \\ c & d - \lambda \end{pmatrix} \begin{pmatrix} b \\ \lambda - a \end{pmatrix} = \begin{pmatrix} 0 \\ bc - (\lambda - a)(\lambda - d) \end{pmatrix} = 0 ,

since (λ−a)(λ−d)=λ2−(a+d)λ+ad=bc(\lambda - a)(\lambda - d) = \lambda^2 - (a + d)\lambda + ad = bc by the characteristic equation. The code returns these vectors without normalizing them. When ∣λ2∣≪∣λ1∣|\lambda_2| \ll |\lambda_1|, the subtraction m−⋅m - \sqrt{\cdot} cancels and λ2\lambda_2 loses relative accuracy; λ2=det⁡A/λ1\lambda_2 = \det A / \lambda_1 would be the stable alternative.

Power method

Starting from x0=(1,…,1)x_0 = (1, \dots, 1) (or a coordinate vector if Ax0=0A x_0 = 0), the method iterates

y=Axk,xk+1=y/∥y∥∞,λk=xkTAxkxkTxk,y = A x_k, \qquad x_{k+1} = y / \lVert y \rVert_\infty, \qquad \lambda_k = \frac{x_k^{\mathsf T} A x_k}{x_k^{\mathsf T} x_k} ,

and stops when ∥Axk−λkxk∥∞≤τ\lVert A x_k - \lambda_k x_k \rVert_\infty \le \tau. If AA is diagonalizable with eigenvalues ∣λ1∣>∣λ2∣≥…|\lambda_1| > |\lambda_2| \ge \dots and x0x_0 has a component c1≠0c_1 \ne 0 along the eigenvector v1v_1, then

Akx0=λ1k(c1v1+∑i≥2ci(λiλ1)kvi),A^{k} x_0 = \lambda_1^{k}\Big(c_1 v_1 + \sum_{i \ge 2} c_i \big(\tfrac{\lambda_i}{\lambda_1}\big)^{k} v_i\Big),

so the direction converges linearly with ratio ∣λ2/λ1∣|\lambda_2 / \lambda_1|, and for symmetric AA the Rayleigh quotient converges with ratio ∣λ2/λ1∣2|\lambda_2 / \lambda_1|^2. When ∣λ1∣=∣λ2∣|\lambda_1| = |\lambda_2| with λ1≠λ2\lambda_1 \ne \lambda_2 (for example ±1\pm 1) the direction oscillates and the residual test never passes, and when AA is nilpotent the iterate reaches zero; both cases return None.

Statistics

variance is the population variance computed in two passes: first the mean aˉ\bar a, then 1N∑(ai−aˉ)2\tfrac1N \sum (a_i - \bar a)^2. The one-pass formula 1N∑ai2−aˉ2\tfrac1N \sum a_i^2 - \bar a^2 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 NN is accumulated as a sum of ones in T, exact up to 2532^{53} entries for Double and 2242^{24} for Float.

Transpose views and products

Transpose::mul computes ATBTA^{\mathsf T} B^{\mathsf T} as (BA)T(BA)^{\mathsf T} by reusing the matrix kernel on the wrapped matrices. Entry by entry,

(ATBT)ik=∑jajibkj,((BA)T)ik=∑jbkjaji,\big(A^{\mathsf T} B^{\mathsf T}\big)_{ik} = \sum_j a_{ji} b_{kj}, \qquad \big((BA)^{\mathsf T}\big)_{ik} = \sum_j b_{kj} a_{ji} ,

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 (i,j)(i, j) at ic+ji c + j, shared by all four targets. Why. It gives O(1)O(1) 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 O(1)O(1). 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 τ\tau = Tolerance::tolerance() = 10−1110^{-11} for Double and Float, applied as follows:

RoutineTest
LU (determinant for n≥5n \ge 5, inverse, is_invertible)pivot ∣ukk∣<τ\lvert u_{kk}\rvert < \tau means singular
ranklargest remaining ∣aik∣<τ\lvert a_{ik}\rvert < \tau means no pivot
reduce_row_eliminationentries ≤τ\le \tau are set to zero
cholesky_decompositionradicand ≤τ\le \tau means not positive definite
is_symmetric, fast-path detection (identity, diagonal, permutation, triangular)∣aij−bij∣≤τ\lvert a_{ij} - b_{ij}\rvert \le \tau
eigen deflation∣em∣≤τ(∣dm∣+∣dm+1∣+1)\lvert e_m\rvert \le \tau(\lvert d_m\rvert + \lvert d_{m+1}\rvert + 1)
power_methodresidual ∥Ax−λx∥∞≤τ\lVert Ax - \lambda x\rVert_\infty \le \tau

An absolute threshold is simple and predictable, but it is not scale invariant. Scaling AA by 10−1210^{-12} makes every pivot fall below τ\tau, so a perfectly conditioned matrix is reported singular; scaling by 101210^{12} lets a numerically singular matrix pass. Scale data to order one before calling these routines. For Float, τ=10−11\tau = 10^{-11} is far below the unit roundoff ≈6×10−8\approx 6 \times 10^{-8}, 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 PTP=IP^{\mathsf T} P = I for a matrix whose columns are distinct coordinate vectors). determinant recognizes triangular matrices for n≥5n \ge 5 and multiplies the diagonal. Each test costs O(n2)O(n^2) and saves an O(n3)O(n^3) factorization. The tests use the tolerance, so a matrix that is diagonal up to τ\tau 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 * col at all times; every public accessor checks the row and column separately.
  • Value-returning methods do not mutate. Only Unit-returning methods, view writes and reduce_row_elimination change a matrix.
  • Checked/unchecked law. x.f() == Ok(x.unchecked_f()) whenever the precondition holds; inverse returns Err(SingularMatrix) exactly when unchecked_inverse returns None.
  • Determinant consistency. For n≥5n \ge 5 and a matrix that is not triangular within τ\tau, determinant returns 0 exactly when the LU factorization reports a pivot below τ\tau, which is also when is_invertible returns false and inverse fails. For n≤4n \le 4 the closed formulas are used for determinant, while is_invertible still uses LU, so a matrix with a tiny non-zero determinant can be “not invertible” with a non-zero determinant.
  • Residual guarantees. cholesky_decomposition and eigen return factors whose reconstruction error is O(u)∥A∥O(u)\lVert A \rVert; power_method returns only pairs with residual at most τ\tau.
  • Complexity. *: rcnrcn; determinant, inverse: O(n3)O(n^3); rank, reduce_row_elimination: O(rcmin⁡(r,c))O(rc\min(r, c)); cholesky_decomposition: n3/3n^3/3; eigen: O(n3)O(n^3); power_method: O(n2)O(n^2) per iteration; statistics: O(rc)O(rc).

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

  1. N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM, 2002, chapters 9 (LU), 10 (Cholesky) and 8 (triangular systems). ↩