Skip to content

Worst-case-optimal leapfrog-unification join, byte-identical to the ProductZipper - #124

Open
MesTTo wants to merge 11 commits into
trueagi-io:mainfrom
MesTTo:wco-leapfrog-join
Open

Worst-case-optimal leapfrog-unification join, byte-identical to the ProductZipper#124
MesTTo wants to merge 11 commits into
trueagi-io:mainfrom
MesTTo:wco-leapfrog-join

Conversation

@MesTTo

@MesTTo MesTTo commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review shape as requested: the join is behind an experimental leapfrog feature flag (default off), main.rs is untouched, and the report is below. The default build compiles the stock ProductZipper path unconditionally; cargo test -p mork --features leapfrog runs the byte-identity differential suite, and cargo run --release -p mork --features leapfrog --example wco_leapfrog reproduces the report (4000 random trials, 0 mismatches; O(s) vs O(s^2) on the AGM triangle, 242.8x at s=4096). Within the feature the MORK_LEAPFROG runtime toggle pins a reference run to the stock path. The gate contract is modeled in Alloy (fac26, github.com/MesTTo/alloy-mork): routing requires feature AND toggle AND flat shape; answers are gate-independent.

MORK evaluates a conjunctive (exec .. (, p1 p2 ..) ..) body with the ProductZipper, a
relation-at-a-time join that materializes the intermediate product before pruning it. On a triangle
over a hub that product is the s² two-paths through the hub, pruned to the few real triangles. This
module, kernel/src/zipper_join.rs, is a variable-at-a-time leapfrog join over the PathMap
byte-trie: it fixes one join variable at a time and intersects the factors on it by seeking the
trie, so the two-paths are never built. It depends only on PathMap and expr, and the engine
integration is one added function and one call-site change in space.rs. The term encoding, the
unification, and the answer emit are expr's own (Tag/byte_item, unify, apply); the module
contributes the seek order.

A router sends every nonempty relation-prefixed conjunction to the join and returns exactly the
ProductZipper's bytes; the routing decision is parse-level. And metta_calculus now dispatches:
the space-to-space transform routes its body through Space::query_multi_dispatch, so mork run
evaluates intersecting conjunctive bodies on the join with no program change. On the AGM-blowup
triangle (e $x $y) (e $y $z) (e $x $z) over a hub of s in-edges and s out-edges:

    s  ans | PZ transitions       PZ us | leapfrog us    wired us |  PZ/wired   PZ us/s^2
  128    3 |         99654        2305 |         345         343 |      6.7x        0.14
  256    3 |        395846        9502 |         658         657 |     14.5x        0.14
  512    3 |       1578054       37911 |        1283        1292 |     29.3x        0.14
 1024    3 |       6301766      149870 |        2612        2592 |     57.8x        0.14
 2048    3 |      25186374      602944 |        5181        5116 |    117.9x        0.14
 4096    3 |     100704326     2459853 |       10284       10218 |    240.7x        0.15

The ProductZipper's transitions grow as s² and its microseconds over s² hold near 0.14, so it is
Θ(s²); the leapfrog is linear on this instance, so the ratio widens with s, to 240x at s = 4096.
The wired column is the whole engine step through metta_calculus, dispatch on: the engine adds
nothing over the raw join. Both return the same three triangles. No relation-at-a-time plan
reaches the AGM bound on the triangle; the seek does (Ngo et al. 2012, Veldhuizen 2014).

How the engine dispatch stays stock

A dispatched body streams through the join one callback per product tuple. Each accepted
assignment reconstructs the stored facts it sits on (a re-indexed factor's columns are put back in
original order and numbering, which a test pins on a coreferent fact), pairs them with the pattern
factors exactly as query_multi_raw does, and re-derives the bindings with expr's unify. So
the effect closure sees the bindings the ProductZipper path produces, the template instantiation
and emit downstream are stock code fed the same inputs, occurs failures are skipped where stock
skips them, and match multiplicity is preserved (a transform-level test pins touched and the
changed flag per body). Interpreted sources and sinks (I/O) and the pattern-directed dumps
keep the stock path and its enumeration order. MORK_LEAPFROG=0 pins everything stock, per
thread, which is how the differentials hold one arm as the reference.

