Source code for nvalchemi.hooks.reporting.layouts.base

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Base classes and protocols for Rich reporting layouts."""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import Literal, Protocol, TypeAlias

import plotext as plt
from rich import box
from rich.ansi import AnsiDecoder
from rich.console import Console, ConsoleOptions, Group, RenderResult
from rich.layout import Layout
from rich.panel import Panel
from rich.table import Table
from rich.text import Text

from nvalchemi.hooks.reporting._scalars import ScalarSnapshot

RichMetricHistory: TypeAlias = Mapping[str, Sequence[tuple[int, float]]]
RichPreviewHistory: TypeAlias = Mapping[str, Sequence[float]]
RichLayoutName: TypeAlias = Literal["auto", "training", "dynamics"]


[docs] class RichLayout(Protocol): """Layout policy used by :class:`~nvalchemi.hooks.reporting.RichReporter`."""
[docs] def default_preview_history(self) -> RichPreviewHistory: """Return synthetic metric curves for static dashboard previews.""" ...
[docs] def default_preview_stage(self) -> str: """Return the hook stage label used by static dashboard previews.""" ...
[docs] def default_preview_epoch(self) -> int | None: """Return the epoch metadata used by static dashboard previews.""" ...
[docs] def default_preview_batch_count(self) -> int | None: """Return the batch metadata used by static dashboard previews.""" ...
[docs] def render( self, snapshot: ScalarSnapshot | None, history: RichMetricHistory, *, title: str, precision: int, max_scalars: int | None, plot_keys: Sequence[str] | None, max_plots: int, plot_height: int, ) -> Layout: """Build the Rich layout for one reporter snapshot.""" ...
[docs] class BaseRichLayout: """Reusable Rich dashboard layout for scalar tables and plot panels. Attributes ---------- name : str Short layout name displayed in the dashboard header. include_dynamics_scalars : bool Whether :class:`~nvalchemi.hooks.reporting.RichReporter` should collect default dynamics observables when this layout is selected. """ def __init__( self, *, name: str, preferred_plot_keys: Sequence[str], latest_title: str, history_title: str, include_dynamics_scalars: bool = False, ) -> None: self.name = name self._preferred_plot_keys = tuple(preferred_plot_keys) self._latest_title = latest_title self._history_title = history_title self.include_dynamics_scalars = include_dynamics_scalars
[docs] def render( self, snapshot: ScalarSnapshot | None, history: RichMetricHistory, *, title: str, precision: int, max_scalars: int | None, plot_keys: Sequence[str] | None, max_plots: int, plot_height: int, ) -> Layout: """Build the Rich layout for one reporter snapshot. Parameters ---------- snapshot : ScalarSnapshot | None Latest scalar snapshot, or ``None`` before the first report. history : RichMetricHistory Retained scalar history keyed by metric name. title : str Dashboard title. precision : int Significant digits used for scalar values. max_scalars : int | None Maximum number of latest scalar rows. plot_keys : Sequence[str] | None Explicit plot key ordering override. max_plots : int Maximum number of plot panels. plot_height : int Plot height in terminal rows. Returns ------- Layout Renderable Rich layout. """ layout = Layout(name="root") layout.split_column( Layout(name="header", size=3), Layout(name="body"), ) layout["body"].split_row( Layout(name="latest", ratio=2), Layout(name="plots", ratio=3), ) layout["header"].update(self._build_header(snapshot, title)) layout["latest"].update( Panel( self._build_table(snapshot, precision, max_scalars), title=self._latest_title, ) ) layout["plots"].update( Panel( self._build_plots( history, precision=precision, plot_keys=plot_keys, max_plots=max_plots, plot_height=plot_height, ), title=self._history_title, ) ) return layout
[docs] def default_preview_history(self) -> RichPreviewHistory: """Return synthetic metric curves for static dashboard previews.""" raise NotImplementedError
[docs] def default_preview_stage(self) -> str: """Return the hook stage label used by static dashboard previews.""" return "AFTER_OPTIMIZER_STEP"
[docs] def default_preview_epoch(self) -> int | None: """Return the epoch metadata used by static dashboard previews.""" return 3
[docs] def default_preview_batch_count(self) -> int | None: """Return the batch metadata used by static dashboard previews.""" return 128
def _build_header( self, snapshot: ScalarSnapshot | None, title: str, ) -> Panel: if snapshot is None: body = f"{title} | {self.name} | waiting for metrics" else: body = f"{title} | {self.name} | {snapshot.stage}" if snapshot.step_count is not None: body = f"{body} | step {snapshot.step_count}" return Panel(Text(body, overflow="fold"), box=box.SIMPLE) def _build_table( self, snapshot: ScalarSnapshot | None, precision: int, max_scalars: int | None, ) -> Table: table = Table(box=box.SIMPLE_HEAD, show_lines=False, expand=True) table.add_column("Metric", overflow="fold") table.add_column("Latest", justify="right", no_wrap=True) if snapshot is None or not snapshot.scalars: table.add_row("(no scalars)", "") return table items = self._scalar_table_items(snapshot) visible_items = items[:max_scalars] if max_scalars is not None else items for key, value in visible_items: table.add_row(key, self._format_value(value, precision)) if len(visible_items) < len(items): table.add_row("...", f"{len(items) - len(visible_items)} omitted") table.caption = self._caption(snapshot) return table def _scalar_table_items(self, snapshot: ScalarSnapshot) -> list[tuple[str, float]]: preferred = [ (key, snapshot.scalars[key]) for key in self._preferred_plot_keys if key in snapshot.scalars ] seen = {key for key, _ in preferred} preferred.extend( (key, value) for key, value in sorted(snapshot.scalars.items()) if key not in seen ) return preferred def _build_plots( self, history: RichMetricHistory, *, precision: int, plot_keys: Sequence[str] | None, max_plots: int, plot_height: int, ) -> Group | Text: keys = self._selected_plot_keys( history, plot_keys=plot_keys, max_plots=max_plots, ) if not keys: return Text("No scalar history yet.") panels = [ Panel( _PlotextSeries( key=key, series=tuple(history[key]), precision=precision, height=plot_height, ), title=key, box=box.SIMPLE, ) for key in keys ] return Group(*panels) def _selected_plot_keys( self, history: RichMetricHistory, *, plot_keys: Sequence[str] | None, max_plots: int, ) -> tuple[str, ...]: if max_plots == 0: return () available = [key for key, values in history.items() if values] if plot_keys is not None: keys = [key for key in plot_keys if key in available] else: keys = [key for key in self._preferred_plot_keys if key in available] keys.extend(sorted(key for key in available if key not in keys)) return tuple(keys[:max_plots]) def _format_value(self, value: float, precision: int) -> str: return f"{value:.{precision}g}" def _caption(self, snapshot: ScalarSnapshot) -> str: parts = [f"rank={snapshot.global_rank}"] if snapshot.event_count is not None: parts.append(f"event={snapshot.event_count}") if snapshot.epoch is not None: parts.append(f"epoch={snapshot.epoch}") if snapshot.batch_count is not None: parts.append(f"batch={snapshot.batch_count}") return " | ".join(parts) def _build_messages(self, snapshot: ScalarSnapshot | None) -> Table: table = Table.grid(expand=True) table.add_column("Level", no_wrap=True) table.add_column("Message", overflow="fold") if snapshot is None or not snapshot.messages: table.add_row("info", "No reporter messages.") return table for message in snapshot.messages[-3:]: prefix = message.level if message.reporter is not None: prefix = f"{prefix}/{message.reporter}" table.add_row(prefix, message.message) return table def _format_duration(self, seconds: float) -> str: if seconds < 60: return f"{seconds:.1f}s" minutes, remaining_seconds = divmod(int(seconds), 60) if minutes < 60: return f"{minutes}m {remaining_seconds}s" hours, remaining_minutes = divmod(minutes, 60) return f"{hours}h {remaining_minutes}m" def _add_scalar_row( self, table: Table, snapshot: ScalarSnapshot, key: str, label: str, precision: int, *, suffix: str = "", scale: float = 1.0, ) -> None: if key not in snapshot.scalars: return value = snapshot.scalars[key] * scale table.add_row(label, f"{self._format_value(value, precision)}{suffix}")
class _PlotextSeries: def __init__( self, *, key: str, series: Sequence[tuple[int, float]], precision: int, height: int, ) -> None: self.key = key self.series = series self.precision = precision self.height = height self.decoder = AnsiDecoder() def __rich_console__( self, console: Console, options: ConsoleOptions, ) -> RenderResult: width = max(20, options.max_width or console.width) canvas = self._build_canvas(width) yield Group(*self.decoder.decode(canvas)) def _build_canvas(self, width: int) -> str: plt.clf() steps = [step for step, _ in self.series] values = [value for _, value in self.series] plt.plotsize(width, self.height) plt.theme("dark") plt.title(self.key) plt.xlabel("step") if len(values) == 1: plt.scatter(steps, values) else: plt.plot(steps, values) return plt.build()