Skip to content

Let a term be evaluated again, and look up a signature once per combinator - #100

Merged
mrhaandi merged 5 commits into
developfrom
bugfix/interpretation-caching
Aug 21, 2026
Merged

Let a term be evaluated again, and look up a signature once per combinator#100
mrhaandi merged 5 commits into
developfrom
bugfix/interpretation-caching

Conversation

@FelixLaarmann

@FelixLaarmann FelixLaarmann commented Aug 21, 2026

Copy link
Copy Markdown
Member

Closes #98.

The measurements and the case for removing the result cache are in #98 and are not repeated here.
This is what the change looks like, and it answers the question I left open there, namely how long
an entry should live.

Tree._interpreted and the two cache sites in interpret are gone, so every combinator is
applied on every evaluation again. In their place, interpret asks _parameters_of for the
parameters of a combinator, and that lookup memoizes on the combinator object.

The memo holds metadata about a combinator, never the result of applying one. Parameters follow
from the callable and not from its arguments, so an entry cannot go stale the way a result can.
The assumption behind it is weaker than one interpret already made, since the body determines
the parameters of an occurrence once and keeps them for the whole argument distribution, cache or
no cache. It can be wrong only if a callable changes its signature between evaluations, and that
ends in a TypeError naming the combinator rather than in a wrong value.

@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: e496e28 Previous: be71b5d Ratio
benchmarks/test_benchmark_maximal_elements.py::test_benchmark_maximal_elements 9.749118761312753 iter/sec (stddev: 0.000273129446901472) 9.608281351562356 iter/sec (stddev: 0.010017085420616013) 0.99
benchmarks/test_benchmark_maze.py::test_benchmark_maze 3.743497299056593 iter/sec (stddev: 0.02100845057698785) 3.9235166334572096 iter/sec (stddev: 0.018797551923491352) 1.05
benchmarks/test_benchmark_maze_contains.py::test_benchmark_maze_contains 3.426409647594738 iter/sec (stddev: 0.01924185941580888) 3.5391214334611916 iter/sec (stddev: 0.025877608823890046) 1.03
benchmarks/test_benchmark_maze_loopfree.py::test_benchmark_maze_loopfree 3.648644110031215 iter/sec (stddev: 0.020082525573004724) 3.8477221717914887 iter/sec (stddev: 0.02072136799063595) 1.05

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

@codecov-commenter

codecov-commenter commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.40476% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.99%. Comparing base (d762d5b) to head (e496e28).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
tests/test_tree_performance.py 98.59% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #100      +/-   ##
===========================================
+ Coverage    79.28%   79.99%   +0.70%     
===========================================
  Files           47       47              
  Lines         3833     3944     +111     
  Branches       532      536       +4     
===========================================
+ Hits          3039     3155     +116     
+ Misses         693      689       -4     
+ Partials       101      100       -1     
Flag Coverage Δ
macos-latest-3.10 79.91% <99.40%> (+0.71%) ⬆️
macos-latest-3.11 79.96% <99.40%> (+0.71%) ⬆️
macos-latest-3.12 79.96% <99.40%> (+0.71%) ⬆️
macos-latest-3.13 79.96% <99.40%> (+0.71%) ⬆️
ubuntu-latest-3.10 79.91% <99.40%> (+0.71%) ⬆️
ubuntu-latest-3.11 79.96% <99.40%> (+0.71%) ⬆️
ubuntu-latest-3.12 79.96% <99.40%> (+0.71%) ⬆️
ubuntu-latest-3.13 79.96% <99.40%> (+0.71%) ⬆️
windows-latest-3.10 79.91% <99.40%> (+0.71%) ⬆️
windows-latest-3.11 79.96% <99.40%> (+0.71%) ⬆️
windows-latest-3.12 79.96% <99.40%> (+0.71%) ⬆️
windows-latest-3.13 79.96% <99.40%> (+0.71%) ⬆️

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.

…nator

`Tree.interpret` kept the result of its last evaluation on the node, keyed on
`id(interpretation)`, and answered from it. That is not a faster evaluation. It
is a different one. An interpretation may have side effects and may answer
differently on every call. A fitness that averages a noisy measurement is the
ordinary case, and a term that replays its first answer denies it. The attribute
comment said as much: "Breaks for non-deterministic interpretations."

Three ways it went wrong, and none of them was reported anywhere.

  * A combinator with a side effect ran once and then never again, however often
    the term was evaluated.
  * `id(interpretation)` names an address, not contents. A dictionary changed in
    place is the same object with a different meaning for every symbol in it,
    and the old entry still answered for it.
  * The entry sits on a node, and a node is shared by every term built around
    it, so a subterm evaluated in one term reported that result through all of
    them.

Removing it costs what it was there for, and that cost was real. Evaluating a
term asked `inspect.signature` for the parameters of every combinator
*occurrence*, at about 5.4 microseconds each, so a chain of five thousand nodes
over two combinators paid for it five thousand times to learn two answers.

That is the part worth remembering, and it is remembered on its own. A module
memo maps a combinator to its parameters. It holds metadata about a combinator,
never the result of applying one, so every combinator is still called on every
evaluation and the three cases above stay intact.

Measured against the previous state, same machine, same run, with fresh terms
per repetition: one term of 5000 nodes 29.0 ms to 11.8 ms, and two thousand
terms of twenty nodes over seven combinators 215.3 ms to 80.7 ms. Where the old
cache answered instead of evaluating, the new state is slower and has to be. The
same term evaluated a thousand times took 0.3 ms and now takes 41.5 ms, because
a thousand evaluations now happen. Enumerating three thousand terms under
predicates over interpreted subterms goes from 1.0 ms to 1.2 ms. The four
benchmarks are unchanged.

The memo is bounded at 1024 entries, because an algebra is typically built
inside the call that evaluates the term. Every example here does it that way, so
each evaluation produces a fresh set of callables that is used once and
unreachable afterwards. Measured on that pattern over 50000 evaluations, an
unbounded memo grows to 200000 entries and becomes slower than the bounded one,
1434 ms against 1245 ms, because its table keeps being rebuilt. The bound is
twenty times the largest algebra in use, since under a round trip through a
working set an LRU degrades all at once rather than gradually: over 200
combinators, `maxsize=128` runs about ninety times slower than `maxsize=1024`.
Lowering the bound below the working set and removing it both fail a test.

A combinator that cannot key the memo is inspected directly rather than
rejected. One written as a value object, with `__eq__` and therefore no
`__hash__`, worked before and still does. One without an introspectable
signature is still reported as the `TypeError` it always was, on every
evaluation, and never replaced by a value.

The four tests that pinned the old behavior are gone, including the one that
required a combinator with a side effect not to run a second time.
`tests/test_interpretation_semantics.py` states the opposite promise, so the
next attempt to cache results has something to fail against.
@FelixLaarmann
FelixLaarmann force-pushed the bugfix/interpretation-caching branch from ebd9b57 to 3e62e03 Compare August 21, 2026 08:12
@FelixLaarmann
FelixLaarmann requested a review from mrhaandi August 21, 2026 08:21
FelixLaarmann and others added 4 commits August 21, 2026 13:01
``_parameters_cached`` and ``_parameters_of`` took ``Any`` while their docstrings said callable and
``inspect.signature`` requires one. The annotation now says the same thing as the documentation.
``parameters_of_c`` did not say which combinator it described, and it was declared one line above
the ``current_combinator`` it reads from. It is now ``parameters_of_current_combinator`` and
follows that declaration.
The comment above ``@lru_cache`` argued from a fresh algebra to the bound without stating the step
in between. That step is that the cache key is the combinator object itself, and a function or a
lambda hashes by identity, so an algebra rebuilt for every evaluation presents new keys for the
same signatures. Without it the paragraph reads as though the key changed on its own. The comment
now states it, keeps the two arguments the bound carries apart, and says why the bound is 1024 when
a working set is at most 49.

Two places still described the per-node interpretation cache this branch removes, one as a reason
for sharing nodes across a generation and one in the module docstring of the invariant tests. Both
are gone.
@mrhaandi
mrhaandi merged commit 725a2c9 into develop Aug 21, 2026
14 checks passed
@FelixLaarmann
FelixLaarmann deleted the bugfix/interpretation-caching branch August 24, 2026 13:52
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.

CoSy does not cache interpretations of Trees

3 participants