Skip to content

feat(madnlp): expose intermediate_callback + fixed_variable_treatment#98

Merged
jack-champagne merged 7 commits into
mainfrom
feat/madnlp-callback-hooks
May 26, 2026
Merged

feat(madnlp): expose intermediate_callback + fixed_variable_treatment#98
jack-champagne merged 7 commits into
mainfrom
feat/madnlp-callback-hooks

Conversation

@jack-champagne

@jack-champagne jack-champagne commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

Adds the plumbing needed to install solver-agnostic per-iteration callbacks during a solve!, with MadNLP wired up. Two layers:

Layer 1 — MadNLP-specific pass-throughs (MadNLPOptions)

  • intermediate_callback::Any = nothing — install any per-iter callback. Accepts either a raw MadNLP.AbstractUserCallback (passed through unwrapped) or a DirectTrajOpt.AbstractIntermediateCallback (wrapped in an internal adapter). Return false to terminate the solve (yields USER_REQUESTED_STOP).
  • fixed_variable_treatment::Any = nothing — override MadNLP's handling of variables with lb == ub. Default behaviour unchanged; pass MadNLP.RelaxBound when a callback needs the full primal vector.

Layer 2 — solver-agnostic abstraction (DirectTrajOpt.AbstractIntermediateCallback)

A new abstract type lives in the Solvers submodule. Subtypes implement the uniform signature

(cb)(primal::AbstractVector, iter::Integer) -> Bool

Each solver extension owns the adapter that translates this into the native callback shape. MadNLP's adapter (_MadNLPCallbackAdapter <: MadNLP.AbstractUserCallback) wraps (solver, mode) → (variable(solver.x), solver.cnt.k). Future Ipopt wiring will add a parallel adapter inside IpoptSolverExt.

This means downstream packages (Piccolo, etc.) can ship per-iter callbacks — live pulse plotting, custom stopping criteria, analysis hooks — by subtyping AbstractIntermediateCallback once, without taking a weak dep on every solver backend.

Why both fields

A callback that wants to reconstruct a NamedTrajectory from the primal vector (the use case that motivated this — live pulse plotting during optimization) cannot work with MadNLP's default MakeParameter, which eliminates fixed boundary variables from the IPM's working primal vector. The two fields ship together so that pattern works out-of-the-box.

Behaviour change

None for existing users. Both fields default to nothing and are skipped by the existing generic set_options! loop.

Test plan

  • MadNLPOptions construction testitem extended to assert both defaults are nothing
  • MadNLP intermediate_callback (raw MadNLP callback) fires per iter — verifies a MadNLP.AbstractUserCallback subtype runs on every IPM iteration
  • MadNLP intermediate_callback (AbstractIntermediateCallback) fires per iter — verifies the solver-agnostic adapter path: callback is wrapped, receives full primal vector matching length(traj.datavec) + traj.global_dim (with RelaxBound)
  • End-to-end smoke test (out of repo): 200-iter SmoothPulseProblem X-gate solve with a LivePulsePlotCallback <: AbstractIntermediateCallback saving a PNG per iter via Piccolo's plot_pulse. Wiring is solid; the rendered frames track the converging pulse correctly.

Follow-ups

  • Parallel adapter in IpoptSolverExt so the same AbstractIntermediateCallback instance works with both solvers.
  • Piccolo PR exposing LivePulsePlotCallback <: AbstractIntermediateCallback from PiccoloMakieExt (no new triple-trigger extension needed thanks to this abstraction).

Add two pass-through fields to `MadNLPOptions` so users can install
`MadNLP.AbstractUserCallback` subtypes for per-iteration hooks (e.g. live
plotting of converging pulses, custom stopping criteria) and override
MadNLP's handling of fixed bound variables.

`fixed_variable_treatment = MadNLP.RelaxBound` is required when a callback
needs the full primal vector — the default `MakeParameter` eliminates fixed
variables from `solver.x` and breaks the `MadNLP.variable(solver.x)` →
`traj.datavec` mapping that trajectory-reconstructing callbacks rely on.

Both fields are forwarded by the existing generic `set_options!` loop and
default to `nothing` (no behaviour change for existing users).
@codecov

codecov Bot commented May 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.28571% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
ext/MadNLPSolverExt/MadNLPSolverExt.jl 90.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

Add an abstract callback type in the Solvers module so per-iteration
hooks can be written once and reused across every solver backend.
Subtypes implement `(cb)(primal::AbstractVector, iter::Integer) -> Bool`;
each solver extension wraps an instance in a native-shape adapter at
solve time.

Wired up in MadNLP via `_MadNLPCallbackAdapter <: MadNLP.AbstractUserCallback`,
which translates `solver.x → MadNLP.variable(solver.x)` and forwards
`solver.cnt.k` as the iteration index. Raw `MadNLP.AbstractUserCallback`
subtypes still pass through unwrapped for users wanting full IPM access.

This unblocks downstream packages (e.g. Piccolo) from shipping plotting
or analysis callbacks without taking a weak dependency on each solver.
Address review feedback on #98:

- Filter mode to `UserCallbackRegular` in `_MadNLPCallbackAdapter`.
  MadNLP also invokes user callbacks during feasibility-restoration and
  robust phases, which would surface intermediate IPM state that's not
  meaningful to trajectory-level callbacks. Silently skipping those
  phases keeps `solver.cnt.k` monotonic from the callback's view.

