Skip to content

fix(solutions): dual() on missing duals + limits of the path duals at switching times - #483

Merged
ocots merged 6 commits into
mainfrom
fix/dual-missing-storage
Oct 23, 2026
Merged

ocots merged 6 commits into
mainfrom
fix/dual-missing-storage

Conversation

@ocots

@ocots ocots commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Needed by control-toolbox/CTFlows.jl#494 (#472, constraint duals of a flow solution). Convention: control-toolbox/OptimalControl.jl#1014.

Conventions applied: ct-conventions v0.8.0 · ct-agents v0.3.0.

What this PR does

1. dual(sol, model, label) when some duals were not stored (bug fix)

A solution may carry the duals of some kinds of constraints only. The solution of a CTFlows flow, for instance, carries the path duals only. For a label whose kind has no stored dual, dual used to:

  • for a box label, return a BoxDualDiff(nothing, nothing, i), which failed with MethodError: objects of type Nothing are not callable when called;
  • for a boundary label, fail at once on nothing[i].

It now throws a PreconditionError that names the kind of constraint and how to get its duals. The message for a solution with no dual at all no longer says "a solution built by a flow has none".

Why PreconditionError and not IncorrectArgument (EXC1): the label is valid (it exists in the model), and so is the solution. What is wrong is the pair: this solution holds no dual of that kind. This is also the type already used when a solution has no dual at all. An unknown label still throws IncorrectArgument.

2. Limits of the path duals in the switching model (feature)

  • SwitchingModel gains the component :path_dual: the (left, right) limits of the duals of the path constraints at each switching time. It is a keyword path_dual, with no column (not known) by default. limits, left_limit, right_limit accept it, and build_solution checks its number of columns (0 or dim_path_constraints_nl).
  • Plots (Plots and Makie, through PlotCase): the dual panel splices the limits like the state, costate and control panels. A multiplier that jumps at the entry or exit of a boundary arc is drawn as a vertical segment, or as a break with jumps = :none.
  • Serialization (JLD, JSON): the limits are saved with the others. A file written before this change loads, with the dual limits not known.

The phase labels already go to the first drawn group when the control is not drawn: tested here for plot(sol, :dual).

Tests

  • test_dual_model.jl: every kind of label on a solution with the path duals only, and the reverse.
  • test_switching.jl: path_dual limits, defaults, display, and shape errors.
  • test_plot_switching.jl: the jump of the dual panel, jumps=:none, limits not known, and the labels on the dual cell.
  • test_switching_serialization.jl: the new keys, and an old file without them.

Locally: solutions, plotting, serialization and extensions suites, 3243 passed.

Documentation

Switching structure: the path_dual limits, with an example plotted. CHANGELOG.md / BREAKING.md: a feature, a fix, and a near-breaking note (a new field of SwitchingModel, whose default positional constructor changes; the documented keyword constructor does not).

Next version: 0.20.1 (no breaking change by REL1). control-toolbox/CTFlows.jl#494 will require it.

Open questions

  1. Sign of the box duals.
    • dual(sol, model, :box_label) returns lb_dual − ub_dual. For a box with only an upper bound, that is −zU ≤ 0, while a path row with only an upper bound gives μ ≥ 0 in the convention of CTSolvers#288. So the same constraint written as a box or as a path row has duals of opposite signs.
    • A fix could be: for a one-sided box, return the multiplier of its side (lb_dual or ub_dual). For a two-sided box, keep ub_dual − lb_dual, to match y = μU − μL.
    • This is a semantic change, to decide together with CTDirect#639 (which also covers the scale by the step).
  2. Should a solution also store the limits of the box duals at the switching times? Not needed until flows report box duals.

🤖 Generated with Claude Code

ocots and others added 3 commits October 23, 2026 21:19
…ind were not stored

A solution may carry the duals of some constraints only (a flow solution
carries the path duals). dual(sol, model, label) then returned a function
that failed with a MethodError on call (box labels), or failed at once
(boundary labels). It now throws a PreconditionError naming the kind of
constraint and how to get its duals. The message of the dual-free case no
longer says that a flow solution has no duals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wn as jumps

SwitchingModel gains the component :path_dual, the (left, right) limits of
the duals of the path constraints at each switching time (no column when
not known, the default). The dual panel of the plots splices them as the
state, costate and control panels do, so a multiplier that jumps at the
entry or exit of a boundary arc is drawn as a vertical segment (or a
break, with jumps=:none). Serialized with the other limits; a file written
before loads with the dual limits not known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oducer; unknown label checked first

When a dual is missing, the suggestion of the PreconditionError is the
note left by the producer of the solution in infos(sol)[:missing_duals],
when there is one: CTFlows knows why it could not rebuild a dual and how
to get it. An unknown label is now an IncorrectArgument on every solution,
including one with no dual at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ocots added a commit to control-toolbox/CTFlows.jl that referenced this pull request Oct 23, 2026
…issing_duals]

dual(sol, ocp, :label) uses this note as the suggestion of its
PreconditionError (control-toolbox/CTModels.jl#483): a constraint given
without label, or the boundary and box duals, which a flow does not
compute (#495). A zero would be wrong for them: a boundary constraint is
always active, a control box is active on a bang arc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ocots

ocots commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

Update: two more changes in dual.

  • When a dual is missing, the suggestion of the PreconditionError is the note infos(sol)[:missing_duals] left by the producer of the solution, if it is a non-empty string; otherwise it is the generic suggestion. CTFlows (feat(flows): constraint duals in the Solution of Flow(ocp, law; constraint=:label) CTFlows.jl#494) uses it to say why a flow could not rebuild a dual and how to get it.
  • The label is checked before the duals. An unknown label is now an IncorrectArgument on every solution; before, a solution with no dual at all answered with a PreconditionError.

suite/solutions: 570 passed.

…s SGN2, SGN3)

dual(sol, model, :box_label) returns the lower-bound multiplier for a
component with a lower bound only, the upper-bound multiplier (>= 0) for
an upper bound only, and ub_dual - lb_dual for two finite bounds or a
fixed component (it was lb_dual - ub_dual). The same constraint written
as a box and as a path constraint now has the same dual (SGN5).

The Duals & Diagnostics page states the convention, with a reference to
ct-conventions SGN1-SGN6, the errors of dual, and the dual of a box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ocots

ocots commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

Update: the sign of the box duals is aligned with the convention (open question 1 of the description), and the convention now has a reference, control-toolbox/ct-conventions#49 (SGN1–SGN6).

  • dual(sol, model, :box_label) (state, control, variable) gives, per component: zL for a lower bound only, zU (≥ 0) for an upper bound only, and the signed zU − zL for two finite bounds or a fixed component (SGN2, SGN3). It was zL − zU for every component. A box and a path constraint written for the same constraint now have the same dual (SGN5). The stored multipliers are unchanged.
  • Breaking (CHANGELOG.md and BREAKING.md have a migration block), so the next version is 0.21.0, not 0.20.1.
  • Duals & Diagnostics page: a Sign convention section citing SGN1–SGN6, the errors of dual, and the dual of a box. The example of the page gave a negative dual to a constraint with a single upper bound; it now gives e^{t−1} ≥ 0. I ran the example by hand.
  • Full suite locally: 4913 passed. CTFlows#494's test_flow_duals.jl is still green against this branch.

@ocots
ocots marked this pull request as ready for review October 23, 2026 20:51
@ocots
ocots merged commit c0fcac4 into main Oct 23, 2026
5 checks passed
@ocots
ocots deleted the fix/dual-missing-storage branch October 23, 2026 20:51
@ocots

ocots commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

Note

Release 0.21.0 right after merging. CTFlows#494 depends on this release (path_dual, box-dual signs, infos[:missing_duals]) and must not be merged before CTModels 0.21.0 is registered. The change is breaking (box-dual signs): see BREAKING.md.

@ocots ocots mentioned this pull request Oct 23, 2026
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