Aggregates: counting inside a join
Area: Rules, aggregates & recursion Teaches: a
count { B }whose bodyBis a join — a relation atom together with a type-membership atom (p in Person) — so the count ranges only over partners that satisfy both. The membership filter and the relation join are the same body; reordering them must not change the count. Prerequisites: aggregates (count/exists), and first-class relations. Run:ox build examples/aggregate_membership_join && ox derive examples/aggregate_membership_join <predicate>
count { Person(p) } counts a population; count { R(a, p) } counts edges. The interesting case is the conjunction — count the R-partners of a that are also Persons — because the membership atom and the relation atom must be solved together, and a bug that drops the membership test silently under-joins to zero.
What to read in root.ar
a0 relates via R to three things, but only two of them (p1, p2) are Persons — p3 is a plain Thing:
pub fact R(a0, p1); // p1 in Person
pub fact R(a0, p2); // p2 in Person
pub fact R(a0, p3); // p3 in Thing, NOT Person
Relation-only count ranges over all three R-partners:
pub derive rel_count_is_three(a) :- Thing(a), count { R(a, p) } >= 3;
Count inside a join intersects the relation with type membership, so only the Person-typed partners count — two, not three:
pub derive member_count_at_least_two(a) :- Thing(a), count { p in Person, R(a, p) } >= 2;
pub derive member_count_at_least_three(a) :- Thing(a), count { p in Person, R(a, p) } >= 3;
Order-independence — swapping the two body atoms is the same join, so the same count of 2:
pub derive member_count_reordered_two(a) :- Thing(a), count { R(a, p), p in Person } >= 2;
Running it
ox derive <predicate> prints the head’s tuples; each firing head returns the bound a (here the individual a0):
derive(rel_count_is_three): 1 tuple(s) (a0) — count{R(a,p)} = 3, so >= 3 holds
derive(member_count_at_least_two): 1 tuple(s) (a0) — count{p in Person, R(a,p)} = 2, so >= 2 holds
derive(member_count_at_least_three): 0 tuple(s) — that count is 2, so >= 3 fails
derive(member_count_reordered_two): 1 tuple(s) (a0) — reordered body, same count of 2
The decisive contrast is rel_count_is_three vs member_count_at_least_three: both ask >= 3 of a count over a0’s R-partners, but the membership atom drops p3 (a non-Person), so the count falls from 3 to 2 and the second head does not fire. The member_count_reordered_two head proves the count is independent of atom order — both arrangements solve the same join.
Honest caveats (what runs today)
- The join inside the aggregate is the ordinary conjunctive body:
p in Personis a type-membership atom andR(a, p)is a relation atom, solved together over the shared variablep. - The count ranges over distinct solution bindings of the body, evaluated against the current state.
This example is compiled and run in CI; its join-count behaviour is pinned by a corpus test (oxc-runtime/tests/examples_corpus.rs::corpus_aggregate_membership_join_count), so the membership-inside-aggregate semantics can’t regress to the silent under-count it was written to guard against.