Skip to content

Patterns

The shapes real pipelines take once they leave the notebook, which pipekit pieces each one uses, and what is still missing.

About this page
Covers Fourteen deployment patterns and four habits that cut across them
Read first A tour of the core
Worked examples The tutorials, linked from each pattern
History Distilled from Report 9 of the GeoStack design reports, which walks twenty cases through one domain (a methane plume from a satellite scene) and now points here

A pipeline is written once and then used in many places: a notebook, a batch job, a web service, a scheduled family of runs, an audit. pipekit's bet is that these are the same object in different harnesses, so the patterns below are mostly about which harness to put around an Operator, and which of its properties (a config, a hash, pickleability) the harness relies on.

Each pattern names its pipekit pieces, says what works today and what does not, and links the tutorial that runs it end to end. The status column uses three marks: today (ships and is tested), partly (works with a workaround), missing (needs code that does not exist yet).

# Pattern Pieces Status Tutorial
1 Explore, then batch Tap, ShapeTrace, Profile, Snapshot today bigger graph
2 ETL over many inputs ProcessMap, Quarantine, Sink today ETL over many regions
3 Validation as operators Assert*, Quarantine, QCError today inference
4 Fusing several inputs Graph, Input today bigger graph
5 One preprocessing object for training and serving Operator config, registry today train, register, forecast
6 Cycles: time stepping and assimilation StatefulOperator, pipekit-cycle today DA cycle, ensemble DA
7 An inference service AsyncMap.run, BatchedMap, the config hash today inference
8 Promotion by tag LocalModelRegistry.tag today —
9 Pipelines from users register, loads_sandboxed, from_yaml_sandboxed today —
10 A pinned artifact InferenceArtifact, TrainingArtifact, to_yaml today —
11 Orchestrator tasks with deterministic keys pipeline_hash today ETL over many regions
12 A/B comparison Fanout partly —
13 Scheduled families, backfills and what-if reruns the config hash today —
14 The feedback loop pipekit-train, registry today train, register, forecast

The snippets share one small operator, a detector with a threshold:

from pipekit import Operator


class Threshold(Operator):
    """x ↦ 1[x > k]."""

    def __init__(self, *, k: float) -> None:
        self.k = k

    def _apply(self, x):
        return x > self.k

Exploration and batch

1. Explore, then batch

The problem. A pipeline grows in a notebook, one step at a time, with prints and plots between the steps. Then it has to run on a thousand inputs, and the prints and plots have to go.

The pattern. Observers pass their input through unchanged, so you can leave them in while you explore and drop them, or point them at a logger, when you batch. ShapeTrace logs each step's shape and dtype, Profile times a step, Snapshot keeps a copy of an intermediate, and Tap calls any function on it. The object you explored with is the object you batch.

from pipekit import Profile, ShapeTrace, Snapshot, Tap

snap, prof = Snapshot(), Profile()
explore = (
    clean | ShapeTrace() | snap.at("clean")   # log shape and dtype, keep a copy
    | features | Tap(plot)                    # look at the features
    | prof.wrap(model)                        # time the model
)
batch = clean | features | model             # the same steps, no observers
snap.captures["clean"], prof.report()        # what the exploration saw

Today. All of it. ShapeTrace(extra=...) adds a line for whatever else a carrier carries (units, a coordinate system).

2. ETL over many inputs

The problem. One pipeline, many inputs (regions, files, stations, days), some of them broken in ways you only find at scale.

The pattern. Extract with a list of keys, transform with ProcessMap(pipeline, on_error="log_and_continue") so one bad input leaves a None instead of killing the run, set malformed records aside with Quarantine, and load with a Sink that writes one keyed output per input. Run check_pickleable(pipeline) before fanning out: closures do not cross process boundaries.

Today. All of it. log_and_continue reports each failure as a warning, with its traceback, on the pipekit.parallel logger, so the run's logging setup decides where failures go.

3. Validation as operators

The problem. Checks scattered through glue code are skipped, duplicated, or run in the wrong place.

The pattern. A check is a step. AssertShape, AssertDType, AssertHasAttribute and AssertCallable raise QCError when an input is wrong; Quarantine(check, sentinel, on_quarantine) replaces a bad input with a sentinel and hands it to a callback, for streams where one bad record should not stop the rest. Put the hard checks at the boundaries (after loading, before the model, before writing) and the soft ones on the records.

Today. All of it. Array-aware checks (finite values, value ranges, across array libraries) are planned for pipekit-array; until then AssertCallable takes any predicate.

4. Fusing several inputs

The problem. A product needs two or more sources at once: two sensors, a measurement and a reference, a feed and its timestamp.

The pattern. A Graph. Each source is an Input, each step is called on the nodes it needs, and the outputs are named. Steps that take two carriers (_apply(self, a, b)) are ordinary operators.

from pipekit import Graph, Input

