7.0 KiB
Feature: Runtime Integrator Selection
Status: completed (2026-09-30)
Measured on the existing criterion benches, constants to methods: bench_orbit
-0.73%, bench_simple_1d +0.42%, bench_interpolation_1d -0.09%. Criterion
calls all three within its noise threshold, so the inlining claim below holds.
Overview
Which integrator runs a problem is fixed at compile time, because Integrator<D>
keeps its structural facts in associated constants:
pub trait Integrator<const D: usize> {
const ORDER: usize;
const STAGES: usize;
const ADAPTIVE: bool;
const DENSE: bool;
const MAIN_STAGES: usize = Self::STAGES;
const EXTRA_STAGES: usize = 0;
const TOTAL_DENSE_STAGES: usize = Self::MAIN_STAGES + Self::EXTRA_STAGES;
}
Problem is generic over S, and Problem::solve reads S::ADAPTIVE to choose
its stepping branch and S::EXTRA_STAGES / S::TOTAL_DENSE_STAGES to decide
whether lazy stages still need computing. A value cannot answer those questions —
only a type can.
So the obvious workaround does not work. An enum wrapping the four integrators
cannot implement Integrator<D>, because it would have to give one
EXTRA_STAGES for all of them, and Vern7 has six where Dormand-Prince has none.
Box<dyn Integrator<D>> is not available either, and will not become available:
step<T: Scalar, P> and interpolate<T: Scalar> are generic methods, so the
trait is not object-safe. Erasing the scalar would defeat the genericity that
makes autodiff work. The enum is the practical route, not dyn.
Why This Feature Matters
This surfaced building astrokit on top of this crate. Three things a dependent
wants and cannot have:
-
Accuracy presets that include a method.
Accuracy::Highshould mean "tight tolerances and a high-order method", since the two belong together — a 7th order method at 1e-6 is waste, and a 3rd order method at 1e-12 is hopeless. As it stands a preset can only set tolerances, and the method has to be named separately as a type parameter. -
An integrator named in a configuration file.
astrokitis built for a study where every run parameter must be declared in a committed file and every propagation must record enough to recreate itself.integrator: "Vern7"deserialized from that file cannot select anything, because the choice is a type. The integrator ends up recorded in the provenance but not settable from it, which is backwards. -
A heterogeneous collection. Benchmarking or comparing methods over the same problem means either a macro over method types or duplicated code per method, where a
Vec<Method>would do.
None of these is fatal. astrokit has settled on Vern7 as a type-level default,
which is the right method for its accuracy range anyway. But the constraint is
structural rather than deliberate, and it is worth fixing before more dependents
grow around it.
Dependencies
None, though it interacts with Tolerances Trait: an enum wrapper needs to forward tolerance setters too, which is much easier once those are trait methods.
Implementation Approach
Step 1: constants become methods
The structural facts move from associated constants to methods taking &self:
pub trait Integrator<const D: usize> {
fn order(&self) -> usize;
fn stages(&self) -> usize;
fn adaptive(&self) -> bool;
fn dense(&self) -> bool;
fn main_stages(&self) -> usize { self.stages() }
fn extra_stages(&self) -> usize { 0 }
fn total_dense_stages(&self) -> usize { self.main_stages() + self.extra_stages() }
// step, interpolate, compute_extra_stages unchanged
}
This costs nothing measurable. Every use of these constants today is already a runtime comparison, not a compile-time size:
if S::ADAPTIVEinProblem::solvebecomesif self.integrator.adaptive(), which monomorphizes to the same constant and folds the same way for a concrete integrator.borrowed.len() < 2 + S::TOTAL_DENSE_STAGEScompares against aVeclength.&dense[2..2 + S::MAIN_STAGES]slices aVec.
None of them appears in type position, so nothing needs the values to be consts.
For a concrete S the method calls inline to the same constants they are now.
This is a breaking change to anyone implementing Integrator by hand — but a
mechanical one, and this crate has no external implementors.
Step 2: an enum in this crate
With step 1 done, an enum can dispatch:
/// An integrator chosen at runtime.
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum Method<const D: usize> {
BS3(BS3<D>),
DormandPrince45(DormandPrince45<D>),
Vern7(Vern7<D>),
Rosenbrock23(Rosenbrock23<D>),
}
impl<const D: usize> Integrator<D> for Method<D> {
fn extra_stages(&self) -> usize {
match self {
Method::Vern7(m) => m.extra_stages(),
Method::BS3(m) => m.extra_stages(),
..
}
}
// and so on, including step and interpolate
}
Each method body is a match forwarding to the wrapped integrator. Verbose, and
a good candidate for a macro over the variant list.
Worth deriving Serialize/Deserialize on a companion MethodName enum so a
method can be named in a configuration file, and adding
MethodName::instantiate(&self) -> Method<D>. That is what makes point 2 above
work.
Step 3: presets
Once a method is a value, an accuracy preset can carry one, pairing a tolerance with a method whose order suits it. That belongs in whichever crate owns the notion of a preset — probably this one, given Default Algorithm Selection is already on the roadmap and needs the same machinery.
The cost of not doing step 1
If the constants must stay, the fallback is a parallel object-ish layer: a second
trait with &self methods, blanket-implemented for everything that implements
Integrator, and Problem generic over that instead. It works, it duplicates the
trait surface, and every implementor carries both. Step 1 is cheaper and cleaner.
Testing Requirements
- Every existing test passes after step 1, with method calls substituted for constant reads.
- A benchmark confirms no regression from the constants becoming methods — the
claim is that they inline, and that should be measured rather than assumed, on
the existing
simple_1dandorbitbenches. Methodgives bit-identical results to the integrator it wraps, for each variant, on a problem with dense output interpolation (which exercises the lazy-stage path whereextra_stagesmatters).- A method name round-trips through serde and instantiates the right variant.
Success Criteria
Problemcan be constructed with an integrator chosen at runtime.- No measurable performance change on the existing criterion benches.
- A dependent crate can offer an accuracy preset that selects both a tolerance and a method, and can read a method name out of a configuration file.
Effort: Medium — step 1 is small and mechanical, step 2 is repetitive