Skip to content

Let a self-referential let-bound closure resolve itself as an upvalue - #54

Merged
revarbat merged 1 commit into
mainfrom
letrec-self-referential-closures
Jul 30, 2026
Merged

Let a self-referential let-bound closure resolve itself as an upvalue#54
revarbat merged 1 commit into
mainfrom
letrec-self-referential-closures

Conversation

@revarbat

Copy link
Copy Markdown
Member

Summary

  • Follow-up to Compile captured closures' own bodies, not just their creation #53. reduce()/accumulate()/while()'s own idiom — let(a = function(...) ... a(...) ...) a(...), a closure that calls itself by name — was excluded from compiled invocation entirely (correct, but permanently interpreted) to avoid an O(n²) blowup, because the self-reference never resolved as a genuine upvalue: LetOp's declareLocal for a ran only after compiling a's own RHS.
  • A direct let(name = function(...) ...) binding's own slot is now declared before compiling that RHS (letrec-style), so a self-reference resolves as Op::LoadUpvalue like any other capture. Op::MakeClosure's runtime handler can't read this one out of slots the way it does every other capture — the closure being built doesn't exist yet at the moment its own captures are normally snapshotted — so it's deferred and patched into the closure's own capturedLet immediately after construction instead.
  • Confirmed via the CLI: a 32,000-element reduce() that previously took 7+ seconds now runs in ~0.05s, correct at every scale tested up to 500,000 elements.
  • Deliberately scoped to direct self-reference — a closure further wrapped in a ternary, or mutual recursion between two sibling let-bound closures (confirmed already broken before this change, in the released v0.8.0), still falls back to interpreted via the existing containsLoadFree guard.

Test plan

  • Full test suite (671 tests, up from 670) passes locally.
  • Renamed/updated SelfReferentialRecursiveClosureNowResolvesAsUpvalueAndRunsCompiled (was ...StillRunsInterpretedAndCorrectly) — confirms both correctness and that it now runs compiled (11 stops vs. 30 before, diffed against the pre-fix build).
  • New PlainLetSelfReferenceStillSeesTheOuterBindingNotItself — confirms the letrec pre-declare is scoped to function-literal RHS only; let(x = x + 1) still correctly sees the outer x.
  • Manually verified via the CLI: count_to/f_1arg (from Compile captured closures' own bodies, not just their creation #53) unaffected; ternary-wrapped self-reference and fnliterals.scad's real accumulate()/while() all still correct.

🤖 Generated with Claude Code

reduce()/accumulate()/while()'s own idiom -- `let(a = function(...)
... a(...) ...) a(...)`, a closure that calls itself by name -- never
resolved that self-reference as a genuine upvalue: LetOp's own
declareLocal for `a` ran only after compiling `a`'s own RHS, so the
reference always fell through to Op::LoadFree. The previous release
worked around the resulting O(n^2) blowup (each fallback call re-
derived a fresh closure via evalIdentifier's ctx.scope->lookupVariable()
path, nesting its own capturedLet one level deeper every time) by
excluding any LoadFree-containing closure from compiled invocation
entirely -- correct, but permanently interpreted.

Declares a direct `let(name = function(...) ...)` binding's own slot
before compiling that RHS (letrec-style), so a self-reference inside
resolves as Op::LoadUpvalue like any other capture. Op::MakeClosure's
runtime handler can't read this one out of `slots` the way it does
every other capture, though -- the closure being built doesn't exist
yet at the moment its own captures are normally snapshotted -- so it's
deferred and patched into the closure's own capturedLet immediately
after construction instead.

Confirmed via the CLI against a real list: a 32,000-element reduce()
that previously took 7+ seconds now runs in ~0.05s, with results
matching the closed-form sum at every scale tested (up to 500,000
elements). Deliberately scoped to a closure directly assigned via
`let` (not one further wrapped in a ternary, and not mutual recursion
between two sibling let-bound closures, which was already broken
before this change) -- containsLoadFree still catches those, leaving
them correctly interpreted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@revarbat
revarbat merged commit 11f1589 into main Jul 30, 2026
3 checks passed
@revarbat
revarbat deleted the letrec-self-referential-closures branch July 30, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant