Skip to content

feat(optimizer): fold a trailing call through a scope call - #294

Merged
Unisay merged 2 commits into
mainfrom
feat/fold-call-through-scope-call
Jul 24, 2026
Merged

feat(optimizer): fold a trailing call through a scope call#294
Unisay merged 2 commits into
mainfrom
feat/fold-call-through-scope-call

Conversation

@Unisay

@Unisay Unisay commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #293 (merge that first; this PR's base is issue-230/collapse-tail-scope-call).

Problem

#293 collapses a scope call sitting directly in tail position, but the corpus's dominant residual shape is one step removed: a scope call whose result is immediately applied — "pick a thunk, then run it". From Golden.UncurryEffect.Test:

return (function()
  if not(n < 1) and n ~= 1 then
    return Golden_UncurryEffect_Test_countdown(n - 1)
  else
    return Effect_Console_log("done")
  end
end)()()

Here the applied call (…)()(), not the scope call itself, is the tail expression, so the #293 rule correctly declines, and every invocation still pays a closure allocation and an extra call.

The rule

foldCallThroughScopeCall rewrites (function() …; return e end)()(args) to (function() …; return e(args) end)(), pushing the trailing application into the tail returns — recursively through a branching tail (IfThenElse/Do), which is exactly where thunk selection lives. Evaluation order is preserved: the leading statements run first, the returned expression is evaluated before the arguments in both forms, and return e(args) is a tail call, so even the activation depth at the moment the result runs is unchanged.

The rule declines on: an early return among leading statements (a path the fold would not cover); a fall-off path in the tail chain (the original calls nil and errors, a partial fold would return nil); a multi-valued tail return (the call consumes the first value only, but the fold cannot drop the other results' effects); arguments mentioning ... (rebound inside the no-parameter callee); an argument free name that a body local would capture (checked via the new declaredNamesInActivation against namesInBlock, now imported from Localize); and non-atomic arguments on a branching tail — they are duplicated syntactically into every return site (still evaluated at most once, since a single branch runs), so only names and literals keep that duplication trivial.

The exposed plain scope call is then spliced away by collapseTailScopeCall from #293. That required one amendment: the fold builds tail scope calls at depths the bottom-up driver has already passed, so collapseTailScopeCall now re-applies itself to the merged function, re-checking the vararg and budget conditions each round. Without it, Golden.UncurryCtor.Test's main body kept one residual IIFE level; with it, the shape above becomes:

local Golden_UncurryEffect_Test_countdown_S_w = function(n)
  local _ = (function()
    local _ = Effect_Console_log("tick")()
    return Effect_Console_log(Data_Show_showIntImpl(n))()
  end)()
  if not(n < 1) and n ~= 1 then
    return Golden_UncurryEffect_Test_countdown(n - 1)()
  else
    return Effect_Console_log("done")()
  end
end

containsReturn is factored out of foldFieldProjectionThroughScopeCall's where clause to the module level, since both folds need the same early-return test.

Golden churn

Thirteen golden.lua files shrink; .ir and eval goldens are untouched. Highlights: UncurryEffect.countdown_S_w (above) reaches a zero-closure tail; TailRecM2Shadow's untilE predicate drops its per-iteration selection closure; LongApplyChain.applySecond_S_w and BugListGenericEq.eq lose the wrapping scope call around their curried result (the remaining (function(v1) … end)(b) beta-redex is a candidate for a separate literal-application splice, not attempted here); the long-bind family (LongEitherBind, LongExceptBind, LongStackBind, LongWriterBind, RecGroupOrder, UncurryCtor, ArrayOfUnits, Issue37, StringCodePoints) runs its final effect directly instead of through a returned thunk.

Verification

  • 11 new unit tests in Optimizer/Spec.hs covering the fold (plain tail, branching tail, nested re-fold, and each decline), plus a bare-rule test for the collapseTailScopeCall self-re-application and an end-to-end composition test through optimizeStatement.
  • cabal test all passes clean twice (fresh Hedgehog seeds), 1037 examples, 0 failures — eval goldens byte-identical, luacheck and the luac/interpreter differential specs green; fourmolu and hlint clean.

Base automatically changed from issue-230/collapse-tail-scope-call to main July 24, 2026 09:38
@Unisay Unisay self-assigned this Jul 24, 2026
@Unisay
Unisay merged commit 6810b5f into main Jul 24, 2026
2 checks passed
@Unisay
Unisay deleted the feat/fold-call-through-scope-call branch July 24, 2026 09:53
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