ML-PIPES

Operators

What Operators Are

Semantically, operators are the steps a pipeline takes to turn an input into an output.

The pipeline itself does not have any task or workflow specific logic. The pipeline is the framework that runs operators in order, threads context, and layers tooling on top of those steps. If a pipeline converts an image into detections, or a text string into a list of tokens, the interesting logic lives in the operators:

In that sense, a pipeline is closer to a harness for operators than to the operators themselves.

How Operators Look In Code

Concretely, an operator is anything the pipeline can execute as one step.

For simple local logic, a plain function is enough:

def strip_text(text: str) -> str:
    return text.strip()

For reusable configured logic, use a class with __call__. Decorating it with @Operator makes it self-describing in repr() and describe():

from ml_pipes.core import Operator


@Operator
class Prefix:
    def __init__(self, prefix: str) -> None:
        self.prefix = prefix

    def __call__(self, text: str) -> str:
        return f"{self.prefix}{text}"
repr(Prefix("tag: "))
# Prefix('tag: ')

Pipelines can mix both forms freely:

from ml_pipes.core import Pipeline

pipeline = Pipeline([strip_text, Prefix("tag: ")])
assert pipeline("  hello  ") == "tag: hello"

What Operators Are Made Of

Most reusable operators have four parts the pipeline cares about:

@Operator
class Prefix:
    def __init__(self, prefix: str) -> None:
        self.prefix = prefix

    def __call__(self, text: str) -> str:
        return f"{self.prefix}{text}"

When that static shape is not enough, define resolve_contract(...). Use it for operators whose real boundary depends on the current upstream type, stored context, or tuple routing. Most normal transform operators do not need it.

Special Operator Shapes

Most operators are plain functions or @Operator classes with a normal __call__ boundary. A few superclass patterns exist because some operators participate in framework mechanisms beyond a normal transform:

These are still operators, but they participate in extra execution mechanics. Read architecture.md for how context and regions fit into pipeline execution, and regions.md for region semantics and examples.

Features Built On Operators

The features below are not provided by the @Operator decorator itself. They work because the framework treats operators as explicit pipeline boundaries with meaningful input, output, and step identity. When those boundaries are chosen well, composition, validation, description, inspection, tracing, and benchmarking all become useful.

The examples below use the same tiny operator chain:

from ml_pipes.collectors import PrintCollector
from ml_pipes.core import Operator, Pipeline


def strip_text(text: str) -> str:
    return text.strip()


@Operator
class Lowercase:
    def __call__(self, text: str) -> str:
        return text.lower()


@Operator
class SplitWords:
    def __init__(self, delimiter: str) -> None:
        self.delimiter = delimiter

    def __call__(self, text: str) -> list[str]:
        return text.split(self.delimiter)

Composition

Composition is the core behavior: the output of one operator becomes the input to the next operator. The pipeline does not need task-specific logic here; it just runs one operator, takes the returned value, and feeds it to the next.

pipeline = Pipeline([strip_text, Lowercase(), SplitWords(delimiter=" ")])
pipeline("  Hello World  ")
# ['hello', 'world']

Validation

Validation checks whether one operator's output boundary is compatible with the next operator's input boundary.

pipeline = Pipeline([strip_text, Lowercase(), SplitWords(delimiter=" ")])
contract = pipeline.validate(strict=True)
print(f"Input: {contract.input_type}, Output: {contract.output_type}")
Input: <class 'str'>, Output: list[str]

Description

Because a pipeline is a list of operators, the operator chain is directly describable.

pipeline = Pipeline([strip_text, Lowercase(), SplitWords(delimiter=" ")])
pipeline.describe()
Pipeline([
  strip_text,
  Lowercase(),
  SplitWords(delimiter=' '),
])

Tracing

Tracing reports latency per operator step.

pipeline = Pipeline([strip_text, Lowercase(), SplitWords(delimiter=" ")])
pipeline.set_tracing(PrintCollector())
pipeline("  Hello World  ")
pipeline.set_tracing(None)
  0:strip_text                      0.01ms  (21.6%)
  1:Lowercase                       0.01ms  (43.1%)
  2:SplitWords                      0.01ms  (17.4%)
  total                             0.03ms

Inspection

Every operator establishes a data boundary. Inspection builds on tracing at those boundaries: tracing records a span for each operator, and Pipeline.inspect() captures that operator's output in the span. The resulting InspectionResult shows how values move through the operator chain.

from ml_pipes.inspection import PipelineInspector

result = pipeline.inspect(value)
PipelineInspector().show(result)

Inspection report overview

For setup, rendering, and custom formatters, see inspection.md.

Benchmark

Benchmarking repeats the pipeline over many runs and aggregates latency at the same operator boundaries tracing uses.

from ml_pipes.benchmark import Benchmark, MeasurementConfig

result = Benchmark(
    pipeline,
    input_fn=lambda: ("sample", "  Hello World  ", None, None),
    measurement=MeasurementConfig(runs=5, warmup=1, percentiles=(0.50,)),
).run()

print(result.to_table())
operator            mean        p50     stddev        min        max
--------------------------------------------------------------------
total              0.04       0.03       0.00       0.03       0.04
0:strip_text       0.01       0.01       0.00       0.01       0.01
1:Lowercase        0.01       0.01       0.00       0.01       0.02
2:SplitWords       0.01       0.01       0.00       0.01       0.01
--------------------------------------------------------------------
runs: 5  (all values in ms)

Design Principles

Operators should satisfy a few hard constraints so they stay safe to compose, swap, and reason about.

Atomic. One meaningful pipeline boundary, not a whole workflow.

Stateless. Keep only construction-time config (name, axis, threshold, prefix, etc.), never hidden runtime memory.

Effect-explicit. If an operator touches context, opens a region, performs a side effect, or crosses a runtime boundary, that should be obvious in its type and behavior.

Reusable at the right layer. Put broadly reusable behavior in shared operators; keep project glue and experiments in local pipeline code.

Composable. Operators should be easy to insert, remove, reorder, validate, inspect, trace, and benchmark as standalone boundaries.

Best Practices For Creating Operators

Scope

Config

Runtime Boundary

Input

Output

Naming And Aliases

Static Verification

Special Operators

Errors And Verification

Operator Packages

So far this page has focused on what operators are and how to create them. In practice, many reusable operators already exist, so most users should start from packages.md and then check the linked package reference for the operators they need.

Start from ml_pipes.standard for shared generic building blocks such as routing, context, regions, and data-preparation work.

Example: if postprocess is still tensor-shaped, start from the tensor package and its reference in packages/tensor/docs/reference.md.