Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

pipekit-cycle

Report 10 · Time-stepping, data assimilation and observation operators, as shipped

Abstract

pipekit-cycle turns a one-step transition into a run over time. A Cycle repeats a step, a DACycle alternates forecast and analysis, and a Recurrence iterates until a condition holds. Three protocols (ForwardModel, ObservationOperator, AnalysisStep) let algorithm libraries plug in without importing it. The scoping report described this package before it was built. Version 0.0.2 ships everything the report named and more: eleven further protocols, a back-and-forth nudging cycle and adjoint specs that pipekit-jax reads. Where the two disagree, the code holds (conventions). A Kalman filter written as a DACycle matches a hand-written loop exactly, and an EnKF converges to it at the 1/Ne1/\sqrt{N_e} rate. Two real gaps remain: a stateful Sequential cannot be a cycle’s step, and the observation index restarts on every call, which silently feeds a restarted cycle the wrong observations. This page proposes two small fixes.

Keywords:pipekit-cycledata assimilationKalman filterEnKFRecurrenceCarryState

Summary

Decision. pipekit-cycle 0.0.2 stays as it is: a separate package beside pipekit-array, depending only on pipekit, with no JAX and no domain code. Its protocols, cycles and helpers are the contract algorithm libraries target. Two additions close the gaps the running example hits, and one more is already proposed in Report 13.

End state. A script composes a step from stateless and stateful operators, cycles it, and restarts it from the last state without re-reading old observations. Algorithm libraries supply forward models, observation operators and analysis steps by structural typing. Every cycle can keep its history, which Report 13’s state profile indexes.

Ownership rules

pipekit-cycle owns the shape of a run over time; the carrier, the physics and the algorithms belong to whoever supplies the steps (Figure 1).

Scripts build cycles from pipekit-cycle; algorithm libraries satisfy its protocols by structural typing. pipekit-cycle groups cycles, DA cycles, protocols, observation and forward helpers, carry states and adjoint specs. It sits on pipekit core; pipekit-jax reads its adjoint specs.

Figure 1:pipekit-cycle 0.0.2 between pipekit core and the code that uses it. Dashed arrows are structural: nothing is imported.

pipekit-cycle owns the cycles, the DA cycles, the protocols, the generic observation and forward helpers, the carry states and the adjoint specs. It imports only pipekit, and never an array library, JAX or a domain package.

Neighbours follow the stack rules. Two matter here: algorithm libraries (filterax, vardax, plumax) satisfy the protocols by structural typing without importing pipekit-cycle, and scripts own the carrier, the initial state, the observations and every file a run writes (Report 13).

What ships

Table 1:The public surface of pipekit-cycle 0.0.2, grouped. Rows in italics go beyond the scoping report.

GroupNamesModule
CyclesCycle, EnsembleCycle, WindowedCycle, Recurrencecycle
DA cyclesDACycle, EnsembleDACycle, SmootherCycleda
NudgingBFNCycle: back-and-forth nudging over a window, adjoint-freebfn
Core protocolsForwardModel, ObservationOperator, AnalysisStepprotocols
Cross-library protocolsReducedBasis, TangentLinearModel, ErrorSubspace, ReducedOrderModel, LatentMap, ObservationNoise, CovarianceLocalizer, EnsembleInflator, StepScheduler, IterativeProcess, SupportsAdjointprotocols
Observation helpersIdentityObs, LinearObs, CallableObs, CompositeObsobs
Forward helpersCallableForward, CompositeForward, NeuralForwardforward
Carry statesDAState, IterationState, WindowStatestate
Adjoint specsAdjointSpec, DirectAdjoint, RecursiveCheckpointAdjoint, TruncatedAdjoint, ImplicitAdjoint, BacksolveAdjoint: frozen dataclasses with no behaviour here, interpreted by pipekit-jaxadjoints

The eleven further protocols (the italic rows of Table 1) are the shared vocabulary for the algorithm libraries: a tangent-linear model and its adjoint (M′, M*), a low-rank error subspace B ≈ LLᵀ, a reduced-order model with encode, decode and step, covariance localisers, ensemble inflators, adaptive step schedulers, and ensemble Kalman inversion as an IterativeProcess. None is used by a cycle in pipekit-cycle; they exist so that, say, filterax’s inflator and vardax’s error subspace agree on one signature.

