Source code for protomotions.agents.supervised.expert_utils

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Expert observation utilities for supervised agents."""

from __future__ import annotations

import logging
from typing import Dict, Any, Optional, TYPE_CHECKING
import torch
from copy import deepcopy

from protomotions.envs.context_sources import SENSOR_SOURCES, source_of_path

if TYPE_CHECKING:
    from protomotions.envs.base_env.config import EnvConfig
    from protomotions.envs.sensors.config import SensorConfig

log = logging.getLogger(__name__)

# The prefix every expert-labelled observation key carries in the student env,
# so the student's own observations and the expert's never collide. One spelling.
EXPERT_OBS_PREFIX = "expert_"


[docs] def validate_expert_action_contract( expert_robot, expert_env, student_robot, student_env ): """Expert labels and executed actions share one joint order and raw-action meaning.""" if expert_robot.kinematic_info.dof_names != student_robot.kinematic_info.dof_names: raise ValueError("Expert and student must use the same ordered joints") if expert_robot.control != student_robot.control: raise ValueError("Expert and student must use the same controller parameters") source, target = expert_env.action_config, student_env.action_config if ( source is None or target is None or source.fn is not target.fn or source.params.keys() != target.params.keys() ): raise ValueError("Expert and student must use the same action config") for key, value in source.params.items(): other = target.params[key] if isinstance(value, torch.Tensor): matches = isinstance(other, torch.Tensor) and torch.equal( value.detach().cpu(), other.detach().cpu() ) else: matches = value == other if not matches: raise ValueError(f"Expert action config mismatch: {key}")
def _sensor_contract_detail( expert: Optional[SensorConfig], target: Optional[SensorConfig], *, noisy: bool ) -> Optional[str]: """Why two sensor configs are an incompatible contract for a source, or None. A clean source needs the same channels and, for localization, the same body and offset; noise is not read. A noisy source needs the whole channel equal, noise and drift included. Random seeds are never compared. """ if expert is None or target is None: return "one environment declares sensors and the other does not" expert_channels, target_channels = expert.channels(), target.channels() if expert_channels.keys() != target_channels.keys(): return f"channels {sorted(expert_channels)} != {sorted(target_channels)}" for channel_name, expert_channel in expert_channels.items(): target_channel = target_channels[channel_name] if noisy: if expert_channel != target_channel: return f"channel {channel_name!r} differs" elif channel_name == "localization": expert_placement = (expert_channel.body_name, tuple(expert_channel.offset)) target_placement = (target_channel.body_name, tuple(target_channel.offset)) if expert_placement != target_placement: return f"channel {channel_name!r} placement differs" return None
[docs] def validate_expert_sensor_contract( expert_env_config: EnvConfig, expert_agent_config: Any, target_sensors: Optional[SensorConfig], ) -> None: """Keep a frozen expert's sensor inputs unchanged in the student environment. Privileged bindings are independent of sensors. A clean estimate requires the same channels and their placement; a noisy estimate also requires the same noise and drift. Random seeds need not match: the contract describes measurements, not their draws. """ actor_keys = get_expert_actor_in_keys(expert_agent_config) expert_sensors = expert_env_config.sensors for name, component in expert_env_config.observation_components.items(): if name not in actor_keys: continue for path in component.dynamic_vars.values(): source = source_of_path(path.path) if source not in SENSOR_SOURCES: continue detail = _sensor_contract_detail( expert_sensors, target_sensors, noisy=source == "noisy" ) if detail is not None: raise ValueError( f"Expert observation {name!r} binds {path.path!r} with a " f"different sensor contract: {detail}" )
[docs] def get_expert_actor_in_keys(expert_agent_config: Any) -> list: """Input keys of the expert's actor, from model.actor.in_keys or model.in_keys.""" model_config = getattr(expert_agent_config, "model", None) actor_config = getattr(model_config, "actor", model_config) actor_in_keys = list(getattr(actor_config, "in_keys", None) or []) if not actor_in_keys: raise ValueError( "Expert agent config must define model.actor.in_keys or model.in_keys " "so supervised expert observations can be wired explicitly." ) return actor_in_keys
[docs] def get_expert_observation_keys( expert_env_config: EnvConfig, expert_agent_config: Any, prefix: str = EXPERT_OBS_PREFIX, ) -> list: """Get the prefixed observation keys that would be added for the expert.""" actor_in_keys = get_expert_actor_in_keys(expert_agent_config) return [ f"{prefix}{obs_key}" for obs_key in expert_env_config.observation_components.keys() if obs_key in actor_in_keys ]
[docs] def get_expert_observation_components( expert_env_config: EnvConfig, expert_agent_config: Any, existing_obs_keys: list = None, prefix: str = EXPERT_OBS_PREFIX, ) -> Dict[str, Any]: """Extract observation components needed by expert's actor with prefixed keys.""" expert_obs_components = {} actor_in_keys = get_expert_actor_in_keys(expert_agent_config) existing_keys_set = set(existing_obs_keys) if existing_obs_keys else set() for obs_key, obs_config in expert_env_config.observation_components.items(): if obs_key not in actor_in_keys: continue prefixed_key = f"{prefix}{obs_key}" if prefixed_key in existing_keys_set: raise ValueError( f"Expert observation key '{prefixed_key}' conflicts with existing observation." ) expert_obs_components[prefixed_key] = deepcopy(obs_config) log.info( f"Loaded {len(expert_obs_components)} expert observation components with prefix '{prefix}'" ) return expert_obs_components