feat(pipeline): improve new pipeline architecture

- Add TransformDataSource for filtering/mapping source items
- Add MetricsDataSource for rendering live pipeline metrics as ASCII art
- Fix display stage registration in StageRegistry
- Register sources with both class name and simple name aliases
- Fix DisplayStage.init() to pass reuse parameter
- Simplify create_default_pipeline to use DataSourceStage wrapper
- Set pygame as default display
- Remove old pipeline tasks from mise.toml
- Add tests for new pipeline architecture
This commit is contained in:
2026-03-16 11:30:21 -07:00
parent 31cabe9128
commit 828b8489e1
7 changed files with 487 additions and 27 deletions

View File

@@ -129,7 +129,7 @@ class Config:
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
display: str = "terminal"
display: str = "pygame"
websocket: bool = False
websocket_port: int = 8765
@@ -237,7 +237,7 @@ GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
# ─── WEBSOCKET ─────────────────────────────────────────────
DISPLAY = _arg_value("--display", sys.argv) or "terminal"
DISPLAY = _arg_value("--display", sys.argv) or "pygame"
WEBSOCKET = "--websocket" in sys.argv
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)

View File

@@ -183,7 +183,7 @@ class DisplayStage(Stage):
def init(self, ctx: PipelineContext) -> bool:
w = ctx.params.viewport_width if ctx.params else 80
h = ctx.params.viewport_height if ctx.params else 24
result = self._display.init(w, h)
result = self._display.init(w, h, reuse=False)
return result is not False
def process(self, data: Any, ctx: PipelineContext) -> Any:

View File

@@ -303,18 +303,14 @@ def create_pipeline_from_params(params: PipelineParams) -> Pipeline:
def create_default_pipeline() -> Pipeline:
"""Create a default pipeline with all standard components."""
from engine.pipeline.adapters import DataSourceStage
from engine.sources_v2 import HeadlinesDataSource
pipeline = Pipeline()
# Add source stage
source = StageRegistry.create("source", "headlines")
if source:
pipeline.add_stage("source", source)
# Add effect stages
for effect_name in ["noise", "fade", "glitch", "firehose", "hud"]:
effect = StageRegistry.create("effect", effect_name)
if effect:
pipeline.add_stage(f"effect_{effect_name}", effect)
# Add source stage (wrapped as Stage)
source = HeadlinesDataSource()
pipeline.add_stage("source", DataSourceStage(source, name="headlines"))
# Add display stage
display = StageRegistry.create("display", "terminal")

View File

@@ -28,7 +28,7 @@ class StageRegistry:
cls._categories[category] = {}
# Use class name as key
key = stage_class.__name__
key = getattr(stage_class, "__name__", stage_class.__class__.__name__)
cls._categories[category][key] = stage_class
@classmethod
@@ -90,6 +90,10 @@ def discover_stages() -> None:
StageRegistry.register("source", HeadlinesDataSource)
StageRegistry.register("source", PoetryDataSource)
StageRegistry.register("source", PipelineDataSource)
StageRegistry._categories["source"]["headlines"] = HeadlinesDataSource
StageRegistry._categories["source"]["poetry"] = PoetryDataSource
StageRegistry._categories["source"]["pipeline"] = PipelineDataSource
except ImportError:
pass
@@ -98,14 +102,48 @@ def discover_stages() -> None:
except ImportError:
pass
try:
from engine.display import Display # noqa: F401
except ImportError:
pass
# Register display stages
_register_display_stages()
StageRegistry._discovered = True
def _register_display_stages() -> None:
"""Register display backends as stages."""
try:
from engine.display import DisplayRegistry
except ImportError:
return
DisplayRegistry.initialize()
for backend_name in DisplayRegistry.list_backends():
factory = _DisplayStageFactory(backend_name)
StageRegistry._categories.setdefault("display", {})[backend_name] = factory
class _DisplayStageFactory:
"""Factory that creates DisplayStage instances for a specific backend."""
def __init__(self, backend_name: str):
self._backend_name = backend_name
def __call__(self):
from engine.display import DisplayRegistry
from engine.pipeline.adapters import DisplayStage
display = DisplayRegistry.create(self._backend_name)
if display is None:
raise RuntimeError(
f"Failed to create display backend: {self._backend_name}"
)
return DisplayStage(display, name=self._backend_name)
@property
def __name__(self) -> str:
return self._backend_name.capitalize() + "Stage"
# Convenience functions
def register_source(stage_class: type[Stage]) -> None:
"""Register a source stage."""

View File

@@ -9,6 +9,7 @@ Each data source implements a common interface:
"""
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
@@ -125,6 +126,115 @@ class PipelineDataSource(DataSource):
return self.fetch()
class MetricsDataSource(DataSource):
"""Data source that renders live pipeline metrics as ASCII art.
Wraps a Pipeline and displays active stages with their average execution
time and approximate FPS impact. Updates lazily when camera is about to
focus on a new node (frame % 15 == 12).
"""
def __init__(
self,
pipeline: Any,
viewport_width: int = 80,
viewport_height: int = 24,
):
self.pipeline = pipeline
self.viewport_width = viewport_width
self.viewport_height = viewport_height
self.frame = 0
self._cached_metrics: dict | None = None
@property
def name(self) -> str:
return "metrics"
@property
def is_dynamic(self) -> bool:
return True
def fetch(self) -> list[SourceItem]:
if self.frame % 15 == 12:
self._cached_metrics = None
if self._cached_metrics is None:
self._cached_metrics = self._fetch_metrics()
buffer = self._render_metrics(self._cached_metrics)
self.frame += 1
content = "\n".join(buffer)
return [
SourceItem(content=content, source="metrics", timestamp=f"f{self.frame}")
]
def _fetch_metrics(self) -> dict:
if hasattr(self.pipeline, "get_metrics_summary"):
metrics = self.pipeline.get_metrics_summary()
if "error" not in metrics:
return metrics
return {"stages": {}, "pipeline": {"avg_ms": 0}}
def _render_metrics(self, metrics: dict) -> list[str]:
stages = metrics.get("stages", {})
if not stages:
return self._render_empty()
active_stages = {
name: stats for name, stats in stages.items() if stats.get("avg_ms", 0) > 0
}
if not active_stages:
return self._render_empty()
total_avg = sum(s["avg_ms"] for s in active_stages.values())
if total_avg == 0:
total_avg = 1
lines: list[str] = []
lines.append("" * self.viewport_width)
lines.append(" PIPELINE METRICS ".center(self.viewport_width, ""))
lines.append("" * self.viewport_width)
header = f"{'STAGE':<20} {'AVG_MS':>8} {'FPS %':>8}"
lines.append(header)
lines.append("" * self.viewport_width)
for name, stats in sorted(active_stages.items()):
avg_ms = stats.get("avg_ms", 0)
fps_impact = (avg_ms / 16.67) * 100 if avg_ms > 0 else 0
row = f"{name:<20} {avg_ms:>7.2f} {fps_impact:>7.1f}%"
lines.append(row[: self.viewport_width])
lines.append("" * self.viewport_width)
total_row = (
f"{'TOTAL':<20} {total_avg:>7.2f} {(total_avg / 16.67) * 100:>7.1f}%"
)
lines.append(total_row[: self.viewport_width])
lines.append("" * self.viewport_width)
lines.append(
f" Frame:{self.frame:04d} Cache:{'HIT' if self._cached_metrics else 'MISS'}"
)
while len(lines) < self.viewport_height:
lines.append(" " * self.viewport_width)
return lines[: self.viewport_height]
def _render_empty(self) -> list[str]:
lines = [" " * self.viewport_width for _ in range(self.viewport_height)]
msg = "No metrics available"
y = self.viewport_height // 2
x = (self.viewport_width - len(msg)) // 2
lines[y] = " " * x + msg + " " * (self.viewport_width - x - len(msg))
return lines
def get_items(self) -> list[SourceItem]:
return self.fetch()
class CachedDataSource(DataSource):
"""Data source that wraps another source with caching."""
@@ -146,6 +256,44 @@ class CachedDataSource(DataSource):
return self._items
class TransformDataSource(DataSource):
"""Data source that transforms items from another source.
Applies optional filter and map functions to each item.
This enables chaining: source → transform → transformed output.
Args:
source: The source to fetch items from
filter_fn: Optional function(item: SourceItem) -> bool
map_fn: Optional function(item: SourceItem) -> SourceItem
"""
def __init__(
self,
source: DataSource,
filter_fn: Callable[[SourceItem], bool] | None = None,
map_fn: Callable[[SourceItem], SourceItem] | None = None,
):
self.source = source
self.filter_fn = filter_fn
self.map_fn = map_fn
@property
def name(self) -> str:
return f"transform:{self.source.name}"
def fetch(self) -> list[SourceItem]:
items = self.source.fetch()
if self.filter_fn:
items = [item for item in items if self.filter_fn(item)]
if self.map_fn:
items = [self.map_fn(item) for item in items]
return items
class CompositeDataSource(DataSource):
"""Data source that combines multiple sources."""

View File

@@ -38,19 +38,16 @@ run-kitty = { run = "uv run mainline.py --display kitty", depends = ["sync-all"]
run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] }
run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] }
run-client = { run = "mise run run-both & sleep 2 && $(open http://localhost:8766 2>/dev/null || xdg-open http://localhost:8766 2>/dev/null || echo 'Open http://localhost:8766 manually'); wait", depends = ["sync-all"] }
run-demo = { run = "uv run mainline.py --demo --display pygame", depends = ["sync-all"] }
run-pipeline = "uv run mainline.py --pipeline-diagram"
run-pipeline-demo = { run = "uv run mainline.py --pipeline-demo --display pygame", depends = ["sync-all"] }
# =====================
# New Pipeline Architecture (unified Stage-based)
# Pipeline Architecture (unified Stage-based)
# =====================
run-v2 = { run = "uv run mainline.py --pipeline --display pygame", depends = ["sync-all"] }
run-v2-demo = { run = "uv run mainline.py --pipeline --pipeline-preset demo --display pygame", depends = ["sync-all"] }
run-v2-poetry = { run = "uv run mainline.py --pipeline --pipeline-preset poetry --display pygame", depends = ["sync-all"] }
run-v2-websocket = { run = "uv run mainline.py --pipeline --pipeline-preset websocket", depends = ["sync-all"] }
run-v2-firehose = { run = "uv run mainline.py --pipeline --pipeline-preset firehose --display pygame", depends = ["sync-all"] }
run-pipeline = { run = "uv run mainline.py --pipeline --display pygame", depends = ["sync-all"] }
run-pipeline-demo = { run = "uv run mainline.py --pipeline --pipeline-preset demo --display pygame", depends = ["sync-all"] }
run-pipeline-poetry = { run = "uv run mainline.py --pipeline --pipeline-preset poetry --display pygame", depends = ["sync-all"] }
run-pipeline-websocket = { run = "uv run mainline.py --pipeline --pipeline-preset websocket", depends = ["sync-all"] }
run-pipeline-firehose = { run = "uv run mainline.py --pipeline --pipeline-preset firehose --display pygame", depends = ["sync-all"] }
# =====================
# Presets (Animation-controlled modes)

281
tests/test_pipeline.py Normal file
View File

@@ -0,0 +1,281 @@
"""
Tests for the new unified pipeline architecture.
"""
from unittest.mock import MagicMock
from engine.pipeline import (
Pipeline,
PipelineConfig,
PipelineContext,
Stage,
StageRegistry,
create_default_pipeline,
discover_stages,
)
class TestStageRegistry:
"""Tests for StageRegistry."""
def setup_method(self):
"""Reset registry before each test."""
StageRegistry._discovered = False
StageRegistry._categories.clear()
StageRegistry._instances.clear()
def test_discover_stages_registers_sources(self):
"""discover_stages registers source stages."""
discover_stages()
sources = StageRegistry.list("source")
assert "HeadlinesDataSource" in sources
assert "PoetryDataSource" in sources
assert "PipelineDataSource" in sources
def test_discover_stages_registers_displays(self):
"""discover_stages registers display stages."""
discover_stages()
displays = StageRegistry.list("display")
assert "terminal" in displays
assert "pygame" in displays
assert "websocket" in displays
assert "null" in displays
assert "sixel" in displays
def test_create_source_stage(self):
"""StageRegistry.create creates source stages."""
discover_stages()
source = StageRegistry.create("source", "HeadlinesDataSource")
assert source is not None
assert source.name == "headlines"
def test_create_display_stage(self):
"""StageRegistry.create creates display stages."""
discover_stages()
display = StageRegistry.create("display", "terminal")
assert display is not None
assert hasattr(display, "_display")
def test_create_display_stage_pygame(self):
"""StageRegistry.create creates pygame display stage."""
discover_stages()
display = StageRegistry.create("display", "pygame")
assert display is not None
class TestPipeline:
"""Tests for Pipeline class."""
def setup_method(self):
"""Reset registry before each test."""
StageRegistry._discovered = False
StageRegistry._categories.clear()
StageRegistry._instances.clear()
discover_stages()
def test_create_pipeline(self):
"""Pipeline can be created with config."""
config = PipelineConfig(source="headlines", display="terminal")
pipeline = Pipeline(config=config)
assert pipeline.config is not None
assert pipeline.config.source == "headlines"
assert pipeline.config.display == "terminal"
def test_add_stage(self):
"""Pipeline.add_stage adds a stage."""
pipeline = Pipeline()
mock_stage = MagicMock(spec=Stage)
mock_stage.name = "test_stage"
mock_stage.category = "test"
pipeline.add_stage("test", mock_stage)
assert "test" in pipeline.stages
def test_build_resolves_dependencies(self):
"""Pipeline.build resolves execution order."""
pipeline = Pipeline()
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.dependencies = set()
mock_display = MagicMock(spec=Stage)
mock_display.name = "display"
mock_display.category = "display"
mock_display.dependencies = {"source"}
pipeline.add_stage("source", mock_source)
pipeline.add_stage("display", mock_display)
pipeline.build()
assert pipeline._initialized is True
assert "source" in pipeline.execution_order
assert "display" in pipeline.execution_order
def test_execute_runs_stages(self):
"""Pipeline.execute runs all stages in order."""
pipeline = Pipeline()
call_order = []
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.dependencies = set()
mock_source.process = lambda data, ctx: call_order.append("source") or "data"
mock_effect = MagicMock(spec=Stage)
mock_effect.name = "effect"
mock_effect.category = "effect"
mock_effect.dependencies = {"source"}
mock_effect.process = lambda data, ctx: call_order.append("effect") or data
mock_display = MagicMock(spec=Stage)
mock_display.name = "display"
mock_display.category = "display"
mock_display.dependencies = {"effect"}
mock_display.process = lambda data, ctx: call_order.append("display") or data
pipeline.add_stage("source", mock_source)
pipeline.add_stage("effect", mock_effect)
pipeline.add_stage("display", mock_display)
pipeline.build()
result = pipeline.execute(None)
assert result.success is True
assert call_order == ["source", "effect", "display"]
def test_execute_handles_stage_failure(self):
"""Pipeline.execute handles stage failures."""
pipeline = Pipeline()
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.dependencies = set()
mock_source.process = lambda data, ctx: "data"
mock_failing = MagicMock(spec=Stage)
mock_failing.name = "failing"
mock_failing.category = "effect"
mock_failing.dependencies = {"source"}
mock_failing.optional = False
mock_failing.process = lambda data, ctx: (_ for _ in ()).throw(
Exception("fail")
)
pipeline.add_stage("source", mock_source)
pipeline.add_stage("failing", mock_failing)
pipeline.build()
result = pipeline.execute(None)
assert result.success is False
assert result.error is not None
def test_optional_stage_failure_continues(self):
"""Pipeline.execute continues on optional stage failure."""
pipeline = Pipeline()
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.dependencies = set()
mock_source.process = lambda data, ctx: "data"
mock_optional = MagicMock(spec=Stage)
mock_optional.name = "optional"
mock_optional.category = "effect"
mock_optional.dependencies = {"source"}
mock_optional.optional = True
mock_optional.process = lambda data, ctx: (_ for _ in ()).throw(
Exception("fail")
)
pipeline.add_stage("source", mock_source)
pipeline.add_stage("optional", mock_optional)
pipeline.build()
result = pipeline.execute(None)
assert result.success is True
class TestPipelineContext:
"""Tests for PipelineContext."""
def test_init_empty(self):
"""PipelineContext initializes with empty services and state."""
ctx = PipelineContext()
assert ctx.services == {}
assert ctx.state == {}
def test_init_with_services(self):
"""PipelineContext accepts initial services."""
ctx = PipelineContext(services={"display": MagicMock()})
assert "display" in ctx.services
def test_init_with_state(self):
"""PipelineContext accepts initial state."""
ctx = PipelineContext(initial_state={"count": 42})
assert ctx.get_state("count") == 42
def test_get_set_services(self):
"""PipelineContext can get/set services."""
ctx = PipelineContext()
mock_service = MagicMock()
ctx.set("test_service", mock_service)
assert ctx.get("test_service") == mock_service
def test_get_set_state(self):
"""PipelineContext can get/set state."""
ctx = PipelineContext()
ctx.set_state("counter", 100)
assert ctx.get_state("counter") == 100
def test_lazy_resolver(self):
"""PipelineContext resolves lazy services."""
ctx = PipelineContext()
config = ctx.get("config")
assert config is not None
def test_has_capability(self):
"""PipelineContext.has_capability checks for services."""
ctx = PipelineContext(services={"display.output": MagicMock()})
assert ctx.has_capability("display.output") is True
assert ctx.has_capability("missing") is False
class TestCreateDefaultPipeline:
"""Tests for create_default_pipeline function."""
def setup_method(self):
"""Reset registry before each test."""
StageRegistry._discovered = False
StageRegistry._categories.clear()
StageRegistry._instances.clear()
discover_stages()
def test_create_default_pipeline(self):
"""create_default_pipeline creates a working pipeline."""
pipeline = create_default_pipeline()
assert pipeline is not None
assert "display" in pipeline.stages