Skip to content

DD backend: compile whole schedules to persistent fused dataflows (engine) - #31

Draft
oflatt-claude wants to merge 67 commits into
saulshanabrook:mainfrom
oflatt-claude:oflatt-dd-rebuild-in-dataflow
Draft

DD backend: compile whole schedules to persistent fused dataflows (engine)#31
oflatt-claude wants to merge 67 commits into
saulshanabrook:mainfrom
oflatt-claude:oflatt-dd-rebuild-in-dataflow

Conversation

@oflatt-claude

Copy link
Copy Markdown

Competitor to #29 (the hybrid): instead of host-side scheduling calling DD per ruleset-iteration, this compiles an entire ScheduleSpec tree into one differential dataflow and keeps that dataflow alive across invocations.

Stacked on #22 (relations encoding) and #29 (perf work) — review the last three commits (e0e39533, f6ea25de, 91bbd181) for the new material.

Design

  • A stateful ScheduleEngine operator walks the schedule (Run/Repeat/Saturate/Sequence, spliced rebuild included) as a stack machine: it fires monotone-fire rule turns, evaluates merges (UF min/max cascades run as waves within one timestamp, not consuming schedule budget), mints, hash-conses, deletes/subsumes. DD does only the incremental joins. Effects feed back through a root-scope Variable, so round r writes are matchable at r+1. One run_schedule call = boot, chase rounds to completion, apply net deltas to the host.
  • Persistent per-spec dataflows: arrangements, engine table state, and match sets survive across invocations (thread-local cache keyed by spec fingerprint, guarded by row-event/fresh-id/rules-version watermarks; any foreign change rebuilds). Eleven separate (run 1)s now cost the same as one (run 11).
  • Cost-based join ordering in plan_join_with (per-view row counts + sampled per-column distincts, exhaustive over ≤4-atom rules). One body-order join in the factorization rule was 90% of all arrangement traffic.
  • Width-ladder monomorphization, one-pass demux by (func, loc) instead of ~85 Tee'd filters, single N-way concatenate, per-timestamp engine queue buckets, quiescent-schedule replay cache.

Numbers (single thread, math-microbenchmark)

workload this PR hybrid (#29) mainline
run 9 ~1.0s 0.8s
run 10 6.8s 5.5s
run 11 (full) ~95s 70s 5.9s
11 × (run 1) (driver pattern) 91.6s n/a (rebuilds per call) 1.2s*

* mainline re-runs from its native rebuild; the DD gap is per-tuple economics, not invocation overhead (now ~ms). Remaining engine-path costs are measured: 73.5M match deltas of re-derivation churn (56% from Add associativity alone) and arrangement maintenance.

Semantics notes (found by testing, worth review)

  • Fired-flag soundness: a fired delete/subsume whose row is re-added in the same round never sees its match retract — the −/+ pair cancels in DD consolidation where mainline's physical row timestamps would re-trigger the rule. Delete/subsume-capable rules therefore reset fired flags each pass; pure writers keep theirs (= interpreter seminaive). Caught by integer_math (341 vs 331 Adds).
  • Replay cache only caches no-op runs: replaying a state-changing (run n) would skip real work.
  • Pre-existing, backend-wide (not new here): DD reports never fill per-rule num_matches, so (print-stats) differs from mainline.

Validation

Corpus 131/131 (shared snapshots with mainline), dd unit/integration suites green, egglog suite 996/0, run9/10/11 outputs bit-identical to the pre-persistence engine, split-run programs semantically identical to the mainline backend. One smoke failure (backend_generic_reads_work_without_an_action_registry) predates this branch.

Design doc with the full perf history and the delta-feeding (v2) plan: dd/docs/rebuild-in-dataflow.md.

Diagnostics: EGGLOG_DD_ENGINE_DEBUG (phase laps, per-turn gaps, per-rule traffic), EGGLOG_DD_VOLUMES, EGGLOG_DD_NO_ORDER, EGGLOG_DD_NO_PERSIST, and a pprof feature writing per-invocation flamegraphs.

🤖 Generated with Claude Code

oflatt and others added 30 commits July 15, 2026 16:19
Foundation for the relation-based term/proof encoding:
- get-fresh! per-sort mint primitive (from the backend id counter)
- term tables: constructor -> relation (function ... Unit :no-merge),
  created via (let fresh (get-fresh!)) (name children fresh)
- extraction cost moved off term tables onto views via :internal-cost

Terms functionally working (term-mode file tests pass modulo snapshots).
Proof constructors still to convert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- proof-datatype + AST + prooflist constructors -> relations (function ... Unit)
- shared mint() helper (get-fresh! + set), returns the fresh var
- term_proof_for_justification emits mint sequences, returns proof var
Scattered nested proof-construction sites still to flatten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every generated nested proof/AST/ProofList expression now builds bottom-up
via mint() (get-fresh! + relation assert). format_prooflist emits mints.
Term-mode parses/typechecks; proof-mode extraction still to update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- extract_eq/prove_exists read the minted id from the last input column
  (is_relation_term / extraction_output_index)
- prove-exists expects a function (constructors are lowered to term relations)
- fix custom-function merge: bare-var merge result no longer wrapped into a call
- container-rebuild proof primitive mints+asserts proof relation rows instead
  of constructor lookup-or-insert
proof_mode_regression: 9/9 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces per-view set-if-empty + view-proof primitives (proof_fresh.rs) and
threads freshly-built constructor terms through set-if-empty so parents build
with canonical children, keeping the FD views canonical (nothing for the encoded
rebuild to re-key).

Term-only mode: validated win on math-microbenchmark — @Rebuilding search+apply
2.67s -> 2.08s (-22%), total 4.45s -> 3.73s (-16%), tuples -10%. No new
regressions (term suite 98/2, both pre-existing relation-encoding snapshot regens).

Proof mode: canonicalization enabled but WIP. Deduped terms leave orphan
term-relation rows (acceptable); the remaining work is bridging the canonical
e-class back to a consumer's expected enode form via the view proof (Sym/Trans)
in prove-exists / remove_globals. Proof suite currently 151/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the plan's natural-term + canonical-term + Congr + set-if-empty
threading in add_term_and_view: builds the natural term (children at as-built
ids) and canonical term (children at view-deduped ids), connects them with a
Congr chain over changed children, dedups via set-if-empty, and records
(natural, connector: natural = deduped) in nat_conn for the parent's Congr.
Returns the deduped e-class so parents/views stay canonical.

Fixes some proof tests (e.g. eqsat_basic). Remaining: the root union still
builds its Rule-proof AST from the deduped e-class (whose AST floats to a
unioned form), so it must consume the connector (Trans of rule[L=nat] with
connector[nat=dedup]); the UF-edge orientation makes that intricate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The root union now routes its proof through the operands' natural forms (whose
ASTs are pinned to the enode the rule built) instead of the deduped e-classes
(whose ASTs float once unioned), then orients to the larger=smaller UF edge with
proof-of-max/min over a shared natural form. Fixes proof checking for the
term-construction plan (the (Neg (Add a (Add b c))) example proves cleanly) and
simple rewrites. Remaining: complex/saturating rewrites where the *matched* LHS
(a body var, no natural form recorded) has itself been unioned and its AST
floats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…en term

The canonical-children term (fv_can) was given a fresh @rule term proof, which
the checker validates against the rule head via check_rule_produces_equality.
But fv_can is built with *deduped* children, which can extract to a canonicalized
shape (a subterm rewritten elsewhere, e.g. matrix's nrows(NamedMat B) -> NamedDim
m) that no longer matches the syntactic rule head -> 'rule head doesn't produce
claimed equality'.

Per the plan, fv_can's proof should be the reflexive Trans(Sym(e-to-e'), e-to-e')
derived from the Congr chain, not a @rule -- so it is exempt from the rule-head
check and serves as the view's 'eclass = f(children)' proof. The natural term
keeps its @rule (its children are the as-matched forms, which do match the head).

Fixes matrix/naturals/fibonacci proof-testing; 'rule head' failures 14 -> 2
(remaining: luminal complex rule + pre-existing @p1 custom-merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erms

Root cause of the proof-mode canonicalization failures on deeply-nested rules
(e.g. matrix, and the constructor parts of luminal/hardboiled_conv1d):

instrument_action's Let case bound the let variable to the built term's deduped
e-class var but did NOT carry the term's nat_conn entry onto the let name. So a
rule like `(let new-e (Bop ...)) (union e new-e)` looked up nat_conn["new-e"],
missed, and `union` fell into the no-connector branch: a bare @rule over
ordering-max/min(e, new-e) whose endpoint extracts the *deduped* (canonicalized)
shape, while process_actions builds the syntactic shape -> "rule head doesn't
produce claimed equality". Fix: propagate nat_conn from the result var to the
let-bound name.

Also keep the natural node *unseeded*: always mint a separate canonical-children
node to seed the view (never collapse to seeding fv_nat), so the natural is never
pulled into the view's congruence :merge (native UnionId) and stays as-built.
Otherwise a birewrite re-keying the view to a differently-shaped partner
(IntImm32 x <-> IntImm 32 x) natively merges the natural into the partner and its
@rule endpoint extracts the wrong shape.

Result: matrix, naturals, fib and all other constructor-based proofs prove
cleanly. Remaining: luminal still fails on its `vec-of` container (containers are
built with deduped elements and their @rule extracts the canonical shape rather
than threading the natural like constructors -- a separate follow-up), and the
pre-existing @p1 custom-merge bug is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The connector-threading fix changed the instrumented proof structure, so all
proof-testing snapshots for passing constructor-based proofs are regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nat_conn maps an instrumentation unit's freshly-minted vars (and let-bound
names) to their natural id + connector; it is local to a single generated
program. The connector-threading fix keys it by the user's let-variable names,
which repeat across rules, so without resetting it, stale entries leak between
rules/merges and can reference out-of-scope vars ("Unbound symbol @vn"). Clear
nat_conn at the start of each rule, merge function, and global action.

No current test changes result; this prevents cross-unit leaks (which a later
container change would otherwise surface).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes luminal's vec-of container proof failure with the container rebuild
itself, rather than an action-side connector.

A container built over deduped element ids has a term-proof whose endpoint
extracts the canonicalized (birewrite-partner) shape instead of the syntactic
one -> "rule head doesn't produce claimed equality" for a parent term. Building
over the *natural* (as-built) element ids fixes the proof, but naturals have no
union-find entry, so the container rebuild can't canonicalize them and the
computation breaks.

