API
All exported types and functions are considered part of the public API, and thus documented in this manual.
NSDETimeParallel.AbstractPararealBackend — Type
AbstractPararealBackendHow the fine sweep of Parareal executes. The algorithm lives ONCE in parareal/parareal.jl; a backend supplies only these primitives:
is_root(backend)— whether this process runs the serial parts (coarse init, correction, convergence check).trueeverywhere except non-root MPI ranks.sync_starts!(backend, cache, k)— make the chunk starting valuesU[n]available wherever chunknwill be solved. No-op off MPI.fine_map!(backend, cache, solution, problem, parareal, k; …)— solve chunksk:Nwith the fine solver; deposit boundary values intocache.F[n+1]on the root and chunk solutions intosolution.lastiterate(MPI ranks keep their chunk local untilcollect_chunks!).snapshot!(backend, solution, k; …)— record iteratekwhensaveiteratesis on. In memory by default; on MPI the ranks have already written their chunk to the run directory insidefine_map!.sync_converged(backend, flag)— agree the convergence decision.collect_chunks!(backend, solution, cache; …)— bring final chunks (and, under MPI withsaveiterates, the per-iterate history) to the root.
NSDETimeParallel.AbstractPararealParameters — Type
An abstract type for parameters of Parareals.
NSDETimeParallel.AbstractTimeParallelCache — Type
An abstract type for caching intermediate computations in AbstractTimeParallelSolvers.
NSDETimeParallel.AbstractTimeParallelIterate — Type
An abstract type for iterates in AbstractTimeParallelSolutions.
NSDETimeParallel.AbstractTimeParallelParameters — Type
An abstract type for parameters in AbstractTimeParallelSolvers.
NSDETimeParallel.AbstractTimeParallelSolution — Type
An abstract type for time-parallel solutions of NSDEBase.AbstractInitialValueProblems.
NSDETimeParallel.AbstractTimeParallelSolver — Type
An abstract type for time-parallel solvers of NSDEBase.AbstractInitialValueProblems.
NSDETimeParallel.AbstractTolerance — Type
An abstract type for tolerance parameters in AbstractTimeParallelSolvers.
NSDETimeParallel.AbstractWeights — Type
An abstract type for proximity-function weights used by AbstractTimeParallelSolvers.
NSDETimeParallel.DistributedBackend — Type
Fine chunks via pmap over the default worker pool (Distributed.jl).
NSDETimeParallel.MPIBackend — Type
SPMD over MPI ranks: rank r owns chunk r + 1. Requires MPI.Init().
NSDETimeParallel.Parareal — Type
Parareal <: AbstractTimeParallelSolverA composite type for the Parareal algorithm.
Constructors
Parareal(finesolver, coarsesolver, parameters, tolerance)
Parareal(finesolver, coarsesolver; parameters=PararealParameters(), tolerance=Tolerance())Arguments
finesolver :: AbstractInitialValueSolver: fine solver (accurate but expensive).coarsesolver :: AbstractInitialValueSolver: coarse solver (rough but quick).parameters :: AbstractPararealParameters: parameters for the correction step.tolerance :: AbstractTolerance: tolerance and error mechanism.
Methods
(parareal::Parareal)(solution::PararealSolution, problem::AbstractInitialValueProblem)
(parareal::Parareal)(problem::AbstractInitialValueProblem)returns the solution of a problem using parareal.
NSDETimeParallel.PararealCache — Type
PararealCache <: AbstractTimeParallelCachePre-allocated state for one Parareal run: the chunk boundary values U, the fine and coarse boundary results F and G, the previous-iterate snapshot U_ for the error function, the chunk time grid T, the chunk problems (built once, their u0 slots referencing U so the correction updates them in place), and reusable solver caches.
NSDETimeParallel.PararealIterate — Type
PararealIterate <: AbstractTimeParallelIterateA composite type for a single iterate in a PararealSolution: one fine chunk solution per time chunk. The chunk storage is CONCRETE — chunks are built eagerly from the fine solver, so eltype(iterate.chunks) is the fine solver's concrete solution type, not an abstract box. This matters: these objects cross thread and process boundaries in the hot path.
Constructors
PararealIterate(chunks::AbstractVector{𝕊}) where 𝕊<:AbstractInitialValueSolution
PararealIterate(problem::AbstractInitialValueProblem, parareal::Parareal)Functions
Methods
(iterate::PararealIterate)(t::Real)returns the value of iterate at t via interpolation of the owning chunk.
NSDETimeParallel.PararealParameters — Type
PararealParameters <: AbstractPararealParametersA composite type for the basic parameters of Parareal.
Constructors
PararealParameters(N, K)
PararealParameters(; N=10, K=N)Arguments
N :: Integer: number of time chunks/processors.K :: Integer: maximum number of iterations.
NSDETimeParallel.PararealSolution — Type
PararealSolution <: AbstractTimeParallelSolutionA composite type for an AbstractTimeParallelSolution obtained using Parareal.
Constructors
PararealSolution(lastiterate, errors)
PararealSolution(problem::AbstractInitialValueProblem, parareal::Parareal)Arguments
lastiterate :: PararealIterateerrors :: AbstractVector{ℝ} where ℝ<:Real: iteration errors.
Functions
firstindex: first index.getindex: get iterate.lastindex: last index.numiterates: number of iterates.numchunks: number of chunks of last iterate.setindex!: set iterate.
Methods
(solution::PararealSolution)(t::Real)returns the value of solution at t via interpolation.
NSDETimeParallel.PipelinedMPIBackend — Type
PipelinedMPIBackend <: AbstractPararealBackendPipelined (task-scheduled) Parareal over MPI ranks, one rank per chunk — the scheduling of Aubanel (thesis §2.7, eq:aubanel_parallel_efficiency). There is no root: the serial coarse chain is passed rank to rank, and each rank runs its fine solve for the next sweep BEFORE blocking on the corrected start from its left neighbour, so the fine work hides the serial coarse cost. Judge measured speed-ups against theoretical_speedup(K, N, ζ; estimate = :aubanel).
This is a SEMANTIC VARIANT of Parareal, not a drop-in adapter. Differences from the primitive-based backends:
Frontier convergence, two flavours. Convergence sweeps left to right as a frontier; a chunk stops once every chunk to its left has accepted AND its own per-chunk test passes, or it hits the finite-termination diagonal (
k = n). The per-chunk test depends ontolerance.ψ:ψ = ψ∞— the CERTIFIED thesis criterion (§3.4subsec:local_proximity): the test is the weighted CURRENT defect of the previous iterate at this rank's outgoing boundary,w^(T[1] − T[n+1]) ‖uold − F(start)‖ ≤ ϵ, whereuoldis the boundary value sent last sweep andF(start)this sweep's fine solve from the very start that produced it — the thesis's one-stage lag (rem:pipelined_protocol). The acceptance flag is the prefix conjunctionA_n = A_{n−1} ∧ tₙ, riding the existing messages; when the run accepts, the assembled vector satisfiesψ∞ ≤ ϵ, hence‖U − U*‖_{W,∞} ≤ s(θ_F) ϵ(thm:equivalence_error_norm_local) — setϵ = r/s(θ_F)to certify radiusr. Requires a PRESET scalar weight:weights.updatew = trueis rejected (no root sees all boundaries at a common sweep). Acceptance is suppressed for one sweep whenever the incoming start changed in the same message that raised the flag (an upstream diagonal exit): the test must be re-run against the frozen start, or the certificate would mix iterates — the thesis's freezing cascade, at most one extra sweep per chunk.- anything else — relative STAGNATION of the outgoing boundary (update ≤
tolerance.ϵ), the pre-thesis heuristic; the globalψand its weights are ignored, with a warning unlessψ = ψ₁. Kept as the default for backward compatibility.
solution.errors[k]records, among chunks still active at sweepk, the largest weighted defect (ψ∞mode: the running restriction ofψ∞to active chunks) or the largest relative update (stagnation mode) — a frontier diagnostic either way.Chunks may stop at different sweeps, so
numiterates(solution)counts the sweeps of the LAST chunk to converge.saveiteratesis not supported: recording every sweep would force the per-sweep synchronisation the pipeline exists to avoid.
Accepted chunks remain fine solves from their accepted starts (one final fine solve is issued whenever a start arrived after the last sweep's), so flatten, seams and every downstream statistic read exactly as for the other backends. At ϵ = 0, K = N, finite termination makes the result the chunked fine solve, bitwise — the same fixed point as SerialBackend, reached by a different route.
Requires MPI.Init() and at least N ranks; ranks beyond N idle through the chain and join the final gather. Select with mode = "PIPELINED" or by passing the backend object; pair with Tolerance(ϵ = r/s, ψ = ψ∞, weights = Weights(w = ŵ)) for the certified criterion (ŵ preset — the updater is rejected here).
NSDETimeParallel.SerialBackend — Type
Runs everything in the calling task. The reference implementation.
NSDETimeParallel.ThreadsBackend — Type
Fine chunks via Threads.@threads (shared memory). No schedule argument is given on purpose: from Julia 1.8 the default is :dynamic, which is what we want, and on 1.6–1.7 the argument does not exist — there the loop is scheduled statically, which is correct and merely balances less well when chunks differ in cost.
NSDETimeParallel.Tolerance — Type
Tolerance <: AbstractToleranceA composite type for the tolerance mechanism of an time-parallel solver.
Constructors
Tolerance(ϵ, ψ, weights)
Tolerance(; ϵ=1e-12, ψ=ψ₁, weights=Weights())Arguments
ϵ :: Real: tolerance.ψ :: Function: error function.weights :: Weights: weights for ψ.
NSDETimeParallel.Weights — Type
Weights <: AbstractWeightsA composite type for the weights of Tolerance.
Constructors
Weights(; w=1.0, updatew=false, δ=1.0)Arguments
w :: Real: weighting factor for ψ, a PER-UNIT-TIME base (w = exp(Λ)for per-time rate Λ). A finite, positive scalar. (Earlier versions admitted a vector here; nothing downstream —ψ₂,ψ∞,update!, the MoWi zoom — ever defined what a per-chunk vector of bases meant, and each threw aMethodErroron one. A vector is now refused at construction.)updatew :: Bool: flags when toupdate!wusing (an approximation of) the Lipschitz function of the fine solver. Bulk-synchronous backends only: underPipelinedMPIBackendwithψ∞there is no root that sees all boundaries at a common sweep, so the updater cannot run — that path REJECTSupdatew = true; presetw(a known rate, or a frozen probe measurement — see the criterion docs).δ :: Real: safety divisor applied to the MEASURED rate only (δ < 1inflates the measured base — the thesis's safety factorw = C·Λ̂withC = 1/δ). It never touches the runningw, so withupdatew = falseupdate!is a strict no-op regardless ofδ.
Functions
update!: updateswusing (an approximation of) the Lipschitz function of the fine solver.
Base.firstindex — Method
firstindex(iterate::PararealIterate)returns the first index of iterate.
Base.firstindex — Method
firstindex(solution::PararealSolution)returns the first index of solution.
Base.getindex — Method
getindex(iterate::PararealIterate, n::Integer)returns the n-th chunk of iterate.
Base.getindex — Method
getindex(solution::PararealSolution, n::Integer)returns the n-th chunk of the last iteration of a PararealSolution.
Base.lastindex — Method
lastindex(iterate::PararealIterate)returns the last index of iterate.
Base.lastindex — Method
lastindex(solution::PararealSolution)returns the last index of solution.
Base.length — Method
length(iterate::PararealIterate)returns the number of chunks of iterate.
Base.length — Method
length(solution::PararealSolution)returns the number of chunks of solution.
Base.setindex! — Method
setindex!(iterate::PararealIterate, value::AbstractInitialValueSolution, n::Integer)stores value into the n-th chunk of iterate.
Base.setindex! — Method
setindex!(solution::PararealSolution, chunk::AbstractInitialValueSolution, n::Integer)stores an AbstractInitialValueSolution as the n-th chunk of the last iteration of a PararealSolution.
NSDEBase.initialize_cache — Method
NSDEBase.initialize_cache(problem, parareal::Parareal) :: PararealCachereturns a reusable PararealCache for problem, honouring the same contract NSDERungeKutta honours for its solvers. Build once, then call parareal(cache, solution, problem; ...) repeatedly — e.g. in timing loops, where rebuilding the cache per solve measures the allocator, not the algorithm.
NSDEBase.initialize_solution — Method
NSDEBase.initialize_solution(problem, parareal::Parareal; saveiterates=false) :: PararealSolutionreturns a PararealSolution for problem. Reusable across solves.
NSDEBase.solve! — Method
solve!(solution::AbstractTimeParallelSolution, problem, solver::AbstractTimeParallelSolver; kwargs...) :: AbstractTimeParallelSolutioncomputes the solution of problem using solver.
NSDEBase.solve — Method
solve(problem, solver::AbstractTimeParallelSolver; kwargs...) :: AbstractTimeParallelSolutioncomputes the solution of problem using solver.
NSDETimeParallel.TimeParallelSolution — Method
TimeParallelSolution(problem::AbstractInitialValueProblem, parareal::Parareal)returns a PararealSolution constructor for the solution of problem with parareal.
NSDETimeParallel.Wnorm — Method
Wnorm(iterate::PararealIterate, reference::AbstractInitialValueSolution, w::Number)weighted distance between iterate and a reference solution at the chunk boundaries.
NSDETimeParallel.boundarytimes — Method
boundarytimes(iterate::PararealIterate)returns the N + 1 chunk-boundary times of iterate: each chunk's start, plus the final chunk's end. These are the points Parareal iterates on — the grid every convergence measure (Wnorm, per-iterate error studies) is taken over.
NSDETimeParallel.boundaryvalues — Method
boundaryvalues(iterate::PararealIterate)returns the N + 1 chunk-boundary values of iterate: each chunk's first state, plus the final chunk's last state — the U vector of the Parareal iteration, read off the chunk solutions. The returned states ALIAS the chunk storage; copy them before mutating.
NSDETimeParallel.coarseinit! — Method
coarseinit!(cache, parareal)runs the serial coarse pass over the chunk grid, filling G and — where makeGs allows — seeding the chunk starting values U. Chunks whose makeGs[n] is false keep whatever U[n] already holds (injected guesses, e.g. from a moving-window driver).
NSDETimeParallel.collect_iterates! — Method
collect_iterates!(solution::PararealSolution; directory)reads the per-iterate chunk files written by the MPI backend under saveiterates back into solution.iterates. Chunks below the diagonal (n < k) are final from earlier iterations and are copied forward.
NSDETimeParallel.contractionrate — Method
contractionrate(errors::AbstractVector{<:Real}) :: Float64
contractionrate(solution::AbstractTimeParallelSolution) :: Float64the observed per-iteration contraction rate β of an error trace: the least-squares slope of log(errors[k]) against k, exponentiated, so that errors[k] ≈ C βᵏ. Non-finite entries and EXACT ZEROS are excluded before fitting — finite termination drives the last error to bitwise zero, which is a property of the algorithm's fixed point, not of its rate. Returns NaN when fewer than two usable points remain: a run that converges in one iteration exhibits no observable rate, and downstream bounds (e.g. the §4.3 outer radius R = r/βᴷ) must treat that as unbounded rather than fabricate a number.
NSDETimeParallel.correct! — Method
correct!(cache, parareal, k)the serial Parareal correction sweep after fine batch k: re-run the coarse solver from the freshest starts and update U[n+1] = F[n+1] + (Gnew − Gold), writing IN PLACE (ping-pong against the U_ snapshot taken by the error function — the old code rebound a fresh array here every step to protect that history).
Two details make finite termination exact in floating point, not just in real arithmetic:
- Order of operations.
F + (Gnew − Gold)returnsFbitwise whenever the two coarse values cancel bitwise;(Gnew + F) − Golddoes not — it roundsFintoGnewfirst and loses low bits (withGnew = Gold = 1e16andF = 1it returns0). Do not "simplify" this expression. - The newly exact interface is assigned, not computed. After batch
kthe startU[k]is already exact, soF[k+1]is the exact value ofU[k+1]andU[k]has not moved sinceG[k+1]was last computed: the coarse re-run atn = kwould reproduceG[k+1]bitwise. Skip it and copyF[k+1]across — one coarse solve saved per sweep, and no arithmetic on the exact prefix at all.
NSDETimeParallel.costratio — Method
costratio(problem, parareal; repeats=3) :: Realmeasures ζ, the coarse/fine cost ratio over ONE chunk of problem under parareal's chunking — the input theoretical_speedup needs. Both solvers are timed on the first chunk with @elapsed (best of repeats, after a compile warm-up). Measure ζ rather than guessing it from step-size ratios: those ignore per-step cost differences between the two solvers.
NSDETimeParallel.flatten — Method
flatten(iterate::PararealIterate)concatenates the chunk solutions into one pair of vectors (u, t), dropping each chunk's FIRST point after chunk 1: boundaries are stored twice (chunk n's last point and chunk n+1's corrected start), and keeping both would double-weight every boundary in any downstream statistic. The kept value is the fine endpoint; the dropped corrected start agrees with it only to the solve tolerance. t is monotone.
NSDETimeParallel.flatten — Method
flatten(solution::PararealSolution)flattens the last iterate: one (u, t) pair for the whole span, chunk boundaries deduplicated.
NSDETimeParallel.iteration_budget — Method
iteration_budget(S::Real, N::Integer, ζ::Real; estimate=:improved) :: Intthe largest per-window iteration count K ∈ 1:N for which Parareal over N chunks with cost ratio ζ still meets the target speed-up S, from the parallel-efficiency estimates behind theoretical_speedup; 0 when the target is infeasible — no K ≥ 1 reaches S, not even a single sweep. The budget is capped at N because the algorithm terminates after N sweeps: a target so modest that every sweep count meets it yields N, not a number beyond the algorithm's range.
estimate = :improved(default): the work-credited bound,S(K) = N / (K (1 + ζN)(1 − (K − 1)/2N)), which is strictly decreasing on1:N. It is inverted by integer bisection againsttheoretical_speedupitself, so the budget agrees with the estimate it is derived from bit for bit. (The closed-form root of the underlying quadratic was used before; its discriminant goes negative exactly when the target is BELOW theK = Nspeed-up — everyKfeasible — and the old code read that as "infeasible" and returned0. It also cancelled badly at largeN.)estimate = :naive:K = ⌊N/(S(1 + ζN))⌋, likewise clamped to0:N.
Measure ζ with costratio; a step-size guess distorts the budget exactly where it matters (large ζN).
NSDETimeParallel.maxseam — Method
maxseam(iterate::PararealIterate) :: Realreturns the largest chunk-boundary seam of iterate (see seams), or 0.0 for a single-chunk iterate.
NSDETimeParallel.maxseam — Method
maxseam(solution::PararealSolution) :: Realmaxseam of the last iterate.
NSDETimeParallel.numchunks — Method
numchunks(iterate::PararealIterate)returns the number of chunks of iterate.
NSDETimeParallel.numchunks — Method
numchunks(solution::PararealSolution)returns the number of chunks of solution.
NSDETimeParallel.numiterates — Method
numiterates(solution::PararealSolution)returns the number of iterates of solution.
NSDETimeParallel.parareal! — Method
parareal!(cache, solution, problem, parareal, backend; saveiterates, directory, nocollect)runs Parareal with the given AbstractPararealBackend. Under MPI this is SPMD: every rank calls it; is_root guards the serial parts.
NSDETimeParallel.seams — Method
seams(iterate::PararealIterate) :: Vector{<:Real}returns the N − 1 chunk-boundary seam sizes of iterate: at each interior boundary, ‖(start of chunk n+1) − (end of chunk n)‖ — the distance between the corrected starting value U[n+1] and the fine endpoint F(U[n]) it should agree with at convergence. This is exactly the discontinuity that flatten hides by dropping the corrected start, so any statistical claim made on flattened output should report maxseam alongside it: under the weighted criterion ψ₂, late-window seams are unconstrained BY DESIGN (the discount forgives them), and only the seam sizes say which regime a run actually exercised. Empty for a single-chunk iterate.
NSDETimeParallel.seams — Method
seams(solution::PararealSolution) :: Vector{<:Real}seams of the last iterate.
NSDETimeParallel.shiftwindow! — Method
shiftwindow!(cache::PararealCache, τ0, τN)re-targets a cache at the window [τ0, τN]: recomputes the chunk grid T and updates every chunk problem's tspan in place. This is the official seam for window drivers (e.g. NSDEMovingWindow) — chunk problems are built once at cache construction, so their time spans must be moved through this function, never by poking cache.T alone.
NSDETimeParallel.theoretical_speedup — Method
theoretical_speedup(K::Integer, N::Integer, ζ::Real; wallclock=false, estimate=:work) :: RealParareal's own speed-up ceiling over the serial fine solve, for K iterations over N chunks with cost ratio ζ = (coarse solve time over one chunk) / (fine solve time over one chunk). Measure ζ — costratio does it on one chunk of each — rather than guessing it. Measured speed-ups must be judged against this ceiling, not against the serial fine solve wishfully.
Three estimates (thesis §2.7), selected with estimate:
:work(default) — the classical WORK bound, whose(1 - (K - 1)/2N)factor credits the shrinkingk:Nsweeps (eq:improved_parallel_efficiency). That credit is saved work, not saved wall time: with workers ≥ chunks a parallel sweep costs one chunk-time however few chunks remain, so wall-clock measurements at smallN(and anyK = Npoint) sit BELOW this bound by construction.:wallclock— the work bound with the credit dropped,N / (K (1 + ζN)): the right comparator for a timing benchmark with a full worker pool.wallclock = trueis an equivalent spelling, kept for backward compatibility; an explicitestimatewins if both are given.:aubanel— the task-scheduled (pipelined) ceiling (eq:aubanel_parallel_efficiency),N / (K (1 - (K - 1)/2N)(1 + ζ) + ζ(N - 1)): the serial coarse chain is passed rank to rank and hidden behind fine work, so theζNterm no longer multipliesK. This is the comparator forPipelinedMPIBackend. Atζ = 0it coincides with:work— with a free coarse solver the pipeline has nothing to hide.
NSDETimeParallel.update! — Method
update!(weights::Weights, U, F, T)updates weights.w from the chunk-boundary values — bulk-synchronous backends only (the pipelined ψ∞ path has no global view and rejects updatew = true): the largest measured per-chunk amplification ‖F[i+1] − F[i]‖ / ‖U[i] − U[i−1]‖, converted to a PER-UNIT-TIME base via r^(1/(T[i+1] − T[i])). The conversion is not optional: ψ₂ consumes w as w^(T[1] − T[n]) — an exponent in TIME — so w must be exp(Λ) with Λ a per-time rate. Storing the raw per-chunk ratio (the old behaviour) is right only when chunks are exactly one time unit long, as in the Lorenz thesis set-up; with 10-unit chunks the discount runs at ten times the honest rate, and ψ₂ goes blind past the first boundary.
NSDETimeParallel.ψ₁ — Method
ψ₁(cache, k, weights)standard relative error function: the mean over chunk boundaries of ‖U[n] − V[n]‖ / ‖U[n]‖, where V is the previous iterate (the coarse prediction F at k = 1). Ignores weights.
NSDETimeParallel.ψ₂ — Method
ψ₂(cache, k, weights)weighted error function: the mean over chunk boundaries of ‖w^(T[1] − T[n]) (U[n] − F[n])‖. With w = exp(Λ) for a problem with Lyapunov exponent Λ, this DISCOUNTS each boundary error at the rate the dynamics amplifies it — the moving-window criterion of the thesis. On long chaotic spans it deliberately forgives late-window errors, so convergence in ψ₂ means "early boundaries settled", not "uniformly within ϵ"; check the terminal error against a serial fine solve when that distinction matters (e.g. in speed-up benchmarks, where K sets the Amdahl ceiling).
NSDETimeParallel.ψ∞ — Method
ψ∞(cache, k, weights)local (pipelined) proximity function — the ℓ∞ member of the weighted family (thesis §3.4, subsec:local_proximity): the MAXIMUM over chunk boundaries of ‖w^(T[1] − T[n]) (U[n] − F[n])‖, against ψ₂'s mean. Same defect, same per-unit-time base w, so ψ₂ ≤ ψ∞ ≤ N ψ₂ on any state.
Why it exists — two properties ψ₂ cannot have:
- Sharper certification.
ψ∞(U) ≤ ϵcertifies the weighted-max errormax_n w^(T[1]−T[n]) ‖U[n] − U*[n]‖ ≤ s(θ_F) ϵwiths(θ_F) = Σ_{j<N} θ_F^j,θ_F = Λ_F/w— no factorN(thesisthm:equivalence_error_norm_local; the ℓ¹ constant isN·s(θ_F)). To certify a target radiusr, setϵ = r / s(θ_F). - Prefix-decomposable acceptance.
ψ∞ ≤ ϵis the conjunction of per-chunk tests, decidable left to right with one boolean riding the messages a pipeline already sends.PipelinedMPIBackendtherefore HONOURSψ∞— the one criterion it can enforce without re-serialising the pipeline — whileψ₁/ψ₂remain bulk-synchronous only.
Like ψ₂, evaluated over the stored chunk STARTS (the terminal boundary is omitted — see the convention footnote in the docs) and clamping w ≥ 1. In the bulk-synchronous loop the n = 1 term is identically zero (F[1] mirrors U[1]).