Skip to content

Enable calling procedures in contracts#1352

Merged
keyboardDrummer merged 146 commits into
strata-org:reviewed-kbd-will-merge-to-mainfrom
keyboardDrummer:issue-924-contract-and-proof-pass
Jun 23, 2026
Merged

Enable calling procedures in contracts#1352
keyboardDrummer merged 146 commits into
strata-org:reviewed-kbd-will-merge-to-mainfrom
keyboardDrummer:issue-924-contract-and-proof-pass

Conversation

@keyboardDrummer

@keyboardDrummer keyboardDrummer commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Functional changes

  1. [Debugging] Improve the printing of Laurel if-then-else expressions
  2. EliminateReturnsInExpression now runs for procedures as well, which enables more types of transparent bodies for procedures. To make it work for both functions and procedures, it was also necessary for the body of functions to be immediately wrapped in a return statement during parsing.
  3. Allow calling procedures from contracts. Combined with the previous change this makes procedures strictly more powerful than functions
  4. Let the transparency pass rewrite the bodies of assume statements so they don't assert anything.
  5. Improve diagnostics related to contracts, using the correct verbiage "precondition" and "postcondition" instead of "assertion"
  6. Generalized the LaurelPass concept so it works for all transformation between Laurel source and Core, not just the Laurel->Laurel transformation. This helps make the documentation more complete.

Why let the transparency pass rewrite the bodies of assume statements so they don't assert anything?

After the contract pass, a call will look like assert <preconditions>; call(..); assume <postconditions>, where the body of the callee looks like assume <preconditions>; <body>; assert <postconditions>. If we now do either concrete execution, or we do inlining, then any assertions that occur inside the pre or postconditions will be asserted twice, because they occur once in an assert and once in an assume. By ignoring the assertions inside the assume, we prevent the duplication.

Whether you also want this behavior for assumptions that were created by users is something I'm not sure about. However, if we want we can let those behave differently. Right now I think we don't have enough data to decide what we want for user created assumptions, and they are AFAIK not yet used, so I think it's OK to change their behavior.

Implementation

Add these passes:

  • [New] EliminateReturnStatements: rewrite return to exit statements, needed for the next pass.
  • [New] ContractPass: translate away pre and postconditions entirely by introducing assertion and assumptions at call sites and at procedure starts and ends
  • [Updated] Lift assertions, assumptions and procedure calls when they occur in expressions. Note: the changes in this pass could have been extracted to a different PR to reduce the scope of this one, but I think that keeping them in this PR is most efficient from a developer time perspective.

Follow-up work

  • Remove the now obsolete functions from Laurel
  • Create WF proofs for quantifier bodies
  • Lift assumptions in expressions to axioms.
  • In the transparency phase, if something has no asserts and only calls functions, only create a function and no procedure

@fabiomadge

Copy link
Copy Markdown
Contributor

Re-checked at 4efaebd45: assert/assume expr-position regression fixed via transformLiftedExpr (clears subst, still lifts calls/holes). Verified (546 green): assert foo() == 5 translates clean; assert <??>assertion does not hold, not the old crash. Older threads addressed/non-gating (inline).

Note: PR is CONFLICTING (7 Laurel files vs base, incl. LiftImperativeExpressions.lean) — worth confirming the fix survives the merge.

