Skip to content

fix[next]: MergeLet guard blind to builtin-named params - #2728

Draft
havogt wants to merge 1 commit into
GridTools:mainfrom
havogt:fix/mergelet-builtin-names
Draft

fix[next]: MergeLet guard blind to builtin-named params#2728
havogt wants to merge 1 commit into
GridTools:mainfrom
havogt:fix/mergelet-builtin-names

Conversation

@havogt

@havogt havogt commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Symptom

MergeLet merges (λ(a) → (λ(b) → body)(arg1))(arg2) into (λ(a, b) → body)(arg2, arg1). One of its guards checks that the inner lambda's arguments do not reference a parameter of the outer lambda — merging would move those references out of the binder that binds them.

That check is defeated whenever the outer parameter's name coincides with a builtin name.

Reproduction

from gt4py.next.iterator.ir_utils import ir_makers as im
from gt4py.next.iterator.transforms.merge_let import MergeLet

for name in ("s", "shift"):
    testee = im.let(name, "it")(im.let("b", im.deref(name))("b"))
    print(f"{testee}  =>  {MergeLet().visit(testee)}")

On main:

(λ(s) → (λ(b) → b)(·s))(it)          =>  (λ(s) → (λ(b) → b)(·s))(it)
(λ(shift) → (λ(b) → b)(·shift))(it)  =>  (λ(shift, b) → b)(it, ·shift)

The two inputs differ only in the name of the outer binder.

Why this is a defect

  • The merged result (λ(shift, b) → b)(it, ·shift) has ·shift as an argument of the very lambda that binds shift, i.e. the reference has been moved out of the scope of its binder. The occurrence now resolves to the shift builtin (or to whatever shift is in the enclosing scope) instead of to the parameter bound to it.
  • The decision of the pass is not invariant under alpha-renaming: renaming s to shift — a semantics-preserving change — flips it from "do not merge" to "merge".

Cause and fix

The guard uses

ref_counts = CountSymbolRefs.apply(inner_lambda_args, [param.id for param in outer_lambda.params])

and CountSymbolRefs.apply defaults to ignore_builtins=True, so SymRefs whose name is a builtin are never counted. Here the names come from outer_lambda.params, so a parameter shadowing a builtin makes the guard count zero references and let the merge through.

The fix passes ignore_builtins=False for this check: within this lambda a reference to such a name is an ordinary reference to the parameter, not to the builtin.

Tests

merge_let had no unit tests upstream; this PR adds the first ones in tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_merge_let.py, covering the plain merge, the two existing "do not merge" guards, and the A/B pair above (parametrized over s / shift) so the alpha-invariance is visible. The shift case fails before the fix and passes after. tests/next_tests/unit_tests/iterator_tests/ is green (502 passed, 1 skipped, 2 xfailed).

`MergeLet` refuses to merge nested lets when the inner lambda's arguments
reference a parameter of the outer lambda. The check went through
`CountSymbolRefs.apply`, whose `ignore_builtins` argument defaults to `True`,
so a reference to an outer parameter whose name happens to coincide with a
builtin was never counted and the guard passed:

    (λ(s)     → (λ(b) → b)(·s))(it)     stays as is
    (λ(shift) → (λ(b) → b)(·shift))(it) becomes (λ(shift, b) → b)(it, ·shift)

The merged form moves `·shift` out of the lambda that binds `shift`, and a
semantics-preserving rename of the binder changes the result of the pass.

Pass `ignore_builtins=False` for that check and add the first unit tests for
`merge_let`.
@havogt

havogt commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Do we actually disallow shadowing builtins? Then I'll transform this PR in one that gives a proper error message at the different levels...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant