Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Home

Linear algebra for embedded systems, microcontrollers, and real-time applications.

A hybrid no_std/alloc library. Stack-first by default. Scales to sparse matrices and Krylov subspace solvers when a heap is available.

API Reference · GitHub

Why rustebra exists

Rust currently lacks a linear algebra library that is simultaneously serious about no_std support and complete enough to cover sparse matrices and iterative solvers. Existing options either assume a heap is always available, or only provide a partial set of operations for constrained environments.

rustebra closes that gap.

Design principles

  • No allocator required by default — The core works entirely on the stack using const generics to fix sizes at compile time.
  • Allocation is opt-in — Heap-backed structures and algorithms are available behind the alloc feature flag.
  • Generic over numeric precision — Works across floating-point types, from microcontrollers without double-precision units to desktop systems.
  • Explicit error handling — Recoverable failures are reported through Result, not panics.

rustebra vs. the competition

Feature
rustebra
ndarray
nalgebra
no_std support
Yes
Full
Partial
Optional
Partial
Optional
Stack-only (no heap required)
Yes
Default
No
No
Yes
For fixed-size
Sparse matrices
Yes
v0.3.0+ (COO, CSR, CSC)
No
Separate crate
Partial
Limited
Krylov solvers
Yes
v0.4.0+ (power iteration)
Partial
Via ndarray-linalg
No
Not in core
3D math/graphics
No
Not focused
No
Not provided
Yes
Excellent
BLAS/LAPACK integration
No
No
Yes
Excellent
No
Pure Rust
Maturity
Early
v0.4.0
Yes
Mature
Yes
Mature
Embedded systems
Yes
Best choice
No
Poor fit
Partial
For fixed-size only

When to use rustebra

Use rustebra if:

  • You need linear algebra without dynamic allocation (embedded, real-time, microcontroller)
  • You’re working with sparse matrices in an embedded context
  • You want no_std + optional alloc (best of both worlds)
  • You need predictable stack-only memory

Use ndarray if:

  • You need production BLAS/LAPACK routines (scientific computing, data science)
  • You’re comfortable with heap allocation and want optimal performance
  • You need large matrices with sophisticated solvers
  • Building NumPy-like workflows in Rust

Use nalgebra if:

  • You need 3D graphics, robotics, or game engine math (Points, Isometries, Rotations)
  • You want optional no_std support with fixed-size matrices
  • Building low-level geometric transformations

Getting started

[dependencies]
rustebra = "0.4.0"

# Optional: heap-backed structures and Krylov solvers
rustebra = { version = "0.4.0", features = ["alloc"] }
# no_std build (default)
cargo build
cargo test

# with alloc feature
cargo build --features alloc
cargo test --features alloc

Explore the docs

Licensed under the Apache License 2.0.

Algorithms

Mathematical reference for every algorithm implemented in this project. Each document covers the theory, the method, and when to use it. Language-agnostic.

v0.2.0 — Matrix decompositions and structural properties

Cholesky Decomposition

What it computes

A factorization of a symmetric positive-definite matrix A into:

A = L · Lᵀ

where L is lower triangular with positive diagonal entries. It is the “square root” of a matrix in the sense that multiplying L by its own transpose recovers A.

Intuition

LU decomposition works for any invertible square matrix, but it doesn’t exploit symmetry. If you know A is symmetric and positive-definite, you can do roughly half the work: instead of finding two different triangular matrices L and U, you find one (L) and observe that U is simply Lᵀ.

The positive-definite condition (all eigenvalues positive) guarantees that the square root exists and that all diagonal entries of L are real and positive — which is what makes the algorithm work without pivoting.

What “symmetric positive-definite” means

  • Symmetric: Aᵢⱼ = Aⱼᵢ for all i, j (the matrix equals its own transpose).
  • Positive-definite: for every non-zero vector x, xᵀAx > 0. Intuitively, A doesn’t flip any vector to point in an opposite direction.

Common sources: covariance matrices in statistics, stiffness matrices in structural engineering, kernel matrices in machine learning.

Method

Compute L column by column. For column j:

Lⱼⱼ = √(Aⱼⱼ − Σ Lⱼₖ²)        (k from 1 to j−1)

Lᵢⱼ = (Aᵢⱼ − Σ LᵢₖLⱼₖ) / Lⱼⱼ  (k from 1 to j−1, for i > j)

If at any point the expression inside the square root is negative or zero, the matrix is not positive-definite and the decomposition does not exist.

Example:

A = | 4   2   2 |       L = | 2    0    0  |
    | 2   5   3 |           | 1    2    0  |
    | 2   3   6 |           | 1    1    √3 |

Verify: L · Lᵀ = A.

Computational cost

O(n³/3) — approximately half the cost of LU decomposition for the same matrix size, because the symmetry means only the lower triangle needs to be computed.

When to use it

  • Solving Ax = b when A is symmetric positive-definite — the most efficient general method in that case.
  • Sampling from multivariate normal distributions (used in statistics and Monte Carlo methods).
  • As a fast check for positive-definiteness: if Cholesky succeeds, the matrix is positive- definite; if it fails (negative under the square root), it is not.

Limitations

  • Only applicable to symmetric positive-definite matrices — will fail or produce incorrect results otherwise.
  • Requires a square root operation at each diagonal step.
  • Does not need pivoting (unlike LU), which simplifies the implementation but also means there is no fallback if the positive-definite condition is violated.

Cofactor Expansion

What it computes

The determinant of a square matrix.

Intuition

The determinant measures how much a matrix scales areas (in 2D) or volumes (in 3D) when applied as a linear transformation. A determinant of 2 means areas double; a determinant of 0 means the transformation collapses space into a lower dimension, losing information irreversibly.

Method

Pick any row or column. For each element in that row or column, multiply it by the determinant of the submatrix obtained by removing that element’s row and column (called the minor), then alternate signs following the checkerboard pattern:

+ - + - ...
- + - + ...
+ - + - ...

The sum of those signed products is the determinant.

Base cases:

For a 1×1 matrix:

det([a]) = a

For a 2×2 matrix (the formula most people remember):

det | a  b | = a·d − b·c
    | c  d |

General case — expanding along the first row:

det(A) = Σ (−1)^(1+j) · a₁ⱼ · det(M₁ⱼ)
          j

where M₁ⱼ is the minor obtained by deleting row 1 and column j.

3×3 example:

det | a  b  c |
    | d  e  f | = a·(e·i − f·h) − b·(d·i − f·g) + c·(d·h − e·g)
    | g  h  i |

Computational cost

O(n!) — grows factorially with matrix size. This is impractical for large matrices. For matrices larger than approximately 4×4, LU decomposition computes the same result far more efficiently.

When to use it

  • Small matrices (up to approximately 4×4) where the direct formula is exact and cheap.
  • Educational contexts where the explicit formula is needed.
  • As a building block for computing the inverse via the adjugate matrix.

Limitations

  • Impractical for large matrices due to O(n!) cost.
  • Accumulates floating-point rounding errors as matrix size grows.
  • Only defined for square matrices.

Condition Number

What it computes

A single non-negative number κ(A) that measures how sensitive the solution of a linear system Ax = b is to small changes in A or b.

κ(A) = σ₁ / σₙ

where σ₁ is the largest singular value of A and σₙ is the smallest.

Equivalently, for invertible matrices:

κ(A) = ‖A‖ · ‖A⁻¹‖

Intuition

Suppose you solve Ax = b and get a solution x. Now perturb b by a tiny amount δb. How much does the solution change?

The condition number bounds the answer:

‖δx‖ / ‖x‖  ≤  κ(A) · ‖δb‖ / ‖b‖

A condition number of 1 means the system is perfectly well-conditioned: a 1% error in b causes at most a 1% error in x. A condition number of 10⁶ means a tiny relative error in b can cause an error up to a million times larger in x — the system is nearly impossible to solve accurately with floating-point arithmetic.

Geometrically: a large condition number means the matrix A is “almost singular” — it maps vectors in some direction to nearly zero, making it nearly impossible to tell which input produced a given output.

Scale and interpretation

κ(A)Interpretation
1Perfect — no amplification of errors
10 – 100Well-conditioned — safe for most computations
10³ – 10⁶Moderately ill-conditioned — results may lose
several digits of precision
> 10⁸Severely ill-conditioned — floating-point results
may be essentially meaningless
Singular matrix — no solution or infinitely many

As a rule of thumb: if κ(A) ≈ 10^k, you lose approximately k digits of precision in the solution.

Method

The most reliable method is via SVD:

κ(A) = σ_max / σ_min

For symmetric positive-definite matrices, the eigenvalues equal the squared singular values, so:

κ(A) = λ_max / λ_min

A cheaper but less reliable estimate uses the LU decomposition — several condition number estimators (LAPACK-style) exist that avoid a full SVD while giving a good approximation.

When to use it

  • Before solving a linear system, to anticipate how accurate the solution can be.
  • When comparing different formulations of the same problem — a better-conditioned formulation gives more accurate results with the same arithmetic.
  • After computing a decomposition, as a sanity check on the result.
  • In iterative methods (Krylov), a large condition number is why preconditioning is needed: it transforms the problem into a better-conditioned one.

Relationship to other algorithms

  • Computed exactly via SVD (most reliable).
  • Estimated cheaply after LU decomposition (less reliable but faster).
  • Directly motivates preconditioning in Krylov methods.
  • A condition number of ∞ (or very large) is the formal definition of a matrix being singular (or nearly singular).

LU Decomposition

What it computes

A factorization of a square matrix A into two triangular matrices:

A = L · U

where L is lower triangular (zeros above the diagonal) and U is upper triangular (zeros below the diagonal).

Intuition

Solving a linear system Ax = b directly is expensive. But if A is already triangular, solving is trivial — you just substitute forward or backward. LU decomposition transforms the general problem into two easy triangular ones.

