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

@@ -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])