fix(pallas): check the fused leaf kernels' shapes against each other - #338
Merged
Conversation
The three Pallas entry points and the leaf-pair reference in this file accepted nine shape disagreements silently, measured 2026-09-07 by `bench/annotation_pilot.py`. This closes them in the BODY. No annotations are added and the census does not move except for one bare parameter on the new helper. WHY THE OLD CHECKS MISSED IT. Each position parameter was checked for `ndim != 3 or shape[-1] != 3` and never for its LEADING extent, while the docstrings promised `ValueError` "if the input shapes are mutually inconsistent" -- a consistency the bodies did not verify. The docstring over-promised and the gap it left is the dangerous kind: perturbing POSITIONS shrinks the Pallas grid, so the output shrinks with it and a caller who checks the shape can notice; perturbing a MASK leaves the grid alone, so the BlockSpec indexes the mask out of bounds, JAX CLAMPS, and leaf 3 silently reuses leaf 2's mask. Real particles masked out or phantom ones included, at exactly the right output shape. Measured before this change: `target_mask` one row short returned a full (3, 2, 4); so did one column short, and so did `source_positions` one leaf short. WHY NOT AN ANNOTATION, WHICH WOULD ALSO HAVE WORKED. Because these parameters' bodies raise a documented `ValueError` over their shape, and a decorator runs first and replaces it -- checklist item 13, and `DELIBERATELY_BARE`'s first entry, which says changing which exception a caller sees is a behaviour change. Unlike #310 this was not a case of closing nothing, which is what made it a real decision rather than a rule application; it is recorded in the phase-2 document and item 13 now carries the general form. The Raises contract is public and the census is not the objective. THE REFERENCE LANE NEEDED ONE TOO, for a reason the Pallas twins do not share. `nearfield_leafpair_jax` had no check at all, and a `leaf_positions` of trailing width 2 produced a two-wide acceleration which the final `concatenate` with the potential turned into a three-wide result -- (3, 2, 2, 3) in, (3, 2, 3) out, the rank right and a component missing. ORDERING IS PART OF THE CHECK, and this PR got it wrong first. Placing the generic source-table checks BEFORE #297's source-width guard made that guard's specific message unreachable -- it explains that a narrower pool reads out of bounds and a wider one silently drops real particles, which the generic message cannot -- and turned five existing tests red. They are now placed after it. That is the same mistake as item 13 committed in the other direction, and the existing tests caught it, which is why they were fixed by reordering rather than by touching them. The decoupled variant's independence is preserved and pinned: `num_targets` and `num_sources` are checked against the arrays they belong to, not against one leaf count, so a source pool with a different leaf count still goes through. Eleven tests, nine red against main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
The last of the 2026-09-07 pilot pass, and the one that is not an annotation PR. Follows
#336 and #337; closes the nine acceptances those two deliberately left alone.
Why a guard and not an annotation
Annotating the position parameters would have closed all nine. It was still the wrong
instrument, and this is the harder version of checklist item 13 rather than a
straightforward application of it:
ValueErrorover its shape, anda decorator runs before the body, so the annotation replaces it;
DELIBERATELY_BARE's first entry states the policy verbatim — changing which exception acaller sees is a behaviour change, not a docs change;
what made this a decision rather than a rule lookup.
So the body now checks what its own docstring already promised, under the exception callers
already expect. No annotations are added. The census moves by exactly +1 bare — the new
helper's
array: Arrayparameter, which by design accepts any array and so genuinely cannotpin a shape. Stated rather than gamed.
What the old checks missed
Every entry point tested
ndim != 3 or shape[-1] != 3and never the leading extent,while the docstrings promised
ValueError"if the input shapes are mutually inconsistent"— a consistency the bodies did not verify. The docstring over-promised, and the gap it left
splits by severity:
The second is the one worth the PR. Measured on
main:target_maskone row short returneda full
(3, 2, 4); so did one column short; so didsource_positionsone leaf short.The annotations could not have caught the mask cases anyway without the positions:
target_mask: Bool[Array, "leaves w"]beside a baretarget_positionsbindsleavesand
wwith one parameter, so those names have nothing to disagree with. The source sidewas already safe precisely because
source_massesandsource_maskboth carrysrcleaves srcslotsand cross-check each other — the pilot rejected those, and this PR doesnot claim them.
The reference lane needed one too, for a different reason
nearfield_leafpair_jaxhad no shape check at all. Aleaf_positionsof trailing width2 makes
accelstwo-wide, and the finalconcatenatewith the potential returns athree-wide result — the rank is right, the contract is 4, and nothing raised.
(3, 2, 2, 3)in,
(3, 2, 3)out. This is the lane the Pallas kernels are checked against, so itmattered more than its one acceptance suggests.
Ordering is part of the check, and I got it wrong first
Worth reading, because it is the same mistake as item 13 committed in the other direction.
Placing the generic source-table checks before #297's source-width guard made that
guard's specific message unreachable — it explains that a narrower pool reads out of bounds
while a wider one silently drops real particles, which a generic message cannot — and turned
five existing tests red:
They were fixed by reordering the checks, not by touching the tests. A new test pins the
priority so a future reorder is visible.
The decoupled variant's independence is preserved
num_targetsandnum_sourcesare deliberately independent there — that separation is thewhole point of the lane — so each operand is checked against the array it belongs to rather
than against a single leaf count. Verified: a source pool of 2 leaves against 3 target leaves
still goes through, and the control test asserts it. A guard that tied them together would
have broken the lane while looking like an improvement.
Tests
tests/unit/pallas/test_fused_leaf_shape_guards.py— 11 tests, 9 red against main, oneper closed acceptance. The 2 green are the control (all four entry points, including the
decoupled independence) and the #297 priority pin.
The sibling
test_fused_leaf_axis_contracts.pyhad a docstring asserting that "what thebodies do not check is the masks, masses, ids and validity arrays". That is no longer true,
so it is updated; the two files now divide as annotations there, body guards here.
STYLE_GUIDE.mditem 13 gains the general form: an annotation is the wrong instrumentwherever a parameter's own body documents a
ValueErrorover its shape, even when it wouldwork — plus the ordering trap above.
Verification
Census —
bench/annotation_census.py, quoted not retyped. Shaped count and share unchanged,which is the point:
🤖 Generated with Claude Code