Once you have L and U, solving Ax = b becomes:

  1. Solve Ly = b for y (forward substitution — trivial because L is lower triangular).
  2. Solve Ux = y for x (back substitution — trivial because U is upper triangular).

The decomposition pays for itself when you need to solve multiple systems with the same A but different right-hand sides b — compute L and U once, reuse them many times.

Method

Gaussian elimination, recorded as a matrix factorization.

The same elimination steps used for row reduction can be captured as multiplications by elementary lower-triangular matrices. Collecting those elimination steps gives L; the result of the elimination is U.

At each step k, for each row i below row k:

multiplier mᵢₖ = aᵢₖ / aₖₖ
row i ← row i − mᵢₖ · row k

The multipliers mᵢₖ fill the lower triangle of L; the remaining matrix is U.

Example:

A = | 2  1  1 |       L = | 1    0   0 |    U = | 2  1   1  |
    | 4  3  3 |           | 2    1   0 |        | 0  1   1  |
    | 8  7  9 |           | 4    3   1 |        | 0  0   2  |

Verify: L · U = A.

Pivoting

If a diagonal entry (pivot) is zero or very small, the division aᵢₖ / aₖₖ becomes undefined or numerically unstable. Partial pivoting solves this by swapping rows before each step to put the largest available entry on the diagonal:

P · A = L · U

where P is a permutation matrix recording the row swaps. Partial pivoting is the standard in practice — LU without pivoting can fail even when the matrix is technically invertible.

Computing the determinant via LU

Once U is available, the determinant is the product of its diagonal entries, adjusted for the sign of the row permutations:

det(A) = (−1)^s · u₁₁ · u₂₂ · ... · uₙₙ

where s is the number of row swaps performed during pivoting. This is O(n²) — far cheaper than cofactor expansion.

Computational cost

O(n³) for the decomposition. O(n²) for each subsequent solve once L and U are known.

When to use it

  • Solving general square linear systems Ax = b, especially when solving multiple times with the same A.
  • Computing the determinant efficiently for matrices larger than approximately 4×4.
  • Computing the matrix inverse (though direct solve is usually preferable).
  • As a building block for other algorithms.

Limitations

  • Only directly applicable to square matrices.
  • Requires partial pivoting for numerical stability.
  • Not the best choice when A has known special structure (symmetric positive-definite → Cholesky; orthogonal → QR).

QR Decomposition

What it computes

A factorization of any m×n matrix A (with m ≥ n) into:

A = Q · R

where Q is orthogonal (its columns are unit vectors, all perpendicular to each other, and Qᵀ·Q = I) and R is upper triangular.

Intuition

An orthogonal matrix Q is a pure rotation or reflection — it doesn’t distort lengths or angles. R is triangular, so easy to work with. Together they let you decompose any linear transformation into “rotate/reflect first, then scale and shear”.

Because Qᵀ = Q⁻¹ (a property unique to orthogonal matrices), solving Ax = b via QR becomes:

QRx = b  →  Rx = Qᵀb

which is a triangular system — solved immediately by back substitution.

Methods

Gram-Schmidt orthogonalization

Take the columns of A one by one. For each new column, subtract its projections onto all previously processed columns to remove any component in their direction, then normalize to unit length. The normalized columns form Q; the projection coefficients fill R.

For columns a₁, a₂, …, aₙ:

u₁ = a₁
u₂ = a₂ − proj_{u₁}(a₂)
u₃ = a₃ − proj_{u₁}(a₃) − proj_{u₂}(a₃)
...

qᵢ = uᵢ / ‖uᵢ‖

where proj_u(a) = (a·u / u·u) · u

Simple to understand, but accumulates floating-point errors for nearly dependent columns. Modified Gram-Schmidt (projecting against already-orthogonalized columns rather than the originals) reduces this error significantly.

Householder reflections

Rather than building Q column by column, apply a sequence of reflection matrices Hₖ that zero out the entries below the diagonal one column at a time:

Hₙ · ... · H₂ · H₁ · A = R
Q = H₁ · H₂ · ... · Hₙ

Each Hₖ is a Householder reflector of the form:

H = I − 2·vvᵀ / (vᵀv)

chosen so that it maps a given vector onto a multiple of a coordinate axis. More numerically stable than Gram-Schmidt and preferred in practice.

Computational cost

O(mn²) for an m×n matrix — more expensive than LU for square systems, but applicable to non-square matrices and more numerically stable.

When to use it

  • Least-squares problems: solving overdetermined systems (more equations than unknowns), where no exact solution exists and the best approximate solution is sought.
  • Eigenvalue algorithms: QR iteration is the basis of the standard method for computing all eigenvalues of a matrix.
  • Arnoldi and Lanczos iterations: QR is used internally to orthogonalize the Krylov basis.
  • When numerical stability matters more than raw speed.

Limitations

  • More expensive than LU for square systems when stability is not a concern.
  • The full Q matrix is large; often only Qᵀb is needed, not Q itself.

Rank

What it computes

The rank of a matrix is the number of linearly independent rows (or equivalently, linearly independent columns). It tells you the effective dimensionality of the information the matrix carries.

Intuition

If a matrix has 4 rows but one of them is a linear combination of the others (e.g. it equals the sum of the first two rows), that row adds no new information. The rank counts only the rows that are genuinely independent — the rest are redundant.

A matrix of size m×n has rank at most min(m, n). When the rank equals min(m, n), the matrix is said to have full rank. When it is lower, the matrix is rank-deficient.

Method

Gaussian elimination to row echelon form.

Apply a sequence of elementary row operations to reduce the matrix:

  1. Find the leftmost column with a non-zero entry (the pivot column).
  2. Swap rows if needed to bring a non-zero entry to the top of that column.
  3. Subtract multiples of that row from all rows below it to zero out every entry below the pivot.
  4. Repeat for the submatrix below and to the right of the current pivot.

The result is a matrix in row echelon form: each row either starts with a leading non-zero entry (a pivot) further to the right than the row above it, or is entirely zero.

The rank is the number of non-zero rows in the result.

Example:

| 1  2  3 |       | 1  2  3 |
| 2  4  6 |  →    | 0  0  0 |   rank = 1
| 3  6  9 |       | 0  0  0 |

Every row was a multiple of the first; after elimination, only one non-zero row remains.

| 1  0  2 |       | 1  0  2 |
| 0  1  3 |  →    | 0  1  3 |   rank = 2
| 0  0  0 |       | 0  0  0 |

Two independent rows survive.

Computational cost

O(m·n·min(m,n)) — efficient for matrices of practical size.

When to use it

  • Checking whether a linear system has a unique solution, infinitely many, or none.
  • Detecting redundancy in a set of equations or vectors.
  • As a prerequisite for understanding SVD and the condition number.

Relationship to other algorithms

  • A square matrix has full rank if and only if its determinant is non-zero.
  • The rank is the number of non-zero singular values (see SVD).
  • A rank-deficient matrix has no inverse.

Singular Value Decomposition (SVD)

What it computes

A factorization of any m×n matrix A into three matrices:

A = U · Σ · Vᵀ

where:

  • U is an m×m orthogonal matrix (the left singular vectors).
  • Σ is an m×n diagonal matrix with non-negative entries σ₁ ≥ σ₂ ≥ … ≥ 0 on the diagonal (the singular values).
  • V is an n×n orthogonal matrix (the right singular vectors).

Intuition

Every linear transformation — no matter how complex — can be broken into three simple steps:

  1. Rotate/reflect the input space (Vᵀ).
  2. Scale each axis independently (Σ — the singular values are the scale factors).
  3. Rotate/reflect the output space (U).

The singular values tell you how much A stretches or compresses space in each “direction”. Large singular values correspond to directions where A amplifies; small ones where it barely affects the input; zero singular values correspond to directions that get completely collapsed (those are the nullspace of A).

Visually: SVD reveals the “principal axes” of the transformation.

Singular values and their meaning

  • The number of non-zero singular values equals the rank of A.
  • The largest singular value σ₁ is the maximum amount A can stretch any unit vector.
  • The smallest non-zero singular value σᵣ is the minimum stretch in the column space.
  • If any singular value is zero, the matrix is rank-deficient (same as having determinant 0 for square matrices).

Method

Computing SVD directly is a multi-step process:

  1. Form AᵀA (an n×n symmetric positive-semidefinite matrix).
  2. Compute its eigendecomposition: AᵀA = V · D · Vᵀ, where D is diagonal with the eigenvalues on the diagonal.
  3. Singular values: σᵢ = √λᵢ where λᵢ are the eigenvalues of AᵀA.
  4. Left singular vectors: uᵢ = A·vᵢ / σᵢ for each non-zero singular value.

In practice, the eigendecomposition in step 2 is computed via the QR algorithm (iterative QR decompositions applied repeatedly until the matrix converges to diagonal form), not by solving the characteristic polynomial directly.

Truncated SVD

Often only the k largest singular values are needed (k ≪ min(m,n)). Computing only those (truncated SVD) is far cheaper than the full decomposition and sufficient for most applications.

Computational cost

O(min(m,n) · m · n) — the most expensive decomposition in this library. For large matrices, iterative methods (Lanczos, Arnoldi) are used to approximate only the needed singular values.

When to use it

  • Least-squares problems: the most robust method, handling rank-deficient cases where QR would struggle.
  • Pseudoinverse: A⁺ = V · Σ⁺ · Uᵀ, where Σ⁺ inverts the non-zero diagonal entries.
  • Low-rank approximation: keep only the k largest singular values and vectors to get the best rank-k approximation of A (used in data compression, PCA, and dimensionality reduction).
  • Condition number: σ₁ / σₙ (see condition number document).
  • Rank determination: count non-zero singular values (more numerically reliable than Gaussian elimination for ill-conditioned matrices).

Limitations

  • The most computationally expensive decomposition in this library.
  • Forming AᵀA explicitly can amplify floating-point errors — bidiagonalization-based algorithms (Golub-Reinsch) avoid this but are more complex to implement.

Book

