Skip to content

Check predicates of rules without a named non-terminal argument - #96

Merged
mrhaandi merged 1 commit into
developfrom
bugfix/goal-predicates
Aug 20, 2026
Merged

Check predicates of rules without a named non-terminal argument#96
mrhaandi merged 1 commit into
developfrom
bugfix/goal-predicates

Conversation

@FelixLaarmann

Copy link
Copy Markdown
Member

Problem:

The resolution derives terms that contains_tree denies are in the solution space. Three lines
of rules are enough to show it on develop:

from cosy.core.solution_space import ConstantArgument, NonTerminalArgument, SolutionSpace
from cosy.core.tree import Tree

space = SolutionSpace()
space.add_rule("D", "digit", (ConstantArgument("d", 0, None),), (lambda vs: vs["d"] > 0,))
space.add_rule("S", "box", (NonTerminalArgument("x", "D"),), ())

[str(t) for t in space.depth_first_resolution("S")]   # develop: ['box (digit 0)']
[str(t) for t in space.enumerate_trees("S")]          # develop: []
space.contains_tree("S", Tree("box", (Tree("digit", (Tree(0, ()),)),)))   # develop: False

The predicate says the digit must be positive. enumerate_trees obeys it and returns nothing;
contains_tree obeys it and answers False for the only term the resolution produced. The
resolution is the odd one out. breadth_first_resolution answers like depth_first_resolution,
and sample_tree draws from the same stream, so it returns a term its own repository forbids.

Two rule shapes are affected, and both are reachable from the public front end.

A rule whose non-terminal arguments are all unnamed. The synthesizer names the arguments a
component declares with argument(...); the arguments it derives from an arrow inside the suffix
stay unnamed. A component with a constraint and an arrow suffix therefore produces exactly this
shape:

repo = {
    "F": SpecificationBuilder().parameter("n", Digits()).constraint(lambda vs: vs["n"] > 0)
         .suffix(Constructor("x") ** Constructor("d", Var("n"))),
    "G": SpecificationBuilder().suffix(Constructor("x")),
    "H": SpecificationBuilder().parameter("k", Digits()).argument("y", Constructor("d", Var("k")))
         .suffix(Constructor("top")),
}
space = Synthesizer(repo).construct_solution_space(Constructor("top"))

A ground rule below the root: This one needs no arrow at all. It is the repository from the
repo above. At the root the resolution decided such a rule. One combinator on top of it was
enough to bypass the check.

Cause:

Goal stores the predicates of a rule under the tuple of positions of the rule's named
non-terminal arguments, and evaluates them once every one of those positions is grounded:

constraints = ({named: (rhs.predicates, rhs.literal_substitution)} if named else {}) if rhs.predicates else {}

If named is empty there is no key. Storing the predicates under the empty tuple is no
alternative either: the cascade that fires the stored predicates indexes its keys with
ps[0][:-1], which an empty tuple cannot answer. So nothing was stored, and nothing was fired.

That left one special case as the entire check: from_rhs_rule decided a rule directly when it
produced no subgoals at all. It applies only to a ground rule, and only at the root of a
derivation, because it lives in from_rhs_rule and update has no counterpart. A rule with
unnamed holes never reached it, and neither did a ground rule anywhere below the root.

enumerate_trees and contains_tree have no such gap. Both build the substitution for a rule
from its named non-terminal arguments plus its literal substitution -- if there are no named
arguments, that is the literal substitution -- and both apply the predicates to it.

What changes:

Decide these predicates where the rule is applied, in both places, on rhs.literal_substitution:

if rhs.predicates and not named and not all(c(rhs.literal_substitution) for c in rhs.predicates):
    return None

That is the whole substitution such a predicate can see, and the file says so itself. From the
docstring of resolution:

An unnamed argument still corresponds to NT_i(X_i) in the logic program, but it cannot be used
in the predicates [...] The predicates [...] are applied to the specific substitution of the
rule, which is given by the constant arguments and the non-terminals arguments that have a name.

A rule without a named non-terminal argument therefore has a substitution consisting of its
constant arguments alone, which is exactly literal_substitution. Its predicates are decidable as
soon as the rule is formed, and both enumerate_trees and contains_tree already decide them
that way.

The change also drops the substitution the old root check ran on,
dict(grounded.values()) | rhs.literal_substitution. That is the same dict as
rhs.literal_substitution alone: at that point grounded has been filled from ConstantArguments
only, so it has the same key set as literal_substitution, and the union lets the literal
substitution win every key. Checked over 1885 systematically generated rule shapes (0-3 arguments,
duplicate constant names included): no difference, same key order, and no Tree in either result.
The order matters and is now fixed by construction: written the other way round, a predicate would
receive Tree(value, ()) for a constant name, whereas enumerate_trees (specific_substitution ... | rule.literal_substitution) and contains_tree (child.root for a ConstantArgument) hand
it the raw value. The existing tests rely on that: the predicate in
tests/test_overlapping_substitution_inference.py compares vs["n"] against 42, which a Tree
would never equal.

from_rhs_rule now also documents when it returns None, in the wording its sibling update
already uses.

The predicates of a rule are stored under the tuple of positions of its named
non-terminal arguments, and the goal cascade evaluates them once all of those
positions are grounded. A rule without a named non-terminal argument has no such
tuple. Storing its predicates under the empty tuple is no alternative either,
because the cascade indexes its keys with `ps[0][:-1]`. Nothing was stored, so
nothing was checked: `from_rhs_rule` decided a ground rule directly, but only at
the root of a derivation, and `update` had no such case at all.

Two rule shapes were therefore applied unchecked by the resolution:

  * a rule whose non-terminal arguments are all unnamed -- the shape a component
    whose suffix is an arrow type produces -- at every position, and
  * a ground rule at every position below the root.

`enumerate_trees` and `contains_tree` evaluate the predicates of such a rule on
its literal substitution and reject the resulting terms. The resolution was thus
the only one of the three procedures that answered differently, and it derived
terms that `contains_tree` denied were in the solution space.

Decide these predicates where the rule is applied, on the literal substitution
of the rule. That is the whole substitution they can see: as the docstring of
`resolution` states, the predicates of a rule are applied to the substitution
given by its constant arguments and its named non-terminal arguments, and an
unnamed argument carries no name a predicate could read. A rule without a named
non-terminal argument is decidable as soon as it is formed.

The substitution the ground-rule check at the root used to run on,
`dict(grounded.values()) | rhs.literal_substitution`, is the same dict as
`rhs.literal_substitution` alone: at that point `grounded` holds exactly the
constant arguments of the rule, so the union lets the literal substitution win
every key it contributes. Had it not, a predicate would receive a `Tree` for a
constant name, where `enumerate_trees` and `contains_tree` pass the raw value.

Rules whose predicates read a derived subterm are untouched. Such a predicate
reads the name of a named non-terminal argument, and a rule that has one keeps
taking the existing storage path.

One consequence is worth spelling out. A recursive repository whose only base
rule is forbidden by its predicate has no term at all, and the depth-first
search now says so by not terminating, where before it answered with a term the
repository forbids. That is what a semi-decidable search does on a query without
an answer; `max_depth` bounds it, and `enumerate_trees` terminates on the same
repository because it works bottom-up. `prune` does not help here: it derives
productivity from the shape of a rule alone, so a rule its predicate forbids
still counts as a base case.

The new tests cover both shapes at the root and below it, with one and with two
predicates per rule, built by hand and once through a specification whose suffix
is an arrow type.
@FelixLaarmann
FelixLaarmann requested a review from mrhaandi August 20, 2026 07:50

@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: b10de34 Previous: be71b5d Ratio
benchmarks/test_benchmark_maximal_elements.py::test_benchmark_maximal_elements 9.30736814895611 iter/sec (stddev: 0.002276356541883544) 9.608281351562356 iter/sec (stddev: 0.010017085420616013) 1.03
benchmarks/test_benchmark_maze.py::test_benchmark_maze 3.925493739416904 iter/sec (stddev: 0.020397060989096376) 3.9235166334572096 iter/sec (stddev: 0.018797551923491352) 1.00
benchmarks/test_benchmark_maze_contains.py::test_benchmark_maze_contains 3.7656390037995258 iter/sec (stddev: 0.012796793585081111) 3.5391214334611916 iter/sec (stddev: 0.025877608823890046) 0.94
benchmarks/test_benchmark_maze_loopfree.py::test_benchmark_maze_loopfree 3.9440951552460803 iter/sec (stddev: 0.011750655478563006) 3.8477221717914887 iter/sec (stddev: 0.02072136799063595) 0.98

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

@codecov-commenter

codecov-commenter commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.38%. Comparing base (2e318a2) to head (b10de34).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop      #96      +/-   ##
===========================================
+ Coverage    73.02%   74.38%   +1.35%     
===========================================
  Files           39       40       +1     
  Lines         2944     3080     +136     
  Branches       494      502       +8     
===========================================
+ Hits          2150     2291     +141     
+ Misses         686      683       -3     
+ Partials       108      106       -2     
Flag Coverage Δ
macos-latest-3.10 74.28% <100.00%> (+1.45%) ⬆️
macos-latest-3.11 74.35% <100.00%> (+1.35%) ⬆️
macos-latest-3.12 74.35% <100.00%> (+1.45%) ⬆️
macos-latest-3.13 74.28% <100.00%> (+1.28%) ⬆️
ubuntu-latest-3.10 74.28% <100.00%> (+1.35%) ⬆️
ubuntu-latest-3.11 74.35% <100.00%> (+1.45%) ⬆️
ubuntu-latest-3.12 74.35% <100.00%> (+1.35%) ⬆️
ubuntu-latest-3.13 74.35% <100.00%> (+1.45%) ⬆️
windows-latest-3.10 74.28% <100.00%> (+1.56%) ⬆️
windows-latest-3.11 74.28% <100.00%> (+1.28%) ⬆️
windows-latest-3.12 74.35% <100.00%> (+1.35%) ⬆️
windows-latest-3.13 74.35% <100.00%> (+1.45%) ⬆️

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 bff5415 into develop Aug 20, 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