Add OR (disjunction) to rule bodies - #7
Conversation
…(Strategy C)
Add `(OR (branch) (branch) ...)` to rule bodies, compiled to a single backend
rule with a fused union node in the core-relations free-join engine — the
surrounding conjunction is scanned once and the branches are enumerated
additively (no rule-splitting / no cartesian product of rules).
Backend: a new `JoinStage::Union { branches }` (core-relations/src/free_join/plan.rs,
executed in execute.rs) forms block 0 of a DecomposedPlan; each branch is a
self-contained sub-plan projecting onto the OR's common variables, written into
one materialization keyed on those variables (deduplicated in memory). The result
block is the surrounding conjunction, planned via the existing tree-decomposition
message-passing (`FusedIntersectMat`) so it joins that materialization by index
and fires the action. `Query.union` / `QueryBuilder::set_union` carry the union;
`egglog-bridge` `RuleBuilder::set_union_branches` bridges it.
Frontend: `OR` is a real AST fact (`GenericFact::Or`); typechecking resolves each
branch (branch-local vars renamed to fresh names), enforces that only variables
common to every branch cross the OR boundary, and binds those common variables
for the actions.
Restrictions: OR rules run in naive mode (no seminaive delta through a union);
branch atoms must be tables (primitives allowed in the surrounding conjunction);
OR is rejected under proofs / term encoding.
- egglog-ast, src/ast/parse.rs: `GenericFact::Or` + parsing
- src/typechecking.rs, src/core.rs, src/lib.rs: typecheck + wire OR to the backend
- core-relations: `JoinStage::Union`, `plan_union`, execution
- egglog-bridge: union plumbing
- tests/disjunction.rs (14 tests), docs/disjunction-design.md, CHANGELOG.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the OR disjunction implementation (Strategy C's fused union node) to support correlated branches and seminaive evaluation: - Typecheck: relax the interface rule so a branch may reference variables bound by the surrounding conjunction (correlated branches); only true branch-locals are renamed/rejected. - Parser: accept the uppercase OR spelling and bare-fact disjuncts (e.g. (OR (= a d) ...)) in addition to C's list-of-facts branches. - add_or_rule: dispatch to Strategy C's fused union node for naive, self-groundable branches, or to a new splitting path (one ordinary backend rule per disjunct, sharing the action) for correlated or seminaive ORs. Split rules are plain conjunctive rules, so per-disjunct canonicalization turns a correlated equality into a shared-variable index-probe join (no cartesian product), and seminaive / :unsafe-seminaive work natively. - Term encoding: allow OR without proofs (proof_form recurses into branches; instrument_fact rewrites branch atoms to view lookups and re-emits an or fact); still rejected with proofs enabled. - Tests: add a rebuild-shaped correlated :unsafe-seminaive test (with a (!= d e) primitive beside the OR and a function lookup in the action) and a correlated naive test; C's 14 independent-union tests still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the rule-splitting compilation of correlated/seminaive OR with C's fused, deduplicating union node, so a row matched via several branches is processed exactly once (disjunctive-semijoin single-rebuild), not once per matching branch. - add_or_rule now always compiles to one backend rule with the fused union node. A correlated OR (a disjunct references a surrounding-conjunction var not common to all disjuncts, e.g. (v a b c) outside and (stale a al) inside) prepends the conjunction into every branch, so branches bind and deduplicate on the shared output row; an independent OR keeps the conjunction as a scanned-once continuation. Output/dedup vars exclude Unit-sorted vars (fixes a term-encoding view-lookup unit result leaking into the dedup key). - Removes the split path (add_or_rule_split / branch_self_groundable). - Runs naive: the seminaive delta machinery can't reach union branch atoms; correct at a fixpoint (a rebuild that deletes the stale row converges), but not incremental. Documented as future work. - Tests: or_correlated_dedup_single_rebuild asserts a row stale in TWO columns fires the action exactly once (dedup); or_correlated_rebuild_to_fixpoint runs the full delete+rebuild with a leader-function lookup to a fixpoint. C's 14 independent-union tests unchanged. Correlated OR also verified under term encoding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A correlated OR now runs seminaive instead of naive: a new tuple in a branch atom drives an index probe of the row, not an O(N) per-iteration re-scan, while the deduplicating materialization keeps the shared action firing once per row. Semi-naive of a union of conjunctions is the union of the per-disjunct expansions: seminaive(OR_i B_i) = union_i seminaive(B_i). Because a correlated OR prepends the surrounding conjunction into every branch, each branch B_i holds all of O union B_i, so its delta expansion is self-contained (constraints never cross branch boundaries). - egglog-bridge add_rules_from_cached: union-aware. For each branch, generate the standard per-atom focus/old timestamp variants over that branch's atoms only, and call the new RuleSetBuilder::add_union_rule_from_cached. - core-relations add_union_rule_from_cached: builds one variant of the cached union plan whose JoinStage::Union holds one variant-branch per variant (the cached branch narrowed by the variant's ts constraints, applied as extra JoinHeaders via reprocess_union_branch). All variant-branches feed the single dedup materialization + shared action. The branch delta constraint reaches the branch sub-plan as a header the execute Union handler already intersects in. - src/lib.rs: correlated OR rules build the backend rule with seminaive=true; independent OR rules keep the scanned-once continuation and run naive. Test: or_correlated_seminaive_delta_driven proves it — the fixpoint fires once per stale row (=1, which naive re-firing would blow past), and adding ONE new stale edge then running one step fires only for the two newly-affected rows (1 -> 3), independent of table size. 17 disjunction + 747 file tests pass; nits clean; correlated OR still works under term encoding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Benchmark: fused-OR rebuild wins
Fused is 1.08× / 7.4% faster than split, and 20% faster than current (vs split's 11%). σ ≤ 0.07s. The win over split is the multi-stale-row dedup: a row whose args both changed is rebuilt once, not once per column. Rebuild-ruleset search time drops 8.66s → 5.51s (~36%); the added Correctness: current / split / fused all produce a byte-identical Benchmark harness + env-gated generator modes are on branch |
Why fused beats split — rebuild-action firing counts (deterministic)
Split does 19.5% more rebuild firings than fused: a row stale in k columns rebuilds k times under split, once under fused (dedup on the row). Fused's firing count matches current's (both per-row), but fused is delta-driven, so it has the lowest rebuild-search time of the three. The staleness-maintenance rule costs ~1.05M monotone inserts (+slightly higher merge), more than offset by the search savings. So fused's win decomposes cleanly: delta-driven (like split) gets it from current's ~9s to ~7s of rebuild search; per-row dedup (unlike split) gets it the rest of the way to ~5.5s and removes split's extra 275k firings. Full four-way report and harness on branch |
Builds on #6 (Strategy C's fused union node) and adds the two things the rebuilding use case needs: correlated branches and a seminaive / delta-driven union.
What's new over C (#6)
ORif it's bound by the surrounding conjunction or common to every branch; branch-locals are renamed and may not escape.seminaive(⋁ᵢ (O ∧ Bᵢ)) = ⋃ᵢ seminaive(O ∧ Bᵢ),add_rules_from_cached(egglog-bridge) is now union-aware: for each disjunct it emits the standard per-atom delta variants over that disjunct's atoms only (never across branches), and each variant's branch delta is carried as extraJoinHeaders reprocessed onto that branch's sub-plan (core-relations). All variants feed the one deduping materialization + single shared action.Net: a new tuple in a branch atom index-probes only the affected rows (no re-scan), and dedup on the output row keeps a row changed in multiple columns to a single action firing.
The motivating rule (efficient e-graph rebuilding)
Verification
tests/disjunction.rs: 17/17, including:or_correlated_dedup_single_rebuild— a row stale in two columns fires the action once (dedup semijoin).or_correlated_seminaive_delta_driven— after fixpointnfire=1(not re-fired per iteration ⇒ not naive); adding one newstaleedge takesnfire 1→3, i.e. only the two newly-affected rows, independent of table size ⇒ delta-driven index probe, not O(N)..eggharness: 747/747;make nitsclean.Restrictions (documented)
ORs (branches share no outer var) still run naive; only correlatedORs are delta-driven so far.!=is rejected with a clean error; use a staleness relation, as above).ORworks under term encoding but is rejected under full proof mode.Performance
A four-way benchmark (current vs split vs fused-OR) on
math-microbenchmarkunder term encoding is running; I'll post the numbers here. Hypothesis: fused-OR matches split's delta-driven win and cuts split's redundant rebuilds of multi-stale rows (fewer action firings).🤖 Generated with Claude Code