This book documents rustebra, a hybrid no_std/alloc linear algebra crate for Rust. It starts with the foundations — scalars and vectors — and builds up through matrices, sparse storage, decompositions, and Krylov subspace solvers.

Part I covers the foundations: the Scalar trait that algorithms in this crate are generic over, and the StaticVector/DynamicVector types built on top of it. Later parts assume familiarity with these building blocks.

Getting Started

rustebra is a hybrid no_std/alloc linear algebra crate for Rust, scaling from embedded targets to dynamic Krylov subspace solvers. The crate is no_std by default (#![cfg_attr(not(test), no_std)] in lib.rs), so it has no dependency on the standard library and no allocator requirement unless you opt in.

Adding the dependency

Add rustebra to Cargo.toml:

[dependencies]
rustebra = "0.3"

no_std vs. alloc

By default, only stack-allocated, compile-time-sized types are available — for example StaticVector<T, N>, whose length N is a const generic. This works on embedded targets with no heap.

Enabling the alloc feature pulls in extern crate alloc and unlocks heap-allocated, runtime-sized types such as DynamicVector<T>:

[dependencies]
rustebra = { version = "0.3", features = ["alloc"] }

A first example

The core building block is Scalar, a trait implemented for f32 and f64 that defines the arithmetic surface (add, sub, mul, div, sqrt, sin, cos) the rest of the crate is generic over. Vector types like StaticVector are built on top of it:

#![allow(unused)]
fn main() {
use rustebra::vector::StaticVector;

let a = StaticVector::new([1.0, 2.0, 3.0]);
let b = StaticVector::new([4.0, 5.0, 6.0]);

println!("a + b = {:?}", a.add(&b));
println!("||a|| = {:.4}", a.norm());
}

StaticVector::new infers N from the array literal’s length, so a and b here are both StaticVector<f64, 3>. See Vectors for the full construction and operations surface.

Scalars & Numeric Types

Every algorithm in rustebra is generic over a Scalar type rather than hard-coded to f32 or f64. Scalar (defined in rustebra::scalar) is the minimal arithmetic surface the rest of the crate depends on: the additive and multiplicative identities (zero, one), the four basic operations (add, sub, mul, div), and the transcendental functions needed by the algorithm layer (sqrt, sin, cos). f32 and f64 are the two implementors provided today.

#![allow(unused)]
fn main() {
use rustebra::scalar::Scalar;

fn double<T: Scalar>(x: T) -> T {
    x.add(x)
}
}

Note the call style for the transcendental methods: Scalar::sqrt(4.0f64) rather than 4.0f64.sqrt(). f64/f32 already have an inherent sqrt from std, which would shadow the trait method if called with dot syntax, so code that needs to stay generic over T: Scalar (or that wants the trait’s specific implementation) calls it as an associated function instead.

How sqrt, sin, and cos are computed

Scalar is implemented for f32/f64 without relying on std’s floating-point intrinsics for these three methods, since the crate is no_std by default:

  • sqrt uses a fixed-iteration Newton-Raphson (Babylonian) iteration: x_{n+1} = (x_n + self / x_n) / 2. self == 0 short-circuits to 0 rather than iterating (the formula would otherwise divide by zero on the first step), and negative inputs return 0 rather than NaN or a panic, since Scalar is an infallible trait that must also support future non-float implementors with no NaN representation.
  • sin and cos use a fixed-iteration Taylor series expansion around zero, after first reducing the input to [-pi, pi] by subtracting the nearest multiple of 2*pi.

In all three cases the iteration count is fixed rather than convergence-checked, so the amount of work done is predictable and independent of the input — important in no_std contexts where there’s no easy way to bound worst-case iteration count otherwise.

FloatTolerance

Comparing floating-point results for approximate equality requires a tolerance, and rustebra doesn’t hard-code one into Scalar itself. Instead, FloatTolerance is a separate trait (also implemented for f32/f64) that adds a single method, epsilon(), reporting the type’s machine epsilon — the smallest positive value e such that T::one().add(e) != T::one(). It’s kept separate from Scalar so that a hypothetical future Scalar implementor with no meaningful notion of machine epsilon (e.g. a fixed-point or exact-rational type) isn’t forced to implement it just to satisfy Scalar.

#![allow(unused)]
fn main() {
use rustebra::scalar::FloatTolerance;

assert_eq!(f64::epsilon(), f64::EPSILON);
}

Gotchas

  • Call the transcendental methods as Scalar::sqrt(x) / Scalar::sin(x) / Scalar::cos(x) when x’s type isn’t pinned to a concrete float — dot-call syntax (x.sqrt()) resolves to std’s inherent method on concrete f32/f64 values, not the trait method, and won’t compile at all in a generic T: Scalar context.
  • Scalar::sqrt of a negative number returns 0, not NaN — don’t rely on NaN propagation to detect a negative input; check the sign yourself if that matters to your code.

Vectors

rustebra provides two vector types: StaticVector<T, N>, stack-allocated with a compile-time length, and DynamicVector<T>, heap-allocated behind the alloc feature with a runtime length. This section covers how to construct them, the arithmetic and dot-product operations they support, and how to compute their norm.

Construction

rustebra vectors come in two flavors, distinguished by where their elements live and how their length is tracked.

StaticVector<T, N> is stack-allocated: N is a const generic, so the length is part of the type itself and is known at compile time. Two StaticVectors with different N are different types — the compiler rejects mismatched-length operations before your code ever runs. This is the default choice in a no_std context, since it requires no allocator.

DynamicVector<T> is heap-allocated (it wraps a Vec<T> internally) and its length is only known at runtime. It’s available behind the alloc feature. Because two DynamicVectors can’t be proven to have the same length at compile time, operations that combine two of them return a Result instead of a bare value.

Both types are constructed directly from their backing collection — StaticVector::new takes a fixed-size array, DynamicVector::new takes a Vec:

#![allow(unused)]
fn main() {
use rustebra::vector::StaticVector;

let v = StaticVector::new([1.0, 2.0, 3.0]);
}

Here N is inferred from the array literal’s length (3), so it rarely needs to be written out explicitly.

Gotchas

  • StaticVector<T, 3> and StaticVector<T, 4> are unrelated types. If you find yourself needing to add vectors whose lengths aren’t known until runtime, reach for DynamicVector instead — trying to force different Ns together is a compile error, not something you can work around with a cast.
  • DynamicVector needs the alloc feature enabled. Without it, rustebra::vector only exports StaticVector, and code referencing DynamicVector won’t compile.

Operations

StaticVector and DynamicVector support the same core set of operations: element-wise addition and subtraction, scalar scaling, the dot product, and the Euclidean norm. The example below constructs two StaticVectors and runs each of them in turn.

#![allow(unused)]
fn main() {
use rustebra::vector::StaticVector;

pub(crate) fn run() {
    println!("== StaticVector ==");
    let a = StaticVector::new([1.0, 2.0, 3.0]);
    let b = StaticVector::new([4.0, 5.0, 6.0]);

    println!("a = {a:?}");
    println!("b = {b:?}");
    println!("a + b = {:?}", a.add(&b));
    println!("a - b = {:?}", a.sub(&b));
    println!("a scaled by 2 = {:?}", a.scale(2.0));
    println!("a . b = {}", a.dot(&b));
    println!("||a|| = {:.4}", a.norm());
}
}

Gotchas

  • add, sub, and dot on StaticVector return a plain value, not a Result — the const generic N guarantees both operands have the same length at compile time, so there’s nothing to fail at runtime. The DynamicVector equivalents return Result<_, LengthMismatch> instead, since two DynamicVectors aren’t guaranteed to match in length.
  • scale takes the factor by value (T, not &T), matching Scalar’s Copy bound — pass the number directly rather than a reference.

Norms & Distances

Both StaticVector and DynamicVector expose a single norm() method: the Euclidean (L2) norm, ‖v‖ = sqrt(v . v), computed as the square root of the vector’s dot product with itself.

#![allow(unused)]
fn main() {
use rustebra::vector::StaticVector;

let v = StaticVector::new([3.0, 4.0]);
assert_eq!(v.norm(), 5.0);
}

There’s no separate distance method. The Euclidean distance between two vectors is just the norm of their difference, and falls out of the existing sub and norm operations:

#![allow(unused)]
fn main() {
use rustebra::vector::StaticVector;

let a = StaticVector::new([1.0, 2.0, 3.0]);
let b = StaticVector::new([4.0, 6.0, 3.0]);

let distance = a.sub(&b).norm();
assert_eq!(distance, 5.0);
}

For DynamicVector, sub returns a Result (two DynamicVectors aren’t guaranteed to match in length at compile time), so the equivalent distance computation is a.sub(&b)?.norm().

Gotchas

  • norm() itself never fails or returns a Result — even on DynamicVector — since computing the norm of a single vector has no dimension-mismatch case to report. It’s sub/add/dot between two DynamicVectors that can fail.
  • sqrt under the hood is Scalar::sqrt, a fixed-iteration approximation rather than a hardware intrinsic (see Scalars & Numeric Types) — norms of extremely large or small magnitude vectors may lose a little precision relative to f64::sqrt.

Matrices

rustebra provides two matrix types: StaticMatrix<T, R, C>, stack-allocated with compile-time-known shape, and DynamicMatrix<T>, heap-allocated behind the alloc feature with a runtime-known shape. This section covers how to construct them and the operations — arithmetic, matrix-vector and matrix-matrix products, transpose, rank, and the LU/QR/SVD decompositions — they support.

Construction

Like vectors, rustebra matrices come in a stack-allocated and a heap-allocated flavor.

StaticMatrix<T, R, C> stores its shape as const generics, so R and C are part of the type and known at compile time. It’s constructed from an array of rows — [[T; C]; R] — and requires no allocator:

#![allow(unused)]
fn main() {
use rustebra::matrix::StaticMatrix;

let m = StaticMatrix::new([[1.0, 2.0], [3.0, 4.0]]);
}

DynamicMatrix<T> is heap-allocated (available behind the alloc feature) and stores its shape — rows and cols — as runtime fields rather than type parameters. It’s constructed from a flat, row-major Vec<T> plus the explicit row and column counts, and returns a Result since the data’s length isn’t statically guaranteed to match rows * cols:

#![allow(unused)]
fn main() {
use rustebra::matrix::DynamicMatrix;

let m = DynamicMatrix::new(2, 2, vec![1.0, 2.0, 3.0, 4.0]).unwrap();
assert_eq!(m.rows(), 2);
assert_eq!(m.cols(), 2);
}

Gotchas

  • StaticMatrix<T, 2, 3> and StaticMatrix<T, 3, 2> are unrelated types, just like differently-sized StaticVectors — the compiler rejects shape-mismatched operations at compile time rather than at runtime.
  • DynamicMatrix::new returns Err(DimensionMismatch) rather than panicking if data.len() != rows * cols — check the result rather than assuming construction always succeeds.

Operations

StaticMatrix and DynamicMatrix support the same core set of operations: element-wise addition and subtraction, scalar scaling, matrix-vector and matrix-matrix products, transpose, rank, and the LU, QR, Cholesky, and SVD decompositions, plus condition number. The example below runs each of them on a StaticMatrix.

#![allow(unused)]
fn main() {
use rustebra::matrix::StaticMatrix;
use rustebra::vector::StaticVector;

pub(crate) fn run() {
    println!("== StaticMatrix ==");
    let a = StaticMatrix::new([[1.0, 2.0], [3.0, 4.0]]);
    let b = StaticMatrix::new([[5.0, 6.0], [7.0, 8.0]]);
    let v = StaticVector::new([1.0, 1.0]);

    println!("a = {a:?}");
    println!("b = {b:?}");
    println!("a + b = {:?}", a.add(&b));
    println!("a - b = {:?}", a.sub(&b));
    println!("a scaled by 2 = {:?}", a.mul_scalar(2.0));
    println!("a * v = {:?}", a.mul_vector(&v));
    println!("a * b = {:?}", a.mul_matrix(&b));
    println!("a^T = {:?}", a.transpose());
    println!("det(a) = {:?}", a.determinant());
    println!("rank(a) = {:?}", a.rank());

    let (l, u, swap_count) = a.lu();
    println!("lu(a) = (l = {l:?}, u = {u:?}, swap_count = {swap_count})");

    let (q, r) = a.qr().expect("a has at least as many rows as columns");
    println!("qr(a) = (q = {q:?}, r = {r:?})");

    let spd = StaticMatrix::new([[4.0, 2.0], [2.0, 2.0]]);
    println!("cholesky(spd) = {:?}", spd.cholesky());

    let mut svd_scratch = [0.0; 5 * 2 * 2 + 2 + 2];
    let (svd_u, sigma, v) = a.svd(&mut svd_scratch).expect("scratch is correctly sized");
    println!("svd(a) = (u = {svd_u:?}, sigma = {sigma:?}, v = {v:?})");

    let mut condition_scratch = [0.0; 7 * 2 * 2 + 3 * 2];
    println!(
        "condition_number(a) = {:?}",
        a.condition_number(&mut condition_scratch)
    );
}
}

Gotchas

  • On StaticMatrix, operations between two matrices (add, sub, mul_matrix, mul_vector) return a plain value, not a Result — the const generics guarantee compatible shapes at compile time. The DynamicMatrix equivalents return Result<_, DimensionMismatch> instead, since two DynamicMatrixs aren’t guaranteed to match in shape.
  • qr returns Result<_, DimensionMismatch> on both types, because R >= C isn’t something the type system can enforce even for StaticMatrix — stable Rust has no way to bound one const generic against another.
  • svd and condition_number on StaticMatrix take a caller-provided scratch buffer sized by an explicit formula (5 * C * C + C + R for svd, 7 * N * N + 3 * N for condition_number) — get the size wrong and you get Err(DimensionMismatch) back rather than a panic. DynamicMatrix’s svd/condition_number allocate their own scratch space internally and don’t take this parameter.
  • determinant on StaticMatrix<T, N, N> returns Err(DeterminantError::MatrixTooLargeWithoutAlloc) if the alloc feature is disabled and N > 4 — for larger matrices without alloc, use rustebra::algorithm::matrix::determinant_lu directly with your own scratch buffer.
  • cholesky requires self to be symmetric positive-definite; it returns Err(CholeskyError::NotPositiveDefinite) rather than a garbage result or a panic when that doesn’t hold.

Sparse Matrices

rustebra also supports sparse matrices — matrices where most entries are zero and only the non-zeros are stored. This section covers the storage formats (COO, CSR, CSC), how to construct and convert between them, and the operations (scaling, matrix-vector and matrix-matrix products, addition, pruning) available on them. Sparse support requires the alloc feature.

CSR/CSC

Compressed sparse row (CSR) and compressed sparse column (CSC) are the two primary sparse storage formats rustebra operates on. Both store only the non-zero entries, using three parallel arrays instead of a dense grid.

CSR (CsrMatrix<T>) stores:

  • row_ptr — length rows + 1; the range row_ptr[i]..row_ptr[i + 1] gives the slice of col_indices/values belonging to row i.
  • col_indices — the column index of each stored entry.
  • values — the value of each stored entry.

CSC (CscMatrix<T>) is the transpose layout: a col_ptr of length cols + 1 plays the role row_ptr plays for CSR, and row_indices plays the role col_indices plays.

#![allow(unused)]
fn main() {
use rustebra::sparse::CsrMatrix;

// 3x3 identity: row i's single entry is at column i, value 1.0.
let eye = CsrMatrix::new(3, 3, vec![0, 1, 2, 3], vec![0, 1, 2], vec![1.0_f64, 1.0, 1.0])
    .unwrap();
assert_eq!(eye.row_ptr(), &[0, 1, 2, 3]);
}

Within a row (CSR) or column (CSC), the stored indices don’t have to be sorted — but every index must be in-bounds, and the pointer array must be non-decreasing, starting at 0 and ending at the total non-zero count. CsrMatrix::new/CscMatrix::new validate these invariants and return Err rather than constructing a malformed matrix.

SortedCsrMatrix / SortedCscMatrix

SortedCsrMatrix<T> and SortedCscMatrix<T> wrap a CsrMatrix/CscMatrix and add the additional guarantee that indices within each row/column are in ascending order. This enables O(log(nnz/rows)) binary-search lookup of a specific entry, and is a precondition some algorithms (sparse triangular solves, certain preconditioners) require. Both types implement Deref to their unsorted counterpart, so every read-only accessor is available without unwrapping. Construct one directly with SortedCsrMatrix::from_csr/ SortedCscMatrix::from_csc (which sorts, paying the cost up front), or get one as the output of any operation that produces sorted results as a side effect of its own algorithm — coo_to_csr, csr_to_csc, csc_to_csr, spmm_csr, add_csr, and add_csc.

Gotchas

  • A CSR matrix with unsorted column indices is still a valid CsrMatrix — sortedness is an opt-in stronger guarantee via SortedCsrMatrix, not a base invariant of CsrMatrix itself.
  • Storing an explicit zero is legal in CsrMatrix/CscMatrix::new (it doesn’t reject zero-valued entries), but validate_csr-style validation used elsewhere treats an explicit zero as a violation — don’t assume every zero has been pruned just because construction succeeded.

Construction & Conversion

Sparse matrices can be built directly in CSR or CSC format, or assembled from unordered (row, col, value) triplets via CooMatrix and converted afterward. The three examples below construct a matrix in each of the three formats:

#![allow(unused)]
fn main() {
use rustebra::sparse::CooMatrix;

pub(crate) fn run() {
    println!("\n== CooMatrix ==");

    let eye = CooMatrix::new(3, 3, vec![0, 1, 2], vec![0, 1, 2], vec![1.0_f64, 1.0, 1.0])
        .expect("valid COO triplets");

    println!("3×3 identity:");
    println!(
        "  rows={}, cols={}, nnz={}",
        eye.rows(),
        eye.cols(),
        eye.nnz()
    );
    println!("  row_indices = {:?}", eye.row_indices());
    println!("  col_indices = {:?}", eye.col_indices());
    println!("  values      = {:?}", eye.values());

    // Duplicate (row, col) entries are legal — they are summed on conversion.
    let dup = CooMatrix::new(2, 2, vec![0, 0, 1], vec![0, 0, 1], vec![3.0_f64, 4.0, 5.0])
        .expect("valid COO with duplicate at (0,0)");
    println!(
        "with duplicate at (0,0): nnz={} (summed on coo_to_csr)",
        dup.nnz()
    );
}
}
#![allow(unused)]
fn main() {
use rustebra::sparse::CsrMatrix;

pub(crate) fn run() {
    println!("\n== CsrMatrix ==");

    // row_ptr has rows+1 entries; row_ptr[i]..row_ptr[i+1] spans row i.
    let eye = CsrMatrix::new(
        3,
        3,
        vec![0, 1, 2, 3],
        vec![0, 1, 2],
        vec![1.0_f64, 1.0, 1.0],
    )
    .expect("valid CSR arrays");

    println!("3×3 identity:");
    println!(
        "  rows={}, cols={}, nnz={}",
        eye.rows(),
        eye.cols(),
        eye.nnz()
    );
    println!("  row_ptr     = {:?}", eye.row_ptr());
    println!("  col_indices = {:?}", eye.col_indices());
    println!("  values      = {:?}", eye.values());
    println!("  row_range(1) = {:?}", eye.row_range(1));
}
}
#![allow(unused)]
fn main() {
use rustebra::sparse::CscMatrix;

pub(crate) fn run() {
    println!("\n== CscMatrix ==");

    // col_ptr has cols+1 entries; col_ptr[j]..col_ptr[j+1] spans column j.
    let eye = CscMatrix::new(
        3,
        3,
        vec![0, 1, 2, 3],
        vec![0, 1, 2],
        vec![1.0_f64, 1.0, 1.0],
    )
    .expect("valid CSC arrays");

    println!("3×3 identity:");
    println!(
        "  rows={}, cols={}, nnz={}",
        eye.rows(),
        eye.cols(),
        eye.nnz()
    );
    println!("  col_ptr     = {:?}", eye.col_ptr());
    println!("  row_indices = {:?}", eye.row_indices());
    println!("  values      = {:?}", eye.values());
    println!("  col_range(2) = {:?}", eye.col_range(2));
}
}

CooMatrix tolerates duplicate (row, col) triplets — they’re logically summed when the matrix is later used, e.g. by conversion to CSR. Converting between formats is a distinct step from construction; the example below covers all four conversion functions (coo_to_csr, csr_to_coo, csr_to_csc, csc_to_csr):

#![allow(unused)]
fn main() {
use rustebra::sparse::{
    CooMatrix, CscMatrix, CsrMatrix, SortedCsrMatrix, coo_to_csr, csc_to_csr, csr_to_coo,
    csr_to_csc,
};

pub(crate) fn run() {
    println!("\n== convert ==");

    // coo_to_csr: duplicate (row, col) entries are summed; columns sorted per row.
    let coo = CooMatrix::new(2, 2, vec![0, 0, 1], vec![0, 0, 1], vec![3.0_f64, 4.0, 5.0])
        .expect("valid COO");
    let csr: SortedCsrMatrix<f64> = coo_to_csr(coo).expect("dimensions fit within limits");
    println!("coo_to_csr (duplicate (0,0) summed: 3+4=7):");
    println!(
        "  row_ptr={:?}  col_indices={:?}  values={:?}",
        csr.row_ptr(),
        csr.col_indices(),
        csr.values()
    );

    // csr_to_coo: expands row_ptr into explicit row indices.
    let csr_eye = CsrMatrix::new(
        3,
        3,
        vec![0, 1, 2, 3],
        vec![0, 1, 2],
        vec![1.0_f64, 1.0, 1.0],
    )
    .expect("valid CSR");
    let coo_out = csr_to_coo(csr_eye).expect("dimensions fit within limits");
    println!(
        "\ncsr_to_coo (3×3 identity): row_indices={:?}",
        coo_out.row_indices()
    );

    // csr_to_csc: transposes storage layout, sorted by (col, row).
    let csr_rect = CsrMatrix::new(
        2,
        3,
        vec![0, 2, 4],
        vec![0, 2, 1, 2],
        vec![1.0_f64, 2.0, 3.0, 4.0],
    )
    .expect("valid 2×3 CSR");
    let csc_out = csr_to_csc(csr_rect).expect("dimensions fit within limits");
    println!("\ncsr_to_csc (2×3):");
    println!(
        "  col_ptr={:?}  row_indices={:?}  values={:?}",
        csc_out.col_ptr(),
        csc_out.row_indices(),
        csc_out.values()
    );

    // csc_to_csr: transposes back, sorted by (row, col).
    let csc_eye = CscMatrix::new(
        3,
        3,
        vec![0, 1, 2, 3],
        vec![0, 1, 2],
        vec![1.0_f64, 1.0, 1.0],
    )
    .expect("valid CSC");
    let csr_back = csc_to_csr(csc_eye).expect("dimensions fit within limits");
    println!(
        "\ncsc_to_csr (3×3 identity): row_ptr={:?}",
        csr_back.row_ptr()
    );
}
}

Gotchas

  • coo_to_csr sums duplicate (row, col) entries and sorts column indices within each row as a side effect, which is why it returns a SortedCsrMatrix rather than a plain CsrMatrix — you get the stronger guarantee “for free” from the conversion algorithm.
  • CsrMatrix::new/CscMatrix::new/CooMatrix::new all return Result rather than panicking on malformed input (mismatched array lengths, out-of-bounds indices, an invalid pointer array) — check the result rather than assuming construction always succeeds.

Sparse Operations

Sparse matrices support scaling, matrix-vector and matrix-matrix products against a dense buffer, sparse-sparse addition, sparse-sparse multiplication, and pruning near-zero entries. Each operation is a free function taking the sparse matrix by value or reference, rather than a method — and each is provided in both a CSR and a CSC variant.

Scaling every stored value by a constant:

#![allow(unused)]
fn main() {
use rustebra::sparse::{CscMatrix, CsrMatrix, scale_csc, scale_csr};

pub(crate) fn run() {
    println!("\n== scale ==");

    let csr =
        CsrMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![4.0_f64, 6.0]).expect("valid CSR");
    let scaled = scale_csr(csr, 0.5);
    println!("scale_csr by 0.5: values = {:?}", scaled.values());

    let csc =
        CscMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![4.0_f64, 6.0]).expect("valid CSC");
    let scaled_csc = scale_csc(csc, 0.5);
    println!("scale_csc by 0.5: values = {:?}", scaled_csc.values());
}
}

