Fix RK45 dense-output FSAL corruption and restore local extrapolation - #436
Conversation
step! performed the FSAL move (k7 of the completed step becomes k1 of the
next) inside the accept branch, but solve() consumes that step's dense
output *after* step! returns: the output saves at line 65 and the
interpolant closure handed to stepfun both run later. Every interpolated
save therefore evaluated the dense-output polynomial with k7 sitting in the
k1 slot, and the k1 weight is not zero (interpC column 1, b1(1) = 35/384),
so each value carried a spurious dt*b1(σ)*(k7 - k1) = O(h²) term. That
drops the dense output from 4th order to 2nd.
This is not a corner case: Luna.run leaves step_on unset by default and
Output.GridCondition saves via yfun(ts) with ts < tn, so every saved
z-slice of a default run is an interpolated value.
Move it to the top of step!, guarded by the previous step's s.ok. step! is
evaluate!'s only caller, so this is one site with one guard, and it stays
ahead of evaluate!(::PreconStepper)'s rebase of ks[1] into the new anchor
frame -- the copy must still happen in the old frame, exactly as before.
s.ok is false before the first step (ks[2:7] are `similar`, i.e. undef) and
after a rejection (k1 must survive for the retry); a min_dt clamp that
forces ok = true in steplims! still gets the move, as it did before.
Nothing between step! returning and the next step! reads or writes ks[1] --
errnorm consumes the k values before the old copy site -- so the stepped
trajectory is unchanged. Verified on the N=5 soliton problem at rtol=1e-8:
step count, hash of the z sequence, hash of the dz sequence, hash of the
final field and its energy are all bit-identical, for both the plain and
the preconditioned stepper. Only interpolated values move.
Measured on a fixed-step model problem with an exact solution
(y' = i(a + |y|²)y, h = 0.1 and 0.025):
dense-output error at the step midpoint, h = 0.1
plain 1.586e-02 -> 1.061e-05
precon 9.973e-04 -> 4.953e-08
convergence order of that error
plain 2.00 -> 4.90
precon 2.00 -> 4.99
relative error of the interpolant's slope at the step start
plain 3.97e-01 -> 2.24e-05
precon 2.50e-02 -> 2.00e-07
endpoint error and its order: unchanged to the last digit
and on the soliton problem, the summed energy of 51 interpolated saves
moves from 8.35581116e7 to 8.35584000e7 against an exact stepped-endpoint
value of 8.3558400113e7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dopri.jl had the two weight vectors of the Dormand-Prince RK5(4)7M pair
labelled the wrong way round: `b5`, commented "Weights for 5th order
method", held [5179/57600, ...], which is the embedded *4th*-order vector,
and `b4` held [35/384, ...], which is the 5th-order one. step! propagated
`b5`, so locextrap=true -- the default, and the only setting anything ever
uses -- ran the lower-order solution of the pair, while locextrap=false
propagated the higher-order one.
Told apart by the quadrature order conditions Σᵢbᵢcᵢᵖ = 1/(p+1), in exact
rational arithmetic: [35/384, ...] satisfies them through p = 4;
[5179/57600, ...] gives Σbc⁴ = 0.19974 ≠ 1/5, so it cannot be 5th order.
[35/384, ...] is also the final Butcher row B[6] and the σ=1 value of the
dense-output polynomial -- both of which follow from DOPRI5 being FSAL, and
neither of which is true of the other vector. It is what scipy's RK45 and
MATLAB's ode45 propagate.
Swap the values so the names mean what they say, and select between them in
step!. errest is unchanged: it was b5 - b4 and is now b4 - b5, the same
numbers, still the standard E vector = (4th order) - (5th order). Both
branches now form yn explicitly instead of one of them relying on the
stage-6 accumulation evaluate! happens to leave behind, so the result no
longer depends on the RHS leaving its input array alone; for locextrap that
is bit-identical to the old path, since b5[1:6] == B[6], b5[7] == 0 and the
accumulation order is the same.
Two things fall out. FSAL becomes exact: stage 7 is the RHS evaluated at the
solution that is now actually propagated, so ks[1] == f(t_{n+1}, y_{n+1})
(measured: exactly 0 residual, was 2.3e-5 at h = 0.1). And the dense output
becomes a continuous extension of the propagated solution -- at the step
endpoint the interpolant reproduces yn to round-off (1.9e-14) instead of
differing from it by the full embedded error estimate (2.1e-5).
This changes the result of every simulation. Measured on a fixed-step model
problem with an exact solution, h = 0.1 and 0.025:
endpoint error, order of convergence
plain 7.27e-04 / 2.09e-06, order 4.22 -> 7.89e-05 / 7.66e-08, order 5.00
precon 2.31e-06 / 8.95e-09, order 4.01 -> 8.80e-08 / 5.74e-11, order 5.29
dense-output error: unchanged (the interpolant is 4th order either way)
and on the N=5 soliton problem at rtol=1e-8, where the exact conserved
energy is 1638400:
plain error 2.2e-03 in 21944 steps -> 1.1e-04 in 14328 steps
precon error 4.6e-01 in 5426 steps -> 7.6e-03 in 5426 steps
i.e. 20-60x more accurate at the same tolerance, and 35% fewer steps for the
plain stepper: the old FSAL inconsistency (k1 was the RHS at the 5th-order
point while the solution carried forward was the 4th-order one) was
inflating the error estimate as well.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes two independent correctness defects in the Dormand–Prince RK45 (DOPRI5) integrator: (1) FSAL state was being moved too early and corrupting dense output, and (2) the 5th/4th order weight vectors were swapped, causing locextrap=true (the default) to propagate the lower-order solution.
Changes:
- Defer the FSAL
k7 → k1move to the start of the nextstep!call (guarded by the previous step’sok) so dense output is evaluated with the correct stage data. - Correct the DOPRI5 weight-vector labeling (
b5= 5th order,b4= embedded 4th order) and explicitly form the propagated solution based onlocextrap. - Add targeted regression tests covering dense-output order, interpolant slope at step start, FSAL move timing/exactness, and rational order-condition checks.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| test/test_rk45.jl | Adds regression tests that detect FSAL/dense-output corruption and permanently pin the correct b-vector ordering and convergence properties. |
| src/RK45.jl | Defers the FSAL move until after dense output is consumed and selects the propagated solution via the corrected b5/b4 weights. |
| src/dopri.jl | Swaps and documents the correct 5th/4th order Dormand–Prince weights and updates the error-estimate vector accordingly. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
@chrisbrahms this is quite a significant bug. I doubt it has affected much of our work, but it is probably what underlines several other issues, such as #203 It came up on my ModelPNPS code as it was this that was causing weird errors when not landing exactly on z grid points. This PR seems to fix everything, but does need careful review (I am doing additonal offline testing). |
|
Huh, good find. Sort of surprised this ever worked then. I had a quick go at repeating one of my current scans because it's quick, and found negligible differences. But that's just one example. Do you think this could be the reason that DiffEq performed so much better in your earlier tests (#400)? |
Merges fix/rk45-dense-output-and-locextrap (LupoLab#436): 7747c93 "Defer RK45 FSAL move so dense output uses the right k1" and 1d7e4c3 "Propagate the 5th-order solution when locextrap is on". Merged rather than cherry-picked so both lines share the commits themselves -- when LupoLab#436 lands on master, git can see the change is already in both and the eventual merge back only has to reconcile this branch's own RK45 rewrite. Conflict resolution, all in src/RK45.jl and test/test_rk45.jl, where that rewrite needs a different shape from the version on master: - the propagation is a fused tchunks pass per branch rather than the .+= loop. With the corrected vectors locextrap needs only k1,k3..k6 (b5[7] == 0), so that pass drops a k7 argument; the embedded branch keeps all seven. Bit-identical to the stage-6 accumulation, as before. - evaluate!(::PreconStepper)'s stage-6 re-accumulation is gone: it existed because the !locextrap path relied on the accumulation that in-place fbar! had clobbered, and step! now rebuilds yn in both branches. - the endpoint-snap rationale in interpolate(), and the step_on tests that pin it, were written around the σ=1 mismatch 1d7e4c3 removes. The snap stays -- a step_on landing can sit 1 ulp off the target, and a save asked for at the endpoint should be the stepped solution itself -- but the tests now assert the polynomial agrees with yn to round-off just outside the window (< 1e-9 relative, measured ~1e-16) instead of "differs by a finite amount", and the coefficient identities follow the corrected names. - solve()'s step_on docstring no longer attributes percent-level scatter to the interpolant. With the FSAL fix it is 4th order, one below the stepped solution, which is the remaining reason to land weak-signal saves on step endpoints. Full test suite on the merged tree: 4574 pass, 12 broken (pre-existing), 0 failures -- including test_perf_bitident.jl, whose bit-identity assertions compare runs within one tree and so are unaffected by the accuracy change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Yes, I think it is a very good chance. This also makes little difference for me on general propagation. The interpolated error was still order 2, and often we had small step sizes anyway. Claude noticed it because of this pathological situation where I am sensitive to a very weak field in the presence of very strong ones on the TG-FROG sims. Even then it is minor. But it is better to be correct. |
|
That being said, while the reasoning is pretty clear, this was mostly Claude's work, so I think we need to do more manual sanity checks before merging. I am testing with more aggressive errors (higher order solitons etc.). I haven't looked up the original DOPRI5 tables though. |
|
LGTM and ready to merge. Can you add a brief summary to #415 ? |





Two independent defects in the DOPRI5 integrator, as two separate commits because their blast radii are very different: the first leaves the stepped trajectory bit-identical, the second changes the result of every simulation.
1.
7747c93— the FSAL move ran before the dense output was consumedstep!performed the FSAL move (k7of the completed step becomesk1of the next) inside its accept branch, butsolveconsumes that step's dense output afterstep!returns — both theoutput=truesaves and the interpolant closure handed tostepfun. Every interpolated save therefore evaluated the dense-output polynomial withk7sitting in thek1slot, and thek1weight is not zero (interpCcolumn 1,b1(1) = 35/384), so each value carried a spuriousdt*b1(σ)*(k7 − k1) = O(h²)term. The dense output was 2nd order instead of 4th.Not a corner case:
Luna.runleavesstep_onunset by default andOutput.GridConditionsaves viayfun(ts)withts < tn, so every saved z-slice of a default run is an interpolated value. Runs that passstep_onaligned to the save grid were unaffected — those saves are exact step endpoints.The fix moves it to the top of
step!, guarded by the previous step'ss.ok.step!isevaluate!'s only caller, so this is one site with one guard, and it stays ahead ofevaluate!(::PreconStepper)'s rebase ofks[1]into the new anchor frame — the copy must still happen in the old frame, exactly as before.2.
1d7e4c3—locextrap=truepropagated the lower-order solutiondopri.jlhad the two weight vectors of the Dormand–Prince RK5(4)7M pair labelled the wrong way round.b5, commented "Weights for 5th order method", held[5179/57600, …], which is the embedded 4th-order vector;b4held[35/384, …], the 5th-order one.step!propagatedb5, solocextrap=true— the default, and the only setting anything in the repo ever uses — ran the lower-order solution of the pair.Told apart by the quadrature order conditions
Σᵢ bᵢcᵢᵖ = 1/(p+1)in exact rational arithmetic:[35/384, …]satisfies them throughp = 4;[5179/57600, …]givesΣbc⁴ = 0.19974 ≠ 1/5, so it cannot be 5th order.[35/384, …]is also the final Butcher rowB[6]and the σ=1 value of the dense-output polynomial — both consequences of DOPRI5 being FSAL, neither true of the other vector — and it is what scipy'sRK45and MATLAB'sode45propagate.The commit swaps the values so the names mean what they say and selects between them in
step!.errestis unchanged (b5 - b4becameb4 - b5: the same numbers, still(4th) − (5th)), so the error estimate and the PI controller's1/5exponent are untouched. Both branches now formynexplicitly rather than one of them relying on the stage-6 accumulationevaluate!happens to leave behind, so the result no longer depends on the RHS leaving its input array alone.Two things fall out. FSAL becomes exact — stage 7 is the RHS evaluated at the solution that is now actually propagated. And the dense output becomes a continuous extension of the propagated solution: at the endpoint the interpolant reproduces
ynto round-off instead of differing by the full embedded error estimate.Measurements
Fixed-step model problem with an exact solution (
y' = i(a + |y|²)y,h = 0.1and0.025):ynat the endpointN=5 soliton problem at
rtol=1e-8(exact conserved energy 1638400):7747c93: stepped trajectory bit-identical — step count and hashes of thez,dzand final-field sequences all unchanged, both steppers. The summed energy of 51 interpolated saves moves from8.35581116e7to8.35584000e7, against an exact8.3558400113e7.1d7e4c3: endpoint energy error2.2e-3 → 1.1e-4in21944 → 14328steps (plain) and4.6e-1 → 7.6e-3in 5426 steps (precon). 20–60× more accurate at the same tolerance, and 35% fewer steps for the plain stepper — the old FSAL inconsistency was inflating the error estimate too.Tests
162 new lines in
test/test_rk45.jl, on a 1-element problem that is exact for both steppers and for the preconditioned split (|y|is conserved), withmin_dt == max_dt == hpinning every step:ks[1]must survive astep!and change on the next one;f(y(tₙ)), notfat the far end;h, plus a repeat atrtol=1e-14where every step is accepted only by themin_dtclamp, exercising thesteplims!-forces-ok=truepath the deferred move must preserve.Reviewer notes
test_gnlse.jl'sargmaxcomparisons atrtol=1e-14.Luna.runuses): allkᵢlive in the frame anchored at the step start; the deferred copy stays ahead ofprop!(ks[1], t, tn)so it is still rebased in the old frame;prop!_maybeandinterpolate's trailingprop!(out, t, ti)still transform saved output to the lab frame on every path.Luna.run'sstepfun(Eω .*= ωwin,twin) mutatess.ynin place after each step, so the FSALk1is evaluated at the un-windowed endpoint. Pre-existing and unchanged by either commit — commit 2 slightly reduces the residual inconsistency.#2supersedes the rationale forstep_ononly partly: the dense output is still 4th order while the endpoints are now 5th, so landing weak-signal saves on step endpoints is still worthwhile.masterate2d1a3d; the three commits upstream has added since touchPolarisation.jl,Scans.jland the workflows only, so there is no overlap. The full suite above was run at that base.🤖 Generated with Claude Code