Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a058fd8
feat: record metric inputs and run metadata during generation
bokalnv Oct 27, 2026
96a15bd
docs: describe what generation records per step, attempt and run
bokalnv Oct 27, 2026
5f3264b
feat: add the autodata_metrics registry and the run report
bokalnv Oct 27, 2026
2a5d495
docs: add the metrics reference page with a table generated from the …
bokalnv Oct 27, 2026
5303c8f
fix: keep earlier runs' records and label only this run's datasets
bokalnv Oct 28, 2026
a2006af
Merge the PR #84 fixes into the metrics framework
bokalnv Oct 28, 2026
9ca575b
fix: report a run from the attempt log of the dataset's own run
bokalnv Oct 28, 2026
5948fc0
docs: say the result file and attempt log come with --result_file
bokalnv Oct 28, 2026
729ffc7
Merge the PR #84 docs fix into the metrics framework
bokalnv Oct 28, 2026
77b511d
feat: make metric recording opt-in and keep algorithm diagnostics in …
bokalnv Oct 28, 2026
d147c48
Merge the opt-in metric recording from PR #84
bokalnv Oct 28, 2026
ca4a948
fix: reject non-finite values, require --report for report options, r…
bokalnv Oct 28, 2026
9ea6a76
fix: record metric inputs with a custom recorder and without a succes…
bokalnv Oct 28, 2026
4f6e1ab
Merge the recorder fixes from PR #84
bokalnv Oct 28, 2026
44a57b8
Merge main after PR #84
bokalnv Oct 28, 2026
c632ff6
feat: add episode checks and validity metrics
bokalnv Oct 28, 2026
2bd8352
fix: check the attempt log against its result and keep reports valid …
bokalnv Oct 29, 2026
f205d26
Merge the review fixes from #90
bokalnv Oct 29, 2026
45d5ca4
refactor: compute baselines on the source as is and share the docs ta…
bokalnv Oct 29, 2026
475e598
fix: refuse a source other than the run's and non-finite gate bounds
bokalnv Oct 29, 2026
7e84d20
Merge the source and gate-bound checks from #90
bokalnv Oct 29, 2026
3d4f435
fix: require scalar gate bounds and qualify the source check in the docs
bokalnv Oct 29, 2026
f0111a3
Merge the gate-bound and docs fixes from #90
bokalnv Oct 29, 2026
fc8e3a8
Merge main after #90
bokalnv Oct 29, 2026
573e049
fix: warn about a dataset without an autodata entry only against a ru…
bokalnv Oct 29, 2026
1a591f2
feat: summarize the metrics report on the console and time annotation…
bokalnv Oct 29, 2026
19cd660
fix: tighten the episode checks and require valid episodes in the e2e…
bokalnv Oct 29, 2026
b3b2d9a
Merge the episode-check fixes from #91
bokalnv Oct 29, 2026
9cbd00a
refactor: derive skipped checks from the check groups and trim the su…
bokalnv Oct 29, 2026
b9c9d5b
fix: report degenerate data instead of failing on it
bokalnv Oct 29, 2026
622af85
fix: keep the report and its summary whole on malformed metric output…
bokalnv Oct 29, 2026
1b74d5b
fix: refuse corrupt run metadata and malformed gate files
bokalnv Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions autodata_metrics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@
Importing this package registers the built-in metrics.
"""

from autodata_metrics import viability
from autodata_metrics import validity, viability
from autodata_metrics.evidence import Episode, Evidence
from autodata_metrics.registry import AREAS, REGISTERED_METRICS, MetricSpec, NotApplicable, Value, metric
from autodata_metrics.report import build_report, format_report, gate_violations
from autodata_metrics.registry import (
AREAS,
REGISTERED_CHECKS,
REGISTERED_METRICS,
CheckSpec,
MetricSpec,
NotApplicable,
Value,
check,
metric,
)
from autodata_metrics.report import build_report, format_report, gate_results, load_gate
56 changes: 51 additions & 5 deletions autodata_metrics/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import numpy as np
from collections.abc import Mapping
from functools import cached_property
from typing import Any


Expand All @@ -22,10 +23,27 @@ def _has_path(record: Any, dotted_key: str) -> bool:


def read_env_args(h5_file: h5py.File) -> dict[str, Any]:
"""The dataset's ``env_args``, or empty when they are not a JSON object."""
"""The dataset's ``env_args``, or empty when it records none.

Raises:
ValueError: If ``env_args`` is not a JSON object, or its ``autodata`` entry lacks the run id and exported
episode count; a run cannot be verified against corrupt metadata.
"""
raw_env_args = h5_file["data"].attrs.get("env_args", "{}")
env_args = json.loads(raw_env_args.decode() if isinstance(raw_env_args, bytes) else raw_env_args)
return env_args if isinstance(env_args, dict) else {}
try:
env_args = json.loads(raw_env_args.decode() if isinstance(raw_env_args, bytes) else raw_env_args)
except (TypeError, ValueError):
env_args = None
if not isinstance(env_args, dict):
raise ValueError(f"{h5_file.filename}: env_args are not a JSON object.")
entry = env_args.get("autodata")
if "autodata" in env_args and not (
isinstance(entry, dict)
and isinstance(entry.get("run_id"), str)
and isinstance(entry.get("exported_episodes"), int)
):
raise ValueError(f"{h5_file.filename}: the autodata entry lacks its run_id or exported_episodes.")
return env_args


