Skip to content

Two calls of one def compare their arguments before either unfolds - #1832

Open
Lorenzobattistela wants to merge 8 commits into
mainfrom
conversion-atom-calls
Open

Lorenzobattistela wants to merge 8 commits into
mainfrom
conversion-atom-calls

Conversation

@Lorenzobattistela

@Lorenzobattistela Lorenzobattistela commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Two calls of one def compare their arguments before either unfolds, as in Lean's lazy delta reduction

Addresses the problem in #1232 with a different change. Thanks to @Giulio2002 for the report and to @Tor-Saxberg for the minimal case.

Problem

compare_go runs term_wnf on both sides before it compares them. The rigid pass (RIGID, the empty book, where no def unfolds) runs only once, at the root of the goal. When two copies of a call line up only after an outer def unfolds, the full pass evaluates both copies:

def big() -> Nat: Nat.pow(2n, 30n)
def boxed() -> Box: Box{big()}
def boxed_eq() -> {Box{big()} == boxed() : Box}: {==}

The rigid pass fails at the root (Ctr against Ref). The full pass then unfolds boxed() and meets big() against big(): the same def, but two different nodes, so === misses. It unfolds both and walks 2^30 Succ cells: stack overflow.

The same happens in types, where it costs time instead of stack:

def cast(w: Both(Heavy(), Same(Unit))) -> Both(Heavy(), Unit): w

Here Same(Unit) is not rigidly Unit, so the full pass normalizes Heavy() on both sides. The time doubles with each step of slow.

Solution

This is the same-head step of Lean's lazy_delta_reduction_step: before either side unfolds, both sides are reduced with no def unfolded (Lean's whnfCore). Two calls of the same def then compare their arguments, and the checker unfolds only when that fails.

  • same_call reduces a side whose head is a redex (a Let, a Rwt, an applied Lam or Mat) with RIGID, the empty book, which unfolds no def. A call, a value or a neutral is already reduced and is not walked again.
  • Two calls of one def on as many arguments compare those arguments with RIGID. Both spines are walked together, so g(1n)(0n) never matches g(0n). A rigid "yes" is a definitional "yes".
  • Two terms still stuck on a redex after that, such as a match whose scrutinee is a call, compare whole and rigidly. Lean reaches these through the same step, because its matchers are defs.
  • In the full pass, a "yes" returns at once and anything else falls through to term_wnf as before. In the rigid pass, two calls that differ are rigidly different. Every other term goes through the rigid pass's own whnf and structural compare.
  • The rigid pass fills no share cell (tm.i === -1 && book !== RIGID). Without this, a rigid walk would fill a cell with a value stuck on a def it may not unfold. The full pass would then inherit that value, and its -2 unification would make the walk exponential.
  • SEEN keeps every rigid EQ answer for one term_compare, under a pair key, like Lean's equiv manager and failure cache. A DAG whose tree is exponential is then walked once, and so is nearly equal data walked again from each level below. It is allocated on the first rigid pair and reset when the next conversion starts.
  • term_compare no longer runs a rigid pass at the root, since the same-head step covers that case.

An earlier version of this PR asked the whole rigid pass at every step of the full pass. That fixed the repro, but it was quadratic on long, nearly equal data (14x on add(a, b) == add(b, a) at n = 3000) and exponential on shared DAGs (34x on a 22-deep chain of f(n) = z(n) + z(n)). Both are gone here.

What this costs, by design: the rigid walk of a call's arguments runs nested inside the full pass, as in Lean. A proof whose full walk is deep and whose call arguments are deep needs both depths of stack. A 20,000-deep box chain over 4,500-element list arguments passes on main and overflows here. The deepest single walk, a unary numeral against its copy, is the same as on main (about 31,900).

Review

Rounds of code review (xhigh, high, medium, low) on this PR found these, now fixed:

  • Copies headed by a local lambda or an applied lambda-match were evaluated on both sides. Fixed by the reduction before the same-head step.
  • len(xs) == len(ys) on literal lists that differ only at the end was quadratic, because the memo only kept cell pairs. Fixed by keeping every rigid pair.
  • Two calls with different numbers of arguments compared equal, which is unsound: {7n == 5n} checked. Fixed by walking both spines together, and covered by _arity.bend.
  • A match stuck on a call was evaluated on both sides. Fixed by comparing stuck terms whole.
  • In the rigid pass, a redex was reduced twice. Fixed.

Checked and not changed: a let chain of matches with no def is exponential on main too, and is a steady 1.8x here.

Measurements

Whole runs of bend <file> (startup, parse and check), median of 7 interleaved rounds on one M-series laptop, main 9d514f3b against this change:

