ML-PIPES

Pipeline Validation

Overview

Validation checks a pipeline before runtime. In the current validation flow, it checks:

  1. region structure
  2. context scope
  3. operator compatibility
  4. strict-mode boundary concreteness, if strict=True

Validation also returns the resolved pipeline contract so you can see what input and output types the pipeline currently exposes.

from ml_pipes.core import Pipeline


class IntToString:
    def __call__(self, value: int) -> str:
        return str(value)


class StringToFloat:
    def __call__(self, value: str) -> float:
        return float(value)


pipeline = Pipeline([IntToString(), StringToFloat()])
contract = pipeline.validate()

assert contract.input_type is int
assert contract.output_type is float

When Validation Runs

Validation never runs unless you ask for it.

pipeline = Pipeline([...], auto_validate=True)  # validate during construction
pipeline.extend([...])                          # re-validates if auto_validate=True
pipeline.validate()                             # explicit validation

Use auto_validate=True when the pipeline is built incrementally, but note:

Operator Compatibility

Validation resolves each step's contract and checks that the previous step's output can feed the next step's input.

Compatible boundaries:

IntToString(value: int) -> str
StringToFloat(value: str) -> float

Fixed-length tuple outputs are unpacked positionally when the next operator takes multiple positional inputs:

IntToPair(value: int) -> tuple[int, str]
PairToBool(number: int, text: str) -> bool

Compatible boundaries follow normal assignability intuition for the supported annotation shapes in this page: the previous step's output can be narrower than the next step's expected input, as long as it is assignable to that input boundary.

Call Signatures (__call__)

For ordinary callable operators, the contract comes from the annotated __call__ signature:

If an ordinary operator is missing input or return annotations, validation fails immediately.

Operator Contracts (resolve_contract(...))

Some operators cannot express their boundary precisely with a static signature alone. resolve_contract(...) exists for those cases.

Use it when the operator boundary depends on:

resolve_contract(...) computes the contract for this exact pipeline position. It returns (input_types, output_type).

For ordinary dynamic operators:

def resolve_contract(self, upstream_annotation, error_type):
    ...

For ContextOp, which also depends on stored context state:

def resolve_contract(self, upstream_annotation, stored_annotations, error_type):
    ...
from ml_pipes.context import Context, ContextOp
from typing import Any


class AttachStored(ContextOp[Any, Any]):
    def __init__(self, name: str):
        self.name = name

    def apply(self, current: Any, context: Context) -> tuple[Any, Context]:
        return (current, context.load(self.name)), context

    def resolve_contract(self, upstream_annotation, stored_annotations, error_type):
        stored_type = stored_annotations.get(self.name)
        if stored_type is None:
            raise error_type(f"{self.name!r} is not available in context")
        return (Any,), tuple[upstream_annotation, stored_type]

Built-in operators such as Store, Recall, Pick, Batch, UnBatch, Scatter, and Gather participate in validation this way.

How Compatibility Is Checked

Validation resolves one contract per step:

Once those boundaries are resolved, validation matches them left to right:

Compatibility Coverage

Validation is a contract checker over a scoped subset of Python's typing model. It is not a general-purpose replacement for a static type checker.

Compatibility is directional. Validation asks whether the previous step's output is assignable to the next step's input, not whether the two annotations are interchangeable.

Annotation shape Examples Current compatibility coverage
Broad placeholders Any, object Accepted as broad contracts. In strict=True, unresolved Any is still rejected at the operator-boundary level.
Concrete classes str, bytes, ImagePayload Supported through normal subclass compatibility.
TypeVars T, TypeVar("T", bound=Base) Supported with bounds and constraints. When the upstream type is concrete, validation specializes TypeVar-based boundaries where it can; otherwise the published contract falls back to the bound or constraint.
Unions A \| B Supported directionally. Every produced option must be assignable to the downstream expectation.
Fixed-length tuples tuple[int, str] Supported and can route positionally into multi-parameter operators.
Variadic tuples tuple[int, ...] Supported as single values. They are not expanded into multiple pipeline inputs.
Runtime generic classes A user class Box(Generic[T_co]) Supported using the TypeVar variance retained by the class at runtime: covariant, contravariant, or invariant.
Runtime type aliases A resolvable alias such as NDArray[S] Resolvable, non-recursive aliases are expanded and their supplied type arguments are bound before matching.
Erased-runtime generics list[T], Sequence[T], Mapping[K, V] These do not expose sufficient runtime parameter metadata for the matcher. Selected built-ins use private registry rules: mutable containers are invariant, Sequence is covariant, and Mapping has invariant keys with covariant values. Unsupported generic forms conservatively remain invariant.
NumPy arrays and dtypes numpy.typing.NDArray[np.uint8], numpy.ndarray[tuple[Any, ...], numpy.dtype[np.uint8]], numpy.dtype[np.uint8] Supported through the same generic matcher. NDArray[S] resolves to native ndarray form; ndarray shape and dtype parameters and dtype scalar parameter are covariant. Bare ndarray and dtype normalize to Any arguments, so they remain broad normal-mode contracts. Partially parameterized fixed-arity forms are rejected.
Structural Protocols Protocol with annotated fields and methods Supported when used as downstream expectations or TypeVar bounds. The current supported protocol shape is non-parameterized structural protocols, including annotated data members and Self-preserving methods.

