filterax × pipekit-cycle¶
Subject: How filterax slots into pipekit-cycle pipelines without importing pipekit.
Decision anchor: D11 — Structural Protocol satisfaction
1 The problem¶
pipekit-cycle defines three @runtime_checkable Protocols that describe a generic data-assimilation cycle:
# from pipekit_cycle.protocols
@runtime_checkable
class ForwardModel(Protocol):
def step(self, state, dt) -> state: ...
@property
def dt(self) -> float: ...
@property
def state_signature(self) -> Any: ...
@runtime_checkable
class ObservationOperator(Protocol):
def __call__(self, state) -> obs: ...
def linearize(self, state) -> Callable: ...
@runtime_checkable
class AnalysisStep(Protocol):
def __call__(self, forecast, obs, *, obs_op, obs_err_cov) -> analysis: ...
These overlap with filterax's AbstractDynamics, AbstractObsOperator, and AbstractSequentialFilter. A filterax filter should drop into a pipekit Sequential / Graph / Cycle without:
- forcing every filterax user to install pipekit, or
- forcing filterax to track pipekit's protocol churn.
2 The decision¶
filterax classes structurally satisfy the pipekit Protocols — same method names, same call shapes — but filterax itself never imports pipekit. The compatibility is duck-typed; @runtime_checkable makes isinstance(filter, pipekit_cycle.AnalysisStep) work at runtime without inheritance.
This mirrors pipekit's own rule (algorithm libraries do not import pipekit). It also matches how vardax, pyrox_gp, and other ecosystem libraries integrate.
3 Compatibility table¶
| filterax surface | pipekit Protocol | Adapter required? |
|---|---|---|
AbstractDynamics.__call__(state, t0, t1) |
ForwardModel.step(state, dt) |
One-line wrapper (see §4.1) |
AbstractObsOperator.__call__(state) |
ObservationOperator.__call__(state) |
None — identical surface |
AbstractObsOperator.linearize(state) (optional) |
ObservationOperator.linearize(state) |
None when present |
AbstractSequentialFilter.analysis(forecast, obs, obs_op, obs_noise) |
AnalysisStep.__call__(forecast, obs, *, obs_op, obs_err_cov) |
One-line wrapper (see §4.2) |
The Dynamics and AnalysisStep surfaces differ in argument names and time-stepping convention; both are bridged by trivial wrappers users write in their own code.
4 Recipes¶
4.1 Use a filterax filter as a pipekit AnalysisStep¶
# user code — neither filterax nor pipekit-cycle imports the other
import filterax
import pipekit_cycle as pkc
filter_ = filterax.ETKF() # or LETKF, EnSRF, ...
class FilterAsAnalysisStep:
"""Bridge filterax.AbstractSequentialFilter.analysis -> pkc.AnalysisStep."""
def __init__(self, filter_):
self._filter = filter_
def __call__(self, forecast, obs, *, obs_op, obs_err_cov):
return self._filter.analysis(forecast, obs, obs_op, obs_err_cov)
step = FilterAsAnalysisStep(filter_)
assert isinstance(step, pkc.AnalysisStep) # passes — runtime_checkable
4.2 Use a filterax dynamics inside a pipekit Cycle¶
filterax AbstractDynamics.__call__(state, t0, t1) carries explicit start and end times; pipekit's step(state, dt) carries a step size. The bridge is one line:
class DynamicsAsForwardModel:
def __init__(self, dyn, dt):
self._dyn = dyn
self.dt = dt
def step(self, state, dt=None):
dt = self.dt if dt is None else dt
return self._dyn(state, 0.0, dt)
state_signature is optional in pipekit; provide it if you want pipekit's shape inference. filterax does not require it.
4.3 Wrap a filter as a pipekit StatefulOperator¶
pipekit threads (carrier, state) -> (carrier, state) through Sequential graphs. A filterax filter does not own its history; the user does. The wrapper is again user-side:
from typing import Any
import filterax
import pipekit as pk
class FilterCycle(pk.StatefulOperator):
"""A filterax filter wrapped as a stateful pipekit operator."""
_is_stateful = True
filter_: filterax.AbstractSequentialFilter
obs_op: filterax.AbstractObsOperator
obs_noise: Any # gaussx / lineax operator
def _apply(self, carrier, state):
# carrier: (forecast_ensemble, obs) tuple
# state: filterax.FilterState
forecast, obs = carrier
result = self.filter_.analysis(forecast, obs, self.obs_op, self.obs_noise)
new_state = filterax.FilterState(
particles=result.particles,
step=state.step + 1,
)
return result, new_state
5 Naming and signature alignment¶
filterax keeps method names and argument orders in step with pipekit's Protocols whenever there is no domain reason to diverge. Where they do diverge:
- Time arguments. filterax dynamics take
(state, t0, t1)(start, end). pipekit takes(state, dt)(step size). Bridge in user code; do not force one convention on the other. - Noise covariance. filterax accepts an
AbstractLinearOperator(gaussx-shaped). pipekit accepts any array-shaped covariance. The wrapper passes through; gaussx operators behave like arrays for elementwise use.
6 What we do not ship¶
filterax.integrations.pipekitis not a module. There is no Python code in filterax that mentions pipekit.- The wrappers above live in user code or in pipekit-cycle's own examples.
7 Testing the contract¶
filterax does not test pipekit compatibility directly. Wave 5 adds an opt-in test guarded by pytest.importorskip("pipekit_cycle"):
def test_filter_satisfies_pipekit_analysis_step():
import pipekit_cycle as pkc
filter_ = filterax.ETKF()
step = FilterAsAnalysisStep(filter_)
assert isinstance(step, pkc.AnalysisStep)
This catches accidental signature drift without coupling the libraries at import time. See issues/wave-5-*.md task FLX-55A.
8 Why not just import pipekit?¶
- Optionality. filterax should run for users who don't use pipekit at all.
- Versioning. pipekit's Protocols are still pre-1.0. Importing them locks filterax to a moving target.
- Symmetry.
vardax,pyrox_gp, and other algorithm libraries follow the same rule. The ecosystem decision is consistent.
If the cost of duck typing becomes untenable (e.g., pipekit grows non-trivial helper functions every filterax user wants), revisit by adding filterax[pipekit] extras with a thin adapter module. That is a future change, not a current commitment.