Stopping criteria

Continuing the square‑root story from the Interface page, we now decide when the iteration should halt. A stopping criterion encapsulates halting logic separately from the algorithm update rule.

Why separate stopping logic?

Decoupling halting from stepping lets us:

  • Reuse generic stopping (iteration caps, time limits) across algorithms.
  • Compose multiple conditions (stop after 1 second OR 100 iterations, etc.).
  • Query convergence indication vs. mere forced termination.
  • Store structured reasons and state (e.g. at which iteration a threshold was met).

Built-in criteria: Heron's method

The package ships several concrete StoppingCriterions:

Each criterion has an associated StoppingCriterionState storing dynamic data (iteration when met, elapsed time, etc.).

Recall our example implementation for Heron's method, where we we added a stopping_criterion to the Algorithm, as well as a stopping_criterion_state to the State.

using AlgorithmsInterface

struct SqrtProblem <: Problem
    S::Float64                # number whose square root we seek
end

struct HeronAlgorithm <: Algorithm
    stopping_criterion        # any StoppingCriterion
end

mutable struct HeronState <: State
    iterate::Float64          # current iterate
    iteration::Int            # current iteration count
    stopping_criterion_state  # any StoppingCriterionState
end

Here, we delve a bit deeper into the core components of what made our algorithm stop, even though we had to add very little additional functionality.

Initialization

The first core component to enable working with stopping criteria is to extend the initialization step to include initializing a StoppingCriterionState as well. Since some of these may require stateful implementations, we also keep a stopping_criterion_state that captures this, and thus needs to be initialized. By default, the initialization happens automatically and the only thing that is left for us to do is to attach this stopping_criterion_state to the state in the initialize_state function, as we already saw before:

function AlgorithmsInterface.initialize_state(
        problem::SqrtProblem, algorithm::HeronAlgorithm,
        stopping_criterion_state::StoppingCriterionState;
        kwargs...
    )
    x0 = rand()
    iteration = 0
    return HeronState(x0, 0, stopping_criterion_state)
end

function AlgorithmsInterface.initialize_state!(
        problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState;
        kwargs...
    )
    state.iteration = 0
    return state
end

Note that we do not need to handle any stopping criteria in the initialize_state! function, as a separate call to AlgorithmsInterface.initialize_stopping_state! is made independently.

Iteration

During the iteration procedure, as set out by our design principles, we do not have to modify any of the code, and the stopping criteria do not show up:

function AlgorithmsInterface.step!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState)
    S = problem.S
    x = state.iterate
    state.iterate = 0.5 * (x + S / x)
    return state
end

What is really going on is that behind the scenes, the loop of the iterative solver expands to code that is equivalent to:

while !is_finished!(problem, algorithm,  state)
    increment!(state)
    step!(problem, algorithm, state)
end

In other words, all of the logic is handled by the is_finished! function. The generic stopping criteria provided by this package have default implementations for this function that work out-of-the-box. This is partially because we used conventional names for the fields in the structs. There, Algorithm assumes the existence of stopping_criterion, while State assumes iterate and iteration and stopping_criterion_state to exist.

Running the algorithm

We can again combine everything into a single function, but now make the stopping criterion accessible:

function heron_sqrt(x; stopping_criterion)
    prob = SqrtProblem(x)
    alg  = HeronAlgorithm(stopping_criterion)
    state = solve(prob, alg)  # allocates & runs
    return state.iterate, state.iteration
end

heron_sqrt(2; stopping_criterion = StopAfterIteration(10))
(1.414213562373095, 10)

With this function, we are now ready to explore different ways of telling the algorithm to stop. For example, using the basic criteria provided by this package, we can alternatively do:

using Dates
criterion = StopAfter(Millisecond(50))
heron_sqrt(2; stopping_criterion = criterion)
(1.414213562373095, 161982)

We can tighten the condition by combining criteria. Suppose we want to stop after either 25 iterations or 50 milliseconds, whichever comes first:

criterion = StopAfterIteration(25) | StopAfter(Millisecond(50))  # logical OR
heron_sqrt(2; stopping_criterion = criterion)
(1.414213562373095, 25)

Conversely, to demand both a minimum iteration quality condition and a cap, use & (logical AND).

criterion = StopAfterIteration(25) & StopAfter(Millisecond(50))  # logical AND
heron_sqrt(2; stopping_criterion = criterion)
(1.414213562373095, 149011)

Implementing a new criterion

It is of course possible that we are not satisfied by the stopping criteria that are provided by default. For example, we might check for convergence by squaring our current iterate and seeing if it equals the input value. In order to do so, we need to define our own struct and implement the required interface.

struct StopWhenSquared <: StoppingCriterion
    tol::Float64    # when do we consider things to be converged
end