The running example: a methane plume advected along a transect

The problem. A methane enhancement x∈RNx \in \mathbb{R}^N, in ppb above background, sits on a periodic transect of N=40N = 40 cells. Wind carries it downstream one hour at a time. A column sensor sees every fourth cell, so there are p=10p = 10 observations per hour, for T=10T = 10 hours. The plume starts as a Gaussian of 50 ppb peak at cell 8.

The model. Let M∈RN×NM \in \mathbb{R}^{N \times N} be first-order upwind advection with Courant number 0.4, M=0.6I+0.4SM = 0.6 I + 0.4 S with SS the one-cell downstream shift; Q=0.01IQ = 0.01 I the model-error covariance; H∈{0,1}p×NH \in \{0, 1\}^{p \times N} the selection of every fourth cell; and R=0.1IR = 0.1 I the observation-error covariance. The truth and the observations follow (1):

xt=Mxt−1+ηt,ηt∼N(0,Q),yt=Hxt+εt,εt∼N(0,R).\begin{aligned} x_t &= M x_{t-1} + \eta_t , & \eta_t &\sim \mathcal{N}(0, Q) , \\ y_t &= H x_t + \varepsilon_t , & \varepsilon_t &\sim \mathcal{N}(0, R) . \end{aligned}

The filter starts from m0=0m_0 = 0 with P0=100IP_0 = 100 I: it knows nothing about the plume. The data are synthetic, so every stage has an exact answer to check against.

Stage 1 · Forecast only (pipekit-cycle)

TL;DR. Push the plume downstream for six hours and keep every second state.

Problem. With no observations the forecast is xt=Mxt−1x_{t} = M x_{t-1}, repeated nn times; a history with stride ss keeps the states after steps 1,1+s,1+2s,…1, 1 + s, 1 + 2s, \ldots

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Shapes: N = 40 cells
import pipekit_cycle as pc

class Advect:                                  # satisfies pc.ForwardModel    user
    dt = 1.0
    state_signature = None
    # x_t = M x_{t−1}
    def step(self, x, dt):
        return M @ x                           # (N,) → (N,)

fc = pc.Cycle(Advect(), n_steps=6, save_history=True, history_stride=2)  # today
x6, state = fc(x_true_0, pc.DAState())         # (N,) → (N,)                 today
len(fc.history)                                # → 3: after steps 1, 3, 5    today
[int(np.argmax(c)) for c, _ in fc.history]     # → [9, 10, 10]: the peak moves
state.t                                        # → 0.0: Cycle keeps no clock

A forecast is a Cycle over a ForwardModel; the history lives on the operator.

A plain Cycle wraps the ForwardModel in an internal adapter and threads the state through untouched, so DAState.t stays at 0; only the DA cycles advance it.

Stage 2 · A Kalman filter as a DACycle (pipekit-cycle)

TL;DR. Assimilate the ten hours of observations with an exact Kalman filter, and check that the cycle changes nothing about the arithmetic.

Problem. The Kalman filter carries a mean mtm_t and a covariance PtP_t:

mtb=Mmt−1,Ptb=MPt−1M⊤+Q,Kt=PtbH⊤(HPtbH⊤+R)−1,mt=mtb+Kt(yt−Hmtb),Pt=(I−KtH)Ptb.\begin{aligned} m_t^b &= M m_{t-1} , & P_t^b &= M P_{t-1} M^\top + Q , \\ K_t &= P_t^b H^\top \left( H P_t^b H^\top + R \right)^{-1} , \\ m_t &= m_t^b + K_t \left( y_t - H m_t^b \right) , & P_t &= \left( I - K_t H \right) P_t^b . \end{aligned}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Shapes: N = 40, p = 10, T = 10; the carrier is (m, P): (N,), (N, N)
class KFForward:                               # pc.ForwardModel              user
    dt = 1.0
    state_signature = None
    # m_b = M m;  P_b = M P Mᵀ + Q
    def step(self, carrier, dt):
        m, P = carrier
        return M @ m, M @ P @ M.T + Q          # ((N,), (N, N)) → same

