Source code for protomotions.agents.base_agent.model

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

"""Base model interface for agent neural networks.

This module defines the abstract base class that all agent models must implement.
It provides a TensorDictModule interface for clean, compilable models.

Key Classes:
    - BaseModel: Abstract base class for all agent models (TensorDictModule)
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Callable

import torch
from tensordict import TensorDict
from tensordict.nn import TensorDictModuleBase
from protomotions.agents.base_agent.config import BaseModelConfig
from abc import abstractmethod


[docs] @dataclass(frozen=True) class RolloutStateSpec: """Declared model-owned per-env rollout context. A model declares the tensor shape, dtype, and initial-value distribution for per-env context that must be carried during rollout and replayed during optimization. The framework owns when context is initialized and which rows are reset after episode boundaries. """ shape: tuple[int, ...] = () init: str | Callable = "zeros" dtype: torch.dtype = torch.float32 def __post_init__(self): shape = self.shape if isinstance(shape, int): shape = (shape,) object.__setattr__(self, "shape", tuple(shape))
[docs] def make_initial_value(self, num_envs: int, device) -> torch.Tensor: shape = (num_envs, *self.shape) init = getattr(self.init, "value", self.init) if callable(init): value = init(num_envs, device) return value.to(device=device, dtype=self.dtype) if init in ("zeros", "zero"): return torch.zeros(shape, dtype=self.dtype, device=device) if init in ("normal", "randn"): return torch.randn(shape, dtype=self.dtype, device=device) if init in ("uniform", "rand"): return torch.rand(shape, dtype=self.dtype, device=device) raise NotImplementedError(f"Unsupported rollout state init: {self.init}")
[docs] class ProtoMotionsTensorDictModule(TensorDictModuleBase): """Common contract for ProtoMotions-owned TensorDict modules. Use this for modules that read and write named TensorDict keys and may own rollout state that must be saved in the experience buffer. Examples include policy/critic modules, normalizing observation processors, container modules, and models with rollout-local latent state. """
[docs] def forward( self, tensordict: TensorDict, log_internals: bool = False, ) -> TensorDict: """Forward pass for ProtoMotions TensorDict modules. ``log_internals`` is part of the common contract. Modules that produce diagnostics can honor it; stateless modules can ignore it. """ raise NotImplementedError
def _proto_children(self) -> list["ProtoMotionsTensorDictModule"]: """Nearest child modules that participate in the ProtoMotions contract.""" children = [] seen = set() def collect(module): for child in module.children(): if isinstance(child, ProtoMotionsTensorDictModule): child_id = id(child) if child_id not in seen: seen.add(child_id) children.append(child) else: collect(child) collect(self) return children
[docs] def rollout_state_specs(self) -> dict[str, RolloutStateSpec]: """Local model-owned per-env rollout state declarations.""" return {}
def _rollout_state_specs_recursive(self) -> dict[str, RolloutStateSpec]: """Collect local and child rollout-state declarations. Containers get child declarations automatically. If two modules claim the same TensorDict key, the declarations must be identical so reset and experience-buffer registration cannot disagree silently. """ specs = dict(self.rollout_state_specs()) for child in self._proto_children(): for key, child_spec in child._rollout_state_specs_recursive().items(): if key in specs and specs[key] != child_spec: raise ValueError( f"Conflicting rollout state spec for '{key}': " f"{specs[key]} vs {child_spec}" ) specs[key] = child_spec return specs
[docs] def reset_rollout_context( self, env_ids=None, num_envs: int = None, device=None ) -> None: """Initialize or reseed model-owned context carried across rollout steps. ``num_envs`` and ``device`` are supplied once during setup, after the simulator is built. Later calls pass only ``env_ids`` to reseed rows for environments that just ended. This method intentionally does not infer shape/device changes mid-run; rebuilding the simulator should call setup again and reinitialize explicitly. """ if num_envs is not None or device is not None: if num_envs is None or device is None: raise ValueError( "reset_rollout_context requires both num_envs and device " "when initializing rollout context." ) self._init_own_rollout_state(num_envs=num_envs, device=device) if env_ids is not None: self._reset_own_rollout_state(env_ids=env_ids) for child in self._proto_children(): child.reset_rollout_context( env_ids=env_ids, num_envs=num_envs, device=device, )
[docs] def init_rollout_state(self, num_envs: int, device) -> None: """Allocate declared rollout-context buffers for this module tree.""" self._init_own_rollout_state(num_envs=num_envs, device=device) for child in self._proto_children(): child.init_rollout_state(num_envs=num_envs, device=device)
def _init_own_rollout_state(self, num_envs: int, device) -> None: """Allocate this module's own declared rollout-context buffers.""" for key, spec in self.rollout_state_specs().items(): state = spec.make_initial_value(num_envs, torch.device(device)) if hasattr(self, key): setattr(self, key, state) else: self.register_buffer(key, state, persistent=False)
[docs] def reset_rollout_state(self, env_ids=None) -> None: """Reseed selected rows for this module tree's rollout context.""" self._reset_own_rollout_state(env_ids=env_ids) for child in self._proto_children(): child.reset_rollout_state(env_ids=env_ids)
def _reset_own_rollout_state(self, env_ids=None) -> None: """Reseed selected rows of this module's initialized rollout context.""" for key, spec in self.rollout_state_specs().items(): state = getattr(self, key, None) if not torch.is_tensor(state): raise RuntimeError(self._uninitialized_rollout_state_message(key)) rows = self._normalize_rollout_env_ids(env_ids, state) if rows.numel() == 0: continue state[rows] = spec.make_initial_value(rows.shape[0], state.device)
[docs] def read_rollout_state(self, tensordict: TensorDict) -> TensorDict: """Inject carried rollout context into ``tensordict`` when absent. Rollout forwards read from model-owned buffers; optimization forwards read replayed values already present in the batch. This helper makes the distinction explicit and avoids each model hand-writing buffer fallbacks. """ batch_size = tensordict.batch_size[0] for key in self.rollout_state_specs(): if key in tensordict.keys(): continue state = getattr(self, key, None) if not torch.is_tensor(state): raise RuntimeError(self._uninitialized_rollout_state_message(key)) if batch_size > state.shape[0]: raise RuntimeError( f"Rollout state '{key}' has {state.shape[0]} envs, but " f"TensorDict batch size is {batch_size}." ) tensordict[key] = state[:batch_size].clone() for child in self._proto_children(): child.read_rollout_state(tensordict) return tensordict
@staticmethod def _normalize_rollout_env_ids(env_ids, state: torch.Tensor) -> torch.Tensor: """Return row indices on the rollout-state device for reset writes.""" if env_ids is None: return torch.arange(state.shape[0], device=state.device, dtype=torch.long) if isinstance(env_ids, list): return torch.tensor(env_ids, device=state.device, dtype=torch.long) return env_ids.to(device=state.device, dtype=torch.long) @staticmethod def _uninitialized_rollout_state_message(key: str) -> str: return ( f"Rollout state '{key}' is not initialized. Call " "reset_rollout_context(num_envs=..., device=...) first." )
[docs] def rollout_context_keys(self) -> list: """Module-owned state that must be stored from rollout and replayed.""" keys = list(self.rollout_state_specs().keys()) for child in self._proto_children(): keys.extend(child.rollout_context_keys()) return list(dict.fromkeys(keys))
[docs] def experience_buffer_keys(self) -> list: """Keys produced during rollout that the experience buffer must store.""" return list(dict.fromkeys(self.out_keys + self.rollout_context_keys()))
[docs] def compute_model_loss( self, tensordict: TensorDict, current_epoch: int, zero_loss, log_prefix: str = "model", ): """Optional module-owned auxiliary loss for agent optimization loops. Most modules do not own an auxiliary loss. Models that do, such as a VAE-backed policy head, override this and return ``(loss, log_dict)``. """ loss = zero_loss * 0.0 log_dict = {} for child in self._proto_children(): child_loss, child_log_dict = child.compute_model_loss( tensordict, current_epoch=current_epoch, zero_loss=zero_loss, log_prefix=log_prefix, ) loss = loss + child_loss log_dict.update(child_log_dict) return loss, log_dict
[docs] class BaseModel(ProtoMotionsTensorDictModule): """Base class for all agent models. All models are TensorDictModules with a single forward method that processes observations and returns all model outputs in a TensorDict. Args: config: Model configuration with architecture parameters. Attributes: config: Stored configuration for the model. in_keys: Input keys for TensorDict (set by subclasses). out_keys: Output keys for TensorDict (default: ["action"]). """
[docs] def __init__(self, config: BaseModelConfig): super().__init__() self.config = config # Default output keys (subclasses can override) self.out_keys = ["action"] # in_keys will be set by subclasses based on their architecture self.in_keys = []
[docs] def optimization_module(self): """Module whose parameters should be optimized by the owning agent.""" return self
[docs] def materialize_from_state_dict(self, state_dict: dict) -> None: """Create lazily-owned modules needed for strict state-dict loading.""" pass
[docs] def materialize(self, tensordict: TensorDict) -> TensorDict: """Run the setup-time pass used to create lazy module parameters.""" return self(tensordict)
[docs] @abstractmethod def forward( self, tensordict: TensorDict, log_internals: bool = False, ) -> TensorDict: """Forward pass through the model. Args: tensordict: TensorDict containing observations. Returns: TensorDict with model outputs added. """ pass