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

4.7 KiB

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:

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:

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:

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.

/// 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