"""The dashboard definition, as the hub stores and accepts it.
These models mirror the web app's ``experiment-dashboard-definition.ts``
one-to-one, with the panel fields it takes from ``experiments.ts``; what
a person writes is the builder in :mod:`.builder`, which converts to and
from them. The ``AGENTS.md`` next to this module says how the two are
kept in step.
"""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from typing import Any, Literal, TypeVar
from pydantic import (
BaseModel,
ConfigDict,
Field,
ModelWrapValidatorHandler,
ValidationInfo,
field_validator,
model_validator,
)
from pydantic.alias_generators import to_camel
from embedl_hub._internal.tracking.errors import TrackingUsageError
DASHBOARD_SCHEMA_VERSION = "0.0.1"
MAX_DASHBOARD_PANELS = 36
MAX_EXPERIMENT_PANEL_ITEMS = 50
MAX_SMOOTHING = 0.99
MAX_DASHBOARD_NAME_LENGTH = 100
MAX_DASHBOARD_DESCRIPTION_LENGTH = 500
MAX_PANEL_TITLE_LENGTH = 200
Aggregation = Literal["latest", "final", "min", "max", "best"]
ItemAggregation = Literal["latest", "final", "min", "max"]
BestDirection = Literal["min", "max"]
PanelWidth = Literal["half", "full"]
PanelItemKind = Literal[
"attribute", "param", "metric", "tag", "externalLink", "artifact"
]
PanelType = Literal[
"line", "bar", "scatter", "parallel", "table", "runDetails"
]
RunAttributeName = Literal[
"status", "type", "author", "startedAt", "endedAt", "duration"
]
_VERSION_PATTERN = re.compile(r"([0-9]+)\.([0-9]+)\.([0-9]+)")
_OWN_VERSION = tuple(
int(part)
for part in _VERSION_PATTERN.fullmatch(DASHBOARD_SCHEMA_VERSION).groups() # type: ignore[union-attr]
)
_ACCEPTED_VERSIONS = f"{_OWN_VERSION[0]}.{_OWN_VERSION[1]}.x"
_IGNORE_UNKNOWN_FIELDS = "ignore_unknown_fields"
T = TypeVar("T")
[docs]
class DashboardSchemaError(TrackingUsageError):
"""Raised when a dashboard does not fit this SDK's schema.
Covers a file the SDK cannot read as well as a :class:`Dashboard`
whose sections and panels do not form a valid layout. Like every
:class:`TrackingUsageError` it escapes a safe scope: the dashboard is
the caller's to fix, not a Hub failure to tolerate.
"""
def check_dashboard_schema_version(
config: Mapping[str, Any],
) -> tuple[int, int, int]:
"""Refuse a dashboard definition this SDK cannot read.
A definition is readable when its ``schemaVersion`` shares the first
two components with :data:`DASHBOARD_SCHEMA_VERSION`; the third may
differ. The integer ``version`` markers the hub still reads from
older stored dashboards are refused.
:param config: The parsed dashboard definition.
:return: The version as its three components.
:raises DashboardSchemaError: If the version is missing, malformed,
obsolete, or differs in a paradigm or breaking component.
"""
version = config.get("schemaVersion")
match = (
_VERSION_PATTERN.fullmatch(version)
if isinstance(version, str)
else None
)
if match is None:
obsolete = config.get("version")
if version is None and isinstance(obsolete, int):
raise DashboardSchemaError(
"The dashboard config carries the obsolete integer version "
f"{obsolete}. This embedl-hub writes {DASHBOARD_SCHEMA_VERSION} "
f"and reads {_ACCEPTED_VERSIONS}; rebuild the dashboard and "
"export it again with Dashboard.save()."
)
raise DashboardSchemaError(
"The dashboard config has no schemaVersion of the form X.Y.Z "
f"(found {version!r}). This embedl-hub writes "
f"{DASHBOARD_SCHEMA_VERSION} and reads {_ACCEPTED_VERSIONS}; "
"export the dashboard again with Dashboard.save()."
)
major, minor, patch = (int(part) for part in match.groups())
if (major, minor) != _OWN_VERSION[:2]:
raise DashboardSchemaError(
f"Dashboard schema {version} is not supported by this "
f"embedl-hub, which uses {DASHBOARD_SCHEMA_VERSION} and reads "
f"{_ACCEPTED_VERSIONS}. Upgrade embedl-hub or rebuild the "
"dashboard with this version."
)
return major, minor, patch
def _utf16_length(value: str) -> int:
return len(value.encode("utf-16-le")) // 2
class _Model(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
validate_by_name=True,
validate_by_alias=True,
serialize_by_alias=True,
validate_assignment=True,
extra="forbid",
)
@model_validator(mode="wrap")
@classmethod
def _ignore_fields_of_a_newer_patch(
cls,
data: Any,
handler: ModelWrapValidatorHandler[Any],
info: ValidationInfo,
) -> Any:
if (
isinstance(data, Mapping)
and info.context
and info.context.get(_IGNORE_UNKNOWN_FIELDS)
):
known = set(cls.model_fields)
known.update(
field.alias
for field in cls.model_fields.values()
if field.alias is not None
)
data = {key: value for key, value in data.items() if key in known}
return handler(data)
class Axis(_Model):
"""A metric or parameter read along one axis of a panel."""
source: Literal["metric", "param"] = "metric"
name: str
aggregation: Aggregation | None = None
best_direction: BestDirection | None = None
class PanelItem(_Model):
"""One row of a run details panel.
``kind`` says where ``name`` is looked up on the run: a run
attribute (``status``, ``type``, ``author``, ``startedAt``,
``endedAt`` or ``duration``), a parameter, a metric, a tag, an
external link or an artifact.
"""
kind: PanelItemKind
name: str = Field(min_length=1)
aggregation: ItemAggregation | None = None
@model_validator(mode="after")
def _attribute_names_are_known(self) -> PanelItem:
if (
self.kind == "attribute"
and self.name not in RunAttributeName.__args__
):
raise ValueError(
f"{self.name!r} is not a run attribute. Use one of "
f"{', '.join(RunAttributeName.__args__)}."
)
return self
class Filters(_Model):
"""Metrics, tags and parameters a dashboard or panel narrows its runs to."""
metrics: list[str] = Field(default_factory=list)
tags: dict[str, list[str]] = Field(default_factory=dict)
params: dict[str, list[str]] = Field(default_factory=dict)
class Table(_Model):
"""Columns and default sort of the run comparison table."""
columns: list[str] = Field(default_factory=list)
default_sort: str = ""
class Chart(_Model):
"""The smoothing the chart controls open with.
Panels carry their own smoothing; this only seeds the dashboard's
control.
"""
smoothing: float = Field(default=0, ge=0, le=MAX_SMOOTHING)
class Panel(_Model):
"""Any dashboard panel.
The subclasses fix ``type`` and name the fields that type reads;
this base carries every field the hub stores, so a dashboard read
back from the hub or a file keeps all of them. ``revision`` is the
hub's edit counter: read from a stored dashboard, never written.
"""
id: str | None = Field(default=None, min_length=1)
revision: int | None = Field(default=None, gt=0, exclude=True)
type: PanelType
title: str
width: PanelWidth = "half"
run_id: str | None = None
items: list[PanelItem] | None = Field(
default=None, max_length=MAX_EXPERIMENT_PANEL_ITEMS
)
metric_names: list[str] | None = None
metric_name: str | None = None
aggregation: Aggregation | None = None
best_direction: BestDirection | None = None
x_axis: Axis | None = None
y_axis: Axis | None = None
axes: list[Axis] | None = None
smoothing: float | None = Field(default=None, ge=0, le=MAX_SMOOTHING)
filters: Filters = Field(default_factory=Filters)
excluded_run_ids: list[str] = Field(default_factory=list)
table: Table = Field(default_factory=Table)
@field_validator("items")
@classmethod
def _items_are_distinct(
cls, items: list[PanelItem] | None
) -> list[PanelItem] | None:
repeated = _duplicates((item.kind, item.name) for item in items or [])
if repeated:
listed = ", ".join(f"{kind} {name!r}" for kind, name in repeated)
raise ValueError(
f"Items are listed more than once: {listed}. The hub keeps "
"the first item of each kind and name."
)
return items
class LinePanel(Panel):
"""Metric curves over steps, one line per run and metric."""
type: Literal["line"] = "line"
metric_names: list[str]
class BarPanel(Panel):
"""One aggregated metric value per run."""
type: Literal["bar"] = "bar"
metric_name: str
aggregation: Aggregation = "latest"
best_direction: BestDirection = "max"
class ScatterPanel(Panel):
"""Runs as points over two metrics or parameters."""
type: Literal["scatter"] = "scatter"
x_axis: Axis
y_axis: Axis
class ParallelPanel(Panel):
"""Runs as lines across several metric or parameter axes."""
type: Literal["parallel"] = "parallel"
axes: list[Axis]
class TablePanel(Panel):
"""The run comparison table, one row per selected run."""
type: Literal["table"] = "table"
width: PanelWidth = "full"
class RunDetailsPanel(Panel):
"""Chosen values of one run, one row each, in the order given.
``run_id`` names the run; the hub falls back to the first selected
run when it is unset or no longer selected.
"""
type: Literal["runDetails"] = "runDetails"
run_id: str | None = None
items: list[PanelItem] = Field(
default_factory=list, max_length=MAX_EXPERIMENT_PANEL_ITEMS
)
class Section(_Model):
"""A titled group of panels, referenced by their ids.
``revision`` is the hub's edit counter: read, never written.
"""
id: str | None = Field(default=None, min_length=1)
revision: int | None = Field(default=None, gt=0, exclude=True)
title: str = "Charts"
collapsed: bool = False
panel_ids: list[str] = Field(default_factory=list)
class DashboardDefinition(_Model):
"""The dashboard definition the hub stores and the API accepts as ``config``."""
schema_version: str = DASHBOARD_SCHEMA_VERSION
filters: Filters = Field(default_factory=Filters)
chart: Chart = Field(default_factory=Chart)
table: Table = Field(default_factory=Table)
sections: list[Section] = Field(default_factory=list)
panels: list[Panel] = Field(
default_factory=list, max_length=MAX_DASHBOARD_PANELS
)
def _duplicates(values: Iterable[T]) -> list[T]:
seen: set[T] = set()
repeated: set[T] = set()
for value in values:
if value in seen:
repeated.add(value)
seen.add(value)
return sorted(repeated)
def _fill_ids(given: list[str | None], prefix: str) -> list[str]:
taken = {value for value in given if value is not None}
filled: list[str] = []
for position, value in enumerate(given, start=1):
if value is None:
number = position
while f"{prefix}-{number}" in taken:
number += 1
value = f"{prefix}-{number}"
taken.add(value)
filled.append(value)
return filled