Dispatch follows a measured policy: a body routes to the join only when it has a
cycle the leapfrog can seek and win on, over an instance where winning is possible. The shape
conjuncts come first and read no data: cyclic over the whole-column variables (a cycle carried
only inside compound arguments gives the seek nothing; the counter machine measured 1.6× slower
without this conjunct), and cyclic over the full variable sets (alpha-acyclic queries are where a
relation-at-a-time plan already meets the optimal bound, which declines paths, semijoins,
enumerations, and pure products outright). Then the instance decides, through bounded cursor
walks. Every relation tiny: decline, the ProductZipper's constants win either way (the tile
puzzle's 56-fact inequality tables). Four or more join factors: dispatch, the product deepens
multiplicatively while the join stays output-bounded (bench clique's 6- and 10-factor queries
run 50× faster dispatched, 15.6 s to 0.3 s). Exactly three factors: dispatch only when a bounded
sample of first-argument values finds a heavy hitter, the hub a product blows up through (the
240× triangle); bench transitive's triangle detect over a million uniform random edges has
none, measured 15% slower dispatched, and declines to exact parity. Last, the functional
dependency probe: a diamond of function tables is a real hyperedge cycle whose AGM bound still
collapses to O(N), so a bounded scan detects each simple factor's trailing dependency and re-runs
the reduction (finite_domain, exactly that shape, measured 3.7× slower under the earliest
shared-column rule and declines at parity now). A hub outside the sample window or a mislabeled
instance only ever falls back to stock performance; the exact version of these decisions is a
cost-based dispatch, which the capped walks approximate. The policy
test pins all seven clauses, and the full mork bench suite runs at parity or faster both ways,
byte-identical, with clique 50x faster dispatched.

Data-side capture, head included

A variable in a stored fact is a wildcard, so the join unifies in both directions. For a query factor
(r (a $p) b) against a fact (r $d b), the stored $d binds the whole compound (a $p). A
relational leapfrog binds query variables to stored subterms but not stored variables to query
subterms; this module recovers that second direction, the step MORK's matcher performs.

The head position is a column like the rest: the seek prefix is the arity byte alone, so a variable
query head unifies with every stored head, and a wildcard stored head is captured under a ground
query head. An earlier revision baked the head into the seek prefix, and the differential caught
both directions returning too little as soon as it generated those shapes; the corpus pins them
now.

cargo run --example wco_leapfrog sends a 12-case corpus and 4000 random conjunctive queries
(variable heads, wildcard-headed facts, and compound columns on both sides included) through the
stock engine, the router, and the wired engine, and diffs the bytes:

[match] witness: data var captures query compound (a $p) (via leapfrog)
[match] occurs-check compound (must be empty)         (via leapfrog)
[match] join-propagated capture (cycle rejected)      (via leapfrog)
[match] coreferent data fact (free-var answer)        (via leapfrog-free)
[match] variable-headed query                         (via leapfrog)
[match] wildcard-headed fact                          (via leapfrog)
[match] wildcard fact propagates a compound through a cycle (via leapfrog-free)
...
4000 random trials, each also through the wired engine: 3895 leapfrog (ground), 105 leapfrog (free-var), 0 fallback, 0 mismatches

The engine level is sealed in-repo: every program under kernel/resources/ runs to several depths
with the dispatch off and on and must produce identical spaces and step counts (a CLI sweep
extends this to 2000 steps), and 20000 random whole programs, several exec atoms with chained and
machinery-colliding bodies run for several steps, must agree the same way. bench bfc passes its
own proof assertions in every mode at size 19 (801 steps, a 2.9M-atom space), and the same
program as plain MM2 files is space-byte-identical off/on at every size 5 through 19; it runs at
parity, since its hot bodies are single-factor sol lookups with no product to prune (dispatched
everywhere anyway it runs 21% slower, which is what the policy is for). An in-module
differential additionally runs the RAW join, no router, against a nested-loop reference over the
same unify on eleven templates centered on the hardest shapes: any divergence would sit in the
seek order, not the unification.

