diff --git a/.cargo/config.toml b/.cargo/config.toml
index 2d84caa..7d0f7b5 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -33,7 +33,7 @@ rustflags = [
]
[alias]
-fit = "run -p multicalc --example curve_fit"
+fit = "run -p multicalc-demos --example curve_fit"
smoke-eabi = "run -p embedded-smoke --release --target thumbv7em-none-eabi"
smoke-eabihf = "run -p embedded-smoke --release --target thumbv7em-none-eabihf"
smoke-m0 = "run -p embedded-smoke --release --no-default-features --target thumbv6m-none-eabi"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7c8a936..894e678 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -122,8 +122,10 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- - name: Build examples
- run: cargo build -p multicalc --examples
+ - name: Build headless demos
+ run: cargo build -p multicalc-demos --examples --no-default-features
+ - name: Run basics
+ run: bash scripts/run_examples.sh
- name: Build benches
run: cargo build -p multicalc --benches
# runs a clean `cargo package` build — the same packaging the Step 15
diff --git a/.github/workflows/full.yml b/.github/workflows/full.yml
index 9e0ed6f..0eb03ad 100644
--- a/.github/workflows/full.yml
+++ b/.github/workflows/full.yml
@@ -149,8 +149,10 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- - name: Build examples
- run: cargo build -p multicalc --examples
+ - name: Build headless demos
+ run: cargo build -p multicalc-demos --examples --no-default-features
+ - name: Run basics
+ run: bash scripts/run_examples.sh
- name: Build benches
run: cargo build -p multicalc --benches
# runs a clean `cargo package` build — the same packaging the Step 15
@@ -197,8 +199,8 @@ jobs:
lcov.info
target/llvm-cov/html
- viz:
- name: viz adapter (host-only, non-PR)
+ demos:
+ name: demos crate (host-only, non-PR)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -206,9 +208,15 @@ jobs:
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- - name: Build (lib + examples)
- run: cargo build -p multicalc-viz --examples
- - name: Clippy
- run: cargo clippy -p multicalc-viz --all-targets -- -D warnings
+ - name: Build (lib + examples, default features)
+ run: cargo build -p multicalc-demos --examples
+ - name: Check headless (no default features)
+ run: cargo check -p multicalc-demos --no-default-features
+ - name: Clippy (default features)
+ run: cargo clippy -p multicalc-demos --all-targets -- -D warnings
+ - name: Clippy (headless)
+ run: cargo clippy -p multicalc-demos --all-targets --no-default-features -- -D warnings
+ - name: No Rerun in the headless tree
+ run: "! cargo tree -p multicalc-demos --no-default-features -i rerun"
- name: Headless record smoke test
- run: cargo test -p multicalc-viz
+ run: cargo test -p multicalc-demos
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c8473a0..a61d862 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -42,6 +42,25 @@ locally anyway, the setup lives in [ci/README.md](ci/README.md) (optional).
- **Docs**: public APIs get a doc example; behavior notes (NaN policy, iteration
budgets) live on the item.
+## Where does a check go?
+
+The workspace has several test and demo layers. Each has one job; adding a check means picking
+the layer that matches and not duplicating another's.
+
+| Layer | Purpose | Must not |
+|---|---|---|
+| doctests | one minimal runnable demo per public item | become the correctness suite |
+| `src/**/test.rs` inline | white-box tests of `pub(crate)` internals only (LU/lmpar) | test public API |
+| `tests/suite/` | **the** correctness suite: public API, edge cases, proptests | re-declare problems/helpers inline |
+| `demos/examples/basics/` | copy-pasteable, headless, terminating demos; multicalc-only imports | exit 0 without ≥1 sanity `assert!`; touch a sink |
+| `demos/examples/showcase/` | live Rerun demos; measured numbers only | panic on edge cases (errors render as demo states); hardcode a perf claim |
+| `benches/` | timing; `.md` tables are labeled illustrative snapshots | present tables as verified claims |
+| `tools/oracle` | cross-implementation goldens (numpy/mpmath/MINPACK) only | duplicate self-consistency tests |
+| `tools/embedded-smoke` | on-target FP-path + stack/text budgets; goldens only via generated `fixtures.rs` | hand-write golden values |
+
+Shared problem definitions and tolerance helpers live in `tools/testkit`, so a problem is
+declared once and reused across `tests/suite/`, the oracle, and embedded-smoke.
+
## Releasing
Releases are automated from `main`:
diff --git a/Cargo.toml b/Cargo.toml
index 42d4d18..d5145d3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = ["crates/multicalc", "tools/embedded-smoke", "tools/oracle", "showcase/viz"]
+members = ["crates/multicalc", "tools/embedded-smoke", "tools/oracle", "tools/testkit", "demos"]
default-members = ["crates/multicalc"]
resolver = "3"
diff --git a/README.md b/README.md
index 4d9a379..e85f80e 100644
--- a/README.md
+++ b/README.md
@@ -11,11 +11,11 @@ derivatives, integrals, curve fitting and linear algebra; built and tested on fi
hardware targets. Exercise the same code from a 64-bit server CPU down to a bare-metal microcontroller.**
-
-
+
+
-*Two of four live [showcase demos](showcase/viz#showcases): a 1 kHz 3-link arm running a complete
+*Two of four live [showcase demos](demos#live-showcases): a 1 kHz 3-link arm running a complete
Levenberg-Marquardt solve every millisecond, and a Newton fractal at ~4 million solves/sec on one
core — every number measured live.*
@@ -102,9 +102,9 @@ below.
- **[Full guide](crates/multicalc/README.md)**: Every feature with a runnable snippet, plus
notes on `no_std`, error handling, and heap allocation.
- **[API docs](https://docs.rs/multicalc)** on docs.rs.
-- **[Examples](crates/multicalc/examples)**: Self-contained programs for each module. Run one
- with `cargo run --example `.
-- **[Live showcases](showcase/viz#showcases)**: Four animated Rerun demos — a 1 kHz IK on a 3-link arm, a
+- **[Examples](demos#start-here)**: Self-contained, self-checking programs for each module in the
+ `demos/` crate. Run one with `cargo run -p multicalc-demos --example `.
+- **[Live showcases](demos#live-showcases)**: Four animated Rerun demos — a 1 kHz IK on a 3-link arm, a
Newton fractal, Fourier epicycles drawing Ferris, and gradient-driven marbles — each streaming
live-measured speed and accuracy.
- **[Benchmarks](crates/multicalc/benches)**: Accuracy figures and measured latency.
@@ -112,9 +112,9 @@ below.
## Repository layout
The published library crate lives in [`crates/multicalc`](crates/multicalc); the repository
-root is a Cargo workspace. A second, dev-only crate,
-[`tools/embedded-smoke`](tools/embedded-smoke), runs `multicalc` on the three bare-metal
-Cortex-M targets under QEMU every PR.
+root is a Cargo workspace. Runnable demos live in the dev-only [`demos/`](demos) crate (basics and
+live Rerun showcases), and [`tools/embedded-smoke`](tools/embedded-smoke) runs `multicalc` on the
+three bare-metal Cortex-M targets under QEMU every PR.
## Contributing
diff --git a/crates/multicalc/Cargo.toml b/crates/multicalc/Cargo.toml
index 74f01e4..48c99de 100644
--- a/crates/multicalc/Cargo.toml
+++ b/crates/multicalc/Cargo.toml
@@ -20,6 +20,7 @@ libm = "0.2"
rand = "0.8"
criterion = "0.5"
proptest = "1.11"
+multicalc-testkit = { path = "../../tools/testkit" }
[features]
default = []
diff --git a/crates/multicalc/README.md b/crates/multicalc/README.md
index 5c08f9e..bf36f50 100644
--- a/crates/multicalc/README.md
+++ b/crates/multicalc/README.md
@@ -33,11 +33,11 @@ Jacobians and Hessians, vector-field operators, and Taylor approximation in a `n
- A runnable example for every module, and a test suite covering each error path.
-
-
+
+
-*Two of four live [showcase demos](showcase/viz#showcases): a 1 kHz 3-link arm running a complete
+*Two of four live [showcase demos](../../demos#live-showcases): a 1 kHz 3-link arm running a complete
Levenberg-Marquardt solve every millisecond, and a Newton fractal at ~4 million solves/sec on one
core — every number measured live.*
@@ -373,11 +373,11 @@ returns a `Vec>` of the scalar (`Vec>` by default).
## Examples
-Runnable, self-contained programs for each module live in [`examples/`](./examples). See
-[examples/README.md](./examples/README.md). Run one with:
+Runnable, self-contained programs for each module live in the [`demos/`](../../demos) crate. See
+[demos/README.md](../../demos/README.md). Run one with:
```sh
-cargo run --example
+cargo run -p multicalc-demos --example
```
## Benchmarks
diff --git a/crates/multicalc/benches/README.md b/crates/multicalc/benches/README.md
index f0b50e3..67505fe 100644
--- a/crates/multicalc/benches/README.md
+++ b/crates/multicalc/benches/README.md
@@ -14,8 +14,9 @@ figures (wall-clock time per call).
Accuracy vs latency: every suite doc reports latency (wall-clock time per call); all five also
report accuracy (how close the result lands to the known value, or the drift of a conserved
-quantity for the ODE systems). The examples in [`examples/`](../examples) reproduce those accuracy
-tables, so the published figures stay honest.
+quantity for the ODE systems). These docs are illustrative snapshots, not verified claims:
+verified accuracy lives in the golden fixtures under [`tools/oracle`](../../../tools/oracle), and
+runnable, self-checking demos live in [`demos/`](../../../demos).
## Running
diff --git a/crates/multicalc/benches/linear_algebra.md b/crates/multicalc/benches/linear_algebra.md
index b68a96f..10ad739 100644
--- a/crates/multicalc/benches/linear_algebra.md
+++ b/crates/multicalc/benches/linear_algebra.md
@@ -7,10 +7,10 @@ report wall-clock medians on the machine noted in [README.md](README.md).
## Accuracy
-Measured by the [`linear_algebra`](../examples/linear_algebra.rs) and [`svd`](../examples/svd.rs)
-stress-test examples on well- and ill-conditioned inputs. Unlike latency, these are deterministic
-numerical errors and reproduce on any machine. Reconstruction is the entrywise error of the
-factorization; the solve residual is $$\lVert Ax - b\rVert$$ for a known solution.
+Measured by the [`linear_algebra`](../../../demos) and [`svd`](../../../demos) stress-test demos
+on well- and ill-conditioned inputs. Unlike latency, these are deterministic numerical errors and
+reproduce on any machine. Reconstruction is the entrywise error of the factorization; the solve
+residual is $$\lVert Ax - b\rVert$$ for a known solution.
### LU and Cholesky (decompose + solve)
diff --git a/crates/multicalc/benches/ode.md b/crates/multicalc/benches/ode.md
index b02fc81..f04bb0d 100644
--- a/crates/multicalc/benches/ode.md
+++ b/crates/multicalc/benches/ode.md
@@ -17,8 +17,8 @@ reports wall-clock medians on the machine noted in [README.md](README.md).
## Accuracy
None of these systems has a closed-form solution, so accuracy is the drift of a conserved
-quantity: `max |Q(t) − Q(0)|` over the trajectory (relative, for the N-body energy). The figures
-below are reproduced by [`examples/ode.rs`](../examples/ode.rs) (`cargo run --example ode`).
+quantity: `max |Q(t) − Q(0)|` over the trajectory (relative, for the N-body energy). The same
+systems run in the [`ode`](../../../demos) demo (`cargo run -p multicalc-demos --example ode`).
| System | Invariant | RK4 drift | RK45 drift | Notes |
| ------------------------------ | --------------------- | --------- | ---------- | ----------------------------------------------------------- |
diff --git a/crates/multicalc/examples/README.md b/crates/multicalc/examples/README.md
deleted file mode 100644
index f398f04..0000000
--- a/crates/multicalc/examples/README.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Examples
-
-Runnable, self-contained examples for each module of `multicalc`. Every file has a `main`
-that prints its results against the known analytic value (with the `|err|`), so you can see
-the API in action and check accuracy at the same time:
-
-```sh
-cargo run --example
-```
-
-Several examples also reproduce the published accuracy figures in [`benches/`](../benches)
-(noted below), so the tables stay honest.
-
-| Example | Module(s) | What it shows |
-| --- | --- | --- |
-| [`differentiation`](differentiation.rs) | `numerical_derivative` | Single- and multi-variable finite-difference derivatives (orders 1-3, partials, mixed partials). Reproduces the differentiation accuracy tables in benches/calculus.md. |
-| [`autodiff_scalars`](autodiff_scalars.rs) | `scalar` | Use `Dual` and `HyperDual` directly: evaluate a generic `Numeric` function and read f, f′, f″ from the result fields (no derivator). |
-| [`jacobian_hessian`](jacobian_hessian.rs) | `numerical_derivative::{jacobian, hessian}` | Jacobian of a vector of functions and the Hessian of a scalar function. |
-| [`iterative_integration`](iterative_integration.rs) | `numerical_integration::iterative_integration` | Boole / Simpson / Trapezoidal rules, multi-variable partial integrals, and infinite / semi-infinite limits. Reproduces the iterative-integration accuracy table in benches/calculus.md. |
-| [`gaussian_integration`](gaussian_integration.rs) | `numerical_integration::gaussian_integration` | Gauss-Legendre (finite), Gauss-Hermite and Gauss-Laguerre (infinite), with the bare-integrand convention. Reproduces the Gaussian-quadrature accuracy table in benches/calculus.md. |
-| [`vector_field`](vector_field.rs) | `vector_field` | Curl, divergence, line integrals and flux integrals. |
-| [`approximation`](approximation.rs) | `approximation` | Linear and quadratic Taylor approximations, `predict`, and goodness-of-fit metrics. |
-| [`linear_algebra`](linear_algebra.rs) | `linear_algebra` | LU and Cholesky factorizations, linear solves, and the direct 4x4 inverse under a latency + approximation-error stress test on well- and ill-conditioned inputs. Reproduces the LU / Cholesky / inverse accuracy tables in benches/linear_algebra.md. |
-| [`svd`](svd.rs) | `linear_algebra::svd` | Singular value decomposition and Moore-Penrose pseudo-inverse under a robotics stress test (Kabsch rotation recovery, a redundant-arm pseudo-inverse, a near-singular Jacobian, and an overdetermined fit) with latency + approximation error. Reproduces the SVD / pseudo-inverse accuracy table in benches/linear_algebra.md. |
-| [`root_finding`](root_finding.rs) | `root_finding` | Bracketed bisection, Newton with exact derivatives, damped (backtracking) Newton rescuing a far start, and a square-system Newton solve, each printed against its known root. |
-| [`curve_fit`](curve_fit.rs) | `optimization` | Levenberg-Marquardt fit of `y = a·e^(b·t)` to sensor samples with exact autodiff Jacobians; prints recovered `a`, `b`, and `\|err\|`. |
-| [`optimization_solvers`](optimization_solvers.rs) | `optimization` | Gauss-Newton on a well-conditioned linear residual (`y = a + b·t`); when GN is enough vs LM (`curve_fit`). |
-| [`lie_groups`](lie_groups.rs) | `spatial` | SO(3)/SE(3) compose, act on a point, exp/log round trips, geodesic interpolation, and a one-`Dual` autodiff derivative pushed through `exp` ∘ `act`. |
-| [`discretization`](discretization.rs) | `discretization`, `linear_algebra::expm` | ZOH on a double integrator, Van Loan process-noise discretization, the filterpy `q_discrete_white_noise` model, and a one-`Dual` derivative pushed through the matrix exponential. |
diff --git a/crates/multicalc/tests/linear_algebra.rs b/crates/multicalc/tests/linear_algebra.rs
deleted file mode 100644
index 7fe8009..0000000
--- a/crates/multicalc/tests/linear_algebra.rs
+++ /dev/null
@@ -1,23 +0,0 @@
-//! Linear algebra integration tests, split by topic. Shared helpers live in `helpers`; the
-//! topic modules are kept in the `linear_algebra/` subdirectory so they form one test binary
-//! rather than one per file.
-
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-
-#[path = "linear_algebra/helpers.rs"]
-mod helpers;
-
-#[path = "linear_algebra/cholesky.rs"]
-mod cholesky;
-#[path = "linear_algebra/lu.rs"]
-mod lu;
-#[path = "linear_algebra/macros.rs"]
-mod macros;
-#[path = "linear_algebra/matrix.rs"]
-mod matrix;
-#[path = "linear_algebra/qr.rs"]
-mod qr;
-#[path = "linear_algebra/svd.rs"]
-mod svd;
-#[path = "linear_algebra/vector.rs"]
-mod vector;
diff --git a/crates/multicalc/tests/linear_algebra/helpers.rs b/crates/multicalc/tests/linear_algebra/helpers.rs
deleted file mode 100644
index 7100c4a..0000000
--- a/crates/multicalc/tests/linear_algebra/helpers.rs
+++ /dev/null
@@ -1,130 +0,0 @@
-//! Shared helpers for the linear algebra test suite.
-
-use multicalc::linear_algebra::Matrix;
-use multicalc::scalar::Numeric;
-
-/// Asserts two matrices agree entrywise within `tol`.
-pub(crate) fn assert_close(
- actual: Matrix,
- expected: Matrix,
- tol: T,
-) {
- for r in 0..R {
- for c in 0..C {
- assert!((actual[(r, c)] - expected[(r, c)]).abs() < tol);
- }
- }
-}
-
-/// Asserts every entry of `m` is within `tol` of the identity matrix.
-pub(crate) fn assert_identity(m: Matrix, tol: T) {
- assert_close(m, Matrix::identity(), tol);
-}
-
-/// Factorizes `a`, checks the factors are triangular, and that they reconstruct `P·A`.
-pub(crate) fn lu_reconstructs(a: Matrix, tol: T) {
- let f = a.lu().unwrap();
- let l = f.l();
- let u = f.u();
- let perm = f.permutation();
-
- // L is unit lower-triangular; U is upper-triangular.
- for r in 0..N {
- assert_eq!(l[(r, r)], T::ONE);
- for c in (r + 1)..N {
- assert_eq!(l[(r, c)], T::ZERO);
- }
- for c in 0..r {
- assert_eq!(u[(r, c)], T::ZERO);
- }
- }
-
- let pa = Matrix::::from_fn(|i, c| a[(perm[i], c)]);
- assert_close(l * u, pa, tol);
-}
-
-/// Checks the Cholesky factor is lower-triangular with a positive diagonal and reconstructs `A`.
-pub(crate) fn cholesky_reconstructs(a: Matrix, tol: T) {
- let l = a.cholesky().unwrap().l();
- for r in 0..N {
- assert!(l[(r, r)] > T::ZERO);
- for c in (r + 1)..N {
- assert_eq!(l[(r, c)], T::ZERO);
- }
- }
- assert_close(l * l.transpose(), a, tol);
-}
-
-/// Checks the singular values are ordered and that `U·diag(σ)·Vᵀ` reconstructs `A`.
-pub(crate) fn svd_reconstructs(
- a: Matrix,
- tol: T,
-) {
- let f = a.svd().unwrap();
- let (u, s, v) = (f.u(), f.singular_values(), f.v());
-
- for k in 0..N {
- assert!(s[k] >= T::ZERO);
- if k + 1 < N {
- assert!(s[k] >= s[k + 1]);
- }
- }
-
- assert_identity(u.transpose() * u, tol);
- assert_identity(v.transpose() * v, tol);
-
- let recon = Matrix::::from_fn(|r, c| {
- let mut acc = T::ZERO;
- for k in 0..N {
- acc += u[(r, k)] * s[k] * v[(c, k)];
- }
- acc
- });
- assert_close(recon, a, tol);
-}
-
-/// Verifies the four Moore–Penrose conditions for the pseudo-inverse of `a`.
-pub(crate) fn svd_moore_penrose(
- a: Matrix,
- tol: T,
-) {
- let ap = a.pseudo_inverse().unwrap();
- assert_close(a * ap * a, a, tol);
- assert_close(ap * a * ap, ap, tol);
- let aap = a * ap;
- assert_close(aap, aap.transpose(), tol);
- let apa = ap * a;
- assert_close(apa, apa.transpose(), tol);
-}
-
-fn max_abs(m: Matrix) -> f32 {
- let mut max = 0.0_f32;
- for r in 0..R {
- for c in 0..C {
- max = max.max(m[(r, c)].abs());
- }
- }
- max
-}
-
-fn f32_scaled_tol(scale: f32, dim: usize) -> f32 {
- 512.0 * f32::EPSILON * dim as f32 * scale.max(1.0)
-}
-
-/// Verifies the four Moore-Penrose conditions for an f32 pseudo-inverse with
-/// tolerances scaled by matrix magnitude and dimension.
-pub(crate) fn svd_moore_penrose_f32(a: Matrix) {
- let ap = a.pseudo_inverse().unwrap();
-
- let aap_a = a * ap * a;
- assert_close(aap_a, a, f32_scaled_tol(max_abs(a), M.max(N)));
-
- let apa_ap = ap * a * ap;
- assert_close(apa_ap, ap, f32_scaled_tol(max_abs(ap), M.max(N)));
-
- let aap = a * ap;
- assert_close(aap, aap.transpose(), f32_scaled_tol(max_abs(aap), M));
-
- let apa = ap * a;
- assert_close(apa, apa.transpose(), f32_scaled_tol(max_abs(apa), N));
-}
diff --git a/crates/multicalc/tests/ode.rs b/crates/multicalc/tests/ode.rs
deleted file mode 100644
index 4abb24a..0000000
--- a/crates/multicalc/tests/ode.rs
+++ /dev/null
@@ -1,7 +0,0 @@
-//! ODE integrator integration tests.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-
-#[path = "ode/rk4.rs"]
-mod rk4;
-#[path = "ode/rk45.rs"]
-mod rk45;
diff --git a/crates/multicalc/tests/spatial.rs b/crates/multicalc/tests/spatial.rs
deleted file mode 100644
index a9cc7e5..0000000
--- a/crates/multicalc/tests/spatial.rs
+++ /dev/null
@@ -1,12 +0,0 @@
-//! Spatial-math integration tests.
-
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-
-#[path = "spatial/quaternion.rs"]
-mod quaternion;
-
-#[path = "spatial/lie.rs"]
-mod lie;
-
-#[path = "spatial/twist_wrench.rs"]
-mod twist_wrench;
diff --git a/crates/multicalc/tests/approximation.rs b/crates/multicalc/tests/suite/approximation.rs
similarity index 100%
rename from crates/multicalc/tests/approximation.rs
rename to crates/multicalc/tests/suite/approximation.rs
diff --git a/crates/multicalc/tests/discretization.rs b/crates/multicalc/tests/suite/discretization.rs
similarity index 100%
rename from crates/multicalc/tests/discretization.rs
rename to crates/multicalc/tests/suite/discretization.rs
diff --git a/crates/multicalc/tests/gaussian_tables.rs b/crates/multicalc/tests/suite/gaussian_tables.rs
similarity index 100%
rename from crates/multicalc/tests/gaussian_tables.rs
rename to crates/multicalc/tests/suite/gaussian_tables.rs
diff --git a/crates/multicalc/tests/linear_algebra/cholesky.rs b/crates/multicalc/tests/suite/linear_algebra/cholesky.rs
similarity index 93%
rename from crates/multicalc/tests/linear_algebra/cholesky.rs
rename to crates/multicalc/tests/suite/linear_algebra/cholesky.rs
index efcbbfd..100688a 100644
--- a/crates/multicalc/tests/linear_algebra/cholesky.rs
+++ b/crates/multicalc/tests/suite/linear_algebra/cholesky.rs
@@ -1,6 +1,6 @@
-use crate::helpers::{assert_close, assert_identity, cholesky_reconstructs};
use multicalc::error::LinalgError;
use multicalc::linear_algebra::{Matrix, Vector};
+use multicalc_testkit::tol::{assert_identity, assert_matrix_close, cholesky_reconstructs};
#[test]
fn cholesky_reconstructs_spd() {
@@ -13,7 +13,7 @@ fn cholesky_reconstructs_spd() {
[-16.0, -43.0, 98.0],
]);
cholesky_reconstructs(a, 1e-12);
- assert_close(
+ assert_matrix_close(
a.cholesky().unwrap().l(),
Matrix::new([[2.0, 0.0, 0.0], [6.0, 1.0, 0.0], [-8.0, 5.0, 3.0]]),
1e-12,
@@ -84,7 +84,7 @@ fn cholesky_solves() {
let f = s.cholesky().unwrap();
let rhs = Matrix::<2, 3>::new([[8.0, 6.0, 4.0], [8.0, 5.0, 3.0]]);
let xm = f.solve_matrix(rhs);
- assert_close(s * xm, rhs, 1e-12);
+ assert_matrix_close(s * xm, rhs, 1e-12);
for c in 0..3 {
let single = f.solve(rhs.column(c));
for r in 0..2 {
@@ -113,5 +113,5 @@ fn cholesky_inverse_matches_lu() {
let inv = a.cholesky().unwrap().inverse();
assert_identity(inv * a, 1e-12);
assert_identity(a * inv, 1e-12);
- assert_close(inv, a.lu().unwrap().inverse(), 1e-12);
+ assert_matrix_close(inv, a.lu().unwrap().inverse(), 1e-12);
}
diff --git a/crates/multicalc/tests/linear_algebra/lu.rs b/crates/multicalc/tests/suite/linear_algebra/lu.rs
similarity index 96%
rename from crates/multicalc/tests/linear_algebra/lu.rs
rename to crates/multicalc/tests/suite/linear_algebra/lu.rs
index 15aec19..f3c9855 100644
--- a/crates/multicalc/tests/linear_algebra/lu.rs
+++ b/crates/multicalc/tests/suite/linear_algebra/lu.rs
@@ -1,6 +1,6 @@
-use crate::helpers::{assert_close, assert_identity, lu_reconstructs};
use multicalc::error::LinalgError;
use multicalc::linear_algebra::{Matrix, Vector};
+use multicalc_testkit::tol::{assert_identity, assert_matrix_close, lu_reconstructs};
// ----- LU decomposition (Doolittle, partial pivoting) -----
@@ -70,7 +70,7 @@ fn lu_solves() {
// Multiple RHS: A·X == B, and each column agrees with a single-RHS solve.
let rhs = Matrix::<3, 2>::new([[7.0, 4.0], [19.0, 10.0], [49.0, 26.0]]);
let xm = f.solve_matrix(rhs);
- assert_close(a * xm, rhs, 1e-12);
+ assert_matrix_close(a * xm, rhs, 1e-12);
for c in 0..2 {
let single = f.solve(rhs.column(c));
for r in 0..3 {
@@ -93,7 +93,7 @@ fn lu_inverse_matches_reference_5x5() {
assert!((a.lu().unwrap().determinant() - 10406.0).abs() < 1e-9);
let inv = a.lu().unwrap().inverse();
- assert_close(
+ assert_matrix_close(
inv,
Matrix::new([
[
diff --git a/crates/multicalc/tests/linear_algebra/macros.rs b/crates/multicalc/tests/suite/linear_algebra/macros.rs
similarity index 100%
rename from crates/multicalc/tests/linear_algebra/macros.rs
rename to crates/multicalc/tests/suite/linear_algebra/macros.rs
diff --git a/crates/multicalc/tests/linear_algebra/matrix.rs b/crates/multicalc/tests/suite/linear_algebra/matrix.rs
similarity index 98%
rename from crates/multicalc/tests/linear_algebra/matrix.rs
rename to crates/multicalc/tests/suite/linear_algebra/matrix.rs
index 01e0f5d..6e200c4 100644
--- a/crates/multicalc/tests/linear_algebra/matrix.rs
+++ b/crates/multicalc/tests/suite/linear_algebra/matrix.rs
@@ -1,6 +1,6 @@
-use crate::helpers::{assert_close, assert_identity};
use multicalc::error::LinalgError;
use multicalc::linear_algebra::{Matrix, Vector};
+use multicalc_testkit::tol::{assert_identity, assert_matrix_close};
// ----- matrix arithmetic, multiply, transpose -----
@@ -113,7 +113,7 @@ fn matrix_4x4_determinant_and_inverse() {
assert_eq!(a.determinant(), 20.0);
let inv = a.inverse().unwrap();
- assert_close(
+ assert_matrix_close(
inv,
Matrix::new([
[0.6, -0.5, 0.0, 0.1],
@@ -136,7 +136,7 @@ fn matrix_4x4_determinant_and_inverse() {
assert_eq!(b.determinant(), -20.0);
let b_inv = b.inverse().unwrap();
- assert_close(
+ assert_matrix_close(
b_inv,
Matrix::new([
[-0.15, 0.45, -0.05, 0.25],
diff --git a/crates/multicalc/tests/suite/linear_algebra/mod.rs b/crates/multicalc/tests/suite/linear_algebra/mod.rs
new file mode 100644
index 0000000..d785780
--- /dev/null
+++ b/crates/multicalc/tests/suite/linear_algebra/mod.rs
@@ -0,0 +1,7 @@
+mod cholesky;
+mod lu;
+mod macros;
+mod matrix;
+mod qr;
+mod svd;
+mod vector;
diff --git a/crates/multicalc/tests/linear_algebra/qr.rs b/crates/multicalc/tests/suite/linear_algebra/qr.rs
similarity index 98%
rename from crates/multicalc/tests/linear_algebra/qr.rs
rename to crates/multicalc/tests/suite/linear_algebra/qr.rs
index ceab57e..cac0678 100644
--- a/crates/multicalc/tests/linear_algebra/qr.rs
+++ b/crates/multicalc/tests/suite/linear_algebra/qr.rs
@@ -1,6 +1,6 @@
-use crate::helpers::{assert_close, assert_identity};
use multicalc::error::LinalgError;
use multicalc::linear_algebra::{Matrix, PivotedQr, Vector};
+use multicalc_testkit::tol::{assert_identity, assert_matrix_close};
// ----- column-pivoted QR (decompose, accessors, solve) -----
@@ -177,7 +177,7 @@ fn qr_factorizes_hilbert_stably() {
// The factorization stays backward-stable regardless of conditioning.
assert_identity(q.transpose() * q, 1e-12);
let ap = Matrix::<8, 8>::from_fn(|i, c| hilbert[(i, perm[c])]);
- assert_close(q * r, ap, 1e-12);
+ assert_matrix_close(q * r, ap, 1e-12);
// Solving is backward-stable (tiny residual) though the solution itself degrades.
let x_true = [1.0; 8];
diff --git a/crates/multicalc/tests/linear_algebra/svd.rs b/crates/multicalc/tests/suite/linear_algebra/svd.rs
similarity index 97%
rename from crates/multicalc/tests/linear_algebra/svd.rs
rename to crates/multicalc/tests/suite/linear_algebra/svd.rs
index 81c7c9e..a0fd36e 100644
--- a/crates/multicalc/tests/linear_algebra/svd.rs
+++ b/crates/multicalc/tests/suite/linear_algebra/svd.rs
@@ -1,8 +1,9 @@
-use crate::helpers::{
- assert_close, assert_identity, svd_moore_penrose, svd_moore_penrose_f32, svd_reconstructs,
-};
use multicalc::error::LinalgError;
use multicalc::linear_algebra::{Matrix, Vector};
+use multicalc_testkit::tol::{
+ assert_identity, assert_matrix_close, svd_moore_penrose, svd_moore_penrose_f32,
+ svd_reconstructs,
+};
#[test]
fn svd_reconstructs_various() {
@@ -215,7 +216,7 @@ fn svd_kabsch_rotation_recovery() {
rhat = uf * v.transpose();
}
// Recovered rotation matches, is orthonormal, and has determinant +1.
- assert_close(rhat, rot, 1e-9);
+ assert_matrix_close(rhat, rot, 1e-9);
assert_identity(rhat.transpose() * rhat, 1e-12);
assert!((rhat.determinant() - 1.0).abs() < 1e-9);
}
@@ -237,9 +238,9 @@ fn svd_redundant_jacobian_pseudo_inverse() {
let jp = j.pseudo_inverse().unwrap();
// Moore–Penrose: J·J⁺·J == J and J·J⁺ symmetric.
- assert_close(j * jp * j, j, 1e-9);
+ assert_matrix_close(j * jp * j, j, 1e-9);
let jjp = j * jp;
- assert_close(jjp, jjp.transpose(), 1e-12);
+ assert_matrix_close(jjp, jjp.transpose(), 1e-12);
// Minimum-norm resolution: J⁺·v beats any other solution of J·x = v.
let vtwist = Vector::<6>::from_fn(|i| i as f64 - 2.5);
diff --git a/crates/multicalc/tests/linear_algebra/vector.rs b/crates/multicalc/tests/suite/linear_algebra/vector.rs
similarity index 100%
rename from crates/multicalc/tests/linear_algebra/vector.rs
rename to crates/multicalc/tests/suite/linear_algebra/vector.rs
diff --git a/crates/multicalc/tests/suite/main.rs b/crates/multicalc/tests/suite/main.rs
new file mode 100644
index 0000000..efc064f
--- /dev/null
+++ b/crates/multicalc/tests/suite/main.rs
@@ -0,0 +1,14 @@
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod approximation;
+mod discretization;
+mod gaussian_tables;
+mod linear_algebra;
+mod numerical_derivative;
+mod numerical_integration;
+mod ode;
+mod optimization;
+mod root_finding;
+mod scalar;
+mod spatial;
+mod utils;
+mod vector_field;
diff --git a/crates/multicalc/tests/numerical_derivative.rs b/crates/multicalc/tests/suite/numerical_derivative.rs
similarity index 98%
rename from crates/multicalc/tests/numerical_derivative.rs
rename to crates/multicalc/tests/suite/numerical_derivative.rs
index 63e8fce..6724e30 100644
--- a/crates/multicalc/tests/numerical_derivative.rs
+++ b/crates/multicalc/tests/suite/numerical_derivative.rs
@@ -9,6 +9,7 @@ use multicalc::numerical_derivative::jacobian::Jacobian;
use multicalc::numerical_derivative::mode::*;
use multicalc::scalar::{Numeric, ScalarFn, ScalarFnN, VectorFn, c};
use multicalc::{scalar_fn, scalar_fn_vec};
+use multicalc_testkit::problems::G;
use proptest::prelude::*;
use proptest::test_runner::{RngAlgorithm, TestRng, TestRunner};
use std::cell::Cell;
@@ -40,9 +41,7 @@ fn ad_first_partials() {
#[test]
fn ad_first_partials_transcendental() {
// f(x, y, z) = y*sin(x) + x*cos(y) + x*y*e^z
- let func = scalar_fn!(|v: &[f64; 3]| {
- v[1] * v[0].sin() + v[0] * v[1].cos() + v[0] * v[1] * v[2].exp()
- });
+ let func = G;
let d = AutoDiffMulti::default();
let point = [1.0, 2.0, 3.0];
@@ -58,9 +57,7 @@ fn ad_first_partials_transcendental() {
#[test]
fn ad_second_partials() {
// f(x, y, z) = y*sin(x) + x*cos(y) + x*y*e^z
- let func = scalar_fn!(|v: &[f64; 3]| {
- v[1] * v[0].sin() + v[0] * v[1].cos() + v[0] * v[1] * v[2].exp()
- });
+ let func = G;
let d = AutoDiffMulti::default();
let point = [1.0, 2.0, 3.0];
diff --git a/crates/multicalc/tests/numerical_integration.rs b/crates/multicalc/tests/suite/numerical_integration.rs
similarity index 100%
rename from crates/multicalc/tests/numerical_integration.rs
rename to crates/multicalc/tests/suite/numerical_integration.rs
diff --git a/crates/multicalc/tests/suite/ode/mod.rs b/crates/multicalc/tests/suite/ode/mod.rs
new file mode 100644
index 0000000..c536345
--- /dev/null
+++ b/crates/multicalc/tests/suite/ode/mod.rs
@@ -0,0 +1,2 @@
+mod rk4;
+mod rk45;
diff --git a/crates/multicalc/tests/ode/rk4.rs b/crates/multicalc/tests/suite/ode/rk4.rs
similarity index 100%
rename from crates/multicalc/tests/ode/rk4.rs
rename to crates/multicalc/tests/suite/ode/rk4.rs
diff --git a/crates/multicalc/tests/ode/rk45.rs b/crates/multicalc/tests/suite/ode/rk45.rs
similarity index 100%
rename from crates/multicalc/tests/ode/rk45.rs
rename to crates/multicalc/tests/suite/ode/rk45.rs
diff --git a/crates/multicalc/tests/optimization.rs b/crates/multicalc/tests/suite/optimization.rs
similarity index 96%
rename from crates/multicalc/tests/optimization.rs
rename to crates/multicalc/tests/suite/optimization.rs
index ae35565..1ddd756 100644
--- a/crates/multicalc/tests/optimization.rs
+++ b/crates/multicalc/tests/suite/optimization.rs
@@ -9,12 +9,13 @@ use multicalc::optimization::{
};
use multicalc::scalar::{Numeric, VectorFn, c};
use multicalc::scalar_fn_vec;
+use multicalc_testkit::problems::Rosenbrock;
// ----- Levenberg-Marquardt solver -----
#[test]
fn lm_solves_rosenbrock() {
- let f = scalar_fn_vec!(|v: &[f64; 2]| [c(10.0) * (v[1] - v[0] * v[0]), c(1.0) - v[0]]);
+ let f = Rosenbrock;
let report: MinimizationReport<2> = LevenbergMarquardt::::default()
.minimize(&f, &[-1.2, 1.0])
.unwrap();
@@ -62,7 +63,7 @@ fn lm_fits_exponential_decay() {
#[test]
fn lm_solves_rosenbrock_f32() {
// One residual definition drives both precisions; eval is generic over the scalar.
- let f = scalar_fn_vec!(|v: &[f64; 2]| [c(10.0) * (v[1] - v[0] * v[0]), c(1.0) - v[0]]);
+ let f = Rosenbrock;
let report = LevenbergMarquardt::>::default()
.minimize(&f, &[-1.2_f32, 1.0])
.unwrap();
@@ -92,7 +93,7 @@ fn lm_reports_non_finite() {
#[test]
fn lm_reports_did_not_converge() {
// A one-iteration budget is too small for Rosenbrock, so the solver runs out.
- let f = scalar_fn_vec!(|v: &[f64; 2]| [c(10.0) * (v[1] - v[0] * v[0]), c(1.0) - v[0]]);
+ let f = Rosenbrock;
let result = LevenbergMarquardt::::default()
.with_patience(1)
.minimize(&f, &[-1.2, 1.0]);
@@ -205,7 +206,7 @@ fn gn_recovers_linear_least_squares() {
#[test]
fn gn_solves_rosenbrock() {
// From a near guess, Gauss-Newton converges quadratically on this zero-residual problem.
- let f = scalar_fn_vec!(|v: &[f64; 2]| [c(10.0) * (v[1] - v[0] * v[0]), c(1.0) - v[0]]);
+ let f = Rosenbrock;
let report = GaussNewton::::default()
.minimize(&f, &[0.9, 0.9])
.unwrap();
@@ -406,7 +407,7 @@ fn check_jacobian, const N: usize, const M: usize>(f: &F, x: &
#[test]
fn autodiff_jacobian_matches_finite_differences() {
// Rosenbrock residual: a low-degree polynomial, so central differences are near exact.
- let rosenbrock = scalar_fn_vec!(|v: &[f64; 2]| [c(10.0) * (v[1] - v[0] * v[0]), c(1.0) - v[0]]);
+ let rosenbrock = Rosenbrock;
assert!(check_jacobian(&rosenbrock, &[-1.2, 1.0]) < 1e-6);
// A transcendental residual exercises the sin and exp derivatives.
@@ -418,7 +419,7 @@ fn autodiff_jacobian_matches_finite_differences() {
fn solvers_accept_a_finite_difference_backend() {
// Swap the autodiff default for a finite-difference Jacobian; both solvers still converge on
// the zero-residual Rosenbrock problem.
- let f = scalar_fn_vec!(|v: &[f64; 2]| [c(10.0) * (v[1] - v[0] * v[0]), c(1.0) - v[0]]);
+ let f = Rosenbrock;
let lm = LevenbergMarquardt::from_derivator(FiniteDifferenceMulti::::default())
.minimize(&f, &[-1.2, 1.0])
diff --git a/crates/multicalc/tests/root_finding.rs b/crates/multicalc/tests/suite/root_finding.rs
similarity index 100%
rename from crates/multicalc/tests/root_finding.rs
rename to crates/multicalc/tests/suite/root_finding.rs
diff --git a/crates/multicalc/tests/scalar.rs b/crates/multicalc/tests/suite/scalar.rs
similarity index 100%
rename from crates/multicalc/tests/scalar.rs
rename to crates/multicalc/tests/suite/scalar.rs
diff --git a/crates/multicalc/tests/spatial/lie.rs b/crates/multicalc/tests/suite/spatial/lie.rs
similarity index 100%
rename from crates/multicalc/tests/spatial/lie.rs
rename to crates/multicalc/tests/suite/spatial/lie.rs
diff --git a/crates/multicalc/tests/suite/spatial/mod.rs b/crates/multicalc/tests/suite/spatial/mod.rs
new file mode 100644
index 0000000..fe34c42
--- /dev/null
+++ b/crates/multicalc/tests/suite/spatial/mod.rs
@@ -0,0 +1,3 @@
+mod lie;
+mod quaternion;
+mod twist_wrench;
diff --git a/crates/multicalc/tests/spatial/quaternion.rs b/crates/multicalc/tests/suite/spatial/quaternion.rs
similarity index 100%
rename from crates/multicalc/tests/spatial/quaternion.rs
rename to crates/multicalc/tests/suite/spatial/quaternion.rs
diff --git a/crates/multicalc/tests/spatial/twist_wrench.rs b/crates/multicalc/tests/suite/spatial/twist_wrench.rs
similarity index 100%
rename from crates/multicalc/tests/spatial/twist_wrench.rs
rename to crates/multicalc/tests/suite/spatial/twist_wrench.rs
diff --git a/crates/multicalc/tests/utils.rs b/crates/multicalc/tests/suite/utils.rs
similarity index 100%
rename from crates/multicalc/tests/utils.rs
rename to crates/multicalc/tests/suite/utils.rs
diff --git a/crates/multicalc/tests/vector_field.rs b/crates/multicalc/tests/suite/vector_field.rs
similarity index 100%
rename from crates/multicalc/tests/vector_field.rs
rename to crates/multicalc/tests/suite/vector_field.rs
diff --git a/demos/Cargo.toml b/demos/Cargo.toml
new file mode 100644
index 0000000..e1dbc12
--- /dev/null
+++ b/demos/Cargo.toml
@@ -0,0 +1,115 @@
+[package]
+name = "multicalc-demos"
+version.workspace = true
+edition.workspace = true
+# Rerun requires Rust 1.92, above the workspace 1.85 floor, so this crate sets its own.
+rust-version = "1.92"
+license.workspace = true
+publish = false
+autoexamples = false
+
+[features]
+default = ["rerun"]
+rerun = ["dep:rerun"]
+
+[dependencies]
+multicalc = { path = "../crates/multicalc" }
+# Pinned to match the paired Rerun viewer version. Only the logging SDK is needed: `spawn()`
+# launches the external viewer on PATH, so the in-process `native_viewer` is not compiled in.
+rerun = { version = "=0.33.1", default-features = false, features = ["sdk"], optional = true }
+
+[lints]
+workspace = true
+
+# Basics: headless, self-checking, multicalc-only. No required-features, so they
+# build and run with the rerun feature off.
+[[example]]
+name = "approximation"
+path = "examples/basics/approximation.rs"
+
+[[example]]
+name = "autodiff_scalars"
+path = "examples/basics/autodiff_scalars.rs"
+
+[[example]]
+name = "curve_fit"
+path = "examples/basics/curve_fit.rs"
+
+[[example]]
+name = "differentiation"
+path = "examples/basics/differentiation.rs"
+
+[[example]]
+name = "discretization"
+path = "examples/basics/discretization.rs"
+
+[[example]]
+name = "gaussian_integration"
+path = "examples/basics/gaussian_integration.rs"
+
+[[example]]
+name = "iterative_integration"
+path = "examples/basics/iterative_integration.rs"
+
+[[example]]
+name = "jacobian_hessian"
+path = "examples/basics/jacobian_hessian.rs"
+
+[[example]]
+name = "lie_groups"
+path = "examples/basics/lie_groups.rs"
+
+[[example]]
+name = "linear_algebra"
+path = "examples/basics/linear_algebra.rs"
+
+[[example]]
+name = "ode"
+path = "examples/basics/ode.rs"
+
+[[example]]
+name = "optimization_solvers"
+path = "examples/basics/optimization_solvers.rs"
+
+[[example]]
+name = "root_finding"
+path = "examples/basics/root_finding.rs"
+
+[[example]]
+name = "svd"
+path = "examples/basics/svd.rs"
+
+[[example]]
+name = "vector_field"
+path = "examples/basics/vector_field.rs"
+
+# Showcases: live Rerun demos, require the rerun feature.
+[[example]]
+name = "curve_fit_live"
+path = "examples/showcase/curve_fit_live.rs"
+required-features = ["rerun"]
+
+[[example]]
+name = "curve_fit_record"
+path = "examples/showcase/curve_fit_record.rs"
+required-features = ["rerun"]
+
+[[example]]
+name = "fourier_ferris"
+path = "examples/showcase/fourier_ferris.rs"
+required-features = ["rerun"]
+
+[[example]]
+name = "gradient_marbles"
+path = "examples/showcase/gradient_marbles.rs"
+required-features = ["rerun"]
+
+[[example]]
+name = "ik_servo"
+path = "examples/showcase/ik_servo.rs"
+required-features = ["rerun"]
+
+[[example]]
+name = "newton_fractal"
+path = "examples/showcase/newton_fractal.rs"
+required-features = ["rerun"]
diff --git a/demos/README.md b/demos/README.md
new file mode 100644
index 0000000..5e7c81c
--- /dev/null
+++ b/demos/README.md
@@ -0,0 +1,169 @@
+# multicalc-demos
+
+Runnable demos for [`multicalc`](../crates/multicalc), in two flavors:
+
+- **Basics** — headless, terminating programs, one per module. Each prints its results against
+ the known analytic value (with the `|err|`) and self-checks with an assert. No viewer, no
+ feature flags; they depend only on `multicalc`.
+- **Showcases** — live [Rerun](https://rerun.io) demos that render an animated scene and stream
+ live-measured speed and accuracy. They require the `rerun` feature (on by default) and a
+ version-matched viewer.
+
+This is a satellite crate: it is never a dependency of the core library, is excluded from
+bare-metal builds and the default `cargo test`, and its dependency tree is excluded from the
+workspace supply-chain audit.
+
+## Start here
+
+No viewer, no flags — each terminates and prints results vs the analytic value with the `|err|`:
+
+```sh
+cargo run -p multicalc-demos --example
+```
+
+| Example | Module(s) | What it shows |
+| --- | --- | --- |
+| `approximation` | `approximation` | Linear and quadratic (Taylor) approximations, `predict`, and goodness-of-fit metrics. |
+| `autodiff_scalars` | `scalar` | Use `Dual` and `HyperDual` directly: evaluate a generic `Numeric` function and read f, f′, f″ from the result fields (no derivator). |
+| `curve_fit` | `optimization` | Levenberg-Marquardt fit of `y = a·e^(b·t)` to sensor samples with exact autodiff Jacobians; prints recovered `a`, `b`, and `\|err\|`. |
+| `differentiation` | `numerical_derivative` | Single- and multi-variable derivatives (orders 1-3, partials, mixed partials) by autodiff. |
+| `discretization` | `discretization`, `linear_algebra::expm` | ZOH on a double integrator, Van Loan process-noise discretization, the filterpy `q_discrete_white_noise` model, and a one-`Dual` derivative through the matrix exponential. |
+| `gaussian_integration` | `numerical_integration::gaussian_integration` | Gauss-Legendre (finite), Gauss-Hermite and Gauss-Laguerre (infinite), with the bare-integrand convention. |
+| `iterative_integration` | `numerical_integration::iterative_integration` | Boole / Simpson / Trapezoidal rules, multi-variable partial integrals, and infinite / semi-infinite limits. |
+| `jacobian_hessian` | `numerical_derivative::{jacobian, hessian}` | Jacobian of a vector of functions and the Hessian of a scalar function. |
+| `lie_groups` | `spatial` | SO(3)/SE(3) compose, act on a point, exp/log round trips, geodesic interpolation, and a one-`Dual` autodiff derivative pushed through `exp` ∘ `act`. |
+| `linear_algebra` | `linear_algebra` | LU and Cholesky factorizations, linear solves, and the direct 4x4 inverse under a latency + approximation-error stress test on well- and ill-conditioned inputs. |
+| `ode` | `ode` | Fixed-step RK4 and adaptive RK45 on the harmonic oscillator (known solution) plus an acrobot, a tumbling quadrotor, and an outer-solar-system N-body, reporting error and conserved-quantity drift. |
+| `optimization_solvers` | `optimization` | Gauss-Newton on a well-conditioned linear residual (`y = a + b·t`); when GN is enough vs LM (`curve_fit`). |
+| `root_finding` | `root_finding` | Bracketed bisection, Newton with exact derivatives, damped (backtracking) Newton rescuing a far start, and a square-system Newton solve, each printed against its known root. |
+| `svd` | `linear_algebra::svd` | Singular value decomposition and Moore-Penrose pseudo-inverse under a robotics stress test (Kabsch rotation recovery, a redundant-arm pseudo-inverse, a near-singular Jacobian, and an overdetermined fit) with latency + approximation error. |
+| `vector_field` | `vector_field` | Curl, divergence, line integrals and flux integrals. |
+
+`linear_algebra` and `svd` also print per-call latency; build them `--release` for representative
+numbers.
+
+## Live showcases
+
+Four live demos, one per core module, each an attention-grabbing animated scene that markets the
+library's raw speed and accuracy. They need the `rerun` feature (on by default) and a
+version-matched viewer already up. **Every number on screen is measured live** with
+`std::time::Instant` inside the demo — nothing is hardcoded. Run each with `--release` (mandatory
+for the timing readouts):
+
+```sh
+cargo run --release -p multicalc-demos --example
+```
+
+Each demo advances its simulation on logical time (a fixed 1 ms per tick / one step per frame),
+so the numbers are deterministic and reproducible. An OS scheduling spike can make a tick display
+late or jitter but never changes what the demo computes.
+
+The figures below are representative of a modern desktop core (`x86_64`, `--release`).
+
+- **`ik_servo`** (optimization) — a 3-link arm runs a complete Levenberg-Marquardt IK solve, with
+ exact autodiff Jacobians, every single millisecond. **Median solve ≈ 6 µs — under 1 % of the
+ 1 ms budget — with zero missed ticks over 120,000 solves.**
+
+ 
+
+- **`newton_fractal`** (root finding) — every pixel is a full Newton-system solve with an exact
+ autodiff Jacobian, and the cubic's basins swirl as its roots orbit. **≈ 4 million Newton
+ solves/sec on one core** (a 256×256 grid re-solved at ~60 fps), each converged root accurate to
+ **≈ 5e-15**.
+
+ 
+
+- **`fourier_ferris`** (integration) — Gauss-Legendre quadrature computes the Fourier coefficients
+ of Ferris's outline; a chain of epicycles then draws the crab. **≈ 600,000 quadrature node
+ evaluations in ≈ 8 ms** at startup, with every coefficient matching the exact closed form to
+ **≈ 1e-15**.
+
+ 
+
+- **`gradient_marbles`** (autodiff) — 2,000 marbles across a 3D Himmelblau landscape, each steered
+ by an exact autodiff gradient every millisecond. **2,000 exact gradients in under 3 µs per tick
+ (~750,000 gradients/ms), and the autodiff-vs-analytic error is pinned at exactly 0.0** on screen.
+
+ 
+
+`curve_fit_live` and `curve_fit_record` are two more showcase examples: the first streams a live
+Levenberg-Marquardt fit, the second writes a `.rrd` (and a `.csv`) with no viewer needed.
+
+## Viewer setup
+
+### Versions
+
+Rerun SDK `=0.33.1` ⇄ viewer `0.33.1`. The SDK is exact-pinned; the viewer must match.
+
+### Install (for the live showcases)
+
+`live()` spawns the external Rerun viewer found on PATH, so install it version-matched to the SDK:
+
+```
+cargo install rerun-cli --locked --version 0.33.1
+# or: pip install rerun-sdk==0.33.1
+# or: cargo binstall rerun-cli --version 0.33.1
+```
+
+### Recorded output and the CSV fallback
+
+`curve_fit_record` needs no viewer; it writes a `.rrd` and a `.csv` to the temp dir:
+
+```
+cargo run -p multicalc-demos --example curve_fit_record
+```
+
+Open the printed `.rrd` in the viewer, or render the CSV fallback:
+
+```
+python demos/plot.py --x t
+```
+
+### WSL usage (viewer on Windows)
+
+The live viewer is a GPU application; under WSL its virtualized GPU often cannot start it. Run
+the viewer on Windows instead (real GPU) and stream to it from WSL over gRPC.
+
+1. Enable mirrored networking so WSL and Windows share `localhost`. In `C:\Users\\.wslconfig`:
+
+ ```ini
+ [wsl2]
+ networkingMode=mirrored
+ ```
+
+ Then from Windows PowerShell run `wsl --shutdown`, reopen WSL, and confirm:
+
+ ```
+ wslinfo --networking-mode # -> mirrored
+ ```
+
+2. Install the viewer on Windows if needed, version-matched to the SDK (0.33.1):
+
+ ```
+ pip install rerun-sdk==0.33.1 # provides the `rerun` command
+ # or download the prebuilt rerun.exe for 0.33.1
+ ```
+
+3. Start the viewer on Windows (it listens on port 9876):
+
+ ```
+ rerun
+ ```
+
+4. From WSL, run a live example. Under WSL it auto-detects the environment and streams to the
+ Windows viewer over the shared localhost instead of spawning a local one:
+
+ ```
+ cargo run -p multicalc-demos --example curve_fit_live
+ ```
+
+ The Windows viewer from step 3 MUST already be running — under WSL the example connects to it
+ and does not spawn one.
+
+On NAT networking (the WSL default) instead of mirrored, set `RERUN_VIZ_URL` to the Windows host,
+launch the viewer bound to `0.0.0.0`, and allow inbound TCP 9876 in Windows Firewall:
+
+```
+export RERUN_VIZ_URL="rerun+http://$(ip route show default | awk '{print $3}'):9876/proxy"
+cargo run -p multicalc-demos --example curve_fit_live
+```
diff --git a/crates/multicalc/examples/approximation.rs b/demos/examples/basics/approximation.rs
similarity index 91%
rename from crates/multicalc/examples/approximation.rs
rename to demos/examples/basics/approximation.rs
index e83c8e6..8dbc2af 100644
--- a/crates/multicalc/examples/approximation.rs
+++ b/demos/examples/basics/approximation.rs
@@ -1,7 +1,7 @@
//! Linear and quadratic (Taylor) approximation of a function about a point, plus
//! goodness-of-fit metrics.
//!
-//! Run with: `cargo run --example approximation`
+//! Run with: `cargo run -p multicalc-demos --example approximation`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -17,6 +17,8 @@ fn main() {
let linear: LinearApproximator = LinearApproximator::default();
let model = linear.get(&f, &base).unwrap();
+ // A first-order model is exact at its own expansion point.
+ assert!((model.predict(&base) - f.eval(&base)).abs() < 1e-9);
println!("Linear model of x + y^2 + z^3 about {base:?}");
println!(
diff --git a/crates/multicalc/examples/autodiff_scalars.rs b/demos/examples/basics/autodiff_scalars.rs
similarity index 95%
rename from crates/multicalc/examples/autodiff_scalars.rs
rename to demos/examples/basics/autodiff_scalars.rs
index 6e9cc9e..e3b049f 100644
--- a/crates/multicalc/examples/autodiff_scalars.rs
+++ b/demos/examples/basics/autodiff_scalars.rs
@@ -1,6 +1,6 @@
//! Using autodiff scalar types directly (`Dual`, `HyperDual`).
//!
-//! Run with: `cargo run --example autodiff_scalars`
+//! Run with: `cargo run -p multicalc-demos --example autodiff_scalars`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
diff --git a/crates/multicalc/examples/curve_fit.rs b/demos/examples/basics/curve_fit.rs
similarity index 95%
rename from crates/multicalc/examples/curve_fit.rs
rename to demos/examples/basics/curve_fit.rs
index e9b02fc..243a696 100644
--- a/crates/multicalc/examples/curve_fit.rs
+++ b/demos/examples/basics/curve_fit.rs
@@ -1,7 +1,7 @@
//! Sensor-calibration curve fit: recover a and b in y = a·e^(b·t) from samples using
//! Levenberg–Marquardt with exact autodiff Jacobians (no hand-derived derivatives), zero heap.
//!
-//! Run with: cargo run --example curve_fit (or: cargo fit)
+//! Run with: cargo run -p multicalc-demos --example curve_fit (or: cargo fit)
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
diff --git a/crates/multicalc/examples/differentiation.rs b/demos/examples/basics/differentiation.rs
similarity index 94%
rename from crates/multicalc/examples/differentiation.rs
rename to demos/examples/basics/differentiation.rs
index aa6bec3..5101a3b 100644
--- a/crates/multicalc/examples/differentiation.rs
+++ b/demos/examples/basics/differentiation.rs
@@ -1,7 +1,7 @@
//! Single- and multi-variable differentiation.
//! The derivative order for a partial is just the number of indices passed.
//!
-//! Run with: `cargo run --example differentiation`
+//! Run with: `cargo run -p multicalc-demos --example differentiation`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -10,6 +10,7 @@ use multicalc::numerical_derivative::derivator::{DerivatorMultiVariable, Derivat
use multicalc::scalar_fn;
fn report(label: &str, value: f64, exact: f64) {
+ assert!((value - exact).abs() < 1e-6, "{label}: |err| too large");
println!(
" {label:<18} = {value:>13.8} (exact {exact:>13.8}, |err| {:.0e})",
(value - exact).abs()
diff --git a/crates/multicalc/examples/discretization.rs b/demos/examples/basics/discretization.rs
similarity index 93%
rename from crates/multicalc/examples/discretization.rs
rename to demos/examples/basics/discretization.rs
index d135d03..5cc49e4 100644
--- a/crates/multicalc/examples/discretization.rs
+++ b/demos/examples/basics/discretization.rs
@@ -1,7 +1,7 @@
//! Discretization: zero-order hold on a double integrator, Van Loan process noise, the discrete
//! white-noise model, and a one-`Dual` derivative through `expm`.
//!
-//! Run with: `cargo run --example discretization`
+//! Run with: `cargo run -p multicalc-demos --example discretization`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -10,6 +10,7 @@ use multicalc::linear_algebra::Matrix;
use multicalc::scalar::Dual;
fn report(label: &str, value: f64, exact: f64) {
+ assert!((value - exact).abs() < 1e-9, "{label}: |err| too large");
println!(
" {label:<22} = {value:>12.8} (exact {exact:>12.8}, |err| {:.0e})",
(value - exact).abs()
diff --git a/crates/multicalc/examples/gaussian_integration.rs b/demos/examples/basics/gaussian_integration.rs
similarity index 90%
rename from crates/multicalc/examples/gaussian_integration.rs
rename to demos/examples/basics/gaussian_integration.rs
index ba54af9..1da4382 100644
--- a/crates/multicalc/examples/gaussian_integration.rs
+++ b/demos/examples/basics/gaussian_integration.rs
@@ -4,7 +4,7 @@
//! are exact (to machine precision) for polynomial integrands, and lose accuracy fast on
//! non-polynomial ones.
//!
-//! Run with: `cargo run --example gaussian_integration`
+//! Run with: `cargo run -p multicalc-demos --example gaussian_integration`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -28,13 +28,14 @@ fn main() {
let legendre = GaussianSingle::from_parameters(5, GaussianQuadratureMethod::GaussLegendre);
println!("Gauss-Legendre (finite limits):");
// int_0^2 (4x^3 - 3x^2) dx = 8 (exact: order 5 handles degree <= 9)
- report(
- "int_0^2 4x^3-3x^2",
- legendre
- .get_single(&|x| 4.0 * x * x * x - 3.0 * x * x, &[0.0, 2.0])
- .unwrap(),
- 8.0,
+ let poly: f64 = legendre
+ .get_single(&|x| 4.0 * x * x * x - 3.0 * x * x, &[0.0, 2.0])
+ .unwrap();
+ assert!(
+ (poly - 8.0).abs() < 1e-9,
+ "Legendre is exact for polynomials"
);
+ report("int_0^2 4x^3-3x^2", poly, 8.0);
// non-polynomial integrand: accuracy falls
report(
"int_0^1 (sinx-sqrtx)e^-x",
diff --git a/crates/multicalc/examples/iterative_integration.rs b/demos/examples/basics/iterative_integration.rs
similarity index 90%
rename from crates/multicalc/examples/iterative_integration.rs
rename to demos/examples/basics/iterative_integration.rs
index 296573b..3893efb 100644
--- a/crates/multicalc/examples/iterative_integration.rs
+++ b/demos/examples/basics/iterative_integration.rs
@@ -4,7 +4,7 @@
//! Also reproduces the iterative-integration accuracy figures in benches/calculus.md: Boole is
//! the highest-order rule and most accurate, Simpson is intermediate, Trapezoidal is lowest order.
//!
-//! Run with: `cargo run --example iterative_integration`
+//! Run with: `cargo run -p multicalc-demos --example iterative_integration`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -25,10 +25,12 @@ fn main() {
// ---- single variable: int_0^2 2x dx = 4 ----
let f = |x: f64| 2.0 * x;
let integrator = IterativeSingle::default(); // Boole's rule, 120 intervals
- println!(
- "int_0^2 2x dx = {:.8} (exact 4)",
- integrator.get_single(&f, &[0.0, 2.0]).unwrap()
+ let two_x = integrator.get_single(&f, &[0.0, 2.0]).unwrap();
+ assert!(
+ (two_x - 4.0).abs() < 1e-9,
+ "Boole is exact for a linear integrand"
);
+ println!("int_0^2 2x dx = {two_x:.8} (exact 4)");
// ---- compare the three rules on the same integrand ----
// int_0^1 (yz x^2 e^x) dx folded three times, with y*z = 6 -> 6*(e - 2)
diff --git a/crates/multicalc/examples/jacobian_hessian.rs b/demos/examples/basics/jacobian_hessian.rs
similarity index 84%
rename from crates/multicalc/examples/jacobian_hessian.rs
rename to demos/examples/basics/jacobian_hessian.rs
index f289acd..0fa0ea2 100644
--- a/crates/multicalc/examples/jacobian_hessian.rs
+++ b/demos/examples/basics/jacobian_hessian.rs
@@ -1,6 +1,6 @@
//! Jacobian and Hessian matrices of multi-variable functions.
//!
-//! Run with: `cargo run --example jacobian_hessian`
+//! Run with: `cargo run -p multicalc-demos --example jacobian_hessian`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -22,6 +22,12 @@ fn main() {
println!(" [{:.4}, {:.4}, {:.4}]", row[0], row[1], row[2]);
}
println!(" (exact [[6, 3, 2], [2, 4, 0]])");
+ let exact = [[6.0, 3.0, 2.0], [2.0, 4.0, 0.0]];
+ for i in 0..2 {
+ for j in 0..3 {
+ assert!((result[i][j] - exact[i][j]).abs() < 1e-9);
+ }
+ }
// ---- Hessian of f(x, y) = y*sin(x) + 2*x*e^y ----
let g = scalar_fn!(|v: &[f64; 2]| v[1] * v[0].sin() + c(2.0) * v[0] * v[1].exp());
diff --git a/crates/multicalc/examples/lie_groups.rs b/demos/examples/basics/lie_groups.rs
similarity index 95%
rename from crates/multicalc/examples/lie_groups.rs
rename to demos/examples/basics/lie_groups.rs
index 4e8f2a6..c0b912d 100644
--- a/crates/multicalc/examples/lie_groups.rs
+++ b/demos/examples/basics/lie_groups.rs
@@ -1,7 +1,7 @@
//! SO(3) and SE(3) Lie groups: compose, act on a point, exp/log round trips, geodesic
//! interpolation, and a one-`Dual` autodiff derivative through the whole composition.
//!
-//! Run with: `cargo run --example lie_groups`
+//! Run with: `cargo run -p multicalc-demos --example lie_groups`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -12,6 +12,7 @@ use multicalc::scalar::Dual;
use multicalc::spatial::{SE3, SO3};
fn report(label: &str, value: f64, exact: f64) {
+ assert!((value - exact).abs() < 1e-9, "{label}: |err| too large");
println!(
" {label:<20} = {value:>12.8} (exact {exact:>12.8}, |err| {:.0e})",
(value - exact).abs()
diff --git a/crates/multicalc/examples/linear_algebra.rs b/demos/examples/basics/linear_algebra.rs
similarity index 91%
rename from crates/multicalc/examples/linear_algebra.rs
rename to demos/examples/basics/linear_algebra.rs
index e9f01c6..969435b 100644
--- a/crates/multicalc/examples/linear_algebra.rs
+++ b/demos/examples/basics/linear_algebra.rs
@@ -3,7 +3,7 @@
//! residual, and inverse identity error) on well- and ill-conditioned inputs.
//!
//! Latency is illustrative in a debug build; run with `--release` for representative numbers:
-//! `cargo run --release --example linear_algebra`
+//! `cargo run -p multicalc-demos --release --example linear_algebra`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -96,6 +96,15 @@ fn inverse4_report(a: Matrix<4, 4>, label: &str) {
}
fn main() {
+ // Sanity: the LU solve residual on a well-conditioned system is tiny.
+ {
+ let a = general::<4>();
+ let x_true = Vector::<4>::from_fn(|i| 1.0 + i as f64);
+ let b = a * x_true;
+ let x = a.lu().unwrap().solve(b);
+ assert!((a * x - b).norm() < 1e-9, "LU solve residual too large");
+ }
+
println!("LU (any invertible matrix) - decompose + solve:");
lu_report(general::<4>(), "general 4x4");
lu_report(general::<8>(), "general 8x8");
diff --git a/crates/multicalc/examples/ode.rs b/demos/examples/basics/ode.rs
similarity index 97%
rename from crates/multicalc/examples/ode.rs
rename to demos/examples/basics/ode.rs
index d3fb1f2..3014ab0 100644
--- a/crates/multicalc/examples/ode.rs
+++ b/demos/examples/basics/ode.rs
@@ -5,7 +5,7 @@
//! reported as the drift in a conserved quantity (energy, kinetic energy, quaternion norm).
//! These figures reproduce the accuracy table in `benches/ode.md`.
//!
-//! Run with: `cargo run --example ode`
+//! Run with: `cargo run -p multicalc-demos --example ode`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -41,6 +41,10 @@ fn harmonic_oscillator() {
" y(2*pi) = [{:.12}, {:.12}] max|err| = {max_err:.2e}",
yf[0], yf[1]
);
+ assert!(
+ max_err < 1e-3,
+ "RK4 should track the exact harmonic solution"
+ );
// RK45: adaptive solve to t = 2*pi, then dense-output sampling.
let solver = Rk45::default().with_rtol(1e-9).with_atol(1e-12);
@@ -68,6 +72,7 @@ fn harmonic_oscillator() {
})
.fold(0.0_f64, f64::max);
println!(" dense-output grid max|err| = {grid_err:.2e}");
+ assert!(grid_err < 1e-6, "RK45 dense output should be accurate");
}
// Largest drift of the invariant `inv` from its initial value over an RK4 integration.
diff --git a/crates/multicalc/examples/optimization_solvers.rs b/demos/examples/basics/optimization_solvers.rs
similarity index 95%
rename from crates/multicalc/examples/optimization_solvers.rs
rename to demos/examples/basics/optimization_solvers.rs
index 92d2f80..7dff214 100644
--- a/crates/multicalc/examples/optimization_solvers.rs
+++ b/demos/examples/basics/optimization_solvers.rs
@@ -5,7 +5,7 @@
//! - **Levenberg-Marquardt** (see curve_fit.rs): damped / trust-region style; prefer when
//! far from the solution or the Jacobian is poorly conditioned.
//!
-//! Run: cargo run -p multicalc --example optimization_solvers
+//! Run: cargo run -p multicalc-demos --example optimization_solvers
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
diff --git a/crates/multicalc/examples/root_finding.rs b/demos/examples/basics/root_finding.rs
similarity index 94%
rename from crates/multicalc/examples/root_finding.rs
rename to demos/examples/basics/root_finding.rs
index b8167be..fb173a5 100644
--- a/crates/multicalc/examples/root_finding.rs
+++ b/demos/examples/basics/root_finding.rs
@@ -1,7 +1,7 @@
//! Root finding: bracketed bisection, Newton with exact derivatives, damped Newton, and a
//! square-system Newton solve. Each result prints against its known root with the `|err|`.
//!
-//! Run with: `cargo run --example root_finding`
+//! Run with: `cargo run -p multicalc-demos --example root_finding`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -67,6 +67,10 @@ fn main() {
.solve(&system, &[1.5, 0.8])
.unwrap();
let err = (r.root[0] - x_true).abs().max((r.root[1] - y_true).abs());
+ assert!(
+ err < 1e-9,
+ "Newton system should converge to the intersection"
+ );
println!("\nNewton system x^2 + y^2 = 4 and x*y = 1");
println!(" root = [{:.12}, {:.12}]", r.root[0], r.root[1]);
println!(
diff --git a/crates/multicalc/examples/svd.rs b/demos/examples/basics/svd.rs
similarity index 97%
rename from crates/multicalc/examples/svd.rs
rename to demos/examples/basics/svd.rs
index be4bcb1..4f57f15 100644
--- a/crates/multicalc/examples/svd.rs
+++ b/demos/examples/basics/svd.rs
@@ -3,7 +3,7 @@
//! recovery, a redundant-arm pseudo-inverse, a near-singular Jacobian, and an overdetermined fit.
//!
//! Latency is illustrative in a debug build; run with `--release` for representative numbers:
-//! `cargo run --release --example svd`
+//! `cargo run -p multicalc-demos --release --example svd`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -87,6 +87,10 @@ fn kabsch() {
}
let rot_err = max_abs(rhat, rot);
let ortho_err = max_abs(rhat.transpose() * rhat, Matrix::<3, 3>::identity());
+ assert!(
+ rot_err < 1e-9 && ortho_err < 1e-9,
+ "SVD should recover the rotation"
+ );
let label = "Kabsch 3x3";
println!(" {label:<20} {ns:>8.1} ns R-error {rot_err:.1e} orthogonality {ortho_err:.1e}");
}
diff --git a/crates/multicalc/examples/vector_field.rs b/demos/examples/basics/vector_field.rs
similarity index 90%
rename from crates/multicalc/examples/vector_field.rs
rename to demos/examples/basics/vector_field.rs
index 88ced60..26ca592 100644
--- a/crates/multicalc/examples/vector_field.rs
+++ b/demos/examples/basics/vector_field.rs
@@ -1,6 +1,6 @@
//! Vector-field calculus: curl, divergence, line integrals and flux integrals.
//!
-//! Run with: `cargo run --example vector_field`
+//! Run with: `cargo run -p multicalc-demos --example vector_field`
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -16,6 +16,8 @@ fn main() {
let curl_2d = curl::get_2d(AutoDiffMulti::default(), &field, &point).unwrap();
let div_2d = divergence::get_2d(AutoDiffMulti::default(), &field, &point).unwrap();
+ assert!((curl_2d + 2.0).abs() < 1e-9, "curl");
+ assert!((div_2d - std::f64::consts::TAU).abs() < 1e-9, "divergence");
println!("field (2xy, 3cos y) at {point:?}");
println!(" curl = {curl_2d:.4} (exact -2)");
println!(
diff --git a/showcase/viz/examples/curve_fit_live.rs b/demos/examples/showcase/curve_fit_live.rs
similarity index 87%
rename from showcase/viz/examples/curve_fit_live.rs
rename to demos/examples/showcase/curve_fit_live.rs
index a9146d6..078fcfa 100644
--- a/showcase/viz/examples/curve_fit_live.rs
+++ b/demos/examples/showcase/curve_fit_live.rs
@@ -1,14 +1,14 @@
//! Streams a Levenberg-Marquardt curve fit (`y = a·e^(b·t)`) to a live Rerun viewer.
//!
-//! Requires the `rerun` viewer (version 0.33.1) on PATH; see showcase/viz/README.md.
-//! Run with: cargo run -p multicalc-viz --example curve_fit_live
+//! Requires the `rerun` viewer (version 0.33.1) on PATH; see demos/README.md.
+//! Run with: cargo run -p multicalc-demos --example curve_fit_live
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use multicalc::LevenbergMarquardt;
use multicalc::numerical_derivative::autodiff::AutoDiffMulti;
use multicalc::scalar::{Numeric, VectorFn};
-use multicalc_viz::{RerunSink, VizError, VizSink};
+use multicalc_demos::{RerunSink, VizError, VizSink};
const A_TRUE: f64 = 100.0;
const B_TRUE: f64 = -0.5;
@@ -39,7 +39,7 @@ fn main() -> Result<(), VizError> {
let fit = |tt: f64| a * (b * tt).exp();
// Spawns the viewer and streams data scatter, fitted curve, and residual series.
- let mut rr = RerunSink::live("multicalc-viz/curve-fit")?;
+ let mut rr = RerunSink::live("multicalc-demos/curve-fit")?;
let data_pts: Vec<[f64; 2]> = (0..M).map(|i| [t[i], y[i]]).collect();
rr.points2d("data", &data_pts)?;
diff --git a/showcase/viz/examples/curve_fit_record.rs b/demos/examples/showcase/curve_fit_record.rs
similarity index 89%
rename from showcase/viz/examples/curve_fit_record.rs
rename to demos/examples/showcase/curve_fit_record.rs
index 6c69c09..0c135fd 100644
--- a/showcase/viz/examples/curve_fit_record.rs
+++ b/demos/examples/showcase/curve_fit_record.rs
@@ -1,13 +1,13 @@
//! Records a Levenberg-Marquardt curve fit (`y = a·e^(b·t)`) to a `.rrd` and a `.csv`.
//!
-//! Run with: cargo run -p multicalc-viz --example curve_fit_record
+//! Run with: cargo run -p multicalc-demos --example curve_fit_record
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use multicalc::LevenbergMarquardt;
use multicalc::numerical_derivative::autodiff::AutoDiffMulti;
use multicalc::scalar::{Numeric, VectorFn};
-use multicalc_viz::{CsvSink, RerunSink, VizError, VizSink};
+use multicalc_demos::{CsvSink, RerunSink, VizError, VizSink};
const A_TRUE: f64 = 100.0;
const B_TRUE: f64 = -0.5;
@@ -42,7 +42,7 @@ fn main() -> Result<(), VizError> {
let csv = dir.join("curve_fit.csv");
// Rerun recording: data scatter, dense fitted curve, per-sample residual series.
- let mut rr = RerunSink::record("multicalc-viz/curve-fit", &rrd)?;
+ let mut rr = RerunSink::record("multicalc-demos/curve-fit", &rrd)?;
let data_pts: Vec<[f64; 2]> = (0..M).map(|i| [t[i], y[i]]).collect();
rr.points2d("data", &data_pts)?;
@@ -76,7 +76,7 @@ fn main() -> Result<(), VizError> {
println!("wrote {} and {}", rrd.display(), csv.display());
println!(
- " open the .rrd in the rerun viewer, or: python showcase/viz/plot.py {} --x t",
+ " open the .rrd in the rerun viewer, or: python demos/plot.py {} --x t",
csv.display()
);
Ok(())
diff --git a/showcase/viz/examples/fourier_ferris.rs b/demos/examples/showcase/fourier_ferris.rs
similarity index 96%
rename from showcase/viz/examples/fourier_ferris.rs
rename to demos/examples/showcase/fourier_ferris.rs
index aa7faba..053b4d5 100644
--- a/showcase/viz/examples/fourier_ferris.rs
+++ b/demos/examples/showcase/fourier_ferris.rs
@@ -11,16 +11,16 @@
//! percentile (not a plot), since it is the OS, not the library. The headline is that math cost and
//! its headroom under the 1 ms budget.
//!
-//! Streams live to a Rerun viewer; see showcase/viz/README.md for the WSL setup.
-//! Run with: cargo run --release -p multicalc-viz --example fourier_ferris
+//! Streams live to a Rerun viewer; see demos/README.md for the WSL setup.
+//! Run with: cargo run --release -p multicalc-demos --example fourier_ferris
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use multicalc::numerical_integration::gaussian_integration::GaussianSingle;
use multicalc::numerical_integration::integrator::IntegratorSingleVariable;
use multicalc::numerical_integration::mode::GaussianQuadratureMethod;
-use multicalc_viz::loop_util::{LatencyRing, Pacer, commas};
-use multicalc_viz::{RerunSink, Rgba, VizError, VizSink};
+use multicalc_demos::loop_util::{LatencyRing, Pacer, commas};
+use multicalc_demos::{RerunSink, Rgba, VizError, VizSink};
use std::collections::VecDeque;
use std::f64::consts::TAU;
use std::time::Instant;
@@ -187,7 +187,7 @@ fn main() -> Result<(), VizError> {
if cfg!(debug_assertions) {
eprintln!(
"WARNING: debug build — timing numbers are meaningless. \
- Re-run with: cargo run --release -p multicalc-viz --example fourier_ferris"
+ Re-run with: cargo run --release -p multicalc-demos --example fourier_ferris"
);
}
@@ -202,7 +202,7 @@ fn main() -> Result<(), VizError> {
commas(node_evals)
);
- let mut rr = RerunSink::live("multicalc-viz/fourier-ferris")?;
+ let mut rr = RerunSink::live("multicalc-demos/fourier-ferris")?;
rr.set_sequence("tick", 0);
rr.series_style(
"plots/coeff_error",
diff --git a/showcase/viz/examples/gradient_marbles.rs b/demos/examples/showcase/gradient_marbles.rs
similarity index 96%
rename from showcase/viz/examples/gradient_marbles.rs
rename to demos/examples/showcase/gradient_marbles.rs
index 7c91529..05cf4ce 100644
--- a/showcase/viz/examples/gradient_marbles.rs
+++ b/demos/examples/showcase/gradient_marbles.rs
@@ -11,16 +11,16 @@
//! lateness is measured too but shown only as a hud percentile (not a plot), since it is the OS,
//! not the library. The headline is that math cost and its headroom under the 1 ms budget.
//!
-//! Streams live to a Rerun viewer; see showcase/viz/README.md for the WSL setup.
-//! Run with: cargo run --release -p multicalc-viz --example gradient_marbles
+//! Streams live to a Rerun viewer; see demos/README.md for the WSL setup.
+//! Run with: cargo run --release -p multicalc-demos --example gradient_marbles
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use multicalc::numerical_derivative::autodiff::AutoDiffMulti;
use multicalc::numerical_derivative::jacobian::Jacobian;
use multicalc::scalar::{Numeric, VectorFn};
-use multicalc_viz::loop_util::{LatencyRing, Pacer, commas};
-use multicalc_viz::{RerunSink, Rgba, VizError, VizSink};
+use multicalc_demos::loop_util::{LatencyRing, Pacer, commas};
+use multicalc_demos::{RerunSink, Rgba, VizError, VizSink};
use std::f64::consts::TAU;
use std::time::Instant;
@@ -162,7 +162,7 @@ fn main() -> Result<(), VizError> {
if cfg!(debug_assertions) {
eprintln!(
"WARNING: debug build — timing numbers are meaningless. \
- Re-run with: cargo run --release -p multicalc-viz --example gradient_marbles"
+ Re-run with: cargo run --release -p multicalc-demos --example gradient_marbles"
);
}
@@ -187,7 +187,7 @@ fn main() -> Result<(), VizError> {
.collect();
respawn(&mut marbles, &mut rng);
- let mut rr = RerunSink::live("multicalc-viz/gradient-marbles")?;
+ let mut rr = RerunSink::live("multicalc-demos/gradient-marbles")?;
rr.set_sequence("tick", 0);
rr.series_style(
"plots/ad_vs_analytic",
diff --git a/showcase/viz/examples/ik_servo.rs b/demos/examples/showcase/ik_servo.rs
similarity index 95%
rename from showcase/viz/examples/ik_servo.rs
rename to demos/examples/showcase/ik_servo.rs
index 0350dc6..c27a97d 100644
--- a/showcase/viz/examples/ik_servo.rs
+++ b/demos/examples/showcase/ik_servo.rs
@@ -13,16 +13,16 @@
//! result. The headline is the math cost and its headroom under the 1 ms budget, not a claim of
//! hard real-time on a desktop OS.
//!
-//! Streams live to a Rerun viewer; see showcase/viz/README.md for the WSL setup.
-//! Run with: cargo run --release -p multicalc-viz --example ik_servo
+//! Streams live to a Rerun viewer; see demos/README.md for the WSL setup.
+//! Run with: cargo run --release -p multicalc-demos --example ik_servo
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use multicalc::LevenbergMarquardt;
use multicalc::numerical_derivative::autodiff::AutoDiffMulti;
use multicalc::scalar::{Numeric, VectorFn};
-use multicalc_viz::loop_util::{LatencyRing, Pacer};
-use multicalc_viz::{RerunSink, Rgba, VizError, VizSink};
+use multicalc_demos::loop_util::{LatencyRing, Pacer};
+use multicalc_demos::{RerunSink, Rgba, VizError, VizSink};
use std::collections::VecDeque;
use std::f64::consts::TAU;
use std::time::Instant;
@@ -126,11 +126,11 @@ fn main() -> Result<(), VizError> {
if cfg!(debug_assertions) {
eprintln!(
"WARNING: debug build — timing numbers are meaningless. \
- Re-run with: cargo run --release -p multicalc-viz --example ik_servo"
+ Re-run with: cargo run --release -p multicalc-demos --example ik_servo"
);
}
- let mut rr = RerunSink::live("multicalc-viz/ik-servo")?;
+ let mut rr = RerunSink::live("multicalc-demos/ik-servo")?;
// Statics: stamp at tick 0 so they forward-fill across the run (see rerun-viz-gotchas).
rr.set_sequence("tick", 0);
diff --git a/showcase/viz/examples/newton_fractal.rs b/demos/examples/showcase/newton_fractal.rs
similarity index 95%
rename from showcase/viz/examples/newton_fractal.rs
rename to demos/examples/showcase/newton_fractal.rs
index 57157d4..defdd83 100644
--- a/showcase/viz/examples/newton_fractal.rs
+++ b/demos/examples/showcase/newton_fractal.rs
@@ -13,16 +13,16 @@
//! *instantaneous* `plots/solves_per_sec` — the hud headline reports the robust median over recent
//! frames instead, which is the library's real rate.
//!
-//! Streams live to a Rerun viewer; see showcase/viz/README.md for the WSL setup.
-//! Run with: cargo run --release -p multicalc-viz --example newton_fractal
+//! Streams live to a Rerun viewer; see demos/README.md for the WSL setup.
+//! Run with: cargo run --release -p multicalc-demos --example newton_fractal
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use multicalc::numerical_derivative::autodiff::AutoDiffMulti;
use multicalc::root_finding::NewtonSystem;
use multicalc::scalar::{Numeric, VectorFn};
-use multicalc_viz::loop_util::{LatencyRing, commas};
-use multicalc_viz::{RerunSink, Rgba, VizError, VizSink};
+use multicalc_demos::loop_util::{LatencyRing, commas};
+use multicalc_demos::{RerunSink, Rgba, VizError, VizSink};
use std::f64::consts::TAU;
use std::time::Instant;
@@ -90,11 +90,11 @@ fn main() -> Result<(), VizError> {
if cfg!(debug_assertions) {
eprintln!(
"WARNING: debug build — throughput numbers are meaningless. \
- Re-run with: cargo run --release -p multicalc-viz --example newton_fractal"
+ Re-run with: cargo run --release -p multicalc-demos --example newton_fractal"
);
}
- let mut rr = RerunSink::live("multicalc-viz/newton-fractal")?;
+ let mut rr = RerunSink::live("multicalc-demos/newton-fractal")?;
rr.set_sequence("frame", 0);
rr.series_style(
"plots/solves_per_sec",
diff --git a/showcase/viz/examples/support/ferris_outline.rs b/demos/examples/showcase/support/ferris_outline.rs
similarity index 100%
rename from showcase/viz/examples/support/ferris_outline.rs
rename to demos/examples/showcase/support/ferris_outline.rs
diff --git a/showcase/viz/examples/support/fourier_ferris_showcase.gif b/demos/examples/showcase/support/fourier_ferris_showcase.gif
similarity index 100%
rename from showcase/viz/examples/support/fourier_ferris_showcase.gif
rename to demos/examples/showcase/support/fourier_ferris_showcase.gif
diff --git a/showcase/viz/examples/support/gradient_marbles_showcase.gif b/demos/examples/showcase/support/gradient_marbles_showcase.gif
similarity index 100%
rename from showcase/viz/examples/support/gradient_marbles_showcase.gif
rename to demos/examples/showcase/support/gradient_marbles_showcase.gif
diff --git a/showcase/viz/examples/support/ik_servo_showcase.gif b/demos/examples/showcase/support/ik_servo_showcase.gif
similarity index 100%
rename from showcase/viz/examples/support/ik_servo_showcase.gif
rename to demos/examples/showcase/support/ik_servo_showcase.gif
diff --git a/showcase/viz/examples/support/newton_fractal_showcase.gif b/demos/examples/showcase/support/newton_fractal_showcase.gif
similarity index 100%
rename from showcase/viz/examples/support/newton_fractal_showcase.gif
rename to demos/examples/showcase/support/newton_fractal_showcase.gif
diff --git a/showcase/viz/plot.py b/demos/plot.py
similarity index 100%
rename from showcase/viz/plot.py
rename to demos/plot.py
diff --git a/showcase/viz/src/csv_sink.rs b/demos/src/csv_sink.rs
similarity index 100%
rename from showcase/viz/src/csv_sink.rs
rename to demos/src/csv_sink.rs
diff --git a/showcase/viz/src/lib.rs b/demos/src/lib.rs
similarity index 58%
rename from showcase/viz/src/lib.rs
rename to demos/src/lib.rs
index 00baf4b..761c3e0 100644
--- a/showcase/viz/src/lib.rs
+++ b/demos/src/lib.rs
@@ -1,10 +1,12 @@
-//! Thin, std-only Rerun visualization adapter for `multicalc`.
+//! Std-only visualization adapter for `multicalc`.
//!
-//! Maps core types to Rerun archetypes behind a small [`VizSink`] trait, with a Rerun backend
-//! ([`RerunSink`], live or recorded) and a CSV backend ([`CsvSink`]) for the `plot.py` fallback.
-//! A satellite crate: never a dependency of the core, excluded from bare-metal builds.
+//! Maps core types to a small [`VizSink`] trait, with a CSV backend ([`CsvSink`]) for the
+//! `plot.py` fallback and, behind the `rerun` feature, a Rerun backend ([`RerunSink`], live or
+//! recorded). With the feature off the crate builds headless, with no Rerun in the dependency
+//! tree. A satellite crate: never a dependency of the core, excluded from bare-metal builds.
mod csv_sink;
+#[cfg(feature = "rerun")]
mod rerun_sink;
mod sink;
@@ -13,10 +15,11 @@ pub mod loop_util;
pub use csv_sink::CsvSink;
pub use multicalc::scalar::Primal;
+#[cfg(feature = "rerun")]
pub use rerun_sink::RerunSink;
pub use sink::{Rgba, VizError, VizSink, VizSinkExt};
-#[cfg(test)]
+#[cfg(all(test, feature = "rerun"))]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -26,10 +29,10 @@ mod tests {
// Headless: `save` needs no viewer, so this runs in CI.
#[test]
fn record_writes_nonempty_rrd() -> Result<(), VizError> {
- let path = std::env::temp_dir().join("multicalc_viz_smoke.rrd");
+ let path = std::env::temp_dir().join("multicalc_demos_smoke.rrd");
let _ = std::fs::remove_file(&path);
- let mut sink = RerunSink::record("multicalc-viz/smoke", &path)?;
+ let mut sink = RerunSink::record("multicalc-demos/smoke", &path)?;
sink.set_sequence("iteration", 0);
sink.scalar("objective", 1.0)?;
sink.points2d("data", &[[0.0, 0.0], [1.0, 1.0]])?;
diff --git a/showcase/viz/src/loop_util.rs b/demos/src/loop_util.rs
similarity index 100%
rename from showcase/viz/src/loop_util.rs
rename to demos/src/loop_util.rs
diff --git a/showcase/viz/src/rerun_sink.rs b/demos/src/rerun_sink.rs
similarity index 100%
rename from showcase/viz/src/rerun_sink.rs
rename to demos/src/rerun_sink.rs
diff --git a/showcase/viz/src/sink.rs b/demos/src/sink.rs
similarity index 100%
rename from showcase/viz/src/sink.rs
rename to demos/src/sink.rs
diff --git a/deny.toml b/deny.toml
index 0241e25..b19d033 100644
--- a/deny.toml
+++ b/deny.toml
@@ -1,9 +1,9 @@
# Supply-chain checks for cargo-deny.
[graph]
-# multicalc-viz is a host-only, unpublished visualization satellite; its heavy Rerun tree is not
+# multicalc-demos is a host-only, unpublished visualization satellite; its heavy Rerun tree is not
# part of the shipped or bare-metal graph, so it is excluded from the supply-chain audit.
-exclude = ["multicalc-viz"]
+exclude = ["multicalc-demos"]
[advisories]
# Fail on known security advisories and yanked crates.
diff --git a/scripts/run_examples.sh b/scripts/run_examples.sh
new file mode 100644
index 0000000..48210d2
--- /dev/null
+++ b/scripts/run_examples.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# Build and run every headless basic demo, failing on the first nonzero exit. Basics
+# take no features and self-check with asserts; showcases are skipped (they need the
+# Rerun viewer and loop forever). Run from the repository root.
+set -euo pipefail
+
+basics=(
+ approximation
+ autodiff_scalars
+ curve_fit
+ differentiation
+ discretization
+ gaussian_integration
+ iterative_integration
+ jacobian_hessian
+ lie_groups
+ linear_algebra
+ ode
+ optimization_solvers
+ root_finding
+ svd
+ vector_field
+)
+
+for name in "${basics[@]}"; do
+ echo "== $name =="
+ cargo run -p multicalc-demos --example "$name" --no-default-features
+done
+
+echo "all ${#basics[@]} basics passed"
diff --git a/showcase/viz/Cargo.toml b/showcase/viz/Cargo.toml
deleted file mode 100644
index 266ade9..0000000
--- a/showcase/viz/Cargo.toml
+++ /dev/null
@@ -1,17 +0,0 @@
-[package]
-name = "multicalc-viz"
-version.workspace = true
-edition.workspace = true
-# Rerun requires Rust 1.92, above the workspace 1.85 floor, so this crate sets its own.
-rust-version = "1.92"
-license.workspace = true
-publish = false
-
-[dependencies]
-multicalc = { path = "../../crates/multicalc" }
-# Pinned to match the paired Rerun viewer version. Only the logging SDK is needed: `spawn()`
-# launches the external viewer on PATH, so the in-process `native_viewer` is not compiled in.
-rerun = { version = "=0.33.1", default-features = false, features = ["sdk"] }
-
-[lints]
-workspace = true
diff --git a/showcase/viz/README.md b/showcase/viz/README.md
deleted file mode 100644
index 5a30552..0000000
--- a/showcase/viz/README.md
+++ /dev/null
@@ -1,147 +0,0 @@
-# multicalc-viz
-
-A thin, std-only [Rerun](https://rerun.io) visualization adapter for `multicalc`. It maps core
-types to Rerun archetypes behind a small `VizSink` trait, with a Rerun backend (`live()` or
-`record(path)`) and a CSV backend for a matplotlib fallback.
-
-This is a satellite crate: it is never a dependency of the core library, is excluded from
-bare-metal builds and the default `cargo test`, and its dependency tree is excluded from the
-workspace supply-chain audit.
-
-## Versions
-
-Rerun SDK `=0.33.1` ⇄ viewer `0.33.1`. The SDK is exact-pinned; the viewer must match.
-
-## Viewer install (for the live example)
-
-`live()` spawns the external Rerun viewer found on PATH, so install it version-matched to the SDK:
-
-```
-cargo install rerun-cli --locked --version 0.33.1
-# or: pip install rerun-sdk==0.33.1
-# or: cargo binstall rerun-cli --version 0.33.1
-```
-
-## Running the examples
-
-Recorded (no viewer needed; writes a `.rrd` and a `.csv` to the temp dir):
-
-```
-cargo run -p multicalc-viz --example curve_fit_record
-```
-
-Open the printed `.rrd` in the viewer, or render the CSV fallback:
-
-```
-python showcase/viz/plot.py --x t
-```
-
-Live (on a normal host this spawns a local viewer; under WSL see the section below):
-
-```
-cargo run -p multicalc-viz --example curve_fit_live
-```
-
-## Showcases
-
-Four live demos, one per core module, each an attention-grabbing animated scene that markets the
-library's raw speed and accuracy. **Every number on screen is measured live** with
-`std::time::Instant` inside the demo — nothing is hardcoded. Run each with `--release` (mandatory
-for the timing readouts) and the viewer already up.
-
-Each demo advances its simulation on logical time (a fixed 1 ms per tick / one step per frame),
-so the numbers are deterministic and reproducible. An OS scheduling spike can make a tick display late or jitter but never changes
-what the demo computes.
-
-The figures below are representative of a modern desktop core (`x86_64`, `--release`).
-
-- **`ik_servo`** (optimization) — a 3-link arm runs a complete Levenberg-Marquardt IK solve, with
- exact autodiff Jacobians, every single millisecond. **Median solve ≈ 6 µs — under 1 % of the
- 1 ms budget — with zero missed ticks over 120,000 solves.**
-
- ```
- cargo run --release -p multicalc-viz --example ik_servo
- ```
-
- 
-
-- **`newton_fractal`** (root finding) — every pixel is a full Newton-system solve with an exact
- autodiff Jacobian, and the cubic's basins swirl as its roots orbit. **≈ 4 million Newton
- solves/sec on one core** (a 256×256 grid re-solved at ~60 fps), each converged root accurate to
- **≈ 5e-15**.
-
- ```
- cargo run --release -p multicalc-viz --example newton_fractal
- ```
-
- 
-
-- **`fourier_ferris`** (integration) — Gauss-Legendre quadrature computes the Fourier coefficients
- of Ferris's outline; a chain of epicycles then draws the crab. **≈ 600,000 quadrature node
- evaluations in ≈ 8 ms** at startup, with every coefficient matching the exact closed form to
- **≈ 1e-15**.
-
- ```
- cargo run --release -p multicalc-viz --example fourier_ferris
- ```
-
- 
-
-- **`gradient_marbles`** (autodiff) — 2,000 marbles across a 3D Himmelblau landscape, each steered
- by an exact autodiff gradient every millisecond. **2,000 exact gradients in under 3 µs per tick
- (~750,000 gradients/ms), and the autodiff-vs-analytic error is pinned at exactly 0.0** on screen.
-
- ```
- cargo run --release -p multicalc-viz --example gradient_marbles
- ```
-
- 
-
-## WSL usage (viewer on Windows)
-
-The live viewer is a GPU application; under WSL its virtualized GPU often cannot start it. Run
-the viewer on Windows instead (real GPU) and stream to it from WSL over gRPC.
-
-1. Enable mirrored networking so WSL and Windows share `localhost`. In `C:\Users\\.wslconfig`:
-
- ```ini
- [wsl2]
- networkingMode=mirrored
- ```
-
- Then from Windows PowerShell run `wsl --shutdown`, reopen WSL, and confirm:
-
- ```
- wslinfo --networking-mode # -> mirrored
- ```
-
-2. Install the viewer on Windows if needed, version-matched to the SDK (0.33.1):
-
- ```
- pip install rerun-sdk==0.33.1 # provides the `rerun` command
- # or download the prebuilt rerun.exe for 0.33.1
- ```
-
-3. Start the viewer on Windows (it listens on port 9876):
-
- ```
- rerun
- ```
-
-4. From WSL, run the live example. Under WSL it auto-detects the environment and streams to the
- Windows viewer over the shared localhost instead of spawning a local one:
-
- ```
- cargo run -p multicalc-viz --example curve_fit_live
- ```
-
- The Windows viewer from step 3 MUST already be running — under WSL the example connects to it
- and does not spawn one.
-
-On NAT networking (the WSL default) instead of mirrored, set `RERUN_VIZ_URL` to the Windows host,
-launch the viewer bound to `0.0.0.0`, and allow inbound TCP 9876 in Windows Firewall:
-
-```
-export RERUN_VIZ_URL="rerun+http://$(ip route show default | awk '{print $3}'):9876/proxy"
-cargo run -p multicalc-viz --example curve_fit_live
-```
diff --git a/tools/embedded-smoke/Cargo.toml b/tools/embedded-smoke/Cargo.toml
index 7a5d4a3..aa4da7d 100644
--- a/tools/embedded-smoke/Cargo.toml
+++ b/tools/embedded-smoke/Cargo.toml
@@ -13,6 +13,7 @@ full-smoke = []
[dependencies]
multicalc = { path = "../../crates/multicalc", default-features = false }
+multicalc-testkit = { path = "../testkit" }
cortex-m-rt = "0.7"
cortex-m-semihosting = "0.5"
# With the "exit" feature a panic prints its message and exits QEMU with a failure code.
diff --git a/tools/embedded-smoke/README.md b/tools/embedded-smoke/README.md
index 20b04fa..0cfe7cf 100644
--- a/tools/embedded-smoke/README.md
+++ b/tools/embedded-smoke/README.md
@@ -13,6 +13,37 @@ ABIs under QEMU. It is a dev-only crate (`publish = false`, not in
`default-members`) and is never built for a host target, and `cortex-m-rt` only
links for the `thumb*` triples.
+## Running
+
+```sh
+rustup target add thumbv7em-none-eabi thumbv7em-none-eabihf thumbv6m-none-eabi
+sudo apt-get install -y qemu-system-arm # provides qemu-system-arm
+cargo install cargo-binutils # provides cargo size, for the gate
+
+cargo run -p embedded-smoke --release --target thumbv7em-none-eabi
+cargo run -p embedded-smoke --release --target thumbv7em-none-eabihf
+cargo run -p embedded-smoke --release --target thumbv6m-none-eabi
+```
+
+Aliases: `cargo smoke-eabi`, `cargo smoke-eabihf`, `cargo smoke-m0`.
+
+## Targets and QEMU machine
+
+| Target | Codegen | QEMU machine | RAM |
+|-------------------------|------------------------|----------------|------|
+| `thumbv7em-none-eabi` | Cortex-M4, soft-float | `netduinoplus2`| 64K |
+| `thumbv7em-none-eabihf` | Cortex-M4, hard-float | `netduinoplus2`| 64K |
+| `thumbv6m-none-eabi` | Cortex-M0, CAS-free | `microbit` | 16K |
+
+The `thumbv7em` ABIs run on `netduinoplus2` (Cortex-M4, FPU, 64K RAM, flash at
+`0x08000000`). `thumbv6m` runs on `microbit` (Cortex-M0, nRF51, 16K RAM, flash
+at `0x0`) — a real M0 core, so the run now asserts both RAM-size and ISA
+fidelity: an oversized image or an out-of-ISA (ARMv7E-M) instruction faults just
+as it would on silicon. `build.rs` picks each target's memory map.
+
+The runners and `rustflags` (`-Tlink.x`, `--nmagic`) live in
+`.cargo/config.toml`; the per-target memory map is supplied by `build.rs`.
+
## Why a separate crate, not tests inside `multicalc`
These smoke tests cannot live next to the code they check. This is a full
@@ -55,23 +86,6 @@ The binary runs under QEMU semihosting:
> Note: `cargo run … | tee` masks the exit code unless `pipefail` is set. CI runs
steps under `bash -eo pipefail`, so a panic still fails the job.
-## Targets and QEMU machine
-
-| Target | Codegen | QEMU machine | RAM |
-|-------------------------|------------------------|----------------|------|
-| `thumbv7em-none-eabi` | Cortex-M4, soft-float | `netduinoplus2`| 64K |
-| `thumbv7em-none-eabihf` | Cortex-M4, hard-float | `netduinoplus2`| 64K |
-| `thumbv6m-none-eabi` | Cortex-M0, CAS-free | `microbit` | 16K |
-
-The `thumbv7em` ABIs run on `netduinoplus2` (Cortex-M4, FPU, 64K RAM, flash at
-`0x08000000`). `thumbv6m` runs on `microbit` (Cortex-M0, nRF51, 16K RAM, flash
-at `0x0`) — a real M0 core, so the run now asserts both RAM-size and ISA
-fidelity: an oversized image or an out-of-ISA (ARMv7E-M) instruction faults just
-as it would on silicon. `build.rs` picks each target's memory map.
-
-The runners and `rustflags` (`-Tlink.x`, `--nmagic`) live in
-`.cargo/config.toml`; the per-target memory map is supplied by `build.rs`.
-
## Stack high-water mark
`main.rs` measures peak stack by painting free stack below the entry frame with
@@ -89,20 +103,6 @@ This is a fixed-window scan below the SP, so it needs no linker symbol and is
identical across machines. If a target cannot yield a stable number, drop its
`STACK_HWM_BYTES` line and leave that ABI `.text`-gated only.
-## Running
-
-```sh
-rustup target add thumbv7em-none-eabi thumbv7em-none-eabihf thumbv6m-none-eabi
-sudo apt-get install -y qemu-system-arm # provides qemu-system-arm
-cargo install cargo-binutils # provides cargo size, for the gate
-
-cargo run -p embedded-smoke --release --target thumbv7em-none-eabi
-cargo run -p embedded-smoke --release --target thumbv7em-none-eabihf
-cargo run -p embedded-smoke --release --target thumbv6m-none-eabi
-```
-
-Aliases: `cargo smoke-eabi`, `cargo smoke-eabihf`, `cargo smoke-m0`.
-
## Size and stack gate
`ci/budgets.toml` holds per-target `.text` and stack budgets with a shared
diff --git a/tools/embedded-smoke/src/checks.rs b/tools/embedded-smoke/src/checks.rs
index b63fd8e..ea7c1cd 100644
--- a/tools/embedded-smoke/src/checks.rs
+++ b/tools/embedded-smoke/src/checks.rs
@@ -11,29 +11,18 @@ use multicalc::error::LinalgError;
use multicalc::linear_algebra::Matrix;
use multicalc::numerical_derivative::autodiff::{AutoDiffMulti, AutoDiffSingle};
use multicalc::numerical_derivative::derivator::DerivatorSingleVariable;
-use multicalc::scalar::{Numeric, VectorFn};
+use multicalc::scalar::Numeric;
use multicalc::scalar_fn;
+use multicalc_testkit::problems::Rosenbrock;
+use multicalc_testkit::tol::{Tol, close};
use crate::fixtures;
-/// Combined absolute/relative closeness, matching `close` in
-/// tools/oracle/src/load.rs: `|got - want| <= abs + rel * max(|got|, |want|)`.
-fn close(got: f64, want: f64, abs: f64, rel: f64) -> bool {
- (got - want).abs() <= abs + rel * got.abs().max(want.abs())
-}
-
/// Golden: the Rosenbrock least-squares minimizer must match the host oracle
/// golden (optimization/rosenbrock). Residuals `[10 (y - x^2), 1 - x]` are zero
/// at the minimum `(1, 1)`. Part of the full set only (thumbv7em).
#[cfg_attr(not(feature = "full-smoke"), allow(dead_code))]
pub fn lm_fit() {
- struct Rosenbrock;
- impl VectorFn<2, 2> for Rosenbrock {
- fn eval(&self, p: &[S; 2]) -> [S; 2] {
- let (x, y) = (p[0], p[1]);
- [S::from_f64(10.0) * (y - x * x), S::from_f64(1.0) - x]
- }
- }
let solver = LevenbergMarquardt::::default().with_patience(100);
let report = solver
.minimize(&Rosenbrock, &fixtures::ROSENBROCK_X0)
@@ -42,8 +31,10 @@ pub fn lm_fit() {
assert!(close(
report.solution[i],
fixtures::ROSENBROCK_SOLUTION[i],
- fixtures::ROSENBROCK_ABS,
- fixtures::ROSENBROCK_REL,
+ Tol {
+ abs: fixtures::ROSENBROCK_ABS,
+ rel: fixtures::ROSENBROCK_REL,
+ },
));
}
}
@@ -91,8 +82,10 @@ pub fn svd_golden() -> [f64; 3] {
assert!(close(
sv[i],
fixtures::SVD_3X3_SINGULAR_VALUES[i],
- fixtures::SVD_3X3_ABS,
- fixtures::SVD_3X3_REL,
+ Tol {
+ abs: fixtures::SVD_3X3_ABS,
+ rel: fixtures::SVD_3X3_REL,
+ },
));
}
[sv[0], sv[1], sv[2]]
diff --git a/tools/oracle/Cargo.toml b/tools/oracle/Cargo.toml
index 37358cc..c653b25 100644
--- a/tools/oracle/Cargo.toml
+++ b/tools/oracle/Cargo.toml
@@ -7,6 +7,7 @@ publish = false
[dependencies]
multicalc = { path = "../../crates/multicalc" }
+multicalc-testkit = { path = "../testkit" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
diff --git a/tools/oracle/src/load.rs b/tools/oracle/src/load.rs
index c36179c..3ea5516 100644
--- a/tools/oracle/src/load.rs
+++ b/tools/oracle/src/load.rs
@@ -74,7 +74,7 @@ pub fn to_vector_f32(v: &Value) -> Vector {
/// True when `got` is within `t` of `want`, using a combined absolute and
/// relative bound: `|got - want| <= abs + rel * max(|got|, |want|)`.
pub fn close(got: f64, want: f64, t: Tol) -> bool {
- (got - want).abs() <= t.abs + t.rel * got.abs().max(want.abs())
+ multicalc_testkit::tol::close(got, want, t.into())
}
/// Asserts a scalar matches the expected value within `t`.
diff --git a/tools/oracle/src/problems.rs b/tools/oracle/src/problems.rs
index 99e23c6..f2bb610 100644
--- a/tools/oracle/src/problems.rs
+++ b/tools/oracle/src/problems.rs
@@ -1,141 +1,14 @@
//! Named-problem registry.
//!
-//! Quadrature integrands and least-squares residuals are functions, so they
-//! cannot live in a JSON fixture. Instead a fixture names a problem by a stable
-//! string key, and both this module and the Python generator implement that key
-//! with the identical formula. Adding a problem means adding it on both sides
-//! under the same key.
+//! The generic integrands and least-squares residuals live in `multicalc-testkit`
+//! so the host oracle and the bare-metal smoke firmware share one definition; they
+//! are re-exported here so fixtures and tests still name them under
+//! `multicalc_oracle::problems`. The ODE right-hand sides stay local because they
+//! carry the integrator's concrete `f64`/`Vector` signature.
-use multicalc::linear_algebra::Vector;
-use multicalc::scalar::{Numeric, VectorFn};
-
-/// Returns the `f64` integrand for a quadrature key. Panics on an unknown key.
-///
-/// Gauss-Hermite folds an `e^{-x^2}` weight and Gauss-Laguerre an `e^{-x}` weight
-/// around this integrand; Legendre and the iterative rules integrate it directly.
-pub fn integrand_f64(key: &str) -> fn(f64) -> f64 {
- match key {
- "two_x" => |x| 2.0 * x,
- "quartic" => |x| 4.0 * x * x * x - 3.0 * x * x,
- "cube" => |x| x * x * x,
- "x_squared" => |x| x * x,
- "inv_1px2" => |x| 1.0 / (1.0 + x * x),
- "exp_neg" => |x| (-x).exp(),
- other => unreachable!("unknown integrand key {other:?}"),
- }
-}
-
-/// Returns the `f32` integrand for a quadrature key. Panics on an unknown key.
-pub fn integrand_f32(key: &str) -> fn(f32) -> f32 {
- match key {
- "two_x" => |x| 2.0 * x,
- "quartic" => |x| 4.0 * x * x * x - 3.0 * x * x,
- "cube" => |x| x * x * x,
- "x_squared" => |x| x * x,
- "inv_1px2" => |x| 1.0 / (1.0 + x * x),
- "exp_neg" => |x| (-x).exp(),
- other => unreachable!("unknown integrand key {other:?}"),
- }
-}
-
-/// Rosenbrock residual `[10*(x1 - x0^2), 1 - x0]`; the minimum is `x = [1, 1]`.
-pub struct Rosenbrock;
-
-impl VectorFn<2, 2> for Rosenbrock {
- fn eval(&self, x: &[S; 2]) -> [S; 2] {
- [S::from_f64(10.0) * (x[1] - x[0] * x[0]), S::ONE - x[0]]
- }
-}
-
-/// Moré-Garbow-Hillstrom trigonometric function (problem 26) in six variables.
-/// Its global minimum is zero.
-pub struct Trigonometric6;
-
-impl VectorFn<6, 6> for Trigonometric6 {
- fn eval(&self, x: &[S; 6]) -> [S; 6] {
- let n = S::from_f64(6.0);
- let mut cos_sum = S::ZERO;
- for &xj in x {
- cos_sum += xj.cos();
- }
- core::array::from_fn(|i| {
- n - cos_sum + S::from_f64((i + 1) as f64) * (S::ONE - x[i].cos()) - x[i].sin()
- })
- }
-}
-
-// Circle-fit target: 40 points sampled exactly on the circle of center (2, -1),
-// radius 3. The same formula is mirrored in the Python generator.
-const CIRCLE_POINTS: usize = 40;
-
-fn circle_px(i: usize) -> f64 {
- let angle = std::f64::consts::TAU * i as f64 / CIRCLE_POINTS as f64;
- 2.0 + 3.0 * angle.cos()
-}
-
-fn circle_py(i: usize) -> f64 {
- let angle = std::f64::consts::TAU * i as f64 / CIRCLE_POINTS as f64;
- -1.0 + 3.0 * angle.sin()
-}
-
-/// Fit a circle `[cx, cy, r]` to 40 fixed points, minimizing the geometric
-/// distance residual `sqrt((x-cx)^2 + (y-cy)^2) - r`. The recovered geometry is
-/// center `(2, -1)`, radius `3`.
-pub struct CircleFit;
+pub use multicalc_testkit::problems::*;
-impl VectorFn<3, CIRCLE_POINTS> for CircleFit {
- fn eval(&self, p: &[S; 3]) -> [S; CIRCLE_POINTS] {
- let (cx, cy, r) = (p[0], p[1], p[2]);
- core::array::from_fn(|i| {
- let dx = S::from_f64(circle_px(i)) - cx;
- let dy = S::from_f64(circle_py(i)) - cy;
- (dx * dx + dy * dy).sqrt() - r
- })
- }
-}
-
-// Gaussian-peaks target: two Gaussians [a, mu, sigma] sampled at 50 points.
-const GAUSS_POINTS: usize = 50;
-const GAUSS_TRUTH: [f64; 6] = [2.0, 3.0, 0.8, 1.5, 7.0, 1.2];
-
-fn gauss_t(i: usize) -> f64 {
- i as f64 * 10.0 / (GAUSS_POINTS as f64 - 1.0)
-}
-
-fn gauss_y(i: usize) -> f64 {
- let t = gauss_t(i);
- let mut y = 0.0;
- for k in 0..2 {
- let a = GAUSS_TRUTH[3 * k];
- let mu = GAUSS_TRUTH[3 * k + 1];
- let sigma = GAUSS_TRUTH[3 * k + 2];
- let z = (t - mu) / sigma;
- y += a * (-(z * z)).exp();
- }
- y
-}
-
-/// Fit two Gaussian peaks `[a, mu, sigma]` to a spectrum sampled at 50 points.
-/// The residual is `model(p) - y`, with `y` the two-peak signal at the true
-/// parameters `[2, 3, 0.8, 1.5, 7, 1.2]`.
-pub struct GaussianPeaks;
-
-impl VectorFn<6, GAUSS_POINTS> for GaussianPeaks {
- fn eval(&self, p: &[S; 6]) -> [S; GAUSS_POINTS] {
- core::array::from_fn(|i| {
- let t = S::from_f64(gauss_t(i));
- let mut model = S::ZERO;
- for k in 0..2 {
- let a = p[3 * k];
- let mu = p[3 * k + 1];
- let sigma = p[3 * k + 2];
- let z = (t - mu) / sigma;
- model += a * (-(z * z)).exp();
- }
- model - S::from_f64(gauss_y(i))
- })
- }
-}
+use multicalc::linear_algebra::Vector;
// ODE right-hand sides `y' = f(t, y)`, with the integrator's exact signature so the
// oracle test can pass `&fn`. Each key is mirrored in the Python generator.
@@ -162,30 +35,3 @@ pub fn ode_van_der_pol_mild(_t: f64, y: &Vector<2>) -> Vector<2> {
let mu = 1.0;
Vector::new([y[1], mu * (1.0 - y[0] * y[0]) * y[1] - y[0]])
}
-
-#[cfg(test)]
-mod tests {
- #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-
- use super::*;
-
- #[test]
- fn integrands_evaluate() {
- assert_eq!(integrand_f64("two_x")(3.0), 6.0);
- assert_eq!(integrand_f64("x_squared")(4.0), 16.0);
- assert_eq!(integrand_f32("cube")(2.0), 8.0);
- }
-
- #[test]
- fn residuals_vanish_at_the_solution() {
- // Each problem is a zero-residual fit at its true parameters.
- let r = Rosenbrock.eval(&[1.0, 1.0]);
- assert!(r.iter().all(|v: &f64| v.abs() < 1e-12));
-
- let c = CircleFit.eval(&[2.0, -1.0, 3.0]);
- assert!(c.iter().all(|v: &f64| v.abs() < 1e-12));
-
- let g = GaussianPeaks.eval(&GAUSS_TRUTH);
- assert!(g.iter().all(|v: &f64| v.abs() < 1e-12));
- }
-}
diff --git a/tools/oracle/src/schema.rs b/tools/oracle/src/schema.rs
index 176c046..96899fa 100644
--- a/tools/oracle/src/schema.rs
+++ b/tools/oracle/src/schema.rs
@@ -163,6 +163,15 @@ pub struct Tol {
pub rel: f64,
}
+impl From for multicalc_testkit::tol::Tol {
+ fn from(t: Tol) -> Self {
+ multicalc_testkit::tol::Tol {
+ abs: t.abs,
+ rel: t.rel,
+ }
+ }
+}
+
/// Per-`/` tolerance table, e.g. `"f64/host"` or `"f32/host"`.
/// Reserved targets: `host`, `aarch64`, `thumbv7em-eabi`, `thumbv7em-eabihf`,
/// `thumbv6m`. v0.7 populates only the `host` entries.
diff --git a/tools/testkit/Cargo.toml b/tools/testkit/Cargo.toml
new file mode 100644
index 0000000..72bac45
--- /dev/null
+++ b/tools/testkit/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "multicalc-testkit"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+publish = false
+
+[dependencies]
+multicalc = { path = "../../crates/multicalc", default-features = false }
+
+# Test-support crate: assert helpers panic by design and the structural checkers
+# unwrap on known-good factorizations, so opt out of the workspace no-panic lints
+# (mirrors what tests/ and embedded-smoke already do).
+[lints.rust]
+unsafe_code = "forbid"
+[lints.clippy]
+unwrap_used = "allow"
+expect_used = "allow"
+panic = "allow"
diff --git a/tools/testkit/src/lib.rs b/tools/testkit/src/lib.rs
new file mode 100644
index 0000000..dd747e3
--- /dev/null
+++ b/tools/testkit/src/lib.rs
@@ -0,0 +1,10 @@
+//! Shared test support: tolerance helpers, structural checkers, and the named
+//! problem registry, usable from host tests and the bare-metal smoke firmware.
+
+#![no_std]
+
+#[cfg(test)]
+extern crate std;
+
+pub mod problems;
+pub mod tol;
diff --git a/tools/testkit/src/problems.rs b/tools/testkit/src/problems.rs
new file mode 100644
index 0000000..0f677c8
--- /dev/null
+++ b/tools/testkit/src/problems.rs
@@ -0,0 +1,171 @@
+//! Named-problem registry.
+//!
+//! Quadrature integrands and least-squares residuals are functions, so they
+//! cannot live in a JSON fixture. Instead a fixture names a problem by a stable
+//! string key, and both this module and the Python generator implement that key
+//! with the identical formula. Adding a problem means adding it on both sides
+//! under the same key.
+
+use multicalc::scalar::{Numeric, ScalarFnN, VectorFn};
+
+/// Returns the `f64` integrand for a quadrature key. Panics on an unknown key.
+///
+/// Gauss-Hermite folds an `e^{-x^2}` weight and Gauss-Laguerre an `e^{-x}` weight
+/// around this integrand; Legendre and the iterative rules integrate it directly.
+pub fn integrand_f64(key: &str) -> fn(f64) -> f64 {
+ match key {
+ "two_x" => |x| 2.0 * x,
+ "quartic" => |x| 4.0 * x * x * x - 3.0 * x * x,
+ "cube" => |x| x * x * x,
+ "x_squared" => |x| x * x,
+ "inv_1px2" => |x| 1.0 / (1.0 + x * x),
+ "exp_neg" => |x| Numeric::exp(-x),
+ other => unreachable!("unknown integrand key {other:?}"),
+ }
+}
+
+/// Returns the `f32` integrand for a quadrature key. Panics on an unknown key.
+pub fn integrand_f32(key: &str) -> fn(f32) -> f32 {
+ match key {
+ "two_x" => |x| 2.0 * x,
+ "quartic" => |x| 4.0 * x * x * x - 3.0 * x * x,
+ "cube" => |x| x * x * x,
+ "x_squared" => |x| x * x,
+ "inv_1px2" => |x| 1.0 / (1.0 + x * x),
+ "exp_neg" => |x| Numeric::exp(-x),
+ other => unreachable!("unknown integrand key {other:?}"),
+ }
+}
+
+/// Transcendental `g(x, y, z) = y·sin x + x·cos y + x·y·eᶻ`.
+pub struct G;
+
+impl ScalarFnN<3> for G {
+ fn eval(&self, v: &[S; 3]) -> S {
+ v[1] * v[0].sin() + v[0] * v[1].cos() + v[0] * v[1] * v[2].exp()
+ }
+}
+
+/// Rosenbrock residual `[10*(x1 - x0^2), 1 - x0]`; the minimum is `x = [1, 1]`.
+pub struct Rosenbrock;
+
+impl VectorFn<2, 2> for Rosenbrock {
+ fn eval(&self, x: &[S; 2]) -> [S; 2] {
+ [S::from_f64(10.0) * (x[1] - x[0] * x[0]), S::ONE - x[0]]
+ }
+}
+
+/// Moré-Garbow-Hillstrom trigonometric function (problem 26) in six variables.
+/// Its global minimum is zero.
+pub struct Trigonometric6;
+
+impl VectorFn<6, 6> for Trigonometric6 {
+ fn eval(&self, x: &[S; 6]) -> [S; 6] {
+ let n = S::from_f64(6.0);
+ let mut cos_sum = S::ZERO;
+ for &xj in x {
+ cos_sum += xj.cos();
+ }
+ core::array::from_fn(|i| {
+ n - cos_sum + S::from_f64((i + 1) as f64) * (S::ONE - x[i].cos()) - x[i].sin()
+ })
+ }
+}
+
+// Circle-fit target: 40 points sampled exactly on the circle of center (2, -1),
+// radius 3. The same formula is mirrored in the Python generator.
+const CIRCLE_POINTS: usize = 40;
+
+fn circle_px(i: usize) -> f64 {
+ let angle = core::f64::consts::TAU * i as f64 / CIRCLE_POINTS as f64;
+ 2.0 + 3.0 * angle.cos()
+}
+
+fn circle_py(i: usize) -> f64 {
+ let angle = core::f64::consts::TAU * i as f64 / CIRCLE_POINTS as f64;
+ -1.0 + 3.0 * angle.sin()
+}
+
+/// Fit a circle `[cx, cy, r]` to 40 fixed points, minimizing the geometric
+/// distance residual `sqrt((x-cx)^2 + (y-cy)^2) - r`. The recovered geometry is
+/// center `(2, -1)`, radius `3`.
+pub struct CircleFit;
+
+impl VectorFn<3, CIRCLE_POINTS> for CircleFit {
+ fn eval(&self, p: &[S; 3]) -> [S; CIRCLE_POINTS] {
+ let (cx, cy, r) = (p[0], p[1], p[2]);
+ core::array::from_fn(|i| {
+ let dx = S::from_f64(circle_px(i)) - cx;
+ let dy = S::from_f64(circle_py(i)) - cy;
+ (dx * dx + dy * dy).sqrt() - r
+ })
+ }
+}
+
+// Gaussian-peaks target: two Gaussians [a, mu, sigma] sampled at 50 points.
+const GAUSS_POINTS: usize = 50;
+const GAUSS_TRUTH: [f64; 6] = [2.0, 3.0, 0.8, 1.5, 7.0, 1.2];
+
+fn gauss_t(i: usize) -> f64 {
+ i as f64 * 10.0 / (GAUSS_POINTS as f64 - 1.0)
+}
+
+fn gauss_y(i: usize) -> f64 {
+ let t = gauss_t(i);
+ let mut y = 0.0;
+ for k in 0..2 {
+ let a = GAUSS_TRUTH[3 * k];
+ let mu = GAUSS_TRUTH[3 * k + 1];
+ let sigma = GAUSS_TRUTH[3 * k + 2];
+ let z = (t - mu) / sigma;
+ y += a * Numeric::exp(-(z * z));
+ }
+ y
+}
+
+/// Fit two Gaussian peaks `[a, mu, sigma]` to a spectrum sampled at 50 points.
+/// The residual is `model(p) - y`, with `y` the two-peak signal at the true
+/// parameters `[2, 3, 0.8, 1.5, 7, 1.2]`.
+pub struct GaussianPeaks;
+
+impl VectorFn<6, GAUSS_POINTS> for GaussianPeaks {
+ fn eval(&self, p: &[S; 6]) -> [S; GAUSS_POINTS] {
+ core::array::from_fn(|i| {
+ let t = S::from_f64(gauss_t(i));
+ let mut model = S::ZERO;
+ for k in 0..2 {
+ let a = p[3 * k];
+ let mu = p[3 * k + 1];
+ let sigma = p[3 * k + 2];
+ let z = (t - mu) / sigma;
+ model += a * (-(z * z)).exp();
+ }
+ model - S::from_f64(gauss_y(i))
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn integrands_evaluate() {
+ assert_eq!(integrand_f64("two_x")(3.0), 6.0);
+ assert_eq!(integrand_f64("x_squared")(4.0), 16.0);
+ assert_eq!(integrand_f32("cube")(2.0), 8.0);
+ }
+
+ #[test]
+ fn residuals_vanish_at_the_solution() {
+ // Each problem is a zero-residual fit at its true parameters.
+ let r = Rosenbrock.eval(&[1.0, 1.0]);
+ assert!(r.iter().all(|v: &f64| v.abs() < 1e-12));
+
+ let c = CircleFit.eval(&[2.0, -1.0, 3.0]);
+ assert!(c.iter().all(|v: &f64| v.abs() < 1e-12));
+
+ let g = GaussianPeaks.eval(&GAUSS_TRUTH);
+ assert!(g.iter().all(|v: &f64| v.abs() < 1e-12));
+ }
+}
diff --git a/tools/testkit/src/tol.rs b/tools/testkit/src/tol.rs
new file mode 100644
index 0000000..2be7f73
--- /dev/null
+++ b/tools/testkit/src/tol.rs
@@ -0,0 +1,157 @@
+//! Closeness helpers in two contracts: an abs+rel `Tol` bound for scalars and
+//! vectors, and an absolute per-entry bound for the linear-algebra structural
+//! checkers. `Numeric` is in scope so `.abs()`/`.max()` resolve to `libm` and
+//! compile on bare metal.
+
+use multicalc::linear_algebra::{Matrix, Vector};
+use multicalc::scalar::Numeric;
+
+/// Absolute and relative thresholds for one comparison.
+#[derive(Clone, Copy, Debug)]
+pub struct Tol {
+ pub abs: f64,
+ pub rel: f64,
+}
+
+/// True when `got` is within `t` of `want`, using a combined absolute and
+/// relative bound: `|got - want| <= abs + rel * max(|got|, |want|)`.
+pub fn close(got: f64, want: f64, t: Tol) -> bool {
+ (got - want).abs() <= t.abs + t.rel * got.abs().max(want.abs())
+}
+
+/// Asserts a scalar matches the expected value within `t`.
+pub fn assert_scalar_close(got: f64, want: f64, t: Tol) {
+ assert!(close(got, want, t), "got {got}, want {want}, tol {t:?}");
+}
+
+/// Asserts every component of a vector matches within `t`.
+pub fn assert_vector_close(got: &Vector, want: &Vector, t: Tol) {
+ for i in 0..N {
+ assert!(
+ close(got[i], want[i], t),
+ "[{i}]: got {}, want {}, tol {t:?}",
+ got[i],
+ want[i]
+ );
+ }
+}
+
+/// Asserts two matrices agree entrywise within an absolute `tol`.
+pub fn assert_matrix_close(
+ actual: Matrix,
+ expected: Matrix,
+ tol: T,
+) {
+ for r in 0..R {
+ for c in 0..C {
+ assert!((actual[(r, c)] - expected[(r, c)]).abs() < tol);
+ }
+ }
+}
+
+/// Asserts every entry of `m` is within `tol` of the identity matrix.
+pub fn assert_identity(m: Matrix, tol: T) {
+ assert_matrix_close(m, Matrix::identity(), tol);
+}
+
+/// Factorizes `a`, checks the factors are triangular, and that they reconstruct `P·A`.
+pub fn lu_reconstructs(a: Matrix, tol: T) {
+ let f = a.lu().unwrap();
+ let l = f.l();
+ let u = f.u();
+ let perm = f.permutation();
+
+ // L is unit lower-triangular; U is upper-triangular.
+ for r in 0..N {
+ assert_eq!(l[(r, r)], T::ONE);
+ for c in (r + 1)..N {
+ assert_eq!(l[(r, c)], T::ZERO);
+ }
+ for c in 0..r {
+ assert_eq!(u[(r, c)], T::ZERO);
+ }
+ }
+
+ let pa = Matrix::::from_fn(|i, c| a[(perm[i], c)]);
+ assert_matrix_close(l * u, pa, tol);
+}
+
+/// Checks the Cholesky factor is lower-triangular with a positive diagonal and reconstructs `A`.
+pub fn cholesky_reconstructs(a: Matrix, tol: T) {
+ let l = a.cholesky().unwrap().l();
+ for r in 0..N {
+ assert!(l[(r, r)] > T::ZERO);
+ for c in (r + 1)..N {
+ assert_eq!(l[(r, c)], T::ZERO);
+ }
+ }
+ assert_matrix_close(l * l.transpose(), a, tol);
+}
+
+/// Checks the singular values are ordered and that `U·diag(σ)·Vᵀ` reconstructs `A`.
+pub fn svd_reconstructs(a: Matrix, tol: T) {
+ let f = a.svd().unwrap();
+ let (u, s, v) = (f.u(), f.singular_values(), f.v());
+
+ for k in 0..N {
+ assert!(s[k] >= T::ZERO);
+ if k + 1 < N {
+ assert!(s[k] >= s[k + 1]);
+ }
+ }
+
+ assert_identity(u.transpose() * u, tol);
+ assert_identity(v.transpose() * v, tol);
+
+ let recon = Matrix::::from_fn(|r, c| {
+ let mut acc = T::ZERO;
+ for k in 0..N {
+ acc += u[(r, k)] * s[k] * v[(c, k)];
+ }
+ acc
+ });
+ assert_matrix_close(recon, a, tol);
+}
+
+/// Verifies the four Moore–Penrose conditions for the pseudo-inverse of `a`.
+pub fn svd_moore_penrose(a: Matrix, tol: T) {
+ let ap = a.pseudo_inverse().unwrap();
+ assert_matrix_close(a * ap * a, a, tol);
+ assert_matrix_close(ap * a * ap, ap, tol);
+ let aap = a * ap;
+ assert_matrix_close(aap, aap.transpose(), tol);
+ let apa = ap * a;
+ assert_matrix_close(apa, apa.transpose(), tol);
+}
+
+fn max_abs(m: Matrix) -> f32 {
+ let mut max = 0.0_f32;
+ for r in 0..R {
+ for c in 0..C {
+ max = max.max(m[(r, c)].abs());
+ }
+ }
+ max
+}
+
+fn f32_scaled_tol(scale: f32, dim: usize) -> f32 {
+ 512.0 * f32::EPSILON * dim as f32 * scale.max(1.0)
+}
+
+/// Verifies the four Moore-Penrose conditions for an f32 pseudo-inverse with
+/// tolerances scaled by matrix magnitude and dimension.
+pub fn svd_moore_penrose_f32(a: Matrix) {
+ let ap = a.pseudo_inverse().unwrap();
+
+ let aap_a = a * ap * a;
+ assert_matrix_close(aap_a, a, f32_scaled_tol(max_abs(a), M.max(N)));
+
+ let apa_ap = ap * a * ap;
+ assert_matrix_close(apa_ap, ap, f32_scaled_tol(max_abs(ap), M.max(N)));
+
+ let aap = a * ap;
+ assert_matrix_close(aap, aap.transpose(), f32_scaled_tol(max_abs(aap), M));
+
+ let apa = ap * a;
+ assert_matrix_close(apa, apa.transpose(), f32_scaled_tol(max_abs(apa), N));
+}