a, b = Input("a"), Input("b")
fused = Align()(a, b)
g = Graph(inputs={"a": a, "b": b}, outputs={"product": Model()(fused)})
g(a=scene_a, b=scene_b)["product"]

Today. All of it, stateful steps included: call the graph with state= and it returns (outputs, state). Shape inference stops at the graph's edge.

Machine learning

5. One preprocessing object for training and serving

The problem. Training normalises with one piece of code and serving with another, and they drift: the classic train/serve skew.

The pattern. The fitted preprocessing is an operator whose fitted values (a mean, a standard deviation, a vocabulary) are its configuration. Store it in the registry next to the model, with its hash in the model's tags, and load both by name at serving time. There is then exactly one Standardize(mean=…, std=…) in the world, and the hash proves which one ran.

scaler_hash = registry.store(scaler, name="scaler")
registry.store(model, name="model", weights=blob, tags={"scaler": scaler_hash})

serve = registry.load("scaler") | model_template.with_weights(
    registry.load_weights("model")
)

Today. All of it (LocalModelRegistry, S3ModelRegistry, JaxModelOp.with_weights). The tutorial fits the scaler on the training split only, so validation never sees its statistics.

6. Cycles: time stepping and assimilation

The problem. Forecast, observe, correct, repeat: the carrier is a state, and every step needs the previous one.

The pattern. pipekit-cycle. A Cycle steps a forward model; a DACycle or EnsembleDACycle alternates forecast and analysis, with observation operators and noise models as Protocols, so the filter can come from any library. Pre-processing (quality control, thinning, unit conversion of the observations) and post-processing (verification, spread–skill) are ordinary operators around the cycle.

Today. All of it.

Serving

7. An inference service

The problem. A web handler receives a request, runs a pipeline and must say exactly what produced its answer.

The pattern. Build the pipeline once at start-up. Inside the handler, which already runs in an event loop, use await AsyncMap(op).run(items) (not AsyncMap(op)(items), which starts its own loop). Batch for a GPU with BatchedMap, or let the model batch. Return the pipeline's content hash with every answer, so a response can be traced to a configuration.

Today. All of it. pipekit provides no server: LitServe, FastAPI or anything else hosts the operator.

8. Promotion by tag

The problem. Several candidate versions exist; one of them is "the" model that serving uses, and promoting a new one (or rolling back) must not need a redeploy.

The pattern. Store every candidate by content hash; point a human-readable tag at the one in service. Serving loads by tag; promotion and rollback move the tag. The same configuration always gets the same hash, so storing it twice is harmless.

import tempfile
from pipekit_experiment import LocalModelRegistry

registry = LocalModelRegistry(tempfile.mkdtemp())
v1 = registry.store(Threshold(k=3.0), name="detector-candidate")
v2 = registry.store(Threshold(k=2.5), name="detector-candidate")

registry.tag(v1, "detector", force=True)   # v1 in service
registry.tag(v2, "detector", force=True)   # promote v2
registry.load("detector")                  # → Threshold(k=2.5)
registry.tag(v1, "detector", force=True)   # roll back
registry.store(Threshold(k=3.0)) == v1     # → True: same config, same hash

Today. All of it.

9. Pipelines from users

The problem. Users (or another team, or a queue) send pipelines as data. Building arbitrary classes from a string is code execution.

The pattern. Register the classes you allow, containers included, and build untrusted input with loads_sandboxed (JSON) or HydraConfigLoader.from_yaml_sandboxed (_target_ YAML). Every class in the tree is checked before anything is built, and anything unregistered raises PermissionError. Operators holding closures are forbid_in_yaml and never deserialise, registered or not.

import pipekit as pk
from pipekit_experiment.adapters.hydra import HydraConfigLoader

pk.register(pk.Sequential)
pk.register(Threshold)

text = """
_target_: pipekit.Sequential
operators:
  - _target_: __main__.Threshold
    k: 2.0
"""
# a Sequential holding Threshold(k=2.0)
detector = HydraConfigLoader.from_yaml_sandboxed(text)

The YAML loader never calls Hydra's instantiate, which would call any dotted path (_target_: os.system): it translates the YAML into a state record and builds that through the allowlist. Interpolations (${...}) are not resolved, so a sandboxed config cannot read the environment.

Today. All of it.

Reproducibility and operations

10. A pinned artifact

The problem. A result has to be reproducible months later, for a review, an audit or a paper: the exact pipeline, the exact weights, the exact dependencies.

The pattern. pipekit-experiment's InferenceArtifact(pipeline_yaml, pinned_model_hashes, backend_info, deps_lock, metadata) records what ran; TrainingArtifact records how the model was made (the training pipeline, the dataset's hash, the model's hash, the tracker run). Keep the artifact with the output.

For the pipeline_yaml field, HydraConfigLoader.to_yaml(pipeline) writes the pipeline that actually ran, however deeply nested, and from_yaml loads it back with the same state.