case main this
boxed_eq (2^30) stack overflow ALL PROOFS CHECK
cast with slow(22n) 3.75 s 0.06 s
the 5 cases in _near.bend (2^16) stack overflow ALL PROOFS CHECK
compute_1600 0.50 s 1.13–1.15x (1.00–1.02x of its pin in gates/perf.ts)
defs, generics, proofs, trees 1.03, 1.01–1.03, 0.99, 1.01–1.03x
add(a, b) == add(b, a) by {==}, n = 3000 1.00–1.02x
len(append(xs, ys)) == len(append(ys, xs)), n = 3000 1.03x
len(xs) == len(ys), literal lists of 4000 0.10 s 1.07x
root_loop(24n, 3) == root_rec(24n, 3) (_shared.bend) 0.24 s 0.25x

--check-only over 1,892 repo files (tests, bench, demos, guide, evals) and 576 BendHub files: every output is identical to main's, with no timeouts. Total time is 0.98x on the repo and 0.99x on the hub.

bend.ts goes from 44,493 to 45,260 ttok (cap 48k).

Tests

  • tests/check/conversion_call_after_unfold.bend: the repro, its mirror, and a literal argument (boxed_at(30n)). Stack overflow on main.
  • tests/check/conversion_call_after_unfold_type.bend: the cast case with slow(26n), through the LE conversion (check-any).
  • tests/check/conversion_call_after_unfold_near.bend: five calls whose arguments are equal only after a few steps, at 2^16. Each one overflows on main.
  • tests/check/conversion_call_after_unfold_differ.bend: calls of one def on different arguments must still fail (Box{8n} against Box{16n}). This guards against a false accept.
  • tests/check/conversion_call_after_unfold_redex.bend: copies of a local lambda, of an applied lambda-match and of a match stuck on slow(26n). Each takes 2^26 steps on both sides without the reduction before the same-head step.
  • tests/check/conversion_call_after_unfold_arity.bend: g(1n)(0n) == g(0n) must fail (7n against 5n). It guards against a false accept.
  • tests/check/conversion_call_after_unfold_shared.bend: the SEEN cache. root_rec is a chain of mix(h, h) on one cell; without the cache the check does not finish in 60 s.

The 22-deep f chain is not a test: BendTT runs out of fuel on it from depth 22, and at depth 20 the old version of this PR still took only 0.75 s, so it would not have caught the regression.

Gates

On main 9d514f3b plus this branch (0e1fbd8):

  • repo 54/54, test 1790/1790, ping 81/81, perf 124/124.
  • gates/safe.ts tests: agree 1149, false reject 0, timeout 0.

🤖 Generated with Claude Code

compare_go unfolded both sides before comparing them, and the rigid pass
(no def unfolds) ran only once, at the root of the goal. A copy that
lines up only after an outer def unfolds was evaluated on both sides:
{Box{big()} == boxed()} with big() = 2^30 overflowed the stack, and a
type holding a slow closed call next to a part that needs one unfold
re-ran the slow call on both sides.

Before compare_go unfolds anything, compare_call walks both spines. If
both are the same Ref and each pair of arguments is one atom (the same
cell, def, literal or bound variable, seen through cells and
annotations, nothing reduced), the calls are equal by congruence. If
not,
nothing is decided and the comparison goes on as before. A failed try
costs one step per argument. Comparing the arguments recursively instead
made compute_1600 1.4x slower, because the walk down unary chains was
repeated at every level. A non-atomic argument is still caught one
unfold lower, where its own call meets its copy.
The atom shortcut caught only calls of one def on the same atoms, so a
copy that agrees only after a few steps (a def naming another, another
def with the same body, equal arguments written differently, a call or
a constructor as the argument) still overflowed. compare_go now asks
the rigid pass (no def unfolds) at every step of the full pass before
it unfolds anything, and the rigid pass at the root of term_compare is
that first step. Both sides evaluate in step, so once they reach the
same text the walk stops there.

The cost is in proofs by computation over large data: compute_1600 is
1.35x its pin, and {add(a, b) == add(b, a)} on unary numbers grows
quadratically, as each rigid try walks the long common prefix again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Lorenzobattistela Lorenzobattistela changed the title A call of one def on the same atoms converts before either side unfolds Each step of a conversion asks the rigid pass first Oct 23, 2026
@Giulio2002

Copy link
Copy Markdown
Contributor

Tested this on the proof workload in Giulio2002/bend-collections. Mixed results: useful frontend speedups, but large regressions in two secp256k1 files.

