Recompute non-lazy interpolation stages after a callback-truncated step#3964
Merged
ChrisRackauckas merged 1 commit intoJul 20, 2026
Conversation
When a ContinuousCallback truncates a step, reeval_internals_due_to_modification! rebuilds the interpolation stages for the shortened dt. The force_calc_end argument it passed to ode_addsteps! could never be true: has_lazy_interpolation is defined as _unwrap_val(alg.lazy), so the guarded !_unwrap_val(alg.lazy) was false whenever the guard was taken, and the else branch used the false default. This was harmless for lazy interpolants, whose extra stages sit outside kshortsize and are therefore dropped by the preceding resize! and rebuilt on demand. For non-lazy interpolants the extra stages sit inside kshortsize (21 vs 13 for Vern8, 11 vs 8 for BS5), so resize! cannot drop them and _ode_addsteps! skipped them, leaving values computed for the original longer dt. Step endpoints stayed correct because they come from the earlier stages; only the interior interpolant of the truncated step was wrong. Affects Vern6/7/8/9 with lazy=false and BS5 with lazy=false. Fixes SciML#3963 Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
ChrisRackauckas
marked this pull request as ready for review
July 20, 2026 11:34
ChrisRackauckas
added a commit
that referenced
this pull request
Jul 21, 2026
Releases unreleased changes on master across four sublibraries: - OrdinaryDiffEqCore 4.7.1 -> 4.8.0 (minor: restores isdiscretecache public API (#3907); also recompute non-lazy interpolation stages after a truncated step (#3964)) - OrdinaryDiffEqBDF 2.3.1 -> 2.3.2 (precompile a mass matrix DAE in the workload (#3966)) - OrdinaryDiffEqExtrapolation 2.3.0 -> 2.3.1 (drop stale BaseThreads/Sequential imports (#3945)) - OrdinaryDiffEqRosenbrock 2.4.1 -> 2.4.2 (SIMD-fuse RosenbrockCache stage loops; document public HybridExplicitImplicitRK (#3938)) Claude-Session: https://claude.ai/code/session_01SFmcAmLrGPrGtzwP333mX8 Co-authored-by: ChrisRackauckas-Claude <accounts@chrisrackauckas.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Fixes #3963.
Please ignore until reviewed by @ChrisRackauckas.
Root cause
reeval_internals_due_to_modification!rebuilds the interpolation stages after aContinuousCallbacktruncates a step. Theforce_calc_endargument it passed toode_addsteps!could never betrue:has_lazy_interpolation(alg)is defined as_unwrap_val(alg.lazy)(OrdinaryDiffEqVerner/src/alg_utils.jl:18), so inside the true branch!_unwrap_val(alg.lazy)is alwaysfalse, and the else branch takes thefalsedefault. Dead logic on both paths.Why that only breaks
lazy=false:lazy=true—kshortsizeexcludes the extra stages (13 for Vern8), so the precedingresize!drops them and lazy interpolation rebuilds them against the newdt. Correct.lazy=false—kshortsizeincludes them (21 for Vern8), soresize!is a no-op and stages 14–21 survive holding values computed for the original, longerdt._ode_addsteps!then skips them, because its guard(allow_calc_end && length(k) < 21) || force_calc_endis false on every term.Step endpoints stay correct since they derive from stages 1–13 — only the interior interpolant of the truncated step is wrong, exactly as reported.
Scope beyond the issue
BS5(lazy=false)has the identical defect (kshortsize11 vs 8, guard atlow_order_rk_addsteps.jl:175). Not mentioned in the issue; verified below.Verification
MRE from the issue, before → after:
Vern8()lazyVern8(lazy=false)Vern9(lazy=false)Vern7(lazy=false)Vern6(lazy=false)Tsit5()Vern8(lazy=false)now reads 4.94e-11, identical toVern8()lazy — that equality is what the bug was breaking. Theprev. cleancolumn is unchanged and the lazy/Tsit5rows are bit-identical, so untruncated steps and non-Verner algorithms are unaffected.Negative controls (tests run against the unfixed tree, to confirm they actually catch the bug):
The Verner failures partition exactly along the
lazyboundary the analysis predicts, and the other 35 tests pass on the unfixed base — so there are no pre-existing failures on this commit.Caveats
ODEDIFFEQ_TEST_GROUP=Corefor OrdinaryDiffEqVerner and OrdinaryDiffEqLowOrderRK only. Not the full monorepo suite, not QA.force_calc_endtruefor every non-lazy algorithm, not just Verner/BS5. Only those two families have aforce_calc_endbranch in_ode_addsteps!at all, and all methods share the 5-arg signature via the genericode_addsteps!(generic_dense.jl:109), so it should be inert elsewhere — but that part rests on inspection rather than execution, and is what CI should confirm.🤖 Generated with Claude Code
https://claude.ai/code/session_01Sc5xC1E4H646cPhLSccGCP