Repository navigation
NonlinearSolveAlg: forward precondition/postcondition into the implicit stage solve - #4158
Merged
ChrisRackauckas merged 1 commit intoAug 8, 2026
Conversation
`NonlinearSolveAlg` gains the `precondition` (left preconditioner `G` on the residual) and `postcondition` (iterate corrector `H`, the PCNR corrector phase) solve options of NonlinearSolve.jl, so an implicit ODE method can carry a limiter or a residual scaling into its stage solves. The stage unknown is the increment `z`, not the ODE state: the state at a stage is `tmp + γ⋅z` for a DIRK stage and `z` itself when `nlsolver.method` is `COEFFICIENT_MULTISTEP`. Forwarding a user's corrector verbatim would hand a limiter written for a physical variable an increment to clip, so both options are conjugated with that affine map and are stated in ODE-state coordinates: `H(u_stage, u_stage_prev, p, cache)` and `G(fu, u_stage, p)`, with `p` the ODE's parameters rather than the stage problem's internal tuple. `H` is applied to the stage predictor as well as at every inner-solver commit point, since `reinit!` does not redo the initial-guess correction that `init` performs. `W` reuse is disabled under a `precondition` because `W` is the Jacobian of the raw stage residual, not of the composed one — which in turn leaves the inner solver differentiating the in-place stage residual, something its preallocated `Float64` buffers and `AutoSpecialize` function wrapper cannot support, so an in-place `precondition` combined with a dual-based inner AD backend is rejected up front rather than left to fail inside ForwardDiff. `NLNewton`, `NLFunctional`, `NLAnderson` and `HomotopyNonlinearSolveAlg` run their own iteration with no commit point a corrector could be applied at; they accept the keywords only to reject them, so supplying one is an error rather than a silent no-op. The options are likewise rejected for `DAEProblem`s and for ModelingToolkit `nlstep_data` stage systems, whose unknowns have no available map back to the ODE state. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
ChrisRackauckas
approved these changes
Aug 8, 2026
ChrisRackauckas
marked this pull request as ready for review
August 8, 2026 20:37
This was referenced Aug 9, 2026
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.
Forwards NonlinearSolve.jl's nonlinear-preconditioning solve options —
precondition(aleft preconditioner
Gon the residual) andpostcondition(an iterate correctorH, thecorrector phase of PCNR that replaces SPICE-style limiting) — into the implicit stage solves
of
NonlinearSolveAlg, so an implicit ODE method can carry a limiter or a residual scalingdown into its Newton iteration:
Both are released solve keywords; nothing here is gated on availability:
2.412.394.254.243.433.39SciMLBase = "3.43"is load-bearing rather than cosmetic: the inner stageinitreceivesthese as ordinary solve keywords, so
SciMLBase.allowedkeywordshas to contain:precondition/:postconditionor the unrecognized-keyword handling trips. 3.43 does.The part that actually needs review: which variable does the corrector see?
An implicit stage does not solve for the ODE state. It solves for the increment
z, andthe state at the stage is recovered as
tmp + γ⋅z(DIRK) or aszitself (whennlsolver.methodisCOEFFICIENT_MULTISTEP, i.e. inside BDF). A user writingH(u, uprev, p, cache) = max(u, 0)for a physical variable means the state. Wiring theoption straight through to the stage
NonlinearProblemwould hand that rule an increment toclip, which is silently and badly wrong — it is a footgun, not a rounding detail.
Measured on
u' = -u,u0 = 1.0,ImplicitEuler,dt = 0.1, one step:A corrector passed straight through to that same stage problem, as NonlinearSolve's own
solvekeyword, is handed[1.0, 1.0, -0.0909…, -0.0909…]: it converges on-0.0909…, theincrement, while the state there is
0.909…. Amax(u, 0)written for a concentration wouldbe clipping the wrong quantity every iteration.
So this PR does not forward the options verbatim. Both are conjugated with the affine
stage map and are stated in ODE-state coordinates:
postcondition(u_stage, u_stage_prev, p, cache)—u_stageisuₙ₊₁forImplicitEuler,the stage value at
t + cΔtfor a DIRK stage, the state itself underCOEFFICIENT_MULTISTEP.pis the ODE's parameter object, not the stage problem'sinternal parameter tuple.
precondition(fu, u_stage, p)— only the iterate and the parameters are remapped.fustays the stage residual
(Δt⋅f(u_stage, p, t + cΔt) - z)/(γΔt); it has no state-spacereading to map onto.
Verified for a multi-stage method too. On
u' = 1,u0 = 0,TRBDF2,dt = 0.5(so theexact state at time
tist), the valuesHreceives are[0.29289…, 0.5, 0.79289…, 1.0]— exactlyt + γΔtandt + Δtfor each of the two steps,i.e. each stage's own time point. And under
FBDF(method === COEFFICIENT_MULTISTEPthroughout) the values stay inside
[exp(-1), 1], the state's range, neverz's.What a reviewer should decide is whether this remapping is the right call. The
alternative — forwarding verbatim and documenting "your corrector sees
z" — is simpler andcomposes more predictably with anything else that inspects the inner problem, but it makes
the natural way to write a limiter wrong by default. This PR takes the position that a
corrector is a statement about the model, so it should be stated in the model's variable;
the conjugation is confined to
StageConditionerinutils.jl, and reverting topass-through would be a small deletion if the other call is preferred.
Consequences of the choice, all documented in the
NonlinearSolveAlgdocstring:z + (H(u) - u)/γ, not byrecomputing
(H(u) - tmp)/γ. Only the former returnszbit-for-bit when the correctorleaves the state alone, and that matters: a limiter goes inactive exactly when the
iteration is converging, and a one-ulp perturbation there is enough to stop the stage
residual from being exactly zero, which stops the (deliberately zero-tolerance) inner cache
from terminating, which
nlsolve!reads as a rejected trial step rather than convergence.There is a test that an identity corrector reproduces the untouched solve exactly.
His also applied once to the stage predictor, the stage analogue of theH(u0, u0, p, nothing)correctioninitperforms.reinit!does not redo it, so without this the firstresidual of every stage would be evaluated at an uncorrected iterate.
u_stage_prevon a stage's first in-loop correction is that stage's corrected predictor.reinit!refreshes the inner cache's iterate but not its previous-iterate buffer, so theraw value there is the previous stage's last
zread through this stage'stmp— not astate at all.
the raw Newton step, so the stage reads as non-converged and the integrator rejects the
step and retries with a smaller
Δt. That is the intended response, and there is a testthat a limiter tight enough to clip repeatedly still converges to the unlimited solution.
preconditionchanges the residual the inner solver differentiates, soWis no longerits Jacobian:
Wreuse (and with it theW-based smoothed error estimate) is disabled forthe stage solve when a
preconditionis supplied. Apostconditiondoes not change theresidual, so
Wreuse stays on.Second finding, also worth a look: in-place
preconditioncannot use ForwardDiffDropping
Wreuse has a consequence I did not expect.#4137's own test states the premise:That is exactly right —
odenlfwrites the stage state andf's output into theFloat64buffers carried in the parameter tuple, and an
AutoSpecializefis aFunctionWrapperaccepting only
Float64and OrdinaryDiffEq's own one-chunk duals. Nothing normally notices,because the reused
Wis handed to the inner solver as its Jacobian so the residual is neverdifferentiated. A
preconditionremoves that reuse, and the inner solver then differentiatesthe residual itself. Measured,
u' = -uin place,ImplicitEuler,dt = 0.1:preconditionNewtonRaphson()(autodiff unset → ForwardDiff)MethodErrorNewtonRaphson(autodiff = AutoForwardDiff())MethodErrorNewtonRaphson(autodiff = AutoFiniteDiff())SimpleNewtonRaphson()MethodErrorSimpleNewtonRaphson(autodiff = AutoFiniteDiff())TrustRegion()MethodErrorOut-of-place is unrestricted (its residual allocates;
NewtonRaphson(),AutoForwardDiffand
SimpleNewtonRaphson()all work), andpostconditionis unaffected in both forms.NonlinearSolveAlg's defaultalgis alreadyNewtonRaphson(autodiff = AutoFiniteDiff()),so the default path works — but
NonlinearSolveAlg(NewtonRaphson(); precondition = G!), themost natural thing to write, is a 200-line
MethodErrorfrom inside ForwardDiff. This PRtherefore rejects an in-place
preconditioncombined with a dual-based (or unset) inner ADbackend, up front, with a message naming the fix. This is a limitation of the surrounding
in-place stage-residual design rather than of the options themselves; making it work would
mean giving
_compute_rhs!dual-capable buffers and getting past theAutoSpecializefunction wrapper, which is well outside this PR.
What accepts the options
NonlinearSolveAlgNLNewton,NLFunctional,NLAndersonHomotopyNonlinearSolveAlgThe hand-rolled loops in
NLNewton/NLFunctional/NLAndersondo not go throughNonlinearSolve's iterate-commit points, so there is no place a corrector could be applied;
HomotopyNonlinearSolveAlgadditionally solves the λ-embedding rather than the stageresidual. Rather than ignore the option, their constructors take it and raise an
ArgumentErrornamingNonlinearSolveAlgas the alternative.This is deliberately stricter than NonlinearSolve's own convention, which reports an
unsupported corrector through the SciMLLogging
unsupported_postconditiontoggle (raising atErrorLevelby default, but turn-downable). The toggle makes sense there becausepostconditionis asolvekeyword that a user may hold fixed while sweeping algorithms;here it is a constructor keyword of one specific nonlinear solver, so you have to have named
NLNewtonexplicitly to pass it, and there is no verbosity object in scope at constructiontime anyway. Happy to switch to the toggle if consistency is preferred.
Implementing a corrector inside the hand-rolled
NLNewton/NLFunctional/NLAndersonloops is the other option, and it is not attempted here — those loops would need commit
points and an equivalent of the predictor correction, which is a separate change with its own
convergence-test consequences. Rejecting is the deliberate choice for now, not an oversight.
Also rejected, at
build_nlsolvertime:DAEProblems — the residual is the implicit DAE residual and its unknown is the stateincrement against
uprev, so the state-space contract does not apply.nlstep_datastage systems — the stage system is code-generated andOrdinaryDiffEq has no map from its unknowns back to the ODE state to state the option in.
An inner NonlinearSolve.jl algorithm that does not itself apply iterate corrections (a
SimpleNonlinearSolveone, say) is caught by NonlinearSolveBase's ownunsupported_postconditionreport when the stage cache is built; there is a test for thattoo.
Compatible with SciML/NonlinearSolve.jl#1148 (
PostconditionSpecifier(H; space = ...)) if itlands: the specifier is callable with the same 4-argument signature, so it passes through
StageConditionerunchanged, andpostcondition_spaceon the adapter falls back to:original, which is right because the stageNonlinearProblemcarries nolb/ub.Tests
New file
lib/OrdinaryDiffEqNonlinearSolve/test/nsa_conditioning_tests.jl, wired intoruntests.jl. It covers the coordinate contract in and out of place, the multistep form, thepredictor correction, the previous-iterate identity, an identity corrector being bit-for-bit
inert, a limiting corrector converging to the unlimited solution,
preconditioncomposinginto the residual,
Wreuse being disabled bypreconditionbut not bypostcondition,survival across
resize!, and every rejection path.Run against the rebased branch with the registry versions above (NonlinearSolveBase 2.41.0,
NonlinearSolve 4.25.0, SciMLBase 3.43.0):
Runic reports no changes on all touched files, and
Test.detect_ambiguities/detect_unbound_argsonOrdinaryDiffEqNonlinearSolveboth report0.
Not verified locally, left to CI: the Downgrade job (whether the new
2.41/4.25/3.43lower bounds resolve against each other and against the sublibraries), and the QA job.
🤖 Generated with Claude Code
https://claude.ai/code/session_01B9GHoo4pFa3DU9yhyTkevL