Setup

  • Base aee1b1f2 vs PR head 4fbf8773.
  • M2 Max/macOS, Bun 1.4.4 running each unmodified checkout's bend2/main.ts FILE --check-only.
  • Identical frozen collection sources for both versions; three interleaved rounds per case. Times below are median wall-clock seconds, including startup/loading/checking.
  • Shared, loaded host, so treat modest differences as indicative rather than isolated-machine benchmark results.
  • Source-link caveat: the repository permalinks below identify the original files at 512b7364. The measured snapshot includes uncommitted compatibility/proof-decomposition changes and is not yet published. Those links are navigation references, not the exact measured source snapshot. The same frozen modified snapshot was used on both sides of this A/B.

Frontend results: base → PR

Regressions:

  • secp256k1 scalarpow: 5.979s → timeout at 60s in all three PR runs. Base passes all three.
  • secp256k1 group/ptm: 2.914s → timeout at 60s in all three PR runs. Base passes all three.
  • Upstream compute_1600: 0.644s → 0.878s, about 36% slower.

The two timeouts are censored measurements, not completed runtimes: they imply >10× and >20× slowdowns respectively relative to the base medians.

The intended conversion_call_after_unfold.bend reproducer does improve: base stack-overflows in all three runs, PR passes in all three (median 0.064s). The _type.bend reproducer times out at 20s on base in all three runs, while PR passes in 0.067s median. The _differ.bend negative test remains rejected by both versions in all three runs.

Separate kernel check

These timings are frontend checks, not BendTT acceptance. In the separate end-to-end --verdict experiment on AES fast/GHASH, both base and PR hit the same configured 12 GB process-group memory cap: base 58.5s, PR 57.4s (another PR run: 59.0s). These are resource-limit failures, not successful verification times. Larger full-root comparisons are still pending.

So this fixes the intended conversion cases and helps several real frontend workloads, but it is not an across-the-board improvement for this library. The repeated scalarpow/ptm regressions seem worth investigating alongside the existing computation-heavy benchmark regression. No proof statements or kernel fuel were changed for this A/B.

Lorenzobattistela and others added 2 commits October 30, 2026 09:05
As in Lean's lazy delta reduction: when both sides of a conversion are
calls of the same def, their arguments are compared rigidly (the empty
book unfolds no def and fills no cell) before either side unfolds. The
rigid answer on a pair of shared cells is kept for the whole conversion,
so a DAG whose tree is exponential is walked once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Lorenzobattistela Lorenzobattistela changed the title Each step of a conversion asks the rigid pass first Two calls of one def compare their arguments before either unfolds Oct 30, 2026
Lorenzobattistela and others added 4 commits October 30, 2026 10:10
…ir is kept

Review (xhigh) of the previous commit found three regressions against
main. Each fix follows Lean's lazy delta reduction more closely:

- Identical copies headed by a redex (a local lambda, an applied
  lambda-match) were evaluated on both sides: main's root rigid pass
  beta-reduced them to one stuck call, and same_call looked only at raw
  heads. Lean runs its same-head step after whnfCore, so same_call now
  reduces a side whose head is a redex (Let, Rwt, an applied Lam or Mat)
  with the rigid book first. A call, a value or a neutral is already
  reduced and is not walked again, which keeps compute_1600 where it was
  (always reducing cost 1.30x).
- len(xs) == len(ys) on literal lists that differ at the end was
  quadratic: each level's same_call walked the rest of the raw tails, and
  the memo saw only cells. Lean's caches key on any pair of terms; the
  memo now keeps every rigid pair, under a pair key (one partner per lhs
  lost an answer when a cell met two partners in turn).
- The rigid pass decides two calls without a whnf again (same_call is
  tri-state in both modes), since a whnf of a stuck call rebuilds its
  spine.

Not changed, by design: the rigid walk of a call's arguments runs nested
in the full pass, so a proof whose full walk is deep and whose call
arguments are deep needs the sum of both stacks (Lean nests the same way).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pares whole

Review (high) of 8d0a4d7 found a soundness break and a regression:

- same_call found each side's head on its own, then compared arguments
  until the shorter spine ran out, so g(1n)(0n) and g(0n) were rigidly
  equal when their shared argument was, and {7n == 5n} checked through
  one conversion. Both spines are now walked together, as before
  8d0a4d7: two calls match only on as many arguments. The test
  conversion_call_after_unfold_arity.bend fails on 8d0a4d7.
