Skip to content

fix: Add recovery for stiff ODE problems - #346

Closed
mhovd wants to merge 3 commits into
mainfrom
fix/ode-stiff-recovery
Closed

fix: Add recovery for stiff ODE problems#346
mhovd wants to merge 3 commits into
mainfrom
fix/ode-stiff-recovery

Conversation

@mhovd

@mhovd mhovd commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Note: this PR was drafted by Fable 5, with manual review.

The LLM notes are below


Stiff models with sudden rate changes (infusion start/end, lagged
boluses, covariate-driven clearance jumps) could kill a whole
simulation through two diffsol traps:

  1. Crushed-step doom loop: when approaching a stop, diffsol clamps
    h to the gap and silently ignores the StepSizeTooSmall this can
    raise. Once h sits below half the minimum step, every later
    step-size update -- including growth, capped at 2x -- fails, even
    though the state is healthy. Near-coincident stops (infusion end
    ULPs from an observation) trigger exactly this.

  2. Stiff-restart rejection cascades: discontinuity restarts keep the
    old step size. A large h from a smooth segment meeting a stiff
    transient must reject its way down at <= 2x shrink per rejection
    within a budget of 40 consecutive rejections; very stiff transients
    exhaust the budget (TooManyErrorTestFailures / StepSizeTooSmall).

The fix is layered, works for all solver families (BDF, SDIRK, RK),
and uses no unsafe code:

  • Coalesce un-integrable gaps up front: stops closer than a few
    minimum timesteps are snapped to without integrating, after
    verifying against the scheduled infusion rates that the skip
    cannot omit material input (bounded by atol + rtol*||y||). The
    same check accepts StopTimeAtCurrentTime for a stop a few ULPs
    ahead and snaps the clock so the next segment cannot restart
    with the pre-boundary infusion rate.

  • Restart hygiene: every restart (boundary reinit or rescue) now
    floors the step size at max(256eps|t|, 1e-9), repairing any h
    crushed by diffsol's stop handling. The floor is a starting value
    only; the error controller may shrink below it afterwards.

  • Bounded rescue restarts: recoverable step failures (step-size
    collapse, error-test/Newton budget exhaustion, linear-solve
    failures) restart in place at first order with a fresh Jacobian
    and a small step, resetting diffsol's failure budgets so the
    solver can walk across the transient. Restart steps shrink 10x
    per stalled attempt; budgets of 16 rescues per segment and 8
    consecutive stalls bound the work. Exhaustion returns the
    original descriptive error annotated with the restart count and
    a pointer toward implicit solvers. Each rescue emits a tracing
    debug event. This also crosses mid-segment RHS discontinuities
    that are not events, e.g. carry-forward covariate changes.

  • Deduplicate the event loop: the advance-to-next-event logic
    (stop selection, left-continuity at infusion boundaries,
    coalescing, rescues) now lives in one shared
    advance_solver_to_event(); the closure-based ODE path and the
    DSL runtime path both call it. The DSL path injects its
    model-function error surfacing through an after_step hook, which
    runs before rescue classification so user-code errors are
    reported as the root cause instead of the solver error they
    provoked. This removes the duplicated loop in dsl/native.rs that
    let the two paths diverge in the first place.

tests/extreme_stiffness.rs exercises the failure modes across all
solver families against closed-form solutions: infusion-end
boundaries at ke up to 1e6 observed inside the transient, ULP-scale
gaps between stops, stiff cutoff followed by a distant observation,
a steep covariate clearance ramp mid-segment, a lagged bolus into
stiff elimination, Michaelis-Menten depletion with tiny Km, and an
impossibly stiff explicit-solver case asserting the descriptive
error. The new tests fail without the fixes.

Siel and others added 2 commits August 20, 2026 20:14
Stiff models with sudden rate changes (infusion start/end, lagged
boluses, covariate-driven clearance jumps) could kill a whole
simulation through two diffsol traps:

1. Crushed-step doom loop: when approaching a stop, diffsol clamps
   h to the gap and silently ignores the StepSizeTooSmall this can
   raise. Once h sits below half the minimum step, every later
   step-size update -- including growth, capped at 2x -- fails, even
   though the state is healthy. Near-coincident stops (infusion end
   ULPs from an observation) trigger exactly this.

2. Stiff-restart rejection cascades: discontinuity restarts keep the
   old step size. A large h from a smooth segment meeting a stiff
   transient must reject its way down at <= 2x shrink per rejection
   within a budget of 40 consecutive rejections; very stiff transients
   exhaust the budget (TooManyErrorTestFailures / StepSizeTooSmall).

The fix is layered, works for all solver families (BDF, SDIRK, RK),
and uses no unsafe code:

- Coalesce un-integrable gaps up front: stops closer than a few
  minimum timesteps are snapped to without integrating, after
  verifying against the scheduled infusion rates that the skip
  cannot omit material input (bounded by atol + rtol*||y||). The
  same check accepts StopTimeAtCurrentTime for a stop a few ULPs
  ahead and snaps the clock so the next segment cannot restart
  with the pre-boundary infusion rate.

- Restart hygiene: every restart (boundary reinit or rescue) now
  floors the step size at max(256*eps*|t|, 1e-9), repairing any h
  crushed by diffsol's stop handling. The floor is a starting value
  only; the error controller may shrink below it afterwards.

- Bounded rescue restarts: recoverable step failures (step-size
  collapse, error-test/Newton budget exhaustion, linear-solve
  failures) restart in place at first order with a fresh Jacobian
  and a small step, resetting diffsol's failure budgets so the
  solver can walk across the transient. Restart steps shrink 10x
  per stalled attempt; budgets of 16 rescues per segment and 8
  consecutive stalls bound the work. Exhaustion returns the
  original descriptive error annotated with the restart count and
  a pointer toward implicit solvers. Each rescue emits a tracing
  debug event. This also crosses mid-segment RHS discontinuities
  that are not events, e.g. carry-forward covariate changes.

- Deduplicate the event loop: the advance-to-next-event logic
  (stop selection, left-continuity at infusion boundaries,
  coalescing, rescues) now lives in one shared
  advance_solver_to_event(); the closure-based ODE path and the
  DSL runtime path both call it. The DSL path injects its
  model-function error surfacing through an after_step hook, which
  runs before rescue classification so user-code errors are
  reported as the root cause instead of the solver error they
  provoked. This removes the duplicated loop in dsl/native.rs that
  let the two paths diverge in the first place.

tests/extreme_stiffness.rs exercises the failure modes across all
solver families against closed-form solutions: infusion-end
boundaries at ke up to 1e6 observed inside the transient, ULP-scale
gaps between stops, stiff cutoff followed by a distant observation,
a steep covariate clearance ramp mid-segment, a lagged bolus into
stiff elimination, Michaelis-Menten depletion with tiny Km, and an
impossibly stiff explicit-solver case asserting the descriptive
error. The new tests fail without the fixes.
Copilot AI lite review requested due to automatic review settings August 21, 2026 07:40
@codecov-commenter

codecov-commenter commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.83459% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.02%. Comparing base (504b86a) to head (6fa3b8e).

Files with missing lines Patch % Lines
src/simulator/equation/ode/mod.rs 82.74% 39 Missing ⚠️
src/error/mod.rs 84.61% 2 Missing ⚠️
src/dsl/native.rs 92.85% 1 Missing ⚠️
src/simulator/equation/ode/closure.rs 92.30% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #346      +/-   ##
==========================================
+ Coverage   83.82%   84.02%   +0.19%     
==========================================
  Files          82       82              
  Lines       33983    34113     +130     
==========================================
+ Hits        28486    28662     +176     
+ Misses       5497     5451      -46     
Files with missing lines Coverage Δ
src/dsl/native.rs 67.85% <92.85%> (+0.51%) ⬆️
src/simulator/equation/ode/closure.rs 77.61% <92.30%> (+0.90%) ⬆️
src/error/mod.rs 66.66% <84.61%> (+22.51%) ⬆️
src/simulator/equation/ode/mod.rs 89.41% <82.74%> (+1.55%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the ODE “event loop” against extreme stiffness and floating-point edge cases (near-coincident stop times, discontinuity restarts), aiming to prevent otherwise-healthy simulations from aborting or hanging, while producing clearer failure messages when the chosen solver truly can’t handle the problem.

Changes:

  • Adds bounded “rescue restart” logic (restart-in-place with fresh Jacobian / small step) and step-size floor handling on restarts to recover from diffsol step-size collapse and rejection cascades.
  • Coalesces un-integrable tiny stop gaps (with an infusion-amount safety check) and centralizes the event-advance loop into a shared advance_solver_to_event() used by both closure and DSL runtime paths.
  • Introduces new regression tests covering extreme stiffness scenarios and stop-time edge cases across solver families.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/extreme_stiffness.rs Adds comprehensive regression tests for stiff discontinuities, near-ULP stop gaps, and “beyond rescue” descriptive failures across solvers.
tests/bolus_reinit_stop_time.rs Adds regression tests for accepting StopTimeAtCurrentTime and ensuring correct post-boundary RHS semantics in both closure and DSL/JIT paths.
src/simulator/equation/ode/mod.rs Implements shared event-loop advancement, stop-gap coalescing, restart step-size flooring, and bounded rescue restarts.
src/simulator/equation/ode/closure.rs Adds infusion-amount computation used to validate safe coalescing of tiny stop gaps.
src/error/mod.rs Adds rescue-attempt context to solver error messages.
src/dsl/native.rs Removes duplicated DSL-native event loop in favor of the shared advance_solver_to_event() path; adjusts function invocation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 27 to 30
use diffsol::{
error::OdeSolverError, ode_solver::method::OdeSolverMethod, NalgebraContext, OdeBuilder,
OdeSolverStopReason, Vector, VectorHost,
OdeSolverConfig, OdeSolverStopReason, Vector, VectorHost,
};
Comment thread src/simulator/equation/ode/mod.rs Outdated
Comment on lines +896 to +902
Ok(OdeSolverStopReason::RootFound(_, _)) => {
return Err(PharmsolError::OtherError(format!(
"solver stopped at an unexpected root at t = {:.4} \
(root finding is not configured)",
stop_time
)));
}
Comment thread src/error/mod.rs
Comment on lines +71 to +75
PharmsolError::DiffsolError(msg) => PharmsolError::DiffsolError(format!(
"{msg} ({rescues} automatic solver restart(s) did not recover; \
the system may be too stiff for the selected solver — stiff \
problems need an implicit solver such as BDF)"
)),
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Projectpharmsol
Branchfix/ode-stiff-recovery
Testbedmhovd-pgx

⚠️ WARNING: Truncated view!

The full continuous benchmarking report exceeds the maximum length allowed on this platform.

🚨 2 Alerts

🐰 View full continuous benchmarking report in Bencher

@mhovd mhovd closed this Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants