Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions source/isaaclab/changelog.d/task-matrix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Added
^^^^^

* Added ``tools/task_matrix.py``, which resolves whether a task's backend combination can
actually run. ``enumerate_task_presets`` reports what a task declares; ``resolve`` builds
the config and runs the runtime validator, so combinations that are declared but unusable
— ``isaacsim_physx`` with the kitless ``ovrtx`` renderer, for example — are identified
before a run is scheduled. Because it returns the resolved configs, automatic selectors
such as ``physics=physx`` are recognised by what they resolve to rather than by name.
278 changes: 278 additions & 0 deletions tools/task_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

"""Resolve which backend combinations a task can actually run, and what they select.

:func:`~isaaclab_tasks.utils.preset_cli.enumerate_task_presets` reports what a task
*declares*, bucketed by selector target. It cannot say whether a declared combination
**works**: OVRTX is kitless and cannot share a process with Kit physics, so
``isaacsim_physx + ovrtx`` is declared yet unusable, and a task can advertise a backend
it has no configuration for.

:func:`resolve` answers that by building the config and running the runtime validator.
It returns the configs the selection resolved to rather than a bare yes/no, which turns
out to answer two further questions for free — both previously handled with
hand-written name lists:

* **Is a name an alias?** ``physics=physx`` resolves to :class:`PhysxAutoCfg`, which
picks OvPhysX kitless and Isaac Sim PhysX under Kit. An alias and the backend it
selects are the same run, so the environment tables list only concrete backends and a
dispatcher must not schedule both. :func:`is_selector` reads that off the resolved
config instead of comparing against the names ``physx`` and ``rtx``.
* **Does a domain preset mirror a backend?** Some tasks give an untyped ``PresetCfg``
field a backend name, which lands under ``DOMAIN`` but is really selected through
``physics=`` / ``renderer=``. A ``presets=`` entry resolving to a physics or renderer
config is such a mirror, whatever it is called.

Deriving all three from one resolution keeps the answers correct as backends are added
or renamed, and costs one config load per combination rather than a second pass.
"""

from __future__ import annotations

from typing import Any, NamedTuple

__all__ = ["Mode", "Resolution", "backend_names", "is_selector", "modes", "resolve", "resolves"]

# Errors meaning the validator could not run at all, rather than that the combination
# under test was rejected. Reporting these as "does not resolve" marks every
# combination unusable and blames the caller's filters for an empty result.
# ``TypeError`` is included because calling the validator with the wrong argument type
# is otherwise indistinguishable from a rejected combination.
_VALIDATOR_FAILURES = (AttributeError, NameError, SyntaxError, TypeError)


class ResolutionError(RuntimeError):
"""Raised when the runtime validator itself could not run."""


class Mode(NamedTuple):
"""One way to run a task.

Args:
physics: Physics preset token, or ``None`` for tasks that declare none.
renderer: Renderer preset token, or ``None`` to run headless.
presets: Domain preset token passed as ``presets=<value>``, or ``None``.
Exactly one at a time: presets targeting the same field conflict, so
``presets=depth,rgb`` is rejected outright.
"""

physics: str | None
renderer: str | None
presets: str | None


class Resolution(NamedTuple):
"""What a selection resolved to.

Args:
physics: Resolved physics config, or ``None`` when the task has none.
renderer: Resolved renderer configs, empty when the task runs headless.
"""

physics: Any | None
renderer: tuple[Any, ...]


def _renderer_cfgs(env_cfg: Any) -> tuple[Any, ...]:
"""Return the renderer configs a resolved env config selected.

Physics sits at ``sim.physics``, but renderers attach to camera sensors and so can
be anywhere in the tree. Walking dataclass fields, dicts and sequences mirrors what
:func:`~isaaclab_tasks.utils.hydra.collect_presets` does for preset nodes.
"""
import dataclasses

from isaaclab.renderers.renderer_cfg import RendererCfg

found: list[Any] = []
seen: set[int] = set()

def visit(obj: Any, depth: int = 0) -> None:
if obj is None or depth > 8 or id(obj) in seen:
return
seen.add(id(obj))
if isinstance(obj, RendererCfg):
found.append(obj)
if dataclasses.is_dataclass(obj):
for field in dataclasses.fields(obj):
visit(getattr(obj, field.name, None), depth + 1)
elif isinstance(obj, dict):
for value in obj.values():
visit(value, depth + 1)
elif isinstance(obj, (list, tuple)):
for value in obj:
visit(value, depth + 1)

visit(env_cfg)
return tuple(found)


def resolve(
task_id: str, *, physics: str | None = None, renderer: str | None = None, presets: str | None = None
) -> Resolution | None:
"""Return what a preset selection resolves to, or ``None`` if it cannot run.

An unknown preset, an unloadable config, and a rejected backend pairing all mean
the same thing to a caller — the combination cannot run — so they return ``None``
alike. A task needing an extra that is not installed also returns ``None``: it
cannot run *here*, which is what the caller is asking.

Args:
task_id: Gym task id.
physics: Physics preset to select, or ``None`` to leave it unset.
renderer: Renderer preset to select, or ``None`` to leave it unset.
presets: Domain preset to select, or ``None`` to leave it unset.

Raises:
ResolutionError: If the validator could not run, e.g. because an Isaac Lab API
it depends on has changed. That is not the same as a rejected combination
and must not be reported as one.
"""
import argparse
import sys

from isaaclab.app.sim_launcher import _get_kit_runtime_sources, _validate_runtime, scan

from isaaclab_tasks.utils import resolve_task_config, setup_preset_cli

parser = argparse.ArgumentParser()
parser.add_argument("--task")
parser.add_argument("--agent", default=None)
argv = ["--task", task_id]
for token, value in (("physics", physics), ("renderer", renderer), ("presets", presets)):
if value is not None:
argv.append(f"{token}={value}")

original_argv = list(sys.argv)
try:
args, remaining = setup_preset_cli(parser, argv)
sys.argv = [sys.argv[0]] + remaining
env_cfg, _ = resolve_task_config(args.task, args.agent)
# Capture before scanning: ``scan`` collapses an automatic selector to the
# concrete backend it picks, which is exactly the distinction callers need.
selected = Resolution(
physics=getattr(getattr(env_cfg, "sim", None), "physics", None),
renderer=_renderer_cfgs(env_cfg),
)
# ``_validate_runtime`` takes the resolved Kit sources, not the parsed args.
# Passing args makes every scan look Kit-backed, which fires the OvPhysX guard
# for every OvPhysX combination and reports them all unusable.
config_scan = scan(env_cfg, args)
_validate_runtime(config_scan, _get_kit_runtime_sources(config_scan, args))
return selected
except ImportError:
return None
except _VALIDATOR_FAILURES as exc:
raise ResolutionError(
f"validating {task_id!r} could not run: {type(exc).__name__}: {exc}. This is an Isaac Lab API failure,"
" not a rejected preset combination."
) from exc
except Exception: # noqa: BLE001 - any other failure means the combination cannot run
return None
finally:
sys.argv = original_argv


def resolves(task_id: str, **selection: str | None) -> bool:
"""Return whether a preset selection can run, for callers that need only that."""
return resolve(task_id, **selection) is not None


def is_selector(task_id: str, *, physics: str | None = None, renderer: str | None = None) -> bool:
"""Return whether a preset name is an automatic selector rather than a backend.

A selector resolves to a config that picks a concrete backend at launch, so it
duplicates whichever backend it selects. Recognised by what it resolves to, so a
selector added upstream needs no edit here.
"""
resolution = resolve(task_id, physics=physics, renderer=renderer)
if resolution is None:
return False
if physics is not None:
return type(resolution.physics).__name__.endswith("AutoCfg")
return any(type(cfg).__name__.startswith("_Auto") for cfg in resolution.renderer)


def backend_names(specs: list[Any] | None = None) -> frozenset[str]:
"""Return every name used as a typed physics or renderer preset in the registry.

Some tasks give an untyped ``PresetCfg`` field a backend name — an observation
preset called ``isaacsim_physx``, say — which buckets under ``DOMAIN`` even though
it is reported through ``physics=`` / ``renderer=``.

Resolution cannot identify those: selecting such a preset resolves to the same
backend the task already defaults to, so nothing about the resolved config
distinguishes it from a genuine domain preset. What does distinguish it is that the
name is a typed backend *somewhere* in the registry, which this sweep collects.
Deriving it keeps the answer correct as backends are added or renamed, where a
hand-written list drifts in both directions.

Sweeps every registered task, so call once and pass the result around.

Args:
specs: Gym specs to sweep. When ``None``, the whole registry is scanned.

Returns:
Backend names, empty if no task declares a typed preset.
"""
import gymnasium as gym

from isaaclab_tasks.utils.preset_cli import enumerate_task_presets
from isaaclab_tasks.utils.preset_target import PresetTarget

if specs is None:
specs = list(gym.registry.values())

names: set[str] = set()
for spec in specs:
# A deprecated task warns on enumeration, and a backend only it declares is on
# its way out rather than something callers should recognise.
if spec.kwargs.get("deprecated"):
continue
preset_map = enumerate_task_presets(spec.id)
if not preset_map:
continue
names |= set(preset_map.get(PresetTarget.PHYSICS, ()))
names |= set(preset_map.get(PresetTarget.RENDERER, ()))
return frozenset(names)


def modes(task_id: str, preset_map: dict[Any, list[str]] | None, *, resolve_modes: bool = True) -> tuple[Mode, ...]:
"""Return the backend combinations for one task.

A task declaring renderers is expanded across them: reporting a camera task as
headless-only omits the thing under test. Domain presets are expanded one at a
time, never combined, because presets targeting the same field conflict.

Args:
task_id: Gym task id.
preset_map: Output of
:func:`~isaaclab_tasks.utils.preset_cli.enumerate_task_presets`, or
``None`` when the config could not be loaded.
resolve_modes: When ``True``, each combination is resolved and only usable ones
are returned. When ``False``, the full cross product is returned unverified,
which is fast but may include combinations that cannot run.

Returns:
Combinations in a deterministic order.
"""
from isaaclab_tasks.utils.preset_target import PresetTarget

preset_map = preset_map or {}
physics_options: tuple[str | None, ...] = tuple(sorted(preset_map.get(PresetTarget.PHYSICS, ()))) or (None,)
renderer_options: tuple[str | None, ...] = tuple(sorted(preset_map.get(PresetTarget.RENDERER, ()))) or (None,)
domains = tuple(sorted(preset_map.get(PresetTarget.DOMAIN, ())))
# ``None`` keeps the task's own default reachable alongside each explicit preset.
domain_options: tuple[str | None, ...] = (None, *domains) if domains else (None,)

found: list[Mode] = []
for physics in physics_options:
for renderer in renderer_options:
for domain in domain_options:
if resolve_modes and not resolves(task_id, physics=physics, renderer=renderer, presets=domain):
continue
found.append(Mode(physics, renderer, domain))
return tuple(found)
Loading