Skip to content

Proj aware join - #149

Open
Adam-Vandervorst wants to merge 9 commits into
mainfrom
proj-aware-join
Open

Proj aware join#149
Adam-Vandervorst wants to merge 9 commits into
mainfrom
proj-aware-join

Conversation

@Adam-Vandervorst

Copy link
Copy Markdown
Collaborator

Skip trie subtrees when no mentions in then are needed.

adamv-symbolica and others added 9 commits August 19, 2026 15:36
A body variable that no template reads and that the body mentions once
is invisible downstream: every value past the first re-derives an
answer byte-for-byte, which the trie then dedups. Both engines stop at
the first witness.

space.rs computes the mask once per transform and marks a variable only
inside its conjunct's TRAILING run -- anything after it in the same
conjunct draws its values from the subtrie the choice selects, so
cutting there would drop answers rather than duplicates. The walk is
allocation-free and does not read the templates at all unless the body
leaves a candidate a template could disqualify.

Only the plain `,` -> `,` write form cuts. The cut drops DUPLICATE
answers, which is invisible only to a sink idempotent in them, and the
`O` form and the source/sink transforms can carry Count, Sum, And,
Head/Tail and the float reductions, whose result is a function of how
many answers arrive.

Leapfrog takes the cut on its own path, chosen once per level before
any loop, so fill_lead_candidates and consume_lead keep the exact code
they had; if the single witness does not match, the level re-runs as a
full enumeration, so the cut can only drop a duplicate. The
ProductZipper descends the first complete subterm instead of
enumerating every variable, size class and arity below the position;
on a factor's trailing variable that descent also crosses the factor
boundary, force-enrolling the next factor. Every other query entry
keeps its signature and passes a zero mask.

Replayed onto main rather than rebased commit-by-commit: main has since
merged #146, which already moved the subterm walk into mork-expr (so
this uses upstream's subterm_parse_step rather than adding another),
deleted first_subterm, and rewrote Bindings and the candidate handling
underneath consume_lead. Thirteen compounding merges would have been
far more error-prone than re-applying the net change to the current
shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The decomposition, crafting, LTS and process-calculus programs were
committed with their generated fact data -- 78k lines. generate.py now
produces all of them, carrying only the rule text, and the directory is
32K.

Numbers move on this base: with #146 merged the naive process calculus
runs 4.7x faster than it did pre-rebase, so the incrementalization
uplift on an 800-long cascade reads 4.4x (delta) and 7.0x (consuming)
rather than 19.7x and 30.2x. The transforms still remove the same
quadratic; the baseline they are removing it from got faster. The other
families are unchanged: cyc 26x/28x, craft 6.7x/14.6x, clq 0.9x/7.1x.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
walking every expression twice

`byte_item` masks an arity, a symbol size and a variable index with
0b0011_1111, so all three are already below 64. Testing `r < 64` before
using a VarRef, or clamping an arity with `.min(64)`, cost a branch and
-- much worse -- read as though a larger value were possible. Those
guards are gone; where the bound matters it is a debug_assert naming
the reason.

The variable cap is handled once rather than per access: the encoding
caps a routable body at 63 variables and a mask cannot name what it
cannot index, so a body past the cap opts out entirely and returns a
zero mask. That is conservative -- a zero mask is the unchanged
enumeration -- and it replaces a bounds check on every NewVar. The test
pins both halves: past the cap nothing is marked, at the cap marking is
normal.

`span()` is itself a full walk of the expression, so calling it and
then walking the bytes did the work twice, once for the body and once
per template. Both walks now run straight off the pointer and stop when
the subterm parse closes.

And the ground stamps do the rest: a template carrying a nonzero stamp
is ground by the stamp's contract, so it holds no variable and is
skipped without being walked at all. That needs the templates as
ExprEnv rather than Expr, which is what the call site already had --
it was flattening them to Expr and discarding the stamps.

Together these take the analysis to parity with not running it.
counter_machine, median of four alignment-varied rounds: 206ms with the
analysis stubbed out, 216ms before this change, 205ms after. bfc,
transitive and tile_puzzle_states are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`vs!` is the only place the walk learns a candidate is non-ground, and
the cut goes around it: descend_first_subterm took the leftmost path
and passed var_facts through untouched. A leftmost subterm can very
well carry a variable -- an arity byte sorts below a symbol byte, so a
compound like `(f $z)` is exactly what gets picked -- and the leaf then
read that fact as ground and handed it to stamp_ground, whose SAFETY
note is explicit that a false stamp makes consumers settle by memcmp
and skip variable hunts over the span.

descend_first_subterm now ORs in the bit of every factor it finds a
variable in, at item heads only so a symbol's payload cannot be read as
a tag.

The invariant is self-checking from now on: in debug builds the leaf
re-derives groundness by walking the fact's span before stamping, and
panics if the bit disagreed. That discriminates -- with the tracking
removed it fires on differential/corpus/programs/projection_cut.mm2,
which had been reaching the bug all along without the wrong stamp
changing any visible answer.

