feat(pipeline): add PureData-style inlet/outlet typing

- Add DataType enum (SOURCE_ITEMS, TEXT_BUFFER, etc.)
- Add inlet_types and outlet_types to Stage
- Add _validate_types() for type checking at build time
- Update tests with proper type annotations
This commit is contained in:
2026-03-16 15:39:36 -07:00
parent 4616a21359
commit 76126bdaac
4 changed files with 717 additions and 2 deletions

View File

@@ -130,6 +130,35 @@ class EffectPluginStage(Stage):
self.category = "effect"
self.optional = False
@property
def stage_type(self) -> str:
"""Return stage_type based on effect name.
HUD effects are overlays.
"""
if self.name == "hud":
return "overlay"
return self.category
@property
def render_order(self) -> int:
"""Return render_order based on effect type.
HUD effects have high render_order to appear on top.
"""
if self.name == "hud":
return 100 # High order for overlays
return 0
@property
def is_overlay(self) -> bool:
"""Return True for HUD effects.
HUD is an overlay - it composes on top of the buffer
rather than transforming it for the next stage.
"""
return self.name == "hud"
@property
def capabilities(self) -> set[str]:
return {f"effect.{self.name}"}
@@ -166,6 +195,10 @@ class EffectPluginStage(Stage):
if key.startswith("sensor."):
effect_ctx.set_state(key, value)
# Copy metrics from PipelineContext to EffectContext
if "metrics" in ctx.state:
effect_ctx.set_state("metrics", ctx.state["metrics"])
# Apply sensor param bindings if effect has them
if hasattr(self._effect, "param_bindings") and self._effect.param_bindings:
bound_config = apply_param_bindings(self._effect, effect_ctx)
@@ -297,6 +330,106 @@ class CameraStage(Stage):
self._camera.reset()
class FontStage(Stage):
"""Stage that applies font rendering to content.
FontStage is a Transform that takes raw content (text, headlines)
and renders it to an ANSI-formatted buffer using the configured font.
This decouples font rendering from data sources, allowing:
- Different fonts per source
- Runtime font swapping
- Font as a pipeline stage
Attributes:
font_path: Path to font file (None = use config default)
font_size: Font size in points (None = use config default)
font_ref: Reference name for registered font ("default", "cjk", etc.)
"""
def __init__(
self,
font_path: str | None = None,
font_size: int | None = None,
font_ref: str | None = "default",
name: str = "font",
):
self.name = name
self.category = "transform"
self.optional = False
self._font_path = font_path
self._font_size = font_size
self._font_ref = font_ref
self._font = None
@property
def stage_type(self) -> str:
return "transform"
@property
def capabilities(self) -> set[str]:
return {f"transform.{self.name}", "render.output"}
@property
def dependencies(self) -> set[str]:
return {"source"}
def init(self, ctx: PipelineContext) -> bool:
"""Initialize font from config or path."""
from engine import config
if self._font_path:
try:
from PIL import ImageFont
size = self._font_size or config.FONT_SZ
self._font = ImageFont.truetype(self._font_path, size)
except Exception:
return False
return True
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Render content with font to buffer."""
if data is None:
return None
from engine.render import make_block
w = ctx.params.viewport_width if ctx.params else 80
# If data is already a list of strings (buffer), return as-is
if isinstance(data, list) and data and isinstance(data[0], str):
return data
# If data is a list of items, render each with font
if isinstance(data, list):
result = []
for item in data:
# Handle SourceItem or tuple (title, source, timestamp)
if hasattr(item, "content"):
title = item.content
src = getattr(item, "source", "unknown")
ts = getattr(item, "timestamp", "0")
elif isinstance(item, tuple):
title = item[0] if len(item) > 0 else ""
src = item[1] if len(item) > 1 else "unknown"
ts = str(item[2]) if len(item) > 2 else "0"
else:
title = str(item)
src = "unknown"
ts = "0"
try:
block = make_block(title, src, ts, w)
result.extend(block)
except Exception:
result.append(title)
return result
return data
def create_stage_from_display(display, name: str = "terminal") -> DisplayStage:
"""Create a Stage from a Display instance."""
return DisplayStage(display, name)
@@ -317,6 +450,96 @@ def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
return CameraStage(camera, name)
def create_stage_from_font(
font_path: str | None = None,
font_size: int | None = None,
font_ref: str | None = "default",
name: str = "font",
) -> FontStage:
"""Create a FontStage for rendering content with fonts."""
return FontStage(
font_path=font_path, font_size=font_size, font_ref=font_ref, name=name
)
class CanvasStage(Stage):
"""Stage that manages a Canvas for rendering.
CanvasStage creates and manages a 2D canvas that can hold rendered content.
Other stages can write to and read from the canvas via the pipeline context.
This enables:
- Pre-rendering content off-screen
- Multiple cameras viewing different regions
- Smooth scrolling (camera moves, content stays)
- Layer compositing
Usage:
- Add CanvasStage to pipeline
- Other stages access canvas via: ctx.get("canvas")
"""
def __init__(
self,
width: int = 80,
height: int = 24,
name: str = "canvas",
):
self.name = name
self.category = "system"
self.optional = True
self._width = width
self._height = height
self._canvas = None
@property
def stage_type(self) -> str:
return "system"
@property
def capabilities(self) -> set[str]:
return {"canvas"}
@property
def dependencies(self) -> set[str]:
return set()
@property
def inlet_types(self) -> set:
from engine.pipeline.core import DataType
return {DataType.ANY}
@property
def outlet_types(self) -> set:
from engine.pipeline.core import DataType
return {DataType.ANY}
def init(self, ctx: PipelineContext) -> bool:
from engine.canvas import Canvas
self._canvas = Canvas(width=self._width, height=self._height)
ctx.set("canvas", self._canvas)
return True
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Pass through data but ensure canvas is in context."""
if self._canvas is None:
from engine.canvas import Canvas
self._canvas = Canvas(width=self._width, height=self._height)
ctx.set("canvas", self._canvas)
return data
def get_canvas(self):
"""Get the canvas instance."""
return self._canvas
def cleanup(self) -> None:
self._canvas = None
def create_items_stage(items, name: str = "headlines") -> ItemsStage:
"""Create a Stage that holds pre-fetched items."""
return ItemsStage(items, name)