Checking for convergence

Then, we need to implement the logic that checks whether an algorithm has finished, which is achieved through is_finished and is_finished!.

using AlgorithmsInterface: DefaultStoppingCriterionState

function AlgorithmsInterface.is_finished(
        problem::SqrtProblem, ::Algorithm, state::State,
        stopping_criterion::StopWhenSquared, ::DefaultStoppingCriterionState
    )
    return state.iteration > 0 && isapprox(state.iterate^2, problem.S; atol = stopping_criterion.tol)
end

Note that we automatically obtain a DefaultStoppingCriterionState as the final argument, in which we have to store the iteration at which convergence is reached. As this is a mutating operation that alters the stopping_criterion_state, we ensure that it is called exactly once per iteration, while the non-mutating version is simply used to inspect the current status.

function AlgorithmsInterface.is_finished!(
        problem::SqrtProblem, ::Algorithm, state::State,
        stopping_criterion::StopWhenSquared, stopping_criterion_state::DefaultStoppingCriterionState
    )
    if state.iteration > 0 && isapprox(state.iterate^2, problem.S; atol = criterion.tol)
        stopping_criterion_state.at_iteration = state.iteration
        return true
    else
        return false
    end
end

Reason and convergence reporting

Finally, we need to implement get_reason and indicates_convergence. These helper functions are required to interact with the logging system, to distinguish between states that are considered ongoing, stopped and converged, or stopped without convergence.

function AlgorithmsInterface.get_reason(stopping_criterion::StopWhenSquared, stopping_criterion_state::DefaultStoppingCriterionState)
    stopping_criterion_state.at_iteration >= 0 || return nothing
    return "The algorithm reached a square root after $(stopping_criterion_state.at_iteration) iterations up to a tolerance of $(stopping_criterion.tol)."
end

AlgorithmsInterface.indicates_convergence(::StopWhenSquared, ::DefaultStoppingCriterionState) = true

Convergence in action

Then we are finally ready to test out our new stopping criteria.

criterion = StopWhenSquared(1e-8)
heron_sqrt(16.0; stopping_criterion = criterion)
(4.000000000000013, 10)

Initialization

Now suppose we want to stop when successive iterates change by less than ϵ. This can be achieved by introducing a new stopping criterion again, but now we have to retain the previous iterate in order to have something to compare against. Similar to the algorithm State, we split up the data into a static part, the StoppingCriterion, and a dynamic part, the StoppingCriterionState.

struct StopWhenStable <: StoppingCriterion
    tol::Float64    # when do we consider things converged
end

mutable struct StopWhenStableState <: StoppingCriterionState
    previous_iterate::Float64       # previous value to compare to
    at_iteration::Int               # iteration at which stability was reached
    delta::Float64                  # difference between the values
end

Note that our mutable state holds both the previous_iterate, which we need to compare to, as well as the iteration at which the condition was satisfied. This is not strictly necessary, but can be convenient to have a persistent indication that convergence was reached.

In order to support these stateful criteria, again an initialization phase is needed. The relevant functions are now:

This could be implemented as follows:

function AlgorithmsInterface.initialize_stopping_state(
        ::Problem, ::Algorithm,
        stopping_criterion::StopWhenStable;
        kwargs...
    )
    return StopWhenStableState(NaN, -1, NaN)
end

function AlgorithmsInterface.initialize_stopping_state!(
        ::Problem, ::Algorithm, ::State,
        stopping_criterion::StopWhenStable,
        stopping_criterion_state::StopWhenStableState;
        kwargs...
    )
    stopping_criterion_state.previous_iterate = NaN
    stopping_criterion_state.at_iteration = -1
    stopping_criterion_state.delta = NaN
    return stopping_criterion_state
end
Note

While for this simple case this does not matter, note that there is a subtle detail associated to the initialization order of the State and StoppingCriterionState respectively. For the first initialization, AlgorithmsInterface.initialize_stopping_state is called before initialize_state. This is required since the State encapsulates the StoppingCriterionState. On the other hand, during the solver, the AlgorithmsInterface.initialize_stopping_state! is called before initialize_state. This can be important for example to ensure that the initialization time of the state is taken into account for the stopping criteria.

The remainder of the implementation follows straightforwardly, where we again take care to only mutate the stopping_criterion_state in the mutating is_finished! implementation.

function AlgorithmsInterface.is_finished!(
        ::Problem, ::Algorithm, state::State, c::StopWhenStable, st::StopWhenStableState
    )

	k = state.iteration
	if k == 0
		st.previous_iterate = state.iterate
		st.at_iteration = -1
		return false
	end

	st.delta = abs(state.iterate - st.previous_iterate)
	st.previous_iterate = state.iterate
	if st.delta < c.tol
		st.at_iteration = k
		return true
	end
	return false
