feat: add pipeline introspection demo mode

- Add PipelineIntrospectionSource that renders live ASCII DAG with metrics
- Add PipelineMetricsSensor exposing pipeline performance as sensor values
- Add PipelineIntrospectionDemo controller with 3-phase animation:
  - Phase 1: Toggle effects one at a time (3s each)
  - Phase 2: LFO drives intensity default→max→min→default
  - Phase 3: All effects with shared LFO (infinite loop)
- Add pipeline-inspect preset
- Add get_frame_times() to Pipeline for sparkline data
- Add tests for new components
- Update mise.toml with pipeline-inspect preset task
This commit is contained in:
2026-03-16 16:55:57 -07:00
parent 2a41a90d79
commit f638fb7597
14 changed files with 1186 additions and 55 deletions

View File

@@ -466,6 +466,10 @@ class Pipeline:
self._frame_metrics.clear()
self._current_frame_number = 0
def get_frame_times(self) -> list[float]:
"""Get historical frame times for sparklines/charts."""
return [f.total_ms for f in self._frame_metrics]
class PipelineRunner:
"""High-level pipeline runner with animation support."""

View File

@@ -0,0 +1,300 @@
"""
Pipeline introspection demo controller - 3-phase animation system.
Phase 1: Toggle each effect on/off one at a time (3s each, 1s gap)
Phase 2: LFO drives intensity default → max → min → default for each effect
Phase 3: All effects with shared LFO driving full waveform
This controller manages the animation and updates the pipeline accordingly.
"""
import time
from dataclasses import dataclass
from enum import Enum, auto
from typing import Any
from engine.effects import get_registry
from engine.sensors.oscillator import OscillatorSensor
class DemoPhase(Enum):
"""The three phases of the pipeline introspection demo."""
PHASE_1_TOGGLE = auto() # Toggle each effect on/off
PHASE_2_LFO = auto() # LFO drives intensity up/down
PHASE_3_SHARED_LFO = auto() # All effects with shared LFO
@dataclass
class PhaseState:
"""State for a single phase of the demo."""
phase: DemoPhase
start_time: float
current_effect_index: int = 0
effect_start_time: float = 0.0
lfo_phase: float = 0.0 # 0.0 to 1.0
@dataclass
class DemoConfig:
"""Configuration for the demo animation."""
effect_cycle_duration: float = 3.0 # seconds per effect
gap_duration: float = 1.0 # seconds between effects
lfo_duration: float = (
4.0 # seconds for full LFO cycle (default → max → min → default)
)
phase_2_effect_duration: float = 4.0 # seconds per effect in phase 2
phase_3_lfo_duration: float = 6.0 # seconds for full waveform in phase 3
class PipelineIntrospectionDemo:
"""Controller for the 3-phase pipeline introspection demo.
Manages effect toggling and LFO modulation across the pipeline.
"""
def __init__(
self,
pipeline: Any,
effect_names: list[str] | None = None,
config: DemoConfig | None = None,
):
self._pipeline = pipeline
self._config = config or DemoConfig()
self._effect_names = effect_names or ["noise", "fade", "glitch", "firehose"]
self._phase = DemoPhase.PHASE_1_TOGGLE
self._phase_state = PhaseState(
phase=DemoPhase.PHASE_1_TOGGLE,
start_time=time.time(),
)
self._shared_oscillator: OscillatorSensor | None = None
self._frame = 0
# Register shared oscillator for phase 3
self._shared_oscillator = OscillatorSensor(
name="demo-lfo",
waveform="sine",
frequency=1.0 / self._config.phase_3_lfo_duration,
)
@property
def phase(self) -> DemoPhase:
return self._phase
@property
def phase_display(self) -> str:
"""Get a human-readable phase description."""
phase_num = {
DemoPhase.PHASE_1_TOGGLE: 1,
DemoPhase.PHASE_2_LFO: 2,
DemoPhase.PHASE_3_SHARED_LFO: 3,
}
return f"Phase {phase_num[self._phase]}"
@property
def effect_names(self) -> list[str]:
return self._effect_names
@property
def shared_oscillator(self) -> OscillatorSensor | None:
return self._shared_oscillator
def update(self) -> dict[str, Any]:
"""Update the demo state and return current parameters.
Returns:
dict with current effect settings for the pipeline
"""
self._frame += 1
current_time = time.time()
elapsed = current_time - self._phase_state.start_time
# Phase transition logic
phase_duration = self._get_phase_duration()
if elapsed >= phase_duration:
self._advance_phase()
# Update based on current phase
if self._phase == DemoPhase.PHASE_1_TOGGLE:
return self._update_phase_1(current_time)
elif self._phase == DemoPhase.PHASE_2_LFO:
return self._update_phase_2(current_time)
else:
return self._update_phase_3(current_time)
def _get_phase_duration(self) -> float:
"""Get duration of current phase in seconds."""
if self._phase == DemoPhase.PHASE_1_TOGGLE:
# Duration = (effect_time + gap) * num_effects + final_gap
return (
self._config.effect_cycle_duration + self._config.gap_duration
) * len(self._effect_names) + self._config.gap_duration
elif self._phase == DemoPhase.PHASE_2_LFO:
return self._config.phase_2_effect_duration * len(self._effect_names)
else:
# Phase 3 runs indefinitely
return float("inf")
def _advance_phase(self) -> None:
"""Advance to the next phase."""
if self._phase == DemoPhase.PHASE_1_TOGGLE:
self._phase = DemoPhase.PHASE_2_LFO
elif self._phase == DemoPhase.PHASE_2_LFO:
self._phase = DemoPhase.PHASE_3_SHARED_LFO
# Start the shared oscillator
if self._shared_oscillator:
self._shared_oscillator.start()
else:
# Phase 3 loops indefinitely - reset for demo replay after long time
self._phase = DemoPhase.PHASE_1_TOGGLE
self._phase_state = PhaseState(
phase=self._phase,
start_time=time.time(),
)
def _update_phase_1(self, current_time: float) -> dict[str, Any]:
"""Phase 1: Toggle each effect on/off one at a time."""
effect_time = current_time - self._phase_state.effect_start_time
# Check if we should move to next effect
cycle_time = self._config.effect_cycle_duration + self._config.gap_duration
effect_index = int((current_time - self._phase_state.start_time) / cycle_time)
# Clamp to valid range
if effect_index >= len(self._effect_names):
effect_index = len(self._effect_names) - 1
# Calculate current effect state
in_gap = effect_time >= self._config.effect_cycle_duration
# Build effect states
effect_states: dict[str, dict[str, Any]] = {}
for i, name in enumerate(self._effect_names):
if i < effect_index:
# Past effects - leave at default
effect_states[name] = {"enabled": False, "intensity": 0.5}
elif i == effect_index:
# Current effect - toggle on/off
if in_gap:
effect_states[name] = {"enabled": False, "intensity": 0.5}
else:
effect_states[name] = {"enabled": True, "intensity": 1.0}
else:
# Future effects - off
effect_states[name] = {"enabled": False, "intensity": 0.5}
# Apply to effect registry
self._apply_effect_states(effect_states)
return {
"phase": "PHASE_1_TOGGLE",
"phase_display": self.phase_display,
"current_effect": self._effect_names[effect_index]
if effect_index < len(self._effect_names)
else None,
"effect_states": effect_states,
"frame": self._frame,
}
def _update_phase_2(self, current_time: float) -> dict[str, Any]:
"""Phase 2: LFO drives intensity default → max → min → default."""
elapsed = current_time - self._phase_state.start_time
effect_index = int(elapsed / self._config.phase_2_effect_duration)
effect_index = min(effect_index, len(self._effect_names) - 1)
# Calculate LFO position (0 → 1 → 0)
effect_elapsed = elapsed % self._config.phase_2_effect_duration
lfo_position = effect_elapsed / self._config.phase_2_effect_duration
# LFO: 0 → 1 → 0 (triangle wave)
if lfo_position < 0.5:
lfo_value = lfo_position * 2 # 0 → 1
else:
lfo_value = 2 - lfo_position * 2 # 1 → 0
# Map to intensity: 0.3 (default) → 1.0 (max) → 0.0 (min) → 0.3 (default)
if lfo_position < 0.25:
# 0.3 → 1.0
intensity = 0.3 + (lfo_position / 0.25) * 0.7
elif lfo_position < 0.75:
# 1.0 → 0.0
intensity = 1.0 - ((lfo_position - 0.25) / 0.5) * 1.0
else:
# 0.0 → 0.3
intensity = ((lfo_position - 0.75) / 0.25) * 0.3
# Build effect states
effect_states: dict[str, dict[str, Any]] = {}
for i, name in enumerate(self._effect_names):
if i < effect_index:
# Past effects - default
effect_states[name] = {"enabled": True, "intensity": 0.5}
elif i == effect_index:
# Current effect - LFO modulated
effect_states[name] = {"enabled": True, "intensity": intensity}
else:
# Future effects - off
effect_states[name] = {"enabled": False, "intensity": 0.5}
# Apply to effect registry
self._apply_effect_states(effect_states)
return {
"phase": "PHASE_2_LFO",
"phase_display": self.phase_display,
"current_effect": self._effect_names[effect_index],
"lfo_value": lfo_value,
"intensity": intensity,
"effect_states": effect_states,
"frame": self._frame,
}
def _update_phase_3(self, current_time: float) -> dict[str, Any]:
"""Phase 3: All effects with shared LFO driving full waveform."""
# Read shared oscillator
lfo_value = 0.5 # Default
if self._shared_oscillator:
sensor_val = self._shared_oscillator.read()
if sensor_val:
lfo_value = sensor_val.value
# All effects enabled with shared LFO
effect_states: dict[str, dict[str, Any]] = {}
for name in self._effect_names:
effect_states[name] = {"enabled": True, "intensity": lfo_value}
# Apply to effect registry
self._apply_effect_states(effect_states)
return {
"phase": "PHASE_3_SHARED_LFO",
"phase_display": self.phase_display,
"lfo_value": lfo_value,
"effect_states": effect_states,
"frame": self._frame,
}
def _apply_effect_states(self, effect_states: dict[str, dict[str, Any]]) -> None:
"""Apply effect states to the effect registry."""
try:
registry = get_registry()
for name, state in effect_states.items():
effect = registry.get(name)
if effect:
effect.config.enabled = state["enabled"]
effect.config.intensity = state["intensity"]
except Exception:
pass # Silently fail if registry not available
def cleanup(self) -> None:
"""Clean up resources."""
if self._shared_oscillator:
self._shared_oscillator.stop()
# Reset all effects to default
self._apply_effect_states(
{name: {"enabled": False, "intensity": 0.5} for name in self._effect_names}
)

View File

@@ -89,17 +89,27 @@ def discover_stages() -> None:
try:
from engine.sources_v2 import (
HeadlinesDataSource,
PipelineDataSource,
PoetryDataSource,
)
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
# Register pipeline introspection source
try:
from engine.pipeline_sources.pipeline_introspection import (
PipelineIntrospectionSource,
)
StageRegistry.register("source", PipelineIntrospectionSource)
StageRegistry._categories["source"]["pipeline-inspect"] = (
PipelineIntrospectionSource
)
except ImportError:
pass

View File

@@ -0,0 +1,7 @@
"""
Data source implementations for the pipeline architecture.
"""
from engine.pipeline_sources.pipeline_introspection import PipelineIntrospectionSource
__all__ = ["PipelineIntrospectionSource"]

View File

@@ -0,0 +1,273 @@
"""
Pipeline introspection source - Renders live visualization of pipeline DAG and metrics.
This DataSource introspects one or more Pipeline instances and renders
an ASCII visualization showing:
- Stage DAG with signal flow connections
- Per-stage execution times
- Sparkline of frame times
- Stage breakdown bars
Example:
source = PipelineIntrospectionSource(pipelines=[my_pipeline])
items = source.fetch() # Returns ASCII visualization
"""
from typing import TYPE_CHECKING
from engine.sources_v2 import DataSource, SourceItem
if TYPE_CHECKING:
from engine.pipeline.controller import Pipeline
SPARKLINE_CHARS = " ▁▂▃▄▅▆▇█"
BAR_CHARS = " ▁▂▃▄▅▆▇█"
class PipelineIntrospectionSource(DataSource):
"""Data source that renders live pipeline introspection visualization.
Renders:
- DAG of stages with signal flow
- Per-stage execution times
- Sparkline of frame history
- Stage breakdown bars
"""
def __init__(
self,
pipelines: list["Pipeline"] | None = None,
viewport_width: int = 100,
viewport_height: int = 35,
):
self._pipelines = pipelines or []
self.viewport_width = viewport_width
self.viewport_height = viewport_height
self.frame = 0
@property
def name(self) -> str:
return "pipeline-inspect"
@property
def is_dynamic(self) -> bool:
return True
@property
def inlet_types(self) -> set:
from engine.pipeline.core import DataType
return {DataType.NONE}
@property
def outlet_types(self) -> set:
from engine.pipeline.core import DataType
return {DataType.SOURCE_ITEMS}
def add_pipeline(self, pipeline: "Pipeline") -> None:
"""Add a pipeline to visualize."""
if pipeline not in self._pipelines:
self._pipelines.append(pipeline)
def remove_pipeline(self, pipeline: "Pipeline") -> None:
"""Remove a pipeline from visualization."""
if pipeline in self._pipelines:
self._pipelines.remove(pipeline)
def fetch(self) -> list[SourceItem]:
"""Fetch the introspection visualization."""
lines = self._render()
self.frame += 1
content = "\n".join(lines)
return [
SourceItem(
content=content, source="pipeline-inspect", timestamp=f"f{self.frame}"
)
]
def get_items(self) -> list[SourceItem]:
return self.fetch()
def _render(self) -> list[str]:
"""Render the full visualization."""
lines: list[str] = []
# Header
lines.extend(self._render_header())
if not self._pipelines:
lines.append(" No pipelines to visualize")
return lines
# Render each pipeline's DAG
for i, pipeline in enumerate(self._pipelines):
if len(self._pipelines) > 1:
lines.append(f" Pipeline {i + 1}:")
lines.extend(self._render_pipeline(pipeline))
# Footer with sparkline
lines.extend(self._render_footer())
return lines
def _render_header(self) -> list[str]:
"""Render the header with frame info and metrics summary."""
lines: list[str] = []
if not self._pipelines:
return ["┌─ PIPELINE INTROSPECTION ──────────────────────────────┐"]
# Get aggregate metrics
total_ms = 0.0
fps = 0.0
frame_count = 0
for pipeline in self._pipelines:
try:
metrics = pipeline.get_metrics_summary()
if metrics and "error" not in metrics:
total_ms = max(total_ms, metrics.get("avg_ms", 0))
fps = max(fps, metrics.get("fps", 0))
frame_count = max(frame_count, metrics.get("frame_count", 0))
except Exception:
pass
header = f"┌─ PIPELINE INTROSPECTION ── frame: {self.frame} ─ avg: {total_ms:.1f}ms ─ fps: {fps:.1f} ─┐"
lines.append(header)
return lines
def _render_pipeline(self, pipeline: "Pipeline") -> list[str]:
"""Render a single pipeline's DAG."""
lines: list[str] = []
stages = pipeline.stages
execution_order = pipeline.execution_order
if not stages:
lines.append(" (no stages)")
return lines
# Build stage info
stage_infos: list[dict] = []
for name in execution_order:
stage = stages.get(name)
if not stage:
continue
try:
metrics = pipeline.get_metrics_summary()
stage_ms = metrics.get("stages", {}).get(name, {}).get("avg_ms", 0.0)
except Exception:
stage_ms = 0.0
stage_infos.append(
{
"name": name,
"category": stage.category,
"ms": stage_ms,
}
)
# Calculate total time for percentages
total_time = sum(s["ms"] for s in stage_infos) or 1.0
# Render DAG - group by category
lines.append("")
lines.append("│ Signal Flow:")
# Group stages by category for display
categories: dict[str, list[dict]] = {}
for info in stage_infos:
cat = info["category"]
if cat not in categories:
categories[cat] = []
categories[cat].append(info)
# Render categories in order
cat_order = ["source", "render", "effect", "overlay", "display", "system"]
for cat in cat_order:
if cat not in categories:
continue
cat_stages = categories[cat]
cat_names = [s["name"] for s in cat_stages]
lines.append(f"{cat}: {''.join(cat_names)}")
# Render timing breakdown
lines.append("")
lines.append("│ Stage Timings:")
for info in stage_infos:
name = info["name"]
ms = info["ms"]
pct = (ms / total_time) * 100
bar = self._render_bar(pct, 20)
lines.append(f"{name:12s} {ms:6.2f}ms {bar} {pct:5.1f}%")
lines.append("")
return lines
def _render_footer(self) -> list[str]:
"""Render the footer with sparkline."""
lines: list[str] = []
# Get frame history from first pipeline
if self._pipelines:
try:
frame_times = self._pipelines[0].get_frame_times()
except Exception:
frame_times = []
else:
frame_times = []
if frame_times:
sparkline = self._render_sparkline(frame_times[-60:], 50)
lines.append(
f"├─ Frame Time History (last {len(frame_times[-60:])} frames) ─────────────────────────────┤"
)
lines.append(f"{sparkline}")
else:
lines.append(
"├─ Frame Time History ─────────────────────────────────────────┤"
)
lines.append(
"│ (collecting data...) │"
)
lines.append(
"└────────────────────────────────────────────────────────────────┘"
)
return lines
def _render_bar(self, percentage: float, width: int) -> str:
"""Render a horizontal bar for percentage."""
filled = int((percentage / 100.0) * width)
bar = "" * filled + "" * (width - filled)
return bar
def _render_sparkline(self, values: list[float], width: int) -> str:
"""Render a sparkline from values."""
if not values:
return " " * width
min_val = min(values)
max_val = max(values)
range_val = max_val - min_val or 1.0
result = []
for v in values[-width:]:
normalized = (v - min_val) / range_val
idx = int(normalized * (len(SPARKLINE_CHARS) - 1))
idx = max(0, min(idx, len(SPARKLINE_CHARS) - 1))
result.append(SPARKLINE_CHARS[idx])
# Pad to width
while len(result) < width:
result.insert(0, " ")
return "".join(result[:width])

