Options System

This guide explains the Options module — the foundational layer for defining, validating, extracting, and tracking configuration values throughout CTSolvers. The Options module is generic and has no dependencies on other CTSolvers modules.

using CTSolvers
using CTBase: CTBase
const Exceptions = CTBase.Exceptions

Overview

The options system has four core types and a set of extraction functions:

flowchart LR OD["OptionDefinition\n(schema)"] --> SM["StrategyMetadata\n(collection of defs)"] SM --> BSO["build_strategy_options\n(validate + merge)"] BSO --> SO["StrategyOptions\n(validated values)"] OD --> EO["extract_option\n(single extraction)"] EO --> OV["OptionValue\n(value + provenance)"]

OptionDefinition

An OptionDefinition is the schema for a single option. It specifies the name, type, default, description, aliases, and an optional validator.

def = OptionDefinition(
    name        = :max_iter,
    type        = Integer,
    default     = 1000,
    description = "Maximum number of iterations",
    aliases     = (:maxiter,),
    validator   = x -> x >= 0 || throw(Exceptions.IncorrectArgument(
        "Invalid max_iter", got = "$x", expected = ">= 0",
    )),
)
max_iter (maxiter) :: Integer (default: 1000)

Fields

FieldTypeDescription
nameSymbolPrimary option name
typeTypeExpected Julia type
defaultAnyDefault value (or NotProvided)
descriptionStringHuman-readable description
aliasesTuple{Vararg{Symbol}}Alternative names
validatorFunction or nothingValidation function

Constructor validation

The constructor automatically:

  1. Checks that default matches the declared type
  2. Runs the validator on the default value (if both are provided)
  3. Skips validation when default is NotProvided

Type mismatch in the constructor:

julia> OptionDefinition(name = :count, type = Integer, default = "hello", description = "A count")ERROR: Control Toolbox Error

❌ Error: CTBase.Exceptions.IncorrectArgument, Type mismatch in option definition
🔍 Got: default value hello of type String, Expected: value of type Integer
📂 Context: OptionDefinition constructor - validating type compatibility
💡 Suggestion: Ensure the default value matches the declared type, or adjust the type parameter

Aliases

Aliases allow users to use alternative names for the same option:

def_alias = OptionDefinition(
    name = :max_iter, type = Int, default = 100,
    description = "Max iterations", aliases = (:maxiter, :max),
)
all_names(def_alias)
(:max_iter, :maxiter, :max)

The extraction system searches all names when looking for a match in kwargs.

Validators

Validators follow the pattern x -> condition || throw(...). They should return a truthy value on success or throw on failure:

validated_def = OptionDefinition(
    name = :tol, type = Real, default = 1e-8,
    description = "Tolerance",
    validator = x -> x > 0 || throw(Exceptions.IncorrectArgument(
        "Invalid tolerance",
        got = "tol=$x", expected = "positive real number (> 0)",
        suggestion = "Use 1e-6 or 1e-8",
    )),
)

Validator failure:

