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 <: AbstractStrategyParameter (see Strategy Parameters in CTBase.jl documentation). Solvers.Ipopt{P<:CPU} is CPU-only; Solvers.MadNLP and Solvers.MadNCL accept Union{CPU,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<: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}) = CPU
CTBase.Strategies.parameter(::Type{<:Solvers.Ipopt{P}}) where {P<: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<: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{<: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) 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<:CPU} <: AbstractNLPSolver
options::CTBase.Strategies.StrategyOptions
end
CTBase.Strategies.id(::Type{<:Solvers.Ipopt}) = :ipopt
CTBase.Strategies.default_parameter(::Type{<:Solvers.Ipopt}) = CPU
CTBase.Strategies.parameter(::Type{<:Solvers.Ipopt{P}}) where {P<: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<:CPU} =
build_ipopt_solver(IpoptTag, P; mode, kwargs...)
# Stub — throws until NLPModelsIpopt is loaded
build_ipopt_solver(::Type{<:Core.AbstractTag}, ::Type{<: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<:CPU} = StrategyMetadata(...)
# Real constructor — validates options for the parameterized type and builds the struct
build_ipopt_solver(::Type{Solvers.IpoptTag}, parameter::Type{<: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{CPU,GPU}} <: AbstractNLPSolver
options::CTBase.Strategies.StrategyOptions
end
# GPU-specific option defaults selected by the parameter
CTBase.Strategies.metadata(::Type{Solvers.MadNLP{GPU}}) = StrategyMetadata(...) # CUDA defaults
Solvers.MadNLP{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, CTBase.Strategies, CTBase.Options
using CTBase.Exceptions
using NLPModelsIpopt, NLPModels, SolverCore
function CTBase.Strategies.metadata(::Type{Solvers.Ipopt{P}}) where {P<:CPU}
return CTBase.Strategies.StrategyMetadata(
CTBase.Options.OptionDefinition(
name = :tol,
type = Real,
default = 1e-8,
description = "Desired convergence tolerance (relative)",
validator = x -> x > 0 || throw(Exceptions.IncorrectArgument(...)),
),
CTBase.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{<:AbstractStrategyParameter};
mode::Symbol = :strict,
kwargs...,
)
opts = CTBase.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<:CPU} <: AbstractNLPSolverwithoptions::CTBase.Strategies.StrategyOptions(widen the bound toUnion{CPU,GPU}for GPU-capable backends)Implement
CTBase.Strategies.id(::Type{<:MySolver}) = :my_solver,CTBase.Strategies.default_parameter(::Type{<:MySolver}) = CPU, andCTBase.Strategies.parameter(::Type{<:MySolver{P}}) where {P<:CPU} = PWrite the constructor chain:
MySolver(; ...)→MySolver{P}(; ...)→build_my_solver(MyTag, P; mode, kwargs...)Write stub:
build_my_solver(::Type{<:Core.AbstractTag}, ::Type{<:AbstractStrategyParameter}; kwargs...) = throw(ExtensionError(...))
In ext/CTSolversMyBackend.jl
Implement
CTBase.Strategies.metadata(::Type{MySolver{P}}) where {P<:CPU}with all option definitionsImplement
Solvers.build_my_solver(::Type{Solvers.MyTag}, parameter::Type{<: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