- A lambda-match stuck on a call, on both sides, was evaluated on both
  sides (main's root rigid pass compared it whole). Lean's same-head step
  reaches such terms because its matchers are defs; here a term whose
  head is still a redex after rigid reduction is stuck on a def, so the
  full pass compares the two whole and rigidly before it unfolds.
- The memo now keeps only EQ answers, instead of trusting that the rigid
  pass is entered only in EQ, and it is allocated on the first rigid pair
  and dropped when the conversion ends.

Not changed, by design: same_call's rigid reduct is not reused by the
full whnf (only a redex head is reduced twice, and on compute_1600 those
are under 100 of the 450k pairs), and the rigid pass stays the RIGID
book sentinel that compare_go already used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is safe

Review (medium) of eda032e:

- In the rigid pass, same_call reduced a redex head with the rigid book
  and compare_one then reduced the same term again. The rigid pass's own
  whnf and its App case already compare stuck calls, so same_call now
  reduces only in the full pass.
- The memo comment claimed a pair holds no free var; it does (the fresh
  vars of a binder). What holds is that vars are levels and a pair met at
  one depth holds only levels below it.

Checked and not changed:

- A let chain of matches with no def is exponential on main too; the PR
  is a steady 1.8x on it (n = 22, 24, 26), and filling cells with values
  in the rigid pass did not change that.
- BendTT agrees with every new test (safe: 0 false rejects).
- The full pass does not continue from same_call's rigid reduct, since
  that would lose the share cell it fills; only redex heads pay it.
- A rigid answer is final only in the rigid pass; in the full pass two
  calls of different defs return after the head walk, before any
  argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/simplify on the PR: the reset on exit was redundant. The reset on entry
already covers a conversion that threw and was caught, and the table of
the last conversion lives only until the next one.

Tried and not kept: a boolean same_call used only by the full pass,
comparing both kinds of rigid try (two calls, two stuck terms) whole
through compare_go. It is 164 ttok shorter but costs 1.49x on
compute_1600, because the rigid pass then rebuilds every call's spine
through term_wnf instead of comparing its arguments in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Giulio2002

Copy link
Copy Markdown
Contributor

Retested the updated head 0e1fbd87 against its base 9d514f3b, this time with a matched comparison of all 112 original proof/spec roots from bend-collections.

This supersedes the earlier revision's performance observations above. These are frontend --check-only results, not --verdict. Both versions used identical frozen collection sources, the same Linux host and Bun 1.4.3, and the same per-invocation limits: 300 seconds, 8 GiB, 100% CPU. Four root pairs ran concurrently, alternating variant order within pairs. Snapshot hashes were checked unchanged afterward.

Fails only on this PR

These four roots pass on BASE but stack-overflow on PR:

All four report:

SOME PROOFS FAIL
Error: the machine stack overflowed (a deep recursion, or a literal too large to expand)

Source-link caveat: these permalinks identify the corresponding original files. As in the earlier comment, the actual tested snapshot contains unpublished compatibility/proof-decomposition changes; the links are navigation references, not exact standalone reproducers. The A/B used the same modified snapshot on both sides. No source/proof changes were made between variants.

What works versus BASE

  • 104 roots pass on both, including f64ratio.bend.
  • Three roots finish on PR while BASE exceeds 300 seconds: proof_ecdsa (PR 288s), proof_order (286s), and proof_schnorr_group (298s). These are single-run, threshold-sensitive completion improvements—not evidence that BASE rejects the proofs.
  • proof_glvsplit.bend times out on both, so I am not attributing that to this PR.
  • Separately, your boxed-call and near-call regression tests stack-overflow on BASE and pass on PR. The arity and differing-argument negative tests remain rejected on both.

Suggested fix direction

I also reran f64divc on macOS/Bun 1.4.4 with only CLI error-stack logging added; bend.ts was unchanged. The stack shows:

RangeError: Maximum call stack size exceeded.
    at term_higher (.../bend.ts:751:17)
    at term_higher (.../bend.ts:751:17)
    at map (native)
    at term_higher (.../bend.ts:762:29)
    at term_wnf (.../bend.ts:2886:19)
    at compare_one (.../bend.ts:3186:11)
    at every (native)
    at every (native)
    ... many repeated every frames ...

This points toward deep structural comparison consuming the JS stack, though it is not yet a reduced root-cause proof. In particular, the recursive ADT/Ctr comparisons use .every(... compare_go(...)).

Proposal, not yet implemented or validated: keep the same-head optimization, but make the deep structural equality traversal stack-safe using an explicit worklist/trampoline rather than recursively nesting comparator callbacks. Preserve comparison mode, binder depth, short-circuit behavior, and sharing semantics; finalize memoized answers only after their child comparisons are resolved. Also inspect the recursive term_higher expansion shown at the top of the trace, rather than assuming the comparator is the sole cause. Add a reduced deep-structure regression alongside the existing shared-DAG/arity/different-argument tests.

That seems a better direction than increasing the host stack or weakening the proof obligations. The full matched sweep shows real improvements, but these four new frontend crashes still block treating the update as an unconditional improvement for this workload.

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