Fix: add a per-eq-sort auxiliary union-find `@UF-Aux-<Sort>` mapping a natural
id to its canonical dedup id plus the connector proof `natural = canonical`.
The container is built over natural element ids (so the term-proof extracts the
syntactic shape); at build time each element's `natural -> (canonical,
connector)` edge is written to `UF-Aux`. The Rust container rebuild
(`rebuild_container_value_rec` / `rebuild_container_proof_rec`) now consults
`UF-Aux` in addition to the main `UF`, chaining `natural ->(UF-Aux)-> canonical
->(UF)-> leader` and composing the connector with the UF proof via `Trans`. So
the rebuild canonicalizes the elements and proves it with the existing
machinery. `UF-Aux` is written only for container elements. Deterministic table
name (`@UF-Aux-<Sort>`) so the rebuild needs no extra plumbing.

Natural-element building is applied to `vec-of` only: it makes elements change
during rebuild, which triggers the rebuild's per-element `@Congr` fold, and that
fold is positional (sound only for ordered containers). Sets/maps keep the
deduped path; an order-independent rebuild proof for a birewrite-canonicalized
unordered-container element is a follow-up (no current test needs it).

Result: luminal (both variants) and all container tests prove cleanly, with no
proof-snapshot changes; only the pre-existing @p1 custom-merge failures remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix the custom-merge (@p1) and lambda evals-to proof-encoding failures by
adopting egglog PR #933's children-free merge-proof mechanism while keeping this
branch's canonical-id (set-if-empty) encoding.

- Proof format/checker: add term-free MergeFnIdx(fn, old, new, idx) and
  MergeFnRow(fn, old, new) proof variants. The conclusion is reconstructed by
  evaluating the merge sub-expression (idx = pre-order position) on the premise
  outputs (subexpr_at_index + run_merge_subexpr), so a :merge action needs
  neither the function key nor an AST. Adds reflexivize_premise.
- Custom-merge encoding: the hidden `current` helper is now 2-column
  (Out, Proof) for eq-sort/eq-container outputs, carrying the view proof so its
  :merge builds MergeFnIdx per subterm + MergeFnRow for the row (via
  add_term_and_view / set-if-empty). No rule-scoped @p1/@p2 in the :merge.
- Reflexivize custom-function-fact rule premises: rebuild can rewrite a matched
  (= (f args) v) premise into a non-reflexive natural->canonical Congr; the
  checker's function-fact normal form requires a reflexive premise.

Proof suite: 180/8 -> 186/2. The 2 remaining (complex_merge_func, rw_analysis
proof-testing) fail only on a shared extraction-count snapshot that also differs
in term_encoding mode (no proofs) -- a separate pre-existing issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adopt egglog PR #933's approach for custom merge functions: the user merge runs
once, in the function's FD pair-valued view's own :merge, instead of a separate
rebuild rule + a `current` helper table. This eliminates a double-merge (the rule
fired on the view-key conflict AND `current`'s :merge fired on the current-key
conflict), which minted over-merged extra term rows -- encoded extraction now
matches normal mode. Canonical ids (set-if-empty) and the children-free
MergeFnIdx/MergeFnRow proofs are unchanged.

- Custom-with-merge functions now use the FD pair-valued view (is_fd_view /
  fd_custom_funcs). `custom_view_merge` runs the merge body once via
  instrument_merge_body (MergeFnIdx subterm proofs) + a MergeFnRow row proof,
  with NO @uf union (unlike constructor congruence).
- Deleted handle_merge_fn, the `current` helper, the rebuild rule, and the
  cleanup rules; removed the now-dead Justification::Merge variant.
- Added the `select-eq` primitive to keep the FD-view merge's proof column stable
  (reuse a premise proof when the merge is idempotent) so merges saturate.
- Routed the query (instrument_fact), write (add_term_and_view), and rebuild
  paths to the FD form for custom-with-merge functions.

Full files suite green (751 passed); proof-testing 188/0. Regenerated the
repro_equal_constant + rw_analysis proof snapshots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Groundwork for running the relations/proofs encoding on the differential-dataflow
backend (egglog-experimental/dd):
- Fix the egglog-experimental build (set_cost.rs: add the new `cost` field to its
  Function commands).
- Unify DD's fresh-id minting onto a shared core-relations counter and add an
  eclass_id_counter() override, so the encoding's `(@get-fresh-<Sort>!)` primitive
  registers and its ids cannot collide with native FreshId ids.
- Add Database::set_counter for the DD merge-rollback path.

DD unit tests 34/34, core-relations 54/54; the non-eq-sort DD corpus passes. Full
eq-sort support still needs get-fresh!/set-if-empty exposed through the backend
SPI (so DD can service them against its host-side mirror) -- follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The term/proof encoding mints eq-sort ids via `(@get-fresh-<Sort>!)` and
canonicalizes constructor terms via `(@set-if-empty-<View>!)`. These were
`WritePrim`s that reached into core-relations db tables and were gated on the
backend having an `ActionRegistry`, so they only worked on the core-relations
reference backend. Expose them as backend operations instead:

- Add `Backend::register_get_fresh` / `register_set_if_empty` /
  `register_view_proof`. `register_get_fresh` has a uniform default minting from
  the backend's eq-class id counter; the other two default to a loud panic and
  are overridden per backend.
- The reference backend (egglog-bridge) implements set-if-empty/view-proof over
  its tables (behavior-preserving). The differential-dataflow backend implements
  them over its host-side `mirror` (intercepted in both `apply_head` and inside a
  view's `:merge` transaction).
- Route the encoder's primitives (proof_fresh.rs) through the SPI via a new
  `add_backend_op_primitive`, dropping the `action_registry()` gate for these
  three ops (retained for genuine registry reads, still unsupported on DD).

Reference backend: full `files` suite 756/0 (no regressions). DD backend now runs
eq-sort programs under term/proof encoding (full DD corpus 131/0; unit 34/34;
core-relations 54/54).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes surfaced while validating the backend/merge work:

- prove_exists panicked ("expected rule justification for existence proof") when
  a constructor's witness row had a non-rule existence proof. A valid existence
  proof need not be rule-justified (it may be Fiat/Merge/Congr); the rule case is
  only an optimization to strip a single-premise wrapping rule. Use the proof
  as-is otherwise (check_proof still validates it). This was order-dependent: the
  witness is a constructor's first row, so a backend iterating rows in a different
  order (the differential-dataflow backend's HashSet mirror) surfaced a non-rule
  row and panicked nondeterministically. It also fixed container_rebuild (4 tests
  hit it deterministically on the reference backend: container witnesses have
  ContainerNormalize/Congr existence proofs) -> container_rebuild 16/0.

- Custom-with-merge FD views with an eq-sort output now canonicalize the output
  column when it is unioned to a smaller leader, via delete+set (re-setting the
  same key would re-run the user merge). Without this, a custom eq-sort output
  later unioned stayed non-canonical and `check` could miss it. (This block was
  authored during the merge re-architecture but landed after that commit.)

files suite 756/0; container_rebuild 16/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the per-sort `@get-fresh-<Sort>!` mint primitives with one generic
`get-fresh!` that takes the target sort as a string literal: `(get-fresh! "Math")`.
Its runtime ignores the arg (the shared eq-class id counter is sort-agnostic); the
string only types the output. This gives the desugared program a stable,
always-registered name instead of per-sort fresh names that a name-sanitizer
mangles on re-parse (so a plain e-graph can re-parse encoded output).

The type constraint has dual behavior: at type-check time it reads the string
literal and assigns the output eq-sort; at resolution/`accept` time (which runs
constraints over placeholder int literals, not the real string) it just requires
the first arg to be a String, letting the output sort come from the resolved
types. Registered once, idempotently, from the first eq-sort declaration.

files suite 756/0. (set-if-empty/view-proof are still per-view — a follow-up;
the eqsat_basic roundtrip needs those generic too.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These per-view mint/canonicalize primitives were named `@set-if-empty-<view>!`
and `@view-proof-<view>`. The `@` is an internal-symbol marker, so a name
sanitizer (e.g. the term-encoding roundtrip test) rewrites it — but the
primitive is re-registered by re-deriving the name with the `@` intact, so the
sanitized reference (`___set-if-empty-...`) no longer resolves to the registered
`@set-if-empty-...`, and re-parsing the desugared program fails with
UnboundFunction.

Drop the `@`: the stable `set-if-empty-`/`view-proof-` prefix now carries no
marker (so the sanitizer leaves it alone), while the embedded view name — a
fresh internal symbol — is rewritten identically at both the reference and the
re-registration, so the primitive stays resolvable. This restores
self-containedness of the desugared program (a plain e-graph can re-parse it).

Fixes eqsat_basic_term_encoding_roundtrip. files 756/0, integration_test 48/0,
container_rebuild 16/0, proof_mode_regression 9/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lobals)

In proof/term mode, `(input …)` lowered to one top-level fiat action per CSV row,
and term-encoding expanded each into ~40 top-level `(let @v …)` globals.
`proof_global_remover` turns every global into its own Constructor function, so
loading N input facts created ~O(N) functions and ran ~O(N) one-shot rules —
super-linear in practice (pointer-analysis proofs: ~9.2s, 380 MiB peak).

Now each `(input …)` lowers, for execution, to a single bodyless rule whose head
holds all rows' fiat actions, run once. The mints become local rule-head lets, so
no per-mint function is created and the input loads in one rule execution. The
head is instrumented with `Fiat`, so proofs are unchanged and no loader rule ever
appears in a proof; the proof checker keeps using the per-row top-level form
(`desugared_before_proofs`).

pointer-analysis-small: reference proofs 9.2s→0.90s, DD proofs 6.3s→1.25s; term
and peak RSS also improved. files 756/0, proof_mode_regression 9/0, and the other
proof suites stay green; `--proof-testing` passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In term/proof mode, `(input …)` now inserts the encoded term/view (and, in proof
mode, AST/proof) rows straight into the backend, rather than compiling and running
a rule. Adds a small backend SPI — `fresh_eclass_id()` + `add_values(rows)` —
serviced by each backend against its own storage (reference-bridge db buffers; the
DD host-side mirror, with no fallback). `EGraph::native_input` mints a term id per
row and plain-inserts the term relation, FD view, and (proof mode)
`@Ast`/`@Fiat`/`@…Proof` rows via `add_values`; duplicate view keys are resolved by
the view `:merge`, so there is no get-or-insert.

Only constructor-subtype functions take the native path — constructors and
relations (which desugar to constructors). Plain custom functions (with or without
`:merge`, including `:no-merge` Unit-output ones) still go through the loader rule;
`lower_inputs_for_execution` routes by subtype. The proof checker keeps consuming
the per-row top-level fiat actions (`desugared_before_proofs`), so proofs are
unchanged.

pointer-analysis-small proofs: 0.90s→0.098s (reference), 1.25s→0.47s (DD); term
0.17s→0.055s. files 756/0, proof_mode_regression (non-luminal) green, DD corpus
unchanged, and `--proof-testing` passes on both backends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…coding

Switch the term/proof encoding to the function-style `remove_globals` pass:
a global `(let x e)` becomes a nullary `:internal-let` function `set` to its
value, instead of a constructor plus a top-level `union`. The global now gets
a functional-dependency view and rebuild rules like any other function, and
references become the lookup `(x)`.

- Encoder: globals are `output_is_eclass` (their output value *is* their
  e-class, like a constructor), so the term relation has no separate output
  column and the view is the congruence FD `() -> (eclass, proof)`. Added a
  global-lookup code path for actions/facts (the one custom lookup proof normal
  form allows) and a global-`set` path that stores value+proof in the view.
- Rebuild: route globals to the constructor-style e-class rebuild (which
  composes the union edge into the view proof) instead of the custom-output
  congruence rebuild, which emitted a nonsensical `Congr` on a nullary term and
  broke proof reconstruction (transitivity middle-term mismatch).
- Skip the post-command rebuild after every non-`union` top-level action: a
  `let`/`set`/insert aliases or dedups under a fresh view key and can't merge
  e-classes, so N global definitions no longer trigger N rebuilds. Any
  non-canonical id is fixed by the next real rebuild, as native defers it.
- Remove the now-unused `proof_global_remover` pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`Database::merge_all` reset the cached column/key indexes of *every* table and
re-summed every table's length on each call, regardless of which tables actually
changed. That is O(all tables) per call, and quadratic when many small tables
each trigger a merge — e.g. a long run of global-`let` definitions under the
term/proof encoding, where each global adds a one-row view table and runs a
one-shot action (→ merge_all). A 1600-global proof chain did ~1.1 billion
table-index resets.

Track the tables modified during the call (the notification list, accumulated
here and in `merge_simple`) and reset only those. Unmodified tables keep their
valid cached indexes — their table version is unchanged, so `Index::refresh`
would be a no-op anyway (same reasoning as `clear_table`). `total_size_estimate`
is maintained incrementally at each merge (mirroring `merge_table`) instead of
re-summed. Same 1600-global chain now does ~62k resets (~18,000x fewer); proof
time roughly halves and the remaining growth is the per-one-shot-action overhead,
not merge_all.

Correctness: core-relations 51/51, egglog + egglog-experimental suites show no
new failures (only pre-existing snapshot diffs), proof-mode file sweep identical
to baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ndex

`free_external_func` scanned the entire `panic_funcs` map on every call
(`retain` looking for the entry whose `id` matches), because the map is keyed by
message but freed by id. That scan ran even for non-panic external functions
(e.g. the term/proof encoding's `get-fresh!` / `set-if-empty`), and `panic_funcs`
grows with the program, so freeing the many short-lived one-shot action rules —
one set per encoded global definition, ~38 per global — was O(panic_funcs) each,
i.e. quadratic overall. On a 4000-global proof chain this was the dominant cost.

Add a reverse `id -> message` index so a free finds its cache entry (or
determines the func is not a cached panic) in O(1). The index is a `BTreeMap` so
it introduces no new randomly-seeded hasher and leaves the seed sequence — and
thus other tables' iteration order, which order-dependent proof extraction reads
— unchanged.

Perf: 4000-global proof chain 37s -> 12s (on top of the merge_all fix); scaling
is now near-linear. Correctness: proof-mode file sweep identical to baseline,
`cargo test -p egglog` unchanged (only the 3 pre-existing snapshot diffs). The
free/keep semantics are preserved exactly: a cached panic with >1 reference is
decremented; its last reference, or any non-cached func, is freed from the db.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`prove_exists` took whichever row the backend yielded first as the witness, and
the root extractor reconstructed from the first matching row. On a backend with
nondeterministic row order — the differential-dataflow backend mirrors each
relation in a `hashbrown::HashSet`, whose iteration order varies per process —
the extracted (always-valid) existence proof therefore varied run to run, so the
`*_proof_testing` snapshot tests were flaky (~40% failure independent of any
recent change).

Select the lexicographically-smallest matching row in both places instead. The
proof is unchanged in validity; it is just now the same one every run. This
removes the flakiness: the full `egglog-experimental` proof suite is now 46/0
deterministically (was ~40-41 passing with 5-6 flaky). Regenerated the one
snapshot (`with_ruleset`) whose stored proof reflected an old nondeterministic
witness; its proof still checks (`--proof-testing` passes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ncoding

Three `proofs::proof_tests` tests were stale after the encoding moved terms and
proof constructors from egglog constructors to relations (`get-fresh!` mint +
`set-if-empty` view) and globals to function style:

- doc_example_add_function1/2: regenerated the encoding snapshots (now show the
  `(function Add (..) Unit)` term relation, `get-fresh!`/`set-if-empty` minting,
  and the function-style global) — the intended output, verified by reading the
  diff.
- proof_encoding_hoists_unnamed_rule_name_in_actions: `@Rule` proofs are now
  emitted as `(set (@rule ..) ())` actions rather than call expressions, so the
  `visit_exprs` count saw zero. Count the `@Rule` set actions instead; they still
  reuse the hoisted rule-name variable and there is more than one, so the test's
  intent is preserved. (The program's proof already checks under `--proof-testing`.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The proof-tracking docs only showed flat, one-level term construction and still
described it in the pre-relations constructor-call style. Add a section that
traces `(union rewrite_var (Neg (Add a (Add b c))))` end to end in the actual
relations encoding: flattening, `get-fresh!` term minting, `set-if-empty` view
interning to canonical ids, the natural-vs-canonical node split, `Congr` over
moved children via `natural = canonical` connectors, and the final oriented
union edge. Also flag the earlier flat rule snippet as schematic and point to
the new section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reorganize the encoding doc so the data structures come first, then actions
(construction, union, delete), then queries, then the rebuilding machinery, then
globals and containers — instead of interleaving term-mode and proof-mode passes
over one example. Along the way, replace the stale pre-relations snippets (which
still showed constructor-call term building) with the actual relations encoding:
`get-fresh!` minting, `set-if-empty` / `view-proof` view interning, and the
function-style term relation. The nested `(Neg (Add a (Add b c)))` walk-through
from the previous commit becomes the "building nested terms with proofs"
subsection under Actions. Snippets were generated from the real encoder output;
the running Add example and the exercised Neg example both pass `--proof-testing`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
oflatt and others added 25 commits July 20, 2026 19:53
…end)

Reverts the earlier removal: set_counter was not dead — egglog-experimental
/dd/src/lib.rs uses it to roll a counter back. The egglog files test doesn't
build the dd crate, so the breakage was missed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
native_input now derives everything from the encoded decls' annotations
(like extraction) instead of proof_state.proof_names + original_typechecking:
term relation by name, view via its term_constructor back-reference, CSV
base-column sorts + the constructor/:merge/:no-merge shape from the schema
(is_fd_view + arity), and proof tables from replay-safe annotations (Fiat
added to the Proof sort's :internal-proof-names; the AST constructor by its
(<sort> <Ast>)->Unit signature; <Sort>Proof via :internal-proof-func). The
(input ...) dispatch keys on is_relation_term() rather than proof mode.

This lets custom-function inputs load natively too, so the bodyless loader
rule + input_loader_rulesets are gone, and — because the load is now
self-contained on the encoded tables — a desugared proof-mode program
replays correctly in a plain e-graph (the desugar+proof round-trip that
previously had to be skipped for (input ...) files now runs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(fail c1 c2 ...) runs the wrapped commands in order and passes iff one of
them errors. This lets fail wrap a block that fails somewhere within it, and
in particular lets proof mode wrap (fail (set ...)) — a set encodes to many
commands, so the old single-command fail (and its FailNonAtomicCommand gate)
couldn't. Desugar/remove-globals now wrap the whole expanded block, not just
the last command.

set_sort_function.egg (an eq-sort :no-merge conflict wrapped in fail) is
moved from the auto proof-unsupported list to an explicit, documented
MANUAL_PROOF_DISABLED_FILES entry: making that conflict fire eagerly needs
native :no-merge, which requires a conditional/UF-aware panic in the merge
machinery (a separate follow-up) — panic doesn't lower inside a :merge today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…values

- nat_conn is now a local map threaded through the action/term builders
  instead of a shared field that was cleared per generated program (C6).
- Add an output_is_eclass(fdecl) helper (constructor || encoded global) and
  use it in term_and_view + rebuilding_rules; is_fd_view delegates its
  internal-let check to is_encoded_global (C4).
- DD backend: route add_values through the merge-aware apply_sets so
  same-key/different-value rows are collapsed by each function's :merge
  (FD-view congruence, :no-merge rejection) instead of coexisting raw;
  stash any error in the panic side channel to surface on the next run (C1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
:no-merge functions were the last merge/conflict logic encoded as a *rule*
(the eq-sort conflict-panic rule in rebuilding_ruleset) plus a primitive-
output 'current' helper. Rather than build new merge machinery to detect
eq-sort conflicts eagerly, mark :no-merge functions unsupported by the
encoding (a new NoMergeFunction reason in command_supports_proof_encoding,
gated on Function{merge:None, let_binding:false} so encoded globals and
constructors are excluded). :no-merge files now run in plain mode only,
where the native backend handles them.

Deletes handle_no_merge_fn (both branches), handle_merge_or_congruence,
the merge_current field + its update_view/native_input uses, and the
:no-merge view-declaration branch — removing the merge-as-a-rule path
entirely; all merge/conflict logic now lives in the view's :merge.

The eggcc-2mm benchmark (egglog + the proof-tested fixture) is converted to
:merge old so it stays proof-supported. 12 genuinely-:no-merge files move to
the proof-unsupported list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Only eq-sort-output :no-merge stays unsupported (needs union-find-aware
conflict detection). A primitive/Unit :no-merge custom is now the FD
pair-valued view (children) -> (output, proof) declared :no-merge with
:internal-identity-vals 1: identity-vals skips on an unchanged raw output
(ignoring the proof column) and the native :no-merge AssertEq panics on a
genuine output conflict. No current helper, no rule.

Since every encoded custom is now FD-viewed (:merge or primitive :no-merge;
eq-sort :no-merge is never encoded), fd_custom_funcs is redundant: removed
the field, its population, func_type_is_fd_view, and the query is_fd branch
(add_term_and_view always uses the FD view). Resolves the fd_custom_funcs
review comment by removal.

10 files regain term/proof coverage; lambda + set_sort_function stay
unsupported (eq-sort :no-merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Extract the duplicated rule-emission template into a rebuild_rule helper.
- Fold the two near-duplicate value-column rebuild blocks into one
  fd_value_rebuild_rule over a ValueRebuild { Eclass, CustomOutput } enum,
  written as if/else (they are mutually exclusive); only the proof and the
  update action differ per arm.
- Reword the confusing 'original behaviour' comment; tighten the
  rebuilding-rules and vec-of comments; clarify that the vec-of rebuild is
  ordered-container-only because the threaded proof is a positional Congr
  fold (the Rust rebuild itself is generic).

Behavior-preserving; no snapshot changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- @UF-Aux-<Sort> is now a fresh UF_Aux_<Sort> name recorded in an
  :internal-uf-aux annotation (carried on the sort's uf tuple) and
  rediscovered on re-parse via uf_aux_parent, instead of a hardcoded name
  the Rust container rebuild recomputed. The rebuild reads it from a
  collected aux_names map (lookup_aux_row).
- add_term_and_view's proof branch now mints all proof terms first, then
  emits the anchor/dedup/view-proof statements, then the vprf-dependent
  connector mints — same behavior, less interleaving; comments tightened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EGGLOG_DD_TIMING=1 prints per-iteration phase times (diff/dd_step/envs/
head/writes) plus delta and binding counts. EGGLOG_DD_DUMP_PLANS=1 dumps
each rule's naive join order, flagging cartesian stages, and arrangement
counts per fused dataflow. EGGLOG_DD_W=<n> overrides the fixed row width
at compile time for width experiments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three changes to shrink what every arrangement stores and compares:

- RowN<const WIDTH>: the fused dataflow is monomorphized over a width
  ladder {8, 16, 32, 48} and each ruleset runs at the smallest width that
  fits its plans (JoinPlan::width), instead of a global [u32; 48]. W = 48
  remains the planning cap.
- Join keys are a single u128 (up to 4 columns packed exactly; wider key
  sets fold the tail into the top lane, with exactness restored by the
  compiled shared-variable checks in the join closure) instead of a
  full-width row.
- Per-tuple bind/remap closures run AtomOps slot programs compiled once
  at dataflow-build time (const/dup/carry/check/write index lists),
  replacing per-row HashMap lookups and Vec allocations.

math-microbenchmark (run 11) drops 211s -> 89s; the DD-internal share of
run 10 (dd_step) drops 11.1s -> 2.6s. Full corpus: 131/131 snapshots
match; math run 10/11 print-size output is bit-identical to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every iteration previously paid O(database) three times over: feeding DD
required a full version-map compare AND clone per read view, each merge
transaction snapshotted whole functions into key->row maps, and the
interpreter rebuilt a per-iteration lookup index per touched function
(plus lookup_row scanned linearly). All three now run on incremental
structures maintained at the existing row-event choke point:

- by_key: a persistent per-function key->located-rows index updated by
  record_row_event. Merge transactions read it lazily through a staged
  overlay (no per-transaction snapshots), lookups and exact-key
  removals are O(1) per key, and the per-iteration LookupIndex is gone.
- event_logs + dd_fused_cursors: per-view append-only signed row logs
  replace the three version maps and per-ruleset fed snapshots. Each
  fused worker folds only its unread window (net weight + last sign per
  row reproduces the remove/insert/refire batches exactly, without
  replaying transients); new workers seed from the full current state;
  fully-consumed prefixes are drained.

math-microbenchmark run 11: 89s -> 72s; run-10 diff phase 1.09s ->
0.31s and events scanned drop from 5.6M (full state) to 1.3M (actual
deltas). Full corpus: 131/131; run 9/10/11 outputs bit-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One arrangement per (relation view, key-column projection) per ruleset,
used by the right side of every stage AND both sides of each rule's
first join: stage-1 joins run arranged-vs-arranged over the shared raw
relations, with the bind/remap slot programs moved inside the join
closure (bind is injective on surviving rows, so multiplicities are
unchanged). Per-rule private stage-1 arrangements and their flat_map
stages disappear; only intermediates of 3+-atom rules still arrange
privately. Also net captured output deltas keyed by the Copy row type,
allocating only for surviving rows.

Honest measurement: on math-microbenchmark this is ~neutral (run 11
71.8s -> 70.1s) — the workload's join-key projections are genuinely
distinct (the 49-rule rebuilding ruleset still needs 50 arrangements),
so there was little to dedup. Where projections do coincide the counts
drop (user ruleset: 33 -> 21). Corpus 131/131; outputs bit-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feasibility spike for moving the term encoding's rebuild loop (union-find
canonicalization + congruence closure) INSIDE the dataflow instead of
driving it via host (saturate ...) rounds, following the flowlog
architecture (fixpoints in iterate scopes; host only commits deltas and
reads outputs).

Formulation: one loop variable labels(x) seeded as identity; congruence
collisions are DERIVED from labels inside the loop (canonicalize term
rows through labels; same (op, canonical children) with distinct eclass
labels emits an ordering-min union edge), and labels re-close by min
propagation over user + congruence edges. Both directions of the mutual
recursion live in one Collection::iterate.

Tests show two-level congruence cascades converge inside a single epoch
and extend incrementally across epochs. docs/rebuild-in-dataflow.md
records the integration design, expected impact (epochs ~225 -> ~30 at
run 11; rebuild's version-bump churn and ~5M host merge sets vanish),
the proof-mode/ordering/delete walls, and next steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…model

Records the agreed direction: compile (rules + schedule) into one dataflow
via a run_schedule backend-API extension (seq = dependency chaining,
saturate = iterate scopes, run-N = gated feedback), with user-rule
saturation in-dataflow as the goal.

Two history-sensitive operators carry egglog's semantics on top of DD's
view maintenance: a memoizing mint (append-only key->id dictionary; replay
returns the same id, so retraction deltas cancel exactly and counter-id
parity is kept) and a rising-edge fire operator (one effect event per
0->positive binding transition, nothing on falling edges) — because
egglog's (delete ...) is DATA, not view retraction: consequences of a
match persist after the trigger row is deleted. Table state is the
integral of an append-only event stream; genuine retraction stays
confined to join bookkeeping and the rebuild view layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two history-sensitive operators the schedule->DD compiler needs to
carry egglog's monotone-fire semantics on top of DD's view maintenance
(docs/rebuild-in-dataflow.md), as frontier-ordered unary operators:

- rising_edge: one +1 effect event per 0->positive transition of a
  binding's count, silence on falling edges. Applies each timestamp's
  NET delta in ascending order before detecting transitions, so a
  same-epoch insert+delete is a no-op and a remove-then-reinsert
  refires (today's version-bump semantics).
- memoizing_mint: an append-only key->id dictionary minting from a
  counter on first demand (in sorted key order per timestamp, for
  deterministic ids) and emitting a never-retracted (key, id) mapping;
  replays and re-demands mint nothing.

The test drives 'A(x) => B(x, fresh!)' across four epochs: deleting
A(1) leaves B's row intact (delete is data, not view retraction), and
re-inserting A(1) refires the rule but reuses the ORIGINAL id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The frontend's schedule interpreter (egglog/src/lib.rs run_schedule over
ResolvedSchedule::{Run, Repeat, Saturate, Sequence}) is already the tree
the backend hook needs. Plan: a ScheduleSpec tree + default-implemented
run_schedule trait method in egglog-backend-trait (main backend unchanged
by construction), frontend delegation, and a fallback rule for regions
with host-side per-iteration machinery (custom Schedulers, until
conditions) that cannot be compiled into the dataflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
egglog-backend-trait gains ScheduleSpec (Run/Repeat/Saturate/Sequence,
rulesets pre-resolved to rule ids) and an optional Backend::run_schedule
hook returning one report per executed Run leaf, defaulting to None —
the reference backend is unchanged by construction.

The frontend's run_schedule lowers ResolvedSchedule to ScheduleSpec and
delegates when the backend accepts, folding the returned leaf reports
exactly as its own interpreter would (RunReport::singleton per leaf,
unioned), so all reports including (print-stats) are bit-identical
either way. Lowering refuses trees containing an until clause (host-side
fact check per leaf visit) or an unknown ruleset; the interpreter then
retries delegation per subtree. collect_rule_ids moves to a shared
helper.

The DD backend accepts every offered tree and interprets it over
run_rules with the frontend's exact control flow — the seam where
schedule regions will next compile into native dataflow fixpoints
(docs/rebuild-in-dataflow.md).

Validation: full egglog crate suite passes; DD corpus 131/131 with
delegation active on every file; math run 9/10 outputs bit-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Promotes rising_edge/memoizing_mint into src/monotone.rs (the compiler
will consume them) and prototypes the two schedule-region mechanisms the
general any-schedule compiler still needed:

- (run N) as gated feedback: a Variable loop whose feedback stream is
  filtered to rounds < N runs at most N bounded hops inside ONE epoch,
  with early convergence for free — matching the frontend's Repeat loop
  exactly on a successor-chain workload.
- Minting inside a saturation scope: memoizing_mint runs at Product
  timestamps (their derived lexicographic Ord refines the lattice order
  for frontier-complete times), assigning ids deterministically in round
  order as the fixpoint grows, and the run-N gate caps minting to what
  the bounded region actually reaches.

Design decisions recorded: the compiler handles ANY schedule with a mint
stage at every fresh-id site (no mint-free-region analysis), and
get-fresh! will take the hash-cons key as arguments so minting is a
keyed, declarative operation the compiler recognizes like the other
term-encoder ops — making the memo dictionary coincide with the FD
view's key->eclass map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DD's run_schedule now attempts full compilation before interpreting:
schedule_compiler lowers a ScheduleSpec tree into a single dataflow —
Sequence chains table states through leaves, Saturate opens a nested
scope whose feedback Variables carry each written table to fixpoint,
Repeat(n) gates that feedback to rounds < n (early convergence free,
Repeat(0) provably a no-op: loops leave 'seed union gated feedback'),
and a Run leaf joins rule bodies (reusing plan_join/AtomOps/u128 keys)
against the incoming state with match-then-apply leaf semantics. Final
states drain into the host tables through the event-recording inserts.

v1 compiles the insert-only datalog subset (pure Live table-atom
bodies; Set-only heads with agreed constant value columns; one loop
level) and falls back to the interpreter otherwise, with env-gated
reasons (EGGLOG_DD_DUMP_PLANS=1). Unit tests pin compiled == interpreted
for saturation and bounded runs; corpus 131/131 with the compiler live.

Fallback probing surfaced the key next wall: under the term encoding
EVERY head mints — even a relation head runs (get-fresh! "@sort") for
its hidden per-row epoch id — so the keyed get-fresh! redesign (mint as
a declarative keyed op the compiler lowers to the mint stage) is the
gating step for compiling real term-encoded schedules, ahead of merges
and nested loops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the schedule compiler to the term encoding's head idiom, which
probing showed EVERY head uses (even relations mint their per-row epoch
id): a head-action interpreter lowers 'Let v = get-fresh!(sort)' to a
memoizing-mint stage keyed by (rule, binding) — ids drawn from a range
reserved on the host counter and consumed after the run — and
'set-if-empty-<view>!' to a new first_per_key latch per (leaf, view):
candidates race per key, the earliest round's minimum wins once, and
pre-existing view keys are primed as zero-priority sentinels. LetAtomTerm
aliases resolve through both; reading a hash-cons RESULT (constructors)
still falls back.

DD's execute_schedule now retries compilation on every loop-bearing
subtree before interpreting it (the whole-tree attempt may fail on a
sibling, e.g. the rebuild sequence the encoding splices into each user
iteration); loop-free leaves stay on the incremental fused path.

Fixes a livelock the new unit tests caught: the monotone operators held
a CapabilitySet parked at the input frontier, which on a feedback path
keeps the loop's rounds advancing forever. They now retain capabilities
only for pending queued data (upsert-style) and drop them when drained,
so loops terminate.

Unit tests pin: replay-stable per-binding ids in deterministic order
with correct counter advancement, and set-if-empty respecting existing
keys. Corpus 131/131; mini-path and math outputs unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the general (any-merge) lowering ahead of the semilattice-optimized
one: merge_hub is ONE stateful operator hosting every written table's
authoritative fold state in a compiled region, mirroring the host
MergeTransaction at each timestamp — set-if-empty first (head-time on
the host), then batched deletes, then merge-aware sets in WAVES to a
fixed point (sorted by merge-dependency level; merge-block set actions
like the union-find loser edge join the next wave). Waves run inside
one timestamp, so Repeat budgets are untouched, exactly the host's
waves-within-iteration model.

The compiler's write model becomes one cycle per writing loop:
state = seeds ∪ hub deltas; bindings join over state; latched tagged
write ops feed the hub through a round-incrementing Variable, so
effects from round r apply at r+1 — matches always see the previous
iteration's state. Rounds are host iterations; the Repeat gate filters
fed effects. Per-table Variables and the first_per_key wiring collapse
into this structure; deletes lower to hub ops.

Merge primitives evaluate against a cloned Database, guarded
dynamically: a result that is not an argument echo or the unit rep
aborts the run BEFORE any host mutation, caches the primitive as
uncompilable (eg.unsafe_prims), and falls back to the interpreter.
Merge table lookups (MergeFn::Function/Lookup) are gated out; writes
must live in one loop with one write-bearing leaf (per-leaf phasing
comes later).

Unit tests pin: UnionId folds colliding writes with existing rows,
hash-cons respects existing keys through the hub, mint ordering and
counter advancement, and AssertEq violations surface as the host's
error with no host mutation. Corpus 131/131; mini-path and math
outputs unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
run_schedule hook (done, needs review), primitive metadata, keyed
get-fresh!, and the shared append-only interner — in payoff order, with
nested loops and multi-leaf phasing on the critical path needing no
shared-crate changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The schedule engine compiles an entire ScheduleSpec tree (Run/Repeat/
Saturate/Sequence, spliced rebuild included) into ONE differential
dataflow: a stateful ScheduleEngine operator walks the schedule as a
stack machine, fires monotone-fire rule turns, evaluates merges/mints/
set-if-empties, and emits row deltas; DD does only the incremental
joins, fed back through a root-scope Variable so round r effects match
at r+1. Replaces the merge-hub prototype (deleted).

Performance work, verified bit-identical on math-microbenchmark at
every step (run9/run10/run11 = ~1.0s / 6.8s / ~103s vs the hybrid's
0.8s / 5.5s / 70s; the first working engine was 16.6s / 231s / n.a.):

- cost-based join ordering in plan_join_with: per-view row counts +
  sampled per-column distincts, exhaustive permutation scoring (rules
  have <=4 atoms). One bad body order (factorization rule joining
  Mul x Mul on one shared var) was 90% of all arrangement traffic.
- width-ladder monomorphization of the engine dataflow ({8,16,32,48}),
  as the fused path already did; the engine ran everything at RowN<48>.
- one partition demux by (func, loc) instead of ~85 per-view filters
  (Tee clones every batch per subscriber), and one N-way concatenate
  for match streams instead of ~200 chained binary concats.
- engine feedback in the flat root u32 scope (4-byte timestamps, no
  subgraph layer); completion is probe.done() after dropping inputs.
- shared (view, key-cols) arrangements on both sides of first joins.
- per-timestamp buckets in the engine's pending queue (the BinaryHeap
  did 73.5M pushes of 72-byte payloads on run 11).
- replay cache for quiescent schedules keyed on spec + a global
  row-event watermark: print-size/print-stats each splice a rebuild
  schedule that cost ~6s at run-11 scale to fire nothing. Only no-op
  runs are cached (budget-limited runs must re-run).

Also: env-gated diagnostics (EGGLOG_DD_ENGINE_DEBUG phase laps, turn
gaps, per-rule match traffic; EGGLOG_DD_VOLUMES arrangement volumes;
EGGLOG_DD_NO_ORDER planner escape hatch) and an optional pprof feature
writing per-invocation flamegraphs. ScheduleLeafReport gains Clone.

Corpus 131/131, dd unit+integration suites green, egglog suite 996/0
(the one smoke failure predates this branch). Remaining gap vs the
hybrid is measured: 73.5M match deltas through the engine on run 11,
56% from the Add-associativity rule; next step is the persistent
cross-invocation dataflow per the design doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compiled schedules now keep their dataflow alive across invocations:
a thread-local cache (keyed by EGraph instance id + spec fingerprint)
holds the timely worker, boot input, probe, and channels; the engine's
arrangements, table state, and match sets persist, so re-running the
same spec pays only for new deltas instead of rebuild + reseed + full
join recompute. Eleven separate (run 1)s now cost the same as one
(run 11) — 91.6s vs 95.4s at run-11 scale (103.1s with persistence
disabled via EGGLOG_DD_NO_PERSIST=1).

Mechanics:
- The engine emits its own seeds on first boot (distinct LOC_SEED_*
  codes route into join views but are filtered from the host-apply
  sink), so there are no per-table input sessions; the only input is
  the boot session.
- A BOOT marker delta resets the scheduler PC per invocation; the
  host steps with a chase loop, keeping the input frontier one round
  ahead until the engine signals done, then drains. Rounds continue
  monotonically across invocations (timely time cannot rewind).
- Reuse is guarded by watermarks: global row-event counter
  (mutation_counter in record_row_event), fresh-id position, and a
  new rules_version bumped on rule add/free. Any foreign change drops
  the entry and rebuilds; error/unsafe outcomes also drop it (the
  engine stops mid-pass). LRU-capped at 4 entries per thread.
- Fired-flag soundness across passes: delete/subsume-capable rules
  reset their fired flags each pass. A fired delete whose row was
  re-added in the SAME round never sees its match retract (the -/+
  pair cancels in the dataflow, where the interpreter's physical row
  timestamps would re-trigger it). Pure writers keep their flags,
  matching interpreter seminaive. Caught by the integer_math corpus
  test (341 vs 331 Adds).
- Per-pass mint accounting (was cumulative since construction).

Also: EGGLOG_DD_DUMP_APPLIES diagnostic; invocation watermark trace
under EGGLOG_DD_ENGINE_DEBUG.

Validation: corpus 131/131, dd suites green, egglog suite 996/0,
math run9/10/11 single-invocation outputs bit-identical to prior DD
references; split-run files semantically identical to the mainline
backend (modulo print-stats: DD reports lack per-rule num_matches —
a pre-existing backend-wide gap — and the term encoding's rulesets
don't exist on mainline).

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

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f79d38b2-a1ec-47ed-aff1-8a02ffee4496

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

oflatt and others added 4 commits July 23, 2026 00:36
Sort each round's raw match deltas and net equal-binding runs by
sequential scan instead of hash-map netting; per-(leaf,rule) state is
fetched once per group. Also: raw-volume counter, per-table emitted-row
classification and distinct-match totals under the debug env vars, and
a turn-trace diagnostic (EGGLOG_DD_TURN_TRACE).

Measured ground truth recorded in the design doc: 62.6M netted deltas
vs 17.26M distinct matches (4.26x re-derivation), churn dominated by
canonicalization rewrites (not epoch columns), and a negative result —
in-dataflow presence-edge distinct is exact but ~2x slower single-
threaded, and differential's distinct_total emits spurious edges inside
this feedback cycle (use reduce-based distinct in cycles).

Corpus 131/131, run9/10/11 and split-11 outputs verified; split-11
driver now 74.7s (was 91.6s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
saulshanabrook#12)

First validation for the FlowLog-style re-architecture. Propagates
min-labels (= egglog's union-find leader) to a fixpoint in a nested
`iterate` scope, feeds the result to a downstream join, and counts how
many tuples that join receives. `consolidate()` after the loop cuts
reception from 17 (every intermediate label 5->4->3->2->1 leaks) to 5
(the net) — the exact mechanism that turns the flat engine's herbie
blow-up (67M match deltas for 172K real emits) into per-epoch net.

Confirms: (1) the churn is a flat-timestamp artifact, not inherent;
(2) `consolidate` after `leave` is necessary and sufficient to contain
it (DD's iterate does not auto-consolidate); (3) the label-propagation
rebuild formulation composes with a downstream stratum. Standalone,
touches no production code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pick-up-later summary of the branch: hybrid-parity perf, the churn wall
that reframed the design, the FlowLog-style nested-iterate-scope vision,
the egglog<->FlowLog mapping, prototypes that de-risk it, the lessons
from failed attempts (seminaive-under-merge, content-addressed minting
key requirements, lossy hash-cons, scope flattening), and the concrete
next steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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