Bridge SDE noise when a late tstop shortens dt - #4095
Merged
ChrisRackauckas merged 1 commit intoAug 2, 2026
Merged
Conversation
`init` draws the pending Brownian increment for the configured `dt`. If
`add_tstop!` (or a dtmax/dtmin clamp) then shortens the step before
`perform_step!` runs, the solver takes a step of the new size while the
noise still holds an increment drawn for the old one. `accept_step!`
advances `W.curt` by that stale `W.dt`, so the noise grid slides off the
solution grid and never comes back:
julia> i = init(prob, EM(); dt = 0.02, save_noise = true)
julia> add_tstop!(i, 0.01); solve!(i)
julia> i.sol.t[1:4], i.W.t[1:4]
([0.0, 0.01, 0.03, 0.05], [0.0, 0.02, 0.04, 0.06])
julia> i.sol.t[end], i.W.t[end]
(1.0, 1.0099999999999998)
The first increment also carries variance 0.02 over a step of 0.01.
Shrinking a pending step from the same start time is what a step
rejection already describes, so `loopheader!` now routes it through
`reject_noise!`: the drawn increment is bridged down to the new `dt` and
the remainder stays on the RSWM stack, leaving `W.curt`/`W.curW` alone.
Growth cannot be bridged, so the guard only fires on a shrink and the
increment is otherwise left as drawn (the previous behaviour).
Regression tests cover both directions: the noise grid must track
`sol.t`, and the noise must not take a step the solver never took
(`W.iter == integrator.iter`, and `u(T) == W(T)` for `du = dW`, where
Euler-Maruyama is exact). The `RSwM1/2/3` variants are checked
separately since only RSwM1 hands a stack chunk back through `W.dt`.
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSn6HPV8G8NWLpUeAEVA9b
Member
Author
|
Local regression sweep against this rebase: 34/34 SDE test files pass (tstops, callable tstops, saveat, events, noise types, scalar/nondiagonal/noncommutative noise, adaptive suites, Stratonovich and ODE convergence, IIF, mass matrix, static arrays, jump RNG, zeroed noise).
On CI, the red checks so far are all pre-existing and reproduce on #4093 / #4094 / master:
The |
This was referenced Aug 2, 2026
ChrisRackauckas
marked this pull request as ready for review
August 2, 2026 20:51
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Draft — please ignore until reviewed by @ChrisRackauckas.
Important
This does not fix #3175, and #3175 should stay open. The error that issue reports no longer reproduces on master — see the section at the bottom. This PR fixes a different defect found while reviewing #4041.
The bug
initdraws the pending Brownian increment for the configureddt. If the step is then shortened beforeperform_step!runs —add_tstop!afterinit— the solver steps the new, shorterdtwhile the noise process still holds an increment drawn for the old one.accept_step!advancesW.curtby that staleW.dt, so the noise grid slides off the solution grid and never returns:The first increment also carries variance 0.02 across a step of 0.01.
The fix
Shrinking a pending step from the same start time is exactly what a step rejection already describes, so
loopheader!routes it through the existingreject_noise!hook.DiffEqNoiseProcess.reject_step!bridges the drawn increment down to the newdtand keeps the remainder on the RSWM stack;W.curt/W.curWare untouched.Instrumenting the comparison shows it firing only for a late
add_tstop!; plain fixed-step and adaptive solves,tstopspassed up front,dtmax-limited solves,saveat, and reverse-time solves never reach it, so existing random streams are unchanged.Distributional verification
du = dW, where Euler-Maruyama is exact andu(t)is the Brownian path. 20,000 seeded paths per configuration, tolerances at 4σ:The bridged increment carries the variance of the step it spans, the stashed remainder is reused without double-counting (uncorrelated successors), and
u(T) == W(T)held on every one of the 20,000 paths for: no tstop, tstop at init, one late tstop, three late tstops, adaptive, andRSwM1/RSwM2/RSwM3.Since
du = dWproduces no step rejections, the reject path was checked separately on multiplicative noise (du = 1.01u dt + 0.87u dW) whereW(1) ~ N(0, 1)is grid-independent:Known gap: the grow direction
fix_dt_at_bounds!can also raisedt(max(dt, dtmin)), and growth cannot be bridged, so this PR leaves that case exactly as master has it — which is wrong, by a factor of 10⁴ in the reproduction. Filed as #4097 rather than fixed here, to keep this PR to the direction it verifies.Tests
Added to
lib/StochasticDiffEq/test/tstops_tests.jl, covering both failure directions:sol.t, andW.curt == integrator.tat the end — these fail on master ([0.0, 0.02, 1.01]vs[0.0, 0.01, 1.0]);W.iter == integrator.iter, andu(T) == W(T)fordu = dW— these guard against "fixing" the grid by committing the stale increment instead of bridging it;RSWMvariants, since onlyRSwM1hands a stack chunk back throughW.dt.31/31 pass here, 27/31 on unmodified master. A 34-file local SDE sweep passes. Runic clean.
Why #3175 is not fixed by this
StochasticDiffEq.jl#413, which #3175 was migrated from, reports:
That error does not reproduce on master. Running the MWE across 8 variants ×
EM/LambaEM— float and integertspan, scalar and vectoru0, rationaldt, tstop on/off the step grid, tstop equal todt, tstop at1e-9— all 16 land on the tstop and finish withReturnCode.Success.The only part of #413's MWE that still fails is
u0 = [1], which throwsInexactError: Int64(0.78…)— an integer state vector that cannot hold the value after a continuous noise increment. That is unrelated to tstops and reproduces identically on master.#4041 targets the same area and asserts
step!lands att == 0.01; that assertion also passes on master and on #4041's own parent commit. Separately, itsresync_noise!callsDiffEqNoiseProcess.accept_step!, which advancesW.curt, incrementsW.iter, folds the stale increment intoW.curW, and pushes a point into the saved path, despite documenting itself as resyncing "without advancing the noise clock". On that branchdu = dWgives|u(T) - W(T)| = 0.128— exactly the discarded increment — andW.iter == 52against 51 solver steps.🤖 Generated with Claude Code
https://claude.ai/code/session_01QSn6HPV8G8NWLpUeAEVA9b