No declined shape

Earlier revisions held one body back from the join: the capture that binds a non-ground compound
and propagates it through a shared variable, where a per-column union-find diverges from
unification (nonflat_uf_unsound, ZipperUnifySafe.thy). The per-column step is no longer that
union-find. It is unify threaded through one bindings store, and an assignment whose bindings
close a cycle is rejected at the answer emit, mirroring Expr::_unify, which covers the shape: the
router is total on relation-prefixed conjunctions. The corpus pins the recovered case, a
fully-wildcard fact capturing a compound and propagating it through a four-factor cycle,
byte-identical to the ProductZipper.

proofs/ carries the Isabelle theories, checked with isabelle build -D proofs under Isabelle2025.
RoutingSafe.thy and ZipperUnifySafe.thy prove the ground and flat coincidences and the retired
byte-level mechanism's boundary. TotalRouterSafe.thy carries the total router's facts: a solution
of an equation system solves every subsystem (solvable_mono, the per-level pruning), pairwise
unifiability does not give a simultaneous solution (threading_necessary, why one bindings store
threads the whole descent), and a variable equated with a term properly containing it has no finite
solution (occurs_unsolvable, the cycle rejection).

Ground join: held cursors and data parallelism

The ground special case, ground_join, opened a fresh read-zipper at prefix + bound for every
column of every factor at each recursion node, re-walking the byte-trie from the root each time. That
re-descend was the join's dominant cost (it is compute-bound). It now holds one SubtermCursor per
factor, opened once at the relation prefix and walked column by column with a floor stack
(SubtermCursor::descend_floor/ascend_floor): the zipper descends into a chosen column value and
ascends back out in place, never re-opened from the root. Byte-identical to the prior join
(ground_join_matches_brute_force) and, on a 3000-node / 150k-edge graph (7,369,139 two-hop
answers), 2731ms -> 1139ms (2.4x).

ground_join_parallel then partitions the leading variable's domain across worker threads by hash
(hash(value) % nworkers), each running the join on the shared read-only map into its own buffer,
concatenated at the end. It is data-race free by construction: the workers only read the immutable
PathMap and each owns its output, and the leading-variable hash partition is a disjoint cover, so
the workers' answer sets union to exactly sequential ground_join's
(ground_join_parallel_matches_sequential). Same graph: 1139ms -> 121ms at 32 workers,
byte-identical, 22.6x over the original. ground_join_for_each streams each answer to a closure
without materializing the domain.

Reproduce: RUSTFLAGS="-C target-cpu=native" cargo +nightly run -p mork --release --example ground_join_parallel.

Scope

The module is a join-stage addition, the worst-case-optimal form of the ProductZipper's semantics
on join-bound conjunctive bodies, now reaching mork run through the dispatch. It claims nothing
about whole-program time where the exec loop is iteration-bound rather than join-bound, and it
deliberately leaves enumeration-shaped bodies, interpreted sources and sinks, and the dumps on the
stock path; the per-eval cost-based dispatch that could also take the compound-shared class is the
next step.

A standalone repo rebuilds all of this from fresh clones of MORK and PathMap at pinned commits;
run.sh overlays the module and the engine patch, runs the demonstration, and now runs YOUR
program: ./run.sh yourfile.mm2 evaluates it on the wired engine, and
./run.sh compare yourfile.mm2 [steps] runs both engines and diffs the result spaces:
https://github.com/MesTTo/mork-wco-leapfrog

@MesTTo MesTTo changed the title Add a worst-case-optimal leapfrog-unification join (byte-identical to the ProductZipper) Worst-case-optimal leapfrog-unification join, byte-identical to the ProductZipper Jul 1, 2026
@MesTTo
MesTTo force-pushed the wco-leapfrog-join branch 2 times, most recently from a519aeb to 9c72bfa Compare July 2, 2026 03:59
@MesTTo

