Implementing a Strategy
This guide walks you through implementing a complete strategy family using the AbstractStrategy contract. We use Collocation and DirectShooting discretizers as concrete examples — real strategies from the CTDirect.jl package.
Read the Architecture page first to understand the type hierarchies and module structure.
The Two-Level Contract
Every strategy implements a two-level contract that separates static metadata from dynamic configuration:
- Type-level methods (
id,metadata) can be called on the type itself — no object needed. This enables registry lookup, option routing, and validation before any resource allocation. - Instance-level methods (
options) are called on instances — they carry the actual configuration with provenance tracking (user vs default).
Defining a Strategy Family
A strategy family is an intermediate abstract type that groups related strategies. Here we define a family for optimal control discretizers:
abstract type AbstractOptimalControlDiscretizer <: Strategies.AbstractStrategy endThis type enables:
- Grouping discretizers in a
StrategyRegistryby family - Dispatching on the family in option routing
- Adding methods common to all discretizers
Implementing a Concrete Strategy: Collocation
Step 1 — Define the struct
A strategy struct needs exactly one field: options::Strategies.StrategyOptions.
struct Collocation <: AbstractOptimalControlDiscretizer
options::Strategies.StrategyOptions
endStep 2 — Implement id
The id method returns a unique Symbol identifier for the strategy. It is a type-level method.
Strategies.id(::Type{<:Collocation}) = :collocationStep 3 — Define default values
Use the __name() convention for private default functions:
__collocation_grid_size()::Int = 250
__collocation_scheme()::Symbol = :midpointStep 4 — Implement metadata
The metadata method returns a StrategyMetadata containing OptionDefinition objects. It is a type-level method.
function Strategies.metadata(::Type{<:Collocation})
return Strategies.StrategyMetadata(
Options.OptionDefinition(
name = :grid_size,
type = Int,
default = __collocation_grid_size(),
description = "Number of time steps for the collocation grid",
),
Options.OptionDefinition(
name = :scheme,
type = Symbol,
default = __collocation_scheme(),
description = "Time integration scheme (e.g., :midpoint, :trapeze)",
),
)
endLet's verify the metadata:
julia> Strategies.metadata(Collocation)StrategyMetadata with 2 options: ├─ grid_size :: Int64 (default: 250) │ description: Number of time steps for the collocation grid └─ scheme :: Symbol (default: midpoint) description: Time integration scheme (e.g., :midpoint, :trapeze)
Step 5 — Implement the constructor
The constructor uses build_strategy_options to validate and merge user-provided options with defaults:
function Collocation(; mode::Symbol = :strict, kwargs...)
opts = Strategies.build_strategy_options(Collocation; mode = mode, kwargs...)
return Collocation(opts)
endStep 6 — Implement options
The options method provides instance-level access to the configured options:
Strategies.options(c::Collocation) = c.optionsNow let's create instances and inspect them:
julia> c = Collocation()Collocation (instance, id: :collocation) ├─ grid_size = 250 [default] └─ scheme = midpoint [default] Tip: use describe(Collocation) to see all available options.
julia> c = Collocation(grid_size = 500, scheme = :trapeze)Collocation (instance, id: :collocation) ├─ grid_size = 500 [user] └─ scheme = trapeze [user] Tip: use describe(Collocation) to see all available options.
Step 7 — Verify the contract
Use validate_strategy_contract to check that all contract methods are correctly implemented:
julia> Strategies.validate_strategy_contract(Collocation)ERROR: UndefVarError: `validate_strategy_contract` not defined in `CTSolvers.Strategies` Suggestion: check for spelling errors or missing imports.
Step 8 — Access options
The StrategyOptions object tracks both values and their provenance:
julia> c = Collocation(grid_size = 100)Collocation (instance, id: :collocation) ├─ grid_size = 100 [user] └─ scheme = midpoint [default] Tip: use describe(Collocation) to see all available options.julia> Strategies.options(c)StrategyOptions with 2 options: ├─ grid_size = 100 [user] └─ scheme = midpoint [default]
julia> Strategies.options(c)[:grid_size]100
julia> Strategies.source(Strategies.options(c), :grid_size):user
julia> Strategies.is_user(Strategies.options(c), :grid_size)true
julia> Strategies.is_default(Strategies.options(c), :scheme)true
Error handling
A typo in an option name triggers a helpful error with Levenshtein suggestion:
julia> Collocation(grdi_size = 500)ERROR: Control Toolbox Error ❌ Error: CTBase.Exceptions.IncorrectArgument, Unknown options provided for Collocation Unrecognized options: [:grdi_size] These options are not defined in the metadata of Collocation. Available options: :grid_size, :scheme Suggestions for :grdi_size: - :grid_size [distance: 2] - :scheme [distance: 8] If you are certain these options exist for the backend, use permissive mode: Collocation(...; mode=:permissive) 📂 Context: build_strategy_options - strict validation
Adding a Second Strategy: DirectShooting
The same pattern applies to any strategy in the family. Here is DirectShooting with different options:
struct DirectShooting <: AbstractOptimalControlDiscretizer
options::Strategies.StrategyOptions
end
Strategies.id(::Type{<:DirectShooting}) = :direct_shooting
__shooting_grid_size()::Int = 100
function Strategies.metadata(::Type{<:DirectShooting})
return Strategies.StrategyMetadata(
Options.OptionDefinition(
name = :grid_size,
type = Int,
default = __shooting_grid_size(),
description = "Number of shooting intervals",
),
)
end
function DirectShooting(; mode::Symbol = :strict, kwargs...)
opts = Strategies.build_strategy_options(DirectShooting; mode = mode, kwargs...)
return DirectShooting(opts)
end
Strategies.options(ds::DirectShooting) = ds.optionsBoth Collocation and DirectShooting define a :grid_size option, but with different defaults (250 vs 100) and descriptions. Each strategy has its own independent OptionDefinition set.
julia> Strategies.validate_strategy_contract(DirectShooting)ERROR: UndefVarError: `validate_strategy_contract` not defined in `CTSolvers.Strategies` Suggestion: check for spelling errors or missing imports.
julia> DirectShooting()DirectShooting (instance, id: :direct_shooting) └─ grid_size = 100 [default] Tip: use describe(DirectShooting) to see all available options.
julia> DirectShooting(grid_size = 50)DirectShooting (instance, id: :direct_shooting) └─ grid_size = 50 [user] Tip: use describe(DirectShooting) to see all available options.
Registering the Family
A StrategyRegistry maps abstract family types to their concrete strategies. This enables lookup by symbol and automated construction.
julia> registry = Strategies.create_registry( AbstractOptimalControlDiscretizer => (Collocation, DirectShooting), )StrategyRegistry with 1 family: └─ Main.AbstractOptimalControlDiscretizer => (:collocation, :direct_shooting)
Query the registry:
julia> Strategies.strategy_ids(AbstractOptimalControlDiscretizer, registry)(:collocation, :direct_shooting)
julia> Strategies.type_from_id(:collocation, AbstractOptimalControlDiscretizer, registry)Main.Collocation
Build a strategy from the registry:
julia> Strategies.build_strategy(:collocation, AbstractOptimalControlDiscretizer, registry; grid_size = 300)Collocation (instance, id: :collocation) ├─ grid_size = 300 [user] └─ scheme = midpoint [default] Tip: use describe(Collocation) to see all available options.
julia> Strategies.build_strategy(:direct_shooting, AbstractOptimalControlDiscretizer, registry; grid_size = 50)DirectShooting (instance, id: :direct_shooting) └─ grid_size = 50 [user] Tip: use describe(DirectShooting) to see all available options.
Integration with Method Tuples
In the full CTSolvers pipeline, a method tuple like (:collocation, :adnlp, :ipopt) identifies one strategy per family. The orchestration layer extracts the right ID for each family:
julia> method = (:collocation, :adnlp, :ipopt)(:collocation, :adnlp, :ipopt)julia> Strategies.extract_id_from_method(method, AbstractOptimalControlDiscretizer, registry):collocation
Build a strategy directly from a method tuple:
julia> Strategies.build_strategy_from_method( method, AbstractOptimalControlDiscretizer, registry; grid_size = 500, scheme = :trapeze, )ERROR: UndefVarError: `build_strategy_from_method` not defined in `CTSolvers.Strategies` Suggestion: check for spelling errors or missing imports.
See Orchestration & Routing for the full multi-strategy routing system.
Introspection
The Strategies API provides type-level introspection without instantiation:
julia> Strategies.option_names(Collocation)(:grid_size, :scheme)
julia> Strategies.option_names(DirectShooting)(:grid_size,)
julia> Strategies.option_defaults(Collocation)(grid_size = 250, scheme = :midpoint)
julia> Strategies.option_defaults(DirectShooting)(grid_size = 100,)
julia> Strategies.option_type(Collocation, :scheme)Symbol
julia> Strategies.option_description(Collocation, :grid_size)"Number of time steps for the collocation grid"
Advanced Patterns
Permissive Mode
Use mode = :permissive to accept backend-specific options that are not declared in the metadata:
julia> Collocation(grid_size = 500, custom_backend_param = 42; mode = :permissive)┌ Warning: Unrecognized options passed to backend │ │ Unvalidated options: [:custom_backend_param] │ │ These options will be passed directly to the Collocation backend │ without validation by CTSolvers. Ensure they are correct. └ @ CTSolvers.Strategies ~/work/CTSolvers.jl/CTSolvers.jl/src/Strategies/api/validation_helpers.jl:105 Collocation (instance, id: :collocation) ├─ grid_size = 500 [user] ├─ scheme = midpoint [default] └─ custom_backend_param = 42 [user] Tip: use describe(Collocation) to see all available options.
Unknown options are stored with :user source but bypass type validation. Known options are still fully validated.
Option Aliases
An OptionDefinition can declare aliases — alternative names that resolve to the primary name:
Options.OptionDefinition(
name = :grid_size,
type = Int,
default = 250,
description = "Number of time steps",
aliases = [:N, :num_steps],
)With this definition, Collocation(N = 100) would be equivalent to Collocation(grid_size = 100).
Custom Validators
Add a validator function to enforce constraints beyond type checking:
Options.OptionDefinition(
name = :grid_size,
type = Int,
default = 250,
description = "Number of time steps",
validator = x -> x > 0 || throw(ArgumentError("grid_size must be positive")),
)The validator is called during construction in both strict and permissive modes.