Skip to content

fix[next]: collect closure vars via the compiler's scope analysis - #2864

Open
havogt wants to merge 1 commit into
GridTools:sf_n_tracer_supportfrom
havogt:c1-closure-vars-symtable
Open

fix[next]: collect closure vars via the compiler's scope analysis#2864
havogt wants to merge 1 commit into
GridTools:sf_n_tracer_supportfrom
havogt:c1-closure-vars-symtable

Conversation

@havogt

@havogt havogt commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Targets sf_n_tracer_support (#2833) rather than main, because the bug is only reachable through the tuple comprehensions that PR introduces. Follow-up to this review comment.

This is the alternative to #2861 — same bug, same tests, different mechanism. They are mutually exclusive; pick one and close the other. Comparison at the bottom.

Problem

A name referenced only inside a tuple comprehension body is not collected as a closure variable, so type deduction rejects it — naming a symbol that is visibly imported at module scope:

@gtx.field_operator
def testee(tracers: tuple[EField, ...]) -> tuple[CField, ...]:
    return tuple(neighbor_sum(t(C2E), axis=C2EDim) for t in tracers)
# UndefinedSymbolError: Undeclared symbol 'neighbor_sum'

This covers every module-level name a real stencil uses: gt4py builtins, FieldOffsets, Dimensions, and module-level field operators — so the natural factoring tuple(_inner(t, ...) for t in tracers) does not work either.

Two things make it more than a limitation: the diagnostic names a symbol that is plainly in scope, and the workaround is to also reference the name outside the comprehension — so whether a stencil compiles depends on an unrelated line elsewhere in the function.

Cause

get_closure_vars_from_function uses inspect.getclosurevars, which disassembles the enclosing function's own code object and collects the names loaded by its LOAD_GLOBAL instructions. A generator expression compiles to its own code object, so the names it uses are recorded there and nowhere else.

Free variables are unaffected, because closing over one forces a cell whose name is recorded on the enclosing code object as co_freevars. Globals need no cell, so nothing links them back. That asymmetry is also why the existing comprehension tests are green — they all define their helpers inside the test function, putting them on the working side of the bug.

Fix

Take the global names from the compiler's own scope analysis, via the symtable module this file already imports, rather than from code objects.

Every scope the compiler creates — the generator expression's included — is a child symtable.Function whose get_globals() is exactly the set of names that compile to LOAD_GLOBAL in that scope. Scoping is therefore correct by construction: a local shadowing a global is is_local in the function and is_free in the comprehension, never global, and the comprehension target is local to the comprehension. Attribute names, which co_names also holds, never appear.

The source is analyzed under a prepended from __future__ import annotations so annotations contribute no names on any supported version — Python evaluates parameter and return annotations in the enclosing scope and never evaluates local variable annotations, but only 3.14's symtable stops reporting the latter. Filtering on isinstance(table, symtable.Function) also skips the ANNOTATION tables 3.14 introduces. Free variables keep coming from inspect.getclosurevars.

Tests

  • test_tuples.py: three integration tests placed next to test_tuple_comprehension_other_fo, so the contrast is visible in one screen — that test defines its helper inside the test (free variable, always worked), the new ones use a module-level operator and a module-level builtin. Plus the used-outside-too variant that documents the workaround.
  • test_tuple_comprehension_closure_vars.py: mechanism-level unit tests pinning that the names live in the nested code object, that the comprehension target is not collected, and that an enclosing local shadowing a module-level name is not picked up as a global.

All new tests fail without the source change.

Validation

  • tests/next_tests/unit_tests/ffront_tests/ + type_system_tests/: 443 passed, 2 skipped, 3 xfailed — on 3.12, 3.13 and 3.14.
  • Comprehension integration tests: 31 passed on roundtrip/numpy, on all three interpreters.
  • uv run mypy src/: clean (361 files). pre-commit: all hooks pass.
  • Not exercised: GPU backends.

Versus #2861

this PR (symtable) #2861 (code objects)
Source of truth CPython's symbol table of the source code objects, LOAD_GLOBAL
Precision correct by construction; attribute and annotation names cannot leak same, once LOAD_GLOBAL is filtered
Reads source again yes (~0.3 ms one-time per function, off the per-call path) no
Annotation names excluded via the PEP 563 prefix excluded for free (unevaluated annotations emit no bytecode)
Version exposure symtable.Function classification + PEP 563 dis opnames
Verified on 3.12 / 3.13 / 3.14 all three 3.12 only

Neither is obviously better. This one depends on documented symtable semantics and reuses machinery already imported here; #2861 stays with the mechanism inspect.getclosurevars itself uses and avoids a second source read.

https://claude.ai/code/session_01VR1cyTQ4wysovMBAwPBAWh

`get_closure_vars_from_function` used `inspect.getclosurevars`, which disassembles
the enclosing function's own code object and collects the names loaded by its
`LOAD_GLOBAL` instructions. A generator expression compiles to a separate code
object, so a name referenced only inside a tuple comprehension body is recorded
there and nowhere else, and was never collected. Type deduction then failed with
`UndefinedSymbolError` on a name that is visibly imported:

    @gtx.field_operator
    def testee(tracers: tuple[EField, ...]) -> tuple[CField, ...]:
        return tuple(neighbor_sum(t(C2E), axis=C2EDim) for t in tracers)
    # UndefinedSymbolError: Undeclared symbol 'neighbor_sum'

This affected every module-level name: builtins, `FieldOffset`s, `Dimension`s
and module-level field operators, so `tuple(_inner(t, ...) for t in tracers)`
did not work either.

Free variables were unaffected, because closing over one forces a cell whose
name is recorded on the enclosing code object as `co_freevars`. Globals need no
cell, so nothing links them back. That is also why the existing comprehension
tests pass: they all define their helpers inside the test function.

Take the global names from the compiler's own scope analysis instead, via the
`symtable` module this file already imports. Every scope the compiler creates,
the generator expression's included, is a child `symtable.Function` whose
`get_globals()` is exactly the set of names that compile to `LOAD_GLOBAL` there,
so locals shadowing a global and comprehension targets are excluded by
construction. The source is analyzed under `from __future__ import annotations`
so annotations contribute no names on any supported version. Free variables keep
coming from `inspect.getclosurevars`.

Claude-Session: https://claude.ai/code/session_01VR1cyTQ4wysovMBAwPBAWh
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