remdm-planner-minihack / tests /test_method_spec_gaps.py
AnonMLuser's picture
Anonymous artefact release
e0eb79a verified
Raw
History Blame Contribute Delete
7.19 kB
"""Literature-anchored specification tests — step-8 gap closure.
Complements tests/test_method_spec.py with the surfaces the step-8 audit
found untested in this repo: the reverse-posterior/remasking/carry-over
chain through remdm_sample, the final greedy cleanup, decode temperature
and label smoothing. Every expected value derives from a cited source or
a derivation written in the docstring — never from the current output of
this or the sibling repo. The craftax twin file carries the same
assertions with the same inputs and tolerances.
References as in tests/test_method_spec.py (MDLM arXiv:2406.07524;
ReMDM arXiv:2503.00307; Holtzman arXiv:1904.09751).
"""
from __future__ import annotations
import math
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from torch import nn
from src.diffusion.loss import mdlm_loss
from src.diffusion.sampling import remdm_sample
from src.diffusion.schedules import linear_schedule, linear_schedule_deriv
class _TimeCodedModel(nn.Module):
"""Stub whose argmax token encodes the decode time bin.
token 0 for t_discrete > 90, token 1 for 50 < t_discrete <= 90,
token 2 otherwise (num_diffusion_steps = 100 in the cfg below).
Logit margin 30 makes categorical sampling deterministic to ~1e-13.
"""
def __init__(self, seq_len: int, vocab: int):
super().__init__()
self.seq_len = seq_len
self.vocab = vocab
def forward(self, local_obs, global_obs, seq, t_discrete):
td = int(t_discrete[0])
idx = 0 if td > 90 else (1 if td > 50 else 2)
logits = torch.full((seq.shape[0], self.seq_len, self.vocab), -30.0)
logits[:, :, idx] = 30.0
return {"actions": logits, "goal_pred": torch.zeros(seq.shape[0], 2)}
def _chain_cfg(eta: float) -> SimpleNamespace:
return SimpleNamespace(
seq_len=32, mask_token=3, action_dim=3, diffusion_steps_eval=3,
temperature=1.0, top_p=1.0, eta=eta, remask_strategy="rescale",
noise_schedule="linear", num_diffusion_steps=100,
)
@pytest.mark.parametrize("eta", [0.0, 0.5])
def test_posterior_remask_carryover_token_distribution(eta):
"""Final token distribution matches the ReMDM Alg 1 posterior chain.
Source: ReMDM eq (6) masked branch, eq (7) sigma_max, Sec 4.1
rescale sigma = eta * sigma_max, MDLM Sec 3.2.3 carry-over.
Derivation (linear schedule, K=3, grid t=1,2/3,1/3 with s=2/3,1/3,0;
the stub decodes token 0 at t=1, token 1 at t=2/3, token 2 at
t<=1/3):
step 1 (t=1): alpha_t=0, alpha_s=1/3, p_unmask=1/3 -> token 0.
step 2 (t=2/3): alpha_t=1/3, alpha_s=2/3, sigma_max=1, sigma=eta;
committed remask w.p. eta; masked unmask w.p.
(2/3-(1-eta)/3)/(2/3) = (1+eta)/2 -> token 1.
step 3 (t=1/3): alpha_s=1 -> sigma_max=0 (no remask), p_unmask=1
-> every remaining mask becomes token 2.
Hence P(0) = (1-eta)/3, P(1) = (1+eta)/3, P(2) = 1/3.
eta=0 additionally pins carry-over (sigma=0: committed tokens are
never re-decided). Same derivation and bound as the craftax twin:
N = 512*32 = 16384; max sigma = 0.0039; bound 0.02 = 5.1 sigma.
"""
B, L = 512, 32
cfg = _chain_cfg(eta)
model = _TimeCodedModel(L, 4)
seq = remdm_sample(
model, torch.zeros(B, 9, 9), torch.zeros(B, 21, 79), cfg, "cpu",
physics_aware=False,
)
freq = np.bincount(seq.numpy().ravel(), minlength=4) / (B * L)
expected = np.array([(1 - eta) / 3, (1 + eta) / 3, 1 / 3, 0.0])
assert np.all(np.abs(freq - expected) < 0.02), (freq, expected)
def test_final_cleanup_commits_all_remaining_masks():
"""With zero denoising steps, the final greedy cleanup decodes every
position at t=0 (argmax), leaving no MASK token.
Source: spec-method 4.9 (final-step commit of remaining masks; the
craftax twin asserts the same). The stub decodes token 2 at t=0,
so the output must be all-2.
"""
B, L = 8, 16
cfg = _chain_cfg(0.0)
cfg.seq_len = L
seq = remdm_sample(
_TimeCodedModel(L, 4), torch.zeros(B, 9, 9), torch.zeros(B, 21, 79),
cfg, "cpu", physics_aware=False, num_steps=0,
)
assert (seq.numpy() == 2).all()
class _FixedLogitsModel(nn.Module):
"""Stub returning fixed logits [0, 1] over two real actions."""
def __init__(self, seq_len: int):
super().__init__()
self.seq_len = seq_len
def forward(self, local_obs, global_obs, seq, t_discrete):
logits = torch.zeros(seq.shape[0], self.seq_len, 3)
logits[:, :, 1] = 1.0
logits[:, :, 2] = 0.0 # masked out by the sampler (>= action_dim)
return {"actions": logits, "goal_pred": torch.zeros(seq.shape[0], 2)}
def test_decode_temperature_scales_logits_before_sampling():
"""Sampling frequencies follow softmax(logits / temperature).
Source: spec-method 5.2. Derivation: logits [0, 1] at temperature
0.5 give softmax([0, 2]) = [0.1192, 0.8808] over the two real
actions; a single denoising step (K=1: t=1, s=0, p_unmask=1)
commits every position in one draw. Statistical: 8192 draws;
sigma = sqrt(0.8808*0.1192/8192) = 0.00358; bound 0.0143 = 4 sigma.
Same numbers as the craftax twin.
"""
B, L = 256, 32 # 8192 positions
cfg = SimpleNamespace(
seq_len=L, mask_token=2, action_dim=2, diffusion_steps_eval=1,
temperature=0.5, top_p=1.0, eta=0.0, remask_strategy="rescale",
noise_schedule="linear", num_diffusion_steps=100,
)
seq = remdm_sample(
_FixedLogitsModel(L), torch.zeros(B, 9, 9), torch.zeros(B, 21, 79),
cfg, "cpu", physics_aware=False,
)
p1 = math.exp(2) / (1 + math.exp(2))
assert abs(seq.float().mean().item() - p1) < 0.0143
def test_label_smoothing_matches_closed_form():
"""Smoothed CE = -[(1-eps)+eps/V] log p_true - (eps/V) sum log p_other.
Source: spec-method 3.7 (smoothing target (1-eps)*onehot + eps/V,
which is exactly torch.nn.functional.cross_entropy's label_smoothing
semantics; eps=0 is the exact ELBO). Derivation: model probs
[0.7,0.1,0.1,0.1], true class 0, V=4, eps=0.3: coefficient on
-log 0.7 is (1-0.3)+0.3/4 = 0.775; each other class gets 0.075.
t pinned to 1 (linear, w=1, everything masked) makes the loss equal
that CE exactly. Same inputs and expectations as the craftax twin.
"""
B, L, V = 4, 8, 4
logits = torch.log(torch.tensor([0.7, 0.1, 0.1, 0.1])).expand(B, L, V).clone()
x0 = torch.zeros(B, L, dtype=torch.long)
zt = torch.full((B, L), 5, dtype=torch.long) # mask_token=5, pad=6
t = torch.ones(B)
expected = -0.775 * math.log(0.7) - 0.075 * 3 * math.log(0.1)
got = mdlm_loss(
logits, x0, zt, t, mask_token=5, pad_token=6,
schedule_fn=linear_schedule, schedule_deriv_fn=linear_schedule_deriv,
label_smoothing=0.3,
)
assert abs(float(got) - expected) < 1e-5
got0 = mdlm_loss(
logits, x0, zt, t, mask_token=5, pad_token=6,
schedule_fn=linear_schedule, schedule_deriv_fn=linear_schedule_deriv,
label_smoothing=0.0,
)
assert abs(float(got0) - (-math.log(0.7))) < 1e-5