Skip to content

FloatIn: an IR-level replacement for pushDeclarationsDownTheInnerScope - #151

Merged
Unisay merged 7 commits into
mainfrom
issue-136/float-in
Jul 3, 2026
Merged

FloatIn: an IR-level replacement for pushDeclarationsDownTheInnerScope#151
Unisay merged 7 commits into
mainfrom
issue-136/float-in

Conversation

@Unisay

@Unisay Unisay commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #136.

Summary

pushDeclarationsDownTheInnerScope (a Lua-AST rule, disabled in #137) sank a let-bound declaration into the body of the returned lambda. That moved evaluation from partial application into every call of the returned function, losing sharing and delaying errors until the function was actually invoked.

This PR replaces it with an IR-level pass, Language.PureScript.Backend.IR.FloatIn, that sinks a Let-bound value into the single IfThenElse branch that uses it. Because it operates on the IR, lambdas are visible as distinct Abs nodes rather than opaque closures, so the pass can rule out exactly the shape that broke sharing: a binding is never sunk across a lambda.

Three correctness issues surfaced during design and review, all covered by regression tests:

  • A bare Let-to-Let transit is not itself progress. Without this rule, let y = a in let x = b in x + y would swap the two bindings on every run. A transit only counts once the descent past it crosses an IfThenElse boundary.
  • A top-down driver is not enough. It decides an outer Let before its children are rewritten, so an inner sink that confines a name's uses to one branch unblocks the enclosing binding only on a second run (a live flake in the idempotence property, ~6% of generated expressions), and a fully-collapsing Let exposes its body to no further rewriting (the "Recurse-escape" class fixed for DCE in First-class passes for the IR pipeline: Pass values, invariant checks, deterministic name supply #149). The pass therefore rewrites bottom-up (transformOf subexpressions): one pass is complete and idempotent, checked by a property and by unit regressions.
  • Sinking must stop at the branch root when the only uses sit inside a lambda in that branch: the binding lands above the Abs, never inside it. Pinned by a unit test and by the eval oracle below.

None of the existing golden fixtures happen to exercise the shape this pass targets (a multi-use, non-inlinable Let binding confined to one IfThenElse branch, since the ordinary inliner already handles the single-use case). This PR adds a small new golden (Golden.FloatIn) with a runnable eval oracle that observes the evaluation policy, not just the computed value: a tick foreign function prints a line every time it is evaluated, and the fixture routes all uses of the shared binding through a lambda called twice inside one branch. The pinned output proves the binding runs once inside the taken branch (sharing preserved, so the sink stopped above the lambda), and never on the branch not taken.

Changes

  • lib/Language/PureScript/Backend/IR/FloatIn.hs (new): the pass. Free-reference tests use an early-exit occurrence scan (usesName, exact under the GUC) instead of building full countFreeRefs maps per query.
  • lib/Language/PureScript/Backend/IR/Optimizer.hs: wires it in between the post-merge optimize/dce fixpoint and magicDo.
  • test/Language/PureScript/Backend/IR/FloatIn/Spec.hs (new): 20 unit cases plus 4 property tests (GUC preservation, free-reference preservation, idempotence, bound-name preservation) against generated expressions. The idempotence property was additionally hammered offline on 20k generated expressions.
  • test/Language/PureScript/Backend/IR/SpecUtils.hs (new) and a parametrized prop in Test.Hspec.Hedgehog.Extended: spec helpers shared with the DCE and FlattenDeepBinds specs instead of a third copy.
  • test/ps/src/Golden/FloatIn/Test.purs (new, with .lua FFI) and its golden/eval fixtures.
  • lib/Language/PureScript/Backend/Lua/Optimizer.hs: deletes the now-superseded pushDeclarationsDownTheInnerScope rule and its pinning test.

Test plan

  • cabal test all --test-show-details=direct passes (365 examples, 0 failures)
  • fourmolu -i lib/ exe/ test/ and hlint lib/ exe/ test/ clean
  • New Golden.FloatIn eval oracle passes, pinning both the computed values and the single tick evaluation inside the taken branch
  • Idempotence and free-reference preservation additionally hammered on 20000 generated expressions in GHCi: 0 violations
  • All other golden fixtures unchanged (no existing fixture exercised this shape before)

Unisay added 6 commits July 3, 2026 15:46
Adds the IR-level float-in pass for issue #136, replacing the disabled
pushDeclarationsDownTheInnerScope Lua-AST rule: sinks a Let-bound value
into the single IfThenElse branch that uses it, never across a lambda.
Not yet wired into the optimizer pipeline, so this commit is a pure
no-op on existing goldens; wiring and golden churn come next.
Runs once between the post-merge optimize/dce fixpoint and magicDo.
None of the existing golden fixtures happen to exercise the shape this
pass targets (a multi-use, non-inlinable Let binding confined to one
IfThenElse branch), so this adds a small new golden with a runnable
eval oracle demonstrating the sink: the generated Lua now declares the
shared local inside the `if` branch instead of above it.
The IR-level float-in pass now covers this rule's intended purpose
without its sharing bug, so the disabled Lua-AST rule (PR #137) and its
pinning test can go.
prop (parametrized by maxSuccess) moves to Test.Hspec.Hedgehog.Extended; the wrap-as-main-export pass runner and the empty UberModule fixture move to Language.PureScript.Backend.IR.SpecUtils. The DCE and FlattenDeepBinds specs use them now; the FloatIn spec follows in the next commits instead of adding a third copy.

Claude-Session: https://claude.ai/code/session_01Bfeu6sp2zVhvuQTnEzYpNG
The fixture was observationally inert: with a pure right-hand side the eval output could not distinguish a correct sink from a missed or unsound one, so the oracle never actually guarded the reordered-evaluation policy the pass relies on. The new tick foreign prints a line every time it is evaluated, and pickShared routes all uses of the shared binding through a lambda called twice inside one branch. The pinned output now proves the binding runs exactly once inside the taken branch — the sink stops above the lambda, preserving sharing, where the #136 bug shape would print twice — and never on the branch not taken.

Claude-Session: https://claude.ai/code/session_01Bfeu6sp2zVhvuQTnEzYpNG
The top-down driver decided an outer Let before its children were rewritten: an inner sink that confined a name's remaining uses to a single branch unblocked the enclosing binding only on the next run of the pass, so floatIn . floatIn kept finding work floatIn left behind (~6 of 20000 generated expressions) and the "is idempotent" property was a live CI flake. Rewritten bottom-up (transformOf subexpressions), a Let decides its groupings' fate only after all its subexpressions are final, which makes one pass complete; this also closes the Recurse-escape gap (the #149 class) structurally, so the refloatLet workaround is gone.

Also driven by the same review:

- usesName, an early-exit occurrence scan (exact under the GUC), replaces building the full countFreeRefs map on every query;
- the condition-use and RecursiveGroup negative tests are de-vacuized: the guard each one pins is now the only thing blocking the sink;
- new pins: wrap-above-Abs placement inside a branch (the #136 bug shape), the uberModuleBindings rewrite path, and inner-sink-unblocks-outer with an explicit double-application check;
- the pipeline comment no longer claims float-in cannot re-open an optimize/dce opportunity (a sink can leave both branches of an IfThenElse alpha-equivalent for removeIfWithEqualBranches); the module header documents the bottom-up design, the corrected magicDo non-interference argument, and the parser-nesting vector backstopped by NestingCheck.

Claude-Session: https://claude.ai/code/session_01Bfeu6sp2zVhvuQTnEzYpNG
@Unisay
Unisay requested a review from Copilot July 3, 2026 19:45
@Unisay Unisay self-assigned this Jul 3, 2026
@Unisay
Unisay marked this pull request as ready for review July 3, 2026 19:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the sharing/evaluation regression caused by the (now removed) Lua-AST optimizer rule pushDeclarationsDownTheInnerScope by replacing it with an IR-level “float-in” optimization. The new pass sinks Let bindings into the single IfThenElse branch that uses them while explicitly never sinking across lambdas, preserving sharing and evaluation timing.

Changes:

  • Add Language.PureScript.Backend.IR.FloatIn, an IR pass that sinks branch-confined Let bindings without crossing Abs nodes.
  • Wire FloatIn into the IR optimizer pipeline after the post-merge optimize/DCE fixpoint and before magicDo.
  • Add substantial test coverage (unit + property tests) and a new runnable golden fixture (Golden.FloatIn) with an eval oracle to pin evaluation/sharing behavior; remove the superseded Lua optimizer rule and its test.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
lib/Language/PureScript/Backend/IR/FloatIn.hs Implements the new IR FloatIn pass and documents its semantics/termination and pipeline placement.
lib/Language/PureScript/Backend/IR/Optimizer.hs Inserts FloatIn into the IR pipeline between optimize/DCE and magicDo.
lib/Language/PureScript/Backend/Lua/Optimizer.hs Removes the obsolete Lua-AST rewrite rule that caused the regression.
test/Language/PureScript/Backend/IR/FloatIn/Spec.hs Adds unit and property tests for FloatIn (GUC preservation, free refs, idempotence, bound names).
test/Language/PureScript/Backend/IR/SpecUtils.hs Introduces shared helpers for running whole-module passes over single expressions in specs.
test/Test/Hspec/Hedgehog/Extended.hs Adds prop helper to run Hedgehog properties with configurable maxSuccess.
test/Language/PureScript/Backend/IR/DCE/Spec.hs Refactors to reuse SpecUtils and the shared prop helper.
test/Language/PureScript/Backend/IR/FlattenDeepBinds/Spec.hs Refactors to use the shared prop helper (removes local duplicate).
test/Main.hs Registers the new FloatIn spec in the test runner.
test/Language/PureScript/Backend/Lua/Optimizer/Spec.hs Removes the pinning test for the deleted Lua optimizer rule.
test/ps/src/Golden/FloatIn/Test.purs New PureScript golden designed to exercise FloatIn’s evaluation/sharing behavior.
test/ps/src/Golden/FloatIn/Test.lua FFI tick helper used by the eval oracle to observe evaluation frequency.
test/ps/output/Golden.FloatIn.Test/corefn.json Generated CoreFn fixture for the new golden module.
test/ps/output/Golden.FloatIn.Test/golden.ir IR golden output for the new golden module.
test/ps/output/Golden.FloatIn.Test/golden.lua Lua golden output for the new golden module.
test/ps/output/Golden.FloatIn.Test/eval/golden.txt Hand-verified eval oracle output pinning the intended evaluation policy.
test/ps/output/Golden.FloatIn.Test/eval/.gitignore Ignores runtime-produced actual.txt for the eval oracle harness.
pslua.cabal Exposes the new library module and includes the new specs/helpers in the test suite.
changelog.d/20260703_190000_unisay_float_in.md Changelog fragment describing the behavior change and its motivation.

Comment thread lib/Language/PureScript/Backend/IR/FloatIn.hs Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Yura <1009751+Unisay@users.noreply.github.com>
@Unisay
Unisay merged commit e02b867 into main Jul 3, 2026
2 checks passed
@Unisay
Unisay deleted the issue-136/float-in branch July 3, 2026 20:25
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.

Lua optimizer rule pushDeclarationsDownTheInnerScope loses sharing and delays evaluation of let-bound values

2 participants