Today. All of it. TrainingLoop.run() returns a TrainingArtifact when pipekit-experiment is installed.

11. Orchestrator tasks with deterministic keys

The problem. An orchestrator (Prefect, Dagster, Airflow, a cron job) retries, resumes and backfills. Every task must know whether it has already run.

The pattern. One task is one operator call on one input. Its key is a hash of the operator's configuration and the input's identity, so a rerun with the same pipeline and input finds its output and skips; a change to either gives a new key.

from pipekit import pipeline_hash


def task_key(op: Operator, input_id: str) -> str:
    """One key per (pipeline configuration, input)."""
    return f"{pipeline_hash(op)[:16]}-{input_id}"


# the same pipeline on the same day: the same key, so the rerun skips
task_key(Threshold(k=3.0), "2026-10-16") == task_key(Threshold(k=3.0), "2026-10-16")
# one argument changed: a new key
task_key(Threshold(k=3.0), "2026-10-16") != task_key(Threshold(k=2.5), "2026-10-16")

Today. All of it. pipeline_hash hashes the whole configuration: every nested operator and, in a Graph, every node and its wiring. It is the hash the registry stores under, so a task key and a registry entry agree.

12. A/B comparison

The problem. A new pipeline version must be compared with the current one on the same inputs before it replaces it.

The pattern. Fanout({"a": current, "b": candidate}) runs both on each input and returns both outputs; a post-processing step compares them. Compare the distribution of the differences, not only their mean: two versions can agree on average and disagree badly on the inputs that matter.

from pipekit import Fanout

ab = Fanout({"a": Threshold(k=3.0), "b": Threshold(k=2.5)})
ab(2.8)   # → {'a': False, 'b': True}

Partly. The side-by-side run ships; a diff of two pipelines' configurations, and a report of where their outputs disagree, do not. pipekit-evaluate's scorers and EvaluationReport are the natural home.

13. Scheduled families, backfills and what-if reruns

The problem. The same pipeline runs every day (monitoring), over a past period (backfill), and again with one setting changed (what-if).

The pattern. Key every output by the pipeline's hash and the input's date or identity (pattern 11). A daily run and a backfill are the same task over different dates. A what-if is a new operator with one constructor argument changed, so it has a new hash and writes beside the original, never over it.

Today. All of it; the schedule belongs to the orchestrator.

14. The feedback loop

The problem. Reviewers accept or reject the pipeline's outputs, and those verdicts should improve the next model.

The pattern. Verdicts become labels; a frozen label set gets a content hash; pipekit-train's TrainingLoop trains on it and returns a TrainingArtifact; the new model is stored, compared with the current one (pattern 12) and promoted by tag (pattern 8). Every model can be traced back to the labels it saw.

Today. All of it, given a label store that hashes its frozen sets.

Habits that cut across the patterns

Composition in the core, infrastructure next door

The core composes and nothing else; deployment infrastructure lives in the sister packages, each in one place.

Concern Lives in
Operators, composition, parallel maps, nested JSON, the sandbox allowlist pipekit
pipeline_hash and the hashing primitives pipekit.hashing
Content-addressed storage, tags, artifacts, YAML loading pipekit-experiment
Training loops and backends pipekit-train
Time stepping and assimilation pipekit-cycle
Benchmarks, scorers and reports pipekit-evaluate
JAX models with weight round-trips pipekit-jax
Domain operators, readers and writers your library, subclassing Operator

Parallelism is chosen per deployment

Nothing is parallel by default; each deployment picks its primitive.

Deployment Primitive
CPU-bound batch over many inputs ProcessMap
I/O-bound reads ThreadMap
I/O inside a running event loop (a server) await AsyncMap(op).run(items)
GPU inference BatchedMap, or the model batches
Across machines the orchestrator, calling pickleable operators

YAML is the shared language, both ways

Every operator, in any library, subclasses Operator, so a Hydra YAML with _target_ paths can name steps from several libraries side by side. HydraConfigLoader.from_yaml builds the nested pipeline, to_yaml writes one built in Python, and from_yaml_sandboxed loads YAML you did not write. A pipeline can start in Python or in YAML; either way the other form is one call away.

JAX speeds the model, not the pipeline

pipekit-jax wraps an Equinox model as an operator (JaxModelOp), with byte-identical weight round-trips through the registry, and adds diffrax forward models for pipekit-cycle. Inside the model, jit and vmap do their work; the pipeline around it stays eager Python. That is a deliberate split: pre- and post-processing are I/O and bookkeeping, where tracing buys little and costs flexibility.

One domain, all the patterns

The GeoStack design reports walk every pattern above through one example, a methane plume from a satellite scene: read the scene, retrieve the enhancement, detect the plume, estimate its emission rate, review it, and retrain. Report 9 has the twenty cases, the libraries each one needs, and the proposals that close the gaps marked partly and missing here.