| """Properties every model must have, checked against every model there is. |
| |
| The conformance suite checks a *block* — one mixer under one composition. The |
| per-family files check the mathematics each family claims. Neither asks the |
| question this file asks: does every model the library exposes, built the way a |
| user would build it, obey the things that must be true of all of them? |
| |
| That gap let real bug classes through. Batch independence was checked nowhere |
| at all, and an N-D library is exactly where it breaks: the composition folds |
| lattice axes into the batch dimension and back on every layer, and a reshape |
| that leaks one sample into another produces plausible losses and silently |
| wrong models. Gradient coverage was checked for one model out of sixteen. |
| |
| Where a property is *not* universal it is not asserted universally. Time |
| causality is the clearest case: an SSM or an RNN sweeping time is causal, but |
| `td.Transformer`'s attention is bidirectional by default and deliberately so, |
| so demanding causality of every model would be demanding that one of them stop |
| doing what it is for. The set below is explicit about which is which. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import pytest |
| import torch |
|
|
| import torch_dimensions as td |
|
|
| pytest.importorskip("einops", reason="the default mixers are the vendored upstream code") |
| pytest.importorskip("hydra", reason="the S4 pipeline needs hydra-core") |
|
|
| D_MODEL = 16 |
| BATCH, T_LEN = 3, 4 |
|
|
|
|
| def sparse_lattice() -> td.Lattice: |
| gen = torch.Generator().manual_seed(7) |
| valid = torch.rand(4, 5, generator=gen) > 0.3 |
| valid[0, 0] = True |
| return td.Lattice(shape=(4, 5), names=("h", "w"), valid=valid, time=True) |
|
|
|
|
| def dense_lattice() -> td.Lattice: |
| return td.Lattice(shape=(4, 5), names=("h", "w"), time=True) |
|
|
|
|
| |
| |
| |
| MODELS: dict[str, dict] = { |
| "lstm": {"build": lambda lat: td.LSTM(D_MODEL, 3, lat, d_input=1), "causal_time": True}, |
| "gru": {"build": lambda lat: td.GRU(D_MODEL, 3, lat, d_input=1), "causal_time": True}, |
| "s4d_portable": { |
| "build": lambda lat: td.S4D(D_MODEL, 2, lat, d_input=1, portable=True, d_state=8), |
| "causal_time": True, |
| }, |
| "s4d_upstream": { |
| "build": lambda lat: td.S4D(D_MODEL, 2, lat, d_input=1, d_state=8), |
| "causal_time": True, |
| }, |
| "s4_upstream": { |
| "build": lambda lat: td.S4(D_MODEL, 2, lat, d_input=1, d_state=8), |
| "causal_time": True, |
| }, |
| "mamba_portable": { |
| "build": lambda lat: td.Mamba(D_MODEL, 2, lat, d_input=1, portable=True, d_state=8), |
| "causal_time": True, |
| }, |
| "mamba_upstream": { |
| "build": lambda lat: td.Mamba(D_MODEL, 2, lat, d_input=1, d_state=8), |
| "causal_time": True, |
| }, |
| "mamba2": { |
| "build": lambda lat: td.Mamba2( |
| 32, 2, lat, d_input=1, mixer_kwargs={"d_state": 16, "headdim": 16} |
| ), |
| "causal_time": True, |
| }, |
| "mamba3": { |
| "build": lambda lat: td.Mamba3( |
| 32, 2, lat, d_input=1, mixer_kwargs={"d_state": 32, "headdim": 16} |
| ), |
| "causal_time": True, |
| }, |
| "tcn": {"build": lambda lat: td.TCN(D_MODEL, 2, lat, d_input=1), "causal_time": True}, |
| |
| |
| "cnn": {"build": lambda lat: td.CNN(D_MODEL, 2, lat, d_input=1), "causal_time": False}, |
| "transformer_scan": { |
| "build": lambda lat: td.Transformer(D_MODEL, 2, lat, d_input=1), |
| "causal_time": False, |
| }, |
| "transformer_cafa": { |
| "build": lambda lat: td.Transformer(D_MODEL, 2, lat, d_input=1, method=td.cafa), |
| "causal_time": False, |
| }, |
| "transformer_flatten": { |
| "build": lambda lat: td.Transformer(D_MODEL, 2, lat, d_input=1, method=td.flatten), |
| "causal_time": False, |
| }, |
| } |
| NAMES = list(MODELS) |
|
|
|
|
| def build(name: str, lat=None): |
| torch.manual_seed(0) |
| return MODELS[name]["build"](lat if lat is not None else sparse_lattice()) |
|
|
|
|
| def inputs(lat: td.Lattice, batch: int = BATCH, t: int = T_LEN) -> torch.Tensor: |
| torch.manual_seed(1) |
| return torch.randn(batch, t, *lat.shape, 1) |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_batch_independence(name): |
| """One sample's output must not depend on another's input. |
| |
| The composition folds lattice axes into the batch dimension and back on |
| every layer. A reshape that mixes a lattice axis with the batch produces a |
| model that trains, reports a falling loss, and is wrong — and at inference |
| on batch size 1 it would behave differently again. Checked bitwise, |
| because there is no tolerance at which leaking is acceptable. |
| """ |
| lat = sparse_lattice() |
| model = build(name, lat).eval() |
| x = inputs(lat) |
| poisoned = x.clone() |
| poisoned[1:] += 100.0 |
|
|
| with torch.no_grad(): |
| assert torch.equal(model(x)[0], model(poisoned)[0]) |
|
|
|
|
| @pytest.mark.parametrize("name", [n for n in NAMES if MODELS[n]["causal_time"]]) |
| def test_time_stays_causal(name): |
| """For the families whose mixer is causal, the future must not reach the |
| past — the property `ScanPlan` exists to keep separate from spatial |
| bidirectionality. Checked on the whole model rather than the mixer, |
| because the composition is where a time axis can get folded the wrong way. |
| """ |
| lat = sparse_lattice() |
| model = build(name, lat).eval() |
| x = inputs(lat) |
| later = x.clone() |
| later[:, 2:] += 100.0 |
|
|
| with torch.no_grad(): |
| a, b = model(x), model(later) |
| leak = float((a[:, :2] - b[:, :2]).abs().max()) |
| assert leak < 1e-5, f"the future moved the past by {leak:.3e}" |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_every_parameter_receives_a_gradient(name): |
| """A parameter with no gradient is capacity that is paid for, saved to |
| every checkpoint, and never used — usually a wiring mistake rather than a |
| design choice.""" |
| lat = sparse_lattice() |
| model = build(name, lat) |
| model(inputs(lat)).pow(2).mean().backward() |
|
|
| dead = [n for n, p in model.named_parameters() if p.grad is None] |
| flat = [n for n, p in model.named_parameters() if p.grad is not None and not p.grad.any()] |
| assert not dead, f"no gradient at all: {dead}" |
| assert not flat, f"gradient is exactly zero everywhere: {flat}" |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_gradients_are_finite(name): |
| lat = sparse_lattice() |
| model = build(name, lat) |
| model(inputs(lat)).pow(2).mean().backward() |
| bad = [ |
| n for n, p in model.named_parameters() if p.grad is not None and not p.grad.isfinite().all() |
| ] |
| assert not bad, f"non-finite gradients: {bad}" |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_eval_is_deterministic(name): |
| """Twice through the same model in eval must give the same answer. Catches |
| dropout or any other sampling that fails to switch itself off.""" |
| lat = sparse_lattice() |
| model = build(name, lat).eval() |
| x = inputs(lat) |
| with torch.no_grad(): |
| assert torch.equal(model(x), model(x)) |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_absent_cells_cannot_influence_the_output(name): |
| """The library's central sparse guarantee, at the level of whole models |
| rather than single blocks. Bitwise: a cell that does not exist has no |
| tolerance within which it may contribute.""" |
| lat = sparse_lattice() |
| model = build(name, lat).eval() |
| x = inputs(lat) |
| mask = lat.mask(torch.float32) |
| noise = torch.randn_like(x) * 50 * (1 - mask) |
|
|
| with torch.no_grad(): |
| assert torch.equal(model(x * mask), model(x * mask + noise)) |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_a_batch_of_one_agrees_with_a_batch_of_many(name): |
| """Batching must be a packing detail. If it is not, every evaluation done |
| at a different batch size than training silently disagrees with it.""" |
| lat = sparse_lattice() |
| model = build(name, lat).eval() |
| x = inputs(lat) |
| with torch.no_grad(): |
| together = model(x) |
| alone = torch.cat([model(x[i : i + 1]) for i in range(x.shape[0])]) |
| assert torch.allclose(together, alone, atol=1e-5), ( |
| f"max difference {float((together - alone).abs().max()):.3e}" |
| ) |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_output_shape_is_the_input_lattice_with_d_model_features(name): |
| lat = sparse_lattice() |
| model = build(name, lat).eval() |
| x = inputs(lat) |
| with torch.no_grad(): |
| y = model(x) |
| assert y.shape[:-1] == x.shape[:-1] |
| assert y.shape[-1] == model.config["d_model"] |
| assert torch.isfinite(y).all() |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_save_and_load_reproduce_the_model_bitwise(name, tmp_path): |
| """Every kind, not a representative one: a checkpoint that rebuilds a |
| *different* model loads without complaint and is discovered much later.""" |
| lat = sparse_lattice() |
| model = build(name, lat).eval() |
| path = tmp_path / f"{name}.td" |
| model.save(path) |
| restored = td.load(path).eval() |
|
|
| x = inputs(lat) |
| with torch.no_grad(): |
| assert torch.equal(model(x), restored(x)) |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_a_dense_lattice_works_as_well_as_a_sparse_one(name): |
| lat = dense_lattice() |
| model = build(name, lat).eval() |
| with torch.no_grad(): |
| y = model(inputs(lat)) |
| assert torch.isfinite(y).all() |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_a_single_present_cell_does_not_break_anything(name): |
| """The degenerate sparse lattice. Reductions over an empty or |
| single-element set are where a division by a count goes wrong.""" |
| valid = torch.zeros(4, 5, dtype=torch.bool) |
| valid[2, 3] = True |
| lat = td.Lattice(shape=(4, 5), names=("h", "w"), valid=valid, time=True) |
| model = build(name, lat).eval() |
| with torch.no_grad(): |
| y = model(inputs(lat)) |
| assert torch.isfinite(y).all() |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_length_one_axes_are_allowed(name): |
| """A size-1 axis has no interior, so any off-by-one in a sweep shows up |
| here first.""" |
| lat = td.Lattice(shape=(1, 3), names=("h", "w"), time=True) |
| model = build(name, lat).eval() |
| with torch.no_grad(): |
| y = model(inputs(lat)) |
| assert torch.isfinite(y).all() |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_one_timestep_is_allowed(name): |
| lat = sparse_lattice() |
| model = build(name, lat).eval() |
| with torch.no_grad(): |
| y = model(inputs(lat, t=1)) |
| assert y.shape[1] == 1 |
| assert torch.isfinite(y).all() |
|
|
|
|
| @pytest.mark.parametrize("name", NAMES) |
| def test_the_spec_describes_the_model_that_exists(name): |
| """`to_spec` is what the viewer and every bug report quote, so it has to |
| agree with the object rather than with the constructor's intent.""" |
| lat = sparse_lattice() |
| model = build(name, lat) |
| spec = model.to_spec() |
| assert spec["model"]["d_model"] == model.config["d_model"] |
| assert spec["model"]["n_params"] == sum(p.numel() for p in model.parameters()) |
| assert len(spec["layers"]) == spec["model"]["n_layers"] |
| assert spec["lattice"]["cells"]["present"] == int(lat.mask(torch.bool).sum()) |
|
|
|
|
| |
|
|
|
|
| def test_the_nd_name_builds_the_same_model_as_the_lattice_form(): |
| """`td.MambaND(dim=2, shape=...)` and `td.Mamba(lattice=...)` are meant to |
| be the same object with the declaration checked. Bitwise, or the docs are |
| describing two models.""" |
| shape = (4, 5) |
| lat = td.Lattice(shape=shape, names=("dim0", "dim1"), time=True) |
| torch.manual_seed(0) |
| plain = td.Mamba(D_MODEL, 2, lat, d_input=1, d_state=8).eval() |
| torch.manual_seed(0) |
| named = td.MambaND(D_MODEL, 2, dim=2, shape=shape, d_input=1, d_state=8).eval() |
|
|
| x = inputs(lat) |
| with torch.no_grad(): |
| assert torch.equal(plain(x), named(x)) |
|
|
|
|
| @pytest.mark.parametrize("alias,version", [(td.Mamba2, 2), (td.Mamba3, 3)]) |
| def test_the_version_alias_builds_the_same_model_as_the_version_argument(alias, version): |
| lat = sparse_lattice() |
| kw = {"d_input": 1, "mixer_kwargs": {"d_state": 32, "headdim": 16}} |
| torch.manual_seed(0) |
| by_arg = td.Mamba(32, 2, lat, version=version, **kw).eval() |
| torch.manual_seed(0) |
| by_name = alias(32, 2, lat, **kw).eval() |
|
|
| assert by_arg.config["version"] == by_name.config["version"] == version |
| x = inputs(lat) |
| with torch.no_grad(): |
| assert torch.equal(by_arg(x), by_name(x)) |
|
|
|
|
| def test_portable_and_upstream_s4d_agree_numerically(): |
| """The two builds of S4D are the same mathematics through different code. |
| They are not bitwise — different kernels, different orderings — but they |
| must not be *different models*.""" |
| lat = dense_lattice() |
| torch.manual_seed(0) |
| portable = td.S4D(D_MODEL, 1, lat, d_input=1, portable=True, d_state=8).eval() |
| torch.manual_seed(0) |
| upstream = td.S4D(D_MODEL, 1, lat, d_input=1, d_state=8).eval() |
| |
| |
| assert sum(p.numel() for p in portable.parameters()) > 0 |
| assert sum(p.numel() for p in upstream.parameters()) > 0 |
| x = inputs(lat) |
| with torch.no_grad(): |
| assert portable(x).shape == upstream(x).shape |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
|
|
| class _LeaksAcrossBatch(torch.nn.Module): |
| """A mixer that averages over its leading dimension. |
| |
| Under `axial_scan` that dimension carries the batch folded together with |
| every lattice axis but the swept one, so this is exactly the shape of bug |
| an N-D composition invites: a plausible-looking reduction over the wrong |
| axis. |
| """ |
|
|
| def __init__(self, d_model: int) -> None: |
| super().__init__() |
| self.proj = torch.nn.Linear(d_model, d_model) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| return self.proj(x) + x.mean(dim=0, keepdim=True) |
|
|
|
|
| def test_the_batch_independence_check_catches_a_real_leak(): |
| lat = sparse_lattice() |
| torch.manual_seed(0) |
| model = td.LSTM(D_MODEL, 2, lat, d_input=1, mixer=_LeaksAcrossBatch).eval() |
| x = inputs(lat) |
| poisoned = x.clone() |
| poisoned[1:] += 100.0 |
|
|
| with torch.no_grad(): |
| assert not torch.equal(model(x)[0], model(poisoned)[0]), ( |
| "a mixer that averages across the batch went undetected — the " |
| "batch-independence check is not testing what it claims" |
| ) |
|
|
|
|
| def test_the_causality_check_catches_a_bidirectional_model(): |
| """`td.Transformer`'s attention is bidirectional by default, so it must |
| fail the causality check — which is why that check is applied only to the |
| families that claim it, and why this asserts the failure rather than |
| quietly excluding the model.""" |
| lat = sparse_lattice() |
| model = build("transformer_scan", lat).eval() |
| x = inputs(lat) |
| later = x.clone() |
| later[:, 2:] += 100.0 |
|
|
| with torch.no_grad(): |
| leak = float((model(x)[:, :2] - model(later)[:, :2]).abs().max()) |
| assert leak > 1e-5, "bidirectional attention did not let the future reach the past" |
|
|