class KalmanAnalysis:                          # pc.AnalysisStep              user
    # K = P_b Hᵀ (H P_b Hᵀ + R)⁻¹
    # m = m_b + K (y − H m_b);  P = (I − K H) P_b
    def __call__(self, forecast, obs, *, obs_op, obs_err_cov):
        m, P = forecast
        Hm = obs_op.linearize(m).H                                   # (p, N)
        K = P @ Hm.T @ np.linalg.inv(Hm @ P @ Hm.T + obs_err_cov)  # (N, p)
        return m + K @ (obs - obs_op(m)), (np.eye(N) - K @ Hm) @ P

da = pc.DACycle(KFForward(), pc.LinearObs(H), KalmanAnalysis(),
                obs_source=pk.Lambda(lambda i: y[i]), n_steps=T)    # today
(m, P), state = da((m0, P0), pc.DAState(obs_err_cov=R))           # today
# → max |m − m_hand| = 0.0, max |P − P_hand| = 0.0
# → RMSE against the truth 0.125 ppb (prior RMSE 17.1 ppb)
state.t, state.cycle_count, len(da.history)   # → 10.0, 10, 10       today

The Kalman filter as a DACycle, against a hand-written loop over

r10-eq-kf
.

Each cycle steps the forward model, calls obs_source(i) for the step’s observations, and hands both to the analysis with obs_err_cov read from the state. The result is bit-for-bit the hand-written filter. Two conventions follow from the code and are worth stating once:

Stage 3 · The same problem as a stochastic EnKF (pipekit-cycle)

TL;DR. Replace the exact covariance with an ensemble and check that the ensemble mean approaches the Kalman mean as the ensemble grows.

Problem. With NeN_e members x(i)x^{(i)}, the forecast covariance is the sample covariance of the anomalies, and every member is updated against its own perturbed observations, as in the overview ((11)). For a linear-Gaussian model the EnKF mean converges to the Kalman mean mtm_t, and its error falls as Ne−1/2N_e^{-1/2} once the ensemble is large enough that the gain is well estimated.

1
2
3
4
5
6
7
8
9
10
# Shapes: N_e members, each (N,); the carrier is a list of N_e arrays
class EnKF:                                    # pc.AnalysisStep              user
    # K = P_b Hᵀ (H P_b Hᵀ + R)⁻¹;  x⁽ⁱ⁾ ← x⁽ⁱ⁾ + K (y + ε⁽ⁱ⁾ − H x⁽ⁱ⁾)
    def __call__(self, forecast, obs, *, obs_op, obs_err_cov): ...   # list → list

eda = pc.EnsembleDACycle(Member(), pc.LinearObs(H), EnKF(),
                         obs_source=obs_source, n_steps=T, n_members=N_e)  # today
members, state = eda(members_0, pc.DAState(obs_err_cov=R))   # N_e × (N,)     today
# RMS |mean(members) − m| , median over 10 seeds; KF posterior std 0.193 ppb
# → N_e =  50: 0.367   N_e = 200: 0.063   N_e = 800: 0.027

An EnKF as an EnsembleDACycle; the carrier is a list of members.

From 200 to 800 members the gap shrinks by a factor of 2.3, close to the factor of 2 that Ne−1/2N_e^{-1/2} predicts: the log-log slope is −0.61, and a seeded bootstrap over the ten seeds puts its 99 % interval at [−0.96, −0.38], which contains −0.5. At 50 members it is six times larger than at 200: with no localisation, 50 members estimate a 40 × 40 covariance poorly, and the gain carries that error. Inflation and localisation are the analysis step’s business; pipekit-cycle names their signatures (EnsembleInflator, CovarianceLocalizer) but does not apply them.

Stage 4 · Swap the forward model (pipekit-cycle)

TL;DR. Replace the physical model with an emulator and leave the cycle unchanged.

Problem. The cycle only needs something that satisfies ForwardModel. An emulator M^\hat{M} trained to reproduce MM should therefore drop in with no other change, and an exact emulator must give the same analysis.

