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.
Prerequisites
Read the Options System guide first to understand OptionDefinition, StrategyMetadata, and StrategyOptions.
The Two-Level Contract
Every strategy implements a two-level contract that separates static metadata from dynamic configuration:
Type-Level (no instantiation needed)
├─ id(::Type{<:S}) → Symbol (routing, registry lookup)
└─ metadata(::Type{<:S}) → StrategyMetadata (option specs + validation rules)
│
▼ routing, validation
Constructor(; mode, kwargs...)
│
▼
Instance-Level (configured object)
└─ options(instance) → StrategyOptions (values + provenance)
│
▼ execution
Strategy computationType-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 familyDispatching 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 a 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:
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:
c = Collocation()Collocation (instance, id=:collocation)
├─ grid_size = 250 [default]
└─ scheme = midpoint [default]
Tip: use describe(Collocation) to see all available options.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.describe(Collocation)Collocation (strategy type)
├─ id: :collocation
├─ hierarchy: Collocation → AbstractOptimalControlDiscretizer → AbstractStrategy
└─ metadata: 2 options defined
│
├─ 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 7 — Access options
The StrategyOptions object tracks both values and their provenance. You can access options in two ways:
Via the options getter:
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.Strategies.options(c)StrategyOptions with 2 options:
├─ grid_size = 100 [user]
└─ scheme = midpoint [default]julia> Strategies.options(c)[:grid_size]
100Directly on the strategy instance (syntactic sugar):
julia> c[:grid_size]
100Both methods are equivalent — the direct access delegates to options(strategy)[key]. Use whichever style you prefer.
Accessing provenance information:
julia> Strategies.source(Strategies.options(c), :grid_size)
:userjulia> Strategies.is_user(Strategies.options(c), :grid_size)
truejulia> Strategies.is_default(Strategies.options(c), :scheme)
trueError handling
A typo in an option name triggers a helpful error with Levenshtein suggestion:
julia> Collocation(grdi_size = 500)
IncorrectArgument → #Collocation#1, implementing-a-strategy.md:124
│
│ 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.optionsSame option name, different definitions
Both 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.
DirectShooting()DirectShooting (instance, id=:direct_shooting)
└─ grid_size = 100 [default]
Tip: use describe(DirectShooting) to see all available options.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.
registry = Strategies.create_registry(
AbstractOptimalControlDiscretizer => (Collocation, DirectShooting),
)StrategyRegistry with 1 family:
└─ AbstractOptimalControlDiscretizer
├─ Collocation (id=:collocation)
└─ DirectShooting (id=:direct_shooting)Query the registry:
julia> Strategies.strategy_ids(AbstractOptimalControlDiscretizer, registry)
(:collocation, :direct_shooting)julia> Strategies.type_from_id(:collocation, AbstractOptimalControlDiscretizer, registry)
Main.CollocationBuild a strategy from the registry:
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.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 CTBase 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)
:collocationBuild a strategy directly from a method tuple:
id = Strategies.extract_id_from_method(method, AbstractOptimalControlDiscretizer, registry)
Strategies.build_strategy(id, AbstractOptimalControlDiscretizer, registry; 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.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)
Symboljulia> 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:
Collocation(grid_size = 500, custom_backend_param = 42; mode = :permissive)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.
Bypass Validation for Specific Options
Use bypass(val) (or its alias force(val)) to skip validation for a single option value while keeping strict mode for everything else.
Unknown option — accepted silently, no warning:
Collocation(grid_size = 500, custom_backend_param = bypass(42))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.Known option with wrong type — normally rejected, accepted with bypass:
julia> Collocation(grid_size = "oops") # type error: grid_size expects Int
IncorrectArgument → #Collocation#1, implementing-a-strategy.md:124
│
│ Invalid option type
│
│ Got value oops of type String
│ Expected Int64
│
│ Context Option extraction for grid_size
│ Hint Ensure the option value matches the expected type
└─Collocation(grid_size = bypass("oops")) # no error: validation skippedCollocation (instance, id=:collocation)
├─ grid_size = oops [user]
└─ scheme = midpoint [default]
Tip: use describe(Collocation) to see all available options.This is more surgical than mode = :permissive:
| Approach | Scope | Unknown option names | Type validation |
|---|---|---|---|
mode = :permissive | all options | accepted with warning | skipped for unknowns |
bypass(val) / force(val) | one value | accepted silently | skipped for that value only |
force is an alias for bypass — choose the name that fits your mental model:
Collocation(grid_size = force("oops")) # same as bypass("oops")Use with care
Bypassed values are not type-checked, even for declared options. A wrong type or invalid value will only surface as a backend-level error.
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.