refactor: remove legacy demo code, integrate metrics via pipeline context

- Remove ~700 lines of legacy code from app.py (run_demo_mode, run_pipeline_demo,
  run_preset_mode, font picker, effects picker)
- HUD now reads metrics from pipeline context (first-class citizen) with fallback
  to global monitor for backwards compatibility
- Add validate_signal_flow() for PureData-style type validation in presets
- Update MicSensor documentation (self-contained, doesn't use MicMonitor)
- Delete test_app.py (was testing removed legacy code)
- Update AGENTS.md with pipeline architecture documentation
This commit is contained in:
2026-03-16 15:41:10 -07:00
parent b27ddbccb8
commit f43920e2f0
6 changed files with 165 additions and 1047 deletions

View File

@@ -161,7 +161,7 @@ The project uses pytest with strict marker enforcement. Test configuration is in
### Test Coverage Strategy ### Test Coverage Strategy
Current coverage: 56% (336 tests) Current coverage: 56% (433 tests)
Key areas with lower coverage (acceptable for now): Key areas with lower coverage (acceptable for now):
- **app.py** (8%): Main entry point - integration heavy, requires terminal - **app.py** (8%): Main entry point - integration heavy, requires terminal
@@ -192,6 +192,47 @@ Performance regression tests are in `tests/test_benchmark.py` with `@pytest.mark
- **effects/** - plugin architecture with performance monitoring - **effects/** - plugin architecture with performance monitoring
- The render pipeline: fetch → render → effects → scroll → terminal output - The render pipeline: fetch → render → effects → scroll → terminal output
### Pipeline Architecture
The new Stage-based pipeline architecture provides capability-based dependency resolution:
- **Stage** (`engine/pipeline/core.py`): Base class for pipeline stages
- **Pipeline** (`engine/pipeline/controller.py`): Executes stages with capability-based dependency resolution
- **StageRegistry** (`engine/pipeline/registry.py`): Discovers and registers stages
- **Stage Adapters** (`engine/pipeline/adapters.py`): Wraps existing components as stages
#### Capability-Based Dependencies
Stages declare capabilities (what they provide) and dependencies (what they need). The Pipeline resolves dependencies using prefix matching:
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
- This allows flexible composition without hardcoding specific stage names
#### Sensor Framework
- **Sensor** (`engine/sensors/__init__.py`): Base class for real-time input sensors
- **SensorRegistry**: Discovers available sensors
- **SensorStage**: Pipeline adapter that provides sensor values to effects
- **MicSensor** (`engine/sensors/mic.py`): Self-contained microphone input
- **OscillatorSensor** (`engine/sensors/oscillator.py`): Test sensor for development
Sensors support param bindings to drive effect parameters in real-time.
### Preset System
Presets use TOML format (no external dependencies):
- Built-in: `engine/presets.toml`
- User config: `~/.config/mainline/presets.toml`
- Local override: `./presets.toml`
- **Preset loader** (`engine/pipeline/preset_loader.py`): Loads and validates presets
- **PipelinePreset** (`engine/pipeline/presets.py`): Dataclass for preset configuration
Functions:
- `validate_preset()` - Validate preset structure
- `validate_signal_path()` - Detect circular dependencies
- `generate_preset_toml()` - Generate skeleton preset
### Display System ### Display System
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol - **Display abstraction** (`engine/display/`): swap display backends via the Display protocol

View File

@@ -1,4 +1,3 @@
from engine.effects.performance import get_monitor
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
@@ -8,15 +7,34 @@ class HudEffect(EffectPlugin):
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
result = list(buf) result = list(buf)
monitor = get_monitor()
# Read metrics from pipeline context (first-class citizen)
# Falls back to global monitor for backwards compatibility
metrics = ctx.get_state("metrics")
if not metrics:
# Fallback to global monitor for backwards compatibility
from engine.effects.performance import get_monitor
monitor = get_monitor()
if monitor:
stats = monitor.get_stats()
if stats and "pipeline" in stats:
metrics = stats
fps = 0.0 fps = 0.0
frame_time = 0.0 frame_time = 0.0
if monitor: if metrics:
stats = monitor.get_stats() if "error" in metrics:
if stats and "pipeline" in stats: pass # No metrics available yet
frame_time = stats["pipeline"].get("avg_ms", 0.0) elif "pipeline" in metrics:
frame_count = stats.get("frame_count", 0) frame_time = metrics["pipeline"].get("avg_ms", 0.0)
frame_count = metrics.get("frame_count", 0)
if frame_count > 0 and frame_time > 0:
fps = 1000.0 / frame_time
elif "avg_ms" in metrics:
# Direct metrics format
frame_time = metrics.get("avg_ms", 0.0)
frame_count = metrics.get("frame_count", 0)
if frame_count > 0 and frame_time > 0: if frame_count > 0 and frame_time > 0:
fps = 1000.0 / frame_time fps = 1000.0 / frame_time
@@ -44,11 +62,17 @@ class HudEffect(EffectPlugin):
f"\033[2;1H\033[38;5;45mEFFECT:\033[0m \033[1;38;5;227m{effect_name:12s}\033[0m \033[38;5;245m|\033[0m {bar} \033[38;5;245m|\033[0m \033[38;5;219m{effect_intensity * 100:.0f}%\033[0m" f"\033[2;1H\033[38;5;45mEFFECT:\033[0m \033[1;38;5;227m{effect_name:12s}\033[0m \033[38;5;245m|\033[0m {bar} \033[38;5;245m|\033[0m \033[38;5;219m{effect_intensity * 100:.0f}%\033[0m"
) )
from engine.effects import get_effect_chain # Try to get pipeline order from context
pipeline_order = ctx.get_state("pipeline_order")
if pipeline_order:
pipeline_str = ",".join(pipeline_order)
else:
# Fallback to legacy effect chain
from engine.effects import get_effect_chain
chain = get_effect_chain() chain = get_effect_chain()
order = chain.get_order() order = chain.get_order() if chain else []
pipeline_str = ",".join(order) if order else "(none)" pipeline_str = ",".join(order) if order else "(none)"
hud_lines.append(f"\033[3;1H\033[38;5;44mPIPELINE:\033[0m {pipeline_str}") hud_lines.append(f"\033[3;1H\033[38;5;44mPIPELINE:\033[0m {pipeline_str}")
for i, line in enumerate(hud_lines): for i, line in enumerate(hud_lines):

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ Gracefully degrades if sounddevice/numpy are unavailable.
.. deprecated:: .. deprecated::
For pipeline integration, use :class:`engine.sensors.mic.MicSensor` instead. For pipeline integration, use :class:`engine.sensors.mic.MicSensor` instead.
MicMonitor is still used as the backend for MicSensor. MicSensor is a self-contained implementation and does not use MicMonitor.
""" """
import atexit import atexit

View File

@@ -152,6 +152,64 @@ def validate_preset(preset: dict[str, Any]) -> list[str]:
return errors return errors
def validate_signal_flow(stages: list[dict]) -> list[str]:
"""Validate signal flow based on inlet/outlet types.
This validates that the preset's stage configuration produces valid
data flow using the PureData-style type system.
Args:
stages: List of stage configs with 'name', 'category', 'inlet_types', 'outlet_types'
Returns:
List of errors (empty if valid)
"""
errors: list[str] = []
if not stages:
errors.append("Signal flow is empty")
return errors
# Define expected types for each category
type_map = {
"source": {"inlet": "NONE", "outlet": "SOURCE_ITEMS"},
"data": {"inlet": "ANY", "outlet": "SOURCE_ITEMS"},
"transform": {"inlet": "SOURCE_ITEMS", "outlet": "TEXT_BUFFER"},
"effect": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
"overlay": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
"camera": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
"display": {"inlet": "TEXT_BUFFER", "outlet": "NONE"},
"render": {"inlet": "SOURCE_ITEMS", "outlet": "TEXT_BUFFER"},
}
# Check stage order and type compatibility
for i, stage in enumerate(stages):
category = stage.get("category", "unknown")
name = stage.get("name", f"stage_{i}")
if category not in type_map:
continue # Skip unknown categories
expected = type_map[category]
# Check against previous stage
if i > 0:
prev = stages[i - 1]
prev_category = prev.get("category", "unknown")
if prev_category in type_map:
prev_outlet = type_map[prev_category]["outlet"]
inlet = expected["inlet"]
# Validate type compatibility
if inlet != "ANY" and prev_outlet != "ANY" and inlet != prev_outlet:
errors.append(
f"Type mismatch at '{name}': "
f"expects {inlet} but previous stage outputs {prev_outlet}"
)
return errors
def validate_signal_path(stages: list[str]) -> list[str]: def validate_signal_path(stages: list[str]) -> list[str]:
"""Validate signal path for circular dependencies and connectivity. """Validate signal path for circular dependencies and connectivity.

View File

@@ -1,55 +0,0 @@
"""
Tests for engine.app module.
"""
from engine.app import _normalize_preview_rows
class TestNormalizePreviewRows:
"""Tests for _normalize_preview_rows function."""
def test_empty_rows(self):
"""Empty input returns empty list."""
result = _normalize_preview_rows([])
assert result == [""]
def test_strips_left_padding(self):
"""Left padding is stripped."""
result = _normalize_preview_rows([" content", " more"])
assert all(not r.startswith(" ") for r in result)
def test_preserves_content(self):
"""Content is preserved."""
result = _normalize_preview_rows([" hello world "])
assert "hello world" in result[0]
def test_handles_all_empty_rows(self):
"""All empty rows returns single empty string."""
result = _normalize_preview_rows(["", " ", ""])
assert result == [""]
class TestAppConstants:
"""Tests for app module constants."""
def test_title_defined(self):
"""TITLE is defined."""
from engine.app import TITLE
assert len(TITLE) > 0
def test_title_lines_are_strings(self):
"""TITLE contains string lines."""
from engine.app import TITLE
assert all(isinstance(line, str) for line in TITLE)
class TestAppImports:
"""Tests for app module imports."""
def test_app_imports_without_error(self):
"""Module imports without error."""
from engine import app
assert app is not None