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 contract — id, metadata, options, parameter, default_parameter (inherited from AbstractStrategy)
Solve contract —
CommonSolve.solve(nlp, solver; display) → ExecutionStatsTag 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}.
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.UnoThe 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:
# In src/Solvers/ipopt.jl
struct IpoptTag <: AbstractTag endStep 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:
# In module Solvers (src/Solvers/ipopt.jl)
struct Ipopt{P<:CTBase.Strategies.CPU} <: AbstractNLPSolver
options::CTBase.Strategies.StrategyOptions
endStep 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:
using CTSolvers
using CTBase
CTBase.Strategies.id(CTSolvers.Solvers.Ipopt):ipoptCTBase.Strategies.default_parameter(::Type{<:Solvers.Ipopt}) = CTBase.Strategies.CPU
CTBase.Strategies.parameter(::Type{<:Solvers.Ipopt{P}}) where {P<:CTBase.Strategies.CPU} = PStep 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:
# 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",
))
endLive 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:
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:
# 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:
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 extensionsSee the Strategy Parameters guide in CTBase.jl documentation for the full parameter contract.
Creating the Extension
File structure
ext/
└── CTSolversIpopt.jl # Single-file extension moduleProject.toml declaration
[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):
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.)
)
end2. Constructor — builds validated options for the parameterized type and returns the solver:
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)
end3. Solve method — implements CommonSolve.solve dispatching on the NLP type and solver type:
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 CTSolversIpoptDisplay 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:
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
→ ExecutionStatsHigh-level: full pipeline
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
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/
Define
MyTag <: Core.AbstractTagDefine the parameterized struct
MySolver{P<:CTBase.Strategies.CPU} <: AbstractNLPSolverwithoptions::CTBase.Strategies.StrategyOptions(widen the bound toUnion{CTBase.Strategies.CPU,CTBase.Strategies.GPU}for GPU-capable backends)Implement
CTBase.Strategies.id(::Type{<:MySolver}) = :my_solver,CTBase.Strategies.default_parameter(::Type{<:MySolver}) = CTBase.Strategies.CPU, andCTBase.Strategies.parameter(::Type{<:MySolver{P}}) where {P<:CTBase.Strategies.CPU} = PWrite the constructor chain:
MySolver(; ...)→MySolver{P}(; ...)→build_my_solver(MyTag, P; mode, kwargs...)Write stub:
build_my_solver(::Type{<:Core.AbstractTag}, ::Type{<:CTBase.Strategies.AbstractStrategyParameter}; kwargs...) = throw(ExtensionError(...))
In ext/CTSolversMyBackend.jl
Implement
CTBase.Strategies.metadata(::Type{MySolver{P}}) where {P<:CTBase.Strategies.CPU}with all option definitionsImplement
Solvers.build_my_solver(::Type{Solvers.MyTag}, parameter::Type{<:CTBase.Strategies.AbstractStrategyParameter}; kwargs...)— real constructorImplement
CommonSolve.solve(nlp, solver::MySolver; display)— solve method invoking the backend
In Project.toml
- Add
MyBackendto[weakdeps]andCTSolversMyBackend = "MyBackend"to[extensions]
Tests
Contract test:
CTBase.Strategies.id(MySolver),CTBase.Strategies.metadata(MySolver), andCTBase.Strategies.options(MySolver())(requires extension loaded)Solve test:
CommonSolve.solve(nlp, solver; display = false)returnsAbstractExecutionStatsExtension error test: without
using MyBackend,MySolver()throwsExtensionError