Replies: 1 comment
-
Plan — Constrained flows & duals (roadmap v4 §3→§2)ContextThe design note .reports/constraints-and-duals-design.md was validated on July 8, 2026:
User process decisions: 5 PRs (1 CTModels, 1 CTBase, 3 CTFlows), beta release after each merge so the chain pins registered betas instead of local dev wiring. Process rules (every step)
PR 1 — CTModels:
|
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Constraints & duals — design note (roadmap v4, §3 then §2)
Date: July 8, 2026. Status: decisions validated (July 8, 2026) — A4 for Part A, B1 for Part B, the label-or-function keyword API, plus the follow-up remarks recorded in B.4 (keyword composition for
NonFixed, and the:total/:partialsemantics with frozenμin:partial).Scope: the two blocked items of roadmap-v4.md:
CTModels.Solutions.Solutionshould represent the presence/absence of dual variables (multipliers), the accessor contract, active-set bookkeeping, and display.Part A is treated first because its outcome (the storage question) gates Part B, per the v4 dependency graph. It turns out the gate opens easily: the recommended Part B design needs no new storage at all (multipliers are inputs to the flow, not integrated state), so Part A reduces to cleaning up what CTModels already has.
Sources consulted: current
CTModels.Solutions(solution_types.jl,dual_model.jl,build_solution.jl,show.jl), the pre-refactor CTFlows in save/src/optimal_control_problem_utils.jl, the current CTFlows architecture (Systems,Flows,MultiPhase),CTBase.Data.ControlLaw, and the Handbook (types-traits-interfaces).0. What exists today (facts, not opinions)
CTModels already stores duals.
Solutionhas adual::DualModelTypefield withDualModelType <: AbstractDualModelas a type parameter. The single concreteDualModelhas 8 fields, eachUnion{Function,Nothing}/Union{ctVector,Nothing}:path_constraints_dualt -> μ(t)ornothingboundary_constraints_dualnothingstate/control_constraints_{lb,ub}_dualt -> ν(t)ornothingvariable_constraints_{lb,ub}_dualnothingbuild_solutiondefaults every dual kwarg tonothing; CTFlows'_build_ocp_solutionnever passes any, so flow-built solutions carry an all-nothingDualModel. The public query isdual(sol, model, label)— per-label lookup across all constraint kinds, throwingIncorrectArgumentfor unknown labels.showbranches at runtime on each field beingnothing.The old CTFlows treated multipliers as user inputs.
Flow(ocp, u, g, μ)builtH(t,x,p,v) = p·f + s p⁰ f⁰ + μ(t,x,p,v)'·g(t,x,u,v)(makeHoverloads insave/). Separate wrapper typesStateConstraint/MixedConstraint/Multiplier, paired positionally at the call site. The flow never computed a multiplier and never returned one; junction conditions (costate jumps) were the shooting method's business, supported by flow concatenation with jumps — which the currentMultiPhasemodule still has (jumpsfield, jump functions at switching times).CTModels already uses sentinel component types for absence:
EmptyTimeGridModel <: AbstractTimeGridModel,EmptyDefinition <: AbstractDefinition. Presence/absence of a component is encoded in the component's type, not in aSolution-level flag.CTModels can hand out a declared constraint by label:
constraint(model, label)functors exist (Models/constraint_functors.jl), andConstraintsModelstores labeled tuples(lb, f!, ub, labels)for path/boundary constraints and labeled boxes.Part A —
Solutionwith and without dualsA.1 The options
A1 — Status quo: one
Solution, per-fieldnothinginDualModelWhat we have. "No duals at all" is representable only as the all-
nothingDualModel; nothing distinguishes "this solution kind never has duals" (flow-built) from "this solver produced no duals this time". Accessors andshowbranch at runtime per field.DualModel{Nothing,…}is concretely typed).dual(...)on an all-nothingmodel currently returns a functor wrappingnothingor fails downstream — the failure mode is late and obscure.A2 — A trait on
Solution(HasDuals/NoDualstype parameter)Add a trait axis to
Solutionitself, dispatch accessors/printers on it.Solutioncontract changes.DualModelTypeparameter already encodes it. Two sources of truth for one fact.Rejected.
A3 — A separate type (
ConstrainedSolution <: AbstractSolution)Keep
Solutiondual-free; a subtype carries duals.CTDirect, plots, serialization, export/import) must now accept both types or the abstract type — a large, ecosystem-wide migration for no layout benefit.Rejected.
A4 — Sentinel component:
EmptyDualModel <: AbstractDualModel⭐ recommendedEncode absence where the data lives, exactly like
EmptyTimeGridModelandEmptyDefinitionalready do:Solutionstays unique; the existingDualModelTypeparameter makes the empty/non-empty state dispatchable at compile time (Holy-trait pattern, no runtime branch).CTModels.Solutions— no new idiom to learn.build_solutionconstructsEmptyDualModel()when all dual kwargs arenothing(the current default), aDualModelotherwise. CTFlows'_build_ocp_solutionthen producesEmptyDualModelwithout changing a line on the CTFlows side.DualModelwith per-fieldnothing, as today. No per-field sentinel proliferation — one sentinel for "nothing at all", per-fieldnothingfor "partially filled". This keeps the change small; per-field refinement remains possible later without breaking anything.Migration cost: one new struct, one
has_duals, a branch inbuild_solution, dispatch inshow, and a guard indual(...).CTDirectis untouched (it passes actual duals, so it keeps gettingDualModel).A.2 Accessor contract (roadmap question 2)
The v4 diagnosis is right: a global
duals(sol)cannot be well-defined (several constraints, partial activity, ambiguous stacking). The supported query should remain the one that is already well-defined and already implemented:Contract, precisely:
has_duals(sol) == false→dual(sol, model, label)throws aCTBase.Exceptions-style exception ("this solution carries no dual variables; it was produced by …"), regardless of the label. Rationale: the Handbook flagsUnion{T,Nothing}returns as an anti-pattern (conditional return types), and a silentnothinghere propagates NaNs into plots and quadratures far from the cause. Loud and early beats quiet and late.has_duals(sol) == trueand the label exists → return what is returned today (functor oft, vector, or scalar per constraint kind).IncorrectArgument, as today.has_duals(sol)(cheap, compile-time). A finerhas_dual(sol, model, label)can be added if a real consumer needs it — not before (YAGNI).On inactive constraints: CTModels stores what the producer gives it and does not interpret activity. A stored
t -> μ(t)that is zero off the active set is the producer's convention (this is what NLP duals from CTDirect naturally look like).dualreturns it as-is. The ambiguity the roadmap worries about ("which stored multiplier belongs to which constraint, and is it active?") is resolved by the label key for the first half and deliberately not resolved by CTModels for the second half — activity is not aSolutionconcept (next section).A.3 Active-set bookkeeping (roadmap question 3)
Position: the active set is a solver-side concept and does not enter
Solutionor the flow.Solutionwould therefore duplicate knowledge the producer has, in a form no current consumer reads. If a consumer appears (e.g. plot shading of boundary arcs), add a field or a metadata entry then, with that consumer's actual requirements on the table.This answers the roadmap's question 3 with its own suspected answer: "maybe the flow only integrates the constrained dynamics and multipliers are never a flow output at all" — yes, and Part B makes that concrete.
A.4 Display (roadmap question 4)
Dispatch, don't branch:
The existing per-field
nothingchecks inside theDualModelmethod remain (partial presence). Graceful degradation becomes a property of the type, not of scatteredifs.A.5 What CTModels needs to agree to (summary of Part A)
EmptyDualModel <: AbstractDualModel+has_dualsSolutions/solution_types.jl,dual_model.jlbuild_solution: all-nothing→EmptyDualModel()Solutions/build_solution.jldual(...)throws onEmptyDualModelwith contextSolutions/dual_model.jlshowdispatches on the dual componentSolutions/show.jlNo change to
Solution's type or field list. No change to CTDirect. No change to CTFlows (it inheritsEmptyDualModelthrough thebuild_solutiondefaults).Part B — Constrained flows: constraints and multipliers
B.1 The mathematical setting (why this shapes the design)
For a path constraint
g ≤ 0treated by the indirect method, along a boundary arc the Hamiltonian gains a penalty term:with, classically:
udetermined byg = 0(ord^q g/dt^q = 0for a constraint of orderq),μdetermined analytically from the stationarity along the arc,τ:p(τ⁺) = p(τ⁻) − ν ∂g/∂x, withνa shooting unknown,Two structural consequences drive everything below:
μis a known function of(t, x, p, v), the constrained Hamiltonian is just another Hamiltonian. No state augmentation, no new integrator machinery, no DAE. The existingHamiltonianSystem/PseudoHamiltonianSystempipeline applies verbatim toH + μ'g. The oldmakeHoverloads are the proof: the entire "constrained" feature was one extra summand.MultiPhaseconcatenation supports today (jumpsatswitching_times). Tangency/entry conditions are shooting residuals, not flow code.The hard problem the roadmap fears ("junction/activation handling … is the hard part") is hard only if the flow must discover activation itself. It shouldn't (A.3): in the indirect workflow, activation is structure, and structure is input.
B.2 The design axis: who provides
μ, and is it bundled withg?B1 — Separate carriers, user-supplied
(g, μ), paired at the call site ⭐ recommendedThe modernized old model. Two new nouns in
CTBase.Data, next toControlLaw(the symmetry the roadmap itself suggests):Same construction discipline as
ControlLaw: parametric functor, trait extractors, typed constructors plus convenience constructors from plain functions (is_autonomous/is_variablekwargs). The pair is assembled at theFlowcall:μis an input exactly like the control law — same epistemic status (both derived analytically by the user from the arc structure), same wrapper philosophy.u_boundarywithout constraint on another arc,gfetched from the model (B.3),μswapped during debugging.B2 — Bundled arc descriptor (
ConstrainedArc(u, g, μ))One carrier for the triple, on the argument that on a boundary arc
uandμare both derived fromg, so the triple is one mathematical object.gwith the wrongμacross arcs.ControlLaw, which is already a standalone citizen ofCTBase.Data— the bundle would contain aControlLaw, creating two ways to pass a control.Flow(ocp, g, μ)with a dummy control) would need a degenerate bundle.Rejected as the primary API. Nothing prevents adding a convenience bundle later on top of separate carriers if call sites get noisy.
B3 — The flow computes
μautomatically from the OCP's declared constraintsActivation detection, constraint order
q, tangency conditions, boundary control fromd^q g/dt^q = 0— the constrained analogue of §1's implicit control, but substantially harder (event detection, structure discovery).∂²H̃/∂u²,∂²H̃/∂z∂uAD primitives) would be a prerequisite anyway — another reason §1 lands first, as the roadmap already orders it.Deferred indefinitely. Design B1 does not preclude it (an automatic layer would produce
(g, μ)pairs and feed the same API).B.3 Linking to the OCP's declared constraints — by label
The roadmap asks for "a way for the flow to read the OCP's declared constraints (coordinate with CTModels)". The coordination already exists: constraints are labeled in
ConstraintsModel, andconstraint(model, label)returns a functor. So letconstraint =accept either form:The label form buys three things:
Ktrait is inferred.MultiPhasetrajectory call should assemblepath_constraints_dual(zeros off-arc, the user'sμon-arc), the label is exactly the keydual(sol, model, label)resolves. This is explicitly not implemented now (A.3: flow-built solutions carryEmptyDualModel), but the design costs nothing to keep it possible.The function form is kept because the old code had it and it is genuinely useful (penalization experiments, constraints not worth declaring in the model).
B.4 API details and validations
constraint = …, multiplier = …rather than the oldFlow(ocp, u, g, μ). Consistent with v4's keyword-first convention (variable,variable_costate, and §1's plannedcontrol = u0). Positional(g, μ)reads ambiguously once §1 adds more call shapes.constraintwithoutmultiplier(or vice-versa) → construction-time error, trait-style validation likevariableforNonFixed.Flow(ocp; constraint = :c1, multiplier = μ)(pure state constraint, the oldFlow(ocp, g, μ)case).constraint = (:c1, :c2), multiplier = (μ1, μ2)— summingΣ μᵢ' gᵢ. Cheap to support in the H builder; matched lengths validated at construction. (Single pair remains the 95% case in indirect practice, since distinct constraints are rarely active on the same arc.)hamiltonian_type = :total | :partialoption stays available on constrained flows::total— full composition:H(t,x,p,v) = p·f + sp0·ℓ + μ(t,x,p,v)'·g(t,x,u(t,x,p,v),v). AD differentiates through everything, including the(x,p)-dependence of bothuandμ.:partial—μjoinsuas a frozen argument of the pseudo-Hamiltonian:H̃c(t,x,p,u,μ,v) = p·f + sp0·ℓ + μ'·g(t,x,u,v). Gradients are taken at fixed(u, μ): we differentiate throughg(its explicit dependence onx,v, withuheld fixed) but not throughμ. This is mathematically consistent on a boundary arc: the neglected term(∂μ/∂(x,p))'·gvanishes there becauseg ≡ 0along the arc — exactly the same mechanism that justifies freezingu(the neglected(∂u/∂(x,p))'·∂H̃/∂uvanishes when∂H̃/∂u = 0). Implementation: the constrained pseudo-Hamiltonian functor gains a frozenμslot alongside the frozenuslot; thePseudoHamiltonianSystemAD pipeline extends with one more pass-through argument.NonFixedproblems.constraint/multiplierare construction-time keywords — they define the integrated system:Flow(ocp, u; constraint = …, multiplier = …).variable/variable_costateremain call-time keywords, unchanged:f(t0, x0, p0, tf; variable = v, variable_costate = true). All four coexist on aNonFixedconstrained problem;gandμmay depend onv(theirVDtrait). Undervariable_costate,ṗᵥ = −∂H/∂vpicks up theμ'gterm with the same:total/:partialsemantics: in:partial,∂/∂vis taken at frozenμ, and the neglected(∂μ/∂v)'·gagain vanishes on the arc. No interaction with the (nonexistent) dual storage.+μ'g,g ≤ 0,:min) must be documented once in the guide with thesp0convention; the old code silently assumed it.B.5 The §3→§2 gate, answered
The roadmap blocked §2 on §3 because "the storage question dictates the augmented-state layout". With B1:
The gate is lifted. §2 reduces to: two carrier types in CTBase.Data, one extra summand in the two Hamiltonian builders, keyword plumbing and validation, tests against an analytic reference (e.g. the constrained double integrator / Bryson–Denham problem, whose boundary-arc
μ,νand junction times are known in closed form).C. Decisions summary
Decisions 1–9 were validated on July 8, 2026; decision 10 records the validated follow-up remark on the
:total/:partialsemantics.SolutionEmptyDualModel+has_dualsextractor;Solutiontype unchangedSolutionduplicates whatDualModelTypealready encodes; subtype duplicates 9/10 fields and forks the ecosystemdual(sol, model, label)only; throws (with context) whenhas_dualsis false; predicatehas_duals(sol)nothing= conditional return type + silent NaN propagationSolution, not in the flowPathConstraintwith kind-trait,Multiplier), in CTBase.Data next toControlLaw; paired at theFlowcall by keywordsControlLaw; auto-computedμis a (much later, maybe never) layer on topμ(t,x,p,v)functionconstraint = :labelfetches fromConstraintsModel(also accepts a plain function)EmptyDualModel(v4 position: no duals from the flow); label-linked design keeps later assembly possible(x, p)unchanged; junctions via existingMultiPhasejumpsμwere unknown, which it isn't under 6:totalvs:partialwith constraints:partialfreezesμ(likeu) and differentiates throughg; sound on-arc since the neglected(∂μ/∂z)'·gterm vanishes whereg ≡ 0μin:partialwould contradict the frozen-argument semantics of the pseudo-Hamiltonian path and buys nothing on the arcImpact per package
Data.PathConstraint(kind as trait parameter),Data.Multiplier, traits + typed/convenience constructors, following theControlLawtemplate.+ μ'gin the:totaland:partialH builders;constraint/multiplierkeywords with both-or-neither validation and label resolution; tests (Bryson–Denham class reference).DualModelthroughbuild_solution, whose non-default path is unchanged).Suggested sequencing
:totalpath first (simplest: one summand in the composed Hamiltonian), then:partial(pseudo-Hamiltonian gains the frozenμslot alongside the frozenu), then the control-free constrained case; analytic-reference tests per step, includingNonFixedcases exercisingvariable/variable_costatealongsideconstraint/multiplier.Open questions (deliberately left out of scope)
DualModel(finerhas_*granularity) — only if a consumer materializes.x − ubresiduals) — expressible today via the function form; a dedicated label form for boxes can wait.u_boundaryfromd^q g/dt^q = 0should share machinery — revisit when §1's IFT primitives exist.Beta Was this translation helpful? Give feedback.
All reactions