Skip to content

Matrix structure and linear algebra

A matrix parameter can carry more than a shape. Beyond how many axes, how long (see Matrices, and named dimensions), mathema tracks a matrix's structure: symmetric, triangular, orthogonal, positive definite, finite. Structure is a claim kind in its own right, and it is also a type that narrows what a claim is checked over and what a linear-algebra identity may assume.

The claim kinds form a hierarchy, cheapest first:

kind example cost
shape A ∈ ℝ^{n×n} axis bookkeeping
element-wise is_finite(A) O(n²) direct
structural A == A.T O(n²) direct
spectral is_positive_definite(A) O(n³) eigen / Cholesky
algebra det(A @ B) == det(A) * det(B) symbolic

Declaring structure

A structure is a marker in the parameter's type, alongside its shape. The canonical spelling is an Annotated hint; Mat(...) is the sugar that produces an equivalent annotation:

from typing import Annotated
from mathema.types import Mat, Shape, Symmetric, PositiveDefinite

# canonical
def f(A: Annotated[list, Shape("n", "n"), Symmetric]): ...

# sugar (equivalent annotation)
def g(A: Mat("n", "n", Symmetric)): ...

The markers, by level: Real, Finite (element-wise); Symmetric, SkewSymmetric, Diagonal, UpperTriangular, LowerTriangular, Identity (structural); Orthogonal, PositiveDefinite, PositiveSemidefinite (spectral). Each maps to a registry predicate (Symmetric to is_symmetric, and so on).

A matrix parameter is drawn as nested lists unless its signature names another runtime type: A: np.ndarray, or Mat("n", "n", runtime="numpy.ndarray") beside the structure markers, samples it as a 2-D array, so def transpose(A: np.ndarray): return A.T holds for A in R^(n,n), f(f(A)) == A where a list of lists would raise at .T. Nested lists are drawn up to 64 per axis. See runtime types.

A declared structure is entailment-closed: PositiveDefinite also asserts is_symmetric (and everything symmetry implies), because a positive-definite matrix is symmetric. Declaring the specific property narrows the domain to everything it entails.

Structure claims

A structure predicate is a claim about a matrix VALUE. The canonical form is the predicate call; the postfix is reading is sugar for it:

is_symmetric(A)            # a precondition on the argument
is_symmetric(f(A))         # the output is symmetric
A is symmetric             # postfix, folds to is_symmetric(A)
f(A) is symmetric          # postfix on an output, folds to is_symmetric(f(A))
f(A) is positive definite  # a multi-word predicate reads the same way

On a bare parameter (is_symmetric(A)) the claim asks whether the function GUARDS the property: does it reject an argument that lacks it, the value analogue of an out-of-domain rejection. On an expression (is_symmetric(f(A)), is_symmetric(A @ B)) it checks the resulting value. Both adjudicate on the examine route: a structural check on synthesised or real values, numpy-fast where numpy is present and a pure-Python fallback otherwise. A spectral check with no numpy declines (skipped) rather than guessing.

is_finite(A) is the matrix-scale sibling of the missing-value axis: it narrows a domain away from nan/inf, fast via numpy. On an expression (is_finite(f(x))) the check reads the claim's assuming clause like every other claim, and a raise from f at a point inside it falsifies the claim, naming the exception.

Both is_finite and is_real also read a result that is a single number, and they read every value the same way with or without numpy installed. A number is an int, a bool, a float, a complex, a Fraction or a Decimal. It is finite when it is neither an infinity nor a NaN (a complex number when both its parts are), and a bool or a Fraction is always finite. is_real reads the value, not its type: a complex number with a zero imaginary part, such as 1 + 0j, is real, and a vector or matrix is real when every entry is. An empty vector or matrix is finite and real, since no entry is otherwise. A result that is not a number, nor a vector or matrix of numbers (None, a string, a ragged list, a list holding None), leaves the claim unknown with "the result is not a number" and the point that returned it, unless another point falsifies the claim.

Narrowing the domain

A structure marker, or an assuming premise, narrows the matrices a claim is sampled over. Under

assuming A is positive definite, f(A) == f(A.T)

the sampler synthesises A by construction as a positive-definite matrix (entailment-closed, so it is symmetric too), because a random matrix is almost never positive definite and rejection alone would never find one. The same premise becomes a sympy assumption on the derive route (below), so the two halves, sampling and proof, read one declaration the same way.

The linear-algebra grammar

The operation vocabulary is Python-flavoured, what a NumPy user writes:

A @ B        matrix product
A.T          transpose
det(A)       determinant
inv(A)       inverse
trace(A)     trace
I(n)         the n-by-n identity
x.T @ A @ x  a quadratic form

What each operator means

The operators read the way numpy reads them, whatever the function's own runtime type: a list-of-lists matrix is evaluated as an array, so x + y never concatenates two lists.

spelling on vectors and matrices
A @ B the matrix product; two vectors give their dot product
A * B elementwise (the Hadamard product); c * A scales
A ** k elementwise power; the matrix power is matrix_power(A, k)
A + B, A - B, -A elementwise; A + c shifts every element
abs(A) elementwise absolute value
|A| the determinant of a declared matrix (sugar, below)
norm(x), ||x|| Euclidean on a vector, Frobenius on a matrix
norm(A, 2), norm(A, 1), norm(A, inf) spectral, largest column sum, largest row sum
||A||_2, ||A||_1, ||A||_inf the same orders with double bars, the order a subscript
A ~= B element by element, with the scalar tolerance

An ordering between a matrix or vector the claim writes and anything (A >= 0, A * A >= 0) is not defined, and the claim is refused as misspecified with the reason; compare a number drawn from it instead (det, trace, norm, an element). A value the function returned is read element by element: f(A) >= 0 holds when it holds at every element, and does not hold over an empty result. An elementwise reading of a written matrix, spelled all(...), is future work. The scalar derive route never reads a vector or matrix as a number: a claim that uses one as a value goes to the matrix algebra below or, when that cannot close it, to sampling.

from mathema.types import Mat, Vec

def two(A: Mat("n", "n"), B: Mat("n", "n")):
    return A

def vecs(x: Vec("n"), y: Vec("n")):
    return x
A * B == B * A   # proven
A @ B == B @ A   # falsified
A ** 2 == A * A   # proven
matrix_power(A, 2) == A @ A   # proven
matrix_power(A, 2) == A * A   # falsified
norm(x + y) <= norm(x) + norm(y)   # holds
‖x + y‖ ≤ ‖x‖ + ‖y‖   # holds
norm(x + y)**2 == norm(x)**2 + norm(y)**2   # falsified
||x + y||^2 == ||x||^2 + ||y||^2 + 2*dot(x, y)   # proven
||x||_inf <= ||x||_2 <= ||x||_1   # holds

The vocabulary

Beyond det, inv, trace, transpose and I(n):

word meaning
dot(x, y), outer(x, y), kron(A, B) inner, outer and Kronecker products
diag(A), diag(v) the diagonal of a matrix; the diagonal matrix of a vector
rank(A), cond(A) numerical rank; the 2-norm condition number
eigvals(A), eigvalsh(A) eigenvalues (complex in general); of a symmetric matrix, real and ascending
solve(A, b), pinv(A) the solution of A @ x == b; the pseudo-inverse
A[i, :], A[:, j] a row, a column
sum(A, axis=0), mean(A, axis=1) reductions along an axis (also prod, min, max)
x.T @ A @ x a quadratic form over a vector x, a number

A decomposition is a claim about its factors, with the numpy function bound by let: let q = numpy.linalg.qr, q(A)[0] @ q(A)[1] ~= A. A vector of the claim's own is declared with let b be R^n, and takes the size the parameters give n. In a premise, x != 0 over a vector says it is not the zero vector, and a claim's own x != 0 compares different shapes and holds. A premise that pins the whole of it to a value, x == 0, is not read yet, and the claim is unknown.

from mathema.types import Mat, Vec

def one(A: Mat("n", "n")):
    return A