Multiplying a sparse matrix by a dense vector:

#![allow(unused)]
fn main() {
use rustebra::sparse::{CscMatrix, CsrMatrix, matvec_csc, matvec_csr};

pub(crate) fn run() {
    println!("\n== matvec ==");

    // [2  0]   [1]   [ 2]
    // [0  5] × [3] = [15]
    let m_csr =
        CsrMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![2.0_f64, 5.0]).expect("valid CSR");
    let y = matvec_csr(&m_csr, &[1.0, 3.0]).expect("dimensions match");
    println!("matvec_csr: [2 0; 0 5] × [1, 3] = {y:?}");

    let m_csc =
        CscMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![2.0_f64, 5.0]).expect("valid CSC");
    let y = matvec_csc(&m_csc, &[1.0, 3.0]).expect("dimensions match");
    println!("matvec_csc: [2 0; 0 5] × [1, 3] = {y:?}");
}
}

Multiplying a sparse matrix by a dense matrix:

#![allow(unused)]
fn main() {
use rustebra::sparse::{CscMatrix, CsrMatrix, matmat_csc, matmat_csr};

pub(crate) fn run() {
    println!("\n== matmat ==");

    // [2  0]   [1  0]   [2  0]
    // [0  3] × [0  4] = [0 12]   (x is row-major)
    let x = [1.0_f64, 0.0, 0.0, 4.0];

    let m_csr =
        CsrMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![2.0_f64, 3.0]).expect("valid CSR");
    let y = matmat_csr(&m_csr, &x, 2).expect("dimensions match");
    println!("matmat_csr: [2 0; 0 3] × [1 0; 0 4] = {y:?}");

    let m_csc =
        CscMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![2.0_f64, 3.0]).expect("valid CSC");
    let y = matmat_csc(&m_csc, &x, 2).expect("dimensions match");
    println!("matmat_csc: [2 0; 0 3] × [1 0; 0 4] = {y:?}");
}
}

Adding two sparse matrices of the same shape:

#![allow(unused)]
fn main() {
use rustebra::sparse::{CscMatrix, CsrMatrix, add_csc, add_csr};

pub(crate) fn run() {
    println!("\n== add ==");

    // [2  0]   [1  3]   [3  3]
    // [0  5] + [0  4] = [0  9]
    let a = CsrMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![2.0_f64, 5.0]).expect("valid CSR");
    let b = CsrMatrix::new(2, 2, vec![0, 2, 3], vec![0, 1, 1], vec![1.0_f64, 3.0, 4.0])
        .expect("valid CSR");
    let c = add_csr(&a, &b).expect("shapes match");
    println!(
        "add_csr: col_indices={:?}  values={:?}",
        c.col_indices(),
        c.values()
    );

    let a = CscMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![2.0_f64, 5.0]).expect("valid CSC");
    let b = CscMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![1.0_f64, 4.0]).expect("valid CSC");
    let c = add_csc(&a, &b).expect("shapes match");
    println!(
        "add_csc: row_indices={:?}  values={:?}",
        c.row_indices(),
        c.values()
    );
}
}

Sparse-sparse matrix multiplication (spmm_csr), which multiplies two CsrMatrix operands and returns a sorted result:

#![allow(unused)]
fn main() {
use rustebra::sparse::{CsrMatrix, spmm_csr};

pub(crate) fn run() {
    println!("\n== spmm ==");

    // [1  0]   [1  2]   [1  2]
    // [0  2] × [3  4] = [6  8]
    let a = CsrMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![1.0_f64, 2.0]).expect("valid CSR");
    let b = CsrMatrix::new(
        2,
        2,
        vec![0, 2, 4],
        vec![0, 1, 0, 1],
        vec![1.0_f64, 2.0, 3.0, 4.0],
    )
    .expect("valid CSR");
    let c = spmm_csr(&a, &b).expect("inner dimensions match");
    println!("spmm_csr: [1 0; 0 2] × [1 2; 3 4]:");
    println!(
        "  row_ptr={:?}  col_indices={:?}  values={:?}",
        c.row_ptr(),
        c.col_indices(),
        c.values()
    );
}
}

Pruning entries within a tolerance of zero:

#![allow(unused)]
fn main() {
use rustebra::sparse::{CsrMatrix, prune_csr};

pub(crate) fn run() {
    println!("\n== prune ==");

    // Entries within [-tolerance, tolerance] are dropped.
    let m = CsrMatrix::new(
        1,
        3,
        vec![0, 3],
        vec![0, 1, 2],
        vec![1e-15_f64, 2.0, -1e-15],
    )
    .expect("valid CSR");
    let pruned = prune_csr(m, 1e-10).expect("dimensions fit within limits");
    println!("prune_csr (tolerance=1e-10, two near-zeros removed):");
    println!("  nnz={}", pruned.nnz());
    println!(
        "  col_indices={:?}  values={:?}",
        pruned.col_indices(),
        pruned.values()
    );
}
}

Sparse matrices also implement SparseLinearOp, an abstraction for “apply this matrix to a dense vector, writing into a caller-supplied buffer” — the interface Krylov solvers are written against so they work with any sparse format:

