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.

Prerequisites

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:

flowchart TB subgraph TypeLevel["Type-Level (no instantiation needed)"] id["id(::Type) → :symbol"] meta["metadata(::Type) → StrategyMetadata"] end subgraph InstanceLevel["Instance-Level (configured object)"] opts["options(instance) → StrategyOptions"] end TypeLevel -->|"routing, validation"| Constructor["Constructor(; mode, kwargs...)"] Constructor --> InstanceLevel InstanceLevel -->|"execution"| Run["Strategy execution"]
  • 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 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 exactly one field: options::Strategies.StrategyOptions.

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.

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

Step 3 — Define default values

Use the __name() convention for private default functions:

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

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:

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:

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.

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