def form(A: Mat("n", "n"), x: Vec("n")):
    return A
trace(A) ~= sum(eigvals(A))   # proven
det(A) ~= prod(eigvals(A))   # proven
trace(A) ~= prod(eigvals(A))   # falsified
let b be R^n, assuming det(A) != 0, A @ solve(A, b) ~= b   # proven
A @ pinv(A) @ A ~= A   # holds
sum(A, axis=0) ~= sum(A.T, axis=1)   # holds
let q = numpy.linalg.qr, q(A)[0] @ q(A)[1] ~= A   # holds
let q = numpy.linalg.qr, q(A)[1] @ q(A)[0] ~= A   # falsified
assuming A is positive definite and x != 0, x.T @ A @ x > 0   # holds
assuming A is symmetric and x != 0, x.T @ A @ x > 0   # falsified

A structure premise and a relation premise combine with and: assuming A is symmetric and det(A) != 0, inv(A) ~= inv(A).T. The identity and skew-symmetric premises reach the derive route as exact rewrites (A is I(n); A.T is -A), since sympy has no assumption for them; positive semidefiniteness has none either, so it narrows sampling only.

Real matrices only

Matrices and vectors are real in this release: a claim over C^n or C^(m,n) is refused as misspecified, with the reason "matrices and vectors are real-only in this release". Complex scalars are unaffected.

A function may still return a vector or matrix of complex numbers, and that result is a value, read as a complex array with or without numpy. is_finite and is_real decide it by value, entry by entry, as they do a complex scalar. The structural properties (symmetric, triangular, diagonal, orthogonal, positive definite and the rest) are read over real matrices only, so on a complex result they leave the claim unknown with the reason "the result is a complex matrix, and is_symmetric is read over real matrices only in this release". With numpy installed, a real claim over R^n or R^(m,n) whose function returns a complex vector or matrix reads it as it reads a complex scalar: no real value there.

Sugar

Three math-paper spellings are accepted as sugar. Each is ambiguous against a scalar reading, so mathema resolves it from the operand's declared type: on a matrix it is the matrix operation, on a scalar the ordinary one.

sugar on a matrix on a scalar
A^T A.T (transpose) A ** T (power)
|A| det(A) abs(A)
A^-1 inv(A) 1 / A (reciprocal)

The type is known from a signature marker, an R^(m,n) domain, or a let ... be R^(m,n) declaration:

let A be R^(n,n), A^T == A          #  ->  A.T == A
for A in R^(n,n), |A| >= 0          #  ->  det(A) >= 0

The bars are the determinant only as written: an explicit abs(A) is always elementwise, and a rendered claim keeps the abs(...) spelling for a matrix.

Because the reading is per-operand, one expression can mix the two: with A a matrix and c a scalar, inv(c * A) == c^-1 * A^-1 reads c^-1 as a reciprocal and A^-1 as an inverse, and the record writes it inv(A*c) = inv(A)/c.

When a claim uses the matrix vocabulary, the record's grammar field is stamped mathema/linalg. This is informative only, a note that the parsing was matrix-aware; it is never required to round-trip, because the canonical statement (det(A @ B) == det(A) * det(B)) re-parses under the base grammar on its own.

Proving identities and inequalities

A matrix-algebra relation is an identity of the algebra, not a fact about a function's body. mathema lifts the claim's own expressions to sympy.MatrixSymbol terms, turns structure markers and premises into sympy assumptions (Q.symmetric, Q.positive_definite, ...), and decides the relation. Equalities are decided by simplifying the difference to zero; scalar comparisons of a determinant or trace are decided by sympy's assumption engine. The identities below are checked against a function of two square matrices of a shared dimension:

from mathema.types import Mat

def f(A: Mat("n", "n"), B: Mat("n", "n")):
    return A
det(A @ B) == det(A) * det(B)   # proven
(A @ B).T == B.T @ A.T   # proven
trace(A + B) == trace(A) + trace(B)   # proven
assuming A is symmetric, A.T == A   # proven
assuming A is orthogonal, A.T @ A == I(n)   # proven
assuming A is positive definite, det(A) > 0   # proven
assuming A is positive definite, trace(A) > 0   # proven

A relation sympy cannot close falls to the matrix-value probe: concrete matrices are sampled (structure-narrowed, as above), both sides evaluated, and the claim either holds across the draws or is falsified with the witnessing matrices. A strict derive route reports the honest unknown instead of sampling.

Facts proven for every size

sympy's matrix algebra does not apply every fact of real matrices on its own, so mathema adds a layer of lemmas, each true of real square matrices of any size, before the two sides are compared:

lemma example
trace cyclicity (cyclic rotations of a product only) trace(A @ B @ C) == trace(C @ A @ B)
trace of a transpose trace(A @ B.T) == trace(B @ A.T)
linearity of trace trace(c*A + B) == c*trace(A) + trace(B)
@ distributes over +, on both sides (A + B) @ C == A @ C + B @ C
scaling through a product (c*A) @ B == c*(A @ B)
transpose of a sum and of a product (A @ B).T == B.T @ A.T
a Gram product A @ A.T or A.T @ A is positive semidefinite det(A @ A.T) >= 0
the trace is the sum of the eigenvalues, the determinant their product det(A) == prod(eigvals(A))
the determinant of a scaled matrix det(c*A) == c**n * det(A)
the rank of a Gram product is the rank of its factor rank(A @ A.T) == rank(A)

A proof that used one names it in its sketch and in the record's mathema.matrix_lemmas meta. Each lemma is exact, so a relation it does not close is not thereby false: the claim is sampled as before.

from mathema.types import Mat, Vec

def f(A: Mat("n", "n"), B: Mat("n", "n"), C: Mat("n", "n"), c: float):
    return A

def form(A: Mat("n", "n"), x: Vec("n")):
    return A
trace(A @ B) == trace(B @ A)   # proven
trace(A @ B @ C) == trace(C @ A @ B)   # proven
A @ (B + C) == A @ B + A @ C   # proven
(A + B) @ (A + B) == A @ A + A @ B + B @ A + B @ B   # proven
trace(A @ A.T) >= 0   # proven
det(A @ A.T) >= 0   # proven
trace(A) == sum(eigvals(A))   # proven
det(A) == prod(eigvals(A))   # proven
det(c*A) == c**n * det(A)   # proven
rank(A @ A.T) == rank(A)   # proven
x.T @ (A @ A.T) @ x >= 0   # proven

What looks like a sibling of a lemma but is false is never proven, and on the default route its sampling finds the witness:

trace(A @ B @ C) == trace(B @ A @ C)   # falsified
trace(A @ B) == trace(A) * trace(B)   # falsified
det(A + B) == det(A) + det(B)   # falsified
A @ B == B @ A   # falsified
(A + B) @ (A + B) == A @ A + 2*(A @ B) + B @ B   # falsified
det(A @ A.T) > 0   # falsified
A * B == A @ B   # falsified

det(A @ A.T) > 0 fails exactly where A is singular. A random matrix is almost never singular, so one draw in eight of a matrix with no declared structure has a zeroed row, the matrix analogue of the boundary values a scalar draw includes.

Structure predicates are proven the same way when the structure follows from the operands', by rules of their own (a product of two symmetric matrices is not symmetric in general, and is never proven so): a Gram product is symmetric and positive semidefinite, and positive definite when its factor is invertible; Y @ M @ Y.T has the symmetry and semidefiniteness of M; a transpose, inverse or power keeps symmetry, orthogonality and definiteness, and swaps upper and lower triangularity; products keep orthogonality, diagonality and triangularity their factors share.

is_symmetric(A @ A.T)   # proven
is_positive_semidefinite(A.T @ A)   # proven
assuming det(A) != 0, is_positive_definite(A.T @ A)   # proven
assuming A is orthogonal, is_orthogonal(A.T)   # proven
assuming A is positive definite, is_positive_definite(inv(A))   # proven
assuming A is upper triangular and B is upper triangular, is_upper_triangular(A @ B)   # proven
is_symmetric(A @ B)   # falsified
assuming A is symmetric and B is symmetric, is_symmetric(A @ B)   # falsified
is_positive_definite(A.T @ A)   # falsified

