Skip to content

Compare and render a term without a descent that follows its depth - #105

Merged
mrhaandi merged 1 commit into
developfrom
bugfix/iterative-tree-comparison
Aug 25, 2026
Merged

Compare and render a term without a descent that follows its depth#105
mrhaandi merged 1 commit into
developfrom
bugfix/iterative-tree-comparison

Conversation

@FelixLaarmann

Copy link
Copy Markdown
Member

Problem

Tree.__eq__ compared the children of two nodes as tuples, and comparing tuples compares their
elements, so a comparison descended one level per level of the term. Tree.__str__ descended the
same way through __rec_to_str__. That bounded the depth a term could have at a fixed fraction of
the interpreter's recursion limit: two separately built copies of a chain overflowed at a third of
the limit on CPython 3.11 and at nearly all of it from 3.12 on. Every dict and every set keyed
on terms carried the same bound, because a lookup whose hash matches ends in a comparison: the
fitness cache of the evolutionary algorithms is keyed on terms, and so is the deduplication of a
population.

Changes

  • __eq__ carries the pairs still to be compared on an explicit stack, asking the same questions
    in the same order and stopping at the first position where the two terms disagree.
  • __str__ carries what it still has to visit the same way, once to descend into a node and once
    to join what its children left behind. The brackets it writes are unchanged.
  • Subterms of at most _SHALLOW_ENOUGH positions keep the comparison and the renderer they had.
  • The comment above __reduce__ said that pickling is the one place in this class where depth is
    a limit. It now names equality and rendering among the iterative operations, which makes it true.

@FelixLaarmann
FelixLaarmann requested a review from mrhaandi August 25, 2026 04:13

@tudo-seal-workflows tudo-seal-workflows Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark CoSy

Details
Benchmark suite Current: 7856ed8 Previous: be71b5d Ratio
benchmarks/test_benchmark_maximal_elements.py::test_benchmark_maximal_elements 9.937766624688578 iter/sec (stddev: 0.0005041658476680435) 9.608281351562356 iter/sec (stddev: 0.010017085420616013) 0.97
benchmarks/test_benchmark_maze.py::test_benchmark_maze 3.9918314131882298 iter/sec (stddev: 0.020639774010039528) 3.9235166334572096 iter/sec (stddev: 0.018797551923491352) 0.98
benchmarks/test_benchmark_maze_contains.py::test_benchmark_maze_contains 3.6246320652301436 iter/sec (stddev: 0.0224579446751456) 3.5391214334611916 iter/sec (stddev: 0.025877608823890046) 0.98
benchmarks/test_benchmark_maze_loopfree.py::test_benchmark_maze_loopfree 4.022406817175069 iter/sec (stddev: 0.016062203100244418) 3.8477221717914887 iter/sec (stddev: 0.02072136799063595) 0.96

This comment was automatically generated by workflow using github-action-benchmark.

@codecov-commenter

codecov-commenter commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.03%. Comparing base (bd072e2) to head (7856ed8).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #105      +/-   ##
===========================================
+ Coverage    83.64%   84.03%   +0.39%     
===========================================
  Files           55       55              
  Lines         4866     4986     +120     
  Branches       600      616      +16     
===========================================
+ Hits          4070     4190     +120     
  Misses         699      699              
  Partials        97       97              
Flag Coverage Δ
macos-latest-3.10 83.97% <100.00%> (+0.39%) ⬆️
macos-latest-3.11 84.01% <100.00%> (+0.39%) ⬆️
macos-latest-3.12 84.01% <100.00%> (+0.39%) ⬆️
macos-latest-3.13 84.01% <100.00%> (+0.39%) ⬆️
ubuntu-latest-3.10 83.97% <100.00%> (+0.39%) ⬆️
ubuntu-latest-3.11 84.01% <100.00%> (+0.39%) ⬆️
ubuntu-latest-3.12 84.01% <100.00%> (+0.39%) ⬆️
ubuntu-latest-3.13 84.01% <100.00%> (+0.39%) ⬆️
windows-latest-3.10 83.97% <100.00%> (+0.39%) ⬆️
windows-latest-3.11 84.01% <100.00%> (+0.39%) ⬆️
windows-latest-3.12 84.01% <100.00%> (+0.39%) ⬆️
windows-latest-3.13 84.01% <100.00%> (+0.39%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread src/cosy/core/tree.py Outdated
# terms, every ``dict.fromkeys`` and the fitness cache of the evolutionary algorithms. The value
# is a compromise: small enough for the descent to fit in a call stack that is already in use,
# large enough that ordinary terms take it.
_SHALLOW_ENOUGH = 64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"shallow" means "depth" is small. the magic constant is used for "size". maybe "_SMALL" is a better name?

Comparing the children of two nodes compared two tuples, and comparing tuples compares their
elements, so `Tree.__eq__` descended one level per level of the term. `Tree.__str__` descended
the same way through `__rec_to_str__`. That bounded the depth a term could have at a fixed
fraction of the interpreter's recursion limit: two separately built copies of a chain
overflowed at a third of the limit on CPython 3.11 and at nearly all of it from 3.12 on, where
the separate C recursion limit binds. Every dict and every set keyed on terms carried the same
bound, because a lookup whose hash matches ends in a comparison: the fitness cache of the
evolutionary algorithms is keyed on terms, and so is the deduplication of a population.

Both operations now carry what they still have to visit on an explicit stack, and subterms of
at most `_SMALL_ENOUGH` positions are compared and rendered as before. `size` counts
positions and is `1 + sum(child.size)`, so a subterm of at most n positions is at most n levels
deep, and the descent below the threshold is bounded by the threshold rather than by the term.
That is a property of the definition, not of a measurement. The comparison asks the same
questions in the same order as before and stops at the first position where the two terms
disagree, and the rendering writes the same brackets.

The descent is kept below the threshold because it is the tuple comparison, which runs in C,
where an explicit stack allocates a tuple and a list slot per node in Python, and `__eq__`
carries every set of terms, every `dict.fromkeys` and the fitness cache. Converting it as well
cost 3.7 times as much on a dict lookup when measured. What that would buy is a smaller
constant, not a wider class of terms: below the threshold this answers what the descent
answered before, because below the threshold it is that descent, and no caller can compare less
than it could before.

One answer changes. A term is equal to itself even where the label at its root is not equal to
itself, because identity is checked first. A term rooted in a NaN used to be unequal to itself
while the same NaN one level down was not, since the tuple comparison settled that pair on
identity before looking at it. Both answers are the reflexive one now, which is what a dict and
a set gave all along. Two terms built apart around a NaN stay unequal.

Costs, from one machine and one build with both trees measured in the same run: comparing is
about 9 % more expensive on the shapes a search produces, a dict lookup over 200 separately
built keys taking 375 against 343 microseconds and a set of 400 terms 385 against 355.
Comparing a term against itself is twice as fast, and rendering is unchanged at 813 against 819
microseconds over 200 terms. The two cases that pay more are the ones that could not be
computed at all before: a chain of 300 nodes compares in 114 against 47 microseconds and
renders in 166 against 119. The benchmark suite moves within 1 % in both directions.

Thirteen tests. Four work at twice the recursion limit, on equality, a dict lookup, rendering,
and a comparison that has to reach the bottom of such a term, which is the statement about the
old bound that does not depend on which interpreter runs it. Four fix what the loop has to
notice by itself and a chain cannot show, because a chain has one child per node: differing
arity, children compared in place, a shared subterm that settles nothing, and the label of a
node the loop descends into. Four fix what a term renders as, against a reference renderer
written independently of `__str__`, on terms on both sides of the threshold. One fixes that
equality is reflexive whatever the labels are.

The test in `tests/test_kernels.py` that records this defect with `pytest.raises(RecursionError)`
is replaced by one asserting the answer the kernel now gives, which is the answer it already gives
for two identical objects.
@FelixLaarmann
FelixLaarmann force-pushed the bugfix/iterative-tree-comparison branch from 0524dc1 to 7856ed8 Compare August 25, 2026 11:13
@mrhaandi
mrhaandi merged commit 3d63ff1 into develop Aug 25, 2026
14 checks passed
@FelixLaarmann
FelixLaarmann deleted the bugfix/iterative-tree-comparison branch August 25, 2026 11:56
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.

3 participants