Spaces:
Sleeping
Sleeping
| # Core Concepts | |
| The pipeline engine is built on four foundational concepts: the **Step protocol**, the **StepContext**, the **contract system**, and the **Pipeline** compositor. Understanding these gives you the mental model for everything else. | |
| --- | |
| ## StepProtocol | |
| A Step is any Python object that satisfies the `StepProtocol` β a structural (duck-typed) interface. No base class is required. | |
| ```python | |
| from collections.abc import Set as AbstractSet | |
| from typing import Protocol, runtime_checkable | |
| @runtime_checkable | |
| class StepProtocol(Protocol): | |
| requires: AbstractSet[str] # metadata keys this step reads | |
| provides: AbstractSet[str] # metadata keys this step writes | |
| def __call__(self, ctx: StepContext) -> StepContext: ... | |
| ``` | |
| Any object with `requires`, `provides`, and a `__call__` method is a valid step: | |
| ```python | |
| class Tokenize: | |
| requires = frozenset() # no dependencies | |
| provides = frozenset({"tokens", "word_count"}) | |
| def __call__(self, ctx: StepContext) -> StepContext: | |
| tokens = str(ctx.sample).split() | |
| return ctx.replace( | |
| metadata=MappingProxyType({ | |
| **ctx.metadata, | |
| "tokens": tokens, | |
| "word_count": len(tokens), | |
| }) | |
| ) | |
| ``` | |
| Key details: | |
| - **`AbstractSet[str]`** accepts both `set` and `frozenset`. Steps can use plain set literals β the pipeline normalizes them to `frozenset` at construction time. | |
| - **`@runtime_checkable`** lets the pipeline use `isinstance(step, StepProtocol)` at construction time to catch missing attributes early, rather than failing at call time. | |
| - **`Pipeline` and `Branch`** both satisfy this protocol, so they can be nested wherever a step is expected. | |
| --- | |
| ## StepContext | |
| `StepContext` is the data carrier passed from step to step. It is a **frozen dataclass** β steps never mutate the incoming context. | |
| ```python | |
| from dataclasses import dataclass, field | |
| from types import MappingProxyType | |
| @dataclass(frozen=True) | |
| class StepContext: | |
| sample: Any = None | |
| metadata: MappingProxyType = field( | |
| default_factory=lambda: MappingProxyType({}) | |
| ) | |
| def replace(self, **changes) -> "StepContext": | |
| return dataclasses.replace(self, **changes) | |
| ``` | |
| The engine only reads `sample` and `metadata`. All domain-specific fields are added by subclassing. | |
| ### The `.replace()` pattern | |
| Steps create new contexts β they never mutate the incoming one: | |
| ```python | |
| def __call__(self, ctx: StepContext) -> StepContext: | |
| result = process(ctx.sample) | |
| return ctx.replace( | |
| metadata=MappingProxyType({**ctx.metadata, "result": result}) | |
| ) | |
| ``` | |
| This is the only way to "modify" a context. `frozen=True` makes mutation a hard error at runtime rather than a subtle bug. | |
| ### Metadata auto-coercion | |
| If a caller passes a plain `dict` as metadata, `StepContext.__post_init__` automatically wraps it in `MappingProxyType`, ensuring mutation is always a runtime error: | |
| ```python | |
| # Both of these produce identical immutable metadata: | |
| ctx = StepContext(sample="hello", metadata={"key": "value"}) | |
| ctx = StepContext(sample="hello", metadata=MappingProxyType({"key": "value"})) | |
| ``` | |
| ### Subclassing for domain fields | |
| Applications subclass `StepContext` to add named fields for concepts shared across their pipelines: | |
| ```python | |
| @dataclass(frozen=True) | |
| class MLContext(StepContext): | |
| # Shared configuration | |
| model_config: dict | None = None | |
| # Produced by steps (None until the providing step runs) | |
| predictions: list | None = None | |
| scores: dict | None = None | |
| report: str | None = None | |
| ``` | |
| Use **named fields** for data shared across multiple steps in the pipeline. Use **`metadata`** for integration-specific or step-specific transient data that doesn't warrant a dedicated field. | |
| !!! tip "When to use which" | |
| - Named field: `predictions`, `scores` β shared by multiple steps, type-checkable | |
| - Metadata: `metadata["debug_log"]`, `metadata["cache_key"]` β step-specific, doesn't pollute the class | |
| ### Why immutability? | |
| - **Branch safety** β All branches receive the same frozen context. No deep copy is needed since no branch can mutate what it receives. | |
| - **Thread safety** β Steps running concurrently (via `workers` or `Branch`) can safely share context objects. | |
| - **Debugging** β Each step returns a new context, creating a clear trace of data transformations. | |
| --- | |
| ## Contracts: requires and provides | |
| Every step declares: | |
| - **`requires`** β the set of field names it reads from the context | |
| - **`provides`** β the set of field names it writes to the context | |
| The pipeline validates these at construction time. | |
| ### How validation works | |
| When you build a pipeline with `.then()`, the engine checks step ordering immediately: | |
| ```python | |
| pipe = ( | |
| Pipeline() | |
| .then(Tokenize()) # provides: {"tokens", "word_count"} | |
| .then(Uppercase()) # requires: {"tokens"} β β Tokenize provides it | |
| ) | |
| ``` | |
| If a step requires a field that a **later** step provides, the pipeline raises `PipelineOrderError`: | |
| ```python | |
| # This raises PipelineOrderError at construction time: | |
| pipe = Pipeline().then(Uppercase()).then(Tokenize()) | |
| # β Uppercase requires "tokens", but Tokenize (which provides it) comes after | |
| ``` | |
| ### External inputs vs internal dependencies | |
| Fields not produced by any step in the pipeline are treated as **external inputs** β they must be present in the initial `StepContext` passed to `run()`. These do not trigger ordering errors: | |
| ```python | |
| class ScoreStep: | |
| requires = frozenset({"predictions"}) # external input | |
| provides = frozenset({"scores"}) | |
| # No error β "predictions" is expected to come from the initial context | |
| pipe = Pipeline().then(ScoreStep()) | |
| ``` | |
| ### Contract inference for nested pipelines | |
| When a `Pipeline` is used as a step inside another pipeline, its `requires` and `provides` are computed automatically from its inner steps: | |
| ```python | |
| inner = Pipeline().then(Tokenize()).then(Uppercase()) | |
| # Inferred automatically: | |
| # inner.requires = frozenset() β Tokenize needs nothing external | |
| # inner.provides = frozenset({"tokens", "word_count", "upper_tokens"}) | |
| outer = Pipeline().then(inner).then(Summarize()) | |
| # Summarize's requirements validated against inner.provides | |
| ``` | |
| The inference algorithm: | |
| 1. Walk steps in order, tracking what has been provided so far | |
| 2. `requires` = fields needed by steps that no earlier step provides (external dependencies) | |
| 3. `provides` = union of all fields any step writes | |
| !!! warning "All Branch children always run" | |
| The contract system assumes all `Branch` children execute. There is no concept of conditional branches where only some children run β all branches always run. If a branch provides a field that a later step requires, validation passes; if that branch were to not run, the pipeline would fail at runtime. | |
| --- | |
| ## Pipeline | |
| A `Pipeline` is an ordered list of steps that runs sequentially for a single input. It satisfies the `StepProtocol`, so it can be nested inside other pipelines. | |
| ### Building a pipeline | |
| Two equivalent forms: | |
| === "Fluent builder (preferred)" | |
| ```python | |
| pipe = ( | |
| Pipeline() | |
| .then(Tokenize()) | |
| .then(Uppercase()) | |
| .then(Summarize()) | |
| ) | |
| ``` | |
| === "Constructor list" | |
| ```python | |
| pipe = Pipeline([ | |
| Tokenize(), | |
| Uppercase(), | |
| Summarize(), | |
| ]) | |
| ``` | |
| Both validate step ordering at construction time. The fluent builder validates **after each `.then()` call**, giving precise error messages about which step caused the violation. | |
| ### The `.branch()` shorthand | |
| Instead of manually creating a `Branch`, use the fluent shorthand: | |
| ```python | |
| pipe = ( | |
| Pipeline() | |
| .then(Tokenize()) | |
| .branch( | |
| Pipeline().then(Uppercase()), | |
| Pipeline().then(Reverse()), | |
| merge=MergeStrategy.RAISE_ON_CONFLICT, | |
| ) | |
| .then(Summarize()) | |
| ) | |
| ``` | |
| This is equivalent to `.then(Branch(...))`. | |
| ### Nesting | |
| A pipeline used as a step is a black box β the outer pipeline sees only its aggregated `requires` and `provides`: | |
| ```python | |
| preprocessing = Pipeline().then(Tokenize()).then(Uppercase()) | |
| postprocessing = Pipeline().then(Summarize()).then(FormatStep()) | |
| full = Pipeline().then(preprocessing).then(postprocessing) | |
| ``` | |
| !!! note "Inner pipeline as a fan-out step" | |
| A step receives one context and must return one context β but nothing prevents it from internally expanding to multiple sub-inputs: | |
| ```python | |
| class MultiSearchStep: | |
| requires = frozenset() | |
| provides = frozenset({"search_results"}) | |
| def __call__(self, ctx: StepContext) -> StepContext: | |
| queries = generate_queries(ctx.sample) | |
| sub_ctxs = [StepContext(sample=q) for q in queries] | |
| sub_pipe = Pipeline().then(FetchStep()) | |
| results = sub_pipe.run(sub_ctxs, workers=len(queries)) | |
| merged = merge_results(results) | |
| return ctx.replace( | |
| metadata=MappingProxyType({**ctx.metadata, "search_results": merged}) | |
| ) | |
| ``` | |
| From the outer pipeline's perspective, `MultiSearchStep` is a single step. The fan-out is an internal implementation detail. | |