Both runners could compare stale output. Neither cleared its target nor
checked the exit status, so a crashed or timed-out run left the
PREVIOUS run's space in place and the comparison passed on it. Both now
remove the file first and require a zero exit; the fuzzer reports a cut
build that fails rather than crediting it with the speed-up.

And the explanations at the places called out were too long, and in one
case wrong: the mask's doc claimed every bound in it was structural,
when the variable cap is a real bound with a real early exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The harness ran the baseline then the variant for every pair. Over a
benchmark that takes tens of seconds, drift across the pair is
monotonic, so whichever runs second is charged for it -- a systematic
bias, not noise that averages out. Each program is now timed twice, on
either side of the other, and the best of each is kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A self-join over one relation needs BOTH deltas: new receivers against
every message, and every receiver against new messages. The lowering
had only the second, so a communication that derives a RECEIVER never
reacted with a message already in the soup -- it was incomplete, not
just slower.

The cascade I measured hid it: every body it derives is a message, so
the missing arm never had anything to do. A soup where receiving yields
a receiver shows it at once -- the monotone rule derives (! deep ok)
and the one-armed delta does not.

Both arms now run, alternated by the same IC controller
process_calculus_bench uses for its own two rules, and the corpus
program carries a soup that catches the difference. Getting it to catch
took three tries, which is worth recording: the delta only loses an
answer when the message has already been RETIRED from the worklist, so
the shape needs an early consumer to retire (! r1 q1) in the first
round and a late-derived receiver that then has to find it in the full
relation. Seeded-and-never-matched, or derived-while-still-pending, and
the one-armed version passes by luck. Verified by removing arm 0: pD
loses exactly (! deep ok).

Also: `min(...) if (bn1 or bn2) else None` in the bench harness read a
genuine 0 ms as failure. Only None is failure.

And `mork test` gains projection_cut_variables, covering what the cut
must take (a trailing don't-care with fan-out, a schematic witness) and
what it must refuse (a non-trailing one, the same nested, and a
repeated variable that is a join variable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It was a hardcoded `pub static ACT_PATH = "/dev/shm/"`, so every ACT
test failed off Linux: macOS has no /dev/shm, and `mork test` died at
the first backup_tree with a bare NotFound.

`act_path()` now reads the ACT_PATH environment variable, and otherwise
takes the best temporary location the platform offers: /dev/shm on
Linux when it is mounted, since a tmpfs keeps an ACT out of the disk,
and the OS temp directory elsewhere -- TMPDIR on macOS, which launchd
always sets per-user, and TEMP/TMP on Windows. The separator is
appended if missing and the directory is created if absent, so pointing
ACT_PATH at a fresh path just works.

Verified on macOS three ways: the default writes to $TMPDIR
(/var/folders/../T/two_bipolar_equal_crossed.act), an explicit
ACT_PATH is honoured, and a non-existent one is created.

This does not make `mork test` pass here. It now reaches a hash
assertion that expects (result XoicVnQv2bk) and gets (result
-Egab4rQ3Nc). That failure is main's, not this branch's: main with its
constant repointed at a real directory fails at the same test with the
same two values. Left alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The expectation predated #146, which made the hasher compile: the
constants came from whatever was silently used while the `gxhash` cfg
never matched. With gxhash 3.x running, `(myexpr ..)` hashes to
-Egab4rQ3Nc and ru8oOBGFlq0.

Portable, contrary to what I said last round: gxhash documents its
hashes as stable across every supported platform for a given major
version, so this is not pinned to this machine's ISA. It is pinned to
gxhash 3.x, and a major bump will move it again.

With this, `mork test` passes end to end on both engines -- and the
same constant makes main pass too, which is how I know the value is the
hasher's and not this branch's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The query-side rule -- mentioned once, unread, trailing -- says nothing
about the DATA sharing a variable across the columns it names. Facts
like (f $x A $x) tie the don't-care column to the column a template
reads, so the audit asked whether $a still iterates over A and B under
(, (f $a $a $_)). It does, on both engines, and the reason is
structural rather than lucky: the discriminating information lives in
the REPEATED column, which is a trie prefix of the don't-care column,
so it is enumerated before the cut ever applies.

More generally a bare fresh singleton is an unconstrained position: the
binding it takes is read by nothing and constrains nothing else, so the
choice cannot propagate. What could still discriminate is a later query
position drawing from the subtrie the choice selected, and that is
precisely what the trailing condition excludes.

Eight aliasing shapes are pinned, expected generated from a NO-CUT
build so the cut is measured against the semantics it must preserve --
including the middle-position case, which must not be cut at all.

Non-vacuous, checked by the unifications counter: over 400 facts
sharing an aliased prefix and differing at the don't-care column, the
cut takes 400 unifications to 1 with the same single answer; over 400
facts differing at the REPEATED column it takes 400 to 400, collapsing
nothing, which is the audited case at scale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adamv-symbolica

Copy link
Copy Markdown
Contributor

(Unsound as currently presented by 590b08c )

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