end

function AlgorithmsInterface.is_finished(
        ::Problem, ::Algorithm, state::State, c::StopWhenStable, st::StopWhenStableState
    )
	k = state.iteration
	k == 0 && return false

	Δ = abs(state.iterate - st.previous_iterate)
	return Δ < c.tol
end

function AlgorithmsInterface.get_reason(c::StopWhenStable, st::StopWhenStableState)
    (st.at_iteration >= 0 && st.delta < c.tol) || return nothing
    return "The algorithm reached an approximate stable point after $(st.at_iteration) iterations; the change $(st.delta) is less than $(c.tol)."
end

AlgorithmsInterface.indicates_convergence(c::StopWhenStable, st::StopWhenStableState) = true

Convergence in action

Again, we can inspect our work:

criterion = StopWhenStable(1e-8)
heron_sqrt(16.0; stopping_criterion = criterion)
(4.0, 11)

Note that our work to ensure the correct interface payed off, as we can still compose this stopping criterion with other criteria as well:

criterion = StopWhenStable(1e-8) | StopAfterIteration(5)
heron_sqrt(16.0; stopping_criterion = criterion)
(4.000506331471994, 5)

Summary

Implementing a criterion usually means defining:

  1. A subtype of StoppingCriterion.
  2. A state subtype of StoppingCriterionState capturing dynamic fields.
  3. initialize_stopping_state and initialize_stopping_state! for setup/reset.
  4. is_finished! (mutating) and optionally is_finished (non‑mutating) variants.
  5. get_reason (return nothing or a string) for user feedback.
  6. indicates_convergence(::YourCriterion) to mark if meeting it implies convergence.

You may also implement Base.summary(io, criterion, criterion_state) for compact status reports.

Reference API

Below are the auto‑generated docs for all stopping criterion infrastructure.

AlgorithmsInterface.DefaultStoppingCriterionStateType
DefaultStoppingCriterionState <: StoppingCriterionState

A StoppingCriterionState that does not require any information besides storing the iteration number when it (last) indicated to stop).

Field

  • at_iteration::Int store the iteration number this state indicated to stop.
    • 0 means already at the start it indicated to stop
    • any negative number means that it did not yet indicate to stop.
source
AlgorithmsInterface.StopAfterType
StopAfter <: StoppingCriterion

store a threshold when to stop looking at the complete runtime. It uses time_ns() to measure the time and you provide a Period as a time limit, for example Minute(15).

Fields

  • threshold stores the Period after which to stop

Constructor

StopAfter(t)

initialize the stopping criterion to a Period t to stop after.

source
AlgorithmsInterface.StopAfterIterationType
StopAfterIteration <: StoppingCriterion

A simple stopping criterion to stop after a maximal number of iterations.

Fields

  • max_iterations stores the maximal iteration number where to stop at

Constructor

StopAfterIteration(maxIter)

initialize the functor to indicate to stop after maxIter iterations.

source
AlgorithmsInterface.StopAfterTimePeriodStateType
StopAfterTimePeriodState <: StoppingCriterionState

A state for stopping criteria that are based on time measurements, for example StopAfter.

  • start stores the starting time when the algorithm is started, that is a call with i=0.
  • time stores the elapsed time
  • at_iteration indicates at which iteration (including i=0) the stopping criterion was fulfilled and is -1 while it is not fulfilled.
source
AlgorithmsInterface.StopWhenAnyType
StopWhenAny <: StoppingCriterion

store an array of StoppingCriterion elements and indicates to stop, when any single one indicates to stop. The reason is given by the concatenation of all reasons (assuming that all non-indicating return "").

Constructors

StopWhenAny(c::Vector{N,StoppingCriterion} where N)
StopWhenAny(c::StoppingCriterion...)
source
Base.:&Method
&(s1,s2)
s1 & s2

Combine two StoppingCriterion within an StopWhenAll. If either s1 (or s2) is already an StopWhenAll, then s2 (or s1) is appended to the list of StoppingCriterion within s1 (or s2).

Example

a = StopAfterIteration(200) & StopAfter(Minute(1))

Is the same as

a = StopWhenAll(StopAfterIteration(200), StopAfter(Minute(1))
source
Base.:|Method
|(s1,s2)
s1 | s2

Combine two StoppingCriterion within an StopWhenAny. If either s1 (or s2) is already an StopWhenAny, then s2 (or s1) is appended to the list of StoppingCriterion within s1 (or s2)

Example

a = StopAfterIteration(200) | StopAfter(Minute(1))

Is the same as

a = StopWhenAny(StopAfterIteration(200), StopAfter(Minute(1)))
source

Next: Logging

With halting logic done, proceed to the logging section to instrument the same example and capture intermediate diagnostics.