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: 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/BREAKING.md b/BREAKING.md new file mode 100644 index 0000000..ca9687d --- /dev/null +++ b/BREAKING.md @@ -0,0 +1,153 @@ + +# 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.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. + +- 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. + +- 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. + +- 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 + +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..06e4657 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,225 @@ + +# 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.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 + +- **CTBase 0.30 is now supported** alongside CTBase 0.29 ([#342](https://github.com/control-toolbox/CTParser.jl/issues/342)). + +### 🐛 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 + +- **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 + +- **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, +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/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 8e8b5cc..56fa680 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.5-beta" authors = ["Jean-Baptiste Caillau "] [deps] @@ -11,11 +11,20 @@ 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.18, 0.27, 0.28" +CTBase = "0.29, 0.30" DocStringExtensions = "0.9" +ExaModels = "0.12" +LinearAlgebra = "1" 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..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.18, 0.27, 0.28" +CTBase = "0.29, 0.30" Documenter = "1" Markdown = "1" MarkdownAST = "0.1" 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 diff --git a/src/onepass.jl b/src/onepass.jl index b1ff16a..d0d7bf0 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 @@ -199,7 +205,7 @@ $(TYPEDSIGNATURES) Parse the expression `e` and update the `ParsingInfo` structure `p`. # Example -```@example +```julia parse!(p, :p_ocp, :(v ∈ R, variable)) ``` """ @@ -425,9 +431,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 +545,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 +556,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 +614,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 +623,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 @@ -635,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) @@ -696,6 +722,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,12 +738,25 @@ 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 - $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 @@ -729,7 +769,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) @@ -737,7 +790,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 @@ -748,7 +805,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) @@ -756,7 +826,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 @@ -767,7 +841,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)), @@ -784,7 +871,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)), @@ -801,7 +901,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)), @@ -824,14 +937,25 @@ 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 - $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]), @@ -885,6 +1009,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]) @@ -915,31 +1040,38 @@ 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( + # 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( + $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 ) 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 @@ -988,6 +1120,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) @@ -1019,21 +1152,28 @@ 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 ) 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) @@ -1070,6 +1210,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]) @@ -1092,19 +1233,26 @@ 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)", - ) # (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) @@ -1154,7 +1302,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 @@ -1252,8 +1400,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 @@ -1264,8 +1424,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 @@ -1276,7 +1448,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 @@ -1286,7 +1464,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 @@ -1298,7 +1482,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 @@ -1386,7 +1570,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))) @@ -1463,7 +1649,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/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) diff --git a/test/Project.toml b/test/Project.toml index 6214d50..8d16399 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" @@ -17,16 +18,17 @@ 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, 0.30" +CTModels = "0.18" +CUDA = "5, 6" +CUDSS = "0.6, 0.7, 0.8" +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" diff --git a/test/runtests.jl b/test/runtests.jl index e29d2aa..40e79fe 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -50,15 +50,56 @@ 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 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 +# 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_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] diff --git a/test/test_onepass_exa.jl b/test/test_onepass_exa.jl index af79abc..d86f06b 100644 --- a/test/test_onepass_exa.jl +++ b/test/test_onepass_exa.jl @@ -4,12 +4,47 @@ 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 __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 @@ -783,7 +818,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 CTBase.PreconditionError o(; backend=backend) o = @def_exa begin tf ∈ R, variable @@ -1046,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 @@ -1085,7 +1135,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 +1190,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "state bounds test" @@ -1159,7 +1209,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "control bounds test" @@ -1179,7 +1229,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "path bounds test" @@ -1199,7 +1249,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "path bounds test" @@ -1238,7 +1288,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "final bounds test" @@ -1257,7 +1307,7 @@ function __test_onepass_exa( ∂(x₃)(t) == 0.5u(t)^2 x₃(1) → min end - @test_throws String o(; backend=backend) + @test_throws CTBase.PreconditionError o(; backend=backend) end test_name = "use case no. 1: simple example (mayer) ($backend_name, $scheme)" @@ -1314,7 +1364,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)" @@ -1803,7 +1853,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 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..5e67a86 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 # --------------------------------------------------------------- @@ -2746,7 +2773,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 @@ -2806,6 +2833,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 # --------------------------------------------------------------- @@ -3026,7 +3084,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 +3092,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,10 +3205,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 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 test_name = "dimensions at runtime" 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 diff --git a/test/test_prefix_bis.jl b/test/test_prefix_bis.jl index 4739825..ad50982 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 CTBase.PreconditionError activate_backend(:fun) + @test_throws CTBase.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)