MesTTo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Both points taken, and done on the branch.

The module now sits on Expr: Tag/byte_item/item_byte for the encoding (the six copied
constants are gone), ExprZipper for materialized-term navigation, and the match at each column is
expr's unify over ExprEnv, with answers emitted through apply. The private trail unifier,
its occurs check, and the hand-rolled coordinated emit are deleted. Two byte-level steps remain,
each stated inline with its reason: a sought ground subterm equal to the candidate skips unify
because on ground terms byte equality is unifiability (ground_unifiable_iff_eq, RoutingSafe.thy),
and a resolved symbol reads its span directly because a symbol cannot contain a variable. Every
wildcard, compound, and free-variable case goes through unify alone.

The move surfaced a real bug, so the critique earned its keep. unify checks occurs per equation
and Expr::_unify compensates by rejecting after apply when a cycle got cut; my first pass
dropped that compensation, so a cycle built across columns (the join-propagated capture closes
x0 = (k (k x0))) leaked one spurious schematic row out of the raw partial entry. The router
declines that body either way, but the raw function was wrong. Fixed by mirroring _unify: the
row is rejected when apply records a cut cycle, and a test now pins that shape's exact answer
set.

Unification being orthogonal to join order is now also a test: a nested-loop reference join over
the same unify must agree with the leapfrog on random schematic facts, eight query shapes, four
hundred seeds, so a divergence would sit in the join order, not the unification.

Re-verified end to end: the corpus including the declined shape, 4000 random flat conjunctive
queries against the ProductZipper with 0 mismatches, and the triangle at 221x at s = 4096, O(s)
against the ProductZipper's Θ(s²). Measured interleaved at equal load, the unify/apply path
costs 4-6% over the byte-level version it replaced; a profile puts what remains in unify on the
free-variable binds, which is where it belongs.

One note: ExprVar = (u8, u8) is private in expr, so the module names the pair type again for
its bindings map. If you would rather export the alias, that is a one-line change in expr I kept
out of this diff.

@MesTTo

MesTTo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Two more things landed on the branch, found by pushing the differential harder.

The head position was exempt from unification: with the relation head baked into the seek prefix, a variable query head returned nothing where the ProductZipper binds it to every stored head, and a wildcard stored head was invisible under a ground query head. The prefix is now the arity byte alone and the head is column 0, so both directions go through the same column machinery as every other position, and the corpus pins both cases.

With the head a column, the routing gate's per-fact scan lost its purpose, and the shape declines went with it: the per-column step is unify with cycle rejection at emit, so the router now takes every relation-prefixed conjunction, the join-propagated capture included. The corpus pins the recovered case, a fully-wildcard fact capturing a compound and propagating it through a four-factor cycle, byte-identical to the ProductZipper. The routability check is parse-level now; it used to scan every fact under each factor prefix per query. nonflat_uf_unsound stands as the boundary of the retired byte-level mechanism, and a new theory, TotalRouterSafe.thy, carries what the total router rests on: subsystem solvability for the pruning, a pairwise-versus-simultaneous counterexample for why one bindings store threads the descent, and the unsolvability of an occurs violation for the emit-time cycle rejection.

Re-verified end to end: a 12-case corpus, 4000 random trials with variable heads, wildcard-headed facts, and compounds on both sides, 0 fallback, 0 mismatches; a 20000-seed in-module differential against a nested-loop reference over the same unify; the triangle unchanged within noise at ~240x. The standalone repo is updated to match.

@MesTTo

MesTTo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

One more commit on the branch: the engine now dispatches, so this is no longer only a standalone
module plus a demonstration.