class Episode:
Expand All @@ -41,12 +59,27 @@ def __contains__(self, key: str) -> bool:
def __getitem__(self, key: str) -> np.ndarray:
return self._group[key][()]

def children(self, key: str) -> list[str]:
"""Names under the group ``key``, such as the end effectors under ``obs/datagen_info/eef_pose``."""
return list(self._group[key])

def datasets(self) -> list[tuple[str, h5py.Dataset]]:
"""Every dataset of the episode with its path, unread until accessed."""
found: list[tuple[str, h5py.Dataset]] = []
self._group.visititems(
lambda path, item: found.append((path, item)) if isinstance(item, h5py.Dataset) else None
)
return found


class Evidence:
"""A dataset, and optionally the generation run that produced it, as metrics read them.

Attributes:
episodes: The dataset's episodes.
all_episodes: The dataset's episodes.
episodes: The episodes that passed every episode check; dataset metrics read these.
failures: Per episode check that applied to some episode, the failing episodes' names and reasons.
check_groups: Per episode check considered, its group; a check absent from ``failures`` did not apply.
env_args: The dataset's ``env_args``; its ``autodata`` entry was written by the producer.
result: Generation result, or None for a dataset without a run record or an interrupted run.
run: The result's ``run`` block, or empty.
Expand All @@ -62,7 +95,10 @@ def __init__(
source: Evidence | None = None,
) -> None:
self.env_args = read_env_args(h5_file)
self.episodes = [Episode(group) for group in h5_file["data"].values()]
self.all_episodes = [Episode(group) for group in h5_file["data"].values()]
self.episodes = list(self.all_episodes)
self.failures: dict[str, dict[str, str]] = {}
self.check_groups: dict[str, str] = {}
self.result = result
self.run: Mapping[str, Any] = (result or {}).get("run", {})
self.attempts = attempts
Expand All @@ -88,3 +124,13 @@ def has(self, key: str) -> bool:
if head == "run":
return _has_path(self.run, rest)
return bool(self.episodes) and all(key in episode for episode in self.episodes)

@cached_property
def camera_paths(self) -> frozenset[str]:
"""Paths of the camera streams, 4-D ``uint8`` arrays, that any episode records."""
return frozenset(
path
for episode in self.all_episodes
for path, dataset in episode.datasets()
if dataset.dtype == np.uint8 and dataset.ndim == 4
)
52 changes: 48 additions & 4 deletions autodata_metrics/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from typing import TYPE_CHECKING, Any, Literal

if TYPE_CHECKING:
from autodata_metrics.evidence import Evidence
from autodata_metrics.evidence import Episode, Evidence

AREAS = ("validity", "diversity", "viability", "diagnostics")

Expand Down Expand Up @@ -47,41 +47,60 @@ class MetricSpec:
compute: Callable[[Evidence], Value]
area: str
signal: str
scope: Literal["dataset", "run"]
scope: Literal["episode", "dataset", "run"]
unit: str
sample: str
better: Literal["higher", "lower"] | None
requires: tuple[str, ...]
primary: str | None
version: int
baseline: bool = False


@dataclass(frozen=True)
class CheckSpec:
"""A registered episode check."""

name: str
check: Callable[[Episode, Evidence], str | None]
group: str
requires: tuple[str, ...]


REGISTERED_METRICS: dict[str, MetricSpec] = {}
REGISTERED_CHECKS: dict[str, CheckSpec] = {}
CHECK_GROUPS = ("correctness", "image")


def metric(
name: str,
*,
area: str,
signal: str,
scope: Literal["dataset", "run"],
scope: Literal["episode", "dataset", "run"],
unit: str,
sample: str,
better: Literal["higher", "lower"] | None = None,
requires: tuple[str, ...] | list[str] = (),
primary: str | None = None,
version: int = 1,
baseline: bool = False,
) -> Callable[[Callable[[Evidence], Value]], Callable[[Evidence], Value]]:
"""Register the decorated function as the metric ``name``.

Args:
name: Unique metric name, used in reports and gates.
area: One of :data:`AREAS`.
signal: What the metric measures within its area, e.g. ``"task correctness"``.
scope: ``"dataset"`` metrics read episodes; ``"run"`` metrics read attempts and the run record.
scope: ``"episode"`` metrics summarize the episode checks over every episode; ``"dataset"`` metrics read
the episodes that passed them; ``"run"`` metrics read attempts and the run record.
unit: Unit of the value.
sample: What the value's sample count counts, in the plural, e.g. ``"attempts"`` or ``"episodes"``.
better: Which direction is better, if either.
requires: Evidence keys; see :meth:`~autodata_metrics.evidence.Evidence.has`.
primary: For a dict value, the entry a gate checks by default.
version: Raise it whenever the definition changes, so gates pinned to the old one fail.
baseline: Also compute the metric on the source dataset.
"""
assert area in AREAS, f"Metric {name!r} has area {area!r}; expected one of {AREAS}."

Expand All @@ -94,11 +113,36 @@ def register(compute: Callable[[Evidence], Value]) -> Callable[[Evidence], Value
signal=signal,
scope=scope,
unit=unit,
sample=sample,
better=better,
requires=tuple(requires),
primary=primary,
version=version,
baseline=baseline,
)
return compute

return register


def check(
name: str, *, group: str = "correctness", requires: tuple[str, ...] | list[str] = ()
) -> Callable[[Callable[[Episode, Evidence], str | None]], Callable[[Episode, Evidence], str | None]]:
"""Register the decorated function as the episode check ``name``.

A check returns None for an episode that passes, a reason for one that fails, or raises :class:`NotApplicable`
for an episode it does not cover. Episodes that fail any check are left out of every dataset metric.

Args:
name: Unique check name.
group: One of :data:`CHECK_GROUPS`; the pass-rate metric the check counts toward.
requires: Evidence keys the check needs; without them it does not run.
"""
assert group in CHECK_GROUPS, f"Check {name!r} has group {group!r}; expected one of {CHECK_GROUPS}."

def register(function: Callable[[Episode, Evidence], str | None]) -> Callable[[Episode, Evidence], str | None]:
assert name not in REGISTERED_CHECKS, f"Check {name!r} is already registered."
REGISTERED_CHECKS[name] = CheckSpec(name=name, check=function, group=group, requires=tuple(requires))
return function

return register
Loading
Loading