Let a self-referential let-bound closure resolve itself as an upvalue - #54
Merged
Conversation
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>
This was referenced Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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'sdeclareLocalforaran only after compilinga's own RHS.let(name = function(...) ...)binding's own slot is now declared before compiling that RHS (letrec-style), so a self-reference resolves asOp::LoadUpvaluelike any other capture.Op::MakeClosure's runtime handler can't read this one out ofslotsthe 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 owncapturedLetimmediately after construction instead.reduce()that previously took 7+ seconds now runs in ~0.05s, correct at every scale tested up to 500,000 elements.let-bound closures (confirmed already broken before this change, in the released v0.8.0), still falls back to interpreted via the existingcontainsLoadFreeguard.Test plan
SelfReferentialRecursiveClosureNowResolvesAsUpvalueAndRunsCompiled(was...StillRunsInterpretedAndCorrectly) — confirms both correctness and that it now runs compiled (11 stops vs. 30 before, diffed against the pre-fix build).PlainLetSelfReferenceStillSeesTheOuterBindingNotItself— confirms the letrec pre-declare is scoped to function-literal RHS only;let(x = x + 1)still correctly sees the outerx.count_to/f_1arg(from Compile captured closures' own bodies, not just their creation #53) unaffected; ternary-wrapped self-reference andfnliterals.scad's realaccumulate()/while()all still correct.🤖 Generated with Claude Code