8000 feat(engine): or and or-join on the streaming engine (#546 PR 2) by adityamukho · Pull Request #626 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

feat(engine): or and or-join on the streaming engine (#546 PR 2) - #626

Merged
adityamukho merged 9 commits into
v3from
feat/546-or
Oct 19, 2026
Merged

adityamukho merged 9 commits into
v3from
feat/546-or

Conversation

@adityamukho
@adityamukho adityamukho commented Oct 19, 2026 •
Copy link
Copy Markdown
Collaborator

PR 2 of 2 for #546 (streaming engine part 4 of 9, #473): or and or-join. Spec: docs/superpowers/specs/2026-10-16-negation-disjunction-engine-design.md §3.5, §3.6 item 3, §5. Plan: docs/superpowers/plans/2026-10-18-546-negation-disjunction.md, steps 10–15 and decision 11, which this PR adds. This does not close #473; #546 is closed by hand after merge.

Executor: one answer on every or path (breaking)

apply_or_clauses has two paths. The joined path evaluates each branch alone and joins its rows to the outer rows. The per-row path starts each branch from the outer row and is taken when a branch has a not, reads outer metadata, or has an expression over an outer variable. Their answers differed. All of the following now give the per-row (spec) answer:

  1. Join equality (spec §3.6 item 3, §5.3). The joined path keyed rows on exact Values, so a keyword never met a ref to the entity it names. With [:alice :friend :bob] [:bob :name "B"], [:find ?e ?n :where [?e :friend ?f] (or [?f :name ?n] [?f :alias ?n])] returned nothing; it now returns :alice "B". Dedup keys stay exact (§5.2).
  2. Branches naming different outer variables. Every branch row was keyed on the outer variables any branch names, so (or [?e :a ?x] [?f :b ?x]) matched nothing. Each row now meets the outer row on the variables it binds itself.
  3. An or-join branch that does not bind every join variable lost its rows on the joined path. It now takes the per-row path, so (or-join [?e] [?x :a 1] [?e :b 2]) keeps every outer row when some entity has :a 1.
  4. :db/valid-at in a branch read the wall clock when the query had no :valid-at. It now reads the query's "now" (branch_valid_at), as the rule path already did.

Items 2–4 are not in the spec. I found 2 and 3 while comparing the two paths for this plan, and the oracle found 4. Recorded in plan decision 11.

Engine: Union and Distinct (spec §5)

  • Placement. or / or-join run after the clauses optimizer::plan places, in text order, before the deferred nots, as on the executor. A query whose first clause to run is an or starts from the empty row (Seed now starts with one).
  • Branches. Each branch is planned by the same code as a not body (Builder::sub): hashed when closed (read once into a table of its new cells by JoinKeys of the imported variables), otherwise correlated (per outer row from a Seed). A branch's nested nots are placed or deferred as in a query. closed now also requires a nested not to read only imported variables that the branch's patterns bind.
  • Union extends each outer row by every row of every branch, BATCH_ROWS at a time. When the branches bind nothing new (every or-join, and an or that only filters), it returns an outer row once, from the first branch with a row.
  • Distinct keeps one row per set of equal rows over every variable, as the executor's Value keys compare: 0.0 == -0.0, a keyword never equals a ref, and a row holding NaN is never a duplicate.
  • Still on the executor (decision 11): a branch with a rule invocation or a nested or; a branch predicate the registry may lack (the executor reports QRY-007 only when a row reaches it); an or whose branches bind their own sources' hidden metadata variables; and a branch whose not reads a variable only the branch's expression binds.

Measurements (#394 envelope, bench machine, base v3 c501f54 vs this PR, two rounds with base and PR on different NUMA nodes, swapped between rounds)

or_2 = [:find ?e :where [?e :group 7] [?e :age ?a] (or-join [?a] (and [?f :group 17] [?f :age ?a] [(< ?a 30)]) (and [?g :group 27] [?g :age ?a] [(> ?a 60)]))] (both branches hashed). p50 of rounds 1 / 2, and peak RSS (round 1):

Scale v3 this PR RSS v3 → PR
100K 111 / 104 ms 8.33 / 8.47 ms 38 → 6 MiB
1M 1.44 / 1.45 s 79.9 / 79.1 ms 306 → 7 MiB
10M 17.1 / 17.3 s 883 / 835 ms 1922 → 12 MiB

Every other workload is unchanged within noise at all three scales. Sub-100 µs shapes flip between about 20 and 50 µs on both builds, as in #602. recursive_ancestors at 10M is capped on both.

Tests

1588 tests (1578 passing, 10 ignored), up from 1578.

  • tests/or_equality_test.rs (5 tests): the changed answers, through execute and a cursor, in memory and after a checkpoint.
  • Engine unit tests (5):
    • 24 disjunction shapes over the engine test history, each with each branch form forced and reordering on and off, compared against the executor at six temporal settings, in memory and on file.
    • Routing, the branch form the planner picks, Distinct, and that a hashed branch is read once and a correlated one runs only for rows earlier branches did not match.
  • tests/plan_independence_test.rs: the matrix gains 15 disjunction shapes, run with branches chosen, forced hashed and forced correlated.
  • The property test generates or / or-join over bound variables; its reference evaluator deduplicates the rows they keep.

I checked that the tests catch a broken operator. A Distinct that keeps duplicates, and hashed Union keys without keyword/ref normalisation, each fail the oracle test. A Union that drops branch rows fails the property test.

Found along the way, not fixed here: #625. An expression that reads a variable only an or binds drops every row, on both paths and on v2.0.7.

Docs site: project-minigraf/minigraf-docs#24

Codex review (read-only, whole diff against the spec and plan): no findings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PtCdzbJMSS1V8qEPaJ4jXz

adityamukho and others added 9 commits October 19, 2026 11:50
apply_or_clauses' unseeded paths keyed branch rows on exact Values, so a
keyword never met a ref to the entity it names, while the seeded path
(a branch with not) matched them. Key on normalize_join_value in both;
dedup keys stay exact (spec §3.6 item 3, §5.3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtCdzbJMSS1V8qEPaJ4jXz
The unseeded path keyed every branch row on the outer variables any branch
names, so with (or [?e :a ?x] [?f :b ?x]) no row met an outer row, while the
seeded path matched both branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtCdzbJMSS1V8qEPaJ4jXz
)

The joined path dropped such a branch's rows; the per-row path, taken when
a branch reads outer metadata or an outer variable in an expression, kept
them. Take the per-row path for it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtCdzbJMSS1V8qEPaJ4jXz
With no :valid-at, an or branch read the wall clock again instead of the
snapshot's now, as the rule path already avoids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtCdzbJMSS1V8qEPaJ4jXz
Union runs each branch as a not body is planned: hashed when closed (read
once into a table of its new cells by imported-variable JoinKeys),
correlated from a Seed otherwise; Distinct follows, over every variable, as
the executor's Value keys compare. A query whose first clause to run is an
or starts from the empty row. A rule invocation or nested or in a branch,
a branch predicate the registry may lack, an or whose branches bind their
own sources' metadata, and a branch not reading only what the branch's
expression binds stay on the executor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtCdzbJMSS1V8qEPaJ4jXz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtCdzbJMSS1V8qEPaJ4jXz
@codecov
codecov Bot commented Oct 19, 2026
Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.48276% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/query/engine/union.rs 95.00% 6 Missing ⚠️
src/query/engine/distinct.rs 84.37% 5 Missing ⚠️
src/query/engine/plan.rs 96.90% 3 Missing ⚠️
src/query/engine/filter.rs 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

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.

1 participant

0