#![allow(unused)]
fn main() {
use rustebra::sparse::{CsrMatrix, SparseLinearOp};

let eye = CsrMatrix::new(2, 2, vec![0, 1, 2], vec![0, 1], vec![1.0_f64, 1.0]).unwrap();
let mut y = [0.0; 2];
eye.apply(&[3.0, 5.0], &mut y).unwrap();
assert_eq!(y, [3.0, 5.0]);
}

Gotchas

  • Every operation here returns Result (e.g. DimensionMismatch for shape-incompatible operands) rather than panicking — sparse matrices carry their shape as runtime fields, so there’s no type-level guarantee two operands are compatible the way there is for StaticMatrix.
  • apply (from SparseLinearOp) writes into a caller-supplied output buffer instead of allocating a new one, so solver loops built on top of it can reuse the same buffer across iterations without allocating per call.
  • validate_csr/validate_csc (in rustebra::sparse) check the canonical format invariants — including that no stored value is an explicit zero — separately from construction. CsrMatrix::new/CscMatrix::new accept explicit zeros; only validate_csr/validate_csc flag them.

Decompositions

rustebra factors matrices via LU (Gaussian elimination with partial pivoting), QR (orthogonal-triangular factorization), Cholesky (for symmetric positive-definite matrices), and SVD (singular value decomposition). Each is available both as a low-level function in rustebra::algorithm::matrix operating on Storage, and as an ergonomic method on StaticMatrix/DynamicMatrix. This section covers LU, QR, and SVD in detail.

LU

LU decomposition factors a square matrix a as l * u, where l is unit lower triangular (1s on the diagonal) and u is upper triangular, up to a row permutation: l * u == p * a, where p is the permutation built by applying the reported row swaps, in order, to the identity.

rustebra computes it via Gaussian elimination with partial pivoting: at each step, the row with the largest-magnitude entry in the current column is swapped into the pivot position before elimination proceeds, which avoids dividing by a small or zero pivot. The low-level lu function delegates to lu_partial_pivot, which documents the pivoting strategy in more detail; both are available directly, alongside the ergonomic StaticMatrix/DynamicMatrix::lu() method used in Matrix Operations.

#![allow(unused)]
fn main() {
use rustebra::algorithm::matrix::{lu, lu_partial_pivot};
use rustebra::storage::StaticStorage;

pub(crate) fn run() {
    println!("\n== LU decomposition ==");
    // [[4, 3], [6, 3]].
    let a = StaticStorage::new([4.0, 3.0, 6.0, 3.0]);
    let mut l = [0.0; 4];
    let mut u = [0.0; 4];

    let swaps = lu(&a, 2, 2, &mut l, &mut u).unwrap();
    println!("l = {l:?}, u = {u:?} (row swaps: {swaps})");

    let mut l_explicit = [0.0; 4];
    let mut u_explicit = [0.0; 4];
    lu_partial_pivot(&a, 2, 2, &mut l_explicit, &mut u_explicit).unwrap();
    println!("l (explicit partial pivot) = {l_explicit:?}, u = {u_explicit:?}");
}
}

Gotchas

  • The permutation p isn’t returned as its own matrix — only the number of row swaps performed (swap_count) is reported. This is enough to recover the permutation’s parity (used by determinant), but not the row ordering itself; there’s no direct way to recover p as a matrix from the public API.
  • lu/lu_partial_pivot are only defined for square matrices — they return Err(DimensionMismatch) for a non-square input, rather than a rectangular LU variant.

QR

QR decomposition factors a rows x cols matrix a (with rows >= cols) as q * r, where q has orthonormal columns and r is upper triangular. rustebra provides two algorithms:

  • qr_householder — builds q as a full rows x rows orthogonal matrix via Householder reflections, zeroing out the sub-diagonal of each column in turn.
  • qr_gram_schmidt — modified Gram-Schmidt orthogonalization, producing a rows x cols q (only as many orthonormal columns as a has, not a full square orthogonal matrix). Projecting each column against the running, already-orthogonalized vector (rather than the original column) is what keeps rounding error from compounding as badly as classical Gram-Schmidt does.

qr is the general-purpose entry point and currently delegates to qr_householder. Both algorithms, plus the entry point, are demonstrated below, alongside the ergonomic StaticMatrix/DynamicMatrix::qr() method used in Matrix Operations.

#![allow(unused)]
fn main() {
use rustebra::algorithm::matrix::{qr, qr_gram_schmidt, qr_householder};
use rustebra::storage::StaticStorage;

pub(crate) fn run() {
    println!("\n== QR decomposition ==");
    // [[3, 5], [4, 0]].
    let a = StaticStorage::new([3.0, 5.0, 4.0, 0.0]);
    let mut scratch = [0.0; 2];

    let mut q = [0.0; 4];
    let mut r = [0.0; 4];
    qr(&a, 2, 2, &mut q, &mut r, &mut scratch).unwrap();
    println!("q = {q:?}, r = {r:?}");

    let mut q_householder = [0.0; 4];
    let mut r_householder = [0.0; 4];
    qr_householder(
        &a,
        2,
        2,
        &mut q_householder,
        &mut r_householder,
        &mut scratch,
    )
    .unwrap();
    println!("q (explicit householder) = {q_householder:?}, r = {r_householder:?}");

    let mut q_gram_schmidt = [0.0; 4];
    let mut r_gram_schmidt = [0.0; 4];
    qr_gram_schmidt(
        &a,
        2,
        2,
        &mut q_gram_schmidt,
        &mut r_gram_schmidt,
        &mut scratch,
    )
    .unwrap();
    println!("q (explicit gram-schmidt) = {q_gram_schmidt:?}, r = {r_gram_schmidt:?}");
}
}

Gotchas

  • qr_householder’s q is rows x rows; qr_gram_schmidt’s q is only rows x cols. Don’t assume both produce a same-shaped q if you swap between them.
  • If a column of a lies entirely in the span of the previous columns (or is zero), Gram- Schmidt has no direction left to normalize into that column of q — rather than erroring, it leaves that column as 0, since linear dependence is a property of the input, not a malformed call.
  • Both algorithms require rows >= cols; this can’t be checked by the type system on StaticMatrix (stable Rust can’t bound one const generic against another), so qr() returns Result on both matrix types even though most other StaticMatrix operations don’t need to.

SVD

Singular value decomposition factors any rows x cols matrix a as u * diag(sigma) * vᵗ, where u has orthonormal columns, sigma is a length-cols vector of non-negative singular values sorted in descending order, and v is a cols x cols orthogonal matrix. Unlike LU or Cholesky, a doesn’t need to be square — the SVD exists for any matrix.

rustebra computes it via svd_qr_iteration: it eigendecomposes aᵗ * a using a fixed-iteration QR algorithm (100 iterations, rather than convergence-checked, so behavior stays predictable in no_std contexts) to get v and sigma, then recovers u from a * v scaled by sigma. svd is the general-purpose entry point, delegating to svd_qr_iteration with an automatically-computed tolerance so callers don’t have to pick one. Both are shown below, alongside the ergonomic StaticMatrix/DynamicMatrix::svd() method used in Matrix Operations.

#![allow(unused)]
fn main() {
use rustebra::algorithm::matrix::{svd, svd_qr_iteration};
use rustebra::storage::StaticStorage;

pub(crate) fn run() {
    println!("\n== Singular Value Decomposition (SVD) ==");
    // [[2, 0], [0, 1]].
    let a = StaticStorage::new([2.0, 0.0, 0.0, 1.0]);
    let mut scratch = [0.0; 5 * 2 * 2 + 2 + 2];

    let mut u = [0.0; 4];
    let mut sigma = [0.0; 2];
    let mut v = [0.0; 4];
    svd(&a, 2, 2, &mut u, &mut sigma, &mut v, &mut scratch).unwrap();
    println!("sigma = {sigma:?}, u = {u:?}, v = {v:?}");

    let mut sigma_explicit = [0.0; 2];
    svd_qr_iteration(
        &a,
        2,
        2,
        &mut u,
        &mut sigma_explicit,
        &mut v,
        &mut scratch,
        1e-9,
    )
    .unwrap();
    println!("sigma (explicit QR iteration) = {sigma_explicit:?}");
}
}

Gotchas

  • svd/svd_qr_iteration need a caller-provided scratch buffer sized 5 * cols * cols + cols + rows — get the length wrong and you get Err(DimensionMismatch) back, not a panic. DynamicMatrix::svd() allocates this internally and doesn’t expose the parameter.
  • The QR iteration count inside svd_qr_iteration is fixed (100), not convergence-checked — for matrices with closely clustered singular values, more iterations might be needed for full precision than this budget provides.

Krylov Methods

rustebra provides iterative solvers and eigenvalue methods in rustebra::krylov:

  • Power iteration for the dominant (largest-magnitude) eigenvalue
  • Inverse power iteration for the eigenvalue nearest an arbitrary shift
  • Conjugate Gradient (CG) for solving symmetric positive-definite linear systems
  • Lanczos iteration, which builds an orthonormal basis of a Krylov subspace and the symmetric tridiagonal matrix a symmetric operator projects onto within it

Unlike the direct decompositions in Decompositions, these refine an estimate (or a basis) over many iterations and can fail to converge — or, for Lanczos, to extend the basis further — within a given budget, in addition to the usual dimension and non-finite-value failure modes.

Power Iteration

power_iteration computes the dominant (largest-magnitude) eigenvalue of an n x n matrix a, along with a corresponding unit-length eigenvector. Starting from an initial guess v0 (normalized first), each iteration multiplies the current estimate by a and renormalizes: v_{k+1} = a * v_k / ‖a * v_k‖. The eigenvalue estimate is the Rayleigh quotient λ_k = v_kᵗ * (a * v_k). Renormalizing every iteration is what keeps repeated multiplication from overflowing or underflowing — only the iterate’s direction evolves, its magnitude is reset to 1 each round.

