From 5230cabf155a96f1f47a2cf91b30fa3a545d8623 Mon Sep 17 00:00:00 2001 From: Stanislav Date: Wed, 4 Nov 2026 17:30:53 +0400 Subject: [PATCH 1/2] Pure functions: parts of the body without slots are not walked Applying a pure function replaces its slots with replace_slots, which went over every subexpression of the body. Parts without slots were already kept as they are (#2085), but still walked: a list passed to a function and written into a pure function inside it, like a graph in Combinatorica's code, was walked once per application, so mapping over its own elements cost the square of its length. Now an expression knows whether a Slot or SlotSequence occurs in it, heads included (f[#][x] has one in its head): worked out once and kept with it, forgotten when its head or elements change. replace_slots returns a part without slots at once. Five applications of {#1, Length[big]} & with a 300-element big make 3020 replace_slots calls on master, now 15. Co-Authored-By: Claude Opus 5.5 --- mathics/core/expression.py | 30 ++++++++++++++++ test/core/test_replace_slots.py | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 test/core/test_replace_slots.py diff --git a/mathics/core/expression.py b/mathics/core/expression.py index 718a2c26e..5f5cc3b5e 100644 --- a/mathics/core/expression.py +++ b/mathics/core/expression.py @@ -371,6 +371,8 @@ class Expression(BaseElement, NumericOperators, EvalMixin): _cache: ExpressionCache | None # (head, elements, element_order) of the last element_order built. _element_order_cache: tuple[BaseElement, tuple[BaseElement, ...], tuple] | None + # Whether no Slot occurs in the expression: see _has_no_slots(). + _slot_free: bool | None = None elements_properties: ElementsProperties | None options: dict[str, Any] | None pattern_sequence: bool @@ -614,6 +616,7 @@ def elements(self, values: Iterable): # Set to build self.elements_properties on next evaluation() self.elements_properties = None self._cache = None + self._slot_free = None self._element_order_cache = None def equal2(self, rhs: Any) -> bool | None: @@ -1732,6 +1735,7 @@ def set_head(self, head: Symbol): raise TypeError("Attempt to turn an Expression into a ListExpression") self._head = head self._cache = None + self._slot_free = None self._element_order_cache = None def set_element(self, index: int, value): @@ -1994,6 +1998,28 @@ def replace_vars(self, vars, options=None, in_function=True) -> "Expression": *[element.replace_vars(vars, options=options) for element in elements], ) + def _has_no_slots(self) -> bool: + """ + True if no Slot or SlotSequence occurs in the expression, heads + included. Worked out once and kept with the expression, so that a + part shared by every application of a pure function is looked at + once; changing the head or the elements forgets it. + """ + flag = self._slot_free + if flag is None: + head = self._head + flag = ( + head is not SymbolSlot + and head is not SymbolSlotSequence + and (not isinstance(head, Expression) or head._has_no_slots()) + and all( + not isinstance(element, Expression) or element._has_no_slots() + for element in self._elements + ) + ) + self._slot_free = flag + return flag + def replace_slots(self, slots, evaluation): """ Replaces Slots (#1, ##, etc) by the corresponding values in `slots` @@ -2020,6 +2046,10 @@ def replace_slots(self, slots, evaluation): elif self._head is SymbolFunction and len(self._elements) == 1: # do not replace Slots in nested Functions return self + if self._has_no_slots(): + # No slot anywhere below, e.g. a graph passed to a function and + # written into the body of a pure function: nothing to walk. + return self head = self._head.replace_slots(slots, evaluation) elements = [ element.replace_slots(slots, evaluation) for element in self._elements diff --git a/test/core/test_replace_slots.py b/test/core/test_replace_slots.py new file mode 100644 index 000000000..0136f44fe --- /dev/null +++ b/test/core/test_replace_slots.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" +Applying a pure function does not walk the parts of its body that have no +slot in them. +""" + +import pytest + +from mathics.core.expression import Expression +from test.helper import check_evaluation, session + + +def test_slot_free_part_is_not_walked(monkeypatch): + big = session.evaluate("Table[{i, w -> i}, {i, 300}]") + body = session.evaluate("Hold[{#1, Length[x]}]").elements[0] + # The body {#1, Length[]}, as when a list is passed to a function + # and written into a pure function inside it. + body = Expression(body.head, body.elements[0], Expression(body.elements[1].head, big)) + calls = [0] + original = Expression.replace_slots + + def counting(self, slots, evaluation): + calls[0] += 1 + return original(self, slots, evaluation) + + monkeypatch.setattr(Expression, "replace_slots", counting) + for i in range(5): + new = body.replace_slots([None, session.evaluate(str(i))], session.evaluation) + assert new.elements[1].elements[0] is big + # Three calls per application (the body, #1 and Length[big]), + # not one for each of the 900 subexpressions of big. + assert calls[0] <= 5 * 4, f"{calls[0]} calls for 5 applications" + + +@pytest.mark.parametrize( + ("str_expr", "str_expected"), + [ + ("rsData = Range[5]; rsF[l_] := Map[{#, Length[l]} &, {a, b}]; rsF[rsData]", "{{a, 5}, {b, 5}}"), + ("Map[{#, {1, 2}} &, {a, b}]", "{{a, {1, 2}}, {b, {1, 2}}}"), + ("Apply[{##, {x}} &, {a, b}]", "{a, b, {x}}"), + ("Map[Function[{#, # &}], {1}]", "{{1, #1 &}}"), + ("Map[{#, Hold[#]} &, {a}]", "{{a, Hold[a]}}"), + ("ClearAll[rsData, rsF]", "Null"), + ], +) +def test_pure_functions(str_expr, str_expected): + check_evaluation(str_expr, str_expected) + + +@pytest.mark.parametrize( + ("str_expr", "str_expected"), + [ + # Slots in a compound head, at any depth. + ("Map[#[0] &, {Sin, Cos}]", "{0, 1}"), + ("Map[{a, {#[0]}} &, {Cos}]", "{{a, {1}}}"), + ("Map[f[#][x] &, {1, 2}]", "{f[1][x], f[2][x]}"), + ("Map[g[h[#]][x] &, {1}]", "{g[h[1]][x]}"), + ("Apply[f[##][y] &, {1, 2}]", "f[1, 2][y]"), + ], +) +def test_slots_in_compound_heads(str_expr, str_expected): + check_evaluation(str_expr, str_expected) From 492f0e213d26572401f55ed2980b98d944bfd409 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 4 Nov 2026 11:39:51 -0500 Subject: [PATCH 2/2] isort and black --- test/core/test_replace_slots.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/core/test_replace_slots.py b/test/core/test_replace_slots.py index 0136f44fe..777b2ed11 100644 --- a/test/core/test_replace_slots.py +++ b/test/core/test_replace_slots.py @@ -4,10 +4,11 @@ slot in them. """ +from test.helper import check_evaluation, session + import pytest from mathics.core.expression import Expression -from test.helper import check_evaluation, session def test_slot_free_part_is_not_walked(monkeypatch): @@ -15,7 +16,9 @@ def test_slot_free_part_is_not_walked(monkeypatch): body = session.evaluate("Hold[{#1, Length[x]}]").elements[0] # The body {#1, Length[]}, as when a list is passed to a function # and written into a pure function inside it. - body = Expression(body.head, body.elements[0], Expression(body.elements[1].head, big)) + body = Expression( + body.head, body.elements[0], Expression(body.elements[1].head, big) + ) calls = [0] original = Expression.replace_slots @@ -35,7 +38,10 @@ def counting(self, slots, evaluation): @pytest.mark.parametrize( ("str_expr", "str_expected"), [ - ("rsData = Range[5]; rsF[l_] := Map[{#, Length[l]} &, {a, b}]; rsF[rsData]", "{{a, 5}, {b, 5}}"), + ( + "rsData = Range[5]; rsF[l_] := Map[{#, Length[l]} &, {a, b}]; rsF[rsData]", + "{{a, 5}, {b, 5}}", + ), ("Map[{#, {1, 2}} &, {a, b}]", "{{a, {1, 2}}, {b, {1, 2}}}"), ("Apply[{##, {x}} &, {a, b}]", "{a, b, {x}}"), ("Map[Function[{#, # &}], {1}]", "{{1, #1 &}}"),