Source code for nvalchemi.hooks.reporting._rich

# 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.
"""Rich live dashboard reporting sink."""

from __future__ import annotations

from collections import deque
from collections.abc import Mapping, Sequence
from enum import Enum
from types import TracebackType

from rich.console import Console
from rich.layout import Layout
from rich.live import Live
from torch import distributed as dist

from nvalchemi.hooks._context import DynamicsContext, HookContext, TrainContext
from nvalchemi.hooks.reporting._distributed import (
    normalize_rank_reduction,
    reduce_scalar_snapshot,
)
from nvalchemi.hooks.reporting._scalars import (
    ScalarCallback,
    ScalarSnapshot,
    collect_scalars,
)
from nvalchemi.hooks.reporting._state import ReportingState
from nvalchemi.hooks.reporting.layouts import (
    DynamicsRichLayout,
    RichLayout,
    TrainingRichLayout,
    resolve_rich_layout,
)

_PREVIEW_DEFAULT = object()


[docs] class RichReporter: """Render scalar reporting snapshots as a live Rich dashboard. Parameters ---------- custom_scalars : Mapping[str, ScalarCallback] | None, optional Additional scalar callbacks passed to :func:`collect_scalars`. include_losses : bool, default True When ``True``, include loss scalars from the hook context. include_optimizer_lrs : bool, default True When ``True``, include optimizer learning rates from the hook context. include_dynamics_scalars : bool | None, optional When ``True``, include default dynamics observables from the hook context. ``None`` lets the selected layout choose; the built-in dynamics layout enables them. rank_reduction : torch.distributed.ReduceOp | {"none", "mean", "sum", "min", "max"} | None, default None Optional distributed reduction applied to scalars before rendering. String values are normalized to :class:`torch.distributed.ReduceOp`. Reduction requires every rank to call this reporter; only rank zero renders the reduced dashboard. title : str, default "nvalchemi report" Dashboard title. precision : int, default 6 Significant digits used when formatting scalar values. max_scalars : int | None, optional Maximum number of scalar rows to show. When omitted, all scalars are shown. history_size : int, default 200 Maximum history points retained per scalar. layout : RichLayout | {"training", "dynamics"} | None, optional Dashboard layout policy. ``None`` and ``"auto"`` select the first built-in layout that supports the first reported context. plot_keys : Sequence[str] | None, optional Scalar keys to plot. When omitted, the selected layout chooses common metrics for that workflow before falling back to alphabetical order. max_plots : int, default 3 Maximum number of history plots shown in the dashboard. plot_height : int, default 8 Height in terminal rows for each plotext plot. refresh_per_second : float, default 2.0 Rich ``Live`` refresh rate used while the reporter is entered. console : Console | None, optional Rich console used for output. When omitted, a stderr console is created. screen : bool, default False Whether Rich ``Live`` should use the terminal alternate screen. transient : bool, default False Whether Rich ``Live`` should clear the dashboard on exit. rank_zero_only : bool, default True Request rank-zero-only dispatch from :class:`ReportingOrchestrator`. strict_layout : bool, default False When ``True``, raise if automatic layout selection cannot match the incoming context. When ``False``, unmatched contexts are ignored. """ def __init__( self, *, custom_scalars: Mapping[str, ScalarCallback] | None = None, include_losses: bool = True, include_optimizer_lrs: bool = True, include_dynamics_scalars: bool | None = None, rank_reduction: dist.ReduceOp | str | None = None, title: str = "nvalchemi report", precision: int = 6, max_scalars: int | None = None, history_size: int = 200, layout: RichLayout | str | None = None, plot_keys: Sequence[str] | None = None, max_plots: int = 3, plot_height: int = 8, refresh_per_second: float = 2.0, console: Console | None = None, screen: bool = False, transient: bool = False, rank_zero_only: bool = True, strict_layout: bool = False, ) -> None: if precision < 0: raise ValueError("RichReporter precision must be non-negative.") if max_scalars is not None and max_scalars < 1: raise ValueError("RichReporter max_scalars must be positive.") if history_size < 1: raise ValueError("RichReporter history_size must be positive.") if max_plots < 0: raise ValueError("RichReporter max_plots must be non-negative.") if plot_height < 4: raise ValueError("RichReporter plot_height must be at least 4.") if refresh_per_second <= 0: raise ValueError("RichReporter refresh_per_second must be positive.") self.custom_scalars = custom_scalars self.include_losses = include_losses self.include_optimizer_lrs = include_optimizer_lrs self.rank_reduction = rank_reduction self._rank_reduction_op, _ = normalize_rank_reduction(rank_reduction) self.title = title self.precision = precision self.max_scalars = max_scalars self.history_size = history_size self._auto_layout = layout is None or layout == "auto" self._layout_selected = not self._auto_layout self.layout = ( TrainingRichLayout() if self._auto_layout else resolve_rich_layout(layout) ) self._include_dynamics_scalars_override = include_dynamics_scalars self.include_dynamics_scalars = ( bool(getattr(self.layout, "include_dynamics_scalars", False)) if include_dynamics_scalars is None else include_dynamics_scalars ) self.plot_keys = tuple(plot_keys) if plot_keys is not None else None self.max_plots = max_plots self.plot_height = plot_height self.refresh_per_second = refresh_per_second self.console = console if console is not None else Console(stderr=True) self.screen = screen self.transient = transient self.strict_layout = strict_layout self._write_rank_zero_only = ( rank_zero_only or self._rank_reduction_op is not None ) self.rank_zero_only = rank_zero_only and self._rank_reduction_op is None self.requires_all_ranks = self._rank_reduction_op is not None self._history: dict[str, deque[tuple[int, float]]] = {} self._latest_snapshot: ScalarSnapshot | None = None self._live: Live | None = None self._entered = False
[docs] @classmethod def preview( cls, *, history: Mapping[str, Sequence[float]] | None = None, layout: RichLayout | str | None = None, steps: Sequence[int] | None = None, console: Console | None = None, stage: str | None = None, step_count: int | None = None, epoch: int | None | object = _PREVIEW_DEFAULT, batch_count: int | None | object = _PREVIEW_DEFAULT, **reporter_kwargs: object, ) -> None: """Render a synthetic dashboard preview. Parameters ---------- history : Mapping[str, Sequence[float]] | None, optional Metric history used to populate plots and latest values. Defaults to representative curves from the selected layout. layout : RichLayout | {"training", "dynamics"} | None, optional Dashboard layout policy. ``None`` selects the training layout. steps : Sequence[int] | None, optional Step values aligned with each history sequence. Defaults to ``range(len(series))``. console : Console | None, optional Rich console used for preview output. stage : str | None, optional Stage label shown in the dashboard header. When omitted, the selected layout supplies a workflow-appropriate default. step_count : int | None, optional Step shown in the dashboard header. Defaults to the final step. epoch : int | None, optional Epoch shown in dashboard metadata. When omitted, the selected layout supplies a workflow-appropriate default. batch_count : int | None, optional Batch count shown in dashboard metadata. When omitted, the selected layout supplies a workflow-appropriate default. **reporter_kwargs : object Additional keyword arguments forwarded to :class:`RichReporter`. """ reporter = cls( console=console, layout=layout, rank_zero_only=False, **reporter_kwargs, ) if reporter._auto_layout: reporter._set_layout(TrainingRichLayout()) reporter.seed_history( reporter.layout.default_preview_history() if history is None else history, steps=steps, stage=stage if stage is not None else reporter.layout.default_preview_stage(), step_count=step_count, epoch=reporter.layout.default_preview_epoch() if epoch is _PREVIEW_DEFAULT else epoch, batch_count=reporter.layout.default_preview_batch_count() if batch_count is _PREVIEW_DEFAULT else batch_count, ) reporter.console.print(reporter.renderable())
@property def history(self) -> dict[str, tuple[tuple[int, float], ...]]: """Return retained scalar history. Returns ------- dict[str, tuple[tuple[int, float], ...]] Mapping from scalar key to ``(step, value)`` history tuples. """ return {key: tuple(values) for key, values in self._history.items()} def __enter__(self) -> RichReporter: """Start the live dashboard.""" if self._entered: return self self._entered = True if self._rank_reduction_op is None and not ( self._auto_layout and not self._layout_selected ): self._start_live() return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: """Stop the live dashboard.""" self.close()
[docs] def close(self) -> None: """Stop the live dashboard if it is active.""" if self._live is None: self._entered = False return self._live.stop() self._live = None self._entered = False
[docs] def report(self, ctx: HookContext, stage: Enum, state: ReportingState) -> None: """Update the dashboard from one scalar snapshot. Parameters ---------- ctx : HookContext Workflow hook context. stage : Enum Hook stage being reported. state : ReportingState Shared reporting state from the orchestrator. """ if not self._ensure_layout(ctx, stage): return snapshot = collect_scalars( ctx, stage, state, custom_scalars=self.custom_scalars, include_losses=self.include_losses, include_optimizer_lrs=self.include_optimizer_lrs, include_dynamics=self.include_dynamics_scalars, include_progress=True, ) if self._rank_reduction_op is not None: snapshot = reduce_scalar_snapshot( snapshot, self.rank_reduction, reporter_name=type(self).__name__, ) if not self._is_rank_zero(ctx): return elif self._write_rank_zero_only and not self._is_rank_zero(ctx): return self._record_snapshot(snapshot) renderable = self.renderable() if self._live is not None: self._live.update(renderable, refresh=False) elif self._entered: self._start_live(renderable) else: self.console.print(renderable)
[docs] def seed_history( self, history: Mapping[str, Sequence[float]], *, steps: Sequence[int] | None = None, stage: str = "AFTER_OPTIMIZER_STEP", step_count: int | None = None, epoch: int | None = None, batch_count: int | None = None, global_rank: int = 0, ) -> ScalarSnapshot: """Seed dashboard history without running a workflow. Parameters ---------- history : Mapping[str, Sequence[float]] Metric history used to populate plots and latest scalar values. steps : Sequence[int] | None, optional Step values aligned with each metric series. stage : str, default "AFTER_OPTIMIZER_STEP" Stage label for the synthetic snapshot. step_count : int | None, optional Step count for the synthetic snapshot. Defaults to the final step. epoch : int | None, optional Epoch metadata for the synthetic snapshot. batch_count : int | None, optional Batch metadata for the synthetic snapshot. global_rank : int, default 0 Rank metadata for the synthetic snapshot. Returns ------- ScalarSnapshot Synthetic latest snapshot produced from ``history``. """ if not history: raise ValueError("RichReporter preview history cannot be empty.") first_values = next(iter(history.values())) if not first_values: raise ValueError( "RichReporter preview history cannot contain empty series." ) if steps is None: resolved_steps = tuple(range(len(first_values))) else: resolved_steps = tuple(steps) if len(resolved_steps) != len(first_values): raise ValueError("RichReporter preview steps must match series length.") self._history = {} latest_scalars: dict[str, float] = {} for key, values in history.items(): if len(values) != len(resolved_steps): raise ValueError("RichReporter preview series lengths must match.") numeric_values = tuple(float(value) for value in values) self._history[key] = deque( zip(resolved_steps, numeric_values, strict=True), maxlen=self.history_size, ) latest_scalars[key] = numeric_values[-1] resolved_step_count = ( step_count if step_count is not None else resolved_steps[-1] ) snapshot = ScalarSnapshot( stage=stage, scalars=latest_scalars, step_count=resolved_step_count, batch_count=batch_count, epoch=epoch, global_rank=global_rank, ) self._latest_snapshot = snapshot return snapshot
[docs] def renderable(self) -> Layout: """Build the current dashboard renderable. Returns ------- Layout Rich layout containing the header, latest scalar table, and plots. """ return self.layout.render( self._latest_snapshot, self.history, title=self.title, precision=self.precision, max_scalars=self.max_scalars, plot_keys=self.plot_keys, max_plots=self.max_plots, plot_height=self.plot_height, )
def _ensure_layout(self, ctx: HookContext, stage: Enum) -> bool: if not self._auto_layout: return True if self._layout_selected: return True if isinstance(ctx, DynamicsContext) or stage.name == "AFTER_STEP": self._set_layout(DynamicsRichLayout()) return True if isinstance(ctx, TrainContext) or _looks_like_training_context(ctx, stage): self._set_layout(TrainingRichLayout()) return True if self.strict_layout: raise ValueError( "RichReporter could not select a layout for " f"context {type(ctx).__name__} at stage {stage.name!r}." ) return False def _set_layout(self, layout: RichLayout) -> None: self.layout = layout self._layout_selected = True if self._include_dynamics_scalars_override is None: self.include_dynamics_scalars = bool( getattr(self.layout, "include_dynamics_scalars", False) ) def _record_snapshot(self, snapshot: ScalarSnapshot) -> None: self._latest_snapshot = snapshot step = self._history_step(snapshot) for key, value in snapshot.scalars.items(): if key not in self._history: self._history[key] = deque(maxlen=self.history_size) self._history[key].append((step, value)) def _history_step(self, snapshot: ScalarSnapshot) -> int: if snapshot.step_count is not None: return snapshot.step_count if snapshot.event_count is not None: return snapshot.event_count lengths = [len(values) for values in self._history.values()] return max(lengths, default=0) def _is_rank_zero(self, ctx: HookContext) -> bool: return ctx.global_rank == 0 def _start_live(self, renderable: Layout | None = None) -> None: if self._live is not None: return self._live = Live( renderable if renderable is not None else self.renderable(), console=self.console, refresh_per_second=self.refresh_per_second, screen=self.screen, transient=self.transient, ) self._live.start()
def _looks_like_training_context(ctx: HookContext, stage: Enum) -> bool: if stage.name == "AFTER_OPTIMIZER_STEP": return True return any( hasattr(ctx, name) for name in ( "loss", "losses", "optimizers", "lr_schedulers", "batch_count", "epoch_step_count", ) )