""" 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.data_sources.sources 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, pipeline: "Pipeline | None" = None, viewport_width: int = 100, viewport_height: int = 35, ): self._pipeline = pipeline # May be None initially, set later via set_pipeline() self.viewport_width = viewport_width self.viewport_height = viewport_height self.frame = 0 self._ready = False def set_pipeline(self, pipeline: "Pipeline") -> None: """Set the pipeline to introspect (call after pipeline is built).""" self._pipeline = [pipeline] # Wrap in list for iteration self._ready = True @property def ready(self) -> bool: """Check if source is ready to fetch.""" return self._ready @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 import DataType return {DataType.NONE} @property def outlet_types(self) -> set: from engine.pipeline import DataType return {DataType.SOURCE_ITEMS} def add_pipeline(self, pipeline: "Pipeline") -> None: """Add a pipeline to visualize.""" if self._pipeline is None: self._pipeline = [pipeline] elif isinstance(self._pipeline, list): self._pipeline.append(pipeline) else: self._pipeline = [self._pipeline, pipeline] self._ready = True def remove_pipeline(self, pipeline: "Pipeline") -> None: """Remove a pipeline from visualization.""" if self._pipeline is None: return elif isinstance(self._pipeline, list): self._pipeline = [p for p in self._pipeline if p is not pipeline] if not self._pipeline: self._pipeline = None self._ready = False elif self._pipeline is pipeline: self._pipeline = None self._ready = False def fetch(self) -> list[SourceItem]: """Fetch the introspection visualization.""" if not self._ready: # Return a placeholder until ready return [ SourceItem( content="Initializing...", source="pipeline-inspect", timestamp="init", ) ] 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()) # Render pipeline(s) if ready if self._ready and self._pipeline: pipelines = ( self._pipeline if isinstance(self._pipeline, list) else [self._pipeline] ) for pipeline in pipelines: lines.extend(self._render_pipeline(pipeline)) # Footer with sparkline lines.extend(self._render_footer()) return lines @property def _pipelines(self) -> list: """Return pipelines as a list for iteration.""" if self._pipeline is None: return [] elif isinstance(self._pipeline, list): return self._pipeline else: return [self._pipeline] def _render_header(self) -> list[str]: """Render the header with frame info and metrics summary.""" lines: list[str] = [] if not self._pipeline: 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: # Get avg_ms from pipeline metrics pipeline_avg = metrics.get("pipeline", {}).get("avg_ms", 0) total_ms = max(total_ms, pipeline_avg) # Calculate FPS from avg_ms if pipeline_avg > 0: fps = max(fps, 1000.0 / pipeline_avg) 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 pipelines = self._pipelines if pipelines: try: frame_times = 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])