A claim about f(A) stays sampled: the derive route reads the claim's own matrix algebra, not the function's body. What stays sampled for every claim: norms, the condition number, eigenvalues other than their sum and product, solve, pinv and the decompositions.

Through numpy's own functions

A function that calls numpy is read through numpy's definition rows: on an array A, A @ A.T reads A.T through numpy.ndarray.T as transpose(A), and the claim is decided by the matrix algebra above. numpy.linalg.solve's definition row, f(a, b) ~= solve(a, b) where det(a) != 0, is stated over a matrix and a vector together, which a proof through definition rows does not read, so a claim about a function calling it is decided by sampling. There A @ f(A, b) == b is unknown: each sampled solution meets b only within the computation's allowance, and numpy's result has no exact reading, so no draw is counted. The same identity in the grammar's own words, A @ solve(A, b) ~= b, is proven (see the vocabulary above).

import numpy as np

def gram(A: np.ndarray):
    return A @ A.T

def solve_for(A: np.ndarray, b: np.ndarray):
    return np.linalg.solve(A, b)
for A in R^(n,n), f(A).T == f(A)   # proven
for A in R^(n,n), f(A) == A   # falsified
for A in R^(n,n), b in [-1e6, 1e6]^n, assuming det(A) != 0, A @ f(A, b) == b   # unknown
for A in R^(n,n), b in [-1e6, 1e6]^n, assuming det(A) != 0, f(A, b) == b   # falsified

Runtime enforcement

@enforce_structure is the structure analogue of @enforce_domain and @enforce_dimensions: it reads a parameter's structure markers and any declared is_<prop>(param) claim, checks each matrix argument at call time, and raises ValueError naming the property and the failing argument before the function runs. It auto-declares the matching predicate claim, so the precondition and its runtime guard are one statement.

from typing import Annotated

from mathema import enforce_structure
from mathema.types import PositiveDefinite, Shape

@enforce_structure()
def cholesky(A: Annotated[list, Shape("n", "n"), PositiveDefinite]): ...

cholesky([[1, 2], [2, 1]])   # ValueError: cholesky: A is not is_positive_definite (positive definite)

A property the registry cannot decide (a spectral check with no numpy) is skipped, never a false rejection.

Rendering

The matrix vocabulary renders through sympy's matrix printing, produced on demand from the canonical claim rather than stored: A.T as A^{T}, det(A) as |A|, a product as juxtaposition, x.T @ A @ x as x^{T} A x, and the elementwise product A * B as A \circ B. Nothing per-claim is kept in the record; the canonical statement is the source, and grammar.to_latex renders it.

A matrix never reads as a commuting number in any rendering: a product over the names a claim's domain, a signature marker or a runtime type makes matrices keeps its written order, so A * B == B * A (true, since * is elementwise) is recorded as A*B = B*A and never as the tautology A*B = A*B, and C * (A @ B) keeps its parentheses.

Limits

The dependency numpy is optional (the test extra); element-wise and structural checks fall back to pure Python, spectral checks decline without it. Determinant and inverse sampling need numpy. Bars around any matrix expression are its determinant, so |A @ B| is the same claim as det(A @ B), and the record writes it with the det spelling. A matrix ordering (A > B) is undefined and refused as misspecified; only scalar comparisons (a determinant, a trace, a norm) are decided. A probe draw whose two sides differ by no more than the round-off its own magnitudes produce (entries near 1e6 and 1e-9 cancelling in a determinant) is not a counterexample, and the note says so; a loss of precision that matters is the float companion's claim. Numerical rank is numpy.linalg.matrix_rank with its default tolerance, so a rank identity the derive route cannot close can fail at a draw whose condition number a product squares past that tolerance; rank(A @ A.T) == rank(A) itself is proven, and the proof is the verdict.