From 9864b65f76d4e2b1853f1cffb8a7ab5cae084b14 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 28 Jun 2026 14:50:03 +0200 Subject: [PATCH 1/2] Add ControlDependence trait family to CTBase.Traits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New strict opt-in family with tags ControlFree and WithControl for encoding control presence in OCPs at the type level, ready for dispatch in downstream packages - Factor shared contract helpers _throw_missing_trait and _throw_trait_not_implemented to eliminate 70% duplication across time_dependence, variable_dependence, and mutability families; behaviour and messages unchanged - Generalise _caller_function_name stacktrace filter to match any has__trait predicate (startswith + endswith pattern) instead of hard-coded names - Collapse has_variable duplicate to direct alias is_variable(obj) - Add 25 unit tests mirroring variable_dependence test structure; Traits suite: 301/301 ✓ - Document the two trait templates (strict opt-in vs default-valued capability) in module docstring; add control-dependence section to guide/traits.md with rationale for abstract-vs-concrete trait types (abstract used for type-param-only axes) - Update api_reference.jl to include control_dependence.jl in the auto-generated API page Follows the exact pattern of variable_dependence for consistency. No changes to existing predicates; _caller_function_name detection now names is_control_free through the new shared helper chain. Co-Authored-By: Claude Haiku 4.5 --- docs/api_reference.jl | 1 + docs/src/guide/traits.md | 64 ++++++-- src/Traits/Traits.jl | 43 +++-- src/Traits/control_dependence.jl | 158 +++++++++++++++++++ src/Traits/helpers.jl | 81 +++++++++- src/Traits/mutability.jl | 18 +-- src/Traits/time_dependence.jl | 18 +-- src/Traits/variable_dependence.jl | 23 +-- test/suite/traits/test_control_dependence.jl | 127 +++++++++++++++ test/suite/traits/test_traits_module.jl | 12 +- 10 files changed, 470 insertions(+), 75 deletions(-) create mode 100644 src/Traits/control_dependence.jl create mode 100644 test/suite/traits/test_control_dependence.jl diff --git a/docs/api_reference.jl b/docs/api_reference.jl index 50e57072..e6612d57 100644 --- a/docs/api_reference.jl +++ b/docs/api_reference.jl @@ -174,6 +174,7 @@ function generate_api_reference(src_dir::String) joinpath("Traits", "mutability.jl"), joinpath("Traits", "time_dependence.jl"), joinpath("Traits", "variable_dependence.jl"), + joinpath("Traits", "control_dependence.jl"), ), ), ( diff --git a/docs/src/guide/traits.md b/docs/src/guide/traits.md index cfa0e799..ded5cea0 100644 --- a/docs/src/guide/traits.md +++ b/docs/src/guide/traits.md @@ -56,6 +56,22 @@ Traits.Fixed <: Traits.VariableDependence Traits.NonFixed <: Traits.VariableDependence ``` +### Control dependence + +Does the optimal control problem carry a control input ``u``? + +| Type | Meaning | +|---|---| +| [`Traits.ControlFree`](@ref CTBase.Traits.ControlFree) | no control: ``ẋ = f(t, x, v)`` | +| [`Traits.WithControl`](@ref CTBase.Traits.WithControl) | control ``u``: ``ẋ = f(t, x, u, v)`` | + +Both are concrete subtypes of [`Traits.ControlDependence`](@ref CTBase.Traits.ControlDependence): + +```@repl traits +Traits.ControlFree <: Traits.ControlDependence +Traits.WithControl <: Traits.ControlDependence +``` + ### Mutability Does a function allocate a new output, or write into a pre-allocated buffer? @@ -78,15 +94,39 @@ below — they are passed directly as type arguments. | Automatic differentiation | [`Traits.WithAD`](@ref CTBase.Traits.WithAD), [`Traits.WithoutAD`](@ref CTBase.Traits.WithoutAD) | | Variable costate | [`Traits.SupportsVariableCostate`](@ref CTBase.Traits.SupportsVariableCostate), [`Traits.NoVariableCostate`](@ref CTBase.Traits.NoVariableCostate) | -## The trait contract - -A type opts in to a trait by implementing **two methods**: one declaring that it -*has* the trait, and one returning the trait *value*. The boolean predicates -([`Traits.is_autonomous`](@ref CTBase.Traits.is_autonomous), -[`Traits.is_variable`](@ref CTBase.Traits.is_variable), …) then follow -generically — they are not implemented per type. - -For time and variable dependence: +!!! note "Abstract tags vs. concrete tags" + Time-dependence tags (`Autonomous`, `NonAutonomous`) are **abstract** types + because they are only ever used as type-parameter *values* (e.g. `Model{Autonomous}`) + — never instantiated. All other tags (`Fixed`/`NonFixed`, `ControlFree`/`WithControl`, + `InPlace`/`OutOfPlace`, …) are **concrete** empty singletons. In practice every tag + is compared as a type (`control_dependence(obj) === Traits.ControlFree`), so the + two forms are used interchangeably; the distinction is historical, not semantic. + +## The trait contract — two templates + +Trait families follow one of **two contracts**, and the choice is dictated by a +single question: **does a safe default value exist?** + +- **Strict opt-in (no safe default).** Time-, variable-, and control-dependence, + and mutability. An object is *either* autonomous or not, *either* control-free or + not — guessing silently would be a correctness bug, so there is no default. A type + must opt in by implementing **two methods**: one declaring that it *has* the trait, + one returning the trait *value*. The boolean predicates + ([`Traits.is_autonomous`](@ref CTBase.Traits.is_autonomous), + [`Traits.is_control_free`](@ref CTBase.Traits.is_control_free), …) then follow + generically. If a type does not opt in, the predicates throw rather than guess. +- **Default-valued capability (safe default exists).** Automatic differentiation + and variable costate are *capabilities*: a conservative "no" is always safe. The + extractor returns a default for any object + ([`Traits.ad_trait`](@ref CTBase.Traits.ad_trait) ``→`` `WithoutAD`, + [`Traits.variable_costate_trait`](@ref CTBase.Traits.variable_costate_trait) ``→`` + `NoVariableCostate`) and concrete types override it — no `has_*` method, no + predicates. + +The "Other families" above (integration mode, dynamics) use neither contract: they +are read directly from a concrete type's parameters. + +For the strict opt-in families — time, variable and control dependence: ```@repl traits struct MyObject end @@ -97,11 +137,16 @@ Traits.time_dependence(::MyObject) = Traits.NonAutonomous Traits.has_variable_dependence_trait(::MyObject) = true Traits.variable_dependence(::MyObject) = Traits.Fixed +Traits.has_control_dependence_trait(::MyObject) = true +Traits.control_dependence(::MyObject) = Traits.WithControl + obj = MyObject() Traits.is_autonomous(obj) Traits.is_nonautonomous(obj) Traits.is_variable(obj) Traits.is_nonvariable(obj) +Traits.is_control_free(obj) +Traits.has_control(obj) ``` For mutability, the same pattern applies with `has_mutability_trait` and `mutability`: @@ -139,6 +184,7 @@ end # hide |---|---| | [`Traits.time_dependence`](@ref CTBase.Traits.time_dependence) | `is_autonomous`, `is_nonautonomous` | | [`Traits.variable_dependence`](@ref CTBase.Traits.variable_dependence) | `is_variable`, `is_nonvariable`, `has_variable` | +| [`Traits.control_dependence`](@ref CTBase.Traits.control_dependence) | `is_control_free`, `has_control` | | [`Traits.mutability`](@ref CTBase.Traits.mutability) | `is_inplace`, `is_outofplace` | | [`Traits.ad_trait`](@ref CTBase.Traits.ad_trait) | — | | [`Traits.variable_costate_trait`](@ref CTBase.Traits.variable_costate_trait) | — | diff --git a/src/Traits/Traits.jl b/src/Traits/Traits.jl index d8ac501f..92fc4537 100644 --- a/src/Traits/Traits.jl +++ b/src/Traits/Traits.jl @@ -12,6 +12,7 @@ no runtime cost. - **abstract.jl**: Root abstract type `AbstractTrait` and family abstractions - **time_dependence.jl**: Time-dependence traits and the opt-in contract - **variable_dependence.jl**: Variable-dependence traits and the opt-in contract +- **control_dependence.jl**: Control-dependence traits and the opt-in contract - **mutability.jl**: Mutability traits and the opt-in contract - **mode.jl**: Integration-mode traits (`EndPointMode`, `TrajectoryMode`) - **dynamics.jl**: Dynamics-type traits (`StateDynamics`, `HamiltonianDynamics`, `AugmentedHamiltonianDynamics`) @@ -25,23 +26,39 @@ no runtime cost. - **Time dependence**: `TimeDependence`, `Autonomous`, `NonAutonomous` - **Variable dependence**: `VariableDependence`, `Fixed`, `NonFixed` +- **Control dependence**: `ControlDependence`, `ControlFree`, `WithControl` - **Mutability**: `InPlace`, `OutOfPlace` - **Integration mode**: `EndPointMode`, `TrajectoryMode` - **Dynamics**: `StateDynamics`, `HamiltonianDynamics`, `AugmentedHamiltonianDynamics` - **Automatic differentiation**: `WithAD`, `WithoutAD` - **Variable costate**: `SupportsVariableCostate`, `NoVariableCostate` -## Trait contract - -For time-dependence, variable-dependence, and mutability, a type opts in by -implementing two methods — `has__trait` returning `true`, and an accessor -(`time_dependence`, `variable_dependence`, `mutability`) returning the trait type. -Boolean predicates (`is_autonomous`, `is_variable`, `is_inplace`, …) are then -derived generically. - -The remaining families (`EndPointMode`, `StateDynamics`, `WithAD`, -`SupportsVariableCostate`) are used as type parameters only and do not use the -two-method contract. +## Trait contracts — two templates + +Trait families follow one of two contracts. The choice is dictated by a single +question: **does a safe default value exist?** + +**1. Strict opt-in (no safe default).** Time-dependence, variable-dependence, +control-dependence, and mutability. Guessing a value silently would be a +correctness bug (an object is *either* autonomous or not), so there is no default: +a type must opt in by implementing two methods — `has__trait` returning +`true`, and an accessor (`time_dependence`, `variable_dependence`, +`control_dependence`, `mutability`) returning the trait type. The fallbacks throw +loudly ([`CTBase.Exceptions.IncorrectArgument`](@ref) / +[`CTBase.Exceptions.NotImplemented`](@ref)) via the shared helpers +`_throw_missing_trait` / `_throw_trait_not_implemented`. Boolean predicates +(`is_autonomous`, `is_variable`, `is_control_free`, `is_inplace`, …) are derived +generically. + +**2. Default-valued capability (safe default exists).** Automatic differentiation +(`ad_trait(::Any) = WithoutAD`) and variable-costate +(`variable_costate_trait(::Any) = NoVariableCostate`). These are *capabilities*: a +conservative "no" is always safe, so the extractor returns a default for any object +and concrete types override it. No `has__trait` guard, no derived predicates. + +The remaining families (`EndPointMode`, `StateDynamics`) are used as type +parameters only (read from a concrete type's parameters, e.g. +`AbstractSystem{TD,VD,D}`) and use neither contract. """ module Traits @@ -65,6 +82,7 @@ include(joinpath(@__DIR__, "variable_costate.jl")) include(joinpath(@__DIR__, "mutability.jl")) include(joinpath(@__DIR__, "time_dependence.jl")) include(joinpath(@__DIR__, "variable_dependence.jl")) +include(joinpath(@__DIR__, "control_dependence.jl")) # ============================================================================== # Module exports @@ -82,10 +100,13 @@ export InPlace, OutOfPlace export WithAD, WithoutAD export SupportsVariableCostate, NoVariableCostate export VariableDependence, Fixed, NonFixed +export ControlDependence, ControlFree, WithControl export ad_trait, variable_costate_trait, dynamics_trait export is_inplace, is_outofplace export is_autonomous, is_nonautonomous, is_variable, is_nonvariable, has_variable +export is_control_free, has_control export has_time_dependence_trait, time_dependence, has_mutability_trait, mutability export has_variable_dependence_trait, variable_dependence +export has_control_dependence_trait, control_dependence end # module Traits diff --git a/src/Traits/control_dependence.jl b/src/Traits/control_dependence.jl new file mode 100644 index 00000000..b8b85a9e --- /dev/null +++ b/src/Traits/control_dependence.jl @@ -0,0 +1,158 @@ +""" +$(TYPEDEF) + +Abstract supertype for control-dependence traits. + +Encodes, at the type level, whether an optimal control problem (or any object +that opts into the trait) carries a control input. + +# Trait Pattern + +Objects that have a control-dependence trait must implement two methods: +- `has_control_dependence_trait(obj::MyType) = true`: Indicates the type has this trait +- `control_dependence(obj::MyType)`: Returns the specific trait value (`ControlFree` or `WithControl`) + +Once these are implemented, the object automatically gains: +- `is_control_free(obj)`: Returns true if `control_dependence(obj)` is `ControlFree` +- `has_control(obj)`: Returns true if `control_dependence(obj)` is `WithControl` + +If `has_control_dependence_trait` is not implemented or returns `false`, +calling `is_control_free`, `has_control`, or `control_dependence` will throw an error +indicating the object does not support control-dependence queries. + +See also: [`CTBase.Traits.ControlFree`](@ref), [`CTBase.Traits.WithControl`](@ref). +""" +abstract type ControlDependence <: AbstractTrait end + +""" +$(TYPEDEF) + +Trait indicating the problem has no control input (control-free). + +A control-free optimal control problem has dynamics `ẋ = f(t, x, v)` with no +control argument; the trajectory is determined by the state (and costate) +equations alone, without a control law. + +See also: [`CTBase.Traits.WithControl`](@ref), [`CTBase.Traits.ControlDependence`](@ref). +""" +struct ControlFree <: ControlDependence end + +""" +$(TYPEDEF) + +Trait indicating the problem has a control input. + +A problem with control has dynamics `ẋ = f(t, x, u, v)` depending on a control +`u`; closing the loop (e.g. for a flow) requires a control law `u(t, x, p)`. + +See also: [`CTBase.Traits.ControlFree`](@ref), [`CTBase.Traits.ControlDependence`](@ref). +""" +struct WithControl <: ControlDependence end + +# ============================================================================= +# Check has trait +# ============================================================================= + +""" +$(TYPEDSIGNATURES) + +Check if the object has the control-dependence trait. + +This fallback method throws an error indicating the object does not support +control-dependence queries. Concrete types that have the trait should implement +`has_control_dependence_trait(obj::MyType) = true`. + +The calling function name is automatically detected from the stacktrace +for better error messages. + +# Arguments +- `obj::Any`: The object to check. + +# Throws +- [`CTBase.Exceptions.IncorrectArgument`](@ref): Always, indicating the object does not have the trait. + +See also: [`CTBase.Traits.ControlDependence`](@ref), [`CTBase.Traits.control_dependence`](@ref). +""" +function has_control_dependence_trait(obj::Any) + return _throw_missing_trait( + obj, :has_control_dependence_trait, :control_dependence, "control-dependence" + ) +end + +""" +$(TYPEDSIGNATURES) + +Return the control-dependence trait value for the object. + +This fallback method throws an error indicating the method is not implemented. +Concrete types that have the trait should implement `control_dependence(obj::MyType)` +to return the specific trait value (`ControlFree` or `WithControl`). + +# Arguments +- `obj::Any`: The object to query. + +# Throws +- [`CTBase.Exceptions.NotImplemented`](@ref): Always, indicating the method must be implemented. + +See also: [`CTBase.Traits.ControlDependence`](@ref), [`CTBase.Traits.has_control_dependence_trait`](@ref). +""" +function control_dependence(obj::Any) + has_control_dependence_trait(obj) + return _throw_trait_not_implemented( + obj, :control_dependence, "control-dependence", "ControlFree or WithControl" + ) +end + +# ============================================================================= +# Trait accessors +# ============================================================================= + +""" +$(TYPEDSIGNATURES) + +Return true if the object is control-free (has no control input). + +Checks that the object has the control-dependence trait, then returns true +if `control_dependence(obj)` is `ControlFree`. + +# Arguments +- `obj::Any`: The object to check. + +# Returns +- `Bool`: true if the object has no control input. + +# Throws +- [`CTBase.Exceptions.IncorrectArgument`](@ref): If the object does not support control-dependence queries. +- [`CTBase.Exceptions.NotImplemented`](@ref): If `control_dependence` is not implemented for the object type. + +See also: [`CTBase.Traits.ControlDependence`](@ref), [`CTBase.Traits.has_control`](@ref). +""" +function is_control_free(obj::Any) + has_control_dependence_trait(obj) + return control_dependence(obj) === ControlFree +end + +""" +$(TYPEDSIGNATURES) + +Return true if the object has a control input. + +Checks that the object has the control-dependence trait, then returns true +if `control_dependence(obj)` is `WithControl`. + +# Arguments +- `obj::Any`: The object to check. + +# Returns +- `Bool`: true if the object has a control input. + +# Throws +- [`CTBase.Exceptions.IncorrectArgument`](@ref): If the object does not support control-dependence queries. +- [`CTBase.Exceptions.NotImplemented`](@ref): If `control_dependence` is not implemented for the object type. + +See also: [`CTBase.Traits.ControlDependence`](@ref), [`CTBase.Traits.is_control_free`](@ref). +""" +function has_control(obj::Any) + has_control_dependence_trait(obj) + return control_dependence(obj) === WithControl +end diff --git a/src/Traits/helpers.jl b/src/Traits/helpers.jl index 71001d01..a78b4bf5 100644 --- a/src/Traits/helpers.jl +++ b/src/Traits/helpers.jl @@ -6,6 +6,11 @@ Return the name of the calling function by inspecting the stacktrace. This is used to provide better error messages in trait check functions without requiring an explicit `source_method` argument. +Frames internal to the trait-contract machinery are skipped: the helper itself, +the shared throwers (`_throw_missing_trait`, `_throw_trait_not_implemented`), any +`has__trait` predicate, and compiler-generated closures (names starting +with `#`). The first remaining frame is the user-facing caller. + # Returns - `Symbol`: The name of the calling function, or `:unknown` if it cannot be determined. """ @@ -14,13 +19,75 @@ function _caller_function_name() for frame in stack func_name = frame.func func_str = string(func_name) - if func_str != "_caller_function_name" && - !startswith(func_str, "#") && - func_str != "has_time_dependence_trait" && - func_str != "has_variable_dependence_trait" && - func_str != "has_mutability_trait" - return func_name - end + startswith(func_str, "#") && continue + func_str == "_caller_function_name" && continue + func_str == "_throw_missing_trait" && continue + func_str == "_throw_trait_not_implemented" && continue + (startswith(func_str, "has_") && endswith(func_str, "_trait")) && continue + return func_name end return :unknown end + +""" + _throw_missing_trait(obj, has_method::Symbol, accessor::Symbol, family::AbstractString) + +Throw the standard [`CTBase.Exceptions.IncorrectArgument`](@ref) used by the +fallback `has__trait(::Any)` method when an object does not opt into a +strict trait family. + +The offending caller is detected automatically from the stacktrace via +[`_caller_function_name`](@ref), so the message names the user-facing predicate +(`is_autonomous`, `is_variable`, …) rather than the internal machinery. + +# Arguments +- `obj`: The object that lacks the trait. +- `has_method::Symbol`: Name of the `has__trait` method to implement. +- `accessor::Symbol`: Name of the trait accessor to implement (`time_dependence`, …). +- `family::AbstractString`: Human-readable family name (e.g. `"time-dependence"`). + +# Throws +- [`CTBase.Exceptions.IncorrectArgument`](@ref): Always. +""" +function _throw_missing_trait( + obj, has_method::Symbol, accessor::Symbol, family::AbstractString +) + source_method = _caller_function_name() + return throw( + Exceptions.IncorrectArgument( + "Cannot call $(source_method) on object of type $(typeof(obj)): no $(family) trait"; + suggestion="Implement $(has_method)(obj::$(typeof(obj))) = true and $(accessor)(obj::$(typeof(obj))) to enable $(family) trait support.", + context="$(uppercasefirst(family)) trait not available", + ), + ) +end + +""" + _throw_trait_not_implemented(obj, accessor::Symbol, family::AbstractString, valid::AbstractString) + +Throw the standard [`CTBase.Exceptions.NotImplemented`](@ref) used by the fallback +accessor (`time_dependence(::Any)`, …) of a strict trait family when the object +declares the trait but does not implement the accessor. + +# Arguments +- `obj`: The object whose accessor is missing. +- `accessor::Symbol`: Name of the trait accessor to implement. +- `family::AbstractString`: Human-readable family name (e.g. `"time-dependence"`). +- `valid::AbstractString`: Human-readable list of valid trait values + (e.g. `"Autonomous or NonAutonomous"`). + +# Throws +- [`CTBase.Exceptions.NotImplemented`](@ref): Always. +""" +function _throw_trait_not_implemented( + obj, accessor::Symbol, family::AbstractString, valid::AbstractString +) + return throw( + Exceptions.NotImplemented( + "$(accessor) not implemented for $(typeof(obj))"; + required_method="$(accessor)(obj::$(typeof(obj)))", + suggestion="Implement $(accessor) for your concrete object type to return the specific $(family) trait ($(valid)).", + context="$(uppercasefirst(family)) trait - required method implementation", + ), + ) +end diff --git a/src/Traits/mutability.jl b/src/Traits/mutability.jl index 9c675bba..e2aa9a33 100644 --- a/src/Traits/mutability.jl +++ b/src/Traits/mutability.jl @@ -94,14 +94,7 @@ for better error messages. See also: [`CTBase.Traits.AbstractMutabilityTrait`](@ref), [`CTBase.Traits.mutability`](@ref). """ function has_mutability_trait(obj::Any) - source_method = _caller_function_name() - return throw( - Exceptions.IncorrectArgument( - "Cannot call $(source_method) on object of type $(typeof(obj)): no mutability trait"; - suggestion="Implement has_mutability_trait(obj::$(typeof(obj))) = true and mutability(obj::$(typeof(obj))) to enable mutability trait support.", - context="Mutability trait not available", - ), - ) + return _throw_missing_trait(obj, :has_mutability_trait, :mutability, "mutability") end """ @@ -123,13 +116,8 @@ See also: [`CTBase.Traits.AbstractMutabilityTrait`](@ref), [`CTBase.Traits.has_m """ function mutability(obj::Any) has_mutability_trait(obj) - return throw( - Exceptions.NotImplemented( - "mutability not implemented for $(typeof(obj))"; - required_method="mutability(obj::$(typeof(obj)))", - suggestion="Implement mutability for your concrete object type to return the specific mutability trait (InPlace or OutOfPlace).", - context="Mutability trait - required method implementation", - ), + return _throw_trait_not_implemented( + obj, :mutability, "mutability", "InPlace or OutOfPlace" ) end diff --git a/src/Traits/time_dependence.jl b/src/Traits/time_dependence.jl index bf8cb249..b2eeeb7b 100644 --- a/src/Traits/time_dependence.jl +++ b/src/Traits/time_dependence.jl @@ -65,13 +65,8 @@ for better error messages. See also: [`CTBase.Traits.TimeDependence`](@ref), [`CTBase.Traits.time_dependence`](@ref). """ function has_time_dependence_trait(obj::Any) - source_method = _caller_function_name() - return throw( - Exceptions.IncorrectArgument( - "Cannot call $(source_method) on object of type $(typeof(obj)): no time-dependence trait"; - suggestion="Implement has_time_dependence_trait(obj::$(typeof(obj))) = true and time_dependence(obj::$(typeof(obj))) to enable time-dependence trait support.", - context="Time-dependence trait not available", - ), + return _throw_missing_trait( + obj, :has_time_dependence_trait, :time_dependence, "time-dependence" ) end @@ -94,13 +89,8 @@ See also: [`CTBase.Traits.TimeDependence`](@ref), [`CTBase.Traits.has_time_depen """ function time_dependence(obj::Any) has_time_dependence_trait(obj) - return throw( - Exceptions.NotImplemented( - "time_dependence not implemented for $(typeof(obj))"; - required_method="time_dependence(obj::$(typeof(obj)))", - suggestion="Implement time_dependence for your concrete object type to return the specific time-dependence trait (Autonomous or NonAutonomous).", - context="Time-dependence trait - required method implementation", - ), + return _throw_trait_not_implemented( + obj, :time_dependence, "time-dependence", "Autonomous or NonAutonomous" ) end diff --git a/src/Traits/variable_dependence.jl b/src/Traits/variable_dependence.jl index ebd12fb7..fdf6a075 100644 --- a/src/Traits/variable_dependence.jl +++ b/src/Traits/variable_dependence.jl @@ -81,13 +81,8 @@ for better error messages. See also: [`CTBase.Traits.VariableDependence`](@ref), [`CTBase.Traits.variable_dependence`](@ref). """ function has_variable_dependence_trait(obj::Any) - source_method = _caller_function_name() - return throw( - Exceptions.IncorrectArgument( - "Cannot call $(source_method) on object of type $(typeof(obj)): no variable-dependence trait"; - suggestion="Implement has_variable_dependence_trait(obj::$(typeof(obj))) = true and variable_dependence(obj::$(typeof(obj))) to enable variable-dependence trait support.", - context="Variable-dependence trait not available", - ), + return _throw_missing_trait( + obj, :has_variable_dependence_trait, :variable_dependence, "variable-dependence" ) end @@ -110,13 +105,8 @@ See also: [`CTBase.Traits.VariableDependence`](@ref), [`CTBase.Traits.has_variab """ function variable_dependence(obj::Any) has_variable_dependence_trait(obj) - return throw( - Exceptions.NotImplemented( - "variable_dependence not implemented for $(typeof(obj))"; - required_method="variable_dependence(obj::$(typeof(obj)))", - suggestion="Implement variable_dependence for your concrete object type to return the specific variable-dependence trait (Fixed or NonFixed).", - context="Variable-dependence trait - required method implementation", - ), + return _throw_trait_not_implemented( + obj, :variable_dependence, "variable-dependence", "Fixed or NonFixed" ) end @@ -194,7 +184,4 @@ if `variable_dependence(obj)` is `NonFixed`. See also: `is_variable`, [`CTBase.Traits.VariableDependence`](@ref). """ -function has_variable(obj::Any) - has_variable_dependence_trait(obj) - return variable_dependence(obj) === NonFixed -end +has_variable(obj::Any) = is_variable(obj) diff --git a/test/suite/traits/test_control_dependence.jl b/test/suite/traits/test_control_dependence.jl new file mode 100644 index 00000000..7e58d7f3 --- /dev/null +++ b/test/suite/traits/test_control_dependence.jl @@ -0,0 +1,127 @@ +module TestControlDependence + +using Test: Test +import CTBase.Exceptions +import CTBase.Traits + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================== +# Fake types for contract testing +# ============================================================================== + +""" +Fake type for testing control-dependence trait pattern. +Implements both required methods: has_control_dependence_trait and control_dependence. +""" +struct FakeControlFree end + +Traits.has_control_dependence_trait(::FakeControlFree) = true +Traits.control_dependence(::FakeControlFree) = Traits.ControlFree + +""" +Fake type for testing control-dependence trait pattern with WithControl. +""" +struct FakeWithControl end + +Traits.has_control_dependence_trait(::FakeWithControl) = true +Traits.control_dependence(::FakeWithControl) = Traits.WithControl + +function test_control_dependence() + Test.@testset "Control Dependence Trait Tests" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - Trait Types + # ==================================================================== + + Test.@testset "UNIT TESTS - Trait Types" begin + Test.@testset "ControlDependence abstract type" begin + Test.@test isdefined(Traits, :ControlDependence) + Test.@test Traits.ControlFree <: Traits.ControlDependence + Test.@test Traits.WithControl <: Traits.ControlDependence + Test.@test Traits.ControlDependence <: Traits.AbstractTrait + end + + Test.@testset "Concrete trait types" begin + Test.@test Traits.ControlFree() isa Traits.ControlFree + Test.@test Traits.WithControl() isa Traits.WithControl + end + end + + # ==================================================================== + # ERROR TESTS - Fallback Methods + # ==================================================================== + + Test.@testset "ERROR TESTS - Fallback Methods" begin + Test.@testset "has_control_dependence_trait throws IncorrectArgument" begin + obj = "not a trait object" + Test.@test_throws Exceptions.IncorrectArgument Traits.has_control_dependence_trait( + obj + ) + end + + Test.@testset "control_dependence throws IncorrectArgument" begin + obj = "not a trait object" + Test.@test_throws Exceptions.IncorrectArgument Traits.control_dependence( + obj + ) + end + + Test.@testset "predicates throw on objects without the trait" begin + obj = "not a trait object" + Test.@test_throws Exceptions.IncorrectArgument Traits.is_control_free(obj) + Test.@test_throws Exceptions.IncorrectArgument Traits.has_control(obj) + end + end + + # ==================================================================== + # CONTRACT TESTS - Control-Dependence Trait Pattern + # ==================================================================== + + Test.@testset "CONTRACT TESTS - Control-Dependence Trait Pattern" begin + Test.@testset "FakeControlFree trait implementation" begin + obj = FakeControlFree() + Test.@test Traits.has_control_dependence_trait(obj) === true + Test.@test Traits.control_dependence(obj) === Traits.ControlFree + Test.@test Traits.is_control_free(obj) === true + Test.@test Traits.has_control(obj) === false + end + + Test.@testset "FakeWithControl trait implementation" begin + obj = FakeWithControl() + Test.@test Traits.has_control_dependence_trait(obj) === true + Test.@test Traits.control_dependence(obj) === Traits.WithControl + Test.@test Traits.is_control_free(obj) === false + Test.@test Traits.has_control(obj) === true + end + end + + # ==================================================================== + # Exports Verification + # ==================================================================== + + Test.@testset "Exports Verification" begin + Test.@testset "Exported control-dependence trait types" begin + for sym in (:ControlDependence, :ControlFree, :WithControl) + Test.@test isdefined(Traits, sym) + end + end + + Test.@testset "Exported control-dependence trait functions" begin + for sym in ( + :has_control_dependence_trait, + :control_dependence, + :is_control_free, + :has_control, + ) + Test.@test isdefined(Traits, sym) + end + end + end + end +end + +end # module + +test_control_dependence() = TestControlDependence.test_control_dependence() diff --git a/test/suite/traits/test_traits_module.jl b/test/suite/traits/test_traits_module.jl index f53d01a4..62a41812 100644 --- a/test/suite/traits/test_traits_module.jl +++ b/test/suite/traits/test_traits_module.jl @@ -15,6 +15,7 @@ # - test_time_dependence.jl for time dependence traits # - test_variable_costate.jl for variable costate traits # - test_variable_dependence.jl for variable dependence traits +# - test_control_dependence.jl for control dependence traits """ module TestTraitsModule @@ -43,6 +44,7 @@ const EXPORTED_ABSTRACT_TYPES = ( :AbstractVariableCostateCapability, :TimeDependence, :VariableDependence, + :ControlDependence, ) const EXPORTED_CONCRETE_TYPES = ( @@ -61,6 +63,8 @@ const EXPORTED_CONCRETE_TYPES = ( :NonAutonomous, :Fixed, :NonFixed, + :ControlFree, + :WithControl, ) const EXPORTED_FUNCTIONS = ( @@ -79,9 +83,15 @@ const EXPORTED_FUNCTIONS = ( :is_variable, :is_nonvariable, :has_variable, + :has_control_dependence_trait, + :control_dependence, + :is_control_free, + :has_control, ) -const PRIVATE_SYMBOLS = (:_caller_function_name,) +const PRIVATE_SYMBOLS = ( + :_caller_function_name, :_throw_missing_trait, :_throw_trait_not_implemented +) # ============================================================================ # Helper functions (generic for reuse in other modules) From abc1438a4b1904f53b3887b824d5e1bffeab10bd Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 28 Jun 2026 14:51:02 +0200 Subject: [PATCH 2/2] docs: add changelog and breaking-changes note for ControlDependence trait family --- BREAKINGS.md | 9 +++++++++ CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 CHANGELOG.md diff --git a/BREAKINGS.md b/BREAKINGS.md index 1b2cca48..182f10fb 100644 --- a/BREAKINGS.md +++ b/BREAKINGS.md @@ -26,6 +26,15 @@ This document outlines all breaking changes introduced in CTBase v0.18.0-beta co - `NotStored` / `NotStoredType` are unchanged and remain extraction-internal to `CTBase.Options` (the defining file was renamed `not_provided.jl` → `not_stored.jl`). +## Non-breaking note (0.25.0-beta) + +- **Traits**: New `ControlDependence` family added for encoding control presence in optimal control problems + - **New trait family**: `ControlDependence` with tags `ControlFree` and `WithControl` + - **Opt-in contract**: types implement `has_control_dependence_trait` and `control_dependence` + - **Derived predicates**: `is_control_free(obj)` and `has_control(obj)` + - **Internal refactoring**: shared helpers for strict-contract error handling reduce duplication + - **No breaking changes**: Purely additive. Existing code unaffected. New trait enables dispatch in downstream packages (CTModels, CTFlows). + ## Non-breaking note (0.24.0-beta) - **Differentiation module**: Added comprehensive AD backend infrastructure for computing gradients diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..32c7529c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Unreleased (0.25.0-beta) + +### Added + +- **Traits**: New `ControlDependence` trait family for encoding control presence in optimal control problems + - Tags: `ControlFree` (no control) and `WithControl` (with control input) + - Opt-in contract: implement `has_control_dependence_trait` and `control_dependence` + - Derived predicates: `is_control_free` and `has_control` + - Enables downstream dispatch in CTFlows and CTModels for control-problem routing + +### Changed + +- **Traits**: Refactored strict-contract machinery to eliminate duplication + - New internal helpers: `_throw_missing_trait` and `_throw_trait_not_implemented` + - Generalised `_caller_function_name` stacktrace filter for any `has__trait` predicate + - All strictfamilies (time-dependence, variable-dependence, mutability, control-dependence) now share the same error handling + - Collapsed `has_variable` duplicate to direct alias of `is_variable` + +- **Documentation**: Updated Traits guide to document the two trait templates (strict opt-in vs default-valued capability) + +### Fixed + +- **Traits**: API reference now includes `control_dependence.jl` in auto-generated documentation + +--- + +## Previous versions + +See git history and release notes for earlier versions.