Skip to content

Pure functions keep the slot-free parts of their body - #2085

Merged
rocky merged 21 commits into
masterfrom
fix-function-body-rebuild
Oct 31, 2026
Merged

rocky merged 21 commits into
masterfrom
fix-function-body-rebuild

Conversation

@tombouctou

Copy link
Copy Markdown
Collaborator

Found while running the examples of Skiena and Pemmaraju's Computational Discrete Mathematics with the Combinatorica package and comparing every result with WMA 15.

Builds on #2084 (Part assignment and shared values), which this needs; please merge that one first.

Filling in the slots of a pure function rebuilt its whole body. A literal list in the body therefore lost what was known of its evaluation and was evaluated again at every application. An example is the permutation group that Combinatorica's KSubsetGroup maps over.

Parts with no slots are now kept as they are:

With[{g = SymmetricGroup[5]}, Do[Map[g[[i]][[#]] &, s, {2}], {i, 120}]]

now takes 1.9 s instead of 33.8 s. KSubsetGroup[SymmetricGroup[5], KSubsets[Range[5], 2]] takes 4 s instead of 14 s.

Since the body is now shared between applications, a Part assignment inside it must not change it in place, which is what #2084 fixes. Without it, (x = {1, {2}}; x[[2, 1]] = #; x) & would rewrite its own body.

All the fixes from this series together, merged on one branch: full pytest 4336 passed, 0 failed; doctests 4679 passed, 0 failed.

🤖 Generated with Claude Code

tombouctou and others added 19 commits October 29, 2026 19:04
x[[{2, 5}]] = v crashed (SubExpression returned None for a
ListExpression position), x[[All, 1]] = v assigned to x[[1]] instead of
the first element of every row, x[[-1]] = v wrote the wrong element, and
a part that does not exist raised IndexError after some parts had
already been changed.

Positions are now resolved and checked before anything is replaced:
a list of positions, All and a Span select several parts, negative
positions count from the end, and a missing part gives Part::partw and
leaves the value unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
l[[0]] = f with l a List, m[[2, 0]] = g on a nested List, and k[[0]] =
List crashed with a TypeError: a List cannot take another head in
place. The part is now replaced by a new expression with the new head.
The value assigned to is held while it changes, so that the value itself
can be replaced as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b = a; b[[2, 1]] = 9 changed a too, and k[] := (w = {1, {2}};
w[[2, 1]] = 5; w) changed the definition of k: the parts on the way to
the assigned position were changed in place, though they may be shared
with other values and definitions. They are now copied first.

A ListExpression changed with set_element works out again whether it
is literal. Span positions counted from the end, and spans with a
negative step, now select the parts WMA selects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Filling in the slots of a pure function rebuilt its whole body, so a
literal list in it, such as the permutation group KSubsetGroup maps
over, lost what was known of its evaluation and was evaluated again at
every application. Parts with no slots are now kept as they are:
With[{g = SymmetricGroup[5]}, Do[Map[g[[i]][[#]] &, s, {2}], {i, 120}]]
takes 1.9 s instead of 33.8 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pa[[{2, f[x]}]] = 5 raised an AttributeError; it now gives the message
WMA gives and leaves pa unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t again

Each p[[i]] = v on a long list went over all its elements several
times: set_element worked out again whether the list is literal, and
afterwards its elements properties and its symbol cache were rebuilt,
and the list was evaluated element by element, because they had been
cleared. ListExpression.set_element now updates the literal value, the
elements properties and the cache from the old ones and the new element;
copies of a list keep them too.

A Fisher-Yates shuffle of Range[1000] by Part assignments (Combinatorica's
RandomPermutation) took 11.7 s with the copy on write of this branch and
9.3 s on master; it now takes 2.8 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
part[[-4]] = 0 on a list of three elements turned -4 into position 0
and replaced the head, giving 0[1, 2, 3]. A negative position now has
to be between -Length and -1, as in WMA; position 0 is reached only as
0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from fix-part-assign-shared to master October 31, 2026 14:03
@rocky

rocky commented Oct 31, 2026

Copy link
Copy Markdown
Member

LGTM. Excellent work in finding these performance problems.

@rocky
rocky merged commit a64effc into master Oct 31, 2026
15 checks passed
@rocky
rocky deleted the fix-function-body-rebuild branch October 31, 2026 14:48
rocky added a commit that referenced this pull request Nov 4, 2026
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 they were still walked. In
Combinatorica's code, a graph is passed to a function and written into a
pure function inside it. That graph was walked once per application, so
mapping over its own elements cost the square of its length.

**Change**
- An expression knows whether a `Slot` or `SlotSequence` occurs in it,
heads included (`f[#][x]` has one in its head).
- This is worked out once and kept with the expression, and forgotten
when its head or elements change.
- `replace_slots` returns a part without slots at once.

**Measurement:** five applications of `{#1, Length[big]} &` with a
300-element `big` make 3020 `replace_slots` calls on master, now 15.

**Tests:** `test/core/test_replace_slots.py`.
- The call count above.
- Pure functions with lists in their bodies, `##`, nested `Function`,
`Hold`.
- Slots in compound heads at any depth: `#[0] &`, `{a, {#[0]}} &`,
`f[#][x] &`, `g[h[#]][x] &`, `f[##][y] &`.

The full pytest run and the doctests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: R. Bernstein <rocky@users.noreply.github.com>
Co-authored-by: rocky <rb@dustyfeet.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.

2 participants