Source code for isaaclab.utils.leapp.export_annotator

# 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

"""Export annotations for Isaac Lab policies using proxy-based patching.

Observation and action annotation share a unified dedup cache so that a
state property (e.g. ``joint_pos``) read by both an observation term and
an action term resolves to one LEAPP input edge.

- Observation term functions see an ``_EnvProxy`` whose scene returns
  ``_EntityProxy`` objects with annotating data proxies.

- Action terms have their ``_asset`` attribute replaced with an
  _ArticulationWriteProxy that intercepts ``_leapp_semantics``-decorated
  write methods **and** routes ``.data`` reads through the same annotating
  data proxy used by observations.

Cache lifecycle (assuming single-env play-mode export):

    compute()                clear cache → obs terms populate cache
    policy inference         TracedTensors propagate through NN
    process_action()         register_buffer for raw_actions
    apply_action() [tracing] reuse cached TracedTensors for state reads,
                             capture write outputs, call output_tensors(),
                             then clear cache
    apply_action() [decim.]  clear cache → fresh reads for simulation
    ...
    compute()                clear cache → fresh reads for next obs
"""

from __future__ import annotations

import inspect
import logging
from collections.abc import Callable
from contextlib import suppress
from typing import TYPE_CHECKING, Any

import torch
from leapp import annotate
from leapp.utils.tensor_description import TensorSemantics

from isaaclab.assets.articulation.base_articulation import BaseArticulation
from isaaclab.managers import ManagerTermBase
from isaaclab.utils.array import convert_to_torch

from .leapp_semantics import select_element_names
from .proxy import _ArticulationWriteProxy, _DataProxy, _EnvProxy, _ManagerTermProxy
from .utils import (
    TracedProxyArray,
    build_command_connection,
    build_write_connection,
)

if TYPE_CHECKING:
    from isaaclab.envs import ManagerBasedEnv


VARIABLE_IMPEDANCE_MODES = frozenset({"variable", "variable_kp"})


def _effective_joint_gains(real_asset) -> tuple[torch.Tensor | None, torch.Tensor | None]:
    """Return per-joint ``(kp, kd)`` tensors reflecting each actuator's PD gains.

    ``data.default_joint_stiffness`` / ``data.default_joint_damping`` only hold the gains of
    *implicit* actuators, which write them to the simulation. *Explicit* actuators (e.g.
    :class:`~isaaclab.actuators.DCMotor`, :class:`~isaaclab.actuators.IdealPDActuator`) compute
    their PD term internally and apply it as joint effort, leaving the sim-level gains at zero --
    so reading the data buffers alone exports zero ``kp``/``kd`` for those joints and the deployed
    policy graph carries no gains. Aggregating from ``asset.actuators`` recovers the true gains for
    every actuator model (a no-op for implicit actuators, whose buffers already match).

    Returns ``None`` independently for any joint-gain buffer the asset does not expose.
    """
    data = getattr(real_asset, "data", None)
    stiffness = getattr(data, "default_joint_stiffness", None)
    damping = getattr(data, "default_joint_damping", None)
    if stiffness is None and damping is None:
        return None, None
    kp = stiffness.torch.clone() if stiffness is not None else None
    kd = damping.torch.clone() if damping is not None else None
    for actuator in getattr(real_asset, "actuators", {}).values():
        if kp is not None:
            kp[:, actuator.joint_indices] = actuator.stiffness
        if kd is not None:
            kd[:, actuator.joint_indices] = actuator.damping
    return kp, kd


# ══════════════════════════════════════════════════════════════════
# ExportPatcher
# ══════════════════════════════════════════════════════════════════


[docs] class ExportPatcher: """Unified patcher that annotates observation inputs and action outputs for LEAPP export. Observation-side property semantics are resolved lazily inside ``_DataProxy`` by combining: - the concrete runtime getter from the backend data class - the nearest ``_leapp_semantics`` metadata found while walking the MRO This lets backends override property implementations without duplicating decorators from the abstract API. - The observation proxy chain (``_EnvProxy`` → ``_SceneProxy`` → ``_EntityProxy`` → ``_DataProxy``) for state reads by observation term functions. - The ``_ArticulationWriteProxy`` on each action term, which intercepts target writes **and** routes ``.data`` reads through the same ``_DataProxy`` / cache. """
[docs] def __init__(self, export_method: str, required_obs_groups: set[str] | None = None): """Initialize the export patcher. Args: export_method: LEAPP export backend passed to :func:`annotate.output_tensors`. required_obs_groups: Observation groups that should be patched, or ``None`` to patch all groups. """ self.task_name: str | None = None self.export_method = export_method self.required_obs_groups = required_obs_groups self._annotated_tensor_cache: dict[tuple[int, str], TracedProxyArray] = {} self._data_property_resolution_cache: dict[tuple[type, str], tuple[Callable, object] | None] = {} self._write_method_resolution_cache: dict[ tuple[type, str], tuple[Callable, object, inspect.Signature] | None ] = {} self._action_output_cache: list[TensorSemantics] = [] self._captured_write_term_names: set[str] = set() self._fallback_term_names: set[str] = set() self._pending_action_output_export: bool = False self._uses_last_action_state: bool = False self._action_term_scene_keys: dict[str, str] = {}
def setup(self, env): """Patch the environment in place for LEAPP-aware export. Args: env: Wrapped manager-based environment whose unwrapped instance should be patched. """ unwrapped = env.env.unwrapped task_name = str(unwrapped.spec.id) self.task_name = task_name proxy_env = _EnvProxy( unwrapped, task_name, self._data_property_resolution_cache, self._annotated_tensor_cache, ) self._disable_training_managers(unwrapped) self._patch_observation_manager(unwrapped.observation_manager, proxy_env) self._patch_history_buffers(unwrapped.observation_manager) self._patch_action_manager( unwrapped.action_manager, self._annotated_tensor_cache, ) # ── Disable training-only managers ───────────────────────────── @staticmethod def _disable_training_managers(unwrapped): """Replace training-only manager methods with no-ops. During export the curriculum, reward, termination, and recorder managers serve no purpose. Disabling them avoids side-effect crashes (e.g. ADR curriculum terms accessing nullified noise configs) and removes unnecessary computation. Args: unwrapped: Unwrapped environment whose training-only managers should be disabled. """ num_envs = unwrapped.num_envs device = unwrapped.device _zero_reward = torch.zeros(num_envs, device=device) _no_termination = torch.zeros(num_envs, dtype=torch.bool, device=device) def _noop_curriculum(env_ids=None): return None def _zero_reward_compute(dt): return _zero_reward def _no_termination_compute(): return _no_termination def _noop(*args, **kwargs): return None if hasattr(unwrapped, "curriculum_manager"): unwrapped.curriculum_manager.compute = _noop_curriculum if hasattr(unwrapped, "reward_manager"): unwrapped.reward_manager.compute = _zero_reward_compute if hasattr(unwrapped, "termination_manager"): unwrapped.termination_manager.compute = _no_termination_compute if hasattr(unwrapped, "recorder_manager"): rm = unwrapped.recorder_manager rm.record_pre_step = _noop rm.record_post_step = _noop rm.record_pre_reset = _noop rm.record_post_reset = _noop rm.record_post_physics_decimation_step = _noop @staticmethod def _resolve_scene_entity_key(scene, entity: Any) -> str | None: """Return the scene dictionary key for an entity. Args: scene: Scene object that stores entity dictionaries. entity: Entity instance to locate. Returns: The scene key for ``entity`` if found, otherwise ``None``. """ for attr_value in vars(scene).values(): if not isinstance(attr_value, dict): continue for key, candidate in attr_value.items(): if candidate is entity: return key return None # ── Observation manager patches ─────────────────────────────── def _patch_history_buffers(self, obs_manager): """Patch history-enabled observation buffers to export as LEAPP state. Args: obs_manager: Observation manager whose history buffers should be wrapped. """ history_buffers = getattr(obs_manager, "_group_obs_term_history_buffer", {}) term_names_by_group = getattr(obs_manager, "_group_obs_term_names", {}) for group_name, term_cfgs in obs_manager._group_obs_term_cfgs.items(): if self.required_obs_groups is not None and group_name not in self.required_obs_groups: continue group_buffers = history_buffers.get(group_name, {}) group_term_names = term_names_by_group.get(group_name, []) for index, term_cfg in enumerate(term_cfgs): history_length = getattr(term_cfg, "history_length", 0) or 0 if history_length <= 0: continue if index >= len(group_term_names): continue term_name = group_term_names[index] circular_buffer = group_buffers.get(term_name) if circular_buffer is None: continue state_name = f"h_{group_name}_{term_name}" self._patch_history_buffer_append(circular_buffer, state_name) def _patch_history_buffer_append(self, circular_buffer, state_name: str): """Replace ``append`` with a functional shift so history is LEAPP state. Production :meth:`~isaaclab.utils.buffers.CircularBuffer.append` shifts with in-place ``copy_``, which the tracer cannot see. During export the same oldest→newest layout is produced with ``torch.cat`` so the recurrence appears in the graph. Observation-manager buffers use the legacy ``(K, B, ...)`` layout (no ``stack_dim``). Args: circular_buffer: Circular buffer instance to patch. state_name: LEAPP state tensor name for the buffer contents. """ if hasattr(circular_buffer, "_leapp_original_append"): return task_name = self.task_name circular_buffer._leapp_original_append = circular_buffer.append def patched_append(data: torch.Tensor) -> None: """Shift history with ``torch.cat`` and annotate as LEAPP state. Args: data: New observation slice appended to the buffer. """ if data.shape[0] != circular_buffer.batch_size: raise ValueError( f"The input data has '{data.shape[0]}' batch size while expecting '{circular_buffer.batch_size}'" ) data = data.to(circular_buffer._device) if circular_buffer._buffer is None: # Match first-push backfill: broadcast into all K slots. circular_buffer._buffer = data.unsqueeze(0).expand(circular_buffer._max_len_int, *data.shape).clone() else: buffer = annotate.state_tensors(task_name, {state_name: circular_buffer._buffer}) circular_buffer._buffer = torch.cat([buffer[1:], data.unsqueeze(0)], dim=0) circular_buffer._buffer = annotate.update_state(task_name, {state_name: circular_buffer._buffer}) circular_buffer._num_pushes += 1 circular_buffer._need_reset = False circular_buffer.append = patched_append def _patch_observation_manager(self, obs_manager, proxy_env): """Patch observation terms to use annotating proxies and disable noise. Args: obs_manager: Observation manager instance to patch. proxy_env: Proxy environment routed into observation terms. """ for group_name, term_cfgs in obs_manager._group_obs_term_cfgs.items(): if self.required_obs_groups is not None and group_name not in self.required_obs_groups: continue for term_cfg in term_cfgs: original_func = term_cfg.func func_name = getattr(original_func, "__name__", None) if func_name == "last_action": self._uses_last_action_state = True term_cfg.func = self._wrap_last_action(original_func) elif func_name == "generated_commands": term_cfg.func = self._wrap_generated_commands(original_func, term_cfg) elif func_name == "projected_gravity": term_cfg.func = self._wrap_projected_gravity(original_func, proxy_env) else: term_cfg.func = self._wrap_with_proxy(original_func, proxy_env) term_cfg.noise = None original_compute = obs_manager.compute cache = self._annotated_tensor_cache def patched_compute(*args, **kwargs): """Clear the tensor dedup cache once per full observation pass.""" cache.clear() return original_compute(*args, **kwargs) obs_manager.compute = patched_compute # ── Action manager patches ──────────────────────────────────── def _patch_action_manager(self, action_manager, cache): """Patch action terms with write/read proxies and manager hooks. Args: action_manager: Action manager instance to patch. cache: Shared tensor dedup cache for annotated state reads. """ assert self.task_name is not None scene = action_manager._env.scene for term_name, term in action_manager._terms.items(): asset = getattr(term, "_asset", None) if isinstance(asset, BaseArticulation): real_asset: BaseArticulation = asset scene_key = self._resolve_scene_entity_key(scene, real_asset) or "ego" data_proxy = _DataProxy( real_asset.data, scene_key, self.task_name, self._data_property_resolution_cache, cache, input_name_resolver=lambda prop_name, k=scene_key: f"{k}_{prop_name}", ) term._asset = _ArticulationWriteProxy( real_asset=real_asset, entity_name=scene_key, term_name=term_name, output_cache=self._action_output_cache, method_resolution_cache=self._write_method_resolution_cache, captured_write_term_names=self._captured_write_term_names, data_proxy=data_proxy, ) self._action_term_scene_keys[term_name] = scene_key self._patch_action_manager_methods(action_manager) def _patch_action_manager_methods(self, action_manager): """Patch ``process_action`` and ``apply_action`` on the action manager instance. ``process_action`` registers raw_action buffers for LEAPP tracing and preserves the action tensor clone. ``apply_action`` coordinates the cache and output lifecycle: - **Tracing pass** (first ``apply_action`` after ``process_action``): The cache still holds TracedTensors populated by ``compute_group``. Action terms that read state (e.g. ``RelativeJointPositionAction`` reading ``joint_pos``) get those TracedTensors from the cache, keeping the LEAPP graph connected. After ``output_tensors()`` the cache is cleared so subsequent decimation sub-steps read fresh values. - **Non-tracing passes** (remaining decimation sub-steps and all subsequent iterations): The cache is cleared **before** running action terms so every ``.data`` read returns the current simulator value, preserving simulation correctness. Args: action_manager: Action manager whose instance methods should be wrapped. """ original_process = action_manager.process_action original_apply = action_manager.apply_action task_name = self.task_name cache = self._annotated_tensor_cache def patched_process_action(action: torch.Tensor): """Register raw_action buffers, call real process_action, preserve action clone.""" original_process(action) action_manager._action = action.clone() self._pending_action_output_export = True def patched_apply_action(): """Coordinate cache lifecycle and LEAPP output annotation.""" if not self._pending_action_output_export: cache.clear() return original_apply() # Tracing pass: cache still holds TracedTensors from compute_group. self._action_output_cache.clear() self._captured_write_term_names.clear() original_apply() self._action_output_cache.extend(self._collect_action_outputs(action_manager)) self._action_output_cache.extend(self._collect_processed_action_fallbacks(action_manager)) if self._uses_last_action_state: annotate.update_state(task_name, {"last_action": action_manager._action}) fallback_terms = self._fallback_term_names static_values = self._collect_action_static_outputs(action_manager, fallback_terms) annotate.output_tensors( task_name, self._action_output_cache, static_outputs=static_values or None, export_with=self.export_method, ) self._pending_action_output_export = False self._action_output_cache.clear() cache.clear() return None action_manager.process_action = patched_process_action action_manager.apply_action = patched_apply_action # ── Observation term wrappers ───────────────────────────────── @staticmethod def _wrap_with_proxy(original_func, proxy_env): """Wrap a term function so it receives the proxy env. Args: original_func: Original observation term function or manager term. proxy_env: Proxy environment routed into the wrapped callable. Returns: Wrapped callable that substitutes ``proxy_env`` for the real env. """ if isinstance(original_func, ManagerTermBase): return _ManagerTermProxy(original_func, proxy_env) def wrapped(*args, **kwargs): """Invoke the original function with the proxy environment. Args: *args: Original positional arguments. **kwargs: Original keyword arguments. Returns: Result of the wrapped observation term. """ if args: args = (proxy_env, *args[1:]) else: args = (proxy_env,) return original_func(*args, **kwargs) wrapped.__name__ = getattr(original_func, "__name__", "unknown") return wrapped @staticmethod def _wrap_projected_gravity(original_func, proxy_env): """Wrap projected gravity as root-quaternion input plus fixed gravity projection. Deployment backends generally provide body orientation, not an already projected gravity vector. During export, keep the policy observation as projected gravity while exposing ``root_quat_w`` at the LEAPP graph boundary. """ def wrapped(*args, **kwargs): """Compute projected gravity from an annotated root quaternion input.""" kwargs.pop("inspect", None) asset_cfg = kwargs.get("asset_cfg") if asset_cfg is None and len(args) > 1: asset_cfg = args[1] asset_name = getattr(asset_cfg, "name", "robot") root_quat_w = proxy_env.scene[asset_name].data.root_quat_w.torch gravity_w = torch.zeros((*root_quat_w.shape[:-1], 3), dtype=root_quat_w.dtype, device=root_quat_w.device) gravity_w[..., 2] = -1.0 quat_xyz = root_quat_w[..., :3] quat_w = root_quat_w[..., 3:4] t = torch.cross(quat_xyz, gravity_w, dim=-1) * 2.0 return gravity_w - quat_w * t + torch.cross(quat_xyz, t, dim=-1) wrapped.__name__ = getattr(original_func, "__name__", "unknown") return wrapped def _wrap_last_action(self, original_func): """Wrap ``last_action`` as a LEAPP state tensor. ``last_action`` is feedback state, not a regular dangling input. We therefore register it through ``annotate.state_tensors(...)`` on the observation side and update it through ``annotate.update_state(...)`` after the traced action pass. Args: original_func: Original ``last_action`` observation term. Returns: Wrapped callable that exports ``last_action`` as LEAPP state. """ task_name = self.task_name def wrapped(env, action_name=None, **kwargs): """Run the wrapped ``last_action`` term and annotate its output. Args: env: Environment passed by the observation manager. action_name: Optional action term name. **kwargs: Additional keyword arguments for the term. Returns: Annotated last-action tensor. """ result = original_func(env, action_name, **kwargs) return annotate.state_tensors(task_name, {"last_action": result}) wrapped.__name__ = original_func.__name__ return wrapped def _wrap_generated_commands(self, original_func, term_cfg): """Wrap the ``generated_commands`` observation term to annotate its output as a LEAPP input. Resolves command semantics (kind, element_names) from the command manager configuration when available. Args: original_func: Original ``generated_commands`` observation term. term_cfg: Observation term config used to resolve the command name. Returns: Wrapped callable that exports generated commands as LEAPP inputs. """ task_name = self.task_name command_name_from_cfg = term_cfg.params.get("command_name") def wrapped(env, command_name=None, **kwargs): """Run the wrapped command term and annotate its output. Args: env: Environment passed by the observation manager. command_name: Optional command term name override. **kwargs: Additional keyword arguments for the term. Returns: Annotated command tensor. """ result = original_func(env, command_name, **kwargs) leapp_input_name = command_name or command_name_from_cfg or "commands" command_cfg = None with suppress(AttributeError, KeyError): command_cfg = env.command_manager.get_term(leapp_input_name).cfg sem = TensorSemantics( name=leapp_input_name, ref=result, kind=getattr(command_cfg, "cmd_kind", None), element_names=getattr(command_cfg, "element_names", None), extra=build_command_connection(leapp_input_name), ) return annotate.input_tensors(task_name, sem) wrapped.__name__ = original_func.__name__ return wrapped # ── Output collection ───────────────────────────────────────── def _collect_action_outputs(self, action_manager) -> list[TensorSemantics]: """Collect non-writer action tensors that should be exported. Args: action_manager: Action manager whose terms should be inspected. Returns: Exportable tensor semantics for dynamic action outputs such as OSC gains. """ tensors: list[TensorSemantics] = [] for term_name, term in action_manager._terms.items(): osc = getattr(term, "_osc", None) if osc and hasattr(osc, "cfg") and osc.cfg.impedance_mode in VARIABLE_IMPEDANCE_MODES: asset = getattr(term, "_asset", None) real_asset = getattr(asset, "_real_asset", asset) joint_ids = getattr(term, "_joint_ids", None) joint_names = getattr(real_asset, "joint_names", None) if real_asset else None scene_key = self._action_term_scene_keys.get(term_name, "ego") tensors.append( TensorSemantics( name=f"{term_name}_kp_gains", ref=torch.diagonal(osc._motion_p_gains_task, dim1=-2, dim2=-1), kind="kp", element_names=select_element_names(joint_names, joint_ids), extra=build_write_connection(scene_key, "write_joint_stiffness_to_sim_index"), ) ) tensors.append( TensorSemantics( name=f"{term_name}_kd_gains", ref=torch.diagonal(osc._motion_d_gains_task, dim1=-2, dim2=-1), kind="kd", element_names=select_element_names(joint_names, joint_ids), extra=build_write_connection(scene_key, "write_joint_damping_to_sim_index"), ) ) return tensors def _collect_processed_action_fallbacks(self, action_manager) -> list[TensorSemantics]: """Fallback: use ``term.processed_actions`` for terms that produced no write outputs. When an action term does not call any ``_leapp_semantics``-decorated write method (e.g. ``PreTrainedPolicyAction`` which delegates writes to a nested sub-policy), we fall back to capturing ``term.processed_actions`` as the output tensor. Args: action_manager: Action manager whose terms should be inspected. Returns: Fallback tensor semantics built from ``processed_actions``. """ logger = logging.getLogger(__name__) fallback_terms: set[str] = set() tensors: list[TensorSemantics] = [] for term_name, term in action_manager._terms.items(): if term_name in self._captured_write_term_names: continue processed = getattr(term, "processed_actions", None) if processed is None: continue if isinstance(processed, torch.Tensor): logger.warning( "Action term '%s' did not write to any asset directly. Falling back to processed_actions as the" " export output.\nIf you wish to add semantic data to this policy, you need to manually annotate it" " with output_tensors.", term_name, ) tensors.append( TensorSemantics( name=term_name, ref=processed.clone(), kind=None, element_names=None, ) ) fallback_terms.add(term_name) self._fallback_term_names = fallback_terms return tensors def _collect_action_static_outputs( self, action_manager, skip_terms: set[str] | None = None ) -> list[TensorSemantics]: """Collect static kp/kd gain values from action terms for export metadata. Terms in ``skip_terms`` are excluded — these are terms that fell back to ``processed_actions`` and whose static gains (kp/kd) belong to a lower abstraction level that is not part of the exported policy. Args: action_manager: Action manager whose terms should be inspected. skip_terms: Action term names whose static outputs should be skipped. Returns: Static tensor semantics for action gains exported as metadata. """ static_values: list[TensorSemantics] = [] for term_name, term in action_manager._terms.items(): if skip_terms and term_name in skip_terms: continue osc = getattr(term, "_osc", None) if osc and hasattr(osc, "cfg") and osc.cfg.impedance_mode in VARIABLE_IMPEDANCE_MODES: continue asset = getattr(term, "_asset", None) real_asset = getattr(asset, "_real_asset", asset) if real_asset and hasattr(real_asset, "data"): joint_ids = getattr(term, "_joint_ids", None) joint_names = getattr(real_asset, "joint_names", None) scene_key = self._action_term_scene_keys.get(term_name, "ego") # Source the PD gains from the actuators so they are correct for every actuator # model. Reading ``data.default_joint_stiffness``/``default_joint_damping`` alone # exports zero gains for explicit actuators (DCMotor, IdealPDActuator, ...), which # keep their gains on the actuator rather than in the sim. See _effective_joint_gains. kp_gains, kd_gains = _effective_joint_gains(real_asset) gain_reference = kp_gains if kp_gains is not None else kd_gains if joint_ids is not None and not isinstance(joint_ids, slice) and gain_reference is not None: joint_ids = convert_to_torch(joint_ids, dtype=torch.long, device=gain_reference.device) if kp_gains is not None: static_values.append( TensorSemantics( name=f"{term_name}_kp_gains", ref=kp_gains if joint_ids is None else kp_gains[:, joint_ids], kind="kp", element_names=select_element_names(joint_names, joint_ids), extra=build_write_connection(scene_key, "write_joint_stiffness_to_sim_index"), ) ) if kd_gains is not None: static_values.append( TensorSemantics( name=f"{term_name}_kd_gains", ref=kd_gains if joint_ids is None else kd_gains[:, joint_ids], kind="kd", element_names=select_element_names(joint_names, joint_ids), extra=build_write_connection(scene_key, "write_joint_damping_to_sim_index"), ) ) return static_values
# ══════════════════════════════════════════════════════════════════ # Public entry point # ══════════════════════════════════════════════════════════════════ def patch_env_for_export( env: ManagerBasedEnv, export_method: str, required_obs_groups: set[str] | None = None, ) -> None: """Patch the env's observation and action managers for LEAPP export. This is a thin public entry point around ``ExportPatcher``. It mutates the provided env instance in-place so that: - Observation terms route through proxy objects that annotate tensor reads from **any** scene entity data class (articulations, rigid objects, sensors, etc.). - Action terms route through proxy objects that annotate both data reads **and** ``Articulation`` write methods. Data properties are resolved lazily through proxies — no hardcoded class list is required. To produce LEAPP input annotations, the accessed data property getter must carry ``_leapp_semantics``. Likewise, action-side write methods must be annotated to produce semantic LEAPP outputs. Undecorated reads and writes are forwarded as normal runtime access, but they do not gain semantic annotation metadata through this patching path. State reads are deduplicated across observation and action paths via a shared cache, so a property like ``joint_pos`` that is read by both an observation term and a relative-position action term appears as a single LEAPP input edge. The underlying env, scene, assets, and tensors remain shared with the rest of the pipeline; only the manager call paths are redirected. Args: env: Manager-based environment to patch in place. export_method: LEAPP export backend passed to :func:`annotate.output_tensors`. required_obs_groups: Observation groups that should be patched, or ``None`` to patch all groups. """ patcher = ExportPatcher(export_method, required_obs_groups=required_obs_groups) patcher.setup(env)