Skip to content

Commit eea042e

Browse files
NSA: don't count rejected inner trial steps as convergence (#3817) (#3893)
A globalized inner solver (TrustRegion and friends) can reject its trial step: `step!` returns with the iterate exactly unmoved and the cache not terminated, having only shrunk its trust region for the next attempt. The outer convergence test read that zero displacement as a perfect solve - `ndz < 1e-5` on the first iteration, `eta*ndz = 0 < kappa` on later ones - and accepted the stage with no correction applied. #4020 surfaces inner caches that *terminated* unsuccessfully; a rejected step terminates nothing, so it slipped through both branches. `nlsolve!` now skips the convergence and divergence bookkeeping for an iteration whose `NonlinearSolveAlg` step left the iterate unmoved without terminating: no new iterate exists to judge, and a zero `ndz` would also poison the next iteration's theta. The inner solver retries with its shrunken radius; if it never moves, the loop runs out and the step is rejected as unconverged. A cache that terminated at zero displacement converged exactly (failures already return `Inf` from `compute_step!`), so that case still counts as convergence. ROBER with FBDF and a TrustRegion inner solver: - fixed dt = 1.0 (transient unresolvable): was ReturnCode.Success with the state frozen at u0; now ConvergenceFailure, matching NewtonRaphson. - adaptive, default tolerances: relerr 1.7e-2 -> 1.8e-5 (NLNewton 2.1e-5, NSA NewtonRaphson 4.0e-6). Same for RobustMultiNewton. - adaptive, reltol=1e-8: relerr 2.4e-8 -> 1.6e-9 (NewtonRaphson 1.3e-9). NewtonRaphson inner solves are unchanged step-for-step (their iterates always move or terminate), and NLNewton never enters the predicate.
1 parent 01f7415 commit eea042e

2 files changed

Lines changed: 63 additions & 1 deletion

File tree

lib/OrdinaryDiffEqNonlinearSolve/src/nlsolve.jl

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
@inline eps_around_one::T) where {T} = 100sqrt(eps(one(θ)))
22

3+
# A globalized inner solver (TrustRegion and friends) can reject its trial step: `step!`
4+
# returns with the iterate exactly unmoved and the cache not terminated, having only shrunk
5+
# its trust region for the next attempt. The zero displacement such a `step!` leaves behind
6+
# is not convergence evidence — the convergence tests below would read it as a perfect solve
7+
# (`ndz < 1e-5` on the first iteration, `η·ndz = 0 < κ` on later ones) and accept the stage
8+
# with no correction applied (#3817). A cache that instead *terminated* at zero displacement
9+
# either converged exactly (which genuinely is convergence) or failed, and failures were
10+
# already turned into `Inf` by `compute_step!`, so termination is the discriminator between
11+
# "already at the root" and "has not decided anything yet".
12+
_rejected_trial_step(nlsolver, ndz) = false
13+
function _rejected_trial_step(nlsolver::NLSolver{<:NonlinearSolveAlg}, ndz)
14+
return iszero(ndz) && NonlinearSolveBase.not_terminated(nlsolver.cache.cache)
15+
end
16+
317
"""
418
nlsolve!(nlsolver::AbstractNLSolver, integrator)
519
@@ -70,6 +84,19 @@ function nlsolve!(
7084
break
7185
end
7286

87+
if _rejected_trial_step(nlsolver, ndz)
88+
@SciMLMessage(
89+
lazy"Inner nonlinear solver rejected its trial step (iter = $(iter)); the unmoved iterate is not treated as convergence",
90+
integrator.opts.verbose, :newton_convergence
91+
)
92+
# No new iterate exists to judge: skip the convergence and divergence
93+
# bookkeeping (a zero `ndz` would also poison the next iteration's `θ`)
94+
# and let the inner solver retry with its shrunken trust region. If it
95+
# never moves, the loop runs out and the step fails as unconverged.
96+
ndz = ndzprev
97+
continue
98+
end
99+
73100
has_prev_θ = hasfield(NL, :prev_θ)
74101
prev_θ = has_prev_θ ? nlsolver.prev_θ : one(ndz)
75102

lib/OrdinaryDiffEqNonlinearSolve/test/nsa_jacobian_reuse_tests.jl

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using OrdinaryDiffEqBDF, OrdinaryDiffEqSDIRK, OrdinaryDiffEqRosenbrock
22
using OrdinaryDiffEqNonlinearSolve
33
using OrdinaryDiffEqNonlinearSolve: NonlinearSolveAlg
4-
using NonlinearSolve: NewtonRaphson
4+
using NonlinearSolve: NewtonRaphson, TrustRegion
55
using ADTypes, LinearAlgebra, SciMLBase
66
using Test
77

@@ -65,3 +65,38 @@ end
6565
@test err(sol) < 3 * err(ref)
6666
@test sol.stats.naccept < 1.25 * ref.stats.naccept
6767
end
68+
69+
@testset "globalized inner solver does not converge on a rejected step" begin
70+
# A TrustRegion trial step that is rejected leaves the iterate exactly unmoved
71+
# without terminating the inner cache; the outer displacement test alone reads
72+
# that as convergence and accepts the stage with no correction applied (#3817).
73+
# Driven at a dt far too coarse for the Robertson transient, so the inner solves
74+
# genuinely cannot converge and the failure must be reported rather than
75+
# silently absorbed.
76+
nsa_tr = NonlinearSolveAlg(TrustRegion(; autodiff = AutoForwardDiff()))
77+
nsa_nr = NonlinearSolveAlg(NewtonRaphson(; autodiff = AutoForwardDiff()))
78+
hard = ODEProblem(f, [1.0, 0.0, 0.0], (0.0, 1.0e3), [0.04, 3.0e7, 1.0e4])
79+
80+
sol_tr = solve(hard, FBDF(nlsolve = nsa_tr); dt = 1.0, adaptive = false)
81+
sol_nr = solve(hard, FBDF(nlsolve = nsa_nr); dt = 1.0, adaptive = false)
82+
83+
# The state must not be reported as a successful solve while frozen at u0.
84+
@test !(SciMLBase.successful_retcode(sol_tr) && sol_tr.u[end] == hard.u0)
85+
# TrustRegion must reach the same verdict as the non-globalized inner solver.
86+
@test SciMLBase.successful_retcode(sol_tr) == SciMLBase.successful_retcode(sol_nr)
87+
end
88+
89+
@testset "TrustRegion matches NewtonRaphson when the solves do converge" begin
90+
nsa_tr = NonlinearSolveAlg(TrustRegion(; autodiff = AutoForwardDiff()))
91+
sol = solve(prob, FBDF(nlsolve = nsa_tr); reltol = 1.0e-8, abstol = 1.0e-10)
92+
@test SciMLBase.successful_retcode(sol)
93+
@test norm(sol.u[end] .- refsol.u[end]) / norm(refsol.u[end]) < 1.0e-4
94+
95+
# At default tolerances the rejected-step false convergence used to cost three
96+
# orders of magnitude (relerr 1.7e-2, where NLNewton gives 2.1e-5); with rejected
97+
# trial steps excluded from the convergence test it lands back at tolerance level
98+
# (measured 1.8e-5).
99+
sol_def = solve(prob, FBDF(nlsolve = nsa_tr))
100+
@test SciMLBase.successful_retcode(sol_def)
101+
@test norm(sol_def.u[end] .- refsol.u[end]) / norm(refsol.u[end]) < 1.0e-3
102+
end

0 commit comments

Comments
 (0)