1
2
3
4
5
6
7
8
# Shapes: carrier (m, P)
emulator = pc.CallableForward(lambda carrier, dt: step_fn(carrier), dt=1.0)  # today
neural = pc.NeuralForward(model_op=pk.Lambda(step_fn), dt=1.0)               # today
for fwd in (emulator, neural):
    da = pc.DACycle(fwd, pc.LinearObs(H), KalmanAnalysis(),
                    obs_source=obs_source, n_steps=T)                       # today
    (m_swap, _), _ = da((m0, P0), pc.DAState(obs_err_cov=R))
# → max |m_swap − m| = 0.0 for both

The forward model swapped twice: a callable and a pipekit Operator.

NeuralForward(model_op, dt, state_signature=None) is a thin, working wrapper, not a stub: step calls model_op(state) and ignores dt, so any pipekit Operator (a registry-loaded model, a Sequential) becomes a forward model.

Stage 5 · A Gauss–Newton retrieval as a Recurrence (pipekit-cycle)

TL;DR. Retrieve a methane column enhancement from five-band transmittances by iterating until the update is negligible.

Problem. Beer–Lambert absorption gives transmittances fb(α)=exp⁡(−kbα)f_b(\alpha) = \exp(-k_b \alpha) in bands b=1,…,5b = 1, \ldots, 5 with absorption coefficients kbk_b, for a column enhancement α\alpha. With measured yby_b and Jacobian Jb=−kbexp⁡(−kbα)J_b = -k_b \exp(-k_b \alpha), Gauss–Newton iterates (3),

αj+1=αj+J⊤(y−f(αj))J⊤J,\alpha_{j+1} = \alpha_j + \frac{J^\top \left( y - f(\alpha_j) \right)}{J^\top J} ,

and stops when ∣αj+1−αj∣<10−8|\alpha_{j+1} - \alpha_j| < 10^{-8}.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Shapes: α scalar; y, f(α), J: (5,)
f = pc.CallableObs(lambda a: np.exp(-k * a),
                   linearize_fn=lambda a: -k * np.exp(-k * a))       # today

class GaussNewton(pk.StatefulOperator):                               # user
    # α ← α + Jᵀ (y − f(α)) / (Jᵀ J)
    def _apply(self, a, s):
        J, r = f.linearize(a), y_b - f(a)                     # (5,), (5,)
        step = float(J @ r / (J @ J))
        return a + step, pc.IterationState(iter_count=s.iter_count + 1,
                                           residual_norm=abs(step))

gn = pc.Recurrence(GaussNewton(),
                   condition_op=pk.Lambda(lambda s: s.residual_norm < 1e-8),
                   max_iters=50)                                      # today
a_hat, s = gn(0.0, pc.IterationState())                               # today
# → α̂ = 120.014 (truth 120), gn.iters = 6, gn.converged = True
# → Jᵀ (y − f(α̂)) = 9.9e-20: the normal equations hold at α̂

An iterative retrieval is a Recurrence with a stateful step and a condition operator.

Recurrence(step_op, condition_op, max_iters=1000) calls condition_op on the state after each step and stops on a truthy result; it records iters and converged on the operator. The condition must be an Operator, so a bare function is wrapped in pk.Lambda.

Stage 6 · Restart the filter, and compose a step (the two gaps)

TL;DR. Run the ten hours as two five-hour calls, as an operational cycle restarted from its last state would, and build a cycle step from a Sequential.

Problem. Restarting a filter from its last carrier and state must give the same analysis as one uninterrupted run, provided the second call reads observations y5,…,y9y_5, \ldots, y_9. A step that mixes a stateless operator and a stateful one is a Sequential, which pipekit core threads state through.

1
2
3
4
5
6
7
8
9
10
11
12
# Shapes: as stage 2
da5 = pc.DACycle(KFForward(), pc.LinearObs(H), KalmanAnalysis(),
                 obs_source=obs_source, n_steps=5)                   # today
c, s = da5((m0, P0), pc.DAState(obs_err_cov=R))                     # hours 0–4
(m_restart, _), s = da5(c, s)                                       # hours 5–9?
# indices passed to obs_source → [0, 1, 2, 3, 4, 0, 1, 2, 3, 4]
# → max |m_restart − m| = 15.4 ppb, although s.cycle_count = 10

