Skip to content

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:

text
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 computation
  • 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:

julia
abstract type AbstractOptimalControlDiscretizer <: Strategies.AbstractStrategy end

This type enables:

  • Grouping discretizers in a StrategyRegistry by 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 a field: options::Strategies.StrategyOptions.

julia
struct Collocation <: AbstractOptimalControlDiscretizer
    options::Strategies.StrategyOptions
end

Step 2 — Implement id

The id method returns a unique Symbol identifier for the strategy. It is a type-level method.

julia
Strategies.id(::Type{<:Collocation}) = :collocation

Step 3 — Define default values

Use the __name() convention for private default functions:

julia
__collocation_grid_size()::Int = 250
__collocation_scheme()::Symbol = :midpoint

Step 4 — Implement metadata

The metadata method returns a StrategyMetadata containing OptionDefinition objects. It is a type-level method.

julia
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)",
        ),
    )
end

Let'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:

julia
function Collocation(; mode::Symbol = :strict, kwargs...)
    opts = Strategies.build_strategy_options(Collocation; mode = mode, kwargs...)
    return Collocation(opts)
end

Step 6 — Implement options

The options method provides instance-level access to the configured options:

julia
Strategies.options(c::Collocation) = c.options

Now 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.
julia
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:

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
julia> Strategies.options(c)[:grid_size]
100

Directly on the strategy instance (syntactic sugar):

julia
julia> c[:grid_size]
100

Both methods are equivalent — the direct access delegates to options(strategy)[key]. Use whichever style you prefer.

Accessing provenance information:

julia
julia> Strategies.source(Strategies.options(c), :grid_size)
:user
julia
julia> Strategies.is_user(Strategies.options(c), :grid_size)
true
julia
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
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:

julia
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.options

Same 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.

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:
└─ AbstractOptimalControlDiscretizer
   ├─ Collocation (id=:collocation)
   └─ DirectShooting (id=:direct_shooting)

Query the registry:

julia
julia> Strategies.strategy_ids(AbstractOptimalControlDiscretizer, registry)
(:collocation, :direct_shooting)
julia
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 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
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
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
julia> Strategies.option_names(Collocation)
(:grid_size, :scheme)
julia
julia> Strategies.option_names(DirectShooting)
(:grid_size,)
julia
julia> Strategies.option_defaults(Collocation)
(grid_size = 250, scheme = :midpoint)
julia
julia> Strategies.option_defaults(DirectShooting)
(grid_size = 100,)
julia
julia> Strategies.option_type(Collocation, :scheme)
Symbol
julia
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)
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:

julia
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
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
└─
julia
Collocation(grid_size = bypass("oops"))   # no error: validation skipped
Collocation (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:

ApproachScopeUnknown option namesType validation
mode = :permissiveall optionsaccepted with warningskipped for unknowns
bypass(val) / force(val)one valueaccepted silentlyskipped for that value only

force is an alias for bypass — choose the name that fits your mental model:

julia
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:

julia
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:

julia
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.