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.ExceptionsOverview
The options system has four core types and a set of extraction functions:
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
| Field | Type | Description |
|---|---|---|
name | Symbol | Primary option name |
type | Type | Expected Julia type |
default | Any | Default value (or NotProvided) |
description | String | Human-readable description |
aliases | Tuple{Vararg{Symbol}} | Alternative names |
validator | Function or nothing | Validation function |
Constructor validation
The constructor automatically:
- Checks that
defaultmatches the declaredtype - Runs the
validatoron thedefaultvalue (if both are provided) - Skips validation when
defaultisNotProvided
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":
NotProvidedNotProvided# 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
| Source | Meaning |
|---|---|
:user | Explicitly provided by the user |
:default | Came from the OptionDefinition default |
:computed | Derived 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: userAccessing 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)falseEncapsulation Best Practices (Strategies)
- To retrieve an
OptionValuefrom a strategy:opt = Strategies.option(opts, :max_iter) - To read value/provenance:
Options.value(opt),Options.source(opt)or directlyOptions.value(opts, :max_iter) - For predicates on a strategy:
Strategies.option_is_user(strategy, key)(orOptions.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: truemeta[: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}) = metaopts = 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] = falseCollection interface
println("keys: ", keys(opts))
println("length: ", length(opts))
println("haskey: ", haskey(opts, :tol))keys: (:max_iter, :tol, :verbose)
length: 3
haskey: truefor (k, v) in pairs(opts)
println(" ", k, " => ", v)
end max_iter => 500
tol => 1.0e-6
verbose => falseValidation 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:
- Searches all names (primary + aliases)
- Validates the type
- Runs the validator
- Returns
OptionValuewith:usersource - 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)