Extending SpectraFit-Core¶
SpectraFit-Core has two extension points that contributors reach for most often: adding a model (a new peak or background kernel) and adding a solver family (a new optimisation algorithm). Both are deliberately multi-crate changes. The crate-per-concern layout means a new solver family is a new crate rather than a patch to an existing one, and the compile-time gates described below mean a half-finished extension fails to build instead of failing at run time in a user's fit.
This page lists every touchpoint. Skipping one is not a silent partial success — it is a failed build or a red pre-commit hook, by design.
Adding a model¶
A model kernel travels from the Rust core out to the Python surface and the
benchmark registry. All six steps belong in the same commit — but do not
count on a hook to tell you when one is missing. pre-merge-schema-sync covers
the nine registered serde/Pydantic struct pairings, and ModelType is
deliberately not one of them (step 4). For a model-add, the thing that
catches a half-finished chain is CI, not your commit.
- Rust kernel — implement the evaluation in
crates/spectrafit-models/.Model::jacobianships a working forward-difference default, so a new kernel is usable without writing a derivative at all. Overridejacobian(and, for the hot path,jacobian_into) only when you want an analytical Jacobian for better performance or precision — it's an optimisation, not a requirement: a real and growing minority of built-in kernels rely on the finite-difference default instead (see Architecture for the current roster). ModelTypeStr— add the wire-string variant incrates/spectrafit-types/src/types.rs. Downstream hand-lists are derived fromModelTypeStr::ALLrather than repeated, so this is the single place the variant set is declared.spectrafit-buildergate —crates/spectrafit-builder/is a compile-time exhaustiveness gate that nothing on the runtime path depends on. A variant that is not handled everywhere fails to compile (E0004) rather than reaching a user.- Python
ModelType— mirror the variant inpython/spectrafit_core/models.py(a hand-writtenStrEnum; nothing generates it). Nothing blocks a plaingit commitif you forget: thepre-merge-schema-synchook explicitly excludesModelTypefrom its checks (see the "Scope" note in its own header), and.claude/hooks/enforce-modeltype-parity.shis a Claude-Code-onlyPostToolUsehook that only warns, so it never fires for a human runninggit commit. What actually catches a mismatch istests/parity/test_schema_parity.py::test_model_type_enum_parity, which asserts the Python enum's values equal the Rust-exposedModelTypeStr::ALLset — it fails in CI, not at commit time, if the two drift apart. - Benchmark registry — register the shape once in
oracles.models.MODEL_REGISTRY. Backends read the registry; none of them may keep a private map of their own. - Case recipe — add at least one benchmark case exercising the new kernel, so the model is covered by the cross-backend comparison rather than only by unit tests.
Add a row for the kernel to docs/reference/models/index.md, wrapping its
formula in <!-- formula:KEY --><!-- /formula --> markers (KEY = the
registry key) rather than hand-typing the LaTeX between them:
docs/_render_model_formulas.py overwrites that span from
MODEL_REGISTRY[KEY].formula_latex on every docs_build, so a hand-typed
formula is silently discarded on the next build. Note that mathematical
notation in Python docstrings is real LaTeX ($\chi^2$, $\pm$), never a
hand-typed Unicode approximation — and that any Field(description=...)
serialised into BenchReport is contract text, so changing one requires
uv run poe contract_regen.
Adding a solver family¶
- New crate — create
crates/spectrafit-<family>/. Respect the dependency direction: thepre-merge-daghook rejects a back-edge, so place code to satisfy the DAG rather than introducing one as a shortcut. Three of the existing families (dogleg,newton-cg,levenberg-marquardt) build on the sharedspectrafit-trust-regioncore;varproinstead sits onspectrafit-graphandspectrafit-models. Reuse whichever fits, or depend only onspectrafit-typesif neither does. Solvervariant — add the variant to theSolverenum incrates/spectrafit-solver/src/dispatch.rsand extend its string parser. Unrecognised solver strings are rejected rather than silently falling back to a default, so the parser arm is required, not optional.- Routing — decide whether
Solver::Autoshould ever select the new family.Autocurrently routes to VarPro when the graph is separable, unconstrained and untied, and to the faer LM family otherwise. ExtendingAutois a behaviour change for every existing user who did not name a solver explicitly; prefer opt-in by name first. - Binding audit — every new
#[pyfunction]registered incrates/spectrafit-core/src/lib.rsand every newSolver::variant needs a one-line entry inscripts/binding_audit_notes.toml, followed byuv run poe audit_bindings_regen.scripts/audit_bindings.pyenforces this in CI. - Parity test — add the family to the cross-implementation parity tests
under
crates/spectrafit-solver/tests/. A new solver that agrees with no existing one on a shared problem is a bug report, not a feature.
Before you push¶
Run the loop for every stack your change touches — a model or solver change
usually touches at least Rust and Python. The Rust half is
cargo fmt --check, the workspace-wide clippy and check invocations listed
in CLAUDE.md,
then:
uv run --with maturin maturin develop --release # Python won't see Rust changes without this
uv run poe lint_ci
uv run poe scenario_smoke # fast cross-stack check
uv run poe benchmark_gate # spectrafit-vs-lmfit regression gate
Rebuilding the extension is the step most often forgotten. A Rust-only test run passes against changed Rust while the installed Python extension is still the old build, so the Python suite silently tests stale code.
Getting help¶
Open an issue at github.com/Anselmoo/SpectraFit-Core/issues. For an extension you intend to contribute back, opening the issue before writing the crate is worthwhile — the DAG and gate constraints above are easier to design around than to retrofit.
Next steps¶
- Wire a new model into the benchmark — Benchmark engine covers the registry entry and case recipe that steps 5-6 above only name in passing.
- Browse the crate you just extended — Rust workspace overview
opens the generated rustdoc per crate, plus the dependency graph the
pre-merge-daghook enforces.