"""Generic ego-checkpoint loader for live submissions. Pipeline: 1. User uploads a tar.gz/zip with a saved_train_run/ directory inside (orbax checkpoint format produced by common.save_load_utils.save_train_run). 2. We extract to a temp dir. 3. The user specifies actor_type (mlp/s5/rnn) and arch params; we build a policy network via agents.initialize_agents on the right env. 4. We load checkpoint params via common.save_load_utils.load_checkpoints. 5. Return a (obs, state, rng) -> int callable that the eval pipeline can use as ego_fn. Cleanup of temp dirs is the caller's responsibility (we yield the path and let the route delete after eval). """ from __future__ import annotations import logging import os import shutil import tarfile import tempfile import zipfile from dataclasses import dataclass from pathlib import Path from typing import Any, Callable log = logging.getLogger(__name__) @dataclass class UploadedCheckpoint: """Parsed metadata + extracted location of a user-uploaded ego ckpt.""" extracted_dir: Path saved_train_run_path: Path actor_type: str arch_params: dict[str, Any] ckpt_key: str idx: int class CheckpointParseError(ValueError): pass def extract_archive(archive_bytes: bytes, dest_root: Path | None = None) -> Path: """Extract a tar.gz or zip into a fresh temp dir, return path.""" dest = Path(tempfile.mkdtemp(prefix="bm_ego_", dir=str(dest_root) if dest_root else None)) log.info("extracting upload to %s", dest) sniff = archive_bytes[:8] is_zip = sniff[:4] == b"PK\x03\x04" or sniff[:4] == b"PK\x05\x06" is_tar_gz = sniff[:2] == b"\x1f\x8b" is_tar = sniff[257:262] == b"ustar" if len(archive_bytes) > 262 else False archive_path = dest / "_upload.bin" archive_path.write_bytes(archive_bytes) if is_zip: with zipfile.ZipFile(archive_path) as zf: zf.extractall(dest) elif is_tar_gz or is_tar: with tarfile.open(archive_path) as tf: tf.extractall(dest) else: raise CheckpointParseError( "upload must be .zip or .tar.gz with a saved_train_run/ directory inside" ) archive_path.unlink(missing_ok=True) return dest def find_saved_train_run(extracted_dir: Path) -> Path: """Locate the saved_train_run/ directory in an extracted archive.""" candidate = extracted_dir / "saved_train_run" if candidate.is_dir(): return candidate for sub in extracted_dir.rglob("saved_train_run"): if sub.is_dir(): return sub raise CheckpointParseError( "no saved_train_run/ directory found in upload. " "expected the orbax checkpoint format from common.save_load_utils.save_train_run" ) def parse_upload( archive_bytes: bytes, actor_type: str, arch_params: dict[str, Any] | None = None, ckpt_key: str = "final_params", idx: int = 0, ) -> UploadedCheckpoint: """Extract the upload + validate it has the expected structure.""" extracted = extract_archive(archive_bytes) saved_path = find_saved_train_run(extracted) return UploadedCheckpoint( extracted_dir=extracted, saved_train_run_path=saved_path, actor_type=actor_type, arch_params=dict(arch_params or {}), ckpt_key=ckpt_key, idx=idx, ) def cleanup_upload(uploaded: UploadedCheckpoint) -> None: """Best-effort cleanup of the temp extraction dir.""" try: shutil.rmtree(uploaded.extracted_dir) except OSError as exc: log.warning("failed to clean up %s: %s", uploaded.extracted_dir, exc) def build_ego_fn(env_name: str, env, uploaded: UploadedCheckpoint) -> Callable: """Construct (obs, state, rng) -> int from an extracted ckpt. Delegates to env adapter's load_ego_checkpoint if defined, otherwise uses the generic policy-class dispatch in agents.initialize_agents. """ from .envs import get as get_env adapter = get_env(env_name) custom_loader = getattr(adapter, "load_ego_checkpoint", None) if callable(custom_loader): return custom_loader( saved_train_run_path=str(uploaded.saved_train_run_path), actor_type=uploaded.actor_type, arch_params=uploaded.arch_params, ckpt_key=uploaded.ckpt_key, idx=uploaded.idx, env=env, ) return _generic_load_ego(env, uploaded) def _generic_load_ego(env, uploaded: UploadedCheckpoint) -> Callable: """Default ego loader: constructs policy via agents.initialize_agents based on actor_type, then loads weights via common.save_load_utils. Used when the env adapter doesn't override load_ego_checkpoint. """ import jax from agents.initialize_agents import ( initialize_s5_agent, initialize_mlp_agent, initialize_rnn_agent, ) from common.save_load_utils import load_checkpoints actor_type = uploaded.actor_type.lower() cfg = dict(uploaded.arch_params) # Some envs (Hanabi) expose observation_space.shape as a scalar int instead # of a tuple. initialize_*_agent does `.shape[0]` which errors on scalar shapes, # AND dict.get evaluates the default eagerly so injecting POLICY_INPUT_DIM # doesn't bypass the broken expression. Patch the Box's shape to a tuple # in-place so the lookup succeeds for both branches. import numpy as np sp = env.observation_space(env.agents[0]) shape = getattr(sp, "shape", None) if shape is not None and not isinstance(shape, tuple): try: sp.shape = (int(shape),) if np.isscalar(shape) else tuple(int(x) for x in np.atleast_1d(shape)) except Exception: cfg.setdefault("POLICY_INPUT_DIM", int(shape) if np.isscalar(shape) else int(np.prod(shape))) rng = jax.random.PRNGKey(0) if actor_type == "s5": policy, _ = initialize_s5_agent(cfg, env, rng) elif actor_type == "mlp": policy, _ = initialize_mlp_agent(cfg, env, rng) elif actor_type == "rnn": policy, _ = initialize_rnn_agent(cfg, env, rng) else: raise CheckpointParseError( f"actor_type '{actor_type}' not supported by generic loader. " "supported: mlp, s5, rnn. for custom architectures, the env adapter " "must implement load_ego_checkpoint." ) params = load_checkpoints( str(uploaded.saved_train_run_path), ckpt_key=uploaded.ckpt_key, ) if isinstance(params, list): params = params[uploaded.idx] elif hasattr(params, "shape") or isinstance(params, dict): try: params = jax.tree.map(lambda x: x[uploaded.idx], params) except Exception: pass hstate_holder = [None] def ego_fn(obs, state, rng): import jax.numpy as jnp if hstate_holder[0] is None: try: hstate_holder[0] = policy.init_hstate(1, aux_info={"agent_id": 0}) except Exception: hstate_holder[0] = None if isinstance(obs, dict): agent_obs = obs["agent_0"] if "agent_0" in obs else next(iter(obs.values())) else: agent_obs = obs try: avail_dim = int(env.action_space(env.agents[0]).n) except Exception: avail_dim = 21 action, new_hstate = policy.get_action_value_policy( params=params, obs=jnp.asarray(agent_obs).reshape(1, 1, -1), done=jnp.zeros((1, 1), dtype=bool), avail_actions=jnp.ones((1, 1, avail_dim), dtype=jnp.float32), hstate=hstate_holder[0], rng=rng, )[:2] if hasattr(policy, "get_action_value_policy") else (None, None) if action is None: try: action, new_hstate = policy.get_action( params=params, obs=agent_obs, done=jnp.array(False), avail_actions=jnp.ones((avail_dim,), dtype=jnp.float32), hstate=hstate_holder[0], rng=rng, env_state=state, ) except Exception: raise hstate_holder[0] = new_hstate return int(jnp.asarray(action).reshape(-1)[0]) return ego_fn