diff --git a/Changelog.md b/Changelog.md index a185eb1..23bca71 100644 --- a/Changelog.md +++ b/Changelog.md @@ -17,11 +17,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `StopReasonAction`, a `LoggingAction` that reports `get_reason` at the `:Stop` context. - Defaults for `get_reason` (`nothing`) and for the type-domain `indicates_convergence` (`false`), so a criterion that implements neither no longer hits a `MethodError` from the derived convergence reporting. - Exports for `DefaultStoppingCriterionState`, `StopAfterTimePeriodState` and `GroupStoppingCriterionState`, which a downstream criterion is expected to reuse. +- `DefaultState`, a `State` with the `iterate`, `iteration` and `stopping_criterion_state` fields, and a `data` field for any additional data. +- Default `initialize_state` and `initialize_state!` methods using a `DefaultState`, so an algorithm only has to provide a `Problem`, an `Algorithm` and a `step!`. +- Default `initialize_state` and `initialize_state!` methods for any `StoppingCriterion`, using a `DefaultStoppingCriterionState`. ### Changed - `indicates_convergence` without a state moved to the type domain: a new criterion implements `indicates_convergence(::Type{YourCriterion})`, and `indicates_convergence(criterion)` forwards to it. `StopWhenAll` and `StopWhenAny` combine their children in the type domain as well, so a criterion that only implements the variant taking an instance is no longer accounted for in a group. +- `StopAfterIteration` now uses the default `initialize_state` and `initialize_state!`. ### Fixed diff --git a/docs/src/interface.md b/docs/src/interface.md index 461b76d..40dc5fc 100644 --- a/docs/src/interface.md +++ b/docs/src/interface.md @@ -58,50 +58,26 @@ end struct HeronAlgorithm <: Algorithm stopping_criterion # will be plugged in later (any StoppingCriterion) end - -mutable struct HeronState <: State - iterate::Float64 # current iterate - iteration::Int # current iteration count - stopping_criterion_state # will be plugged in later (any StoppingCriterionState) -end ``` +For the state, we use the [`DefaultState`](@ref), which stores the `iterate`, the `iteration` count and the `stopping_criterion_state`. +If you need more, see [defining your own state](@ref sec_custom_state) below. + ### Initialization -In order to start implementing the core parts of our algorithm, we start at the very beginning. There are two main entry points provided by the interface: - [`initialize_state`](@ref) constructs an entirely new state for the algorithm - [`initialize_state!`](@ref) (in-place) reset of an existing state. -An example implementation might look like: - -```@example Heron -function AlgorithmsInterface.initialize_state(problem::SqrtProblem, algorithm::HeronAlgorithm; kwargs...) - x0 = rand() # random initial guess - stopping_criterion_state = initialize_state(problem, algorithm, algorithm.stopping_criterion) - return HeronState(x0, 0, stopping_criterion_state) -end - -function AlgorithmsInterface.initialize_state!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState; kwargs...) - # reset the state for the algorithm - state.iterate = rand() - state.iteration = 0 - - # reset the state for the stopping criterion - state = AlgorithmsInterface.initialize_state!( - problem, algorithm, algorithm.stopping_criterion, state.stopping_criterion_state - ) - return state -end -``` +Both have default implementations that work with a [`DefaultState`](@ref), so there is nothing to define here. ### Iteration steps Algorithms define a mutable step via [`step!`](@ref). For Heron's method: ```@example Heron -function AlgorithmsInterface.step!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState) +function AlgorithmsInterface.step!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::DefaultState) S = problem.S x = state.iterate state.iterate = 0.5 * (x + S / x) @@ -129,7 +105,7 @@ With these definitions in place you can already run (assuming you also choose a function heron_sqrt(x; maxiter = 10) prob = SqrtProblem(x) alg = HeronAlgorithm(StopAfterIteration(maxiter)) - return solve(prob, alg) # allocates & runs + return solve(prob, alg; iterate = 1.0) # initial guess, allocates & runs end println("Approximate sqrt: ", heron_sqrt(16.0)) @@ -139,6 +115,78 @@ Note that [`solve`](@ref) will default to returning `state.iterate`. If desired, this can be customized by altering [`finalize_state!`](@ref). We will refine this example with better halting logic and logging shortly. +### Carrying extra data + +Any additional data the algorithm needs can be stored in the `data::D` field of the [`DefaultState{V, S, D}`](@ref). + +```@example Heron +struct TrackingHeronAlgorithm <: Algorithm + stopping_criterion +end + +function AlgorithmsInterface.step!(problem::SqrtProblem, ::TrackingHeronAlgorithm, state::DefaultState) + S = problem.S + x = state.iterate + state.iterate = 0.5 * (x + S / x) + push!(state.data.residuals, abs(state.iterate^2 - S)) + return state +end + +problem = SqrtProblem(16.0) +tracking = TrackingHeronAlgorithm(StopAfterIteration(5)) +state = initialize_state(problem, tracking, 1.0, (; residuals = Float64[])) +solve!(problem, tracking, state) +state.data.residuals +``` + +Here the initial iterate and the data are passed positionally, as `initialize_state(problem, algorithm, iterate, state_data)`. + +### [Defining your own state](@id sec_custom_state) + +If you need named fields or want to dispatch on the state, subtype [`State`](@ref) instead. +Such a state has to provide the same three properties, as well as its own initialization methods: + +```@example Heron +mutable struct HeronState <: State + iterate::Float64 # current iterate + iteration::Int # current iteration count + stopping_criterion_state # any StoppingCriterionState +end + +function AlgorithmsInterface.initialize_state(problem::SqrtProblem, algorithm::HeronAlgorithm; kwargs...) + x0 = rand() # random initial guess + stopping_criterion_state = initialize_state(problem, algorithm, algorithm.stopping_criterion) + return HeronState(x0, 0, stopping_criterion_state) +end + +function AlgorithmsInterface.initialize_state!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState; kwargs...) + # reset the state for the algorithm + state.iterate = rand() + state.iteration = 0 + + # reset the state for the stopping criterion + AlgorithmsInterface.initialize_state!( + problem, algorithm, algorithm.stopping_criterion, state.stopping_criterion_state + ) + return state +end + +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 +``` + +Since this state picks its own initial guess, the `iterate` keyword is no longer needed: + +```@example Heron +println("Approximate sqrt: ", solve(SqrtProblem(16.0), HeronAlgorithm(StopAfterIteration(10)))) +``` + +Note that the three required *properties* do not enforce a struct with these *fields*, and the properties can be emulated with correct implementations of `Base.getproperty`. + ## Reference: Core interface types & functions Below are the automatic API docs for the core interface pieces. Read them after grasping the example above – the intent should now be clearer. @@ -172,7 +220,7 @@ Private = true ```@autodocs Modules = [AlgorithmsInterface] -Pages = ["interface/state.jl"] +Pages = ["interface/state.jl", "default_state.jl"] Order = [:type, :function] Private = true ``` diff --git a/docs/src/logging.md b/docs/src/logging.md index 2dcabc5..935fdc2 100644 --- a/docs/src/logging.md +++ b/docs/src/logging.md @@ -46,26 +46,7 @@ struct HeronAlgorithm <: Algorithm stopping_criterion end -mutable struct HeronState <: State - iterate::Float64 - iteration::Int - stopping_criterion_state -end - -function AlgorithmsInterface.initialize_state(problem::SqrtProblem, algorithm::HeronAlgorithm; kwargs...) - x0 = rand() - stopping_criterion_state = initialize_state(problem, algorithm, algorithm.stopping_criterion) - return HeronState(x0, 0, stopping_criterion_state) -end - -function AlgorithmsInterface.initialize_state!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState; kwargs...) - state.iterate = rand() - state.iteration = 0 - initialize_state!(problem, algorithm, algorithm.stopping_criterion, state.stopping_criterion_state) - return state -end - -function AlgorithmsInterface.step!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState) +function AlgorithmsInterface.step!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::DefaultState) S = problem.S x = state.iterate state.iterate = 0.5 * (x + S / x) @@ -75,7 +56,7 @@ end function heron_sqrt(x; stopping_criterion = StopAfterIteration(10)) prob = SqrtProblem(x) alg = HeronAlgorithm(stopping_criterion) - return solve(prob, alg) # allocates & runs + return solve(prob, alg; iterate = 1.0) # allocates & runs end nothing # hide ``` @@ -211,7 +192,7 @@ function AlgorithmsInterface.handle_message!( action::CaptureHistory, problem::SqrtProblem, algorithm::HeronAlgorithm, - state::HeronState; + state::DefaultState; kwargs... ) push!(action.iterates, state.iterate) @@ -287,7 +268,7 @@ end StatsCollector() = StatsCollector(0, 0.0, 0.0) function AlgorithmsInterface.handle_message!( - action::StatsCollector, problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState; + action::StatsCollector, problem::SqrtProblem, algorithm::HeronAlgorithm, state::DefaultState; kwargs... ) action.count += 1 @@ -415,7 +396,7 @@ Here we will illustrate this by a slight adaptation of our algorithm, which coul To emit a custom logging event from within your algorithm, call [`emit_message`](@ref): ```@example Heron -function AlgorithmsInterface.step!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState) +function AlgorithmsInterface.step!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::DefaultState) # Suppose we check for numerical issues if !isfinite(state.iterate) || mod(state.iteration, 10) == 0 emit_message(problem, algorithm, state, :Restart) diff --git a/docs/src/stopping_criterion.md b/docs/src/stopping_criterion.md index 944ae6d..4c762cb 100644 --- a/docs/src/stopping_criterion.md +++ b/docs/src/stopping_criterion.md @@ -27,7 +27,7 @@ The package ships several concrete [`StoppingCriterion`](@ref)s: Each criterion has an associated [`StoppingCriterionState`](@ref) storing dynamic data (iteration when met, elapsed time, etc.). -Recall our [example implementation](@ref sec_heron) for Heron's method, where we added a `stopping_criterion` to the `Algorithm`, as well as a `stopping_criterion_state` to the `State`. +Recall our [example implementation](@ref sec_heron) for Heron's method, where the `Algorithm` carries a `stopping_criterion` and the `State` a `stopping_criterion_state`. ```@example Heron using AlgorithmsInterface @@ -39,41 +39,24 @@ 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`](@ref) as well. -This can conveniently be done through the same initialization functions we used for initializing the state: +The first core component to enable working with stopping criteria is to initialize a [`StoppingCriterionState`](@ref) as well. +This is done through the same initialization functions we used for initializing the state: - [`initialize_state`](@ref) constructs an entirely new stopping state for the algorithm - [`initialize_state!`](@ref) (in-place) reset of an existing stopping state. -```@example Heron -function AlgorithmsInterface.initialize_state(problem::SqrtProblem, algorithm::HeronAlgorithm; kwargs...) - x0 = rand() # random initial guess - stopping_criterion_state = initialize_state(problem, algorithm, algorithm.stopping_criterion) - return HeronState(x0, 0, stopping_criterion_state) -end - -function AlgorithmsInterface.initialize_state!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState; kwargs...) - # reset the state for the algorithm - state.iterate = rand() - state.iteration = 0 +For a [`DefaultState`](@ref) this is already taken care of, by a default that roughly does - # reset the state for the stopping criterion - state = AlgorithmsInterface.initialize_state!( - problem, algorithm, algorithm.stopping_criterion, state.stopping_criterion_state - ) - return state +```julia +function AlgorithmsInterface.initialize_state(problem::Problem, algorithm::Algorithm; iterate, kwargs...) + stopping_criterion_state = initialize_state(problem, algorithm, algorithm.stopping_criterion; kwargs...) + return DefaultState(iterate, stopping_criterion_state) end ``` @@ -82,7 +65,7 @@ end 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: ```@example Heron -function AlgorithmsInterface.step!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::HeronState) +function AlgorithmsInterface.step!(problem::SqrtProblem, algorithm::HeronAlgorithm, state::DefaultState) S = problem.S x = state.iterate state.iterate = 0.5 * (x + S / x) @@ -112,7 +95,7 @@ We can again combine everything into a single function, but now make the stoppin function heron_sqrt(x; stopping_criterion) prob = SqrtProblem(x) alg = HeronAlgorithm(stopping_criterion) - return solve(prob, alg) # allocates & runs + return solve(prob, alg; iterate = 1.0) # allocates & runs end heron_sqrt(2; stopping_criterion = StopAfterIteration(10)) @@ -148,6 +131,9 @@ Suppose we want to stop when successive iterates change by less than `ϵ`, we co In order to do so, we need to define our own structs and implement the required interface. Again, we split up the data into a _static_ part, the [`StoppingCriterion`](@ref), and a _dynamic_ part, the [`StoppingCriterionState`](@ref). +By default, a criterion gets a [`DefaultStoppingCriterionState`](@ref), which only records the iteration at which it triggered. +Ours also has to remember the previous iterate, so it needs its own state. + ```@example Heron struct StopWhenStable <: StoppingCriterion tol::Float64 # when do we consider things converged @@ -253,8 +239,8 @@ The variant taking a criterion simply forwards to the type, and the two-argument ```@example Heron criterion = StopWhenStable(1e-8) -state = AlgorithmsInterface.initialize_state(SqrtProblem(16.0), HeronAlgorithm(criterion), criterion) -indicates_convergence(criterion), indicates_convergence(criterion, state) +criterion_state = AlgorithmsInterface.initialize_state(SqrtProblem(16.0), HeronAlgorithm(criterion), criterion) +indicates_convergence(criterion), indicates_convergence(criterion, criterion_state) ``` The criterion always *could* indicate convergence, but its fresh state has not yet seen it happen. @@ -274,7 +260,7 @@ Since [`solve`](@ref) returns only the iterate, we use [`solve!`](@ref) with a s function heron_verdict(x, criterion) problem = SqrtProblem(x) algorithm = HeronAlgorithm(criterion) - state = AlgorithmsInterface.initialize_state(problem, algorithm) + state = AlgorithmsInterface.initialize_state(problem, algorithm, 1.0) solve!(problem, algorithm, state) @@ -320,16 +306,19 @@ heron_sqrt(16.0; stopping_criterion = criterion) ### Summary -Implementing a criterion usually means defining: +Implementing a criterion means defining: 1. A subtype of [`StoppingCriterion`](@ref). -2. A state subtype of [`StoppingCriterionState`](@ref) capturing dynamic fields, including an `at_iteration` recording when the criterion triggered. -3. `initialize_state` and `initialize_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, gated on `is_active`. -6. `indicates_convergence(::Type{YourCriterion})` to mark if meeting it implies convergence. +2. `is_finished!` (mutating) and optionally `is_finished` (non‑mutating) variants. +3. `get_reason` (return `nothing` or a string) for user feedback, gated on `is_active`. +4. `indicates_convergence(::Type{YourCriterion})` to mark if meeting it implies convergence. The `(criterion,)` and the `(criterion, criterion_state)` variant are derived from this one and do not need to be defined. +If the criterion needs more state than the [`DefaultStoppingCriterionState`](@ref), it additionally defines: + +* A state subtype of [`StoppingCriterionState`](@ref) capturing its dynamic fields, including an `at_iteration` recording when the criterion triggered. +* `initialize_state` and `initialize_state!` for setup/reset. + You may also implement `Base.summary(io, criterion, criterion_state)` for compact status reports, and `is_active(criterion, criterion_state)` if your state does not record its status in an `at_iteration` property. diff --git a/src/AlgorithmsInterface.jl b/src/AlgorithmsInterface.jl index 74ae2f6..3626591 100644 --- a/src/AlgorithmsInterface.jl +++ b/src/AlgorithmsInterface.jl @@ -18,12 +18,14 @@ include("interface/state.jl") include("interface/interface.jl") include("stopping_criterion.jl") +include("default_state.jl") include("logging.jl") include("test_suite.jl") # general interface export Algorithm, Problem, State +export DefaultState export initialize_state, initialize_state! export finalize_state! diff --git a/src/default_state.jl b/src/default_state.jl new file mode 100644 index 0000000..aaf98f1 --- /dev/null +++ b/src/default_state.jl @@ -0,0 +1,68 @@ +@doc """ + DefaultState <: State + +A [`State`](@ref) that stores the required properties for the [`State`](@ref) interface, +together with a `data` field for any further data an [`Algorithm`](@ref) needs. +It is the state used by the default [`initialize_state`](@ref) and [`initialize_state!`](@ref). + +For more involved states, consider defining your own subtype of [`State`](@ref) instead. + +# Fields + +* `iterate` stores the current iterate ``x^{(k)}``. +* `stopping_criterion_state` stores the [`StoppingCriterionState`](@ref) belonging to the [`StoppingCriterion`](@ref) of the [`Algorithm`](@ref). +* `data` stores any further data, for example as a `NamedTuple`, or `nothing` if not needed. +* `iteration::Int` stores the current iteration step ``k`` that is currently being performed or was last performed. + +# Constructor + + DefaultState(iterate, stopping_criterion_state, data = nothing) + +Initialize the state to start at `iterate` with iteration `0`. +""" +mutable struct DefaultState{V, S <: StoppingCriterionState, D} <: State + iterate::V + stopping_criterion_state::S + data::D + iteration::Int +end + +DefaultState(iterate, stopping_criterion_state::StoppingCriterionState, data = nothing) = + DefaultState(iterate, stopping_criterion_state, data, 0) + +# Default initialization, documented in `_doc_init_state` +function initialize_state( + problem::Problem, algorithm::Algorithm, iterate, state_data = nothing; kwargs... + ) + stopping_criterion_state = initialize_state( + problem, algorithm, algorithm.stopping_criterion; kwargs... + ) + return DefaultState(iterate, stopping_criterion_state, state_data) +end + +function initialize_state( + problem::Problem, algorithm::Algorithm; iterate, state_data = nothing, kwargs... + ) + return initialize_state(problem, algorithm, iterate, state_data; kwargs...) +end + +function initialize_state!( + problem::Problem, algorithm::Algorithm, state::DefaultState, iterate, + state_data = state.data; kwargs..., + ) + state.iterate = iterate + state.data = state_data + state.iteration = 0 + initialize_state!( + problem, algorithm, algorithm.stopping_criterion, state.stopping_criterion_state; + kwargs..., + ) + return state +end + +function initialize_state!( + problem::Problem, algorithm::Algorithm, state::DefaultState; + iterate = state.iterate, state_data = state.data, kwargs..., + ) + return initialize_state!(problem, algorithm, state, iterate, state_data; kwargs...) +end diff --git a/src/interface/interface.jl b/src/interface/interface.jl index f37d513..cc6e831 100644 --- a/src/interface/interface.jl +++ b/src/interface/interface.jl @@ -5,6 +5,17 @@ _doc_init_state = """ Initialize a [`State`](@ref) based on a [`Problem`](@ref) and an [`Algorithm`](@ref). The `kwargs...` should allow to initialize for example the initial point. This can be done in-place for `state`, then only values that did change have to be provided. + +By default, both work with a [`DefaultState`](@ref), and also accept positional arguments: + + state = initialize_state(problem, algorithm, iterate, state_data; kwargs...) + state = initialize_state!(problem, algorithm, state, iterate, state_data; kwargs...) + +Here `state_data` is stored in the `data` field, and defaults to `nothing` when allocating and to `state.data` when resetting. +The remaining `kwargs...` are passed on to the initialization of the [`StoppingCriterion`](@ref). + +Custom states should implement the keyword form, to avoid ambiguities with +`initialize_state(problem, algorithm, stopping_criterion)`. """ function initialize_state end diff --git a/src/stopping_criterion.jl b/src/stopping_criterion.jl index 6bed86e..7e282ac 100644 --- a/src/stopping_criterion.jl +++ b/src/stopping_criterion.jl @@ -3,19 +3,18 @@ An abstract type to represent a stopping criterion of an [`Algorithm`](@ref). -A concrete [`StoppingCriterion`](@ref) should also implement an -[`initialize_state(problem::Problem, algorithm::Algorithm, stopping_criterion::StoppingCriterion; kwargs...)`](@ref) function to create its accompanying -[`StoppingCriterionState`](@ref), as well as the corresponding mutating variant to reset such a [`StoppingCriterionState`](@ref). - It should usually implement * [`is_finished!`](@ref)`(problem, algorithm, state, stopping_criterion, stopping_criterion_state)` * [`is_finished`](@ref)`(problem, algorithm, state, stopping_criterion, stopping_criterion_state)` -* [`initialize_state!`](@ref)`(problem, algorithm, stopping_criterion)` -* [`initialize_state`](@ref)`(problem, algorithm, stopping_criterion)` * [`get_reason`](@ref)`(stopping_criterion, stopping_criterion_state)` * [`indicates_convergence`](@ref)`(::Type{<:StoppingCriterion})` +By default, its [`StoppingCriterionState`](@ref) is a [`DefaultStoppingCriterionState`](@ref). +A criterion that needs more should implement +[`initialize_state(problem::Problem, algorithm::Algorithm, stopping_criterion::StoppingCriterion; kwargs...)`](@ref) +to create its own [`StoppingCriterionState`](@ref), as well as the mutating variant to reset it. + Note that only [`indicates_convergence`](@ref) has to be implemented: it answers whether meeting this criterion *would* mean convergence, which is a static property of the criterion type alone. Both the variant taking a criterion and the one that additionally takes a [`StoppingCriterionState`](@ref), answering whether it *did* happen are derived from it. @@ -563,6 +562,8 @@ end A [`StoppingCriterionState`](@ref) that does not require any information besides storing the iteration number at which it (last) indicated to stop. +It is also the default return value for [`initialize_state`](@ref) for a [`StoppingCriterion`](@ref). + # Fields * `at_iteration::Int` stores the iteration number at which this state indicated to stop. @@ -574,9 +575,11 @@ mutable struct DefaultStoppingCriterionState <: StoppingCriterionState DefaultStoppingCriterionState() = new(-1) end -initialize_state(::Problem, ::Algorithm, ::StopAfterIteration; kwargs...) = DefaultStoppingCriterionState() +# Fallbacks for criteria without a state of their own +initialize_state(::Problem, ::Algorithm, ::StoppingCriterion; kwargs...) = + DefaultStoppingCriterionState() function initialize_state!( - ::Problem, ::Algorithm, ::StopAfterIteration, + ::Problem, ::Algorithm, ::StoppingCriterion, stopping_criterion_state::DefaultStoppingCriterionState; kwargs..., ) @@ -584,7 +587,6 @@ function initialize_state!( return stopping_criterion_state end - function is_finished( ::Problem, ::Algorithm, state::State, stop_after_iteration::StopAfterIteration, diff --git a/test/state.jl b/test/state.jl new file mode 100644 index 0000000..aedf3b8 --- /dev/null +++ b/test/state.jl @@ -0,0 +1,161 @@ +# Tests for the default state and the default state initialization + +using Test +using AlgorithmsInterface +using AlgorithmsInterface: Test as AIT +using AlgorithmsInterface: increment! +using Dates + +# Fixtures +# -------- + +# A problem and an algorithm without their own state type +struct HalvingProblem <: Problem + target::Float64 +end + +struct Halving{S <: StoppingCriterion} <: Algorithm + stopping_criterion::S +end + +function AlgorithmsInterface.step!(problem::HalvingProblem, ::Halving, state::DefaultState) + state.iterate = (state.iterate + problem.target) / 2 + return state +end + +# Same, but storing a step counter in `data` +struct CountingHalving{S <: StoppingCriterion} <: Algorithm + stopping_criterion::S +end + +mutable struct StepCounter + steps::Int +end + +function AlgorithmsInterface.step!( + problem::HalvingProblem, ::CountingHalving, state::DefaultState, + ) + state.iterate = (state.iterate + problem.target) / 2 + state.data.steps += 1 + return state +end + +# Tests +# ----- + +@testset "DefaultState construction" begin + scs = DefaultStoppingCriterionState() + + state = DefaultState(2.0, scs) + @test state isa DefaultState{Float64, typeof(scs), Nothing} + @test state.iterate == 2.0 + @test state.stopping_criterion_state === scs + @test state.data === nothing + @test state.iteration == 0 + + # `iteration` is the fourth positional argument + @test DefaultState(2.0, scs, nothing, 3).iteration == 3 + + # `data` can be anything, and its contents are not exposed as properties + named_tuple_state = DefaultState(2.0, scs, (; gradient = 1.0)) + @test named_tuple_state.data.gradient == 1.0 + @test !hasproperty(named_tuple_state, :gradient) + + dict_state = DefaultState(2.0, scs, Dict{Symbol, Any}(:gradient => 1.0)) + @test dict_state.data[:gradient] == 1.0 + dict_state.data[:hessian] = 2.0 + @test dict_state.data[:hessian] == 2.0 +end + +@testset "DefaultState satisfies the State interface" begin + problem = AIT.DummyProblem() + algorithm = AIT.DummyAlgorithm(StopAfterIteration(5)) + state = DefaultState(2.0, DefaultStoppingCriterionState()) + + @test increment!(state) === state + @test state.iteration == 1 + @test finalize_state!(problem, algorithm, state) == 2.0 + @test !is_finished!(problem, algorithm, state) + @test !is_active(algorithm, state) +end + +@testset "initialize_state defaults to a DefaultState" begin + problem = AIT.DummyProblem() + algorithm = AIT.DummyAlgorithm(StopAfterIteration(5)) + + state = initialize_state(problem, algorithm, 2.0) + @test state isa DefaultState + @test state.iterate == 2.0 + @test state.iteration == 0 + @test state.data === nothing + # the accompanying criterion state comes from the algorithm's own stopping criterion + @test state.stopping_criterion_state isa DefaultStoppingCriterionState + @test state.stopping_criterion_state.at_iteration == -1 + + # `state_data` is positional + @test initialize_state(problem, algorithm, 2.0, (; a = 1)).data == (; a = 1) + + # the keyword form forwards to the same implementation + @test initialize_state(problem, algorithm; iterate = 2.0, state_data = (; a = 1)).data == + (; a = 1) + + # type stable + @test @inferred(initialize_state(problem, algorithm, 2.0)) isa DefaultState + + # the `iterate` keyword is required + @test_throws UndefKeywordError initialize_state(problem, algorithm) + + # criteria with their own state still use it + time_algorithm = AIT.DummyAlgorithm(StopAfter(Second(1))) + @test initialize_state( + problem, time_algorithm, 2.0, + ).stopping_criterion_state isa StopAfterTimePeriodState +end + +@testset "initialize_state! resets a DefaultState" begin + problem = AIT.DummyProblem() + algorithm = AIT.DummyAlgorithm(StopAfterIteration(5)) + state = initialize_state(problem, algorithm, 2.0, (; a = 1)) + + state.iteration = 4 + state.stopping_criterion_state.at_iteration = 4 + + # returns the state, not the stopping criterion state + @test initialize_state!(problem, algorithm, state) === state + @test state.iteration == 0 + @test state.stopping_criterion_state.at_iteration == -1 + # the iterate and data are kept if not provided + @test state.iterate == 2.0 + @test state.data == (; a = 1) + + # positionally: iterate, then the data + initialize_state!(problem, algorithm, state, 3.0, (; a = 9)) + @test state.iterate == 3.0 + @test state.data == (; a = 9) + initialize_state!(problem, algorithm, state, 3.5) + @test state.iterate == 3.5 + @test state.data == (; a = 9) + + # the keyword form forwards to the same implementation + initialize_state!(problem, algorithm, state; iterate = 4.0, state_data = (; a = 7)) + @test state.iterate == 4.0 + @test state.data == (; a = 7) +end + +@testset "an algorithm without a state type of its own can be solved" begin + problem = HalvingProblem(1.0) + algorithm = Halving(StopAfterIteration(40)) + + # only `step!` is defined + @test solve(problem, algorithm; iterate = 100.0) ≈ 1.0 + + state = initialize_state(problem, algorithm, 100.0) + @test solve!(problem, algorithm, state) ≈ 1.0 + @test state.iteration == 40 + + # with data + counting = CountingHalving(StopAfterIteration(7)) + counting_state = initialize_state(problem, counting, 100.0, StepCounter(0)) + solve!(problem, counting, counting_state) + @test counting_state.data.steps == 7 +end diff --git a/test/stopping_criterion.jl b/test/stopping_criterion.jl index 7948fe3..637af40 100644 --- a/test/stopping_criterion.jl +++ b/test/stopping_criterion.jl @@ -9,18 +9,10 @@ problem = AIT.DummyProblem() # --------------- # `StopAfterIteration` and `StopAfter` both have `indicates_convergence == false`, so neither can # exercise the convergence-versus-fallback logic. This one does. +# It also leaves its state to the default, so it exercises the `initialize_state` fallbacks. struct StopWhenConverged <: StoppingCriterion at::Int end -AlgorithmsInterface.initialize_state(::Problem, ::Algorithm, ::StopWhenConverged; kwargs...) = - DefaultStoppingCriterionState() -function AlgorithmsInterface.initialize_state!( - ::Problem, ::Algorithm, ::StopWhenConverged, - stopping_criterion_state::DefaultStoppingCriterionState; kwargs..., - ) - stopping_criterion_state.at_iteration = -1 - return stopping_criterion_state -end function AlgorithmsInterface.is_finished( ::Problem, ::Algorithm, state::State, stop_when_converged::StopWhenConverged, ::DefaultStoppingCriterionState, @@ -78,18 +70,9 @@ end AlgorithmsInterface.get_reason(::CountingCriterion, ::CountingCriterionState) = nothing AlgorithmsInterface.indicates_convergence(::Type{CountingCriterion}) = false -# Indicates to stop immediately, but implements nothing beyond the bare minimum: no `get_reason` -# and no `indicates_convergence`, so it exercises the fallbacks for both. +# Indicates to stop immediately, but implements nothing beyond the bare minimum: no `get_reason`, +# no `indicates_convergence` and no state of its own, so it exercises the fallbacks for all three. struct SilentCriterion <: StoppingCriterion end -AlgorithmsInterface.initialize_state(::Problem, ::Algorithm, ::SilentCriterion; kwargs...) = - DefaultStoppingCriterionState() -function AlgorithmsInterface.initialize_state!( - ::Problem, ::Algorithm, ::SilentCriterion, - stopping_criterion_state::DefaultStoppingCriterionState; kwargs..., - ) - stopping_criterion_state.at_iteration = -1 - return stopping_criterion_state -end AlgorithmsInterface.is_finished( ::Problem, ::Algorithm, ::State, ::SilentCriterion, ::DefaultStoppingCriterionState ) = true @@ -121,6 +104,32 @@ AlgorithmsInterface.is_active( ) = stopping_criterion_state.stopped AlgorithmsInterface.indicates_convergence(::Type{UnconventionalCriterion}) = true +@testset "DefaultStoppingCriterionState" begin + scs = DefaultStoppingCriterionState() + @test scs isa StoppingCriterionState + @test scs.at_iteration == -1 +end + +@testset "a criterion without a state of its own falls back to the default" begin + # `SilentCriterion` provides neither `initialize_state` nor `initialize_state!` + silent = SilentCriterion() + algorithm = AIT.DummyAlgorithm(silent) + + scs = initialize_state(problem, algorithm, silent) + @test scs isa DefaultStoppingCriterionState + @test scs.at_iteration == -1 + + scs.at_iteration = 3 + @test initialize_state!(problem, algorithm, silent, scs) === scs + @test scs.at_iteration == -1 + + # a criterion that does provide them keeps its own state type + counting = CountingCriterion() + @test initialize_state( + problem, AIT.DummyAlgorithm(counting), counting, + ) isa CountingCriterionState +end + @testset "StopAfterIteration" begin s1 = StopAfterIteration(2) @test s1 isa StoppingCriterion