# 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` keeps its structural facts in associated constants: ```rust pub trait Integrator { 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`, because it would have to give one `EXTRA_STAGES` for all of them, and Vern7 has six where Dormand-Prince has none. `Box>` is not available either, and will not become available: `step` and `interpolate` 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: 1. **Accuracy presets that include a method.** `Accuracy::High` should 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. 2. **An integrator named in a configuration file.** `astrokit` is 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. 3. **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` 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](40-tolerances-trait.md): 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`: ```rust pub trait Integrator { 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::ADAPTIVE` in `Problem::solve` becomes `if self.integrator.adaptive()`, which monomorphizes to the same constant and folds the same way for a concrete integrator. - `borrowed.len() < 2 + S::TOTAL_DENSE_STAGES` compares against a `Vec` length. - `&dense[2..2 + S::MAIN_STAGES]` slices a `Vec`. 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: ```rust /// An integrator chosen at runtime. #[non_exhaustive] #[derive(Debug, Clone, Copy)] pub enum Method { BS3(BS3), DormandPrince45(DormandPrince45), Vern7(Vern7), Rosenbrock23(Rosenbrock23), } impl Integrator for Method { 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`. 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](13-default-algorithm-selection.md) 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_1d` and `orbit` benches. - `Method` gives bit-identical results to the integrator it wraps, for each variant, on a problem with dense output interpolation (which exercises the lazy-stage path where `extra_stages` matters). - A method name round-trips through serde and instantiates the right variant. ## Success Criteria - `Problem` can 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