step = pk.Sequential([pk.Lambda(lambda x: M @ x), Count()])         # today
step(x, pc.DAState())          # → threads the state: cycle_count 1  today
pc.Cycle(step, n_steps=3)
# → TypeError: step_op must be a StatefulOperator or a ForwardModel, got Sequential.

Two gaps in 0.0.2: the observation index restarts, and a stateful Sequential is not a step.

The restart silently re-assimilates the first five hours of observations and misplaces the plume by up to 15 ppb, with no warning: the state’s cycle_count is 10, but obs_source sees the call-local index. BFNCycle’s docstring already tells users to “supply a stateful or clock-aware obs_source” for a global clock. Workstream B makes the clock part of the cycle. The Sequential case is workstream A.

The scoping report’s names, checked

Table 2 checks the stack names the scoping report’s examples use.

Table 2:The stack names the scoping report’s examples use, at the pinned revisions.

Name in the reportAt the pinned revisions
pc.Cycle, pc.EnsembleCycle, pc.WindowedCycle, pc.Recurrence, pc.DACycle, pc.EnsembleDACycle, pc.SmootherCycleexist; signatures in Section ??
pc.NeuralForward(model_op=..., dt=...)exists, as a working wrapper
pc.Checkpoint(cycle_op)does not exist; pipekit-train has a training Checkpoint callback, and CarryState.to_dict serialises a state
pk.config_hash(da)does not exist; pipekit.hashing.sha256_hex(stable_json(da.get_config())), or Report 9’s proposed pipeline_hash
pk.qc.AssertCallable as condition_opexists, but raises instead of returning a boolean (stage 5)
gc.CatalogTimeQuery, gz.tropomi.LoadL2, gz.qa.AssertValidFractiondo not exist at the pinned revisions
StateCatalog, state_cat.write(history, ...)not built; Report 13 replaces them with a state profile in geocatalog
pe.S3ModelRegistry, registry.load(name)S3ModelRegistry and load(ref) exist in pipekit-experiment, usually imported as px; pe is pipekit-evaluate elsewhere in these reports
filterx.adapters.pipekit.EnKFAnalysis, plumax.adapters.pipekit.ChemistryForwardnot checked: those libraries are not pinned here. The library is filterax in the overview; pipekit-cycle’s README quickstart still says filterx

Current-state audit

Two of the scoping report’s pieces need action; the rest ship and work (Table 3), and Figure 2 counts the page’s calls by marker. The audit read references/geostack/pipekit at ef3c17f on 13 October 2026.

A grid with one row per stage of the running example and per workstream, and one square per stack call in that row's sketches, coloured by its marker. Of 27 calls, 20 run today, 2 are proposed by this report's workstreams and 5 are code the reader writes.

Figure 2:What this page’s sketches call: of 27 stack calls, 20 run today, 2 are proposed by this report’s workstreams and 5 are code the reader writes (how to read it).

Table 3:pipekit-cycle 0.0.2, piece by piece.

