"""End-to-end smoke tests for the ``experiments/rl_finetuning/`` pipeline. The RL fine-tuning suite reuses the ``src/`` denoiser but layers its own config merge, 25-entry ablation registry, reward model and optimizer factories on top. Those are what this file exercises. """ from __future__ import annotations import copy import importlib from types import SimpleNamespace import pytest import torch import yaml from tests.conftest import ( PROJECT_ROOT, TINY_OVERRIDES, assert_cli_ok, discover_modules, run_cli, ) EXPERIMENT_MODULES = discover_modules("experiments") RUN_ABLATIONS = "experiments/rl_finetuning/run_ablations.py" ABLATION_CONFIGS = PROJECT_ROOT / "experiments" / "rl_finetuning" / "configs" # ── Config ─────────────────────────────────────────────────────────── @pytest.fixture def abl_cfg() -> SimpleNamespace: """Mirror run_ablations' merge order, then shrink to toy dimensions.""" from src.diffusion.schedules import get_schedule merged: dict = {} for path in ( PROJECT_ROOT / "configs" / "defaults.yaml", ABLATION_CONFIGS / "ablations_default.yaml", ABLATION_CONFIGS / "ablations_fast.yaml", ): merged.update(yaml.safe_load(path.read_text()) or {}) merged.update(TINY_OVERRIDES) merged.update( { "batch_size": 4, "max_iter": 1, "grad_steps_per_iter": 1, "episodes_per_iter": 1, "eval_episodes": 1, "lr": 3e-4, } ) cfg = SimpleNamespace(**merged) cfg._schedule_fn = get_schedule(cfg.noise_schedule) return cfg @pytest.fixture def abl_batch(abl_cfg): """Synthetic RL fine-tuning batch: obs, clean actions and advantages.""" batch = abl_cfg.batch_size return { "local_obs": torch.randint( 0, 1000, (batch, abl_cfg.crop_size, abl_cfg.crop_size) ).long(), "global_obs": torch.randint( 0, 1000, (batch, abl_cfg.map_h, abl_cfg.map_w) ).long(), "x0": torch.randint( 0, abl_cfg.action_dim, (batch, abl_cfg.seq_len) ).long(), "advantages": torch.rand(batch), } # ── 1. Imports ─────────────────────────────────────────────────────── def test_experiment_module_list_is_not_empty(): assert len(EXPERIMENT_MODULES) > 10, EXPERIMENT_MODULES @pytest.mark.parametrize("module_name", EXPERIMENT_MODULES) def test_experiment_module_imports_cleanly(module_name): importlib.import_module(module_name) # ── 2. Registry and model instantiation ────────────────────────────── def test_registry_is_well_formed(): from experiments.rl_finetuning.ablations.registry import REGISTRY assert len(REGISTRY) == 26 for name, spec in REGISTRY.items(): assert spec.name == name assert callable(spec.loss_factory), name assert callable(spec.optimizer_factory), name assert spec.group in {"Baseline", "A", "B", "C", "D"}, name def test_reward_model_forward(abl_cfg): from experiments.rl_finetuning.ablations.training import RewardModel model = RewardModel(obs_dim=16, width=8, depth=2).eval() with torch.no_grad(): out = model(torch.randn(5, 16)) assert out.shape == (5,) assert out.dtype is torch.float32 assert torch.isfinite(out).all() def test_reward_model_training_step(): from experiments.rl_finetuning.ablations.training import RewardModel model = RewardModel(obs_dim=16, width=8, depth=2) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3) loss = torch.nn.functional.mse_loss(model(torch.randn(5, 16)), torch.randn(5)) loss.backward() optimizer.step() assert torch.isfinite(loss) def test_mixed_replay_buffer_roundtrip(abl_cfg): from experiments.rl_finetuning.ablations.training import MixedReplayBuffer buffer = MixedReplayBuffer( capacity=8, seq_len=abl_cfg.seq_len, device=torch.device("cpu") ) buffer.push( torch.zeros(4, 9, 9, dtype=torch.long), torch.zeros(4, 21, 79, dtype=torch.long), torch.zeros(4, abl_cfg.seq_len, dtype=torch.long), torch.ones(4), ) assert buffer.size == 4 local, glob, x0, returns = buffer.sample(3) assert local.shape == (3, 9, 9) assert glob.shape == (3, 21, 79) assert x0.shape == (3, abl_cfg.seq_len) assert torch.isfinite(returns).all() def _push_marked(buffer, seq_len, values): """Push one window per entry of *values*, tagged by its return.""" n = len(values) marks = torch.tensor(values, dtype=torch.float32) buffer.push( torch.zeros(n, 9, 9, dtype=torch.long), torch.zeros(n, 21, 79, dtype=torch.long), torch.zeros(n, seq_len, dtype=torch.long), marks, ) def test_mixed_replay_buffer_wraps_without_losing_rows(abl_cfg): """A push that straddles the ring boundary keeps the newest windows.""" from experiments.rl_finetuning.ablations.training import MixedReplayBuffer buffer = MixedReplayBuffer( capacity=8, seq_len=abl_cfg.seq_len, device=torch.device("cpu") ) _push_marked(buffer, abl_cfg.seq_len, list(range(6))) _push_marked(buffer, abl_cfg.seq_len, list(range(6, 12))) assert buffer.size == 8 held = sorted(buffer._returns.tolist()) assert held == [4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0] def test_mixed_replay_buffer_survives_a_push_larger_than_itself(abl_cfg): """One iteration can collect more windows than the buffer holds. Under ``--fast`` the buffer is 500 windows and a single iteration collected 1,061, which raised ``RuntimeError: The expanded size of the tensor (500) must match the existing size (561)`` and the suite silently skipped the ablation. """ from experiments.rl_finetuning.ablations.training import MixedReplayBuffer buffer = MixedReplayBuffer( capacity=8, seq_len=abl_cfg.seq_len, device=torch.device("cpu") ) _push_marked(buffer, abl_cfg.seq_len, list(range(20))) assert buffer.size == 8 held = sorted(buffer._returns.tolist()) assert held == [12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0], ( "an oversized push must leave the most recent `capacity` windows" ) local, glob, x0, returns = buffer.sample(4) assert local.shape == (4, 9, 9) assert torch.isfinite(returns).all() def test_mixed_replay_buffer_handles_an_oversized_push_after_a_partial_fill(abl_cfg): """The overflow path is also correct when the write index is not 0.""" from experiments.rl_finetuning.ablations.training import MixedReplayBuffer buffer = MixedReplayBuffer( capacity=8, seq_len=abl_cfg.seq_len, device=torch.device("cpu") ) _push_marked(buffer, abl_cfg.seq_len, [100.0, 101.0, 102.0]) _push_marked(buffer, abl_cfg.seq_len, list(range(20))) assert buffer.size == 8 held = sorted(buffer._returns.tolist()) assert held == [12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0] def test_compute_advantages_is_finite(): from experiments.rl_finetuning.ablations.training import compute_advantages returns = torch.tensor([0.0, 1.0, 5.0, -2.0]) adv, mean, std = compute_advantages( returns, floor=0.1, cap=5.0, wins_only=False, win_thresh=0.5, use_running_stats=False, ema_decay=0.99, running_mean=0.0, running_std=1.0, ) assert adv.shape == returns.shape assert torch.isfinite(adv).all() assert all(map(torch.isfinite, (torch.tensor(mean), torch.tensor(std)))) # ── 3 & 4. Forward pass and one training step, per ablation ────────── def _build_ablation(spec, cfg): """Reproduce run_ablation's model/loss/optimizer wiring, minus rollouts.""" from experiments.rl_finetuning.ablations.losses import LossContext from experiments.rl_finetuning.ablations.optimizers import ( apply_lora_to_model, make_optimizer_lora, ) from src.models.denoiser import make_model model = make_model(cfg) ref_model = copy.deepcopy(model).eval() for param in ref_model.parameters(): param.requires_grad = False extra = {} if spec.name == "ewc": extra["fisher"] = { name: torch.ones_like(param) for name, param in model.named_parameters() } if spec.use_lora: lora_params = apply_lora_to_model( model, getattr(cfg, "lora_rank", 8), getattr(cfg, "lora_alpha", 16.0), ) optimizer = make_optimizer_lora(cfg, lora_params) else: optimizer = spec.optimizer_factory(cfg, model) ctx = LossContext( ref_model=ref_model, schedule_fn=cfg._schedule_fn, cfg=cfg ) return model, spec.loss_factory(ctx, **extra), optimizer def _registry_names(): from experiments.rl_finetuning.ablations.registry import REGISTRY return sorted(REGISTRY) @pytest.mark.parametrize("ablation_name", _registry_names()) def test_ablation_training_step_is_finite(ablation_name, abl_cfg, abl_batch): from experiments.rl_finetuning.ablations.registry import REGISTRY spec = REGISTRY[ablation_name] model, loss_fn, optimizer = _build_ablation(spec, abl_cfg) model.train() optimizer.zero_grad() loss = loss_fn( model, abl_batch["local_obs"], abl_batch["global_obs"], abl_batch["x0"], abl_batch["advantages"], abl_cfg, torch.device("cpu"), ) loss.backward() optimizer.step() assert loss.ndim == 0 assert loss.dtype is torch.float32 assert torch.isfinite(loss), f"{ablation_name} produced {loss.item()}" for name, param in model.named_parameters(): if param.grad is not None: assert torch.isfinite(param.grad).all(), f"{ablation_name}/{name}" def test_ablation_forward_pass_shapes(abl_cfg, abl_batch): from experiments.rl_finetuning.ablations.losses import _forward_and_loss from src.models.denoiser import make_model model = make_model(abl_cfg).eval() per_sample, aux, logits, zt, t_discrete = _forward_and_loss( model, abl_batch["local_obs"], abl_batch["global_obs"], abl_batch["x0"], abl_cfg, torch.device("cpu"), ) batch = abl_cfg.batch_size assert per_sample.shape == (batch,) assert logits.shape == (batch, abl_cfg.seq_len, abl_cfg.action_dim) assert logits.dtype is torch.float32 assert zt.shape == (batch, abl_cfg.seq_len) assert t_discrete.shape == (batch,) assert torch.isfinite(per_sample).all() assert torch.isfinite(aux) assert torch.isfinite(logits).all() # ── 5. Save and reload ─────────────────────────────────────────────── def test_finetuned_checkpoint_roundtrip_preserves_output( abl_cfg, abl_batch, tmp_path ): """Round-trip through the format run_ablation loads (``ema_state_dict``).""" from src.models.denoiser import ModelEMA, make_model model = make_model(abl_cfg).eval() ema = ModelEMA(model, decay=getattr(abl_cfg, "ema_decay", 0.999)) t = torch.zeros(abl_cfg.batch_size, dtype=torch.long) eval_model = ema.make_eval_model(model) with torch.no_grad(): before = eval_model( abl_batch["local_obs"], abl_batch["global_obs"], abl_batch["x0"], t )["actions"] path = tmp_path / "finetuned.pth" torch.save({"ema_state_dict": ema.state_dict()}, path) reloaded = make_model(abl_cfg) ckpt = torch.load(path, map_location="cpu", weights_only=False) reloaded.load_state_dict(ckpt["ema_state_dict"]) reloaded.eval() with torch.no_grad(): after = reloaded( abl_batch["local_obs"], abl_batch["global_obs"], abl_batch["x0"], t )["actions"] assert torch.equal(before, after) def test_lora_weights_roundtrip(abl_cfg, abl_batch, tmp_path): from experiments.rl_finetuning.ablations.optimizers import apply_lora_to_model from src.models.denoiser import make_model model = make_model(abl_cfg) apply_lora_to_model( model, getattr(abl_cfg, "lora_rank", 8), getattr(abl_cfg, "lora_alpha", 16.0) ) model.eval() t = torch.zeros(abl_cfg.batch_size, dtype=torch.long) with torch.no_grad(): before = model( abl_batch["local_obs"], abl_batch["global_obs"], abl_batch["x0"], t )["actions"] path = tmp_path / "lora.pth" torch.save(model.state_dict(), path) reloaded = make_model(abl_cfg) apply_lora_to_model( reloaded, getattr(abl_cfg, "lora_rank", 8), getattr(abl_cfg, "lora_alpha", 16.0), ) reloaded.load_state_dict(torch.load(path, map_location="cpu", weights_only=False)) reloaded.eval() with torch.no_grad(): after = reloaded( abl_batch["local_obs"], abl_batch["global_obs"], abl_batch["x0"], t )["actions"] assert torch.equal(before, after) # ── 6. Entry points ────────────────────────────────────────────────── def test_run_ablations_help(): result = run_cli(RUN_ABLATIONS, "--help") assert_cli_ok(result) assert "--ablations" in result.stdout def test_run_ablations_exposes_the_gdelta_measurement(): """The gradient measurement is a mode of the suite, not its own script, so its results land in a run directory and get published with the rest. """ result = run_cli(RUN_ABLATIONS, "--help") assert_cli_ok(result) for flag in ( "--measure-gdelta", "--gdelta-seeds", "--gdelta-draws", "--gdelta-inputs", ): assert flag in result.stdout def test_measure_gdelta_errors_cleanly_without_a_checkpoint(tmp_path): """No checkpoint resolves -> a usage error, not a traceback.""" result = run_cli( RUN_ABLATIONS, "--measure-gdelta", "--output-dir", str(tmp_path / "out"), ) assert result.returncode != 0 combined = result.stdout + result.stderr assert "--checkpoint is required for --measure-gdelta" in combined assert "Traceback" not in combined def test_run_ablations_list(): from experiments.rl_finetuning.ablations.registry import REGISTRY result = run_cli(RUN_ABLATIONS, "--list") assert_cli_ok(result) for name in REGISTRY: assert name in result.stdout def test_run_ablations_requires_a_checkpoint(tmp_path): result = run_cli( RUN_ABLATIONS, "--ablations", "baseline_rl", "--fast", "--output-dir", str(tmp_path / "out"), ) assert result.returncode != 0 assert "checkpoint" in (result.stdout + result.stderr).lower() def test_run_ablations_rejects_unknown_ablation(tmp_path, tiny_checkpoint_file): result = run_cli( RUN_ABLATIONS, "--ablations", "not-an-ablation", "--checkpoint", str(tiny_checkpoint_file), "--output-dir", str(tmp_path / "out"), ) assert result.returncode != 0 assert "unknown ablation" in (result.stdout + result.stderr).lower()