immut design
Design goal
immut gives linear algebra with value semantics: a matrix or vector, once
built, never changes, and every operation returns a new value. Programs can
then keep old versions, share values freely between components and reason
about code by substitution. The package also aims to compute exactly whenever
the scalar type allows it, so that integer and big-integer matrices get exact
determinants and powers rather than floating-point approximations.
Mathematical background
Value semantics and referential transparency
An expression is referentially transparent when it can be replaced by its value without changing the program. With immutable matrices,
so any expression mentioning means the same thing on either side of the update. Algebraic identities can then be used directly as program transformations: and denote the same value for exact scalars, and an intermediate result may be reused or recomputed at will.
Persistent storage
Entries live in row-major order in moonbitlang/core/immut/vector, a
persistent vector implemented as a trie with branching factor 32. Replacing
one element copies the path from the root to the leaf and shares every other
node, so
and the old matrix stays valid. Reads also cost ; at most seven levels cover elements. Whole-matrix operations rebuild the trie in .
Matrix powers by repeated squaring
For a square matrix over a semiring, write in binary. Then
which needs at most squarings and as many extra
products, instead of products. The rearrangement of the product is
valid because matrix multiplication is associative over any semiring (see the
algebra design); commutativity of the scalars is not needed,
since all factors are powers of the same . pow keeps a state , an
exponent and a base with the invariant
initially ; each step either multiplies by when is odd, then halves and squares . The invariant is preserved, and when the state is . For fixed-width integers the result is exact in even when it overflows, because wrapping arithmetic is the ring arithmetic of that quotient.
Fraction-free determinant
Gaussian elimination over a field computes as the product of pivots but divides at every step, which leaves the integers. Bareiss’ algorithm keeps every intermediate value an integer.11 E. H. Bareiss, “Sylvester’s identity and multistep integer-preserving Gaussian elimination”, Mathematics of Computation 22 (1968), 565–578. Let , , and for and
By Sylvester’s determinant identity, each equals the minor of formed by rows and columns :
Two consequences follow. First, the division in the recurrence is exact: the numerator is a multiple of the previous pivot, so over an integral domain such as the quotient is again in the domain. Second, the last value is the full determinant, . The proof of the identity is in the attachment below.
Pivoting fits in without breaking exactness. Row of depends only on row of and on rows , so exchanging two rows with indices before step is the same as running the algorithm on with those rows exchanged, which multiplies the final result by . If the whole column below the pivot is zero, those minors vanish, the rows are linearly dependent, and .
Every intermediate value is a minor of , so Hadamard’s inequality bounds them all:
over the rows involved. The numerator of the recurrence is a difference
of two products of such minors, so it is bounded by twice the square of that
product. This gives
an overflow criterion for Int: if
satisfies , no intermediate value overflows and the result is
exact (for Int64, ). For BigInt the algorithm is always
exact, every intermediate integer is bounded by , and it uses
arithmetic operations.
For the package uses closed formulas instead. For it is the cofactor expansion along the first row; for it is Laplace’s expansion along the first two rows,
six products of complementary minors. These formulas use only ring operations, so they are exact for every ring and need no division at all.
Lazy matrices
A MatrixFn is a pair with .
Operations compose functions: is , transpose is
, and the product is
evaluated on demand. Nothing is cached, so the cost of one entry of a product is the inner dimension times the cost of entries of the factors. A product tree of depth over matrices therefore costs per entry: lazy powers are cheap to build and expensive to read.
Design decisions
Persistent vector storage
Options. (a) A copied Array per update, (b) a persistent trie,
(c) functions only. Decision. (b) for Matrix and Vector, (c) offered
separately as MatrixFn. Why. A copied array makes set cost ; a
trie makes it while keeping reads fast and the value
immutable. Functions are useful for structured or symbolic matrices, but their
read cost depends on the history of operations, so they are a separate type
whose cost model is stated rather than hidden.
Exact algorithms where the scalar allows them
determinant asks only for Compare + Num + Div, not for a field, so it
accepts Int, Int64 and BigInt. With Bareiss elimination the result is
exact over those types; over Double it behaves like elimination with scaled
pivots. The mutable package, which targets floating point,
uses LU with partial pivoting and a tolerance instead.
Checked short names, unchecked explicit names
matmul, trace, determinant and pow return Result; their
unchecked_* partners abort. The operators +, -, * cannot return
Result and abort on mismatched shapes. The checked form validates and then
calls the unchecked one, which gives the law
checked(x) == Ok(unchecked(x)) on the domain by construction.
Alignment with mutable
Names, argument order and checked/unchecked conventions match @mutable
wherever both packages offer an operation, and the
consistency tests compare their results. Differences are
deliberate and listed here:
| Operation | immut | mutable |
|---|---|---|
| identity | Matrix::identity(n) | top-level identity(n) |
| update | set returns a new matrix | set writes in place |
m[r][c] = x | not available | available |
| determinant | Bareiss, exact over integral domains | LU with tolerance, floating point |
| decompositions, inverse, statistics | not available | available |
dot | not on Vector (see ImmutableDenseVector::dot) | Vector::dot |
No subtraction on Vector
Vector implements Add, Mul and Neg but not Sub; u - v is written
u + -v. This is an asymmetry inherited from earlier releases, not a
mathematical statement. The backends/default wrappers provide -.
Correctness and invariants
- Immutability. No public operation modifies an existing
Matrix,VectororMatrixFn;setandswap_*return new values. - Shape invariants. and the backing vector has exactly elements; constructors reject anything else by aborting.
- Bounds. Every public read checks row and column separately, so a column index that would land inside the next row is rejected rather than read.
- Exactness. Over
BigInt,determinantandpoware exact; overIntandInt64,powis exact modulo anddeterminantis exact unless a minor overflows (see the Hadamard bound). - Empty cases. and of the matrix are and ; ; a product with inner dimension is the zero matrix.
- Complexity.
set: ;map,+,transpose,swap_*: ;matmul: ;determinant: ;pow: .
Alternatives rejected
- Floating-point LU for
immutdeterminants. It would need a field and a tolerance and would lose exactness for integer matrices, the main reason to use this package. - A runtime backend selector inside
Matrix. Backends are separate types (backends/default);Matrixhas one representation. - Caching in
MatrixFn. It would make a pure value hold mutable state and change the cost model silently. Materialize withMatrix::makeinstead.
Boundaries
immut does not provide in-place updates, views, inverses, decompositions,
eigenvalues, statistics or tolerance-based predicates; those belong to
mutable. It does not detect integer overflow, does not offer a
checked MatrixFn API, and does not implement the algebra
traits itself (the wrappers in backends/default do).
Footnotes
-
E. H. Bareiss, “Sylvester’s identity and multistep integer-preserving Gaussian elimination”, Mathematics of Computation 22 (1968), 565–578. ↩