Skip to content

[Laurel] Support constrained types as composite fields#1364

Merged
olivier-aws merged 24 commits into
reviewed-kbd-will-merge-to-mainfrom
pr-constrained-composite-fields-main2
Jun 23, 2026
Merged

[Laurel] Support constrained types as composite fields#1364
olivier-aws merged 24 commits into
reviewed-kbd-will-merge-to-mainfrom
pr-constrained-composite-fields-main2

Conversation

@olivier-aws

@olivier-aws olivier-aws commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for constrained types as composite fields in Laurel — e.g. a composite with a field of a refinement type:

constrained nat = x: int where x >= 0 witness 0
composite Counter { var count: nat }

Field writes now check the constraint (c#count := -1 fails), and the field is boxed as its base type on the heap.

ConstrainedTypeElim changes made along the way

Supporting this cleanly required running ConstrainedTypeElim earlier in the Laurel pipeline (right after typeAliasElim, before HeapParameterization) so that constrained field types are lowered to their base types before the heap-boxing pass needs them. Moving the pass forward exposed a few places where it had implicitly relied on running last, which this PR fixes:

  1. Composite field typesConstrainedTypeElim removed the constrained type definitions but left UserDefined "nat" references inside composite fields, which then failed to resolve in Core translation. elimCompositeType now resolves constrained field types to their base types (and runs elimination on instance procedures) before the definitions are removed. The field-write constraint check (previously enabled by HeapParameterization via a Declare temporary) is now emitted directly from the .Field assignment target.

  2. Constrained-typed assignments in expression positionelimStmt only checked assignments that appear as statements, so y := (x := -1) + 1 with x : nat was unchecked. wrapExprAssigns now traverses expression positions and wraps such assignments as { x := v; assert T$constraint(x); x }, asserting on a read-back so the RHS is evaluated exactly once (semantics-preserving).

  3. LiftExpressionAssignments assert ordering — for the read-back form to work, the assert must stay after the assignment through lowering. transformExpr was prepending .Assert/.Assume in expression-position blocks only after the assignment had already been prepended, moving them ahead of it (so they checked the stale value). transformExpr now lifts .Assert/.Assume during its traversal so they keep their position relative to assignments lifted from the same block. This is a general correctness fix for the lifting pass.

  4. Cleanup — once ConstrainedTypeElim runs first, HeapParameterization no longer needs its own constrained-type resolution (the field types reaching it are already base types), so the dead resolveConstrainedType helper and the shared resolveConstrainedTypeWith helper in LaurelAST were removed. Also included the offending type name in the LaurelToCoreTranslator "could not be resolved" diagnostic.

Pipeline placement

constrainedTypeElimPass is moved to the second position (after typeAliasElimPass). It has no comesBefore ordering constraints and nothing depends on it running late, so the load-time comesBeforeRespected check still holds.

Notes

Testing

  • Full lake build passes (495 jobs, including all #guard_msgs elaboration-time tests and the load-time pipeline-ordering check).
  • T11_ConstrainedField covers constrained composite fields (valid/invalid writes + a documented read-side completeness gap).
  • T10_ConstrainedTypes gains a sideEffect case exercising an assignment-in-expression to a constrained local.

Add resolveConstrainedType helper in HeapParameterization that chases
constrained type chains to their base type before choosing a Box
variant. Without this, a field of constrained type (e.g. nat = int
where x >= 0) would incorrectly be boxed as BoxComposite instead of
BoxInt.
T11: constraint violation when assigning -1 to a nat field

(Split from combined test commit; bitvector T10 case moved to the
bitvector branch.)
HeapParameterization and ConstrainedTypeElim previously had separate
implementations for resolving a constrained type to its base type, which
risked divergence (e.g. HeapParameterization did not recurse into type
application arguments, and used fuel-bounded recursion instead of chasing
the full chain).

Extract a single lookup-agnostic resolveConstrainedTypeWith in LaurelAST
that takes a lookup closure (Identifier -> Option ConstrainedType). Both
passes now delegate to it:
- HeapParameterization supplies a SemanticModel-backed lookup
- ConstrainedTypeElim supplies a ConstrainedTypeMap-backed lookup

This guarantees both passes always agree on the base type chosen for any
constrained type, including constrained types nested in type applications.
Add readCountCompletenessGap to T11 documenting that reading a constrained
composite field does not recover its constraint: HeapParameterization boxes
the field as its unconstrained base type (BoxInt) and no 'assume constraint'
is inserted on field reads, so a legitimately constructed nat field cannot be
relied upon as >= 0 after a read.

Writes are sound (constraint is asserted); the gap is read-side completeness
only. Local constrained variables are unaffected (uninitialized locals get an
assume). To be fixed in a follow-up PR by assuming the constraint on field
reads and maintaining it across havoc/modifies.
Moving ConstrainedTypeElim to the front of the Laurel pipeline (before
HeapParameterization and LiftExpressionAssignments) exposed two ways the
pass silently dropped constraint checks, because it implicitly assumed
those later passes had already run. This fixes both and adds a supporting
fix to LiftExpressionAssignments.

1. Constrained types in composite fields (T11_ConstrainedField)

   constrainedTypeElim removed the constrained type definitions from the
   program but left `UserDefined "nat"` references inside composite field
   types. When the pass ran last, HeapParameterization had already boxed
   those fields (BoxInt) using the still-present model entry, so nothing
   dangled. Running first, the `nat` reference survived to the final Core
   translation, which failed with "UserDefined type nat could not be
   resolved to a composite or datatype".

   Fix: elimCompositeType now resolves constrained field types to their
   base types and runs elimination on the composite's instance procedures
   before the constrained type definitions are removed. The field-write
   constraint check that HeapParameterization used to enable (via a Declare
   temporary) is now emitted directly from the .Field assignment target,
   asserting the constraint on the stored value.

2. Constrained-typed assignments in expression position (T10 sideEffect)

   elimStmt only inserted reassignment/declaration constraint checks for
   assignments that appear as statements. An assignment nested in an
   expression, e.g. `y := (x := -1) + 1` with `x : nat`, was invisible to
   it. Such assignments are only hoisted to statement level by the later
   LiftExpressionAssignments pass, so when ConstrainedTypeElim ran last the
   check was produced; running first, the `-1` store to `nat` went
   unchecked and the procedure verified unsoundly.

   Fix: wrapExprAssigns traverses expression positions (assignment values
   and if/while conditions) and wraps any assignment to a constrained-typed
   target in `{ x := v; assert T$constraint(x); x }`. The constraint is
   asserted on a read-back of the target after the assignment, so the
   right-hand side `v` is evaluated exactly once (semantics-preserving);
   asserting on a copy of `v` would double-evaluate an impure RHS.

3. LiftExpressionAssignments: preserve assert order in expr-position blocks

   The read-back form above only works if the assert stays after the
   assignment through lowering. transformExpr processes a block's
   statements right-to-left, prepending lifted assignments as it goes, but
   .Assert/.Assume fell through to the catch-all and were prepended later
   by onlyKeepSideEffectStmtsAndLast -- landing in front of the assignment,
   so the assert checked the stale (assumed-valid) pre-assignment value and
   always passed.

   Fix: transformExpr now handles .Assert/.Assume during the traversal,
   prepending them at the right point so they keep their position relative
   to assignments lifted from the same block. They yield a dummy value
   (Core has no assert-expression) that the surrounding block discards as a
   non-final statement. This is a general correctness fix for the lifting
   pass, not specific to constrained types.

Also: include the offending type name in the LaurelToCoreTranslator
"could not be resolved" StrataBug diagnostic, which made (1) diagnosable.

Tests: added the `sideEffect` case with its now-correct error annotation
to T10_ConstrainedTypes. Full `lake build` (including #guard_msgs tests)
passes, along with the constrained-type, composite-field, lifting,
short-circuit, control-flow, and impure-expression example suites.
…terization

`resolveConstrainedType` was added to box constrained composite fields back
when `ConstrainedTypeElim` ran last in the pipeline. Now that
`ConstrainedTypeElim` runs first, it removes all constrained type
definitions and (via `elimCompositeType`) lowers constrained field types to
their base types before `HeapParameterization` runs. After the subsequent
re-resolution the model contains no `.constrainedType` entries, so the
lookup in `resolveConstrainedType` always returns `none` and the function is
an identity.

Confirmed empirically: instrumenting the lookup to trace whenever it finds a
constrained type produced zero hits across the constrained-type,
composite-field, and datatype example suites. `heapParameterization` is only
invoked from the pipeline, so the ordering guarantee always holds.

Remove the helper and use `ty` directly in `boxDestructorName`,
`boxConstructorName`, and `boxConstructorDef`. Full `lake build` passes.
`resolveConstrainedTypeWith` was extracted into LaurelAST so that both
HeapParameterization and ConstrainedTypeElim could share one constrained
type resolution algorithm. HeapParameterization no longer resolves
constrained types (ConstrainedTypeElim runs first and lowers them), leaving
ConstrainedTypeElim as the only user.

Remove the shared helper from LaurelAST and restore ConstrainedTypeElim's
self-contained `resolveBaseType` recursion (which still chases constrained
type chains and recurses into type-application arguments). Full `lake build`
passes.
@olivier-aws olivier-aws force-pushed the pr-constrained-composite-fields-main2 branch from 83afc2a to 3069d9d Compare June 11, 2026 22:03
@olivier-aws olivier-aws changed the title [Laurel] Make ConstrainedTypeElim sound when run before lowering passes [Laurel] Support constrained types as composite fields Jun 11, 2026

@keyboardDrummer keyboardDrummer 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.

Nice!

Comment thread Strata/Languages/Laurel/ConstrainedTypeElim.lean Outdated
@keyboardDrummer keyboardDrummer changed the base branch from main2 to reviewed-kbd-will-merge-to-main June 12, 2026 13:11
@olivier-aws olivier-aws enabled auto-merge June 12, 2026 22:38

@fabiomadge fabiomadge 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.

Already approved, but flagging a verified correctness bug — requesting changes. The feature is sound and deferring the read-side gap is fine (verified: genuine incompleteness, not unsoundness).

Field-write constraint check double-evaluates the RHS. The .Field arm at :222 asserts the constraint on value — the whole RHS expression — which has already been emitted as the field-write statement just above (:197), so any side effect in the RHS runs twice. Verified on this head, with count : nat and x starting at 0: c#count := (x := x + 1) + 1 leaves x == 2, not 1; the ConstrainedTypeElim output is literally c#count := (x:=x+1)+1; assert nat$constraint((x:=x+1)+1)x:=x+1 appears twice, so x is incremented 0→2 instead of 0→1. A legal program gets the wrong state. Fix inline at :222: assert on a read-back of the field, not on value. With it, x == 1 holds (non-vacuous) and suite + proofs stay green once T11's two does not hold carets become could not be proved (the read-back reasons through a heap read). This is in the hand-written elimStmt traversal you flagged at :180 — though it's a double-emission rather than the catch-all incompleteness you mentioned. Notably the pass already has a constrainedTargetReadback helper (:133) that builds exactly this field read-back for expression-position assignments; the statement-level arm just doesn't use it.

Non-blocking — a smaller alternative. The bug, the new wrapExprAssigns, and the edit to the shared LiftImperativeExpressions all stem from moving ConstrainedTypeElim ahead of lifting. Keeping it last (as on main2) and instead resolving constrained field types to base inside HeapParameterization (box selection + Eq/Neq) reaches the same goal: I prototyped it at −79 lines, no shared-pass change, cleaner "does not hold" diagnostic, double-eval can't arise. I know #1356 argued heap-param shouldn't know about constrained types — resurfacing it only because that was decided before this bug and before I'd verified the alternative end-to-end. Your call, not a blocker.

Minor aside (no action). Eq/Neq ref-compares any .UserDefined _ operand without resolving constrained types. The reorder makes that safe here (verified: c#count == d#count lowers to an int compare), so it's an ordering invariant, not a live bug — but a one-line resolveBoxType there would make it order-independent.

Comment thread Strata/Languages/Laurel/ConstrainedTypeElim.lean Outdated
The .Field arm of ConstrainedTypeElim.elimStmt asserted the constrained
field-write check on `value` -- the full RHS expression -- which had
already been emitted as the field-write statement just above. Any side
effect in the RHS therefore ran twice (e.g. `c#count := (x := x+1) + 1`
incremented `x` 0->2 instead of 0->1), so a legal program reached the
wrong state.

Assert the constraint on a read-back of the field instead of on `value`,
so the RHS is evaluated exactly once. This mirrors the existing
`constrainedTargetReadback` helper used for expression-position
assignments. The pipeline order is unchanged.

Because the read-back reasons through a symbolic heap read, the two
T11_ConstrainedField diagnostics change from "assertion does not hold"
to "assertion could not be proved"; annotations updated to match.

Full `lake build` passes (495 jobs).
Adds `fieldWriteEvaluatesRhsOnce` to T11_ConstrainedField, covering the
soundness bug Fabio Madge flagged on PR #1364: writing a constrained
field with a side-effecting RHS, `c#count := (x := x + 1) + 1`, must run
the side effect exactly once. The test asserts `x == 1` after the write;
under the previous double-evaluation bug `x` would be 2 and the assertion
fails. Verified the test fails when the bug is reintroduced and passes
with the read-back fix.

Full `lake build` passes.
@olivier-aws olivier-aws requested a review from fabiomadge June 15, 2026 11:40
…ields-refac

Constrained composite fields refac
fabiomadge
fabiomadge previously approved these changes Jun 23, 2026
@olivier-aws olivier-aws added this pull request to the merge queue Jun 23, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Jun 23, 2026
@olivier-aws olivier-aws enabled auto-merge June 23, 2026 13:23
@olivier-aws

Copy link
Copy Markdown
Contributor Author

@keyboardDrummer-bot please fix conflicts

…mposite-fields-main2

Resolve conflicts in ConstrainedTypeElim.lean and MapStmtExpr.lean:
- Keep PR's elimStmts/elimNode approach, discard base branch's old elimStmt code
- Add postTest parameter to While patterns in mapStmtExprUsedM and mapStmtExprFlattenM
@olivier-aws olivier-aws added this pull request to the merge queue Jun 23, 2026
Merged via the queue into reviewed-kbd-will-merge-to-main with commit 6a074b1 Jun 23, 2026
18 checks passed
@olivier-aws olivier-aws deleted the pr-constrained-composite-fields-main2 branch June 23, 2026 15:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants