Files
ordinary-diffeq/roadmap/features/40-tolerances-trait.md
T

130 lines
4.7 KiB
Markdown

# Feature: Tolerances Trait
**Status**: completed (2026-09-30)
## Overview
Error tolerances are set through inherent builder methods on each integrator
rather than through a trait:
```rust
impl<const D: usize> Vern7<D> where Vern7<D>: Integrator<D> {
pub fn a_tol(mut self, a_tol: f64) -> Self { .. }
pub fn a_tol_full(mut self, a_tol: SVector<f64, D>) -> Self { .. }
pub fn r_tol(mut self, r_tol: f64) -> Self { .. }
}
```
`DormandPrince45`, `BS3` and `Rosenbrock23` each have the same four methods,
written out separately. There is no trait bound that says "this integrator has
tolerances", so a downstream crate generic over `S: Integrator<D>` cannot set
them, and cannot read back what they were.
## Why This Feature Matters
A library wrapping this one wants to offer accuracy presets:
```rust
Propagation::new(..).accuracy(Accuracy::Medium) // sets a_tol and r_tol
Propagation::new(..).a_tol(1e-10)
```
That is impossible over a generic `S`. `astrokit` worked around it by declaring
its own trait and implementing it for three of the four integrators:
```rust
pub trait Tolerances<const D: usize>: Integrator<D> {
fn with_a_tol(self, a_tol: f64) -> Self;
fn with_a_tol_per_component(self, a_tol: SVector<f64, D>) -> Self;
fn with_r_tol(self, r_tol: f64) -> Self;
}
impl<const D: usize> Tolerances<D> for Vern7<D> { .. }
```
That works, but the trait belongs here. Downstream it has three problems: any
integrator added to this crate is invisible to it until the downstream crate is
updated, a second dependent would declare the same trait again, and the orphan
rule means only crates that own the trait can extend it.
Reading tolerances back matters separately. A run record that says which
integrator ran but not at what tolerance does not describe the run.
## Dependencies
None.
## Implementation Approach
A separate trait rather than more methods on `Integrator`. `Integrator` already
carries `const ADAPTIVE: bool` because not every method is adaptive, and a
fixed-step method has no tolerances to set. Splitting them keeps that honest and
leaves room for a fixed-step integrator that implements `Integrator` and not
this.
```rust
/// An adaptive integrator's error tolerances.
pub trait Tolerances<const D: usize>: Integrator<D> {
/// The absolute tolerance, per component.
fn absolute_tolerance(&self) -> SVector<f64, D>;
/// The relative tolerance.
fn relative_tolerance(&self) -> f64;
/// Sets the absolute tolerance for every component.
fn a_tol(self, a_tol: f64) -> Self;
/// Sets the absolute tolerance per component.
fn a_tol_full(self, a_tol: SVector<f64, D>) -> Self;
/// Sets the relative tolerance.
fn r_tol(self, r_tol: f64) -> Self;
}
```
The setters keep the names they already have, so **no existing call site
changes**. `Vern7::new().a_tol(1e-12).r_tol(1e-12)` continues to compile,
resolving through the trait instead of the inherent impl, because `Integrator` is
already in the prelude and `Tolerances` would be too.
The getters need different names because `a_tol` is taken by the setter. Naming
them `absolute_tolerance`/`relative_tolerance` avoids the collision and reads
better than `a_tol_value`.
### Steps
1. Declare `Tolerances<D>` in `src/integrator/mod.rs`.
2. For each of `Vern7`, `DormandPrince45`, `BS3`, `Rosenbrock23`: move the three
setter bodies from the inherent impl into an `impl Tolerances<D>`, and add the
two getters returning the stored fields. Keep `new()` inherent.
3. Export `Tolerances` from the prelude.
4. Delete `astrokit`'s local copy and import this one.
### What not to do
Do not add the tolerance methods to `Integrator` itself. That would oblige a
fixed-step method to implement setters that mean nothing, and `const ADAPTIVE`
exists precisely to admit such methods.
Do not add `Default` or `Copy` as supertraits of `Integrator` while doing this,
tempting as it is for downstream bounds. All four current integrators are both,
but a multistep method holding Nordsieck history would reasonably be neither, and
a downstream crate can bound on them explicitly where it needs them.
## Testing Requirements
- Every integrator round-trips: set a tolerance, read it back.
- `a_tol` sets all components; `a_tol_full` sets them independently.
- A generic function over `S: Tolerances<D>` compiles and changes step counts as
the tolerance changes, for each integrator.
- Existing tests continue to pass unchanged, which is the point: the setter names
do not move.
## Success Criteria
- A downstream crate generic over `S: Tolerances<D>` can set and read tolerances
for every adaptive integrator in this crate.
- No call site in this crate or its tests changes.
- `astrokit` deletes its local `Tolerances`.
**Effort**: Small