AnonMLuser's picture
Anonymous artefact release
e0eb79a verified
Raw
History Blame Contribute Delete
9.05 kB
"""Shared fixtures for the smoke suite.
Everything here is CPU-only, seeded, and confined to pytest tmp directories.
No test reads a real dataset or checkpoint, and none touches the network.
"""
from __future__ import annotations
import importlib
import os
import pkgutil
import random
import subprocess
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
TINY_ENV = "MiniHack-Room-Random-5x5-v0"
CLI_TIMEOUT = 120
# Shrunken model, diffusion and loop settings. Keys mirror configs/defaults.yaml
# so the real loader and the real code paths are exercised unchanged.
TINY_OVERRIDES: dict = {
"id_envs": [TINY_ENV],
"ood_envs": [TINY_ENV],
"n_embd": 32,
"n_head": 2,
"n_layer": 1,
"seq_len": 8,
# replan_every must not exceed seq_len or rollouts index past the plan.
"replan_every": 8,
"num_diffusion_steps": 4,
"diffusion_steps_eval": 2,
"diffusion_steps_collect": 2,
"buffer_capacity": 32,
"dagger_batch_size": 4,
"offline_batch_size": 4,
"total_timesteps": 1,
"id_eval_every_timesteps": 10**9,
"ood_eval_every_timesteps": 10**9,
"checkpoint_every_timesteps": 10**9,
"episodes_per_iteration": 1,
"grad_steps_per_iteration": 1,
"eval_episodes_per_env": 1,
"checkpoint_eval_episodes": 1,
"num_collection_workers": 0,
"collect_episodes_per_env": 1,
"collect_num_workers": 1,
"use_wandb": False,
"save_policy": False,
"torch_compile": False,
"use_amp": False,
"device": "cpu",
"seed": 0,
# defaults.yaml is the paper recipe, and these four OVERRIDE the
# env-step-derived budget when non-null. Pinned back to null so the
# tiny total_timesteps above actually governs the offline run;
# inheriting the recipe would train 60000 grad steps and time out.
"offline_total_grad_steps": None,
"offline_eval_every_grad_steps": None,
"offline_checkpoint_every_grad_steps": None,
"offline_buffer_capacity": None,
}
# ── Optional dependency / hardware gates ─────────────────────────────
def _importable(name: str) -> bool:
try:
importlib.import_module(name)
return True
except Exception:
return False
def _cuda_available() -> bool:
try:
import torch
return torch.cuda.is_available()
except Exception:
return False
requires_minihack = pytest.mark.skipif(
not (_importable("minihack") and _importable("nle")),
reason=(
"MiniHack/NLE not installed - environment rollouts unavailable. "
"Install with `uv sync` to enable these tests."
),
)
requires_cuda = pytest.mark.skipif(
not _cuda_available(),
reason="No CUDA device available - GPU-only code path skipped.",
)
# ── Module discovery (used to parametrise the import tests) ──────────
def discover_modules(package: str) -> list[str]:
"""Return *package* and every importable submodule name beneath it."""
pkg = importlib.import_module(package)
names = [package]
names += [m.name for m in pkgutil.walk_packages(pkg.__path__, f"{package}.")]
return sorted(names)
# ── Global isolation ─────────────────────────────────────────────────
@pytest.fixture(scope="session", autouse=True)
def _isolate_side_effects(tmp_path_factory):
"""Redirect library scratch dirs into tmp and disable outbound calls."""
sandbox = tmp_path_factory.mktemp("env_isolation")
# Matplotlib spends ~15s rebuilding its font cache on a cold directory, so
# this one lives in a stable spot under the system temp dir rather than in
# the per-run sandbox. Still outside the repo and outside the user's home.
mpl_cache = Path(tempfile.gettempdir()) / "remdm-smoke-mplconfig"
mpl_cache.mkdir(parents=True, exist_ok=True)
os.environ["MPLCONFIGDIR"] = str(mpl_cache)
os.environ["WANDB_MODE"] = "disabled"
os.environ["WANDB_DIR"] = str(sandbox / "wandb")
os.environ["WANDB_SILENT"] = "true"
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["DEVICE"] = "cpu"
os.environ["CUDA_VISIBLE_DEVICES"] = ""
yield
@pytest.fixture(autouse=True)
def _seeded():
"""Fix every RNG before each test."""
import numpy as np
import torch
random.seed(0)
np.random.seed(0)
torch.manual_seed(0)
# ── Configs ──────────────────────────────────────────────────────────
@pytest.fixture(scope="session")
def real_cfg() -> SimpleNamespace:
"""The unmodified production config, forced onto CPU."""
from src.config import load_config
cfg = load_config("configs/defaults.yaml")
cfg.device = "cpu"
return cfg
@pytest.fixture
def tiny_cfg() -> SimpleNamespace:
"""Production config loaded for real, then shrunk to toy dimensions."""
from src.config import load_config
cfg = load_config("configs/defaults.yaml")
for key, value in TINY_OVERRIDES.items():
setattr(cfg, key, value)
return cfg
@pytest.fixture
def tiny_config_file(tmp_path: Path) -> Path:
"""Write the shrunken config to disk for CLI entry-point tests."""
payload = dict(TINY_OVERRIDES)
payload["checkpoint_dir"] = str(tmp_path / "checkpoints")
payload["collect_output"] = str(tmp_path / "dataset.pt")
payload["baselines_output_dir"] = str(tmp_path / "baselines")
path = tmp_path / "tiny.yaml"
path.write_text(yaml.safe_dump(payload))
return path
# ── Synthetic data ───────────────────────────────────────────────────
@pytest.fixture
def tiny_batch(tiny_cfg):
"""A synthetic model-input batch: (local_obs, global_obs, actions)."""
import torch
batch = 4
local = torch.randint(0, 1000, (batch, tiny_cfg.crop_size, tiny_cfg.crop_size))
glob = torch.randint(0, 1000, (batch, tiny_cfg.map_h, tiny_cfg.map_w))
actions = torch.randint(0, tiny_cfg.action_dim, (batch, tiny_cfg.seq_len))
return local.long(), glob.long(), actions.long()
@pytest.fixture
def tiny_trajectory(tiny_cfg) -> dict:
"""A synthetic oracle trajectory in the on-disk dataset format."""
import numpy as np
steps = 12
return {
"local": np.random.randint(
0, 1000, (steps, tiny_cfg.crop_size, tiny_cfg.crop_size), dtype=np.int16
),
"global": np.random.randint(
0, 1000, (steps, tiny_cfg.map_h, tiny_cfg.map_w), dtype=np.int16
),
"actions": np.random.randint(0, tiny_cfg.action_dim, (steps,), dtype=np.int64),
"env_id": TINY_ENV,
}
@pytest.fixture
def tiny_dataset_file(tmp_path: Path, tiny_trajectory) -> Path:
"""A two-trajectory dataset written in the format run_offline expects."""
import torch
path = tmp_path / "dataset.pt"
torch.save({"trajectories": [tiny_trajectory, tiny_trajectory]}, path)
return path
@pytest.fixture
def tiny_checkpoint_file(tmp_path: Path, tiny_cfg) -> Path:
"""A checkpoint of an untrained tiny model, in the production format."""
import torch
from src.models.denoiser import ModelEMA, make_model
model = make_model(tiny_cfg)
ema = ModelEMA(model, decay=tiny_cfg.ema_decay)
path = tmp_path / "tiny_checkpoint.pth"
torch.save(
{
"model_state_dict": model.state_dict(),
"ema_state_dict": ema.state_dict(),
},
path,
)
return path
# ── CLI helper ───────────────────────────────────────────────────────
def run_cli(*args: str, timeout: int = CLI_TIMEOUT) -> subprocess.CompletedProcess:
"""Run a project entry point in a CPU-only, offline subprocess."""
env = dict(os.environ)
env.update(
{
"DEVICE": "cpu",
"CUDA_VISIBLE_DEVICES": "",
"WANDB_MODE": "disabled",
"PYTHONWARNINGS": "ignore",
"PYTHONPATH": str(PROJECT_ROOT),
}
)
return subprocess.run(
[sys.executable, *args],
cwd=PROJECT_ROOT,
env=env,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def assert_cli_ok(result: subprocess.CompletedProcess) -> None:
"""Fail with the captured output when an entry point exits non-zero."""
if result.returncode != 0:
raise AssertionError(
f"exit code {result.returncode}\n"
f"--- stdout ---\n{result.stdout[-3000:]}\n"
f"--- stderr ---\n{result.stderr[-3000:]}"
)