Skip to content

Count the inhabitants a search node reaches, from the tree and from the program - #106

Merged
mrhaandi merged 1 commit into
developfrom
feature/branch-counting
Aug 27, 2026
Merged

Count the inhabitants a search node reaches, from the tree and from the program#106
mrhaandi merged 1 commit into
developfrom
feature/branch-counting

Conversation

@FelixLaarmann

Copy link
Copy Markdown
Member

Problem:

Random search is best-first search under a randomizing cost function, and two things are asked of
it: every prefix of its stream is a sample from a chosen distribution, and it draws without
replacement. A search that picks clauses uniformly delivers neither. The uniform choice ignores
how many inhabitants lie below each child, so it concentrates the sample on the terms the grammar
reaches quickly and never reaches the long ones, whatever distribution was intended. Weighting the
choice needs a count per child and per cost value, and nothing in the package produces one.

Changes:

A new module cosy.search.counting, with ten of its names re-exported from the package. It is
additive: nothing but the package __init__ imports it and no behavior changes.

branch_counts materializes the retained derivation tree of a query and counts its success
branches per cost value. It assumes nothing about the program and takes any computable cost
function. The recursion is iterative and carries the size of each partial inhabitant along, so a
node that already exceeds the bound is cut without being measured again. The bound on term size is
what makes the tree finite, on a recursive space as well, because every expansion fixes at least
one function symbol that every completion below it shares.

size_table computes the same numbers from the program instead: one row N_A(s) per non-terminal
and size, filled by increasing s. Every clause writes at least its terminal, so row s depends
only on rows below it and one pass suffices. The fill walks the sizes a non-terminal actually
occupies rather than every size below the total, and it stops at the largest term the program has
rather than at the bound. A clause of three holes or more is convolved through a row for its tail,
so the memory stays in the size of the program rather than in the number of distinct hole tuples.
This is what makes a generous bound affordable: on the list space the tree form reaches a bound of
12 in 46 s holding 531 440 nodes, while the table answers a bound of 400 in 0.2 ms and agrees with
the closed form 3^(s-1) throughout.

decomposable_or_raise decides the condition under which the second construction is valid. A
table indexed by the non-terminal cannot see a predicate that reads a hole, since such a predicate
makes the residual at that hole a proper subset of the hole's language and the table then counts
terms the space does not contain. The check names the offending clauses and their argument
positions rather than returning numbers that are quietly too large, and there is no fallback: a
caller who hits it uses the tree form, which needs no hypothesis. It is necessarily conservative,
because a predicate is an opaque callable and which variables it reads is recorded nowhere, so a
clause counts as offending as soon as it carries a predicate and a named non-terminal argument. A
predicate over the literals of its clause alone is not reported, and the fill drops the clauses it
rejects exactly as Goal.from_rhs_rule and Goal.update do.

assert_unambiguous_within decides the second condition, the one under which the counts at the
root are the number of inhabitants rather than the number of derivations. It costs a full
traversal of the retained tree, so it is a validation tool and no counting path runs it.
branch_multiplicities reports the witnesses it names.

A hole whose sort the program never derives counts as empty rather than raising.
construct_solution_space does not prune, so a clause whose argument type turned out uninhabited
survives and names a sort that never becomes a head, and both constructions read such a hole the
same way. A filled table is shared between readers, so its rows and the mappings holding them are
read-only: rebinding a row would leave the convolution cache answering from the numbers that were
there before.

The reference spaces in tests/search_fixtures.py grow by the shapes the counts have to be right
on, and the membership oracle they are checked against moves to tests/_generate_and_check.py,
beside them. It builds every term over a signature up to the bound and asks contains_tree which
of them the space contains, so it shares no machinery with the recursion it validates. The space
whose clauses write two symbols each is named two_symbol_clause_space rather than after its
literals, since literal_space already names the space with a constant argument beside a
non-terminal one.

…he program

A search that is meant to draw from a chosen distribution cannot pick clauses uniformly. The
uniform choice ignores how many inhabitants lie below each child, so it concentrates the sample
on the terms the grammar reaches quickly and never reaches the long ones, whatever distribution
was intended. Weighting the choice needs a count per child, and the package had no way to
produce one.

cosy.search.counting supplies that count in two constructions, which arrive at the same numbers
and differ in what they cost and what they assume.

- branch_counts materializes the retained derivation tree and counts its success branches per
  cost value. It needs no hypothesis about the program and it costs the number of inhabitants,
  since the counts have to be complete before anything can be drawn. The recursion is iterative
  and carries the size of each partial inhabitant along, so a node whose partial inhabitant
  already exceeds the bound is cut without being measured again. The bound on term size is what
  makes the retained tree finite, on a recursive space as well, because every expansion fixes at
  least one function symbol that every completion below it shares.
- size_table computes the same numbers from the program: one row N_A(s) per non-terminal and
  size, filled by increasing s, at a cost in the size of the program and the bound alone. Every
  clause writes at least its terminal, so row s depends only on rows below it and one pass by
  increasing size suffices. The fill walks the sizes a non-terminal actually occupies rather than
  every size below the total, and it stops at the largest term the program has rather than at the
  bound, which is exact on an acyclic program and saturates at the bound on a recursive one. A
  clause of three holes or more is convolved through suffix rows, so the memory stays in the size
  of the program rather than in the number of distinct hole tuples.

The table is offered with its condition and never without it. It is indexed by the non-terminal,
so it cannot see a predicate that reads a hole: such a predicate makes the residual at that hole
a proper subset of the hole's language, and the table then counts terms the space does not
contain. decomposable_or_raise decides this and names the offending clauses and their argument
positions rather than returning numbers that are quietly too large. The condition is necessarily
conservative, because a predicate is an opaque callable and which variables it reads is recorded
nowhere, so a clause counts as offending as soon as it carries a predicate and a named
non-terminal argument. A predicate over the literals of its clause alone is not reported, and the
fill drops the clauses it rejects, exactly as the engine does.

assert_unambiguous_within decides the hypothesis under which the branch counts at the root are
the number of inhabitants rather than the number of derivations. It costs a full traversal, so it
is a validation tool and no counting path runs it.

The tests check both constructions against exhaustive enumeration rather than against each other.
The oracle builds every term over a signature up to the bound and asks contains_tree which of
them the space contains, so it shares no machinery with the recursion it validates; it lives in
tests/_generate_and_check.py, next to the reference spaces it enumerates. The reference spaces
grow by the shapes the counts have to be right on: a space with a closed form, an ambiguous one
that derives one term twice, a space whose clauses write two symbols each, and one space per
shape of predicate that decides whether the table applies.

The space whose clauses write two symbols is named two_symbol_clause_space rather than after its
literals, since literal_space already names the space with a constant argument beside a
non-terminal one.

Three things the table form got wrong, found while testing it and fixed here rather than carried
in.

- A hole whose sort the program never derives raised a KeyError instead of counting as empty. The
  synthesis does not prune, so a clause whose argument type turned out uninhabited survives and
  names a sort that never becomes a head. The tree form already reads such a hole as empty, and the
  fill carries the same guard one step further on, but the pass that decides where to stop filling
  ran before it and subscripted the reach of a sort it had never seen.
- A stored tail row stopped at the largest term any *sort* has, which is short of what the tail
  itself reaches whenever the clause that splits into it derives nothing: that clause is what would
  carry the tail's reach up to its head. The table then answered two different numbers for one
  convolution, depending on whether the tuple happened to be a stored tail, and a caller filling
  holes from the left asks for exactly the stored ones. The stopping point is now the reach of the
  rows that are kept, sorts and tails alike.
- A filled table is shared between readers and its rows refuse assignment, but the mapping holding
  them did not, so one reader could rebind a whole row under its sort and leave the convolution
  cache answering from the numbers that were there before. Both mappings are read-only views now.

A suffix is only ever a tail and never the head of a split, so the occupancy list that was kept
for each one was written every round and never read. It is gone.

Two reference spaces are here because a mutation run showed the suite could not tell the fill from
a broken one without them. `ternary_space` has a clause of three holes, which is the only shape
whose convolution needs a row for the tail of the clause: with every space having at most two
holes, that whole path ran under the suite without a single test reaching it. `mixed_arity_space`
writes into one size from two clauses at once, which is what says that a size is recorded as
occupied once per row rather than once per clause. Both are checked against closed forms, the
ternary Catalan numbers and a count this file computes by hand.
@FelixLaarmann
FelixLaarmann requested a review from mrhaandi August 26, 2026 05:11

@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: 531b379 Previous: be71b5d Ratio
benchmarks/test_benchmark_maximal_elements.py::test_benchmark_maximal_elements 9.88421579211616 iter/sec (stddev: 0.0005093223282924836) 9.608281351562356 iter/sec (stddev: 0.010017085420616013) 0.97
benchmarks/test_benchmark_maze.py::test_benchmark_maze 4.192933768470181 iter/sec (stddev: 0.017375647640240622) 3.9235166334572096 iter/sec (stddev: 0.018797551923491352) 0.94
benchmarks/test_benchmark_maze_contains.py::test_benchmark_maze_contains 3.8954035895526973 iter/sec (stddev: 0.014869210471046624) 3.5391214334611916 iter/sec (stddev: 0.025877608823890046) 0.91
benchmarks/test_benchmark_maze_loopfree.py::test_benchmark_maze_loopfree 4.180229054062536 iter/sec (stddev: 0.012761972443776212) 3.8477221717914887 iter/sec (stddev: 0.02072136799063595) 0.92

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

@codecov-commenter

codecov-commenter commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.23717% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.02%. Comparing base (da0aa34) to head (531b379).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
tests/search_fixtures.py 76.31% 27 Missing ⚠️
src/cosy/search/counting.py 98.98% 0 Missing and 3 partials ⚠️
tests/test_table_form.py 99.20% 2 Missing ⚠️
tests/test_branch_counts.py 99.43% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #106      +/-   ##
===========================================
+ Coverage    84.25%   86.02%   +1.77%     
===========================================
  Files           56       60       +4     
  Lines         5056     5933     +877     
  Branches       616      718     +102     
===========================================
+ Hits          4260     5104     +844     
- Misses         699      729      +30     
- Partials        97      100       +3     
Flag Coverage Δ
macos-latest-3.10 85.97% <96.23%> (+1.77%) ⬆️
macos-latest-3.11 86.01% <96.23%> (+1.77%) ⬆️
macos-latest-3.12 86.01% <96.23%> (+1.77%) ⬆️
macos-latest-3.13 86.01% <96.23%> (+1.77%) ⬆️
ubuntu-latest-3.10 85.97% <96.23%> (+1.77%) ⬆️
ubuntu-latest-3.11 86.01% <96.23%> (+1.77%) ⬆️
ubuntu-latest-3.12 86.01% <96.23%> (+1.77%) ⬆️
ubuntu-latest-3.13 86.01% <96.23%> (+1.77%) ⬆️
windows-latest-3.10 85.97% <96.23%> (+1.77%) ⬆️
windows-latest-3.11 86.01% <96.23%> (+1.77%) ⬆️
windows-latest-3.12 86.01% <96.23%> (+1.77%) ⬆️
windows-latest-3.13 86.01% <96.23%> (+1.77%) ⬆️

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.

@mrhaandi
mrhaandi merged commit b08f1af into develop Aug 27, 2026
15 checks passed
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