View File

@@ -0,0 +1,114 @@
"""
Pipeline metrics sensor - Exposes pipeline performance data as sensor values.
This sensor reads metrics from a Pipeline instance and provides them
as sensor values that can drive effect parameters.
Example:
sensor = PipelineMetricsSensor(pipeline)
sensor.read() # Returns SensorValue with total_ms, fps, etc.
"""
from typing import TYPE_CHECKING
from engine.sensors import Sensor, SensorValue
if TYPE_CHECKING:
from engine.pipeline.controller import Pipeline
class PipelineMetricsSensor(Sensor):
"""Sensor that reads metrics from a Pipeline instance.
Provides real-time performance data:
- total_ms: Total frame time in milliseconds
- fps: Calculated frames per second
- stage_timings: Dict of stage name -> duration_ms
Can be bound to effect parameters for reactive visuals.
"""
def __init__(self, pipeline: "Pipeline | None" = None, name: str = "pipeline"):
self._pipeline = pipeline
self.name = name
self.unit = "ms"
self._last_values: dict[str, float] = {
"total_ms": 0.0,
"fps": 0.0,
"avg_ms": 0.0,
"min_ms": 0.0,
"max_ms": 0.0,
}
@property
def available(self) -> bool:
return self._pipeline is not None
def set_pipeline(self, pipeline: "Pipeline") -> None:
"""Set or update the pipeline to read metrics from."""
self._pipeline = pipeline
def read(self) -> SensorValue | None:
"""Read current metrics from the pipeline."""
if not self._pipeline:
return None
try:
metrics = self._pipeline.get_metrics_summary()
except Exception:
return None
if not metrics or "error" in metrics:
return None
self._last_values["total_ms"] = metrics.get("total_ms", 0.0)
self._last_values["fps"] = metrics.get("fps", 0.0)
self._last_values["avg_ms"] = metrics.get("avg_ms", 0.0)
self._last_values["min_ms"] = metrics.get("min_ms", 0.0)
self._last_values["max_ms"] = metrics.get("max_ms", 0.0)
# Provide total_ms as primary value (for LFO-style effects)
return SensorValue(
sensor_name=self.name,
value=self._last_values["total_ms"],
timestamp=0.0,
unit=self.unit,
)
def get_stage_timing(self, stage_name: str) -> float:
"""Get timing for a specific stage."""
if not self._pipeline:
return 0.0
try:
metrics = self._pipeline.get_metrics_summary()
stages = metrics.get("stages", {})
return stages.get(stage_name, {}).get("avg_ms", 0.0)
except Exception:
return 0.0
def get_all_timings(self) -> dict[str, float]:
"""Get all stage timings as a dict."""
if not self._pipeline:
return {}
try:
metrics = self._pipeline.get_metrics_summary()
return metrics.get("stages", {})
except Exception:
return {}
def get_frame_history(self) -> list[float]:
"""Get historical frame times for sparklines."""
if not self._pipeline:
return []
try:
return self._pipeline.get_frame_times()
except Exception:
return []
def start(self) -> bool:
"""Start the sensor (no-op for read-only metrics)."""
return True
def stop(self) -> None:
"""Stop the sensor (no-op for read-only metrics)."""
pass