transform_multi_multi_ routes its body through a new Space::query_multi_dispatch (one added
function and one call-site change in space.rs). A dispatched body streams through the join one
callback per product tuple: each accepted assignment reconstructs the stored facts it sits on,
pairs them with the pattern factors exactly as query_multi_raw does, and re-derives the bindings
with expr's unify, so the effect closure, the template instantiation, and the emit are stock
code fed the same inputs, and multiplicity is preserved. MORK_LEAPFROG=0 pins everything stock
per thread.

Dispatch is policy-gated on measurement, not reflex: only bodies with a variable column shared by
two or more factors route, the class with an intersection for the seek to exploit. Dispatched
everywhere (MORK_LEAPFROG=all), the counter machine runs 3.4x slower (callgrind: 3.3x the
instructions), so it stays stock at parity; the triangle runs 240x end to end through
metta_calculus at s = 4096, the whole engine step now costing the same as the raw join.

Evidence, all in the branch: every kernel/resources/*.mm2 program byte-identical off/on to
several depths in-repo and to 2000 steps in a sweep; 20000 random whole programs (several exec
atoms, chained bodies, machinery collisions, multi-step) byte-identical including performed step
counts; a transform-level test pinning touched and the changed flag per body; a re-index test
pinning that a streamed leaf returns a coreferent fact's original bytes; the demonstration now
runs every corpus case and all 4000 random trials a third way, through the wired engine; and
bench bfc passes its proof assertions in every mode, spaces byte-identical as MM2 files at every
size, at parity, its single-factor sol lookups correctly left stock (forced dispatch would cost
it 21%).

The standalone repo follows: ./run.sh yourfile.mm2 runs a program on the wired engine, and
./run.sh compare yourfile.mm2 [steps] runs both engines and diffs the result spaces (run.sh used
to forward arguments to the demonstration binary, which ignored them; someone hit that, fixed).

@Adam-Vandervorst

Copy link
Copy Markdown
Collaborator

If this is indeed capable of running through metta_calculus, please run it over the existing main.rs benchmark test and benchmark suite with a flag. I.e., not touching main.rs, just toggling the (experimental) feature flag. And report on the mork bench default and mork test outputs.

@MesTTo
MesTTo force-pushed the wco-leapfrog-join branch from 6aff483 to e666609 Compare July 3, 2026 06:40
@MesTTo

MesTTo commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Ran the validation on the PR branch, release build with target-cpu=native.

mork test with MORK_LEAPFROG=all (forces every routable body through the leapfrog join, the maximum-coverage setting): 21 passed, 0 failed.

mork bench default, stock MORK_LEAPFROG=0 vs leapfrog (default dispatch). Every benchmark's result, step count, size, and unification count is byte-identical across the two runs. The only things that differ are wall-clock timings and the process_calculus instruction counter.

The asymptotic separation is in clique (finding k-cliques is a cyclic conjunctive query, the AGM-bound workload):

workload stock ProductZipper leapfrog ratio
3-cliques 27.9 ms 33.2 ms 0.84x
4-cliques 561.0 ms 74.4 ms 7.5x
5-cliques 15,531.6 ms 103.0 ms 150.8x

Stock grows 20x then 28x from one clique size to the next; leapfrog grows 2.2x then 1.4x. That is the WCO/AGM separation, and it widens with query size. At k=3 the dispatch overhead makes it a wash, by k=5 it is 150x and still opening up.

process_calculus: identical result, identical unification count (3,431,002), VM instructions drop from 1,062,604,016 to 544,133,432 (1.95x fewer) for the same work. Its wall-clock is unchanged because its bottleneck is the emit, not the join.

bfc, counter_machine, finite_domain, taxi_lts, tile_puzzle_states, and transitive are within single-run wall-clock noise with byte-identical results. No regression.

Numbers are single-run wall-clock, so the sub-benchmarks within a few percent are run-to-run noise; the clique separation is large enough to be unambiguous.

@MesTTo

MesTTo commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Follow-on stacked on this: #130 adds factorized evaluation of the aggregate sinks (COUNT/SUM/MIN/MAX/AND in O(N^fhtw) via GHD sum-product and Yannakakis semi-join reduction), byte-identical to the enumerate sinks and gated on Alloy-checked soundness conditions. It builds directly on this join, so it reduces to just the aggregation commits once this lands.

@MesTTo

MesTTo commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Current numbers on this tip (e491c44, cargo run --release --example wco_leapfrog):

4000 random flat-conjunctive trials, also through the wired engine: 0 mismatches
    s  ans | PZ transitions      PZ us | leapfrog us  wired us |  PZ/wired  PZ us/s^2
  128    3 |         99654       4149 |         728       598 |      6.9x       0.25
 1024    3 |       6301766     256367 |        4421      4449 |     57.6x       0.24
 4096    3 |     100704326    4142601 |       17005     17059 |    242.8x       0.25

O(s) on the AGM triangle where the ProductZipper is O(s^2) (the constant PZ us/s^2 column is the quadratic); the wired column is the whole engine step through metta_calculus with dispatch on. mork bench all deterministic counters and mork test are unchanged with the router off and on.

MesTTo added 10 commits July 4, 2026 23:34
zipper_join.rs answers a conjunctive (exec .. (, ..) ..) by seeking the PathMap byte-trie one
variable at a time and intersecting factors on each shared variable, instead of materializing the
product the ProductZipper builds. The term encoding, unification, and answer emit are expr's own
(Tag/byte_item, unify, apply); the module contributes the seek order. Behind a self-contained
router it returns exactly the ProductZipper's answers on the conjunctive fragment it covers
(ground and free-variable answers, and data-side capture of a query compound), and runs in O(s) on
the AGM triangle where the ProductZipper is O(s^2). It declines the one compound shape a
per-column union-find is provably unsound on (proofs/ZipperUnifySafe.thy, nonflat_uf_unsound),
keeping it on the ProductZipper, and an assignment whose bindings close a cycle yields no row,
mirroring Expr::_unify's occurs rejection. Depends only on PathMap and expr, and adds one pub mod
line. cargo run --example wco_leapfrog reproduces the differential and the scaling table.
The relation head was baked into the seek prefix, so it was exempt from unification in both
directions: a variable query head returned nothing where the ProductZipper unifies it with every
stored head, and a wildcard stored head was invisible under a ground query head. The prefix is now
the arity byte alone and the head is column 0 like any argument, so both directions flow through
the ordinary column machinery (a ground head seeks exact plus wildcards, a variable head
enumerates).

With the head a column, the per-fact gate scan lost its meaning and its need: the per-column step
is full unification with cycle rejection at emit, so no query or fact shape requires a decline.
The router now takes every nonempty relation-prefixed conjunction, the once-declined
join-propagated capture included, and the routability check is parse-level, reading nothing from
the map (it was a full fact scan per query).

Verified: the corpus grows head-position and cycle cases (11 shapes, all matching the
ProductZipper), the random distribution gains variable heads, wildcard-headed facts, and compound
columns (4000 trials, 0 fallback, 0 mismatches; variable-headed patterns keep total arity 3 so
they cannot match the harness's own exec and ans atoms, which is full-evaluation semantics rather
than the compared query), and an in-module adversarial differential runs the raw join against a
nested-loop reference over the same unify on eleven templates around the old boundary (20000
seeds, 0 mismatches). The triangle is unchanged within noise: the O(1) gate pays for the head
column.
TotalRouterSafe.thy: a solution of an equation system solves every subsystem (solvable_mono, the
per-level pruning), pairwise unifiability does not give a simultaneous solution
(threading_necessary, why one bindings store threads the whole descent), and a variable equated
with a term properly containing it has no finite solution (occurs_unsolvable, the emit-time cycle
rejection). nonflat_uf_unsound stands as the boundary of the retired byte-level union-find, the
mechanism whose decline this router no longer needs. Checked with isabelle build -D proofs.
A fully-wildcard fact captures a compound and propagates it through a four-factor cycle:
(h $s1 $u1) binds $u1 to (k v3), which (e (k $s0) v2) then absorbs. The total router finds the
answer row, byte-identical to the ProductZipper; the shape is the one a route co-tuned to the old
decline structurally misses, so the corpus pins it.
The space-to-space transform, the ,/, exec path, now routes its body
through query_multi_dispatch: a nonempty relation-prefixed conjunction
with a variable occurring as a whole column in two or more factors runs
on the worst-case-optimal join, and everything else, with interpreted
sources, sinks, and the pattern dumps, keeps the ProductZipper path.
The join streams one callback per product tuple: an accepted leaf
reconstructs its per-factor stored facts (a re-indexed factor's columns
go back to original order and numbering), pairs them with the pattern
exactly as query_multi_raw does, and re-derives the bindings with
mork_expr::unify, so the template emit downstream is stock and
byte-identity is inherited rather than re-proven. MORK_LEAPFROG=0 pins
the stock path per thread; =all dispatches every routable body.

The policy is measured, not assumed. The triangle runs 240x end to end
through metta_calculus at s=4096. The counter machine, whose bodies
never intersect, would run 3.4x slower dispatched everywhere (callgrind:
3.3x the instructions) and stays stock at parity; a 10^6-tuple pure
product would run 1.8x slower and stays stock. Nil's bfc chainer is at
parity too, its hot single-factor sol lookups correctly left stock
(forced dispatch costs it 21%), and byte-identical off/on at every
proof size with the bench's own assertions passing.

Every resource program is byte-identical both ways to 2000 steps, as
are 20000 random whole programs with chained exec atoms and machinery
collisions, the corpus, and 4000 random single-flip trials, each also
run through the wired engine; the transform's match count and changed
flag agree case by case.
The shipped policy dispatched any body with a variable column shared by
two factors, which routed finite_domain, a functional pipeline whose
factors share columns, onto the join for a 3.7x loss (bench: 70ms
stock, 261ms dispatched). The gate is now the actual boundary of the
worst-case-optimal advantage, three conjuncts, cheapest first: the body
must be cyclic over its whole-column variables (a cycle carried only
inside compounds gives the seek nothing; the counter machine measured
1.6x slower without this), cyclic over its full variable sets
(alpha-acyclic queries are where a relation-at-a-time plan already
meets the optimal bound), and not functionally degenerate (a diamond of
function tables is FD-acyclic, its AGM bound linear, per
Gottlob-Lee-Valiant-Valiant and Abo Khamis-Ngo-Suciu). The first two
conjuncts read no data; the third probes each simple factor's trailing
functional dependency over the facts, one shared scan per prefix, dead
at the first determinant collision, capped at 200k rows, so a genuine
graph pattern stops paying within its first facts and dispatches.

Measured: finite_domain 70.3ms stock / 79.4ms dispatched (the residual
is the one-time confirm scan of the genuinely functional tables);
counter machine 12/13ms parity; the s=4096 triangle keeps 2602ms ->
10ms. Resources, bfc, the corpus, 4000 trials, and 20000 random whole
programs stay byte-identical; a policy test pins the four routing
classes.
The full bench suite measured the boundary inside the cyclic
non-functional class. bench transitive's triangle detect over a
million uniform random edges ran 15% slower dispatched (the product
never blows up there, so the join's per-candidate machinery loses on
constants), while bench clique's 6- and 10-factor queries ran 50x
faster dispatched, and the hub triangle keeps its 250x. The gate now
reads a bounded region of the instance before dispatching: every
relation tiny declines, since the ProductZipper's straight-line
constants win outright; four or more join factors dispatch, since the
product deepens multiplicatively while the join stays output-bounded;
a three-factor body dispatches only when a bounded sample of
first-argument values finds a heavy hitter, the hub a product blows up
through. A hub outside the sample window is missed and the body merely
stays on the stock path; the exact version of this decision is the
cost-based dispatch these capped cursor walks approximate.

Measured after: transitive 1.01 (from 1.15), clique 0.02 (kept),
tile_puzzle 1.01, finite_domain 1.00, the s=4096 triangle 2506ms ->
10ms (kept), every other bench at parity. Resources, bfc, the corpus,
4000 trials, and 20000 random whole programs stay byte-identical, and
the policy test pins all seven clauses.
…und_join

ground_join opened a fresh read-zipper at prefix+bound for every column of every
factor at every recursion node, re-walking the byte-trie from the root each time
(the dominant cost; the join is compute-bound). Hold one SubtermCursor per factor
instead, opened once at the relation prefix and walked column by column with a
floor stack (SubtermCursor::descend_floor/ascend_floor): the zipper descends and
ascends in place, never re-opened from root. Byte-identical to the prior join
(ground_join_matches_brute_force) and, measured on a 3000-node/150k-edge graph
(7,369,139 two-hop answers), 2731ms -> 1139ms (2.4x).

ground_join_parallel partitions the leading variable's domain across worker
threads by hash (hash(value) % nworkers), each running the join on the SHARED
READ-ONLY map into its own buffer, concatenated at the end. Safe by construction:
concurrent reads of an immutable PathMap, no shared writes; the workers' answer
sets partition the whole, so the result equals sequential ground_join
(ground_join_parallel_matches_sequential). Same graph: 1139ms -> 121ms at 32
workers (byte-identical). ground_join_for_each streams answers without
materializing the domain. See examples/ground_join_parallel.rs.
…a only

ground_join does not unify. It is complete only when every joined relation is
fully ground; on schematic data it is a strict subset of the ProductZipper and
silently drops answers (a stored (sol $n ..) is not matched against a query
(sol Z ..); a nonground compound query column stays unconsumed). A caller that
may join over schematic facts must detect that and route to a unifying join.
Making the contract explicit at the API so callers gate correctly.
The routing block in query_multi_dispatch now compiles only under a new
default-off leapfrog feature, so the default build takes the stock
ProductZipper path unconditionally; with the feature on, the MORK_LEAPFROG
runtime toggle keeps working within it, and the wco_leapfrog example requires
the feature. This is the review shape requested on the PR: the join under an
experimental flag, main.rs untouched. The gate contract is modeled in Alloy
(fac26 in MesTTo/alloy-mork): routing requires feature AND toggle AND flat
shape, every other state is the stock engine, and answers are gate-independent.
@MesTTo
MesTTo force-pushed the wco-leapfrog-join branch from e491c44 to 5e38b42 Compare July 4, 2026 13:38
PathMap already exposes this as BitMask::test_bit (src/utils/mod.rs:609), implemented for ByteMask. The local copy duplicated it byte-for-byte and indexed the mask words directly instead of going through the bounds-checked accessor. least_ge keeps its next_bit call: ByteMask::next_bit is strictly greater-than, so testing k first is a real gap it fills.
@MesTTo

MesTTo commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

dropped the local has_bit here and in #130, it's BitMask::test_bit in pathmap.

the report, by instruction and transition count so it doesn't move machine to machine. at s=4096 the ProductZipper retires ~63B instructions and the leapfrog ~250M, a 250x gap. the transition counts are exact: 99654, 395846, 1578054, 6301766, 25186374, 100704326 across s=128 to 4096, 4x per doubling, so Θ(s²) vs the leapfrog's linear scan. byte-identical answers at every s. no relation-at-a-time plan hits the AGM bound on the triangle, the seek does.

on the gate: it's one WCO join behind a profitability check, not a planner picking among kernels. routability is parse-level, and the check reads bounded cardinality off the trie, so it's deterministic. yeah it's cost-based in the broad sense, but bounded and deterministic, not the wall-clock router i cut from #128. it's there because dispatching everything regresses the shapes the leapfrog can't win (counter_machine, transitive, finite_domain, bfc). if the read still bugs you i can make it purely structural, keeps most of the win.

#130 is stacked on this, so its diff includes this whole file + the ghd.rs aggregation. once this lands it rebases to just the aggregation.

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