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