Typed Pipelines¶
Type annotations make stage boundaries reviewable before execution. In a pipeline, the key question is:
Module 01 answers that question with domain dataclasses, typed stage signatures, and
the generic RagPipe wrapper. It does not yet introduce ParamSpec,
Concatenate, typed decorators, or context injection.
Domain transitions are the first type system¶
FuncPipe models its stage order with distinct values:
def clean_doc(doc: RawDoc | CleanDoc) -> CleanDoc: ...
def chunk_doc(
doc: CleanDoc,
env: RagEnv,
) -> list[ChunkWithoutEmbedding]: ...
def embed_chunk(chunk: ChunkWithoutEmbedding) -> Chunk: ...
The types expose an invalid composition:
clean_doc returns CleanDoc, while embed_chunk requires
ChunkWithoutEmbedding. The missing chunking transition is visible without tracing
field access at runtime.
Container shape is part of the contract¶
chunk_doc returns many slices for one document:
embed_chunk transforms one slice:
They cannot be directly composed as unary functions. The caller must map embedding over the returned list:
A type annotation should reveal this one-to-many boundary instead of hiding it behind
Any.
Configuration belongs in the signature¶
RagEnv makes chunk policy explicit:
The annotation alone does not validate runtime values. The frozen dataclass checks
that chunk_size is an exact positive integer. Static and runtime responsibilities
are complementary:
| Mechanism | Catches |
|---|---|
| annotation | callers passing a statically known wrong type |
| constructor validation | invalid values at runtime |
| behavioral test | incorrect slicing despite valid values |
flow is intentionally weakly typed¶
Module 01's flow accepts:
This makes the implementation small, but a type checker cannot reject mismatched adjacent stages:
The construction type-checks through Any; execution fails when embed_chunk
receives a CleanDoc.
Use flow when the chain is obvious and separately typed domain functions make the
boundary clear. Do not present it as machine-checked pipeline composition.
RagPipe preserves adjacent types¶
The generic wrapper records input and output:
A = TypeVar("A")
B = TypeVar("B")
C = TypeVar("C")
class RagPipe(Generic[A, B]):
def __init__(self, stage: Callable[[A], B]):
self._stage = stage
def __call__(self, value: A) -> B:
return self._stage(value)
def then(self, next_stage: Callable[[B], C]) -> "RagPipe[A, C]":
return RagPipe(lambda value: next_stage(self._stage(value)))
A valid chain:
A static checker can reject:
because embed_chunk does not accept CleanDoc.
RagPipe still models only unary stages. It does not bind RagEnv, flatten chunk
lists, or prove purity. Types constrain shape, not effects.
Why full_rag stays explicit¶
The tracked Module 01 application uses:
def docs_to_embedded(docs: list[RawDoc], env: RagEnv) -> list[Chunk]:
cleaned = [clean_doc(doc) for doc in docs]
chunked = [chunk for doc in cleaned for chunk in chunk_doc(doc, env)]
embedded = [embed_chunk(chunk) for chunk in chunked]
return embedded
The intermediate names and annotations describe:
- batch mapping;
- one-to-many expansion;
- configuration use;
- another batch mapping.
Forcing this through RagPipe would require new adapters whose types are more complex
than the domain behavior. The explicit function is the better teaching surface.
A typing mismatch the module repaired¶
The cleaning law is:
If clean_doc accepted only RawDoc, that executable law would contradict its type
signature because the inner call returns CleanDoc. Module 01 therefore types the
input as RawDoc | CleanDoc.
This is a useful review lesson: a runtime test can pass while the public type contract rejects the same expression. Tests and annotations must describe the same domain.
What types do not prove¶
These functions have identical shapes:
def pure_length(text: str) -> int:
return len(text)
def recorded_length(text: str) -> int:
audit.append(text)
return len(text)
Typing cannot distinguish their effects. It also cannot establish determinism, canonical ordering, idempotence, or resource bounds.
Use types with behavior tests and source review:
types: adjacent values fit
examples: named behavior is understandable
properties: law holds over a wider domain
review: hidden effects and ownership are acceptable
Proof route¶
Inspect:
capstone/module-reference-states/module-01/src/funcpipe_rag/rag_types.py
capstone/module-reference-states/module-01/src/funcpipe_rag/pipeline_stages.py
capstone/module-reference-states/module-01/src/funcpipe_rag/rag_pipe.py
Run:
PYTHONPATH=capstone/module-reference-states/module-01/src \
python -m pytest -q \
capstone/module-reference-states/module-01/tests/learning/test_module_01_purity_foundations.py
The short proof checks runtime invariants and behavior. A type checker is a separate route; passing tests do not substitute for it.
Learner work¶
Define a typed unary adapter:
Use it in:
Provide:
- exact annotations with no
Any; - an example for an empty abstract;
- an example for a final short chunk;
- a deliberately incompatible
.thencall for static inspection; - an explanation of why the adapter captures policy and why Module 02, not Module 01, develops that configurator pattern further.
Continue with Typed Pipeline Review for the compact review checklist before moving to effect ownership.