fabiomadge
fabiomadge previously approved these changes Jun 22, 2026
Comment thread StrataTest/Languages/Laurel/Examples/Objects/T1_MutableFields.lean
@keyboardDrummer keyboardDrummer added this pull request to the merge queue Jun 23, 2026
Merged via the queue into strata-org:reviewed-kbd-will-merge-to-main with commit 49c3e1a Jun 23, 2026
15 checks passed
fabiomadge added a commit to fabiomadge/Strata that referenced this pull request Jun 26, 2026
ContractPass (strata-org#1352, Remy Willems) was correct for every program expressible on
its base: the base has no `<T>` grammar binder and nothing constructs a `.TVar`
parameter type, so a polymorphic procedure could not exist and the pass only ever
saw monomorphic procedures. This extends it to the new input class strata-org#1394 introduces
— procedures with type parameters — which is an integration requirement of the
polymorphism work, not a defect in strata-org#1352.

Two changes, both gated on the presence of a type variable (no-op, byte-identical
for monomorphic callees):
- Call-site arg temps: the pass emits `var $cp : <paramType> := arg` typed at the
  callee's DECLARED param type. For a polymorphic callee (`idp<T>(x: T)`) that is the
  type variable `T`, which resolution treats as authoritative and never replaces with
  the argument's type — so `T` reaches Core unbound, and a multi-instantiation body
  (`idp(5)` + `idp(true)`) forces one `T` to be both `int` and `bool`. When a param
  type mentions a `.TVar`, type the temp from the ARGUMENT instead (`computeExprType`),
  giving each call site its own instantiation.
- Contract helper functions: `$pre`/`$post` helpers mention `T` in their parameter
  types, so they must bind the procedure's `typeArgs`.

Threads the SemanticModel into the pass (it was discarded) to type arguments.
Surfaced by rebasing strata-org#1394 onto a base that had since added ContractPass; pins
poly_proc_sound / poly_proc_multi / generic_map_param.
fabiomadge added a commit to fabiomadge/Strata that referenced this pull request Jun 26, 2026
The post-covariance checker asserted Parent.post after assuming Child.post, but with
`preconditions := []` — it never assumed Parent.pre. Behavioral post-covariance is
`Parent.pre ⇒ (Child.post ⇒ Parent.post)`: a caller invoking the parent contract has
already established Parent.pre, so a sound covariant override whose post implies the
parent's only UNDER the parent precondition (e.g. parent `requires a >= 0 ensures
r >= 0`, child `ensures r == a`) was spuriously OVER-REJECTED.

Fix: the post-checker now assumes Parent.pre (renamed into the child's parameter names).
Parent.pre — NOT Child.pre: Child.pre is contravariantly weaker and assuming it would be
unsound; Parent.pre ⇒ Child.pre is enforced separately by the pre-checker. Completeness
fix (was over-rejection, never wrong-accept); the genuine-violation twin still fails even
with Parent.pre assumed.

Surfaced by rebasing onto a base that added ContractPass (strata-org#1352), which made `requires`
clauses meaningful end-to-end and exposed this latent gap. 2 new corpus cases
(liskov_post_covariance_under_parent_pre + its must-fail violation twin). Full lake test
green (only pre-existing ion-java).
fabiomadge added a commit that referenced this pull request Jun 26, 2026
…ring

Merging the base brought in #1352, whose ContractPass desugars every
procedure contract into helper functions and strips the procedure's
preconditions. Core's `mkContractWFProc` checks a postcondition's
partial-op well-formedness assuming the procedure's preconditions (and
earlier postconditions) in declaration order; with the preconditions
stripped, that context is gone, so a postcondition that is well-formed
only under those conditions becomes unprovable. This broke the Seq/Array
contract tests (T24 `contractSeq`, T25 `setFirst`), where `Sequence.select`
/ `a[0]` carry bounds preconditions.

Fix (no Core change):
- ContractPass keeps a procedure's non-procedure-calling preconditions as
  *free* instead of stripping them. Call-site checking is unchanged (the
  `$pre` helpers still assert them), but `mkContractWFProc` and the body VC
  again assume them, restoring the context for native (opaque-path)
  postcondition WF. Procedure-calling preconditions are dropped from the
  spec (illegal as a contract expression) and enforced only via their
  `$pre` helper.
- The `$postN` helper body is guarded as `context ==> condition` (context =
  preconditions ++ earlier postconditions). Core's WF-obligation collector
  assumes an implication's LHS when checking the RHS, so the helper's own
  partial-op WF sees that context — this reaches the sibling-postcondition
  case (e.g. `ensures Array.length(a) > 0; ensures a[0] == v`) that a
  precondition alone cannot supply.
- TransparencyPass strips preconditions from the `$asFunction` twin: now
  that ContractPass retains them on the procedure, the twin would otherwise
  inherit them and fire a duplicate WF obligation at every application.
- SubscriptElim: adapt `subscriptElimPass` to the renamed `LoweringPass`
  and 3-argument `run` signature introduced by the base.

Verified: full StrataTest suite (566 jobs) green; adversarial soundness
probes pass (false pre/postconditions still rejected, call-site precondition
violations still caught), differential-identical to the base.

See #1418.
fabiomadge added a commit that referenced this pull request Jun 30, 2026
…ring

Merging the base brought in #1352, whose ContractPass desugars every
procedure contract into helper functions and strips the procedure's
preconditions. Core's `mkContractWFProc` checks a postcondition's
partial-op well-formedness assuming the procedure's preconditions (and
earlier postconditions) in declaration order; with the preconditions
stripped, that context is gone, so a postcondition that is well-formed
only under those conditions becomes unprovable. This broke the Seq/Array
contract tests (T24 `contractSeq`, T25 `setFirst`), where `Sequence.select`
/ `a[0]` carry bounds preconditions.

Fix (no Core change):
- The `$postN` postcondition helper carries the procedure's preconditions
  plus the earlier postconditions as *free* preconditions of the helper.
  Core's function-WF generation assumes a function's preconditions in order
  before checking the WF of its body, so a partial op in the postcondition
  is checked with that context in scope. Carrying them on the helper (rather
  than guarding the body as `assumed ==> condition`) means a call site that
  applies the helper learns the postcondition directly; `free` keeps them
  assumption-only, so they are never re-asserted there.
- ContractPass keeps a procedure's non-procedure-calling preconditions as
  *free* instead of stripping them. The opaque path retains the postcondition
  natively on the procedure (it is the impl-satisfaction check, run by Core's
  `ProcedureEval`/`mkContractWFProc` over `spec.postconditions` — distinct
  from the `$post` helper, which only handles call sites). Without the
  preconditions in scope, that native postcondition's partial-op WF is
  unprovable. Call-site checking is unchanged (the `$pre` helpers still assert
  them). Procedure-calling preconditions are dropped from the spec (illegal as
  a contract expression) and enforced only via their `$pre` helper.
- TransparencyPass strips preconditions from the `$asFunction` twin: now that
  ContractPass retains them on the procedure, the twin would otherwise inherit
  them and fire a duplicate WF obligation at every application.
- SubscriptElim: adapt `subscriptElimPass` to the renamed `LoweringPass` and
  3-argument `run` signature introduced by the base.

Verified: full StrataTest suite (566 jobs) green; adversarial soundness
probes pass (false pre/postconditions rejected, call-site precondition
violations caught), differential-identical to base.

See #1418.
fabiomadge added a commit that referenced this pull request Jun 30, 2026
…ring

Merging the base brought in #1352, whose ContractPass desugars every
procedure contract into helper functions and strips the procedure's
preconditions. Core's `mkContractWFProc` checks a postcondition's
partial-op well-formedness assuming the procedure's preconditions (and
earlier postconditions) in declaration order; with the preconditions
stripped, that context is gone, so a postcondition that is well-formed
only under those conditions becomes unprovable. This broke the Seq/Array
contract tests (T24 `contractSeq`, T25 `setFirst`), where `Sequence.select`
/ `a[0]` carry bounds preconditions.

Fix (no Core change):
- The `$postN` postcondition helper carries the procedure's preconditions
  plus the earlier postconditions as *free* preconditions of the helper.
  Core's function-WF generation assumes a function's preconditions in order
  before checking the WF of its body, so a partial op in the postcondition
  is checked with that context in scope. Carrying them on the helper (rather
  than guarding the body as `assumed ==> condition`) means a call site that
  applies the helper learns the postcondition directly; `free` keeps them
  assumption-only, so they are never re-asserted there.
- ContractPass keeps a procedure's non-procedure-calling preconditions as
  *free* instead of stripping them. The opaque path retains the postcondition
  natively on the procedure (it is the impl-satisfaction check, run by Core's
  `ProcedureEval`/`mkContractWFProc` over `spec.postconditions` — distinct
  from the `$post` helper, which only handles call sites). Without the
  preconditions in scope, that native postcondition's partial-op WF is
  unprovable. Call-site checking is unchanged (the `$pre` helpers still assert
  them). Procedure-calling preconditions are dropped from the spec (illegal as
  a contract expression) and enforced only via their `$pre` helper.
- TransparencyPass strips preconditions from the `$asFunction` twin: now that
  ContractPass retains them on the procedure, the twin would otherwise inherit
  them and fire a duplicate WF obligation at every application.
- SubscriptElim: adapt `subscriptElimPass` to the renamed `LoweringPass` and
  3-argument `run` signature introduced by the base.

Verified: full StrataTest suite (566 jobs) green; adversarial soundness
probes pass (false pre/postconditions rejected, call-site precondition
violations caught), differential-identical to base.

See #1418.
fabiomadge added a commit that referenced this pull request Jun 30, 2026
…ring

Merging the base brought in #1352, whose ContractPass desugars every
procedure contract into helper functions and strips the procedure's
preconditions. Core's `mkContractWFProc` checks a postcondition's
partial-op well-formedness assuming the procedure's preconditions (and
earlier postconditions) in declaration order; with the preconditions
stripped, that context is gone, so a postcondition that is well-formed
only under those conditions becomes unprovable. This broke the Seq/Array
contract tests (T24 `contractSeq`, T25 `setFirst`), where `Sequence.select`
/ `a[0]` carry bounds preconditions.

Fix (no Core change):
- The `$postN` postcondition helper carries the procedure's preconditions
  plus the earlier postconditions as *free* preconditions of the helper.
  Core's function-WF generation assumes a function's preconditions in order
  before checking the WF of its body, so a partial op in the postcondition
  is checked with that context in scope. Carrying them on the helper (rather
  than guarding the body as `assumed ==> condition`) means a call site that
  applies the helper learns the postcondition directly; `free` keeps them
  assumption-only, so they are never re-asserted there.
- ContractPass keeps a procedure's non-procedure-calling preconditions as
  *free* instead of stripping them. The opaque path retains the postcondition
  natively on the procedure (it is the impl-satisfaction check, run by Core's
  `ProcedureEval`/`mkContractWFProc` over `spec.postconditions` — distinct
  from the `$post` helper, which only handles call sites). Without the
  preconditions in scope, that native postcondition's partial-op WF is
  unprovable. Call-site checking is unchanged (the `$pre` helpers still assert
  them). Procedure-calling preconditions are dropped from the spec (illegal as
  a contract expression) and enforced only via their `$pre` helper.
- TransparencyPass strips preconditions from the `$asFunction` twin: now that
  ContractPass retains them on the procedure, the twin would otherwise inherit
  them and fire a duplicate WF obligation at every application.
- SubscriptElim: adapt `subscriptElimPass` to the renamed `LoweringPass` and
  3-argument `run` signature introduced by the base.

Verified: full StrataTest suite (566 jobs) green; adversarial soundness
probes pass (false pre/postconditions rejected, call-site precondition
violations caught), differential-identical to base.

See #1418.
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.

5 participants