Skip to content

Implementing a Solver

This guide explains how to implement an optimization solver in CTSolvers. Solvers are strategies that wrap NLP backend libraries (Ipopt, MadNLP, Knitro, etc.) behind a unified interface. We use Solvers.Ipopt as the reference example throughout.

Prerequisites

Read Architecture first. A solver is a strategy (see Implementing a Strategy in CTBase.jl documentation) with two additional requirements: a solve contract and a Tag Dispatch extension.

The AbstractNLPSolver Contract

A solver must satisfy three contracts: 2. Strategy contractid, metadata, options, parameter, default_parameter (inherited from AbstractStrategy)

  1. Solve contractCommonSolve.solve(nlp, solver; display) → ExecutionStats

  2. Tag Dispatch — separates type definition from backend implementation

Solvers are parameterized by an execution parameter P <: CTBase.Strategies.AbstractStrategyParameter (see Strategy Parameters in CTBase.jl documentation). Solvers.Ipopt{P<:CTBase.Strategies.CPU} is CPU-only; Solvers.MadNLP and Solvers.MadNCL accept Union{CTBase.Strategies.CPU,CTBase.Strategies.GPU}.

text
AbstractStrategy
├─ id(::Type)       → Symbol
├─ metadata(::Type) → StrategyMetadata
└─ options(inst)    → StrategyOptions

AbstractNLPSolver <: AbstractStrategy
└─ CommonSolve.solve(nlp, solver; display) → ExecutionStats
   ├─► Solvers.Ipopt{P<:CPU}
   ├─► Solvers.MadNLP{P<:Union{CPU,GPU}}
   ├─► Solvers.MadNCL{P<:Union{CPU,GPU}}
   ├─► Solvers.Knitro
   └─► Solvers.Uno

The generic stub throws NotImplemented until a backend extension provides the typed method. Without the extension loaded, constructing a solver throws ExtensionError.

Implementing the Solver Type

Step 1 — Define the Tag

A tag type is a lightweight struct used for dispatch. It routes the constructor call to the right extension:

julia
# In src/Solvers/ipopt.jl
struct IpoptTag <: AbstractTag end

Step 2 — Define the parameterized struct

Like any strategy, the solver has a single options field. It is parameterized by its execution parameter — Ipopt supports CPU only:

julia
# In module Solvers (src/Solvers/ipopt.jl)
struct Ipopt{P<:CTBase.Strategies.CPU} <: AbstractNLPSolver
    options::CTBase.Strategies.StrategyOptions
end

Step 3 — Implement id and the default parameter

The id is available even without the extension. default_parameter tells the unparameterized constructor which parameter to use, and parameter declares the parameter type of the strategy:

julia
using CTSolvers
using CTBase
CTBase.Strategies.id(CTSolvers.Solvers.Ipopt)
:ipopt
julia
CTBase.Strategies.default_parameter(::Type{<:Solvers.Ipopt}) = CTBase.Strategies.CPU
CTBase.Strategies.parameter(::Type{<:Solvers.Ipopt{P}}) where {P<:CTBase.Strategies.CPU} = P

Step 4 — Constructors with Tag Dispatch

The unparameterized constructor resolves the default parameter, then delegates to the parameterized one, which calls a build_* function dispatching on the tag and parameter types (passed as types, not instances). The stub in src/ throws an ExtensionError until the extension is loaded:

julia
# Unparameterized → resolve the default parameter
function Solvers.Ipopt(; mode::Symbol = :strict, kwargs...)
    P = CTBase.Strategies.default_parameter(Solvers.Ipopt)
    return Solvers.Ipopt{P}(; mode = mode, kwargs...)
end

# Parameterized → tag dispatch, IpoptTag and P passed as TYPES
function Solvers.Ipopt{P}(; mode::Symbol = :strict, kwargs...) where {P<:CTBase.Strategies.CPU}
    return _build_ipopt_solver(IpoptTag, P; mode = mode, kwargs...)
end

# Stub — real implementation in ext/CTSolversIpopt.jl
function _build_ipopt_solver(
    ::Type{<:Core.AbstractTag}, parameter::Type{<:CTBase.Strategies.AbstractStrategyParameter}; kwargs...
)
    throw(Exceptions.ExtensionError(
        :NLPModelsIpopt;
        message = "to create Solvers.Ipopt, access options, and solve problems",
        feature = "Solvers.Ipopt functionality",
        context = "Load NLPModelsIpopt extension first: using NLPModelsIpopt",
    ))
end

Live demonstration of the ExtensionError for all solvers:

julia> CTSolvers.Solvers.MadNLP()
MadNLP{CPU} (instance, id=:madnlp)
├─ max_iter = 1000  [default]
├─ tol = 1.0e-8  [default]
├─ linear_solver = MumpsSolver  [computed]
└─ print_level = INFO  [default]
Tip: use describe(MadNLP{CPU}) to see all available options.

Why Tag Dispatch?

The metadata (option definitions) and the solve method (backend call) both live in the extension. The tag type allows the constructor in src/ to dispatch to the extension without a direct dependency on the backend package.

The Tag Dispatch Pattern

src/Solvers/ipopt.jl — type definition and stubs, always loaded with CTSolvers:

julia
struct IpoptTag <: Core.AbstractTag end

struct Ipopt{P<:CTBase.Strategies.CPU} <: AbstractNLPSolver
    options::CTBase.Strategies.StrategyOptions
end

CTBase.Strategies.id(::Type{<:Solvers.Ipopt}) = :ipopt
CTBase.Strategies.default_parameter(::Type{<:Solvers.Ipopt}) = CTBase.Strategies.CPU
CTBase.Strategies.parameter(::Type{<:Solvers.Ipopt{P}}) where {P<:CTBase.Strategies.CPU} = P

# Constructors — resolve the parameter, then dispatch via tag (types, not instances)
Solvers.Ipopt(; mode = :strict, kwargs...) =
    Solvers.Ipopt{CTBase.Strategies.default_parameter(Solvers.Ipopt)}(; mode, kwargs...)

Solvers.Ipopt{P}(; mode = :strict, kwargs...) where {P<:CTBase.Strategies.CPU} =
    _build_ipopt_solver(IpoptTag, P; mode, kwargs...)

# Stub — throws until NLPModelsIpopt is loaded
_build_ipopt_solver(::Type{<:Core.AbstractTag}, ::Type{<:CTBase.Strategies.AbstractStrategyParameter}; kwargs...) =
    throw(Exceptions.ExtensionError(:NLPModelsIpopt))

ext/CTSolversIpopt.jl — real implementations, loaded only with using NLPModelsIpopt:

julia
# Option definitions (parameterized on P)
CTBase.Strategies.metadata(::Type{Solvers.Ipopt{P}}) where {P<:CTBase.Strategies.CPU} = StrategyMetadata(...)

# Real constructor — validates options for the parameterized type and builds the struct
_build_ipopt_solver(::Type{Solvers.IpoptTag}, parameter::Type{<:CTBase.Strategies.AbstractStrategyParameter}; mode, kwargs...) =
    Solvers.Ipopt{parameter}(CTBase.Strategies.build_strategy_options(Solvers.Ipopt{parameter}; mode, kwargs...))

# Solve method — dispatches on NLP type and solver type
CommonSolve.solve(nlp::NLPModels.AbstractNLPModel, solver::Solvers.Ipopt; display = true) =
    solve_with_ipopt(nlp; options_dict(solver)...)

This keeps CTSolvers lightweight — NLPModelsIpopt is only loaded when the user does using NLPModelsIpopt.

Parameterization {P} {#Parameterization-{P}}

The execution parameter P flows through the whole chain — constructor, build_*, and metadata — so a single implementation covers every supported backend. A GPU-capable solver simply widens the bound and provides GPU-specific defaults through the parameterized metadata:

julia
struct MadNLP{P<:Union{CTBase.Strategies.CPU,CTBase.Strategies.GPU}} <: AbstractNLPSolver
    options::CTBase.Strategies.StrategyOptions
end

# GPU-specific option defaults selected by the parameter
CTBase.Strategies.metadata(::Type{Solvers.MadNLP{CTBase.Strategies.GPU}}) = StrategyMetadata(...)  # CUDA defaults

Solvers.MadNLP{CTBase.Strategies.GPU}(max_iter = 1000)   # requires the CUDA-related extensions

See the Strategy Parameters guide in CTBase.jl documentation for the full parameter contract.

Creating the Extension

File structure

text
ext/
└── CTSolversIpopt.jl    # Single-file extension module

Project.toml declaration

toml
[weakdeps]
NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"

[extensions]
CTSolversIpopt = "NLPModelsIpopt"

Extension implementation

The extension module provides three things:

1. Metadata — option definitions with types, defaults, validators (parameterized on P):

julia
module CTSolversIpopt

using CTSolvers: CTSolvers, Solvers
using CTBase: Strategies, Options, Exceptions
using NLPModelsIpopt: NLPModelsIpopt
using NLPModels: NLPModels
using SolverCore: SolverCore

function Strategies.metadata(::Type{Solvers.Ipopt{P}}) where {P<:Strategies.CPU}
    return Strategies.StrategyMetadata(
        Options.OptionDefinition(
            name = :tol,
            type = Real,
            default = 1e-8,
            description = "Desired convergence tolerance (relative)",
            validator = x -> x > 0 || throw(Exceptions.IncorrectArgument(...)),
        ),
        Options.OptionDefinition(
            name = :max_iter,
            type = Integer,
            default = 1000,
            description = "Maximum number of iterations",
            aliases = (:maxiter,),
            validator = x -> x >= 0 || throw(Exceptions.IncorrectArgument(...)),
        ),
        # ... more options (print_level, linear_solver, mu_strategy, etc.)
    )
end

2. Constructor — builds validated options for the parameterized type and returns the solver:

julia
function Solvers._build_ipopt_solver(
    ::Type{Solvers.IpoptTag},
    parameter::Type{<:Strategies.AbstractStrategyParameter};
    mode::Symbol = :strict,
    kwargs...,
)
    opts = Strategies.build_strategy_options(Solvers.Ipopt{parameter}; mode = mode, kwargs...)
    return Solvers.Ipopt{parameter}(opts)
end

3. Solve method — implements CommonSolve.solve dispatching on the NLP type and solver type:

julia
function CommonSolve.solve(
    nlp::NLPModels.AbstractNLPModel,
    solver::Solvers.Ipopt;
    display::Bool = true,
)::SolverCore.GenericExecutionStats
    options = CTBase.Strategies.options_dict(solver)
    options[:print_level] = display ? options[:print_level] : 0
    return solve_with_ipopt(nlp; options...)
end

function solve_with_ipopt(nlp::NLPModels.AbstractNLPModel; kwargs...)
    ipopt_solver = NLPModelsIpopt.IpoptSolver(nlp)
    return NLPModelsIpopt.solve!(ipopt_solver, nlp; kwargs...)
end

end # module CTSolversIpopt

Display handling

The display parameter controls solver output. When display = false, the solver sets print_level = 0 to suppress all output. This is a convention shared by all CTSolvers solvers.

CommonSolve Integration

CTSolvers provides a unified CommonSolve.solve interface at two levels:

text
High-level:  solve(problem, x0, modeler, solver)          ← orchestration.jl

                ├─► build_model(problem, x0, modeler)     → BuiltModel

                ├─► CommonSolve.solve(built.nlp, solver)  → ExecutionStats

                └─► build_solution(built, stats, modeler) → OCP Solution

Mid-level:   CommonSolve.solve(nlp, solver; display)      ← backend extension
                → ExecutionStats

High-level: full pipeline

julia
using CommonSolve

solution = solve(problem, x0, modeler, solver)
# Internally:
#   1. built = build_model(problem, x0, modeler)
#   2. stats = CommonSolve.solve(built.nlp, solver)
#   3. solution = build_solution(built, stats, modeler)

Mid-level: NLP → Stats

julia
using ADNLPModels, NLPModelsIpopt

nlp = ADNLPModel(x -> sum(x.^2), zeros(10))
solver = CTSolvers.Solvers.Ipopt(max_iter = 1000)
stats = CommonSolve.solve(nlp, solver; display = false)

Summary: Adding a New Solver

To add a new solver (e.g., MySolver backed by MyBackend):

In src/Solvers/

  1. Define MyTag <: Core.AbstractTag

  2. Define the parameterized struct MySolver{P<:CTBase.Strategies.CPU} <: AbstractNLPSolver with options::CTBase.Strategies.StrategyOptions (widen the bound to Union{CTBase.Strategies.CPU,CTBase.Strategies.GPU} for GPU-capable backends)

  3. Implement CTBase.Strategies.id(::Type{<:MySolver}) = :my_solver, CTBase.Strategies.default_parameter(::Type{<:MySolver}) = CTBase.Strategies.CPU, and CTBase.Strategies.parameter(::Type{<:MySolver{P}}) where {P<:CTBase.Strategies.CPU} = P

  4. Write the constructor chain: MySolver(; ...)MySolver{P}(; ...)build_my_solver(MyTag, P; mode, kwargs...)

  5. Write stub: build_my_solver(::Type{<:Core.AbstractTag}, ::Type{<:CTBase.Strategies.AbstractStrategyParameter}; kwargs...) = throw(ExtensionError(...))

In ext/CTSolversMyBackend.jl

  1. Implement CTBase.Strategies.metadata(::Type{MySolver{P}}) where {P<:CTBase.Strategies.CPU} with all option definitions

  2. Implement Solvers.build_my_solver(::Type{Solvers.MyTag}, parameter::Type{<:CTBase.Strategies.AbstractStrategyParameter}; kwargs...) — real constructor

  3. Implement CommonSolve.solve(nlp, solver::MySolver; display) — solve method invoking the backend

In Project.toml

  1. Add MyBackend to [weakdeps] and CTSolversMyBackend = "MyBackend" to [extensions]

Tests

  1. Contract test: CTBase.Strategies.id(MySolver), CTBase.Strategies.metadata(MySolver), and CTBase.Strategies.options(MySolver()) (requires extension loaded)

  2. Solve test: CommonSolve.solve(nlp, solver; display = false) returns AbstractExecutionStats

  3. Extension error test: without using MyBackend, MySolver() throws ExtensionError