Thresholds and factors
Two conventions govern every mechanism in the package. Both are enforced at construction, so a configuration that violates either fails immediately rather than producing a run that looks plausible and is outside the theory.
Acceptance is not scaling
TRParams carries three thresholds satisfying
\[0 \le \eta \le \eta_1 \le \eta_2 < 1 ,\]
and they answer different questions:
| threshold | question | read by |
|---|---|---|
η | is this step worth taking? | the solver alone |
η₁ | was the model poor enough that the region should shrink? | the rule alone |
η₂ | was it good enough that the region should grow? | the rule alone |
η defines the successful and unsuccessful index sets
\[\mathcal{S} = \{k : \rho_k \ge \eta\}, \qquad \mathcal{U} = \{k : \rho_k < \eta\} ,\]
and nothing else in the package compares ρ with it. η₁ and η₂ never decide whether a step is taken.
using TrustRegionRadius, ADNLPModels
a = TRParams(η = 0.0, η1 = 0.1, η2 = 0.9) # decoupled
b = TRParams(η1 = 0.1, η2 = 0.9) # η defaults to η1
(a.η, a.η1, b.η, b.η1)(0.0, 0.1, 0.1, 0.1)The keywords are ASCII, matching update_radius!. η₁, η₂ and Δ₀ are accepted as aliases and readable as properties either way, so TRParams(η₁ = 0.2).η1 == 0.2.
Because η defaults to η₁, existing code is unaffected until it asks for something else.
What decoupling buys
Setting η < η₁ opens a band in which ρ_k ∈ [η, η₁): the step is accepted and the radius still contracts. The model was good enough to make progress worth keeping and bad enough that the region should not be trusted at its current size. Nothing in the framework forbids this, and no rule needed changing to support it — the rules already branch on η₁.
Setting η = 0 accepts every step with a positive predicted reduction. This is admissible in Part I and excluded by the framework of Curtis & Scheinberg (2020), which requires a strictly positive acceptance threshold; it is worth a column of its own in any comparison.
To measure how often the new regime is entered, test ρ_k < η₁ on accepted iterations — not whether Δ decreased. RDelta contracts by γ₂ on every mildly successful iteration, so a shrinking radius does not isolate the band:
ss = stats.solver_specific
ρ, acc = ss[:ratio_trajectory], ss[:accepted_trajectory]
count(i -> acc[i] && ρ[i] < params.η1, eachindex(acc))The count is zero by construction whenever η = η₁.
Why the rules are told the outcome
update_radius!(rule, Δ, ρ, accepted, η1, η2, s_norm, g_norm_old, g_norm_new)accepted is passed rather than recomputed. A rule cannot derive it: acceptance is decided by η, which the rule never receives, and the retrospective rules are handed ρ̃ in the ρ slot, so they do not see ρ_k at all. For RRTR and RRTRGrad this argument is load-bearing — see Retrospective rules and rejected steps.
Rules that constrain the thresholds
Some rules cannot accept every admissible triple. The step-driven rules need η₁ > 0, because at η₁ = 0 their aggressive branch is unreachable and the lower-bound constant of Part I degenerates. validate_thresholds is called once when the solver is built, so the failure is a constructor error rather than a silent change of behaviour:
nlp = ADNLPModel(x -> sum(abs2, x), [1.0, 2.0])
try
DeterministicTRSolver(nlp; rule = RStep(),
params = TRParams(η = 0.0, η1 = 0.0, η2 = 0.9))
catch e
showerror(stdout, e)
endArgumentError: RStep: the step-driven update needs η1 > 0Rules with no such requirement accept η₁ = 0 unchanged. To add a requirement for a new rule, define a method of validate_thresholds; the fallback accepts everything.
One convention for the scaling factors
Every rule carries γ₁, γ₂, γ₃ with
\[0 < \gamma_1 \le \gamma_2 < 1 < \gamma_3 ,\]
γ₁ the aggressive contraction, γ₂ the mild one, γ₃ the expansion. This is the convention of Parts I–II, checked in every constructor by check_factors, which throws an ArgumentError naming the rule and the offending inequality. A rule that does not use one of the three passes nothing for it; the remaining inequalities are still checked.
ArgumentError rather than @assert deliberately: @assert is documented as liable to be disabled, and these are argument checks rather than internal invariants.
The rule constructors take ASCII factor names, γ1, γ2, γ3. The Unicode aliases η₁, η₂, Δ₀ exist on TRParams but do not extend to the rules, so RGrad(γ₂ = 2.0) raises MethodError: no method matching RGrad(; γ₂::Float64) rather than the validation error you were reaching for.
for f in (() -> RGrad(γ2 = 2.0),
() -> RDelta(γ1 = 0.8, γ2 = 0.3),
() -> RDelta(γ3 = 0.9),
() -> RGrad(γ1 = 0.25, γ2 = 0.5, γ3 = 2.0))
try
f()
println("constructed")
catch e
println(sprint(showerror, e))
end
endArgumentError: RGrad: need 0 < γ2 < 1, got γ2 = 2.0
ArgumentError: RDelta: need γ1 ≤ γ2, got γ1 = 0.8, γ2 = 0.3
ArgumentError: RDelta: need γ3 > 1, got γ3 = 0.9
constructedThe Hei family carries one extra requirement, γ₃ > 1 + γ₂, so that the smooth factor can exceed the value it takes at the threshold; see The Hei family.
Rules whose factors were renumbered
Four rules previously used γ₂ for expansion, which is now the mild contraction slot:
| rule | before | now |
|---|---|---|
RGrad | γ₁ = 0.25, γ₂ = 2.0 | γ₁ = 0.25, γ₂ = 0.5, γ₃ = 2.0 |
RGradCapped | γ₁ = 0.25, γ₂ = 2.0 | γ₁ = 0.25, γ₂ = 0.5, γ₃ = 2.0 |
RRTR | γ₀ = 0.0625, γ₁ = 0.25, γ₂ = 2.5 | γ₁ = 0.0625, γ₂ = 0.25, γ₃ = 2.5 |
RRTRGrad | γ₁ = 0.25, γ₂ = 2.0 | γ₁ = 0.25, γ₃ = 2.0 |
RDelta, RStep and RDFO keep their values; only their checks are tighter, since γ₂ < 1 and γ₁ ≤ γ₂ were previously unverified. Old keyword calls now throw, so none of these can pass silently. MIGRATION.md in the repository root lists the case that can: a positional call, to any of the four rules that take positional factors, whose numbers happen to satisfy 0 < γ1 ≤ γ2 < 1 < γ3 under the new reading. The renumbering moved what slot 3 means, and nothing can distinguish that from a deliberate call.
Radius bounds
Every rule carries Δmin and Δmax and applies them on every branch. Previously Δmax was honoured only on the expansion branch of some rules and absent from others, so the field meant different things in different rules and the solver's global cap hid the inconsistency. check_bounds enforces 0 ≤ Δmin ≤ Δmax at construction — the old clamp threw from inside the iteration instead.
API
TrustRegionRadius.TRParams — TypeTRParams{T}(; η, η1, η2, Δ0, Δmin, Δmax, max_iterations, tol, tol_H,
max_time, true_stop, stop_z)Solver parameters, shared by all three solvers.
Fields
η: acceptance threshold; the step is taken whenρ ≥ η(defaultη1)η1: first scaling threshold, passed to the radius rule (default 0.1)η2: second scaling threshold, "very successful" (default 0.9)Δ0: initial radius; ignored by rules of the formΔ = μ·crit(default 1)Δmin,Δmax: hard floor and cap (default 0,Inf)max_iterations(default 10 000)tol: first-order tolerance on‖g‖(default√eps)tol_H: second-order tolerance onλ_min(B);-1disables it (default-1)max_time: wall-clock budget in seconds (defaultInf)true_stop: how a satisfied stopping test is confirmed (default:none)stop_z: confidence multiplier for the statistical modes (default 2)
Confirming the stopping test
‖ĝ_k‖ ≤ tol is a statement about one batch. On a small batch it can be met on sampling noise alone, and then the solver reports :first_order at a point that is not critical — a batch of N = 2 with an exact model puts the step on the batch minimiser, so ‖ĝ‖ is zero to rounding whatever ∇f is doing. true_stop chooses what has to hold before that stop is believed:
| value | cost per candidate stop | accepts the stop when |
|---|---|---|
:none | none | always — the batch test is the test |
:test | none | ‖ĝ_k‖ + z·σ_g/√N_k ≤ tol |
:full | one cap-sized pass | ‖g‖ ≤ tol on the largest batch allowed |
:both | a pass only when :test is inconclusive | :test, else :full |
:test is free: it asks whether the batch can resolve tol at all, by putting a one-sided confidence bound on ‖∇f‖ from the plug-in standard error σ_g/√N_k. It never stops on noise, but it can refuse to stop for a long time when N_k is held down by the sampling rule.
:full is exact and always pays. :both is the one to reach for: the cheap bound settles the well-resolved case with no extra evaluations, and the expensive pass is spent only when the batch genuinely cannot tell. See confirm_gradient_norm! for what "the largest batch allowed" means and how its cost is accounted.
true_stop = false and true_stop = true remain accepted, and mean :none and :full. A deterministic solver rejects anything but :none — the batch test is the true test there. :full and :both on an expectation need has_truth; :test does not, since it consults only the variance the oracle already tracks.
:none reproduces the literature, and every benchmark result recorded in this repository was produced under it. It is the right default for a survey whose subject is the mechanisms, but a :first_order status obtained under it is a claim about a batch, not about ∇f. Report which mode produced a number.
0 ≤ η ≤ η1 ≤ η2 < 1. η₁, η₂ and Δ₀ work as keywords and as property names too: TRParams(η₁ = 0.2).η1 == 0.2.
Acceptance is decoupled from scaling
η governs whether the trial point is taken; η1 and η2 govern only how the radius is scaled, and are the only two a rule ever sees. η < η1 opens a middle band, ρ ∈ [η, η1), in which the step is accepted and the radius nonetheless contracts. η defaults to η1, reproducing the coupled behaviour exactly.
TrustRegionRadius.validate_thresholds — Functionvalidate_thresholds(rule, η, η1, η2) -> nothingHook called once per solve, after the parameters are known, for rules that require more of (η, η1, η2) than 0 ≤ η ≤ η1 ≤ η2 < 1. Default: no-op. Throws ArgumentError on a violation.
TrustRegionRadius.check_factors — Functioncheck_factors(name; γ1, γ2, γ3) -> nothingEnforce the standing convention 0 < γ1 ≤ γ2 < 1 < γ3 on the scaling factors of a rule. Pass nothing for a factor the rule does not use; the remaining inequalities are still checked.
An ArgumentError rather than @assert, because @assert is documented as liable to be disabled and these are argument checks, not internal invariants. The same choice is now made everywhere in the package, including ScaledIdentity and SPDTarget, which previously used @assert.
TrustRegionRadius.check_bounds — Functioncheck_bounds(name; Δmin, Δmax) -> nothingEnforce 0 ≤ Δmin ≤ Δmax on a rule's own radius bounds.
Previously nothing checked this, and RStep/RRTR applied the bounds with clamp, which throws when lo > hi — so a mistyped bound surfaced as an ArgumentError from deep inside the iteration rather than at construction. _clamp_radius no longer throws; this does, at construction, which is where the mistake is.
TrustRegionRadius.confirms_stop — Functionconfirms_stop(p) -> BoolWhether p asks for any confirmation of a satisfied stopping test.
TrustRegionRadius.confirmation_needs_truth — Functionconfirmation_needs_truth(p) -> BoolWhether the confirmation mode can require an exact gradient. True for :full and :both, false for :test, which reads only the variance the oracle already has.