Skip to content

Align with CTBase 0.29 / CTModels 0.18 / ExaModels 0.12 - #326

Open
ocots wants to merge 23 commits into
mainfrom
chore/deps-examodels-0.12
Open

Align with CTBase 0.29 / CTModels 0.18 / ExaModels 0.12#326
ocots wants to merge 23 commits into
mainfrom
chore/deps-examodels-0.12

Conversation

@ocots

@ocots ocots commented Aug 25, 2026

Copy link
Copy Markdown
Member

Closes #325. Closes #323. Closes #322. Closes #339. Closes #230. Closes #341. Closes #342. Closes #343. Closes #344.

Problem

CTSolvers 0.5.3 already declares ExaModels = "0.12", but its extension only reads backend metadata. The package that actually emits ExaCore / variable / constraint / objective calls is CTParser, from def_exa in src/onepass.jl — and ExaModels 0.12 deleted that mutable builder API (src/deprecated.jl is gone, along with variable, parameter, subexpr, constraint! and LegacyExaCore).

So CTSolvers' declared 0.12 support was nominal until this: any :exa solve failed at run time, inside generated code, with UndefVarError: variable not defined in ExaModels. CTParser is the one package in the stack whose source had to change.

Public API changes

1. The :exa emission requires ExaModels ≥ 0.12

Supporting both 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.

# 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 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.

2. Untyped String errors become CTException subtypes

Seventeen sites typed by the Handbook's choice rule — a single argument's value out of domain is IncorrectArgument, a relational/state/timing contract is PreconditionError.

# Before
catch e
    e == "unknown numerical scheme: gauss_legendre_2 (possible choices are ...)"
end

# After
catch e
    e isa CTBase.IncorrectArgument
end

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. And :fun is a perfectly valid backend name: what is forbidden is toggling it, a state contract rather than a bad value, so that is PreconditionError while an unrecognised backend name is IncorrectArgument.

An unplanned blocker, upstream

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: 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 PR 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. Upstream question — asked as "what is the intended direction?" rather than as a patch: madsuite-org/ExaModels.jl#323.

Two details from the port worth knowing about: upstream's Section F (ExaModels.add_con(core, ::AbstractVector)) is broken as written and is not ported, and enabling the Null zero elimination surfaces a genuine ambiguity in ExaModels' own src/simdfunction.jl:142-143 that is worked around here, with the reasoning in a comment.