View File

@@ -86,6 +86,7 @@ class Pipeline:
self._capability_map = self._build_capability_map()
self._execution_order = self._resolve_dependencies()
self._validate_dependencies()
self._validate_types()
self._initialized = True
return self
@@ -185,6 +186,60 @@ class Pipeline:
"Missing capabilities:\n" + "\n".join(msgs),
)
def _validate_types(self) -> None:
"""Validate inlet/outlet types between connected stages.
PureData-style type validation. Each stage declares its inlet_types
(what it accepts) and outlet_types (what it produces). This method
validates that connected stages have compatible types.
Raises StageError if type mismatch is detected.
"""
from engine.pipeline.core import DataType
errors: list[str] = []
for i, name in enumerate(self._execution_order):
stage = self._stages.get(name)
if not stage:
continue
inlet_types = stage.inlet_types
# Check against previous stage's outlet types
if i > 0:
prev_name = self._execution_order[i - 1]
prev_stage = self._stages.get(prev_name)
if prev_stage:
prev_outlets = prev_stage.outlet_types
# Check if any outlet type is accepted by this inlet
compatible = (
DataType.ANY in inlet_types
or DataType.ANY in prev_outlets
or bool(prev_outlets & inlet_types)
)
if not compatible:
errors.append(
f" - {name} (inlet: {inlet_types}) "
f"{prev_name} (outlet: {prev_outlets})"
)
# Check display/sink stages (should accept TEXT_BUFFER)
if (
stage.category == "display"
and DataType.TEXT_BUFFER not in inlet_types
and DataType.ANY not in inlet_types
):
errors.append(f" - {name} is display but doesn't accept TEXT_BUFFER")
if errors:
raise StageError(
"type_validation",
"Type mismatch in pipeline connections:\n" + "\n".join(errors),
)
def initialize(self) -> bool:
"""Initialize all stages in execution order."""
for name in self._execution_order:
@@ -194,7 +249,12 @@ class Pipeline:
return True
def execute(self, data: Any | None = None) -> StageResult:
"""Execute the pipeline with the given input data."""
"""Execute the pipeline with the given input data.
Pipeline execution:
1. Execute all non-overlay stages in dependency order
2. Apply overlay stages on top (sorted by render_order)
"""
if not self._initialized:
self.build()
@@ -209,11 +269,37 @@ class Pipeline:
frame_start = time.perf_counter() if self._metrics_enabled else 0
stage_timings: list[StageMetrics] = []
# Separate overlay stages from regular stages
overlay_stages: list[tuple[int, Stage]] = []
regular_stages: list[str] = []
for name in self._execution_order:
stage = self._stages.get(name)
if not stage or not stage.is_enabled():
continue
# Safely check is_overlay - handle MagicMock and other non-bool returns
try:
is_overlay = bool(getattr(stage, "is_overlay", False))
except Exception:
is_overlay = False
if is_overlay:
# Safely get render_order
try:
render_order = int(getattr(stage, "render_order", 0))
except Exception:
render_order = 0
overlay_stages.append((render_order, stage))
else:
regular_stages.append(name)
# Execute regular stages in dependency order
for name in regular_stages:
stage = self._stages.get(name)
if not stage or not stage.is_enabled():
continue
stage_start = time.perf_counter() if self._metrics_enabled else 0
try:
@@ -241,6 +327,42 @@ class Pipeline:
)
)
# Apply overlay stages (sorted by render_order)
overlay_stages.sort(key=lambda x: x[0])
for render_order, stage in overlay_stages:
stage_start = time.perf_counter() if self._metrics_enabled else 0
stage_name = f"[overlay]{stage.name}"
try:
# Overlays receive current_data but don't pass their output to next stage
# Instead, their output is composited on top
overlay_output = stage.process(current_data, self.context)
# For now, we just let the overlay output pass through
# In a more sophisticated implementation, we'd composite it
if overlay_output is not None:
current_data = overlay_output
except Exception as e:
if not stage.optional:
return StageResult(
success=False,
data=current_data,
error=str(e),
stage_name=stage_name,
)
if self._metrics_enabled:
stage_duration = (time.perf_counter() - stage_start) * 1000
chars_in = len(str(data)) if data else 0
chars_out = len(str(current_data)) if current_data else 0
stage_timings.append(
StageMetrics(
name=stage_name,
duration_ms=stage_duration,
chars_in=chars_in,
chars_out=chars_out,
)
)
if self._metrics_enabled:
total_duration = (time.perf_counter() - frame_start) * 1000
self._frame_metrics.append(
@@ -250,6 +372,12 @@ class Pipeline:
stages=stage_timings,
)
)
# Store metrics in context for other stages (like HUD)
# This makes metrics a first-class pipeline citizen
if self.context:
self.context.state["metrics"] = self.get_metrics_summary()
if len(self._frame_metrics) > self._max_metrics_frames:
self._frame_metrics.pop(0)
self._current_frame_number += 1
@@ -282,6 +410,22 @@ class Pipeline:
"""Get list of stage names."""
return list(self._stages.keys())
def get_overlay_stages(self) -> list[Stage]:
"""Get all overlay stages sorted by render_order."""
overlays = [stage for stage in self._stages.values() if stage.is_overlay]
overlays.sort(key=lambda s: s.render_order)
return overlays
def get_stage_type(self, name: str) -> str:
"""Get the stage_type for a stage."""
stage = self._stages.get(name)
return stage.stage_type if stage else ""
def get_render_order(self, name: str) -> int:
"""Get the render_order for a stage."""
stage = self._stages.get(name)
return stage.render_order if stage else 0
def get_metrics_summary(self) -> dict:
"""Get summary of collected metrics."""
if not self._frame_metrics:

View File

@@ -5,17 +5,40 @@ This module provides the foundation for a clean, dependency-managed pipeline:
- Stage: Base class for all pipeline components (sources, effects, displays, cameras)
- PipelineContext: Dependency injection context for runtime data exchange
- Capability system: Explicit capability declarations with duck-typing support
- DataType: PureData-style inlet/outlet typing for validation
"""
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from engine.pipeline.params import PipelineParams
class DataType(Enum):
"""PureData-style data types for inlet/outlet validation.
Each type represents a specific data format that flows through the pipeline.
This enables compile-time-like validation of connections.
Examples:
SOURCE_ITEMS: List[SourceItem] - raw items from sources
ITEM_TUPLES: List[tuple] - (title, source, timestamp) tuples
TEXT_BUFFER: List[str] - rendered ANSI buffer for display
RAW_TEXT: str - raw text strings
"""
SOURCE_ITEMS = auto() # List[SourceItem] - from DataSource
ITEM_TUPLES = auto() # List[tuple] - (title, source, ts)
TEXT_BUFFER = auto() # List[str] - ANSI buffer
RAW_TEXT = auto() # str - raw text
ANY = auto() # Accepts any type
NONE = auto() # No data (terminator)
@dataclass
class StageConfig:
"""Configuration for a single stage."""
@@ -35,18 +58,78 @@ class Stage(ABC):
- Effects: Post-processors (noise, fade, glitch, hud)
- Displays: Output backends (terminal, pygame, websocket)
- Cameras: Viewport controllers (vertical, horizontal, omni)
- Overlays: UI elements that compose on top (HUD)
Stages declare:
- capabilities: What they provide to other stages
- dependencies: What they need from other stages
- stage_type: Category of stage (source, effect, overlay, display)
- render_order: Execution order within category
- is_overlay: If True, output is composited on top, not passed downstream
Duck-typing is supported: any class with the required methods can act as a Stage.
"""
name: str
category: str # "source", "effect", "display", "camera"
category: str # "source", "effect", "overlay", "display", "camera"
optional: bool = False # If True, pipeline continues even if stage fails
@property
def stage_type(self) -> str:
"""Category of stage for ordering.
Valid values: "source", "effect", "overlay", "display", "camera"
Defaults to category for backwards compatibility.
"""
return self.category
@property
def render_order(self) -> int:
"""Execution order within stage_type group.
Higher values execute later. Useful for ordering overlays
or effects that need specific execution order.
"""
return 0
@property
def is_overlay(self) -> bool:
"""If True, this stage's output is composited on top of the buffer.
Overlay stages don't pass their output to the next stage.
Instead, their output is layered on top of the final buffer.
Use this for HUD, status displays, and similar UI elements.
"""
return False
@property
def inlet_types(self) -> set[DataType]:
"""Return set of data types this stage accepts.
PureData-style inlet typing. If the connected upstream stage's
outlet_type is not in this set, the pipeline will raise an error.
Examples:
- Source stages: {DataType.NONE} (no input needed)
- Transform stages: {DataType.ITEM_TUPLES, DataType.TEXT_BUFFER}
- Display stages: {DataType.TEXT_BUFFER}
"""
return {DataType.ANY}
@property
def outlet_types(self) -> set[DataType]:
"""Return set of data types this stage produces.
PureData-style outlet typing. Downstream stages must accept
this type in their inlet_types.
Examples:
- Source stages: {DataType.SOURCE_ITEMS}
- Transform stages: {DataType.TEXT_BUFFER}
- Display stages: {DataType.NONE} (consumes data)
"""
return {DataType.ANY}
@property
def capabilities(self) -> set[str]:
"""Return set of capabilities this stage provides.

View File

@@ -100,16 +100,28 @@ class TestPipeline:
def test_build_resolves_dependencies(self):
"""Pipeline.build resolves execution order."""
from engine.pipeline.core import DataType
pipeline = Pipeline()
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.stage_type = "source"
mock_source.render_order = 0
mock_source.is_overlay = False
mock_source.inlet_types = {DataType.NONE}
mock_source.outlet_types = {DataType.SOURCE_ITEMS}
mock_source.dependencies = set()
mock_source.capabilities = {"source"}
mock_display = MagicMock(spec=Stage)
mock_display.name = "display"
mock_display.category = "display"
mock_display.stage_type = "display"
mock_display.render_order = 0
mock_display.is_overlay = False
mock_display.inlet_types = {DataType.ANY} # Accept any type
mock_display.outlet_types = {DataType.NONE}
mock_display.dependencies = {"source"}
mock_display.capabilities = {"display"}
@@ -123,6 +135,8 @@ class TestPipeline:
def test_execute_runs_stages(self):
"""Pipeline.execute runs all stages in order."""
from engine.pipeline.core import DataType
pipeline = Pipeline()
call_order = []
@@ -130,6 +144,11 @@ class TestPipeline:
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.stage_type = "source"
mock_source.render_order = 0
mock_source.is_overlay = False
mock_source.inlet_types = {DataType.NONE}
mock_source.outlet_types = {DataType.SOURCE_ITEMS}
mock_source.dependencies = set()
mock_source.capabilities = {"source"}
mock_source.process = lambda data, ctx: call_order.append("source") or "data"
@@ -137,6 +156,11 @@ class TestPipeline:
mock_effect = MagicMock(spec=Stage)
mock_effect.name = "effect"
mock_effect.category = "effect"
mock_effect.stage_type = "effect"
mock_effect.render_order = 0
mock_effect.is_overlay = False
mock_effect.inlet_types = {DataType.SOURCE_ITEMS}
mock_effect.outlet_types = {DataType.TEXT_BUFFER}
mock_effect.dependencies = {"source"}
mock_effect.capabilities = {"effect"}
mock_effect.process = lambda data, ctx: call_order.append("effect") or data
@@ -144,6 +168,11 @@ class TestPipeline:
mock_display = MagicMock(spec=Stage)
mock_display.name = "display"
mock_display.category = "display"
mock_display.stage_type = "display"
mock_display.render_order = 0
mock_display.is_overlay = False
mock_display.inlet_types = {DataType.TEXT_BUFFER}
mock_display.outlet_types = {DataType.NONE}
mock_display.dependencies = {"effect"}
mock_display.capabilities = {"display"}
mock_display.process = lambda data, ctx: call_order.append("display") or data
@@ -165,6 +194,9 @@ class TestPipeline:
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.stage_type = "source"
mock_source.render_order = 0
mock_source.is_overlay = False
mock_source.dependencies = set()
mock_source.capabilities = {"source"}
mock_source.process = lambda data, ctx: "data"
@@ -172,6 +204,9 @@ class TestPipeline:
mock_failing = MagicMock(spec=Stage)
mock_failing.name = "failing"
mock_failing.category = "effect"
mock_failing.stage_type = "effect"
mock_failing.render_order = 0
mock_failing.is_overlay = False
mock_failing.dependencies = {"source"}
mock_failing.capabilities = {"effect"}
mock_failing.optional = False
@@ -195,6 +230,9 @@ class TestPipeline:
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.stage_type = "source"
mock_source.render_order = 0
mock_source.is_overlay = False
mock_source.dependencies = set()
mock_source.capabilities = {"source"}
mock_source.process = lambda data, ctx: "data"
@@ -202,6 +240,9 @@ class TestPipeline:
mock_optional = MagicMock(spec=Stage)
mock_optional.name = "optional"
mock_optional.category = "effect"
mock_optional.stage_type = "effect"
mock_optional.render_order = 0
mock_optional.is_overlay = False
mock_optional.dependencies = {"source"}
mock_optional.capabilities = {"effect"}
mock_optional.optional = True
@@ -918,3 +959,227 @@ class TestPipelineMetrics:
# After reset, metrics collection starts fresh
pipeline.execute("test3")
assert pipeline.get_metrics_summary()["frame_count"] == 1
class TestOverlayStages:
"""Tests for overlay stage support."""
def test_stage_is_overlay_property(self):
"""Stage has is_overlay property defaulting to False."""
from engine.pipeline.core import Stage
class TestStage(Stage):
name = "test"
category = "effect"
def process(self, data, ctx):
return data
stage = TestStage()
assert stage.is_overlay is False
def test_stage_render_order_property(self):
"""Stage has render_order property defaulting to 0."""
from engine.pipeline.core import Stage
class TestStage(Stage):
name = "test"
category = "effect"
def process(self, data, ctx):
return data
stage = TestStage()
assert stage.render_order == 0
def test_stage_stage_type_property(self):
"""Stage has stage_type property defaulting to category."""
from engine.pipeline.core import Stage
class TestStage(Stage):
name = "test"
category = "effect"
def process(self, data, ctx):
return data
stage = TestStage()
assert stage.stage_type == "effect"
def test_pipeline_get_overlay_stages(self):
"""Pipeline.get_overlay_stages returns overlay stages sorted by render_order."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
class OverlayStageA(Stage):
name = "overlay_a"
category = "overlay"
@property
def is_overlay(self):
return True
@property
def render_order(self):
return 10
def process(self, data, ctx):
return data
class OverlayStageB(Stage):
name = "overlay_b"
category = "overlay"
@property
def is_overlay(self):
return True
@property
def render_order(self):
return 5
def process(self, data, ctx):
return data
class RegularStage(Stage):
name = "regular"
category = "effect"
def process(self, data, ctx):
return data
pipeline = Pipeline()
pipeline.add_stage("overlay_a", OverlayStageA())
pipeline.add_stage("overlay_b", OverlayStageB())
pipeline.add_stage("regular", RegularStage())
pipeline.build()
overlays = pipeline.get_overlay_stages()
assert len(overlays) == 2
# Should be sorted by render_order
assert overlays[0].name == "overlay_b" # render_order=5
assert overlays[1].name == "overlay_a" # render_order=10
def test_pipeline_executes_overlays_after_regular(self):
"""Pipeline executes overlays after regular stages."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
call_order = []
class RegularStage(Stage):
name = "regular"
category = "effect"
def process(self, data, ctx):
call_order.append("regular")
return data
class OverlayStage(Stage):
name = "overlay"
category = "overlay"
@property
def is_overlay(self):
return True
@property
def render_order(self):
return 100
def process(self, data, ctx):
call_order.append("overlay")
return data
pipeline = Pipeline()
pipeline.add_stage("regular", RegularStage())
pipeline.add_stage("overlay", OverlayStage())
pipeline.build()
pipeline.execute("data")
assert call_order == ["regular", "overlay"]
def test_effect_plugin_stage_hud_is_overlay(self):
"""EffectPluginStage marks HUD as overlay."""
from engine.effects.types import EffectConfig, EffectPlugin
from engine.pipeline.adapters import EffectPluginStage
class HudEffect(EffectPlugin):
name = "hud"
config = EffectConfig(enabled=True)
def process(self, buf, ctx):
return buf
def configure(self, config):
pass
stage = EffectPluginStage(HudEffect(), name="hud")
assert stage.is_overlay is True
assert stage.stage_type == "overlay"
assert stage.render_order == 100
def test_effect_plugin_stage_non_hud_not_overlay(self):
"""EffectPluginStage marks non-HUD effects as not overlay."""
from engine.effects.types import EffectConfig, EffectPlugin
from engine.pipeline.adapters import EffectPluginStage
class FadeEffect(EffectPlugin):
name = "fade"
config = EffectConfig(enabled=True)
def process(self, buf, ctx):
return buf
def configure(self, config):
pass
stage = EffectPluginStage(FadeEffect(), name="fade")
assert stage.is_overlay is False
assert stage.stage_type == "effect"
assert stage.render_order == 0
def test_pipeline_get_stage_type(self):
"""Pipeline.get_stage_type returns stage_type for a stage."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
class TestStage(Stage):
name = "test"
category = "effect"
@property
def stage_type(self):
return "overlay"
def process(self, data, ctx):
return data
pipeline = Pipeline()
pipeline.add_stage("test", TestStage())
pipeline.build()
assert pipeline.get_stage_type("test") == "overlay"
def test_pipeline_get_render_order(self):
"""Pipeline.get_render_order returns render_order for a stage."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
class TestStage(Stage):
name = "test"
category = "effect"
@property
def render_order(self):
return 42
def process(self, data, ctx):
return data
pipeline = Pipeline()
pipeline.add_stage("test", TestStage())
pipeline.build()
assert pipeline.get_render_order("test") == 42