[Laurel] Support constrained types as composite fields#1364
Conversation
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.
83afc2a to
3069d9d
Compare
fabiomadge
left a comment
There was a problem hiding this comment.
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.
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.
…mposite-fields-main2
…ields-refac Constrained composite fields refac
… into pr-constrained-composite-fields-main2
|
@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
921b86f
6a074b1
Summary
Adds support for constrained types as composite fields in Laurel — e.g. a composite with a field of a refinement type:
Field writes now check the constraint (
c#count := -1fails), and the field is boxed as its base type on the heap.ConstrainedTypeElim changes made along the way
Supporting this cleanly required running
ConstrainedTypeElimearlier in the Laurel pipeline (right aftertypeAliasElim, beforeHeapParameterization) 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:Composite field types —
ConstrainedTypeElimremoved the constrained type definitions but leftUserDefined "nat"references inside composite fields, which then failed to resolve in Core translation.elimCompositeTypenow 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 byHeapParameterizationvia aDeclaretemporary) is now emitted directly from the.Fieldassignment target.Constrained-typed assignments in expression position —
elimStmtonly checked assignments that appear as statements, soy := (x := -1) + 1withx : natwas unchecked.wrapExprAssignsnow 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).LiftExpressionAssignmentsassert ordering — for the read-back form to work, the assert must stay after the assignment through lowering.transformExprwas prepending.Assert/.Assumein expression-position blocks only after the assignment had already been prepended, moving them ahead of it (so they checked the stale value).transformExprnow lifts.Assert/.Assumeduring 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.Cleanup — once
ConstrainedTypeElimruns first,HeapParameterizationno longer needs its own constrained-type resolution (the field types reaching it are already base types), so the deadresolveConstrainedTypehelper and the sharedresolveConstrainedTypeWithhelper inLaurelASTwere removed. Also included the offending type name in theLaurelToCoreTranslator"could not be resolved" diagnostic.Pipeline placement
constrainedTypeElimPassis moved to the second position (aftertypeAliasElimPass). It has nocomesBeforeordering constraints and nothing depends on it running late, so the load-timecomesBeforeRespectedcheck still holds.Notes
main2(which includes the Document Laurel passes and their dependencies, and other documentation updates #1222 pipeline-framework refactor).Testing
lake buildpasses (495 jobs, including all#guard_msgselaboration-time tests and the load-time pipeline-ordering check).T11_ConstrainedFieldcovers constrained composite fields (valid/invalid writes + a documented read-side completeness gap).T10_ConstrainedTypesgains asideEffectcase exercising an assignment-in-expression to a constrained local.