SymbolsDefined inUsed byVerdict
Cycle(step_op, n_steps, save_history=False, history_stride=1)cycle.py:71SimulationDataset (pipekit-train), SequentialHealthy. Returns (carrier, state); history on .history
EnsembleCycle(step_op, n_steps, n_members)cycle.py:139Healthy. List carrier, shared state; no history (Report 13 D1)
WindowedCycle(step_op, window, stride=1)cycle.py:202Healthy. Input is a list of carriers; returns the window-final carriers
Recurrence(step_op, condition_op, max_iters=1000)cycle.py:265Healthy. Records iters, converged
step_op coercion, _coerce_stepcycle.py:50every cycle aboveSplit ownership. Accepts a StatefulOperator or a ForwardModel; rejects a stateful Sequential that pipekit core threads state through
DACycle(forward_model, obs_op, analysis_step, obs_source=None, n_steps=1, save_history=True)da.py:84the overview’s stage 4Healthy. Exact against a hand-written Kalman filter
EnsembleDACycle(..., n_steps=1, *, n_members)da.py:184Healthy. No key, no inflation argument, no history
obs_source(i) and obs_err_cov from the stateda.py, bfn.pyall DA cyclesSplit ownership. Call-local index; R fixed per call (stage 6; the overview’s obs_err_source)
SmootherCycle(forward_model, obs_op, analysis_step, window, stride=1, obs_source=None)da.py:284Healthy. stride is windows per call; the analysis receives lists of forecasts and observations
BFNCycle(forward_model, obs_op, nudging_gain, *, window, max_iters=10, tol=1e-3, …)bfn.py:79Healthy. Beyond the plan
IdentityObs, LinearObs(H), CallableObs(fn, linearize_fn=None), CompositeObs(components)obs.pyHealthy. CompositeObs applies components[0] first
CallableForward(fn, dt), CompositeForward(components), NeuralForward(model_op, dt)forward.pyHealthy. CompositeForward sums dt and ignores the one passed
the 14 protocolsprotocols.pyalgorithm adapters (not checked)Healthy. All runtime_checkable
adjoint specsadjoints.pypipekit_jax.adjointsHealthy. Specs only; pipekit-jax interprets them

Smaller findings:

Workstream A · Stateful pipelines as cycle steps

Decision. A Sequential that holds at least one stateful step is a valid step_op for every cycle (Table 4).

Table 4:Workstream A’s moves.

SymbolDestinationReason
a Sequential branch in _coerce_steppipekit_cycle.cycleSequential._is_stateful already identifies the case and Sequential._apply_stateful already threads the state; only the type check is missing
Sequential with no stateful stepstill rejected, with a clearer messagea stateless pipeline is not a transition of the carry state; wrap it as a CallableForward or a ForwardModel instead
  1. Accept it. In _coerce_step, return a Sequential unchanged when step_op._is_stateful is true, so Cycle, EnsembleCycle, WindowedCycle and Recurrence take it.

  2. Name the rule in the error. A stateless Sequential gets a message that says which wrapper to use.

  3. Make the private check public if the cycles are not the only callers: an is_stateful property on Sequential in pipekit core.

1
2
3
4
5
6
7
# Shapes: x (N,)
step = pk.Sequential([pk.Lambda(lambda x: M @ x), Count()])   # stateful   today
# Today:
pc.Cycle(step, n_steps=3)                     # → TypeError                today
# After:
x3, s = pc.Cycle(step, n_steps=3)(x, pc.DAState())            # proposed (A)
# → x3 = M³ x, s.cycle_count = 3

Stage 6’s composed step, before and after workstream A.

Workstream B · Observation inputs on the cycle’s clock

Decision. The DA cycles take their observation index, and optionally R, from the cycle’s own clock in the state (Table 5).

Table 5:Workstream B’s moves.

SymbolDestinationReason
obs_clock: Literal["call", "state"] = "call"DACycle, EnsembleDACycle, SmootherCycle, BFNCyclewith "state", obs_source receives state.cycle_count (or the window index), so a restarted cycle reads the right observations; the default keeps today’s behaviour
obs_err_source: Operator | Nonethe same cyclesreturns RtR_t for the step and writes it into DAState.obs_err_cov before the analysis; the overview’s recommended answer to a per-step R (overview, open questions)

B1 · An index that survives restarts

The cycle already advances cycle_count in the state. With obs_clock="state" it passes that count to obs_source instead of the call-local i. Nothing else changes, and the default stays "call" so existing scripts keep their meaning.

B2 · A per-step observation error

Today a script can return (y_t, R_t) from obs_source and unpack it in its own analysis step, because the analysis is user code. That works, but it hides R from the cycle and from every analysis step that expects obs_err_cov. obs_err_source puts RtR_t where the protocol already expects it.

1
2
3
4
5
6
7
8
9
10
11
# Shapes: as stage 2
# Today:
da5 = pc.DACycle(KFForward(), pc.LinearObs(H), KalmanAnalysis(),
                 obs_source=obs_source, n_steps=5)                    # today
# → second call reads y₀ … y₄ again; max |m_restart − m| = 15.4 ppb

