forked from genewildish/Mainline
- Implements pipeline hot-rebuild with state preservation (issue #43) - Adds auto-injection of MVP stages for missing capabilities - Adds radial camera mode for polar coordinate scanning - Adds afterimage and motionblur effects using framebuffer history - Adds comprehensive acceptance tests for camera modes and pipeline rebuild - Updates presets.toml with new effect configurations Related to: #35 (Pipeline Mutation API epic) Closes: #43, #44, #45
222 lines
6.8 KiB
Python
222 lines
6.8 KiB
Python
"""
|
|
Pipeline validation and MVP (Minimum Viable Pipeline) injection.
|
|
|
|
Provides validation functions to ensure pipelines meet minimum requirements
|
|
and can auto-inject sensible defaults when fields are missing or invalid.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from engine.display import BorderMode, DisplayRegistry
|
|
from engine.effects import get_registry
|
|
from engine.pipeline.params import PipelineParams
|
|
|
|
# Known valid values
|
|
VALID_SOURCES = ["headlines", "poetry", "fixture", "empty", "pipeline-inspect"]
|
|
VALID_CAMERAS = [
|
|
"feed",
|
|
"scroll",
|
|
"vertical",
|
|
"horizontal",
|
|
"omni",
|
|
"floating",
|
|
"bounce",
|
|
"radial",
|
|
"static",
|
|
"none",
|
|
"",
|
|
]
|
|
VALID_DISPLAYS = None # Will be populated at runtime from DisplayRegistry
|
|
|
|
|
|
@dataclass
|
|
class ValidationResult:
|
|
"""Result of validation with changes and warnings."""
|
|
|
|
valid: bool
|
|
warnings: list[str]
|
|
changes: list[str]
|
|
config: Any # PipelineConfig (forward ref)
|
|
params: PipelineParams
|
|
|
|
|
|
# MVP defaults
|
|
MVP_DEFAULTS = {
|
|
"source": "fixture",
|
|
"display": "terminal",
|
|
"camera": "static", # Static camera provides camera_y=0 for viewport filtering
|
|
"effects": [],
|
|
"border": False,
|
|
}
|
|
|
|
|
|
def validate_pipeline_config(
|
|
config: Any, params: PipelineParams, allow_unsafe: bool = False
|
|
) -> ValidationResult:
|
|
"""Validate pipeline configuration against MVP requirements.
|
|
|
|
Args:
|
|
config: PipelineConfig object (has source, display, camera, effects fields)
|
|
params: PipelineParams object (has border field)
|
|
allow_unsafe: If True, don't inject defaults or enforce MVP
|
|
|
|
Returns:
|
|
ValidationResult with validity, warnings, changes, and validated config/params
|
|
"""
|
|
warnings = []
|
|
changes = []
|
|
|
|
if allow_unsafe:
|
|
# Still do basic validation but don't inject defaults
|
|
# Always return valid=True when allow_unsafe is set
|
|
warnings.extend(_validate_source(config.source))
|
|
warnings.extend(_validate_display(config.display))
|
|
warnings.extend(_validate_camera(config.camera))
|
|
warnings.extend(_validate_effects(config.effects))
|
|
warnings.extend(_validate_border(params.border))
|
|
return ValidationResult(
|
|
valid=True, # Always valid with allow_unsafe
|
|
warnings=warnings,
|
|
changes=[],
|
|
config=config,
|
|
params=params,
|
|
)
|
|
|
|
# MVP injection mode
|
|
# Source
|
|
source_issues = _validate_source(config.source)
|
|
if source_issues:
|
|
warnings.extend(source_issues)
|
|
config.source = MVP_DEFAULTS["source"]
|
|
changes.append(f"source → {MVP_DEFAULTS['source']}")
|
|
|
|
# Display
|
|
display_issues = _validate_display(config.display)
|
|
if display_issues:
|
|
warnings.extend(display_issues)
|
|
config.display = MVP_DEFAULTS["display"]
|
|
changes.append(f"display → {MVP_DEFAULTS['display']}")
|
|
|
|
# Camera
|
|
camera_issues = _validate_camera(config.camera)
|
|
if camera_issues:
|
|
warnings.extend(camera_issues)
|
|
config.camera = MVP_DEFAULTS["camera"]
|
|
changes.append("camera → static (no camera stage)")
|
|
|
|
# Effects
|
|
effect_issues = _validate_effects(config.effects)
|
|
if effect_issues:
|
|
warnings.extend(effect_issues)
|
|
# Only change if all effects are invalid
|
|
if len(config.effects) == 0 or all(
|
|
e not in _get_valid_effects() for e in config.effects
|
|
):
|
|
config.effects = MVP_DEFAULTS["effects"]
|
|
changes.append("effects → [] (none)")
|
|
else:
|
|
# Remove invalid effects, keep valid ones
|
|
valid_effects = [e for e in config.effects if e in _get_valid_effects()]
|
|
if valid_effects != config.effects:
|
|
config.effects = valid_effects
|
|
changes.append(f"effects → {valid_effects}")
|
|
|
|
# Border (in params)
|
|
border_issues = _validate_border(params.border)
|
|
if border_issues:
|
|
warnings.extend(border_issues)
|
|
params.border = MVP_DEFAULTS["border"]
|
|
changes.append(f"border → {MVP_DEFAULTS['border']}")
|
|
|
|
valid = len(warnings) == 0
|
|
if changes:
|
|
# If we made changes, pipeline should be valid now
|
|
valid = True
|
|
|
|
return ValidationResult(
|
|
valid=valid,
|
|
warnings=warnings,
|
|
changes=changes,
|
|
config=config,
|
|
params=params,
|
|
)
|
|
|
|
|
|
def _validate_source(source: str) -> list[str]:
|
|
"""Validate source field."""
|
|
if not source:
|
|
return ["source is empty"]
|
|
if source not in VALID_SOURCES:
|
|
return [f"unknown source '{source}', valid sources: {VALID_SOURCES}"]
|
|
return []
|
|
|
|
|
|
def _validate_display(display: str) -> list[str]:
|
|
"""Validate display field."""
|
|
if not display:
|
|
return ["display is empty"]
|
|
# Check if display is available (lazy load registry)
|
|
try:
|
|
available = DisplayRegistry.list_backends()
|
|
if display not in available:
|
|
return [f"display '{display}' not available, available: {available}"]
|
|
except Exception as e:
|
|
return [f"error checking display availability: {e}"]
|
|
return []
|
|
|
|
|
|
def _validate_camera(camera: str | None) -> list[str]:
|
|
"""Validate camera field."""
|
|
if camera is None:
|
|
return ["camera is None"]
|
|
# Empty string is valid (static, no camera stage)
|
|
if camera == "":
|
|
return []
|
|
if camera not in VALID_CAMERAS:
|
|
return [f"unknown camera '{camera}', valid cameras: {VALID_CAMERAS}"]
|
|
return []
|
|
|
|
|
|
def _get_valid_effects() -> set[str]:
|
|
"""Get set of valid effect names."""
|
|
registry = get_registry()
|
|
return set(registry.list_all().keys())
|
|
|
|
|
|
def _validate_effects(effects: list[str]) -> list[str]:
|
|
"""Validate effects list."""
|
|
if effects is None:
|
|
return ["effects is None"]
|
|
valid_effects = _get_valid_effects()
|
|
issues = []
|
|
for effect in effects:
|
|
if effect not in valid_effects:
|
|
issues.append(
|
|
f"unknown effect '{effect}', valid effects: {sorted(valid_effects)}"
|
|
)
|
|
return issues
|
|
|
|
|
|
def _validate_border(border: bool | BorderMode) -> list[str]:
|
|
"""Validate border field."""
|
|
if isinstance(border, bool):
|
|
return []
|
|
if isinstance(border, BorderMode):
|
|
return []
|
|
return [f"invalid border value, must be bool or BorderMode, got {type(border)}"]
|
|
|
|
|
|
def get_mvp_summary(config: Any, params: PipelineParams) -> str:
|
|
"""Get a human-readable summary of the MVP pipeline configuration."""
|
|
camera_text = "none" if not config.camera else config.camera
|
|
effects_text = "none" if not config.effects else ", ".join(config.effects)
|
|
return (
|
|
f"MVP Pipeline Configuration:\n"
|
|
f" Source: {config.source}\n"
|
|
f" Display: {config.display}\n"
|
|
f" Camera: {camera_text} (static if empty)\n"
|
|
f" Effects: {effects_text}\n"
|
|
f" Border: {params.border}"
|
|
)
|