#![allow(unused)]
fn main() {
use rustebra::krylov::power_iteration;
use rustebra::storage::StaticStorage;

// [[2, 0], [0, 1]]; dominant eigenvalue 2, eigenvector (1, 0).
let a = StaticStorage::new([2.0, 0.0, 0.0, 1.0]);
let v0 = StaticStorage::new([1.0, 1.0]);
let mut eigenvector = [0.0; 2];
let mut scratch = [0.0; 2];

let eigenvalue = power_iteration(&a, 2, &v0, 100, 1e-10, &mut eigenvector, &mut scratch)
    .unwrap();
assert!((eigenvalue - 2.0).abs() < 1e-9);
}

Iteration stops once both the eigenvalue estimate and the eigenpair residual (‖a * v_k - λ_k * v_k‖) fall within the requested tolerance, relative to the eigenvalue’s magnitude. Convergence speed depends on how well-separated the dominant eigenvalue is from the second-largest: the eigenvector error shrinks by roughly |λ2 / λ1| per iteration.

Inverse power iteration

Plain power iteration can only find the largest-magnitude eigenvalue. inverse_power_iteration targets the eigenvalue nearest an arbitrary shift instead, by applying the same direction-refinement loop to the operator (a - shift * I)⁻¹ — computed via one partial-pivoted LU factorization up front, then a triangular solve per iteration rather than a plain matrix-vector product. With shift == 0, this finds the smallest-magnitude eigenvalue. Convergence rate becomes |λ1 - shift| / |λ2 - shift| (nearest and second-nearest the shift), so a shift close to a good eigenvalue estimate converges very fast.

Gotchas

  • Power iteration doesn’t converge — and returns ConvergenceError::MaxIterationsExceeded — when the two largest-magnitude eigenvalues are exactly tied in magnitude but not value (e.g. λ2 == -λ1): the iterate oscillates forever rather than settling.
  • inverse_power_iteration returns ConvergenceError::SingularShift if shift coincides with (or lands within singular_tol of) an actual eigenvalue, rather than solving against amplified numerical noise. A shift merely near an eigenvalue but past that check is not a problem — it converges faster, not worse.
  • Both functions return ConvergenceError::ZeroVector if v0 has zero norm, and ConvergenceError::NonFinite if an iterate goes NaN/infinite — errors, not panics or silent garbage output, per the crate’s no-unwrap-in-library-code policy.

Conjugate Gradient (CG)

conjugate_gradient solves the symmetric positive-definite (SPD) linear system A x = b via iterative refinement: building a sequence of conjugate directions from residuals, then stepping along each direction to get closer to the solution. CG exploits the SPD structure for rapid convergence — in exact arithmetic, it converges to the true solution in at most n iterations for an n × n matrix, but in practice finite-precision arithmetic and early stopping via residual tolerance reach an approximate solution much faster.

Each iteration does one matrix-vector product (A * p_k) and two dot products, with no factorization and only 4 fixed-size working vectors (r, p, Ap, x). This makes CG ideal for large systems where factorization is too expensive.

#![allow(unused)]
fn main() {
use rustebra::krylov::conjugate_gradient;
use rustebra::storage::StaticStorage;

// Symmetric positive-definite 2x2 matrix: [[2, 1], [1, 2]].
// System: 2x + y = 1, x + 2y = 2. Solution: x = [0, 1].
let a = StaticStorage::new([2.0_f64, 1.0, 1.0, 2.0]);
let b = [1.0, 2.0];
let x0 = [0.0, 0.0];
let mut x = [0.0; 2];
let mut r = [0.0; 2];
let mut p = [0.0; 2];
let mut ap = [0.0; 2];

conjugate_gradient(&a, 2, &b, &x0, 100, 1e-10, &mut x, &mut r, &mut p, &mut ap)
    .unwrap();

// Solution satisfies A x = b
assert!((x[0] - 0.0).abs() < 1e-8);
assert!((x[1] - 1.0).abs() < 1e-8);
}

Convergence

The convergence rate depends on the condition number κ(A) = λ_max / λ_min of a: the iteration converges to within factor (√κ - 1) / (√κ + 1) per step in the energy norm ‖e‖_a = √(e^T A e). This means:

  • Well-conditioned matrices (κ ≈ 1): Fast convergence, often within a handful of iterations.
  • Ill-conditioned matrices (κ >> 1): Slow convergence, but still graceful degradation.

For example, a matrix with κ = 100 converges at rate roughly (10 - 1) / (10 + 1) ≈ 0.82 per iteration, so even pathological cases eventually reach a solution with patience.

SPD Detection

A genuinely SPD matrix has all positive eigenvalues. CG detects a non-SPD input operationally: if the dot product p_k · (A p_k) ever becomes zero, negative, or NaN mid-iteration, the algorithm returns ConvergenceError::NonFinite. This happens automatically for:

  • Negative-definite or indefinite matrices
  • Singular or numerically singular matrices
  • NaN/Inf from overflow or ill-conditioning
#![allow(unused)]
fn main() {
use rustebra::krylov::{conjugate_gradient, ConvergenceError};
use rustebra::storage::StaticStorage;

// Indefinite matrix: [[1, 0], [0, -1]] has eigenvalues 1 and -1.
let a = StaticStorage::new([1.0_f64, 0.0, 0.0, -1.0]);
let b = [1.0, 1.0];
let x0 = [0.0, 0.0];
let mut x = [0.0; 2];
let mut r = [0.0; 2];
let mut p = [0.0; 2];
let mut ap = [0.0; 2];

let result = conjugate_gradient(&a, 2, &b, &x0, 100, 1e-10, &mut x, &mut r, &mut p, &mut ap);
assert_eq!(result, Err(ConvergenceError::NonFinite));
}

Gotchas

  • Initial guess matters: A good initial guess x0 can reduce iterations significantly. If you have no prior estimate, x0 = [0, ..., 0] is standard.
  • Tolerance choice: Requesting tol = 1e-15 on f64 (machine epsilon ~2.2e-16) can lead to MaxIterationsExceeded. Use tol relative to the scale of b.
  • Matrix must be SPD: Non-symmetric or indefinite matrices cause immediate failure. If unsure, use a general-purpose solver like QR or SVD instead.
  • Nonfinite iterates: If A x_k contains NaN or Inf, CG returns ConvergenceError::NonFinite on the next residual check — not a silent failure or panic, per the crate’s policy.

Example: Least-Squares via Normal Equations

While CG solves A x = b directly, you can solve overdetermined least-squares min ‖A x - b‖ via the normal equations: solve A^T A x = A^T b. The system is symmetric and positive-definite (assuming full column rank), so CG applies:

#![allow(unused)]
fn main() {
use rustebra::krylov::conjugate_gradient;
use rustebra::storage::StaticStorage;

// Overdetermined system: 3 equations, 2 unknowns.
// A = [[1, 1], [1, 2], [1, 3]], b = [2, 3, 5]
// A^T A = [[3, 6], [6, 14]] (symmetric, positive-definite)
// A^T b = [10, 24]
let ata = StaticStorage::new([3.0_f64, 6.0, 6.0, 14.0]);
let atb = [10.0, 24.0];
let x0 = [0.0, 0.0];
let mut x = [0.0; 2];
let mut r = [0.0; 2];
let mut p = [0.0; 2];
let mut ap = [0.0; 2];

conjugate_gradient(&ata, 2, &atb, &x0, 100, 1e-10, &mut x, &mut r, &mut p, &mut ap)
    .unwrap();

// Verify solution approximately
assert!(x[0] > 0.5 && x[0] < 1.5);
assert!(x[1] > 1.0 && x[1] < 2.0);
}

Note: Normal equations can amplify rounding error for ill-conditioned matrices (κ(A^T A) ≈ κ(A)²). For numerical robustness, use QR decomposition or SVD instead.

Lanczos Iteration

lanczos tridiagonalizes a symmetric n x n matrix a over a K-dimensional Krylov subspace: starting from a normalized v0, it builds an orthonormal basis Q of span{v0, a*v0, ..., a^{K-1}*v0} and returns the projection T = Qᵗ * a * Q, a symmetric K x K tridiagonal matrix. With K == n and no breakdown, T is orthogonally similar to a and therefore has exactly its spectrum; with K < n, T’s eigenvalues (the Ritz values) approximate the extreme eigenvalues of a, generally converging well before K reaches n. Unlike power_iteration and inverse_power_iteration, which each refine a single eigenpair, Lanczos builds a whole subspace at once — the basis for later extracting several eigenvalues, or for feeding other Krylov solvers (CG, MINRES) that need the same tridiagonal projection.

Each step computes w = a * q_j, records the diagonal entry α_j = q_jᵗ * w, subtracts off the components along q_j and the previous basis vector q_{j-1} (the three-term recurrence), and normalizes what remains into q_{j+1}, recording its length as the off-diagonal entry β_j. In floating point, rounding error erodes the basis’s orthogonality as the Ritz values converge, so every step also re-orthogonalizes w against every basis vector built so far (full reorthogonalization) rather than relying on the three-term recurrence alone.

#![allow(unused)]
fn main() {
use rustebra::krylov::lanczos;
use rustebra::storage::{Basis, StaticStorage};

pub(crate) fn run() {
    println!("\n== Lanczos iteration ==");
    // [[4, 1, 2], [1, 3, 1], [2, 1, 5]], symmetric but not already tridiagonal.
    let a = StaticStorage::new([4.0, 1.0, 2.0, 1.0, 3.0, 1.0, 2.0, 1.0, 5.0]);
    let v0 = StaticStorage::new([1.0, 1.0, 1.0]);
    let mut buffer = [0.0; 9];
    let mut basis = Basis::<f64, 3>::new(&mut buffer, 3).unwrap();
    let mut scratch = [0.0; 3];

    let t = lanczos(&a, 3, &v0, 1e-12, &mut basis, &mut scratch).unwrap();
    println!("diagonal = {:?}", t.diagonal());
    println!("off_diagonal = {:?}", t.off_diagonal());

    // Requesting fewer basis vectors than the matrix dimension (K < n) still produces the
    // leading block of the same tridiagonal form, at a fraction of the memory: only `K`
    // vectors of the basis are ever stored.
    let mut partial_buffer = [0.0; 6];
    let mut partial_basis = Basis::<f64, 2>::new(&mut partial_buffer, 3).unwrap();
    let mut partial_scratch = [0.0; 3];
    let partial_t = lanczos(&a, 3, &v0, 1e-12, &mut partial_basis, &mut partial_scratch).unwrap();
    println!("partial diagonal (K = 2) = {:?}", partial_t.diagonal());
}
}