julia> extract_option((tol = -1.0,), validated_def)┌ Error: Validation failed for option tol with value -1.0
│   exception =
│    Control Toolbox Error
│    
│    ❌ Error: CTBase.Exceptions.IncorrectArgument, Invalid tolerance
│    🔍 Got: tol=-1.0, Expected: positive real number (> 0)
│    💡 Suggestion: Use 1e-6 or 1e-8
│    📍 In your code:
│         #5 at options_system.md:95
│         └── extract_option at extraction.jl:70
│             └── top-level scope at REPL[1]:1
│    
│    Stacktrace:
│      [1] (::Main.var"#5#6")(x::Float64)
│        @ Main ./options_system.md:95
│      [2] extract_option(kwargs::@NamedTuple{tol::Float64}, def::CTSolvers.Options.OptionDefinition{Float64})
│        @ CTSolvers.Options ~/work/CTSolvers.jl/CTSolvers.jl/src/Options/extraction.jl:70
│      [3] top-level scope
│        @ REPL[1]:1
│      [4] eval(m::Module, e::Any)
│        @ Core ./boot.jl:489
│      [5] #67
│        @ ~/.julia/packages/Documenter/AXNMp/src/expander_pipeline.jl:978 [inlined]
│      [6] cd(f::Documenter.var"#67#68"{Module}, dir::String)
│        @ Base.Filesystem ./file.jl:112
│      [7] (::Documenter.var"#65#66"{Documenter.Page, Module})()
│        @ Documenter ~/.julia/packages/Documenter/AXNMp/src/expander_pipeline.jl:977
│      [8] (::IOCapture.var"#12#13"{Type{InterruptException}, Documenter.var"#65#66"{Documenter.Page, Module}, IOContext{Base.PipeEndpoint}, IOContext{Base.PipeEndpoint}, Base.PipeEndpoint, Base.PipeEndpoint})()
│        @ IOCapture ~/.julia/packages/IOCapture/MR051/src/IOCapture.jl:170
│      [9] with_logstate(f::IOCapture.var"#12#13"{Type{InterruptException}, Documenter.var"#65#66"{Documenter.Page, Module}, IOContext{Base.PipeEndpoint}, IOContext{Base.PipeEndpoint}, Base.PipeEndpoint, Base.PipeEndpoint}, logstate::Base.CoreLogging.LogState)
│        @ Base.CoreLogging ./logging/logging.jl:542
│     [10] with_logger(f::Function, logger::Base.CoreLogging.ConsoleLogger)
│        @ Base.CoreLogging ./logging/logging.jl:653
│     [11] capture(f::Documenter.var"#65#66"{Documenter.Page, Module}; rethrow::Type, color::Bool, passthrough::Bool, capture_buffer::IOBuffer, io_context::Vector{Any})
│        @ IOCapture ~/.julia/packages/IOCapture/MR051/src/IOCapture.jl:167
│     [12] runner(::Type{Documenter.Expanders.REPLBlocks}, node::MarkdownAST.Node{Nothing}, page::Documenter.Page, doc::Documenter.Document)
│        @ Documenter ~/.julia/packages/Documenter/AXNMp/src/expander_pipeline.jl:976
│     [13] dispatch(::Type{Documenter.Expanders.ExpanderPipeline}, ::MarkdownAST.Node{Nothing}, ::Vararg{Any})
│        @ Documenter.Selectors ~/.julia/packages/Documenter/AXNMp/src/utilities/Selectors.jl:170
│     [14] expand(doc::Documenter.Document)
│        @ Documenter ~/.julia/packages/Documenter/AXNMp/src/expander_pipeline.jl:60
│     [15] runner(::Type{Documenter.Builder.ExpandTemplates}, doc::Documenter.Document)
│        @ Documenter ~/.julia/packages/Documenter/AXNMp/src/builder_pipeline.jl:224
│     [16] dispatch(::Type{Documenter.Builder.DocumentPipeline}, x::Documenter.Document)
│        @ Documenter.Selectors ~/.julia/packages/Documenter/AXNMp/src/utilities/Selectors.jl:170
│     [17] #95
│        @ ~/.julia/packages/Documenter/AXNMp/src/makedocs.jl:283 [inlined]
│     [18] withenv(::Documenter.var"#95#96"{Documenter.Document}, ::Pair{String, Nothing}, ::Vararg{Pair{String, Nothing}})
│        @ Base ./env.jl:265
│     [19] #93
│        @ ~/.julia/packages/Documenter/AXNMp/src/makedocs.jl:282 [inlined]
│     [20] cd(f::Documenter.var"#93#94"{Documenter.Document}, dir::String)
│        @ Base.Filesystem ./file.jl:112
│     [21] makedocs(; debug::Bool, format::Documenter.HTMLWriter.HTML, kwargs::@Kwargs{draft::Bool, remotes::Nothing, warnonly::Bool, sitename::String, pages::Vector{Pair{String, Any}}})
│        @ Documenter ~/.julia/packages/Documenter/AXNMp/src/makedocs.jl:281
│     [22] (::var"#4#5")(api_pages::Vector{Pair{String, Vector{Pair{String, String}}}})
│        @ Main ~/work/CTSolvers.jl/CTSolvers.jl/docs/make.jl:36
│     [23] with_api_reference(f::var"#4#5", src_dir::String, ext_dir::String)
│        @ Main ~/work/CTSolvers.jl/CTSolvers.jl/docs/api_reference.jl:304
│     [24] top-level scope
│        @ ~/work/CTSolvers.jl/CTSolvers.jl/docs/make.jl:35
│     [25] include(mapexpr::Function, mod::Module, _path::String)
│        @ Base ./Base.jl:307
│     [26] top-level scope
│        @ none:1
│     [27] eval(m::Module, e::Any)
│        @ Core ./boot.jl:489
│     [28] exec_options(opts::Base.JLOptions)
│        @ Base ./client.jl:283
│     [29] _start()
│        @ Base ./client.jl:550
└ @ CTSolvers.Options ~/work/CTSolvers.jl/CTSolvers.jl/src/Options/extraction.jl:72
ERROR: Control Toolbox Error

❌ Error: CTBase.Exceptions.IncorrectArgument, Invalid tolerance
🔍 Got: tol=-1.0, Expected: positive real number (> 0)
💡 Suggestion: Use 1e-6 or 1e-8

NotProvided

NotProvided is a sentinel value that distinguishes "no default" from "default is nothing":

NotProvided
NotProvided
# Option with NotProvided default — omitted if user doesn't provide it
opt_np = OptionDefinition(
    name = :mu_init, type = Real, default = NotProvided,
    description = "Initial barrier parameter",
)
mu_init :: Real (default: NotProvided)