GPU runner capability detection (#339)

The CI phase above swapped the retired kkt runner for occidata, but the suite still had no notion of which runner it was executing on. Both :exa GPU tiers were gated behind a bare short-circuit on CUDA.functional(), so a correctly-skipped run on a CPU laptop and a silently-broken one on occidata — device present but not functional — produced the identical output: a green run with the GPU tier simply absent. That is the anti-pattern the Handbook's philosophy/testing.md §"Capability-gated tests" forbids.

test/runtests.jl now carries the single capability module the Handbook asks for, aligned with CTSolvers (its #189 / #217):

module TestCapabilities
const CUDA_FUNCTIONAL  = CUDA.functional()
const ON_GPU_RUNNER    = any(gpu -> occursin(gpu, get(ENV, "RUNNER_NAME", "")), ("kkt", "occidata"))
const GPU_SOLVER_ARMED = isdefined(MadNLPGPU, :CUDSSSolver)
end

The substring match is deliberate: RUNNER_NAME is set by the GitHub Actions runner agent itself — no CI.yml or CTActions change needed — to the runner's registered name, and ours are registered as kkt-runner / occidata-runner, whereas the CI.yml runs_on label is the bare kkt / occidata. Detection covers both runners per the issue, even though only occidata is a live target today.

GPU_SOLVER_ARMED diverges from CTSolvers' MadNLPGPU.CUDSSSolver isa Type on purpose: the symbol only exists once MadNLPGPUCUDAExt loads, so that form would throw UndefVarError at module load and abort the whole run instead of failing one assertion. It asserts the same thing.

The two GPU tiers now branch to Test.@test_skip, and a new test/test_environment_contract.jl enforces the contract centrally: the MadNLPGPU/CUDSS extension must be armed on every runner (this is what catches the CUDSS wiring regression), a device must be present on kkt/occidata, and the silent-guard anti-pattern must not reappear anywhere under test/. CTSolvers' companion isdefined(Main, ...) audit is not ported — it exists because every CTSolvers suite file is wrapped in its own module, whereas CTParser's tests are a mix of module-wrapped and flat files included straight into Main, where the idiom is legitimate.

Verified by faking the runner on a CPU box, which is the only way to exercise the loud-failure path locally:

$ RUNNER_NAME=occidata-runner julia --project -e 'using Pkg; Pkg.test(;test_args=["environment_contract"])'
GPU driver required on the GPU runner: Test Failed at test/test_environment_contract.jl:82
$ RUNNER_NAME=kkt-runner julia --project -e 'using Pkg; Pkg.test(;test_args=["environment_contract"])'
GPU driver required on the GPU runner: Test Failed at test/test_environment_contract.jl:82

The audit proved itself the same way, unprompted: its first run failed on the explanatory comments I had just written into the two test files, which spelled out the literal pattern. Reworded, green.

Phases

Test results

file result
test_aqua.jl 11/11 (piracies=true unchanged)
test_control_zero.jl 37/37
test_dynamics_exa.jl 100/100 + 4 skipped
test_environment_contract.jl 2/2 + 1 skipped
test_exa_linalg.jl 491/491
test_initial_guess.jl 199/199
test_onepass_exa.jl 601/601 + 4 skipped
test_onepass_exa_bis.jl 176/176
test_onepass_fun.jl 638/638
test_onepass_fun_bis.jl 129/129
test_prefix.jl 15/15
test_prefix_bis.jl 42/42
test_utils.jl 93/93
test_utils_bis.jl 58/58
total 2592/2592, 9 skipped

No errors, no failures, and no WARNING: … conflicts with an existing identifier in the log. CPU only locally — the occidata GPU job runs on this PR via its label.

Update (phases I–M). Full suite re-run green after each addition. Latest run: 2594 pass, 9 broken (pre-existing), 0 fail/error — the count moved from 2592 with the #338 follow-up already noted plus +3 from the #343 tests and +1 from the #344 test, minus test-set reshuffles on the :exa side.

The 9 skips are the point of #339: they are the 4 GPU scheme tiers per :exa test file, plus the "device only required on kkt/occidata" skip, all now visible as Broken in the summary instead of vanishing. On occidata the first 8 become real runs and the ninth becomes an asserted pass. The total moved 2580 → 2592 for reasons mostly unrelated to #339: +2 from the new file, and +5 in each of test_onepass_exa.jl and test_onepass_fun_bis.jl from the #338 fix, which landed after the earlier table was written.

Follow-up

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
ocots and others added 2 commits August 25, 2026 16:40
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@ocots ocots added run ci github-runner Trigger the CI workflow on GitHub-hosted runners run ci occidata-runner Trigger the CI workflow on the self-hosted occidata runner (GPU/CUDA) and removed kkt-runner labels Aug 25, 2026
ocots and others added 4 commits August 25, 2026 17:26
…un 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 <target>" 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
ocots and others added 2 commits August 25, 2026 21:21
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@ocots
ocots marked this pull request as ready for review August 26, 2026 07:21
@ocots

ocots commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

@jbcaillau please review.

@ocots ocots removed run ci github-runner Trigger the CI workflow on GitHub-hosted runners run ci occidata-runner Trigger the CI workflow on the self-hosted occidata runner (GPU/CUDA) labels Aug 26, 2026
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.49635% with 37 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.71%. Comparing base (1d5b387) to head (5db77ee).

Files with missing lines Patch % Lines
ext/CTParserExaModels.jl 83.18% 37 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #326      +/-   ##
==========================================
- Coverage   98.22%   95.71%   -2.52%     
==========================================
  Files           4        5       +1     
  Lines        1073     1307     +234     
==========================================
+ Hits         1054     1251     +197     
- Misses         19       56      +37     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…n tolerance

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 (f5ab71f, 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 <noreply@anthropic.com>
@ocots ocots added run ci github-runner Trigger the CI workflow on GitHub-hosted runners run ci occidata-runner Trigger the CI workflow on the self-hosted occidata runner (GPU/CUDA) labels Aug 26, 2026
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ocots and others added 2 commits August 29, 2026 10:07
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 <noreply@anthropic.com>
Test-suite and metadata only; src/ and ext/ are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ocots and others added 3 commits August 30, 2026 19:05
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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 <noreply@anthropic.com>
ocots and others added 5 commits August 31, 2026 16:05
`@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 <noreply@anthropic.com>
…nt-344

fix(parser): print the trace once in @def trace mode (#344)
…ective-343

fix(parser): reject constraint bounds that depend on v/x/u/t (#343)
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 <noreply@anthropic.com>
@ocots

ocots commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Added since the initial review — phases I–M

Beyond the original ExaModels 0.12 / typed-errors / CI scope, this branch now also carries:

I — 0.9.3-beta prep (985e6ea)

@def / @init docstring examples moved from ```@example to static ```julia fences. Documenter does not execute @example blocks when the docstrings are transcluded into a consumer's @docs block, so they produced warnings downstream. Closes #341.

J — 0.9.4-beta prep, CTBase 0.30 (adb0477)

[compat] widened to CTBase = "0.29, 0.30". No source change — needed one level down for CTFlows 0.18's Makie backend. Closes #342.

K — constraint bounds must be effective (#343, merged as #345)

A constraint whose bound referenced v / the state / the control / the time, e.g. x₂(0) == v, was rejected — but with a leaked internal gensym:

Line 7: x₂(0) == v
UndefVarError: `v##286` not defined in `Main`

lb / ub are evaluated once at build time and cannot see a function-argument name (for :fun the gensym is never bound at all). p_constraint! now checks both bounds, before the :fun / :exa dispatch, and returns a CTBase.ParsingError naming the cause and pointing to x₂(0) - v == 0. The constrained side may still reference the variable (e.g. 0 ≤ r(0) - z ≤ 1) — only the bounds are restricted. Tests in test_onepass_fun.jl and test_onepass_exa.jl.

L — trace mode prints once (#344, merged as #347)

@def name … end true printed the parsed model twice when :exa was active: def_fun re-parses via def_exa(e; log=log) to embed the ExaModels builder, and that second pass re-emitted the trace. The :exa sub-parse now runs with log=false. Test captures stdout of def_fun(…; log=true) and asserts one occurrence of each trace marker.

M — 0.9.5-beta prep (5db77ee)

Project.toml0.9.5-beta; CHANGELOG.md and BREAKING.md sections for #343 / #344. No breaking changes: #343 only changes the exception type for input that already failed to build, #344 is trace-only output.

Status

Full suite green after M: 2594 pass, 9 broken (pre-existing), 0 fail/error (CPU-only locally; occidata GPU job runs on the PR).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment