From 518d6abe8e90ec51b4b034ff33240f04e8968609 Mon Sep 17 00:00:00 2001 From: Ulysses Tiberious <132908333+utiberious@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:11:57 -0400 Subject: [PATCH 1/4] fix: avoid slow simplification during display --- galgebra/_utils/simplify.py | 52 +++++++++++++ galgebra/metric.py | 17 ++++- galgebra/mv.py | 4 +- test/test_simplify.py | 143 ++++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 galgebra/_utils/simplify.py create mode 100644 test/test_simplify.py diff --git a/galgebra/_utils/simplify.py b/galgebra/_utils/simplify.py new file mode 100644 index 00000000..1fea2b76 --- /dev/null +++ b/galgebra/_utils/simplify.py @@ -0,0 +1,52 @@ +"""Compatibility helpers for simplification across SymPy releases.""" + +import re + +import sympy +from sympy import preorder_traversal, simplify, trigsimp +from sympy.functions.elementary.hyperbolic import HyperbolicFunction +from sympy.functions.elementary.trigonometric import TrigonometricFunction + + +def _major_minor(version): + """Return the leading major and minor numbers from a version string.""" + match = re.match(r'^(\d+)\.(\d+)', version) + if match is None: + return (0, 0) + return tuple(map(int, match.groups())) + + +_SYMPY_MAJOR_MINOR = _major_minor(sympy.__version__) + +# SymPy 1.13's gh-26390 added a nested replace traversal to the FU +# simplifier. Its cost is proportional to the expression tree size times the +# number of trig and hyperbolic nodes. +_FU_TRAVERSAL_COST_LIMIT = 4096 +_TRIG_FUNCTIONS = (TrigonometricFunction, HyperbolicFunction) + + +def _has_expensive_fu_traversal(expr): + """Whether ``simplify`` is likely to hit SymPy's slow FU traversal.""" + if _SYMPY_MAJOR_MINOR < (1, 13): + return False + + trig_nodes = 0 + for node_count, node in enumerate(preorder_traversal(expr), 1): + if isinstance(node, _TRIG_FUNCTIONS): + trig_nodes += 1 + if node_count * trig_nodes >= _FU_TRAVERSAL_COST_LIMIT: + return True + return False + + +def simplify_for_display(expr): + """Simplify display output while avoiding a SymPy 1.13+ regression. + + This helper is only for rendering. Algebraic operations retain ordinary + ``simplify``. Remove the fallback after SymPy replaces the nested + traversal introduced by gh-26390 and galgebra's minimum supported SymPy + includes that fix. + """ + if _has_expensive_fu_traversal(expr): + return trigsimp(expr, method='old') + return simplify(expr) diff --git a/galgebra/metric.py b/galgebra/metric.py index aa26f91b..a5f8f9df 100644 --- a/galgebra/metric.py +++ b/galgebra/metric.py @@ -14,6 +14,7 @@ from . import printer from ._utils import cached_property as _cached_property +from ._utils.simplify import simplify_for_display from .atoms import ( BasisVectorSymbol, DotProductSymbol, MatrixFunction, Determinant, ) @@ -299,7 +300,8 @@ def symbols_list(s, indices=None, sub=True, commutative=False): class Simp: - modes = [simplify] + _default_modes = [simplify] + modes = _default_modes @staticmethod def profile(s): @@ -312,6 +314,19 @@ def apply(expr): obj += apply_function_list(Simp.modes, coef) * base return obj + @staticmethod + def apply_display(expr): + """Apply the display fallback unless the user selected a profile.""" + modes = ( + [simplify_for_display] + if Simp.modes == Simp._default_modes + else Simp.modes + ) + obj = S.Zero + for coef, base in linear_expand_terms(expr): + obj += apply_function_list(modes, coef) * base + return obj + @staticmethod def applymv(mv): return Mv(Simp.apply(mv.obj), ga=mv.Ga) diff --git a/galgebra/mv.py b/galgebra/mv.py index 544d8d5d..f1501802 100644 --- a/galgebra/mv.py +++ b/galgebra/mv.py @@ -612,7 +612,7 @@ def _sympystr(self, print_obj: printer.GaPrinter) -> str: # note: this just replaces `self` for the rest of this function obj = expand(self.obj) - obj = metric.Simp.apply(obj) + obj = metric.Simp.apply_display(obj) self = Mv(obj, ga=self.Ga) if self.i_grade == 0: @@ -697,7 +697,7 @@ def append_plus(c_str): # note: this just replaces `self` for the rest of this function obj = expand(self.obj) try: - obj = metric.Simp.apply(obj) + obj = metric.Simp.apply_display(obj) except ZeroDivisionError: pass # SymPy trigsimp regression; display without simplification self = Mv(obj, ga=self.Ga) diff --git a/test/test_simplify.py b/test/test_simplify.py new file mode 100644 index 00000000..2a886388 --- /dev/null +++ b/test/test_simplify.py @@ -0,0 +1,143 @@ +from unittest import mock + +from sympy import Add, cos, cosh, simplify, sin, sinh, symbols + +from galgebra._utils import simplify as simplify_module +from galgebra.ga import Ga +from galgebra.metric import Simp + + +x, y = symbols('x y') + + +def _paired_trig_expression(count, extra=0): + terms = [sin(x + i) + cos(x + i) for i in range(count)] + if extra != 0: + terms.append(extra) + return Add(*terms, evaluate=False) + + +def test_major_minor(): + assert simplify_module._major_minor('1.13.3') == (1, 13) + assert simplify_module._major_minor('1.15.dev') == (1, 15) + assert simplify_module._major_minor('unknown') == (0, 0) + + +def test_boundary_routes_only_at_or_above_limit(): + below = _paired_trig_expression(15) + above = _paired_trig_expression(16) + + assert not simplify_module._has_expensive_fu_traversal(below) + assert simplify_module._has_expensive_fu_traversal(above) + + +def test_algebra_keeps_general_simplification_above_display_boundary(): + rational = (y**2 - 1)/(y - 1) + expr = _paired_trig_expression(16, rational) + + result = Simp.apply(expr) + + assert not result.has(rational) + assert simplify(result - expr) == 0 + + +def test_display_route_can_preserve_unrelated_algebraic_form(): + rational = (y**2 - 1)/(y - 1) + expr = _paired_trig_expression(16, rational) + + with ( + mock.patch.object(simplify_module, 'simplify') as general, + mock.patch.object( + simplify_module, 'trigsimp', return_value=expr + ) as old, + ): + assert Simp.apply_display(expr) == expr + + general.assert_not_called() + old.assert_called_once() + routed = old.call_args.args[0] + assert old.call_args.kwargs == {'method': 'old'} + assert simplify(routed - expr) == 0 + + +def test_small_display_expression_uses_general_simplification(): + expr = sin(x)**2 + cos(x)**2 + + with ( + mock.patch.object( + simplify_module, 'simplify', return_value=1 + ) as general, + mock.patch.object(simplify_module, 'trigsimp') as old, + ): + assert Simp.apply_display(expr) == 1 + + general.assert_called_once() + assert simplify(general.call_args.args[0] - expr) == 0 + old.assert_not_called() + + +def test_sympy_before_1_13_uses_general_display_simplification(): + expr = _paired_trig_expression(16) + + with ( + mock.patch.object(simplify_module, '_SYMPY_MAJOR_MINOR', (1, 12)), + mock.patch.object( + simplify_module, 'simplify', return_value=1 + ) as general, + mock.patch.object(simplify_module, 'trigsimp') as old, + ): + assert Simp.apply_display(expr) == 1 + + general.assert_called_once() + assert simplify(general.call_args.args[0] - expr) == 0 + old.assert_not_called() + + +def test_custom_profile_overrides_display_fallback(): + original_modes = Simp.modes + custom = mock.Mock(return_value=x) + Simp.profile([custom]) + try: + assert Simp.apply_display(_paired_trig_expression(16)) == x + finally: + Simp.modes = original_modes + + custom.assert_called_once() + + +def test_copied_default_profile_restores_display_fallback(): + original_modes = Simp.modes + restored_modes = Simp.modes[:] + try: + Simp.profile([mock.Mock(return_value=x)]) + Simp.profile(restored_modes) + with mock.patch( + 'galgebra.metric.simplify_for_display', return_value=1 + ) as display: + assert Simp.apply_display(x) == 1 + finally: + Simp.modes = original_modes + + display.assert_called_once_with(x) + + +def test_prolate_spheroidal_divergence_renders(): + a = symbols('a', real=True) + coords = xi, eta, phi = symbols('xi eta phi', real=True) + ps3d, *_ = Ga.build( + 'e_xi e_eta e_phi', + X=[ + a*sinh(xi)*sin(eta)*cos(phi), + a*sinh(xi)*sin(eta)*sin(phi), + a*cosh(xi)*cos(eta), + ], + coords=coords, + norm=True, + ) + vector = ps3d.mv('A', 'vector', f=True) + + rendered = str(ps3d.grad | vector) + + assert 'D{eta}A__eta' in rendered + assert 'D{phi}A__phi' in rendered + assert 'D{xi}A__xi' in rendered From b6151548a25e8dc70d913ca81525540b8b53e0c3 Mon Sep 17 00:00:00 2001 From: Ulysses Tiberious <132908333+utiberious@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:19:56 -0400 Subject: [PATCH 2/4] fix: narrow display simplification fallback --- doc/changelog.rst | 17 +++-- examples/LaTeX/curvi_linear_latex.py | 2 +- galgebra/_utils/simplify.py | 38 ++++++++--- galgebra/metric.py | 10 ++- test/test_simplify.py | 94 ++++++++++++++++++++++++---- 5 files changed, 130 insertions(+), 31 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index d02ede32..f9ffa796 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -13,13 +13,18 @@ Changelog - :bug:`590` Worked around a performance regression in SymPy 1.13 that caused ``examples/ipython/LaTeX.ipynb`` (``check('curvi_linear_latex')``) to time out after 600 s on SymPy ≥ 1.13. SymPy PR #26390 added an O(N·M) - ``.replace()`` traversal inside ``TR3``/``futrig`` that is a no-op for - galgebra's symbolic trig arguments but dominated each of the ~70 - ``Simp.apply`` calls during ``Ga.build(norm=True)`` for curvilinear - coordinates. The fix uses ``trigsimp(method='old')`` via ``Simp.profile`` - for the affected example, cutting run time from > 600 s to < 6 s. + ``.replace()`` traversal inside ``TR3``/``futrig`` that made simplification + of the prolate-spheroidal output stall during display. The fix uses + ``trigsimp(method='old')`` via ``Simp.profile`` for the affected example, + cutting its run time from > 600 s to < 6 s. A notebook note documents the two cosmetic output differences from the - pre-1.13 form; a proper upstream fix is tracked in :issue:`576`. + pre-1.13 form. + +- :bug:`598` Multivector string and LaTeX display now avoid the same SymPy + regression outside that example. Large expressions with trigonometric and + hyperbolic functions under non-integral powers use the bounded + ``trigsimp(method='old')`` path. Algebraic simplification and explicit + ``Simp.profile`` modes remain unchanged. - :support:`589` Added Step 0 to the release-process runbook (``doc/dev/release-process.md``): open a release issue before preparing the diff --git a/examples/LaTeX/curvi_linear_latex.py b/examples/LaTeX/curvi_linear_latex.py index 77c54de2..7be83f5a 100644 --- a/examples/LaTeX/curvi_linear_latex.py +++ b/examples/LaTeX/curvi_linear_latex.py @@ -189,7 +189,7 @@ def main(): from sympy import trigsimp from galgebra.metric import Simp - orig_modes = Simp.modes[:] + orig_modes = Simp.modes Simp.profile([lambda e: trigsimp(e, method='old')]) try: derivatives_in_spherical_coordinates() diff --git a/galgebra/_utils/simplify.py b/galgebra/_utils/simplify.py index 1fea2b76..6e5c560e 100644 --- a/galgebra/_utils/simplify.py +++ b/galgebra/_utils/simplify.py @@ -19,10 +19,10 @@ def _major_minor(version): _SYMPY_MAJOR_MINOR = _major_minor(sympy.__version__) # SymPy 1.13's gh-26390 added a nested replace traversal to the FU -# simplifier. Its cost is proportional to the expression tree size times the -# number of trig and hyperbolic nodes. +# simplifier. The observed slow expression combines trigonometric and +# hyperbolic functions under non-integral powers; its cost is proportional to +# the expression tree size times the number of those function nodes. _FU_TRAVERSAL_COST_LIMIT = 4096 -_TRIG_FUNCTIONS = (TrigonometricFunction, HyperbolicFunction) def _has_expensive_fu_traversal(expr): @@ -30,13 +30,31 @@ def _has_expensive_fu_traversal(expr): if _SYMPY_MAJOR_MINOR < (1, 13): return False - trig_nodes = 0 - for node_count, node in enumerate(preorder_traversal(expr), 1): - if isinstance(node, _TRIG_FUNCTIONS): - trig_nodes += 1 - if node_count * trig_nodes >= _FU_TRAVERSAL_COST_LIMIT: - return True - return False + nodes = list(preorder_traversal(expr)) + trig_nodes = sum( + isinstance(node, TrigonometricFunction) for node in nodes + ) + hyperbolic_nodes = sum( + isinstance(node, HyperbolicFunction) for node in nodes + ) + traversal_cost = len(nodes) * (trig_nodes + hyperbolic_nodes) + if ( + trig_nodes == 0 + or hyperbolic_nodes == 0 + or traversal_cost < _FU_TRAVERSAL_COST_LIMIT + ): + return False + + return any( + ( + node.is_Pow + and node.exp.is_integer is False + and node.base.is_Add + and node.base.has(TrigonometricFunction) + and node.base.has(HyperbolicFunction) + ) + for node in nodes + ) def simplify_for_display(expr): diff --git a/galgebra/metric.py b/galgebra/metric.py index a5f8f9df..ecd5983e 100644 --- a/galgebra/metric.py +++ b/galgebra/metric.py @@ -300,8 +300,9 @@ def symbols_list(s, indices=None, sub=True, commutative=False): class Simp: - _default_modes = [simplify] - modes = _default_modes + _default_modes = (simplify,) + modes = list(_default_modes) + _default_modes_instance = modes @staticmethod def profile(s): @@ -319,7 +320,10 @@ def apply_display(expr): """Apply the display fallback unless the user selected a profile.""" modes = ( [simplify_for_display] - if Simp.modes == Simp._default_modes + if ( + Simp.modes is Simp._default_modes_instance + and tuple(Simp.modes) == Simp._default_modes + ) else Simp.modes ) obj = S.Zero diff --git a/test/test_simplify.py b/test/test_simplify.py index 2a886388..f1bbd1e1 100644 --- a/test/test_simplify.py +++ b/test/test_simplify.py @@ -1,6 +1,7 @@ from unittest import mock -from sympy import Add, cos, cosh, simplify, sin, sinh, symbols +import pytest +from sympy import Add, cos, cosh, simplify, sin, sinh, sqrt, symbols from galgebra._utils import simplify as simplify_module from galgebra.ga import Ga @@ -17,6 +18,14 @@ def _paired_trig_expression(count, extra=0): return Add(*terms, evaluate=False) +def _mixed_nested_expression(count): + terms = [ + sin(x + i) + cos(x + i) + sinh(y + i) + cosh(y + i) + for i in range(count) + ] + return Add(*terms, evaluate=False)/sqrt(sin(x)**2 + sinh(y)**2) + + def test_major_minor(): assert simplify_module._major_minor('1.13.3') == (1, 13) assert simplify_module._major_minor('1.15.dev') == (1, 15) @@ -24,28 +33,60 @@ def test_major_minor(): def test_boundary_routes_only_at_or_above_limit(): - below = _paired_trig_expression(15) - above = _paired_trig_expression(16) + below = _mixed_nested_expression(7) + above = _mixed_nested_expression(8) - assert not simplify_module._has_expensive_fu_traversal(below) - assert simplify_module._has_expensive_fu_traversal(above) + with mock.patch.object( + simplify_module, '_SYMPY_MAJOR_MINOR', (1, 13) + ): + assert not simplify_module._has_expensive_fu_traversal(below) + assert simplify_module._has_expensive_fu_traversal(above) -def test_algebra_keeps_general_simplification_above_display_boundary(): +def test_shallow_trig_sum_uses_real_general_simplifier(): rational = (y**2 - 1)/(y - 1) expr = _paired_trig_expression(16, rational) - result = Simp.apply(expr) + result = simplify_module.simplify_for_display(expr) assert not result.has(rational) assert simplify(result - expr) == 0 +def test_shallow_mixed_sum_does_not_match_failure_shape(): + terms = [ + sin(x + i) + cos(x + i) + sinh(y + i) + cosh(y + i) + for i in range(8) + ] + expr = Add(*terms, evaluate=False) + + with mock.patch.object( + simplify_module, '_SYMPY_MAJOR_MINOR', (1, 13) + ): + assert not simplify_module._has_expensive_fu_traversal(expr) + + +def test_algebra_keeps_general_simplification(): + rational = (y**2 - 1)/(y - 1) + + with mock.patch( + 'galgebra.metric.simplify_for_display' + ) as display: + result = Simp.apply(rational) + + assert not result.has(rational) + assert simplify(result - rational) == 0 + display.assert_not_called() + + def test_display_route_can_preserve_unrelated_algebraic_form(): rational = (y**2 - 1)/(y - 1) - expr = _paired_trig_expression(16, rational) + expr = _mixed_nested_expression(8) + rational with ( + mock.patch.object( + simplify_module, '_SYMPY_MAJOR_MINOR', (1, 13) + ), mock.patch.object(simplify_module, 'simplify') as general, mock.patch.object( simplify_module, 'trigsimp', return_value=expr @@ -105,12 +146,11 @@ def test_custom_profile_overrides_display_fallback(): custom.assert_called_once() -def test_copied_default_profile_restores_display_fallback(): +def test_default_profile_object_restores_display_fallback(): original_modes = Simp.modes - restored_modes = Simp.modes[:] try: Simp.profile([mock.Mock(return_value=x)]) - Simp.profile(restored_modes) + Simp.profile(original_modes) with mock.patch( 'galgebra.metric.simplify_for_display', return_value=1 ) as display: @@ -121,7 +161,39 @@ def test_copied_default_profile_restores_display_fallback(): display.assert_called_once_with(x) +def test_explicit_simplify_profile_overrides_display_fallback(): + original_modes = Simp.modes + try: + Simp.profile([simplify]) + with mock.patch( + 'galgebra.metric.simplify_for_display' + ) as display: + assert Simp.apply_display(sin(x)**2 + cos(x)**2) == 1 + finally: + Simp.modes = original_modes + + display.assert_not_called() + + +def test_in_place_profile_change_overrides_display_fallback(): + custom = mock.Mock(return_value=x) + Simp.modes.append(custom) + try: + with mock.patch( + 'galgebra.metric.simplify_for_display' + ) as display: + assert Simp.apply_display(x) == x + finally: + Simp.modes.remove(custom) + + custom.assert_called_once_with(x) + display.assert_not_called() + + def test_prolate_spheroidal_divergence_renders(): + if simplify_module._SYMPY_MAJOR_MINOR < (1, 13): + pytest.skip('display fallback targets SymPy 1.13 and newer') + a = symbols('a', real=True) coords = xi, eta, phi = symbols('xi eta phi', real=True) ps3d, *_ = Ga.build( From 41e98be8031a4dc378493c502e54c6dccf98f54d Mon Sep 17 00:00:00 2001 From: Ulysses Tiberious <132908333+utiberious@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:09:20 -0400 Subject: [PATCH 3/4] fix: score display fallback candidates locally --- galgebra/_utils/simplify.py | 37 +++++++++++++++++------------------- test/test_simplify.py | 38 +++++++++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/galgebra/_utils/simplify.py b/galgebra/_utils/simplify.py index 6e5c560e..ab65402b 100644 --- a/galgebra/_utils/simplify.py +++ b/galgebra/_utils/simplify.py @@ -19,10 +19,21 @@ def _major_minor(version): _SYMPY_MAJOR_MINOR = _major_minor(sympy.__version__) # SymPy 1.13's gh-26390 added a nested replace traversal to the FU -# simplifier. The observed slow expression combines trigonometric and -# hyperbolic functions under non-integral powers; its cost is proportional to -# the expression tree size times the number of those function nodes. -_FU_TRAVERSAL_COST_LIMIT = 4096 +# simplifier. The observed slow expression contains a sufficiently complex +# additive base of trigonometric and hyperbolic functions under a +# non-integral power. Score each such base independently so unrelated terms +# elsewhere in the expression cannot make a small radical look expensive. +_FU_TRAVERSAL_COST_LIMIT = 18 + + +def _fu_candidate_cost(base): + """Estimate nested traversal work within one candidate power base.""" + nodes = list(preorder_traversal(base)) + function_nodes = sum( + isinstance(node, (TrigonometricFunction, HyperbolicFunction)) + for node in nodes + ) + return len(nodes) * function_nodes def _has_expensive_fu_traversal(expr): @@ -30,21 +41,6 @@ def _has_expensive_fu_traversal(expr): if _SYMPY_MAJOR_MINOR < (1, 13): return False - nodes = list(preorder_traversal(expr)) - trig_nodes = sum( - isinstance(node, TrigonometricFunction) for node in nodes - ) - hyperbolic_nodes = sum( - isinstance(node, HyperbolicFunction) for node in nodes - ) - traversal_cost = len(nodes) * (trig_nodes + hyperbolic_nodes) - if ( - trig_nodes == 0 - or hyperbolic_nodes == 0 - or traversal_cost < _FU_TRAVERSAL_COST_LIMIT - ): - return False - return any( ( node.is_Pow @@ -52,8 +48,9 @@ def _has_expensive_fu_traversal(expr): and node.base.is_Add and node.base.has(TrigonometricFunction) and node.base.has(HyperbolicFunction) + and _fu_candidate_cost(node.base) >= _FU_TRAVERSAL_COST_LIMIT ) - for node in nodes + for node in preorder_traversal(expr) ) diff --git a/test/test_simplify.py b/test/test_simplify.py index f1bbd1e1..a1bae937 100644 --- a/test/test_simplify.py +++ b/test/test_simplify.py @@ -8,7 +8,7 @@ from galgebra.metric import Simp -x, y = symbols('x y') +x, y, u, v = symbols('x y u v') def _paired_trig_expression(count, extra=0): @@ -18,12 +18,8 @@ def _paired_trig_expression(count, extra=0): return Add(*terms, evaluate=False) -def _mixed_nested_expression(count): - terms = [ - sin(x + i) + cos(x + i) + sinh(y + i) + cosh(y + i) - for i in range(count) - ] - return Add(*terms, evaluate=False)/sqrt(sin(x)**2 + sinh(y)**2) +def _mixed_nested_expression(): + return 1/sqrt(sin(x)**2 + sinh(y)**2) def test_major_minor(): @@ -33,12 +29,14 @@ def test_major_minor(): def test_boundary_routes_only_at_or_above_limit(): - below = _mixed_nested_expression(7) - above = _mixed_nested_expression(8) + below = sqrt(sin(x) + sinh(y)) + above = _mixed_nested_expression() with mock.patch.object( simplify_module, '_SYMPY_MAJOR_MINOR', (1, 13) ): + assert simplify_module._fu_candidate_cost(below.base) == 10 + assert simplify_module._fu_candidate_cost(above.base) == 18 assert not simplify_module._has_expensive_fu_traversal(below) assert simplify_module._has_expensive_fu_traversal(above) @@ -53,6 +51,26 @@ def test_shallow_trig_sum_uses_real_general_simplifier(): assert simplify(result - expr) == 0 +def test_small_mixed_radical_cannot_borrow_unrelated_expression_cost(): + rational = (y**2 - 1)/(y - 1) + expr = Add( + *[sin(x + i) + cos(x + i) for i in range(16)], + rational, + sqrt(sin(u) + sinh(v)), + evaluate=False, + ) + + with mock.patch.object( + simplify_module, '_SYMPY_MAJOR_MINOR', (1, 13) + ): + assert not simplify_module._has_expensive_fu_traversal(expr) + + result = simplify_module.simplify_for_display(expr) + + assert not result.has(rational) + assert simplify(result - expr) == 0 + + def test_shallow_mixed_sum_does_not_match_failure_shape(): terms = [ sin(x + i) + cos(x + i) + sinh(y + i) + cosh(y + i) @@ -81,7 +99,7 @@ def test_algebra_keeps_general_simplification(): def test_display_route_can_preserve_unrelated_algebraic_form(): rational = (y**2 - 1)/(y - 1) - expr = _mixed_nested_expression(8) + rational + expr = _mixed_nested_expression() + rational with ( mock.patch.object( From 449c4d21b604d167e7a3a71102c5ebb5fb77f368 Mon Sep 17 00:00:00 2001 From: Ulysses Tiberious <132908333+utiberious@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:50:19 -0400 Subject: [PATCH 4/4] fix: match only the observed display slowdown --- galgebra/_utils/simplify.py | 47 ++++++++++++++++++++++--------------- test/test_simplify.py | 37 ++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/galgebra/_utils/simplify.py b/galgebra/_utils/simplify.py index ab65402b..921027fd 100644 --- a/galgebra/_utils/simplify.py +++ b/galgebra/_utils/simplify.py @@ -19,21 +19,33 @@ def _major_minor(version): _SYMPY_MAJOR_MINOR = _major_minor(sympy.__version__) # SymPy 1.13's gh-26390 added a nested replace traversal to the FU -# simplifier. The observed slow expression contains a sufficiently complex -# additive base of trigonometric and hyperbolic functions under a -# non-integral power. Score each such base independently so unrelated terms -# elsewhere in the expression cannot make a small radical look expensive. -_FU_TRAVERSAL_COST_LIMIT = 18 - - -def _fu_candidate_cost(base): - """Estimate nested traversal work within one candidate power base.""" - nodes = list(preorder_traversal(base)) - function_nodes = sum( - isinstance(node, (TrigonometricFunction, HyperbolicFunction)) - for node in nodes +# simplifier. Match only the observed two-term prolate radical shape. A +# numerical tree-cost heuristic admitted benign expressions whose unrelated +# terms happened to produce the same score. + + +def _is_squared_function(term, function_type): + return ( + term.is_Pow + and term.exp == 2 + and isinstance(term.base, function_type) + ) + + +def _is_mixed_squared_base(base): + """Whether ``base`` is one trig square plus one hyperbolic square.""" + if not base.is_Add or len(base.args) != 2: + return False + return ( + any( + _is_squared_function(term, TrigonometricFunction) + for term in base.args + ) + and any( + _is_squared_function(term, HyperbolicFunction) + for term in base.args + ) ) - return len(nodes) * function_nodes def _has_expensive_fu_traversal(expr): @@ -44,11 +56,8 @@ def _has_expensive_fu_traversal(expr): return any( ( node.is_Pow - and node.exp.is_integer is False - and node.base.is_Add - and node.base.has(TrigonometricFunction) - and node.base.has(HyperbolicFunction) - and _fu_candidate_cost(node.base) >= _FU_TRAVERSAL_COST_LIMIT + and abs(node.exp) == sympy.S.Half + and _is_mixed_squared_base(node.base) ) for node in preorder_traversal(expr) ) diff --git a/test/test_simplify.py b/test/test_simplify.py index a1bae937..abc3f11f 100644 --- a/test/test_simplify.py +++ b/test/test_simplify.py @@ -1,7 +1,7 @@ from unittest import mock import pytest -from sympy import Add, cos, cosh, simplify, sin, sinh, sqrt, symbols +from sympy import Add, Rational, cos, cosh, simplify, sin, sinh, sqrt, symbols from galgebra._utils import simplify as simplify_module from galgebra.ga import Ga @@ -9,6 +9,7 @@ x, y, u, v = symbols('x y u v') +z = symbols('z:4') def _paired_trig_expression(count, extra=0): @@ -28,17 +29,17 @@ def test_major_minor(): assert simplify_module._major_minor('unknown') == (0, 0) -def test_boundary_routes_only_at_or_above_limit(): - below = sqrt(sin(x) + sinh(y)) - above = _mixed_nested_expression() +def test_routes_only_observed_mixed_squared_shape(): + plain = sqrt(sin(x) + sinh(y)) + observed = _mixed_nested_expression() + other_power = (sin(x)**2 + sinh(y)**2)**Rational(1, 3) with mock.patch.object( simplify_module, '_SYMPY_MAJOR_MINOR', (1, 13) ): - assert simplify_module._fu_candidate_cost(below.base) == 10 - assert simplify_module._fu_candidate_cost(above.base) == 18 - assert not simplify_module._has_expensive_fu_traversal(below) - assert simplify_module._has_expensive_fu_traversal(above) + assert not simplify_module._has_expensive_fu_traversal(plain) + assert not simplify_module._has_expensive_fu_traversal(other_power) + assert simplify_module._has_expensive_fu_traversal(observed) def test_shallow_trig_sum_uses_real_general_simplifier(): @@ -71,6 +72,26 @@ def test_small_mixed_radical_cannot_borrow_unrelated_expression_cost(): assert simplify(result - expr) == 0 +def test_large_benign_mixed_radical_uses_general_simplifier(): + rational = (y**2 - 1)/(y - 1) + expr = Add( + *[sin(x + i) + cos(x + i) for i in range(16)], + rational, + sqrt(sin(u) + sinh(v) + sum(z)), + evaluate=False, + ) + + with mock.patch.object( + simplify_module, '_SYMPY_MAJOR_MINOR', (1, 13) + ): + assert not simplify_module._has_expensive_fu_traversal(expr) + + result = simplify_module.simplify_for_display(expr) + + assert not result.has(rational) + assert simplify(result - expr) == 0 + + def test_shallow_mixed_sum_does_not_match_failure_shape(): terms = [ sin(x + i) + cos(x + i) + sinh(y + i) + cosh(y + i)