Implementing a Modeler

This guide explains how to implement an optimization modeler in CTSolvers. Modelers are strategies that convert AbstractOptimizationProblem instances into NLP backend models and convert NLP solver results back into problem-specific solutions. We use Modelers.ADNLP and Modelers.Exa as reference examples.

Prerequisites

Read Architecture first. A modeler is a strategy (see Implementing a Strategy in CTBase.jl documentation) with two additional callable contracts.

The AbstractNLPModeler Contract

A modeler must satisfy three contracts:

  1. Strategy contractid, metadata, options (inherited from AbstractStrategy)
  2. Model building callable(modeler)(prob, initial_guess) → NLP model
  3. Solution building callable(modeler)(prob, nlp_stats) → Solution
classDiagram class AbstractStrategy { <<abstract>> id(::Type)::Symbol metadata(::Type)::StrategyMetadata options(instance)::StrategyOptions } class AbstractNLPModeler { <<abstract>> (modeler)(prob, x0) → NLP (modeler)(prob, stats) → Solution } AbstractStrategy <|-- AbstractNLPModeler AbstractNLPModeler <|-- Modelers.ADNLP AbstractNLPModeler <|-- Modelers.Exa

Both callables have default implementations that throw NotImplemented.

using CTSolvers
using CTBase: CTBase

The id is available directly:

CTBase.Strategies.id(CTSolvers.Modelers.ADNLP)
:adnlp
CTBase.Strategies.id(CTSolvers.Modelers.Exa)
:exa

Step-by-Step Implementation

We walk through the Modelers.ADNLP implementation as a reference.

Step 1 — Define the struct

struct Modelers.ADNLP <: AbstractNLPModeler
    options::CTBase.Strategies.StrategyOptions
end

Step 2 — Implement id

CTBase.Strategies.id(CTSolvers.Modelers.ADNLP)
:adnlp

Step 3 — Define defaults and metadata

The metadata defines all configurable options with types, defaults, and validators:

CTBase.Strategies.metadata(CTSolvers.Modelers.ADNLP)
StrategyMetadata with 11 options:
│  
├─ show_time::Bool (default: NotProvided)
│  description: Whether to show timing information while building the ADNLP model
│  
├─ backend (adnlp_backend)::Symbol (default: optimized)
│  description: Automatic differentiation backend used by ADNLPModels.
│               Available: zygote, default, generic, enzyme, optimized and manual.
│  
├─ matrix_free::Bool (default: NotProvided)
│  description: Enable matrix-free mode (avoids explicit Hessian/Jacobian matrices)
│  
├─ name::String (default: NotProvided)
│  description: Name of the optimization model for identification
│  
├─ gradient_backend::Union{Nothing, ADNLPModels.ADBackend, Type{<:ADNLPModels.ADBackend}} (default: NotProvided)
│  description: Override backend for gradient computation (advanced users only)
│  
├─ hprod_backend::Union{Nothing, ADNLPModels.ADBackend, Type{<:ADNLPModels.ADBackend}} (default: NotProvided)
│  description: Override backend for Hessian-vector product (advanced users only)
│  
├─ jprod_backend::Union{Nothing, ADNLPModels.ADBackend, Type{<:ADNLPModels.ADBackend}} (default: NotProvided)
│  description: Override backend for Jacobian-vector product (advanced users only)
│  
├─ jtprod_backend::Union{Nothing, ADNLPModels.ADBackend, Type{<:ADNLPModels.ADBackend}} (default: NotProvided)
│  description: Override backend for transpose Jacobian-vector product (advanced users only)
│  
├─ jacobian_backend::Union{Nothing, ADNLPModels.ADBackend, Type{<:ADNLPModels.ADBackend}} (default: NotProvided)
│  description: Override backend for Jacobian matrix computation (advanced users only)
│  
├─ hessian_backend::Union{Nothing, ADNLPModels.ADBackend, Type{<:ADNLPModels.ADBackend}} (default: NotProvided)
│  description: Override backend for Hessian matrix computation (advanced users only)
│  
└─ ghjvprod_backend::Union{Nothing, ADNLPModels.ADBackend, Type{<:ADNLPModels.ADBackend}} (default: NotProvided)
   description: Override backend for g^T ∇²c(x)v computation (advanced users only)

Step 4 — Constructor and options accessor

The constructor validates options and stores them:

modeler = CTSolvers.Modelers.ADNLP(backend = :optimized)
ADNLP{CPU} (instance, id=:adnlp)
└─ backend = optimized  [user]
Tip: use describe(ADNLP) to see all available options.
CTBase.Strategies.options(modeler)
StrategyOptions with 1 option:
└─ backend = optimized  [user]

Step 5 — Model building callable

This is the core of the modeler. It retrieves the appropriate builder from the problem and invokes it:

function (modeler::Modelers.ADNLP)(
    prob::AbstractOptimizationProblem,
    initial_guess,
)::ADNLPModels.ADNLPModel
    # Get the builder registered for this problem type
    builder = get_adnlp_model_builder(prob)

    # Extract modeler options as a Dict
    options = CTBase.Strategies.options_dict(modeler)

    # Build the NLP model, passing all options to the builder
    return builder(initial_guess; options...)
end

The key interaction is with the Builder pattern: the modeler doesn't know how to build the model itself — it asks the problem for a builder, then calls it. See Implementing an Optimization Problem for how builders work.

Step 6 — Solution building callable

Same pattern, but for converting NLP results back into a problem-specific solution:

function (modeler::Modelers.ADNLP)(
    prob::AbstractOptimizationProblem,
    nlp_solution::SolverCore.AbstractExecutionStats,
)
    builder = get_adnlp_solution_builder(prob)
    return builder(nlp_solution)
end

Modelers.Exa: A Second Example

Modelers.Exa follows the same pattern with different options and a slightly different callable signature:

struct Modelers.Exa <: AbstractNLPModeler
    options::CTBase.Strategies.StrategyOptions
end

CTBase.Strategies.id(::Type{<:Modelers.Exa}) = :exa

function CTBase.Strategies.metadata(::Type{<:Modelers.Exa})
    return CTBase.Strategies.StrategyMetadata(
        CTBase.Options.OptionDefinition(
            name = :base_type,
            type = DataType,
            default = Float64,
            description = "Base floating-point type used by ExaModels",
            validator = validate_exa_base_type,
        ),
        CTBase.Options.OptionDefinition(
            name = :backend,
            type = Union{Nothing, KernelAbstractions.Backend},
            default = nothing,
            description = "Execution backend for ExaModels (CPU, GPU, etc.)",
        ),
    )
end

The model building callable extracts base_type as a positional argument:

function (modeler::Modelers.Exa)(
    prob::AbstractOptimizationProblem,
    initial_guess,
)::ExaModels.ExaModel
    builder = get_exa_model_builder(prob)
    options = CTBase.Strategies.options_dict(modeler)

    # ExaModels requires BaseType as first positional argument
    BaseType = options[:base_type]
    delete!(options, :base_type)

    return builder(BaseType, initial_guess; options...)
end
Different builder signatures

ADNLPModelBuilder takes (initial_guess; kwargs...) while ExaModelBuilder takes (BaseType, initial_guess; kwargs...). Each modeler adapts the call to its builder's expected signature.

Integration with buildmodel / buildsolution

The Optimization module provides two generic functions that delegate to the modeler's callables:

# In src/Optimization/building.jl

function build_model(prob, initial_guess, modeler)
    return modeler(prob, initial_guess)
end

function build_solution(prob, model_solution, modeler)
    return modeler(prob, model_solution)
end

These are used by the high-level CommonSolve.solve:

sequenceDiagram participant User participant Solve as CommonSolve.solve participant BuildModel as build_model participant Modeler as Modelers.ADNLP participant Problem as AbstractOptimizationProblem participant Builder as ADNLPModelBuilder User->>Solve: solve(problem, x0, modeler, solver) Solve->>BuildModel: build_model(problem, x0, modeler) BuildModel->>Modeler: modeler(problem, x0) Modeler->>Problem: get_adnlp_model_builder(problem) Problem-->>Modeler: ADNLPModelBuilder Modeler->>Builder: builder(x0; show_time, backend, ...) Builder-->>Modeler: ADNLPModel Modeler-->>Solve: ADNLPModel

Validation

Verify the three contract methods explicitly:

# id is always available
CTBase.Strategies.id(Modelers.ADNLP)    # => :adnlp
CTBase.Strategies.id(Modelers.Exa)      # => :exa

# metadata is available without extension
CTBase.Strategies.metadata(Modelers.ADNLP) isa CTBase.Strategies.StrategyMetadata  # => true
CTBase.Strategies.metadata(Modelers.Exa)   isa CTBase.Strategies.StrategyMetadata  # => true

# options requires a constructed instance
modeler = Modelers.ADNLP()
CTBase.Strategies.options(modeler) isa CTBase.Strategies.StrategyOptions            # => true

For the callables, test with a fake or real problem:

# Create a fake problem with builders
prob = FakeOptimizationProblem(adnlp_builder, adnlp_solution_builder)

# Test model building
modeler = Modelers.ADNLP(backend = :optimized)
nlp = modeler(prob, x0)
@test nlp isa ADNLPModels.ADNLPModel

# Test solution building
stats = solve(nlp, solver)
solution = modeler(prob, stats)
@test solution isa ExpectedSolutionType

Summary: Adding a New Modeler

To add a new modeler (e.g., MyModeler for a new NLP backend):

  1. Define MyModeler <: AbstractNLPModeler with options::CTBase.Strategies.StrategyOptions
  2. Implement CTBase.Strategies.id(::Type{<:MyModeler}) = :my_backend
  3. Implement CTBase.Strategies.metadata(::Type{<:MyModeler}) with option definitions
  4. Write constructor: MyModeler(; mode, kwargs...)
  5. Implement CTBase.Strategies.options(m::MyModeler) = m.options
  6. Implement model building callable: (modeler::MyModeler)(prob, x0) → NLP
  7. Implement solution building callable: (modeler::MyModeler)(prob, stats) → Solution
  8. Add corresponding builder types in Optimization if needed (MyModelBuilder, MySolutionBuilder)
  9. Add contract methods in Optimization: get_my_model_builder, get_my_solution_builder