Note: method matching strips the receiver first, then requires exact callable shape: order, keyword-visible names, kinds, and defaults. Parameter annotations are checked contravariantly; return annotations covariantly.

Not supported: class-object boundaries such as type[Proto].
Other typing features Annotated, Literal, generic Protocol[T], ParamSpec, overload-oriented typing constructs Not part of the current documented compatibility contract. Some cases may work incidentally, but they are not guaranteed. Prefer a simpler boundary annotation or use resolve_contract(...) when you need a more explicit contract.

For more information regarding call signatures and operator contracts, see operators.md.

If a boundary is incompatible, validation raises with the step label:

Pipeline contract mismatch at 1:PairToBool:
  IntToPair provides tuple[int, str] but PairToBool expects (int, float)

Context Scope

Validation tracks stored keys in the same runtime scope. A Recall("x") is valid only if "x" was stored earlier in that scope.

Scope rules:

Examples:

from ml_pipes.core import Pipeline
from ml_pipes.standard import Batch, Recall, Store, UnBatch

Pipeline([Store("x"), Recall("x")])                             # valid
Pipeline([Recall("x")])                                         # invalid
Pipeline([Recall("x"), Store("x")])                             # invalid
Pipeline([Batch(size=2), Store("x"), UnBatch(), Recall("x")])   # invalid
Pipeline([Store("x"), Batch(size=2), UnBatch(), Recall("x")])   # valid

When a recall fails, validation reports the operator label and the keys available at that point.

A representative error looks like:

Pipeline step 0:Recall references a key that was not stored: 'x'. Keys available at this point: (none)

Region Structure

Validation checks that region openers and closers are structurally valid. It rejects:

Examples:

Batch -> Op -> Op -> UnBatch                valid
Batch -> Op -> Op                           Batch has no matching UnBatch
Op -> UnBatch                               UnBatch has no matching opener
Scatter -> Batch -> Gather                  regions interleave
Batch -> Batch -> UnBatch -> UnBatch        directly nested Batch forbidden

A representative error looks like:

Pipeline step 0:Batch has no matching UnBatch

For region semantics and built-in region pairs, see regions.md.

Strict Mode

strict=True adds another check on top of normal validation. It does not change operator compatibility, context checks, or region checks. It inspects operator boundaries themselves and rejects unresolved ambiguity.

from typing import Any
from ml_pipes.core import Pipeline


class VagueOp:
    def __call__(self, value: Any) -> Any:
        return value


Pipeline([VagueOp()]).validate()              # accepted
Pipeline([VagueOp()]).validate(strict=True)   # raises

In practice, strict mode means:

Generic operators can still pass strict mode if they justify their boundary concretely through resolve_contract(...).

A representative error looks like:

Strict mode violation at 0:VagueOp: input type is unresolved (Any).
  Fix: annotate the parameter with a concrete type, or implement resolve_contract to accept and thread the upstream type dynamically.

Returned Contract and Input Modes

Validation always runs the checks above. Once those checks succeed, it returns a TypeContract(input_type=..., output_type=...). The returned output type comes from the last resolved operator boundary. The returned input type is tightened from the entry boundary, any declared pipeline_input_type, and optionally by backward inference. Strict mode is orthogonal; it adds validation on top of this flow but does not change how compatibility or input modes work.

Default Mode

contract = pipeline.validate()

With no declared input and no backward inference, validation starts at Any and can only tighten from the entry boundary the forward pass resolves. In practice, that is usually the first step's resolved input boundary. It does not back-propagate constraints from later steps.

If the entry boundary stays vague, validation can still succeed, but the published pipeline input stays loose:

from typing import Any
from ml_pipes.core import Pipeline


class VagueOp:
    def __call__(self, value: Any) -> Any:
        return value


contract = Pipeline([VagueOp()]).validate()
assert contract.input_type is Any

Declared Input

contract = pipeline.validate(pipeline_input_type=...)

Declared input mode seeds forward validation with a known pipeline input type. This is the direct way to tighten the entry boundary when the first operators are generic.

The declared input is checked, not just copied. If it conflicts with the entry boundary, validation fails during compatibility checking.

Declared input can also be partial. Validation combines what you declare with what the operators require:

from typing import Any
from ml_pipes.core import Pipeline


class ParsePair:
    def __call__(self, value: tuple[int, str]) -> bool:
        return True


contract = Pipeline([ParsePair()]).validate(
    pipeline_input_type=tuple[Any, str]
)

assert contract.input_type == tuple[int, str]
assert contract.output_type is bool

Backward Inference

contract = pipeline.validate(inference=True)

Backward inference can tighten the returned input type further by propagating downstream constraints backward through transitive or contract-driven steps.

from typing import Any
from ml_pipes.core import Pipeline


class IntToString:
    def __call__(self, value: int) -> str:
        return str(value)


class ContractPassthrough:
    def __call__(self, value: Any) -> Any:
        return value

    def resolve_contract(self, upstream_annotation, error_type):
        return (Any,), upstream_annotation


contract = Pipeline([ContractPassthrough(), IntToString()]).validate(
    inference=True
)

assert contract.input_type is int
assert contract.output_type is str

Errors and Warnings

Most validation failures raise PipelineValidationError.

Unsupported bare generic annotations can still fail earlier during annotation normalization.

Validation also emits PipelineValidationWarning when a multi-parameter operator defines positional defaults, because runtime dispatch ignores those defaults for pipeline chaining.