From 82d1b5f93e09c1cb15778300c840a201de1996df Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 25 Aug 2026 16:15:40 +0200 Subject: [PATCH 01/20] deps: raise CTBase to 0.29, CTModels to 0.18, ExaModels to 0.12 Align CTParser's compat bounds with the released ecosystem: CTBase 0.29.3, CTModels 0.18.0, CTSolvers 0.5.3 and CTFlows 0.17.2. Verified in the real suite: the :fun groups (test_onepass_fun, _bis, utils, initial_guess) stay green at 1054/1054 against CTBase 0.29.3 + CTModels 0.18.0 + OrderedCollections 2.0.1, so these three bumps need no source change. CTBase 0.29 dropped its top-level exports, but its submodules are `using`-ed inside CTBase, so the two symbols src/ uses -- ctindices and ctupperscripts -- still resolve, as does ParsingError, which generated code reaches through e_prefix. The ExaModels bump does NOT stand on its own: 0.12 deleted the mutable builder API that def_exa emits, so every :exa group now fails with UndefVarError: `variable` not defined in `ExaModels` That is deliberate at this commit -- it reproduces the breakage inside the real suite, and the next commit migrates the emission to the functional builder API. CUDA, MadNLP and MadNLPGPU keep their lower bound, mirroring CTSolvers 0.5.3, so the GitHub-hosted runners are not forced onto a CUDA 6 resolve. Refs #325 Co-Authored-By: Claude Opus 5 --- Project.toml | 6 +++--- docs/Project.toml | 2 +- test/Project.toml | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Project.toml b/Project.toml index 8e8b5cc..0db5f41 100644 --- a/Project.toml +++ b/Project.toml @@ -12,10 +12,10 @@ Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a" Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" [compat] -CTBase = "0.18, 0.27, 0.28" +CTBase = "0.29" DocStringExtensions = "0.9" MLStyle = "0.4" -OrderedCollections = "1" -Parameters = "0.12, 0.13" +OrderedCollections = "1, 2" +Parameters = "0.13" Unicode = "1" julia = "1.10" diff --git a/docs/Project.toml b/docs/Project.toml index 631245d..fe32870 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -5,7 +5,7 @@ Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391" [compat] -CTBase = "0.18, 0.27, 0.28" +CTBase = "0.29" Documenter = "1" Markdown = "1" MarkdownAST = "0.1" diff --git a/test/Project.toml b/test/Project.toml index 6214d50..c6b28c0 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -17,16 +17,16 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] Aqua = "0.8" BenchmarkTools = "1" -CTBase = "0.18, 0.27, 0.28" -CTModels = "0.10, 0.14, 0.15" -CUDA = "5" -ExaModels = "0.9" +CTBase = "0.29" +CTModels = "0.18" +CUDA = "5, 6" +ExaModels = "0.12" Interpolations = "0.16" KernelAbstractions = "0.9" LinearAlgebra = "1" -MadNLP = "0.9" -MadNLPGPU = "0.8" +MadNLP = "0.9, 0.10" +MadNLPGPU = "0.8, 0.10" NLPModels = "0.21" -OrderedCollections = "1.8" +OrderedCollections = "1.8, 2" Test = "1.10" julia = "1.10" From c5d425d384e6b59f297d5e3fcea5e817b03a0d8e Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 25 Aug 2026 16:40:15 +0200 Subject: [PATCH 02/20] feat(exa)!: emit the ExaModels 0.12 functional builder API ExaModels 0.12 deleted the mutable builder API that def_exa emitted: variable, parameter, subexpr, constraint! and LegacyExaCore are gone with src/deprecated.jl. The replacement is functional -- add_var/add_con/add_obj each return (new_core, result) -- so the generated code now threads and rebinds the core. 23 emission sites in src/onepass.jl: p_variable_exa! / p_state_exa! / p_control_exa! variable -> add_var 3 p_constraint_exa! constraint -> add_con 6 p_dynamics_exa! constraint -> add_con 4 p_dynamics_coord_exa! constraint -> add_con 4 p_lagrange_exa! objective -> add_obj 5 p_mayer_exa! objective -> add_obj 1 The five constraint! calls in p_constraint_fun! are CTModels', not ExaModels', and do not move. Three emission shapes needed care, each validated against ExaModels 0.12 before being written: - value used: the binding stays outside __wrap's try, as the existing comment requires, while the core is rebound inside it -- ($p_ocp, $x) = try ... end. Reassigning an existing outer local from inside try/catch is visible outside; only new declarations are not. - per-scheme dynamics: the `if` is kept and each branch returns add_con's pair, so ($p_ocp, $(p.dyn_con)[$i]) = if scheme == ... end destructures both at once, rebinding the core on every loop iteration. - per-scheme Lagrange: the rebinding moves *inside* each branch, because the trapeze branch adds two objectives and the `if`'s own value is unused. ExaCore(base_type; backend, minimize) is left exactly as it was. Under 0.12 it no longer warns -- the deprecation shim is gone -- so no `concrete` keyword is passed. That is deliberate: with the 0.12 default (Vector{Any} block storage) typeof(core) is invariant across every add_*, so rebinding is free, whereas concrete = Val(true) changes the core's type on each add_* and would recompile the builder per block. test/test_exa_linalg.jl builds an ExaCore directly and moves the same way. Results: test_control_zero 37/37, test_onepass_exa_bis 176/176, test_onepass_exa 564 pass / 12 errors, test_dynamics_exa 96 pass / 4 errors, test_exa_linalg 136 pass / 20 fail / 103 errors. Every one of those remaining failures has a single cause, unrelated to this commit: ExaModels ships ext/ExaModelsOptimalControl.jl -- the successor to 0.9's ExaModelsLinearAlgebra -- but never registers it in [extensions], so Julia never loads it and the node linear-algebra glue (dot, convert, zero, scalar x vector) is absent. Verified that the shipped file still works verbatim against 0.12 once loaded by hand. Handled next. Refs #325 Co-Authored-By: Claude Opus 5 --- src/onepass.jl | 78 +++++++++++++++++++++++------------------ test/test_exa_linalg.jl | 6 ++-- 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/onepass.jl b/src/onepass.jl index b1ff16a..927bcef 100644 --- a/src/onepass.jl +++ b/src/onepass.jl @@ -425,9 +425,9 @@ function p_variable_exa!(p, p_ocp, v, q, vv; components_names=nothing) pref = prefix_exa() code_box = :($(p.l_v) = -Inf * ones($q); $(p.u_v) = Inf * ones($q)) p.box_v = concat(p.box_v, code_box) - code = :($pref.variable($p_ocp, $q; lvar=($(p.l_v)), uvar=($(p.u_v)), start=init[1])) + code = :($pref.add_var($p_ocp, $q; lvar=($(p.l_v)), uvar=($(p.u_v)), start=init[1])) code = __wrap(code, p.lnum, p.line) - code = :($v = $code) # affectation must be done outside try ... catch (otherwise declaration known only to try local scope) + code = :(($p_ocp, $v) = $code) # affectation must be done outside try ... catch (otherwise declaration known only to try local scope); add_var returns (core, var), so the core is rebound here return code end @@ -539,7 +539,7 @@ function p_state_exa!(p, p_ocp, x, n, xx; components_names=nothing) p.box_x = concat(p.box_x, code_box) i = __symgen(:i) j = __symgen(:j) - code = :($pref.variable( + code = :($pref.add_var( $p_ocp, $n, 0:grid_size; @@ -550,7 +550,7 @@ function p_state_exa!(p, p_ocp, x, n, xx; components_names=nothing) code = __wrap(code, p.lnum, p.line) p.dyn_con = __symgen(:dyn_con) # name for the constraints associated with the dynamics code = quote - $x = $code + ($p_ocp, $x) = $code # add_var returns (core, var), so the core is rebound here $(p.dyn_con) = Vector{$pref.Constraint}(undef, $n) # affectation must be done outside try ... catch (otherwise declaration known only to try local scope) end return code @@ -608,7 +608,7 @@ function p_control_exa!(p, p_ocp, u, m, uu; components_names=nothing) p.box_u = concat(p.box_u, code_box) i = __symgen(:i) j = __symgen(:j) - code = :($pref.variable( + code = :($pref.add_var( $p_ocp, $m, 0:grid_size; @@ -617,7 +617,7 @@ function p_control_exa!(p, p_ocp, u, m, uu; components_names=nothing) start=init[3], )) code = __wrap(code, p.lnum, p.line) - code = :($u = $code) # affectation must be done outside try ... catch (otherwise declaration known only to try local scope) + code = :(($p_ocp, $u) = $code) # affectation must be done outside try ... catch (otherwise declaration known only to try local scope); add_var returns (core, var), so the core is rebound here return code end @@ -713,10 +713,12 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) quote length($e1) == length($e3) || throw("wrong bound dimension") # (vs. __throw) since raised at runtime if length($e1) == 1 - $pref.constraint($p_ocp, $e2; lcon=($e1[1]), ucon=($e3[1])) # todo: add _denull + ($p_ocp, _) = $pref.add_con($p_ocp, $e2; lcon=($e1[1]), ucon=($e3[1])) # todo: add _denull else for $l in 1:length($e1) - $pref.constraint($p_ocp, $e2[$l]; lcon=($e1[$l]), ucon=($e3[$l])) # todo: add _denull + ($p_ocp, _) = $pref.add_con( + $p_ocp, $e2[$l]; lcon=($e1[$l]), ucon=($e3[$l]) + ) # todo: add _denull end end end @@ -737,7 +739,11 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) e2 = subs3(e2, x0, p.x, i, 0) concat( code, - :($pref.constraint($p_ocp, $e2 for $i in $rg; lcon=($e1), ucon=($e3))), + :( + ($p_ocp, _) = $pref.add_con( + $p_ocp, $e2 for $i in $rg; lcon=($e1), ucon=($e3) + ) + ), ) end (:final, rg) => begin @@ -756,7 +762,11 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) e2 = subs3(e2, xf, p.x, i, :grid_size) concat( code, - :($pref.constraint($p_ocp, $e2 for $i in $rg; lcon=($e1), ucon=($e3))), + :( + ($p_ocp, _) = $pref.add_con( + $p_ocp, $e2 for $i in $rg; lcon=($e1), ucon=($e3) + ) + ), ) end (:variable_range, rg) => begin @@ -826,12 +836,12 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) quote length($e1) == length($e3) || throw("wrong bound dimension") # (vs. __throw) since raised at runtime if length($e1) == 1 - $pref.constraint( + ($p_ocp, _) = $pref.add_con( $p_ocp, $e2 for $j in 0:grid_size; lcon=($e1[1]), ucon=($e3[1]) ) # todo: add _denull else for $l in 1:length($e1) - $pref.constraint( + ($p_ocp, _) = $pref.add_con( $p_ocp, $e2[$l] for $j in 0:grid_size; lcon=($e1[$l]), @@ -915,23 +925,19 @@ function p_dynamics_exa!(p, p_ocp, x, t, e) i = __symgen(:i) code = quote for $i in 1:($(p.dim_x)) - $(p.dyn_con)[$i] = if scheme == :euler # dyn_con already defined outside try catch - $pref.constraint( - $p_ocp, - $dxj[$i] - $(p.dt) * $ej1[$i] for $j1 in 0:(grid_size - 1) - ) # todo: add _denull + # each branch returns add_con's (core, constraint) pair, so the core is + # rebound on every loop iteration alongside the constraint handle + ($p_ocp, $(p.dyn_con)[$i]) = if scheme == :euler # dyn_con already defined outside try catch + $pref.add_con($p_ocp, $dxj[$i] - $(p.dt) * $ej1[$i] for $j1 in 0:(grid_size - 1)) # todo: add _denull elseif scheme ∈ (:euler_implicit, :euler_b) # euler_b is deprecated - $pref.constraint( - $p_ocp, - $dxj[$i] - $(p.dt) * $ej2[$i] for $j1 in 0:(grid_size - 1) - ) # todo: add _denull + $pref.add_con($p_ocp, $dxj[$i] - $(p.dt) * $ej2[$i] for $j1 in 0:(grid_size - 1)) # todo: add _denull elseif scheme == :midpoint - $pref.constraint( + $pref.add_con( $p_ocp, $dxj[$i] - $(p.dt) * $ej12[$i] for $j1 in 0:(grid_size - 1) ) # todo: add _denull elseif scheme ∈ (:trapeze, :trapezoidal) # trapezoidal is deprecated - $pref.constraint( + $pref.add_con( $p_ocp, $dxj[$i] - $(p.dt) * ($ej1[$i] + $ej2[$i]) / 2 for $j1 in 0:(grid_size - 1) # todo: add _denull @@ -1019,14 +1025,16 @@ function p_dynamics_coord_exa!(p, p_ocp, x, i::Integer, t, e) # todo: also also ej12 = subs(ej12, p.t, :($(p.t0) + $j12 * $(p.dt))) dxij = :($(p.x)[$i, $j2] - $(p.x)[$i, $j1]) code = quote - $(p.dyn_con)[$i] = if scheme == :euler # dyn_con already defined outside try catch - $pref.constraint($p_ocp, $dxij - $(p.dt) * $ej1 for $j1 in 0:(grid_size - 1)) # todo: add _denull + # each branch returns add_con's (core, constraint) pair, so the core is rebound + # alongside the constraint handle + ($p_ocp, $(p.dyn_con)[$i]) = if scheme == :euler # dyn_con already defined outside try catch + $pref.add_con($p_ocp, $dxij - $(p.dt) * $ej1 for $j1 in 0:(grid_size - 1)) # todo: add _denull elseif scheme ∈ (:euler_implicit, :euler_b) # euler_b is deprecated - $pref.constraint($p_ocp, $dxij - $(p.dt) * $ej2 for $j1 in 0:(grid_size - 1)) # todo: add _denull + $pref.add_con($p_ocp, $dxij - $(p.dt) * $ej2 for $j1 in 0:(grid_size - 1)) # todo: add _denull elseif scheme == :midpoint - $pref.constraint($p_ocp, $dxij - $(p.dt) * $ej12 for $j1 in 0:(grid_size - 1)) # todo: add _denull + $pref.add_con($p_ocp, $dxij - $(p.dt) * $ej12 for $j1 in 0:(grid_size - 1)) # todo: add _denull elseif scheme ∈ (:trapeze, :trapezoidal) # trapezoidal is deprecated - $pref.constraint( + $pref.add_con( $p_ocp, $dxij - $(p.dt) * ($ej1 + $ej2) / 2 for $j1 in 0:(grid_size - 1) # todo: add _denull ) @@ -1092,15 +1100,17 @@ function p_lagrange_exa!(p, p_ocp, e, type) ej12 = subs(ej12, ut, :([$(p.u)[$k, $j1] for $k in 1:($(p.dim_u))])) ej12 = subs(ej12, p.t, :($(p.t0) + $j12 * $(p.dt))) code = quote + # the rebinding sits inside each branch, not around the `if`: the trapeze branch + # adds two objectives, and the `if`'s own value is unused here if scheme == :euler - $pref.objective($p_ocp, $(p.dt) * $ej1 for $j1 in 0:(grid_size - 1)) # todo: add _denull + ($p_ocp, _) = $pref.add_obj($p_ocp, $(p.dt) * $ej1 for $j1 in 0:(grid_size - 1)) # todo: add _denull elseif scheme ∈ (:euler_implicit, :euler_b) # euler_b is deprecated - $pref.objective($p_ocp, $(p.dt) * $ej1 for $j1 in 1:grid_size) # todo: add _denull + ($p_ocp, _) = $pref.add_obj($p_ocp, $(p.dt) * $ej1 for $j1 in 1:grid_size) # todo: add _denull elseif scheme == :midpoint - $pref.objective($p_ocp, $(p.dt) * $ej12 for $j1 in 0:(grid_size - 1)) # todo: add _denull + ($p_ocp, _) = $pref.add_obj($p_ocp, $(p.dt) * $ej12 for $j1 in 0:(grid_size - 1)) # todo: add _denull elseif scheme ∈ (:trapeze, :trapezoidal) # trapezoidal is deprecated - $pref.objective($p_ocp, $(p.dt) * $ej1 / 2 for $j1 in (0, grid_size)) # todo: add _denull - $pref.objective($p_ocp, $(p.dt) * $ej1 for $j1 in 1:(grid_size - 1)) # todo: add _denull + ($p_ocp, _) = $pref.add_obj($p_ocp, $(p.dt) * $ej1 / 2 for $j1 in (0, grid_size)) # todo: add _denull + ($p_ocp, _) = $pref.add_obj($p_ocp, $(p.dt) * $ej1 for $j1 in 1:(grid_size - 1)) # todo: add _denull else throw( "unknown numerical scheme: $scheme (possible choices are :euler, :euler_implicit, :midpoint, :trapeze)", @@ -1154,7 +1164,7 @@ function p_mayer_exa!(p, p_ocp, e, type) e = subs2(e, xf, p.x, :grid_size) e = subs(e, xf, :([$(p.x)[$k, grid_size] for $k in 1:($(p.dim_x))])) # now, x[i](t0) has been replaced by x[i, 0] and x[i](tf) by x[i, grid_size] - code = :($pref.objective($p_ocp, $e)) # todo: add _denull + code = :(($p_ocp, _) = $pref.add_obj($p_ocp, $e)) # todo: add _denull return __wrap(code, p.lnum, p.line) end diff --git a/test/test_exa_linalg.jl b/test/test_exa_linalg.jl index d5044aa..c8d7174 100644 --- a/test/test_exa_linalg.jl +++ b/test/test_exa_linalg.jl @@ -155,7 +155,7 @@ function test_exa_linalg() real_reinterp2 = reinterpret(Float64, complex_vec2) result4 = dot(real_reinterp1, real_reinterp2) @test result4 isa Real - @test result4 ≈ 1*5 + 2*6 + 3*7 + 4*8 # 70.0 + @test result4 ≈ 1 * 5 + 2 * 6 + 3 * 7 + 4 * 8 # 70.0 # Test mixed wrapper types result5 = dot(v1_view, v2_reshaped) @@ -497,9 +497,9 @@ function test_exa_linalg() end @testset "ExaCore variable arrays" begin - # Create a more realistic test using ExaModels.variable + # Create a more realistic test using ExaModels.add_var c = ExaModels.ExaCore() - xvar = ExaModels.variable(c, 2, 0:10, lvar=0, uvar=1) + c, xvar = ExaModels.add_var(c, 2, 0:10; lvar=0, uvar=1) # Create vector from variable v = [xvar[i, 1] for i in 1:2] From f8a0fda45327b23acb47d4d09b4c8006252e2c5b Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 25 Aug 2026 17:20:59 +0200 Subject: [PATCH 03/20] fix(exa): carry ExaModels' unregistered linear-algebra extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExaModels ships ext/ExaModelsOptimalControl.jl -- the successor to 0.9's ExaModelsLinearAlgebra -- but never declares it in [extensions], so Julia never loads it. Everything it provides is unreachable: dot, * on node vectors and matrices, det, norm, tr, diag, convert/promote_rule/zero/one for AbstractNode, and the Null zero/one elimination. LinearAlgebra is still in ExaModels' [weakdeps] with nothing referencing it, and OptimalControl -- the trigger the rename implies -- is not in [weakdeps] at all. True of main as well as of the 0.12.0 release. That is not cosmetic here. It breaks a real @def feature: a dynamics written as ∂(x)(t) == A * x(t) + B * u(t), or an objective using dot(q, x(t)), applies those operators to arrays of ExaModels nodes while the model is being built. The shipped file still works verbatim against 0.12 once loaded by hand, so this carries a port of it as ext/CTParserExaModels.jl, triggered by ExaModels + LinearAlgebra weak dependencies. CTParser is the package that *emits* the ExaModels code, so it is the natural owner of that contract until upstream wires its own extension up. Three things the port had to get right: - ExaModels 0.12's core defines node arithmetic only generically, on AbstractNode. There is no Null-specific method anywhere in it -- `hasmethod` says otherwise only because Null <: AbstractNode. So every Null overload here is strictly more specific and overwrites nothing, and skipping them is not an option: without the zero/one elimination test_exa_linalg sits at 381/485. - Upstream's Section F (ExaModels.add_con(core, ::AbstractVector)) is not ported. It is broken as written -- it starts from c1 = nothing and calls the removed ExaModels.constraint on it -- and p_constraint_exa! never emits the vector form anyway, it loops over components. - Folding structural zeros to Null lets both operands of a second-order adjoint pass be SecondAdjointNull at once, which reaches a genuine ambiguity in ExaModels' own src/simdfunction.jl:142-143: _hdrpass_val has methods for (<:SecondAdjointNull, ::Type) and (::Type, <:SecondAdjointNull) but none for the intersection. Both return Val(0), so the missing value is forced. Defined here, with the reasoning in a comment; it hits second derivatives of a dot-written dynamics under the trapeze scheme. Aqua needs no exemption: Aqua.test_all(CTParser) inspects the package module, not its extensions, so piracies=true stays on unchanged and still passes 11/11. CLAUDE.md and AGENTS.md said "no ext/"; both now describe the one extension and the condition for deleting it. All :exa groups green: test_onepass_exa 596/596, test_onepass_exa_bis 176/176, test_control_zero 37/37, test_dynamics_exa 100/100, test_exa_linalg 491/491. Upstream question (intended direction, not a patch): madsuite-org/ExaModels.jl#323. Refs #325 Co-Authored-By: Claude Opus 5 --- AGENTS.md | 11 +- CLAUDE.md | 11 +- Project.toml | 9 + ext/CTParserExaModels.jl | 596 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 ext/CTParserExaModels.jl diff --git a/AGENTS.md b/AGENTS.md index ab6e4d8..8c273a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,14 +6,23 @@ Quick-reference for any agent working on this repository. ## Repository Layout -CTParser is a single flat module — no submodule split, no `ext/`. +CTParser is a single flat module — no submodule split. ```text src/ # onepass.jl (the `@def` parser), defaults.jl, initial_guess.jl, utils.jl +ext/ # CTParserExaModels.jl only — see below test/ # Test suite: flat files (not test/suite/) docs/ # Documentation site (DocumenterVitepress) ``` +`ext/` holds exactly one extension, and it is **temporary**. ExaModels ships the +linear-algebra glue its expression nodes need (`dot`, `*` on node vectors/matrices, +`det`, `norm`, `Null` zero elimination) but never declares that extension in its +`[extensions]`, so Julia never loads it. Until upstream wires it up, CTParser carries +a port of it, triggered by `ExaModels` + `LinearAlgebra` weak dependencies. Delete +the file and the two weakdeps when upstream fixes it — see +[#325](https://github.com/control-toolbox/CTParser.jl/issues/325). + --- ## Developer Resources diff --git a/CLAUDE.md b/CLAUDE.md index 508cb87..7a06dcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,14 +6,23 @@ Quick-reference for any agent working on this repository. ## Repository Layout -CTParser is a single flat module — no submodule split, no `ext/`. +CTParser is a single flat module — no submodule split. ```text src/ # onepass.jl (the `@def` parser), defaults.jl, initial_guess.jl, utils.jl +ext/ # CTParserExaModels.jl only — see below test/ # Test suite: flat files (not test/suite/) docs/ # Documentation site (DocumenterVitepress) ``` +`ext/` holds exactly one extension, and it is **temporary**. ExaModels ships the +linear-algebra glue its expression nodes need (`dot`, `*` on node vectors/matrices, +`det`, `norm`, `Null` zero elimination) but never declares that extension in its +`[extensions]`, so Julia never loads it. Until upstream wires it up, CTParser carries +a port of it, triggered by `ExaModels` + `LinearAlgebra` weak dependencies. Delete +the file and the two weakdeps when upstream fixes it — see +[#325](https://github.com/control-toolbox/CTParser.jl/issues/325). + --- ## Developer Resources diff --git a/Project.toml b/Project.toml index 0db5f41..33567d3 100644 --- a/Project.toml +++ b/Project.toml @@ -11,9 +11,18 @@ OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a" Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" +[weakdeps] +ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" + +[extensions] +CTParserExaModels = ["ExaModels", "LinearAlgebra"] + [compat] CTBase = "0.29" DocStringExtensions = "0.9" +ExaModels = "0.12" +LinearAlgebra = "1" MLStyle = "0.4" OrderedCollections = "1, 2" Parameters = "0.13" diff --git a/ext/CTParserExaModels.jl b/ext/CTParserExaModels.jl new file mode 100644 index 0000000..265cc84 --- /dev/null +++ b/ext/CTParserExaModels.jl @@ -0,0 +1,596 @@ +""" + CTParserExaModels + +Linear-algebra glue for ExaModels expression nodes, needed by the code `@def` emits +for the `:exa` backend. + +# Why this exists + +A user writing an optimal control problem for the `:exa` backend may use ordinary +linear algebra on the state and control: + +```julia +@def begin + ∂(x)(t) == A * x(t) + B * u(t) # matrix × vector + ∫(dot(q, x(t))) → min # dot on a vector of nodes +end +``` + +`@def` substitutes ExaModels expression nodes for `x(t)` and `u(t)`, so those +operators are applied to `ExaModels.AbstractNode` values rather than numbers. +ExaModels does not define them in its core module. + +Up to ExaModels 0.9 they came from `ExaModelsLinearAlgebra`, an extension triggered +by `LinearAlgebra`. From 0.11 that extension was renamed `ExaModelsOptimalControl` +and re-triggered on `OptimalControl` — but it is **not declared in `[extensions]`**, +and `OptimalControl` is not in `[weakdeps]` either, so Julia never loads it. The +file still ships in the tarball, and still works verbatim against 0.12; it is only +unwired. `LinearAlgebra` remains in ExaModels' `[weakdeps]` with nothing referencing +it, which is the fingerprint of the same slip. This is true of ExaModels `main` as +well as the 0.12.0 release. + +# Provenance and scope + +The method definitions below are ported from that shipped-but-unregistered +`ext/ExaModelsOptimalControl.jl`, minus one section: + +- Upstream's Section F (`ExaModels.add_con(core, ::AbstractVector)`) is not ported: + it is broken as written (it starts from `c1 = nothing` and calls the removed + `ExaModels.constraint` on it), and `p_constraint_exa!` never emits the vector + form — it loops over components instead. + +Nothing here overwrites an ExaModels method. ExaModels' core defines arithmetic on +`AbstractNode` generically; every `Null`-specific method below is strictly more +specific, and adds the zero/one elimination that keeps a structural zero from +becoming a `Node2(*, Null(0), x)` left in the expression graph. Without them a +model still evaluates correctly — the redundant nodes just make the graph, and so +the derivative kernels, larger than they need to be. + +# Lifetime + +Temporary. When ExaModels declares its own extension, delete this file and drop the +two weak dependencies from `Project.toml`; nothing else references them. Aqua needs +no exemption — `Aqua.test_all(CTParser)` inspects the package module, not its +extensions, so `piracies=true` stays on as it is. See control-toolbox/CTParser.jl#325. +""" +module CTParserExaModels + +using ExaModels: ExaModels +using LinearAlgebra: LinearAlgebra + +# ============================================================================ +# Null node pass-throughs for LinearAlgebra functions +# +# A Null wraps a constant, so a unary function of it is again a constant and can +# be folded now rather than left as a Node1 for the AD kernels to walk. +# ============================================================================ + +for f in [:inv, :abs, :sqrt, :cbrt, :abs2, :exp, :log, :sin, :cos, :tan] + @eval @inline function Base.$f(x::ExaModels.Null{T}) where {T<:Real} + return ExaModels.Null(Base.$f(x.value)) + end +end + +# ============================================================================ +# Scalar Null arithmetic with zero/one elimination +# ============================================================================ + +# Null op Null +for op in (:+, :-, :*) + @eval @inline function Base.$op( + a::ExaModels.Null{T}, b::ExaModels.Null{S} + ) where {T<:Real,S<:Real} + return ExaModels.Null(Base.$op(a.value, b.value)) + end +end + +# Null + AbstractNode / AbstractNode + Null (zero elimination) +@inline function Base.:+(a::ExaModels.Null{T}, b::ExaModels.AbstractNode) where {T<:Real} + return ExaModels.Node2(+, a, b) +end +@inline function Base.:+(a::ExaModels.AbstractNode, b::ExaModels.Null{T}) where {T<:Real} + return b.value == zero(T) ? a : ExaModels.Node2(+, a, b) +end + +# Null * AbstractNode / AbstractNode * Null (zero/one elimination) +@inline function Base.:*(a::ExaModels.Null{T}, b::ExaModels.AbstractNode) where {T<:Real} + return if a.value == zero(T) + ExaModels.Null(zero(T)) + elseif a.value == one(T) + b + else + ExaModels.Node2(*, a, b) + end +end +@inline function Base.:*(a::ExaModels.AbstractNode, b::ExaModels.Null{T}) where {T<:Real} + return if b.value == zero(T) + ExaModels.Null(zero(T)) + elseif b.value == one(T) + a + else + ExaModels.Node2(*, a, b) + end +end + +# Null - AbstractNode / AbstractNode - Null +@inline function Base.:-(a::ExaModels.Null{T}, b::ExaModels.AbstractNode) where {T<:Real} + return a.value == zero(T) ? ExaModels.Node1(-, b) : ExaModels.Node2(-, a, b) +end +@inline function Base.:-(a::ExaModels.AbstractNode, b::ExaModels.Null{T}) where {T<:Real} + return b.value == zero(T) ? a : ExaModels.Node2(-, a, b) +end + +# Null op Real / Real op Null +for op in (:+, :-, :*) + @eval @inline function Base.$op(a::ExaModels.Null{T}, b::Real) where {T<:Real} + return ExaModels.Null(Base.$op(a.value, b)) + end + @eval @inline function Base.$op(a::Real, b::ExaModels.Null{T}) where {T<:Real} + return ExaModels.Null(Base.$op(a, b.value)) + end +end + +# Null op Integer / Integer op Null — disambiguate Null{T} op Real vs AbstractNode op Integer +for op in (:+, :-, :*) + @eval @inline function Base.$op(a::ExaModels.Null{T}, b::Integer) where {T<:Real} + return ExaModels.Null(Base.$op(a.value, b)) + end + @eval @inline function Base.$op(a::Integer, b::ExaModels.Null{T}) where {T<:Real} + return ExaModels.Null(Base.$op(a, b.value)) + end +end + +# Integer × AbstractNode zero/one elimination (more specific than core's Real × AbstractNode) +# Fixes: 0 * x → Null(0), 1 * x → x, 0 + x → x, etc. +@inline function Base.:*(a::Integer, b::ExaModels.AbstractNode) + return if iszero(a) + ExaModels.Null(zero(a)) + elseif isone(a) + b + else + ExaModels.Node2(*, a, b) + end +end +@inline function Base.:*(a::ExaModels.AbstractNode, b::Integer) + return if iszero(b) + ExaModels.Null(zero(b)) + elseif isone(b) + a + else + ExaModels.Node2(*, a, b) + end +end +@inline function Base.:+(a::Integer, b::ExaModels.AbstractNode) + return iszero(a) ? b : ExaModels.Node2(+, a, b) +end +@inline function Base.:+(a::ExaModels.AbstractNode, b::Integer) + return iszero(b) ? a : ExaModels.Node2(+, a, b) +end +@inline function Base.:-(a::Integer, b::ExaModels.AbstractNode) + return iszero(a) ? ExaModels.Node1(-, b) : ExaModels.Node2(-, a, b) +end +@inline function Base.:-(a::ExaModels.AbstractNode, b::Integer) + return iszero(b) ? a : ExaModels.Node2(-, a, b) +end + +# ---------------------------------------------------------------------------- +# Upstream ambiguity, surfaced by the zero elimination above +# +# ExaModels declares both +# _hdrpass_val(::Type{<:SecondAdjointNull}, ::Type) +# _hdrpass_val(::Type, ::Type{<:SecondAdjointNull}) +# and no method for the case where *both* arguments are `SecondAdjointNull`, so +# that call is ambiguous (simdfunction.jl:142-143). Folding a structural zero to +# `Null` is what makes both operands Null at once, so the ambiguity only shows up +# once the eliminations above are in play — it hits second-order derivatives of a +# dynamics written with `dot`, under the trapeze scheme. +# +# The value is forced, not a judgement call: both declared methods return `Val(0)`, +# so the intersection must too. This is the "Possible fix" Julia itself prints. +# Delete along with the rest of this file once upstream fixes it. +# ---------------------------------------------------------------------------- + +function ExaModels._hdrpass_val( + ::Type{<:ExaModels.SecondAdjointNull}, ::Type{<:ExaModels.SecondAdjointNull} +) + return Val(0) +end + +# ============================================================================ +# Type aliases, promotion, and adjoint for nodes +# ============================================================================ + +const ExaNode = Union{ + ExaModels.AbstractNode,ExaModels.AbstractAdjointNode,ExaModels.AbstractSecondAdjointNode +} +const VecExaNode = AbstractVector{<:ExaNode} +const MatExaNode = AbstractMatrix{<:ExaNode} + +# Type promotion: [x, 0] should give Vector{AbstractNode} with Null(0), not Vector{Any} +Base.promote_rule(::Type{<:ExaModels.AbstractNode}, ::Type{<:Real}) = ExaModels.AbstractNode +function Base.convert(::Type{ExaModels.AbstractNode}, x::Real) + return iszero(x) ? zero(ExaModels.AbstractNode) : ExaModels.Null(x) +end + +# zero/one for ExaNode types — needed by stdlib (e.g. tr) and general array ops +Base.zero(::Type{<:ExaModels.AbstractNode}) = ExaModels.Null(0) +Base.zero(::ExaNode) = ExaModels.Null(0) +Base.one(::Type{<:ExaModels.AbstractNode}) = ExaModels.Null(1) +Base.one(::ExaNode) = ExaModels.Null(1) + +# adjoint/transpose for scalar ExaNode — nodes are real-valued, so both are identity +Base.adjoint(x::ExaModels.AbstractNode) = x +Base.adjoint(x::ExaModels.AbstractAdjointNode) = x +Base.adjoint(x::ExaModels.AbstractSecondAdjointNode) = x +Base.transpose(x::ExaModels.AbstractNode) = x +Base.transpose(x::ExaModels.AbstractAdjointNode) = x +Base.transpose(x::ExaModels.AbstractSecondAdjointNode) = x + +# adjoint/transpose for matrices of ExaNode — materialize to plain Matrix +Base.adjoint(A::MatExaNode) = [A[j, i] for i in axes(A, 2), j in axes(A, 1)] +Base.transpose(A::MatExaNode) = [A[j, i] for i in axes(A, 2), j in axes(A, 1)] + +# Dispatch pair constants for 3-way type combos +const _VEC_PAIRS = [ + (VecExaNode, VecExaNode), + (AbstractVector{<:Real}, VecExaNode), + (VecExaNode, AbstractVector{<:Real}), +] +const _MAT_PAIRS = [ + (MatExaNode, MatExaNode), + (AbstractMatrix{<:Real}, MatExaNode), + (MatExaNode, AbstractMatrix{<:Real}), +] + +# ============================================================================ +# Plain Julia scalar decompositions +# +# Every operation below rebuilds the result element by element from scalar node +# arithmetic. Nodes are expression graph vertices, not numbers, so the stdlib's +# BLAS-backed paths do not apply. +# ============================================================================ + +# --- sum --- + +function Base.sum(v::VecExaNode) + s = v[1] + for i in 2:length(v) + s = s + v[i] + end + return s +end + +# --- dot --- + +for (T1, T2) in _VEC_PAIRS + @eval function LinearAlgebra.dot(a::$T1, b::$T2) + @assert length(a) == length(b) + s = a[1] * b[1] + for i in 2:length(a) + s = s + a[i] * b[i] + end + return s + end +end + +# --- scalar * vector --- + +for (T1, T2) in + [(Real, VecExaNode), (ExaNode, AbstractVector{<:Real}), (ExaNode, VecExaNode)] + @eval Base.:*(a::$T1, b::$T2) = [a * b[i] for i in eachindex(b)] +end +for (T1, T2) in + [(VecExaNode, Real), (AbstractVector{<:Real}, ExaNode), (VecExaNode, ExaNode)] + @eval Base.:*(a::$T1, b::$T2) = [a[i] * b for i in eachindex(a)] +end + +# --- scalar * matrix --- + +for (T1, T2) in + [(Real, MatExaNode), (ExaNode, AbstractMatrix{<:Real}), (ExaNode, MatExaNode)] + @eval Base.:*(a::$T1, b::$T2) = [a * b[i, j] for i in axes(b, 1), j in axes(b, 2)] +end +for (T1, T2) in + [(MatExaNode, Real), (AbstractMatrix{<:Real}, ExaNode), (MatExaNode, ExaNode)] + @eval Base.:*(a::$T1, b::$T2) = [a[i, j] * b for i in axes(a, 1), j in axes(a, 2)] +end + +# --- matrix * vector (inline dot to avoid dispatch issues with view types) --- + +function _dot_row(A, i, x) + n = size(A, 2) + s = A[i, 1] * x[1] + for j in 2:n + s = s + A[i, j] * x[j] + end + return s +end + +for (T1, T2) in [ + (MatExaNode, VecExaNode), + (AbstractMatrix{<:Real}, VecExaNode), + (MatExaNode, AbstractVector{<:Real}), +] + @eval function Base.:*(A::$T1, x::$T2) + m = size(A, 1) + @assert size(A, 2) == length(x) + return [_dot_row(A, i, x) for i in 1:m] + end +end + +# --- matrix * matrix (inline dot to avoid dispatch issues) --- + +function _dot_col(A, i, B, j) + n = size(A, 2) + s = A[i, 1] * B[1, j] + for k in 2:n + s = s + A[i, k] * B[k, j] + end + return s +end + +for (T1, T2) in _MAT_PAIRS + @eval function Base.:*(A::$T1, B::$T2) + @assert size(A, 2) == size(B, 1) + m, n = size(A, 1), size(B, 2) + return [_dot_col(A, i, B, j) for i in 1:m, j in 1:n] + end +end + +# --- vector +/- --- + +for op in (:+, :-) + for (T1, T2) in _VEC_PAIRS + @eval function Base.$op(a::$T1, b::$T2) + @assert length(a) == length(b) + return [$op(a[i], b[i]) for i in eachindex(a)] + end + end +end + +# Win dispatch over Base's +(::Array, ::Array...) from arraymath.jl +for (T1, T2) in [(ExaNode, ExaNode), (Real, ExaNode), (ExaNode, Real)] + @eval function Base.:+(a::Array{<:$T1,1}, b::Array{<:$T2,1}) + @assert length(a) == length(b) + return [a[i] + b[i] for i in eachindex(a)] + end + @eval function Base.:+(A::Array{<:$T1,2}, B::Array{<:$T2,2}) + @assert size(A) == size(B) + return [A[i, j] + B[i, j] for i in axes(A, 1), j in axes(A, 2)] + end +end + +# Unary minus for vector/matrix of nodes +Base.:-(a::VecExaNode) = [-a[i] for i in eachindex(a)] +Base.:-(A::MatExaNode) = [-A[i, j] for i in axes(A, 1), j in axes(A, 2)] + +# --- matrix +/- --- + +for op in (:+, :-) + for (T1, T2) in _MAT_PAIRS + @eval function Base.$op(A::$T1, B::$T2) + @assert size(A) == size(B) + return [$op(A[i, j], B[i, j]) for i in axes(A, 1), j in axes(A, 2)] + end + end +end + +# --- tr --- + +function _tr_impl(A) + @assert size(A, 1) == size(A, 2) "Matrix must be square for tr" + n = size(A, 1) + s = A[1, 1] + for i in 2:n + s = s + A[i, i] + end + return s +end + +LinearAlgebra.tr(A::MatExaNode) = _tr_impl(A) +# More specific methods to win dispatch over stdlib's tr(::Matrix{T}) (Julia 1.10) +# and tr(::StridedMatrix{T}) (Julia 1.12+) +LinearAlgebra.tr(A::Matrix{<:ExaNode}) = _tr_impl(A) +LinearAlgebra.tr(A::StridedMatrix{<:ExaNode}) = _tr_impl(A) + +# --- diag --- + +function LinearAlgebra.diag(A::MatExaNode) + n = minimum(size(A)) + return [A[i, i] for i in 1:n] +end + +# --- diagm --- + +function LinearAlgebra.diagm(v::VecExaNode) + n = length(v) + T = typeof(v[1]) + M = Matrix{Union{T,ExaModels.Null{Int}}}(undef, n, n) + for i in 1:n, j in 1:n + if i == j + M[i, j] = v[i] + else + M[i, j] = ExaModels.Null(0) + end + end + return M +end + +# --- adjoint/transpose operations --- + +# v' * w = dot(v, w) +for (TA, TV, TB) in [ + (ExaNode, VecExaNode, VecExaNode), + (ExaNode, VecExaNode, AbstractVector{<:Real}), + (Real, AbstractVector{<:Real}, VecExaNode), +] + @eval function Base.:*(a::LinearAlgebra.Adjoint{<:$TA,<:$TV}, b::$TB) + return LinearAlgebra.dot(parent(a), b) + end +end + +# v' * A +for (TA, TV, TB) in + [(ExaNode, VecExaNode, MatExaNode), (Real, AbstractVector{<:Real}, MatExaNode)] + @eval function Base.:*(a::LinearAlgebra.Adjoint{<:$TA,<:$TV}, B::$TB) + v = parent(a) + @assert length(v) == size(B, 1) + n = size(B, 2) + return adjoint([LinearAlgebra.dot(v, [B[k, j] for k in 1:size(B, 1)]) for j in 1:n]) + end +end +function Base.:*( + a::LinearAlgebra.Adjoint{<:ExaNode,<:VecExaNode}, B::AbstractMatrix{<:Real} +) + v = parent(a) + @assert length(v) == size(B, 1) + n = size(B, 2) + return adjoint([LinearAlgebra.dot(v, view(B, :, j)) for j in 1:n]) +end + +# ============================================================================ +# Optimized scalar expansions +# +# det and norm expand to a single scalar expression graph. The small-size det +# cases are written out rather than recursed so the resulting graph stays flat. +# ============================================================================ + +# --- det (specialized for small sizes) --- + +_det_1x1(A) = A[1, 1] + +# 2x2: a11*a22 - a12*a21 +function _det_2x2(A) + a11 = A[1, 1] + a12 = A[1, 2] + a21 = A[2, 1] + a22 = A[2, 2] + return a11 * a22 - a12 * a21 +end + +# 3x3: Sarrus' rule (optimized expansion) +function _det_3x3(A) + a11 = A[1, 1] + a12 = A[1, 2] + a13 = A[1, 3] + a21 = A[2, 1] + a22 = A[2, 2] + a23 = A[2, 3] + a31 = A[3, 1] + a32 = A[3, 2] + a33 = A[3, 3] + return a11 * (a22 * a33 - a23 * a32) - a12 * (a21 * a33 - a23 * a31) + + a13 * (a21 * a32 - a22 * a31) +end + +# 4x4: cofactor expansion along first row +function _det_4x4(A) + a11 = A[1, 1] + a12 = A[1, 2] + a13 = A[1, 3] + a14 = A[1, 4] + m11 = + A[2, 2] * (A[3, 3] * A[4, 4] - A[3, 4] * A[4, 3]) - + A[2, 3] * (A[3, 2] * A[4, 4] - A[3, 4] * A[4, 2]) + + A[2, 4] * (A[3, 2] * A[4, 3] - A[3, 3] * A[4, 2]) + m12 = + A[2, 1] * (A[3, 3] * A[4, 4] - A[3, 4] * A[4, 3]) - + A[2, 3] * (A[3, 1] * A[4, 4] - A[3, 4] * A[4, 1]) + + A[2, 4] * (A[3, 1] * A[4, 3] - A[3, 3] * A[4, 1]) + m13 = + A[2, 1] * (A[3, 2] * A[4, 4] - A[3, 4] * A[4, 2]) - + A[2, 2] * (A[3, 1] * A[4, 4] - A[3, 4] * A[4, 1]) + + A[2, 4] * (A[3, 1] * A[4, 2] - A[3, 2] * A[4, 1]) + m14 = + A[2, 1] * (A[3, 2] * A[4, 3] - A[3, 3] * A[4, 2]) - + A[2, 2] * (A[3, 1] * A[4, 3] - A[3, 3] * A[4, 1]) + + A[2, 3] * (A[3, 1] * A[4, 2] - A[3, 2] * A[4, 1]) + return a11 * m11 - a12 * m12 + a13 * m13 - a14 * m14 +end + +# General determinant via cofactor expansion (recursive, for N > 4) +function _det_recursive(A) + n = size(A, 1) + @assert size(A, 1) == size(A, 2) "Matrix must be square" + if n == 1 + return _det_1x1(A) + elseif n == 2 + return _det_2x2(A) + elseif n == 3 + return _det_3x3(A) + elseif n == 4 + return _det_4x4(A) + end + s = A[1, 1] * _det_recursive(A[2:end, 2:end]) + for j in 2:n + cols = vcat(1:(j - 1), (j + 1):n) + minor = _det_recursive(A[2:end, cols]) + if iseven(j) + s = s - A[1, j] * minor + else + s = s + A[1, j] * minor + end + end + return s +end + +# Dispatch det for matrices containing ExaNode elements +function LinearAlgebra.det(A::MatExaNode) + @assert size(A, 1) == size(A, 2) "Matrix must be square for det" + return _det_recursive(A) +end + +# --- norm --- + +# 2-norm for vectors of nodes: sqrt(sum(xi^2)) +function LinearAlgebra.norm(v::VecExaNode) + s = v[1]^2 + for i in 2:length(v) + s = s + v[i]^2 + end + return sqrt(s) +end + +# p-norm for vectors of nodes: (sum(abs(xi)^p))^(1/p) +function LinearAlgebra.norm(v::VecExaNode, p::Real) + if p == 2 + return LinearAlgebra.norm(v) + elseif p == 1 + s = abs(v[1]) + for i in 2:length(v) + s = s + abs(v[i]) + end + return s + elseif p == Inf + error("Inf-norm is not differentiable and not supported for ExaNode vectors") + else + s = abs(v[1])^p + for i in 2:length(v) + s = s + abs(v[i])^p + end + return s^(1 / p) + end +end + +# Frobenius norm for matrices of nodes: sqrt(sum(aij^2)) +function LinearAlgebra.norm(A::MatExaNode) + s = A[1, 1]^2 + for j in axes(A, 2), i in axes(A, 1) + (i == 1 && j == 1) && continue + s = s + A[i, j]^2 + end + return sqrt(s) +end + +# --- cross product (3D only) --- + +for (T1, T2) in _VEC_PAIRS + @eval function LinearAlgebra.cross(a::$T1, b::$T2) + @assert length(a) == 3 && length(b) == 3 "Cross product requires 3D vectors" + return [ + a[2] * b[3] - a[3] * b[2], a[3] * b[1] - a[1] * b[3], a[1] * b[2] - a[2] * b[1] + ] + end +end + +end # module CTParserExaModels From 6139789a388dfa9ab8314d215efba48e455add4a Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 25 Aug 2026 17:26:14 +0200 Subject: [PATCH 04/20] ci: replace the retired kkt runner with occidata, rename labels to "run ci ..." The self-hosted kkt runner no longer exists, so its job could never be scheduled. Replaced by occidata, following CTFlows.jl, which already runs both. Labels renamed to the "run ci " form used across the ecosystem, so the CI triggers group together in the label list instead of scattering among the topic labels: github-runner -> run ci github-runner kkt-runner -> run ci occidata-runner Jobs renamed to match CTFlows too (test-cpu-github, test-gpu-occidata), which says what runs where rather than only which runner it lands on. The label-gating logic is unchanged, including the `github.event.label.name` guard on the 'labeled' branch that keeps an unrelated label from re-triggering CI. Labels created and the obsolete pair deleted on the repository. Refs #325 Co-Authored-By: Claude Opus 5 --- .github/workflows/CI.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d23f82d..9508238 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -10,7 +10,7 @@ on: jobs: # Job for the GitHub hosted runners (ubuntu, macos, windows) - test-github-runner: + test-cpu-github: # A 'labeled' event fires once per label added, and re-evaluates against the PR's # *current* full label set — so adding several labels in a row would otherwise # re-run this job on every subsequent label add. Only react to 'labeled' when it's @@ -18,8 +18,8 @@ jobs: # checking the current label set as before. if: > github.event_name != 'pull_request' || - (github.event.action == 'labeled' && github.event.label.name == 'github-runner') || - (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'github-runner')) + (github.event.action == 'labeled' && github.event.label.name == 'run ci github-runner') || + (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run ci github-runner')) uses: control-toolbox/CTActions/.github/workflows/ci.yml@main with: runs_on: '["ubuntu-latest", "macos-latest", "windows-latest"]' @@ -28,17 +28,17 @@ jobs: secrets: SSH_KEY: ${{ secrets.SSH_KEY }} - # Job for the self-hosted runner kkt (GPU/CUDA) - test-kkt-runner: - # See the comment on test-github-runner above. + # Job for the self-hosted runner occidata (GPU/CUDA) + test-gpu-occidata: + # See the comment on test-cpu-github above. if: > github.event_name != 'pull_request' || - (github.event.action == 'labeled' && github.event.label.name == 'kkt-runner') || - (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'kkt-runner')) + (github.event.action == 'labeled' && github.event.label.name == 'run ci occidata-runner') || + (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run ci occidata-runner')) uses: control-toolbox/CTActions/.github/workflows/ci.yml@main with: versions: '["1"]' - runs_on: '[["kkt"]]' + runs_on: '[["occidata"]]' runner_type: 'self-hosted' use_ct_registry: true secrets: From cb2d573f2b0b7f947b7e2aa6bc33164f1e3225c3 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 25 Aug 2026 18:58:09 +0200 Subject: [PATCH 05/20] fix(exceptions)!: throw CTException subtypes instead of bare String Seventeen sites threw a bare String, so typeof(e) === String: callers could not dispatch on them, and showerror fell back to show, rendering the message wrapped in quotes instead of the Reason / Context / Hint block every other error in the ecosystem produces. Typed by the Handbook's choice rule -- a single argument's value out of domain is IncorrectArgument, a relational/state/timing contract is PreconditionError: unknown numerical scheme 3 sites IncorrectArgument lower/upper bound length mismatch 2 sites PreconditionError bound lengths vs the constrained range 5 sites PreconditionError unknown value for the getter's val kwarg 1 site IncorrectArgument unknown parsing backend 4 sites IncorrectArgument :fun cannot be activated or deactivated 2 sites PreconditionError Two of those took an actual call. A bound-length mismatch relates two things -- the bounds to each other, or to the constrained range -- which is the Handbook's own heuristic for PreconditionError, not IncorrectArgument. And ':fun' is a perfectly valid backend name: what is forbidden is toggling it, a state contract rather than a bad value, so it is PreconditionError while an unrecognised backend name is IncorrectArgument. Each throw carries got/expected (or reason) and a suggestion; the bound errors build their reason at run time from the actual lengths, which the old single-line message never reported. Generated code goes through the existing e_prefix (:CTBase), so the four emitters that needed it now bind e_pref alongside pref. The plain runtime functions (activate_backend, deactivate_backend, is_active_backend, parsing) call CTBase directly. The comment claiming __throw had to be avoided here was right about __throw -- it builds a macro-expansion-time expression -- but said nothing about the thrown object's type: __wrap rethrows whatever it caught, so the type is preserved either way. Reworded. The 19 @test_throws String assertions now assert the concrete type. Fixes #322. Refs #325 Co-Authored-By: Claude Opus 5 --- src/onepass.jl | 198 +++++++++++++++++++++++++++++++---- test/test_onepass_exa.jl | 18 ++-- test/test_onepass_exa_bis.jl | 2 +- test/test_onepass_fun.jl | 8 +- test/test_prefix_bis.jl | 10 +- 5 files changed, 195 insertions(+), 41 deletions(-) diff --git a/src/onepass.jl b/src/onepass.jl index 927bcef..5d43147 100644 --- a/src/onepass.jl +++ b/src/onepass.jl @@ -696,6 +696,7 @@ end function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) pref = prefix_exa() + e_pref = e_prefix() isnothing(e1) && (e1 = :(-Inf * ones(length($e3)))) isnothing(e3) && (e3 = :(Inf * ones(length($e1)))) code = @match c_type begin @@ -711,7 +712,18 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) e2 = subs2(e2, xf, p.x, :grid_size) e2 = subs(e2, xf, :([$(p.x)[$k, grid_size] for $k in 1:($(p.dim_x))])) quote - length($e1) == length($e3) || throw("wrong bound dimension") # (vs. __throw) since raised at runtime + length($e1) == length($e3) || throw( + $e_pref.PreconditionError( + "lower and upper bounds have different lengths"; + reason=string( + "lower bound has length ", + length($e1), + ", upper bound has length ", + length($e3), + ), + suggestion="give both bounds the same length", + ), + ) # typed, not __throw: raised at run time inside __wrap-ped generated code if length($e1) == 1 ($p_ocp, _) = $pref.add_con($p_ocp, $e2; lcon=($e1[1]), ucon=($e3[1])) # todo: add _denull else @@ -731,7 +743,20 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) rg = as_range(rg) # case rg = i (vs i:j or i:p:j) end code = :( - length($e1) == length($e3) == length($rg) || throw("wrong bound dimension") + length($e1) == length($e3) == length($rg) || throw( + $e_pref.PreconditionError( + "bound lengths do not match the constrained range"; + reason=string( + "lower bound has length ", + length($e1), + ", upper bound has length ", + length($e3), + ", range has length ", + length($rg), + ), + suggestion="give both bounds the same length as the range", + ), + ) # typed, not __throw: raised at run time inside __wrap-ped generated code ) # (vs. __throw) since raised at runtime x0 = __symgen(:x0) i = __symgen(:i) @@ -754,7 +779,20 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) rg = as_range(rg) # case rg = i (vs i:j or i:p:j) end code = :( - length($e1) == length($e3) == length($rg) || throw("wrong bound dimension") + length($e1) == length($e3) == length($rg) || throw( + $e_pref.PreconditionError( + "bound lengths do not match the constrained range"; + reason=string( + "lower bound has length ", + length($e1), + ", upper bound has length ", + length($e3), + ", range has length ", + length($rg), + ), + suggestion="give both bounds the same length as the range", + ), + ) # typed, not __throw: raised at run time inside __wrap-ped generated code ) # (vs. __throw) since raised at runtime xf = __symgen(:xf) i = __symgen(:i) @@ -777,7 +815,20 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) rg = as_range(rg) # case rg = i (vs i:j or i:p:j) end code_box = :( - length($e1) == length($e3) == length($rg) || throw("wrong bound dimension") + length($e1) == length($e3) == length($rg) || throw( + $e_pref.PreconditionError( + "bound lengths do not match the constrained range"; + reason=string( + "lower bound has length ", + length($e1), + ", upper bound has length ", + length($e3), + ", range has length ", + length($rg), + ), + suggestion="give both bounds the same length as the range", + ), + ) # typed, not __throw: raised at run time inside __wrap-ped generated code ) # (vs. __throw) since raised at runtime code_box = __wrap( concat(code_box, :($(p.l_v)[$rg] .= $e1; $(p.u_v)[$rg] .= $e3)), @@ -794,7 +845,20 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) rg = as_range(rg) # case rg = i (vs i:j or i:p:j) end code_box = :( - length($e1) == length($e3) == length($rg) || throw("wrong bound dimension") + length($e1) == length($e3) == length($rg) || throw( + $e_pref.PreconditionError( + "bound lengths do not match the constrained range"; + reason=string( + "lower bound has length ", + length($e1), + ", upper bound has length ", + length($e3), + ", range has length ", + length($rg), + ), + suggestion="give both bounds the same length as the range", + ), + ) # typed, not __throw: raised at run time inside __wrap-ped generated code ) # (vs. __throw) since raised at runtime code_box = __wrap( concat(code_box, :($(p.l_x)[$rg] .= $e1; $(p.u_x)[$rg] .= $e3)), @@ -811,7 +875,20 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) rg = as_range(rg) # case rg = i (vs i:j or i:p:j) end code_box = :( - length($e1) == length($e3) == length($rg) || throw("wrong bound dimension") + length($e1) == length($e3) == length($rg) || throw( + $e_pref.PreconditionError( + "bound lengths do not match the constrained range"; + reason=string( + "lower bound has length ", + length($e1), + ", upper bound has length ", + length($e3), + ", range has length ", + length($rg), + ), + suggestion="give both bounds the same length as the range", + ), + ) # typed, not __throw: raised at run time inside __wrap-ped generated code ) # (vs. __throw) since raised at runtime code_box = __wrap( concat(code_box, :($(p.l_u)[$rg] .= $e1; $(p.u_u)[$rg] .= $e3)), @@ -834,7 +911,18 @@ function p_constraint_exa!(p, p_ocp, e1, e2, e3, c_type, label) e2 = subs(e2, ut, :([$(p.u)[$k, $j] for $k in 1:($(p.dim_u))])) e2 = subs(e2, p.t, :($(p.t0) + $j * $(p.dt))) quote - length($e1) == length($e3) || throw("wrong bound dimension") # (vs. __throw) since raised at runtime + length($e1) == length($e3) || throw( + $e_pref.PreconditionError( + "lower and upper bounds have different lengths"; + reason=string( + "lower bound has length ", + length($e1), + ", upper bound has length ", + length($e3), + ), + suggestion="give both bounds the same length", + ), + ) # typed, not __throw: raised at run time inside __wrap-ped generated code if length($e1) == 1 ($p_ocp, _) = $pref.add_con( $p_ocp, $e2 for $j in 0:grid_size; lcon=($e1[1]), ucon=($e3[1]) @@ -895,6 +983,7 @@ end function p_dynamics_exa!(p, p_ocp, x, t, e) pref = prefix_exa() + e_pref = e_prefix() xt = __symgen(:xt) ut = __symgen(:ut) e = replace_call(e, [p.x, p.u], p.t, [xt, ut]) @@ -928,9 +1017,15 @@ function p_dynamics_exa!(p, p_ocp, x, t, e) # each branch returns add_con's (core, constraint) pair, so the core is # rebound on every loop iteration alongside the constraint handle ($p_ocp, $(p.dyn_con)[$i]) = if scheme == :euler # dyn_con already defined outside try catch - $pref.add_con($p_ocp, $dxj[$i] - $(p.dt) * $ej1[$i] for $j1 in 0:(grid_size - 1)) # todo: add _denull + $pref.add_con( + $p_ocp, + $dxj[$i] - $(p.dt) * $ej1[$i] for $j1 in 0:(grid_size - 1) + ) # todo: add _denull elseif scheme ∈ (:euler_implicit, :euler_b) # euler_b is deprecated - $pref.add_con($p_ocp, $dxj[$i] - $(p.dt) * $ej2[$i] for $j1 in 0:(grid_size - 1)) # todo: add _denull + $pref.add_con( + $p_ocp, + $dxj[$i] - $(p.dt) * $ej2[$i] for $j1 in 0:(grid_size - 1) + ) # todo: add _denull elseif scheme == :midpoint $pref.add_con( $p_ocp, @@ -944,8 +1039,13 @@ function p_dynamics_exa!(p, p_ocp, x, t, e) ) else throw( - "unknown numerical scheme: $scheme (possible choices are :euler, :euler_implicit, :midpoint, :trapeze)", - ) # (vs. __throw) since raised at runtime (and __wrap-ped) + $e_pref.IncorrectArgument( + "unknown numerical scheme"; + got=string(scheme), + expected=":euler, :euler_implicit, :midpoint or :trapeze", + suggestion="pass one of the supported schemes to the :exa backend", + ), + ) # typed, not __throw: raised at run time inside __wrap-ped generated code end end end @@ -994,6 +1094,7 @@ end function p_dynamics_coord_exa!(p, p_ocp, x, i::Integer, t, e) # todo: also also add coord = range for :exa pref = prefix_exa() + e_pref = e_prefix() i ∈ p.dyn_coords && return __throw("dynamics coordinate $i already defined", p.lnum, p.line) append!(p.dyn_coords, i) @@ -1040,8 +1141,13 @@ function p_dynamics_coord_exa!(p, p_ocp, x, i::Integer, t, e) # todo: also also ) else throw( - "unknown numerical scheme: $scheme (possible choices are :euler, :euler_implicit, :midpoint, :trapeze)", - ) # (vs. __throw) since raised at runtime (and __wrap-ped) + $e_pref.IncorrectArgument( + "unknown numerical scheme"; + got=string(scheme), + expected=":euler, :euler_implicit, :midpoint or :trapeze", + suggestion="pass one of the supported schemes to the :exa backend", + ), + ) # typed, not __throw: raised at run time inside __wrap-ped generated code end end return __wrap(code, p.lnum, p.line) @@ -1078,6 +1184,7 @@ end function p_lagrange_exa!(p, p_ocp, e, type) pref = prefix_exa() + e_pref = e_prefix() xt = __symgen(:xt) ut = __symgen(:ut) e = replace_call(e, [p.x, p.u], p.t, [xt, ut]) @@ -1113,8 +1220,13 @@ function p_lagrange_exa!(p, p_ocp, e, type) ($p_ocp, _) = $pref.add_obj($p_ocp, $(p.dt) * $ej1 for $j1 in 1:(grid_size - 1)) # todo: add _denull else throw( - "unknown numerical scheme: $scheme (possible choices are :euler, :euler_implicit, :midpoint, :trapeze)", - ) # (vs. __throw) since raised at runtime (and __wrap-ped) + $e_pref.IncorrectArgument( + "unknown numerical scheme"; + got=string(scheme), + expected=":euler, :euler_implicit, :midpoint or :trapeze", + suggestion="pass one of the supported schemes to the :exa backend", + ), + ) # typed, not __throw: raised at run time inside __wrap-ped generated code end end return __wrap(code, p.lnum, p.line) @@ -1262,8 +1374,20 @@ $(TYPEDSIGNATURES) Activate parsing backend. Possible choices: `:exa`. """ function activate_backend(backend) - backend ∈ PARSING_BACKENDS || throw("unknown parsing backend") - backend == :fun && throw("backend :fun is always active") + backend ∈ PARSING_BACKENDS || throw( + CTBase.IncorrectArgument( + "unknown parsing backend"; + got=string(backend), + expected=join(string.(":", PARSING_BACKENDS), " or "), + ), + ) + backend == :fun && throw( + CTBase.PreconditionError( + "backend :fun cannot be activated or deactivated"; + reason=":fun is the default parsing backend and is always active", + suggestion="only an optional backend such as :exa can be toggled", + ), + ) ACTIVE_PARSING_BACKENDS[backend] = true return nothing end @@ -1274,8 +1398,20 @@ $(TYPEDSIGNATURES) Deactivate parsing backend. Possible choices: `:exa`. """ function deactivate_backend(backend) - backend ∈ PARSING_BACKENDS || throw("unknown parsing backend") - backend == :fun && throw("backend :fun is always active") + backend ∈ PARSING_BACKENDS || throw( + CTBase.IncorrectArgument( + "unknown parsing backend"; + got=string(backend), + expected=join(string.(":", PARSING_BACKENDS), " or "), + ), + ) + backend == :fun && throw( + CTBase.PreconditionError( + "backend :fun cannot be activated or deactivated"; + reason=":fun is the default parsing backend and is always active", + suggestion="only an optional backend such as :exa can be toggled", + ), + ) ACTIVE_PARSING_BACKENDS[backend] = false return nothing end @@ -1286,7 +1422,13 @@ $(TYPEDSIGNATURES) Check whether backend is active or not. """ function is_active_backend(backend) - backend ∈ PARSING_BACKENDS || throw("unknown parsing backend") + backend ∈ PARSING_BACKENDS || throw( + CTBase.IncorrectArgument( + "unknown parsing backend"; + got=string(backend), + expected=join(string.(":", PARSING_BACKENDS), " or "), + ), + ) return ACTIVE_PARSING_BACKENDS[backend] end @@ -1296,7 +1438,13 @@ $(TYPEDSIGNATURES) Call the primitive associated with symbol s (:alias, etc.) for the associated backend. """ function parsing(s, backend) - backend ∈ PARSING_BACKENDS || throw("unknown parsing backend") + backend ∈ PARSING_BACKENDS || throw( + CTBase.IncorrectArgument( + "unknown parsing backend"; + got=string(backend), + expected=join(string.(":", PARSING_BACKENDS), " or "), + ), + ) return PARSING_DIR[backend][s] end @@ -1473,7 +1621,13 @@ function def_exa(e; log=false) $pref.multipliers_U(sol, $(p.v)) end else - throw("unknown value $val for kwarg val") + throw( + $e_pref.IncorrectArgument( + "unknown value for kwarg val"; + got=string(val), + expected=":state, :control, :variable, :costate, :state_l, :state_u, :control_l, :control_u, :variable_l or :variable_u", + ), + ) end return Array(res) # conversion to Array for GPU end diff --git a/test/test_onepass_exa.jl b/test/test_onepass_exa.jl index af79abc..132179e 100644 --- a/test/test_onepass_exa.jl +++ b/test/test_onepass_exa.jl @@ -783,7 +783,7 @@ function __test_onepass_exa( -1 ≤ x₂(0) + x₁(tf) + tf ≤ [1, 2] x₁(0) + 2cos(x₂(tf)) → min end - @test_throws String o(; backend=backend) + @test_throws PreconditionError o(; backend=backend) o = @def_exa begin tf ∈ R, variable @@ -1085,7 +1085,7 @@ function __test_onepass_exa( x(0) + 2cos(x(1)) → min end @test discretise_exa(o; backend=backend, scheme=scheme) isa ExaModels.ExaModel - @test_throws String discretise_exa(o; scheme=:foo) + @test_throws CTBase.IncorrectArgument discretise_exa(o; scheme=:foo) end test_name = "lagrange cost ($backend_name, $scheme)" @@ -1140,7 +1140,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws PreconditionError o(; backend=backend) end test_name = "state bounds test" @@ -1159,7 +1159,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws PreconditionError o(; backend=backend) end test_name = "control bounds test" @@ -1179,7 +1179,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws PreconditionError o(; backend=backend) end test_name = "path bounds test" @@ -1199,7 +1199,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws PreconditionError o(; backend=backend) end test_name = "path bounds test" @@ -1238,7 +1238,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws PreconditionError o(; backend=backend) end test_name = "final bounds test" @@ -1257,7 +1257,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws PreconditionError o(; backend=backend) end test_name = "use case no. 1: simple example (mayer) ($backend_name, $scheme)" @@ -1314,7 +1314,7 @@ function __test_onepass_exa( @test size(getter(s; val=:control_u)) == (1, N + 1) @test size(getter(s; val=:variable_l)) == (0,) @test size(getter(s; val=:variable_u)) == (0,) - @test_throws String getter(s; val=:foo) + @test_throws CTBase.IncorrectArgument getter(s; val=:foo) end test_name = "use case no. 1: simple example (mayer), testing getters (2/2) ($backend_name, $scheme)" diff --git a/test/test_onepass_exa_bis.jl b/test/test_onepass_exa_bis.jl index 40b1903..6e4827a 100644 --- a/test/test_onepass_exa_bis.jl +++ b/test/test_onepass_exa_bis.jl @@ -208,7 +208,7 @@ function test_onepass_exa_bis() @test f_exa isa Function # Unknown backend should throw a String error - @test_throws String CTParser.parsing(:alias, :unknown_backend) + @test_throws CTBase.IncorrectArgument CTParser.parsing(:alias, :unknown_backend) # Unknown primitive for a valid backend should raise a KeyError @test_throws KeyError CTParser.parsing(:unknown_primitive, :fun) diff --git a/test/test_onepass_fun.jl b/test/test_onepass_fun.jl index c143d8c..6e7a23b 100644 --- a/test/test_onepass_fun.jl +++ b/test/test_onepass_fun.jl @@ -3147,10 +3147,10 @@ function test_onepass_fun() @test is_active_backend(:exa) deactivate_backend(:exa) @test !is_active_backend(:exa) - @test_throws String activate_backend(:fun) - @test_throws String deactivate_backend(:fun) - @test_throws String activate_backend(:foo) - @test_throws String deactivate_backend(:foo) + @test_throws PreconditionError activate_backend(:fun) + @test_throws PreconditionError deactivate_backend(:fun) + @test_throws CTBase.IncorrectArgument activate_backend(:foo) + @test_throws CTBase.IncorrectArgument deactivate_backend(:foo) end test_name = "dimensions at runtime" diff --git a/test/test_prefix_bis.jl b/test/test_prefix_bis.jl index 4739825..a1bd963 100644 --- a/test/test_prefix_bis.jl +++ b/test/test_prefix_bis.jl @@ -26,12 +26,12 @@ function test_prefix_bis() println("backend activation errors (bis)") # Unknown backend should throw an error (String from `throw("...")`) - @test_throws String activate_backend(:unknown_backend) - @test_throws String deactivate_backend(:unknown_backend) + @test_throws CTBase.IncorrectArgument activate_backend(:unknown_backend) + @test_throws CTBase.IncorrectArgument deactivate_backend(:unknown_backend) # :fun is always active, so trying to (de)activate it should fail - @test_throws String activate_backend(:fun) - @test_throws String deactivate_backend(:fun) + @test_throws PreconditionError activate_backend(:fun) + @test_throws PreconditionError deactivate_backend(:fun) end @testset "backend activation (twisted sequences)" begin @@ -55,7 +55,7 @@ function test_prefix_bis() @test is_active_backend(:exa) # is_active_backend on unknown backend should throw - @test_throws String is_active_backend(:unknown_backend) + @test_throws CTBase.IncorrectArgument is_active_backend(:unknown_backend) # Sanity check: :fun is never affected by toggling :exa @test is_active_backend(:fun) From ad4b988d029ecaa3bdc45f15a9dc6299a0a9a5e8 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 25 Aug 2026 18:58:15 +0200 Subject: [PATCH 06/20] test: qualify the ExaModels import to drop the constraint name clash test/runtests.jl did a bare `using ExaModels` while `constraint` was already imported from CTModels, so ExaModels' exported `constraint` landed in Main on top of it and every run opened with WARNING: using ExaModels.constraint in module Main conflicts with an existing identifier. Still true under ExaModels 0.12, where `constraint` remains exported for the oracle form. Now a qualified `using ExaModels: ExaModels`, which is also Handbook tenet 2. No call site needed changing: the test files already write ExaModels.x throughout. Full suite: 2580/2580, and the warning is gone from the log. Fixes #230. Refs #325 Co-Authored-By: Claude Opus 5 --- test/runtests.jl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/runtests.jl b/test/runtests.jl index e29d2aa..d58cc6f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -50,7 +50,11 @@ import CTModels: criterion, Model, get_build_examodel -using ExaModels +# Qualified: a bare `using ExaModels` brings its exported `constraint` into Main, where +# it clashes with the `constraint` imported from CTModels above and every :exa test run +# opens with "WARNING: using ExaModels.constraint in module Main conflicts with an +# existing identifier". Test files already write ExaModels.x throughout. Fixes #230. +using ExaModels: ExaModels using LinearAlgebra using MadNLP using MadNLPGPU From e185d6e1e4c5f43fd88b03bdcd1ad73115e37abb Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 25 Aug 2026 18:59:21 +0200 Subject: [PATCH 07/20] chore(release): 0.9.0, with CHANGELOG and BREAKING Leaves the beta series behind and aligns CTParser with the released ecosystem: CTBase 0.29.3, CTModels 0.18.0, CTSolvers 0.5.3, CTFlows 0.17.2. 0.9.0 rather than 0.8.18-beta because two public API changes land here: the :exa emission now requires ExaModels >= 0.12 (no shim is possible -- CTParser does not depend on ExaModels, so it cannot detect the version at macro-expansion time), and errors that used to be bare Strings are now CTException subtypes. The repository had neither CHANGELOG.md nor BREAKING.md, which the Handbook requires of every package. Created with its retroactive bootstrap: a baseline entry for v0.8.15 (2026-04-21, the last non-beta tag) pointing at git log for earlier history, then the full 0.9.0 entry. Both breaking changes appear in both files with # Before / # After migration blocks, and BREAKING.md carries non-breaking notes for the new extension and the compat bumps. Full suite green at 2580/2580 across all 13 files, no errors, no failures, and no name-clash warning. Docs build clean; the remaining warnings are pre-existing undocumented internals. Refs #325 Co-Authored-By: Claude Opus 5 --- BREAKING.md | 114 +++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 158 +++++++++++++++++++++++++++++++++++++++++++++++++++ Project.toml | 2 +- 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 BREAKING.md create mode 100644 CHANGELOG.md diff --git a/BREAKING.md b/BREAKING.md new file mode 100644 index 0000000..123c7cf --- /dev/null +++ b/BREAKING.md @@ -0,0 +1,114 @@ + +# Breaking Changes + +Breaking changes in CTParser releases, and how to migrate. Tracked from the 0.8.15 +baseline onward; see [CHANGELOG.md](CHANGELOG.md) for the full record. + +## [0.9.0] - 2026-08-25 + +### The `:exa` emission requires ExaModels ≥ 0.12 + +ExaModels 0.12 deleted `src/deprecated.jl` and with it the mutable builder API — +`variable`, `parameter`, `subexpr`, `constraint!`, `LegacyExaCore`. The code `@def` +generates for `backend = :exa` now uses the functional API instead, where `add_var` / +`add_con` / `add_obj` return `(new_core, result)` and the core is threaded through the +build. + +**Who is affected**: anyone pinning ExaModels below 0.12 while using the `:exa` backend. +Nothing else — `@def` problem definitions, the `:fun` backend, and the shape of the +`(model, getter)` pair `build_examodel` returns are all unchanged. + +There is no compatibility shim, and adding one is not practical: CTParser does not +depend on ExaModels (the module is reached through `prefix_exa()`), so it cannot detect +the version at macro-expansion time and pick an emission. + +**Migration**: raise your ExaModels bound. + +```toml +# Before +ExaModels = "0.9" + +# After +ExaModels = "0.12" +``` + +For reference, what changed in the generated code: + +```julia +# Before — emitted against ExaModels 0.9–0.11 +c = ExaModels.ExaCore(base_type; backend, minimize) +x = ExaModels.variable(c, n, 0:grid_size; lvar, uvar, start) +ExaModels.constraint(c, expr for j in 0:grid_size-1; lcon, ucon) +ExaModels.objective(c, expr for j in 0:grid_size-1) + +# After — emitted against ExaModels 0.12 +c = ExaModels.ExaCore(base_type; backend, minimize) # unchanged +c, x = ExaModels.add_var(c, n, 0:grid_size; lvar, uvar, start) +c, _ = ExaModels.add_con(c, expr for j in 0:grid_size-1; lcon, ucon) +c, _ = ExaModels.add_obj(c, expr for j in 0:grid_size-1) +``` + +### Untyped `String` errors are now `CTException` subtypes + +Seventeen sites in `src/onepass.jl` threw a bare `String`. Callers could not dispatch on +them, and `showerror` fell back to `show`, rendering the message wrapped in quotes rather +than the formatted block every other error in the ecosystem produces. + +**Who is affected**: code that catches these errors and compares the caught value to a +string, or that matches on `String`. + +| what | type now | +| --- | --- | +| unknown numerical scheme | `CTBase.IncorrectArgument` | +| lower/upper bound length mismatch | `CTBase.PreconditionError` | +| bound lengths vs. the constrained range | `CTBase.PreconditionError` | +| unknown value for the getter's `val` keyword | `CTBase.IncorrectArgument` | +| unknown parsing backend | `CTBase.IncorrectArgument` | +| `:fun` cannot be activated or deactivated | `CTBase.PreconditionError` | + +The split follows the Handbook's rule: a single argument's value out of domain is +`IncorrectArgument`; a relational, state or timing contract is `PreconditionError`. A +bound-length mismatch relates two things — the bounds to each other, or to the +constrained range — hence `PreconditionError`. `:fun` is a perfectly valid backend name; +what is forbidden is toggling it, which is a state contract, not a bad value. + +**Migration**: + +```julia +# Before +try + solve(ocp, :exa; scheme=:gauss_legendre_2) +catch e + e == "unknown numerical scheme: gauss_legendre_2 (possible choices are ...)" +end + +# After +try + solve(ocp, :exa; scheme=:gauss_legendre_2) +catch e + e isa CTBase.IncorrectArgument +end +``` + +`@test_throws String` in a downstream test suite becomes `@test_throws +CTBase.IncorrectArgument` or `@test_throws CTBase.PreconditionError`, per the table. + +## Non-breaking note (0.9.0) + +- **New `CTParserExaModels` extension.** Additive, and triggered only when both + `ExaModels` and `LinearAlgebra` are loaded — the main module gains no dependency. It + restores the linear algebra on ExaModels expression nodes that a dynamics like + `∂(x)(t) == A * x(t) + B * u(t)` needs, which ExaModels 0.12 ships but never registers + in its own `[extensions]`. **No migration required.** It is temporary: when upstream + wires its extension up ([madsuite-org/ExaModels.jl#323](https://github.com/madsuite-org/ExaModels.jl/issues/323)), + the file and the two weak dependencies go away, with no user-visible change either way. + +- **Compat bounds** raised for CTBase (`0.29`), CTModels (`0.18`), OrderedCollections + (`1, 2`) and Parameters (`0.13`), and widened for CUDA (`5, 6`), MadNLP (`0.9, 0.10`) + and MadNLPGPU (`0.8, 0.10`). **No breaking change**: none of these required a source + change, and the `:fun` test groups stayed green at 1054/1054 across the bump. + +## [0.8.15] - 2026-04-21 — baseline + +Reference version. Breaking changes are tracked from here onward; use `git log` for +earlier history. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..024656d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,158 @@ + +# Changelog + +All notable changes to CTParser will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.9.0] - 2026-08-25 + +Aligns CTParser with the released ecosystem — CTBase 0.29.3, CTModels 0.18.0, +CTSolvers 0.5.3, CTFlows 0.17.2 — and leaves the beta series behind. + +CTSolvers 0.5.3 already declared `ExaModels = "0.12"`, but it only reads backend +metadata from ExaModels. The package that *emits* the builder calls is CTParser, from +`def_exa` in `src/onepass.jl`, and ExaModels 0.12 deleted the mutable builder API those +calls used. Until this release, that declared 0.12 support was nominal: any `:exa` solve +failed at run time with `UndefVarError: variable not defined in ExaModels`. + +### 💥 Breaking Changes + +#### The `:exa` emission requires ExaModels ≥ 0.12 + +ExaModels 0.12 removed `variable`, `parameter`, `subexpr`, `constraint!` and +`LegacyExaCore` along with `src/deprecated.jl`. The replacement is functional: +`add_var` / `add_con` / `add_obj` each return `(new_core, result)`, so the code `@def` +generates now threads and rebinds the core. + +Supporting both APIs is not practical. CTParser does not depend on ExaModels — the +module is reached through `prefix_exa()` — so it cannot branch on the version at +macro-expansion time. + +**Migration**: none for `@def` users, whose problem definitions are unchanged. Callers +pinning ExaModels must move to 0.12. + +```julia +# Before — emitted against ExaModels 0.9–0.11 +c = ExaModels.ExaCore(base_type; backend, minimize) +x = ExaModels.variable(c, n, 0:grid_size; lvar, uvar, start) +ExaModels.constraint(c, expr for j in 0:grid_size-1; lcon, ucon) +ExaModels.objective(c, expr for j in 0:grid_size-1) + +# After — emitted against ExaModels 0.12 +c = ExaModels.ExaCore(base_type; backend, minimize) # unchanged +c, x = ExaModels.add_var(c, n, 0:grid_size; lvar, uvar, start) +c, _ = ExaModels.add_con(c, expr for j in 0:grid_size-1; lcon, ucon) +c, _ = ExaModels.add_obj(c, expr for j in 0:grid_size-1) +``` + +`ExaCore` itself is called exactly as before. Under 0.12 it no longer warns — the +deprecation shim is gone — so no `concrete` keyword is passed. That is deliberate: with +the 0.12 default (`Vector{Any}` block storage) `typeof(core)` is invariant across every +`add_*`, so rebinding the core costs nothing, whereas `concrete = Val(true)` changes the +core's type on each `add_*` and would recompile the builder once per block. + +See [BREAKING.md](BREAKING.md). + +#### Untyped `String` errors are now `CTException` subtypes + +Seventeen sites in `src/onepass.jl` threw a bare `String`, so `typeof(e) === String` and +callers had nothing typed to catch. They now throw the type the Handbook's choice rule +selects — a single argument's value out of domain is `IncorrectArgument`; a relational, +state or timing contract is `PreconditionError`: + +| what | type | +| --- | --- | +| unknown numerical scheme | `IncorrectArgument` | +| lower/upper bound length mismatch | `PreconditionError` | +| bound lengths vs. the constrained range | `PreconditionError` | +| unknown value for the getter's `val` keyword | `IncorrectArgument` | +| unknown parsing backend | `IncorrectArgument` | +| `:fun` cannot be activated or deactivated | `PreconditionError` | + +Each carries `got`/`expected` (or `reason`) and a `suggestion`, so `showerror` renders +the `Reason / Context / Hint` block every other error in the ecosystem produces, instead +of the message wrapped in quotes. + +**Migration**: + +```julia +# Before +catch e + e == "unknown numerical scheme: gauss_legendre_2 (possible choices are ...)" +end + +# After +catch e + e isa CTBase.IncorrectArgument +end +``` + +See [BREAKING.md](BREAKING.md). + +### ✨ New Features + +- **`CTParserExaModels` extension** — linear algebra on ExaModels expression nodes + (`dot`, `*` on node vectors and matrices, `det`, `norm`, `tr`, `diag`, `cross`, + `convert`/`promote_rule`/`zero`/`one` for `AbstractNode`, and `Null` zero/one + elimination), triggered by `ExaModels` + `LinearAlgebra` weak dependencies. It makes a + dynamics written as `∂(x)(t) == A * x(t) + B * u(t)`, or an objective using + `dot(q, x(t))`, work again under ExaModels 0.12. + + This is a **temporary** port of ExaModels' own `ext/ExaModelsOptimalControl.jl`, which + ships in the package but is never declared in its `[extensions]`, so Julia never loads + it. Upstream question: [madsuite-org/ExaModels.jl#323](https://github.com/madsuite-org/ExaModels.jl/issues/323). + Delete the file and the two weak dependencies once upstream wires its own up. + +### 🐛 Bug Fixes + +- **Unknown discretisation schemes are catchable** ([#322](https://github.com/control-toolbox/CTParser.jl/issues/322)). +- **No deprecation warning from `ExaCore`** ([#323](https://github.com/control-toolbox/CTParser.jl/issues/323)). + Fixed by the ExaModels upgrade itself, not by the `concrete = Val(true)` the issue + suggested — under 0.12 the current call emits nothing. The `with_logger(NullLogger())` + workaround can come out of the OptimalControl documentation + ([OptimalControl.jl#877](https://github.com/control-toolbox/OptimalControl.jl/issues/877)). +- **No name clash on `constraint` in the test runner** ([#230](https://github.com/control-toolbox/CTParser.jl/issues/230)). + `test/runtests.jl` used a bare `using ExaModels` while `constraint` was imported from + CTModels, so every run opened with `WARNING: using ExaModels.constraint in module Main + conflicts with an existing identifier`. Now a qualified `using ExaModels: ExaModels`. + +### 📦 Dependencies + +| | from | to | +| --- | --- | --- | +| `CTBase` | `0.18, 0.27, 0.28` | `0.29` | +| `CTModels` (test) | `0.10, 0.14, 0.15` | `0.18` | +| `ExaModels` (test) | `0.9` | `0.12` | +| `CUDA` (test) | `5` | `5, 6` | +| `MadNLP` (test) | `0.9` | `0.9, 0.10` | +| `MadNLPGPU` (test) | `0.8` | `0.8, 0.10` | +| `OrderedCollections` | `1` | `1, 2` | +| `Parameters` | `0.12, 0.13` | `0.13` | + +CUDA, MadNLP and MadNLPGPU keep their lower bound, mirroring CTSolvers 0.5.3, so the +GitHub-hosted runners are not forced onto a CUDA 6 resolve. The CTBase, CTModels and +OrderedCollections bumps needed no source change. + +### 🧪 Testing + +- The 19 `@test_throws String` assertions now assert the concrete exception type. +- `test/test_exa_linalg.jl` migrated off the removed `ExaModels.variable`. + +### 🔄 Refactoring + +- **CI**: the retired self-hosted `kkt` runner replaced by `occidata`, and the trigger + labels renamed to the ecosystem's `run ci ` form. + +### ✅ Compatibility + +- **Breaking**: see the two entries above and [BREAKING.md](BREAKING.md). Problem + definitions written with `@def` are unaffected; the breaks are the ExaModels floor and + the exception types. + +## [0.8.15] - 2026-04-21 — baseline + +This is the reference version. No changelog was maintained before this point; use +`git log` for earlier history. Breaking changes from this version onward are tracked in +[BREAKING.md](BREAKING.md). diff --git a/Project.toml b/Project.toml index 33567d3..98ca580 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "CTParser" uuid = "32681960-a1b1-40db-9bff-a1ca817385d1" -version = "0.8.17-beta" +version = "0.9.0" authors = ["Jean-Baptiste Caillau "] [deps] From f02aedce915ff0638129c96672bc6649724c4bee Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 25 Aug 2026 21:21:30 +0200 Subject: [PATCH 08/20] test: load CUDSS, which MadNLPGPU 0.10 no longer pulls in The occidata GPU job failed with 48 errors, all the same: MadNLPGPU: cannot build a GPU sparse KKT system because the GPU backend extension is not loaded. For CUDA, the extension activates only once both the CUDA backend and CUDSS are loaded -- add `using CUDSS` ... before solving a model with GPU arrays. Nothing to do with the ExaModels migration. MadNLPGPU moved CUDSS from [deps] to [weakdeps] between 0.8 and 0.10: its CUDA extension now triggers on ["CUDACore", "CUDSS", "cuBLAS", "cuSOLVER", "cuSPARSE"], so CUDSS stopped arriving transitively and the consumer has to load it. Added to test/Project.toml and to the runner, with the compat range CTSolvers 0.5.3 already uses. There was a second effect worth recording. With CUDSS absent, nothing constrained GPUToolbox, so it resolved to 3.0.0 and dragged CUDA to 6.3.0. CUDSS 0.6+ declares GPUToolbox = ["0.3", "1"], so simply adding CUDSS to a manifest already pinned that way is unsatisfiable. Resolving the test environment from scratch settles on CUDA 6.2.0 + CUDSS 0.8.0 + GPUToolbox 1.1.1, which is consistent -- so the CUDA = "5, 6" bound stays as it is; it was never the problem. Invisible locally: CUDA.functional() is false on a CPU-only machine, so every GPU path is skipped and the CPU suite passed 2580/2580 without ever touching this. `using CUDSS` itself loads fine without a GPU, so the GitHub-hosted runners are unaffected. Refs #325 Co-Authored-By: Claude Opus 5 --- test/Project.toml | 2 ++ test/runtests.jl | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/test/Project.toml b/test/Project.toml index c6b28c0..521b05f 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -4,6 +4,7 @@ BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd" CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d" CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" +CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e" ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2" Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c" @@ -20,6 +21,7 @@ BenchmarkTools = "1" CTBase = "0.29" CTModels = "0.18" CUDA = "5, 6" +CUDSS = "0.6, 0.7, 0.8" ExaModels = "0.12" Interpolations = "0.16" KernelAbstractions = "0.9" diff --git a/test/runtests.jl b/test/runtests.jl index d58cc6f..000fb60 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -59,6 +59,13 @@ using LinearAlgebra using MadNLP using MadNLPGPU using CUDA +# MadNLPGPU 0.10 moved CUDSS from [deps] to [weakdeps]: its CUDA extension now triggers +# on ["CUDACore", "CUDSS", "cuBLAS", "cuSOLVER", "cuSPARSE"], so CUDSS no longer arrives +# transitively and the consumer must load it. Without this, every GPU solve dies with +# "MadNLPGPU: cannot build a GPU sparse KKT system because the GPU backend extension is +# not loaded". Invisible on a CPU-only machine, where CUDA.functional() is false and the +# GPU paths never run. +using CUDSS: CUDSS using BenchmarkTools using Interpolations using NLPModels From dcf4995194765392ccf18a86fcfd33084dfdf660 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 25 Aug 2026 22:37:12 +0200 Subject: [PATCH 09/20] test: qualify PreconditionError for consistency with IncorrectArgument The Phase C commit typed 11 assertions across these four files: some as CTBase.IncorrectArgument (never imported bare, so qualification was mandatory) and some as a bare PreconditionError, which happens to resolve because runtests.jl still does `import CTBase: CTBase, ParsingError, PreconditionError`. Two names for the same kind of thing read inconsistently side by side. Qualified every @test_throws PreconditionError in the four files to CTBase.PreconditionError, including three sites the Phase C commit did not touch (the pre-existing @def-detects-a-precondition-violation assertions in test_onepass_fun.jl) -- purely cosmetic there, since bare PreconditionError already meant CTBase.PreconditionError via the same import; qualifying it changes nothing at run time. Re-ran all four groups: 1452/1452. Refs #325 Co-Authored-By: Claude Opus 5 --- test/test_onepass_exa.jl | 14 +++++++------- test/test_onepass_fun.jl | 10 +++++----- test/test_prefix_bis.jl | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/test/test_onepass_exa.jl b/test/test_onepass_exa.jl index 132179e..580fa0d 100644 --- a/test/test_onepass_exa.jl +++ b/test/test_onepass_exa.jl @@ -783,7 +783,7 @@ function __test_onepass_exa( -1 ≤ x₂(0) + x₁(tf) + tf ≤ [1, 2] x₁(0) + 2cos(x₂(tf)) → min end - @test_throws PreconditionError o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) o = @def_exa begin tf ∈ R, variable @@ -1140,7 +1140,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws PreconditionError o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "state bounds test" @@ -1159,7 +1159,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws PreconditionError o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "control bounds test" @@ -1179,7 +1179,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws PreconditionError o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "path bounds test" @@ -1199,7 +1199,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws PreconditionError o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "path bounds test" @@ -1238,7 +1238,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws PreconditionError o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "final bounds test" @@ -1257,7 +1257,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws PreconditionError o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "use case no. 1: simple example (mayer) ($backend_name, $scheme)" diff --git a/test/test_onepass_fun.jl b/test/test_onepass_fun.jl index 6e7a23b..901f93d 100644 --- a/test/test_onepass_fun.jl +++ b/test/test_onepass_fun.jl @@ -2746,7 +2746,7 @@ function test_onepass_fun() # this one is detected by the generated code (and not the parser) t0 = 9.0 tf = 9.1 - @test_throws PreconditionError @def o begin + @test_throws CTBase.PreconditionError @def o begin t ∈ [t0, tf], time t ∈ [t0, tf], time end @@ -3026,7 +3026,7 @@ function test_onepass_fun() x1(0) → min end - @test_throws PreconditionError @def begin + @test_throws CTBase.PreconditionError @def begin t ∈ [0, 1], time x ∈ R², state u ∈ R², control @@ -3034,7 +3034,7 @@ function test_onepass_fun() x1(0) → min end - @test_throws PreconditionError @def begin + @test_throws CTBase.PreconditionError @def begin t ∈ [0, 1], time x ∈ R³, state u ∈ R², control @@ -3147,8 +3147,8 @@ function test_onepass_fun() @test is_active_backend(:exa) deactivate_backend(:exa) @test !is_active_backend(:exa) - @test_throws PreconditionError activate_backend(:fun) - @test_throws PreconditionError deactivate_backend(:fun) + @test_throws CTBase.PreconditionError activate_backend(:fun) + @test_throws CTBase.PreconditionError deactivate_backend(:fun) @test_throws CTBase.IncorrectArgument activate_backend(:foo) @test_throws CTBase.IncorrectArgument deactivate_backend(:foo) end diff --git a/test/test_prefix_bis.jl b/test/test_prefix_bis.jl index a1bd963..ad50982 100644 --- a/test/test_prefix_bis.jl +++ b/test/test_prefix_bis.jl @@ -30,8 +30,8 @@ function test_prefix_bis() @test_throws CTBase.IncorrectArgument deactivate_backend(:unknown_backend) # :fun is always active, so trying to (de)activate it should fail - @test_throws PreconditionError activate_backend(:fun) - @test_throws PreconditionError deactivate_backend(:fun) + @test_throws CTBase.PreconditionError activate_backend(:fun) + @test_throws CTBase.PreconditionError deactivate_backend(:fun) end @testset "backend activation (twisted sequences)" begin From 28562bb9502eeb5b8030c0b27d0b09cffec1b816 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Wed, 26 Aug 2026 10:19:58 +0200 Subject: [PATCH 10/20] beta version --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 98ca580..63cd074 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "CTParser" uuid = "32681960-a1b1-40db-9bff-a1ca817385d1" -version = "0.9.0" +version = "0.9.0-beta" authors = ["Jean-Baptiste Caillau "] [deps] From ef0a29094de7945e344d51718a92b1a91e295657 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Wed, 26 Aug 2026 13:33:46 +0200 Subject: [PATCH 11/20] test: loosen use case 8's atol on GPU, tightened past the solver's own tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.9.0-beta tag push (triggered by main's on.push.tags CI trigger) exercised the occidata GPU job on a commit one line away from a fully green run (dcf4995 -> 28562bb, only Project.toml's version string changed), and turned up one failure: use case no. 8: vectorised dynamics (GPU, midpoint): Test Failed Expression: ≈(obj1 - obj2, 0, atol = __atol) Evaluated: 5.722384364137412e-7 ≈ 0 (atol=1.0e-9) Not a regression from this branch. The test dates from 2025-12-21 (f5ab71fd, git blame), long before this PR, and it compares the objective of two INDEPENDENTLY converged MadNLP solves -- a vectorised formulation built from A[i,:]' * x(t) dot products, and a hand-unrolled scalar one -- at atol=1e-9, which is already tighter than the solver's own convergence tolerance (`tol=tolerance`, 1e-8 by default). Two separate solves are only mathematically guaranteed to agree to the solver's own tolerance, not machine precision; CPU happened to satisfy 1e-9 anyway because its floating-point summation order is deterministic and matches between runs, while GPU's parallel reduction order does not. There is already a precedent for this exact adjustment in the same file (line ~1900, `__atol = 1e-3 # otherwise would just work for midpoint`) for the same class of comparison. occidata had never reached this test before: the job was first blocked by the ExaModels 0.12 API break (Phase B), then by MadNLPGPU no longer pulling in CUDSS (f02aedc). This is the first CI run in which it ever ran to completion. __atol is now backend-dependent: unchanged at 1e-9 on CPU (verified: onepass_exa 596/596, identical to before this commit, since the ternary evaluates to the same branch), loosened to 1e-5 on GPU -- about 17x the observed 5.7e-7, comfortable margin without hiding an actual regression. Refs #325 Co-Authored-By: Claude Opus 5 --- test/test_onepass_exa.jl | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/test_onepass_exa.jl b/test/test_onepass_exa.jl index 580fa0d..4fad750 100644 --- a/test/test_onepass_exa.jl +++ b/test/test_onepass_exa.jl @@ -1803,7 +1803,12 @@ function __test_onepass_exa( sol2 = madnlp(m2; tol=tolerance, max_iter=max_iter, kwargs...) obj2 = sol2.objective - __atol = 1e-9 + # GPU floating-point reduction order differs from CPU's for the A[i,:]' * x(t) + # dot products this case is built from (LinearAlgebra.dot, ported in + # ext/CTParserExaModels.jl), so obj1 and obj2 -- two independently converged + # MadNLP solves -- agree only to the solver's own tolerance on GPU, not to + # machine precision as they happen to on CPU. + __atol = backend_name == "GPU" ? 1e-5 : 1e-9 @test obj1 - obj2 ≈ 0 atol = __atol end From 6b2a5dc7eec3a3980f948aef929f901555ab8014 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Thu, 27 Aug 2026 15:02:51 +0200 Subject: [PATCH 12/20] fix: avoid misleading parser lines for structured errors Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/onepass.jl | 26 ++++++++++++++++---------- test/test_onepass_exa.jl | 27 +++++++++++++++++++++++++++ test/test_onepass_fun_bis.jl | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/onepass.jl b/src/onepass.jl index 5d43147..b14a626 100644 --- a/src/onepass.jl +++ b/src/onepass.jl @@ -176,18 +176,24 @@ end """ $(TYPEDSIGNATURES) -Wrap a block of generated code with line-aware error reporting. +Wrap a block of generated code with selective line-aware error reporting. -The returned expression executes `e` inside a `try/catch` block and, in -case of an exception, prints the originating line number and source -text before rethrowing. +The returned expression executes `e` inside a `try/catch` block. Structured +`CTBase.CTException` values are rethrown unchanged because they already carry +their own diagnostic information; other exceptions are preceded by the +originating line number and source text before being rethrown. """ -__wrap(e, n, line) = quote - try - $e - catch - println("Line ", $n, ": ", $line) - rethrow() +__wrap(e, n, line) = begin + ct_exception = CTBase.CTException + return quote + try + $e + catch err + if !(err isa $ct_exception) + println("Line ", $n, ": ", $line) + end + rethrow() + end end end diff --git a/test/test_onepass_exa.jl b/test/test_onepass_exa.jl index 4fad750..07578ce 100644 --- a/test/test_onepass_exa.jl +++ b/test/test_onepass_exa.jl @@ -4,6 +4,33 @@ activate_backend(:exa) # nota bene: needs to be executed before @def are expanded function test_onepass_exa() + @testset "unsupported scheme does not print a misleading source line" begin + o = @def begin + t ∈ [0, 1], time + x ∈ R, state + u ∈ R, control + ∂(x)(t) == u(t) + ∫(u(t)^2) → min + end + + output = Pipe() + err = Base.redirect_stdout(() -> begin + try + discretise_exa(o; scheme=:gauss_legendre_2) + catch caught + caught + end + end, output) + close(output.in) + captured = read(output.out, String) + + @test err isa CTBase.IncorrectArgument + @test err.got == "gauss_legendre_2" + @test err.expected == ":euler, :euler_implicit, :midpoint or :trapeze" + @test err.suggestion == "pass one of the supported schemes to the :exa backend" + @test !occursin("Line ", captured) + end + l_scheme = [:euler, :euler_implicit, :midpoint, :trapeze] #l_scheme = [:midpoint] for scheme in l_scheme diff --git a/test/test_onepass_fun_bis.jl b/test/test_onepass_fun_bis.jl index 7f9a90b..433281f 100644 --- a/test/test_onepass_fun_bis.jl +++ b/test/test_onepass_fun_bis.jl @@ -17,7 +17,40 @@ function test_onepass_fun_bis() # __wrap should catch and rethrow the original exception wrapped_err = CTParser.__wrap(:(error("boom")), 1, "line") @test wrapped_err isa Expr - @test_throws ErrorException eval(wrapped_err) + output = Pipe() + err = Base.redirect_stdout(() -> begin + try + eval(wrapped_err) + catch caught + caught + end + end, output) + close(output.in) + captured = read(output.out, String) + @test err isa ErrorException + @test occursin("Line 1: line", captured) + + # Structured CTBase exceptions already carry their own diagnostic context + structured = CTBase.IncorrectArgument( + "invalid scheme"; + got="gauss_legendre_2", + expected=":euler or :midpoint", + ) + wrapped_structured = CTParser.__wrap(:(throw($structured)), 6, "dynamics") + output = Pipe() + err = Base.redirect_stdout(() -> begin + try + eval(wrapped_structured) + catch caught + caught + end + end, output) + close(output.in) + captured = read(output.out, String) + @test err === structured + @test isempty(captured) + @test err.got == "gauss_legendre_2" + @test err.expected == ":euler or :midpoint" end @testset "p_dynamics! precondition errors" begin From edb51ece2d10e7baef00612af1ce4475c35158a1 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Thu, 27 Aug 2026 22:19:18 +0200 Subject: [PATCH 13/20] chore(release): prepare 0.9.1-beta Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- BREAKING.md | 7 +++++++ CHANGELOG.md | 6 ++++++ Project.toml | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/BREAKING.md b/BREAKING.md index 123c7cf..4a4594f 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -4,6 +4,13 @@ Breaking changes in CTParser releases, and how to migrate. Tracked from the 0.8.15 baseline onward; see [CHANGELOG.md](CHANGELOG.md) for the full record. +## [0.9.1-beta] - 2026-08-26 + +No breaking changes in this release. + +- Structured CTBase errors no longer print an unrelated `Line n: ...` message on + `stdout`; their existing structured diagnostics and exception types are unchanged. + ## [0.9.0] - 2026-08-25 ### The `:exa` emission requires ExaModels ≥ 0.12 diff --git a/CHANGELOG.md b/CHANGELOG.md index 024656d..d2176a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to CTParser will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.1-beta] - 2026-08-26 + +### 🐛 Bug Fixes + +- **Structured errors no longer print a misleading source line** ([#338](https://github.com/control-toolbox/CTParser.jl/issues/338)). Errors such as an unsupported ExaModels `scheme` are propagated with their structured diagnostic without an unrelated `Line n: ...` message on `stdout`. + ## [0.9.0] - 2026-08-25 Aligns CTParser with the released ecosystem — CTBase 0.29.3, CTModels 0.18.0, diff --git a/Project.toml b/Project.toml index 63cd074..391ebd0 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "CTParser" uuid = "32681960-a1b1-40db-9bff-a1ca817385d1" -version = "0.9.0-beta" +version = "0.9.1-beta" authors = ["Jean-Baptiste Caillau "] [deps] From 94351c5a199a100c31c8c5b091d3ac47332d3a6d Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sat, 29 Aug 2026 10:07:22 +0200 Subject: [PATCH 14/20] test: detect the kkt/occidata GPU runners and skip visibly The suite had no notion of which runner it was executing on. The two :exa GPU tiers were gated behind a bare short-circuit on CUDA.functional(), so a correctly-skipped run on a developer machine and a silently-broken one on a GPU runner produced the same output: a green run with the GPU tier absent. test/runtests.jl now holds the single capability module the Handbook asks for (philosophy/testing.md, "Capability-gated tests"), mirroring CTSolvers: CUDA_FUNCTIONAL, ON_GPU_RUNNER and GPU_SOLVER_ARMED. ON_GPU_RUNNER matches the kkt / occidata substring of RUNNER_NAME -- the self-hosted runners are registered as kkt-runner / occidata-runner, whereas the CI.yml runs_on label is the bare kkt / occidata -- so a missing device fails loudly on either. RUNNER_NAME is set by the GitHub Actions runner agent itself, so no CI.yml or CTActions change is needed. The two GPU tiers now branch to Test.@test_skip, showing as Broken in the summary, and test/test_environment_contract.jl enforces the contract: the MadNLPGPU/CUDSS extension must be armed on every runner, a device must be present on the GPU runners, and the silent-guard anti-pattern must not reappear under test/. GPU_SOLVER_ARMED uses isdefined rather than CTSolvers' `CUDSSSolver isa Type`: the symbol only exists once MadNLPGPUCUDAExt loads, and an UndefVarError at module load would abort the run instead of failing one assertion. Closes #339. Co-Authored-By: Claude Opus 5 --- test/runtests.jl | 30 +++++++++ test/test_dynamics_exa.jl | 11 +++- test/test_environment_contract.jl | 101 ++++++++++++++++++++++++++++++ test/test_onepass_exa.jl | 10 ++- 4 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 test/test_environment_contract.jl diff --git a/test/runtests.jl b/test/runtests.jl index 000fb60..40e79fe 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -70,6 +70,36 @@ using BenchmarkTools using Interpolations using NLPModels +# Capability constants, computed once, here. `CUDA_FUNCTIONAL` is the suite's single +# CUDA-device predicate — never write a bare `CUDA.functional()` guard in a test file +# (duplicated copies drift; Handbook philosophy/testing.md §"Capability-gated tests"). +# `ON_GPU_RUNNER` turns the device tier from *skipped* into *required* on the self-hosted +# GPU runners: `RUNNER_NAME` is set by the GitHub Actions runner agent itself (no CI.yml +# or CTActions change needed) to the runner's *registered* name. Ours are registered as +# `kkt-runner` / `occidata-runner` — the CI.yml `runs_on` label is the bare +# `kkt`/`occidata`, a different string — so match on the substring to survive the +# `-runner` suffix. Enforcement lives centrally in test/test_environment_contract.jl. +module TestCapabilities +using CUDA: CUDA +using CUDSS: CUDSS # with CUDA, arms MadNLPGPUCUDAExt +using MadNLPGPU: MadNLPGPU + +const CUDA_FUNCTIONAL = CUDA.functional() +const ON_GPU_RUNNER = any( + gpu -> occursin(gpu, get(ENV, "RUNNER_NAME", "")), ("kkt", "occidata") +) +# `isdefined`, not CTSolvers' `MadNLPGPU.CUDSSSolver isa Type`: the symbol only exists +# once MadNLPGPUCUDAExt loads, and an UndefVarError at module load would abort the whole +# run instead of failing one assertion. +const GPU_SOLVER_ARMED = isdefined(MadNLPGPU, :CUDSSSolver) +end + +if TestCapabilities.CUDA_FUNCTIONAL + println("✓ CUDA functional, GPU tests enabled") +else + println("⚠️ CUDA not functional, GPU device tests will be skipped (Test.@test_skip)") +end + include("utils.jl") # Controls nested testset output formatting (used by individual test files) diff --git a/test/test_dynamics_exa.jl b/test/test_dynamics_exa.jl index 22096f1..60b6b6e 100644 --- a/test/test_dynamics_exa.jl +++ b/test/test_dynamics_exa.jl @@ -9,7 +9,16 @@ function test_dynamics_exa() l_scheme = [:euler, :euler_implicit, :midpoint, :trapeze] for scheme in l_scheme __test_dynamics_exa(; scheme=scheme) - CUDA.functional() && __test_dynamics_exa(CUDABackend(); scheme=scheme) + # Visible skip: short-circuiting on the raw device predicate makes a correctly-skipped + # run (no device, as expected on a developer machine) and a silently-broken one (device + # missing on a GPU runner) look identical. See test_environment_contract.jl. + if Main.TestCapabilities.CUDA_FUNCTIONAL + __test_dynamics_exa(CUDABackend(); scheme=scheme) + else + @testset "vector-form dynamics (GPU, $scheme)" begin + @test_skip "GPU dynamics tests need a functional CUDA device" + end + end end end diff --git a/test/test_environment_contract.jl b/test/test_environment_contract.jl new file mode 100644 index 0000000..1bfc5f9 --- /dev/null +++ b/test/test_environment_contract.jl @@ -0,0 +1,101 @@ +module TestEnvironmentContract + +using Test: Test + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# Central enforcement of the Handbook's capability-gated-test contract (philosophy/testing.md +# §"Capability-gated tests"), the CTParser side of control-toolbox/CTParser.jl#339. CTSolvers +# carries the same file at test/suite/environment/test_environment_contract.jl; two +# deliberate differences: +# +# - its companion `isdefined(Main, ...)` audit is not ported. That audit exists because +# every CTSolvers suite file is wrapped in its own module, which makes such a check +# always false. CTParser's test files are a mix of module-wrapped (this one, +# test_control_zero.jl) and flat files included straight into `Main`, where the idiom is +# legitimate, so the audit would flag correct code. +# - the silent-guard audit below is stricter than CTSolvers' `if`-anchored regex, because +# the anti-pattern CTParser actually had was the short-circuit `CUDA.functional() && ...`, +# which an `if`-anchored pattern does not see. + +""" + _silent_cuda_guard_offenders() + +Find, among the test files next to this one (`@__DIR__`, not `pwd()`, so the caller's +working directory does not matter), every mention of a raw CUDA-device predicate — a local +`is_cuda_on()` or a bare `CUDA.functional()` call. + +The suite has exactly one such predicate, `Main.TestCapabilities.CUDA_FUNCTIONAL`, computed +in `test/runtests.jl`. Any other copy is the anti-pattern this file exists to catch: it +makes a correctly-skipped run (no device, as expected on a CPU/developer machine) and a +silently-broken one (device *should* be present but isn't) produce the same output — a +green testset with zero assertions. The fix is +`if Main.TestCapabilities.CUDA_FUNCTIONAL ... else Test.@test_skip ... end`, with the +device tier made *required* on the GPU runners by the testset below. + +Two files are excluded from the walk: `runtests.jl`, which legitimately holds the single +definition and the comments naming it, and this file, which necessarily spells out the very +pattern it searches for. +""" +function _silent_cuda_guard_offenders() + test_dir = @__DIR__ + excluded = ("runtests.jl", basename(@__FILE__)) + offenders = Tuple{String,Int,String}[] + # Assembled from two literals so this line does not match itself. + pattern = Regex("(is_cuda_on\\(\\)|CUDA" * "\\.functional\\(\\))") + for f in sort(readdir(test_dir)) + (endswith(f, ".jl") && f ∉ excluded) || continue + for (lineno, line) in enumerate(eachline(joinpath(test_dir, f))) + if match(pattern, line) !== nothing + push!(offenders, (f, lineno, strip(line))) + end + end + end + return offenders +end + +function test_environment_contract() + Test.@testset "Test-environment contract" verbose = VERBOSE showtiming = SHOWTIMING begin + Test.@testset "GPU solver extension is armed" begin + # Runs on every runner, CPU laptops included: "armed" comes from packages being + # loaded (test/Project.toml + the `using`s in runtests.jl), not from a driver + # being present. This is the assertion that catches the CUDSS wiring regression + # — MadNLPGPU 0.10 moved CUDSS to [weakdeps], and without it every GPU solve + # dies with "cannot build a GPU sparse KKT system because the GPU backend + # extension is not loaded", invisibly on a CPU-only machine. + Test.@test Main.TestCapabilities.GPU_SOLVER_ARMED + end + + Test.@testset "GPU driver required on the GPU runner" begin + # On a machine that is supposed to have a GPU, a missing or broken device fails + # loudly here rather than being silently skipped everywhere else. + # + # `RUNNER_NAME` is set automatically by the GitHub Actions runner agent itself + # (no .github/workflows/CI.yml or CTActions change needed) to the runner's + # *registered* name — `kkt-runner` / `occidata-runner` for our self-hosted GPU + # runners, where the CI.yml `runs_on` label is the bare `kkt` / `occidata`. + # `ON_GPU_RUNNER` (test/runtests.jl) matches on the substring, so it survives + # the `-runner` suffix; if a runner is renamed past that, this check stops + # firing silently rather than failing loudly. + if Main.TestCapabilities.ON_GPU_RUNNER + Test.@test Main.TestCapabilities.CUDA_FUNCTIONAL + else + Test.@test_skip "a CUDA device is only required on the kkt/occidata runners" + end + end + + Test.@testset "silent CUDA-guard anti-pattern has not returned" begin + offenders = _silent_cuda_guard_offenders() + Test.@test isempty(offenders) + for (file, lineno, text) in offenders + @warn "silent CUDA guard at $file:$lineno — read Main.TestCapabilities.CUDA_FUNCTIONAL and give it a Test.@test_skip else-branch" text + end + end + end +end + +end # module + +# CRITICAL: Redefine in outer scope for TestRunner +test_environment_contract() = TestEnvironmentContract.test_environment_contract() diff --git a/test/test_onepass_exa.jl b/test/test_onepass_exa.jl index 07578ce..60c86a9 100644 --- a/test/test_onepass_exa.jl +++ b/test/test_onepass_exa.jl @@ -35,8 +35,16 @@ function test_onepass_exa() #l_scheme = [:midpoint] for scheme in l_scheme __test_onepass_exa(; scheme=scheme, print_level=MadNLP.WARN) - CUDA.functional() && + # Visible skip: short-circuiting on the raw device predicate makes a correctly-skipped + # run (no device, as expected on a developer machine) and a silently-broken one (device + # missing on a GPU runner) look identical. See test_environment_contract.jl. + if Main.TestCapabilities.CUDA_FUNCTIONAL __test_onepass_exa(CUDABackend(); scheme=scheme, print_level=MadNLP.WARN) + else + @testset "onepass exa (GPU, $scheme)" begin + @test_skip "GPU :exa solves need a functional CUDA device" + end + end end end From faa3f20664ee57473fe1c7ac990ca7dd269f9649 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sat, 29 Aug 2026 10:07:22 +0200 Subject: [PATCH 15/20] chore(release): prepare 0.9.2-beta Test-suite and metadata only; src/ and ext/ are untouched. Co-Authored-By: Claude Opus 5 --- BREAKING.md | 10 ++++++++++ CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ Project.toml | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/BREAKING.md b/BREAKING.md index 4a4594f..eb12a40 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -4,6 +4,16 @@ Breaking changes in CTParser releases, and how to migrate. Tracked from the 0.8.15 baseline onward; see [CHANGELOG.md](CHANGELOG.md) for the full record. +## [0.9.2-beta] - 2026-08-28 + +No breaking changes in this release. + +- Test-suite and release metadata only ([#339](https://github.com/control-toolbox/CTParser.jl/issues/339)): + GPU runner capability detection now recognises both the `kkt` and `occidata` + self-hosted runners, the GPU test tiers skip visibly with `Test.@test_skip` on + CPU runners, and a new `test/test_environment_contract.jl` enforces both. `src/`, + `ext/` and `.github/workflows/CI.yml` are unchanged. + ## [0.9.1-beta] - 2026-08-26 No breaking changes in this release. diff --git a/CHANGELOG.md b/CHANGELOG.md index d2176a2..8927289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,42 @@ All notable changes to CTParser will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.2-beta] - 2026-08-28 + +### 🧪 Testing + +- **GPU runner capability detection recognises both `kkt` and `occidata`** + ([#339](https://github.com/control-toolbox/CTParser.jl/issues/339)). The suite had no + notion of which runner it was executing on. `test/runtests.jl` now defines a single + `TestCapabilities` module holding `CUDA_FUNCTIONAL`, `ON_GPU_RUNNER` and + `GPU_SOLVER_ARMED`. `ON_GPU_RUNNER` matches the `kkt` / `occidata` substring of + `RUNNER_NAME` — the self-hosted runners are registered as `kkt-runner` / + `occidata-runner`, whereas the `CI.yml` `runs_on` label is the bare `kkt` / `occidata` + — so a missing or broken CUDA device now fails loudly on either GPU runner instead of + being silently skipped. `RUNNER_NAME` is set by the GitHub Actions runner agent itself, + so no `.github/workflows/CI.yml` or CTActions change was needed. + +- **The two GPU test tiers now skip visibly.** `test_dynamics_exa.jl` and + `test_onepass_exa.jl` gated their GPU runs behind a bare short-circuit on the raw device + predicate, which made a correctly-skipped run (no device, as expected on a developer + machine) and a silently-broken one (device missing on a GPU runner) produce the same + output: a green run with the GPU tier simply absent. Both now branch to + `Test.@test_skip`, so each scheme's GPU tier shows as `Broken` in the summary — eight + entries on a CPU runner. + +- **New meta-test `test/test_environment_contract.jl`**, mirroring CTSolvers' + `test/suite/environment/test_environment_contract.jl`. It asserts that the + MadNLPGPU/CUDSS GPU solver extension is armed (on every runner, CPU laptops included — + this is the assertion that catches the CUDSS wiring regression), that a CUDA device is + present when running on `kkt` or `occidata`, and that the silent-guard anti-pattern has + not reappeared anywhere under `test/`. + +### ✅ Compatibility + +- **No breaking changes**: test-suite and release metadata only — `src/` and `ext/` are + untouched, and `.github/workflows/CI.yml` is unchanged (it already targets `occidata`). + See [BREAKING.md](BREAKING.md). + ## [0.9.1-beta] - 2026-08-26 ### 🐛 Bug Fixes diff --git a/Project.toml b/Project.toml index 391ebd0..80554b2 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "CTParser" uuid = "32681960-a1b1-40db-9bff-a1ca817385d1" -version = "0.9.1-beta" +version = "0.9.2-beta" authors = ["Jean-Baptiste Caillau "] [deps] From 985e6eaf6a39d7d8b8924b58436257c87e238fe1 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 30 Aug 2026 19:05:34 +0200 Subject: [PATCH 16/20] chore(release): prepare 0.9.3-beta Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- BREAKING.md | 8 ++++++++ CHANGELOG.md | 10 ++++++++++ Project.toml | 2 +- src/onepass.jl | 4 ++-- src/utils.jl | 22 +++++++++++----------- 5 files changed, 32 insertions(+), 14 deletions(-) diff --git a/BREAKING.md b/BREAKING.md index eb12a40..76bcff2 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -4,6 +4,14 @@ Breaking changes in CTParser releases, and how to migrate. Tracked from the 0.8.15 baseline onward; see [CHANGELOG.md](CHANGELOG.md) for the full record. +## [0.9.3-beta] - 2026-08-30 + +No breaking changes in this release. + +- Documentation-only fix ([#341](https://github.com/control-toolbox/CTParser.jl/issues/341)): + examples in docstrings now use static `julia` fences so they render correctly when + transcluded into consumer documentation. Runtime behavior and the public API are unchanged. + ## [0.9.2-beta] - 2026-08-28 No breaking changes in this release. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8927289..7e517d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to CTParser will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.3-beta] - 2026-08-30 + +### 🐛 Bug Fixes + +- **Docstring examples no longer trigger Documenter warnings** ([#341](https://github.com/control-toolbox/CTParser.jl/issues/341)). Examples embedded in docstrings now use static `julia` fences instead of `@example` fences, because Documenter does not execute `@example` blocks when those docstrings are transcluded into a consumer's `@docs` block. + +### ✅ Compatibility + +- **No breaking changes**: this release only adjusts docstring rendering and release metadata. See [BREAKING.md](BREAKING.md). + ## [0.9.2-beta] - 2026-08-28 ### 🧪 Testing diff --git a/Project.toml b/Project.toml index 80554b2..3723fd2 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "CTParser" uuid = "32681960-a1b1-40db-9bff-a1ca817385d1" -version = "0.9.2-beta" +version = "0.9.3-beta" authors = ["Jean-Baptiste Caillau "] [deps] diff --git a/src/onepass.jl b/src/onepass.jl index b14a626..5595744 100644 --- a/src/onepass.jl +++ b/src/onepass.jl @@ -205,7 +205,7 @@ $(TYPEDSIGNATURES) Parse the expression `e` and update the `ParsingInfo` structure `p`. # Example -```@example +```julia parse!(p, :p_ocp, :(v ∈ R, variable)) ``` """ @@ -1462,7 +1462,7 @@ Define an optimal control problem. One pass parsing of the definition. Can be us will display the parsing steps. # Example -```@example +```julia ocp = @def begin tf ∈ R, variable t ∈ [ 0, tf ], time diff --git a/src/utils.jl b/src/utils.jl index 360593f..99582f4 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -40,7 +40,7 @@ $(TYPEDSIGNATURES) Expr iterator: apply `_Expr` to nodes and `f` to leaves of the AST. # Example -```@example +```julia julia> id(e) = expr_it(e, Expr, x -> x) ``` """ @@ -60,7 +60,7 @@ $(TYPEDSIGNATURES) Substitute expression `e1` by expression `e2` in expression `e`. # Examples -```@example +```julia julia> e = :( ∫( r(t)^2 + 2u₁(t)) → min ) :(∫(r(t) ^ 2 + 2 * u₁(t)) → min) @@ -105,7 +105,7 @@ Handles two patterns: See also: subs2m. # Examples -```@example +```julia julia> # Scalar indexing julia> e = :(x0[1] * 2xf[3] - cos(xf[2]) * 2x0[2]) julia> subs2(subs2(e, :x0, :x, 0), :xf, :x, :N) @@ -143,7 +143,7 @@ $(TYPEDSIGNATURES) Substitute x[rg] by y[i, j], whatever rg, in e. (Note: rg is then expected to be used to loop on i.) # Examples -```@example +```julia julia> e = :(x0[1:2:d] * 2xf[1:3]) :(x0[1:2:d] * (2 * xf[1:3])) @@ -178,7 +178,7 @@ Bare symbols like x (without indexing) are NOT substituted. See also: subs2. # Examples -```@example +```julia julia> e = :(x0[1] * 2xf[3] - cos(xf[2]) * 2x0[2]) :(x0[1] * (2 * xf[3]) - cos(xf[2]) * (2 * x0[2])) @@ -219,7 +219,7 @@ $(TYPEDSIGNATURES) Replace calls in e of the form `(...x...)(t)` by `(...y...)`. # Example -```@example +```julia julia> t = :t; t0 = 0; tf = :tf; x = :x; u = :u; @@ -250,7 +250,7 @@ $(TYPEDSIGNATURES) Replace calls in e of the form `(...x1...x2...)(t)` by `(...y1...y2...)` for all symbols `x1`, `x2`... in the vector `x`. # Example -```@example +```julia julia> t = :t; t0 = 0; tf = :tf; x = :x; u = :u; @@ -292,7 +292,7 @@ $(TYPEDSIGNATURES) Return true if e contains e1. # Example -```@example +```julia julia> e = :( ∫( x[1](t)^2 + 2*u(t) ) → min ) :(∫((x[1])(t) ^ 2 + 2 * u(t)) → min) @@ -345,7 +345,7 @@ $(TYPEDSIGNATURES) Return true if e contains a `(...x...)(t)` call. # Example -```@example +```julia julia> e = :( ∫( x[1](t)^2 + 2*u(t) ) → min ) :(∫((x[1])(t) ^ 2 + 2 * u(t)) → min) @@ -378,7 +378,7 @@ $(TYPEDSIGNATURES) Concatenate two expressions without creating extra blocks (as `Expr(:block, e1, e2)` would do). # Example -```@example +```julia julia> e1 = :(x = 1; y = 2) quote x = 1 @@ -425,7 +425,7 @@ together with the appropriate value (range, updated expression...) Expressions l is the control and `t0` the initial time return `:other`. # Example -```@example +```julia julia> t = :t; t0 = 0; tf = :tf; x = :x; u = :u; v = :v julia> constraint_type(:( ẏ(t) ), t, t0, tf, x, u, v) From adb047761a439ca258d329a035652b07a4673696 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 30 Aug 2026 19:47:59 +0200 Subject: [PATCH 17/20] chore(release): prepare 0.9.4-beta with CTBase 0.30 support Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 6 +++++- Project.toml | 4 ++-- docs/Project.toml | 2 +- test/Project.toml | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e517d6..d9e2c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ All notable changes to CTParser will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.9.3-beta] - 2026-08-30 +## [0.9.4-beta] - 2026-08-30 + +### ✅ Compatibility + +- **CTBase 0.30 is now supported** alongside CTBase 0.29 ([#342](https://github.com/control-toolbox/CTParser.jl/issues/342)). ### 🐛 Bug Fixes diff --git a/Project.toml b/Project.toml index 3723fd2..81ed170 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "CTParser" uuid = "32681960-a1b1-40db-9bff-a1ca817385d1" -version = "0.9.3-beta" +version = "0.9.4-beta" authors = ["Jean-Baptiste Caillau "] [deps] @@ -19,7 +19,7 @@ LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" CTParserExaModels = ["ExaModels", "LinearAlgebra"] [compat] -CTBase = "0.29" +CTBase = "0.29, 0.30" DocStringExtensions = "0.9" ExaModels = "0.12" LinearAlgebra = "1" diff --git a/docs/Project.toml b/docs/Project.toml index fe32870..6b1c7b4 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -5,7 +5,7 @@ Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391" [compat] -CTBase = "0.29" +CTBase = "0.29, 0.30" Documenter = "1" Markdown = "1" MarkdownAST = "0.1" diff --git a/test/Project.toml b/test/Project.toml index 521b05f..8d16399 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -18,7 +18,7 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] Aqua = "0.8" BenchmarkTools = "1" -CTBase = "0.29" +CTBase = "0.29, 0.30" CTModels = "0.18" CUDA = "5, 6" CUDSS = "0.6, 0.7, 0.8" From 47765d4048b95e91ba3e60057016c16b161b34f2 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Mon, 31 Aug 2026 15:28:57 +0200 Subject: [PATCH 18/20] fix(parser): reject constraint bounds that depend on v/x/u/t (#343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A boundary/path constraint whose bound referenced the optimization variable (e.g. `x₂(0) == v`) failed with a leaked internal gensym `UndefVarError: v##NNNN` instead of a clear error, because `lb`/`ub` are evaluated once at build time and cannot see a function-argument name. `p_constraint!` now checks both bounds and returns a `ParsingError` pointing to the fix (`x₂(0) - v == 0`). Backend-agnostic: covers both `:fun` and `:exa`. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 ++++++ src/onepass.jl | 20 ++++++++++++++++++++ test/test_onepass_exa.jl | 15 +++++++++++++++ test/test_onepass_fun.jl | 31 +++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e2c91..e97150d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to CTParser will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### 🐛 Bug Fixes + +- **A constraint bound that depends on `v`, the state, the control or the time is now rejected with a clear `ParsingError`** ([#343](https://github.com/control-toolbox/CTParser.jl/issues/343)). Previously `x₂(0) == v` (with `v` the optimization variable) failed with an internal `UndefVarError: v##NNNN` leaked from generated code. Such a relation must be written as a functional constraint by moving the term to the constrained side, e.g. `x₂(0) - v == 0`. + ## [0.9.4-beta] - 2026-08-30 ### ✅ Compatibility diff --git a/src/onepass.jl b/src/onepass.jl index 5595744..bd8f134 100644 --- a/src/onepass.jl +++ b/src/onepass.jl @@ -641,6 +641,26 @@ function p_constraint!( log && println("constraint ($c_type): $e1 ≤ $e2 ≤ $e3, ($label)") label isa Int && (label = Symbol(:eq, label)) label isa Symbol || return __throw("forbidden label: $label", p.lnum, p.line) + # a constraint bound must be "effective": lb/ub are evaluated once at build time, + # so they cannot reference v/x/u/t (doing so leaks an internal gensym — see #343) + for (b, side) in ((e1, "lower"), (e3, "upper")) + isnothing(b) && continue + for (sym, what) in ( + (p.v, "the variable"), + (p.x, "the state"), + (p.u, "the control"), + (p.t, "the time"), + ) + isnothing(sym) && continue + has(b, sym) && return __throw( + "the $side bound of a constraint must not depend on $what; " * + "write a functional constraint instead by moving the term to the " * + "constrained side (e.g. `x₂(0) - v == 0` rather than `x₂(0) == v`)", + p.lnum, + p.line, + ) + end + end xut = __symgen(:xut) ee2 = replace_call(e2, [p.x, p.u], p.t, [xut, xut]) has(ee2, p.t) && (p.is_autonomous = false) diff --git a/test/test_onepass_exa.jl b/test/test_onepass_exa.jl index 60c86a9..d86f06b 100644 --- a/test/test_onepass_exa.jl +++ b/test/test_onepass_exa.jl @@ -1081,6 +1081,21 @@ function __test_onepass_exa( end @test_throws ParsingError o(; backend=backend) + # a constraint bound must not depend on the variable (#343) + o = @def_exa begin + v ∈ R, variable + t ∈ [0, 1], time + x ∈ R⁴, state + u ∈ R⁵, control + x₂(0) == v + ∂(x₁)(t) == x₁(t) + ∂(x₂)(t) == x₁(t) + ∂(x₃)(t) == x₁(t) + ∂(x₄)(t) == x₁(t) + x₁(0) + 2cos(x₂(1)) → min + end + @test_throws ParsingError o(; backend=backend) + o = @def_exa begin t ∈ [0, 1], time x ∈ R⁴, state diff --git a/test/test_onepass_fun.jl b/test/test_onepass_fun.jl index 901f93d..dae17f1 100644 --- a/test/test_onepass_fun.jl +++ b/test/test_onepass_fun.jl @@ -2806,6 +2806,37 @@ function test_onepass_fun() ẋ(t) == A * x(t) + B * u(t) ∫(u(t)^2) / 2 → min # forbidden end + + # a constraint bound must be effective: it must not depend on the variable (#343) + @test_throws ParsingError @def o begin + v ∈ R, variable + t ∈ [0, 1], time + x ∈ R², state + u ∈ R, control + x₂(0) == v + ẋ(t) == [x₂(t), u(t)] + end + + # ... nor on the state (bound side) + @test_throws ParsingError @def o begin + t ∈ [0, 1], time + x ∈ R², state + u ∈ R, control + x₁(0) ≤ x₂(0) + ẋ(t) == [x₂(t), u(t)] + end + + # the documented work-around still builds fine + o = @def begin + v ∈ R, variable + t ∈ [0, 1], time + x ∈ R², state + u ∈ R, control + x₂(0) - v == 0 + ẋ(t) == [x₂(t), u(t)] + ∫(0.5u(t)^2) → min + end + @test o isa Model end # --------------------------------------------------------------- From aa6684a6a6f3dd4f1edd0aea2687bba8881d2ddc Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Mon, 31 Aug 2026 16:05:55 +0200 Subject: [PATCH 19/20] fix(parser): print the trace once in @def trace mode (#344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@def name … end true` printed the parsed model twice whenever the `:exa` backend was active: `def_fun` re-parses the definition to build the ExaModels artifact and that second pass inherited the `log` flag, re-emitting the whole trace. The `:exa` sub-parse now runs with `log=false`; the `:fun` pass already produced the trace. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 ++++++ src/onepass.jl | 4 +++- test/test_onepass_fun.jl | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e2c91..031af27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to CTParser will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### 🐛 Bug Fixes + +- **Trace mode (`@def name … end true`) no longer prints the parsed model twice** ([#344](https://github.com/control-toolbox/CTParser.jl/issues/344)). When the `:exa` backend is active, `def_fun` parses the definition a second time to build the ExaModels artifact; that second pass was inheriting the `log` flag and re-emitting the whole trace. It now runs with `log=false`. + ## [0.9.4-beta] - 2026-08-30 ### ✅ Compatibility diff --git a/src/onepass.jl b/src/onepass.jl index 5595744..62f3801 100644 --- a/src/onepass.jl +++ b/src/onepass.jl @@ -1550,7 +1550,9 @@ function def_fun(e; log=false) end if is_active_backend(:exa) - build_exa = def_exa(e; log=log) + # log=false: the :fun pass above already emitted the trace; a second one here + # would print the whole parsed model twice (#344) + build_exa = def_exa(e; log=false) code = concat(code, :($pref.build($p_ocp; build_examodel=($build_exa)))) else code = concat(code, :($pref.build($p_ocp))) diff --git a/test/test_onepass_fun.jl b/test/test_onepass_fun.jl index 901f93d..5f2519e 100644 --- a/test/test_onepass_fun.jl +++ b/test/test_onepass_fun.jl @@ -124,6 +124,33 @@ function test_onepass_fun() end true @test initial_time(o) == 0 @test final_time(o, [0, 2]) == 2 + + # trace mode prints the parsed model once, not once per active backend (#344) + was_exa = is_active_backend(:exa) + was_exa || activate_backend(:exa) + try + trace = mktemp() do path, io + redirect_stdout(io) do + CTParser.def_fun( + :(begin + tf ∈ R, variable + t ∈ [0, tf], time + x = (q, v) ∈ R², state + u ∈ R, control + ẋ(t) == [v(t), u(t)] + ∫(u(t)^2) → min + end); + log=true, + ) + end + flush(io) + read(path, String) + end + @test count("objective (Lagrange)", trace) == 1 + @test count("state: x, dim: 2", trace) == 1 + finally + was_exa || deactivate_backend(:exa) + end end # --------------------------------------------------------------- From 5db77ee3006be9b5b4e7c2d4286bebeadebcfcb2 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Mon, 31 Aug 2026 20:15:11 +0200 Subject: [PATCH 20/20] chore(release): prepare 0.9.5-beta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the two parser fixes merged into this branch: - #343 — constraint bounds depending on v/x/u/t are rejected with a clear ParsingError instead of a leaked internal gensym - #344 — @def trace mode prints the parsed model once, not twice No breaking changes: #343 only affects inputs that already errored, #344 is trace-only output. CHANGELOG and BREAKING updated. Co-Authored-By: Claude Sonnet 5 --- BREAKING.md | 14 ++++++++++++++ CHANGELOG.md | 6 +++++- Project.toml | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/BREAKING.md b/BREAKING.md index 76bcff2..ca9687d 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -4,6 +4,20 @@ Breaking changes in CTParser releases, and how to migrate. Tracked from the 0.8.15 baseline onward; see [CHANGELOG.md](CHANGELOG.md) for the full record. +## [0.9.5-beta] - 2026-08-31 + +No breaking changes in this release. + +- Constraint bounds that depend on `v`, the state, the control or the time + ([#343](https://github.com/control-toolbox/CTParser.jl/issues/343)) are now rejected + with a `CTBase.ParsingError` naming the cause. Such definitions already failed to + build — previously with an internal `UndefVarError` leaked from generated code — so no + working `@def` changes. Rewrite the relation as a functional constraint, e.g. + `x₂(0) - v == 0` instead of `x₂(0) == v`. +- Trace mode (`@def name … end true`) prints the parsed model once instead of twice + ([#344](https://github.com/control-toolbox/CTParser.jl/issues/344)). Trace output only; + the returned model and the public API are unchanged. + ## [0.9.3-beta] - 2026-08-30 No breaking changes in this release. diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b658c..06e4657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,17 @@ All notable changes to CTParser will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.9.5-beta] - 2026-08-31 ### 🐛 Bug Fixes - **A constraint bound that depends on `v`, the state, the control or the time is now rejected with a clear `ParsingError`** ([#343](https://github.com/control-toolbox/CTParser.jl/issues/343)). Previously `x₂(0) == v` (with `v` the optimization variable) failed with an internal `UndefVarError: v##NNNN` leaked from generated code. Such a relation must be written as a functional constraint by moving the term to the constrained side, e.g. `x₂(0) - v == 0`. - **Trace mode (`@def name … end true`) no longer prints the parsed model twice** ([#344](https://github.com/control-toolbox/CTParser.jl/issues/344)). When the `:exa` backend is active, `def_fun` parses the definition a second time to build the ExaModels artifact; that second pass was inheriting the `log` flag and re-emitting the whole trace. It now runs with `log=false`. +### ✅ Compatibility + +- **No breaking changes**: both fixes only affect inputs that already errored (`#343`) or trace-only output (`#344`). See [BREAKING.md](BREAKING.md). + ## [0.9.4-beta] - 2026-08-30 ### ✅ Compatibility diff --git a/Project.toml b/Project.toml index 81ed170..56fa680 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "CTParser" uuid = "32681960-a1b1-40db-9bff-a1ca817385d1" -version = "0.9.4-beta" +version = "0.9.5-beta" authors = ["Jean-Baptiste Caillau "] [deps]