View File

@@ -94,38 +94,6 @@ class PoetryDataSource(DataSource):
return [SourceItem(content=t, source=s, timestamp=ts) for t, s, ts in items]
class PipelineDataSource(DataSource):
"""Data source for pipeline visualization (demo mode). Dynamic - updates every frame."""
def __init__(self, viewport_width: int = 80, viewport_height: int = 24):
self.viewport_width = viewport_width
self.viewport_height = viewport_height
self.frame = 0
@property
def name(self) -> str:
return "pipeline"
@property
def is_dynamic(self) -> bool:
return True
def fetch(self) -> list[SourceItem]:
from engine.pipeline_viz import generate_large_network_viewport
buffer = generate_large_network_viewport(
self.viewport_width, self.viewport_height, self.frame
)
self.frame += 1
content = "\n".join(buffer)
return [
SourceItem(content=content, source="pipeline", timestamp=f"f{self.frame}")
]
def get_items(self) -> list[SourceItem]:
return self.fetch()
class MetricsDataSource(DataSource):
"""Data source that renders live pipeline metrics as ASCII art.
@@ -340,9 +308,6 @@ class SourceRegistry:
def create_poetry(self) -> PoetryDataSource:
return PoetryDataSource()
def create_pipeline(self, width: int = 80, height: int = 24) -> PipelineDataSource:
return PipelineDataSource(width, height)
_global_registry: SourceRegistry | None = None