Parallel-scan minGRU variants (log-space, signed, rotation, Givens) with an optional Triton scan backend.
Project description
minGRU
PyTorch implementation of the minGRU from Feng, Tung, Ahmed, Bengio &
Hajimirsadeghi, Were RNNs All We Needed? (arXiv:2410.01201) — a recurrent
layer whose gates depend only on the current input, so the whole sequence
trains in one parallel scan instead of step-by-step backpropagation through
time (BPTT). The library is the mingru package (pip name mingru-scans); pure PyTorch, no dependencies beyond torch.
This repo ships the base minGRU plus three variants that each fix a specific
gap in it: SignedMinGRU (state can flip sign, not just decay toward
zero), RotationMinGRU (state can track operations that don't
commute, like composing permutations), and GivensMinGRU (richer
non-commuting state via rotations across 8-dimensional blocks instead of
2D planes). All four share one mixer=
interface on MinGRUBlock/MinGRUStack, and all four optionally accept
a time-aware decay term for irregularly-spaced sequences — a real-world
gap between events, not just a token count (see "Time-aware decay,"
below). What each variant actually buys you, in measured accuracy, is
below.
Install
pip install mingru-scans
Requires torch>=2.8 (CPU or CUDA); the import name is mingru:
import torch
from mingru import MinGRUStack
stack = MinGRUStack(input_size=32, d_model=256, n_layers=4) # mixer="log" (default)
x = torch.randn(4, 10, 32)
y, state = stack(x) # (B, T, 32) -> (B, T, 256), per-block states
The optional Triton scan backend is imported lazily on first use of a Triton-backed symbol, so import mingru works on CPU-only installs; the MINGRU_SCAN control and GPU path are documented on the docs site. On Linux the default PyPI torch bundles the matching Triton automatically; if your CUDA-capable torch came without it (mingru.available() reports this), pip install "mingru-scans[triton]" is an unpinned fallback. Full documentation, including the GivensGRU and Triton-scans deep dives, lives at https://chris-santiago.github.io/minGRU/.
GPU acceleration (fused Triton kernels)
An optional Triton backend supplies fused forward+backward GPU kernels for all four scan primitives (linear_scan, matrix_scan, matrix_affine_scan, parallel_scan_log) plus an angle-fused rotation path for GivensMinGRU. It sits behind a zero-config dispatch seam set by MINGRU_SCAN: auto (default: use Triton when a CUDA GPU and a working Triton install are present, else the pure-PyTorch eager path), eager, or triton (force it). CPU-only installs never import Triton.
The win is concentrated in the matrix/block ops. Measured on an NVIDIA L4 (torch 2.8.0+cu128, Triton 3.4.0), forward+backward the Triton kernels run 39x–168x faster than eager on matrix_scan/matrix_affine_scan, and the angle-fused GivensMinGRU backward cuts peak memory from 395 MB to 38 MB. The log-space parallel_scan_log is the exception. Its recompute-in-backward kernel is 0.70x–0.80x slower than eager forward+backward, so eager stays the right default for the baseline MinGRU/SignedMinGRU mixers. All 590 CPU-vs-GPU parity checks pass. Kernel design and the full benchmark tables are in the Triton scan kernels explanation and experiments/bench/ (scan_bench.md, scan_memory.md, scan_parity.md).
What this shows
Two word-problem tasks probe exactly the gaps above (full task/eval setup in "Reproducing," below):
- parity — label each prefix of a bit string with its running XOR (chance 0.5). The natural solution is a state that flips sign on a 1 and holds on a 0: a transition coefficient of −1.
- S3 — label each prefix of a sequence of permutations of 3 objects (picture cups being swapped and rotated) with their net composition so far (chance ≈ 0.17). Composition order matters, so this needs a transition that doesn't commute.
Class names (used in the code) and probe/results names (used in the table
below and in probes.py) don't match 1:1 — here's the bridge:
module class (mixer=) |
name in probes / results table | notes |
|---|---|---|
MinGRU (mixer="log", default) |
minGRU |
log-space, positive states; chance-level on both tasks at any depth |
SignedMinGRU, coupled=False (default) |
minGRU-signed-tanh |
decoupled eigenvalue; recommended for parity-like tasks |
SignedMinGRU, coupled=True (legacy) |
minGRU-signed |
pinned to coupled=True in probes.py; kept under its historical name |
RotationMinGRU (mixer="rotation") |
minGRU-rotsnap |
runs at L=1; depth via the list-mixer rows (minGRU-hetero-*, minGRU-rotation2); needs the CKPT=1 best-val@128 protocol below |
GivensMinGRU (mixer="givens") |
minGRU-hetero-sg8 (the mixer=["signed", "givens"] composer stack; no standalone single-mixer entry in probes.py's MIXER_REGISTRY) |
measured on S3-hier, not the plain S3/parity columns below — see "Givens variant" |
torch.nn.GRU (reference baseline) |
GRU |
state-dependent gating; the ceiling both tasks are measured against |
Recommended: for parity-like problems (state must flip sign based on a
running property), use SignedMinGRU with its default coupled=False
(minGRU-signed-tanh below) — it holds 0.994 mean accuracy at 16x the
training length (n=6, worst seed 0.984), vs. 0.592 for the legacy
coupled=True form. For problems needing non-commutative state tracking
(composing operations where order matters), use RotationMinGRU
(mixer="rotation", one layer only; minGRU-rotsnap below) with the
CKPT=1 best-val@128 protocol described in "Rotation variant" — it reaches
0.958 mean accuracy at 16x length (n=8), though only 1 of 8 seeds lands the
exact solution, so budget for retries. If you only need behavior near the
training length, SignedMinGRU fits S3-like composition in-distribution
too (0.999 @64) — its shortcut just decays with length; rotation is what
holds at 4x-16x. The base MinGRU (log-space,
mixer="log") stays at chance on both tasks regardless of depth — a
parameterization limit, not a training one. A standard GRU remains the
ceiling: state-dependent gating holds both tasks at 1.000 (to three
decimals) at every tested length with a single layer. For deeper stacks
that must both extract a non-commutative operation from raw input and
then compose it (a harder joint problem than tracking a single operation
directly), swapping the composer from a 2D rotation block to
GivensMinGRU (mixer="givens") raises fit reliability on the S3-hier
task from 1 of 12 seeds to 8 of 12 (Fisher exact p ≈ 0.009), at a matched
64-element per-token state and within +2.2% of full-stack parameters —
though, like the other continuous composers here, it buys no attractor at
length and still decays by T=1024. See "The hierarchical task: S3-hier,"
below, for the task's construction and the full cross-mechanism evidence
table, and "Givens variant" for the mechanism, the measured decay, and
the parallel-only design case for choosing it over the sequential
delta-rule composer.
Numbers below are multi-seed means (torch 2.5.1, CPU; seed counts stated
per row). Protocol: seq2seq tagging (dense supervision), T_train=64,
d_model=64, batch 128, Adam lr 3e-3, budget ≤1600 steps; the selection
protocol is labeled per row. parity rows use early-stop at 99.9%
train-length accuracy; every S3 minGRU-variant row uses the best-val@128
checkpoint protocol ("Rotation variant," below), so the diagonal and
rotation rows compare mechanisms under one selection procedure rather
than mechanism-plus-protocol against mechanism. Base minGRU rows never
reach the early-stop criterion and consume the full budget.
| task | model | layers | seeds | protocol | acc@64 | acc@256 | acc@512 | acc@1024 |
|---|---|---|---|---|---|---|---|---|
| parity | GRU |
1 | 3 | early-stop | 1.000 | 1.000 | 1.000 | 1.000 |
| parity | minGRU (base, log-space) |
1 | 3 | early-stop | 0.542 | 0.511 | 0.506 | 0.502 |
| parity | minGRU-signed (coupled=True) |
1 | 3 | early-stop | 1.000 | 0.866 | 0.687 | 0.592 |
| parity | minGRU-signed-tanh (default) |
1 | 6 | early-stop | 1.000 | 1.000 | 0.999 | 0.994 (worst seed 0.984) |
| S3 | GRU |
1 | 3 | early-stop | 1.000 | 1.000 | 1.000 | 1.000 |
| S3 | minGRU (base, log-space) |
1 | 3 | early-stop | 0.229 | 0.182 | 0.175 | 0.171 |
| S3 | minGRU-signed (coupled=True) |
1 | 3 | best-val@128 | 0.470 | 0.344 | 0.277 | 0.226 |
| S3 | minGRU-signed (coupled=True) |
4 | 3 | best-val@128 | 0.994 | 0.713 | 0.506 | 0.368 |
| S3 | minGRU-signed-tanh (default) |
1 | 3 | best-val@128 | 0.999 | 0.943 | 0.864 | 0.732 |
| S3 | minGRU-signed-tanh (default) |
4 | 3 | best-val@128 | 0.997 | 0.908 | 0.769 | 0.574 |
| S3 | minGRU-rotsnap |
1 | 8 | best-val@128 | 1.000 | 1.000 | 0.996 | 0.958 (exact-to-16x in 1/8 seeds) |
The S3 diagonal rows tell a sharper story than "can't fit": the
decoupled signed-tanh fits S3 in-distribution (0.999 @64) through a
bounded-depth shortcut and decays steadily with length — 0.732 @1024 at
L=1, and depth doesn't rescue it (0.574 @1024 at L=4) — while the
rotation row holds near the exact automaton (0.958 @1024). The coupled
legacy form fails to fit S3 at L=1 even under checkpoint selection —
the one knob separating it from the signed-tanh row is the
|a_t| ≤ 1 − z_t ceiling ("Signed variant," below), which makes every
strong eigenvalue cost a second saturation. So the diagonal/rotation
separation lives at length generalization, not training fit — exactly
where the representability argument ("Expressivity limits," below)
predicts it.
The deeper S3 diagonal cells carry high seed-to-seed variance (per-seed
acc@256 spans 0.66-0.76 for coupled=True L=4 and 0.83-0.95 for
signed-tanh L=4), so treat those means as indicative rather than
tight. Earlier early-stop records for the coupled S3 rows, and detail
on a train/eval generator-seeding fix applied across this table (seed 0
unaffected), live in experiments/EXPERIMENTS.md.
Base MinGRU (log-space) is measured at chance on both tasks, and
depth doesn't move it: L=4 rows (3 seeds, round base-mingru in
experiments/lab_results.jsonl) match the L=1 rows above — parity
0.516 @64 falling to 0.501 @1024, S3 0.222 @64 falling to 0.170 @1024.
It cannot represent a −1 transition or a non-commuting one at any
width, so this is a parameterization failure, not a training one.
A few caveats apply to every number above: all runs use one learning
rate, and null/partial results are budget-relative ("didn't land the
exact solution in 1600 steps" ≠ "cannot"). The minGRU wrappers also
include a block MLP that the GRU baseline lacks, which favors the
minGRU variants — this strengthens their negative results (chance-level
despite the extra capacity) and mildly weakens attribution of their
positive ones. Finally, every comparison in the table above is
internal — against the repo's own floor (base MinGRU), its prior
parameterization (coupled=True), or its ceiling (torch.nn.GRU). A
Grazzi-style negative-eigenvalue scan is not run as a baseline anywhere
in this repo. DeltaNet/DeltaProduct's transition rule is compared, but
as a mechanism-level reimplementation under this repo's protocol, not
their released code — see "Incumbent comparison (mechanism-level)"
under "Rotation variant," below. So the table above establishes
"reproduces the predicted behavior, minGRU-natively," not "competitive
with the incumbents"; the incumbent-comparison subsection is the one
place in this README that compares a published mechanism directly, and
even there it is transition-rule-vs-transition-rule under one protocol,
not a claim about the incumbents' released systems.
Model
The minGRU removes the hidden-state dependency from the GRU's gates (which eliminates the reset gate) and drops the tanh range restriction:
z_t = sigmoid(Linear_z(x_t))
h~_t = Linear_h(x_t)
h_t = (1 - z_t) ⊙ h_{t-1} + z_t ⊙ h~_t
Because z_t and h~_t depend only on x_t, the recurrence is a
first-order linear scan h_t = a_t ⊙ h_{t-1} + b_t, computable over the
full sequence in parallel (Heinsen log-space scan via logcumsumexp) — no
BPTT. Parameter count is O(2·d_h·d_x) vs. O(3·d_h·(d_x + d_h)) for a
standard GRU.
This implements the paper's log-space variant (their Appendix B, the
numerically stable form). It applies g(x) = x + 0.5 (x ≥ 0), sigmoid(x) (x < 0) to candidate and initial states, which means hidden states are
strictly positive. That is a property of this parameterization, not of
the "vanilla" minGRU in the paper's Appendix A.
Components
| class / fn | role |
|---|---|
MinGRU |
one scan layer (log-space, positive states, a_t ∈ (0,1)); parallel forward, recurrent step; optional time-decay (decay=, decay_rate=, log1p_delta=) scales a_t only — see "Time-aware decay" |
SignedMinGRU |
signed diagonal transitions (linear-space scan, a_t ∈ (−1,1), unconstrained states); coupled=False (default, decoupled eigenvalue) or coupled=True (legacy reproduction); same API; same time-decay kwargs as MinGRU |
RotationMinGRU |
2x2 block rotation transitions (non-diagonal, matrix scan); snap grid manufactures exact-angle attractors; depth is a measured tradeoff, not a fixed limit (see "Rotation variant"); same API; same time-decay kwargs, scaling the whole 2x2 block (direction unaffected) |
GivensMinGRU |
k-dim block-rotation transitions (non-diagonal, matrix_affine_scan); each transition is a product of rounds brick-wall Givens layers per block_size-dim block, special-orthogonal and continuous (no snap) — richer per-token maps than RotationMinGRU's 2x2 at the same per-token state (see "Givens variant"); same API; same time-decay kwargs, scaling the whole block by a scalar (rotation unaffected) |
linear_scan |
Hillis–Steele associative scan for h_t = a_t·h_{t−1} + b_t with signed scalar coefficients |
matrix_scan |
Hillis–Steele associative scan for h_t = M_t @ h_{t−1} + b_t with 2x2 matrix coefficients (non-commutative composition) |
matrix_affine_scan |
Hillis–Steele associative scan for h_t = M_t @ h_{t−1} + b_t with k×k matrix coefficients (the block_size generalization of matrix_scan, used by GivensMinGRU) |
MinGRUBlock |
pre-norm residual block: LN → mixer → +x, LN → MLP → +x (mixer selects "log", "signed", "rotation", or "givens"; mixer_kwargs forwarded to its constructor); forward/step take an optional delta_t, forwarded to the block's mixer unchanged |
MinGRUStack |
input projection → N blocks → final LN; full state threading; mixer= is a str (applied to every block, unchanged) or a list[str] of length n_layers mixing types per block (e.g. one rotation block inside a signed/log stack); mixer_kwargs is the current flat dict for a str mixer, or None/a dict keyed by mixer type for a list mixer, applied to every block of that type (two blocks of the same type share one config); more than one "rotation" entry warns once per construction (STE-compounding, doesn't block construction) and proceeds, while multiple "givens" entries do not warn (the continuous Givens transition has no straight-through snap to compound); decay_layers="all" (default) or "last" selects which blocks receive the resolved kwargs' decay keys, positionally, whatever each block's type; forward/step take an optional delta_t, routed only to blocks whose mixer has decay enabled |
MinGRU is deliberately atomic. A single layer's gates cannot condition on
accumulated state (that is the parallelism trade); stacking recovers
history-dependent gating with one level of indirection — layer l's gates
see layer l−1's hidden states — and the block MLP supplies the
cross-channel interaction the diagonal scan cannot (channel i never
touches channel j inside the recurrence). RotationMinGRU's 2x2 blocks
mix their own 2 channels internally, but not across blocks — the MLP still
supplies interaction between blocks. Set mlp_expansion=0 for scan-only
blocks to ablate the channel mixer separately.
Usage
Training (parallel over T), default log-space mixer:
stack = MinGRUStack(input_size=32, d_model=256, n_layers=4) # mixer="log" (default)
y, state = stack(x) # (B, T, 32) -> (B, T, 256), per-block states
Selecting a different mixer via mixer=/mixer_kwargs:
signed_stack = MinGRUStack(32, 256, n_layers=4, mixer="signed")
# decoupled eigenvalue (default) -- the better length-generalizing form
legacy_stack = MinGRUStack(
32, 256, n_layers=4, mixer="signed", mixer_kwargs={"coupled": True}
)
# bit-exact reproduction of the previous default parameterization
rot_stack = MinGRUStack(32, 256, n_layers=1, mixer="rotation")
# non-diagonal 2x2 rotation blocks; single-mixer use, and only under the
# best-val@128 training protocol -- see "Rotation variant" below
givens_stack = MinGRUStack(32, 256, n_layers=2, mixer="givens")
# k-dim block-rotation transitions (default block_size=8, rounds=3);
# richer non-commutative per-token maps than "rotation", continuous (no
# snap); d_model must be a multiple of block_size -- see "Givens variant"
Mixing mixer types across layers (mixer= as a list[str], one name per
block; mixer_kwargs keyed by type instead of flat):
import torch
from mingru import MinGRUStack
hetero_stack = MinGRUStack(
32, 256, n_layers=2, mixer=["signed", "rotation"],
mixer_kwargs={"signed": {"coupled": True}, "rotation": {"snap": (2, 3, 6)}},
)
x = torch.randn(4, 10, 32)
y, state = hetero_stack(x) # (B, T, 32) -> (B, T, 256), per-block states
# one rotation block inside a deeper signed stack (extract-then-compose);
# mixer_kwargs is keyed by mixer type, not applied flat, when mixer is a
# list -- see "The hierarchical task: S3-hier" for the measured depth/hierarchy tradeoffs.
# A mixer list with more than one "rotation" entry (e.g. ["rotation",
# "rotation"]) warns once per construction (STE-compounding) and proceeds.
hetero_stack_givens = MinGRUStack(
32, 256, n_layers=2, mixer=["signed", "givens"],
)
x = torch.randn(4, 10, 32)
y, state = hetero_stack_givens(x) # (B, T, 32) -> (B, T, 256), per-block states
# signed extractor -> Givens composer (block_size=8, rounds=3 defaults);
# no multi-rotation-style warning on construction -- see "Givens variant"
# for the measured fit-reliability and length-generalization tradeoffs
Streaming inference (O(1) memory; state is n_layers × d_model per sample):
state = stack.init_state()
for x_t in event_stream: # x_t: (B, input_size)
y_t, state = stack.step(x_t, state)
Chunked / TBPTT training:
carry = None
for chunk in sequence_chunks: # each (B, T_chunk, input_size)
y, carry = stack(chunk, state=carry)
loss(y).backward()
carry = [h.detach() for h in carry]
Learned initial state (module-owned, positivity by construction):
m = MinGRU(32, 64, learnable_h0=True) # zero-init == fixed g(0)=0.5 default
learnable_h0 routes to the block/stack's mixer only when mixer="log" or
"signed"; RotationMinGRU and GivensMinGRU each own their h_0
unconditionally (see "Rotation variant" and "Givens variant" below) and do
not accept the flag.
State conventions
All exposed state is real hidden state — an output of forward() or
step() — under one convention across every mixer:
forward(h_0=...)takes(B, 1, d_h);step(h_prev=...)takes(B, d_h). Crossing between streaming and chunked modes needs an explicitunsqueeze(intentional — no silent dimension coercion). Same shapes forMinGRU,SignedMinGRU,RotationMinGRU, andGivensMinGRU.- Do not pass pre-activations. The paper's reference code treats
h_0as a pre-activation and appliesgto it internally; this implementation does not. Passing a pre-activation here silently double-appliesg(~0.43 max error vs. 1e-5 when fixed) — a real footgun if you reuse the paper's chunked-carry pattern verbatim. For a learned initial state, uselearnable_h0=Truerather than passing a pre-activation through the call signature. - Validation (log-space
MinGRUonly). Strictly negative entries inh_0raise, viatorch._assert_async(device-side on CUDA, so chunked loops incur no per-chunk host sync). Exact zeros are accepted and clamped to the dtype's smallest normal beforelog(), since legitimately small states underflow to 0.0 in fp16/bf16.SignedMinGRU,RotationMinGRU, andGivensMinGRUaccept any realh_0— no clamp, no check; the positivity constraint is a property of the log-space parameterization only. RotationMinGRU.h_0andGivensMinGRU.h_0are each an intrinsic learned parameter, not an optional flag (see "Rotation variant" and "Givens variant," below): a zero state has no orbit under the group action, so it can't demonstrate tracking, and a state on a reflection axis collapses reflections onto rotations.
The full derivation and code path for each rule above is in the
corresponding docstrings in the mingru package (forward, step, log_g).
Implementation notes
log_ggradient at 0. This implementation uses a nestedwhereinstead of the paper's relu-guarded branch: value-identical everywhere, but it gives the correct gradient (1/g(0) = 2) atx = 0instead of the relu guard's incorrect 0, which silently deadens any zero-initialized parameter routed through it (e.g.learnable_h0at its default init — no crash, no wrong output, the parameter just never trains). Full derivation is in thelog_gdocstring.step()runs under@torch.no_grad()for every mixer. Training is intended through the parallelforward; if you need BPTT through the sequential path, remove the decorator.- Numerical agreement between the parallel and sequential paths, fp32 at
T=128: ~1e-5 for
MinGRU(logcumsumexpaccumulation), ~1e-7 forSignedMinGRU(no exp/log round-trip), ~1e-6 forRotationMinGRU, and ~1e-5 forGivensMinGRU(a product ofroundsk×k blocks per step, so more matrix accumulation than the single 2x2) — none exact by construction.
The ladder: fading, flipping, turning, reading
Every mixer in this module is a machine that carries a small memory along a sequence and updates it at each step. The differences between them come down to one question: what is the update allowed to do to the memory? Answer it four increasingly generous ways and you get a ladder — each rung does everything below it, plus exactly one new thing.
Rung 1 — memory that fades (MinGRU). The base minGRU's update
is: keep some fraction of what you had, blend in some of what you just
saw. The fraction is always between 0 and 1, so old memory can only
shrink toward new input. That's a moving average with an
input-controlled blend — genuinely useful (it is most of what
"context" means in practice), but it can only fade. Ask it whether
it has seen an even or odd number of 1s and it's stuck: an average of
what you saw carries no trace of even-versus-odd. Measured: chance on
parity at any depth. ("Time-aware decay," below, is a different axis
entirely: it makes this fading time-continuous — gated by the
real-world gap between events rather than by token count — and it
composes with every rung above, scaling how fast existing memory fades
between events without adding a new capability of its own.)
Rung 2 — memory that flips (SignedMinGRU). Let the keep-fraction
go negative: multiply the memory by −1 and it flips sign.
Even-versus-odd is now trivial — flip on every 1, hold on every 0,
read the sign at the end. The new capability is alternation: state
that oscillates and cancels instead of only decaying. The subtlety the
experiments surfaced is that reachable in principle isn't enough —
the −1 has to be a place training naturally settles. That is why the
default parameterization matters: tanh saturates at exactly −1,
while the legacy coupled form can approach but never reach it, and the
shortfall compounds with sequence length.
Rung 3 — memory that turns (RotationMinGRU). A flip is still
just multiplication by a number, and numbers commute: ×(−1) then ×(+1)
equals ×(+1) then ×(−1). So no rung-2 machine can track anything where
order matters. The fix: make the memory a little arrow in a plane
and each update a rotation (or reflection) of that arrow. Rotations
don't commute — turn-then-flip lands somewhere different from
flip-then-turn — so the arrow can carry the running composition of
operations, like following three cups through a shuffle. The rung-2
subtlety returns, sharper: the useful angles (a third of a turn, for
three cups) are not places training naturally settles, so this variant
snaps its angles to an exact grid — which is also why the grid must
contain the angles your problem needs. Whether depth can supply the
feature extraction a single rung-3 layer lacks turns out to be a real
tradeoff, not a yes/no — see "Rotation variant," below.
(GivensMinGRU occupies this same rung with a richer per-token map —
k-dimensional block rotations instead of a single 2D plane — not a fifth
rung; see "Givens variant," below.)
Rung 4 — memory that reads itself (a standard GRU). Everything below shares one discipline: the update at step t is chosen by the input at step t alone, never by looking at the current memory. That discipline is the entire reason rungs 1–3 train in one parallel pass — updates fixed in advance can be composed in any grouping, so a scan works. A GRU breaks the discipline: it reads its memory before deciding how to change it. That buys genuinely sequential computation (one layer solves both probe tasks at every length tested, and harder tasks this repo doesn't attempt), and it costs exactly the thing this repo exists to keep — each step must wait for the one before it.
The ladder is not "worse to better." It's a menu of what you can afford: rungs 1–3 keep parallel training and each buys one specific new kind of memory; rung 4 buys the rest and pays with sequentiality. The probes in "What this shows" measure precisely these boundaries, and "Expressivity limits" below is the formal version of this picture.
Expressivity limits
- Positive states (log-space variant). Each
MinGRUblock's scan output lives in(0, ∞)(the residual stream itself is signed). If hidden-state magnitude carries task signal, the halved per-dimension range is worth checking in a proxy run rather than assuming parity with a standard GRU.SignedMinGRUremoves this constraint. - Expressivity class. Input-dependent, state-independent transitions
put all four mixers in the same broad class as Mamba/S6 and GLA:
fixed-depth stacks are TC⁰ (Merrill et al., The Illusion of State in
State-Space Models) and cannot do unbounded state tracking that a
single nonlinear GRU layer can. (Circuit-complexity shorthand used
throughout: TC⁰ is the class of problems solvable by
constant-depth parallel circuits — depth that does not grow with
sequence length, which is exactly what a stack of parallel scans is.
NC¹ is the strictly-harder-believed class needing depth that
grows logarithmically with input length; its complete problems, like
composing arbitrary permutations of 5 elements (S5), are the
canonical "inherently sequential" computations — the ones a true
recurrence handles step by step and a constant-depth parallel model
is believed unable to.) Depth recovers bounded-depth nonlinear
interaction with history, not sequential computation. Depth-matched
comparisons against a standard GRU confound layer count with
mechanism. Non-diagonal transitions (
RotationMinGRU,GivensMinGRU) lift the commutativity restriction and provably solve bounded non-abelian tracking (S3, a solvable group) that no diagonal variant can at any width — but that is not the same as the unbounded, non-solvable-group tracking (e.g. S5, NC¹-complete) that requires state-dependent gates; see the Signed and Rotation variant sections below. The probes make all of this measurable. - A natural objection: "rotations are abelian." Rotations confined
to fixed disjoint 2×2 planes with pure rotation angles are equivalent
to a complex-diagonal transition (the LRU parameterization); they
commute — angles just add — so that family is order-blind exactly like
the real diagonal case, and the objection is correct for that
family. Neither promoted rotation mixer is in it, and the recorded
measurements separate them.
RotationMinGRU's per-block map isR(θ_t)·diag(1, tanh u_t)— rotation and flip, an O(2)-valued family generating the dihedral group, which is non-abelian — and it fits S3 at one layer (1.000 / 1.000 / 0.996 / 0.958, n=8, "Rotation variant" below), a result unavailable to any commuting transition family at any width: commuting maps yield the same final state under any reordering of the inputs, while S3 labels change under reordering.GivensMinGRUcomposes rotations in staggered planes — disjoint planes within a round commute; the stagger across rounds is what breaks commutativity — at fixed block size, so per-token cost stays linear in width rather than the O(d²) of a full SO(d) transition. Two adjacent corrections the objection often carries: parity is Z₂ — abelian — and is held at length by the signed diagonal mixer (0.994 @1024, n=6, worst seed 0.984), which needs an eigenvalue at −1, not non-commutativity (the delta-rule reimplementations record 0.851 and 0.810 @1024 on the same probe, n=3 each — "Incumbent comparison" below); and S5 is not what non-diagonal transitions unlock — it is NC¹-complete, a ceiling on this entire fixed-depth class (previous bullet), while non-diagonal transitions unlock the solvable groups inside TC⁰.
Signed variant (SignedMinGRU)
The log-space scan requires positive transition coefficients (log of
1−z), which hard-codes two limitations at once: hidden states confined
to (0, ∞) via g, and transition eigenvalues confined to (0, 1) —
monotone EWMA-style memory only. SignedMinGRU lifts both by switching
to a linear-space associative scan (linear_scan). Two parameterizations
of the eigenvalue are available:
a_t = tanh(Linear_s(x_t)) # coupled=False (default)
h_t = a_t ⊙ h_{t−1} + z_t ⊙ Linear_h(x_t) # eigenvalues in (−1, 1), no g
a_t = (1 − z_t) ⊙ tanh(Linear_s(x_t)) # coupled=True (legacy)
h_t = a_t ⊙ h_{t−1} + z_t ⊙ Linear_h(x_t)
coupled=True is a bit-exact reproduction of the parameterization this
module shipped before this update (identical parameter shapes and
construction order, so identical seeds give identical weights) — kept as
the one-flag legacy path.
Why the default changed. The coupled form imposes a ceiling
|a_t| ≤ 1 − z_t: reaching the eigenvalue −1 that a task like parity
needs asks the gate to also saturate (z_t → 0) — one target value,
two simultaneous saturations to pay for. That shows up as the
length-generalization decay in the parity rows of "What this shows,"
above. The decoupled form removes the ceiling entirely: tanh's own
asymptote is the eigenvalue's attractor (the value the gate settles
toward under training), so it needs only one saturation and reaches the
target "for free," holding much closer to exact out to 4x-16x the
training length. That is why coupled=False is now the default — the
previous parameterization remains exactly reproducible behind
coupled=True.
This decoupled mechanism is Grazzi et al.'s (ICLR 2025, Unlocking
State-Tracking in Linear RNNs Through Negative Eigenvalues)
negative-eigenvalue range, instantiated in minGRU's gate structure — a
repo improvement, not an independent novelty claim (no incumbent
comparison against Grazzi et al.'s own parameterization has been run
here; see experiments/SUMMARY.md, Open work). The general motivation
for negative eigenvalues also traces to Merrill, Petty & Sabharwal
(2024, ICML): negative eigenvalues restore per-layer parity /
sign-alternation dynamics that a positive-diagonal scan cannot
represent at any width.
What decoupling does not restore: diagonal transitions still
commute (scalar multiplication), so non-abelian state tracking
(permutation composition and everything NC¹-complete) remains out of
reach at any width, coupled or not — that requires non-diagonal
transitions (RotationMinGRU or GivensMinGRU, below) or state-dependent
gates (i.e., a standard GRU). All diagonal variants, signed or not, remain
in TC⁰ per Merrill et al.'s iterated-scalar-product argument.
Practical differences from MinGRU: any real h_0 is legal (no
positivity check, no underflow clamp), and there are 3 linear heads
instead of 2 (mind parameter-matched comparisons). The scan is
O(T log T) work / O(log T) depth in pure torch ops. Numerical agreement
vs. the sequential path is covered in "Implementation notes," above.
The expressivity ladder, per layer:
| variant | scan | per-layer capability |
|---|---|---|
MinGRU (a ∈ (0,1)) |
log-space | monotone EWMA memory |
SignedMinGRU (a ∈ (−1,1)) |
linear-space | + parity, abelian tracking |
RotationMinGRU (2x2 blocks) |
matrix-combine | + non-commutative: S3/D3 exactly representable |
| state-dependent gates | none (sequential) | = a standard GRU |
The ladder's non-diagonal rung is implemented — see below.
Rotation variant (RotationMinGRU)
Completes the expressivity ladder. State is n = hidden_size / 2
independent 2D (planar) blocks; per block, the transition is a full
2x2 affine map instead of a scalar one:
M_t = R(theta_t) @ diag(1, tanh(u_t))
h_t = M_t @ h_{t−1} + b_t, b_t = z_t ⊙ Linear_h(x_t)
(h_{t−1}, h_t, b_t viewed as 2-vectors per block.) 2x2
rotation/reflection matrices form a non-abelian group under
composition (unlike scalar multiplication), so RotationMinGRU — built
on a non-commutative parallel scan, matrix_scan (Hillis–Steele over
the 2x2 affine monoid) — can represent state-tracking automata over
non-abelian groups that no diagonal scan can, at any width. D3
(isomorphic to S3, the smallest non-abelian group) embeds in O(2), so
one layer exactly represents the S3 running-product task; see the
mechanism verification in experiments/SUMMARY.md (per-block matrices
extracted from a trained model satisfy the D3 composition table to
~1e-4 — a certifiable automaton, not an inferred one).
The underlying idea — non-diagonal transitions from the orthogonal family unlocking non-abelian state tracking in a parallelizable linear RNN — is DeltaProduct's (Siems et al., NeurIPS 2025): they build transitions as products of generalized Householder reflections (two reflections compose to a rotation) and demonstrate group state tracking including dihedral tasks. What is specific to this variant is the fixed 2x2 planar-block parameterization with an explicit angle head, the STE snap grid that manufactures attractors at exact group angles, and the weights-level homomorphism certificate. Their transition rule is compared here at the mechanism level — see "Incumbent comparison (mechanism-level)," below — but no comparison against their released code or published numbers has been run; treat this variant as a minGRU-native take on their mechanism, not a claimed improvement over it.
Angle snapping (snap). With snap set (default (2, 3, 4, 6),
cycled across blocks), theta_t is quantized per block to an exact
multiple of 2π/K via a straight-through estimator (STE): forward uses
the snapped angle, gradient passes through the pre-snap "soft" angle
unchanged. This manufactures an attractor at exact group elements, the
same role tanh's asymptote plays for SignedMinGRU's eigenvalue:
without it (snap=None, a legitimate, documented ladder rung),
continuous rotation angles have no attractor and drift under length
generalization, since error in the angle compounds with sequence
length. The snap grid must contain the rotations being tracked. A
block with K in its grid can land exactly only on angles that are
whole multiples of 2π/K — picture a clock face with K positions.
If the pattern your task cycles through has a period that isn't on
that clock, the exact solution doesn't exist on the grid and the
model can only approximate it, no matter how long it trains.
Concretely: a task cycling through 5 states needs steps of 2π/5,
which a K=5 (or 10, 15, ...) clock has and a K=4 or K=6 clock
does not — so tracking Z/5 needs a multiple of 5 somewhere in snap.
Rule of thumb: for every cyclic pattern of period n the task must
track exactly, include a K that is a multiple of n. The default
snap=(2, 3, 4, 6) was chosen for the D3/S3 task — its rotation part
is a 3-cycle, covered by the 3 and 6 in the grid; other
state-tracking targets need their own grid.
How do you find your task's periods? Ask, for each repeatable
operation in your data: if this operation happens over and over,
after how many repeats is the tracked state back where it started?
That count is a period the grid must cover. A toggle (on/off, sign
flip) has period 2. A three-way cyclic swap has period 3. A running
counter mod 7 — day of week, for example — has period 7. If several
operations coexist, collect each one's period and cover them all (one
K per period, or a single K equal to their least common multiple).
And if nothing in your task ever cycles back exactly — states just
grow, drift, or fade — then there's no period to snap to, and the
rotation grid isn't the right tool for that part of the problem:
that's what the fading (decay) and flipping (signed) rungs are for.
Depth: L=2 is validated under the best-val@128 protocol; L=4 is
untested. Measured through the MinGRUStack list-mixer path (task
S3, best-val@128 protocol, 3 seeds each (0, 1, 2), MAX_STEPS=1600),
multi-seed means:
L=2 stack (task S3) |
seeds | acc@64 | acc@256 | acc@512 | acc@1024 |
|---|---|---|---|---|---|
rotation → signed (minGRU-hetero-rs) |
3 | 1.000 | 1.000 | 0.999 | 0.966 |
rotation × 2 (minGRU-rotation2) |
3 | 1.000 | 0.999 | 0.983 | 0.941 |
signed → rotation (minGRU-hetero-sr) |
3 | 0.999 | 0.976 | 0.940 | 0.863 |
L=1 reference (minGRU-rotsnap, above) |
8 | 1.000 | 1.000 | 0.996 | 0.958 |
All three depth-2 configurations land close to the L=1 reference;
rotation×2 (two rotation blocks) still emits the multi-rotation
warning below. Seed caveat: these are n=3 cells on a variant whose own
n=8 run spans 0.859–1.000 @1024, so three seeds sit inside the
demonstrated seed-noise band — read the table as "depth-2 stacks train
and stay in the L=1 reference's range," not as a ranking of the three
configurations. Deeper homogeneous rotation stacks (L=4) are untested
under this protocol and remain open. (Run history:
experiments/EXPERIMENTS.md.)
Depth-2 rotation stacks on the harder extract-then-compose task — and the cross-mechanism comparison they sit in — are covered in "The hierarchical task: S3-hier," below.
Training protocol: best-val@128 selection + retry-on-flag. The
exact automaton is reachable but is not a stable attractor of
standard training — runs wander in and out of the exact solution over
the course of optimization, and once train accuracy saturates the loss
is blind to the difference between an exact solution and a decaying
shortcut. The validated protocol replaces early-stop with
best-checkpoint selection by validation accuracy at a length longer
than the training length (T=128 when training at T=64 — not one of the
eventual eval lengths, so it cannot leak into reported metrics),
evaluated over the full step budget. A best-val@128 score below 1.0
flags the run as failed and should be retried rather than trusted.
probes.py's CKPT=1 env var implements the selection half of this
protocol:
CKPT=1 python probes.py S3 minGRU-rotsnap
Seed success rate. Across 8 fresh seeds under this protocol, only
1 of 8 lands the exact solution (accuracy 1.0 to the checked precision
at every length out to 1024); the other 7 reach best-val@128 = 1.0 (no
flag) yet still decay measurably at 4x-16x the training length (worst
seed 0.859 @1024). The mean length-generalization numbers in "What this
shows," above (1.000 @256, 0.996 @512, 0.958 @1024) are the honest
average over all 8 seeds, including the non-exact ones — not a
best-seed headline. Per the mechanism verification in
experiments/SUMMARY.md, every seed — including the non-exact ones —
contains a D3 representation readable off its weights; failed seeds
are simply less exact, not missing the mechanism.
The flag is therefore one-directional: best-val@128 < 1.0 reliably means retry, but the flag passing does not certify exactness at length — confirm length generalization directly rather than relying on the flag alone, and budget for retries when reproducing this variant.
Incumbent comparison (mechanism-level). The DeltaNet (Yang et al.)
/ DeltaProduct (Siems et al., NeurIPS 2025) transition rule is
reimplemented as a lab mixer, DeltaNetMixer in
experiments/variants.py: nh=1 ("deltanet", a single Householder
reflection per token) and nh=2 ("deltaproduct2", two reflections
per token, composing to a rotation). This is a reimplementation of the
transition rule, not their released code (Triton/CUDA, a different
training stack that does not run in this repo's CPU-only environment),
measured under the same protocol as the S3 table above (3 seeds,
best-val@128 checkpoint selection, MAX_STEPS=1600):
| task | model | seeds | protocol | acc@64 | acc@256 | acc@512 | acc@1024 |
|---|---|---|---|---|---|---|---|
| S3 | deltanet (nh=1), L=1 |
3 | best-val@128 | 0.419 | 0.356 | 0.345 | 0.339 |
| S3 | deltaproduct2 (nh=2), L=1 |
3 | best-val@128 | 1.000 | 1.000 | 1.000 | 0.989 |
| S3 | minGRU-rotsnap, L=1 (reference, above) |
8 | best-val@128 | 1.000 | 1.000 | 0.996 | 0.958 |
nh=1 cannot fit S3 (mean@64 = 0.419, in the diagonal/reflection chance
band). A single reflection has determinant -1 on every application and
cannot compose to the even-permutation elements S3 needs, matching
DeltaProduct's own representability theory. nh=2 fits S3 on every
seed and trains reliably with no retry protocol: all three seeds reach
best-val@128 = 1.000 by step 200-300 (against the 1600-step budget) and
no seed raises the sub-1.0 flag that minGRU-rotsnap relies on to mark
a run for retry. That is a materially better training profile than
minGRU-rotsnap on this task at this budget: the snap grid's rotsnap
reference lands the exact solution in only 1 of 8 seeds and still needs
the retry-on-flag protocol to separate that seed from the other 7,
while deltaproduct2's 3 of 3 seeds all land near-exactly without any
retries (an unequal-sample comparison — n=3 vs. n=8 — though the
val@128-by-step-300 unanimity leaves little room for a seed-luck
reading). On parity, both incumbent configurations show the same
length-generalization inconsistency across seeds: deltanet mean@1024
= 0.851 (per-seed range 0.730-1.000) and deltaproduct2 mean@1024 =
0.810 (per-seed range 0.692-1.000), both below this repo's recorded
signed-tanh parity result (n=6, mean@1024 = 0.994, worst seed 0.984).
Signed-tanh's tanh asymptote gives it a length-generalization
attractor the delta rule's beta/k parameterization does not share
under this protocol.
What the snap mechanism still uniquely provides: an exact solution when
a seed lands it (minGRU-rotsnap's 1 exact-to-16x seed of 8, vs.
deltaproduct2's near-exact-but-not-exact 0.974-0.999 @1024 across all
3 seeds) and the weights-level D3 homomorphism certificate (per-block
matrices extracted from a trained model satisfy the D3 composition
table to ~1e-4 — see above and experiments/SUMMARY.md).
Capacity disclosure. At the shared d_model=64, parameter counts
are deltanet 16,900 / deltaproduct2 25,480 vs. RotationMinGRU
12,544 / SignedMinGRU 12,480. Both incumbent configurations carry
more parameters than the minGRU variants they are compared against,
and deltaproduct2 carries roughly 2x deltanet's own count. More
materially, both incumbent configurations carry a per-token state of
n_heads * d_k * d_v = 1,024 elements (4 heads x 16 x 16), against 64
state elements per token for the minGRU variants. The incumbents
carry roughly 16x more state per token at this d_model, which favors
their fit and length-generalization numbers above. These are
same-d_model, not same-capacity, comparisons.
All of the above is budget-relative (1600-step budget, 3 seeds, this
repo's harness and protocol only): it is not a claim about the
incumbents' released systems, published training regimes, or published
numbers, and it does not run in the other direction either: nothing
here shows the minGRU variants would still lead at a larger budget or a
different protocol. (Run history: experiments/EXPERIMENTS.md.)
Alternatives tried and dropped. Three other fixes were tested and
abandoned: a full orthogonality constraint on the transition matrices,
a regularizer that pulls angles toward the snap grid, and post-hoc
projection/ablation of near-exact blocks at inference. Each either
hurt length generalization or was redundant with the best-val@128
selection above (full comparison in experiments/SUMMARY.md, rounds 5
and 8).
Practical differences from the other mixers: 4 linear heads (z, h,
theta, u) vs. SignedMinGRU's 3 / GivensMinGRU's 3 / MinGRU's 2
(mind parameter-matched comparisons); h_0 is an intrinsic learned
parameter with no learnable_h0 flag (see "State conventions");
hidden_size must be
even (ValueError otherwise). The scan is O(T log T) work / O(log T)
depth in pure torch ops, same tradeoff as linear_scan. Numerical
agreement vs. the sequential path is covered in "Implementation notes,"
above.
The hierarchical task: S3-hier (extract, then compose)
(This section is the evidence base for the promoted GivensMinGRU
mixer among others; "Givens variant," below, gives that mixer's own
summary, mechanism, and costs without repeating this table. Protocol
terms — best-val@128 selection, the retry flag — are defined under
"Rotation variant," above.)
The task. S3-hier (chance ≈ 1/6) is the harder of the repo's two
S3 probes: the group operation is hidden inside a pair of sub-tokens.
Sub-tokens are drawn uniformly from {0..5}; each consecutive pair
(x[2k], x[2k+1]) selects a generator g = LATIN[a, b], composed onto
the running S3 product when the pair completes. Labels are dense: odd
positions carry the just-updated composition, even (mid-pair) positions
carry the previous one.
LATIN is a fixed 6x6 Latin square (every symbol exactly once per row
and column), so a single sub-token carries no information about the
generator: the distribution of g given a alone — or b alone — is
uniform, and no per-token shortcut exists. It is additionally verified
non-isotopic to both groups of order six (Z6 and S3): no relabeling of
rows, columns, and symbols turns it into either group's Cayley table,
so a rotation layer cannot absorb the lookup by relabeling its angle
assignment the way it can for a plain group operation. (VERIFY_LATIN=1
re-runs the import-time sanity check; probes.py documents the
exhaustive offline isotopy verification.) Extraction is therefore
genuine work that must happen before composition — the property the
heterogeneous stacks below are built around.
A worked sample, verbatim from probes.make_s3_hier (seed-0 training
stream, batch row 0, first six steps):
x 1 5 0 2 1 1 ...
pairs (1,5) (0,2) (1,1)
g 0 5 2 g = LATIN[a, b]
y 0 0 0 5 5 1 ...
The label updates only when a pair completes and carries mid-pair; the first drawn pair happens to map to the identity, so the label holds at 0 through step 4.
Depth vs. hierarchy: a tradeoff, not a ranking. Measured at
MAX_STEPS=1600 unless noted (seeds per row as listed; every number
budget-relative), multi-seed means. Rotation- and delta-bearing rows
use best-val@128 checkpoint selection (val@384 where noted);
signed-tanh and GRU rows use the early-stop protocol they are
recorded under. The n=6 minGRU-hetero-sr row pools rounds
hetero-legB-v2 (seeds 0-2) and hetero-loop-03-base-ext (seeds
3-5), same protocol:
config (task S3-hier, chance ≈ 0.167) |
seeds | acc@64 | acc@256 | acc@512 | acc@1024 |
|---|---|---|---|---|---|
signed → givens8 (hetero-sg8, experiments/hetero_lab.py) |
12 | 0.949 | 0.885 | 0.787 | 0.613 |
signed → deltaproduct2 (hetero-sd2, experiments/hetero_lab.py) |
6 | 1.000 | 0.987 | 0.814 | 0.530 |
signed → 64-state delta (hetero-sdm) |
12 | 0.575 | 0.495 | 0.457 | 0.376 |
| signed-tanh, L=2 (homogeneous) | 3 | 0.985 | 0.866 | 0.754 | 0.620 |
signed → rotation (minGRU-hetero-sr) |
6 | 0.572 | 0.442 | 0.375 | 0.309 |
rotation → signed (minGRU-hetero-rs) |
3 | 0.448 | 0.325 | 0.247 | 0.206 |
rotation × 2 (minGRU-rotation2) |
3 | 0.365 | 0.256 | 0.212 | 0.189 |
deltaproduct2, L=1 |
6 | 0.238 | 0.184 | 0.176 | 0.171 |
GRU, L=1 |
3 | 0.232 | 0.184 | 0.175 | 0.171 |
minGRU-rotsnap, L=1 |
3 | 0.225 | 0.183 | 0.174 | 0.170 |
signed → rotation at 4x budget (MAX_STEPS=6400) |
3 | 0.890 | 0.658 | 0.570 | 0.502 |
Homogeneous 2-layer signed-tanh wins in-budget fit among the
rotation-family rows but shows the same length decay documented for
SignedMinGRU above: a diagonal-scan approximation to a genuinely
non-commutative composition, not the exact automaton. The
extract-then-compose rotation stack (signed → rotation) fits on one
seed in six at the standard budget (best-val@128 0.988 on that seed)
and sits near chance on the rest — its n=6 mean hides a 0.186–0.535
per-seed spread @1024. Rotation-bearing configurations show
a 0.86–1.0 per-seed spread at n=8 on plain S3 — the large gaps here
(fit vs. chance) are the trustworthy signal, not small differences
between adjacent rows.
The 4x-budget row's mean hides a split: one of three seeds finds the
exact solution and generalizes to 0.9834 @1024 (the best
length-generalization figure measured on S3-hier), while the other
two don't. Best-val@128 correctly flags those two seeds (0.573, 0.763)
as runs to retry, not trust. This is the same fast-but-unstable
training dynamic already documented for plain minGRU-rotsnap,
extending into the hetero stack's harder joint extract-and-compose
optimization problem.
Why the results split this way: composing first is hard to undo — a
rotation layer fed raw sub-tokens builds a mixture nothing downstream
is observed to take apart — a design rationale consistent with
rotation→signed winning on plain S3 (where tokens are the
operations) and sitting at chance here, though the order effect is not
itself isolated from other differences between the two stacks. Why the
working order still trains poorly is measured rather than
hypothesized, as far as linear probes can see: the extractor is not
the bottleneck on any recorded seed. On instrumented reruns, a linear
probe decodes the pair's generator from layer-1 states at ≥0.95 within
400–800 steps on every seed — including seeds that plateau
permanently — while the composer's snapped angles keep oscillating
between grid cells (7–24% of snapped indices flip between checkpoints
on plateau seeds, collapsing to ~0.1–1% only on a run that locks). The
failure is the composer's search for an exact discrete solution, and
that solution's training basin is measurably tiny at the standard
budget: retraining for 1600 steps from the winning weights perturbed
by 1% per-tensor noise recovers near-exact accuracy on 2 of 3 seeds
but returns to the exact automaton on none, at any perturbation scale
tested (one winner checkpoint, Gaussian perturbations, n=3 per scale;
longer retrain budgets untested — round hetero-loop-14-basin).
Auxiliary supervision or extractor-first schedules would target a
coupling bottleneck that does not exist; best-val@128 remains the
working tool — it hits 1.0 on runs that land the exact automaton and
flags the ones that don't.
Swapping the composer mechanism makes the attribution causal. Keeping
the signed extractor and replacing the snapped-rotation composer with
a DeltaProduct-style nh=2 delta-rule layer (hetero-sd2, top table
row) takes S3-hier fit from 1/6 seeds to 6/6 at the same budget
(best-val@128 = 1.0 by step 500–1600, no retries, no seed lottery),
while that same delta layer alone at L=1 sits at chance on all six
seeds — the extract-then-compose depth split is still doing the work.
The cost is exactness: the delta composer reaches 0.530 @1024 (n=6),
rising to ≈0.60 at 4x budget (selected at val@384, a non-test length,
since val@128 saturates once fit) and approaching an asymptote well
below the 0.983 @1024 that the rotation composer's rare exact run
holds. Diagnostics rule out amplitude decay, extractor feature drift,
input non-stationarity, and per-class map noise as fixable causes of
that drift — quantizing the composer's input preserves in-distribution
fit and worsens generalization, so the composer's continuous code is
functional, not noisy (per-seed evidence:
experiments/EXPERIMENTS.md, hetero-loop rounds).
Within the rotation family, per-token map richness separates reliable
composers from unreliable ones — measured by holding the per-token
state at 64 elements (every promoted mixer's size) and parameters
nearly constant (+2.2% full-stack). 2-dimensional rotation blocks fit
1/12 seeds continuous (1/6 snapped); 8-dimensional rotation blocks
(givens8: per-token transitions built from three rounds of Givens
rotations, special-orthogonal by construction, continuous, trained
through the same parallel associative scan) fit 8/12 (Fisher exact
p ≈ 0.009 against the 2D rate), with three of the four misses
near-fit (best-val@128 0.83–0.98) and one low seed.
A rounds ablation at fixed block size attributes that gap to the coupling that breaks within-block commutativity, not to block size: with rounds=1 (four disjoint, commuting 2D planes inside the 8D block) the composer fits 0/12 — no better than the 2D composer — while the single staggered coupling layer of rounds=2 recovers 6/12 and rounds=3 reaches the 8/12 above (0/12 vs 8/12 p ≈ 0.0013; rounds 2 vs 3 inseparable, p ≈ 0.68; fit-only generalization unchanged across rounds — per-seed rows under hetero-loop-19-rounds). The delta rule
shrunk to the same 64-element state fits 4/12 with chance plateaus on
the misses (hetero-sdm above) — so the full-size delta composer's
6/6 owes much of its reliability to its 16x-larger state (1,024
elements per token, p ≈ 0.013 for the state-size effect; capacity
disclosure above) — though the Givens-vs-small-delta comparison
itself (8/12 vs 4/12, p ≈ 0.22, unmatched composer parameters: 14,624
vs 3,306) is suggestive rather than established. Fit quality is
mechanism-independent: seeds that fit generalize equally under either
composer (0.733 vs 0.739 @1024 fit-only means) — the mechanisms
differ in how often training finds a solution, not in how well the
found solutions generalize. Among configurations that fit reliably,
the Givens-8 composer's fit-only profile (0.927 @512, 0.733 @1024;
best seed 0.956/0.812) leads the full-size delta composer's
(0.815/0.530 across its 6/6 fits); the homogeneous signed-tanh L=2
row (0.620 @1024) sits close behind under its different (early-stop)
protocol — per-seed rows, parameter counts, and Fisher tests in
experiments/EXPERIMENTS.md.
A lone rotation layer, rotation×2, rotation→signed, a lone delta-rule
layer (deltaproduct2, L=1), and an unconstrained GRU all sit at or
near chance on S3-hier: no single capability (depth alone, or a
composition mechanism alone) is enough — extracting the pair and
composing it non-commutatively both have to happen. Read together:
homogeneous signed depth trains reliably and decays with length;
composer reliability rises with per-token map richness within the
rotation family and with state size within the delta family; and
exactness at length remains unique to the snapped composer's rare
winner (0.983 @1024) — no continuous composer reached it. No configuration wins outright and no claim is
budget-independent: every number above is relative to the stated step
budget, not a claim that any configuration solves S3-hier.
Givens variant (GivensMinGRU)
Enriches the non-commutative rung of the ladder without leaving it.
RotationMinGRU gives each transition a single 2x2 planar rotation;
GivensMinGRU builds each per-token transition from a product of Givens
rotations acting on a block_size-dimensional block of state, a strictly
richer family of orthogonal maps at the same per-token state.
What a Givens rotation is. A Givens rotation is the smallest possible
rotation: it acts on one coordinate plane of a k-dimensional space and
leaves the other k−2 directions fixed — the identity matrix except for
cos/sin entries in two rows. It is the atom of orthogonal structure:
exactly orthogonal at every parameter value (no normalization step),
determinant +1, one angle per plane. RotationMinGRU is already a Givens
machine at k = 2 — one plane, one input-dependent angle per token.
GivensMinGRU composes many of them: at the default block_size = 8,
rounds = 3, each per-token transition is a product of three brick-wall
layers of Givens rotations (round 0 pairs planes (0,1),(2,3),(4,5),(6,7);
round 1 the staggered planes (1,2),(3,4),(5,6),(7,0); round 2 repeats
round 0's pattern), all angles emitted by one linear head. Disjoint planes
within a round commute; the stagger between rounds is what couples all
eight dimensions. Products of enough Givens rotations reach all of SO(k),
so three rounds are a deliberate budget, not a representational limit.
The brick-wall mesh itself is the standard construction from the
orthogonal/unitary-RNN literature — EUNN (Jing et al., 2017), built on the
rectangular interferometer mesh of Clements et al. (2016), with the Givens
rotation as described in Golub & Van Loan — borrowed deliberately: what is
new here is the input-dependent angles per token and the measured
trainability result, not the factorization. The
result stays continuous, stays exactly special-orthogonal, stays parallel
(the same associative scan, generalized to k×k blocks by
matrix_affine_scan), and keeps the standard 64-element per-token state at
the repo's d_model.
Where the richer map earns its place. The extra structure is a
trainability lever, not a jump in expressivity class — an 8D Givens
product tracks the same solvable-group automata as a 2x2 rotation, and
both remain below the state-dependent-gate line. What it changes is how
often training finds a working composer. Within the rotation family, at
matched 64-element per-token state, 8-dimensional Givens blocks fit
S3-hier on 8 of 12 seeds against 1 of 12 for continuous 2D rotation
blocks (Fisher exact p ≈ 0.009). The full measured evidence — per-length
means, the delta-family comparison, and the map-richness argument — is
tabulated under "The hierarchical task: S3-hier," above; this section covers the
mechanism and its costs rather than restating that table.
The trade it does not escape. Orthogonality buys norm preservation: no
amplitude decay across hundreds of compositions. But continuity still
means no attractor — nothing pins a learned angle to the exact group
element it approximates, so small angle errors compound with length
exactly as an unsnapped RotationMinGRU's do, and accuracy still decays by
T = 1024. Among seeds that fit, the givens8 composer holds 0.927 @512
and 0.733 @1024 (best seed 0.956 / 0.812); pooled across all 12 seeds the
signed → givens8 profile is 0.949 / 0.885 / 0.787 / 0.613 at
@64 / @256 / @512 / @1024 (the top row of "The hierarchical task: S3-hier"
table). Exactness at length remains unique to the snapped 2D composer's
rare exact seed (0.983 @1024) — no continuous composer, Givens included,
reaches it. Snapping the Givens angles is the obvious hybrid and an open
question, not a promise.
Capacity disclosure. At matched 64-element per-token state, the givens8 composer carries 14,624 parameters against the 2D-rotation composer's 12,544 — the map-richness gradient above costs +2.2% at the full stack, not a step change in capacity, so the fit-rate separation is not attributable to parameter count. Against the delta-rule composer shrunk to the same 64-element state (which fits 4 of 12 seeds), the comparison is parameter-unmatched (14,624 vs 3,306) and reads as suggestive only (p ≈ 0.22).
Measured CPU cost. The parallel k×k scan is not the cheap path on CPU.
Measured uncontended forward+backward at B = 128, T = 64 (min of three
runs), one training step costs 0.961s for the parallel-scan givens8
transition against 0.179s for the sequential delta-rule path. The case for
GivensMinGRU is the parallel-only design constraint together with fit
reliability and length generalization within the rotation family, not
training-step speed: no parallel-scan configuration measured here beats the
sequential delta path on CPU, and the promotion rests on the first three
properties rather than the last.
Practical differences from the other mixers: 3 linear heads (theta, z, h)
vs. RotationMinGRU's 4 / SignedMinGRU's 3 (mind parameter-matched
comparisons); hidden_size must be a multiple of block_size and
block_size must be even (ValueError otherwise); h_0 is an intrinsic
learned parameter with no learnable_h0 flag (see "State conventions");
stacks holding more than one Givens block construct without the
multi-rotation warning, since the continuous transition has no
straight-through snap to compound. The scan is O(T log T) work / O(log T)
depth in pure torch ops. Numerical agreement vs. the sequential path is
covered in "Implementation notes," above.
Time-aware decay
Every mixer above assumes evenly-spaced steps: one update per token,
no notion of how much real time elapsed between events. decay= adds
an optional per-event forgetting term so a gap between events shrinks
whatever the recurrence was already going to keep, without touching
how it injects new information. Available on MinGRU, SignedMinGRU,
RotationMinGRU, and GivensMinGRU alike, and threaded through
MinGRUBlock / MinGRUStack.
What follows works mechanism-first: the transition math and the
delta_t contract below, then two experiments — a channel ablation
asking what the mechanism adds beyond just handing a model the raw
time gaps as an input feature ("Does the decay mechanism add anything
beyond a delta_t feature?" — short answer: the feature does most of
the work; the mechanism adds a smaller, separately-measurable margin
on top), and a recovery check showing what an unhelpful decay rate
costs ("Recovery check"). Skip ahead to either if you want the
evidence before the mechanism.
Mechanism. Each step's transition coefficient is scaled by
gamma = exp(-lambda * f(delta_t)), where lambda >= 0 is a per-
channel (per-block, for RotationMinGRU/GivensMinGRU) decay rate and
f is identity or log1p. gamma multiplies the transition only —
injection (b_t, the new information entering at this step) is never
decayed, in every mixer:
MinGRU(log-space): additive in log-space —log(gamma * (1 - z_t)) = log(1 - z_t) - lambda * f(delta_t)— no newexp/loground-trip.SignedMinGRU:gamma * a_t(the signed diagonal transition).RotationMinGRU:gamma * M_t— decay scales the whole 2x2 block by a positive scalar, so the snapped rotation angle is recovered unchanged from the decayed matrix; only amplitude is affected (direction/amplitude separation, unaffected by decay).GivensMinGRU:gamma * M_tper block, the identical semantics toRotationMinGRU— a scalargammacommutes with the orthogonal block action, so the composed rotation is recovered unchanged from the decayed matrix; only amplitude fades.
Because lambda >= 0 and delta_t >= 0, gamma is always in (0, 1] — decay can only shrink the transition, never amplify it.
The delta_t contract. delta_t is (B, T) or (B, T, 1) to
forward (squeezed internally), (B,) or (B, 1) to step.
delta_t[:, t] is the gap preceding event t. delta_t = 0 gives
gamma = 1 exactly — no discount — and this holds at every position,
including t = 0: there is no implicit "first event is exempt
from decay" special case (see design notes, below); callers
who want no decay at the start of a sequence pass delta_t[:, 0] = 0
themselves. Negative entries are clamped to 0 with a
once-per-module-instance warning (a data-quality event, not a hard
error; the check runs on CPU tensors only — on CUDA it is skipped
rather than forcing a host sync, though the clamp always applies).
log1p_delta=True applies log1p before scaling by lambda
— useful when raw gaps span orders of magnitude, compressing the
range lambda needs to operate over. Passing delta_t with decay
disabled, or enabling decay without passing delta_t, both raise
ValueError at call time.
Two modes, set via decay=:
"fixed":lambda = decay_rate, a scalar buffer, uniform across channels/blocks — not learned."learnable":lambda = softplus(rho), onerhoper hidden channel (MinGRU/SignedMinGRU) or per block (RotationMinGRU/GivensMinGRU);rhois initialized solambda == decay_rateexactly at construction (decay_rateis then an init value, not a fixed constant).
decay_layers (stack-level, MinGRUStack(..., decay_layers=...)):
"all" (default) applies the decay keys in mixer_kwargs to every
block uniformly; "last" strips them from every block but the final
one, for last-layer-only decay (a common convention in stacked
recurrent encoders). Any other string raises ValueError at
construction.
delta_t passed to a stack's forward/step is routed only to
blocks whose mixer has decay enabled; if no block in the stack decays,
passing delta_t raises ValueError.
Design notes — two choices worth knowing about, since other time-decayed RNNs commonly make them differently:
softpluson the rate parameter itself, not a learned projection ofdelta_t. A common alternative computes the rate asrelu(proj(delta_t)). Both halves of that form were rejected:reluhas the module's own documented dead-gradient problem (seelog_gin "Implementation notes"), and swapping insoftplusover a projection would makesoftplus(proj(0))strictly positive — decaying even at zero gap and violating thedelta_t = 0 => gamma = 1contract. Resolution:softpluson a plain learnable rate (lambda = softplus(rho)), not on any function ofdelta_t.- No
t = 0exemption. Some implementations exempt the first event from decay as a special case; this module doesn't need one — thedelta_t[:, 0] = 0convention (above) already givesgamma = 1there for a true sequence start. Not special-casing position 0 is also what keeps chunked-vs-full equivalence exact: an absolute-position exemption would have to know which chunk is the first one.
from mingru import MinGRUStack
import torch
stack = MinGRUStack(
input_size=32, d_model=64, n_layers=2, mixer="signed",
mixer_kwargs={"decay": "learnable", "decay_rate": 0.05, "log1p_delta": True},
)
x = torch.randn(8, 100, 32) # (B, T, input_size)
delta_t = torch.rand(8, 100).clamp(min=0.01) # gap preceding each event
delta_t[:, 0] = 0 # true sequence start
y, state = stack(x, delta_t=delta_t) # (B, T, 64), per-block states
Does the decay mechanism add anything beyond a delta_t feature?
delta_t can reach a model two ways: as an appended input feature
(log1p(delta_t) concatenated onto the token embedding, available to
every model) or mechanically (consumed by a mixer's decay path,
scaling its transition). session-parity (a running-XOR task that
resets at session boundaries — gaps well above a threshold, vs. gaps
well below it within a session) isolates the two channels under a
fairness rule: every model gets the feature; only decay-enabled rows
additionally get the mechanism. Three probe rows form a channel
ablation, not a head-to-head model comparison: feature channel only
(minGRU-signed-tanh), mechanism channel only
(minGRU-signed-tanh-tdecay-mech), and both channels together
(minGRU-signed-tanh-tdecay).
Protocol: seq2seq tagging, T_train=64, budget <=1500 steps,
early-stop at 99.9% train-length accuracy (the same protocol as the
other signed-tanh rows), 3 seeds (0, 1, 2), decay="learnable" at
decay_rate=0.05 init, log1p_delta=True.
| task | model (channel) | seeds | acc@64 | acc@256 | steps to early-stop |
|---|---|---|---|---|---|
| session-parity | both channels (minGRU-signed-tanh-tdecay) |
3 | 1.000 | 0.998 | 233 (avg) |
| session-parity | feature channel only (minGRU-signed-tanh) |
3 | 0.998 | 0.993 | 600 (avg) |
| session-parity | mechanism channel only (minGRU-signed-tanh-tdecay-mech) |
3 | 0.971 | 0.947 | never (full 1500-step budget, every seed) |
The feature channel does most of the work. Isolating the mechanism channel alone shows this directly: it loses to feature-only on both accuracy axes (acc@64 0.971 vs. 0.998, acc@256 0.947 vs. 0.993) and never reaches the early-stop threshold within the standard 1500-step budget. A 4x-budget check (6000 steps, same 3 seeds) narrows the gap — acc@256 rises to a 0.975 mean — but doesn't close it and still never early-stops; the deficit looks structural, not simply an artifact of under-training. Removing the feature channel doesn't change the qualitative recovery-check picture either (below) — the mechanism-only row's behavior there tracks the both-channels row closely.
Both channels together beats feature-only, modestly and with a
disclosed confound: it wins acc@256 on every matched seed (0.997 vs.
0.990, 0.999 vs. 0.997, 0.998 vs. 0.992 — though the ranges touch:
both-channels' worst seed equals feature-only's best at the ledger's
precision), converges to the early-stop threshold ~2.6x faster on
average (233 vs. 600 steps), and reaches a ~3x lower error rate (0.2%
vs. 0.7%). The learned rate also lands in a consistent, narrow band
across seeds (mean 0.052-0.053) — the mechanism settles on a timescale
that separates the within-session gap range ([0.1, 1.0]) from the
boundary range ([50, 100]) rather than drifting seed-to-seed. The
both-channels row also carries extra per-channel rate parameters the
feature-only row lacks, so its win is not the mechanism in isolation.
Read the two comparisons together: the feature and mechanism channels are complementary, not substitutable — but asymmetrically so. The feature channel carries the both-channels win; the mechanism channel underperforms on its own and adds a separately-measurable, smaller improvement on top of the feature, rather than standing in for it.
Recovery check: does an unhelpful decay rate anneal away?
On plain parity — a task that never needs a reset, but with the exact
delta_t distribution from session-parity supplied anyway — a
well-behaved decay mechanism should settle near "don't decay." This
checks that half, plus whether accuracy actually recovers to the
non-decay level (same protocol/seeds as above; steps omitted below
since both rows converge in <=100 steps):
| task | model | seeds | acc@64 | acc@256 | lambda mean |
|---|---|---|---|---|---|
| parity-timestamped | both channels (minGRU-signed-tanh-tdecay) |
3 | 1.000 | 0.786 | 0.0499 |
| parity | non-decay comparison (minGRU-signed-tanh) |
3 | 1.000 | 1.000 | n/a |
The learned rate does stay near its low 0.05 init (mean 0.0499, not
drifting toward heavier decay) — the qualitative half of the recovery
check holds. Accuracy does not fully recover: a ~21-point acc@256
gap remains (0.786 vs. 1.000). Cause: log1p of a boundary-scale gap
(delta_t in [50, 100]) is already ~4-4.6, so even at
lambda ≈ 0.05, gamma ≈ exp(-0.05 * 4.3) ≈ 0.81 per boundary-scale
event — and session-parity's distribution puts roughly a dozen such
gaps into a 256-step sequence, compounding to a large state loss by
T=256 even though lambda looks small in isolation. Practical
consequence: decay_rate (or its learnable init) is a real
hyperparameter, not a knob training reliably anneals to zero on its
own when a task doesn't need decay — pick it with the task's actual
gap distribution in mind.
A finer init sweep (same protocol and seeds, 3 per cell; the 0.05
rows are the tables above) makes the tradeoff explicit:
decay_rate init |
session-parity acc@256 | steps to early-stop (avg) | recovery acc@256 |
|---|---|---|---|
| 0.005 | 0.997 | 633 | 0.997 |
| 0.01 | 0.995 | 533 | 0.986 |
| 0.02 | 0.997 | 467 | 0.895 |
| 0.05 | 0.998 | 233 | 0.786 |
| reference | feature-only: 0.993 | 600 | non-decay: 1.000 |
Three things move monotonically and in lockstep with the per-boundary
gamma = exp(-R * ~4.3) (0.98 at R=0.005 down to 0.81 at
R=0.05). The recovery gap closes as the init drops — essentially
gone (~0.3pp) at 0.005. The session-parity advantage thins in the
other direction: every init beats the feature-only baseline at the
mean, but the accuracy differences between inits sit within seed
noise; what cleanly separates 0.05 is convergence speed (233 steps,
vs. 467-633 at the smaller inits — the smallest init is slower than
the baseline's 600). No single init gets both ends.
The sweep also sharpens why training won't fix this for you: the
learned rate moves up but never down. On session-parity, lambda
climbs above its init at every R (e.g. 0.0050 → 0.0057, 0.0500 →
0.0530) — when decay helps fit the training sequences, the gradient
signal exists and training uses it. On the recovery task it sits
exactly at init at every R: over-decay's cost only materializes at
lengths beyond T_train, so the training objective contains no
signal against it. The init is a prior that training refines in one
direction only. Since the trend is smooth and monotone, the reliable
way to set decay_rate is validation at your target sequence
length (the same longer-length selection lesson as the checkpoint
protocol above): around 0.05 when gaps are known-informative,
0.005–0.01 as the conservative default when unsure, and
decay=None when they're known-irrelevant.
Reproducing
probes.py tests the ladder empirically on the two word problems
defined in "What this shows" (seq2seq tagging with dense supervision,
following Merrill et al.'s setup). Models train at T=64 and are
evaluated at T=64 (in-distribution) and longer lengths (256/512/1024,
length generalization) — the length-gen columns are what separate
"expresses the recurrent solution" from "learned a depth-bounded
shortcut for the training length."
Two torch pins. The installed package advertises a torch>=2.8 floor so the optional Triton scan backend is available to library users. The evidence commands here run from a repo checkout, not an install: the root min_gru.py/triton_scans.py drivers re-export the packaged library from src/, so every recorded command reproduces verbatim under the frozen torch==2.5.1 evidence pin with nothing installed and no Triton import attempted. Pin it explicitly with, for example, uv run --python 3.12 --with 'torch==2.5.1' python min_gru.py (or the probes.py commands below).
python probes.py TASK MODEL [N_LAYERS]
TASK— one ofparity,S3,S3-hier,session-parity,parity-timestamped.S3-hieris the harder extract-then-compose probe (chance ≈ 0.167; see "The hierarchical task: S3-hier"). The last two supplydelta_t(see "Time-aware decay");GRUhas nodelta_tinput path and raisesValueErroron them.MODEL— one ofGRU,minGRU,minGRU-signed,minGRU-signed-tanh,minGRU-signed-tanh-tdecay,minGRU-signed-tanh-tdecay-mech,minGRU-rotsnap,minGRU-hetero-sr,minGRU-hetero-rs,minGRU-rotation2,minGRU-hetero-sg8.minGRU-signedis pinned tocoupled=True: the legacy parameterization, kept under its historical name so recorded rows keep their meaning. The two-tdecayrows aredecay="learnable"atdecay_rate=0.05,log1p_delta=True;-mechskips thedelta_tfeature concat (see "Time-aware decay").minGRU-hetero-sr/-rsare 2-layerMinGRUStack(mixer=["signed", "rotation"])stacks, one order each (signed→rotation, rotation→signed);minGRU-rotation2ismixer=["rotation", "rotation"], the broken-baseline reference (emits oneUserWarningat construction).minGRU-hetero-sg8is themixer=["signed", "givens"]stack (signed extractor → Givens composer) measured onS3-hier; it constructs without a warning. All four fixN_LAYERSto the mixer list's length (2) — omit it or pass 2 explicitly; a conflicting value raisesValueError. See "Rotation variant" and "Givens variant" for their measured accuracy.N_LAYERS— defaults to1. The single-mixerminGRU-rotsnaprow runs atL=1(its recorded protocol); rotation at depth runs through the list-mixer rows above — see "Rotation variant" for the measured depth tradeoff.MAX_STEPS(env var) — overrides the training budget (default 1600).CKPT=1(env var) — replaces early-stop with best-val@128 checkpoint selection; required forminGRU-rotsnap's validated protocol, off by default so legacy early-stop rows stay reproducible.
Single-cell examples:
MAX_STEPS=200 python probes.py parity minGRU-signed # legacy coupled reproduction
CKPT=1 python probes.py S3 minGRU-rotsnap # protocol-correct rotation-snap run
python probes.py session-parity minGRU-signed-tanh-tdecay # time-aware decay channel ablation
A smoke-test grid covering the ladder (single seed per cell — the
multi-seed numbers in "What this shows," above, come from
experiments/variants.py, same protocol plus extra seeds and eval
lengths 512/1024):
for t in parity S3; do
python probes.py $t GRU
for m in minGRU minGRU-signed minGRU-signed-tanh; do
python probes.py $t $m 1
python probes.py $t $m 4
done
done
CKPT=1 python probes.py parity minGRU-rotsnap 1
CKPT=1 python probes.py S3 minGRU-rotsnap 1
CPU-only is sufficient; most cells run in seconds to ~15 minutes (1-layer cells fastest), rotation-snap cells run ~1-2 minutes each. Expect small numeric differences across torch versions but the same qualitative pattern.
The @512 column in "What this shows" is not separately tabulated in
experiments/SUMMARY.md; it is computed directly from
experiments/lab_results.jsonl using the identical seed sets as the
corresponding @256/@1024 cells. See experiments/SUMMARY.md for the
fully reproduced evidence trail (per-seed results, the mechanism
verification, and the record of what was tried and dropped).
Tests
python min_gru.py runs the built-in suite: parallel-vs-sequential
equivalence for every mixer (MinGRU, SignedMinGRU decoupled and
coupled=True, RotationMinGRU, GivensMinGRU, and their stacks),
chunked-vs-full equivalence for the same set (including carries with
negative/unbounded states), SignedMinGRU(coupled=True)
construction-order determinism (bit-exact legacy reproduction),
matrix_scan/matrix_affine_scan vs. brute-force sequential recurrence
plus a gradcheck, RotationMinGRU's snapped-angle grid exactness and
gradient flow into all four heads and h_0, GivensMinGRU's exact
orthogonality/determinant and gradient flow into all three heads and
h_0, RotationMinGRU's ValueError on odd hidden_size,
GivensMinGRU's ValueError on indivisible hidden_size and odd
block_size, MinGRUBlock's ValueError on an unknown mixer name,
gradient flow through both diagonal scans and into h0_pre from
zero-init (guards the log_g fix), log_g gradient at 0 equal to 2,
and h_0 validation for the log-space variant (underflowed zeros
accepted, negatives raise).
Further reading
Full per-seed evidence, the mechanism-verification detail, and the
record of what was tried and dropped behind the numbers in this README
live in experiments/:
experiments/SUMMARY.md— curated synthesis: full multi-seed tables, the D3 mechanism-verification detail, what was tried and dropped, and open work.experiments/EXPERIMENTS.md— round-by-round experiment log with per-round detail.experiments/lab_results.jsonl— raw per-seed result rows.
References
Feng, L., Tung, F., Ahmed, M. O., Bengio, Y., & Hajimirsadeghi, H. (2024). Were RNNs All We Needed? arXiv:2410.01201.
Merrill, W., Petty, J., & Sabharwal, A. (2024). The Illusion of State in State-Space Models. ICML 2024. arXiv:2404.08819.
Grazzi, R., et al. (2025). Unlocking State-Tracking in Linear RNNs Through Negative Eigenvalues. ICLR 2025.
Yang, S., Wang, B., Zhang, Y., Shen, Y., & Kim, Y. (2024). Parallelizing Linear Transformers with the Delta Rule over Sequence Length. arXiv:2406.06484.
Siems, J., Carstensen, T., Zela, A., Hutter, F., Pontil, M., & Grazzi, R. (2025). DeltaProduct: Improving State-Tracking in Linear RNNs via Householder Products. NeurIPS 2025. arXiv:2502.10297.
Jing, L., Shen, Y., Dubcek, T., Peurifoy, J., Skirlo, S., LeCun, Y., Tegmark, M., & Soljačić, M. (2017). Tunable Efficient Unitary Neural Networks (EUNN) and their application to RNNs. ICML 2017. arXiv:1612.05231.
Clements, W. R., Humphreys, P. C., Metcalf, B. J., Kolthammer, W. S., & Walmsley, I. A. (2016). Optimal design for universal multiport interferometers. Optica 3(12).
Golub, G. H., & Van Loan, C. F. (2013). Matrix Computations (4th ed.). Johns Hopkins University Press. (Givens rotations.)
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mingru_scans-0.1.0.tar.gz.
File metadata
- Download URL: mingru_scans-0.1.0.tar.gz
- Upload date:
- Size: 124.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bd58c7ab91ac2082c8c9e86041189ad928c1e10412ada41ce9cf4ea7670ea82b
|
|
| MD5 |
e87b38efdeffc560e1a743f81fe0d9b4
|
|
| BLAKE2b-256 |
8f5279d9ad5cdb662d5e7829b5d541cb00461d4f052513a5475bc8742d208157
|
Provenance
The following attestation bundles were made for mingru_scans-0.1.0.tar.gz:
Publisher:
publish.yaml on chris-santiago/minGRU
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mingru_scans-0.1.0.tar.gz -
Subject digest:
bd58c7ab91ac2082c8c9e86041189ad928c1e10412ada41ce9cf4ea7670ea82b - Sigstore transparency entry: 2192255166
- Sigstore integration time:
-
Permalink:
chris-santiago/minGRU@c2d98ceb09bc1927470af86512e9e898fc981ddf -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/chris-santiago
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yaml@c2d98ceb09bc1927470af86512e9e898fc981ddf -
Trigger Event:
release
-
Statement type:
File details
Details for the file mingru_scans-0.1.0-py3-none-any.whl.
File metadata
- Download URL: mingru_scans-0.1.0-py3-none-any.whl
- Upload date:
- Size: 95.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8de8847b160f959474c4ac7c59c2a70421579a1020263efe5cd7729d9f226610
|
|
| MD5 |
efa81b24a6957cf456d224a514f7cd3c
|
|
| BLAKE2b-256 |
be36cf34f6546194bd88444201fa1415564cf50790ada35f5c9fa7cc369c11c4
|
Provenance
The following attestation bundles were made for mingru_scans-0.1.0-py3-none-any.whl:
Publisher:
publish.yaml on chris-santiago/minGRU
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mingru_scans-0.1.0-py3-none-any.whl -
Subject digest:
8de8847b160f959474c4ac7c59c2a70421579a1020263efe5cd7729d9f226610 - Sigstore transparency entry: 2192255250
- Sigstore integration time:
-
Permalink:
chris-santiago/minGRU@c2d98ceb09bc1927470af86512e9e898fc981ddf -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/chris-santiago
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yaml@c2d98ceb09bc1927470af86512e9e898fc981ddf -
Trigger Event:
release
-
Statement type: