Why SpectraFit-Core¶
Most of this site is reference material: what a function does, what a parameter means. This page is different. It makes the engineering case for why spectrafit-core exists as a new implementation rather than as a wrapper around an established one.
lmfit and scipy's curve_fit represent a mature, widely-used
pure-Python approach. They compose models in Python, dispatch in
Python, and leave the math to NumPy. spectrafit-core takes a different,
compiled-kernel approach to the same class of problem: it moves
composition and evaluation into a typed Rust core.
Neither approach is wrong. This page is about what the compiled approach actually buys. Each claim below points at the place in the codebase that enforces it, so you need not take the page's word for anything.
At a glance¶
Pure-Python approach (lmfit, curve_fit) |
spectrafit-core | |
|---|---|---|
| Model composition | Python objects, model1 + model2 |
Rust DAG (directed acyclic graph), compiled once |
| Per-iteration evaluation | Python dispatch loop | Single compiled Rust loop |
| Jacobian | Numerical (finite-difference) by default | Hand-derived closed form for all but eleven kernels; those eleven use a central difference |
| Solver selection | Named by the caller | Named by the caller too (solver="lm" by default) — the opt-in solver="auto" is what routes on graph structure; 10 strategies available |
| Multi-dataset fitting | Hand-rolled per project | Native: stacked-slice and N-D, exact degrees-of-freedom (DOF) accounting |
| Speed/accuracy claims | Accuracy verified in lmfit's own test suite against NIST StRD — the Statistical Reference Datasets of the US National Institute of Standards and Technology | Accuracy and speed recomputed against independent oracle backends every run, behind a regression gate (3–7 axes; see below) |
A benchmark that verifies itself, not just asserts¶
The speed and accuracy numbers on the Performance page aren't self-reported. Every benchmark case is cross-checked against independent parity oracles, and a subset is checked further against the externally certified datasets of NIST StRD.
Oracle coverage is per-backend, not universal. Each backend runs only the cases whose model it can express, and the optional JAX/optimistix backend runs only when it is installed at all.
The shipped benchmark ladder's 50-repetition run recorded 151 cases in
total. That run is 2026-08-16_run_037, and its manifest lives at
reproducibility/ladder/rungs/rung_050/manifest.json — the artefact
published on the public mirror, not the placeholder
Performance page. Of those 151 cases, lmfit (the
baseline) and spectrafit itself covered all 151, the three scipy-ls-*
variants covered 147 each, and JAX covered 127. Each run records its own
per-backend coverage in its own artefacts, so you can check these numbers
rather than take them on trust. Never assume an oracle ran everywhere.
Those are the shipped run's counts, not today's. The case catalog keeps
growing. At this page's current commit, oracles.cases.build_catalog()
returns 154 cases, 130 of them JAX-eligible, not 151. The derived
headline figures are the geomean speedup, the win counts, and the per-case
\(\lvert \Delta r^2 \rvert\). Each is only meaningful against a fresh run
over the current catalog, and producing one means re-running the
benchmark, which is out of scope for this documentation pass. Until that
run ships, read 151 / 147 / 127 as that shipped artefact's counts, not
as a statement about the current catalog.
A regression gate then judges the run as a whole (oracles.cli gate;
the thresholds are the defaults in python/oracles/cli.py). This page is
the canonical description of the gate's axis count. That count is not
fixed, so other pages should link here rather than restate a number.
_gate_evaluate always emits three axes, then appends up to four more. It
appends each of those four only when its backing evidence actually exists,
so nothing passes by absence. _gate_self_perf_check adds self_perf
only when a perf baseline is pinned. _append_bool_axis adds
model_selection, sigma_calibration and speed_inference one at a
time, each only when the matching evidence — nested-adequacy,
\(\sigma\)-calibration and speed-inference respectively — is present and not
skipped. A single gate run therefore carries a minimum of three axes and
a maximum of seven:
| Axis | Always present? | What it actually measures | Default threshold |
|---|---|---|---|
speed |
always | Aggregate geometric-mean speedup over the baseline across every case | \(\geq 1.0\) |
accuracy |
always | Largest \(\lvert \Delta r^2 \rvert\) between spectrafit and the baseline on any one case, with the optfn category excluded |
\(\leq 10^{-3}\) |
regressions |
always | Cases where any supported backend failed to converge — a suite-health count, not a spectrafit-vs-baseline comparison | 0 |
self_perf |
only when a perf baseline is pinned | spectrafit's current geomean speedup vs its own pinned self-baseline, as a ratio | ratio floor \(1 - \text{perf\_tolerance}\) |
model_selection |
only when nested-adequacy evidence is supplied | BIC recovers the true model order | boolean pass |
sigma_calibration |
only when calibration evidence is supplied and not skipped | bootstrap coverage of the reported uncertainty | boolean pass |
speed_inference |
only when speed-inference evidence is supplied and not skipped | statistical significance of the measured speed advantage | boolean pass |
Read those axes literally, because their scope matters. The speed axis is an aggregate, so a single case where spectrafit is slower does not fail the run. The accuracy axis is a tolerance, so a case with \(\lvert \Delta r^2 \rvert = 5 \times 10^{-4}\) passes cleanly. The gate sets a floor on the run as a whole; it is not a per-case guarantee.
NIST StRD is a separate mechanism, not a fourth gate axis. It is trust-ledger wire W8, and it governs the report's evidence rung — how far the credibility ladder can be climbed — rather than whether the run passes or fails.
The shipped ladder's deepest run, at 50 repetitions, reached
credibility rung 5
(reproducibility/ladder/rungs/rung_050/trust.json, run
2026-08-16_run_037). That run audits all 21 registered claims in the
claim ledger (oracles.audit.claims.CLAIM_REGISTRY). A claim counts as
audited only when its backing verification wire passed
(oracles.audit.claims.audited_count). The run carries 19 verification
wires of its own (W1, W2a–W2d, W3–W11, S1–S5); of those,
17 passed, 2 were skipped and 0 failed. The two skips are W5
render-fidelity, which only runs in continuous integration (CI), and S3
doc-owner-truth.
NIST alone does not unlock rung 5. _compute_rung
(python/oracles/audit/runner.py) requires W8 (NIST StRD) and W10
(\(\sigma\)-calibration) and W11 (speed inference) to all pass, on top
of the core ladder already clearing rung 4.
Benchmark engine describes how
the gate is computed (oracles.cli run / gate, with
oracles.models.MODEL_REGISTRY as the parity oracle).
Limitations sets out what this verification deliberately
does not yet cover, because the goal here is an honestly-scoped claim
rather than a maximal one. This is the headline differentiator, not an
afterthought: a speed number nobody can independently re-derive is a
marketing claim, not an engineering one.
One scope limit on that harness deserves stating plainly, because the
paragraphs above are easy to read as promising more. The rig in
python/oracles/ is backend-agnostic by construction: a backend is a
class implementing is_supported, build, run and extract, and only
one adapter in the whole harness is specific to spectrafit-core. Anyone
publishing a fitting library can therefore, in principle, add a backend
and obtain the same cross-implementation timings, parameter agreement and
NIST comparison.
But no external adopter has done so, and the non-spectroscopic cases in the suite are synthetic. Reuse of the harness against another library is therefore a documented capability rather than a demonstrated one. Read it that way until someone outside this project runs it.
A compiled kernel, not a Python dispatch loop¶
lmfit's model1 + model2 builds a binary tree of Python objects, and
every solver iteration walks that tree recursively at Python speed. That
is the pure-Python approach's natural shape, and a perfectly reasonable
one for smaller problems or rapid prototyping.
spectrafit-core instead compiles a model graph once into a Rust
struct, then evaluates it as a single O(N_nodes × N_x) loop with no
per-iteration Python round-trips. The
Model Composition — DAG IR
page draws the direct comparison; DAG IR here means the
directed-acyclic-graph intermediate representation that a model compiles
to.
This is the single biggest source of the speed numbers on the Performance page. Solving runs as a Rust hot loop rather than an interpreted one, whichever of the ten solver strategies below is driving it.
Analytical Jacobians, not finite differences by default¶
The pure-Python approach typically falls back to numerical
(finite-difference) Jacobians, because deriving and maintaining analytical
ones by hand for every model is real ongoing work. spectrafit-core's
built-in kernels instead ship a hand-derived Jacobian in closed form for
all but eleven kernels — eleven of the 37 Rust kernels registered in
the model_manifest! macro in crates/spectrafit-types/src/types.rs.
Two denominators are in play here, and conflating them would overstate the
result. The Python parity oracle (oracles.models.MODEL_REGISTRY)
currently registers 35 of those 37 shapes, and
tests/parity/test_kernel_parity.py parameterises over that registry.
Kernel parity is therefore proven for 35 of the 37 kernels, not for "all"
of them. For a worked closed-form example, see
crates/spectrafit-models/src/gaussian.rs.
The eleven exceptions — asym_ir, breit_wigner, harmonic_ir, kww,
log_normal, moffat, pearson7, split_gaussian, split_pearson7,
students_t, tauc — each hand-roll a central-difference Jacobian in
their own jacobian implementation. That costs 2 · N_params extra model
evaluations per Jacobian. None of the eleven inherits the Model trait's
forward-difference default, which crates/spectrafit-models/src/lib.rs
documents but no shipped kernel uses.
Finite-difference is a real, working fallback, not a placeholder. It is
what makes a brand-new kernel usable on day one; see
Adding a Model.
It stays the fallback rather than the primary path for one reason: a
closed-form formula does not need those 2 · N_params extra evaluations.
Ten solver strategies, with opt-in structure-aware routing¶
By default, the caller names the solver family. FitOptions.solver is
"lm" — Levenberg-Marquardt — unless you say otherwise, and
Solver::parse (crates/spectrafit-solver/src/dispatch.rs) accepts ten
strings: lm, lm-legacy, trf, geodesic, dogleg, newton-cg,
global, varpro, irls[:weight] and auto. Deriving the solver from
the graph is what the opt-in "auto" does; it is not the default.
When you do ask for solver="auto", it inspects the model graph's
structure — separability, tied parameters, active bounds — and routes on
what it finds. A graph that is separable, with no tied parameters and no
bounds on the nonlinear parameters, goes to VarPro (Variable Projection).
Everything else goes to Trust-Region-Reflective (TRF), the Coleman–Li
bound-scaled variant of Levenberg-Marquardt.
Under "auto" there is no plain-Levenberg-Marquardt branch at all,
because bound scaling is set for Auto on every non-VarPro path
(bound_scaling: solver == Solver::Trf || solver == Solver::Auto). That is
a deliberate choice: "auto" previously did fall through to plain LM,
but always routing the non-VarPro case through the bound-scaled variant
means "auto" never has to guess whether bounds are actually in play to
decide which behavior to give you.
The remaining strategies are explicit choices, for cases "auto" cannot
detect from graph topology alone. Reach for dogleg and newton-cg for
research-grade trust-region tuning, geodesic for sloppy or degenerate
multi-peak surfaces, global for genuinely multi-modal objectives, and
the three irls (Iteratively Reweighted Least Squares) weight variants
for heavy-tailed outliers. "auto" never selects global or irls,
because the signal for them lives in the data rather than in the graph.
Choosing a Solver carries the full decision guide, with literature references for each method. These are established numerical-optimization techniques (Coleman & Li 1996, Powell 1970, Steihaug 1983, Transtrum & Sethna 2012), not reinvented ones. What's new is having all of them behind one structure-aware dispatch, in a single compiled core.
Multi-dataset and native N-D fitting, not a hand-rolled loop¶
A pure-Python project usually builds joint fitting across multiple spectra by hand, once per use case: a loop over datasets, parameters shared manually, and degrees-of-freedom accounting the caller has to get right themselves.
spectrafit-core makes two distinct joint-fitting patterns native, both with exact DOF accounting. Stacked slices covers many lower-dimensional datasets that share a subset of parameters, such as a series of spectra sharing one instrument-broadening width. Native N-D covers one model evaluated over a genuinely multi-dimensional coordinate space, such as a 2-D Gaussian map; there the graph itself fixes the dimensionality, which you never declare separately.
For runnable examples of each, see Multi-Dataset Joint Fitting and N-Dimensional Fitting.
Typed all the way down¶
The model graph, parameters, fit options and results are Pydantic models
on the Python side, and a serde-tagged IR on the Rust side. Two
mechanisms keep the two sides in sync, rather than convention alone: a
compile-time exhaustiveness gate (spectrafit-builder's E0004 check)
and a schema-parity test (tests/parity/test_schema_parity.py).
A new model variant that isn't wired into both sides fails cargo test.
It does not fail silently at runtime. See
Parameter Model and
Model Reference for the resulting contract,
and Adding a Model for what the gate actually
catches.
Next steps¶
- Get it running — Installation covers
the
uv/maturinbuild and the test-suite check that confirms it worked. - See the compiled kernel fit real data — Quickstart fits a single Gaussian peak in a few lines against the public API.
- Go deeper on solver routing — Solver selection
covers what
solver="auto"actually inspects on the graph before routing to VarPro or the LM family.