Skip to content

Make adaptive time updates exact at large |t| (fixes #567; stacked on #561) - #569

Draft
ChrisRackauckas-Claude wants to merge 21 commits into
SciML:masterfrom
ChrisRackauckas-Claude:fix/representable-time-steps
Draft

ChrisRackauckas-Claude wants to merge 21 commits into
SciML:masterfrom
ChrisRackauckas-Claude:fix/representable-time-steps

Conversation

@ChrisRackauckas-Claude

@ChrisRackauckas-Claude ChrisRackauckas-Claude commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Ignore this draft until reviewed by @ChrisRackauckas.

Stacked on #561: it uses #561's step helpers in integrator_utils.jl. The branch is #561's head 8da09d3 (which already merges current master 4f726d5), then one commit, cb6aef9 Make every adaptive time update exact, which fixes #567. The mixed-type tstops fix that this PR first carried is now part of #561 (51c325d).

What changed and why

In all nine adaptive EnsembleGPUKernel steppers, t += dt rounds when |t| is large relative to dt. Time then advances by a different amount than the step integrated (Tsit5 with dt = 12 at t = 2^26 ends at 60 instead of 64). And a step that error control shortened could round onto tf, completing the solve with part of the interval un-integrated (round-7 review of #561). Before the first trial step and after every rejection, the shared helper _representable_step now rounds dt down to the largest step whose end time t + dt is exactly representable. A step of exactly the distance to the landing target (tf or a stop) is kept as is. The time advance therefore always equals the integrated length, and a shortened step can never round up onto tf. If no representable end time exists before t + dt, i.e. time precision cannot resolve the step, the step becomes 0 and fails the existing dtmin check with an explicit dt<dtmin. Rounding only ever goes down, so a rejected step is never re-enlarged and the error-control loop cannot cycle.

Reproducers

The round-7 review of #561, Vern7, Float32 t0 = 2^26, tf = nextfloat(t0) (tf - t0 = 8), u' = (1/8, -u₂/8), dt = 8, exact endpoint [1, exp(-1)]:

source result
master bacef85 / #561 ceeed40 Success, [0.72025377, 0.48662883] (accepted dt = 5.76203, stored t = tf)
this branch ERROR: dt<dtmin. Error control rejects the one representable step and no shorter step has a representable end time

Its step-level grid (invariant_grid.jl, 281 stiffness values per algorithm, t0 = 2^26 Float32 and 2^55 Float64, 8-unit final interval) finds a counterexample (t == tf after a step shorter than tf - t0) for all 18 algorithm/precision pairs on master and on #561, and none on this branch.

#567's reproducer (no tstops, Float32 t0 = 2^26, tf = t0 + 64, exact 64):

algorithm, dt master bacef85 this branch
Tsit5, 12 60.50067 64.0
Tsit5, 20 68.83426 64.0
Vern9, 12 60.031746 64.0
Rodas4, 20 64.73596 64.00001
Rodas5P, 12 66.5423 64.000206

The t0 + 64 survey with a stop at t0 + 24 now gives 64 for all nine algorithms (Rodas5P 64.0001, Kvaerno3 63.999996). On #561 Vern9, Rodas4 and Rodas5P gave 60.3, 65.6 and 63.8.

Tests

Added to test/gpu_kernel_de/adaptive_endpoint.jl (CPU group), across all nine adaptive algorithms:

  • Rounded completion at large t, Float32 and Float64. The review's step-level grid asserts that a step reporting t == tf covered the whole interval. The public reproducer must either fail with dt<dtmin or return the exact endpoint.
  • Time advance equals integrated length at large t, Float32 and Float64, no tstops: u' = 1 over 64 units at t0 = 2^26 / 2^55 with dt ∈ {12, 20}. The final state must be ≈ 64.

Before/after, Julia 1.12.7, include-ing the test file (384 assertions):

source result
master bacef85 229 pass / 83 fail / 36 errors; both new large-t tests fail for all 9 algorithms in both precisions
#561 ceeed40 326 pass / 56 fail / 2 errors: both large-t tests fail for all 9 in both precisions (the 2 errors are the mixed-type stops, fixed in #561 at 51c325d)
this branch cb6aef9 523 / 523 pass (the #567 tests plus all of #561's current tests)

Verification

# 4771eb5 (cb6aef9 differs only by a comment in #561's part); JULIA_PKG_PRECOMPILE_AUTO=0, clean worktree per version, Enzyme v0.13.209 (unpinned; #568 is on master)
GROUP=CPU    julia +lts  -> Tsit5 accuracy | 172 172; Adaptive endpoint termination | 523 523; Testing DiffEqGPU tests passed
GROUP=CPU    julia +1.12 -> Tsit5 accuracy | 172 172; Adaptive endpoint termination | 523 523; Testing DiffEqGPU tests passed
GROUP=OpenCL julia +lts  (pocl) -> Tsit5 accuracy | 112 112; Testing DiffEqGPU tests passed
GROUP=OpenCL julia +1.12 (pocl) -> Tsit5 accuracy | 112 112; Testing DiffEqGPU tests passed
GROUP=Enzyme julia +lts  -> Enzyme ensemble gradients (Float64, adaptive=true) | 1 1; Lorenz parameter gradients | 2 2; Testing DiffEqGPU tests passed
GROUP=Enzyme julia +1.12 -> Enzyme ensemble gradients (Float64, adaptive=true) | 1 1; Lorenz parameter gradients | 2 2; Testing DiffEqGPU tests passed

Runic --check, typos, and git diff --check exit 0 on the changed files.

Merged from #561: saveat before a continuous event (e1000a2)

e1000a2 merges #561's 8adb69a. That commit fixes the CUDA "Callbacks" failure at test/gpu_kernel_de/stiff_ode/gpu_ode_continuous_callbacks.jl:185 (GPURodas4, 0.71378124 < 7e-4): https://github.com/SciML/DiffEqGPU.jl/actions/runs/37130654908/job/111224921829. apply_callback! moved integrator.u to the event state before saving saveat points. The Rodas4/Rodas5P/Kvaerno interpolants use integrator.u as the step's end state, so points before the event were interpolated against the wrong endpoint. #561's 1b90199 (no dtnew clamp after acceptance) let the step after a bounce run to tf past the next bounce, which exposed it. Cause, bisect and failing-before/passing-after evidence are in #561's body. The only conflict was in test/gpu_kernel_de/adaptive_endpoint.jl, where both branches append testsets; both were kept. On the merge, the endpoint file runs 577/577 (Julia 1.12, CPU). Runic --check and typos exit 0.

Risk assessment

Known CI noise

Not verified

  • CUDA/AMDGPU/Metal/oneAPI execution. This host has no GPU. Only CUDA CI can confirm the Callbacks fix on GPU.
  • After the merge e1000a2, only the endpoint test file was rerun locally (host overloaded). The full CPU, OpenCL and Enzyme groups are left to CI.
  • GROUP=QA.

Closes #567 (once #561 is merged).

🤖 Generated with Claude Code (model: claude-opus-5-5[1m])

https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np

ChrisRackauckas and others added 6 commits September 27, 2026 14:27
Keep the step's own state at a tstop only when the accepted step length is
exactly the distance to that stop; timestamps within one ulp say nothing
about how far past the stop the step integrated (8 time units at t = 2^26 in
Float32). Otherwise interpolate to the stop as before.

Never clamp the next step to a stop closer than the minimum step size, and
leave stops within the snap threshold of `tf` to the endpoint snap. Adjacent
representable stops are then reached by the following step's tstop branch
instead of forcing `dt < dtmin`.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
Shorten each trial step so it ends exactly on the next pending tstop (when
that stop is at least dtmin away) instead of overshooting and replacing the
step's end state with an interpolated one. The step's dense output then
stays intact for saveat points it covers, and the state at the stop is the
step's own result.

Honor every pending stop before `tf`, including stops within dtmin of it,
and allow a step shorter than dtmin only as the forced final interval to
`tf`. Previously such stops were left to the endpoint snap, which skipped
their callbacks (including terminating ones).

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
A step shorter than dtmin was allowed whenever less than dtmin remained to
`tf`. When error control rejected that final interval and shrank the step,
the shorter accepted step was then snapped to `tf`, reporting success with
only part of the interval integrated. Exempt only a step covering exactly
the remaining interval; a rejected sub-dtmin final step fails with
`dt<dtmin`, as before.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
Replace the absolute 1e-14 endpoint snap with exact landing. Each trial step
is bounded by the next landing target (the next pending tstop at least dtmin
away, otherwise `tf`) and becomes exactly the remaining distance when it would
reach the target or leave less than 1% of a step. The accept branch sets
`t = tf` only for a step of exactly `tf - t`, so completion always means the
whole interval was integrated; only the rounding of `t + (tf - t)` is absorbed.

A stop lies inside an accepted step only when `stop - t <= dt` (no absolute
tolerance). The dtmin check also rejects steps too small to change `t`, and
exempts only a step that is exactly the remaining interval.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
A Float64 `tstops` array for a Float32 problem made the bounded step Float64,
and Kvaerno3/Kvaerno5 then failed to build their nonlinear solver. Convert
the pending stop to the time type before using it.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
@ChrisRackauckas-Claude
ChrisRackauckas-Claude force-pushed the fix/representable-time-steps branch from 6c0a04a to 5b3f63c Compare October 2, 2026 02:36
A stop closer than dtmin to the current time was not a landing target, so the
step covering it landed there by evaluating its dense output near Θ = 1,
which is inaccurate for the Float32 Verner interpolants (Vern9 was off by
4e-4 on an exactly integrable problem). Make every pending stop after `t` a
landing target and exempt a step that lands exactly on its target from
dtmin. A step shortened only to land on a stop keeps the controller's
earlier proposal for the next step, and `dtnew` is no longer clamped after
acceptance (the next trial step is bounded anyway), so a one-ulp landing
step does not collapse the step size.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
@ChrisRackauckas-Claude
ChrisRackauckas-Claude force-pushed the fix/representable-time-steps branch from 5b3f63c to ad82e36 Compare October 2, 2026 04:49
ChrisRackauckas and others added 3 commits October 2, 2026 01:44
Cramer's rule in the 2x2/3x3 `linear_solve` forms products of matrix entries
without scaling. A Rosenbrock W = I/(γ dt) - J with a tiny landing step has
entries near 1/dt, so the determinant overflowed and Rodas4/Rodas5P silently
returned a zero (or NaN) increment. When the determinant or the solution is
not finite, solve the system divided by its largest entry instead; the
solution is unchanged and well-scaled systems keep their exact arithmetic.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
Landing on a stop with an integrated step of length near floatmin overflows
the `C/dt` terms in the Rosenbrock stage formulas, and the NaN error estimate
was accepted. Below `_landing_floor = 1024 / floatmax(T)` (|C| ≤ 166 in
Rodas5P), a stop is reached by interpolating the covering step as before, and
a final interval shorter than the floor fails `dtmin` instead of running the
step.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np

# Conflicts:
#	src/ensemblegpukernel/integrators/integrator_utils.jl
@ChrisRackauckas-Claude
ChrisRackauckas-Claude force-pushed the fix/representable-time-steps branch from ad82e36 to b6d091a Compare October 2, 2026 22:55
ChrisRackauckas and others added 2 commits October 2, 2026 20:19
A step whose state or scaled error is not finite is never accepted. A step
shortened to land on a stop retries with the controller's covering step and
lands on the stop by interpolation; any other step is rejected and shrunk.
The check is on the norm's inputs because `ODE_DEFAULT_NORM` uses fast-math,
under which `isfinite` on its result is folded to true. This replaces the
fixed landing floor, which a scaled mass matrix could still overflow.

`savevalues!` returns the accepted state (or the previous one) for saveat
times at the ends of a step; the dense output, which is not exact at Θ = 0
or 1 for every method, is used only strictly inside the step.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
`continue` in the error-control loop produced SPIR-V that fails validation on
OpenCL (a block emitted before its dominator). Fold the retry into the
rejection branch instead: a non-finite step sets `EEst = Inf`, and a rejected
landing step retries with the controller's covering step rather than a
shrunken one.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
@ChrisRackauckas-Claude
ChrisRackauckas-Claude force-pushed the fix/representable-time-steps branch from b6d091a to a66dee9 Compare October 3, 2026 03:46
ChrisRackauckas and others added 2 commits October 3, 2026 01:10
The retry after an overflowing landing step uses the controller's covering
step, which can reach past `tf`; the step was then accepted past `tf` with
the wrong time and state. Such a step now lands on `tf` by interpolation, so
endpoint callbacks still run. A state obtained by interpolating to a stop or
to `tf` that is not finite fails explicitly instead of returning success.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
Fold the covering retry's landing on `tf` into the stop-landing branch so
each adaptive stepper keeps a single dense-output evaluation in the accept
branch, which keeps the kernels' code size (and Julia 1.12 compile time)
where it was.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
@ChrisRackauckas-Claude
ChrisRackauckas-Claude force-pushed the fix/representable-time-steps branch from a66dee9 to 9c7047d Compare October 3, 2026 08:22
ChrisRackauckas and others added 3 commits October 3, 2026 05:33
A covering retry that could reach past `tf` made the dense-output call in
the accept branch reachable for every problem, including those without
tstops, where it is otherwise dead code. Compiling the Verner interpolants
for ForwardDiff duals inline in the step loop took 236 s (Vern7) and 1177 s
(Vern9) on Julia 1.12, against ~20 s and ~50 s on master. The covering retry
now applies only to a landing on a stop before `tf`, so it never reaches past
`tf`. A final interval whose step is not finite fails explicitly (`dt<dtmin`)
instead of being interpolated.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
`t += dt` rounds when `t` is large relative to `dt`, so time advanced by a
different amount than the step integrated, and a step shortened by error
control could round onto `tf` and complete the solve over un-integrated
dynamics (SciML#567). Before the first trial step and after every rejection,
round the step down to one whose end time is exactly representable; a step of
exactly the distance to the landing target is kept. The time advance then
always equals the integrated length, and a step with no representable end
time fails the dtmin check.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5[1m]
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
@ChrisRackauckas-Claude
ChrisRackauckas-Claude force-pushed the fix/representable-time-steps branch from 9c7047d to cb6aef9 Compare October 3, 2026 10:55
ChrisRackauckas and others added 4 commits October 3, 2026 10:43
`2^55` is an Int, which overflows to 0 on 32-bit Julia, so the saveat
testsets' `eps(t0) == T(8)` assertion failed on the x86 CPU job.
`T(2)^55` and `T(2)^26` give bit-identical times on 64-bit.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Devin CLI (edit); Claude Code 2.1.280 (verification and commit)
Agent-Model: swe-2; claude-opus-5-5[1m]
Agent-Session: local Devin job ~/sandbox/swe2-queue/work/difeqgpu-561-x86-int-overflow-r2 on amdci2; https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
Claude-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
`apply_callback!` moved `integrator.u` to the event state and then saved the
pending `saveat` points with the step's dense output. The Rosenbrock (Rodas4,
Rodas5P) and Hermite (Kvaerno3, Kvaerno5) interpolants use `integrator.u` as
the step's end state, so every `saveat` point between `tprev` and the event
was interpolated against the wrong endpoint. Save those points first, while
the step's state is intact.

The defect predates this branch. Master's clamp of `dtnew` to the distance to
`tf` usually kept steps short enough that no `saveat` point shared a step with
an event; 1b90199 removed the clamp, so the step after the bounce at t = 9 in
the stiff ContinuousCallback test reaches tf, contains the bounce at t = 15,
and the save at t = 9.1 was off by 0.71 (GPURodas4, CUDA).

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.280
Agent-Model: claude-opus-5-5
Agent-Session: https://claude.ai/code/session_01SJfy3ez7u4CqvAerKrT5np

This branch has not been deployed

No deployments
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.

Adaptive kernel steppers: t += dt rounding decouples time from integrated length at large t

2 participants