fix(model): zero masked pair slots before the reverse gather - #20
Open
E-Rum wants to merge 1 commit into
Open
Conversation
Masked pair slots left each GNN layer holding nonzero features, since the transformer returns edge + attn + mlp(...) unmasked. The reverse gather is the only consumer of a masked slot, so a reverse pointing at the padded sentinel picked those features up, and the output then depended on the packed width -- a caller-side shape choice, not physics. Reachable wherever a reverse lands on the sentinel: under truncate_edges overflow, or through pack_edges on a neighbour list trimmed asymmetrically upstream (a per-center knn cap), where it happens with overflow=False and no diagnostic. Guard it structurally: padded slots must leave the backbone at exactly zero, plus a width-invariance check over an orphaned reverse. Note that a fresh init leaves every bias zero, which zeroes the sentinel and hides the leak, so the invariance test shifts parameters off init. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
E-Rum
force-pushed
the
fix/zero-masked-pair-slots
branch
from
August 17, 2026 13:01
d2b3447 to
2c6802e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Backbone.__call__masks edge features going into the transformer layers, butTransformerLayerreturnsedge + attn + mlp(...)unmasked, so padded pair slotsleave each GNN layer holding nonzero features. The reverse gather
(
reversed_flat = edge_flat[reverse]) is the one place a masked slot is read, soany
reverseentry pointing at the padded sentinel picks those features up — andthe model's output then depends on the packed width
k, which is a caller-sideshape choice, not physics.
Within pet-jax's own selection path this is unreachable in normal operation:
_select_edgesis symmetric (pair_cutoffsaverages the two atoms' cutoffs), soa selected pair's reciprocal is always selected too, and a sentinel-pointing
reverse arises only under
overflow— already a flagged failure. Butpack_edgesaccepts a pre-trimmed neighbour list and documents only a per-centercapacity requirement, so a caller that trims per-center by distance rank (an
asymmetric cut, unlike a distance cut) hits this in steady state with
overflow=Falseand no diagnostic.Measured on a downstream trained checkpoint whose neighbour list is knn-trimmed
per center, where ~10% of surviving directed edges have lost their reciprocal:
max_neighbors + 1(its training width)overflowisFalsein both. Force error degrades monotonically across widthsbetween those two points. Bisecting the batch axes one at a time, every other
padded axis is exactly invariant — only the pack width moves the result. With a
symmetric neighbour list (reciprocal-pruned, no orphans) the two widths agree to
4e-6 eV, which isolates the orphan gather as the sole path.
The width dependence comes from where the sentinel lives. It is the last slot of
the padded dummy-atom row, so that row is fully masked; its center token is
Dense(0)= bias, nonzero, while its edge tokens are zero, so the attentionaverage puts weight
1/(1 + k)on a nonzero vector. On the checkpoint above thesentinel feature vector drifts in norm from 8.75 at
k=17to 9.77 atk=384,saturating, and every orphan edge gathers it.
Fix
One multiply per GNN layer. Masked slots now leave the backbone at exactly zero,
which makes the forward invariant to the pack width, makes the sentinel the
zeroed edge that callers of
pack_edgesreasonably assume, and also cleans uppet-jax's own overflow path.
Tests
tests/test_select.py:test_masked_slots_carry_no_features— padded slots leave the backbone atexactly zero. This is the guard: it fails on
mainat plain init, with notolerances involved.
test_backbone_invariant_to_packed_width— with an orphaned reverse, featuresat
kandk + 11must agree._orphaned()helper — drops one direction of one pair, reproducing an upstreamper-center trim.
Both fixture cases (rocksalt crystal, H2O): 4 failures on
main, 0 with the fix.The existing
test_pack_edges_round_trip/test_pack_edges_reverse_involutionpass throughout — note the latter asserts the very symmetry this bug hides behind,
and passes only because
to_structureyields a symmetric neighbour list.Two things worth knowing if you extend these tests:
initleaves every bias zero, which zeroes the sentinel and hides thebug completely. The invariance test shifts parameters off init deliberately.
not discriminate — pre/post agreement stayed at ~1e-9 relative even with bias
offsets up to +8. The leak only becomes load-bearing in a trained model, where
the junk vector is comparable in magnitude to real edge features. Assert the
structural property, not downstream energies.
Compatibility
This changes the function for any checkpoint trained through an asymmetric
neighbour list: those weights have absorbed the sentinel as a de facto constant
input. Applying the fix to such a checkpoint without retraining degraded its
validation error by roughly two orders of magnitude, so affected checkpoints need
retraining rather than a reload.
Checkpoints with no orphan edges are unaffected: a run whose trim never binds
(
max_neighborsbelow the cap, so the neighbour list stays symmetric) isbit-identical before and after.
Not addressed here
pack_edgesstill accepts an asymmetric neighbour list silently. Itsoverflowflag reports only per-center capacity, not "a selected pair'sreciprocal was dropped". Worth either documenting the symmetry requirement or
extending the flag, so the next caller doesn't repeat this.
_pack_selected_to_flat, every non-fitting pairwrites to slot
P_sel - 1viasel_to_pair.at[slot].set(...), which wouldcollide with a real edge if the last atom row were ever exactly full. Trailing
padded rows appear to prevent it; I did not construct a case.
not run locally (git-lfs assets, and no pytest in my container — I drove the
test functions directly).
🤖 Generated with Claude Code