Second-order variants
Every mechanism in this package controls the radius by comparing it against a criticality measure. The first-order measure is ‖g_k‖, which vanishes at every critical point — minimisers, saddles and maxima alike. The second-order measure is
\[\tau_k = \max\bigl\{\,\|g_k\|,\; -\lambda_{\min}(B_k)\,\bigr\},\]
which vanishes only where the model is genuinely second-order critical.
Replacing one by the other is the whole of the upgrade: the update logic does not change. That is why SecondOrder is a wrapper rather than a new family of rules — it forwards update_radius! to the rule it wraps and overrides only criticality.
using TrustRegionRadius, ADNLPModels
nlp = ADNLPModel(p -> p[1]^4 - p[1]^3 + (0.25 - p[1]/2)*p[2]^2 + p[2]^4/4, [-0.5, 0.6])
stats = tr_solve(nlp;
rule = RGradTau(μ = 1.0), # Δ_k = μ_k τ_k, μ updated exactly as in RGrad
model = ExactHessian(), # must be able to report negative curvature
subsolver = EigenPoint(SteihaugCG()), # must be able to exploit it
params = TRParams(tol = 1e-8, tol_H = 1e-6),
trace = true)
(stats.status, stats.solution)(:second_order, [-5.193891395132776e-5, 9.32151241473877e-14])The three pieces, and why all three are needed
A second-order run needs a measure that stays positive at a saddle, a model that can see the negative curvature, and a solver that can move along it. Any one missing and the run silently degrades to a first-order one.
| piece | what it supplies | what happens without it |
|---|---|---|
SecondOrder / τ | a positive radius near a saddle | the radius collapses; see below |
ExactHessian, SR1Model | λ_min < 0 when it exists | τ ≡ ‖g‖, an expensive no-op — the solver now warns |
EigenPoint, ExactMS | a step along the negative direction | positive radius, nowhere to go |
tol_H > 0 | refusal to stop at a saddle | :first_order reported at a saddle |
What τ repairs
At an exact saddle ‖g‖ = 0 while λ_min < 0, so a ‖g‖-anchored rule reports the radius it would use at a solution. The two anchored families then fail differently:
RGrad(‖g‖)halts outright.Δ_k = μ_k‖g_k‖ = 0, so every subsequent step is zero,ρisNaN, and no amount of negative curvature in the model can be used.RDFO(‖g‖)contracts geometrically. The testΔ_k > ζ‖g_k‖ = 0succeeds at every iteration, so the radius is multiplied byγ₂ < 1for ever and reaches the stagnation floor inO(log(1/eps))iterations.
Both are repaired by τ, which equals -λ_min > 0 there and holds the radius at the scale of the curvature actually available.
λ, gn = -1.0, 0.0 # an exact saddle
r = RGrad(μ = 1.0)
rτ = RGradTau(μ = 1.0)
(initial_radius(r, 1.0, criticality(r, gn, λ)), # dead
initial_radius(rτ, 1.0, criticality(rτ, gn, λ))) # alive(0.0, 1.0)Stopping
tol_H adds the second-order test to the stopping rule: the run stops when ‖g‖ ≤ tol and λ_min(B) ≥ -tol_H, and reports :second_order. It is disabled by default (tol_H = -1), so nothing changes for a first-order run and no curvature estimate is paid for.
Note what the status does and does not claim. :second_order is a statement about the model Hessian, so it is a statement about f only insofar as the model reports the curvature of f — exactly for ExactHessian, asymptotically for SR1Model under coherence, and never for a model constrained positive definite.
LBFGSModel enforces B ≻ 0, so λ_min > 0 always, so τ = ‖g‖ identically and the second-order variant is an expensive no-op that will nonetheless report :second_order at a saddle. The same holds for ScaledIdentity and SPDTarget. Pair SecondOrder with ExactHessian or SR1Model, and read :lambda_min_trajectory to confirm the model ever reported negative curvature at all. This is declared through reports_negative_curvature and the solver emits a warning once per run when the two are paired, so the no-op is not silent.
Why the subsolver matters
τ-anchoring keeps the radius positive; it does not make the step go anywhere.
SteihaugCG stops at the first direction of negative curvature and runs to the boundary along it. That is a real decrease, but the direction is whichever the CG recurrence happened to reach, and the guaranteed decrease carries no |λ_min|Δ² term. EigenPoint wraps it, and when λ_min < 0 compares the inner step against the eigenpoint ±Δ v_min, which decreases the model by at least ½|λ_min|Δ², keeping whichever is better. ExactMS solves the subproblem exactly, hard case included, so it satisfies the condition unwrapped.
This is what makes the O(max{ε_g^{-2}, ε_H^{-3}}) complexity bound attainable in practice rather than only in principle.
Curvature estimation
lambda_min_estimate uses a dense symmetric eigendecomposition for n ≤ nmax (free when the subsolver is already ExactMS) and Lanczos with full reorthogonalisation above it. curvature_estimate is the type-stable form the solver calls: it always returns a (λ, v) pair and hands it to the subsolver through curv, so EigenPoint no longer recomputes an eigendecomposition the solver has just done.
A Ritz value satisfies λ_Ritz ≥ λ_min, so an under-resolved estimate understates the negative curvature. In τ that biases the measure down and makes the second-order test optimistic — it can report :second_order at a saddle whose negative direction Lanczos has not yet found. Raise lanczos_k when the status matters; the dense branch is exact.
Trace
With trace = true and a curvature estimate in play, two further trajectories appear:
ss = stats.solver_specific
(length(ss[:lambda_min_trajectory]), # λ_min(B_k)
length(ss[:tau_trajectory])) # τ_k, the measure the rule actually saw(15, 15)Comparing :tau_trajectory against :grad_trajectory is the cheapest second-order diagnostic available: wherever τ > ‖g‖, the run is somewhere the gradient alone calls critical and the curvature does not.
API
TrustRegionRadius.SecondOrder — TypeSecondOrder(rule)The second-order variant of a criticality-anchored rule: identical update logic, with the radius anchored to τ = max{‖g‖, −λ_min(B)} instead of ‖g‖.
SecondOrder(RGrad(μ = 1.0)) # Δ_k = μ_k τ_k, μ updated exactly as before
SecondOrder(RDFO(ζ = 1.0)) # expands while Δ_k ≤ ζ τ_kOnly rules that actually read the criticality measure may be wrapped; wrapping RDelta or RStep would change nothing, since they never consult it, so it raises ArgumentError rather than producing a silent no-op.
What the upgrade buys
At a first-order critical point that is not a minimiser, ‖g‖ = 0 while λ_min < 0. A rule anchored to ‖g‖ therefore reports the radius it would use at a solution, and the two anchored families fail in different ways:
RGrad(‖g‖)setsΔ_k = μ_k‖g_k‖ = 0and halts outright — every subsequent step is zero, ρ isNaN, and no descent direction can be taken however much negative curvature the model reports;RDFO(‖g‖)findsΔ_k > ζ‖g_k‖ = 0at every iteration and contracts geometrically, so the radius reaches the stagnation floor inO(log(1/eps))iterations.
Under τ both are repaired: τ_k = -λ_min > 0 near such a point, the radius stays proportional to the curvature available, and a subsolver that exploits negative curvature escapes. This is the sharpest experiment in the second-order half of the survey, and it is two lines to run — see the docstring of EigenPoint for the other half of what makes it work.
LBFGSModel enforces B ≻ 0, so λ_min > 0 always, so τ = ‖g‖ identically and SecondOrder is an expensive no-op. The same holds for ScaledIdentity, SPDTarget and the outer-product models. A second-order variant is only meaningful over a model that can report negative curvature — ExactHessian or SR1Model.
This is now enforced rather than described: reports_negative_curvature is a trait on ModelHessian, and TRSolver warns once at construction when a τ-anchored rule or tol_H > 0 meets a model that cannot supply the information. The previous docstring claimed the solver warned; nothing did.
Fields
The wrapped rule's fields are forwarded, so SecondOrder(RGrad(μ = 2.0)).μ works and r.inner reaches the rule itself.
TrustRegionRadius.RGradTau — FunctionRGradTau(; μ = 1.0, γ1, γ2, γ3, half_test)Δ_k = μ_k τ_k with μ updated exactly as in RGrad: the four branches of Update (R-grad), the ‖s_k‖ > ½Δ_k guard on expansion, no cap.
Shorthand for SecondOrder(RGrad(...)). Since μ_k is now the ratio Δ_k/τ_k rather than Δ_k/‖g_k‖, the geometric search it performs is a search for the second-order inactivity threshold.
TrustRegionRadius.RGradCappedTau — FunctionRGradCappedTau(; μ, μ_max, γ1, γ2, γ3, half_test)RGradCapped anchored to τ. The cap carries the same warning as in the first-order case, now against the second-order threshold: μ_max below it leaves the trust region binding for ever.
TrustRegionRadius.RDFOTau — FunctionRDFOTau(; ζ = 1.0, γ1, γ2, γ3, Δmin, Δmax)RDFO anchored to τ: contract on an unsuccessful iteration, otherwise shrink when Δ_k > ζ τ_k and expand when Δ_k ≤ ζ τ_k.
TrustRegionRadius.RAdaptiveGradTau — FunctionRAdaptiveGradTau(; kwargs...)RAdaptiveGrad anchored to τ.
The multiplier accumulates (μ_{k+1} = μ_k R(ρ_k)), so it is unbounded above and inherits RGrad's unconditional eventual inactivity.
TrustRegionRadius.RAdaptiveGradCappedTau — FunctionRAdaptiveGradCappedTau(; kwargs...)RAdaptiveGradCapped with ω = τ. The capped adaptive-multiplier rule of the appendix, RAdaptiveGrad(ω, μ̄), measured by τ rather than by ‖g‖.
TrustRegionRadius.RRTRGradTau — FunctionRRTRGradTau(; kwargs...)RRTRGrad anchored to τ: the retrospective ratio ρ̃ drives the multiplier, and τ sets the scale it multiplies.
TrustRegionRadius.criticality — Functioncriticality(rule, g_norm, λmin) -> Float64The quantity rule measures its radius against. ‖g‖ by default; τ for a rule wrapped in SecondOrder.
The solver evaluates this once per iteration and passes the result to initial_radius and update_radius! in place of the gradient norm. A rule that ignores criticality altogether — RDelta, RStep — never sees the difference.
TrustRegionRadius.needs_curvature — Functionneeds_curvature(rule) -> BoolWhether rule needs λ_min(B_k), and so whether the solver should pay for a curvature estimate at every iteration.
false by default. The estimate costs a dense symmetric eigendecomposition on small problems and a short Lanczos run otherwise, so it is not levied unless a rule, the second-order stopping test, or EigenPoint asks for it.
TrustRegionRadius.tau_criticality — Functiontau_criticality(g_norm, λmin) -> Float64The second-order criticality measure
\[τ = \max\{\,\|g\|,\; -\lambda_{\min}(B)\,\} = \max\{\|g\|, -\lambda^-\}, \qquad \lambda^- = \min\{\lambda_{\min}(B),\,0\}.\]
τ = 0 exactly when g = 0 and B ⪰ 0, i.e. at a second-order critical point of the model. Positive curvature never enlarges it: λmin > 0 contributes -λ^- = 0 and the measure falls back to ‖g‖.
TrustRegionRadius.lambda_min_estimate — Functionlambda_min_estimate(model, nlp, x; nmax = 200, lanczos_k = 40, vector = false)Smallest eigenvalue of the model Hessian B(x), returned as a Float64, or the pair (λ, v) when vector = true.
Two regimes:
n ≤ nmax: a dense symmetric eigendecomposition ofdense_hessian. Exact.n > nmax:lanczos_ksteps of Lanczos with full reorthogonalisation againsthessian_op, returning the smallest Ritz value and its Ritz vector.
Prefer curvature_estimate inside the solver: it has a single return type, whereas this returns either a scalar or a tuple depending on a keyword.
A Ritz value satisfies λ_Ritz ≥ λ_min, so an under-resolved run understates the available negative curvature. In τ = max{‖g‖, −λ_min} that biases the measure downward and makes the second-order test optimistic — it can report second-order criticality at a saddle whose negative direction Lanczos has not yet found. Raise lanczos_k when the second-order status matters; the estimate is exact in the dense branch.
TrustRegionRadius.model_hessian_norm — Functionmodel_hessian_norm(model, nlp, x; nmax = 200, lanczos_k = 40) -> Float64‖H_k‖, the operator norm of the model Hessian at x.
This is the quantity the majorant of Part I is built from: M_k = L + max_{i≤k} ‖H_i‖, against which the radius summability of Part II is stated, Σ_k Δ_k²/M_k < ∞. Reporting Σ_k Δ_k² instead is equivalent only while {‖H_k‖} is bounded, which is exactly the hypothesis the weaker form was introduced to drop, so on a quasi-Newton model whose curvature grows the two statements are different.
Dense and exact for n ≤ nmax; lanczos_k Lanczos steps otherwise, taking max(|θ_min|, |θ_max|) over the Ritz values. Returns NaN rather than throwing when the model cannot supply a Hessian at x: this is a diagnostic and must not be able to fail a run.
The large-n branch shares its recurrence with lambda_min_estimate — lanczos_k means the same thing in both, and the two ends of the spectrum come off the same tridiagonal.
The branch previously ran a fixed thirty steps of power iteration on H² with no convergence test. The per-step contraction is (λ₂/λ₁)², so on a clustered spectrum it removes almost nothing. Measured on eigenvalues {i/25 : i = 1…250}, where (λ₂/λ₁)² = 0.992:
| method | matrix-vector products | relative error |
|---|---|---|
| power iteration, 30 steps | 60 | 6.5e-03 |
| power iteration, 300 steps | 600 | 3.3e-05 |
| power iteration, 3000 steps | 6000 | 0 |
Lanczos, k = 40 | 40 | 2.1e-05 |
Lanczos, k = 60 | 60 | 9.5e-09 |
Raising the fixed count and adding a convergence test were both considered; neither is worth doing to the wrong algorithm. At equal cost Lanczos is nearly six orders of magnitude more accurate here, and on a well-separated spectrum both are exact, so nothing is given up.
The error remains one-sided: Ritz values interlace the spectrum, so ‖H_k‖ is understated, M_k = L + max_i‖H_i‖ is understated, and Σ_k Δ_k²/M_k is overstated. That is the safe direction for a claim that the series is finite and the wrong one for a reported value. Raise lanczos_k, or force the dense branch with nmax, before quoting a number from a large problem.
TrustRegionRadius.curvature_estimate — Functioncurvature_estimate(model, nlp, x, want_vector; nmax, lanczos_k)
-> (λ::Float64, v::Vector{Float64})Type-stable wrapper around lambda_min_estimate: always returns a pair, with an empty v when the eigenvector was not requested.
This is what the solver calls, once per iteration, and what it hands to solve_subproblem! through the curv keyword. EigenPoint previously computed its own estimate on top of the solver's, so a τ-anchored run with EigenPoint(SteihaugCG()) paid for two dense eigendecompositions per iteration.
TrustRegionRadius.EigenPoint — TypeEigenPoint(inner = SteihaugCG(); nmax = 200, lanczos_k = 40)Wraps a subproblem solver so that the returned step always achieves at least the model decrease available along the leftmost eigenvector.
When λ_min(B) < 0 this computes the eigenpoint
\[d = \pm\Delta\, v_{\min}, \qquad m(0) - m(d) \;\ge\; \tfrac12 |\lambda_{\min}|\,\Delta^2,\]
with the sign chosen so that gᵀd ≤ 0, and returns whichever of d and the inner solver's step decreases the model more. When λ_min ≥ 0 the inner step is returned untouched and nothing is spent beyond the curvature estimate.
The estimate is taken from the curv argument when the solver has already computed it (which it has whenever the rule is τ-anchored or tol_H > 0), and computed here only otherwise. needs_eigenvector tells the solver to ask for the eigenvector as well, so the shared estimate is the one with v.
Why this is needed
τ-anchoring keeps the radius positive near a saddle; it does not make the step go anywhere. The two halves are independent, and both are required:
SteihaugCGterminates on the first direction of negative curvature and runs to the boundary along it. That is a genuine decrease, but the direction is whichever the CG recurrence happened to reach, and the guaranteed decrease carries no|λ_min|Δ²term — the fraction of the eigenpoint decrease it achieves can be arbitrarily small.ExactMSalready solves the subproblem exactly, hard case included, so it satisfies the condition on its own and needs no wrapping. Wrapping it is harmless and now costs nothing extra when the solver is already estimating the curvature.
So EigenPoint(SteihaugCG()) is the combination to use for second-order runs on anything but the smallest problems, and it is what makes the O(max{ε_g^{-2}, ε_H^{-3}}) complexity bound of Part II attainable in practice rather than only in principle.
TrustRegionRadius.second_order_status — Functionsecond_order_status(g_norm, λmin, tol_g, tol_H) -> Symbol:second_order when ‖g‖ ≤ tol_g and λ_min ≥ −tol_H, :first_order when only the gradient test passes, :unknown otherwise.
Kept separate from the solver loop so that a trace can be re-classified after the fact under a different tol_H without re-running anything.