# After:
da5 = pc.DACycle(KFForward(), pc.LinearObs(H), KalmanAnalysis(),
                 obs_source=obs_source, obs_err_source=r_source,
                 obs_clock="state", n_steps=5)                  # proposed (B1, B2)
# → second call reads y₅ … y₉; m_restart equals stage 2's m

Stage 6’s restart, before and after workstream B.

Testing gates

Each workstream merges only when its gates pass; the existing gates are checks the code passes today and must keep passing (Table 6).

Table 6:Gates for the proposals and for what ships.

GateWorkstreamPasses when
A1 Composed stepACycle(Sequential([Lambda(M @ ·), Count()]), n_steps=3) returns M3xM^3 x to 0 and cycle_count == 3; a stateless Sequential still raises TypeError
B1 RestartB1two 5-step calls with obs_clock="state" give the 10-step analysis of stage 2 to 0 (today 15.4 ppb); with the default the indices are [0 … 4, 0 … 4] as today
B2 Per-step RB2with obs_err_source returning Rt=(0.1+0.01t)IR_t = (0.1 + 0.01 t) I, the cycle equals a hand-written Kalman filter using the same RtR_t to 0
K1 Kalman exactness (existing)—stage 2: DACycle equals the hand-written filter to 0 in mean and covariance. Passes today
K2 EnKF rate (existing)—stage 3: the log-log slope of the median RMS gap from Ne=200N_e = 200 to 800 is consistent with the Ne−1/2N_e^{-1/2} rate: -0.5 lies inside its 99 % interval from a seeded bootstrap over the 10 seeds (4,000 resamples). Passes today: slope −0.61, interval [−0.96, −0.38]
K3 Model swap (existing)—stage 4: CallableForward and NeuralForward wrapping the same step give the same analysis to 0. Passes today
K4 Retrieval (existing)—stage 5: Gauss–Newton converges in at most 10 iterations with $

Sequencing and risks

The two workstreams are independent and small, so they ship together with Report 13’s ensemble histories; the documentation follows (Figure 3).

Phase 1: A1 stateful Sequential steps, B1 the observation clock, and Report 13's D1 ensemble histories, side by side, then gates A1, B1 and R13-D1. Phase 2: the README and the latent design.

Figure 3:Small fixes first, then the documentation and the latent design.

Open questions

  1. Should a plain Cycle advance the clock? DACycle advances DAState.t by dt; Cycle leaves it at 0, so Report 13 computes valid times from the history index. Advancing t when the step is a ForwardModel and the state has t would make the two agree.

  2. Should AnalysisStep receive the state? Passing state= as a keyword would let analyses read the clock, but it changes the protocol every adapter satisfies.

  3. A cycle checkpoint. The scoping report’s Checkpoint(cycle_op) does not exist. A checkpoint is the triple (config, CarryState.to_dict(), carrier); is it a pipekit-cycle operator, a pipekit-experiment artifact, or a script’s job?

  4. The window’s start state in SmootherCycle. Should the analysis receive the state the window started from, so a 4D-Var analysis need not carry it inside the carrier?

  5. CompositeForward and dt. Summing the components’ steps is one reading of composition (operator splitting with sequential sub-steps). Should a composite that advances all components over one shared dt exist as well?

  6. The latent design. Build latent.py as docs/design/latent.md drafts it, or retire the draft in favour of the shipped LatentMap and ReducedOrderModel protocols?

Upcoming work

Table 7 lists the follow-ups.

Table 7:Follow-ups from checking the scoping report against the code.

Follow-upWhereWhy
Refresh the README for 0.0.2 and fix filterx → filterax in its quickstartpipekit-cycle README.mdthe table lists v0.0.1 and omits BFNCycle, the adjoint specs and eleven protocols
Document the carrier convention for covariances and ensemblespipekit-cycle docsthe analysis never sees the state, so PP and the ensemble belong in the carrier (stage 2)
Add an adapter test against one algorithm library, and pin filterax in references/ and UPSTREAMpipekit-cycle tests; references/filterax; scripts/capabilities.pythe protocols’ main users are unchecked; once filterax is pinned, the notebook’s KalmanAnalysis and EnKF (user-written AnalysisSteps, as the report’s running example) are replaced by imports of filterax’s

References

Sources