| """Our composition against the composition the papers actually specify. |
| |
| Every other verification in this library checks a **1-D mixer** against its |
| source: the S4D kernel is bitwise identical to upstream's, the S4 DPLR kernel |
| matches at 3e-8, the Mamba scan at 1e-6. None of them checks the **N-D |
| composition**, which is the part this library claims as its own contribution — |
| so until now the central claim rested on internal consistency (the Kronecker |
| identity, the separable-conv identity) rather than on agreement with a |
| published N-D method. |
| |
| This file closes that. The method under test is S4ND (Nguyen et al., 2022), |
| whose composition is not a sweep at all: |
| |
| one 1-D SSM kernel per axis |
| -> outer product of their Fourier transforms |
| -> a single N-D FFT convolution over all axes at once |
| |
| That is "simultaneous separable". Ours is one axis per layer, sequentially. |
| The two are *supposed* to be the same operator, and the test is whether they |
| are — measured, not assumed. |
| |
| **Why the reference is written here rather than imported.** The upstream repo |
| needs `hydra` to import a module that computes a kernel, and its N-D model |
| carries a config framework, a Lightning trainer and a rank-specific einsum |
| table. Vendoring that to run twenty lines of arithmetic would trade a testable |
| claim for a dependency and a license review. So the *composition rule* is |
| transcribed here as an explicit oracle — exactly what `kron_operator` is for |
| the kernel family, and what the dense state-space matrix-power reference is |
| for S4 — and the kernels it is fed come from our own `_S4DKernel`, which is |
| already proven bitwise equal to theirs. Cross-checking against the real repo |
| belongs in the portability dossier (PLAN.md Phase 7), not in CI. |
| """ |
|
|
| import pytest |
| import torch |
|
|
| import torch_dimensions as td |
| from torch_dimensions.mixers.ssm import _S4DKernel |
|
|
|
|
| def causal_fft_conv(seq: torch.Tensor, k: torch.Tensor) -> torch.Tensor: |
| """The bare convolution inside `_KernelConvMixer`, with no skip or gate. |
| |
| `(M, A, H)` in and out. `n = 2A` makes it a linear rather than a circular |
| convolution, which is what lets a truncated result still be exact. |
| """ |
| a = seq.shape[1] |
| n = 2 * a |
| xt = seq.transpose(1, 2) |
| y = torch.fft.irfft(torch.fft.rfft(xt, n=n) * torch.fft.rfft(k, n=n), n=n)[..., :a] |
| return y.transpose(1, 2) |
|
|
|
|
| def s4nd_simultaneous(x: torch.Tensor, kernels: list[torch.Tensor]) -> torch.Tensor: |
| """S4ND's composition: outer-product the per-axis kernels, convolve once. |
| |
| Transcribed from `src/models/sequence/modules/s4nd.py` (`contract_version=0`): |
| every axis but the last is transformed with `fft`, the last with `rfft`, |
| the results are outer-producted into one N-D kernel, and the input goes |
| through a single `rfftn` of the padded shape. |
| |
| `x` is `(B, *shape, H)`; each kernel is `(H, axis_length)`. |
| """ |
| rank = len(kernels) |
| sizes = [k.shape[-1] for k in kernels] |
| padded = [2 * s for s in sizes] |
| dims = tuple(range(-rank, 0)) |
|
|
| u = x.permute(0, x.ndim - 1, *range(1, x.ndim - 1)) |
| u_f = torch.fft.rfftn(u, s=tuple(padded), dim=dims) |
|
|
| |
| k_f = None |
| for axis, (k, n) in enumerate(zip(kernels, padded, strict=True)): |
| t = torch.fft.rfft(k, n=n) if axis == rank - 1 else torch.fft.fft(k, n=n) |
| |
| shaped = t.reshape(t.shape[0], *([1] * axis), t.shape[-1]) |
| k_f = shaped if k_f is None else k_f.unsqueeze(-1) * shaped |
|
|
| y = torch.fft.irfftn(u_f * k_f, s=tuple(padded), dim=dims) |
| y = y[(..., *(slice(0, s) for s in sizes))] |
| return y.permute(0, *range(2, y.ndim), 1) |
|
|
|
|
| @pytest.mark.parametrize("shape", [(6, 7), (4, 5, 6)]) |
| def test_our_sequential_sweep_equals_s4nds_simultaneous_kernel(shape): |
| """**The N-D claim, checked against a published N-D method.** |
| |
| S4ND applies every axis at once through one N-D kernel; we apply one axis |
| per layer. If the library's premise is right these are the same operator, |
| and they are — to machine precision, at rank 2 and rank 3. |
| |
| This is the strongest single piece of evidence for "an N-D model is a 1-D |
| mixer plus a plan for sweeping it": the plan reproduces, exactly, a model |
| that was never written as a sweep. |
| """ |
| torch.manual_seed(0) |
| h, rank = 3, len(shape) |
| names = tuple("hwd"[:rank]) |
| lat = td.Lattice(shape=shape, names=names) |
|
|
| kernels = [_S4DKernel(h, d_state=16).double()(size) for size in shape] |
| x = torch.randn(2, *shape, h, dtype=torch.float64) |
|
|
| ours = x |
| for name, k in zip(names, kernels, strict=True): |
| ours = td.axial_apply(ours, lat, name, lambda s, k=k: causal_fft_conv(s, k)) |
|
|
| theirs = s4nd_simultaneous(x, kernels) |
|
|
| diff = (ours - theirs).abs().max().item() |
| scale = theirs.abs().max().item() |
| assert diff / scale < 1e-12, f"rank {rank}: sequential differs from simultaneous by {diff:.2e}" |
|
|
|
|
| def test_the_equivalence_needs_a_channel_diagonal_kernel(): |
| """The negative control, and the reason LTI.md's correction matters. |
| |
| S4ND's kernels are diagonal in channels — one scalar filter per channel per |
| axis — which is exactly the "scalar-valued filter" case where per-axis |
| operators commute and a sequential sweep collapses to one joint kernel. |
| Give each axis a filter that *mixes channels* and the equivalence dies: |
| sequential and simultaneous are then different models, because matrix- |
| valued filters do not commute. |
| |
| Without this control the test above would pass just as happily against an |
| implementation that had quietly stopped being separable. |
| """ |
| torch.manual_seed(0) |
| h, shape = 3, (5, 6) |
| lat = td.Lattice(shape=shape, names=("h", "w")) |
| x = torch.randn(2, *shape, h, dtype=torch.float64) |
|
|
| kernels = [_S4DKernel(h, d_state=16).double()(size) for size in shape] |
| mix = torch.randn(h, h, dtype=torch.float64) |
|
|
| def mixing_conv(seq, k): |
| return causal_fft_conv(seq, k) @ mix |
|
|
| ours = x |
| for name, k in zip(("h", "w"), kernels, strict=True): |
| ours = td.axial_apply(ours, lat, name, lambda s, k=k: mixing_conv(s, k)) |
|
|
| |
| |
| |
| theirs = s4nd_simultaneous(x, kernels) @ mix @ mix |
|
|
| diff = (ours - theirs).abs().max().item() |
| assert diff / theirs.abs().max().item() > 1e-3, ( |
| "a channel-mixing filter must break the sequential/simultaneous identity" |
| ) |
|
|