File size: 11,286 Bytes
038acee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 | """Literature-anchored specification tests.
Every expected value here is derived from a primary source (cited
per test; full references below) or from a derivation written out in
the docstring - never from the current output of this or the sibling repo. The minihack repo
carries the same assertions with the same inputs and tolerances wherever
the mathematics is parameter-free and shared.
Tolerances: closed-form checks use atol=1e-6 (an order of magnitude above
float32 round-off, which the parity probes measured at <4e-8 on these
functions). Statistical checks state their sampling distribution and use a
4-sigma bound (or the stated multiple) with the derivation in the docstring.
References:
- MDLM: Sahoo et al., "Simple and Effective Masked Diffusion Language
Models", NeurIPS 2024. arXiv:2406.07524.
- Shi: Shi et al., "Simplified and Generalized Masked Diffusion for
Discrete Data", NeurIPS 2024. arXiv:2406.04329.
- ReMDM: Wang et al., "Remasking Discrete Diffusion Models with
Inference-Time Scaling", NeurIPS 2025. arXiv:2503.00307.
- Holtzman et al., "The Curious Case of Neural Text Degeneration",
ICLR 2020. arXiv:1904.09751.
"""
from __future__ import annotations
import math
import jax
import jax.numpy as jnp
import numpy as np
from src.diffusion.forward import forward_process
from src.diffusion.loss import compute_loss
from src.diffusion.sampling import (
_nucleus_sample,
_sigma_max,
sample_plan_inpainting,
sigma_cap,
sigma_conf,
sigma_rescale,
)
from src.diffusion.schedules import (
SCHEDULE_MAP,
cosine_schedule,
cosine_schedule_deriv,
linear_schedule,
linear_schedule_deriv,
)
ATOL = 1e-6
T_GRID = jnp.array([0.0, 1.0 / 3.0, 0.5, 2.0 / 3.0, 1.0])
# ---------------------------------------------------------------------------
# Noise schedules
# ---------------------------------------------------------------------------
def test_linear_schedule_closed_form():
"""alpha(t) = 1 - t; alpha'(t) = -1.
Source: MDLM (Sahoo et al.) App E.1 eq (90) family; ReMDM Sec 3
convention alpha(0)=1, alpha(1)=0. Same grid and tolerance as the
minihack twin test.
"""
expected = np.array([1.0, 2 / 3, 0.5, 1 / 3, 0.0])
assert np.allclose(np.asarray(linear_schedule(T_GRID)), expected, atol=ATOL)
assert np.allclose(np.asarray(linear_schedule_deriv(T_GRID)), -1.0, atol=ATOL)
def test_cosine_schedule_closed_form():
"""alpha(t) = cos(pi t / 2); alpha'(t) = -(pi/2) sin(pi t / 2).
Source: MDLM App E.1 eq (92) ("Cosine"). Values: cos(pi/6)=sqrt(3)/2,
cos(pi/4)=sqrt(2)/2, cos(pi/3)=1/2.
"""
expected = np.array([1.0, math.sqrt(3) / 2, math.sqrt(2) / 2, 0.5, 0.0])
assert np.allclose(np.asarray(cosine_schedule(T_GRID)), expected, atol=ATOL)
expected_d = np.array(
[0.0, -math.pi / 4, -(math.pi / 2) * math.sqrt(2) / 2,
-(math.pi / 2) * math.sqrt(3) / 2, -math.pi / 2]
)
assert np.allclose(np.asarray(cosine_schedule_deriv(T_GRID)), expected_d, atol=ATOL)
def test_schedule_registry_names_follow_mdlm_e1():
"""The label "cosine" must denote MDLM eq (92), not eq (91)."""
fn, deriv = SCHEDULE_MAP["cosine"]
assert abs(float(fn(jnp.array(0.5))) - math.sqrt(2) / 2) < ATOL
assert abs(float(deriv(jnp.array(0.5))) + (math.pi / 2) * math.sqrt(2) / 2) < ATOL
# ---------------------------------------------------------------------------
# Forward corruption
# ---------------------------------------------------------------------------
def test_forward_marginal_endpoints():
"""q(z_t|x) = Cat(alpha_t x + (1-alpha_t) m): alpha=1 identity,
alpha=0 all-MASK, deterministically (uniform draws lie in [0,1)).
Source: MDLM Sec 3.2.1.
"""
rng = jax.random.PRNGKey(0)
x0 = jax.random.randint(rng, (4, 32), 0, 17)
z_keep = forward_process(rng, x0, jnp.ones(4), mask_id=17)
assert bool(jnp.all(z_keep == x0))
z_mask = forward_process(rng, x0, jnp.zeros(4), mask_id=17)
assert bool(jnp.all(z_mask == 17))
def test_forward_marginal_rate():
"""Empirical mask rate at alpha=0.7 is 0.3 within 4 sigma.
Source: MDLM Sec 3.2.1. N = 200*64 = 12800 Bernoulli(0.3) draws;
sigma = sqrt(0.3*0.7/12800) = 0.00405; bound = 4 sigma = 0.0162.
"""
rng = jax.random.PRNGKey(1)
x0 = jnp.zeros((200, 64), dtype=jnp.int32)
zt = forward_process(rng, x0, jnp.full(200, 0.7), mask_id=17)
rate = float((zt == 17).mean())
assert abs(rate - 0.3) < 0.0162
# ---------------------------------------------------------------------------
# Loss: NELBO estimator
# ---------------------------------------------------------------------------
_V, _H = 5, 8
def _uniform_apply(params, obs, z, t, rng):
return jnp.zeros((obs.shape[0], _H, _V))
def _loss(t_pin, schedule_name, valid, B):
fn, deriv = SCHEDULE_MAP[schedule_name]
rng = jax.random.PRNGKey(0)
x0 = jax.random.randint(rng, (B, _H), 0, _V)
obs = jnp.zeros((B, 3))
loss, info = compute_loss(
_uniform_apply, None, rng, x0, obs, valid, _V, fn, deriv,
t_min=t_pin, t_max=t_pin,
)
return float(loss)
def test_loss_all_masked_linear_t1():
"""Loss = w(1) * log V = log V with everything masked, uniform logits.
Source: MDLM eq (10); Shi eq (4). Derivation: t pinned to 1 makes the
forward step deterministic (alpha=0, all masked); uniform logits give
CE = log V per position; sum/H = log V; linear w(1) = 1.
"""
got = _loss(1.0, "linear", jnp.ones(4), B=4)
assert abs(got - math.log(_V)) < 1e-5
def test_loss_weight_uses_analytic_derivative_cosine_t1():
"""Cosine at t=1: w(1) = -alpha'(1)/(1-alpha(1)) = pi/2, so loss =
(pi/2) log V.
Source: MDLM eq (10) with the eq (92) schedule: alpha'(1) =
-(pi/2) sin(pi/2) = -pi/2 and alpha(1)=0. Pins the analytic-derivative
form of the weight.
"""
got = _loss(1.0, "cosine", jnp.ones(4), B=4)
assert abs(got - (math.pi / 2) * math.log(_V)) < 1e-5
def test_loss_invalid_samples_contribute_zero():
"""A sample with valid=0 contributes exactly zero to the batch mean.
Source: the validity mask is the benchmark-forced analogue of PAD
exclusion (MDLM Sec 3.2.3: loss over masked positions of real data
only). Derivation: B=2 at t=1 (all masked), valid=[1,0]:
mean(log V, 0) = log V / 2.
"""
got = _loss(1.0, "linear", jnp.array([1.0, 0.0]), B=2)
assert abs(got - math.log(_V) / 2) < 1e-5
def test_loss_denominator_is_per_token_not_per_masked():
"""E[loss] at pinned t=0.5 (cosine) = w(0.5) * log V * (1 - alpha),
which distinguishes the per-token denominator (1/H) from the
per-masked-count denominator, whose expectation is
w(0.5) * log V (3.41x larger here).
Source: MDLM eq (8)/(10); Shi eq (4) (no division by the realised
masked count). Derivation: per-sample loss = w * log V * n_hat/H with
n_hat ~ Bin(H=8, 1-alpha=0.29289); mean over B=8192 samples has sigma
= w*logV*std(n_hat)/H/sqrt(B) = w*logV*0.00178; the bound below is
0.01*w*logV (~5.6 sigma), far smaller than the 2.41x separation
between the two hypotheses.
"""
alpha = math.sqrt(2) / 2
w = (math.pi / 2) * math.sin(math.pi / 4) / (1 - alpha)
expected = w * math.log(_V) * (1 - alpha)
got = _loss(0.5, "cosine", jnp.ones(8192), B=8192)
assert abs(got - expected) < 0.01 * w * math.log(_V)
# ---------------------------------------------------------------------------
# Reverse step: remasking schedules and the sigma bound
# ---------------------------------------------------------------------------
def test_sigma_strategies_closed_form_and_bound():
"""Same grid and assertions as the minihack twin (ReMDM eq (7), Sec 4.1)."""
eta = 0.5
for k in range(1, 10):
a_t = jnp.array(1 - k / 10)
a_s = jnp.array(1 - (k + 1) / 10)
smax = min(1.0, (1 - float(a_s)) / float(a_t))
assert abs(float(_sigma_max(a_t, a_s)) - smax) < ATOL
assert abs(float(sigma_rescale(a_t, a_s, eta)) - eta * smax) < ATOL
assert abs(float(sigma_cap(a_t, a_s, eta)) - min(eta, smax)) < ATOL
def test_conf_strategy_softmax_of_stored_psi():
"""sigma_conf = softmax(-psi) * eta * sigma_max over unmasked positions,
zero at masked; lower psi => higher remask probability; sums to
eta * sigma_max.
Source: ReMDM Sec 4.1 (Confidence-Based Schedule).
"""
eta = 0.5
a_t, a_s = jnp.array(0.5), jnp.array(0.1)
smax = float(_sigma_max(a_t, a_s))
psi = jnp.array([[0.9, 0.2, jnp.inf, 0.5]])
unmasked = jnp.array([[True, True, False, True]])
sigma = np.asarray(sigma_conf(a_t, a_s, eta, psi, unmasked))
assert sigma[0, 2] == 0.0
assert sigma[0, 1] > sigma[0, 3] > sigma[0, 0]
assert abs(sigma[0, [0, 1, 3]].sum() - eta * smax) < 1e-5
assert (sigma <= smax + ATOL).all()
# ---------------------------------------------------------------------------
# Nucleus filtering
# ---------------------------------------------------------------------------
def test_nucleus_sample_support_and_frequencies():
"""Nucleus sampling draws only from the smallest prefix with cumulative
mass >= p, with renormalised probabilities.
Source: ReMDM Sec 5 (nucleus sampling, Holtzman et al.). For probs
[0.5, 0.3, 0.15, 0.05] and p=0.9 the support is {0,1,2} with
renormalised probs [0.5263, 0.3158, 0.1579]. Statistical: 4096 draws;
per-token sigma = sqrt(p_i(1-p_i)/4096) <= 0.0078; bound = 4 sigma
= 0.0313.
"""
probs = jnp.array([0.5, 0.3, 0.15, 0.05])
logits = jnp.log(jnp.broadcast_to(probs, (4096, 1, 4)))
tokens = np.asarray(_nucleus_sample(jax.random.PRNGKey(0), logits, 0.9)).ravel()
counts = np.bincount(tokens, minlength=4) / tokens.size
assert counts[3] == 0.0, "out-of-nucleus token was sampled"
renorm = np.array([0.5, 0.3, 0.15]) / 0.95
assert np.all(np.abs(counts[:3] - renorm) < 0.0313)
# ---------------------------------------------------------------------------
# Prefix locking through the full corrected chain
# ---------------------------------------------------------------------------
def test_prefix_lock_survives_loop_mode_chain():
"""A locked prefix is bit-identical after a full ReMDM chain with loop
mode and the conf strategy active, and the output contains no MASK.
Source: planning-as-inpainting (Diffuser Sec 3.3: conditioned values
are fixed throughout denoising) on top of ReMDM Alg 1 / Sec 4.2 loop.
Uses a uniform-logits stub model.
"""
B, H, V = 3, 8, 5
fn, _ = SCHEDULE_MAP["cosine"]
def apply_fn(params, obs, z, t, rng):
return jnp.zeros((obs.shape[0], H, V))
history = jnp.tile(jnp.arange(H, dtype=jnp.int32) % V, (B, 1))
hist_len = jnp.array([0, 3, H], dtype=jnp.int32)
plan = sample_plan_inpainting(
apply_fn, None, jax.random.PRNGKey(0), jnp.zeros((B, 3)),
history, hist_len, V, H, diffusion_steps=6, schedule_fn=fn,
remask_strategy="conf", eta=0.5, use_loop=True, t_on=0.7, t_off=0.3,
temperature=0.5, top_p=0.95,
)
plan = np.asarray(plan)
assert (plan != V).all(), "output contains MASK tokens"
assert (plan[1, :3] == np.asarray(history)[1, :3]).all()
assert (plan[2] == np.asarray(history)[2]).all()
|