When extract_option encounters a NotProvided default and the user hasn't provided the option, the option is excluded from the result:

result, remaining = extract_option((other = 42,), opt_np)
println("Result: ", result)
println("Remaining: ", remaining)
Result: NotStored
Remaining: (other = 42,)

OptionValue and Provenance

OptionValue wraps a value with its provenance — where it came from:

OptionValue(1000, :user)
1000 (user)
OptionValue(1e-8, :default)
1.0e-8 (default)
OptionValue(42, :computed)
42 (computed)

Three sources

SourceMeaning
:userExplicitly provided by the user
:defaultCame from the OptionDefinition default
:computedDerived or computed from other options

Invalid source:

julia> OptionValue(42, :invalid_source)ERROR: Control Toolbox Error

❌ Error: CTBase.Exceptions.IncorrectArgument, Invalid option source
🔍 Got: source=invalid_source, Expected: :default, :user, or :computed
📂 Context: OptionValue constructor - validating source provenance
💡 Suggestion: Use one of the valid source symbols: :default (tool default), :user (user-provided), or :computed (derived)

Provenance tracking enables introspection — you can tell whether a value was explicitly chosen or inherited from defaults:

opt = OptionValue(1000, :user)
println("Value: ", opt.value)
println("Source: ", opt.source)
Value: 1000
Source: user

Accessing Option Properties (Getters)

Use the getters in Options to access OptionDefinition and OptionValue fields instead of reading struct fields directly. This keeps encapsulation intact and aligns with Strategies overrides.

using CTSolvers.Options

def = OptionDefinition(
    name = :max_iter,
    type = Int,
    default = 100,
    description = "Maximum iterations",
    aliases = (:maxiter,),
)

@show Options.name(def)
@show Options.type(def)
@show Options.default(def)
@show Options.description(def)
@show Options.aliases(def)
@show Options.is_required(def)

opt = OptionValue(200, :user)
@show Options.value(opt)
@show Options.source(opt)
@show Options.is_user(opt)
@show Options.is_default(opt)
@show Options.is_computed(opt)
false

Encapsulation Best Practices (Strategies)

  • To retrieve an OptionValue from a strategy: opt = Strategies.option(opts, :max_iter)
  • To read value/provenance: Options.value(opt), Options.source(opt) or directly Options.value(opts, :max_iter)
  • For predicates on a strategy: Strategies.option_is_user(strategy, key) (or Options.is_user(options(strategy), key)).
  • Avoid direct field access (.value, .source, .options), which is reserved for the owning module.

Example usage (using DemoStrategy defined below):

using CTSolvers.Strategies

# Build strategy options with user-provided values
opts = Strategies.build_strategy_options(DemoStrategy; max_iter=250, tol=1e-7)

# Encapsulated access to option values
opt = Strategies.option(opts, :max_iter)
Options.value(opt)    # Returns: 250
Options.source(opt)   # Returns: :user

# Check provenance
Options.is_user(opts, :max_iter)    # Returns: true
Options.is_default(opts, :tol)      # Returns: false (user provided)

StrategyMetadata Overview (Strategies)

StrategyMetadata is a collection of OptionDefinition objects that describes all configurable options for a strategy. It is returned by Strategies.metadata(::Type).

meta = CTSolvers.Strategies.StrategyMetadata(
    OptionDefinition(name = :tol, type = Real, default = 1e-8, description = "Tolerance"),
    OptionDefinition(name = :max_iter, type = Integer, default = 1000, description = "Max iterations"),
    OptionDefinition(name = :verbose, type = Bool, default = false, description = "Verbose output"),
)
StrategyMetadata with 3 options:
├─ tol :: Real (default: 1.0e-8)
│  description: Tolerance
├─ max_iter :: Integer (default: 1000)
│  description: Max iterations
└─ verbose :: Bool (default: false)
   description: Verbose output

Collection interface

StrategyMetadata implements the standard Julia collection interface:

println("keys:   ", keys(meta))
println("length: ", length(meta))
println("haskey: ", haskey(meta, :tol))
keys:   (:tol, :max_iter, :verbose)
length: 3
haskey: true
meta[:tol]
tol :: Real (default: 1.0e-8)

Uniqueness

The constructor validates that all option names (including aliases) are unique across the entire metadata collection.

StrategyOptions

StrategyOptions stores the validated option values for a strategy instance. It is created by build_strategy_options.

abstract type DemoStrategy <: CTSolvers.Strategies.AbstractStrategy end
CTSolvers.Strategies.id(::Type{DemoStrategy}) = :demo
CTSolvers.Strategies.metadata(::Type{DemoStrategy}) = meta
opts = CTSolvers.Strategies.build_strategy_options(DemoStrategy;
    max_iter = 500, tol = 1e-6,
)
StrategyOptions with 3 options:
├─ max_iter = 500  [user]
├─ tol = 1.0e-6  [user]
└─ verbose = false  [default]