- Auto-couple `fixed_variable_treatment = MadNLP.RelaxBound` when an
  `AbstractIntermediateCallback` is installed without an explicit value.
  Emits an `@info` log so the override is visible. Raw MadNLP callbacks
  are untouched (they manage their own primal access).

- Exhaustive type check on `intermediate_callback` — explicit
  `ArgumentError` for non-callback inputs instead of failing deep inside
  MadNLP's option parser.

- `AbstractIntermediateCallback` docstring now documents the
  primal-aliasing contract (copy if retaining), iter monotonicity, and
  the MadNLP RelaxBound auto-coupling.

- New testitems:
  - `auto-couples RelaxBound` — agnostic callback without explicit
    `fixed_variable_treatment` still receives the full primal.
  - `early termination via return false` — callback returning `false`
    after N iters halts the solve before `max_iter`.
  - `rejects invalid type` — bare `Function` raises `ArgumentError`.
Mirror MadNLP's own field signature (`fixed_variable_treatment::Type`)
instead of leaving it as `::Any = nothing`. Catches bad inputs at the
`MadNLPOptions` constructor (Strings, Symbols) rather than deep in
MadNLP's option parser, while preserving `nothing` as the "defer to
MadNLP's kkt_system-aware default" sentinel.

The `set_options!` auto-couple branch is unchanged behaviourally
(`=== nothing` still matches the default; `MadNLP.RelaxBound` is a
`Type`, so the assignment is type-consistent). Explicit overrides
(both `MadNLP.RelaxBound` and `MadNLP.MakeParameter`) still flow
through correctly.
When an `AbstractIntermediateCallback` is installed without explicit
`fixed_variable_treatment`, only override to `RelaxBound` if MadNLP's
own conditional default would otherwise pick `MakeParameter` and break
the callback. For `kkt_system <: SparseCondensedKKTSystem`, MadNLP's
default is already `RelaxBound`, so leave its if-one-liner alone.

Updated docstring on the field; added a testitem asserting that
`kkt_system = SparseCondensedKKTSystem` bypasses the auto-couple
(captured `@info` stream is empty for our message + primal length still
matches the full NLP variable count).
The new "MadNLP auto-couple respects MadNLP's conditional default"
testitem used `with_logger(SimpleLogger(...))` from the `Logging`
stdlib, which isn't in DTO's test extras and failed to resolve in CI.
Switch to `Test.collect_test_logs` — available via the testitem's
auto-imported Test stdlib, no extra dep needed.
@jack-champagne jack-champagne merged commit e021785 into main May 26, 2026
10 of 11 checks passed
@jack-champagne jack-champagne mentioned this pull request May 26, 2026
jack-champagne added a commit that referenced this pull request May 26, 2026
Patch bump for #97 (alloc fixes) and #98 (callback hooks + AbstractIntermediateCallback).
jack-champagne added a commit to harmoniqs/Piccolo.jl that referenced this pull request May 27, 2026
#219)

* feat(viz): LivePulsePlotCallback for live pulse plotting during solves

Add a `LivePulsePlotCallback` that renders the current pulse via
`plot_pulse(extract_pulse(qtraj, traj))` every IPM iteration, optionally
saving a PNG per render. Useful for watching an optimization converge in
real time and for generating animations of the trajectory's evolution.

The callback subtypes `DirectTrajOpt.AbstractIntermediateCallback`, so it
plugs into any DTO solver backend without taking weak dependencies on
solver packages:

    cb = LivePulsePlotCallback(qtraj, qcp.prob.trajectory; save_dir = "out/")
    solve!(qcp; options = MadNLPOptions(
        intermediate_callback    = cb,
        fixed_variable_treatment = MadNLP.RelaxBound,
    ))

Stub lives at `src/visualizations/live_callbacks.jl` (always loaded);
implementation lives in the existing `PiccoloMakieExt` extension and
activates when a Makie backend is present. No new extension package
required — works with `CairoMakie`, `GLMakie`, or `WGLMakie`.

Depends on harmoniqs/DirectTrajOpt.jl#98 (`AbstractIntermediateCallback`
abstraction).

* docs: reflect DTO's auto-coupling of RelaxBound

DTO #98's review pass added auto-coupling of `fixed_variable_treatment =
MadNLP.RelaxBound` when an `AbstractIntermediateCallback` is installed
without an explicit value. Drop the now-unneeded explicit setting from
docstring example and testitem; update the length-mismatch warning to
reflect that this state means the user has overridden the default.

* review: tighten LivePulsePlotCallback + drop test-time MadNLP dep

Address review feedback on #219:

- Drop `import MadNLP` from the testitem. MadNLP isn't a Piccolo test
  dep (and adding it pulls in a heavy transitive tree for a single
  test). Replace the live-solve assertion with a direct-invocation test
  that synthesizes a primal vector — exercises the actual Piccolo
  behavior (stub → extension constructor, primal handling, plot/save)
  without coupling to the solver. End-to-end MadNLP coverage already
  lives in DTO #98's own tests.

- Constructor validates `every >= 1` (an `every = 0` instance used to
  build fine and then DivideError on first invocation).

- Move the `every` gate before `update!` to skip the trajectory copy
  on dropped iters.

- `@warn` for primal-length mismatch now uses `maxlog = 1` so a user
  who explicitly overrode RelaxBound doesn't get N warnings.

- Bump frame filename padding from 3 to 5 digits — keeps lexical sort
  correct up to `iter_99999.png`.

- Three new testitems cover: type contract + direct invocation + frame
  distinctness; `every > 1` skipping (4 frames over 11 invocations
  with `every = 3`); `every <= 0` rejection.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant