Shape contract: "1-D = scalar" across Flow constructors
This page is the single cross-constructor reference for the ecosystem's "1-D = scalar" convention: a one-dimensional state (or costate) must be a scalar end to end — never a length-1 container — in every user-facing function, Flow call, and returned trajectory accessor. It consolidates what used to be a handful of scattered per-page admonitions into one table and one set of runnable examples, and records the design decision behind issue #357: which constructors this convention applies to, and why one deliberately sits outside it.
Every ✓ / ✗ example below is a Documenter @example/@repl block re-run on every documentation build, so this page cannot drift from the code. Scope: CPU only, same as every other page in this section.
The rule
CTFlows.Systems._coerce_state is the coercion applied at every point call and trajectory accessor governed by this convention:
a bare
Number(::Real,::Complex) stays a scalar — trivially, it already is one;a length-1
AbstractVector([2.0],SA[2.0], …) collapses to its single element, viaSystems._safe_only(a GPU-safeonly);a length ≥ 2
AbstractVector, or anyAbstractMatrix(batch mode), is left untouched (identity) — this convention only ever removes a spurious length-1 wrapper, never changes any other shape.
The same rule is applied on the input side too: a state-flow's RHS wrapper reads the initial state's shape once (at the first call, from the config) and builds the matching only/identity coercion for the user's function — so a Data.VectorField or SciMLBase.ODEFunction written against a scalar x is called with a genuine scalar even when x0 was supplied as [2.0].
The dividing line
The fix for #357 does not split constructors by "control-toolbox object vs. SciML object" — it splits them by whether the call reaches CTFlows' own Systems/ Trajectories dispatch at all:
Flow(::SciMLBase.AbstractODEFunction)(seeFlow(SciML)) wraps the function inCTFlowsSciMLFlows.SciMLFunctionSystem, which implementsSystems.AbstractSystemand shares the exact samebuild_trajectorydispatch asVectorFieldSystem— so it is in scope, even though it wraps a SciML type.Flow(::SciMLBase.AbstractODEProblem)(seeFlow(SciML)) wraps the fully-assembled problem inSciMLProblemFlow, which callsSciMLBase.remake+CommonSolve.solvedirectly — it never constructs a CTFlowsAbstractSystemand never reachesTrajectories.build_trajectory, so there is nothing for this convention to attach to. This is a genuine bypass, not an oversight: coercing here would mean silently reshaping a value the user handed straight to their ownSciMLBase.ODEProblem.
Compliance table
| Constructor | Reaches CTFlows dispatch? | Coercion mechanism | 1-D = scalar? | Reference |
|---|---|---|---|---|
Flow(::VectorField) | ✓ | Systems._coerce_state | ✓ | Flow(VectorField) |
Flow(::HamiltonianVectorField) | ✓ | Systems._coerce_state | ✓ | Flow(HamiltonianVectorField) |
Flow(::Hamiltonian) | ✓ | Systems._coerce_state | ✓ | Flow(Hamiltonian) |
Flow(h̃, DynClosedLoop) | ✓ | Systems._coerce_state (PseudoHamiltonianSystem) | ✓ | Flow(h̃, law) |
Flow(fc, law) | ✓ | Flows._dim_coerce (from length(x0)) | ✓ | Flow(fc, law) |
Flow(ocp) / Flow(ocp, law) | ✓ | Flows._dim_coerce (from the OCP's declared state_dimension) | ✓ | Flow(ocp) / Flow(ocp, law) |
Flow(::SciMLBase.AbstractODEFunction) | ✓ | Systems._coerce_state (SciMLFunctionSystem) | ✓ (new, #357) | Flow(SciML) |
Flow(::SciMLBase.AbstractODEProblem) | ✗ (direct remake+solve) | — none — | ✗ (by design) | Flow(SciML) |
✓ a scalar or length-1 Vector/SVector state (or costate) collapses to a scalar, in both the point and trajectory call styles. ✗ the state keeps whatever container shape it was given — deliberately, for Flow(::ODEProblem).
The Flow(fc, law)/Flow(ocp[, law]) rows use Flows._dim_coerce — a second, dimension-int-driven implementation of the same rule, layered outside an inner VectorFieldSystem-based flow (see CTFlows.Flows.ControlledFlow). Because only is idempotent on an already-scalar value, this outer coercion is a safe no-op once the inner flow has already collapsed the state — the two mechanisms compose without double-applying anything.
Demonstrated: the two constructors this issue fixed
Flow(::VectorField)
vf = Data.VectorField(x -> -x)
flow = Flows.Flow(vf; reltol=1e-8)
flow(0.0, [1.0], 1.0) # a length-1 Vector x0 → a scalar xf, not [exp(-1)]0.36787944125650673julia> sol = flow((0.0, 1.0), [1.0]);
julia> Trajectories.state(sol)(1.0) # scalar too — the trajectory accessor is coerced the same way
0.3678794412565068Flow(::SciMLBase.AbstractODEFunction)
f = SciMLBase.ODEFunction{false}((u, p, t) -> -p .* u)
flow_f = Flows.Flow(f; reltol=1e-8)
flow_f(0.0, [1.0], 1.0; variable=1.0) # same collapse — SciMLFunctionSystem shares the fix0.36787944125650673Contrasted: the constructor this issue deliberately excludes
prob = SciMLBase.ODEProblem(f, [1.0], (0.0, 1.0), 1.0)
pflow = Flows.Flow(prob; reltol=1e-8)
pflow(0.0, [1.0], 1.0; variable=1.0) # stays a length-1 Vector — no CTFlows dispatch, no coercion1-element Vector{Float64}:
0.36787944125650673The same [1.0] state produces a scalar through Flow(::ODEFunction) and a length-1 Vector through Flow(::ODEProblem) — same numeric dynamics, deliberately different shape, because only one of the two reaches the dispatch this convention lives on.
See also
1-D = scalar (Handbook) — the ecosystem-wide convention this page implements for
CTFlows.jl.Issue #357 — the audit and fix this page documents.
Compatibility overview — the per-constructor feature matrix and the other flow types.
Flow(VectorField),Flow(HamiltonianVectorField),Flow(Hamiltonian),Flow(SciML)— the per-constructor compatibility pages.CTFlows.Systems._coerce_state— the shared coercion mechanism.