Access patterns

println("opts[:max_iter] = ", opts[:max_iter])
println("opts[:tol]      = ", opts[:tol])
println("opts[:verbose]  = ", opts[:verbose])
opts[:max_iter] = 500
opts[:tol]      = 1.0e-6
opts[:verbose]  = false

Collection interface

println("keys:   ", keys(opts))
println("length: ", length(opts))
println("haskey: ", haskey(opts, :tol))
keys:   (:max_iter, :tol, :verbose)
length: 3
haskey: true
for (k, v) in pairs(opts)
    println("  ", k, " => ", v)
end
  max_iter => 500
  tol => 1.0e-6
  verbose => false

Validation Modes

build_strategy_options supports two validation modes.

Strict mode (default)

Rejects unknown options with a helpful error message:

julia> CTSolvers.Strategies.build_strategy_options(DemoStrategy; max_itr = 500)ERROR: Control Toolbox Error

❌ Error: CTBase.Exceptions.IncorrectArgument, Unknown options provided for DemoStrategy

Unrecognized options: [:max_itr]

These options are not defined in the metadata of DemoStrategy.

Available options:
  :max_iter,   :tol,   :verbose

Suggestions for :max_itr:
  - :max_iter [distance: 1]
  - :tol [distance: 7]
  - :verbose [distance: 7]

If you are certain these options exist for the backend,
use permissive mode:
  DemoStrategy(...; mode=:permissive)

📂 Context: build_strategy_options - strict validation

Permissive mode

Accepts unknown options with a warning and stores them with :user source:

opts_perm = CTSolvers.Strategies.build_strategy_options(DemoStrategy;
    mode = :permissive, max_iter = 500, custom_flag = true,
)
println("keys: ", keys(opts_perm))
┌ Warning: Unrecognized options passed to backend
│
│ Unvalidated options: [:custom_flag]
│
│ These options will be passed directly to the DemoStrategy backend
│ without validation by CTSolvers. Ensure they are correct.
└ @ CTSolvers.Strategies ~/work/CTSolvers.jl/CTSolvers.jl/src/Strategies/api/validation_helpers.jl:105
keys: (:max_iter, :tol, :verbose, :custom_flag)

Extraction Functions

extract_option

Extracts a single option from a NamedTuple:

def_grid = OptionDefinition(
    name = :grid_size, type = Int, default = 100,
    description = "Grid size", aliases = (:n,),
)
opt_value, remaining = extract_option((n = 200, tol = 1e-6), def_grid)
println("Extracted: ", opt_value)
println("Remaining: ", remaining)
Extracted: 200 (user)
Remaining: (tol = 1.0e-6,)

The function:

  1. Searches all names (primary + aliases)
  2. Validates the type
  3. Runs the validator
  4. Returns OptionValue with :user source
  5. Removes the matched key from remaining kwargs

Type mismatch in extraction:

julia> extract_option((grid_size = "hello",), def_grid)ERROR: Control Toolbox Error

❌ Error: CTBase.Exceptions.IncorrectArgument, Invalid option type
🔍 Got: value hello of type String, Expected: Int64
📂 Context: Option extraction for grid_size
💡 Suggestion: Ensure the option value matches the expected type

extract_options

Extracts multiple options at once:

defs = [
    OptionDefinition(name = :grid_size, type = Int, default = 100, description = "Grid"),
    OptionDefinition(name = :tol, type = Float64, default = 1e-6, description = "Tol"),
]
extracted, remaining = extract_options((grid_size = 200, max_iter = 1000), defs)
println("Extracted: ", extracted)
println("Remaining: ", remaining)
Extracted: Dict{Symbol, CTSolvers.Options.OptionValue}(:grid_size => 200 (user), :tol => 1.0e-6 (default))
Remaining: (max_iter = 1000,)

extract_raw_options

Unwraps OptionValue wrappers and filters out NotProvided values:

raw_input = (
    backend   = OptionValue(:optimized, :user),
    show_time = OptionValue(false, :default),
    optional  = OptionValue(NotProvided, :default),
)
extract_raw_options(raw_input)
(show_time = false, backend = :optimized)

Data Flow Summary

flowchart TD User["User kwargs\n(max_iter=500, tol=1e-6)"] Meta["StrategyMetadata\n(OptionDefinition collection)"] BSO["build_strategy_options\n(validate, merge, track provenance)"] SO["StrategyOptions\n(max_iter=500 :user, tol=1e-6 :user,\nprint_level=5 :default)"] Dict["options_dict\n(Dict for backend)"] User --> BSO Meta --> BSO BSO --> SO SO --> Dict