Breakdown

When the candidate for the next basis vector has (numerically) zero norm relative to ‖a * q_j‖, the Krylov subspace is invariant: there’s no new direction to extend the basis with, and the call fails with ConvergenceError::Breakdown rather than dividing by a vanishing norm. This is not a numerical failure to work around — it’s a structural property of the pairing of a and v0. A repeated eigenvalue can contribute at most one basis vector to the Krylov subspace no matter how large K is (the identity matrix breaks down immediately for any v0, since a * v0 never points anywhere new), and a v0 that happens to lie in a proper invariant subspace of a breaks down as soon as that subspace is exhausted, even with a fully distinct spectrum. The remedy is different from a plain convergence failure: retry with a smaller K, or a different v0.

Gotchas

  • a is assumed symmetric, never verified. For a non-symmetric input the projection isn’t actually tridiagonal, and T silently misrepresents it — Lanczos has no equivalent of the dimension or non-finite checks for this assumption, since checking symmetry itself would cost as much as the decomposition it’s protecting.
  • tol has no auto-computed default, the same as power_iteration and inverse_power_iteration — see Krylov Tolerance and Convergence Criteria. A tol of 0 detects only exact breakdown.
  • The basis size K is a const generic on the caller’s Basis buffer, not a runtime parameter — see Krylov Basis-Size Const-Generic Convention. K > n is a DimensionMismatch: an n-dimensional space has no K orthonormal directions to find.
  • Both ConvergenceError::ZeroVector and ConvergenceError::NonFinite on v0 are checked up front, even when K == 0 means no basis vector is ever written — a K == 0 call is not a shortcut around input validation, only around the iteration itself.

Eigenvalues & Eigenvectors

An eigenvector of a square matrix a is a non-zero vector v whose direction a leaves unchanged: a * v = λ * v for some scalar λ, its corresponding eigenvalue. Geometrically, a only stretches or shrinks v (by a factor of λ) rather than rotating it into a different direction. Eigenvalues show up throughout numerical linear algebra: a matrix’s condition number (see Condition Number) is the ratio of its largest to smallest singular value — themselves the square roots of the eigenvalues of aᵗ * a — and stability analysis of iterative systems generally comes down to whether relevant eigenvalues stay inside or outside the unit circle.

rustebra doesn’t compute eigenvalues via a direct, closed-form decomposition (the way it computes LU or QR). Instead, Krylov Methods provides two iterative methods: power_iteration, which converges to the dominant (largest-magnitude) eigenvalue and a corresponding eigenvector, and inverse_power_iteration, which converges to whichever eigenvalue lies nearest a caller-chosen shift — useful for targeting the smallest eigenvalue, or any eigenvalue you already have a rough estimate for. See that section for the algorithms, their convergence behavior, and worked examples.

Gotchas

  • These are iterative approximations, not exact decompositions — they return an error (MaxIterationsExceeded) rather than a result if convergence criteria aren’t met within the given iteration budget, and convergence speed depends on how well-separated the target eigenvalue is from its neighbors. See Power Iteration for the specifics.

Numerical Stability

This section documents two aspects of how rustebra behaves numerically: its policy on NaN/Inf propagation, and the precision trade-offs behind its fixed-iteration approximations for sqrt, sin, and cos.

NaN/Inf Policy

Outside of rustebra::krylov, the crate does not check for NaN or infinite values — it lets IEEE-754 arithmetic propagate them the way plain f32/f64 operations naturally do. An operation that receives or produces a non-finite value doesn’t return an error; the non-finite value just flows through.

#![allow(unused)]
fn main() {
let x = 0.0_f64 / 0.0;
assert!(x.is_nan());
// rustebra's arithmetic (add/sub/mul/div, dot products, norms, ...) doesn't intercept
// this — a NaN input produces a NaN output, the same as raw f64 arithmetic would.
}

Krylov methods (power_iteration, inverse_power_iteration) are the deliberate exception: they loop, so a poisoned iterate doesn’t just produce one bad result — it burns the entire remaining iteration budget computing on garbage. On embedded targets that budget is bounded and can’t be spent elsewhere, so these functions detect a non-finite iterate and stop early with ConvergenceError::NonFinite, rather than paying for the full iteration count on a chain of NaN arithmetic.

Gotchas

  • Don’t rely on a NaN result from most rustebra operations to signal an error condition the way Result does elsewhere in the crate — only the Krylov methods actively check for and report non-finite values. Everywhere else, a NaN/Inf input or intermediate result is expected to propagate silently, same as raw floating-point arithmetic.
  • Scalar::sqrt of a negative number is a deliberate exception to this exception: it returns 0, not NaN — see Scalars & Numeric Types.

Precision Guarantees

rustebra implements sqrt, sin, and cos itself rather than depending on std’s floating-point intrinsics, since the crate is no_std by default. Each uses a fixed-iteration approximation rather than a convergence-checked loop:

  • sqrt uses Newton-Raphson (Babylonian) iteration, x_{n+1} = (x_n + value / x_n) / 2, run for a fixed 50 iterations.
  • sin/cos use a Taylor series expansion around zero (after range-reducing the input to [-pi, pi]), run for a fixed 20 iterations.

In both cases the iteration count is fixed instead of stopping once successive iterates stop changing, so the amount of work done is predictable and independent of the input — important in no_std contexts, where there’s no easy way to bound worst-case iteration count for a convergence-checked loop otherwise. The trade-off is that precision isn’t uniform across the input domain: iteration counts were chosen generously enough to converge for the normal range of inputs these functions expect (vector norms and angles typical of linear algebra work), but may lose precision at the extreme ends of a type’s exponent range, where more iterations would be needed to leave the initial guess’s slow-convergence region.

#![allow(unused)]
fn main() {
use rustebra::scalar::Scalar;

let result = Scalar::sqrt(2.0_f64);
assert!((result - core::f64::consts::SQRT_2).abs() < 1e-9);
}

Comparing two floating-point results for approximate equality — accounting for the rounding error these iterative approximations (and floating-point arithmetic generally) introduce — is what FloatTolerance::epsilon() is for; see Scalars & Numeric Types.

Gotchas

  • These functions don’t document a specific worst-case error bound (e.g. “accurate to N ULPs”) — the guarantee is the mechanism (a fixed, generously-sized iteration budget tuned for typical inputs), not a numeric precision figure. Don’t assume bit-for-bit parity with std’s f64::sqrt/sin/cos for values at the edges of the valid range.

no_std & Embedded

rustebra is no_std by default: src/lib.rs declares #![cfg_attr(not(test), no_std)], so the crate has no dependency on the standard library (outside test builds) and no allocator requirement unless you opt in.

Without any feature flags, only stack-allocated, compile-time-sized types are available: StaticVector<T, N> and StaticMatrix<T, R, C>, whose lengths/shapes are const generics. These work on embedded targets with no heap at all.

Enabling the alloc feature pulls in extern crate alloc and unlocks heap-allocated, runtime-sized types: DynamicVector<T>, DynamicMatrix<T>, and every sparse matrix type in rustebra::sparse (CooMatrix, CsrMatrix, CscMatrix, and their sorted variants), all of which are gated behind #[cfg(feature = "alloc")] and simply don’t exist in the crate’s public API without it.

# no_std, no allocator: only StaticVector/StaticMatrix are available.
[dependencies]
rustebra = "0.3"

# adds DynamicVector/DynamicMatrix and the sparse module.
[dependencies]
rustebra = { version = "0.3", features = ["alloc"] }

Gotchas

  • Code that references DynamicVector, DynamicMatrix, or anything in rustebra::sparse without the alloc feature enabled won’t compile — these items aren’t hidden behind a runtime check, they’re absent from the crate entirely.
  • StaticMatrix::determinant on matrices larger than 4x4 returns Err(DeterminantError::MatrixTooLargeWithoutAlloc) when the alloc feature is disabled — cofactor expansion beyond that size needs heap-allocated scratch space that isn’t available in a pure no_std build.

Performance

The main performance-relevant design choice in rustebra is the split between Static* and Dynamic* types (see no_std & Embedded). StaticVector<T, N> and StaticMatrix<T, R, C> are stack-allocated with their size fixed at compile time — no heap allocation, no indirection through a Vec, and their length/shape checks (where the type system can rule out a mismatch) are eliminated entirely rather than deferred to runtime. DynamicVector<T> and DynamicMatrix<T> trade that for runtime-flexible sizing, at the cost of a heap allocation per construction and a Result-returning runtime shape check on every operation between two of them.

For algorithms with a caller-provided scratch buffer (svd, condition_number, qr on StaticMatrix), the caller controls whether that buffer lives on the stack or the heap — sizing it as a stack array keeps the whole computation allocation-free.

Beyond this structural choice, rustebra doesn’t publish benchmark numbers or make specific throughput/complexity claims — the underlying algorithms (Gaussian elimination for LU, Householder/Gram-Schmidt for QR, QR iteration for SVD) use standard, textbook time complexity for their respective operations.

Cookbook

This section collects short, task-oriented recipes for common jobs — the kind of thing you’d otherwise piece together from the reference chapters above. There are no recipes here yet; check back as the cookbook grows.