Skip to content

Lift unlabeled block expressions emitted by Python-to-Laurel#1133

Open
tautschnig wants to merge 6 commits into
mainfrom
tautschnig/lift-block
Open

Lift unlabeled block expressions emitted by Python-to-Laurel#1133
tautschnig wants to merge 6 commits into
mainfrom
tautschnig/lift-block

Conversation

@tautschnig
Copy link
Copy Markdown
Contributor

@tautschnig tautschnig commented May 6, 2026

he Python-to-Laurel translator emits unlabeled Block expressions in expression position for several patterns, including modeled method calls with precondition tag checks ({ asserts...; Call }) and unmodeled method calls (PR #1019: { havocs...; Hole }). These patterns trigger block expression should have been lowered in a separate pass errors in LaurelToCoreTranslator for nested cases, because LiftImperativeExpressions was not fully lowering blocks/assignments embedded inside declare-with-init initializers.

LaurelToCoreTranslator does already handle several block-in-expression patterns natively:

  • .Block [single] — singleton blocks
  • .Block (.Assign [.Declare ⟨name, ty⟩] init :: rest) — translated as a let-binding (app(abs(name, body), value))

so this PR is now scoped narrowly to make sure only valid expression blocks reach the translator, rather than collapsing block structure unconditionally.

Changes in LiftImperativeExpressions.lean:

  1. transformExpr.Assign for .Declare targets without substitution now recursively transforms the initializer with a fresh prepend stack and reassembles state as outerPrepends ++ innerPrepends ++ [liftedDecl], returning a Var(.Local name) reference as the expression's value. Previously the case did return expr early, leaving inner blocks / assignments / nondeterministic holes in the initializer unlowered. The outerPrepends-first ordering preserves correct execution order when a substitution-introducing snapshot decl (from a later-in-source assignment, processed earlier in the right-to-left traversal) is referenced from the transformed initializer.

  2. New helper containsAssignmentOutsideUnlabeledBlock is used by the .Assert / .Assume cases of transformStmt. These cases previously bailed out whenever the condition contained any assignment; they now proceed when the assignment is confined to an unlabeled block — since the .Block case of transformExpr together with onlyKeepSideEffectStmtsAndLast will safely lift those side effects as prepends.

    The original containsAssignment is preserved for the ITE case, which correctly isolates branch-local prepends when a branch contains assignments.

  3. transformExpr.Block is unchanged in its structural behavior: sub-expressions are recursively transformed, side effects are extracted via onlyKeepSideEffectStmtsAndLast, and the result is wrapped back as a Block. We deliberately do not collapse Block structure here so that singleton blocks and var x := init-prefixed blocks remain in shape for the translator's let-binding case.

A regression test (nestedBlockInDeclInit) covering the { var t := { x := 1; x }; t + 1 } pattern is added to LiftExpressionAssignmentsTest.lean. Existing test expected outputs are kept consistent with this approach (singleton blocks { x } / { y } / { 0 } / { t + 1 } rather than unwrapped values).

Eliminates all block expression should have been lowered errors in the internal benchmark suite.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

The Python-to-Laurel translator emits unlabeled Block expressions in
expression position for two common patterns:

  * Modeled method calls with precondition tag checks:
      { asserts...; Call }
  * Unmodeled method calls (PR #1019): havocs receiver + Any-typed args,
    then yields a Hole:
      { havoc_stmt...; Hole }

The Laurel-to-Core translator rejects any Block in expression position
("block expression should have been lowered in a separate pass"), so
the LiftImperativeExpressions pass must eliminate these patterns.

The existing Block case in transformExpr kept the Block structure and
only transformed its sub-expressions. This change replaces the Block
structure with "prepend side-effects + return last element as value",
which is the natural semantics for these patterns.

Changes in LiftImperativeExpressions.lean:

1. transformExpr .Block case: for unlabeled blocks, recursively transform
   each stmt (preserves SSA-snapshot semantics for assigns in expression
   position), hoist asserts/declares via onlyKeepSideEffectStmtsAndLast,
   then return the last element as the expression value rather than
   wrapping back into a Block.

2. New helper containsAssignmentOutsideUnlabeledBlock: used by the
   Assert/Assume cases in transformStmt. These cases previously bailed
   out when a condition contained any assignment; they now proceed when
   the assignment is inside an unlabeled block (since the .Block case
   above will safely lift the side-effect).

   The original containsAssignment is preserved for use by the ITE case,
   which correctly isolates branch-local prepends when a branch contains
   assignments.

Eliminates all "block expression should have been lowered" errors in
the internal benchmark suite.

Co-authored-by: Kiro <kiro-agent@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 updates the Laurel liftExpressionAssignments pass so that unlabeled Block expressions occurring in expression position are lowered into “prepended side-effects + final value expression”, eliminating Block nodes that the Laurel-to-Core translator rejects. This targets Python-to-Laurel output patterns like { asserts; Call } and { havoc...; Hole }, and relaxes assert/assume-condition handling to allow assignments when they occur inside such unlabeled blocks.

Changes:

  • Change transformExpr’s .Block handling so unlabeled blocks are lowered by hoisting side effects and returning the last element as the value (instead of rebuilding a Block).
  • Add containsAssignmentOutsideUnlabeledBlock and use it in transformStmt for .Assert / .Assume so conditions can be transformed when assignments are confined to unlabeled blocks.
  • Extend lifting tests with an additional procedure to cover conditional assignment lifting behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
Strata/Languages/Laurel/LiftImperativeExpressions.lean Lowers unlabeled block expressions to prepends + value; adjusts assert/assume filtering for assignments inside unlabeled blocks.
StrataTest/Languages/Laurel/LiftExpressionAssignmentsTest.lean Adds regression coverage for unlabeled blocks in expression contexts and ITE branches.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Strata/Languages/Laurel/LiftImperativeExpressions.lean Outdated
Comment thread StrataTest/Languages/Laurel/LiftExpressionAssignmentsTest.lean Outdated
@keyboardDrummer
Copy link
Copy Markdown
Contributor

keyboardDrummer commented May 6, 2026

Hey, thanks for this PR. You're right that Laurel should support the code that PythonToLaurel is generating here.

One thing though, the existing error "block expression should have been lowered" is phrased too strongly. Sorry about that. LaurelToCoreTranslator is intended to support some blocks in expressions, namely the ones that can be translated to Core expressions. The intention is for a block like { var x: int := <expr1>; <expr2> } to be modeled as a Core let binding.

Could we investigate making sure that only valid expression blocks make it to LaurelToCoreTranslator, instead of preventing all expression blocks from reaching there? Lifting all blocks and not having any let expressions seems like an overly aggressive transformation that won't benefit the performance/debugability of our pipeline. Let's look at the two problematic Python cases mentioned by this PR.

The Python-to-Laurel translator emits unlabeled Block expressions in expression position for two common patterns:

Modeled method calls with precondition tag checks: { asserts...; Call }

Can we lift the asserts? The Laurel->Core translator will handle a singleton block { Call }

Unmodeled method calls (PR #1019): havocs receiver + Any-typed args, then yields a Hole: { havoc_stmt...; Hole }

Can you say more about what havoc_stmt looks like? If it's a destructive assignment, like x := ?, then it should have been lifted. If it's a variable declaration with (non-det) initializer, like var x: int := ?, then that's something that LaurelToCoreTranslator is intended to support. Note the case | .Block (⟨ .Assign [⟨ .Declare ⟨name, ty ⟩, _source⟩] initializer, innerSrc⟩ :: rest) label => do ...

Maybe there should be a separate case for a hole assignment in a block: | .Block (⟨ .Assign [⟨ .Declare ⟨name, ty ⟩, _source⟩] .Hole, innerSrc⟩ :: rest) label => do ...

Surgical follow-up addressing the two review threads on PR #1133:

1. Fix the bug Copilot flagged in transformExpr.Assign for .Declare
   targets without a substitution: the previous "return expr" early-
   returned the original assignment with the initializer un-transformed,
   so any unlabeled block / nested assignment / nondeterministic hole
   embedded in the initializer escaped lifting and reached the Laurel-
   to-Core translator unchanged.

   The fix recursively transforms the initializer with a fresh prepend
   stack, then reassembles state as outerPrepends ++ innerPrepends ++
   [liftedDecl]. The outerPrepends-first ordering is important: when an
   already-processed (later in source) assignment introduced a snapshot
   substitution that this declaration's initializer now references, the
   snapshot declaration must precede the use.

2. Revert the wholesale .Block unwrapping that this PR previously added.
   keyboardDrummer pointed out that LaurelToCoreTranslator already
   natively handles singleton blocks (`{ e }`) and var-init-prefixed
   blocks (translating them as let-bindings via app(abs(name, body),
   value)), so collapsing the Block structure unconditionally was
   pessimizing those cases. Going back to the original two-line .Block
   case lets the translator see the structure it can handle. The
   actual benefit of this PR — relaxing assert/assume condition
   handling via containsAssignmentOutsideUnlabeledBlock — is preserved.

3. Add the regression test Copilot requested for nested-block-in-decl-
   initializer (`var y := { var t := { x := 1; x }; t + 1 }`). Without
   change (1) above, the inner block survives and the Core translator
   raises "block expression should have been lowered". Existing test
   expectations are updated to reflect the .Block revert (singleton
   `{ x }` / `{ y }` / `{ 0 }` / `{ t + 1 }` instead of unwrapped values).

Co-authored-by: Kiro <kiro-agent@users.noreply.github.com>
@tautschnig
Copy link
Copy Markdown
Contributor Author

@keyboardDrummer:

Good point. In 977afc1 I reverted the wholesale .Block unwrapping so let-binding-shaped blocks ({ var x := init; ...; e }) and singletons reach LaurelToCoreTranslator intact.

Re: havoc_stmt — for the unmodeled-call pattern (PythonToLaurel.translateCall) it's a destructive Assign [.Local recv] .Hole over an existing local; after EliminateHoles runs the trailing hole becomes hole(Unknown), so by lift time the pattern is { x := hole(Unknown); …; hole(Unknown) }, which the existing .Assign[.Local] lift path reduces to a singleton { hole(Unknown) } — the translator already handles it. The { asserts; Call } case is similarly handled (asserts hoisted, residual { Call } is a singleton). No new translator case needed.

Comment thread StrataTest/Languages/Laurel/LiftExpressionAssignmentsTest.lean Outdated
@keyboardDrummer
Copy link
Copy Markdown
Contributor

keyboardDrummer commented May 19, 2026

for the unmodeled-call pattern (PythonToLaurel.translateCall) it's a destructive Assign [.Local recv] .Hole over an existing local; after EliminateHoles runs the trailing hole becomes hole(Unknown), so by lift time the pattern is { x := hole(Unknown); …; hole(Unknown) }, which the existing .Assign[.Local] lift path reduces to a singleton { hole(Unknown) } — the translator already handles it. The { asserts; Call } case is similarly handled (asserts hoisted, residual { Call } is a singleton). No new translator case needed.

If the holes are already covered, what cases do you still need block lifting for? Is it just assertions/assumptions that occur in expressions? Can we not lift those then? My feeling is that block lifting, while probably effective for correctness, is not the most idiomatic solution.

You can add

  | .Assume cond =>
      let prepends ← takePrepends
      let seqCond ← transformExpr cond
      let argPrepends ← takePrepends
      modify fun s => { s with prependedStmts := argPrepends ++ [⟨.Assume seqCond, source⟩] ++ prepends }
      default

  | .Assert cond =>
      let prepends ← takePrepends
      let seqCond ← transformExpr cond.condition
      let argPrepends ← takePrepends
      modify fun s => { s with prependedStmts := argPrepends ++ [⟨.Assert { cond with condition := seqCond }, source⟩] ++ prepends }
      default

to transformExpr to lift those.

tautschnig and others added 3 commits May 19, 2026 10:46
Two changes responding to keyboardDrummer's observation that the tests
in this PR don't actually exercise its changes:

1. T2_ImpureExpressions.lean: add three deductive-verification
   regression cases that fail on origin/main and pass with this PR:

   * assertWithBlockExpr / assumeWithBlockExpr exercise the assert/assume
     condition relaxation via containsAssignmentOutsideUnlabeledBlock.
     Before the fix, the inner unlabeled block survived to the
     Laurel-to-Core translator and triggered "block expression should
     have been lowered in a separate pass". Verified directly: both fail
     on a worktree at origin/main with that exact diagnostic.

   * nestedBlockInDeclInit exercises the fix in transformExpr.Assign
     for .Declare targets without a substitution. Before the fix, the
     case early-returned the original expression without recursing into
     the initializer, so the inner { x := 1; x } survived to the
     translator. Same verification: fails on origin/main, passes here.

   keyboardDrummer's suggested condAssign program is intentionally not
   added — it already verifies cleanly on origin/main and would not
   exercise this PR.

2. Rename StrataTest/Languages/Laurel/LiftExpressionAssignmentsTest.lean
   to LiftExpressionAssignmentsIdiomaticityTest.lean, drop the condAssign
   case (which doesn't depend on this PR), and refresh the module
   docstring to call out that this file is a quality-of-output check
   (parse + resolve + lift + #guard_msgs against pretty-printed Laurel),
   not a correctness check. Correctness now lives in the T2_ImpureExpressions
   regression cases above.

Co-authored-by: Kiro <kiro-agent@users.noreply.github.com>
Per keyboardDrummer's follow-up suggestion in
#1133 (comment),
handle asserts/assumes that appear in expression position directly in
transformExpr rather than relying on the surrounding transformStmt.

New transformExpr.Assert and .Assume cases:
- Save the outer prepend stack, transform the condition with a fresh
  one, and reassemble as outerPrepends ++ condPrepends ++ [liftedAssert].
- Front-to-back execution order: outer (later-source-position) prepends
  first, then condition side-effects, then this assert/assume.
- Return a LiteralBool true placeholder; onlyKeepSideEffectStmtsAndLast
  drops it from non-last positions of the surrounding block.
- Preserve the existing user-facing error contract: if the condition
  contains a top-level destructive assignment, leave the assert/assume
  unchanged so the Laurel-to-Core translator can emit "destructive
  assignments are not supported in functions or contracts" against the
  original source.

The transformStmt.Assert/.Assume cases retain the same bail-out check
and otherwise just call transformExpr on the condition.

This fixes a latent case my PR previously missed: an assert/assume
inside an expression-position block whose condition itself contains an
unlabeled block with an assignment. Before, transformExpr on .Assert
fell through to the default catch-all and the inner block survived to
the translator. Added regression test `nestedAssertInBlockExpr` to
T2_ImpureExpressions covering exactly that pattern.

Termination: the recursion `transformExpr cond.condition` is justified
via the existing `Condition.sizeOf_condition_lt` lemma; the
decreasing_by block now does `try have := Condition.sizeOf_condition_lt`
before `term_by_mem`, mirroring MapStmtExpr.lean and TypeHierarchy.lean.

Idiomaticity test update: with the new behaviour, asserts inside
expression-position blocks now have their conditions transformed
right-to-left like other expressions, which means a reference to a
later-mutated variable is rewritten through the substitution map. For
`assertInBlockExpr`, the inner `assert x == 0` becomes
`assert $x_0 == 0` (using the just-introduced snapshot of x),
positioned after the snapshot/assignment in execution order.
Semantically equivalent to the previous output, more uniform with the
other lifted prepends.

Co-authored-by: Kiro <kiro-agent@users.noreply.github.com>
@tautschnig
Copy link
Copy Markdown
Contributor Author

If the holes are already covered, what cases do you still need block lifting for? Is it just assertions/assumptions that occur in expressions? Can we not lift those then? My feeling is that block lifting, while probably effective for correctness, is not the most idiomatic solution.
[...]

Good call — done in ad373f9. transformExpr now has .Assert/.Assume cases that recursively transform the condition and lift the assert/assume to the prepend stack with a leaf placeholder, replacing the special-casing in onlyKeepSideEffectStmtsAndLast. As a side benefit this also fixes a latent case my earlier draft missed — an assert/assume inside an expression-position block whose condition contains another unlabeled block with an assignment — covered by a new nestedAssertInBlockExpr regression test.

The containsAssignmentOutsideUnlabeledBlock guard is kept so a top-level destructive assignment in a condition (e.g. assert (x := 2) == 2) still surfaces the existing destructive assignments are not supported in functions or contracts diagnostic from LaurelToCoreTranslator rather than getting silently lifted.

};

// Regression for #1133: an unlabeled-block expression that contains a
// destructive assignment must be admissible inside an `assert` condition.
Copy link
Copy Markdown
Contributor

@keyboardDrummer keyboardDrummer May 19, 2026

Choose a reason for hiding this comment

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

// Regression for #1133: an unlabeled-block expression that contains a
// destructive assignment must be admissible inside an assert condition.

Why must that be admissible? What is the use-case? I was planning to update Resolution.lean so it explicitly rejects assignments to variables that occur inside assert/assume but were the variable was defined outside of it.

rejection logic. This is used for assert/assume conditions, which should
proceed with transformation even when an unlabeled block embeds an
assignment (e.g. havoc from PR #1019's unmodeled call handling). -/
def containsAssignmentOutsideUnlabeledBlock (expr : StmtExprMd) : Bool :=
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.

Is the catch-all to false actually correct here? I'm having trouble understanding what happens if it returns a "false negative" to transformExpr. Is it "accidentally correct" since transformExpr has a catch-all that doesn't recurse into the same variants?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants