Skip to content

Disjunction (OR): Strategy C — fused union node in the free-join engine - #6

Closed
oflatt-claude wants to merge 1 commit into
mainfrom
disjunction-fused-union-node
Closed

Disjunction (OR): Strategy C — fused union node in the free-join engine#6
oflatt-claude wants to merge 1 commit into
mainfrom
disjunction-fused-union-node

Conversation

@oflatt-claude

Copy link
Copy Markdown
Owner

Implements OR disjunction as Strategy C: a fused union node in the core-relations free-join engine. Companion to #5 (Strategy B).

(rule ((OR ((edge x y))
           ((edge y x))))       ; symmetric closure
      ((connected x y)))

How it works

OR compiles to a single backend rule (no rule-splitting, no cartesian product of rules). A new JoinStage::Union { branches } (core-relations/src/free_join/plan.rs, executed in execute.rs) is block 0 of a DecomposedPlan:

  • Each branch is a self-contained sub-plan over its own atoms, projecting onto the OR's common variables.
  • All branches write into one materialization keyed on the common variables (deduplicated in memory) via the existing InPlaceMaterializer.
  • The result block is the surrounding conjunction C, planned by the ordinary plan_single_bag, which auto-generates a FusedIntersectMat prologue that iterates the distinct union tuples and probes C's atoms by index — so C is scanned once and joined against the union, never re-scanned per branch.

Query.union / QueryBuilder::set_union carry the union to the planner; egglog-bridge's RuleBuilder::set_union_branches bridges it. Frontend: GenericFact::Or + typecheck resolves each branch (branch-local vars renamed fresh), enforces the common-variable interface rule, and binds the common vars for actions.

Tests

  • tests/disjunction.rs: 14/14 (incl. a branch with an internal join on a branch-local var, output-var-in-continuation, multi/nested OR, rewrite :when, union in actions, error cases)
  • full .egg harness: 747/747
  • make nits: clean

Restrictions

  • Naive evaluation (an OR rule opts out of seminaive — no delta through a union).
  • Branch atoms must be tables (primitives are fine in the surrounding conjunction).
  • Rejected under proofs / term encoding.

B vs C — honest comparison

Both #5 (B) and this (C) end up materializing the union's output tuples keyed on the common variables and joining the surrounding conjunction against that materialization. Materializing is essentially inherent here: to index-join C against the union you need the union's tuples in an indexable form. So this is not a "materialized vs zero-materialization streaming" contrast.

They differ in plan structure / integration:

  • B (Disjunction (OR): Strategy B — materialized union in the query planner #5): a bespoke top-level Plan::UnionPlan with its own serial+parallel executor (run_union_plan_serial, dedup_union_mat) and union bags as a leading phase.
  • C (this): a JoinStage::Union folded into the existing DecomposedPlan, reusing the tree-decomposition materializer and FusedIntersectMat for the continuation — fewer new execution paths, more reuse of existing machinery.

A truly zero-materialization streaming union (stream each branch match straight into the continuation) would re-probe C per branch tuple; since egglog does not dedup action firings for ordinary rules anyway, that is essentially the work rule-splitting does — which is why both strategies materialize the deduped union instead.

🤖 Generated with Claude Code

…(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>
@oflatt-claude

Copy link
Copy Markdown
Owner Author

Superseded by #7, which builds on this fused union node and adds correlated branches + seminaive-through-union (the version needed for efficient rebuilding).

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.

2 participants