Skip to content

Detect same-name symbols with differing SymPy assumptions - #2486

Draft
ThrudPrimrose wants to merge 11 commits into
mainfrom
symbol-assumption-collision-check
Draft

Detect same-name symbols with differing SymPy assumptions#2486
ThrudPrimrose wants to merge 11 commits into
mainfrom
symbol-assumption-collision-check

Conversation

@ThrudPrimrose

@ThrudPrimrose ThrudPrimrose commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

SymPy folds assumptions into symbol identity. symbol('i', integer=True) and symbol('i', integer=True, nonnegative=True) are two distinct objects, even though the name denotes one value in an SDFG. This has been the root cause of many problems. This issue prevents loop2map from failing, serialization having undeterministic behavior and many more...

As a first step I propose follwing:

What this adds

Three functions in dace/sdfg/validation.py:

  • symbol_assumption_spellings(sdfg) walks array shapes/strides/offsets, map ranges, memlet subsets, loop init/update/condition and interstate edge conditions/assignments, and returns, per symbol name, every assumption set it is spelled with and where.
  • symbol_assumption_collisions(sdfg, name=None) filters that to names with more than one spelling.
  • check_symbol_assumption_collisions(sdfg, name=None) raises InvalidSDFGError on those, reporting only the assumptions the variants disagree on.

Two call sites, both gated on experimental.check_symbol_assumption_collisions, default false:

  • validate_sdfg, whole-SDFG.
  • SDFG.add_symbol, scoped to the name being registered and checked before the name lands in symbols.

Tests

tests/sdfg/validation/symbol_assumption_collision_test.py, 6 tests: single spelling passes, two spellings are reported with both locations and only the distinguishing assumption in the message, the name filter, validate honors the flag in both positions, add_symbol trips before registering and still accepts a clean name, and the flag defaults to off.

SymPy folds assumptions into symbol identity, so one name spelled two ways
is two distinct objects: index arithmetic does not cancel and dependence
predicates silently answer wrong. Report it at validation and when a symbol
is registered, behind an opt-in experimental config flag, default off.
SymPy folds assumptions into symbol identity, so one name spelled two ways
is two distinct objects: index arithmetic does not cancel and dependence
predicates silently answer wrong.

The frontend minted loop iterators with bound-derived nonnegative/positive
assumptions, and with unestablished ones passed as an explicit None, both of
which differ from the plain symbol every reparse yields. Mint the canonical
spelling instead; subset covering re-derives nonnegativity in subsets.nng.

Validation now rejects a collision unconditionally, walking the SDFG once and
deriving assumptions only for distinct objects that share a name.
SymPy folds assumptions into symbol identity, so `dace.symbol('N',
positive=True)` in an annotated shape is a different object from the bare N
that reparsing a subset or a map range yields. Both spellings then coexist and
index arithmetic over them stops cancelling, silently. tests/library/pblas
pgemm hits this through public API.

Symbols stored in an SDFG are now always bare. Assumptions move to a new
SDFG.symbol_assumptions registry, written only through
update_symbol_assumptions, which refines monotonically and rejects
contradictions; add_symbol is unchanged. assume_symbols re-mints them
transiently where a proof needs the facts.

Validation rejects a stored assumed symbol unconditionally.
The undefined sentinel is spelled "?", which validate_name rejects, so
re-minting it as a bare symbol raised NameError for every descriptor with an
undefined shape. It carries no assumptions and cannot be declared, so it has
nothing to contribute to the registry.
A pass minting dace.symbol('Px', positive=True) into a map range left the assumed
spelling in the graph, while add_datadesc absorbs the same name off the transient it
mints beside it. The two spellings are distinct SymPy objects, so the collision check
rejected the SDFG -- samples/simple/mandelbrot.py under the transformation tester.

Normalize where a map range is stored, not per transformation: RangeProperty bares
the bounds through a new symbolic.bare_symbols. Assumptions stay reachable through
SDFG.symbol_assumptions and assume_symbols.
Range parses each bound through one seam, so that is where the always-bare
invariant sits: start, end, step, tile, Indices elements and what replace()
substitutes in. A caller minting dace.symbol('N', positive=True) into a memlet
or a map range can no longer split the name. Drops the RangeProperty guard the
deeper seam subsumes, and bare_symbols now returns its argument untouched when
it is already bare and skips numbers outright.

The Range JSON round-trip test asserted the assumption survived; a Range never
holds one now, so it asserts the bare spelling and the dtype instead.
assume_symbols re-minted a symbol per query, and dace symbols deliberately
bypass sympy's cache, so the assumption closure was rebuilt every time. Mint
through an lru_cache and xreplace rather than subs: the keys are the stored
objects, so nothing needs matching.
A map or consume parameter is scope-local: its range is what is known about it,
and nothing hoists that into SDFG.symbol_assumptions. Reusing the name of a
symbol the registry holds facts about therefore makes every reapplication site
assume those facts of the wrong symbol. Reusing a parameter name across
disjoint scopes stays legal and silent.
Negative tests build collisions the way passes really do -- desc.shape assigned
behind the coercing seam -- instead of hand-writing Range.ranges. New: a memlet
subset built from an assumed symbol stores the bare one while the fact lands in
the registry; assume_symbols folds Max, sqrt and inequalities exactly as the
declared symbol did; parameters stay scope-local across the three scope rules.
ThrudPrimrose added a commit that referenced this pull request Aug 11, 2026
set_symbol_nonnegative_assumptions stamped nonnegative=True symbols through
replace_dict. Post-#2486 every subset/map-range store bares its bounds, so the
stamp survived only in descriptor shapes/strides -- exactly the two-spelling
state the now-mergeable collision check rejects. Record the fact in
SDFG.symbol_assumptions instead (the registry absorb_symbol_assumptions also
feeds); proof sites re-apply it transiently via sdfg.assume_symbols. The
registry doubles as the convergence memory _names_still_plain scanned stores
for, so that walk is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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