diff --git a/source/isaaclab/changelog.d/task-matrix.rst b/source/isaaclab/changelog.d/task-matrix.rst new file mode 100644 index 00000000000..6daa77bcefc --- /dev/null +++ b/source/isaaclab/changelog.d/task-matrix.rst @@ -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. diff --git a/tools/task_matrix.py b/tools/task_matrix.py new file mode 100644 index 00000000000..3dc6dd8d12d --- /dev/null +++ b/tools/task_matrix.py @@ -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=``, 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)