From 09c6c9c82365a8a398a9b77c1c861de6b8cf61c8 Mon Sep 17 00:00:00 2001 From: Spielopoly Date: Sun, 14 Jun 2026 14:12:30 +0000 Subject: [PATCH 1/4] Fix lru_cache type-conflation in symbolic.simplify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simplify() is memoized with @lru_cache, which keys entries with hash(). In Python, booleans are integers (hash(True) == hash(1), True == sympy.Integer(1)), so the cache cannot distinguish simplify(True) from simplify(sympy.Integer(1)) — they collapse onto one entry. Whichever is evaluated first wins; the other caller gets a wrong-typed result. This is process-global and order-dependent: any path feeding a plain bool to simplify (e.g. a comparison simplify(s == 1)) poisons the cache so later simplify(Integer(1)) returns BooleanTrue. Downstream this crashes memlet-volume propagation for single-iteration maps (volume == Integer(1)) with "TypeError: Property volume must be a literal or symbolic expression, got BooleanTrue". Fix: Construct the cache with typed=True so arguments of different types get distinct entries even when they hash and compare equal --- dace/symbolic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dace/symbolic.py b/dace/symbolic.py index 74600aa84f..ba4d9e2093 100644 --- a/dace/symbolic.py +++ b/dace/symbolic.py @@ -2205,7 +2205,7 @@ def _pystr_to_symbolic_uncached(expr, symbol_map=None, simplify=None) -> sympy.B return sympy_to_dace(result, symbol_map) -@lru_cache(maxsize=2048) +@lru_cache(maxsize=2048, typed=True) def simplify(expr: SymbolicType) -> SymbolicType: return sympy.simplify(expr) From e03f03108eb3b58dc24215dd50ee9afe084e05f8 Mon Sep 17 00:00:00 2001 From: Spielopoly Date: Sun, 14 Jun 2026 14:25:01 +0000 Subject: [PATCH 2/4] Add test for previous fix --- tests/simplify_cache_typed_test.py | 133 +++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/simplify_cache_typed_test.py diff --git a/tests/simplify_cache_typed_test.py b/tests/simplify_cache_typed_test.py new file mode 100644 index 0000000000..28be144c7f --- /dev/null +++ b/tests/simplify_cache_typed_test.py @@ -0,0 +1,133 @@ +# Copyright 2019-2026 ETH Zurich and the DaCe authors. All rights reserved. +""" +Regression tests for the ``dace.symbolic.simplify`` ``lru_cache`` type-conflation +bug. + +``simplify`` is wrapped in :func:`functools.lru_cache`, which keys entries by +``hash``/``==``. Python conflates booleans with integers +(``hash(True) == hash(1)`` and ``True == sympy.Integer(1)``), so an *untyped* +cache stores ``simplify(True)`` and ``simplify(sympy.Integer(1))`` under the +same entry. Once ``simplify(True)`` (returning ``sympy.true``) is cached, a +later ``simplify(sympy.Integer(1))`` returns that cached ``BooleanTrue`` instead +of the integer ``1`` -- and vice versa. The same holds for ``False``/``0``. + +This poisoning is process-global and order dependent: an unrelated caller that +fed a plain ``bool`` to ``simplify`` earlier (e.g. comparing a concrete shape, +``simplify(s == 1)``) would later crash memlet-volume propagation with +``TypeError: Property volume must be a literal or symbolic expression`` when the +propagated volume happened to be ``sympy.Integer(1)``. + +The fix is to construct the cache with ``typed=True`` so arguments of different +types (``bool`` vs ``sympy.Integer``) are cached under distinct entries. + +These tests are written to be **branch-agnostic**: they assert SymPy-level +semantics only (``simplify`` returns a SymPy object), so they pass on the fixed +code and fail/raise on the unfixed code, independent of any caller-side +workaround. Every test clears the cache first to stay order independent. +""" + +import sympy +from sympy.logic.boolalg import Boolean + +import dace + + +def _fresh_cache() -> None: + """Clear the ``simplify`` cache so each test is order independent.""" + dace.symbolic.simplify.cache_clear() + + +def test_simplify_bool_does_not_poison_integer_one(): + """``simplify(True)`` first must not turn ``simplify(Integer(1))`` boolean.""" + _fresh_cache() + # Caches sympy.true; on an untyped cache this poisons the key ``1``. + dace.symbolic.simplify(True) + + result = dace.symbolic.simplify(sympy.Integer(1)) + assert isinstance(result, sympy.Integer), \ + f"expected sympy.Integer, got {type(result).__name__}: {result!r}" + assert not isinstance(result, Boolean) + assert result == 1 + + +def test_simplify_integer_one_does_not_poison_bool(): + """The reverse order: a cached ``Integer(1)`` must not leak into ``simplify(True)``.""" + _fresh_cache() + dace.symbolic.simplify(sympy.Integer(1)) + + result = dace.symbolic.simplify(True) + assert isinstance(result, Boolean), \ + f"expected a sympy boolean, got {type(result).__name__}: {result!r}" + assert bool(result) is True + + +def test_simplify_bool_does_not_poison_integer_zero(): + """Same conflation exists for ``False``/``0`` -- integer direction stays typed.""" + _fresh_cache() + dace.symbolic.simplify(False) + + result = dace.symbolic.simplify(sympy.Integer(0)) + assert isinstance(result, sympy.Integer), \ + f"expected sympy.Integer, got {type(result).__name__}: {result!r}" + assert not isinstance(result, Boolean) + assert result == 0 + + +def test_simplify_integer_zero_does_not_poison_bool(): + """``False``/``0`` -- boolean direction stays boolean.""" + _fresh_cache() + dace.symbolic.simplify(sympy.Integer(0)) + + result = dace.symbolic.simplify(False) + assert isinstance(result, Boolean), \ + f"expected a sympy boolean, got {type(result).__name__}: {result!r}" + assert bool(result) is False + + +def test_simplify_symbolic_expressions_still_cache(): + """Normal symbolic inputs are unaffected and are still served from the cache.""" + _fresh_cache() + n = sympy.Symbol('N') + assert dace.symbolic.simplify(n + 1 - n) == 1 + assert dace.symbolic.simplify((n**2 - 1) / (n - 1)) == n + 1 + # Same expression again: served from the cache with the same result. + assert dace.symbolic.simplify(n + 1 - n) == 1 + assert dace.symbolic.simplify.cache_info().hits >= 1 + + +def test_volume_propagation_after_bool_simplify(): + """End-to-end: a ``bool`` fed to ``simplify`` must not break memlet-volume + propagation on an unrelated SDFG (the original order-dependent crash).""" + from dace.sdfg.propagation import propagate_memlets_sdfg + + _fresh_cache() + # The poisoning call pattern: a concrete shape comparison yields a Python + # ``bool``, which simplifies to ``sympy.true``. + poison = dace.symbolic.simplify(sympy.Integer(1) == 1) + assert bool(poison) is True + + sdfg = dace.SDFG('simplify_cache_volume_repro') + sdfg.add_array('A', [4], dace.float64) + sdfg.add_array('B', [4], dace.float64) + state = sdfg.add_state() + # A single-iteration map: the propagated volume is ``sympy.Integer(1)`` -- + # exactly the cache key that a cached ``simplify(True)`` poisons. + state.add_mapped_tasklet('copy', + dict(i='0:1'), + dict(inp=dace.Memlet('A[i]')), + 'out = inp', + dict(out=dace.Memlet('B[i]')), + external_edges=True) + # Without the fix this raised: TypeError: Property volume must be a literal + # or symbolic expression (the propagated volume was a BooleanTrue). + propagate_memlets_sdfg(sdfg) + sdfg.validate() + + +if __name__ == '__main__': + test_simplify_bool_does_not_poison_integer_one() + test_simplify_integer_one_does_not_poison_bool() + test_simplify_bool_does_not_poison_integer_zero() + test_simplify_integer_zero_does_not_poison_bool() + test_simplify_symbolic_expressions_still_cache() + test_volume_propagation_after_bool_simplify() From 0ce4c1d88d1066165374940a130441f7c4871106 Mon Sep 17 00:00:00 2001 From: Yakup Koray Budanaz Date: Mon, 15 Jun 2026 16:01:13 +0200 Subject: [PATCH 3/4] [fortran] Fix fparser circular import on Python 3.14 Importing fparser's Fortran2008 before Fortran2003 triggers a Fortran2008 -> Fortran2003 -> label_do_stmt_r816 -> Fortran2008.Loop_Control back-import that fails on Python 3.14 with recent fparser (0.2.3+): `ImportError: cannot import name 'Loop_Control' from partially initialized module 'fparser.two.Fortran2008'`. Import Fortran2003 first to break the cycle, and relax the requirements pin from `fparser==0.1.4` to `fparser>0.1.3` so the fixed newer fparser can be used. --- dace/frontend/fortran/ast_components.py | 3 ++- requirements.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/dace/frontend/fortran/ast_components.py b/dace/frontend/fortran/ast_components.py index 1bccd14654..9097460b46 100644 --- a/dace/frontend/fortran/ast_components.py +++ b/dace/frontend/fortran/ast_components.py @@ -1,6 +1,7 @@ # Copyright 2019-2023 ETH Zurich and the DaCe authors. All rights reserved. -from fparser.two import Fortran2008 as f08 +# NOTE: Fortran2003 needs to be imported before Fortran2008 (circular import otherwise). from fparser.two import Fortran2003 as f03 +from fparser.two import Fortran2008 as f08 from fparser.two import symbol_table from dace.frontend.fortran import ast_internal_classes diff --git a/requirements.txt b/requirements.txt index 4ee823a761..6877c07e0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ astunparse==1.6.3 dill==0.3.9 -fparser==0.1.4 +fparser>0.1.3 mpmath==1.3.0 networkx==3.4.2 numpy==1.26.4 From 8d39ae112ccbca073beb62e8fd93809876537021 Mon Sep 17 00:00:00 2001 From: Spielopoly Date: Mon, 29 Jun 2026 12:52:05 +0000 Subject: [PATCH 4/4] make tests more lenient so they cover both sympy and python types instead of just sympy types. Also simplify docstring --- tests/simplify_cache_typed_test.py | 37 ++++++------------------------ 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/tests/simplify_cache_typed_test.py b/tests/simplify_cache_typed_test.py index 28be144c7f..f568f4c404 100644 --- a/tests/simplify_cache_typed_test.py +++ b/tests/simplify_cache_typed_test.py @@ -1,29 +1,6 @@ # Copyright 2019-2026 ETH Zurich and the DaCe authors. All rights reserved. """ -Regression tests for the ``dace.symbolic.simplify`` ``lru_cache`` type-conflation -bug. - -``simplify`` is wrapped in :func:`functools.lru_cache`, which keys entries by -``hash``/``==``. Python conflates booleans with integers -(``hash(True) == hash(1)`` and ``True == sympy.Integer(1)``), so an *untyped* -cache stores ``simplify(True)`` and ``simplify(sympy.Integer(1))`` under the -same entry. Once ``simplify(True)`` (returning ``sympy.true``) is cached, a -later ``simplify(sympy.Integer(1))`` returns that cached ``BooleanTrue`` instead -of the integer ``1`` -- and vice versa. The same holds for ``False``/``0``. - -This poisoning is process-global and order dependent: an unrelated caller that -fed a plain ``bool`` to ``simplify`` earlier (e.g. comparing a concrete shape, -``simplify(s == 1)``) would later crash memlet-volume propagation with -``TypeError: Property volume must be a literal or symbolic expression`` when the -propagated volume happened to be ``sympy.Integer(1)``. - -The fix is to construct the cache with ``typed=True`` so arguments of different -types (``bool`` vs ``sympy.Integer``) are cached under distinct entries. - -These tests are written to be **branch-agnostic**: they assert SymPy-level -semantics only (``simplify`` returns a SymPy object), so they pass on the fixed -code and fail/raise on the unfixed code, independent of any caller-side -workaround. Every test clears the cache first to stay order independent. +Regressions tests to ensure LRU cache is type-aware """ import sympy @@ -44,9 +21,9 @@ def test_simplify_bool_does_not_poison_integer_one(): dace.symbolic.simplify(True) result = dace.symbolic.simplify(sympy.Integer(1)) - assert isinstance(result, sympy.Integer), \ + assert isinstance(result, (sympy.Integer, int)), \ f"expected sympy.Integer, got {type(result).__name__}: {result!r}" - assert not isinstance(result, Boolean) + assert not isinstance(result, (Boolean, bool)) assert result == 1 @@ -56,7 +33,7 @@ def test_simplify_integer_one_does_not_poison_bool(): dace.symbolic.simplify(sympy.Integer(1)) result = dace.symbolic.simplify(True) - assert isinstance(result, Boolean), \ + assert isinstance(result, (Boolean, bool)), \ f"expected a sympy boolean, got {type(result).__name__}: {result!r}" assert bool(result) is True @@ -67,9 +44,9 @@ def test_simplify_bool_does_not_poison_integer_zero(): dace.symbolic.simplify(False) result = dace.symbolic.simplify(sympy.Integer(0)) - assert isinstance(result, sympy.Integer), \ + assert isinstance(result, (sympy.Integer, int)), \ f"expected sympy.Integer, got {type(result).__name__}: {result!r}" - assert not isinstance(result, Boolean) + assert not isinstance(result, (Boolean, bool)) assert result == 0 @@ -79,7 +56,7 @@ def test_simplify_integer_zero_does_not_poison_bool(): dace.symbolic.simplify(sympy.Integer(0)) result = dace.symbolic.simplify(False) - assert isinstance(result, Boolean), \ + assert isinstance(result, (Boolean, bool)), \ f"expected a sympy boolean, got {type(result).__name__}: {result!r}" assert bool(result) is False