diff --git a/.github/workflows/SpellCheck.yml b/.github/workflows/SpellCheck.yml index fe1c7c41..55133632 100644 --- a/.github/workflows/SpellCheck.yml +++ b/.github/workflows/SpellCheck.yml @@ -7,3 +7,5 @@ on: jobs: call: uses: control-toolbox/CTActions/.github/workflows/spell-check.yml@main + with: + config-path: '_typos.toml' diff --git a/Project.toml b/Project.toml index cb6e6cd6..edf2b8a3 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "CTBase" uuid = "54762871-cc72-4466-b8e8-f6c8b58076cd" -version = "0.20.0-beta" +version = "0.21.0-beta" authors = ["Olivier Cots ", "Jean-Baptiste Caillau "] [deps] diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 00000000..fdd8d6f9 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,16 @@ +[default] +locale = "en" +extend-ignore-re = [ + "adnlp_backen", + "ipopt_backen", + # Common variable names in our codebase + "strat", + "Strat", +] + +[files] +extend-exclude = [ + "*.json", + "*.toml", + "*.svg", +] diff --git a/docs/README.md b/docs/README.md index 64bea01e..e78f8182 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,3 +13,22 @@ Preview after build: `npx serve docs/build/1 --listen 5173` Documentation conventions and build workflow: [control-toolbox Handbook](https://github.com/control-toolbox/Handbook). + +## `@repl` vs `@example` — ANSI color rule + +DocumenterVitepress processes code block output differently depending on how it is produced: + +| Block type | Output fence | ANSI codes | Result | +| --- | --- | --- | --- | +| `@example` | ` ```ansi ` | processed by Shiki | **colors render correctly** | +| `@repl` | ` ```julia ` | parsed as Julia syntax | **raw escape sequences — ugly** | + +**Rules:** + +- Use **`@example`** when the output comes from a `show` method that uses ANSI colors + (strategy instances, `StrategyOptions`, `StrategyMetadata`, `StrategyRegistry`, …). +- Use **`@repl`** only when the output is a plain scalar value with no custom colored `show` + (integers, booleans, symbols, plain strings, tuples of symbols, types). +- Use **`@repl` + `try/catch # hide` + `showerror(IOContext(stdout, :color => false), e) # hide`** + to display exceptions — the `showerror` call produces plain text that renders cleanly inside a + `julia`-fenced block. diff --git a/docs/api_reference.jl b/docs/api_reference.jl index 46ec02fe..b902903c 100644 --- a/docs/api_reference.jl +++ b/docs/api_reference.jl @@ -23,28 +23,56 @@ function generate_api_reference(src_dir::String) joinpath("Core", "types.jl"), joinpath("Core", "matrix_utils.jl"), joinpath("Core", "function_utils.jl"), joinpath("Core", "macros.jl"), )), - (mod=CTBase.Interpolation, title="Interpolation", filename="interpolation", files=src( - joinpath("Interpolation", "Interpolation.jl"), joinpath("Interpolation", "types.jl"), - joinpath("Interpolation", "ctinterpolate.jl"), joinpath("Interpolation", "display.jl"), - )), (mod=CTBase.Descriptions, title="Descriptions", filename="descriptions", files=src( joinpath("Descriptions", "Descriptions.jl"), joinpath("Descriptions", "types.jl"), joinpath("Descriptions", "similarity.jl"), joinpath("Descriptions", "display.jl"), joinpath("Descriptions", "catalog.jl"), joinpath("Descriptions", "complete.jl"), joinpath("Descriptions", "remove.jl"), )), + (mod=CTBase.DevTools, title="DevTools", filename="devtools", files=src( + joinpath("DevTools", "DevTools.jl"), joinpath("DevTools", "coverage_postprocessing.jl"), + joinpath("DevTools", "documenter_reference.jl"), joinpath("DevTools", "test_runner.jl"), + )), (mod=CTBase.Exceptions, title="Exceptions", filename="exceptions", files=src( joinpath("Exceptions", "Exceptions.jl"), joinpath("Exceptions", "types.jl"), joinpath("Exceptions", "display.jl"), )), + (mod=CTBase.Interpolation, title="Interpolation", filename="interpolation", files=src( + joinpath("Interpolation", "Interpolation.jl"), joinpath("Interpolation", "types.jl"), + joinpath("Interpolation", "ctinterpolate.jl"), joinpath("Interpolation", "display.jl"), + )), + (mod=CTBase.Options, title="Options", filename="options", files=src( + joinpath("Options", "Options.jl"), joinpath("Options", "not_provided.jl"), + joinpath("Options", "option_value.jl"), joinpath("Options", "option_definition.jl"), + joinpath("Options", "extraction.jl"), + )), + (mod=CTBase.Orchestration, title="Orchestration", filename="orchestration", files=src( + joinpath("Orchestration", "Orchestration.jl"), + joinpath("Orchestration", "disambiguation.jl"), + joinpath("Orchestration", "builders.jl"), + joinpath("Orchestration", "routing.jl"), + )), + (mod=CTBase.Strategies, title="Strategies", filename="strategies", files=src( + joinpath("Strategies", "Strategies.jl"), + joinpath("Strategies", "display_formatting.jl"), + joinpath("Strategies", "contract", "abstract_strategy.jl"), + joinpath("Strategies", "contract", "metadata.jl"), + joinpath("Strategies", "contract", "strategy_options.jl"), + joinpath("Strategies", "contract", "parameters.jl"), + joinpath("Strategies", "api", "registry.jl"), + joinpath("Strategies", "api", "describe_registry.jl"), + joinpath("Strategies", "api", "introspection.jl"), + joinpath("Strategies", "api", "bypass.jl"), + joinpath("Strategies", "api", "builders.jl"), + joinpath("Strategies", "api", "configuration.jl"), + joinpath("Strategies", "api", "utilities.jl"), + joinpath("Strategies", "api", "validation_helpers.jl"), + joinpath("Strategies", "api", "disambiguation.jl"), + )), (mod=CTBase.Unicode, title="Unicode", filename="unicode", files=src( joinpath("Unicode", "Unicode.jl"), joinpath("Unicode", "subscripts.jl"), joinpath("Unicode", "superscripts.jl"), )), - (mod=CTBase.DevTools, title="DevTools", filename="devtools", files=src( - joinpath("DevTools", "DevTools.jl"), joinpath("DevTools", "coverage_postprocessing.jl"), - joinpath("DevTools", "documenter_reference.jl"), joinpath("DevTools", "test_runner.jl"), - )), ] pages = [ diff --git a/docs/make.jl b/docs/make.jl index cdbc4de7..cf53a441 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -69,9 +69,17 @@ with_api_reference(src_dir) do api_pages ), pages=[ "Getting Started" => "getting-started.md", - "User Guides" => [ + "Core Concepts" => [ "Descriptions" => joinpath("guide", "descriptions.md"), "Exceptions" => joinpath("guide", "exceptions.md"), + ], + "Strategies & Options" => [ + "Options System" => joinpath("guide", "options-system.md"), + "Implementing a Strategy" => joinpath("guide", "implementing-a-strategy.md"), + "Strategy Parameters" => joinpath("guide", "strategy-parameters.md"), + "Orchestration & Routing" => joinpath("guide", "orchestration-and-routing.md"), + ], + "Developer Tools" => [ "Test Runner" => joinpath("guide", "test-runner.md"), "Coverage" => joinpath("guide", "coverage.md"), "API Documentation" => joinpath("guide", "api-documentation.md"), diff --git a/docs/src/guide/implementing-a-strategy.md b/docs/src/guide/implementing-a-strategy.md new file mode 100644 index 00000000..7f119eef --- /dev/null +++ b/docs/src/guide/implementing-a-strategy.md @@ -0,0 +1,405 @@ +# Implementing a Strategy + +```@meta +CurrentModule = CTBase +``` + +This guide walks you through implementing a complete strategy family using the `AbstractStrategy` contract. We use **Collocation** and **DirectShooting** discretizers as concrete examples. + +!!! tip "Prerequisites" + Read the [Options System](@ref) guide first to understand `OptionDefinition`, `StrategyMetadata`, and `StrategyOptions`. + +```@setup strategy +using CTBase +using CTBase.Strategies +using CTBase.Options +``` + +## The Two-Level Contract + +Every strategy implements a **two-level contract** that separates static metadata from dynamic configuration: + +```text +Type-Level (no instantiation needed) +├─ id(::Type{<:S}) → Symbol (routing, registry lookup) +└─ metadata(::Type{<:S}) → StrategyMetadata (option specs + validation rules) + │ + ▼ routing, validation + Constructor(; mode, kwargs...) + │ + ▼ +Instance-Level (configured object) +└─ options(instance) → StrategyOptions (values + provenance) + │ + ▼ execution + Strategy computation +``` + +- **Type-level** methods (`id`, `metadata`) can be called on the **type itself** — no object needed. This enables registry lookup, option routing, and validation before any resource allocation. +- **Instance-level** methods (`options`) are called on **instances** — they carry the actual configuration with provenance tracking (user vs default). + +## Defining a Strategy Family + +A strategy family is an intermediate abstract type that groups related strategies. Here we define a family for optimal control discretizers: + +```@example strategy +abstract type AbstractOptimalControlDiscretizer <: Strategies.AbstractStrategy end +nothing # hide +``` + +This type enables: + +- Grouping discretizers in a `StrategyRegistry` by family +- Dispatching on the family in option routing +- Adding methods common to all discretizers + +## Implementing a Concrete Strategy: Collocation + +### Step 1 — Define the struct + +A strategy struct needs a field: `options::Strategies.StrategyOptions`. + +```@example strategy +struct Collocation <: AbstractOptimalControlDiscretizer + options::Strategies.StrategyOptions +end +nothing # hide +``` + +### Step 2 — Implement `id` + +The `id` method returns a unique `Symbol` identifier for the strategy. It is a **type-level** method. + +```@example strategy +Strategies.id(::Type{<:Collocation}) = :collocation +nothing # hide +``` + +### Step 3 — Define default values + +Use the `__name()` convention for private default functions: + +```@example strategy +__collocation_grid_size()::Int = 250 +__collocation_scheme()::Symbol = :midpoint +nothing # hide +``` + +### Step 4 — Implement `metadata` + +The `metadata` method returns a `StrategyMetadata` containing `OptionDefinition` objects. It is a **type-level** method. + +```@example strategy +function Strategies.metadata(::Type{<:Collocation}) + return Strategies.StrategyMetadata( + Options.OptionDefinition( + name = :grid_size, + type = Int, + default = __collocation_grid_size(), + description = "Number of time steps for the collocation grid", + ), + Options.OptionDefinition( + name = :scheme, + type = Symbol, + default = __collocation_scheme(), + description = "Time integration scheme (e.g., :midpoint, :trapeze)", + ), + ) +end +nothing # hide +``` + +Let's verify the metadata: + +```@example strategy +Strategies.metadata(Collocation) +``` + +### Step 5 — Implement the constructor + +The constructor uses `build_strategy_options` to validate and merge user-provided options with defaults: + +```@example strategy +function Collocation(; mode::Symbol = :strict, kwargs...) + opts = Strategies.build_strategy_options(Collocation; mode = mode, kwargs...) + return Collocation(opts) +end +nothing # hide +``` + +### Step 6 — Implement `options` + +The `options` method provides instance-level access to the configured options: + +```@example strategy +Strategies.options(c::Collocation) = c.options +nothing # hide +``` + +Now let's create instances and inspect them: + +```@example strategy +c = Collocation() +``` + +```@example strategy +c = Collocation(grid_size = 500, scheme = :trapeze) +``` + +```@example strategy +describe(Collocation) +``` + +### Step 7 — Access options + +The `StrategyOptions` object tracks both values and their provenance. You can access options in two ways: + +**Via the `options` getter:** + +```@example strategy +c = Collocation(grid_size = 100) +``` + +```@example strategy +Strategies.options(c) +``` + +```@repl strategy +Strategies.options(c)[:grid_size] +``` + +**Directly on the strategy instance (syntactic sugar):** + +```@repl strategy +c[:grid_size] +``` + +Both methods are equivalent — the direct access delegates to `options(strategy)[key]`. Use whichever style you prefer. + +**Accessing provenance information:** + +```@repl strategy +Strategies.source(Strategies.options(c), :grid_size) +``` + +```@repl strategy +Strategies.is_user(Strategies.options(c), :grid_size) +``` + +```@repl strategy +Strategies.is_default(Strategies.options(c), :scheme) +``` + +### Error handling + +A typo in an option name triggers a helpful error with Levenshtein suggestion: + +```@repl strategy +try # hide +Collocation(grdi_size = 500) +catch e # hide +showerror(IOContext(stdout, :color => false), e) # hide +end # hide +``` + +## Adding a Second Strategy: DirectShooting + +The same pattern applies to any strategy in the family. Here is `DirectShooting` with different options: + +```@example strategy +struct DirectShooting <: AbstractOptimalControlDiscretizer + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{<:DirectShooting}) = :direct_shooting + +__shooting_grid_size()::Int = 100 + +function Strategies.metadata(::Type{<:DirectShooting}) + return Strategies.StrategyMetadata( + Options.OptionDefinition( + name = :grid_size, + type = Int, + default = __shooting_grid_size(), + description = "Number of shooting intervals", + ), + ) +end + +function DirectShooting(; mode::Symbol = :strict, kwargs...) + opts = Strategies.build_strategy_options(DirectShooting; mode = mode, kwargs...) + return DirectShooting(opts) +end + +Strategies.options(ds::DirectShooting) = ds.options +nothing # hide +``` + +!!! note "Same option name, different definitions" + Both `Collocation` and `DirectShooting` define a `:grid_size` option, but with different defaults (250 vs 100) and descriptions. Each strategy has its own independent `OptionDefinition` set. + +```@example strategy +DirectShooting() +``` + +```@example strategy +DirectShooting(grid_size = 50) +``` + +## Registering the Family + +A `StrategyRegistry` maps abstract family types to their concrete strategies. This enables lookup by symbol and automated construction. + +```@example strategy +registry = Strategies.create_registry( + AbstractOptimalControlDiscretizer => (Collocation, DirectShooting), +) +``` + +Query the registry: + +```@repl strategy +Strategies.strategy_ids(AbstractOptimalControlDiscretizer, registry) +``` + +```@repl strategy +Strategies.type_from_id(:collocation, AbstractOptimalControlDiscretizer, registry) +``` + +Build a strategy from the registry: + +```@example strategy +Strategies.build_strategy(:collocation, AbstractOptimalControlDiscretizer, registry; grid_size = 300) +``` + +```@example strategy +Strategies.build_strategy(:direct_shooting, AbstractOptimalControlDiscretizer, registry; grid_size = 50) +``` + +## Integration with Method Tuples + +In the full CTBase pipeline, a **method tuple** like `(:collocation, :adnlp, :ipopt)` identifies one strategy per family. The orchestration layer extracts the right ID for each family: + +```@repl strategy +method = (:collocation, :adnlp, :ipopt) +Strategies.extract_id_from_method(method, AbstractOptimalControlDiscretizer, registry) +``` + +Build a strategy directly from a method tuple: + +```@example strategy +id = Strategies.extract_id_from_method(method, AbstractOptimalControlDiscretizer, registry) +Strategies.build_strategy(id, AbstractOptimalControlDiscretizer, registry; grid_size = 500, scheme = :trapeze) +``` + +See [Orchestration & Routing](@ref) for the full multi-strategy routing system. + +## Introspection + +The Strategies API provides type-level introspection without instantiation: + +```@repl strategy +Strategies.option_names(Collocation) +``` + +```@repl strategy +Strategies.option_names(DirectShooting) +``` + +```@repl strategy +Strategies.option_defaults(Collocation) +``` + +```@repl strategy +Strategies.option_defaults(DirectShooting) +``` + +```@repl strategy +Strategies.option_type(Collocation, :scheme) +``` + +```@repl strategy +Strategies.option_description(Collocation, :grid_size) +``` + +## Advanced Patterns + +### Permissive Mode + +Use `mode = :permissive` to accept backend-specific options that are not declared in the metadata: + +```@example strategy +Collocation(grid_size = 500, custom_backend_param = 42; mode = :permissive) +``` + +Unknown options are stored with `:user` source but bypass type validation. Known options are still fully validated. + +### Bypass Validation for Specific Options + +Use `bypass(val)` (or its alias `force(val)`) to skip validation for a **single option value** while keeping strict mode for everything else. + +**Unknown option** — accepted silently, no warning: + +```@example strategy +Collocation(grid_size = 500, custom_backend_param = bypass(42)) +``` + +**Known option with wrong type** — normally rejected, accepted with `bypass`: + +```@repl strategy +try # hide +Collocation(grid_size = "oops") # type error: grid_size expects Int +catch e; showerror(IOContext(stdout, :color => false), e) end # hide +``` + +```@example strategy +Collocation(grid_size = bypass("oops")) # no error: validation skipped +``` + +This is more surgical than `mode = :permissive`: + +| Approach | Scope | Unknown option names | Type validation | +|--------------------------------|-------------|-----------------------|-----------------------------| +| `mode = :permissive` | all options | accepted with warning | skipped for unknowns | +| `bypass(val)` / `force(val)` | one value | accepted silently | skipped for that value only | + +`force` is an alias for `bypass` — choose the name that fits your mental model: + +```julia +Collocation(grid_size = force("oops")) # same as bypass("oops") +``` + +!!! warning "Use with care" + Bypassed values are not type-checked, even for declared options. A wrong type or invalid value will only surface as a backend-level error. + +### Option Aliases + +An `OptionDefinition` can declare aliases — alternative names that resolve to the primary name: + +```julia +Options.OptionDefinition( + name = :grid_size, + type = Int, + default = 250, + description = "Number of time steps", + aliases = [:N, :num_steps], +) +``` + +With this definition, `Collocation(N = 100)` would be equivalent to `Collocation(grid_size = 100)`. + +### Custom Validators + +Add a `validator` function to enforce constraints beyond type checking: + +```julia +Options.OptionDefinition( + name = :grid_size, + type = Int, + default = 250, + description = "Number of time steps", + validator = x -> x > 0 || throw(ArgumentError("grid_size must be positive")), +) +``` + +The validator is called during construction in both strict and permissive modes. diff --git a/docs/src/guide/options-system.md b/docs/src/guide/options-system.md new file mode 100644 index 00000000..f1704537 --- /dev/null +++ b/docs/src/guide/options-system.md @@ -0,0 +1,435 @@ +```@meta +CurrentModule = CTBase +``` + +# Options System + +This guide explains the Options module — the foundational layer for defining, validating, extracting, and tracking configuration values throughout CTBase. The Options module is generic and has no dependencies on other CTBase modules. + +```@example options +using CTBase +nothing # hide +``` + +## Overview + +The options system has four core types and a set of extraction functions: + +```text +OptionDefinition (schema) +├─► StrategyMetadata (collection of definitions) +│ └─► build_strategy_options (validate + merge) +│ └─► StrategyOptions (validated values) +└─► extract_option (single extraction) + └─► OptionValue (value + provenance) +``` + +## OptionDefinition + +An `OptionDefinition` is the schema for a single option. It specifies the name, type, default, description, aliases, and an optional validator. + +```@example options +using CTBase.Options: OptionDefinition, OptionValue, NotProvided # hide +using CTBase.Options: all_names, extract_option, extract_options, extract_raw_options # hide +def = OptionDefinition( + name = :max_iter, + type = Integer, + default = 1000, + description = "Maximum number of iterations", + aliases = (:maxiter,), + validator = x -> x >= 0 || throw(CTBase.Exceptions.IncorrectArgument( + "Invalid max_iter", got = "$x", expected = ">= 0", + )), +) +``` + +### 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: + +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: + +```@repl options +try # hide +OptionDefinition(name = :count, type = Integer, default = "hello", description = "A count") +catch e # hide +showerror(IOContext(stdout, :color => false), e) # hide +end # hide +``` + +### Aliases + +Aliases allow users to use alternative names for the same option: + +```@example options +def_alias = OptionDefinition( + name = :max_iter, type = Int, default = 100, + description = "Max iterations", aliases = (:maxiter, :max), +) +all_names(def_alias) +``` + +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: + +```@example options +validated_def = OptionDefinition( + name = :tol, type = Real, default = 1e-8, + description = "Tolerance", + validator = x -> x > 0 || throw(CTBase.Exceptions.IncorrectArgument( + "Invalid tolerance", + got = "tol=$x", expected = "positive real number (> 0)", + suggestion = "Use 1e-6 or 1e-8", + )), +) +nothing # hide +``` + +Validator failure: + +```@repl options +try # hide +extract_option((tol = -1.0,), validated_def) +catch e # hide +showerror(IOContext(stdout, :color => false), e) # hide +end # hide +``` + +## NotProvided + +`NotProvided` is a sentinel value that distinguishes "no default" from "default is `nothing`": + +```@example options +NotProvided +``` + +```@example options +# 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", +) +``` + +When `extract_option` encounters a `NotProvided` default and the user hasn't provided the option, the option is excluded from the result: + +```@example options +result, remaining = extract_option((other = 42,), opt_np) +println("Result: ", result) +println("Remaining: ", remaining) +``` + +## OptionValue and Provenance + +`OptionValue` wraps a value with its **provenance** — where it came from: + +```@example options +OptionValue(1000, :user) +``` + +```@example options +OptionValue(1e-8, :default) +``` + +```@example options +OptionValue(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: + +```@repl options +try # hide +OptionValue(42, :invalid_source) +catch e # hide +showerror(IOContext(stdout, :color => false), e) # hide +end # hide +``` + +Provenance tracking enables introspection — you can tell whether a value was explicitly chosen or inherited from defaults: + +```@example options +opt = OptionValue(1000, :user) +println("Value: ", opt.value) +println("Source: ", opt.source) +``` + +## Accessing Option Properties + +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. + +```@example options +using CTBase.Options +def2 = OptionDefinition( + name = :max_iter, + type = Int, + default = 100, + description = "Maximum iterations", + aliases = (:maxiter,), +) +opt2 = OptionValue(200, :user) +nothing # hide +``` + +```@repl options +Options.name(def2) +Options.type(def2) +Options.default(def2) +Options.description(def2) +Options.aliases(def2) +Options.is_required(def2) +Options.value(opt2) +Options.source(opt2) +Options.is_user(opt2) +Options.is_default(opt2) +Options.is_computed(opt2) +``` + +## StrategyMetadata Overview + +`StrategyMetadata` is a collection of `OptionDefinition` objects that describes all configurable options for a strategy. It is returned by `Strategies.metadata(::Type)`. + +```@example options +meta = CTBase.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"), +) +``` + +### Collection interface + +`StrategyMetadata` implements the standard Julia collection interface: + +```@example options +println("keys: ", keys(meta)) +println("length: ", length(meta)) +println("haskey: ", haskey(meta, :tol)) +``` + +```@example options +meta[:tol] +``` + +### 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`. + +```@example options +abstract type DemoStrategy <: CTBase.Strategies.AbstractStrategy end +CTBase.Strategies.id(::Type{DemoStrategy}) = :demo +CTBase.Strategies.metadata(::Type{DemoStrategy}) = meta +nothing # hide +``` + +```@example options +opts = CTBase.Strategies.build_strategy_options(DemoStrategy; + max_iter = 500, tol = 1e-6, +) +``` + +### Access patterns + +```@example options +println("opts[:max_iter] = ", opts[:max_iter]) +println("opts[:tol] = ", opts[:tol]) +println("opts[:verbose] = ", opts[:verbose]) +``` + +### Collection interface + +```@example options +println("keys: ", keys(opts)) +println("length: ", length(opts)) +println("haskey: ", haskey(opts, :tol)) +``` + +```@example options +for (k, v) in pairs(opts) + println(" ", k, " => ", v) +end +``` + +### Conversion to Dict + +StrategyOptions can be converted to a mutable `Dict` for modification before passing to backend solvers or model builders: + +```@example options +dict = CTBase.Strategies.options_dict(opts) +println("Type: ", typeof(dict)) +println("max_iter: ", dict[:max_iter]) +``` + +The conversion unwraps `OptionValue` wrappers and filters out `NotProvided` values: + +```@example options +# Modify the dict (doesn't affect original StrategyOptions) +dict[:max_iter] = 1000 +println("Dict: ", dict[:max_iter]) +println("Original: ", opts[:max_iter]) +``` + +This pattern is commonly used in solver extensions and modelers to customize options before passing them to backend implementations. + +## Encapsulation Best Practices + +Prefer the `Options` and `Strategies` getters over direct field access: + +- `opts[:key]` — raw option value +- `opts.key` — full `OptionValue` (value + provenance), displayed as `500 (user)` +- `CTBase.Strategies.option(opts, :key)` — same as dot notation +- `Options.value(opts, :key)`, `Options.source(opts, :key)` — scalar access +- `Options.is_user(opts, :key)`, `Options.is_default(opts, :key)` — provenance predicates + +Using `opts` defined above: + +```@repl options +CTBase.Strategies.option(opts, :max_iter) +Options.value(opts, :max_iter) +Options.source(opts, :max_iter) +Options.is_user(opts, :max_iter) +Options.is_default(opts, :verbose) +``` + +!!! tip "Direct access shortcut on strategy instances" + When working with a concrete strategy, `strategy[:key]` is syntactic sugar for + `Strategies.options(strategy)[:key]` — both return the raw value. See + [Implementing a Strategy](@ref) for a complete example. + +## Validation Modes + +`build_strategy_options` supports two validation modes. + +### Strict mode (default) + +Rejects unknown options with a helpful error message: + +```@repl options +try # hide +CTBase.Strategies.build_strategy_options(DemoStrategy; max_itr = 500) +catch e # hide +showerror(IOContext(stdout, :color => false), e) # hide +end # hide +``` + +### Permissive mode + +Accepts unknown options with a warning and stores them with `:user` source: + +```@example options +opts_perm = CTBase.Strategies.build_strategy_options(DemoStrategy; + mode = :permissive, max_iter = 500, custom_flag = true, +) +println("keys: ", keys(opts_perm)) +``` + +## Extraction Functions + +### `extract_option` + +Extracts a single option from a `NamedTuple`: + +```@example options +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) +``` + +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: + +```@repl options +try # hide +extract_option((grid_size = "hello",), def_grid) +catch e # hide +showerror(IOContext(stdout, :color => false), e) # hide +end # hide +``` + +### `extract_options` + +Extracts multiple options at once: + +```@example options +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) +``` + +### `extract_raw_options` + +Unwraps `OptionValue` wrappers and filters out `NotProvided` values: + +```@example options +raw_input = ( + backend = OptionValue(:optimized, :user), + show_time = OptionValue(false, :default), + optional = OptionValue(NotProvided, :default), +) +extract_raw_options(raw_input) +``` + +## Data Flow Summary + +```text +User kwargs StrategyMetadata +(max_iter=500, tol=1e-6) (OptionDefinition collection) + │ │ + └──────────────┬──────────────┘ + ▼ + build_strategy_options + (validate, merge, track provenance) + │ + ▼ + StrategyOptions + (max_iter=500 :user, tol=1e-6 :user, + print_level=5 :default) + │ + ▼ + options_dict + (Dict for backend) +``` diff --git a/docs/src/guide/orchestration-and-routing.md b/docs/src/guide/orchestration-and-routing.md new file mode 100644 index 00000000..77cc8942 --- /dev/null +++ b/docs/src/guide/orchestration-and-routing.md @@ -0,0 +1,276 @@ +# Orchestration and Routing + +```@meta +CurrentModule = CTBase +``` + +This guide explains how the Orchestration module routes user-provided keyword arguments to the correct strategy in a multi-strategy pipeline. It covers the method tuple concept, automatic routing, disambiguation syntax, and the helper functions that power the system. + +!!! tip "Prerequisites" + Read [Implementing a Strategy](@ref) first. Orchestration builds on top of the strategy metadata system. + +```@setup routing +using CTBase +using CTBase.Options: OptionDefinition +using CTBase.Strategies: route_to +using CTBase.Orchestration: resolve_method, route_all_options +using CTBase.Orchestration: extract_strategy_ids, build_strategy_to_family_map, build_option_ownership_map +``` + +We define three fake strategies — a discretizer, a modeler, and a solver — with a shared `backend` option to demonstrate routing and disambiguation: + +```@example routing +# --- Fake discretizer family --- +abstract type AbstractFakeDiscretizer <: CTBase.Strategies.AbstractStrategy end +struct FakeCollocation <: AbstractFakeDiscretizer; options::CTBase.Strategies.StrategyOptions; end +CTBase.Strategies.id(::Type{<:FakeCollocation}) = :collocation +CTBase.Strategies.metadata(::Type{<:FakeCollocation}) = CTBase.Strategies.StrategyMetadata( + OptionDefinition(name = :grid_size, type = Int, default = 100, description = "Grid size"), +) +FakeCollocation(; kwargs...) = FakeCollocation(CTBase.Strategies.build_strategy_options(FakeCollocation; kwargs...)) + +# --- Fake modeler family --- +abstract type AbstractFakeModeler <: CTBase.Strategies.AbstractStrategy end +struct FakeADNLP <: AbstractFakeModeler; options::CTBase.Strategies.StrategyOptions; end +CTBase.Strategies.id(::Type{<:FakeADNLP}) = :adnlp +CTBase.Strategies.metadata(::Type{<:FakeADNLP}) = CTBase.Strategies.StrategyMetadata( + OptionDefinition(name = :backend, type = Symbol, default = :default, description = "AD backend"), +) +FakeADNLP(; kwargs...) = FakeADNLP(CTBase.Strategies.build_strategy_options(FakeADNLP; kwargs...)) + +# --- Fake solver family --- +abstract type AbstractFakeSolver <: CTBase.Strategies.AbstractStrategy end +struct FakeIpopt <: AbstractFakeSolver; options::CTBase.Strategies.StrategyOptions; end +CTBase.Strategies.id(::Type{<:FakeIpopt}) = :ipopt +CTBase.Strategies.metadata(::Type{<:FakeIpopt}) = CTBase.Strategies.StrategyMetadata( + OptionDefinition(name = :max_iter, type = Integer, default = 1000, description = "Max iterations"), + OptionDefinition(name = :backend, type = Symbol, default = :cpu, description = "Compute backend"), +) +FakeIpopt(; kwargs...) = FakeIpopt(CTBase.Strategies.build_strategy_options(FakeIpopt; kwargs...)) + +# --- Registry --- +registry = CTBase.Strategies.create_registry( + AbstractFakeDiscretizer => (FakeCollocation,), + AbstractFakeModeler => (FakeADNLP,), + AbstractFakeSolver => (FakeIpopt,), +) +``` + +## The Method Tuple Concept + +A **method tuple** identifies which concrete strategy to use for each role in the pipeline: + +```@example routing +method = (:collocation, :adnlp, :ipopt) +nothing # hide +``` + +Each symbol is a strategy `id` (returned by `Strategies.id(::Type)`). The **families** mapping associates each role with its abstract type: + +```@example routing +families = ( + discretizer = AbstractFakeDiscretizer, + modeler = AbstractFakeModeler, + solver = AbstractFakeSolver, +) +nothing # hide +``` + +The orchestration system uses the `StrategyRegistry` to resolve each symbol to its concrete type and access its metadata. + +## Automatic Routing + +When a user passes keyword arguments, `route_all_options` automatically routes each option to the strategy that owns it: + +```julia +solve(ocp, :collocation, :adnlp, :ipopt; + grid_size = 100, # only discretizer defines this → auto-route + max_iter = 1000, # only solver defines this → auto-route + display = true, # action option → extracted separately +) +``` + +The routing algorithm: + +```text +User kwargs + │ + ▼ +Extract action options (display, etc.) + │ + ▼ +Remaining kwargs + │ + ▼ +Build option ownership map +(which family defines each option) + │ + ├─ 0 owners → ERROR: Unknown option + │ + ├─ 1 owner → Auto-route to owner + │ + └─ 2+ owners → Disambiguation syntax used? + ├─ Yes → Route to specified strategy + └─ No → ERROR: Ambiguous option +``` + +### How it works internally + +1. **Extract action options** — options like `display` are matched against `action_defs` and removed from the pool +2. **Build strategy-to-family map** — maps each strategy ID to its family name (e.g., `:ipopt → :solver`) +3. **Build option ownership map** — scans all strategy metadata to determine which family defines each option name +4. **Route each remaining option** — auto-route if unambiguous, require disambiguation if ambiguous, error if unknown + +## Disambiguation + +When an option name appears in multiple strategies (e.g., `backend` is defined by both the modeler and the solver), the user must disambiguate using `route_to`: + +`route_to` accepts two equivalent syntaxes — keyword and positional — choose based on preference: + +| Syntax | Example | +|-------------|------------------------------| +| Keyword | `route_to(adnlp = :sparse)` | +| Positional | `route_to(:adnlp, :sparse)` | + +### Single strategy + +```julia +# keyword syntax +solve(ocp, :collocation, :adnlp, :ipopt; + backend = route_to(adnlp = :sparse), +) + +# positional syntax (equivalent) +solve(ocp, :collocation, :adnlp, :ipopt; + backend = route_to(:adnlp, :sparse), +) +``` + +### Multiple strategies + +```julia +# keyword syntax +solve(ocp, :collocation, :adnlp, :ipopt; + backend = route_to(adnlp = :sparse, ipopt = :cpu), +) + +# positional syntax (equivalent) +solve(ocp, :collocation, :adnlp, :ipopt; + backend = route_to(:adnlp, :sparse, :ipopt, :cpu), +) +``` + +### How `route_to` works + +`route_to` creates a `RoutedOption` object that carries `(strategy_id => value)` pairs: + +```@example routing +opt = route_to(ipopt = 100, adnlp = 50) +``` + +The orchestration layer uses `extract_strategy_ids` to unpack this object during routing — see [Helper Functions](@ref) below. + +## Helper Functions + +The helper functions below are used internally by `route_all_options`. They operate on a `ResolvedMethod` — a precomputed view of the method tuple. In normal usage you do not need to call them directly; they are exposed for advanced use cases (custom routing logic, introspection, testing). + +To use them, first call `resolve_method`: + +```@example routing +resolved = resolve_method(method, families, registry) +nothing # hide +``` + +### `build_strategy_to_family_map` + +Maps each strategy ID in the method to its family name: + +```@example routing +build_strategy_to_family_map(resolved, families, registry) +``` + +### `build_option_ownership_map` + +Scans all strategy metadata and maps each option name to the set of families that define it: + +```@example routing +build_option_ownership_map(resolved, families, registry) +``` + +Note that `:backend` is owned by both `:modeler` and `:solver` — it is ambiguous and requires disambiguation. + +### `extract_strategy_ids` + +Unpacks a `RoutedOption` into a vector of `(value, strategy_id)` pairs: + +```@example routing +extract_strategy_ids(route_to(ipopt = 100, adnlp = 50), resolved) +``` + +For plain (non-routed) values, no disambiguation is detected — the function returns `nothing`: + +```@repl routing +extract_strategy_ids(:plain_value, resolved) +``` + +Passing an unknown strategy ID throws an error: + +```@repl routing +try # hide +extract_strategy_ids(route_to(unknown = 42), resolved) +catch e # hide +showerror(IOContext(stdout, :color => false), e) # hide +end # hide +``` + +## Complete Example + +Auto-routing with disambiguation and action option extraction: + +```@example routing +action_defs = [ + OptionDefinition(name = :display, type = Bool, default = true, + description = "Display solver progress"), +] + +kwargs = ( + grid_size = 100, # auto-routed to discretizer + max_iter = 500, # auto-routed to solver + backend = route_to(adnlp = :optimized), # disambiguated to modeler + display = false, # action option +) + +routed = route_all_options(method, families, action_defs, kwargs, registry) +``` + +Action options: + +```@example routing +routed.action +``` + +Strategy options per family: + +```@example routing +routed.strategies +``` + +### Error: unknown option + +```@repl routing +try # hide +route_all_options(method, families, action_defs, (foo = 42,), registry) +catch e # hide +showerror(IOContext(stdout, :color => false), e) # hide +end # hide +``` + +### Error: ambiguous option without disambiguation + +```@repl routing +try # hide +route_all_options(method, families, action_defs, (backend = :sparse,), registry) +catch e # hide +showerror(IOContext(stdout, :color => false), e) # hide +end # hide +``` diff --git a/docs/src/guide/strategy-parameters.md b/docs/src/guide/strategy-parameters.md new file mode 100644 index 00000000..b2b1b38e --- /dev/null +++ b/docs/src/guide/strategy-parameters.md @@ -0,0 +1,308 @@ +```@meta +CurrentModule = CTBase +``` + +# Strategy Parameters + +This guide explains the **Strategy Parameters** system in CTBase. Parameters are singleton types that allow a strategy to specialize its metadata and default options depending on the execution context (e.g., CPU vs GPU). + +!!! tip "Prerequisites" + Read the [Implementing a Strategy](@ref) guide first. Parameters extend the strategy system with type-based specialization. + +```@setup params +using CTBase +using CTBase.Strategies +using CTBase.Options +``` + +## Concept + +**Strategy parameters** are singleton types that enable: + +- **Type-based dispatch** so the same strategy struct can carry different defaults on CPU vs GPU +- **Compile-time specialization** through Julia's type system +- **Registry-level routing** so a method tuple like `(:mysolver, :cpu)` resolves to the right concrete type + +Parameters are **not** runtime values — they exist purely for dispatch and metadata specialization. + +## Built-in Parameters + +CTBase ships two built-in parameters: + +```@example params +Strategies.id(Strategies.CPU) +``` + +```@example params +Strategies.id(Strategies.GPU) +``` + +```@example params +Strategies.description(Strategies.CPU) +``` + +`describe` shows full introspection for a parameter type: + +```@example params +Strategies.describe(Strategies.CPU) +``` + +## Parameter Contract + +Every parameter type must: + +1. **Subtype `AbstractStrategyParameter`** +2. **Be a singleton** (no fields) +3. **Implement `id(::Type{<:YourParameter})`** returning a `Symbol` +4. **Implement `description(::Type{<:YourParameter})`** returning a `String` + +```@example params +struct Distributed <: Strategies.AbstractStrategyParameter end +Strategies.id(::Type{Distributed}) = :distributed +Strategies.description(::Type{Distributed}) = "Distributed multi-node execution" +Strategies.describe(Distributed) +``` + +## Parameter Validation + +```@example params +Strategies.is_a_parameter(Strategies.CPU) # true +``` + +```@example params +Strategies.is_a_parameter(Int) # false +``` + +```@example params +Strategies.parameter_id(Strategies.CPU) # :cpu +``` + +`validate_parameter_type` checks the full contract and returns `nothing` if valid: + +```@example params +Strategies.validate_parameter_type(Strategies.CPU) +``` + +## Parameterized Strategy: Step-by-Step + +A parameterized strategy is a generic struct over `P <: AbstractStrategyParameter`. The `metadata` method is specialized on `Type{MyStrategy{P}}`, letting Julia dispatch select the right defaults for each parameter. + +### Step 1 — Define the strategy family and struct + +```@example params +abstract type AbstractFakeOptimizer <: Strategies.AbstractStrategy end + +struct FakeOptimizer{P <: Strategies.AbstractStrategyParameter} <: AbstractFakeOptimizer + options::Strategies.StrategyOptions +end +nothing # hide +``` + +### Step 2 — Implement `id` + +All parameter variants share the same ID: + +```@example params +Strategies.id(::Type{<:FakeOptimizer}) = :fake_optimizer +nothing # hide +``` + +### Step 3 — Parameter-specific default helpers + +```@example params +__fake_default_precision(::Type{Strategies.CPU}) = :float64 +__fake_default_precision(::Type{Strategies.GPU}) = :float32 +nothing # hide +``` + +### Step 4 — Implement `metadata` specialized on the parameterized type + +The dispatch is on `::Type{FakeOptimizer{P}}` — not on a second argument: + +```@example params +function Strategies.metadata(::Type{FakeOptimizer{P}}) where {P <: Strategies.AbstractStrategyParameter} + return Strategies.StrategyMetadata( + Options.OptionDefinition( + name = :precision, + type = Symbol, + default = __fake_default_precision(P), + description = "Numerical precision (:float64 for CPU, :float32 for GPU)", + computed = true, + ), + Options.OptionDefinition( + name = :max_iter, + type = Int, + default = 1000, + description = "Maximum number of iterations", + ), + ) +end +nothing # hide +``` + +!!! note "Mark computed options with `computed=true`" + An option whose default value depends on the parameter type `P` should be marked `computed=true`. It is evaluated at metadata construction time, not hard-coded. This flag is optional but strongly recommended: `describe` separates computed options by parameter (showing the actual default for each), making the parameter-specific behavior immediately visible to users. + +Let's verify the metadata for each parameter — the `:precision` default differs: + +```@example params +Strategies.metadata(FakeOptimizer{Strategies.CPU}) +``` + +```@example params +Strategies.metadata(FakeOptimizer{Strategies.GPU}) +``` + +### Step 5 — Implement the constructor + +The constructor is specialized on the parameterized type so `build_strategy_options` calls the right `metadata`: + +```@example params +function FakeOptimizer{P}(; mode::Symbol = :strict, kwargs...) where {P <: Strategies.AbstractStrategyParameter} + opts = Strategies.build_strategy_options(FakeOptimizer{P}; mode = mode, kwargs...) + return FakeOptimizer{P}(opts) +end +nothing # hide +``` + +### Step 6 — Instantiate and inspect + +```@example params +FakeOptimizer{Strategies.CPU}() +``` + +```@example params +FakeOptimizer{Strategies.GPU}() +``` + +```@example params +FakeOptimizer{Strategies.CPU}(max_iter = 500) +``` + +Option access works exactly like non-parameterized strategies: + +```@example params +solver = FakeOptimizer{Strategies.GPU}(max_iter = 200) +``` + +```@repl params +solver[:precision] +solver[:max_iter] +Strategies.source(Strategies.options(solver), :max_iter) +``` + +## Registering Parameterized Strategies + +In `create_registry`, a parameterized strategy is declared as a `(StrategyType, [Param1, Param2, ...])` tuple. Non-parameterized strategies are listed as plain types: + +```@example params +registry = Strategies.create_registry( + AbstractFakeOptimizer => ( + (FakeOptimizer, [Strategies.CPU, Strategies.GPU]), + ), +) +``` + +The registry expands this into one concrete type per parameter. `strategy_ids` deduplicates: + +```@example params +Strategies.strategy_ids(AbstractFakeOptimizer, registry) +``` + +```@example params +Strategies.type_from_id(:fake_optimizer, AbstractFakeOptimizer, registry; parameter=Strategies.CPU) +``` + +```@example params +Strategies.type_from_id(:fake_optimizer, AbstractFakeOptimizer, registry; parameter=Strategies.GPU) +``` + +## Building Strategies from the Registry + +`build_strategy` accepts an optional parameter type as second argument: + +```@example params +Strategies.build_strategy(:fake_optimizer, Strategies.CPU, AbstractFakeOptimizer, registry; + max_iter = 300) +``` + +```@example params +Strategies.build_strategy(:fake_optimizer, Strategies.GPU, AbstractFakeOptimizer, registry) +``` + +## Method Tuple Routing + +When using a method tuple (e.g., `(:fake_optimizer, :cpu)`), `extract_global_parameter_from_method` reads the parameter token from the registry: + +```@example params +method = (:fake_optimizer, :cpu) +param = Strategies.extract_global_parameter_from_method(method, registry) +``` + +```@example params +id = Strategies.extract_id_from_method(method, AbstractFakeOptimizer, registry) +Strategies.build_strategy(id, param, AbstractFakeOptimizer, registry) +``` + +## Mixed Registries + +A registry can mix parameterized and non-parameterized strategies in the same family: + +```@example params +struct FallbackOptimizer <: AbstractFakeOptimizer + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{<:FallbackOptimizer}) = :fallback + +function Strategies.metadata(::Type{<:FallbackOptimizer}) + return Strategies.StrategyMetadata( + Options.OptionDefinition( + name = :max_iter, type = Int, default = 500, + description = "Maximum iterations", + ), + ) +end + +function FallbackOptimizer(; mode::Symbol = :strict, kwargs...) + opts = Strategies.build_strategy_options(FallbackOptimizer; mode = mode, kwargs...) + return FallbackOptimizer(opts) +end + +mixed_registry = Strategies.create_registry( + AbstractFakeOptimizer => ( + FallbackOptimizer, + (FakeOptimizer, [Strategies.CPU, Strategies.GPU]), + ), +) +``` + +```@example params +Strategies.strategy_ids(AbstractFakeOptimizer, mixed_registry) +``` + +## `describe` with a Registry + +`describe` with a registry shows the full picture including available parameters: + +```@example params +Strategies.describe(:fake_optimizer, registry) +``` + +## Summary + +| Aspect | Description | +|--------|-------------| +| **Purpose** | Compile-time specialization of strategy defaults and metadata | +| **Contract** | Singleton struct + `id` + `description` implementations | +| **Built-in** | `CPU`, `GPU` | +| **Metadata dispatch** | `metadata(::Type{MyStrategy{P}}) where {P}` | +| **Registry syntax** | `(MyStrategy, [CPU, GPU])` tuple inside the family tuple | +| **Builder** | `build_strategy(id, Param, Family, registry; kwargs...)` | +| **Validation** | `validate_parameter_type`, `is_a_parameter`, `parameter_id` | + +## See Also + +- [Implementing a Strategy](@ref) — Strategy contract and metadata +- [Options System](@ref) — `OptionDefinition`, `StrategyOptions` +- `Strategies.AbstractStrategyParameter` — API reference diff --git a/src/CTBase.jl b/src/CTBase.jl index abe634b0..3832490a 100644 --- a/src/CTBase.jl +++ b/src/CTBase.jl @@ -20,6 +20,18 @@ using .Core include(joinpath(@__DIR__, "Exceptions", "Exceptions.jl")) using .Exceptions +# Options module - generic option handling +include(joinpath(@__DIR__, "Options", "Options.jl")) +using .Options + +# Strategies module - generic strategy contract and registry +include(joinpath(@__DIR__, "Strategies", "Strategies.jl")) +using .Strategies + +# Orchestration module - option routing and disambiguation +include(joinpath(@__DIR__, "Orchestration", "Orchestration.jl")) +using .Orchestration + # Unicode module - Unicode character utilities include(joinpath(@__DIR__, "Unicode", "Unicode.jl")) using .Unicode diff --git a/src/Exceptions/display.jl b/src/Exceptions/display.jl index a8e928d9..80f8dc68 100644 --- a/src/Exceptions/display.jl +++ b/src/Exceptions/display.jl @@ -281,10 +281,16 @@ function _print_pipe_field(io, label::String, value, max_len::Int, color::Symbol end end else - # Single value + # String value — split on newlines so continuation lines keep the │ prefix + lines = split(string(value), '\n') print(io, Core._dim("│", io), " ", Core._bold(rpad(label, max_len), io), " ") - _print_colored(io, string(value), color) + _print_colored(io, lines[1], color) println(io) + for line in lines[2:end] + print(io, Core._dim("│", io), " ", " "^max_len, " ") + _print_colored(io, line, color) + println(io) + end end end @@ -340,8 +346,10 @@ function _format_user_friendly_error(io::IO, e::CTException) # Blank pipe separator println(io, Core._dim("│", io)) - # Message - println(io, Core._dim("│", io), " ", Core._bold(e.msg, io)) + # Message (each line gets its own │ prefix) + for line in split(rstrip(e.msg), '\n') + println(io, Core._dim("│", io), " ", Core._bold(line, io)) + end # Build field pairs primary_pairs = _build_primary_pairs(e) diff --git a/src/Options/Options.jl b/src/Options/Options.jl new file mode 100644 index 00000000..37f37fad --- /dev/null +++ b/src/Options/Options.jl @@ -0,0 +1,35 @@ +""" +Generic option handling for the Control Toolbox ecosystem. + +This module provides the foundational types and functions for: +- Option value tracking with provenance +- Option schema definition with validation and aliases +- Option extraction with alias support +- Type validation and helpful error messages + +The Options module is deliberately generic and has no dependencies on other +CTBase modules, making it reusable across the ecosystem. +""" +module Options + +# Imports +import DocStringExtensions: TYPEDEF, TYPEDSIGNATURES +import CTBase.Core +import CTBase.Exceptions + +# Submodules +include(joinpath(@__DIR__, "not_provided.jl")) +include(joinpath(@__DIR__, "option_value.jl")) +include(joinpath(@__DIR__, "option_definition.jl")) +include(joinpath(@__DIR__, "extraction.jl")) + +# Public API + +export NotProvided, NotProvidedType +export OptionValue, OptionDefinition, extract_option, extract_options, extract_raw_options +export all_names, aliases +export is_user, is_default, is_computed # is_computed works for both OptionValue and OptionDefinition +export is_required, has_default, has_validator +export name, type, default, description, validator, value, source + +end # module Options diff --git a/src/Options/extraction.jl b/src/Options/extraction.jl new file mode 100644 index 00000000..a68a297b --- /dev/null +++ b/src/Options/extraction.jl @@ -0,0 +1,244 @@ +# Option extraction and alias management + +""" +$(TYPEDSIGNATURES) + +Extract a single option from a NamedTuple using its definition, with support for aliases. + +This function searches through all valid names (primary name + aliases) in the definition +to find the option value in the provided kwargs. If found, it validates the value, +checks the type, and returns an `OptionValue` with `:user` source. If not found, +returns the default value with `:default` source. + +# Arguments +- `kwargs::NamedTuple`: NamedTuple containing potential option values. +- `def::OptionDefinition`: Definition defining the option to extract. + +# Returns +- `(OptionValue, NamedTuple)`: Tuple containing the extracted option value and the remaining kwargs. + +# Notes +- If a validator is provided in the definition, it will be called on the extracted value. +- Validators should follow the pattern `x -> condition || throw(ArgumentError("message"))`. +- If validation fails, the original exception is rethrown after logging context with `@error`. +- Type mismatches throw `Exceptions.IncorrectArgument` exceptions. +- The function removes the found option from the returned kwargs. + +# Throws +- `CTBase.Exceptions.IncorrectArgument`: If type mismatch between value and definition +- `Exception`: If validator function fails + +# Example +```julia +def = OptionDefinition( + name = :grid_size, + type = Int, + default = 100, + description = "Grid size", + aliases = (:n, :size) +) + +kwargs = (n=200, tol=1e-6, max_iter=1000) +opt, remaining = extract_option(kwargs, def) +opt.value # 200 +opt.source # :user +remaining.tol # 1e-6 +``` +""" +function extract_option(kwargs::NamedTuple, def::OptionDefinition) + # Try all names (primary + aliases) + for name in all_names(def) + if haskey(kwargs, name) + value = kwargs[name] + + # Type check FIRST - strict validation with exceptions + if !isa(value, def.type) + throw( + Exceptions.IncorrectArgument( + "Invalid option type"; + got="value $value of type $(typeof(value))", + expected="$(def.type)", + suggestion="Ensure the option value matches the expected type", + context="Option extraction for $(def.name)", + ), + ) + end + + # Validate if validator provided (after type check) + if def.validator !== nothing + def.validator(value) + end + + # Remove from kwargs + remaining = NamedTuple(k => v for (k, v) in pairs(kwargs) if k != name) + + return OptionValue(value, :user), remaining + end + end + + # Not found - check if default is NotProvided + if def.default isa NotProvidedType + # No default and not provided by user - return NotStored to signal "don't store" + return NotStored, kwargs + end + + # Not found, return default with appropriate source + # Use :computed source if default is computed from parameters, otherwise :default + source = def.computed ? :computed : :default + return OptionValue(def.default, source), kwargs +end + +""" +$(TYPEDSIGNATURES) + +Extract multiple options from a NamedTuple using a vector of definitions. + +This function iteratively applies `extract_option` for each definition in the vector, +building a dictionary of extracted options while progressively removing processed +options from the kwargs. + +# Arguments +- `kwargs::NamedTuple`: NamedTuple containing potential option values. +- `defs::Vector{OptionDefinition}`: Vector of definitions defining options to extract. + +# Returns +- `(Dict{Symbol, OptionValue}, NamedTuple)`: Dictionary mapping option names to their values, and remaining kwargs. + +# Notes +- The extraction order follows the order of definitions in the vector. +- Each definition's primary name is used as the dictionary key. +- Options not found in kwargs use their definition default values. +- Validation is performed for each option using `extract_option`. + +# Throws +- Any exception raised by validators in the definitions + +See also: `extract_option`, `OptionDefinition`, `OptionValue` + +# Example +```julia +defs = [ + OptionDefinition(name = :grid_size, type = Int, default = 100, description = "Grid size"), + OptionDefinition(name = :tol, type = Float64, default = 1e-6, description = "Tolerance") +] + +kwargs = (grid_size=200, max_iter=1000) +options, remaining = extract_options(kwargs, defs) +options[:grid_size].value # 200 +options[:tol].value # 1e-6 +remaining.max_iter # 1000 +``` +""" +function extract_options(kwargs::NamedTuple, defs::Vector{<:OptionDefinition}) + extracted = Dict{Symbol,OptionValue}() + remaining = kwargs + + for def in defs + opt_value, remaining = extract_option(remaining, def) + # Only store if not NotStored (NotProvided options that weren't provided return NotStored) + if !(opt_value isa NotStoredType) + extracted[def.name] = opt_value + end + end + + return extracted, remaining +end + +""" +$(TYPEDSIGNATURES) + +Extract multiple options from a NamedTuple using a NamedTuple of definitions. + +This function is similar to the Vector version but returns a NamedTuple instead +of a Dict for convenience when the definition structure is known at compile time. + +# Arguments +- `kwargs::NamedTuple`: NamedTuple containing potential option values. +- `defs::NamedTuple`: NamedTuple of definitions defining options to extract. + +# Returns +- `(NamedTuple, NamedTuple)`: NamedTuple of extracted options and remaining kwargs. + +# Notes +- The extraction order follows the order of definitions in the NamedTuple. +- Each definition's primary name is used as the key in the returned NamedTuple. +- Options not found in kwargs use their definition default values. +- Validation is performed for each option using `extract_option`. + +# Throws +- Any exception raised by validators in the definitions + +See also: `extract_option`, `OptionDefinition`, `OptionValue` + +# Example +```julia +defs = ( + grid_size = OptionDefinition(name = :grid_size, type = Int, default = 100, description = "Grid size"), + tol = OptionDefinition(name = :tol, type = Float64, default = 1e-6, description = "Tolerance") +) + +kwargs = (grid_size=200, max_iter=1000) +options, remaining = extract_options(kwargs, defs) +options.grid_size.value # 200 +options.tol.value # 1e-6 +remaining.max_iter # 1000 +``` +""" +function extract_options(kwargs::NamedTuple, defs::NamedTuple) + extracted_pairs = Pair{Symbol,OptionValue}[] + remaining = kwargs + + for (key, def) in pairs(defs) + opt_value, remaining = extract_option(remaining, def) + # Only store if not NotStored (NotProvided options that weren't provided return NotStored) + if !(opt_value isa NotStoredType) + push!(extracted_pairs, key => opt_value) + end + end + + extracted = NamedTuple(extracted_pairs) + return extracted, remaining +end + +""" +$(TYPEDSIGNATURES) + +Extract raw option values from a NamedTuple of options, unwrapping OptionValue wrappers +and filtering out `NotProvided` values. + +This utility function is useful when passing options to external builders or functions +that expect plain keyword arguments without OptionValue wrappers or undefined options. + +Options with `NotProvided` values are excluded from the result, allowing external +builders to use their own defaults. Options with explicit `nothing` values are included. + +# Arguments +- `options::NamedTuple`: NamedTuple containing option values (may be wrapped in OptionValue) + +# Returns +- `NamedTuple`: NamedTuple with unwrapped values, excluding any `NotProvided` values + +# Example +```julia +opts = (backend = OptionValue(:optimized, :user), + show_time = OptionValue(false, :default), + minimize = OptionValue(nothing, :default), + optional = OptionValue(NotProvided, :default)) + +extract_raw_options(opts) +# (backend = :optimized, show_time = false, minimize = nothing) +``` + +See also: `OptionValue`, `extract_options`, `NotProvided` +""" +function extract_raw_options(options::NamedTuple) + raw_opts_dict = Dict{Symbol,Any}() + for (k, v) in pairs(options) + val = v isa OptionValue ? v.value : v + # Filter out NotProvided values, but keep nothing values + if !(val isa NotProvidedType) + raw_opts_dict[k] = val + end + end + return NamedTuple(raw_opts_dict) +end diff --git a/src/Options/not_provided.jl b/src/Options/not_provided.jl new file mode 100644 index 00000000..e8ef882b --- /dev/null +++ b/src/Options/not_provided.jl @@ -0,0 +1,98 @@ +# NotProvided sentinel type +# +# Singleton type representing the absence of a default value for an option. + +""" +$(TYPEDEF) + +Singleton type representing the absence of a default value for an option. + +This type is used to distinguish between: +- `default = NotProvided`: No default value, option must be provided by user or not stored +- `default = nothing`: The default value is explicitly `nothing` + +# Example +```julia +# Option with no default - won't be stored if not provided +opt1 = OptionDefinition( + name = :minimize, + type = Union{Bool, Nothing}, + default = NotProvided, + description = "Whether to minimize" +) + +# Option with explicit nothing default - will be stored as nothing +opt2 = OptionDefinition( + name = :backend, + type = Union{Nothing, KernelAbstractions.Backend}, + default = nothing, + description = "Execution backend" +) +``` + +See also: `OptionDefinition`, `extract_options` +""" +struct NotProvidedType end + +""" + NotProvided + +Singleton instance of `NotProvidedType`. + +Use this as the default value in `OptionDefinition` to indicate +that an option has no default value and should not be stored if not provided +by the user. + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition( + name = :optional_param, + type = Any, + default = NotProvided, + description = "Optional parameter" + ) + +julia> # If user doesn't provide it, it won't be stored +julia> opts, _ = extract_options((other=1,), [def]) +julia> haskey(opts, :optional_param) +false +``` +""" +const NotProvided = NotProvidedType() + +# Pretty printing +Base.show(io::IO, ::NotProvidedType) = print(io, "NotProvided") + +""" +$(TYPEDEF) + +Internal sentinel type used by the option extraction system to signal that an option +should not be stored in the instance. + +This is returned by `extract_option` when an option has `NotProvided` as its +default and was not provided by the user. + +# Note +This type is internal to the Options module and should not be used directly by users. +Use `NotProvided` instead. + +See also: `NotProvided`, `extract_option` +""" +struct NotStoredType end + +""" + NotStored + +Internal singleton instance of `NotStoredType`. + +Used internally by the option extraction system to signal that an option should not +be stored. This is distinct from `nothing` which is a valid option value. + +See also: `NotProvided`, `extract_option` +""" +const NotStored = NotStoredType() + +# Pretty printing +Base.show(io::IO, ::NotStoredType) = print(io, "NotStored") diff --git a/src/Options/option_definition.jl b/src/Options/option_definition.jl new file mode 100644 index 00000000..a6796c20 --- /dev/null +++ b/src/Options/option_definition.jl @@ -0,0 +1,756 @@ +# Unified option definition and schema + +""" +$(TYPEDEF) + +Unified option definition for both option extraction and strategy contracts. + +This type provides a comprehensive option definition that can be used for: +- Option extraction in the Options module +- Strategy contract definition in the Strategies module +- Action schema definition + +# Fields +- `name::Symbol`: Primary name of the option +- `type::Type`: Expected Julia type for the option value +- `default::T`: Default value when the option is not provided (type parameter `T`) +- `description::String`: Human-readable description of the option's purpose +- `aliases::Tuple{Vararg{Symbol}}`: Alternative names for this option (default: empty tuple) +- `validator::Union{Function, Nothing}`: Optional validation function (default: `nothing`) +- `computed::Bool`: Whether the default value is computed from parameters (default: `false`) + +# Type Parameter `T` + +The type parameter `T` represents the type of the default value: +- `T = Any` when `default = nothing` (explicit nothing default) +- `T = NotProvidedType` when `default = NotProvided` (no default value) +- `T = typeof(default)` for concrete default values + +# Validator Contract + +Validators must follow this pattern: +```julia +x -> condition || throw(ArgumentError("error message")) +``` + +The validator should: +- Return `true` (or any truthy value) if the value is valid +- Throw an exception (preferably `ArgumentError`) if the value is invalid +- Be a pure function without side effects + +# Constructor Validation + +The constructor performs the following validations: +1. Checks that `default` matches the specified `type` (unless `default` is `nothing` or `NotProvided`) +2. Runs the `validator` on the `default` value (if both are provided and `default` is not `NotProvided`) + +# Example +```julia +def = OptionDefinition( + name = :max_iter, + type = Int, + default = 100, + description = "Maximum number of iterations", + aliases = (:max, :maxiter), + validator = x -> x > 0 || throw(ArgumentError("\$x must be positive")) +) + +def.name # :max_iter +def.aliases # (:max, :maxiter) +all_names(def) # (:max_iter, :max, :maxiter) +``` + +# Throws +- `CTBase.Exceptions.IncorrectArgument`: If the default value does not match the declared type +- `Exception`: If the validator function fails when applied to the default value + +See also: `all_names`, `extract_option`, `extract_options`, `NotProvided` +""" +struct OptionDefinition{T} + name::Symbol + type::Type # Not parameterized to allow NotProvided with any declared type + default::T + description::String + aliases::Tuple{Vararg{Symbol}} + validator::Union{Function,Nothing} + computed::Bool + + function OptionDefinition{T}(; + name::Symbol, + type::Type, + default::T, + description::String, + aliases::Tuple{Vararg{Symbol}}=(), + validator::Union{Function,Nothing}=nothing, + computed::Bool=false, + ) where {T} + # Validate with custom validator if provided (skip for NotProvided) + if validator !== nothing && !(default isa NotProvidedType) + try + validator(default) + catch e + @error "Validation failed for option $name with default value $default" exception=( + e, catch_backtrace() + ) + rethrow() + end + end + + new{T}(name, type, default, description, aliases, validator, computed) + end +end + +""" +$(TYPEDSIGNATURES) + +Convenience constructor that infers the type parameter `T` from the default value. + +This constructor automatically determines the appropriate type parameter and +delegates to specialized methods based on the type of `default`: +- `nothing` → creates `OptionDefinition{Any}` with `type = Any` +- `NotProvided` → creates `OptionDefinition{NotProvidedType}` with preserved `type` +- concrete values → creates `OptionDefinition{T}` where `T = typeof(default)` + +# Arguments +- `name::Symbol`: Primary name of the option +- `type::Type`: Expected Julia type for the option value +- `default`: Default value (type parameter `T` is inferred automatically) +- `description::String`: Human-readable description of the option's purpose +- `aliases::Tuple{Vararg{Symbol}}`: Alternative names (default: empty tuple) +- `validator::Union{Function, Nothing}`: Optional validation function (default: `nothing`) +- `computed::Bool`: Whether the default is computed from parameters (default: `false`) + +# Returns +- `OptionDefinition{T}`: Option definition with inferred type parameter + +# Throws +- `CTBase.Exceptions.IncorrectArgument`: If concrete `default` is not compatible with `type` + +# Example +```julia-repl +julia> using CTBase.Options + +julia> # Concrete default - infers Int +julia> def1 = OptionDefinition( + name = :max_iter, + type = Int, + default = 100, + description = "Maximum iterations" + ) +OptionDefinition{Int}(...) + +julia> # Nothing default - creates Any +julia> def2 = OptionDefinition( + name = :backend, + type = Union{Nothing, String}, + default = nothing, + description = "Execution backend" + ) +OptionDefinition{Any}(...) + +julia> # No default - creates NotProvidedType +julia> def3 = OptionDefinition( + name = :input_file, + type = String, + default = NotProvided, + description = "Input file path" + ) +OptionDefinition{NotProvidedType}(...) +``` + +# Notes +- For `nothing` defaults, the `type` parameter is ignored and set to `Any` +- For `NotProvided` defaults, the declared `type` is preserved for validation +- For concrete defaults, type compatibility between `default` and `type` is enforced +- The validator function is applied to the default value (except for `NotProvided`) + +See also: `OptionDefinition{T}`, `_construct_option_definition`, `NotProvided` +""" +function OptionDefinition(; + name::Symbol, + type::Type, + default, + description::String, + aliases::Tuple{Vararg{Symbol}}=(), + validator::Union{Function,Nothing}=nothing, + computed::Bool=false, +) + return _construct_option_definition( + name, type, default, description, aliases, validator, computed + ) +end + +# Dispatch methods for different default types + +""" +$(TYPEDSIGNATURES) + +Construct an `OptionDefinition` with a `nothing` default value. + +This method handles the special case where `default = nothing`, creating an +`OptionDefinition{Any}` with `type = Any` since `nothing` can represent any type. + +# Arguments +- `name::Symbol`: Primary name of the option +- `type::Type`: Expected Julia type (ignored for `nothing` defaults) +- `default::Nothing`: Must be `nothing` +- `description::String`: Human-readable description +- `aliases::Tuple{Vararg{Symbol}}`: Alternative names (default: empty tuple) +- `validator::Union{Function, Nothing}`: Optional validation function (default: `nothing`) +- `computed::Bool`: Whether the default is computed from parameters (default: `false`) + +# Returns +- `OptionDefinition{Any}`: Option definition with `nothing` default + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = _construct_option_definition( + :backend, + Union{Nothing, String}, + nothing, + "Execution backend", + (:be,) + ) +OptionDefinition{Any}(...) + +julia> default(def) +nothing +``` + +See also: `OptionDefinition`, `NotProvided` +""" +function _construct_option_definition( + name::Symbol, + type::Type, + default::Nothing, + description::String, + aliases::Tuple{Vararg{Symbol}}, + validator::Union{Function,Nothing}, + computed::Bool, +) + return OptionDefinition{Any}(; + name=name, + type=Any, + default=nothing, + description=description, + aliases=aliases, + validator=validator, + computed=computed, + ) +end + +""" +$(TYPEDSIGNATURES) + +Construct an `OptionDefinition` with a `NotProvided` default value. + +This method handles the special case where `default = NotProvided`, creating an +`OptionDefinition{NotProvidedType}`. The declared `type` is preserved since +`NotProvided` indicates the absence of a default value while maintaining type +information for validation. + +# Arguments +- `name::Symbol`: Primary name of the option +- `type::Type`: Expected Julia type for user-provided values +- `default::NotProvidedType`: Must be `NotProvided` +- `description::String`: Human-readable description +- `aliases::Tuple{Vararg{Symbol}}`: Alternative names (default: empty tuple) +- `validator::Union{Function, Nothing}`: Optional validation function (default: `nothing`) +- `computed::Bool`: Whether the default is computed from parameters (default: `false`) + +# Returns +- `OptionDefinition{NotProvidedType}`: Option definition with no default value + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = _construct_option_definition( + :input_file, + String, + NotProvided, + "Input file path", + (:input,) + ) +OptionDefinition{NotProvidedType}(...) + +julia> is_required(def) +true +``` + +See also: `OptionDefinition`, `NotProvided`, `is_required` +""" +function _construct_option_definition( + name::Symbol, + type::Type, + default::NotProvidedType, + description::String, + aliases::Tuple{Vararg{Symbol}}, + validator::Union{Function,Nothing}, + computed::Bool, +) + return OptionDefinition{NotProvidedType}(; + name=name, + type=type, + default=default, + description=description, + aliases=aliases, + validator=validator, + computed=computed, + ) +end + +""" +$(TYPEDSIGNATURES) + +Construct an `OptionDefinition` with a concrete default value. + +This method handles the general case where `default` is a concrete value. +It infers the type parameter `T` from the default value and validates that +the default value is compatible with the declared `type`. + +# Arguments +- `name::Symbol`: Primary name of the option +- `type::Type`: Expected Julia type for the option value +- `default::T`: Default value (type `T` is inferred) +- `description::String`: Human-readable description +- `aliases::Tuple{Vararg{Symbol}}`: Alternative names (default: empty tuple) +- `validator::Union{Function, Nothing}`: Optional validation function (default: `nothing`) +- `computed::Bool`: Whether the default is computed from parameters (default: `false`) + +# Returns +- `OptionDefinition{T}`: Option definition with concrete default value + +# Throws +- `CTBase.Exceptions.IncorrectArgument`: If `default` is not compatible with `type` + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = _construct_option_definition( + :max_iter, + Int, + 100, + "Maximum number of iterations", + (:max,) + ) +OptionDefinition{Int}(...) + +julia> default(def) +100 +``` + +See also: `OptionDefinition`, `Exceptions.IncorrectArgument` +""" +function _construct_option_definition( + name::Symbol, + type::Type, + default::T, + description::String, + aliases::Tuple{Vararg{Symbol}}, + validator::Union{Function,Nothing}, + computed::Bool, +) where {T} + # Check type compatibility + if !isa(default, type) + throw( + Exceptions.IncorrectArgument( + "Type mismatch in option definition"; + got="default value $default of type $T", + expected="value of type $type", + suggestion="Ensure the default value matches the declared type, or adjust the type parameter", + context="OptionDefinition constructor - validating type compatibility", + ), + ) + end + + # Create with inferred type + return OptionDefinition{T}(; + name=name, + type=type, + default=default, + description=description, + aliases=aliases, + validator=validator, + computed=computed, + ) +end + +# OptionDefinition getters and introspection + +""" +$(TYPEDSIGNATURES) + +Get the primary name of this option definition. + +# Returns +- `Symbol`: The option name + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition(name=:max_iter, type=Int, default=100, + description="Maximum iterations") +OptionDefinition{Int}(...) + +julia> name(def) +:max_iter +``` + +See also: `type`, `default`, `aliases` +""" +name(def::OptionDefinition) = def.name + +""" +$(TYPEDSIGNATURES) + +Get the expected type for this option definition. + +# Returns +- `Type`: The expected type + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition(name=:max_iter, type=Int, default=100, + description="Maximum iterations") +OptionDefinition{Int}(...) + +julia> type(def) +Int +``` + +See also: `name`, `default` +""" +type(def::OptionDefinition) = def.type + +""" +$(TYPEDSIGNATURES) + +Get the default value for this option definition. + +# Returns +- The default value + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition(name=:max_iter, type=Int, default=100, + description="Maximum iterations") +OptionDefinition{Int}(...) + +julia> default(def) +100 +``` + +See also: `name`, `type`, `is_required` +""" +default(def::OptionDefinition) = def.default + +""" +$(TYPEDSIGNATURES) + +Get the description for this option definition. + +# Returns +- `String`: The option description + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition(name=:max_iter, type=Int, default=100, + description="Maximum iterations") +OptionDefinition{Int}(...) + +julia> description(def) +"Maximum iterations" +``` + +See also: `name`, `type` +""" +description(def::OptionDefinition) = def.description + +""" +$(TYPEDSIGNATURES) + +Get the validator function for this option definition. + +# Returns +- `Union{Function, Nothing}`: The validator function or `nothing` + +# Example +```julia-repl +julia> using CTBase.Options + +julia> validator_fn = x -> x > 0 + +julia> def = OptionDefinition(name=:max_iter, type=Int, default=100, + description="Maximum iterations", + validator=validator_fn) +OptionDefinition{Int}(...) + +julia> validator(def) === validator_fn +true +``` + +See also: `has_validator`, `name` +""" +validator(def::OptionDefinition) = def.validator + +""" +$(TYPEDSIGNATURES) + +Get the aliases for this option definition. + +# Returns +- `Tuple{Vararg{Symbol}}`: Tuple of alias names + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition(name=:max_iter, type=Int, default=100, + description="Maximum iterations", + aliases=(:max, :maxiter)) +OptionDefinition{Int}(...) + +julia> aliases(def) +(:max, :maxiter) +``` + +See also: `all_names`, `name` +""" +aliases(def::OptionDefinition) = def.aliases + +""" +$(TYPEDSIGNATURES) + +Check if this option is required (has no default value). + +Returns `true` when the default value is `NotProvided`. + +# Returns +- `Bool`: `true` if the option is required + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition(name=:input, type=String, default=NotProvided, + description="Input file") +OptionDefinition{NotProvidedType}(...) + +julia> is_required(def) +true +``` + +See also: `has_default`, `default` +""" +is_required(def::OptionDefinition) = def.default isa NotProvidedType + +""" +$(TYPEDSIGNATURES) + +Check if this option definition has a default value. + +Returns `false` when the default value is `NotProvided`. + +# Returns +- `Bool`: `true` if a default value is defined + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition(name=:max_iter, type=Int, default=100, + description="Maximum iterations") +OptionDefinition{Int}(...) + +julia> has_default(def) +true +``` + +See also: `is_required`, `default` +""" +has_default(def::OptionDefinition) = !(def.default isa NotProvidedType) + +""" +$(TYPEDSIGNATURES) + +Check if this option definition has a validator function. + +# Returns +- `Bool`: `true` if a validator is defined + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition(name=:max_iter, type=Int, default=100, + description="Maximum iterations", + validator=x -> x > 0) +OptionDefinition{Int}(...) + +julia> has_validator(def) +true +``` + +See also: `validator`, `name` +""" +has_validator(def::OptionDefinition) = def.validator !== nothing + +""" +$(TYPEDSIGNATURES) + +Check if this option definition has a computed default value. + +Returns `true` when the default value is computed from strategy parameters +(e.g., `backend` in `Exa{GPU}` which depends on the `GPU` parameter). + +# Returns +- `Bool`: `true` if the default is computed from parameters + +# Example +```julia-repl +julia> using CTBase.Options + +julia> # Static default +julia> def1 = OptionDefinition(name=:max_iter, type=Int, default=100, + description="Maximum iterations") +OptionDefinition{Int}(...) + +julia> is_computed(def1) +false + +julia> # Computed default +julia> def2 = OptionDefinition(name=:backend, type=Any, default=compute_backend(), + description="Backend", computed=true) +OptionDefinition{...}(...) + +julia> is_computed(def2) +true +``` + +See also: `has_default`, `is_required`, `OptionDefinition` +""" +is_computed(def::OptionDefinition) = def.computed + +# Get all names (primary + aliases) for extraction +""" +$(TYPEDSIGNATURES) + +Return all valid names for an option definition (primary name plus aliases). + +This function is used by the extraction system to search for an option in kwargs +using all possible names (primary name and all aliases). + +# Arguments +- `def::OptionDefinition`: The option definition + +# Returns +- `Tuple{Vararg{Symbol}}`: Tuple containing the primary name followed by all aliases + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition( + name = :grid_size, + type = Int, + default = 100, + description = "Grid size", + aliases = (:n, :size) + ) +OptionDefinition{Int}(...) + +julia> all_names(def) +(:grid_size, :n, :size) +``` + +See also: `OptionDefinition`, `extract_option` +""" +all_names(def::OptionDefinition) = (def.name, def.aliases...) + +# Display +""" +$(TYPEDSIGNATURES) + +Display an OptionDefinition in a readable format. + +Shows the option name, type, default value, and description. If aliases are present, +they are shown in parentheses after the primary name. + +# Arguments +- `io::IO`: Output stream +- `def::OptionDefinition`: The option definition to display + +# Example +```julia-repl +julia> using CTBase.Options + +julia> def = OptionDefinition( + name = :max_iter, + type = Int, + default = 100, + description = "Maximum iterations", + aliases = (:max, :maxiter) + ) +OptionDefinition{Int}(...) + +julia> println(def) +max_iter (max, maxiter) :: Int64 + default: 100 + Maximum iterations +``` + +See also: `OptionDefinition` +""" +function Base.show(io::IO, def::OptionDefinition) + fmt = Core.get_format_codes(io) + + # Show primary name with aliases if present + if isempty(def.aliases) + print(io, fmt.name, def.name, fmt.reset, "::", fmt.type, def.type, fmt.reset) + else + print( + io, + fmt.name, + def.name, + fmt.reset, + " (", + fmt.keyword, + join(def.aliases, ", "), + fmt.reset, + ")::", + fmt.type, + def.type, + fmt.reset, + ) + end + + # Show default with source indicator + if def.computed + print( + io, + " (", + fmt.value, + "default: ", + def.default, + fmt.reset, + " [", + fmt.keyword, + "computed", + fmt.reset, + "])", + ) + else + print(io, " (", fmt.value, "default: ", def.default, fmt.reset, ")") + end +end diff --git a/src/Options/option_value.jl b/src/Options/option_value.jl new file mode 100644 index 00000000..48405bbe --- /dev/null +++ b/src/Options/option_value.jl @@ -0,0 +1,196 @@ +# Option value representation with provenance + +""" +$(TYPEDEF) + +Represents an option value with its source provenance. + +# Fields +- `value::T`: The actual option value. +- `source::Symbol`: Where the value came from (`:default`, `:user`, `:computed`). + +# Constructor Validation + +The constructor validates the source symbol using Val dispatch for compile-time +type safety and performance optimization. Invalid sources throw `Exceptions.IncorrectArgument`. + +# Notes +The `source` field tracks the provenance of the option value: +- `:default`: Value comes from the tool's default configuration +- `:user`: Value was explicitly provided by the user +- `:computed`: Value was computed/derived from other options + +# Example +```julia-repl +julia> using CTBase.Options + +julia> opt = OptionValue(100, :user) +OptionValue{Int64}(100, :user) + +julia> opt.value +100 + +julia> opt.source +:user +``` + +# Throws +- `Exceptions.IncorrectArgument`: If source is not one of `:default`, `:user`, or `:computed` + +See also: `value`, `source`, `is_user` +""" +struct OptionValue{T} + value::T + source::Symbol + + function OptionValue(value::T, source::Symbol) where {T} + _construct_option_value(value, Val(source)) + end + + # Internal constructor dispatch functions + function _construct_option_value(value::T, ::Val{:default}) where {T} + return new{T}(value, :default) + end + + function _construct_option_value(value::T, ::Val{:user}) where {T} + return new{T}(value, :user) + end + + function _construct_option_value(value::T, ::Val{:computed}) where {T} + return new{T}(value, :computed) + end + + function _construct_option_value(value, source::Val) + throw( + Exceptions.IncorrectArgument( + "Invalid option source"; + got="source=$(typeof(source).parameters[1])", + expected=":default, :user, or :computed", + suggestion="Use one of the valid source symbols: :default (tool default), :user (user-provided), or :computed (derived)", + context="OptionValue constructor - validating source provenance", + ), + ) + end +end + +""" +$(TYPEDSIGNATURES) + +Create an `OptionValue` defaulting to `:user` source. + +# Arguments +- `value`: The option value. + +# Returns +- `OptionValue{T}`: Option value with `:user` source. + +# Example +```julia +OptionValue(42) # Equivalent to OptionValue(42, :user) +``` +""" +OptionValue(value) = OptionValue(value, :user) + +# OptionValue getters and introspection + +""" +$(TYPEDSIGNATURES) + +Get the value from this option value wrapper. + +# Returns +- The stored option value + +# Example +```julia +opt = OptionValue(100, :user) +value(opt) # 100 +``` + +See also: `source`, `is_user` +""" +value(opt::OptionValue) = opt.value + +""" +$(TYPEDSIGNATURES) + +Get the source provenance of this option value. + +# Returns +- `Symbol`: `:default`, `:user`, or `:computed` + +# Example +```julia +opt = OptionValue(100, :user) +source(opt) # :user +``` + +See also: `value`, `is_user` +""" +source(opt::OptionValue) = opt.source + +""" +$(TYPEDSIGNATURES) + +Check if this option value was explicitly provided by the user. + +# Returns +- `Bool`: `true` if the source is `:user` + +# Example +```julia +opt = OptionValue(100, :user) +is_user(opt) # true +``` + +See also: `is_default`, `is_computed`, `source` +""" +is_user(opt::OptionValue) = opt.source === :user + +""" +$(TYPEDSIGNATURES) + +Check if this option value is using its default. + +# Returns +- `Bool`: `true` if the source is `:default` + +# Example +```julia +opt = OptionValue(100, :default) +is_default(opt) # true +``` + +See also: `is_user`, `is_computed`, `source` +""" +is_default(opt::OptionValue) = opt.source === :default + +""" +$(TYPEDSIGNATURES) + +Check if this option value was computed from other options. + +# Returns +- `Bool`: `true` if the source is `:computed` + +# Example +```julia +opt = OptionValue(100, :computed) +is_computed(opt) # true +``` + +See also: `is_user`, `is_default`, `source` +""" +is_computed(opt::OptionValue) = opt.source === :computed + +""" +$(TYPEDSIGNATURES) + +Display the option value in the format "value (source)". + +# Example +```julia +println(OptionValue(3.14, :default)) # "3.14 (default)" +``` +""" +Base.show(io::IO, opt::OptionValue) = print(io, "$(opt.value) ($(opt.source))") diff --git a/src/Orchestration/Orchestration.jl b/src/Orchestration/Orchestration.jl new file mode 100644 index 00000000..de582043 --- /dev/null +++ b/src/Orchestration/Orchestration.jl @@ -0,0 +1,38 @@ +""" +High-level orchestration utilities. + +This module provides the glue between **actions** (problem-level options) and +**strategies** (algorithmic components) by handling option routing, +disambiguation, and helper builders. + +# Public API +- `route_all_options`: Strategy-aware option router with disambiguation support +- `extract_strategy_ids`, `build_strategy_to_family_map`, `build_option_ownership_map`: Helpers used by the router +- `build_strategy_from_resolved`, `option_names_from_resolved`: Builders based on resolved method information +- `ResolvedMethod`: Struct containing resolved method information from disambiguation +- `resolve_method`: Function that resolves method tokens to strategy families and IDs + +See also: `Options`, `Strategies` +""" +module Orchestration + +# Imports +import DocStringExtensions: TYPEDEF, TYPEDSIGNATURES +import CTBase.Exceptions + +# CTBase modules +using ..Options +using ..Strategies + +# Submodules +include(joinpath(@__DIR__, "disambiguation.jl")) +include(joinpath(@__DIR__, "builders.jl")) +include(joinpath(@__DIR__, "routing.jl")) + +# Public API +export route_all_options +export extract_strategy_ids, build_strategy_to_family_map, build_option_ownership_map +export build_strategy_from_resolved, option_names_from_resolved +export ResolvedMethod, resolve_method + +end # module Orchestration diff --git a/src/Orchestration/builders.jl b/src/Orchestration/builders.jl new file mode 100644 index 00000000..e031be53 --- /dev/null +++ b/src/Orchestration/builders.jl @@ -0,0 +1,130 @@ +# Strategy builders based on ResolvedMethod + +""" +$(TYPEDSIGNATURES) + +Return the option names for the active strategy of a given family in a resolved method. + +This function is the resolved-method counterpart of strategy option introspection. +It avoids re-parsing the method tuple by using `resolved.ids_by_family` and the +global `resolved.parameter`. + +# Arguments +- `resolved::ResolvedMethod`: Output of `resolve_method` +- `family_name::Symbol`: Family key in `families` (e.g. `:solver`, `:modeler`) +- `families::NamedTuple`: Mapping `family_name => family_type` used for resolution +- `registry::Strategies.StrategyRegistry`: Strategy registry + +# Returns +- `Tuple{Vararg{Symbol}}`: Option names for the active strategy + +# Throws +- `CTBase.Exceptions.IncorrectArgument`: If the active strategy is parameterized but + `resolved.parameter` is `nothing` + +# Example +```julia +resolved = resolve_method(method, families, registry) +names = option_names_from_resolved(resolved, :solver, families, registry) +``` + +See also: `resolve_method`, `build_strategy_from_resolved`, `Strategies.option_names` +""" +function option_names_from_resolved( + resolved::ResolvedMethod, + family_name::Symbol, + families::NamedTuple, + registry::Strategies.StrategyRegistry, +) + family_type = getfield(families, family_name) + s_id = getfield(resolved.ids_by_family, family_name) + param = resolved.parameter + + available = Strategies.available_parameters(s_id, family_type, registry) + strategy_type = if isempty(available) + Strategies.type_from_id(s_id, family_type, registry) + else + p = if param === nothing + throw( + Exceptions.IncorrectArgument( + "Missing parameter in resolved method"; + got="strategy :$s_id in resolved method with parameter=nothing", + expected="a parameter type for parameterized strategies", + suggestion="Ensure resolve_method validated a parameter token for the method", + context="option_names_from_resolved - parameter required", + ), + ) + else + (param::Type{<:Strategies.AbstractStrategyParameter}) + end + Strategies.type_from_id(s_id, family_type, registry; parameter=p) + end + + return Strategies.option_names(strategy_type) +end + +""" +$(TYPEDSIGNATURES) + +Build the active strategy instance of a given family from a resolved method. + +This function is the resolved-method counterpart of strategy construction. It is +intended to be used after option routing, where the method tuple has already been +validated and resolved by `resolve_method`. + +# Arguments +- `resolved::ResolvedMethod`: Output of `resolve_method` +- `family_name::Symbol`: Family key in `families` (e.g. `:solver`, `:modeler`) +- `families::NamedTuple`: Mapping `family_name => family_type` used for resolution +- `registry::Strategies.StrategyRegistry`: Strategy registry +- `mode::Symbol=:strict`: Validation mode for option extraction (`:strict` or `:permissive`) +- `kwargs...`: Options to pass to the strategy constructor + +# Returns +- Concrete strategy instance for the selected ID (parameterized if required) + +# Throws +- `CTBase.Exceptions.IncorrectArgument`: If the active strategy is parameterized but + `resolved.parameter` is `nothing` + +# Example +```julia +resolved = resolve_method(method, families, registry) +solver = build_strategy_from_resolved(resolved, :solver, families, registry; mode=:strict, kwargs...) +``` + +See also: `resolve_method`, `Strategies.build_strategy`, `option_names_from_resolved` +""" +function build_strategy_from_resolved( + resolved::ResolvedMethod, + family_name::Symbol, + families::NamedTuple, + registry::Strategies.StrategyRegistry; + mode::Symbol=:strict, + kwargs..., +) + family_type = getfield(families, family_name) + s_id = getfield(resolved.ids_by_family, family_name) + param = resolved.parameter + + available = Strategies.available_parameters(s_id, family_type, registry) + if isempty(available) + return Strategies.build_strategy(s_id, family_type, registry; mode=mode, kwargs...) + end + + p = if param === nothing + throw( + Exceptions.IncorrectArgument( + "Missing parameter in resolved method"; + got="strategy :$s_id in resolved method with parameter=nothing", + expected="a parameter type for parameterized strategies", + suggestion="Ensure resolve_method validated a parameter token for the method", + context="build_strategy_from_resolved - parameter required", + ), + ) + else + (param::Type{<:Strategies.AbstractStrategyParameter}) + end + + return Strategies.build_strategy(s_id, p, family_type, registry; mode=mode, kwargs...) +end diff --git a/src/Orchestration/disambiguation.jl b/src/Orchestration/disambiguation.jl new file mode 100644 index 00000000..1e855e4e --- /dev/null +++ b/src/Orchestration/disambiguation.jl @@ -0,0 +1,300 @@ +# Disambiguation helpers for strategy-based option routing + +""" +$(TYPEDEF) + +Resolved representation of a method tuple for strategy-aware option routing. + +`ResolvedMethod` contains precomputed lookups derived from a `method` tuple and a +set of `families`. It is intended to be created via `resolve_method` and +then reused by routing and builder utilities. + +# Fields +- `tokens::T`: The original method tokens. +- `ids_by_family::I`: Family-wise strategy IDs extracted from `tokens`. +- `strategy_to_family::Dict{Symbol, Symbol}`: Reverse map `strategy_id => family_name`. +- `strategy_ids::Tuple{Vararg{Symbol}}`: Tuple of active strategy IDs. +- `parameter::Union{Nothing, Type{<:Strategies.AbstractStrategyParameter}}`: Optional global + parameter extracted from the method tuple. + +# Notes +- This type is internal to `CTBase.Orchestration`. + +See also: `resolve_method`, `route_all_options` +""" +struct ResolvedMethod{T<:Tuple,I<:NamedTuple} + tokens::T + ids_by_family::I + strategy_to_family::Dict{Symbol,Symbol} + strategy_ids::Tuple{Vararg{Symbol}} + parameter::Union{Nothing,Type{<:Strategies.AbstractStrategyParameter}} +end + +""" +$(TYPEDSIGNATURES) + +Resolve a method tuple into a `ResolvedMethod` for routing and builders. + +This function extracts one strategy ID per family (using the `Strategies` contract), +builds reverse lookup maps, and extracts a global strategy parameter (if present). + +# Arguments +- `method::Tuple{Vararg{Symbol}}`: Method tokens, e.g. `(:collocation, :adnlp, :ipopt)`. +- `families::NamedTuple`: Mapping `family_name => family_type` used for ID extraction. +- `registry::Strategies.StrategyRegistry`: Strategy registry. + +# Returns +- `ResolvedMethod`: Precomputed lookups derived from `method`. + +# Throws +- `CTBase.Exceptions.IncorrectArgument`: If a family strategy ID cannot be extracted from `method`. + +# Example +```julia +resolved = resolve_method(method, families, registry) +resolved.strategy_ids +``` + +See also: `extract_strategy_ids`, `build_strategy_to_family_map` +""" +function resolve_method( + method::Tuple{Vararg{Symbol}}, + families::NamedTuple, + registry::Strategies.StrategyRegistry, +)::ResolvedMethod + ids_by_family = NamedTuple{keys(families)}( + Tuple( + Strategies.extract_id_from_method(method, family_type, registry) for + (family_name, family_type) in pairs(families) + ), + ) + + strategy_to_family = Dict{Symbol,Symbol}( + getfield(ids_by_family, family_name) => family_name for + family_name in keys(ids_by_family) + ) + + strategy_ids = Tuple( + getfield(ids_by_family, family_name) for family_name in keys(ids_by_family) + ) + + parameter = Strategies.extract_global_parameter_from_method(method, registry) + + return ResolvedMethod( + method, ids_by_family, strategy_to_family, strategy_ids, parameter + ) +end + +""" +$(TYPEDSIGNATURES) + +Extract strategy IDs from a routed option. + +This function processes a `RoutedOption` created by `route_to` and +validates that all specified strategy IDs are present in the method tuple. + +# Arguments +- `raw::Strategies.RoutedOption`: The routed option to process +- `resolved::ResolvedMethod`: Resolved method information (active strategy IDs) + +# Returns +- `Vector{Tuple{Any, Symbol}}`: Vector of (value, strategy_id) pairs + +# Throws +- `Exceptions.IncorrectArgument`: If a strategy ID in the routed option + is not present in the method tuple + +# Example +```julia +resolved = resolve_method(method, families, registry) +routed = route_to(solver=100, modeler=50) +ids = extract_strategy_ids(routed, resolved) +``` + +See also: `route_to`, `RoutedOption`, `extract_strategy_ids(::Any, ::ResolvedMethod)` +""" +function extract_strategy_ids( + raw::Strategies.RoutedOption, resolved::ResolvedMethod +)::Vector{Tuple{Any,Symbol}} + results = Tuple{Any,Symbol}[] + for (strategy_id, value) in pairs(raw) + if strategy_id in resolved.strategy_ids + push!(results, (value, strategy_id)) + else + throw( + Exceptions.IncorrectArgument( + "Strategy ID not found in method tuple"; + got="strategy ID :$strategy_id", + expected="one of available strategy IDs: $(resolved.tokens)", + suggestion="Use a valid strategy ID from your method tuple", + context="extract_strategy_ids - validating RoutedOption strategy ID", + ), + ) + end + end + return results +end + +# Strategy-to-family mapping + +""" +$(TYPEDSIGNATURES) + +Build a mapping from strategy IDs to family names. + +This helper function creates a reverse lookup dictionary that maps each +strategy ID in the method to its corresponding family name. This is used +by the routing system to determine which family owns each strategy. + +# Arguments +- `resolved::ResolvedMethod`: Resolved method information (active strategy IDs) +- `families::NamedTuple`: NamedTuple mapping family names to abstract types +- `registry::Strategies.StrategyRegistry`: Strategy registry + +# Returns +- `Dict{Symbol, Symbol}`: Dictionary mapping strategy ID => family name + +# Example +```julia +resolved = resolve_method(method, families, registry) +map = build_strategy_to_family_map(resolved, families, registry) +``` + +See also: `build_option_ownership_map`, `extract_strategy_ids` +""" +function build_strategy_to_family_map( + resolved::ResolvedMethod, families::NamedTuple, registry::Strategies.StrategyRegistry +)::Dict{Symbol,Symbol} + return copy(resolved.strategy_to_family) +end + +# Option ownership map + +""" +$(TYPEDSIGNATURES) + +Build a mapping from option names to the families that own them. + +This function analyzes the metadata of all strategies in the method to +determine which family (or families) define each option. Options that +appear in multiple families are considered ambiguous and require +disambiguation. + +# Arguments +- `resolved::ResolvedMethod`: Resolved method information (active strategies) +- `families::NamedTuple`: NamedTuple mapping family names to abstract types +- `registry::Strategies.StrategyRegistry`: Strategy registry + +# Returns +- `Dict{Symbol, Set{Symbol}}`: Dictionary mapping option_name => + Set{family_name} + +# Example +```julia +resolved = resolve_method(method, families, registry) +map = build_option_ownership_map(resolved, families, registry) +``` + +# Notes +- Options appearing in only one family can be auto-routed +- Options appearing in multiple families require disambiguation syntax +- Options not appearing in any family will trigger an error during routing + +See also: `build_strategy_to_family_map`, `route_all_options` +""" +function build_option_ownership_map( + resolved::ResolvedMethod, families::NamedTuple, registry::Strategies.StrategyRegistry +)::Dict{Symbol,Set{Symbol}} + option_owners = Dict{Symbol,Set{Symbol}}() + + for (family_name, family_type) in pairs(families) + id = getfield(resolved.ids_by_family, family_name) + strategy_type = Strategies.type_from_id(id, family_type, registry) + meta = Strategies.metadata(strategy_type) + + for (primary_name, def) in pairs(meta) + if !haskey(option_owners, primary_name) + option_owners[primary_name] = Set{Symbol}() + end + push!(option_owners[primary_name], family_name) + + for alias in def.aliases + if !haskey(option_owners, alias) + option_owners[alias] = Set{Symbol}() + end + push!(option_owners[alias], family_name) + end + end + end + + return option_owners +end + +""" +$(TYPEDSIGNATURES) + +Extract strategy IDs from a non-routed option. + +This fallback method handles option values that do not use disambiguation syntax. +It returns `nothing` to indicate that no routing information is present. + +# Arguments +- `raw`: The raw option value to analyze (any type) +- `resolved::ResolvedMethod`: Resolved method information (unused in this method) + +# Returns +- `nothing`: Always returns `nothing` since no disambiguation syntax is detected + +# Example +```julia +resolved = resolve_method(method, families, registry) +result = extract_strategy_ids(100, resolved) # Returns nothing +``` + +See also: `extract_strategy_ids(::Strategies.RoutedOption, ::ResolvedMethod)`, `route_to` +""" +function extract_strategy_ids(raw, resolved::ResolvedMethod)::Nothing + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Build a mapping from alias names to their primary option names for all strategies in the method. + +# Arguments +- `resolved::ResolvedMethod`: Resolved method information (active strategies) +- `families::NamedTuple`: NamedTuple mapping family names to abstract types +- `registry::Strategies.StrategyRegistry`: Strategy registry + +# Returns +- `Dict{Symbol, Symbol}`: Dictionary mapping `alias => primary_name` + +# Example +```julia +resolved = resolve_method(method, families, registry) +alias_map = build_alias_to_primary_map(resolved, families, registry) +``` + +See also: `build_option_ownership_map`, `resolve_method` + +""" +function build_alias_to_primary_map( + resolved::ResolvedMethod, families::NamedTuple, registry::Strategies.StrategyRegistry +)::Dict{Symbol,Symbol} + alias_map = Dict{Symbol,Symbol}() + + for (family_name, family_type) in pairs(families) + id = getfield(resolved.ids_by_family, family_name) + strategy_type = Strategies.type_from_id(id, family_type, registry) + meta = Strategies.metadata(strategy_type) + + for (primary_name, def) in pairs(meta) + for alias in def.aliases + alias_map[alias] = primary_name + end + end + end + + return alias_map +end diff --git a/src/Orchestration/routing.jl b/src/Orchestration/routing.jl new file mode 100644 index 00000000..82c0b607 --- /dev/null +++ b/src/Orchestration/routing.jl @@ -0,0 +1,791 @@ +# Option routing with strategy-aware disambiguation + +""" +$(TYPEDSIGNATURES) + +Route all options with support for disambiguation and multi-strategy routing. + +This is the main orchestration function that separates action options from +strategy options and routes each strategy option to the appropriate family. +It supports automatic routing for unambiguous options and explicit +disambiguation syntax for options that appear in multiple strategies. + +# Arguments +- `method::Tuple{Vararg{Symbol}}`: Complete method tuple (e.g., + `(:collocation, :adnlp, :ipopt)`) +- `families::NamedTuple`: NamedTuple mapping family names to AbstractStrategy + types +- `action_defs::Vector{Options.OptionDefinition}`: Definitions for + action-specific options +- `kwargs::NamedTuple`: All keyword arguments (action + strategy options mixed) +- `registry::Strategies.StrategyRegistry`: Strategy registry +- `source_mode::Symbol=:description`: Controls error verbosity (`:description` + for user-facing, `:explicit` for internal) + +# Returns +NamedTuple with two fields: +- `action::NamedTuple`: NamedTuple of action options (with `OptionValue` + wrappers) +- `strategies::NamedTuple`: NamedTuple of strategy options per family (raw + values, may contain `BypassValue` wrappers for bypassed options) + +# Disambiguation Syntax + +**Auto-routing** (unambiguous): +```julia +solve(ocp, :collocation, :adnlp, :ipopt; grid_size=100) +# grid_size only belongs to discretizer => auto-route +``` + +**Single strategy** (disambiguate): +```julia +solve(ocp, :collocation, :adnlp, :ipopt; backend = route_to(adnlp=:sparse)) +# backend belongs to both modeler and solver => disambiguate to :adnlp +``` + +**Multi-strategy** (set for multiple): +```julia +solve(ocp, :collocation, :adnlp, :ipopt; + backend = route_to(adnlp=:sparse, ipopt=:cpu) +) +# Set backend to :sparse for modeler AND :cpu for solver +``` + +**Bypass validation** (unknown backend option): +```julia +solve(ocp, :collocation, :adnlp, :ipopt; + custom_opt = route_to(ipopt=bypass(42)) +) +# BypassValue(42) is routed to solver and accepted unconditionally +``` + +# Throws + +- `CTBase.Exceptions.IncorrectArgument`: If an option is unknown, ambiguous without + disambiguation, or routed to the wrong strategy + +# Example +```julia +method = (:collocation, :adnlp, :ipopt) +families = (discretizer = DiscretizerFamily, modeler = ModelerFamily, solver = SolverFamily) +action_defs = Options.OptionDefinition[] +kwargs = (grid_size=100, backend=Strategies.route_to(adnlp=:sparse)) +routed = route_all_options(method, families, action_defs, kwargs, registry) +``` + +See also: `extract_strategy_ids`, `build_strategy_to_family_map`, `build_option_ownership_map` +""" +function route_all_options( + method::Tuple{Vararg{Symbol}}, + families::NamedTuple, + action_defs::Vector{<:Options.OptionDefinition}, + kwargs::NamedTuple, + registry::Strategies.StrategyRegistry; + source_mode::Symbol=:description, +) + # Step 1: Resolve method + resolved = resolve_method(method, families, registry) + + # Step 2: Separate action and strategy options + action_options, strategy_kwargs = _separate_action_and_strategy_options( + kwargs, action_defs + ) + + # Step 3: Build routing context + context = _build_routing_context(resolved, families, registry) + + # Step 4: Check for shadowing + _check_action_option_shadowing(action_options, context.option_owners) + + # Step 5: Route strategy options + routed = _initialize_routing_dict(families) + for (key, raw_val) in pairs(strategy_kwargs) + _route_single_option!( + routed, key, raw_val, context, resolved, families, registry, source_mode + ) + end + + # Step 6: Build final result + return _build_routed_result(action_options, routed) +end + +# ---------------------------------------------------------------------------- +# Private Helper Functions for route_all_options +# ---------------------------------------------------------------------------- + +""" +$(TYPEDEF) + +Internal struct to encapsulate routing context. + +Holds precomputed mappings used during option routing to avoid +passing multiple dictionaries around and improve performance. + +# Fields +- `strategy_to_family::Dict{Symbol, Symbol}`: Maps strategy IDs to their family names +- `option_owners::Dict{Symbol, Set{Symbol}}`: Maps option names to the set of families that own them + +# Notes +- This struct is immutable and created once per routing operation +- Precomputing these mappings avoids repeated lookups during routing +- Used internally by the routing helper functions +""" +struct RoutingContext + strategy_to_family::Dict{Symbol,Symbol} + option_owners::Dict{Symbol,Set{Symbol}} +end + +""" +$(TYPEDSIGNATURES) + +Separate action options from strategy options. + +Filters out RoutedOption values from action extraction, processes action +definitions, and re-integrates RoutedOption values for strategy routing. + +# Arguments +- `kwargs::NamedTuple`: All keyword arguments (action + strategy options mixed) +- `action_defs::Vector{<:Options.OptionDefinition}`: Definitions for action-specific options + +# Returns +- `Tuple{Dict, NamedTuple}`: (action_options, strategy_kwargs) where: + - `action_options`: Dict of extracted action options with OptionValue wrappers + - `strategy_kwargs`: NamedTuple of remaining kwargs for strategy routing + +# Notes +- RoutedOption values are excluded from action extraction and preserved for strategy routing +- Action options are wrapped in OptionValue with source tracking +- Strategy options remain in their original form for further processing +""" +function _separate_action_and_strategy_options( + kwargs::NamedTuple, action_defs::Vector{<:Options.OptionDefinition} +)::Tuple{Dict,NamedTuple} + # Filter out RoutedOption values for action extraction + action_kwargs = NamedTuple( + k => v for (k, v) in pairs(kwargs) if !(v isa Strategies.RoutedOption) + ) + + action_options, remaining_action_kwargs = Options.extract_options( + action_kwargs, action_defs + ) + + # Re-integrate RoutedOption values for strategy routing + remaining_kwargs = merge( + remaining_action_kwargs, + NamedTuple(k => v for (k, v) in pairs(kwargs) if v isa Strategies.RoutedOption), + ) + + return (action_options, remaining_kwargs) +end + +""" +$(TYPEDSIGNATURES) + +Build routing context with precomputed mappings. + +Creates a RoutingContext containing strategy-to-family and option ownership +maps to optimize routing performance by avoiding repeated computations. + +# Arguments +- `resolved::ResolvedMethod`: Resolved method containing strategy information +- `families::NamedTuple`: NamedTuple mapping family names to AbstractStrategy types +- `registry::Strategies.StrategyRegistry`: Strategy registry for metadata lookup + +# Returns +- `RoutingContext`: Context containing precomputed mappings for efficient routing + +# Notes +- Precomputes expensive mapping operations once per routing call +- Strategy-to-family mapping enables quick family lookup from strategy ID +- Option ownership mapping enables quick validation of option routing +""" +function _build_routing_context( + resolved::ResolvedMethod, families::NamedTuple, registry::Strategies.StrategyRegistry +)::RoutingContext + strategy_to_family = build_strategy_to_family_map(resolved, families, registry) + option_owners = build_option_ownership_map(resolved, families, registry) + return RoutingContext(strategy_to_family, option_owners) +end + +""" +$(TYPEDSIGNATURES) + +Check for action option shadowing and emit info messages. + +Detects when a user-provided action option also exists in strategy metadata, +which means the action option "shadows" the strategy option. Emits +informational messages to help users understand the shadowing. + +# Arguments +- `action_options::Dict`: Dictionary of extracted action options with OptionValue wrappers +- `option_owners::Dict{Symbol, Set{Symbol}}`: Maps option names to families that own them + +# Returns +- `Nothing`: This function only emits info messages + +# Notes +- Only checks user-provided options (source === :user), not default values +- Provides helpful guidance on using route_to() for specific strategy targeting +- Uses @info to emit messages without interrupting execution +""" +function _check_action_option_shadowing( + action_options::Dict, option_owners::Dict{Symbol,Set{Symbol}} +)::Nothing + for (k, opt_val) in action_options + if opt_val.source === :user && + haskey(option_owners, k) && + !isempty(option_owners[k]) + owners_str = join(sort(collect(option_owners[k])), ", ") + @info "Option `$(k)` was intercepted as a global action option. " * + "It is also available for the following strategy families: $(owners_str). " * + "To pass it specifically to a strategy, use `route_to($(k)=...)`." + end + end + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Initialize the routing dictionary structure. + +Creates an empty routing dictionary with one entry per family to collect +routed options during the routing process. + +# Arguments +- `families::NamedTuple`: NamedTuple mapping family names to AbstractStrategy types + +# Returns +- `Dict{Symbol, Vector{Pair{Symbol, Any}}}`: Empty routing dictionary with entries for each family + +# Notes +- Each family gets an empty Vector{Pair{Symbol, Any}} to collect routed options +- The structure enables efficient accumulation of options per family +- Used as the starting point for routing operations +""" +function _initialize_routing_dict( + families::NamedTuple +)::Dict{Symbol,Vector{Pair{Symbol,Any}}} + routed = Dict{Symbol,Vector{Pair{Symbol,Any}}}() + for family_name in keys(families) + routed[family_name] = Pair{Symbol,Any}[] + end + return routed +end + +""" +$(TYPEDSIGNATURES) + +Route a single option with explicit disambiguation. + +Handles options wrapped in route_to() with explicit strategy targets. +Validates that the target family owns the option or that bypass is used. + +# Arguments +- `routed::Dict{Symbol, Vector{Pair{Symbol, Any}}}`: Routing dictionary to populate +- `key::Symbol`: Option name being routed +- `disambiguations::Vector{Tuple{Any, Symbol}}`: List of (value, strategy_id) pairs +- `context::RoutingContext`: Precomputed routing mappings +- `resolved::ResolvedMethod`: Resolved method containing strategy information +- `families::NamedTuple`: NamedTuple mapping family names to AbstractStrategy types +- `registry::Strategies.StrategyRegistry`: Strategy registry for metadata lookup + +# Returns +- `Nothing`: Modifies `routed` in-place + +# Throws +- `Exceptions.IncorrectArgument`: If option is unknown or routed to wrong family + +# Notes +- BypassValue allows routing unknown options without validation +- Validates option ownership to prevent incorrect routing +- Provides helpful error messages for misrouted options +""" +function _route_with_disambiguation!( + routed::Dict{Symbol,Vector{Pair{Symbol,Any}}}, + key::Symbol, + disambiguations::Vector{Tuple{Any,Symbol}}, + context::RoutingContext, + resolved::ResolvedMethod, + families::NamedTuple, + registry::Strategies.StrategyRegistry, +)::Nothing + for (value, strategy_id) in disambiguations + family_name = context.strategy_to_family[strategy_id] + owners = get(context.option_owners, key, Set{Symbol}()) + + if family_name in owners || value isa Strategies.BypassValue + # Known option → route normally + # BypassValue → route without validation + push!(routed[family_name], key => value) + elseif isempty(owners) + # Unknown option with explicit target but no bypass → error + _error_unknown_option( + key, resolved, families, context.strategy_to_family, registry + ) + else + # Option exists but in wrong family + valid_strategies = [ + id for (id, fam) in context.strategy_to_family if fam in owners + ] + throw( + Exceptions.IncorrectArgument( + "Invalid option routing"; + got="option :$key to strategy :$strategy_id", + expected="option to be routed to one of: $valid_strategies", + suggestion="Check option ownership or use correct strategy identifier", + context="route_options - validating strategy-specific option routing", + ), + ) + end + end + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Route a single option automatically based on ownership. + +Handles options without explicit disambiguation by checking ownership: +- Unknown option → error with helpful suggestions +- Single owner → auto-route to that family +- Multiple owners → ambiguity error requiring disambiguation + +# Arguments +- `routed::Dict{Symbol, Vector{Pair{Symbol, Any}}}`: Routing dictionary to populate +- `key::Symbol`: Option name being routed +- `value::Any`: Option value to route +- `context::RoutingContext`: Precomputed routing mappings +- `resolved::ResolvedMethod`: Resolved method containing strategy information +- `families::NamedTuple`: NamedTuple mapping family names to AbstractStrategy types +- `registry::Strategies.StrategyRegistry`: Strategy registry for metadata lookup +- `source_mode::Symbol`: Controls error verbosity (:description or :explicit) + +# Returns +- `Nothing`: Modifies `routed` in-place + +# Throws +- `Exceptions.IncorrectArgument`: If option is unknown or ambiguous + +# Notes +- Uses option ownership mapping to determine routing destination +- Provides detailed error messages with suggestions for unknown/ambiguous options +- Auto-routing only occurs when option has exactly one owner +""" +function _route_auto!( + routed::Dict{Symbol,Vector{Pair{Symbol,Any}}}, + key::Symbol, + value::Any, + context::RoutingContext, + resolved::ResolvedMethod, + families::NamedTuple, + registry::Strategies.StrategyRegistry, + source_mode::Symbol, +)::Nothing + owners = get(context.option_owners, key, Set{Symbol}()) + + if isempty(owners) + # Unknown option - provide helpful error + _error_unknown_option(key, resolved, families, context.strategy_to_family, registry) + elseif length(owners) == 1 + # Unambiguous - auto-route + family_name = first(owners) + push!(routed[family_name], key => value) + else + # Ambiguous - need disambiguation + _error_ambiguous_option( + key, + value, + owners, + context.strategy_to_family, + source_mode, + resolved, + families, + registry, + ) + end + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Route a single option (dispatcher). + +Determines whether the option has explicit disambiguation and routes accordingly. +Acts as the main dispatcher for option routing logic. + +# Arguments +- `routed::Dict{Symbol, Vector{Pair{Symbol, Any}}}`: Routing dictionary to populate +- `key::Symbol`: Option name being routed +- `raw_val::Any`: Raw option value (may be wrapped in RoutedOption) +- `context::RoutingContext`: Precomputed routing mappings +- `resolved::ResolvedMethod`: Resolved method containing strategy information +- `families::NamedTuple`: NamedTuple mapping family names to AbstractStrategy types +- `registry::Strategies.StrategyRegistry`: Strategy registry for metadata lookup +- `source_mode::Symbol`: Controls error verbosity (:description or :explicit) + +# Returns +- `Nothing`: Modifies `routed` in-place + +# Notes +- Extracts strategy disambiguations from RoutedOption values if present +- Delegates to _route_with_disambiguation! for explicit routing +- Delegates to _route_auto! for automatic routing +- Central point for all option routing decisions +""" +function _route_single_option!( + routed::Dict{Symbol,Vector{Pair{Symbol,Any}}}, + key::Symbol, + raw_val::Any, + context::RoutingContext, + resolved::ResolvedMethod, + families::NamedTuple, + registry::Strategies.StrategyRegistry, + source_mode::Symbol, +)::Nothing + disambiguations = extract_strategy_ids(raw_val, resolved) + + if disambiguations !== nothing + _route_with_disambiguation!( + routed, key, disambiguations, context, resolved, families, registry + ) + else + _route_auto!( + routed, key, raw_val, context, resolved, families, registry, source_mode + ) + end + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Build the final routed result structure. + +Converts the routing dictionary and action options into the final NamedTuple format +expected by the routing system API. + +# Arguments +- `action_options::Dict`: Dictionary of extracted action options with OptionValue wrappers +- `routed::Dict{Symbol, Vector{Pair{Symbol, Any}}}`: Routing dictionary with options per family + +# Returns +- `NamedTuple`: Final result with structure `(action=..., strategies=...)` where: + - `action`: NamedTuple of action options with OptionValue wrappers + - `strategies`: NamedTuple of strategy options per family (raw values) + +# Notes +- Converts routing dictionary to nested NamedTuple structure +- Preserves OptionValue wrappers for action options +- Strategy options remain in their raw form for downstream processing +- This is the final step in the routing pipeline +""" +function _build_routed_result( + action_options::Dict, routed::Dict{Symbol,Vector{Pair{Symbol,Any}}} +)::NamedTuple + strategy_options = NamedTuple( + family_name => NamedTuple(pairs) for (family_name, pairs) in routed + ) + + action_nt = (; (k => v for (k, v) in action_options)...) + + return (action=action_nt, strategies=strategy_options) +end + +# ---------------------------------------------------------------------------- +# Error Message Helpers (Private) +# ---------------------------------------------------------------------------- + +""" +$(TYPEDSIGNATURES) + +Search for an option in all strategies of the registry, excluding strategies in the current method. + +This helper function checks if an unknown option exists in strategies that are not part of the +current method, enabling helpful error messages that suggest the user may have chosen the wrong strategy. + +# Arguments +- `key::Symbol`: The option name to search for +- `resolved::ResolvedMethod`: Resolved method containing current strategy IDs +- `families::NamedTuple`: NamedTuple mapping family names to family types +- `registry::Strategies.StrategyRegistry`: Strategy registry containing all registered strategies + +# Returns +- `Vector{Tuple{Symbol, Symbol}}`: Vector of (strategy_id, family_name) tuples for strategies that have this option + +# Notes +- Strategies in the current method are excluded from the search +- Uses try/catch around metadata() for robustness against incomplete strategy definitions +- Results are not ordered; caller should sort if needed +""" +function _find_option_in_registry( + key::Symbol, + resolved::ResolvedMethod, + families::NamedTuple, + registry::Strategies.StrategyRegistry, +)::Vector{Tuple{Symbol,Symbol}} + # Get set of strategy IDs in current method + current_strategy_ids = Set(collect(resolved.ids_by_family)) + + # Search all strategies in registry + matches = Tuple{Symbol,Symbol}[] + for (family_type, strategy_types) in registry.families + # Find the family name for this family_type + family_name = nothing + for (fname, ftype) in pairs(families) + if ftype === family_type + family_name = fname + break + end + end + if family_name === nothing + continue # This family_type is not in current families, skip + end + + for strategy_type in strategy_types + strategy_id = Strategies.id(strategy_type) + # Skip if this strategy is in current method + if strategy_id in current_strategy_ids + continue + end + # Check if option exists in this strategy's metadata + try + meta = Strategies.metadata(strategy_type) + if haskey(meta, key) + push!(matches, (strategy_id, family_name)) + end + catch + # Skip if metadata fails (robustness) + end + end + end + + return matches +end + +""" +$(TYPEDSIGNATURES) + +Helper to throw an informative error when an option doesn't belong to any strategy. +Lists all available options for the active strategies to help the user. + +# Notes +- Also searches the registry for strategies not in the current method that have this option, + suggesting the user may have chosen the wrong strategy. +""" +function _error_unknown_option( + key::Symbol, + resolved::ResolvedMethod, + families::NamedTuple, + strategy_to_family::Dict{Symbol,Symbol}, + registry::Strategies.StrategyRegistry, +) + # Build helpful error message showing all available options + all_options = Dict{Symbol,Vector{Symbol}}() + for (family_name, family_type) in pairs(families) + id = getfield(resolved.ids_by_family, family_name) + option_names = option_names_from_resolved(resolved, family_name, families, registry) + all_options[id] = collect(option_names) + end + + msg = + "Option :$key doesn't belong to any strategy in method $(resolved.tokens).\n\n" * + "Available options:\n" + for (id, option_names) in all_options + family = strategy_to_family[id] + msg *= " $family (:$id): $(join(option_names, ", "))\n" + end + + # Suggest closest options across all strategies (using primary names + aliases) + suggestion_parts = String[] + + # First, suggest similar options if any + all_suggestions = _collect_suggestions_across_strategies( + key, resolved, families, registry; max_suggestions=3 + ) + if !isempty(all_suggestions) + push!( + suggestion_parts, + "Did you mean?\n" * + join([" - $(Strategies.format_suggestion(s))" for s in all_suggestions], "\n"), + ) + end + + # Then, check if option exists in other strategies in registry + registry_matches = _find_option_in_registry(key, resolved, families, registry) + if !isempty(registry_matches) + if !isempty(all_suggestions) + push!(suggestion_parts, "\n") + end + matches_str = join([" :$sid ($family)" for (sid, family) in registry_matches], ", ") + push!( + suggestion_parts, + "This option exists in other strategies:$matches_str.\n" * + "Perhaps you selected the wrong strategy? Consider using a different method.", + ) + end + + # Then, suggest bypass if user is confident about the option + if !isempty(all_suggestions) || !isempty(registry_matches) + push!(suggestion_parts, "\n") + end + push!( + suggestion_parts, + "If you're confident this option exists for a specific strategy, " * + "use bypass() to skip validation:\n" * + " custom_opt = route_to(=bypass())", + ) + + # Combine all suggestions + suggestion = join(suggestion_parts, "") + + throw( + Exceptions.IncorrectArgument( + "Unknown option provided"; + got="option :$key in method $(resolved.tokens)", + expected="valid option name for one of the strategies", + suggestion=suggestion, + context="route_options - unknown option validation", + ), + ) +end + +""" +$(TYPEDSIGNATURES) + +Collect option suggestions across all strategies in the method, deduplicated by primary name. +Returns the top `max_suggestions` results sorted by minimum Levenshtein distance. +""" +function _collect_suggestions_across_strategies( + key::Symbol, + resolved::ResolvedMethod, + families::NamedTuple, + registry::Strategies.StrategyRegistry; + max_suggestions::Int=3, +) + # Collect suggestions from all strategies, keeping best distance per primary name + best = Dict{ + Symbol,@NamedTuple{primary::Symbol,aliases::Tuple{Vararg{Symbol}},distance::Int} + }() + for (family_name, family_type) in pairs(families) + id = getfield(resolved.ids_by_family, family_name) + strategy_type = Strategies.type_from_id(id, family_type, registry) + suggestions = Strategies.suggest_options( + key, strategy_type; max_suggestions=typemax(Int) + ) + for s in suggestions + if !haskey(best, s.primary) || s.distance < best[s.primary].distance + best[s.primary] = s + end + end + end + + # Sort by distance and take top suggestions + results = sort(collect(values(best)); by=x -> x.distance) + n = min(max_suggestions, length(results)) + return results[1:n] +end + +""" +$(TYPEDSIGNATURES) + +Helper to throw an informative error when an option belongs to multiple strategies and needs disambiguation. +Suggests using `route_to` syntax with specific examples for the conflicting strategies. +""" +function _error_ambiguous_option( + key::Symbol, + value::Any, + owners::Set{Symbol}, + strategy_to_family::Dict{Symbol,Symbol}, + source_mode::Symbol, + resolved::ResolvedMethod, + families::NamedTuple, + registry::Strategies.StrategyRegistry, +) + # Find which strategies own this option + strategies = [id for (id, fam) in strategy_to_family if fam in owners] + + # Collect aliases for this option from each strategy's metadata + alias_info = String[] + for (family_name, family_type) in pairs(families) + if family_name in owners + try + sid = getfield(resolved.ids_by_family, family_name) + strategy_type = Strategies.type_from_id(sid, family_type, registry) + meta = Strategies.metadata(strategy_type) + if haskey(meta, key) + def = meta[key] + if !isempty(def.aliases) + push!(alias_info, " :$sid aliases: $(join(def.aliases, ", "))") + end + end + catch + # Skip if metadata lookup fails + end + end + end + + if source_mode === :description + # User-friendly error message with route_to() syntax + msg = + "Option :$key is ambiguous between strategies: " * + "$(join(strategies, ", ")).\n\n" * + "Disambiguate using route_to():\n" + for id in strategies + fam = strategy_to_family[id] + msg *= " $key = route_to($id=$value) # Route to $fam\n" + end + msg *= + "\nOr set for multiple strategies:\n" * + " $key = route_to(" * + join(["$id=$value" for id in strategies], ", ") * + ")" + # Build suggestion with alias info + suggestion = "Use route_to() like $key = route_to($(first(strategies))=$value) to specify target strategy" + if !isempty(alias_info) + suggestion *= + ". Or use strategy-specific aliases to avoid ambiguity:\n" * + join(alias_info, "\n") + end + throw( + Exceptions.IncorrectArgument( + "Ambiguous option requires disambiguation"; + got="option :$key between strategies: $(join(strategies, ", "))", + expected="strategy-specific routing using route_to()", + suggestion=suggestion, + context="route_options - ambiguous option resolution", + ), + ) + else + # Internal/developer error message + throw( + Exceptions.IncorrectArgument( + "Ambiguous option in explicit mode"; + got="option :$key between families: $owners", + expected="unambiguous option routing in explicit mode", + suggestion="Use route_to() for disambiguation or switch to description mode", + context="route_options - explicit mode ambiguity validation", + ), + ) + end +end + +""" +$(TYPEDSIGNATURES) + +Helper to warn when an unknown option is routed in permissive mode. +""" +function _warn_unknown_option_permissive( + key::Symbol, strategy_id::Symbol, family_name::Symbol +) + @warn """ + Unknown option routed in permissive mode + + Option :$key is not defined in the metadata of strategy :$strategy_id ($family_name). + + This option will be passed directly to the strategy backend without validation. + Ensure the option name and value are correct for the backend. + """ +end diff --git a/src/Strategies/Strategies.jl b/src/Strategies/Strategies.jl new file mode 100644 index 00000000..0ee4be16 --- /dev/null +++ b/src/Strategies/Strategies.jl @@ -0,0 +1,82 @@ +""" +Strategy management and registry for the Control Toolbox ecosystem. + +This module provides: +- Abstract strategy contract and interface +- Strategy registry for explicit dependency management +- Strategy building and validation utilities +- Metadata management for strategy families + +The Strategies module depends on Options for option handling +but provides higher-level strategy management capabilities. +""" +module Strategies + +# Importing to avoid namespace pollution +import DocStringExtensions: TYPEDEF, TYPEDSIGNATURES +import CTBase.Core +import CTBase.Exceptions + +# Using CTBase modules to get access to the api +using ..Options + +# ============================================================================== +# Include submodules +# ============================================================================== + +include(joinpath(@__DIR__, "display_formatting.jl")) +include(joinpath(@__DIR__, "contract", "abstract_strategy.jl")) +include(joinpath(@__DIR__, "contract", "metadata.jl")) +include(joinpath(@__DIR__, "contract", "strategy_options.jl")) +include(joinpath(@__DIR__, "contract", "parameters.jl")) + +include(joinpath(@__DIR__, "api", "registry.jl")) +include(joinpath(@__DIR__, "api", "describe_registry.jl")) +include(joinpath(@__DIR__, "api", "introspection.jl")) +include(joinpath(@__DIR__, "api", "bypass.jl")) +include(joinpath(@__DIR__, "api", "builders.jl")) +include(joinpath(@__DIR__, "api", "configuration.jl")) +include(joinpath(@__DIR__, "api", "utilities.jl")) +include(joinpath(@__DIR__, "api", "validation_helpers.jl")) +include(joinpath(@__DIR__, "api", "disambiguation.jl")) + +# ============================================================================== +# Public API +# ============================================================================== + +# Core types +export AbstractStrategy, + StrategyRegistry, StrategyMetadata, StrategyOptions, OptionDefinition +export RoutedOption, BypassValue +export AbstractStrategyParameter, CPU, GPU + +# Type-level contract methods +export id, metadata, description + +# Instance-level contract methods +export options + +# Display and introspection +export describe + +# Registry functions +export create_registry, strategy_ids, type_from_id, get_parameter_type + +# Introspection functions +export option_names, option_type, option_description, option_default, option_defaults +export option_is_user, option_is_default, option_is_computed +export option_value, option_source, has_option +# export is_user, is_default, is_computed # no need to re-export +# export value, source # no need to re-export + +# Builder functions +export build_strategy, extract_id_from_method, available_parameters + +# Configuration functions +export build_strategy_options, resolve_alias + +# Utility functions +export filter_options, suggest_options, format_suggestion, options_dict, route_to +export bypass, force + +end # module Strategies diff --git a/src/Strategies/api/builders.jl b/src/Strategies/api/builders.jl new file mode 100644 index 00000000..3092af62 --- /dev/null +++ b/src/Strategies/api/builders.jl @@ -0,0 +1,332 @@ +# ============================================================================ +# Strategy Builders and Construction Utilities +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Build a strategy instance from its ID and options. + +This function creates a concrete strategy instance by: +1. Looking up the strategy type from its ID in the registry +2. Constructing the instance with the provided options + +# Arguments +- `id::Symbol`: Strategy identifier (e.g., `:adnlp`, `:ipopt`) +- `family::Type{<:AbstractStrategy}`: Abstract family type to search within +- `registry::StrategyRegistry`: Registry containing strategy mappings +- `mode::Symbol=:strict`: Validation mode (`:strict` or `:permissive`) +- `kwargs...`: Options to pass to the strategy constructor + +# Returns +- Concrete strategy instance of the appropriate type + +# Throws +- `Exceptions.IncorrectArgument`: If the strategy ID is not found in the registry for the given family + +# Example +```julia-repl +julia> registry = create_registry( + AbstractNLPModeler => (Modelers.ADNLP, Modelers.Exa) + ) + +julia> modeler = build_strategy(:adnlp, AbstractNLPModeler, registry; backend=:sparse) +Modelers.ADNLP(options=StrategyOptions{...}) + +julia> modeler = build_strategy(:adnlp, AbstractNLPModeler, registry; + backend=:sparse, mode=:permissive) +Modelers.ADNLP(options=StrategyOptions{...}) +``` + +See also: `type_from_id` +""" +function build_strategy( + id::Symbol, + family::Type{<:AbstractStrategy}, + registry::StrategyRegistry; + mode::Symbol=:strict, + kwargs..., +) + T = type_from_id(id, family, registry) + return T(; mode=mode, kwargs...) +end + +""" +$(TYPEDSIGNATURES) + +Build a parameterized strategy instance from ID, parameter, and options. + +This function creates a concrete parameterized strategy instance by: +1. Looking up the parameterized strategy type from its ID and parameter +2. Constructing the instance with the provided options + +# Arguments +- `id::Symbol`: Strategy identifier (e.g., `:madnlp`) +- `parameter::Type{<:AbstractStrategyParameter}`: Parameter type (e.g., `GPU`) +- `family::Type{<:AbstractStrategy}`: Abstract family type to search within +- `registry::StrategyRegistry`: Registry containing strategy mappings +- `mode::Symbol=:strict`: Validation mode (`:strict` or `:permissive`) +- `kwargs...`: Options to pass to the strategy constructor + +# Returns +- Concrete parameterized strategy instance (e.g., `MadNLP{GPU}`) + +# Throws +- `CTBase.Exceptions.IncorrectArgument`: If the strategy-parameter combination is not found + +# Example +```julia-repl +julia> registry = create_registry( + AbstractNLPSolver => ((MadNLP, [CPU, GPU]),) + ) + +julia> solver = build_strategy(:madnlp, GPU, AbstractNLPSolver, registry; max_iter=1000) +MadNLP{GPU}(options=StrategyOptions{...}) +``` + +See also: `build_strategy` +""" +function build_strategy( + id::Symbol, + parameter::Type{<:AbstractStrategyParameter}, + family::Type{<:AbstractStrategy}, + registry::StrategyRegistry; + mode::Symbol=:strict, + kwargs..., +) + T = type_from_id(id, family, registry; parameter=parameter) + return T(; mode=mode, kwargs...) +end + +""" +$(TYPEDSIGNATURES) + +Extract the strategy ID for a specific family from a method tuple. + +A method tuple contains multiple strategy IDs (e.g., `(:collocation, :adnlp, :ipopt)`). +This function identifies which ID corresponds to the requested family. + +# Arguments +- `method::Tuple{Vararg{Symbol}}`: Tuple of strategy IDs +- `family::Type{<:AbstractStrategy}`: Abstract family type to search for +- `registry::StrategyRegistry`: Registry containing strategy mappings + +# Returns +- `Symbol`: The ID corresponding to the requested family + +# Throws +- `Exceptions.IncorrectArgument`: If no ID or multiple IDs are found for the family + +# Example +```julia-repl +julia> method = (:collocation, :adnlp, :ipopt) + +julia> extract_id_from_method(method, AbstractNLPModeler, registry) +:adnlp + +julia> extract_id_from_method(method, AbstractNLPSolver, registry) +:ipopt +``` + +See also: `strategy_ids` +""" +function extract_id_from_method( + method::Tuple{Vararg{Symbol}}, + family::Type{<:AbstractStrategy}, + registry::StrategyRegistry, +) + allowed = strategy_ids(family, registry) + found::Union{Nothing,Symbol} = nothing + n_hits::Int = 0 + + for s in method + if s in allowed + n_hits += 1 + if found === nothing + found = s + end + end + end + + if n_hits == 1 + return (found::Symbol) + elseif n_hits == 0 + throw( + Exceptions.IncorrectArgument( + "No strategy ID found for family in method"; + got="family $family in method $method", + expected="family ID present in method tuple", + suggestion="Add the family ID to your method tuple, e.g., (:$family, ...)", + context="extract_id_from_method - validating method tuple contains family", + ), + ) + else + throw( + Exceptions.IncorrectArgument( + "Multiple strategy IDs found for family in method"; + got="family $family appears $n_hits times in method $method", + expected="exactly one ID per family in method tuple", + suggestion="Remove duplicate family IDs from method tuple, keep only one", + context="extract_id_from_method - validating unique family IDs", + ), + ) + end +end + +""" +$(TYPEDSIGNATURES) + +Internal helper returning the set of all registered strategy IDs. + +This function is used by registry utilities that need to distinguish strategy +tokens from other tokens that may appear in a method tuple (e.g. parameter +tokens). + +# Arguments +- `registry::StrategyRegistry`: Strategy registry. + +# Returns +- `Set{Symbol}`: Set of all strategy IDs present in the registry. + +# Notes +- This function is internal and not part of the public API. +""" +function _strategy_id_set(registry::StrategyRegistry) + ids = Set{Symbol}() + for strategies in values(registry.families) + for T in strategies + push!(ids, id(T)) + end + end + return ids +end + +""" +$(TYPEDSIGNATURES) + +Return all available strategy parameter types for a given `(strategy_id, family)`. + +This function is used by orchestration to validate that a global parameter token +present in the method tuple is compatible with all selected strategies. + +# Arguments +- `strategy_id::Symbol`: Strategy identifier (e.g. `:madnlp`). +- `family::Type{<:AbstractStrategy}`: Family to search within. +- `registry::StrategyRegistry`: Strategy registry. + +# Returns +- `Vector{Type{<:AbstractStrategyParameter}}`: Supported parameter types. Returns + an empty vector if the strategy is not parameterized. + +See also: `extract_global_parameter_from_method`, `get_parameter_type` +""" +function available_parameters( + strategy_id::Symbol, family::Type{<:AbstractStrategy}, registry::StrategyRegistry +) + params = Type{<:AbstractStrategyParameter}[] + for T in registry.families[family] + if id(T) === strategy_id + P = get_parameter_type(T) + if P !== nothing + push!(params, P::Type{<:AbstractStrategyParameter}) + end + end + end + return params +end + +""" +$(TYPEDSIGNATURES) + +Extract the global strategy parameter from a method tuple. + +The method tuple may contain at most one parameter token (e.g. `:cpu`, `:gpu`). +If present, it is resolved to a parameter type using `registry.parameters`. + +If any of the selected strategies in the method are parameterized, then a +parameter token is required and must be supported by each parameterized strategy. + +# Arguments +- `method::Tuple{Vararg{Symbol}}`: Method tuple containing strategy IDs and + optionally one parameter token. +- `registry::StrategyRegistry`: Strategy registry. + +# Returns +- `Union{Nothing, Type{<:AbstractStrategyParameter}}`: The extracted parameter + type, or `nothing` if none is present. + +# Throws +- `Exceptions.IncorrectArgument`: If more than one parameter token is present, + if a parameter is missing but required, if a parameter is unsupported, or if a + parameter token is provided but no selected strategy is parameterized. + +See also: `available_parameters`, `Strategies.AbstractStrategyParameter` +""" +function extract_global_parameter_from_method( + method::Tuple{Vararg{Symbol}}, registry::StrategyRegistry +) + param_map = registry.parameters + param_tokens = Symbol[s for s in method if haskey(param_map, s)] + if length(param_tokens) > 1 + throw( + Exceptions.IncorrectArgument( + "Multiple parameters found in method"; + got="method $method", + expected="at most one global parameter token", + suggestion="Remove extra parameter tokens; keep a single one like :cpu or :gpu", + context="extract_global_parameter_from_method - validating unique global parameter", + ), + ) + end + param = isempty(param_tokens) ? nothing : param_map[param_tokens[1]] + + strategy_ids = _strategy_id_set(registry) + selected_strategy_ids = Symbol[s for s in method if s in strategy_ids] + + any_parameterized = false + for (family, _) in registry.families + for s_id in selected_strategy_ids + available = available_parameters(s_id, family, registry) + if !isempty(available) + any_parameterized = true + if param === nothing + throw( + Exceptions.IncorrectArgument( + "Missing parameter in method"; + got="method $method", + expected="a global parameter token for parameterized strategies", + suggestion="Add :cpu or :gpu to your method tuple", + context="extract_global_parameter_from_method - parameter required", + ), + ) + end + if !(param in available) + available_ids = Tuple(id(p) for p in available) + throw( + Exceptions.IncorrectArgument( + "Unsupported parameter in method"; + got="strategy :$s_id with parameter $(id(param)) in method $method", + expected="strategy :$s_id with one of: $available_ids", + suggestion="Use one of: $available_ids", + context="extract_global_parameter_from_method - validating parameter support", + ), + ) + end + end + end + end + + if param !== nothing && !any_parameterized + throw( + Exceptions.IncorrectArgument( + "Useless parameter in method"; + got="method $method with parameter $(id(param))", + expected="parameter token to be accepted by at least one selected strategy", + suggestion="Remove the parameter token or select a strategy that accepts it", + context="extract_global_parameter_from_method - unused parameter", + ), + ) + end + + return param +end diff --git a/src/Strategies/api/bypass.jl b/src/Strategies/api/bypass.jl new file mode 100644 index 00000000..9b621f16 --- /dev/null +++ b/src/Strategies/api/bypass.jl @@ -0,0 +1,121 @@ +# ============================================================================ +# Bypass Mechanism for Explicit Option Validation +# ============================================================================ + +""" +$(TYPEDEF) + +Wrapper type for option values that should bypass validation. + +This type is used to explicitly skip validation for specific options when +constructing strategies. It is particularly useful for passing backend-specific +options that are not defined in the strategy's metadata. + +# Fields +- `value::T`: The wrapped option value + +# Example +```julia-repl +julia> val = bypass(42) +BypassValue(42) +``` + +See also: `bypass` +""" +struct BypassValue{T} + value::T +end + +""" +$(TYPEDSIGNATURES) + +Mark an option value to bypass validation. + +This function creates a `BypassValue` wrapper around the provided value. +When passed to a strategy constructor, this value will be accepted even if the +option name is unknown (not in metadata) or if validation would otherwise fail. + +This can be combined with `route_to` to bypass validation for specific +strategies when routing ambiguous options. + +# Arguments +- `val`: The option value to wrap + +# Returns +- `BypassValue`: The wrapped value + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> # Pass an unknown option directly to strategy +julia> solver = Ipopt( + max_iter=100, + custom_backend_option=bypass(42) # Bypasses validation + ) +Ipopt(options=StrategyOptions{...}) + +julia> # Alternative syntax using force alias +julia> solver = Ipopt( + max_iter=100, + custom_backend_option=force(42) # Same as bypass(42) + ) +Ipopt(options=StrategyOptions{...}) + +julia> # Combine with routing for ambiguous options +julia> solve(ocp, method; + backend = route_to(ipopt=bypass(42)) # Route to ipopt AND bypass validation + ) +``` + +# Notes +- Use with caution! Bypassed options are passed directly to the backend. +- Typos in option names will not be caught by validation. +- Invalid values for the backend will cause backend-level errors. +- Can be combined with `route_to` for strategy-specific bypassing +- `force` is an alias for `bypass` - they are identical functions + +See also: `BypassValue`, `route_to`, `force` +""" +bypass(val) = BypassValue(val) + +""" +$(TYPEDSIGNATURES) + +Force an option value to bypass validation. + +This function is an alias for `bypass` and provides identical functionality. +The name `force` may be more intuitive for users who prefer "force" semantics +when bypassing validation. + +# Arguments +- `val`: The option value to wrap + +# Returns +- `BypassValue`: The wrapped value + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> # Force acceptance of unknown option +julia> solver = Ipopt( + max_iter=100, + custom_backend_option=force(42) # Forces validation bypass + ) +Ipopt(options=StrategyOptions{...}) + +julia> # Same as bypass(42) +julia> @test force(42) == bypass(42) +true +``` + +# Notes +- `force` and `bypass` are the same function: `force === bypass` +- Choose the name that best fits your mental model +- Both functions create `BypassValue` wrappers +- Use with caution for the same reasons as `bypass` + +See also: `BypassValue`, `bypass`, `route_to` +""" +const force = bypass diff --git a/src/Strategies/api/configuration.jl b/src/Strategies/api/configuration.jl new file mode 100644 index 00000000..d4f78479 --- /dev/null +++ b/src/Strategies/api/configuration.jl @@ -0,0 +1,187 @@ +# ============================================================================ +# Strategy configuration and setup +# ============================================================================ + +using DocStringExtensions + +""" +$(TYPEDSIGNATURES) + +Build StrategyOptions from user kwargs and strategy metadata. + +This function creates a StrategyOptions instance by: +1. Validating the mode parameter (`:strict` or `:permissive`) +2. Extracting known options from kwargs using the Options API +3. Handling unknown options based on the mode +4. Converting the extracted Dict to NamedTuple +5. Wrapping in StrategyOptions + +The Options.extract_options function handles: +- Alias resolution to primary names +- Type validation +- Custom validators +- Default values +- Provenance tracking (:user, :default) + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type to build options for +- `mode::Symbol = :strict`: Validation mode (`:strict` or `:permissive`) + - `:strict` (default): Rejects unknown options with detailed error message + - `:permissive`: Accepts unknown options with warning, stores with `:user` source (unvalidated) +- `kwargs...`: User-provided option values + +# Returns +- `StrategyOptions`: Validated options with provenance tracking + +# Throws +- `Exceptions.IncorrectArgument`: If mode is not `:strict` or `:permissive` +- `Exceptions.IncorrectArgument`: If an unknown option is provided in strict mode +- `Exceptions.IncorrectArgument`: If type validation fails (both modes) +- `Exceptions.IncorrectArgument`: If custom validation fails (both modes) + +# Example +```julia-repl +# Define a minimal strategy for demonstration +julia> struct MyStrategy <: AbstractStrategy end +julia> Strategies.metadata(::Type{MyStrategy}) = StrategyMetadata( + OptionDefinition(name=:max_iter, type=Int, default=100) + ) + +# Strict mode (default) - rejects unknown options +julia> opts = build_strategy_options(MyStrategy; max_iter=200) +StrategyOptions with 1 option: + max_iter = 200 [user] + +# Permissive mode - accepts unknown options with warning +julia> opts = build_strategy_options(MyStrategy; max_iter=200, custom_opt=123, mode=:permissive) +┌ Warning: Unrecognized options passed to MyStrategy +│ Unvalidated options: [:custom_opt] +└ ... +StrategyOptions with 2 options: + max_iter = 200 [user] + custom_opt = 123 [user] +``` + +# Notes +- Known options are always validated (type, custom validators) regardless of mode +- Unknown options in permissive mode are stored with source `:user` but bypass validation +- Use permissive mode only when you need to pass backend-specific options not defined in the strategy metadata + +See also: `StrategyOptions`, `metadata`, `Options.extract_options` +""" +function build_strategy_options( + strategy_type::Type{<:AbstractStrategy}; mode::Symbol=:strict, kwargs... +) + # Validate mode parameter + if mode ∉ (:strict, :permissive) + throw( + Exceptions.IncorrectArgument( + "Invalid validation mode"; + got="mode=$mode", + expected=":strict or :permissive", + suggestion="Use mode=:strict for strict validation (default) or mode=:permissive to accept unknown options with warnings", + context="build_strategy_options - validating mode parameter", + ), + ) + end + + meta = metadata(strategy_type) + defs = collect(values(meta)) + + # Separate BypassValue kwargs from normal kwargs + # BypassValue options are accepted unconditionally regardless of mode + input_kwargs = (; kwargs...) + bypass_pairs = Pair{Symbol,Any}[] + normal_pairs = Pair{Symbol,Any}[] + for (k, v) in pairs(input_kwargs) + if v isa BypassValue + push!(bypass_pairs, k => v.value) + else + push!(normal_pairs, k => v) + end + end + normal_kwargs = NamedTuple(normal_pairs) + + # Use Options.extract_options for validation and extraction of normal options + # This validates known options (type, custom validators, etc.) + extracted, remaining = Options.extract_options(normal_kwargs, defs) + + # Handle unknown normal options based on mode + if !isempty(remaining) + if mode == :strict + _error_unknown_options_strict(remaining, strategy_type, meta) + else # mode == :permissive + _warn_unknown_options_permissive(remaining, strategy_type) + # Store unvalidated options with :user source + # Note: These options bypass validation but are still user-provided + for (key, value) in pairs(remaining) + extracted[key] = Options.OptionValue(value, :user) + end + end + end + + # Inject bypassed options unconditionally (no validation, no warning) + for (key, value) in bypass_pairs + extracted[key] = Options.OptionValue(value, :user) + end + + # Build alias_map from metadata + alias_map = Dict{Symbol,Symbol}() + for def in defs + for alias in Options.aliases(def) + alias_map[alias] = Options.name(def) + end + end + + # Convert Dict to NamedTuple + nt = (; (k => v for (k, v) in extracted)...) + + return StrategyOptions(nt, alias_map) +end + +""" +$(TYPEDSIGNATURES) + +Resolve an alias to its primary key name. + +Searches through strategy metadata to find if a given key is either: +1. A primary option name +2. An alias for a primary option name + +# Arguments +- `meta::StrategyMetadata`: Strategy metadata to search in +- `key::Symbol`: Key to resolve (can be primary name or alias) + +# Returns +- `Union{Symbol, Nothing}`: Primary key if found, `nothing` otherwise + +# Example +```julia-repl +julia> meta = metadata(MyStrategy) +julia> resolve_alias(meta, :max_iter) # Primary name +:max_iter + +julia> resolve_alias(meta, :max) # Alias +:max_iter + +julia> resolve_alias(meta, :unknown) # Not found +nothing +``` + +See also: `StrategyMetadata`, `OptionDefinition` +""" +function resolve_alias(meta::StrategyMetadata, key::Symbol) + # Check if key is a primary name + if haskey(meta, key) + return key + end + + # Check if key is an alias + for (primary_key, spec) in pairs(meta) + if key in spec.aliases + return primary_key + end + end + + return nothing +end diff --git a/src/Strategies/api/describe_registry.jl b/src/Strategies/api/describe_registry.jl new file mode 100644 index 00000000..6b088bae --- /dev/null +++ b/src/Strategies/api/describe_registry.jl @@ -0,0 +1,799 @@ +# ============================================================================ +# Describe - Registry-aware introspection +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Display comprehensive information about a strategy using its ID and registry. + +This function provides registry-aware introspection that shows: +- Strategy ID and family membership +- Available parameters (CPU, GPU, etc.) +- Default parameter (if applicable) +- Options grouped by source (common vs computed) +- Parameter-specific computed option values + +# Arguments +- `strategy_id::Symbol`: The strategy identifier (e.g., `:adnlp`, `:exa`, `:ipopt`) +- `registry::StrategyRegistry`: The registry containing strategy definitions + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> registry = create_registry( + AbstractNLPModeler => ( + (ADNLP, [CPU]), + (Exa, [CPU, GPU]) + ) + ) + +julia> describe(:exa, registry) +Exa (strategy) +├─ id: :exa +├─ family: AbstractNLPModeler +├─ default parameter: CPU +├─ parameters: CPU, GPU +│ +├─ common options (1 option): +│ └─ base_type::DataType (default: Float64) +│ description: Base floating-point type used by ExaModels +│ +├─ computed options for CPU: +│ └─ backend::Union{Nothing, ...} (default: nothing [computed]) +│ description: Execution backend for ExaModels +│ +└─ computed options for GPU: + └─ backend::Union{Nothing, ...} (default: CUDABackend [computed]) + description: Execution backend for ExaModels +``` + +# Throws +- `Exceptions.IncorrectArgument`: If the strategy ID is not found in the registry + +See also: `describe(::Type{<:AbstractStrategy})`, `StrategyRegistry`, `create_registry` +""" +function describe(id_symbol::Symbol, registry::StrategyRegistry) + describe(stdout, id_symbol, registry) +end + +function describe(io::IO, id_symbol::Symbol, registry::StrategyRegistry) + # Disambiguation: check if it's a parameter ID first, then strategy ID + if haskey(registry.parameters, id_symbol) + # It's a parameter ID + param_type = registry.parameters[id_symbol] + _describe_parameter_registry(io, id_symbol, param_type, registry) + else + # Try as strategy ID + _describe_strategy_registry(io, id_symbol, registry) + end +end + +""" +Describe a strategy using registry (internal implementation). +""" +function _describe_strategy_registry( + io::IO, strategy_id::Symbol, registry::StrategyRegistry +) + fmt = Core.get_format_codes(io) + + # 1. Find family and strategy types from registry + family, strategy_types = _find_strategy_in_registry(strategy_id, registry) + + # 2. Get base type name (without parameters for header) + base_type = first(strategy_types) + type_name = _strategy_base_name(base_type) + + # 3. Get available parameters + params = [get_parameter_type(T) for T in strategy_types] + params = filter(!isnothing, params) + unique!(params) # Remove duplicates + + # 4. Get default parameter (if parameterized) + default_param = if !isempty(params) + try + # Try to get the UnionAll wrapper type + wrapper_type = if base_type isa UnionAll + base_type + elseif base_type isa DataType && base_type.name.wrapper isa UnionAll + base_type.name.wrapper + else + base_type + end + _default_parameter(wrapper_type) + catch + nothing + end + else + nothing + end + + # 5. Header with hierarchy + println(io, fmt.name, type_name, fmt.reset, " (strategy)") + println( + io, "├─ ", fmt.label, "id: ", fmt.reset, fmt.keyword, ":", strategy_id, fmt.reset + ) + + # Build hierarchy chain for the base type + hierarchy_chain = _supertype_chain(base_type, AbstractStrategy) + hierarchy_str = join( + [fmt.type * string(nameof(t)) * fmt.reset for t in hierarchy_chain], " → " + ) + println(io, "├─ ", fmt.label, "hierarchy: ", fmt.reset, hierarchy_str) + + # Strategy description (if defined) + base_wrapper = if base_type isa UnionAll + base_type + elseif base_type isa DataType && base_type.name.wrapper isa UnionAll + base_type.name.wrapper + else + base_type + end + desc = try + description(base_wrapper) + catch + nothing + end + if desc !== nothing + _print_labeled_multiline(io, "├─ ", "│ ", fmt, "description: ", desc) + end + + println( + io, "├─ ", fmt.label, "family: ", fmt.reset, fmt.type, nameof(family), fmt.reset + ) + + if !isempty(params) + if default_param !== nothing + println( + io, + "├─ ", + fmt.label, + "default parameter: ", + fmt.reset, + fmt.type, + nameof(default_param), + fmt.reset, + ) + end + param_names = join([fmt.type * string(nameof(P)) * fmt.reset for P in params], ", ") + println(io, "├─ ", fmt.label, "parameters: ", fmt.reset, param_names) + println(io, "│") # vertical separator + else + println(io, "│") # vertical separator for consistency + end + + # 6. Retrieve and display metadata + _describe_metadata(io, fmt, strategy_types, params, registry) +end + +""" +Describe a parameter using registry (internal implementation). +""" +function _describe_parameter_registry( + io::IO, + param_id::Symbol, + param_type::Type{<:AbstractStrategyParameter}, + registry::StrategyRegistry, +) + fmt = Core.get_format_codes(io) + type_name = nameof(param_type) + param_desc = description(param_type) + + # Build hierarchy chain + hierarchy_chain = [param_type, AbstractStrategyParameter] + hierarchy_str = join( + [fmt.type * string(nameof(T)) * fmt.reset for T in hierarchy_chain], " → " + ) + + println(io, fmt.name, type_name, fmt.reset, " (parameter)") + println(io, "├─ ", fmt.label, "id: ", fmt.reset, fmt.keyword, ":", param_id, fmt.reset) + println(io, "├─ ", fmt.label, "hierarchy: ", fmt.reset, hierarchy_str) + println(io, "├─ ", fmt.label, "description: ", fmt.reset, param_desc) + + # Find strategies using this parameter + strategies_using = _find_strategies_using_parameter(param_type, registry) + + if !isempty(strategies_using) + println(io, "│") + n_strategies = length(strategies_using) + println( + io, + "└─ ", + fmt.label, + "used by strategies (", + fmt.reset, + fmt.count, + n_strategies, + fmt.reset, + "):", + ) + + for (i, (strat_id, family, strat_type)) in enumerate(strategies_using) + is_last = i == length(strategies_using) + prefix = is_last ? " └─ " : " ├─ " + println( + io, + prefix, + fmt.keyword, + ":", + strat_id, + fmt.reset, + " (", + fmt.type, + nameof(family), + fmt.reset, + ") → ", + fmt.type, + _strategy_type_name(strat_type), + fmt.reset, + ) + end + else + println(io, "│") + println( + io, + "└─ ", + fmt.label, + "used by strategies: ", + fmt.reset, + fmt.keyword, + "none", + fmt.reset, + ) + end +end + +# ============================================================================ +# Private helpers for registry-aware describe +# ============================================================================ + +""" +Build a supertype chain from a type up to (and including) a stop type. + +Returns a vector of types representing the inheritance chain. + +# Arguments +- `T::Type`: Starting type +- `stop_at::Type`: Type to stop at (inclusive) + +# Returns +- `Vector{Type}`: Chain of types from T to stop_at + +# Example +```julia +chain = _supertype_chain(ADNLP{CPU}, AbstractStrategy) +# Returns: [ADNLP{CPU}, AbstractNLPModeler, AbstractStrategy] +``` +""" +function _supertype_chain(T::Type, stop_at::Type) + chain = Type[T] + current = T + + while current !== stop_at && current !== Any + current = supertype(current) + push!(chain, current) + if current === stop_at + break + end + end + + return chain +end + +""" +Find all strategies in the registry that use a specific parameter type. + +Returns a vector of tuples: (strategy_id, family_type, strategy_type) + +# Arguments +- `param_type::Type{<:AbstractStrategyParameter}`: The parameter type to search for +- `registry::StrategyRegistry`: The registry to search in + +# Returns +- `Vector{Tuple{Symbol, Type, Type}}`: List of (strategy_id, family, strategy_type) tuples +""" +function _find_strategies_using_parameter( + param_type::Type{<:AbstractStrategyParameter}, registry::StrategyRegistry +) + results = Tuple{Symbol,Type,Type}[] + + for (family, types) in registry.families + for T in types + # Check if this strategy type uses the parameter + strat_param = get_parameter_type(T) + if strat_param === param_type + strat_id = id(T) + push!(results, (strat_id, family, T)) + end + end + end + + # Sort by strategy ID for consistent display + sort!(results; by=x -> x[1]) + + return results +end + +""" +Find a strategy in the registry by its ID. + +Returns `(family_type, [matched_types...])` where matched_types are all +strategy types with the given ID. + +Throws `IncorrectArgument` if the ID is not found. +""" +function _find_strategy_in_registry(strategy_id::Symbol, registry::StrategyRegistry) + for (family, types) in registry.families + matched = filter(T -> id(T) === strategy_id, types) + if !isempty(matched) + return (family, matched) + end + end + + # Not found - provide helpful error with available IDs + all_ids = Symbol[] + for (family, types) in registry.families + for T in types + push!(all_ids, id(T)) + end + end + unique!(all_ids) + + # Check if it might be a parameter ID + if haskey(registry.parameters, strategy_id) + throw( + Exceptions.IncorrectArgument( + "Symbol is a parameter ID, not a strategy ID"; + got=":$strategy_id", + expected="a strategy ID from: $all_ids", + suggestion="This is a parameter ID. Use describe(:$strategy_id, registry) to see parameter info.", + context="describe - disambiguating strategy vs parameter ID", + ), + ) + end + + throw( + Exceptions.IncorrectArgument( + "ID not found in registry"; + got=":$strategy_id", + expected="one of available strategy IDs: $all_ids or parameter IDs: $(collect(keys(registry.parameters)))", + suggestion="Check available IDs or register the missing strategy/parameter", + context="describe - looking up ID in registry", + ), + ) +end + +""" +$(TYPEDSIGNATURES) + +Extract a clean type name from a DataType by removing module prefixes while preserving parameter structure. + +This method handles both parameterized DataTypes (e.g., `Exa{CPU}`) and non-parameterized +DataTypes (e.g., `Collocation`). For parameterized types, it removes module prefixes from +both the base type and parameters while preserving the parameter structure. + +# Arguments +- `T::DataType`: The DataType to format + +# Returns +- `String`: Clean type name without module prefixes (e.g., `"Exa{CPU}"` or `"Collocation"`) + +# Examples +```julia-repl +julia> using CTBase.Strategies + +julia> struct FakeExa{P <: CTBase.Strategies.AbstractStrategyParameter} end +julia> _strategy_type_name(FakeExa{CTBase.Strategies.CPU}) +"FakeExa{CPU}" + +julia> _strategy_type_name(Collocation) +"Collocation" +``` + +# Notes +- This is the most common case, handling concrete instantiated types +- For parameterized types, each parameter is formatted recursively + +See also: [`describe`](@ref), [`_describe_parameter_registry`](@ref) +""" +function _strategy_type_name(T::DataType) + base_name = string(T.name.name) + if !isempty(T.parameters) + param_names = map(T.parameters) do p + p isa DataType ? _strategy_type_name(p) : string(nameof(p)) + end + return "$base_name{$(join(param_names, ", "))}" + end + + return base_name +end + +""" +$(TYPEDSIGNATURES) + +Extract a clean type name from a UnionAll type by removing module prefixes. + +This method handles generic types that have not been fully instantiated, preserving +the type parameter variable name. + +# Arguments +- `T::UnionAll`: The UnionAll type to format + +# Returns +- `String`: Clean type name with generic parameter (e.g., `"Exa{P}"` where P is the type variable) + +# Notes +- This is a fallback for generic types that are not yet instantiated +- Less common than the DataType method in typical usage + +See also: [`_strategy_type_name(::DataType)`](@ref) +""" +function _strategy_type_name(T::UnionAll) + base_name = string(T.body.name.name) + param_name = string(nameof(T.var)) + return "$base_name{$param_name}" +end + +""" +$(TYPEDSIGNATURES) + +Extract a clean type name from any other Type. + +This is the most general fallback method for types that don't match more specific methods. + +# Arguments +- `T::Type`: Any type that doesn't match other methods + +# Returns +- `String`: String representation of the type + +# Notes +- This is the ultimate fallback for edge cases +- Simply converts the type to a string representation + +See also: [`_strategy_type_name(::DataType)`](@ref), [`_strategy_type_name(::UnionAll)`](@ref) +""" +function _strategy_type_name(T::Type) + return string(T) +end + +""" +$(TYPEDSIGNATURES) + +Extract the base type name without parameters for strategy headers. + +This function removes module prefixes and parameter information, returning just the +clean base type name (e.g., "ADNLP" from "ADNLP{CPU}"). + +# Arguments +- `T::Type`: The type to extract the base name from + +# Returns +- `String`: Clean base type name without parameters + +# Examples +```julia-repl +julia> using CTBase.Strategies + +julia> struct FakeADNLP{P <: CTBase.Strategies.AbstractStrategyParameter} end +julia> _strategy_base_name(FakeADNLP{CTBase.Strategies.CPU}) +"FakeADNLP" + +julia> _strategy_base_name(Collocation) +"Collocation" +``` + +# Notes +- Used specifically for strategy headers to avoid redundancy with parameter display +- Handles both DataType and UnionAll types + +See also: [`_strategy_type_name`](@ref), [`describe`](@ref) +""" +function _strategy_base_name(T::DataType) + return string(T.name.name) +end + +function _strategy_base_name(T::UnionAll) + return string(T.body.name.name) +end + +function _strategy_base_name(T::Type) + return string(nameof(T)) +end + +""" +Display metadata for strategy types, handling multiple parameters and extensions. + +For strategies with multiple parameters, groups options into: +- Common options (default source, same across parameters) +- Computed options (per-parameter, shown separately) +""" +function _describe_metadata( + io::IO, fmt, strategy_types::Vector, params::Vector, registry::StrategyRegistry +) + if isempty(params) + # Non-parameterized strategy - simple case + _describe_single_metadata(io, fmt, first(strategy_types)) + elseif length(params) == 1 + # Single parameter - simple case + _describe_single_metadata(io, fmt, first(strategy_types)) + else + # Multiple parameters - group common vs computed options + _describe_multi_param_metadata(io, fmt, strategy_types, params) + end +end + +""" +Display metadata for a single strategy type (non-parameterized or single parameter). +""" +function _describe_single_metadata(io::IO, fmt, strategy_type::Type) + # Try to get metadata, catch ExtensionError + meta = try + metadata(strategy_type) + catch e + if e isa Exceptions.ExtensionError + # Extension not loaded - display in red + ext_names = join(e.weakdeps, ", ") + println( + io, + "└─ ", + fmt.label, + "options: ", + fmt.reset, + "\033[31m", # Red color + "requires extension ", + ext_names, + "\033[0m", # Reset color + ) + return nothing + else + rethrow() + end + end + + # Display all options + n_opts = length(meta) + println( + io, + "└─ ", + fmt.label, + "options (", + fmt.reset, + fmt.count, + n_opts, + fmt.reset, + " option", + n_opts == 1 ? "" : "s", + "):", + ) + + items = collect(pairs(meta)) + for (i, (key, def)) in enumerate(items) + is_first = i == 1 + if is_first + println(io, " │ ") + end + is_last = i == length(items) + prefix = is_last ? " └─ " : " ├─ " + cont = is_last ? " " : " │ " + println(io, prefix, def) + _print_labeled_multiline( + io, cont, cont, fmt, "description: ", Options.description(def) + ) + # Add separator line between options (except after last) + if !is_last + println(io, cont) + end + end +end + +""" +Display metadata for multi-parameter strategies, grouping common and computed options. +""" +function _describe_multi_param_metadata(io::IO, fmt, strategy_types::Vector, params::Vector) + # Collect metadata for each parameter + param_metadata = Dict{Type,Union{StrategyMetadata,Nothing}}() + param_errors = Dict{Type,Union{Exceptions.ExtensionError,Nothing}}() + + for (T, P) in zip(strategy_types, params) + meta = try + metadata(T) + catch e + if e isa Exceptions.ExtensionError + param_errors[P] = e + nothing # Extension not loaded for this parameter + else + rethrow() + end + end + param_metadata[P] = meta + end + + # Check if all metadata is missing (all extensions not loaded) + if all(isnothing, values(param_metadata)) + # All extensions missing - get extension names from first error + first_error = first(values(param_errors)) + ext_names = join(first_error.weakdeps, ", ") + println( + io, + "└─ ", + fmt.label, + "options: ", + fmt.reset, + "\033[31m", # Red color + "requires extension ", + ext_names, + "\033[0m", # Reset color + ) + return nothing + end + + # Collect all option names and definitions across parameters + all_option_names = Set{Symbol}() + option_defs = Dict{Symbol,Vector{Tuple{Type,OptionDefinition}}}() + + for (P, meta) in param_metadata + if meta !== nothing + for (name, def) in pairs(meta) + push!(all_option_names, name) + if !haskey(option_defs, name) + option_defs[name] = [] + end + push!(option_defs[name], (P, def)) + end + end + end + + # Separate common (default) and computed options + common_options = Symbol[] + computed_options = Symbol[] + + for name in all_option_names + defs = option_defs[name] + # Check if this option is computed in any parameter variant + is_computed = any(Options.is_computed(def) for (P, def) in defs) + if is_computed + push!(computed_options, name) + else + push!(common_options, name) + end + end + + # Display computed options per parameter first + for (i, P) in enumerate(params) + is_last_param = i == length(params) + meta = param_metadata[P] + + if meta === nothing + # Extension not loaded for this parameter + prefix = (is_last_param && isempty(common_options)) ? "└─ " : "├─ " + # Get extension names from error + ext_error = get(param_errors, P, nothing) + ext_names = ext_error !== nothing ? join(ext_error.weakdeps, ", ") : "unknown" + println( + io, + prefix, + fmt.label, + "computed options for ", + fmt.reset, + fmt.type, + nameof(P), + fmt.reset, + ": ", + "\033[31m", # Red color + "requires extension ", + ext_names, + "\033[0m", # Reset color + ) + if !is_last_param || !isempty(common_options) + println(io, "│") + end + continue + end + + # Filter computed options for this parameter + param_computed = filter(name -> name in computed_options, keys(meta)) + + if isempty(param_computed) + # No computed options for this parameter + prefix = (is_last_param && isempty(common_options)) ? "└─ " : "├─ " + println( + io, + prefix, + fmt.label, + "computed options for ", + fmt.reset, + fmt.type, + nameof(P), + fmt.reset, + ": ", + fmt.keyword, + "none", + fmt.reset, + ) + if !is_last_param || !isempty(common_options) + println(io, "│") + end + continue + end + + # Display computed options for this parameter + prefix = (is_last_param && isempty(common_options)) ? "└─ " : "├─ " + println( + io, + prefix, + fmt.label, + "computed options for ", + fmt.reset, + fmt.type, + nameof(P), + fmt.reset, + ":", + ) + + param_computed_list = collect(param_computed) + for (j, name) in enumerate(param_computed_list) + is_last_opt = j == length(param_computed_list) + opt_prefix = if is_last_param && isempty(common_options) + is_last_opt ? " └─ " : " ├─ " + else + is_last_opt ? "│ └─ " : "│ ├─ " + end + opt_cont = if is_last_param && isempty(common_options) + is_last_opt ? " " : " │ " + else + is_last_opt ? "│ " : "│ │ " + end + + def = meta[name] + println(io, opt_prefix, def) + _print_labeled_multiline( + io, opt_cont, opt_cont, fmt, "description: ", Options.description(def) + ) + + if !is_last_opt + println(io, opt_cont) + end + end + + if !is_last_param || !isempty(common_options) + println(io, "│") + end + end + + # Display common options last + if !isempty(common_options) + n_common = length(common_options) + println( + io, + "└─ ", + fmt.label, + "common options (", + fmt.reset, + fmt.count, + n_common, + fmt.reset, + " option", + n_common == 1 ? "" : "s", + "):", + ) + + for (i, name) in enumerate(common_options) + is_last = i == length(common_options) + prefix = is_last ? " └─ " : " ├─ " + cont = is_last ? " " : " │ " + + # Use definition from first available parameter + (P, def) = first(option_defs[name]) + println(io, prefix, def) + _print_labeled_multiline( + io, cont, cont, fmt, "description: ", Options.description(def) + ) + + if !is_last + println(io, cont) + end + end + end +end diff --git a/src/Strategies/api/disambiguation.jl b/src/Strategies/api/disambiguation.jl new file mode 100644 index 00000000..1dd661d2 --- /dev/null +++ b/src/Strategies/api/disambiguation.jl @@ -0,0 +1,370 @@ +# ============================================================================ +# Option disambiguation helpers +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Routed Option Type +# ---------------------------------------------------------------------------- + +""" +$(TYPEDEF) + +Routed option value with explicit strategy targeting. + +This type is created by `route_to` to disambiguate options that exist +in multiple strategies. It wraps one or more (strategy_id => value) pairs, +allowing the orchestration layer to route each value to its intended strategy. + +# Fields +- `routes::NamedTuple`: NamedTuple of strategy_id => value mappings + +# Iteration +`RoutedOption` implements the collection interface and can be iterated like a dictionary: +- `keys(opt)`: Strategy IDs +- `values(opt)`: Option values +- `pairs(opt)`: (strategy_id, value) pairs +- `for (id, val) in opt`: Direct iteration over pairs +- `opt[:strategy]`: Index by strategy ID +- `haskey(opt, :strategy)`: Check if strategy exists +- `length(opt)`: Number of routes + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> # Single strategy +julia> opt = route_to(solver=100) +RoutedOption((solver = 100,)) + +julia> # Multiple strategies +julia> opt = route_to(solver=100, modeler=50) +RoutedOption((solver = 100, modeler = 50)) + +julia> # Iterate over routes +julia> for (id, val) in opt + println("\$id => \$val") + end +solver => 100 +modeler => 50 +``` + +See also: `route_to` +""" +struct RoutedOption + routes::NamedTuple + + function RoutedOption(routes::NamedTuple) + if isempty(routes) + throw( + Exceptions.PreconditionError( + "RoutedOption requires at least one route"; + reason="empty routes NamedTuple provided", + suggestion="Use route_to(strategy=value) to create a routed option", + context="RoutedOption constructor precondition", + ), + ) + end + new(routes) + end +end + +""" +$(TYPEDSIGNATURES) + +Validate a NamedTuple of routes and wrap it in a `RoutedOption`. + +Throws `PreconditionError` if `routes` is empty. +""" +function _route_to_from_namedtuple(routes::NamedTuple) + if isempty(routes) + throw( + Exceptions.PreconditionError( + "route_to requires at least one strategy-value pair"; + reason="empty routes NamedTuple provided", + suggestion="Use route_to(solver=100) or route_to(:solver, 100)", + context="route_to - internal helper precondition", + ), + ) + end + return RoutedOption(routes) +end + +""" +$(TYPEDSIGNATURES) + +Create a disambiguated option value by explicitly routing it to specific strategies. + +This function resolves ambiguity when the same option name exists in multiple +strategies (e.g., both modeler and solver have `max_iter`). It creates a +`RoutedOption` that tells the orchestration layer exactly which strategy +should receive which value. + +# Arguments +- `kwargs...`: Named arguments where keys are strategy identifiers (`:solver`, `:modeler`, etc.) + and values are the option values to route to those strategies + +# Returns +- `RoutedOption`: A routed option containing the strategy => value mappings + +# Throws +- `Exceptions.PreconditionError`: If no strategies are provided + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> # Single strategy +julia> route_to(solver=100) +RoutedOption((solver = 100,)) + +julia> # Multiple strategies with different values +julia> route_to(solver=100, modeler=50) +RoutedOption((solver = 100, modeler = 50)) + +julia> # Alternative positional syntax +julia> route_to(:solver, 100, :modeler, 50) +RoutedOption((solver = 100, modeler = 50)) +``` + +# Usage in solve() +```julia +# Without disambiguation - error if max_iter exists in multiple strategies +solve(ocp, method; max_iter=100) # ❌ Ambiguous! + +# With disambiguation - explicit routing (keyword syntax) +solve(ocp, method; + max_iter = route_to(solver=100) # Only solver gets 100 +) + +solve(ocp, method; + max_iter = route_to(solver=100, modeler=50) # Different values for each +) + +# With disambiguation - explicit routing (positional syntax) +solve(ocp, method; + max_iter = route_to(:solver, 100, :modeler, 50) # Different values for each +) +``` + +# Notes +- Strategy identifiers must match the actual strategy IDs in your method tuple +- You can route to one or multiple strategies in a single call +- Alternative positional syntax: `route_to(:solver, 100, :modeler, 50)` +- Both syntaxes are equivalent; choose based on preference +- This is the recommended way to disambiguate options +- The orchestration layer will validate that the strategy IDs exist + +See also: `RoutedOption`, `route_all_options` +""" +function route_to(; kwargs...) + return _route_to_from_namedtuple(NamedTuple(kwargs)) +end + +""" +$(TYPEDSIGNATURES) + +Create a disambiguated option value using positional arguments. + +This is an alternative syntax to the keyword argument version. Accepts +alternating strategy identifier (Symbol) and value pairs. + +# Arguments +- `args::Vararg{Any}`: Alternating strategy_id (Symbol) and value pairs. + Must have an even number of arguments. Odd-numbered arguments must be Symbols. + +# Returns +- `RoutedOption`: A routed option containing the strategy => value mappings + +# Throws +- `Exceptions.PreconditionError`: If no arguments provided, odd number of arguments, + or odd-numbered arguments are not Symbols + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> # Single strategy +julia> route_to(:solver, 100) +RoutedOption((solver = 100,)) + +julia> # Multiple strategies +julia> route_to(:solver, 100, :modeler, 50) +RoutedOption((solver = 100, modeler = 50)) +``` + +# Notes +- This is equivalent to the keyword syntax: `route_to(solver=100, modeler=50)` +- Strategy identifiers must be Symbols (e.g., `:solver`, not `"solver"`) +- The number of arguments must be even (pairs of Symbol-value) + +See also: `route_to(; kwargs...)`, `RoutedOption` +""" +function route_to(args::Vararg{Any}) + # Validate at least one pair + if isempty(args) + throw( + Exceptions.PreconditionError( + "route_to requires at least one strategy-value pair"; + reason="no arguments provided", + suggestion="Use route_to(:solver, 100) or route_to(:solver, 100, :modeler, 50)", + context="route_to - positional syntax precondition", + ), + ) + end + + # Validate even number of arguments (each Symbol must have a value) + if length(args) % 2 != 0 + throw( + Exceptions.PreconditionError( + "route_to requires an even number of arguments (Symbol-value pairs)"; + got="$(length(args)) arguments (odd number)", + expected="even number of arguments (e.g., 2 for one strategy, 4 for two strategies)", + suggestion="Ensure each strategy Symbol has a corresponding value: route_to(:solver, 100) or route_to(:solver, 100, :modeler, 50)", + context="route_to - positional syntax precondition", + ), + ) + end + + # Build NamedTuple from pairs + pairs = NamedTuple() + for i in 1:2:length(args) + strategy_id = args[i] + value = args[i + 1] + + # Validate strategy_id is a Symbol + if !(strategy_id isa Symbol) + throw( + Exceptions.PreconditionError( + "Strategy identifier must be a Symbol"; + got="strategy_id = $strategy_id (type: $(typeof(strategy_id)))", + expected="Symbol (e.g., :solver, :modeler)", + suggestion="Use Symbols for strategy identifiers: route_to(:solver, 100)", + context="route_to - positional syntax precondition", + ), + ) + end + + pairs = merge(pairs, NamedTuple{(strategy_id,)}((value,))) + end + + # Delegate to internal helper + return _route_to_from_namedtuple(pairs) +end + +# ============================================================================ +# Collection Interface for RoutedOption +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Return an iterator over the strategy IDs in the routed option. + +# Example +```julia-repl +julia> opt = route_to(solver=100, modeler=50) +julia> collect(keys(opt)) +2-element Vector{Symbol}: + :solver + :modeler +``` +""" +Base.keys(r::RoutedOption) = keys(r.routes) + +""" +$(TYPEDSIGNATURES) + +Return an iterator over the values in the routed option. + +# Example +```julia-repl +julia> opt = route_to(solver=100, modeler=50) +julia> collect(values(opt)) +2-element Vector{Int64}: + 100 + 50 +``` +""" +Base.values(r::RoutedOption) = values(r.routes) + +""" +$(TYPEDSIGNATURES) + +Return an iterator over (strategy_id => value) pairs. + +# Example +```julia-repl +julia> opt = route_to(solver=100, modeler=50) +julia> for (id, val) in pairs(opt) + println("\$id => \$val") + end +solver => 100 +modeler => 50 +``` +""" +Base.pairs(r::RoutedOption) = pairs(r.routes) + +""" +$(TYPEDSIGNATURES) + +Iterate over (strategy_id => value) pairs. + +This allows direct iteration: `for (id, val) in routed_option`. + +# Example +```julia-repl +julia> opt = route_to(solver=100, modeler=50) +julia> for (id, val) in opt + println("\$id => \$val") + end +solver => 100 +modeler => 50 +``` +""" +Base.iterate(r::RoutedOption, state...) = iterate(pairs(r.routes), state...) + +""" +$(TYPEDSIGNATURES) + +Return the number of routes in the routed option. + +# Example +```julia-repl +julia> opt = route_to(solver=100, modeler=50) +julia> length(opt) +2 +``` +""" +Base.length(r::RoutedOption) = length(r.routes) + +""" +$(TYPEDSIGNATURES) + +Check if a strategy ID exists in the routed option. + +# Example +```julia-repl +julia> opt = route_to(solver=100) +julia> haskey(opt, :solver) +true +julia> haskey(opt, :modeler) +false +``` +""" +Base.haskey(r::RoutedOption, key::Symbol) = haskey(r.routes, key) + +""" +$(TYPEDSIGNATURES) + +Get the value for a specific strategy ID. + +# Example +```julia-repl +julia> opt = route_to(solver=100, modeler=50) +julia> opt[:solver] +100 +julia> opt[:modeler] +50 +``` +""" +Base.getindex(r::RoutedOption, key::Symbol) = r.routes[key] diff --git a/src/Strategies/api/introspection.jl b/src/Strategies/api/introspection.jl new file mode 100644 index 00000000..2f88daf5 --- /dev/null +++ b/src/Strategies/api/introspection.jl @@ -0,0 +1,411 @@ +# ============================================================================ +# Strategy and option introspection API +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Get all option names for a strategy type. + +Returns a tuple of all option names defined in the strategy's metadata. +This is useful for discovering what options are available without needing +to instantiate the strategy. + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type to introspect + +# Returns +- `Tuple{Vararg{Symbol}}`: Tuple of option names + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> option_names(MyStrategy) +(:max_iter, :tol, :backend) + +julia> for name in option_names(MyStrategy) + println("Available option: ", name) + end +Available option: max_iter +Available option: tol +Available option: backend +``` + +# Notes +- This function operates on types, not instances +- If you have an instance, use `option_names(typeof(strategy))` + +See also: `option_type`, `option_description`, `option_default` +""" +function option_names(strategy_type::Type{<:AbstractStrategy}) + meta = metadata(strategy_type) + return Tuple(keys(meta)) +end + +""" +$(TYPEDSIGNATURES) + +Get the expected type for a specific option. + +Returns the Julia type that the option value must satisfy. This is useful +for validation and documentation purposes. + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type +- `key::Symbol`: The option name + +# Returns +- `Type`: The expected type for the option value + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> option_type(MyStrategy, :max_iter) +Int64 + +julia> option_type(MyStrategy, :tol) +Float64 +``` + +# Throws +- `KeyError`: If the option name does not exist + +# Notes +- This function operates on types, not instances +- If you have an instance, use `option_type(typeof(strategy), key)` + +See also: `option_description`, `option_default` +""" +function option_type(strategy_type::Type{<:AbstractStrategy}, key::Symbol) + meta = metadata(strategy_type) + return Options.type(meta[key]) +end + +""" +$(TYPEDSIGNATURES) + +Get the human-readable description for a specific option. + +Returns the documentation string that explains what the option controls. +This is useful for generating help messages and documentation. + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type +- `key::Symbol`: The option name + +# Returns +- `String`: The option description + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> option_description(MyStrategy, :max_iter) +"Maximum number of iterations" + +julia> option_description(MyStrategy, :tol) +"Convergence tolerance" +``` + +# Throws +- `KeyError`: If the option name does not exist + +# Notes +- This function operates on types, not instances +- If you have an instance, use `option_description(typeof(strategy), key)` + +See also: `option_type`, `option_default` +""" +function option_description(strategy_type::Type{<:AbstractStrategy}, key::Symbol) + meta = metadata(strategy_type) + return Options.description(meta[key]) +end + +""" +$(TYPEDSIGNATURES) + +Get the default value for a specific option. + +Returns the value that will be used if the option is not explicitly provided +by the user during strategy construction. + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type +- `key::Symbol`: The option name + +# Returns +- The default value for the option (type depends on the option) + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> option_default(MyStrategy, :max_iter) +100 + +julia> option_default(MyStrategy, :tol) +1.0e-6 +``` + +# Throws +- `KeyError`: If the option name does not exist + +# Notes +- This function operates on types, not instances +- If you have an instance, use `option_default(typeof(strategy), key)` + +See also: `option_defaults`, `option_type` +""" +function option_default(strategy_type::Type{<:AbstractStrategy}, key::Symbol) + meta = metadata(strategy_type) + return Options.default(meta[key]) +end + +""" +$(TYPEDSIGNATURES) + +Get all default values as a NamedTuple. + +Returns a NamedTuple containing the default value for every option defined +in the strategy's metadata. This is useful for resetting configurations or +understanding the baseline behavior. + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type + +# Returns +- `NamedTuple`: All default values keyed by option name + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> option_defaults(MyStrategy) +(max_iter = 100, tol = 1.0e-6) + +julia> defaults = option_defaults(MyStrategy) +julia> defaults.max_iter +100 +``` + +# Notes +- This function operates on types, not instances +- If you have an instance, use `option_defaults(typeof(strategy))` + +See also: `option_default`, `option_names` +""" +function option_defaults(strategy_type::Type{<:AbstractStrategy}) + meta = metadata(strategy_type) + defaults = NamedTuple(key => Options.default(spec) for (key, spec) in pairs(meta)) + return defaults +end + +""" +$(TYPEDSIGNATURES) + +Get the current value of an option from a strategy instance. + +Returns the effective value that the strategy is using for the specified option. +This may be a user-provided value or the default value. + +# Arguments +- `strategy::AbstractStrategy`: The strategy instance +- `key::Symbol`: The option name + +# Returns +- The current option value (type depends on the option) + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> strategy = MyStrategy(max_iter=200) +julia> option_value(strategy, :max_iter) +200 + +julia> option_value(strategy, :tol) # Uses default +1.0e-6 +``` + +# Throws +- `KeyError`: If the option name does not exist + +See also: `option_source`, `options` +""" +function option_value(strategy::AbstractStrategy, key::Symbol) + opts = options(strategy) + return opts[key] +end + +""" +$(TYPEDSIGNATURES) + +Get the source provenance of an option value. + +Returns a symbol indicating where the option value came from: +- `:user` - Explicitly provided by the user +- `:default` - Using the default value from metadata +- `:computed` - Calculated from other options + +# Arguments +- `strategy::AbstractStrategy`: The strategy instance +- `key::Symbol`: The option name + +# Returns +- `Symbol`: The source provenance (`:user`, `:default`, or `:computed`) + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> strategy = MyStrategy(max_iter=200) +julia> option_source(strategy, :max_iter) +:user + +julia> option_source(strategy, :tol) +:default +``` + +# Throws +- `KeyError`: If the option name does not exist + +See also: `option_value`, `is_user`, `is_default` +""" +function option_source(strategy::AbstractStrategy, key::Symbol) + return Options.source(options(strategy), key) +end + +""" +$(TYPEDSIGNATURES) + +Check if an option exists in a strategy instance. + +Returns `true` if the option is present in the strategy's options, +`false` otherwise. This is useful for checking if unknown options +were stored in permissive mode. + +# Arguments +- `strategy::AbstractStrategy`: The strategy instance +- `key::Symbol`: The option name + +# Returns +- `Bool`: `true` if the option exists + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> strategy = MyStrategy(max_iter=200; mode=:permissive, custom_opt=123) +julia> has_option(strategy, :max_iter) +true + +julia> has_option(strategy, :custom_opt) +true + +julia> has_option(strategy, :nonexistent) +false +``` + +See also: `option_value`, `option_source` +""" +function has_option(strategy::AbstractStrategy, key::Symbol) + return haskey(options(strategy), key) +end + +""" +$(TYPEDSIGNATURES) + +Check if an option value was provided by the user. + +Returns `true` if the option was explicitly set by the user during construction, +`false` if it's using the default value or was computed. + +# Arguments +- `strategy::AbstractStrategy`: The strategy instance +- `key::Symbol`: The option name + +# Returns +- `Bool`: `true` if the option source is `:user` + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> strategy = MyStrategy(max_iter=200) +julia> is_user(strategy, :max_iter) +true + +julia> is_user(strategy, :tol) +false +``` + +See also: `is_default`, `is_computed`, `option_source` +""" +function option_is_user(strategy::AbstractStrategy, key::Symbol) + return Options.is_user(options(strategy), key) +end + +""" +$(TYPEDSIGNATURES) + +Check if an option value is using its default. + +Returns `true` if the option is using the default value from metadata, +`false` if it was provided by the user or computed. + +# Arguments +- `strategy::AbstractStrategy`: The strategy instance +- `key::Symbol`: The option name + +# Returns +- `Bool`: `true` if the option source is `:default` + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> strategy = MyStrategy(max_iter=200) +julia> is_default(strategy, :max_iter) +false + +julia> is_default(strategy, :tol) +true +``` + +See also: `is_user`, `is_computed`, `option_source` +""" +function option_is_default(strategy::AbstractStrategy, key::Symbol) + return Options.is_default(options(strategy), key) +end + +""" +$(TYPEDSIGNATURES) + +Check if an option value was computed from other options. + +Returns `true` if the option was calculated based on other option values, +`false` if it was provided by the user or is using the default. + +# Arguments +- `strategy::AbstractStrategy`: The strategy instance +- `key::Symbol`: The option name + +# Returns +- `Bool`: `true` if the option source is `:computed` + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> strategy = MyStrategy() +julia> is_computed(strategy, :derived_value) +true +``` + +See also: `is_user`, `is_default`, `option_source` +""" +function option_is_computed(strategy::AbstractStrategy, key::Symbol) + return Options.is_computed(options(strategy), key) +end diff --git a/src/Strategies/api/registry.jl b/src/Strategies/api/registry.jl new file mode 100644 index 00000000..2064d637 --- /dev/null +++ b/src/Strategies/api/registry.jl @@ -0,0 +1,627 @@ +# ============================================================================ +# Strategy registry for explicit dependency management +# ============================================================================ + +""" +$(TYPEDEF) + +Registry mapping strategy families to their concrete types. + +This type provides an explicit, immutable registry for managing strategy types +organized by family. It enables: +- **Type lookup by ID**: Find concrete types from symbolic identifiers +- **Family introspection**: List all strategies in a family +- **Validation**: Ensure ID uniqueness and type hierarchy correctness + +# Design Philosophy + +The registry uses an **explicit passing pattern** rather than global mutable state: +- Created once via `create_registry` +- Passed explicitly to functions that need it +- Thread-safe (no shared mutable state) +- Testable (easy to create multiple registries) + +# Fields +- `families::Dict{Type{<:AbstractStrategy}, Vector{Type}}`: Maps abstract family types to concrete strategy types + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> registry = create_registry( + AbstractNLPModeler => (Modelers.ADNLP, Modelers.Exa), + AbstractNLPSolver => (Solvers.Ipopt, Solvers.MadNLP) + ) +StrategyRegistry with 2 families + +julia> strategy_ids(AbstractNLPModeler, registry) +(:adnlp, :exa) + +julia> T = type_from_id(:adnlp, AbstractNLPModeler, registry) +Modelers.ADNLP +``` + +See also: `create_registry`, `strategy_ids`, `type_from_id` +""" +struct StrategyRegistry + families::Dict{Type{<:AbstractStrategy},Vector{Type}} + parameters::Dict{Symbol,Type{<:AbstractStrategyParameter}} +end + +""" +$(TYPEDSIGNATURES) + +Create a strategy registry from family-to-strategies mappings. + +This function validates the registry structure and ensures: +- All strategy IDs are unique within each family +- All strategies are subtypes of their declared family +- No duplicate family definitions + +# Arguments +- `pairs...`: Pairs of family type => tuple of strategy types + +# Returns +- `StrategyRegistry`: Validated registry ready for use + +# Validation Rules + +1. **ID Uniqueness**: Within each family, all strategy `id()` values must be unique +2. **Type Hierarchy**: Each strategy must be a subtype of its family +3. **No Duplicates**: Each family can only appear once in the registry + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> registry = create_registry( + AbstractNLPModeler => (Modelers.ADNLP, Modelers.Exa), + AbstractNLPSolver => (Solvers.Ipopt, Solvers.MadNLP, Solvers.Knitro) + ) +StrategyRegistry with 2 families + +julia> strategy_ids(AbstractNLPModeler, registry) +(:adnlp, :exa) +``` + +# Throws +- `ErrorException`: If duplicate IDs are found within a family +- `ErrorException`: If a strategy is not a subtype of its family +- `ErrorException`: If a family appears multiple times + +See also: `StrategyRegistry`, `strategy_ids`, `type_from_id` +""" +function create_registry(pairs::Pair...) + families = Dict{Type{<:AbstractStrategy},Vector{Type}}() + + # IMPORTANT: Collect all strategy IDs for GLOBAL uniqueness validation + # Parameter IDs can be reused across different strategies (same CPU parameter for Exa, MadNLP, etc.) + all_strategy_ids = Set{Symbol}() + + # IMPORTANT: Parameter IDs must be globally unique across parameter types + # (and must not conflict with strategy IDs). + parameter_id_to_type = Dict{Symbol,Type{<:AbstractStrategyParameter}}() + + # Validate that all pairs have the correct structure + for pair in pairs + family, strategies = pair + if !(family isa DataType && family <: AbstractStrategy) + throw( + Exceptions.IncorrectArgument( + "Invalid strategy family type"; + got="family=$family of type $(typeof(family))", + expected="DataType subtype of AbstractStrategy", + suggestion="Use a valid AbstractStrategy subtype as the family type", + context="StrategyRegistry constructor - validating family types", + ), + ) + end + if !(strategies isa Tuple) + throw( + Exceptions.IncorrectArgument( + "Invalid strategies format"; + got="strategies of type $(typeof(strategies))", + expected="Tuple of strategy types or (Type, [Param1, Param2, ...]) tuples", + suggestion="Provide strategies as a tuple, e.g., (Strategy1, Strategy2) or (Strategy, [Param1, Param2])", + context="StrategyRegistry constructor - validating strategies format", + ), + ) + end + end + + for (family, strategy_list) in pairs + # Check for duplicate family + if haskey(families, family) + throw( + Exceptions.IncorrectArgument( + "Duplicate family registration"; + got="family $family already registered", + expected="unique family types in registry", + suggestion="Remove duplicate family or use a different family type", + context="StrategyRegistry constructor - checking family uniqueness", + ), + ) + end + + strategies = Type[] + + for item in strategy_list + if item isa Tuple + # Parameterized strategy: (Type, [Param1, Param2, ...]) + strategy_type, param_types = item + + # Validate strategy type (can be DataType or UnionAll for parameterized types) + if !( + strategy_type isa UnionAll || + (strategy_type isa DataType && strategy_type <: AbstractStrategy) + ) + throw( + Exceptions.IncorrectArgument( + "Invalid strategy type in parameterized tuple"; + got="strategy_type=$strategy_type of type $(typeof(strategy_type))", + expected="UnionAll or DataType subtype of AbstractStrategy", + suggestion="Use a valid AbstractStrategy subtype, e.g., (MyStrategy, [CPU, GPU])", + context="create_registry - validating parameterized strategy type", + ), + ) + end + + # Validate parameter types + if !(param_types isa Tuple || param_types isa Vector) + throw( + Exceptions.IncorrectArgument( + "Invalid parameter types in parameterized tuple"; + got="param_types=$param_types of type $(typeof(param_types))", + expected="Tuple or Vector of parameter types", + suggestion="Use (MyStrategy, [CPU, GPU]) or (MyStrategy, (CPU, GPU))", + context="create_registry - validating parameter types", + ), + ) + end + + # Check GLOBAL uniqueness of strategy ID + strategy_id = id(strategy_type) + if strategy_id in all_strategy_ids + throw( + Exceptions.IncorrectArgument( + "Duplicate ID detected"; + got="ID :$strategy_id used multiple times", + expected="unique IDs across all strategies and parameters", + suggestion="Ensure each strategy and parameter has a unique id()", + context="create_registry - validating global ID uniqueness", + ), + ) + end + push!(all_strategy_ids, strategy_id) + + # Check parameter types and create parameterized types + # Parameters can be reused across different strategies (same CPU for Exa, MadNLP, etc.) + for param_type in param_types + if !(param_type isa DataType && param_type <: AbstractStrategyParameter) + throw( + Exceptions.IncorrectArgument( + "Invalid parameter type"; + got="parameter_type=$param_type of type $(typeof(param_type))", + expected="DataType subtype of AbstractStrategyParameter", + suggestion="Use valid parameter types like CPU, GPU", + context="create_registry - validating parameter type", + ), + ) + end + + # Check that parameter ID doesn't conflict with strategy IDs + param_id = id(param_type) + if param_id in all_strategy_ids + throw( + Exceptions.IncorrectArgument( + "Parameter ID conflicts with strategy ID"; + got="parameter ID :$param_id conflicts with strategy ID", + expected="parameter IDs different from all strategy IDs", + suggestion="Choose different parameter IDs or strategy IDs", + context="create_registry - validating parameter/strategy ID conflicts", + ), + ) + end + + # Check GLOBAL uniqueness of parameter IDs across parameter types + if haskey(parameter_id_to_type, param_id) + existing = parameter_id_to_type[param_id] + if existing != param_type + throw( + Exceptions.IncorrectArgument( + "Duplicate parameter ID detected"; + got="parameter ID :$param_id used by both $existing and $param_type", + expected="unique IDs across all parameter types", + suggestion="Ensure each parameter type has a unique id()", + context="create_registry - validating global parameter ID uniqueness", + ), + ) + end + else + parameter_id_to_type[param_id] = ( + param_type::Type{<:AbstractStrategyParameter} + ) + end + + # Create parameterized strategy type + push!(strategies, strategy_type{param_type}) + end + else + # Non-parameterized strategy: Type (can be UnionAll for parameterized types with default) + strategy_type = item + + if !( + strategy_type isa UnionAll || + (strategy_type isa DataType && strategy_type <: AbstractStrategy) + ) + throw( + Exceptions.IncorrectArgument( + "Invalid strategy type"; + got="strategy_type=$strategy_type of type $(typeof(strategy_type))", + expected="UnionAll or DataType subtype of AbstractStrategy", + suggestion="Use a valid AbstractStrategy subtype", + context="create_registry - validating strategy type", + ), + ) + end + + # Check GLOBAL uniqueness of strategy ID + strategy_id = id(strategy_type) + if strategy_id in all_strategy_ids + throw( + Exceptions.IncorrectArgument( + "Duplicate ID detected"; + got="ID :$strategy_id used multiple times", + expected="unique IDs across all strategies and parameters", + suggestion="Ensure each strategy and parameter has a unique id()", + context="create_registry - validating global ID uniqueness", + ), + ) + end + push!(all_strategy_ids, strategy_id) + push!(strategies, strategy_type) + end + end + + # Validate all strategies are subtypes of family + for T in strategies + if !(T <: family) + throw( + Exceptions.IncorrectArgument( + "Strategy type not compatible with family"; + got="strategy type $T", + expected="subtype of family $family", + suggestion="Ensure strategy type $T is properly defined as <: $family", + context="StrategyRegistry constructor - validating strategy-family relationships", + ), + ) + end + end + + families[family] = strategies + end + + return StrategyRegistry(families, parameter_id_to_type) +end + +""" +$(TYPEDSIGNATURES) + +Get all strategy IDs for a given family. + +Returns a tuple of symbolic identifiers for all strategies registered under +the specified family type. The order matches the registration order. + +# Arguments +- `family::Type{<:AbstractStrategy}`: The abstract family type +- `registry::StrategyRegistry`: The registry to query + +# Returns +- `Tuple{Vararg{Symbol}}`: Tuple of strategy IDs in registration order + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> ids = strategy_ids(AbstractNLPModeler, registry) +(:adnlp, :exa) + +julia> for strategy_id in ids + println("Available: ", strategy_id) + end +Available: adnlp +Available: exa +``` + +# Throws +- `ErrorException`: If the family is not found in the registry + +See also: `type_from_id`, `create_registry` +""" +function strategy_ids(family::Type{<:AbstractStrategy}, registry::StrategyRegistry) + if !haskey(registry.families, family) + available_families = collect(keys(registry.families)) + throw( + Exceptions.IncorrectArgument( + "Strategy family not found in registry"; + got="family $family", + expected="one of registered families: $available_families", + suggestion="Check available families or register the missing family first", + context="strategy_ids - looking up family in registry", + ), + ) + end + strategies = registry.families[family] + + # Deduplicate IDs (important for parameterized strategies) + seen = Set{Symbol}() + ids = Symbol[] + for T in strategies + s_id = id(T) + if s_id ∉ seen + push!(seen, s_id) + push!(ids, s_id) + end + end + return Tuple(ids) +end + +""" +$(TYPEDSIGNATURES) + +Lookup a strategy type from its ID within a family. + +Searches the registry for a strategy with the given symbolic identifier within +the specified family. This is the core lookup mechanism used by the builder +functions to convert symbolic descriptions to concrete types. + +# Arguments +- `strategy_id::Symbol`: The symbolic identifier to look up +- `family::Type{<:AbstractStrategy}`: The family to search within +- `registry::StrategyRegistry`: The registry to query + +# Returns +- `Type{<:AbstractStrategy}`: The concrete strategy type matching the ID + +# Example +```julia-repl +julia> using CTBase.Strategies + +julia> T = type_from_id(:adnlp, AbstractNLPModeler, registry) +Modelers.ADNLP + +julia> id(T) +:adnlp +``` + +# Throws +- `Exceptions.IncorrectArgument`: If the family is not found in the registry +- `Exceptions.IncorrectArgument`: If the ID is not found within the family (includes suggestions) + +See also: `strategy_ids`, `build_strategy` +""" +function type_from_id( + strategy_id::Symbol, + family::Type{<:AbstractStrategy}, + registry::StrategyRegistry; + parameter::Union{Type{<:AbstractStrategyParameter},Nothing}=nothing, +) + if !haskey(registry.families, family) + available_families = collect(keys(registry.families)) + throw( + Exceptions.IncorrectArgument( + "Strategy family not found in registry"; + got="family $family", + expected="one of registered families: $available_families", + suggestion="Check available families or register the missing family first", + context="type_from_id - looking up family in registry", + ), + ) + end + + for T in registry.families[family] + if id(T) === strategy_id + if parameter === nothing || get_parameter_type(T) == parameter + return T + end + end + end + + # Not found - provide helpful error with available options + available = strategy_ids(family, registry) + if parameter !== nothing + # More specific error for parameterized search + param_strategies = [T for T in registry.families[family] if id(T) === strategy_id] + if isempty(param_strategies) + throw( + Exceptions.IncorrectArgument( + "Unknown strategy ID"; + got=":$strategy_id for family $family", + expected="one of available IDs: $available", + suggestion="Check available strategy IDs or register the missing strategy", + context="type_from_id - looking up strategy ID in family", + ), + ) + else + available_params = [ + get_parameter_type(T) for + T in param_strategies if get_parameter_type(T) !== nothing + ] + throw( + Exceptions.IncorrectArgument( + "Strategy not found with specified parameter - check available parameters"; + got="strategy :$strategy_id with parameter $parameter", + expected="strategy :$strategy_id with one of: $available_params", + suggestion="Check available parameters in the registry or use a non-parameterized version", + context="type_from_id - looking up parameterized strategy", + ), + ) + end + else + throw( + Exceptions.IncorrectArgument( + "Unknown strategy ID"; + got=":$strategy_id for family $family", + expected="one of available IDs: $available", + suggestion="Check available strategy IDs or register the missing strategy", + context="type_from_id - looking up strategy ID in family", + ), + ) + end +end + +# Display +function Base.show(io::IO, registry::StrategyRegistry) + fmt = Core.get_format_codes(io) + n_families = length(registry.families) + print( + io, + fmt.name, + "StrategyRegistry", + fmt.reset, + " with ", + fmt.count, + n_families, + fmt.reset, + " ", + n_families == 1 ? "family" : "families", + ) +end + +function Base.show(io::IO, ::MIME"text/plain", registry::StrategyRegistry) + fmt = Core.get_format_codes(io) + n_families = length(registry.families) + n_params = length(registry.parameters) + has_params = n_params > 0 + + # Header: "StrategyRegistry with N families and M parameters:" + print( + io, + fmt.name, "StrategyRegistry", fmt.reset, + " with ", + fmt.count, n_families, fmt.reset, " ", n_families == 1 ? "family" : "families", + ) + if has_params + print( + io, + " and ", + fmt.count, n_params, fmt.reset, " ", n_params == 1 ? "parameter" : "parameters", + ) + end + println(io, ":") + + items = collect(registry.families) + for (i, (family, strategies)) in enumerate(items) + # A family is "last" (uses └─) only when it is the last family AND no parameters follow + is_last_family = i == length(items) && !has_params + family_prefix = is_last_family ? "└─ " : "├─ " + println(io, family_prefix, fmt.name, nameof(family), fmt.reset) + + # Group strategies by ID, preserving registration order + seen_ids = Symbol[] + id_to_types = Dict{Symbol,Vector{Type}}() + for T in strategies + strategy_id = id(T) + if !haskey(id_to_types, strategy_id) + id_to_types[strategy_id] = [] + push!(seen_ids, strategy_id) + end + push!(id_to_types[strategy_id], T) + end + + # Display each unique strategy ID on one line + for (j, strategy_id) in enumerate(seen_ids) + types = id_to_types[strategy_id] + is_last_strategy = j == length(seen_ids) + strategy_prefix = if is_last_family + is_last_strategy ? " └─ " : " ├─ " + else + is_last_strategy ? "│ └─ " : "│ ├─ " + end + + # Base type name (without parameter) + base_name = string(nameof(first(types))) + + # Collect parameter types if any + params = filter(!isnothing, [get_parameter_type(T) for T in types]) + + if isempty(params) + println( + io, + strategy_prefix, + fmt.type, base_name, fmt.reset, + " (", fmt.label, "id=", fmt.reset, fmt.keyword, ":", strategy_id, fmt.reset, ")", + ) + else + # Show parameter routing keys as symbols: [:cpu, :gpu] + param_str = join( + [fmt.keyword * ":" * string(id(P)) * fmt.reset for P in params], ", " + ) + println( + io, + strategy_prefix, + fmt.type, base_name, fmt.reset, + " (", fmt.label, "id=", fmt.reset, fmt.keyword, ":", strategy_id, fmt.reset, + ") [", param_str, "]", + ) + end + end + end + + # Parameters section as last tree entry: "└─ parameters: :cpu → CPU, :gpu → GPU" + if has_params + param_items = sort(collect(registry.parameters); by=p -> string(p[1])) + param_str = join( + [ + fmt.keyword * ":" * string(p_id) * fmt.reset * + " → " * + fmt.type * string(nameof(p_type)) * fmt.reset + for (p_id, p_type) in param_items + ], + ", ", + ) + println(io, "└─ ", fmt.label, "parameters: ", fmt.reset, param_str) + end +end + +""" +$(TYPEDSIGNATURES) + +Extract the parameter type from a parameterized strategy type. + +For parameterized strategies like `MadNLP{CPU}`, this returns the parameter type `CPU`. +For non-parameterized strategies, this returns `nothing`. + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type to extract parameter from + +# Returns +- `Union{Type{<:AbstractStrategyParameter}, Nothing}`: Parameter type or `nothing` if non-parameterized + +# Examples +```julia-repl +julia> get_parameter_type(MadNLP{CPU}) +CPU + +julia> get_parameter_type(MadNLP{GPU}) +GPU + +julia> get_parameter_type(Ipopt) +nothing +``` +""" +function get_parameter_type(strategy_type::Type) + # For parameterized strategies like MadNLP{CPU}, extract the parameter type + # Check if this type has parameters by examining its type parameters + try + # Try to get the first type parameter + param_type = strategy_type.parameters[1] + if param_type <: AbstractStrategyParameter + return param_type + end + catch e + # No parameters or error accessing parameters + end + + return nothing +end diff --git a/src/Strategies/api/utilities.jl b/src/Strategies/api/utilities.jl new file mode 100644 index 00000000..caf02e60 --- /dev/null +++ b/src/Strategies/api/utilities.jl @@ -0,0 +1,253 @@ +# ============================================================================ +# Strategy utilities and helper functions +# ============================================================================ + +using DocStringExtensions + +""" +$(TYPEDSIGNATURES) + +Filter a NamedTuple by excluding specified keys. + +# Arguments +- `nt::NamedTuple`: NamedTuple to filter +- `exclude::Symbol`: Single key to exclude + +# Returns +- `NamedTuple`: New NamedTuple without the excluded key + +# Example +```julia-repl +julia> opts = (max_iter=100, tol=1e-6, debug=true) +julia> filter_options(opts, :debug) +(max_iter = 100, tol = 1.0e-6) +``` + +See also: `filter_options(::NamedTuple, ::Tuple)` +""" +function filter_options(nt::NamedTuple, exclude::Symbol) + return filter_options(nt, (exclude,)) +end + +""" +$(TYPEDSIGNATURES) + +Filter a NamedTuple by excluding specified keys. + +# Arguments +- `nt::NamedTuple`: NamedTuple to filter +- `exclude::Tuple{Vararg{Symbol}}`: Tuple of keys to exclude + +# Returns +- `NamedTuple`: New NamedTuple without the excluded keys + +# Example +```julia-repl +julia> opts = (max_iter=100, tol=1e-6, debug=true) +julia> filter_options(opts, (:debug, :tol)) +(max_iter = 100,) +``` + +See also: `filter_options(::NamedTuple, ::Symbol)` +""" +function filter_options(nt::NamedTuple, exclude::Tuple{Vararg{Symbol}}) + exclude_set = Set(exclude) + filtered_pairs = [key => value for (key, value) in pairs(nt) if key ∉ exclude_set] + return NamedTuple(filtered_pairs) +end + +""" +$(TYPEDSIGNATURES) + +Extract strategy options as a mutable Dict, ready for modification. + +This is a convenience method that combines two steps into one: +1. Getting `StrategyOptions` from the strategy +2. Converting to `Dict` via `options_dict(StrategyOptions)` + +# Arguments +- `strategy::AbstractStrategy`: Strategy instance (solver, modeler, etc.) + +# Returns +- `Dict{Symbol, Any}`: Mutable dictionary of option values + +# Example +```julia-repl +julia> using CTBase + +julia> solver = FakeSolver(max_iter=1000, tol=1e-8) + +julia> opts = Strategies.options_dict(solver) +Dict{Symbol, Any} with 2 entries: + :max_iter => 1000 + :tol => 1.0e-8 + +julia> opts[:max_iter] = 500 # Modify as needed +500 + +julia> solve_with_backend(nlp; opts...) +``` + +# Notes +This function delegates to `options_dict(StrategyOptions)` for the actual conversion. +It is particularly useful in solver extensions and modelers where you need to extract +options and potentially modify them before passing to backend solvers or model builders. + +See also: `options`, `options_dict(::StrategyOptions)` +""" +function options_dict(strategy::AbstractStrategy) + return options_dict(options(strategy)) +end + +""" +$(TYPEDSIGNATURES) + +Suggest similar option names for an unknown key using Levenshtein distance. + +For each option, the distance is the minimum over the primary name and all its aliases. +Results are grouped by primary option name and sorted by this minimum distance. + +# Arguments +- `key::Symbol`: Unknown key to find suggestions for +- `strategy_type::Type{<:AbstractStrategy}`: Strategy type to search in +- `max_suggestions::Int=3`: Maximum number of suggestions to return + +# Returns +- `Vector{@NamedTuple{primary::Symbol, aliases::Tuple{Vararg{Symbol}}, distance::Int}}`: + Suggested options sorted by distance (closest first), each with primary name, aliases, and distance. + +# Example +```julia-repl +julia> suggest_options(:max_it, MyStrategy) +1-element Vector{...}: + (primary = :max_iter, aliases = (), distance = 2) + +julia> suggest_options(:adnlp_backen, MyStrategy) +1-element Vector{...}: + (primary = :backend, aliases = (:adnlp_backend,), distance = 1) +``` + +# Note +The distance of an option to the key is `min(dist(key, primary), dist(key, alias1), ...)`. +This ensures that options with a close alias are suggested even if the primary name is far. + +See also: `resolve_alias`, `levenshtein_distance` +""" +function suggest_options( + key::Symbol, strategy_type::Type{<:AbstractStrategy}; max_suggestions::Int=3 +) + meta = metadata(strategy_type) + return suggest_options(key, meta; max_suggestions=max_suggestions) +end + +""" +$(TYPEDSIGNATURES) + +Suggest similar option names from a `StrategyMetadata` using Levenshtein distance. + +See `suggest_options(::Symbol, ::Type{<:AbstractStrategy})` for details. +""" +function suggest_options(key::Symbol, meta::StrategyMetadata; max_suggestions::Int=3) + key_str = string(key) + + # For each option, compute min distance over primary name + aliases + results = NamedTuple{ + (:primary, :aliases, :distance),Tuple{Symbol,Tuple{Vararg{Symbol}},Int} + }[] + for (primary_name, def) in pairs(meta) + # Distance to primary name + min_dist = levenshtein_distance(key_str, string(primary_name)) + # Distance to each alias + for alias in def.aliases + d = levenshtein_distance(key_str, string(alias)) + min_dist = min(min_dist, d) + end + push!(results, (primary=primary_name, aliases=def.aliases, distance=min_dist)) + end + + # Sort by distance, then take top suggestions + sort!(results; by=x -> x.distance) + n = min(max_suggestions, length(results)) + return results[1:n] +end + +""" +$(TYPEDSIGNATURES) + +Format a suggestion entry as a human-readable string. + +# Example +```julia-repl +julia> format_suggestion((primary=:backend, aliases=(:adnlp_backend,), distance=1)) +":backend (alias: adnlp_backend) [distance: 1]" +``` +""" +function format_suggestion(s::NamedTuple) + str = ":$(s.primary)" + if !isempty(s.aliases) + alias_label = length(s.aliases) == 1 ? "alias" : "aliases" + str *= " ($alias_label: $(join(s.aliases, ", ")))" + end + str *= " [distance: $(s.distance)]" + return str +end + +""" +$(TYPEDSIGNATURES) + +Compute the Levenshtein distance between two strings. + +The Levenshtein distance is the minimum number of single-character edits +(insertions, deletions, or substitutions) required to change one string into another. + +# Arguments +- `s1::String`: First string +- `s2::String`: Second string + +# Returns +- `Int`: Levenshtein distance between the two strings + +# Example +```julia-repl +julia> levenshtein_distance("kitten", "sitting") +3 + +julia> levenshtein_distance("max_iter", "max_it") +2 +``` + +# Algorithm +Uses dynamic programming with O(m*n) time and space complexity, +where m and n are the lengths of the input strings. + +See also: `suggest_options` +""" +function levenshtein_distance(s1::String, s2::String) + m, n = length(s1), length(s2) + d = zeros(Int, m + 1, n + 1) + + # Initialize base cases + for i in 0:m + d[i + 1, 1] = i + end + for j in 0:n + d[1, j + 1] = j + end + + # Fill the matrix + for j in 1:n + for i in 1:m + if s1[i] == s2[j] + d[i + 1, j + 1] = d[i, j] # No operation needed + else + d[i + 1, j + 1] = min( + d[i, j + 1] + 1, # deletion + d[i + 1, j] + 1, # insertion + d[i, j] + 1, # substitution + ) + end + end + end + + return d[m + 1, n + 1] +end diff --git a/src/Strategies/api/validation_helpers.jl b/src/Strategies/api/validation_helpers.jl new file mode 100644 index 00000000..c283521c --- /dev/null +++ b/src/Strategies/api/validation_helpers.jl @@ -0,0 +1,113 @@ +# ============================================================================ +# Validation helper functions for strict/permissive mode +# ============================================================================ + +using DocStringExtensions + +""" +$(TYPEDSIGNATURES) + +Throw an error for unknown options in strict mode. + +This function generates a detailed error message that includes: +- List of unrecognized options +- Available options from metadata +- Suggestions based on Levenshtein distance +- Guidance on using permissive mode + +# Arguments +- `remaining::NamedTuple`: Unknown options provided by user +- `strategy_type::Type{<:AbstractStrategy}`: Strategy type being configured +- `meta::StrategyMetadata`: Strategy metadata with option definitions + +# Throws +- `Exceptions.IncorrectArgument`: Always throws with detailed error message + +# Example +```julia +# Internal use only - called by build_strategy_options() +_error_unknown_options_strict((unknown_opt=123,), Solvers.Ipopt, meta) +``` + +See also: `build_strategy_options`, `suggest_options` +""" +function _error_unknown_options_strict( + remaining::NamedTuple, strategy_type::Type{<:AbstractStrategy}, meta::StrategyMetadata +) + unknown_keys = collect(keys(remaining)) + strategy_name = string(nameof(strategy_type)) + + # Build list of available options + available_keys = sort(collect(keys(meta))) + available_str = join([" :$k" for k in available_keys], ", ") + + # Generate suggestions for each unknown key + suggestions_str = "" + for key in unknown_keys + suggestions = suggest_options(key, strategy_type; max_suggestions=3) + if !isempty(suggestions) + suggestions_str *= "\nSuggestions for :$key:\n" + for s in suggestions + suggestions_str *= " - $(format_suggestion(s))\n" + end + end + end + + # Build complete error message + message = """ + Unknown options provided for $strategy_name + + Unrecognized options: $unknown_keys + + These options are not defined in the metadata of $strategy_name. + + Available options: + $available_str + $suggestions_str + If you are certain these options exist for the backend, + use permissive mode: + $strategy_name(...; mode=:permissive) + """ + + throw( + Exceptions.IncorrectArgument( + message; context="build_strategy_options - strict validation" + ), + ) +end + +""" +$(TYPEDSIGNATURES) + +Warn about unknown options in permissive mode. + +This function generates a warning message that informs the user that +unvalidated options will be passed directly to the backend without validation. + +# Arguments +- `remaining::NamedTuple`: Unknown options provided by user +- `strategy_type::Type{<:AbstractStrategy}`: Strategy type being configured + +# Example +```julia +# Internal use only - called by build_strategy_options() +_warn_unknown_options_permissive((custom_opt=123,), Solvers.Ipopt) +``` + +See also: `build_strategy_options`, `_error_unknown_options_strict` +""" +function _warn_unknown_options_permissive( + remaining::NamedTuple, strategy_type::Type{<:AbstractStrategy} +) + unknown_keys = collect(keys(remaining)) + strategy_name = string(nameof(strategy_type)) + + @warn """ + Unrecognized options passed to backend + + Unvalidated options: $unknown_keys + + These options will be passed directly to the $strategy_name backend + without validation by CTBase. Ensure they are correct. + """ +end diff --git a/src/Strategies/contract/abstract_strategy.jl b/src/Strategies/contract/abstract_strategy.jl new file mode 100644 index 00000000..956f9952 --- /dev/null +++ b/src/Strategies/contract/abstract_strategy.jl @@ -0,0 +1,630 @@ +""" +$(TYPEDEF) + +Abstract base type for all strategies in the control-toolbox ecosystem. + +Every concrete strategy must implement a **two-level contract** separating static type metadata from dynamic instance configuration. + +## Contract Overview + +### Type-Level Contract (Static Metadata) + +Methods defined on the **type** that describe what the strategy can do: + +- `id(::Type{<:MyStrategy})::Symbol` - Unique identifier for routing and introspection +- `metadata(::Type{<:MyStrategy})::StrategyMetadata` - Option specifications and validation rules + +**Why type-level?** These methods enable: +- **Introspection without instantiation** - Query capabilities without creating objects +- **Routing and dispatch** - Select strategies by symbol for automated construction +- **Validation before construction** - Verify compatibility before resource allocation + +### Instance-Level Contract (Configured State) + +Methods defined on **instances** that provide the actual configuration: + +- `options(strategy::MyStrategy)::StrategyOptions` - Current option values with provenance tracking + +**Why instance-level?** These methods enable: +- **Multiple configurations** - Different instances with different settings +- **Provenance tracking** - Know which options came from user vs defaults +- **Encapsulation** - Configuration state belongs to the executing object + +## Implementation Requirements + +Every concrete strategy must provide: + +1. **Type definition** with an `options::StrategyOptions` field (recommended) +2. **Type-level methods** for `id` and `metadata` +3. **Constructor** accepting keyword arguments (uses `build_strategy_options`) +4. **Instance-level access** to configured options + +## Validation Modes + +The strategy system supports two validation modes for option handling: + +- **Strict Mode (default)**: Rejects unknown options with detailed error messages + - Provides early error detection and safety + - Suggests corrections for typos using Levenshtein distance + - Ideal for development and production environments + +- **Permissive Mode**: Accepts unknown options with warnings + - Allows backend-specific options without breaking changes + - Maintains validation for known options (types, custom validators) + - Ideal for advanced users and experimental features + +The validation mode is controlled by the `mode` parameter in constructors: + +```julia-repl +# Strict mode (default) - rejects unknown options +julia> MyStrategy(unknown_option=123) # ERROR + +# Permissive mode - accepts unknown options with warning +julia> MyStrategy(unknown_option=123; mode=:permissive) # WARNING but works +``` + +## API Methods + +The Strategies module provides these methods for working with strategies: + +- `id(strategy_type)` - Get the unique identifier +- `metadata(strategy_type)` - Get option specifications +- `options(strategy)` - Get current configuration +- `build_strategy_options(Type; mode=:strict, kwargs...)` - Validate and merge options + +# Example + +```julia-repl +# Define strategy type +julia> struct MyStrategy <: AbstractStrategy + options::StrategyOptions + end + +# Implement type-level contract +julia> id(::Type{<:MyStrategy}) = :mystrategy +julia> metadata(::Type{<:MyStrategy}) = StrategyMetadata( + OptionDefinition(name=:max_iter, type=Int, default=100, description="Max iterations") + ) + +# Implement constructor (required) +julia> function MyStrategy(; mode::Symbol=:strict, kwargs...) + options = build_strategy_options(MyStrategy; mode=mode, kwargs...) + return MyStrategy(options) + end + +# Use the strategy +julia> strategy = MyStrategy(max_iter=200) # Instance with custom config (strict mode) +julia> id(typeof(strategy)) # => :mystrategy (type-level) +julia> options(strategy) # => StrategyOptions (instance-level) + +# Use with permissive mode for unknown options +julia> strategy = MyStrategy(max_iter=200, custom_option=123; mode=:permissive) +``` + +# Notes + +- **Type-level methods** are called on the type: `id(MyStrategy)` +- **Instance-level methods** are called on instances: `options(strategy)` +- **Constructor pattern** is required for registry-based construction +- **Strategy families** can be created with intermediate abstract types +""" +abstract type AbstractStrategy end + +""" +$(TYPEDSIGNATURES) + +Return the unique identifier for this strategy type. + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type + +# Returns +- `Symbol`: Unique identifier for the strategy + +# Example +```julia-repl +# For a concrete strategy type MyStrategy: +julia> id(MyStrategy) +:mystrategy +``` +""" +function id end + +""" +$(TYPEDSIGNATURES) + +Return the current options of a strategy as a StrategyOptions. + +# Arguments +- `strategy::AbstractStrategy`: The strategy instance + +# Returns +- `StrategyOptions`: Current option values with provenance tracking + +# Example +```julia-repl +# For a concrete strategy instance: +julia> strategy = MyStrategy(backend=:sparse) +julia> opts = options(strategy) +julia> opts +StrategyOptions with values=(backend=:sparse), sources=(backend=:user) +``` +""" +function options end + +""" +$(TYPEDSIGNATURES) + +Return metadata about a strategy type. + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type + +# Returns +- `StrategyMetadata`: Option specifications and validation rules + +# Example +```julia-repl +# For a concrete strategy type MyStrategy: +julia> meta = metadata(MyStrategy) +julia> meta +StrategyMetadata with option definitions for max_iter, etc. +``` +""" +function metadata end + +# ============================================================================ +# Default implementations that error if not overridden +# ============================================================================ + +# These default implementations enforce the contract by throwing helpful error +# messages when concrete strategies don't implement required methods. + +""" +Default implementation for `id(::Type{T})` that throws `NotImplemented`. + +This ensures that any concrete strategy type must explicitly implement +the `id` method to provide its unique identifier. + +# Throws + +- `Exceptions.NotImplemented`: When the concrete type doesn't override this method +""" +function id(::Type{T}) where {T<:AbstractStrategy} + throw( + Exceptions.NotImplemented( + "Strategy ID method not implemented"; + required_method="id(::Type{<:$T})", + suggestion="Implement id(::Type{<:$T}) to return a unique Symbol identifier", + context="AbstractStrategy.id - required method implementation", + ), + ) +end + +""" +Default implementation for `metadata(::Type{T})` that throws `NotImplemented`. + +This ensures that any concrete strategy type must explicitly implement +the `metadata` method to provide its option specifications. + +The error message reminds developers to return a `StrategyMetadata` wrapping +a `Dict` of `OptionDefinition` objects. + +# Throws + +- `Exceptions.NotImplemented`: When the concrete type doesn't override this method +""" +function metadata(::Type{T}) where {T<:AbstractStrategy} + throw( + Exceptions.NotImplemented( + "Strategy metadata method not implemented"; + required_method="metadata(::Type{<:$T})", + suggestion="Implement metadata(::Type{<:$T}) to return StrategyMetadata with OptionDefinitions", + context="AbstractStrategy.metadata - required method implementation", + ), + ) +end + +""" +Default implementation for `options(strategy::T)` with flexible field access. + +This implementation supports two common patterns for strategy types: + +1. **Field-based (recommended)**: Strategy has an `options::StrategyOptions` field +2. **Custom getter**: Strategy implements its own `options()` method + +If the strategy type has an `options` field, this implementation returns it. +Otherwise, it throws a `NotImplemented` error to indicate that the concrete +type must implement its own getter. + +# Arguments +- `strategy::T`: The strategy instance + +# Returns +- `StrategyOptions`: The configured options for the strategy + +# Throws + +- `Exceptions.NotImplemented`: When the strategy has no `options` field and doesn't + implement a custom `options()` method +""" +function options(strategy::T) where {T<:AbstractStrategy} + if hasfield(T, :options) + # Recommended pattern: direct field access for performance + return getfield(strategy, :options) + else + # Fallback: require custom implementation for complex internal structures + throw( + Exceptions.NotImplemented( + "Strategy options method not implemented"; + required_method="options(strategy::$T)", + suggestion="Add options::StrategyOptions field to strategy type or implement custom options() method", + context="AbstractStrategy.options - required method implementation", + ), + ) + end +end + +# ============================================================================ +# Collection Interface - Delegation to StrategyOptions +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Get the value of a strategy option (without source information). + +This method delegates to the underlying `StrategyOptions` collection, providing +convenient bracket notation access to strategy options. Aliases are automatically +resolved to canonical names. + +# Arguments +- `strategy::AbstractStrategy`: The strategy instance +- `key::Symbol`: Option name (canonical or alias) + +# Returns +- The unwrapped option value + +# Example +```julia-repl +julia> modeler = Modelers.ADNLP(backend=:sparse, max_iter=1000) + +julia> modeler[:max_iter] # Canonical name +1000 + +julia> modeler[:maxiter] # Alias - automatically resolved +1000 +``` + +# Notes +- This is syntactic sugar for `options(strategy)[key]` +- All functionality (alias resolution, provenance tracking) is handled by StrategyOptions +- Use `options(strategy)` for full access to OptionValue objects with source information + +See also: [`options`](@ref), [`Base.haskey`](@ref), [`Base.keys`](@ref), [`CTBase.Strategies.StrategyOptions`](@extref) +""" +function Base.getindex(strategy::AbstractStrategy, key::Symbol) + return options(strategy)[key] +end + +""" +$(TYPEDSIGNATURES) + +Check if a strategy option exists. + +This method delegates to the underlying `StrategyOptions` collection. Aliases are +automatically resolved to canonical names. + +# Arguments +- `strategy::AbstractStrategy`: The strategy instance +- `key::Symbol`: Option name to check (canonical or alias) + +# Returns +- `Bool`: `true` if the option exists + +# Example +```julia-repl +julia> modeler = Modelers.ADNLP(backend=:sparse) + +julia> haskey(modeler, :max_iter) +true + +julia> haskey(modeler, :maxiter) # Alias - automatically resolved +true + +julia> haskey(modeler, :nonexistent) +false +``` + +# Notes +- This is syntactic sugar for `haskey(options(strategy), key)` +- Aliases are automatically resolved to canonical names + +See also: [`options`](@ref), [`Base.getindex`](@ref), [`Base.keys`](@ref), [`CTBase.Strategies.StrategyOptions`](@extref) +""" +function Base.haskey(strategy::AbstractStrategy, key::Symbol) + return haskey(options(strategy), key) +end + +""" +$(TYPEDSIGNATURES) + +Get all option names for a strategy. + +This method delegates to the underlying `StrategyOptions` collection, providing +access to all option names (canonical names only, not aliases). + +# Arguments +- `strategy::AbstractStrategy`: The strategy instance + +# Returns +- Iterator of option names (Symbols) + +# Example +```julia-repl +julia> modeler = Modelers.ADNLP(backend=:sparse, max_iter=1000) + +julia> collect(keys(modeler)) +[:backend, :max_iter, :matrix_free, :show_time, :name] +``` + +# Notes +- This is syntactic sugar for `keys(options(strategy))` +- Returns canonical names only (not aliases) + +See also: [`options`](@ref), [`Base.getindex`](@ref), [`Base.haskey`](@ref), [`CTBase.Strategies.StrategyOptions`](@extref) +""" +function Base.keys(strategy::AbstractStrategy) + return keys(options(strategy)) +end + +# ============================================================================ +# Display - Instance +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Pretty display of a strategy instance with tree-style formatting. + +Shows the concrete type name, strategy id, and all configured options +with their values and provenance sources. + +# Arguments +- `io::IO`: Output stream +- `::MIME"text/plain"`: MIME type for pretty printing +- `strategy::AbstractStrategy`: The strategy instance to display + +# Example +```julia-repl +julia> FakeSolver() +FakeSolver (instance, id: :fake_solver) +├─ max_iter = 1000 [default] +└─ tol = 1.0e-8 [default] +Tip: use describe(FakeSolver) to see all available options. +``` + +See also: `describe`, `options` +""" +function Base.show(io::IO, ::MIME"text/plain", strategy::T) where {T<:AbstractStrategy} + fmt = Core.get_format_codes(io) + type_name = nameof(T) + strategy_id = id(T) + opts = options(strategy) + + # Build display name: include parameter type when present (e.g. FakeOptimizer{CPU}) + param = get_parameter_type(T) + display_name = + param === nothing ? string(type_name) : string(type_name, "{", nameof(param), "}") + + # Header with ID on first line + println( + io, + fmt.name, + display_name, + fmt.reset, + " (instance, id=", + fmt.keyword, + ":", + strategy_id, + fmt.reset, + ")", + ) + + items = collect(pairs(opts.options)) + for (i, (key, opt)) in enumerate(items) + is_last = i == length(items) + prefix = is_last ? "└─ " : "├─ " + println( + io, + prefix, + fmt.name, + key, + fmt.reset, + " = ", + fmt.value, + Options.value(opt), + fmt.reset, + " [", + fmt.label, + Options.source(opt), + fmt.reset, + "]", + ) + end + + println( + io, + fmt.label, + "Tip: use describe(", + type_name, + ") to see all available options.", + fmt.reset, + ) +end + +""" +$(TYPEDSIGNATURES) + +Compact display of a strategy instance. + +# Arguments +- `io::IO`: Output stream +- `strategy::AbstractStrategy`: The strategy instance to display + +# Example +```julia-repl +julia> print(FakeSolver()) +FakeSolver(max_iter=1000, tol=1.0e-8) +``` + +See also: `Base.show(::IO, ::MIME"text/plain", ::AbstractStrategy)` +""" +function Base.show(io::IO, strategy::T) where {T<:AbstractStrategy} + fmt = Core.get_format_codes(io) + type_name = nameof(T) + opts = options(strategy) + + param = get_parameter_type(T) + display_name = + param === nothing ? string(type_name) : string(type_name, "{", nameof(param), "}") + + print(io, fmt.name, display_name, fmt.reset, "(") + print( + io, + join( + ( + fmt.name * + "$k" * + fmt.reset * + "=" * + fmt.value * + "$(Options.value(v))" * + fmt.reset for (k, v) in pairs(opts.options) + ), + ", ", + ), + ) + print(io, ")") +end + +# ============================================================================ +# Describe - Type introspection +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Display detailed information about a strategy type, including its id, +supertype, and full metadata with all available option definitions. + +This function is useful for discovering what options a strategy accepts +before constructing an instance. + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type to describe + +# Example +```julia-repl +julia> describe(Modelers.ADNLP) +Modelers.ADNLP (strategy type) +├─ id: :adnlp +├─ supertype: AbstractNLPModeler +└─ metadata: 4 options defined + ├─ show_time :: Bool (default: false) + │ description: Whether to show timing information + ├─ backend :: Symbol (default: optimized) + │ description: AD backend used by ADNLPModels + └─ matrix_free :: Bool (default: false) + description: Enable matrix-free mode +``` + +See also: `metadata`, `id`, `options` +""" +function describe end + +""" +$(TYPEDSIGNATURES) + +Return an optional description for a strategy type. + +By default returns `nothing` (no description). Strategies may override this +to provide a human-readable summary and, optionally, a reference URL. +Multi-line descriptions are supported using `'\\n'`. + +# Returns +- `Nothing`: When no description is defined (default) +- `String`: Human-readable description, optionally with URL on a second line + +# Example +```julia +# Default: no description +description(MyStrategy) # returns nothing + +# Override with description and URL +description(::Type{<:Modelers.ADNLP}) = + "NLP modeler using ADNLPModels.\\nSee: https://jso.dev/ADNLPModels.jl" +``` + +See also: [`describe`](@ref), [`AbstractStrategy`](@ref) +""" +description(::Type{<:AbstractStrategy}) = nothing + +function describe(strategy_type::Type{T}) where {T<:AbstractStrategy} + describe(stdout, strategy_type) +end + +function describe(io::IO, ::Type{T}) where {T<:AbstractStrategy} + fmt = Core.get_format_codes(io) + type_name = nameof(T) + strategy_id = id(T) + meta = metadata(T) + desc = description(T) + + # Build hierarchy chain up to AbstractStrategy + hierarchy_chain = Type[T] + current = T + while current !== AbstractStrategy && current !== Any + current = supertype(current) + push!(hierarchy_chain, current) + if current === AbstractStrategy + break + end + end + hierarchy_str = join( + [fmt.type * string(nameof(t)) * fmt.reset for t in hierarchy_chain], " → " + ) + + println(io, type_name, " (strategy type)") + + # id line + println(io, "├─ id: :", strategy_id) + + # hierarchy line + println(io, "├─ hierarchy: ", hierarchy_str) + if desc !== nothing + _print_labeled_multiline(io, "├─ ", "│ ", fmt, "description: ", desc) + end + + # metadata section + n_opts = length(meta) + println(io, "└─ metadata: ", n_opts, " option", n_opts == 1 ? "" : "s", " defined") + items = collect(pairs(meta)) + for (i, (key, def)) in enumerate(items) + is_first = i == 1 + if is_first + println(io, " │ ") + end + is_last = i == length(items) + prefix = is_last ? " └─ " : " ├─ " + cont = is_last ? " " : " │ " + println(io, prefix, def) + _print_labeled_multiline( + io, cont, cont, fmt, "description: ", Options.description(def) + ) + # Add separator line between options (except after last) + if !is_last + println(io, cont) + end + end +end diff --git a/src/Strategies/contract/metadata.jl b/src/Strategies/contract/metadata.jl new file mode 100644 index 00000000..6714537d --- /dev/null +++ b/src/Strategies/contract/metadata.jl @@ -0,0 +1,380 @@ +""" +$(TYPEDEF) + +Metadata about a strategy type, wrapping option definitions. + +This type serves as a container for `OptionDefinition` objects that define +the contract for a strategy's configuration options. It is returned by the +type-level `metadata(::Type{<:AbstractStrategy})` method and provides a +convenient interface for accessing and managing option definitions. + +# Strategy Contract + +Every concrete strategy type must implement the `metadata` method to return +a `StrategyMetadata` instance describing its configurable options: + +```julia +function metadata(::Type{<:MyStrategy}) + return StrategyMetadata( + OptionDefinition(...), + OptionDefinition(...), + # ... more option definitions + ) +end +``` + +This metadata is used by: +- **Validation**: Check option types and values before construction +- **Documentation**: Auto-generate option documentation +- **Introspection**: Query available options without instantiation +- **Construction**: Build `StrategyOptions` with `build_strategy_options` + +# Fields +- `specs::NamedTuple`: NamedTuple mapping option names to their definitions (type-stable) + +# Type Parameter +- `NT <: NamedTuple`: The concrete NamedTuple type holding the option definitions + +# Constructor + +The constructor accepts a variable number of `OptionDefinition` arguments and +automatically builds the internal NamedTuple, validating that all option names +are unique. The type parameter is inferred automatically. + +# Collection Interface + +`StrategyMetadata` implements standard Julia collection interfaces: +- `meta[:option_name]` - Access definition by name +- `keys(meta)` - Get all option names +- `values(meta)` - Get all definitions +- `pairs(meta)` - Iterate over name-definition pairs +- `length(meta)` - Number of options + +# Example - Standalone Usage +```julia-repl +julia> using CTBase.Strategies + +julia> meta = StrategyMetadata( + OptionDefinition( + name = :max_iter, + type = Int, + default = 100, + description = "Maximum iterations", + aliases = (:max, :maxiter), + validator = x -> x > 0 || throw(ArgumentError("\$x must be positive")) + ), + OptionDefinition( + name = :tol, + type = Float64, + default = 1e-6, + description = "Convergence tolerance" + ) + ) +StrategyMetadata with 2 options: + max_iter (max, maxiter) :: Int64 + default: 100 + description: Maximum iterations + tol :: Float64 + default: 1.0e-6 + description: Convergence tolerance + +julia> meta[:max_iter].name +:max_iter + +julia> collect(keys(meta)) +2-element Vector{Symbol}: + :max_iter + :tol +``` + +# Example - Strategy Implementation +```julia +# Define a concrete strategy type +struct MyOptimizer <: AbstractStrategy + options::StrategyOptions +end + +# Implement the metadata contract (type-level) +function metadata(::Type{<:MyOptimizer}) + return StrategyMetadata( + OptionDefinition( + name = :max_iter, + type = Int, + default = 100, + description = "Maximum number of iterations", + validator = x -> x > 0 || throw(ArgumentError("max_iter must be positive")) + ), + OptionDefinition( + name = :tol, + type = Float64, + default = 1e-6, + description = "Convergence tolerance", + validator = x -> x > 0 || throw(ArgumentError("tol must be positive")) + ) + ) +end + +# Implement the id contract (type-level) +id(::Type{<:MyOptimizer}) = :myoptimizer + +# Implement constructor using build_strategy_options +function MyOptimizer(; kwargs...) + options = build_strategy_options(MyOptimizer; kwargs...) + return MyOptimizer(options) +end + +# Now the strategy can be used with automatic validation +julia> strategy = MyOptimizer(max_iter=200, tol=1e-8) +julia> options(strategy) +StrategyOptions(max_iter=200, tol=1.0e-8) +``` + +# Throws +- `Exceptions.IncorrectArgument`: If duplicate option names are provided + +See also: `OptionDefinition`, `AbstractStrategy`, `build_strategy_options` +""" +struct StrategyMetadata{NT<:NamedTuple} + specs::NT + + function StrategyMetadata(defs::OptionDefinition...) + # Check for duplicate names + names = [Options.name(def) for def in defs] + if length(names) != length(unique(names)) + duplicates = [n for n in names if count(==(n), names) > 1] + throw( + Exceptions.IncorrectArgument( + "Duplicate option names detected"; + got="duplicate names: $(unique(duplicates))", + expected="unique option names for each strategy", + suggestion="Check your OptionDefinition definitions and ensure each name is unique", + context="StrategyMetadata constructor - validating option name uniqueness", + ), + ) + end + + # Convert to NamedTuple using names as keys + names_tuple = Tuple(Options.name(def) for def in defs) + specs_nt = NamedTuple{names_tuple}(defs) + NT = typeof(specs_nt) + + new{NT}(specs_nt) + end +end + +# ============================================================================ +# Collection Interface - Indexability and Iteration +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Access an option definition by name. + +# Arguments +- `meta::StrategyMetadata`: Strategy metadata +- `key::Symbol`: Option name to retrieve + +# Returns +- `OptionDefinition`: The option definition for the specified name + +# Throws +- `FieldError`: If the option name is not defined + +# Example +```julia-repl +julia> meta[:max_iter] +OptionDefinition{Int64} + name: max_iter + type: Int64 + default: 100 + description: Maximum iterations + +julia> meta[:max_iter].default +100 +``` + +See also: `Base.keys`, `Base.values`, `Base.haskey` +""" +Base.getindex(meta::StrategyMetadata, key::Symbol) = meta.specs[key] + +""" +$(TYPEDSIGNATURES) + +Get all option names defined in the metadata. + +# Arguments +- `meta::StrategyMetadata`: Strategy metadata + +# Returns +- Iterator of option names (Symbols) + +# Example +```julia-repl +julia> collect(keys(meta)) +2-element Vector{Symbol}: + :max_iter + :tol +``` + +See also: `Base.values`, `Base.pairs` +""" +Base.keys(meta::StrategyMetadata) = keys(meta.specs) + +""" +$(TYPEDSIGNATURES) + +Get all option definitions. + +# Arguments +- `meta::StrategyMetadata`: Strategy metadata + +# Returns +- Iterator of `OptionDefinition` objects + +# Example +```julia-repl +julia> for def in values(meta) + println(def.name, ": ", def.description) + end +max_iter: Maximum iterations +tol: Convergence tolerance +``` + +See also: `Base.keys`, `Base.pairs` +""" +Base.values(meta::StrategyMetadata) = values(meta.specs) + +""" +$(TYPEDSIGNATURES) + +Iterate over (name, definition) pairs. + +# Arguments +- `meta::StrategyMetadata`: Strategy metadata + +# Returns +- Iterator of (Symbol, OptionDefinition) pairs + +# Example +```julia-repl +julia> for (name, def) in pairs(meta) + println(name, " => ", def.type) + end +max_iter => Int64 +tol => Float64 +``` + +See also: `Base.keys`, `Base.values` +""" +Base.pairs(meta::StrategyMetadata) = pairs(meta.specs) + +""" +$(TYPEDSIGNATURES) + +Iterate over (name, definition) pairs. + +This enables using `StrategyMetadata` in for loops and other iteration contexts. + +# Arguments +- `meta::StrategyMetadata`: Strategy metadata +- `state...`: Iteration state (internal) + +# Returns +- Tuple of ((Symbol, OptionDefinition), state) or `nothing` when done + +# Example +```julia-repl +julia> for (name, def) in meta + println("\$name: \$(def.description)") + end +max_iter: Maximum iterations +tol: Convergence tolerance +``` + +See also: `Base.pairs`, `Base.keys` +""" +Base.iterate(meta::StrategyMetadata, state...) = iterate(pairs(meta.specs), state...) + +""" +$(TYPEDSIGNATURES) + +Get the number of option definitions. + +# Arguments +- `meta::StrategyMetadata`: Strategy metadata + +# Returns +- `Int`: Number of option definitions + +# Example +```julia-repl +julia> length(meta) +2 +``` + +See also: `Base.isempty`, `Base.haskey` +""" +Base.length(meta::StrategyMetadata) = length(meta.specs) + +""" +$(TYPEDSIGNATURES) + +Check if an option definition exists. + +# Arguments +- `meta::StrategyMetadata`: Strategy metadata +- `key::Symbol`: Option name to check + +# Returns +- `Bool`: `true` if the option exists + +# Example +```julia-repl +julia> haskey(meta, :max_iter) +true + +julia> haskey(meta, :nonexistent) +false +``` + +See also: `Base.getindex`, `Base.keys` +""" +Base.haskey(meta::StrategyMetadata, key::Symbol) = haskey(meta.specs, key) + +# Display +function Base.show(io::IO, ::MIME"text/plain", meta::StrategyMetadata) + fmt = Core.get_format_codes(io) + n = length(meta) + println( + io, + fmt.name, + "StrategyMetadata", + fmt.reset, + " with ", + fmt.count, + n, + fmt.reset, + " option", + n == 1 ? "" : "s", + ":", + ) + items = collect(pairs(meta)) + for (i, (key, def)) in enumerate(items) + is_first = i == 1 + if is_first + println(io, "│ ") + end + is_last = i == length(items) + prefix = is_last ? "└─ " : "├─ " + cont = is_last ? " " : "│ " + println(io, prefix, def) + _print_labeled_multiline( + io, cont, cont, fmt, "description: ", Options.description(def) + ) + # Add separator line between options (except after last) + if !is_last + println(io, cont) + end + end +end diff --git a/src/Strategies/contract/parameters.jl b/src/Strategies/contract/parameters.jl new file mode 100644 index 00000000..10db27ac --- /dev/null +++ b/src/Strategies/contract/parameters.jl @@ -0,0 +1,373 @@ +# ============================================================================ +# Strategy Parameters Contract +# ============================================================================ + +""" +Abstract base type for strategy parameters. + +Strategy parameters allow specialization of strategy behavior and default options. +Every concrete parameter must implement: +- `id(::Type{<:AbstractStrategyParameter})::Symbol` - Unique identifier + +# Examples +```julia +struct CPU <: AbstractStrategyParameter end +id(::Type{CPU}) = :cpu + +struct GPU <: AbstractStrategyParameter end +id(::Type{GPU}) = :gpu +``` + +# Notes +- Parameters are singleton types (no fields) - they exist only for type dispatch +- IDs must be globally unique across all strategies and parameters +- Parameters are used to specialize default options in strategy metadata +""" +abstract type AbstractStrategyParameter end + +""" +$(TYPEDSIGNATURES) + +Get the unique identifier for a parameter type. + +Every concrete parameter type must implement this method to provide +a unique symbol identifier used in routing and registry operations. + +# Arguments +- `parameter_type::Type{<:AbstractStrategyParameter}`: The parameter type + +# Returns +- `Symbol`: Unique identifier for the parameter + +# Throws +- `CTBase.Exceptions.NotImplemented`: If the parameter type doesn't implement this method + +# Examples +```julia-repl +julia> id(CPU) +:cpu + +julia> id(GPU) +:gpu +``` +""" +function id(parameter_type::Type{<:AbstractStrategyParameter}) + throw( + Exceptions.NotImplemented( + "id() must be implemented for parameter type"; + required_method="id(::Type{$(parameter_type)})", + suggestion="Define id(::Type{$(parameter_type)}) = :your_id", + context="AbstractStrategyParameter contract", + ), + ) +end + +""" +$(TYPEDSIGNATURES) + +Check whether a type is a strategy parameter type. + +This predicate is useful for contract validation and generic code paths that +need to distinguish parameter types from other types. + +# Arguments +- `T::Type`: Any Julia type + +# Returns +- `Bool`: `true` if `T <: AbstractStrategyParameter`, otherwise `false` + +# Example +```julia-repl +julia> Strategies.is_a_parameter(Strategies.CPU) +true + +julia> Strategies.is_a_parameter(Int) +false +``` + +See also: `AbstractStrategyParameter`, `validate_parameter_type` +""" +is_a_parameter(::Type{T}) where {T} = T <: AbstractStrategyParameter + +""" +$(TYPEDSIGNATURES) + +!!! warning "Deprecated" + `is_parameter_type` is deprecated; use `is_a_parameter` instead. +""" +function is_parameter_type(::Type{T}) where {T} + Base.depwarn("`is_parameter_type` is deprecated, use `is_a_parameter` instead.", :is_parameter_type) + return is_a_parameter(T) +end + +""" +$(TYPEDSIGNATURES) + +Get the identifier of a strategy parameter type. + +This is an explicit alias for `id` to make code using parameter IDs +more self-documenting. + +# Arguments +- `parameter_type::Type{<:AbstractStrategyParameter}`: The parameter type + +# Returns +- `Symbol`: The parameter identifier + +# Example +```julia-repl +julia> Strategies.parameter_id(Strategies.CPU) +:cpu +``` + +See also: `id`, `AbstractStrategyParameter` +""" +parameter_id(parameter_type::Type{<:AbstractStrategyParameter}) = id(parameter_type) + +""" +$(TYPEDSIGNATURES) + +Validate that a parameter type satisfies the `AbstractStrategyParameter` contract. + +This function performs lightweight structural checks: +- the parameter type must be concrete +- the parameter type must be a singleton type (no fields) +- the parameter type must implement `id` + +# Arguments +- `parameter_type::Type{<:AbstractStrategyParameter}`: The parameter type to validate + +# Returns +- `Nothing`: Returns `nothing` if validation succeeds + +# Throws +- `Exceptions.IncorrectArgument`: If the parameter type is not concrete or has fields +- `Exceptions.NotImplemented`: If the parameter type does not implement `id` + +# Example +```julia +struct MyParam <: Strategies.AbstractStrategyParameter end +Strategies.id(::Type{MyParam}) = :my_param + +Strategies.validate_parameter_type(MyParam) # returns nothing +``` + +# Notes +- This function does not validate global ID uniqueness; that is handled by registry construction. + +See also: `id`, `parameter_id`, `is_a_parameter` +""" +function validate_parameter_type(parameter_type::Type{<:AbstractStrategyParameter}) + if !isconcretetype(parameter_type) + throw( + Exceptions.IncorrectArgument( + "Invalid parameter type"; + got="parameter_type=$parameter_type", + expected="a concrete DataType subtype of AbstractStrategyParameter", + suggestion="Define a concrete struct subtype, e.g. struct MyParam <: AbstractStrategyParameter end", + context="validate_parameter_type - contract validation", + ), + ) + end + if fieldcount(parameter_type) != 0 + throw( + Exceptions.IncorrectArgument( + "Invalid parameter type"; + got="parameter_type=$parameter_type with $(fieldcount(parameter_type)) fields", + expected="a singleton parameter type with no fields", + suggestion="Remove fields from the parameter type; use type dispatch only", + context="validate_parameter_type - singleton type requirement", + ), + ) + end + _ = id(parameter_type) + return nothing +end + +# ============================================================================ + +""" +CPU parameter type for CPU-based computation. + +This parameter indicates that a strategy should use CPU-based backends +and default options optimized for CPU execution. +""" +struct CPU <: AbstractStrategyParameter end + +""" +GPU parameter type for GPU-based computation. + +This parameter indicates that a strategy should use GPU-based backends +and default options optimized for GPU execution. + +# Notes +- Requires CUDA.jl to be loaded and functional +- Strategies may throw `CTBase.Exceptions.ExtensionError` if CUDA is not available +""" +struct GPU <: AbstractStrategyParameter end + +# Implement the contract for built-in parameters +id(::Type{CPU}) = :cpu +id(::Type{GPU}) = :gpu + +# ============================================================================ +# Parameter Description Contract +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Get the description for a parameter type. + +Every concrete parameter type should implement this method to provide +a human-readable description of the parameter's purpose and behavior. + +# Arguments +- `parameter_type::Type{<:AbstractStrategyParameter}`: The parameter type + +# Returns +- `String`: Human-readable description of the parameter + +# Throws +- `Exceptions.NotImplemented`: If the parameter type doesn't implement this method + +# Example +\`\`\`julia-repl +julia> using CTBase.Strategies + +julia> description(CPU) +"CPU-based computation" + +julia> description(GPU) +"GPU-based computation" +\`\`\` + +See also: [`id`](@ref), [`AbstractStrategyParameter`](@ref) +""" +function description(parameter_type::Type{<:AbstractStrategyParameter}) + throw( + Exceptions.NotImplemented( + "description() must be implemented for parameter type"; + required_method="description(::Type{$(parameter_type)})", + suggestion="Define description(::Type{$(parameter_type)}) = \"Your description\"", + context="AbstractStrategyParameter contract", + ), + ) +end + +""" +$(TYPEDSIGNATURES) + +CPU parameter description. +""" +description(::Type{CPU}) = "CPU-based computation" + +""" +$(TYPEDSIGNATURES) + +GPU parameter description. +""" +description(::Type{GPU}) = "GPU-based computation" + +# ============================================================================ +# Describe - Parameter introspection +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Display comprehensive information about a parameter type. + +This function provides type-level introspection that shows: +- Parameter ID +- Type hierarchy chain +- Description + +# Arguments +- `parameter_type::Type{<:AbstractStrategyParameter}`: The parameter type to describe + +# Example +\`\`\`julia-repl +julia> using CTBase.Strategies + +julia> describe(CPU) +CPU (parameter) +├─ id: :cpu +├─ hierarchy: CPU → AbstractStrategyParameter +└─ description: CPU-based computation +\`\`\` + +See also: [`describe(::Symbol, ::StrategyRegistry)`](@ref), [`id`](@ref), [`description`](@ref) +""" +function describe(parameter_type::Type{T}) where {T<:AbstractStrategyParameter} + describe(stdout, parameter_type) +end + +""" +$(TYPEDSIGNATURES) + +Display parameter information to a specific IO stream. + +See [`describe(::Type{<:AbstractStrategyParameter})`](@ref) for details. +""" +function describe(io::IO, parameter_type::Type{T}) where {T<:AbstractStrategyParameter} + fmt = Core.get_format_codes(io) + type_name = nameof(parameter_type) + param_id = id(parameter_type) + param_desc = description(parameter_type) + + # Build hierarchy chain: parameter → AbstractStrategyParameter + hierarchy_chain = [parameter_type, AbstractStrategyParameter] + hierarchy_str = join( + [fmt.type * string(nameof(T)) * fmt.reset for T in hierarchy_chain], " → " + ) + + println(io, fmt.name, type_name, fmt.reset, " (parameter)") + println(io, "├─ ", fmt.label, "id: ", fmt.reset, fmt.keyword, ":", param_id, fmt.reset) + println(io, "├─ ", fmt.label, "hierarchy: ", fmt.reset, hierarchy_str) + _print_labeled_multiline(io, "└─ ", " ", fmt, "description: ", param_desc) +end + +# ============================================================================ +# Parameter Support Validation +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Get the default parameter type for a strategy. + +This function returns the default parameter type that a strategy accepts. +Strategies should override this method to specify their default parameter. + +# Arguments +- `strategy_type::Type{<:AbstractStrategy}`: The strategy type + +# Returns +- `Type{<:AbstractStrategyParameter}`: Default parameter type + +# Default Behavior +By default, returns `CPU` for backward compatibility. Strategies that +have a different default parameter should override this method. + +# Example +```julia +# Strategy that defaults to CPU +_default_parameter(::Type{<:MyStrategy}) = CPU + +# Strategy that defaults to GPU +_default_parameter(::Type{<:MyOtherStrategy}) = GPU +``` + +See also: `CPU`, `GPU` +""" +function _default_parameter(::Type{<:AbstractStrategy}) + throw( + Exceptions.NotImplemented( + "Strategy must implement _default_parameter"; + required_method="Strategies._default_parameter(::Type{<:YourStrategy})", + suggestion="Define Strategies._default_parameter(::Type{<:YourStrategy}) = CPU or GPU", + context="Parameter contract - all parameterized strategies must declare default parameter", + ), + ) +end diff --git a/src/Strategies/contract/strategy_options.jl b/src/Strategies/contract/strategy_options.jl new file mode 100644 index 00000000..7e2f423a --- /dev/null +++ b/src/Strategies/contract/strategy_options.jl @@ -0,0 +1,772 @@ +""" +$(TYPEDEF) + +Wrapper for strategy option values with provenance tracking. + +This type stores options as a collection of `OptionValue` objects, each containing +both the value and its source (`:user`, `:default`, or `:computed`). + +## Validation Modes + +Strategy options are built using `build_strategy_options()` which supports two validation modes: + +- **Strict Mode (default)**: Only known options are accepted + - Unknown options trigger detailed error messages with suggestions + - Type validation and custom validators are enforced + - Provides early error detection and safety + +- **Permissive Mode**: Unknown options are accepted with warnings + - Unknown options are stored with `:user` source + - Type validation and custom validators still apply to known options + - Allows backend-specific options without breaking changes + +# Fields +- `options::NamedTuple`: NamedTuple of OptionValue objects with provenance +- `alias_map::Dict{Symbol, Symbol}`: Mapping from alias names to canonical names + +# Construction + +```julia-repl +julia> using CTBase.Strategies, CTBase.Options + +julia> opts = StrategyOptions( + max_iter = OptionValue(200, :user), + tol = OptionValue(1e-6, :default) + ) +StrategyOptions with 2 options: + max_iter = 200 [user] + tol = 1.0e-6 [default] +``` + +# Building Options with Validation + +```julia-repl +# Strict mode (default) - rejects unknown options +julia> opts = build_strategy_options(MyStrategy; max_iter=200) +StrategyOptions(...) + +# Permissive mode - accepts unknown options with warning +julia> opts = build_strategy_options(MyStrategy; max_iter=200, custom_opt=123; mode=:permissive) +StrategyOptions(...) # with warning about custom_opt +``` + +# Access patterns + +```julia-repl +# Get value only (canonical name) +julia> opts[:max_iter] +200 + +# Get value using alias +julia> opts[:maxiter] # Alias automatically resolved +200 + +# Get OptionValue (value + source) +julia> opts.max_iter +OptionValue(200, :user) + +# Get source only +julia> source(opts, :max_iter) +:user + +# Check if user-provided +julia> is_user(opts, :max_iter) +true + +# Check if option exists (works with aliases) +julia> haskey(opts, :maxiter) +true +``` + +# Iteration + +```julia-repl +# Iterate over values +julia> for value in opts + println(value) + end + +# Iterate over (name, value) pairs +julia> for (name, value) in opts + println("\$name = \$value") + end +``` + +See also: `OptionValue`, `source`, `is_user`, `is_default`, `is_computed` +""" +struct StrategyOptions{NT<:NamedTuple} + options::NT + alias_map::Dict{Symbol,Symbol} + + function StrategyOptions( + options::NT, alias_map::Dict{Symbol,Symbol}=Dict{Symbol,Symbol}() + ) where {NT<:NamedTuple} + for (key, val) in pairs(options) + if !(val isa Options.OptionValue) + throw( + Exceptions.IncorrectArgument( + "Invalid option value type"; + got="$(typeof(val)) for key :$key", + expected="OptionValue for all strategy options", + suggestion="Wrap your value with OptionValue(value, :user/:default/:computed) or use the StrategyOptions constructor", + context="StrategyOptions constructor - validating option types", + ), + ) + end + end + new{NT}(options, alias_map) + end + + StrategyOptions(; kwargs...) = StrategyOptions((; kwargs...), Dict{Symbol,Symbol}()) +end + +# ============================================================================ +# Alias resolution helper +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +**Private helper function** - for internal framework use only. + +Resolve an alias to its canonical name, or return the key unchanged if not an alias. + +This function performs O(1) lookup in the alias_map to resolve aliases to their +canonical names. If the key is not found in the alias_map, it is assumed to be +already canonical and returned unchanged. + +!!! warning "Internal Use Only" + This function is **not part of the public API** and may change without notice. + External code should use the public access methods which handle alias resolution automatically. + +# Arguments +- `opts::StrategyOptions`: Strategy options containing the alias map +- `key::Symbol`: Key to resolve (can be canonical name or alias) + +# Returns +- `Symbol`: Canonical name for the option + +# Example +```julia +# Internal usage only +canonical = _resolve_key(opts, :maxiter) # Returns :max_iter +canonical = _resolve_key(opts, :max_iter) # Returns :max_iter (already canonical) +``` + +See also: `Base.getindex`, `Base.haskey` +""" +_resolve_key(opts::StrategyOptions, key::Symbol) = get(getfield(opts, :alias_map), key, key) + +# ============================================================================ +# Value access - returns unwrapped value +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Get the value of an option (without source information). + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `key::Symbol`: Option name + +# Returns +- The unwrapped option value + +# Notes +This method is type-unstable due to dynamic key lookup. For type-stable access, +use the `get(::Val{key})` method or direct field access. + +# Example +```julia-repl +julia> opts[:max_iter] # Canonical name +200 + +julia> opts[:maxiter] # Alias - automatically resolved +200 + +julia> get(opts, Val(:max_iter)) # Type-stable +200 +``` + +# Notes +- Aliases are automatically resolved to canonical names +- Both canonical names and aliases can be used interchangeably + +See also: `Base.getproperty`, `source`, `get(::StrategyOptions, ::Val)` +""" +function Base.getindex(opts::StrategyOptions, key::Symbol) + Options.value(option(opts, _resolve_key(opts, key))) +end + +""" +$(TYPEDSIGNATURES) + +Type-stable access to option value using Val. + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `::Val{key}`: Compile-time key + +# Returns +- The unwrapped option value with exact type inference + +# Example +```julia-repl +julia> get(opts, Val(:max_iter)) +200 +``` + +See also: `Base.getindex`, `Base.getproperty` +""" +function Base.get(opts::StrategyOptions{NT}, ::Val{key}) where {NT<:NamedTuple,key} + return Options.value(option(opts, key)) +end + +""" +$(TYPEDSIGNATURES) + +Get the OptionValue for an option (with source information). + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `key::Symbol`: Option name or `:options` for the internal field + +# Returns +- `OptionValue`: Complete option with value and source, or the internal options field + +# Example +```julia-repl +julia> opts.max_iter +OptionValue(200, :user) + +julia> opts.max_iter.value +200 + +julia> opts.max_iter.source +:user +``` + +# Notes +- This method does NOT resolve aliases (use `opts[:alias]` for alias resolution) +- Only canonical field names work with dot notation +- Use bracket notation `opts[:alias]` for alias support + +See also: `Base.getindex`, `source` +""" +function Base.getproperty(opts::StrategyOptions, key::Symbol) + # Special handling for internal fields + if key === :options + return _raw_options(opts) + elseif key === :alias_map + return getfield(opts, :alias_map) + else + # Dot notation does NOT resolve aliases - only canonical names work + return _raw_options(opts)[key] + end +end + +# ========================================================================== +# OptionValue access helpers +# ========================================================================== + +""" +$(TYPEDSIGNATURES) + +Get the `OptionValue` wrapper for an option. + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `key::Symbol`: Option name + +# Returns +- `Options.OptionValue`: The option value wrapper + +# Example +```julia-repl +julia> opt = option(opts, :max_iter) +julia> Options.value(opt) +200 + +julia> opt = option(opts, :maxiter) # Alias - automatically resolved +julia> Options.value(opt) +200 +``` + +# Notes +- Aliases are automatically resolved to canonical names + +See also: `Base.getproperty`, `Options.source` +""" +option(opts::StrategyOptions, key::Symbol) = _raw_options(opts)[_resolve_key(opts, key)] + +# ============================================================================ +# Source access helpers +# ============================================================================ +""" +$(TYPEDSIGNATURES) + +Get the value of an option. + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `key::Symbol`: Option name + +# Returns +- `Any`: Value of the option + +# Example +```julia-repl +julia> Options.value(opts, :max_iter) +200 +``` + +See also: `Options.is_user`, `Options.is_default`, `Options.is_computed` +""" +function Options.value(opts::StrategyOptions, key::Symbol) + return Options.value(option(opts, key)) +end + +""" +$(TYPEDSIGNATURES) + +Get the source of an option. + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `key::Symbol`: Option name + +# Returns +- `Symbol`: Source of the option (`:user`, `:default`, or `:computed`) + +# Example +```julia-repl +julia> Options.source(opts, :max_iter) +:user +``` + +See also: `Options.is_user`, `Options.is_default`, `Options.is_computed` +""" +function Options.source(opts::StrategyOptions, key::Symbol) + return Options.source(option(opts, key)) +end + +""" +$(TYPEDSIGNATURES) + +Check if an option was provided by the user. + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `key::Symbol`: Option name + +# Returns +- `Bool`: `true` if the option was provided by the user + +# Example +```julia-repl +julia> Options.is_user(opts, :max_iter) +true +``` + +See also: `Options.source`, `Options.is_default`, `Options.is_computed` +""" +function Options.is_user(opts::StrategyOptions, key::Symbol) + return Options.is_user(option(opts, key)) +end + +""" +$(TYPEDSIGNATURES) + +Check if an option is using its default value. + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `key::Symbol`: Option name + +# Returns +- `Bool`: `true` if the option is using its default value + +# Example +```julia-repl +julia> Options.is_default(opts, :tol) +true +``` + +See also: `Options.source`, `Options.is_user`, `Options.is_computed` +""" +function Options.is_default(opts::StrategyOptions, key::Symbol) + return Options.is_default(option(opts, key)) +end + +""" +$(TYPEDSIGNATURES) + +Check if an option was computed. + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `key::Symbol`: Option name + +# Returns +- `Bool`: `true` if the option was computed + +# Example +```julia-repl +julia> Options.is_computed(opts, :step) +true +``` + +See also: `Options.source`, `Options.is_user`, `Options.is_default` +""" +function Options.is_computed(opts::StrategyOptions, key::Symbol) + return Options.is_computed(option(opts, key)) +end + +# ============================================================================ +# Private Helper for Internal Use +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +**Private helper function** - for internal framework use only. + +Returns the raw NamedTuple of OptionValue objects from the internal storage. +This is needed for `Options.extract_raw_options` which requires access to the +full OptionValue objects, not just their `.value` fields. + +!!! warning "Internal Use Only" + This function is **not part of the public API** and may change without notice. + External code should use the public collection interface (`pairs`, `keys`, `values`, etc.). + +# Returns +- NamedTuple of `(Symbol => OptionValue)` from the internal storage +""" +_raw_options(opts::StrategyOptions) = getfield(opts, :options) + +# ============================================================================ +# Collection interface +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Get all option names. + +# Arguments +- `opts::StrategyOptions`: Strategy options + +# Returns +- Iterator of option names (Symbols) + +# Example +```julia-repl +julia> collect(keys(opts)) +[:max_iter, :tol] +``` + +See also: `Base.values`, `Base.pairs` +""" +Base.keys(opts::StrategyOptions) = keys(_raw_options(opts)) +""" +$(TYPEDSIGNATURES) + +Get all option values (unwrapped). + +# Arguments +- `opts::StrategyOptions`: Strategy options + +# Returns +- Generator of unwrapped option values + +# Example +```julia-repl +julia> collect(values(opts)) +[200, 1.0e-6] +``` + +See also: `Base.keys`, `Base.pairs` +""" +function Base.values(opts::StrategyOptions) + (Options.value(opt) for opt in values(_raw_options(opts))) +end +""" +$(TYPEDSIGNATURES) + +Get all (name, value) pairs (values unwrapped). + +# Arguments +- `opts::StrategyOptions`: Strategy options + +# Returns +- Generator of (Symbol, value) pairs + +# Example +```julia-repl +julia> collect(pairs(opts)) +[:max_iter => 200, :tol => 1.0e-6] +``` + +See also: `Base.keys`, `Base.values` +""" +function Base.pairs(opts::StrategyOptions) + (k => Options.value(v) for (k, v) in pairs(_raw_options(opts))) +end + +""" +$(TYPEDSIGNATURES) + +Iterate over option values (unwrapped). + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `state...`: Iteration state (optional) + +# Returns +- Tuple of (value, state) or `nothing` when done + +# Example +```julia-repl +julia> for value in opts + println(value) + end +200 +1.0e-6 +``` + +See also: `Base.keys`, `Base.values`, `Base.pairs` +""" +function Base.iterate(opts::StrategyOptions, state...) + result = iterate(values(_raw_options(opts)), state...) + result === nothing && return nothing + (opt, newstate) = result + return (Options.value(opt), newstate) +end + +""" +$(TYPEDSIGNATURES) + +Get number of options. + +# Arguments +- `opts::StrategyOptions`: Strategy options + +# Returns +- `Int`: Number of options + +# Example +```julia-repl +julia> length(opts) +2 +``` + +See also: `Base.isempty`, `Base.haskey` +""" +Base.length(opts::StrategyOptions) = length(_raw_options(opts)) +""" +$(TYPEDSIGNATURES) + +Check if options collection is empty. + +# Arguments +- `opts::StrategyOptions`: Strategy options + +# Returns +- `Bool`: `true` if no options are present + +# Example +```julia-repl +julia> isempty(opts) +false +``` + +See also: `Base.length`, `Base.haskey` +""" +Base.isempty(opts::StrategyOptions) = isempty(_raw_options(opts)) +""" +$(TYPEDSIGNATURES) + +Check if an option exists. + +# Arguments +- `opts::StrategyOptions`: Strategy options +- `key::Symbol`: Option name to check + +# Returns +- `Bool`: `true` if the option exists + +# Example +```julia-repl +julia> haskey(opts, :max_iter) +true + +julia> haskey(opts, :maxiter) # Alias - automatically resolved +true + +julia> haskey(opts, :nonexistent) +false +``` + +# Notes +- Aliases are automatically resolved to canonical names +- Both canonical names and aliases can be used + +See also: `Base.length`, `Base.isempty` +""" +function Base.haskey(opts::StrategyOptions, key::Symbol) + haskey(_raw_options(opts), _resolve_key(opts, key)) +end + +# ============================================================================ +# Conversion utilities +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Extract strategy options as a mutable Dict, ready for modification. + +This method converts StrategyOptions to a Dict by unwrapping OptionValue +wrappers and filtering out NotProvided values. The resulting Dict is mutable +and can be modified before passing to backend solvers or model builders. + +# Arguments +- `opts::StrategyOptions`: Strategy options to convert + +# Returns +- `Dict{Symbol, Any}`: Mutable dictionary of option values + +# Example +```julia-repl +julia> using CTBase.Strategies, CTBase.Options + +julia> opts = StrategyOptions( + max_iter = OptionValue(500, :user), + tolerance = OptionValue(1e-8, :default) + ) + +julia> dict = options_dict(opts) +Dict{Symbol, Any} with 2 entries: + :max_iter => 500 + :tolerance => 1.0e-8 + +julia> dict[:verbose] = true # Modify as needed +true +``` + +# Notes +- NotProvided values are filtered out +- Explicit nothing values are preserved +- The returned Dict is mutable and independent from the original StrategyOptions + +See also: `Options.extract_raw_options`, `_raw_options` +""" +function options_dict(opts::StrategyOptions) + raw_opts = Options.extract_raw_options(_raw_options(opts)) + return Dict{Symbol,Any}(pairs(raw_opts)) +end + +# ============================================================================ +# Display +# ============================================================================ + +""" +$(TYPEDSIGNATURES) + +Display StrategyOptions with values and their provenance sources. + +This method formats the output to show each option value alongside its source +(`:user`, `:default`, or `:computed`) for complete traceability. + +# Arguments +- `io::IO`: Output stream +- `::MIME"text/plain"`: MIME type for pretty printing +- `opts::StrategyOptions`: Strategy options to display + +# Example +```julia-repl +julia> opts +StrategyOptions with 2 options: + max_iter = 200 [user] + tol = 1.0e-6 [default] +``` + +See also: `Base.show` +""" +function Base.show(io::IO, ::MIME"text/plain", opts::StrategyOptions) + fmt = Core.get_format_codes(io) + n = length(opts) + println( + io, + fmt.name, + "StrategyOptions", + fmt.reset, + " with ", + fmt.count, + n, + fmt.reset, + " option", + n == 1 ? "" : "s", + ":", + ) + items = collect(pairs(_raw_options(opts))) + for (i, (key, opt)) in enumerate(items) + is_last = i == length(items) + prefix = is_last ? "└─ " : "├─ " + println( + io, + prefix, + fmt.name, + key, + fmt.reset, + " = ", + fmt.value, + Options.value(opt), + fmt.reset, + " [", + fmt.label, + Options.source(opt), + fmt.reset, + "]", + ) + end +end + +""" +$(TYPEDSIGNATURES) + +Compact display of StrategyOptions. + +# Arguments +- `io::IO`: Output stream +- `opts::StrategyOptions`: Strategy options to display + +# Example +```julia-repl +julia> print(opts) +StrategyOptions(max_iter=200, tol=1.0e-6) +``` + +See also: `Base.show(::IO, ::MIME"text/plain", ::StrategyOptions)` +""" +function Base.show(io::IO, opts::StrategyOptions) + fmt = Core.get_format_codes(io) + print(io, fmt.name, "StrategyOptions", fmt.reset, "(") + print( + io, + join( + ( + fmt.name * + "$k" * + fmt.reset * + "=" * + fmt.value * + "$(Options.value(v))" * + fmt.reset for (k, v) in pairs(_raw_options(opts)) + ), + ", ", + ), + ) + print(io, ")") +end diff --git a/src/Strategies/display_formatting.jl b/src/Strategies/display_formatting.jl new file mode 100644 index 00000000..8ae007d4 --- /dev/null +++ b/src/Strategies/display_formatting.jl @@ -0,0 +1,41 @@ +# Display formatting utilities +# +# Unified color and formatting constants for consistent display across all show() methods +""" +$(TYPEDSIGNATURES) + +Print a labeled field with multi-line text support, aligning continuation lines under the text. + +The first line is printed as `prefix + colored_label + text_line_1`. Each subsequent +line (split on `'\\n'`) is indented to align with the start of the text, using +`cont` followed by spaces equal to the visible length of `label`. + +# Arguments +- `io::IO`: Output stream +- `prefix::String`: Prefix for the first line (e.g., `"├─ "` or `" │ "`) +- `cont::String`: Continuation prefix for subsequent lines (e.g., `"│ "` or `" "`) +- `fmt`: Format codes from `Core.get_format_codes` +- `label::String`: The field label text (e.g., `"description: "`) +- `text::String`: The field value, may contain `'\\n'` for multi-line content + +# Example +```julia +fmt = Core.get_format_codes(stdout) +_print_labeled_multiline(stdout, "├─ ", "│ ", fmt, "description: ", "Line one.\\nLine two.") +# Output: +# ├─ description: Line one. +# │ Line two. +``` +""" +function _print_labeled_multiline( + io::IO, prefix::String, cont::String, fmt, label::String, text::String +) + lines = split(text, '\n') + println(io, prefix, fmt.label, label, fmt.reset, lines[1]) + if length(lines) > 1 + padding = cont * " " ^ length(label) + for line in lines[2:end] + println(io, padding, line) + end + end +end diff --git a/test/suite/options/test_computed_source.jl b/test/suite/options/test_computed_source.jl new file mode 100644 index 00000000..e6a43e2e --- /dev/null +++ b/test/suite/options/test_computed_source.jl @@ -0,0 +1,232 @@ +module TestOptionsComputedSource + +using Test: Test +import CTBase.Exceptions +import CTBase.Options +import CTBase.Strategies + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# TOP-LEVEL: Define fake parameterized strategy for testing +struct FakeParameterizedStrategy{P} <: Strategies.AbstractStrategy + options::Strategies.StrategyOptions +end + +# Implement required contract methods +Strategies.id(::Type{<:FakeParameterizedStrategy}) = :fake_param +Strategies._default_parameter(::Type{<:FakeParameterizedStrategy}) = Strategies.CPU + +# Define metadata with computed option +function Strategies.metadata(::Type{<:FakeParameterizedStrategy{P}}) where {P} + return Strategies.StrategyMetadata( + Strategies.OptionDefinition(; + name=:static_opt, type=Int, default=42, description="Static option" + ), + Strategies.OptionDefinition(; + name=:computed_opt, + type=Symbol, + default=(P == Strategies.CPU ? :cpu_value : :gpu_value), + description="Computed option", + computed=true, + ), + ) +end + +# Constructor +function FakeParameterizedStrategy{P}(; mode::Symbol=:strict, kwargs...) where {P} + opts = Strategies.build_strategy_options( + FakeParameterizedStrategy{P}; mode=mode, kwargs... + ) + return FakeParameterizedStrategy{P}(opts) +end + +function test_computed_source() + Test.@testset "Computed Source" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - OptionDefinition with computed field + # ==================================================================== + + Test.@testset "OptionDefinition - computed field" begin + Test.@testset "Default behavior (computed=false)" begin + def = Options.OptionDefinition( + name=:max_iter, type=Int, default=100, description="Maximum iterations" + ) + Test.@test !Options.is_computed(def) + Test.@test def.computed == false + end + + Test.@testset "Explicit computed=true" begin + def = Options.OptionDefinition( + name=:backend, + type=Any, + default=nothing, + description="Computed backend", + computed=true, + ) + Test.@test Options.is_computed(def) + Test.@test def.computed == true + end + + Test.@testset "Explicit computed=false" begin + def = Options.OptionDefinition( + name=:tol, + type=Float64, + default=1e-6, + description="Tolerance", + computed=false, + ) + Test.@test !Options.is_computed(def) + Test.@test def.computed == false + end + + Test.@testset "Predicate is_computed(def)" begin + static_def = Options.OptionDefinition( + name=:static_opt, type=Int, default=42, description="Static option" + ) + computed_def = Options.OptionDefinition( + name=:computed_opt, + type=Int, + default=42, + description="Computed option", + computed=true, + ) + + Test.@test !Options.is_computed(static_def) + Test.@test Options.is_computed(computed_def) + end + end + + # ==================================================================== + # UNIT TESTS - extract_option with computed defaults + # ==================================================================== + + Test.@testset "extract_option - computed source" begin + Test.@testset "Computed default not overridden → :computed source" begin + def = Options.OptionDefinition( + name=:backend, + type=Union{Nothing,Symbol}, + default=:auto, + description="Backend", + computed=true, + ) + + kwargs = (other_opt=123,) + opt_value, remaining = Options.extract_option(kwargs, def) + + Test.@test Options.value(opt_value) == :auto + Test.@test Options.source(opt_value) == :computed + Test.@test Options.is_computed(opt_value) + Test.@test !Options.is_default(opt_value) + Test.@test !Options.is_user(opt_value) + end + + Test.@testset "Computed default overridden by user → :user source" begin + def = Options.OptionDefinition( + name=:backend, + type=Union{Nothing,Symbol}, + default=:auto, + description="Backend", + computed=true, + ) + + kwargs = (backend=:custom, other_opt=123) + opt_value, remaining = Options.extract_option(kwargs, def) + + Test.@test Options.value(opt_value) == :custom + Test.@test Options.source(opt_value) == :user + Test.@test Options.is_user(opt_value) + Test.@test !Options.is_computed(opt_value) + Test.@test !Options.is_default(opt_value) + end + + Test.@testset "Static default not overridden → :default source" begin + def = Options.OptionDefinition( + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + computed=false, + ) + + kwargs = (other_opt=123,) + opt_value, remaining = Options.extract_option(kwargs, def) + + Test.@test Options.value(opt_value) == 100 + Test.@test Options.source(opt_value) == :default + Test.@test Options.is_default(opt_value) + Test.@test !Options.is_computed(opt_value) + Test.@test !Options.is_user(opt_value) + end + + Test.@testset "Static default overridden by user → :user source" begin + def = Options.OptionDefinition( + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + computed=false, + ) + + kwargs = (max_iter=200, other_opt=123) + opt_value, remaining = Options.extract_option(kwargs, def) + + Test.@test Options.value(opt_value) == 200 + Test.@test Options.source(opt_value) == :user + Test.@test Options.is_user(opt_value) + Test.@test !Options.is_computed(opt_value) + Test.@test !Options.is_default(opt_value) + end + end + + # ==================================================================== + # INTEGRATION TESTS - Strategy with computed options + # ==================================================================== + + Test.@testset "Strategy with computed options" begin + # Note: FakeParameterizedStrategy is defined at module top-level + + Test.@testset "CPU parameter - computed option has :computed source" begin + strategy = FakeParameterizedStrategy{Strategies.CPU}() + + # Check static option has :default source + Test.@test Strategies.option_value(strategy, :static_opt) == 42 + Test.@test Strategies.option_source(strategy, :static_opt) == :default + + # Check computed option has :computed source + Test.@test Strategies.option_value(strategy, :computed_opt) == :cpu_value + Test.@test Strategies.option_source(strategy, :computed_opt) == :computed + Test.@test Strategies.option_is_computed(strategy, :computed_opt) + end + + Test.@testset "GPU parameter - computed option has :computed source" begin + strategy = FakeParameterizedStrategy{Strategies.GPU}() + + # Check static option has :default source + Test.@test Strategies.option_value(strategy, :static_opt) == 42 + Test.@test Strategies.option_source(strategy, :static_opt) == :default + + # Check computed option has :computed source + Test.@test Strategies.option_value(strategy, :computed_opt) == :gpu_value + Test.@test Strategies.option_source(strategy, :computed_opt) == :computed + Test.@test Strategies.option_is_computed(strategy, :computed_opt) + end + + Test.@testset "User override changes to :user source" begin + strategy = FakeParameterizedStrategy{Strategies.CPU}(computed_opt=:custom) + + # Check computed option overridden by user has :user source + Test.@test Strategies.option_value(strategy, :computed_opt) == :custom + Test.@test Strategies.option_source(strategy, :computed_opt) == :user + Test.@test Strategies.option_is_user(strategy, :computed_opt) + Test.@test !Strategies.option_is_computed(strategy, :computed_opt) + end + end + end +end + +end # module + +# CRITICAL: Redefine in outer scope for TestRunner +test_computed_source() = TestOptionsComputedSource.test_computed_source() diff --git a/test/suite/options/test_coverage_options.jl b/test/suite/options/test_coverage_options.jl new file mode 100644 index 00000000..4441fc59 --- /dev/null +++ b/test/suite/options/test_coverage_options.jl @@ -0,0 +1,254 @@ +module TestCoverageOptions + +using Test: Test +import CTBase.Exceptions +import CTBase.Options +import CTBase.Strategies + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Fake types for testing (must be at module top-level) +# ============================================================================ + +abstract type FakeNLPModeler <: Strategies.AbstractStrategy end + +struct CovOptFakeStrategy <: Strategies.AbstractStrategy + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{<:CovOptFakeStrategy}) = :cov_opt_fake + +function Strategies.metadata(::Type{<:CovOptFakeStrategy}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:alpha, type=Float64, default=1.0, description="Alpha parameter" + ), + ) +end + +function test_coverage_options() + Test.@testset "Coverage: Options & StrategyOptions" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - NotStored display (not_provided.jl) + # ==================================================================== + + Test.@testset "NotStored display" begin + buf = IOBuffer() + show(buf, Options.NotStored) + Test.@test String(take!(buf)) == "NotStored" + end + + Test.@testset "NotProvided display" begin + buf = IOBuffer() + show(buf, Options.NotProvided) + Test.@test String(take!(buf)) == "NotProvided" + end + + Test.@testset "NotStored type" begin + Test.@test Options.NotStored isa Options.NotStoredType + Test.@test typeof(Options.NotStored) == Options.NotStoredType + end + + # ==================================================================== + # UNIT TESTS - StrategyOptions (strategy_options.jl) + # ==================================================================== + + Test.@testset "StrategyOptions - invalid value type" begin + Test.@test_throws Exceptions.IncorrectArgument Strategies.StrategyOptions(( + bad_key=42, + )) + end + + Test.@testset "StrategyOptions - getproperty :options" begin + opts = Strategies.StrategyOptions(alpha=Options.OptionValue(1.0, :default)) + Test.@test opts.options isa NamedTuple + Test.@test opts.alpha isa Options.OptionValue + Test.@test Options.value(opts.alpha) == 1.0 + end + + Test.@testset "StrategyOptions - getindex" begin + opts = Strategies.StrategyOptions(alpha=Options.OptionValue(2.0, :user)) + Test.@test opts[:alpha] == 2.0 + end + + Test.@testset "StrategyOptions - get(Val)" begin + opts = Strategies.StrategyOptions(alpha=Options.OptionValue(3.0, :computed)) + Test.@test get(opts, Val(:alpha)) == 3.0 + end + + Test.@testset "StrategyOptions - source helpers" begin + opts = Strategies.StrategyOptions( + a=Options.OptionValue(1, :user), + b=Options.OptionValue(2, :default), + c=Options.OptionValue(3, :computed), + ) + Test.@test Strategies.source(opts, :a) === :user + Test.@test Strategies.source(opts, :b) === :default + Test.@test Strategies.source(opts, :c) === :computed + Test.@test Strategies.is_user(opts, :a) === true + Test.@test Strategies.is_user(opts, :b) === false + Test.@test Strategies.is_default(opts, :b) === true + Test.@test Strategies.is_default(opts, :a) === false + Test.@test Strategies.is_computed(opts, :c) === true + Test.@test Strategies.is_computed(opts, :a) === false + end + + Test.@testset "StrategyOptions - _raw_options" begin + opts = Strategies.StrategyOptions(x=Options.OptionValue(10, :user)) + raw = Strategies._raw_options(opts) + Test.@test raw isa NamedTuple + Test.@test raw.x isa Options.OptionValue + end + + Test.@testset "StrategyOptions - collection interface" begin + opts = Strategies.StrategyOptions( + a=Options.OptionValue(1, :user), b=Options.OptionValue(2, :default) + ) + + # keys + Test.@test :a in keys(opts) + Test.@test :b in keys(opts) + + # values + vals = collect(values(opts)) + Test.@test 1 in vals + Test.@test 2 in vals + + # pairs + ps = collect(pairs(opts)) + Test.@test any(p -> p.first == :a && p.second == 1, ps) + + # length + Test.@test length(opts) == 2 + + # isempty + Test.@test !isempty(opts) + Test.@test isempty(Strategies.StrategyOptions()) + + # haskey + Test.@test haskey(opts, :a) + Test.@test !haskey(opts, :nonexistent) + + # iterate + collected = [] + for v in opts + push!(collected, v) + end + Test.@test length(collected) == 2 + Test.@test 1 in collected + Test.@test 2 in collected + end + + Test.@testset "StrategyOptions - display" begin + opts = Strategies.StrategyOptions( + a=Options.OptionValue(1, :user), b=Options.OptionValue(2, :default) + ) + + # Pretty display - test individual components + buf = IOBuffer() + show(buf, MIME("text/plain"), opts) + output = String(take!(buf)) + + # Test that components appear regardless of color formatting + Test.@test occursin("StrategyOptions", output) + Test.@test occursin("2", output) # Number of options + Test.@test occursin("options", output) + Test.@test occursin("a", output) + Test.@test occursin("1", output) + Test.@test occursin("user", output) + Test.@test occursin("b", output) + + # Compact display - test individual components + buf2 = IOBuffer() + show(buf2, opts) + output2 = String(take!(buf2)) + + Test.@test occursin("StrategyOptions", output2) + Test.@test occursin("a", output2) + Test.@test occursin("1", output2) + Test.@test occursin("b", output2) + Test.@test occursin("2", output2) + + # Single option (singular) - test individual components + opts1 = Strategies.StrategyOptions(x=Options.OptionValue(42, :default)) + buf3 = IOBuffer() + show(buf3, MIME("text/plain"), opts1) + output3 = String(take!(buf3)) + + Test.@test occursin("1", output3) + Test.@test occursin("option", output3) + Test.@test occursin("x", output3) + Test.@test occursin("42", output3) + end + + # ==================================================================== + # UNIT TESTS - StrategyRegistry display (registry.jl) + # ==================================================================== + + Test.@testset "StrategyRegistry - display" begin + registry = Strategies.create_registry( + Strategies.AbstractStrategy => (CovOptFakeStrategy,) + ) + + # Compact display - test individual components + buf = IOBuffer() + show(buf, registry) + output = String(take!(buf)) + + Test.@test occursin("StrategyRegistry", output) + Test.@test occursin("1", output) # Number of families + Test.@test occursin("family", output) + + # Pretty display - test individual components + buf2 = IOBuffer() + show(buf2, MIME("text/plain"), registry) + output2 = String(take!(buf2)) + + Test.@test occursin("StrategyRegistry", output2) + Test.@test occursin("cov_opt_fake", output2) + end + + Test.@testset "StrategyRegistry - validation errors" begin + # Invalid family type + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + Int => (CovOptFakeStrategy,) + ) + + # Invalid strategies format (not a tuple) + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + Strategies.AbstractStrategy => [CovOptFakeStrategy] + ) + + # Duplicate family + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + Strategies.AbstractStrategy => (CovOptFakeStrategy,), + Strategies.AbstractStrategy => (CovOptFakeStrategy,), + ) + + # Family not found in registry + registry = Strategies.create_registry( + Strategies.AbstractStrategy => (CovOptFakeStrategy,) + ) + Test.@test_throws Exceptions.IncorrectArgument Strategies.strategy_ids( + FakeNLPModeler, registry + ) + + # Unknown strategy ID + Test.@test_throws Exceptions.IncorrectArgument Strategies.type_from_id( + :nonexistent, Strategies.AbstractStrategy, registry + ) + + # Family not found in type_from_id + Test.@test_throws Exceptions.IncorrectArgument Strategies.type_from_id( + :cov_opt_fake, FakeNLPModeler, registry + ) + end + end +end + +end # module + +test_coverage_options() = TestCoverageOptions.test_coverage_options() diff --git a/test/suite/options/test_extraction_api.jl b/test/suite/options/test_extraction_api.jl new file mode 100644 index 00000000..beb53165 --- /dev/null +++ b/test/suite/options/test_extraction_api.jl @@ -0,0 +1,459 @@ +module TestOptionsExtractionAPI + +using Test: Test +import CTBase.Exceptions +import CTBase.Options +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Helper types and functions +# ============================================================================ + +# Simple validator for testing +positive_validator(x::Int) = x > 0 || throw(ArgumentError("$x must be positive")) + +# Range validator for testing +function range_validator(x::Int) + (1 <= x <= 100) || throw(ArgumentError("$x must be between 1 and 100")) +end + +# String validator for testing +function nonempty_validator(s::String) + !isempty(s) || throw(ArgumentError("String must not be empty")) +end + +# ============================================================================ +# Test entry point +# ============================================================================ + +function test_extraction_api() + + # ============================================================================ + # UNIT TESTS + # ============================================================================ + + Test.@testset "Extraction API" verbose = VERBOSE showtiming = SHOWTIMING begin + Test.@testset "extract_option - Basic functionality" begin + # Test with exact name match + def = Options.OptionDefinition( + name=:grid_size, type=Int, default=100, description="Grid size" + ) + kwargs = (grid_size=200, tol=1e-6) + + opt_value, remaining = Options.extract_option(kwargs, def) + + Test.@test opt_value.value == 200 + Test.@test opt_value.source == :user + Test.@test remaining == (tol=1e-6,) + end + + Test.@testset "extract_option - Alias resolution" begin + # Test with alias + def = Options.OptionDefinition( + name=:grid_size, + type=Int, + default=100, + description="Grid size", + aliases=(:n, :size), + ) + kwargs = (n=200, tol=1e-6) + + opt_value, remaining = Options.extract_option(kwargs, def) + + Test.@test opt_value.value == 200 + Test.@test opt_value.source == :user + Test.@test remaining == (tol=1e-6,) + + # Test with different alias + kwargs = (size=300, max_iter=1000) + opt_value, remaining = Options.extract_option(kwargs, def) + + Test.@test opt_value.value == 300 + Test.@test opt_value.source == :user + Test.@test remaining == (max_iter=1000,) + end + + Test.@testset "extract_option - Default values" begin + # Test when option not found + def = Options.OptionDefinition( + name=:grid_size, type=Int, default=100, description="Grid size" + ) + kwargs = (tol=1e-6, max_iter=1000) + + opt_value, remaining = Options.extract_option(kwargs, def) + + Test.@test opt_value.value == 100 + Test.@test opt_value.source == :default + Test.@test remaining == kwargs # Unchanged + end + + Test.@testset "extract_option - Validation" begin + # Test with successful validation + def = Options.OptionDefinition( + name=:grid_size, + type=Int, + default=100, + description="Grid size", + validator=x -> x > 0 || throw(ArgumentError("$x must be positive")), + ) + kwargs = (grid_size=200,) + + opt_value, remaining = Options.extract_option(kwargs, def) + + Test.@test opt_value.value == 200 + Test.@test opt_value.source == :user + + # Test with failed validation (redirect stderr to hide @error logs) + kwargs = (grid_size=-5,) + Test.@test_throws ArgumentError redirect_stderr(devnull) do + Options.extract_option(kwargs, def) + end + end + + Test.@testset "extract_option - Type checking" begin + # Test type mismatch (should throw IncorrectArgument) + def = Options.OptionDefinition( + name=:grid_size, type=Int, default=100, description="Grid size" + ) + kwargs = (grid_size="200",) # String instead of Int + + Test.@test_throws Exceptions.IncorrectArgument Options.extract_option( + kwargs, def + ) + end + + Test.@testset "extract_options - Vector version" begin + defs = [ + Options.OptionDefinition( + name=:grid_size, type=Int, default=100, description="Grid size" + ), + Options.OptionDefinition( + name=:tol, type=Float64, default=1e-6, description="Tolerance" + ), + Options.OptionDefinition( + name=:max_iter, type=Int, default=1000, description="Max iterations" + ), + ] + kwargs = (grid_size=200, tol=1e-8, other_option="ignored") + + extracted, remaining = Options.extract_options(kwargs, defs) + + Test.@test extracted[:grid_size].value == 200 + Test.@test extracted[:grid_size].source == :user + Test.@test extracted[:tol].value == 1e-8 + Test.@test extracted[:tol].source == :user + Test.@test extracted[:max_iter].value == 1000 + Test.@test extracted[:max_iter].source == :default + Test.@test remaining == (other_option="ignored",) + end + + Test.@testset "extract_options - NamedTuple version" begin + defs = ( + grid_size=Options.OptionDefinition( + name=:grid_size, type=Int, default=100, description="Grid size" + ), + tol=Options.OptionDefinition( + name=:tol, type=Float64, default=1e-6, description="Tolerance" + ), + ) + kwargs = (grid_size=200, tol=1e-8, max_iter=1000) + + extracted, remaining = Options.extract_options(kwargs, defs) + + Test.@test extracted.grid_size.value == 200 + Test.@test extracted.grid_size.source == :user + Test.@test extracted.tol.value == 1e-8 + Test.@test extracted.tol.source == :user + Test.@test remaining == (max_iter=1000,) + end + + Test.@testset "extract_options - Complex scenario with aliases" begin + defs = [ + Options.OptionDefinition( + name=:grid_size, + type=Int, + default=100, + description="Grid size", + aliases=(:n, :size), + validator=positive_validator, + ), + Options.OptionDefinition( + name=:tolerance, + type=Float64, + default=1e-6, + description="Tolerance", + aliases=(:tol,), + ), + Options.OptionDefinition( + name=:max_iterations, + type=Int, + default=1000, + description="Max iterations", + aliases=(:max_iter, :iterations), + ), + ] + kwargs = (n=50, tol=1e-8, iterations=500, unused="value") + + extracted, remaining = Options.extract_options(kwargs, defs) + + Test.@test extracted[:grid_size].value == 50 + Test.@test extracted[:grid_size].source == :user + Test.@test extracted[:tolerance].value == 1e-8 + Test.@test extracted[:tolerance].source == :user + Test.@test extracted[:max_iterations].value == 500 + Test.@test extracted[:max_iterations].source == :user + Test.@test remaining == (unused="value",) + end + + Test.@testset "Performance - Type stability" begin + # Focus on functional correctness + def = Options.OptionDefinition( + name=:test, type=Int, default=42, description="Test" + ) + kwargs = (test=100,) + + result = Options.extract_option(kwargs, def) + Test.@test result[1] isa Options.OptionValue + Test.@test result[2] isa NamedTuple + + defs = [def] + result = Options.extract_options(kwargs, defs) + Test.@test result[1] isa Dict{Symbol,Options.OptionValue} + Test.@test result[2] isa NamedTuple + end + + Test.@testset "Error handling" begin + # Validator that accepts default but rejects other values + def = Options.OptionDefinition( + name=:test, + type=Int, + default=42, + description="Test", + validator=x -> x == 42 || throw(ArgumentError("$x must be 42")), + ) + kwargs = (test=100,) + + # Test validation error propagation (redirect stderr to hide @error logs) + Test.@test_throws ArgumentError redirect_stderr(devnull) do + Options.extract_option(kwargs, def) + end + + # Test with multiple definitions, one fails + defs = [ + Options.OptionDefinition( + name=:good, type=Int, default=42, description="Good" + ), + Options.OptionDefinition( + name=:bad, + type=Int, + default=42, + description="Bad", + validator=x -> x == 42 || throw(ArgumentError("$x must be 42")), + ), + ] + kwargs = (good=100, bad=200) + + Test.@test_throws ArgumentError redirect_stderr(devnull) do + Options.extract_options(kwargs, defs) + end + end + + Test.@testset "Type check before validator" begin + # Test that type check happens BEFORE validator call + # This prevents MethodError when validator has type annotations + typed_validator(x::Int) = x > 0 || throw(ArgumentError("$x must be positive")) + + def = Options.OptionDefinition( + name=:test_option, + type=Int, + default=42, + description="Test option with typed validator", + validator=typed_validator, + ) + + # Pass wrong type - should get IncorrectArgument, NOT MethodError + kwargs = (test_option="not_an_int",) + Test.@test_throws Exceptions.IncorrectArgument Options.extract_option( + kwargs, def + ) + + # Verify error message mentions type mismatch + try + Options.extract_option(kwargs, def) + Test.@test false # Should not reach here + catch e + Test.@test e isa Exceptions.IncorrectArgument + Test.@test occursin("Invalid option type", e.msg) + Test.@test occursin("String", string(e.got)) + Test.@test occursin("Int", string(e.expected)) + end + + # Pass correct type but invalid value - should get ArgumentError from validator + kwargs = (test_option=-5,) + Test.@test_throws ArgumentError redirect_stderr(devnull) do + Options.extract_option(kwargs, def) + end + end + end # UNIT TESTS + + # ============================================================================ + # INTEGRATION TESTS + # ============================================================================ + + Test.@testset "Extraction API Integration" verbose = VERBOSE showtiming = SHOWTIMING begin + Test.@testset "Integration with OptionValue and OptionDefinition" begin + # Test complete workflow + defs = ( + size=Options.OptionDefinition( + name=:grid_size, + type=Int, + default=100, + description="Grid size", + aliases=(:n, :size), + validator=positive_validator, + ), + tolerance=Options.OptionDefinition( + name=:tolerance, + type=Float64, + default=1e-6, + description="Tolerance", + aliases=(:tol,), + ), + verbose=Options.OptionDefinition( + name=:verbose, type=Bool, default=false, description="Verbose" + ), + ) + + # Test with mixed aliases and validation + kwargs = (n=50, tol=1e-8, verbose=true, extra="ignored") + + extracted, remaining = Options.extract_options(kwargs, defs) + + # Verify all options extracted correctly + Test.@test extracted.size.value == 50 + Test.@test extracted.size.source == :user + Test.@test extracted.tolerance.value == 1e-8 + Test.@test extracted.tolerance.source == :user + Test.@test extracted.verbose.value == true + Test.@test extracted.verbose.source == :user + + # Verify only unused options remain + Test.@test remaining == (extra="ignored",) + + # Test OptionValue functionality + Test.@test string(extracted.size) == "50 (user)" + Test.@test extracted.size.value isa Int + Test.@test extracted.tolerance.value isa Float64 + Test.@test extracted.verbose.value isa Bool + end + + Test.@testset "Realistic tool configuration scenario" begin + # Simulate a realistic tool configuration + tool_defs = [ + Options.OptionDefinition( + name=:grid_size, + type=Int, + default=100, + description="Grid size", + aliases=(:n, :size), + ), + Options.OptionDefinition( + name=:tolerance, + type=Float64, + default=1e-6, + description="Tolerance", + aliases=(:tol,), + ), + Options.OptionDefinition( + name=:max_iterations, + type=Int, + default=1000, + description="Max iterations", + aliases=(:max_iter, :iterations), + ), + Options.OptionDefinition( + name=:solver, + type=String, + default="ipopt", + description="Solver", + aliases=(:algorithm,), + ), + Options.OptionDefinition( + name=:verbose, type=Bool, default=false, description="Verbose" + ), + Options.OptionDefinition( + name=:output_file, + type=String, + default=nothing, + description="Output file", + aliases=(:out, :output), + ), + ] + + # Test configuration with various options + config = ( + n=200, + tol=1e-8, + max_iter=500, + algorithm="knitro", + verbose=true, + output="results.txt", + debug_mode=true, # Extra option not in schemas + ) + + extracted, remaining = Options.extract_options(config, tool_defs) + + # Verify extraction + Test.@test extracted[:grid_size].value == 200 + Test.@test extracted[:tolerance].value == 1e-8 + Test.@test extracted[:max_iterations].value == 500 + Test.@test extracted[:solver].value == "knitro" + Test.@test extracted[:verbose].value == true + Test.@test extracted[:output_file].value == "results.txt" + + # Verify only non-schema options remain + Test.@test remaining == (debug_mode=true,) + + # Test all sources are correct + for (name, opt_value) in extracted + Test.@test opt_value.source == :user # All were provided + end + end + + Test.@testset "Edge cases and boundary conditions" begin + # Test with empty kwargs + def = Options.OptionDefinition( + name=:test, type=Int, default=42, description="Test" + ) + empty_kwargs = NamedTuple() + + opt_value, remaining = Options.extract_option(empty_kwargs, def) + Test.@test opt_value.value == 42 + Test.@test opt_value.source == :default + Test.@test remaining == NamedTuple() + + # Test with empty definitions + empty_defs = Options.OptionDefinition[] + kwargs = (a=1, b=2) + + extracted, remaining = Options.extract_options(kwargs, empty_defs) + Test.@test isempty(extracted) + Test.@test remaining == kwargs + + # Test with nothing default + def_no_default = Options.OptionDefinition( + name=:optional, type=String, default=nothing, description="Optional" + ) + kwargs_no_match = (other="value",) + + opt_value, remaining = Options.extract_option(kwargs_no_match, def_no_default) + Test.@test opt_value.value === nothing + Test.@test opt_value.source == :default + end + end # INTEGRATION TESTS +end # test_extraction_api() + +end # module + +test_extraction_api() = TestOptionsExtractionAPI.test_extraction_api() diff --git a/test/suite/options/test_not_provided.jl b/test/suite/options/test_not_provided.jl new file mode 100644 index 00000000..6e25411d --- /dev/null +++ b/test/suite/options/test_not_provided.jl @@ -0,0 +1,226 @@ +module TestOptionsNotProvided + +using Test: Test +import CTBase.Options +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +""" + test_not_provided() + +Test the NotProvided type and its behavior in the option system. +""" +function test_not_provided() + Test.@testset "NotProvided Type Tests" verbose = VERBOSE showtiming = SHOWTIMING begin + Test.@testset "NotProvided Basic Properties" begin + Test.@test Options.NotProvided isa Options.NotProvidedType + Test.@test typeof(Options.NotProvided) == Options.NotProvidedType + Test.@test string(Options.NotProvided) == "NotProvided" + end + + Test.@testset "OptionDefinition with NotProvided" begin + # Option with NotProvided default + def_not_provided = Options.OptionDefinition( + name=:optional_param, + type=Union{Int,Nothing}, + default=Options.NotProvided, + description="Optional parameter", + ) + + Test.@test Options.default(def_not_provided) === Options.NotProvided + Test.@test Options.default(def_not_provided) isa Options.NotProvidedType + + # Option with nothing default (different!) + def_nothing = Options.OptionDefinition( + name=:nullable_param, + type=Union{Int,Nothing}, + default=nothing, + description="Nullable parameter", + ) + + Test.@test Options.default(def_nothing) === nothing + Test.@test !(Options.default(def_nothing) isa Options.NotProvidedType) + end + + Test.@testset "extract_option with NotProvided" begin + def = Options.OptionDefinition( + name=:optional, + type=Union{Int,Nothing}, + default=Options.NotProvided, + description="Optional", + ) + + # Case 1: User provides value + kwargs_provided = (optional=42, other="test") + opt_val, remaining = Options.extract_option(kwargs_provided, def) + + Test.@test opt_val !== nothing # Should return OptionValue + Test.@test opt_val isa Options.OptionValue + Test.@test Options.value(opt_val) == 42 + Test.@test Options.source(opt_val) == :user + Test.@test !haskey(remaining, :optional) + + # Case 2: User does NOT provide value + kwargs_not_provided = (other="test",) + opt_val2, remaining2 = Options.extract_option(kwargs_not_provided, def) + + Test.@test opt_val2 isa Options.NotStoredType # Should return NotStored (signal "don't store") + Test.@test remaining2 == kwargs_not_provided + end + + Test.@testset "extract_options filters NotProvided" begin + defs = [ + Options.OptionDefinition( + name=:required, + type=Int, + default=100, + description="Required with default", + ), + Options.OptionDefinition( + name=:optional, + type=Union{Int,Nothing}, + default=Options.NotProvided, + description="Optional", + ), + Options.OptionDefinition( + name=:nullable, + type=Union{Int,Nothing}, + default=nothing, + description="Nullable with nothing default", + ), + ] + + # User provides only 'required' + kwargs = (required=200,) + extracted, remaining = Options.extract_options(kwargs, defs) + + # Check what's stored + Test.@test haskey(extracted, :required) + Test.@test !haskey(extracted, :optional) # NotProvided + not provided = not stored + Test.@test haskey(extracted, :nullable) # nothing default = always stored + + Test.@test Options.value(extracted[:required]) == 200 + Test.@test Options.value(extracted[:nullable]) === nothing + + # Verify NO NotProvidedType in extracted values + for (k, v) in pairs(extracted) + Test.@test !(Options.value(v) isa Options.NotProvidedType) + end + end + + Test.@testset "extract_options stores nothing defaults correctly" begin + # Test that options with explicit nothing default are stored + defs = [ + Options.OptionDefinition( + name=:backend, + type=Union{Nothing,Symbol}, + default=nothing, + description="Backend with nothing default", + ), + Options.OptionDefinition( + name=:minimize, + type=Union{Bool,Nothing}, + default=Options.NotProvided, + description="Minimize with NotProvided", + ), + ] + + # User provides neither option + kwargs = (other="test",) + extracted, remaining = Options.extract_options(kwargs, defs) + + # backend should be stored with nothing value + Test.@test haskey(extracted, :backend) + Test.@test Options.value(extracted[:backend]) === nothing + Test.@test Options.source(extracted[:backend]) == :default + + # minimize should NOT be stored + Test.@test !haskey(extracted, :minimize) + + # Now test when user provides backend = nothing explicitly + kwargs2 = (backend=nothing,) + extracted2, _ = Options.extract_options(kwargs2, defs) + + # backend should be stored with nothing value from user + Test.@test haskey(extracted2, :backend) + Test.@test Options.value(extracted2[:backend]) === nothing + Test.@test Options.source(extracted2[:backend]) == :user # User provided it + + # minimize still not stored + Test.@test !haskey(extracted2, :minimize) + end + + Test.@testset "extract_raw_options should never see NotProvided" begin + # Simulate what would be stored in an instance + stored_options = ( + backend=Options.OptionValue(:optimized, :default), + show_time=Options.OptionValue(false, :user), + nullable_opt=Options.OptionValue(nothing, :default), + # Note: optional with NotProvided is NOT here (not stored) + ) + + raw = Options.extract_raw_options(stored_options) + + # Verify all values are unwrapped + Test.@test raw.backend == :optimized + Test.@test raw.show_time == false + Test.@test raw.nullable_opt === nothing + + # Verify NO NotProvidedType in raw values + for (k, v) in pairs(stored_options) + Test.@test !(Options.value(v) isa Options.NotProvidedType) + end + end + + Test.@testset "Complete workflow: NotProvided never stored" begin + # Define options like Modelers.Exa + defs_nt = ( + base_type=Options.OptionDefinition( + name=:base_type, type=DataType, default=Float64, description="Base type" + ), + minimize=Options.OptionDefinition( + name=:minimize, + type=Union{Bool,Nothing}, + default=Options.NotProvided, + description="Minimize flag", + ), + backend=Options.OptionDefinition( + name=:backend, type=Any, default=nothing, description="Backend" + ), + ) + + # User provides only base_type + user_kwargs = (base_type=Float32,) + + # Extract options (what gets stored in instance) + extracted, _ = Options.extract_options(user_kwargs, defs_nt) + + # Verify minimize is NOT stored (NotProvided + not provided) + Test.@test haskey(extracted, :base_type) + Test.@test !haskey(extracted, :minimize) # ✅ Key point! + Test.@test haskey(extracted, :backend) # nothing default = stored + + # Verify NO NotProvidedType in extracted + for (k, v) in pairs(extracted) + Test.@test !(v.value isa Options.NotProvidedType) + end + + # Extract raw options (what gets passed to builder) + raw = Options.extract_raw_options(extracted) + + # Verify minimize is NOT in raw options + Test.@test haskey(raw, :base_type) + Test.@test !haskey(raw, :minimize) # ✅ Not passed to builder + Test.@test haskey(raw, :backend) + + # Verify NO NotProvidedType in raw + for (k, v) in pairs(raw) + Test.@test !(v isa Options.NotProvidedType) + end + end + end +end + +end # module + +test_not_provided() = TestOptionsNotProvided.test_not_provided() diff --git a/test/suite/options/test_option_definition.jl b/test/suite/options/test_option_definition.jl new file mode 100644 index 00000000..7ec1c92f --- /dev/null +++ b/test/suite/options/test_option_definition.jl @@ -0,0 +1,694 @@ +module TestOptionsOptionDefinition + +using Test: Test +import CTBase.Exceptions +import CTBase.Options +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +function test_option_definition() + Test.@testset "OptionDefinition" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # META TESTS - Exports / Public API surface + # ==================================================================== + + Test.@testset "Exports verification" begin + Test.@test isdefined(Options, :OptionDefinition) + Test.@test isdefined(Options, :name) + Test.@test isdefined(Options, :type) + Test.@test isdefined(Options, :default) + Test.@test isdefined(Options, :description) + Test.@test isdefined(Options, :aliases) + Test.@test isdefined(Options, :validator) + Test.@test isdefined(Options, :is_required) + Test.@test isdefined(Options, :has_default) + Test.@test isdefined(Options, :has_validator) + Test.@test isdefined(Options, :all_names) + end + + # ==================================================================== + # BASIC CONSTRUCTION + # ==================================================================== + + Test.@testset "Basic construction" begin + # Minimal constructor + def = Options.OptionDefinition( + name=:test_option, type=Int, default=42, description="Test option" + ) + Test.@test Options.name(def) == :test_option + Test.@test_nowarn Test.@inferred Options.name(def) + + Test.@test Options.type(def) == Int + + Test.@test Options.default(def) == 42 + Test.@test_nowarn Test.@inferred Options.default(def) + + Test.@test Options.description(def) == "Test option" + Test.@test_nowarn Test.@inferred Options.description(def) + + Test.@test Options.aliases(def) == () + + Test.@test Options.validator(def) === nothing + end + + # ======================================================================== + # Full construction with aliases and validator + # ======================================================================== + + Test.@testset "Full construction" begin + validator = x -> x > 0 + def = Options.OptionDefinition( + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:max, :maxiter), + validator=validator, + ) + Test.@test Options.name(def) == :max_iter + Test.@test Options.type(def) == Int + Test.@test Options.default(def) == 100 + Test.@test Options.description(def) == "Maximum iterations" + Test.@test Options.aliases(def) == (:max, :maxiter) + Test.@test Options.validator(def) === validator + end + + # ======================================================================== + # Minimal construction + # ======================================================================== + + Test.@testset "Minimal construction" begin + def = Options.OptionDefinition( + name=:test, type=String, default="default", description="Test option" + ) + Test.@test Options.name(def) == :test + Test.@test Options.type(def) == String + Test.@test Options.default(def) == "default" + Test.@test Options.description(def) == "Test option" + Test.@test Options.aliases(def) == () + Test.@test Options.validator(def) === nothing + end + + # ======================================================================== + # Validation + # ======================================================================== + + Test.@testset "Validation" begin + # Valid default value type + Test.@test_nowarn Options.OptionDefinition( + name=:test, type=Int, default=42, description="Test" + ) + + # Invalid default value type + Test.@test_throws Exceptions.IncorrectArgument Options.OptionDefinition( + name=:test, type=Int, default="not an int", description="Test" + ) + + # Valid validator with valid default + Test.@test_nowarn Options.OptionDefinition( + name=:test, type=Int, default=42, description="Test", validator=x -> x > 0 + ) + + # Invalid validator with invalid default (redirect stderr to hide @error logs) + Test.@test_throws ErrorException redirect_stderr(devnull) do + Options.OptionDefinition( + name=:test, + type=Int, + default=-5, + description="Test", + validator=x -> x > 0 || error("Must be positive"), + ) + end + end + + # ======================================================================== + # all_names function + # ======================================================================== + + Test.@testset "all_names function" begin + def = Options.OptionDefinition( + name=:max_iter, + type=Int, + default=100, + description="Test", + aliases=(:max, :maxiter), + ) + names = Options.all_names(def) + Test.@test names == (:max_iter, :max, :maxiter) + Test.@test_nowarn Options.all_names(def) + end + + # ==================================================================== + # HELPER FUNCTIONS + # ==================================================================== + + Test.@testset "Helper functions" begin + def_required = Options.OptionDefinition( + name=:input, + type=String, + default=Options.NotProvided, + description="Input file", + ) + def_optional = Options.OptionDefinition( + name=:max_iter, type=Int, default=100, description="Max iterations" + ) + def_with_validator = Options.OptionDefinition( + name=:tol, + type=Float64, + default=1e-6, + description="Tolerance", + validator=x -> x > 0, + ) + + Test.@test Options.is_required(def_required) == true + Test.@test_nowarn Test.@inferred Options.is_required(def_required) + + Test.@test Options.is_required(def_optional) == false + Test.@test_nowarn Test.@inferred Options.is_required(def_optional) + + Test.@test Options.has_default(def_required) == false + Test.@test_nowarn Test.@inferred Options.has_default(def_required) + + Test.@test Options.has_default(def_optional) == true + Test.@test_nowarn Test.@inferred Options.has_default(def_optional) + + Test.@test Options.has_validator(def_with_validator) == true + Test.@test_nowarn Test.@inferred Options.has_validator(def_with_validator) + + Test.@test Options.has_validator(def_optional) == false + Test.@test_nowarn Test.@inferred Options.has_validator(def_optional) + end + + # ======================================================================== + # Dispatch methods - Unit tests + # ======================================================================== + + Test.@testset "Dispatch methods - Unit tests" begin + Test.@testset "_construct_option_definition with Nothing" begin + # Test the Nothing dispatch method directly + def = Options._construct_option_definition( + :backend, + Union{Nothing,String}, + nothing, + "Execution backend", + (:be,), + nothing, + false, + ) + + Test.@test def isa Options.OptionDefinition{Any} + Test.@test Options.name(def) == :backend + Test.@test Options.type(def) == Any + Test.@test Options.default(def) === nothing + Test.@test Options.description(def) == "Execution backend" + Test.@test Options.aliases(def) == (:be,) + Test.@test Options.validator(def) === nothing + end + + Test.@testset "_construct_option_definition with NotProvided" begin + # Test the NotProvided dispatch method directly + def = Options._construct_option_definition( + :input_file, + String, + Options.NotProvided, + "Input file path", + (:input,), + nothing, + false, + ) + + Test.@test def isa Options.OptionDefinition{Options.NotProvidedType} + Test.@test Options.name(def) == :input_file + Test.@test Options.type(def) == String + Test.@test Options.default(def) === Options.NotProvided + Test.@test Options.description(def) == "Input file path" + Test.@test Options.aliases(def) == (:input,) + Test.@test Options.validator(def) === nothing + end + + Test.@testset "_construct_option_definition with concrete values" begin + # Test Int concrete value + def_int = Options._construct_option_definition( + :max_iter, + Int, + 100, + "Maximum iterations", + (:max, :maxiter), + nothing, + false, + ) + + Test.@test def_int isa Options.OptionDefinition{Int} + Test.@test Options.name(def_int) == :max_iter + Test.@test Options.type(def_int) == Int + Test.@test Options.default(def_int) == 100 + Test.@test Options.description(def_int) == "Maximum iterations" + Test.@test Options.aliases(def_int) == (:max, :maxiter) + Test.@test Options.validator(def_int) === nothing + + # Test String concrete value + def_string = Options._construct_option_definition( + :output_file, + String, + "output.txt", + "Output file name", + (), + nothing, + false, + ) + + Test.@test def_string isa Options.OptionDefinition{String} + Test.@test Options.name(def_string) == :output_file + Test.@test Options.type(def_string) == String + Test.@test Options.default(def_string) == "output.txt" + Test.@test Options.description(def_string) == "Output file name" + Test.@test Options.aliases(def_string) == () + Test.@test Options.validator(def_string) === nothing + + # Test Float64 concrete value with validator + validator = x -> x > 0 + def_float = Options._construct_option_definition( + :tolerance, Float64, 1e-6, "Convergence tolerance", (), validator, false + ) + + Test.@test def_float isa Options.OptionDefinition{Float64} + Test.@test Options.name(def_float) == :tolerance + Test.@test Options.type(def_float) == Float64 + Test.@test Options.default(def_float) == 1e-6 + Test.@test Options.description(def_float) == "Convergence tolerance" + Test.@test Options.aliases(def_float) == () + Test.@test Options.validator(def_float) === validator + end + + Test.@testset "_construct_option_definition type compatibility validation" begin + # Test type mismatch error in concrete dispatch method + Test.@test_throws Exceptions.IncorrectArgument Options._construct_option_definition( + :test_option, Int, "not an int", "Test option", (), nothing, false + ) + + # Test type mismatch with Union type + Test.@test_throws Exceptions.IncorrectArgument Options._construct_option_definition( + :test_option, + Union{Int,Float64}, + "not a number", + "Test option", + (), + nothing, + false, + ) + + # Test valid Union type (should work) + Test.@test_nowarn Options._construct_option_definition( + :test_option, + Union{Int,Float64}, + 42, # Int is valid for Union{Int, Float64} + "Test option", + (), + nothing, + false, + ) + end + end + + # ======================================================================== + # Constructor dispatch integration + # ======================================================================== + + Test.@testset "Constructor dispatch integration" begin + Test.@testset "Public constructor routes to correct dispatch method" begin + # Test that public constructor with nothing creates OptionDefinition{Any} + def_nothing = Options.OptionDefinition( + name=:backend, + type=Union{Nothing,String}, + default=nothing, + description="Execution backend", + ) + Test.@test def_nothing isa Options.OptionDefinition{Any} + Test.@test Options.type(def_nothing) == Any # type is overridden to Any + + # Test that public constructor with NotProvided creates OptionDefinition{NotProvidedType} + def_not_provided = Options.OptionDefinition( + name=:input_file, + type=String, + default=Options.NotProvided, + description="Input file", + ) + Test.@test def_not_provided isa + Options.OptionDefinition{Options.NotProvidedType} + Test.@test Options.type(def_not_provided) == String # type is preserved + + # Test that public constructor with concrete value creates correct type + def_concrete = Options.OptionDefinition( + name=:max_iter, type=Int, default=100, description="Maximum iterations" + ) + Test.@test def_concrete isa Options.OptionDefinition{Int} + Test.@test Options.type(def_concrete) == Int + end + + Test.@testset "Type parameter inference correctness" begin + # Test various concrete types + def_int = Options.OptionDefinition( + name=:i, type=Int, default=42, description="int" + ) + Test.@test def_int isa Options.OptionDefinition{Int64} + + def_float = Options.OptionDefinition( + name=:f, type=Float64, default=3.14, description="float" + ) + Test.@test def_float isa Options.OptionDefinition{Float64} + + def_string = Options.OptionDefinition( + name=:s, type=String, default="hello", description="string" + ) + Test.@test def_string isa Options.OptionDefinition{String} + + def_bool = Options.OptionDefinition( + name=:b, type=Bool, default=true, description="bool" + ) + Test.@test def_bool isa Options.OptionDefinition{Bool} + + # Test that Nothing always creates Any regardless of declared type + def_any_type = Options.OptionDefinition( + name=:any, type=String, default=nothing, description="any" + ) + Test.@test def_any_type isa Options.OptionDefinition{Any} + Test.@test Options.type(def_any_type) == Any + end + end + + # ======================================================================== + # Edge cases + # ======================================================================== + + Test.@testset "Edge cases" begin + # nothing default (allowed) + def = Options.OptionDefinition( + name=:test, type=Any, default=nothing, description="Test" + ) + Test.@test def.default === nothing + + # nothing validator (allowed) + def = Options.OptionDefinition( + name=:test, type=Int, default=42, description="Test", validator=nothing + ) + Test.@test def.validator === nothing + end + + # ======================================================================== + # Getters and introspection + # ======================================================================== + + Test.@testset "Getters and introspection" begin + validator = x -> x > 0 + def = Options.OptionDefinition( + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:max, :maxiter), + validator=validator, + ) + + Test.@test Options.name(def) === :max_iter + Test.@test Options.type(def) === Int + Test.@test Options.default(def) === 100 + Test.@test Options.description(def) == "Maximum iterations" + Test.@test Options.aliases(def) == (:max, :maxiter) + Test.@test Options.validator(def) === validator + Test.@test Options.has_default(def) === true + Test.@test Options.is_required(def) === false + Test.@test Options.has_validator(def) === true + + required_def = Options.OptionDefinition( + name=:input, + type=String, + default=Options.NotProvided, + description="Input file", + ) + Test.@test Options.has_default(required_def) === false + Test.@test Options.is_required(required_def) === true + Test.@test Options.has_validator(required_def) === false + end + + # ======================================================================== + # Type stability tests + # ======================================================================== + + Test.@testset "Type stability" begin + # Test that OptionDefinition is parameterized correctly + def_int = Options.OptionDefinition( + name=:test_int, type=Int, default=42, description="Test" + ) + Test.@test def_int isa Options.OptionDefinition{Int64} + + def_float = Options.OptionDefinition( + name=:test_float, type=Float64, default=3.14, description="Test" + ) + Test.@test def_float isa Options.OptionDefinition{Float64} + + def_string = Options.OptionDefinition( + name=:test_string, type=String, default="hello", description="Test" + ) + Test.@test def_string isa Options.OptionDefinition{String} + + # Test type-stable access to default field via function + function get_default(def::Options.OptionDefinition{T}) where {T} + return def.default + end + + Test.@inferred get_default(def_int) + Test.@test typeof(def_int.default) === Int64 + Test.@test get_default(def_int) === 42 + + Test.@inferred get_default(def_float) + Test.@test typeof(def_float.default) === Float64 + Test.@test get_default(def_float) === 3.14 + + Test.@inferred get_default(def_string) + Test.@test typeof(def_string.default) === String + Test.@test get_default(def_string) === "hello" + + # Test heterogeneous collections (Vector{OptionDefinition{<:Any}}) + defs = Options.OptionDefinition[def_int, def_float, def_string] + Test.@test length(defs) == 3 + Test.@test defs[1] isa Options.OptionDefinition{Int64} + Test.@test defs[2] isa Options.OptionDefinition{Float64} + Test.@test defs[3] isa Options.OptionDefinition{String} + + # Test that accessing defaults in a loop maintains type information + function sum_int_defaults(defs::Vector{<:Options.OptionDefinition}) + total = 0 + for def in defs + if def isa Options.OptionDefinition{Int} + total += def.default # Type-stable within branch + end + end + return total + end + + int_defs = [ + Options.OptionDefinition( + name=Symbol("opt$i"), type=Int, default=i, description="test" + ) for i in 1:5 + ] + Test.@test sum_int_defaults(int_defs) == 15 + end + + # ======================================================================== + # Display functionality + # ======================================================================== + + Test.@testset "Display" begin + # Test individual components without relying on exact color formatting + def_min = Options.OptionDefinition( + name=:test, type=Int, default=42, description="Test option" + ) + def_full = Options.OptionDefinition( + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:max, :maxiter), + ) + + # Test minimal definition components + io_min = IOBuffer() + println(io_min, def_min) + output_min = String(take!(io_min)) + + Test.@test occursin("test", output_min) + Test.@test occursin("Int", output_min) + Test.@test occursin("42", output_min) + Test.@test occursin("default", output_min) # "default" appears in the output + + # Test full definition components + io_full = IOBuffer() + println(io_full, def_full) + output_full = String(take!(io_full)) + + Test.@test occursin("max_iter", output_full) + Test.@test occursin("max", output_full) + Test.@test occursin("maxiter", output_full) + Test.@test occursin("100", output_full) + Test.@test occursin("default", output_full) # "default" appears in the output + end + end + + Test.@testset "Type parameter inference correctness" begin + # Test various concrete types + def_int = Options.OptionDefinition(name=:i, type=Int, default=42, description="int") + Test.@test def_int isa Options.OptionDefinition{Int64} + + def_float = Options.OptionDefinition( + name=:f, type=Float64, default=3.14, description="float" + ) + Test.@test def_float isa Options.OptionDefinition{Float64} + + def_string = Options.OptionDefinition( + name=:s, type=String, default="hello", description="string" + ) + Test.@test def_string isa Options.OptionDefinition{String} + + def_bool = Options.OptionDefinition( + name=:b, type=Bool, default=true, description="bool" + ) + Test.@test def_bool isa Options.OptionDefinition{Bool} + + # Test that Nothing always creates Any regardless of declared type + def_any_type = Options.OptionDefinition( + name=:any, type=String, default=nothing, description="any" + ) + Test.@test def_any_type isa Options.OptionDefinition{Any} + Test.@test Options.type(def_any_type) == Any + end +end + +# ======================================================================== +# Edge cases +# ======================================================================== + +Test.@testset "Edge cases" begin + # nothing default (allowed) + def = Options.OptionDefinition( + name=:test, type=Any, default=nothing, description="Test" + ) + Test.@test def.default === nothing + + # nothing validator (allowed) + def = Options.OptionDefinition( + name=:test, type=Int, default=42, description="Test", validator=nothing + ) + Test.@test def.validator === nothing +end + +# ======================================================================== +# Getters and introspection +# ======================================================================== + +Test.@testset "Getters and introspection" begin + validator = x -> x > 0 + def = Options.OptionDefinition( + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:max, :maxiter), + validator=validator, + ) + + Test.@test Options.name(def) === :max_iter + Test.@test Options.type(def) === Int + Test.@test Options.default(def) === 100 + Test.@test Options.description(def) == "Maximum iterations" + Test.@test Options.aliases(def) == (:max, :maxiter) + Test.@test Options.validator(def) === validator + Test.@test Options.has_default(def) === true + Test.@test Options.is_required(def) === false + Test.@test Options.has_validator(def) === true + + required_def = Options.OptionDefinition( + name=:input, type=String, default=Options.NotProvided, description="Input file" + ) + Test.@test Options.has_default(required_def) === false + Test.@test Options.is_required(required_def) === true + Test.@test Options.has_validator(required_def) === false +end + +# ======================================================================== +# Type stability tests +# ======================================================================== + +Test.@testset "Type stability" begin + # Test that OptionDefinition is parameterized correctly + def_int = Options.OptionDefinition( + name=:test_int, type=Int, default=42, description="Test" + ) + Test.@test def_int isa Options.OptionDefinition{Int64} + + def_float = Options.OptionDefinition( + name=:test_float, type=Float64, default=3.14, description="Test" + ) + Test.@test def_float isa Options.OptionDefinition{Float64} + + def_string = Options.OptionDefinition( + name=:test_string, type=String, default="hello", description="Test" + ) + Test.@test def_string isa Options.OptionDefinition{String} + + # Test type-stable access to default field via function + function get_default(def::Options.OptionDefinition{T}) where {T} + return def.default + end + + Test.@inferred get_default(def_int) + Test.@test typeof(def_int.default) === Int64 + Test.@test get_default(def_int) === 42 + + Test.@inferred get_default(def_float) + Test.@test typeof(def_float.default) === Float64 + Test.@test get_default(def_float) === 3.14 + + Test.@inferred get_default(def_string) + Test.@test typeof(def_string.default) === String + Test.@test get_default(def_string) === "hello" + + # Test heterogeneous collections (Vector{OptionDefinition{<:Any}}) + defs = Options.OptionDefinition[def_int, def_float, def_string] + Test.@test length(defs) == 3 + Test.@test defs[1] isa Options.OptionDefinition{Int64} + Test.@test defs[2] isa Options.OptionDefinition{Float64} + Test.@test defs[3] isa Options.OptionDefinition{String} + + # Test that accessing defaults in a loop maintains type information + function sum_int_defaults(defs::Vector{<:Options.OptionDefinition}) + total = 0 + for def in defs + if def isa Options.OptionDefinition{Int} + total += def.default # Type-stable within branch + end + end + return total + end + + int_defs = [ + Options.OptionDefinition( + name=Symbol("opt$i"), type=Int, default=i, description="test" + ) for i in 1:5 + ] + Test.@test sum_int_defaults(int_defs) == 15 +end + +# ======================================================================== +# Display functionality +# ======================================================================== + +Test.@testset "Display" begin + # Skip display tests with colors - they're visually verified in documentation + # The show methods work correctly, colors are just hard to test with occursin + Test.@test true # Placeholder to ensure testset runs +end + +end # module + +test_option_definition() = TestOptionsOptionDefinition.test_option_definition() diff --git a/test/suite/options/test_options.jl b/test/suite/options/test_options.jl new file mode 100644 index 00000000..ae4696be --- /dev/null +++ b/test/suite/options/test_options.jl @@ -0,0 +1,371 @@ +module TestOptions + +using Test: Test +import CTBase.Exceptions +using CTBase: CTBase +import CTBase.Options +using CTBase.Options # For testing exported symbols + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true +const CurrentModule = TestOptions + +""" + test_options() + +Tests for Options module. + +This function tests the complete Options module including: +- Option value tracking with provenance +- Option schema definition with validation and aliases +- Option extraction with alias support +- Type validation and helpful error messages +""" +function test_options() + Test.@testset "Options Module" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # META TESTS - Exports / Public API surface + # ==================================================================== + + Test.@testset "Exports verification" begin + # Test that Options module is available + Test.@testset "Options Module" begin + Test.@test isdefined(CTBase, :Options) + Test.@test CTBase.Options isa Module + end + + # Test exported types + Test.@testset "Exported Types" begin + for T in (NotProvidedType, OptionValue, OptionDefinition) + Test.@testset "$(nameof(T))" begin + Test.@test isdefined(Options, nameof(T)) + Test.@test isdefined(CurrentModule, nameof(T)) + Test.@test T isa DataType || T isa UnionAll + end + end + end + + # Test exported constants + Test.@testset "Exported Constants" begin + for c in (:NotProvided,) + Test.@testset "$c" begin + Test.@test isdefined(Options, c) + Test.@test isdefined(CurrentModule, c) + end + end + end + + # Test exported functions + Test.@testset "Exported Functions" begin + for f in ( + :extract_option, + :extract_options, + :extract_raw_options, + :all_names, + :aliases, + :is_required, + :has_default, + :has_validator, + :name, + :type, + :default, + :description, + :validator, + :value, + :source, + :is_user, + :is_default, + :is_computed, + ) + Test.@testset "$f" begin + Test.@test isdefined(Options, f) + Test.@test isdefined(CurrentModule, f) + Test.@test getfield(CurrentModule, f) isa Function + end + end + end + end + + # ==================================================================== + # UNIT TESTS - NotProvided and NotStored + # ==================================================================== + + Test.@testset "NotProvided and NotStored" begin + Test.@testset "NotProvided display" begin + buf = IOBuffer() + show(buf, Options.NotProvided) + Test.@test String(take!(buf)) == "NotProvided" + end + + Test.@testset "NotStored display" begin + buf = IOBuffer() + show(buf, Options.NotStored) + Test.@test String(take!(buf)) == "NotStored" + end + + Test.@testset "NotStored type" begin + Test.@test Options.NotStored isa Options.NotStoredType + Test.@test typeof(Options.NotStored) == Options.NotStoredType + end + end + + # ==================================================================== + # UNIT TESTS - OptionValue construction and helpers + # ==================================================================== + + Test.@testset "OptionValue" begin + Test.@testset "OptionValue construction" begin + # Test with explicit source + opt_user = Options.OptionValue(42, :user) + Test.@test opt_user.value == 42 + Test.@test opt_user.source == :user + Test.@test typeof(opt_user) == Options.OptionValue{Int} + + # Test with default source (note: default source is :user in current implementation) + opt_default = Options.OptionValue(3.14) + Test.@test opt_default.value == 3.14 + Test.@test opt_default.source == :user + Test.@test typeof(opt_default) == Options.OptionValue{Float64} + + # Test with different types + opt_str = Options.OptionValue("hello", :default) + Test.@test opt_str.value == "hello" + Test.@test opt_str.source == :default + + opt_bool = Options.OptionValue(true, :computed) + Test.@test opt_bool.value == true + Test.@test opt_bool.source == :computed + end + + Test.@testset "OptionValue validation" begin + # Test invalid sources + Test.@test_throws Exceptions.IncorrectArgument Options.OptionValue( + 42, :invalid + ) + Test.@test_throws Exceptions.IncorrectArgument Options.OptionValue( + 42, :wrong + ) + Test.@test_throws Exceptions.IncorrectArgument Options.OptionValue( + 42, :DEFAULT + ) # case sensitive + end + + Test.@testset "OptionValue display" begin + opt = Options.OptionValue(100, :user) + io = IOBuffer() + Base.show(io, opt) + Test.@test String(take!(io)) == "100 (user)" + + opt_default = Options.OptionValue(3.14, :default) + io = IOBuffer() + Base.show(io, opt_default) + Test.@test String(take!(io)) == "3.14 (default)" + end + + Test.@testset "OptionValue type stability" begin + opt_int = Options.OptionValue(42, :user) + opt_float = Options.OptionValue(3.14, :user) + + # Test that types are preserved + Test.@test typeof(opt_int.value) == Int + Test.@test typeof(opt_float.value) == Float64 + + # Test that the struct is parameterized correctly + Test.@test typeof(opt_int) == Options.OptionValue{Int} + Test.@test typeof(opt_float) == Options.OptionValue{Float64} + end + + Test.@testset "Getters and introspection" begin + opt_user = Options.OptionValue(42, :user) + opt_default = Options.OptionValue(3.14, :default) + opt_computed = Options.OptionValue(true, :computed) + + Test.@test Options.value(opt_user) === 42 + Test.@test Options.source(opt_user) === :user + Test.@test_nowarn Test.@inferred Options.source(opt_user) + + Test.@test Options.is_user(opt_user) === true + Test.@test_nowarn Test.@inferred Options.is_user(opt_user) + + Test.@test Options.is_default(opt_default) === true + Test.@test_nowarn Test.@inferred Options.is_default(opt_default) + + Test.@test Options.is_computed(opt_computed) === true + Test.@test_nowarn Test.@inferred Options.is_computed(opt_computed) + Test.@test Options.is_default(opt_user) === false + Test.@test Options.is_computed(opt_user) === false + + Test.@test Options.value(opt_default) === 3.14 + Test.@test Options.source(opt_default) === :default + Test.@test Options.is_user(opt_default) === false + Test.@test Options.is_default(opt_default) === true + Test.@test Options.is_computed(opt_default) === false + + Test.@test Options.value(opt_computed) === true + Test.@test Options.source(opt_computed) === :computed + Test.@test Options.is_user(opt_computed) === false + Test.@test Options.is_default(opt_computed) === false + Test.@test Options.is_computed(opt_computed) === true + end + end + + # ==================================================================== + # UNIT TESTS - OptionDefinition + # ==================================================================== + + Test.@testset "OptionDefinition" begin + Test.@testset "Basic construction" begin + opt_def = Options.OptionDefinition( + name=:test_option, type=Int, default=42, description="Test option" + ) + + Test.@test Options.name(opt_def) == :test_option + Test.@test Options.type(opt_def) == Int + Test.@test Options.default(opt_def) == 42 + Test.@test Options.description(opt_def) == "Test option" + Test.@test Options.aliases(opt_def) == () + Test.@test Options.validator(opt_def) === nothing + end + + Test.@testset "With aliases" begin + opt_def = Options.OptionDefinition( + name=:test_option, + type=Int, + default=42, + description="Test option", + aliases=(:test_opt, :alias2), + ) + + Test.@test Options.aliases(opt_def) == (:test_opt, :alias2) + Test.@test Options.all_names(opt_def) == (:test_option, :test_opt, :alias2) + end + + Test.@testset "With validator" begin + validator = x -> x > 0 + opt_def = Options.OptionDefinition( + name=:test_option, + type=Int, + default=42, + description="Test option", + validator=validator, + ) + + Test.@test Options.validator(opt_def) === validator + Test.@test Options.has_validator(opt_def) == true + end + + Test.@testset "Helper functions" begin + opt_def_with_default = Options.OptionDefinition( + name=:test_option, type=Int, default=42, description="Test option" + ) + opt_def_no_default = Options.OptionDefinition( + name=:required_option, + type=Int, + default=Options.NotProvided, + description="Required option", + ) + + Test.@test Options.has_default(opt_def_with_default) == true + Test.@test Options.has_default(opt_def_no_default) == false + Test.@test Options.is_required(opt_def_no_default) == true + Test.@test Options.is_required(opt_def_with_default) == false + end + end + + # ==================================================================== + # UNIT TESTS - Option extraction + # ==================================================================== + + Test.@testset "Option extraction" begin + Test.@testset "extract_option" begin + opt_def = Options.OptionDefinition( + name=:test_option, + type=Int, + default=42, + description="Test option", + aliases=(:alias1, :alias2), + ) + + # Test extraction by primary name + options = (test_option=100,) + opt_value, remaining = Options.extract_option(options, opt_def) + Test.@test opt_value isa Options.OptionValue + Test.@test opt_value.value == 100 + Test.@test opt_value.source == :user + Test.@test remaining isa NamedTuple + + # Test extraction by alias + options = (alias1=200,) + opt_value, remaining = Options.extract_option(options, opt_def) + Test.@test opt_value isa Options.OptionValue + Test.@test opt_value.value == 200 + Test.@test opt_value.source == :user + Test.@test remaining isa NamedTuple + + # Test default when not provided + options = NamedTuple() + opt_value, remaining = Options.extract_option(options, opt_def) + Test.@test opt_value isa Options.OptionValue + Test.@test opt_value.value == 42 + Test.@test opt_value.source == :default + Test.@test remaining isa NamedTuple + + # Test NotProvided when no default + opt_def_no_default = Options.OptionDefinition( + name=:required_option, + type=Int, + default=Options.NotProvided, + description="Required option", + ) + options = NamedTuple() + opt_value, remaining = Options.extract_option(options, opt_def_no_default) + Test.@test opt_value isa Options.NotStoredType + Test.@test remaining isa NamedTuple + end + + Test.@testset "extract_options" begin + opt_defs = [ + Options.OptionDefinition( + name=:option1, type=Int, default=1, description="First option" + ), + Options.OptionDefinition( + name=:option2, + type=String, + default="default", + description="Second option", + ), + Options.OptionDefinition( + name=:option3, + type=Float64, + default=Options.NotProvided, + description="Third option (required)", + ), + ] + + # Test with all options provided + options = (option1=10, option2="custom", option3=3.14) + extracted, remaining = Options.extract_options(options, opt_defs) + Test.@test extracted[:option1] isa Options.OptionValue + Test.@test extracted[:option1].value == 10 + Test.@test extracted[:option2] isa Options.OptionValue + Test.@test extracted[:option2].value == "custom" + Test.@test extracted[:option3] isa Options.OptionValue + Test.@test extracted[:option3].value == 3.14 + Test.@test remaining isa NamedTuple + + # Test with some defaults + options = (option3=2.71,) + extracted, remaining = Options.extract_options(options, opt_defs) + Test.@test extracted[:option1].value == 1 # default + Test.@test extracted[:option2].value == "default" # default + Test.@test extracted[:option3].value == 2.71 # provided + Test.@test remaining isa NamedTuple + end + end + end +end + +end # module + +test_options() = TestOptions.test_options() diff --git a/test/suite/options/test_options_value.jl b/test/suite/options/test_options_value.jl new file mode 100644 index 00000000..15e53253 --- /dev/null +++ b/test/suite/options/test_options_value.jl @@ -0,0 +1,130 @@ +module TestOptionsValue + +using Test: Test +import CTBase.Exceptions +using CTBase: CTBase +import CTBase.Options +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +function test_options_value() + Test.@testset "Options module" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # META TESTS - Exports / Public API surface + # ==================================================================== + + Test.@testset "Exports verification" begin + Test.@test isdefined(CTBase, :Options) + Test.@test isdefined(Options, :OptionValue) + Test.@test isdefined(Options, :NotProvided) + Test.@test isdefined(Options, :value) + Test.@test isdefined(Options, :source) + Test.@test isdefined(Options, :is_user) + Test.@test isdefined(Options, :is_default) + Test.@test isdefined(Options, :is_computed) + end + + # ==================================================================== + # UNIT TESTS - OptionValue construction and helpers + # ==================================================================== + Test.@testset "OptionValue construction" begin + # Test with explicit source + opt_user = Options.OptionValue(42, :user) + Test.@test opt_user.value == 42 + Test.@test opt_user.source == :user + Test.@test typeof(opt_user) == Options.OptionValue{Int} + + # Test with default source (note: default source is :user in current implementation) + opt_default = Options.OptionValue(3.14) + Test.@test opt_default.value == 3.14 + Test.@test opt_default.source == :user + Test.@test typeof(opt_default) == Options.OptionValue{Float64} + + # Test with different types + opt_str = Options.OptionValue("hello", :default) + Test.@test opt_str.value == "hello" + Test.@test opt_str.source == :default + + opt_bool = Options.OptionValue(true, :computed) + Test.@test opt_bool.value == true + Test.@test opt_bool.source == :computed + end + + # Test OptionValue validation + Test.@testset "OptionValue validation" begin + # Test invalid sources + Test.@test_throws Exceptions.IncorrectArgument Options.OptionValue(42, :invalid) + Test.@test_throws Exceptions.IncorrectArgument Options.OptionValue(42, :wrong) + Test.@test_throws Exceptions.IncorrectArgument Options.OptionValue(42, :DEFAULT) # case sensitive + end + + # Test OptionValue display + Test.@testset "OptionValue display" begin + opt = Options.OptionValue(100, :user) + io = IOBuffer() + Base.show(io, opt) + Test.@test String(take!(io)) == "100 (user)" + + opt_default = Options.OptionValue(3.14, :default) + io = IOBuffer() + Base.show(io, opt_default) + Test.@test String(take!(io)) == "3.14 (default)" + end + + # Test OptionValue type stability + Test.@testset "OptionValue type stability" begin + opt_int = Options.OptionValue(42, :user) + opt_float = Options.OptionValue(3.14, :user) + + # Test that types are preserved + Test.@test typeof(opt_int.value) == Int + Test.@test typeof(opt_float.value) == Float64 + + # Test that the struct is parameterized correctly + Test.@test typeof(opt_int) == Options.OptionValue{Int} + Test.@test typeof(opt_float) == Options.OptionValue{Float64} + end + + # ======================================================================== + # Getters and introspection + # ======================================================================== + + Test.@testset "Getters and introspection" begin + opt_user = Options.OptionValue(42, :user) + opt_default = Options.OptionValue(3.14, :default) + opt_computed = Options.OptionValue(true, :computed) + + Test.@test Options.value(opt_user) === 42 + Test.@test Options.source(opt_user) === :user + Test.@test_nowarn Test.@inferred Options.source(opt_user) + + Test.@test Options.is_user(opt_user) === true + Test.@test_nowarn Test.@inferred Options.is_user(opt_user) + + Test.@test Options.is_default(opt_default) === true + Test.@test_nowarn Test.@inferred Options.is_default(opt_default) + + Test.@test Options.is_computed(opt_computed) === true + Test.@test_nowarn Test.@inferred Options.is_computed(opt_computed) + Test.@test Options.is_default(opt_user) === false + Test.@test Options.is_computed(opt_user) === false + + Test.@test Options.value(opt_default) === 3.14 + Test.@test Options.source(opt_default) === :default + Test.@test Options.is_user(opt_default) === false + Test.@test Options.is_default(opt_default) === true + Test.@test Options.is_computed(opt_default) === false + + Test.@test Options.value(opt_computed) === true + Test.@test Options.source(opt_computed) === :computed + Test.@test Options.is_user(opt_computed) === false + Test.@test Options.is_default(opt_computed) === false + Test.@test Options.is_computed(opt_computed) === true + end + end +end + +end # module + +test_options_value() = TestOptionsValue.test_options_value() diff --git a/test/suite/orchestration/test_coverage_disambiguation.jl b/test/suite/orchestration/test_coverage_disambiguation.jl new file mode 100644 index 00000000..1c52f351 --- /dev/null +++ b/test/suite/orchestration/test_coverage_disambiguation.jl @@ -0,0 +1,262 @@ +module TestCoverageDisambiguation + +using Test: Test +import CTBase.Exceptions +import CTBase.Orchestration +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Test fixtures (minimal strategy setup) +# ============================================================================ + +abstract type CovDiscretizer <: Strategies.AbstractStrategy end +abstract type CovModeler <: Strategies.AbstractStrategy end +abstract type CovSolver <: Strategies.AbstractStrategy end + +struct CovCollocation <: CovDiscretizer + options::Strategies.StrategyOptions +end +Strategies.id(::Type{CovCollocation}) = :collocation +function Strategies.metadata(::Type{CovCollocation}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:grid_size, type=Int, default=100, description="Grid size" + ), + ) +end + +struct CovADNLP <: CovModeler + options::Strategies.StrategyOptions +end +Strategies.id(::Type{CovADNLP}) = :adnlp +function Strategies.metadata(::Type{CovADNLP}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:backend, + type=Symbol, + default=:dense, + description="Backend type", + aliases=(:adnlp_backend,), + ), + ) +end + +struct CovIpopt <: CovSolver + options::Strategies.StrategyOptions +end +Strategies.id(::Type{CovIpopt}) = :ipopt +function Strategies.metadata(::Type{CovIpopt}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, + type=Int, + default=1000, + description="Maximum iterations", + aliases=(:maxiter,), + ), + Options.OptionDefinition(; + name=:backend, + type=Symbol, + default=:cpu, + description="Solver backend", + aliases=(:ipopt_backend,), + ), + ) +end + +const COV_REGISTRY = Strategies.create_registry( + CovDiscretizer => (CovCollocation,), CovModeler => (CovADNLP,), CovSolver => (CovIpopt,) +) + +const COV_METHOD = (:collocation, :adnlp, :ipopt) + +const COV_FAMILIES = (discretizer=CovDiscretizer, modeler=CovModeler, solver=CovSolver) + +const COV_ACTION_DEFS = [ + Options.OptionDefinition(; + name=:display, type=Bool, default=true, description="Display progress" + ), +] + +# ============================================================================ +# Test function +# ============================================================================ + +function test_coverage_disambiguation() + Test.@testset "Coverage: Disambiguation & Routing" verbose=VERBOSE showtiming=SHOWTIMING begin + resolved = Orchestration.resolve_method(COV_METHOD, COV_FAMILIES, COV_REGISTRY) + + # ==================================================================== + # UNIT TESTS - build_alias_to_primary_map (disambiguation.jl) + # ==================================================================== + + Test.@testset "build_alias_to_primary_map" begin + alias_map = Orchestration.build_alias_to_primary_map( + resolved, COV_FAMILIES, COV_REGISTRY + ) + + Test.@test alias_map isa Dict{Symbol,Symbol} + Test.@test alias_map[:adnlp_backend] == :backend + Test.@test alias_map[:ipopt_backend] == :backend + Test.@test alias_map[:maxiter] == :max_iter + Test.@test !haskey(alias_map, :grid_size) + Test.@test !haskey(alias_map, :backend) + end + + # ==================================================================== + # UNIT TESTS - route_all_options (routing.jl) + # ==================================================================== + + Test.@testset "route_all_options - auto-route unambiguous" begin + result = Orchestration.route_all_options( + COV_METHOD, + COV_FAMILIES, + COV_ACTION_DEFS, + (; grid_size=200, max_iter=500, display=false), + COV_REGISTRY, + ) + + Test.@test Options.value(result.action.display) == false + Test.@test result.strategies.discretizer.grid_size == 200 + Test.@test result.strategies.solver.max_iter == 500 + end + + Test.@testset "route_all_options - disambiguated option" begin + result = Orchestration.route_all_options( + COV_METHOD, + COV_FAMILIES, + COV_ACTION_DEFS, + (; backend=Strategies.route_to(adnlp=:sparse)), + COV_REGISTRY, + ) + + Test.@test result.strategies.modeler.backend == :sparse + end + + Test.@testset "route_all_options - multi-strategy disambiguation" begin + result = Orchestration.route_all_options( + COV_METHOD, + COV_FAMILIES, + COV_ACTION_DEFS, + (; backend=Strategies.route_to(adnlp=:sparse, ipopt=:gpu)), + COV_REGISTRY, + ) + + Test.@test result.strategies.modeler.backend == :sparse + Test.@test result.strategies.solver.backend == :gpu + end + + Test.@testset "route_all_options - unknown option error" begin + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + COV_METHOD, + COV_FAMILIES, + COV_ACTION_DEFS, + (; totally_unknown=42), + COV_REGISTRY, + ) + end + + Test.@testset "route_all_options - ambiguous option error (description mode)" begin + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + COV_METHOD, + COV_FAMILIES, + COV_ACTION_DEFS, + (; backend=:sparse), + COV_REGISTRY; + source_mode=:description, + ) + end + + Test.@testset "route_all_options - ambiguous option error (explicit mode)" begin + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + COV_METHOD, + COV_FAMILIES, + COV_ACTION_DEFS, + (; backend=:sparse), + COV_REGISTRY; + source_mode=:explicit, + ) + end + + Test.@testset "route_all_options - invalid mode" begin + # mode parameter no longer exists in route_all_options + # invalid keyword arguments throw MethodError, not IncorrectArgument + Test.@test_throws Exception Orchestration.route_all_options( + COV_METHOD, + COV_FAMILIES, + COV_ACTION_DEFS, + (;), + COV_REGISTRY; + mode=:invalid_mode, + ) + end + + Test.@testset "route_all_options - invalid routing target" begin + # Route backend to discretizer (which doesn't own backend) + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + COV_METHOD, + COV_FAMILIES, + COV_ACTION_DEFS, + (; backend=Strategies.route_to(collocation=:sparse)), + COV_REGISTRY, + ) + end + + Test.@testset "route_all_options - bypass unknown disambiguated" begin + # Use bypass(val) to route unknown options + result = Orchestration.route_all_options( + COV_METHOD, + COV_FAMILIES, + COV_ACTION_DEFS, + (; unknown_opt=Strategies.route_to(adnlp=Strategies.bypass(42))), + COV_REGISTRY, + ) + + bv = result.strategies.modeler[:unknown_opt] + Test.@test bv isa Strategies.BypassValue + Test.@test bv.value == 42 + end + + Test.@testset "route_all_options - strict mode unknown disambiguated" begin + # Without bypass, unknown options always fail + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + COV_METHOD, + COV_FAMILIES, + COV_ACTION_DEFS, + (; unknown_opt=Strategies.route_to(adnlp=42)), + COV_REGISTRY, + ) + end + + Test.@testset "route_all_options - empty kwargs" begin + result = Orchestration.route_all_options( + COV_METHOD, COV_FAMILIES, COV_ACTION_DEFS, (;), COV_REGISTRY + ) + + Test.@test Options.value(result.action.display) == true + Test.@test isempty(result.strategies.discretizer) + Test.@test isempty(result.strategies.modeler) + Test.@test isempty(result.strategies.solver) + end + + # ==================================================================== + # UNIT TESTS - alias routing via ownership map + # ==================================================================== + + Test.@testset "route_all_options - alias auto-route" begin + result = Orchestration.route_all_options( + COV_METHOD, COV_FAMILIES, COV_ACTION_DEFS, (; maxiter=500), COV_REGISTRY + ) + + Test.@test result.strategies.solver.maxiter == 500 + end + end +end + +end # module + +test_coverage_disambiguation() = TestCoverageDisambiguation.test_coverage_disambiguation() diff --git a/test/suite/orchestration/test_coverage_missing_parameter.jl b/test/suite/orchestration/test_coverage_missing_parameter.jl new file mode 100644 index 00000000..42fb8aee --- /dev/null +++ b/test/suite/orchestration/test_coverage_missing_parameter.jl @@ -0,0 +1,140 @@ +module TestCoverageMissingParameter + +using Test: Test +import CTBase.Exceptions +import CTBase.Orchestration +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# FAKE TYPES FOR TESTING (TOP-LEVEL) +# ============================================================================ + +abstract type FakeFamily <: Strategies.AbstractStrategy end + +# Parameterized fake strategy +struct FakeParamStrategy{P<:Strategies.AbstractStrategyParameter} <: FakeFamily + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{<:FakeParamStrategy}) = :fake_param + +function Strategies.metadata( + ::Type{<:FakeParamStrategy{P}} +) where {P<:Strategies.AbstractStrategyParameter} + return Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:test_opt, type=Int, default=42, description="Test option" + ), + ) +end + +Strategies.options(s::FakeParamStrategy) = s.options + +function FakeParamStrategy{P}(; + mode::Symbol=:strict, kwargs... +) where {P<:Strategies.AbstractStrategyParameter} + opts = Strategies.build_strategy_options(FakeParamStrategy{P}; mode=mode, kwargs...) + return FakeParamStrategy{P}(opts) +end + +# ============================================================================ +# TEST FUNCTION +# ============================================================================ + +""" + test_coverage_missing_parameter() + +🧪 **Applying Testing Rule**: Unit Tests for missing parameter error branches + +Tests uncovered lines in Orchestration: +- builders.jl:47, 109, 117 - Missing parameter errors for parameterized strategies +""" +function test_coverage_missing_parameter() + Test.@testset "Orchestration Missing Parameter Coverage" verbose=VERBOSE showtiming=SHOWTIMING begin + + # Create registry with parameterized strategy + registry = Strategies.create_registry( + FakeFamily => ((FakeParamStrategy, [Strategies.CPU, Strategies.GPU]),) + ) + + families = (fake=FakeFamily,) + + # ==================================================================== + # UNIT TESTS - option_names_from_resolved with missing parameter + # ==================================================================== + + Test.@testset "option_names_from_resolved - Missing Parameter Error" begin + # Create a resolved method WITHOUT parameter (parameter=nothing) + # but with a parameterized strategy + method = (:fake_param,) + + # Manually construct ResolvedMethod with parameter=nothing + # to trigger the error branch at builders.jl:47 + ids_by_family = (fake=:fake_param,) + strategy_to_family = Dict(:fake_param => :fake) + strategy_ids = (:fake_param,) + parameter = nothing # This will trigger the error + + resolved = Orchestration.ResolvedMethod( + method, ids_by_family, strategy_to_family, strategy_ids, parameter + ) + + # This should throw IncorrectArgument because parameter is nothing + # but the strategy is parameterized (covers builders.jl:47) + Test.@test_throws Exceptions.IncorrectArgument Orchestration.option_names_from_resolved( + resolved, :fake, families, registry + ) + end + + # ==================================================================== + # UNIT TESTS - build_strategy_from_resolved with missing parameter + # ==================================================================== + + Test.@testset "build_strategy_from_resolved - Missing Parameter Error" begin + # Same setup as above + method = (:fake_param,) + ids_by_family = (fake=:fake_param,) + strategy_to_family = Dict(:fake_param => :fake) + strategy_ids = (:fake_param,) + parameter = nothing + + resolved = Orchestration.ResolvedMethod( + method, ids_by_family, strategy_to_family, strategy_ids, parameter + ) + + # This should throw IncorrectArgument at builders.jl:109 or 117 + Test.@test_throws Exceptions.IncorrectArgument Orchestration.build_strategy_from_resolved( + resolved, :fake, families, registry + ) + end + + # ==================================================================== + # UNIT TESTS - Successful cases with parameter provided + # ==================================================================== + + Test.@testset "Successful cases with parameter" begin + # Create proper resolved method WITH parameter + method = (:fake_param, :cpu) + resolved = Orchestration.resolve_method(method, families, registry) + + # This should work fine + Test.@test_nowarn Orchestration.option_names_from_resolved( + resolved, :fake, families, registry + ) + + Test.@test_nowarn Orchestration.build_strategy_from_resolved( + resolved, :fake, families, registry + ) + end + end +end + +end # module + +function test_coverage_missing_parameter() + TestCoverageMissingParameter.test_coverage_missing_parameter() +end diff --git a/test/suite/orchestration/test_coverage_routing_catch.jl b/test/suite/orchestration/test_coverage_routing_catch.jl new file mode 100644 index 00000000..3ff4d758 --- /dev/null +++ b/test/suite/orchestration/test_coverage_routing_catch.jl @@ -0,0 +1,139 @@ +module TestCoverageRoutingCatch + +using Test: Test +import CTBase.Exceptions +import CTBase.Orchestration +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# FAKE TYPES FOR TESTING (TOP-LEVEL) +# ============================================================================ + +abstract type FakeDiscretizer <: Strategies.AbstractStrategy end +abstract type FakeModeler <: Strategies.AbstractStrategy end + +struct FakeDisc <: FakeDiscretizer + options::Strategies.StrategyOptions +end + +struct FakeMod <: FakeModeler + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{FakeDisc}) = :fake_disc +Strategies.id(::Type{FakeMod}) = :fake_mod + +# Both have a 'backend' option to create ambiguity +function Strategies.metadata(::Type{FakeDisc}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:backend, type=Symbol, default=:cpu, description="Backend" + ), + ) +end + +function Strategies.metadata(::Type{FakeMod}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:backend, type=Symbol, default=:dense, description="Backend" + ), + ) +end + +Strategies.options(s::Union{FakeDisc,FakeMod}) = s.options + +function FakeDisc(; mode::Symbol=:strict, kwargs...) + opts = Strategies.build_strategy_options(FakeDisc; mode=mode, kwargs...) + return FakeDisc(opts) +end + +function FakeMod(; mode::Symbol=:strict, kwargs...) + opts = Strategies.build_strategy_options(FakeMod; mode=mode, kwargs...) + return FakeMod(opts) +end + +# ============================================================================ +# TEST FUNCTION +# ============================================================================ + +""" + test_coverage_routing_catch() + +🧪 **Applying Testing Rule**: Unit Tests for routing error branches + +Tests uncovered lines in routing.jl: +- Line 330: catch block in _error_ambiguous_option when metadata lookup fails +- Lines 379, 384: _warn_unknown_option_permissive (not called in current codebase) +""" +function test_coverage_routing_catch() + Test.@testset "Routing Catch Block Coverage" verbose=VERBOSE showtiming=SHOWTIMING begin + registry = Strategies.create_registry( + FakeDiscretizer => (FakeDisc,), FakeModeler => (FakeMod,) + ) + + families = (discretizer=FakeDiscretizer, modeler=FakeModeler) + action_defs = Options.OptionDefinition[] + + # ==================================================================== + # UNIT TESTS - Ambiguous option error (triggers catch at line 330) + # ==================================================================== + + Test.@testset "Ambiguous Option Error" begin + # Both FakeDisc and FakeMod have 'backend' option + # Providing it without disambiguation should trigger ambiguity error + method = (:fake_disc, :fake_mod) + kwargs = (backend=:sparse,) # Ambiguous! + + # This should throw IncorrectArgument due to ambiguity + # The error path includes a try-catch for metadata lookup (line 330) + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + method, families, action_defs, kwargs, registry + ) + end + + # ==================================================================== + # UNIT TESTS - Explicit mode ambiguity (line 379, 384) + # ==================================================================== + + Test.@testset "Explicit Mode Ambiguity" begin + # Test with source_mode=:explicit (internal mode) + # This triggers different error messages at lines 379, 384 + method = (:fake_disc, :fake_mod) + kwargs = (backend=:sparse,) + + # With explicit mode, ambiguity error has different message + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + method, families, action_defs, kwargs, registry; source_mode=:explicit + ) + end + + # ==================================================================== + # UNIT TESTS - Successful disambiguation + # ==================================================================== + + Test.@testset "Successful Disambiguation" begin + method = (:fake_disc, :fake_mod) + + # Disambiguate using route_to + kwargs = (backend=Strategies.route_to(fake_disc=:cpu, fake_mod=:dense),) + + # This should work fine + result = Orchestration.route_all_options( + method, families, action_defs, kwargs, registry + ) + + Test.@test haskey(result.strategies.discretizer, :backend) + Test.@test haskey(result.strategies.modeler, :backend) + Test.@test result.strategies.discretizer[:backend] === :cpu + Test.@test result.strategies.modeler[:backend] === :dense + end + end +end + +end # module + +test_coverage_routing_catch() = TestCoverageRoutingCatch.test_coverage_routing_catch() diff --git a/test/suite/orchestration/test_method_builders.jl b/test/suite/orchestration/test_method_builders.jl new file mode 100644 index 00000000..1355a000 --- /dev/null +++ b/test/suite/orchestration/test_method_builders.jl @@ -0,0 +1,177 @@ +module TestOrchestrationMethodBuilders + +using Test: Test +import CTBase.Orchestration +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Test fixtures (minimal strategy setup) +# ============================================================================ + +abstract type BuilderTestDiscretizer <: Strategies.AbstractStrategy end +abstract type BuilderTestModeler <: Strategies.AbstractStrategy end + +struct BuilderCollocation <: BuilderTestDiscretizer + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{BuilderCollocation}) = :collocation +function Strategies.metadata(::Type{BuilderCollocation}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:grid_size, type=Int, default=100, description="Grid size" + ), + ) +end +Strategies.options(s::BuilderCollocation) = s.options + +struct BuilderADNLP <: BuilderTestModeler + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{BuilderADNLP}) = :adnlp +function Strategies.metadata(::Type{BuilderADNLP}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:backend, type=Symbol, default=:dense, description="Backend type" + ), + Options.OptionDefinition(; + name=:show_time, type=Bool, default=false, description="Show timing" + ), + ) +end +Strategies.options(s::BuilderADNLP) = s.options + +# Constructors +function BuilderCollocation(; kwargs...) + meta = Strategies.metadata(BuilderCollocation) + defs = collect(values(meta)) + extracted, _ = Options.extract_options((; kwargs...), defs) + opts = Strategies.StrategyOptions(NamedTuple(extracted)) + return BuilderCollocation(opts) +end + +function BuilderADNLP(; kwargs...) + meta = Strategies.metadata(BuilderADNLP) + defs = collect(values(meta)) + extracted, _ = Options.extract_options((; kwargs...), defs) + opts = Strategies.StrategyOptions(NamedTuple(extracted)) + return BuilderADNLP(opts) +end + +const BUILDER_REGISTRY = Strategies.create_registry( + BuilderTestDiscretizer => (BuilderCollocation,), BuilderTestModeler => (BuilderADNLP,) +) + +const BUILDER_METHOD = (:collocation, :adnlp) + +# ============================================================================ +# Test function +# ============================================================================ + +function test_method_builders() + Test.@testset "Orchestration Method Builders" verbose = VERBOSE showtiming = SHOWTIMING begin + families = (discretizer=BuilderTestDiscretizer, modeler=BuilderTestModeler) + resolved = Orchestration.resolve_method(BUILDER_METHOD, families, BUILDER_REGISTRY) + + # ==================================================================== + # build_strategy_from_resolved + # ==================================================================== + + Test.@testset "build_strategy_from_resolved" begin + # Build with default options + discretizer = Orchestration.build_strategy_from_resolved( + resolved, :discretizer, families, BUILDER_REGISTRY + ) + + Test.@test discretizer isa BuilderCollocation + Test.@test Strategies.option_value(discretizer, :grid_size) == 100 + + # Build with custom options + discretizer2 = Orchestration.build_strategy_from_resolved( + resolved, :discretizer, families, BUILDER_REGISTRY; grid_size=200 + ) + + Test.@test discretizer2 isa BuilderCollocation + Test.@test Strategies.option_value(discretizer2, :grid_size) == 200 + + # Build modeler + modeler = Orchestration.build_strategy_from_resolved( + resolved, + :modeler, + families, + BUILDER_REGISTRY; + backend=:sparse, + show_time=true, + ) + + Test.@test modeler isa BuilderADNLP + Test.@test Strategies.option_value(modeler, :backend) === :sparse + Test.@test Strategies.option_value(modeler, :show_time) === true + end + + # ==================================================================== + # option_names_from_resolved + # ==================================================================== + + Test.@testset "option_names_from_resolved" begin + # Get option names for discretizer + names = Orchestration.option_names_from_resolved( + resolved, :discretizer, families, BUILDER_REGISTRY + ) + + Test.@test names isa Tuple + Test.@test :grid_size in names + Test.@test length(names) == 1 + + # Get option names for modeler + names2 = Orchestration.option_names_from_resolved( + resolved, :modeler, families, BUILDER_REGISTRY + ) + + Test.@test names2 isa Tuple + Test.@test :backend in names2 + Test.@test :show_time in names2 + Test.@test length(names2) == 2 + end + + # ==================================================================== + # Integration: Build and inspect + # ==================================================================== + + Test.@testset "Integration: Build and inspect workflow" begin + # 1. Get option names + discretizer_opts = Orchestration.option_names_from_resolved( + resolved, :discretizer, families, BUILDER_REGISTRY + ) + modeler_opts = Orchestration.option_names_from_resolved( + resolved, :modeler, families, BUILDER_REGISTRY + ) + + Test.@test :grid_size in discretizer_opts + Test.@test :backend in modeler_opts + + # 2. Build strategies with those options + discretizer = Orchestration.build_strategy_from_resolved( + resolved, :discretizer, families, BUILDER_REGISTRY; grid_size=150 + ) + modeler = Orchestration.build_strategy_from_resolved( + resolved, :modeler, families, BUILDER_REGISTRY; backend=:sparse + ) + + # 3. Verify strategies were built correctly + Test.@test discretizer isa BuilderCollocation + Test.@test modeler isa BuilderADNLP + Test.@test Strategies.option_value(discretizer, :grid_size) == 150 + Test.@test Strategies.option_value(modeler, :backend) === :sparse + end + end +end + +end # module + +test_method_builders() = TestOrchestrationMethodBuilders.test_method_builders() diff --git a/test/suite/orchestration/test_orchestration.jl b/test/suite/orchestration/test_orchestration.jl new file mode 100644 index 00000000..a6f0ff1e --- /dev/null +++ b/test/suite/orchestration/test_orchestration.jl @@ -0,0 +1,108 @@ +module TestOrchestration + +using Test: Test +import CTBase.Exceptions +using CTBase: CTBase +import CTBase.Orchestration +import CTBase.Strategies +import CTBase.Options +using CTBase.Orchestration # For testing exported symbols + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true +const CurrentModule = TestOrchestration + +""" + test_orchestration() + +Tests for Orchestration module exports. + +This function tests the complete Orchestration module exports including: +- Abstract types (ResolvedMethod) +- Exported functions (routing, resolution, building functions) +- Internal functions (NOT exported) +""" +function test_orchestration() + Test.@testset "Orchestration Module" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # META TESTS - Exports / Public API surface + # ==================================================================== + + Test.@testset "Exports verification" begin + # Test that Orchestration module is available + Test.@testset "Orchestration Module" begin + Test.@test isdefined(CTBase, :Orchestration) + Test.@test CTBase.Orchestration isa Module + end + + # Test exported types + Test.@testset "Exported Types" begin + for T in (ResolvedMethod,) + Test.@testset "$(nameof(T))" begin + Test.@test isdefined(Orchestration, nameof(T)) + Test.@test isdefined(CurrentModule, nameof(T)) + Test.@test T isa DataType || T isa UnionAll + end + end + end + + # Test exported functions + Test.@testset "Exported Functions" begin + for f in ( + :route_all_options, + :resolve_method, + :extract_strategy_ids, + :build_strategy_to_family_map, + :build_option_ownership_map, + :build_strategy_from_resolved, + :option_names_from_resolved, + ) + Test.@testset "$f" begin + Test.@test isdefined(Orchestration, f) + Test.@test isdefined(CurrentModule, f) + Test.@test getfield(CurrentModule, f) isa Function + end + end + end + + # Test that internal symbols are NOT exported + Test.@testset "Internal Types (not exported)" begin + for T in (:RoutingContext,) + Test.@testset "$T" begin + Test.@test isdefined(Orchestration, T) + Test.@test !isdefined(CurrentModule, T) + end + end + end + + Test.@testset "Internal Functions (not exported)" begin + for f in ( + :build_alias_to_primary_map, # Internal helper function + :_error_unknown_option, # Internal error functions + :_collect_suggestions_across_strategies, + :_error_ambiguous_option, + :_warn_unknown_option_permissive, + # New internal functions from route_all_options refactoring + :_separate_action_and_strategy_options, + :_build_routing_context, + :_check_action_option_shadowing, + :_initialize_routing_dict, + :_route_single_option!, + :_route_with_disambiguation!, + :_route_auto!, + :_build_routed_result, + ) + Test.@testset "$f" begin + Test.@test isdefined(Orchestration, f) + Test.@test !isdefined(CurrentModule, f) + end + end + end + end + end +end + +end # module + +test_orchestration() = TestOrchestration.test_orchestration() diff --git a/test/suite/orchestration/test_orchestration_disambiguation.jl b/test/suite/orchestration/test_orchestration_disambiguation.jl new file mode 100644 index 00000000..cd1a4d65 --- /dev/null +++ b/test/suite/orchestration/test_orchestration_disambiguation.jl @@ -0,0 +1,228 @@ +module TestOrchestrationDisambiguation + +using Test: Test +import CTBase.Exceptions +import CTBase.Orchestration +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Test fixtures (minimal strategy setup) +# ============================================================================ + +abstract type TestDiscretizer <: Strategies.AbstractStrategy end +abstract type TestModeler <: Strategies.AbstractStrategy end +abstract type TestSolver <: Strategies.AbstractStrategy end + +struct CollocationMock <: TestDiscretizer end +Strategies.id(::Type{CollocationMock}) = :collocation +Strategies.metadata(::Type{CollocationMock}) = Strategies.StrategyMetadata() + +struct ADNLPMock <: TestModeler end +Strategies.id(::Type{ADNLPMock}) = :adnlp +function Strategies.metadata(::Type{ADNLPMock}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:backend, + type=Symbol, + default=:dense, + description="Backend type", + aliases=(:adnlp_backend,), + ), + ) +end + +struct IpoptMock <: TestSolver end +Strategies.id(::Type{IpoptMock}) = :ipopt +function Strategies.metadata(::Type{IpoptMock}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, type=Int, default=1000, description="Maximum iterations" + ), + Options.OptionDefinition(; + name=:backend, + type=Symbol, + default=:cpu, + description="Solver backend", + aliases=(:ipopt_backend,), + ), + ) +end + +const TEST_REGISTRY = Strategies.create_registry( + TestDiscretizer => (CollocationMock,), + TestModeler => (ADNLPMock,), + TestSolver => (IpoptMock,), +) + +const TEST_METHOD = (:collocation, :adnlp, :ipopt) + +const TEST_FAMILIES = (discretizer=TestDiscretizer, modeler=TestModeler, solver=TestSolver) + +# ============================================================================ +# Test function +# ============================================================================ + +function test_orchestration_disambiguation() + Test.@testset "Orchestration Disambiguation" verbose = VERBOSE showtiming = SHOWTIMING begin + resolved = Orchestration.resolve_method(TEST_METHOD, TEST_FAMILIES, TEST_REGISTRY) + + # ==================================================================== + # extract_strategy_ids - Unit Tests + # ==================================================================== + + Test.@testset "extract_strategy_ids" begin + # No disambiguation - plain value + Test.@test Orchestration.extract_strategy_ids(:sparse, resolved) === nothing + Test.@test Orchestration.extract_strategy_ids(100, resolved) === nothing + Test.@test Orchestration.extract_strategy_ids("string", resolved) === nothing + + # Single strategy disambiguation + result = Orchestration.extract_strategy_ids( + Strategies.route_to(adnlp=:sparse), resolved + ) + Test.@test result isa Vector{Tuple{Any,Symbol}} + Test.@test length(result) == 1 + Test.@test result[1] == (:sparse, :adnlp) + + # Multi-strategy disambiguation + result = Orchestration.extract_strategy_ids( + Strategies.route_to(adnlp=:sparse, ipopt=:cpu), resolved + ) + Test.@test result isa Vector{Tuple{Any,Symbol}} + Test.@test length(result) == 2 + Test.@test result[1] == (:sparse, :adnlp) + Test.@test result[2] == (:cpu, :ipopt) + + # Invalid strategy ID in single disambiguation + Test.@test_throws Exceptions.IncorrectArgument Orchestration.extract_strategy_ids( + Strategies.route_to(unknown=:sparse), resolved + ) + + # Invalid strategy ID in multi disambiguation + Test.@test_throws Exceptions.IncorrectArgument Orchestration.extract_strategy_ids( + Strategies.route_to(adnlp=:sparse, unknown=:cpu), resolved + ) + + # Non-disambiguated values should return nothing + result = Orchestration.extract_strategy_ids(:plain_value, resolved) + Test.@test result === nothing + + # Another non-disambiguated case + result2 = Orchestration.extract_strategy_ids(100, resolved) + Test.@test result2 === nothing + + # Empty tuple + Test.@test Orchestration.extract_strategy_ids((), resolved) === nothing + end + + # ==================================================================== + # build_strategy_to_family_map - Unit Tests + # ==================================================================== + + Test.@testset "build_strategy_to_family_map" begin + map = Orchestration.build_strategy_to_family_map( + resolved, TEST_FAMILIES, TEST_REGISTRY + ) + + Test.@test map isa Dict{Symbol,Symbol} + Test.@test length(map) == 3 + Test.@test map[:collocation] == :discretizer + Test.@test map[:adnlp] == :modeler + Test.@test map[:ipopt] == :solver + end + + # ==================================================================== + # build_option_ownership_map - Unit Tests + # ==================================================================== + + Test.@testset "build_option_ownership_map" begin + map = Orchestration.build_option_ownership_map( + resolved, TEST_FAMILIES, TEST_REGISTRY + ) + + Test.@test map isa Dict{Symbol,Set{Symbol}} + + # max_iter only in solver + Test.@test haskey(map, :max_iter) + Test.@test map[:max_iter] == Set([:solver]) + + # backend in both modeler and solver (ambiguous!) + Test.@test haskey(map, :backend) + Test.@test map[:backend] == Set([:modeler, :solver]) + Test.@test length(map[:backend]) == 2 + end + + # ==================================================================== + # Ambiguous option error includes aliases + # ==================================================================== + + Test.@testset "Ambiguous option error shows aliases" begin + # backend is ambiguous between adnlp and ipopt + # The error message should mention the aliases adnlp_backend and ipopt_backend + try + Orchestration.route_all_options( + TEST_METHOD, + TEST_FAMILIES, + Options.OptionDefinition[], + (; backend=:sparse), + TEST_REGISTRY; + source_mode=:description, + ) + Test.@test false # Should not reach here + catch e + Test.@test e isa Exceptions.IncorrectArgument + msg = sprint(showerror, e) + # Check that route_to suggestion is present + Test.@test occursin("route_to", msg) + # Check that aliases are mentioned + Test.@test occursin("adnlp_backend", msg) + Test.@test occursin("ipopt_backend", msg) + # Check that the alias section header is present + Test.@test occursin("aliases", msg) + end + end + + # ==================================================================== + # Integration test + # ==================================================================== + + Test.@testset "Integration: Disambiguation workflow" begin + # Build both maps + strategy_map = Orchestration.build_strategy_to_family_map( + resolved, TEST_FAMILIES, TEST_REGISTRY + ) + option_map = Orchestration.build_option_ownership_map( + resolved, TEST_FAMILIES, TEST_REGISTRY + ) + + # Simulate disambiguation detection + disamb = Orchestration.extract_strategy_ids( + Strategies.route_to(adnlp=:sparse), resolved + ) + Test.@test disamb !== nothing + Test.@test length(disamb) == 1 + + value, strategy_id = disamb[1] + Test.@test value == :sparse + Test.@test strategy_id == :adnlp + + # Verify routing would work + family = strategy_map[strategy_id] + Test.@test family == :modeler + + # Verify option ownership + Test.@test :backend in keys(option_map) + Test.@test family in option_map[:backend] + end + end +end + +end # module + +function test_orchestration_disambiguation() + TestOrchestrationDisambiguation.test_orchestration_disambiguation() +end diff --git a/test/suite/orchestration/test_resolved_builders.jl b/test/suite/orchestration/test_resolved_builders.jl new file mode 100644 index 00000000..2bd79463 --- /dev/null +++ b/test/suite/orchestration/test_resolved_builders.jl @@ -0,0 +1,115 @@ +module TestResolvedBuilders + +using Test: Test +import CTBase.Exceptions +import CTBase.Orchestration +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +abstract type RBDiscretizer <: Strategies.AbstractStrategy end +abstract type RBModeler <: Strategies.AbstractStrategy end + +struct RBDiscA <: RBDiscretizer + options::Strategies.StrategyOptions +end + +struct RBModA <: RBModeler + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{RBDiscA}) = :rb_disc_a +Strategies.id(::Type{RBModA}) = :rb_mod_a + +function Strategies.metadata(::Type{RBDiscA}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:grid_size, type=Int, default=10, description="Grid size" + ), + ) +end + +function Strategies.metadata(::Type{RBModA}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:backend, type=Symbol, default=:dense, description="Backend" + ), + ) +end + +Strategies.options(s::Union{RBDiscA,RBModA}) = s.options + +function RBDiscA(; mode::Symbol=:strict, kwargs...) + opts = Strategies.build_strategy_options(RBDiscA; mode=mode, kwargs...) + return RBDiscA(opts) +end + +function RBModA(; mode::Symbol=:strict, kwargs...) + opts = Strategies.build_strategy_options(RBModA; mode=mode, kwargs...) + return RBModA(opts) +end + +const RB_REGISTRY = Strategies.create_registry( + RBDiscretizer => (RBDiscA,), RBModeler => (RBModA,) +) + +const RB_FAMILIES = (discretizer=RBDiscretizer, modeler=RBModeler) + +const RB_METHOD = (:rb_disc_a, :rb_mod_a) + +function test_resolved_builders() + Test.@testset "Orchestration resolved builders" verbose=VERBOSE showtiming=SHOWTIMING begin + resolved = Orchestration.resolve_method(RB_METHOD, RB_FAMILIES, RB_REGISTRY) + + # ==================================================================== + # UNIT TESTS - option_names_from_resolved + # ==================================================================== + + Test.@testset "option_names_from_resolved" begin + disc_opts = Orchestration.option_names_from_resolved( + resolved, :discretizer, RB_FAMILIES, RB_REGISTRY + ) + Test.@test disc_opts == (:grid_size,) + + mod_opts = Orchestration.option_names_from_resolved( + resolved, :modeler, RB_FAMILIES, RB_REGISTRY + ) + Test.@test :backend in mod_opts + Test.@test length(mod_opts) == 1 + end + + # ==================================================================== + # UNIT TESTS - build_strategy_from_resolved + # ==================================================================== + + Test.@testset "build_strategy_from_resolved" begin + disc = Orchestration.build_strategy_from_resolved( + resolved, :discretizer, RB_FAMILIES, RB_REGISTRY + ) + Test.@test disc isa RBDiscA + Test.@test Strategies.option_value(disc, :grid_size) == 10 + + mod = Orchestration.build_strategy_from_resolved( + resolved, :modeler, RB_FAMILIES, RB_REGISTRY; backend=:sparse + ) + Test.@test mod isa RBModA + Test.@test Strategies.option_value(mod, :backend) == :sparse + end + + # ==================================================================== + # UNIT TESTS - Error path: family_name mismatch + # ==================================================================== + + Test.@testset "build_strategy_from_resolved invalid family_name" begin + Test.@test_throws Exception Orchestration.build_strategy_from_resolved( + resolved, :nonexistent, RB_FAMILIES, RB_REGISTRY + ) + end + end +end + +end # module + +test_resolved_builders() = TestResolvedBuilders.test_resolved_builders() diff --git a/test/suite/orchestration/test_routing.jl b/test/suite/orchestration/test_routing.jl new file mode 100644 index 00000000..6e9aa611 --- /dev/null +++ b/test/suite/orchestration/test_routing.jl @@ -0,0 +1,479 @@ +module TestOrchestrationRouting + +using Test: Test +import CTBase.Exceptions +using CTBase: CTBase +import CTBase.Orchestration +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true +const CurrentModule = TestOrchestrationRouting + +# ============================================================================ +# Test fixtures +# ============================================================================ + +abstract type RoutingTestDiscretizer <: Strategies.AbstractStrategy end +abstract type RoutingTestModeler <: Strategies.AbstractStrategy end +abstract type RoutingTestSolver <: Strategies.AbstractStrategy end + +struct RoutingCollocation <: RoutingTestDiscretizer end +Strategies.id(::Type{RoutingCollocation}) = :collocation +function Strategies.metadata(::Type{RoutingCollocation}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:grid_size, type=Int, default=100, description="Grid size" + ), + ) +end + +struct RoutingADNLP <: RoutingTestModeler end +Strategies.id(::Type{RoutingADNLP}) = :adnlp +function Strategies.metadata(::Type{RoutingADNLP}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:backend, + type=Symbol, + default=:dense, + description="Backend type", + aliases=(:adnlp_backend,), + ), + ) +end + +struct RoutingIpopt <: RoutingTestSolver end +Strategies.id(::Type{RoutingIpopt}) = :ipopt +function Strategies.metadata(::Type{RoutingIpopt}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, type=Int, default=1000, description="Maximum iterations" + ), + Options.OptionDefinition(; + name=:backend, + type=Symbol, + default=:cpu, + description="Solver backend", + aliases=(:ipopt_backend,), + ), + ) +end + +struct ShadowingSolver <: RoutingTestSolver end +Strategies.id(::Type{ShadowingSolver}) = :shadow_solver +function Strategies.metadata(::Type{ShadowingSolver}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:display, type=Bool, default=false, description="Solver display" + ), + ) +end + +const ROUTING_REGISTRY = Strategies.create_registry( + RoutingTestDiscretizer => (RoutingCollocation,), + RoutingTestModeler => (RoutingADNLP,), + RoutingTestSolver => (RoutingIpopt,), +) + +const ROUTING_METHOD = (:collocation, :adnlp, :ipopt) + +const ROUTING_FAMILIES = ( + discretizer=RoutingTestDiscretizer, modeler=RoutingTestModeler, solver=RoutingTestSolver +) + +const ROUTING_ACTION_DEFS = [ + Options.OptionDefinition(; + name=:display, type=Bool, default=true, description="Display progress" + ), + Options.OptionDefinition(; + name=:initial_guess, type=Any, default=nothing, description="Initial guess" + ), +] + +# ============================================================================ +# Test function +# ============================================================================ + +function test_routing() + Test.@testset "Orchestration Routing" verbose = VERBOSE showtiming = SHOWTIMING begin + resolved = Orchestration.resolve_method( + ROUTING_METHOD, ROUTING_FAMILIES, ROUTING_REGISTRY + ) + + # ==================================================================== + # META TESTS - Exports / Public API surface + # ==================================================================== + + Test.@testset "Type stability smoke tests" begin + Test.@test_nowarn Orchestration.resolve_method( + ROUTING_METHOD, ROUTING_FAMILIES, ROUTING_REGISTRY + ) + Test.@test_nowarn Orchestration.extract_strategy_ids(:plain_value, resolved) + end + + # ==================================================================== + # Action Option Shadowing Detection + # ==================================================================== + + Test.@testset "Action Option Shadowing Detection" begin + # Use the custom registry/families where solver also has :display option + + shadow_registry = Strategies.create_registry( + RoutingTestDiscretizer => (RoutingCollocation,), + RoutingTestModeler => (RoutingADNLP,), + RoutingTestSolver => (ShadowingSolver,), + ) + shadow_method = (:collocation, :adnlp, :shadow_solver) + + # 1. When user provides the shadowed option + kwargs = (display=false,) + + # We expect an @info message explaining the shadowing + Test.@test_logs ( + :info, r"Option `display` was intercepted.*available for.*solver" + ) begin + routed = Orchestration.route_all_options( + shadow_method, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + shadow_registry, + ) + Test.@test Options.value(routed.action[:display]) == false + Test.@test Options.source(routed.action[:display]) === :user + end + + # 2. When option is not provided by user (default is used), NO warning + kwargs_empty = NamedTuple() + routed_empty = Orchestration.route_all_options( + shadow_method, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs_empty, + shadow_registry, + ) + Test.@test Options.value(routed_empty.action[:display]) == true # default + Test.@test Options.source(routed_empty.action[:display]) === :default + + # 3. When user explicitly routes the shadowed option to the strategy via route_to + kwargs_routed = (display=Strategies.route_to(shadow_solver=false),) + routed_explicit = Orchestration.route_all_options( + shadow_method, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs_routed, + shadow_registry, + ) + + # The action should get the default value (true) since route_to bypasses action extraction + Test.@test Options.value(routed_explicit.action[:display]) == true + Test.@test Options.source(routed_explicit.action[:display]) === :default + + # The strategy should get the explicitly routed value (false) + Test.@test haskey(routed_explicit.strategies.solver, :display) + Test.@test routed_explicit.strategies.solver[:display] == false + end + + # ==================================================================== + # Auto-routing (unambiguous options) + # ==================================================================== + + Test.@testset "Auto-routing unambiguous options" begin + kwargs = (grid_size=200, max_iter=2000, display=false) + + routed = Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + ROUTING_REGISTRY, + ) + + # Check action options (Dict of OptionValue wrappers) + Test.@test haskey(routed.action, :display) + Test.@test Options.value(routed.action[:display]) === false + Test.@test Options.source(routed.action[:display]) === :user + + # Check strategy options (raw NamedTuples) + Test.@test haskey(routed.strategies, :discretizer) + Test.@test haskey(routed.strategies, :modeler) + Test.@test haskey(routed.strategies, :solver) + + # Access raw values from NamedTuples + Test.@test haskey(routed.strategies.discretizer, :grid_size) + Test.@test routed.strategies.discretizer[:grid_size] == 200 + Test.@test haskey(routed.strategies.solver, :max_iter) + Test.@test routed.strategies.solver[:max_iter] == 2000 + end + + # ==================================================================== + # Single strategy disambiguation + # ==================================================================== + + Test.@testset "Single strategy disambiguation" begin + kwargs = (backend=Strategies.route_to(adnlp=:sparse), display=true) + + routed = Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + ROUTING_REGISTRY, + ) + + # backend should be routed to modeler only + Test.@test haskey(routed.strategies.modeler, :backend) + Test.@test routed.strategies.modeler[:backend] === :sparse + Test.@test !haskey(routed.strategies.solver, :backend) + end + + # ==================================================================== + # Multi-strategy disambiguation + # ==================================================================== + + Test.@testset "Multi-strategy disambiguation" begin + kwargs = (backend=Strategies.route_to(adnlp=:sparse, ipopt=:cpu),) + + routed = Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + ROUTING_REGISTRY, + ) + + # backend should be routed to both + Test.@test haskey(routed.strategies.modeler, :backend) + Test.@test routed.strategies.modeler[:backend] === :sparse + Test.@test haskey(routed.strategies.solver, :backend) + Test.@test routed.strategies.solver[:backend] === :cpu + end + + # ==================================================================== + # Error: Unknown option + # ==================================================================== + + Test.@testset "Error on unknown option" begin + kwargs = (unknown_option=123,) + + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + ROUTING_REGISTRY, + ) + end + + # ==================================================================== + # Error: Ambiguous option without disambiguation + # ==================================================================== + + Test.@testset "Error on ambiguous option" begin + kwargs = (backend=:sparse,) # No disambiguation + + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + ROUTING_REGISTRY, + ) + end + + # ==================================================================== + # Error: Invalid disambiguation target + # ==================================================================== + + Test.@testset "Error on invalid disambiguation" begin + # Try to route max_iter to modeler (wrong family) + kwargs = (max_iter=Strategies.route_to(adnlp=1000),) + + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + ROUTING_REGISTRY, + ) + end + + # ==================================================================== + # Routing via aliases (unambiguous) + # ==================================================================== + + Test.@testset "Auto-routing via alias (unambiguous)" begin + # adnlp_backend is an alias for backend, only in modeler => unambiguous + kwargs = (adnlp_backend=:sparse,) + + routed = Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + ROUTING_REGISTRY, + ) + + # adnlp_backend should be routed to modeler + Test.@test haskey(routed.strategies.modeler, :adnlp_backend) + Test.@test routed.strategies.modeler[:adnlp_backend] === :sparse + # solver should NOT have it + Test.@test !haskey(routed.strategies.solver, :adnlp_backend) + end + + Test.@testset "Auto-routing via solver alias (unambiguous)" begin + # ipopt_backend is an alias for backend, only in solver => unambiguous + kwargs = (ipopt_backend=:gpu,) + + routed = Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + ROUTING_REGISTRY, + ) + + # ipopt_backend should be routed to solver + Test.@test haskey(routed.strategies.solver, :ipopt_backend) + Test.@test routed.strategies.solver[:ipopt_backend] === :gpu + # modeler should NOT have it + Test.@test !haskey(routed.strategies.modeler, :ipopt_backend) + end + + Test.@testset "Mixed alias and primary routing" begin + # Use alias for one strategy and primary for another + kwargs = (adnlp_backend=:sparse, max_iter=500, grid_size=200) + + routed = Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + ROUTING_REGISTRY, + ) + + Test.@test routed.strategies.modeler[:adnlp_backend] === :sparse + Test.@test routed.strategies.solver[:max_iter] == 500 + Test.@test routed.strategies.discretizer[:grid_size] == 200 + end + + Test.@testset "Ownership map includes aliases" begin + map = Orchestration.build_option_ownership_map( + resolved, ROUTING_FAMILIES, ROUTING_REGISTRY + ) + + # Primary names + Test.@test haskey(map, :backend) + Test.@test length(map[:backend]) == 2 # modeler + solver + Test.@test haskey(map, :max_iter) + Test.@test length(map[:max_iter]) == 1 # solver only + + # Aliases should be in the map too + Test.@test haskey(map, :adnlp_backend) + Test.@test length(map[:adnlp_backend]) == 1 # modeler only + Test.@test :modeler in map[:adnlp_backend] + + Test.@test haskey(map, :ipopt_backend) + Test.@test length(map[:ipopt_backend]) == 1 # solver only + Test.@test :solver in map[:ipopt_backend] + end + + # ==================================================================== + # Integration: Mixed routing + # ==================================================================== + + Test.@testset "Integration: Mixed routing" begin + kwargs = ( + grid_size=150, + backend=Strategies.route_to(adnlp=:sparse, ipopt=:gpu), + max_iter=500, + display=false, + initial_guess=:warm, + ) + + routed = Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + kwargs, + ROUTING_REGISTRY, + ) + + # Action options (Dict of OptionValue wrappers) + Test.@test Options.value(routed.action[:display]) === false + Test.@test Options.value(routed.action[:initial_guess]) === :warm + + # Strategy options (raw NamedTuples) + Test.@test routed.strategies.discretizer[:grid_size] == 150 + Test.@test routed.strategies.modeler[:backend] === :sparse + Test.@test routed.strategies.solver[:backend] === :gpu + Test.@test routed.strategies.solver[:max_iter] == 500 + end + # ==================================================================== + # Unknown option error suggests closest options (alias-aware) + # ==================================================================== + + Test.@testset "Unknown option error suggests closest (alias-aware)" begin + # adnlp_backen is close to alias adnlp_backend (distance 1) + # but far from primary name backend (distance 7) + # The error should suggest :backend (alias: adnlp_backend) + try + Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + (; adnlp_backen=:sparse), + ROUTING_REGISTRY, + ) + Test.@test false # Should not reach here + catch e + Test.@test e isa Exceptions.IncorrectArgument + msg = sprint(showerror, e) + # Should suggest backend via alias proximity + Test.@test occursin("Did you mean?", msg) + Test.@test occursin("backend", msg) + Test.@test occursin("adnlp_backend", msg) + end + + # ipopt_backen is close to alias ipopt_backend (distance 1) + try + Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + (; ipopt_backen=:gpu), + ROUTING_REGISTRY, + ) + Test.@test false + catch e + Test.@test e isa Exceptions.IncorrectArgument + msg = sprint(showerror, e) + Test.@test occursin("Did you mean?", msg) + Test.@test occursin("backend", msg) + Test.@test occursin("ipopt_backend", msg) + end + + # max_ite is close to primary max_iter (distance 1), no alias needed + try + Orchestration.route_all_options( + ROUTING_METHOD, + ROUTING_FAMILIES, + ROUTING_ACTION_DEFS, + (; max_ite=500), + ROUTING_REGISTRY, + ) + Test.@test false + catch e + Test.@test e isa Exceptions.IncorrectArgument + msg = sprint(showerror, e) + Test.@test occursin("Did you mean?", msg) + Test.@test occursin("max_iter", msg) + end + end + end +end + +end # module + +test_routing() = TestOrchestrationRouting.test_routing() diff --git a/test/suite/orchestration/test_routing_internals.jl b/test/suite/orchestration/test_routing_internals.jl new file mode 100644 index 00000000..aa7e3d7e --- /dev/null +++ b/test/suite/orchestration/test_routing_internals.jl @@ -0,0 +1,568 @@ +module TestRoutingInternals + +using Test: Test +import CTBase.Exceptions +import CTBase.Orchestration +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Test fixtures - Minimal strategy types for testing +# ============================================================================ + +abstract type InternalTestDiscretizer <: Strategies.AbstractStrategy end +abstract type InternalTestModeler <: Strategies.AbstractStrategy end +abstract type InternalTestSolver <: Strategies.AbstractStrategy end + +struct InternalCollocation <: InternalTestDiscretizer end +Strategies.id(::Type{InternalCollocation}) = :collocation +function Strategies.metadata(::Type{InternalCollocation}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:grid_size, type=Int, default=100, description="Grid size" + ), + ) +end + +struct InternalADNLP <: InternalTestModeler end +Strategies.id(::Type{InternalADNLP}) = :adnlp +function Strategies.metadata(::Type{InternalADNLP}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:backend, type=Symbol, default=:dense, description="Backend type" + ), + ) +end + +struct InternalIpopt <: InternalTestSolver end +Strategies.id(::Type{InternalIpopt}) = :ipopt +function Strategies.metadata(::Type{InternalIpopt}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, type=Int, default=100, description="Max iterations" + ), + Options.OptionDefinition(; + name=:backend, type=Symbol, default=:cpu, description="Backend type" + ), + ) +end + +# Additional strategy for testing registry search +struct InternalMadNLP <: InternalTestSolver end +Strategies.id(::Type{InternalMadNLP}) = :madnlp +function Strategies.metadata(::Type{InternalMadNLP}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, type=Int, default=1000, description="Max iterations" + ), + Options.OptionDefinition(; + name=:custom_opt, type=Int, default=42, description="Custom option for testing" + ), + ) +end + +# Test registry and families +const INTERNAL_REGISTRY = Strategies.create_registry( + InternalTestDiscretizer => (InternalCollocation,), + InternalTestModeler => (InternalADNLP,), + InternalTestSolver => (InternalIpopt, InternalMadNLP), +) + +const INTERNAL_FAMILIES = ( + discretizer=InternalTestDiscretizer, + modeler=InternalTestModeler, + solver=InternalTestSolver, +) + +const INTERNAL_METHOD = (:collocation, :adnlp, :ipopt) + +# Action option definitions +const INTERNAL_ACTION_DEFS = [ + Options.OptionDefinition(; + name=:display, type=Bool, default=true, description="Display output" + ), +] + +""" + test_routing_internals() + +Tests for internal helper functions created during route_all_options refactoring. + +This test suite validates the behavior of the 8 private functions extracted +from route_all_options to improve modularity and adherence to SRP. +""" +function test_routing_internals() + Test.@testset "Routing Internals" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - Internal Helper Functions + # ==================================================================== + + Test.@testset "_separate_action_and_strategy_options" begin + # Test with action options only + kwargs = (display=false,) + action_opts, strategy_kwargs = Orchestration._separate_action_and_strategy_options( + kwargs, INTERNAL_ACTION_DEFS + ) + Test.@test haskey(action_opts, :display) + Test.@test Options.value(action_opts[:display]) == false + Test.@test isempty(strategy_kwargs) + + # Test with strategy options only + kwargs = (grid_size=200,) + action_opts, strategy_kwargs = Orchestration._separate_action_and_strategy_options( + kwargs, INTERNAL_ACTION_DEFS + ) + # action_opts will contain default values even if not provided by user + Test.@test haskey(action_opts, :display) + Test.@test Options.source(action_opts[:display]) === :default + Test.@test strategy_kwargs.grid_size == 200 + + # Test with mixed options + kwargs = (display=false, grid_size=200, max_iter=500) + action_opts, strategy_kwargs = Orchestration._separate_action_and_strategy_options( + kwargs, INTERNAL_ACTION_DEFS + ) + Test.@test haskey(action_opts, :display) + Test.@test Options.value(action_opts[:display]) == false + Test.@test strategy_kwargs.grid_size == 200 + Test.@test strategy_kwargs.max_iter == 500 + + # Test with RoutedOption (should be preserved in strategy_kwargs) + routed_backend = Strategies.route_to(adnlp=:sparse) + kwargs = (display=false, backend=routed_backend) + action_opts, strategy_kwargs = Orchestration._separate_action_and_strategy_options( + kwargs, INTERNAL_ACTION_DEFS + ) + Test.@test haskey(action_opts, :display) + Test.@test haskey(strategy_kwargs, :backend) + # The RoutedOption should be preserved in strategy_kwargs + Test.@test strategy_kwargs[:backend] === routed_backend + end + + Test.@testset "_build_routing_context" begin + resolved = Orchestration.resolve_method( + INTERNAL_METHOD, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + + context = Orchestration._build_routing_context( + resolved, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + + # Test that context is created + Test.@test context isa Orchestration.RoutingContext + + # Test strategy_to_family mapping + Test.@test haskey(context.strategy_to_family, :collocation) + Test.@test haskey(context.strategy_to_family, :adnlp) + Test.@test haskey(context.strategy_to_family, :ipopt) + Test.@test context.strategy_to_family[:collocation] == :discretizer + Test.@test context.strategy_to_family[:adnlp] == :modeler + Test.@test context.strategy_to_family[:ipopt] == :solver + + # Test option_owners mapping + Test.@test haskey(context.option_owners, :grid_size) + Test.@test haskey(context.option_owners, :backend) + Test.@test haskey(context.option_owners, :max_iter) + Test.@test :discretizer in context.option_owners[:grid_size] + Test.@test :modeler in context.option_owners[:backend] + Test.@test :solver in context.option_owners[:backend] + Test.@test :solver in context.option_owners[:max_iter] + end + + Test.@testset "_check_action_option_shadowing" begin + resolved = Orchestration.resolve_method( + INTERNAL_METHOD, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + context = Orchestration._build_routing_context( + resolved, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + + # Test with no shadowing (display is action-only) + action_opts = Dict(:display => Options.OptionValue(false, :user)) + # Should not log anything (no shadowing) + Orchestration._check_action_option_shadowing(action_opts, context.option_owners) + + # Test with shadowing (if we had an action option that also exists in strategies) + # This is tested indirectly via route_all_options tests + Test.@test true # Placeholder - shadowing is tested in integration tests + end + + Test.@testset "_initialize_routing_dict" begin + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + + # Test that all families have entries + Test.@test haskey(routed, :discretizer) + Test.@test haskey(routed, :modeler) + Test.@test haskey(routed, :solver) + + # Test that all entries are empty vectors + Test.@test isempty(routed[:discretizer]) + Test.@test isempty(routed[:modeler]) + Test.@test isempty(routed[:solver]) + + # Test that entries are of correct type + Test.@test routed[:discretizer] isa Vector{Pair{Symbol,Any}} + end + + Test.@testset "_route_with_disambiguation!" begin + resolved = Orchestration.resolve_method( + INTERNAL_METHOD, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + context = Orchestration._build_routing_context( + resolved, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + + # Test routing to single strategy + disambiguations = Tuple{Any,Symbol}[(:sparse, :adnlp)] + Orchestration._route_with_disambiguation!( + routed, + :backend, + disambiguations, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + ) + Test.@test length(routed[:modeler]) == 1 + Test.@test routed[:modeler][1] == (:backend => :sparse) + + # Test routing to multiple strategies + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + disambiguations = Tuple{Any,Symbol}[(:sparse, :adnlp), (:gpu, :ipopt)] + Orchestration._route_with_disambiguation!( + routed, + :backend, + disambiguations, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + ) + Test.@test length(routed[:modeler]) == 1 + Test.@test routed[:modeler][1] == (:backend => :sparse) + Test.@test length(routed[:solver]) == 1 + Test.@test routed[:solver][1] == (:backend => :gpu) + + # Test with BypassValue + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + bypass_val = Strategies.bypass(42) + disambiguations = Tuple{Any,Symbol}[(bypass_val, :ipopt)] + Orchestration._route_with_disambiguation!( + routed, + :unknown_opt, + disambiguations, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + ) + Test.@test length(routed[:solver]) == 1 + Test.@test routed[:solver][1][1] == :unknown_opt + Test.@test routed[:solver][1][2] isa Strategies.BypassValue + Test.@test routed[:solver][1][2].value == 42 + + # Test error: unknown option without bypass + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + disambiguations = Tuple{Any,Symbol}[(42, :ipopt)] + Test.@test_throws Exceptions.IncorrectArgument Orchestration._route_with_disambiguation!( + routed, + :totally_unknown, + disambiguations, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + ) + + # Test error: routing to wrong family + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + disambiguations = Tuple{Any,Symbol}[(:sparse, :collocation)] # backend doesn't belong to discretizer + Test.@test_throws Exceptions.IncorrectArgument Orchestration._route_with_disambiguation!( + routed, + :backend, + disambiguations, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + ) + end + + Test.@testset "_route_auto!" begin + resolved = Orchestration.resolve_method( + INTERNAL_METHOD, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + context = Orchestration._build_routing_context( + resolved, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + + # Test auto-routing unambiguous option + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + Orchestration._route_auto!( + routed, + :grid_size, + 200, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + :description, + ) + Test.@test length(routed[:discretizer]) == 1 + Test.@test routed[:discretizer][1] == (:grid_size => 200) + + # Test auto-routing another unambiguous option + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + Orchestration._route_auto!( + routed, + :max_iter, + 500, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + :description, + ) + Test.@test length(routed[:solver]) == 1 + Test.@test routed[:solver][1] == (:max_iter => 500) + + # Test error: unknown option + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + Test.@test_throws Exceptions.IncorrectArgument Orchestration._route_auto!( + routed, + :totally_unknown, + 42, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + :description, + ) + + # Test error: ambiguous option (backend belongs to both modeler and solver) + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + Test.@test_throws Exceptions.IncorrectArgument Orchestration._route_auto!( + routed, + :backend, + :sparse, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + :description, + ) + end + + Test.@testset "_route_single_option!" begin + resolved = Orchestration.resolve_method( + INTERNAL_METHOD, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + context = Orchestration._build_routing_context( + resolved, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + + # Test auto-routing path (unambiguous option) + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + Orchestration._route_single_option!( + routed, + :grid_size, + 200, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + :description, + ) + Test.@test length(routed[:discretizer]) == 1 + Test.@test routed[:discretizer][1] == (:grid_size => 200) + + # Test disambiguation path (RoutedOption) + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + routed_opt = Strategies.route_to(adnlp=:sparse) + Orchestration._route_single_option!( + routed, + :backend, + routed_opt, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + :description, + ) + Test.@test length(routed[:modeler]) == 1 + Test.@test routed[:modeler][1] == (:backend => :sparse) + + # Test multi-strategy disambiguation + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + routed_opt = Strategies.route_to(adnlp=:sparse, ipopt=:gpu) + Orchestration._route_single_option!( + routed, + :backend, + routed_opt, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + :description, + ) + Test.@test length(routed[:modeler]) == 1 + Test.@test routed[:modeler][1] == (:backend => :sparse) + Test.@test length(routed[:solver]) == 1 + Test.@test routed[:solver][1] == (:backend => :gpu) + end + + Test.@testset "_build_routed_result" begin + # Test with empty options + action_opts = Dict() + routed = Dict( + :discretizer => Pair{Symbol,Any}[], + :modeler => Pair{Symbol,Any}[], + :solver => Pair{Symbol,Any}[], + ) + result = Orchestration._build_routed_result(action_opts, routed) + Test.@test result isa NamedTuple + Test.@test haskey(result, :action) + Test.@test haskey(result, :strategies) + Test.@test isempty(result.action) + Test.@test isempty(result.strategies.discretizer) + + # Test with action options + action_opts = Dict(:display => Options.OptionValue(false, :user)) + routed = Dict( + :discretizer => Pair{Symbol,Any}[(:grid_size => 200)], + :modeler => Pair{Symbol,Any}[(:backend => :sparse)], + :solver => Pair{Symbol,Any}[(:max_iter => 500)], + ) + result = Orchestration._build_routed_result(action_opts, routed) + Test.@test haskey(result.action, :display) + Test.@test Options.value(result.action.display) == false + Test.@test result.strategies.discretizer.grid_size == 200 + Test.@test result.strategies.modeler.backend == :sparse + Test.@test result.strategies.solver.max_iter == 500 + + # Test with multiple options per family + routed = Dict( + :discretizer => + Pair{Symbol,Any}[(:grid_size => 200), (:method => :trapezoid)], + :modeler => Pair{Symbol,Any}[(:backend => :sparse)], + :solver => Pair{Symbol,Any}[(:max_iter => 500), (:tol => 1e-6)], + ) + result = Orchestration._build_routed_result(action_opts, routed) + Test.@test result.strategies.discretizer.grid_size == 200 + Test.@test result.strategies.discretizer.method == :trapezoid + Test.@test result.strategies.solver.max_iter == 500 + Test.@test result.strategies.solver.tol == 1e-6 + end + + Test.@testset "_find_option_in_registry" begin + # Test with option that exists in other strategies + resolved = Orchestration.resolve_method( + INTERNAL_METHOD, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + # :custom_opt exists in InternalMadNLP but not in current method (ipopt) + matches = Orchestration._find_option_in_registry( + :custom_opt, resolved, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + Test.@test length(matches) == 1 + Test.@test matches[1] == (:madnlp, :solver) + + # Test with option that doesn't exist in registry + matches = Orchestration._find_option_in_registry( + :totally_unknown, resolved, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + Test.@test isempty(matches) + + # Test with option that only exists in current method strategies + # :grid_size exists in InternalCollocation which is in current method + matches = Orchestration._find_option_in_registry( + :grid_size, resolved, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + Test.@test isempty(matches) # Should be empty since it's in current method + + # Test with option that exists in multiple strategies across families + # :backend exists in both InternalADNLP and InternalIpopt, but both are in current method + # So result should be empty + matches = Orchestration._find_option_in_registry( + :backend, resolved, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + Test.@test isempty(matches) + + # Test with method that doesn't use MadNLP + alt_method = (:collocation, :adnlp, :madnlp) + resolved_alt = Orchestration.resolve_method( + alt_method, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + # :max_iter exists in InternalIpopt (not in current method) + matches = Orchestration._find_option_in_registry( + :max_iter, resolved_alt, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + Test.@test length(matches) == 1 + Test.@test matches[1] == (:ipopt, :solver) + end + + # ==================================================================== + # INTEGRATION TESTS - Complete workflow through internal functions + # ==================================================================== + + Test.@testset "Complete internal workflow" begin + # Simulate the complete flow of route_all_options using internal functions + kwargs = ( + display=false, grid_size=200, backend=Strategies.route_to(adnlp=:sparse) + ) + + # Step 1: Resolve method + resolved = Orchestration.resolve_method( + INTERNAL_METHOD, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + + # Step 2: Separate action and strategy options + action_opts, strategy_kwargs = Orchestration._separate_action_and_strategy_options( + kwargs, INTERNAL_ACTION_DEFS + ) + Test.@test haskey(action_opts, :display) + Test.@test haskey(strategy_kwargs, :grid_size) + Test.@test haskey(strategy_kwargs, :backend) + + # Step 3: Build routing context + context = Orchestration._build_routing_context( + resolved, INTERNAL_FAMILIES, INTERNAL_REGISTRY + ) + Test.@test context isa Orchestration.RoutingContext + + # Step 4: Check shadowing (no-op in this case) + Orchestration._check_action_option_shadowing(action_opts, context.option_owners) + + # Step 5: Route strategy options + routed = Orchestration._initialize_routing_dict(INTERNAL_FAMILIES) + for (key, raw_val) in pairs(strategy_kwargs) + Orchestration._route_single_option!( + routed, + key, + raw_val, + context, + resolved, + INTERNAL_FAMILIES, + INTERNAL_REGISTRY, + :description, + ) + end + Test.@test length(routed[:discretizer]) == 1 + Test.@test length(routed[:modeler]) == 1 + + # Step 6: Build final result + result = Orchestration._build_routed_result(action_opts, routed) + Test.@test Options.value(result.action.display) == false + Test.@test result.strategies.discretizer.grid_size == 200 + Test.@test result.strategies.modeler.backend == :sparse + end + end +end + +end # module + +test_routing_internals() = TestRoutingInternals.test_routing_internals() diff --git a/test/suite/orchestration/test_routing_validation.jl b/test/suite/orchestration/test_routing_validation.jl new file mode 100644 index 00000000..23017fb9 --- /dev/null +++ b/test/suite/orchestration/test_routing_validation.jl @@ -0,0 +1,238 @@ +""" +Unit tests for strict/permissive mode in option routing. + +Tests the behavior of route_all_options() with mode parameter, +ensuring unknown options are handled correctly in both strict and permissive modes. +""" +module TestRoutingValidation + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies +import CTBase.Orchestration +import CTBase.Options + +# Test options for verbose output +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Helper: Create test registry and families +# ============================================================================ + +# Define mock types for testing +abstract type TestDiscretizerFamily <: Strategies.AbstractStrategy end +struct MyDiscretizer <: TestDiscretizerFamily end +Strategies.id(::Type{MyDiscretizer}) = :test_discretizer +Strategies.metadata(::Type{MyDiscretizer}) = Strategies.StrategyMetadata() + +abstract type TestModelerFamily <: Strategies.AbstractStrategy end +struct MyModeler <: TestModelerFamily end +Strategies.id(::Type{MyModeler}) = :test_modeler +Strategies.metadata(::Type{MyModeler}) = Strategies.StrategyMetadata() + +abstract type TestSolverFamily <: Strategies.AbstractStrategy end +struct MySolver <: TestSolverFamily end +Strategies.id(::Type{MySolver}) = :test_solver +Strategies.metadata(::Type{MySolver}) = Strategies.StrategyMetadata() + +# Additional strategy for testing registry search +struct MyOtherSolver <: TestSolverFamily end +Strategies.id(::Type{MyOtherSolver}) = :test_other_solver +function Strategies.metadata(::Type{MyOtherSolver}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:custom_opt, type=Int, default=42, description="Custom option" + ), + ) +end + +function create_test_setup() + # Create a simple registry with test strategies + registry = Strategies.create_registry( + TestDiscretizerFamily => (MyDiscretizer,), + TestModelerFamily => (MyModeler,), + TestSolverFamily => (MySolver, MyOtherSolver), + ) + + # Define families + families = ( + discretizer=TestDiscretizerFamily, + modeler=TestModelerFamily, + solver=TestSolverFamily, + ) + + # Define action options + action_defs = [ + Options.OptionDefinition(; + name=:display, type=Bool, default=true, description="Display progress" + ), + ] + + return registry, families, action_defs +end + +function test_routing_validation() + Test.@testset "Routing Validation Modes" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - Mode Parameter Validation + # ==================================================================== + + Test.@testset "Mode Parameter Validation" begin + registry, families, action_defs = create_test_setup() + method = (:test_discretizer, :test_modeler, :test_solver) + kwargs = (display=true,) + + # route_all_options has no mode parameter; routing always works + Test.@test_nowarn Orchestration.route_all_options( + method, families, action_defs, kwargs, registry + ) + end + + # ==================================================================== + # UNIT TESTS - Strict Mode (Default) + # ==================================================================== + + Test.@testset "Strict Mode - Unknown Option Rejected" begin + registry, families, action_defs = create_test_setup() + method = (:test_discretizer, :test_modeler, :test_solver) + + # Unknown option without disambiguation always fails + kwargs = (unknown_option=123,) + + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + method, families, action_defs, kwargs, registry + ) + end + + Test.@testset "Strict Mode - Unknown Disambiguated Option Rejected" begin + registry, families, action_defs = create_test_setup() + method = (:test_discretizer, :test_modeler, :test_solver) + + # Unknown option with disambiguation but no bypass always fails + kwargs = (unknown_option=Strategies.route_to(test_solver=123),) + + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + method, families, action_defs, kwargs, registry + ) + end + + # ==================================================================== + # UNIT TESTS - Permissive Mode + # ==================================================================== + + Test.@testset "Bypass - Unknown Disambiguated Option Accepted" begin + registry, families, action_defs = create_test_setup() + method = (:test_discretizer, :test_modeler, :test_solver) + + # Unknown option with bypass(val) is accepted and routed as BypassValue + kwargs = ( + custom_option=Strategies.route_to(test_solver=Strategies.bypass(123)), + ) + + result = Orchestration.route_all_options( + method, families, action_defs, kwargs, registry + ) + + # BypassValue is preserved in routed options + bv = result.strategies.solver[:custom_option] + Test.@test bv isa Strategies.BypassValue + Test.@test bv.value == 123 + end + + Test.@testset "Bypass - Multiple Unknown Options" begin + registry, families, action_defs = create_test_setup() + method = (:test_discretizer, :test_modeler, :test_solver) + + # Multiple unknown options with bypass + kwargs = ( + custom1=Strategies.route_to(test_solver=Strategies.bypass(100)), + custom2=Strategies.route_to(test_modeler=Strategies.bypass(200)), + ) + + result = Orchestration.route_all_options( + method, families, action_defs, kwargs, registry + ) + + Test.@test result.strategies.solver[:custom1].value == 100 + Test.@test result.strategies.modeler[:custom2].value == 200 + end + + Test.@testset "Unknown Without Disambiguation Still Fails" begin + registry, families, action_defs = create_test_setup() + method = (:test_discretizer, :test_modeler, :test_solver) + + # Unknown option without disambiguation always fails (no bypass possible) + kwargs = (unknown_option=123,) + + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + method, families, action_defs, kwargs, registry + ) + end + + # ==================================================================== + # UNIT TESTS - Invalid Routing Detection + # ==================================================================== + + Test.@testset "Invalid Routing - Wrong Strategy for Known Option" begin + registry, families, action_defs = create_test_setup() + method = (:test_discretizer, :test_modeler, :test_solver) + + # Known option routed to wrong strategy always fails (even with bypass) + # grid_size belongs to discretizer, not solver + kwargs = (display=true,) + result = Orchestration.route_all_options( + method, families, action_defs, kwargs, registry + ) + + Test.@test Options.value(result.action[:display]) == true + end + + # ==================================================================== + # UNIT TESTS - Default Mode is Strict + # ==================================================================== + + Test.@testset "Default Mode is Strict" begin + registry, families, action_defs = create_test_setup() + method = (:test_discretizer, :test_modeler, :test_solver) + + # Without mode parameter, should behave as strict + kwargs = (unknown_option=Strategies.route_to(test_solver=123),) + + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + method, families, action_defs, kwargs, registry + ) + end + + # ==================================================================== + # INTEGRATION TESTS - Registry Match Suggestions + # ==================================================================== + + Test.@testset "Unknown option with registry match suggestion" begin + registry, families, action_defs = create_test_setup() + method = (:test_discretizer, :test_modeler, :test_solver) + + # :custom_opt exists in MyOtherSolver but not in current method (test_solver) + kwargs = (custom_opt=123,) + + err = try + Orchestration.route_all_options(method, families, action_defs, kwargs, registry) + Test.@test false # Should not reach here + catch e + e + end + + # Verify the error message mentions the registry match + Test.@test err isa Exceptions.IncorrectArgument + Test.@test occursin("This option exists in other strategies", err.suggestion) + Test.@test occursin(":test_other_solver (solver)", err.suggestion) + Test.@test occursin("Perhaps you selected the wrong strategy", err.suggestion) + end + end +end + +end # module + +# Export test function to outer scope +test_routing_validation() = TestRoutingValidation.test_routing_validation() diff --git a/test/suite/strategies/test_abstract_strategy.jl b/test/suite/strategies/test_abstract_strategy.jl new file mode 100644 index 00000000..2acfb38c --- /dev/null +++ b/test/suite/strategies/test_abstract_strategy.jl @@ -0,0 +1,333 @@ +module TestStrategiesAbstractStrategy + +using Test: Test +import CTBase.Core +import CTBase.Exceptions +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Fake strategy types for testing (must be at module top-level) +# ============================================================================ + +struct FakeStrategy <: Strategies.AbstractStrategy + options::Strategies.StrategyOptions +end + +struct IncompleteStrategy <: Strategies.AbstractStrategy + # Missing options field - should trigger error path +end + +# ============================================================================ +# Implement required contract methods for FakeStrategy +# ============================================================================ + +Strategies.id(::Type{<:FakeStrategy}) = :fake +Strategies.id(::Type{<:IncompleteStrategy}) = :incomplete + +function Strategies.metadata(::Type{<:FakeStrategy}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:max, :maxiter), + ), + Options.OptionDefinition(; + name=:tol, type=Float64, default=1e-6, description="Tolerance" + ), + ) +end + +Strategies.metadata(::Type{<:IncompleteStrategy}) = Strategies.StrategyMetadata() + +Strategies.options(strategy::FakeStrategy) = strategy.options + +# Additional test struct for error handling +struct UnimplementedStrategy <: Strategies.AbstractStrategy end + +# Fake strategy with description for testing multi-line display +struct FakeStrategyWithDescription <: Strategies.AbstractStrategy + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{<:FakeStrategyWithDescription}) = :fake_described +function Strategies.metadata(::Type{<:FakeStrategyWithDescription}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; name=:n, type=Int, default=10, description="Grid size.") + ) +end +Strategies.options(s::FakeStrategyWithDescription) = s.options +function Strategies.description(::Type{<:FakeStrategyWithDescription}) + "A strategy for testing description display.\nSee: https://example.com" +end + +# ============================================================================ +# Test function +# ============================================================================ + +""" + test_abstract_strategy() + +Tests for abstract strategy contract. +""" +function test_abstract_strategy() + Test.@testset "Abstract Strategy" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ======================================================================== + # UNIT TESTS + # ======================================================================== + + Test.@testset "Unit Tests" begin + Test.@testset "AbstractStrategy type" begin + Test.@test FakeStrategy <: Strategies.AbstractStrategy + Test.@test IncompleteStrategy <: Strategies.AbstractStrategy + end + + Test.@testset "id() type-level" begin + Test.@test Strategies.id(FakeStrategy) == :fake + Test.@test Strategies.id(IncompleteStrategy) == :incomplete + end + + Test.@testset "id() with typeof" begin + fake_opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user) + ) + fake_strategy = FakeStrategy(fake_opts) + + Test.@test Strategies.id(typeof(fake_strategy)) == :fake + Test.@test Strategies.id(typeof(fake_strategy)) == + Strategies.id(FakeStrategy) + end + + Test.@testset "metadata function" begin + fake_meta = Strategies.metadata(FakeStrategy) + Test.@test fake_meta isa Strategies.StrategyMetadata + Test.@test length(fake_meta) == 2 + Test.@test :max_iter in keys(fake_meta) + Test.@test :tol in keys(fake_meta) + + incomplete_meta = Strategies.metadata(IncompleteStrategy) + Test.@test incomplete_meta isa Strategies.StrategyMetadata + Test.@test length(incomplete_meta) == 0 + end + + Test.@testset "options function" begin + fake_opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user) + ) + fake_strategy = FakeStrategy(fake_opts) + + retrieved_opts = Strategies.options(fake_strategy) + Test.@test retrieved_opts === fake_opts + Test.@test retrieved_opts[:max_iter] == 200 + end + + Test.@testset "Error handling" begin + # Test NotImplemented errors for unimplemented methods + Test.@test_throws Exceptions.NotImplemented Strategies.id( + UnimplementedStrategy + ) + Test.@test_throws Exceptions.NotImplemented Strategies.metadata( + UnimplementedStrategy + ) + + # Test options error for strategy without options field + incomplete_strategy = IncompleteStrategy() + Test.@test_throws Exceptions.NotImplemented Strategies.options( + incomplete_strategy + ) + end + + Test.@testset "Collection interface - getindex" begin + # Use build_strategy_options to properly initialize alias_map + fake_opts = Strategies.build_strategy_options(FakeStrategy; max_iter=200, tol=1e-8) + fake_strategy = FakeStrategy(fake_opts) + + # Test getindex with canonical name + Test.@test fake_strategy[:max_iter] == 200 + Test.@test fake_strategy[:tol] == 1e-8 + + # Test getindex with alias (requires build_strategy_options for alias_map) + Test.@test fake_strategy[:max] == 200 + Test.@test fake_strategy[:maxiter] == 200 + end + + Test.@testset "Collection interface - haskey" begin + # Use build_strategy_options to properly initialize alias_map + fake_opts = Strategies.build_strategy_options(FakeStrategy; max_iter=200, tol=1e-8) + fake_strategy = FakeStrategy(fake_opts) + + # Test haskey with canonical name + Test.@test haskey(fake_strategy, :max_iter) + Test.@test haskey(fake_strategy, :tol) + + # Test haskey with alias (requires build_strategy_options for alias_map) + Test.@test haskey(fake_strategy, :max) + Test.@test haskey(fake_strategy, :maxiter) + + # Test haskey with non-existent key + Test.@test !haskey(fake_strategy, :nonexistent) + end + + Test.@testset "Collection interface - keys" begin + # Use build_strategy_options to properly initialize alias_map + fake_opts = Strategies.build_strategy_options(FakeStrategy; max_iter=200, tol=1e-8) + fake_strategy = FakeStrategy(fake_opts) + + # Test keys returns all option names + key_list = collect(keys(fake_strategy)) + Test.@test :max_iter in key_list + Test.@test :tol in key_list + Test.@test length(key_list) == 2 + end + end + + # ======================================================================== + # UNIT TESTS - description contract + # ======================================================================== + + Test.@testset "description() contract" begin + Test.@testset "default returns nothing" begin + Test.@test Strategies.description(FakeStrategy) === nothing + Test.@test Strategies.description(IncompleteStrategy) === nothing + Test.@test Strategies.description(UnimplementedStrategy) === nothing + end + + Test.@testset "concrete implementation returns String" begin + desc = Strategies.description(FakeStrategyWithDescription) + Test.@test desc isa String + Test.@test occursin("testing description", desc) + Test.@test occursin("https://example.com", desc) + Test.@test occursin("\n", desc) + end + end + + Test.@testset "describe() with description" begin + Test.@testset "no description — no strategy-level description line" begin + io = IOBuffer() + Strategies.describe(io, FakeStrategy) + output = String(take!(io)) + Test.@test occursin("FakeStrategy", output) + Test.@test occursin("id", output) + Test.@test occursin("hierarchy", output) + # Strategy-level description appears as "├─ description:" (not indented under options) + lines = split(output, '\n') + strategy_desc_lines = filter(l -> startswith(l, "├─ description:"), lines) + Test.@test isempty(strategy_desc_lines) + end + + Test.@testset "with description — description line shown" begin + io = IOBuffer() + Strategies.describe(io, FakeStrategyWithDescription) + output = String(take!(io)) + Test.@test occursin("description:", output) + Test.@test occursin("testing description", output) + end + + Test.@testset "multi-line description — second line indented" begin + io = IOBuffer() + Strategies.describe(io, FakeStrategyWithDescription) + output = String(take!(io)) + lines = split(output, '\n') + desc_idx = findfirst(l -> occursin("description:", l), lines) + Test.@test desc_idx !== nothing + Test.@test occursin("https://example.com", output) + # The URL line is a continuation: starts with the cont prefix "│" + url_line = findfirst(l -> occursin("https://example.com", l), lines) + Test.@test url_line !== nothing + Test.@test startswith(lines[url_line], "│") + end + end + + Test.@testset "_print_labeled_multiline helper" begin + Test.@testset "single-line text" begin + io = IOBuffer() + fmt = Core.get_format_codes(io) + Strategies._print_labeled_multiline( + io, "├─ ", "│ ", fmt, "description: ", "Single line." + ) + output = String(take!(io)) + # Label and text are present (possibly separated by ANSI fmt codes) + Test.@test occursin("description: ", output) + Test.@test occursin("Single line.", output) + Test.@test length(split(output, '\n'; keepempty=false)) == 1 + end + + Test.@testset "multi-line text — continuation aligned" begin + io = IOBuffer() + fmt = Core.get_format_codes(io) + Strategies._print_labeled_multiline( + io, "├─ ", "│ ", fmt, "description: ", "Line one.\nLine two." + ) + output = String(take!(io)) + lines = split(output, '\n'; keepempty=false) + Test.@test length(lines) == 2 + Test.@test occursin("Line one.", lines[1]) + Test.@test occursin("Line two.", lines[2]) + # Continuation line has padding (starts with cont prefix, not same as line 1) + Test.@test !occursin("Line two.", lines[1]) + Test.@test startswith(lines[2], "│") + end + end + + # ======================================================================== + # INTEGRATION TESTS + # ======================================================================== + + Test.@testset "Integration Tests" begin + Test.@testset "Complete strategy workflow" begin + # Create strategy with options + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :user), + ) + strategy = FakeStrategy(opts) + + # Test complete contract + Test.@test Strategies.id(typeof(strategy)) == :fake + Test.@test Strategies.metadata(typeof(strategy)) isa + Strategies.StrategyMetadata + Test.@test Strategies.options(strategy) === opts + + # Verify metadata contains expected options + meta = Strategies.metadata(typeof(strategy)) + Test.@test :max_iter in keys(meta) + Test.@test meta[:max_iter].type == Int + Test.@test meta[:max_iter].default == 100 + end + + Test.@testset "Strategy with aliases" begin + # Test that metadata correctly handles aliases + meta = Strategies.metadata(FakeStrategy) + max_iter_def = meta[:max_iter] + + Test.@test max_iter_def.aliases == (:max, :maxiter) + Test.@test :max_iter in keys(meta) + Test.@test :tol in keys(meta) + end + + Test.@testset "Strategy display" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + ) + strategy = FakeStrategy(opts) + + # Test that strategy components can be displayed + redirect_stdout(devnull) do + Test.@test_nowarn show(stdout, Strategies.metadata(typeof(strategy))) + Test.@test_nowarn show(stdout, Strategies.options(strategy)) + end + end + end + end +end + +end # module + +test_abstract_strategy() = TestStrategiesAbstractStrategy.test_abstract_strategy() diff --git a/test/suite/strategies/test_builders.jl b/test/suite/strategies/test_builders.jl new file mode 100644 index 00000000..0da9e22b --- /dev/null +++ b/test/suite/strategies/test_builders.jl @@ -0,0 +1,308 @@ +module TestStrategiesBuilders + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Test strategy types (reuse from test_abstract_strategy.jl) +# ============================================================================ + +# Define test strategy families +abstract type AbstractTestModeler <: Strategies.AbstractStrategy end +abstract type AbstractTestSolver <: Strategies.AbstractStrategy end + +# Concrete test strategies +struct TestModelerA <: AbstractTestModeler + options::Strategies.StrategyOptions +end + +struct TestModelerB <: AbstractTestModeler + options::Strategies.StrategyOptions +end + +struct TestSolverX <: AbstractTestSolver + options::Strategies.StrategyOptions +end + +struct TestSolverY <: AbstractTestSolver + options::Strategies.StrategyOptions +end + +# Implement contract methods +Strategies.id(::Type{<:TestModelerA}) = :modeler_a +Strategies.id(::Type{<:TestModelerB}) = :modeler_b +Strategies.id(::Type{<:TestSolverX}) = :solver_x +Strategies.id(::Type{<:TestSolverY}) = :solver_y + +function Strategies.metadata(::Type{<:TestModelerA}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:backend, type=Symbol, default=:dense, description="Backend type" + ), + Options.OptionDefinition(; + name=:verbose, type=Bool, default=false, description="Verbose output" + ), + ) +end + +function Strategies.metadata(::Type{<:TestModelerB}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:precision, type=Int, default=64, description="Precision bits" + ), + ) +end + +function Strategies.metadata(::Type{<:TestSolverX}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, type=Int, default=100, description="Maximum iterations" + ), + ) +end + +function Strategies.metadata(::Type{<:TestSolverY}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:tol, type=Float64, default=1e-6, description="Tolerance" + ), + ) +end + +Strategies.options(s::Union{TestModelerA,TestModelerB,TestSolverX,TestSolverY}) = s.options + +# Helper function to convert Dict{Symbol, OptionValue} to NamedTuple +function dict_to_namedtuple(d::Dict{Symbol,<:Any}) + return (; (k => v for (k, v) in d)...) +end + +# Constructors with kwargs +function TestModelerA(; kwargs...) + meta = Strategies.metadata(TestModelerA) + defs = collect(values(meta)) + extracted, _ = Options.extract_options((; kwargs...), defs) + opts = Strategies.StrategyOptions(dict_to_namedtuple(extracted)) + return TestModelerA(opts) +end + +function TestModelerB(; kwargs...) + meta = Strategies.metadata(TestModelerB) + defs = collect(values(meta)) + extracted, _ = Options.extract_options((; kwargs...), defs) + opts = Strategies.StrategyOptions(dict_to_namedtuple(extracted)) + return TestModelerB(opts) +end + +function TestSolverX(; kwargs...) + meta = Strategies.metadata(TestSolverX) + defs = collect(values(meta)) + extracted, _ = Options.extract_options((; kwargs...), defs) + opts = Strategies.StrategyOptions(dict_to_namedtuple(extracted)) + return TestSolverX(opts) +end + +function TestSolverY(; kwargs...) + meta = Strategies.metadata(TestSolverY) + defs = collect(values(meta)) + extracted, _ = Options.extract_options((; kwargs...), defs) + opts = Strategies.StrategyOptions(dict_to_namedtuple(extracted)) + return TestSolverY(opts) +end + +# ============================================================================ +# Test function +# ============================================================================ + +""" + test_builders() + +Tests for strategy builders. +""" +function test_builders() + Test.@testset "Strategy Builders" verbose=VERBOSE showtiming=SHOWTIMING begin + + # Create test registry + registry = Strategies.create_registry( + AbstractTestModeler => (TestModelerA, TestModelerB), + AbstractTestSolver => (TestSolverX, TestSolverY), + ) + + # ==================================================================== + # build_strategy + # ==================================================================== + + Test.@testset "build_strategy" begin + # Build with default options + modeler = Strategies.build_strategy(:modeler_a, AbstractTestModeler, registry) + Test.@test modeler isa TestModelerA + Test.@test Strategies.option_value(modeler, :backend) == :dense + Test.@test Strategies.option_value(modeler, :verbose) == false + + # Build with custom options + solver = Strategies.build_strategy( + :solver_x, AbstractTestSolver, registry; max_iter=200 + ) + Test.@test solver isa TestSolverX + Test.@test Strategies.option_value(solver, :max_iter) == 200 + + # Build different strategy in same family + modeler_b = Strategies.build_strategy( + :modeler_b, AbstractTestModeler, registry; precision=32 + ) + Test.@test modeler_b isa TestModelerB + Test.@test Strategies.option_value(modeler_b, :precision) == 32 + + # Test error on unknown ID + Test.@test_throws Exceptions.IncorrectArgument Strategies.build_strategy( + :unknown, AbstractTestModeler, registry + ) + end + + # ==================================================================== + # extract_id_from_method + # ==================================================================== + + Test.@testset "extract_id_from_method" begin + # Single ID for family + method = (:modeler_a, :solver_x) + id = Strategies.extract_id_from_method(method, AbstractTestModeler, registry) + Test.@test id == :modeler_a + + # Extract different family from same method + id2 = Strategies.extract_id_from_method(method, AbstractTestSolver, registry) + Test.@test id2 == :solver_x + + # Method with multiple strategies + method2 = (:modeler_b, :solver_y) + id3 = Strategies.extract_id_from_method(method2, AbstractTestModeler, registry) + Test.@test id3 == :modeler_b + + # Error: No ID for family + method_no_modeler = (:solver_x, :solver_y) + Test.@test_throws Exceptions.IncorrectArgument Strategies.extract_id_from_method( + method_no_modeler, AbstractTestModeler, registry + ) + + # Error: Multiple IDs for same family + method_duplicate = (:modeler_a, :modeler_b, :solver_x) + Test.@test_throws Exceptions.IncorrectArgument Strategies.extract_id_from_method( + method_duplicate, AbstractTestModeler, registry + ) + end + + # ==================================================================== + # option_names + # ==================================================================== + + Test.@testset "option_names" begin + # Get option names for modeler + modeler_type = Strategies.type_from_id( + :modeler_a, AbstractTestModeler, registry + ) + names = Strategies.option_names(modeler_type) + Test.@test names isa Tuple + Test.@test :backend in names + Test.@test :verbose in names + Test.@test length(names) == 2 + + # Get option names for solver + solver_type = Strategies.type_from_id(:solver_x, AbstractTestSolver, registry) + names2 = Strategies.option_names(solver_type) + Test.@test names2 isa Tuple + Test.@test :max_iter in names2 + Test.@test length(names2) == 1 + + # Different strategy + modeler_b_type = Strategies.type_from_id( + :modeler_b, AbstractTestModeler, registry + ) + names3 = Strategies.option_names(modeler_b_type) + Test.@test :precision in names3 + Test.@test length(names3) == 1 + end + + # ==================================================================== + # build_strategy (id-direct) + # ==================================================================== + + Test.@testset "build_strategy (id-direct)" begin + # Build modeler + modeler = Strategies.build_strategy( + :modeler_a, AbstractTestModeler, registry; backend=:sparse + ) + Test.@test modeler isa TestModelerA + Test.@test Strategies.option_value(modeler, :backend) == :sparse + + # Build solver + solver = Strategies.build_strategy( + :solver_x, AbstractTestSolver, registry; max_iter=500 + ) + Test.@test solver isa TestSolverX + Test.@test Strategies.option_value(solver, :max_iter) == 500 + + # Build with default options + modeler2 = Strategies.build_strategy(:modeler_a, AbstractTestModeler, registry) + Test.@test modeler2 isa TestModelerA + Test.@test Strategies.option_value(modeler2, :backend) == :dense + + # Different strategy + modeler_b = Strategies.build_strategy( + :modeler_b, AbstractTestModeler, registry; precision=128 + ) + Test.@test modeler_b isa TestModelerB + Test.@test Strategies.option_value(modeler_b, :precision) == 128 + end + + # ==================================================================== + # Integration test + # ==================================================================== + + Test.@testset "Integration: Full pipeline" begin + # Simulate a complete workflow + method = (:modeler_a, :solver_x) + + # 1. Extract IDs + modeler_id = Strategies.extract_id_from_method( + method, AbstractTestModeler, registry + ) + solver_id = Strategies.extract_id_from_method( + method, AbstractTestSolver, registry + ) + Test.@test modeler_id == :modeler_a + Test.@test solver_id == :solver_x + + # 2. Get option names + modeler_type = Strategies.type_from_id( + modeler_id, AbstractTestModeler, registry + ) + solver_type = Strategies.type_from_id(solver_id, AbstractTestSolver, registry) + modeler_opts = Strategies.option_names(modeler_type) + solver_opts = Strategies.option_names(solver_type) + Test.@test :backend in modeler_opts + Test.@test :max_iter in solver_opts + + # 3. Build strategies + modeler = Strategies.build_strategy( + modeler_id, AbstractTestModeler, registry; backend=:sparse, verbose=true + ) + solver = Strategies.build_strategy( + solver_id, AbstractTestSolver, registry; max_iter=1000 + ) + + Test.@test modeler isa TestModelerA + Test.@test solver isa TestSolverX + Test.@test Strategies.option_value(modeler, :backend) == :sparse + Test.@test Strategies.option_value(modeler, :verbose) == true + Test.@test Strategies.option_value(solver, :max_iter) == 1000 + end + end +end + +end # module + +test_builders() = TestStrategiesBuilders.test_builders() diff --git a/test/suite/strategies/test_builders_parameters.jl b/test/suite/strategies/test_builders_parameters.jl new file mode 100644 index 00000000..60b07cc7 --- /dev/null +++ b/test/suite/strategies/test_builders_parameters.jl @@ -0,0 +1,301 @@ +module TestBuildersParameters + +using Test: Test +import CTBase.Strategies +import CTBase.Exceptions + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# TOP-LEVEL: Define all structs here +abstract type TestFamily <: Strategies.AbstractStrategy end + +struct TestStratA <: TestFamily + options::Strategies.StrategyOptions +end + +struct TestStratB{P<:Strategies.AbstractStrategyParameter} <: TestFamily + options::Strategies.StrategyOptions +end + +# Implement contracts +Strategies.id(::Type{<:TestStratA}) = :teststrata +Strategies.id(::Type{<:TestStratB}) = :teststratb + +# Simple metadata for testing +function Strategies.metadata(::Type{T}) where {T<:TestStratA} + Strategies.StrategyMetadata( + Strategies.OptionDefinition(; + name=:opt1, type=Int, default=10, description="Test option 1" + ), + ) +end + +function Strategies.metadata(::Type{T}) where {T<:TestStratB} + Strategies.StrategyMetadata( + Strategies.OptionDefinition(; + name=:opt1, type=Int, default=20, description="Test option 1" + ), + Strategies.OptionDefinition(; + name=:backend, + type=Union{Nothing,String}, + default=nothing, + description="Test backend", + ), + ) +end + +# Helper metadata for parameter-specific defaults +function __teststratb_backend(::Type{Strategies.CPU}) + return nothing +end + +function __teststratb_backend(::Type{Strategies.GPU}) + return "cuda_backend" +end + +# Parameter-specific metadata +function Strategies.metadata( + ::Type{TestStratB{P}} +) where {P<:Strategies.AbstractStrategyParameter} + return Strategies.StrategyMetadata( + Strategies.OptionDefinition(; + name=:opt1, type=Int, default=20, description="Test option 1" + ), + Strategies.OptionDefinition(; + name=:backend, + type=Union{Nothing,String}, + default=__teststratb_backend(P), + description="Test backend", + ), + ) +end + +# Simple constructors +function TestStratA(; mode::Symbol=:strict, kwargs...) + opts = Strategies.build_strategy_options(TestStratA; mode=mode, kwargs...) + return TestStratA(opts) +end + +function TestStratB{P}(; + mode::Symbol=:strict, kwargs... +) where {P<:Strategies.AbstractStrategyParameter} + opts = Strategies.build_strategy_options(TestStratB{P}; mode=mode, kwargs...) + return TestStratB{P}(opts) +end + +function test_builders_parameters() + Test.@testset "Builders with Parameters" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - extract_global_parameter_from_method + # ==================================================================== + + Test.@testset "extract_global_parameter_from_method" begin + r = Strategies.create_registry( + TestFamily => (TestStratA, (TestStratB, [Strategies.CPU, Strategies.GPU])) + ) + + Test.@test Strategies.extract_global_parameter_from_method( + (:teststratb, :cpu), r + ) == Strategies.CPU + Test.@test Strategies.extract_global_parameter_from_method( + (:teststratb, :gpu), r + ) == Strategies.GPU + Test.@test_throws Exceptions.IncorrectArgument Strategies.extract_global_parameter_from_method( + (:teststratb,), r + ) + Test.@test_throws Exceptions.IncorrectArgument Strategies.extract_global_parameter_from_method( + (:teststrata, :cpu), r + ) + + Test.@test Strategies.extract_global_parameter_from_method( + (:teststratb, :cpu, :teststrata), r + ) == Strategies.CPU + Test.@test Strategies.extract_global_parameter_from_method( + (:cpu, :teststrata, :teststratb), r + ) == Strategies.CPU + end + + Test.@testset "registry.parameters empty when no parameterized strategies" begin + r = Strategies.create_registry( + TestFamily => (TestStratA,), # No parameterized strategies + ) + + # Registry parameters should be empty (no parameterized strategies registered) + Test.@test isempty(r.parameters) + + # :cpu/:gpu are not recognized as parameter tokens (registry.parameters is empty) + # So they are treated as unknown tokens and extract returns nothing + Test.@test Strategies.extract_global_parameter_from_method( + (:teststrata, :cpu), r + ) === nothing + Test.@test Strategies.extract_global_parameter_from_method( + (:teststrata, :gpu), r + ) === nothing + end + + Test.@testset "registry.parameters contains discovered parameters" begin + r = Strategies.create_registry( + TestFamily => (TestStratA, (TestStratB, [Strategies.CPU, Strategies.GPU])) + ) + + # Registry parameters should contain CPU and GPU (discovered from TestStratB) + Test.@test !isempty(r.parameters) + Test.@test haskey(r.parameters, :cpu) + Test.@test haskey(r.parameters, :gpu) + Test.@test r.parameters[:cpu] === Strategies.CPU + Test.@test r.parameters[:gpu] === Strategies.GPU + Test.@test length(r.parameters) == 2 + end + + # ==================================================================== + # UNIT TESTS - build_strategy with parameter (id-direct) + # ==================================================================== + + Test.@testset "build_strategy parameterized (id-direct)" begin + r = Strategies.create_registry( + TestFamily => (TestStratA, (TestStratB, [Strategies.CPU, Strategies.GPU])) + ) + + p_cpu = Strategies.extract_global_parameter_from_method((:teststratb, :cpu), r) + p_gpu = Strategies.extract_global_parameter_from_method((:teststratb, :gpu), r) + s_cpu = Strategies.build_strategy(:teststratb, p_cpu, TestFamily, r) + s_gpu = Strategies.build_strategy(:teststratb, p_gpu, TestFamily, r) + + Test.@test s_cpu isa TestStratB{Strategies.CPU} + Test.@test s_gpu isa TestStratB{Strategies.GPU} + end + + Test.@testset "build_strategy non-parameterized (id-direct)" begin + r = Strategies.create_registry( + TestFamily => (TestStratA, (TestStratB, [Strategies.CPU, Strategies.GPU])) + ) + + s = Strategies.build_strategy(:teststrata, TestFamily, r) + Test.@test s isa TestStratA + end + + Test.@testset "build_strategy parameterized with options (id-direct)" begin + r = Strategies.create_registry( + TestFamily => (TestStratA, (TestStratB, [Strategies.CPU, Strategies.GPU])) + ) + + p = Strategies.extract_global_parameter_from_method((:teststratb, :cpu), r) + s = Strategies.build_strategy(:teststratb, p, TestFamily, r; opt1=100) + Test.@test s isa TestStratB{Strategies.CPU} + Test.@test Strategies.option_value(s, :opt1) == 100 + end + + # ==================================================================== + # UNIT TESTS - build_strategy with parameter + # ==================================================================== + + Test.@testset "build_strategy parameterized" begin + r = Strategies.create_registry( + TestFamily => ((TestStratB, [Strategies.CPU, Strategies.GPU]),) + ) + + s_cpu = Strategies.build_strategy(:teststratb, Strategies.CPU, TestFamily, r) + s_gpu = Strategies.build_strategy(:teststratb, Strategies.GPU, TestFamily, r) + + Test.@test s_cpu isa TestStratB{Strategies.CPU} + Test.@test s_gpu isa TestStratB{Strategies.GPU} + end + + Test.@testset "build_strategy parameterized with options" begin + r = Strategies.create_registry( + TestFamily => ((TestStratB, [Strategies.CPU, Strategies.GPU]),) + ) + + s = Strategies.build_strategy( + :teststratb, Strategies.GPU, TestFamily, r; opt1=50 + ) + Test.@test s isa TestStratB{Strategies.GPU} + Test.@test Strategies.option_value(s, :opt1) == 50 + end + + Test.@testset "build_strategy unsupported parameter error" begin + r = Strategies.create_registry( + TestFamily => ((TestStratB, [Strategies.CPU]),), # Only CPU + ) + + Test.@test_throws Exceptions.IncorrectArgument Strategies.build_strategy( + :teststratb, Strategies.GPU, TestFamily, r + ) + end + + # ==================================================================== + # UNIT TESTS - option_names with parameter (id-direct) + # ==================================================================== + + Test.@testset "option_names parameterized (id-direct)" begin + r = Strategies.create_registry( + TestFamily => (TestStratA, (TestStratB, [Strategies.CPU, Strategies.GPU])) + ) + + p_cpu = Strategies.extract_global_parameter_from_method((:teststratb, :cpu), r) + p_gpu = Strategies.extract_global_parameter_from_method((:teststratb, :gpu), r) + Tcpu = Strategies.type_from_id(:teststratb, TestFamily, r; parameter=p_cpu) + Tgpu = Strategies.type_from_id(:teststratb, TestFamily, r; parameter=p_gpu) + names_cpu = Strategies.option_names(Tcpu) + names_gpu = Strategies.option_names(Tgpu) + + Test.@test :opt1 in names_cpu + Test.@test :backend in names_cpu + Test.@test :opt1 in names_gpu + Test.@test :backend in names_gpu + end + + Test.@testset "option_names non-parameterized (id-direct)" begin + r = Strategies.create_registry( + TestFamily => (TestStratA, (TestStratB, [Strategies.CPU, Strategies.GPU])) + ) + + T = Strategies.type_from_id(:teststrata, TestFamily, r) + names = Strategies.option_names(T) + Test.@test names == (:opt1,) + end + + # ==================================================================== + # INTEGRATION TESTS + # ==================================================================== + + Test.@testset "Parameter-specific default options" begin + r = Strategies.create_registry( + TestFamily => ((TestStratB, [Strategies.CPU, Strategies.GPU]),) + ) + + p_cpu = Strategies.extract_global_parameter_from_method((:teststratb, :cpu), r) + p_gpu = Strategies.extract_global_parameter_from_method((:teststratb, :gpu), r) + s_cpu = Strategies.build_strategy(:teststratb, p_cpu, TestFamily, r) + s_gpu = Strategies.build_strategy(:teststratb, p_gpu, TestFamily, r) + + # Check that defaults are different based on parameter + Test.@test Strategies.option_value(s_cpu, :backend) === nothing + Test.@test Strategies.option_value(s_gpu, :backend) == "cuda_backend" + end + + Test.@testset "Correctness verification" begin + r = Strategies.create_registry( + TestFamily => ((TestStratB, [Strategies.CPU, Strategies.GPU]),) + ) + + # Verify extract_global_parameter_from_method returns correct types + result = Strategies.extract_global_parameter_from_method((:teststratb, :cpu), r) + Test.@test result === Strategies.CPU + + # Verify builder functions return correct types + s1 = Strategies.build_strategy(:teststratb, result, TestFamily, r) + Test.@test s1 isa TestStratB{Strategies.CPU} + + s2 = Strategies.build_strategy(:teststratb, Strategies.CPU, TestFamily, r) + Test.@test s2 isa TestStratB{Strategies.CPU} + end + end +end + +end # module + +# Redefine in outer scope for TestRunner +test_builders_parameters() = TestBuildersParameters.test_builders_parameters() diff --git a/test/suite/strategies/test_bypass.jl b/test/suite/strategies/test_bypass.jl new file mode 100644 index 00000000..e25ca13a --- /dev/null +++ b/test/suite/strategies/test_bypass.jl @@ -0,0 +1,211 @@ +module TestBypass + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies +import CTBase.Orchestration +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Mock strategy for testing +# ============================================================================ + +abstract type BypassTestSolver <: Strategies.AbstractStrategy end + +struct MockSolver <: BypassTestSolver + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{MockSolver}) = :mock_solver + +function Strategies.metadata(::Type{MockSolver}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, type=Int, default=100, description="Maximum iterations" + ), + Options.OptionDefinition(; + name=:tol, type=Float64, default=1e-6, description="Tolerance" + ), + ) +end + +function MockSolver(; mode::Symbol=:strict, kwargs...) + options = Strategies.build_strategy_options(MockSolver; mode=mode, kwargs...) + return MockSolver(options) +end + +const BYPASS_REGISTRY = Strategies.create_registry(BypassTestSolver => (MockSolver,)) + +const BYPASS_FAMILIES = (solver=BypassTestSolver,) +const BYPASS_METHOD = (:mock_solver,) +const BYPASS_ACTION_DEFS = Options.OptionDefinition[] + +# ============================================================================ +# Test function +# ============================================================================ + +function test_bypass() + Test.@testset "Bypass Mechanism" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - BypassValue type + # ==================================================================== + + Test.@testset "BypassValue construction" begin + val = Strategies.bypass(42) + Test.@test val isa Strategies.BypassValue + Test.@test val.value == 42 + + val_str = Strategies.bypass("hello") + Test.@test val_str isa Strategies.BypassValue{String} + Test.@test val_str.value == "hello" + + val_sym = Strategies.bypass(:sparse) + Test.@test val_sym.value === :sparse + end + + # ==================================================================== + # UNIT TESTS - Explicit construction with bypass(val) + # ==================================================================== + + Test.@testset "Explicit construction - bypass(val)" begin + # Strict mode rejects unknown options + Test.@test_throws Exceptions.IncorrectArgument MockSolver(unknown_opt=99) + + # bypass(val) accepted even in strict mode + strat = MockSolver(unknown_opt=Strategies.bypass(99)) + Test.@test Strategies.has_option(strat, :unknown_opt) + Test.@test Strategies.option_value(strat, :unknown_opt) == 99 + Test.@test Strategies.option_source(strat, :unknown_opt) === :user + + # Known options still validated normally + strat2 = MockSolver(max_iter=500) + Test.@test Strategies.option_value(strat2, :max_iter) == 500 + + # Mixed: known + bypassed + strat3 = MockSolver(max_iter=200, backend=Strategies.bypass(:sparse)) + Test.@test Strategies.option_value(strat3, :max_iter) == 200 + Test.@test Strategies.option_value(strat3, :backend) === :sparse + + # Multiple bypassed options + strat4 = MockSolver( + custom_a=Strategies.bypass(1), custom_b=Strategies.bypass("x") + ) + Test.@test Strategies.option_value(strat4, :custom_a) == 1 + Test.@test Strategies.option_value(strat4, :custom_b) == "x" + end + + # ==================================================================== + # UNIT TESTS - route_to with bypass(val) + # ==================================================================== + + Test.@testset "route_to with bypass(val) - routing" begin + # Unknown option with bypass → routed as BypassValue, no error + kwargs = (custom_opt=Strategies.route_to(mock_solver=Strategies.bypass(42)),) + routed = Orchestration.route_all_options( + BYPASS_METHOD, BYPASS_FAMILIES, BYPASS_ACTION_DEFS, kwargs, BYPASS_REGISTRY + ) + + Test.@test haskey(routed.strategies.solver, :custom_opt) + bv = routed.strategies.solver[:custom_opt] + Test.@test bv isa Strategies.BypassValue + Test.@test bv.value == 42 + + # Unknown option WITHOUT bypass → error + kwargs_no_bypass = (custom_opt=Strategies.route_to(mock_solver=42),) + Test.@test_throws Exceptions.IncorrectArgument Orchestration.route_all_options( + BYPASS_METHOD, + BYPASS_FAMILIES, + BYPASS_ACTION_DEFS, + kwargs_no_bypass, + BYPASS_REGISTRY, + ) + end + + Test.@testset "route_to with bypass(val) - end-to-end" begin + # Route BypassValue, then build strategy: bypass accepted by constructor + kwargs = (custom_opt=Strategies.route_to(mock_solver=Strategies.bypass(99)),) + routed = Orchestration.route_all_options( + BYPASS_METHOD, BYPASS_FAMILIES, BYPASS_ACTION_DEFS, kwargs, BYPASS_REGISTRY + ) + + # Build strategy with routed options (mode=:strict, bypass handles itself) + strat = MockSolver(; routed.strategies.solver...) + Test.@test Strategies.has_option(strat, :custom_opt) + Test.@test Strategies.option_value(strat, :custom_opt) == 99 + + # Known option routed normally (no bypass needed) + kwargs_known = (max_iter=Strategies.route_to(mock_solver=500),) + routed_known = Orchestration.route_all_options( + BYPASS_METHOD, + BYPASS_FAMILIES, + BYPASS_ACTION_DEFS, + kwargs_known, + BYPASS_REGISTRY, + ) + strat_known = MockSolver(; routed_known.strategies.solver...) + Test.@test Strategies.option_value(strat_known, :max_iter) == 500 + end + + # ==================================================================== + # UNIT TESTS - mode=:permissive still works independently + # ==================================================================== + + Test.@testset "mode=:permissive still works" begin + redirect_stderr(devnull) do + strat = MockSolver(unknown_opt=42; mode=:permissive) + Test.@test Strategies.has_option(strat, :unknown_opt) + Test.@test Strategies.option_value(strat, :unknown_opt) == 42 + end + end + + # ==================================================================== + # UNIT TESTS - Bypass Validation Power + # ==================================================================== + + Test.@testset "Bypass Validation Power" begin + # 1. Bypass type validation for known option + # max_iter is Int, we pass String via bypass + strat = MockSolver(max_iter=Strategies.bypass("not_an_int")) + Test.@test Strategies.option_value(strat, :max_iter) == "not_an_int" + Test.@test Strategies.option_source(strat, :max_iter) === :user + + # 2. Overwrite default with different type + # tol is Float64 (default 1e-6), we pass Symbol via bypass + strat2 = MockSolver(tol=Strategies.bypass(:flexible)) + Test.@test Strategies.option_value(strat2, :tol) === :flexible + Test.@test Strategies.option_source(strat2, :tol) === :user + end + + # ==================================================================== + # UNIT TESTS - Force Alias + # ==================================================================== + + Test.@testset "Force Alias" begin + # Test that force and bypass are the same function + Test.@test Strategies.force === Strategies.bypass + + # Test that force and bypass produce identical results + Test.@test Strategies.force(42) == Strategies.bypass(42) + Test.@test Strategies.force("test") == Strategies.bypass("test") + Test.@test Strategies.force(:symbol) == Strategies.bypass(:symbol) + + # Test that force creates BypassValue + result = Strategies.force(123) + Test.@test result isa Strategies.BypassValue + Test.@test result.value == 123 + + # Test that force works in strategy construction + strat = MockSolver(unknown_opt=Strategies.force(456)) + Test.@test Strategies.option_value(strat, :unknown_opt) == 456 + Test.@test Strategies.option_source(strat, :unknown_opt) === :user + end + end +end + +end # module + +test_bypass() = TestBypass.test_bypass() diff --git a/test/suite/strategies/test_configuration.jl b/test/suite/strategies/test_configuration.jl new file mode 100644 index 00000000..f1ff399f --- /dev/null +++ b/test/suite/strategies/test_configuration.jl @@ -0,0 +1,269 @@ +module TestStrategiesConfiguration + +using Test: Test +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Test strategies with metadata +# ============================================================================ + +abstract type AbstractTestStrategy <: Strategies.AbstractStrategy end + +struct TestStrategyA <: AbstractTestStrategy + options::Strategies.StrategyOptions +end + +struct TestStrategyB <: AbstractTestStrategy + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{TestStrategyA}) = :test_a +Strategies.id(::Type{TestStrategyB}) = :test_b + +function Strategies.metadata(::Type{TestStrategyA}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:max, :maxiter), + ), + Options.OptionDefinition(; + name=:tolerance, + type=Float64, + default=1e-6, + description="Convergence tolerance", + aliases=(:tol,), + ), + Options.OptionDefinition(; + name=:verbose, type=Bool, default=false, description="Verbose output" + ), + ) +end + +function Strategies.metadata(::Type{TestStrategyB}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:backend, type=Symbol, default=:default, description="Backend to use" + ), + Options.OptionDefinition(; + name=:precision, + type=Int, + default=64, + description="Numerical precision", + validator=x -> x in (32, 64, 128), + ), + ) +end + +Strategies.options(s::Union{TestStrategyA,TestStrategyB}) = s.options + +# ============================================================================ +# Test function +# ============================================================================ + +""" + test_configuration() + +Tests for strategy configuration. +""" +function test_configuration() + Test.@testset "Strategy Configuration" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # build_strategy_options + # ==================================================================== + + Test.@testset "build_strategy_options" begin + # Basic construction with defaults + opts = Strategies.build_strategy_options(TestStrategyA) + Test.@test opts isa Strategies.StrategyOptions + Test.@test opts[:max_iter] == 100 + Test.@test opts[:tolerance] == 1e-6 + Test.@test opts[:verbose] == false + + # Override with user values + opts2 = Strategies.build_strategy_options(TestStrategyA; max_iter=200) + Test.@test opts2[:max_iter] == 200 + Test.@test opts2[:tolerance] == 1e-6 + + # Multiple user values + opts3 = Strategies.build_strategy_options( + TestStrategyA; max_iter=300, tolerance=1e-8, verbose=true + ) + Test.@test opts3[:max_iter] == 300 + Test.@test opts3[:tolerance] == 1e-8 + Test.@test opts3[:verbose] == true + + # Alias resolution + opts4 = Strategies.build_strategy_options(TestStrategyA; max=150) + Test.@test opts4[:max_iter] == 150 + + opts5 = Strategies.build_strategy_options(TestStrategyA; tol=1e-10) + Test.@test opts5[:tolerance] == 1e-10 + + # Different strategy + opts6 = Strategies.build_strategy_options(TestStrategyB; backend=:sparse) + Test.@test opts6[:backend] == :sparse + Test.@test opts6[:precision] == 64 + end + + # ==================================================================== + # BypassValue in build_strategy_options + # ==================================================================== + + Test.@testset "BypassValue in build_strategy_options" begin + # Bypass unknown option + opts = Strategies.build_strategy_options( + TestStrategyA; unknown=Strategies.bypass(42) + ) + Test.@test opts[:unknown] == 42 + Test.@test Strategies.source(opts, :unknown) == :user + + # Bypass type validation (max_iter is Int) + opts2 = Strategies.build_strategy_options( + TestStrategyA; max_iter=Strategies.bypass("not_an_int") + ) + Test.@test opts2[:max_iter] == "not_an_int" + + # Bypass overwrites default (tolerance is 1e-6) + opts3 = Strategies.build_strategy_options( + TestStrategyA; tolerance=Strategies.bypass(1e-8) + ) + Test.@test opts3[:tolerance] == 1e-8 + end + + # ==================================================================== + # resolve_alias + # ==================================================================== + + Test.@testset "resolve_alias" begin + meta = Strategies.metadata(TestStrategyA) + + # Primary name returns itself + Test.@test Strategies.resolve_alias(meta, :max_iter) == :max_iter + Test.@test Strategies.resolve_alias(meta, :tolerance) == :tolerance + Test.@test Strategies.resolve_alias(meta, :verbose) == :verbose + + # Aliases resolve to primary name + Test.@test Strategies.resolve_alias(meta, :max) == :max_iter + Test.@test Strategies.resolve_alias(meta, :maxiter) == :max_iter + Test.@test Strategies.resolve_alias(meta, :tol) == :tolerance + + # Unknown key returns nothing + Test.@test Strategies.resolve_alias(meta, :unknown) === nothing + Test.@test Strategies.resolve_alias(meta, :invalid) === nothing + end + + # ==================================================================== + # filter_options + # ==================================================================== + + Test.@testset "filter_options" begin + opts = (max_iter=100, tolerance=1e-6, verbose=true, debug=false) + + # Filter single key + filtered1 = Strategies.filter_options(opts, :debug) + Test.@test filtered1 == (max_iter=100, tolerance=1e-6, verbose=true) + Test.@test !haskey(filtered1, :debug) + + # Filter multiple keys + filtered2 = Strategies.filter_options(opts, (:debug, :verbose)) + Test.@test filtered2 == (max_iter=100, tolerance=1e-6) + Test.@test !haskey(filtered2, :debug) + Test.@test !haskey(filtered2, :verbose) + + # Filter all keys + filtered3 = Strategies.filter_options( + opts, (:max_iter, :tolerance, :verbose, :debug) + ) + Test.@test filtered3 == NamedTuple() + Test.@test length(filtered3) == 0 + + # Filter non-existent key (should not error) + filtered4 = Strategies.filter_options(opts, :nonexistent) + Test.@test filtered4 == opts + end + + # ==================================================================== + # suggest_options + # ==================================================================== + + Test.@testset "suggest_options" begin + # Similar to existing option + suggestions1 = Strategies.suggest_options(:max_it, TestStrategyA) + Test.@test suggestions1[1].primary == :max_iter + + # Similar to alias + suggestions2 = Strategies.suggest_options(:tolrance, TestStrategyA) + Test.@test suggestions2[1].primary == :tolerance + + # Limit suggestions + suggestions3 = Strategies.suggest_options(:x, TestStrategyA; max_suggestions=2) + Test.@test length(suggestions3) <= 2 + + # Returns structured results + suggestions4 = Strategies.suggest_options(:unknown, TestStrategyA) + Test.@test !isempty(suggestions4) + Test.@test haskey(suggestions4[1], :primary) + Test.@test haskey(suggestions4[1], :aliases) + Test.@test haskey(suggestions4[1], :distance) + end + + # ==================================================================== + # levenshtein_distance (internal utility) + # ==================================================================== + + Test.@testset "levenshtein_distance" begin + # Identical strings + Test.@test Strategies.levenshtein_distance("test", "test") == 0 + + # Single character difference + Test.@test Strategies.levenshtein_distance("test", "best") == 1 + Test.@test Strategies.levenshtein_distance("test", "text") == 1 + + # Multiple differences + Test.@test Strategies.levenshtein_distance("kitten", "sitting") == 3 + + # Empty strings + Test.@test Strategies.levenshtein_distance("", "") == 0 + Test.@test Strategies.levenshtein_distance("test", "") == 4 + Test.@test Strategies.levenshtein_distance("", "test") == 4 + + # Relevant for option names + Test.@test Strategies.levenshtein_distance("max_iter", "max_it") == 2 + Test.@test Strategies.levenshtein_distance("tolerance", "tolrance") == 1 + end + + # ==================================================================== + # Integration: Full pipeline + # ==================================================================== + + Test.@testset "Integration: Configuration pipeline" begin + # Build options with aliases + opts = Strategies.build_strategy_options( + TestStrategyA; + max=250, # Alias for max_iter + tol=1e-9, # Alias for tolerance + ) + + Test.@test opts[:max_iter] == 250 + Test.@test opts[:tolerance] == 1e-9 + Test.@test opts[:verbose] == false # Default + + # Filter and verify + raw_opts = (max_iter=250, tolerance=1e-9, verbose=false) + filtered = Strategies.filter_options(raw_opts, :verbose) + Test.@test filtered == (max_iter=250, tolerance=1e-9) + end + end +end + +end # module + +test_configuration() = TestStrategiesConfiguration.test_configuration() diff --git a/test/suite/strategies/test_coverage_abstract_strategy.jl b/test/suite/strategies/test_coverage_abstract_strategy.jl new file mode 100644 index 00000000..1c21ca19 --- /dev/null +++ b/test/suite/strategies/test_coverage_abstract_strategy.jl @@ -0,0 +1,252 @@ +module TestCoverageAbstractStrategy + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Fake strategy types for testing (must be at module top-level) +# ============================================================================ + +struct CovFakeStrategy <: Strategies.AbstractStrategy + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{<:CovFakeStrategy}) = :cov_fake + +function Strategies.metadata(::Type{<:CovFakeStrategy}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:maxiter,), + ), + Options.OptionDefinition(; + name=:tol, type=Float64, default=1e-6, description="Convergence tolerance" + ), + ) +end + +Strategies.options(s::CovFakeStrategy) = s.options + +struct CovNoOptionsStrategy <: Strategies.AbstractStrategy + data::Int +end + +Strategies.id(::Type{<:CovNoOptionsStrategy}) = :cov_no_opts + +Strategies.metadata(::Type{<:CovNoOptionsStrategy}) = Strategies.StrategyMetadata() + +struct CovNoIdStrategy <: Strategies.AbstractStrategy end + +struct CovNoMetaStrategy <: Strategies.AbstractStrategy end + +Strategies.id(::Type{<:CovNoMetaStrategy}) = :cov_no_meta + +# Single-option strategy for singular display +struct CovSingleOptStrategy <: Strategies.AbstractStrategy + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{<:CovSingleOptStrategy}) = :cov_single + +function Strategies.metadata(::Type{<:CovSingleOptStrategy}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:value, type=Int, default=42, description="Single value" + ), + ) +end + +Strategies.options(s::CovSingleOptStrategy) = s.options + +# ============================================================================ +# Test function +# ============================================================================ + +function test_coverage_abstract_strategy() + Test.@testset "Coverage: Abstract Strategy" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - show(io, MIME"text/plain", strategy) - pretty display + # ==================================================================== + + Test.@testset "show(io, MIME text/plain) - instance display" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + ) + strategy = CovFakeStrategy(opts) + + buf = IOBuffer() + show(buf, MIME("text/plain"), strategy) + output = String(take!(buf)) + + Test.@test occursin("CovFakeStrategy", output) + Test.@test occursin("instance", output) + Test.@test occursin("cov_fake", output) + Test.@test occursin("max_iter", output) + Test.@test occursin("200", output) + Test.@test occursin("user", output) + Test.@test occursin("tol", output) + Test.@test occursin("default", output) + Test.@test occursin("Tip:", output) + end + + Test.@testset "show(io, MIME text/plain) - no id" begin + opts = Strategies.StrategyOptions() + strategy = CovNoIdStrategy() + + buf = IOBuffer() + Test.@test_throws Exceptions.NotImplemented show( + buf, MIME("text/plain"), strategy + ) + end + + Test.@testset "show(io, MIME text/plain) - no options" begin + strategy = CovNoOptionsStrategy(42) + + buf = IOBuffer() + Test.@test_throws Exceptions.NotImplemented show( + buf, MIME("text/plain"), strategy + ) + end + + Test.@testset "show(io, MIME text/plain) - single option (└─ prefix)" begin + opts = Strategies.StrategyOptions(value=Options.OptionValue(42, :default)) + strategy = CovSingleOptStrategy(opts) + + buf = IOBuffer() + show(buf, MIME("text/plain"), strategy) + output = String(take!(buf)) + + Test.@test occursin("└─", output) + Test.@test occursin("value", output) + end + + # ==================================================================== + # UNIT TESTS - show(io, strategy) - compact display + # ==================================================================== + + Test.@testset "show(io, strategy) - compact display" begin + # Test individual components without relying on exact color formatting + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + ) + strategy = CovFakeStrategy(opts) + + buf = IOBuffer() + show(buf, strategy) + output = String(take!(buf)) + + # Test that strategy name appears (colors don't matter) + Test.@test occursin("CovFakeStrategy", output) + + # Test that individual components appear + Test.@test occursin("max_iter", output) + Test.@test occursin("200", output) + Test.@test occursin("tol", output) + Test.@test occursin("1.0e-8", output) + + # Test that parentheses/structure exists + Test.@test occursin("(", output) + Test.@test occursin(")", output) + Test.@test occursin("=", output) + end + + Test.@testset "show(io, strategy) - no options" begin + strategy = CovNoOptionsStrategy(42) + + buf = IOBuffer() + Test.@test_throws Exceptions.NotImplemented show(buf, strategy) + end + + # ==================================================================== + # UNIT TESTS - describe(strategy_type) + # ==================================================================== + + Test.@testset "describe(strategy_type) - full metadata" begin + buf = IOBuffer() + Strategies.describe(buf, CovFakeStrategy) + output = String(take!(buf)) + + Test.@test occursin("CovFakeStrategy", output) + Test.@test occursin("strategy type", output) + Test.@test occursin("cov_fake", output) + Test.@test occursin("hierarchy", output) + Test.@test occursin("metadata", output) + Test.@test occursin("2 options defined", output) + Test.@test occursin("max_iter", output) + Test.@test occursin("tol", output) + Test.@test occursin("description:", output) + end + + Test.@testset "describe(strategy_type) - single option (singular)" begin + buf = IOBuffer() + Strategies.describe(buf, CovSingleOptStrategy) + output = String(take!(buf)) + + Test.@test occursin("1 option defined", output) + Test.@test occursin("└─", output) + end + + Test.@testset "describe(strategy_type) - no metadata (early return)" begin + buf = IOBuffer() + Test.@test_throws Exceptions.NotImplemented Strategies.describe( + buf, CovNoIdStrategy + ) + end + + Test.@testset "describe(strategy_type) - empty metadata (0 options)" begin + buf = IOBuffer() + Strategies.describe(buf, CovNoOptionsStrategy) + output = String(take!(buf)) + + Test.@test occursin("CovNoOptionsStrategy", output) + Test.@test occursin("0 options defined", output) + end + + Test.@testset "describe(stdout, strategy_type)" begin + redirect_stdout(devnull) do + Test.@test_nowarn Strategies.describe(CovFakeStrategy) + end + end + + # ==================================================================== + # UNIT TESTS - options() default with field access + # ==================================================================== + + Test.@testset "options() default - field access" begin + opts = Strategies.StrategyOptions(max_iter=Options.OptionValue(100, :default)) + strategy = CovFakeStrategy(opts) + Test.@test Strategies.options(strategy) === opts + end + + Test.@testset "options() default - no options field" begin + strategy = CovNoOptionsStrategy(42) + Test.@test_throws Exceptions.NotImplemented Strategies.options(strategy) + end + + # ==================================================================== + # UNIT TESTS - NotImplemented errors + # ==================================================================== + + Test.@testset "NotImplemented errors" begin + Test.@test_throws Exceptions.NotImplemented Strategies.id(CovNoIdStrategy) + Test.@test_throws Exceptions.NotImplemented Strategies.metadata(CovNoIdStrategy) + end + end +end + +end # module + +function test_coverage_abstract_strategy() + TestCoverageAbstractStrategy.test_coverage_abstract_strategy() +end diff --git a/test/suite/strategies/test_disambiguation.jl b/test/suite/strategies/test_disambiguation.jl new file mode 100644 index 00000000..a668f062 --- /dev/null +++ b/test/suite/strategies/test_disambiguation.jl @@ -0,0 +1,255 @@ +""" +Unit tests for option disambiguation with RoutedOption and route_to(). + +Tests the behavior of the route_to() helper function and RoutedOption type +for creating disambiguated option values with strategy routing. +""" +module TestDisambiguation + +using Test: Test +import CTBase.Strategies + +# Test options for verbose output +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +function test_disambiguation() + Test.@testset "Option Disambiguation" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - RoutedOption Type + # ==================================================================== + + Test.@testset "RoutedOption Type" begin + # Create RoutedOption directly + routes = (solver=100,) + opt = Strategies.RoutedOption(routes) + Test.@test opt isa Strategies.RoutedOption + Test.@test collect(pairs(opt)) == collect(pairs(routes)) + + # Empty routes should throw + Test.@test_throws Exception Strategies.RoutedOption(NamedTuple()) + end + + # ==================================================================== + # UNIT TESTS - route_to() Basic Functionality + # ==================================================================== + + Test.@testset "route_to() Single Strategy" begin + result = Strategies.route_to(solver=100) + Test.@test result isa Strategies.RoutedOption + Test.@test length(result) == 1 + Test.@test result[:solver] == 100 + end + + Test.@testset "route_to() Multiple Strategies" begin + result = Strategies.route_to(solver=100, modeler=50) + Test.@test result isa Strategies.RoutedOption + Test.@test length(result) == 2 + Test.@test result[:solver] == 100 + Test.@test result[:modeler] == 50 + end + + Test.@testset "route_to() No Arguments Error" begin + Test.@test_throws Exception Strategies.route_to() + end + + # ==================================================================== + # UNIT TESTS - route_to() Positional Syntax + # ==================================================================== + + Test.@testset "route_to() Positional Syntax - Single Strategy" begin + result = Strategies.route_to(:solver, 100) + Test.@test result isa Strategies.RoutedOption + Test.@test length(result) == 1 + Test.@test result[:solver] == 100 + end + + Test.@testset "route_to() Positional Syntax - Multiple Strategies" begin + result = Strategies.route_to(:solver, 100, :modeler, 50) + Test.@test result isa Strategies.RoutedOption + Test.@test length(result) == 2 + Test.@test result[:solver] == 100 + Test.@test result[:modeler] == 50 + end + + Test.@testset "route_to() Positional Syntax - Three Strategies" begin + result = Strategies.route_to(:solver, 100, :modeler, 50, :discretizer, 200) + Test.@test length(result) == 3 + Test.@test result[:solver] == 100 + Test.@test result[:modeler] == 50 + Test.@test result[:discretizer] == 200 + end + + Test.@testset "route_to() Positional Syntax - Different Value Types" begin + # Integer + result = Strategies.route_to(:modeler, 42) + Test.@test result[:modeler] == 42 + + # Float + result = Strategies.route_to(:solver, 1.5e-6) + Test.@test result[:solver] == 1.5e-6 + + # String + result = Strategies.route_to(:optimizer, "ipopt") + Test.@test result[:optimizer] == "ipopt" + + # Boolean + result = Strategies.route_to(:solver, true) + Test.@test result[:solver] == true + + # Symbol + result = Strategies.route_to(:modeler, :auto) + Test.@test result[:modeler] == :auto + end + + Test.@testset "route_to() Positional Syntax - Error Cases" begin + # No arguments + Test.@test_throws Exception Strategies.route_to() + + # Odd number of arguments + Test.@test_throws Exception Strategies.route_to(:solver) + + # Non-Symbol strategy identifier + Test.@test_throws Exception Strategies.route_to("solver", 100) + Test.@test_throws Exception Strategies.route_to(1, 100) + end + + Test.@testset "route_to() Syntax Equivalence" begin + # Both syntaxes should produce identical results + kw_result = Strategies.route_to(solver=100, modeler=50) + pos_result = Strategies.route_to(:solver, 100, :modeler, 50) + + Test.@test collect(pairs(kw_result)) == collect(pairs(pos_result)) + Test.@test kw_result[:solver] == pos_result[:solver] + Test.@test kw_result[:modeler] == pos_result[:modeler] + end + + # ==================================================================== + # UNIT TESTS - Different Value Types + # ==================================================================== + + Test.@testset "Different Value Types" begin + # Integer value + result = Strategies.route_to(modeler=42) + Test.@test result[:modeler] == 42 + + # Float value + result = Strategies.route_to(solver=1.5e-6) + Test.@test result[:solver] == 1.5e-6 + + # String value + result = Strategies.route_to(optimizer="ipopt") + Test.@test result[:optimizer] == "ipopt" + + # Boolean value + result = Strategies.route_to(solver=true) + Test.@test result[:solver] == true + + # Symbol value + result = Strategies.route_to(modeler=:auto) + Test.@test result[:modeler] == :auto + end + + Test.@testset "Different Strategy Identifiers" begin + # Common strategy identifiers + Test.@test Strategies.route_to(solver=100)[:solver] == 100 + Test.@test Strategies.route_to(modeler=100)[:modeler] == 100 + Test.@test Strategies.route_to(optimizer=100)[:optimizer] == 100 + Test.@test Strategies.route_to(discretizer=100)[:discretizer] == 100 + end + + # ==================================================================== + # UNIT TESTS - Complex Values + # ==================================================================== + + Test.@testset "Complex Value Types" begin + # Array value + result = Strategies.route_to(solver=[1, 2, 3]) + Test.@test result[:solver] == [1, 2, 3] + + # Tuple value + result = Strategies.route_to(modeler=(1, 2)) + Test.@test result[:modeler] == (1, 2) + + # NamedTuple value + result = Strategies.route_to(solver=(a=1, b=2)) + Test.@test result[:solver] == (a=1, b=2) + end + + # ==================================================================== + # UNIT TESTS - Multiple Strategies Use Cases + # ==================================================================== + + Test.@testset "Multiple Strategies with Same Option" begin + # Different values for different strategies + result = Strategies.route_to(solver=100, modeler=50, discretizer=200) + Test.@test length(result) == 3 + Test.@test result[:solver] == 100 + Test.@test result[:modeler] == 50 + Test.@test result[:discretizer] == 200 + end + + # ==================================================================== + # UNIT TESTS - Edge Cases + # ==================================================================== + + Test.@testset "Edge Cases" begin + # Nothing value + result = Strategies.route_to(solver=nothing) + Test.@test result[:solver] === nothing + + # Missing value + result = Strategies.route_to(solver=missing) + Test.@test result[:solver] === missing + end + + # ==================================================================== + # UNIT TESTS - Collection Interface + # ==================================================================== + + Test.@testset "Collection Interface - Iteration" begin + opt = Strategies.route_to(solver=100, modeler=50) + + # Test keys() + Test.@test :solver in keys(opt) + Test.@test :modeler in keys(opt) + Test.@test collect(keys(opt)) == [:solver, :modeler] + + # Test values() + Test.@test 100 in values(opt) + Test.@test 50 in values(opt) + Test.@test collect(values(opt)) == [100, 50] + + # Test pairs() + pairs_collected = collect(pairs(opt)) + Test.@test length(pairs_collected) == 2 + Test.@test pairs_collected[1] == (:solver => 100) + Test.@test pairs_collected[2] == (:modeler => 50) + + # Test direct iteration (should yield pairs) + for (id, val) in opt + Test.@test id in (:solver, :modeler) + Test.@test val in (100, 50) + end + + # Test getindex[] + Test.@test opt[:solver] == 100 + Test.@test opt[:modeler] == 50 + + # Test haskey + Test.@test haskey(opt, :solver) + Test.@test haskey(opt, :modeler) + Test.@test !haskey(opt, :discretizer) + + # Test length + Test.@test length(opt) == 2 + Test.@test length(Strategies.route_to(solver=1)) == 1 + end + end +end + +end # module + +# Export test function to outer scope +test_disambiguation() = TestDisambiguation.test_disambiguation() diff --git a/test/suite/strategies/test_introspection.jl b/test/suite/strategies/test_introspection.jl new file mode 100644 index 00000000..c63e1dc3 --- /dev/null +++ b/test/suite/strategies/test_introspection.jl @@ -0,0 +1,327 @@ +module TestStrategiesIntrospection + +using Test: Test +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Fake strategy types for testing (must be at module top-level) +# ============================================================================ + +struct IntrospectionTestStrategy <: Strategies.AbstractStrategy + options::Strategies.StrategyOptions +end + +struct EmptyOptionsStrategy <: Strategies.AbstractStrategy + options::Strategies.StrategyOptions +end + +# ============================================================================ +# Implement contract methods +# ============================================================================ + +Strategies.id(::Type{<:IntrospectionTestStrategy}) = :introspection_test + +function Strategies.metadata(::Type{<:IntrospectionTestStrategy}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, + type=Int, + default=100, + description="Maximum number of iterations", + aliases=(:max, :maxiter), + ), + Options.OptionDefinition(; + name=:tol, type=Float64, default=1e-6, description="Convergence tolerance" + ), + Options.OptionDefinition(; + name=:backend, type=Symbol, default=:cpu, description="Execution backend" + ), + ) +end + +Strategies.id(::Type{<:EmptyOptionsStrategy}) = :empty_options +Strategies.metadata(::Type{<:EmptyOptionsStrategy}) = Strategies.StrategyMetadata() + +# ============================================================================ +# Test function +# ============================================================================ + +""" + test_introspection() + +Tests for strategy introspection utilities. +""" +function test_introspection() + Test.@testset "Strategy Introspection" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ======================================================================== + # UNIT TESTS + # ======================================================================== + + Test.@testset "Unit Tests" begin + + # ==================================================================== + # Type-level introspection (metadata access) + # ==================================================================== + + Test.@testset "option_names - type-level" begin + names = Strategies.option_names(IntrospectionTestStrategy) + Test.@test names isa Tuple + Test.@test length(names) == 3 + Test.@test :max_iter in names + Test.@test :tol in names + Test.@test :backend in names + + # Empty strategy + empty_names = Strategies.option_names(EmptyOptionsStrategy) + Test.@test empty_names isa Tuple + Test.@test length(empty_names) == 0 + end + + Test.@testset "option_type - type-level" begin + Test.@test Strategies.option_type(IntrospectionTestStrategy, :max_iter) === + Int + Test.@test Strategies.option_type(IntrospectionTestStrategy, :tol) === + Float64 + Test.@test Strategies.option_type(IntrospectionTestStrategy, :backend) === + Symbol + + # Unknown option (FieldError in Julia 1.11+, ErrorException in 1.10) + Test.@test_throws Exception Strategies.option_type( + IntrospectionTestStrategy, :nonexistent + ) + end + + Test.@testset "option_description - type-level" begin + desc = Strategies.option_description(IntrospectionTestStrategy, :max_iter) + Test.@test desc isa String + Test.@test desc == "Maximum number of iterations" + + desc2 = Strategies.option_description(IntrospectionTestStrategy, :tol) + Test.@test desc2 == "Convergence tolerance" + + # Unknown option (FieldError in Julia 1.11+, ErrorException in 1.10) + Test.@test_throws Exception Strategies.option_description( + IntrospectionTestStrategy, :nonexistent + ) + end + + Test.@testset "option_default - type-level" begin + Test.@test Strategies.option_default( + IntrospectionTestStrategy, :max_iter + ) == 100 + Test.@test Strategies.option_default(IntrospectionTestStrategy, :tol) == + 1e-6 + Test.@test Strategies.option_default(IntrospectionTestStrategy, :backend) == + :cpu + + # Unknown option (FieldError in Julia 1.11+, ErrorException in 1.10) + Test.@test_throws Exception Strategies.option_default( + IntrospectionTestStrategy, :nonexistent + ) + end + + Test.@testset "option_defaults - type-level" begin + defaults = Strategies.option_defaults(IntrospectionTestStrategy) + Test.@test defaults isa NamedTuple + Test.@test length(defaults) == 3 + Test.@test defaults.max_iter == 100 + Test.@test defaults.tol == 1e-6 + Test.@test defaults.backend == :cpu + + # Empty strategy + empty_defaults = Strategies.option_defaults(EmptyOptionsStrategy) + Test.@test empty_defaults isa NamedTuple + Test.@test length(empty_defaults) == 0 + end + + # ==================================================================== + # Instance-level introspection (configured state access) + # ==================================================================== + + Test.@testset "option_value - instance-level" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :user), + backend=Options.OptionValue(:gpu, :user), + ) + strategy = IntrospectionTestStrategy(opts) + + Test.@test Strategies.option_value(strategy, :max_iter) == 200 + Test.@test Strategies.option_value(strategy, :tol) == 1e-8 + Test.@test Strategies.option_value(strategy, :backend) == :gpu + + # Unknown option (NamedTuple throws FieldError in Julia 1.11+, ErrorException in 1.10) + Test.@test_throws Exception Strategies.option_value(strategy, :nonexistent) + end + + Test.@testset "option_source - instance-level" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-6, :default), + backend=Options.OptionValue(:cpu, :computed), + ) + strategy = IntrospectionTestStrategy(opts) + + Test.@test Strategies.option_source(strategy, :max_iter) === :user + Test.@test Strategies.option_source(strategy, :tol) === :default + Test.@test Strategies.option_source(strategy, :backend) === :computed + + # Unknown option (NamedTuple throws FieldError in Julia 1.11+, ErrorException in 1.10) + Test.@test_throws Exception Strategies.option_source(strategy, :nonexistent) + end + + Test.@testset "is_user - instance-level" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-6, :default), + backend=Options.OptionValue(:cpu, :computed), + ) + strategy = IntrospectionTestStrategy(opts) + + Test.@test Strategies.option_is_user(strategy, :max_iter) === true + Test.@test Strategies.option_is_user(strategy, :tol) === false + Test.@test Strategies.option_is_user(strategy, :backend) === false + end + + Test.@testset "is_default - instance-level" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-6, :default), + backend=Options.OptionValue(:cpu, :computed), + ) + strategy = IntrospectionTestStrategy(opts) + + Test.@test Strategies.option_is_default(strategy, :max_iter) === false + Test.@test Strategies.option_is_default(strategy, :tol) === true + Test.@test Strategies.option_is_default(strategy, :backend) === false + end + + Test.@testset "is_computed - instance-level" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-6, :default), + backend=Options.OptionValue(:cpu, :computed), + ) + strategy = IntrospectionTestStrategy(opts) + + Test.@test Strategies.option_is_computed(strategy, :max_iter) === false + Test.@test Strategies.option_is_computed(strategy, :tol) === false + Test.@test Strategies.option_is_computed(strategy, :backend) === true + end + end + + # ======================================================================== + # INTEGRATION TESTS + # ======================================================================== + + Test.@testset "Integration Tests" begin + Test.@testset "Type-level vs instance-level consistency" begin + # Type-level metadata + type_names = Strategies.option_names(IntrospectionTestStrategy) + type_defaults = Strategies.option_defaults(IntrospectionTestStrategy) + + # Create instance with user values + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :user), + backend=Options.OptionValue(:gpu, :user), + ) + strategy = IntrospectionTestStrategy(opts) + + # Type-level should be independent of instance + Test.@test Strategies.option_names(typeof(strategy)) == type_names + Test.@test Strategies.option_defaults(typeof(strategy)) == type_defaults + + # Instance values should differ from defaults + Test.@test Strategies.option_value(strategy, :max_iter) != + type_defaults.max_iter + Test.@test Strategies.option_value(strategy, :tol) != type_defaults.tol + Test.@test Strategies.option_value(strategy, :backend) != + type_defaults.backend + end + + Test.@testset "Provenance tracking workflow" begin + # Create strategy with mixed sources + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-6, :default), + backend=Options.OptionValue(:cpu, :computed), + ) + strategy = IntrospectionTestStrategy(opts) + + # Verify provenance predicates are mutually exclusive + for key in (:max_iter, :tol, :backend) + sources = [ + Strategies.option_is_user(strategy, key), + Strategies.option_is_default(strategy, key), + Strategies.option_is_computed(strategy, key), + ] + Test.@test count(sources) == 1 # Exactly one should be true + end + end + + Test.@testset "Complete introspection workflow" begin + # 1. Discover available options (type-level) + names = Strategies.option_names(IntrospectionTestStrategy) + Test.@test length(names) == 3 + + # 2. Query metadata for each option (type-level) + for name in names + type_info = Strategies.option_type(IntrospectionTestStrategy, name) + desc = Strategies.option_description(IntrospectionTestStrategy, name) + default = Strategies.option_default(IntrospectionTestStrategy, name) + + Test.@test type_info isa Type + Test.@test desc isa String + Test.@test !isnothing(default) + end + + # 3. Create instance with custom values + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(150, :user), + tol=Options.OptionValue(1e-6, :default), + backend=Options.OptionValue(:cpu, :default), + ) + strategy = IntrospectionTestStrategy(opts) + + # 4. Query instance state + for name in names + value = Strategies.option_value(strategy, name) + source = Strategies.option_source(strategy, name) + + Test.@test !isnothing(value) + Test.@test source in (:user, :default, :computed) + end + end + + Test.@testset "typeof() pattern for type-level functions" begin + # Create instance + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-6, :default), + backend=Options.OptionValue(:cpu, :default), + ) + strategy = IntrospectionTestStrategy(opts) + + # Type-level functions should work with typeof() + Test.@test Strategies.option_names(typeof(strategy)) == + Strategies.option_names(IntrospectionTestStrategy) + + Test.@test Strategies.option_type(typeof(strategy), :max_iter) == + Strategies.option_type(IntrospectionTestStrategy, :max_iter) + + Test.@test Strategies.option_defaults(typeof(strategy)) == + Strategies.option_defaults(IntrospectionTestStrategy) + end + end + end +end + +end # module + +test_introspection() = TestStrategiesIntrospection.test_introspection() diff --git a/test/suite/strategies/test_metadata.jl b/test/suite/strategies/test_metadata.jl new file mode 100644 index 00000000..37240ce5 --- /dev/null +++ b/test/suite/strategies/test_metadata.jl @@ -0,0 +1,234 @@ +module TestStrategiesMetadata + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +""" + test_metadata() + +Tests for strategy metadata functionality. +""" +function test_metadata() + Test.@testset "StrategyMetadata" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ======================================================================== + # Basic construction with varargs + # ======================================================================== + + Test.@testset "Basic construction" begin + meta = Strategies.StrategyMetadata( + Options.OptionDefinition( + name=:max_iter, type=Int, default=100, description="Maximum iterations" + ), + Options.OptionDefinition( + name=:tol, type=Float64, default=1e-6, description="Tolerance" + ), + ) + + Test.@test length(meta) == 2 + Test.@test Set(keys(meta)) == Set((:max_iter, :tol)) + Test.@test Options.name(meta[:max_iter]) == :max_iter + Test.@test Options.type(meta[:max_iter]) == Int + Test.@test Options.default(meta[:max_iter]) == 100 + Test.@test Options.type(meta[:tol]) == Float64 + Test.@test meta[:tol].default == 1e-6 + end + + # ======================================================================== + # Construction with aliases and validators + # ======================================================================== + + Test.@testset "Advanced construction" begin + meta = Strategies.StrategyMetadata( + Options.OptionDefinition( + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:max, :maxiter), + validator=x -> x > 0, + ), + ) + + def = meta[:max_iter] + Test.@test def.aliases == (:max, :maxiter) + Test.@test def.validator !== nothing + Test.@test def.validator(10) == true + end + + # ======================================================================== + # Duplicate name detection + # ======================================================================== + + Test.@testset "Duplicate detection" begin + Test.@test_throws Exceptions.IncorrectArgument Strategies.StrategyMetadata( + Options.OptionDefinition( + name=:max_iter, type=Int, default=100, description="First" + ), + Options.OptionDefinition( + name=:max_iter, type=Int, default=200, description="Second" + ), + ) + end + + # ======================================================================== + # Empty metadata + # ======================================================================== + + Test.@testset "Empty metadata" begin + meta = Strategies.StrategyMetadata() + Test.@test length(meta) == 0 + Test.@test collect(keys(meta)) == [] + end + + # ======================================================================== + # Indexability and iteration + # ======================================================================== + + Test.@testset "Indexability" begin + meta = Strategies.StrategyMetadata( + Options.OptionDefinition( + name=:option1, type=Int, default=1, description="First option" + ), + Options.OptionDefinition( + name=:option2, type=String, default="test", description="Second option" + ), + ) + + # Test getindex + Test.@test meta[:option1].default == 1 + Test.@test meta[:option2].default == "test" + + # Test keys, values, pairs + Test.@test Set(keys(meta)) == Set((:option1, :option2)) + Test.@test length(collect(values(meta))) == 2 + Test.@test length(collect(pairs(meta))) == 2 + + # Test iteration + count = 0 + for (key, def) in meta + Test.@test key in (:option1, :option2) + Test.@test def isa Options.OptionDefinition + count += 1 + end + Test.@test count == 2 + end + + # ======================================================================== + # Display functionality + # ======================================================================== + + Test.@testset "Display" begin + meta = Strategies.StrategyMetadata( + Options.OptionDefinition( + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:max, :maxiter), + validator=x -> x > 0, + ), + Options.OptionDefinition( + name=:tol, + type=Float64, + default=1e-6, + description="Convergence tolerance", + ), + ) + + # Test that show method produces expected output format + io = IOBuffer() + Base.show(io, MIME"text/plain"(), meta) + output = String(take!(io)) + + # Test individual components without relying on exact color formatting + Test.@test occursin("StrategyMetadata", output) + Test.@test occursin("2", output) + Test.@test occursin("options", output) + Test.@test occursin("max_iter", output) + Test.@test occursin("max", output) + Test.@test occursin("maxiter", output) + Test.@test occursin("Int64", output) + Test.@test occursin("tol", output) + Test.@test occursin("Float64", output) + Test.@test occursin("default", output) + Test.@test occursin("100", output) + Test.@test occursin("1.0e-6", output) + Test.@test occursin("description", output) + Test.@test occursin("Maximum", output) + Test.@test occursin("iterations", output) + Test.@test occursin("Convergence", output) + Test.@test occursin("tolerance", output) + end + + # ======================================================================== + # Type stability tests + # ======================================================================== + + Test.@testset "Type stability" begin + # Create metadata with different types + meta = Strategies.StrategyMetadata( + Options.OptionDefinition( + name=:max_iter, type=Int, default=100, description="Maximum iterations" + ), + Options.OptionDefinition( + name=:tol, type=Float64, default=1e-6, description="Tolerance" + ), + ) + + # Test that StrategyMetadata is parameterized correctly + Test.@test meta isa Strategies.StrategyMetadata{<:NamedTuple} + + # Verify that the NamedTuple preserves concrete types + Test.@test meta[:max_iter] isa Options.OptionDefinition{Int64} + Test.@test meta[:tol] isa Options.OptionDefinition{Float64} + + # Test direct access to specs (type-stable) + function get_max_iter_spec(m::Strategies.StrategyMetadata) + return m[:max_iter] + end + function get_tol_spec(m::Strategies.StrategyMetadata) + return m[:tol] + end + + Test.@inferred get_max_iter_spec(meta) + Test.@test get_max_iter_spec(meta).default === 100 + + Test.@inferred get_tol_spec(meta) + Test.@test get_tol_spec(meta).default === 1e-6 + + # Note: Dynamic access via Symbol (meta[:key]) cannot be type-stable + # This is expected and acceptable since metadata access happens at construction time + Test.@test meta[:max_iter] isa Options.OptionDefinition{Int64} + Test.@test meta[:tol] isa Options.OptionDefinition{Float64} + + # Test type-stable iteration with type narrowing + function sum_int_defaults(m::Strategies.StrategyMetadata) + total = 0 + for (key, def) in m + if def isa Options.OptionDefinition{Int} + total += def.default # Type-stable within branch + end + end + return total + end + + Test.@inferred sum_int_defaults(meta) + Test.@test sum_int_defaults(meta) == 100 + + # Test that values() preserves types + vals = collect(values(meta)) + Test.@test vals[1] isa Options.OptionDefinition{Int64} + Test.@test vals[2] isa Options.OptionDefinition{Float64} + end + end +end + +end # module + +test_metadata() = TestStrategiesMetadata.test_metadata() diff --git a/test/suite/strategies/test_parameter_contract_helpers.jl b/test/suite/strategies/test_parameter_contract_helpers.jl new file mode 100644 index 00000000..d38c1ac6 --- /dev/null +++ b/test/suite/strategies/test_parameter_contract_helpers.jl @@ -0,0 +1,57 @@ +module TestParameterContractHelpers + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# TOP-LEVEL: Define all structs here +struct FakeParamOk <: Strategies.AbstractStrategyParameter end +Strategies.id(::Type{FakeParamOk}) = :fake_param_ok + +struct FakeParamWithField <: Strategies.AbstractStrategyParameter + x::Int +end +Strategies.id(::Type{FakeParamWithField}) = :fake_param_with_field + +function test_parameter_contract_helpers() + Test.@testset "Strategy Parameter Contract Helpers" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - Predicates and Aliases + # ==================================================================== + + Test.@testset "Predicates and aliases" begin + Test.@test Strategies.is_a_parameter(Strategies.CPU) + Test.@test Strategies.is_a_parameter(Strategies.GPU) + Test.@test Strategies.is_a_parameter(FakeParamOk) + Test.@test !Strategies.is_a_parameter(Int) + + Test.@test Strategies.parameter_id(Strategies.CPU) == :cpu + Test.@test Strategies.parameter_id(Strategies.GPU) == :gpu + Test.@test Strategies.parameter_id(FakeParamOk) == :fake_param_ok + end + + # ==================================================================== + # UNIT TESTS - validate_parameter_type + # ==================================================================== + + Test.@testset "validate_parameter_type" begin + Test.@test Strategies.validate_parameter_type(Strategies.CPU) === nothing + Test.@test Strategies.validate_parameter_type(FakeParamOk) === nothing + + Test.@test_throws Exceptions.IncorrectArgument Strategies.validate_parameter_type( + FakeParamWithField + ) + end + end +end + +end # module + +# Redefine in outer scope for TestRunner +function test_parameter_contract_helpers() + TestParameterContractHelpers.test_parameter_contract_helpers() +end diff --git a/test/suite/strategies/test_parameters.jl b/test/suite/strategies/test_parameters.jl new file mode 100644 index 00000000..e2d2d054 --- /dev/null +++ b/test/suite/strategies/test_parameters.jl @@ -0,0 +1,68 @@ +module TestParameters + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# TOP-LEVEL: Define all structs here +struct BadParam <: Strategies.AbstractStrategyParameter end + +function test_parameters() + Test.@testset "AbstractStrategyParameter Contract" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - Contract Implementation + # ==================================================================== + + Test.@testset "Built-in parameter IDs" begin + Test.@test Strategies.id(Strategies.CPU) == :cpu + Test.@test Strategies.id(Strategies.GPU) == :gpu + end + + Test.@testset "NotImplemented for parameter without id()" begin + Test.@test_throws Exceptions.NotImplemented Strategies.id(BadParam) + end + + Test.@testset "Singleton types (no state)" begin + Test.@test sizeof(Strategies.CPU) == 0 + Test.@test sizeof(Strategies.GPU) == 0 + Test.@test fieldcount(Strategies.CPU) == 0 + Test.@test fieldcount(Strategies.GPU) == 0 + end + + Test.@testset "Parameter inheritance" begin + Test.@test Strategies.CPU <: Strategies.AbstractStrategyParameter + Test.@test Strategies.GPU <: Strategies.AbstractStrategyParameter + Test.@test Strategies.AbstractStrategyParameter isa Type + end + + Test.@testset "Parameter type stability" begin + Test.@test_nowarn Test.@inferred Strategies.id(Strategies.CPU) + Test.@test_nowarn Test.@inferred Strategies.id(Strategies.GPU) + end + + # ==================================================================== + # INTEGRATION TESTS + # ==================================================================== + + Test.@testset "Parameter uniqueness" begin + # CPU and GPU should have different IDs + Test.@test Strategies.id(Strategies.CPU) != Strategies.id(Strategies.GPU) + end + + Test.@testset "Parameter in registry context" begin + # Test that parameters can be used in registry creation + # This will be tested more thoroughly in registry tests + Test.@test Strategies.id(Strategies.CPU) isa Symbol + Test.@test Strategies.id(Strategies.GPU) isa Symbol + end + end +end + +end # module + +# Redefine in outer scope for TestRunner +test_parameters() = TestParameters.test_parameters() diff --git a/test/suite/strategies/test_parameters_coverage.jl b/test/suite/strategies/test_parameters_coverage.jl new file mode 100644 index 00000000..7aed8d64 --- /dev/null +++ b/test/suite/strategies/test_parameters_coverage.jl @@ -0,0 +1,157 @@ +module TestParametersCoverage + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# TOP-LEVEL: Define all test types here (never inside test functions) +abstract type TestAbstractParam <: Strategies.AbstractStrategyParameter end + +struct TestParamWithFields <: Strategies.AbstractStrategyParameter + value::Int +end +Strategies.id(::Type{TestParamWithFields}) = :test_with_fields + +struct TestValidParam <: Strategies.AbstractStrategyParameter end +Strategies.id(::Type{TestValidParam}) = :test_valid + +struct TestParamNoId <: Strategies.AbstractStrategyParameter end + +""" + test_parameters_coverage() + +🧪 **Applying Testing Rule**: Unit Tests for strategy parameters + +Tests uncovered lines in parameters.jl: +- Line 149: validate_parameter_type with non-concrete type +- Lines 193-194: id() for CPU and GPU parameters +""" +function test_parameters_coverage() + Test.@testset "Strategy Parameters Coverage" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - id() for Built-in Parameters + # ==================================================================== + + Test.@testset "id() for Built-in Parameters" begin + # Test id() for CPU and GPU (covers parameters.jl:193-194) + Test.@test Strategies.id(Strategies.CPU) === :cpu + Test.@test Strategies.id(Strategies.GPU) === :gpu + end + + # ==================================================================== + # UNIT TESTS - validate_parameter_type Error Cases + # ==================================================================== + + Test.@testset "validate_parameter_type - Non-Concrete Type" begin + # Test validation with non-concrete type (covers parameters.jl:149) + # TestAbstractParam is defined at module top-level + + # Should throw IncorrectArgument for abstract type + redirect_stderr(devnull) do + Test.@test_throws Exceptions.IncorrectArgument Strategies.validate_parameter_type( + TestAbstractParam + ) + end + + # Verify error message content + err = try + Strategies.validate_parameter_type(TestAbstractParam) + catch e + e + end + Test.@test err isa Exceptions.IncorrectArgument + Test.@test occursin("concrete", string(err)) + end + + Test.@testset "validate_parameter_type - Parameter with Fields" begin + # Test validation with parameter that has fields + # TestParamWithFields is defined at module top-level + + # Should throw IncorrectArgument for non-singleton type + redirect_stderr(devnull) do + Test.@test_throws Exceptions.IncorrectArgument Strategies.validate_parameter_type( + TestParamWithFields + ) + end + + # Verify error message content + err = try + Strategies.validate_parameter_type(TestParamWithFields) + catch e + e + end + Test.@test err isa Exceptions.IncorrectArgument + Test.@test occursin("singleton", string(err)) + end + + Test.@testset "validate_parameter_type - Valid Parameters" begin + # Test validation with valid parameters + + # CPU and GPU should validate successfully + Test.@test_nowarn Strategies.validate_parameter_type(Strategies.CPU) + Test.@test_nowarn Strategies.validate_parameter_type(Strategies.GPU) + + # TestValidParam is defined at module top-level + Test.@test_nowarn Strategies.validate_parameter_type(TestValidParam) + end + + # ==================================================================== + # UNIT TESTS - parameter_id() Alias + # ==================================================================== + + Test.@testset "parameter_id() Alias" begin + # Test parameter_id() as alias for id() + Test.@test Strategies.parameter_id(Strategies.CPU) === :cpu + Test.@test Strategies.parameter_id(Strategies.GPU) === :gpu + + # Should be identical to id() + Test.@test Strategies.parameter_id(Strategies.CPU) === + Strategies.id(Strategies.CPU) + Test.@test Strategies.parameter_id(Strategies.GPU) === + Strategies.id(Strategies.GPU) + end + + # ==================================================================== + # UNIT TESTS - is_a_parameter() Predicate + # ==================================================================== + + Test.@testset "is_a_parameter() Predicate" begin + # Test is_a_parameter() predicate + Test.@test Strategies.is_a_parameter(Strategies.CPU) === true + Test.@test Strategies.is_a_parameter(Strategies.GPU) === true + Test.@test Strategies.is_a_parameter(Int) === false + Test.@test Strategies.is_a_parameter(String) === false + Test.@test Strategies.is_a_parameter( + Strategies.AbstractStrategyParameter + ) === true + end + + # ==================================================================== + # UNIT TESTS - NotImplemented Error for id() + # ==================================================================== + + Test.@testset "id() NotImplemented Error" begin + # Test that id() throws NotImplemented for types without implementation + # TestParamNoId is defined at module top-level + + Test.@test_throws Exceptions.NotImplemented Strategies.id(TestParamNoId) + + # Verify error message content + err = try + Strategies.id(TestParamNoId) + catch e + e + end + Test.@test err isa Exceptions.NotImplemented + Test.@test occursin("id()", string(err)) + end + end +end + +end # module + +test_parameters_coverage() = TestParametersCoverage.test_parameters_coverage() diff --git a/test/suite/strategies/test_registry.jl b/test/suite/strategies/test_registry.jl new file mode 100644 index 00000000..a4e209e9 --- /dev/null +++ b/test/suite/strategies/test_registry.jl @@ -0,0 +1,262 @@ +module TestStrategiesRegistry + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Fake strategy types for testing (must be at module top-level) +# ============================================================================ + +abstract type AbstractTestFamily <: Strategies.AbstractStrategy end +abstract type AbstractOtherFamily <: Strategies.AbstractStrategy end + +struct TestStrategyA <: AbstractTestFamily + options::Strategies.StrategyOptions +end + +struct TestStrategyB <: AbstractTestFamily + options::Strategies.StrategyOptions +end + +struct TestStrategyC <: AbstractOtherFamily + options::Strategies.StrategyOptions +end + +struct WrongTypeStrategy <: Strategies.AbstractStrategy + options::Strategies.StrategyOptions +end + +# ============================================================================ +# Implement contract methods +# ============================================================================ + +Strategies.id(::Type{<:TestStrategyA}) = :strategy_a +Strategies.id(::Type{<:TestStrategyB}) = :strategy_b +Strategies.id(::Type{<:TestStrategyC}) = :strategy_c +Strategies.id(::Type{<:WrongTypeStrategy}) = :wrong + +Strategies.metadata(::Type{<:TestStrategyA}) = Strategies.StrategyMetadata() +Strategies.metadata(::Type{<:TestStrategyB}) = Strategies.StrategyMetadata() +Strategies.metadata(::Type{<:TestStrategyC}) = Strategies.StrategyMetadata() +Strategies.metadata(::Type{<:WrongTypeStrategy}) = Strategies.StrategyMetadata() + +# ============================================================================ +# Test function +# ============================================================================ + +""" + test_registry() + +Tests for strategy registry API. +""" +function test_registry() + Test.@testset "Strategy Registry" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ======================================================================== + # UNIT TESTS + # ======================================================================== + + Test.@testset "Unit Tests" begin + Test.@testset "StrategyRegistry type" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA, TestStrategyB) + ) + Test.@test registry isa Strategies.StrategyRegistry + Test.@test hasfield(typeof(registry), :families) + end + + Test.@testset "create_registry - basic creation" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA, TestStrategyB), + AbstractOtherFamily => (TestStrategyC,), + ) + + Test.@test registry isa Strategies.StrategyRegistry + Test.@test length(registry.families) == 2 + Test.@test haskey(registry.families, AbstractTestFamily) + Test.@test haskey(registry.families, AbstractOtherFamily) + end + + Test.@testset "create_registry - empty registry" begin + registry = Strategies.create_registry() + Test.@test registry isa Strategies.StrategyRegistry + Test.@test length(registry.families) == 0 + end + + Test.@testset "create_registry - single family" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA,) + ) + Test.@test length(registry.families) == 1 + Test.@test length(registry.families[AbstractTestFamily]) == 1 + end + + Test.@testset "create_registry - validation: duplicate IDs" begin + # Create a duplicate ID by reusing TestStrategyA + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + AbstractTestFamily => (TestStrategyA, TestStrategyA) + ) + end + + Test.@testset "create_registry - validation: wrong type hierarchy" begin + # WrongTypeStrategy is not a subtype of AbstractTestFamily + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + AbstractTestFamily => (TestStrategyA, WrongTypeStrategy) + ) + end + + Test.@testset "create_registry - validation: duplicate family" begin + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + AbstractTestFamily => (TestStrategyA,), + AbstractTestFamily => (TestStrategyB,), + ) + end + + Test.@testset "strategy_ids - basic lookup" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA, TestStrategyB), + AbstractOtherFamily => (TestStrategyC,), + ) + + ids = Strategies.strategy_ids(AbstractTestFamily, registry) + Test.@test ids isa Tuple + Test.@test length(ids) == 2 + Test.@test :strategy_a in ids + Test.@test :strategy_b in ids + + other_ids = Strategies.strategy_ids(AbstractOtherFamily, registry) + Test.@test length(other_ids) == 1 + Test.@test :strategy_c in other_ids + end + + Test.@testset "strategy_ids - empty family" begin + registry = Strategies.create_registry(AbstractTestFamily => ()) + ids = Strategies.strategy_ids(AbstractTestFamily, registry) + Test.@test ids isa Tuple + Test.@test length(ids) == 0 + end + + Test.@testset "strategy_ids - unknown family" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA,) + ) + Test.@test_throws Exceptions.IncorrectArgument Strategies.strategy_ids( + AbstractOtherFamily, registry + ) + end + + Test.@testset "type_from_id - basic lookup" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA, TestStrategyB) + ) + + T = Strategies.type_from_id(:strategy_a, AbstractTestFamily, registry) + Test.@test T === TestStrategyA + + T2 = Strategies.type_from_id(:strategy_b, AbstractTestFamily, registry) + Test.@test T2 === TestStrategyB + end + + Test.@testset "type_from_id - unknown ID" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA,) + ) + Test.@test_throws Exceptions.IncorrectArgument Strategies.type_from_id( + :nonexistent, AbstractTestFamily, registry + ) + end + + Test.@testset "type_from_id - unknown family" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA,) + ) + Test.@test_throws Exceptions.IncorrectArgument Strategies.type_from_id( + :strategy_a, AbstractOtherFamily, registry + ) + end + + Test.@testset "Display - show(io, registry)" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA, TestStrategyB) + ) + io = IOBuffer() + show(io, registry) + output = String(take!(io)) + Test.@test occursin("StrategyRegistry", output) + Test.@test occursin("families", output) || occursin("family", output) + end + + Test.@testset "Display - show(io, MIME, registry)" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA, TestStrategyB), + AbstractOtherFamily => (TestStrategyC,), + ) + io = IOBuffer() + show(io, MIME("text/plain"), registry) + output = String(take!(io)) + Test.@test occursin("StrategyRegistry", output) + Test.@test occursin("AbstractTestFamily", output) + Test.@test occursin("AbstractOtherFamily", output) + end + end + + # ======================================================================== + # INTEGRATION TESTS + # ======================================================================== + + Test.@testset "Integration Tests" begin + Test.@testset "Registry with multiple families" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA, TestStrategyB), + AbstractOtherFamily => (TestStrategyC,), + ) + + # Lookup across families + T1 = Strategies.type_from_id(:strategy_a, AbstractTestFamily, registry) + T2 = Strategies.type_from_id(:strategy_c, AbstractOtherFamily, registry) + + Test.@test T1 === TestStrategyA + Test.@test T2 === TestStrategyC + Test.@test T1 !== T2 + + # IDs are scoped to families + ids1 = Strategies.strategy_ids(AbstractTestFamily, registry) + ids2 = Strategies.strategy_ids(AbstractOtherFamily, registry) + Test.@test length(ids1) == 2 + Test.@test length(ids2) == 1 + end + + Test.@testset "Round-trip: type -> id -> type" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA, TestStrategyB) + ) + + original_type = TestStrategyA + strategy_id = Strategies.id(original_type) + retrieved_type = Strategies.type_from_id( + strategy_id, AbstractTestFamily, registry + ) + + Test.@test retrieved_type === original_type + end + + Test.@testset "Registry immutability" begin + registry = Strategies.create_registry( + AbstractTestFamily => (TestStrategyA,) + ) + + # Registry should be immutable - cannot add families after creation + Test.@test !ismutable(registry) + end + end + end +end + +end # module + +test_registry() = TestStrategiesRegistry.test_registry() diff --git a/test/suite/strategies/test_registry_parameters.jl b/test/suite/strategies/test_registry_parameters.jl new file mode 100644 index 00000000..25b90496 --- /dev/null +++ b/test/suite/strategies/test_registry_parameters.jl @@ -0,0 +1,223 @@ +module TestRegistryParameters + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# TOP-LEVEL: Define all structs here +abstract type FakeFamily <: Strategies.AbstractStrategy end +struct FakeStratA <: FakeFamily end +struct FakeStratB{P<:Strategies.AbstractStrategyParameter} <: FakeFamily end + +# Implement contracts +Strategies.id(::Type{<:FakeStratA}) = :fakestrata +Strategies.id(::Type{<:FakeStratB}) = :fakestratb + +# Fake parameter for testing +struct FakeParam <: Strategies.AbstractStrategyParameter end +Strategies.id(::Type{FakeParam}) = :fakeparam + +# Additional test structs (must be at top level) +struct FakeStratWithIdCpu <: FakeFamily end +Strategies.id(::Type{<:FakeStratWithIdCpu}) = :cpu + +struct FakeParam2 <: Strategies.AbstractStrategyParameter end +Strategies.id(::Type{FakeParam2}) = :fakeparam + +struct FakeStratC <: FakeFamily end +Strategies.id(::Type{<:FakeStratC}) = :fakestrata # Same as FakeStratA + +function test_registry_parameters() + Test.@testset "Registry with Parameters" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - create_registry with parameterized strategies + # ==================================================================== + + Test.@testset "create_registry parameterized" begin + r = Strategies.create_registry( + FakeFamily => (FakeStratA, (FakeStratB, [Strategies.CPU, Strategies.GPU])) + ) + + # Check that the registry contains the correct types + types = r.families[FakeFamily] + Test.@test FakeStratA in types + Test.@test FakeStratB{Strategies.CPU} in types + Test.@test FakeStratB{Strategies.GPU} in types + Test.@test length(types) == 3 + end + + Test.@testset "create_registry with multiple parameterized strategies" begin + r = Strategies.create_registry( + FakeFamily => (FakeStratA, (FakeStratB, [Strategies.CPU, Strategies.GPU])) + ) + + types = r.families[FakeFamily] + Test.@test FakeStratA in types + Test.@test FakeStratB{Strategies.CPU} in types + Test.@test FakeStratB{Strategies.GPU} in types + Test.@test length(types) == 3 + end + + Test.@testset "create_registry validation - invalid strategy type" begin + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + FakeFamily => ((String, [Strategies.CPU]),), # String is not a strategy + ) + end + + Test.@testset "create_registry validation - invalid parameter type" begin + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + FakeFamily => ((FakeStratB, [String]),), # String is not a parameter + ) + end + + Test.@testset "create_registry validation - invalid parameter format" begin + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + FakeFamily => ((FakeStratB, "not a tuple"),), # Not a tuple/vector + ) + end + + # ==================================================================== + # UNIT TESTS - Global ID uniqueness + # ==================================================================== + + Test.@testset "Global ID uniqueness - strategy vs parameter" begin + # :cpu cannot be both a strategy ID and a parameter ID + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + FakeFamily => (FakeStratWithIdCpu, (FakeStratB, [Strategies.CPU])) + ) + end + + Test.@testset "Global ID uniqueness - parameter vs parameter" begin + # :fakeparam cannot be used by two different parameter types + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + FakeFamily => ((FakeStratB, [FakeParam, FakeParam2]),) + ) + end + + Test.@testset "Global ID uniqueness - strategy vs strategy" begin + # Same ID for two different strategies should fail + Test.@test_throws Exceptions.IncorrectArgument Strategies.create_registry( + FakeFamily => (FakeStratA, FakeStratC) + ) + end + + # ==================================================================== + # UNIT TESTS - strategy_ids deduplication + # ==================================================================== + + Test.@testset "strategy_ids deduplication" begin + r = Strategies.create_registry( + FakeFamily => (FakeStratA, (FakeStratB, [Strategies.CPU, Strategies.GPU])) + ) + ids = Strategies.strategy_ids(FakeFamily, r) + Test.@test length(ids) == length(unique(ids)) # No duplicates + Test.@test :fakestrata in ids + Test.@test :fakestratb in ids + Test.@test length(ids) == 2 # Only 2 unique IDs despite 3 types + end + + Test.@testset "strategy_ids with only parameterized strategies" begin + r = Strategies.create_registry( + FakeFamily => ((FakeStratB, [Strategies.CPU, Strategies.GPU]),) + ) + ids = Strategies.strategy_ids(FakeFamily, r) + Test.@test length(ids) == 1 # :fakestratb + Test.@test :fakestratb in ids + end + + # ==================================================================== + # UNIT TESTS - get_parameter_type + # ==================================================================== + + Test.@testset "get_parameter_type" begin + Test.@test Strategies.get_parameter_type(FakeStratB{Strategies.CPU}) == + Strategies.CPU + Test.@test Strategies.get_parameter_type(FakeStratB{Strategies.GPU}) == + Strategies.GPU + Test.@test Strategies.get_parameter_type(FakeStratA) === nothing + end + + # Test.@testset "get_parameter_type type stability" begin + # Test.@test_nowarn Test.@inferred Strategies.get_parameter_type(FakeStratB{Strategies.CPU}) + # Test.@test_nowarn Test.@inferred Strategies.get_parameter_type(FakeStratA) + # end + + # ==================================================================== + # UNIT TESTS - type_from_id with parameter + # ==================================================================== + + Test.@testset "type_from_id with parameter" begin + r = Strategies.create_registry( + FakeFamily => ((FakeStratB, [Strategies.CPU, Strategies.GPU]),) + ) + + T_cpu = Strategies.type_from_id( + :fakestratb, FakeFamily, r; parameter=Strategies.CPU + ) + T_gpu = Strategies.type_from_id( + :fakestratb, FakeFamily, r; parameter=Strategies.GPU + ) + + Test.@test T_cpu == FakeStratB{Strategies.CPU} + Test.@test T_gpu == FakeStratB{Strategies.GPU} + end + + Test.@testset "type_from_id without parameter" begin + r = Strategies.create_registry( + FakeFamily => ((FakeStratB, [Strategies.CPU, Strategies.GPU]),) + ) + + # Should return the first match (implementation-dependent but should work) + T = Strategies.type_from_id(:fakestratb, FakeFamily, r) + Test.@test T in (FakeStratB{Strategies.CPU}, FakeStratB{Strategies.GPU}) + end + + Test.@testset "type_from_id parameter not found" begin + r = Strategies.create_registry( + FakeFamily => ((FakeStratB, [Strategies.CPU]),), # Only CPU + ) + + Test.@test_throws Exceptions.IncorrectArgument Strategies.type_from_id( + :fakestratb, FakeFamily, r; parameter=Strategies.GPU + ) + end + + Test.@testset "type_from_id strategy not found" begin + r = Strategies.create_registry(FakeFamily => (FakeStratA,)) + + Test.@test_throws Exceptions.IncorrectArgument Strategies.type_from_id( + :nonexistent, FakeFamily, r + ) + end + + # ==================================================================== + # INTEGRATION TESTS + # ==================================================================== + + Test.@testset "Registry display with parameterized strategies" begin + r = Strategies.create_registry( + FakeFamily => (FakeStratA, (FakeStratB, [Strategies.CPU, Strategies.GPU])) + ) + + # Test that display works without errors + io = IOBuffer() + show(io, r) + output = String(take!(io)) + Test.@test occursin("StrategyRegistry", output) + + io = IOBuffer() + show(io, MIME"text/plain"(), r) + output = String(take!(io)) + Test.@test occursin("FakeFamily", output) + end + end +end + +end # module + +# Redefine in outer scope for TestRunner +test_registry_parameters() = TestRegistryParameters.test_registry_parameters() diff --git a/test/suite/strategies/test_strategies.jl b/test/suite/strategies/test_strategies.jl new file mode 100644 index 00000000..08697e09 --- /dev/null +++ b/test/suite/strategies/test_strategies.jl @@ -0,0 +1,208 @@ +module TestStrategies + +using Test: Test +import CTBase.Exceptions +using CTBase: CTBase +import CTBase.Strategies +import CTBase.Options +using CTBase.Strategies # For testing exported symbols + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true +const CurrentModule = TestStrategies + +""" + test_strategies() + +Tests for Strategies module exports. + +This function tests the complete Strategies module exports including: +- Abstract types (AbstractStrategy, StrategyRegistry, StrategyMetadata, etc.) +- Parameter types (AbstractStrategyParameter, CPU, GPU) +- Utility types (RoutedOption, BypassValue, OptionDefinition) +- Contract functions (id, metadata, options) +- Registry functions (create_registry, strategy_ids, etc.) +- Introspection functions (option_names, option_type, etc.) +- Builder functions (build_strategy, extract_id_from_method, etc.) +- Configuration functions (build_strategy_options, resolve_alias) +- Utility functions (filter_options, suggest_options, etc.) +""" +function test_strategies() + Test.@testset "Strategies Module" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # META TESTS - Exports / Public API surface + # ==================================================================== + + Test.@testset "Exports verification" begin + # Test that Strategies module is available + Test.@testset "Strategies Module" begin + Test.@test isdefined(CTBase, :Strategies) + Test.@test CTBase.Strategies isa Module + end + + # Test exported abstract types + Test.@testset "Exported Abstract Types" begin + for T in (AbstractStrategy, StrategyRegistry, AbstractStrategyParameter) + Test.@testset "$(nameof(T))" begin + Test.@test isdefined(Strategies, nameof(T)) + Test.@test isdefined(CurrentModule, nameof(T)) + Test.@test T isa DataType || T isa UnionAll + end + end + end + + # Test exported concrete types + Test.@testset "Exported Concrete Types" begin + for T in ( + StrategyMetadata, + StrategyOptions, + OptionDefinition, + RoutedOption, + BypassValue, + CPU, + GPU, + ) + Test.@testset "$(nameof(T))" begin + Test.@test isdefined(Strategies, nameof(T)) + Test.@test isdefined(CurrentModule, nameof(T)) + Test.@test T isa DataType || T isa UnionAll + end + end + end + + # Test exported contract functions + Test.@testset "Exported Contract Functions" begin + for f in (:id, :metadata, :options) + Test.@testset "$f" begin + Test.@test isdefined(Strategies, f) + Test.@test isdefined(CurrentModule, f) + Test.@test getfield(CurrentModule, f) isa Function + end + end + end + + # Test exported registry functions + Test.@testset "Exported Registry Functions" begin + for f in + (:create_registry, :strategy_ids, :type_from_id, :get_parameter_type) + Test.@testset "$f" begin + Test.@test isdefined(Strategies, f) + Test.@test isdefined(CurrentModule, f) + Test.@test getfield(CurrentModule, f) isa Function + end + end + end + + # Test exported introspection functions + Test.@testset "Exported Introspection Functions" begin + for f in ( + :option_names, + :option_type, + :option_description, + :option_default, + :option_defaults, + :option_is_user, + :option_is_default, + :option_is_computed, + :option_value, + :option_source, + :has_option, + ) + Test.@testset "$f" begin + Test.@test isdefined(Strategies, f) + Test.@test isdefined(CurrentModule, f) + Test.@test getfield(CurrentModule, f) isa Function + end + end + end + + # Test exported builder functions + Test.@testset "Exported Builder Functions" begin + for f in (:build_strategy, :extract_id_from_method, :available_parameters) + Test.@testset "$f" begin + Test.@test isdefined(Strategies, f) + Test.@test isdefined(CurrentModule, f) + Test.@test getfield(CurrentModule, f) isa Function + end + end + end + + # Test exported configuration functions + Test.@testset "Exported Configuration Functions" begin + for f in (:build_strategy_options, :resolve_alias) + Test.@testset "$f" begin + Test.@test isdefined(Strategies, f) + Test.@test isdefined(CurrentModule, f) + Test.@test getfield(CurrentModule, f) isa Function + end + end + end + + # Test exported utility functions + Test.@testset "Exported Utility Functions" begin + for f in ( + :describe, + :filter_options, + :suggest_options, + :format_suggestion, + :options_dict, + :route_to, + :bypass, + :force, + ) + Test.@testset "$f" begin + Test.@test isdefined(Strategies, f) + Test.@test isdefined(CurrentModule, f) + Test.@test getfield(CurrentModule, f) isa Function + end + end + end + + # Test that internal symbols are NOT exported + Test.@testset "Internal Functions (not exported)" begin + for f in ( + :_strategy_id_set, # Internal helper function + :_error_unknown_options_strict, # Internal error functions + :_warn_unknown_options_permissive, + :_default_parameter, # Internal contract function + :validate_parameter_type, # Internal validation function + ) + Test.@testset "$f" begin + Test.@test isdefined(Strategies, f) + Test.@test !isdefined(CurrentModule, f) + end + end + end + end + + # ==================================================================== + # UNIT TESTS - Type hierarchy and interface compliance + # ==================================================================== + + Test.@testset "Type hierarchy" begin + Test.@testset "Abstract types" begin + Test.@test Strategies.AbstractStrategy <: Any + Test.@test Strategies.StrategyRegistry <: Any + Test.@test Strategies.AbstractStrategyParameter <: Any + end + + Test.@testset "Parameter types" begin + Test.@test Strategies.CPU <: Strategies.AbstractStrategyParameter + Test.@test Strategies.GPU <: Strategies.AbstractStrategyParameter + end + end + + Test.@testset "Display and introspection" begin + Test.@testset "describe function" begin + # Test that describe function exists and is callable + Test.@test isdefined(Strategies, :describe) + Test.@test getfield(Strategies, :describe) isa Function + end + end + end +end + +end # module + +test_strategies() = TestStrategies.test_strategies() diff --git a/test/suite/strategies/test_strategy_options.jl b/test/suite/strategies/test_strategy_options.jl new file mode 100644 index 00000000..6ef2aedc --- /dev/null +++ b/test/suite/strategies/test_strategy_options.jl @@ -0,0 +1,334 @@ +module TestStrategiesStrategyOptions + +using Test: Test +import CTBase.Exceptions +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Test function +# ============================================================================ + +""" + test_strategy_options() + +Tests for strategy-specific options handling. +""" +function test_strategy_options() + Test.@testset "Strategy Options" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ======================================================================== + # UNIT TESTS + # ======================================================================== + + Test.@testset "Unit Tests" begin + Test.@testset "Construction" begin + # Valid construction with keyword arguments + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-6, :default), + ) + + Test.@test opts isa Strategies.StrategyOptions + Test.@test length(opts) == 2 + end + + Test.@testset "Validation - OptionValue required" begin + # Should error if not OptionValue + Test.@test_throws Exceptions.IncorrectArgument Strategies.StrategyOptions( + max_iter=200, # Not an OptionValue + ) + end + + Test.@testset "Validation - valid sources" begin + # Valid sources are validated by OptionValue constructor + for source in (:user, :default, :computed) + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, source) + ) + Test.@test Strategies.source(opts, :max_iter) == source + end + + # Invalid source throws in OptionValue constructor + Test.@test_throws Exceptions.IncorrectArgument Options.OptionValue( + 200, :invalid + ) + end + + Test.@testset "Value access" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + display=Options.OptionValue(true, :computed), + ) + + # Test getindex - returns unwrapped value + Test.@test opts[:max_iter] == 200 + Test.@test opts[:tol] == 1e-8 + Test.@test opts[:display] == true + end + + Test.@testset "OptionValue access" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + ) + + # Test getproperty - returns full OptionValue + Test.@test opts.max_iter isa Options.OptionValue + Test.@test opts.max_iter.value == 200 + Test.@test opts.max_iter.source == :user + + Test.@test opts.tol.value == 1e-8 + Test.@test opts.tol.source == :default + end + + Test.@testset "Source access helpers" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + step=Options.OptionValue(0.01, :computed), + ) + + # Test source() helper + Test.@test Strategies.source(opts, :max_iter) == :user + Test.@test Strategies.source(opts, :tol) == :default + Test.@test Strategies.source(opts, :step) == :computed + + # Test Options-level helpers on StrategyOptions + Test.@test Options.value(opts, :max_iter) == 200 + Test.@test Options.value(opts, :tol) == 1e-8 + Test.@test Options.value(opts, :step) == 0.01 + Test.@test Options.source(opts, :max_iter) == :user + Test.@test Options.source(opts, :tol) == :default + Test.@test Options.source(opts, :step) == :computed + + # Test is_user() helper + Test.@test Strategies.is_user(opts, :max_iter) == true + Test.@test Strategies.is_user(opts, :tol) == false + Test.@test Options.is_user(opts, :max_iter) == true + Test.@test Options.is_user(opts, :tol) == false + + # Test is_default() helper + Test.@test Strategies.is_default(opts, :tol) == true + Test.@test Strategies.is_default(opts, :max_iter) == false + Test.@test Options.is_default(opts, :tol) == true + Test.@test Options.is_default(opts, :max_iter) == false + + # Test is_computed() helper + Test.@test Strategies.is_computed(opts, :step) == true + Test.@test Strategies.is_computed(opts, :tol) == false + Test.@test Options.is_computed(opts, :step) == true + Test.@test Options.is_computed(opts, :tol) == false + end + + Test.@testset "Alias resolution" begin + # Create StrategyOptions with alias_map + alias_map = Dict{Symbol,Symbol}( + :maxiter => :max_iter, :max_iterations => :max_iter, :tolerance => :tol + ) + opts = Strategies.StrategyOptions( + ( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + ), + alias_map, + ) + + # Test getindex with canonical name + Test.@test opts[:max_iter] == 200 + Test.@test opts[:tol] == 1e-8 + + # Test getindex with aliases + Test.@test opts[:maxiter] == 200 + Test.@test opts[:max_iterations] == 200 + Test.@test opts[:tolerance] == 1e-8 + + # Test haskey with canonical name + Test.@test haskey(opts, :max_iter) + Test.@test haskey(opts, :tol) + + # Test haskey with aliases + Test.@test haskey(opts, :maxiter) + Test.@test haskey(opts, :max_iterations) + Test.@test haskey(opts, :tolerance) + + # Test haskey with non-existent key + Test.@test !haskey(opts, :nonexistent) + + # Test that getproperty does NOT resolve aliases (only canonical names) + Test.@test opts.max_iter isa Options.OptionValue + Test.@test opts.max_iter.value == 200 + # Dot notation doesn't resolve aliases - accessing via alias throws + Test.@test_throws Exception opts.maxiter + + # Test source access with aliases + Test.@test Options.source(opts, :max_iter) == :user + Test.@test Options.source(opts, :maxiter) == :user # Via alias + Test.@test Options.source(opts, :max_iterations) == :user # Via alias + + # Test value access with aliases + Test.@test Options.value(opts, :max_iter) == 200 + Test.@test Options.value(opts, :maxiter) == 200 # Via alias + Test.@test Options.value(opts, :max_iterations) == 200 # Via alias + end + + Test.@testset "Collection interface" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + display=Options.OptionValue(true, :computed), + ) + + # Test keys (only canonical names, not aliases) + Test.@test collect(keys(opts)) == [:max_iter, :tol, :display] + + # Test values (unwrapped) + Test.@test collect(values(opts)) == [200, 1e-8, true] + + # Test pairs (unwrapped values) + pairs_collected = collect(pairs(opts)) + Test.@test length(pairs_collected) == 3 + Test.@test pairs_collected[1] == (:max_iter => 200) + Test.@test pairs_collected[2] == (:tol => 1e-8) + Test.@test pairs_collected[3] == (:display => true) + + # Test iteration (unwrapped values) + iterated_values = [] + for value in opts + push!(iterated_values, value) + end + Test.@test iterated_values == [200, 1e-8, true] + + # Test length, isempty, haskey + Test.@test length(opts) == 3 + Test.@test !isempty(opts) + Test.@test haskey(opts, :max_iter) + Test.@test !haskey(opts, :nonexistent) + end + + Test.@testset "Edge cases" begin + # Empty options + opts = Strategies.StrategyOptions() + Test.@test length(opts) == 0 + Test.@test isempty(opts) + Test.@test collect(keys(opts)) == [] + + # Single option + opts = Strategies.StrategyOptions( + only_option=Options.OptionValue(42, :user) + ) + Test.@test opts[:only_option] == 42 + Test.@test Strategies.source(opts, :only_option) == :user + end + end + + # ======================================================================== + # INTEGRATION TESTS + # ======================================================================== + + Test.@testset "Integration Tests" begin + Test.@testset "Display functionality" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + computed_val=Options.OptionValue(3.14, :computed), + ) + + # Test MIME display + io = IOBuffer() + show(io, MIME"text/plain"(), opts) + output = String(take!(io)) + + # Test individual components without relying on exact color formatting + Test.@test occursin("StrategyOptions", output) + Test.@test occursin("3", output) + Test.@test occursin("options", output) + Test.@test occursin("max_iter", output) + Test.@test occursin("200", output) + Test.@test occursin("user", output) + Test.@test occursin("tol", output) + Test.@test occursin("1.0e-8", output) + Test.@test occursin("default", output) + Test.@test occursin("computed_val", output) + Test.@test occursin("3.14", output) + Test.@test occursin("computed", output) + end + + Test.@testset "Integration with OptionDefinition" begin + # Create OptionDefinition + opt_def = Options.OptionDefinition( + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:max, :maxiter), + ) + + # Create StrategyOptions from user input + opts = Strategies.StrategyOptions(max_iter=Options.OptionValue(200, :user)) + + # Test integration + Test.@test opts[:max_iter] == 200 + Test.@test typeof(opts[:max_iter]) == Int # Type matches OptionDefinition + + # Test that we can access the source + Test.@test Strategies.source(opts, :max_iter) == :user + end + + Test.@testset "Complex option scenarios" begin + # Strategy with mixed sources + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + backend=Options.OptionValue(:sparse, :user), + verbose=Options.OptionValue(false, :default), + computed_step=Options.OptionValue(0.01, :computed), + ) + + # Test all functionality works with complex scenario + Test.@test length(opts) == 5 + Test.@test opts[:max_iter] == 200 + Test.@test opts[:backend] == :sparse + Test.@test Strategies.source(opts, :computed_step) == :computed + + # Test display with complex scenario + io = IOBuffer() + show(io, MIME"text/plain"(), opts) + output = String(take!(io)) + + # Test individual components without relying on exact color formatting + Test.@test occursin("max_iter", output) + Test.@test occursin("200", output) + Test.@test occursin("user", output) + Test.@test occursin("tol", output) + Test.@test occursin("1.0e-8", output) + Test.@test occursin("default", output) + Test.@test occursin("backend", output) + Test.@test occursin("sparse", output) + Test.@test occursin("computed_step", output) + Test.@test occursin("0.01", output) + Test.@test occursin("computed", output) + end + + Test.@testset "Performance and type stability" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(200, :user), + tol=Options.OptionValue(1e-8, :default), + ) + + # Test basic functionality works + Test.@test opts[:max_iter] == 200 + Test.@test length(opts) == 2 + Test.@test length(collect(values(opts))) == 2 + end + end + end +end + +end # module + +test_strategy_options() = TestStrategiesStrategyOptions.test_strategy_options() diff --git a/test/suite/strategies/test_utilities.jl b/test/suite/strategies/test_utilities.jl new file mode 100644 index 00000000..b0b5493e --- /dev/null +++ b/test/suite/strategies/test_utilities.jl @@ -0,0 +1,456 @@ +module TestStrategiesUtilities + +using Test: Test +import CTBase.Strategies +import CTBase.Options + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Test strategy for suggestions +# ============================================================================ + +abstract type AbstractTestUtilStrategy <: Strategies.AbstractStrategy end + +struct TestUtilStrategy <: AbstractTestUtilStrategy + options::Strategies.StrategyOptions +end + +Strategies.id(::Type{TestUtilStrategy}) = :test_util + +function Strategies.metadata(::Type{TestUtilStrategy}) + Strategies.StrategyMetadata( + Options.OptionDefinition(; + name=:max_iter, + type=Int, + default=100, + description="Maximum iterations", + aliases=(:max, :maxiter), + ), + Options.OptionDefinition(; + name=:tolerance, + type=Float64, + default=1e-6, + description="Convergence tolerance", + aliases=(:tol,), + ), + Options.OptionDefinition(; + name=:verbose, type=Bool, default=false, description="Verbose output" + ), + ) +end + +Strategies.options(s::TestUtilStrategy) = s.options + +# ============================================================================ +# Test function +# ============================================================================ + +""" + test_utilities() + +Tests for strategy utilities. +""" +function test_utilities() + Test.@testset "Strategy Utilities" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # filter_options - Single key + # ==================================================================== + + Test.@testset "filter_options - single key" begin + opts = (max_iter=100, tolerance=1e-6, verbose=true, debug=false) + + # Filter single key + filtered = Strategies.filter_options(opts, :debug) + Test.@test filtered == (max_iter=100, tolerance=1e-6, verbose=true) + Test.@test !haskey(filtered, :debug) + Test.@test haskey(filtered, :max_iter) + Test.@test haskey(filtered, :tolerance) + Test.@test haskey(filtered, :verbose) + + # Filter another key + filtered2 = Strategies.filter_options(opts, :verbose) + Test.@test filtered2 == (max_iter=100, tolerance=1e-6, debug=false) + Test.@test !haskey(filtered2, :verbose) + + # Filter non-existent key (should not error) + filtered3 = Strategies.filter_options(opts, :nonexistent) + Test.@test filtered3 == opts + Test.@test length(filtered3) == 4 + end + + # ==================================================================== + # filter_options - Multiple keys + # ==================================================================== + + Test.@testset "filter_options - multiple keys" begin + opts = (max_iter=100, tolerance=1e-6, verbose=true, debug=false) + + # Filter two keys + filtered1 = Strategies.filter_options(opts, (:debug, :verbose)) + Test.@test filtered1 == (max_iter=100, tolerance=1e-6) + Test.@test !haskey(filtered1, :debug) + Test.@test !haskey(filtered1, :verbose) + Test.@test length(filtered1) == 2 + + # Filter three keys + filtered2 = Strategies.filter_options(opts, (:debug, :verbose, :tolerance)) + Test.@test filtered2 == (max_iter=100,) + Test.@test length(filtered2) == 1 + + # Filter all keys + filtered3 = Strategies.filter_options( + opts, (:max_iter, :tolerance, :verbose, :debug) + ) + Test.@test filtered3 == NamedTuple() + Test.@test length(filtered3) == 0 + Test.@test isempty(filtered3) + + # Filter with some non-existent keys + filtered4 = Strategies.filter_options(opts, (:debug, :nonexistent)) + Test.@test filtered4 == (max_iter=100, tolerance=1e-6, verbose=true) + end + + # ==================================================================== + # suggest_options + # ==================================================================== + + Test.@testset "suggest_options - structured results" begin + # Similar to existing option + suggestions1 = Strategies.suggest_options(:max_it, TestUtilStrategy) + Test.@test !isempty(suggestions1) + Test.@test suggestions1[1].primary == :max_iter + # Distance should be min over primary and all aliases + expected_dist1 = min( + Strategies.levenshtein_distance("max_it", "max_iter"), + Strategies.levenshtein_distance("max_it", "max"), + Strategies.levenshtein_distance("max_it", "maxiter"), + ) + Test.@test suggestions1[1].distance == expected_dist1 + Test.@test suggestions1[1].aliases == (:max, :maxiter) + + # Similar to alias - alias proximity should help + suggestions2 = Strategies.suggest_options(:tolrance, TestUtilStrategy) + Test.@test suggestions2[1].primary == :tolerance + Test.@test suggestions2[1].aliases == (:tol,) + # Distance should be min of dist to "tolerance" and dist to "tol" + expected_dist = min( + Strategies.levenshtein_distance("tolrance", "tolerance"), + Strategies.levenshtein_distance("tolrance", "tol"), + ) + Test.@test suggestions2[1].distance == expected_dist + + # Very different key + suggestions3 = Strategies.suggest_options(:xyz, TestUtilStrategy) + Test.@test length(suggestions3) <= 3 # Default max_suggestions + Test.@test !isempty(suggestions3) + + # Limit suggestions + suggestions4 = Strategies.suggest_options( + :x, TestUtilStrategy; max_suggestions=2 + ) + Test.@test length(suggestions4) <= 2 + + # Single suggestion + suggestions5 = Strategies.suggest_options( + :unknown, TestUtilStrategy; max_suggestions=1 + ) + Test.@test length(suggestions5) == 1 + Test.@test haskey(suggestions5[1], :primary) + Test.@test haskey(suggestions5[1], :aliases) + Test.@test haskey(suggestions5[1], :distance) + + # Exact match should be first suggestion with distance 0 + suggestions6 = Strategies.suggest_options(:max_iter, TestUtilStrategy) + Test.@test suggestions6[1].primary == :max_iter + Test.@test suggestions6[1].distance == 0 + + # Exact alias match should give distance 0 + suggestions7 = Strategies.suggest_options(:tol, TestUtilStrategy) + Test.@test suggestions7[1].primary == :tolerance + Test.@test suggestions7[1].distance == 0 + end + + # ==================================================================== + # suggest_options - alias proximity advantage + # ==================================================================== + + Test.@testset "suggest_options - alias proximity advantage" begin + # KEY TEST: keyword close to an alias but far from primary name + # :maxiter is an alias of :max_iter + # :maxite is close to :maxiter (distance 1) but farther from :max_iter (distance 2) + suggestions = Strategies.suggest_options(:maxite, TestUtilStrategy) + Test.@test suggestions[1].primary == :max_iter + # Without alias awareness, distance would be levenshtein("maxite", "max_iter") = 3 + # With alias awareness, distance is min(3, levenshtein("maxite", "maxiter")) = min(3, 1) = 1 + dist_to_primary = Strategies.levenshtein_distance("maxite", "max_iter") + dist_to_alias = Strategies.levenshtein_distance("maxite", "maxiter") + Test.@test dist_to_alias < dist_to_primary # Alias is closer + Test.@test suggestions[1].distance == dist_to_alias # Uses alias distance + + # :to is close to :tol (distance 1) but far from :tolerance (distance 7) + suggestions2 = Strategies.suggest_options(:to, TestUtilStrategy) + # :tol alias should bring :tolerance closer + dist_to_primary2 = Strategies.levenshtein_distance("to", "tolerance") + dist_to_alias2 = Strategies.levenshtein_distance("to", "tol") + Test.@test dist_to_alias2 < dist_to_primary2 + # Find the tolerance entry + tol_entry = nothing + for s in suggestions2 + if s.primary == :tolerance + tol_entry = s + break + end + end + Test.@test tol_entry !== nothing + Test.@test tol_entry.distance == dist_to_alias2 + end + + # ==================================================================== + # format_suggestion + # ==================================================================== + + Test.@testset "format_suggestion" begin + # Without aliases + s1 = (primary=:verbose, aliases=(), distance=2) + formatted1 = Strategies.format_suggestion(s1) + Test.@test occursin(":verbose", formatted1) + Test.@test occursin("[distance: 2]", formatted1) + Test.@test !occursin("alias", formatted1) + + # With single alias + s2 = (primary=:backend, aliases=(:adnlp_backend,), distance=1) + formatted2 = Strategies.format_suggestion(s2) + Test.@test occursin(":backend", formatted2) + Test.@test occursin("adnlp_backend", formatted2) + Test.@test occursin("alias:", formatted2) + Test.@test occursin("[distance: 1]", formatted2) + + # With multiple aliases + s3 = (primary=:max_iter, aliases=(:max, :maxiter), distance=0) + formatted3 = Strategies.format_suggestion(s3) + Test.@test occursin(":max_iter", formatted3) + Test.@test occursin("max", formatted3) + Test.@test occursin("maxiter", formatted3) + Test.@test occursin("aliases:", formatted3) + Test.@test occursin("[distance: 0]", formatted3) + end + + # ==================================================================== + # levenshtein_distance + # ==================================================================== + + Test.@testset "levenshtein_distance" begin + # Identical strings + Test.@test Strategies.levenshtein_distance("test", "test") == 0 + Test.@test Strategies.levenshtein_distance("", "") == 0 + Test.@test Strategies.levenshtein_distance("hello", "hello") == 0 + + # Single character difference - substitution + Test.@test Strategies.levenshtein_distance("test", "best") == 1 + Test.@test Strategies.levenshtein_distance("test", "text") == 1 + Test.@test Strategies.levenshtein_distance("cat", "bat") == 1 + + # Single character difference - insertion + Test.@test Strategies.levenshtein_distance("test", "tests") == 1 + Test.@test Strategies.levenshtein_distance("cat", "cart") == 1 + + # Single character difference - deletion + Test.@test Strategies.levenshtein_distance("tests", "test") == 1 + Test.@test Strategies.levenshtein_distance("cart", "cat") == 1 + + # Multiple differences + Test.@test Strategies.levenshtein_distance("kitten", "sitting") == 3 + Test.@test Strategies.levenshtein_distance("saturday", "sunday") == 3 + + # Empty strings + Test.@test Strategies.levenshtein_distance("test", "") == 4 + Test.@test Strategies.levenshtein_distance("", "test") == 4 + Test.@test Strategies.levenshtein_distance("hello", "") == 5 + + # Relevant for option names + Test.@test Strategies.levenshtein_distance("max_iter", "max_it") == 2 + Test.@test Strategies.levenshtein_distance("tolerance", "tolrance") == 1 + Test.@test Strategies.levenshtein_distance("verbose", "verbos") == 1 + + # Symmetry property + Test.@test Strategies.levenshtein_distance("abc", "def") == + Strategies.levenshtein_distance("def", "abc") + Test.@test Strategies.levenshtein_distance("hello", "world") == + Strategies.levenshtein_distance("world", "hello") + end + + # ==================================================================== + # options_dict + # ==================================================================== + + Test.@testset "options_dict" begin + # Create a strategy with options + strategy = TestUtilStrategy( + Strategies.build_strategy_options( + TestUtilStrategy; max_iter=500, tolerance=1e-8, verbose=true + ), + ) + + # Extract options as Dict + options = Strategies.options_dict(strategy) + + # Verify it's a Dict + Test.@test options isa Dict{Symbol,Any} + + # Verify all options are present + Test.@test haskey(options, :max_iter) + Test.@test haskey(options, :tolerance) + Test.@test haskey(options, :verbose) + + # Verify values are correct (unwrapped from OptionValue) + Test.@test options[:max_iter] == 500 + Test.@test options[:tolerance] == 1e-8 + Test.@test options[:verbose] == true + + # Verify it's mutable (can modify) + options[:max_iter] = 1000 + Test.@test options[:max_iter] == 1000 + + # Verify can add new keys + options[:new_option] = :test + Test.@test options[:new_option] == :test + + # Verify can delete keys + delete!(options, :verbose) + Test.@test !haskey(options, :verbose) + Test.@test haskey(options, :max_iter) + Test.@test haskey(options, :tolerance) + end + + # ==================================================================== + # options_dict - StrategyOptions method + # ==================================================================== + + Test.@testset "options_dict - StrategyOptions method" begin + Test.@testset "Direct StrategyOptions conversion" begin + # Create StrategyOptions directly + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(500, :user), + tolerance=Options.OptionValue(1e-8, :user), + verbose=Options.OptionValue(true, :default), + ) + + # Convert to Dict + dict = Strategies.options_dict(opts) + + # Verify it's a Dict + Test.@test dict isa Dict{Symbol,Any} + + # Verify all options are present + Test.@test haskey(dict, :max_iter) + Test.@test haskey(dict, :tolerance) + Test.@test haskey(dict, :verbose) + + # Verify values are correct (unwrapped from OptionValue) + Test.@test dict[:max_iter] == 500 + Test.@test dict[:tolerance] == 1e-8 + Test.@test dict[:verbose] == true + + # Verify it's mutable + dict[:max_iter] = 1000 + Test.@test dict[:max_iter] == 1000 + end + + Test.@testset "Type Stability" begin + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(500, :user), + tolerance=Options.OptionValue(1e-8, :user), + ) + result = Test.@inferred Strategies.options_dict(opts) + Test.@test result isa Dict{Symbol,Any} + end + + Test.@testset "NotProvided filtering" begin + # Create StrategyOptions with NotProvided value + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(500, :user), + optional=Options.OptionValue(Options.NotProvided, :default), + ) + + # Convert to Dict + dict = Strategies.options_dict(opts) + + # Verify NotProvided is filtered out + Test.@test haskey(dict, :max_iter) + Test.@test !haskey(dict, :optional) + end + + Test.@testset "Nothing preservation" begin + # Create StrategyOptions with explicit nothing + opts = Strategies.StrategyOptions( + max_iter=Options.OptionValue(500, :user), + optional=Options.OptionValue(nothing, :default), + ) + + # Convert to Dict + dict = Strategies.options_dict(opts) + + # Verify nothing is preserved + Test.@test haskey(dict, :max_iter) + Test.@test haskey(dict, :optional) + Test.@test dict[:optional] === nothing + end + end + + # ==================================================================== + # Integration: Utilities pipeline + # ==================================================================== + + Test.@testset "Integration: Utilities pipeline" begin + # Create options and filter + opts = (max_iter=100, tolerance=1e-6, verbose=true, debug=false, extra=:value) + + # Filter debug options + filtered = Strategies.filter_options(opts, (:debug, :extra)) + Test.@test filtered == (max_iter=100, tolerance=1e-6, verbose=true) + + # Get suggestions for typo + suggestions = Strategies.suggest_options(:max_itr, TestUtilStrategy) + Test.@test suggestions[1].primary == :max_iter + + # Verify distance calculation + dist = Strategies.levenshtein_distance("max_itr", "max_iter") + Test.@test dist == 1 # One character difference + end + + # ==================================================================== + # Integration: options_dict workflow + # ==================================================================== + + Test.@testset "Integration: options_dict workflow" begin + # Create strategy + strategy = TestUtilStrategy( + Strategies.build_strategy_options( + TestUtilStrategy; max_iter=100, tolerance=1e-6 + ), + ) + + # Extract and modify options (typical solver extension pattern) + options = Strategies.options_dict(strategy) + options[:verbose] = true # Modify + options[:max_iter] = 200 # Override + + # Verify modifications + Test.@test options[:verbose] == true + Test.@test options[:max_iter] == 200 + Test.@test options[:tolerance] == 1e-6 + + # Original strategy options unchanged + orig_opts = Strategies.options(strategy) + Test.@test orig_opts[:max_iter] == 100 + Test.@test orig_opts[:verbose] == false + end + end +end + +end # module + +test_utilities() = TestStrategiesUtilities.test_utilities()