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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user