From d5d5cb85cfb396fc89c37f1b600cda71ff68145a Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 18:21:08 +0000 Subject: [PATCH 001/189] Add type checking to Laurel resolution pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change resolveStmtExpr to return (StmtExprMd × HighTypeMd) - Add type checks for: - Boolean conditions in if/while/assert/assume - Numeric operands in arithmetic/comparison operations - Boolean operands in logical operations - Argument types matching parameter types in static calls - Argument types matching parameter types in instance calls - Assignment value type matching target type - Function body type matching declared output type - Report type mismatches as diagnostics (compilation continues) - Handle cascading errors: Unknown types are compatible with everything, UserDefined types skip strict checking (subtype relationships not tracked), void types skip assignment checks (statements don't produce values) Closes #1120 --- Strata/Languages/Laurel/Resolution.lean | 345 +++++++++++++++++------- 1 file changed, 253 insertions(+), 92 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 16bcf1333f..287382d3f9 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -326,7 +326,14 @@ def resolveHighType (ty : HighTypeMd) : ResolveM HighTypeMd := do | .UserDefined ref => let ref' ← resolveRef ref ty.source (expected := #[.compositeType, .constrainedType, .datatypeDefinition, .typeAlias]) - pure (.UserDefined ref') + -- If the reference resolved to the wrong kind, treat the type as Unknown to avoid cascading errors + let s ← get + let kindOk : Bool := match s.scope.get? ref.text with + | some (_, node) => node.kind == .unresolved || + (#[ResolvedNodeKind.compositeType, .constrainedType, .datatypeDefinition, .typeAlias].contains node.kind) + | none => true -- unresolved references already reported + if kindOk then pure (HighType.UserDefined ref') + else pure HighType.Unknown | .TTypedField vt => let vt' ← resolveHighType vt pure (.TTypedField vt') @@ -353,40 +360,119 @@ def resolveHighType (ty : HighTypeMd) : ResolveM HighTypeMd := do | other => pure other return { val := val', source := ty.source } -def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM StmtExprMd := do +/-- Emit a type mismatch diagnostic. -/ +private def typeMismatch (source : Option FileRange) (expected : String) (actual : HighTypeMd) : ResolveM Unit := do + let actualStr := toString (formatHighTypeVal actual.val) + let diag := diagnosticFromSource source s!"Type mismatch: expected {expected}, but got '{actualStr}'" + modify fun s => { s with errors := s.errors.push diag } + +/-- Check that a type is boolean, emitting a diagnostic if not. -/ +private def checkBool (source : Option FileRange) (ty : HighTypeMd) : ResolveM Unit := do + match ty.val with + | .TBool | .Unknown => pure () + | .UserDefined _ => pure () -- constrained types may wrap bool + | _ => typeMismatch source "bool" ty + +/-- Check that a type is numeric (int, real, or float64), emitting a diagnostic if not. -/ +private def checkNumeric (source : Option FileRange) (ty : HighTypeMd) : ResolveM Unit := do + match ty.val with + | .TInt | .TReal | .TFloat64 | .Unknown => pure () + | .UserDefined _ => pure () -- constrained types may wrap numeric types + | _ => typeMismatch source "a numeric type" ty + +/-- Check that two types are compatible, emitting a diagnostic if not. + UserDefined types are always considered compatible with each other since + subtype relationships (inheritance) are not tracked during resolution. -/ +private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do + match expected.val, actual.val with + | .Unknown, _ => pure () + | _, .Unknown => pure () + | _, .MultiValuedExpr _ => pure () -- arity mismatch already reported separately + | .UserDefined _, _ => pure () -- subtype relationships not tracked here + | _, .UserDefined _ => pure () -- subtype relationships not tracked here + | _, _ => + if !highEq expected actual then + let expectedStr := toString (formatHighTypeVal expected.val) + let actualStr := toString (formatHighTypeVal actual.val) + let diag := diagnosticFromSource source s!"Type mismatch: expected '{expectedStr}', but got '{actualStr}'" + modify fun s => { s with errors := s.errors.push diag } + +/-- Get the type of a resolved variable reference from scope. -/ +private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do + let s ← get + match s.scope.get? ref.text with + | some (_, node) => pure node.getType + | none => pure { val := .Unknown, source := ref.source } + +/-- Get the call return type and parameter types for a callee from scope. -/ +private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List HighTypeMd) := do + let s ← get + match s.scope.get? callee.text with + | some (_, .staticProcedure proc) => + let retTy := match proc.outputs with + | [singleOutput] => singleOutput.type + | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } + pure (retTy, proc.inputs.map (·.type)) + | some (_, .instanceProcedure _ proc) => + let retTy := match proc.outputs with + | [singleOutput] => singleOutput.type + | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } + pure (retTy, proc.inputs.map (·.type)) + | some (_, .datatypeConstructor t _) => + -- Testers (e.g. "Color..isRed") return Bool; constructors return the type + if (callee.text.splitOn "..is").length > 1 then + pure ({ val := .TBool, source := callee.source }, []) + else + pure ({ val := .UserDefined t, source := callee.source }, []) + | some (_, .parameter p) => pure (p.type, []) + | some (_, .constant c) => pure (c.type, []) + | _ => pure ({ val := .Unknown, source := callee.source }, []) + +def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match _: exprMd with | AstNode.mk expr source => - let val' ← match _: expr with + let (val', ty) ← match _: expr with | .IfThenElse cond thenBr elseBr => - let cond' ← resolveStmtExpr cond - let thenBr' ← resolveStmtExpr thenBr - let elseBr' ← elseBr.attach.mapM (fun a => have := a.property; resolveStmtExpr a.val) - pure (.IfThenElse cond' thenBr' elseBr') + let (cond', condTy) ← resolveStmtExpr cond + checkBool cond'.source condTy + let (thenBr', thenTy) ← resolveStmtExpr thenBr + let elseBr' ← elseBr.attach.mapM (fun a => have := a.property; do + let (e', _) ← resolveStmtExpr a.val; pure e') + pure (.IfThenElse cond' thenBr' elseBr', thenTy) | .Block stmts label => withScope do - let stmts' ← stmts.mapM resolveStmtExpr - pure (.Block stmts' label) + let results ← stmts.mapM resolveStmtExpr + let stmts' := results.map (·.1) + let lastTy := match results.getLast? with + | some (_, ty) => ty + | none => { val := .TVoid, source := source } + pure (.Block stmts' label, lastTy) | .While cond invs dec body => - let cond' ← resolveStmtExpr cond - let invs' ← invs.attach.mapM (fun a => have := a.property; resolveStmtExpr a.val) - let dec' ← dec.attach.mapM (fun a => have := a.property; resolveStmtExpr a.val) - let body' ← resolveStmtExpr body - pure (.While cond' invs' dec' body') - | .Exit target => pure (.Exit target) + let (cond', condTy) ← resolveStmtExpr cond + checkBool cond'.source condTy + let invs' ← invs.attach.mapM (fun a => have := a.property; do + let (e', _) ← resolveStmtExpr a.val; pure e') + let dec' ← dec.attach.mapM (fun a => have := a.property; do + let (e', _) ← resolveStmtExpr a.val; pure e') + let (body', _) ← resolveStmtExpr body + pure (.While cond' invs' dec' body', { val := .TVoid, source := source }) + | .Exit target => pure (.Exit target, { val := .TVoid, source := source }) | .Return val => do - let val' ← val.attach.mapM (fun a => have := a.property; resolveStmtExpr a.val) - pure (.Return val') - | .LiteralInt v => pure (.LiteralInt v) - | .LiteralBool v => pure (.LiteralBool v) - | .LiteralString v => pure (.LiteralString v) - | .LiteralDecimal v => pure (.LiteralDecimal v) + let val' ← val.attach.mapM (fun a => have := a.property; do + let (e', _) ← resolveStmtExpr a.val; pure e') + pure (.Return val', { val := .TVoid, source := source }) + | .LiteralInt v => pure (.LiteralInt v, { val := .TInt, source := source }) + | .LiteralBool v => pure (.LiteralBool v, { val := .TBool, source := source }) + | .LiteralString v => pure (.LiteralString v, { val := .TString, source := source }) + | .LiteralDecimal v => pure (.LiteralDecimal v, { val := .TReal, source := source }) | .Var (.Local ref) => let ref' ← resolveRef ref source - pure (.Var (.Local ref')) + let ty ← getVarType ref + pure (.Var (.Local ref'), ty) | .Var (.Declare param) => let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') - pure (.Var (.Declare ⟨name', ty'⟩)) + pure (.Var (.Declare ⟨name', ty'⟩), { val := .TVoid, source := source }) | .Assign targets value => let targets' ← targets.attach.mapM fun ⟨v, _⟩ => do let ⟨vv, vs⟩ := v @@ -395,14 +481,14 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM StmtExprMd := do let ref' ← resolveRef ref source pure (⟨.Local ref', vs⟩ : VariableMd) | .Field target fieldName => - let target' ← resolveStmtExpr target + let (target', _) ← resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source pure (⟨.Field target' fieldName', vs⟩ : VariableMd) | .Declare param => let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let value' ← resolveStmtExpr value + let (value', valueTy) ← resolveStmtExpr value -- Check that LHS target count matches the number of outputs from the RHS. -- This fires for procedure calls (which can have multiple outputs). -- Functions always have exactly 1 output in the model, so single-target function calls pass trivially. @@ -424,84 +510,144 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM StmtExprMd := do let diag := diagnosticFromSource source s!"Assignment target count mismatch: {targets'.length} targets but right-hand side produces {expectedOutputCount} values" modify fun s => { s with errors := s.errors.push diag } - pure (.Assign targets' value') + -- Type check: for single-target assignments, check value type matches target type + -- Skip when value type is void (RHS is a statement like while/return that doesn't produce a value) + if targets'.length == 1 && valueTy.val != HighType.TVoid then + if let some target := targets'.head? then + let targetTy := match target.val with + | .Local ref => do + let s ← get + match s.scope.get? ref.text with + | some (_, node) => pure node.getType + | none => pure { val := HighType.Unknown, source := ref.source : HighTypeMd } + | .Declare param => pure param.type + | .Field _ fieldName => do + let s ← get + match s.scope.get? fieldName.text with + | some (_, node) => pure node.getType + | none => pure { val := HighType.Unknown, source := fieldName.source : HighTypeMd } + let tTy ← targetTy + checkAssignable source tTy valueTy + pure (.Assign targets' value', valueTy) | .Var (.Field target fieldName) => - let target' ← resolveStmtExpr target + let (target', _) ← resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source - pure (.Var (.Field target' fieldName')) + let ty ← getVarType fieldName + pure (.Var (.Field target' fieldName'), ty) | .PureFieldUpdate target fieldName newVal => - let target' ← resolveStmtExpr target + let (target', targetTy) ← resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source - let newVal' ← resolveStmtExpr newVal - pure (.PureFieldUpdate target' fieldName' newVal') + let (newVal', _) ← resolveStmtExpr newVal + pure (.PureFieldUpdate target' fieldName' newVal', targetTy) | .StaticCall callee args => let callee' ← resolveRef callee source (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) - let args' ← args.mapM resolveStmtExpr - pure (.StaticCall callee' args') + let results ← args.mapM resolveStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let (retTy, paramTypes) ← getCallInfo callee + -- Check argument types match parameter types + for (argTy, paramTy) in argTypes.zip paramTypes do + checkAssignable source paramTy argTy + pure (.StaticCall callee' args', retTy) | .PrimitiveOp op args => - let args' ← args.mapM resolveStmtExpr - pure (.PrimitiveOp op args') + let results ← args.mapM resolveStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let resultTy := match op with + | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies + | .Lt | .Leq | .Gt | .Geq => HighType.TBool + | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => + match argTypes.head? with + | some headTy => headTy.val + | none => HighType.TInt + | .StrConcat => HighType.TString + -- Type check operands + match op with + | .And | .Or | .AndThen | .OrElse | .Not | .Implies => + for aTy in argTypes do checkBool source aTy + | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => + for aTy in argTypes do checkNumeric source aTy + | .Eq | .Neq | .StrConcat => pure () + pure (.PrimitiveOp op args', { val := resultTy, source := source }) | .New ref => let ref' ← resolveRef ref source (expected := #[.compositeType, .datatypeDefinition]) - pure (.New ref') - | .This => pure .This + -- If the reference resolved to the wrong kind, use Unknown type to avoid cascading errors + let s ← get + let kindOk : Bool := match s.scope.get? ref.text with + | some (_, node) => node.kind == .unresolved || + (#[ResolvedNodeKind.compositeType, .datatypeDefinition].contains node.kind) + | none => true + let ty := if kindOk then { val := HighType.UserDefined ref', source := source } + else { val := HighType.Unknown, source := source } + pure (.New ref', ty) + | .This => pure (.This, { val := .Unknown, source := source }) | .ReferenceEquals lhs rhs => - let lhs' ← resolveStmtExpr lhs - let rhs' ← resolveStmtExpr rhs - pure (.ReferenceEquals lhs' rhs') + let (lhs', _) ← resolveStmtExpr lhs + let (rhs', _) ← resolveStmtExpr rhs + pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => - let target' ← resolveStmtExpr target + let (target', _) ← resolveStmtExpr target let ty' ← resolveHighType ty - pure (.AsType target' ty') + pure (.AsType target' ty', ty') | .IsType target ty => - let target' ← resolveStmtExpr target + let (target', _) ← resolveStmtExpr target let ty' ← resolveHighType ty - pure (.IsType target' ty') + pure (.IsType target' ty', { val := .TBool, source := source }) | .InstanceCall target callee args => - let target' ← resolveStmtExpr target + let (target', _) ← resolveStmtExpr target let callee' ← resolveRef callee source (expected := #[.instanceProcedure, .staticProcedure]) - let args' ← args.mapM resolveStmtExpr - pure (.InstanceCall target' callee' args') + let results ← args.mapM resolveStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let (retTy, paramTypes) ← getCallInfo callee + -- Check argument types match parameter types (skip first param which is 'self') + let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] + for (argTy, paramTy) in argTypes.zip callParamTypes do + checkAssignable source paramTy argTy + pure (.InstanceCall target' callee' args', retTy) | .Quantifier mode param trigger body => withScope do let paramTy' ← resolveHighType param.type let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') - let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; resolveStmtExpr pv.val) - let body' ← resolveStmtExpr body - pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body') + let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do + let (e', _) ← resolveStmtExpr pv.val; pure e') + let (body', _) ← resolveStmtExpr body + pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) | .Assigned name => - let name' ← resolveStmtExpr name - pure (.Assigned name') + let (name', _) ← resolveStmtExpr name + pure (.Assigned name', { val := .TBool, source := source }) | .Old val => - let val' ← resolveStmtExpr val - pure (.Old val') + let (val', valTy) ← resolveStmtExpr val + pure (.Old val', valTy) | .Fresh val => - let val' ← resolveStmtExpr val - pure (.Fresh val') + let (val', _) ← resolveStmtExpr val + pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => - let cond' ← resolveStmtExpr condExpr - pure (.Assert { condition := cond', summary }) + let (cond', condTy) ← resolveStmtExpr condExpr + checkBool cond'.source condTy + pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) | .Assume cond => - let cond' ← resolveStmtExpr cond - pure (.Assume cond') + let (cond', condTy) ← resolveStmtExpr cond + checkBool cond'.source condTy + pure (.Assume cond', { val := .TVoid, source := source }) | .ProveBy val proof => - let val' ← resolveStmtExpr val - let proof' ← resolveStmtExpr proof - pure (.ProveBy val' proof') + let (val', valTy) ← resolveStmtExpr val + let (proof', _) ← resolveStmtExpr proof + pure (.ProveBy val' proof', valTy) | .ContractOf ty fn => - let fn' ← resolveStmtExpr fn - pure (.ContractOf ty fn') - | .Abstract => pure .Abstract - | .All => pure .All + let (fn', _) ← resolveStmtExpr fn + pure (.ContractOf ty fn', { val := .Unknown, source := source }) + | .Abstract => pure (.Abstract, { val := .Unknown, source := source }) + | .All => pure (.All, { val := .Unknown, source := source }) | .Hole det type => match type with | some ty => let ty' ← resolveHighType ty - pure (.Hole det ty') - | none => pure (.Hole det none) - return { val := val', source := source } + pure (.Hole det ty', ty') + | none => pure (.Hole det none, { val := .Unknown, source := source }) + return ({ val := val', source := source }, ty) termination_by exprMd decreasing_by all_goals term_by_mem @@ -511,21 +657,21 @@ def resolveParameter (param : Parameter) : ResolveM Parameter := do let name' ← defineNameCheckDup param.name (.parameter ⟨param.name, ty'⟩) return ⟨name', ty'⟩ -/-- Resolve a procedure body. -/ -def resolveBody (body : Body) : ResolveM Body := do +/-- Resolve a procedure body. Returns the resolved body and its type. -/ +def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do match body with | .Transparent b => - let b' ← resolveStmtExpr b - return .Transparent b' + let (b', ty) ← resolveStmtExpr b + return (.Transparent b', ty) | .Opaque posts impl mods => - let posts' ← posts.mapM (·.mapM resolveStmtExpr) - let impl' ← impl.mapM resolveStmtExpr - let mods' ← mods.mapM resolveStmtExpr - return .Opaque posts' impl' mods' + let posts' ← posts.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') + let impl' ← impl.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let mods' ← mods.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + return (.Opaque posts' impl' mods', { val := .TVoid, source := none }) | .Abstract posts => - let posts' ← posts.mapM (·.mapM resolveStmtExpr) - return .Abstract posts' - | .External => return .External + let posts' ← posts.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') + return (.Abstract posts', { val := .TVoid, source := none }) + | .External => return (.External, { val := .TVoid, source := none }) /-- Resolve a procedure: resolve its name, then resolve params, contracts, and body in a new scope. -/ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do @@ -533,14 +679,22 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do withScope do let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) - let dec' ← proc.decreases.mapM resolveStmtExpr - let body' ← resolveBody proc.body + let pres' ← proc.preconditions.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') + let dec' ← proc.decreases.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" modify fun s => { s with errors := s.errors.push diag } - let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr + -- Check body type matches declared output type for functional procedures with transparent bodies + if proc.isFunctional && body'.isTransparent then + match proc.outputs with + | [singleOutput] => + -- Only check when body produces a value (not void from return/while/assign) + if bodyTy.val != HighType.TVoid then + checkAssignable proc.name.source singleOutput.type bodyTy + | _ => pure () + let invokeOn' ← proc.invokeOn.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, preconditions := pres', decreases := dec', @@ -566,14 +720,21 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv modify fun s => { s with instanceTypeName := some typeName.text } let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) - let dec' ← proc.decreases.mapM resolveStmtExpr - let body' ← resolveBody proc.body + let pres' ← proc.preconditions.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') + let dec' ← proc.decreases.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" modify fun s => { s with errors := s.errors.push diag } - let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr + -- Check body type matches declared output type for functional procedures with transparent bodies + if proc.isFunctional && body'.isTransparent then + match proc.outputs with + | [singleOutput] => + if bodyTy.val != HighType.TVoid then + checkAssignable proc.name.source singleOutput.type bodyTy + | _ => pure () + let invokeOn' ← proc.invokeOn.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' modify fun s => { s with instanceTypeName := savedInstType } return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, @@ -615,8 +776,8 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do -- in scope when resolving the constraint and witness expressions. let (valueName', constraint', witness') ← withScope do let valueName' ← defineNameCheckDup ct.valueName (.quantifierVar ct.valueName base') - let constraint' ← resolveStmtExpr ct.constraint - let witness' ← resolveStmtExpr ct.witness + let (constraint', _) ← resolveStmtExpr ct.constraint + let (witness', _) ← resolveStmtExpr ct.witness return (valueName', constraint', witness') return .Constrained { name := ctName', base := base', valueName := valueName', constraint := constraint', witness := witness' } @@ -642,7 +803,7 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do /-- Resolve a constant definition. -/ def resolveConstant (c : Constant) : ResolveM Constant := do let ty' ← resolveHighType c.type - let init' ← c.initializer.mapM resolveStmtExpr + let init' ← c.initializer.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' let name' ← resolveRef c.name return { name := name', type := ty', initializer := init' } From f65de03b28af37b7c24f534c2bcbb8ba25f912b4 Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 18:58:54 +0000 Subject: [PATCH 002/189] Fix type checking: skip TCore types in assignability check TCore is a pass-through type from Core that should not be checked during Laurel resolution. Without this, two identical TCore types (e.g. 'Core Any') would fail highEq (which has no TCore case) and produce spurious 'Type mismatch' diagnostics. --- Strata/Languages/Laurel/Resolution.lean | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 287382d3f9..43d8866d0d 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -390,6 +390,8 @@ private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) | _, .MultiValuedExpr _ => pure () -- arity mismatch already reported separately | .UserDefined _, _ => pure () -- subtype relationships not tracked here | _, .UserDefined _ => pure () -- subtype relationships not tracked here + | .TCore _, _ => pure () -- pass-through Core types not checked during resolution + | _, .TCore _ => pure () -- pass-through Core types not checked during resolution | _, _ => if !highEq expected actual then let expectedStr := toString (formatHighTypeVal expected.val) From 7aaea818895c9b1e2b4f510a8281ea0d586b67bf Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 19:22:28 +0000 Subject: [PATCH 003/189] Simplify assignment arity check to use valueTy directly Derive expected output count from the RHS type (MultiValuedExpr gives the arity, otherwise 1) instead of re-looking up the procedure. This ensures LHS and RHS arity always match for assignments. --- Strata/Languages/Laurel/Resolution.lean | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 43d8866d0d..d87f97cd73 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -491,24 +491,11 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) let (value', valueTy) ← resolveStmtExpr value - -- Check that LHS target count matches the number of outputs from the RHS. - -- This fires for procedure calls (which can have multiple outputs). - -- Functions always have exactly 1 output in the model, so single-target function calls pass trivially. - let expectedOutputCount ← match value'.val with - | .StaticCall callee _ => do - let s ← get - match s.scope.get? callee.text with - | some (_, .staticProcedure proc) => pure proc.outputs.length - | some (_, .instanceProcedure _ proc) => pure proc.outputs.length - | _ => pure 1 - | .InstanceCall _ callee _ => do - let s ← get - match s.scope.get? callee.text with - | some (_, .instanceProcedure _ proc) => pure proc.outputs.length - | some (_, .staticProcedure proc) => pure proc.outputs.length - | _ => pure 1 - | _ => pure 1 - if targets'.length != expectedOutputCount then + -- Check that LHS target count matches the RHS arity (derived from the value type). + let expectedOutputCount := match valueTy.val with + | .MultiValuedExpr tys => tys.length + | _ => 1 + if valueTy.val != HighType.TVoid && targets'.length != expectedOutputCount then let diag := diagnosticFromSource source s!"Assignment target count mismatch: {targets'.length} targets but right-hand side produces {expectedOutputCount} values" modify fun s => { s with errors := s.errors.push diag } From 179d16dc811c006331ae0ae2e98e4c71869556b1 Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 19:26:32 +0000 Subject: [PATCH 004/189] Add tests for type checking error diagnostics in resolution pass Tests confirm that the following type errors are reported: - Non-boolean condition in if/assert/assume/while - Non-boolean operand in logical operators (&&) - Non-numeric operand in comparisons (<) - Assignment type mismatch (int := bool) - Function return type mismatch - Static call argument type mismatch --- .../Laurel/ResolutionTypeCheckTests.lean | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean new file mode 100644 index 0000000000..01ccd40708 --- /dev/null +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -0,0 +1,149 @@ +/- + Copyright Strata Contributors + + SPDX-License-Identifier: Apache-2.0 OR MIT +-/ + +/- +Tests that the resolution pass detects type checking errors — e.g. using an int +where a bool is expected, or passing the wrong type to a procedure. +-/ + +import StrataTest.Util.TestDiagnostics +import Strata.DDM.Elab +import Strata.DDM.BuiltinDialects.Init +import Strata.Languages.Laurel.Grammar.LaurelGrammar +import Strata.Languages.Laurel.Grammar.ConcreteToAbstractTreeTranslator +import Strata.Languages.Laurel.Resolution + +open StrataTest.Util +open Strata +open Strata.Elab (parseStrataProgramFromDialect) + +namespace Strata.Laurel + +/-- Run only parsing + resolution and return diagnostics (no SMT verification). -/ +private def processResolution (input : Lean.Parser.InputContext) : IO (Array Diagnostic) := do + let dialects := Strata.Elab.LoadedDialects.ofDialects! #[initDialect, Laurel] + let strataProgram ← parseStrataProgramFromDialect dialects Laurel.name input + let uri := Strata.Uri.file input.fileName + match Laurel.TransM.run uri (Laurel.parseProgram strataProgram) with + | .error e => throw (IO.userError s!"Translation errors: {e}") + | .ok program => + let result := resolve program + let files := Map.insert Map.empty uri input.fileMap + return result.errors.toList.map (fun dm => dm.toDiagnostic files) |>.toArray + +/-! ## Non-boolean condition in if-then-else -/ + +def ifCondNotBool := r" +function foo(x: int): int { + if x then 1 else 0 +// ^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "IfCondNotBool" ifCondNotBool 39 processResolution + +/-! ## Non-boolean condition in assert -/ + +def assertCondNotBool := r" +procedure baz() opaque { + var x: int := 42; + assert x +// ^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "AssertCondNotBool" assertCondNotBool 49 processResolution + +/-! ## Non-boolean condition in assume -/ + +def assumeCondNotBool := r" +procedure qux() opaque { + var x: int := 42; + assume x +// ^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "AssumeCondNotBool" assumeCondNotBool 59 processResolution + +/-! ## Non-boolean operand in logical and -/ + +def logicalAndNotBool := r" +function foo(x: int, y: bool): bool { + x && y +//^^^^^^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "LogicalAndNotBool" logicalAndNotBool 69 processResolution + +/-! ## Assignment type mismatch -/ + +def assignTypeMismatch := r" +procedure foo() opaque { + var x: int := true +//^^^^^^^^^^^^^^^^^^ error: expected 'int', but got 'bool' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "AssignTypeMismatch" assignTypeMismatch 79 processResolution + +/-! ## Function return type mismatch -/ + +def returnTypeMismatch := r" +function foo(): int { +// ^^^ error: expected 'int', but got 'bool' + true +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "ReturnTypeMismatch" returnTypeMismatch 89 processResolution + +/-! ## Static call argument type mismatch -/ + +def callArgTypeMismatch := r" +function bar(x: int): int { x }; +function foo(): int { + bar(true) +//^^^^^^^^^ error: expected 'int', but got 'bool' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "CallArgTypeMismatch" callArgTypeMismatch 99 processResolution + +/-! ## Non-boolean condition in while loop -/ + +def whileCondNotBool := r" +procedure wh() opaque { + var x: int := 1; + while (x) { } +// ^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "WhileCondNotBool" whileCondNotBool 109 processResolution + +/-! ## Non-numeric operand in comparison -/ + +def comparisonNotNumeric := r" +function cmp(x: string, y: int): bool { + x < y +//^^^^^ error: expected a numeric type, but got 'string' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "ComparisonNotNumeric" comparisonNotNumeric 121 processResolution + +end Laurel From 76ea8dfbf8ae711189974131c7d90988ed3b7e93 Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 19:29:32 +0000 Subject: [PATCH 005/189] Add multi-output procedure in expression position check and test - Add checkSingleValued helper that detects MultiValuedExpr types used in expression position (e.g., as operands to PrimitiveOp) - Emit error: "Multi-output procedure '' used in expression position" - Add ResolutionTypeTests.lean with test for assert multi(1) == 1 --- Strata/Languages/Laurel/Resolution.lean | 17 +++++++ .../Languages/Laurel/ResolutionTypeTests.lean | 50 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 StrataTest/Languages/Laurel/ResolutionTypeTests.lean diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index d87f97cd73..367259b9ac 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -399,6 +399,20 @@ private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) let diag := diagnosticFromSource source s!"Type mismatch: expected '{expectedStr}', but got '{actualStr}'" modify fun s => { s with errors := s.errors.push diag } +/-- Check that an expression is single-valued (not a multi-output procedure call). + Emits an error if the expression has MultiValuedExpr type. -/ +private def checkSingleValued (expr : StmtExprMd) (ty : HighTypeMd) : ResolveM Unit := do + match ty.val with + | .MultiValuedExpr _ => + let calleeName := match expr.val with + | .StaticCall callee _ => callee.text + | .InstanceCall _ callee _ => callee.text + | _ => "expression" + let diag := diagnosticFromSource expr.source + s!"Multi-output procedure '{calleeName}' used in expression position" + modify fun s => { s with errors := s.errors.push diag } + | _ => pure () + /-- Get the type of a resolved variable reference from scope. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get @@ -543,6 +557,9 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) let results ← args.mapM resolveStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) + -- Check that no argument is a multi-output procedure call + for (arg, argTy) in results do + checkSingleValued arg argTy let resultTy := match op with | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies | .Lt | .Leq | .Gt | .Geq => HighType.TBool diff --git a/StrataTest/Languages/Laurel/ResolutionTypeTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeTests.lean new file mode 100644 index 0000000000..b3d10b55f0 --- /dev/null +++ b/StrataTest/Languages/Laurel/ResolutionTypeTests.lean @@ -0,0 +1,50 @@ +/- + Copyright Strata Contributors + + SPDX-License-Identifier: Apache-2.0 OR MIT +-/ + +/- +Tests that the resolution pass detects type checking errors — e.g. using a +multi-output procedure in expression position. +-/ + +import StrataTest.Util.TestDiagnostics +import Strata.DDM.Elab +import Strata.DDM.BuiltinDialects.Init +import Strata.Languages.Laurel.Grammar.LaurelGrammar +import Strata.Languages.Laurel.Grammar.ConcreteToAbstractTreeTranslator +import Strata.Languages.Laurel.Resolution + +open StrataTest.Util +open Strata +open Strata.Elab (parseStrataProgramFromDialect) + +namespace Strata.Laurel + +/-- Run only parsing + resolution and return diagnostics (no SMT verification). -/ +private def processResolution (input : Lean.Parser.InputContext) : IO (Array Diagnostic) := do + let dialects := Strata.Elab.LoadedDialects.ofDialects! #[initDialect, Laurel] + let strataProgram ← parseStrataProgramFromDialect dialects Laurel.name input + let uri := Strata.Uri.file input.fileName + match Laurel.TransM.run uri (Laurel.parseProgram strataProgram) with + | .error e => throw (IO.userError s!"Translation errors: {e}") + | .ok program => + let result := resolve program + let files := Map.insert Map.empty uri input.fileMap + return result.errors.toList.map (fun dm => dm.toDiagnostic files) |>.toArray + +/-! ## Multi-output procedure used in expression position -/ + +def multiOutputInExpr := r" +procedure multi(x: int) returns (a: int, b: int) opaque; +procedure test() opaque { + assert multi(1) == 1 +// ^^^^^^^^ error: Multi-output procedure 'multi' used in expression position +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "MultiOutputInExpr" multiOutputInExpr 42 processResolution + +end Laurel From 0a26f1d6eb180cc153f9116a9bb83ae9913f06cb Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 19:48:41 +0000 Subject: [PATCH 006/189] Remove checkSingleValued; let type checks report multi-output errors naturally Instead of a dedicated 'Multi-output procedure used in expression position' error, multi-output calls in expression position now produce standard type mismatch errors like 'expected int, but got (int, int)'. - Remove checkSingleValued function and its call in PrimitiveOp - Remove MultiValuedExpr skip in checkAssignable - Add Eq/Neq operand compatibility check - Add formatType helper for nice MultiValuedExpr formatting - Skip assignment type check when arity already mismatches --- Strata/Languages/Laurel/Resolution.lean | 42 +++++++++---------- .../Languages/Laurel/ResolutionTypeTests.lean | 2 +- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 367259b9ac..cbedf8f53c 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -360,9 +360,17 @@ def resolveHighType (ty : HighTypeMd) : ResolveM HighTypeMd := do | other => pure other return { val := val', source := ty.source } +/-- Format a type for use in diagnostics. -/ +private def formatType (ty : HighTypeMd) : String := + match ty.val with + | .MultiValuedExpr tys => + let parts := tys.map (fun t => toString (formatHighTypeVal t.val)) + "(" ++ ", ".intercalate parts ++ ")" + | other => toString (formatHighTypeVal other) + /-- Emit a type mismatch diagnostic. -/ private def typeMismatch (source : Option FileRange) (expected : String) (actual : HighTypeMd) : ResolveM Unit := do - let actualStr := toString (formatHighTypeVal actual.val) + let actualStr := formatType actual let diag := diagnosticFromSource source s!"Type mismatch: expected {expected}, but got '{actualStr}'" modify fun s => { s with errors := s.errors.push diag } @@ -387,32 +395,17 @@ private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) match expected.val, actual.val with | .Unknown, _ => pure () | _, .Unknown => pure () - | _, .MultiValuedExpr _ => pure () -- arity mismatch already reported separately | .UserDefined _, _ => pure () -- subtype relationships not tracked here | _, .UserDefined _ => pure () -- subtype relationships not tracked here | .TCore _, _ => pure () -- pass-through Core types not checked during resolution | _, .TCore _ => pure () -- pass-through Core types not checked during resolution | _, _ => if !highEq expected actual then - let expectedStr := toString (formatHighTypeVal expected.val) - let actualStr := toString (formatHighTypeVal actual.val) + let expectedStr := formatType expected + let actualStr := formatType actual let diag := diagnosticFromSource source s!"Type mismatch: expected '{expectedStr}', but got '{actualStr}'" modify fun s => { s with errors := s.errors.push diag } -/-- Check that an expression is single-valued (not a multi-output procedure call). - Emits an error if the expression has MultiValuedExpr type. -/ -private def checkSingleValued (expr : StmtExprMd) (ty : HighTypeMd) : ResolveM Unit := do - match ty.val with - | .MultiValuedExpr _ => - let calleeName := match expr.val with - | .StaticCall callee _ => callee.text - | .InstanceCall _ callee _ => callee.text - | _ => "expression" - let diag := diagnosticFromSource expr.source - s!"Multi-output procedure '{calleeName}' used in expression position" - modify fun s => { s with errors := s.errors.push diag } - | _ => pure () - /-- Get the type of a resolved variable reference from scope. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get @@ -515,7 +508,8 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) modify fun s => { s with errors := s.errors.push diag } -- Type check: for single-target assignments, check value type matches target type -- Skip when value type is void (RHS is a statement like while/return that doesn't produce a value) - if targets'.length == 1 && valueTy.val != HighType.TVoid then + -- Skip when there's an arity mismatch (already reported above) + if targets'.length == 1 && targets'.length == expectedOutputCount && valueTy.val != HighType.TVoid then if let some target := targets'.head? then let targetTy := match target.val with | .Local ref => do @@ -557,9 +551,6 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) let results ← args.mapM resolveStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) - -- Check that no argument is a multi-output procedure call - for (arg, argTy) in results do - checkSingleValued arg argTy let resultTy := match op with | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies | .Lt | .Leq | .Gt | .Geq => HighType.TBool @@ -574,7 +565,12 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) for aTy in argTypes do checkBool source aTy | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => for aTy in argTypes do checkNumeric source aTy - | .Eq | .Neq | .StrConcat => pure () + | .Eq | .Neq => + -- Check that operands are compatible with each other + match argTypes with + | [lhsTy, rhsTy] => checkAssignable source rhsTy lhsTy + | _ => pure () + | .StrConcat => pure () pure (.PrimitiveOp op args', { val := resultTy, source := source }) | .New ref => let ref' ← resolveRef ref source diff --git a/StrataTest/Languages/Laurel/ResolutionTypeTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeTests.lean index b3d10b55f0..89ac1a162c 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeTests.lean @@ -40,7 +40,7 @@ def multiOutputInExpr := r" procedure multi(x: int) returns (a: int, b: int) opaque; procedure test() opaque { assert multi(1) == 1 -// ^^^^^^^^ error: Multi-output procedure 'multi' used in expression position +// ^^^^^^^^^^^^^ error: expected 'int', but got '(int, int)' }; " From 2acc758f5b79f3ca8281301602187296227850db Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Wed, 6 May 2026 17:07:52 +0000 Subject: [PATCH 007/189] Address review feedback: symmetric Eq/Neq errors, extract helper, consolidate tests - Add checkComparable helper for symmetric Eq/Neq error messages ("Operands of '==' have incompatible types 'X' and 'Y'") - Extract resolveStmtExprExpr helper to reduce repeated pattern - Add constant initializer type check in resolveConstant - Merge ResolutionTypeTests.lean into ResolutionTypeCheckTests.lean - Add tests: equality type mismatch, assignment target count mismatch, UserDefined pass-through (documents known limitation) - Update checkAssignable doc comment to mention TCore types --- Strata/Languages/Laurel/Resolution.lean | 54 ++++++--- .../Laurel/ResolutionTypeCheckTests.lean | 107 +++++++++++++----- .../Languages/Laurel/ResolutionTypeTests.lean | 50 -------- 3 files changed, 118 insertions(+), 93 deletions(-) delete mode 100644 StrataTest/Languages/Laurel/ResolutionTypeTests.lean diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index cbedf8f53c..4bfa2d39dc 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -390,7 +390,8 @@ private def checkNumeric (source : Option FileRange) (ty : HighTypeMd) : Resolve /-- Check that two types are compatible, emitting a diagnostic if not. UserDefined types are always considered compatible with each other since - subtype relationships (inheritance) are not tracked during resolution. -/ + subtype relationships (inheritance) are not tracked during resolution. + TCore types are not checked since they are pass-through types from the Core language. -/ private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do match expected.val, actual.val with | .Unknown, _ => pure () @@ -406,6 +407,22 @@ private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) let diag := diagnosticFromSource source s!"Type mismatch: expected '{expectedStr}', but got '{actualStr}'" modify fun s => { s with errors := s.errors.push diag } +/-- Check that two types are comparable (for == and !=), emitting a symmetric diagnostic if not. -/ +private def checkComparable (source : Option FileRange) (lhsTy : HighTypeMd) (rhsTy : HighTypeMd) : ResolveM Unit := do + match lhsTy.val, rhsTy.val with + | .Unknown, _ => pure () + | _, .Unknown => pure () + | .UserDefined _, _ => pure () + | _, .UserDefined _ => pure () + | .TCore _, _ => pure () + | _, .TCore _ => pure () + | _, _ => + if !highEq lhsTy rhsTy then + let lhsStr := formatType lhsTy + let rhsStr := formatType rhsTy + let diag := diagnosticFromSource source s!"Operands of '==' have incompatible types '{lhsStr}' and '{rhsStr}'" + modify fun s => { s with errors := s.errors.push diag } + /-- Get the type of a resolved variable reference from scope. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get @@ -566,9 +583,9 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => for aTy in argTypes do checkNumeric source aTy | .Eq | .Neq => - -- Check that operands are compatible with each other + -- Check that operands are compatible with each other (symmetric check) match argTypes with - | [lhsTy, rhsTy] => checkAssignable source rhsTy lhsTy + | [lhsTy, rhsTy] => checkComparable source lhsTy rhsTy | _ => pure () | .StrConcat => pure () pure (.PrimitiveOp op args', { val := resultTy, source := source }) @@ -653,6 +670,11 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) termination_by exprMd decreasing_by all_goals term_by_mem +/-- Resolve a statement expression, discarding the synthesized type. + Use when only the resolved expression is needed (invariants, decreases, etc.). -/ +private def resolveStmtExprExpr (e : StmtExprMd) : ResolveM StmtExprMd := do + let (e', _) ← resolveStmtExpr e; pure e' + /-- Resolve a parameter: assign a fresh ID and add to scope. -/ def resolveParameter (param : Parameter) : ResolveM Parameter := do let ty' ← resolveHighType param.type @@ -666,12 +688,12 @@ def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do let (b', ty) ← resolveStmtExpr b return (.Transparent b', ty) | .Opaque posts impl mods => - let posts' ← posts.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') - let impl' ← impl.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' - let mods' ← mods.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let posts' ← posts.mapM (·.mapM resolveStmtExprExpr) + let impl' ← impl.mapM resolveStmtExprExpr + let mods' ← mods.mapM resolveStmtExprExpr return (.Opaque posts' impl' mods', { val := .TVoid, source := none }) | .Abstract posts => - let posts' ← posts.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') + let posts' ← posts.mapM (·.mapM resolveStmtExprExpr) return (.Abstract posts', { val := .TVoid, source := none }) | .External => return (.External, { val := .TVoid, source := none }) @@ -681,8 +703,8 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do withScope do let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') - let dec' ← proc.decreases.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExprExpr) + let dec' ← proc.decreases.mapM resolveStmtExprExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -696,7 +718,7 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let invokeOn' ← proc.invokeOn.mapM resolveStmtExprExpr return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, preconditions := pres', decreases := dec', @@ -722,8 +744,8 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv modify fun s => { s with instanceTypeName := some typeName.text } let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') - let dec' ← proc.decreases.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExprExpr) + let dec' ← proc.decreases.mapM resolveStmtExprExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -736,7 +758,7 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let invokeOn' ← proc.invokeOn.mapM resolveStmtExprExpr modify fun s => { s with instanceTypeName := savedInstType } return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, @@ -805,7 +827,11 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do /-- Resolve a constant definition. -/ def resolveConstant (c : Constant) : ResolveM Constant := do let ty' ← resolveHighType c.type - let init' ← c.initializer.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let init' ← c.initializer.mapM fun e => do + let (e', eTy) ← resolveStmtExpr e + if eTy.val != HighType.TVoid then + checkAssignable e'.source ty' eTy + pure e' let name' ← resolveRef c.name return { name := name', type := ty', initializer := init' } diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 01ccd40708..3a9fa8f174 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -34,7 +34,7 @@ private def processResolution (input : Lean.Parser.InputContext) : IO (Array Dia let files := Map.insert Map.empty uri input.fileMap return result.errors.toList.map (fun dm => dm.toDiagnostic files) |>.toArray -/-! ## Non-boolean condition in if-then-else -/ +/-! ## Non-boolean conditions -/ def ifCondNotBool := r" function foo(x: int): int { @@ -44,9 +44,7 @@ function foo(x: int): int { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "IfCondNotBool" ifCondNotBool 39 processResolution - -/-! ## Non-boolean condition in assert -/ +#eval testInputWithOffset "IfCondNotBool" ifCondNotBool 44 processResolution def assertCondNotBool := r" procedure baz() opaque { @@ -57,9 +55,7 @@ procedure baz() opaque { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "AssertCondNotBool" assertCondNotBool 49 processResolution - -/-! ## Non-boolean condition in assume -/ +#eval testInputWithOffset "AssertCondNotBool" assertCondNotBool 54 processResolution def assumeCondNotBool := r" procedure qux() opaque { @@ -70,9 +66,20 @@ procedure qux() opaque { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "AssumeCondNotBool" assumeCondNotBool 59 processResolution +#eval testInputWithOffset "AssumeCondNotBool" assumeCondNotBool 64 processResolution + +def whileCondNotBool := r" +procedure wh() opaque { + var x: int := 1; + while (x) { } +// ^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "WhileCondNotBool" whileCondNotBool 74 processResolution -/-! ## Non-boolean operand in logical and -/ +/-! ## Logical operator type checks -/ def logicalAndNotBool := r" function foo(x: int, y: bool): bool { @@ -82,9 +89,21 @@ function foo(x: int, y: bool): bool { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "LogicalAndNotBool" logicalAndNotBool 69 processResolution +#eval testInputWithOffset "LogicalAndNotBool" logicalAndNotBool 84 processResolution + +/-! ## Numeric operator type checks -/ + +def comparisonNotNumeric := r" +function cmp(x: string, y: int): bool { + x < y +//^^^^^ error: expected a numeric type, but got 'string' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "ComparisonNotNumeric" comparisonNotNumeric 94 processResolution -/-! ## Assignment type mismatch -/ +/-! ## Assignment type checks -/ def assignTypeMismatch := r" procedure foo() opaque { @@ -94,9 +113,9 @@ procedure foo() opaque { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "AssignTypeMismatch" assignTypeMismatch 79 processResolution +#eval testInputWithOffset "AssignTypeMismatch" assignTypeMismatch 104 processResolution -/-! ## Function return type mismatch -/ +/-! ## Function return type checks -/ def returnTypeMismatch := r" function foo(): int { @@ -106,9 +125,9 @@ function foo(): int { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "ReturnTypeMismatch" returnTypeMismatch 89 processResolution +#eval testInputWithOffset "ReturnTypeMismatch" returnTypeMismatch 114 processResolution -/-! ## Static call argument type mismatch -/ +/-! ## Call argument type checks -/ def callArgTypeMismatch := r" function bar(x: int): int { x }; @@ -119,31 +138,61 @@ function foo(): int { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "CallArgTypeMismatch" callArgTypeMismatch 99 processResolution +#eval testInputWithOffset "CallArgTypeMismatch" callArgTypeMismatch 124 processResolution -/-! ## Non-boolean condition in while loop -/ +/-! ## Equality operator type checks -/ -def whileCondNotBool := r" -procedure wh() opaque { - var x: int := 1; - while (x) { } -// ^ error: expected bool, but got 'int' +def equalityTypeMismatch := r" +function cmp(x: int, y: string): bool { + x == y +//^^^^^^ error: Operands of '==' have incompatible types 'int' and 'string' }; " #guard_msgs (error, drop all) in -#eval testInputWithOffset "WhileCondNotBool" whileCondNotBool 109 processResolution +#eval testInputWithOffset "EqualityTypeMismatch" equalityTypeMismatch 134 processResolution -/-! ## Non-numeric operand in comparison -/ +/-! ## Multi-output procedures -/ -def comparisonNotNumeric := r" -function cmp(x: string, y: int): bool { - x < y -//^^^^^ error: expected a numeric type, but got 'string' +def multiOutputInExpr := r" +procedure multi(x: int) returns (a: int, b: int) opaque; +procedure test() opaque { + assert multi(1) == 1 +// ^^^^^^^^^^^^^ error: Operands of '==' have incompatible types '(int, int)' and 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "MultiOutputInExpr" multiOutputInExpr 146 processResolution + +def assignTargetCountMismatch := r" +procedure multi() returns (a: int, b: int) opaque; +procedure test() opaque { + var x: int := multi() +//^^^^^^^^^^^^^^^^^^^^^ error: Assignment target count mismatch:1 targets but right-hand side produces 2 values +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "AssignTargetCountMismatch" assignTargetCountMismatch 156 processResolution + +/-! ## UserDefined type pass-through (known limitation) + +UserDefined types skip strict assignability checks because subtype/inheritance +relationships are not tracked during resolution. This test documents that +cross-type assignments are silently accepted today. When hierarchy tracking +lands, this test should be updated to expect a rejection. -/ + +def userDefinedPassThrough := r" +composite Dog { } +composite Cat { } +procedure test() opaque { + var x: Dog := new Cat }; " +-- This should produce NO diagnostics (UserDefined types are not checked against each other) #guard_msgs (error, drop all) in -#eval testInputWithOffset "ComparisonNotNumeric" comparisonNotNumeric 121 processResolution +#eval testInputWithOffset "UserDefinedPassThrough" userDefinedPassThrough 170 processResolution end Laurel diff --git a/StrataTest/Languages/Laurel/ResolutionTypeTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeTests.lean deleted file mode 100644 index 89ac1a162c..0000000000 --- a/StrataTest/Languages/Laurel/ResolutionTypeTests.lean +++ /dev/null @@ -1,50 +0,0 @@ -/- - Copyright Strata Contributors - - SPDX-License-Identifier: Apache-2.0 OR MIT --/ - -/- -Tests that the resolution pass detects type checking errors — e.g. using a -multi-output procedure in expression position. --/ - -import StrataTest.Util.TestDiagnostics -import Strata.DDM.Elab -import Strata.DDM.BuiltinDialects.Init -import Strata.Languages.Laurel.Grammar.LaurelGrammar -import Strata.Languages.Laurel.Grammar.ConcreteToAbstractTreeTranslator -import Strata.Languages.Laurel.Resolution - -open StrataTest.Util -open Strata -open Strata.Elab (parseStrataProgramFromDialect) - -namespace Strata.Laurel - -/-- Run only parsing + resolution and return diagnostics (no SMT verification). -/ -private def processResolution (input : Lean.Parser.InputContext) : IO (Array Diagnostic) := do - let dialects := Strata.Elab.LoadedDialects.ofDialects! #[initDialect, Laurel] - let strataProgram ← parseStrataProgramFromDialect dialects Laurel.name input - let uri := Strata.Uri.file input.fileName - match Laurel.TransM.run uri (Laurel.parseProgram strataProgram) with - | .error e => throw (IO.userError s!"Translation errors: {e}") - | .ok program => - let result := resolve program - let files := Map.insert Map.empty uri input.fileMap - return result.errors.toList.map (fun dm => dm.toDiagnostic files) |>.toArray - -/-! ## Multi-output procedure used in expression position -/ - -def multiOutputInExpr := r" -procedure multi(x: int) returns (a: int, b: int) opaque; -procedure test() opaque { - assert multi(1) == 1 -// ^^^^^^^^^^^^^ error: expected 'int', but got '(int, int)' -}; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "MultiOutputInExpr" multiOutputInExpr 42 processResolution - -end Laurel From e243dbdcf67dbd69a55820a06a7b81c2ad8d1164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 09:44:39 -0400 Subject: [PATCH 008/189] add explanations about the typechecking routine added --- Strata/Languages/Laurel/Resolution.lean | 77 +++++++-- docs/verso/LaurelDoc.lean | 215 ++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 14 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 4bfa2d39dc..e7155a7ca8 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -13,24 +13,73 @@ import Strata.Languages.Python.PythonLaurelCorePrelude /-! # Name Resolution Pass -Assigns a unique numeric ID to every definition and reference node in a -Laurel program, then resolves references to their definitions. +Turns a freshly parsed Laurel `Program` (where every `Identifier` has +`uniqueId := none`) into a program where every definition has a fresh numeric +ID and every reference points to the ID of the definition it names. The pass +also synthesizes a `HighType` for every `StmtExpr` and emits diagnostics for +unresolved names, duplicate definitions, kind mismatches (e.g. using a +constant where a type is expected), and type mismatches. + +The entry point is `resolve`. It returns a `ResolutionResult` containing the +resolved program, a `SemanticModel` (the `refToDef` map and ID counters), and +the accumulated diagnostics. ## Design -The resolution pass operates in two phases: +The resolution pass operates in two phases. ### Phase 1: ID Assignment and Reference Resolution -Walks the AST, assigning fresh unique IDs to all definition nodes and -resolving references by looking up names in the current lexical scope. -After this phase, every definition and reference node has its `id` field -filled in. + +Walks the AST under `ResolveM`, a state monad over `ResolveState`. Phase 1: +- assigns fresh unique IDs to all definition nodes via `defineNameCheckDup`, +- resolves references by looking up names in the current lexical scope via + `resolveRef` (and `resolveFieldRef` for fields, which uses the target's + declared type to build a qualified lookup key), +- opens fresh nested scopes via `withScope` for blocks, quantifiers, + procedure bodies, and constrained-type constraint/witness expressions, +- synthesizes a `HighType` for every `StmtExpr` and runs the type-checking + helpers (`checkBool`, `checkNumeric`, `checkAssignable`, `checkComparable`) + on assignments, call arguments, condition positions, functional bodies, and + constant initializers. + +Before any bodies are walked, `preRegisterTopLevel` registers every top-level +name (types and their constructors / testers / destructors / instance +procedures / fields, constants, static procedures) into scope with a +placeholder `ResolvedNode`. The placeholders are overwritten with real nodes +as each definition is fully resolved. This is what allows declaration order to +not matter inside a Laurel program. + +When a reference fails to resolve, or a `UserDefined` type reference resolves +to the wrong kind, Phase 1 records the name as `ResolvedNode.unresolved` (or +the type as `HighType.Unknown`) and continues. Both are treated as wildcards +by the type checker, so subsequent uses do not produce cascading errors. + +After this phase, every definition and reference node has its `uniqueId` +field filled in. ### Phase 2: Build refToDef Map + Walks the *resolved* AST (where all definitions already have their UUIDs) -and builds a map from each definition's ID to its `ResolvedNode`. Because this -happens after Phase 1, the `ResolvedNode` values in the map contain the fully -resolved sub-trees (e.g. a procedure's parameters already have their IDs). +and builds a map from each definition's ID to its `ResolvedNode`. Because +this happens after Phase 1, the `ResolvedNode` values in the map contain the +fully resolved sub-trees (e.g. a procedure's parameters already have their +IDs). + +### Scopes + +Three forms of scope are maintained on `ResolveState`: +- `scope` — the current lexical scope, mapping name → `(uniqueId, ResolvedNode)`, + saved and restored by `withScope`. +- `currentScopeNames` — names defined at the current nesting level only, used + by `defineNameCheckDup` to detect duplicates. +- `typeScopes` — per-composite-type scopes mapping field names to scope + entries. Built by `resolveTypeDefinition` *before* descending into instance + procedures (and inheriting from `extending` parents), so that field + references inside method bodies can be resolved. +- `instanceTypeName` — when resolving inside an instance procedure, the + owning composite type's name. Used by `resolveFieldRef` as a fallback so + that a bare `self.field` reference resolves through the type scope when + `self` has type `Any`. ### Definition nodes (introduce a name into scope) - `Variable.Declare` — local variable declaration (in `Assign` targets or `Var`) @@ -51,10 +100,10 @@ resolved sub-trees (e.g. a procedure's parameters already have their IDs). - `StmtExpr.Exit` — exit a labelled block - `HighType.UserDefined` — type reference -Each of these nodes carries an `id : Nat` field (defaulting to `0`). -The ID assignment pass fills in unique values. The resolution pass then -builds a map from reference IDs to `ResolvedNode` values describing the -definition each reference resolves to. +Each of these nodes carries a `uniqueId : Option Nat` field (defaulting to +`none`). Phase 1 fills in unique values; Phase 2 then builds a map from +reference IDs to `ResolvedNode` values describing the definition each +reference resolves to. -/ namespace Strata.Laurel diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 4d153eb439..ef6014580d 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -146,6 +146,221 @@ A Laurel program consists of procedures, global variables, type definitions, and {docstring Strata.Laurel.Program} +# Type checking + +Type checking runs as part of the resolution pass, in `resolveStmtExpr`. Resolution +synthesizes a {name Strata.Laurel.HighType}`HighType` for every {name Strata.Laurel.StmtExpr}`StmtExpr` +bottom-up and emits diagnostics when the synthesized type clashes with what its context +requires. + +## Type system at a glance + +The checker is *synthesis-only* (no inference, no subtyping) over a flat type lattice, with +three _wildcard_ types that disable checking: + +- {name Strata.Laurel.HighType.Unknown}`Unknown` — synthesized when a name fails to resolve, + when a {name Strata.Laurel.HighType.UserDefined}`UserDefined` reference resolves to the + wrong kind, or for constructs whose result type isn't tracked + ({name Strata.Laurel.StmtExpr.This}`This`, + {name Strata.Laurel.StmtExpr.Abstract}`Abstract`, + {name Strata.Laurel.StmtExpr.All}`All`, + {name Strata.Laurel.StmtExpr.ContractOf}`ContractOf`, untyped + {name Strata.Laurel.StmtExpr.Hole}`Hole`). It is compatible with everything in both + directions (acts like _any_). +- {name Strata.Laurel.HighType.UserDefined}`UserDefined _` — also treated bivariantly. + Subtype/inheritance relationships aren't tracked here, and a + {name Strata.Laurel.HighType.UserDefined}`UserDefined` may be a constrained type wrapping a + primitive, so it's accepted wherever a primitive is expected. +- {name Strata.Laurel.HighType.TCore}`TCore _` — pass-through types from the Core language; + never checked. + +Everything else ({name Strata.Laurel.HighType.TInt}`TInt`, +{name Strata.Laurel.HighType.TReal}`TReal`, +{name Strata.Laurel.HighType.TFloat64}`TFloat64`, +{name Strata.Laurel.HighType.TBool}`TBool`, +{name Strata.Laurel.HighType.TString}`TString`, +{name Strata.Laurel.HighType.TVoid}`TVoid`, +{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr [..]`) is compared by +*structural equality* via {name Strata.Laurel.highEq}`highEq`. There is no implicit numeric +promotion: {name Strata.Laurel.HighType.TInt}`TInt`, +{name Strata.Laurel.HighType.TReal}`TReal`, and +{name Strata.Laurel.HighType.TFloat64}`TFloat64` are siblings, not a chain. + +{name Strata.Laurel.HighType.TVoid}`TVoid` marks expressions that produce no value +({name Strata.Laurel.StmtExpr.Return}`Return`, +{name Strata.Laurel.StmtExpr.Exit}`Exit`, +{name Strata.Laurel.StmtExpr.While}`While`, +{name Strata.Laurel.StmtExpr.Assert}`Assert`, +{name Strata.Laurel.StmtExpr.Assume}`Assume`, +{name Strata.Laurel.Variable.Declare}`Var Declare`, opaque/abstract/external bodies). +{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr tys` models the result of a +procedure call with multiple outputs. + +## Checking judgments + +Four helper checks fire from context positions: + +- `checkBool` — accepts {name Strata.Laurel.HighType.TBool}`TBool`, + {name Strata.Laurel.HighType.Unknown}`Unknown`, or any + {name Strata.Laurel.HighType.UserDefined}`UserDefined`. Used by + {name Strata.Laurel.StmtExpr.IfThenElse}`if`/{name Strata.Laurel.StmtExpr.While}`while` + conditions, logical primitive ops, + {name Strata.Laurel.StmtExpr.Assert}`Assert`, and + {name Strata.Laurel.StmtExpr.Assume}`Assume`. +- `checkNumeric` — accepts {name Strata.Laurel.HighType.TInt}`TInt`, + {name Strata.Laurel.HighType.TReal}`TReal`, + {name Strata.Laurel.HighType.TFloat64}`TFloat64`, + {name Strata.Laurel.HighType.Unknown}`Unknown`, or any + {name Strata.Laurel.HighType.UserDefined}`UserDefined`. Used by arithmetic and ordering + primitive ops. +- `checkAssignable expected actual` — accepts equality under + {name Strata.Laurel.highEq}`highEq`, *or* either side being + {name Strata.Laurel.HighType.Unknown}`Unknown` / + {name Strata.Laurel.HighType.UserDefined}`UserDefined` / + {name Strata.Laurel.HighType.TCore}`TCore`. Used by assignment, call arguments, functional + body vs. declared output, and constant initializers. +- `checkComparable` — same wildcards as `checkAssignable`, but with a symmetric error message. + Used for the operands of {name Strata.Laurel.Operation.Eq}`==` and + {name Strata.Laurel.Operation.Neq}`!=`. + +The {name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out in `checkBool` and +`checkNumeric` is conservative on purpose: a constrained type might wrap a +{name Strata.Laurel.HighType.TBool}`bool` or a numeric type. + +## Synthesis rules + +Literals synthesize their obvious primitive types: integers give +{name Strata.Laurel.HighType.TInt}`TInt`, booleans +{name Strata.Laurel.HighType.TBool}`TBool`, strings +{name Strata.Laurel.HighType.TString}`TString`, decimals +{name Strata.Laurel.HighType.TReal}`TReal`. Variable and field references take their type +from scope; a {name Strata.Laurel.Variable.Declare}`Var (.Declare p)` synthesizes +{name Strata.Laurel.HighType.TVoid}`TVoid` because it is a declaration statement. + +Control flow: +- {name Strata.Laurel.StmtExpr.IfThenElse}`if c then t else e_1; …; e_n` — `c` is checked + against bool; the result type is the _then_-branch type. Else-branch types are discarded. +- {name Strata.Laurel.StmtExpr.Block}`Block [s_1; …; s_n]` — the type is the last + statement's type, or {name Strata.Laurel.HighType.TVoid}`TVoid` if empty. This is what makes + a transparent functional body usable as a value. +- {name Strata.Laurel.StmtExpr.While}`While`, + {name Strata.Laurel.StmtExpr.Exit}`Exit`, + {name Strata.Laurel.StmtExpr.Return}`Return _`, + {name Strata.Laurel.StmtExpr.Assert}`Assert`, + {name Strata.Laurel.StmtExpr.Assume}`Assume` — all synthesize + {name Strata.Laurel.HighType.TVoid}`TVoid`. The condition positions of + {name Strata.Laurel.StmtExpr.While}`While`, + {name Strata.Laurel.StmtExpr.Assert}`Assert`, and + {name Strata.Laurel.StmtExpr.Assume}`Assume` enforce `checkBool`. + +Calls ({name Strata.Laurel.StmtExpr.StaticCall}`StaticCall`, +{name Strata.Laurel.StmtExpr.InstanceCall}`InstanceCall`) synthesize each argument, then apply +`checkAssignable param arg` pairwise. +{name Strata.Laurel.StmtExpr.InstanceCall}`InstanceCall` drops the first parameter (the +implicit `self`). The return type is determined as follows: +- procedure with one output → that output's type +- procedure with `n ≠ 1` outputs → + {name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr [t_1, …, t_n]` +- datatype constructor whose name contains `..is` → + {name Strata.Laurel.HighType.TBool}`TBool` (testers) +- other datatype constructors → {name Strata.Laurel.HighType.UserDefined}`UserDefined T` +- parameters or constants in callee position → their declared type +- anything else → {name Strata.Laurel.HighType.Unknown}`Unknown` + +Primitive ops (see {name Strata.Laurel.Operation}`Operation`): +- {name Strata.Laurel.Operation.And}`And`, + {name Strata.Laurel.Operation.Or}`Or`, + {name Strata.Laurel.Operation.AndThen}`AndThen`, + {name Strata.Laurel.Operation.OrElse}`OrElse`, + {name Strata.Laurel.Operation.Not}`Not`, + {name Strata.Laurel.Operation.Implies}`Implies` — operands `checkBool`; result + {name Strata.Laurel.HighType.TBool}`TBool`. +- {name Strata.Laurel.Operation.Lt}`Lt`, + {name Strata.Laurel.Operation.Leq}`Leq`, + {name Strata.Laurel.Operation.Gt}`Gt`, + {name Strata.Laurel.Operation.Geq}`Geq` — operands `checkNumeric`; result + {name Strata.Laurel.HighType.TBool}`TBool`. +- {name Strata.Laurel.Operation.Eq}`Eq`, + {name Strata.Laurel.Operation.Neq}`Neq` — `checkComparable lhs rhs` (binary only); result + {name Strata.Laurel.HighType.TBool}`TBool`. +- {name Strata.Laurel.Operation.Neg}`Neg`, + {name Strata.Laurel.Operation.Add}`Add`, + {name Strata.Laurel.Operation.Sub}`Sub`, + {name Strata.Laurel.Operation.Mul}`Mul`, + {name Strata.Laurel.Operation.Div}`Div`, + {name Strata.Laurel.Operation.Mod}`Mod`, + {name Strata.Laurel.Operation.DivT}`DivT`, + {name Strata.Laurel.Operation.ModT}`ModT` — operands `checkNumeric`; result is the type of + the first argument. +- {name Strata.Laurel.Operation.StrConcat}`StrConcat` — no operand check; result + {name Strata.Laurel.HighType.TString}`TString`. + +The _result is the type of the first argument_ rule is how arithmetic handles +{name Strata.Laurel.HighType.TInt}`TInt` / {name Strata.Laurel.HighType.TReal}`TReal` / +{name Strata.Laurel.HighType.TFloat64}`TFloat64` without a unification step. A consequence: +`int + real` will not be flagged, since each operand individually passes `checkNumeric`. + +Other forms: +- {name Strata.Laurel.StmtExpr.New}`New T` synthesizes + {name Strata.Laurel.HighType.UserDefined}`UserDefined T`, falling back to + {name Strata.Laurel.HighType.Unknown}`Unknown` if `T` resolved to the wrong kind. +- {name Strata.Laurel.StmtExpr.AsType}`AsType e T` synthesizes `T`. + {name Strata.Laurel.StmtExpr.IsType}`IsType _ _` and + {name Strata.Laurel.StmtExpr.ReferenceEquals}`ReferenceEquals` synthesize + {name Strata.Laurel.HighType.TBool}`TBool`. +- {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`, + {name Strata.Laurel.StmtExpr.Assigned}`Assigned`, + {name Strata.Laurel.StmtExpr.Fresh}`Fresh` synthesize + {name Strata.Laurel.HighType.TBool}`TBool`. +- {name Strata.Laurel.StmtExpr.Old}`Old e` and + {name Strata.Laurel.StmtExpr.ProveBy}`ProveBy val proof` propagate the type of their first + sub-expression. {name Strata.Laurel.StmtExpr.PureFieldUpdate}`PureFieldUpdate target …` + propagates the type of `target`. +- {name Strata.Laurel.StmtExpr.Hole}`Hole _ (some T)` synthesizes `T`. + {name Strata.Laurel.StmtExpr.Hole}`Hole _ none`, + {name Strata.Laurel.StmtExpr.This}`This`, + {name Strata.Laurel.StmtExpr.Abstract}`Abstract`, + {name Strata.Laurel.StmtExpr.All}`All`, and + {name Strata.Laurel.StmtExpr.ContractOf}`ContractOf` synthesize + {name Strata.Laurel.HighType.Unknown}`Unknown`. + +## Checking positions + +There is no separate checking mode — checking happens by synthesizing and then invoking one of +the four helpers above. The places that check: + +1. *Assignment.* Target count must equal RHS arity + ({name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr` length, else 1), suppressed + when RHS is {name Strata.Laurel.HighType.TVoid}`TVoid`. When single-target and arities + match, `checkAssignable target_ty value_ty` runs. +2. *Call arguments.* `checkAssignable param_ty arg_ty` for each pair (instance calls skip + `self`). +3. *Functional procedure body.* When a {name Strata.Laurel.Procedure}`Procedure` is + `isFunctional`, has a transparent body, exactly one output, and the body type is not + {name Strata.Laurel.HighType.TVoid}`TVoid`, `checkAssignable output_ty body_ty` runs. +4. *Constant initializer.* `checkAssignable declared_ty init_ty`, skipped when the + initializer is {name Strata.Laurel.HighType.TVoid}`TVoid`. + +## Summary + +In type-system terms, the checker is: + +- *monomorphic, structurally-equal, no-subtyping* over primitive types, +- with a *gradual / dynamic escape hatch* — {name Strata.Laurel.HighType.Unknown}`Unknown`, + {name Strata.Laurel.HighType.UserDefined}`UserDefined`, and + {name Strata.Laurel.HighType.TCore}`TCore` are bivariantly compatible with everything, so + unresolved names, user-defined types, and Core types never produce spurious mismatches, +- in *synthesis-only direction* (no contextual checking flowing into expressions), +- with *arity tracking via tuple types* + ({name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr`) for multi-output + procedures, +- and *side-effecting expressions modeled as* + {name Strata.Laurel.HighType.TVoid}`TVoid` so blocks, returns, and loops compose cleanly. + +The wildcard carve-outs are the dominant design choice: the checker's behavior on +user-defined and unresolved-kind code is essentially _anything goes_, and strict checking +applies only between the built-in primitive types. + # Translation Pipeline Laurel programs are verified by translating them to Strata Core and then invoking the Core From 8cb4ab2979e78f653550a19854fbd965915a9a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 11:54:47 -0400 Subject: [PATCH 009/189] bidirectional type checking first implementation : blocks --- Strata/Languages/Laurel/Resolution.lean | 155 ++++++++++++++++-------- 1 file changed, 102 insertions(+), 53 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index e7155a7ca8..b423e09304 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -423,6 +423,19 @@ private def typeMismatch (source : Option FileRange) (expected : String) (actual let diag := diagnosticFromSource source s!"Type mismatch: expected {expected}, but got '{actualStr}'" modify fun s => { s with errors := s.errors.push diag } +/-- Subtyping. Stub: structural equality via `highEq`. + TODO: To be replaced with a real check that walks `extending` chains for composites, unfolds aliases, and unwraps constrained types to their base. -/ +private def isSubtype (sub sup : HighTypeMd) : Bool := highEq sub sup + +/-- Gradual consistency-subtyping (Siek–Taha style): `Unknown` is the dynamic + type and is consistent with everything in either direction. `TCore` is a + migration escape hatch and is bivariantly compatible for now. -/ +private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := + match sub.val, sup.val with + | .Unknown, _ | _, .Unknown => true + | .TCore _, _ | _, .TCore _ => true + | _, _ => isSubtype sub sup + /-- Check that a type is boolean, emitting a diagnostic if not. -/ private def checkBool (source : Option FileRange) (ty : HighTypeMd) : ResolveM Unit := do match ty.val with @@ -503,38 +516,41 @@ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List Hig | some (_, .constant c) => pure (c.type, []) | _ => pure ({ val := .Unknown, source := callee.source }, []) -def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do +def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match _: exprMd with | AstNode.mk expr source => let (val', ty) ← match _: expr with | .IfThenElse cond thenBr elseBr => - let (cond', condTy) ← resolveStmtExpr cond + let (cond', condTy) ← synthStmtExpr cond checkBool cond'.source condTy - let (thenBr', thenTy) ← resolveStmtExpr thenBr + let (thenBr', thenTy) ← synthStmtExpr thenBr let elseBr' ← elseBr.attach.mapM (fun a => have := a.property; do - let (e', _) ← resolveStmtExpr a.val; pure e') + let (e', _) ← synthStmtExpr a.val; pure e') pure (.IfThenElse cond' thenBr' elseBr', thenTy) | .Block stmts label => + -- Synth-mode block: non-last statements have their synthesized type discarded + -- (lax rule, matches Java/Python/JS expression-statement semantics). + -- The last statement's synthesized type becomes the block's type. withScope do - let results ← stmts.mapM resolveStmtExpr + let results ← stmts.mapM synthStmtExpr let stmts' := results.map (·.1) let lastTy := match results.getLast? with | some (_, ty) => ty | none => { val := .TVoid, source := source } pure (.Block stmts' label, lastTy) | .While cond invs dec body => - let (cond', condTy) ← resolveStmtExpr cond + let (cond', condTy) ← synthStmtExpr cond checkBool cond'.source condTy let invs' ← invs.attach.mapM (fun a => have := a.property; do - let (e', _) ← resolveStmtExpr a.val; pure e') + let (e', _) ← synthStmtExpr a.val; pure e') let dec' ← dec.attach.mapM (fun a => have := a.property; do - let (e', _) ← resolveStmtExpr a.val; pure e') - let (body', _) ← resolveStmtExpr body + let (e', _) ← synthStmtExpr a.val; pure e') + let (body', _) ← synthStmtExpr body pure (.While cond' invs' dec' body', { val := .TVoid, source := source }) | .Exit target => pure (.Exit target, { val := .TVoid, source := source }) | .Return val => do let val' ← val.attach.mapM (fun a => have := a.property; do - let (e', _) ← resolveStmtExpr a.val; pure e') + let (e', _) ← synthStmtExpr a.val; pure e') pure (.Return val', { val := .TVoid, source := source }) | .LiteralInt v => pure (.LiteralInt v, { val := .TInt, source := source }) | .LiteralBool v => pure (.LiteralBool v, { val := .TBool, source := source }) @@ -556,14 +572,14 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) let ref' ← resolveRef ref source pure (⟨.Local ref', vs⟩ : VariableMd) | .Field target fieldName => - let (target', _) ← resolveStmtExpr target + let (target', _) ← synthStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source pure (⟨.Field target' fieldName', vs⟩ : VariableMd) | .Declare param => let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let (value', valueTy) ← resolveStmtExpr value + let (value', valueTy) ← synthStmtExpr value -- Check that LHS target count matches the RHS arity (derived from the value type). let expectedOutputCount := match valueTy.val with | .MultiValuedExpr tys => tys.length @@ -593,19 +609,19 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) checkAssignable source tTy valueTy pure (.Assign targets' value', valueTy) | .Var (.Field target fieldName) => - let (target', _) ← resolveStmtExpr target + let (target', _) ← synthStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source let ty ← getVarType fieldName pure (.Var (.Field target' fieldName'), ty) | .PureFieldUpdate target fieldName newVal => - let (target', targetTy) ← resolveStmtExpr target + let (target', targetTy) ← synthStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source - let (newVal', _) ← resolveStmtExpr newVal + let (newVal', _) ← synthStmtExpr newVal pure (.PureFieldUpdate target' fieldName' newVal', targetTy) | .StaticCall callee args => let callee' ← resolveRef callee source (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) - let results ← args.mapM resolveStmtExpr + let results ← args.mapM synthStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee @@ -614,7 +630,7 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) checkAssignable source paramTy argTy pure (.StaticCall callee' args', retTy) | .PrimitiveOp op args => - let results ← args.mapM resolveStmtExpr + let results ← args.mapM synthStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let resultTy := match op with @@ -652,22 +668,22 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) pure (.New ref', ty) | .This => pure (.This, { val := .Unknown, source := source }) | .ReferenceEquals lhs rhs => - let (lhs', _) ← resolveStmtExpr lhs - let (rhs', _) ← resolveStmtExpr rhs + let (lhs', _) ← synthStmtExpr lhs + let (rhs', _) ← synthStmtExpr rhs pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => - let (target', _) ← resolveStmtExpr target + let (target', _) ← synthStmtExpr target let ty' ← resolveHighType ty pure (.AsType target' ty', ty') | .IsType target ty => - let (target', _) ← resolveStmtExpr target + let (target', _) ← synthStmtExpr target let ty' ← resolveHighType ty pure (.IsType target' ty', { val := .TBool, source := source }) | .InstanceCall target callee args => - let (target', _) ← resolveStmtExpr target + let (target', _) ← synthStmtExpr target let callee' ← resolveRef callee source (expected := #[.instanceProcedure, .staticProcedure]) - let results ← args.mapM resolveStmtExpr + let results ← args.mapM synthStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee @@ -681,32 +697,32 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) let paramTy' ← resolveHighType param.type let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do - let (e', _) ← resolveStmtExpr pv.val; pure e') - let (body', _) ← resolveStmtExpr body + let (e', _) ← synthStmtExpr pv.val; pure e') + let (body', _) ← synthStmtExpr body pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) | .Assigned name => - let (name', _) ← resolveStmtExpr name + let (name', _) ← synthStmtExpr name pure (.Assigned name', { val := .TBool, source := source }) | .Old val => - let (val', valTy) ← resolveStmtExpr val + let (val', valTy) ← synthStmtExpr val pure (.Old val', valTy) | .Fresh val => - let (val', _) ← resolveStmtExpr val + let (val', _) ← synthStmtExpr val pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => - let (cond', condTy) ← resolveStmtExpr condExpr + let (cond', condTy) ← synthStmtExpr condExpr checkBool cond'.source condTy pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) | .Assume cond => - let (cond', condTy) ← resolveStmtExpr cond + let (cond', condTy) ← synthStmtExpr cond checkBool cond'.source condTy pure (.Assume cond', { val := .TVoid, source := source }) | .ProveBy val proof => - let (val', valTy) ← resolveStmtExpr val - let (proof', _) ← resolveStmtExpr proof + let (val', valTy) ← synthStmtExpr val + let (proof', _) ← synthStmtExpr proof pure (.ProveBy val' proof', valTy) | .ContractOf ty fn => - let (fn', _) ← resolveStmtExpr fn + let (fn', _) ← synthStmtExpr fn pure (.ContractOf ty fn', { val := .Unknown, source := source }) | .Abstract => pure (.Abstract, { val := .Unknown, source := source }) | .All => pure (.All, { val := .Unknown, source := source }) @@ -721,8 +737,45 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) /-- Resolve a statement expression, discarding the synthesized type. Use when only the resolved expression is needed (invariants, decreases, etc.). -/ -private def resolveStmtExprExpr (e : StmtExprMd) : ResolveM StmtExprMd := do - let (e', _) ← resolveStmtExpr e; pure e' +private def synthStmtExprExpr (e : StmtExprMd) : ResolveM StmtExprMd := do + let (e', _) ← synthStmtExpr e; pure e' + +/-- Check-mode resolution: resolve `e` and verify its type is a consistent + subtype of `expected`. Bidirectional rules for individual constructs push + `expected` into subexpressions; everything else falls back to subsumption + (synth, then `isConsistentSubtype actual expected`). -/ +def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do + match _: exprMd with + | AstNode.mk expr source => + match _: expr with + | .Block stmts label => + -- Bespoke check rule: discard non-last statement types (lax), push + -- `expected` into the last statement. Empty block reduces to subsumption + -- of TVoid against `expected`. + -- The init traversal calls `synthStmtExpr`, a different function, so it + -- needs no termination proof; only the recursive `checkStmtExpr last` + -- call needs `last ∈ stmts`, supplied by `List.mem_of_getLast?`. + withScope do + let init' ← stmts.dropLast.mapM (fun s => do + let (s', _) ← synthStmtExpr s; pure s') + match _lastResult: stmts.getLast? with + | none => + let tvoid : HighTypeMd := { val := .TVoid, source := source } + unless isConsistentSubtype tvoid expected do + typeMismatch source (formatType expected) tvoid + pure { val := .Block init' label, source := source } + | some last => + have := List.mem_of_getLast? _lastResult + let last' ← checkStmtExpr last expected + pure { val := .Block (init' ++ [last']) label, source := source } + | _ => + -- Subsumption fallback: synth then check `actual <: expected`. + let (e', actual) ← synthStmtExpr exprMd + unless isConsistentSubtype actual expected do + typeMismatch source (formatType expected) actual + pure e' + termination_by exprMd + decreasing_by all_goals term_by_mem /-- Resolve a parameter: assign a fresh ID and add to scope. -/ def resolveParameter (param : Parameter) : ResolveM Parameter := do @@ -734,15 +787,15 @@ def resolveParameter (param : Parameter) : ResolveM Parameter := do def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do match body with | .Transparent b => - let (b', ty) ← resolveStmtExpr b + let (b', ty) ← synthStmtExpr b return (.Transparent b', ty) | .Opaque posts impl mods => - let posts' ← posts.mapM (·.mapM resolveStmtExprExpr) - let impl' ← impl.mapM resolveStmtExprExpr - let mods' ← mods.mapM resolveStmtExprExpr + let posts' ← posts.mapM (·.mapM synthStmtExprExpr) + let impl' ← impl.mapM synthStmtExprExpr + let mods' ← mods.mapM synthStmtExprExpr return (.Opaque posts' impl' mods', { val := .TVoid, source := none }) | .Abstract posts => - let posts' ← posts.mapM (·.mapM resolveStmtExprExpr) + let posts' ← posts.mapM (·.mapM synthStmtExprExpr) return (.Abstract posts', { val := .TVoid, source := none }) | .External => return (.External, { val := .TVoid, source := none }) @@ -752,8 +805,8 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do withScope do let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExprExpr) - let dec' ← proc.decreases.mapM resolveStmtExprExpr + let pres' ← proc.preconditions.mapM (·.mapM synthStmtExprExpr) + let dec' ← proc.decreases.mapM synthStmtExprExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -767,7 +820,7 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM resolveStmtExprExpr + let invokeOn' ← proc.invokeOn.mapM synthStmtExprExpr return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, preconditions := pres', decreases := dec', @@ -793,8 +846,8 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv modify fun s => { s with instanceTypeName := some typeName.text } let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExprExpr) - let dec' ← proc.decreases.mapM resolveStmtExprExpr + let pres' ← proc.preconditions.mapM (·.mapM synthStmtExprExpr) + let dec' ← proc.decreases.mapM synthStmtExprExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -807,7 +860,7 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM resolveStmtExprExpr + let invokeOn' ← proc.invokeOn.mapM synthStmtExprExpr modify fun s => { s with instanceTypeName := savedInstType } return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, @@ -849,8 +902,8 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do -- in scope when resolving the constraint and witness expressions. let (valueName', constraint', witness') ← withScope do let valueName' ← defineNameCheckDup ct.valueName (.quantifierVar ct.valueName base') - let (constraint', _) ← resolveStmtExpr ct.constraint - let (witness', _) ← resolveStmtExpr ct.witness + let (constraint', _) ← synthStmtExpr ct.constraint + let (witness', _) ← synthStmtExpr ct.witness return (valueName', constraint', witness') return .Constrained { name := ctName', base := base', valueName := valueName', constraint := constraint', witness := witness' } @@ -876,11 +929,7 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do /-- Resolve a constant definition. -/ def resolveConstant (c : Constant) : ResolveM Constant := do let ty' ← resolveHighType c.type - let init' ← c.initializer.mapM fun e => do - let (e', eTy) ← resolveStmtExpr e - if eTy.val != HighType.TVoid then - checkAssignable e'.source ty' eTy - pure e' + let init' ← c.initializer.mapM (checkStmtExpr · ty') let name' ← resolveRef c.name return { name := name', type := ty', initializer := init' } From fbaa911c4970a5dace98ca8d6a7fd638b9d4846e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 11:57:13 -0400 Subject: [PATCH 010/189] add resolution-only function discards the type synthesized --- Strata/Languages/Laurel/Resolution.lean | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index b423e09304..bd49bd8376 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -737,7 +737,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := /-- Resolve a statement expression, discarding the synthesized type. Use when only the resolved expression is needed (invariants, decreases, etc.). -/ -private def synthStmtExprExpr (e : StmtExprMd) : ResolveM StmtExprMd := do +private def resolveStmtExpr (e : StmtExprMd) : ResolveM StmtExprMd := do let (e', _) ← synthStmtExpr e; pure e' /-- Check-mode resolution: resolve `e` and verify its type is a consistent @@ -790,12 +790,12 @@ def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do let (b', ty) ← synthStmtExpr b return (.Transparent b', ty) | .Opaque posts impl mods => - let posts' ← posts.mapM (·.mapM synthStmtExprExpr) - let impl' ← impl.mapM synthStmtExprExpr - let mods' ← mods.mapM synthStmtExprExpr + let posts' ← posts.mapM (·.mapM resolveStmtExpr) + let impl' ← impl.mapM resolveStmtExpr + let mods' ← mods.mapM resolveStmtExpr return (.Opaque posts' impl' mods', { val := .TVoid, source := none }) | .Abstract posts => - let posts' ← posts.mapM (·.mapM synthStmtExprExpr) + let posts' ← posts.mapM (·.mapM resolveStmtExpr) return (.Abstract posts', { val := .TVoid, source := none }) | .External => return (.External, { val := .TVoid, source := none }) @@ -805,8 +805,8 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do withScope do let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM synthStmtExprExpr) - let dec' ← proc.decreases.mapM synthStmtExprExpr + let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) + let dec' ← proc.decreases.mapM resolveStmtExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -820,7 +820,7 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM synthStmtExprExpr + let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, preconditions := pres', decreases := dec', @@ -846,8 +846,8 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv modify fun s => { s with instanceTypeName := some typeName.text } let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM synthStmtExprExpr) - let dec' ← proc.decreases.mapM synthStmtExprExpr + let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) + let dec' ← proc.decreases.mapM resolveStmtExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -860,7 +860,7 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM synthStmtExprExpr + let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr modify fun s => { s with instanceTypeName := savedInstType } return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, From 39c8dd5f644fa34d84627763cebc961676089d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 12:10:32 -0400 Subject: [PATCH 011/189] document type system --- docs/verso/LaurelDoc.lean | 402 +++++++++++++++++++------------------- 1 file changed, 200 insertions(+), 202 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index ef6014580d..9f89926f4a 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -148,218 +148,216 @@ A Laurel program consists of procedures, global variables, type definitions, and # Type checking -Type checking runs as part of the resolution pass, in `resolveStmtExpr`. Resolution -synthesizes a {name Strata.Laurel.HighType}`HighType` for every {name Strata.Laurel.StmtExpr}`StmtExpr` -bottom-up and emits diagnostics when the synthesized type clashes with what its context -requires. - -## Type system at a glance - -The checker is *synthesis-only* (no inference, no subtyping) over a flat type lattice, with -three _wildcard_ types that disable checking: - -- {name Strata.Laurel.HighType.Unknown}`Unknown` — synthesized when a name fails to resolve, - when a {name Strata.Laurel.HighType.UserDefined}`UserDefined` reference resolves to the - wrong kind, or for constructs whose result type isn't tracked - ({name Strata.Laurel.StmtExpr.This}`This`, - {name Strata.Laurel.StmtExpr.Abstract}`Abstract`, - {name Strata.Laurel.StmtExpr.All}`All`, - {name Strata.Laurel.StmtExpr.ContractOf}`ContractOf`, untyped - {name Strata.Laurel.StmtExpr.Hole}`Hole`). It is compatible with everything in both - directions (acts like _any_). -- {name Strata.Laurel.HighType.UserDefined}`UserDefined _` — also treated bivariantly. - Subtype/inheritance relationships aren't tracked here, and a - {name Strata.Laurel.HighType.UserDefined}`UserDefined` may be a constrained type wrapping a - primitive, so it's accepted wherever a primitive is expected. -- {name Strata.Laurel.HighType.TCore}`TCore _` — pass-through types from the Core language; - never checked. - -Everything else ({name Strata.Laurel.HighType.TInt}`TInt`, -{name Strata.Laurel.HighType.TReal}`TReal`, -{name Strata.Laurel.HighType.TFloat64}`TFloat64`, -{name Strata.Laurel.HighType.TBool}`TBool`, -{name Strata.Laurel.HighType.TString}`TString`, -{name Strata.Laurel.HighType.TVoid}`TVoid`, -{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr [..]`) is compared by -*structural equality* via {name Strata.Laurel.highEq}`highEq`. There is no implicit numeric -promotion: {name Strata.Laurel.HighType.TInt}`TInt`, -{name Strata.Laurel.HighType.TReal}`TReal`, and -{name Strata.Laurel.HighType.TFloat64}`TFloat64` are siblings, not a chain. - -{name Strata.Laurel.HighType.TVoid}`TVoid` marks expressions that produce no value -({name Strata.Laurel.StmtExpr.Return}`Return`, +Type checking is woven into the resolution pass: every +{name Strata.Laurel.StmtExpr}`StmtExpr` gets a {name Strata.Laurel.HighType}`HighType`, and +mismatches against the surrounding context become diagnostics. The design is +*bidirectional*: each construct is resolved either in *synthesis* mode — return a type +inferred from the expression — or in *checking* mode — verify that the expression has a +given expected type. The two are different functions on +{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. + +This page describes the design choices behind the checker. The implementation is in +`Resolution.lean`. + +## The two judgments + +There are two operations on expressions, written here in standard bidirectional notation: + +``` +Γ ⊢ e ⇒ T -- "e synthesizes T" (synthStmtExpr) +Γ ⊢ e ⇐ T -- "e checks against T" (checkStmtExpr) +``` + +Each construct picks a mode based on whether its type is determined locally (synth) or by +context (check). Mode assignment is part of the design — see _Mode assignment per construct_ +below. + +The two judgments are connected by a single change-of-direction rule, *subsumption*: + +``` +Γ ⊢ e ⇒ A A <: B +───────────────────── (sub) + Γ ⊢ e ⇐ B +``` + +Subsumption is the *only* place the checker switches from check to synth mode. It fires as a +default fallback in {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` for every construct +without a bespoke check rule: synthesize the expression's type, then verify the result is a +subtype of the expected type. Bespoke check rules push the expected type *into* +subexpressions instead of bouncing through synthesis, which keeps error messages localized +and lets the expected type propagate through nested control flow. + +## Subtyping and gradual consistency + +The relation `<:` is implemented by two Lean functions — both currently stubs, both +intended to be sharpened: + +- `isSubtype` — pure subtyping. The stub is structural + equality via {name Strata.Laurel.highEq}`highEq`. The eventual real version walks the + `extending` chain for {name Strata.Laurel.CompositeType}`CompositeType`, unfolds + {name Strata.Laurel.TypeAlias}`TypeAlias` to its target, and unwraps + {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base. +- `isConsistentSubtype` — gradual consistency, in + the Siek–Taha sense. {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type + `?` and is consistent with everything in either direction; otherwise the relation + delegates to `isSubtype`. {name Strata.Laurel.HighType.TCore}`TCore` is bivariantly + consistent for now, as a clearly-labelled migration escape hatch from the Core language — + this carve-out is intentionally temporary. + +Subsumption (and every bespoke check rule) uses +`isConsistentSubtype`, never raw `isSubtype`. That +single choice is what makes the system *gradual*: an expression of type +{name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) +flows freely into any typed slot, and any expression flows freely into a slot of type +{name Strata.Laurel.HighType.Unknown}`Unknown`. Strict checking is applied between +fully-known types only. + +## What changed from the synth-only design + +A previous iteration was synth-only with three *bivariantly-compatible* wildcards: +{name Strata.Laurel.HighType.Unknown}`Unknown`, +{name Strata.Laurel.HighType.UserDefined}`UserDefined`, and +{name Strata.Laurel.HighType.TCore}`TCore`. The +{name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was particularly +load-bearing: it meant that *no* assignment, call argument, or comparison involving a user +type was ever rejected, because subtyping wasn't tracked at all and constrained types +weren't unwrapped — we couldn't tell what was safe. + +The bidirectional design replaces that with two cleanly-separated concerns: + +- {name Strata.Laurel.HighType.Unknown}`Unknown` keeps wildcard semantics, but now as a + *real* semantic claim (gradual typing) rather than a workaround. +- {name Strata.Laurel.HighType.UserDefined}`UserDefined` becomes a regular type. Once + `isSubtype` is implemented properly, `Cat ≤ Animal` will + pass, `Cat ≤ Dog` will fail, and constrained types will be unwrappable to their base. The + current stub is conservative (structural equality only); it can be tightened + incrementally without changing any callers. + +## Block and `TVoid` + +Statement-position constructs that produce no value synthesize +{name Strata.Laurel.HighType.TVoid}`TVoid`: +{name Strata.Laurel.StmtExpr.Return}`Return`, {name Strata.Laurel.StmtExpr.Exit}`Exit`, {name Strata.Laurel.StmtExpr.While}`While`, {name Strata.Laurel.StmtExpr.Assert}`Assert`, {name Strata.Laurel.StmtExpr.Assume}`Assume`, -{name Strata.Laurel.Variable.Declare}`Var Declare`, opaque/abstract/external bodies). -{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr tys` models the result of a -procedure call with multiple outputs. - -## Checking judgments - -Four helper checks fire from context positions: - -- `checkBool` — accepts {name Strata.Laurel.HighType.TBool}`TBool`, - {name Strata.Laurel.HighType.Unknown}`Unknown`, or any - {name Strata.Laurel.HighType.UserDefined}`UserDefined`. Used by - {name Strata.Laurel.StmtExpr.IfThenElse}`if`/{name Strata.Laurel.StmtExpr.While}`while` - conditions, logical primitive ops, - {name Strata.Laurel.StmtExpr.Assert}`Assert`, and - {name Strata.Laurel.StmtExpr.Assume}`Assume`. -- `checkNumeric` — accepts {name Strata.Laurel.HighType.TInt}`TInt`, - {name Strata.Laurel.HighType.TReal}`TReal`, - {name Strata.Laurel.HighType.TFloat64}`TFloat64`, - {name Strata.Laurel.HighType.Unknown}`Unknown`, or any - {name Strata.Laurel.HighType.UserDefined}`UserDefined`. Used by arithmetic and ordering - primitive ops. -- `checkAssignable expected actual` — accepts equality under - {name Strata.Laurel.highEq}`highEq`, *or* either side being - {name Strata.Laurel.HighType.Unknown}`Unknown` / - {name Strata.Laurel.HighType.UserDefined}`UserDefined` / - {name Strata.Laurel.HighType.TCore}`TCore`. Used by assignment, call arguments, functional - body vs. declared output, and constant initializers. -- `checkComparable` — same wildcards as `checkAssignable`, but with a symmetric error message. - Used for the operands of {name Strata.Laurel.Operation.Eq}`==` and - {name Strata.Laurel.Operation.Neq}`!=`. - -The {name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out in `checkBool` and -`checkNumeric` is conservative on purpose: a constrained type might wrap a -{name Strata.Laurel.HighType.TBool}`bool` or a numeric type. - -## Synthesis rules - -Literals synthesize their obvious primitive types: integers give -{name Strata.Laurel.HighType.TInt}`TInt`, booleans -{name Strata.Laurel.HighType.TBool}`TBool`, strings -{name Strata.Laurel.HighType.TString}`TString`, decimals -{name Strata.Laurel.HighType.TReal}`TReal`. Variable and field references take their type -from scope; a {name Strata.Laurel.Variable.Declare}`Var (.Declare p)` synthesizes -{name Strata.Laurel.HighType.TVoid}`TVoid` because it is a declaration statement. - -Control flow: -- {name Strata.Laurel.StmtExpr.IfThenElse}`if c then t else e_1; …; e_n` — `c` is checked - against bool; the result type is the _then_-branch type. Else-branch types are discarded. -- {name Strata.Laurel.StmtExpr.Block}`Block [s_1; …; s_n]` — the type is the last - statement's type, or {name Strata.Laurel.HighType.TVoid}`TVoid` if empty. This is what makes - a transparent functional body usable as a value. -- {name Strata.Laurel.StmtExpr.While}`While`, - {name Strata.Laurel.StmtExpr.Exit}`Exit`, - {name Strata.Laurel.StmtExpr.Return}`Return _`, - {name Strata.Laurel.StmtExpr.Assert}`Assert`, - {name Strata.Laurel.StmtExpr.Assume}`Assume` — all synthesize - {name Strata.Laurel.HighType.TVoid}`TVoid`. The condition positions of - {name Strata.Laurel.StmtExpr.While}`While`, - {name Strata.Laurel.StmtExpr.Assert}`Assert`, and - {name Strata.Laurel.StmtExpr.Assume}`Assume` enforce `checkBool`. - -Calls ({name Strata.Laurel.StmtExpr.StaticCall}`StaticCall`, -{name Strata.Laurel.StmtExpr.InstanceCall}`InstanceCall`) synthesize each argument, then apply -`checkAssignable param arg` pairwise. -{name Strata.Laurel.StmtExpr.InstanceCall}`InstanceCall` drops the first parameter (the -implicit `self`). The return type is determined as follows: -- procedure with one output → that output's type -- procedure with `n ≠ 1` outputs → - {name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr [t_1, …, t_n]` -- datatype constructor whose name contains `..is` → - {name Strata.Laurel.HighType.TBool}`TBool` (testers) -- other datatype constructors → {name Strata.Laurel.HighType.UserDefined}`UserDefined T` -- parameters or constants in callee position → their declared type -- anything else → {name Strata.Laurel.HighType.Unknown}`Unknown` - -Primitive ops (see {name Strata.Laurel.Operation}`Operation`): -- {name Strata.Laurel.Operation.And}`And`, - {name Strata.Laurel.Operation.Or}`Or`, - {name Strata.Laurel.Operation.AndThen}`AndThen`, - {name Strata.Laurel.Operation.OrElse}`OrElse`, - {name Strata.Laurel.Operation.Not}`Not`, - {name Strata.Laurel.Operation.Implies}`Implies` — operands `checkBool`; result - {name Strata.Laurel.HighType.TBool}`TBool`. -- {name Strata.Laurel.Operation.Lt}`Lt`, - {name Strata.Laurel.Operation.Leq}`Leq`, - {name Strata.Laurel.Operation.Gt}`Gt`, - {name Strata.Laurel.Operation.Geq}`Geq` — operands `checkNumeric`; result - {name Strata.Laurel.HighType.TBool}`TBool`. -- {name Strata.Laurel.Operation.Eq}`Eq`, - {name Strata.Laurel.Operation.Neq}`Neq` — `checkComparable lhs rhs` (binary only); result - {name Strata.Laurel.HighType.TBool}`TBool`. -- {name Strata.Laurel.Operation.Neg}`Neg`, - {name Strata.Laurel.Operation.Add}`Add`, - {name Strata.Laurel.Operation.Sub}`Sub`, - {name Strata.Laurel.Operation.Mul}`Mul`, - {name Strata.Laurel.Operation.Div}`Div`, - {name Strata.Laurel.Operation.Mod}`Mod`, - {name Strata.Laurel.Operation.DivT}`DivT`, - {name Strata.Laurel.Operation.ModT}`ModT` — operands `checkNumeric`; result is the type of - the first argument. -- {name Strata.Laurel.Operation.StrConcat}`StrConcat` — no operand check; result - {name Strata.Laurel.HighType.TString}`TString`. - -The _result is the type of the first argument_ rule is how arithmetic handles -{name Strata.Laurel.HighType.TInt}`TInt` / {name Strata.Laurel.HighType.TReal}`TReal` / -{name Strata.Laurel.HighType.TFloat64}`TFloat64` without a unification step. A consequence: -`int + real` will not be flagged, since each operand individually passes `checkNumeric`. - -Other forms: -- {name Strata.Laurel.StmtExpr.New}`New T` synthesizes - {name Strata.Laurel.HighType.UserDefined}`UserDefined T`, falling back to - {name Strata.Laurel.HighType.Unknown}`Unknown` if `T` resolved to the wrong kind. -- {name Strata.Laurel.StmtExpr.AsType}`AsType e T` synthesizes `T`. - {name Strata.Laurel.StmtExpr.IsType}`IsType _ _` and - {name Strata.Laurel.StmtExpr.ReferenceEquals}`ReferenceEquals` synthesize - {name Strata.Laurel.HighType.TBool}`TBool`. -- {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`, - {name Strata.Laurel.StmtExpr.Assigned}`Assigned`, - {name Strata.Laurel.StmtExpr.Fresh}`Fresh` synthesize - {name Strata.Laurel.HighType.TBool}`TBool`. -- {name Strata.Laurel.StmtExpr.Old}`Old e` and - {name Strata.Laurel.StmtExpr.ProveBy}`ProveBy val proof` propagate the type of their first - sub-expression. {name Strata.Laurel.StmtExpr.PureFieldUpdate}`PureFieldUpdate target …` - propagates the type of `target`. -- {name Strata.Laurel.StmtExpr.Hole}`Hole _ (some T)` synthesizes `T`. - {name Strata.Laurel.StmtExpr.Hole}`Hole _ none`, - {name Strata.Laurel.StmtExpr.This}`This`, - {name Strata.Laurel.StmtExpr.Abstract}`Abstract`, - {name Strata.Laurel.StmtExpr.All}`All`, and - {name Strata.Laurel.StmtExpr.ContractOf}`ContractOf` synthesize - {name Strata.Laurel.HighType.Unknown}`Unknown`. - -## Checking positions - -There is no separate checking mode — checking happens by synthesizing and then invoking one of -the four helpers above. The places that check: - -1. *Assignment.* Target count must equal RHS arity - ({name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr` length, else 1), suppressed - when RHS is {name Strata.Laurel.HighType.TVoid}`TVoid`. When single-target and arities - match, `checkAssignable target_ty value_ty` runs. -2. *Call arguments.* `checkAssignable param_ty arg_ty` for each pair (instance calls skip - `self`). -3. *Functional procedure body.* When a {name Strata.Laurel.Procedure}`Procedure` is - `isFunctional`, has a transparent body, exactly one output, and the body type is not - {name Strata.Laurel.HighType.TVoid}`TVoid`, `checkAssignable output_ty body_ty` runs. -4. *Constant initializer.* `checkAssignable declared_ty init_ty`, skipped when the - initializer is {name Strata.Laurel.HighType.TVoid}`TVoid`. - -## Summary - -In type-system terms, the checker is: - -- *monomorphic, structurally-equal, no-subtyping* over primitive types, -- with a *gradual / dynamic escape hatch* — {name Strata.Laurel.HighType.Unknown}`Unknown`, - {name Strata.Laurel.HighType.UserDefined}`UserDefined`, and - {name Strata.Laurel.HighType.TCore}`TCore` are bivariantly compatible with everything, so - unresolved names, user-defined types, and Core types never produce spurious mismatches, -- in *synthesis-only direction* (no contextual checking flowing into expressions), +{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies. +This makes blocks compose cleanly: control-flow statements don't pollute a block's +synthesized type. + +A {name Strata.Laurel.StmtExpr.Block}`Block` is statement chaining `{ s_1; …; s_n }`. The +checker treats it permissively in two ways: + +1. *Non-last statements are not required to be {name Strata.Laurel.HighType.TVoid}`TVoid`.* + In synth mode their types are computed and discarded; in check mode they are still + synthesized rather than checked against `void`. This matches Java/Python/JavaScript + expression-statement semantics: `f(x);` where `f` returns a value is normal idiomatic + code, and forcing an explicit discard would be hostile to the imperative style Laurel + targets. The cost is that `5;` (a literal in statement position) is silently accepted; if + we ever want to flag that, it should land as a lint, not a type error. + +2. *The last statement is the block's type.* Empty blocks have type + {name Strata.Laurel.HighType.TVoid}`TVoid`. This is what lets a transparent functional + procedure body be `{ … some statements …; expr }`. + +In check mode, the bespoke `Block` rule pushes the expected type into the *last* statement +rather than checking the block's synthesized type at the boundary. This buys two things: +errors fire at the actual offending sub-expression (e.g. inside a deeply nested +{name Strata.Laurel.StmtExpr.IfThenElse}`if`), and the expected type keeps propagating +through nested {name Strata.Laurel.StmtExpr.Block}`Block` / +{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / +{name Strata.Laurel.StmtExpr.Hole}`Hole` / +{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. Empty blocks reduce to subsumption of +{name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. + +## Mode assignment per construct + +The intended mode for each construct (some are still being converted to bidirectional in +the implementation): + +| Construct | Mode | Notes | +|---|---|---| +| Literals, `Var .Local`, `Var .Field`, `New T`, `IsType`, `ReferenceEquals`, `Quantifier`, `Assigned`, `Fresh`, `Hole _ (some T)`, `StaticCall`, `InstanceCall` | synth | type is determined locally | +| `Var .Declare`, `Exit`, `Return`, `While`, `Assert`, `Assume`, `Assign` | synth ⇒ {name Strata.Laurel.HighType.TVoid}`TVoid` | side-effecting; condition operands checked inward | +| `IfThenElse cond t e_opt` | bespoke check | `cond ⇐ TBool`; `t ⇐ T`; `e ⇐ T` if present | +| `Block` | bespoke check | `s_1..s_{n-1}` synth, `s_n ⇐ T`; synth uses last's synthesized type | +| `Hole _ none` | bespoke check | check mode succeeds with `expected`; synth mode → `Unknown` | +| `AsType e T` | synth ⇒ `T` | the cast is the user's claim; no check on `e` | +| `Old`, `ProveBy v _`, `PureFieldUpdate t _ _` | propagate type of subexpr | unchanged | +| `This`, `Abstract`, `All`, `ContractOf` | synth ⇒ {name Strata.Laurel.HighType.Unknown}`Unknown` | type not tracked | + +{name Strata.Laurel.StmtExpr.PrimitiveOp}`PrimitiveOp` operands are checked inward against +the operator's expected operand type ({name Strata.Laurel.HighType.TBool}`TBool` for +logical, numeric for arithmetic and ordering, {name Strata.Laurel.HighType.TString}`TString` +for `StrConcat`). {name Strata.Laurel.Operation.Eq}`Eq`/{name Strata.Laurel.Operation.Neq}`Neq` +synthesize both operands and require consistency in either direction +(`isConsistentSubtype l r ∨ isConsistentSubtype r l`). + +Arithmetic ops `Neg`/`Add`/…/`ModT` synthesize *the type of the first argument*. This is how +the checker handles {name Strata.Laurel.HighType.TInt}`TInt` / +{name Strata.Laurel.HighType.TReal}`TReal` / {name Strata.Laurel.HighType.TFloat64}`TFloat64` +without a unification step. A consequence: `int + real` is not flagged today, since each +operand passes the numeric check individually. A real fix would be a numeric-promotion or +unification rule; for now this is a known relaxation. + +## Two helpers for resolution sites + +Some positions (procedure preconditions, decreases, invariants, postconditions, modifies +clauses, constrained-type witness, etc.) need resolution to run but the type of the +expression is either uninteresting or already known by another path. They use: + +- {name Strata.Laurel.synthStmtExpr}`synthStmtExpr` — the full synth API, returning + `(StmtExprMd × HighTypeMd)`. +- {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` — the check API, returning the resolved + expression and verifying its type is a consistent subtype of the expected type. +- `resolveStmtExpr` — a thin wrapper that calls + `synthStmtExpr` and discards the synthesized type. Used at sites where typing is not + enforced (verification annotations, modifies/reads clauses). + +The right principle is: when the position has a known expected type +({name Strata.Laurel.HighType.TBool}`TBool` for conditions, numeric for `decreases`, the +declared output for a constant initializer or a functional body), use +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. When it doesn't, use +`resolveStmtExpr`. {name Strata.Laurel.synthStmtExpr}`synthStmtExpr` +itself is mostly an internal interface used by other rules. + +## Returns and the expected return type + +`Return e` synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` (the construct itself +produces no value), but the *value being returned* should be checked against the enclosing +procedure's declared output type. The intended design: thread the expected return type +through {name Strata.Laurel.ResolveState}`ResolveState`, set it from `proc.outputs` in +{name Strata.Laurel.resolveProcedure}`resolveProcedure` / +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` before resolving the +body, and have the `Return` rule push the expected type into its value via +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. This closes a soundness gap in the +synth-only design where `return 0` in a `bool`-returning procedure was not caught (because +the body's overall synthesized type was {name Strata.Laurel.HighType.TVoid}`TVoid` and the +body-vs-output check was skipped on `TVoid`). + +## What this is, in type-system terms + +The checker is: + +- *bidirectional*, with a single subsumption rule at the synth↔check boundary, +- with a *gradual* relation (`isConsistentSubtype`) + rather than a strict one — {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic + type, justified by Laurel's targeting of dynamic source languages, +- over a *nominal-with-stubs* subtype relation + (`isSubtype`) — currently structural equality, intended to + walk inheritance chains and unwrap aliases / constrained types, - with *arity tracking via tuple types* ({name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr`) for multi-output procedures, - and *side-effecting expressions modeled as* {name Strata.Laurel.HighType.TVoid}`TVoid` so blocks, returns, and loops compose cleanly. -The wildcard carve-outs are the dominant design choice: the checker's behavior on -user-defined and unresolved-kind code is essentially _anything goes_, and strict checking -applies only between the built-in primitive types. +The wildcard carve-out for {name Strata.Laurel.HighType.UserDefined}`UserDefined` from the +previous design is gone — user-defined types are no longer a backdoor through the checker. +The {name Strata.Laurel.HighType.TCore}`TCore` carve-out is preserved for now as a +migration aid and is expected to be removed. # Translation Pipeline From 14d27b4be0371c835496148ead2e653e0fb13007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:26:06 -0400 Subject: [PATCH 012/189] ifthenelse type checking --- Strata/Languages/Laurel/Resolution.lean | 43 ++++++++++++++++--------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index bd49bd8376..97f6556331 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -516,17 +516,25 @@ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List Hig | some (_, .constant c) => pure (c.type, []) | _ => pure ({ val := .Unknown, source := callee.source }, []) +mutual def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match _: exprMd with | AstNode.mk expr source => let (val', ty) ← match _: expr with | .IfThenElse cond thenBr elseBr => - let (cond', condTy) ← synthStmtExpr cond - checkBool cond'.source condTy + -- Condition is checked against TBool. The result type is TVoid when the + -- else branch is absent (statement form: the then-branch's value is + -- discarded), otherwise the then-branch's synthesized type. We don't + -- compare the two branches against each other since statement-position + -- ifs commonly mix a value branch with a TVoid branch (return/exit). + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } let (thenBr', thenTy) ← synthStmtExpr thenBr let elseBr' ← elseBr.attach.mapM (fun a => have := a.property; do let (e', _) ← synthStmtExpr a.val; pure e') - pure (.IfThenElse cond' thenBr' elseBr', thenTy) + let resultTy := match elseBr with + | none => { val := .TVoid, source := source } + | some _ => thenTy + pure (.IfThenElse cond' thenBr' elseBr', resultTy) | .Block stmts label => -- Synth-mode block: non-last statements have their synthesized type discarded -- (lax rule, matches Java/Python/JS expression-statement semantics). @@ -732,13 +740,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := pure (.Hole det ty', ty') | none => pure (.Hole det none, { val := .Unknown, source := source }) return ({ val := val', source := source }, ty) - termination_by exprMd - decreasing_by all_goals term_by_mem - -/-- Resolve a statement expression, discarding the synthesized type. - Use when only the resolved expression is needed (invariants, decreases, etc.). -/ -private def resolveStmtExpr (e : StmtExprMd) : ResolveM StmtExprMd := do - let (e', _) ← synthStmtExpr e; pure e' + termination_by (exprMd, 0) + decreasing_by all_goals first + | (apply Prod.Lex.left; term_by_mem) + | (apply Prod.Lex.right; decide) /-- Check-mode resolution: resolve `e` and verify its type is a consistent subtype of `expected`. Bidirectional rules for individual constructs push @@ -752,11 +757,9 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE -- Bespoke check rule: discard non-last statement types (lax), push -- `expected` into the last statement. Empty block reduces to subsumption -- of TVoid against `expected`. - -- The init traversal calls `synthStmtExpr`, a different function, so it - -- needs no termination proof; only the recursive `checkStmtExpr last` - -- call needs `last ∈ stmts`, supplied by `List.mem_of_getLast?`. withScope do - let init' ← stmts.dropLast.mapM (fun s => do + let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do + have : s ∈ stmts := List.dropLast_subset stmts hMem let (s', _) ← synthStmtExpr s; pure s') match _lastResult: stmts.getLast? with | none => @@ -774,8 +777,16 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE unless isConsistentSubtype actual expected do typeMismatch source (formatType expected) actual pure e' - termination_by exprMd - decreasing_by all_goals term_by_mem + termination_by (exprMd, 1) + decreasing_by all_goals first + | (apply Prod.Lex.left; term_by_mem) + | (try subst_eqs; apply Prod.Lex.right; decide) +end + +/-- Resolve a statement expression, discarding the synthesized type. + Use when only the resolved expression is needed (invariants, decreases, etc.). -/ +private def resolveStmtExpr (e : StmtExprMd) : ResolveM StmtExprMd := do + let (e', _) ← synthStmtExpr e; pure e' /-- Resolve a parameter: assign a fresh ID and add to scope. -/ def resolveParameter (param : Parameter) : ResolveM Parameter := do From d3750b4390a332917bf9e14cfc817ccceb7cd29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:28:11 -0400 Subject: [PATCH 013/189] document ifthenelse type checking --- docs/verso/LaurelDoc.lean | 57 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 9f89926f4a..c3d9a314f2 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -272,6 +272,61 @@ through nested {name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. Empty blocks reduce to subsumption of {name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. +## IfThenElse + +{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse cond t e_opt` has been converted to +*partial* bidirectional form. Today the implementation has a synth rule but reaches check +mode only through the subsumption fallback; a bespoke check rule that pushes the expected +type into both branches is the planned next step. + +The synth rule: + +- *Condition.* `cond` is checked against {name Strata.Laurel.HighType.TBool}`TBool` via a + recursive `checkStmtExpr cond TBool` call. This replaces the previous synth-then-`checkBool` + pattern with the clean bidirectional one — the expected type is pushed inward, so a + literal `if 5 then …` flags the literal directly rather than the surrounding `if`. +- *Branches.* `thenBr` is synthesized; if present, `elseBr` is synthesized too. The two + branch types are *not* compared against each other. The reason is that in Laurel's + unified statement-expression model, statement-position `if`s commonly mix a value + branch with a {name Strata.Laurel.HighType.TVoid}`TVoid` branch (early + {name Strata.Laurel.StmtExpr.Return}`return`, {name Strata.Laurel.StmtExpr.Exit}`exit`, an + {name Strata.Laurel.StmtExpr.Assert}`assert`, …), which a strict equality check on + branches would reject incorrectly. +- *Result type.* When `elseBr` is `none`, the result is + {name Strata.Laurel.HighType.TVoid}`TVoid` — the construct is in statement form and the + then-branch's value is discarded. When `elseBr` is `some _`, the result is the + then-branch's synthesized type. The arbitrary preference for the then-branch here is + harmless: the result is always consumed by an enclosing `checkAssignable` / + subsumption-fallback, which gives a one-sided check against the surrounding context's + expected type. + +The change to `none` → {name Strata.Laurel.HighType.TVoid}`TVoid` closes a soundness gap in +the previous design, where `if c then 5` synthesized {name Strata.Laurel.HighType.TInt}`TInt` +unconditionally — even though there is no value when `c` is false — so an assignment +`x: int := if c then 5` would have type-checked. With the new rule, the synthesized type is +{name Strata.Laurel.HighType.TVoid}`TVoid` and the assignment is correctly rejected. + +The planned bespoke check rule is straightforward: `cond ⇐ TBool`, `thenBr ⇐ expected`, and +`elseBr ⇐ expected` if present; if absent, fall back to subsumption of +{name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. The benefit is the +same as for `Block`: errors fire at the offending sub-expression rather than the +surrounding `if`, and the expected type propagates through nested control flow. + +## Mutual recursion and termination + +{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr` are now mutually recursive: the synth rule +for {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` invokes check-mode resolution for +the condition, and the check function falls back to synth via the subsumption rule. + +Termination uses a lexicographic measure `(exprMd, tag)` where the tag is `0` for +{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and `1` for +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. Any descent into a strict subterm +decreases via `Prod.Lex.left` (first component shrinks); the subsumption rule +`check e → synth e` calls synth on the *same* expression, which decreases via +`Prod.Lex.right` (second component goes from 1 to 0). This is the standard well-founded +encoding for bidirectional systems where one direction calls the other on the same input. + ## Mode assignment per construct The intended mode for each construct (some are still being converted to bidirectional in @@ -281,7 +336,7 @@ the implementation): |---|---|---| | Literals, `Var .Local`, `Var .Field`, `New T`, `IsType`, `ReferenceEquals`, `Quantifier`, `Assigned`, `Fresh`, `Hole _ (some T)`, `StaticCall`, `InstanceCall` | synth | type is determined locally | | `Var .Declare`, `Exit`, `Return`, `While`, `Assert`, `Assume`, `Assign` | synth ⇒ {name Strata.Laurel.HighType.TVoid}`TVoid` | side-effecting; condition operands checked inward | -| `IfThenElse cond t e_opt` | bespoke check | `cond ⇐ TBool`; `t ⇐ T`; `e ⇐ T` if present | +| `IfThenElse cond t e_opt` | synth (`cond ⇐ TBool`); planned bespoke check | see below | | `Block` | bespoke check | `s_1..s_{n-1}` synth, `s_n ⇐ T`; synth uses last's synthesized type | | `Hole _ none` | bespoke check | check mode succeeds with `expected`; synth mode → `Unknown` | | `AsType e T` | synth ⇒ `T` | the cast is the user's claim; no check on `e` | From 37e6c359e6d62c7499702aa11b465d27dafd49d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:33:24 -0400 Subject: [PATCH 014/189] typechecking description refactor general design rules (one section per rule) --- docs/verso/LaurelDoc.lean | 392 +++++++++++++++++++++++++++----------- 1 file changed, 277 insertions(+), 115 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index c3d9a314f2..64dd119a59 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -234,128 +234,290 @@ The bidirectional design replaces that with two cleanly-separated concerns: current stub is conservative (structural equality only); it can be tightened incrementally without changing any callers. -## Block and `TVoid` - -Statement-position constructs that produce no value synthesize -{name Strata.Laurel.HighType.TVoid}`TVoid`: -{name Strata.Laurel.StmtExpr.Return}`Return`, -{name Strata.Laurel.StmtExpr.Exit}`Exit`, -{name Strata.Laurel.StmtExpr.While}`While`, -{name Strata.Laurel.StmtExpr.Assert}`Assert`, -{name Strata.Laurel.StmtExpr.Assume}`Assume`, -{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies. -This makes blocks compose cleanly: control-flow statements don't pollute a block's -synthesized type. - -A {name Strata.Laurel.StmtExpr.Block}`Block` is statement chaining `{ s_1; …; s_n }`. The -checker treats it permissively in two ways: - -1. *Non-last statements are not required to be {name Strata.Laurel.HighType.TVoid}`TVoid`.* - In synth mode their types are computed and discarded; in check mode they are still - synthesized rather than checked against `void`. This matches Java/Python/JavaScript - expression-statement semantics: `f(x);` where `f` returns a value is normal idiomatic - code, and forcing an explicit discard would be hostile to the imperative style Laurel - targets. The cost is that `5;` (a literal in statement position) is silently accepted; if - we ever want to flag that, it should land as a lint, not a type error. - -2. *The last statement is the block's type.* Empty blocks have type - {name Strata.Laurel.HighType.TVoid}`TVoid`. This is what lets a transparent functional - procedure body be `{ … some statements …; expr }`. - -In check mode, the bespoke `Block` rule pushes the expected type into the *last* statement -rather than checking the block's synthesized type at the boundary. This buys two things: -errors fire at the actual offending sub-expression (e.g. inside a deeply nested -{name Strata.Laurel.StmtExpr.IfThenElse}`if`), and the expected type keeps propagating -through nested {name Strata.Laurel.StmtExpr.Block}`Block` / +## Notation + +Typing rules are written in the standard derivation-tree form: premises above the line, +conclusion below, rule name on the right. + +``` +premise_1 premise_2 … premise_n +───────────────────────────────────── (Rule-Name) + conclusion +``` + +We use: + +- `e ⇒ T` — _e_ synthesizes _T_ (synth mode, `synthStmtExpr`). +- `e ⇐ T` — _e_ checks against _T_ (check mode, `checkStmtExpr`). +- `T <: U` — gradual consistency-subtyping, i.e. `isConsistentSubtype T U`. +- `Γ` for the lexical scope is left implicit — every rule threads it identically. + +Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This +includes {name Strata.Laurel.StmtExpr.Return}`Return`, +{name Strata.Laurel.StmtExpr.Exit}`Exit`, {name Strata.Laurel.StmtExpr.While}`While`, +{name Strata.Laurel.StmtExpr.Assert}`Assert`, {name Strata.Laurel.StmtExpr.Assume}`Assume`, +{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies +— they're recorded in the rules below. + +## Subsumption (the synth↔check boundary) + +``` +e ⇒ A A <: B +───────────────── (Sub) + e ⇐ B +``` + +Subsumption is the *only* place check switches to synth. It fires as the default fallback +in `checkStmtExpr` for every construct without a bespoke check rule. Bespoke check rules +push the expected type *into* subexpressions, which keeps errors localized. + +## Typing rules + +Below, each construct is given as a derivation. Rules marked with ✓ in the implementation +column are implemented today; rules marked ✗ are planned. The current implementation has +bespoke check rules for {name Strata.Laurel.StmtExpr.Block}`Block` only; everything else +reaches check mode through Sub. Where a synth rule pushes an expected type into a +subexpression (e.g. `cond ⇐ TBool` in {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse`), +that's listed as a premise. + +### Literals and references + +``` + (Lit-Int) ✓ +───────────── ──────────────── ───────────────── + LiteralInt n ⇒ TInt LiteralBool b ⇒ TBool LiteralString s ⇒ TString + +──────────────────────── Γ(x) = T + LiteralDecimal d ⇒ TReal ───────────────── (Var-Local) ✓ + Var (.Local x) ⇒ T + + e ⇒ _ Γ(f) = T_f Γ(x) ↦ T fresh +───────────────────────── (Var-Field) ✓ ───────────────────────── (Var-Declare) ✓ + Var (.Field e f) ⇒ T_f Var (.Declare ⟨x, T⟩) ⇒ TVoid +``` + +`Var (.Field e f)` resolves `f` against the type of `e` (or the enclosing instance type for +`self.f`); the typing rule is independent of which path resolution took. + +### IfThenElse + +``` +cond ⇐ TBool thenBr ⇒ T +───────────────────────────── (If-NoElse) ✓ + IfThenElse cond thenBr none ⇒ TVoid + +cond ⇐ TBool thenBr ⇒ T_t elseBr ⇒ T_e +───────────────────────────────────────────────── (If-Synth) ✓ + IfThenElse cond thenBr (some elseBr) ⇒ T_t + +cond ⇐ TBool thenBr ⇐ T elseBr ⇐ T +───────────────────────────────────────────── (If-Check) ✗ (planned) + IfThenElse cond thenBr (some elseBr) ⇐ T +``` + +If-Synth picks the then-branch type by convention; the result is always consumed by an +enclosing `checkAssignable` or by Sub, which provides a one-sided check against the +surrounding context. The two branches are deliberately not compared against each other: +statement-position `if`s commonly mix a value branch with a +{name Strata.Laurel.HighType.TVoid}`TVoid` branch (early `return`, `exit`, `assert`, …), +which a strict equality check would reject incorrectly. + +If-NoElse synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value +to give back when `cond` is false. This rejects `x : int := if c then 5` at the assignment. + +### Block + +``` + none of these statements has a typing premise + (their synthesized types are discarded — lax) + ─────────────────────────────────────────── + s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇒ T + ──────────────────────────────────────────────────────── (Block-Synth) ✓ + Block [s_1; …; s_n] label ⇒ T + +──────────────────── (Block-Synth-Empty) ✓ + Block [] label ⇒ TVoid + + s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇐ T +───────────────────────────────────────────── (Block-Check) ✓ + Block [s_1; …; s_n] label ⇐ T + + TVoid <: T +───────────────────── (Block-Check-Empty) ✓ + Block [] label ⇐ T +``` + +Block-Synth is lax: non-last statements are synthesized but their types are discarded. +This matches Java/Python/JavaScript expression-statement semantics: `f(x);` where `f` +returns a value is normal idiomatic code. The cost is that `5;` (a literal in statement +position) is silently accepted; flagging it would belong to a lint, not the type checker. + +Block-Check pushes the expected type into the *last* statement rather than checking the +block's synthesized type at the boundary. Errors then fire at the offending subexpression +inside `s_n` rather than at the surrounding {name Strata.Laurel.StmtExpr.Block}`Block`, and +the expected type keeps propagating through nested +{name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / {name Strata.Laurel.StmtExpr.Hole}`Hole` / -{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. Empty blocks reduce to subsumption of -{name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. - -## IfThenElse - -{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse cond t e_opt` has been converted to -*partial* bidirectional form. Today the implementation has a synth rule but reaches check -mode only through the subsumption fallback; a bespoke check rule that pushes the expected -type into both branches is the planned next step. - -The synth rule: - -- *Condition.* `cond` is checked against {name Strata.Laurel.HighType.TBool}`TBool` via a - recursive `checkStmtExpr cond TBool` call. This replaces the previous synth-then-`checkBool` - pattern with the clean bidirectional one — the expected type is pushed inward, so a - literal `if 5 then …` flags the literal directly rather than the surrounding `if`. -- *Branches.* `thenBr` is synthesized; if present, `elseBr` is synthesized too. The two - branch types are *not* compared against each other. The reason is that in Laurel's - unified statement-expression model, statement-position `if`s commonly mix a value - branch with a {name Strata.Laurel.HighType.TVoid}`TVoid` branch (early - {name Strata.Laurel.StmtExpr.Return}`return`, {name Strata.Laurel.StmtExpr.Exit}`exit`, an - {name Strata.Laurel.StmtExpr.Assert}`assert`, …), which a strict equality check on - branches would reject incorrectly. -- *Result type.* When `elseBr` is `none`, the result is - {name Strata.Laurel.HighType.TVoid}`TVoid` — the construct is in statement form and the - then-branch's value is discarded. When `elseBr` is `some _`, the result is the - then-branch's synthesized type. The arbitrary preference for the then-branch here is - harmless: the result is always consumed by an enclosing `checkAssignable` / - subsumption-fallback, which gives a one-sided check against the surrounding context's - expected type. - -The change to `none` → {name Strata.Laurel.HighType.TVoid}`TVoid` closes a soundness gap in -the previous design, where `if c then 5` synthesized {name Strata.Laurel.HighType.TInt}`TInt` -unconditionally — even though there is no value when `c` is false — so an assignment -`x: int := if c then 5` would have type-checked. With the new rule, the synthesized type is -{name Strata.Laurel.HighType.TVoid}`TVoid` and the assignment is correctly rejected. - -The planned bespoke check rule is straightforward: `cond ⇐ TBool`, `thenBr ⇐ expected`, and -`elseBr ⇐ expected` if present; if absent, fall back to subsumption of -{name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. The benefit is the -same as for `Block`: errors fire at the offending sub-expression rather than the -surrounding `if`, and the expected type propagates through nested control flow. +{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. Empty blocks reduce to a subsumption +check of {name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. + +### Statements that synthesize TVoid + +``` +───────────────── (Exit) ✓ cond ⇐ TBool invs ⇐ TBool dec ⇐ ? body ⇒ _ + Exit target ⇒ TVoid ──────────────────────────────────────────────────────────────── (While) ✓-ish + While cond invs dec body ⇒ TVoid + + +───────────────────────── (Return-None) ✓ e ⇒ _ + Return none ⇒ TVoid ───────────────────── (Return-Some) ✓ + Return (some e) ⇒ TVoid + + +cond ⇐ TBool cond ⇐ TBool +────────────────── (Assert) ✓-ish ────────────── (Assume) ✓-ish + Assert cond ⇒ TVoid Assume cond ⇒ TVoid + + + Γ(x) = T_x e ⇒ T_e T_e <: T_x targets ⇒ Ts e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i +───────────────────────────────────────── (Assign-Single) ✓-ish ─────────────────────────────────────────────────────────────────── (Assign-Multi) ✓-ish + Assign [x] e ⇒ TVoid Assign targets e ⇒ TVoid +``` + +✓-ish marks rules that are implemented but still call the legacy `checkBool` / +`checkAssignable` helpers rather than `checkStmtExpr cond TBool`. Functionally equivalent +under the gradual relation `<:` (since `checkBool` accepts the same types as +`isConsistentSubtype _ TBool` modulo the temporary {name Strata.Laurel.HighType.TCore}`TCore` +carve-out); slated to be migrated to `checkStmtExpr`. + +The {name Strata.Laurel.StmtExpr.Return}`Return`-with-value rule today only resolves `e` +without checking it against the enclosing procedure's declared output type. The intended +rule is: + +``` + Γ_proc.outputs = [T] e ⇐ T +───────────────────────────────── (Return-Some-Checked) ✗ (planned) + Return (some e) ⇒ TVoid +``` + +This requires threading the expected return type through `ResolveState`. Without it, +`return 0` in a `bool`-returning procedure goes uncaught. + +### Calls and primitive operations + +``` + callee resolves to procedure with inputs Ts and outputs [T] + args ⇒ Us U_i <: T_i (pairwise) +────────────────────────────────────────────────────────────── (Static-Call) ✓-ish + StaticCall callee args ⇒ T + + callee resolves to procedure with inputs Ts and outputs [T_1; …; T_n] (n ≠ 1) + args ⇒ Us U_i <: T_i (pairwise) +───────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi) ✓-ish + StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] + + target ⇒ _ callee resolves with inputs [self; Ts] and outputs [T] + args ⇒ Us U_i <: T_i (pairwise; self is dropped) +───────────────────────────────────────────────────────────────────────── (Instance-Call) ✓-ish + InstanceCall target callee args ⇒ T + + + args ⇐ TBool (each) +────────────────────────────── (Op-Bool) ✓-ish op ∈ {And, Or, AndThen, OrElse, Not, Implies} + PrimitiveOp op args ⇒ TBool + + + args ⇐ Numeric (each) +───────────────────────────── (Op-Cmp) ✓-ish op ∈ {Lt, Leq, Gt, Geq} + PrimitiveOp op args ⇒ TBool + + + lhs ⇒ T_l rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l +────────────────────────────────────────────────────────── (Op-Eq) ✓-ish op ∈ {Eq, Neq} + PrimitiveOp op [lhs; rhs] ⇒ TBool + + + args ⇐ Numeric (each) args.head ⇒ T +────────────────────────────────────────── (Op-Arith) ✓-ish op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} + PrimitiveOp op args ⇒ T + + + args ⇐ TString (each) — current implementation: no operand check +───────────────────────────── (Op-Concat) ✓-ish + PrimitiveOp op args ⇒ TString +``` + +`Numeric` abbreviates "consistent with one of +{name Strata.Laurel.HighType.TInt}`TInt`, {name Strata.Laurel.HighType.TReal}`TReal`, +{name Strata.Laurel.HighType.TFloat64}`TFloat64`". Today this is enforced by `checkNumeric` +rather than a `checkStmtExpr` chain; equivalent under the gradual relation. + +Op-Arith's "result is the type of the first argument" rule handles `int + int → int`, +`real + real → real`, etc. without a unification step. A consequence: `int + real` is *not* +flagged because each operand individually passes the numeric check. A real fix would be a +numeric-promotion or unification rule; for now this is a known relaxation. + +Op-Concat currently performs no operand check; the rule above describes the intended +behavior. + +### Object-related and verification forms + +``` + ref resolves to a composite or datatype T +───────────────────────────────────────────── (New-Ok) ✓ otherwise New ref ⇒ Unknown + New ref ⇒ UserDefined T + + +───────────────── (This) ✓ ──────────────────────────── (Abstract / All / ContractOf) ✓ + This ⇒ Unknown Abstract / All / ContractOf … ⇒ Unknown + + + lhs ⇒ _ rhs ⇒ _ +───────────────────────── (RefEq) ✓ target ⇒ _ + ReferenceEquals lhs rhs ⇒ TBool ────────────────── (AsType) ✓ + AsType target T ⇒ T + + + target ⇒ _ body ⇒ _ +───────────────── (IsType) ✓ ────────────────────────── (Quantifier) ✓ + IsType target T ⇒ TBool Quantifier mode ⟨x, T⟩ trig body ⇒ TBool + + + name ⇒ _ v ⇒ T v ⇒ _ +───────────────── (Assigned) ✓ ──────────── (Old) ✓ ────────────── (Fresh) ✓ + Assigned name ⇒ TBool Old v ⇒ T Fresh v ⇒ TBool + + + v ⇒ T proof ⇒ _ target ⇒ T_t newVal ⇒ _ +────────────────────── (ProveBy) ✓ ───────────────────────────────── (PureFieldUpdate) ✓ + ProveBy v proof ⇒ T PureFieldUpdate target f newVal ⇒ T_t +``` + +### Holes + +``` + Unknown <: T +───────────────────── (Hole-Some) ✓ ───────────────────── (Hole-None-Synth) ✓ ───────────────────── (Hole-None-Check) ✗ (planned) + Hole d (some T) ⇒ T Hole d none ⇒ Unknown Hole d none ⇐ T +``` + +In check mode, `Hole d none ⇐ T` reduces to subsumption today (`Unknown <: T`, which always +holds). The planned bespoke rule would record the inferred `T` on the hole node so +downstream passes can see it, instead of leaving `none` until the hole-inference pass. ## Mutual recursion and termination -{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr` are now mutually recursive: the synth rule -for {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` invokes check-mode resolution for -the condition, and the check function falls back to synth via the subsumption rule. +`synthStmtExpr` and `checkStmtExpr` are mutually recursive: the synth rule for +{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` invokes check-mode resolution for the +condition, and the check function falls back to synth via Sub. Termination uses a lexicographic measure `(exprMd, tag)` where the tag is `0` for -{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and `1` for -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. Any descent into a strict subterm -decreases via `Prod.Lex.left` (first component shrinks); the subsumption rule -`check e → synth e` calls synth on the *same* expression, which decreases via -`Prod.Lex.right` (second component goes from 1 to 0). This is the standard well-founded -encoding for bidirectional systems where one direction calls the other on the same input. - -## Mode assignment per construct - -The intended mode for each construct (some are still being converted to bidirectional in -the implementation): - -| Construct | Mode | Notes | -|---|---|---| -| Literals, `Var .Local`, `Var .Field`, `New T`, `IsType`, `ReferenceEquals`, `Quantifier`, `Assigned`, `Fresh`, `Hole _ (some T)`, `StaticCall`, `InstanceCall` | synth | type is determined locally | -| `Var .Declare`, `Exit`, `Return`, `While`, `Assert`, `Assume`, `Assign` | synth ⇒ {name Strata.Laurel.HighType.TVoid}`TVoid` | side-effecting; condition operands checked inward | -| `IfThenElse cond t e_opt` | synth (`cond ⇐ TBool`); planned bespoke check | see below | -| `Block` | bespoke check | `s_1..s_{n-1}` synth, `s_n ⇐ T`; synth uses last's synthesized type | -| `Hole _ none` | bespoke check | check mode succeeds with `expected`; synth mode → `Unknown` | -| `AsType e T` | synth ⇒ `T` | the cast is the user's claim; no check on `e` | -| `Old`, `ProveBy v _`, `PureFieldUpdate t _ _` | propagate type of subexpr | unchanged | -| `This`, `Abstract`, `All`, `ContractOf` | synth ⇒ {name Strata.Laurel.HighType.Unknown}`Unknown` | type not tracked | - -{name Strata.Laurel.StmtExpr.PrimitiveOp}`PrimitiveOp` operands are checked inward against -the operator's expected operand type ({name Strata.Laurel.HighType.TBool}`TBool` for -logical, numeric for arithmetic and ordering, {name Strata.Laurel.HighType.TString}`TString` -for `StrConcat`). {name Strata.Laurel.Operation.Eq}`Eq`/{name Strata.Laurel.Operation.Neq}`Neq` -synthesize both operands and require consistency in either direction -(`isConsistentSubtype l r ∨ isConsistentSubtype r l`). - -Arithmetic ops `Neg`/`Add`/…/`ModT` synthesize *the type of the first argument*. This is how -the checker handles {name Strata.Laurel.HighType.TInt}`TInt` / -{name Strata.Laurel.HighType.TReal}`TReal` / {name Strata.Laurel.HighType.TFloat64}`TFloat64` -without a unification step. A consequence: `int + real` is not flagged today, since each -operand passes the numeric check individually. A real fix would be a numeric-promotion or -unification rule; for now this is a known relaxation. +`synthStmtExpr` and `1` for `checkStmtExpr`. Any descent into a strict subterm decreases +via `Prod.Lex.left` (first component shrinks); Sub calls synth on the *same* expression, +which decreases via `Prod.Lex.right` (second component goes from 1 to 0). This is the +standard well-founded encoding for bidirectional systems where one direction calls the +other on the same input. ## Two helpers for resolution sites From 60822bbd1bbd6c6823971e1fe39ec0e24c361308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:44:13 -0400 Subject: [PATCH 015/189] reformat typechecking section --- docs/verso/LaurelDoc.lean | 632 +++++++++++++++++++++----------------- 1 file changed, 351 insertions(+), 281 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 64dd119a59..1577232261 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -150,432 +150,502 @@ A Laurel program consists of procedures, global variables, type definitions, and Type checking is woven into the resolution pass: every {name Strata.Laurel.StmtExpr}`StmtExpr` gets a {name Strata.Laurel.HighType}`HighType`, and -mismatches against the surrounding context become diagnostics. The design is -*bidirectional*: each construct is resolved either in *synthesis* mode — return a type -inferred from the expression — or in *checking* mode — verify that the expression has a -given expected type. The two are different functions on -{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. - -This page describes the design choices behind the checker. The implementation is in +mismatches against the surrounding context become diagnostics. The implementation is in `Resolution.lean`. -## The two judgments +## Design + +### Bidirectional type checking There are two operations on expressions, written here in standard bidirectional notation: ``` -Γ ⊢ e ⇒ T -- "e synthesizes T" (synthStmtExpr) -Γ ⊢ e ⇐ T -- "e checks against T" (checkStmtExpr) +e ⇒ T -- "e synthesizes T" (synthStmtExpr) +e ⇐ T -- "e checks against T" (checkStmtExpr) ``` -Each construct picks a mode based on whether its type is determined locally (synth) or by -context (check). Mode assignment is part of the design — see _Mode assignment per construct_ -below. - -The two judgments are connected by a single change-of-direction rule, *subsumption*: +Synthesis returns a type inferred from the expression itself; checking verifies that the +expression has a given expected type. Each construct picks a mode based on whether its type +is determined locally (synth) or by context (check). The two judgments are connected by a +single change-of-direction rule, *subsumption*: ``` -Γ ⊢ e ⇒ A A <: B -───────────────────── (sub) - Γ ⊢ e ⇐ B +e ⇒ A A <: B +───────────────── (Sub) + e ⇐ B ``` -Subsumption is the *only* place the checker switches from check to synth mode. It fires as a -default fallback in {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` for every construct -without a bespoke check rule: synthesize the expression's type, then verify the result is a -subtype of the expected type. Bespoke check rules push the expected type *into* -subexpressions instead of bouncing through synthesis, which keeps error messages localized -and lets the expected type propagate through nested control flow. - -## Subtyping and gradual consistency +Subsumption is the *only* place the checker switches from check to synth mode. It fires as +the default fallback in +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr` for every construct without a bespoke +check rule: synthesize the expression's type, then verify the result is a subtype of the +expected type. Bespoke check rules push the expected type *into* subexpressions instead of +bouncing through synthesis, which keeps error messages localized and lets the expected type +propagate through nested control flow. + +`synthStmtExpr` and `checkStmtExpr` are mutually recursive: synth rules invoke check on +subexpressions whose expected type is known (e.g. `cond ⇐ TBool` in +{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse`), and `checkStmtExpr` falls back to +`synthStmtExpr` via Sub. Termination uses a lexicographic measure `(exprMd, tag)` where the +tag is `0` for synth and `1` for check; any descent into a strict subterm decreases via +`Prod.Lex.left`, while Sub calls synth on the *same* expression and decreases via +`Prod.Lex.right`. This is the standard well-founded encoding for bidirectional systems. + +There is also a thin `resolveStmtExpr` wrapper that calls `synthStmtExpr` and discards the +synthesized type. It's used at sites where typing is not enforced (verification annotations, +modifies/reads clauses). The right principle for new call sites is: when the position has a +known expected type ({name Strata.Laurel.HighType.TBool}`TBool` for conditions, numeric for +`decreases`, the declared output for a constant initializer or a functional body), use +`checkStmtExpr`. When it doesn't, use `resolveStmtExpr`. `synthStmtExpr` itself is mostly an +internal interface used by other rules. + +### Gradual typing The relation `<:` is implemented by two Lean functions — both currently stubs, both intended to be sharpened: -- `isSubtype` — pure subtyping. The stub is structural - equality via {name Strata.Laurel.highEq}`highEq`. The eventual real version walks the - `extending` chain for {name Strata.Laurel.CompositeType}`CompositeType`, unfolds +- `isSubtype` — pure subtyping. The stub is structural equality via + {name Strata.Laurel.highEq}`highEq`. The eventual real version walks the `extending` + chain for {name Strata.Laurel.CompositeType}`CompositeType`, unfolds {name Strata.Laurel.TypeAlias}`TypeAlias` to its target, and unwraps {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base. -- `isConsistentSubtype` — gradual consistency, in - the Siek–Taha sense. {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type - `?` and is consistent with everything in either direction; otherwise the relation - delegates to `isSubtype`. {name Strata.Laurel.HighType.TCore}`TCore` is bivariantly - consistent for now, as a clearly-labelled migration escape hatch from the Core language — - this carve-out is intentionally temporary. - -Subsumption (and every bespoke check rule) uses -`isConsistentSubtype`, never raw `isSubtype`. That -single choice is what makes the system *gradual*: an expression of type +- `isConsistentSubtype` — gradual consistency, in the Siek–Taha sense. + {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type `?` and is consistent + with everything in either direction; otherwise the relation delegates to `isSubtype`. + {name Strata.Laurel.HighType.TCore}`TCore` is bivariantly consistent for now, as a + clearly-labelled migration escape hatch from the Core language — this carve-out is + intentionally temporary. + +Subsumption (and every bespoke check rule) uses `isConsistentSubtype`, never raw +`isSubtype`. That single choice is what makes the system *gradual*: an expression of type {name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) flows freely into any typed slot, and any expression flows freely into a slot of type {name Strata.Laurel.HighType.Unknown}`Unknown`. Strict checking is applied between fully-known types only. -## What changed from the synth-only design - A previous iteration was synth-only with three *bivariantly-compatible* wildcards: {name Strata.Laurel.HighType.Unknown}`Unknown`, {name Strata.Laurel.HighType.UserDefined}`UserDefined`, and {name Strata.Laurel.HighType.TCore}`TCore`. The -{name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was particularly -load-bearing: it meant that *no* assignment, call argument, or comparison involving a user -type was ever rejected, because subtyping wasn't tracked at all and constrained types -weren't unwrapped — we couldn't tell what was safe. +{name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was load-bearing: no +assignment, call argument, or comparison involving a user type was ever rejected. The +bidirectional design retires that carve-out — user-defined types are now a regular +participant in `<:`, and tightening `isSubtype` (to walk inheritance and unwrap +constrained types) gradually buys real checking on user-defined code without changing +callers. -The bidirectional design replaces that with two cleanly-separated concerns: +Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This +includes {name Strata.Laurel.StmtExpr.Return}`Return`, +{name Strata.Laurel.StmtExpr.Exit}`Exit`, {name Strata.Laurel.StmtExpr.While}`While`, +{name Strata.Laurel.StmtExpr.Assert}`Assert`, {name Strata.Laurel.StmtExpr.Assume}`Assume`, +{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies +— recorded in the rules below. -- {name Strata.Laurel.HighType.Unknown}`Unknown` keeps wildcard semantics, but now as a - *real* semantic claim (gradual typing) rather than a workaround. -- {name Strata.Laurel.HighType.UserDefined}`UserDefined` becomes a regular type. Once - `isSubtype` is implemented properly, `Cat ≤ Animal` will - pass, `Cat ≤ Dog` will fail, and constrained types will be unwrappable to their base. The - current stub is conservative (structural equality only); it can be tightened - incrementally without changing any callers. +## Typing rules -## Notation +Each construct is given as a derivation. Premises sit above the line, conclusion below. +Rules tagged `(impl)` are implemented; rules tagged `(planned)` describe the intended +behavior but aren't yet wired in. `Γ` (the lexical scope) is left implicit; every rule +threads it identically. -Typing rules are written in the standard derivation-tree form: premises above the line, -conclusion below, rule name on the right. +### Sub (subsumption) ``` -premise_1 premise_2 … premise_n -───────────────────────────────────── (Rule-Name) - conclusion +e ⇒ A A <: B +───────────────── (Sub, impl) + e ⇐ B ``` -We use: +The default fallback in `checkStmtExpr`. Used by every construct that doesn't have a +bespoke check rule. -- `e ⇒ T` — _e_ synthesizes _T_ (synth mode, `synthStmtExpr`). -- `e ⇐ T` — _e_ checks against _T_ (check mode, `checkStmtExpr`). -- `T <: U` — gradual consistency-subtyping, i.e. `isConsistentSubtype T U`. -- `Γ` for the lexical scope is left implicit — every rule threads it identically. +### LiteralInt -Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This -includes {name Strata.Laurel.StmtExpr.Return}`Return`, -{name Strata.Laurel.StmtExpr.Exit}`Exit`, {name Strata.Laurel.StmtExpr.While}`While`, -{name Strata.Laurel.StmtExpr.Assert}`Assert`, {name Strata.Laurel.StmtExpr.Assume}`Assume`, -{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies -— they're recorded in the rules below. +``` +───────────────────── (Lit-Int, impl) + LiteralInt n ⇒ TInt +``` -## Subsumption (the synth↔check boundary) +### LiteralBool ``` -e ⇒ A A <: B -───────────────── (Sub) - e ⇐ B +────────────────────── (Lit-Bool, impl) + LiteralBool b ⇒ TBool ``` -Subsumption is the *only* place check switches to synth. It fires as the default fallback -in `checkStmtExpr` for every construct without a bespoke check rule. Bespoke check rules -push the expected type *into* subexpressions, which keeps errors localized. +### LiteralString -## Typing rules +``` +──────────────────────────── (Lit-String, impl) + LiteralString s ⇒ TString +``` -Below, each construct is given as a derivation. Rules marked with ✓ in the implementation -column are implemented today; rules marked ✗ are planned. The current implementation has -bespoke check rules for {name Strata.Laurel.StmtExpr.Block}`Block` only; everything else -reaches check mode through Sub. Where a synth rule pushes an expected type into a -subexpression (e.g. `cond ⇐ TBool` in {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse`), -that's listed as a premise. +### LiteralDecimal -### Literals and references +``` +───────────────────────────── (Lit-Decimal, impl) + LiteralDecimal d ⇒ TReal +``` +### Var (.Local) + +``` + Γ(x) = T +────────────────────── (Var-Local, impl) + Var (.Local x) ⇒ T ``` - (Lit-Int) ✓ -───────────── ──────────────── ───────────────── - LiteralInt n ⇒ TInt LiteralBool b ⇒ TBool LiteralString s ⇒ TString -──────────────────────── Γ(x) = T - LiteralDecimal d ⇒ TReal ───────────────── (Var-Local) ✓ - Var (.Local x) ⇒ T +### Var (.Field) - e ⇒ _ Γ(f) = T_f Γ(x) ↦ T fresh -───────────────────────── (Var-Field) ✓ ───────────────────────── (Var-Declare) ✓ - Var (.Field e f) ⇒ T_f Var (.Declare ⟨x, T⟩) ⇒ TVoid ``` + e ⇒ _ Γ(f) = T_f +───────────────────────── (Var-Field, impl) + Var (.Field e f) ⇒ T_f +``` + +`f` is resolved against the type of `e` (or the enclosing instance type for `self.f`); the +typing rule is independent of which path resolution took. -`Var (.Field e f)` resolves `f` against the type of `e` (or the enclosing instance type for -`self.f`); the typing rule is independent of which path resolution took. +### Var (.Declare) + +``` + Γ(x) ↦ T fresh +────────────────────────────────── (Var-Declare, impl) + Var (.Declare ⟨x, T⟩) ⇒ TVoid +``` ### IfThenElse ``` cond ⇐ TBool thenBr ⇒ T -───────────────────────────── (If-NoElse) ✓ - IfThenElse cond thenBr none ⇒ TVoid +───────────────────────────────────────── (If-NoElse, impl) + IfThenElse cond thenBr none ⇒ TVoid + cond ⇐ TBool thenBr ⇒ T_t elseBr ⇒ T_e -───────────────────────────────────────────────── (If-Synth) ✓ - IfThenElse cond thenBr (some elseBr) ⇒ T_t +───────────────────────────────────────────────── (If-Synth, impl) + IfThenElse cond thenBr (some elseBr) ⇒ T_t + cond ⇐ TBool thenBr ⇐ T elseBr ⇐ T -───────────────────────────────────────────── (If-Check) ✗ (planned) - IfThenElse cond thenBr (some elseBr) ⇐ T +───────────────────────────────────────────── (If-Check, planned) + IfThenElse cond thenBr (some elseBr) ⇐ T ``` -If-Synth picks the then-branch type by convention; the result is always consumed by an -enclosing `checkAssignable` or by Sub, which provides a one-sided check against the -surrounding context. The two branches are deliberately not compared against each other: -statement-position `if`s commonly mix a value branch with a -{name Strata.Laurel.HighType.TVoid}`TVoid` branch (early `return`, `exit`, `assert`, …), -which a strict equality check would reject incorrectly. - If-NoElse synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value -to give back when `cond` is false. This rejects `x : int := if c then 5` at the assignment. +to give back when `cond` is false. Without this rule, `x : int := if c then 5` would +type-check spuriously. + +If-Synth picks the then-branch type; the result is always consumed by an enclosing +`checkAssignable` or by Sub, which provides a one-sided check against the surrounding +context. The two branches are deliberately not compared against each other: statement-position +`if`s commonly mix a value branch with a {name Strata.Laurel.HighType.TVoid}`TVoid` branch +(early `return`, `exit`, `assert`, …), which a strict equality check would reject incorrectly. ### Block ``` - none of these statements has a typing premise - (their synthesized types are discarded — lax) - ─────────────────────────────────────────── - s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇒ T - ──────────────────────────────────────────────────────── (Block-Synth) ✓ - Block [s_1; …; s_n] label ⇒ T + s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇒ T +─────────────────────────────────────────────────── (Block-Synth, impl) + Block [s_1; …; s_n] label ⇒ T + -──────────────────── (Block-Synth-Empty) ✓ +──────────────────────── (Block-Synth-Empty, impl) Block [] label ⇒ TVoid - s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇐ T -───────────────────────────────────────────── (Block-Check) ✓ - Block [s_1; …; s_n] label ⇐ T + + s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇐ T +─────────────────────────────────────────────────── (Block-Check, impl) + Block [s_1; …; s_n] label ⇐ T + TVoid <: T -───────────────────── (Block-Check-Empty) ✓ +────────────────────── (Block-Check-Empty, impl) Block [] label ⇐ T ``` -Block-Synth is lax: non-last statements are synthesized but their types are discarded. -This matches Java/Python/JavaScript expression-statement semantics: `f(x);` where `f` +The non-last statements are synthesized but their types are discarded — this is the lax +rule. It matches Java/Python/JavaScript expression-statement semantics: `f(x);` where `f` returns a value is normal idiomatic code. The cost is that `5;` (a literal in statement position) is silently accepted; flagging it would belong to a lint, not the type checker. -Block-Check pushes the expected type into the *last* statement rather than checking the -block's synthesized type at the boundary. Errors then fire at the offending subexpression -inside `s_n` rather than at the surrounding {name Strata.Laurel.StmtExpr.Block}`Block`, and -the expected type keeps propagating through nested +In check mode, the expected type is pushed into the *last* statement rather than checked at +the boundary. Errors then fire at the offending subexpression inside `s_n`, and the +expected type keeps propagating through nested {name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / {name Strata.Laurel.StmtExpr.Hole}`Hole` / -{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. Empty blocks reduce to a subsumption -check of {name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. +{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. -### Statements that synthesize TVoid +### Exit ``` -───────────────── (Exit) ✓ cond ⇐ TBool invs ⇐ TBool dec ⇐ ? body ⇒ _ - Exit target ⇒ TVoid ──────────────────────────────────────────────────────────────── (While) ✓-ish - While cond invs dec body ⇒ TVoid +───────────────────── (Exit, impl) + Exit target ⇒ TVoid +``` +### Return -───────────────────────── (Return-None) ✓ e ⇒ _ - Return none ⇒ TVoid ───────────────────── (Return-Some) ✓ - Return (some e) ⇒ TVoid +``` +───────────────────────── (Return-None, impl) + Return none ⇒ TVoid -cond ⇐ TBool cond ⇐ TBool -────────────────── (Assert) ✓-ish ────────────── (Assume) ✓-ish - Assert cond ⇒ TVoid Assume cond ⇒ TVoid + e ⇒ _ +────────────────────────── (Return-Some, impl) + Return (some e) ⇒ TVoid - Γ(x) = T_x e ⇒ T_e T_e <: T_x targets ⇒ Ts e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i -───────────────────────────────────────── (Assign-Single) ✓-ish ─────────────────────────────────────────────────────────────────── (Assign-Multi) ✓-ish - Assign [x] e ⇒ TVoid Assign targets e ⇒ TVoid + Γ_proc.outputs = [T] e ⇐ T +───────────────────────────────── (Return-Some-Checked, planned) + Return (some e) ⇒ TVoid ``` -✓-ish marks rules that are implemented but still call the legacy `checkBool` / -`checkAssignable` helpers rather than `checkStmtExpr cond TBool`. Functionally equivalent -under the gradual relation `<:` (since `checkBool` accepts the same types as -`isConsistentSubtype _ TBool` modulo the temporary {name Strata.Laurel.HighType.TCore}`TCore` -carve-out); slated to be migrated to `checkStmtExpr`. +The current `Return-Some` rule discards the value's synthesized type. The planned rule +threads the expected return type through {name Strata.Laurel.ResolveState}`ResolveState` +(set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedure` / +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`), so `return 0` in +a `bool`-returning procedure can be caught at the `Return` site. -The {name Strata.Laurel.StmtExpr.Return}`Return`-with-value rule today only resolves `e` -without checking it against the enclosing procedure's declared output type. The intended -rule is: +### While ``` - Γ_proc.outputs = [T] e ⇐ T -───────────────────────────────── (Return-Some-Checked) ✗ (planned) - Return (some e) ⇒ TVoid + cond ⇐ TBool invs_i ⇐ TBool dec ⇐ ? body ⇒ _ +───────────────────────────────────────────────────────────── (While, impl-ish) + While cond invs dec body ⇒ TVoid ``` -This requires threading the expected return type through `ResolveState`. Without it, -`return 0` in a `bool`-returning procedure goes uncaught. +`impl-ish` here means the rule is implemented but `cond` and `invs_i` go through the legacy +`checkBool` helper rather than `checkStmtExpr cond TBool`. Functionally equivalent under +`<:`; slated for migration. -### Calls and primitive operations +### Assert ``` - callee resolves to procedure with inputs Ts and outputs [T] + cond ⇐ TBool +────────────────────────── (Assert, impl-ish) + Assert cond ⇒ TVoid +``` + +### Assume + +``` + cond ⇐ TBool +───────────────────── (Assume, impl-ish) + Assume cond ⇒ TVoid +``` + +### Assign + +``` + Γ(x) = T_x e ⇒ T_e T_e <: T_x +───────────────────────────────────────── (Assign-Single, impl-ish) + Assign [x] e ⇒ TVoid + + + targets ⇒ Ts e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i +───────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl-ish) + Assign targets e ⇒ TVoid +``` + +### StaticCall + +``` + callee = static-procedure with inputs Ts and outputs [T] args ⇒ Us U_i <: T_i (pairwise) -────────────────────────────────────────────────────────────── (Static-Call) ✓-ish - StaticCall callee args ⇒ T +──────────────────────────────────────────────────────────── (Static-Call, impl-ish) + StaticCall callee args ⇒ T + - callee resolves to procedure with inputs Ts and outputs [T_1; …; T_n] (n ≠ 1) + callee = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 args ⇒ Us U_i <: T_i (pairwise) -───────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi) ✓-ish - StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] +───────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl-ish) + StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] +``` + +### InstanceCall - target ⇒ _ callee resolves with inputs [self; Ts] and outputs [T] +``` + target ⇒ _ callee = instance-procedure with inputs [self; Ts] and outputs [T] args ⇒ Us U_i <: T_i (pairwise; self is dropped) -───────────────────────────────────────────────────────────────────────── (Instance-Call) ✓-ish - InstanceCall target callee args ⇒ T +───────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl-ish) + InstanceCall target callee args ⇒ T +``` +### PrimitiveOp (logical) - args ⇐ TBool (each) -────────────────────────────── (Op-Bool) ✓-ish op ∈ {And, Or, AndThen, OrElse, Not, Implies} +``` + args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} +───────────────────────────── (Op-Bool, impl-ish) PrimitiveOp op args ⇒ TBool +``` +### PrimitiveOp (comparison) - args ⇐ Numeric (each) -───────────────────────────── (Op-Cmp) ✓-ish op ∈ {Lt, Leq, Gt, Geq} +``` + args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} +───────────────────────────── (Op-Cmp, impl-ish) PrimitiveOp op args ⇒ TBool +``` +`Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, +{name Strata.Laurel.HighType.TReal}`TReal`, +{name Strata.Laurel.HighType.TFloat64}`TFloat64`". Today this is enforced by `checkNumeric` +rather than a `checkStmtExpr` chain; equivalent under `<:`. + +### PrimitiveOp (equality) - lhs ⇒ T_l rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l -────────────────────────────────────────────────────────── (Op-Eq) ✓-ish op ∈ {Eq, Neq} - PrimitiveOp op [lhs; rhs] ⇒ TBool +``` + lhs ⇒ T_l rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} +────────────────────────────────────────────────────── (Op-Eq, impl-ish) + PrimitiveOp op [lhs; rhs] ⇒ TBool +``` +### PrimitiveOp (arithmetic) - args ⇐ Numeric (each) args.head ⇒ T -────────────────────────────────────────── (Op-Arith) ✓-ish op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} +``` + args_i ⇐ Numeric args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} +────────────────────────────────────────── (Op-Arith, impl-ish) PrimitiveOp op args ⇒ T +``` + +The "result is the type of the first argument" rule handles `int + int → int`, +`real + real → real` etc. without unification. A consequence: `int + real` is *not* +flagged today — each operand individually passes `Numeric`. A real fix would be a +numeric-promotion or unification rule; for now this is a known relaxation. +### PrimitiveOp (string concatenation) - args ⇐ TString (each) — current implementation: no operand check -───────────────────────────── (Op-Concat) ✓-ish +``` + args_i ⇐ TString op = StrConcat +───────────────────────────── (Op-Concat, planned) PrimitiveOp op args ⇒ TString ``` -`Numeric` abbreviates "consistent with one of -{name Strata.Laurel.HighType.TInt}`TInt`, {name Strata.Laurel.HighType.TReal}`TReal`, -{name Strata.Laurel.HighType.TFloat64}`TFloat64`". Today this is enforced by `checkNumeric` -rather than a `checkStmtExpr` chain; equivalent under the gradual relation. - -Op-Arith's "result is the type of the first argument" rule handles `int + int → int`, -`real + real → real`, etc. without a unification step. A consequence: `int + real` is *not* -flagged because each operand individually passes the numeric check. A real fix would be a -numeric-promotion or unification rule; for now this is a known relaxation. - -Op-Concat currently performs no operand check; the rule above describes the intended -behavior. +The current implementation performs no operand check on `StrConcat`; the planned rule +above describes the intended behavior. -### Object-related and verification forms +### New ``` ref resolves to a composite or datatype T -───────────────────────────────────────────── (New-Ok) ✓ otherwise New ref ⇒ Unknown +───────────────────────────────────────────── (New-Ok, impl) New ref ⇒ UserDefined T -───────────────── (This) ✓ ──────────────────────────── (Abstract / All / ContractOf) ✓ - This ⇒ Unknown Abstract / All / ContractOf … ⇒ Unknown + ref does not resolve to a composite or datatype +───────────────────────────────────────────────── (New-Fallback, impl) + New ref ⇒ Unknown +``` + +### AsType +``` + target ⇒ _ +───────────────────── (AsType, impl) + AsType target T ⇒ T +``` - lhs ⇒ _ rhs ⇒ _ -───────────────────────── (RefEq) ✓ target ⇒ _ - ReferenceEquals lhs rhs ⇒ TBool ────────────────── (AsType) ✓ - AsType target T ⇒ T +`AsType` does not check `target` against `T` — the cast is the user's claim. +### IsType - target ⇒ _ body ⇒ _ -───────────────── (IsType) ✓ ────────────────────────── (Quantifier) ✓ - IsType target T ⇒ TBool Quantifier mode ⟨x, T⟩ trig body ⇒ TBool +``` + target ⇒ _ +────────────────────────── (IsType, impl) + IsType target T ⇒ TBool +``` +### ReferenceEquals - name ⇒ _ v ⇒ T v ⇒ _ -───────────────── (Assigned) ✓ ──────────── (Old) ✓ ────────────── (Fresh) ✓ - Assigned name ⇒ TBool Old v ⇒ T Fresh v ⇒ TBool +``` + lhs ⇒ _ rhs ⇒ _ +─────────────────────────────── (RefEq, impl) + ReferenceEquals lhs rhs ⇒ TBool +``` +### Quantifier - v ⇒ T proof ⇒ _ target ⇒ T_t newVal ⇒ _ -────────────────────── (ProveBy) ✓ ───────────────────────────────── (PureFieldUpdate) ✓ - ProveBy v proof ⇒ T PureFieldUpdate target f newVal ⇒ T_t +``` + body ⇒ _ +───────────────────────────────────────────── (Quantifier, impl) + Quantifier mode ⟨x, T⟩ trig body ⇒ TBool ``` -### Holes +### Assigned ``` - Unknown <: T -───────────────────── (Hole-Some) ✓ ───────────────────── (Hole-None-Synth) ✓ ───────────────────── (Hole-None-Check) ✗ (planned) - Hole d (some T) ⇒ T Hole d none ⇒ Unknown Hole d none ⇐ T + name ⇒ _ +───────────────────────── (Assigned, impl) + Assigned name ⇒ TBool ``` -In check mode, `Hole d none ⇐ T` reduces to subsumption today (`Unknown <: T`, which always +### Old + +``` + v ⇒ T +───────────── (Old, impl) + Old v ⇒ T +``` + +### Fresh + +``` + v ⇒ _ +────────────────── (Fresh, impl) + Fresh v ⇒ TBool +``` + +### ProveBy + +``` + v ⇒ T proof ⇒ _ +────────────────────────── (ProveBy, impl) + ProveBy v proof ⇒ T +``` + +### PureFieldUpdate + +``` + target ⇒ T_t newVal ⇒ _ +───────────────────────────────────── (PureFieldUpdate, impl) + PureFieldUpdate target f newVal ⇒ T_t +``` + +### This + +``` +───────────────────── (This, impl) + This ⇒ Unknown +``` + +### Abstract / All / ContractOf + +``` +──────────────────────────────────────── (Abstract / All / ContractOf, impl) + Abstract / All / ContractOf … ⇒ Unknown +``` + +### Hole + +``` +─────────────────────── (Hole-Some, impl) + Hole d (some T) ⇒ T + + +───────────────────────── (Hole-None-Synth, impl) + Hole d none ⇒ Unknown + + + Unknown <: T +────────────────────── (Hole-None-Check, planned) + Hole d none ⇐ T +``` + +In check mode today, `Hole d none ⇐ T` reduces to subsumption (`Unknown <: T`, which always holds). The planned bespoke rule would record the inferred `T` on the hole node so downstream passes can see it, instead of leaving `none` until the hole-inference pass. -## Mutual recursion and termination - -`synthStmtExpr` and `checkStmtExpr` are mutually recursive: the synth rule for -{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` invokes check-mode resolution for the -condition, and the check function falls back to synth via Sub. - -Termination uses a lexicographic measure `(exprMd, tag)` where the tag is `0` for -`synthStmtExpr` and `1` for `checkStmtExpr`. Any descent into a strict subterm decreases -via `Prod.Lex.left` (first component shrinks); Sub calls synth on the *same* expression, -which decreases via `Prod.Lex.right` (second component goes from 1 to 0). This is the -standard well-founded encoding for bidirectional systems where one direction calls the -other on the same input. - -## Two helpers for resolution sites - -Some positions (procedure preconditions, decreases, invariants, postconditions, modifies -clauses, constrained-type witness, etc.) need resolution to run but the type of the -expression is either uninteresting or already known by another path. They use: - -- {name Strata.Laurel.synthStmtExpr}`synthStmtExpr` — the full synth API, returning - `(StmtExprMd × HighTypeMd)`. -- {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` — the check API, returning the resolved - expression and verifying its type is a consistent subtype of the expected type. -- `resolveStmtExpr` — a thin wrapper that calls - `synthStmtExpr` and discards the synthesized type. Used at sites where typing is not - enforced (verification annotations, modifies/reads clauses). - -The right principle is: when the position has a known expected type -({name Strata.Laurel.HighType.TBool}`TBool` for conditions, numeric for `decreases`, the -declared output for a constant initializer or a functional body), use -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. When it doesn't, use -`resolveStmtExpr`. {name Strata.Laurel.synthStmtExpr}`synthStmtExpr` -itself is mostly an internal interface used by other rules. - -## Returns and the expected return type - -`Return e` synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` (the construct itself -produces no value), but the *value being returned* should be checked against the enclosing -procedure's declared output type. The intended design: thread the expected return type -through {name Strata.Laurel.ResolveState}`ResolveState`, set it from `proc.outputs` in -{name Strata.Laurel.resolveProcedure}`resolveProcedure` / -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` before resolving the -body, and have the `Return` rule push the expected type into its value via -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. This closes a soundness gap in the -synth-only design where `return 0` in a `bool`-returning procedure was not caught (because -the body's overall synthesized type was {name Strata.Laurel.HighType.TVoid}`TVoid` and the -body-vs-output check was skipped on `TVoid`). - -## What this is, in type-system terms - -The checker is: - -- *bidirectional*, with a single subsumption rule at the synth↔check boundary, -- with a *gradual* relation (`isConsistentSubtype`) - rather than a strict one — {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic - type, justified by Laurel's targeting of dynamic source languages, -- over a *nominal-with-stubs* subtype relation - (`isSubtype`) — currently structural equality, intended to - walk inheritance chains and unwrap aliases / constrained types, -- with *arity tracking via tuple types* - ({name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr`) for multi-output - procedures, -- and *side-effecting expressions modeled as* - {name Strata.Laurel.HighType.TVoid}`TVoid` so blocks, returns, and loops compose cleanly. - -The wildcard carve-out for {name Strata.Laurel.HighType.UserDefined}`UserDefined` from the -previous design is gone — user-defined types are no longer a backdoor through the checker. -The {name Strata.Laurel.HighType.TCore}`TCore` carve-out is preserved for now as a -migration aid and is expected to be removed. - # Translation Pipeline Laurel programs are verified by translating them to Strata Core and then invoking the Core From 7d24b64f955d6b121479f2d884ed7dfb40f3d74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:48:06 -0400 Subject: [PATCH 016/189] concise explanations --- docs/verso/LaurelDoc.lean | 72 +++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 41 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 1577232261..d79bf17900 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -243,10 +243,10 @@ includes {name Strata.Laurel.StmtExpr.Return}`Return`, ## Typing rules -Each construct is given as a derivation. Premises sit above the line, conclusion below. -Rules tagged `(impl)` are implemented; rules tagged `(planned)` describe the intended -behavior but aren't yet wired in. `Γ` (the lexical scope) is left implicit; every rule -threads it identically. +Each construct is given as a derivation. `(impl)` = implemented; `(planned)` = intended, +not yet wired in. `(impl-ish)` = implemented but still calls a legacy helper (`checkBool` / +`checkNumeric`/`checkAssignable`) instead of going through `checkStmtExpr`; functionally +equivalent under `<:`. ### Sub (subsumption) @@ -256,8 +256,7 @@ e ⇒ A A <: B e ⇐ B ``` -The default fallback in `checkStmtExpr`. Used by every construct that doesn't have a -bespoke check rule. +Fallback in `checkStmtExpr` whenever no bespoke check rule applies. ### LiteralInt @@ -303,8 +302,8 @@ bespoke check rule. Var (.Field e f) ⇒ T_f ``` -`f` is resolved against the type of `e` (or the enclosing instance type for `self.f`); the -typing rule is independent of which path resolution took. +Resolution looks `f` up against the type of `e` (or the enclosing instance type for +`self.f`); the typing rule itself is path-agnostic. ### Var (.Declare) @@ -332,15 +331,12 @@ cond ⇐ TBool thenBr ⇐ T elseBr ⇐ T IfThenElse cond thenBr (some elseBr) ⇐ T ``` -If-NoElse synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value -to give back when `cond` is false. Without this rule, `x : int := if c then 5` would -type-check spuriously. +If-NoElse uses {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value when +`cond` is false; without it, `x : int := if c then 5` would type-check spuriously. -If-Synth picks the then-branch type; the result is always consumed by an enclosing -`checkAssignable` or by Sub, which provides a one-sided check against the surrounding -context. The two branches are deliberately not compared against each other: statement-position -`if`s commonly mix a value branch with a {name Strata.Laurel.HighType.TVoid}`TVoid` branch -(early `return`, `exit`, `assert`, …), which a strict equality check would reject incorrectly. +If-Synth picks the then-branch arbitrarily and does *not* compare branches: a statement- +position `if` often pairs a value branch with a `return`/`exit`/`assert`. The surrounding +context's `checkAssignable` or Sub provides the actual check downstream. ### Block @@ -364,15 +360,13 @@ context. The two branches are deliberately not compared against each other: stat Block [] label ⇐ T ``` -The non-last statements are synthesized but their types are discarded — this is the lax -rule. It matches Java/Python/JavaScript expression-statement semantics: `f(x);` where `f` -returns a value is normal idiomatic code. The cost is that `5;` (a literal in statement -position) is silently accepted; flagging it would belong to a lint, not the type checker. +Non-last statements are synthesized but their types discarded (the lax rule). This matches +Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` +is silently accepted; flagging it belongs to a lint. -In check mode, the expected type is pushed into the *last* statement rather than checked at -the boundary. Errors then fire at the offending subexpression inside `s_n`, and the -expected type keeps propagating through nested -{name Strata.Laurel.StmtExpr.Block}`Block` / +Check mode pushes `T` into the *last* statement instead of comparing the block's +synthesized type at the boundary. Errors then fire at the offending subexpression, and `T` +keeps propagating through nested {name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / {name Strata.Laurel.StmtExpr.Hole}`Hole` / {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. @@ -401,11 +395,11 @@ expected type keeps propagating through nested Return (some e) ⇒ TVoid ``` -The current `Return-Some` rule discards the value's synthesized type. The planned rule -threads the expected return type through {name Strata.Laurel.ResolveState}`ResolveState` -(set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedure` / -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`), so `return 0` in -a `bool`-returning procedure can be caught at the `Return` site. +`Return-Some` currently throws away the value's type, so `return 0` in a `bool`-returning +procedure isn't caught. The planned rule threads the expected return type through +{name Strata.Laurel.ResolveState}`ResolveState` (set from `proc.outputs` in +{name Strata.Laurel.resolveProcedure}`resolveProcedure` / +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`). ### While @@ -415,9 +409,8 @@ a `bool`-returning procedure can be caught at the `Return` site. While cond invs dec body ⇒ TVoid ``` -`impl-ish` here means the rule is implemented but `cond` and `invs_i` go through the legacy -`checkBool` helper rather than `checkStmtExpr cond TBool`. Functionally equivalent under -`<:`; slated for migration. +`dec` (the optional decreases clause) is currently resolved without a type check; the +intended target is a numeric type, not yet enforced. ### Assert @@ -490,8 +483,7 @@ a `bool`-returning procedure can be caught at the `Return` site. `Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, {name Strata.Laurel.HighType.TReal}`TReal`, -{name Strata.Laurel.HighType.TFloat64}`TFloat64`". Today this is enforced by `checkNumeric` -rather than a `checkStmtExpr` chain; equivalent under `<:`. +{name Strata.Laurel.HighType.TFloat64}`TFloat64`". ### PrimitiveOp (equality) @@ -509,10 +501,9 @@ rather than a `checkStmtExpr` chain; equivalent under `<:`. PrimitiveOp op args ⇒ T ``` -The "result is the type of the first argument" rule handles `int + int → int`, -`real + real → real` etc. without unification. A consequence: `int + real` is *not* -flagged today — each operand individually passes `Numeric`. A real fix would be a -numeric-promotion or unification rule; for now this is a known relaxation. +"Result is the type of the first argument" handles `int + int → int`, `real + real → real`, +etc. without unification. Known relaxation: `int + real` passes (each operand individually +passes `Numeric`); a proper fix needs numeric promotion or unification. ### PrimitiveOp (string concatenation) @@ -522,8 +513,7 @@ numeric-promotion or unification rule; for now this is a known relaxation. PrimitiveOp op args ⇒ TString ``` -The current implementation performs no operand check on `StrConcat`; the planned rule -above describes the intended behavior. +Operand check not yet implemented — `StrConcat` accepts any operands today. ### New @@ -546,7 +536,7 @@ above describes the intended behavior. AsType target T ⇒ T ``` -`AsType` does not check `target` against `T` — the cast is the user's claim. +`target` is resolved but not checked against `T` — the cast is the user's claim. ### IsType From 2d8531948737c2fda8b8862c5ac5c8b2c763c196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:49:38 -0400 Subject: [PATCH 017/189] restore contexts in rules --- docs/verso/LaurelDoc.lean | 290 ++++++++++++++++++++------------------ 1 file changed, 151 insertions(+), 139 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index d79bf17900..7eb90d4f87 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -243,17 +243,19 @@ includes {name Strata.Laurel.StmtExpr.Return}`Return`, ## Typing rules -Each construct is given as a derivation. `(impl)` = implemented; `(planned)` = intended, -not yet wired in. `(impl-ish)` = implemented but still calls a legacy helper (`checkBool` / -`checkNumeric`/`checkAssignable`) instead of going through `checkStmtExpr`; functionally -equivalent under `<:`. +Each construct is given as a derivation. `Γ` is the current lexical scope (see +{name Strata.Laurel.ResolveState}`ResolveState`'s `scope`); it threads identically through +every premise and conclusion unless a rule explicitly extends it (written `Γ, x : T`). +`(impl)` = implemented; `(planned)` = intended, not yet wired in. `(impl-ish)` = implemented +but still calls a legacy helper (`checkBool` / `checkNumeric` / `checkAssignable`) instead of +going through `checkStmtExpr`; functionally equivalent under `<:`. ### Sub (subsumption) ``` -e ⇒ A A <: B -───────────────── (Sub, impl) - e ⇐ B +Γ ⊢ e ⇒ A A <: B +───────────────────── (Sub, impl) + Γ ⊢ e ⇐ B ``` Fallback in `checkStmtExpr` whenever no bespoke check rule applies. @@ -261,45 +263,45 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. ### LiteralInt ``` -───────────────────── (Lit-Int, impl) - LiteralInt n ⇒ TInt +────────────────────────── (Lit-Int, impl) + Γ ⊢ LiteralInt n ⇒ TInt ``` ### LiteralBool ``` -────────────────────── (Lit-Bool, impl) - LiteralBool b ⇒ TBool +─────────────────────────── (Lit-Bool, impl) + Γ ⊢ LiteralBool b ⇒ TBool ``` ### LiteralString ``` -──────────────────────────── (Lit-String, impl) - LiteralString s ⇒ TString +───────────────────────────────── (Lit-String, impl) + Γ ⊢ LiteralString s ⇒ TString ``` ### LiteralDecimal ``` -───────────────────────────── (Lit-Decimal, impl) - LiteralDecimal d ⇒ TReal +────────────────────────────────── (Lit-Decimal, impl) + Γ ⊢ LiteralDecimal d ⇒ TReal ``` ### Var (.Local) ``` - Γ(x) = T -────────────────────── (Var-Local, impl) - Var (.Local x) ⇒ T + Γ(x) = T +─────────────────────────── (Var-Local, impl) + Γ ⊢ Var (.Local x) ⇒ T ``` ### Var (.Field) ``` - e ⇒ _ Γ(f) = T_f -───────────────────────── (Var-Field, impl) - Var (.Field e f) ⇒ T_f + Γ ⊢ e ⇒ _ Γ(f) = T_f +────────────────────────────── (Var-Field, impl) + Γ ⊢ Var (.Field e f) ⇒ T_f ``` Resolution looks `f` up against the type of `e` (or the enclosing instance type for @@ -308,27 +310,30 @@ Resolution looks `f` up against the type of `e` (or the enclosing instance type ### Var (.Declare) ``` - Γ(x) ↦ T fresh -────────────────────────────────── (Var-Declare, impl) - Var (.Declare ⟨x, T⟩) ⇒ TVoid + x ∉ dom(Γ) +───────────────────────────────────────── (Var-Declare, impl) + Γ ⊢ Var (.Declare ⟨x, T⟩) ⇒ TVoid ⊣ Γ, x : T ``` +`⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the +remainder of the enclosing scope. + ### IfThenElse ``` -cond ⇐ TBool thenBr ⇒ T -───────────────────────────────────────── (If-NoElse, impl) - IfThenElse cond thenBr none ⇒ TVoid +Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T +───────────────────────────────────────────── (If-NoElse, impl) + Γ ⊢ IfThenElse cond thenBr none ⇒ TVoid -cond ⇐ TBool thenBr ⇒ T_t elseBr ⇒ T_e -───────────────────────────────────────────────── (If-Synth, impl) - IfThenElse cond thenBr (some elseBr) ⇒ T_t +Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e +────────────────────────────────────────────────────────────── (If-Synth, impl) + Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T_t -cond ⇐ TBool thenBr ⇐ T elseBr ⇐ T -───────────────────────────────────────────── (If-Check, planned) - IfThenElse cond thenBr (some elseBr) ⇐ T +Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T +────────────────────────────────────────────────────────── (If-Check, planned) + Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T ``` If-NoElse uses {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value when @@ -341,25 +346,30 @@ context's `checkAssignable` or Sub provides the actual check downstream. ### Block ``` - s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇒ T -─────────────────────────────────────────────────── (Block-Synth, impl) - Block [s_1; …; s_n] label ⇒ T +Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇒ T +─────────────────────────────────────────────────────────────────────────── (Block-Synth, impl) + Γ ⊢ Block [s_1; …; s_n] label ⇒ T -──────────────────────── (Block-Synth-Empty, impl) - Block [] label ⇒ TVoid +───────────────────────────── (Block-Synth-Empty, impl) + Γ ⊢ Block [] label ⇒ TVoid - s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇐ T -─────────────────────────────────────────────────── (Block-Check, impl) - Block [s_1; …; s_n] label ⇐ T +Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇐ T +─────────────────────────────────────────────────────────────────────────── (Block-Check, impl) + Γ ⊢ Block [s_1; …; s_n] label ⇐ T TVoid <: T -────────────────────── (Block-Check-Empty, impl) - Block [] label ⇐ T +───────────────────────── (Block-Check-Empty, impl) + Γ ⊢ Block [] label ⇐ T ``` +The notation `Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i` says each statement is resolved in the scope produced +by its predecessor and may itself extend the scope (`Var (.Declare …)` does); the +`Γ_{n-1}` that types `s_n` is the scope after all earlier declarations. Bindings introduced +inside the block don't escape — `Γ` is what surrounds the block. + Non-last statements are synthesized but their types discarded (the lax rule). This matches Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` is silently accepted; flagging it belongs to a lint. @@ -374,25 +384,25 @@ keeps propagating through nested {name Strata.Laurel.StmtExpr.Block}`Block` / ### Exit ``` -───────────────────── (Exit, impl) - Exit target ⇒ TVoid +──────────────────────── (Exit, impl) + Γ ⊢ Exit target ⇒ TVoid ``` ### Return ``` -───────────────────────── (Return-None, impl) - Return none ⇒ TVoid +───────────────────────────── (Return-None, impl) + Γ ⊢ Return none ⇒ TVoid - e ⇒ _ -────────────────────────── (Return-Some, impl) - Return (some e) ⇒ TVoid + Γ ⊢ e ⇒ _ +────────────────────────────── (Return-Some, impl) + Γ ⊢ Return (some e) ⇒ TVoid - Γ_proc.outputs = [T] e ⇐ T -───────────────────────────────── (Return-Some-Checked, planned) - Return (some e) ⇒ TVoid + Γ_proc.outputs = [T] Γ ⊢ e ⇐ T +────────────────────────────────────── (Return-Some-Checked, planned) + Γ ⊢ Return (some e) ⇒ TVoid ``` `Return-Some` currently throws away the value's type, so `return 0` in a `bool`-returning @@ -404,9 +414,9 @@ procedure isn't caught. The planned rule threads the expected return type throug ### While ``` - cond ⇐ TBool invs_i ⇐ TBool dec ⇐ ? body ⇒ _ -───────────────────────────────────────────────────────────── (While, impl-ish) - While cond invs dec body ⇒ TVoid + Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ +─────────────────────────────────────────────────────────────────────────────── (While, impl-ish) + Γ ⊢ While cond invs dec body ⇒ TVoid ``` `dec` (the optional decreases clause) is currently resolved without a type check; the @@ -415,70 +425,70 @@ intended target is a numeric type, not yet enforced. ### Assert ``` - cond ⇐ TBool -────────────────────────── (Assert, impl-ish) - Assert cond ⇒ TVoid + Γ ⊢ cond ⇐ TBool +────────────────────────────── (Assert, impl-ish) + Γ ⊢ Assert cond ⇒ TVoid ``` ### Assume ``` - cond ⇐ TBool -───────────────────── (Assume, impl-ish) - Assume cond ⇒ TVoid + Γ ⊢ cond ⇐ TBool +───────────────────────────── (Assume, impl-ish) + Γ ⊢ Assume cond ⇒ TVoid ``` ### Assign ``` - Γ(x) = T_x e ⇒ T_e T_e <: T_x -───────────────────────────────────────── (Assign-Single, impl-ish) - Assign [x] e ⇒ TVoid + Γ(x) = T_x Γ ⊢ e ⇒ T_e T_e <: T_x +─────────────────────────────────────────────── (Assign-Single, impl-ish) + Γ ⊢ Assign [x] e ⇒ TVoid - targets ⇒ Ts e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i -───────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl-ish) - Assign targets e ⇒ TVoid + Γ ⊢ targets_i = x_i Γ(x_i) = T_i Γ ⊢ e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i +───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl-ish) + Γ ⊢ Assign targets e ⇒ TVoid ``` ### StaticCall ``` - callee = static-procedure with inputs Ts and outputs [T] - args ⇒ Us U_i <: T_i (pairwise) -──────────────────────────────────────────────────────────── (Static-Call, impl-ish) - StaticCall callee args ⇒ T + Γ(callee) = static-procedure with inputs Ts and outputs [T] + Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) +───────────────────────────────────────────────────────────── (Static-Call, impl-ish) + Γ ⊢ StaticCall callee args ⇒ T - callee = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 - args ⇒ Us U_i <: T_i (pairwise) -───────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl-ish) - StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] + Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 + Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) +────────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl-ish) + Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` ### InstanceCall ``` - target ⇒ _ callee = instance-procedure with inputs [self; Ts] and outputs [T] - args ⇒ Us U_i <: T_i (pairwise; self is dropped) -───────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl-ish) - InstanceCall target callee args ⇒ T + Γ ⊢ target ⇒ _ Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T] + Γ ⊢ args ⇒ Us U_i <: T_i (pairwise; self is dropped) +───────────────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl-ish) + Γ ⊢ InstanceCall target callee args ⇒ T ``` ### PrimitiveOp (logical) ``` - args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} -───────────────────────────── (Op-Bool, impl-ish) - PrimitiveOp op args ⇒ TBool + Γ ⊢ args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} +────────────────────────────────── (Op-Bool, impl-ish) + Γ ⊢ PrimitiveOp op args ⇒ TBool ``` ### PrimitiveOp (comparison) ``` - args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} -───────────────────────────── (Op-Cmp, impl-ish) - PrimitiveOp op args ⇒ TBool + Γ ⊢ args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} +───────────────────────────────── (Op-Cmp, impl-ish) + Γ ⊢ PrimitiveOp op args ⇒ TBool ``` `Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, @@ -488,17 +498,17 @@ intended target is a numeric type, not yet enforced. ### PrimitiveOp (equality) ``` - lhs ⇒ T_l rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} -────────────────────────────────────────────────────── (Op-Eq, impl-ish) - PrimitiveOp op [lhs; rhs] ⇒ TBool + Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} +───────────────────────────────────────────────────────────────── (Op-Eq, impl-ish) + Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool ``` ### PrimitiveOp (arithmetic) ``` - args_i ⇐ Numeric args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} -────────────────────────────────────────── (Op-Arith, impl-ish) - PrimitiveOp op args ⇒ T + Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} +────────────────────────────────────────────────── (Op-Arith, impl-ish) + Γ ⊢ PrimitiveOp op args ⇒ T ``` "Result is the type of the first argument" handles `int + int → int`, `real + real → real`, @@ -508,9 +518,9 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ### PrimitiveOp (string concatenation) ``` - args_i ⇐ TString op = StrConcat -───────────────────────────── (Op-Concat, planned) - PrimitiveOp op args ⇒ TString + Γ ⊢ args_i ⇐ TString op = StrConcat +───────────────────────────────────── (Op-Concat, planned) + Γ ⊢ PrimitiveOp op args ⇒ TString ``` Operand check not yet implemented — `StrConcat` accepts any operands today. @@ -518,22 +528,22 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. ### New ``` - ref resolves to a composite or datatype T -───────────────────────────────────────────── (New-Ok, impl) - New ref ⇒ UserDefined T + Γ(ref) is a composite or datatype T +────────────────────────────────────────── (New-Ok, impl) + Γ ⊢ New ref ⇒ UserDefined T - ref does not resolve to a composite or datatype -───────────────────────────────────────────────── (New-Fallback, impl) - New ref ⇒ Unknown + Γ(ref) is not a composite or datatype +───────────────────────────────────────── (New-Fallback, impl) + Γ ⊢ New ref ⇒ Unknown ``` ### AsType ``` - target ⇒ _ -───────────────────── (AsType, impl) - AsType target T ⇒ T + Γ ⊢ target ⇒ _ +───────────────────────────── (AsType, impl) + Γ ⊢ AsType target T ⇒ T ``` `target` is resolved but not checked against `T` — the cast is the user's claim. @@ -541,95 +551,97 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. ### IsType ``` - target ⇒ _ -────────────────────────── (IsType, impl) - IsType target T ⇒ TBool + Γ ⊢ target ⇒ _ +───────────────────────────────── (IsType, impl) + Γ ⊢ IsType target T ⇒ TBool ``` ### ReferenceEquals ``` - lhs ⇒ _ rhs ⇒ _ -─────────────────────────────── (RefEq, impl) - ReferenceEquals lhs rhs ⇒ TBool + Γ ⊢ lhs ⇒ _ Γ ⊢ rhs ⇒ _ +─────────────────────────────────────── (RefEq, impl) + Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool ``` ### Quantifier ``` - body ⇒ _ -───────────────────────────────────────────── (Quantifier, impl) - Quantifier mode ⟨x, T⟩ trig body ⇒ TBool + Γ, x : T ⊢ body ⇒ _ +───────────────────────────────────────────────── (Quantifier, impl) + Γ ⊢ Quantifier mode ⟨x, T⟩ trig body ⇒ TBool ``` +The bound variable `x : T` is introduced in scope only for the body (and trigger). + ### Assigned ``` - name ⇒ _ -───────────────────────── (Assigned, impl) - Assigned name ⇒ TBool + Γ ⊢ name ⇒ _ +───────────────────────────── (Assigned, impl) + Γ ⊢ Assigned name ⇒ TBool ``` ### Old ``` - v ⇒ T -───────────── (Old, impl) - Old v ⇒ T + Γ ⊢ v ⇒ T +───────────────── (Old, impl) + Γ ⊢ Old v ⇒ T ``` ### Fresh ``` - v ⇒ _ -────────────────── (Fresh, impl) - Fresh v ⇒ TBool + Γ ⊢ v ⇒ _ +───────────────────── (Fresh, impl) + Γ ⊢ Fresh v ⇒ TBool ``` ### ProveBy ``` - v ⇒ T proof ⇒ _ -────────────────────────── (ProveBy, impl) - ProveBy v proof ⇒ T + Γ ⊢ v ⇒ T Γ ⊢ proof ⇒ _ +─────────────────────────────────── (ProveBy, impl) + Γ ⊢ ProveBy v proof ⇒ T ``` ### PureFieldUpdate ``` - target ⇒ T_t newVal ⇒ _ -───────────────────────────────────── (PureFieldUpdate, impl) - PureFieldUpdate target f newVal ⇒ T_t + Γ ⊢ target ⇒ T_t Γ ⊢ newVal ⇒ _ +─────────────────────────────────────────────── (PureFieldUpdate, impl) + Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t ``` ### This ``` -───────────────────── (This, impl) - This ⇒ Unknown +────────────────────────── (This, impl) + Γ ⊢ This ⇒ Unknown ``` ### Abstract / All / ContractOf ``` -──────────────────────────────────────── (Abstract / All / ContractOf, impl) - Abstract / All / ContractOf … ⇒ Unknown +───────────────────────────────────────────── (Abstract / All / ContractOf, impl) + Γ ⊢ Abstract / All / ContractOf … ⇒ Unknown ``` ### Hole ``` -─────────────────────── (Hole-Some, impl) - Hole d (some T) ⇒ T +──────────────────────────── (Hole-Some, impl) + Γ ⊢ Hole d (some T) ⇒ T -───────────────────────── (Hole-None-Synth, impl) - Hole d none ⇒ Unknown +───────────────────────────────── (Hole-None-Synth, impl) + Γ ⊢ Hole d none ⇒ Unknown - Unknown <: T -────────────────────── (Hole-None-Check, planned) - Hole d none ⇐ T + Unknown <: T +───────────────────────── (Hole-None-Check, planned) + Γ ⊢ Hole d none ⇐ T ``` In check mode today, `Hole d none ⇐ T` reduces to subsumption (`Unknown <: T`, which always From 55083a6716ee3a3a3054188846a3dff828d1cc62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:53:23 -0400 Subject: [PATCH 018/189] simplify presentation --- docs/verso/LaurelDoc.lean | 235 +++++++++++++++++++++++++------------- 1 file changed, 157 insertions(+), 78 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 7eb90d4f87..ff0d09183d 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -250,7 +250,26 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x but still calls a legacy helper (`checkBool` / `checkNumeric` / `checkAssignable`) instead of going through `checkStmtExpr`; functionally equivalent under `<:`. -### Sub (subsumption) +### Index + +- *Subsumption* — Sub +- *Literals* — Lit-Int, Lit-Bool, Lit-String, Lit-Decimal +- *Variables* — Var-Local, Var-Field, Var-Declare +- *Control flow* — If-NoElse, If-Synth, If-Check (planned); Block-Synth, Block-Synth-Empty, + Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, Return-Some-Checked + (planned); While +- *Verification statements* — Assert, Assume +- *Assignment* — Assign-Single, Assign-Multi +- *Calls* — Static-Call, Static-Call-Multi, Instance-Call +- *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat (planned) +- *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate +- *Verification expressions* — Quantifier, Assigned, Old, Fresh, ProveBy +- *Untyped forms* — This; Abstract / All / ContractOf +- *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check (planned) + +### Subsumption + +#### Sub ``` Γ ⊢ e ⇒ A A <: B @@ -260,35 +279,39 @@ going through `checkStmtExpr`; functionally equivalent under `<:`. Fallback in `checkStmtExpr` whenever no bespoke check rule applies. -### LiteralInt +### Literals + +#### Lit-Int ``` ────────────────────────── (Lit-Int, impl) Γ ⊢ LiteralInt n ⇒ TInt ``` -### LiteralBool +#### Lit-Bool ``` ─────────────────────────── (Lit-Bool, impl) Γ ⊢ LiteralBool b ⇒ TBool ``` -### LiteralString +#### Lit-String ``` ───────────────────────────────── (Lit-String, impl) Γ ⊢ LiteralString s ⇒ TString ``` -### LiteralDecimal +#### Lit-Decimal ``` ────────────────────────────────── (Lit-Decimal, impl) Γ ⊢ LiteralDecimal d ⇒ TReal ``` -### Var (.Local) +### Variables + +#### Var-Local ``` Γ(x) = T @@ -296,7 +319,7 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. Γ ⊢ Var (.Local x) ⇒ T ``` -### Var (.Field) +#### Var-Field ``` Γ ⊢ e ⇒ _ Γ(f) = T_f @@ -307,7 +330,7 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. Resolution looks `f` up against the type of `e` (or the enclosing instance type for `self.f`); the typing rule itself is path-agnostic. -### Var (.Declare) +#### Var-Declare ``` x ∉ dom(Γ) @@ -318,100 +341,125 @@ Resolution looks `f` up against the type of `e` (or the enclosing instance type `⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the remainder of the enclosing scope. -### IfThenElse +### Control flow + +#### If-NoElse ``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T ───────────────────────────────────────────── (If-NoElse, impl) Γ ⊢ IfThenElse cond thenBr none ⇒ TVoid +``` + +The construct synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no +value when `cond` is false; without this, `x : int := if c then 5` would type-check +spuriously. +#### If-Synth +``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e ────────────────────────────────────────────────────────────── (If-Synth, impl) Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T_t +``` + +Picks the then-branch type arbitrarily; the two branches are *not* compared, since a +statement-position `if` often pairs a value branch with a `return`/`exit`/`assert`. The +enclosing `checkAssignable` or Sub provides the actual check downstream. +#### If-Check +``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T ────────────────────────────────────────────────────────── (If-Check, planned) Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T ``` -If-NoElse uses {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value when -`cond` is false; without it, `x : int := if c then 5` would type-check spuriously. - -If-Synth picks the then-branch arbitrarily and does *not* compare branches: a statement- -position `if` often pairs a value branch with a `return`/`exit`/`assert`. The surrounding -context's `checkAssignable` or Sub provides the actual check downstream. - -### Block +#### Block-Synth ``` Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇒ T ─────────────────────────────────────────────────────────────────────────── (Block-Synth, impl) Γ ⊢ Block [s_1; …; s_n] label ⇒ T +``` + +`Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i` says each statement is resolved in the scope produced by its +predecessor and may itself extend it (`Var (.Declare …)` does); `s_n` is typed in +`Γ_{n-1}`. Bindings introduced inside the block don't escape — `Γ` is what surrounds the +block. +Non-last statements are synthesized but their types discarded (the lax rule). This matches +Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` +is silently accepted; flagging it belongs to a lint. +#### Block-Synth-Empty + +``` ───────────────────────────── (Block-Synth-Empty, impl) Γ ⊢ Block [] label ⇒ TVoid +``` +#### Block-Check +``` Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇐ T ─────────────────────────────────────────────────────────────────────────── (Block-Check, impl) Γ ⊢ Block [s_1; …; s_n] label ⇐ T +``` + +Pushes `T` into the *last* statement rather than comparing the block's synthesized type at +the boundary. Errors fire at the offending subexpression, and `T` keeps propagating through +nested {name Strata.Laurel.StmtExpr.Block}`Block` / +{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / +{name Strata.Laurel.StmtExpr.Hole}`Hole` / +{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. +#### Block-Check-Empty +``` TVoid <: T ───────────────────────── (Block-Check-Empty, impl) Γ ⊢ Block [] label ⇐ T ``` -The notation `Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i` says each statement is resolved in the scope produced -by its predecessor and may itself extend the scope (`Var (.Declare …)` does); the -`Γ_{n-1}` that types `s_n` is the scope after all earlier declarations. Bindings introduced -inside the block don't escape — `Γ` is what surrounds the block. - -Non-last statements are synthesized but their types discarded (the lax rule). This matches -Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` -is silently accepted; flagging it belongs to a lint. - -Check mode pushes `T` into the *last* statement instead of comparing the block's -synthesized type at the boundary. Errors then fire at the offending subexpression, and `T` -keeps propagating through nested {name Strata.Laurel.StmtExpr.Block}`Block` / -{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / -{name Strata.Laurel.StmtExpr.Hole}`Hole` / -{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. - -### Exit +#### Exit ``` ──────────────────────── (Exit, impl) Γ ⊢ Exit target ⇒ TVoid ``` -### Return +#### Return-None ``` ───────────────────────────── (Return-None, impl) Γ ⊢ Return none ⇒ TVoid +``` +#### Return-Some +``` Γ ⊢ e ⇒ _ ────────────────────────────── (Return-Some, impl) Γ ⊢ Return (some e) ⇒ TVoid +``` +The value's synthesized type is currently discarded, so `return 0` in a `bool`-returning +procedure isn't caught. Replaced by Return-Some-Checked once the expected return type is +threaded through {name Strata.Laurel.ResolveState}`ResolveState`. +#### Return-Some-Checked + +``` Γ_proc.outputs = [T] Γ ⊢ e ⇐ T ────────────────────────────────────── (Return-Some-Checked, planned) Γ ⊢ Return (some e) ⇒ TVoid ``` -`Return-Some` currently throws away the value's type, so `return 0` in a `bool`-returning -procedure isn't caught. The planned rule threads the expected return type through -{name Strata.Laurel.ResolveState}`ResolveState` (set from `proc.outputs` in -{name Strata.Laurel.resolveProcedure}`resolveProcedure` / -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`). +Set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedure` / +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`. -### While +#### While ``` Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ @@ -419,10 +467,12 @@ procedure isn't caught. The planned rule threads the expected return type throug Γ ⊢ While cond invs dec body ⇒ TVoid ``` -`dec` (the optional decreases clause) is currently resolved without a type check; the -intended target is a numeric type, not yet enforced. +`dec` (the optional decreases clause) is resolved without a type check today; the intended +target is a numeric type. + +### Verification statements -### Assert +#### Assert ``` Γ ⊢ cond ⇐ TBool @@ -430,7 +480,7 @@ intended target is a numeric type, not yet enforced. Γ ⊢ Assert cond ⇒ TVoid ``` -### Assume +#### Assume ``` Γ ⊢ cond ⇐ TBool @@ -438,35 +488,45 @@ intended target is a numeric type, not yet enforced. Γ ⊢ Assume cond ⇒ TVoid ``` -### Assign +### Assignment + +#### Assign-Single ``` Γ(x) = T_x Γ ⊢ e ⇒ T_e T_e <: T_x ─────────────────────────────────────────────── (Assign-Single, impl-ish) Γ ⊢ Assign [x] e ⇒ TVoid +``` +#### Assign-Multi +``` Γ ⊢ targets_i = x_i Γ(x_i) = T_i Γ ⊢ e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i ───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl-ish) Γ ⊢ Assign targets e ⇒ TVoid ``` -### StaticCall +### Calls + +#### Static-Call ``` Γ(callee) = static-procedure with inputs Ts and outputs [T] Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) ───────────────────────────────────────────────────────────── (Static-Call, impl-ish) Γ ⊢ StaticCall callee args ⇒ T +``` +#### Static-Call-Multi +``` Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) ────────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl-ish) Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` -### InstanceCall +#### Instance-Call ``` Γ ⊢ target ⇒ _ Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T] @@ -475,7 +535,13 @@ intended target is a numeric type, not yet enforced. Γ ⊢ InstanceCall target callee args ⇒ T ``` -### PrimitiveOp (logical) +### Primitive operations + +`Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, +{name Strata.Laurel.HighType.TReal}`TReal`, +{name Strata.Laurel.HighType.TFloat64}`TFloat64`". + +#### Op-Bool ``` Γ ⊢ args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} @@ -483,7 +549,7 @@ intended target is a numeric type, not yet enforced. Γ ⊢ PrimitiveOp op args ⇒ TBool ``` -### PrimitiveOp (comparison) +#### Op-Cmp ``` Γ ⊢ args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} @@ -491,11 +557,7 @@ intended target is a numeric type, not yet enforced. Γ ⊢ PrimitiveOp op args ⇒ TBool ``` -`Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, -{name Strata.Laurel.HighType.TReal}`TReal`, -{name Strata.Laurel.HighType.TFloat64}`TFloat64`". - -### PrimitiveOp (equality) +#### Op-Eq ``` Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} @@ -503,7 +565,7 @@ intended target is a numeric type, not yet enforced. Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool ``` -### PrimitiveOp (arithmetic) +#### Op-Arith ``` Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} @@ -515,7 +577,7 @@ intended target is a numeric type, not yet enforced. etc. without unification. Known relaxation: `int + real` passes (each operand individually passes `Numeric`); a proper fix needs numeric promotion or unification. -### PrimitiveOp (string concatenation) +#### Op-Concat ``` Γ ⊢ args_i ⇐ TString op = StrConcat @@ -525,20 +587,25 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. Operand check not yet implemented — `StrConcat` accepts any operands today. -### New +### Object forms + +#### New-Ok ``` Γ(ref) is a composite or datatype T ────────────────────────────────────────── (New-Ok, impl) Γ ⊢ New ref ⇒ UserDefined T +``` +#### New-Fallback +``` Γ(ref) is not a composite or datatype ───────────────────────────────────────── (New-Fallback, impl) Γ ⊢ New ref ⇒ Unknown ``` -### AsType +#### AsType ``` Γ ⊢ target ⇒ _ @@ -548,7 +615,7 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. `target` is resolved but not checked against `T` — the cast is the user's claim. -### IsType +#### IsType ``` Γ ⊢ target ⇒ _ @@ -556,7 +623,7 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. Γ ⊢ IsType target T ⇒ TBool ``` -### ReferenceEquals +#### RefEq ``` Γ ⊢ lhs ⇒ _ Γ ⊢ rhs ⇒ _ @@ -564,7 +631,17 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool ``` -### Quantifier +#### PureFieldUpdate + +``` + Γ ⊢ target ⇒ T_t Γ ⊢ newVal ⇒ _ +─────────────────────────────────────────────── (PureFieldUpdate, impl) + Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t +``` + +### Verification expressions + +#### Quantifier ``` Γ, x : T ⊢ body ⇒ _ @@ -574,7 +651,7 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. The bound variable `x : T` is introduced in scope only for the body (and trigger). -### Assigned +#### Assigned ``` Γ ⊢ name ⇒ _ @@ -582,7 +659,7 @@ The bound variable `x : T` is introduced in scope only for the body (and trigger Γ ⊢ Assigned name ⇒ TBool ``` -### Old +#### Old ``` Γ ⊢ v ⇒ T @@ -590,7 +667,7 @@ The bound variable `x : T` is introduced in scope only for the body (and trigger Γ ⊢ Old v ⇒ T ``` -### Fresh +#### Fresh ``` Γ ⊢ v ⇒ _ @@ -598,7 +675,7 @@ The bound variable `x : T` is introduced in scope only for the body (and trigger Γ ⊢ Fresh v ⇒ TBool ``` -### ProveBy +#### ProveBy ``` Γ ⊢ v ⇒ T Γ ⊢ proof ⇒ _ @@ -606,47 +683,49 @@ The bound variable `x : T` is introduced in scope only for the body (and trigger Γ ⊢ ProveBy v proof ⇒ T ``` -### PureFieldUpdate - -``` - Γ ⊢ target ⇒ T_t Γ ⊢ newVal ⇒ _ -─────────────────────────────────────────────── (PureFieldUpdate, impl) - Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t -``` +### Untyped forms -### This +#### This ``` ────────────────────────── (This, impl) Γ ⊢ This ⇒ Unknown ``` -### Abstract / All / ContractOf +#### Abstract / All / ContractOf ``` ───────────────────────────────────────────── (Abstract / All / ContractOf, impl) Γ ⊢ Abstract / All / ContractOf … ⇒ Unknown ``` -### Hole +### Holes + +#### Hole-Some ``` ──────────────────────────── (Hole-Some, impl) Γ ⊢ Hole d (some T) ⇒ T +``` +#### Hole-None-Synth +``` ───────────────────────────────── (Hole-None-Synth, impl) Γ ⊢ Hole d none ⇒ Unknown +``` +#### Hole-None-Check +``` Unknown <: T ───────────────────────── (Hole-None-Check, planned) Γ ⊢ Hole d none ⇐ T ``` In check mode today, `Hole d none ⇐ T` reduces to subsumption (`Unknown <: T`, which always -holds). The planned bespoke rule would record the inferred `T` on the hole node so -downstream passes can see it, instead of leaving `none` until the hole-inference pass. +holds). The planned rule would record the inferred `T` on the hole node so downstream +passes can see it, instead of leaving `none` until the hole-inference pass. # Translation Pipeline From 1feda5bc8c7246f6b1ef0389bfeb1e78aad68c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 14:14:16 -0400 Subject: [PATCH 019/189] add back in contexts --- docs/verso/LaurelDoc.lean | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index ff0d09183d..4b5f314c2d 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -160,8 +160,8 @@ mismatches against the surrounding context become diagnostics. The implementatio There are two operations on expressions, written here in standard bidirectional notation: ``` -e ⇒ T -- "e synthesizes T" (synthStmtExpr) -e ⇐ T -- "e checks against T" (checkStmtExpr) +Γ ⊢ e ⇒ T -- "e synthesizes T" (synthStmtExpr) +Γ ⊢ e ⇐ T -- "e checks against T" (checkStmtExpr) ``` Synthesis returns a type inferred from the expression itself; checking verifies that the @@ -170,9 +170,9 @@ is determined locally (synth) or by context (check). The two judgments are conne single change-of-direction rule, *subsumption*: ``` -e ⇒ A A <: B -───────────────── (Sub) - e ⇐ B +Γ ⊢ e ⇒ A A <: B +───────────────────── (Sub) + Γ ⊢ e ⇐ B ``` Subsumption is the *only* place the checker switches from check to synth mode. It fires as From 9bb2990e9de638b16a7bea013d815742c759b5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 14:50:07 -0400 Subject: [PATCH 020/189] =?UTF-8?q?describe=20literals=20and=20easy=20rule?= =?UTF-8?q?s=20(call,=20assert/assume=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/verso/LaurelDoc.lean | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 4b5f314c2d..4abf2eff2c 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -246,9 +246,7 @@ includes {name Strata.Laurel.StmtExpr.Return}`Return`, Each construct is given as a derivation. `Γ` is the current lexical scope (see {name Strata.Laurel.ResolveState}`ResolveState`'s `scope`); it threads identically through every premise and conclusion unless a rule explicitly extends it (written `Γ, x : T`). -`(impl)` = implemented; `(planned)` = intended, not yet wired in. `(impl-ish)` = implemented -but still calls a legacy helper (`checkBool` / `checkNumeric` / `checkAssignable`) instead of -going through `checkStmtExpr`; functionally equivalent under `<:`. +`(impl)` = implemented; `(planned)` = intended, not yet wired in. ### Index @@ -261,7 +259,7 @@ going through `checkStmtExpr`; functionally equivalent under `<:`. - *Verification statements* — Assert, Assume - *Assignment* — Assign-Single, Assign-Multi - *Calls* — Static-Call, Static-Call-Multi, Instance-Call -- *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat (planned) +- *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat - *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate - *Verification expressions* — Quantifier, Assigned, Old, Fresh, ProveBy - *Untyped forms* — This; Abstract / All / ContractOf @@ -365,7 +363,8 @@ spuriously. Picks the then-branch type arbitrarily; the two branches are *not* compared, since a statement-position `if` often pairs a value branch with a `return`/`exit`/`assert`. The -enclosing `checkAssignable` or Sub provides the actual check downstream. +enclosing context's check (Sub, or a containing `checkSubtype` like an assignment) provides +the actual check downstream. #### If-Check @@ -463,7 +462,7 @@ Set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedur ``` Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ -─────────────────────────────────────────────────────────────────────────────── (While, impl-ish) +─────────────────────────────────────────────────────────────────────────────── (While, impl) Γ ⊢ While cond invs dec body ⇒ TVoid ``` @@ -476,7 +475,7 @@ target is a numeric type. ``` Γ ⊢ cond ⇐ TBool -────────────────────────────── (Assert, impl-ish) +────────────────────────────── (Assert, impl) Γ ⊢ Assert cond ⇒ TVoid ``` @@ -484,7 +483,7 @@ target is a numeric type. ``` Γ ⊢ cond ⇐ TBool -───────────────────────────── (Assume, impl-ish) +───────────────────────────── (Assume, impl) Γ ⊢ Assume cond ⇒ TVoid ``` @@ -494,7 +493,7 @@ target is a numeric type. ``` Γ(x) = T_x Γ ⊢ e ⇒ T_e T_e <: T_x -─────────────────────────────────────────────── (Assign-Single, impl-ish) +─────────────────────────────────────────────── (Assign-Single, impl) Γ ⊢ Assign [x] e ⇒ TVoid ``` @@ -502,7 +501,7 @@ target is a numeric type. ``` Γ ⊢ targets_i = x_i Γ(x_i) = T_i Γ ⊢ e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i -───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl-ish) +───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl) Γ ⊢ Assign targets e ⇒ TVoid ``` @@ -513,7 +512,7 @@ target is a numeric type. ``` Γ(callee) = static-procedure with inputs Ts and outputs [T] Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) -───────────────────────────────────────────────────────────── (Static-Call, impl-ish) +───────────────────────────────────────────────────────────── (Static-Call, impl) Γ ⊢ StaticCall callee args ⇒ T ``` @@ -522,7 +521,7 @@ target is a numeric type. ``` Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) -────────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl-ish) +────────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl) Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` @@ -531,7 +530,7 @@ target is a numeric type. ``` Γ ⊢ target ⇒ _ Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T] Γ ⊢ args ⇒ Us U_i <: T_i (pairwise; self is dropped) -───────────────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl-ish) +───────────────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl) Γ ⊢ InstanceCall target callee args ⇒ T ``` @@ -545,7 +544,7 @@ target is a numeric type. ``` Γ ⊢ args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} -────────────────────────────────── (Op-Bool, impl-ish) +────────────────────────────────── (Op-Bool, impl) Γ ⊢ PrimitiveOp op args ⇒ TBool ``` @@ -553,7 +552,7 @@ target is a numeric type. ``` Γ ⊢ args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} -───────────────────────────────── (Op-Cmp, impl-ish) +───────────────────────────────── (Op-Cmp, impl) Γ ⊢ PrimitiveOp op args ⇒ TBool ``` @@ -561,7 +560,7 @@ target is a numeric type. ``` Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} -───────────────────────────────────────────────────────────────── (Op-Eq, impl-ish) +───────────────────────────────────────────────────────────────── (Op-Eq, impl) Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool ``` @@ -569,7 +568,7 @@ target is a numeric type. ``` Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} -────────────────────────────────────────────────── (Op-Arith, impl-ish) +────────────────────────────────────────────────── (Op-Arith, impl) Γ ⊢ PrimitiveOp op args ⇒ T ``` @@ -581,12 +580,10 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ``` Γ ⊢ args_i ⇐ TString op = StrConcat -───────────────────────────────────── (Op-Concat, planned) +───────────────────────────────────── (Op-Concat, impl) Γ ⊢ PrimitiveOp op args ⇒ TString ``` -Operand check not yet implemented — `StrConcat` accepts any operands today. - ### Object forms #### New-Ok From fea9f951b282d780f4a5af6cfc5bfcd738aad73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 14:50:34 -0400 Subject: [PATCH 021/189] remove old helpers + mis-subtyping diagnostics --- Strata/Languages/Laurel/Resolution.lean | 112 +++++++++--------------- 1 file changed, 41 insertions(+), 71 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 97f6556331..1259185178 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -37,10 +37,10 @@ Walks the AST under `ResolveM`, a state monad over `ResolveState`. Phase 1: declared type to build a qualified lookup key), - opens fresh nested scopes via `withScope` for blocks, quantifiers, procedure bodies, and constrained-type constraint/witness expressions, -- synthesizes a `HighType` for every `StmtExpr` and runs the type-checking - helpers (`checkBool`, `checkNumeric`, `checkAssignable`, `checkComparable`) - on assignments, call arguments, condition positions, functional bodies, and - constant initializers. +- synthesizes a `HighType` for every `StmtExpr` and checks it (via + `checkStmtExpr` for fresh subexpressions, or `checkSubtype` when a type is + already in hand) on assignments, call arguments, condition positions, + functional bodies, and constant initializers. Before any bodies are walked, `preRegisterTopLevel` registers every top-level name (types and their constructors / testers / destructors / instance @@ -436,54 +436,21 @@ private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := | .TCore _, _ | _, .TCore _ => true | _, _ => isSubtype sub sup -/-- Check that a type is boolean, emitting a diagnostic if not. -/ -private def checkBool (source : Option FileRange) (ty : HighTypeMd) : ResolveM Unit := do +/-- Type-level subtype check: emits the standard "expected/got" diagnostic when + `actual` is not a consistent subtype of `expected`. Used at sites where the + actual type is already in hand (assignment, call args, body vs declared + output) — equivalent to `checkStmtExpr e expected` but without re-synthesizing. -/ +private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do + unless isConsistentSubtype actual expected do + typeMismatch source (s!"'{formatType expected}'") actual + +/-- Test whether a type is in the set of numeric primitives, modulo gradual + consistency. Used by Op-Cmp / Op-Arith. -/ +private def isConsistentNumeric (ty : HighTypeMd) : Bool := match ty.val with - | .TBool | .Unknown => pure () - | .UserDefined _ => pure () -- constrained types may wrap bool - | _ => typeMismatch source "bool" ty - -/-- Check that a type is numeric (int, real, or float64), emitting a diagnostic if not. -/ -private def checkNumeric (source : Option FileRange) (ty : HighTypeMd) : ResolveM Unit := do - match ty.val with - | .TInt | .TReal | .TFloat64 | .Unknown => pure () - | .UserDefined _ => pure () -- constrained types may wrap numeric types - | _ => typeMismatch source "a numeric type" ty - -/-- Check that two types are compatible, emitting a diagnostic if not. - UserDefined types are always considered compatible with each other since - subtype relationships (inheritance) are not tracked during resolution. - TCore types are not checked since they are pass-through types from the Core language. -/ -private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do - match expected.val, actual.val with - | .Unknown, _ => pure () - | _, .Unknown => pure () - | .UserDefined _, _ => pure () -- subtype relationships not tracked here - | _, .UserDefined _ => pure () -- subtype relationships not tracked here - | .TCore _, _ => pure () -- pass-through Core types not checked during resolution - | _, .TCore _ => pure () -- pass-through Core types not checked during resolution - | _, _ => - if !highEq expected actual then - let expectedStr := formatType expected - let actualStr := formatType actual - let diag := diagnosticFromSource source s!"Type mismatch: expected '{expectedStr}', but got '{actualStr}'" - modify fun s => { s with errors := s.errors.push diag } - -/-- Check that two types are comparable (for == and !=), emitting a symmetric diagnostic if not. -/ -private def checkComparable (source : Option FileRange) (lhsTy : HighTypeMd) (rhsTy : HighTypeMd) : ResolveM Unit := do - match lhsTy.val, rhsTy.val with - | .Unknown, _ => pure () - | _, .Unknown => pure () - | .UserDefined _, _ => pure () - | _, .UserDefined _ => pure () - | .TCore _, _ => pure () - | _, .TCore _ => pure () - | _, _ => - if !highEq lhsTy rhsTy then - let lhsStr := formatType lhsTy - let rhsStr := formatType rhsTy - let diag := diagnosticFromSource source s!"Operands of '==' have incompatible types '{lhsStr}' and '{rhsStr}'" - modify fun s => { s with errors := s.errors.push diag } + | .TInt | .TReal | .TFloat64 | .Unknown => true + | .TCore _ => true + | _ => false /-- Get the type of a resolved variable reference from scope. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do @@ -547,10 +514,9 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | none => { val := .TVoid, source := source } pure (.Block stmts' label, lastTy) | .While cond invs dec body => - let (cond', condTy) ← synthStmtExpr cond - checkBool cond'.source condTy + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } let invs' ← invs.attach.mapM (fun a => have := a.property; do - let (e', _) ← synthStmtExpr a.val; pure e') + checkStmtExpr a.val { val := .TBool, source := a.val.source }) let dec' ← dec.attach.mapM (fun a => have := a.property; do let (e', _) ← synthStmtExpr a.val; pure e') let (body', _) ← synthStmtExpr body @@ -614,7 +580,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | some (_, node) => pure node.getType | none => pure { val := HighType.Unknown, source := fieldName.source : HighTypeMd } let tTy ← targetTy - checkAssignable source tTy valueTy + checkSubtype source tTy valueTy pure (.Assign targets' value', valueTy) | .Var (.Field target fieldName) => let (target', _) ← synthStmtExpr target @@ -633,9 +599,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee - -- Check argument types match parameter types for (argTy, paramTy) in argTypes.zip paramTypes do - checkAssignable source paramTy argTy + checkSubtype source paramTy argTy pure (.StaticCall callee' args', retTy) | .PrimitiveOp op args => let results ← args.mapM synthStmtExpr @@ -649,18 +614,25 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | some headTy => headTy.val | none => HighType.TInt | .StrConcat => HighType.TString - -- Type check operands match op with | .And | .Or | .AndThen | .OrElse | .Not | .Implies => - for aTy in argTypes do checkBool source aTy + for aTy in argTypes do + checkSubtype source { val := .TBool, source := aTy.source } aTy | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => - for aTy in argTypes do checkNumeric source aTy + for aTy in argTypes do + unless isConsistentNumeric aTy do typeMismatch aTy.source "a numeric type" aTy | .Eq | .Neq => - -- Check that operands are compatible with each other (symmetric check) + -- Symmetric: pass if either direction is consistent. match argTypes with - | [lhsTy, rhsTy] => checkComparable source lhsTy rhsTy + | [lhsTy, rhsTy] => + unless isConsistentSubtype lhsTy rhsTy || isConsistentSubtype rhsTy lhsTy do + let diag := diagnosticFromSource source + s!"Operands of '==' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" + modify fun s => { s with errors := s.errors.push diag } | _ => pure () - | .StrConcat => pure () + | .StrConcat => + for aTy in argTypes do + checkSubtype source { val := .TString, source := aTy.source } aTy pure (.PrimitiveOp op args', { val := resultTy, source := source }) | .New ref => let ref' ← resolveRef ref source @@ -695,10 +667,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee - -- Check argument types match parameter types (skip first param which is 'self') + -- Skip first param (self) when matching args. let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] for (argTy, paramTy) in argTypes.zip callParamTypes do - checkAssignable source paramTy argTy + checkSubtype source paramTy argTy pure (.InstanceCall target' callee' args', retTy) | .Quantifier mode param trigger body => withScope do @@ -718,12 +690,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let (val', _) ← synthStmtExpr val pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => - let (cond', condTy) ← synthStmtExpr condExpr - checkBool cond'.source condTy + let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) | .Assume cond => - let (cond', condTy) ← synthStmtExpr cond - checkBool cond'.source condTy + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } pure (.Assume cond', { val := .TVoid, source := source }) | .ProveBy val proof => let (val', valTy) ← synthStmtExpr val @@ -829,7 +799,7 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do | [singleOutput] => -- Only check when body produces a value (not void from return/while/assign) if bodyTy.val != HighType.TVoid then - checkAssignable proc.name.source singleOutput.type bodyTy + checkSubtype proc.name.source singleOutput.type bodyTy | _ => pure () let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr return { name := procName', inputs := inputs', outputs := outputs', @@ -869,7 +839,7 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv match proc.outputs with | [singleOutput] => if bodyTy.val != HighType.TVoid then - checkAssignable proc.name.source singleOutput.type bodyTy + checkSubtype proc.name.source singleOutput.type bodyTy | _ => pure () let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr modify fun s => { s with instanceTypeName := savedInstType } From ce11f125c62c5820b48a70165903a4156a0531b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 14:56:38 -0400 Subject: [PATCH 022/189] quantifier check for bool body --- Strata/Languages/Laurel/Resolution.lean | 2 +- docs/verso/LaurelDoc.lean | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 1259185178..01676402aa 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -678,7 +678,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do let (e', _) ← synthStmtExpr pv.val; pure e') - let (body', _) ← synthStmtExpr body + let body' ← checkStmtExpr body { val := .TBool, source := body.source } pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) | .Assigned name => let (name', _) ← synthStmtExpr name diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 4abf2eff2c..34217be4ab 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -641,12 +641,14 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. #### Quantifier ``` - Γ, x : T ⊢ body ⇒ _ + Γ, x : T ⊢ body ⇐ TBool ───────────────────────────────────────────────── (Quantifier, impl) Γ ⊢ Quantifier mode ⟨x, T⟩ trig body ⇒ TBool ``` -The bound variable `x : T` is introduced in scope only for the body (and trigger). +The bound variable `x : T` is introduced in scope only for the body (and trigger). The body +is checked against {name Strata.Laurel.HighType.TBool}`TBool` since a quantifier is a +proposition; without this, `forall x: int :: x + 1` would be silently accepted. #### Assigned From 2207acc29c55802e387d9ce5f71770bbb1385f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 15:03:02 -0400 Subject: [PATCH 023/189] remove redundant headings --- docs/verso/LaurelDoc.lean | 96 --------------------------------------- 1 file changed, 96 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 34217be4ab..6bb9271cbf 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -267,8 +267,6 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x ### Subsumption -#### Sub - ``` Γ ⊢ e ⇒ A A <: B ───────────────────── (Sub, impl) @@ -279,29 +277,21 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. ### Literals -#### Lit-Int - ``` ────────────────────────── (Lit-Int, impl) Γ ⊢ LiteralInt n ⇒ TInt ``` -#### Lit-Bool - ``` ─────────────────────────── (Lit-Bool, impl) Γ ⊢ LiteralBool b ⇒ TBool ``` -#### Lit-String - ``` ───────────────────────────────── (Lit-String, impl) Γ ⊢ LiteralString s ⇒ TString ``` -#### Lit-Decimal - ``` ────────────────────────────────── (Lit-Decimal, impl) Γ ⊢ LiteralDecimal d ⇒ TReal @@ -309,16 +299,12 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. ### Variables -#### Var-Local - ``` Γ(x) = T ─────────────────────────── (Var-Local, impl) Γ ⊢ Var (.Local x) ⇒ T ``` -#### Var-Field - ``` Γ ⊢ e ⇒ _ Γ(f) = T_f ────────────────────────────── (Var-Field, impl) @@ -328,8 +314,6 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. Resolution looks `f` up against the type of `e` (or the enclosing instance type for `self.f`); the typing rule itself is path-agnostic. -#### Var-Declare - ``` x ∉ dom(Γ) ───────────────────────────────────────── (Var-Declare, impl) @@ -341,8 +325,6 @@ remainder of the enclosing scope. ### Control flow -#### If-NoElse - ``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T ───────────────────────────────────────────── (If-NoElse, impl) @@ -353,8 +335,6 @@ The construct synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because the value when `cond` is false; without this, `x : int := if c then 5` would type-check spuriously. -#### If-Synth - ``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e ────────────────────────────────────────────────────────────── (If-Synth, impl) @@ -366,16 +346,12 @@ statement-position `if` often pairs a value branch with a `return`/`exit`/`asser enclosing context's check (Sub, or a containing `checkSubtype` like an assignment) provides the actual check downstream. -#### If-Check - ``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T ────────────────────────────────────────────────────────── (If-Check, planned) Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T ``` -#### Block-Synth - ``` Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇒ T ─────────────────────────────────────────────────────────────────────────── (Block-Synth, impl) @@ -391,15 +367,11 @@ Non-last statements are synthesized but their types discarded (the lax rule). Th Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` is silently accepted; flagging it belongs to a lint. -#### Block-Synth-Empty - ``` ───────────────────────────── (Block-Synth-Empty, impl) Γ ⊢ Block [] label ⇒ TVoid ``` -#### Block-Check - ``` Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇐ T ─────────────────────────────────────────────────────────────────────────── (Block-Check, impl) @@ -413,30 +385,22 @@ nested {name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.Hole}`Hole` / {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. -#### Block-Check-Empty - ``` TVoid <: T ───────────────────────── (Block-Check-Empty, impl) Γ ⊢ Block [] label ⇐ T ``` -#### Exit - ``` ──────────────────────── (Exit, impl) Γ ⊢ Exit target ⇒ TVoid ``` -#### Return-None - ``` ───────────────────────────── (Return-None, impl) Γ ⊢ Return none ⇒ TVoid ``` -#### Return-Some - ``` Γ ⊢ e ⇒ _ ────────────────────────────── (Return-Some, impl) @@ -447,8 +411,6 @@ The value's synthesized type is currently discarded, so `return 0` in a `bool`-r procedure isn't caught. Replaced by Return-Some-Checked once the expected return type is threaded through {name Strata.Laurel.ResolveState}`ResolveState`. -#### Return-Some-Checked - ``` Γ_proc.outputs = [T] Γ ⊢ e ⇐ T ────────────────────────────────────── (Return-Some-Checked, planned) @@ -458,8 +420,6 @@ threaded through {name Strata.Laurel.ResolveState}`ResolveState`. Set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedure` / {name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`. -#### While - ``` Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ ─────────────────────────────────────────────────────────────────────────────── (While, impl) @@ -471,16 +431,12 @@ target is a numeric type. ### Verification statements -#### Assert - ``` Γ ⊢ cond ⇐ TBool ────────────────────────────── (Assert, impl) Γ ⊢ Assert cond ⇒ TVoid ``` -#### Assume - ``` Γ ⊢ cond ⇐ TBool ───────────────────────────── (Assume, impl) @@ -489,16 +445,12 @@ target is a numeric type. ### Assignment -#### Assign-Single - ``` Γ(x) = T_x Γ ⊢ e ⇒ T_e T_e <: T_x ─────────────────────────────────────────────── (Assign-Single, impl) Γ ⊢ Assign [x] e ⇒ TVoid ``` -#### Assign-Multi - ``` Γ ⊢ targets_i = x_i Γ(x_i) = T_i Γ ⊢ e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i ───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl) @@ -507,8 +459,6 @@ target is a numeric type. ### Calls -#### Static-Call - ``` Γ(callee) = static-procedure with inputs Ts and outputs [T] Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) @@ -516,8 +466,6 @@ target is a numeric type. Γ ⊢ StaticCall callee args ⇒ T ``` -#### Static-Call-Multi - ``` Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) @@ -525,8 +473,6 @@ target is a numeric type. Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` -#### Instance-Call - ``` Γ ⊢ target ⇒ _ Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T] Γ ⊢ args ⇒ Us U_i <: T_i (pairwise; self is dropped) @@ -540,32 +486,24 @@ target is a numeric type. {name Strata.Laurel.HighType.TReal}`TReal`, {name Strata.Laurel.HighType.TFloat64}`TFloat64`". -#### Op-Bool - ``` Γ ⊢ args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} ────────────────────────────────── (Op-Bool, impl) Γ ⊢ PrimitiveOp op args ⇒ TBool ``` -#### Op-Cmp - ``` Γ ⊢ args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} ───────────────────────────────── (Op-Cmp, impl) Γ ⊢ PrimitiveOp op args ⇒ TBool ``` -#### Op-Eq - ``` Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} ───────────────────────────────────────────────────────────────── (Op-Eq, impl) Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool ``` -#### Op-Arith - ``` Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ────────────────────────────────────────────────── (Op-Arith, impl) @@ -576,8 +514,6 @@ target is a numeric type. etc. without unification. Known relaxation: `int + real` passes (each operand individually passes `Numeric`); a proper fix needs numeric promotion or unification. -#### Op-Concat - ``` Γ ⊢ args_i ⇐ TString op = StrConcat ───────────────────────────────────── (Op-Concat, impl) @@ -586,24 +522,18 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ### Object forms -#### New-Ok - ``` Γ(ref) is a composite or datatype T ────────────────────────────────────────── (New-Ok, impl) Γ ⊢ New ref ⇒ UserDefined T ``` -#### New-Fallback - ``` Γ(ref) is not a composite or datatype ───────────────────────────────────────── (New-Fallback, impl) Γ ⊢ New ref ⇒ Unknown ``` -#### AsType - ``` Γ ⊢ target ⇒ _ ───────────────────────────── (AsType, impl) @@ -612,24 +542,18 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. `target` is resolved but not checked against `T` — the cast is the user's claim. -#### IsType - ``` Γ ⊢ target ⇒ _ ───────────────────────────────── (IsType, impl) Γ ⊢ IsType target T ⇒ TBool ``` -#### RefEq - ``` Γ ⊢ lhs ⇒ _ Γ ⊢ rhs ⇒ _ ─────────────────────────────────────── (RefEq, impl) Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool ``` -#### PureFieldUpdate - ``` Γ ⊢ target ⇒ T_t Γ ⊢ newVal ⇒ _ ─────────────────────────────────────────────── (PureFieldUpdate, impl) @@ -638,8 +562,6 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ### Verification expressions -#### Quantifier - ``` Γ, x : T ⊢ body ⇐ TBool ───────────────────────────────────────────────── (Quantifier, impl) @@ -650,32 +572,24 @@ The bound variable `x : T` is introduced in scope only for the body (and trigger is checked against {name Strata.Laurel.HighType.TBool}`TBool` since a quantifier is a proposition; without this, `forall x: int :: x + 1` would be silently accepted. -#### Assigned - ``` Γ ⊢ name ⇒ _ ───────────────────────────── (Assigned, impl) Γ ⊢ Assigned name ⇒ TBool ``` -#### Old - ``` Γ ⊢ v ⇒ T ───────────────── (Old, impl) Γ ⊢ Old v ⇒ T ``` -#### Fresh - ``` Γ ⊢ v ⇒ _ ───────────────────── (Fresh, impl) Γ ⊢ Fresh v ⇒ TBool ``` -#### ProveBy - ``` Γ ⊢ v ⇒ T Γ ⊢ proof ⇒ _ ─────────────────────────────────── (ProveBy, impl) @@ -684,15 +598,11 @@ proposition; without this, `forall x: int :: x + 1` would be silently accepted. ### Untyped forms -#### This - ``` ────────────────────────── (This, impl) Γ ⊢ This ⇒ Unknown ``` -#### Abstract / All / ContractOf - ``` ───────────────────────────────────────────── (Abstract / All / ContractOf, impl) Γ ⊢ Abstract / All / ContractOf … ⇒ Unknown @@ -700,22 +610,16 @@ proposition; without this, `forall x: int :: x + 1` would be silently accepted. ### Holes -#### Hole-Some - ``` ──────────────────────────── (Hole-Some, impl) Γ ⊢ Hole d (some T) ⇒ T ``` -#### Hole-None-Synth - ``` ───────────────────────────────── (Hole-None-Synth, impl) Γ ⊢ Hole d none ⇒ Unknown ``` -#### Hole-None-Check - ``` Unknown <: T ───────────────────────── (Hole-None-Check, planned) From 4cbab19c4b809853cfd45484462c6f2660890bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 15:09:05 -0400 Subject: [PATCH 024/189] class-rules : updates on same-typed fields + this in class context only --- Strata/Languages/Laurel/Resolution.lean | 17 +++++++++++-- docs/verso/LaurelDoc.lean | 32 +++++++++++++++++++------ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 01676402aa..bd139a67d1 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -590,7 +590,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .PureFieldUpdate target fieldName newVal => let (target', targetTy) ← synthStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source - let (newVal', _) ← synthStmtExpr newVal + let fieldTy ← getVarType fieldName' + let newVal' ← checkStmtExpr newVal fieldTy pure (.PureFieldUpdate target' fieldName' newVal', targetTy) | .StaticCall callee args => let callee' ← resolveRef callee source @@ -646,7 +647,19 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let ty := if kindOk then { val := HighType.UserDefined ref', source := source } else { val := HighType.Unknown, source := source } pure (.New ref', ty) - | .This => pure (.This, { val := .Unknown, source := source }) + | .This => + let s ← get + match s.instanceTypeName with + | some typeName => + let typeId : Identifier := + match s.scope.get? typeName with + | some (uid, _) => { text := typeName, uniqueId := some uid, source := source } + | none => { text := typeName, source := source } + pure (.This, { val := .UserDefined typeId, source := source }) + | none => + let diag := diagnosticFromSource source "'this' is not allowed outside instance methods" + modify fun s => { s with errors := s.errors.push diag } + pure (.This, { val := .Unknown, source := source }) | .ReferenceEquals lhs rhs => let (lhs', _) ← synthStmtExpr lhs let (rhs', _) ← synthStmtExpr rhs diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 6bb9271cbf..8506285865 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -262,7 +262,8 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x - *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat - *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate - *Verification expressions* — Quantifier, Assigned, Old, Fresh, ProveBy -- *Untyped forms* — This; Abstract / All / ContractOf +- *Self reference* — This-Inside, This-Outside +- *Untyped forms* — Abstract / All / ContractOf - *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check (planned) ### Subsumption @@ -555,11 +556,14 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ``` ``` - Γ ⊢ target ⇒ T_t Γ ⊢ newVal ⇒ _ -─────────────────────────────────────────────── (PureFieldUpdate, impl) - Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t + Γ ⊢ target ⇒ T_t Γ(f) = T_f Γ ⊢ newVal ⇐ T_f +───────────────────────────────────────────────────────────── (PureFieldUpdate, impl) + Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t ``` +`f` is resolved against `T_t` (or the enclosing instance type) and `newVal` is checked +against the field's declared type. + ### Verification expressions ``` @@ -596,13 +600,27 @@ proposition; without this, `forall x: int :: x + 1` would be silently accepted. Γ ⊢ ProveBy v proof ⇒ T ``` -### Untyped forms +### Self reference ``` -────────────────────────── (This, impl) - Γ ⊢ This ⇒ Unknown + Γ.instanceTypeName = some T +────────────────────────────────── (This-Inside, impl) + Γ ⊢ This ⇒ UserDefined T + + + Γ.instanceTypeName = none +────────────────────────────── (This-Outside, impl) + Γ ⊢ This ⇒ Unknown [emits "'this' is not allowed outside instance methods"] ``` +`Γ.instanceTypeName` is the +{name Strata.Laurel.ResolveState}`ResolveState` field set by +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` for the duration of +an instance method body. With it, `this.field` and instance-method dispatch synthesize real +types instead of being wildcarded through {name Strata.Laurel.HighType.Unknown}`Unknown`. + +### Untyped forms + ``` ───────────────────────────────────────────── (Abstract / All / ContractOf, impl) Γ ⊢ Abstract / All / ContractOf … ⇒ Unknown From 0a38a7cf2b159802ba89bbb911edd37fdbb69d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 15:37:40 -0400 Subject: [PATCH 025/189] references checks --- Strata/Languages/Laurel/Resolution.lean | 21 ++++++++++++++++++--- docs/verso/LaurelDoc.lean | 23 +++++++++++++++++------ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index bd139a67d1..3df24ce05e 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -452,6 +452,15 @@ private def isConsistentNumeric (ty : HighTypeMd) : Bool := | .TCore _ => true | _ => false +/-- Test whether a type is a user-defined reference type, modulo gradual + consistency. Used by Fresh and ReferenceEquals, which only make sense on + composite/datatype references. -/ +private def isConsistentReference (ty : HighTypeMd) : Bool := + match ty.val with + | .UserDefined _ | .Unknown => true + | .TCore _ => true + | _ => false + /-- Get the type of a resolved variable reference from scope. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get @@ -661,8 +670,12 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := modify fun s => { s with errors := s.errors.push diag } pure (.This, { val := .Unknown, source := source }) | .ReferenceEquals lhs rhs => - let (lhs', _) ← synthStmtExpr lhs - let (rhs', _) ← synthStmtExpr rhs + let (lhs', lhsTy) ← synthStmtExpr lhs + let (rhs', rhsTy) ← synthStmtExpr rhs + unless isConsistentReference lhsTy do + typeMismatch lhsTy.source "a reference type" lhsTy + unless isConsistentReference rhsTy do + typeMismatch rhsTy.source "a reference type" rhsTy pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => let (target', _) ← synthStmtExpr target @@ -700,7 +713,9 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let (val', valTy) ← synthStmtExpr val pure (.Old val', valTy) | .Fresh val => - let (val', _) ← synthStmtExpr val + let (val', valTy) ← synthStmtExpr val + unless isConsistentReference valTy do + typeMismatch valTy.source "a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 8506285865..2c7f9542d2 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -550,11 +550,18 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ``` ``` - Γ ⊢ lhs ⇒ _ Γ ⊢ rhs ⇒ _ -─────────────────────────────────────── (RefEq, impl) - Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool + Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r isReference T_l isReference T_r +───────────────────────────────────────────────────────────────────────────── (RefEq, impl) + Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool ``` +`isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined`, +{name Strata.Laurel.HighType.Unknown}`Unknown`, or {name Strata.Laurel.HighType.TCore}`TCore` +type. Reference equality is meaningless on primitives. Compatibility between `T_l` and +`T_r` (e.g. rejecting `Cat === Dog` for unrelated user-defined types) is delegated to +future tightening of `<:` — today, two distinct user-defined names already mismatch +structurally, so the check would only fire under stronger subtyping. + ``` Γ ⊢ target ⇒ T_t Γ(f) = T_f Γ ⊢ newVal ⇐ T_f ───────────────────────────────────────────────────────────── (PureFieldUpdate, impl) @@ -589,11 +596,15 @@ proposition; without this, `forall x: int :: x + 1` would be silently accepted. ``` ``` - Γ ⊢ v ⇒ _ -───────────────────── (Fresh, impl) - Γ ⊢ Fresh v ⇒ TBool + Γ ⊢ v ⇒ T isReference T +───────────────────────────────── (Fresh, impl) + Γ ⊢ Fresh v ⇒ TBool ``` +`isReference T` is the same predicate as in {name Strata.Laurel.StmtExpr.ReferenceEquals}`ReferenceEquals`. +{name Strata.Laurel.StmtExpr.Fresh}`Fresh` only makes sense on heap-allocated references; +`fresh(5)` is rejected. + ``` Γ ⊢ v ⇒ T Γ ⊢ proof ⇒ _ ─────────────────────────────────── (ProveBy, impl) From 3efef2fad0357bd5514ca6c8694397612eba8254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 16:21:06 -0400 Subject: [PATCH 026/189] pretty printers --- Strata/Languages/Laurel/Laurel.lean | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index a5cd11439c..2fd34ce3b0 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -100,6 +100,20 @@ inductive Operation : Type where | StrConcat deriving Repr +instance : ToString Operation where + toString + | .Eq => "==" | .Neq => "!=" + | .And => "&&" | .Or => "||" + | .Not => "!" | .Implies => "==>" + | .AndThen => "&&!" | .OrElse => "||!" + | .Neg => "-" | .Add => "+" + | .Sub => "-" | .Mul => "*" + | .Div => "/" | .Mod => "%" + | .DivT => "/t" | .ModT => "%t" + | .Lt => "<" | .Leq => "<=" + | .Gt => ">" | .Geq => ">=" + | .StrConcat => "++" + /-- A wrapper that pairs a value with source-level metadata such as source locations and annotations. All Laurel AST nodes are wrapped in @@ -334,6 +348,40 @@ inductive ContractType where | Reads | Modifies | Precondition | PostCondition end +/-- A short user-facing name for the construct, used in diagnostic messages. -/ +def StmtExpr.constrName : StmtExpr → String + | .IfThenElse .. => "if" + | .Block .. => "block" + | .While .. => "while" + | .Exit .. => "exit" + | .Return .. => "return" + | .LiteralInt .. => "integer literal" + | .LiteralBool .. => "boolean literal" + | .LiteralString .. => "string literal" + | .LiteralDecimal .. => "decimal literal" + | .Var .. => "variable" + | .Assign .. => ":=" + | .PureFieldUpdate .. => "field update" + | .StaticCall .. => "call" + | .PrimitiveOp op _ => toString op + | .New .. => "new" + | .This => "this" + | .ReferenceEquals .. => "reference equality" + | .AsType .. => "as" + | .IsType .. => "is" + | .InstanceCall .. => "method call" + | .Quantifier .. => "quantifier" + | .Assigned .. => "assigned" + | .Old .. => "old" + | .Fresh .. => "fresh" + | .Assert .. => "assert" + | .Assume .. => "assume" + | .ProveBy .. => "by" + | .ContractOf .. => "contractOf" + | .Abstract => "abstract" + | .All => "all" + | .Hole .. => "hole" + @[expose] abbrev HighTypeMd := AstNode HighType @[expose] abbrev StmtExprMd := AstNode StmtExpr @[expose] abbrev VariableMd := AstNode Variable From 59ce64d229796657ff883861b3a739a312a3f9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 16:38:07 -0400 Subject: [PATCH 027/189] better type mismatch diagnostics --- Strata/Languages/Laurel/Resolution.lean | 74 ++++++++++++++----------- docs/verso/LaurelDoc.lean | 36 +++++++----- 2 files changed, 62 insertions(+), 48 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 3df24ce05e..26783e2de9 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -417,24 +417,35 @@ private def formatType (ty : HighTypeMd) : String := "(" ++ ", ".intercalate parts ++ ")" | other => toString (formatHighTypeVal other) -/-- Emit a type mismatch diagnostic. -/ -private def typeMismatch (source : Option FileRange) (expected : String) (actual : HighTypeMd) : ResolveM Unit := do - let actualStr := formatType actual - let diag := diagnosticFromSource source s!"Type mismatch: expected {expected}, but got '{actualStr}'" +/-- Emit a type mismatch diagnostic. With a `construct`, the message is + "'' , got ''"; without, + ", got ''". -/ +private def typeMismatch (source : Option FileRange) (construct : Option StmtExpr) + (problem : String) (actual : HighTypeMd) : ResolveM Unit := do + let constructor := match construct with + | some c => s!"'{c.constrName}' " + | none => "" + let diag := diagnosticFromSource source s!"{constructor}{problem}, got '{formatType actual}'" modify fun s => { s with errors := s.errors.push diag } /-- Subtyping. Stub: structural equality via `highEq`. TODO: To be replaced with a real check that walks `extending` chains for composites, unfolds aliases, and unwraps constrained types to their base. -/ private def isSubtype (sub sup : HighTypeMd) : Bool := highEq sub sup -/-- Gradual consistency-subtyping (Siek–Taha style): `Unknown` is the dynamic - type and is consistent with everything in either direction. `TCore` is a - migration escape hatch and is bivariantly compatible for now. -/ -private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := - match sub.val, sup.val with +/-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the + dynamic type and is consistent with everything; otherwise the relation + delegates to structural equality. `TCore` is a temporary migration + escape hatch. -/ +private def isConsistent (a b : HighTypeMd) : Bool := + match a.val, b.val with | .Unknown, _ | _, .Unknown => true | .TCore _, _ | _, .TCore _ => true - | _, _ => isSubtype sub sup + | _, _ => highEq a b + +/-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For the flat type + lattice this collapses to `sub ~ sup ∨ sub <: sup`. -/ +private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := + isConsistent sub sup || isSubtype sub sup /-- Type-level subtype check: emits the standard "expected/got" diagnostic when `actual` is not a consistent subtype of `expected`. Used at sites where the @@ -442,20 +453,20 @@ private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := output) — equivalent to `checkStmtExpr e expected` but without re-synthesizing. -/ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do unless isConsistentSubtype actual expected do - typeMismatch source (s!"'{formatType expected}'") actual + typeMismatch source none s!"expected '{formatType expected}'" actual -/-- Test whether a type is in the set of numeric primitives, modulo gradual - consistency. Used by Op-Cmp / Op-Arith. -/ -private def isConsistentNumeric (ty : HighTypeMd) : Bool := +/-- Test whether a type is in the set of numeric primitives. `Unknown` and + `TCore` are accepted as gradual escape hatches. Used by Op-Cmp / Op-Arith. -/ +private def isNumeric (ty : HighTypeMd) : Bool := match ty.val with | .TInt | .TReal | .TFloat64 | .Unknown => true | .TCore _ => true | _ => false -/-- Test whether a type is a user-defined reference type, modulo gradual - consistency. Used by Fresh and ReferenceEquals, which only make sense on - composite/datatype references. -/ -private def isConsistentReference (ty : HighTypeMd) : Bool := +/-- Test whether a type is a user-defined reference type. `Unknown` and `TCore` + are accepted as gradual escape hatches. Used by Fresh and ReferenceEquals, + which only make sense on composite/datatype references. -/ +private def isReference (ty : HighTypeMd) : Bool := match ty.val with | .UserDefined _ | .Unknown => true | .TCore _ => true @@ -630,14 +641,14 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := checkSubtype source { val := .TBool, source := aTy.source } aTy | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => for aTy in argTypes do - unless isConsistentNumeric aTy do typeMismatch aTy.source "a numeric type" aTy + unless isNumeric aTy do + typeMismatch aTy.source (some expr) "expected a numeric type" aTy | .Eq | .Neq => - -- Symmetric: pass if either direction is consistent. match argTypes with | [lhsTy, rhsTy] => - unless isConsistentSubtype lhsTy rhsTy || isConsistentSubtype rhsTy lhsTy do + unless isConsistent lhsTy rhsTy do let diag := diagnosticFromSource source - s!"Operands of '==' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" + s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" modify fun s => { s with errors := s.errors.push diag } | _ => pure () | .StrConcat => @@ -672,10 +683,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .ReferenceEquals lhs rhs => let (lhs', lhsTy) ← synthStmtExpr lhs let (rhs', rhsTy) ← synthStmtExpr rhs - unless isConsistentReference lhsTy do - typeMismatch lhsTy.source "a reference type" lhsTy - unless isConsistentReference rhsTy do - typeMismatch rhsTy.source "a reference type" rhsTy + unless isReference lhsTy do + typeMismatch lhsTy.source (some expr) "expected a reference type" lhsTy + unless isReference rhsTy do + typeMismatch rhsTy.source (some expr) "expected a reference type" rhsTy pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => let (target', _) ← synthStmtExpr target @@ -714,8 +725,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := pure (.Old val', valTy) | .Fresh val => let (val', valTy) ← synthStmtExpr val - unless isConsistentReference valTy do - typeMismatch valTy.source "a reference type" valTy + unless isReference valTy do + typeMismatch valTy.source (some expr) "expected a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } @@ -761,9 +772,7 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE let (s', _) ← synthStmtExpr s; pure s') match _lastResult: stmts.getLast? with | none => - let tvoid : HighTypeMd := { val := .TVoid, source := source } - unless isConsistentSubtype tvoid expected do - typeMismatch source (formatType expected) tvoid + checkSubtype source expected { val := .TVoid, source := source } pure { val := .Block init' label, source := source } | some last => have := List.mem_of_getLast? _lastResult @@ -772,8 +781,7 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← synthStmtExpr exprMd - unless isConsistentSubtype actual expected do - typeMismatch source (formatType expected) actual + checkSubtype source expected actual pure e' termination_by (exprMd, 1) decreasing_by all_goals first diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 2c7f9542d2..4ff9cd7f0f 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -201,27 +201,30 @@ internal interface used by other rules. ### Gradual typing -The relation `<:` is implemented by two Lean functions — both currently stubs, both -intended to be sharpened: +The relation `<:` (used in Sub) is built from three Lean functions: - `isSubtype` — pure subtyping. The stub is structural equality via {name Strata.Laurel.highEq}`highEq`. The eventual real version walks the `extending` chain for {name Strata.Laurel.CompositeType}`CompositeType`, unfolds {name Strata.Laurel.TypeAlias}`TypeAlias` to its target, and unwraps {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base. -- `isConsistentSubtype` — gradual consistency, in the Siek–Taha sense. - {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type `?` and is consistent - with everything in either direction; otherwise the relation delegates to `isSubtype`. - {name Strata.Laurel.HighType.TCore}`TCore` is bivariantly consistent for now, as a - clearly-labelled migration escape hatch from the Core language — this carve-out is - intentionally temporary. - -Subsumption (and every bespoke check rule) uses `isConsistentSubtype`, never raw -`isSubtype`. That single choice is what makes the system *gradual*: an expression of type +- `isConsistent` — the symmetric gradual relation `~` (Siek–Taha): + {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type and is consistent with + everything; otherwise structural equality. +- `isConsistentSubtype` — defined as `isConsistent ∨ isSubtype`. For our flat lattice this + is the standard collapse of `∃R. T ~ R ∧ R <: U`. + +{name Strata.Laurel.HighType.TCore}`TCore` is bivariantly consistent for now as a temporary +migration escape hatch from the Core language; the carve-out lives in `isConsistent` and is +intentionally temporary. + +Sub (and every bespoke check rule) uses `isConsistentSubtype`. That single choice is what +makes the system *gradual*: an expression of type {name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) flows freely into any typed slot, and any expression flows freely into a slot of type {name Strata.Laurel.HighType.Unknown}`Unknown`. Strict checking is applied between -fully-known types only. +fully-known types only. The symmetric `isConsistent` is used directly by Op-Eq, where the +operand types must be mutually consistent (no subtype direction is privileged). A previous iteration was synth-only with three *bivariantly-compatible* wildcards: {name Strata.Laurel.HighType.Unknown}`Unknown`, @@ -500,11 +503,14 @@ target is a numeric type. ``` ``` - Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} -───────────────────────────────────────────────────────────────── (Op-Eq, impl) - Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool + Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l ~ T_r op ∈ {Eq, Neq} +───────────────────────────────────────────────────────── (Op-Eq, impl) + Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool ``` +`~` is the consistency relation `isConsistent` — symmetric, with the +{name Strata.Laurel.HighType.Unknown}`Unknown` wildcard. + ``` Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ────────────────────────────────────────────────── (Op-Arith, impl) From 538a68779c01857887c494545f1bc48eccb979ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 16:41:21 -0400 Subject: [PATCH 028/189] check ifthenelse --- Strata/Languages/Laurel/Resolution.lean | 10 ++++++++++ docs/verso/LaurelDoc.lean | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 26783e2de9..03fc98e54e 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -778,6 +778,16 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE have := List.mem_of_getLast? _lastResult let last' ← checkStmtExpr last expected pure { val := .Block (init' ++ [last']) label, source := source } + | .IfThenElse cond thenBr elseBr => + -- Push `expected` into both branches (rather than going through the synth + -- rule + Sub at the boundary). Without an else branch, fall back to + -- subsumption of TVoid against `expected`. + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let thenBr' ← checkStmtExpr thenBr expected + let elseBr' ← elseBr.attach.mapM (fun ⟨e, _⟩ => checkStmtExpr e expected) + if elseBr.isNone then + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .IfThenElse cond' thenBr' elseBr', source := source } | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← synthStmtExpr exprMd diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 4ff9cd7f0f..20550333f8 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -256,9 +256,9 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x - *Subsumption* — Sub - *Literals* — Lit-Int, Lit-Bool, Lit-String, Lit-Decimal - *Variables* — Var-Local, Var-Field, Var-Declare -- *Control flow* — If-NoElse, If-Synth, If-Check (planned); Block-Synth, Block-Synth-Empty, - Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, Return-Some-Checked - (planned); While +- *Control flow* — If-NoElse, If-Synth, If-Check, If-Check-NoElse; Block-Synth, + Block-Synth-Empty, Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, + Return-Some-Checked (planned); While - *Verification statements* — Assert, Assume - *Assignment* — Assign-Single, Assign-Multi - *Calls* — Static-Call, Static-Call-Multi, Instance-Call @@ -352,10 +352,21 @@ the actual check downstream. ``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T -────────────────────────────────────────────────────────── (If-Check, planned) +────────────────────────────────────────────────────────── (If-Check, impl) Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T + + +Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T TVoid <: T +───────────────────────────────────────────────────── (If-Check-NoElse, impl) + Γ ⊢ IfThenElse cond thenBr none ⇐ T ``` +Check mode pushes `T` into both branches (rather than going through If-Synth + Sub at the +boundary). Errors fire at the offending branch instead of the surrounding `if`. Without an +else branch, the construct can only succeed when `T` admits +{name Strata.Laurel.HighType.TVoid}`TVoid` — the same subsumption check `Block-Check-Empty` +performs for an empty block. + ``` Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇒ T ─────────────────────────────────────────────────────────────────────────── (Block-Synth, impl) From a0862ee46a7a0de74cc4b098e5fc4f584b49fc9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:01:12 -0400 Subject: [PATCH 029/189] type check returns multiple return arity : 0 = Void 1 = return expr // return; with returns (res:T) signature n = return; allowed only --- Strata/Languages/Laurel/Resolution.lean | 33 ++++++++++++++++++- docs/verso/LaurelDoc.lean | 43 ++++++++++++++++++------- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 03fc98e54e..e781c9b65d 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -262,6 +262,10 @@ structure ResolveState where /-- When resolving inside an instance procedure, the owning composite type name. Used by `resolveFieldRef` to resolve `self.field` when `self` has type `Any`. -/ instanceTypeName : Option String := none + /-- When resolving inside a procedure body, the declared output types (in + declaration order). `none` means no enclosing procedure. Used by `Return` + to type-check the optional return value and to flag arity/shape mismatches. -/ + expectedReturnTypes : Option (List HighTypeMd) := none @[expose] abbrev ResolveM := StateM ResolveState @@ -543,8 +547,29 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := pure (.While cond' invs' dec' body', { val := .TVoid, source := source }) | .Exit target => pure (.Exit target, { val := .TVoid, source := source }) | .Return val => do + -- Match the optional return value against the enclosing procedure's + -- declared outputs. `expectedReturnTypes = none` means we're not inside a + -- procedure body (e.g. resolving a constant initializer); skip the check. + let expected := (← get).expectedReturnTypes let val' ← val.attach.mapM (fun a => have := a.property; do - let (e', _) ← synthStmtExpr a.val; pure e') + match expected with + | some [singleOutput] => checkStmtExpr a.val singleOutput + | _ => let (e', _) ← synthStmtExpr a.val; pure e') + -- Arity/shape diagnostics independent of the value's own type. + match val, expected with + | none, some [] => pure () + | none, some [_] => pure () -- Dafny-style early exit + | none, some _ => pure () -- multi-output: bare return is fine + | some _, some [] => + let diag := diagnosticFromSource source + "void procedure cannot return a value" + modify fun s => { s with errors := s.errors.push diag } + | some _, some [_] => pure () -- value already checked above + | some _, some _ => + let diag := diagnosticFromSource source + "multi-output procedure cannot use 'return e'; assign to named outputs instead" + modify fun s => { s with errors := s.errors.push diag } + | _, none => pure () -- no enclosing procedure pure (.Return val', { val := .TVoid, source := source }) | .LiteralInt v => pure (.LiteralInt v, { val := .TInt, source := source }) | .LiteralBool v => pure (.LiteralBool v, { val := .TBool, source := source }) @@ -834,7 +859,10 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do let outputs' ← proc.outputs.mapM resolveParameter let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) let dec' ← proc.decreases.mapM resolveStmtExpr + let savedReturns := (← get).expectedReturnTypes + modify fun s => { s with expectedReturnTypes := some (outputs'.map (·.type)) } let (body', bodyTy) ← resolveBody proc.body + modify fun s => { s with expectedReturnTypes := savedReturns } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" @@ -875,7 +903,10 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv let outputs' ← proc.outputs.mapM resolveParameter let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) let dec' ← proc.decreases.mapM resolveStmtExpr + let savedReturns := (← get).expectedReturnTypes + modify fun s => { s with expectedReturnTypes := some (outputs'.map (·.type)) } let (body', bodyTy) ← resolveBody proc.body + modify fun s => { s with expectedReturnTypes := savedReturns } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 20550333f8..b7b74c5af2 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -258,7 +258,7 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x - *Variables* — Var-Local, Var-Field, Var-Declare - *Control flow* — If-NoElse, If-Synth, If-Check, If-Check-NoElse; Block-Synth, Block-Synth-Empty, Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, - Return-Some-Checked (planned); While + Return-Void-Error, Return-Multi-Error; While - *Verification statements* — Assert, Assume - *Assignment* — Assign-Single, Assign-Multi - *Calls* — Static-Call, Static-Call-Multi, Instance-Call @@ -411,29 +411,48 @@ nested {name Strata.Laurel.StmtExpr.Block}`Block` / Γ ⊢ Exit target ⇒ TVoid ``` +`Return` matches the optional return value against the enclosing procedure's declared +outputs. The expected output types are threaded through +{name Strata.Laurel.ResolveState}`ResolveState`'s `expectedReturnTypes`, set from +`proc.outputs` by {name Strata.Laurel.resolveProcedure}`resolveProcedure` / +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` for the duration of +the body. `none` means "no enclosing procedure" — e.g. resolving a constant initializer — +and skips all `Return` checks. + ``` ───────────────────────────── (Return-None, impl) Γ ⊢ Return none ⇒ TVoid ``` +A bare `return;` is allowed in any context. In a single-output procedure it acts as a +Dafny-style early exit — the output parameter retains whatever was last assigned to it. + ``` - Γ ⊢ e ⇒ _ -────────────────────────────── (Return-Some, impl) - Γ ⊢ Return (some e) ⇒ TVoid + Γ_proc.outputs = [T] Γ ⊢ e ⇐ T +────────────────────────────────────── (Return-Some, impl) + Γ ⊢ Return (some e) ⇒ TVoid ``` -The value's synthesized type is currently discarded, so `return 0` in a `bool`-returning -procedure isn't caught. Replaced by Return-Some-Checked once the expected return type is -threaded through {name Strata.Laurel.ResolveState}`ResolveState`. +In a single-output procedure, the value is checked against the declared output type. This +closes the prior soundness gap where `return 0` in a `bool`-returning procedure went +uncaught. ``` - Γ_proc.outputs = [T] Γ ⊢ e ⇐ T -────────────────────────────────────── (Return-Some-Checked, planned) - Γ ⊢ Return (some e) ⇒ TVoid + Γ_proc.outputs = [] +───────────────────────────────── (Return-Void-Error, impl) + Γ ⊢ Return (some e) — error: "void procedure cannot return a value" + + + Γ_proc.outputs = [T_1; …; T_n] (n ≥ 2) +────────────────────────────────────────────────────────── (Return-Multi-Error, impl) + Γ ⊢ Return (some e) — error: "multi-output procedure cannot + use 'return e'; assign to named outputs instead" ``` -Set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedure` / -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`. +Multi-output procedures use named-output assignment (`r := …` on the declared output +parameters). `return e` syntactically takes a single +{name Strata.Laurel.StmtExpr.Return}`Option StmtExpr`, so it cannot carry multiple values; +flagging it points users at the named-output convention. ``` Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ From b58a578b5538157a7b560b262f89f5492e28ab4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:15:26 -0400 Subject: [PATCH 030/189] type check procedures contracts --- Strata/Languages/Laurel/Resolution.lean | 25 ++++++++++++- docs/verso/LaurelDoc.lean | 47 +++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index e781c9b65d..a0e441092c 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -764,8 +764,31 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let (proof', _) ← synthStmtExpr proof pure (.ProveBy val' proof', valTy) | .ContractOf ty fn => + -- `fn` must be a direct identifier reference resolving to a procedure. + -- Anything else (arbitrary expressions, references to non-procedures) is + -- ill-formed: a contract belongs to a *named* procedure. let (fn', _) ← synthStmtExpr fn - pure (.ContractOf ty fn', { val := .Unknown, source := source }) + let s ← get + let fnIsProcRef : Bool := match fn'.val with + | .Var (.Local ref) => + match s.scope.get? ref.text with + | some (_, node) => + node.kind == .staticProcedure || + node.kind == .instanceProcedure || + node.kind == .unresolved + | none => true -- unresolved name already reported + | _ => false + unless fnIsProcRef do + let diag := diagnosticFromSource fn.source + "'contractOf' expected a procedure reference" + modify fun s => { s with errors := s.errors.push diag } + -- Result type: Bool for pre/postconditions, set of heap references for + -- reads/modifies. The element type of the set is left as Unknown for now + -- since the rule doesn't recover it from `fn`. + let resultTy : HighType := match ty with + | .Precondition | .PostCondition => .TBool + | .Reads | .Modifies => .TSet { val := .Unknown, source := none } + pure (.ContractOf ty fn', { val := resultTy, source := source }) | .Abstract => pure (.Abstract, { val := .Unknown, source := source }) | .All => pure (.All, { val := .Unknown, source := source }) | .Hole det type => match type with diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index b7b74c5af2..b98a1bda98 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -266,7 +266,8 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x - *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate - *Verification expressions* — Quantifier, Assigned, Old, Fresh, ProveBy - *Self reference* — This-Inside, This-Outside -- *Untyped forms* — Abstract / All / ContractOf +- *Untyped forms* — Abstract / All +- *ContractOf* — ContractOf-Bool, ContractOf-Set, ContractOf-Error - *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check (planned) ### Subsumption @@ -669,10 +670,50 @@ types instead of being wildcarded through {name Strata.Laurel.HighType.Unknown}` ### Untyped forms ``` -───────────────────────────────────────────── (Abstract / All / ContractOf, impl) - Γ ⊢ Abstract / All / ContractOf … ⇒ Unknown +───────────────────────────────── (Abstract / All, impl) + Γ ⊢ Abstract / All … ⇒ Unknown ``` +### ContractOf + +`ContractOf ty fn` extracts a procedure's contract clause as a value: its preconditions +(`Precondition`), postconditions (`PostCondition`), reads set (`Reads`), or modifies set +(`Modifies`). `fn` must be a direct identifier reference to a procedure — a contract belongs +to a *named* procedure, not an arbitrary expression. + +``` + fn = Var (.Local id) Γ(id) ∈ {staticProcedure, instanceProcedure} +───────────────────────────────────────────────────────────────────────── (ContractOf-Bool, impl) + Γ ⊢ ContractOf Precondition fn ⇒ TBool + Γ ⊢ ContractOf PostCondition fn ⇒ TBool + + + fn = Var (.Local id) Γ(id) ∈ {staticProcedure, instanceProcedure} +───────────────────────────────────────────────────────────────────────── (ContractOf-Set, impl) + Γ ⊢ ContractOf Reads fn ⇒ TSet Unknown + Γ ⊢ ContractOf Modifies fn ⇒ TSet Unknown +``` + +`Precondition` and `PostCondition` are propositions, hence +{name Strata.Laurel.HighType.TBool}`TBool`. `Reads` and `Modifies` are sets of heap-allocated +locations — composite/datatype references and fields. The element type is left as +{name Strata.Laurel.HighType.Unknown}`Unknown` for now since the rule doesn't yet recover it +from `fn`'s declared modifies/reads clauses. + +``` + fn is not a procedure reference +───────────────────────────────────────────── (ContractOf-Error, impl) + Γ ⊢ ContractOf … fn — error: "'contractOf' expected a procedure reference" +``` + +When `fn` doesn't resolve to a procedure (e.g. it's an arbitrary expression, or resolves to +a constant/variable), the diagnostic fires and the construct synthesizes +{name Strata.Laurel.HighType.Unknown}`Unknown` to suppress cascading errors. + +The constructor is reserved for future use — Laurel's grammar has no `contractOf` +production today, and the translator emits "not yet implemented" for it. The typing rule +exists so resolution remains exhaustive over `StmtExpr`. + ### Holes ``` From 1a639d14a3fa2474353b8c96909a657b9ddf1577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:28:15 -0400 Subject: [PATCH 031/189] check untyped holes --- Strata/Languages/Laurel/Resolution.lean | 5 +++++ docs/verso/LaurelDoc.lean | 17 ++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index a0e441092c..5022293b73 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -836,6 +836,11 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE if elseBr.isNone then checkSubtype source expected { val := .TVoid, source := source } pure { val := .IfThenElse cond' thenBr' elseBr', source := source } + | .Hole det none => + -- Untyped hole in check mode: record the expected type on the node so + -- downstream passes don't have to infer it again. Subsumption is trivial + -- (Unknown <: T always holds). + pure { val := .Hole det (some expected), source := source } | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← synthStmtExpr exprMd diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index b98a1bda98..68c1fe0f62 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -268,7 +268,7 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x - *Self reference* — This-Inside, This-Outside - *Untyped forms* — Abstract / All - *ContractOf* — ContractOf-Bool, ContractOf-Set, ContractOf-Error -- *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check (planned) +- *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check ### Subsumption @@ -727,14 +727,17 @@ exists so resolution remains exhaustive over `StmtExpr`. ``` ``` - Unknown <: T -───────────────────────── (Hole-None-Check, planned) - Γ ⊢ Hole d none ⇐ T +───────────────────────────────────── (Hole-None-Check, impl) + Γ ⊢ Hole d none ⇐ T ↦ Hole d (some T) ``` -In check mode today, `Hole d none ⇐ T` reduces to subsumption (`Unknown <: T`, which always -holds). The planned rule would record the inferred `T` on the hole node so downstream -passes can see it, instead of leaving `none` until the hole-inference pass. +In check mode, an untyped hole records the expected type `T` on the node directly. The +subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it +just preserves the type information that's available at the check-mode boundary instead of +discarding it. A separate +{name Strata.Laurel.InferHoleTypes}`InferHoleTypes` pass still runs after resolution to +annotate holes that ended up in synth-only positions; over time, as more constructs gain +bespoke check rules, fewer holes will need that pass. # Translation Pipeline From 5717c346ef2dbc2196fc7e1ed7247b8bc320c11d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:28:49 -0400 Subject: [PATCH 032/189] remove dangling reference --- docs/verso/LaurelDoc.lean | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 68c1fe0f62..bf812f7eb5 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -734,10 +734,9 @@ exists so resolution remains exhaustive over `StmtExpr`. In check mode, an untyped hole records the expected type `T` on the node directly. The subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it just preserves the type information that's available at the check-mode boundary instead of -discarding it. A separate -{name Strata.Laurel.InferHoleTypes}`InferHoleTypes` pass still runs after resolution to -annotate holes that ended up in synth-only positions; over time, as more constructs gain -bespoke check rules, fewer holes will need that pass. +discarding it. A separate `InferHoleTypes` pass still runs after resolution to annotate holes that ended +up in synth-only positions; over time, as more constructs gain bespoke check rules, fewer +holes will need that pass. # Translation Pipeline From d8b8c7ca25071ed5a023912210c33821b8382e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:44:38 -0400 Subject: [PATCH 033/189] move subtyping/consistency rules in type definition --- Strata/Languages/Laurel/Laurel.lean | 19 +++++++++++++++++++ Strata/Languages/Laurel/Resolution.lean | 19 ------------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index 2fd34ce3b0..4fc77650cc 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -488,6 +488,25 @@ instance : BEq HighTypeMd where deriving instance BEq for HighType +/-- Subtyping. Stub: structural equality via `highEq`. + TODO: walk `extending` chains for composites, unfold aliases, unwrap + constrained types to their base. -/ +def isSubtype (sub sup : HighTypeMd) : Bool := highEq sub sup + +/-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the + dynamic type and is consistent with everything; otherwise structural + equality. `TCore` is a temporary migration escape hatch. -/ +def isConsistent (a b : HighTypeMd) : Bool := + match a.val, b.val with + | .Unknown, _ | _, .Unknown => true + | .TCore _, _ | _, .TCore _ => true + | _, _ => highEq a b + +/-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For our flat lattice + this collapses to `sub ~ sup ∨ sub <: sup`. -/ +def isConsistentSubtype (sub sup : HighTypeMd) : Bool := + isConsistent sub sup || isSubtype sub sup + def HighType.isBool : HighType → Bool | TBool => true | _ => false diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 5022293b73..8c88d5189b 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -432,25 +432,6 @@ private def typeMismatch (source : Option FileRange) (construct : Option StmtExp let diag := diagnosticFromSource source s!"{constructor}{problem}, got '{formatType actual}'" modify fun s => { s with errors := s.errors.push diag } -/-- Subtyping. Stub: structural equality via `highEq`. - TODO: To be replaced with a real check that walks `extending` chains for composites, unfolds aliases, and unwraps constrained types to their base. -/ -private def isSubtype (sub sup : HighTypeMd) : Bool := highEq sub sup - -/-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the - dynamic type and is consistent with everything; otherwise the relation - delegates to structural equality. `TCore` is a temporary migration - escape hatch. -/ -private def isConsistent (a b : HighTypeMd) : Bool := - match a.val, b.val with - | .Unknown, _ | _, .Unknown => true - | .TCore _, _ | _, .TCore _ => true - | _, _ => highEq a b - -/-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For the flat type - lattice this collapses to `sub ~ sup ∨ sub <: sup`. -/ -private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := - isConsistent sub sup || isSubtype sub sup - /-- Type-level subtype check: emits the standard "expected/got" diagnostic when `actual` is not a consistent subtype of `expected`. Used at sites where the actual type is already in hand (assignment, call args, body vs declared From bbee6a7a0516c93aa14552b94c24f81a6302248a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:45:09 -0400 Subject: [PATCH 034/189] inferholetypes flag already filled hole types when inconsistent --- Strata/Languages/Laurel/InferHoleTypes.lean | 13 ++++++++++++- docs/verso/LaurelDoc.lean | 11 ++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Strata/Languages/Laurel/InferHoleTypes.lean b/Strata/Languages/Laurel/InferHoleTypes.lean index d56ad86881..026a82e5b9 100644 --- a/Strata/Languages/Laurel/InferHoleTypes.lean +++ b/Strata/Languages/Laurel/InferHoleTypes.lean @@ -87,7 +87,7 @@ private def inferExpr (expr : StmtExprMd) (expectedType : HighTypeMd) : InferHol match expr with | AstNode.mk val source => match val with - | .Hole det _ => + | .Hole det existingTy => if expectedType.val == .Unknown then modify fun s => { s with statistics := s.statistics.increment s!"{InferHoleTypesStats.holesLeftUnknown}" @@ -95,6 +95,17 @@ private def inferExpr (expr : StmtExprMd) (expectedType : HighTypeMd) : InferHol } return expr else + -- If the hole already carried a type (from resolution's Hole-None-Check + -- rule, or from a user-written `?: T`), flag a conflict when the two + -- types disagree under consistency (gradual ~). + match existingTy with + | some prior => + unless isConsistent prior expectedType do + modify fun s => { s with + diagnostics := s.diagnostics ++ [diagnosticFromSource source + s!"hole annotated with '{formatHighTypeVal prior.val}' but context expects '{formatHighTypeVal expectedType.val}'"] + } + | none => pure () modify fun s => { s with statistics := s.statistics.increment s!"{InferHoleTypesStats.holesAnnotated}" } return ⟨.Hole det (some expectedType), source⟩ | .PrimitiveOp op args => diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index bf812f7eb5..300c7393c7 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -734,9 +734,14 @@ exists so resolution remains exhaustive over `StmtExpr`. In check mode, an untyped hole records the expected type `T` on the node directly. The subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it just preserves the type information that's available at the check-mode boundary instead of -discarding it. A separate `InferHoleTypes` pass still runs after resolution to annotate holes that ended -up in synth-only positions; over time, as more constructs gain bespoke check rules, fewer -holes will need that pass. +discarding it. + +A separate `InferHoleTypes` pass still runs after resolution to annotate holes that ended +up in synth-only positions. When that pass encounters a hole whose type was already set +(by Hole-None-Check or by a user-written `?: T`), it checks the resolution-time and +inference-time types for consistency under `~`; a disagreement fires the diagnostic +*"hole annotated with 'T_resolution' but context expects 'T_inference'"*, surfacing what +would otherwise be a silent overwrite. # Translation Pipeline From 9e4c2f3dfc723ee36e55285bf53dfcd6ed7fe49d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:50:14 -0400 Subject: [PATCH 035/189] future roadmap --- Strata/Languages/Laurel/Resolution.lean | 19 ++++++++++++ docs/verso/LaurelDoc.lean | 41 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 8c88d5189b..152b2bf529 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -104,6 +104,25 @@ Each of these nodes carries a `uniqueId : Option Nat` field (defaulting to `none`). Phase 1 fills in unique values; Phase 2 then builds a map from reference IDs to `ResolvedNode` values describing the definition each reference resolves to. + +## Future structural changes + +A few open structural questions worth recording — see the *Type checking* section of +`LaurelDoc.lean` for context. + +- *Rename to `NameTypeResolution`.* This pass resolves names and type-checks expressions in + one walk. The current name only mentions half of what it does. `NameTypeResolution.lean` + (or similar) would advertise both responsibilities. +- *Eliminate `LaurelTypes.computeExprType` by caching types.* Five later passes + (`LaurelToCoreTranslator`, `ModifiesClauses`, `LiftImperativeExpressions`, + `HeapParameterization`, `TypeHierarchy`) re-derive `StmtExpr` types after resolution. + Resolution already synthesizes those types and discards them. Caching per-node types on + `SemanticModel` (or directly on the AST) would let the later passes look them up instead + of recomputing. +- *Shrink or remove `InferHoleTypes`.* `Hole-None-Check` already records expected types + during resolution for holes in check-mode positions. Holes in synth-only positions still + need the post-pass, but as more constructs gain bespoke check rules, fewer holes need + it; eventually the pass can go away. -/ namespace Strata.Laurel diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 300c7393c7..0b39902de0 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -743,6 +743,47 @@ inference-time types for consistency under `~`; a disagreement fires the diagnos *"hole annotated with 'T_resolution' but context expects 'T_inference'"*, surfacing what would otherwise be a silent overwrite. +## Future structural changes + +The current pipeline has resolution and several downstream passes that recompute or +re-derive type information that resolution already synthesized. A few cleanups worth +considering: + +### Rename `Resolution.lean` → `NameTypeResolution.lean` + +The pass resolves names *and* type-checks expressions in one walk; the file name only +advertises the first half. A rename (e.g. `NameTypeResolution.lean` or +`ResolutionAndTyping.lean`) would describe what the pass actually does. The +`SemanticModel` and `ResolvedNode` types could keep their names — they're about resolved +references, not typing. + +### Eliminate `LaurelTypes.computeExprType` by caching types + +`LaurelTypes.lean` exports `computeExprType : SemanticModel → StmtExprMd → HighTypeMd`, +which five later passes call (`LaurelToCoreTranslator`, `ModifiesClauses`, +`LiftImperativeExpressions`, `HeapParameterization`, `TypeHierarchy`) to ask "what's the +type of this expression?" after resolution. Resolution already synthesizes the same types +during its walk, then discards them. Two ways to remove the duplication: + +- *Cache types on the AST.* Add a `HighTypeMd` field to `StmtExpr` (or a parallel + `Std.HashMap Nat HighTypeMd` keyed by node-id, attached to `SemanticModel`), populate it + during resolution, and have later passes read it. `computeExprType` becomes a lookup, + not a re-traversal. +- *Make the cache opt-in.* Same idea, but only enable the type-cache for passes that need + it. Less invasive but partially defeats the point. + +The duplication isn't a correctness issue today (both paths produce consistent results), +just wasted work and a maintenance hazard. + +### Shrink or remove `InferHoleTypes` + +`InferHoleTypes` walks the post-resolution AST a second time to annotate holes. Now that +Hole-None-Check writes the expected type during resolution for holes in check-mode +positions, the post-pass only needs to handle holes in synth-only positions (e.g. call +arguments resolved through `synthStmtExpr` instead of `checkStmtExpr`). As more constructs +gain bespoke check rules, fewer holes will reach `InferHoleTypes`; eventually the pass +can be deleted entirely. + # Translation Pipeline Laurel programs are verified by translating them to Strata Core and then invoking the Core From 6fc8e368d90be69cc942518607ebfd2a79f23352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 18:15:30 -0400 Subject: [PATCH 036/189] fix multi value return destructuring --- Strata/Languages/Laurel/Resolution.lean | 54 ++++++++++++------------- docs/verso/LaurelDoc.lean | 25 +++++++----- 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 152b2bf529..622b35c302 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -599,33 +599,33 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) let (value', valueTy) ← synthStmtExpr value - -- Check that LHS target count matches the RHS arity (derived from the value type). - let expectedOutputCount := match valueTy.val with - | .MultiValuedExpr tys => tys.length - | _ => 1 - if valueTy.val != HighType.TVoid && targets'.length != expectedOutputCount then - let diag := diagnosticFromSource source - s!"Assignment target count mismatch: {targets'.length} targets but right-hand side produces {expectedOutputCount} values" - modify fun s => { s with errors := s.errors.push diag } - -- Type check: for single-target assignments, check value type matches target type - -- Skip when value type is void (RHS is a statement like while/return that doesn't produce a value) - -- Skip when there's an arity mismatch (already reported above) - if targets'.length == 1 && targets'.length == expectedOutputCount && valueTy.val != HighType.TVoid then - if let some target := targets'.head? then - let targetTy := match target.val with - | .Local ref => do - let s ← get - match s.scope.get? ref.text with - | some (_, node) => pure node.getType - | none => pure { val := HighType.Unknown, source := ref.source : HighTypeMd } - | .Declare param => pure param.type - | .Field _ fieldName => do - let s ← get - match s.scope.get? fieldName.text with - | some (_, node) => pure node.getType - | none => pure { val := HighType.Unknown, source := fieldName.source : HighTypeMd } - let tTy ← targetTy - checkSubtype source tTy valueTy + -- Compute the target's declared type, regardless of whether it's a Local, + -- a Field, or a fresh Declare. + let targetType (t : VariableMd) : ResolveM HighTypeMd := do + let s ← get + match t.val with + | .Local ref => + match s.scope.get? ref.text with + | some (_, node) => pure node.getType + | none => pure { val := .Unknown, source := ref.source } + | .Declare param => pure param.type + | .Field _ fieldName => + match s.scope.get? fieldName.text with + | some (_, node) => pure node.getType + | none => pure { val := .Unknown, source := fieldName.source } + -- Skip all checks when the RHS is a statement (TVoid) — no value to assign. + if valueTy.val != HighType.TVoid then + let targetTys ← targets'.mapM targetType + -- Build the expected type from the targets' declared types: a single + -- type when there's one target, a tuple (MultiValuedExpr) otherwise. + -- This matches the shape of `valueTy`, which is itself MultiValuedExpr + -- exactly when the RHS produces multiple values. A single tuple-vs-tuple + -- check then covers both arity and per-position type mismatches in one + -- diagnostic. + let expectedTy : HighTypeMd := match targetTys with + | [single] => single + | _ => { val := .MultiValuedExpr targetTys, source := source } + checkSubtype source expectedTy valueTy pure (.Assign targets' value', valueTy) | .Var (.Field target fieldName) => let (target', _) ← synthStmtExpr target diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 0b39902de0..63fed89e9b 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -260,7 +260,7 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x Block-Synth-Empty, Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, Return-Void-Error, Return-Multi-Error; While - *Verification statements* — Assert, Assume -- *Assignment* — Assign-Single, Assign-Multi +- *Assignment* — Assign - *Calls* — Static-Call, Static-Call-Multi, Instance-Call - *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat - *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate @@ -481,16 +481,23 @@ target is a numeric type. ### Assignment ``` - Γ(x) = T_x Γ ⊢ e ⇒ T_e T_e <: T_x -─────────────────────────────────────────────── (Assign-Single, impl) - Γ ⊢ Assign [x] e ⇒ TVoid -``` + Γ ⊢ targets_i ⇒ T_i Γ ⊢ e ⇒ T_e ExpectedTy <: T_e +───────────────────────────────────────────────────────────────── (Assign, impl) + Γ ⊢ Assign targets e ⇒ TVoid + where ExpectedTy = T_1 if |targets| = 1 + = MultiValuedExpr [T_1; …; T_n] otherwise ``` - Γ ⊢ targets_i = x_i Γ(x_i) = T_i Γ ⊢ e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i -───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl) - Γ ⊢ Assign targets e ⇒ TVoid -``` + +The target's declared type `T_i` comes from the variable's scope entry (for +{name Strata.Laurel.Variable.Local}`Local` and {name Strata.Laurel.Variable.Field}`Field`) +or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. Both +single- and multi-target forms collapse into one tuple-vs-tuple check: when the RHS is a +{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr`, both arity and per-position +type mismatches surface in a single diagnostic of shape *"expected '(int, int, int)', got +'(int, string)'"*. When the RHS is {name Strata.Laurel.HighType.TVoid}`TVoid` (a +side-effecting statement: `while`, `return`, …), all checks are skipped — there's no value +to assign. ### Calls From dbc220a6716371a0458bb05b3e4588293f0b4137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 18:15:49 -0400 Subject: [PATCH 037/189] fix error messages to match current type mismatch reporting --- .../Fundamentals/T22_ArityMismatch.lean | 2 +- .../Laurel/ResolutionTypeCheckTests.lean | 36 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean index 94c0f22371..dea2d510fb 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean @@ -39,7 +39,7 @@ procedure mismatch() { var x: int; assign x := twoReturns() -//^^^^^^^^^^^^^^^^^^^^^^^^ error: Assignment target count mismatch +//^^^^^^^^^^^^^^^^^^^^^^^^ error: expected 'int', got '(int, int)' }; " diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 3a9fa8f174..85318ad7e9 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -39,7 +39,7 @@ private def processResolution (input : Lean.Parser.InputContext) : IO (Array Dia def ifCondNotBool := r" function foo(x: int): int { if x then 1 else 0 -// ^ error: expected bool, but got 'int' +// ^ error: expected 'bool', got 'int' }; " @@ -50,7 +50,7 @@ def assertCondNotBool := r" procedure baz() opaque { var x: int := 42; assert x -// ^ error: expected bool, but got 'int' +// ^ error: expected 'bool', got 'int' }; " @@ -61,7 +61,7 @@ def assumeCondNotBool := r" procedure qux() opaque { var x: int := 42; assume x -// ^ error: expected bool, but got 'int' +// ^ error: expected 'bool', got 'int' }; " @@ -72,7 +72,7 @@ def whileCondNotBool := r" procedure wh() opaque { var x: int := 1; while (x) { } -// ^ error: expected bool, but got 'int' +// ^ error: expected 'bool', got 'int' }; " @@ -84,7 +84,7 @@ procedure wh() opaque { def logicalAndNotBool := r" function foo(x: int, y: bool): bool { x && y -//^^^^^^ error: expected bool, but got 'int' +//^^^^^^ error: expected 'bool', got 'int' }; " @@ -95,8 +95,8 @@ function foo(x: int, y: bool): bool { def comparisonNotNumeric := r" function cmp(x: string, y: int): bool { +// ^^^^^^ error: '<' expected a numeric type, got 'string' x < y -//^^^^^ error: expected a numeric type, but got 'string' }; " @@ -108,7 +108,7 @@ function cmp(x: string, y: int): bool { def assignTypeMismatch := r" procedure foo() opaque { var x: int := true -//^^^^^^^^^^^^^^^^^^ error: expected 'int', but got 'bool' +//^^^^^^^^^^^^^^^^^^ error: expected 'int', got 'bool' }; " @@ -119,7 +119,7 @@ procedure foo() opaque { def returnTypeMismatch := r" function foo(): int { -// ^^^ error: expected 'int', but got 'bool' +// ^^^ error: expected 'int', got 'bool' true }; " @@ -133,7 +133,7 @@ def callArgTypeMismatch := r" function bar(x: int): int { x }; function foo(): int { bar(true) -//^^^^^^^^^ error: expected 'int', but got 'bool' +//^^^^^^^^^ error: expected 'int', got 'bool' }; " @@ -169,30 +169,30 @@ def assignTargetCountMismatch := r" procedure multi() returns (a: int, b: int) opaque; procedure test() opaque { var x: int := multi() -//^^^^^^^^^^^^^^^^^^^^^ error: Assignment target count mismatch:1 targets but right-hand side produces 2 values +//^^^^^^^^^^^^^^^^^^^^^ error: expected 'int', got '(int, int)' }; " #guard_msgs (error, drop all) in #eval testInputWithOffset "AssignTargetCountMismatch" assignTargetCountMismatch 156 processResolution -/-! ## UserDefined type pass-through (known limitation) +/-! ## UserDefined cross-type assignment (now rejected) -UserDefined types skip strict assignability checks because subtype/inheritance -relationships are not tracked during resolution. This test documents that -cross-type assignments are silently accepted today. When hierarchy tracking -lands, this test should be updated to expect a rejection. -/ +Cross-type assignments between unrelated user-defined types are rejected +because `isSubtype` is currently structural equality. Once `isSubtype` walks +`extending` chains, this test will need a related-types example to keep +exercising the success path. -/ -def userDefinedPassThrough := r" +def userDefinedCrossType := r" composite Dog { } composite Cat { } procedure test() opaque { var x: Dog := new Cat +//^^^^^^^^^^^^^^^^^^^^^ error: expected 'Dog', got 'Cat' }; " --- This should produce NO diagnostics (UserDefined types are not checked against each other) #guard_msgs (error, drop all) in -#eval testInputWithOffset "UserDefinedPassThrough" userDefinedPassThrough 170 processResolution +#eval testInputWithOffset "UserDefinedCrossType" userDefinedCrossType 170 processResolution end Laurel From 4cb6de774c65249a57970623b4bc2c1b69859443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 18:17:26 -0400 Subject: [PATCH 038/189] fix error reporting location --- Strata/Languages/Laurel/Resolution.lean | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 622b35c302..677ba564d2 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -645,8 +645,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee - for (argTy, paramTy) in argTypes.zip paramTypes do - checkSubtype source paramTy argTy + for ((a, aTy), paramTy) in (args'.zip argTypes).zip paramTypes do + checkSubtype a.source paramTy aTy pure (.StaticCall callee' args', retTy) | .PrimitiveOp op args => let results ← args.mapM synthStmtExpr @@ -662,12 +662,12 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .StrConcat => HighType.TString match op with | .And | .Or | .AndThen | .OrElse | .Not | .Implies => - for aTy in argTypes do - checkSubtype source { val := .TBool, source := aTy.source } aTy + for (a, aTy) in args'.zip argTypes do + checkSubtype a.source { val := .TBool, source := a.source } aTy | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => - for aTy in argTypes do + for (a, aTy) in args'.zip argTypes do unless isNumeric aTy do - typeMismatch aTy.source (some expr) "expected a numeric type" aTy + typeMismatch a.source (some expr) "expected a numeric type" aTy | .Eq | .Neq => match argTypes with | [lhsTy, rhsTy] => @@ -677,8 +677,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := modify fun s => { s with errors := s.errors.push diag } | _ => pure () | .StrConcat => - for aTy in argTypes do - checkSubtype source { val := .TString, source := aTy.source } aTy + for (a, aTy) in args'.zip argTypes do + checkSubtype a.source { val := .TString, source := a.source } aTy pure (.PrimitiveOp op args', { val := resultTy, source := source }) | .New ref => let ref' ← resolveRef ref source @@ -709,9 +709,9 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let (lhs', lhsTy) ← synthStmtExpr lhs let (rhs', rhsTy) ← synthStmtExpr rhs unless isReference lhsTy do - typeMismatch lhsTy.source (some expr) "expected a reference type" lhsTy + typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy unless isReference rhsTy do - typeMismatch rhsTy.source (some expr) "expected a reference type" rhsTy + typeMismatch rhs'.source (some expr) "expected a reference type" rhsTy pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => let (target', _) ← synthStmtExpr target @@ -731,8 +731,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let (retTy, paramTypes) ← getCallInfo callee -- Skip first param (self) when matching args. let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] - for (argTy, paramTy) in argTypes.zip callParamTypes do - checkSubtype source paramTy argTy + for ((a, aTy), paramTy) in (args'.zip argTypes).zip callParamTypes do + checkSubtype a.source paramTy aTy pure (.InstanceCall target' callee' args', retTy) | .Quantifier mode param trigger body => withScope do @@ -751,7 +751,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .Fresh val => let (val', valTy) ← synthStmtExpr val unless isReference valTy do - typeMismatch valTy.source (some expr) "expected a reference type" valTy + typeMismatch val'.source (some expr) "expected a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } From 12c7a965182ca977949755b990cda45f72d8c706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 18:22:07 -0400 Subject: [PATCH 039/189] fix location error reporting --- StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 85318ad7e9..112fa7eba9 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -84,7 +84,7 @@ procedure wh() opaque { def logicalAndNotBool := r" function foo(x: int, y: bool): bool { x && y -//^^^^^^ error: expected 'bool', got 'int' +//^ error: expected 'bool', got 'int' }; " @@ -95,8 +95,8 @@ function foo(x: int, y: bool): bool { def comparisonNotNumeric := r" function cmp(x: string, y: int): bool { -// ^^^^^^ error: '<' expected a numeric type, got 'string' x < y +//^ error: '<' expected a numeric type, got 'string' }; " @@ -133,7 +133,7 @@ def callArgTypeMismatch := r" function bar(x: int): int { x }; function foo(): int { bar(true) -//^^^^^^^^^ error: expected 'int', got 'bool' +// ^^^^ error: expected 'int', got 'bool' }; " From 9e353acb9be95c1d3a94f42210e21f46dc3ac09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 18 May 2026 11:30:16 -0400 Subject: [PATCH 040/189] fix field lookup --- Strata/Languages/Laurel/Resolution.lean | 35 +++++++++++++++---------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 677ba564d2..cb9ab36b00 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -272,6 +272,12 @@ structure ResolveState where nextId : Nat := 1 /-- Current lexical scope (name → definition ID). -/ scope : Scope := {} + /-- Map from definition uniqueId to its ResolvedNode. Populated alongside + `scope` whenever a definition is registered. Unlike `scope`, this map is + *not* saved/restored by `withScope` — uniqueIds are global. Used by + `getVarType` to look up types for references whose `text` doesn't match + a scope key (notably fields, which are scoped under qualified keys). -/ + idToNode : Std.HashMap Nat ResolvedNode := {} /-- Names defined at the current scope level (for duplicate detection). -/ currentScopeNames : Std.HashSet String := {} /-- Per-composite-type field scopes (type name → field name → scope entry). -/ @@ -315,8 +321,10 @@ def defineNameCheckDup (iden : Identifier) (node : ResolvedNode) (overrideResolu let id ← freshId pure ({ iden with uniqueId := some (id) }, id) - modify fun s => { s with scope := s.scope.insert resolutionName (uniqueId, node), - currentScopeNames := s.currentScopeNames.insert resolutionName } + modify fun s => { s with + scope := s.scope.insert resolutionName (uniqueId, node), + idToNode := s.idToNode.insert uniqueId node, + currentScopeNames := s.currentScopeNames.insert resolutionName } return name' /-- Resolve a reference: look up the name in scope and assign the definition's ID. @@ -476,12 +484,18 @@ private def isReference (ty : HighTypeMd) : Bool := | .TCore _ => true | _ => false -/-- Get the type of a resolved variable reference from scope. -/ +/-- Get the type of a resolved reference. Tries the lexical scope by name + first; if that misses (notably for fields, which are scoped under + qualified keys like "Container.intValue"), falls back to a uniqueId + lookup populated as definitions are registered. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get match s.scope.get? ref.text with | some (_, node) => pure node.getType - | none => pure { val := .Unknown, source := ref.source } + | none => + match ref.uniqueId.bind s.idToNode.get? with + | some node => pure node.getType + | none => pure { val := .Unknown, source := ref.source } /-- Get the call return type and parameter types for a callee from scope. -/ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List HighTypeMd) := do @@ -602,17 +616,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := -- Compute the target's declared type, regardless of whether it's a Local, -- a Field, or a fresh Declare. let targetType (t : VariableMd) : ResolveM HighTypeMd := do - let s ← get match t.val with - | .Local ref => - match s.scope.get? ref.text with - | some (_, node) => pure node.getType - | none => pure { val := .Unknown, source := ref.source } + | .Local ref => getVarType ref | .Declare param => pure param.type - | .Field _ fieldName => - match s.scope.get? fieldName.text with - | some (_, node) => pure node.getType - | none => pure { val := .Unknown, source := fieldName.source } + | .Field _ fieldName => getVarType fieldName -- Skip all checks when the RHS is a statement (TVoid) — no value to assign. if valueTy.val != HighType.TVoid then let targetTys ← targets'.mapM targetType @@ -630,7 +637,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .Var (.Field target fieldName) => let (target', _) ← synthStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source - let ty ← getVarType fieldName + let ty ← getVarType fieldName' pure (.Var (.Field target' fieldName'), ty) | .PureFieldUpdate target fieldName newVal => let (target', targetTy) ← synthStmtExpr target From 228559e8693597bd0bdf72f23701055e401a369b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 18 May 2026 11:50:19 -0400 Subject: [PATCH 041/189] fix silent fail --- .../Languages/Laurel/Examples/Objects/T5_inheritance.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean b/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean index ba406b0ddc..4db9a56da2 100644 --- a/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean +++ b/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean @@ -98,5 +98,5 @@ procedure diamondInheritance() //} " -#guard_msgs (drop info) in +#guard_msgs in #eval testInputWithOffset "Inheritance" program 14 processLaurelFile From 3b3e598201c3724dfec5f066aca11cec27e133c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 18 May 2026 16:47:06 -0400 Subject: [PATCH 042/189] thread typing context through type resolution/inheritance --- Strata/Languages/Laurel/InferHoleTypes.lean | 10 ++- Strata/Languages/Laurel/Laurel.lean | 85 ++++++++++++++++++--- Strata/Languages/Laurel/Resolution.lean | 35 ++++++--- 3 files changed, 106 insertions(+), 24 deletions(-) diff --git a/Strata/Languages/Laurel/InferHoleTypes.lean b/Strata/Languages/Laurel/InferHoleTypes.lean index 026a82e5b9..248d90716d 100644 --- a/Strata/Languages/Laurel/InferHoleTypes.lean +++ b/Strata/Languages/Laurel/InferHoleTypes.lean @@ -51,6 +51,8 @@ inductive InferHoleTypesStats where structure InferHoleState where model : SemanticModel + /-- Type-relation tables used by the consistency check on pre-annotated holes. -/ + typeContext : TypeContext currentOutputType : HighTypeMd statistics : Statistics := {} diagnostics : List DiagnosticModel := [] @@ -100,7 +102,8 @@ private def inferExpr (expr : StmtExprMd) (expectedType : HighTypeMd) : InferHol -- types disagree under consistency (gradual ~). match existingTy with | some prior => - unless isConsistent prior expectedType do + let ctx := (← get).typeContext + unless isConsistent ctx prior expectedType do modify fun s => { s with diagnostics := s.diagnostics ++ [diagnosticFromSource source s!"hole annotated with '{formatHighTypeVal prior.val}' but context expects '{formatHighTypeVal expectedType.val}'"] @@ -183,7 +186,10 @@ private def inferProcedure (proc : Procedure) : InferHoleM Procedure := do Annotate every `.Hole` in the program with a type inferred from context. -/ def inferHoleTypes (model : SemanticModel) (program : Program) : Program × List DiagnosticModel × Statistics := - let initState : InferHoleState := { model := model, currentOutputType := { val := .Unknown, source := none }} + let initState : InferHoleState := { + model := model, + typeContext := TypeContext.ofTypes program.types, + currentOutputType := { val := .Unknown, source := none } } let (procs, finalState) := (program.staticProcedures.mapM inferProcedure).run initState ({ program with staticProcedures := procs }, finalState.diagnostics, finalState.statistics) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index 4fc77650cc..b15ef2040f 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -488,24 +488,76 @@ instance : BEq HighTypeMd where deriving instance BEq for HighType -/-- Subtyping. Stub: structural equality via `highEq`. - TODO: walk `extending` chains for composites, unfold aliases, unwrap - constrained types to their base. -/ -def isSubtype (sub sup : HighTypeMd) : Bool := highEq sub sup +/-- Lookup tables threaded through subtyping/consistency checks. Built from + the program's `TypeDefinition`s by the resolution pass: + - `unfoldMap` maps an alias or constrained type's name to the type it + unwraps to (alias target / constrained base). Followed transitively to + reach a non-alias, non-constrained type. + - `extendingMap` maps a composite type's name to the *direct* parents in + its `extending` list. Walked transitively for the subtype check. -/ +structure TypeContext where + unfoldMap : Std.HashMap String HighTypeMd := {} + extendingMap : Std.HashMap String (List String) := {} + deriving Inhabited + +/-- Unfold aliases and constrained types to their underlying type. + Composites and primitives are returned unchanged. A `visited` set guards + against cycles in the alias/constrained graph (already cycle-checked + elsewhere, but keeps `unfold` safe to call independently). -/ +partial def TypeContext.unfold (ctx : TypeContext) (ty : HighTypeMd) + (visited : Std.HashSet String := {}) : HighTypeMd := + match ty.val with + | .UserDefined name => + if visited.contains name.text then ty + else match ctx.unfoldMap.get? name.text with + | some target => ctx.unfold target (visited.insert name.text) + | none => ty + | _ => ty + +/-- All ancestors of a composite type (including itself), reachable via + repeated `extending` lookups. The `fuel` cap is the number of distinct + type names ever registered, bounding the BFS even with malformed input. -/ +partial def TypeContext.ancestors (ctx : TypeContext) (name : String) : Std.HashSet String := + let rec go (acc : Std.HashSet String) (frontier : List String) : Std.HashSet String := + match frontier with + | [] => acc + | n :: rest => + if acc.contains n then go acc rest + else + let acc' := acc.insert n + let parents := (ctx.extendingMap.get? n).getD [] + go acc' (parents ++ rest) + go {} [name] + +/-- Subtyping. Walks `extending` chains for composites, unfolds aliases, and + unwraps constrained types to their base before falling back to structural + equality via `highEq`. -/ +def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := + let sub' := ctx.unfold sub + let sup' := ctx.unfold sup + match sub'.val, sup'.val with + | .UserDefined subName, .UserDefined supName => + -- After unfolding, both sides are composites (or unresolved). A composite + -- is a subtype of any type in its extending chain. + (ctx.ancestors subName.text).contains supName.text || highEq sub' sup' + | _, _ => highEq sub' sup' /-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the dynamic type and is consistent with everything; otherwise structural - equality. `TCore` is a temporary migration escape hatch. -/ -def isConsistent (a b : HighTypeMd) : Bool := - match a.val, b.val with + equality after unfolding aliases / constrained types. `TCore` is a + temporary migration escape hatch. -/ +def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := + let a' := ctx.unfold a + let b' := ctx.unfold b + match a'.val, b'.val with | .Unknown, _ | _, .Unknown => true | .TCore _, _ | _, .TCore _ => true - | _, _ => highEq a b + | _, _ => highEq a' b' /-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For our flat lattice this collapses to `sub ~ sup ∨ sub <: sup`. -/ -def isConsistentSubtype (sub sup : HighTypeMd) : Bool := - isConsistent sub sup || isSubtype sub sup +def isConsistentSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := + isConsistent ctx sub sup || isSubtype ctx sub sup def HighType.isBool : HighType → Bool | TBool => true @@ -644,6 +696,19 @@ def TypeDefinition.name : TypeDefinition → Identifier | .Datatype ty => ty.name | .Alias ty => ty.name +/-- Build a `TypeContext` from a list of `TypeDefinition`s. + Aliases populate `unfoldMap` with their target; constrained types populate + it with their base; composites populate `extendingMap` with their direct + parents. Datatypes contribute nothing — they're nominal and irreducible. -/ +def TypeContext.ofTypes (types : List TypeDefinition) : TypeContext := + types.foldl (init := {}) fun ctx td => + match td with + | .Alias ta => { ctx with unfoldMap := ctx.unfoldMap.insert ta.name.text ta.target } + | .Constrained ct => { ctx with unfoldMap := ctx.unfoldMap.insert ct.name.text ct.base } + | .Composite c => + { ctx with extendingMap := ctx.extendingMap.insert c.name.text (c.extending.map (·.text)) } + | .Datatype _ => ctx + structure Constant where name : Identifier type : HighTypeMd diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index cb9ab36b00..0efbe7060b 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -291,6 +291,10 @@ structure ResolveState where declaration order). `none` means no enclosing procedure. Used by `Return` to type-check the optional return value and to flag arity/shape mismatches. -/ expectedReturnTypes : Option (List HighTypeMd) := none + /-- Type-relation tables (alias/constrained unfolding + composite extending + chains) used by the subtyping/consistency checks. Built once from + `program.types` at the start of `resolve`. -/ + typeContext : TypeContext := {} @[expose] abbrev ResolveM := StateM ResolveState @@ -464,13 +468,16 @@ private def typeMismatch (source : Option FileRange) (construct : Option StmtExp actual type is already in hand (assignment, call args, body vs declared output) — equivalent to `checkStmtExpr e expected` but without re-synthesizing. -/ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do - unless isConsistentSubtype actual expected do + let ctx := (← get).typeContext + unless isConsistentSubtype ctx actual expected do typeMismatch source none s!"expected '{formatType expected}'" actual /-- Test whether a type is in the set of numeric primitives. `Unknown` and - `TCore` are accepted as gradual escape hatches. Used by Op-Cmp / Op-Arith. -/ -private def isNumeric (ty : HighTypeMd) : Bool := - match ty.val with + `TCore` are accepted as gradual escape hatches. Aliases and constrained + types are unfolded first so e.g. `nat` (constrained over `int`) counts as + numeric. Used by Op-Cmp / Op-Arith. -/ +private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := + match (ctx.unfold ty).val with | .TInt | .TReal | .TFloat64 | .Unknown => true | .TCore _ => true | _ => false @@ -478,8 +485,8 @@ private def isNumeric (ty : HighTypeMd) : Bool := /-- Test whether a type is a user-defined reference type. `Unknown` and `TCore` are accepted as gradual escape hatches. Used by Fresh and ReferenceEquals, which only make sense on composite/datatype references. -/ -private def isReference (ty : HighTypeMd) : Bool := - match ty.val with +private def isReference (ctx : TypeContext) (ty : HighTypeMd) : Bool := + match (ctx.unfold ty).val with | .UserDefined _ | .Unknown => true | .TCore _ => true | _ => false @@ -672,13 +679,15 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := for (a, aTy) in args'.zip argTypes do checkSubtype a.source { val := .TBool, source := a.source } aTy | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => + let ctx := (← get).typeContext for (a, aTy) in args'.zip argTypes do - unless isNumeric aTy do + unless isNumeric ctx aTy do typeMismatch a.source (some expr) "expected a numeric type" aTy | .Eq | .Neq => match argTypes with | [lhsTy, rhsTy] => - unless isConsistent lhsTy rhsTy do + let ctx := (← get).typeContext + unless isConsistent ctx lhsTy rhsTy do let diag := diagnosticFromSource source s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" modify fun s => { s with errors := s.errors.push diag } @@ -715,9 +724,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .ReferenceEquals lhs rhs => let (lhs', lhsTy) ← synthStmtExpr lhs let (rhs', rhsTy) ← synthStmtExpr rhs - unless isReference lhsTy do + let ctx := (← get).typeContext + unless isReference ctx lhsTy do typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy - unless isReference rhsTy do + unless isReference ctx rhsTy do typeMismatch rhs'.source (some expr) "expected a reference type" rhsTy pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => @@ -757,7 +767,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := pure (.Old val', valTy) | .Fresh val => let (val', valTy) ← synthStmtExpr val - unless isReference valTy do + unless isReference (← get).typeContext valTy do typeMismatch val'.source (some expr) "expected a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => @@ -1246,7 +1256,8 @@ def resolve (program : Program) (existingModel: Option SemanticModel := none) : return { staticProcedures := staticProcs', staticFields := staticFields', types := types', constants := constants' } let nextId := existingModel.elim 1 (fun m => m.nextId) - let (program', finalState) := phase1.run { nextId := nextId } + let typeContext := TypeContext.ofTypes program.types + let (program', finalState) := phase1.run { nextId := nextId, typeContext } -- Phase 2: build refToDef from the resolved program (all definitions now have UUIDs) let refToDef := buildRefToDef program' { program := program', From 2dffa2a48932e6bafd891b761244ae48b90c11dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 18 May 2026 16:48:29 -0400 Subject: [PATCH 043/189] drop info report of an expected downcast failure ; to fix this, we need to improve the testing facilities for Laurel --- .../Languages/Laurel/Examples/Objects/T5_inheritance.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean b/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean index 4db9a56da2..ba406b0ddc 100644 --- a/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean +++ b/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean @@ -98,5 +98,5 @@ procedure diamondInheritance() //} " -#guard_msgs in +#guard_msgs (drop info) in #eval testInputWithOffset "Inheritance" program 14 processLaurelFile From 6fa3c22675f310cf33b7700a598b3b9ecd03a649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 10:28:05 -0400 Subject: [PATCH 044/189] fix typing doc direction --- docs/verso/LaurelDoc.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 63fed89e9b..1acbd5c02f 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -481,7 +481,7 @@ target is a numeric type. ### Assignment ``` - Γ ⊢ targets_i ⇒ T_i Γ ⊢ e ⇒ T_e ExpectedTy <: T_e + Γ ⊢ targets_i ⇒ T_i Γ ⊢ e ⇒ T_e T_e <: ExpectedTy ───────────────────────────────────────────────────────────────── (Assign, impl) Γ ⊢ Assign targets e ⇒ TVoid From 3440420f952c6804a24886ac6e91b400e3265415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 10:32:50 -0400 Subject: [PATCH 045/189] fix documentation : subtyping is implemented --- docs/verso/LaurelDoc.lean | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 1acbd5c02f..52e2cda2b7 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -203,11 +203,13 @@ internal interface used by other rules. The relation `<:` (used in Sub) is built from three Lean functions: -- `isSubtype` — pure subtyping. The stub is structural equality via - {name Strata.Laurel.highEq}`highEq`. The eventual real version walks the `extending` - chain for {name Strata.Laurel.CompositeType}`CompositeType`, unfolds +- `isSubtype` — pure subtyping. Walks the `extending` chain for + {name Strata.Laurel.CompositeType}`CompositeType` (via + {name Strata.Laurel.TypeContext.ancestors}`TypeContext.ancestors`), unfolds {name Strata.Laurel.TypeAlias}`TypeAlias` to its target, and unwraps - {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base. + {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base (both via + {name Strata.Laurel.TypeContext.unfold}`TypeContext.unfold`), then falls back to + structural equality via {name Strata.Laurel.highEq}`highEq`. - `isConsistent` — the symmetric gradual relation `~` (Siek–Taha): {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type and is consistent with everything; otherwise structural equality. @@ -233,9 +235,8 @@ A previous iteration was synth-only with three *bivariantly-compatible* wildcard {name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was load-bearing: no assignment, call argument, or comparison involving a user type was ever rejected. The bidirectional design retires that carve-out — user-defined types are now a regular -participant in `<:`, and tightening `isSubtype` (to walk inheritance and unwrap -constrained types) gradually buys real checking on user-defined code without changing -callers. +participant in `<:`, with `isSubtype` walking inheritance chains and unwrapping aliases +and constrained types to deliver real checking on user-defined code. Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This includes {name Strata.Laurel.StmtExpr.Return}`Return`, From 8fc56ae0206c18b257ed06f7174fd174a578dbbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 13:14:35 -0400 Subject: [PATCH 046/189] remove special treatment of TCore --- Strata/Languages/Laurel/Laurel.lean | 5 ++--- Strata/Languages/Laurel/Resolution.lean | 16 +++++++--------- docs/verso/LaurelDoc.lean | 15 +++++---------- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index b15ef2040f..ff67dafe1b 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -467,6 +467,7 @@ def highEq (a : HighTypeMd) (b : HighTypeMd) : Bool := match _a: a.val, _b: b.va | HighType.TSet t1, HighType.TSet t2 => highEq t1 t2 | HighType.TMap k1 v1, HighType.TMap k2 v2 => highEq k1 k2 && highEq v1 v2 | HighType.UserDefined r1, HighType.UserDefined r2 => r1.text == r2.text + | HighType.TCore s1, HighType.TCore s2 => s1 == s2 | HighType.Applied b1 args1, HighType.Applied b2 args2 => highEq b1 b2 && args1.length == args2.length && (args1.attach.zip args2 |>.all (fun (a1, a2) => highEq a1.1 a2)) | HighType.Pure b1, HighType.Pure b2 => highEq b1 b2 @@ -544,14 +545,12 @@ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := /-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the dynamic type and is consistent with everything; otherwise structural - equality after unfolding aliases / constrained types. `TCore` is a - temporary migration escape hatch. -/ + equality after unfolding aliases / constrained types. -/ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := let a' := ctx.unfold a let b' := ctx.unfold b match a'.val, b'.val with | .Unknown, _ | _, .Unknown => true - | .TCore _, _ | _, .TCore _ => true | _, _ => highEq a' b' /-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For our flat lattice diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 0efbe7060b..81d96adca6 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -472,23 +472,21 @@ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (ac unless isConsistentSubtype ctx actual expected do typeMismatch source none s!"expected '{formatType expected}'" actual -/-- Test whether a type is in the set of numeric primitives. `Unknown` and - `TCore` are accepted as gradual escape hatches. Aliases and constrained - types are unfolded first so e.g. `nat` (constrained over `int`) counts as - numeric. Used by Op-Cmp / Op-Arith. -/ +/-- Test whether a type is in the set of numeric primitives. `Unknown` is + accepted as a gradual escape hatch. Aliases and constrained types are + unfolded first so e.g. `nat` (constrained over `int`) counts as numeric. + Used by Op-Cmp / Op-Arith. -/ private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := match (ctx.unfold ty).val with | .TInt | .TReal | .TFloat64 | .Unknown => true - | .TCore _ => true | _ => false -/-- Test whether a type is a user-defined reference type. `Unknown` and `TCore` - are accepted as gradual escape hatches. Used by Fresh and ReferenceEquals, - which only make sense on composite/datatype references. -/ +/-- Test whether a type is a user-defined reference type. `Unknown` is accepted + as a gradual escape hatch. Used by Fresh and ReferenceEquals, which only + make sense on composite/datatype references. -/ private def isReference (ctx : TypeContext) (ty : HighTypeMd) : Bool := match (ctx.unfold ty).val with | .UserDefined _ | .Unknown => true - | .TCore _ => true | _ => false /-- Get the type of a resolved reference. Tries the lexical scope by name diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 52e2cda2b7..e73ab90f00 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -216,10 +216,6 @@ The relation `<:` (used in Sub) is built from three Lean functions: - `isConsistentSubtype` — defined as `isConsistent ∨ isSubtype`. For our flat lattice this is the standard collapse of `∃R. T ~ R ∧ R <: U`. -{name Strata.Laurel.HighType.TCore}`TCore` is bivariantly consistent for now as a temporary -migration escape hatch from the Core language; the carve-out lives in `isConsistent` and is -intentionally temporary. - Sub (and every bespoke check rule) uses `isConsistentSubtype`. That single choice is what makes the system *gradual*: an expression of type {name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) @@ -228,10 +224,9 @@ flows freely into any typed slot, and any expression flows freely into a slot of fully-known types only. The symmetric `isConsistent` is used directly by Op-Eq, where the operand types must be mutually consistent (no subtype direction is privileged). -A previous iteration was synth-only with three *bivariantly-compatible* wildcards: -{name Strata.Laurel.HighType.Unknown}`Unknown`, -{name Strata.Laurel.HighType.UserDefined}`UserDefined`, and -{name Strata.Laurel.HighType.TCore}`TCore`. The +A previous iteration was synth-only with two *bivariantly-compatible* wildcards: +{name Strata.Laurel.HighType.Unknown}`Unknown` and +{name Strata.Laurel.HighType.UserDefined}`UserDefined`. The {name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was load-bearing: no assignment, call argument, or comparison involving a user type was ever rejected. The bidirectional design retires that carve-out — user-defined types are now a regular @@ -600,8 +595,8 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool ``` -`isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined`, -{name Strata.Laurel.HighType.Unknown}`Unknown`, or {name Strata.Laurel.HighType.TCore}`TCore` +`isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined` +or {name Strata.Laurel.HighType.Unknown}`Unknown` type. Reference equality is meaningless on primitives. Compatibility between `T_l` and `T_r` (e.g. rejecting `Cat === Dog` for unrelated user-defined types) is delegated to future tightening of `<:` — today, two distinct user-defined names already mismatch From d476f6b6f76a58aa48ea933670ecdcf123a1016e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 13:50:03 -0400 Subject: [PATCH 047/189] fix TCore documentation --- .../Languages/Laurel/ResolutionTypeCheckTests.lean | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 112fa7eba9..b78f3b22df 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -176,12 +176,11 @@ procedure test() opaque { #guard_msgs (error, drop all) in #eval testInputWithOffset "AssignTargetCountMismatch" assignTargetCountMismatch 156 processResolution -/-! ## UserDefined cross-type assignment (now rejected) +/-! ## UserDefined cross-type assignment -Cross-type assignments between unrelated user-defined types are rejected -because `isSubtype` is currently structural equality. Once `isSubtype` walks -`extending` chains, this test will need a related-types example to keep -exercising the success path. -/ +Assignments between unrelated composites are rejected: `isSubtype` walks +`extending` chains, so two composites with no common ancestor are not +subtypes of each other. -/ def userDefinedCrossType := r" composite Dog { } From 40236cecd644312a27d28b1df5403e26788376f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 13:50:31 -0400 Subject: [PATCH 048/189] uniform <=/=> and use latex rule presentation --- docs/verso/LaurelDoc.lean | 390 ++++++++++---------------------------- 1 file changed, 95 insertions(+), 295 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index e73ab90f00..140a250407 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -169,11 +169,7 @@ expression has a given expected type. Each construct picks a mode based on wheth is determined locally (synth) or by context (check). The two judgments are connected by a single change-of-direction rule, *subsumption*: -``` -Γ ⊢ e ⇒ A A <: B -───────────────────── (Sub) - Γ ⊢ e ⇐ B -``` +$$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` Subsumption is the *only* place the checker switches from check to synth mode. It fires as the default fallback in @@ -186,9 +182,10 @@ propagate through nested control flow. `synthStmtExpr` and `checkStmtExpr` are mutually recursive: synth rules invoke check on subexpressions whose expected type is known (e.g. `cond ⇐ TBool` in {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse`), and `checkStmtExpr` falls back to -`synthStmtExpr` via Sub. Termination uses a lexicographic measure `(exprMd, tag)` where the -tag is `0` for synth and `1` for check; any descent into a strict subterm decreases via -`Prod.Lex.left`, while Sub calls synth on the *same* expression and decreases via +`synthStmtExpr` via \[⇐\] Sub. Termination uses a lexicographic measure `(exprMd, tag)` +where the tag is `0` for synth and `1` for check; any descent into a strict subterm +decreases via `Prod.Lex.left`, while \[⇐\] Sub calls synth on the *same* expression and +decreases via `Prod.Lex.right`. This is the standard well-founded encoding for bidirectional systems. There is also a thin `resolveStmtExpr` wrapper that calls `synthStmtExpr` and discards the @@ -201,7 +198,7 @@ internal interface used by other rules. ### Gradual typing -The relation `<:` (used in Sub) is built from three Lean functions: +The relation `<:` (used in \[⇐\] Sub) is built from three Lean functions: - `isSubtype` — pure subtyping. Walks the `extending` chain for {name Strata.Laurel.CompositeType}`CompositeType` (via @@ -216,13 +213,13 @@ The relation `<:` (used in Sub) is built from three Lean functions: - `isConsistentSubtype` — defined as `isConsistent ∨ isSubtype`. For our flat lattice this is the standard collapse of `∃R. T ~ R ∧ R <: U`. -Sub (and every bespoke check rule) uses `isConsistentSubtype`. That single choice is what +\[⇐\] Sub (and every bespoke check rule) uses `isConsistentSubtype`. That single choice is what makes the system *gradual*: an expression of type {name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) flows freely into any typed slot, and any expression flows freely into a slot of type {name Strata.Laurel.HighType.Unknown}`Unknown`. Strict checking is applied between -fully-known types only. The symmetric `isConsistent` is used directly by Op-Eq, where the -operand types must be mutually consistent (no subtype direction is privileged). +fully-known types only. The symmetric `isConsistent` is used directly by \[⇒\] Op-Eq, where +the operand types must be mutually consistent (no subtype direction is privileged). A previous iteration was synth-only with two *bivariantly-compatible* wildcards: {name Strata.Laurel.HighType.Unknown}`Unknown` and @@ -245,130 +242,90 @@ includes {name Strata.Laurel.StmtExpr.Return}`Return`, Each construct is given as a derivation. `Γ` is the current lexical scope (see {name Strata.Laurel.ResolveState}`ResolveState`'s `scope`); it threads identically through every premise and conclusion unless a rule explicitly extends it (written `Γ, x : T`). -`(impl)` = implemented; `(planned)` = intended, not yet wired in. + +Each rule is tagged with `[⇒]` (synthesis) or `[⇐]` (checking) to make the +direction explicit. When a construct has both modes, the `-Synth` / `-Check` +suffix is dropped in favor of the prefix. ### Index -- *Subsumption* — Sub -- *Literals* — Lit-Int, Lit-Bool, Lit-String, Lit-Decimal -- *Variables* — Var-Local, Var-Field, Var-Declare -- *Control flow* — If-NoElse, If-Synth, If-Check, If-Check-NoElse; Block-Synth, - Block-Synth-Empty, Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, - Return-Void-Error, Return-Multi-Error; While -- *Verification statements* — Assert, Assume -- *Assignment* — Assign -- *Calls* — Static-Call, Static-Call-Multi, Instance-Call -- *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat -- *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate -- *Verification expressions* — Quantifier, Assigned, Old, Fresh, ProveBy -- *Self reference* — This-Inside, This-Outside -- *Untyped forms* — Abstract / All -- *ContractOf* — ContractOf-Bool, ContractOf-Set, ContractOf-Error -- *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check +- *Subsumption* — \[⇐\] Sub +- *Literals* — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal +- *Variables* — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇒\] Var-Declare +- *Control flow* — \[⇒\] If-NoElse, \[⇒\] If, \[⇐\] If, \[⇐\] If-NoElse; + \[⇒\] Block, \[⇒\] Block-Empty, \[⇐\] Block, \[⇐\] Block-Empty; \[⇒\] Exit; + \[⇒\] Return-None, \[⇒\] Return-Some, \[⇒\] Return-Void-Error, + \[⇒\] Return-Multi-Error; \[⇒\] While +- *Verification statements* — \[⇒\] Assert, \[⇒\] Assume +- *Assignment* — \[⇒\] Assign +- *Calls* — \[⇒\] Static-Call, \[⇒\] Static-Call-Multi, \[⇒\] Instance-Call +- *Primitive operations* — \[⇒\] Op-Bool, \[⇒\] Op-Cmp, \[⇒\] Op-Eq, \[⇒\] Op-Arith, + \[⇒\] Op-Concat +- *Object forms* — \[⇒\] New-Ok, \[⇒\] New-Fallback; \[⇒\] AsType; \[⇒\] IsType; + \[⇒\] RefEq; \[⇒\] PureFieldUpdate +- *Verification expressions* — \[⇒\] Quantifier, \[⇒\] Assigned, \[⇒\] Old, + \[⇒\] Fresh, \[⇒\] ProveBy +- *Self reference* — \[⇒\] This-Inside, \[⇒\] This-Outside +- *Untyped forms* — \[⇒\] Abstract / All +- *ContractOf* — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error +- *Holes* — \[⇒\] Hole-Some, \[⇒\] Hole-None, \[⇐\] Hole-None ### Subsumption -``` -Γ ⊢ e ⇒ A A <: B -───────────────────── (Sub, impl) - Γ ⊢ e ⇐ B -``` +$$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` Fallback in `checkStmtExpr` whenever no bespoke check rule applies. ### Literals -``` -────────────────────────── (Lit-Int, impl) - Γ ⊢ LiteralInt n ⇒ TInt -``` +$$`\frac{}{\Gamma \vdash \mathsf{LiteralInt}\;n \Rightarrow \mathsf{TInt}} \quad \text{([⇒] Lit-Int)}` -``` -─────────────────────────── (Lit-Bool, impl) - Γ ⊢ LiteralBool b ⇒ TBool -``` +$$`\frac{}{\Gamma \vdash \mathsf{LiteralBool}\;b \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Lit-Bool)}` -``` -───────────────────────────────── (Lit-String, impl) - Γ ⊢ LiteralString s ⇒ TString -``` +$$`\frac{}{\Gamma \vdash \mathsf{LiteralString}\;s \Rightarrow \mathsf{TString}} \quad \text{([⇒] Lit-String)}` -``` -────────────────────────────────── (Lit-Decimal, impl) - Γ ⊢ LiteralDecimal d ⇒ TReal -``` +$$`\frac{}{\Gamma \vdash \mathsf{LiteralDecimal}\;d \Rightarrow \mathsf{TReal}} \quad \text{([⇒] Lit-Decimal)}` ### Variables -``` - Γ(x) = T -─────────────────────────── (Var-Local, impl) - Γ ⊢ Var (.Local x) ⇒ T -``` +$$`\frac{\Gamma(x) = T}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Local}\;x) \Rightarrow T} \quad \text{([⇒] Var-Local)}` -``` - Γ ⊢ e ⇒ _ Γ(f) = T_f -────────────────────────────── (Var-Field, impl) - Γ ⊢ Var (.Field e f) ⇒ T_f -``` +$$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Field}\;e\;f) \Rightarrow T_f} \quad \text{([⇒] Var-Field)}` Resolution looks `f` up against the type of `e` (or the enclosing instance type for `self.f`); the typing rule itself is path-agnostic. -``` - x ∉ dom(Γ) -───────────────────────────────────────── (Var-Declare, impl) - Γ ⊢ Var (.Declare ⟨x, T⟩) ⇒ TVoid ⊣ Γ, x : T -``` +$$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T\rangle) \Rightarrow \mathsf{TVoid} \dashv \Gamma, x : T} \quad \text{([⇒] Var-Declare)}` `⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the remainder of the enclosing scope. ### Control flow -``` -Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T -───────────────────────────────────────────── (If-NoElse, impl) - Γ ⊢ IfThenElse cond thenBr none ⇒ TVoid -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-NoElse)}` The construct synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value when `cond` is false; without this, `x : int := if c then 5` would type-check spuriously. -``` -Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e -────────────────────────────────────────────────────────────── (If-Synth, impl) - Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T_t -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t} \quad \text{([⇒] If)}` Picks the then-branch type arbitrarily; the two branches are *not* compared, since a statement-position `if` often pairs a value branch with a `return`/`exit`/`assert`. The -enclosing context's check (Sub, or a containing `checkSubtype` like an assignment) provides -the actual check downstream. - -``` -Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T -────────────────────────────────────────────────────────── (If-Check, impl) - Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T +enclosing context's check (\[⇐\] Sub, or a containing `checkSubtype` like an assignment) +provides the actual check downstream. +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \Gamma \vdash \mathit{elseBr} \Leftarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Leftarrow T} \quad \text{([⇐] If)}` -Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T TVoid <: T -───────────────────────────────────────────────────── (If-Check-NoElse, impl) - Γ ⊢ IfThenElse cond thenBr none ⇐ T -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Leftarrow T} \quad \text{([⇐] If-NoElse)}` -Check mode pushes `T` into both branches (rather than going through If-Synth + Sub at the -boundary). Errors fire at the offending branch instead of the surrounding `if`. Without an -else branch, the construct can only succeed when `T` admits -{name Strata.Laurel.HighType.TVoid}`TVoid` — the same subsumption check `Block-Check-Empty` +Check mode pushes `T` into both branches (rather than going through \[⇒\] If + \[⇐\] Sub at +the boundary). Errors fire at the offending branch instead of the surrounding `if`. +Without an else branch, the construct can only succeed when `T` admits +{name Strata.Laurel.HighType.TVoid}`TVoid` — the same subsumption check `\[⇐\] Block-Empty` performs for an empty block. -``` -Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇒ T -─────────────────────────────────────────────────────────────────────────── (Block-Synth, impl) - Γ ⊢ Block [s_1; …; s_n] label ⇒ T -``` +$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block)}` `Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i` says each statement is resolved in the scope produced by its predecessor and may itself extend it (`Var (.Declare …)` does); `s_n` is typed in @@ -379,16 +336,9 @@ Non-last statements are synthesized but their types discarded (the lax rule). Th Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` is silently accepted; flagging it belongs to a lint. -``` -───────────────────────────── (Block-Synth-Empty, impl) - Γ ⊢ Block [] label ⇒ TVoid -``` +$$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Block-Empty)}` -``` -Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇐ T -─────────────────────────────────────────────────────────────────────────── (Block-Check, impl) - Γ ⊢ Block [s_1; …; s_n] label ⇐ T -``` +$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` Pushes `T` into the *last* statement rather than comparing the block's synthesized type at the boundary. Errors fire at the offending subexpression, and `T` keeps propagating through @@ -397,16 +347,9 @@ nested {name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.Hole}`Hole` / {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. -``` - TVoid <: T -───────────────────────── (Block-Check-Empty, impl) - Γ ⊢ Block [] label ⇐ T -``` +$$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Empty)}` -``` -──────────────────────── (Exit, impl) - Γ ⊢ Exit target ⇒ TVoid -``` +$$`\frac{}{\Gamma \vdash \mathsf{Exit}\;\mathit{target} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Exit)}` `Return` matches the optional return value against the enclosing procedure's declared outputs. The expected output types are threaded through @@ -416,74 +359,42 @@ outputs. The expected output types are threaded through the body. `none` means "no enclosing procedure" — e.g. resolving a constant initializer — and skips all `Return` checks. -``` -───────────────────────────── (Return-None, impl) - Γ ⊢ Return none ⇒ TVoid -``` +$$`\frac{}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-None)}` A bare `return;` is allowed in any context. In a single-output procedure it acts as a Dafny-style early exit — the output parameter retains whatever was last assigned to it. -``` - Γ_proc.outputs = [T] Γ ⊢ e ⇐ T -────────────────────────────────────── (Return-Some, impl) - Γ ⊢ Return (some e) ⇒ TVoid -``` +$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T] \quad \Gamma \vdash e \Leftarrow T}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-Some)}` In a single-output procedure, the value is checked against the declared output type. This closes the prior soundness gap where `return 0` in a `bool`-returning procedure went uncaught. -``` - Γ_proc.outputs = [] -───────────────────────────────── (Return-Void-Error, impl) - Γ ⊢ Return (some e) — error: "void procedure cannot return a value" - +$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = []}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “void procedure cannot return a value”}} \quad \text{([⇒] Return-Void-Error)}` - Γ_proc.outputs = [T_1; …; T_n] (n ≥ 2) -────────────────────────────────────────────────────────── (Return-Multi-Error, impl) - Γ ⊢ Return (some e) — error: "multi-output procedure cannot - use 'return e'; assign to named outputs instead" -``` +$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇒] Return-Multi-Error)}` Multi-output procedures use named-output assignment (`r := …` on the declared output parameters). `return e` syntactically takes a single {name Strata.Laurel.StmtExpr.Return}`Option StmtExpr`, so it cannot carry multiple values; flagging it points users at the named-output convention. -``` - Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ -─────────────────────────────────────────────────────────────────────────────── (While, impl) - Γ ⊢ While cond invs dec body ⇒ TVoid -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{dec} \Leftarrow {?} \quad \Gamma \vdash \mathit{body} \Rightarrow \_}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{dec}\;\mathit{body} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] While)}` `dec` (the optional decreases clause) is resolved without a type check today; the intended target is a numeric type. ### Verification statements -``` - Γ ⊢ cond ⇐ TBool -────────────────────────────── (Assert, impl) - Γ ⊢ Assert cond ⇒ TVoid -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assert)}` -``` - Γ ⊢ cond ⇐ TBool -───────────────────────────── (Assume, impl) - Γ ⊢ Assume cond ⇒ TVoid -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assume}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assume)}` ### Assignment -``` - Γ ⊢ targets_i ⇒ T_i Γ ⊢ e ⇒ T_e T_e <: ExpectedTy -───────────────────────────────────────────────────────────────── (Assign, impl) - Γ ⊢ Assign targets e ⇒ TVoid +$$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Rightarrow T_e \quad T_e <: \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assign)}` - where ExpectedTy = T_1 if |targets| = 1 - = MultiValuedExpr [T_1; …; T_n] otherwise -``` +where `ExpectedTy = T_1` if `|targets| = 1` and `MultiValuedExpr [T_1; …; T_n]` otherwise. The target's declared type `T_i` comes from the variable's scope entry (for {name Strata.Laurel.Variable.Local}`Local` and {name Strata.Laurel.Variable.Field}`Field`) @@ -497,26 +408,11 @@ to assign. ### Calls -``` - Γ(callee) = static-procedure with inputs Ts and outputs [T] - Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) -───────────────────────────────────────────────────────────── (Static-Call, impl) - Γ ⊢ StaticCall callee args ⇒ T -``` +$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Static-Call)}` -``` - Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 - Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) -────────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl) - Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] -``` +$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` -``` - Γ ⊢ target ⇒ _ Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T] - Γ ⊢ args ⇒ Us U_i <: T_i (pairwise; self is dropped) -───────────────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl) - Γ ⊢ InstanceCall target callee args ⇒ T -``` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Instance-Call)}` ### Primitive operations @@ -524,76 +420,36 @@ to assign. {name Strata.Laurel.HighType.TReal}`TReal`, {name Strata.Laurel.HighType.TFloat64}`TFloat64`". -``` - Γ ⊢ args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} -────────────────────────────────── (Op-Bool, impl) - Γ ⊢ PrimitiveOp op args ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TBool} \quad \mathit{op} \in \{\mathsf{And}, \mathsf{Or}, \mathsf{AndThen}, \mathsf{OrElse}, \mathsf{Not}, \mathsf{Implies}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Bool)}` -``` - Γ ⊢ args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} -───────────────────────────────── (Op-Cmp, impl) - Γ ⊢ PrimitiveOp op args ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \mathit{op} \in \{\mathsf{Lt}, \mathsf{Leq}, \mathsf{Gt}, \mathsf{Geq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Cmp)}` -``` - Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l ~ T_r op ∈ {Eq, Neq} -───────────────────────────────────────────────────────── (Op-Eq, impl) - Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad T_l \sim T_r \quad \mathit{op} \in \{\mathsf{Eq}, \mathsf{Neq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;[\mathit{lhs}; \mathit{rhs}] \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Eq)}` `~` is the consistency relation `isConsistent` — symmetric, with the {name Strata.Laurel.HighType.Unknown}`Unknown` wildcard. -``` - Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} -────────────────────────────────────────────────── (Op-Arith, impl) - Γ ⊢ PrimitiveOp op args ⇒ T -``` +$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \Gamma \vdash \mathit{args}.\mathsf{head} \Rightarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` "Result is the type of the first argument" handles `int + int → int`, `real + real → real`, etc. without unification. Known relaxation: `int + real` passes (each operand individually passes `Numeric`); a proper fix needs numeric promotion or unification. -``` - Γ ⊢ args_i ⇐ TString op = StrConcat -───────────────────────────────────── (Op-Concat, impl) - Γ ⊢ PrimitiveOp op args ⇒ TString -``` +$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` ### Object forms -``` - Γ(ref) is a composite or datatype T -────────────────────────────────────────── (New-Ok, impl) - Γ ⊢ New ref ⇒ UserDefined T -``` +$$`\frac{\Gamma(\mathit{ref}) \text{ is a composite or datatype } T}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] New-Ok)}` -``` - Γ(ref) is not a composite or datatype -───────────────────────────────────────── (New-Fallback, impl) - Γ ⊢ New ref ⇒ Unknown -``` +$$`\frac{\Gamma(\mathit{ref}) \text{ is not a composite or datatype}}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] New-Fallback)}` -``` - Γ ⊢ target ⇒ _ -───────────────────────────── (AsType, impl) - Γ ⊢ AsType target T ⇒ T -``` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{AsType}\;\mathit{target}\;T \Rightarrow T} \quad \text{([⇒] AsType)}` `target` is resolved but not checked against `T` — the cast is the user's claim. -``` - Γ ⊢ target ⇒ _ -───────────────────────────────── (IsType, impl) - Γ ⊢ IsType target T ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` -``` - Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r isReference T_l isReference T_r -───────────────────────────────────────────────────────────────────────────── (RefEq, impl) - Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad \mathsf{isReference}\;T_l \quad \mathsf{isReference}\;T_r}{\Gamma \vdash \mathsf{ReferenceEquals}\;\mathit{lhs}\;\mathit{rhs} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] RefEq)}` `isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined` or {name Strata.Laurel.HighType.Unknown}`Unknown` @@ -602,67 +458,36 @@ type. Reference equality is meaningless on primitives. Compatibility between `T_ future tightening of `<:` — today, two distinct user-defined names already mismatch structurally, so the check would only fire under stronger subtyping. -``` - Γ ⊢ target ⇒ T_t Γ(f) = T_f Γ ⊢ newVal ⇐ T_f -───────────────────────────────────────────────────────────── (PureFieldUpdate, impl) - Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t -``` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow T_t \quad \Gamma(f) = T_f \quad \Gamma \vdash \mathit{newVal} \Leftarrow T_f}{\Gamma \vdash \mathsf{PureFieldUpdate}\;\mathit{target}\;f\;\mathit{newVal} \Rightarrow T_t} \quad \text{([⇒] PureFieldUpdate)}` `f` is resolved against `T_t` (or the enclosing instance type) and `newVal` is checked against the field's declared type. ### Verification expressions -``` - Γ, x : T ⊢ body ⇐ TBool -───────────────────────────────────────────────── (Quantifier, impl) - Γ ⊢ Quantifier mode ⟨x, T⟩ trig body ⇒ TBool -``` +$$`\frac{\Gamma, x : T \vdash \mathit{body} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Quantifier}\;\mathit{mode}\;\langle x, T\rangle\;\mathit{trig}\;\mathit{body} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Quantifier)}` The bound variable `x : T` is introduced in scope only for the body (and trigger). The body is checked against {name Strata.Laurel.HighType.TBool}`TBool` since a quantifier is a proposition; without this, `forall x: int :: x + 1` would be silently accepted. -``` - Γ ⊢ name ⇒ _ -───────────────────────────── (Assigned, impl) - Γ ⊢ Assigned name ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{name} \Rightarrow \_}{\Gamma \vdash \mathsf{Assigned}\;\mathit{name} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Assigned)}` -``` - Γ ⊢ v ⇒ T -───────────────── (Old, impl) - Γ ⊢ Old v ⇒ T -``` +$$`\frac{\Gamma \vdash v \Rightarrow T}{\Gamma \vdash \mathsf{Old}\;v \Rightarrow T} \quad \text{([⇒] Old)}` -``` - Γ ⊢ v ⇒ T isReference T -───────────────────────────────── (Fresh, impl) - Γ ⊢ Fresh v ⇒ TBool -``` +$$`\frac{\Gamma \vdash v \Rightarrow T \quad \mathsf{isReference}\;T}{\Gamma \vdash \mathsf{Fresh}\;v \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Fresh)}` `isReference T` is the same predicate as in {name Strata.Laurel.StmtExpr.ReferenceEquals}`ReferenceEquals`. {name Strata.Laurel.StmtExpr.Fresh}`Fresh` only makes sense on heap-allocated references; `fresh(5)` is rejected. -``` - Γ ⊢ v ⇒ T Γ ⊢ proof ⇒ _ -─────────────────────────────────── (ProveBy, impl) - Γ ⊢ ProveBy v proof ⇒ T -``` +$$`\frac{\Gamma \vdash v \Rightarrow T \quad \Gamma \vdash \mathit{proof} \Rightarrow \_}{\Gamma \vdash \mathsf{ProveBy}\;v\;\mathit{proof} \Rightarrow T} \quad \text{([⇒] ProveBy)}` ### Self reference -``` - Γ.instanceTypeName = some T -────────────────────────────────── (This-Inside, impl) - Γ ⊢ This ⇒ UserDefined T - +$$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{some}\;T}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] This-Inside)}` - Γ.instanceTypeName = none -────────────────────────────── (This-Outside, impl) - Γ ⊢ This ⇒ Unknown [emits "'this' is not allowed outside instance methods"] -``` +$$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{none}}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{Unknown}\;\;[\text{emits “‘this’ is not allowed outside instance methods”}]} \quad \text{([⇒] This-Outside)}` `Γ.instanceTypeName` is the {name Strata.Laurel.ResolveState}`ResolveState` field set by @@ -672,10 +497,7 @@ types instead of being wildcarded through {name Strata.Laurel.HighType.Unknown}` ### Untyped forms -``` -───────────────────────────────── (Abstract / All, impl) - Γ ⊢ Abstract / All … ⇒ Unknown -``` +$$`\frac{}{\Gamma \vdash \mathsf{Abstract}\;/\;\mathsf{All}\;\ldots \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Abstract / All)}` ### ContractOf @@ -684,18 +506,9 @@ types instead of being wildcarded through {name Strata.Laurel.HighType.Unknown}` (`Modifies`). `fn` must be a direct identifier reference to a procedure — a contract belongs to a *named* procedure, not an arbitrary expression. -``` - fn = Var (.Local id) Γ(id) ∈ {staticProcedure, instanceProcedure} -───────────────────────────────────────────────────────────────────────── (ContractOf-Bool, impl) - Γ ⊢ ContractOf Precondition fn ⇒ TBool - Γ ⊢ ContractOf PostCondition fn ⇒ TBool +$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Precondition}\;\mathit{fn} \Rightarrow \mathsf{TBool} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{PostCondition}\;\mathit{fn} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] ContractOf-Bool)}` - - fn = Var (.Local id) Γ(id) ∈ {staticProcedure, instanceProcedure} -───────────────────────────────────────────────────────────────────────── (ContractOf-Set, impl) - Γ ⊢ ContractOf Reads fn ⇒ TSet Unknown - Γ ⊢ ContractOf Modifies fn ⇒ TSet Unknown -``` +$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Reads}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{Modifies}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown}} \quad \text{([⇒] ContractOf-Set)}` `Precondition` and `PostCondition` are propositions, hence {name Strata.Laurel.HighType.TBool}`TBool`. `Reads` and `Modifies` are sets of heap-allocated @@ -703,11 +516,7 @@ locations — composite/datatype references and fields. The element type is left {name Strata.Laurel.HighType.Unknown}`Unknown` for now since the rule doesn't yet recover it from `fn`'s declared modifies/reads clauses. -``` - fn is not a procedure reference -───────────────────────────────────────────── (ContractOf-Error, impl) - Γ ⊢ ContractOf … fn — error: "'contractOf' expected a procedure reference" -``` +$$`\frac{\mathit{fn} \text{ is not a procedure reference}}{\Gamma \vdash \mathsf{ContractOf}\;\ldots\;\mathit{fn} \rightsquigarrow \text{error: “‘contractOf’ expected a procedure reference”}} \quad \text{([⇒] ContractOf-Error)}` When `fn` doesn't resolve to a procedure (e.g. it's an arbitrary expression, or resolves to a constant/variable), the diagnostic fires and the construct synthesizes @@ -719,20 +528,11 @@ exists so resolution remains exhaustive over `StmtExpr`. ### Holes -``` -──────────────────────────── (Hole-Some, impl) - Γ ⊢ Hole d (some T) ⇒ T -``` +$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T) \Rightarrow T} \quad \text{([⇒] Hole-Some)}` -``` -───────────────────────────────── (Hole-None-Synth, impl) - Γ ⊢ Hole d none ⇒ Unknown -``` +$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Hole-None)}` -``` -───────────────────────────────────── (Hole-None-Check, impl) - Γ ⊢ Hole d none ⇐ T ↦ Hole d (some T) -``` +$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \;\;\mapsto\;\; \mathsf{Hole}\;d\;(\mathsf{some}\;T)} \quad \text{([⇐] Hole-None)}` In check mode, an untyped hole records the expected type `T` on the node directly. The subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it @@ -741,7 +541,7 @@ discarding it. A separate `InferHoleTypes` pass still runs after resolution to annotate holes that ended up in synth-only positions. When that pass encounters a hole whose type was already set -(by Hole-None-Check or by a user-written `?: T`), it checks the resolution-time and +(by \[⇐\] Hole-None or by a user-written `?: T`), it checks the resolution-time and inference-time types for consistency under `~`; a disagreement fires the diagnostic *"hole annotated with 'T_resolution' but context expects 'T_inference'"*, surfacing what would otherwise be a silent overwrite. @@ -781,7 +581,7 @@ just wasted work and a maintenance hazard. ### Shrink or remove `InferHoleTypes` `InferHoleTypes` walks the post-resolution AST a second time to annotate holes. Now that -Hole-None-Check writes the expected type during resolution for holes in check-mode +\[⇐\] Hole-None writes the expected type during resolution for holes in check-mode positions, the post-pass only needs to handle holes in synth-only positions (e.g. call arguments resolved through `synthStmtExpr` instead of `checkStmtExpr`). As more constructs gain bespoke check rules, fewer holes will reach `InferHoleTypes`; eventually the pass From c544b2bc39876174b365902018a4814c77b100df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 15:30:10 -0400 Subject: [PATCH 049/189] extract typing rules out in helper functions for easier verso documentation --- Strata/Languages/Laurel/Resolution.lean | 1091 ++++++++++++++++------- 1 file changed, 791 insertions(+), 300 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 81d96adca6..80982bfa59 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -526,345 +526,836 @@ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List Hig | some (_, .constant c) => pure (c.type, []) | _ => pure ({ val := .Unknown, source := callee.source }, []) +/-! ## Typing rules + +Each typing rule from the Laurel manual is implemented as its own helper +inside the mutual block below. Helpers are grouped by section to mirror the +*Typing rules* index in `LaurelDoc.lean`: + +- Literals — `synthLitInt`, `synthLitBool`, `synthLitString`, `synthLitDecimal` +- Variables — `synthVarLocal`, `synthVarField`, `synthVarDeclare` +- Control flow — `synthIfThenElse`, `synthBlock`, `synthWhile`, `synthExit`, + `synthReturn`, `checkBlock`, `checkIfThenElse` +- Verification statements — `synthAssert`, `synthAssume` +- Assignment — `synthAssign` +- Calls — `synthStaticCall`, `synthInstanceCall` +- Primitive operations — `synthPrimitiveOp` +- Object forms — `synthNew`, `synthAsType`, `synthIsType`, `synthRefEq`, + `synthPureFieldUpdate` +- Verification expressions — `synthQuantifier`, `synthAssigned`, `synthOld`, + `synthFresh`, `synthProveBy` +- Self reference — `synthThis` +- Untyped forms — `synthAbstract`, `synthAll` +- ContractOf — `synthContractOf` +- Holes — `synthHole`, `checkHoleNone` + +The dispatch functions `synthStmtExpr` and `checkStmtExpr` simply pattern-match +on the constructor and delegate to the corresponding helper. -/ + +-- The `h : exprMd.val = .Foo args ...` parameters on the recursive helpers +-- look unused to the linter, but each one is referenced by that helper's +-- `decreasing_by` tactic to relate `sizeOf args` to `sizeOf exprMd`. +set_option linter.unusedVariables false in mutual + +-- ### Dispatch + +/-- Synth-mode resolution: resolve `e` and synthesize its `HighType`. + Each constructor delegates to its rule's helper. -/ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do - match _: exprMd with + match h_node: exprMd with | AstNode.mk expr source => - let (val', ty) ← match _: expr with + let (val', ty) ← match h_expr: expr with | .IfThenElse cond thenBr elseBr => - -- Condition is checked against TBool. The result type is TVoid when the - -- else branch is absent (statement form: the then-branch's value is - -- discarded), otherwise the then-branch's synthesized type. We don't - -- compare the two branches against each other since statement-position - -- ifs commonly mix a value branch with a TVoid branch (return/exit). - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } - let (thenBr', thenTy) ← synthStmtExpr thenBr - let elseBr' ← elseBr.attach.mapM (fun a => have := a.property; do - let (e', _) ← synthStmtExpr a.val; pure e') - let resultTy := match elseBr with - | none => { val := .TVoid, source := source } - | some _ => thenTy - pure (.IfThenElse cond' thenBr' elseBr', resultTy) + synthIfThenElse exprMd cond thenBr elseBr (by rw [h_node]) | .Block stmts label => - -- Synth-mode block: non-last statements have their synthesized type discarded - -- (lax rule, matches Java/Python/JS expression-statement semantics). - -- The last statement's synthesized type becomes the block's type. - withScope do - let results ← stmts.mapM synthStmtExpr - let stmts' := results.map (·.1) - let lastTy := match results.getLast? with - | some (_, ty) => ty - | none => { val := .TVoid, source := source } - pure (.Block stmts' label, lastTy) + synthBlock exprMd stmts label (by rw [h_node]) | .While cond invs dec body => - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } - let invs' ← invs.attach.mapM (fun a => have := a.property; do - checkStmtExpr a.val { val := .TBool, source := a.val.source }) - let dec' ← dec.attach.mapM (fun a => have := a.property; do - let (e', _) ← synthStmtExpr a.val; pure e') - let (body', _) ← synthStmtExpr body - pure (.While cond' invs' dec' body', { val := .TVoid, source := source }) - | .Exit target => pure (.Exit target, { val := .TVoid, source := source }) - | .Return val => do - -- Match the optional return value against the enclosing procedure's - -- declared outputs. `expectedReturnTypes = none` means we're not inside a - -- procedure body (e.g. resolving a constant initializer); skip the check. - let expected := (← get).expectedReturnTypes - let val' ← val.attach.mapM (fun a => have := a.property; do - match expected with - | some [singleOutput] => checkStmtExpr a.val singleOutput - | _ => let (e', _) ← synthStmtExpr a.val; pure e') - -- Arity/shape diagnostics independent of the value's own type. - match val, expected with - | none, some [] => pure () - | none, some [_] => pure () -- Dafny-style early exit - | none, some _ => pure () -- multi-output: bare return is fine - | some _, some [] => - let diag := diagnosticFromSource source - "void procedure cannot return a value" - modify fun s => { s with errors := s.errors.push diag } - | some _, some [_] => pure () -- value already checked above - | some _, some _ => - let diag := diagnosticFromSource source - "multi-output procedure cannot use 'return e'; assign to named outputs instead" - modify fun s => { s with errors := s.errors.push diag } - | _, none => pure () -- no enclosing procedure - pure (.Return val', { val := .TVoid, source := source }) - | .LiteralInt v => pure (.LiteralInt v, { val := .TInt, source := source }) - | .LiteralBool v => pure (.LiteralBool v, { val := .TBool, source := source }) - | .LiteralString v => pure (.LiteralString v, { val := .TString, source := source }) - | .LiteralDecimal v => pure (.LiteralDecimal v, { val := .TReal, source := source }) - | .Var (.Local ref) => - let ref' ← resolveRef ref source - let ty ← getVarType ref - pure (.Var (.Local ref'), ty) - | .Var (.Declare param) => - let ty' ← resolveHighType param.type - let name' ← defineNameCheckDup param.name (.var param.name ty') - pure (.Var (.Declare ⟨name', ty'⟩), { val := .TVoid, source := source }) - | .Assign targets value => - let targets' ← targets.attach.mapM fun ⟨v, _⟩ => do - let ⟨vv, vs⟩ := v - match vv with - | .Local ref => - let ref' ← resolveRef ref source - pure (⟨.Local ref', vs⟩ : VariableMd) - | .Field target fieldName => - let (target', _) ← synthStmtExpr target - let fieldName' ← resolveFieldRef target' fieldName source - pure (⟨.Field target' fieldName', vs⟩ : VariableMd) - | .Declare param => - let ty' ← resolveHighType param.type - let name' ← defineNameCheckDup param.name (.var param.name ty') - pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let (value', valueTy) ← synthStmtExpr value - -- Compute the target's declared type, regardless of whether it's a Local, - -- a Field, or a fresh Declare. - let targetType (t : VariableMd) : ResolveM HighTypeMd := do - match t.val with - | .Local ref => getVarType ref - | .Declare param => pure param.type - | .Field _ fieldName => getVarType fieldName - -- Skip all checks when the RHS is a statement (TVoid) — no value to assign. - if valueTy.val != HighType.TVoid then - let targetTys ← targets'.mapM targetType - -- Build the expected type from the targets' declared types: a single - -- type when there's one target, a tuple (MultiValuedExpr) otherwise. - -- This matches the shape of `valueTy`, which is itself MultiValuedExpr - -- exactly when the RHS produces multiple values. A single tuple-vs-tuple - -- check then covers both arity and per-position type mismatches in one - -- diagnostic. - let expectedTy : HighTypeMd := match targetTys with - | [single] => single - | _ => { val := .MultiValuedExpr targetTys, source := source } - checkSubtype source expectedTy valueTy - pure (.Assign targets' value', valueTy) + synthWhile exprMd cond invs dec body (by rw [h_node]) + | .Exit target => pure (synthExit target source) + | .Return val => + synthReturn exprMd source val (by rw [h_node]) + | .LiteralInt v => pure (synthLitInt v source) + | .LiteralBool v => pure (synthLitBool v source) + | .LiteralString v => pure (synthLitString v source) + | .LiteralDecimal v => pure (synthLitDecimal v source) + | .Var (.Local ref) => synthVarLocal ref source + | .Var (.Declare param) => synthVarDeclare param source | .Var (.Field target fieldName) => - let (target', _) ← synthStmtExpr target - let fieldName' ← resolveFieldRef target' fieldName source - let ty ← getVarType fieldName' - pure (.Var (.Field target' fieldName'), ty) + synthVarField exprMd target fieldName source (by rw [h_node]) + | .Assign targets value => + synthAssign exprMd targets value source (by rw [h_node]) | .PureFieldUpdate target fieldName newVal => - let (target', targetTy) ← synthStmtExpr target - let fieldName' ← resolveFieldRef target' fieldName source - let fieldTy ← getVarType fieldName' - let newVal' ← checkStmtExpr newVal fieldTy - pure (.PureFieldUpdate target' fieldName' newVal', targetTy) + synthPureFieldUpdate exprMd target fieldName newVal (by rw [h_node]) | .StaticCall callee args => - let callee' ← resolveRef callee source - (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) - let results ← args.mapM synthStmtExpr - let args' := results.map (·.1) - let argTypes := results.map (·.2) - let (retTy, paramTypes) ← getCallInfo callee - for ((a, aTy), paramTy) in (args'.zip argTypes).zip paramTypes do - checkSubtype a.source paramTy aTy - pure (.StaticCall callee' args', retTy) + synthStaticCall exprMd callee args source (by rw [h_node]) | .PrimitiveOp op args => - let results ← args.mapM synthStmtExpr - let args' := results.map (·.1) - let argTypes := results.map (·.2) - let resultTy := match op with - | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies - | .Lt | .Leq | .Gt | .Geq => HighType.TBool - | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => - match argTypes.head? with - | some headTy => headTy.val - | none => HighType.TInt - | .StrConcat => HighType.TString - match op with - | .And | .Or | .AndThen | .OrElse | .Not | .Implies => - for (a, aTy) in args'.zip argTypes do - checkSubtype a.source { val := .TBool, source := a.source } aTy - | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => - let ctx := (← get).typeContext - for (a, aTy) in args'.zip argTypes do - unless isNumeric ctx aTy do - typeMismatch a.source (some expr) "expected a numeric type" aTy - | .Eq | .Neq => - match argTypes with - | [lhsTy, rhsTy] => - let ctx := (← get).typeContext - unless isConsistent ctx lhsTy rhsTy do - let diag := diagnosticFromSource source - s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" - modify fun s => { s with errors := s.errors.push diag } - | _ => pure () - | .StrConcat => - for (a, aTy) in args'.zip argTypes do - checkSubtype a.source { val := .TString, source := a.source } aTy - pure (.PrimitiveOp op args', { val := resultTy, source := source }) - | .New ref => - let ref' ← resolveRef ref source - (expected := #[.compositeType, .datatypeDefinition]) - -- If the reference resolved to the wrong kind, use Unknown type to avoid cascading errors - let s ← get - let kindOk : Bool := match s.scope.get? ref.text with - | some (_, node) => node.kind == .unresolved || - (#[ResolvedNodeKind.compositeType, .datatypeDefinition].contains node.kind) - | none => true - let ty := if kindOk then { val := HighType.UserDefined ref', source := source } - else { val := HighType.Unknown, source := source } - pure (.New ref', ty) - | .This => - let s ← get - match s.instanceTypeName with - | some typeName => - let typeId : Identifier := - match s.scope.get? typeName with - | some (uid, _) => { text := typeName, uniqueId := some uid, source := source } - | none => { text := typeName, source := source } - pure (.This, { val := .UserDefined typeId, source := source }) - | none => - let diag := diagnosticFromSource source "'this' is not allowed outside instance methods" - modify fun s => { s with errors := s.errors.push diag } - pure (.This, { val := .Unknown, source := source }) + synthPrimitiveOp exprMd expr op args source h_expr (by rw [h_node]) + | .New ref => synthNew ref source + | .This => synthThis source | .ReferenceEquals lhs rhs => - let (lhs', lhsTy) ← synthStmtExpr lhs - let (rhs', rhsTy) ← synthStmtExpr rhs - let ctx := (← get).typeContext - unless isReference ctx lhsTy do - typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy - unless isReference ctx rhsTy do - typeMismatch rhs'.source (some expr) "expected a reference type" rhsTy - pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) + synthRefEq exprMd expr lhs rhs source h_expr (by rw [h_node]) | .AsType target ty => - let (target', _) ← synthStmtExpr target - let ty' ← resolveHighType ty - pure (.AsType target' ty', ty') + synthAsType exprMd target ty (by rw [h_node]) | .IsType target ty => - let (target', _) ← synthStmtExpr target - let ty' ← resolveHighType ty - pure (.IsType target' ty', { val := .TBool, source := source }) + synthIsType exprMd target ty source (by rw [h_node]) | .InstanceCall target callee args => - let (target', _) ← synthStmtExpr target - let callee' ← resolveRef callee source - (expected := #[.instanceProcedure, .staticProcedure]) - let results ← args.mapM synthStmtExpr - let args' := results.map (·.1) - let argTypes := results.map (·.2) - let (retTy, paramTypes) ← getCallInfo callee - -- Skip first param (self) when matching args. - let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] - for ((a, aTy), paramTy) in (args'.zip argTypes).zip callParamTypes do - checkSubtype a.source paramTy aTy - pure (.InstanceCall target' callee' args', retTy) + synthInstanceCall exprMd target callee args source (by rw [h_node]) | .Quantifier mode param trigger body => - withScope do - let paramTy' ← resolveHighType param.type - let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') - let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do - let (e', _) ← synthStmtExpr pv.val; pure e') - let body' ← checkStmtExpr body { val := .TBool, source := body.source } - pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) + synthQuantifier exprMd mode param trigger body source (by rw [h_node]) | .Assigned name => - let (name', _) ← synthStmtExpr name - pure (.Assigned name', { val := .TBool, source := source }) + synthAssigned exprMd name source (by rw [h_node]) | .Old val => - let (val', valTy) ← synthStmtExpr val - pure (.Old val', valTy) + synthOld exprMd val (by rw [h_node]) | .Fresh val => - let (val', valTy) ← synthStmtExpr val - unless isReference (← get).typeContext valTy do - typeMismatch val'.source (some expr) "expected a reference type" valTy - pure (.Fresh val', { val := .TBool, source := source }) + synthFresh exprMd expr val source h_expr (by rw [h_node]) | .Assert ⟨condExpr, summary⟩ => - let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } - pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) + synthAssert exprMd condExpr summary source (by rw [h_node]) | .Assume cond => - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } - pure (.Assume cond', { val := .TVoid, source := source }) + synthAssume exprMd cond source (by rw [h_node]) | .ProveBy val proof => - let (val', valTy) ← synthStmtExpr val - let (proof', _) ← synthStmtExpr proof - pure (.ProveBy val' proof', valTy) + synthProveBy exprMd val proof (by rw [h_node]) | .ContractOf ty fn => - -- `fn` must be a direct identifier reference resolving to a procedure. - -- Anything else (arbitrary expressions, references to non-procedures) is - -- ill-formed: a contract belongs to a *named* procedure. - let (fn', _) ← synthStmtExpr fn - let s ← get - let fnIsProcRef : Bool := match fn'.val with - | .Var (.Local ref) => - match s.scope.get? ref.text with - | some (_, node) => - node.kind == .staticProcedure || - node.kind == .instanceProcedure || - node.kind == .unresolved - | none => true -- unresolved name already reported - | _ => false - unless fnIsProcRef do - let diag := diagnosticFromSource fn.source - "'contractOf' expected a procedure reference" - modify fun s => { s with errors := s.errors.push diag } - -- Result type: Bool for pre/postconditions, set of heap references for - -- reads/modifies. The element type of the set is left as Unknown for now - -- since the rule doesn't recover it from `fn`. - let resultTy : HighType := match ty with - | .Precondition | .PostCondition => .TBool - | .Reads | .Modifies => .TSet { val := .Unknown, source := none } - pure (.ContractOf ty fn', { val := resultTy, source := source }) - | .Abstract => pure (.Abstract, { val := .Unknown, source := source }) - | .All => pure (.All, { val := .Unknown, source := source }) - | .Hole det type => match type with - | some ty => - let ty' ← resolveHighType ty - pure (.Hole det ty', ty') - | none => pure (.Hole det none, { val := .Unknown, source := source }) + synthContractOf exprMd ty fn source (by rw [h_node]) + | .Abstract => pure (synthAbstract source) + | .All => pure (synthAll source) + | .Hole det type => synthHole det type source return ({ val := val', source := source }, ty) - termination_by (exprMd, 0) + termination_by (exprMd, 2) decreasing_by all_goals first | (apply Prod.Lex.left; term_by_mem) + | (try subst h_node; apply Prod.Lex.right; decide) | (apply Prod.Lex.right; decide) -/-- Check-mode resolution: resolve `e` and verify its type is a consistent - subtype of `expected`. Bidirectional rules for individual constructs push - `expected` into subexpressions; everything else falls back to subsumption - (synth, then `isConsistentSubtype actual expected`). -/ +/-- Check-mode resolution (rule **Sub** at the boundary): resolve `e` and + verify its type is a consistent subtype of `expected`. Bidirectional rules + for individual constructs push `expected` into subexpressions; everything + else falls back to subsumption (synth, then `isConsistentSubtype actual + expected`). -/ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do - match _: exprMd with + match h_node: exprMd with | AstNode.mk expr source => - match _: expr with + match h_expr: expr with | .Block stmts label => - -- Bespoke check rule: discard non-last statement types (lax), push - -- `expected` into the last statement. Empty block reduces to subsumption - -- of TVoid against `expected`. - withScope do - let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do - have : s ∈ stmts := List.dropLast_subset stmts hMem - let (s', _) ← synthStmtExpr s; pure s') - match _lastResult: stmts.getLast? with - | none => - checkSubtype source expected { val := .TVoid, source := source } - pure { val := .Block init' label, source := source } - | some last => - have := List.mem_of_getLast? _lastResult - let last' ← checkStmtExpr last expected - pure { val := .Block (init' ++ [last']) label, source := source } + checkBlock exprMd stmts label expected source (by rw [h_node]) | .IfThenElse cond thenBr elseBr => - -- Push `expected` into both branches (rather than going through the synth - -- rule + Sub at the boundary). Without an else branch, fall back to - -- subsumption of TVoid against `expected`. - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } - let thenBr' ← checkStmtExpr thenBr expected - let elseBr' ← elseBr.attach.mapM (fun ⟨e, _⟩ => checkStmtExpr e expected) - if elseBr.isNone then - checkSubtype source expected { val := .TVoid, source := source } - pure { val := .IfThenElse cond' thenBr' elseBr', source := source } - | .Hole det none => - -- Untyped hole in check mode: record the expected type on the node so - -- downstream passes don't have to infer it again. Subsumption is trivial - -- (Unknown <: T always holds). - pure { val := .Hole det (some expected), source := source } + checkIfThenElse exprMd cond thenBr elseBr expected source (by rw [h_node]) + | .Hole det none => pure (checkHoleNone det expected source) | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← synthStmtExpr exprMd checkSubtype source expected actual pure e' - termination_by (exprMd, 1) + termination_by (exprMd, 3) decreasing_by all_goals first | (apply Prod.Lex.left; term_by_mem) | (try subst_eqs; apply Prod.Lex.right; decide) + | (try subst h_node; apply Prod.Lex.right; decide) + | (apply Prod.Lex.right; decide) + +-- ### Literals + +/-- Rule **Lit-Int**: `Γ ⊢ LiteralInt n ⇒ TInt`. -/ +def synthLitInt (v : Int) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.LiteralInt v, { val := .TInt, source := source }) + +/-- Rule **Lit-Bool**: `Γ ⊢ LiteralBool b ⇒ TBool`. -/ +def synthLitBool (v : Bool) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.LiteralBool v, { val := .TBool, source := source }) + +/-- Rule **Lit-String**: `Γ ⊢ LiteralString s ⇒ TString`. -/ +def synthLitString (v : String) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.LiteralString v, { val := .TString, source := source }) + +/-- Rule **Lit-Decimal**: `Γ ⊢ LiteralDecimal d ⇒ TReal`. -/ +def synthLitDecimal (v : Decimal) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.LiteralDecimal v, { val := .TReal, source := source }) + +-- ### Variables + +/-- Rule **Var-Local**: `Γ(x) = T ⊢ Var (.Local x) ⇒ T`. Resolves `ref` against + the lexical scope and reads its declared type. -/ +def synthVarLocal (ref : Identifier) (source : Option FileRange) : + ResolveM (StmtExpr × HighTypeMd) := do + let ref' ← resolveRef ref source + let ty ← getVarType ref + pure (.Var (.Local ref'), ty) + +/-- Rule **Var-Declare**: extends the surrounding scope with `x : T` and + synthesizes `TVoid` (the declaration itself produces no value). -/ +def synthVarDeclare (param : Parameter) (source : Option FileRange) : + ResolveM (StmtExpr × HighTypeMd) := do + let ty' ← resolveHighType param.type + let name' ← defineNameCheckDup param.name (.var param.name ty') + pure (.Var (.Declare ⟨name', ty'⟩), { val := .TVoid, source := source }) + +/-- Rule **Var-Field**: `Γ ⊢ e ⇒ _, Γ(f) = T_f ⊢ Var (.Field e f) ⇒ T_f`. + `f` is looked up against the type of `e` (or the enclosing instance type + for `self.f`); the typing rule itself is path-agnostic. -/ +def synthVarField (exprMd : StmtExprMd) + (target : StmtExprMd) (fieldName : Identifier) (source : Option FileRange) + (h : exprMd.val = .Var (.Field target fieldName)) : + ResolveM (StmtExpr × HighTypeMd) := do + let (target', _) ← synthStmtExpr target + let fieldName' ← resolveFieldRef target' fieldName source + let ty ← getVarType fieldName' + pure (.Var (.Field target' fieldName'), ty) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +-- ### Control flow + +/-- Rules **If-NoElse** / **If-Synth**: `cond` is checked against `TBool`. + With no else branch, the construct is a statement — `thenBr` is checked + against `TVoid` and the result is `TVoid`, so `if c then 5` is rejected. + With an else branch, the then-branch's synthesized type is returned; the + two branches are *not* compared against each other, since a statement- + position `if` often pairs a value branch with `return`/`exit`/`assert`. -/ +def synthIfThenElse (exprMd : StmtExprMd) + (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) + (h : exprMd.val = .IfThenElse cond thenBr elseBr) : + ResolveM (StmtExpr × HighTypeMd) := do + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let voidTy : HighTypeMd := { val := .TVoid, source := exprMd.source } + match elseBr with + | none => + let thenBr' ← checkStmtExpr thenBr voidTy + pure (.IfThenElse cond' thenBr' none, voidTy) + | some e => + let (thenBr', thenTy) ← synthStmtExpr thenBr + let (elseBr', _) ← synthStmtExpr e + pure (.IfThenElse cond' thenBr' (some elseBr'), thenTy) + termination_by (exprMd, 1) + decreasing_by + all_goals first + | (apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + try omega) + | (apply Prod.Lex.right; decide) + +/-- Rules **Block-Synth** / **Block-Synth-Empty**: non-last statements are + synthesized but their types discarded (the lax rule, matching + Java/Python/JS expression-statement semantics); the last statement's type + becomes the block's type, or `TVoid` for an empty block. The block opens + a fresh nested scope. -/ +def synthBlock (exprMd : StmtExprMd) + (stmts : List StmtExprMd) (label : Option String) + (h : exprMd.val = .Block stmts label) : + ResolveM (StmtExpr × HighTypeMd) := do + withScope do + let results ← stmts.mapM synthStmtExpr + let stmts' := results.map (·.1) + let lastTy := match results.getLast? with + | some (_, ty) => ty + | none => { val := .TVoid, source := exprMd.source } + pure (.Block stmts' label, lastTy) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + have := List.sizeOf_lt_of_mem ‹_ ∈ stmts› + omega + +/-- Rule **While**: `cond ⇐ TBool`, each invariant `⇐ TBool`, optional + `decreases` is resolved without a type check (intended target is numeric), + body is synthesized; the construct itself synthesizes `TVoid`. -/ +def synthWhile (exprMd : StmtExprMd) + (cond : StmtExprMd) (invs : List StmtExprMd) + (dec : Option StmtExprMd) (body : StmtExprMd) + (h : exprMd.val = .While cond invs dec body) : + ResolveM (StmtExpr × HighTypeMd) := do + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let invs' ← invs.attach.mapM (fun a => have := a.property; do + checkStmtExpr a.val { val := .TBool, source := a.val.source }) + let dec' ← dec.attach.mapM (fun a => have := a.property; do + let (e', _) ← synthStmtExpr a.val; pure e') + let (body', _) ← synthStmtExpr body + pure (.While cond' invs' dec' body', { val := .TVoid, source := exprMd.source }) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try (have := List.sizeOf_lt_of_mem ‹_ ∈ invs›) + try simp_all + omega + +/-- Rule **Exit**: `Γ ⊢ Exit target ⇒ TVoid`. -/ +def synthExit (target : String) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.Exit target, { val := .TVoid, source := source }) + +/-- Rules **Return-None** / **Return-Some** / **Return-Void-Error** / + **Return-Multi-Error**: matches the optional return value against the + enclosing procedure's declared outputs (`expectedReturnTypes`). `none` + means "no enclosing procedure" — e.g. resolving a constant initializer — + and skips all `Return` checks. -/ +def synthReturn (exprMd : StmtExprMd) (source : Option FileRange) + (val : Option StmtExprMd) + (h : exprMd.val = .Return val) : + ResolveM (StmtExpr × HighTypeMd) := do + let expected := (← get).expectedReturnTypes + let val' ← val.attach.mapM (fun a => have := a.property; do + match expected with + | some [singleOutput] => checkStmtExpr a.val singleOutput + | _ => let (e', _) ← synthStmtExpr a.val; pure e') + -- Arity/shape diagnostics independent of the value's own type. + match val, expected with + | none, some [] => pure () + | none, some [_] => pure () -- Dafny-style early exit + | none, some _ => pure () -- multi-output: bare return is fine + | some _, some [] => + let diag := diagnosticFromSource source + "void procedure cannot return a value" + modify fun s => { s with errors := s.errors.push diag } + | some _, some [_] => pure () -- value already checked above + | some _, some _ => + let diag := diagnosticFromSource source + "multi-output procedure cannot use 'return e'; assign to named outputs instead" + modify fun s => { s with errors := s.errors.push diag } + | _, none => pure () -- no enclosing procedure + pure (.Return val', { val := .TVoid, source := source }) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + simp_all + omega + +/-- Rules **Block-Check** / **Block-Check-Empty**: pushes `expected` into the + *last* statement rather than comparing the block's synthesized type at the + boundary. Errors fire at the offending subexpression, and `T` keeps + propagating through nested `Block` / `IfThenElse` / `Hole` / `Quantifier`. + Empty blocks reduce to a subsumption check of `TVoid` against `expected`. -/ +def checkBlock (exprMd : StmtExprMd) + (stmts : List StmtExprMd) (label : Option String) + (expected : HighTypeMd) (source : Option FileRange) + (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do + withScope do + let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do + have : s ∈ stmts := List.dropLast_subset stmts hMem + let (s', _) ← synthStmtExpr s; pure s') + match _lastResult: stmts.getLast? with + | none => + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .Block init' label, source := source } + | some last => + have := List.mem_of_getLast? _lastResult + let last' ← checkStmtExpr last expected + pure { val := .Block (init' ++ [last']) label, source := source } + termination_by (exprMd, 0) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try (have := List.sizeOf_lt_of_mem ‹_ ∈ stmts›) + try simp_all + omega + +/-- Rules **If-Check** / **If-Check-NoElse**: pushes `expected` into both + branches (rather than going through If-Synth + Sub at the boundary). + Errors fire at the offending branch instead of the surrounding `if`. + Without an else branch, the construct can only succeed when `T` admits + `TVoid`. -/ +def checkIfThenElse (exprMd : StmtExprMd) + (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) + (expected : HighTypeMd) (source : Option FileRange) + (h : exprMd.val = .IfThenElse cond thenBr elseBr) : ResolveM StmtExprMd := do + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let thenBr' ← checkStmtExpr thenBr expected + let elseBr' ← elseBr.attach.mapM (fun ⟨e, _⟩ => checkStmtExpr e expected) + if elseBr.isNone then + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .IfThenElse cond' thenBr' elseBr', source := source } + termination_by (exprMd, 0) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +-- ### Verification statements + +/-- Rule **Assert**: `cond` is checked against `TBool`; the construct + synthesizes `TVoid`. -/ +def synthAssert (exprMd : StmtExprMd) + (condExpr : StmtExprMd) (summary : Option String) (source : Option FileRange) + (h : exprMd.val = .Assert ⟨condExpr, summary⟩) : + ResolveM (StmtExpr × HighTypeMd) := do + let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } + pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +/-- Rule **Assume**: `cond` is checked against `TBool`; the construct + synthesizes `TVoid`. -/ +def synthAssume (exprMd : StmtExprMd) + (cond : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .Assume cond) : + ResolveM (StmtExpr × HighTypeMd) := do + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + pure (.Assume cond', { val := .TVoid, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +-- ### Assignment + +/-- Rule **Assign**: each target's declared type `T_i` (from `Local`, + `Field`, or fresh `Declare`) is collapsed into a tuple `ExpectedTy` + (single type if one target, otherwise `MultiValuedExpr [T_1; …; T_n]`) + and checked against the RHS's synthesized type. When the RHS is a + statement (`TVoid`) — `while`, `return`, … — all checks are skipped: + there's no value to assign. -/ +def synthAssign (exprMd : StmtExprMd) + (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .Assign targets value) : + ResolveM (StmtExpr × HighTypeMd) := do + let targets' ← targets.attach.mapM fun ⟨v, _⟩ => do + let ⟨vv, vs⟩ := v + match vv with + | .Local ref => + let ref' ← resolveRef ref source + pure (⟨.Local ref', vs⟩ : VariableMd) + | .Field target fieldName => + let (target', _) ← synthStmtExpr target + let fieldName' ← resolveFieldRef target' fieldName source + pure (⟨.Field target' fieldName', vs⟩ : VariableMd) + | .Declare param => + let ty' ← resolveHighType param.type + let name' ← defineNameCheckDup param.name (.var param.name ty') + pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) + let (value', valueTy) ← synthStmtExpr value + let targetType (t : VariableMd) : ResolveM HighTypeMd := do + match t.val with + | .Local ref => getVarType ref + | .Declare param => pure param.type + | .Field _ fieldName => getVarType fieldName + if valueTy.val != HighType.TVoid then + let targetTys ← targets'.mapM targetType + let expectedTy : HighTypeMd := match targetTys with + | [single] => single + | _ => { val := .MultiValuedExpr targetTys, source := source } + checkSubtype source expectedTy valueTy + pure (.Assign targets' value', valueTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + try (have := List.sizeOf_lt_of_mem ‹_ ∈ targets›; simp_all) + omega + +-- ### Calls + +/-- Rules **Static-Call** / **Static-Call-Multi**: callee is resolved against + the expected kinds (parameter, static procedure, datatype constructor, + constant); each argument is synthesized and checked against the + corresponding parameter type. The result type is the (possibly + multi-valued) declared output type from `getCallInfo`. -/ +def synthStaticCall (exprMd : StmtExprMd) + (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .StaticCall callee args) : + ResolveM (StmtExpr × HighTypeMd) := do + let callee' ← resolveRef callee source + (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) + let results ← args.mapM synthStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let (retTy, paramTypes) ← getCallInfo callee + for ((a, aTy), paramTy) in (args'.zip argTypes).zip paramTypes do + checkSubtype a.source paramTy aTy + pure (.StaticCall callee' args', retTy) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + have := List.sizeOf_lt_of_mem ‹_ ∈ args› + omega + +/-- Rule **Instance-Call**: target is synthesized; callee resolves to an + instance or static procedure; arguments are checked pairwise against the + callee's parameter types after dropping `self`. -/ +def synthInstanceCall (exprMd : StmtExprMd) + (target : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) + (source : Option FileRange) + (h : exprMd.val = .InstanceCall target callee args) : + ResolveM (StmtExpr × HighTypeMd) := do + let (target', _) ← synthStmtExpr target + let callee' ← resolveRef callee source + (expected := #[.instanceProcedure, .staticProcedure]) + let results ← args.mapM synthStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let (retTy, paramTypes) ← getCallInfo callee + let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] + for ((a, aTy), paramTy) in (args'.zip argTypes).zip callParamTypes do + checkSubtype a.source paramTy aTy + pure (.InstanceCall target' callee' args', retTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try (have := List.sizeOf_lt_of_mem ‹_ ∈ args›) + try simp_all + omega + +-- ### Primitive operations + +/-- Rules **Op-Bool** / **Op-Cmp** / **Op-Eq** / **Op-Arith** / **Op-Concat**: + each operator family has its own argument-type discipline and result + type. Arguments are synthesized first, then the per-family check fires + (`⇐ TBool` for booleans, `Numeric` for arithmetic/comparison, consistency + `~` for equality, `⇐ TString` for concatenation). The result type is + `TBool` for booleans/comparisons/equality, the head argument's type for + arithmetic, `TString` for concatenation. -/ +def synthPrimitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) + (op : Operation) (args : List StmtExprMd) (source : Option FileRange) + (h_expr : expr = .PrimitiveOp op args) + (h : exprMd.val = .PrimitiveOp op args) : + ResolveM (StmtExpr × HighTypeMd) := do + let _ := h_expr -- carries the constructor identity for `expr` in diagnostics + let results ← args.mapM synthStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let resultTy := match op with + | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies + | .Lt | .Leq | .Gt | .Geq => HighType.TBool + | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => + match argTypes.head? with + | some headTy => headTy.val + | none => HighType.TInt + | .StrConcat => HighType.TString + match op with + | .And | .Or | .AndThen | .OrElse | .Not | .Implies => + for (a, aTy) in args'.zip argTypes do + checkSubtype a.source { val := .TBool, source := a.source } aTy + | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => + let ctx := (← get).typeContext + for (a, aTy) in args'.zip argTypes do + unless isNumeric ctx aTy do + typeMismatch a.source (some expr) "expected a numeric type" aTy + | .Eq | .Neq => + match argTypes with + | [lhsTy, rhsTy] => + let ctx := (← get).typeContext + unless isConsistent ctx lhsTy rhsTy do + let diag := diagnosticFromSource source + s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" + modify fun s => { s with errors := s.errors.push diag } + | _ => pure () + | .StrConcat => + for (a, aTy) in args'.zip argTypes do + checkSubtype a.source { val := .TString, source := a.source } aTy + pure (.PrimitiveOp op args', { val := resultTy, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + have := List.sizeOf_lt_of_mem ‹_ ∈ args› + omega + +-- ### Object forms + +/-- Rules **New-Ok** / **New-Fallback**: when `ref` resolves to a composite or + datatype, the type is `UserDefined ref`; otherwise `Unknown` (suppresses + cascading errors after the kind diagnostic has already fired). -/ +def synthNew (ref : Identifier) (source : Option FileRange) : + ResolveM (StmtExpr × HighTypeMd) := do + let ref' ← resolveRef ref source + (expected := #[.compositeType, .datatypeDefinition]) + let s ← get + let kindOk : Bool := match s.scope.get? ref.text with + | some (_, node) => node.kind == .unresolved || + (#[ResolvedNodeKind.compositeType, .datatypeDefinition].contains node.kind) + | none => true + let ty := if kindOk then { val := HighType.UserDefined ref', source := source } + else { val := HighType.Unknown, source := source } + pure (.New ref', ty) + +/-- Rule **AsType**: `target` is resolved but not checked against `T` — the + cast is the user's claim. The synthesized type is `T`. -/ +def synthAsType (exprMd : StmtExprMd) + (target : StmtExprMd) (ty : HighTypeMd) + (h : exprMd.val = .AsType target ty) : + ResolveM (StmtExpr × HighTypeMd) := do + let (target', _) ← synthStmtExpr target + let ty' ← resolveHighType ty + pure (.AsType target' ty', ty') + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **IsType**: `target` is resolved; the synthesized type is `TBool`. -/ +def synthIsType (exprMd : StmtExprMd) + (target : StmtExprMd) (ty : HighTypeMd) (source : Option FileRange) + (h : exprMd.val = .IsType target ty) : + ResolveM (StmtExpr × HighTypeMd) := do + let (target', _) ← synthStmtExpr target + let ty' ← resolveHighType ty + pure (.IsType target' ty', { val := .TBool, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **RefEq**: both operands must be reference types (`UserDefined` or + `Unknown`). Reference equality is meaningless on primitives. -/ +def synthRefEq (exprMd : StmtExprMd) (expr : StmtExpr) + (lhs rhs : StmtExprMd) (source : Option FileRange) + (h_expr : expr = .ReferenceEquals lhs rhs) + (h : exprMd.val = .ReferenceEquals lhs rhs) : + ResolveM (StmtExpr × HighTypeMd) := do + let _ := h_expr + let (lhs', lhsTy) ← synthStmtExpr lhs + let (rhs', rhsTy) ← synthStmtExpr rhs + let ctx := (← get).typeContext + unless isReference ctx lhsTy do + typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy + unless isReference ctx rhsTy do + typeMismatch rhs'.source (some expr) "expected a reference type" rhsTy + pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **PureFieldUpdate**: `target` is synthesized, `f` resolved against + `T_t` (or the enclosing instance type), and `newVal` checked against the + field's declared type. The synthesized type is `T_t` — updating a field + on a pure type produces a new value of the same type. -/ +def synthPureFieldUpdate (exprMd : StmtExprMd) + (target : StmtExprMd) (fieldName : Identifier) (newVal : StmtExprMd) + (h : exprMd.val = .PureFieldUpdate target fieldName newVal) : + ResolveM (StmtExpr × HighTypeMd) := do + let (target', targetTy) ← synthStmtExpr target + let fieldName' ← resolveFieldRef target' fieldName target.source + let fieldTy ← getVarType fieldName' + let newVal' ← checkStmtExpr newVal fieldTy + pure (.PureFieldUpdate target' fieldName' newVal', targetTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +-- ### Verification expressions + +/-- Rule **Quantifier**: opens a fresh scope, binds `x : T`, resolves the + optional trigger, and checks the body against `TBool`. The construct + itself synthesizes `TBool` since a quantifier is a proposition. -/ +def synthQuantifier (exprMd : StmtExprMd) + (mode : QuantifierMode) (param : Parameter) + (trigger : Option StmtExprMd) (body : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .Quantifier mode param trigger body) : + ResolveM (StmtExpr × HighTypeMd) := do + withScope do + let paramTy' ← resolveHighType param.type + let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') + let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do + let (e', _) ← synthStmtExpr pv.val; pure e') + let body' ← checkStmtExpr body { val := .TBool, source := body.source } + pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +/-- Rule **Assigned**: `name` is synthesized; the construct synthesizes + `TBool`. -/ +def synthAssigned (exprMd : StmtExprMd) + (name : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .Assigned name) : + ResolveM (StmtExpr × HighTypeMd) := do + let (name', _) ← synthStmtExpr name + pure (.Assigned name', { val := .TBool, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **Old**: `Γ ⊢ v ⇒ T ⊢ Old v ⇒ T`. -/ +def synthOld (exprMd : StmtExprMd) + (val : StmtExprMd) + (h : exprMd.val = .Old val) : + ResolveM (StmtExpr × HighTypeMd) := do + let (val', valTy) ← synthStmtExpr val + pure (.Old val', valTy) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **Fresh**: `v` is synthesized and must have a reference type + (`UserDefined` or `Unknown`). The construct itself synthesizes `TBool`. -/ +def synthFresh (exprMd : StmtExprMd) (expr : StmtExpr) + (val : StmtExprMd) (source : Option FileRange) + (h_expr : expr = .Fresh val) + (h : exprMd.val = .Fresh val) : + ResolveM (StmtExpr × HighTypeMd) := do + let _ := h_expr + let (val', valTy) ← synthStmtExpr val + unless isReference (← get).typeContext valTy do + typeMismatch val'.source (some expr) "expected a reference type" valTy + pure (.Fresh val', { val := .TBool, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **ProveBy**: `v` and `proof` are both synthesized; the construct's + type is `v`'s type — `proof` is a hint for downstream verification. -/ +def synthProveBy (exprMd : StmtExprMd) + (val proof : StmtExprMd) + (h : exprMd.val = .ProveBy val proof) : + ResolveM (StmtExpr × HighTypeMd) := do + let (val', valTy) ← synthStmtExpr val + let (proof', _) ← synthStmtExpr proof + pure (.ProveBy val' proof', valTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +-- ### Self reference + +/-- Rules **This-Inside** / **This-Outside**: when `instanceTypeName` is set + (we're inside an instance method), `This` synthesizes `UserDefined T`; + otherwise an error is emitted and the type collapses to `Unknown`. -/ +def synthThis (source : Option FileRange) : + ResolveM (StmtExpr × HighTypeMd) := do + let s ← get + match s.instanceTypeName with + | some typeName => + let typeId : Identifier := + match s.scope.get? typeName with + | some (uid, _) => { text := typeName, uniqueId := some uid, source := source } + | none => { text := typeName, source := source } + pure (.This, { val := .UserDefined typeId, source := source }) + | none => + let diag := diagnosticFromSource source "'this' is not allowed outside instance methods" + modify fun s => { s with errors := s.errors.push diag } + pure (.This, { val := .Unknown, source := source }) + +-- ### Untyped forms + +/-- Rule **Abstract**: synthesizes `Unknown`. -/ +def synthAbstract (source : Option FileRange) : StmtExpr × HighTypeMd := + (.Abstract, { val := .Unknown, source := source }) + +/-- Rule **All**: synthesizes `Unknown`. -/ +def synthAll (source : Option FileRange) : StmtExpr × HighTypeMd := + (.All, { val := .Unknown, source := source }) + +-- ### ContractOf + +/-- Rules **ContractOf-Bool** / **ContractOf-Set** / **ContractOf-Error**: + `fn` must be a direct identifier reference resolving to a procedure; + anything else is ill-formed (a contract belongs to a *named* procedure). + Pre/postconditions are propositions (`TBool`); reads/modifies are sets of + heap references with element type `Unknown` for now. -/ +def synthContractOf (exprMd : StmtExprMd) + (ty : ContractType) (fn : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .ContractOf ty fn) : + ResolveM (StmtExpr × HighTypeMd) := do + let (fn', _) ← synthStmtExpr fn + let s ← get + let fnIsProcRef : Bool := match fn'.val with + | .Var (.Local ref) => + match s.scope.get? ref.text with + | some (_, node) => + node.kind == .staticProcedure || + node.kind == .instanceProcedure || + node.kind == .unresolved + | none => true -- unresolved name already reported + | _ => false + unless fnIsProcRef do + let diag := diagnosticFromSource fn.source + "'contractOf' expected a procedure reference" + modify fun s => { s with errors := s.errors.push diag } + let resultTy : HighType := match ty with + | .Precondition | .PostCondition => .TBool + | .Reads | .Modifies => .TSet { val := .Unknown, source := none } + pure (.ContractOf ty fn', { val := resultTy, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +-- ### Holes + +/-- Rules **Hole-Some** / **Hole-None-Synth**: a typed hole synthesizes its + annotation; an untyped hole in synth position synthesizes `Unknown`. -/ +def synthHole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange) : + ResolveM (StmtExpr × HighTypeMd) := do + match type with + | some ty => + let ty' ← resolveHighType ty + pure (.Hole det ty', ty') + | none => pure (.Hole det none, { val := .Unknown, source := source }) + +/-- Rule **Hole-None-Check**: an untyped hole in check mode records the + expected type on the node so downstream passes don't have to infer it + again. The subsumption check is trivial (`Unknown <: T` always holds), so + this rule never fails — it just preserves the type information available + at the check-mode boundary. -/ +def checkHoleNone (det : Bool) (expected : HighTypeMd) (source : Option FileRange) : + StmtExprMd := + { val := .Hole det (some expected), source := source } + end /-- Resolve a statement expression, discarding the synthesized type. From b0ffaf5ec299044ca665c55105fc67863c7795e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 15:30:24 -0400 Subject: [PATCH 050/189] better if-then-else typing discipline --- docs/verso/LaurelDoc.lean | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 140a250407..a53f161f60 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -302,11 +302,12 @@ remainder of the enclosing scope. ### Control flow -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-NoElse)}` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow \mathsf{TVoid}}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-NoElse)}` The construct synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no -value when `cond` is false; without this, `x : int := if c then 5` would type-check -spuriously. +value when `cond` is false; the then-branch is checked against +{name Strata.Laurel.HighType.TVoid}`TVoid` so `x : int := if c then 5` is rejected at the +branch rather than slipping through to a downstream subsumption. $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t} \quad \text{([⇒] If)}` From 887c8889e57b33ffcd420543bcaf6f4763bd00b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 16:38:23 -0400 Subject: [PATCH 051/189] if then else type synthesis --- Strata/Languages/Laurel/Laurel.lean | 37 +++++++++++++++++++ Strata/Languages/Laurel/Resolution.lean | 15 +++++--- .../Laurel/ResolutionTypeCheckTests.lean | 21 +++++++++++ docs/verso/LaurelDoc.lean | 15 +++++--- 4 files changed, 77 insertions(+), 11 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index ff67dafe1b..4dd9b7a0f9 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -558,6 +558,43 @@ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := def isConsistentSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := isConsistent ctx sub sup || isSubtype ctx sub sup +/-- BFS through `extendingMap` starting from `name` and stopping at the first + type that is also in `targetAncestors`. Used by `joinTypes` to find a + common ancestor between two composites; `visited` cuts off cycles. -/ +partial def TypeContext.firstCommonAncestor (ctx : TypeContext) + (name : String) (targetAncestors : Std.HashSet String) : Option String := + let rec go (frontier : List String) (visited : Std.HashSet String) : Option String := + match frontier with + | [] => none + | n :: rest => + if visited.contains n then go rest visited + else if targetAncestors.contains n then some n + else + let parents := (ctx.extendingMap.get? n).getD [] + go (rest ++ parents) (visited.insert n) + go [name] {} + +/-- Least upper bound for the if-then-else synthesis rule. When `a` and `b` + are subtype-related, returns the larger; for unrelated composites, walks + `extending` chains for the first common ancestor. When no common + supertype exists (e.g. unrelated primitives, or a value branch paired + with a `TVoid` `return`/`exit`), falls back to `a` — the enclosing + context's `checkSubtype` then surfaces any mismatch against the + then-branch's type, preserving the historical statement-form behavior. -/ +def joinTypes (ctx : TypeContext) (a b : HighTypeMd) : HighTypeMd := + if isConsistentSubtype ctx a b then b + else if isConsistentSubtype ctx b a then a + else + let a' := ctx.unfold a + let b' := ctx.unfold b + match a'.val, b'.val with + | .UserDefined aName, .UserDefined bName => + match ctx.firstCommonAncestor aName.text (ctx.ancestors bName.text) with + | some name => + { val := .UserDefined { text := name, source := none }, source := a.source } + | none => a + | _, _ => a + def HighType.isBool : HighType → Bool | TBool => true | _ => false diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 80982bfa59..75bbbabb0d 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -713,9 +713,13 @@ def synthVarField (exprMd : StmtExprMd) /-- Rules **If-NoElse** / **If-Synth**: `cond` is checked against `TBool`. With no else branch, the construct is a statement — `thenBr` is checked against `TVoid` and the result is `TVoid`, so `if c then 5` is rejected. - With an else branch, the then-branch's synthesized type is returned; the - two branches are *not* compared against each other, since a statement- - position `if` often pairs a value branch with `return`/`exit`/`assert`. -/ + With an else branch, the result type is the join (LUB) of the two + branches' synthesized types, so `if c then new Left else new Right` + synthesizes the common ancestor `Top` rather than committing to one + branch arbitrarily. When no common supertype exists (e.g. a value branch + paired with a `TVoid` `return`/`exit`), `joinTypes` falls back to the + then-branch's type and the enclosing context's check surfaces any + mismatch downstream. -/ def synthIfThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (h : exprMd.val = .IfThenElse cond thenBr elseBr) : @@ -728,8 +732,9 @@ def synthIfThenElse (exprMd : StmtExprMd) pure (.IfThenElse cond' thenBr' none, voidTy) | some e => let (thenBr', thenTy) ← synthStmtExpr thenBr - let (elseBr', _) ← synthStmtExpr e - pure (.IfThenElse cond' thenBr' (some elseBr'), thenTy) + let (elseBr', elseTy) ← synthStmtExpr e + let ctx := (← get).typeContext + pure (.IfThenElse cond' thenBr' (some elseBr'), joinTypes ctx thenTy elseTy) termination_by (exprMd, 1) decreasing_by all_goals first diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index b78f3b22df..c674bf0fe4 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -194,4 +194,25 @@ procedure test() opaque { #guard_msgs (error, drop all) in #eval testInputWithOffset "UserDefinedCrossType" userDefinedCrossType 170 processResolution +/-! ## If-then-else branch join + +When the two branches have different but subtype-related types, the construct +synthesizes their join (least upper bound) — not the then-branch arbitrarily. +So `if c then new Left else new Right`, with `Left, Right <: Top`, synthesizes +`Top` and an assignment to a `Left`-typed variable is rejected. -/ + +def ifBranchJoinToCommonAncestor := r" +composite Top { } +composite Left extends Top { } +composite Right extends Top { } +procedure test(c: bool) opaque { + var x: Top := if c then new Left else new Right; + var y: Left := if c then new Left else new Right +//^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: expected 'Left', got 'Top' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "IfBranchJoinToCommonAncestor" ifBranchJoinToCommonAncestor 198 processResolution + end Laurel diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index a53f161f60..e87db76d31 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -309,12 +309,15 @@ value when `cond` is false; the then-branch is checked against {name Strata.Laurel.HighType.TVoid}`TVoid` so `x : int := if c then 5` is rejected at the branch rather than slipping through to a downstream subsumption. -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t} \quad \text{([⇒] If)}` - -Picks the then-branch type arbitrarily; the two branches are *not* compared, since a -statement-position `if` often pairs a value branch with a `return`/`exit`/`assert`. The -enclosing context's check (\[⇐\] Sub, or a containing `checkSubtype` like an assignment) -provides the actual check downstream. +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t \sqcup T_e} \quad \text{([⇒] If)}` + +The result is the join (least upper bound) of the two branch types, so +`if c then small else big` synthesizes the common supertype rather than committing to one +branch arbitrarily. The join walks `extending` chains for composites; when no common +supertype exists (e.g. a value branch paired with a `TVoid` `return`/`exit`), it falls +back to `T_t` and the enclosing context's check (\[⇐\] Sub, or a containing +`checkSubtype` like an assignment) surfaces any mismatch downstream against the +then-branch's type. $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \Gamma \vdash \mathit{elseBr} \Leftarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Leftarrow T} \quad \text{([⇐] If)}` From 8ad24ac4c8fae712afd7ff546b2a4bcd7f246611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 16:40:58 -0400 Subject: [PATCH 052/189] move test to appropriate location --- .../Examples/Objects/T9_IfBranchJoin.lean | 35 +++++++++++++++++++ .../Laurel/ResolutionTypeCheckTests.lean | 21 ----------- 2 files changed, 35 insertions(+), 21 deletions(-) create mode 100644 StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean diff --git a/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean b/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean new file mode 100644 index 0000000000..9149d2e647 --- /dev/null +++ b/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean @@ -0,0 +1,35 @@ +/- + Copyright Strata Contributors + + SPDX-License-Identifier: Apache-2.0 OR MIT +-/ + +import StrataTest.Util.TestDiagnostics +import StrataTest.Languages.Laurel.TestExamples + +open StrataTest.Util + +namespace Strata +namespace Laurel + +/- +When the two branches of an `if/else` have different but subtype-related +types, the construct synthesizes their join (least upper bound) — not the +then-branch arbitrarily. So `if c then new Left else new Right`, with +`Left, Right <: Top`, synthesizes `Top`. Storing it in a `Top`-typed +variable succeeds, but storing it in a `Left`-typed variable is rejected. +-/ + +def program := r" +composite Top { } +composite Left extends Top { } +composite Right extends Top { } +procedure test(c: bool) opaque { + var x: Top := if c then new Left else new Right; + var y: Left := if c then new Left else new Right +//^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: expected 'Left', got 'Top' +}; +" + +#guard_msgs (drop info) in +#eval testInputWithOffset "IfBranchJoin" program 22 processLaurelFile diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index c674bf0fe4..b78f3b22df 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -194,25 +194,4 @@ procedure test() opaque { #guard_msgs (error, drop all) in #eval testInputWithOffset "UserDefinedCrossType" userDefinedCrossType 170 processResolution -/-! ## If-then-else branch join - -When the two branches have different but subtype-related types, the construct -synthesizes their join (least upper bound) — not the then-branch arbitrarily. -So `if c then new Left else new Right`, with `Left, Right <: Top`, synthesizes -`Top` and an assignment to a `Left`-typed variable is rejected. -/ - -def ifBranchJoinToCommonAncestor := r" -composite Top { } -composite Left extends Top { } -composite Right extends Top { } -procedure test(c: bool) opaque { - var x: Top := if c then new Left else new Right; - var y: Left := if c then new Left else new Right -//^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: expected 'Left', got 'Top' -}; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "IfBranchJoinToCommonAncestor" ifBranchJoinToCommonAncestor 198 processResolution - end Laurel From 4f7b6bac817e0971394d32da2c8bd972680f6b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 17:03:17 -0400 Subject: [PATCH 053/189] very strict dereference comparison --- Strata/Languages/Laurel/Resolution.lean | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 75bbbabb0d..01443dee39 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1152,6 +1152,10 @@ def synthRefEq (exprMd : StmtExprMd) (expr : StmtExpr) typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy unless isReference ctx rhsTy do typeMismatch rhs'.source (some expr) "expected a reference type" rhsTy + unless isConsistent ctx lhsTy rhsTy do + let diag := diagnosticFromSource source + s!"'{expr.constrName}' operands have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" + modify fun s => { s with errors := s.errors.push diag } pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) termination_by (exprMd, 1) decreasing_by From 59a846173e03efe44915ceee1cb3fceb038f4d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 17:04:00 -0400 Subject: [PATCH 054/189] consistent references when comparing --- docs/verso/LaurelDoc.lean | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index e87db76d31..34b5772920 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -453,14 +453,15 @@ $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{AsT $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` -$$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad \mathsf{isReference}\;T_l \quad \mathsf{isReference}\;T_r}{\Gamma \vdash \mathsf{ReferenceEquals}\;\mathit{lhs}\;\mathit{rhs} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] RefEq)}` +$$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad \mathsf{isReference}\;T_l \quad \mathsf{isReference}\;T_r \quad T_l \sim T_r}{\Gamma \vdash \mathsf{ReferenceEquals}\;\mathit{lhs}\;\mathit{rhs} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] RefEq)}` `isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined` or {name Strata.Laurel.HighType.Unknown}`Unknown` -type. Reference equality is meaningless on primitives. Compatibility between `T_l` and -`T_r` (e.g. rejecting `Cat === Dog` for unrelated user-defined types) is delegated to -future tightening of `<:` — today, two distinct user-defined names already mismatch -structurally, so the check would only fire under stronger subtyping. +type. Reference equality is meaningless on primitives. The operands must also be +consistent under `~` (Siek–Taha consistency), matching the rule applied by +{name Strata.Laurel.Operation.Eq}`==`: two distinct user-defined types like `Cat` and +`Dog` are rejected, while either side being `Unknown` is accepted as a gradual escape +hatch. $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow T_t \quad \Gamma(f) = T_f \quad \Gamma \vdash \mathit{newVal} \Leftarrow T_f}{\Gamma \vdash \mathsf{PureFieldUpdate}\;\mathit{target}\;f\;\mathit{newVal} \Rightarrow T_t} \quad \text{([⇒] PureFieldUpdate)}` From 8a950f8d97c497fea2ea9421626cbf6ddf274ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 09:33:07 -0400 Subject: [PATCH 055/189] fix assign by creating a checking rule --- Strata/Languages/Laurel/Resolution.lean | 56 ++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 01443dee39..ddaf0df040 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -640,6 +640,8 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE checkBlock exprMd stmts label expected source (by rw [h_node]) | .IfThenElse cond thenBr elseBr => checkIfThenElse exprMd cond thenBr elseBr expected source (by rw [h_node]) + | .Assign targets value => + checkAssign exprMd targets value expected source (by rw [h_node]) | .Hole det none => pure (checkHoleNone det expected source) | _ => -- Subsumption fallback: synth then check `actual <: expected`. @@ -933,7 +935,10 @@ def synthAssume (exprMd : StmtExprMd) (single type if one target, otherwise `MultiValuedExpr [T_1; …; T_n]`) and checked against the RHS's synthesized type. When the RHS is a statement (`TVoid`) — `while`, `return`, … — all checks are skipped: - there's no value to assign. -/ + there's no value to assign. The construct synthesizes the RHS's type, + so that expression-position assignments like `x ++ (y := s)` see a + string in the second operand; statement-position uses are accommodated + by `checkAssign`, which accepts `TVoid` as the expected type. -/ def synthAssign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assign targets value) : @@ -975,6 +980,55 @@ def synthAssign (exprMd : StmtExprMd) try (have := List.sizeOf_lt_of_mem ‹_ ∈ targets›; simp_all) omega +/-- Rule **Assign-Check**: an assignment in statement position (checked + against `TVoid`) discards its RHS value, so the synthesized type is not + compared against `expected`. This lets `b := 1` appear as the last + statement of a block in an else-less `if` (whose branch is checked + against `TVoid`) without firing a subsumption error against the RHS's + type. For non-`TVoid` expected types, falls back to subsumption. -/ +def checkAssign (exprMd : StmtExprMd) + (targets : List VariableMd) (value : StmtExprMd) + (expected : HighTypeMd) (source : Option FileRange) + (h : exprMd.val = .Assign targets value) : ResolveM StmtExprMd := do + let targets' ← targets.attach.mapM fun ⟨v, _⟩ => do + let ⟨vv, vs⟩ := v + match vv with + | .Local ref => + let ref' ← resolveRef ref source + pure (⟨.Local ref', vs⟩ : VariableMd) + | .Field target fieldName => + let (target', _) ← synthStmtExpr target + let fieldName' ← resolveFieldRef target' fieldName source + pure (⟨.Field target' fieldName', vs⟩ : VariableMd) + | .Declare param => + let ty' ← resolveHighType param.type + let name' ← defineNameCheckDup param.name (.var param.name ty') + pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) + let (value', valueTy) ← synthStmtExpr value + let targetType (t : VariableMd) : ResolveM HighTypeMd := do + match t.val with + | .Local ref => getVarType ref + | .Declare param => pure param.type + | .Field _ fieldName => getVarType fieldName + if valueTy.val != HighType.TVoid then + let targetTys ← targets'.mapM targetType + let assignedTy : HighTypeMd := match targetTys with + | [single] => single + | _ => { val := .MultiValuedExpr targetTys, source := source } + checkSubtype source assignedTy valueTy + unless expected.val matches .TVoid do + checkSubtype source expected valueTy + pure { val := .Assign targets' value', source := source } + termination_by (exprMd, 0) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + try (have := List.sizeOf_lt_of_mem ‹_ ∈ targets›; simp_all) + omega + -- ### Calls /-- Rules **Static-Call** / **Static-Call-Multi**: callee is resolved against From fd75ea594b00ea4241cf46f4f8bdfcc5d8f9ba5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 10:05:43 -0400 Subject: [PATCH 056/189] documentation is moved to in-code docstrings --- Strata/Languages/Laurel/Laurel.lean | 38 +++- Strata/Languages/Laurel/Resolution.lean | 205 +++++++++++++----- docs/verso/LaurelDoc.lean | 262 ++++++++---------------- 3 files changed, 272 insertions(+), 233 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index 4dd9b7a0f9..ceab6a3025 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -530,9 +530,14 @@ partial def TypeContext.ancestors (ctx : TypeContext) (name : String) : Std.Hash go acc' (parents ++ rest) go {} [name] -/-- Subtyping. Walks `extending` chains for composites, unfolds aliases, and - unwraps constrained types to their base before falling back to structural - equality via `highEq`. -/ +/-- Pure subtyping `<:`. Walks the `extending` chain for `CompositeType` + (via `TypeContext.ancestors`), unfolds `TypeAlias` to its target, and + unwraps `ConstrainedType` to its base (both via `TypeContext.unfold`), + then falls back to structural equality via `highEq`. + + Used together with `isConsistent` to form `isConsistentSubtype`, which + is what the bidirectional checker invokes at every check-mode boundary + (rule `[⇐] Sub`). -/ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := let sub' := ctx.unfold sub let sup' := ctx.unfold sup @@ -543,9 +548,13 @@ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := (ctx.ancestors subName.text).contains supName.text || highEq sub' sup' | _, _ => highEq sub' sup' -/-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the - dynamic type and is consistent with everything; otherwise structural - equality after unfolding aliases / constrained types. -/ +/-- Consistency `~` (Siek–Taha): the symmetric gradual relation. `Unknown` + is the dynamic type and is consistent with everything; otherwise + structural equality after unfolding aliases / constrained types. + + Used directly by `[⇒] Op-Eq`, where the operand types must be mutually + consistent (no subtype direction is privileged), and as one half of + `isConsistentSubtype`. -/ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := let a' := ctx.unfold a let b' := ctx.unfold b @@ -554,7 +563,22 @@ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := | _, _ => highEq a' b' /-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For our flat lattice - this collapses to `sub ~ sup ∨ sub <: sup`. -/ + this collapses to `sub ~ sup ∨ sub <: sup` — the standard collapse. + + Used by rule `[⇐] Sub` (and every bespoke check rule). That single + choice is what makes the system *gradual*: an expression of type + `Unknown` (a hole, an unresolved name, a `Hole _ none`) flows freely + into any typed slot, and any expression flows freely into a slot of + type `Unknown`. Strict checking is applied between fully-known types + only. + + A previous iteration was synth-only with two *bivariantly-compatible* + wildcards: `Unknown` and `UserDefined`. The `UserDefined` carve-out was + load-bearing: no assignment, call argument, or comparison involving a + user type was ever rejected. The bidirectional design retires that + carve-out — user-defined types are now a regular participant in `<:`, + with `isSubtype` walking inheritance chains and unwrapping aliases + and constrained types to deliver real checking on user-defined code. -/ def isConsistentSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := isConsistent ctx sub sup || isSubtype ctx sub sup diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index ddaf0df040..970e32d0ad 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -560,8 +560,20 @@ mutual -- ### Dispatch -/-- Synth-mode resolution: resolve `e` and synthesize its `HighType`. - Each constructor delegates to its rule's helper. -/ +/-- Synth-mode resolution: resolve `e` and synthesize its `HighType`, + written `Γ ⊢ e ⇒ T`. Each constructor delegates to its rule's helper. + + Synthesis returns a type inferred from the expression itself; checking + (`checkStmtExpr`) verifies that the expression has a given expected + type. Each construct picks a mode based on whether its type is + determined locally (synth) or by context (check). Synth rules invoke + check on subexpressions whose expected type is known (e.g. + `cond ⇐ TBool` in `IfThenElse`); `checkStmtExpr` falls back to + `synthStmtExpr` via subsumption (rule `[⇐] Sub`). The two functions + are mutually recursive, with termination on a lexicographic measure + `(exprMd, tag)` — tag `0` for check, `1` for synth — so that + subsumption (which calls synth on the *same* expression) can decrease + via `Prod.Lex.right`. -/ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match h_node: exprMd with | AstNode.mk expr source => @@ -628,10 +640,22 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | (apply Prod.Lex.right; decide) /-- Check-mode resolution (rule **Sub** at the boundary): resolve `e` and - verify its type is a consistent subtype of `expected`. Bidirectional rules - for individual constructs push `expected` into subexpressions; everything - else falls back to subsumption (synth, then `isConsistentSubtype actual - expected`). -/ + verify its type is a consistent subtype of `expected`, written + `Γ ⊢ e ⇐ T`. Bidirectional rules for individual constructs (`Block`, + `IfThenElse`, `Assign`, `Hole`) push `expected` into subexpressions + rather than bouncing through synthesis, which keeps error messages + localized and lets the expected type propagate through nested control + flow. Everything else falls back to subsumption — synthesize, then + verify `isConsistentSubtype actual expected`. + + The right principle for new call sites is: when the position has a + known expected type (`TBool` for conditions, numeric for `decreases`, + the declared output for a constant initializer or a functional body), + use `checkStmtExpr`. When it doesn't, use `resolveStmtExpr` (a thin + wrapper that calls `synthStmtExpr` and discards the synthesized type, + used at sites where typing is not enforced — verification annotations, + modifies/reads clauses). `synthStmtExpr` itself is mostly an internal + interface used by other rules. -/ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do match h_node: exprMd with | AstNode.mk expr source => @@ -714,14 +738,19 @@ def synthVarField (exprMd : StmtExprMd) /-- Rules **If-NoElse** / **If-Synth**: `cond` is checked against `TBool`. With no else branch, the construct is a statement — `thenBr` is checked - against `TVoid` and the result is `TVoid`, so `if c then 5` is rejected. + against `TVoid` and the result is `TVoid`, so `x : int := if c then 5` + is rejected at the branch rather than slipping through to a downstream + subsumption. + With an else branch, the result type is the join (LUB) of the two - branches' synthesized types, so `if c then new Left else new Right` - synthesizes the common ancestor `Top` rather than committing to one - branch arbitrarily. When no common supertype exists (e.g. a value branch - paired with a `TVoid` `return`/`exit`), `joinTypes` falls back to the - then-branch's type and the enclosing context's check surfaces any - mismatch downstream. -/ + branches' synthesized types, so `if c then small else big` synthesizes + the common supertype rather than committing to one branch arbitrarily; + `if c then new Left else new Right` synthesizes the common ancestor. + When no common supertype exists (e.g. a value branch paired with a + `TVoid` `return`/`exit`), `joinTypes` falls back to the then-branch's + type and the enclosing context's check (`[⇐] Sub`, or a containing + `checkSubtype` like an assignment) surfaces any mismatch downstream + against the then-branch's type. -/ def synthIfThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (h : exprMd.val = .IfThenElse cond thenBr elseBr) : @@ -747,11 +776,14 @@ def synthIfThenElse (exprMd : StmtExprMd) try omega) | (apply Prod.Lex.right; decide) -/-- Rules **Block-Synth** / **Block-Synth-Empty**: non-last statements are - synthesized but their types discarded (the lax rule, matching - Java/Python/JS expression-statement semantics); the last statement's type +/-- Rules **Block-Synth** / **Block-Synth-Empty**: each statement is resolved + in the scope produced by its predecessor and may itself extend it + (`Var (.Declare …)` does); non-last statements are synthesized but their + types discarded (the lax rule, matching Java/Python/JS where `f(x);` is + normal even when `f` returns a value — trade-off: `5;` is silently + accepted, flagging it belongs to a lint). The last statement's type becomes the block's type, or `TVoid` for an empty block. The block opens - a fresh nested scope. -/ + a fresh nested scope, so bindings introduced inside don't escape. -/ def synthBlock (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (h : exprMd.val = .Block stmts label) : @@ -772,8 +804,9 @@ def synthBlock (exprMd : StmtExprMd) omega /-- Rule **While**: `cond ⇐ TBool`, each invariant `⇐ TBool`, optional - `decreases` is resolved without a type check (intended target is numeric), - body is synthesized; the construct itself synthesizes `TVoid`. -/ + `decreases` is resolved without a type check today (the intended target + is a numeric type), body is synthesized; the construct itself + synthesizes `TVoid`. -/ def synthWhile (exprMd : StmtExprMd) (cond : StmtExprMd) (invs : List StmtExprMd) (dec : Option StmtExprMd) (body : StmtExprMd) @@ -802,9 +835,22 @@ def synthExit (target : String) (source : Option FileRange) : StmtExpr × HighTy /-- Rules **Return-None** / **Return-Some** / **Return-Void-Error** / **Return-Multi-Error**: matches the optional return value against the - enclosing procedure's declared outputs (`expectedReturnTypes`). `none` - means "no enclosing procedure" — e.g. resolving a constant initializer — - and skips all `Return` checks. -/ + enclosing procedure's declared outputs. The expected output types are + threaded through `ResolveState.expectedReturnTypes`, set from + `proc.outputs` by `resolveProcedure` / `resolveInstanceProcedure` for + the duration of the body; `none` means "no enclosing procedure" — e.g. + resolving a constant initializer — and skips all `Return` checks. + + A bare `return;` is allowed in any context. In a single-output procedure + it acts as a Dafny-style early exit — the output parameter retains + whatever was last assigned to it. In a single-output procedure, `return e` + is checked against the declared output type (closing the prior soundness + gap where `return 0` in a `bool`-returning procedure went uncaught). + + Multi-output procedures use named-output assignment (`r := …` on the + declared output parameters); `return e` syntactically takes a single + `Option StmtExpr` and cannot carry multiple values, so it is flagged with + a diagnostic pointing users at the named-output convention. -/ def synthReturn (exprMd : StmtExprMd) (source : Option FileRange) (val : Option StmtExprMd) (h : exprMd.val = .Return val) : @@ -841,9 +887,11 @@ def synthReturn (exprMd : StmtExprMd) (source : Option FileRange) /-- Rules **Block-Check** / **Block-Check-Empty**: pushes `expected` into the *last* statement rather than comparing the block's synthesized type at the - boundary. Errors fire at the offending subexpression, and `T` keeps - propagating through nested `Block` / `IfThenElse` / `Hole` / `Quantifier`. - Empty blocks reduce to a subsumption check of `TVoid` against `expected`. -/ + boundary. Errors fire at the offending subexpression, and `expected` + keeps propagating through nested `Block` / `IfThenElse` / `Hole` / + `Quantifier`. Empty blocks reduce to a subsumption check of `TVoid` + against `expected` — the same check `[⇐] Block-Empty` performs when + `T` admits `TVoid`. -/ def checkBlock (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) @@ -873,8 +921,9 @@ def checkBlock (exprMd : StmtExprMd) /-- Rules **If-Check** / **If-Check-NoElse**: pushes `expected` into both branches (rather than going through If-Synth + Sub at the boundary). Errors fire at the offending branch instead of the surrounding `if`. - Without an else branch, the construct can only succeed when `T` admits - `TVoid`. -/ + Without an else branch, the construct can only succeed when `expected` + admits `TVoid` — the same subsumption check `[⇐] Block-Empty` performs + for an empty block. -/ def checkIfThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) @@ -933,12 +982,16 @@ def synthAssume (exprMd : StmtExprMd) /-- Rule **Assign**: each target's declared type `T_i` (from `Local`, `Field`, or fresh `Declare`) is collapsed into a tuple `ExpectedTy` (single type if one target, otherwise `MultiValuedExpr [T_1; …; T_n]`) - and checked against the RHS's synthesized type. When the RHS is a - statement (`TVoid`) — `while`, `return`, … — all checks are skipped: - there's no value to assign. The construct synthesizes the RHS's type, - so that expression-position assignments like `x ++ (y := s)` see a - string in the second operand; statement-position uses are accommodated - by `checkAssign`, which accepts `TVoid` as the expected type. -/ + and checked against the RHS's synthesized type. Both single- and + multi-target forms collapse into one tuple-vs-tuple check: when the RHS + is a `MultiValuedExpr`, both arity and per-position type mismatches + surface in a single diagnostic of shape *"expected '(int, int, int)', + got '(int, string)'"*. When the RHS is `TVoid` (a side-effecting + statement: `while`, `return`, …), all checks are skipped — there's no + value to assign. The construct synthesizes the RHS's type, so that + expression-position assignments like `x ++ (y := s)` see a string in + the second operand; statement-position uses are accommodated by + `checkAssign`, which accepts `TVoid` as the expected type. -/ def synthAssign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assign targets value) : @@ -1090,11 +1143,17 @@ def synthInstanceCall (exprMd : StmtExprMd) /-- Rules **Op-Bool** / **Op-Cmp** / **Op-Eq** / **Op-Arith** / **Op-Concat**: each operator family has its own argument-type discipline and result - type. Arguments are synthesized first, then the per-family check fires - (`⇐ TBool` for booleans, `Numeric` for arithmetic/comparison, consistency - `~` for equality, `⇐ TString` for concatenation). The result type is - `TBool` for booleans/comparisons/equality, the head argument's type for - arithmetic, `TString` for concatenation. -/ + type. Arguments are synthesized first, then the per-family check fires: + `⇐ TBool` for booleans, `Numeric` (consistent with `TInt`, `TReal`, or + `TFloat64`) for arithmetic/comparison, consistency `~` for equality + (symmetric — no subtype direction is privileged), `⇐ TString` for + concatenation. The result type is `TBool` for + booleans/comparisons/equality, the head argument's type for arithmetic + ("result is the type of the first argument" handles `int + int → int`, + `real + real → real`, etc. without unification — known relaxation: + `int + real` passes since each operand individually passes `Numeric`; + a proper fix needs numeric promotion or unification), `TString` for + concatenation. -/ def synthPrimitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) (op : Operation) (args : List StmtExprMd) (source : Option FileRange) (h_expr : expr = .PrimitiveOp op args) @@ -1161,7 +1220,9 @@ def synthNew (ref : Identifier) (source : Option FileRange) : pure (.New ref', ty) /-- Rule **AsType**: `target` is resolved but not checked against `T` — the - cast is the user's claim. The synthesized type is `T`. -/ + cast is the user's claim. The synthesized type is `T`. + + `IsType` is the runtime test counterpart and synthesizes `TBool`. -/ def synthAsType (exprMd : StmtExprMd) (target : StmtExprMd) (ty : HighTypeMd) (h : exprMd.val = .AsType target ty) : @@ -1192,7 +1253,12 @@ def synthIsType (exprMd : StmtExprMd) omega /-- Rule **RefEq**: both operands must be reference types (`UserDefined` or - `Unknown`). Reference equality is meaningless on primitives. -/ + `Unknown`) — reference equality is meaningless on primitives. The + operands must also be mutually consistent (the symmetric `isConsistent`), + so `Cat === Dog` is rejected when `Cat` and `Dog` are unrelated + user-defined types, while `Cat === Animal` is accepted when `Cat` + extends `Animal` (the gradual `Unknown` wildcard makes either side + flow freely against the other). -/ def synthRefEq (exprMd : StmtExprMd) (expr : StmtExpr) (lhs rhs : StmtExprMd) (source : Option FileRange) (h_expr : expr = .ReferenceEquals lhs rhs) @@ -1242,9 +1308,11 @@ def synthPureFieldUpdate (exprMd : StmtExprMd) -- ### Verification expressions -/-- Rule **Quantifier**: opens a fresh scope, binds `x : T`, resolves the - optional trigger, and checks the body against `TBool`. The construct - itself synthesizes `TBool` since a quantifier is a proposition. -/ +/-- Rule **Quantifier**: opens a fresh scope, binds `x : T` (in scope only + for the body and trigger), resolves the optional trigger, and checks + the body against `TBool` since a quantifier is a proposition. Without + that body check, `forall x: int :: x + 1` would be silently accepted. + The construct itself synthesizes `TBool`. -/ def synthQuantifier (exprMd : StmtExprMd) (mode : QuantifierMode) (param : Parameter) (trigger : Option StmtExprMd) (body : StmtExprMd) (source : Option FileRange) @@ -1296,7 +1364,9 @@ def synthOld (exprMd : StmtExprMd) omega /-- Rule **Fresh**: `v` is synthesized and must have a reference type - (`UserDefined` or `Unknown`). The construct itself synthesizes `TBool`. -/ + (`UserDefined` or `Unknown`) — `Fresh` only makes sense on + heap-allocated references, so `fresh(5)` is rejected. The construct + itself synthesizes `TBool`. -/ def synthFresh (exprMd : StmtExprMd) (expr : StmtExpr) (val : StmtExprMd) (source : Option FileRange) (h_expr : expr = .Fresh val) @@ -1334,8 +1404,13 @@ def synthProveBy (exprMd : StmtExprMd) -- ### Self reference /-- Rules **This-Inside** / **This-Outside**: when `instanceTypeName` is set - (we're inside an instance method), `This` synthesizes `UserDefined T`; - otherwise an error is emitted and the type collapses to `Unknown`. -/ + (we're inside an instance method, populated on `ResolveState` by + `resolveInstanceProcedure` for the duration of an instance method body), + `This` synthesizes `UserDefined T`. With it, `this.field` and + instance-method dispatch synthesize real types instead of being + wildcarded through `Unknown`. Otherwise an error is emitted ("'this' + is not allowed outside instance methods") and the type collapses to + `Unknown` to suppress cascading errors. -/ def synthThis (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let s ← get @@ -1364,10 +1439,25 @@ def synthAll (source : Option FileRange) : StmtExpr × HighTypeMd := -- ### ContractOf /-- Rules **ContractOf-Bool** / **ContractOf-Set** / **ContractOf-Error**: - `fn` must be a direct identifier reference resolving to a procedure; - anything else is ill-formed (a contract belongs to a *named* procedure). - Pre/postconditions are propositions (`TBool`); reads/modifies are sets of - heap references with element type `Unknown` for now. -/ + `ContractOf ty fn` extracts a procedure's contract clause as a value: + its preconditions (`Precondition`), postconditions (`PostCondition`), + reads set (`Reads`), or modifies set (`Modifies`). `fn` must be a + direct identifier reference resolving to a procedure — a contract + belongs to a *named* procedure, not an arbitrary expression. Anything + else fires the diagnostic *"'contractOf' expected a procedure + reference"* and the construct synthesizes `Unknown` to suppress + cascading errors. + + `Precondition` and `PostCondition` are propositions, hence `TBool`. + `Reads` and `Modifies` are sets of heap-allocated locations — + composite/datatype references and fields. The element type is left as + `Unknown` for now since the rule doesn't yet recover it from `fn`'s + declared modifies/reads clauses. + + The constructor is reserved for future use — Laurel's grammar has no + `contractOf` production today, and the translator emits "not yet + implemented" for it. The typing rule exists so resolution remains + exhaustive over `StmtExpr`. -/ def synthContractOf (exprMd : StmtExprMd) (ty : ContractType) (fn : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .ContractOf ty fn) : @@ -1412,9 +1502,18 @@ def synthHole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange /-- Rule **Hole-None-Check**: an untyped hole in check mode records the expected type on the node so downstream passes don't have to infer it - again. The subsumption check is trivial (`Unknown <: T` always holds), so - this rule never fails — it just preserves the type information available - at the check-mode boundary. -/ + again. The subsumption check is trivial (`Unknown <: T` always holds), + so this rule never fails — it just preserves the type information + available at the check-mode boundary instead of discarding it. + + A separate `InferHoleTypes` pass still runs after resolution to + annotate holes that ended up in synth-only positions. When that pass + encounters a hole whose type was already set (by `[⇐] Hole-None` or by + a user-written `?: T`), it checks the resolution-time and + inference-time types for consistency under `~`; a disagreement fires + the diagnostic *"hole annotated with 'T_resolution' but context + expects 'T_inference'"*, surfacing what would otherwise be a silent + overwrite. -/ def checkHoleNone (det : Bool) (expected : HighTypeMd) (source : Option FileRange) : StmtExprMd := { val := .Hole det (some expected), source := source } diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 34b5772920..7583d4d079 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -165,70 +165,31 @@ There are two operations on expressions, written here in standard bidirectional ``` Synthesis returns a type inferred from the expression itself; checking verifies that the -expression has a given expected type. Each construct picks a mode based on whether its type -is determined locally (synth) or by context (check). The two judgments are connected by a -single change-of-direction rule, *subsumption*: +expression has a given expected type. Each construct picks a mode based on whether its +type is determined locally (synth) or by context (check). The two judgments are connected +by a single change-of-direction rule, *subsumption*: $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` -Subsumption is the *only* place the checker switches from check to synth mode. It fires as -the default fallback in -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr` for every construct without a bespoke -check rule: synthesize the expression's type, then verify the result is a subtype of the -expected type. Bespoke check rules push the expected type *into* subexpressions instead of -bouncing through synthesis, which keeps error messages localized and lets the expected type -propagate through nested control flow. - -`synthStmtExpr` and `checkStmtExpr` are mutually recursive: synth rules invoke check on -subexpressions whose expected type is known (e.g. `cond ⇐ TBool` in -{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse`), and `checkStmtExpr` falls back to -`synthStmtExpr` via \[⇐\] Sub. Termination uses a lexicographic measure `(exprMd, tag)` -where the tag is `0` for synth and `1` for check; any descent into a strict subterm -decreases via `Prod.Lex.left`, while \[⇐\] Sub calls synth on the *same* expression and -decreases via -`Prod.Lex.right`. This is the standard well-founded encoding for bidirectional systems. - -There is also a thin `resolveStmtExpr` wrapper that calls `synthStmtExpr` and discards the -synthesized type. It's used at sites where typing is not enforced (verification annotations, -modifies/reads clauses). The right principle for new call sites is: when the position has a -known expected type ({name Strata.Laurel.HighType.TBool}`TBool` for conditions, numeric for -`decreases`, the declared output for a constant initializer or a functional body), use -`checkStmtExpr`. When it doesn't, use `resolveStmtExpr`. `synthStmtExpr` itself is mostly an -internal interface used by other rules. +The two judgments are implemented as +{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`: + +{docstring Strata.Laurel.synthStmtExpr} + +{docstring Strata.Laurel.checkStmtExpr} ### Gradual typing -The relation `<:` (used in \[⇐\] Sub) is built from three Lean functions: - -- `isSubtype` — pure subtyping. Walks the `extending` chain for - {name Strata.Laurel.CompositeType}`CompositeType` (via - {name Strata.Laurel.TypeContext.ancestors}`TypeContext.ancestors`), unfolds - {name Strata.Laurel.TypeAlias}`TypeAlias` to its target, and unwraps - {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base (both via - {name Strata.Laurel.TypeContext.unfold}`TypeContext.unfold`), then falls back to - structural equality via {name Strata.Laurel.highEq}`highEq`. -- `isConsistent` — the symmetric gradual relation `~` (Siek–Taha): - {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type and is consistent with - everything; otherwise structural equality. -- `isConsistentSubtype` — defined as `isConsistent ∨ isSubtype`. For our flat lattice this - is the standard collapse of `∃R. T ~ R ∧ R <: U`. - -\[⇐\] Sub (and every bespoke check rule) uses `isConsistentSubtype`. That single choice is what -makes the system *gradual*: an expression of type -{name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) -flows freely into any typed slot, and any expression flows freely into a slot of type -{name Strata.Laurel.HighType.Unknown}`Unknown`. Strict checking is applied between -fully-known types only. The symmetric `isConsistent` is used directly by \[⇒\] Op-Eq, where -the operand types must be mutually consistent (no subtype direction is privileged). - -A previous iteration was synth-only with two *bivariantly-compatible* wildcards: -{name Strata.Laurel.HighType.Unknown}`Unknown` and -{name Strata.Laurel.HighType.UserDefined}`UserDefined`. The -{name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was load-bearing: no -assignment, call argument, or comparison involving a user type was ever rejected. The -bidirectional design retires that carve-out — user-defined types are now a regular -participant in `<:`, with `isSubtype` walking inheritance chains and unwrapping aliases -and constrained types to deliver real checking on user-defined code. +The relation `<:` (used in \[⇐\] Sub) is built from three Lean functions — +{name Strata.Laurel.isSubtype}`isSubtype`, {name Strata.Laurel.isConsistent}`isConsistent`, +and {name Strata.Laurel.isConsistentSubtype}`isConsistentSubtype`: + +{docstring Strata.Laurel.isSubtype} + +{docstring Strata.Laurel.isConsistent} + +{docstring Strata.Laurel.isConsistentSubtype} Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This includes {name Strata.Laurel.StmtExpr.Return}`Return`, @@ -270,64 +231,64 @@ suffix is dropped in favor of the prefix. - *ContractOf* — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error - *Holes* — \[⇒\] Hole-Some, \[⇒\] Hole-None, \[⇐\] Hole-None +Each LaTeX rule below is followed by the docstring of the helper that implements it +(grouped when one helper covers multiple rules). + ### Subsumption $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` -Fallback in `checkStmtExpr` whenever no bespoke check rule applies. +Fallback in {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` whenever no bespoke check +rule applies. ### Literals $$`\frac{}{\Gamma \vdash \mathsf{LiteralInt}\;n \Rightarrow \mathsf{TInt}} \quad \text{([⇒] Lit-Int)}` +{docstring Strata.Laurel.synthLitInt} + $$`\frac{}{\Gamma \vdash \mathsf{LiteralBool}\;b \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Lit-Bool)}` +{docstring Strata.Laurel.synthLitBool} + $$`\frac{}{\Gamma \vdash \mathsf{LiteralString}\;s \Rightarrow \mathsf{TString}} \quad \text{([⇒] Lit-String)}` +{docstring Strata.Laurel.synthLitString} + $$`\frac{}{\Gamma \vdash \mathsf{LiteralDecimal}\;d \Rightarrow \mathsf{TReal}} \quad \text{([⇒] Lit-Decimal)}` +{docstring Strata.Laurel.synthLitDecimal} + ### Variables $$`\frac{\Gamma(x) = T}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Local}\;x) \Rightarrow T} \quad \text{([⇒] Var-Local)}` +{docstring Strata.Laurel.synthVarLocal} + $$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Field}\;e\;f) \Rightarrow T_f} \quad \text{([⇒] Var-Field)}` -Resolution looks `f` up against the type of `e` (or the enclosing instance type for -`self.f`); the typing rule itself is path-agnostic. +{docstring Strata.Laurel.synthVarField} $$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T\rangle) \Rightarrow \mathsf{TVoid} \dashv \Gamma, x : T} \quad \text{([⇒] Var-Declare)}` `⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the remainder of the enclosing scope. +{docstring Strata.Laurel.synthVarDeclare} + ### Control flow $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow \mathsf{TVoid}}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-NoElse)}` -The construct synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no -value when `cond` is false; the then-branch is checked against -{name Strata.Laurel.HighType.TVoid}`TVoid` so `x : int := if c then 5` is rejected at the -branch rather than slipping through to a downstream subsumption. - $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t \sqcup T_e} \quad \text{([⇒] If)}` -The result is the join (least upper bound) of the two branch types, so -`if c then small else big` synthesizes the common supertype rather than committing to one -branch arbitrarily. The join walks `extending` chains for composites; when no common -supertype exists (e.g. a value branch paired with a `TVoid` `return`/`exit`), it falls -back to `T_t` and the enclosing context's check (\[⇐\] Sub, or a containing -`checkSubtype` like an assignment) surfaces any mismatch downstream against the -then-branch's type. +{docstring Strata.Laurel.synthIfThenElse} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \Gamma \vdash \mathit{elseBr} \Leftarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Leftarrow T} \quad \text{([⇐] If)}` $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Leftarrow T} \quad \text{([⇐] If-NoElse)}` -Check mode pushes `T` into both branches (rather than going through \[⇒\] If + \[⇐\] Sub at -the boundary). Errors fire at the offending branch instead of the surrounding `if`. -Without an else branch, the construct can only succeed when `T` admits -{name Strata.Laurel.HighType.TVoid}`TVoid` — the same subsumption check `\[⇐\] Block-Empty` -performs for an empty block. +{docstring Strata.Laurel.checkIfThenElse} $$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block)}` @@ -336,79 +297,56 @@ predecessor and may itself extend it (`Var (.Declare …)` does); `s_n` is typed `Γ_{n-1}`. Bindings introduced inside the block don't escape — `Γ` is what surrounds the block. -Non-last statements are synthesized but their types discarded (the lax rule). This matches -Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` -is silently accepted; flagging it belongs to a lint. - $$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Block-Empty)}` -$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` +{docstring Strata.Laurel.synthBlock} -Pushes `T` into the *last* statement rather than comparing the block's synthesized type at -the boundary. Errors fire at the offending subexpression, and `T` keeps propagating through -nested {name Strata.Laurel.StmtExpr.Block}`Block` / -{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / -{name Strata.Laurel.StmtExpr.Hole}`Hole` / -{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. +$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` $$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Empty)}` +{docstring Strata.Laurel.checkBlock} + $$`\frac{}{\Gamma \vdash \mathsf{Exit}\;\mathit{target} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Exit)}` -`Return` matches the optional return value against the enclosing procedure's declared -outputs. The expected output types are threaded through -{name Strata.Laurel.ResolveState}`ResolveState`'s `expectedReturnTypes`, set from -`proc.outputs` by {name Strata.Laurel.resolveProcedure}`resolveProcedure` / -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` for the duration of -the body. `none` means "no enclosing procedure" — e.g. resolving a constant initializer — -and skips all `Return` checks. +{docstring Strata.Laurel.synthExit} $$`\frac{}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-None)}` -A bare `return;` is allowed in any context. In a single-output procedure it acts as a -Dafny-style early exit — the output parameter retains whatever was last assigned to it. - $$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T] \quad \Gamma \vdash e \Leftarrow T}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-Some)}` -In a single-output procedure, the value is checked against the declared output type. This -closes the prior soundness gap where `return 0` in a `bool`-returning procedure went -uncaught. - $$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = []}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “void procedure cannot return a value”}} \quad \text{([⇒] Return-Void-Error)}` $$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇒] Return-Multi-Error)}` -Multi-output procedures use named-output assignment (`r := …` on the declared output -parameters). `return e` syntactically takes a single -{name Strata.Laurel.StmtExpr.Return}`Option StmtExpr`, so it cannot carry multiple values; -flagging it points users at the named-output convention. +{docstring Strata.Laurel.synthReturn} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{dec} \Leftarrow {?} \quad \Gamma \vdash \mathit{body} \Rightarrow \_}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{dec}\;\mathit{body} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] While)}` -`dec` (the optional decreases clause) is resolved without a type check today; the intended -target is a numeric type. +{docstring Strata.Laurel.synthWhile} ### Verification statements $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assert)}` +{docstring Strata.Laurel.synthAssert} + $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assume}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assume)}` +{docstring Strata.Laurel.synthAssume} + ### Assignment $$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Rightarrow T_e \quad T_e <: \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assign)}` where `ExpectedTy = T_1` if `|targets| = 1` and `MultiValuedExpr [T_1; …; T_n]` otherwise. - The target's declared type `T_i` comes from the variable's scope entry (for {name Strata.Laurel.Variable.Local}`Local` and {name Strata.Laurel.Variable.Field}`Field`) -or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. Both -single- and multi-target forms collapse into one tuple-vs-tuple check: when the RHS is a -{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr`, both arity and per-position -type mismatches surface in a single diagnostic of shape *"expected '(int, int, int)', got -'(int, string)'"*. When the RHS is {name Strata.Laurel.HighType.TVoid}`TVoid` (a -side-effecting statement: `while`, `return`, …), all checks are skipped — there's no value -to assign. +or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. + +{docstring Strata.Laurel.synthAssign} + +{docstring Strata.Laurel.checkAssign} ### Calls @@ -416,8 +354,12 @@ $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` +{docstring Strata.Laurel.synthStaticCall} + $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Instance-Call)}` +{docstring Strata.Laurel.synthInstanceCall} + ### Primitive operations `Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, @@ -430,106 +372,88 @@ $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \mathit $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad T_l \sim T_r \quad \mathit{op} \in \{\mathsf{Eq}, \mathsf{Neq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;[\mathit{lhs}; \mathit{rhs}] \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Eq)}` -`~` is the consistency relation `isConsistent` — symmetric, with the -{name Strata.Laurel.HighType.Unknown}`Unknown` wildcard. - $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \Gamma \vdash \mathit{args}.\mathsf{head} \Rightarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` -"Result is the type of the first argument" handles `int + int → int`, `real + real → real`, -etc. without unification. Known relaxation: `int + real` passes (each operand individually -passes `Numeric`); a proper fix needs numeric promotion or unification. - $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` +{docstring Strata.Laurel.synthPrimitiveOp} + ### Object forms $$`\frac{\Gamma(\mathit{ref}) \text{ is a composite or datatype } T}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] New-Ok)}` $$`\frac{\Gamma(\mathit{ref}) \text{ is not a composite or datatype}}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] New-Fallback)}` +{docstring Strata.Laurel.synthNew} + $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{AsType}\;\mathit{target}\;T \Rightarrow T} \quad \text{([⇒] AsType)}` -`target` is resolved but not checked against `T` — the cast is the user's claim. +{docstring Strata.Laurel.synthAsType} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` +{docstring Strata.Laurel.synthIsType} + $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad \mathsf{isReference}\;T_l \quad \mathsf{isReference}\;T_r \quad T_l \sim T_r}{\Gamma \vdash \mathsf{ReferenceEquals}\;\mathit{lhs}\;\mathit{rhs} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] RefEq)}` `isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined` -or {name Strata.Laurel.HighType.Unknown}`Unknown` -type. Reference equality is meaningless on primitives. The operands must also be -consistent under `~` (Siek–Taha consistency), matching the rule applied by -{name Strata.Laurel.Operation.Eq}`==`: two distinct user-defined types like `Cat` and -`Dog` are rejected, while either side being `Unknown` is accepted as a gradual escape -hatch. +or {name Strata.Laurel.HighType.Unknown}`Unknown` type. `~` is the consistency relation +{name Strata.Laurel.isConsistent}`isConsistent` — symmetric, with the +{name Strata.Laurel.HighType.Unknown}`Unknown` wildcard. + +{docstring Strata.Laurel.synthRefEq} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow T_t \quad \Gamma(f) = T_f \quad \Gamma \vdash \mathit{newVal} \Leftarrow T_f}{\Gamma \vdash \mathsf{PureFieldUpdate}\;\mathit{target}\;f\;\mathit{newVal} \Rightarrow T_t} \quad \text{([⇒] PureFieldUpdate)}` -`f` is resolved against `T_t` (or the enclosing instance type) and `newVal` is checked -against the field's declared type. +{docstring Strata.Laurel.synthPureFieldUpdate} ### Verification expressions $$`\frac{\Gamma, x : T \vdash \mathit{body} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Quantifier}\;\mathit{mode}\;\langle x, T\rangle\;\mathit{trig}\;\mathit{body} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Quantifier)}` -The bound variable `x : T` is introduced in scope only for the body (and trigger). The body -is checked against {name Strata.Laurel.HighType.TBool}`TBool` since a quantifier is a -proposition; without this, `forall x: int :: x + 1` would be silently accepted. +{docstring Strata.Laurel.synthQuantifier} $$`\frac{\Gamma \vdash \mathit{name} \Rightarrow \_}{\Gamma \vdash \mathsf{Assigned}\;\mathit{name} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Assigned)}` +{docstring Strata.Laurel.synthAssigned} + $$`\frac{\Gamma \vdash v \Rightarrow T}{\Gamma \vdash \mathsf{Old}\;v \Rightarrow T} \quad \text{([⇒] Old)}` +{docstring Strata.Laurel.synthOld} + $$`\frac{\Gamma \vdash v \Rightarrow T \quad \mathsf{isReference}\;T}{\Gamma \vdash \mathsf{Fresh}\;v \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Fresh)}` -`isReference T` is the same predicate as in {name Strata.Laurel.StmtExpr.ReferenceEquals}`ReferenceEquals`. -{name Strata.Laurel.StmtExpr.Fresh}`Fresh` only makes sense on heap-allocated references; -`fresh(5)` is rejected. +{docstring Strata.Laurel.synthFresh} $$`\frac{\Gamma \vdash v \Rightarrow T \quad \Gamma \vdash \mathit{proof} \Rightarrow \_}{\Gamma \vdash \mathsf{ProveBy}\;v\;\mathit{proof} \Rightarrow T} \quad \text{([⇒] ProveBy)}` +{docstring Strata.Laurel.synthProveBy} + ### Self reference $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{some}\;T}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] This-Inside)}` $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{none}}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{Unknown}\;\;[\text{emits “‘this’ is not allowed outside instance methods”}]} \quad \text{([⇒] This-Outside)}` -`Γ.instanceTypeName` is the -{name Strata.Laurel.ResolveState}`ResolveState` field set by -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` for the duration of -an instance method body. With it, `this.field` and instance-method dispatch synthesize real -types instead of being wildcarded through {name Strata.Laurel.HighType.Unknown}`Unknown`. +{docstring Strata.Laurel.synthThis} ### Untyped forms $$`\frac{}{\Gamma \vdash \mathsf{Abstract}\;/\;\mathsf{All}\;\ldots \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Abstract / All)}` -### ContractOf +{docstring Strata.Laurel.synthAbstract} -`ContractOf ty fn` extracts a procedure's contract clause as a value: its preconditions -(`Precondition`), postconditions (`PostCondition`), reads set (`Reads`), or modifies set -(`Modifies`). `fn` must be a direct identifier reference to a procedure — a contract belongs -to a *named* procedure, not an arbitrary expression. +{docstring Strata.Laurel.synthAll} + +### ContractOf $$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Precondition}\;\mathit{fn} \Rightarrow \mathsf{TBool} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{PostCondition}\;\mathit{fn} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] ContractOf-Bool)}` $$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Reads}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{Modifies}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown}} \quad \text{([⇒] ContractOf-Set)}` -`Precondition` and `PostCondition` are propositions, hence -{name Strata.Laurel.HighType.TBool}`TBool`. `Reads` and `Modifies` are sets of heap-allocated -locations — composite/datatype references and fields. The element type is left as -{name Strata.Laurel.HighType.Unknown}`Unknown` for now since the rule doesn't yet recover it -from `fn`'s declared modifies/reads clauses. - $$`\frac{\mathit{fn} \text{ is not a procedure reference}}{\Gamma \vdash \mathsf{ContractOf}\;\ldots\;\mathit{fn} \rightsquigarrow \text{error: “‘contractOf’ expected a procedure reference”}} \quad \text{([⇒] ContractOf-Error)}` -When `fn` doesn't resolve to a procedure (e.g. it's an arbitrary expression, or resolves to -a constant/variable), the diagnostic fires and the construct synthesizes -{name Strata.Laurel.HighType.Unknown}`Unknown` to suppress cascading errors. - -The constructor is reserved for future use — Laurel's grammar has no `contractOf` -production today, and the translator emits "not yet implemented" for it. The typing rule -exists so resolution remains exhaustive over `StmtExpr`. +{docstring Strata.Laurel.synthContractOf} ### Holes @@ -537,19 +461,11 @@ $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T) \Rightarrow T} \qu $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Hole-None)}` +{docstring Strata.Laurel.synthHole} + $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \;\;\mapsto\;\; \mathsf{Hole}\;d\;(\mathsf{some}\;T)} \quad \text{([⇐] Hole-None)}` -In check mode, an untyped hole records the expected type `T` on the node directly. The -subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it -just preserves the type information that's available at the check-mode boundary instead of -discarding it. - -A separate `InferHoleTypes` pass still runs after resolution to annotate holes that ended -up in synth-only positions. When that pass encounters a hole whose type was already set -(by \[⇐\] Hole-None or by a user-written `?: T`), it checks the resolution-time and -inference-time types for consistency under `~`; a disagreement fires the diagnostic -*"hole annotated with 'T_resolution' but context expects 'T_inference'"*, surfacing what -would otherwise be a silent overwrite. +{docstring Strata.Laurel.checkHoleNone} ## Future structural changes From fbb5c81c0620bb9f05728dc3de0a643e15584506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 14:16:41 -0400 Subject: [PATCH 057/189] namespace scoping to make code less verbose --- Strata/Languages/Laurel/Resolution.lean | 307 ++++++++++++------------ docs/verso/LaurelDoc.lean | 90 +++---- 2 files changed, 201 insertions(+), 196 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 970e32d0ad..080142d15c 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -38,7 +38,7 @@ Walks the AST under `ResolveM`, a state monad over `ResolveState`. Phase 1: - opens fresh nested scopes via `withScope` for blocks, quantifiers, procedure bodies, and constrained-type constraint/witness expressions, - synthesizes a `HighType` for every `StmtExpr` and checks it (via - `checkStmtExpr` for fresh subexpressions, or `checkSubtype` when a type is + `Check.resolveStmtExpr` for fresh subexpressions, or `checkSubtype` when a type is already in hand) on assignments, call arguments, condition positions, functional bodies, and constant initializers. @@ -466,7 +466,7 @@ private def typeMismatch (source : Option FileRange) (construct : Option StmtExp /-- Type-level subtype check: emits the standard "expected/got" diagnostic when `actual` is not a consistent subtype of `expected`. Used at sites where the actual type is already in hand (assignment, call args, body vs declared - output) — equivalent to `checkStmtExpr e expected` but without re-synthesizing. -/ + output) — equivalent to `Check.resolveStmtExpr e expected` but without re-synthesizing. -/ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do let ctx := (← get).typeContext unless isConsistentSubtype ctx actual expected do @@ -532,26 +532,28 @@ Each typing rule from the Laurel manual is implemented as its own helper inside the mutual block below. Helpers are grouped by section to mirror the *Typing rules* index in `LaurelDoc.lean`: -- Literals — `synthLitInt`, `synthLitBool`, `synthLitString`, `synthLitDecimal` -- Variables — `synthVarLocal`, `synthVarField`, `synthVarDeclare` -- Control flow — `synthIfThenElse`, `synthBlock`, `synthWhile`, `synthExit`, - `synthReturn`, `checkBlock`, `checkIfThenElse` -- Verification statements — `synthAssert`, `synthAssume` -- Assignment — `synthAssign` -- Calls — `synthStaticCall`, `synthInstanceCall` -- Primitive operations — `synthPrimitiveOp` -- Object forms — `synthNew`, `synthAsType`, `synthIsType`, `synthRefEq`, - `synthPureFieldUpdate` -- Verification expressions — `synthQuantifier`, `synthAssigned`, `synthOld`, - `synthFresh`, `synthProveBy` -- Self reference — `synthThis` -- Untyped forms — `synthAbstract`, `synthAll` -- ContractOf — `synthContractOf` -- Holes — `synthHole`, `checkHoleNone` - -The dispatch functions `synthStmtExpr` and `checkStmtExpr` simply pattern-match +- Literals — `Synth.litInt`, `Synth.litBool`, `Synth.litString`, `Synth.litDecimal` +- Variables — `Synth.varLocal`, `Synth.varField`, `Synth.varDeclare` +- Control flow — `Synth.ifThenElse`, `Synth.block`, `Synth.while`, `Synth.exit`, + `Synth.return`, `Check.block`, `Check.ifThenElse` +- Verification statements — `Synth.assert`, `Synth.assume` +- Assignment — `Synth.assign`, `Check.assign` +- Calls — `Synth.staticCall`, `Synth.instanceCall` +- Primitive operations — `Synth.primitiveOp` +- Object forms — `Synth.new`, `Synth.asType`, `Synth.isType`, `Synth.refEq`, + `Synth.pureFieldUpdate` +- Verification expressions — `Synth.quantifier`, `Synth.assigned`, `Synth.old`, + `Synth.fresh`, `Synth.proveBy` +- Self reference — `Synth.this` +- Untyped forms — `Synth.abstract`, `Synth.all` +- ContractOf — `Synth.contractOf` +- Holes — `Synth.hole`, `Check.holeNone` + +The dispatch functions `Synth.resolveStmtExpr` and `Check.resolveStmtExpr` simply pattern-match on the constructor and delegate to the corresponding helper. -/ +namespace Resolution + -- The `h : exprMd.val = .Foo args ...` parameters on the recursive helpers -- look unused to the linter, but each one is referenced by that helper's -- `decreasing_by` tactic to relate `sizeOf args` to `sizeOf exprMd`. @@ -564,74 +566,74 @@ mutual written `Γ ⊢ e ⇒ T`. Each constructor delegates to its rule's helper. Synthesis returns a type inferred from the expression itself; checking - (`checkStmtExpr`) verifies that the expression has a given expected + (`Check.resolveStmtExpr`) verifies that the expression has a given expected type. Each construct picks a mode based on whether its type is determined locally (synth) or by context (check). Synth rules invoke check on subexpressions whose expected type is known (e.g. - `cond ⇐ TBool` in `IfThenElse`); `checkStmtExpr` falls back to - `synthStmtExpr` via subsumption (rule `[⇐] Sub`). The two functions + `cond ⇐ TBool` in `IfThenElse`); `Check.resolveStmtExpr` falls back to + `Synth.resolveStmtExpr` via subsumption (rule `[⇐] Sub`). The two functions are mutually recursive, with termination on a lexicographic measure `(exprMd, tag)` — tag `0` for check, `1` for synth — so that subsumption (which calls synth on the *same* expression) can decrease via `Prod.Lex.right`. -/ -def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do +def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match h_node: exprMd with | AstNode.mk expr source => let (val', ty) ← match h_expr: expr with | .IfThenElse cond thenBr elseBr => - synthIfThenElse exprMd cond thenBr elseBr (by rw [h_node]) + Synth.ifThenElse exprMd cond thenBr elseBr (by rw [h_node]) | .Block stmts label => - synthBlock exprMd stmts label (by rw [h_node]) + Synth.block exprMd stmts label (by rw [h_node]) | .While cond invs dec body => - synthWhile exprMd cond invs dec body (by rw [h_node]) - | .Exit target => pure (synthExit target source) + Synth.while exprMd cond invs dec body (by rw [h_node]) + | .Exit target => pure (Synth.exit target source) | .Return val => - synthReturn exprMd source val (by rw [h_node]) - | .LiteralInt v => pure (synthLitInt v source) - | .LiteralBool v => pure (synthLitBool v source) - | .LiteralString v => pure (synthLitString v source) - | .LiteralDecimal v => pure (synthLitDecimal v source) - | .Var (.Local ref) => synthVarLocal ref source - | .Var (.Declare param) => synthVarDeclare param source + Synth.return exprMd source val (by rw [h_node]) + | .LiteralInt v => pure (Synth.litInt v source) + | .LiteralBool v => pure (Synth.litBool v source) + | .LiteralString v => pure (Synth.litString v source) + | .LiteralDecimal v => pure (Synth.litDecimal v source) + | .Var (.Local ref) => Synth.varLocal ref source + | .Var (.Declare param) => Synth.varDeclare param source | .Var (.Field target fieldName) => - synthVarField exprMd target fieldName source (by rw [h_node]) + Synth.varField exprMd target fieldName source (by rw [h_node]) | .Assign targets value => - synthAssign exprMd targets value source (by rw [h_node]) + Synth.assign exprMd targets value source (by rw [h_node]) | .PureFieldUpdate target fieldName newVal => - synthPureFieldUpdate exprMd target fieldName newVal (by rw [h_node]) + Synth.pureFieldUpdate exprMd target fieldName newVal (by rw [h_node]) | .StaticCall callee args => - synthStaticCall exprMd callee args source (by rw [h_node]) + Synth.staticCall exprMd callee args source (by rw [h_node]) | .PrimitiveOp op args => - synthPrimitiveOp exprMd expr op args source h_expr (by rw [h_node]) - | .New ref => synthNew ref source - | .This => synthThis source + Synth.primitiveOp exprMd expr op args source h_expr (by rw [h_node]) + | .New ref => Synth.new ref source + | .This => Synth.this source | .ReferenceEquals lhs rhs => - synthRefEq exprMd expr lhs rhs source h_expr (by rw [h_node]) + Synth.refEq exprMd expr lhs rhs source h_expr (by rw [h_node]) | .AsType target ty => - synthAsType exprMd target ty (by rw [h_node]) + Synth.asType exprMd target ty (by rw [h_node]) | .IsType target ty => - synthIsType exprMd target ty source (by rw [h_node]) + Synth.isType exprMd target ty source (by rw [h_node]) | .InstanceCall target callee args => - synthInstanceCall exprMd target callee args source (by rw [h_node]) + Synth.instanceCall exprMd target callee args source (by rw [h_node]) | .Quantifier mode param trigger body => - synthQuantifier exprMd mode param trigger body source (by rw [h_node]) + Synth.quantifier exprMd mode param trigger body source (by rw [h_node]) | .Assigned name => - synthAssigned exprMd name source (by rw [h_node]) + Synth.assigned exprMd name source (by rw [h_node]) | .Old val => - synthOld exprMd val (by rw [h_node]) + Synth.old exprMd val (by rw [h_node]) | .Fresh val => - synthFresh exprMd expr val source h_expr (by rw [h_node]) + Synth.fresh exprMd expr val source h_expr (by rw [h_node]) | .Assert ⟨condExpr, summary⟩ => - synthAssert exprMd condExpr summary source (by rw [h_node]) + Synth.assert exprMd condExpr summary source (by rw [h_node]) | .Assume cond => - synthAssume exprMd cond source (by rw [h_node]) + Synth.assume exprMd cond source (by rw [h_node]) | .ProveBy val proof => - synthProveBy exprMd val proof (by rw [h_node]) + Synth.proveBy exprMd val proof (by rw [h_node]) | .ContractOf ty fn => - synthContractOf exprMd ty fn source (by rw [h_node]) - | .Abstract => pure (synthAbstract source) - | .All => pure (synthAll source) - | .Hole det type => synthHole det type source + Synth.contractOf exprMd ty fn source (by rw [h_node]) + | .Abstract => pure (Synth.abstract source) + | .All => pure (Synth.all source) + | .Hole det type => Synth.hole det type source return ({ val := val', source := source }, ty) termination_by (exprMd, 2) decreasing_by all_goals first @@ -651,25 +653,25 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := The right principle for new call sites is: when the position has a known expected type (`TBool` for conditions, numeric for `decreases`, the declared output for a constant initializer or a functional body), - use `checkStmtExpr`. When it doesn't, use `resolveStmtExpr` (a thin - wrapper that calls `synthStmtExpr` and discards the synthesized type, + use `Check.resolveStmtExpr`. When it doesn't, use `resolveStmtExpr` (a thin + wrapper that calls `Synth.resolveStmtExpr` and discards the synthesized type, used at sites where typing is not enforced — verification annotations, - modifies/reads clauses). `synthStmtExpr` itself is mostly an internal + modifies/reads clauses). `Synth.resolveStmtExpr` itself is mostly an internal interface used by other rules. -/ -def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do +def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do match h_node: exprMd with | AstNode.mk expr source => match h_expr: expr with | .Block stmts label => - checkBlock exprMd stmts label expected source (by rw [h_node]) + Check.block exprMd stmts label expected source (by rw [h_node]) | .IfThenElse cond thenBr elseBr => - checkIfThenElse exprMd cond thenBr elseBr expected source (by rw [h_node]) + Check.ifThenElse exprMd cond thenBr elseBr expected source (by rw [h_node]) | .Assign targets value => - checkAssign exprMd targets value expected source (by rw [h_node]) - | .Hole det none => pure (checkHoleNone det expected source) + Check.assign exprMd targets value expected source (by rw [h_node]) + | .Hole det none => pure (Check.holeNone det expected source) | _ => -- Subsumption fallback: synth then check `actual <: expected`. - let (e', actual) ← synthStmtExpr exprMd + let (e', actual) ← Synth.resolveStmtExpr exprMd checkSubtype source expected actual pure e' termination_by (exprMd, 3) @@ -682,26 +684,26 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE -- ### Literals /-- Rule **Lit-Int**: `Γ ⊢ LiteralInt n ⇒ TInt`. -/ -def synthLitInt (v : Int) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.litInt (v : Int) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralInt v, { val := .TInt, source := source }) /-- Rule **Lit-Bool**: `Γ ⊢ LiteralBool b ⇒ TBool`. -/ -def synthLitBool (v : Bool) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.litBool (v : Bool) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralBool v, { val := .TBool, source := source }) /-- Rule **Lit-String**: `Γ ⊢ LiteralString s ⇒ TString`. -/ -def synthLitString (v : String) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.litString (v : String) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralString v, { val := .TString, source := source }) /-- Rule **Lit-Decimal**: `Γ ⊢ LiteralDecimal d ⇒ TReal`. -/ -def synthLitDecimal (v : Decimal) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.litDecimal (v : Decimal) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralDecimal v, { val := .TReal, source := source }) -- ### Variables /-- Rule **Var-Local**: `Γ(x) = T ⊢ Var (.Local x) ⇒ T`. Resolves `ref` against the lexical scope and reads its declared type. -/ -def synthVarLocal (ref : Identifier) (source : Option FileRange) : +def Synth.varLocal (ref : Identifier) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let ref' ← resolveRef ref source let ty ← getVarType ref @@ -709,7 +711,7 @@ def synthVarLocal (ref : Identifier) (source : Option FileRange) : /-- Rule **Var-Declare**: extends the surrounding scope with `x : T` and synthesizes `TVoid` (the declaration itself produces no value). -/ -def synthVarDeclare (param : Parameter) (source : Option FileRange) : +def Synth.varDeclare (param : Parameter) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') @@ -718,11 +720,11 @@ def synthVarDeclare (param : Parameter) (source : Option FileRange) : /-- Rule **Var-Field**: `Γ ⊢ e ⇒ _, Γ(f) = T_f ⊢ Var (.Field e f) ⇒ T_f`. `f` is looked up against the type of `e` (or the enclosing instance type for `self.f`); the typing rule itself is path-agnostic. -/ -def synthVarField (exprMd : StmtExprMd) +def Synth.varField (exprMd : StmtExprMd) (target : StmtExprMd) (fieldName : Identifier) (source : Option FileRange) (h : exprMd.val = .Var (.Field target fieldName)) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source let ty ← getVarType fieldName' pure (.Var (.Field target' fieldName'), ty) @@ -751,19 +753,19 @@ def synthVarField (exprMd : StmtExprMd) type and the enclosing context's check (`[⇐] Sub`, or a containing `checkSubtype` like an assignment) surfaces any mismatch downstream against the then-branch's type. -/ -def synthIfThenElse (exprMd : StmtExprMd) +def Synth.ifThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (h : exprMd.val = .IfThenElse cond thenBr elseBr) : ResolveM (StmtExpr × HighTypeMd) := do - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } let voidTy : HighTypeMd := { val := .TVoid, source := exprMd.source } match elseBr with | none => - let thenBr' ← checkStmtExpr thenBr voidTy + let thenBr' ← Check.resolveStmtExpr thenBr voidTy pure (.IfThenElse cond' thenBr' none, voidTy) | some e => - let (thenBr', thenTy) ← synthStmtExpr thenBr - let (elseBr', elseTy) ← synthStmtExpr e + let (thenBr', thenTy) ← Synth.resolveStmtExpr thenBr + let (elseBr', elseTy) ← Synth.resolveStmtExpr e let ctx := (← get).typeContext pure (.IfThenElse cond' thenBr' (some elseBr'), joinTypes ctx thenTy elseTy) termination_by (exprMd, 1) @@ -784,12 +786,12 @@ def synthIfThenElse (exprMd : StmtExprMd) accepted, flagging it belongs to a lint). The last statement's type becomes the block's type, or `TVoid` for an empty block. The block opens a fresh nested scope, so bindings introduced inside don't escape. -/ -def synthBlock (exprMd : StmtExprMd) +def Synth.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (h : exprMd.val = .Block stmts label) : ResolveM (StmtExpr × HighTypeMd) := do withScope do - let results ← stmts.mapM synthStmtExpr + let results ← stmts.mapM Synth.resolveStmtExpr let stmts' := results.map (·.1) let lastTy := match results.getLast? with | some (_, ty) => ty @@ -807,17 +809,17 @@ def synthBlock (exprMd : StmtExprMd) `decreases` is resolved without a type check today (the intended target is a numeric type), body is synthesized; the construct itself synthesizes `TVoid`. -/ -def synthWhile (exprMd : StmtExprMd) +def Synth.while (exprMd : StmtExprMd) (cond : StmtExprMd) (invs : List StmtExprMd) (dec : Option StmtExprMd) (body : StmtExprMd) (h : exprMd.val = .While cond invs dec body) : ResolveM (StmtExpr × HighTypeMd) := do - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } let invs' ← invs.attach.mapM (fun a => have := a.property; do - checkStmtExpr a.val { val := .TBool, source := a.val.source }) + Check.resolveStmtExpr a.val { val := .TBool, source := a.val.source }) let dec' ← dec.attach.mapM (fun a => have := a.property; do - let (e', _) ← synthStmtExpr a.val; pure e') - let (body', _) ← synthStmtExpr body + let (e', _) ← Synth.resolveStmtExpr a.val; pure e') + let (body', _) ← Synth.resolveStmtExpr body pure (.While cond' invs' dec' body', { val := .TVoid, source := exprMd.source }) termination_by (exprMd, 1) decreasing_by @@ -830,7 +832,7 @@ def synthWhile (exprMd : StmtExprMd) omega /-- Rule **Exit**: `Γ ⊢ Exit target ⇒ TVoid`. -/ -def synthExit (target : String) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.exit (target : String) (source : Option FileRange) : StmtExpr × HighTypeMd := (.Exit target, { val := .TVoid, source := source }) /-- Rules **Return-None** / **Return-Some** / **Return-Void-Error** / @@ -851,15 +853,15 @@ def synthExit (target : String) (source : Option FileRange) : StmtExpr × HighTy declared output parameters); `return e` syntactically takes a single `Option StmtExpr` and cannot carry multiple values, so it is flagged with a diagnostic pointing users at the named-output convention. -/ -def synthReturn (exprMd : StmtExprMd) (source : Option FileRange) +def Synth.return (exprMd : StmtExprMd) (source : Option FileRange) (val : Option StmtExprMd) (h : exprMd.val = .Return val) : ResolveM (StmtExpr × HighTypeMd) := do let expected := (← get).expectedReturnTypes let val' ← val.attach.mapM (fun a => have := a.property; do match expected with - | some [singleOutput] => checkStmtExpr a.val singleOutput - | _ => let (e', _) ← synthStmtExpr a.val; pure e') + | some [singleOutput] => Check.resolveStmtExpr a.val singleOutput + | _ => let (e', _) ← Synth.resolveStmtExpr a.val; pure e') -- Arity/shape diagnostics independent of the value's own type. match val, expected with | none, some [] => pure () @@ -892,21 +894,21 @@ def synthReturn (exprMd : StmtExprMd) (source : Option FileRange) `Quantifier`. Empty blocks reduce to a subsumption check of `TVoid` against `expected` — the same check `[⇐] Block-Empty` performs when `T` admits `TVoid`. -/ -def checkBlock (exprMd : StmtExprMd) +def Check.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do withScope do let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do have : s ∈ stmts := List.dropLast_subset stmts hMem - let (s', _) ← synthStmtExpr s; pure s') + let (s', _) ← Synth.resolveStmtExpr s; pure s') match _lastResult: stmts.getLast? with | none => checkSubtype source expected { val := .TVoid, source := source } pure { val := .Block init' label, source := source } | some last => have := List.mem_of_getLast? _lastResult - let last' ← checkStmtExpr last expected + let last' ← Check.resolveStmtExpr last expected pure { val := .Block (init' ++ [last']) label, source := source } termination_by (exprMd, 0) decreasing_by @@ -924,13 +926,13 @@ def checkBlock (exprMd : StmtExprMd) Without an else branch, the construct can only succeed when `expected` admits `TVoid` — the same subsumption check `[⇐] Block-Empty` performs for an empty block. -/ -def checkIfThenElse (exprMd : StmtExprMd) +def Check.ifThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .IfThenElse cond thenBr elseBr) : ResolveM StmtExprMd := do - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } - let thenBr' ← checkStmtExpr thenBr expected - let elseBr' ← elseBr.attach.mapM (fun ⟨e, _⟩ => checkStmtExpr e expected) + let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } + let thenBr' ← Check.resolveStmtExpr thenBr expected + let elseBr' ← elseBr.attach.mapM (fun ⟨e, _⟩ => Check.resolveStmtExpr e expected) if elseBr.isNone then checkSubtype source expected { val := .TVoid, source := source } pure { val := .IfThenElse cond' thenBr' elseBr', source := source } @@ -947,11 +949,11 @@ def checkIfThenElse (exprMd : StmtExprMd) /-- Rule **Assert**: `cond` is checked against `TBool`; the construct synthesizes `TVoid`. -/ -def synthAssert (exprMd : StmtExprMd) +def Synth.assert (exprMd : StmtExprMd) (condExpr : StmtExprMd) (summary : Option String) (source : Option FileRange) (h : exprMd.val = .Assert ⟨condExpr, summary⟩) : ResolveM (StmtExpr × HighTypeMd) := do - let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } + let cond' ← Check.resolveStmtExpr condExpr { val := .TBool, source := condExpr.source } pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) termination_by (exprMd, 1) decreasing_by @@ -963,11 +965,11 @@ def synthAssert (exprMd : StmtExprMd) /-- Rule **Assume**: `cond` is checked against `TBool`; the construct synthesizes `TVoid`. -/ -def synthAssume (exprMd : StmtExprMd) +def Synth.assume (exprMd : StmtExprMd) (cond : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assume cond) : ResolveM (StmtExpr × HighTypeMd) := do - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } pure (.Assume cond', { val := .TVoid, source := source }) termination_by (exprMd, 1) decreasing_by @@ -991,8 +993,8 @@ def synthAssume (exprMd : StmtExprMd) value to assign. The construct synthesizes the RHS's type, so that expression-position assignments like `x ++ (y := s)` see a string in the second operand; statement-position uses are accommodated by - `checkAssign`, which accepts `TVoid` as the expected type. -/ -def synthAssign (exprMd : StmtExprMd) + `Check.assign`, which accepts `TVoid` as the expected type. -/ +def Synth.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assign targets value) : ResolveM (StmtExpr × HighTypeMd) := do @@ -1003,14 +1005,14 @@ def synthAssign (exprMd : StmtExprMd) let ref' ← resolveRef ref source pure (⟨.Local ref', vs⟩ : VariableMd) | .Field target fieldName => - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source pure (⟨.Field target' fieldName', vs⟩ : VariableMd) | .Declare param => let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let (value', valueTy) ← synthStmtExpr value + let (value', valueTy) ← Synth.resolveStmtExpr value let targetType (t : VariableMd) : ResolveM HighTypeMd := do match t.val with | .Local ref => getVarType ref @@ -1039,7 +1041,7 @@ def synthAssign (exprMd : StmtExprMd) statement of a block in an else-less `if` (whose branch is checked against `TVoid`) without firing a subsumption error against the RHS's type. For non-`TVoid` expected types, falls back to subsumption. -/ -def checkAssign (exprMd : StmtExprMd) +def Check.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Assign targets value) : ResolveM StmtExprMd := do @@ -1050,14 +1052,14 @@ def checkAssign (exprMd : StmtExprMd) let ref' ← resolveRef ref source pure (⟨.Local ref', vs⟩ : VariableMd) | .Field target fieldName => - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source pure (⟨.Field target' fieldName', vs⟩ : VariableMd) | .Declare param => let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let (value', valueTy) ← synthStmtExpr value + let (value', valueTy) ← Synth.resolveStmtExpr value let targetType (t : VariableMd) : ResolveM HighTypeMd := do match t.val with | .Local ref => getVarType ref @@ -1089,13 +1091,13 @@ def checkAssign (exprMd : StmtExprMd) constant); each argument is synthesized and checked against the corresponding parameter type. The result type is the (possibly multi-valued) declared output type from `getCallInfo`. -/ -def synthStaticCall (exprMd : StmtExprMd) +def Synth.staticCall (exprMd : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) (h : exprMd.val = .StaticCall callee args) : ResolveM (StmtExpr × HighTypeMd) := do let callee' ← resolveRef callee source (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) - let results ← args.mapM synthStmtExpr + let results ← args.mapM Synth.resolveStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee @@ -1113,15 +1115,15 @@ def synthStaticCall (exprMd : StmtExprMd) /-- Rule **Instance-Call**: target is synthesized; callee resolves to an instance or static procedure; arguments are checked pairwise against the callee's parameter types after dropping `self`. -/ -def synthInstanceCall (exprMd : StmtExprMd) +def Synth.instanceCall (exprMd : StmtExprMd) (target : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) (h : exprMd.val = .InstanceCall target callee args) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let callee' ← resolveRef callee source (expected := #[.instanceProcedure, .staticProcedure]) - let results ← args.mapM synthStmtExpr + let results ← args.mapM Synth.resolveStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee @@ -1154,13 +1156,13 @@ def synthInstanceCall (exprMd : StmtExprMd) `int + real` passes since each operand individually passes `Numeric`; a proper fix needs numeric promotion or unification), `TString` for concatenation. -/ -def synthPrimitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) +def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) (op : Operation) (args : List StmtExprMd) (source : Option FileRange) (h_expr : expr = .PrimitiveOp op args) (h : exprMd.val = .PrimitiveOp op args) : ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr -- carries the constructor identity for `expr` in diagnostics - let results ← args.mapM synthStmtExpr + let results ← args.mapM Synth.resolveStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let resultTy := match op with @@ -1206,7 +1208,7 @@ def synthPrimitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) /-- Rules **New-Ok** / **New-Fallback**: when `ref` resolves to a composite or datatype, the type is `UserDefined ref`; otherwise `Unknown` (suppresses cascading errors after the kind diagnostic has already fired). -/ -def synthNew (ref : Identifier) (source : Option FileRange) : +def Synth.new (ref : Identifier) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let ref' ← resolveRef ref source (expected := #[.compositeType, .datatypeDefinition]) @@ -1223,11 +1225,11 @@ def synthNew (ref : Identifier) (source : Option FileRange) : cast is the user's claim. The synthesized type is `T`. `IsType` is the runtime test counterpart and synthesizes `TBool`. -/ -def synthAsType (exprMd : StmtExprMd) +def Synth.asType (exprMd : StmtExprMd) (target : StmtExprMd) (ty : HighTypeMd) (h : exprMd.val = .AsType target ty) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let ty' ← resolveHighType ty pure (.AsType target' ty', ty') termination_by (exprMd, 1) @@ -1238,11 +1240,11 @@ def synthAsType (exprMd : StmtExprMd) omega /-- Rule **IsType**: `target` is resolved; the synthesized type is `TBool`. -/ -def synthIsType (exprMd : StmtExprMd) +def Synth.isType (exprMd : StmtExprMd) (target : StmtExprMd) (ty : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .IsType target ty) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let ty' ← resolveHighType ty pure (.IsType target' ty', { val := .TBool, source := source }) termination_by (exprMd, 1) @@ -1259,14 +1261,14 @@ def synthIsType (exprMd : StmtExprMd) user-defined types, while `Cat === Animal` is accepted when `Cat` extends `Animal` (the gradual `Unknown` wildcard makes either side flow freely against the other). -/ -def synthRefEq (exprMd : StmtExprMd) (expr : StmtExpr) +def Synth.refEq (exprMd : StmtExprMd) (expr : StmtExpr) (lhs rhs : StmtExprMd) (source : Option FileRange) (h_expr : expr = .ReferenceEquals lhs rhs) (h : exprMd.val = .ReferenceEquals lhs rhs) : ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr - let (lhs', lhsTy) ← synthStmtExpr lhs - let (rhs', rhsTy) ← synthStmtExpr rhs + let (lhs', lhsTy) ← Synth.resolveStmtExpr lhs + let (rhs', rhsTy) ← Synth.resolveStmtExpr rhs let ctx := (← get).typeContext unless isReference ctx lhsTy do typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy @@ -1289,14 +1291,14 @@ def synthRefEq (exprMd : StmtExprMd) (expr : StmtExpr) `T_t` (or the enclosing instance type), and `newVal` checked against the field's declared type. The synthesized type is `T_t` — updating a field on a pure type produces a new value of the same type. -/ -def synthPureFieldUpdate (exprMd : StmtExprMd) +def Synth.pureFieldUpdate (exprMd : StmtExprMd) (target : StmtExprMd) (fieldName : Identifier) (newVal : StmtExprMd) (h : exprMd.val = .PureFieldUpdate target fieldName newVal) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', targetTy) ← synthStmtExpr target + let (target', targetTy) ← Synth.resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName target.source let fieldTy ← getVarType fieldName' - let newVal' ← checkStmtExpr newVal fieldTy + let newVal' ← Check.resolveStmtExpr newVal fieldTy pure (.PureFieldUpdate target' fieldName' newVal', targetTy) termination_by (exprMd, 1) decreasing_by @@ -1313,7 +1315,7 @@ def synthPureFieldUpdate (exprMd : StmtExprMd) the body against `TBool` since a quantifier is a proposition. Without that body check, `forall x: int :: x + 1` would be silently accepted. The construct itself synthesizes `TBool`. -/ -def synthQuantifier (exprMd : StmtExprMd) +def Synth.quantifier (exprMd : StmtExprMd) (mode : QuantifierMode) (param : Parameter) (trigger : Option StmtExprMd) (body : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Quantifier mode param trigger body) : @@ -1322,8 +1324,8 @@ def synthQuantifier (exprMd : StmtExprMd) let paramTy' ← resolveHighType param.type let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do - let (e', _) ← synthStmtExpr pv.val; pure e') - let body' ← checkStmtExpr body { val := .TBool, source := body.source } + let (e', _) ← Synth.resolveStmtExpr pv.val; pure e') + let body' ← Check.resolveStmtExpr body { val := .TBool, source := body.source } pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) termination_by (exprMd, 1) decreasing_by @@ -1336,11 +1338,11 @@ def synthQuantifier (exprMd : StmtExprMd) /-- Rule **Assigned**: `name` is synthesized; the construct synthesizes `TBool`. -/ -def synthAssigned (exprMd : StmtExprMd) +def Synth.assigned (exprMd : StmtExprMd) (name : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assigned name) : ResolveM (StmtExpr × HighTypeMd) := do - let (name', _) ← synthStmtExpr name + let (name', _) ← Synth.resolveStmtExpr name pure (.Assigned name', { val := .TBool, source := source }) termination_by (exprMd, 1) decreasing_by @@ -1350,11 +1352,11 @@ def synthAssigned (exprMd : StmtExprMd) omega /-- Rule **Old**: `Γ ⊢ v ⇒ T ⊢ Old v ⇒ T`. -/ -def synthOld (exprMd : StmtExprMd) +def Synth.old (exprMd : StmtExprMd) (val : StmtExprMd) (h : exprMd.val = .Old val) : ResolveM (StmtExpr × HighTypeMd) := do - let (val', valTy) ← synthStmtExpr val + let (val', valTy) ← Synth.resolveStmtExpr val pure (.Old val', valTy) termination_by (exprMd, 1) decreasing_by @@ -1367,13 +1369,13 @@ def synthOld (exprMd : StmtExprMd) (`UserDefined` or `Unknown`) — `Fresh` only makes sense on heap-allocated references, so `fresh(5)` is rejected. The construct itself synthesizes `TBool`. -/ -def synthFresh (exprMd : StmtExprMd) (expr : StmtExpr) +def Synth.fresh (exprMd : StmtExprMd) (expr : StmtExpr) (val : StmtExprMd) (source : Option FileRange) (h_expr : expr = .Fresh val) (h : exprMd.val = .Fresh val) : ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr - let (val', valTy) ← synthStmtExpr val + let (val', valTy) ← Synth.resolveStmtExpr val unless isReference (← get).typeContext valTy do typeMismatch val'.source (some expr) "expected a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) @@ -1386,12 +1388,12 @@ def synthFresh (exprMd : StmtExprMd) (expr : StmtExpr) /-- Rule **ProveBy**: `v` and `proof` are both synthesized; the construct's type is `v`'s type — `proof` is a hint for downstream verification. -/ -def synthProveBy (exprMd : StmtExprMd) +def Synth.proveBy (exprMd : StmtExprMd) (val proof : StmtExprMd) (h : exprMd.val = .ProveBy val proof) : ResolveM (StmtExpr × HighTypeMd) := do - let (val', valTy) ← synthStmtExpr val - let (proof', _) ← synthStmtExpr proof + let (val', valTy) ← Synth.resolveStmtExpr val + let (proof', _) ← Synth.resolveStmtExpr proof pure (.ProveBy val' proof', valTy) termination_by (exprMd, 1) decreasing_by @@ -1411,7 +1413,7 @@ def synthProveBy (exprMd : StmtExprMd) wildcarded through `Unknown`. Otherwise an error is emitted ("'this' is not allowed outside instance methods") and the type collapses to `Unknown` to suppress cascading errors. -/ -def synthThis (source : Option FileRange) : +def Synth.this (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let s ← get match s.instanceTypeName with @@ -1429,11 +1431,11 @@ def synthThis (source : Option FileRange) : -- ### Untyped forms /-- Rule **Abstract**: synthesizes `Unknown`. -/ -def synthAbstract (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.abstract (source : Option FileRange) : StmtExpr × HighTypeMd := (.Abstract, { val := .Unknown, source := source }) /-- Rule **All**: synthesizes `Unknown`. -/ -def synthAll (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.all (source : Option FileRange) : StmtExpr × HighTypeMd := (.All, { val := .Unknown, source := source }) -- ### ContractOf @@ -1458,11 +1460,11 @@ def synthAll (source : Option FileRange) : StmtExpr × HighTypeMd := `contractOf` production today, and the translator emits "not yet implemented" for it. The typing rule exists so resolution remains exhaustive over `StmtExpr`. -/ -def synthContractOf (exprMd : StmtExprMd) +def Synth.contractOf (exprMd : StmtExprMd) (ty : ContractType) (fn : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .ContractOf ty fn) : ResolveM (StmtExpr × HighTypeMd) := do - let (fn', _) ← synthStmtExpr fn + let (fn', _) ← Synth.resolveStmtExpr fn let s ← get let fnIsProcRef : Bool := match fn'.val with | .Var (.Local ref) => @@ -1492,7 +1494,7 @@ def synthContractOf (exprMd : StmtExprMd) /-- Rules **Hole-Some** / **Hole-None-Synth**: a typed hole synthesizes its annotation; an untyped hole in synth position synthesizes `Unknown`. -/ -def synthHole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange) : +def Synth.hole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do match type with | some ty => @@ -1514,16 +1516,19 @@ def synthHole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange the diagnostic *"hole annotated with 'T_resolution' but context expects 'T_inference'"*, surfacing what would otherwise be a silent overwrite. -/ -def checkHoleNone (det : Bool) (expected : HighTypeMd) (source : Option FileRange) : +def Check.holeNone (det : Bool) (expected : HighTypeMd) (source : Option FileRange) : StmtExprMd := { val := .Hole det (some expected), source := source } -end +end -- mutual +end Resolution + +open Resolution /-- Resolve a statement expression, discarding the synthesized type. Use when only the resolved expression is needed (invariants, decreases, etc.). -/ private def resolveStmtExpr (e : StmtExprMd) : ResolveM StmtExprMd := do - let (e', _) ← synthStmtExpr e; pure e' + let (e', _) ← Synth.resolveStmtExpr e; pure e' /-- Resolve a parameter: assign a fresh ID and add to scope. -/ def resolveParameter (param : Parameter) : ResolveM Parameter := do @@ -1535,7 +1540,7 @@ def resolveParameter (param : Parameter) : ResolveM Parameter := do def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do match body with | .Transparent b => - let (b', ty) ← synthStmtExpr b + let (b', ty) ← Synth.resolveStmtExpr b return (.Transparent b', ty) | .Opaque posts impl mods => let posts' ← posts.mapM (·.mapM resolveStmtExpr) @@ -1656,8 +1661,8 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do -- in scope when resolving the constraint and witness expressions. let (valueName', constraint', witness') ← withScope do let valueName' ← defineNameCheckDup ct.valueName (.quantifierVar ct.valueName base') - let (constraint', _) ← synthStmtExpr ct.constraint - let (witness', _) ← synthStmtExpr ct.witness + let (constraint', _) ← Synth.resolveStmtExpr ct.constraint + let (witness', _) ← Synth.resolveStmtExpr ct.witness return (valueName', constraint', witness') return .Constrained { name := ctName', base := base', valueName := valueName', constraint := constraint', witness := witness' } @@ -1683,7 +1688,7 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do /-- Resolve a constant definition. -/ def resolveConstant (c : Constant) : ResolveM Constant := do let ty' ← resolveHighType c.type - let init' ← c.initializer.mapM (checkStmtExpr · ty') + let init' ← c.initializer.mapM (Check.resolveStmtExpr · ty') let name' ← resolveRef c.name return { name := name', type := ty', initializer := init' } diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 7583d4d079..b9d1070f42 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -160,8 +160,8 @@ mismatches against the surrounding context become diagnostics. The implementatio There are two operations on expressions, written here in standard bidirectional notation: ``` -Γ ⊢ e ⇒ T -- "e synthesizes T" (synthStmtExpr) -Γ ⊢ e ⇐ T -- "e checks against T" (checkStmtExpr) +Γ ⊢ e ⇒ T -- "e synthesizes T" (Synth.resolveStmtExpr) +Γ ⊢ e ⇐ T -- "e checks against T" (Check.resolveStmtExpr) ``` Synthesis returns a type inferred from the expression itself; checking verifies that the @@ -172,12 +172,12 @@ by a single change-of-direction rule, *subsumption*: $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` The two judgments are implemented as -{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`: +{name Strata.Laurel.Resolution.Synth.resolveStmtExpr}`Synth.resolveStmtExpr` and +{name Strata.Laurel.Resolution.Check.resolveStmtExpr}`Check.resolveStmtExpr`: -{docstring Strata.Laurel.synthStmtExpr} +{docstring Strata.Laurel.Resolution.Synth.resolveStmtExpr} -{docstring Strata.Laurel.checkStmtExpr} +{docstring Strata.Laurel.Resolution.Check.resolveStmtExpr} ### Gradual typing @@ -238,43 +238,43 @@ Each LaTeX rule below is followed by the docstring of the helper that implements $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` -Fallback in {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` whenever no bespoke check +Fallback in {name Strata.Laurel.Resolution.Check.resolveStmtExpr}`Check.resolveStmtExpr` whenever no bespoke check rule applies. ### Literals $$`\frac{}{\Gamma \vdash \mathsf{LiteralInt}\;n \Rightarrow \mathsf{TInt}} \quad \text{([⇒] Lit-Int)}` -{docstring Strata.Laurel.synthLitInt} +{docstring Strata.Laurel.Resolution.Synth.litInt} $$`\frac{}{\Gamma \vdash \mathsf{LiteralBool}\;b \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Lit-Bool)}` -{docstring Strata.Laurel.synthLitBool} +{docstring Strata.Laurel.Resolution.Synth.litBool} $$`\frac{}{\Gamma \vdash \mathsf{LiteralString}\;s \Rightarrow \mathsf{TString}} \quad \text{([⇒] Lit-String)}` -{docstring Strata.Laurel.synthLitString} +{docstring Strata.Laurel.Resolution.Synth.litString} $$`\frac{}{\Gamma \vdash \mathsf{LiteralDecimal}\;d \Rightarrow \mathsf{TReal}} \quad \text{([⇒] Lit-Decimal)}` -{docstring Strata.Laurel.synthLitDecimal} +{docstring Strata.Laurel.Resolution.Synth.litDecimal} ### Variables $$`\frac{\Gamma(x) = T}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Local}\;x) \Rightarrow T} \quad \text{([⇒] Var-Local)}` -{docstring Strata.Laurel.synthVarLocal} +{docstring Strata.Laurel.Resolution.Synth.varLocal} $$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Field}\;e\;f) \Rightarrow T_f} \quad \text{([⇒] Var-Field)}` -{docstring Strata.Laurel.synthVarField} +{docstring Strata.Laurel.Resolution.Synth.varField} $$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T\rangle) \Rightarrow \mathsf{TVoid} \dashv \Gamma, x : T} \quad \text{([⇒] Var-Declare)}` `⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the remainder of the enclosing scope. -{docstring Strata.Laurel.synthVarDeclare} +{docstring Strata.Laurel.Resolution.Synth.varDeclare} ### Control flow @@ -282,13 +282,13 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t \sqcup T_e} \quad \text{([⇒] If)}` -{docstring Strata.Laurel.synthIfThenElse} +{docstring Strata.Laurel.Resolution.Synth.ifThenElse} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \Gamma \vdash \mathit{elseBr} \Leftarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Leftarrow T} \quad \text{([⇐] If)}` $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Leftarrow T} \quad \text{([⇐] If-NoElse)}` -{docstring Strata.Laurel.checkIfThenElse} +{docstring Strata.Laurel.Resolution.Check.ifThenElse} $$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block)}` @@ -299,17 +299,17 @@ block. $$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Block-Empty)}` -{docstring Strata.Laurel.synthBlock} +{docstring Strata.Laurel.Resolution.Synth.block} $$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` $$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Empty)}` -{docstring Strata.Laurel.checkBlock} +{docstring Strata.Laurel.Resolution.Check.block} $$`\frac{}{\Gamma \vdash \mathsf{Exit}\;\mathit{target} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Exit)}` -{docstring Strata.Laurel.synthExit} +{docstring Strata.Laurel.Resolution.Synth.exit} $$`\frac{}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-None)}` @@ -319,21 +319,21 @@ $$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = []}{\Gamma \vdash \mathsf{Ret $$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇒] Return-Multi-Error)}` -{docstring Strata.Laurel.synthReturn} +{docstring Strata.Laurel.Resolution.Synth.return} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{dec} \Leftarrow {?} \quad \Gamma \vdash \mathit{body} \Rightarrow \_}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{dec}\;\mathit{body} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] While)}` -{docstring Strata.Laurel.synthWhile} +{docstring Strata.Laurel.Resolution.Synth.while} ### Verification statements $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assert)}` -{docstring Strata.Laurel.synthAssert} +{docstring Strata.Laurel.Resolution.Synth.assert} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assume}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assume)}` -{docstring Strata.Laurel.synthAssume} +{docstring Strata.Laurel.Resolution.Synth.assume} ### Assignment @@ -344,9 +344,9 @@ The target's declared type `T_i` comes from the variable's scope entry (for {name Strata.Laurel.Variable.Local}`Local` and {name Strata.Laurel.Variable.Field}`Field`) or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. -{docstring Strata.Laurel.synthAssign} +{docstring Strata.Laurel.Resolution.Synth.assign} -{docstring Strata.Laurel.checkAssign} +{docstring Strata.Laurel.Resolution.Check.assign} ### Calls @@ -354,11 +354,11 @@ $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` -{docstring Strata.Laurel.synthStaticCall} +{docstring Strata.Laurel.Resolution.Synth.staticCall} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Instance-Call)}` -{docstring Strata.Laurel.synthInstanceCall} +{docstring Strata.Laurel.Resolution.Synth.instanceCall} ### Primitive operations @@ -376,7 +376,7 @@ $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \Gamma $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` -{docstring Strata.Laurel.synthPrimitiveOp} +{docstring Strata.Laurel.Resolution.Synth.primitiveOp} ### Object forms @@ -384,15 +384,15 @@ $$`\frac{\Gamma(\mathit{ref}) \text{ is a composite or datatype } T}{\Gamma \vda $$`\frac{\Gamma(\mathit{ref}) \text{ is not a composite or datatype}}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] New-Fallback)}` -{docstring Strata.Laurel.synthNew} +{docstring Strata.Laurel.Resolution.Synth.new} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{AsType}\;\mathit{target}\;T \Rightarrow T} \quad \text{([⇒] AsType)}` -{docstring Strata.Laurel.synthAsType} +{docstring Strata.Laurel.Resolution.Synth.asType} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` -{docstring Strata.Laurel.synthIsType} +{docstring Strata.Laurel.Resolution.Synth.isType} $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad \mathsf{isReference}\;T_l \quad \mathsf{isReference}\;T_r \quad T_l \sim T_r}{\Gamma \vdash \mathsf{ReferenceEquals}\;\mathit{lhs}\;\mathit{rhs} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] RefEq)}` @@ -401,33 +401,33 @@ or {name Strata.Laurel.HighType.Unknown}`Unknown` type. `~` is the consistency r {name Strata.Laurel.isConsistent}`isConsistent` — symmetric, with the {name Strata.Laurel.HighType.Unknown}`Unknown` wildcard. -{docstring Strata.Laurel.synthRefEq} +{docstring Strata.Laurel.Resolution.Synth.refEq} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow T_t \quad \Gamma(f) = T_f \quad \Gamma \vdash \mathit{newVal} \Leftarrow T_f}{\Gamma \vdash \mathsf{PureFieldUpdate}\;\mathit{target}\;f\;\mathit{newVal} \Rightarrow T_t} \quad \text{([⇒] PureFieldUpdate)}` -{docstring Strata.Laurel.synthPureFieldUpdate} +{docstring Strata.Laurel.Resolution.Synth.pureFieldUpdate} ### Verification expressions $$`\frac{\Gamma, x : T \vdash \mathit{body} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Quantifier}\;\mathit{mode}\;\langle x, T\rangle\;\mathit{trig}\;\mathit{body} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Quantifier)}` -{docstring Strata.Laurel.synthQuantifier} +{docstring Strata.Laurel.Resolution.Synth.quantifier} $$`\frac{\Gamma \vdash \mathit{name} \Rightarrow \_}{\Gamma \vdash \mathsf{Assigned}\;\mathit{name} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Assigned)}` -{docstring Strata.Laurel.synthAssigned} +{docstring Strata.Laurel.Resolution.Synth.assigned} $$`\frac{\Gamma \vdash v \Rightarrow T}{\Gamma \vdash \mathsf{Old}\;v \Rightarrow T} \quad \text{([⇒] Old)}` -{docstring Strata.Laurel.synthOld} +{docstring Strata.Laurel.Resolution.Synth.old} $$`\frac{\Gamma \vdash v \Rightarrow T \quad \mathsf{isReference}\;T}{\Gamma \vdash \mathsf{Fresh}\;v \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Fresh)}` -{docstring Strata.Laurel.synthFresh} +{docstring Strata.Laurel.Resolution.Synth.fresh} $$`\frac{\Gamma \vdash v \Rightarrow T \quad \Gamma \vdash \mathit{proof} \Rightarrow \_}{\Gamma \vdash \mathsf{ProveBy}\;v\;\mathit{proof} \Rightarrow T} \quad \text{([⇒] ProveBy)}` -{docstring Strata.Laurel.synthProveBy} +{docstring Strata.Laurel.Resolution.Synth.proveBy} ### Self reference @@ -435,15 +435,15 @@ $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{some}\;T}{\Gamma \vdash \mat $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{none}}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{Unknown}\;\;[\text{emits “‘this’ is not allowed outside instance methods”}]} \quad \text{([⇒] This-Outside)}` -{docstring Strata.Laurel.synthThis} +{docstring Strata.Laurel.Resolution.Synth.this} ### Untyped forms $$`\frac{}{\Gamma \vdash \mathsf{Abstract}\;/\;\mathsf{All}\;\ldots \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Abstract / All)}` -{docstring Strata.Laurel.synthAbstract} +{docstring Strata.Laurel.Resolution.Synth.abstract} -{docstring Strata.Laurel.synthAll} +{docstring Strata.Laurel.Resolution.Synth.all} ### ContractOf @@ -453,7 +453,7 @@ $$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma $$`\frac{\mathit{fn} \text{ is not a procedure reference}}{\Gamma \vdash \mathsf{ContractOf}\;\ldots\;\mathit{fn} \rightsquigarrow \text{error: “‘contractOf’ expected a procedure reference”}} \quad \text{([⇒] ContractOf-Error)}` -{docstring Strata.Laurel.synthContractOf} +{docstring Strata.Laurel.Resolution.Synth.contractOf} ### Holes @@ -461,11 +461,11 @@ $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T) \Rightarrow T} \qu $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Hole-None)}` -{docstring Strata.Laurel.synthHole} +{docstring Strata.Laurel.Resolution.Synth.hole} $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \;\;\mapsto\;\; \mathsf{Hole}\;d\;(\mathsf{some}\;T)} \quad \text{([⇐] Hole-None)}` -{docstring Strata.Laurel.checkHoleNone} +{docstring Strata.Laurel.Resolution.Check.holeNone} ## Future structural changes @@ -504,7 +504,7 @@ just wasted work and a maintenance hazard. `InferHoleTypes` walks the post-resolution AST a second time to annotate holes. Now that \[⇐\] Hole-None writes the expected type during resolution for holes in check-mode positions, the post-pass only needs to handle holes in synth-only positions (e.g. call -arguments resolved through `synthStmtExpr` instead of `checkStmtExpr`). As more constructs +arguments resolved through `Synth.resolveStmtExpr` instead of `Check.resolveStmtExpr`). As more constructs gain bespoke check rules, fewer holes will reach `InferHoleTypes`; eventually the pass can be deleted entirely. From 2a3536af78c8c7c4500aecd8007baa5620da796e Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 18:21:08 +0000 Subject: [PATCH 058/189] Add type checking to Laurel resolution pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change resolveStmtExpr to return (StmtExprMd × HighTypeMd) - Add type checks for: - Boolean conditions in if/while/assert/assume - Numeric operands in arithmetic/comparison operations - Boolean operands in logical operations - Argument types matching parameter types in static calls - Argument types matching parameter types in instance calls - Assignment value type matching target type - Function body type matching declared output type - Report type mismatches as diagnostics (compilation continues) - Handle cascading errors: Unknown types are compatible with everything, UserDefined types skip strict checking (subtype relationships not tracked), void types skip assignment checks (statements don't produce values) Closes #1120 --- Strata/Languages/Laurel/Resolution.lean | 345 +++++++++++++++++------- 1 file changed, 253 insertions(+), 92 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 16bcf1333f..287382d3f9 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -326,7 +326,14 @@ def resolveHighType (ty : HighTypeMd) : ResolveM HighTypeMd := do | .UserDefined ref => let ref' ← resolveRef ref ty.source (expected := #[.compositeType, .constrainedType, .datatypeDefinition, .typeAlias]) - pure (.UserDefined ref') + -- If the reference resolved to the wrong kind, treat the type as Unknown to avoid cascading errors + let s ← get + let kindOk : Bool := match s.scope.get? ref.text with + | some (_, node) => node.kind == .unresolved || + (#[ResolvedNodeKind.compositeType, .constrainedType, .datatypeDefinition, .typeAlias].contains node.kind) + | none => true -- unresolved references already reported + if kindOk then pure (HighType.UserDefined ref') + else pure HighType.Unknown | .TTypedField vt => let vt' ← resolveHighType vt pure (.TTypedField vt') @@ -353,40 +360,119 @@ def resolveHighType (ty : HighTypeMd) : ResolveM HighTypeMd := do | other => pure other return { val := val', source := ty.source } -def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM StmtExprMd := do +/-- Emit a type mismatch diagnostic. -/ +private def typeMismatch (source : Option FileRange) (expected : String) (actual : HighTypeMd) : ResolveM Unit := do + let actualStr := toString (formatHighTypeVal actual.val) + let diag := diagnosticFromSource source s!"Type mismatch: expected {expected}, but got '{actualStr}'" + modify fun s => { s with errors := s.errors.push diag } + +/-- Check that a type is boolean, emitting a diagnostic if not. -/ +private def checkBool (source : Option FileRange) (ty : HighTypeMd) : ResolveM Unit := do + match ty.val with + | .TBool | .Unknown => pure () + | .UserDefined _ => pure () -- constrained types may wrap bool + | _ => typeMismatch source "bool" ty + +/-- Check that a type is numeric (int, real, or float64), emitting a diagnostic if not. -/ +private def checkNumeric (source : Option FileRange) (ty : HighTypeMd) : ResolveM Unit := do + match ty.val with + | .TInt | .TReal | .TFloat64 | .Unknown => pure () + | .UserDefined _ => pure () -- constrained types may wrap numeric types + | _ => typeMismatch source "a numeric type" ty + +/-- Check that two types are compatible, emitting a diagnostic if not. + UserDefined types are always considered compatible with each other since + subtype relationships (inheritance) are not tracked during resolution. -/ +private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do + match expected.val, actual.val with + | .Unknown, _ => pure () + | _, .Unknown => pure () + | _, .MultiValuedExpr _ => pure () -- arity mismatch already reported separately + | .UserDefined _, _ => pure () -- subtype relationships not tracked here + | _, .UserDefined _ => pure () -- subtype relationships not tracked here + | _, _ => + if !highEq expected actual then + let expectedStr := toString (formatHighTypeVal expected.val) + let actualStr := toString (formatHighTypeVal actual.val) + let diag := diagnosticFromSource source s!"Type mismatch: expected '{expectedStr}', but got '{actualStr}'" + modify fun s => { s with errors := s.errors.push diag } + +/-- Get the type of a resolved variable reference from scope. -/ +private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do + let s ← get + match s.scope.get? ref.text with + | some (_, node) => pure node.getType + | none => pure { val := .Unknown, source := ref.source } + +/-- Get the call return type and parameter types for a callee from scope. -/ +private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List HighTypeMd) := do + let s ← get + match s.scope.get? callee.text with + | some (_, .staticProcedure proc) => + let retTy := match proc.outputs with + | [singleOutput] => singleOutput.type + | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } + pure (retTy, proc.inputs.map (·.type)) + | some (_, .instanceProcedure _ proc) => + let retTy := match proc.outputs with + | [singleOutput] => singleOutput.type + | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } + pure (retTy, proc.inputs.map (·.type)) + | some (_, .datatypeConstructor t _) => + -- Testers (e.g. "Color..isRed") return Bool; constructors return the type + if (callee.text.splitOn "..is").length > 1 then + pure ({ val := .TBool, source := callee.source }, []) + else + pure ({ val := .UserDefined t, source := callee.source }, []) + | some (_, .parameter p) => pure (p.type, []) + | some (_, .constant c) => pure (c.type, []) + | _ => pure ({ val := .Unknown, source := callee.source }, []) + +def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match _: exprMd with | AstNode.mk expr source => - let val' ← match _: expr with + let (val', ty) ← match _: expr with | .IfThenElse cond thenBr elseBr => - let cond' ← resolveStmtExpr cond - let thenBr' ← resolveStmtExpr thenBr - let elseBr' ← elseBr.attach.mapM (fun a => have := a.property; resolveStmtExpr a.val) - pure (.IfThenElse cond' thenBr' elseBr') + let (cond', condTy) ← resolveStmtExpr cond + checkBool cond'.source condTy + let (thenBr', thenTy) ← resolveStmtExpr thenBr + let elseBr' ← elseBr.attach.mapM (fun a => have := a.property; do + let (e', _) ← resolveStmtExpr a.val; pure e') + pure (.IfThenElse cond' thenBr' elseBr', thenTy) | .Block stmts label => withScope do - let stmts' ← stmts.mapM resolveStmtExpr - pure (.Block stmts' label) + let results ← stmts.mapM resolveStmtExpr + let stmts' := results.map (·.1) + let lastTy := match results.getLast? with + | some (_, ty) => ty + | none => { val := .TVoid, source := source } + pure (.Block stmts' label, lastTy) | .While cond invs dec body => - let cond' ← resolveStmtExpr cond - let invs' ← invs.attach.mapM (fun a => have := a.property; resolveStmtExpr a.val) - let dec' ← dec.attach.mapM (fun a => have := a.property; resolveStmtExpr a.val) - let body' ← resolveStmtExpr body - pure (.While cond' invs' dec' body') - | .Exit target => pure (.Exit target) + let (cond', condTy) ← resolveStmtExpr cond + checkBool cond'.source condTy + let invs' ← invs.attach.mapM (fun a => have := a.property; do + let (e', _) ← resolveStmtExpr a.val; pure e') + let dec' ← dec.attach.mapM (fun a => have := a.property; do + let (e', _) ← resolveStmtExpr a.val; pure e') + let (body', _) ← resolveStmtExpr body + pure (.While cond' invs' dec' body', { val := .TVoid, source := source }) + | .Exit target => pure (.Exit target, { val := .TVoid, source := source }) | .Return val => do - let val' ← val.attach.mapM (fun a => have := a.property; resolveStmtExpr a.val) - pure (.Return val') - | .LiteralInt v => pure (.LiteralInt v) - | .LiteralBool v => pure (.LiteralBool v) - | .LiteralString v => pure (.LiteralString v) - | .LiteralDecimal v => pure (.LiteralDecimal v) + let val' ← val.attach.mapM (fun a => have := a.property; do + let (e', _) ← resolveStmtExpr a.val; pure e') + pure (.Return val', { val := .TVoid, source := source }) + | .LiteralInt v => pure (.LiteralInt v, { val := .TInt, source := source }) + | .LiteralBool v => pure (.LiteralBool v, { val := .TBool, source := source }) + | .LiteralString v => pure (.LiteralString v, { val := .TString, source := source }) + | .LiteralDecimal v => pure (.LiteralDecimal v, { val := .TReal, source := source }) | .Var (.Local ref) => let ref' ← resolveRef ref source - pure (.Var (.Local ref')) + let ty ← getVarType ref + pure (.Var (.Local ref'), ty) | .Var (.Declare param) => let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') - pure (.Var (.Declare ⟨name', ty'⟩)) + pure (.Var (.Declare ⟨name', ty'⟩), { val := .TVoid, source := source }) | .Assign targets value => let targets' ← targets.attach.mapM fun ⟨v, _⟩ => do let ⟨vv, vs⟩ := v @@ -395,14 +481,14 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM StmtExprMd := do let ref' ← resolveRef ref source pure (⟨.Local ref', vs⟩ : VariableMd) | .Field target fieldName => - let target' ← resolveStmtExpr target + let (target', _) ← resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source pure (⟨.Field target' fieldName', vs⟩ : VariableMd) | .Declare param => let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let value' ← resolveStmtExpr value + let (value', valueTy) ← resolveStmtExpr value -- Check that LHS target count matches the number of outputs from the RHS. -- This fires for procedure calls (which can have multiple outputs). -- Functions always have exactly 1 output in the model, so single-target function calls pass trivially. @@ -424,84 +510,144 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM StmtExprMd := do let diag := diagnosticFromSource source s!"Assignment target count mismatch: {targets'.length} targets but right-hand side produces {expectedOutputCount} values" modify fun s => { s with errors := s.errors.push diag } - pure (.Assign targets' value') + -- Type check: for single-target assignments, check value type matches target type + -- Skip when value type is void (RHS is a statement like while/return that doesn't produce a value) + if targets'.length == 1 && valueTy.val != HighType.TVoid then + if let some target := targets'.head? then + let targetTy := match target.val with + | .Local ref => do + let s ← get + match s.scope.get? ref.text with + | some (_, node) => pure node.getType + | none => pure { val := HighType.Unknown, source := ref.source : HighTypeMd } + | .Declare param => pure param.type + | .Field _ fieldName => do + let s ← get + match s.scope.get? fieldName.text with + | some (_, node) => pure node.getType + | none => pure { val := HighType.Unknown, source := fieldName.source : HighTypeMd } + let tTy ← targetTy + checkAssignable source tTy valueTy + pure (.Assign targets' value', valueTy) | .Var (.Field target fieldName) => - let target' ← resolveStmtExpr target + let (target', _) ← resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source - pure (.Var (.Field target' fieldName')) + let ty ← getVarType fieldName + pure (.Var (.Field target' fieldName'), ty) | .PureFieldUpdate target fieldName newVal => - let target' ← resolveStmtExpr target + let (target', targetTy) ← resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source - let newVal' ← resolveStmtExpr newVal - pure (.PureFieldUpdate target' fieldName' newVal') + let (newVal', _) ← resolveStmtExpr newVal + pure (.PureFieldUpdate target' fieldName' newVal', targetTy) | .StaticCall callee args => let callee' ← resolveRef callee source (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) - let args' ← args.mapM resolveStmtExpr - pure (.StaticCall callee' args') + let results ← args.mapM resolveStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let (retTy, paramTypes) ← getCallInfo callee + -- Check argument types match parameter types + for (argTy, paramTy) in argTypes.zip paramTypes do + checkAssignable source paramTy argTy + pure (.StaticCall callee' args', retTy) | .PrimitiveOp op args => - let args' ← args.mapM resolveStmtExpr - pure (.PrimitiveOp op args') + let results ← args.mapM resolveStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let resultTy := match op with + | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies + | .Lt | .Leq | .Gt | .Geq => HighType.TBool + | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => + match argTypes.head? with + | some headTy => headTy.val + | none => HighType.TInt + | .StrConcat => HighType.TString + -- Type check operands + match op with + | .And | .Or | .AndThen | .OrElse | .Not | .Implies => + for aTy in argTypes do checkBool source aTy + | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => + for aTy in argTypes do checkNumeric source aTy + | .Eq | .Neq | .StrConcat => pure () + pure (.PrimitiveOp op args', { val := resultTy, source := source }) | .New ref => let ref' ← resolveRef ref source (expected := #[.compositeType, .datatypeDefinition]) - pure (.New ref') - | .This => pure .This + -- If the reference resolved to the wrong kind, use Unknown type to avoid cascading errors + let s ← get + let kindOk : Bool := match s.scope.get? ref.text with + | some (_, node) => node.kind == .unresolved || + (#[ResolvedNodeKind.compositeType, .datatypeDefinition].contains node.kind) + | none => true + let ty := if kindOk then { val := HighType.UserDefined ref', source := source } + else { val := HighType.Unknown, source := source } + pure (.New ref', ty) + | .This => pure (.This, { val := .Unknown, source := source }) | .ReferenceEquals lhs rhs => - let lhs' ← resolveStmtExpr lhs - let rhs' ← resolveStmtExpr rhs - pure (.ReferenceEquals lhs' rhs') + let (lhs', _) ← resolveStmtExpr lhs + let (rhs', _) ← resolveStmtExpr rhs + pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => - let target' ← resolveStmtExpr target + let (target', _) ← resolveStmtExpr target let ty' ← resolveHighType ty - pure (.AsType target' ty') + pure (.AsType target' ty', ty') | .IsType target ty => - let target' ← resolveStmtExpr target + let (target', _) ← resolveStmtExpr target let ty' ← resolveHighType ty - pure (.IsType target' ty') + pure (.IsType target' ty', { val := .TBool, source := source }) | .InstanceCall target callee args => - let target' ← resolveStmtExpr target + let (target', _) ← resolveStmtExpr target let callee' ← resolveRef callee source (expected := #[.instanceProcedure, .staticProcedure]) - let args' ← args.mapM resolveStmtExpr - pure (.InstanceCall target' callee' args') + let results ← args.mapM resolveStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let (retTy, paramTypes) ← getCallInfo callee + -- Check argument types match parameter types (skip first param which is 'self') + let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] + for (argTy, paramTy) in argTypes.zip callParamTypes do + checkAssignable source paramTy argTy + pure (.InstanceCall target' callee' args', retTy) | .Quantifier mode param trigger body => withScope do let paramTy' ← resolveHighType param.type let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') - let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; resolveStmtExpr pv.val) - let body' ← resolveStmtExpr body - pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body') + let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do + let (e', _) ← resolveStmtExpr pv.val; pure e') + let (body', _) ← resolveStmtExpr body + pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) | .Assigned name => - let name' ← resolveStmtExpr name - pure (.Assigned name') + let (name', _) ← resolveStmtExpr name + pure (.Assigned name', { val := .TBool, source := source }) | .Old val => - let val' ← resolveStmtExpr val - pure (.Old val') + let (val', valTy) ← resolveStmtExpr val + pure (.Old val', valTy) | .Fresh val => - let val' ← resolveStmtExpr val - pure (.Fresh val') + let (val', _) ← resolveStmtExpr val + pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => - let cond' ← resolveStmtExpr condExpr - pure (.Assert { condition := cond', summary }) + let (cond', condTy) ← resolveStmtExpr condExpr + checkBool cond'.source condTy + pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) | .Assume cond => - let cond' ← resolveStmtExpr cond - pure (.Assume cond') + let (cond', condTy) ← resolveStmtExpr cond + checkBool cond'.source condTy + pure (.Assume cond', { val := .TVoid, source := source }) | .ProveBy val proof => - let val' ← resolveStmtExpr val - let proof' ← resolveStmtExpr proof - pure (.ProveBy val' proof') + let (val', valTy) ← resolveStmtExpr val + let (proof', _) ← resolveStmtExpr proof + pure (.ProveBy val' proof', valTy) | .ContractOf ty fn => - let fn' ← resolveStmtExpr fn - pure (.ContractOf ty fn') - | .Abstract => pure .Abstract - | .All => pure .All + let (fn', _) ← resolveStmtExpr fn + pure (.ContractOf ty fn', { val := .Unknown, source := source }) + | .Abstract => pure (.Abstract, { val := .Unknown, source := source }) + | .All => pure (.All, { val := .Unknown, source := source }) | .Hole det type => match type with | some ty => let ty' ← resolveHighType ty - pure (.Hole det ty') - | none => pure (.Hole det none) - return { val := val', source := source } + pure (.Hole det ty', ty') + | none => pure (.Hole det none, { val := .Unknown, source := source }) + return ({ val := val', source := source }, ty) termination_by exprMd decreasing_by all_goals term_by_mem @@ -511,21 +657,21 @@ def resolveParameter (param : Parameter) : ResolveM Parameter := do let name' ← defineNameCheckDup param.name (.parameter ⟨param.name, ty'⟩) return ⟨name', ty'⟩ -/-- Resolve a procedure body. -/ -def resolveBody (body : Body) : ResolveM Body := do +/-- Resolve a procedure body. Returns the resolved body and its type. -/ +def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do match body with | .Transparent b => - let b' ← resolveStmtExpr b - return .Transparent b' + let (b', ty) ← resolveStmtExpr b + return (.Transparent b', ty) | .Opaque posts impl mods => - let posts' ← posts.mapM (·.mapM resolveStmtExpr) - let impl' ← impl.mapM resolveStmtExpr - let mods' ← mods.mapM resolveStmtExpr - return .Opaque posts' impl' mods' + let posts' ← posts.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') + let impl' ← impl.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let mods' ← mods.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + return (.Opaque posts' impl' mods', { val := .TVoid, source := none }) | .Abstract posts => - let posts' ← posts.mapM (·.mapM resolveStmtExpr) - return .Abstract posts' - | .External => return .External + let posts' ← posts.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') + return (.Abstract posts', { val := .TVoid, source := none }) + | .External => return (.External, { val := .TVoid, source := none }) /-- Resolve a procedure: resolve its name, then resolve params, contracts, and body in a new scope. -/ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do @@ -533,14 +679,22 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do withScope do let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) - let dec' ← proc.decreases.mapM resolveStmtExpr - let body' ← resolveBody proc.body + let pres' ← proc.preconditions.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') + let dec' ← proc.decreases.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" modify fun s => { s with errors := s.errors.push diag } - let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr + -- Check body type matches declared output type for functional procedures with transparent bodies + if proc.isFunctional && body'.isTransparent then + match proc.outputs with + | [singleOutput] => + -- Only check when body produces a value (not void from return/while/assign) + if bodyTy.val != HighType.TVoid then + checkAssignable proc.name.source singleOutput.type bodyTy + | _ => pure () + let invokeOn' ← proc.invokeOn.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, preconditions := pres', decreases := dec', @@ -566,14 +720,21 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv modify fun s => { s with instanceTypeName := some typeName.text } let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) - let dec' ← proc.decreases.mapM resolveStmtExpr - let body' ← resolveBody proc.body + let pres' ← proc.preconditions.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') + let dec' ← proc.decreases.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" modify fun s => { s with errors := s.errors.push diag } - let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr + -- Check body type matches declared output type for functional procedures with transparent bodies + if proc.isFunctional && body'.isTransparent then + match proc.outputs with + | [singleOutput] => + if bodyTy.val != HighType.TVoid then + checkAssignable proc.name.source singleOutput.type bodyTy + | _ => pure () + let invokeOn' ← proc.invokeOn.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' modify fun s => { s with instanceTypeName := savedInstType } return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, @@ -615,8 +776,8 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do -- in scope when resolving the constraint and witness expressions. let (valueName', constraint', witness') ← withScope do let valueName' ← defineNameCheckDup ct.valueName (.quantifierVar ct.valueName base') - let constraint' ← resolveStmtExpr ct.constraint - let witness' ← resolveStmtExpr ct.witness + let (constraint', _) ← resolveStmtExpr ct.constraint + let (witness', _) ← resolveStmtExpr ct.witness return (valueName', constraint', witness') return .Constrained { name := ctName', base := base', valueName := valueName', constraint := constraint', witness := witness' } @@ -642,7 +803,7 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do /-- Resolve a constant definition. -/ def resolveConstant (c : Constant) : ResolveM Constant := do let ty' ← resolveHighType c.type - let init' ← c.initializer.mapM resolveStmtExpr + let init' ← c.initializer.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' let name' ← resolveRef c.name return { name := name', type := ty', initializer := init' } From 39b4f6c22c5e02c5d14548cf6f00f327a77e4e45 Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 18:58:54 +0000 Subject: [PATCH 059/189] Fix type checking: skip TCore types in assignability check TCore is a pass-through type from Core that should not be checked during Laurel resolution. Without this, two identical TCore types (e.g. 'Core Any') would fail highEq (which has no TCore case) and produce spurious 'Type mismatch' diagnostics. --- Strata/Languages/Laurel/Resolution.lean | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 287382d3f9..43d8866d0d 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -390,6 +390,8 @@ private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) | _, .MultiValuedExpr _ => pure () -- arity mismatch already reported separately | .UserDefined _, _ => pure () -- subtype relationships not tracked here | _, .UserDefined _ => pure () -- subtype relationships not tracked here + | .TCore _, _ => pure () -- pass-through Core types not checked during resolution + | _, .TCore _ => pure () -- pass-through Core types not checked during resolution | _, _ => if !highEq expected actual then let expectedStr := toString (formatHighTypeVal expected.val) From bdae7ebfd1d2c457df8f4482d658e0ece0b3ab72 Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 19:22:28 +0000 Subject: [PATCH 060/189] Simplify assignment arity check to use valueTy directly Derive expected output count from the RHS type (MultiValuedExpr gives the arity, otherwise 1) instead of re-looking up the procedure. This ensures LHS and RHS arity always match for assignments. --- Strata/Languages/Laurel/Resolution.lean | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 43d8866d0d..d87f97cd73 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -491,24 +491,11 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) let (value', valueTy) ← resolveStmtExpr value - -- Check that LHS target count matches the number of outputs from the RHS. - -- This fires for procedure calls (which can have multiple outputs). - -- Functions always have exactly 1 output in the model, so single-target function calls pass trivially. - let expectedOutputCount ← match value'.val with - | .StaticCall callee _ => do - let s ← get - match s.scope.get? callee.text with - | some (_, .staticProcedure proc) => pure proc.outputs.length - | some (_, .instanceProcedure _ proc) => pure proc.outputs.length - | _ => pure 1 - | .InstanceCall _ callee _ => do - let s ← get - match s.scope.get? callee.text with - | some (_, .instanceProcedure _ proc) => pure proc.outputs.length - | some (_, .staticProcedure proc) => pure proc.outputs.length - | _ => pure 1 - | _ => pure 1 - if targets'.length != expectedOutputCount then + -- Check that LHS target count matches the RHS arity (derived from the value type). + let expectedOutputCount := match valueTy.val with + | .MultiValuedExpr tys => tys.length + | _ => 1 + if valueTy.val != HighType.TVoid && targets'.length != expectedOutputCount then let diag := diagnosticFromSource source s!"Assignment target count mismatch: {targets'.length} targets but right-hand side produces {expectedOutputCount} values" modify fun s => { s with errors := s.errors.push diag } From f5302f971e4f977f625dd761942f1a548f83771d Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 19:26:32 +0000 Subject: [PATCH 061/189] Add tests for type checking error diagnostics in resolution pass Tests confirm that the following type errors are reported: - Non-boolean condition in if/assert/assume/while - Non-boolean operand in logical operators (&&) - Non-numeric operand in comparisons (<) - Assignment type mismatch (int := bool) - Function return type mismatch - Static call argument type mismatch --- .../Laurel/ResolutionTypeCheckTests.lean | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean new file mode 100644 index 0000000000..01ccd40708 --- /dev/null +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -0,0 +1,149 @@ +/- + Copyright Strata Contributors + + SPDX-License-Identifier: Apache-2.0 OR MIT +-/ + +/- +Tests that the resolution pass detects type checking errors — e.g. using an int +where a bool is expected, or passing the wrong type to a procedure. +-/ + +import StrataTest.Util.TestDiagnostics +import Strata.DDM.Elab +import Strata.DDM.BuiltinDialects.Init +import Strata.Languages.Laurel.Grammar.LaurelGrammar +import Strata.Languages.Laurel.Grammar.ConcreteToAbstractTreeTranslator +import Strata.Languages.Laurel.Resolution + +open StrataTest.Util +open Strata +open Strata.Elab (parseStrataProgramFromDialect) + +namespace Strata.Laurel + +/-- Run only parsing + resolution and return diagnostics (no SMT verification). -/ +private def processResolution (input : Lean.Parser.InputContext) : IO (Array Diagnostic) := do + let dialects := Strata.Elab.LoadedDialects.ofDialects! #[initDialect, Laurel] + let strataProgram ← parseStrataProgramFromDialect dialects Laurel.name input + let uri := Strata.Uri.file input.fileName + match Laurel.TransM.run uri (Laurel.parseProgram strataProgram) with + | .error e => throw (IO.userError s!"Translation errors: {e}") + | .ok program => + let result := resolve program + let files := Map.insert Map.empty uri input.fileMap + return result.errors.toList.map (fun dm => dm.toDiagnostic files) |>.toArray + +/-! ## Non-boolean condition in if-then-else -/ + +def ifCondNotBool := r" +function foo(x: int): int { + if x then 1 else 0 +// ^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "IfCondNotBool" ifCondNotBool 39 processResolution + +/-! ## Non-boolean condition in assert -/ + +def assertCondNotBool := r" +procedure baz() opaque { + var x: int := 42; + assert x +// ^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "AssertCondNotBool" assertCondNotBool 49 processResolution + +/-! ## Non-boolean condition in assume -/ + +def assumeCondNotBool := r" +procedure qux() opaque { + var x: int := 42; + assume x +// ^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "AssumeCondNotBool" assumeCondNotBool 59 processResolution + +/-! ## Non-boolean operand in logical and -/ + +def logicalAndNotBool := r" +function foo(x: int, y: bool): bool { + x && y +//^^^^^^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "LogicalAndNotBool" logicalAndNotBool 69 processResolution + +/-! ## Assignment type mismatch -/ + +def assignTypeMismatch := r" +procedure foo() opaque { + var x: int := true +//^^^^^^^^^^^^^^^^^^ error: expected 'int', but got 'bool' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "AssignTypeMismatch" assignTypeMismatch 79 processResolution + +/-! ## Function return type mismatch -/ + +def returnTypeMismatch := r" +function foo(): int { +// ^^^ error: expected 'int', but got 'bool' + true +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "ReturnTypeMismatch" returnTypeMismatch 89 processResolution + +/-! ## Static call argument type mismatch -/ + +def callArgTypeMismatch := r" +function bar(x: int): int { x }; +function foo(): int { + bar(true) +//^^^^^^^^^ error: expected 'int', but got 'bool' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "CallArgTypeMismatch" callArgTypeMismatch 99 processResolution + +/-! ## Non-boolean condition in while loop -/ + +def whileCondNotBool := r" +procedure wh() opaque { + var x: int := 1; + while (x) { } +// ^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "WhileCondNotBool" whileCondNotBool 109 processResolution + +/-! ## Non-numeric operand in comparison -/ + +def comparisonNotNumeric := r" +function cmp(x: string, y: int): bool { + x < y +//^^^^^ error: expected a numeric type, but got 'string' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "ComparisonNotNumeric" comparisonNotNumeric 121 processResolution + +end Laurel From 7d00faed7089f4493e04a4e8d1266c6f14610ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 09:44:39 -0400 Subject: [PATCH 062/189] add explanations about the typechecking routine added --- Strata/Languages/Laurel/Resolution.lean | 77 +++++++-- docs/verso/LaurelDoc.lean | 215 ++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 14 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 4bfa2d39dc..e7155a7ca8 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -13,24 +13,73 @@ import Strata.Languages.Python.PythonLaurelCorePrelude /-! # Name Resolution Pass -Assigns a unique numeric ID to every definition and reference node in a -Laurel program, then resolves references to their definitions. +Turns a freshly parsed Laurel `Program` (where every `Identifier` has +`uniqueId := none`) into a program where every definition has a fresh numeric +ID and every reference points to the ID of the definition it names. The pass +also synthesizes a `HighType` for every `StmtExpr` and emits diagnostics for +unresolved names, duplicate definitions, kind mismatches (e.g. using a +constant where a type is expected), and type mismatches. + +The entry point is `resolve`. It returns a `ResolutionResult` containing the +resolved program, a `SemanticModel` (the `refToDef` map and ID counters), and +the accumulated diagnostics. ## Design -The resolution pass operates in two phases: +The resolution pass operates in two phases. ### Phase 1: ID Assignment and Reference Resolution -Walks the AST, assigning fresh unique IDs to all definition nodes and -resolving references by looking up names in the current lexical scope. -After this phase, every definition and reference node has its `id` field -filled in. + +Walks the AST under `ResolveM`, a state monad over `ResolveState`. Phase 1: +- assigns fresh unique IDs to all definition nodes via `defineNameCheckDup`, +- resolves references by looking up names in the current lexical scope via + `resolveRef` (and `resolveFieldRef` for fields, which uses the target's + declared type to build a qualified lookup key), +- opens fresh nested scopes via `withScope` for blocks, quantifiers, + procedure bodies, and constrained-type constraint/witness expressions, +- synthesizes a `HighType` for every `StmtExpr` and runs the type-checking + helpers (`checkBool`, `checkNumeric`, `checkAssignable`, `checkComparable`) + on assignments, call arguments, condition positions, functional bodies, and + constant initializers. + +Before any bodies are walked, `preRegisterTopLevel` registers every top-level +name (types and their constructors / testers / destructors / instance +procedures / fields, constants, static procedures) into scope with a +placeholder `ResolvedNode`. The placeholders are overwritten with real nodes +as each definition is fully resolved. This is what allows declaration order to +not matter inside a Laurel program. + +When a reference fails to resolve, or a `UserDefined` type reference resolves +to the wrong kind, Phase 1 records the name as `ResolvedNode.unresolved` (or +the type as `HighType.Unknown`) and continues. Both are treated as wildcards +by the type checker, so subsequent uses do not produce cascading errors. + +After this phase, every definition and reference node has its `uniqueId` +field filled in. ### Phase 2: Build refToDef Map + Walks the *resolved* AST (where all definitions already have their UUIDs) -and builds a map from each definition's ID to its `ResolvedNode`. Because this -happens after Phase 1, the `ResolvedNode` values in the map contain the fully -resolved sub-trees (e.g. a procedure's parameters already have their IDs). +and builds a map from each definition's ID to its `ResolvedNode`. Because +this happens after Phase 1, the `ResolvedNode` values in the map contain the +fully resolved sub-trees (e.g. a procedure's parameters already have their +IDs). + +### Scopes + +Three forms of scope are maintained on `ResolveState`: +- `scope` — the current lexical scope, mapping name → `(uniqueId, ResolvedNode)`, + saved and restored by `withScope`. +- `currentScopeNames` — names defined at the current nesting level only, used + by `defineNameCheckDup` to detect duplicates. +- `typeScopes` — per-composite-type scopes mapping field names to scope + entries. Built by `resolveTypeDefinition` *before* descending into instance + procedures (and inheriting from `extending` parents), so that field + references inside method bodies can be resolved. +- `instanceTypeName` — when resolving inside an instance procedure, the + owning composite type's name. Used by `resolveFieldRef` as a fallback so + that a bare `self.field` reference resolves through the type scope when + `self` has type `Any`. ### Definition nodes (introduce a name into scope) - `Variable.Declare` — local variable declaration (in `Assign` targets or `Var`) @@ -51,10 +100,10 @@ resolved sub-trees (e.g. a procedure's parameters already have their IDs). - `StmtExpr.Exit` — exit a labelled block - `HighType.UserDefined` — type reference -Each of these nodes carries an `id : Nat` field (defaulting to `0`). -The ID assignment pass fills in unique values. The resolution pass then -builds a map from reference IDs to `ResolvedNode` values describing the -definition each reference resolves to. +Each of these nodes carries a `uniqueId : Option Nat` field (defaulting to +`none`). Phase 1 fills in unique values; Phase 2 then builds a map from +reference IDs to `ResolvedNode` values describing the definition each +reference resolves to. -/ namespace Strata.Laurel diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 4d153eb439..ef6014580d 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -146,6 +146,221 @@ A Laurel program consists of procedures, global variables, type definitions, and {docstring Strata.Laurel.Program} +# Type checking + +Type checking runs as part of the resolution pass, in `resolveStmtExpr`. Resolution +synthesizes a {name Strata.Laurel.HighType}`HighType` for every {name Strata.Laurel.StmtExpr}`StmtExpr` +bottom-up and emits diagnostics when the synthesized type clashes with what its context +requires. + +## Type system at a glance + +The checker is *synthesis-only* (no inference, no subtyping) over a flat type lattice, with +three _wildcard_ types that disable checking: + +- {name Strata.Laurel.HighType.Unknown}`Unknown` — synthesized when a name fails to resolve, + when a {name Strata.Laurel.HighType.UserDefined}`UserDefined` reference resolves to the + wrong kind, or for constructs whose result type isn't tracked + ({name Strata.Laurel.StmtExpr.This}`This`, + {name Strata.Laurel.StmtExpr.Abstract}`Abstract`, + {name Strata.Laurel.StmtExpr.All}`All`, + {name Strata.Laurel.StmtExpr.ContractOf}`ContractOf`, untyped + {name Strata.Laurel.StmtExpr.Hole}`Hole`). It is compatible with everything in both + directions (acts like _any_). +- {name Strata.Laurel.HighType.UserDefined}`UserDefined _` — also treated bivariantly. + Subtype/inheritance relationships aren't tracked here, and a + {name Strata.Laurel.HighType.UserDefined}`UserDefined` may be a constrained type wrapping a + primitive, so it's accepted wherever a primitive is expected. +- {name Strata.Laurel.HighType.TCore}`TCore _` — pass-through types from the Core language; + never checked. + +Everything else ({name Strata.Laurel.HighType.TInt}`TInt`, +{name Strata.Laurel.HighType.TReal}`TReal`, +{name Strata.Laurel.HighType.TFloat64}`TFloat64`, +{name Strata.Laurel.HighType.TBool}`TBool`, +{name Strata.Laurel.HighType.TString}`TString`, +{name Strata.Laurel.HighType.TVoid}`TVoid`, +{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr [..]`) is compared by +*structural equality* via {name Strata.Laurel.highEq}`highEq`. There is no implicit numeric +promotion: {name Strata.Laurel.HighType.TInt}`TInt`, +{name Strata.Laurel.HighType.TReal}`TReal`, and +{name Strata.Laurel.HighType.TFloat64}`TFloat64` are siblings, not a chain. + +{name Strata.Laurel.HighType.TVoid}`TVoid` marks expressions that produce no value +({name Strata.Laurel.StmtExpr.Return}`Return`, +{name Strata.Laurel.StmtExpr.Exit}`Exit`, +{name Strata.Laurel.StmtExpr.While}`While`, +{name Strata.Laurel.StmtExpr.Assert}`Assert`, +{name Strata.Laurel.StmtExpr.Assume}`Assume`, +{name Strata.Laurel.Variable.Declare}`Var Declare`, opaque/abstract/external bodies). +{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr tys` models the result of a +procedure call with multiple outputs. + +## Checking judgments + +Four helper checks fire from context positions: + +- `checkBool` — accepts {name Strata.Laurel.HighType.TBool}`TBool`, + {name Strata.Laurel.HighType.Unknown}`Unknown`, or any + {name Strata.Laurel.HighType.UserDefined}`UserDefined`. Used by + {name Strata.Laurel.StmtExpr.IfThenElse}`if`/{name Strata.Laurel.StmtExpr.While}`while` + conditions, logical primitive ops, + {name Strata.Laurel.StmtExpr.Assert}`Assert`, and + {name Strata.Laurel.StmtExpr.Assume}`Assume`. +- `checkNumeric` — accepts {name Strata.Laurel.HighType.TInt}`TInt`, + {name Strata.Laurel.HighType.TReal}`TReal`, + {name Strata.Laurel.HighType.TFloat64}`TFloat64`, + {name Strata.Laurel.HighType.Unknown}`Unknown`, or any + {name Strata.Laurel.HighType.UserDefined}`UserDefined`. Used by arithmetic and ordering + primitive ops. +- `checkAssignable expected actual` — accepts equality under + {name Strata.Laurel.highEq}`highEq`, *or* either side being + {name Strata.Laurel.HighType.Unknown}`Unknown` / + {name Strata.Laurel.HighType.UserDefined}`UserDefined` / + {name Strata.Laurel.HighType.TCore}`TCore`. Used by assignment, call arguments, functional + body vs. declared output, and constant initializers. +- `checkComparable` — same wildcards as `checkAssignable`, but with a symmetric error message. + Used for the operands of {name Strata.Laurel.Operation.Eq}`==` and + {name Strata.Laurel.Operation.Neq}`!=`. + +The {name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out in `checkBool` and +`checkNumeric` is conservative on purpose: a constrained type might wrap a +{name Strata.Laurel.HighType.TBool}`bool` or a numeric type. + +## Synthesis rules + +Literals synthesize their obvious primitive types: integers give +{name Strata.Laurel.HighType.TInt}`TInt`, booleans +{name Strata.Laurel.HighType.TBool}`TBool`, strings +{name Strata.Laurel.HighType.TString}`TString`, decimals +{name Strata.Laurel.HighType.TReal}`TReal`. Variable and field references take their type +from scope; a {name Strata.Laurel.Variable.Declare}`Var (.Declare p)` synthesizes +{name Strata.Laurel.HighType.TVoid}`TVoid` because it is a declaration statement. + +Control flow: +- {name Strata.Laurel.StmtExpr.IfThenElse}`if c then t else e_1; …; e_n` — `c` is checked + against bool; the result type is the _then_-branch type. Else-branch types are discarded. +- {name Strata.Laurel.StmtExpr.Block}`Block [s_1; …; s_n]` — the type is the last + statement's type, or {name Strata.Laurel.HighType.TVoid}`TVoid` if empty. This is what makes + a transparent functional body usable as a value. +- {name Strata.Laurel.StmtExpr.While}`While`, + {name Strata.Laurel.StmtExpr.Exit}`Exit`, + {name Strata.Laurel.StmtExpr.Return}`Return _`, + {name Strata.Laurel.StmtExpr.Assert}`Assert`, + {name Strata.Laurel.StmtExpr.Assume}`Assume` — all synthesize + {name Strata.Laurel.HighType.TVoid}`TVoid`. The condition positions of + {name Strata.Laurel.StmtExpr.While}`While`, + {name Strata.Laurel.StmtExpr.Assert}`Assert`, and + {name Strata.Laurel.StmtExpr.Assume}`Assume` enforce `checkBool`. + +Calls ({name Strata.Laurel.StmtExpr.StaticCall}`StaticCall`, +{name Strata.Laurel.StmtExpr.InstanceCall}`InstanceCall`) synthesize each argument, then apply +`checkAssignable param arg` pairwise. +{name Strata.Laurel.StmtExpr.InstanceCall}`InstanceCall` drops the first parameter (the +implicit `self`). The return type is determined as follows: +- procedure with one output → that output's type +- procedure with `n ≠ 1` outputs → + {name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr [t_1, …, t_n]` +- datatype constructor whose name contains `..is` → + {name Strata.Laurel.HighType.TBool}`TBool` (testers) +- other datatype constructors → {name Strata.Laurel.HighType.UserDefined}`UserDefined T` +- parameters or constants in callee position → their declared type +- anything else → {name Strata.Laurel.HighType.Unknown}`Unknown` + +Primitive ops (see {name Strata.Laurel.Operation}`Operation`): +- {name Strata.Laurel.Operation.And}`And`, + {name Strata.Laurel.Operation.Or}`Or`, + {name Strata.Laurel.Operation.AndThen}`AndThen`, + {name Strata.Laurel.Operation.OrElse}`OrElse`, + {name Strata.Laurel.Operation.Not}`Not`, + {name Strata.Laurel.Operation.Implies}`Implies` — operands `checkBool`; result + {name Strata.Laurel.HighType.TBool}`TBool`. +- {name Strata.Laurel.Operation.Lt}`Lt`, + {name Strata.Laurel.Operation.Leq}`Leq`, + {name Strata.Laurel.Operation.Gt}`Gt`, + {name Strata.Laurel.Operation.Geq}`Geq` — operands `checkNumeric`; result + {name Strata.Laurel.HighType.TBool}`TBool`. +- {name Strata.Laurel.Operation.Eq}`Eq`, + {name Strata.Laurel.Operation.Neq}`Neq` — `checkComparable lhs rhs` (binary only); result + {name Strata.Laurel.HighType.TBool}`TBool`. +- {name Strata.Laurel.Operation.Neg}`Neg`, + {name Strata.Laurel.Operation.Add}`Add`, + {name Strata.Laurel.Operation.Sub}`Sub`, + {name Strata.Laurel.Operation.Mul}`Mul`, + {name Strata.Laurel.Operation.Div}`Div`, + {name Strata.Laurel.Operation.Mod}`Mod`, + {name Strata.Laurel.Operation.DivT}`DivT`, + {name Strata.Laurel.Operation.ModT}`ModT` — operands `checkNumeric`; result is the type of + the first argument. +- {name Strata.Laurel.Operation.StrConcat}`StrConcat` — no operand check; result + {name Strata.Laurel.HighType.TString}`TString`. + +The _result is the type of the first argument_ rule is how arithmetic handles +{name Strata.Laurel.HighType.TInt}`TInt` / {name Strata.Laurel.HighType.TReal}`TReal` / +{name Strata.Laurel.HighType.TFloat64}`TFloat64` without a unification step. A consequence: +`int + real` will not be flagged, since each operand individually passes `checkNumeric`. + +Other forms: +- {name Strata.Laurel.StmtExpr.New}`New T` synthesizes + {name Strata.Laurel.HighType.UserDefined}`UserDefined T`, falling back to + {name Strata.Laurel.HighType.Unknown}`Unknown` if `T` resolved to the wrong kind. +- {name Strata.Laurel.StmtExpr.AsType}`AsType e T` synthesizes `T`. + {name Strata.Laurel.StmtExpr.IsType}`IsType _ _` and + {name Strata.Laurel.StmtExpr.ReferenceEquals}`ReferenceEquals` synthesize + {name Strata.Laurel.HighType.TBool}`TBool`. +- {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`, + {name Strata.Laurel.StmtExpr.Assigned}`Assigned`, + {name Strata.Laurel.StmtExpr.Fresh}`Fresh` synthesize + {name Strata.Laurel.HighType.TBool}`TBool`. +- {name Strata.Laurel.StmtExpr.Old}`Old e` and + {name Strata.Laurel.StmtExpr.ProveBy}`ProveBy val proof` propagate the type of their first + sub-expression. {name Strata.Laurel.StmtExpr.PureFieldUpdate}`PureFieldUpdate target …` + propagates the type of `target`. +- {name Strata.Laurel.StmtExpr.Hole}`Hole _ (some T)` synthesizes `T`. + {name Strata.Laurel.StmtExpr.Hole}`Hole _ none`, + {name Strata.Laurel.StmtExpr.This}`This`, + {name Strata.Laurel.StmtExpr.Abstract}`Abstract`, + {name Strata.Laurel.StmtExpr.All}`All`, and + {name Strata.Laurel.StmtExpr.ContractOf}`ContractOf` synthesize + {name Strata.Laurel.HighType.Unknown}`Unknown`. + +## Checking positions + +There is no separate checking mode — checking happens by synthesizing and then invoking one of +the four helpers above. The places that check: + +1. *Assignment.* Target count must equal RHS arity + ({name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr` length, else 1), suppressed + when RHS is {name Strata.Laurel.HighType.TVoid}`TVoid`. When single-target and arities + match, `checkAssignable target_ty value_ty` runs. +2. *Call arguments.* `checkAssignable param_ty arg_ty` for each pair (instance calls skip + `self`). +3. *Functional procedure body.* When a {name Strata.Laurel.Procedure}`Procedure` is + `isFunctional`, has a transparent body, exactly one output, and the body type is not + {name Strata.Laurel.HighType.TVoid}`TVoid`, `checkAssignable output_ty body_ty` runs. +4. *Constant initializer.* `checkAssignable declared_ty init_ty`, skipped when the + initializer is {name Strata.Laurel.HighType.TVoid}`TVoid`. + +## Summary + +In type-system terms, the checker is: + +- *monomorphic, structurally-equal, no-subtyping* over primitive types, +- with a *gradual / dynamic escape hatch* — {name Strata.Laurel.HighType.Unknown}`Unknown`, + {name Strata.Laurel.HighType.UserDefined}`UserDefined`, and + {name Strata.Laurel.HighType.TCore}`TCore` are bivariantly compatible with everything, so + unresolved names, user-defined types, and Core types never produce spurious mismatches, +- in *synthesis-only direction* (no contextual checking flowing into expressions), +- with *arity tracking via tuple types* + ({name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr`) for multi-output + procedures, +- and *side-effecting expressions modeled as* + {name Strata.Laurel.HighType.TVoid}`TVoid` so blocks, returns, and loops compose cleanly. + +The wildcard carve-outs are the dominant design choice: the checker's behavior on +user-defined and unresolved-kind code is essentially _anything goes_, and strict checking +applies only between the built-in primitive types. + # Translation Pipeline Laurel programs are verified by translating them to Strata Core and then invoking the Core From 941f4aec63c01e1e2b147f34bbe67f7b416eef0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 11:54:47 -0400 Subject: [PATCH 063/189] bidirectional type checking first implementation : blocks --- Strata/Languages/Laurel/Resolution.lean | 155 ++++++++++++++++-------- 1 file changed, 102 insertions(+), 53 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index e7155a7ca8..b423e09304 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -423,6 +423,19 @@ private def typeMismatch (source : Option FileRange) (expected : String) (actual let diag := diagnosticFromSource source s!"Type mismatch: expected {expected}, but got '{actualStr}'" modify fun s => { s with errors := s.errors.push diag } +/-- Subtyping. Stub: structural equality via `highEq`. + TODO: To be replaced with a real check that walks `extending` chains for composites, unfolds aliases, and unwraps constrained types to their base. -/ +private def isSubtype (sub sup : HighTypeMd) : Bool := highEq sub sup + +/-- Gradual consistency-subtyping (Siek–Taha style): `Unknown` is the dynamic + type and is consistent with everything in either direction. `TCore` is a + migration escape hatch and is bivariantly compatible for now. -/ +private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := + match sub.val, sup.val with + | .Unknown, _ | _, .Unknown => true + | .TCore _, _ | _, .TCore _ => true + | _, _ => isSubtype sub sup + /-- Check that a type is boolean, emitting a diagnostic if not. -/ private def checkBool (source : Option FileRange) (ty : HighTypeMd) : ResolveM Unit := do match ty.val with @@ -503,38 +516,41 @@ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List Hig | some (_, .constant c) => pure (c.type, []) | _ => pure ({ val := .Unknown, source := callee.source }, []) -def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do +def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match _: exprMd with | AstNode.mk expr source => let (val', ty) ← match _: expr with | .IfThenElse cond thenBr elseBr => - let (cond', condTy) ← resolveStmtExpr cond + let (cond', condTy) ← synthStmtExpr cond checkBool cond'.source condTy - let (thenBr', thenTy) ← resolveStmtExpr thenBr + let (thenBr', thenTy) ← synthStmtExpr thenBr let elseBr' ← elseBr.attach.mapM (fun a => have := a.property; do - let (e', _) ← resolveStmtExpr a.val; pure e') + let (e', _) ← synthStmtExpr a.val; pure e') pure (.IfThenElse cond' thenBr' elseBr', thenTy) | .Block stmts label => + -- Synth-mode block: non-last statements have their synthesized type discarded + -- (lax rule, matches Java/Python/JS expression-statement semantics). + -- The last statement's synthesized type becomes the block's type. withScope do - let results ← stmts.mapM resolveStmtExpr + let results ← stmts.mapM synthStmtExpr let stmts' := results.map (·.1) let lastTy := match results.getLast? with | some (_, ty) => ty | none => { val := .TVoid, source := source } pure (.Block stmts' label, lastTy) | .While cond invs dec body => - let (cond', condTy) ← resolveStmtExpr cond + let (cond', condTy) ← synthStmtExpr cond checkBool cond'.source condTy let invs' ← invs.attach.mapM (fun a => have := a.property; do - let (e', _) ← resolveStmtExpr a.val; pure e') + let (e', _) ← synthStmtExpr a.val; pure e') let dec' ← dec.attach.mapM (fun a => have := a.property; do - let (e', _) ← resolveStmtExpr a.val; pure e') - let (body', _) ← resolveStmtExpr body + let (e', _) ← synthStmtExpr a.val; pure e') + let (body', _) ← synthStmtExpr body pure (.While cond' invs' dec' body', { val := .TVoid, source := source }) | .Exit target => pure (.Exit target, { val := .TVoid, source := source }) | .Return val => do let val' ← val.attach.mapM (fun a => have := a.property; do - let (e', _) ← resolveStmtExpr a.val; pure e') + let (e', _) ← synthStmtExpr a.val; pure e') pure (.Return val', { val := .TVoid, source := source }) | .LiteralInt v => pure (.LiteralInt v, { val := .TInt, source := source }) | .LiteralBool v => pure (.LiteralBool v, { val := .TBool, source := source }) @@ -556,14 +572,14 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) let ref' ← resolveRef ref source pure (⟨.Local ref', vs⟩ : VariableMd) | .Field target fieldName => - let (target', _) ← resolveStmtExpr target + let (target', _) ← synthStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source pure (⟨.Field target' fieldName', vs⟩ : VariableMd) | .Declare param => let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let (value', valueTy) ← resolveStmtExpr value + let (value', valueTy) ← synthStmtExpr value -- Check that LHS target count matches the RHS arity (derived from the value type). let expectedOutputCount := match valueTy.val with | .MultiValuedExpr tys => tys.length @@ -593,19 +609,19 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) checkAssignable source tTy valueTy pure (.Assign targets' value', valueTy) | .Var (.Field target fieldName) => - let (target', _) ← resolveStmtExpr target + let (target', _) ← synthStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source let ty ← getVarType fieldName pure (.Var (.Field target' fieldName'), ty) | .PureFieldUpdate target fieldName newVal => - let (target', targetTy) ← resolveStmtExpr target + let (target', targetTy) ← synthStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source - let (newVal', _) ← resolveStmtExpr newVal + let (newVal', _) ← synthStmtExpr newVal pure (.PureFieldUpdate target' fieldName' newVal', targetTy) | .StaticCall callee args => let callee' ← resolveRef callee source (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) - let results ← args.mapM resolveStmtExpr + let results ← args.mapM synthStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee @@ -614,7 +630,7 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) checkAssignable source paramTy argTy pure (.StaticCall callee' args', retTy) | .PrimitiveOp op args => - let results ← args.mapM resolveStmtExpr + let results ← args.mapM synthStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let resultTy := match op with @@ -652,22 +668,22 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) pure (.New ref', ty) | .This => pure (.This, { val := .Unknown, source := source }) | .ReferenceEquals lhs rhs => - let (lhs', _) ← resolveStmtExpr lhs - let (rhs', _) ← resolveStmtExpr rhs + let (lhs', _) ← synthStmtExpr lhs + let (rhs', _) ← synthStmtExpr rhs pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => - let (target', _) ← resolveStmtExpr target + let (target', _) ← synthStmtExpr target let ty' ← resolveHighType ty pure (.AsType target' ty', ty') | .IsType target ty => - let (target', _) ← resolveStmtExpr target + let (target', _) ← synthStmtExpr target let ty' ← resolveHighType ty pure (.IsType target' ty', { val := .TBool, source := source }) | .InstanceCall target callee args => - let (target', _) ← resolveStmtExpr target + let (target', _) ← synthStmtExpr target let callee' ← resolveRef callee source (expected := #[.instanceProcedure, .staticProcedure]) - let results ← args.mapM resolveStmtExpr + let results ← args.mapM synthStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee @@ -681,32 +697,32 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) let paramTy' ← resolveHighType param.type let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do - let (e', _) ← resolveStmtExpr pv.val; pure e') - let (body', _) ← resolveStmtExpr body + let (e', _) ← synthStmtExpr pv.val; pure e') + let (body', _) ← synthStmtExpr body pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) | .Assigned name => - let (name', _) ← resolveStmtExpr name + let (name', _) ← synthStmtExpr name pure (.Assigned name', { val := .TBool, source := source }) | .Old val => - let (val', valTy) ← resolveStmtExpr val + let (val', valTy) ← synthStmtExpr val pure (.Old val', valTy) | .Fresh val => - let (val', _) ← resolveStmtExpr val + let (val', _) ← synthStmtExpr val pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => - let (cond', condTy) ← resolveStmtExpr condExpr + let (cond', condTy) ← synthStmtExpr condExpr checkBool cond'.source condTy pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) | .Assume cond => - let (cond', condTy) ← resolveStmtExpr cond + let (cond', condTy) ← synthStmtExpr cond checkBool cond'.source condTy pure (.Assume cond', { val := .TVoid, source := source }) | .ProveBy val proof => - let (val', valTy) ← resolveStmtExpr val - let (proof', _) ← resolveStmtExpr proof + let (val', valTy) ← synthStmtExpr val + let (proof', _) ← synthStmtExpr proof pure (.ProveBy val' proof', valTy) | .ContractOf ty fn => - let (fn', _) ← resolveStmtExpr fn + let (fn', _) ← synthStmtExpr fn pure (.ContractOf ty fn', { val := .Unknown, source := source }) | .Abstract => pure (.Abstract, { val := .Unknown, source := source }) | .All => pure (.All, { val := .Unknown, source := source }) @@ -721,8 +737,45 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) /-- Resolve a statement expression, discarding the synthesized type. Use when only the resolved expression is needed (invariants, decreases, etc.). -/ -private def resolveStmtExprExpr (e : StmtExprMd) : ResolveM StmtExprMd := do - let (e', _) ← resolveStmtExpr e; pure e' +private def synthStmtExprExpr (e : StmtExprMd) : ResolveM StmtExprMd := do + let (e', _) ← synthStmtExpr e; pure e' + +/-- Check-mode resolution: resolve `e` and verify its type is a consistent + subtype of `expected`. Bidirectional rules for individual constructs push + `expected` into subexpressions; everything else falls back to subsumption + (synth, then `isConsistentSubtype actual expected`). -/ +def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do + match _: exprMd with + | AstNode.mk expr source => + match _: expr with + | .Block stmts label => + -- Bespoke check rule: discard non-last statement types (lax), push + -- `expected` into the last statement. Empty block reduces to subsumption + -- of TVoid against `expected`. + -- The init traversal calls `synthStmtExpr`, a different function, so it + -- needs no termination proof; only the recursive `checkStmtExpr last` + -- call needs `last ∈ stmts`, supplied by `List.mem_of_getLast?`. + withScope do + let init' ← stmts.dropLast.mapM (fun s => do + let (s', _) ← synthStmtExpr s; pure s') + match _lastResult: stmts.getLast? with + | none => + let tvoid : HighTypeMd := { val := .TVoid, source := source } + unless isConsistentSubtype tvoid expected do + typeMismatch source (formatType expected) tvoid + pure { val := .Block init' label, source := source } + | some last => + have := List.mem_of_getLast? _lastResult + let last' ← checkStmtExpr last expected + pure { val := .Block (init' ++ [last']) label, source := source } + | _ => + -- Subsumption fallback: synth then check `actual <: expected`. + let (e', actual) ← synthStmtExpr exprMd + unless isConsistentSubtype actual expected do + typeMismatch source (formatType expected) actual + pure e' + termination_by exprMd + decreasing_by all_goals term_by_mem /-- Resolve a parameter: assign a fresh ID and add to scope. -/ def resolveParameter (param : Parameter) : ResolveM Parameter := do @@ -734,15 +787,15 @@ def resolveParameter (param : Parameter) : ResolveM Parameter := do def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do match body with | .Transparent b => - let (b', ty) ← resolveStmtExpr b + let (b', ty) ← synthStmtExpr b return (.Transparent b', ty) | .Opaque posts impl mods => - let posts' ← posts.mapM (·.mapM resolveStmtExprExpr) - let impl' ← impl.mapM resolveStmtExprExpr - let mods' ← mods.mapM resolveStmtExprExpr + let posts' ← posts.mapM (·.mapM synthStmtExprExpr) + let impl' ← impl.mapM synthStmtExprExpr + let mods' ← mods.mapM synthStmtExprExpr return (.Opaque posts' impl' mods', { val := .TVoid, source := none }) | .Abstract posts => - let posts' ← posts.mapM (·.mapM resolveStmtExprExpr) + let posts' ← posts.mapM (·.mapM synthStmtExprExpr) return (.Abstract posts', { val := .TVoid, source := none }) | .External => return (.External, { val := .TVoid, source := none }) @@ -752,8 +805,8 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do withScope do let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExprExpr) - let dec' ← proc.decreases.mapM resolveStmtExprExpr + let pres' ← proc.preconditions.mapM (·.mapM synthStmtExprExpr) + let dec' ← proc.decreases.mapM synthStmtExprExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -767,7 +820,7 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM resolveStmtExprExpr + let invokeOn' ← proc.invokeOn.mapM synthStmtExprExpr return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, preconditions := pres', decreases := dec', @@ -793,8 +846,8 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv modify fun s => { s with instanceTypeName := some typeName.text } let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExprExpr) - let dec' ← proc.decreases.mapM resolveStmtExprExpr + let pres' ← proc.preconditions.mapM (·.mapM synthStmtExprExpr) + let dec' ← proc.decreases.mapM synthStmtExprExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -807,7 +860,7 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM resolveStmtExprExpr + let invokeOn' ← proc.invokeOn.mapM synthStmtExprExpr modify fun s => { s with instanceTypeName := savedInstType } return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, @@ -849,8 +902,8 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do -- in scope when resolving the constraint and witness expressions. let (valueName', constraint', witness') ← withScope do let valueName' ← defineNameCheckDup ct.valueName (.quantifierVar ct.valueName base') - let (constraint', _) ← resolveStmtExpr ct.constraint - let (witness', _) ← resolveStmtExpr ct.witness + let (constraint', _) ← synthStmtExpr ct.constraint + let (witness', _) ← synthStmtExpr ct.witness return (valueName', constraint', witness') return .Constrained { name := ctName', base := base', valueName := valueName', constraint := constraint', witness := witness' } @@ -876,11 +929,7 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do /-- Resolve a constant definition. -/ def resolveConstant (c : Constant) : ResolveM Constant := do let ty' ← resolveHighType c.type - let init' ← c.initializer.mapM fun e => do - let (e', eTy) ← resolveStmtExpr e - if eTy.val != HighType.TVoid then - checkAssignable e'.source ty' eTy - pure e' + let init' ← c.initializer.mapM (checkStmtExpr · ty') let name' ← resolveRef c.name return { name := name', type := ty', initializer := init' } From be1d8decb67fa2eb53333b52c595def904efd2c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 11:57:13 -0400 Subject: [PATCH 064/189] add resolution-only function discards the type synthesized --- Strata/Languages/Laurel/Resolution.lean | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index b423e09304..bd49bd8376 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -737,7 +737,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := /-- Resolve a statement expression, discarding the synthesized type. Use when only the resolved expression is needed (invariants, decreases, etc.). -/ -private def synthStmtExprExpr (e : StmtExprMd) : ResolveM StmtExprMd := do +private def resolveStmtExpr (e : StmtExprMd) : ResolveM StmtExprMd := do let (e', _) ← synthStmtExpr e; pure e' /-- Check-mode resolution: resolve `e` and verify its type is a consistent @@ -790,12 +790,12 @@ def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do let (b', ty) ← synthStmtExpr b return (.Transparent b', ty) | .Opaque posts impl mods => - let posts' ← posts.mapM (·.mapM synthStmtExprExpr) - let impl' ← impl.mapM synthStmtExprExpr - let mods' ← mods.mapM synthStmtExprExpr + let posts' ← posts.mapM (·.mapM resolveStmtExpr) + let impl' ← impl.mapM resolveStmtExpr + let mods' ← mods.mapM resolveStmtExpr return (.Opaque posts' impl' mods', { val := .TVoid, source := none }) | .Abstract posts => - let posts' ← posts.mapM (·.mapM synthStmtExprExpr) + let posts' ← posts.mapM (·.mapM resolveStmtExpr) return (.Abstract posts', { val := .TVoid, source := none }) | .External => return (.External, { val := .TVoid, source := none }) @@ -805,8 +805,8 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do withScope do let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM synthStmtExprExpr) - let dec' ← proc.decreases.mapM synthStmtExprExpr + let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) + let dec' ← proc.decreases.mapM resolveStmtExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -820,7 +820,7 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM synthStmtExprExpr + let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, preconditions := pres', decreases := dec', @@ -846,8 +846,8 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv modify fun s => { s with instanceTypeName := some typeName.text } let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM synthStmtExprExpr) - let dec' ← proc.decreases.mapM synthStmtExprExpr + let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) + let dec' ← proc.decreases.mapM resolveStmtExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -860,7 +860,7 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM synthStmtExprExpr + let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr modify fun s => { s with instanceTypeName := savedInstType } return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, From 83e449f1fc6f4a72bb3eecf7da4b66d0882fa80c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 12:10:32 -0400 Subject: [PATCH 065/189] document type system --- docs/verso/LaurelDoc.lean | 402 +++++++++++++++++++------------------- 1 file changed, 200 insertions(+), 202 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index ef6014580d..9f89926f4a 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -148,218 +148,216 @@ A Laurel program consists of procedures, global variables, type definitions, and # Type checking -Type checking runs as part of the resolution pass, in `resolveStmtExpr`. Resolution -synthesizes a {name Strata.Laurel.HighType}`HighType` for every {name Strata.Laurel.StmtExpr}`StmtExpr` -bottom-up and emits diagnostics when the synthesized type clashes with what its context -requires. - -## Type system at a glance - -The checker is *synthesis-only* (no inference, no subtyping) over a flat type lattice, with -three _wildcard_ types that disable checking: - -- {name Strata.Laurel.HighType.Unknown}`Unknown` — synthesized when a name fails to resolve, - when a {name Strata.Laurel.HighType.UserDefined}`UserDefined` reference resolves to the - wrong kind, or for constructs whose result type isn't tracked - ({name Strata.Laurel.StmtExpr.This}`This`, - {name Strata.Laurel.StmtExpr.Abstract}`Abstract`, - {name Strata.Laurel.StmtExpr.All}`All`, - {name Strata.Laurel.StmtExpr.ContractOf}`ContractOf`, untyped - {name Strata.Laurel.StmtExpr.Hole}`Hole`). It is compatible with everything in both - directions (acts like _any_). -- {name Strata.Laurel.HighType.UserDefined}`UserDefined _` — also treated bivariantly. - Subtype/inheritance relationships aren't tracked here, and a - {name Strata.Laurel.HighType.UserDefined}`UserDefined` may be a constrained type wrapping a - primitive, so it's accepted wherever a primitive is expected. -- {name Strata.Laurel.HighType.TCore}`TCore _` — pass-through types from the Core language; - never checked. - -Everything else ({name Strata.Laurel.HighType.TInt}`TInt`, -{name Strata.Laurel.HighType.TReal}`TReal`, -{name Strata.Laurel.HighType.TFloat64}`TFloat64`, -{name Strata.Laurel.HighType.TBool}`TBool`, -{name Strata.Laurel.HighType.TString}`TString`, -{name Strata.Laurel.HighType.TVoid}`TVoid`, -{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr [..]`) is compared by -*structural equality* via {name Strata.Laurel.highEq}`highEq`. There is no implicit numeric -promotion: {name Strata.Laurel.HighType.TInt}`TInt`, -{name Strata.Laurel.HighType.TReal}`TReal`, and -{name Strata.Laurel.HighType.TFloat64}`TFloat64` are siblings, not a chain. - -{name Strata.Laurel.HighType.TVoid}`TVoid` marks expressions that produce no value -({name Strata.Laurel.StmtExpr.Return}`Return`, +Type checking is woven into the resolution pass: every +{name Strata.Laurel.StmtExpr}`StmtExpr` gets a {name Strata.Laurel.HighType}`HighType`, and +mismatches against the surrounding context become diagnostics. The design is +*bidirectional*: each construct is resolved either in *synthesis* mode — return a type +inferred from the expression — or in *checking* mode — verify that the expression has a +given expected type. The two are different functions on +{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. + +This page describes the design choices behind the checker. The implementation is in +`Resolution.lean`. + +## The two judgments + +There are two operations on expressions, written here in standard bidirectional notation: + +``` +Γ ⊢ e ⇒ T -- "e synthesizes T" (synthStmtExpr) +Γ ⊢ e ⇐ T -- "e checks against T" (checkStmtExpr) +``` + +Each construct picks a mode based on whether its type is determined locally (synth) or by +context (check). Mode assignment is part of the design — see _Mode assignment per construct_ +below. + +The two judgments are connected by a single change-of-direction rule, *subsumption*: + +``` +Γ ⊢ e ⇒ A A <: B +───────────────────── (sub) + Γ ⊢ e ⇐ B +``` + +Subsumption is the *only* place the checker switches from check to synth mode. It fires as a +default fallback in {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` for every construct +without a bespoke check rule: synthesize the expression's type, then verify the result is a +subtype of the expected type. Bespoke check rules push the expected type *into* +subexpressions instead of bouncing through synthesis, which keeps error messages localized +and lets the expected type propagate through nested control flow. + +## Subtyping and gradual consistency + +The relation `<:` is implemented by two Lean functions — both currently stubs, both +intended to be sharpened: + +- `isSubtype` — pure subtyping. The stub is structural + equality via {name Strata.Laurel.highEq}`highEq`. The eventual real version walks the + `extending` chain for {name Strata.Laurel.CompositeType}`CompositeType`, unfolds + {name Strata.Laurel.TypeAlias}`TypeAlias` to its target, and unwraps + {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base. +- `isConsistentSubtype` — gradual consistency, in + the Siek–Taha sense. {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type + `?` and is consistent with everything in either direction; otherwise the relation + delegates to `isSubtype`. {name Strata.Laurel.HighType.TCore}`TCore` is bivariantly + consistent for now, as a clearly-labelled migration escape hatch from the Core language — + this carve-out is intentionally temporary. + +Subsumption (and every bespoke check rule) uses +`isConsistentSubtype`, never raw `isSubtype`. That +single choice is what makes the system *gradual*: an expression of type +{name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) +flows freely into any typed slot, and any expression flows freely into a slot of type +{name Strata.Laurel.HighType.Unknown}`Unknown`. Strict checking is applied between +fully-known types only. + +## What changed from the synth-only design + +A previous iteration was synth-only with three *bivariantly-compatible* wildcards: +{name Strata.Laurel.HighType.Unknown}`Unknown`, +{name Strata.Laurel.HighType.UserDefined}`UserDefined`, and +{name Strata.Laurel.HighType.TCore}`TCore`. The +{name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was particularly +load-bearing: it meant that *no* assignment, call argument, or comparison involving a user +type was ever rejected, because subtyping wasn't tracked at all and constrained types +weren't unwrapped — we couldn't tell what was safe. + +The bidirectional design replaces that with two cleanly-separated concerns: + +- {name Strata.Laurel.HighType.Unknown}`Unknown` keeps wildcard semantics, but now as a + *real* semantic claim (gradual typing) rather than a workaround. +- {name Strata.Laurel.HighType.UserDefined}`UserDefined` becomes a regular type. Once + `isSubtype` is implemented properly, `Cat ≤ Animal` will + pass, `Cat ≤ Dog` will fail, and constrained types will be unwrappable to their base. The + current stub is conservative (structural equality only); it can be tightened + incrementally without changing any callers. + +## Block and `TVoid` + +Statement-position constructs that produce no value synthesize +{name Strata.Laurel.HighType.TVoid}`TVoid`: +{name Strata.Laurel.StmtExpr.Return}`Return`, {name Strata.Laurel.StmtExpr.Exit}`Exit`, {name Strata.Laurel.StmtExpr.While}`While`, {name Strata.Laurel.StmtExpr.Assert}`Assert`, {name Strata.Laurel.StmtExpr.Assume}`Assume`, -{name Strata.Laurel.Variable.Declare}`Var Declare`, opaque/abstract/external bodies). -{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr tys` models the result of a -procedure call with multiple outputs. - -## Checking judgments - -Four helper checks fire from context positions: - -- `checkBool` — accepts {name Strata.Laurel.HighType.TBool}`TBool`, - {name Strata.Laurel.HighType.Unknown}`Unknown`, or any - {name Strata.Laurel.HighType.UserDefined}`UserDefined`. Used by - {name Strata.Laurel.StmtExpr.IfThenElse}`if`/{name Strata.Laurel.StmtExpr.While}`while` - conditions, logical primitive ops, - {name Strata.Laurel.StmtExpr.Assert}`Assert`, and - {name Strata.Laurel.StmtExpr.Assume}`Assume`. -- `checkNumeric` — accepts {name Strata.Laurel.HighType.TInt}`TInt`, - {name Strata.Laurel.HighType.TReal}`TReal`, - {name Strata.Laurel.HighType.TFloat64}`TFloat64`, - {name Strata.Laurel.HighType.Unknown}`Unknown`, or any - {name Strata.Laurel.HighType.UserDefined}`UserDefined`. Used by arithmetic and ordering - primitive ops. -- `checkAssignable expected actual` — accepts equality under - {name Strata.Laurel.highEq}`highEq`, *or* either side being - {name Strata.Laurel.HighType.Unknown}`Unknown` / - {name Strata.Laurel.HighType.UserDefined}`UserDefined` / - {name Strata.Laurel.HighType.TCore}`TCore`. Used by assignment, call arguments, functional - body vs. declared output, and constant initializers. -- `checkComparable` — same wildcards as `checkAssignable`, but with a symmetric error message. - Used for the operands of {name Strata.Laurel.Operation.Eq}`==` and - {name Strata.Laurel.Operation.Neq}`!=`. - -The {name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out in `checkBool` and -`checkNumeric` is conservative on purpose: a constrained type might wrap a -{name Strata.Laurel.HighType.TBool}`bool` or a numeric type. - -## Synthesis rules - -Literals synthesize their obvious primitive types: integers give -{name Strata.Laurel.HighType.TInt}`TInt`, booleans -{name Strata.Laurel.HighType.TBool}`TBool`, strings -{name Strata.Laurel.HighType.TString}`TString`, decimals -{name Strata.Laurel.HighType.TReal}`TReal`. Variable and field references take their type -from scope; a {name Strata.Laurel.Variable.Declare}`Var (.Declare p)` synthesizes -{name Strata.Laurel.HighType.TVoid}`TVoid` because it is a declaration statement. - -Control flow: -- {name Strata.Laurel.StmtExpr.IfThenElse}`if c then t else e_1; …; e_n` — `c` is checked - against bool; the result type is the _then_-branch type. Else-branch types are discarded. -- {name Strata.Laurel.StmtExpr.Block}`Block [s_1; …; s_n]` — the type is the last - statement's type, or {name Strata.Laurel.HighType.TVoid}`TVoid` if empty. This is what makes - a transparent functional body usable as a value. -- {name Strata.Laurel.StmtExpr.While}`While`, - {name Strata.Laurel.StmtExpr.Exit}`Exit`, - {name Strata.Laurel.StmtExpr.Return}`Return _`, - {name Strata.Laurel.StmtExpr.Assert}`Assert`, - {name Strata.Laurel.StmtExpr.Assume}`Assume` — all synthesize - {name Strata.Laurel.HighType.TVoid}`TVoid`. The condition positions of - {name Strata.Laurel.StmtExpr.While}`While`, - {name Strata.Laurel.StmtExpr.Assert}`Assert`, and - {name Strata.Laurel.StmtExpr.Assume}`Assume` enforce `checkBool`. - -Calls ({name Strata.Laurel.StmtExpr.StaticCall}`StaticCall`, -{name Strata.Laurel.StmtExpr.InstanceCall}`InstanceCall`) synthesize each argument, then apply -`checkAssignable param arg` pairwise. -{name Strata.Laurel.StmtExpr.InstanceCall}`InstanceCall` drops the first parameter (the -implicit `self`). The return type is determined as follows: -- procedure with one output → that output's type -- procedure with `n ≠ 1` outputs → - {name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr [t_1, …, t_n]` -- datatype constructor whose name contains `..is` → - {name Strata.Laurel.HighType.TBool}`TBool` (testers) -- other datatype constructors → {name Strata.Laurel.HighType.UserDefined}`UserDefined T` -- parameters or constants in callee position → their declared type -- anything else → {name Strata.Laurel.HighType.Unknown}`Unknown` - -Primitive ops (see {name Strata.Laurel.Operation}`Operation`): -- {name Strata.Laurel.Operation.And}`And`, - {name Strata.Laurel.Operation.Or}`Or`, - {name Strata.Laurel.Operation.AndThen}`AndThen`, - {name Strata.Laurel.Operation.OrElse}`OrElse`, - {name Strata.Laurel.Operation.Not}`Not`, - {name Strata.Laurel.Operation.Implies}`Implies` — operands `checkBool`; result - {name Strata.Laurel.HighType.TBool}`TBool`. -- {name Strata.Laurel.Operation.Lt}`Lt`, - {name Strata.Laurel.Operation.Leq}`Leq`, - {name Strata.Laurel.Operation.Gt}`Gt`, - {name Strata.Laurel.Operation.Geq}`Geq` — operands `checkNumeric`; result - {name Strata.Laurel.HighType.TBool}`TBool`. -- {name Strata.Laurel.Operation.Eq}`Eq`, - {name Strata.Laurel.Operation.Neq}`Neq` — `checkComparable lhs rhs` (binary only); result - {name Strata.Laurel.HighType.TBool}`TBool`. -- {name Strata.Laurel.Operation.Neg}`Neg`, - {name Strata.Laurel.Operation.Add}`Add`, - {name Strata.Laurel.Operation.Sub}`Sub`, - {name Strata.Laurel.Operation.Mul}`Mul`, - {name Strata.Laurel.Operation.Div}`Div`, - {name Strata.Laurel.Operation.Mod}`Mod`, - {name Strata.Laurel.Operation.DivT}`DivT`, - {name Strata.Laurel.Operation.ModT}`ModT` — operands `checkNumeric`; result is the type of - the first argument. -- {name Strata.Laurel.Operation.StrConcat}`StrConcat` — no operand check; result - {name Strata.Laurel.HighType.TString}`TString`. - -The _result is the type of the first argument_ rule is how arithmetic handles -{name Strata.Laurel.HighType.TInt}`TInt` / {name Strata.Laurel.HighType.TReal}`TReal` / -{name Strata.Laurel.HighType.TFloat64}`TFloat64` without a unification step. A consequence: -`int + real` will not be flagged, since each operand individually passes `checkNumeric`. - -Other forms: -- {name Strata.Laurel.StmtExpr.New}`New T` synthesizes - {name Strata.Laurel.HighType.UserDefined}`UserDefined T`, falling back to - {name Strata.Laurel.HighType.Unknown}`Unknown` if `T` resolved to the wrong kind. -- {name Strata.Laurel.StmtExpr.AsType}`AsType e T` synthesizes `T`. - {name Strata.Laurel.StmtExpr.IsType}`IsType _ _` and - {name Strata.Laurel.StmtExpr.ReferenceEquals}`ReferenceEquals` synthesize - {name Strata.Laurel.HighType.TBool}`TBool`. -- {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`, - {name Strata.Laurel.StmtExpr.Assigned}`Assigned`, - {name Strata.Laurel.StmtExpr.Fresh}`Fresh` synthesize - {name Strata.Laurel.HighType.TBool}`TBool`. -- {name Strata.Laurel.StmtExpr.Old}`Old e` and - {name Strata.Laurel.StmtExpr.ProveBy}`ProveBy val proof` propagate the type of their first - sub-expression. {name Strata.Laurel.StmtExpr.PureFieldUpdate}`PureFieldUpdate target …` - propagates the type of `target`. -- {name Strata.Laurel.StmtExpr.Hole}`Hole _ (some T)` synthesizes `T`. - {name Strata.Laurel.StmtExpr.Hole}`Hole _ none`, - {name Strata.Laurel.StmtExpr.This}`This`, - {name Strata.Laurel.StmtExpr.Abstract}`Abstract`, - {name Strata.Laurel.StmtExpr.All}`All`, and - {name Strata.Laurel.StmtExpr.ContractOf}`ContractOf` synthesize - {name Strata.Laurel.HighType.Unknown}`Unknown`. - -## Checking positions - -There is no separate checking mode — checking happens by synthesizing and then invoking one of -the four helpers above. The places that check: - -1. *Assignment.* Target count must equal RHS arity - ({name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr` length, else 1), suppressed - when RHS is {name Strata.Laurel.HighType.TVoid}`TVoid`. When single-target and arities - match, `checkAssignable target_ty value_ty` runs. -2. *Call arguments.* `checkAssignable param_ty arg_ty` for each pair (instance calls skip - `self`). -3. *Functional procedure body.* When a {name Strata.Laurel.Procedure}`Procedure` is - `isFunctional`, has a transparent body, exactly one output, and the body type is not - {name Strata.Laurel.HighType.TVoid}`TVoid`, `checkAssignable output_ty body_ty` runs. -4. *Constant initializer.* `checkAssignable declared_ty init_ty`, skipped when the - initializer is {name Strata.Laurel.HighType.TVoid}`TVoid`. - -## Summary - -In type-system terms, the checker is: - -- *monomorphic, structurally-equal, no-subtyping* over primitive types, -- with a *gradual / dynamic escape hatch* — {name Strata.Laurel.HighType.Unknown}`Unknown`, - {name Strata.Laurel.HighType.UserDefined}`UserDefined`, and - {name Strata.Laurel.HighType.TCore}`TCore` are bivariantly compatible with everything, so - unresolved names, user-defined types, and Core types never produce spurious mismatches, -- in *synthesis-only direction* (no contextual checking flowing into expressions), +{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies. +This makes blocks compose cleanly: control-flow statements don't pollute a block's +synthesized type. + +A {name Strata.Laurel.StmtExpr.Block}`Block` is statement chaining `{ s_1; …; s_n }`. The +checker treats it permissively in two ways: + +1. *Non-last statements are not required to be {name Strata.Laurel.HighType.TVoid}`TVoid`.* + In synth mode their types are computed and discarded; in check mode they are still + synthesized rather than checked against `void`. This matches Java/Python/JavaScript + expression-statement semantics: `f(x);` where `f` returns a value is normal idiomatic + code, and forcing an explicit discard would be hostile to the imperative style Laurel + targets. The cost is that `5;` (a literal in statement position) is silently accepted; if + we ever want to flag that, it should land as a lint, not a type error. + +2. *The last statement is the block's type.* Empty blocks have type + {name Strata.Laurel.HighType.TVoid}`TVoid`. This is what lets a transparent functional + procedure body be `{ … some statements …; expr }`. + +In check mode, the bespoke `Block` rule pushes the expected type into the *last* statement +rather than checking the block's synthesized type at the boundary. This buys two things: +errors fire at the actual offending sub-expression (e.g. inside a deeply nested +{name Strata.Laurel.StmtExpr.IfThenElse}`if`), and the expected type keeps propagating +through nested {name Strata.Laurel.StmtExpr.Block}`Block` / +{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / +{name Strata.Laurel.StmtExpr.Hole}`Hole` / +{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. Empty blocks reduce to subsumption of +{name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. + +## Mode assignment per construct + +The intended mode for each construct (some are still being converted to bidirectional in +the implementation): + +| Construct | Mode | Notes | +|---|---|---| +| Literals, `Var .Local`, `Var .Field`, `New T`, `IsType`, `ReferenceEquals`, `Quantifier`, `Assigned`, `Fresh`, `Hole _ (some T)`, `StaticCall`, `InstanceCall` | synth | type is determined locally | +| `Var .Declare`, `Exit`, `Return`, `While`, `Assert`, `Assume`, `Assign` | synth ⇒ {name Strata.Laurel.HighType.TVoid}`TVoid` | side-effecting; condition operands checked inward | +| `IfThenElse cond t e_opt` | bespoke check | `cond ⇐ TBool`; `t ⇐ T`; `e ⇐ T` if present | +| `Block` | bespoke check | `s_1..s_{n-1}` synth, `s_n ⇐ T`; synth uses last's synthesized type | +| `Hole _ none` | bespoke check | check mode succeeds with `expected`; synth mode → `Unknown` | +| `AsType e T` | synth ⇒ `T` | the cast is the user's claim; no check on `e` | +| `Old`, `ProveBy v _`, `PureFieldUpdate t _ _` | propagate type of subexpr | unchanged | +| `This`, `Abstract`, `All`, `ContractOf` | synth ⇒ {name Strata.Laurel.HighType.Unknown}`Unknown` | type not tracked | + +{name Strata.Laurel.StmtExpr.PrimitiveOp}`PrimitiveOp` operands are checked inward against +the operator's expected operand type ({name Strata.Laurel.HighType.TBool}`TBool` for +logical, numeric for arithmetic and ordering, {name Strata.Laurel.HighType.TString}`TString` +for `StrConcat`). {name Strata.Laurel.Operation.Eq}`Eq`/{name Strata.Laurel.Operation.Neq}`Neq` +synthesize both operands and require consistency in either direction +(`isConsistentSubtype l r ∨ isConsistentSubtype r l`). + +Arithmetic ops `Neg`/`Add`/…/`ModT` synthesize *the type of the first argument*. This is how +the checker handles {name Strata.Laurel.HighType.TInt}`TInt` / +{name Strata.Laurel.HighType.TReal}`TReal` / {name Strata.Laurel.HighType.TFloat64}`TFloat64` +without a unification step. A consequence: `int + real` is not flagged today, since each +operand passes the numeric check individually. A real fix would be a numeric-promotion or +unification rule; for now this is a known relaxation. + +## Two helpers for resolution sites + +Some positions (procedure preconditions, decreases, invariants, postconditions, modifies +clauses, constrained-type witness, etc.) need resolution to run but the type of the +expression is either uninteresting or already known by another path. They use: + +- {name Strata.Laurel.synthStmtExpr}`synthStmtExpr` — the full synth API, returning + `(StmtExprMd × HighTypeMd)`. +- {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` — the check API, returning the resolved + expression and verifying its type is a consistent subtype of the expected type. +- `resolveStmtExpr` — a thin wrapper that calls + `synthStmtExpr` and discards the synthesized type. Used at sites where typing is not + enforced (verification annotations, modifies/reads clauses). + +The right principle is: when the position has a known expected type +({name Strata.Laurel.HighType.TBool}`TBool` for conditions, numeric for `decreases`, the +declared output for a constant initializer or a functional body), use +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. When it doesn't, use +`resolveStmtExpr`. {name Strata.Laurel.synthStmtExpr}`synthStmtExpr` +itself is mostly an internal interface used by other rules. + +## Returns and the expected return type + +`Return e` synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` (the construct itself +produces no value), but the *value being returned* should be checked against the enclosing +procedure's declared output type. The intended design: thread the expected return type +through {name Strata.Laurel.ResolveState}`ResolveState`, set it from `proc.outputs` in +{name Strata.Laurel.resolveProcedure}`resolveProcedure` / +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` before resolving the +body, and have the `Return` rule push the expected type into its value via +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. This closes a soundness gap in the +synth-only design where `return 0` in a `bool`-returning procedure was not caught (because +the body's overall synthesized type was {name Strata.Laurel.HighType.TVoid}`TVoid` and the +body-vs-output check was skipped on `TVoid`). + +## What this is, in type-system terms + +The checker is: + +- *bidirectional*, with a single subsumption rule at the synth↔check boundary, +- with a *gradual* relation (`isConsistentSubtype`) + rather than a strict one — {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic + type, justified by Laurel's targeting of dynamic source languages, +- over a *nominal-with-stubs* subtype relation + (`isSubtype`) — currently structural equality, intended to + walk inheritance chains and unwrap aliases / constrained types, - with *arity tracking via tuple types* ({name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr`) for multi-output procedures, - and *side-effecting expressions modeled as* {name Strata.Laurel.HighType.TVoid}`TVoid` so blocks, returns, and loops compose cleanly. -The wildcard carve-outs are the dominant design choice: the checker's behavior on -user-defined and unresolved-kind code is essentially _anything goes_, and strict checking -applies only between the built-in primitive types. +The wildcard carve-out for {name Strata.Laurel.HighType.UserDefined}`UserDefined` from the +previous design is gone — user-defined types are no longer a backdoor through the checker. +The {name Strata.Laurel.HighType.TCore}`TCore` carve-out is preserved for now as a +migration aid and is expected to be removed. # Translation Pipeline From a7d90d63ad44660a69dd60d3e9a7a5d5a78c9dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:26:06 -0400 Subject: [PATCH 066/189] ifthenelse type checking --- Strata/Languages/Laurel/Resolution.lean | 43 ++++++++++++++++--------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index bd49bd8376..97f6556331 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -516,17 +516,25 @@ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List Hig | some (_, .constant c) => pure (c.type, []) | _ => pure ({ val := .Unknown, source := callee.source }, []) +mutual def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match _: exprMd with | AstNode.mk expr source => let (val', ty) ← match _: expr with | .IfThenElse cond thenBr elseBr => - let (cond', condTy) ← synthStmtExpr cond - checkBool cond'.source condTy + -- Condition is checked against TBool. The result type is TVoid when the + -- else branch is absent (statement form: the then-branch's value is + -- discarded), otherwise the then-branch's synthesized type. We don't + -- compare the two branches against each other since statement-position + -- ifs commonly mix a value branch with a TVoid branch (return/exit). + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } let (thenBr', thenTy) ← synthStmtExpr thenBr let elseBr' ← elseBr.attach.mapM (fun a => have := a.property; do let (e', _) ← synthStmtExpr a.val; pure e') - pure (.IfThenElse cond' thenBr' elseBr', thenTy) + let resultTy := match elseBr with + | none => { val := .TVoid, source := source } + | some _ => thenTy + pure (.IfThenElse cond' thenBr' elseBr', resultTy) | .Block stmts label => -- Synth-mode block: non-last statements have their synthesized type discarded -- (lax rule, matches Java/Python/JS expression-statement semantics). @@ -732,13 +740,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := pure (.Hole det ty', ty') | none => pure (.Hole det none, { val := .Unknown, source := source }) return ({ val := val', source := source }, ty) - termination_by exprMd - decreasing_by all_goals term_by_mem - -/-- Resolve a statement expression, discarding the synthesized type. - Use when only the resolved expression is needed (invariants, decreases, etc.). -/ -private def resolveStmtExpr (e : StmtExprMd) : ResolveM StmtExprMd := do - let (e', _) ← synthStmtExpr e; pure e' + termination_by (exprMd, 0) + decreasing_by all_goals first + | (apply Prod.Lex.left; term_by_mem) + | (apply Prod.Lex.right; decide) /-- Check-mode resolution: resolve `e` and verify its type is a consistent subtype of `expected`. Bidirectional rules for individual constructs push @@ -752,11 +757,9 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE -- Bespoke check rule: discard non-last statement types (lax), push -- `expected` into the last statement. Empty block reduces to subsumption -- of TVoid against `expected`. - -- The init traversal calls `synthStmtExpr`, a different function, so it - -- needs no termination proof; only the recursive `checkStmtExpr last` - -- call needs `last ∈ stmts`, supplied by `List.mem_of_getLast?`. withScope do - let init' ← stmts.dropLast.mapM (fun s => do + let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do + have : s ∈ stmts := List.dropLast_subset stmts hMem let (s', _) ← synthStmtExpr s; pure s') match _lastResult: stmts.getLast? with | none => @@ -774,8 +777,16 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE unless isConsistentSubtype actual expected do typeMismatch source (formatType expected) actual pure e' - termination_by exprMd - decreasing_by all_goals term_by_mem + termination_by (exprMd, 1) + decreasing_by all_goals first + | (apply Prod.Lex.left; term_by_mem) + | (try subst_eqs; apply Prod.Lex.right; decide) +end + +/-- Resolve a statement expression, discarding the synthesized type. + Use when only the resolved expression is needed (invariants, decreases, etc.). -/ +private def resolveStmtExpr (e : StmtExprMd) : ResolveM StmtExprMd := do + let (e', _) ← synthStmtExpr e; pure e' /-- Resolve a parameter: assign a fresh ID and add to scope. -/ def resolveParameter (param : Parameter) : ResolveM Parameter := do From 0958f26991d097f0846cbb599419142a311d10cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:28:11 -0400 Subject: [PATCH 067/189] document ifthenelse type checking --- docs/verso/LaurelDoc.lean | 57 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 9f89926f4a..c3d9a314f2 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -272,6 +272,61 @@ through nested {name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. Empty blocks reduce to subsumption of {name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. +## IfThenElse + +{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse cond t e_opt` has been converted to +*partial* bidirectional form. Today the implementation has a synth rule but reaches check +mode only through the subsumption fallback; a bespoke check rule that pushes the expected +type into both branches is the planned next step. + +The synth rule: + +- *Condition.* `cond` is checked against {name Strata.Laurel.HighType.TBool}`TBool` via a + recursive `checkStmtExpr cond TBool` call. This replaces the previous synth-then-`checkBool` + pattern with the clean bidirectional one — the expected type is pushed inward, so a + literal `if 5 then …` flags the literal directly rather than the surrounding `if`. +- *Branches.* `thenBr` is synthesized; if present, `elseBr` is synthesized too. The two + branch types are *not* compared against each other. The reason is that in Laurel's + unified statement-expression model, statement-position `if`s commonly mix a value + branch with a {name Strata.Laurel.HighType.TVoid}`TVoid` branch (early + {name Strata.Laurel.StmtExpr.Return}`return`, {name Strata.Laurel.StmtExpr.Exit}`exit`, an + {name Strata.Laurel.StmtExpr.Assert}`assert`, …), which a strict equality check on + branches would reject incorrectly. +- *Result type.* When `elseBr` is `none`, the result is + {name Strata.Laurel.HighType.TVoid}`TVoid` — the construct is in statement form and the + then-branch's value is discarded. When `elseBr` is `some _`, the result is the + then-branch's synthesized type. The arbitrary preference for the then-branch here is + harmless: the result is always consumed by an enclosing `checkAssignable` / + subsumption-fallback, which gives a one-sided check against the surrounding context's + expected type. + +The change to `none` → {name Strata.Laurel.HighType.TVoid}`TVoid` closes a soundness gap in +the previous design, where `if c then 5` synthesized {name Strata.Laurel.HighType.TInt}`TInt` +unconditionally — even though there is no value when `c` is false — so an assignment +`x: int := if c then 5` would have type-checked. With the new rule, the synthesized type is +{name Strata.Laurel.HighType.TVoid}`TVoid` and the assignment is correctly rejected. + +The planned bespoke check rule is straightforward: `cond ⇐ TBool`, `thenBr ⇐ expected`, and +`elseBr ⇐ expected` if present; if absent, fall back to subsumption of +{name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. The benefit is the +same as for `Block`: errors fire at the offending sub-expression rather than the +surrounding `if`, and the expected type propagates through nested control flow. + +## Mutual recursion and termination + +{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr` are now mutually recursive: the synth rule +for {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` invokes check-mode resolution for +the condition, and the check function falls back to synth via the subsumption rule. + +Termination uses a lexicographic measure `(exprMd, tag)` where the tag is `0` for +{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and `1` for +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. Any descent into a strict subterm +decreases via `Prod.Lex.left` (first component shrinks); the subsumption rule +`check e → synth e` calls synth on the *same* expression, which decreases via +`Prod.Lex.right` (second component goes from 1 to 0). This is the standard well-founded +encoding for bidirectional systems where one direction calls the other on the same input. + ## Mode assignment per construct The intended mode for each construct (some are still being converted to bidirectional in @@ -281,7 +336,7 @@ the implementation): |---|---|---| | Literals, `Var .Local`, `Var .Field`, `New T`, `IsType`, `ReferenceEquals`, `Quantifier`, `Assigned`, `Fresh`, `Hole _ (some T)`, `StaticCall`, `InstanceCall` | synth | type is determined locally | | `Var .Declare`, `Exit`, `Return`, `While`, `Assert`, `Assume`, `Assign` | synth ⇒ {name Strata.Laurel.HighType.TVoid}`TVoid` | side-effecting; condition operands checked inward | -| `IfThenElse cond t e_opt` | bespoke check | `cond ⇐ TBool`; `t ⇐ T`; `e ⇐ T` if present | +| `IfThenElse cond t e_opt` | synth (`cond ⇐ TBool`); planned bespoke check | see below | | `Block` | bespoke check | `s_1..s_{n-1}` synth, `s_n ⇐ T`; synth uses last's synthesized type | | `Hole _ none` | bespoke check | check mode succeeds with `expected`; synth mode → `Unknown` | | `AsType e T` | synth ⇒ `T` | the cast is the user's claim; no check on `e` | From 2a513c7a580c03cfe336cee47887ff17d1ce4f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:33:24 -0400 Subject: [PATCH 068/189] typechecking description refactor general design rules (one section per rule) --- docs/verso/LaurelDoc.lean | 392 +++++++++++++++++++++++++++----------- 1 file changed, 277 insertions(+), 115 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index c3d9a314f2..64dd119a59 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -234,128 +234,290 @@ The bidirectional design replaces that with two cleanly-separated concerns: current stub is conservative (structural equality only); it can be tightened incrementally without changing any callers. -## Block and `TVoid` - -Statement-position constructs that produce no value synthesize -{name Strata.Laurel.HighType.TVoid}`TVoid`: -{name Strata.Laurel.StmtExpr.Return}`Return`, -{name Strata.Laurel.StmtExpr.Exit}`Exit`, -{name Strata.Laurel.StmtExpr.While}`While`, -{name Strata.Laurel.StmtExpr.Assert}`Assert`, -{name Strata.Laurel.StmtExpr.Assume}`Assume`, -{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies. -This makes blocks compose cleanly: control-flow statements don't pollute a block's -synthesized type. - -A {name Strata.Laurel.StmtExpr.Block}`Block` is statement chaining `{ s_1; …; s_n }`. The -checker treats it permissively in two ways: - -1. *Non-last statements are not required to be {name Strata.Laurel.HighType.TVoid}`TVoid`.* - In synth mode their types are computed and discarded; in check mode they are still - synthesized rather than checked against `void`. This matches Java/Python/JavaScript - expression-statement semantics: `f(x);` where `f` returns a value is normal idiomatic - code, and forcing an explicit discard would be hostile to the imperative style Laurel - targets. The cost is that `5;` (a literal in statement position) is silently accepted; if - we ever want to flag that, it should land as a lint, not a type error. - -2. *The last statement is the block's type.* Empty blocks have type - {name Strata.Laurel.HighType.TVoid}`TVoid`. This is what lets a transparent functional - procedure body be `{ … some statements …; expr }`. - -In check mode, the bespoke `Block` rule pushes the expected type into the *last* statement -rather than checking the block's synthesized type at the boundary. This buys two things: -errors fire at the actual offending sub-expression (e.g. inside a deeply nested -{name Strata.Laurel.StmtExpr.IfThenElse}`if`), and the expected type keeps propagating -through nested {name Strata.Laurel.StmtExpr.Block}`Block` / +## Notation + +Typing rules are written in the standard derivation-tree form: premises above the line, +conclusion below, rule name on the right. + +``` +premise_1 premise_2 … premise_n +───────────────────────────────────── (Rule-Name) + conclusion +``` + +We use: + +- `e ⇒ T` — _e_ synthesizes _T_ (synth mode, `synthStmtExpr`). +- `e ⇐ T` — _e_ checks against _T_ (check mode, `checkStmtExpr`). +- `T <: U` — gradual consistency-subtyping, i.e. `isConsistentSubtype T U`. +- `Γ` for the lexical scope is left implicit — every rule threads it identically. + +Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This +includes {name Strata.Laurel.StmtExpr.Return}`Return`, +{name Strata.Laurel.StmtExpr.Exit}`Exit`, {name Strata.Laurel.StmtExpr.While}`While`, +{name Strata.Laurel.StmtExpr.Assert}`Assert`, {name Strata.Laurel.StmtExpr.Assume}`Assume`, +{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies +— they're recorded in the rules below. + +## Subsumption (the synth↔check boundary) + +``` +e ⇒ A A <: B +───────────────── (Sub) + e ⇐ B +``` + +Subsumption is the *only* place check switches to synth. It fires as the default fallback +in `checkStmtExpr` for every construct without a bespoke check rule. Bespoke check rules +push the expected type *into* subexpressions, which keeps errors localized. + +## Typing rules + +Below, each construct is given as a derivation. Rules marked with ✓ in the implementation +column are implemented today; rules marked ✗ are planned. The current implementation has +bespoke check rules for {name Strata.Laurel.StmtExpr.Block}`Block` only; everything else +reaches check mode through Sub. Where a synth rule pushes an expected type into a +subexpression (e.g. `cond ⇐ TBool` in {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse`), +that's listed as a premise. + +### Literals and references + +``` + (Lit-Int) ✓ +───────────── ──────────────── ───────────────── + LiteralInt n ⇒ TInt LiteralBool b ⇒ TBool LiteralString s ⇒ TString + +──────────────────────── Γ(x) = T + LiteralDecimal d ⇒ TReal ───────────────── (Var-Local) ✓ + Var (.Local x) ⇒ T + + e ⇒ _ Γ(f) = T_f Γ(x) ↦ T fresh +───────────────────────── (Var-Field) ✓ ───────────────────────── (Var-Declare) ✓ + Var (.Field e f) ⇒ T_f Var (.Declare ⟨x, T⟩) ⇒ TVoid +``` + +`Var (.Field e f)` resolves `f` against the type of `e` (or the enclosing instance type for +`self.f`); the typing rule is independent of which path resolution took. + +### IfThenElse + +``` +cond ⇐ TBool thenBr ⇒ T +───────────────────────────── (If-NoElse) ✓ + IfThenElse cond thenBr none ⇒ TVoid + +cond ⇐ TBool thenBr ⇒ T_t elseBr ⇒ T_e +───────────────────────────────────────────────── (If-Synth) ✓ + IfThenElse cond thenBr (some elseBr) ⇒ T_t + +cond ⇐ TBool thenBr ⇐ T elseBr ⇐ T +───────────────────────────────────────────── (If-Check) ✗ (planned) + IfThenElse cond thenBr (some elseBr) ⇐ T +``` + +If-Synth picks the then-branch type by convention; the result is always consumed by an +enclosing `checkAssignable` or by Sub, which provides a one-sided check against the +surrounding context. The two branches are deliberately not compared against each other: +statement-position `if`s commonly mix a value branch with a +{name Strata.Laurel.HighType.TVoid}`TVoid` branch (early `return`, `exit`, `assert`, …), +which a strict equality check would reject incorrectly. + +If-NoElse synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value +to give back when `cond` is false. This rejects `x : int := if c then 5` at the assignment. + +### Block + +``` + none of these statements has a typing premise + (their synthesized types are discarded — lax) + ─────────────────────────────────────────── + s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇒ T + ──────────────────────────────────────────────────────── (Block-Synth) ✓ + Block [s_1; …; s_n] label ⇒ T + +──────────────────── (Block-Synth-Empty) ✓ + Block [] label ⇒ TVoid + + s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇐ T +───────────────────────────────────────────── (Block-Check) ✓ + Block [s_1; …; s_n] label ⇐ T + + TVoid <: T +───────────────────── (Block-Check-Empty) ✓ + Block [] label ⇐ T +``` + +Block-Synth is lax: non-last statements are synthesized but their types are discarded. +This matches Java/Python/JavaScript expression-statement semantics: `f(x);` where `f` +returns a value is normal idiomatic code. The cost is that `5;` (a literal in statement +position) is silently accepted; flagging it would belong to a lint, not the type checker. + +Block-Check pushes the expected type into the *last* statement rather than checking the +block's synthesized type at the boundary. Errors then fire at the offending subexpression +inside `s_n` rather than at the surrounding {name Strata.Laurel.StmtExpr.Block}`Block`, and +the expected type keeps propagating through nested +{name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / {name Strata.Laurel.StmtExpr.Hole}`Hole` / -{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. Empty blocks reduce to subsumption of -{name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. - -## IfThenElse - -{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse cond t e_opt` has been converted to -*partial* bidirectional form. Today the implementation has a synth rule but reaches check -mode only through the subsumption fallback; a bespoke check rule that pushes the expected -type into both branches is the planned next step. - -The synth rule: - -- *Condition.* `cond` is checked against {name Strata.Laurel.HighType.TBool}`TBool` via a - recursive `checkStmtExpr cond TBool` call. This replaces the previous synth-then-`checkBool` - pattern with the clean bidirectional one — the expected type is pushed inward, so a - literal `if 5 then …` flags the literal directly rather than the surrounding `if`. -- *Branches.* `thenBr` is synthesized; if present, `elseBr` is synthesized too. The two - branch types are *not* compared against each other. The reason is that in Laurel's - unified statement-expression model, statement-position `if`s commonly mix a value - branch with a {name Strata.Laurel.HighType.TVoid}`TVoid` branch (early - {name Strata.Laurel.StmtExpr.Return}`return`, {name Strata.Laurel.StmtExpr.Exit}`exit`, an - {name Strata.Laurel.StmtExpr.Assert}`assert`, …), which a strict equality check on - branches would reject incorrectly. -- *Result type.* When `elseBr` is `none`, the result is - {name Strata.Laurel.HighType.TVoid}`TVoid` — the construct is in statement form and the - then-branch's value is discarded. When `elseBr` is `some _`, the result is the - then-branch's synthesized type. The arbitrary preference for the then-branch here is - harmless: the result is always consumed by an enclosing `checkAssignable` / - subsumption-fallback, which gives a one-sided check against the surrounding context's - expected type. - -The change to `none` → {name Strata.Laurel.HighType.TVoid}`TVoid` closes a soundness gap in -the previous design, where `if c then 5` synthesized {name Strata.Laurel.HighType.TInt}`TInt` -unconditionally — even though there is no value when `c` is false — so an assignment -`x: int := if c then 5` would have type-checked. With the new rule, the synthesized type is -{name Strata.Laurel.HighType.TVoid}`TVoid` and the assignment is correctly rejected. - -The planned bespoke check rule is straightforward: `cond ⇐ TBool`, `thenBr ⇐ expected`, and -`elseBr ⇐ expected` if present; if absent, fall back to subsumption of -{name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. The benefit is the -same as for `Block`: errors fire at the offending sub-expression rather than the -surrounding `if`, and the expected type propagates through nested control flow. +{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. Empty blocks reduce to a subsumption +check of {name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. + +### Statements that synthesize TVoid + +``` +───────────────── (Exit) ✓ cond ⇐ TBool invs ⇐ TBool dec ⇐ ? body ⇒ _ + Exit target ⇒ TVoid ──────────────────────────────────────────────────────────────── (While) ✓-ish + While cond invs dec body ⇒ TVoid + + +───────────────────────── (Return-None) ✓ e ⇒ _ + Return none ⇒ TVoid ───────────────────── (Return-Some) ✓ + Return (some e) ⇒ TVoid + + +cond ⇐ TBool cond ⇐ TBool +────────────────── (Assert) ✓-ish ────────────── (Assume) ✓-ish + Assert cond ⇒ TVoid Assume cond ⇒ TVoid + + + Γ(x) = T_x e ⇒ T_e T_e <: T_x targets ⇒ Ts e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i +───────────────────────────────────────── (Assign-Single) ✓-ish ─────────────────────────────────────────────────────────────────── (Assign-Multi) ✓-ish + Assign [x] e ⇒ TVoid Assign targets e ⇒ TVoid +``` + +✓-ish marks rules that are implemented but still call the legacy `checkBool` / +`checkAssignable` helpers rather than `checkStmtExpr cond TBool`. Functionally equivalent +under the gradual relation `<:` (since `checkBool` accepts the same types as +`isConsistentSubtype _ TBool` modulo the temporary {name Strata.Laurel.HighType.TCore}`TCore` +carve-out); slated to be migrated to `checkStmtExpr`. + +The {name Strata.Laurel.StmtExpr.Return}`Return`-with-value rule today only resolves `e` +without checking it against the enclosing procedure's declared output type. The intended +rule is: + +``` + Γ_proc.outputs = [T] e ⇐ T +───────────────────────────────── (Return-Some-Checked) ✗ (planned) + Return (some e) ⇒ TVoid +``` + +This requires threading the expected return type through `ResolveState`. Without it, +`return 0` in a `bool`-returning procedure goes uncaught. + +### Calls and primitive operations + +``` + callee resolves to procedure with inputs Ts and outputs [T] + args ⇒ Us U_i <: T_i (pairwise) +────────────────────────────────────────────────────────────── (Static-Call) ✓-ish + StaticCall callee args ⇒ T + + callee resolves to procedure with inputs Ts and outputs [T_1; …; T_n] (n ≠ 1) + args ⇒ Us U_i <: T_i (pairwise) +───────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi) ✓-ish + StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] + + target ⇒ _ callee resolves with inputs [self; Ts] and outputs [T] + args ⇒ Us U_i <: T_i (pairwise; self is dropped) +───────────────────────────────────────────────────────────────────────── (Instance-Call) ✓-ish + InstanceCall target callee args ⇒ T + + + args ⇐ TBool (each) +────────────────────────────── (Op-Bool) ✓-ish op ∈ {And, Or, AndThen, OrElse, Not, Implies} + PrimitiveOp op args ⇒ TBool + + + args ⇐ Numeric (each) +───────────────────────────── (Op-Cmp) ✓-ish op ∈ {Lt, Leq, Gt, Geq} + PrimitiveOp op args ⇒ TBool + + + lhs ⇒ T_l rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l +────────────────────────────────────────────────────────── (Op-Eq) ✓-ish op ∈ {Eq, Neq} + PrimitiveOp op [lhs; rhs] ⇒ TBool + + + args ⇐ Numeric (each) args.head ⇒ T +────────────────────────────────────────── (Op-Arith) ✓-ish op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} + PrimitiveOp op args ⇒ T + + + args ⇐ TString (each) — current implementation: no operand check +───────────────────────────── (Op-Concat) ✓-ish + PrimitiveOp op args ⇒ TString +``` + +`Numeric` abbreviates "consistent with one of +{name Strata.Laurel.HighType.TInt}`TInt`, {name Strata.Laurel.HighType.TReal}`TReal`, +{name Strata.Laurel.HighType.TFloat64}`TFloat64`". Today this is enforced by `checkNumeric` +rather than a `checkStmtExpr` chain; equivalent under the gradual relation. + +Op-Arith's "result is the type of the first argument" rule handles `int + int → int`, +`real + real → real`, etc. without a unification step. A consequence: `int + real` is *not* +flagged because each operand individually passes the numeric check. A real fix would be a +numeric-promotion or unification rule; for now this is a known relaxation. + +Op-Concat currently performs no operand check; the rule above describes the intended +behavior. + +### Object-related and verification forms + +``` + ref resolves to a composite or datatype T +───────────────────────────────────────────── (New-Ok) ✓ otherwise New ref ⇒ Unknown + New ref ⇒ UserDefined T + + +───────────────── (This) ✓ ──────────────────────────── (Abstract / All / ContractOf) ✓ + This ⇒ Unknown Abstract / All / ContractOf … ⇒ Unknown + + + lhs ⇒ _ rhs ⇒ _ +───────────────────────── (RefEq) ✓ target ⇒ _ + ReferenceEquals lhs rhs ⇒ TBool ────────────────── (AsType) ✓ + AsType target T ⇒ T + + + target ⇒ _ body ⇒ _ +───────────────── (IsType) ✓ ────────────────────────── (Quantifier) ✓ + IsType target T ⇒ TBool Quantifier mode ⟨x, T⟩ trig body ⇒ TBool + + + name ⇒ _ v ⇒ T v ⇒ _ +───────────────── (Assigned) ✓ ──────────── (Old) ✓ ────────────── (Fresh) ✓ + Assigned name ⇒ TBool Old v ⇒ T Fresh v ⇒ TBool + + + v ⇒ T proof ⇒ _ target ⇒ T_t newVal ⇒ _ +────────────────────── (ProveBy) ✓ ───────────────────────────────── (PureFieldUpdate) ✓ + ProveBy v proof ⇒ T PureFieldUpdate target f newVal ⇒ T_t +``` + +### Holes + +``` + Unknown <: T +───────────────────── (Hole-Some) ✓ ───────────────────── (Hole-None-Synth) ✓ ───────────────────── (Hole-None-Check) ✗ (planned) + Hole d (some T) ⇒ T Hole d none ⇒ Unknown Hole d none ⇐ T +``` + +In check mode, `Hole d none ⇐ T` reduces to subsumption today (`Unknown <: T`, which always +holds). The planned bespoke rule would record the inferred `T` on the hole node so +downstream passes can see it, instead of leaving `none` until the hole-inference pass. ## Mutual recursion and termination -{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr` are now mutually recursive: the synth rule -for {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` invokes check-mode resolution for -the condition, and the check function falls back to synth via the subsumption rule. +`synthStmtExpr` and `checkStmtExpr` are mutually recursive: the synth rule for +{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` invokes check-mode resolution for the +condition, and the check function falls back to synth via Sub. Termination uses a lexicographic measure `(exprMd, tag)` where the tag is `0` for -{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and `1` for -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. Any descent into a strict subterm -decreases via `Prod.Lex.left` (first component shrinks); the subsumption rule -`check e → synth e` calls synth on the *same* expression, which decreases via -`Prod.Lex.right` (second component goes from 1 to 0). This is the standard well-founded -encoding for bidirectional systems where one direction calls the other on the same input. - -## Mode assignment per construct - -The intended mode for each construct (some are still being converted to bidirectional in -the implementation): - -| Construct | Mode | Notes | -|---|---|---| -| Literals, `Var .Local`, `Var .Field`, `New T`, `IsType`, `ReferenceEquals`, `Quantifier`, `Assigned`, `Fresh`, `Hole _ (some T)`, `StaticCall`, `InstanceCall` | synth | type is determined locally | -| `Var .Declare`, `Exit`, `Return`, `While`, `Assert`, `Assume`, `Assign` | synth ⇒ {name Strata.Laurel.HighType.TVoid}`TVoid` | side-effecting; condition operands checked inward | -| `IfThenElse cond t e_opt` | synth (`cond ⇐ TBool`); planned bespoke check | see below | -| `Block` | bespoke check | `s_1..s_{n-1}` synth, `s_n ⇐ T`; synth uses last's synthesized type | -| `Hole _ none` | bespoke check | check mode succeeds with `expected`; synth mode → `Unknown` | -| `AsType e T` | synth ⇒ `T` | the cast is the user's claim; no check on `e` | -| `Old`, `ProveBy v _`, `PureFieldUpdate t _ _` | propagate type of subexpr | unchanged | -| `This`, `Abstract`, `All`, `ContractOf` | synth ⇒ {name Strata.Laurel.HighType.Unknown}`Unknown` | type not tracked | - -{name Strata.Laurel.StmtExpr.PrimitiveOp}`PrimitiveOp` operands are checked inward against -the operator's expected operand type ({name Strata.Laurel.HighType.TBool}`TBool` for -logical, numeric for arithmetic and ordering, {name Strata.Laurel.HighType.TString}`TString` -for `StrConcat`). {name Strata.Laurel.Operation.Eq}`Eq`/{name Strata.Laurel.Operation.Neq}`Neq` -synthesize both operands and require consistency in either direction -(`isConsistentSubtype l r ∨ isConsistentSubtype r l`). - -Arithmetic ops `Neg`/`Add`/…/`ModT` synthesize *the type of the first argument*. This is how -the checker handles {name Strata.Laurel.HighType.TInt}`TInt` / -{name Strata.Laurel.HighType.TReal}`TReal` / {name Strata.Laurel.HighType.TFloat64}`TFloat64` -without a unification step. A consequence: `int + real` is not flagged today, since each -operand passes the numeric check individually. A real fix would be a numeric-promotion or -unification rule; for now this is a known relaxation. +`synthStmtExpr` and `1` for `checkStmtExpr`. Any descent into a strict subterm decreases +via `Prod.Lex.left` (first component shrinks); Sub calls synth on the *same* expression, +which decreases via `Prod.Lex.right` (second component goes from 1 to 0). This is the +standard well-founded encoding for bidirectional systems where one direction calls the +other on the same input. ## Two helpers for resolution sites From fcbe1fc9bdd0711753063faaa1eba357737d3469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:44:13 -0400 Subject: [PATCH 069/189] reformat typechecking section --- docs/verso/LaurelDoc.lean | 632 +++++++++++++++++++++----------------- 1 file changed, 351 insertions(+), 281 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 64dd119a59..1577232261 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -150,432 +150,502 @@ A Laurel program consists of procedures, global variables, type definitions, and Type checking is woven into the resolution pass: every {name Strata.Laurel.StmtExpr}`StmtExpr` gets a {name Strata.Laurel.HighType}`HighType`, and -mismatches against the surrounding context become diagnostics. The design is -*bidirectional*: each construct is resolved either in *synthesis* mode — return a type -inferred from the expression — or in *checking* mode — verify that the expression has a -given expected type. The two are different functions on -{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. - -This page describes the design choices behind the checker. The implementation is in +mismatches against the surrounding context become diagnostics. The implementation is in `Resolution.lean`. -## The two judgments +## Design + +### Bidirectional type checking There are two operations on expressions, written here in standard bidirectional notation: ``` -Γ ⊢ e ⇒ T -- "e synthesizes T" (synthStmtExpr) -Γ ⊢ e ⇐ T -- "e checks against T" (checkStmtExpr) +e ⇒ T -- "e synthesizes T" (synthStmtExpr) +e ⇐ T -- "e checks against T" (checkStmtExpr) ``` -Each construct picks a mode based on whether its type is determined locally (synth) or by -context (check). Mode assignment is part of the design — see _Mode assignment per construct_ -below. - -The two judgments are connected by a single change-of-direction rule, *subsumption*: +Synthesis returns a type inferred from the expression itself; checking verifies that the +expression has a given expected type. Each construct picks a mode based on whether its type +is determined locally (synth) or by context (check). The two judgments are connected by a +single change-of-direction rule, *subsumption*: ``` -Γ ⊢ e ⇒ A A <: B -───────────────────── (sub) - Γ ⊢ e ⇐ B +e ⇒ A A <: B +───────────────── (Sub) + e ⇐ B ``` -Subsumption is the *only* place the checker switches from check to synth mode. It fires as a -default fallback in {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` for every construct -without a bespoke check rule: synthesize the expression's type, then verify the result is a -subtype of the expected type. Bespoke check rules push the expected type *into* -subexpressions instead of bouncing through synthesis, which keeps error messages localized -and lets the expected type propagate through nested control flow. - -## Subtyping and gradual consistency +Subsumption is the *only* place the checker switches from check to synth mode. It fires as +the default fallback in +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr` for every construct without a bespoke +check rule: synthesize the expression's type, then verify the result is a subtype of the +expected type. Bespoke check rules push the expected type *into* subexpressions instead of +bouncing through synthesis, which keeps error messages localized and lets the expected type +propagate through nested control flow. + +`synthStmtExpr` and `checkStmtExpr` are mutually recursive: synth rules invoke check on +subexpressions whose expected type is known (e.g. `cond ⇐ TBool` in +{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse`), and `checkStmtExpr` falls back to +`synthStmtExpr` via Sub. Termination uses a lexicographic measure `(exprMd, tag)` where the +tag is `0` for synth and `1` for check; any descent into a strict subterm decreases via +`Prod.Lex.left`, while Sub calls synth on the *same* expression and decreases via +`Prod.Lex.right`. This is the standard well-founded encoding for bidirectional systems. + +There is also a thin `resolveStmtExpr` wrapper that calls `synthStmtExpr` and discards the +synthesized type. It's used at sites where typing is not enforced (verification annotations, +modifies/reads clauses). The right principle for new call sites is: when the position has a +known expected type ({name Strata.Laurel.HighType.TBool}`TBool` for conditions, numeric for +`decreases`, the declared output for a constant initializer or a functional body), use +`checkStmtExpr`. When it doesn't, use `resolveStmtExpr`. `synthStmtExpr` itself is mostly an +internal interface used by other rules. + +### Gradual typing The relation `<:` is implemented by two Lean functions — both currently stubs, both intended to be sharpened: -- `isSubtype` — pure subtyping. The stub is structural - equality via {name Strata.Laurel.highEq}`highEq`. The eventual real version walks the - `extending` chain for {name Strata.Laurel.CompositeType}`CompositeType`, unfolds +- `isSubtype` — pure subtyping. The stub is structural equality via + {name Strata.Laurel.highEq}`highEq`. The eventual real version walks the `extending` + chain for {name Strata.Laurel.CompositeType}`CompositeType`, unfolds {name Strata.Laurel.TypeAlias}`TypeAlias` to its target, and unwraps {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base. -- `isConsistentSubtype` — gradual consistency, in - the Siek–Taha sense. {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type - `?` and is consistent with everything in either direction; otherwise the relation - delegates to `isSubtype`. {name Strata.Laurel.HighType.TCore}`TCore` is bivariantly - consistent for now, as a clearly-labelled migration escape hatch from the Core language — - this carve-out is intentionally temporary. - -Subsumption (and every bespoke check rule) uses -`isConsistentSubtype`, never raw `isSubtype`. That -single choice is what makes the system *gradual*: an expression of type +- `isConsistentSubtype` — gradual consistency, in the Siek–Taha sense. + {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type `?` and is consistent + with everything in either direction; otherwise the relation delegates to `isSubtype`. + {name Strata.Laurel.HighType.TCore}`TCore` is bivariantly consistent for now, as a + clearly-labelled migration escape hatch from the Core language — this carve-out is + intentionally temporary. + +Subsumption (and every bespoke check rule) uses `isConsistentSubtype`, never raw +`isSubtype`. That single choice is what makes the system *gradual*: an expression of type {name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) flows freely into any typed slot, and any expression flows freely into a slot of type {name Strata.Laurel.HighType.Unknown}`Unknown`. Strict checking is applied between fully-known types only. -## What changed from the synth-only design - A previous iteration was synth-only with three *bivariantly-compatible* wildcards: {name Strata.Laurel.HighType.Unknown}`Unknown`, {name Strata.Laurel.HighType.UserDefined}`UserDefined`, and {name Strata.Laurel.HighType.TCore}`TCore`. The -{name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was particularly -load-bearing: it meant that *no* assignment, call argument, or comparison involving a user -type was ever rejected, because subtyping wasn't tracked at all and constrained types -weren't unwrapped — we couldn't tell what was safe. +{name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was load-bearing: no +assignment, call argument, or comparison involving a user type was ever rejected. The +bidirectional design retires that carve-out — user-defined types are now a regular +participant in `<:`, and tightening `isSubtype` (to walk inheritance and unwrap +constrained types) gradually buys real checking on user-defined code without changing +callers. -The bidirectional design replaces that with two cleanly-separated concerns: +Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This +includes {name Strata.Laurel.StmtExpr.Return}`Return`, +{name Strata.Laurel.StmtExpr.Exit}`Exit`, {name Strata.Laurel.StmtExpr.While}`While`, +{name Strata.Laurel.StmtExpr.Assert}`Assert`, {name Strata.Laurel.StmtExpr.Assume}`Assume`, +{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies +— recorded in the rules below. -- {name Strata.Laurel.HighType.Unknown}`Unknown` keeps wildcard semantics, but now as a - *real* semantic claim (gradual typing) rather than a workaround. -- {name Strata.Laurel.HighType.UserDefined}`UserDefined` becomes a regular type. Once - `isSubtype` is implemented properly, `Cat ≤ Animal` will - pass, `Cat ≤ Dog` will fail, and constrained types will be unwrappable to their base. The - current stub is conservative (structural equality only); it can be tightened - incrementally without changing any callers. +## Typing rules -## Notation +Each construct is given as a derivation. Premises sit above the line, conclusion below. +Rules tagged `(impl)` are implemented; rules tagged `(planned)` describe the intended +behavior but aren't yet wired in. `Γ` (the lexical scope) is left implicit; every rule +threads it identically. -Typing rules are written in the standard derivation-tree form: premises above the line, -conclusion below, rule name on the right. +### Sub (subsumption) ``` -premise_1 premise_2 … premise_n -───────────────────────────────────── (Rule-Name) - conclusion +e ⇒ A A <: B +───────────────── (Sub, impl) + e ⇐ B ``` -We use: +The default fallback in `checkStmtExpr`. Used by every construct that doesn't have a +bespoke check rule. -- `e ⇒ T` — _e_ synthesizes _T_ (synth mode, `synthStmtExpr`). -- `e ⇐ T` — _e_ checks against _T_ (check mode, `checkStmtExpr`). -- `T <: U` — gradual consistency-subtyping, i.e. `isConsistentSubtype T U`. -- `Γ` for the lexical scope is left implicit — every rule threads it identically. +### LiteralInt -Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This -includes {name Strata.Laurel.StmtExpr.Return}`Return`, -{name Strata.Laurel.StmtExpr.Exit}`Exit`, {name Strata.Laurel.StmtExpr.While}`While`, -{name Strata.Laurel.StmtExpr.Assert}`Assert`, {name Strata.Laurel.StmtExpr.Assume}`Assume`, -{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies -— they're recorded in the rules below. +``` +───────────────────── (Lit-Int, impl) + LiteralInt n ⇒ TInt +``` -## Subsumption (the synth↔check boundary) +### LiteralBool ``` -e ⇒ A A <: B -───────────────── (Sub) - e ⇐ B +────────────────────── (Lit-Bool, impl) + LiteralBool b ⇒ TBool ``` -Subsumption is the *only* place check switches to synth. It fires as the default fallback -in `checkStmtExpr` for every construct without a bespoke check rule. Bespoke check rules -push the expected type *into* subexpressions, which keeps errors localized. +### LiteralString -## Typing rules +``` +──────────────────────────── (Lit-String, impl) + LiteralString s ⇒ TString +``` -Below, each construct is given as a derivation. Rules marked with ✓ in the implementation -column are implemented today; rules marked ✗ are planned. The current implementation has -bespoke check rules for {name Strata.Laurel.StmtExpr.Block}`Block` only; everything else -reaches check mode through Sub. Where a synth rule pushes an expected type into a -subexpression (e.g. `cond ⇐ TBool` in {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse`), -that's listed as a premise. +### LiteralDecimal -### Literals and references +``` +───────────────────────────── (Lit-Decimal, impl) + LiteralDecimal d ⇒ TReal +``` +### Var (.Local) + +``` + Γ(x) = T +────────────────────── (Var-Local, impl) + Var (.Local x) ⇒ T ``` - (Lit-Int) ✓ -───────────── ──────────────── ───────────────── - LiteralInt n ⇒ TInt LiteralBool b ⇒ TBool LiteralString s ⇒ TString -──────────────────────── Γ(x) = T - LiteralDecimal d ⇒ TReal ───────────────── (Var-Local) ✓ - Var (.Local x) ⇒ T +### Var (.Field) - e ⇒ _ Γ(f) = T_f Γ(x) ↦ T fresh -───────────────────────── (Var-Field) ✓ ───────────────────────── (Var-Declare) ✓ - Var (.Field e f) ⇒ T_f Var (.Declare ⟨x, T⟩) ⇒ TVoid ``` + e ⇒ _ Γ(f) = T_f +───────────────────────── (Var-Field, impl) + Var (.Field e f) ⇒ T_f +``` + +`f` is resolved against the type of `e` (or the enclosing instance type for `self.f`); the +typing rule is independent of which path resolution took. -`Var (.Field e f)` resolves `f` against the type of `e` (or the enclosing instance type for -`self.f`); the typing rule is independent of which path resolution took. +### Var (.Declare) + +``` + Γ(x) ↦ T fresh +────────────────────────────────── (Var-Declare, impl) + Var (.Declare ⟨x, T⟩) ⇒ TVoid +``` ### IfThenElse ``` cond ⇐ TBool thenBr ⇒ T -───────────────────────────── (If-NoElse) ✓ - IfThenElse cond thenBr none ⇒ TVoid +───────────────────────────────────────── (If-NoElse, impl) + IfThenElse cond thenBr none ⇒ TVoid + cond ⇐ TBool thenBr ⇒ T_t elseBr ⇒ T_e -───────────────────────────────────────────────── (If-Synth) ✓ - IfThenElse cond thenBr (some elseBr) ⇒ T_t +───────────────────────────────────────────────── (If-Synth, impl) + IfThenElse cond thenBr (some elseBr) ⇒ T_t + cond ⇐ TBool thenBr ⇐ T elseBr ⇐ T -───────────────────────────────────────────── (If-Check) ✗ (planned) - IfThenElse cond thenBr (some elseBr) ⇐ T +───────────────────────────────────────────── (If-Check, planned) + IfThenElse cond thenBr (some elseBr) ⇐ T ``` -If-Synth picks the then-branch type by convention; the result is always consumed by an -enclosing `checkAssignable` or by Sub, which provides a one-sided check against the -surrounding context. The two branches are deliberately not compared against each other: -statement-position `if`s commonly mix a value branch with a -{name Strata.Laurel.HighType.TVoid}`TVoid` branch (early `return`, `exit`, `assert`, …), -which a strict equality check would reject incorrectly. - If-NoElse synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value -to give back when `cond` is false. This rejects `x : int := if c then 5` at the assignment. +to give back when `cond` is false. Without this rule, `x : int := if c then 5` would +type-check spuriously. + +If-Synth picks the then-branch type; the result is always consumed by an enclosing +`checkAssignable` or by Sub, which provides a one-sided check against the surrounding +context. The two branches are deliberately not compared against each other: statement-position +`if`s commonly mix a value branch with a {name Strata.Laurel.HighType.TVoid}`TVoid` branch +(early `return`, `exit`, `assert`, …), which a strict equality check would reject incorrectly. ### Block ``` - none of these statements has a typing premise - (their synthesized types are discarded — lax) - ─────────────────────────────────────────── - s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇒ T - ──────────────────────────────────────────────────────── (Block-Synth) ✓ - Block [s_1; …; s_n] label ⇒ T + s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇒ T +─────────────────────────────────────────────────── (Block-Synth, impl) + Block [s_1; …; s_n] label ⇒ T + -──────────────────── (Block-Synth-Empty) ✓ +──────────────────────── (Block-Synth-Empty, impl) Block [] label ⇒ TVoid - s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇐ T -───────────────────────────────────────────── (Block-Check) ✓ - Block [s_1; …; s_n] label ⇐ T + + s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇐ T +─────────────────────────────────────────────────── (Block-Check, impl) + Block [s_1; …; s_n] label ⇐ T + TVoid <: T -───────────────────── (Block-Check-Empty) ✓ +────────────────────── (Block-Check-Empty, impl) Block [] label ⇐ T ``` -Block-Synth is lax: non-last statements are synthesized but their types are discarded. -This matches Java/Python/JavaScript expression-statement semantics: `f(x);` where `f` +The non-last statements are synthesized but their types are discarded — this is the lax +rule. It matches Java/Python/JavaScript expression-statement semantics: `f(x);` where `f` returns a value is normal idiomatic code. The cost is that `5;` (a literal in statement position) is silently accepted; flagging it would belong to a lint, not the type checker. -Block-Check pushes the expected type into the *last* statement rather than checking the -block's synthesized type at the boundary. Errors then fire at the offending subexpression -inside `s_n` rather than at the surrounding {name Strata.Laurel.StmtExpr.Block}`Block`, and -the expected type keeps propagating through nested +In check mode, the expected type is pushed into the *last* statement rather than checked at +the boundary. Errors then fire at the offending subexpression inside `s_n`, and the +expected type keeps propagating through nested {name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / {name Strata.Laurel.StmtExpr.Hole}`Hole` / -{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. Empty blocks reduce to a subsumption -check of {name Strata.Laurel.HighType.TVoid}`TVoid` against the expected type. +{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. -### Statements that synthesize TVoid +### Exit ``` -───────────────── (Exit) ✓ cond ⇐ TBool invs ⇐ TBool dec ⇐ ? body ⇒ _ - Exit target ⇒ TVoid ──────────────────────────────────────────────────────────────── (While) ✓-ish - While cond invs dec body ⇒ TVoid +───────────────────── (Exit, impl) + Exit target ⇒ TVoid +``` +### Return -───────────────────────── (Return-None) ✓ e ⇒ _ - Return none ⇒ TVoid ───────────────────── (Return-Some) ✓ - Return (some e) ⇒ TVoid +``` +───────────────────────── (Return-None, impl) + Return none ⇒ TVoid -cond ⇐ TBool cond ⇐ TBool -────────────────── (Assert) ✓-ish ────────────── (Assume) ✓-ish - Assert cond ⇒ TVoid Assume cond ⇒ TVoid + e ⇒ _ +────────────────────────── (Return-Some, impl) + Return (some e) ⇒ TVoid - Γ(x) = T_x e ⇒ T_e T_e <: T_x targets ⇒ Ts e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i -───────────────────────────────────────── (Assign-Single) ✓-ish ─────────────────────────────────────────────────────────────────── (Assign-Multi) ✓-ish - Assign [x] e ⇒ TVoid Assign targets e ⇒ TVoid + Γ_proc.outputs = [T] e ⇐ T +───────────────────────────────── (Return-Some-Checked, planned) + Return (some e) ⇒ TVoid ``` -✓-ish marks rules that are implemented but still call the legacy `checkBool` / -`checkAssignable` helpers rather than `checkStmtExpr cond TBool`. Functionally equivalent -under the gradual relation `<:` (since `checkBool` accepts the same types as -`isConsistentSubtype _ TBool` modulo the temporary {name Strata.Laurel.HighType.TCore}`TCore` -carve-out); slated to be migrated to `checkStmtExpr`. +The current `Return-Some` rule discards the value's synthesized type. The planned rule +threads the expected return type through {name Strata.Laurel.ResolveState}`ResolveState` +(set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedure` / +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`), so `return 0` in +a `bool`-returning procedure can be caught at the `Return` site. -The {name Strata.Laurel.StmtExpr.Return}`Return`-with-value rule today only resolves `e` -without checking it against the enclosing procedure's declared output type. The intended -rule is: +### While ``` - Γ_proc.outputs = [T] e ⇐ T -───────────────────────────────── (Return-Some-Checked) ✗ (planned) - Return (some e) ⇒ TVoid + cond ⇐ TBool invs_i ⇐ TBool dec ⇐ ? body ⇒ _ +───────────────────────────────────────────────────────────── (While, impl-ish) + While cond invs dec body ⇒ TVoid ``` -This requires threading the expected return type through `ResolveState`. Without it, -`return 0` in a `bool`-returning procedure goes uncaught. +`impl-ish` here means the rule is implemented but `cond` and `invs_i` go through the legacy +`checkBool` helper rather than `checkStmtExpr cond TBool`. Functionally equivalent under +`<:`; slated for migration. -### Calls and primitive operations +### Assert ``` - callee resolves to procedure with inputs Ts and outputs [T] + cond ⇐ TBool +────────────────────────── (Assert, impl-ish) + Assert cond ⇒ TVoid +``` + +### Assume + +``` + cond ⇐ TBool +───────────────────── (Assume, impl-ish) + Assume cond ⇒ TVoid +``` + +### Assign + +``` + Γ(x) = T_x e ⇒ T_e T_e <: T_x +───────────────────────────────────────── (Assign-Single, impl-ish) + Assign [x] e ⇒ TVoid + + + targets ⇒ Ts e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i +───────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl-ish) + Assign targets e ⇒ TVoid +``` + +### StaticCall + +``` + callee = static-procedure with inputs Ts and outputs [T] args ⇒ Us U_i <: T_i (pairwise) -────────────────────────────────────────────────────────────── (Static-Call) ✓-ish - StaticCall callee args ⇒ T +──────────────────────────────────────────────────────────── (Static-Call, impl-ish) + StaticCall callee args ⇒ T + - callee resolves to procedure with inputs Ts and outputs [T_1; …; T_n] (n ≠ 1) + callee = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 args ⇒ Us U_i <: T_i (pairwise) -───────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi) ✓-ish - StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] +───────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl-ish) + StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] +``` + +### InstanceCall - target ⇒ _ callee resolves with inputs [self; Ts] and outputs [T] +``` + target ⇒ _ callee = instance-procedure with inputs [self; Ts] and outputs [T] args ⇒ Us U_i <: T_i (pairwise; self is dropped) -───────────────────────────────────────────────────────────────────────── (Instance-Call) ✓-ish - InstanceCall target callee args ⇒ T +───────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl-ish) + InstanceCall target callee args ⇒ T +``` +### PrimitiveOp (logical) - args ⇐ TBool (each) -────────────────────────────── (Op-Bool) ✓-ish op ∈ {And, Or, AndThen, OrElse, Not, Implies} +``` + args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} +───────────────────────────── (Op-Bool, impl-ish) PrimitiveOp op args ⇒ TBool +``` +### PrimitiveOp (comparison) - args ⇐ Numeric (each) -───────────────────────────── (Op-Cmp) ✓-ish op ∈ {Lt, Leq, Gt, Geq} +``` + args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} +───────────────────────────── (Op-Cmp, impl-ish) PrimitiveOp op args ⇒ TBool +``` +`Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, +{name Strata.Laurel.HighType.TReal}`TReal`, +{name Strata.Laurel.HighType.TFloat64}`TFloat64`". Today this is enforced by `checkNumeric` +rather than a `checkStmtExpr` chain; equivalent under `<:`. + +### PrimitiveOp (equality) - lhs ⇒ T_l rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l -────────────────────────────────────────────────────────── (Op-Eq) ✓-ish op ∈ {Eq, Neq} - PrimitiveOp op [lhs; rhs] ⇒ TBool +``` + lhs ⇒ T_l rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} +────────────────────────────────────────────────────── (Op-Eq, impl-ish) + PrimitiveOp op [lhs; rhs] ⇒ TBool +``` +### PrimitiveOp (arithmetic) - args ⇐ Numeric (each) args.head ⇒ T -────────────────────────────────────────── (Op-Arith) ✓-ish op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} +``` + args_i ⇐ Numeric args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} +────────────────────────────────────────── (Op-Arith, impl-ish) PrimitiveOp op args ⇒ T +``` + +The "result is the type of the first argument" rule handles `int + int → int`, +`real + real → real` etc. without unification. A consequence: `int + real` is *not* +flagged today — each operand individually passes `Numeric`. A real fix would be a +numeric-promotion or unification rule; for now this is a known relaxation. +### PrimitiveOp (string concatenation) - args ⇐ TString (each) — current implementation: no operand check -───────────────────────────── (Op-Concat) ✓-ish +``` + args_i ⇐ TString op = StrConcat +───────────────────────────── (Op-Concat, planned) PrimitiveOp op args ⇒ TString ``` -`Numeric` abbreviates "consistent with one of -{name Strata.Laurel.HighType.TInt}`TInt`, {name Strata.Laurel.HighType.TReal}`TReal`, -{name Strata.Laurel.HighType.TFloat64}`TFloat64`". Today this is enforced by `checkNumeric` -rather than a `checkStmtExpr` chain; equivalent under the gradual relation. - -Op-Arith's "result is the type of the first argument" rule handles `int + int → int`, -`real + real → real`, etc. without a unification step. A consequence: `int + real` is *not* -flagged because each operand individually passes the numeric check. A real fix would be a -numeric-promotion or unification rule; for now this is a known relaxation. - -Op-Concat currently performs no operand check; the rule above describes the intended -behavior. +The current implementation performs no operand check on `StrConcat`; the planned rule +above describes the intended behavior. -### Object-related and verification forms +### New ``` ref resolves to a composite or datatype T -───────────────────────────────────────────── (New-Ok) ✓ otherwise New ref ⇒ Unknown +───────────────────────────────────────────── (New-Ok, impl) New ref ⇒ UserDefined T -───────────────── (This) ✓ ──────────────────────────── (Abstract / All / ContractOf) ✓ - This ⇒ Unknown Abstract / All / ContractOf … ⇒ Unknown + ref does not resolve to a composite or datatype +───────────────────────────────────────────────── (New-Fallback, impl) + New ref ⇒ Unknown +``` + +### AsType +``` + target ⇒ _ +───────────────────── (AsType, impl) + AsType target T ⇒ T +``` - lhs ⇒ _ rhs ⇒ _ -───────────────────────── (RefEq) ✓ target ⇒ _ - ReferenceEquals lhs rhs ⇒ TBool ────────────────── (AsType) ✓ - AsType target T ⇒ T +`AsType` does not check `target` against `T` — the cast is the user's claim. +### IsType - target ⇒ _ body ⇒ _ -───────────────── (IsType) ✓ ────────────────────────── (Quantifier) ✓ - IsType target T ⇒ TBool Quantifier mode ⟨x, T⟩ trig body ⇒ TBool +``` + target ⇒ _ +────────────────────────── (IsType, impl) + IsType target T ⇒ TBool +``` +### ReferenceEquals - name ⇒ _ v ⇒ T v ⇒ _ -───────────────── (Assigned) ✓ ──────────── (Old) ✓ ────────────── (Fresh) ✓ - Assigned name ⇒ TBool Old v ⇒ T Fresh v ⇒ TBool +``` + lhs ⇒ _ rhs ⇒ _ +─────────────────────────────── (RefEq, impl) + ReferenceEquals lhs rhs ⇒ TBool +``` +### Quantifier - v ⇒ T proof ⇒ _ target ⇒ T_t newVal ⇒ _ -────────────────────── (ProveBy) ✓ ───────────────────────────────── (PureFieldUpdate) ✓ - ProveBy v proof ⇒ T PureFieldUpdate target f newVal ⇒ T_t +``` + body ⇒ _ +───────────────────────────────────────────── (Quantifier, impl) + Quantifier mode ⟨x, T⟩ trig body ⇒ TBool ``` -### Holes +### Assigned ``` - Unknown <: T -───────────────────── (Hole-Some) ✓ ───────────────────── (Hole-None-Synth) ✓ ───────────────────── (Hole-None-Check) ✗ (planned) - Hole d (some T) ⇒ T Hole d none ⇒ Unknown Hole d none ⇐ T + name ⇒ _ +───────────────────────── (Assigned, impl) + Assigned name ⇒ TBool ``` -In check mode, `Hole d none ⇐ T` reduces to subsumption today (`Unknown <: T`, which always +### Old + +``` + v ⇒ T +───────────── (Old, impl) + Old v ⇒ T +``` + +### Fresh + +``` + v ⇒ _ +────────────────── (Fresh, impl) + Fresh v ⇒ TBool +``` + +### ProveBy + +``` + v ⇒ T proof ⇒ _ +────────────────────────── (ProveBy, impl) + ProveBy v proof ⇒ T +``` + +### PureFieldUpdate + +``` + target ⇒ T_t newVal ⇒ _ +───────────────────────────────────── (PureFieldUpdate, impl) + PureFieldUpdate target f newVal ⇒ T_t +``` + +### This + +``` +───────────────────── (This, impl) + This ⇒ Unknown +``` + +### Abstract / All / ContractOf + +``` +──────────────────────────────────────── (Abstract / All / ContractOf, impl) + Abstract / All / ContractOf … ⇒ Unknown +``` + +### Hole + +``` +─────────────────────── (Hole-Some, impl) + Hole d (some T) ⇒ T + + +───────────────────────── (Hole-None-Synth, impl) + Hole d none ⇒ Unknown + + + Unknown <: T +────────────────────── (Hole-None-Check, planned) + Hole d none ⇐ T +``` + +In check mode today, `Hole d none ⇐ T` reduces to subsumption (`Unknown <: T`, which always holds). The planned bespoke rule would record the inferred `T` on the hole node so downstream passes can see it, instead of leaving `none` until the hole-inference pass. -## Mutual recursion and termination - -`synthStmtExpr` and `checkStmtExpr` are mutually recursive: the synth rule for -{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` invokes check-mode resolution for the -condition, and the check function falls back to synth via Sub. - -Termination uses a lexicographic measure `(exprMd, tag)` where the tag is `0` for -`synthStmtExpr` and `1` for `checkStmtExpr`. Any descent into a strict subterm decreases -via `Prod.Lex.left` (first component shrinks); Sub calls synth on the *same* expression, -which decreases via `Prod.Lex.right` (second component goes from 1 to 0). This is the -standard well-founded encoding for bidirectional systems where one direction calls the -other on the same input. - -## Two helpers for resolution sites - -Some positions (procedure preconditions, decreases, invariants, postconditions, modifies -clauses, constrained-type witness, etc.) need resolution to run but the type of the -expression is either uninteresting or already known by another path. They use: - -- {name Strata.Laurel.synthStmtExpr}`synthStmtExpr` — the full synth API, returning - `(StmtExprMd × HighTypeMd)`. -- {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` — the check API, returning the resolved - expression and verifying its type is a consistent subtype of the expected type. -- `resolveStmtExpr` — a thin wrapper that calls - `synthStmtExpr` and discards the synthesized type. Used at sites where typing is not - enforced (verification annotations, modifies/reads clauses). - -The right principle is: when the position has a known expected type -({name Strata.Laurel.HighType.TBool}`TBool` for conditions, numeric for `decreases`, the -declared output for a constant initializer or a functional body), use -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. When it doesn't, use -`resolveStmtExpr`. {name Strata.Laurel.synthStmtExpr}`synthStmtExpr` -itself is mostly an internal interface used by other rules. - -## Returns and the expected return type - -`Return e` synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` (the construct itself -produces no value), but the *value being returned* should be checked against the enclosing -procedure's declared output type. The intended design: thread the expected return type -through {name Strata.Laurel.ResolveState}`ResolveState`, set it from `proc.outputs` in -{name Strata.Laurel.resolveProcedure}`resolveProcedure` / -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` before resolving the -body, and have the `Return` rule push the expected type into its value via -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`. This closes a soundness gap in the -synth-only design where `return 0` in a `bool`-returning procedure was not caught (because -the body's overall synthesized type was {name Strata.Laurel.HighType.TVoid}`TVoid` and the -body-vs-output check was skipped on `TVoid`). - -## What this is, in type-system terms - -The checker is: - -- *bidirectional*, with a single subsumption rule at the synth↔check boundary, -- with a *gradual* relation (`isConsistentSubtype`) - rather than a strict one — {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic - type, justified by Laurel's targeting of dynamic source languages, -- over a *nominal-with-stubs* subtype relation - (`isSubtype`) — currently structural equality, intended to - walk inheritance chains and unwrap aliases / constrained types, -- with *arity tracking via tuple types* - ({name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr`) for multi-output - procedures, -- and *side-effecting expressions modeled as* - {name Strata.Laurel.HighType.TVoid}`TVoid` so blocks, returns, and loops compose cleanly. - -The wildcard carve-out for {name Strata.Laurel.HighType.UserDefined}`UserDefined` from the -previous design is gone — user-defined types are no longer a backdoor through the checker. -The {name Strata.Laurel.HighType.TCore}`TCore` carve-out is preserved for now as a -migration aid and is expected to be removed. - # Translation Pipeline Laurel programs are verified by translating them to Strata Core and then invoking the Core From 084fa2d21be3b02dbe49754e8eb0ccd8029276fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:48:06 -0400 Subject: [PATCH 070/189] concise explanations --- docs/verso/LaurelDoc.lean | 72 +++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 41 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 1577232261..d79bf17900 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -243,10 +243,10 @@ includes {name Strata.Laurel.StmtExpr.Return}`Return`, ## Typing rules -Each construct is given as a derivation. Premises sit above the line, conclusion below. -Rules tagged `(impl)` are implemented; rules tagged `(planned)` describe the intended -behavior but aren't yet wired in. `Γ` (the lexical scope) is left implicit; every rule -threads it identically. +Each construct is given as a derivation. `(impl)` = implemented; `(planned)` = intended, +not yet wired in. `(impl-ish)` = implemented but still calls a legacy helper (`checkBool` / +`checkNumeric`/`checkAssignable`) instead of going through `checkStmtExpr`; functionally +equivalent under `<:`. ### Sub (subsumption) @@ -256,8 +256,7 @@ e ⇒ A A <: B e ⇐ B ``` -The default fallback in `checkStmtExpr`. Used by every construct that doesn't have a -bespoke check rule. +Fallback in `checkStmtExpr` whenever no bespoke check rule applies. ### LiteralInt @@ -303,8 +302,8 @@ bespoke check rule. Var (.Field e f) ⇒ T_f ``` -`f` is resolved against the type of `e` (or the enclosing instance type for `self.f`); the -typing rule is independent of which path resolution took. +Resolution looks `f` up against the type of `e` (or the enclosing instance type for +`self.f`); the typing rule itself is path-agnostic. ### Var (.Declare) @@ -332,15 +331,12 @@ cond ⇐ TBool thenBr ⇐ T elseBr ⇐ T IfThenElse cond thenBr (some elseBr) ⇐ T ``` -If-NoElse synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value -to give back when `cond` is false. Without this rule, `x : int := if c then 5` would -type-check spuriously. +If-NoElse uses {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value when +`cond` is false; without it, `x : int := if c then 5` would type-check spuriously. -If-Synth picks the then-branch type; the result is always consumed by an enclosing -`checkAssignable` or by Sub, which provides a one-sided check against the surrounding -context. The two branches are deliberately not compared against each other: statement-position -`if`s commonly mix a value branch with a {name Strata.Laurel.HighType.TVoid}`TVoid` branch -(early `return`, `exit`, `assert`, …), which a strict equality check would reject incorrectly. +If-Synth picks the then-branch arbitrarily and does *not* compare branches: a statement- +position `if` often pairs a value branch with a `return`/`exit`/`assert`. The surrounding +context's `checkAssignable` or Sub provides the actual check downstream. ### Block @@ -364,15 +360,13 @@ context. The two branches are deliberately not compared against each other: stat Block [] label ⇐ T ``` -The non-last statements are synthesized but their types are discarded — this is the lax -rule. It matches Java/Python/JavaScript expression-statement semantics: `f(x);` where `f` -returns a value is normal idiomatic code. The cost is that `5;` (a literal in statement -position) is silently accepted; flagging it would belong to a lint, not the type checker. +Non-last statements are synthesized but their types discarded (the lax rule). This matches +Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` +is silently accepted; flagging it belongs to a lint. -In check mode, the expected type is pushed into the *last* statement rather than checked at -the boundary. Errors then fire at the offending subexpression inside `s_n`, and the -expected type keeps propagating through nested -{name Strata.Laurel.StmtExpr.Block}`Block` / +Check mode pushes `T` into the *last* statement instead of comparing the block's +synthesized type at the boundary. Errors then fire at the offending subexpression, and `T` +keeps propagating through nested {name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / {name Strata.Laurel.StmtExpr.Hole}`Hole` / {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. @@ -401,11 +395,11 @@ expected type keeps propagating through nested Return (some e) ⇒ TVoid ``` -The current `Return-Some` rule discards the value's synthesized type. The planned rule -threads the expected return type through {name Strata.Laurel.ResolveState}`ResolveState` -(set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedure` / -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`), so `return 0` in -a `bool`-returning procedure can be caught at the `Return` site. +`Return-Some` currently throws away the value's type, so `return 0` in a `bool`-returning +procedure isn't caught. The planned rule threads the expected return type through +{name Strata.Laurel.ResolveState}`ResolveState` (set from `proc.outputs` in +{name Strata.Laurel.resolveProcedure}`resolveProcedure` / +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`). ### While @@ -415,9 +409,8 @@ a `bool`-returning procedure can be caught at the `Return` site. While cond invs dec body ⇒ TVoid ``` -`impl-ish` here means the rule is implemented but `cond` and `invs_i` go through the legacy -`checkBool` helper rather than `checkStmtExpr cond TBool`. Functionally equivalent under -`<:`; slated for migration. +`dec` (the optional decreases clause) is currently resolved without a type check; the +intended target is a numeric type, not yet enforced. ### Assert @@ -490,8 +483,7 @@ a `bool`-returning procedure can be caught at the `Return` site. `Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, {name Strata.Laurel.HighType.TReal}`TReal`, -{name Strata.Laurel.HighType.TFloat64}`TFloat64`". Today this is enforced by `checkNumeric` -rather than a `checkStmtExpr` chain; equivalent under `<:`. +{name Strata.Laurel.HighType.TFloat64}`TFloat64`". ### PrimitiveOp (equality) @@ -509,10 +501,9 @@ rather than a `checkStmtExpr` chain; equivalent under `<:`. PrimitiveOp op args ⇒ T ``` -The "result is the type of the first argument" rule handles `int + int → int`, -`real + real → real` etc. without unification. A consequence: `int + real` is *not* -flagged today — each operand individually passes `Numeric`. A real fix would be a -numeric-promotion or unification rule; for now this is a known relaxation. +"Result is the type of the first argument" handles `int + int → int`, `real + real → real`, +etc. without unification. Known relaxation: `int + real` passes (each operand individually +passes `Numeric`); a proper fix needs numeric promotion or unification. ### PrimitiveOp (string concatenation) @@ -522,8 +513,7 @@ numeric-promotion or unification rule; for now this is a known relaxation. PrimitiveOp op args ⇒ TString ``` -The current implementation performs no operand check on `StrConcat`; the planned rule -above describes the intended behavior. +Operand check not yet implemented — `StrConcat` accepts any operands today. ### New @@ -546,7 +536,7 @@ above describes the intended behavior. AsType target T ⇒ T ``` -`AsType` does not check `target` against `T` — the cast is the user's claim. +`target` is resolved but not checked against `T` — the cast is the user's claim. ### IsType From 294be7bdc6d3655b51355756a5f0dc38229cfe46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:49:38 -0400 Subject: [PATCH 071/189] restore contexts in rules --- docs/verso/LaurelDoc.lean | 290 ++++++++++++++++++++------------------ 1 file changed, 151 insertions(+), 139 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index d79bf17900..7eb90d4f87 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -243,17 +243,19 @@ includes {name Strata.Laurel.StmtExpr.Return}`Return`, ## Typing rules -Each construct is given as a derivation. `(impl)` = implemented; `(planned)` = intended, -not yet wired in. `(impl-ish)` = implemented but still calls a legacy helper (`checkBool` / -`checkNumeric`/`checkAssignable`) instead of going through `checkStmtExpr`; functionally -equivalent under `<:`. +Each construct is given as a derivation. `Γ` is the current lexical scope (see +{name Strata.Laurel.ResolveState}`ResolveState`'s `scope`); it threads identically through +every premise and conclusion unless a rule explicitly extends it (written `Γ, x : T`). +`(impl)` = implemented; `(planned)` = intended, not yet wired in. `(impl-ish)` = implemented +but still calls a legacy helper (`checkBool` / `checkNumeric` / `checkAssignable`) instead of +going through `checkStmtExpr`; functionally equivalent under `<:`. ### Sub (subsumption) ``` -e ⇒ A A <: B -───────────────── (Sub, impl) - e ⇐ B +Γ ⊢ e ⇒ A A <: B +───────────────────── (Sub, impl) + Γ ⊢ e ⇐ B ``` Fallback in `checkStmtExpr` whenever no bespoke check rule applies. @@ -261,45 +263,45 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. ### LiteralInt ``` -───────────────────── (Lit-Int, impl) - LiteralInt n ⇒ TInt +────────────────────────── (Lit-Int, impl) + Γ ⊢ LiteralInt n ⇒ TInt ``` ### LiteralBool ``` -────────────────────── (Lit-Bool, impl) - LiteralBool b ⇒ TBool +─────────────────────────── (Lit-Bool, impl) + Γ ⊢ LiteralBool b ⇒ TBool ``` ### LiteralString ``` -──────────────────────────── (Lit-String, impl) - LiteralString s ⇒ TString +───────────────────────────────── (Lit-String, impl) + Γ ⊢ LiteralString s ⇒ TString ``` ### LiteralDecimal ``` -───────────────────────────── (Lit-Decimal, impl) - LiteralDecimal d ⇒ TReal +────────────────────────────────── (Lit-Decimal, impl) + Γ ⊢ LiteralDecimal d ⇒ TReal ``` ### Var (.Local) ``` - Γ(x) = T -────────────────────── (Var-Local, impl) - Var (.Local x) ⇒ T + Γ(x) = T +─────────────────────────── (Var-Local, impl) + Γ ⊢ Var (.Local x) ⇒ T ``` ### Var (.Field) ``` - e ⇒ _ Γ(f) = T_f -───────────────────────── (Var-Field, impl) - Var (.Field e f) ⇒ T_f + Γ ⊢ e ⇒ _ Γ(f) = T_f +────────────────────────────── (Var-Field, impl) + Γ ⊢ Var (.Field e f) ⇒ T_f ``` Resolution looks `f` up against the type of `e` (or the enclosing instance type for @@ -308,27 +310,30 @@ Resolution looks `f` up against the type of `e` (or the enclosing instance type ### Var (.Declare) ``` - Γ(x) ↦ T fresh -────────────────────────────────── (Var-Declare, impl) - Var (.Declare ⟨x, T⟩) ⇒ TVoid + x ∉ dom(Γ) +───────────────────────────────────────── (Var-Declare, impl) + Γ ⊢ Var (.Declare ⟨x, T⟩) ⇒ TVoid ⊣ Γ, x : T ``` +`⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the +remainder of the enclosing scope. + ### IfThenElse ``` -cond ⇐ TBool thenBr ⇒ T -───────────────────────────────────────── (If-NoElse, impl) - IfThenElse cond thenBr none ⇒ TVoid +Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T +───────────────────────────────────────────── (If-NoElse, impl) + Γ ⊢ IfThenElse cond thenBr none ⇒ TVoid -cond ⇐ TBool thenBr ⇒ T_t elseBr ⇒ T_e -───────────────────────────────────────────────── (If-Synth, impl) - IfThenElse cond thenBr (some elseBr) ⇒ T_t +Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e +────────────────────────────────────────────────────────────── (If-Synth, impl) + Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T_t -cond ⇐ TBool thenBr ⇐ T elseBr ⇐ T -───────────────────────────────────────────── (If-Check, planned) - IfThenElse cond thenBr (some elseBr) ⇐ T +Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T +────────────────────────────────────────────────────────── (If-Check, planned) + Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T ``` If-NoElse uses {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value when @@ -341,25 +346,30 @@ context's `checkAssignable` or Sub provides the actual check downstream. ### Block ``` - s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇒ T -─────────────────────────────────────────────────── (Block-Synth, impl) - Block [s_1; …; s_n] label ⇒ T +Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇒ T +─────────────────────────────────────────────────────────────────────────── (Block-Synth, impl) + Γ ⊢ Block [s_1; …; s_n] label ⇒ T -──────────────────────── (Block-Synth-Empty, impl) - Block [] label ⇒ TVoid +───────────────────────────── (Block-Synth-Empty, impl) + Γ ⊢ Block [] label ⇒ TVoid - s_1 ⇒ _ … s_{n-1} ⇒ _ s_n ⇐ T -─────────────────────────────────────────────────── (Block-Check, impl) - Block [s_1; …; s_n] label ⇐ T +Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇐ T +─────────────────────────────────────────────────────────────────────────── (Block-Check, impl) + Γ ⊢ Block [s_1; …; s_n] label ⇐ T TVoid <: T -────────────────────── (Block-Check-Empty, impl) - Block [] label ⇐ T +───────────────────────── (Block-Check-Empty, impl) + Γ ⊢ Block [] label ⇐ T ``` +The notation `Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i` says each statement is resolved in the scope produced +by its predecessor and may itself extend the scope (`Var (.Declare …)` does); the +`Γ_{n-1}` that types `s_n` is the scope after all earlier declarations. Bindings introduced +inside the block don't escape — `Γ` is what surrounds the block. + Non-last statements are synthesized but their types discarded (the lax rule). This matches Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` is silently accepted; flagging it belongs to a lint. @@ -374,25 +384,25 @@ keeps propagating through nested {name Strata.Laurel.StmtExpr.Block}`Block` / ### Exit ``` -───────────────────── (Exit, impl) - Exit target ⇒ TVoid +──────────────────────── (Exit, impl) + Γ ⊢ Exit target ⇒ TVoid ``` ### Return ``` -───────────────────────── (Return-None, impl) - Return none ⇒ TVoid +───────────────────────────── (Return-None, impl) + Γ ⊢ Return none ⇒ TVoid - e ⇒ _ -────────────────────────── (Return-Some, impl) - Return (some e) ⇒ TVoid + Γ ⊢ e ⇒ _ +────────────────────────────── (Return-Some, impl) + Γ ⊢ Return (some e) ⇒ TVoid - Γ_proc.outputs = [T] e ⇐ T -───────────────────────────────── (Return-Some-Checked, planned) - Return (some e) ⇒ TVoid + Γ_proc.outputs = [T] Γ ⊢ e ⇐ T +────────────────────────────────────── (Return-Some-Checked, planned) + Γ ⊢ Return (some e) ⇒ TVoid ``` `Return-Some` currently throws away the value's type, so `return 0` in a `bool`-returning @@ -404,9 +414,9 @@ procedure isn't caught. The planned rule threads the expected return type throug ### While ``` - cond ⇐ TBool invs_i ⇐ TBool dec ⇐ ? body ⇒ _ -───────────────────────────────────────────────────────────── (While, impl-ish) - While cond invs dec body ⇒ TVoid + Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ +─────────────────────────────────────────────────────────────────────────────── (While, impl-ish) + Γ ⊢ While cond invs dec body ⇒ TVoid ``` `dec` (the optional decreases clause) is currently resolved without a type check; the @@ -415,70 +425,70 @@ intended target is a numeric type, not yet enforced. ### Assert ``` - cond ⇐ TBool -────────────────────────── (Assert, impl-ish) - Assert cond ⇒ TVoid + Γ ⊢ cond ⇐ TBool +────────────────────────────── (Assert, impl-ish) + Γ ⊢ Assert cond ⇒ TVoid ``` ### Assume ``` - cond ⇐ TBool -───────────────────── (Assume, impl-ish) - Assume cond ⇒ TVoid + Γ ⊢ cond ⇐ TBool +───────────────────────────── (Assume, impl-ish) + Γ ⊢ Assume cond ⇒ TVoid ``` ### Assign ``` - Γ(x) = T_x e ⇒ T_e T_e <: T_x -───────────────────────────────────────── (Assign-Single, impl-ish) - Assign [x] e ⇒ TVoid + Γ(x) = T_x Γ ⊢ e ⇒ T_e T_e <: T_x +─────────────────────────────────────────────── (Assign-Single, impl-ish) + Γ ⊢ Assign [x] e ⇒ TVoid - targets ⇒ Ts e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i -───────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl-ish) - Assign targets e ⇒ TVoid + Γ ⊢ targets_i = x_i Γ(x_i) = T_i Γ ⊢ e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i +───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl-ish) + Γ ⊢ Assign targets e ⇒ TVoid ``` ### StaticCall ``` - callee = static-procedure with inputs Ts and outputs [T] - args ⇒ Us U_i <: T_i (pairwise) -──────────────────────────────────────────────────────────── (Static-Call, impl-ish) - StaticCall callee args ⇒ T + Γ(callee) = static-procedure with inputs Ts and outputs [T] + Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) +───────────────────────────────────────────────────────────── (Static-Call, impl-ish) + Γ ⊢ StaticCall callee args ⇒ T - callee = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 - args ⇒ Us U_i <: T_i (pairwise) -───────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl-ish) - StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] + Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 + Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) +────────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl-ish) + Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` ### InstanceCall ``` - target ⇒ _ callee = instance-procedure with inputs [self; Ts] and outputs [T] - args ⇒ Us U_i <: T_i (pairwise; self is dropped) -───────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl-ish) - InstanceCall target callee args ⇒ T + Γ ⊢ target ⇒ _ Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T] + Γ ⊢ args ⇒ Us U_i <: T_i (pairwise; self is dropped) +───────────────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl-ish) + Γ ⊢ InstanceCall target callee args ⇒ T ``` ### PrimitiveOp (logical) ``` - args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} -───────────────────────────── (Op-Bool, impl-ish) - PrimitiveOp op args ⇒ TBool + Γ ⊢ args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} +────────────────────────────────── (Op-Bool, impl-ish) + Γ ⊢ PrimitiveOp op args ⇒ TBool ``` ### PrimitiveOp (comparison) ``` - args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} -───────────────────────────── (Op-Cmp, impl-ish) - PrimitiveOp op args ⇒ TBool + Γ ⊢ args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} +───────────────────────────────── (Op-Cmp, impl-ish) + Γ ⊢ PrimitiveOp op args ⇒ TBool ``` `Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, @@ -488,17 +498,17 @@ intended target is a numeric type, not yet enforced. ### PrimitiveOp (equality) ``` - lhs ⇒ T_l rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} -────────────────────────────────────────────────────── (Op-Eq, impl-ish) - PrimitiveOp op [lhs; rhs] ⇒ TBool + Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} +───────────────────────────────────────────────────────────────── (Op-Eq, impl-ish) + Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool ``` ### PrimitiveOp (arithmetic) ``` - args_i ⇐ Numeric args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} -────────────────────────────────────────── (Op-Arith, impl-ish) - PrimitiveOp op args ⇒ T + Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} +────────────────────────────────────────────────── (Op-Arith, impl-ish) + Γ ⊢ PrimitiveOp op args ⇒ T ``` "Result is the type of the first argument" handles `int + int → int`, `real + real → real`, @@ -508,9 +518,9 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ### PrimitiveOp (string concatenation) ``` - args_i ⇐ TString op = StrConcat -───────────────────────────── (Op-Concat, planned) - PrimitiveOp op args ⇒ TString + Γ ⊢ args_i ⇐ TString op = StrConcat +───────────────────────────────────── (Op-Concat, planned) + Γ ⊢ PrimitiveOp op args ⇒ TString ``` Operand check not yet implemented — `StrConcat` accepts any operands today. @@ -518,22 +528,22 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. ### New ``` - ref resolves to a composite or datatype T -───────────────────────────────────────────── (New-Ok, impl) - New ref ⇒ UserDefined T + Γ(ref) is a composite or datatype T +────────────────────────────────────────── (New-Ok, impl) + Γ ⊢ New ref ⇒ UserDefined T - ref does not resolve to a composite or datatype -───────────────────────────────────────────────── (New-Fallback, impl) - New ref ⇒ Unknown + Γ(ref) is not a composite or datatype +───────────────────────────────────────── (New-Fallback, impl) + Γ ⊢ New ref ⇒ Unknown ``` ### AsType ``` - target ⇒ _ -───────────────────── (AsType, impl) - AsType target T ⇒ T + Γ ⊢ target ⇒ _ +───────────────────────────── (AsType, impl) + Γ ⊢ AsType target T ⇒ T ``` `target` is resolved but not checked against `T` — the cast is the user's claim. @@ -541,95 +551,97 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. ### IsType ``` - target ⇒ _ -────────────────────────── (IsType, impl) - IsType target T ⇒ TBool + Γ ⊢ target ⇒ _ +───────────────────────────────── (IsType, impl) + Γ ⊢ IsType target T ⇒ TBool ``` ### ReferenceEquals ``` - lhs ⇒ _ rhs ⇒ _ -─────────────────────────────── (RefEq, impl) - ReferenceEquals lhs rhs ⇒ TBool + Γ ⊢ lhs ⇒ _ Γ ⊢ rhs ⇒ _ +─────────────────────────────────────── (RefEq, impl) + Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool ``` ### Quantifier ``` - body ⇒ _ -───────────────────────────────────────────── (Quantifier, impl) - Quantifier mode ⟨x, T⟩ trig body ⇒ TBool + Γ, x : T ⊢ body ⇒ _ +───────────────────────────────────────────────── (Quantifier, impl) + Γ ⊢ Quantifier mode ⟨x, T⟩ trig body ⇒ TBool ``` +The bound variable `x : T` is introduced in scope only for the body (and trigger). + ### Assigned ``` - name ⇒ _ -───────────────────────── (Assigned, impl) - Assigned name ⇒ TBool + Γ ⊢ name ⇒ _ +───────────────────────────── (Assigned, impl) + Γ ⊢ Assigned name ⇒ TBool ``` ### Old ``` - v ⇒ T -───────────── (Old, impl) - Old v ⇒ T + Γ ⊢ v ⇒ T +───────────────── (Old, impl) + Γ ⊢ Old v ⇒ T ``` ### Fresh ``` - v ⇒ _ -────────────────── (Fresh, impl) - Fresh v ⇒ TBool + Γ ⊢ v ⇒ _ +───────────────────── (Fresh, impl) + Γ ⊢ Fresh v ⇒ TBool ``` ### ProveBy ``` - v ⇒ T proof ⇒ _ -────────────────────────── (ProveBy, impl) - ProveBy v proof ⇒ T + Γ ⊢ v ⇒ T Γ ⊢ proof ⇒ _ +─────────────────────────────────── (ProveBy, impl) + Γ ⊢ ProveBy v proof ⇒ T ``` ### PureFieldUpdate ``` - target ⇒ T_t newVal ⇒ _ -───────────────────────────────────── (PureFieldUpdate, impl) - PureFieldUpdate target f newVal ⇒ T_t + Γ ⊢ target ⇒ T_t Γ ⊢ newVal ⇒ _ +─────────────────────────────────────────────── (PureFieldUpdate, impl) + Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t ``` ### This ``` -───────────────────── (This, impl) - This ⇒ Unknown +────────────────────────── (This, impl) + Γ ⊢ This ⇒ Unknown ``` ### Abstract / All / ContractOf ``` -──────────────────────────────────────── (Abstract / All / ContractOf, impl) - Abstract / All / ContractOf … ⇒ Unknown +───────────────────────────────────────────── (Abstract / All / ContractOf, impl) + Γ ⊢ Abstract / All / ContractOf … ⇒ Unknown ``` ### Hole ``` -─────────────────────── (Hole-Some, impl) - Hole d (some T) ⇒ T +──────────────────────────── (Hole-Some, impl) + Γ ⊢ Hole d (some T) ⇒ T -───────────────────────── (Hole-None-Synth, impl) - Hole d none ⇒ Unknown +───────────────────────────────── (Hole-None-Synth, impl) + Γ ⊢ Hole d none ⇒ Unknown - Unknown <: T -────────────────────── (Hole-None-Check, planned) - Hole d none ⇐ T + Unknown <: T +───────────────────────── (Hole-None-Check, planned) + Γ ⊢ Hole d none ⇐ T ``` In check mode today, `Hole d none ⇐ T` reduces to subsumption (`Unknown <: T`, which always From 06ce7b98b9a1c424702bec8f666bec78faadf19f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 13:53:23 -0400 Subject: [PATCH 072/189] simplify presentation --- docs/verso/LaurelDoc.lean | 235 +++++++++++++++++++++++++------------- 1 file changed, 157 insertions(+), 78 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 7eb90d4f87..ff0d09183d 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -250,7 +250,26 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x but still calls a legacy helper (`checkBool` / `checkNumeric` / `checkAssignable`) instead of going through `checkStmtExpr`; functionally equivalent under `<:`. -### Sub (subsumption) +### Index + +- *Subsumption* — Sub +- *Literals* — Lit-Int, Lit-Bool, Lit-String, Lit-Decimal +- *Variables* — Var-Local, Var-Field, Var-Declare +- *Control flow* — If-NoElse, If-Synth, If-Check (planned); Block-Synth, Block-Synth-Empty, + Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, Return-Some-Checked + (planned); While +- *Verification statements* — Assert, Assume +- *Assignment* — Assign-Single, Assign-Multi +- *Calls* — Static-Call, Static-Call-Multi, Instance-Call +- *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat (planned) +- *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate +- *Verification expressions* — Quantifier, Assigned, Old, Fresh, ProveBy +- *Untyped forms* — This; Abstract / All / ContractOf +- *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check (planned) + +### Subsumption + +#### Sub ``` Γ ⊢ e ⇒ A A <: B @@ -260,35 +279,39 @@ going through `checkStmtExpr`; functionally equivalent under `<:`. Fallback in `checkStmtExpr` whenever no bespoke check rule applies. -### LiteralInt +### Literals + +#### Lit-Int ``` ────────────────────────── (Lit-Int, impl) Γ ⊢ LiteralInt n ⇒ TInt ``` -### LiteralBool +#### Lit-Bool ``` ─────────────────────────── (Lit-Bool, impl) Γ ⊢ LiteralBool b ⇒ TBool ``` -### LiteralString +#### Lit-String ``` ───────────────────────────────── (Lit-String, impl) Γ ⊢ LiteralString s ⇒ TString ``` -### LiteralDecimal +#### Lit-Decimal ``` ────────────────────────────────── (Lit-Decimal, impl) Γ ⊢ LiteralDecimal d ⇒ TReal ``` -### Var (.Local) +### Variables + +#### Var-Local ``` Γ(x) = T @@ -296,7 +319,7 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. Γ ⊢ Var (.Local x) ⇒ T ``` -### Var (.Field) +#### Var-Field ``` Γ ⊢ e ⇒ _ Γ(f) = T_f @@ -307,7 +330,7 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. Resolution looks `f` up against the type of `e` (or the enclosing instance type for `self.f`); the typing rule itself is path-agnostic. -### Var (.Declare) +#### Var-Declare ``` x ∉ dom(Γ) @@ -318,100 +341,125 @@ Resolution looks `f` up against the type of `e` (or the enclosing instance type `⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the remainder of the enclosing scope. -### IfThenElse +### Control flow + +#### If-NoElse ``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T ───────────────────────────────────────────── (If-NoElse, impl) Γ ⊢ IfThenElse cond thenBr none ⇒ TVoid +``` + +The construct synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no +value when `cond` is false; without this, `x : int := if c then 5` would type-check +spuriously. +#### If-Synth +``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e ────────────────────────────────────────────────────────────── (If-Synth, impl) Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T_t +``` + +Picks the then-branch type arbitrarily; the two branches are *not* compared, since a +statement-position `if` often pairs a value branch with a `return`/`exit`/`assert`. The +enclosing `checkAssignable` or Sub provides the actual check downstream. +#### If-Check +``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T ────────────────────────────────────────────────────────── (If-Check, planned) Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T ``` -If-NoElse uses {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value when -`cond` is false; without it, `x : int := if c then 5` would type-check spuriously. - -If-Synth picks the then-branch arbitrarily and does *not* compare branches: a statement- -position `if` often pairs a value branch with a `return`/`exit`/`assert`. The surrounding -context's `checkAssignable` or Sub provides the actual check downstream. - -### Block +#### Block-Synth ``` Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇒ T ─────────────────────────────────────────────────────────────────────────── (Block-Synth, impl) Γ ⊢ Block [s_1; …; s_n] label ⇒ T +``` + +`Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i` says each statement is resolved in the scope produced by its +predecessor and may itself extend it (`Var (.Declare …)` does); `s_n` is typed in +`Γ_{n-1}`. Bindings introduced inside the block don't escape — `Γ` is what surrounds the +block. +Non-last statements are synthesized but their types discarded (the lax rule). This matches +Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` +is silently accepted; flagging it belongs to a lint. +#### Block-Synth-Empty + +``` ───────────────────────────── (Block-Synth-Empty, impl) Γ ⊢ Block [] label ⇒ TVoid +``` +#### Block-Check +``` Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇐ T ─────────────────────────────────────────────────────────────────────────── (Block-Check, impl) Γ ⊢ Block [s_1; …; s_n] label ⇐ T +``` + +Pushes `T` into the *last* statement rather than comparing the block's synthesized type at +the boundary. Errors fire at the offending subexpression, and `T` keeps propagating through +nested {name Strata.Laurel.StmtExpr.Block}`Block` / +{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / +{name Strata.Laurel.StmtExpr.Hole}`Hole` / +{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. +#### Block-Check-Empty +``` TVoid <: T ───────────────────────── (Block-Check-Empty, impl) Γ ⊢ Block [] label ⇐ T ``` -The notation `Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i` says each statement is resolved in the scope produced -by its predecessor and may itself extend the scope (`Var (.Declare …)` does); the -`Γ_{n-1}` that types `s_n` is the scope after all earlier declarations. Bindings introduced -inside the block don't escape — `Γ` is what surrounds the block. - -Non-last statements are synthesized but their types discarded (the lax rule). This matches -Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` -is silently accepted; flagging it belongs to a lint. - -Check mode pushes `T` into the *last* statement instead of comparing the block's -synthesized type at the boundary. Errors then fire at the offending subexpression, and `T` -keeps propagating through nested {name Strata.Laurel.StmtExpr.Block}`Block` / -{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / -{name Strata.Laurel.StmtExpr.Hole}`Hole` / -{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. - -### Exit +#### Exit ``` ──────────────────────── (Exit, impl) Γ ⊢ Exit target ⇒ TVoid ``` -### Return +#### Return-None ``` ───────────────────────────── (Return-None, impl) Γ ⊢ Return none ⇒ TVoid +``` +#### Return-Some +``` Γ ⊢ e ⇒ _ ────────────────────────────── (Return-Some, impl) Γ ⊢ Return (some e) ⇒ TVoid +``` +The value's synthesized type is currently discarded, so `return 0` in a `bool`-returning +procedure isn't caught. Replaced by Return-Some-Checked once the expected return type is +threaded through {name Strata.Laurel.ResolveState}`ResolveState`. +#### Return-Some-Checked + +``` Γ_proc.outputs = [T] Γ ⊢ e ⇐ T ────────────────────────────────────── (Return-Some-Checked, planned) Γ ⊢ Return (some e) ⇒ TVoid ``` -`Return-Some` currently throws away the value's type, so `return 0` in a `bool`-returning -procedure isn't caught. The planned rule threads the expected return type through -{name Strata.Laurel.ResolveState}`ResolveState` (set from `proc.outputs` in -{name Strata.Laurel.resolveProcedure}`resolveProcedure` / -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`). +Set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedure` / +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`. -### While +#### While ``` Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ @@ -419,10 +467,12 @@ procedure isn't caught. The planned rule threads the expected return type throug Γ ⊢ While cond invs dec body ⇒ TVoid ``` -`dec` (the optional decreases clause) is currently resolved without a type check; the -intended target is a numeric type, not yet enforced. +`dec` (the optional decreases clause) is resolved without a type check today; the intended +target is a numeric type. + +### Verification statements -### Assert +#### Assert ``` Γ ⊢ cond ⇐ TBool @@ -430,7 +480,7 @@ intended target is a numeric type, not yet enforced. Γ ⊢ Assert cond ⇒ TVoid ``` -### Assume +#### Assume ``` Γ ⊢ cond ⇐ TBool @@ -438,35 +488,45 @@ intended target is a numeric type, not yet enforced. Γ ⊢ Assume cond ⇒ TVoid ``` -### Assign +### Assignment + +#### Assign-Single ``` Γ(x) = T_x Γ ⊢ e ⇒ T_e T_e <: T_x ─────────────────────────────────────────────── (Assign-Single, impl-ish) Γ ⊢ Assign [x] e ⇒ TVoid +``` +#### Assign-Multi +``` Γ ⊢ targets_i = x_i Γ(x_i) = T_i Γ ⊢ e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i ───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl-ish) Γ ⊢ Assign targets e ⇒ TVoid ``` -### StaticCall +### Calls + +#### Static-Call ``` Γ(callee) = static-procedure with inputs Ts and outputs [T] Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) ───────────────────────────────────────────────────────────── (Static-Call, impl-ish) Γ ⊢ StaticCall callee args ⇒ T +``` +#### Static-Call-Multi +``` Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) ────────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl-ish) Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` -### InstanceCall +#### Instance-Call ``` Γ ⊢ target ⇒ _ Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T] @@ -475,7 +535,13 @@ intended target is a numeric type, not yet enforced. Γ ⊢ InstanceCall target callee args ⇒ T ``` -### PrimitiveOp (logical) +### Primitive operations + +`Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, +{name Strata.Laurel.HighType.TReal}`TReal`, +{name Strata.Laurel.HighType.TFloat64}`TFloat64`". + +#### Op-Bool ``` Γ ⊢ args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} @@ -483,7 +549,7 @@ intended target is a numeric type, not yet enforced. Γ ⊢ PrimitiveOp op args ⇒ TBool ``` -### PrimitiveOp (comparison) +#### Op-Cmp ``` Γ ⊢ args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} @@ -491,11 +557,7 @@ intended target is a numeric type, not yet enforced. Γ ⊢ PrimitiveOp op args ⇒ TBool ``` -`Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, -{name Strata.Laurel.HighType.TReal}`TReal`, -{name Strata.Laurel.HighType.TFloat64}`TFloat64`". - -### PrimitiveOp (equality) +#### Op-Eq ``` Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} @@ -503,7 +565,7 @@ intended target is a numeric type, not yet enforced. Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool ``` -### PrimitiveOp (arithmetic) +#### Op-Arith ``` Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} @@ -515,7 +577,7 @@ intended target is a numeric type, not yet enforced. etc. without unification. Known relaxation: `int + real` passes (each operand individually passes `Numeric`); a proper fix needs numeric promotion or unification. -### PrimitiveOp (string concatenation) +#### Op-Concat ``` Γ ⊢ args_i ⇐ TString op = StrConcat @@ -525,20 +587,25 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. Operand check not yet implemented — `StrConcat` accepts any operands today. -### New +### Object forms + +#### New-Ok ``` Γ(ref) is a composite or datatype T ────────────────────────────────────────── (New-Ok, impl) Γ ⊢ New ref ⇒ UserDefined T +``` +#### New-Fallback +``` Γ(ref) is not a composite or datatype ───────────────────────────────────────── (New-Fallback, impl) Γ ⊢ New ref ⇒ Unknown ``` -### AsType +#### AsType ``` Γ ⊢ target ⇒ _ @@ -548,7 +615,7 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. `target` is resolved but not checked against `T` — the cast is the user's claim. -### IsType +#### IsType ``` Γ ⊢ target ⇒ _ @@ -556,7 +623,7 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. Γ ⊢ IsType target T ⇒ TBool ``` -### ReferenceEquals +#### RefEq ``` Γ ⊢ lhs ⇒ _ Γ ⊢ rhs ⇒ _ @@ -564,7 +631,17 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool ``` -### Quantifier +#### PureFieldUpdate + +``` + Γ ⊢ target ⇒ T_t Γ ⊢ newVal ⇒ _ +─────────────────────────────────────────────── (PureFieldUpdate, impl) + Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t +``` + +### Verification expressions + +#### Quantifier ``` Γ, x : T ⊢ body ⇒ _ @@ -574,7 +651,7 @@ Operand check not yet implemented — `StrConcat` accepts any operands today. The bound variable `x : T` is introduced in scope only for the body (and trigger). -### Assigned +#### Assigned ``` Γ ⊢ name ⇒ _ @@ -582,7 +659,7 @@ The bound variable `x : T` is introduced in scope only for the body (and trigger Γ ⊢ Assigned name ⇒ TBool ``` -### Old +#### Old ``` Γ ⊢ v ⇒ T @@ -590,7 +667,7 @@ The bound variable `x : T` is introduced in scope only for the body (and trigger Γ ⊢ Old v ⇒ T ``` -### Fresh +#### Fresh ``` Γ ⊢ v ⇒ _ @@ -598,7 +675,7 @@ The bound variable `x : T` is introduced in scope only for the body (and trigger Γ ⊢ Fresh v ⇒ TBool ``` -### ProveBy +#### ProveBy ``` Γ ⊢ v ⇒ T Γ ⊢ proof ⇒ _ @@ -606,47 +683,49 @@ The bound variable `x : T` is introduced in scope only for the body (and trigger Γ ⊢ ProveBy v proof ⇒ T ``` -### PureFieldUpdate - -``` - Γ ⊢ target ⇒ T_t Γ ⊢ newVal ⇒ _ -─────────────────────────────────────────────── (PureFieldUpdate, impl) - Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t -``` +### Untyped forms -### This +#### This ``` ────────────────────────── (This, impl) Γ ⊢ This ⇒ Unknown ``` -### Abstract / All / ContractOf +#### Abstract / All / ContractOf ``` ───────────────────────────────────────────── (Abstract / All / ContractOf, impl) Γ ⊢ Abstract / All / ContractOf … ⇒ Unknown ``` -### Hole +### Holes + +#### Hole-Some ``` ──────────────────────────── (Hole-Some, impl) Γ ⊢ Hole d (some T) ⇒ T +``` +#### Hole-None-Synth +``` ───────────────────────────────── (Hole-None-Synth, impl) Γ ⊢ Hole d none ⇒ Unknown +``` +#### Hole-None-Check +``` Unknown <: T ───────────────────────── (Hole-None-Check, planned) Γ ⊢ Hole d none ⇐ T ``` In check mode today, `Hole d none ⇐ T` reduces to subsumption (`Unknown <: T`, which always -holds). The planned bespoke rule would record the inferred `T` on the hole node so -downstream passes can see it, instead of leaving `none` until the hole-inference pass. +holds). The planned rule would record the inferred `T` on the hole node so downstream +passes can see it, instead of leaving `none` until the hole-inference pass. # Translation Pipeline From 835d8b5ade3872d856eef4927520c1b5e0ccff84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 14:14:16 -0400 Subject: [PATCH 073/189] add back in contexts --- docs/verso/LaurelDoc.lean | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index ff0d09183d..4b5f314c2d 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -160,8 +160,8 @@ mismatches against the surrounding context become diagnostics. The implementatio There are two operations on expressions, written here in standard bidirectional notation: ``` -e ⇒ T -- "e synthesizes T" (synthStmtExpr) -e ⇐ T -- "e checks against T" (checkStmtExpr) +Γ ⊢ e ⇒ T -- "e synthesizes T" (synthStmtExpr) +Γ ⊢ e ⇐ T -- "e checks against T" (checkStmtExpr) ``` Synthesis returns a type inferred from the expression itself; checking verifies that the @@ -170,9 +170,9 @@ is determined locally (synth) or by context (check). The two judgments are conne single change-of-direction rule, *subsumption*: ``` -e ⇒ A A <: B -───────────────── (Sub) - e ⇐ B +Γ ⊢ e ⇒ A A <: B +───────────────────── (Sub) + Γ ⊢ e ⇐ B ``` Subsumption is the *only* place the checker switches from check to synth mode. It fires as From a7ae7bb996eb5f856b8a5cee7db85d8121c5caa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 14:50:07 -0400 Subject: [PATCH 074/189] =?UTF-8?q?describe=20literals=20and=20easy=20rule?= =?UTF-8?q?s=20(call,=20assert/assume=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/verso/LaurelDoc.lean | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 4b5f314c2d..4abf2eff2c 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -246,9 +246,7 @@ includes {name Strata.Laurel.StmtExpr.Return}`Return`, Each construct is given as a derivation. `Γ` is the current lexical scope (see {name Strata.Laurel.ResolveState}`ResolveState`'s `scope`); it threads identically through every premise and conclusion unless a rule explicitly extends it (written `Γ, x : T`). -`(impl)` = implemented; `(planned)` = intended, not yet wired in. `(impl-ish)` = implemented -but still calls a legacy helper (`checkBool` / `checkNumeric` / `checkAssignable`) instead of -going through `checkStmtExpr`; functionally equivalent under `<:`. +`(impl)` = implemented; `(planned)` = intended, not yet wired in. ### Index @@ -261,7 +259,7 @@ going through `checkStmtExpr`; functionally equivalent under `<:`. - *Verification statements* — Assert, Assume - *Assignment* — Assign-Single, Assign-Multi - *Calls* — Static-Call, Static-Call-Multi, Instance-Call -- *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat (planned) +- *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat - *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate - *Verification expressions* — Quantifier, Assigned, Old, Fresh, ProveBy - *Untyped forms* — This; Abstract / All / ContractOf @@ -365,7 +363,8 @@ spuriously. Picks the then-branch type arbitrarily; the two branches are *not* compared, since a statement-position `if` often pairs a value branch with a `return`/`exit`/`assert`. The -enclosing `checkAssignable` or Sub provides the actual check downstream. +enclosing context's check (Sub, or a containing `checkSubtype` like an assignment) provides +the actual check downstream. #### If-Check @@ -463,7 +462,7 @@ Set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedur ``` Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ -─────────────────────────────────────────────────────────────────────────────── (While, impl-ish) +─────────────────────────────────────────────────────────────────────────────── (While, impl) Γ ⊢ While cond invs dec body ⇒ TVoid ``` @@ -476,7 +475,7 @@ target is a numeric type. ``` Γ ⊢ cond ⇐ TBool -────────────────────────────── (Assert, impl-ish) +────────────────────────────── (Assert, impl) Γ ⊢ Assert cond ⇒ TVoid ``` @@ -484,7 +483,7 @@ target is a numeric type. ``` Γ ⊢ cond ⇐ TBool -───────────────────────────── (Assume, impl-ish) +───────────────────────────── (Assume, impl) Γ ⊢ Assume cond ⇒ TVoid ``` @@ -494,7 +493,7 @@ target is a numeric type. ``` Γ(x) = T_x Γ ⊢ e ⇒ T_e T_e <: T_x -─────────────────────────────────────────────── (Assign-Single, impl-ish) +─────────────────────────────────────────────── (Assign-Single, impl) Γ ⊢ Assign [x] e ⇒ TVoid ``` @@ -502,7 +501,7 @@ target is a numeric type. ``` Γ ⊢ targets_i = x_i Γ(x_i) = T_i Γ ⊢ e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i -───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl-ish) +───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl) Γ ⊢ Assign targets e ⇒ TVoid ``` @@ -513,7 +512,7 @@ target is a numeric type. ``` Γ(callee) = static-procedure with inputs Ts and outputs [T] Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) -───────────────────────────────────────────────────────────── (Static-Call, impl-ish) +───────────────────────────────────────────────────────────── (Static-Call, impl) Γ ⊢ StaticCall callee args ⇒ T ``` @@ -522,7 +521,7 @@ target is a numeric type. ``` Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) -────────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl-ish) +────────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl) Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` @@ -531,7 +530,7 @@ target is a numeric type. ``` Γ ⊢ target ⇒ _ Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T] Γ ⊢ args ⇒ Us U_i <: T_i (pairwise; self is dropped) -───────────────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl-ish) +───────────────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl) Γ ⊢ InstanceCall target callee args ⇒ T ``` @@ -545,7 +544,7 @@ target is a numeric type. ``` Γ ⊢ args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} -────────────────────────────────── (Op-Bool, impl-ish) +────────────────────────────────── (Op-Bool, impl) Γ ⊢ PrimitiveOp op args ⇒ TBool ``` @@ -553,7 +552,7 @@ target is a numeric type. ``` Γ ⊢ args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} -───────────────────────────────── (Op-Cmp, impl-ish) +───────────────────────────────── (Op-Cmp, impl) Γ ⊢ PrimitiveOp op args ⇒ TBool ``` @@ -561,7 +560,7 @@ target is a numeric type. ``` Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} -───────────────────────────────────────────────────────────────── (Op-Eq, impl-ish) +───────────────────────────────────────────────────────────────── (Op-Eq, impl) Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool ``` @@ -569,7 +568,7 @@ target is a numeric type. ``` Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} -────────────────────────────────────────────────── (Op-Arith, impl-ish) +────────────────────────────────────────────────── (Op-Arith, impl) Γ ⊢ PrimitiveOp op args ⇒ T ``` @@ -581,12 +580,10 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ``` Γ ⊢ args_i ⇐ TString op = StrConcat -───────────────────────────────────── (Op-Concat, planned) +───────────────────────────────────── (Op-Concat, impl) Γ ⊢ PrimitiveOp op args ⇒ TString ``` -Operand check not yet implemented — `StrConcat` accepts any operands today. - ### Object forms #### New-Ok From f3d657ee45e8520500f77f9456993c50b3ceeedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 14:50:34 -0400 Subject: [PATCH 075/189] remove old helpers + mis-subtyping diagnostics --- Strata/Languages/Laurel/Resolution.lean | 112 +++++++++--------------- 1 file changed, 41 insertions(+), 71 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 97f6556331..1259185178 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -37,10 +37,10 @@ Walks the AST under `ResolveM`, a state monad over `ResolveState`. Phase 1: declared type to build a qualified lookup key), - opens fresh nested scopes via `withScope` for blocks, quantifiers, procedure bodies, and constrained-type constraint/witness expressions, -- synthesizes a `HighType` for every `StmtExpr` and runs the type-checking - helpers (`checkBool`, `checkNumeric`, `checkAssignable`, `checkComparable`) - on assignments, call arguments, condition positions, functional bodies, and - constant initializers. +- synthesizes a `HighType` for every `StmtExpr` and checks it (via + `checkStmtExpr` for fresh subexpressions, or `checkSubtype` when a type is + already in hand) on assignments, call arguments, condition positions, + functional bodies, and constant initializers. Before any bodies are walked, `preRegisterTopLevel` registers every top-level name (types and their constructors / testers / destructors / instance @@ -436,54 +436,21 @@ private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := | .TCore _, _ | _, .TCore _ => true | _, _ => isSubtype sub sup -/-- Check that a type is boolean, emitting a diagnostic if not. -/ -private def checkBool (source : Option FileRange) (ty : HighTypeMd) : ResolveM Unit := do +/-- Type-level subtype check: emits the standard "expected/got" diagnostic when + `actual` is not a consistent subtype of `expected`. Used at sites where the + actual type is already in hand (assignment, call args, body vs declared + output) — equivalent to `checkStmtExpr e expected` but without re-synthesizing. -/ +private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do + unless isConsistentSubtype actual expected do + typeMismatch source (s!"'{formatType expected}'") actual + +/-- Test whether a type is in the set of numeric primitives, modulo gradual + consistency. Used by Op-Cmp / Op-Arith. -/ +private def isConsistentNumeric (ty : HighTypeMd) : Bool := match ty.val with - | .TBool | .Unknown => pure () - | .UserDefined _ => pure () -- constrained types may wrap bool - | _ => typeMismatch source "bool" ty - -/-- Check that a type is numeric (int, real, or float64), emitting a diagnostic if not. -/ -private def checkNumeric (source : Option FileRange) (ty : HighTypeMd) : ResolveM Unit := do - match ty.val with - | .TInt | .TReal | .TFloat64 | .Unknown => pure () - | .UserDefined _ => pure () -- constrained types may wrap numeric types - | _ => typeMismatch source "a numeric type" ty - -/-- Check that two types are compatible, emitting a diagnostic if not. - UserDefined types are always considered compatible with each other since - subtype relationships (inheritance) are not tracked during resolution. - TCore types are not checked since they are pass-through types from the Core language. -/ -private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do - match expected.val, actual.val with - | .Unknown, _ => pure () - | _, .Unknown => pure () - | .UserDefined _, _ => pure () -- subtype relationships not tracked here - | _, .UserDefined _ => pure () -- subtype relationships not tracked here - | .TCore _, _ => pure () -- pass-through Core types not checked during resolution - | _, .TCore _ => pure () -- pass-through Core types not checked during resolution - | _, _ => - if !highEq expected actual then - let expectedStr := formatType expected - let actualStr := formatType actual - let diag := diagnosticFromSource source s!"Type mismatch: expected '{expectedStr}', but got '{actualStr}'" - modify fun s => { s with errors := s.errors.push diag } - -/-- Check that two types are comparable (for == and !=), emitting a symmetric diagnostic if not. -/ -private def checkComparable (source : Option FileRange) (lhsTy : HighTypeMd) (rhsTy : HighTypeMd) : ResolveM Unit := do - match lhsTy.val, rhsTy.val with - | .Unknown, _ => pure () - | _, .Unknown => pure () - | .UserDefined _, _ => pure () - | _, .UserDefined _ => pure () - | .TCore _, _ => pure () - | _, .TCore _ => pure () - | _, _ => - if !highEq lhsTy rhsTy then - let lhsStr := formatType lhsTy - let rhsStr := formatType rhsTy - let diag := diagnosticFromSource source s!"Operands of '==' have incompatible types '{lhsStr}' and '{rhsStr}'" - modify fun s => { s with errors := s.errors.push diag } + | .TInt | .TReal | .TFloat64 | .Unknown => true + | .TCore _ => true + | _ => false /-- Get the type of a resolved variable reference from scope. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do @@ -547,10 +514,9 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | none => { val := .TVoid, source := source } pure (.Block stmts' label, lastTy) | .While cond invs dec body => - let (cond', condTy) ← synthStmtExpr cond - checkBool cond'.source condTy + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } let invs' ← invs.attach.mapM (fun a => have := a.property; do - let (e', _) ← synthStmtExpr a.val; pure e') + checkStmtExpr a.val { val := .TBool, source := a.val.source }) let dec' ← dec.attach.mapM (fun a => have := a.property; do let (e', _) ← synthStmtExpr a.val; pure e') let (body', _) ← synthStmtExpr body @@ -614,7 +580,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | some (_, node) => pure node.getType | none => pure { val := HighType.Unknown, source := fieldName.source : HighTypeMd } let tTy ← targetTy - checkAssignable source tTy valueTy + checkSubtype source tTy valueTy pure (.Assign targets' value', valueTy) | .Var (.Field target fieldName) => let (target', _) ← synthStmtExpr target @@ -633,9 +599,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee - -- Check argument types match parameter types for (argTy, paramTy) in argTypes.zip paramTypes do - checkAssignable source paramTy argTy + checkSubtype source paramTy argTy pure (.StaticCall callee' args', retTy) | .PrimitiveOp op args => let results ← args.mapM synthStmtExpr @@ -649,18 +614,25 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | some headTy => headTy.val | none => HighType.TInt | .StrConcat => HighType.TString - -- Type check operands match op with | .And | .Or | .AndThen | .OrElse | .Not | .Implies => - for aTy in argTypes do checkBool source aTy + for aTy in argTypes do + checkSubtype source { val := .TBool, source := aTy.source } aTy | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => - for aTy in argTypes do checkNumeric source aTy + for aTy in argTypes do + unless isConsistentNumeric aTy do typeMismatch aTy.source "a numeric type" aTy | .Eq | .Neq => - -- Check that operands are compatible with each other (symmetric check) + -- Symmetric: pass if either direction is consistent. match argTypes with - | [lhsTy, rhsTy] => checkComparable source lhsTy rhsTy + | [lhsTy, rhsTy] => + unless isConsistentSubtype lhsTy rhsTy || isConsistentSubtype rhsTy lhsTy do + let diag := diagnosticFromSource source + s!"Operands of '==' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" + modify fun s => { s with errors := s.errors.push diag } | _ => pure () - | .StrConcat => pure () + | .StrConcat => + for aTy in argTypes do + checkSubtype source { val := .TString, source := aTy.source } aTy pure (.PrimitiveOp op args', { val := resultTy, source := source }) | .New ref => let ref' ← resolveRef ref source @@ -695,10 +667,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee - -- Check argument types match parameter types (skip first param which is 'self') + -- Skip first param (self) when matching args. let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] for (argTy, paramTy) in argTypes.zip callParamTypes do - checkAssignable source paramTy argTy + checkSubtype source paramTy argTy pure (.InstanceCall target' callee' args', retTy) | .Quantifier mode param trigger body => withScope do @@ -718,12 +690,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let (val', _) ← synthStmtExpr val pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => - let (cond', condTy) ← synthStmtExpr condExpr - checkBool cond'.source condTy + let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) | .Assume cond => - let (cond', condTy) ← synthStmtExpr cond - checkBool cond'.source condTy + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } pure (.Assume cond', { val := .TVoid, source := source }) | .ProveBy val proof => let (val', valTy) ← synthStmtExpr val @@ -829,7 +799,7 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do | [singleOutput] => -- Only check when body produces a value (not void from return/while/assign) if bodyTy.val != HighType.TVoid then - checkAssignable proc.name.source singleOutput.type bodyTy + checkSubtype proc.name.source singleOutput.type bodyTy | _ => pure () let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr return { name := procName', inputs := inputs', outputs := outputs', @@ -869,7 +839,7 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv match proc.outputs with | [singleOutput] => if bodyTy.val != HighType.TVoid then - checkAssignable proc.name.source singleOutput.type bodyTy + checkSubtype proc.name.source singleOutput.type bodyTy | _ => pure () let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr modify fun s => { s with instanceTypeName := savedInstType } From 054a8ba39bbe67b2a390e9c1e91ed368988303f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 14:56:38 -0400 Subject: [PATCH 076/189] quantifier check for bool body --- Strata/Languages/Laurel/Resolution.lean | 2 +- docs/verso/LaurelDoc.lean | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 1259185178..01676402aa 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -678,7 +678,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do let (e', _) ← synthStmtExpr pv.val; pure e') - let (body', _) ← synthStmtExpr body + let body' ← checkStmtExpr body { val := .TBool, source := body.source } pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) | .Assigned name => let (name', _) ← synthStmtExpr name diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 4abf2eff2c..34217be4ab 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -641,12 +641,14 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. #### Quantifier ``` - Γ, x : T ⊢ body ⇒ _ + Γ, x : T ⊢ body ⇐ TBool ───────────────────────────────────────────────── (Quantifier, impl) Γ ⊢ Quantifier mode ⟨x, T⟩ trig body ⇒ TBool ``` -The bound variable `x : T` is introduced in scope only for the body (and trigger). +The bound variable `x : T` is introduced in scope only for the body (and trigger). The body +is checked against {name Strata.Laurel.HighType.TBool}`TBool` since a quantifier is a +proposition; without this, `forall x: int :: x + 1` would be silently accepted. #### Assigned From 597c79e687987c2e7b11e52103e44bb9f00aa42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 15:03:02 -0400 Subject: [PATCH 077/189] remove redundant headings --- docs/verso/LaurelDoc.lean | 96 --------------------------------------- 1 file changed, 96 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 34217be4ab..6bb9271cbf 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -267,8 +267,6 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x ### Subsumption -#### Sub - ``` Γ ⊢ e ⇒ A A <: B ───────────────────── (Sub, impl) @@ -279,29 +277,21 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. ### Literals -#### Lit-Int - ``` ────────────────────────── (Lit-Int, impl) Γ ⊢ LiteralInt n ⇒ TInt ``` -#### Lit-Bool - ``` ─────────────────────────── (Lit-Bool, impl) Γ ⊢ LiteralBool b ⇒ TBool ``` -#### Lit-String - ``` ───────────────────────────────── (Lit-String, impl) Γ ⊢ LiteralString s ⇒ TString ``` -#### Lit-Decimal - ``` ────────────────────────────────── (Lit-Decimal, impl) Γ ⊢ LiteralDecimal d ⇒ TReal @@ -309,16 +299,12 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. ### Variables -#### Var-Local - ``` Γ(x) = T ─────────────────────────── (Var-Local, impl) Γ ⊢ Var (.Local x) ⇒ T ``` -#### Var-Field - ``` Γ ⊢ e ⇒ _ Γ(f) = T_f ────────────────────────────── (Var-Field, impl) @@ -328,8 +314,6 @@ Fallback in `checkStmtExpr` whenever no bespoke check rule applies. Resolution looks `f` up against the type of `e` (or the enclosing instance type for `self.f`); the typing rule itself is path-agnostic. -#### Var-Declare - ``` x ∉ dom(Γ) ───────────────────────────────────────── (Var-Declare, impl) @@ -341,8 +325,6 @@ remainder of the enclosing scope. ### Control flow -#### If-NoElse - ``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T ───────────────────────────────────────────── (If-NoElse, impl) @@ -353,8 +335,6 @@ The construct synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because the value when `cond` is false; without this, `x : int := if c then 5` would type-check spuriously. -#### If-Synth - ``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e ────────────────────────────────────────────────────────────── (If-Synth, impl) @@ -366,16 +346,12 @@ statement-position `if` often pairs a value branch with a `return`/`exit`/`asser enclosing context's check (Sub, or a containing `checkSubtype` like an assignment) provides the actual check downstream. -#### If-Check - ``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T ────────────────────────────────────────────────────────── (If-Check, planned) Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T ``` -#### Block-Synth - ``` Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇒ T ─────────────────────────────────────────────────────────────────────────── (Block-Synth, impl) @@ -391,15 +367,11 @@ Non-last statements are synthesized but their types discarded (the lax rule). Th Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` is silently accepted; flagging it belongs to a lint. -#### Block-Synth-Empty - ``` ───────────────────────────── (Block-Synth-Empty, impl) Γ ⊢ Block [] label ⇒ TVoid ``` -#### Block-Check - ``` Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇐ T ─────────────────────────────────────────────────────────────────────────── (Block-Check, impl) @@ -413,30 +385,22 @@ nested {name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.Hole}`Hole` / {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. -#### Block-Check-Empty - ``` TVoid <: T ───────────────────────── (Block-Check-Empty, impl) Γ ⊢ Block [] label ⇐ T ``` -#### Exit - ``` ──────────────────────── (Exit, impl) Γ ⊢ Exit target ⇒ TVoid ``` -#### Return-None - ``` ───────────────────────────── (Return-None, impl) Γ ⊢ Return none ⇒ TVoid ``` -#### Return-Some - ``` Γ ⊢ e ⇒ _ ────────────────────────────── (Return-Some, impl) @@ -447,8 +411,6 @@ The value's synthesized type is currently discarded, so `return 0` in a `bool`-r procedure isn't caught. Replaced by Return-Some-Checked once the expected return type is threaded through {name Strata.Laurel.ResolveState}`ResolveState`. -#### Return-Some-Checked - ``` Γ_proc.outputs = [T] Γ ⊢ e ⇐ T ────────────────────────────────────── (Return-Some-Checked, planned) @@ -458,8 +420,6 @@ threaded through {name Strata.Laurel.ResolveState}`ResolveState`. Set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedure` / {name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`. -#### While - ``` Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ ─────────────────────────────────────────────────────────────────────────────── (While, impl) @@ -471,16 +431,12 @@ target is a numeric type. ### Verification statements -#### Assert - ``` Γ ⊢ cond ⇐ TBool ────────────────────────────── (Assert, impl) Γ ⊢ Assert cond ⇒ TVoid ``` -#### Assume - ``` Γ ⊢ cond ⇐ TBool ───────────────────────────── (Assume, impl) @@ -489,16 +445,12 @@ target is a numeric type. ### Assignment -#### Assign-Single - ``` Γ(x) = T_x Γ ⊢ e ⇒ T_e T_e <: T_x ─────────────────────────────────────────────── (Assign-Single, impl) Γ ⊢ Assign [x] e ⇒ TVoid ``` -#### Assign-Multi - ``` Γ ⊢ targets_i = x_i Γ(x_i) = T_i Γ ⊢ e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i ───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl) @@ -507,8 +459,6 @@ target is a numeric type. ### Calls -#### Static-Call - ``` Γ(callee) = static-procedure with inputs Ts and outputs [T] Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) @@ -516,8 +466,6 @@ target is a numeric type. Γ ⊢ StaticCall callee args ⇒ T ``` -#### Static-Call-Multi - ``` Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) @@ -525,8 +473,6 @@ target is a numeric type. Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` -#### Instance-Call - ``` Γ ⊢ target ⇒ _ Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T] Γ ⊢ args ⇒ Us U_i <: T_i (pairwise; self is dropped) @@ -540,32 +486,24 @@ target is a numeric type. {name Strata.Laurel.HighType.TReal}`TReal`, {name Strata.Laurel.HighType.TFloat64}`TFloat64`". -#### Op-Bool - ``` Γ ⊢ args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} ────────────────────────────────── (Op-Bool, impl) Γ ⊢ PrimitiveOp op args ⇒ TBool ``` -#### Op-Cmp - ``` Γ ⊢ args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} ───────────────────────────────── (Op-Cmp, impl) Γ ⊢ PrimitiveOp op args ⇒ TBool ``` -#### Op-Eq - ``` Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} ───────────────────────────────────────────────────────────────── (Op-Eq, impl) Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool ``` -#### Op-Arith - ``` Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ────────────────────────────────────────────────── (Op-Arith, impl) @@ -576,8 +514,6 @@ target is a numeric type. etc. without unification. Known relaxation: `int + real` passes (each operand individually passes `Numeric`); a proper fix needs numeric promotion or unification. -#### Op-Concat - ``` Γ ⊢ args_i ⇐ TString op = StrConcat ───────────────────────────────────── (Op-Concat, impl) @@ -586,24 +522,18 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ### Object forms -#### New-Ok - ``` Γ(ref) is a composite or datatype T ────────────────────────────────────────── (New-Ok, impl) Γ ⊢ New ref ⇒ UserDefined T ``` -#### New-Fallback - ``` Γ(ref) is not a composite or datatype ───────────────────────────────────────── (New-Fallback, impl) Γ ⊢ New ref ⇒ Unknown ``` -#### AsType - ``` Γ ⊢ target ⇒ _ ───────────────────────────── (AsType, impl) @@ -612,24 +542,18 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. `target` is resolved but not checked against `T` — the cast is the user's claim. -#### IsType - ``` Γ ⊢ target ⇒ _ ───────────────────────────────── (IsType, impl) Γ ⊢ IsType target T ⇒ TBool ``` -#### RefEq - ``` Γ ⊢ lhs ⇒ _ Γ ⊢ rhs ⇒ _ ─────────────────────────────────────── (RefEq, impl) Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool ``` -#### PureFieldUpdate - ``` Γ ⊢ target ⇒ T_t Γ ⊢ newVal ⇒ _ ─────────────────────────────────────────────── (PureFieldUpdate, impl) @@ -638,8 +562,6 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ### Verification expressions -#### Quantifier - ``` Γ, x : T ⊢ body ⇐ TBool ───────────────────────────────────────────────── (Quantifier, impl) @@ -650,32 +572,24 @@ The bound variable `x : T` is introduced in scope only for the body (and trigger is checked against {name Strata.Laurel.HighType.TBool}`TBool` since a quantifier is a proposition; without this, `forall x: int :: x + 1` would be silently accepted. -#### Assigned - ``` Γ ⊢ name ⇒ _ ───────────────────────────── (Assigned, impl) Γ ⊢ Assigned name ⇒ TBool ``` -#### Old - ``` Γ ⊢ v ⇒ T ───────────────── (Old, impl) Γ ⊢ Old v ⇒ T ``` -#### Fresh - ``` Γ ⊢ v ⇒ _ ───────────────────── (Fresh, impl) Γ ⊢ Fresh v ⇒ TBool ``` -#### ProveBy - ``` Γ ⊢ v ⇒ T Γ ⊢ proof ⇒ _ ─────────────────────────────────── (ProveBy, impl) @@ -684,15 +598,11 @@ proposition; without this, `forall x: int :: x + 1` would be silently accepted. ### Untyped forms -#### This - ``` ────────────────────────── (This, impl) Γ ⊢ This ⇒ Unknown ``` -#### Abstract / All / ContractOf - ``` ───────────────────────────────────────────── (Abstract / All / ContractOf, impl) Γ ⊢ Abstract / All / ContractOf … ⇒ Unknown @@ -700,22 +610,16 @@ proposition; without this, `forall x: int :: x + 1` would be silently accepted. ### Holes -#### Hole-Some - ``` ──────────────────────────── (Hole-Some, impl) Γ ⊢ Hole d (some T) ⇒ T ``` -#### Hole-None-Synth - ``` ───────────────────────────────── (Hole-None-Synth, impl) Γ ⊢ Hole d none ⇒ Unknown ``` -#### Hole-None-Check - ``` Unknown <: T ───────────────────────── (Hole-None-Check, planned) From 3ea24314eb3cdb1358cbbb95caed4e68692a6bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 15:09:05 -0400 Subject: [PATCH 078/189] class-rules : updates on same-typed fields + this in class context only --- Strata/Languages/Laurel/Resolution.lean | 17 +++++++++++-- docs/verso/LaurelDoc.lean | 32 +++++++++++++++++++------ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 01676402aa..bd139a67d1 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -590,7 +590,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .PureFieldUpdate target fieldName newVal => let (target', targetTy) ← synthStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source - let (newVal', _) ← synthStmtExpr newVal + let fieldTy ← getVarType fieldName' + let newVal' ← checkStmtExpr newVal fieldTy pure (.PureFieldUpdate target' fieldName' newVal', targetTy) | .StaticCall callee args => let callee' ← resolveRef callee source @@ -646,7 +647,19 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let ty := if kindOk then { val := HighType.UserDefined ref', source := source } else { val := HighType.Unknown, source := source } pure (.New ref', ty) - | .This => pure (.This, { val := .Unknown, source := source }) + | .This => + let s ← get + match s.instanceTypeName with + | some typeName => + let typeId : Identifier := + match s.scope.get? typeName with + | some (uid, _) => { text := typeName, uniqueId := some uid, source := source } + | none => { text := typeName, source := source } + pure (.This, { val := .UserDefined typeId, source := source }) + | none => + let diag := diagnosticFromSource source "'this' is not allowed outside instance methods" + modify fun s => { s with errors := s.errors.push diag } + pure (.This, { val := .Unknown, source := source }) | .ReferenceEquals lhs rhs => let (lhs', _) ← synthStmtExpr lhs let (rhs', _) ← synthStmtExpr rhs diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 6bb9271cbf..8506285865 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -262,7 +262,8 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x - *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat - *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate - *Verification expressions* — Quantifier, Assigned, Old, Fresh, ProveBy -- *Untyped forms* — This; Abstract / All / ContractOf +- *Self reference* — This-Inside, This-Outside +- *Untyped forms* — Abstract / All / ContractOf - *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check (planned) ### Subsumption @@ -555,11 +556,14 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ``` ``` - Γ ⊢ target ⇒ T_t Γ ⊢ newVal ⇒ _ -─────────────────────────────────────────────── (PureFieldUpdate, impl) - Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t + Γ ⊢ target ⇒ T_t Γ(f) = T_f Γ ⊢ newVal ⇐ T_f +───────────────────────────────────────────────────────────── (PureFieldUpdate, impl) + Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t ``` +`f` is resolved against `T_t` (or the enclosing instance type) and `newVal` is checked +against the field's declared type. + ### Verification expressions ``` @@ -596,13 +600,27 @@ proposition; without this, `forall x: int :: x + 1` would be silently accepted. Γ ⊢ ProveBy v proof ⇒ T ``` -### Untyped forms +### Self reference ``` -────────────────────────── (This, impl) - Γ ⊢ This ⇒ Unknown + Γ.instanceTypeName = some T +────────────────────────────────── (This-Inside, impl) + Γ ⊢ This ⇒ UserDefined T + + + Γ.instanceTypeName = none +────────────────────────────── (This-Outside, impl) + Γ ⊢ This ⇒ Unknown [emits "'this' is not allowed outside instance methods"] ``` +`Γ.instanceTypeName` is the +{name Strata.Laurel.ResolveState}`ResolveState` field set by +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` for the duration of +an instance method body. With it, `this.field` and instance-method dispatch synthesize real +types instead of being wildcarded through {name Strata.Laurel.HighType.Unknown}`Unknown`. + +### Untyped forms + ``` ───────────────────────────────────────────── (Abstract / All / ContractOf, impl) Γ ⊢ Abstract / All / ContractOf … ⇒ Unknown From 787282667faf36164c86ffed3fc832bbe75a3a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 15:37:40 -0400 Subject: [PATCH 079/189] references checks --- Strata/Languages/Laurel/Resolution.lean | 21 ++++++++++++++++++--- docs/verso/LaurelDoc.lean | 23 +++++++++++++++++------ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index bd139a67d1..3df24ce05e 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -452,6 +452,15 @@ private def isConsistentNumeric (ty : HighTypeMd) : Bool := | .TCore _ => true | _ => false +/-- Test whether a type is a user-defined reference type, modulo gradual + consistency. Used by Fresh and ReferenceEquals, which only make sense on + composite/datatype references. -/ +private def isConsistentReference (ty : HighTypeMd) : Bool := + match ty.val with + | .UserDefined _ | .Unknown => true + | .TCore _ => true + | _ => false + /-- Get the type of a resolved variable reference from scope. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get @@ -661,8 +670,12 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := modify fun s => { s with errors := s.errors.push diag } pure (.This, { val := .Unknown, source := source }) | .ReferenceEquals lhs rhs => - let (lhs', _) ← synthStmtExpr lhs - let (rhs', _) ← synthStmtExpr rhs + let (lhs', lhsTy) ← synthStmtExpr lhs + let (rhs', rhsTy) ← synthStmtExpr rhs + unless isConsistentReference lhsTy do + typeMismatch lhsTy.source "a reference type" lhsTy + unless isConsistentReference rhsTy do + typeMismatch rhsTy.source "a reference type" rhsTy pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => let (target', _) ← synthStmtExpr target @@ -700,7 +713,9 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let (val', valTy) ← synthStmtExpr val pure (.Old val', valTy) | .Fresh val => - let (val', _) ← synthStmtExpr val + let (val', valTy) ← synthStmtExpr val + unless isConsistentReference valTy do + typeMismatch valTy.source "a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 8506285865..2c7f9542d2 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -550,11 +550,18 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. ``` ``` - Γ ⊢ lhs ⇒ _ Γ ⊢ rhs ⇒ _ -─────────────────────────────────────── (RefEq, impl) - Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool + Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r isReference T_l isReference T_r +───────────────────────────────────────────────────────────────────────────── (RefEq, impl) + Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool ``` +`isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined`, +{name Strata.Laurel.HighType.Unknown}`Unknown`, or {name Strata.Laurel.HighType.TCore}`TCore` +type. Reference equality is meaningless on primitives. Compatibility between `T_l` and +`T_r` (e.g. rejecting `Cat === Dog` for unrelated user-defined types) is delegated to +future tightening of `<:` — today, two distinct user-defined names already mismatch +structurally, so the check would only fire under stronger subtyping. + ``` Γ ⊢ target ⇒ T_t Γ(f) = T_f Γ ⊢ newVal ⇐ T_f ───────────────────────────────────────────────────────────── (PureFieldUpdate, impl) @@ -589,11 +596,15 @@ proposition; without this, `forall x: int :: x + 1` would be silently accepted. ``` ``` - Γ ⊢ v ⇒ _ -───────────────────── (Fresh, impl) - Γ ⊢ Fresh v ⇒ TBool + Γ ⊢ v ⇒ T isReference T +───────────────────────────────── (Fresh, impl) + Γ ⊢ Fresh v ⇒ TBool ``` +`isReference T` is the same predicate as in {name Strata.Laurel.StmtExpr.ReferenceEquals}`ReferenceEquals`. +{name Strata.Laurel.StmtExpr.Fresh}`Fresh` only makes sense on heap-allocated references; +`fresh(5)` is rejected. + ``` Γ ⊢ v ⇒ T Γ ⊢ proof ⇒ _ ─────────────────────────────────── (ProveBy, impl) From b212eb5404b2aad4f0d5388a38c2a4d46f71dd40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 16:21:06 -0400 Subject: [PATCH 080/189] pretty printers --- Strata/Languages/Laurel/Laurel.lean | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index 86ae83d022..a7dc4377d2 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -100,6 +100,20 @@ inductive Operation : Type where | StrConcat deriving Repr +instance : ToString Operation where + toString + | .Eq => "==" | .Neq => "!=" + | .And => "&&" | .Or => "||" + | .Not => "!" | .Implies => "==>" + | .AndThen => "&&!" | .OrElse => "||!" + | .Neg => "-" | .Add => "+" + | .Sub => "-" | .Mul => "*" + | .Div => "/" | .Mod => "%" + | .DivT => "/t" | .ModT => "%t" + | .Lt => "<" | .Leq => "<=" + | .Gt => ">" | .Geq => ">=" + | .StrConcat => "++" + /-- A wrapper that pairs a value with source-level metadata such as source locations and annotations. All Laurel AST nodes are wrapped in @@ -334,6 +348,40 @@ inductive ContractType where | Reads | Modifies | Precondition | PostCondition end +/-- A short user-facing name for the construct, used in diagnostic messages. -/ +def StmtExpr.constrName : StmtExpr → String + | .IfThenElse .. => "if" + | .Block .. => "block" + | .While .. => "while" + | .Exit .. => "exit" + | .Return .. => "return" + | .LiteralInt .. => "integer literal" + | .LiteralBool .. => "boolean literal" + | .LiteralString .. => "string literal" + | .LiteralDecimal .. => "decimal literal" + | .Var .. => "variable" + | .Assign .. => ":=" + | .PureFieldUpdate .. => "field update" + | .StaticCall .. => "call" + | .PrimitiveOp op _ => toString op + | .New .. => "new" + | .This => "this" + | .ReferenceEquals .. => "reference equality" + | .AsType .. => "as" + | .IsType .. => "is" + | .InstanceCall .. => "method call" + | .Quantifier .. => "quantifier" + | .Assigned .. => "assigned" + | .Old .. => "old" + | .Fresh .. => "fresh" + | .Assert .. => "assert" + | .Assume .. => "assume" + | .ProveBy .. => "by" + | .ContractOf .. => "contractOf" + | .Abstract => "abstract" + | .All => "all" + | .Hole .. => "hole" + @[expose] abbrev HighTypeMd := AstNode HighType @[expose] abbrev StmtExprMd := AstNode StmtExpr @[expose] abbrev VariableMd := AstNode Variable From c51fc96efb94cf742a3b24af9dcd6c0b92117acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 16:38:07 -0400 Subject: [PATCH 081/189] better type mismatch diagnostics --- Strata/Languages/Laurel/Resolution.lean | 74 ++++++++++++++----------- docs/verso/LaurelDoc.lean | 36 +++++++----- 2 files changed, 62 insertions(+), 48 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 3df24ce05e..26783e2de9 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -417,24 +417,35 @@ private def formatType (ty : HighTypeMd) : String := "(" ++ ", ".intercalate parts ++ ")" | other => toString (formatHighTypeVal other) -/-- Emit a type mismatch diagnostic. -/ -private def typeMismatch (source : Option FileRange) (expected : String) (actual : HighTypeMd) : ResolveM Unit := do - let actualStr := formatType actual - let diag := diagnosticFromSource source s!"Type mismatch: expected {expected}, but got '{actualStr}'" +/-- Emit a type mismatch diagnostic. With a `construct`, the message is + "'' , got ''"; without, + ", got ''". -/ +private def typeMismatch (source : Option FileRange) (construct : Option StmtExpr) + (problem : String) (actual : HighTypeMd) : ResolveM Unit := do + let constructor := match construct with + | some c => s!"'{c.constrName}' " + | none => "" + let diag := diagnosticFromSource source s!"{constructor}{problem}, got '{formatType actual}'" modify fun s => { s with errors := s.errors.push diag } /-- Subtyping. Stub: structural equality via `highEq`. TODO: To be replaced with a real check that walks `extending` chains for composites, unfolds aliases, and unwraps constrained types to their base. -/ private def isSubtype (sub sup : HighTypeMd) : Bool := highEq sub sup -/-- Gradual consistency-subtyping (Siek–Taha style): `Unknown` is the dynamic - type and is consistent with everything in either direction. `TCore` is a - migration escape hatch and is bivariantly compatible for now. -/ -private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := - match sub.val, sup.val with +/-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the + dynamic type and is consistent with everything; otherwise the relation + delegates to structural equality. `TCore` is a temporary migration + escape hatch. -/ +private def isConsistent (a b : HighTypeMd) : Bool := + match a.val, b.val with | .Unknown, _ | _, .Unknown => true | .TCore _, _ | _, .TCore _ => true - | _, _ => isSubtype sub sup + | _, _ => highEq a b + +/-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For the flat type + lattice this collapses to `sub ~ sup ∨ sub <: sup`. -/ +private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := + isConsistent sub sup || isSubtype sub sup /-- Type-level subtype check: emits the standard "expected/got" diagnostic when `actual` is not a consistent subtype of `expected`. Used at sites where the @@ -442,20 +453,20 @@ private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := output) — equivalent to `checkStmtExpr e expected` but without re-synthesizing. -/ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do unless isConsistentSubtype actual expected do - typeMismatch source (s!"'{formatType expected}'") actual + typeMismatch source none s!"expected '{formatType expected}'" actual -/-- Test whether a type is in the set of numeric primitives, modulo gradual - consistency. Used by Op-Cmp / Op-Arith. -/ -private def isConsistentNumeric (ty : HighTypeMd) : Bool := +/-- Test whether a type is in the set of numeric primitives. `Unknown` and + `TCore` are accepted as gradual escape hatches. Used by Op-Cmp / Op-Arith. -/ +private def isNumeric (ty : HighTypeMd) : Bool := match ty.val with | .TInt | .TReal | .TFloat64 | .Unknown => true | .TCore _ => true | _ => false -/-- Test whether a type is a user-defined reference type, modulo gradual - consistency. Used by Fresh and ReferenceEquals, which only make sense on - composite/datatype references. -/ -private def isConsistentReference (ty : HighTypeMd) : Bool := +/-- Test whether a type is a user-defined reference type. `Unknown` and `TCore` + are accepted as gradual escape hatches. Used by Fresh and ReferenceEquals, + which only make sense on composite/datatype references. -/ +private def isReference (ty : HighTypeMd) : Bool := match ty.val with | .UserDefined _ | .Unknown => true | .TCore _ => true @@ -630,14 +641,14 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := checkSubtype source { val := .TBool, source := aTy.source } aTy | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => for aTy in argTypes do - unless isConsistentNumeric aTy do typeMismatch aTy.source "a numeric type" aTy + unless isNumeric aTy do + typeMismatch aTy.source (some expr) "expected a numeric type" aTy | .Eq | .Neq => - -- Symmetric: pass if either direction is consistent. match argTypes with | [lhsTy, rhsTy] => - unless isConsistentSubtype lhsTy rhsTy || isConsistentSubtype rhsTy lhsTy do + unless isConsistent lhsTy rhsTy do let diag := diagnosticFromSource source - s!"Operands of '==' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" + s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" modify fun s => { s with errors := s.errors.push diag } | _ => pure () | .StrConcat => @@ -672,10 +683,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .ReferenceEquals lhs rhs => let (lhs', lhsTy) ← synthStmtExpr lhs let (rhs', rhsTy) ← synthStmtExpr rhs - unless isConsistentReference lhsTy do - typeMismatch lhsTy.source "a reference type" lhsTy - unless isConsistentReference rhsTy do - typeMismatch rhsTy.source "a reference type" rhsTy + unless isReference lhsTy do + typeMismatch lhsTy.source (some expr) "expected a reference type" lhsTy + unless isReference rhsTy do + typeMismatch rhsTy.source (some expr) "expected a reference type" rhsTy pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => let (target', _) ← synthStmtExpr target @@ -714,8 +725,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := pure (.Old val', valTy) | .Fresh val => let (val', valTy) ← synthStmtExpr val - unless isConsistentReference valTy do - typeMismatch valTy.source "a reference type" valTy + unless isReference valTy do + typeMismatch valTy.source (some expr) "expected a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } @@ -761,9 +772,7 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE let (s', _) ← synthStmtExpr s; pure s') match _lastResult: stmts.getLast? with | none => - let tvoid : HighTypeMd := { val := .TVoid, source := source } - unless isConsistentSubtype tvoid expected do - typeMismatch source (formatType expected) tvoid + checkSubtype source expected { val := .TVoid, source := source } pure { val := .Block init' label, source := source } | some last => have := List.mem_of_getLast? _lastResult @@ -772,8 +781,7 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← synthStmtExpr exprMd - unless isConsistentSubtype actual expected do - typeMismatch source (formatType expected) actual + checkSubtype source expected actual pure e' termination_by (exprMd, 1) decreasing_by all_goals first diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 2c7f9542d2..4ff9cd7f0f 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -201,27 +201,30 @@ internal interface used by other rules. ### Gradual typing -The relation `<:` is implemented by two Lean functions — both currently stubs, both -intended to be sharpened: +The relation `<:` (used in Sub) is built from three Lean functions: - `isSubtype` — pure subtyping. The stub is structural equality via {name Strata.Laurel.highEq}`highEq`. The eventual real version walks the `extending` chain for {name Strata.Laurel.CompositeType}`CompositeType`, unfolds {name Strata.Laurel.TypeAlias}`TypeAlias` to its target, and unwraps {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base. -- `isConsistentSubtype` — gradual consistency, in the Siek–Taha sense. - {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type `?` and is consistent - with everything in either direction; otherwise the relation delegates to `isSubtype`. - {name Strata.Laurel.HighType.TCore}`TCore` is bivariantly consistent for now, as a - clearly-labelled migration escape hatch from the Core language — this carve-out is - intentionally temporary. - -Subsumption (and every bespoke check rule) uses `isConsistentSubtype`, never raw -`isSubtype`. That single choice is what makes the system *gradual*: an expression of type +- `isConsistent` — the symmetric gradual relation `~` (Siek–Taha): + {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type and is consistent with + everything; otherwise structural equality. +- `isConsistentSubtype` — defined as `isConsistent ∨ isSubtype`. For our flat lattice this + is the standard collapse of `∃R. T ~ R ∧ R <: U`. + +{name Strata.Laurel.HighType.TCore}`TCore` is bivariantly consistent for now as a temporary +migration escape hatch from the Core language; the carve-out lives in `isConsistent` and is +intentionally temporary. + +Sub (and every bespoke check rule) uses `isConsistentSubtype`. That single choice is what +makes the system *gradual*: an expression of type {name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) flows freely into any typed slot, and any expression flows freely into a slot of type {name Strata.Laurel.HighType.Unknown}`Unknown`. Strict checking is applied between -fully-known types only. +fully-known types only. The symmetric `isConsistent` is used directly by Op-Eq, where the +operand types must be mutually consistent (no subtype direction is privileged). A previous iteration was synth-only with three *bivariantly-compatible* wildcards: {name Strata.Laurel.HighType.Unknown}`Unknown`, @@ -500,11 +503,14 @@ target is a numeric type. ``` ``` - Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l <: T_r ∨ T_r <: T_l op ∈ {Eq, Neq} -───────────────────────────────────────────────────────────────── (Op-Eq, impl) - Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool + Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l ~ T_r op ∈ {Eq, Neq} +───────────────────────────────────────────────────────── (Op-Eq, impl) + Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool ``` +`~` is the consistency relation `isConsistent` — symmetric, with the +{name Strata.Laurel.HighType.Unknown}`Unknown` wildcard. + ``` Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ────────────────────────────────────────────────── (Op-Arith, impl) From a70d171f34b888ef84ffa6502965d68f74771a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 16:41:21 -0400 Subject: [PATCH 082/189] check ifthenelse --- Strata/Languages/Laurel/Resolution.lean | 10 ++++++++++ docs/verso/LaurelDoc.lean | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 26783e2de9..03fc98e54e 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -778,6 +778,16 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE have := List.mem_of_getLast? _lastResult let last' ← checkStmtExpr last expected pure { val := .Block (init' ++ [last']) label, source := source } + | .IfThenElse cond thenBr elseBr => + -- Push `expected` into both branches (rather than going through the synth + -- rule + Sub at the boundary). Without an else branch, fall back to + -- subsumption of TVoid against `expected`. + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let thenBr' ← checkStmtExpr thenBr expected + let elseBr' ← elseBr.attach.mapM (fun ⟨e, _⟩ => checkStmtExpr e expected) + if elseBr.isNone then + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .IfThenElse cond' thenBr' elseBr', source := source } | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← synthStmtExpr exprMd diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 4ff9cd7f0f..20550333f8 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -256,9 +256,9 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x - *Subsumption* — Sub - *Literals* — Lit-Int, Lit-Bool, Lit-String, Lit-Decimal - *Variables* — Var-Local, Var-Field, Var-Declare -- *Control flow* — If-NoElse, If-Synth, If-Check (planned); Block-Synth, Block-Synth-Empty, - Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, Return-Some-Checked - (planned); While +- *Control flow* — If-NoElse, If-Synth, If-Check, If-Check-NoElse; Block-Synth, + Block-Synth-Empty, Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, + Return-Some-Checked (planned); While - *Verification statements* — Assert, Assume - *Assignment* — Assign-Single, Assign-Multi - *Calls* — Static-Call, Static-Call-Multi, Instance-Call @@ -352,10 +352,21 @@ the actual check downstream. ``` Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T -────────────────────────────────────────────────────────── (If-Check, planned) +────────────────────────────────────────────────────────── (If-Check, impl) Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T + + +Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T TVoid <: T +───────────────────────────────────────────────────── (If-Check-NoElse, impl) + Γ ⊢ IfThenElse cond thenBr none ⇐ T ``` +Check mode pushes `T` into both branches (rather than going through If-Synth + Sub at the +boundary). Errors fire at the offending branch instead of the surrounding `if`. Without an +else branch, the construct can only succeed when `T` admits +{name Strata.Laurel.HighType.TVoid}`TVoid` — the same subsumption check `Block-Check-Empty` +performs for an empty block. + ``` Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇒ T ─────────────────────────────────────────────────────────────────────────── (Block-Synth, impl) From 453d293f94e778e0c9e62d573401b5418f1e24ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:01:12 -0400 Subject: [PATCH 083/189] type check returns multiple return arity : 0 = Void 1 = return expr // return; with returns (res:T) signature n = return; allowed only --- Strata/Languages/Laurel/Resolution.lean | 33 ++++++++++++++++++- docs/verso/LaurelDoc.lean | 43 ++++++++++++++++++------- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 03fc98e54e..e781c9b65d 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -262,6 +262,10 @@ structure ResolveState where /-- When resolving inside an instance procedure, the owning composite type name. Used by `resolveFieldRef` to resolve `self.field` when `self` has type `Any`. -/ instanceTypeName : Option String := none + /-- When resolving inside a procedure body, the declared output types (in + declaration order). `none` means no enclosing procedure. Used by `Return` + to type-check the optional return value and to flag arity/shape mismatches. -/ + expectedReturnTypes : Option (List HighTypeMd) := none @[expose] abbrev ResolveM := StateM ResolveState @@ -543,8 +547,29 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := pure (.While cond' invs' dec' body', { val := .TVoid, source := source }) | .Exit target => pure (.Exit target, { val := .TVoid, source := source }) | .Return val => do + -- Match the optional return value against the enclosing procedure's + -- declared outputs. `expectedReturnTypes = none` means we're not inside a + -- procedure body (e.g. resolving a constant initializer); skip the check. + let expected := (← get).expectedReturnTypes let val' ← val.attach.mapM (fun a => have := a.property; do - let (e', _) ← synthStmtExpr a.val; pure e') + match expected with + | some [singleOutput] => checkStmtExpr a.val singleOutput + | _ => let (e', _) ← synthStmtExpr a.val; pure e') + -- Arity/shape diagnostics independent of the value's own type. + match val, expected with + | none, some [] => pure () + | none, some [_] => pure () -- Dafny-style early exit + | none, some _ => pure () -- multi-output: bare return is fine + | some _, some [] => + let diag := diagnosticFromSource source + "void procedure cannot return a value" + modify fun s => { s with errors := s.errors.push diag } + | some _, some [_] => pure () -- value already checked above + | some _, some _ => + let diag := diagnosticFromSource source + "multi-output procedure cannot use 'return e'; assign to named outputs instead" + modify fun s => { s with errors := s.errors.push diag } + | _, none => pure () -- no enclosing procedure pure (.Return val', { val := .TVoid, source := source }) | .LiteralInt v => pure (.LiteralInt v, { val := .TInt, source := source }) | .LiteralBool v => pure (.LiteralBool v, { val := .TBool, source := source }) @@ -834,7 +859,10 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do let outputs' ← proc.outputs.mapM resolveParameter let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) let dec' ← proc.decreases.mapM resolveStmtExpr + let savedReturns := (← get).expectedReturnTypes + modify fun s => { s with expectedReturnTypes := some (outputs'.map (·.type)) } let (body', bodyTy) ← resolveBody proc.body + modify fun s => { s with expectedReturnTypes := savedReturns } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" @@ -875,7 +903,10 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv let outputs' ← proc.outputs.mapM resolveParameter let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) let dec' ← proc.decreases.mapM resolveStmtExpr + let savedReturns := (← get).expectedReturnTypes + modify fun s => { s with expectedReturnTypes := some (outputs'.map (·.type)) } let (body', bodyTy) ← resolveBody proc.body + modify fun s => { s with expectedReturnTypes := savedReturns } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 20550333f8..b7b74c5af2 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -258,7 +258,7 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x - *Variables* — Var-Local, Var-Field, Var-Declare - *Control flow* — If-NoElse, If-Synth, If-Check, If-Check-NoElse; Block-Synth, Block-Synth-Empty, Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, - Return-Some-Checked (planned); While + Return-Void-Error, Return-Multi-Error; While - *Verification statements* — Assert, Assume - *Assignment* — Assign-Single, Assign-Multi - *Calls* — Static-Call, Static-Call-Multi, Instance-Call @@ -411,29 +411,48 @@ nested {name Strata.Laurel.StmtExpr.Block}`Block` / Γ ⊢ Exit target ⇒ TVoid ``` +`Return` matches the optional return value against the enclosing procedure's declared +outputs. The expected output types are threaded through +{name Strata.Laurel.ResolveState}`ResolveState`'s `expectedReturnTypes`, set from +`proc.outputs` by {name Strata.Laurel.resolveProcedure}`resolveProcedure` / +{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` for the duration of +the body. `none` means "no enclosing procedure" — e.g. resolving a constant initializer — +and skips all `Return` checks. + ``` ───────────────────────────── (Return-None, impl) Γ ⊢ Return none ⇒ TVoid ``` +A bare `return;` is allowed in any context. In a single-output procedure it acts as a +Dafny-style early exit — the output parameter retains whatever was last assigned to it. + ``` - Γ ⊢ e ⇒ _ -────────────────────────────── (Return-Some, impl) - Γ ⊢ Return (some e) ⇒ TVoid + Γ_proc.outputs = [T] Γ ⊢ e ⇐ T +────────────────────────────────────── (Return-Some, impl) + Γ ⊢ Return (some e) ⇒ TVoid ``` -The value's synthesized type is currently discarded, so `return 0` in a `bool`-returning -procedure isn't caught. Replaced by Return-Some-Checked once the expected return type is -threaded through {name Strata.Laurel.ResolveState}`ResolveState`. +In a single-output procedure, the value is checked against the declared output type. This +closes the prior soundness gap where `return 0` in a `bool`-returning procedure went +uncaught. ``` - Γ_proc.outputs = [T] Γ ⊢ e ⇐ T -────────────────────────────────────── (Return-Some-Checked, planned) - Γ ⊢ Return (some e) ⇒ TVoid + Γ_proc.outputs = [] +───────────────────────────────── (Return-Void-Error, impl) + Γ ⊢ Return (some e) — error: "void procedure cannot return a value" + + + Γ_proc.outputs = [T_1; …; T_n] (n ≥ 2) +────────────────────────────────────────────────────────── (Return-Multi-Error, impl) + Γ ⊢ Return (some e) — error: "multi-output procedure cannot + use 'return e'; assign to named outputs instead" ``` -Set from `proc.outputs` in {name Strata.Laurel.resolveProcedure}`resolveProcedure` / -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure`. +Multi-output procedures use named-output assignment (`r := …` on the declared output +parameters). `return e` syntactically takes a single +{name Strata.Laurel.StmtExpr.Return}`Option StmtExpr`, so it cannot carry multiple values; +flagging it points users at the named-output convention. ``` Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ From 7bc098dd20af45249423e2cf06e0f0e25583dbbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:15:26 -0400 Subject: [PATCH 084/189] type check procedures contracts --- Strata/Languages/Laurel/Resolution.lean | 25 ++++++++++++- docs/verso/LaurelDoc.lean | 47 +++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index e781c9b65d..a0e441092c 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -764,8 +764,31 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let (proof', _) ← synthStmtExpr proof pure (.ProveBy val' proof', valTy) | .ContractOf ty fn => + -- `fn` must be a direct identifier reference resolving to a procedure. + -- Anything else (arbitrary expressions, references to non-procedures) is + -- ill-formed: a contract belongs to a *named* procedure. let (fn', _) ← synthStmtExpr fn - pure (.ContractOf ty fn', { val := .Unknown, source := source }) + let s ← get + let fnIsProcRef : Bool := match fn'.val with + | .Var (.Local ref) => + match s.scope.get? ref.text with + | some (_, node) => + node.kind == .staticProcedure || + node.kind == .instanceProcedure || + node.kind == .unresolved + | none => true -- unresolved name already reported + | _ => false + unless fnIsProcRef do + let diag := diagnosticFromSource fn.source + "'contractOf' expected a procedure reference" + modify fun s => { s with errors := s.errors.push diag } + -- Result type: Bool for pre/postconditions, set of heap references for + -- reads/modifies. The element type of the set is left as Unknown for now + -- since the rule doesn't recover it from `fn`. + let resultTy : HighType := match ty with + | .Precondition | .PostCondition => .TBool + | .Reads | .Modifies => .TSet { val := .Unknown, source := none } + pure (.ContractOf ty fn', { val := resultTy, source := source }) | .Abstract => pure (.Abstract, { val := .Unknown, source := source }) | .All => pure (.All, { val := .Unknown, source := source }) | .Hole det type => match type with diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index b7b74c5af2..b98a1bda98 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -266,7 +266,8 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x - *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate - *Verification expressions* — Quantifier, Assigned, Old, Fresh, ProveBy - *Self reference* — This-Inside, This-Outside -- *Untyped forms* — Abstract / All / ContractOf +- *Untyped forms* — Abstract / All +- *ContractOf* — ContractOf-Bool, ContractOf-Set, ContractOf-Error - *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check (planned) ### Subsumption @@ -669,10 +670,50 @@ types instead of being wildcarded through {name Strata.Laurel.HighType.Unknown}` ### Untyped forms ``` -───────────────────────────────────────────── (Abstract / All / ContractOf, impl) - Γ ⊢ Abstract / All / ContractOf … ⇒ Unknown +───────────────────────────────── (Abstract / All, impl) + Γ ⊢ Abstract / All … ⇒ Unknown ``` +### ContractOf + +`ContractOf ty fn` extracts a procedure's contract clause as a value: its preconditions +(`Precondition`), postconditions (`PostCondition`), reads set (`Reads`), or modifies set +(`Modifies`). `fn` must be a direct identifier reference to a procedure — a contract belongs +to a *named* procedure, not an arbitrary expression. + +``` + fn = Var (.Local id) Γ(id) ∈ {staticProcedure, instanceProcedure} +───────────────────────────────────────────────────────────────────────── (ContractOf-Bool, impl) + Γ ⊢ ContractOf Precondition fn ⇒ TBool + Γ ⊢ ContractOf PostCondition fn ⇒ TBool + + + fn = Var (.Local id) Γ(id) ∈ {staticProcedure, instanceProcedure} +───────────────────────────────────────────────────────────────────────── (ContractOf-Set, impl) + Γ ⊢ ContractOf Reads fn ⇒ TSet Unknown + Γ ⊢ ContractOf Modifies fn ⇒ TSet Unknown +``` + +`Precondition` and `PostCondition` are propositions, hence +{name Strata.Laurel.HighType.TBool}`TBool`. `Reads` and `Modifies` are sets of heap-allocated +locations — composite/datatype references and fields. The element type is left as +{name Strata.Laurel.HighType.Unknown}`Unknown` for now since the rule doesn't yet recover it +from `fn`'s declared modifies/reads clauses. + +``` + fn is not a procedure reference +───────────────────────────────────────────── (ContractOf-Error, impl) + Γ ⊢ ContractOf … fn — error: "'contractOf' expected a procedure reference" +``` + +When `fn` doesn't resolve to a procedure (e.g. it's an arbitrary expression, or resolves to +a constant/variable), the diagnostic fires and the construct synthesizes +{name Strata.Laurel.HighType.Unknown}`Unknown` to suppress cascading errors. + +The constructor is reserved for future use — Laurel's grammar has no `contractOf` +production today, and the translator emits "not yet implemented" for it. The typing rule +exists so resolution remains exhaustive over `StmtExpr`. + ### Holes ``` From c69210a638e66a382cdd0c4889ffedbb5edb6792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:28:15 -0400 Subject: [PATCH 085/189] check untyped holes --- Strata/Languages/Laurel/Resolution.lean | 5 +++++ docs/verso/LaurelDoc.lean | 17 ++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index a0e441092c..5022293b73 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -836,6 +836,11 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE if elseBr.isNone then checkSubtype source expected { val := .TVoid, source := source } pure { val := .IfThenElse cond' thenBr' elseBr', source := source } + | .Hole det none => + -- Untyped hole in check mode: record the expected type on the node so + -- downstream passes don't have to infer it again. Subsumption is trivial + -- (Unknown <: T always holds). + pure { val := .Hole det (some expected), source := source } | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← synthStmtExpr exprMd diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index b98a1bda98..68c1fe0f62 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -268,7 +268,7 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x - *Self reference* — This-Inside, This-Outside - *Untyped forms* — Abstract / All - *ContractOf* — ContractOf-Bool, ContractOf-Set, ContractOf-Error -- *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check (planned) +- *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check ### Subsumption @@ -727,14 +727,17 @@ exists so resolution remains exhaustive over `StmtExpr`. ``` ``` - Unknown <: T -───────────────────────── (Hole-None-Check, planned) - Γ ⊢ Hole d none ⇐ T +───────────────────────────────────── (Hole-None-Check, impl) + Γ ⊢ Hole d none ⇐ T ↦ Hole d (some T) ``` -In check mode today, `Hole d none ⇐ T` reduces to subsumption (`Unknown <: T`, which always -holds). The planned rule would record the inferred `T` on the hole node so downstream -passes can see it, instead of leaving `none` until the hole-inference pass. +In check mode, an untyped hole records the expected type `T` on the node directly. The +subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it +just preserves the type information that's available at the check-mode boundary instead of +discarding it. A separate +{name Strata.Laurel.InferHoleTypes}`InferHoleTypes` pass still runs after resolution to +annotate holes that ended up in synth-only positions; over time, as more constructs gain +bespoke check rules, fewer holes will need that pass. # Translation Pipeline From e823cef7284b43feee2ae98b286bfbde98efb4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:28:49 -0400 Subject: [PATCH 086/189] remove dangling reference --- docs/verso/LaurelDoc.lean | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 68c1fe0f62..bf812f7eb5 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -734,10 +734,9 @@ exists so resolution remains exhaustive over `StmtExpr`. In check mode, an untyped hole records the expected type `T` on the node directly. The subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it just preserves the type information that's available at the check-mode boundary instead of -discarding it. A separate -{name Strata.Laurel.InferHoleTypes}`InferHoleTypes` pass still runs after resolution to -annotate holes that ended up in synth-only positions; over time, as more constructs gain -bespoke check rules, fewer holes will need that pass. +discarding it. A separate `InferHoleTypes` pass still runs after resolution to annotate holes that ended +up in synth-only positions; over time, as more constructs gain bespoke check rules, fewer +holes will need that pass. # Translation Pipeline From 47ad5db6d71389b8111ed680f4f87dbf849f0684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:44:38 -0400 Subject: [PATCH 087/189] move subtyping/consistency rules in type definition --- Strata/Languages/Laurel/Laurel.lean | 19 +++++++++++++++++++ Strata/Languages/Laurel/Resolution.lean | 19 ------------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index a7dc4377d2..a8c665d64c 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -489,6 +489,25 @@ instance : BEq HighTypeMd where deriving instance BEq for HighType +/-- Subtyping. Stub: structural equality via `highEq`. + TODO: walk `extending` chains for composites, unfold aliases, unwrap + constrained types to their base. -/ +def isSubtype (sub sup : HighTypeMd) : Bool := highEq sub sup + +/-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the + dynamic type and is consistent with everything; otherwise structural + equality. `TCore` is a temporary migration escape hatch. -/ +def isConsistent (a b : HighTypeMd) : Bool := + match a.val, b.val with + | .Unknown, _ | _, .Unknown => true + | .TCore _, _ | _, .TCore _ => true + | _, _ => highEq a b + +/-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For our flat lattice + this collapses to `sub ~ sup ∨ sub <: sup`. -/ +def isConsistentSubtype (sub sup : HighTypeMd) : Bool := + isConsistent sub sup || isSubtype sub sup + def HighType.isBool : HighType → Bool | TBool => true | _ => false diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 5022293b73..8c88d5189b 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -432,25 +432,6 @@ private def typeMismatch (source : Option FileRange) (construct : Option StmtExp let diag := diagnosticFromSource source s!"{constructor}{problem}, got '{formatType actual}'" modify fun s => { s with errors := s.errors.push diag } -/-- Subtyping. Stub: structural equality via `highEq`. - TODO: To be replaced with a real check that walks `extending` chains for composites, unfolds aliases, and unwraps constrained types to their base. -/ -private def isSubtype (sub sup : HighTypeMd) : Bool := highEq sub sup - -/-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the - dynamic type and is consistent with everything; otherwise the relation - delegates to structural equality. `TCore` is a temporary migration - escape hatch. -/ -private def isConsistent (a b : HighTypeMd) : Bool := - match a.val, b.val with - | .Unknown, _ | _, .Unknown => true - | .TCore _, _ | _, .TCore _ => true - | _, _ => highEq a b - -/-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For the flat type - lattice this collapses to `sub ~ sup ∨ sub <: sup`. -/ -private def isConsistentSubtype (sub sup : HighTypeMd) : Bool := - isConsistent sub sup || isSubtype sub sup - /-- Type-level subtype check: emits the standard "expected/got" diagnostic when `actual` is not a consistent subtype of `expected`. Used at sites where the actual type is already in hand (assignment, call args, body vs declared From 40e1572215ccd4cb0a4fba28ba81dd2a75e430d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:45:09 -0400 Subject: [PATCH 088/189] inferholetypes flag already filled hole types when inconsistent --- Strata/Languages/Laurel/InferHoleTypes.lean | 13 ++++++++++++- docs/verso/LaurelDoc.lean | 11 ++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Strata/Languages/Laurel/InferHoleTypes.lean b/Strata/Languages/Laurel/InferHoleTypes.lean index d56ad86881..026a82e5b9 100644 --- a/Strata/Languages/Laurel/InferHoleTypes.lean +++ b/Strata/Languages/Laurel/InferHoleTypes.lean @@ -87,7 +87,7 @@ private def inferExpr (expr : StmtExprMd) (expectedType : HighTypeMd) : InferHol match expr with | AstNode.mk val source => match val with - | .Hole det _ => + | .Hole det existingTy => if expectedType.val == .Unknown then modify fun s => { s with statistics := s.statistics.increment s!"{InferHoleTypesStats.holesLeftUnknown}" @@ -95,6 +95,17 @@ private def inferExpr (expr : StmtExprMd) (expectedType : HighTypeMd) : InferHol } return expr else + -- If the hole already carried a type (from resolution's Hole-None-Check + -- rule, or from a user-written `?: T`), flag a conflict when the two + -- types disagree under consistency (gradual ~). + match existingTy with + | some prior => + unless isConsistent prior expectedType do + modify fun s => { s with + diagnostics := s.diagnostics ++ [diagnosticFromSource source + s!"hole annotated with '{formatHighTypeVal prior.val}' but context expects '{formatHighTypeVal expectedType.val}'"] + } + | none => pure () modify fun s => { s with statistics := s.statistics.increment s!"{InferHoleTypesStats.holesAnnotated}" } return ⟨.Hole det (some expectedType), source⟩ | .PrimitiveOp op args => diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index bf812f7eb5..300c7393c7 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -734,9 +734,14 @@ exists so resolution remains exhaustive over `StmtExpr`. In check mode, an untyped hole records the expected type `T` on the node directly. The subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it just preserves the type information that's available at the check-mode boundary instead of -discarding it. A separate `InferHoleTypes` pass still runs after resolution to annotate holes that ended -up in synth-only positions; over time, as more constructs gain bespoke check rules, fewer -holes will need that pass. +discarding it. + +A separate `InferHoleTypes` pass still runs after resolution to annotate holes that ended +up in synth-only positions. When that pass encounters a hole whose type was already set +(by Hole-None-Check or by a user-written `?: T`), it checks the resolution-time and +inference-time types for consistency under `~`; a disagreement fires the diagnostic +*"hole annotated with 'T_resolution' but context expects 'T_inference'"*, surfacing what +would otherwise be a silent overwrite. # Translation Pipeline From 3897097f8407645d7db5a91e7dfda400eb78509d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 17:50:14 -0400 Subject: [PATCH 089/189] future roadmap --- Strata/Languages/Laurel/Resolution.lean | 19 ++++++++++++ docs/verso/LaurelDoc.lean | 41 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 8c88d5189b..152b2bf529 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -104,6 +104,25 @@ Each of these nodes carries a `uniqueId : Option Nat` field (defaulting to `none`). Phase 1 fills in unique values; Phase 2 then builds a map from reference IDs to `ResolvedNode` values describing the definition each reference resolves to. + +## Future structural changes + +A few open structural questions worth recording — see the *Type checking* section of +`LaurelDoc.lean` for context. + +- *Rename to `NameTypeResolution`.* This pass resolves names and type-checks expressions in + one walk. The current name only mentions half of what it does. `NameTypeResolution.lean` + (or similar) would advertise both responsibilities. +- *Eliminate `LaurelTypes.computeExprType` by caching types.* Five later passes + (`LaurelToCoreTranslator`, `ModifiesClauses`, `LiftImperativeExpressions`, + `HeapParameterization`, `TypeHierarchy`) re-derive `StmtExpr` types after resolution. + Resolution already synthesizes those types and discards them. Caching per-node types on + `SemanticModel` (or directly on the AST) would let the later passes look them up instead + of recomputing. +- *Shrink or remove `InferHoleTypes`.* `Hole-None-Check` already records expected types + during resolution for holes in check-mode positions. Holes in synth-only positions still + need the post-pass, but as more constructs gain bespoke check rules, fewer holes need + it; eventually the pass can go away. -/ namespace Strata.Laurel diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 300c7393c7..0b39902de0 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -743,6 +743,47 @@ inference-time types for consistency under `~`; a disagreement fires the diagnos *"hole annotated with 'T_resolution' but context expects 'T_inference'"*, surfacing what would otherwise be a silent overwrite. +## Future structural changes + +The current pipeline has resolution and several downstream passes that recompute or +re-derive type information that resolution already synthesized. A few cleanups worth +considering: + +### Rename `Resolution.lean` → `NameTypeResolution.lean` + +The pass resolves names *and* type-checks expressions in one walk; the file name only +advertises the first half. A rename (e.g. `NameTypeResolution.lean` or +`ResolutionAndTyping.lean`) would describe what the pass actually does. The +`SemanticModel` and `ResolvedNode` types could keep their names — they're about resolved +references, not typing. + +### Eliminate `LaurelTypes.computeExprType` by caching types + +`LaurelTypes.lean` exports `computeExprType : SemanticModel → StmtExprMd → HighTypeMd`, +which five later passes call (`LaurelToCoreTranslator`, `ModifiesClauses`, +`LiftImperativeExpressions`, `HeapParameterization`, `TypeHierarchy`) to ask "what's the +type of this expression?" after resolution. Resolution already synthesizes the same types +during its walk, then discards them. Two ways to remove the duplication: + +- *Cache types on the AST.* Add a `HighTypeMd` field to `StmtExpr` (or a parallel + `Std.HashMap Nat HighTypeMd` keyed by node-id, attached to `SemanticModel`), populate it + during resolution, and have later passes read it. `computeExprType` becomes a lookup, + not a re-traversal. +- *Make the cache opt-in.* Same idea, but only enable the type-cache for passes that need + it. Less invasive but partially defeats the point. + +The duplication isn't a correctness issue today (both paths produce consistent results), +just wasted work and a maintenance hazard. + +### Shrink or remove `InferHoleTypes` + +`InferHoleTypes` walks the post-resolution AST a second time to annotate holes. Now that +Hole-None-Check writes the expected type during resolution for holes in check-mode +positions, the post-pass only needs to handle holes in synth-only positions (e.g. call +arguments resolved through `synthStmtExpr` instead of `checkStmtExpr`). As more constructs +gain bespoke check rules, fewer holes will reach `InferHoleTypes`; eventually the pass +can be deleted entirely. + # Translation Pipeline Laurel programs are verified by translating them to Strata Core and then invoking the Core From 86b6d2735eaf2894c412a99507f78f344ee288f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 18:15:30 -0400 Subject: [PATCH 090/189] fix multi value return destructuring --- Strata/Languages/Laurel/Resolution.lean | 54 ++++++++++++------------- docs/verso/LaurelDoc.lean | 25 +++++++----- 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 152b2bf529..622b35c302 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -599,33 +599,33 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) let (value', valueTy) ← synthStmtExpr value - -- Check that LHS target count matches the RHS arity (derived from the value type). - let expectedOutputCount := match valueTy.val with - | .MultiValuedExpr tys => tys.length - | _ => 1 - if valueTy.val != HighType.TVoid && targets'.length != expectedOutputCount then - let diag := diagnosticFromSource source - s!"Assignment target count mismatch: {targets'.length} targets but right-hand side produces {expectedOutputCount} values" - modify fun s => { s with errors := s.errors.push diag } - -- Type check: for single-target assignments, check value type matches target type - -- Skip when value type is void (RHS is a statement like while/return that doesn't produce a value) - -- Skip when there's an arity mismatch (already reported above) - if targets'.length == 1 && targets'.length == expectedOutputCount && valueTy.val != HighType.TVoid then - if let some target := targets'.head? then - let targetTy := match target.val with - | .Local ref => do - let s ← get - match s.scope.get? ref.text with - | some (_, node) => pure node.getType - | none => pure { val := HighType.Unknown, source := ref.source : HighTypeMd } - | .Declare param => pure param.type - | .Field _ fieldName => do - let s ← get - match s.scope.get? fieldName.text with - | some (_, node) => pure node.getType - | none => pure { val := HighType.Unknown, source := fieldName.source : HighTypeMd } - let tTy ← targetTy - checkSubtype source tTy valueTy + -- Compute the target's declared type, regardless of whether it's a Local, + -- a Field, or a fresh Declare. + let targetType (t : VariableMd) : ResolveM HighTypeMd := do + let s ← get + match t.val with + | .Local ref => + match s.scope.get? ref.text with + | some (_, node) => pure node.getType + | none => pure { val := .Unknown, source := ref.source } + | .Declare param => pure param.type + | .Field _ fieldName => + match s.scope.get? fieldName.text with + | some (_, node) => pure node.getType + | none => pure { val := .Unknown, source := fieldName.source } + -- Skip all checks when the RHS is a statement (TVoid) — no value to assign. + if valueTy.val != HighType.TVoid then + let targetTys ← targets'.mapM targetType + -- Build the expected type from the targets' declared types: a single + -- type when there's one target, a tuple (MultiValuedExpr) otherwise. + -- This matches the shape of `valueTy`, which is itself MultiValuedExpr + -- exactly when the RHS produces multiple values. A single tuple-vs-tuple + -- check then covers both arity and per-position type mismatches in one + -- diagnostic. + let expectedTy : HighTypeMd := match targetTys with + | [single] => single + | _ => { val := .MultiValuedExpr targetTys, source := source } + checkSubtype source expectedTy valueTy pure (.Assign targets' value', valueTy) | .Var (.Field target fieldName) => let (target', _) ← synthStmtExpr target diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 0b39902de0..63fed89e9b 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -260,7 +260,7 @@ every premise and conclusion unless a rule explicitly extends it (written `Γ, x Block-Synth-Empty, Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, Return-Void-Error, Return-Multi-Error; While - *Verification statements* — Assert, Assume -- *Assignment* — Assign-Single, Assign-Multi +- *Assignment* — Assign - *Calls* — Static-Call, Static-Call-Multi, Instance-Call - *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat - *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate @@ -481,16 +481,23 @@ target is a numeric type. ### Assignment ``` - Γ(x) = T_x Γ ⊢ e ⇒ T_e T_e <: T_x -─────────────────────────────────────────────── (Assign-Single, impl) - Γ ⊢ Assign [x] e ⇒ TVoid -``` + Γ ⊢ targets_i ⇒ T_i Γ ⊢ e ⇒ T_e ExpectedTy <: T_e +───────────────────────────────────────────────────────────────── (Assign, impl) + Γ ⊢ Assign targets e ⇒ TVoid + where ExpectedTy = T_1 if |targets| = 1 + = MultiValuedExpr [T_1; …; T_n] otherwise ``` - Γ ⊢ targets_i = x_i Γ(x_i) = T_i Γ ⊢ e ⇒ MultiValuedExpr Us |Ts| = |Us| U_i <: T_i -───────────────────────────────────────────────────────────────────────────────────────────────────────── (Assign-Multi, impl) - Γ ⊢ Assign targets e ⇒ TVoid -``` + +The target's declared type `T_i` comes from the variable's scope entry (for +{name Strata.Laurel.Variable.Local}`Local` and {name Strata.Laurel.Variable.Field}`Field`) +or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. Both +single- and multi-target forms collapse into one tuple-vs-tuple check: when the RHS is a +{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr`, both arity and per-position +type mismatches surface in a single diagnostic of shape *"expected '(int, int, int)', got +'(int, string)'"*. When the RHS is {name Strata.Laurel.HighType.TVoid}`TVoid` (a +side-effecting statement: `while`, `return`, …), all checks are skipped — there's no value +to assign. ### Calls From 18eb6c97186e9e9ef64db14e64caddc8328a3976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 18:15:49 -0400 Subject: [PATCH 091/189] fix error messages to match current type mismatch reporting --- .../Fundamentals/T22_ArityMismatch.lean | 2 +- .../Laurel/ResolutionTypeCheckTests.lean | 36 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean index 94c0f22371..dea2d510fb 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean @@ -39,7 +39,7 @@ procedure mismatch() { var x: int; assign x := twoReturns() -//^^^^^^^^^^^^^^^^^^^^^^^^ error: Assignment target count mismatch +//^^^^^^^^^^^^^^^^^^^^^^^^ error: expected 'int', got '(int, int)' }; " diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 3a9fa8f174..85318ad7e9 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -39,7 +39,7 @@ private def processResolution (input : Lean.Parser.InputContext) : IO (Array Dia def ifCondNotBool := r" function foo(x: int): int { if x then 1 else 0 -// ^ error: expected bool, but got 'int' +// ^ error: expected 'bool', got 'int' }; " @@ -50,7 +50,7 @@ def assertCondNotBool := r" procedure baz() opaque { var x: int := 42; assert x -// ^ error: expected bool, but got 'int' +// ^ error: expected 'bool', got 'int' }; " @@ -61,7 +61,7 @@ def assumeCondNotBool := r" procedure qux() opaque { var x: int := 42; assume x -// ^ error: expected bool, but got 'int' +// ^ error: expected 'bool', got 'int' }; " @@ -72,7 +72,7 @@ def whileCondNotBool := r" procedure wh() opaque { var x: int := 1; while (x) { } -// ^ error: expected bool, but got 'int' +// ^ error: expected 'bool', got 'int' }; " @@ -84,7 +84,7 @@ procedure wh() opaque { def logicalAndNotBool := r" function foo(x: int, y: bool): bool { x && y -//^^^^^^ error: expected bool, but got 'int' +//^^^^^^ error: expected 'bool', got 'int' }; " @@ -95,8 +95,8 @@ function foo(x: int, y: bool): bool { def comparisonNotNumeric := r" function cmp(x: string, y: int): bool { +// ^^^^^^ error: '<' expected a numeric type, got 'string' x < y -//^^^^^ error: expected a numeric type, but got 'string' }; " @@ -108,7 +108,7 @@ function cmp(x: string, y: int): bool { def assignTypeMismatch := r" procedure foo() opaque { var x: int := true -//^^^^^^^^^^^^^^^^^^ error: expected 'int', but got 'bool' +//^^^^^^^^^^^^^^^^^^ error: expected 'int', got 'bool' }; " @@ -119,7 +119,7 @@ procedure foo() opaque { def returnTypeMismatch := r" function foo(): int { -// ^^^ error: expected 'int', but got 'bool' +// ^^^ error: expected 'int', got 'bool' true }; " @@ -133,7 +133,7 @@ def callArgTypeMismatch := r" function bar(x: int): int { x }; function foo(): int { bar(true) -//^^^^^^^^^ error: expected 'int', but got 'bool' +//^^^^^^^^^ error: expected 'int', got 'bool' }; " @@ -169,30 +169,30 @@ def assignTargetCountMismatch := r" procedure multi() returns (a: int, b: int) opaque; procedure test() opaque { var x: int := multi() -//^^^^^^^^^^^^^^^^^^^^^ error: Assignment target count mismatch:1 targets but right-hand side produces 2 values +//^^^^^^^^^^^^^^^^^^^^^ error: expected 'int', got '(int, int)' }; " #guard_msgs (error, drop all) in #eval testInputWithOffset "AssignTargetCountMismatch" assignTargetCountMismatch 156 processResolution -/-! ## UserDefined type pass-through (known limitation) +/-! ## UserDefined cross-type assignment (now rejected) -UserDefined types skip strict assignability checks because subtype/inheritance -relationships are not tracked during resolution. This test documents that -cross-type assignments are silently accepted today. When hierarchy tracking -lands, this test should be updated to expect a rejection. -/ +Cross-type assignments between unrelated user-defined types are rejected +because `isSubtype` is currently structural equality. Once `isSubtype` walks +`extending` chains, this test will need a related-types example to keep +exercising the success path. -/ -def userDefinedPassThrough := r" +def userDefinedCrossType := r" composite Dog { } composite Cat { } procedure test() opaque { var x: Dog := new Cat +//^^^^^^^^^^^^^^^^^^^^^ error: expected 'Dog', got 'Cat' }; " --- This should produce NO diagnostics (UserDefined types are not checked against each other) #guard_msgs (error, drop all) in -#eval testInputWithOffset "UserDefinedPassThrough" userDefinedPassThrough 170 processResolution +#eval testInputWithOffset "UserDefinedCrossType" userDefinedCrossType 170 processResolution end Laurel From 151efeb19dad0ae3d6edae58fe89b8c42295b8cf Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 19:29:32 +0000 Subject: [PATCH 092/189] Add multi-output procedure in expression position check and test - Add checkSingleValued helper that detects MultiValuedExpr types used in expression position (e.g., as operands to PrimitiveOp) - Emit error: "Multi-output procedure '' used in expression position" - Add ResolutionTypeTests.lean with test for assert multi(1) == 1 --- Strata/Languages/Laurel/Resolution.lean | 17 +++++++ .../Languages/Laurel/ResolutionTypeTests.lean | 50 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 StrataTest/Languages/Laurel/ResolutionTypeTests.lean diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index d87f97cd73..367259b9ac 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -399,6 +399,20 @@ private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) let diag := diagnosticFromSource source s!"Type mismatch: expected '{expectedStr}', but got '{actualStr}'" modify fun s => { s with errors := s.errors.push diag } +/-- Check that an expression is single-valued (not a multi-output procedure call). + Emits an error if the expression has MultiValuedExpr type. -/ +private def checkSingleValued (expr : StmtExprMd) (ty : HighTypeMd) : ResolveM Unit := do + match ty.val with + | .MultiValuedExpr _ => + let calleeName := match expr.val with + | .StaticCall callee _ => callee.text + | .InstanceCall _ callee _ => callee.text + | _ => "expression" + let diag := diagnosticFromSource expr.source + s!"Multi-output procedure '{calleeName}' used in expression position" + modify fun s => { s with errors := s.errors.push diag } + | _ => pure () + /-- Get the type of a resolved variable reference from scope. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get @@ -543,6 +557,9 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) let results ← args.mapM resolveStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) + -- Check that no argument is a multi-output procedure call + for (arg, argTy) in results do + checkSingleValued arg argTy let resultTy := match op with | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies | .Lt | .Leq | .Gt | .Geq => HighType.TBool diff --git a/StrataTest/Languages/Laurel/ResolutionTypeTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeTests.lean new file mode 100644 index 0000000000..b3d10b55f0 --- /dev/null +++ b/StrataTest/Languages/Laurel/ResolutionTypeTests.lean @@ -0,0 +1,50 @@ +/- + Copyright Strata Contributors + + SPDX-License-Identifier: Apache-2.0 OR MIT +-/ + +/- +Tests that the resolution pass detects type checking errors — e.g. using a +multi-output procedure in expression position. +-/ + +import StrataTest.Util.TestDiagnostics +import Strata.DDM.Elab +import Strata.DDM.BuiltinDialects.Init +import Strata.Languages.Laurel.Grammar.LaurelGrammar +import Strata.Languages.Laurel.Grammar.ConcreteToAbstractTreeTranslator +import Strata.Languages.Laurel.Resolution + +open StrataTest.Util +open Strata +open Strata.Elab (parseStrataProgramFromDialect) + +namespace Strata.Laurel + +/-- Run only parsing + resolution and return diagnostics (no SMT verification). -/ +private def processResolution (input : Lean.Parser.InputContext) : IO (Array Diagnostic) := do + let dialects := Strata.Elab.LoadedDialects.ofDialects! #[initDialect, Laurel] + let strataProgram ← parseStrataProgramFromDialect dialects Laurel.name input + let uri := Strata.Uri.file input.fileName + match Laurel.TransM.run uri (Laurel.parseProgram strataProgram) with + | .error e => throw (IO.userError s!"Translation errors: {e}") + | .ok program => + let result := resolve program + let files := Map.insert Map.empty uri input.fileMap + return result.errors.toList.map (fun dm => dm.toDiagnostic files) |>.toArray + +/-! ## Multi-output procedure used in expression position -/ + +def multiOutputInExpr := r" +procedure multi(x: int) returns (a: int, b: int) opaque; +procedure test() opaque { + assert multi(1) == 1 +// ^^^^^^^^ error: Multi-output procedure 'multi' used in expression position +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "MultiOutputInExpr" multiOutputInExpr 42 processResolution + +end Laurel From d7e29032cb6f49a8e251b537bc0e5afd9e8dd8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 18:17:26 -0400 Subject: [PATCH 093/189] fix error reporting location --- Strata/Languages/Laurel/Resolution.lean | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 622b35c302..677ba564d2 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -645,8 +645,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee - for (argTy, paramTy) in argTypes.zip paramTypes do - checkSubtype source paramTy argTy + for ((a, aTy), paramTy) in (args'.zip argTypes).zip paramTypes do + checkSubtype a.source paramTy aTy pure (.StaticCall callee' args', retTy) | .PrimitiveOp op args => let results ← args.mapM synthStmtExpr @@ -662,12 +662,12 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .StrConcat => HighType.TString match op with | .And | .Or | .AndThen | .OrElse | .Not | .Implies => - for aTy in argTypes do - checkSubtype source { val := .TBool, source := aTy.source } aTy + for (a, aTy) in args'.zip argTypes do + checkSubtype a.source { val := .TBool, source := a.source } aTy | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => - for aTy in argTypes do + for (a, aTy) in args'.zip argTypes do unless isNumeric aTy do - typeMismatch aTy.source (some expr) "expected a numeric type" aTy + typeMismatch a.source (some expr) "expected a numeric type" aTy | .Eq | .Neq => match argTypes with | [lhsTy, rhsTy] => @@ -677,8 +677,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := modify fun s => { s with errors := s.errors.push diag } | _ => pure () | .StrConcat => - for aTy in argTypes do - checkSubtype source { val := .TString, source := aTy.source } aTy + for (a, aTy) in args'.zip argTypes do + checkSubtype a.source { val := .TString, source := a.source } aTy pure (.PrimitiveOp op args', { val := resultTy, source := source }) | .New ref => let ref' ← resolveRef ref source @@ -709,9 +709,9 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let (lhs', lhsTy) ← synthStmtExpr lhs let (rhs', rhsTy) ← synthStmtExpr rhs unless isReference lhsTy do - typeMismatch lhsTy.source (some expr) "expected a reference type" lhsTy + typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy unless isReference rhsTy do - typeMismatch rhsTy.source (some expr) "expected a reference type" rhsTy + typeMismatch rhs'.source (some expr) "expected a reference type" rhsTy pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => let (target', _) ← synthStmtExpr target @@ -731,8 +731,8 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := let (retTy, paramTypes) ← getCallInfo callee -- Skip first param (self) when matching args. let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] - for (argTy, paramTy) in argTypes.zip callParamTypes do - checkSubtype source paramTy argTy + for ((a, aTy), paramTy) in (args'.zip argTypes).zip callParamTypes do + checkSubtype a.source paramTy aTy pure (.InstanceCall target' callee' args', retTy) | .Quantifier mode param trigger body => withScope do @@ -751,7 +751,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .Fresh val => let (val', valTy) ← synthStmtExpr val unless isReference valTy do - typeMismatch valTy.source (some expr) "expected a reference type" valTy + typeMismatch val'.source (some expr) "expected a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } From 138993cb20fc51774bac408a2866e9437ebc7f5d Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Tue, 5 May 2026 19:48:41 +0000 Subject: [PATCH 094/189] Remove checkSingleValued; let type checks report multi-output errors naturally Instead of a dedicated 'Multi-output procedure used in expression position' error, multi-output calls in expression position now produce standard type mismatch errors like 'expected int, but got (int, int)'. - Remove checkSingleValued function and its call in PrimitiveOp - Remove MultiValuedExpr skip in checkAssignable - Add Eq/Neq operand compatibility check - Add formatType helper for nice MultiValuedExpr formatting - Skip assignment type check when arity already mismatches --- Strata/Languages/Laurel/Resolution.lean | 42 +++++++++---------- .../Languages/Laurel/ResolutionTypeTests.lean | 2 +- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 367259b9ac..cbedf8f53c 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -360,9 +360,17 @@ def resolveHighType (ty : HighTypeMd) : ResolveM HighTypeMd := do | other => pure other return { val := val', source := ty.source } +/-- Format a type for use in diagnostics. -/ +private def formatType (ty : HighTypeMd) : String := + match ty.val with + | .MultiValuedExpr tys => + let parts := tys.map (fun t => toString (formatHighTypeVal t.val)) + "(" ++ ", ".intercalate parts ++ ")" + | other => toString (formatHighTypeVal other) + /-- Emit a type mismatch diagnostic. -/ private def typeMismatch (source : Option FileRange) (expected : String) (actual : HighTypeMd) : ResolveM Unit := do - let actualStr := toString (formatHighTypeVal actual.val) + let actualStr := formatType actual let diag := diagnosticFromSource source s!"Type mismatch: expected {expected}, but got '{actualStr}'" modify fun s => { s with errors := s.errors.push diag } @@ -387,32 +395,17 @@ private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) match expected.val, actual.val with | .Unknown, _ => pure () | _, .Unknown => pure () - | _, .MultiValuedExpr _ => pure () -- arity mismatch already reported separately | .UserDefined _, _ => pure () -- subtype relationships not tracked here | _, .UserDefined _ => pure () -- subtype relationships not tracked here | .TCore _, _ => pure () -- pass-through Core types not checked during resolution | _, .TCore _ => pure () -- pass-through Core types not checked during resolution | _, _ => if !highEq expected actual then - let expectedStr := toString (formatHighTypeVal expected.val) - let actualStr := toString (formatHighTypeVal actual.val) + let expectedStr := formatType expected + let actualStr := formatType actual let diag := diagnosticFromSource source s!"Type mismatch: expected '{expectedStr}', but got '{actualStr}'" modify fun s => { s with errors := s.errors.push diag } -/-- Check that an expression is single-valued (not a multi-output procedure call). - Emits an error if the expression has MultiValuedExpr type. -/ -private def checkSingleValued (expr : StmtExprMd) (ty : HighTypeMd) : ResolveM Unit := do - match ty.val with - | .MultiValuedExpr _ => - let calleeName := match expr.val with - | .StaticCall callee _ => callee.text - | .InstanceCall _ callee _ => callee.text - | _ => "expression" - let diag := diagnosticFromSource expr.source - s!"Multi-output procedure '{calleeName}' used in expression position" - modify fun s => { s with errors := s.errors.push diag } - | _ => pure () - /-- Get the type of a resolved variable reference from scope. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get @@ -515,7 +508,8 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) modify fun s => { s with errors := s.errors.push diag } -- Type check: for single-target assignments, check value type matches target type -- Skip when value type is void (RHS is a statement like while/return that doesn't produce a value) - if targets'.length == 1 && valueTy.val != HighType.TVoid then + -- Skip when there's an arity mismatch (already reported above) + if targets'.length == 1 && targets'.length == expectedOutputCount && valueTy.val != HighType.TVoid then if let some target := targets'.head? then let targetTy := match target.val with | .Local ref => do @@ -557,9 +551,6 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) let results ← args.mapM resolveStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) - -- Check that no argument is a multi-output procedure call - for (arg, argTy) in results do - checkSingleValued arg argTy let resultTy := match op with | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies | .Lt | .Leq | .Gt | .Geq => HighType.TBool @@ -574,7 +565,12 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) for aTy in argTypes do checkBool source aTy | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => for aTy in argTypes do checkNumeric source aTy - | .Eq | .Neq | .StrConcat => pure () + | .Eq | .Neq => + -- Check that operands are compatible with each other + match argTypes with + | [lhsTy, rhsTy] => checkAssignable source rhsTy lhsTy + | _ => pure () + | .StrConcat => pure () pure (.PrimitiveOp op args', { val := resultTy, source := source }) | .New ref => let ref' ← resolveRef ref source diff --git a/StrataTest/Languages/Laurel/ResolutionTypeTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeTests.lean index b3d10b55f0..89ac1a162c 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeTests.lean @@ -40,7 +40,7 @@ def multiOutputInExpr := r" procedure multi(x: int) returns (a: int, b: int) opaque; procedure test() opaque { assert multi(1) == 1 -// ^^^^^^^^ error: Multi-output procedure 'multi' used in expression position +// ^^^^^^^^^^^^^ error: expected 'int', but got '(int, int)' }; " From 4a4bbc6bbf4f3741942272ec588b64d3fd1b2685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 15 May 2026 18:22:07 -0400 Subject: [PATCH 095/189] fix location error reporting --- StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 85318ad7e9..112fa7eba9 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -84,7 +84,7 @@ procedure wh() opaque { def logicalAndNotBool := r" function foo(x: int, y: bool): bool { x && y -//^^^^^^ error: expected 'bool', got 'int' +//^ error: expected 'bool', got 'int' }; " @@ -95,8 +95,8 @@ function foo(x: int, y: bool): bool { def comparisonNotNumeric := r" function cmp(x: string, y: int): bool { -// ^^^^^^ error: '<' expected a numeric type, got 'string' x < y +//^ error: '<' expected a numeric type, got 'string' }; " @@ -133,7 +133,7 @@ def callArgTypeMismatch := r" function bar(x: int): int { x }; function foo(): int { bar(true) -//^^^^^^^^^ error: expected 'int', got 'bool' +// ^^^^ error: expected 'int', got 'bool' }; " From d9caa3732e44b897f70a168d904115081918ce72 Mon Sep 17 00:00:00 2001 From: keyboardDrummer-bot Date: Wed, 6 May 2026 17:07:52 +0000 Subject: [PATCH 096/189] Address review feedback: symmetric Eq/Neq errors, extract helper, consolidate tests - Add checkComparable helper for symmetric Eq/Neq error messages ("Operands of '==' have incompatible types 'X' and 'Y'") - Extract resolveStmtExprExpr helper to reduce repeated pattern - Add constant initializer type check in resolveConstant - Merge ResolutionTypeTests.lean into ResolutionTypeCheckTests.lean - Add tests: equality type mismatch, assignment target count mismatch, UserDefined pass-through (documents known limitation) - Update checkAssignable doc comment to mention TCore types --- Strata/Languages/Laurel/Resolution.lean | 54 ++++++--- .../Laurel/ResolutionTypeCheckTests.lean | 107 +++++++++++++----- .../Languages/Laurel/ResolutionTypeTests.lean | 50 -------- 3 files changed, 118 insertions(+), 93 deletions(-) delete mode 100644 StrataTest/Languages/Laurel/ResolutionTypeTests.lean diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index cbedf8f53c..4bfa2d39dc 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -390,7 +390,8 @@ private def checkNumeric (source : Option FileRange) (ty : HighTypeMd) : Resolve /-- Check that two types are compatible, emitting a diagnostic if not. UserDefined types are always considered compatible with each other since - subtype relationships (inheritance) are not tracked during resolution. -/ + subtype relationships (inheritance) are not tracked during resolution. + TCore types are not checked since they are pass-through types from the Core language. -/ private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do match expected.val, actual.val with | .Unknown, _ => pure () @@ -406,6 +407,22 @@ private def checkAssignable (source : Option FileRange) (expected : HighTypeMd) let diag := diagnosticFromSource source s!"Type mismatch: expected '{expectedStr}', but got '{actualStr}'" modify fun s => { s with errors := s.errors.push diag } +/-- Check that two types are comparable (for == and !=), emitting a symmetric diagnostic if not. -/ +private def checkComparable (source : Option FileRange) (lhsTy : HighTypeMd) (rhsTy : HighTypeMd) : ResolveM Unit := do + match lhsTy.val, rhsTy.val with + | .Unknown, _ => pure () + | _, .Unknown => pure () + | .UserDefined _, _ => pure () + | _, .UserDefined _ => pure () + | .TCore _, _ => pure () + | _, .TCore _ => pure () + | _, _ => + if !highEq lhsTy rhsTy then + let lhsStr := formatType lhsTy + let rhsStr := formatType rhsTy + let diag := diagnosticFromSource source s!"Operands of '==' have incompatible types '{lhsStr}' and '{rhsStr}'" + modify fun s => { s with errors := s.errors.push diag } + /-- Get the type of a resolved variable reference from scope. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get @@ -566,9 +583,9 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => for aTy in argTypes do checkNumeric source aTy | .Eq | .Neq => - -- Check that operands are compatible with each other + -- Check that operands are compatible with each other (symmetric check) match argTypes with - | [lhsTy, rhsTy] => checkAssignable source rhsTy lhsTy + | [lhsTy, rhsTy] => checkComparable source lhsTy rhsTy | _ => pure () | .StrConcat => pure () pure (.PrimitiveOp op args', { val := resultTy, source := source }) @@ -653,6 +670,11 @@ def resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) termination_by exprMd decreasing_by all_goals term_by_mem +/-- Resolve a statement expression, discarding the synthesized type. + Use when only the resolved expression is needed (invariants, decreases, etc.). -/ +private def resolveStmtExprExpr (e : StmtExprMd) : ResolveM StmtExprMd := do + let (e', _) ← resolveStmtExpr e; pure e' + /-- Resolve a parameter: assign a fresh ID and add to scope. -/ def resolveParameter (param : Parameter) : ResolveM Parameter := do let ty' ← resolveHighType param.type @@ -666,12 +688,12 @@ def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do let (b', ty) ← resolveStmtExpr b return (.Transparent b', ty) | .Opaque posts impl mods => - let posts' ← posts.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') - let impl' ← impl.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' - let mods' ← mods.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let posts' ← posts.mapM (·.mapM resolveStmtExprExpr) + let impl' ← impl.mapM resolveStmtExprExpr + let mods' ← mods.mapM resolveStmtExprExpr return (.Opaque posts' impl' mods', { val := .TVoid, source := none }) | .Abstract posts => - let posts' ← posts.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') + let posts' ← posts.mapM (·.mapM resolveStmtExprExpr) return (.Abstract posts', { val := .TVoid, source := none }) | .External => return (.External, { val := .TVoid, source := none }) @@ -681,8 +703,8 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do withScope do let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') - let dec' ← proc.decreases.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExprExpr) + let dec' ← proc.decreases.mapM resolveStmtExprExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -696,7 +718,7 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let invokeOn' ← proc.invokeOn.mapM resolveStmtExprExpr return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, preconditions := pres', decreases := dec', @@ -722,8 +744,8 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv modify fun s => { s with instanceTypeName := some typeName.text } let inputs' ← proc.inputs.mapM resolveParameter let outputs' ← proc.outputs.mapM resolveParameter - let pres' ← proc.preconditions.mapM (·.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e') - let dec' ← proc.decreases.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExprExpr) + let dec' ← proc.decreases.mapM resolveStmtExprExpr let (body', bodyTy) ← resolveBody proc.body if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -736,7 +758,7 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv if bodyTy.val != HighType.TVoid then checkAssignable proc.name.source singleOutput.type bodyTy | _ => pure () - let invokeOn' ← proc.invokeOn.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let invokeOn' ← proc.invokeOn.mapM resolveStmtExprExpr modify fun s => { s with instanceTypeName := savedInstType } return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, @@ -805,7 +827,11 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do /-- Resolve a constant definition. -/ def resolveConstant (c : Constant) : ResolveM Constant := do let ty' ← resolveHighType c.type - let init' ← c.initializer.mapM fun e => do let (e', _) ← resolveStmtExpr e; pure e' + let init' ← c.initializer.mapM fun e => do + let (e', eTy) ← resolveStmtExpr e + if eTy.val != HighType.TVoid then + checkAssignable e'.source ty' eTy + pure e' let name' ← resolveRef c.name return { name := name', type := ty', initializer := init' } diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 01ccd40708..3a9fa8f174 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -34,7 +34,7 @@ private def processResolution (input : Lean.Parser.InputContext) : IO (Array Dia let files := Map.insert Map.empty uri input.fileMap return result.errors.toList.map (fun dm => dm.toDiagnostic files) |>.toArray -/-! ## Non-boolean condition in if-then-else -/ +/-! ## Non-boolean conditions -/ def ifCondNotBool := r" function foo(x: int): int { @@ -44,9 +44,7 @@ function foo(x: int): int { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "IfCondNotBool" ifCondNotBool 39 processResolution - -/-! ## Non-boolean condition in assert -/ +#eval testInputWithOffset "IfCondNotBool" ifCondNotBool 44 processResolution def assertCondNotBool := r" procedure baz() opaque { @@ -57,9 +55,7 @@ procedure baz() opaque { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "AssertCondNotBool" assertCondNotBool 49 processResolution - -/-! ## Non-boolean condition in assume -/ +#eval testInputWithOffset "AssertCondNotBool" assertCondNotBool 54 processResolution def assumeCondNotBool := r" procedure qux() opaque { @@ -70,9 +66,20 @@ procedure qux() opaque { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "AssumeCondNotBool" assumeCondNotBool 59 processResolution +#eval testInputWithOffset "AssumeCondNotBool" assumeCondNotBool 64 processResolution + +def whileCondNotBool := r" +procedure wh() opaque { + var x: int := 1; + while (x) { } +// ^ error: expected bool, but got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "WhileCondNotBool" whileCondNotBool 74 processResolution -/-! ## Non-boolean operand in logical and -/ +/-! ## Logical operator type checks -/ def logicalAndNotBool := r" function foo(x: int, y: bool): bool { @@ -82,9 +89,21 @@ function foo(x: int, y: bool): bool { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "LogicalAndNotBool" logicalAndNotBool 69 processResolution +#eval testInputWithOffset "LogicalAndNotBool" logicalAndNotBool 84 processResolution + +/-! ## Numeric operator type checks -/ + +def comparisonNotNumeric := r" +function cmp(x: string, y: int): bool { + x < y +//^^^^^ error: expected a numeric type, but got 'string' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "ComparisonNotNumeric" comparisonNotNumeric 94 processResolution -/-! ## Assignment type mismatch -/ +/-! ## Assignment type checks -/ def assignTypeMismatch := r" procedure foo() opaque { @@ -94,9 +113,9 @@ procedure foo() opaque { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "AssignTypeMismatch" assignTypeMismatch 79 processResolution +#eval testInputWithOffset "AssignTypeMismatch" assignTypeMismatch 104 processResolution -/-! ## Function return type mismatch -/ +/-! ## Function return type checks -/ def returnTypeMismatch := r" function foo(): int { @@ -106,9 +125,9 @@ function foo(): int { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "ReturnTypeMismatch" returnTypeMismatch 89 processResolution +#eval testInputWithOffset "ReturnTypeMismatch" returnTypeMismatch 114 processResolution -/-! ## Static call argument type mismatch -/ +/-! ## Call argument type checks -/ def callArgTypeMismatch := r" function bar(x: int): int { x }; @@ -119,31 +138,61 @@ function foo(): int { " #guard_msgs (error, drop all) in -#eval testInputWithOffset "CallArgTypeMismatch" callArgTypeMismatch 99 processResolution +#eval testInputWithOffset "CallArgTypeMismatch" callArgTypeMismatch 124 processResolution -/-! ## Non-boolean condition in while loop -/ +/-! ## Equality operator type checks -/ -def whileCondNotBool := r" -procedure wh() opaque { - var x: int := 1; - while (x) { } -// ^ error: expected bool, but got 'int' +def equalityTypeMismatch := r" +function cmp(x: int, y: string): bool { + x == y +//^^^^^^ error: Operands of '==' have incompatible types 'int' and 'string' }; " #guard_msgs (error, drop all) in -#eval testInputWithOffset "WhileCondNotBool" whileCondNotBool 109 processResolution +#eval testInputWithOffset "EqualityTypeMismatch" equalityTypeMismatch 134 processResolution -/-! ## Non-numeric operand in comparison -/ +/-! ## Multi-output procedures -/ -def comparisonNotNumeric := r" -function cmp(x: string, y: int): bool { - x < y -//^^^^^ error: expected a numeric type, but got 'string' +def multiOutputInExpr := r" +procedure multi(x: int) returns (a: int, b: int) opaque; +procedure test() opaque { + assert multi(1) == 1 +// ^^^^^^^^^^^^^ error: Operands of '==' have incompatible types '(int, int)' and 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "MultiOutputInExpr" multiOutputInExpr 146 processResolution + +def assignTargetCountMismatch := r" +procedure multi() returns (a: int, b: int) opaque; +procedure test() opaque { + var x: int := multi() +//^^^^^^^^^^^^^^^^^^^^^ error: Assignment target count mismatch:1 targets but right-hand side produces 2 values +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "AssignTargetCountMismatch" assignTargetCountMismatch 156 processResolution + +/-! ## UserDefined type pass-through (known limitation) + +UserDefined types skip strict assignability checks because subtype/inheritance +relationships are not tracked during resolution. This test documents that +cross-type assignments are silently accepted today. When hierarchy tracking +lands, this test should be updated to expect a rejection. -/ + +def userDefinedPassThrough := r" +composite Dog { } +composite Cat { } +procedure test() opaque { + var x: Dog := new Cat }; " +-- This should produce NO diagnostics (UserDefined types are not checked against each other) #guard_msgs (error, drop all) in -#eval testInputWithOffset "ComparisonNotNumeric" comparisonNotNumeric 121 processResolution +#eval testInputWithOffset "UserDefinedPassThrough" userDefinedPassThrough 170 processResolution end Laurel diff --git a/StrataTest/Languages/Laurel/ResolutionTypeTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeTests.lean deleted file mode 100644 index 89ac1a162c..0000000000 --- a/StrataTest/Languages/Laurel/ResolutionTypeTests.lean +++ /dev/null @@ -1,50 +0,0 @@ -/- - Copyright Strata Contributors - - SPDX-License-Identifier: Apache-2.0 OR MIT --/ - -/- -Tests that the resolution pass detects type checking errors — e.g. using a -multi-output procedure in expression position. --/ - -import StrataTest.Util.TestDiagnostics -import Strata.DDM.Elab -import Strata.DDM.BuiltinDialects.Init -import Strata.Languages.Laurel.Grammar.LaurelGrammar -import Strata.Languages.Laurel.Grammar.ConcreteToAbstractTreeTranslator -import Strata.Languages.Laurel.Resolution - -open StrataTest.Util -open Strata -open Strata.Elab (parseStrataProgramFromDialect) - -namespace Strata.Laurel - -/-- Run only parsing + resolution and return diagnostics (no SMT verification). -/ -private def processResolution (input : Lean.Parser.InputContext) : IO (Array Diagnostic) := do - let dialects := Strata.Elab.LoadedDialects.ofDialects! #[initDialect, Laurel] - let strataProgram ← parseStrataProgramFromDialect dialects Laurel.name input - let uri := Strata.Uri.file input.fileName - match Laurel.TransM.run uri (Laurel.parseProgram strataProgram) with - | .error e => throw (IO.userError s!"Translation errors: {e}") - | .ok program => - let result := resolve program - let files := Map.insert Map.empty uri input.fileMap - return result.errors.toList.map (fun dm => dm.toDiagnostic files) |>.toArray - -/-! ## Multi-output procedure used in expression position -/ - -def multiOutputInExpr := r" -procedure multi(x: int) returns (a: int, b: int) opaque; -procedure test() opaque { - assert multi(1) == 1 -// ^^^^^^^^^^^^^ error: expected 'int', but got '(int, int)' -}; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "MultiOutputInExpr" multiOutputInExpr 42 processResolution - -end Laurel From f2fea0a8b1170fefc0609c776a12f9ae16a1218e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 18 May 2026 16:47:06 -0400 Subject: [PATCH 097/189] thread typing context through type resolution/inheritance --- Strata/Languages/Laurel/InferHoleTypes.lean | 10 ++- Strata/Languages/Laurel/Laurel.lean | 85 ++++++++++++++++++--- Strata/Languages/Laurel/Resolution.lean | 35 ++++++--- 3 files changed, 106 insertions(+), 24 deletions(-) diff --git a/Strata/Languages/Laurel/InferHoleTypes.lean b/Strata/Languages/Laurel/InferHoleTypes.lean index 026a82e5b9..248d90716d 100644 --- a/Strata/Languages/Laurel/InferHoleTypes.lean +++ b/Strata/Languages/Laurel/InferHoleTypes.lean @@ -51,6 +51,8 @@ inductive InferHoleTypesStats where structure InferHoleState where model : SemanticModel + /-- Type-relation tables used by the consistency check on pre-annotated holes. -/ + typeContext : TypeContext currentOutputType : HighTypeMd statistics : Statistics := {} diagnostics : List DiagnosticModel := [] @@ -100,7 +102,8 @@ private def inferExpr (expr : StmtExprMd) (expectedType : HighTypeMd) : InferHol -- types disagree under consistency (gradual ~). match existingTy with | some prior => - unless isConsistent prior expectedType do + let ctx := (← get).typeContext + unless isConsistent ctx prior expectedType do modify fun s => { s with diagnostics := s.diagnostics ++ [diagnosticFromSource source s!"hole annotated with '{formatHighTypeVal prior.val}' but context expects '{formatHighTypeVal expectedType.val}'"] @@ -183,7 +186,10 @@ private def inferProcedure (proc : Procedure) : InferHoleM Procedure := do Annotate every `.Hole` in the program with a type inferred from context. -/ def inferHoleTypes (model : SemanticModel) (program : Program) : Program × List DiagnosticModel × Statistics := - let initState : InferHoleState := { model := model, currentOutputType := { val := .Unknown, source := none }} + let initState : InferHoleState := { + model := model, + typeContext := TypeContext.ofTypes program.types, + currentOutputType := { val := .Unknown, source := none } } let (procs, finalState) := (program.staticProcedures.mapM inferProcedure).run initState ({ program with staticProcedures := procs }, finalState.diagnostics, finalState.statistics) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index a8c665d64c..5b6a7ee252 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -489,24 +489,76 @@ instance : BEq HighTypeMd where deriving instance BEq for HighType -/-- Subtyping. Stub: structural equality via `highEq`. - TODO: walk `extending` chains for composites, unfold aliases, unwrap - constrained types to their base. -/ -def isSubtype (sub sup : HighTypeMd) : Bool := highEq sub sup +/-- Lookup tables threaded through subtyping/consistency checks. Built from + the program's `TypeDefinition`s by the resolution pass: + - `unfoldMap` maps an alias or constrained type's name to the type it + unwraps to (alias target / constrained base). Followed transitively to + reach a non-alias, non-constrained type. + - `extendingMap` maps a composite type's name to the *direct* parents in + its `extending` list. Walked transitively for the subtype check. -/ +structure TypeContext where + unfoldMap : Std.HashMap String HighTypeMd := {} + extendingMap : Std.HashMap String (List String) := {} + deriving Inhabited + +/-- Unfold aliases and constrained types to their underlying type. + Composites and primitives are returned unchanged. A `visited` set guards + against cycles in the alias/constrained graph (already cycle-checked + elsewhere, but keeps `unfold` safe to call independently). -/ +partial def TypeContext.unfold (ctx : TypeContext) (ty : HighTypeMd) + (visited : Std.HashSet String := {}) : HighTypeMd := + match ty.val with + | .UserDefined name => + if visited.contains name.text then ty + else match ctx.unfoldMap.get? name.text with + | some target => ctx.unfold target (visited.insert name.text) + | none => ty + | _ => ty + +/-- All ancestors of a composite type (including itself), reachable via + repeated `extending` lookups. The `fuel` cap is the number of distinct + type names ever registered, bounding the BFS even with malformed input. -/ +partial def TypeContext.ancestors (ctx : TypeContext) (name : String) : Std.HashSet String := + let rec go (acc : Std.HashSet String) (frontier : List String) : Std.HashSet String := + match frontier with + | [] => acc + | n :: rest => + if acc.contains n then go acc rest + else + let acc' := acc.insert n + let parents := (ctx.extendingMap.get? n).getD [] + go acc' (parents ++ rest) + go {} [name] + +/-- Subtyping. Walks `extending` chains for composites, unfolds aliases, and + unwraps constrained types to their base before falling back to structural + equality via `highEq`. -/ +def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := + let sub' := ctx.unfold sub + let sup' := ctx.unfold sup + match sub'.val, sup'.val with + | .UserDefined subName, .UserDefined supName => + -- After unfolding, both sides are composites (or unresolved). A composite + -- is a subtype of any type in its extending chain. + (ctx.ancestors subName.text).contains supName.text || highEq sub' sup' + | _, _ => highEq sub' sup' /-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the dynamic type and is consistent with everything; otherwise structural - equality. `TCore` is a temporary migration escape hatch. -/ -def isConsistent (a b : HighTypeMd) : Bool := - match a.val, b.val with + equality after unfolding aliases / constrained types. `TCore` is a + temporary migration escape hatch. -/ +def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := + let a' := ctx.unfold a + let b' := ctx.unfold b + match a'.val, b'.val with | .Unknown, _ | _, .Unknown => true | .TCore _, _ | _, .TCore _ => true - | _, _ => highEq a b + | _, _ => highEq a' b' /-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For our flat lattice this collapses to `sub ~ sup ∨ sub <: sup`. -/ -def isConsistentSubtype (sub sup : HighTypeMd) : Bool := - isConsistent sub sup || isSubtype sub sup +def isConsistentSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := + isConsistent ctx sub sup || isSubtype ctx sub sup def HighType.isBool : HighType → Bool | TBool => true @@ -645,6 +697,19 @@ def TypeDefinition.name : TypeDefinition → Identifier | .Datatype ty => ty.name | .Alias ty => ty.name +/-- Build a `TypeContext` from a list of `TypeDefinition`s. + Aliases populate `unfoldMap` with their target; constrained types populate + it with their base; composites populate `extendingMap` with their direct + parents. Datatypes contribute nothing — they're nominal and irreducible. -/ +def TypeContext.ofTypes (types : List TypeDefinition) : TypeContext := + types.foldl (init := {}) fun ctx td => + match td with + | .Alias ta => { ctx with unfoldMap := ctx.unfoldMap.insert ta.name.text ta.target } + | .Constrained ct => { ctx with unfoldMap := ctx.unfoldMap.insert ct.name.text ct.base } + | .Composite c => + { ctx with extendingMap := ctx.extendingMap.insert c.name.text (c.extending.map (·.text)) } + | .Datatype _ => ctx + structure Constant where name : Identifier type : HighTypeMd diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index cb9ab36b00..0efbe7060b 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -291,6 +291,10 @@ structure ResolveState where declaration order). `none` means no enclosing procedure. Used by `Return` to type-check the optional return value and to flag arity/shape mismatches. -/ expectedReturnTypes : Option (List HighTypeMd) := none + /-- Type-relation tables (alias/constrained unfolding + composite extending + chains) used by the subtyping/consistency checks. Built once from + `program.types` at the start of `resolve`. -/ + typeContext : TypeContext := {} @[expose] abbrev ResolveM := StateM ResolveState @@ -464,13 +468,16 @@ private def typeMismatch (source : Option FileRange) (construct : Option StmtExp actual type is already in hand (assignment, call args, body vs declared output) — equivalent to `checkStmtExpr e expected` but without re-synthesizing. -/ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do - unless isConsistentSubtype actual expected do + let ctx := (← get).typeContext + unless isConsistentSubtype ctx actual expected do typeMismatch source none s!"expected '{formatType expected}'" actual /-- Test whether a type is in the set of numeric primitives. `Unknown` and - `TCore` are accepted as gradual escape hatches. Used by Op-Cmp / Op-Arith. -/ -private def isNumeric (ty : HighTypeMd) : Bool := - match ty.val with + `TCore` are accepted as gradual escape hatches. Aliases and constrained + types are unfolded first so e.g. `nat` (constrained over `int`) counts as + numeric. Used by Op-Cmp / Op-Arith. -/ +private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := + match (ctx.unfold ty).val with | .TInt | .TReal | .TFloat64 | .Unknown => true | .TCore _ => true | _ => false @@ -478,8 +485,8 @@ private def isNumeric (ty : HighTypeMd) : Bool := /-- Test whether a type is a user-defined reference type. `Unknown` and `TCore` are accepted as gradual escape hatches. Used by Fresh and ReferenceEquals, which only make sense on composite/datatype references. -/ -private def isReference (ty : HighTypeMd) : Bool := - match ty.val with +private def isReference (ctx : TypeContext) (ty : HighTypeMd) : Bool := + match (ctx.unfold ty).val with | .UserDefined _ | .Unknown => true | .TCore _ => true | _ => false @@ -672,13 +679,15 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := for (a, aTy) in args'.zip argTypes do checkSubtype a.source { val := .TBool, source := a.source } aTy | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => + let ctx := (← get).typeContext for (a, aTy) in args'.zip argTypes do - unless isNumeric aTy do + unless isNumeric ctx aTy do typeMismatch a.source (some expr) "expected a numeric type" aTy | .Eq | .Neq => match argTypes with | [lhsTy, rhsTy] => - unless isConsistent lhsTy rhsTy do + let ctx := (← get).typeContext + unless isConsistent ctx lhsTy rhsTy do let diag := diagnosticFromSource source s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" modify fun s => { s with errors := s.errors.push diag } @@ -715,9 +724,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .ReferenceEquals lhs rhs => let (lhs', lhsTy) ← synthStmtExpr lhs let (rhs', rhsTy) ← synthStmtExpr rhs - unless isReference lhsTy do + let ctx := (← get).typeContext + unless isReference ctx lhsTy do typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy - unless isReference rhsTy do + unless isReference ctx rhsTy do typeMismatch rhs'.source (some expr) "expected a reference type" rhsTy pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) | .AsType target ty => @@ -757,7 +767,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := pure (.Old val', valTy) | .Fresh val => let (val', valTy) ← synthStmtExpr val - unless isReference valTy do + unless isReference (← get).typeContext valTy do typeMismatch val'.source (some expr) "expected a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) | .Assert ⟨condExpr, summary⟩ => @@ -1246,7 +1256,8 @@ def resolve (program : Program) (existingModel: Option SemanticModel := none) : return { staticProcedures := staticProcs', staticFields := staticFields', types := types', constants := constants' } let nextId := existingModel.elim 1 (fun m => m.nextId) - let (program', finalState) := phase1.run { nextId := nextId } + let typeContext := TypeContext.ofTypes program.types + let (program', finalState) := phase1.run { nextId := nextId, typeContext } -- Phase 2: build refToDef from the resolved program (all definitions now have UUIDs) let refToDef := buildRefToDef program' { program := program', From f4fd6ffa90c61d4f4e5caba059492a437236605d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 18 May 2026 11:30:16 -0400 Subject: [PATCH 098/189] fix field lookup --- Strata/Languages/Laurel/Resolution.lean | 35 +++++++++++++++---------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 677ba564d2..cb9ab36b00 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -272,6 +272,12 @@ structure ResolveState where nextId : Nat := 1 /-- Current lexical scope (name → definition ID). -/ scope : Scope := {} + /-- Map from definition uniqueId to its ResolvedNode. Populated alongside + `scope` whenever a definition is registered. Unlike `scope`, this map is + *not* saved/restored by `withScope` — uniqueIds are global. Used by + `getVarType` to look up types for references whose `text` doesn't match + a scope key (notably fields, which are scoped under qualified keys). -/ + idToNode : Std.HashMap Nat ResolvedNode := {} /-- Names defined at the current scope level (for duplicate detection). -/ currentScopeNames : Std.HashSet String := {} /-- Per-composite-type field scopes (type name → field name → scope entry). -/ @@ -315,8 +321,10 @@ def defineNameCheckDup (iden : Identifier) (node : ResolvedNode) (overrideResolu let id ← freshId pure ({ iden with uniqueId := some (id) }, id) - modify fun s => { s with scope := s.scope.insert resolutionName (uniqueId, node), - currentScopeNames := s.currentScopeNames.insert resolutionName } + modify fun s => { s with + scope := s.scope.insert resolutionName (uniqueId, node), + idToNode := s.idToNode.insert uniqueId node, + currentScopeNames := s.currentScopeNames.insert resolutionName } return name' /-- Resolve a reference: look up the name in scope and assign the definition's ID. @@ -476,12 +484,18 @@ private def isReference (ty : HighTypeMd) : Bool := | .TCore _ => true | _ => false -/-- Get the type of a resolved variable reference from scope. -/ +/-- Get the type of a resolved reference. Tries the lexical scope by name + first; if that misses (notably for fields, which are scoped under + qualified keys like "Container.intValue"), falls back to a uniqueId + lookup populated as definitions are registered. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get match s.scope.get? ref.text with | some (_, node) => pure node.getType - | none => pure { val := .Unknown, source := ref.source } + | none => + match ref.uniqueId.bind s.idToNode.get? with + | some node => pure node.getType + | none => pure { val := .Unknown, source := ref.source } /-- Get the call return type and parameter types for a callee from scope. -/ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List HighTypeMd) := do @@ -602,17 +616,10 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := -- Compute the target's declared type, regardless of whether it's a Local, -- a Field, or a fresh Declare. let targetType (t : VariableMd) : ResolveM HighTypeMd := do - let s ← get match t.val with - | .Local ref => - match s.scope.get? ref.text with - | some (_, node) => pure node.getType - | none => pure { val := .Unknown, source := ref.source } + | .Local ref => getVarType ref | .Declare param => pure param.type - | .Field _ fieldName => - match s.scope.get? fieldName.text with - | some (_, node) => pure node.getType - | none => pure { val := .Unknown, source := fieldName.source } + | .Field _ fieldName => getVarType fieldName -- Skip all checks when the RHS is a statement (TVoid) — no value to assign. if valueTy.val != HighType.TVoid then let targetTys ← targets'.mapM targetType @@ -630,7 +637,7 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | .Var (.Field target fieldName) => let (target', _) ← synthStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source - let ty ← getVarType fieldName + let ty ← getVarType fieldName' pure (.Var (.Field target' fieldName'), ty) | .PureFieldUpdate target fieldName newVal => let (target', targetTy) ← synthStmtExpr target From fb6fdd605f5fe4997175fd0b3500314c877388e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 18 May 2026 16:48:29 -0400 Subject: [PATCH 099/189] drop info report of an expected downcast failure ; to fix this, we need to improve the testing facilities for Laurel --- .../Languages/Laurel/Examples/Objects/T5_inheritance.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean b/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean index 4db9a56da2..ba406b0ddc 100644 --- a/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean +++ b/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean @@ -98,5 +98,5 @@ procedure diamondInheritance() //} " -#guard_msgs in +#guard_msgs (drop info) in #eval testInputWithOffset "Inheritance" program 14 processLaurelFile From f5f57c19b4d8cff3e4b759bba9ecd5d33a03753d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 18 May 2026 11:50:19 -0400 Subject: [PATCH 100/189] fix silent fail --- .../Languages/Laurel/Examples/Objects/T5_inheritance.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean b/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean index ba406b0ddc..4db9a56da2 100644 --- a/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean +++ b/StrataTest/Languages/Laurel/Examples/Objects/T5_inheritance.lean @@ -98,5 +98,5 @@ procedure diamondInheritance() //} " -#guard_msgs (drop info) in +#guard_msgs in #eval testInputWithOffset "Inheritance" program 14 processLaurelFile From c28cd1c5acbdc49b8642083617e48dc196e124f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 10:28:05 -0400 Subject: [PATCH 101/189] fix typing doc direction --- docs/verso/LaurelDoc.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 63fed89e9b..1acbd5c02f 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -481,7 +481,7 @@ target is a numeric type. ### Assignment ``` - Γ ⊢ targets_i ⇒ T_i Γ ⊢ e ⇒ T_e ExpectedTy <: T_e + Γ ⊢ targets_i ⇒ T_i Γ ⊢ e ⇒ T_e T_e <: ExpectedTy ───────────────────────────────────────────────────────────────── (Assign, impl) Γ ⊢ Assign targets e ⇒ TVoid From cdfdda8cdecc88dadc62a7a111aa75e080922330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 10:32:50 -0400 Subject: [PATCH 102/189] fix documentation : subtyping is implemented --- docs/verso/LaurelDoc.lean | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 1acbd5c02f..52e2cda2b7 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -203,11 +203,13 @@ internal interface used by other rules. The relation `<:` (used in Sub) is built from three Lean functions: -- `isSubtype` — pure subtyping. The stub is structural equality via - {name Strata.Laurel.highEq}`highEq`. The eventual real version walks the `extending` - chain for {name Strata.Laurel.CompositeType}`CompositeType`, unfolds +- `isSubtype` — pure subtyping. Walks the `extending` chain for + {name Strata.Laurel.CompositeType}`CompositeType` (via + {name Strata.Laurel.TypeContext.ancestors}`TypeContext.ancestors`), unfolds {name Strata.Laurel.TypeAlias}`TypeAlias` to its target, and unwraps - {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base. + {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base (both via + {name Strata.Laurel.TypeContext.unfold}`TypeContext.unfold`), then falls back to + structural equality via {name Strata.Laurel.highEq}`highEq`. - `isConsistent` — the symmetric gradual relation `~` (Siek–Taha): {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type and is consistent with everything; otherwise structural equality. @@ -233,9 +235,8 @@ A previous iteration was synth-only with three *bivariantly-compatible* wildcard {name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was load-bearing: no assignment, call argument, or comparison involving a user type was ever rejected. The bidirectional design retires that carve-out — user-defined types are now a regular -participant in `<:`, and tightening `isSubtype` (to walk inheritance and unwrap -constrained types) gradually buys real checking on user-defined code without changing -callers. +participant in `<:`, with `isSubtype` walking inheritance chains and unwrapping aliases +and constrained types to deliver real checking on user-defined code. Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This includes {name Strata.Laurel.StmtExpr.Return}`Return`, From fbb1de394982c4448494961012087c582cdcbc34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 13:14:35 -0400 Subject: [PATCH 103/189] remove special treatment of TCore --- Strata/Languages/Laurel/Laurel.lean | 5 ++--- Strata/Languages/Laurel/Resolution.lean | 16 +++++++--------- docs/verso/LaurelDoc.lean | 15 +++++---------- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index 5b6a7ee252..c5f5dede7c 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -468,6 +468,7 @@ def highEq (a : HighTypeMd) (b : HighTypeMd) : Bool := match _a: a.val, _b: b.va | HighType.TSet t1, HighType.TSet t2 => highEq t1 t2 | HighType.TMap k1 v1, HighType.TMap k2 v2 => highEq k1 k2 && highEq v1 v2 | HighType.UserDefined r1, HighType.UserDefined r2 => r1.text == r2.text + | HighType.TCore s1, HighType.TCore s2 => s1 == s2 | HighType.Applied b1 args1, HighType.Applied b2 args2 => highEq b1 b2 && args1.length == args2.length && (args1.attach.zip args2 |>.all (fun (a1, a2) => highEq a1.1 a2)) | HighType.Pure b1, HighType.Pure b2 => highEq b1 b2 @@ -545,14 +546,12 @@ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := /-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the dynamic type and is consistent with everything; otherwise structural - equality after unfolding aliases / constrained types. `TCore` is a - temporary migration escape hatch. -/ + equality after unfolding aliases / constrained types. -/ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := let a' := ctx.unfold a let b' := ctx.unfold b match a'.val, b'.val with | .Unknown, _ | _, .Unknown => true - | .TCore _, _ | _, .TCore _ => true | _, _ => highEq a' b' /-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For our flat lattice diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 0efbe7060b..81d96adca6 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -472,23 +472,21 @@ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (ac unless isConsistentSubtype ctx actual expected do typeMismatch source none s!"expected '{formatType expected}'" actual -/-- Test whether a type is in the set of numeric primitives. `Unknown` and - `TCore` are accepted as gradual escape hatches. Aliases and constrained - types are unfolded first so e.g. `nat` (constrained over `int`) counts as - numeric. Used by Op-Cmp / Op-Arith. -/ +/-- Test whether a type is in the set of numeric primitives. `Unknown` is + accepted as a gradual escape hatch. Aliases and constrained types are + unfolded first so e.g. `nat` (constrained over `int`) counts as numeric. + Used by Op-Cmp / Op-Arith. -/ private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := match (ctx.unfold ty).val with | .TInt | .TReal | .TFloat64 | .Unknown => true - | .TCore _ => true | _ => false -/-- Test whether a type is a user-defined reference type. `Unknown` and `TCore` - are accepted as gradual escape hatches. Used by Fresh and ReferenceEquals, - which only make sense on composite/datatype references. -/ +/-- Test whether a type is a user-defined reference type. `Unknown` is accepted + as a gradual escape hatch. Used by Fresh and ReferenceEquals, which only + make sense on composite/datatype references. -/ private def isReference (ctx : TypeContext) (ty : HighTypeMd) : Bool := match (ctx.unfold ty).val with | .UserDefined _ | .Unknown => true - | .TCore _ => true | _ => false /-- Get the type of a resolved reference. Tries the lexical scope by name diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 52e2cda2b7..e73ab90f00 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -216,10 +216,6 @@ The relation `<:` (used in Sub) is built from three Lean functions: - `isConsistentSubtype` — defined as `isConsistent ∨ isSubtype`. For our flat lattice this is the standard collapse of `∃R. T ~ R ∧ R <: U`. -{name Strata.Laurel.HighType.TCore}`TCore` is bivariantly consistent for now as a temporary -migration escape hatch from the Core language; the carve-out lives in `isConsistent` and is -intentionally temporary. - Sub (and every bespoke check rule) uses `isConsistentSubtype`. That single choice is what makes the system *gradual*: an expression of type {name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) @@ -228,10 +224,9 @@ flows freely into any typed slot, and any expression flows freely into a slot of fully-known types only. The symmetric `isConsistent` is used directly by Op-Eq, where the operand types must be mutually consistent (no subtype direction is privileged). -A previous iteration was synth-only with three *bivariantly-compatible* wildcards: -{name Strata.Laurel.HighType.Unknown}`Unknown`, -{name Strata.Laurel.HighType.UserDefined}`UserDefined`, and -{name Strata.Laurel.HighType.TCore}`TCore`. The +A previous iteration was synth-only with two *bivariantly-compatible* wildcards: +{name Strata.Laurel.HighType.Unknown}`Unknown` and +{name Strata.Laurel.HighType.UserDefined}`UserDefined`. The {name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was load-bearing: no assignment, call argument, or comparison involving a user type was ever rejected. The bidirectional design retires that carve-out — user-defined types are now a regular @@ -600,8 +595,8 @@ passes `Numeric`); a proper fix needs numeric promotion or unification. Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool ``` -`isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined`, -{name Strata.Laurel.HighType.Unknown}`Unknown`, or {name Strata.Laurel.HighType.TCore}`TCore` +`isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined` +or {name Strata.Laurel.HighType.Unknown}`Unknown` type. Reference equality is meaningless on primitives. Compatibility between `T_l` and `T_r` (e.g. rejecting `Cat === Dog` for unrelated user-defined types) is delegated to future tightening of `<:` — today, two distinct user-defined names already mismatch From a4674bdd092d2cdeb9b127e2b7556c4f99a9398c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 13:50:03 -0400 Subject: [PATCH 104/189] fix TCore documentation --- .../Languages/Laurel/ResolutionTypeCheckTests.lean | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 112fa7eba9..b78f3b22df 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -176,12 +176,11 @@ procedure test() opaque { #guard_msgs (error, drop all) in #eval testInputWithOffset "AssignTargetCountMismatch" assignTargetCountMismatch 156 processResolution -/-! ## UserDefined cross-type assignment (now rejected) +/-! ## UserDefined cross-type assignment -Cross-type assignments between unrelated user-defined types are rejected -because `isSubtype` is currently structural equality. Once `isSubtype` walks -`extending` chains, this test will need a related-types example to keep -exercising the success path. -/ +Assignments between unrelated composites are rejected: `isSubtype` walks +`extending` chains, so two composites with no common ancestor are not +subtypes of each other. -/ def userDefinedCrossType := r" composite Dog { } From 77d32f1ff2ed797ec02d4fa937169b0db267c506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 13:50:31 -0400 Subject: [PATCH 105/189] uniform <=/=> and use latex rule presentation --- docs/verso/LaurelDoc.lean | 390 ++++++++++---------------------------- 1 file changed, 95 insertions(+), 295 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index e73ab90f00..140a250407 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -169,11 +169,7 @@ expression has a given expected type. Each construct picks a mode based on wheth is determined locally (synth) or by context (check). The two judgments are connected by a single change-of-direction rule, *subsumption*: -``` -Γ ⊢ e ⇒ A A <: B -───────────────────── (Sub) - Γ ⊢ e ⇐ B -``` +$$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` Subsumption is the *only* place the checker switches from check to synth mode. It fires as the default fallback in @@ -186,9 +182,10 @@ propagate through nested control flow. `synthStmtExpr` and `checkStmtExpr` are mutually recursive: synth rules invoke check on subexpressions whose expected type is known (e.g. `cond ⇐ TBool` in {name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse`), and `checkStmtExpr` falls back to -`synthStmtExpr` via Sub. Termination uses a lexicographic measure `(exprMd, tag)` where the -tag is `0` for synth and `1` for check; any descent into a strict subterm decreases via -`Prod.Lex.left`, while Sub calls synth on the *same* expression and decreases via +`synthStmtExpr` via \[⇐\] Sub. Termination uses a lexicographic measure `(exprMd, tag)` +where the tag is `0` for synth and `1` for check; any descent into a strict subterm +decreases via `Prod.Lex.left`, while \[⇐\] Sub calls synth on the *same* expression and +decreases via `Prod.Lex.right`. This is the standard well-founded encoding for bidirectional systems. There is also a thin `resolveStmtExpr` wrapper that calls `synthStmtExpr` and discards the @@ -201,7 +198,7 @@ internal interface used by other rules. ### Gradual typing -The relation `<:` (used in Sub) is built from three Lean functions: +The relation `<:` (used in \[⇐\] Sub) is built from three Lean functions: - `isSubtype` — pure subtyping. Walks the `extending` chain for {name Strata.Laurel.CompositeType}`CompositeType` (via @@ -216,13 +213,13 @@ The relation `<:` (used in Sub) is built from three Lean functions: - `isConsistentSubtype` — defined as `isConsistent ∨ isSubtype`. For our flat lattice this is the standard collapse of `∃R. T ~ R ∧ R <: U`. -Sub (and every bespoke check rule) uses `isConsistentSubtype`. That single choice is what +\[⇐\] Sub (and every bespoke check rule) uses `isConsistentSubtype`. That single choice is what makes the system *gradual*: an expression of type {name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) flows freely into any typed slot, and any expression flows freely into a slot of type {name Strata.Laurel.HighType.Unknown}`Unknown`. Strict checking is applied between -fully-known types only. The symmetric `isConsistent` is used directly by Op-Eq, where the -operand types must be mutually consistent (no subtype direction is privileged). +fully-known types only. The symmetric `isConsistent` is used directly by \[⇒\] Op-Eq, where +the operand types must be mutually consistent (no subtype direction is privileged). A previous iteration was synth-only with two *bivariantly-compatible* wildcards: {name Strata.Laurel.HighType.Unknown}`Unknown` and @@ -245,130 +242,90 @@ includes {name Strata.Laurel.StmtExpr.Return}`Return`, Each construct is given as a derivation. `Γ` is the current lexical scope (see {name Strata.Laurel.ResolveState}`ResolveState`'s `scope`); it threads identically through every premise and conclusion unless a rule explicitly extends it (written `Γ, x : T`). -`(impl)` = implemented; `(planned)` = intended, not yet wired in. + +Each rule is tagged with `[⇒]` (synthesis) or `[⇐]` (checking) to make the +direction explicit. When a construct has both modes, the `-Synth` / `-Check` +suffix is dropped in favor of the prefix. ### Index -- *Subsumption* — Sub -- *Literals* — Lit-Int, Lit-Bool, Lit-String, Lit-Decimal -- *Variables* — Var-Local, Var-Field, Var-Declare -- *Control flow* — If-NoElse, If-Synth, If-Check, If-Check-NoElse; Block-Synth, - Block-Synth-Empty, Block-Check, Block-Check-Empty; Exit; Return-None, Return-Some, - Return-Void-Error, Return-Multi-Error; While -- *Verification statements* — Assert, Assume -- *Assignment* — Assign -- *Calls* — Static-Call, Static-Call-Multi, Instance-Call -- *Primitive operations* — Op-Bool, Op-Cmp, Op-Eq, Op-Arith, Op-Concat -- *Object forms* — New-Ok, New-Fallback; AsType; IsType; RefEq; PureFieldUpdate -- *Verification expressions* — Quantifier, Assigned, Old, Fresh, ProveBy -- *Self reference* — This-Inside, This-Outside -- *Untyped forms* — Abstract / All -- *ContractOf* — ContractOf-Bool, ContractOf-Set, ContractOf-Error -- *Holes* — Hole-Some, Hole-None-Synth, Hole-None-Check +- *Subsumption* — \[⇐\] Sub +- *Literals* — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal +- *Variables* — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇒\] Var-Declare +- *Control flow* — \[⇒\] If-NoElse, \[⇒\] If, \[⇐\] If, \[⇐\] If-NoElse; + \[⇒\] Block, \[⇒\] Block-Empty, \[⇐\] Block, \[⇐\] Block-Empty; \[⇒\] Exit; + \[⇒\] Return-None, \[⇒\] Return-Some, \[⇒\] Return-Void-Error, + \[⇒\] Return-Multi-Error; \[⇒\] While +- *Verification statements* — \[⇒\] Assert, \[⇒\] Assume +- *Assignment* — \[⇒\] Assign +- *Calls* — \[⇒\] Static-Call, \[⇒\] Static-Call-Multi, \[⇒\] Instance-Call +- *Primitive operations* — \[⇒\] Op-Bool, \[⇒\] Op-Cmp, \[⇒\] Op-Eq, \[⇒\] Op-Arith, + \[⇒\] Op-Concat +- *Object forms* — \[⇒\] New-Ok, \[⇒\] New-Fallback; \[⇒\] AsType; \[⇒\] IsType; + \[⇒\] RefEq; \[⇒\] PureFieldUpdate +- *Verification expressions* — \[⇒\] Quantifier, \[⇒\] Assigned, \[⇒\] Old, + \[⇒\] Fresh, \[⇒\] ProveBy +- *Self reference* — \[⇒\] This-Inside, \[⇒\] This-Outside +- *Untyped forms* — \[⇒\] Abstract / All +- *ContractOf* — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error +- *Holes* — \[⇒\] Hole-Some, \[⇒\] Hole-None, \[⇐\] Hole-None ### Subsumption -``` -Γ ⊢ e ⇒ A A <: B -───────────────────── (Sub, impl) - Γ ⊢ e ⇐ B -``` +$$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` Fallback in `checkStmtExpr` whenever no bespoke check rule applies. ### Literals -``` -────────────────────────── (Lit-Int, impl) - Γ ⊢ LiteralInt n ⇒ TInt -``` +$$`\frac{}{\Gamma \vdash \mathsf{LiteralInt}\;n \Rightarrow \mathsf{TInt}} \quad \text{([⇒] Lit-Int)}` -``` -─────────────────────────── (Lit-Bool, impl) - Γ ⊢ LiteralBool b ⇒ TBool -``` +$$`\frac{}{\Gamma \vdash \mathsf{LiteralBool}\;b \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Lit-Bool)}` -``` -───────────────────────────────── (Lit-String, impl) - Γ ⊢ LiteralString s ⇒ TString -``` +$$`\frac{}{\Gamma \vdash \mathsf{LiteralString}\;s \Rightarrow \mathsf{TString}} \quad \text{([⇒] Lit-String)}` -``` -────────────────────────────────── (Lit-Decimal, impl) - Γ ⊢ LiteralDecimal d ⇒ TReal -``` +$$`\frac{}{\Gamma \vdash \mathsf{LiteralDecimal}\;d \Rightarrow \mathsf{TReal}} \quad \text{([⇒] Lit-Decimal)}` ### Variables -``` - Γ(x) = T -─────────────────────────── (Var-Local, impl) - Γ ⊢ Var (.Local x) ⇒ T -``` +$$`\frac{\Gamma(x) = T}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Local}\;x) \Rightarrow T} \quad \text{([⇒] Var-Local)}` -``` - Γ ⊢ e ⇒ _ Γ(f) = T_f -────────────────────────────── (Var-Field, impl) - Γ ⊢ Var (.Field e f) ⇒ T_f -``` +$$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Field}\;e\;f) \Rightarrow T_f} \quad \text{([⇒] Var-Field)}` Resolution looks `f` up against the type of `e` (or the enclosing instance type for `self.f`); the typing rule itself is path-agnostic. -``` - x ∉ dom(Γ) -───────────────────────────────────────── (Var-Declare, impl) - Γ ⊢ Var (.Declare ⟨x, T⟩) ⇒ TVoid ⊣ Γ, x : T -``` +$$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T\rangle) \Rightarrow \mathsf{TVoid} \dashv \Gamma, x : T} \quad \text{([⇒] Var-Declare)}` `⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the remainder of the enclosing scope. ### Control flow -``` -Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T -───────────────────────────────────────────── (If-NoElse, impl) - Γ ⊢ IfThenElse cond thenBr none ⇒ TVoid -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-NoElse)}` The construct synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no value when `cond` is false; without this, `x : int := if c then 5` would type-check spuriously. -``` -Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e -────────────────────────────────────────────────────────────── (If-Synth, impl) - Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T_t -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t} \quad \text{([⇒] If)}` Picks the then-branch type arbitrarily; the two branches are *not* compared, since a statement-position `if` often pairs a value branch with a `return`/`exit`/`assert`. The -enclosing context's check (Sub, or a containing `checkSubtype` like an assignment) provides -the actual check downstream. - -``` -Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T -────────────────────────────────────────────────────────── (If-Check, impl) - Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T +enclosing context's check (\[⇐\] Sub, or a containing `checkSubtype` like an assignment) +provides the actual check downstream. +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \Gamma \vdash \mathit{elseBr} \Leftarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Leftarrow T} \quad \text{([⇐] If)}` -Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T TVoid <: T -───────────────────────────────────────────────────── (If-Check-NoElse, impl) - Γ ⊢ IfThenElse cond thenBr none ⇐ T -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Leftarrow T} \quad \text{([⇐] If-NoElse)}` -Check mode pushes `T` into both branches (rather than going through If-Synth + Sub at the -boundary). Errors fire at the offending branch instead of the surrounding `if`. Without an -else branch, the construct can only succeed when `T` admits -{name Strata.Laurel.HighType.TVoid}`TVoid` — the same subsumption check `Block-Check-Empty` +Check mode pushes `T` into both branches (rather than going through \[⇒\] If + \[⇐\] Sub at +the boundary). Errors fire at the offending branch instead of the surrounding `if`. +Without an else branch, the construct can only succeed when `T` admits +{name Strata.Laurel.HighType.TVoid}`TVoid` — the same subsumption check `\[⇐\] Block-Empty` performs for an empty block. -``` -Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇒ T -─────────────────────────────────────────────────────────────────────────── (Block-Synth, impl) - Γ ⊢ Block [s_1; …; s_n] label ⇒ T -``` +$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block)}` `Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i` says each statement is resolved in the scope produced by its predecessor and may itself extend it (`Var (.Declare …)` does); `s_n` is typed in @@ -379,16 +336,9 @@ Non-last statements are synthesized but their types discarded (the lax rule). Th Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` is silently accepted; flagging it belongs to a lint. -``` -───────────────────────────── (Block-Synth-Empty, impl) - Γ ⊢ Block [] label ⇒ TVoid -``` +$$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Block-Empty)}` -``` -Γ_0 = Γ Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n) Γ_{n-1} ⊢ s_n ⇐ T -─────────────────────────────────────────────────────────────────────────── (Block-Check, impl) - Γ ⊢ Block [s_1; …; s_n] label ⇐ T -``` +$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` Pushes `T` into the *last* statement rather than comparing the block's synthesized type at the boundary. Errors fire at the offending subexpression, and `T` keeps propagating through @@ -397,16 +347,9 @@ nested {name Strata.Laurel.StmtExpr.Block}`Block` / {name Strata.Laurel.StmtExpr.Hole}`Hole` / {name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. -``` - TVoid <: T -───────────────────────── (Block-Check-Empty, impl) - Γ ⊢ Block [] label ⇐ T -``` +$$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Empty)}` -``` -──────────────────────── (Exit, impl) - Γ ⊢ Exit target ⇒ TVoid -``` +$$`\frac{}{\Gamma \vdash \mathsf{Exit}\;\mathit{target} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Exit)}` `Return` matches the optional return value against the enclosing procedure's declared outputs. The expected output types are threaded through @@ -416,74 +359,42 @@ outputs. The expected output types are threaded through the body. `none` means "no enclosing procedure" — e.g. resolving a constant initializer — and skips all `Return` checks. -``` -───────────────────────────── (Return-None, impl) - Γ ⊢ Return none ⇒ TVoid -``` +$$`\frac{}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-None)}` A bare `return;` is allowed in any context. In a single-output procedure it acts as a Dafny-style early exit — the output parameter retains whatever was last assigned to it. -``` - Γ_proc.outputs = [T] Γ ⊢ e ⇐ T -────────────────────────────────────── (Return-Some, impl) - Γ ⊢ Return (some e) ⇒ TVoid -``` +$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T] \quad \Gamma \vdash e \Leftarrow T}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-Some)}` In a single-output procedure, the value is checked against the declared output type. This closes the prior soundness gap where `return 0` in a `bool`-returning procedure went uncaught. -``` - Γ_proc.outputs = [] -───────────────────────────────── (Return-Void-Error, impl) - Γ ⊢ Return (some e) — error: "void procedure cannot return a value" - +$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = []}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “void procedure cannot return a value”}} \quad \text{([⇒] Return-Void-Error)}` - Γ_proc.outputs = [T_1; …; T_n] (n ≥ 2) -────────────────────────────────────────────────────────── (Return-Multi-Error, impl) - Γ ⊢ Return (some e) — error: "multi-output procedure cannot - use 'return e'; assign to named outputs instead" -``` +$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇒] Return-Multi-Error)}` Multi-output procedures use named-output assignment (`r := …` on the declared output parameters). `return e` syntactically takes a single {name Strata.Laurel.StmtExpr.Return}`Option StmtExpr`, so it cannot carry multiple values; flagging it points users at the named-output convention. -``` - Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool Γ ⊢ dec ⇐ ? Γ ⊢ body ⇒ _ -─────────────────────────────────────────────────────────────────────────────── (While, impl) - Γ ⊢ While cond invs dec body ⇒ TVoid -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{dec} \Leftarrow {?} \quad \Gamma \vdash \mathit{body} \Rightarrow \_}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{dec}\;\mathit{body} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] While)}` `dec` (the optional decreases clause) is resolved without a type check today; the intended target is a numeric type. ### Verification statements -``` - Γ ⊢ cond ⇐ TBool -────────────────────────────── (Assert, impl) - Γ ⊢ Assert cond ⇒ TVoid -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assert)}` -``` - Γ ⊢ cond ⇐ TBool -───────────────────────────── (Assume, impl) - Γ ⊢ Assume cond ⇒ TVoid -``` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assume}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assume)}` ### Assignment -``` - Γ ⊢ targets_i ⇒ T_i Γ ⊢ e ⇒ T_e T_e <: ExpectedTy -───────────────────────────────────────────────────────────────── (Assign, impl) - Γ ⊢ Assign targets e ⇒ TVoid +$$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Rightarrow T_e \quad T_e <: \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assign)}` - where ExpectedTy = T_1 if |targets| = 1 - = MultiValuedExpr [T_1; …; T_n] otherwise -``` +where `ExpectedTy = T_1` if `|targets| = 1` and `MultiValuedExpr [T_1; …; T_n]` otherwise. The target's declared type `T_i` comes from the variable's scope entry (for {name Strata.Laurel.Variable.Local}`Local` and {name Strata.Laurel.Variable.Field}`Field`) @@ -497,26 +408,11 @@ to assign. ### Calls -``` - Γ(callee) = static-procedure with inputs Ts and outputs [T] - Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) -───────────────────────────────────────────────────────────── (Static-Call, impl) - Γ ⊢ StaticCall callee args ⇒ T -``` +$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Static-Call)}` -``` - Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n], n ≠ 1 - Γ ⊢ args ⇒ Us U_i <: T_i (pairwise) -────────────────────────────────────────────────────────────────────────────────── (Static-Call-Multi, impl) - Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] -``` +$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` -``` - Γ ⊢ target ⇒ _ Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T] - Γ ⊢ args ⇒ Us U_i <: T_i (pairwise; self is dropped) -───────────────────────────────────────────────────────────────────────────────────────────── (Instance-Call, impl) - Γ ⊢ InstanceCall target callee args ⇒ T -``` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Instance-Call)}` ### Primitive operations @@ -524,76 +420,36 @@ to assign. {name Strata.Laurel.HighType.TReal}`TReal`, {name Strata.Laurel.HighType.TFloat64}`TFloat64`". -``` - Γ ⊢ args_i ⇐ TBool op ∈ {And, Or, AndThen, OrElse, Not, Implies} -────────────────────────────────── (Op-Bool, impl) - Γ ⊢ PrimitiveOp op args ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TBool} \quad \mathit{op} \in \{\mathsf{And}, \mathsf{Or}, \mathsf{AndThen}, \mathsf{OrElse}, \mathsf{Not}, \mathsf{Implies}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Bool)}` -``` - Γ ⊢ args_i ⇐ Numeric op ∈ {Lt, Leq, Gt, Geq} -───────────────────────────────── (Op-Cmp, impl) - Γ ⊢ PrimitiveOp op args ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \mathit{op} \in \{\mathsf{Lt}, \mathsf{Leq}, \mathsf{Gt}, \mathsf{Geq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Cmp)}` -``` - Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r T_l ~ T_r op ∈ {Eq, Neq} -───────────────────────────────────────────────────────── (Op-Eq, impl) - Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad T_l \sim T_r \quad \mathit{op} \in \{\mathsf{Eq}, \mathsf{Neq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;[\mathit{lhs}; \mathit{rhs}] \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Eq)}` `~` is the consistency relation `isConsistent` — symmetric, with the {name Strata.Laurel.HighType.Unknown}`Unknown` wildcard. -``` - Γ ⊢ args_i ⇐ Numeric Γ ⊢ args.head ⇒ T op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} -────────────────────────────────────────────────── (Op-Arith, impl) - Γ ⊢ PrimitiveOp op args ⇒ T -``` +$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \Gamma \vdash \mathit{args}.\mathsf{head} \Rightarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` "Result is the type of the first argument" handles `int + int → int`, `real + real → real`, etc. without unification. Known relaxation: `int + real` passes (each operand individually passes `Numeric`); a proper fix needs numeric promotion or unification. -``` - Γ ⊢ args_i ⇐ TString op = StrConcat -───────────────────────────────────── (Op-Concat, impl) - Γ ⊢ PrimitiveOp op args ⇒ TString -``` +$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` ### Object forms -``` - Γ(ref) is a composite or datatype T -────────────────────────────────────────── (New-Ok, impl) - Γ ⊢ New ref ⇒ UserDefined T -``` +$$`\frac{\Gamma(\mathit{ref}) \text{ is a composite or datatype } T}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] New-Ok)}` -``` - Γ(ref) is not a composite or datatype -───────────────────────────────────────── (New-Fallback, impl) - Γ ⊢ New ref ⇒ Unknown -``` +$$`\frac{\Gamma(\mathit{ref}) \text{ is not a composite or datatype}}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] New-Fallback)}` -``` - Γ ⊢ target ⇒ _ -───────────────────────────── (AsType, impl) - Γ ⊢ AsType target T ⇒ T -``` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{AsType}\;\mathit{target}\;T \Rightarrow T} \quad \text{([⇒] AsType)}` `target` is resolved but not checked against `T` — the cast is the user's claim. -``` - Γ ⊢ target ⇒ _ -───────────────────────────────── (IsType, impl) - Γ ⊢ IsType target T ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` -``` - Γ ⊢ lhs ⇒ T_l Γ ⊢ rhs ⇒ T_r isReference T_l isReference T_r -───────────────────────────────────────────────────────────────────────────── (RefEq, impl) - Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad \mathsf{isReference}\;T_l \quad \mathsf{isReference}\;T_r}{\Gamma \vdash \mathsf{ReferenceEquals}\;\mathit{lhs}\;\mathit{rhs} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] RefEq)}` `isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined` or {name Strata.Laurel.HighType.Unknown}`Unknown` @@ -602,67 +458,36 @@ type. Reference equality is meaningless on primitives. Compatibility between `T_ future tightening of `<:` — today, two distinct user-defined names already mismatch structurally, so the check would only fire under stronger subtyping. -``` - Γ ⊢ target ⇒ T_t Γ(f) = T_f Γ ⊢ newVal ⇐ T_f -───────────────────────────────────────────────────────────── (PureFieldUpdate, impl) - Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t -``` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow T_t \quad \Gamma(f) = T_f \quad \Gamma \vdash \mathit{newVal} \Leftarrow T_f}{\Gamma \vdash \mathsf{PureFieldUpdate}\;\mathit{target}\;f\;\mathit{newVal} \Rightarrow T_t} \quad \text{([⇒] PureFieldUpdate)}` `f` is resolved against `T_t` (or the enclosing instance type) and `newVal` is checked against the field's declared type. ### Verification expressions -``` - Γ, x : T ⊢ body ⇐ TBool -───────────────────────────────────────────────── (Quantifier, impl) - Γ ⊢ Quantifier mode ⟨x, T⟩ trig body ⇒ TBool -``` +$$`\frac{\Gamma, x : T \vdash \mathit{body} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Quantifier}\;\mathit{mode}\;\langle x, T\rangle\;\mathit{trig}\;\mathit{body} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Quantifier)}` The bound variable `x : T` is introduced in scope only for the body (and trigger). The body is checked against {name Strata.Laurel.HighType.TBool}`TBool` since a quantifier is a proposition; without this, `forall x: int :: x + 1` would be silently accepted. -``` - Γ ⊢ name ⇒ _ -───────────────────────────── (Assigned, impl) - Γ ⊢ Assigned name ⇒ TBool -``` +$$`\frac{\Gamma \vdash \mathit{name} \Rightarrow \_}{\Gamma \vdash \mathsf{Assigned}\;\mathit{name} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Assigned)}` -``` - Γ ⊢ v ⇒ T -───────────────── (Old, impl) - Γ ⊢ Old v ⇒ T -``` +$$`\frac{\Gamma \vdash v \Rightarrow T}{\Gamma \vdash \mathsf{Old}\;v \Rightarrow T} \quad \text{([⇒] Old)}` -``` - Γ ⊢ v ⇒ T isReference T -───────────────────────────────── (Fresh, impl) - Γ ⊢ Fresh v ⇒ TBool -``` +$$`\frac{\Gamma \vdash v \Rightarrow T \quad \mathsf{isReference}\;T}{\Gamma \vdash \mathsf{Fresh}\;v \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Fresh)}` `isReference T` is the same predicate as in {name Strata.Laurel.StmtExpr.ReferenceEquals}`ReferenceEquals`. {name Strata.Laurel.StmtExpr.Fresh}`Fresh` only makes sense on heap-allocated references; `fresh(5)` is rejected. -``` - Γ ⊢ v ⇒ T Γ ⊢ proof ⇒ _ -─────────────────────────────────── (ProveBy, impl) - Γ ⊢ ProveBy v proof ⇒ T -``` +$$`\frac{\Gamma \vdash v \Rightarrow T \quad \Gamma \vdash \mathit{proof} \Rightarrow \_}{\Gamma \vdash \mathsf{ProveBy}\;v\;\mathit{proof} \Rightarrow T} \quad \text{([⇒] ProveBy)}` ### Self reference -``` - Γ.instanceTypeName = some T -────────────────────────────────── (This-Inside, impl) - Γ ⊢ This ⇒ UserDefined T - +$$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{some}\;T}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] This-Inside)}` - Γ.instanceTypeName = none -────────────────────────────── (This-Outside, impl) - Γ ⊢ This ⇒ Unknown [emits "'this' is not allowed outside instance methods"] -``` +$$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{none}}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{Unknown}\;\;[\text{emits “‘this’ is not allowed outside instance methods”}]} \quad \text{([⇒] This-Outside)}` `Γ.instanceTypeName` is the {name Strata.Laurel.ResolveState}`ResolveState` field set by @@ -672,10 +497,7 @@ types instead of being wildcarded through {name Strata.Laurel.HighType.Unknown}` ### Untyped forms -``` -───────────────────────────────── (Abstract / All, impl) - Γ ⊢ Abstract / All … ⇒ Unknown -``` +$$`\frac{}{\Gamma \vdash \mathsf{Abstract}\;/\;\mathsf{All}\;\ldots \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Abstract / All)}` ### ContractOf @@ -684,18 +506,9 @@ types instead of being wildcarded through {name Strata.Laurel.HighType.Unknown}` (`Modifies`). `fn` must be a direct identifier reference to a procedure — a contract belongs to a *named* procedure, not an arbitrary expression. -``` - fn = Var (.Local id) Γ(id) ∈ {staticProcedure, instanceProcedure} -───────────────────────────────────────────────────────────────────────── (ContractOf-Bool, impl) - Γ ⊢ ContractOf Precondition fn ⇒ TBool - Γ ⊢ ContractOf PostCondition fn ⇒ TBool +$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Precondition}\;\mathit{fn} \Rightarrow \mathsf{TBool} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{PostCondition}\;\mathit{fn} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] ContractOf-Bool)}` - - fn = Var (.Local id) Γ(id) ∈ {staticProcedure, instanceProcedure} -───────────────────────────────────────────────────────────────────────── (ContractOf-Set, impl) - Γ ⊢ ContractOf Reads fn ⇒ TSet Unknown - Γ ⊢ ContractOf Modifies fn ⇒ TSet Unknown -``` +$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Reads}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{Modifies}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown}} \quad \text{([⇒] ContractOf-Set)}` `Precondition` and `PostCondition` are propositions, hence {name Strata.Laurel.HighType.TBool}`TBool`. `Reads` and `Modifies` are sets of heap-allocated @@ -703,11 +516,7 @@ locations — composite/datatype references and fields. The element type is left {name Strata.Laurel.HighType.Unknown}`Unknown` for now since the rule doesn't yet recover it from `fn`'s declared modifies/reads clauses. -``` - fn is not a procedure reference -───────────────────────────────────────────── (ContractOf-Error, impl) - Γ ⊢ ContractOf … fn — error: "'contractOf' expected a procedure reference" -``` +$$`\frac{\mathit{fn} \text{ is not a procedure reference}}{\Gamma \vdash \mathsf{ContractOf}\;\ldots\;\mathit{fn} \rightsquigarrow \text{error: “‘contractOf’ expected a procedure reference”}} \quad \text{([⇒] ContractOf-Error)}` When `fn` doesn't resolve to a procedure (e.g. it's an arbitrary expression, or resolves to a constant/variable), the diagnostic fires and the construct synthesizes @@ -719,20 +528,11 @@ exists so resolution remains exhaustive over `StmtExpr`. ### Holes -``` -──────────────────────────── (Hole-Some, impl) - Γ ⊢ Hole d (some T) ⇒ T -``` +$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T) \Rightarrow T} \quad \text{([⇒] Hole-Some)}` -``` -───────────────────────────────── (Hole-None-Synth, impl) - Γ ⊢ Hole d none ⇒ Unknown -``` +$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Hole-None)}` -``` -───────────────────────────────────── (Hole-None-Check, impl) - Γ ⊢ Hole d none ⇐ T ↦ Hole d (some T) -``` +$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \;\;\mapsto\;\; \mathsf{Hole}\;d\;(\mathsf{some}\;T)} \quad \text{([⇐] Hole-None)}` In check mode, an untyped hole records the expected type `T` on the node directly. The subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it @@ -741,7 +541,7 @@ discarding it. A separate `InferHoleTypes` pass still runs after resolution to annotate holes that ended up in synth-only positions. When that pass encounters a hole whose type was already set -(by Hole-None-Check or by a user-written `?: T`), it checks the resolution-time and +(by \[⇐\] Hole-None or by a user-written `?: T`), it checks the resolution-time and inference-time types for consistency under `~`; a disagreement fires the diagnostic *"hole annotated with 'T_resolution' but context expects 'T_inference'"*, surfacing what would otherwise be a silent overwrite. @@ -781,7 +581,7 @@ just wasted work and a maintenance hazard. ### Shrink or remove `InferHoleTypes` `InferHoleTypes` walks the post-resolution AST a second time to annotate holes. Now that -Hole-None-Check writes the expected type during resolution for holes in check-mode +\[⇐\] Hole-None writes the expected type during resolution for holes in check-mode positions, the post-pass only needs to handle holes in synth-only positions (e.g. call arguments resolved through `synthStmtExpr` instead of `checkStmtExpr`). As more constructs gain bespoke check rules, fewer holes will reach `InferHoleTypes`; eventually the pass From ccc2a986d2afb58caab5985e335dfe2aa1fa6c49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 15:30:10 -0400 Subject: [PATCH 106/189] extract typing rules out in helper functions for easier verso documentation --- Strata/Languages/Laurel/Resolution.lean | 1091 ++++++++++++++++------- 1 file changed, 791 insertions(+), 300 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 81d96adca6..80982bfa59 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -526,345 +526,836 @@ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List Hig | some (_, .constant c) => pure (c.type, []) | _ => pure ({ val := .Unknown, source := callee.source }, []) +/-! ## Typing rules + +Each typing rule from the Laurel manual is implemented as its own helper +inside the mutual block below. Helpers are grouped by section to mirror the +*Typing rules* index in `LaurelDoc.lean`: + +- Literals — `synthLitInt`, `synthLitBool`, `synthLitString`, `synthLitDecimal` +- Variables — `synthVarLocal`, `synthVarField`, `synthVarDeclare` +- Control flow — `synthIfThenElse`, `synthBlock`, `synthWhile`, `synthExit`, + `synthReturn`, `checkBlock`, `checkIfThenElse` +- Verification statements — `synthAssert`, `synthAssume` +- Assignment — `synthAssign` +- Calls — `synthStaticCall`, `synthInstanceCall` +- Primitive operations — `synthPrimitiveOp` +- Object forms — `synthNew`, `synthAsType`, `synthIsType`, `synthRefEq`, + `synthPureFieldUpdate` +- Verification expressions — `synthQuantifier`, `synthAssigned`, `synthOld`, + `synthFresh`, `synthProveBy` +- Self reference — `synthThis` +- Untyped forms — `synthAbstract`, `synthAll` +- ContractOf — `synthContractOf` +- Holes — `synthHole`, `checkHoleNone` + +The dispatch functions `synthStmtExpr` and `checkStmtExpr` simply pattern-match +on the constructor and delegate to the corresponding helper. -/ + +-- The `h : exprMd.val = .Foo args ...` parameters on the recursive helpers +-- look unused to the linter, but each one is referenced by that helper's +-- `decreasing_by` tactic to relate `sizeOf args` to `sizeOf exprMd`. +set_option linter.unusedVariables false in mutual + +-- ### Dispatch + +/-- Synth-mode resolution: resolve `e` and synthesize its `HighType`. + Each constructor delegates to its rule's helper. -/ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do - match _: exprMd with + match h_node: exprMd with | AstNode.mk expr source => - let (val', ty) ← match _: expr with + let (val', ty) ← match h_expr: expr with | .IfThenElse cond thenBr elseBr => - -- Condition is checked against TBool. The result type is TVoid when the - -- else branch is absent (statement form: the then-branch's value is - -- discarded), otherwise the then-branch's synthesized type. We don't - -- compare the two branches against each other since statement-position - -- ifs commonly mix a value branch with a TVoid branch (return/exit). - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } - let (thenBr', thenTy) ← synthStmtExpr thenBr - let elseBr' ← elseBr.attach.mapM (fun a => have := a.property; do - let (e', _) ← synthStmtExpr a.val; pure e') - let resultTy := match elseBr with - | none => { val := .TVoid, source := source } - | some _ => thenTy - pure (.IfThenElse cond' thenBr' elseBr', resultTy) + synthIfThenElse exprMd cond thenBr elseBr (by rw [h_node]) | .Block stmts label => - -- Synth-mode block: non-last statements have their synthesized type discarded - -- (lax rule, matches Java/Python/JS expression-statement semantics). - -- The last statement's synthesized type becomes the block's type. - withScope do - let results ← stmts.mapM synthStmtExpr - let stmts' := results.map (·.1) - let lastTy := match results.getLast? with - | some (_, ty) => ty - | none => { val := .TVoid, source := source } - pure (.Block stmts' label, lastTy) + synthBlock exprMd stmts label (by rw [h_node]) | .While cond invs dec body => - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } - let invs' ← invs.attach.mapM (fun a => have := a.property; do - checkStmtExpr a.val { val := .TBool, source := a.val.source }) - let dec' ← dec.attach.mapM (fun a => have := a.property; do - let (e', _) ← synthStmtExpr a.val; pure e') - let (body', _) ← synthStmtExpr body - pure (.While cond' invs' dec' body', { val := .TVoid, source := source }) - | .Exit target => pure (.Exit target, { val := .TVoid, source := source }) - | .Return val => do - -- Match the optional return value against the enclosing procedure's - -- declared outputs. `expectedReturnTypes = none` means we're not inside a - -- procedure body (e.g. resolving a constant initializer); skip the check. - let expected := (← get).expectedReturnTypes - let val' ← val.attach.mapM (fun a => have := a.property; do - match expected with - | some [singleOutput] => checkStmtExpr a.val singleOutput - | _ => let (e', _) ← synthStmtExpr a.val; pure e') - -- Arity/shape diagnostics independent of the value's own type. - match val, expected with - | none, some [] => pure () - | none, some [_] => pure () -- Dafny-style early exit - | none, some _ => pure () -- multi-output: bare return is fine - | some _, some [] => - let diag := diagnosticFromSource source - "void procedure cannot return a value" - modify fun s => { s with errors := s.errors.push diag } - | some _, some [_] => pure () -- value already checked above - | some _, some _ => - let diag := diagnosticFromSource source - "multi-output procedure cannot use 'return e'; assign to named outputs instead" - modify fun s => { s with errors := s.errors.push diag } - | _, none => pure () -- no enclosing procedure - pure (.Return val', { val := .TVoid, source := source }) - | .LiteralInt v => pure (.LiteralInt v, { val := .TInt, source := source }) - | .LiteralBool v => pure (.LiteralBool v, { val := .TBool, source := source }) - | .LiteralString v => pure (.LiteralString v, { val := .TString, source := source }) - | .LiteralDecimal v => pure (.LiteralDecimal v, { val := .TReal, source := source }) - | .Var (.Local ref) => - let ref' ← resolveRef ref source - let ty ← getVarType ref - pure (.Var (.Local ref'), ty) - | .Var (.Declare param) => - let ty' ← resolveHighType param.type - let name' ← defineNameCheckDup param.name (.var param.name ty') - pure (.Var (.Declare ⟨name', ty'⟩), { val := .TVoid, source := source }) - | .Assign targets value => - let targets' ← targets.attach.mapM fun ⟨v, _⟩ => do - let ⟨vv, vs⟩ := v - match vv with - | .Local ref => - let ref' ← resolveRef ref source - pure (⟨.Local ref', vs⟩ : VariableMd) - | .Field target fieldName => - let (target', _) ← synthStmtExpr target - let fieldName' ← resolveFieldRef target' fieldName source - pure (⟨.Field target' fieldName', vs⟩ : VariableMd) - | .Declare param => - let ty' ← resolveHighType param.type - let name' ← defineNameCheckDup param.name (.var param.name ty') - pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let (value', valueTy) ← synthStmtExpr value - -- Compute the target's declared type, regardless of whether it's a Local, - -- a Field, or a fresh Declare. - let targetType (t : VariableMd) : ResolveM HighTypeMd := do - match t.val with - | .Local ref => getVarType ref - | .Declare param => pure param.type - | .Field _ fieldName => getVarType fieldName - -- Skip all checks when the RHS is a statement (TVoid) — no value to assign. - if valueTy.val != HighType.TVoid then - let targetTys ← targets'.mapM targetType - -- Build the expected type from the targets' declared types: a single - -- type when there's one target, a tuple (MultiValuedExpr) otherwise. - -- This matches the shape of `valueTy`, which is itself MultiValuedExpr - -- exactly when the RHS produces multiple values. A single tuple-vs-tuple - -- check then covers both arity and per-position type mismatches in one - -- diagnostic. - let expectedTy : HighTypeMd := match targetTys with - | [single] => single - | _ => { val := .MultiValuedExpr targetTys, source := source } - checkSubtype source expectedTy valueTy - pure (.Assign targets' value', valueTy) + synthWhile exprMd cond invs dec body (by rw [h_node]) + | .Exit target => pure (synthExit target source) + | .Return val => + synthReturn exprMd source val (by rw [h_node]) + | .LiteralInt v => pure (synthLitInt v source) + | .LiteralBool v => pure (synthLitBool v source) + | .LiteralString v => pure (synthLitString v source) + | .LiteralDecimal v => pure (synthLitDecimal v source) + | .Var (.Local ref) => synthVarLocal ref source + | .Var (.Declare param) => synthVarDeclare param source | .Var (.Field target fieldName) => - let (target', _) ← synthStmtExpr target - let fieldName' ← resolveFieldRef target' fieldName source - let ty ← getVarType fieldName' - pure (.Var (.Field target' fieldName'), ty) + synthVarField exprMd target fieldName source (by rw [h_node]) + | .Assign targets value => + synthAssign exprMd targets value source (by rw [h_node]) | .PureFieldUpdate target fieldName newVal => - let (target', targetTy) ← synthStmtExpr target - let fieldName' ← resolveFieldRef target' fieldName source - let fieldTy ← getVarType fieldName' - let newVal' ← checkStmtExpr newVal fieldTy - pure (.PureFieldUpdate target' fieldName' newVal', targetTy) + synthPureFieldUpdate exprMd target fieldName newVal (by rw [h_node]) | .StaticCall callee args => - let callee' ← resolveRef callee source - (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) - let results ← args.mapM synthStmtExpr - let args' := results.map (·.1) - let argTypes := results.map (·.2) - let (retTy, paramTypes) ← getCallInfo callee - for ((a, aTy), paramTy) in (args'.zip argTypes).zip paramTypes do - checkSubtype a.source paramTy aTy - pure (.StaticCall callee' args', retTy) + synthStaticCall exprMd callee args source (by rw [h_node]) | .PrimitiveOp op args => - let results ← args.mapM synthStmtExpr - let args' := results.map (·.1) - let argTypes := results.map (·.2) - let resultTy := match op with - | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies - | .Lt | .Leq | .Gt | .Geq => HighType.TBool - | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => - match argTypes.head? with - | some headTy => headTy.val - | none => HighType.TInt - | .StrConcat => HighType.TString - match op with - | .And | .Or | .AndThen | .OrElse | .Not | .Implies => - for (a, aTy) in args'.zip argTypes do - checkSubtype a.source { val := .TBool, source := a.source } aTy - | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => - let ctx := (← get).typeContext - for (a, aTy) in args'.zip argTypes do - unless isNumeric ctx aTy do - typeMismatch a.source (some expr) "expected a numeric type" aTy - | .Eq | .Neq => - match argTypes with - | [lhsTy, rhsTy] => - let ctx := (← get).typeContext - unless isConsistent ctx lhsTy rhsTy do - let diag := diagnosticFromSource source - s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" - modify fun s => { s with errors := s.errors.push diag } - | _ => pure () - | .StrConcat => - for (a, aTy) in args'.zip argTypes do - checkSubtype a.source { val := .TString, source := a.source } aTy - pure (.PrimitiveOp op args', { val := resultTy, source := source }) - | .New ref => - let ref' ← resolveRef ref source - (expected := #[.compositeType, .datatypeDefinition]) - -- If the reference resolved to the wrong kind, use Unknown type to avoid cascading errors - let s ← get - let kindOk : Bool := match s.scope.get? ref.text with - | some (_, node) => node.kind == .unresolved || - (#[ResolvedNodeKind.compositeType, .datatypeDefinition].contains node.kind) - | none => true - let ty := if kindOk then { val := HighType.UserDefined ref', source := source } - else { val := HighType.Unknown, source := source } - pure (.New ref', ty) - | .This => - let s ← get - match s.instanceTypeName with - | some typeName => - let typeId : Identifier := - match s.scope.get? typeName with - | some (uid, _) => { text := typeName, uniqueId := some uid, source := source } - | none => { text := typeName, source := source } - pure (.This, { val := .UserDefined typeId, source := source }) - | none => - let diag := diagnosticFromSource source "'this' is not allowed outside instance methods" - modify fun s => { s with errors := s.errors.push diag } - pure (.This, { val := .Unknown, source := source }) + synthPrimitiveOp exprMd expr op args source h_expr (by rw [h_node]) + | .New ref => synthNew ref source + | .This => synthThis source | .ReferenceEquals lhs rhs => - let (lhs', lhsTy) ← synthStmtExpr lhs - let (rhs', rhsTy) ← synthStmtExpr rhs - let ctx := (← get).typeContext - unless isReference ctx lhsTy do - typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy - unless isReference ctx rhsTy do - typeMismatch rhs'.source (some expr) "expected a reference type" rhsTy - pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) + synthRefEq exprMd expr lhs rhs source h_expr (by rw [h_node]) | .AsType target ty => - let (target', _) ← synthStmtExpr target - let ty' ← resolveHighType ty - pure (.AsType target' ty', ty') + synthAsType exprMd target ty (by rw [h_node]) | .IsType target ty => - let (target', _) ← synthStmtExpr target - let ty' ← resolveHighType ty - pure (.IsType target' ty', { val := .TBool, source := source }) + synthIsType exprMd target ty source (by rw [h_node]) | .InstanceCall target callee args => - let (target', _) ← synthStmtExpr target - let callee' ← resolveRef callee source - (expected := #[.instanceProcedure, .staticProcedure]) - let results ← args.mapM synthStmtExpr - let args' := results.map (·.1) - let argTypes := results.map (·.2) - let (retTy, paramTypes) ← getCallInfo callee - -- Skip first param (self) when matching args. - let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] - for ((a, aTy), paramTy) in (args'.zip argTypes).zip callParamTypes do - checkSubtype a.source paramTy aTy - pure (.InstanceCall target' callee' args', retTy) + synthInstanceCall exprMd target callee args source (by rw [h_node]) | .Quantifier mode param trigger body => - withScope do - let paramTy' ← resolveHighType param.type - let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') - let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do - let (e', _) ← synthStmtExpr pv.val; pure e') - let body' ← checkStmtExpr body { val := .TBool, source := body.source } - pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) + synthQuantifier exprMd mode param trigger body source (by rw [h_node]) | .Assigned name => - let (name', _) ← synthStmtExpr name - pure (.Assigned name', { val := .TBool, source := source }) + synthAssigned exprMd name source (by rw [h_node]) | .Old val => - let (val', valTy) ← synthStmtExpr val - pure (.Old val', valTy) + synthOld exprMd val (by rw [h_node]) | .Fresh val => - let (val', valTy) ← synthStmtExpr val - unless isReference (← get).typeContext valTy do - typeMismatch val'.source (some expr) "expected a reference type" valTy - pure (.Fresh val', { val := .TBool, source := source }) + synthFresh exprMd expr val source h_expr (by rw [h_node]) | .Assert ⟨condExpr, summary⟩ => - let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } - pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) + synthAssert exprMd condExpr summary source (by rw [h_node]) | .Assume cond => - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } - pure (.Assume cond', { val := .TVoid, source := source }) + synthAssume exprMd cond source (by rw [h_node]) | .ProveBy val proof => - let (val', valTy) ← synthStmtExpr val - let (proof', _) ← synthStmtExpr proof - pure (.ProveBy val' proof', valTy) + synthProveBy exprMd val proof (by rw [h_node]) | .ContractOf ty fn => - -- `fn` must be a direct identifier reference resolving to a procedure. - -- Anything else (arbitrary expressions, references to non-procedures) is - -- ill-formed: a contract belongs to a *named* procedure. - let (fn', _) ← synthStmtExpr fn - let s ← get - let fnIsProcRef : Bool := match fn'.val with - | .Var (.Local ref) => - match s.scope.get? ref.text with - | some (_, node) => - node.kind == .staticProcedure || - node.kind == .instanceProcedure || - node.kind == .unresolved - | none => true -- unresolved name already reported - | _ => false - unless fnIsProcRef do - let diag := diagnosticFromSource fn.source - "'contractOf' expected a procedure reference" - modify fun s => { s with errors := s.errors.push diag } - -- Result type: Bool for pre/postconditions, set of heap references for - -- reads/modifies. The element type of the set is left as Unknown for now - -- since the rule doesn't recover it from `fn`. - let resultTy : HighType := match ty with - | .Precondition | .PostCondition => .TBool - | .Reads | .Modifies => .TSet { val := .Unknown, source := none } - pure (.ContractOf ty fn', { val := resultTy, source := source }) - | .Abstract => pure (.Abstract, { val := .Unknown, source := source }) - | .All => pure (.All, { val := .Unknown, source := source }) - | .Hole det type => match type with - | some ty => - let ty' ← resolveHighType ty - pure (.Hole det ty', ty') - | none => pure (.Hole det none, { val := .Unknown, source := source }) + synthContractOf exprMd ty fn source (by rw [h_node]) + | .Abstract => pure (synthAbstract source) + | .All => pure (synthAll source) + | .Hole det type => synthHole det type source return ({ val := val', source := source }, ty) - termination_by (exprMd, 0) + termination_by (exprMd, 2) decreasing_by all_goals first | (apply Prod.Lex.left; term_by_mem) + | (try subst h_node; apply Prod.Lex.right; decide) | (apply Prod.Lex.right; decide) -/-- Check-mode resolution: resolve `e` and verify its type is a consistent - subtype of `expected`. Bidirectional rules for individual constructs push - `expected` into subexpressions; everything else falls back to subsumption - (synth, then `isConsistentSubtype actual expected`). -/ +/-- Check-mode resolution (rule **Sub** at the boundary): resolve `e` and + verify its type is a consistent subtype of `expected`. Bidirectional rules + for individual constructs push `expected` into subexpressions; everything + else falls back to subsumption (synth, then `isConsistentSubtype actual + expected`). -/ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do - match _: exprMd with + match h_node: exprMd with | AstNode.mk expr source => - match _: expr with + match h_expr: expr with | .Block stmts label => - -- Bespoke check rule: discard non-last statement types (lax), push - -- `expected` into the last statement. Empty block reduces to subsumption - -- of TVoid against `expected`. - withScope do - let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do - have : s ∈ stmts := List.dropLast_subset stmts hMem - let (s', _) ← synthStmtExpr s; pure s') - match _lastResult: stmts.getLast? with - | none => - checkSubtype source expected { val := .TVoid, source := source } - pure { val := .Block init' label, source := source } - | some last => - have := List.mem_of_getLast? _lastResult - let last' ← checkStmtExpr last expected - pure { val := .Block (init' ++ [last']) label, source := source } + checkBlock exprMd stmts label expected source (by rw [h_node]) | .IfThenElse cond thenBr elseBr => - -- Push `expected` into both branches (rather than going through the synth - -- rule + Sub at the boundary). Without an else branch, fall back to - -- subsumption of TVoid against `expected`. - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } - let thenBr' ← checkStmtExpr thenBr expected - let elseBr' ← elseBr.attach.mapM (fun ⟨e, _⟩ => checkStmtExpr e expected) - if elseBr.isNone then - checkSubtype source expected { val := .TVoid, source := source } - pure { val := .IfThenElse cond' thenBr' elseBr', source := source } - | .Hole det none => - -- Untyped hole in check mode: record the expected type on the node so - -- downstream passes don't have to infer it again. Subsumption is trivial - -- (Unknown <: T always holds). - pure { val := .Hole det (some expected), source := source } + checkIfThenElse exprMd cond thenBr elseBr expected source (by rw [h_node]) + | .Hole det none => pure (checkHoleNone det expected source) | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← synthStmtExpr exprMd checkSubtype source expected actual pure e' - termination_by (exprMd, 1) + termination_by (exprMd, 3) decreasing_by all_goals first | (apply Prod.Lex.left; term_by_mem) | (try subst_eqs; apply Prod.Lex.right; decide) + | (try subst h_node; apply Prod.Lex.right; decide) + | (apply Prod.Lex.right; decide) + +-- ### Literals + +/-- Rule **Lit-Int**: `Γ ⊢ LiteralInt n ⇒ TInt`. -/ +def synthLitInt (v : Int) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.LiteralInt v, { val := .TInt, source := source }) + +/-- Rule **Lit-Bool**: `Γ ⊢ LiteralBool b ⇒ TBool`. -/ +def synthLitBool (v : Bool) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.LiteralBool v, { val := .TBool, source := source }) + +/-- Rule **Lit-String**: `Γ ⊢ LiteralString s ⇒ TString`. -/ +def synthLitString (v : String) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.LiteralString v, { val := .TString, source := source }) + +/-- Rule **Lit-Decimal**: `Γ ⊢ LiteralDecimal d ⇒ TReal`. -/ +def synthLitDecimal (v : Decimal) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.LiteralDecimal v, { val := .TReal, source := source }) + +-- ### Variables + +/-- Rule **Var-Local**: `Γ(x) = T ⊢ Var (.Local x) ⇒ T`. Resolves `ref` against + the lexical scope and reads its declared type. -/ +def synthVarLocal (ref : Identifier) (source : Option FileRange) : + ResolveM (StmtExpr × HighTypeMd) := do + let ref' ← resolveRef ref source + let ty ← getVarType ref + pure (.Var (.Local ref'), ty) + +/-- Rule **Var-Declare**: extends the surrounding scope with `x : T` and + synthesizes `TVoid` (the declaration itself produces no value). -/ +def synthVarDeclare (param : Parameter) (source : Option FileRange) : + ResolveM (StmtExpr × HighTypeMd) := do + let ty' ← resolveHighType param.type + let name' ← defineNameCheckDup param.name (.var param.name ty') + pure (.Var (.Declare ⟨name', ty'⟩), { val := .TVoid, source := source }) + +/-- Rule **Var-Field**: `Γ ⊢ e ⇒ _, Γ(f) = T_f ⊢ Var (.Field e f) ⇒ T_f`. + `f` is looked up against the type of `e` (or the enclosing instance type + for `self.f`); the typing rule itself is path-agnostic. -/ +def synthVarField (exprMd : StmtExprMd) + (target : StmtExprMd) (fieldName : Identifier) (source : Option FileRange) + (h : exprMd.val = .Var (.Field target fieldName)) : + ResolveM (StmtExpr × HighTypeMd) := do + let (target', _) ← synthStmtExpr target + let fieldName' ← resolveFieldRef target' fieldName source + let ty ← getVarType fieldName' + pure (.Var (.Field target' fieldName'), ty) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +-- ### Control flow + +/-- Rules **If-NoElse** / **If-Synth**: `cond` is checked against `TBool`. + With no else branch, the construct is a statement — `thenBr` is checked + against `TVoid` and the result is `TVoid`, so `if c then 5` is rejected. + With an else branch, the then-branch's synthesized type is returned; the + two branches are *not* compared against each other, since a statement- + position `if` often pairs a value branch with `return`/`exit`/`assert`. -/ +def synthIfThenElse (exprMd : StmtExprMd) + (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) + (h : exprMd.val = .IfThenElse cond thenBr elseBr) : + ResolveM (StmtExpr × HighTypeMd) := do + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let voidTy : HighTypeMd := { val := .TVoid, source := exprMd.source } + match elseBr with + | none => + let thenBr' ← checkStmtExpr thenBr voidTy + pure (.IfThenElse cond' thenBr' none, voidTy) + | some e => + let (thenBr', thenTy) ← synthStmtExpr thenBr + let (elseBr', _) ← synthStmtExpr e + pure (.IfThenElse cond' thenBr' (some elseBr'), thenTy) + termination_by (exprMd, 1) + decreasing_by + all_goals first + | (apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + try omega) + | (apply Prod.Lex.right; decide) + +/-- Rules **Block-Synth** / **Block-Synth-Empty**: non-last statements are + synthesized but their types discarded (the lax rule, matching + Java/Python/JS expression-statement semantics); the last statement's type + becomes the block's type, or `TVoid` for an empty block. The block opens + a fresh nested scope. -/ +def synthBlock (exprMd : StmtExprMd) + (stmts : List StmtExprMd) (label : Option String) + (h : exprMd.val = .Block stmts label) : + ResolveM (StmtExpr × HighTypeMd) := do + withScope do + let results ← stmts.mapM synthStmtExpr + let stmts' := results.map (·.1) + let lastTy := match results.getLast? with + | some (_, ty) => ty + | none => { val := .TVoid, source := exprMd.source } + pure (.Block stmts' label, lastTy) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + have := List.sizeOf_lt_of_mem ‹_ ∈ stmts› + omega + +/-- Rule **While**: `cond ⇐ TBool`, each invariant `⇐ TBool`, optional + `decreases` is resolved without a type check (intended target is numeric), + body is synthesized; the construct itself synthesizes `TVoid`. -/ +def synthWhile (exprMd : StmtExprMd) + (cond : StmtExprMd) (invs : List StmtExprMd) + (dec : Option StmtExprMd) (body : StmtExprMd) + (h : exprMd.val = .While cond invs dec body) : + ResolveM (StmtExpr × HighTypeMd) := do + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let invs' ← invs.attach.mapM (fun a => have := a.property; do + checkStmtExpr a.val { val := .TBool, source := a.val.source }) + let dec' ← dec.attach.mapM (fun a => have := a.property; do + let (e', _) ← synthStmtExpr a.val; pure e') + let (body', _) ← synthStmtExpr body + pure (.While cond' invs' dec' body', { val := .TVoid, source := exprMd.source }) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try (have := List.sizeOf_lt_of_mem ‹_ ∈ invs›) + try simp_all + omega + +/-- Rule **Exit**: `Γ ⊢ Exit target ⇒ TVoid`. -/ +def synthExit (target : String) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.Exit target, { val := .TVoid, source := source }) + +/-- Rules **Return-None** / **Return-Some** / **Return-Void-Error** / + **Return-Multi-Error**: matches the optional return value against the + enclosing procedure's declared outputs (`expectedReturnTypes`). `none` + means "no enclosing procedure" — e.g. resolving a constant initializer — + and skips all `Return` checks. -/ +def synthReturn (exprMd : StmtExprMd) (source : Option FileRange) + (val : Option StmtExprMd) + (h : exprMd.val = .Return val) : + ResolveM (StmtExpr × HighTypeMd) := do + let expected := (← get).expectedReturnTypes + let val' ← val.attach.mapM (fun a => have := a.property; do + match expected with + | some [singleOutput] => checkStmtExpr a.val singleOutput + | _ => let (e', _) ← synthStmtExpr a.val; pure e') + -- Arity/shape diagnostics independent of the value's own type. + match val, expected with + | none, some [] => pure () + | none, some [_] => pure () -- Dafny-style early exit + | none, some _ => pure () -- multi-output: bare return is fine + | some _, some [] => + let diag := diagnosticFromSource source + "void procedure cannot return a value" + modify fun s => { s with errors := s.errors.push diag } + | some _, some [_] => pure () -- value already checked above + | some _, some _ => + let diag := diagnosticFromSource source + "multi-output procedure cannot use 'return e'; assign to named outputs instead" + modify fun s => { s with errors := s.errors.push diag } + | _, none => pure () -- no enclosing procedure + pure (.Return val', { val := .TVoid, source := source }) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + simp_all + omega + +/-- Rules **Block-Check** / **Block-Check-Empty**: pushes `expected` into the + *last* statement rather than comparing the block's synthesized type at the + boundary. Errors fire at the offending subexpression, and `T` keeps + propagating through nested `Block` / `IfThenElse` / `Hole` / `Quantifier`. + Empty blocks reduce to a subsumption check of `TVoid` against `expected`. -/ +def checkBlock (exprMd : StmtExprMd) + (stmts : List StmtExprMd) (label : Option String) + (expected : HighTypeMd) (source : Option FileRange) + (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do + withScope do + let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do + have : s ∈ stmts := List.dropLast_subset stmts hMem + let (s', _) ← synthStmtExpr s; pure s') + match _lastResult: stmts.getLast? with + | none => + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .Block init' label, source := source } + | some last => + have := List.mem_of_getLast? _lastResult + let last' ← checkStmtExpr last expected + pure { val := .Block (init' ++ [last']) label, source := source } + termination_by (exprMd, 0) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try (have := List.sizeOf_lt_of_mem ‹_ ∈ stmts›) + try simp_all + omega + +/-- Rules **If-Check** / **If-Check-NoElse**: pushes `expected` into both + branches (rather than going through If-Synth + Sub at the boundary). + Errors fire at the offending branch instead of the surrounding `if`. + Without an else branch, the construct can only succeed when `T` admits + `TVoid`. -/ +def checkIfThenElse (exprMd : StmtExprMd) + (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) + (expected : HighTypeMd) (source : Option FileRange) + (h : exprMd.val = .IfThenElse cond thenBr elseBr) : ResolveM StmtExprMd := do + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let thenBr' ← checkStmtExpr thenBr expected + let elseBr' ← elseBr.attach.mapM (fun ⟨e, _⟩ => checkStmtExpr e expected) + if elseBr.isNone then + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .IfThenElse cond' thenBr' elseBr', source := source } + termination_by (exprMd, 0) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +-- ### Verification statements + +/-- Rule **Assert**: `cond` is checked against `TBool`; the construct + synthesizes `TVoid`. -/ +def synthAssert (exprMd : StmtExprMd) + (condExpr : StmtExprMd) (summary : Option String) (source : Option FileRange) + (h : exprMd.val = .Assert ⟨condExpr, summary⟩) : + ResolveM (StmtExpr × HighTypeMd) := do + let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } + pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +/-- Rule **Assume**: `cond` is checked against `TBool`; the construct + synthesizes `TVoid`. -/ +def synthAssume (exprMd : StmtExprMd) + (cond : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .Assume cond) : + ResolveM (StmtExpr × HighTypeMd) := do + let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + pure (.Assume cond', { val := .TVoid, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +-- ### Assignment + +/-- Rule **Assign**: each target's declared type `T_i` (from `Local`, + `Field`, or fresh `Declare`) is collapsed into a tuple `ExpectedTy` + (single type if one target, otherwise `MultiValuedExpr [T_1; …; T_n]`) + and checked against the RHS's synthesized type. When the RHS is a + statement (`TVoid`) — `while`, `return`, … — all checks are skipped: + there's no value to assign. -/ +def synthAssign (exprMd : StmtExprMd) + (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .Assign targets value) : + ResolveM (StmtExpr × HighTypeMd) := do + let targets' ← targets.attach.mapM fun ⟨v, _⟩ => do + let ⟨vv, vs⟩ := v + match vv with + | .Local ref => + let ref' ← resolveRef ref source + pure (⟨.Local ref', vs⟩ : VariableMd) + | .Field target fieldName => + let (target', _) ← synthStmtExpr target + let fieldName' ← resolveFieldRef target' fieldName source + pure (⟨.Field target' fieldName', vs⟩ : VariableMd) + | .Declare param => + let ty' ← resolveHighType param.type + let name' ← defineNameCheckDup param.name (.var param.name ty') + pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) + let (value', valueTy) ← synthStmtExpr value + let targetType (t : VariableMd) : ResolveM HighTypeMd := do + match t.val with + | .Local ref => getVarType ref + | .Declare param => pure param.type + | .Field _ fieldName => getVarType fieldName + if valueTy.val != HighType.TVoid then + let targetTys ← targets'.mapM targetType + let expectedTy : HighTypeMd := match targetTys with + | [single] => single + | _ => { val := .MultiValuedExpr targetTys, source := source } + checkSubtype source expectedTy valueTy + pure (.Assign targets' value', valueTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + try (have := List.sizeOf_lt_of_mem ‹_ ∈ targets›; simp_all) + omega + +-- ### Calls + +/-- Rules **Static-Call** / **Static-Call-Multi**: callee is resolved against + the expected kinds (parameter, static procedure, datatype constructor, + constant); each argument is synthesized and checked against the + corresponding parameter type. The result type is the (possibly + multi-valued) declared output type from `getCallInfo`. -/ +def synthStaticCall (exprMd : StmtExprMd) + (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .StaticCall callee args) : + ResolveM (StmtExpr × HighTypeMd) := do + let callee' ← resolveRef callee source + (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) + let results ← args.mapM synthStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let (retTy, paramTypes) ← getCallInfo callee + for ((a, aTy), paramTy) in (args'.zip argTypes).zip paramTypes do + checkSubtype a.source paramTy aTy + pure (.StaticCall callee' args', retTy) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + have := List.sizeOf_lt_of_mem ‹_ ∈ args› + omega + +/-- Rule **Instance-Call**: target is synthesized; callee resolves to an + instance or static procedure; arguments are checked pairwise against the + callee's parameter types after dropping `self`. -/ +def synthInstanceCall (exprMd : StmtExprMd) + (target : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) + (source : Option FileRange) + (h : exprMd.val = .InstanceCall target callee args) : + ResolveM (StmtExpr × HighTypeMd) := do + let (target', _) ← synthStmtExpr target + let callee' ← resolveRef callee source + (expected := #[.instanceProcedure, .staticProcedure]) + let results ← args.mapM synthStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let (retTy, paramTypes) ← getCallInfo callee + let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] + for ((a, aTy), paramTy) in (args'.zip argTypes).zip callParamTypes do + checkSubtype a.source paramTy aTy + pure (.InstanceCall target' callee' args', retTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try (have := List.sizeOf_lt_of_mem ‹_ ∈ args›) + try simp_all + omega + +-- ### Primitive operations + +/-- Rules **Op-Bool** / **Op-Cmp** / **Op-Eq** / **Op-Arith** / **Op-Concat**: + each operator family has its own argument-type discipline and result + type. Arguments are synthesized first, then the per-family check fires + (`⇐ TBool` for booleans, `Numeric` for arithmetic/comparison, consistency + `~` for equality, `⇐ TString` for concatenation). The result type is + `TBool` for booleans/comparisons/equality, the head argument's type for + arithmetic, `TString` for concatenation. -/ +def synthPrimitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) + (op : Operation) (args : List StmtExprMd) (source : Option FileRange) + (h_expr : expr = .PrimitiveOp op args) + (h : exprMd.val = .PrimitiveOp op args) : + ResolveM (StmtExpr × HighTypeMd) := do + let _ := h_expr -- carries the constructor identity for `expr` in diagnostics + let results ← args.mapM synthStmtExpr + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let resultTy := match op with + | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies + | .Lt | .Leq | .Gt | .Geq => HighType.TBool + | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => + match argTypes.head? with + | some headTy => headTy.val + | none => HighType.TInt + | .StrConcat => HighType.TString + match op with + | .And | .Or | .AndThen | .OrElse | .Not | .Implies => + for (a, aTy) in args'.zip argTypes do + checkSubtype a.source { val := .TBool, source := a.source } aTy + | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => + let ctx := (← get).typeContext + for (a, aTy) in args'.zip argTypes do + unless isNumeric ctx aTy do + typeMismatch a.source (some expr) "expected a numeric type" aTy + | .Eq | .Neq => + match argTypes with + | [lhsTy, rhsTy] => + let ctx := (← get).typeContext + unless isConsistent ctx lhsTy rhsTy do + let diag := diagnosticFromSource source + s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" + modify fun s => { s with errors := s.errors.push diag } + | _ => pure () + | .StrConcat => + for (a, aTy) in args'.zip argTypes do + checkSubtype a.source { val := .TString, source := a.source } aTy + pure (.PrimitiveOp op args', { val := resultTy, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + have := List.sizeOf_lt_of_mem ‹_ ∈ args› + omega + +-- ### Object forms + +/-- Rules **New-Ok** / **New-Fallback**: when `ref` resolves to a composite or + datatype, the type is `UserDefined ref`; otherwise `Unknown` (suppresses + cascading errors after the kind diagnostic has already fired). -/ +def synthNew (ref : Identifier) (source : Option FileRange) : + ResolveM (StmtExpr × HighTypeMd) := do + let ref' ← resolveRef ref source + (expected := #[.compositeType, .datatypeDefinition]) + let s ← get + let kindOk : Bool := match s.scope.get? ref.text with + | some (_, node) => node.kind == .unresolved || + (#[ResolvedNodeKind.compositeType, .datatypeDefinition].contains node.kind) + | none => true + let ty := if kindOk then { val := HighType.UserDefined ref', source := source } + else { val := HighType.Unknown, source := source } + pure (.New ref', ty) + +/-- Rule **AsType**: `target` is resolved but not checked against `T` — the + cast is the user's claim. The synthesized type is `T`. -/ +def synthAsType (exprMd : StmtExprMd) + (target : StmtExprMd) (ty : HighTypeMd) + (h : exprMd.val = .AsType target ty) : + ResolveM (StmtExpr × HighTypeMd) := do + let (target', _) ← synthStmtExpr target + let ty' ← resolveHighType ty + pure (.AsType target' ty', ty') + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **IsType**: `target` is resolved; the synthesized type is `TBool`. -/ +def synthIsType (exprMd : StmtExprMd) + (target : StmtExprMd) (ty : HighTypeMd) (source : Option FileRange) + (h : exprMd.val = .IsType target ty) : + ResolveM (StmtExpr × HighTypeMd) := do + let (target', _) ← synthStmtExpr target + let ty' ← resolveHighType ty + pure (.IsType target' ty', { val := .TBool, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **RefEq**: both operands must be reference types (`UserDefined` or + `Unknown`). Reference equality is meaningless on primitives. -/ +def synthRefEq (exprMd : StmtExprMd) (expr : StmtExpr) + (lhs rhs : StmtExprMd) (source : Option FileRange) + (h_expr : expr = .ReferenceEquals lhs rhs) + (h : exprMd.val = .ReferenceEquals lhs rhs) : + ResolveM (StmtExpr × HighTypeMd) := do + let _ := h_expr + let (lhs', lhsTy) ← synthStmtExpr lhs + let (rhs', rhsTy) ← synthStmtExpr rhs + let ctx := (← get).typeContext + unless isReference ctx lhsTy do + typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy + unless isReference ctx rhsTy do + typeMismatch rhs'.source (some expr) "expected a reference type" rhsTy + pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **PureFieldUpdate**: `target` is synthesized, `f` resolved against + `T_t` (or the enclosing instance type), and `newVal` checked against the + field's declared type. The synthesized type is `T_t` — updating a field + on a pure type produces a new value of the same type. -/ +def synthPureFieldUpdate (exprMd : StmtExprMd) + (target : StmtExprMd) (fieldName : Identifier) (newVal : StmtExprMd) + (h : exprMd.val = .PureFieldUpdate target fieldName newVal) : + ResolveM (StmtExpr × HighTypeMd) := do + let (target', targetTy) ← synthStmtExpr target + let fieldName' ← resolveFieldRef target' fieldName target.source + let fieldTy ← getVarType fieldName' + let newVal' ← checkStmtExpr newVal fieldTy + pure (.PureFieldUpdate target' fieldName' newVal', targetTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +-- ### Verification expressions + +/-- Rule **Quantifier**: opens a fresh scope, binds `x : T`, resolves the + optional trigger, and checks the body against `TBool`. The construct + itself synthesizes `TBool` since a quantifier is a proposition. -/ +def synthQuantifier (exprMd : StmtExprMd) + (mode : QuantifierMode) (param : Parameter) + (trigger : Option StmtExprMd) (body : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .Quantifier mode param trigger body) : + ResolveM (StmtExpr × HighTypeMd) := do + withScope do + let paramTy' ← resolveHighType param.type + let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') + let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do + let (e', _) ← synthStmtExpr pv.val; pure e') + let body' ← checkStmtExpr body { val := .TBool, source := body.source } + pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +/-- Rule **Assigned**: `name` is synthesized; the construct synthesizes + `TBool`. -/ +def synthAssigned (exprMd : StmtExprMd) + (name : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .Assigned name) : + ResolveM (StmtExpr × HighTypeMd) := do + let (name', _) ← synthStmtExpr name + pure (.Assigned name', { val := .TBool, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **Old**: `Γ ⊢ v ⇒ T ⊢ Old v ⇒ T`. -/ +def synthOld (exprMd : StmtExprMd) + (val : StmtExprMd) + (h : exprMd.val = .Old val) : + ResolveM (StmtExpr × HighTypeMd) := do + let (val', valTy) ← synthStmtExpr val + pure (.Old val', valTy) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **Fresh**: `v` is synthesized and must have a reference type + (`UserDefined` or `Unknown`). The construct itself synthesizes `TBool`. -/ +def synthFresh (exprMd : StmtExprMd) (expr : StmtExpr) + (val : StmtExprMd) (source : Option FileRange) + (h_expr : expr = .Fresh val) + (h : exprMd.val = .Fresh val) : + ResolveM (StmtExpr × HighTypeMd) := do + let _ := h_expr + let (val', valTy) ← synthStmtExpr val + unless isReference (← get).typeContext valTy do + typeMismatch val'.source (some expr) "expected a reference type" valTy + pure (.Fresh val', { val := .TBool, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +/-- Rule **ProveBy**: `v` and `proof` are both synthesized; the construct's + type is `v`'s type — `proof` is a hint for downstream verification. -/ +def synthProveBy (exprMd : StmtExprMd) + (val proof : StmtExprMd) + (h : exprMd.val = .ProveBy val proof) : + ResolveM (StmtExpr × HighTypeMd) := do + let (val', valTy) ← synthStmtExpr val + let (proof', _) ← synthStmtExpr proof + pure (.ProveBy val' proof', valTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +-- ### Self reference + +/-- Rules **This-Inside** / **This-Outside**: when `instanceTypeName` is set + (we're inside an instance method), `This` synthesizes `UserDefined T`; + otherwise an error is emitted and the type collapses to `Unknown`. -/ +def synthThis (source : Option FileRange) : + ResolveM (StmtExpr × HighTypeMd) := do + let s ← get + match s.instanceTypeName with + | some typeName => + let typeId : Identifier := + match s.scope.get? typeName with + | some (uid, _) => { text := typeName, uniqueId := some uid, source := source } + | none => { text := typeName, source := source } + pure (.This, { val := .UserDefined typeId, source := source }) + | none => + let diag := diagnosticFromSource source "'this' is not allowed outside instance methods" + modify fun s => { s with errors := s.errors.push diag } + pure (.This, { val := .Unknown, source := source }) + +-- ### Untyped forms + +/-- Rule **Abstract**: synthesizes `Unknown`. -/ +def synthAbstract (source : Option FileRange) : StmtExpr × HighTypeMd := + (.Abstract, { val := .Unknown, source := source }) + +/-- Rule **All**: synthesizes `Unknown`. -/ +def synthAll (source : Option FileRange) : StmtExpr × HighTypeMd := + (.All, { val := .Unknown, source := source }) + +-- ### ContractOf + +/-- Rules **ContractOf-Bool** / **ContractOf-Set** / **ContractOf-Error**: + `fn` must be a direct identifier reference resolving to a procedure; + anything else is ill-formed (a contract belongs to a *named* procedure). + Pre/postconditions are propositions (`TBool`); reads/modifies are sets of + heap references with element type `Unknown` for now. -/ +def synthContractOf (exprMd : StmtExprMd) + (ty : ContractType) (fn : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .ContractOf ty fn) : + ResolveM (StmtExpr × HighTypeMd) := do + let (fn', _) ← synthStmtExpr fn + let s ← get + let fnIsProcRef : Bool := match fn'.val with + | .Var (.Local ref) => + match s.scope.get? ref.text with + | some (_, node) => + node.kind == .staticProcedure || + node.kind == .instanceProcedure || + node.kind == .unresolved + | none => true -- unresolved name already reported + | _ => false + unless fnIsProcRef do + let diag := diagnosticFromSource fn.source + "'contractOf' expected a procedure reference" + modify fun s => { s with errors := s.errors.push diag } + let resultTy : HighType := match ty with + | .Precondition | .PostCondition => .TBool + | .Reads | .Modifies => .TSet { val := .Unknown, source := none } + pure (.ContractOf ty fn', { val := resultTy, source := source }) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + +-- ### Holes + +/-- Rules **Hole-Some** / **Hole-None-Synth**: a typed hole synthesizes its + annotation; an untyped hole in synth position synthesizes `Unknown`. -/ +def synthHole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange) : + ResolveM (StmtExpr × HighTypeMd) := do + match type with + | some ty => + let ty' ← resolveHighType ty + pure (.Hole det ty', ty') + | none => pure (.Hole det none, { val := .Unknown, source := source }) + +/-- Rule **Hole-None-Check**: an untyped hole in check mode records the + expected type on the node so downstream passes don't have to infer it + again. The subsumption check is trivial (`Unknown <: T` always holds), so + this rule never fails — it just preserves the type information available + at the check-mode boundary. -/ +def checkHoleNone (det : Bool) (expected : HighTypeMd) (source : Option FileRange) : + StmtExprMd := + { val := .Hole det (some expected), source := source } + end /-- Resolve a statement expression, discarding the synthesized type. From 3d61b0479dd1b8e35cafd1d7da449635e5f6fc28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 15:30:24 -0400 Subject: [PATCH 107/189] better if-then-else typing discipline --- docs/verso/LaurelDoc.lean | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 140a250407..a53f161f60 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -302,11 +302,12 @@ remainder of the enclosing scope. ### Control flow -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-NoElse)}` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow \mathsf{TVoid}}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-NoElse)}` The construct synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no -value when `cond` is false; without this, `x : int := if c then 5` would type-check -spuriously. +value when `cond` is false; the then-branch is checked against +{name Strata.Laurel.HighType.TVoid}`TVoid` so `x : int := if c then 5` is rejected at the +branch rather than slipping through to a downstream subsumption. $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t} \quad \text{([⇒] If)}` From 665e88f3c01a419f8da2eaa117b3764673251693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 16:38:23 -0400 Subject: [PATCH 108/189] if then else type synthesis --- Strata/Languages/Laurel/Laurel.lean | 37 +++++++++++++++++++ Strata/Languages/Laurel/Resolution.lean | 15 +++++--- .../Laurel/ResolutionTypeCheckTests.lean | 21 +++++++++++ docs/verso/LaurelDoc.lean | 15 +++++--- 4 files changed, 77 insertions(+), 11 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index c5f5dede7c..771788958a 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -559,6 +559,43 @@ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := def isConsistentSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := isConsistent ctx sub sup || isSubtype ctx sub sup +/-- BFS through `extendingMap` starting from `name` and stopping at the first + type that is also in `targetAncestors`. Used by `joinTypes` to find a + common ancestor between two composites; `visited` cuts off cycles. -/ +partial def TypeContext.firstCommonAncestor (ctx : TypeContext) + (name : String) (targetAncestors : Std.HashSet String) : Option String := + let rec go (frontier : List String) (visited : Std.HashSet String) : Option String := + match frontier with + | [] => none + | n :: rest => + if visited.contains n then go rest visited + else if targetAncestors.contains n then some n + else + let parents := (ctx.extendingMap.get? n).getD [] + go (rest ++ parents) (visited.insert n) + go [name] {} + +/-- Least upper bound for the if-then-else synthesis rule. When `a` and `b` + are subtype-related, returns the larger; for unrelated composites, walks + `extending` chains for the first common ancestor. When no common + supertype exists (e.g. unrelated primitives, or a value branch paired + with a `TVoid` `return`/`exit`), falls back to `a` — the enclosing + context's `checkSubtype` then surfaces any mismatch against the + then-branch's type, preserving the historical statement-form behavior. -/ +def joinTypes (ctx : TypeContext) (a b : HighTypeMd) : HighTypeMd := + if isConsistentSubtype ctx a b then b + else if isConsistentSubtype ctx b a then a + else + let a' := ctx.unfold a + let b' := ctx.unfold b + match a'.val, b'.val with + | .UserDefined aName, .UserDefined bName => + match ctx.firstCommonAncestor aName.text (ctx.ancestors bName.text) with + | some name => + { val := .UserDefined { text := name, source := none }, source := a.source } + | none => a + | _, _ => a + def HighType.isBool : HighType → Bool | TBool => true | _ => false diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 80982bfa59..75bbbabb0d 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -713,9 +713,13 @@ def synthVarField (exprMd : StmtExprMd) /-- Rules **If-NoElse** / **If-Synth**: `cond` is checked against `TBool`. With no else branch, the construct is a statement — `thenBr` is checked against `TVoid` and the result is `TVoid`, so `if c then 5` is rejected. - With an else branch, the then-branch's synthesized type is returned; the - two branches are *not* compared against each other, since a statement- - position `if` often pairs a value branch with `return`/`exit`/`assert`. -/ + With an else branch, the result type is the join (LUB) of the two + branches' synthesized types, so `if c then new Left else new Right` + synthesizes the common ancestor `Top` rather than committing to one + branch arbitrarily. When no common supertype exists (e.g. a value branch + paired with a `TVoid` `return`/`exit`), `joinTypes` falls back to the + then-branch's type and the enclosing context's check surfaces any + mismatch downstream. -/ def synthIfThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (h : exprMd.val = .IfThenElse cond thenBr elseBr) : @@ -728,8 +732,9 @@ def synthIfThenElse (exprMd : StmtExprMd) pure (.IfThenElse cond' thenBr' none, voidTy) | some e => let (thenBr', thenTy) ← synthStmtExpr thenBr - let (elseBr', _) ← synthStmtExpr e - pure (.IfThenElse cond' thenBr' (some elseBr'), thenTy) + let (elseBr', elseTy) ← synthStmtExpr e + let ctx := (← get).typeContext + pure (.IfThenElse cond' thenBr' (some elseBr'), joinTypes ctx thenTy elseTy) termination_by (exprMd, 1) decreasing_by all_goals first diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index b78f3b22df..c674bf0fe4 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -194,4 +194,25 @@ procedure test() opaque { #guard_msgs (error, drop all) in #eval testInputWithOffset "UserDefinedCrossType" userDefinedCrossType 170 processResolution +/-! ## If-then-else branch join + +When the two branches have different but subtype-related types, the construct +synthesizes their join (least upper bound) — not the then-branch arbitrarily. +So `if c then new Left else new Right`, with `Left, Right <: Top`, synthesizes +`Top` and an assignment to a `Left`-typed variable is rejected. -/ + +def ifBranchJoinToCommonAncestor := r" +composite Top { } +composite Left extends Top { } +composite Right extends Top { } +procedure test(c: bool) opaque { + var x: Top := if c then new Left else new Right; + var y: Left := if c then new Left else new Right +//^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: expected 'Left', got 'Top' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "IfBranchJoinToCommonAncestor" ifBranchJoinToCommonAncestor 198 processResolution + end Laurel diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index a53f161f60..e87db76d31 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -309,12 +309,15 @@ value when `cond` is false; the then-branch is checked against {name Strata.Laurel.HighType.TVoid}`TVoid` so `x : int := if c then 5` is rejected at the branch rather than slipping through to a downstream subsumption. -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t} \quad \text{([⇒] If)}` - -Picks the then-branch type arbitrarily; the two branches are *not* compared, since a -statement-position `if` often pairs a value branch with a `return`/`exit`/`assert`. The -enclosing context's check (\[⇐\] Sub, or a containing `checkSubtype` like an assignment) -provides the actual check downstream. +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t \sqcup T_e} \quad \text{([⇒] If)}` + +The result is the join (least upper bound) of the two branch types, so +`if c then small else big` synthesizes the common supertype rather than committing to one +branch arbitrarily. The join walks `extending` chains for composites; when no common +supertype exists (e.g. a value branch paired with a `TVoid` `return`/`exit`), it falls +back to `T_t` and the enclosing context's check (\[⇐\] Sub, or a containing +`checkSubtype` like an assignment) surfaces any mismatch downstream against the +then-branch's type. $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \Gamma \vdash \mathit{elseBr} \Leftarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Leftarrow T} \quad \text{([⇐] If)}` From 95763e243b11c554ef262d234c4f64c3659a8444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 16:40:58 -0400 Subject: [PATCH 109/189] move test to appropriate location --- .../Examples/Objects/T9_IfBranchJoin.lean | 35 +++++++++++++++++++ .../Laurel/ResolutionTypeCheckTests.lean | 21 ----------- 2 files changed, 35 insertions(+), 21 deletions(-) create mode 100644 StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean diff --git a/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean b/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean new file mode 100644 index 0000000000..9149d2e647 --- /dev/null +++ b/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean @@ -0,0 +1,35 @@ +/- + Copyright Strata Contributors + + SPDX-License-Identifier: Apache-2.0 OR MIT +-/ + +import StrataTest.Util.TestDiagnostics +import StrataTest.Languages.Laurel.TestExamples + +open StrataTest.Util + +namespace Strata +namespace Laurel + +/- +When the two branches of an `if/else` have different but subtype-related +types, the construct synthesizes their join (least upper bound) — not the +then-branch arbitrarily. So `if c then new Left else new Right`, with +`Left, Right <: Top`, synthesizes `Top`. Storing it in a `Top`-typed +variable succeeds, but storing it in a `Left`-typed variable is rejected. +-/ + +def program := r" +composite Top { } +composite Left extends Top { } +composite Right extends Top { } +procedure test(c: bool) opaque { + var x: Top := if c then new Left else new Right; + var y: Left := if c then new Left else new Right +//^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: expected 'Left', got 'Top' +}; +" + +#guard_msgs (drop info) in +#eval testInputWithOffset "IfBranchJoin" program 22 processLaurelFile diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index c674bf0fe4..b78f3b22df 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -194,25 +194,4 @@ procedure test() opaque { #guard_msgs (error, drop all) in #eval testInputWithOffset "UserDefinedCrossType" userDefinedCrossType 170 processResolution -/-! ## If-then-else branch join - -When the two branches have different but subtype-related types, the construct -synthesizes their join (least upper bound) — not the then-branch arbitrarily. -So `if c then new Left else new Right`, with `Left, Right <: Top`, synthesizes -`Top` and an assignment to a `Left`-typed variable is rejected. -/ - -def ifBranchJoinToCommonAncestor := r" -composite Top { } -composite Left extends Top { } -composite Right extends Top { } -procedure test(c: bool) opaque { - var x: Top := if c then new Left else new Right; - var y: Left := if c then new Left else new Right -//^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: expected 'Left', got 'Top' -}; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "IfBranchJoinToCommonAncestor" ifBranchJoinToCommonAncestor 198 processResolution - end Laurel From 034110555e32d23f45e3f69909adeb65ec2043f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 17:03:17 -0400 Subject: [PATCH 110/189] very strict dereference comparison --- Strata/Languages/Laurel/Resolution.lean | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 75bbbabb0d..01443dee39 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1152,6 +1152,10 @@ def synthRefEq (exprMd : StmtExprMd) (expr : StmtExpr) typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy unless isReference ctx rhsTy do typeMismatch rhs'.source (some expr) "expected a reference type" rhsTy + unless isConsistent ctx lhsTy rhsTy do + let diag := diagnosticFromSource source + s!"'{expr.constrName}' operands have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" + modify fun s => { s with errors := s.errors.push diag } pure (.ReferenceEquals lhs' rhs', { val := .TBool, source := source }) termination_by (exprMd, 1) decreasing_by From 3fbb542d1a2f17c887e5603a466479b9b0043916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 19 May 2026 17:04:00 -0400 Subject: [PATCH 111/189] consistent references when comparing --- docs/verso/LaurelDoc.lean | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index e87db76d31..34b5772920 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -453,14 +453,15 @@ $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{AsT $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` -$$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad \mathsf{isReference}\;T_l \quad \mathsf{isReference}\;T_r}{\Gamma \vdash \mathsf{ReferenceEquals}\;\mathit{lhs}\;\mathit{rhs} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] RefEq)}` +$$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad \mathsf{isReference}\;T_l \quad \mathsf{isReference}\;T_r \quad T_l \sim T_r}{\Gamma \vdash \mathsf{ReferenceEquals}\;\mathit{lhs}\;\mathit{rhs} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] RefEq)}` `isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined` or {name Strata.Laurel.HighType.Unknown}`Unknown` -type. Reference equality is meaningless on primitives. Compatibility between `T_l` and -`T_r` (e.g. rejecting `Cat === Dog` for unrelated user-defined types) is delegated to -future tightening of `<:` — today, two distinct user-defined names already mismatch -structurally, so the check would only fire under stronger subtyping. +type. Reference equality is meaningless on primitives. The operands must also be +consistent under `~` (Siek–Taha consistency), matching the rule applied by +{name Strata.Laurel.Operation.Eq}`==`: two distinct user-defined types like `Cat` and +`Dog` are rejected, while either side being `Unknown` is accepted as a gradual escape +hatch. $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow T_t \quad \Gamma(f) = T_f \quad \Gamma \vdash \mathit{newVal} \Leftarrow T_f}{\Gamma \vdash \mathsf{PureFieldUpdate}\;\mathit{target}\;f\;\mathit{newVal} \Rightarrow T_t} \quad \text{([⇒] PureFieldUpdate)}` From 25028250463e067b0c6b9d3126b44fa42b3be442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 09:33:07 -0400 Subject: [PATCH 112/189] fix assign by creating a checking rule --- Strata/Languages/Laurel/Resolution.lean | 56 ++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 01443dee39..ddaf0df040 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -640,6 +640,8 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE checkBlock exprMd stmts label expected source (by rw [h_node]) | .IfThenElse cond thenBr elseBr => checkIfThenElse exprMd cond thenBr elseBr expected source (by rw [h_node]) + | .Assign targets value => + checkAssign exprMd targets value expected source (by rw [h_node]) | .Hole det none => pure (checkHoleNone det expected source) | _ => -- Subsumption fallback: synth then check `actual <: expected`. @@ -933,7 +935,10 @@ def synthAssume (exprMd : StmtExprMd) (single type if one target, otherwise `MultiValuedExpr [T_1; …; T_n]`) and checked against the RHS's synthesized type. When the RHS is a statement (`TVoid`) — `while`, `return`, … — all checks are skipped: - there's no value to assign. -/ + there's no value to assign. The construct synthesizes the RHS's type, + so that expression-position assignments like `x ++ (y := s)` see a + string in the second operand; statement-position uses are accommodated + by `checkAssign`, which accepts `TVoid` as the expected type. -/ def synthAssign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assign targets value) : @@ -975,6 +980,55 @@ def synthAssign (exprMd : StmtExprMd) try (have := List.sizeOf_lt_of_mem ‹_ ∈ targets›; simp_all) omega +/-- Rule **Assign-Check**: an assignment in statement position (checked + against `TVoid`) discards its RHS value, so the synthesized type is not + compared against `expected`. This lets `b := 1` appear as the last + statement of a block in an else-less `if` (whose branch is checked + against `TVoid`) without firing a subsumption error against the RHS's + type. For non-`TVoid` expected types, falls back to subsumption. -/ +def checkAssign (exprMd : StmtExprMd) + (targets : List VariableMd) (value : StmtExprMd) + (expected : HighTypeMd) (source : Option FileRange) + (h : exprMd.val = .Assign targets value) : ResolveM StmtExprMd := do + let targets' ← targets.attach.mapM fun ⟨v, _⟩ => do + let ⟨vv, vs⟩ := v + match vv with + | .Local ref => + let ref' ← resolveRef ref source + pure (⟨.Local ref', vs⟩ : VariableMd) + | .Field target fieldName => + let (target', _) ← synthStmtExpr target + let fieldName' ← resolveFieldRef target' fieldName source + pure (⟨.Field target' fieldName', vs⟩ : VariableMd) + | .Declare param => + let ty' ← resolveHighType param.type + let name' ← defineNameCheckDup param.name (.var param.name ty') + pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) + let (value', valueTy) ← synthStmtExpr value + let targetType (t : VariableMd) : ResolveM HighTypeMd := do + match t.val with + | .Local ref => getVarType ref + | .Declare param => pure param.type + | .Field _ fieldName => getVarType fieldName + if valueTy.val != HighType.TVoid then + let targetTys ← targets'.mapM targetType + let assignedTy : HighTypeMd := match targetTys with + | [single] => single + | _ => { val := .MultiValuedExpr targetTys, source := source } + checkSubtype source assignedTy valueTy + unless expected.val matches .TVoid do + checkSubtype source expected valueTy + pure { val := .Assign targets' value', source := source } + termination_by (exprMd, 0) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + try (have := List.sizeOf_lt_of_mem ‹_ ∈ targets›; simp_all) + omega + -- ### Calls /-- Rules **Static-Call** / **Static-Call-Multi**: callee is resolved against From c9ce9405fd1c1bc94c267533151d40aa95a89154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 10:05:43 -0400 Subject: [PATCH 113/189] documentation is moved to in-code docstrings --- Strata/Languages/Laurel/Laurel.lean | 38 +++- Strata/Languages/Laurel/Resolution.lean | 205 +++++++++++++----- docs/verso/LaurelDoc.lean | 262 ++++++++---------------- 3 files changed, 272 insertions(+), 233 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index 771788958a..05268233df 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -531,9 +531,14 @@ partial def TypeContext.ancestors (ctx : TypeContext) (name : String) : Std.Hash go acc' (parents ++ rest) go {} [name] -/-- Subtyping. Walks `extending` chains for composites, unfolds aliases, and - unwraps constrained types to their base before falling back to structural - equality via `highEq`. -/ +/-- Pure subtyping `<:`. Walks the `extending` chain for `CompositeType` + (via `TypeContext.ancestors`), unfolds `TypeAlias` to its target, and + unwraps `ConstrainedType` to its base (both via `TypeContext.unfold`), + then falls back to structural equality via `highEq`. + + Used together with `isConsistent` to form `isConsistentSubtype`, which + is what the bidirectional checker invokes at every check-mode boundary + (rule `[⇐] Sub`). -/ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := let sub' := ctx.unfold sub let sup' := ctx.unfold sup @@ -544,9 +549,13 @@ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := (ctx.ancestors subName.text).contains supName.text || highEq sub' sup' | _, _ => highEq sub' sup' -/-- Consistency (Siek–Taha): the symmetric gradual relation. `Unknown` is the - dynamic type and is consistent with everything; otherwise structural - equality after unfolding aliases / constrained types. -/ +/-- Consistency `~` (Siek–Taha): the symmetric gradual relation. `Unknown` + is the dynamic type and is consistent with everything; otherwise + structural equality after unfolding aliases / constrained types. + + Used directly by `[⇒] Op-Eq`, where the operand types must be mutually + consistent (no subtype direction is privileged), and as one half of + `isConsistentSubtype`. -/ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := let a' := ctx.unfold a let b' := ctx.unfold b @@ -555,7 +564,22 @@ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := | _, _ => highEq a' b' /-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For our flat lattice - this collapses to `sub ~ sup ∨ sub <: sup`. -/ + this collapses to `sub ~ sup ∨ sub <: sup` — the standard collapse. + + Used by rule `[⇐] Sub` (and every bespoke check rule). That single + choice is what makes the system *gradual*: an expression of type + `Unknown` (a hole, an unresolved name, a `Hole _ none`) flows freely + into any typed slot, and any expression flows freely into a slot of + type `Unknown`. Strict checking is applied between fully-known types + only. + + A previous iteration was synth-only with two *bivariantly-compatible* + wildcards: `Unknown` and `UserDefined`. The `UserDefined` carve-out was + load-bearing: no assignment, call argument, or comparison involving a + user type was ever rejected. The bidirectional design retires that + carve-out — user-defined types are now a regular participant in `<:`, + with `isSubtype` walking inheritance chains and unwrapping aliases + and constrained types to deliver real checking on user-defined code. -/ def isConsistentSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := isConsistent ctx sub sup || isSubtype ctx sub sup diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index ddaf0df040..970e32d0ad 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -560,8 +560,20 @@ mutual -- ### Dispatch -/-- Synth-mode resolution: resolve `e` and synthesize its `HighType`. - Each constructor delegates to its rule's helper. -/ +/-- Synth-mode resolution: resolve `e` and synthesize its `HighType`, + written `Γ ⊢ e ⇒ T`. Each constructor delegates to its rule's helper. + + Synthesis returns a type inferred from the expression itself; checking + (`checkStmtExpr`) verifies that the expression has a given expected + type. Each construct picks a mode based on whether its type is + determined locally (synth) or by context (check). Synth rules invoke + check on subexpressions whose expected type is known (e.g. + `cond ⇐ TBool` in `IfThenElse`); `checkStmtExpr` falls back to + `synthStmtExpr` via subsumption (rule `[⇐] Sub`). The two functions + are mutually recursive, with termination on a lexicographic measure + `(exprMd, tag)` — tag `0` for check, `1` for synth — so that + subsumption (which calls synth on the *same* expression) can decrease + via `Prod.Lex.right`. -/ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match h_node: exprMd with | AstNode.mk expr source => @@ -628,10 +640,22 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := | (apply Prod.Lex.right; decide) /-- Check-mode resolution (rule **Sub** at the boundary): resolve `e` and - verify its type is a consistent subtype of `expected`. Bidirectional rules - for individual constructs push `expected` into subexpressions; everything - else falls back to subsumption (synth, then `isConsistentSubtype actual - expected`). -/ + verify its type is a consistent subtype of `expected`, written + `Γ ⊢ e ⇐ T`. Bidirectional rules for individual constructs (`Block`, + `IfThenElse`, `Assign`, `Hole`) push `expected` into subexpressions + rather than bouncing through synthesis, which keeps error messages + localized and lets the expected type propagate through nested control + flow. Everything else falls back to subsumption — synthesize, then + verify `isConsistentSubtype actual expected`. + + The right principle for new call sites is: when the position has a + known expected type (`TBool` for conditions, numeric for `decreases`, + the declared output for a constant initializer or a functional body), + use `checkStmtExpr`. When it doesn't, use `resolveStmtExpr` (a thin + wrapper that calls `synthStmtExpr` and discards the synthesized type, + used at sites where typing is not enforced — verification annotations, + modifies/reads clauses). `synthStmtExpr` itself is mostly an internal + interface used by other rules. -/ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do match h_node: exprMd with | AstNode.mk expr source => @@ -714,14 +738,19 @@ def synthVarField (exprMd : StmtExprMd) /-- Rules **If-NoElse** / **If-Synth**: `cond` is checked against `TBool`. With no else branch, the construct is a statement — `thenBr` is checked - against `TVoid` and the result is `TVoid`, so `if c then 5` is rejected. + against `TVoid` and the result is `TVoid`, so `x : int := if c then 5` + is rejected at the branch rather than slipping through to a downstream + subsumption. + With an else branch, the result type is the join (LUB) of the two - branches' synthesized types, so `if c then new Left else new Right` - synthesizes the common ancestor `Top` rather than committing to one - branch arbitrarily. When no common supertype exists (e.g. a value branch - paired with a `TVoid` `return`/`exit`), `joinTypes` falls back to the - then-branch's type and the enclosing context's check surfaces any - mismatch downstream. -/ + branches' synthesized types, so `if c then small else big` synthesizes + the common supertype rather than committing to one branch arbitrarily; + `if c then new Left else new Right` synthesizes the common ancestor. + When no common supertype exists (e.g. a value branch paired with a + `TVoid` `return`/`exit`), `joinTypes` falls back to the then-branch's + type and the enclosing context's check (`[⇐] Sub`, or a containing + `checkSubtype` like an assignment) surfaces any mismatch downstream + against the then-branch's type. -/ def synthIfThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (h : exprMd.val = .IfThenElse cond thenBr elseBr) : @@ -747,11 +776,14 @@ def synthIfThenElse (exprMd : StmtExprMd) try omega) | (apply Prod.Lex.right; decide) -/-- Rules **Block-Synth** / **Block-Synth-Empty**: non-last statements are - synthesized but their types discarded (the lax rule, matching - Java/Python/JS expression-statement semantics); the last statement's type +/-- Rules **Block-Synth** / **Block-Synth-Empty**: each statement is resolved + in the scope produced by its predecessor and may itself extend it + (`Var (.Declare …)` does); non-last statements are synthesized but their + types discarded (the lax rule, matching Java/Python/JS where `f(x);` is + normal even when `f` returns a value — trade-off: `5;` is silently + accepted, flagging it belongs to a lint). The last statement's type becomes the block's type, or `TVoid` for an empty block. The block opens - a fresh nested scope. -/ + a fresh nested scope, so bindings introduced inside don't escape. -/ def synthBlock (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (h : exprMd.val = .Block stmts label) : @@ -772,8 +804,9 @@ def synthBlock (exprMd : StmtExprMd) omega /-- Rule **While**: `cond ⇐ TBool`, each invariant `⇐ TBool`, optional - `decreases` is resolved without a type check (intended target is numeric), - body is synthesized; the construct itself synthesizes `TVoid`. -/ + `decreases` is resolved without a type check today (the intended target + is a numeric type), body is synthesized; the construct itself + synthesizes `TVoid`. -/ def synthWhile (exprMd : StmtExprMd) (cond : StmtExprMd) (invs : List StmtExprMd) (dec : Option StmtExprMd) (body : StmtExprMd) @@ -802,9 +835,22 @@ def synthExit (target : String) (source : Option FileRange) : StmtExpr × HighTy /-- Rules **Return-None** / **Return-Some** / **Return-Void-Error** / **Return-Multi-Error**: matches the optional return value against the - enclosing procedure's declared outputs (`expectedReturnTypes`). `none` - means "no enclosing procedure" — e.g. resolving a constant initializer — - and skips all `Return` checks. -/ + enclosing procedure's declared outputs. The expected output types are + threaded through `ResolveState.expectedReturnTypes`, set from + `proc.outputs` by `resolveProcedure` / `resolveInstanceProcedure` for + the duration of the body; `none` means "no enclosing procedure" — e.g. + resolving a constant initializer — and skips all `Return` checks. + + A bare `return;` is allowed in any context. In a single-output procedure + it acts as a Dafny-style early exit — the output parameter retains + whatever was last assigned to it. In a single-output procedure, `return e` + is checked against the declared output type (closing the prior soundness + gap where `return 0` in a `bool`-returning procedure went uncaught). + + Multi-output procedures use named-output assignment (`r := …` on the + declared output parameters); `return e` syntactically takes a single + `Option StmtExpr` and cannot carry multiple values, so it is flagged with + a diagnostic pointing users at the named-output convention. -/ def synthReturn (exprMd : StmtExprMd) (source : Option FileRange) (val : Option StmtExprMd) (h : exprMd.val = .Return val) : @@ -841,9 +887,11 @@ def synthReturn (exprMd : StmtExprMd) (source : Option FileRange) /-- Rules **Block-Check** / **Block-Check-Empty**: pushes `expected` into the *last* statement rather than comparing the block's synthesized type at the - boundary. Errors fire at the offending subexpression, and `T` keeps - propagating through nested `Block` / `IfThenElse` / `Hole` / `Quantifier`. - Empty blocks reduce to a subsumption check of `TVoid` against `expected`. -/ + boundary. Errors fire at the offending subexpression, and `expected` + keeps propagating through nested `Block` / `IfThenElse` / `Hole` / + `Quantifier`. Empty blocks reduce to a subsumption check of `TVoid` + against `expected` — the same check `[⇐] Block-Empty` performs when + `T` admits `TVoid`. -/ def checkBlock (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) @@ -873,8 +921,9 @@ def checkBlock (exprMd : StmtExprMd) /-- Rules **If-Check** / **If-Check-NoElse**: pushes `expected` into both branches (rather than going through If-Synth + Sub at the boundary). Errors fire at the offending branch instead of the surrounding `if`. - Without an else branch, the construct can only succeed when `T` admits - `TVoid`. -/ + Without an else branch, the construct can only succeed when `expected` + admits `TVoid` — the same subsumption check `[⇐] Block-Empty` performs + for an empty block. -/ def checkIfThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) @@ -933,12 +982,16 @@ def synthAssume (exprMd : StmtExprMd) /-- Rule **Assign**: each target's declared type `T_i` (from `Local`, `Field`, or fresh `Declare`) is collapsed into a tuple `ExpectedTy` (single type if one target, otherwise `MultiValuedExpr [T_1; …; T_n]`) - and checked against the RHS's synthesized type. When the RHS is a - statement (`TVoid`) — `while`, `return`, … — all checks are skipped: - there's no value to assign. The construct synthesizes the RHS's type, - so that expression-position assignments like `x ++ (y := s)` see a - string in the second operand; statement-position uses are accommodated - by `checkAssign`, which accepts `TVoid` as the expected type. -/ + and checked against the RHS's synthesized type. Both single- and + multi-target forms collapse into one tuple-vs-tuple check: when the RHS + is a `MultiValuedExpr`, both arity and per-position type mismatches + surface in a single diagnostic of shape *"expected '(int, int, int)', + got '(int, string)'"*. When the RHS is `TVoid` (a side-effecting + statement: `while`, `return`, …), all checks are skipped — there's no + value to assign. The construct synthesizes the RHS's type, so that + expression-position assignments like `x ++ (y := s)` see a string in + the second operand; statement-position uses are accommodated by + `checkAssign`, which accepts `TVoid` as the expected type. -/ def synthAssign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assign targets value) : @@ -1090,11 +1143,17 @@ def synthInstanceCall (exprMd : StmtExprMd) /-- Rules **Op-Bool** / **Op-Cmp** / **Op-Eq** / **Op-Arith** / **Op-Concat**: each operator family has its own argument-type discipline and result - type. Arguments are synthesized first, then the per-family check fires - (`⇐ TBool` for booleans, `Numeric` for arithmetic/comparison, consistency - `~` for equality, `⇐ TString` for concatenation). The result type is - `TBool` for booleans/comparisons/equality, the head argument's type for - arithmetic, `TString` for concatenation. -/ + type. Arguments are synthesized first, then the per-family check fires: + `⇐ TBool` for booleans, `Numeric` (consistent with `TInt`, `TReal`, or + `TFloat64`) for arithmetic/comparison, consistency `~` for equality + (symmetric — no subtype direction is privileged), `⇐ TString` for + concatenation. The result type is `TBool` for + booleans/comparisons/equality, the head argument's type for arithmetic + ("result is the type of the first argument" handles `int + int → int`, + `real + real → real`, etc. without unification — known relaxation: + `int + real` passes since each operand individually passes `Numeric`; + a proper fix needs numeric promotion or unification), `TString` for + concatenation. -/ def synthPrimitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) (op : Operation) (args : List StmtExprMd) (source : Option FileRange) (h_expr : expr = .PrimitiveOp op args) @@ -1161,7 +1220,9 @@ def synthNew (ref : Identifier) (source : Option FileRange) : pure (.New ref', ty) /-- Rule **AsType**: `target` is resolved but not checked against `T` — the - cast is the user's claim. The synthesized type is `T`. -/ + cast is the user's claim. The synthesized type is `T`. + + `IsType` is the runtime test counterpart and synthesizes `TBool`. -/ def synthAsType (exprMd : StmtExprMd) (target : StmtExprMd) (ty : HighTypeMd) (h : exprMd.val = .AsType target ty) : @@ -1192,7 +1253,12 @@ def synthIsType (exprMd : StmtExprMd) omega /-- Rule **RefEq**: both operands must be reference types (`UserDefined` or - `Unknown`). Reference equality is meaningless on primitives. -/ + `Unknown`) — reference equality is meaningless on primitives. The + operands must also be mutually consistent (the symmetric `isConsistent`), + so `Cat === Dog` is rejected when `Cat` and `Dog` are unrelated + user-defined types, while `Cat === Animal` is accepted when `Cat` + extends `Animal` (the gradual `Unknown` wildcard makes either side + flow freely against the other). -/ def synthRefEq (exprMd : StmtExprMd) (expr : StmtExpr) (lhs rhs : StmtExprMd) (source : Option FileRange) (h_expr : expr = .ReferenceEquals lhs rhs) @@ -1242,9 +1308,11 @@ def synthPureFieldUpdate (exprMd : StmtExprMd) -- ### Verification expressions -/-- Rule **Quantifier**: opens a fresh scope, binds `x : T`, resolves the - optional trigger, and checks the body against `TBool`. The construct - itself synthesizes `TBool` since a quantifier is a proposition. -/ +/-- Rule **Quantifier**: opens a fresh scope, binds `x : T` (in scope only + for the body and trigger), resolves the optional trigger, and checks + the body against `TBool` since a quantifier is a proposition. Without + that body check, `forall x: int :: x + 1` would be silently accepted. + The construct itself synthesizes `TBool`. -/ def synthQuantifier (exprMd : StmtExprMd) (mode : QuantifierMode) (param : Parameter) (trigger : Option StmtExprMd) (body : StmtExprMd) (source : Option FileRange) @@ -1296,7 +1364,9 @@ def synthOld (exprMd : StmtExprMd) omega /-- Rule **Fresh**: `v` is synthesized and must have a reference type - (`UserDefined` or `Unknown`). The construct itself synthesizes `TBool`. -/ + (`UserDefined` or `Unknown`) — `Fresh` only makes sense on + heap-allocated references, so `fresh(5)` is rejected. The construct + itself synthesizes `TBool`. -/ def synthFresh (exprMd : StmtExprMd) (expr : StmtExpr) (val : StmtExprMd) (source : Option FileRange) (h_expr : expr = .Fresh val) @@ -1334,8 +1404,13 @@ def synthProveBy (exprMd : StmtExprMd) -- ### Self reference /-- Rules **This-Inside** / **This-Outside**: when `instanceTypeName` is set - (we're inside an instance method), `This` synthesizes `UserDefined T`; - otherwise an error is emitted and the type collapses to `Unknown`. -/ + (we're inside an instance method, populated on `ResolveState` by + `resolveInstanceProcedure` for the duration of an instance method body), + `This` synthesizes `UserDefined T`. With it, `this.field` and + instance-method dispatch synthesize real types instead of being + wildcarded through `Unknown`. Otherwise an error is emitted ("'this' + is not allowed outside instance methods") and the type collapses to + `Unknown` to suppress cascading errors. -/ def synthThis (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let s ← get @@ -1364,10 +1439,25 @@ def synthAll (source : Option FileRange) : StmtExpr × HighTypeMd := -- ### ContractOf /-- Rules **ContractOf-Bool** / **ContractOf-Set** / **ContractOf-Error**: - `fn` must be a direct identifier reference resolving to a procedure; - anything else is ill-formed (a contract belongs to a *named* procedure). - Pre/postconditions are propositions (`TBool`); reads/modifies are sets of - heap references with element type `Unknown` for now. -/ + `ContractOf ty fn` extracts a procedure's contract clause as a value: + its preconditions (`Precondition`), postconditions (`PostCondition`), + reads set (`Reads`), or modifies set (`Modifies`). `fn` must be a + direct identifier reference resolving to a procedure — a contract + belongs to a *named* procedure, not an arbitrary expression. Anything + else fires the diagnostic *"'contractOf' expected a procedure + reference"* and the construct synthesizes `Unknown` to suppress + cascading errors. + + `Precondition` and `PostCondition` are propositions, hence `TBool`. + `Reads` and `Modifies` are sets of heap-allocated locations — + composite/datatype references and fields. The element type is left as + `Unknown` for now since the rule doesn't yet recover it from `fn`'s + declared modifies/reads clauses. + + The constructor is reserved for future use — Laurel's grammar has no + `contractOf` production today, and the translator emits "not yet + implemented" for it. The typing rule exists so resolution remains + exhaustive over `StmtExpr`. -/ def synthContractOf (exprMd : StmtExprMd) (ty : ContractType) (fn : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .ContractOf ty fn) : @@ -1412,9 +1502,18 @@ def synthHole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange /-- Rule **Hole-None-Check**: an untyped hole in check mode records the expected type on the node so downstream passes don't have to infer it - again. The subsumption check is trivial (`Unknown <: T` always holds), so - this rule never fails — it just preserves the type information available - at the check-mode boundary. -/ + again. The subsumption check is trivial (`Unknown <: T` always holds), + so this rule never fails — it just preserves the type information + available at the check-mode boundary instead of discarding it. + + A separate `InferHoleTypes` pass still runs after resolution to + annotate holes that ended up in synth-only positions. When that pass + encounters a hole whose type was already set (by `[⇐] Hole-None` or by + a user-written `?: T`), it checks the resolution-time and + inference-time types for consistency under `~`; a disagreement fires + the diagnostic *"hole annotated with 'T_resolution' but context + expects 'T_inference'"*, surfacing what would otherwise be a silent + overwrite. -/ def checkHoleNone (det : Bool) (expected : HighTypeMd) (source : Option FileRange) : StmtExprMd := { val := .Hole det (some expected), source := source } diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 34b5772920..7583d4d079 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -165,70 +165,31 @@ There are two operations on expressions, written here in standard bidirectional ``` Synthesis returns a type inferred from the expression itself; checking verifies that the -expression has a given expected type. Each construct picks a mode based on whether its type -is determined locally (synth) or by context (check). The two judgments are connected by a -single change-of-direction rule, *subsumption*: +expression has a given expected type. Each construct picks a mode based on whether its +type is determined locally (synth) or by context (check). The two judgments are connected +by a single change-of-direction rule, *subsumption*: $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` -Subsumption is the *only* place the checker switches from check to synth mode. It fires as -the default fallback in -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr` for every construct without a bespoke -check rule: synthesize the expression's type, then verify the result is a subtype of the -expected type. Bespoke check rules push the expected type *into* subexpressions instead of -bouncing through synthesis, which keeps error messages localized and lets the expected type -propagate through nested control flow. - -`synthStmtExpr` and `checkStmtExpr` are mutually recursive: synth rules invoke check on -subexpressions whose expected type is known (e.g. `cond ⇐ TBool` in -{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse`), and `checkStmtExpr` falls back to -`synthStmtExpr` via \[⇐\] Sub. Termination uses a lexicographic measure `(exprMd, tag)` -where the tag is `0` for synth and `1` for check; any descent into a strict subterm -decreases via `Prod.Lex.left`, while \[⇐\] Sub calls synth on the *same* expression and -decreases via -`Prod.Lex.right`. This is the standard well-founded encoding for bidirectional systems. - -There is also a thin `resolveStmtExpr` wrapper that calls `synthStmtExpr` and discards the -synthesized type. It's used at sites where typing is not enforced (verification annotations, -modifies/reads clauses). The right principle for new call sites is: when the position has a -known expected type ({name Strata.Laurel.HighType.TBool}`TBool` for conditions, numeric for -`decreases`, the declared output for a constant initializer or a functional body), use -`checkStmtExpr`. When it doesn't, use `resolveStmtExpr`. `synthStmtExpr` itself is mostly an -internal interface used by other rules. +The two judgments are implemented as +{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and +{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`: + +{docstring Strata.Laurel.synthStmtExpr} + +{docstring Strata.Laurel.checkStmtExpr} ### Gradual typing -The relation `<:` (used in \[⇐\] Sub) is built from three Lean functions: - -- `isSubtype` — pure subtyping. Walks the `extending` chain for - {name Strata.Laurel.CompositeType}`CompositeType` (via - {name Strata.Laurel.TypeContext.ancestors}`TypeContext.ancestors`), unfolds - {name Strata.Laurel.TypeAlias}`TypeAlias` to its target, and unwraps - {name Strata.Laurel.ConstrainedType}`ConstrainedType` to its base (both via - {name Strata.Laurel.TypeContext.unfold}`TypeContext.unfold`), then falls back to - structural equality via {name Strata.Laurel.highEq}`highEq`. -- `isConsistent` — the symmetric gradual relation `~` (Siek–Taha): - {name Strata.Laurel.HighType.Unknown}`Unknown` is the dynamic type and is consistent with - everything; otherwise structural equality. -- `isConsistentSubtype` — defined as `isConsistent ∨ isSubtype`. For our flat lattice this - is the standard collapse of `∃R. T ~ R ∧ R <: U`. - -\[⇐\] Sub (and every bespoke check rule) uses `isConsistentSubtype`. That single choice is what -makes the system *gradual*: an expression of type -{name Strata.Laurel.HighType.Unknown}`Unknown` (a hole, an unresolved name, a `Hole _ none`) -flows freely into any typed slot, and any expression flows freely into a slot of type -{name Strata.Laurel.HighType.Unknown}`Unknown`. Strict checking is applied between -fully-known types only. The symmetric `isConsistent` is used directly by \[⇒\] Op-Eq, where -the operand types must be mutually consistent (no subtype direction is privileged). - -A previous iteration was synth-only with two *bivariantly-compatible* wildcards: -{name Strata.Laurel.HighType.Unknown}`Unknown` and -{name Strata.Laurel.HighType.UserDefined}`UserDefined`. The -{name Strata.Laurel.HighType.UserDefined}`UserDefined` carve-out was load-bearing: no -assignment, call argument, or comparison involving a user type was ever rejected. The -bidirectional design retires that carve-out — user-defined types are now a regular -participant in `<:`, with `isSubtype` walking inheritance chains and unwrapping aliases -and constrained types to deliver real checking on user-defined code. +The relation `<:` (used in \[⇐\] Sub) is built from three Lean functions — +{name Strata.Laurel.isSubtype}`isSubtype`, {name Strata.Laurel.isConsistent}`isConsistent`, +and {name Strata.Laurel.isConsistentSubtype}`isConsistentSubtype`: + +{docstring Strata.Laurel.isSubtype} + +{docstring Strata.Laurel.isConsistent} + +{docstring Strata.Laurel.isConsistentSubtype} Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This includes {name Strata.Laurel.StmtExpr.Return}`Return`, @@ -270,64 +231,64 @@ suffix is dropped in favor of the prefix. - *ContractOf* — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error - *Holes* — \[⇒\] Hole-Some, \[⇒\] Hole-None, \[⇐\] Hole-None +Each LaTeX rule below is followed by the docstring of the helper that implements it +(grouped when one helper covers multiple rules). + ### Subsumption $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` -Fallback in `checkStmtExpr` whenever no bespoke check rule applies. +Fallback in {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` whenever no bespoke check +rule applies. ### Literals $$`\frac{}{\Gamma \vdash \mathsf{LiteralInt}\;n \Rightarrow \mathsf{TInt}} \quad \text{([⇒] Lit-Int)}` +{docstring Strata.Laurel.synthLitInt} + $$`\frac{}{\Gamma \vdash \mathsf{LiteralBool}\;b \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Lit-Bool)}` +{docstring Strata.Laurel.synthLitBool} + $$`\frac{}{\Gamma \vdash \mathsf{LiteralString}\;s \Rightarrow \mathsf{TString}} \quad \text{([⇒] Lit-String)}` +{docstring Strata.Laurel.synthLitString} + $$`\frac{}{\Gamma \vdash \mathsf{LiteralDecimal}\;d \Rightarrow \mathsf{TReal}} \quad \text{([⇒] Lit-Decimal)}` +{docstring Strata.Laurel.synthLitDecimal} + ### Variables $$`\frac{\Gamma(x) = T}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Local}\;x) \Rightarrow T} \quad \text{([⇒] Var-Local)}` +{docstring Strata.Laurel.synthVarLocal} + $$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Field}\;e\;f) \Rightarrow T_f} \quad \text{([⇒] Var-Field)}` -Resolution looks `f` up against the type of `e` (or the enclosing instance type for -`self.f`); the typing rule itself is path-agnostic. +{docstring Strata.Laurel.synthVarField} $$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T\rangle) \Rightarrow \mathsf{TVoid} \dashv \Gamma, x : T} \quad \text{([⇒] Var-Declare)}` `⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the remainder of the enclosing scope. +{docstring Strata.Laurel.synthVarDeclare} + ### Control flow $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow \mathsf{TVoid}}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-NoElse)}` -The construct synthesizes {name Strata.Laurel.HighType.TVoid}`TVoid` because there is no -value when `cond` is false; the then-branch is checked against -{name Strata.Laurel.HighType.TVoid}`TVoid` so `x : int := if c then 5` is rejected at the -branch rather than slipping through to a downstream subsumption. - $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t \sqcup T_e} \quad \text{([⇒] If)}` -The result is the join (least upper bound) of the two branch types, so -`if c then small else big` synthesizes the common supertype rather than committing to one -branch arbitrarily. The join walks `extending` chains for composites; when no common -supertype exists (e.g. a value branch paired with a `TVoid` `return`/`exit`), it falls -back to `T_t` and the enclosing context's check (\[⇐\] Sub, or a containing -`checkSubtype` like an assignment) surfaces any mismatch downstream against the -then-branch's type. +{docstring Strata.Laurel.synthIfThenElse} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \Gamma \vdash \mathit{elseBr} \Leftarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Leftarrow T} \quad \text{([⇐] If)}` $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Leftarrow T} \quad \text{([⇐] If-NoElse)}` -Check mode pushes `T` into both branches (rather than going through \[⇒\] If + \[⇐\] Sub at -the boundary). Errors fire at the offending branch instead of the surrounding `if`. -Without an else branch, the construct can only succeed when `T` admits -{name Strata.Laurel.HighType.TVoid}`TVoid` — the same subsumption check `\[⇐\] Block-Empty` -performs for an empty block. +{docstring Strata.Laurel.checkIfThenElse} $$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block)}` @@ -336,79 +297,56 @@ predecessor and may itself extend it (`Var (.Declare …)` does); `s_n` is typed `Γ_{n-1}`. Bindings introduced inside the block don't escape — `Γ` is what surrounds the block. -Non-last statements are synthesized but their types discarded (the lax rule). This matches -Java/Python/JS where `f(x);` is normal even when `f` returns a value. The trade-off: `5;` -is silently accepted; flagging it belongs to a lint. - $$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Block-Empty)}` -$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` +{docstring Strata.Laurel.synthBlock} -Pushes `T` into the *last* statement rather than comparing the block's synthesized type at -the boundary. Errors fire at the offending subexpression, and `T` keeps propagating through -nested {name Strata.Laurel.StmtExpr.Block}`Block` / -{name Strata.Laurel.StmtExpr.IfThenElse}`IfThenElse` / -{name Strata.Laurel.StmtExpr.Hole}`Hole` / -{name Strata.Laurel.StmtExpr.Quantifier}`Quantifier`. +$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` $$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Empty)}` +{docstring Strata.Laurel.checkBlock} + $$`\frac{}{\Gamma \vdash \mathsf{Exit}\;\mathit{target} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Exit)}` -`Return` matches the optional return value against the enclosing procedure's declared -outputs. The expected output types are threaded through -{name Strata.Laurel.ResolveState}`ResolveState`'s `expectedReturnTypes`, set from -`proc.outputs` by {name Strata.Laurel.resolveProcedure}`resolveProcedure` / -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` for the duration of -the body. `none` means "no enclosing procedure" — e.g. resolving a constant initializer — -and skips all `Return` checks. +{docstring Strata.Laurel.synthExit} $$`\frac{}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-None)}` -A bare `return;` is allowed in any context. In a single-output procedure it acts as a -Dafny-style early exit — the output parameter retains whatever was last assigned to it. - $$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T] \quad \Gamma \vdash e \Leftarrow T}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-Some)}` -In a single-output procedure, the value is checked against the declared output type. This -closes the prior soundness gap where `return 0` in a `bool`-returning procedure went -uncaught. - $$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = []}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “void procedure cannot return a value”}} \quad \text{([⇒] Return-Void-Error)}` $$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇒] Return-Multi-Error)}` -Multi-output procedures use named-output assignment (`r := …` on the declared output -parameters). `return e` syntactically takes a single -{name Strata.Laurel.StmtExpr.Return}`Option StmtExpr`, so it cannot carry multiple values; -flagging it points users at the named-output convention. +{docstring Strata.Laurel.synthReturn} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{dec} \Leftarrow {?} \quad \Gamma \vdash \mathit{body} \Rightarrow \_}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{dec}\;\mathit{body} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] While)}` -`dec` (the optional decreases clause) is resolved without a type check today; the intended -target is a numeric type. +{docstring Strata.Laurel.synthWhile} ### Verification statements $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assert)}` +{docstring Strata.Laurel.synthAssert} + $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assume}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assume)}` +{docstring Strata.Laurel.synthAssume} + ### Assignment $$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Rightarrow T_e \quad T_e <: \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assign)}` where `ExpectedTy = T_1` if `|targets| = 1` and `MultiValuedExpr [T_1; …; T_n]` otherwise. - The target's declared type `T_i` comes from the variable's scope entry (for {name Strata.Laurel.Variable.Local}`Local` and {name Strata.Laurel.Variable.Field}`Field`) -or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. Both -single- and multi-target forms collapse into one tuple-vs-tuple check: when the RHS is a -{name Strata.Laurel.HighType.MultiValuedExpr}`MultiValuedExpr`, both arity and per-position -type mismatches surface in a single diagnostic of shape *"expected '(int, int, int)', got -'(int, string)'"*. When the RHS is {name Strata.Laurel.HighType.TVoid}`TVoid` (a -side-effecting statement: `while`, `return`, …), all checks are skipped — there's no value -to assign. +or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. + +{docstring Strata.Laurel.synthAssign} + +{docstring Strata.Laurel.checkAssign} ### Calls @@ -416,8 +354,12 @@ $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` +{docstring Strata.Laurel.synthStaticCall} + $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Instance-Call)}` +{docstring Strata.Laurel.synthInstanceCall} + ### Primitive operations `Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, @@ -430,106 +372,88 @@ $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \mathit $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad T_l \sim T_r \quad \mathit{op} \in \{\mathsf{Eq}, \mathsf{Neq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;[\mathit{lhs}; \mathit{rhs}] \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Eq)}` -`~` is the consistency relation `isConsistent` — symmetric, with the -{name Strata.Laurel.HighType.Unknown}`Unknown` wildcard. - $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \Gamma \vdash \mathit{args}.\mathsf{head} \Rightarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` -"Result is the type of the first argument" handles `int + int → int`, `real + real → real`, -etc. without unification. Known relaxation: `int + real` passes (each operand individually -passes `Numeric`); a proper fix needs numeric promotion or unification. - $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` +{docstring Strata.Laurel.synthPrimitiveOp} + ### Object forms $$`\frac{\Gamma(\mathit{ref}) \text{ is a composite or datatype } T}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] New-Ok)}` $$`\frac{\Gamma(\mathit{ref}) \text{ is not a composite or datatype}}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] New-Fallback)}` +{docstring Strata.Laurel.synthNew} + $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{AsType}\;\mathit{target}\;T \Rightarrow T} \quad \text{([⇒] AsType)}` -`target` is resolved but not checked against `T` — the cast is the user's claim. +{docstring Strata.Laurel.synthAsType} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` +{docstring Strata.Laurel.synthIsType} + $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad \mathsf{isReference}\;T_l \quad \mathsf{isReference}\;T_r \quad T_l \sim T_r}{\Gamma \vdash \mathsf{ReferenceEquals}\;\mathit{lhs}\;\mathit{rhs} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] RefEq)}` `isReference T` holds when `T` is a {name Strata.Laurel.HighType.UserDefined}`UserDefined` -or {name Strata.Laurel.HighType.Unknown}`Unknown` -type. Reference equality is meaningless on primitives. The operands must also be -consistent under `~` (Siek–Taha consistency), matching the rule applied by -{name Strata.Laurel.Operation.Eq}`==`: two distinct user-defined types like `Cat` and -`Dog` are rejected, while either side being `Unknown` is accepted as a gradual escape -hatch. +or {name Strata.Laurel.HighType.Unknown}`Unknown` type. `~` is the consistency relation +{name Strata.Laurel.isConsistent}`isConsistent` — symmetric, with the +{name Strata.Laurel.HighType.Unknown}`Unknown` wildcard. + +{docstring Strata.Laurel.synthRefEq} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow T_t \quad \Gamma(f) = T_f \quad \Gamma \vdash \mathit{newVal} \Leftarrow T_f}{\Gamma \vdash \mathsf{PureFieldUpdate}\;\mathit{target}\;f\;\mathit{newVal} \Rightarrow T_t} \quad \text{([⇒] PureFieldUpdate)}` -`f` is resolved against `T_t` (or the enclosing instance type) and `newVal` is checked -against the field's declared type. +{docstring Strata.Laurel.synthPureFieldUpdate} ### Verification expressions $$`\frac{\Gamma, x : T \vdash \mathit{body} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Quantifier}\;\mathit{mode}\;\langle x, T\rangle\;\mathit{trig}\;\mathit{body} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Quantifier)}` -The bound variable `x : T` is introduced in scope only for the body (and trigger). The body -is checked against {name Strata.Laurel.HighType.TBool}`TBool` since a quantifier is a -proposition; without this, `forall x: int :: x + 1` would be silently accepted. +{docstring Strata.Laurel.synthQuantifier} $$`\frac{\Gamma \vdash \mathit{name} \Rightarrow \_}{\Gamma \vdash \mathsf{Assigned}\;\mathit{name} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Assigned)}` +{docstring Strata.Laurel.synthAssigned} + $$`\frac{\Gamma \vdash v \Rightarrow T}{\Gamma \vdash \mathsf{Old}\;v \Rightarrow T} \quad \text{([⇒] Old)}` +{docstring Strata.Laurel.synthOld} + $$`\frac{\Gamma \vdash v \Rightarrow T \quad \mathsf{isReference}\;T}{\Gamma \vdash \mathsf{Fresh}\;v \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Fresh)}` -`isReference T` is the same predicate as in {name Strata.Laurel.StmtExpr.ReferenceEquals}`ReferenceEquals`. -{name Strata.Laurel.StmtExpr.Fresh}`Fresh` only makes sense on heap-allocated references; -`fresh(5)` is rejected. +{docstring Strata.Laurel.synthFresh} $$`\frac{\Gamma \vdash v \Rightarrow T \quad \Gamma \vdash \mathit{proof} \Rightarrow \_}{\Gamma \vdash \mathsf{ProveBy}\;v\;\mathit{proof} \Rightarrow T} \quad \text{([⇒] ProveBy)}` +{docstring Strata.Laurel.synthProveBy} + ### Self reference $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{some}\;T}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] This-Inside)}` $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{none}}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{Unknown}\;\;[\text{emits “‘this’ is not allowed outside instance methods”}]} \quad \text{([⇒] This-Outside)}` -`Γ.instanceTypeName` is the -{name Strata.Laurel.ResolveState}`ResolveState` field set by -{name Strata.Laurel.resolveInstanceProcedure}`resolveInstanceProcedure` for the duration of -an instance method body. With it, `this.field` and instance-method dispatch synthesize real -types instead of being wildcarded through {name Strata.Laurel.HighType.Unknown}`Unknown`. +{docstring Strata.Laurel.synthThis} ### Untyped forms $$`\frac{}{\Gamma \vdash \mathsf{Abstract}\;/\;\mathsf{All}\;\ldots \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Abstract / All)}` -### ContractOf +{docstring Strata.Laurel.synthAbstract} -`ContractOf ty fn` extracts a procedure's contract clause as a value: its preconditions -(`Precondition`), postconditions (`PostCondition`), reads set (`Reads`), or modifies set -(`Modifies`). `fn` must be a direct identifier reference to a procedure — a contract belongs -to a *named* procedure, not an arbitrary expression. +{docstring Strata.Laurel.synthAll} + +### ContractOf $$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Precondition}\;\mathit{fn} \Rightarrow \mathsf{TBool} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{PostCondition}\;\mathit{fn} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] ContractOf-Bool)}` $$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Reads}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{Modifies}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown}} \quad \text{([⇒] ContractOf-Set)}` -`Precondition` and `PostCondition` are propositions, hence -{name Strata.Laurel.HighType.TBool}`TBool`. `Reads` and `Modifies` are sets of heap-allocated -locations — composite/datatype references and fields. The element type is left as -{name Strata.Laurel.HighType.Unknown}`Unknown` for now since the rule doesn't yet recover it -from `fn`'s declared modifies/reads clauses. - $$`\frac{\mathit{fn} \text{ is not a procedure reference}}{\Gamma \vdash \mathsf{ContractOf}\;\ldots\;\mathit{fn} \rightsquigarrow \text{error: “‘contractOf’ expected a procedure reference”}} \quad \text{([⇒] ContractOf-Error)}` -When `fn` doesn't resolve to a procedure (e.g. it's an arbitrary expression, or resolves to -a constant/variable), the diagnostic fires and the construct synthesizes -{name Strata.Laurel.HighType.Unknown}`Unknown` to suppress cascading errors. - -The constructor is reserved for future use — Laurel's grammar has no `contractOf` -production today, and the translator emits "not yet implemented" for it. The typing rule -exists so resolution remains exhaustive over `StmtExpr`. +{docstring Strata.Laurel.synthContractOf} ### Holes @@ -537,19 +461,11 @@ $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T) \Rightarrow T} \qu $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Hole-None)}` +{docstring Strata.Laurel.synthHole} + $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \;\;\mapsto\;\; \mathsf{Hole}\;d\;(\mathsf{some}\;T)} \quad \text{([⇐] Hole-None)}` -In check mode, an untyped hole records the expected type `T` on the node directly. The -subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it -just preserves the type information that's available at the check-mode boundary instead of -discarding it. - -A separate `InferHoleTypes` pass still runs after resolution to annotate holes that ended -up in synth-only positions. When that pass encounters a hole whose type was already set -(by \[⇐\] Hole-None or by a user-written `?: T`), it checks the resolution-time and -inference-time types for consistency under `~`; a disagreement fires the diagnostic -*"hole annotated with 'T_resolution' but context expects 'T_inference'"*, surfacing what -would otherwise be a silent overwrite. +{docstring Strata.Laurel.checkHoleNone} ## Future structural changes From d2de9dac9e2b139460589aece4350837e4170d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 14:16:41 -0400 Subject: [PATCH 114/189] namespace scoping to make code less verbose --- Strata/Languages/Laurel/Resolution.lean | 307 ++++++++++++------------ docs/verso/LaurelDoc.lean | 90 +++---- 2 files changed, 201 insertions(+), 196 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 970e32d0ad..080142d15c 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -38,7 +38,7 @@ Walks the AST under `ResolveM`, a state monad over `ResolveState`. Phase 1: - opens fresh nested scopes via `withScope` for blocks, quantifiers, procedure bodies, and constrained-type constraint/witness expressions, - synthesizes a `HighType` for every `StmtExpr` and checks it (via - `checkStmtExpr` for fresh subexpressions, or `checkSubtype` when a type is + `Check.resolveStmtExpr` for fresh subexpressions, or `checkSubtype` when a type is already in hand) on assignments, call arguments, condition positions, functional bodies, and constant initializers. @@ -466,7 +466,7 @@ private def typeMismatch (source : Option FileRange) (construct : Option StmtExp /-- Type-level subtype check: emits the standard "expected/got" diagnostic when `actual` is not a consistent subtype of `expected`. Used at sites where the actual type is already in hand (assignment, call args, body vs declared - output) — equivalent to `checkStmtExpr e expected` but without re-synthesizing. -/ + output) — equivalent to `Check.resolveStmtExpr e expected` but without re-synthesizing. -/ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do let ctx := (← get).typeContext unless isConsistentSubtype ctx actual expected do @@ -532,26 +532,28 @@ Each typing rule from the Laurel manual is implemented as its own helper inside the mutual block below. Helpers are grouped by section to mirror the *Typing rules* index in `LaurelDoc.lean`: -- Literals — `synthLitInt`, `synthLitBool`, `synthLitString`, `synthLitDecimal` -- Variables — `synthVarLocal`, `synthVarField`, `synthVarDeclare` -- Control flow — `synthIfThenElse`, `synthBlock`, `synthWhile`, `synthExit`, - `synthReturn`, `checkBlock`, `checkIfThenElse` -- Verification statements — `synthAssert`, `synthAssume` -- Assignment — `synthAssign` -- Calls — `synthStaticCall`, `synthInstanceCall` -- Primitive operations — `synthPrimitiveOp` -- Object forms — `synthNew`, `synthAsType`, `synthIsType`, `synthRefEq`, - `synthPureFieldUpdate` -- Verification expressions — `synthQuantifier`, `synthAssigned`, `synthOld`, - `synthFresh`, `synthProveBy` -- Self reference — `synthThis` -- Untyped forms — `synthAbstract`, `synthAll` -- ContractOf — `synthContractOf` -- Holes — `synthHole`, `checkHoleNone` - -The dispatch functions `synthStmtExpr` and `checkStmtExpr` simply pattern-match +- Literals — `Synth.litInt`, `Synth.litBool`, `Synth.litString`, `Synth.litDecimal` +- Variables — `Synth.varLocal`, `Synth.varField`, `Synth.varDeclare` +- Control flow — `Synth.ifThenElse`, `Synth.block`, `Synth.while`, `Synth.exit`, + `Synth.return`, `Check.block`, `Check.ifThenElse` +- Verification statements — `Synth.assert`, `Synth.assume` +- Assignment — `Synth.assign`, `Check.assign` +- Calls — `Synth.staticCall`, `Synth.instanceCall` +- Primitive operations — `Synth.primitiveOp` +- Object forms — `Synth.new`, `Synth.asType`, `Synth.isType`, `Synth.refEq`, + `Synth.pureFieldUpdate` +- Verification expressions — `Synth.quantifier`, `Synth.assigned`, `Synth.old`, + `Synth.fresh`, `Synth.proveBy` +- Self reference — `Synth.this` +- Untyped forms — `Synth.abstract`, `Synth.all` +- ContractOf — `Synth.contractOf` +- Holes — `Synth.hole`, `Check.holeNone` + +The dispatch functions `Synth.resolveStmtExpr` and `Check.resolveStmtExpr` simply pattern-match on the constructor and delegate to the corresponding helper. -/ +namespace Resolution + -- The `h : exprMd.val = .Foo args ...` parameters on the recursive helpers -- look unused to the linter, but each one is referenced by that helper's -- `decreasing_by` tactic to relate `sizeOf args` to `sizeOf exprMd`. @@ -564,74 +566,74 @@ mutual written `Γ ⊢ e ⇒ T`. Each constructor delegates to its rule's helper. Synthesis returns a type inferred from the expression itself; checking - (`checkStmtExpr`) verifies that the expression has a given expected + (`Check.resolveStmtExpr`) verifies that the expression has a given expected type. Each construct picks a mode based on whether its type is determined locally (synth) or by context (check). Synth rules invoke check on subexpressions whose expected type is known (e.g. - `cond ⇐ TBool` in `IfThenElse`); `checkStmtExpr` falls back to - `synthStmtExpr` via subsumption (rule `[⇐] Sub`). The two functions + `cond ⇐ TBool` in `IfThenElse`); `Check.resolveStmtExpr` falls back to + `Synth.resolveStmtExpr` via subsumption (rule `[⇐] Sub`). The two functions are mutually recursive, with termination on a lexicographic measure `(exprMd, tag)` — tag `0` for check, `1` for synth — so that subsumption (which calls synth on the *same* expression) can decrease via `Prod.Lex.right`. -/ -def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do +def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match h_node: exprMd with | AstNode.mk expr source => let (val', ty) ← match h_expr: expr with | .IfThenElse cond thenBr elseBr => - synthIfThenElse exprMd cond thenBr elseBr (by rw [h_node]) + Synth.ifThenElse exprMd cond thenBr elseBr (by rw [h_node]) | .Block stmts label => - synthBlock exprMd stmts label (by rw [h_node]) + Synth.block exprMd stmts label (by rw [h_node]) | .While cond invs dec body => - synthWhile exprMd cond invs dec body (by rw [h_node]) - | .Exit target => pure (synthExit target source) + Synth.while exprMd cond invs dec body (by rw [h_node]) + | .Exit target => pure (Synth.exit target source) | .Return val => - synthReturn exprMd source val (by rw [h_node]) - | .LiteralInt v => pure (synthLitInt v source) - | .LiteralBool v => pure (synthLitBool v source) - | .LiteralString v => pure (synthLitString v source) - | .LiteralDecimal v => pure (synthLitDecimal v source) - | .Var (.Local ref) => synthVarLocal ref source - | .Var (.Declare param) => synthVarDeclare param source + Synth.return exprMd source val (by rw [h_node]) + | .LiteralInt v => pure (Synth.litInt v source) + | .LiteralBool v => pure (Synth.litBool v source) + | .LiteralString v => pure (Synth.litString v source) + | .LiteralDecimal v => pure (Synth.litDecimal v source) + | .Var (.Local ref) => Synth.varLocal ref source + | .Var (.Declare param) => Synth.varDeclare param source | .Var (.Field target fieldName) => - synthVarField exprMd target fieldName source (by rw [h_node]) + Synth.varField exprMd target fieldName source (by rw [h_node]) | .Assign targets value => - synthAssign exprMd targets value source (by rw [h_node]) + Synth.assign exprMd targets value source (by rw [h_node]) | .PureFieldUpdate target fieldName newVal => - synthPureFieldUpdate exprMd target fieldName newVal (by rw [h_node]) + Synth.pureFieldUpdate exprMd target fieldName newVal (by rw [h_node]) | .StaticCall callee args => - synthStaticCall exprMd callee args source (by rw [h_node]) + Synth.staticCall exprMd callee args source (by rw [h_node]) | .PrimitiveOp op args => - synthPrimitiveOp exprMd expr op args source h_expr (by rw [h_node]) - | .New ref => synthNew ref source - | .This => synthThis source + Synth.primitiveOp exprMd expr op args source h_expr (by rw [h_node]) + | .New ref => Synth.new ref source + | .This => Synth.this source | .ReferenceEquals lhs rhs => - synthRefEq exprMd expr lhs rhs source h_expr (by rw [h_node]) + Synth.refEq exprMd expr lhs rhs source h_expr (by rw [h_node]) | .AsType target ty => - synthAsType exprMd target ty (by rw [h_node]) + Synth.asType exprMd target ty (by rw [h_node]) | .IsType target ty => - synthIsType exprMd target ty source (by rw [h_node]) + Synth.isType exprMd target ty source (by rw [h_node]) | .InstanceCall target callee args => - synthInstanceCall exprMd target callee args source (by rw [h_node]) + Synth.instanceCall exprMd target callee args source (by rw [h_node]) | .Quantifier mode param trigger body => - synthQuantifier exprMd mode param trigger body source (by rw [h_node]) + Synth.quantifier exprMd mode param trigger body source (by rw [h_node]) | .Assigned name => - synthAssigned exprMd name source (by rw [h_node]) + Synth.assigned exprMd name source (by rw [h_node]) | .Old val => - synthOld exprMd val (by rw [h_node]) + Synth.old exprMd val (by rw [h_node]) | .Fresh val => - synthFresh exprMd expr val source h_expr (by rw [h_node]) + Synth.fresh exprMd expr val source h_expr (by rw [h_node]) | .Assert ⟨condExpr, summary⟩ => - synthAssert exprMd condExpr summary source (by rw [h_node]) + Synth.assert exprMd condExpr summary source (by rw [h_node]) | .Assume cond => - synthAssume exprMd cond source (by rw [h_node]) + Synth.assume exprMd cond source (by rw [h_node]) | .ProveBy val proof => - synthProveBy exprMd val proof (by rw [h_node]) + Synth.proveBy exprMd val proof (by rw [h_node]) | .ContractOf ty fn => - synthContractOf exprMd ty fn source (by rw [h_node]) - | .Abstract => pure (synthAbstract source) - | .All => pure (synthAll source) - | .Hole det type => synthHole det type source + Synth.contractOf exprMd ty fn source (by rw [h_node]) + | .Abstract => pure (Synth.abstract source) + | .All => pure (Synth.all source) + | .Hole det type => Synth.hole det type source return ({ val := val', source := source }, ty) termination_by (exprMd, 2) decreasing_by all_goals first @@ -651,25 +653,25 @@ def synthStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := The right principle for new call sites is: when the position has a known expected type (`TBool` for conditions, numeric for `decreases`, the declared output for a constant initializer or a functional body), - use `checkStmtExpr`. When it doesn't, use `resolveStmtExpr` (a thin - wrapper that calls `synthStmtExpr` and discards the synthesized type, + use `Check.resolveStmtExpr`. When it doesn't, use `resolveStmtExpr` (a thin + wrapper that calls `Synth.resolveStmtExpr` and discards the synthesized type, used at sites where typing is not enforced — verification annotations, - modifies/reads clauses). `synthStmtExpr` itself is mostly an internal + modifies/reads clauses). `Synth.resolveStmtExpr` itself is mostly an internal interface used by other rules. -/ -def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do +def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do match h_node: exprMd with | AstNode.mk expr source => match h_expr: expr with | .Block stmts label => - checkBlock exprMd stmts label expected source (by rw [h_node]) + Check.block exprMd stmts label expected source (by rw [h_node]) | .IfThenElse cond thenBr elseBr => - checkIfThenElse exprMd cond thenBr elseBr expected source (by rw [h_node]) + Check.ifThenElse exprMd cond thenBr elseBr expected source (by rw [h_node]) | .Assign targets value => - checkAssign exprMd targets value expected source (by rw [h_node]) - | .Hole det none => pure (checkHoleNone det expected source) + Check.assign exprMd targets value expected source (by rw [h_node]) + | .Hole det none => pure (Check.holeNone det expected source) | _ => -- Subsumption fallback: synth then check `actual <: expected`. - let (e', actual) ← synthStmtExpr exprMd + let (e', actual) ← Synth.resolveStmtExpr exprMd checkSubtype source expected actual pure e' termination_by (exprMd, 3) @@ -682,26 +684,26 @@ def checkStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtE -- ### Literals /-- Rule **Lit-Int**: `Γ ⊢ LiteralInt n ⇒ TInt`. -/ -def synthLitInt (v : Int) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.litInt (v : Int) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralInt v, { val := .TInt, source := source }) /-- Rule **Lit-Bool**: `Γ ⊢ LiteralBool b ⇒ TBool`. -/ -def synthLitBool (v : Bool) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.litBool (v : Bool) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralBool v, { val := .TBool, source := source }) /-- Rule **Lit-String**: `Γ ⊢ LiteralString s ⇒ TString`. -/ -def synthLitString (v : String) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.litString (v : String) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralString v, { val := .TString, source := source }) /-- Rule **Lit-Decimal**: `Γ ⊢ LiteralDecimal d ⇒ TReal`. -/ -def synthLitDecimal (v : Decimal) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.litDecimal (v : Decimal) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralDecimal v, { val := .TReal, source := source }) -- ### Variables /-- Rule **Var-Local**: `Γ(x) = T ⊢ Var (.Local x) ⇒ T`. Resolves `ref` against the lexical scope and reads its declared type. -/ -def synthVarLocal (ref : Identifier) (source : Option FileRange) : +def Synth.varLocal (ref : Identifier) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let ref' ← resolveRef ref source let ty ← getVarType ref @@ -709,7 +711,7 @@ def synthVarLocal (ref : Identifier) (source : Option FileRange) : /-- Rule **Var-Declare**: extends the surrounding scope with `x : T` and synthesizes `TVoid` (the declaration itself produces no value). -/ -def synthVarDeclare (param : Parameter) (source : Option FileRange) : +def Synth.varDeclare (param : Parameter) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') @@ -718,11 +720,11 @@ def synthVarDeclare (param : Parameter) (source : Option FileRange) : /-- Rule **Var-Field**: `Γ ⊢ e ⇒ _, Γ(f) = T_f ⊢ Var (.Field e f) ⇒ T_f`. `f` is looked up against the type of `e` (or the enclosing instance type for `self.f`); the typing rule itself is path-agnostic. -/ -def synthVarField (exprMd : StmtExprMd) +def Synth.varField (exprMd : StmtExprMd) (target : StmtExprMd) (fieldName : Identifier) (source : Option FileRange) (h : exprMd.val = .Var (.Field target fieldName)) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source let ty ← getVarType fieldName' pure (.Var (.Field target' fieldName'), ty) @@ -751,19 +753,19 @@ def synthVarField (exprMd : StmtExprMd) type and the enclosing context's check (`[⇐] Sub`, or a containing `checkSubtype` like an assignment) surfaces any mismatch downstream against the then-branch's type. -/ -def synthIfThenElse (exprMd : StmtExprMd) +def Synth.ifThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (h : exprMd.val = .IfThenElse cond thenBr elseBr) : ResolveM (StmtExpr × HighTypeMd) := do - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } let voidTy : HighTypeMd := { val := .TVoid, source := exprMd.source } match elseBr with | none => - let thenBr' ← checkStmtExpr thenBr voidTy + let thenBr' ← Check.resolveStmtExpr thenBr voidTy pure (.IfThenElse cond' thenBr' none, voidTy) | some e => - let (thenBr', thenTy) ← synthStmtExpr thenBr - let (elseBr', elseTy) ← synthStmtExpr e + let (thenBr', thenTy) ← Synth.resolveStmtExpr thenBr + let (elseBr', elseTy) ← Synth.resolveStmtExpr e let ctx := (← get).typeContext pure (.IfThenElse cond' thenBr' (some elseBr'), joinTypes ctx thenTy elseTy) termination_by (exprMd, 1) @@ -784,12 +786,12 @@ def synthIfThenElse (exprMd : StmtExprMd) accepted, flagging it belongs to a lint). The last statement's type becomes the block's type, or `TVoid` for an empty block. The block opens a fresh nested scope, so bindings introduced inside don't escape. -/ -def synthBlock (exprMd : StmtExprMd) +def Synth.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (h : exprMd.val = .Block stmts label) : ResolveM (StmtExpr × HighTypeMd) := do withScope do - let results ← stmts.mapM synthStmtExpr + let results ← stmts.mapM Synth.resolveStmtExpr let stmts' := results.map (·.1) let lastTy := match results.getLast? with | some (_, ty) => ty @@ -807,17 +809,17 @@ def synthBlock (exprMd : StmtExprMd) `decreases` is resolved without a type check today (the intended target is a numeric type), body is synthesized; the construct itself synthesizes `TVoid`. -/ -def synthWhile (exprMd : StmtExprMd) +def Synth.while (exprMd : StmtExprMd) (cond : StmtExprMd) (invs : List StmtExprMd) (dec : Option StmtExprMd) (body : StmtExprMd) (h : exprMd.val = .While cond invs dec body) : ResolveM (StmtExpr × HighTypeMd) := do - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } let invs' ← invs.attach.mapM (fun a => have := a.property; do - checkStmtExpr a.val { val := .TBool, source := a.val.source }) + Check.resolveStmtExpr a.val { val := .TBool, source := a.val.source }) let dec' ← dec.attach.mapM (fun a => have := a.property; do - let (e', _) ← synthStmtExpr a.val; pure e') - let (body', _) ← synthStmtExpr body + let (e', _) ← Synth.resolveStmtExpr a.val; pure e') + let (body', _) ← Synth.resolveStmtExpr body pure (.While cond' invs' dec' body', { val := .TVoid, source := exprMd.source }) termination_by (exprMd, 1) decreasing_by @@ -830,7 +832,7 @@ def synthWhile (exprMd : StmtExprMd) omega /-- Rule **Exit**: `Γ ⊢ Exit target ⇒ TVoid`. -/ -def synthExit (target : String) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.exit (target : String) (source : Option FileRange) : StmtExpr × HighTypeMd := (.Exit target, { val := .TVoid, source := source }) /-- Rules **Return-None** / **Return-Some** / **Return-Void-Error** / @@ -851,15 +853,15 @@ def synthExit (target : String) (source : Option FileRange) : StmtExpr × HighTy declared output parameters); `return e` syntactically takes a single `Option StmtExpr` and cannot carry multiple values, so it is flagged with a diagnostic pointing users at the named-output convention. -/ -def synthReturn (exprMd : StmtExprMd) (source : Option FileRange) +def Synth.return (exprMd : StmtExprMd) (source : Option FileRange) (val : Option StmtExprMd) (h : exprMd.val = .Return val) : ResolveM (StmtExpr × HighTypeMd) := do let expected := (← get).expectedReturnTypes let val' ← val.attach.mapM (fun a => have := a.property; do match expected with - | some [singleOutput] => checkStmtExpr a.val singleOutput - | _ => let (e', _) ← synthStmtExpr a.val; pure e') + | some [singleOutput] => Check.resolveStmtExpr a.val singleOutput + | _ => let (e', _) ← Synth.resolveStmtExpr a.val; pure e') -- Arity/shape diagnostics independent of the value's own type. match val, expected with | none, some [] => pure () @@ -892,21 +894,21 @@ def synthReturn (exprMd : StmtExprMd) (source : Option FileRange) `Quantifier`. Empty blocks reduce to a subsumption check of `TVoid` against `expected` — the same check `[⇐] Block-Empty` performs when `T` admits `TVoid`. -/ -def checkBlock (exprMd : StmtExprMd) +def Check.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do withScope do let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do have : s ∈ stmts := List.dropLast_subset stmts hMem - let (s', _) ← synthStmtExpr s; pure s') + let (s', _) ← Synth.resolveStmtExpr s; pure s') match _lastResult: stmts.getLast? with | none => checkSubtype source expected { val := .TVoid, source := source } pure { val := .Block init' label, source := source } | some last => have := List.mem_of_getLast? _lastResult - let last' ← checkStmtExpr last expected + let last' ← Check.resolveStmtExpr last expected pure { val := .Block (init' ++ [last']) label, source := source } termination_by (exprMd, 0) decreasing_by @@ -924,13 +926,13 @@ def checkBlock (exprMd : StmtExprMd) Without an else branch, the construct can only succeed when `expected` admits `TVoid` — the same subsumption check `[⇐] Block-Empty` performs for an empty block. -/ -def checkIfThenElse (exprMd : StmtExprMd) +def Check.ifThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .IfThenElse cond thenBr elseBr) : ResolveM StmtExprMd := do - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } - let thenBr' ← checkStmtExpr thenBr expected - let elseBr' ← elseBr.attach.mapM (fun ⟨e, _⟩ => checkStmtExpr e expected) + let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } + let thenBr' ← Check.resolveStmtExpr thenBr expected + let elseBr' ← elseBr.attach.mapM (fun ⟨e, _⟩ => Check.resolveStmtExpr e expected) if elseBr.isNone then checkSubtype source expected { val := .TVoid, source := source } pure { val := .IfThenElse cond' thenBr' elseBr', source := source } @@ -947,11 +949,11 @@ def checkIfThenElse (exprMd : StmtExprMd) /-- Rule **Assert**: `cond` is checked against `TBool`; the construct synthesizes `TVoid`. -/ -def synthAssert (exprMd : StmtExprMd) +def Synth.assert (exprMd : StmtExprMd) (condExpr : StmtExprMd) (summary : Option String) (source : Option FileRange) (h : exprMd.val = .Assert ⟨condExpr, summary⟩) : ResolveM (StmtExpr × HighTypeMd) := do - let cond' ← checkStmtExpr condExpr { val := .TBool, source := condExpr.source } + let cond' ← Check.resolveStmtExpr condExpr { val := .TBool, source := condExpr.source } pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) termination_by (exprMd, 1) decreasing_by @@ -963,11 +965,11 @@ def synthAssert (exprMd : StmtExprMd) /-- Rule **Assume**: `cond` is checked against `TBool`; the construct synthesizes `TVoid`. -/ -def synthAssume (exprMd : StmtExprMd) +def Synth.assume (exprMd : StmtExprMd) (cond : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assume cond) : ResolveM (StmtExpr × HighTypeMd) := do - let cond' ← checkStmtExpr cond { val := .TBool, source := cond.source } + let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } pure (.Assume cond', { val := .TVoid, source := source }) termination_by (exprMd, 1) decreasing_by @@ -991,8 +993,8 @@ def synthAssume (exprMd : StmtExprMd) value to assign. The construct synthesizes the RHS's type, so that expression-position assignments like `x ++ (y := s)` see a string in the second operand; statement-position uses are accommodated by - `checkAssign`, which accepts `TVoid` as the expected type. -/ -def synthAssign (exprMd : StmtExprMd) + `Check.assign`, which accepts `TVoid` as the expected type. -/ +def Synth.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assign targets value) : ResolveM (StmtExpr × HighTypeMd) := do @@ -1003,14 +1005,14 @@ def synthAssign (exprMd : StmtExprMd) let ref' ← resolveRef ref source pure (⟨.Local ref', vs⟩ : VariableMd) | .Field target fieldName => - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source pure (⟨.Field target' fieldName', vs⟩ : VariableMd) | .Declare param => let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let (value', valueTy) ← synthStmtExpr value + let (value', valueTy) ← Synth.resolveStmtExpr value let targetType (t : VariableMd) : ResolveM HighTypeMd := do match t.val with | .Local ref => getVarType ref @@ -1039,7 +1041,7 @@ def synthAssign (exprMd : StmtExprMd) statement of a block in an else-less `if` (whose branch is checked against `TVoid`) without firing a subsumption error against the RHS's type. For non-`TVoid` expected types, falls back to subsumption. -/ -def checkAssign (exprMd : StmtExprMd) +def Check.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Assign targets value) : ResolveM StmtExprMd := do @@ -1050,14 +1052,14 @@ def checkAssign (exprMd : StmtExprMd) let ref' ← resolveRef ref source pure (⟨.Local ref', vs⟩ : VariableMd) | .Field target fieldName => - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName source pure (⟨.Field target' fieldName', vs⟩ : VariableMd) | .Declare param => let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let (value', valueTy) ← synthStmtExpr value + let (value', valueTy) ← Synth.resolveStmtExpr value let targetType (t : VariableMd) : ResolveM HighTypeMd := do match t.val with | .Local ref => getVarType ref @@ -1089,13 +1091,13 @@ def checkAssign (exprMd : StmtExprMd) constant); each argument is synthesized and checked against the corresponding parameter type. The result type is the (possibly multi-valued) declared output type from `getCallInfo`. -/ -def synthStaticCall (exprMd : StmtExprMd) +def Synth.staticCall (exprMd : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) (h : exprMd.val = .StaticCall callee args) : ResolveM (StmtExpr × HighTypeMd) := do let callee' ← resolveRef callee source (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) - let results ← args.mapM synthStmtExpr + let results ← args.mapM Synth.resolveStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee @@ -1113,15 +1115,15 @@ def synthStaticCall (exprMd : StmtExprMd) /-- Rule **Instance-Call**: target is synthesized; callee resolves to an instance or static procedure; arguments are checked pairwise against the callee's parameter types after dropping `self`. -/ -def synthInstanceCall (exprMd : StmtExprMd) +def Synth.instanceCall (exprMd : StmtExprMd) (target : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) (h : exprMd.val = .InstanceCall target callee args) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let callee' ← resolveRef callee source (expected := #[.instanceProcedure, .staticProcedure]) - let results ← args.mapM synthStmtExpr + let results ← args.mapM Synth.resolveStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee @@ -1154,13 +1156,13 @@ def synthInstanceCall (exprMd : StmtExprMd) `int + real` passes since each operand individually passes `Numeric`; a proper fix needs numeric promotion or unification), `TString` for concatenation. -/ -def synthPrimitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) +def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) (op : Operation) (args : List StmtExprMd) (source : Option FileRange) (h_expr : expr = .PrimitiveOp op args) (h : exprMd.val = .PrimitiveOp op args) : ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr -- carries the constructor identity for `expr` in diagnostics - let results ← args.mapM synthStmtExpr + let results ← args.mapM Synth.resolveStmtExpr let args' := results.map (·.1) let argTypes := results.map (·.2) let resultTy := match op with @@ -1206,7 +1208,7 @@ def synthPrimitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) /-- Rules **New-Ok** / **New-Fallback**: when `ref` resolves to a composite or datatype, the type is `UserDefined ref`; otherwise `Unknown` (suppresses cascading errors after the kind diagnostic has already fired). -/ -def synthNew (ref : Identifier) (source : Option FileRange) : +def Synth.new (ref : Identifier) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let ref' ← resolveRef ref source (expected := #[.compositeType, .datatypeDefinition]) @@ -1223,11 +1225,11 @@ def synthNew (ref : Identifier) (source : Option FileRange) : cast is the user's claim. The synthesized type is `T`. `IsType` is the runtime test counterpart and synthesizes `TBool`. -/ -def synthAsType (exprMd : StmtExprMd) +def Synth.asType (exprMd : StmtExprMd) (target : StmtExprMd) (ty : HighTypeMd) (h : exprMd.val = .AsType target ty) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let ty' ← resolveHighType ty pure (.AsType target' ty', ty') termination_by (exprMd, 1) @@ -1238,11 +1240,11 @@ def synthAsType (exprMd : StmtExprMd) omega /-- Rule **IsType**: `target` is resolved; the synthesized type is `TBool`. -/ -def synthIsType (exprMd : StmtExprMd) +def Synth.isType (exprMd : StmtExprMd) (target : StmtExprMd) (ty : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .IsType target ty) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', _) ← synthStmtExpr target + let (target', _) ← Synth.resolveStmtExpr target let ty' ← resolveHighType ty pure (.IsType target' ty', { val := .TBool, source := source }) termination_by (exprMd, 1) @@ -1259,14 +1261,14 @@ def synthIsType (exprMd : StmtExprMd) user-defined types, while `Cat === Animal` is accepted when `Cat` extends `Animal` (the gradual `Unknown` wildcard makes either side flow freely against the other). -/ -def synthRefEq (exprMd : StmtExprMd) (expr : StmtExpr) +def Synth.refEq (exprMd : StmtExprMd) (expr : StmtExpr) (lhs rhs : StmtExprMd) (source : Option FileRange) (h_expr : expr = .ReferenceEquals lhs rhs) (h : exprMd.val = .ReferenceEquals lhs rhs) : ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr - let (lhs', lhsTy) ← synthStmtExpr lhs - let (rhs', rhsTy) ← synthStmtExpr rhs + let (lhs', lhsTy) ← Synth.resolveStmtExpr lhs + let (rhs', rhsTy) ← Synth.resolveStmtExpr rhs let ctx := (← get).typeContext unless isReference ctx lhsTy do typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy @@ -1289,14 +1291,14 @@ def synthRefEq (exprMd : StmtExprMd) (expr : StmtExpr) `T_t` (or the enclosing instance type), and `newVal` checked against the field's declared type. The synthesized type is `T_t` — updating a field on a pure type produces a new value of the same type. -/ -def synthPureFieldUpdate (exprMd : StmtExprMd) +def Synth.pureFieldUpdate (exprMd : StmtExprMd) (target : StmtExprMd) (fieldName : Identifier) (newVal : StmtExprMd) (h : exprMd.val = .PureFieldUpdate target fieldName newVal) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', targetTy) ← synthStmtExpr target + let (target', targetTy) ← Synth.resolveStmtExpr target let fieldName' ← resolveFieldRef target' fieldName target.source let fieldTy ← getVarType fieldName' - let newVal' ← checkStmtExpr newVal fieldTy + let newVal' ← Check.resolveStmtExpr newVal fieldTy pure (.PureFieldUpdate target' fieldName' newVal', targetTy) termination_by (exprMd, 1) decreasing_by @@ -1313,7 +1315,7 @@ def synthPureFieldUpdate (exprMd : StmtExprMd) the body against `TBool` since a quantifier is a proposition. Without that body check, `forall x: int :: x + 1` would be silently accepted. The construct itself synthesizes `TBool`. -/ -def synthQuantifier (exprMd : StmtExprMd) +def Synth.quantifier (exprMd : StmtExprMd) (mode : QuantifierMode) (param : Parameter) (trigger : Option StmtExprMd) (body : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Quantifier mode param trigger body) : @@ -1322,8 +1324,8 @@ def synthQuantifier (exprMd : StmtExprMd) let paramTy' ← resolveHighType param.type let paramName' ← defineNameCheckDup param.name (.quantifierVar param.name paramTy') let trigger' ← trigger.attach.mapM (fun pv => have := pv.property; do - let (e', _) ← synthStmtExpr pv.val; pure e') - let body' ← checkStmtExpr body { val := .TBool, source := body.source } + let (e', _) ← Synth.resolveStmtExpr pv.val; pure e') + let body' ← Check.resolveStmtExpr body { val := .TBool, source := body.source } pure (.Quantifier mode ⟨paramName', paramTy'⟩ trigger' body', { val := .TBool, source := source }) termination_by (exprMd, 1) decreasing_by @@ -1336,11 +1338,11 @@ def synthQuantifier (exprMd : StmtExprMd) /-- Rule **Assigned**: `name` is synthesized; the construct synthesizes `TBool`. -/ -def synthAssigned (exprMd : StmtExprMd) +def Synth.assigned (exprMd : StmtExprMd) (name : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assigned name) : ResolveM (StmtExpr × HighTypeMd) := do - let (name', _) ← synthStmtExpr name + let (name', _) ← Synth.resolveStmtExpr name pure (.Assigned name', { val := .TBool, source := source }) termination_by (exprMd, 1) decreasing_by @@ -1350,11 +1352,11 @@ def synthAssigned (exprMd : StmtExprMd) omega /-- Rule **Old**: `Γ ⊢ v ⇒ T ⊢ Old v ⇒ T`. -/ -def synthOld (exprMd : StmtExprMd) +def Synth.old (exprMd : StmtExprMd) (val : StmtExprMd) (h : exprMd.val = .Old val) : ResolveM (StmtExpr × HighTypeMd) := do - let (val', valTy) ← synthStmtExpr val + let (val', valTy) ← Synth.resolveStmtExpr val pure (.Old val', valTy) termination_by (exprMd, 1) decreasing_by @@ -1367,13 +1369,13 @@ def synthOld (exprMd : StmtExprMd) (`UserDefined` or `Unknown`) — `Fresh` only makes sense on heap-allocated references, so `fresh(5)` is rejected. The construct itself synthesizes `TBool`. -/ -def synthFresh (exprMd : StmtExprMd) (expr : StmtExpr) +def Synth.fresh (exprMd : StmtExprMd) (expr : StmtExpr) (val : StmtExprMd) (source : Option FileRange) (h_expr : expr = .Fresh val) (h : exprMd.val = .Fresh val) : ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr - let (val', valTy) ← synthStmtExpr val + let (val', valTy) ← Synth.resolveStmtExpr val unless isReference (← get).typeContext valTy do typeMismatch val'.source (some expr) "expected a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) @@ -1386,12 +1388,12 @@ def synthFresh (exprMd : StmtExprMd) (expr : StmtExpr) /-- Rule **ProveBy**: `v` and `proof` are both synthesized; the construct's type is `v`'s type — `proof` is a hint for downstream verification. -/ -def synthProveBy (exprMd : StmtExprMd) +def Synth.proveBy (exprMd : StmtExprMd) (val proof : StmtExprMd) (h : exprMd.val = .ProveBy val proof) : ResolveM (StmtExpr × HighTypeMd) := do - let (val', valTy) ← synthStmtExpr val - let (proof', _) ← synthStmtExpr proof + let (val', valTy) ← Synth.resolveStmtExpr val + let (proof', _) ← Synth.resolveStmtExpr proof pure (.ProveBy val' proof', valTy) termination_by (exprMd, 1) decreasing_by @@ -1411,7 +1413,7 @@ def synthProveBy (exprMd : StmtExprMd) wildcarded through `Unknown`. Otherwise an error is emitted ("'this' is not allowed outside instance methods") and the type collapses to `Unknown` to suppress cascading errors. -/ -def synthThis (source : Option FileRange) : +def Synth.this (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let s ← get match s.instanceTypeName with @@ -1429,11 +1431,11 @@ def synthThis (source : Option FileRange) : -- ### Untyped forms /-- Rule **Abstract**: synthesizes `Unknown`. -/ -def synthAbstract (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.abstract (source : Option FileRange) : StmtExpr × HighTypeMd := (.Abstract, { val := .Unknown, source := source }) /-- Rule **All**: synthesizes `Unknown`. -/ -def synthAll (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.all (source : Option FileRange) : StmtExpr × HighTypeMd := (.All, { val := .Unknown, source := source }) -- ### ContractOf @@ -1458,11 +1460,11 @@ def synthAll (source : Option FileRange) : StmtExpr × HighTypeMd := `contractOf` production today, and the translator emits "not yet implemented" for it. The typing rule exists so resolution remains exhaustive over `StmtExpr`. -/ -def synthContractOf (exprMd : StmtExprMd) +def Synth.contractOf (exprMd : StmtExprMd) (ty : ContractType) (fn : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .ContractOf ty fn) : ResolveM (StmtExpr × HighTypeMd) := do - let (fn', _) ← synthStmtExpr fn + let (fn', _) ← Synth.resolveStmtExpr fn let s ← get let fnIsProcRef : Bool := match fn'.val with | .Var (.Local ref) => @@ -1492,7 +1494,7 @@ def synthContractOf (exprMd : StmtExprMd) /-- Rules **Hole-Some** / **Hole-None-Synth**: a typed hole synthesizes its annotation; an untyped hole in synth position synthesizes `Unknown`. -/ -def synthHole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange) : +def Synth.hole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do match type with | some ty => @@ -1514,16 +1516,19 @@ def synthHole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange the diagnostic *"hole annotated with 'T_resolution' but context expects 'T_inference'"*, surfacing what would otherwise be a silent overwrite. -/ -def checkHoleNone (det : Bool) (expected : HighTypeMd) (source : Option FileRange) : +def Check.holeNone (det : Bool) (expected : HighTypeMd) (source : Option FileRange) : StmtExprMd := { val := .Hole det (some expected), source := source } -end +end -- mutual +end Resolution + +open Resolution /-- Resolve a statement expression, discarding the synthesized type. Use when only the resolved expression is needed (invariants, decreases, etc.). -/ private def resolveStmtExpr (e : StmtExprMd) : ResolveM StmtExprMd := do - let (e', _) ← synthStmtExpr e; pure e' + let (e', _) ← Synth.resolveStmtExpr e; pure e' /-- Resolve a parameter: assign a fresh ID and add to scope. -/ def resolveParameter (param : Parameter) : ResolveM Parameter := do @@ -1535,7 +1540,7 @@ def resolveParameter (param : Parameter) : ResolveM Parameter := do def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do match body with | .Transparent b => - let (b', ty) ← synthStmtExpr b + let (b', ty) ← Synth.resolveStmtExpr b return (.Transparent b', ty) | .Opaque posts impl mods => let posts' ← posts.mapM (·.mapM resolveStmtExpr) @@ -1656,8 +1661,8 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do -- in scope when resolving the constraint and witness expressions. let (valueName', constraint', witness') ← withScope do let valueName' ← defineNameCheckDup ct.valueName (.quantifierVar ct.valueName base') - let (constraint', _) ← synthStmtExpr ct.constraint - let (witness', _) ← synthStmtExpr ct.witness + let (constraint', _) ← Synth.resolveStmtExpr ct.constraint + let (witness', _) ← Synth.resolveStmtExpr ct.witness return (valueName', constraint', witness') return .Constrained { name := ctName', base := base', valueName := valueName', constraint := constraint', witness := witness' } @@ -1683,7 +1688,7 @@ def resolveTypeDefinition (td : TypeDefinition) : ResolveM TypeDefinition := do /-- Resolve a constant definition. -/ def resolveConstant (c : Constant) : ResolveM Constant := do let ty' ← resolveHighType c.type - let init' ← c.initializer.mapM (checkStmtExpr · ty') + let init' ← c.initializer.mapM (Check.resolveStmtExpr · ty') let name' ← resolveRef c.name return { name := name', type := ty', initializer := init' } diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 7583d4d079..b9d1070f42 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -160,8 +160,8 @@ mismatches against the surrounding context become diagnostics. The implementatio There are two operations on expressions, written here in standard bidirectional notation: ``` -Γ ⊢ e ⇒ T -- "e synthesizes T" (synthStmtExpr) -Γ ⊢ e ⇐ T -- "e checks against T" (checkStmtExpr) +Γ ⊢ e ⇒ T -- "e synthesizes T" (Synth.resolveStmtExpr) +Γ ⊢ e ⇐ T -- "e checks against T" (Check.resolveStmtExpr) ``` Synthesis returns a type inferred from the expression itself; checking verifies that the @@ -172,12 +172,12 @@ by a single change-of-direction rule, *subsumption*: $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` The two judgments are implemented as -{name Strata.Laurel.synthStmtExpr}`synthStmtExpr` and -{name Strata.Laurel.checkStmtExpr}`checkStmtExpr`: +{name Strata.Laurel.Resolution.Synth.resolveStmtExpr}`Synth.resolveStmtExpr` and +{name Strata.Laurel.Resolution.Check.resolveStmtExpr}`Check.resolveStmtExpr`: -{docstring Strata.Laurel.synthStmtExpr} +{docstring Strata.Laurel.Resolution.Synth.resolveStmtExpr} -{docstring Strata.Laurel.checkStmtExpr} +{docstring Strata.Laurel.Resolution.Check.resolveStmtExpr} ### Gradual typing @@ -238,43 +238,43 @@ Each LaTeX rule below is followed by the docstring of the helper that implements $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` -Fallback in {name Strata.Laurel.checkStmtExpr}`checkStmtExpr` whenever no bespoke check +Fallback in {name Strata.Laurel.Resolution.Check.resolveStmtExpr}`Check.resolveStmtExpr` whenever no bespoke check rule applies. ### Literals $$`\frac{}{\Gamma \vdash \mathsf{LiteralInt}\;n \Rightarrow \mathsf{TInt}} \quad \text{([⇒] Lit-Int)}` -{docstring Strata.Laurel.synthLitInt} +{docstring Strata.Laurel.Resolution.Synth.litInt} $$`\frac{}{\Gamma \vdash \mathsf{LiteralBool}\;b \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Lit-Bool)}` -{docstring Strata.Laurel.synthLitBool} +{docstring Strata.Laurel.Resolution.Synth.litBool} $$`\frac{}{\Gamma \vdash \mathsf{LiteralString}\;s \Rightarrow \mathsf{TString}} \quad \text{([⇒] Lit-String)}` -{docstring Strata.Laurel.synthLitString} +{docstring Strata.Laurel.Resolution.Synth.litString} $$`\frac{}{\Gamma \vdash \mathsf{LiteralDecimal}\;d \Rightarrow \mathsf{TReal}} \quad \text{([⇒] Lit-Decimal)}` -{docstring Strata.Laurel.synthLitDecimal} +{docstring Strata.Laurel.Resolution.Synth.litDecimal} ### Variables $$`\frac{\Gamma(x) = T}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Local}\;x) \Rightarrow T} \quad \text{([⇒] Var-Local)}` -{docstring Strata.Laurel.synthVarLocal} +{docstring Strata.Laurel.Resolution.Synth.varLocal} $$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Field}\;e\;f) \Rightarrow T_f} \quad \text{([⇒] Var-Field)}` -{docstring Strata.Laurel.synthVarField} +{docstring Strata.Laurel.Resolution.Synth.varField} $$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T\rangle) \Rightarrow \mathsf{TVoid} \dashv \Gamma, x : T} \quad \text{([⇒] Var-Declare)}` `⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the remainder of the enclosing scope. -{docstring Strata.Laurel.synthVarDeclare} +{docstring Strata.Laurel.Resolution.Synth.varDeclare} ### Control flow @@ -282,13 +282,13 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t \sqcup T_e} \quad \text{([⇒] If)}` -{docstring Strata.Laurel.synthIfThenElse} +{docstring Strata.Laurel.Resolution.Synth.ifThenElse} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \Gamma \vdash \mathit{elseBr} \Leftarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Leftarrow T} \quad \text{([⇐] If)}` $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Leftarrow T} \quad \text{([⇐] If-NoElse)}` -{docstring Strata.Laurel.checkIfThenElse} +{docstring Strata.Laurel.Resolution.Check.ifThenElse} $$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block)}` @@ -299,17 +299,17 @@ block. $$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Block-Empty)}` -{docstring Strata.Laurel.synthBlock} +{docstring Strata.Laurel.Resolution.Synth.block} $$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` $$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Empty)}` -{docstring Strata.Laurel.checkBlock} +{docstring Strata.Laurel.Resolution.Check.block} $$`\frac{}{\Gamma \vdash \mathsf{Exit}\;\mathit{target} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Exit)}` -{docstring Strata.Laurel.synthExit} +{docstring Strata.Laurel.Resolution.Synth.exit} $$`\frac{}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-None)}` @@ -319,21 +319,21 @@ $$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = []}{\Gamma \vdash \mathsf{Ret $$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇒] Return-Multi-Error)}` -{docstring Strata.Laurel.synthReturn} +{docstring Strata.Laurel.Resolution.Synth.return} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{dec} \Leftarrow {?} \quad \Gamma \vdash \mathit{body} \Rightarrow \_}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{dec}\;\mathit{body} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] While)}` -{docstring Strata.Laurel.synthWhile} +{docstring Strata.Laurel.Resolution.Synth.while} ### Verification statements $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assert)}` -{docstring Strata.Laurel.synthAssert} +{docstring Strata.Laurel.Resolution.Synth.assert} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assume}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assume)}` -{docstring Strata.Laurel.synthAssume} +{docstring Strata.Laurel.Resolution.Synth.assume} ### Assignment @@ -344,9 +344,9 @@ The target's declared type `T_i` comes from the variable's scope entry (for {name Strata.Laurel.Variable.Local}`Local` and {name Strata.Laurel.Variable.Field}`Field`) or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. -{docstring Strata.Laurel.synthAssign} +{docstring Strata.Laurel.Resolution.Synth.assign} -{docstring Strata.Laurel.checkAssign} +{docstring Strata.Laurel.Resolution.Check.assign} ### Calls @@ -354,11 +354,11 @@ $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` -{docstring Strata.Laurel.synthStaticCall} +{docstring Strata.Laurel.Resolution.Synth.staticCall} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Instance-Call)}` -{docstring Strata.Laurel.synthInstanceCall} +{docstring Strata.Laurel.Resolution.Synth.instanceCall} ### Primitive operations @@ -376,7 +376,7 @@ $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \Gamma $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` -{docstring Strata.Laurel.synthPrimitiveOp} +{docstring Strata.Laurel.Resolution.Synth.primitiveOp} ### Object forms @@ -384,15 +384,15 @@ $$`\frac{\Gamma(\mathit{ref}) \text{ is a composite or datatype } T}{\Gamma \vda $$`\frac{\Gamma(\mathit{ref}) \text{ is not a composite or datatype}}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] New-Fallback)}` -{docstring Strata.Laurel.synthNew} +{docstring Strata.Laurel.Resolution.Synth.new} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{AsType}\;\mathit{target}\;T \Rightarrow T} \quad \text{([⇒] AsType)}` -{docstring Strata.Laurel.synthAsType} +{docstring Strata.Laurel.Resolution.Synth.asType} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` -{docstring Strata.Laurel.synthIsType} +{docstring Strata.Laurel.Resolution.Synth.isType} $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad \mathsf{isReference}\;T_l \quad \mathsf{isReference}\;T_r \quad T_l \sim T_r}{\Gamma \vdash \mathsf{ReferenceEquals}\;\mathit{lhs}\;\mathit{rhs} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] RefEq)}` @@ -401,33 +401,33 @@ or {name Strata.Laurel.HighType.Unknown}`Unknown` type. `~` is the consistency r {name Strata.Laurel.isConsistent}`isConsistent` — symmetric, with the {name Strata.Laurel.HighType.Unknown}`Unknown` wildcard. -{docstring Strata.Laurel.synthRefEq} +{docstring Strata.Laurel.Resolution.Synth.refEq} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow T_t \quad \Gamma(f) = T_f \quad \Gamma \vdash \mathit{newVal} \Leftarrow T_f}{\Gamma \vdash \mathsf{PureFieldUpdate}\;\mathit{target}\;f\;\mathit{newVal} \Rightarrow T_t} \quad \text{([⇒] PureFieldUpdate)}` -{docstring Strata.Laurel.synthPureFieldUpdate} +{docstring Strata.Laurel.Resolution.Synth.pureFieldUpdate} ### Verification expressions $$`\frac{\Gamma, x : T \vdash \mathit{body} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Quantifier}\;\mathit{mode}\;\langle x, T\rangle\;\mathit{trig}\;\mathit{body} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Quantifier)}` -{docstring Strata.Laurel.synthQuantifier} +{docstring Strata.Laurel.Resolution.Synth.quantifier} $$`\frac{\Gamma \vdash \mathit{name} \Rightarrow \_}{\Gamma \vdash \mathsf{Assigned}\;\mathit{name} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Assigned)}` -{docstring Strata.Laurel.synthAssigned} +{docstring Strata.Laurel.Resolution.Synth.assigned} $$`\frac{\Gamma \vdash v \Rightarrow T}{\Gamma \vdash \mathsf{Old}\;v \Rightarrow T} \quad \text{([⇒] Old)}` -{docstring Strata.Laurel.synthOld} +{docstring Strata.Laurel.Resolution.Synth.old} $$`\frac{\Gamma \vdash v \Rightarrow T \quad \mathsf{isReference}\;T}{\Gamma \vdash \mathsf{Fresh}\;v \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Fresh)}` -{docstring Strata.Laurel.synthFresh} +{docstring Strata.Laurel.Resolution.Synth.fresh} $$`\frac{\Gamma \vdash v \Rightarrow T \quad \Gamma \vdash \mathit{proof} \Rightarrow \_}{\Gamma \vdash \mathsf{ProveBy}\;v\;\mathit{proof} \Rightarrow T} \quad \text{([⇒] ProveBy)}` -{docstring Strata.Laurel.synthProveBy} +{docstring Strata.Laurel.Resolution.Synth.proveBy} ### Self reference @@ -435,15 +435,15 @@ $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{some}\;T}{\Gamma \vdash \mat $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{none}}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{Unknown}\;\;[\text{emits “‘this’ is not allowed outside instance methods”}]} \quad \text{([⇒] This-Outside)}` -{docstring Strata.Laurel.synthThis} +{docstring Strata.Laurel.Resolution.Synth.this} ### Untyped forms $$`\frac{}{\Gamma \vdash \mathsf{Abstract}\;/\;\mathsf{All}\;\ldots \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Abstract / All)}` -{docstring Strata.Laurel.synthAbstract} +{docstring Strata.Laurel.Resolution.Synth.abstract} -{docstring Strata.Laurel.synthAll} +{docstring Strata.Laurel.Resolution.Synth.all} ### ContractOf @@ -453,7 +453,7 @@ $$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma $$`\frac{\mathit{fn} \text{ is not a procedure reference}}{\Gamma \vdash \mathsf{ContractOf}\;\ldots\;\mathit{fn} \rightsquigarrow \text{error: “‘contractOf’ expected a procedure reference”}} \quad \text{([⇒] ContractOf-Error)}` -{docstring Strata.Laurel.synthContractOf} +{docstring Strata.Laurel.Resolution.Synth.contractOf} ### Holes @@ -461,11 +461,11 @@ $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T) \Rightarrow T} \qu $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Hole-None)}` -{docstring Strata.Laurel.synthHole} +{docstring Strata.Laurel.Resolution.Synth.hole} $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \;\;\mapsto\;\; \mathsf{Hole}\;d\;(\mathsf{some}\;T)} \quad \text{([⇐] Hole-None)}` -{docstring Strata.Laurel.checkHoleNone} +{docstring Strata.Laurel.Resolution.Check.holeNone} ## Future structural changes @@ -504,7 +504,7 @@ just wasted work and a maintenance hazard. `InferHoleTypes` walks the post-resolution AST a second time to annotate holes. Now that \[⇐\] Hole-None writes the expected type during resolution for holes in check-mode positions, the post-pass only needs to handle holes in synth-only positions (e.g. call -arguments resolved through `synthStmtExpr` instead of `checkStmtExpr`). As more constructs +arguments resolved through `Synth.resolveStmtExpr` instead of `Check.resolveStmtExpr`). As more constructs gain bespoke check rules, fewer holes will reach `InferHoleTypes`; eventually the pass can be deleted entirely. From 5826fffe3f98a17d850c93e25c1e45670cd1402d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 15:32:18 -0400 Subject: [PATCH 115/189] cleanup rules presentation --- Strata/Languages/Laurel/Resolution.lean | 307 ++++++++++++++++-------- docs/verso/LaurelDoc.lean | 15 +- 2 files changed, 216 insertions(+), 106 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 080142d15c..cb72db5599 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -683,41 +683,46 @@ def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : Resolv -- ### Literals -/-- Rule **Lit-Int**: `Γ ⊢ LiteralInt n ⇒ TInt`. -/ +/-- `Γ ⊢ LiteralInt n ⇒ TInt` -/ def Synth.litInt (v : Int) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralInt v, { val := .TInt, source := source }) -/-- Rule **Lit-Bool**: `Γ ⊢ LiteralBool b ⇒ TBool`. -/ +/-- `Γ ⊢ LiteralBool b ⇒ TBool` -/ def Synth.litBool (v : Bool) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralBool v, { val := .TBool, source := source }) -/-- Rule **Lit-String**: `Γ ⊢ LiteralString s ⇒ TString`. -/ +/-- `Γ ⊢ LiteralString s ⇒ TString` -/ def Synth.litString (v : String) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralString v, { val := .TString, source := source }) -/-- Rule **Lit-Decimal**: `Γ ⊢ LiteralDecimal d ⇒ TReal`. -/ +/-- `Γ ⊢ LiteralDecimal d ⇒ TReal` -/ def Synth.litDecimal (v : Decimal) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralDecimal v, { val := .TReal, source := source }) -- ### Variables -/-- Rule **Var-Local**: `Γ(x) = T ⊢ Var (.Local x) ⇒ T`. Resolves `ref` against - the lexical scope and reads its declared type. -/ +/-- `Γ(x) = T ∴ Γ ⊢ Var (.Local x) ⇒ T` + + Resolves `ref` against the lexical scope and reads its declared type. -/ def Synth.varLocal (ref : Identifier) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let ref' ← resolveRef ref source let ty ← getVarType ref pure (.Var (.Local ref'), ty) -/-- Rule **Var-Declare**: extends the surrounding scope with `x : T` and - synthesizes `TVoid` (the declaration itself produces no value). -/ +/-- `x ∉ dom(Γ) ∴ Γ ⊢ Var (.Declare ⟨x, T⟩) ⇒ TVoid ⊣ Γ, x : T` + + `⊣ Γ, x : T` records that the surrounding scope is extended with the + new binding for the remainder of the enclosing scope. The declaration + itself produces no value, hence `TVoid`. -/ def Synth.varDeclare (param : Parameter) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (.Var (.Declare ⟨name', ty'⟩), { val := .TVoid, source := source }) -/-- Rule **Var-Field**: `Γ ⊢ e ⇒ _, Γ(f) = T_f ⊢ Var (.Field e f) ⇒ T_f`. +/-- `Γ ⊢ e ⇒ _, Γ(f) = T_f ∴ Γ ⊢ Var (.Field e f) ⇒ T_f` + `f` is looked up against the type of `e` (or the enclosing instance type for `self.f`); the typing rule itself is path-agnostic. -/ def Synth.varField (exprMd : StmtExprMd) @@ -738,7 +743,15 @@ def Synth.varField (exprMd : StmtExprMd) -- ### Control flow -/-- Rules **If-NoElse** / **If-Synth**: `cond` is checked against `TBool`. +/-- When there is an else branch: + + `Γ ⊢ cond ⇐ TBool, Γ ⊢ thenBr ⇒ T_t, Γ ⊢ elseBr ⇒ T_e ∴ Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T_t ⊔ T_e` + + Otherwise: + + `Γ ⊢ cond ⇐ TBool, Γ ⊢ thenBr ⇐ TVoid ∴ Γ ⊢ IfThenElse cond thenBr none ⇒ TVoid` + + `cond` is checked against `TBool`. With no else branch, the construct is a statement — `thenBr` is checked against `TVoid` and the result is `TVoid`, so `x : int := if c then 5` is rejected at the branch rather than slipping through to a downstream @@ -778,7 +791,13 @@ def Synth.ifThenElse (exprMd : StmtExprMd) try omega) | (apply Prod.Lex.right; decide) -/-- Rules **Block-Synth** / **Block-Synth-Empty**: each statement is resolved +/-- Cases on whether the statement list is empty. + + `Γ_0 = Γ, Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n), Γ_{n-1} ⊢ s_n ⇒ T ∴ Γ ⊢ Block [s_1; …; s_n] label ⇒ T` + + `Γ ⊢ Block [] label ⇒ TVoid` + + Each statement is resolved in the scope produced by its predecessor and may itself extend it (`Var (.Declare …)` does); non-last statements are synthesized but their types discarded (the lax rule, matching Java/Python/JS where `f(x);` is @@ -805,10 +824,11 @@ def Synth.block (exprMd : StmtExprMd) have := List.sizeOf_lt_of_mem ‹_ ∈ stmts› omega -/-- Rule **While**: `cond ⇐ TBool`, each invariant `⇐ TBool`, optional - `decreases` is resolved without a type check today (the intended target - is a numeric type), body is synthesized; the construct itself - synthesizes `TVoid`. -/ +/-- `Γ ⊢ cond ⇐ TBool, Γ ⊢ invs_i ⇐ TBool, Γ ⊢ dec ⇐ ?, Γ ⊢ body ⇒ _ ∴ Γ ⊢ While cond invs dec body ⇒ TVoid` + + `cond ⇐ TBool`, each invariant `⇐ TBool`, optional `decreases` is + resolved without a type check today (the intended target is a numeric + type), body is synthesized; the construct itself synthesizes `TVoid`. -/ def Synth.while (exprMd : StmtExprMd) (cond : StmtExprMd) (invs : List StmtExprMd) (dec : Option StmtExprMd) (body : StmtExprMd) @@ -831,12 +851,22 @@ def Synth.while (exprMd : StmtExprMd) try simp_all omega -/-- Rule **Exit**: `Γ ⊢ Exit target ⇒ TVoid`. -/ +/-- `Γ ⊢ Exit target ⇒ TVoid` -/ def Synth.exit (target : String) (source : Option FileRange) : StmtExpr × HighTypeMd := (.Exit target, { val := .TVoid, source := source }) -/-- Rules **Return-None** / **Return-Some** / **Return-Void-Error** / - **Return-Multi-Error**: matches the optional return value against the +/-- Cases on whether the return value is `none` or `some e`, and on the + arity of the enclosing procedure's declared outputs. + + `Γ ⊢ Return none ⇒ TVoid` + + `Γ_proc.outputs = [T], Γ ⊢ e ⇐ T ∴ Γ ⊢ Return (some e) ⇒ TVoid` + + `Γ_proc.outputs = [] ∴ Γ ⊢ Return (some e) ↝ error: "void procedure cannot return a value"` + + `Γ_proc.outputs = [T_1; …; T_n] (n ≥ 2) ∴ Γ ⊢ Return (some e) ↝ error: "multi-output procedure cannot use 'return e'; assign to named outputs instead"` + + Matches the optional return value against the enclosing procedure's declared outputs. The expected output types are threaded through `ResolveState.expectedReturnTypes`, set from `proc.outputs` by `resolveProcedure` / `resolveInstanceProcedure` for @@ -887,13 +917,18 @@ def Synth.return (exprMd : StmtExprMd) (source : Option FileRange) simp_all omega -/-- Rules **Block-Check** / **Block-Check-Empty**: pushes `expected` into the - *last* statement rather than comparing the block's synthesized type at the - boundary. Errors fire at the offending subexpression, and `expected` - keeps propagating through nested `Block` / `IfThenElse` / `Hole` / - `Quantifier`. Empty blocks reduce to a subsumption check of `TVoid` - against `expected` — the same check `[⇐] Block-Empty` performs when - `T` admits `TVoid`. -/ +/-- Cases on whether the statement list is empty. + + `Γ_0 = Γ, Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n), Γ_{n-1} ⊢ s_n ⇐ T ∴ Γ ⊢ Block [s_1; …; s_n] label ⇐ T` + + `TVoid <: T ∴ Γ ⊢ Block [] label ⇐ T` + + Pushes `expected` into the *last* statement rather than comparing the + block's synthesized type at the boundary. Errors fire at the offending + subexpression, and `expected` keeps propagating through nested `Block` + / `IfThenElse` / `Hole` / `Quantifier`. Empty blocks reduce to a + subsumption check of `TVoid` against `expected` — the same check + `[⇐] Block-Empty` performs when `T` admits `TVoid`. -/ def Check.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) @@ -920,12 +955,19 @@ def Check.block (exprMd : StmtExprMd) try simp_all omega -/-- Rules **If-Check** / **If-Check-NoElse**: pushes `expected` into both - branches (rather than going through If-Synth + Sub at the boundary). - Errors fire at the offending branch instead of the surrounding `if`. - Without an else branch, the construct can only succeed when `expected` - admits `TVoid` — the same subsumption check `[⇐] Block-Empty` performs - for an empty block. -/ +/-- When there is an else branch: + + `Γ ⊢ cond ⇐ TBool, Γ ⊢ thenBr ⇐ T, Γ ⊢ elseBr ⇐ T ∴ Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T` + + Otherwise: + + `Γ ⊢ cond ⇐ TBool, Γ ⊢ thenBr ⇐ T, TVoid <: T ∴ Γ ⊢ IfThenElse cond thenBr none ⇐ T` + + Pushes `expected` into both branches (rather than going through + If-Synth + Sub at the boundary). Errors fire at the offending branch + instead of the surrounding `if`. Without an else branch, the construct + can only succeed when `expected` admits `TVoid` — the same subsumption + check `[⇐] Block-Empty` performs for an empty block. -/ def Check.ifThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) @@ -947,8 +989,9 @@ def Check.ifThenElse (exprMd : StmtExprMd) -- ### Verification statements -/-- Rule **Assert**: `cond` is checked against `TBool`; the construct - synthesizes `TVoid`. -/ +/-- `Γ ⊢ cond ⇐ TBool ∴ Γ ⊢ Assert cond ⇒ TVoid` + + `cond` is checked against `TBool`; the construct synthesizes `TVoid`. -/ def Synth.assert (exprMd : StmtExprMd) (condExpr : StmtExprMd) (summary : Option String) (source : Option FileRange) (h : exprMd.val = .Assert ⟨condExpr, summary⟩) : @@ -963,8 +1006,9 @@ def Synth.assert (exprMd : StmtExprMd) try simp_all omega -/-- Rule **Assume**: `cond` is checked against `TBool`; the construct - synthesizes `TVoid`. -/ +/-- `Γ ⊢ cond ⇐ TBool ∴ Γ ⊢ Assume cond ⇒ TVoid` + + `cond` is checked against `TBool`; the construct synthesizes `TVoid`. -/ def Synth.assume (exprMd : StmtExprMd) (cond : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assume cond) : @@ -981,7 +1025,11 @@ def Synth.assume (exprMd : StmtExprMd) -- ### Assignment -/-- Rule **Assign**: each target's declared type `T_i` (from `Local`, +/-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇒ T_e, T_e <: ExpectedTy ∴ Γ ⊢ Assign targets e ⇒ TVoid` + + where `ExpectedTy = T_1` if `|targets| = 1`, else `MultiValuedExpr [T_1; …; T_n]`. + + Each target's declared type `T_i` (from `Local`, `Field`, or fresh `Declare`) is collapsed into a tuple `ExpectedTy` (single type if one target, otherwise `MultiValuedExpr [T_1; …; T_n]`) and checked against the RHS's synthesized type. Both single- and @@ -1035,12 +1083,19 @@ def Synth.assign (exprMd : StmtExprMd) try (have := List.sizeOf_lt_of_mem ‹_ ∈ targets›; simp_all) omega -/-- Rule **Assign-Check**: an assignment in statement position (checked - against `TVoid`) discards its RHS value, so the synthesized type is not - compared against `expected`. This lets `b := 1` appear as the last - statement of a block in an else-less `if` (whose branch is checked - against `TVoid`) without firing a subsumption error against the RHS's - type. For non-`TVoid` expected types, falls back to subsumption. -/ +/-- Cases on whether `expected` is `TVoid` (statement position) or some + other type (expression position). + + `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇒ T_e, T_e <: ExpectedTy ∴ Γ ⊢ Assign targets e ⇐ TVoid` + + `Γ ⊢ Assign targets e ⇒ T_e, T_e <: T ∴ Γ ⊢ Assign targets e ⇐ T (T ≠ TVoid)` + + An assignment in statement position (checked against `TVoid`) discards + its RHS value, so the synthesized type is not compared against + `expected`. This lets `b := 1` appear as the last statement of a block + in an else-less `if` (whose branch is checked against `TVoid`) without + firing a subsumption error against the RHS's type. For non-`TVoid` + expected types, falls back to subsumption. -/ def Check.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) @@ -1086,11 +1141,17 @@ def Check.assign (exprMd : StmtExprMd) -- ### Calls -/-- Rules **Static-Call** / **Static-Call-Multi**: callee is resolved against - the expected kinds (parameter, static procedure, datatype constructor, - constant); each argument is synthesized and checked against the - corresponding parameter type. The result type is the (possibly - multi-valued) declared output type from `getCallInfo`. -/ +/-- Cases on the arity of the callee's declared outputs. + + `Γ(callee) = static-procedure with inputs Ts and outputs [T], Γ ⊢ args ⇒ Us, U_i <: T_i (pairwise) ∴ Γ ⊢ StaticCall callee args ⇒ T` + + `Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n] (n ≠ 1), Γ ⊢ args ⇒ Us, U_i <: T_i (pairwise) ∴ Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n]` + + Callee is resolved against the expected kinds (parameter, static + procedure, datatype constructor, constant); each argument is + synthesized and checked against the corresponding parameter type. The + result type is the (possibly multi-valued) declared output type from + `getCallInfo`. -/ def Synth.staticCall (exprMd : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) (h : exprMd.val = .StaticCall callee args) : @@ -1112,9 +1173,11 @@ def Synth.staticCall (exprMd : StmtExprMd) have := List.sizeOf_lt_of_mem ‹_ ∈ args› omega -/-- Rule **Instance-Call**: target is synthesized; callee resolves to an - instance or static procedure; arguments are checked pairwise against the - callee's parameter types after dropping `self`. -/ +/-- `Γ ⊢ target ⇒ _, Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T], Γ ⊢ args ⇒ Us, U_i <: T_i (pairwise; self dropped) ∴ Γ ⊢ InstanceCall target callee args ⇒ T` + + Target is synthesized; callee resolves to an instance or static + procedure; arguments are checked pairwise against the callee's + parameter types after dropping `self`. -/ def Synth.instanceCall (exprMd : StmtExprMd) (target : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) @@ -1143,8 +1206,19 @@ def Synth.instanceCall (exprMd : StmtExprMd) -- ### Primitive operations -/-- Rules **Op-Bool** / **Op-Cmp** / **Op-Eq** / **Op-Arith** / **Op-Concat**: - each operator family has its own argument-type discipline and result +/-- Cases on the operator family. + + `Γ ⊢ args_i ⇐ TBool, op ∈ {And, Or, AndThen, OrElse, Not, Implies} ∴ Γ ⊢ PrimitiveOp op args ⇒ TBool` + + `Γ ⊢ args_i ⇐ Numeric, op ∈ {Lt, Leq, Gt, Geq} ∴ Γ ⊢ PrimitiveOp op args ⇒ TBool` + + `Γ ⊢ lhs ⇒ T_l, Γ ⊢ rhs ⇒ T_r, T_l ~ T_r, op ∈ {Eq, Neq} ∴ Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool` + + `Γ ⊢ args_i ⇐ Numeric, Γ ⊢ args.head ⇒ T, op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇒ T` + + `Γ ⊢ args_i ⇐ TString, op = StrConcat ∴ Γ ⊢ PrimitiveOp op args ⇒ TString` + + Each operator family has its own argument-type discipline and result type. Arguments are synthesized first, then the per-family check fires: `⇐ TBool` for booleans, `Numeric` (consistent with `TInt`, `TReal`, or `TFloat64`) for arithmetic/comparison, consistency `~` for equality @@ -1205,9 +1279,15 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) -- ### Object forms -/-- Rules **New-Ok** / **New-Fallback**: when `ref` resolves to a composite or - datatype, the type is `UserDefined ref`; otherwise `Unknown` (suppresses - cascading errors after the kind diagnostic has already fired). -/ +/-- Cases on whether `ref` resolves to a composite/datatype. + + `Γ(ref) is a composite or datatype T ∴ Γ ⊢ New ref ⇒ UserDefined T` + + `Γ(ref) is not a composite or datatype ∴ Γ ⊢ New ref ⇒ Unknown` + + When `ref` resolves to a composite or datatype, the type is + `UserDefined ref`; otherwise `Unknown` (suppresses cascading errors + after the kind diagnostic has already fired). -/ def Synth.new (ref : Identifier) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let ref' ← resolveRef ref source @@ -1221,8 +1301,10 @@ def Synth.new (ref : Identifier) (source : Option FileRange) : else { val := HighType.Unknown, source := source } pure (.New ref', ty) -/-- Rule **AsType**: `target` is resolved but not checked against `T` — the - cast is the user's claim. The synthesized type is `T`. +/-- `Γ ⊢ target ⇒ _ ∴ Γ ⊢ AsType target T ⇒ T` + + `target` is resolved but not checked against `T` — the cast is the + user's claim. The synthesized type is `T`. `IsType` is the runtime test counterpart and synthesizes `TBool`. -/ def Synth.asType (exprMd : StmtExprMd) @@ -1239,7 +1321,9 @@ def Synth.asType (exprMd : StmtExprMd) simp [h] at hsz omega -/-- Rule **IsType**: `target` is resolved; the synthesized type is `TBool`. -/ +/-- `Γ ⊢ target ⇒ _ ∴ Γ ⊢ IsType target T ⇒ TBool` + + `target` is resolved; the synthesized type is `TBool`. -/ def Synth.isType (exprMd : StmtExprMd) (target : StmtExprMd) (ty : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .IsType target ty) : @@ -1254,10 +1338,12 @@ def Synth.isType (exprMd : StmtExprMd) simp [h] at hsz omega -/-- Rule **RefEq**: both operands must be reference types (`UserDefined` or - `Unknown`) — reference equality is meaningless on primitives. The - operands must also be mutually consistent (the symmetric `isConsistent`), - so `Cat === Dog` is rejected when `Cat` and `Dog` are unrelated +/-- `Γ ⊢ lhs ⇒ T_l, Γ ⊢ rhs ⇒ T_r, isReference T_l, isReference T_r, T_l ~ T_r ∴ Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool` + + Both operands must be reference types (`UserDefined` or `Unknown`) — + reference equality is meaningless on primitives. The operands must + also be mutually consistent (the symmetric `isConsistent`), so + `Cat === Dog` is rejected when `Cat` and `Dog` are unrelated user-defined types, while `Cat === Animal` is accepted when `Cat` extends `Animal` (the gradual `Unknown` wildcard makes either side flow freely against the other). -/ @@ -1287,10 +1373,12 @@ def Synth.refEq (exprMd : StmtExprMd) (expr : StmtExpr) simp [h] at hsz omega -/-- Rule **PureFieldUpdate**: `target` is synthesized, `f` resolved against - `T_t` (or the enclosing instance type), and `newVal` checked against the - field's declared type. The synthesized type is `T_t` — updating a field - on a pure type produces a new value of the same type. -/ +/-- `Γ ⊢ target ⇒ T_t, Γ(f) = T_f, Γ ⊢ newVal ⇐ T_f ∴ Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t` + + `target` is synthesized, `f` resolved against `T_t` (or the enclosing + instance type), and `newVal` checked against the field's declared + type. The synthesized type is `T_t` — updating a field on a pure type + produces a new value of the same type. -/ def Synth.pureFieldUpdate (exprMd : StmtExprMd) (target : StmtExprMd) (fieldName : Identifier) (newVal : StmtExprMd) (h : exprMd.val = .PureFieldUpdate target fieldName newVal) : @@ -1310,11 +1398,13 @@ def Synth.pureFieldUpdate (exprMd : StmtExprMd) -- ### Verification expressions -/-- Rule **Quantifier**: opens a fresh scope, binds `x : T` (in scope only - for the body and trigger), resolves the optional trigger, and checks - the body against `TBool` since a quantifier is a proposition. Without - that body check, `forall x: int :: x + 1` would be silently accepted. - The construct itself synthesizes `TBool`. -/ +/-- `Γ, x : T ⊢ body ⇐ TBool ∴ Γ ⊢ Quantifier mode ⟨x, T⟩ trig body ⇒ TBool` + + Opens a fresh scope, binds `x : T` (in scope only for the body and + trigger), resolves the optional trigger, and checks the body against + `TBool` since a quantifier is a proposition. Without that body check, + `forall x: int :: x + 1` would be silently accepted. The construct + itself synthesizes `TBool`. -/ def Synth.quantifier (exprMd : StmtExprMd) (mode : QuantifierMode) (param : Parameter) (trigger : Option StmtExprMd) (body : StmtExprMd) (source : Option FileRange) @@ -1336,8 +1426,9 @@ def Synth.quantifier (exprMd : StmtExprMd) try simp_all omega -/-- Rule **Assigned**: `name` is synthesized; the construct synthesizes - `TBool`. -/ +/-- `Γ ⊢ name ⇒ _ ∴ Γ ⊢ Assigned name ⇒ TBool` + + `name` is synthesized; the construct synthesizes `TBool`. -/ def Synth.assigned (exprMd : StmtExprMd) (name : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assigned name) : @@ -1351,7 +1442,7 @@ def Synth.assigned (exprMd : StmtExprMd) simp [h] at hsz omega -/-- Rule **Old**: `Γ ⊢ v ⇒ T ⊢ Old v ⇒ T`. -/ +/-- `Γ ⊢ v ⇒ T ∴ Γ ⊢ Old v ⇒ T` -/ def Synth.old (exprMd : StmtExprMd) (val : StmtExprMd) (h : exprMd.val = .Old val) : @@ -1365,10 +1456,11 @@ def Synth.old (exprMd : StmtExprMd) simp [h] at hsz omega -/-- Rule **Fresh**: `v` is synthesized and must have a reference type - (`UserDefined` or `Unknown`) — `Fresh` only makes sense on - heap-allocated references, so `fresh(5)` is rejected. The construct - itself synthesizes `TBool`. -/ +/-- `Γ ⊢ v ⇒ T, isReference T ∴ Γ ⊢ Fresh v ⇒ TBool` + + `v` is synthesized and must have a reference type (`UserDefined` or + `Unknown`) — `Fresh` only makes sense on heap-allocated references, so + `fresh(5)` is rejected. The construct itself synthesizes `TBool`. -/ def Synth.fresh (exprMd : StmtExprMd) (expr : StmtExpr) (val : StmtExprMd) (source : Option FileRange) (h_expr : expr = .Fresh val) @@ -1386,8 +1478,10 @@ def Synth.fresh (exprMd : StmtExprMd) (expr : StmtExpr) simp [h] at hsz omega -/-- Rule **ProveBy**: `v` and `proof` are both synthesized; the construct's - type is `v`'s type — `proof` is a hint for downstream verification. -/ +/-- `Γ ⊢ v ⇒ T, Γ ⊢ proof ⇒ _ ∴ Γ ⊢ ProveBy v proof ⇒ T` + + `v` and `proof` are both synthesized; the construct's type is `v`'s + type — `proof` is a hint for downstream verification. -/ def Synth.proveBy (exprMd : StmtExprMd) (val proof : StmtExprMd) (h : exprMd.val = .ProveBy val proof) : @@ -1405,14 +1499,21 @@ def Synth.proveBy (exprMd : StmtExprMd) -- ### Self reference -/-- Rules **This-Inside** / **This-Outside**: when `instanceTypeName` is set - (we're inside an instance method, populated on `ResolveState` by - `resolveInstanceProcedure` for the duration of an instance method body), - `This` synthesizes `UserDefined T`. With it, `this.field` and - instance-method dispatch synthesize real types instead of being - wildcarded through `Unknown`. Otherwise an error is emitted ("'this' - is not allowed outside instance methods") and the type collapses to - `Unknown` to suppress cascading errors. -/ +/-- Cases on whether `instanceTypeName` is set (i.e., we're inside an + instance method). + + `Γ.instanceTypeName = some T ∴ Γ ⊢ This ⇒ UserDefined T` + + `Γ.instanceTypeName = none ∴ Γ ⊢ This ⇒ Unknown` (emits "'this' is not allowed outside instance methods") + + When `instanceTypeName` is set (we're inside an instance method, + populated on `ResolveState` by `resolveInstanceProcedure` for the + duration of an instance method body), `This` synthesizes + `UserDefined T`. With it, `this.field` and instance-method dispatch + synthesize real types instead of being wildcarded through `Unknown`. + Otherwise an error is emitted ("'this' is not allowed outside instance + methods") and the type collapses to `Unknown` to suppress cascading + errors. -/ def Synth.this (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let s ← get @@ -1430,17 +1531,25 @@ def Synth.this (source : Option FileRange) : -- ### Untyped forms -/-- Rule **Abstract**: synthesizes `Unknown`. -/ +/-- `Γ ⊢ Abstract ⇒ Unknown` -/ def Synth.abstract (source : Option FileRange) : StmtExpr × HighTypeMd := (.Abstract, { val := .Unknown, source := source }) -/-- Rule **All**: synthesizes `Unknown`. -/ +/-- `Γ ⊢ All ⇒ Unknown` -/ def Synth.all (source : Option FileRange) : StmtExpr × HighTypeMd := (.All, { val := .Unknown, source := source }) -- ### ContractOf -/-- Rules **ContractOf-Bool** / **ContractOf-Set** / **ContractOf-Error**: +/-- Cases on the contract type `ty` and on whether `fn` is a procedure + reference. + + `fn = Var (.Local id), Γ(id) ∈ {staticProcedure, instanceProcedure} ∴ Γ ⊢ ContractOf Precondition fn ⇒ TBool and Γ ⊢ ContractOf PostCondition fn ⇒ TBool` + + `fn = Var (.Local id), Γ(id) ∈ {staticProcedure, instanceProcedure} ∴ Γ ⊢ ContractOf Reads fn ⇒ TSet Unknown and Γ ⊢ ContractOf Modifies fn ⇒ TSet Unknown` + + `fn is not a procedure reference ∴ Γ ⊢ ContractOf _ fn ↝ error: "'contractOf' expected a procedure reference"` + `ContractOf ty fn` extracts a procedure's contract clause as a value: its preconditions (`Precondition`), postconditions (`PostCondition`), reads set (`Reads`), or modifies set (`Modifies`). `fn` must be a @@ -1492,8 +1601,14 @@ def Synth.contractOf (exprMd : StmtExprMd) -- ### Holes -/-- Rules **Hole-Some** / **Hole-None-Synth**: a typed hole synthesizes its - annotation; an untyped hole in synth position synthesizes `Unknown`. -/ +/-- Cases on whether the hole has a type annotation. + + `Γ ⊢ Hole d (some T) ⇒ T` + + `Γ ⊢ Hole d none ⇒ Unknown` + + A typed hole synthesizes its annotation; an untyped hole in synth + position synthesizes `Unknown`. -/ def Synth.hole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do match type with @@ -1502,8 +1617,10 @@ def Synth.hole (det : Bool) (type : Option HighTypeMd) (source : Option FileRang pure (.Hole det ty', ty') | none => pure (.Hole det none, { val := .Unknown, source := source }) -/-- Rule **Hole-None-Check**: an untyped hole in check mode records the - expected type on the node so downstream passes don't have to infer it +/-- `Γ ⊢ Hole d none ⇐ T ↦ Γ ⊢ Hole d (some T)` + + An untyped hole in check mode records the expected type on the node + so downstream passes don't have to infer it again. The subsumption check is trivial (`Unknown <: T` always holds), so this rule never fails — it just preserves the type information available at the check-mode boundary instead of discarding it. diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index b9d1070f42..c3493faa12 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -205,8 +205,7 @@ Each construct is given as a derivation. `Γ` is the current lexical scope (see every premise and conclusion unless a rule explicitly extends it (written `Γ, x : T`). Each rule is tagged with `[⇒]` (synthesis) or `[⇐]` (checking) to make the -direction explicit. When a construct has both modes, the `-Synth` / `-Check` -suffix is dropped in favor of the prefix. +direction explicit. ### Index @@ -231,9 +230,6 @@ suffix is dropped in favor of the prefix. - *ContractOf* — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error - *Holes* — \[⇒\] Hole-Some, \[⇒\] Hole-None, \[⇐\] Hole-None -Each LaTeX rule below is followed by the docstring of the helper that implements it -(grouped when one helper covers multiple rules). - ### Subsumption $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` @@ -271,9 +267,6 @@ $$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \ma $$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T\rangle) \Rightarrow \mathsf{TVoid} \dashv \Gamma, x : T} \quad \text{([⇒] Var-Declare)}` -`⊣ Γ, x : T` records that the surrounding `Γ` is extended with the new binding for the -remainder of the enclosing scope. - {docstring Strata.Laurel.Resolution.Synth.varDeclare} ### Control flow @@ -292,9 +285,9 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda $$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block)}` -`Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i` says each statement is resolved in the scope produced by its -predecessor and may itself extend it (`Var (.Declare …)` does); `s_n` is typed in -`Γ_{n-1}`. Bindings introduced inside the block don't escape — `Γ` is what surrounds the +$`Γ_{i-1} ⊢ s_i ⇒ \_ ⊣ Γ_i` says each statement is resolved in the scope produced by its +predecessor and may itself extend it (`Var (.Declare …)` does); $`s_n` is typed in +$`Γ_{n-1}`. Bindings introduced inside the block don't escape — $`Γ` is what surrounds the block. $$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Block-Empty)}` From 1cde2a13378fabd240777abe497dd11022ea48a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 16:02:49 -0400 Subject: [PATCH 116/189] expand prose around Block typing rules --- docs/verso/LaurelDoc.lean | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index c3493faa12..ddbb57f6f6 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -285,19 +285,42 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda $$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block)}` -$`Γ_{i-1} ⊢ s_i ⇒ \_ ⊣ Γ_i` says each statement is resolved in the scope produced by its -predecessor and may itself extend it (`Var (.Declare …)` does); $`s_n` is typed in -$`Γ_{n-1}`. Bindings introduced inside the block don't escape — $`Γ` is what surrounds the -block. +Reading the premise: $`\Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i` means $`s_i` +is resolved under the scope $`\Gamma_{i-1}` produced by its predecessor, synthesizes some +type (the `_` discards it — non-last statements are sequenced for effect, not value), and +produces a possibly extended scope $`\Gamma_i` that the next statement sees. In practice +only `Var (.Declare …)` actually extends the scope; every other construct leaves it +unchanged so $`\Gamma_i = \Gamma_{i-1}`. The last statement $`s_n` is typed in +$`\Gamma_{n-1}` and *its* synthesized type $`T` becomes the block's type. The block +opens a fresh nested scope, so declarations made inside don't leak out — once the block +ends, the surrounding $`\Gamma` is restored. + +Discarding the types of non-last statements matches Java/Python/JavaScript, where +`f(x);` is a normal statement even when `f` returns a value. The trade-off is that a +stray expression like `5;` is silently accepted; flagging that belongs to a lint, not +the type checker. $$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Block-Empty)}` +An empty block has no last statement to take a type from, so it defaults to `TVoid`. + {docstring Strata.Laurel.Resolution.Synth.block} $$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` +The check form differs from the synth form in exactly one place: the *last* statement is +checked against the block's expected type $`T` instead of synthesizing freely. Non-last +statements are still synthesized-and-discarded, just as in the synth rule. Pushing $`T` +into the tail (rather than synthesizing the whole block and applying \[⇐\] Sub at the +boundary) means a type mismatch is reported at the offending subexpression's source +location, and the expectation continues to propagate through nested `Block` / +`IfThenElse` / `Hole` / `Quantifier` constructs that have their own check rules. + $$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Empty)}` +With no last statement to push the expectation into, the empty-block check falls back to +a single subsumption test: an empty block is acceptable wherever `TVoid` is. + {docstring Strata.Laurel.Resolution.Check.block} $$`\frac{}{\Gamma \vdash \mathsf{Exit}\;\mathit{target} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Exit)}` From cc16c3e5ef0de4cc1fe8b26530a5e24b2d5d36c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 16:03:54 -0400 Subject: [PATCH 117/189] remove synthesis rule for if-then-else Synth.resolveStmtExpr is now non-total: any constructor without a synthesis rule (currently only IfThenElse) hits a wildcard arm that emits a typeMismatch diagnostic and returns Unknown to suppress cascading errors at the use site. typeMismatch drops the trailing "got 'Unknown'" suffix when actual is Unknown, matching the "we couldn't synthesize a type" semantics. The deleted synth rule plus joinTypes / firstCommonAncestor are preserved on the leo/ifthenelse-synth-lub feature branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- Strata/Languages/Laurel/Laurel.lean | 37 -------------- Strata/Languages/Laurel/Resolution.lean | 67 ++++++------------------- docs/verso/LaurelDoc.lean | 8 +-- 3 files changed, 15 insertions(+), 97 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index 05268233df..658cbada38 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -583,43 +583,6 @@ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := def isConsistentSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := isConsistent ctx sub sup || isSubtype ctx sub sup -/-- BFS through `extendingMap` starting from `name` and stopping at the first - type that is also in `targetAncestors`. Used by `joinTypes` to find a - common ancestor between two composites; `visited` cuts off cycles. -/ -partial def TypeContext.firstCommonAncestor (ctx : TypeContext) - (name : String) (targetAncestors : Std.HashSet String) : Option String := - let rec go (frontier : List String) (visited : Std.HashSet String) : Option String := - match frontier with - | [] => none - | n :: rest => - if visited.contains n then go rest visited - else if targetAncestors.contains n then some n - else - let parents := (ctx.extendingMap.get? n).getD [] - go (rest ++ parents) (visited.insert n) - go [name] {} - -/-- Least upper bound for the if-then-else synthesis rule. When `a` and `b` - are subtype-related, returns the larger; for unrelated composites, walks - `extending` chains for the first common ancestor. When no common - supertype exists (e.g. unrelated primitives, or a value branch paired - with a `TVoid` `return`/`exit`), falls back to `a` — the enclosing - context's `checkSubtype` then surfaces any mismatch against the - then-branch's type, preserving the historical statement-form behavior. -/ -def joinTypes (ctx : TypeContext) (a b : HighTypeMd) : HighTypeMd := - if isConsistentSubtype ctx a b then b - else if isConsistentSubtype ctx b a then a - else - let a' := ctx.unfold a - let b' := ctx.unfold b - match a'.val, b'.val with - | .UserDefined aName, .UserDefined bName => - match ctx.firstCommonAncestor aName.text (ctx.ancestors bName.text) with - | some name => - { val := .UserDefined { text := name, source := none }, source := a.source } - | none => a - | _, _ => a - def HighType.isBool : HighType → Bool | TBool => true | _ => false diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index cb72db5599..b22672a7ad 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -454,13 +454,18 @@ private def formatType (ty : HighTypeMd) : String := /-- Emit a type mismatch diagnostic. With a `construct`, the message is "'' , got ''"; without, - ", got ''". -/ + ", got ''". When `actual` is `Unknown` the trailing + `got '…'` is dropped — "we couldn't synthesize a type" is the + statement, not "the type we got was Unknown". -/ private def typeMismatch (source : Option FileRange) (construct : Option StmtExpr) (problem : String) (actual : HighTypeMd) : ResolveM Unit := do let constructor := match construct with | some c => s!"'{c.constrName}' " | none => "" - let diag := diagnosticFromSource source s!"{constructor}{problem}, got '{formatType actual}'" + let suffix := match actual.val with + | .Unknown => "" + | _ => s!", got '{formatType actual}'" + let diag := diagnosticFromSource source s!"{constructor}{problem}{suffix}" modify fun s => { s with errors := s.errors.push diag } /-- Type-level subtype check: emits the standard "expected/got" diagnostic when @@ -534,7 +539,7 @@ inside the mutual block below. Helpers are grouped by section to mirror the - Literals — `Synth.litInt`, `Synth.litBool`, `Synth.litString`, `Synth.litDecimal` - Variables — `Synth.varLocal`, `Synth.varField`, `Synth.varDeclare` -- Control flow — `Synth.ifThenElse`, `Synth.block`, `Synth.while`, `Synth.exit`, +- Control flow — `Synth.block`, `Synth.while`, `Synth.exit`, `Synth.return`, `Check.block`, `Check.ifThenElse` - Verification statements — `Synth.assert`, `Synth.assume` - Assignment — `Synth.assign`, `Check.assign` @@ -580,8 +585,6 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy match h_node: exprMd with | AstNode.mk expr source => let (val', ty) ← match h_expr: expr with - | .IfThenElse cond thenBr elseBr => - Synth.ifThenElse exprMd cond thenBr elseBr (by rw [h_node]) | .Block stmts label => Synth.block exprMd stmts label (by rw [h_node]) | .While cond invs dec body => @@ -634,6 +637,12 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy | .Abstract => pure (Synth.abstract source) | .All => pure (Synth.all source) | .Hole det type => Synth.hole det type source + | _ => + let unknown : HighTypeMd := { val := .Unknown, source := source } + typeMismatch source (some expr) + "has no synthesis rule; use it in a position with a known expected type" + unknown + pure (expr, unknown) return ({ val := val', source := source }, ty) termination_by (exprMd, 2) decreasing_by all_goals first @@ -743,54 +752,6 @@ def Synth.varField (exprMd : StmtExprMd) -- ### Control flow -/-- When there is an else branch: - - `Γ ⊢ cond ⇐ TBool, Γ ⊢ thenBr ⇒ T_t, Γ ⊢ elseBr ⇒ T_e ∴ Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T_t ⊔ T_e` - - Otherwise: - - `Γ ⊢ cond ⇐ TBool, Γ ⊢ thenBr ⇐ TVoid ∴ Γ ⊢ IfThenElse cond thenBr none ⇒ TVoid` - - `cond` is checked against `TBool`. - With no else branch, the construct is a statement — `thenBr` is checked - against `TVoid` and the result is `TVoid`, so `x : int := if c then 5` - is rejected at the branch rather than slipping through to a downstream - subsumption. - - With an else branch, the result type is the join (LUB) of the two - branches' synthesized types, so `if c then small else big` synthesizes - the common supertype rather than committing to one branch arbitrarily; - `if c then new Left else new Right` synthesizes the common ancestor. - When no common supertype exists (e.g. a value branch paired with a - `TVoid` `return`/`exit`), `joinTypes` falls back to the then-branch's - type and the enclosing context's check (`[⇐] Sub`, or a containing - `checkSubtype` like an assignment) surfaces any mismatch downstream - against the then-branch's type. -/ -def Synth.ifThenElse (exprMd : StmtExprMd) - (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) - (h : exprMd.val = .IfThenElse cond thenBr elseBr) : - ResolveM (StmtExpr × HighTypeMd) := do - let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } - let voidTy : HighTypeMd := { val := .TVoid, source := exprMd.source } - match elseBr with - | none => - let thenBr' ← Check.resolveStmtExpr thenBr voidTy - pure (.IfThenElse cond' thenBr' none, voidTy) - | some e => - let (thenBr', thenTy) ← Synth.resolveStmtExpr thenBr - let (elseBr', elseTy) ← Synth.resolveStmtExpr e - let ctx := (← get).typeContext - pure (.IfThenElse cond' thenBr' (some elseBr'), joinTypes ctx thenTy elseTy) - termination_by (exprMd, 1) - decreasing_by - all_goals first - | (apply Prod.Lex.left - have hsz := exprMd.sizeOf_val_lt - simp [h] at hsz - try simp_all - try omega) - | (apply Prod.Lex.right; decide) - /-- Cases on whether the statement list is empty. `Γ_0 = Γ, Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n), Γ_{n-1} ⊢ s_n ⇒ T ∴ Γ ⊢ Block [s_1; …; s_n] label ⇒ T` diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index ddbb57f6f6..9e9e0d383b 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -212,7 +212,7 @@ direction explicit. - *Subsumption* — \[⇐\] Sub - *Literals* — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal - *Variables* — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇒\] Var-Declare -- *Control flow* — \[⇒\] If-NoElse, \[⇒\] If, \[⇐\] If, \[⇐\] If-NoElse; +- *Control flow* — \[⇐\] If, \[⇐\] If-NoElse; \[⇒\] Block, \[⇒\] Block-Empty, \[⇐\] Block, \[⇐\] Block-Empty; \[⇒\] Exit; \[⇒\] Return-None, \[⇒\] Return-Some, \[⇒\] Return-Void-Error, \[⇒\] Return-Multi-Error; \[⇒\] While @@ -271,12 +271,6 @@ $$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.De ### Control flow -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow \mathsf{TVoid}}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-NoElse)}` - -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t \sqcup T_e} \quad \text{([⇒] If)}` - -{docstring Strata.Laurel.Resolution.Synth.ifThenElse} - $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \Gamma \vdash \mathit{elseBr} \Leftarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Leftarrow T} \quad \text{([⇐] If)}` $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Leftarrow T} \quad \text{([⇐] If-NoElse)}` From fbccb1509d44957ef4f80d53c33a949943c0dd50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 16:06:32 -0400 Subject: [PATCH 118/189] remove synthesis rule for blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synth.block deleted; the dispatcher's wildcard arm now handles .Block the same way it handles .IfThenElse — emit a typeMismatch and return Unknown. Block prose in LaurelDoc.lean reframed around the check rule since it's now the only block typing rule. The deleted synth rule is preserved on the leo/block-synth feature branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- Strata/Languages/Laurel/Resolution.lean | 37 +------------------------ docs/verso/LaurelDoc.lean | 32 ++++++++------------- 2 files changed, 12 insertions(+), 57 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index b22672a7ad..6331bf7bfc 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -539,7 +539,7 @@ inside the mutual block below. Helpers are grouped by section to mirror the - Literals — `Synth.litInt`, `Synth.litBool`, `Synth.litString`, `Synth.litDecimal` - Variables — `Synth.varLocal`, `Synth.varField`, `Synth.varDeclare` -- Control flow — `Synth.block`, `Synth.while`, `Synth.exit`, +- Control flow — `Synth.while`, `Synth.exit`, `Synth.return`, `Check.block`, `Check.ifThenElse` - Verification statements — `Synth.assert`, `Synth.assume` - Assignment — `Synth.assign`, `Check.assign` @@ -585,8 +585,6 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy match h_node: exprMd with | AstNode.mk expr source => let (val', ty) ← match h_expr: expr with - | .Block stmts label => - Synth.block exprMd stmts label (by rw [h_node]) | .While cond invs dec body => Synth.while exprMd cond invs dec body (by rw [h_node]) | .Exit target => pure (Synth.exit target source) @@ -752,39 +750,6 @@ def Synth.varField (exprMd : StmtExprMd) -- ### Control flow -/-- Cases on whether the statement list is empty. - - `Γ_0 = Γ, Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n), Γ_{n-1} ⊢ s_n ⇒ T ∴ Γ ⊢ Block [s_1; …; s_n] label ⇒ T` - - `Γ ⊢ Block [] label ⇒ TVoid` - - Each statement is resolved - in the scope produced by its predecessor and may itself extend it - (`Var (.Declare …)` does); non-last statements are synthesized but their - types discarded (the lax rule, matching Java/Python/JS where `f(x);` is - normal even when `f` returns a value — trade-off: `5;` is silently - accepted, flagging it belongs to a lint). The last statement's type - becomes the block's type, or `TVoid` for an empty block. The block opens - a fresh nested scope, so bindings introduced inside don't escape. -/ -def Synth.block (exprMd : StmtExprMd) - (stmts : List StmtExprMd) (label : Option String) - (h : exprMd.val = .Block stmts label) : - ResolveM (StmtExpr × HighTypeMd) := do - withScope do - let results ← stmts.mapM Synth.resolveStmtExpr - let stmts' := results.map (·.1) - let lastTy := match results.getLast? with - | some (_, ty) => ty - | none => { val := .TVoid, source := exprMd.source } - pure (.Block stmts' label, lastTy) - termination_by (exprMd, 1) - decreasing_by - apply Prod.Lex.left - have hsz := exprMd.sizeOf_val_lt - simp [h] at hsz - have := List.sizeOf_lt_of_mem ‹_ ∈ stmts› - omega - /-- `Γ ⊢ cond ⇐ TBool, Γ ⊢ invs_i ⇐ TBool, Γ ⊢ dec ⇐ ?, Γ ⊢ body ⇒ _ ∴ Γ ⊢ While cond invs dec body ⇒ TVoid` `cond ⇐ TBool`, each invariant `⇐ TBool`, optional `decreases` is diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 9e9e0d383b..f358fcc258 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -213,7 +213,7 @@ direction explicit. - *Literals* — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal - *Variables* — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇒\] Var-Declare - *Control flow* — \[⇐\] If, \[⇐\] If-NoElse; - \[⇒\] Block, \[⇒\] Block-Empty, \[⇐\] Block, \[⇐\] Block-Empty; \[⇒\] Exit; + \[⇐\] Block, \[⇐\] Block-Empty; \[⇒\] Exit; \[⇒\] Return-None, \[⇒\] Return-Some, \[⇒\] Return-Void-Error, \[⇒\] Return-Multi-Error; \[⇒\] While - *Verification statements* — \[⇒\] Assert, \[⇒\] Assume @@ -277,38 +277,28 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda {docstring Strata.Laurel.Resolution.Check.ifThenElse} -$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block)}` +$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` Reading the premise: $`\Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i` means $`s_i` is resolved under the scope $`\Gamma_{i-1}` produced by its predecessor, synthesizes some type (the `_` discards it — non-last statements are sequenced for effect, not value), and produces a possibly extended scope $`\Gamma_i` that the next statement sees. In practice only `Var (.Declare …)` actually extends the scope; every other construct leaves it -unchanged so $`\Gamma_i = \Gamma_{i-1}`. The last statement $`s_n` is typed in -$`\Gamma_{n-1}` and *its* synthesized type $`T` becomes the block's type. The block -opens a fresh nested scope, so declarations made inside don't leak out — once the block -ends, the surrounding $`\Gamma` is restored. +unchanged so $`\Gamma_i = \Gamma_{i-1}`. The *last* statement $`s_n` is checked against +the block's expected type $`T` rather than synthesizing freely. The block opens a fresh +nested scope, so declarations made inside don't leak out — once the block ends, the +surrounding $`\Gamma` is restored. Discarding the types of non-last statements matches Java/Python/JavaScript, where `f(x);` is a normal statement even when `f` returns a value. The trade-off is that a stray expression like `5;` is silently accepted; flagging that belongs to a lint, not the type checker. -$$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Block-Empty)}` - -An empty block has no last statement to take a type from, so it defaults to `TVoid`. - -{docstring Strata.Laurel.Resolution.Synth.block} - -$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` - -The check form differs from the synth form in exactly one place: the *last* statement is -checked against the block's expected type $`T` instead of synthesizing freely. Non-last -statements are still synthesized-and-discarded, just as in the synth rule. Pushing $`T` -into the tail (rather than synthesizing the whole block and applying \[⇐\] Sub at the -boundary) means a type mismatch is reported at the offending subexpression's source -location, and the expectation continues to propagate through nested `Block` / -`IfThenElse` / `Hole` / `Quantifier` constructs that have their own check rules. +Pushing $`T` into the tail (rather than synthesizing the whole block and applying +\[⇐\] Sub at the boundary) means a type mismatch is reported at the offending +subexpression's source location, and the expectation continues to propagate through +nested `Block` / `IfThenElse` / `Hole` / `Quantifier` constructs that have their own +check rules. $$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Empty)}` From 7d3cdf6c9ffb4b774a0dc9af9ae56f6cd1d4dd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 16:28:10 -0400 Subject: [PATCH 119/189] move statement-shaped constructs to check-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VarDeclare, While, Exit, Return, Assert, Assume — none of these have any reason to ever produce a synthesized type other than TVoid, so each gets a dedicated Check. rule that performs the construct's own work (Return's arity diagnostics, VarDeclare's scope extension, etc.) plus a TVoid-vs-expected subsumption check, replacing the corresponding Synth.. The synth dispatcher's wildcard arm now catches these too: any program that puts a VarDeclare/While/Exit/Return/Assert/Assume in synth position gets the standard "no synthesis rule" diagnostic. The deleted synth rules are preserved on leo/synth-control-flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- Strata/Languages/Laurel/Resolution.lean | 163 +++++++++++++----------- docs/verso/LaurelDoc.lean | 40 +++--- 2 files changed, 109 insertions(+), 94 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 6331bf7bfc..0758a4ad19 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -538,10 +538,10 @@ inside the mutual block below. Helpers are grouped by section to mirror the *Typing rules* index in `LaurelDoc.lean`: - Literals — `Synth.litInt`, `Synth.litBool`, `Synth.litString`, `Synth.litDecimal` -- Variables — `Synth.varLocal`, `Synth.varField`, `Synth.varDeclare` -- Control flow — `Synth.while`, `Synth.exit`, - `Synth.return`, `Check.block`, `Check.ifThenElse` -- Verification statements — `Synth.assert`, `Synth.assume` +- Variables — `Synth.varLocal`, `Synth.varField`, `Check.varDeclare` +- Control flow — `Check.while`, `Check.exit`, `Check.return`, + `Check.block`, `Check.ifThenElse` +- Verification statements — `Check.assert`, `Check.assume` - Assignment — `Synth.assign`, `Check.assign` - Calls — `Synth.staticCall`, `Synth.instanceCall` - Primitive operations — `Synth.primitiveOp` @@ -585,17 +585,11 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy match h_node: exprMd with | AstNode.mk expr source => let (val', ty) ← match h_expr: expr with - | .While cond invs dec body => - Synth.while exprMd cond invs dec body (by rw [h_node]) - | .Exit target => pure (Synth.exit target source) - | .Return val => - Synth.return exprMd source val (by rw [h_node]) | .LiteralInt v => pure (Synth.litInt v source) | .LiteralBool v => pure (Synth.litBool v source) | .LiteralString v => pure (Synth.litString v source) | .LiteralDecimal v => pure (Synth.litDecimal v source) | .Var (.Local ref) => Synth.varLocal ref source - | .Var (.Declare param) => Synth.varDeclare param source | .Var (.Field target fieldName) => Synth.varField exprMd target fieldName source (by rw [h_node]) | .Assign targets value => @@ -624,10 +618,6 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy Synth.old exprMd val (by rw [h_node]) | .Fresh val => Synth.fresh exprMd expr val source h_expr (by rw [h_node]) - | .Assert ⟨condExpr, summary⟩ => - Synth.assert exprMd condExpr summary source (by rw [h_node]) - | .Assume cond => - Synth.assume exprMd cond source (by rw [h_node]) | .ProveBy val proof => Synth.proveBy exprMd val proof (by rw [h_node]) | .ContractOf ty fn => @@ -676,6 +666,16 @@ def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : Resolv | .Assign targets value => Check.assign exprMd targets value expected source (by rw [h_node]) | .Hole det none => pure (Check.holeNone det expected source) + | .Var (.Declare param) => Check.varDeclare param expected source + | .While cond invs dec body => + Check.while exprMd cond invs dec body expected source (by rw [h_node]) + | .Exit target => pure (Check.exit target expected source) + | .Return val => + Check.return exprMd val expected source (by rw [h_node]) + | .Assert ⟨condExpr, summary⟩ => + Check.assert exprMd condExpr summary expected source (by rw [h_node]) + | .Assume cond => + Check.assume exprMd cond expected source (by rw [h_node]) | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← Synth.resolveStmtExpr exprMd @@ -717,17 +717,6 @@ def Synth.varLocal (ref : Identifier) (source : Option FileRange) : let ty ← getVarType ref pure (.Var (.Local ref'), ty) -/-- `x ∉ dom(Γ) ∴ Γ ⊢ Var (.Declare ⟨x, T⟩) ⇒ TVoid ⊣ Γ, x : T` - - `⊣ Γ, x : T` records that the surrounding scope is extended with the - new binding for the remainder of the enclosing scope. The declaration - itself produces no value, hence `TVoid`. -/ -def Synth.varDeclare (param : Parameter) (source : Option FileRange) : - ResolveM (StmtExpr × HighTypeMd) := do - let ty' ← resolveHighType param.type - let name' ← defineNameCheckDup param.name (.var param.name ty') - pure (.Var (.Declare ⟨name', ty'⟩), { val := .TVoid, source := source }) - /-- `Γ ⊢ e ⇒ _, Γ(f) = T_f ∴ Γ ⊢ Var (.Field e f) ⇒ T_f` `f` is looked up against the type of `e` (or the enclosing instance type @@ -748,26 +737,43 @@ def Synth.varField (exprMd : StmtExprMd) try simp_all omega +/-- `x ∉ dom(Γ), TVoid <: T ∴ Γ ⊢ Var (.Declare ⟨x, T_x⟩) ⇐ T ⊣ Γ, x : T_x` + + `⊣ Γ, x : T_x` records that the surrounding scope is extended with the + new binding for the remainder of the enclosing scope. The declaration + itself produces no value, so `expected` must admit `TVoid`. -/ +def Check.varDeclare (param : Parameter) + (expected : HighTypeMd) (source : Option FileRange) : + ResolveM StmtExprMd := do + let ty' ← resolveHighType param.type + let name' ← defineNameCheckDup param.name (.var param.name ty') + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .Var (.Declare ⟨name', ty'⟩), source := source } + -- ### Control flow -/-- `Γ ⊢ cond ⇐ TBool, Γ ⊢ invs_i ⇐ TBool, Γ ⊢ dec ⇐ ?, Γ ⊢ body ⇒ _ ∴ Γ ⊢ While cond invs dec body ⇒ TVoid` +/-- `Γ ⊢ cond ⇐ TBool, Γ ⊢ invs_i ⇐ TBool, Γ ⊢ dec ⇐ ?, Γ ⊢ body ⇐ T, TVoid <: T ∴ Γ ⊢ While cond invs dec body ⇐ T` - `cond ⇐ TBool`, each invariant `⇐ TBool`, optional `decreases` is - resolved without a type check today (the intended target is a numeric - type), body is synthesized; the construct itself synthesizes `TVoid`. -/ -def Synth.while (exprMd : StmtExprMd) + `cond` is checked against `TBool`, each invariant against `TBool`, + optional `decreases` is currently resolved without a type check (the + intended target is a numeric type), and the body is checked against + the surrounding `expected` type. The construct itself produces no + value, so `expected` must admit `TVoid`. -/ +def Check.while (exprMd : StmtExprMd) (cond : StmtExprMd) (invs : List StmtExprMd) (dec : Option StmtExprMd) (body : StmtExprMd) + (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .While cond invs dec body) : - ResolveM (StmtExpr × HighTypeMd) := do + ResolveM StmtExprMd := do let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } let invs' ← invs.attach.mapM (fun a => have := a.property; do Check.resolveStmtExpr a.val { val := .TBool, source := a.val.source }) let dec' ← dec.attach.mapM (fun a => have := a.property; do let (e', _) ← Synth.resolveStmtExpr a.val; pure e') - let (body', _) ← Synth.resolveStmtExpr body - pure (.While cond' invs' dec' body', { val := .TVoid, source := exprMd.source }) - termination_by (exprMd, 1) + let body' ← Check.resolveStmtExpr body expected + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .While cond' invs' dec' body', source := source } + termination_by (exprMd, 0) decreasing_by all_goals apply Prod.Lex.left @@ -777,27 +783,30 @@ def Synth.while (exprMd : StmtExprMd) try simp_all omega -/-- `Γ ⊢ Exit target ⇒ TVoid` -/ -def Synth.exit (target : String) (source : Option FileRange) : StmtExpr × HighTypeMd := - (.Exit target, { val := .TVoid, source := source }) +/-- `TVoid <: T ∴ Γ ⊢ Exit target ⇐ T` -/ +def Check.exit (target : String) (expected : HighTypeMd) + (source : Option FileRange) : ResolveM StmtExprMd := do + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .Exit target, source := source } /-- Cases on whether the return value is `none` or `some e`, and on the arity of the enclosing procedure's declared outputs. - `Γ ⊢ Return none ⇒ TVoid` + `TVoid <: T ∴ Γ ⊢ Return none ⇐ T` - `Γ_proc.outputs = [T], Γ ⊢ e ⇐ T ∴ Γ ⊢ Return (some e) ⇒ TVoid` + `Γ_proc.outputs = [T_o], Γ ⊢ e ⇐ T_o, TVoid <: T ∴ Γ ⊢ Return (some e) ⇐ T` `Γ_proc.outputs = [] ∴ Γ ⊢ Return (some e) ↝ error: "void procedure cannot return a value"` `Γ_proc.outputs = [T_1; …; T_n] (n ≥ 2) ∴ Γ ⊢ Return (some e) ↝ error: "multi-output procedure cannot use 'return e'; assign to named outputs instead"` - Matches the optional return value against the - enclosing procedure's declared outputs. The expected output types are - threaded through `ResolveState.expectedReturnTypes`, set from - `proc.outputs` by `resolveProcedure` / `resolveInstanceProcedure` for - the duration of the body; `none` means "no enclosing procedure" — e.g. - resolving a constant initializer — and skips all `Return` checks. + The `Return` construct itself produces no value, so `expected` must + admit `TVoid`. The optional payload is matched against the enclosing + procedure's declared outputs (threaded through + `ResolveState.expectedReturnTypes`, set from `proc.outputs` by + `resolveProcedure` / `resolveInstanceProcedure` for the duration of + the body; `none` means "no enclosing procedure" — e.g. resolving a + constant initializer — and skips all `Return` checks). A bare `return;` is allowed in any context. In a single-output procedure it acts as a Dafny-style early exit — the output parameter retains @@ -807,34 +816,35 @@ def Synth.exit (target : String) (source : Option FileRange) : StmtExpr × HighT Multi-output procedures use named-output assignment (`r := …` on the declared output parameters); `return e` syntactically takes a single - `Option StmtExpr` and cannot carry multiple values, so it is flagged with - a diagnostic pointing users at the named-output convention. -/ -def Synth.return (exprMd : StmtExprMd) (source : Option FileRange) - (val : Option StmtExprMd) + `Option StmtExpr` and cannot carry multiple values, so it is flagged + with a diagnostic pointing users at the named-output convention. -/ +def Check.return (exprMd : StmtExprMd) + (val : Option StmtExprMd) (expected : HighTypeMd) + (source : Option FileRange) (h : exprMd.val = .Return val) : - ResolveM (StmtExpr × HighTypeMd) := do - let expected := (← get).expectedReturnTypes + ResolveM StmtExprMd := do + let expectedReturn := (← get).expectedReturnTypes let val' ← val.attach.mapM (fun a => have := a.property; do - match expected with + match expectedReturn with | some [singleOutput] => Check.resolveStmtExpr a.val singleOutput | _ => let (e', _) ← Synth.resolveStmtExpr a.val; pure e') - -- Arity/shape diagnostics independent of the value's own type. - match val, expected with + match val, expectedReturn with | none, some [] => pure () - | none, some [_] => pure () -- Dafny-style early exit - | none, some _ => pure () -- multi-output: bare return is fine + | none, some [_] => pure () + | none, some _ => pure () | some _, some [] => let diag := diagnosticFromSource source "void procedure cannot return a value" modify fun s => { s with errors := s.errors.push diag } - | some _, some [_] => pure () -- value already checked above + | some _, some [_] => pure () | some _, some _ => let diag := diagnosticFromSource source "multi-output procedure cannot use 'return e'; assign to named outputs instead" modify fun s => { s with errors := s.errors.push diag } - | _, none => pure () -- no enclosing procedure - pure (.Return val', { val := .TVoid, source := source }) - termination_by (exprMd, 1) + | _, none => pure () + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .Return val', source := source } + termination_by (exprMd, 0) decreasing_by all_goals apply Prod.Lex.left @@ -915,16 +925,19 @@ def Check.ifThenElse (exprMd : StmtExprMd) -- ### Verification statements -/-- `Γ ⊢ cond ⇐ TBool ∴ Γ ⊢ Assert cond ⇒ TVoid` +/-- `Γ ⊢ cond ⇐ TBool, TVoid <: T ∴ Γ ⊢ Assert cond ⇐ T` - `cond` is checked against `TBool`; the construct synthesizes `TVoid`. -/ -def Synth.assert (exprMd : StmtExprMd) - (condExpr : StmtExprMd) (summary : Option String) (source : Option FileRange) + `cond` is checked against `TBool`; the construct produces no value, + so `expected` must admit `TVoid`. -/ +def Check.assert (exprMd : StmtExprMd) + (condExpr : StmtExprMd) (summary : Option String) + (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Assert ⟨condExpr, summary⟩) : - ResolveM (StmtExpr × HighTypeMd) := do + ResolveM StmtExprMd := do let cond' ← Check.resolveStmtExpr condExpr { val := .TBool, source := condExpr.source } - pure (.Assert { condition := cond', summary }, { val := .TVoid, source := source }) - termination_by (exprMd, 1) + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .Assert { condition := cond', summary }, source := source } + termination_by (exprMd, 0) decreasing_by apply Prod.Lex.left have hsz := exprMd.sizeOf_val_lt @@ -932,16 +945,18 @@ def Synth.assert (exprMd : StmtExprMd) try simp_all omega -/-- `Γ ⊢ cond ⇐ TBool ∴ Γ ⊢ Assume cond ⇒ TVoid` +/-- `Γ ⊢ cond ⇐ TBool, TVoid <: T ∴ Γ ⊢ Assume cond ⇐ T` - `cond` is checked against `TBool`; the construct synthesizes `TVoid`. -/ -def Synth.assume (exprMd : StmtExprMd) - (cond : StmtExprMd) (source : Option FileRange) + `cond` is checked against `TBool`; the construct produces no value, + so `expected` must admit `TVoid`. -/ +def Check.assume (exprMd : StmtExprMd) + (cond : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Assume cond) : - ResolveM (StmtExpr × HighTypeMd) := do + ResolveM StmtExprMd := do let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } - pure (.Assume cond', { val := .TVoid, source := source }) - termination_by (exprMd, 1) + checkSubtype source expected { val := .TVoid, source := source } + pure { val := .Assume cond', source := source } + termination_by (exprMd, 0) decreasing_by apply Prod.Lex.left have hsz := exprMd.sizeOf_val_lt diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index f358fcc258..ffa9cba9c4 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -211,12 +211,12 @@ direction explicit. - *Subsumption* — \[⇐\] Sub - *Literals* — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal -- *Variables* — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇒\] Var-Declare +- *Variables* — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇐\] Var-Declare - *Control flow* — \[⇐\] If, \[⇐\] If-NoElse; - \[⇐\] Block, \[⇐\] Block-Empty; \[⇒\] Exit; - \[⇒\] Return-None, \[⇒\] Return-Some, \[⇒\] Return-Void-Error, - \[⇒\] Return-Multi-Error; \[⇒\] While -- *Verification statements* — \[⇒\] Assert, \[⇒\] Assume + \[⇐\] Block, \[⇐\] Block-Empty; \[⇐\] Exit; + \[⇐\] Return-None, \[⇐\] Return-Some, \[⇐\] Return-Void-Error, + \[⇐\] Return-Multi-Error; \[⇐\] While +- *Verification statements* — \[⇐\] Assert, \[⇐\] Assume - *Assignment* — \[⇒\] Assign - *Calls* — \[⇒\] Static-Call, \[⇒\] Static-Call-Multi, \[⇒\] Instance-Call - *Primitive operations* — \[⇒\] Op-Bool, \[⇒\] Op-Cmp, \[⇒\] Op-Eq, \[⇒\] Op-Arith, @@ -265,9 +265,9 @@ $$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \ma {docstring Strata.Laurel.Resolution.Synth.varField} -$$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T\rangle) \Rightarrow \mathsf{TVoid} \dashv \Gamma, x : T} \quad \text{([⇒] Var-Declare)}` +$$`\frac{x \notin \mathrm{dom}(\Gamma) \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T_x\rangle) \Leftarrow T \dashv \Gamma, x : T_x} \quad \text{([⇐] Var-Declare)}` -{docstring Strata.Laurel.Resolution.Synth.varDeclare} +{docstring Strata.Laurel.Resolution.Check.varDeclare} ### Control flow @@ -307,33 +307,33 @@ a single subsumption test: an empty block is acceptable wherever `TVoid` is. {docstring Strata.Laurel.Resolution.Check.block} -$$`\frac{}{\Gamma \vdash \mathsf{Exit}\;\mathit{target} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Exit)}` +$$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Exit}\;\mathit{target} \Leftarrow T} \quad \text{([⇐] Exit)}` -{docstring Strata.Laurel.Resolution.Synth.exit} +{docstring Strata.Laurel.Resolution.Check.exit} -$$`\frac{}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-None)}` +$$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Leftarrow T} \quad \text{([⇐] Return-None)}` -$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T] \quad \Gamma \vdash e \Leftarrow T}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Return-Some)}` +$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_o] \quad \Gamma \vdash e \Leftarrow T_o \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \Leftarrow T} \quad \text{([⇐] Return-Some)}` -$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = []}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “void procedure cannot return a value”}} \quad \text{([⇒] Return-Void-Error)}` +$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = []}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “void procedure cannot return a value”}} \quad \text{([⇐] Return-Void-Error)}` -$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇒] Return-Multi-Error)}` +$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇐] Return-Multi-Error)}` -{docstring Strata.Laurel.Resolution.Synth.return} +{docstring Strata.Laurel.Resolution.Check.return} -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{dec} \Leftarrow {?} \quad \Gamma \vdash \mathit{body} \Rightarrow \_}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{dec}\;\mathit{body} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] While)}` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{dec} \Leftarrow {?} \quad \Gamma \vdash \mathit{body} \Leftarrow T \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{dec}\;\mathit{body} \Leftarrow T} \quad \text{([⇐] While)}` -{docstring Strata.Laurel.Resolution.Synth.while} +{docstring Strata.Laurel.Resolution.Check.while} ### Verification statements -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assert)}` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Leftarrow T} \quad \text{([⇐] Assert)}` -{docstring Strata.Laurel.Resolution.Synth.assert} +{docstring Strata.Laurel.Resolution.Check.assert} -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assume}\;\mathit{cond} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assume)}` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Assume}\;\mathit{cond} \Leftarrow T} \quad \text{([⇐] Assume)}` -{docstring Strata.Laurel.Resolution.Synth.assume} +{docstring Strata.Laurel.Resolution.Check.assume} ### Assignment From be18c88ad45d1b78e3011cba0b7b6d0eb3bb6e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 16:41:53 -0400 Subject: [PATCH 120/189] move Old and ProveBy to check-only Both constructs are pass-through: Old v has the type of v, ProveBy v p has the type of v (proof is just a hint). Check.old / Check.proveBy push the surrounding expectation into the inner expression rather than synthing-then-subsuming. The proof in ProveBy still synthesizes since it has no type constraint of its own. The deleted synth rules are preserved on leo/synth-old-proveby. Co-Authored-By: Claude Opus 4.7 (1M context) --- Strata/Languages/Laurel/Resolution.lean | 49 ++++++++++++++----------- docs/verso/LaurelDoc.lean | 12 +++--- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 0758a4ad19..8811039d61 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -547,8 +547,8 @@ inside the mutual block below. Helpers are grouped by section to mirror the - Primitive operations — `Synth.primitiveOp` - Object forms — `Synth.new`, `Synth.asType`, `Synth.isType`, `Synth.refEq`, `Synth.pureFieldUpdate` -- Verification expressions — `Synth.quantifier`, `Synth.assigned`, `Synth.old`, - `Synth.fresh`, `Synth.proveBy` +- Verification expressions — `Synth.quantifier`, `Synth.assigned`, + `Synth.fresh`, `Check.old`, `Check.proveBy` - Self reference — `Synth.this` - Untyped forms — `Synth.abstract`, `Synth.all` - ContractOf — `Synth.contractOf` @@ -614,12 +614,8 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy Synth.quantifier exprMd mode param trigger body source (by rw [h_node]) | .Assigned name => Synth.assigned exprMd name source (by rw [h_node]) - | .Old val => - Synth.old exprMd val (by rw [h_node]) | .Fresh val => Synth.fresh exprMd expr val source h_expr (by rw [h_node]) - | .ProveBy val proof => - Synth.proveBy exprMd val proof (by rw [h_node]) | .ContractOf ty fn => Synth.contractOf exprMd ty fn source (by rw [h_node]) | .Abstract => pure (Synth.abstract source) @@ -676,6 +672,10 @@ def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : Resolv Check.assert exprMd condExpr summary expected source (by rw [h_node]) | .Assume cond => Check.assume exprMd cond expected source (by rw [h_node]) + | .Old val => + Check.old exprMd val expected source (by rw [h_node]) + | .ProveBy val proof => + Check.proveBy exprMd val proof expected source (by rw [h_node]) | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← Synth.resolveStmtExpr exprMd @@ -1383,14 +1383,17 @@ def Synth.assigned (exprMd : StmtExprMd) simp [h] at hsz omega -/-- `Γ ⊢ v ⇒ T ∴ Γ ⊢ Old v ⇒ T` -/ -def Synth.old (exprMd : StmtExprMd) - (val : StmtExprMd) +/-- `Γ ⊢ v ⇐ T ∴ Γ ⊢ Old v ⇐ T` + + `Old v` has the same type as `v`, so the surrounding expectation + propagates straight through. -/ +def Check.old (exprMd : StmtExprMd) + (val : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Old val) : - ResolveM (StmtExpr × HighTypeMd) := do - let (val', valTy) ← Synth.resolveStmtExpr val - pure (.Old val', valTy) - termination_by (exprMd, 1) + ResolveM StmtExprMd := do + let val' ← Check.resolveStmtExpr val expected + pure { val := .Old val', source := source } + termination_by (exprMd, 0) decreasing_by apply Prod.Lex.left have hsz := exprMd.sizeOf_val_lt @@ -1419,18 +1422,20 @@ def Synth.fresh (exprMd : StmtExprMd) (expr : StmtExpr) simp [h] at hsz omega -/-- `Γ ⊢ v ⇒ T, Γ ⊢ proof ⇒ _ ∴ Γ ⊢ ProveBy v proof ⇒ T` +/-- `Γ ⊢ v ⇐ T, Γ ⊢ proof ⇒ _ ∴ Γ ⊢ ProveBy v proof ⇐ T` - `v` and `proof` are both synthesized; the construct's type is `v`'s - type — `proof` is a hint for downstream verification. -/ -def Synth.proveBy (exprMd : StmtExprMd) - (val proof : StmtExprMd) + `ProveBy v proof` has the same type as `v` (the proof is just a hint + for downstream verification), so the surrounding expectation + propagates into `v`. The proof itself has no constraint on its type + and is still synthesized. -/ +def Check.proveBy (exprMd : StmtExprMd) + (val proof : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .ProveBy val proof) : - ResolveM (StmtExpr × HighTypeMd) := do - let (val', valTy) ← Synth.resolveStmtExpr val + ResolveM StmtExprMd := do + let val' ← Check.resolveStmtExpr val expected let (proof', _) ← Synth.resolveStmtExpr proof - pure (.ProveBy val' proof', valTy) - termination_by (exprMd, 1) + pure { val := .ProveBy val' proof', source := source } + termination_by (exprMd, 0) decreasing_by all_goals apply Prod.Lex.left diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index ffa9cba9c4..862e0f7348 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -223,8 +223,8 @@ direction explicit. \[⇒\] Op-Concat - *Object forms* — \[⇒\] New-Ok, \[⇒\] New-Fallback; \[⇒\] AsType; \[⇒\] IsType; \[⇒\] RefEq; \[⇒\] PureFieldUpdate -- *Verification expressions* — \[⇒\] Quantifier, \[⇒\] Assigned, \[⇒\] Old, - \[⇒\] Fresh, \[⇒\] ProveBy +- *Verification expressions* — \[⇒\] Quantifier, \[⇒\] Assigned, \[⇐\] Old, + \[⇒\] Fresh, \[⇐\] ProveBy - *Self reference* — \[⇒\] This-Inside, \[⇒\] This-Outside - *Untyped forms* — \[⇒\] Abstract / All - *ContractOf* — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error @@ -417,17 +417,17 @@ $$`\frac{\Gamma \vdash \mathit{name} \Rightarrow \_}{\Gamma \vdash \mathsf{Assig {docstring Strata.Laurel.Resolution.Synth.assigned} -$$`\frac{\Gamma \vdash v \Rightarrow T}{\Gamma \vdash \mathsf{Old}\;v \Rightarrow T} \quad \text{([⇒] Old)}` +$$`\frac{\Gamma \vdash v \Leftarrow T}{\Gamma \vdash \mathsf{Old}\;v \Leftarrow T} \quad \text{([⇐] Old)}` -{docstring Strata.Laurel.Resolution.Synth.old} +{docstring Strata.Laurel.Resolution.Check.old} $$`\frac{\Gamma \vdash v \Rightarrow T \quad \mathsf{isReference}\;T}{\Gamma \vdash \mathsf{Fresh}\;v \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Fresh)}` {docstring Strata.Laurel.Resolution.Synth.fresh} -$$`\frac{\Gamma \vdash v \Rightarrow T \quad \Gamma \vdash \mathit{proof} \Rightarrow \_}{\Gamma \vdash \mathsf{ProveBy}\;v\;\mathit{proof} \Rightarrow T} \quad \text{([⇒] ProveBy)}` +$$`\frac{\Gamma \vdash v \Leftarrow T \quad \Gamma \vdash \mathit{proof} \Rightarrow \_}{\Gamma \vdash \mathsf{ProveBy}\;v\;\mathit{proof} \Leftarrow T} \quad \text{([⇐] ProveBy)}` -{docstring Strata.Laurel.Resolution.Synth.proveBy} +{docstring Strata.Laurel.Resolution.Check.proveBy} ### Self reference From 4c23930cb5429f6096f008ec4052f3e016a1bb05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 16:44:42 -0400 Subject: [PATCH 121/189] push assignment target types into RHS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synth.assign and Check.assign now compute ExpectedTy from the targets' declared types and push it into the RHS via Check.resolveStmtExpr, instead of synthesizing the RHS and verifying ExpectedTy <: valueTy afterward. This means bidirectional rules in the RHS (Check.ifThenElse, Check.block, …) propagate the assignment's type into nested constructs: `var x: int := if c then a else b` checks each branch against int directly, with errors fired at the offending branch. Synth.assign returns ExpectedTy (rather than the RHS's synthesized type), since the RHS has been checked against ExpectedTy and any mismatch is already reported. Expression-position assignments like `x ++ (y := s)` see y's declared type, not s's actual type — matches the principle that a typed binding has its declared type. The TVoid-RHS skip in the previous code (which avoided spurious diagnostics when the RHS was a side-effecting statement like `return` or `while` that synthesized TVoid) is no longer needed: those constructs are check-only now and will be checked against ExpectedTy directly, surfacing a clear "expected 'int', got 'TVoid'" error if the user wrote something nonsensical like `x: int := while (…) {…}`. The previous synth-then-verify behavior is preserved on leo/synth-assign. Co-Authored-By: Claude Opus 4.7 (1M context) --- Strata/Languages/Laurel/Resolution.lean | 82 ++++++++++++------------- docs/verso/LaurelDoc.lean | 8 ++- 2 files changed, 47 insertions(+), 43 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 8811039d61..a2986a5d83 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -966,23 +966,26 @@ def Check.assume (exprMd : StmtExprMd) -- ### Assignment -/-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇒ T_e, T_e <: ExpectedTy ∴ Γ ⊢ Assign targets e ⇒ TVoid` +/-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy ∴ Γ ⊢ Assign targets e ⇒ ExpectedTy` where `ExpectedTy = T_1` if `|targets| = 1`, else `MultiValuedExpr [T_1; …; T_n]`. - Each target's declared type `T_i` (from `Local`, - `Field`, or fresh `Declare`) is collapsed into a tuple `ExpectedTy` - (single type if one target, otherwise `MultiValuedExpr [T_1; …; T_n]`) - and checked against the RHS's synthesized type. Both single- and - multi-target forms collapse into one tuple-vs-tuple check: when the RHS - is a `MultiValuedExpr`, both arity and per-position type mismatches - surface in a single diagnostic of shape *"expected '(int, int, int)', - got '(int, string)'"*. When the RHS is `TVoid` (a side-effecting - statement: `while`, `return`, …), all checks are skipped — there's no - value to assign. The construct synthesizes the RHS's type, so that - expression-position assignments like `x ++ (y := s)` see a string in - the second operand; statement-position uses are accommodated by - `Check.assign`, which accepts `TVoid` as the expected type. -/ + Each target's declared type `T_i` (from `Local`, `Field`, or fresh + `Declare`) is collapsed into a tuple `ExpectedTy` (single type if one + target, otherwise `MultiValuedExpr [T_1; …; T_n]`) and pushed into + the RHS via `Check.resolveStmtExpr`. This means the RHS's bidirectional + rules (e.g. `Check.ifThenElse`, `Check.block`) propagate `ExpectedTy` + inward: `var x: int := if c then a else b` checks each branch against + `int` directly, with errors fired at the offending branch. + + Multi-target forms produce a single tuple-vs-tuple check: when the + RHS is itself `MultiValuedExpr` (a multi-output procedure call), both + arity and per-position type mismatches surface in a single diagnostic + of shape *"expected '(int, int, int)', got '(int, string)'"*. + + The synthesized type is `ExpectedTy`, so expression-position + assignments like `x ++ (y := s)` see the target type in the second + operand. -/ def Synth.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assign targets value) : @@ -1001,19 +1004,17 @@ def Synth.assign (exprMd : StmtExprMd) let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let (value', valueTy) ← Synth.resolveStmtExpr value let targetType (t : VariableMd) : ResolveM HighTypeMd := do match t.val with | .Local ref => getVarType ref | .Declare param => pure param.type | .Field _ fieldName => getVarType fieldName - if valueTy.val != HighType.TVoid then - let targetTys ← targets'.mapM targetType - let expectedTy : HighTypeMd := match targetTys with - | [single] => single - | _ => { val := .MultiValuedExpr targetTys, source := source } - checkSubtype source expectedTy valueTy - pure (.Assign targets' value', valueTy) + let targetTys ← targets'.mapM targetType + let expectedTy : HighTypeMd := match targetTys with + | [single] => single + | _ => { val := .MultiValuedExpr targetTys, source := source } + let value' ← Check.resolveStmtExpr value expectedTy + pure (.Assign targets' value', expectedTy) termination_by (exprMd, 1) decreasing_by all_goals @@ -1024,19 +1025,20 @@ def Synth.assign (exprMd : StmtExprMd) try (have := List.sizeOf_lt_of_mem ‹_ ∈ targets›; simp_all) omega -/-- Cases on whether `expected` is `TVoid` (statement position) or some - other type (expression position). +/-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy, ExpectedTy <: T ∴ Γ ⊢ Assign targets e ⇐ T` - `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇒ T_e, T_e <: ExpectedTy ∴ Γ ⊢ Assign targets e ⇐ TVoid` + where `ExpectedTy = T_1` if `|targets| = 1`, else + `MultiValuedExpr [T_1; …; T_n]`. - `Γ ⊢ Assign targets e ⇒ T_e, T_e <: T ∴ Γ ⊢ Assign targets e ⇐ T (T ≠ TVoid)` - - An assignment in statement position (checked against `TVoid`) discards - its RHS value, so the synthesized type is not compared against - `expected`. This lets `b := 1` appear as the last statement of a block - in an else-less `if` (whose branch is checked against `TVoid`) without - firing a subsumption error against the RHS's type. For non-`TVoid` - expected types, falls back to subsumption. -/ + Like `Synth.assign`, the target tuple type is pushed into the RHS so + bidirectional rules in the RHS receive the assignment's type. The + outer subsumption `ExpectedTy <: T` accommodates use as a statement + (`T = TVoid`, no value to compare) or as an expression + (`T ≠ TVoid`, the result type must match). When `T = TVoid` the + subsumption is satisfied trivially since `_ <: TVoid` only when the + LHS is also `TVoid` — the assignment value is discarded in statement + position and we want no further check, so the subsumption is + skipped. -/ def Check.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) @@ -1055,20 +1057,18 @@ def Check.assign (exprMd : StmtExprMd) let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let (value', valueTy) ← Synth.resolveStmtExpr value let targetType (t : VariableMd) : ResolveM HighTypeMd := do match t.val with | .Local ref => getVarType ref | .Declare param => pure param.type | .Field _ fieldName => getVarType fieldName - if valueTy.val != HighType.TVoid then - let targetTys ← targets'.mapM targetType - let assignedTy : HighTypeMd := match targetTys with - | [single] => single - | _ => { val := .MultiValuedExpr targetTys, source := source } - checkSubtype source assignedTy valueTy + let targetTys ← targets'.mapM targetType + let expectedTy : HighTypeMd := match targetTys with + | [single] => single + | _ => { val := .MultiValuedExpr targetTys, source := source } + let value' ← Check.resolveStmtExpr value expectedTy unless expected.val matches .TVoid do - checkSubtype source expected valueTy + checkSubtype source expected expectedTy pure { val := .Assign targets' value', source := source } termination_by (exprMd, 0) decreasing_by diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 862e0f7348..827bb2757d 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -337,12 +337,16 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \mathsf{TVo ### Assignment -$$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Rightarrow T_e \quad T_e <: \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Assign)}` +$$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Leftarrow \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathit{ExpectedTy}} \quad \text{([⇒] Assign)}` + +$$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Leftarrow \mathit{ExpectedTy} \quad \mathit{ExpectedTy} <: T}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Leftarrow T} \quad \text{([⇐] Assign)}` where `ExpectedTy = T_1` if `|targets| = 1` and `MultiValuedExpr [T_1; …; T_n]` otherwise. The target's declared type `T_i` comes from the variable's scope entry (for {name Strata.Laurel.Variable.Local}`Local` and {name Strata.Laurel.Variable.Field}`Field`) -or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. +or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. The +RHS receives `ExpectedTy` via `Check.resolveStmtExpr`, so bidirectional rules in the +RHS propagate the assignment's type into nested constructs. {docstring Strata.Laurel.Resolution.Synth.assign} From 971c39dde386881f5ffc1d6b22494d5e1175e632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 16:49:47 -0400 Subject: [PATCH 122/189] fix return type --- Strata/Languages/Laurel/Resolution.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index a2986a5d83..2bbf1c92ce 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -665,7 +665,7 @@ def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : Resolv | .Var (.Declare param) => Check.varDeclare param expected source | .While cond invs dec body => Check.while exprMd cond invs dec body expected source (by rw [h_node]) - | .Exit target => pure (Check.exit target expected source) + | .Exit target => Check.exit target expected source | .Return val => Check.return exprMd val expected source (by rw [h_node]) | .Assert ⟨condExpr, summary⟩ => From f3da746a495decdf6cd704cecf7c668a9d97eaec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 16:54:13 -0400 Subject: [PATCH 123/189] =?UTF-8?q?add=20[=E2=87=90]=20Assign=20entry=20to?= =?UTF-8?q?=20typing-rule=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/verso/LaurelDoc.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 827bb2757d..86fef7e8e3 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -217,7 +217,7 @@ direction explicit. \[⇐\] Return-None, \[⇐\] Return-Some, \[⇐\] Return-Void-Error, \[⇐\] Return-Multi-Error; \[⇐\] While - *Verification statements* — \[⇐\] Assert, \[⇐\] Assume -- *Assignment* — \[⇒\] Assign +- *Assignment* — \[⇒\] Assign, \[⇐\] Assign - *Calls* — \[⇒\] Static-Call, \[⇒\] Static-Call-Multi, \[⇒\] Instance-Call - *Primitive operations* — \[⇒\] Op-Bool, \[⇒\] Op-Cmp, \[⇒\] Op-Eq, \[⇒\] Op-Arith, \[⇒\] Op-Concat From 5717a725d9c7ca1d34a03adc1d65099bda02b64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 16:56:53 -0400 Subject: [PATCH 124/189] remove synthesis rule for assign Synth.assign is gone; only Check.assign remains. Expression-position assignments (e.g. x ++ (y := s)) now hit the synth wildcard like every other migrated control-flow construct, producing the same "no synthesis rule" diagnostic. No test in the suite uses assignment in expression position; idiomatic Laurel only uses assign as a statement. The deleted synth rule is preserved on leo/synth-assign-expr-position. Co-Authored-By: Claude Opus 4.7 (1M context) --- Strata/Languages/Laurel/Resolution.lean | 81 +++---------------------- docs/verso/LaurelDoc.lean | 6 +- 2 files changed, 11 insertions(+), 76 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 2bbf1c92ce..2f2de9fabd 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -542,7 +542,7 @@ inside the mutual block below. Helpers are grouped by section to mirror the - Control flow — `Check.while`, `Check.exit`, `Check.return`, `Check.block`, `Check.ifThenElse` - Verification statements — `Check.assert`, `Check.assume` -- Assignment — `Synth.assign`, `Check.assign` +- Assignment — `Check.assign` - Calls — `Synth.staticCall`, `Synth.instanceCall` - Primitive operations — `Synth.primitiveOp` - Object forms — `Synth.new`, `Synth.asType`, `Synth.isType`, `Synth.refEq`, @@ -592,8 +592,6 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy | .Var (.Local ref) => Synth.varLocal ref source | .Var (.Field target fieldName) => Synth.varField exprMd target fieldName source (by rw [h_node]) - | .Assign targets value => - Synth.assign exprMd targets value source (by rw [h_node]) | .PureFieldUpdate target fieldName newVal => Synth.pureFieldUpdate exprMd target fieldName newVal (by rw [h_node]) | .StaticCall callee args => @@ -966,79 +964,20 @@ def Check.assume (exprMd : StmtExprMd) -- ### Assignment -/-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy ∴ Γ ⊢ Assign targets e ⇒ ExpectedTy` - - where `ExpectedTy = T_1` if `|targets| = 1`, else `MultiValuedExpr [T_1; …; T_n]`. - - Each target's declared type `T_i` (from `Local`, `Field`, or fresh - `Declare`) is collapsed into a tuple `ExpectedTy` (single type if one - target, otherwise `MultiValuedExpr [T_1; …; T_n]`) and pushed into - the RHS via `Check.resolveStmtExpr`. This means the RHS's bidirectional - rules (e.g. `Check.ifThenElse`, `Check.block`) propagate `ExpectedTy` - inward: `var x: int := if c then a else b` checks each branch against - `int` directly, with errors fired at the offending branch. - - Multi-target forms produce a single tuple-vs-tuple check: when the - RHS is itself `MultiValuedExpr` (a multi-output procedure call), both - arity and per-position type mismatches surface in a single diagnostic - of shape *"expected '(int, int, int)', got '(int, string)'"*. - - The synthesized type is `ExpectedTy`, so expression-position - assignments like `x ++ (y := s)` see the target type in the second - operand. -/ -def Synth.assign (exprMd : StmtExprMd) - (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) - (h : exprMd.val = .Assign targets value) : - ResolveM (StmtExpr × HighTypeMd) := do - let targets' ← targets.attach.mapM fun ⟨v, _⟩ => do - let ⟨vv, vs⟩ := v - match vv with - | .Local ref => - let ref' ← resolveRef ref source - pure (⟨.Local ref', vs⟩ : VariableMd) - | .Field target fieldName => - let (target', _) ← Synth.resolveStmtExpr target - let fieldName' ← resolveFieldRef target' fieldName source - pure (⟨.Field target' fieldName', vs⟩ : VariableMd) - | .Declare param => - let ty' ← resolveHighType param.type - let name' ← defineNameCheckDup param.name (.var param.name ty') - pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) - let targetType (t : VariableMd) : ResolveM HighTypeMd := do - match t.val with - | .Local ref => getVarType ref - | .Declare param => pure param.type - | .Field _ fieldName => getVarType fieldName - let targetTys ← targets'.mapM targetType - let expectedTy : HighTypeMd := match targetTys with - | [single] => single - | _ => { val := .MultiValuedExpr targetTys, source := source } - let value' ← Check.resolveStmtExpr value expectedTy - pure (.Assign targets' value', expectedTy) - termination_by (exprMd, 1) - decreasing_by - all_goals - apply Prod.Lex.left - have hsz := exprMd.sizeOf_val_lt - simp [h] at hsz - try simp_all - try (have := List.sizeOf_lt_of_mem ‹_ ∈ targets›; simp_all) - omega - /-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy, ExpectedTy <: T ∴ Γ ⊢ Assign targets e ⇐ T` where `ExpectedTy = T_1` if `|targets| = 1`, else `MultiValuedExpr [T_1; …; T_n]`. - Like `Synth.assign`, the target tuple type is pushed into the RHS so - bidirectional rules in the RHS receive the assignment's type. The - outer subsumption `ExpectedTy <: T` accommodates use as a statement - (`T = TVoid`, no value to compare) or as an expression - (`T ≠ TVoid`, the result type must match). When `T = TVoid` the - subsumption is satisfied trivially since `_ <: TVoid` only when the - LHS is also `TVoid` — the assignment value is discarded in statement - position and we want no further check, so the subsumption is - skipped. -/ + The target tuple type is pushed into the RHS via + `Check.resolveStmtExpr`, so bidirectional rules in the RHS receive + the assignment's type. The outer subsumption `ExpectedTy <: T` + accommodates use as a statement (`T = TVoid`, no value to compare) + or as an expression (`T ≠ TVoid`, the result type must match). When + `T = TVoid` the subsumption is satisfied trivially since `_ <: TVoid` + only when the LHS is also `TVoid` — the assignment value is + discarded in statement position and we want no further check, so the + subsumption is skipped. -/ def Check.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 86fef7e8e3..43e92692a6 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -217,7 +217,7 @@ direction explicit. \[⇐\] Return-None, \[⇐\] Return-Some, \[⇐\] Return-Void-Error, \[⇐\] Return-Multi-Error; \[⇐\] While - *Verification statements* — \[⇐\] Assert, \[⇐\] Assume -- *Assignment* — \[⇒\] Assign, \[⇐\] Assign +- *Assignment* — \[⇐\] Assign - *Calls* — \[⇒\] Static-Call, \[⇒\] Static-Call-Multi, \[⇒\] Instance-Call - *Primitive operations* — \[⇒\] Op-Bool, \[⇒\] Op-Cmp, \[⇒\] Op-Eq, \[⇒\] Op-Arith, \[⇒\] Op-Concat @@ -337,8 +337,6 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \mathsf{TVo ### Assignment -$$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Leftarrow \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathit{ExpectedTy}} \quad \text{([⇒] Assign)}` - $$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Leftarrow \mathit{ExpectedTy} \quad \mathit{ExpectedTy} <: T}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Leftarrow T} \quad \text{([⇐] Assign)}` where `ExpectedTy = T_1` if `|targets| = 1` and `MultiValuedExpr [T_1; …; T_n]` otherwise. @@ -348,8 +346,6 @@ or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. RHS receives `ExpectedTy` via `Check.resolveStmtExpr`, so bidirectional rules in the RHS propagate the assignment's type into nested constructs. -{docstring Strata.Laurel.Resolution.Synth.assign} - {docstring Strata.Laurel.Resolution.Check.assign} ### Calls From 88e01cb65162e56d70ca45f780dc9005f328bbd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 20 May 2026 17:13:15 -0400 Subject: [PATCH 125/189] better docstrings --- Strata/Languages/Laurel/Resolution.lean | 116 +++++++++++++----------- 1 file changed, 63 insertions(+), 53 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 2f2de9fabd..3f85e99a27 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -554,8 +554,10 @@ inside the mutual block below. Helpers are grouped by section to mirror the - ContractOf — `Synth.contractOf` - Holes — `Synth.hole`, `Check.holeNone` -The dispatch functions `Synth.resolveStmtExpr` and `Check.resolveStmtExpr` simply pattern-match -on the constructor and delegate to the corresponding helper. -/ +The dispatch functions `Synth.resolveStmtExpr` and `Check.resolveStmtExpr` +pattern-match on the constructor and delegate to the corresponding helper. +`Synth.resolveStmtExpr` is non-total: constructors without a synthesis rule +hit a wildcard arm that emits a diagnostic and returns `Unknown`. -/ namespace Resolution @@ -568,19 +570,19 @@ mutual -- ### Dispatch /-- Synth-mode resolution: resolve `e` and synthesize its `HighType`, - written `Γ ⊢ e ⇒ T`. Each constructor delegates to its rule's helper. - - Synthesis returns a type inferred from the expression itself; checking - (`Check.resolveStmtExpr`) verifies that the expression has a given expected - type. Each construct picks a mode based on whether its type is - determined locally (synth) or by context (check). Synth rules invoke - check on subexpressions whose expected type is known (e.g. - `cond ⇐ TBool` in `IfThenElse`); `Check.resolveStmtExpr` falls back to - `Synth.resolveStmtExpr` via subsumption (rule `[⇐] Sub`). The two functions - are mutually recursive, with termination on a lexicographic measure - `(exprMd, tag)` — tag `0` for check, `1` for synth — so that - subsumption (which calls synth on the *same* expression) can decrease - via `Prod.Lex.right`. -/ + written `Γ ⊢ e ⇒ T`. Each constructor with a synthesis rule delegates + to its rule's helper; constructors without one (statement-shaped + constructs like `IfThenElse`, `Block`, `While`, `Return`, `Assign`, + …) hit a wildcard arm that emits a `typeMismatch` diagnostic and + returns `Unknown` to suppress cascading errors. + + Synthesis returns a type inferred from the expression itself; + checking (`Check.resolveStmtExpr`) verifies that the expression has + a given expected type. The two functions are mutually recursive, + with termination on a lexicographic measure `(exprMd, tag)` — tag + `2` for synth, `3` for check, helpers smaller — so that subsumption + (which calls synth on the *same* expression) can decrease via + `Prod.Lex.right`. -/ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTypeMd) := do match h_node: exprMd with | AstNode.mk expr source => @@ -634,21 +636,27 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy /-- Check-mode resolution (rule **Sub** at the boundary): resolve `e` and verify its type is a consistent subtype of `expected`, written - `Γ ⊢ e ⇐ T`. Bidirectional rules for individual constructs (`Block`, - `IfThenElse`, `Assign`, `Hole`) push `expected` into subexpressions - rather than bouncing through synthesis, which keeps error messages - localized and lets the expected type propagate through nested control - flow. Everything else falls back to subsumption — synthesize, then - verify `isConsistentSubtype actual expected`. + `Γ ⊢ e ⇐ T`. Bidirectional rules for individual constructs push + `expected` into subexpressions rather than bouncing through + synthesis, which keeps error messages localized and lets the + expected type propagate through nested control flow. Constructs + with a dedicated `Check.` rule: + + - bindings — `Var (.Declare …)`, `Assign` + - control flow — `Block`, `IfThenElse`, `While`, `Exit`, `Return` + - verification — `Assert`, `Assume`, `Old`, `ProveBy` + - holes — untyped `Hole` + + Everything else falls back to subsumption — synthesize, then verify + `isConsistentSubtype actual expected`. The right principle for new call sites is: when the position has a known expected type (`TBool` for conditions, numeric for `decreases`, the declared output for a constant initializer or a functional body), - use `Check.resolveStmtExpr`. When it doesn't, use `resolveStmtExpr` (a thin - wrapper that calls `Synth.resolveStmtExpr` and discards the synthesized type, - used at sites where typing is not enforced — verification annotations, - modifies/reads clauses). `Synth.resolveStmtExpr` itself is mostly an internal - interface used by other rules. -/ + use `Check.resolveStmtExpr`. When it doesn't, use `resolveStmtExpr` + (a thin wrapper that calls `Synth.resolveStmtExpr` and discards the + synthesized type, used at sites where typing is not enforced — + verification annotations, modifies/reads clauses). -/ def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : ResolveM StmtExprMd := do match h_node: exprMd with | AstNode.mk expr source => @@ -964,20 +972,20 @@ def Check.assume (exprMd : StmtExprMd) -- ### Assignment -/-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy, ExpectedTy <: T ∴ Γ ⊢ Assign targets e ⇐ T` +/-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy, ExpectedTy <: T ∴ Γ ⊢ Assign targets e ⇐ T` (T ≠ TVoid) + + `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy ∴ Γ ⊢ Assign targets e ⇐ TVoid` where `ExpectedTy = T_1` if `|targets| = 1`, else `MultiValuedExpr [T_1; …; T_n]`. The target tuple type is pushed into the RHS via `Check.resolveStmtExpr`, so bidirectional rules in the RHS receive - the assignment's type. The outer subsumption `ExpectedTy <: T` - accommodates use as a statement (`T = TVoid`, no value to compare) - or as an expression (`T ≠ TVoid`, the result type must match). When - `T = TVoid` the subsumption is satisfied trivially since `_ <: TVoid` - only when the LHS is also `TVoid` — the assignment value is - discarded in statement position and we want no further check, so the - subsumption is skipped. -/ + the assignment's type. When `T ≠ TVoid` (expression position) the + outer subsumption `ExpectedTy <: T` is enforced. When `T = TVoid` + (statement position) the subsumption is skipped: the assignment's + value is discarded as a statement, so there is nothing to compare + against `expected`. -/ def Check.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) @@ -1086,30 +1094,32 @@ def Synth.instanceCall (exprMd : StmtExprMd) -- ### Primitive operations -/-- Cases on the operator family. +/-- Cases on the operator family. All operands are synthesized first; + then a per-family verification fires using `checkSubtype` (a post-synth + subtype test, not bidirectional check resolution). - `Γ ⊢ args_i ⇐ TBool, op ∈ {And, Or, AndThen, OrElse, Not, Implies} ∴ Γ ⊢ PrimitiveOp op args ⇒ TBool` + `Γ ⊢ args_i ⇒ U_i, U_i <: TBool, op ∈ {And, Or, AndThen, OrElse, Not, Implies} ∴ Γ ⊢ PrimitiveOp op args ⇒ TBool` - `Γ ⊢ args_i ⇐ Numeric, op ∈ {Lt, Leq, Gt, Geq} ∴ Γ ⊢ PrimitiveOp op args ⇒ TBool` + `Γ ⊢ args_i ⇒ U_i, Numeric U_i, op ∈ {Lt, Leq, Gt, Geq} ∴ Γ ⊢ PrimitiveOp op args ⇒ TBool` `Γ ⊢ lhs ⇒ T_l, Γ ⊢ rhs ⇒ T_r, T_l ~ T_r, op ∈ {Eq, Neq} ∴ Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool` - `Γ ⊢ args_i ⇐ Numeric, Γ ⊢ args.head ⇒ T, op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇒ T` - - `Γ ⊢ args_i ⇐ TString, op = StrConcat ∴ Γ ⊢ PrimitiveOp op args ⇒ TString` - - Each operator family has its own argument-type discipline and result - type. Arguments are synthesized first, then the per-family check fires: - `⇐ TBool` for booleans, `Numeric` (consistent with `TInt`, `TReal`, or - `TFloat64`) for arithmetic/comparison, consistency `~` for equality - (symmetric — no subtype direction is privileged), `⇐ TString` for - concatenation. The result type is `TBool` for - booleans/comparisons/equality, the head argument's type for arithmetic - ("result is the type of the first argument" handles `int + int → int`, - `real + real → real`, etc. without unification — known relaxation: - `int + real` passes since each operand individually passes `Numeric`; - a proper fix needs numeric promotion or unification), `TString` for - concatenation. -/ + `Γ ⊢ args_i ⇒ U_i, Numeric U_i, Γ ⊢ args.head ⇒ T, op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇒ T` + + `Γ ⊢ args_i ⇒ U_i, U_i <: TString, op = StrConcat ∴ Γ ⊢ PrimitiveOp op args ⇒ TString` + + `Numeric T` is the predicate "T unfolds to TInt / TReal / TFloat64 + (or Unknown via the gradual escape hatch)" — not a single type, so it + cannot serve as an `expected` for `Check.resolveStmtExpr`. `~` is + symmetric consistency under the gradual relation, so equality has no + privileged operand direction. + + The result type is `TBool` for booleans/comparisons/equality, the + head argument's type for arithmetic ("result is the type of the + first argument" handles `int + int → int`, `real + real → real`, + etc. without unification — known relaxation: `int + real` passes + since each operand individually passes `Numeric`; a proper fix needs + numeric promotion or unification), `TString` for concatenation. -/ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) (op : Operation) (args : List StmtExprMd) (source : Option FileRange) (h_expr : expr = .PrimitiveOp op args) From f0016e6a99dff032eeb46bce80f556f8d6cf93cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 21 May 2026 09:45:29 -0400 Subject: [PATCH 126/189] move holes to check-only, roll back InferHoleTypes Synth.hole is gone; both flavors of Hole are now check-only: - Check.holeSome validates a user annotation T_h against the surrounding expected T via subsumption (T_h <: T) and preserves the annotation on the node. - Check.holeNone records the surrounding expected type as the hole's annotation, same as before. A Hole reaching synth mode hits the dispatcher's wildcard arm and produces a "no synthesis rule" diagnostic. The Hole-None-Check pre-annotation guarantee from earlier is now extended: every hole reachable in a check-mode position carries a type after resolution. InferHoleTypes.lean is restored to the pre-branch version (parent of 40e15722). The consistency-check logic that flagged disagreement between resolution-time and inference-time types is gone, along with the typeContext threading that supported it. The deleted Synth.hole rule is preserved on leo/synth-hole. Co-Authored-By: Claude Opus 4.7 (1M context) --- Strata/Languages/Laurel/InferHoleTypes.lean | 21 +------- Strata/Languages/Laurel/Resolution.lean | 55 ++++++++------------- docs/verso/LaurelDoc.lean | 18 +++---- 3 files changed, 31 insertions(+), 63 deletions(-) diff --git a/Strata/Languages/Laurel/InferHoleTypes.lean b/Strata/Languages/Laurel/InferHoleTypes.lean index 248d90716d..d56ad86881 100644 --- a/Strata/Languages/Laurel/InferHoleTypes.lean +++ b/Strata/Languages/Laurel/InferHoleTypes.lean @@ -51,8 +51,6 @@ inductive InferHoleTypesStats where structure InferHoleState where model : SemanticModel - /-- Type-relation tables used by the consistency check on pre-annotated holes. -/ - typeContext : TypeContext currentOutputType : HighTypeMd statistics : Statistics := {} diagnostics : List DiagnosticModel := [] @@ -89,7 +87,7 @@ private def inferExpr (expr : StmtExprMd) (expectedType : HighTypeMd) : InferHol match expr with | AstNode.mk val source => match val with - | .Hole det existingTy => + | .Hole det _ => if expectedType.val == .Unknown then modify fun s => { s with statistics := s.statistics.increment s!"{InferHoleTypesStats.holesLeftUnknown}" @@ -97,18 +95,6 @@ private def inferExpr (expr : StmtExprMd) (expectedType : HighTypeMd) : InferHol } return expr else - -- If the hole already carried a type (from resolution's Hole-None-Check - -- rule, or from a user-written `?: T`), flag a conflict when the two - -- types disagree under consistency (gradual ~). - match existingTy with - | some prior => - let ctx := (← get).typeContext - unless isConsistent ctx prior expectedType do - modify fun s => { s with - diagnostics := s.diagnostics ++ [diagnosticFromSource source - s!"hole annotated with '{formatHighTypeVal prior.val}' but context expects '{formatHighTypeVal expectedType.val}'"] - } - | none => pure () modify fun s => { s with statistics := s.statistics.increment s!"{InferHoleTypesStats.holesAnnotated}" } return ⟨.Hole det (some expectedType), source⟩ | .PrimitiveOp op args => @@ -186,10 +172,7 @@ private def inferProcedure (proc : Procedure) : InferHoleM Procedure := do Annotate every `.Hole` in the program with a type inferred from context. -/ def inferHoleTypes (model : SemanticModel) (program : Program) : Program × List DiagnosticModel × Statistics := - let initState : InferHoleState := { - model := model, - typeContext := TypeContext.ofTypes program.types, - currentOutputType := { val := .Unknown, source := none } } + let initState : InferHoleState := { model := model, currentOutputType := { val := .Unknown, source := none }} let (procs, finalState) := (program.staticProcedures.mapM inferProcedure).run initState ({ program with staticProcedures := procs }, finalState.diagnostics, finalState.statistics) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 3f85e99a27..77ea08c5e6 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -119,10 +119,11 @@ A few open structural questions worth recording — see the *Type checking* sect Resolution already synthesizes those types and discards them. Caching per-node types on `SemanticModel` (or directly on the AST) would let the later passes look them up instead of recomputing. -- *Shrink or remove `InferHoleTypes`.* `Hole-None-Check` already records expected types - during resolution for holes in check-mode positions. Holes in synth-only positions still - need the post-pass, but as more constructs gain bespoke check rules, fewer holes need - it; eventually the pass can go away. +- *Shrink or remove `InferHoleTypes`.* Holes are check-only now: `Hole-Some` validates + user annotations against the surrounding type, and `Hole-None` records the expected + type for untyped holes. Every hole reachable in a check-mode position already carries + a type after resolution; the inference pass is left handling whatever residue remains + and can plausibly be deleted entirely. -/ namespace Strata.Laurel @@ -552,7 +553,7 @@ inside the mutual block below. Helpers are grouped by section to mirror the - Self reference — `Synth.this` - Untyped forms — `Synth.abstract`, `Synth.all` - ContractOf — `Synth.contractOf` -- Holes — `Synth.hole`, `Check.holeNone` +- Holes — `Check.holeSome`, `Check.holeNone` The dispatch functions `Synth.resolveStmtExpr` and `Check.resolveStmtExpr` pattern-match on the constructor and delegate to the corresponding helper. @@ -620,7 +621,6 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy Synth.contractOf exprMd ty fn source (by rw [h_node]) | .Abstract => pure (Synth.abstract source) | .All => pure (Synth.all source) - | .Hole det type => Synth.hole det type source | _ => let unknown : HighTypeMd := { val := .Unknown, source := source } typeMismatch source (some expr) @@ -645,7 +645,7 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy - bindings — `Var (.Declare …)`, `Assign` - control flow — `Block`, `IfThenElse`, `While`, `Exit`, `Return` - verification — `Assert`, `Assume`, `Old`, `ProveBy` - - holes — untyped `Hole` + - holes — `Hole` (typed and untyped) Everything else falls back to subsumption — synthesize, then verify `isConsistentSubtype actual expected`. @@ -668,6 +668,7 @@ def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : Resolv | .Assign targets value => Check.assign exprMd targets value expected source (by rw [h_node]) | .Hole det none => pure (Check.holeNone det expected source) + | .Hole det (some ty) => Check.holeSome det ty expected source | .Var (.Declare param) => Check.varDeclare param expected source | .While cond invs dec body => Check.while exprMd cond invs dec body expected source (by rw [h_node]) @@ -1496,38 +1497,24 @@ def Synth.contractOf (exprMd : StmtExprMd) -- ### Holes -/-- Cases on whether the hole has a type annotation. +/-- `T_h <: T ∴ Γ ⊢ Hole d (some T_h) ⇐ T` - `Γ ⊢ Hole d (some T) ⇒ T` - - `Γ ⊢ Hole d none ⇒ Unknown` - - A typed hole synthesizes its annotation; an untyped hole in synth - position synthesizes `Unknown`. -/ -def Synth.hole (det : Bool) (type : Option HighTypeMd) (source : Option FileRange) : - ResolveM (StmtExpr × HighTypeMd) := do - match type with - | some ty => - let ty' ← resolveHighType ty - pure (.Hole det ty', ty') - | none => pure (.Hole det none, { val := .Unknown, source := source }) + A typed hole carries the user's annotation `T_h`. The annotation is + resolved and verified against the surrounding `expected` type via + subsumption; the resolved annotation is preserved on the node so + downstream passes (hole elimination) can generate correctly typed + uninterpreted functions. -/ +def Check.holeSome (det : Bool) (ty : HighTypeMd) (expected : HighTypeMd) + (source : Option FileRange) : ResolveM StmtExprMd := do + let ty' ← resolveHighType ty + checkSubtype source expected ty' + pure { val := .Hole det (some ty'), source := source } /-- `Γ ⊢ Hole d none ⇐ T ↦ Γ ⊢ Hole d (some T)` An untyped hole in check mode records the expected type on the node - so downstream passes don't have to infer it - again. The subsumption check is trivial (`Unknown <: T` always holds), - so this rule never fails — it just preserves the type information - available at the check-mode boundary instead of discarding it. - - A separate `InferHoleTypes` pass still runs after resolution to - annotate holes that ended up in synth-only positions. When that pass - encounters a hole whose type was already set (by `[⇐] Hole-None` or by - a user-written `?: T`), it checks the resolution-time and - inference-time types for consistency under `~`; a disagreement fires - the diagnostic *"hole annotated with 'T_resolution' but context - expects 'T_inference'"*, surfacing what would otherwise be a silent - overwrite. -/ + so downstream passes (hole elimination) don't have to infer it + again. -/ def Check.holeNone (det : Bool) (expected : HighTypeMd) (source : Option FileRange) : StmtExprMd := { val := .Hole det (some expected), source := source } diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 43e92692a6..f668685da4 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -228,7 +228,7 @@ direction explicit. - *Self reference* — \[⇒\] This-Inside, \[⇒\] This-Outside - *Untyped forms* — \[⇒\] Abstract / All - *ContractOf* — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error -- *Holes* — \[⇒\] Hole-Some, \[⇒\] Hole-None, \[⇐\] Hole-None +- *Holes* — \[⇐\] Hole-Some, \[⇐\] Hole-None ### Subsumption @@ -457,11 +457,9 @@ $$`\frac{\mathit{fn} \text{ is not a procedure reference}}{\Gamma \vdash \mathsf ### Holes -$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T) \Rightarrow T} \quad \text{([⇒] Hole-Some)}` +$$`\frac{T_h <: T}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T_h) \Leftarrow T} \quad \text{([⇐] Hole-Some)}` -$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Hole-None)}` - -{docstring Strata.Laurel.Resolution.Synth.hole} +{docstring Strata.Laurel.Resolution.Check.holeSome} $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \;\;\mapsto\;\; \mathsf{Hole}\;d\;(\mathsf{some}\;T)} \quad \text{([⇐] Hole-None)}` @@ -502,11 +500,11 @@ just wasted work and a maintenance hazard. ### Shrink or remove `InferHoleTypes` `InferHoleTypes` walks the post-resolution AST a second time to annotate holes. Now that -\[⇐\] Hole-None writes the expected type during resolution for holes in check-mode -positions, the post-pass only needs to handle holes in synth-only positions (e.g. call -arguments resolved through `Synth.resolveStmtExpr` instead of `Check.resolveStmtExpr`). As more constructs -gain bespoke check rules, fewer holes will reach `InferHoleTypes`; eventually the pass -can be deleted entirely. +holes are check-only — \[⇐\] Hole-Some validates user annotations against context, and +\[⇐\] Hole-None records the expected type for untyped holes — every hole reachable in a +check-mode position already carries a type after resolution. `InferHoleTypes` is left +with whatever residue (in principle nothing, since synth-position holes are now flagged +as errors at resolution time and don't reach the inference pass). # Translation Pipeline From 778faf7d8977dbabb52945d700dc380e275756cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 21 May 2026 10:08:17 -0400 Subject: [PATCH 127/189] remove future structural changes entirely --- Strata/Languages/Laurel/Resolution.lean | 20 ------------ docs/verso/LaurelDoc.lean | 41 ------------------------- 2 files changed, 61 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 77ea08c5e6..51fdfaeaaa 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -104,26 +104,6 @@ Each of these nodes carries a `uniqueId : Option Nat` field (defaulting to `none`). Phase 1 fills in unique values; Phase 2 then builds a map from reference IDs to `ResolvedNode` values describing the definition each reference resolves to. - -## Future structural changes - -A few open structural questions worth recording — see the *Type checking* section of -`LaurelDoc.lean` for context. - -- *Rename to `NameTypeResolution`.* This pass resolves names and type-checks expressions in - one walk. The current name only mentions half of what it does. `NameTypeResolution.lean` - (or similar) would advertise both responsibilities. -- *Eliminate `LaurelTypes.computeExprType` by caching types.* Five later passes - (`LaurelToCoreTranslator`, `ModifiesClauses`, `LiftImperativeExpressions`, - `HeapParameterization`, `TypeHierarchy`) re-derive `StmtExpr` types after resolution. - Resolution already synthesizes those types and discards them. Caching per-node types on - `SemanticModel` (or directly on the AST) would let the later passes look them up instead - of recomputing. -- *Shrink or remove `InferHoleTypes`.* Holes are check-only now: `Hole-Some` validates - user annotations against the surrounding type, and `Hole-None` records the expected - type for untyped holes. Every hole reachable in a check-mode position already carries - a type after resolution; the inference pass is left handling whatever residue remains - and can plausibly be deleted entirely. -/ namespace Strata.Laurel diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index f668685da4..71c6595c6d 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -465,47 +465,6 @@ $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \;\;\mapst {docstring Strata.Laurel.Resolution.Check.holeNone} -## Future structural changes - -The current pipeline has resolution and several downstream passes that recompute or -re-derive type information that resolution already synthesized. A few cleanups worth -considering: - -### Rename `Resolution.lean` → `NameTypeResolution.lean` - -The pass resolves names *and* type-checks expressions in one walk; the file name only -advertises the first half. A rename (e.g. `NameTypeResolution.lean` or -`ResolutionAndTyping.lean`) would describe what the pass actually does. The -`SemanticModel` and `ResolvedNode` types could keep their names — they're about resolved -references, not typing. - -### Eliminate `LaurelTypes.computeExprType` by caching types - -`LaurelTypes.lean` exports `computeExprType : SemanticModel → StmtExprMd → HighTypeMd`, -which five later passes call (`LaurelToCoreTranslator`, `ModifiesClauses`, -`LiftImperativeExpressions`, `HeapParameterization`, `TypeHierarchy`) to ask "what's the -type of this expression?" after resolution. Resolution already synthesizes the same types -during its walk, then discards them. Two ways to remove the duplication: - -- *Cache types on the AST.* Add a `HighTypeMd` field to `StmtExpr` (or a parallel - `Std.HashMap Nat HighTypeMd` keyed by node-id, attached to `SemanticModel`), populate it - during resolution, and have later passes read it. `computeExprType` becomes a lookup, - not a re-traversal. -- *Make the cache opt-in.* Same idea, but only enable the type-cache for passes that need - it. Less invasive but partially defeats the point. - -The duplication isn't a correctness issue today (both paths produce consistent results), -just wasted work and a maintenance hazard. - -### Shrink or remove `InferHoleTypes` - -`InferHoleTypes` walks the post-resolution AST a second time to annotate holes. Now that -holes are check-only — \[⇐\] Hole-Some validates user annotations against context, and -\[⇐\] Hole-None records the expected type for untyped holes — every hole reachable in a -check-mode position already carries a type after resolution. `InferHoleTypes` is left -with whatever residue (in principle nothing, since synth-position holes are now flagged -as errors at resolution time and don't reach the inference pass). - # Translation Pipeline Laurel programs are verified by translating them to Strata Core and then invoking the Core From b649e94d26d42b9d6085205cd928a0c9605401c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 21 May 2026 13:27:58 -0400 Subject: [PATCH 128/189] update rules --- Strata/Languages/Laurel/Resolution.lean | 102 +++++++++++++++--------- 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 51fdfaeaaa..0e002ec048 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -770,26 +770,30 @@ def Check.while (exprMd : StmtExprMd) try simp_all omega -/-- `TVoid <: T ∴ Γ ⊢ Exit target ⇐ T` -/ -def Check.exit (target : String) (expected : HighTypeMd) +/-- `Γ ⊢ Exit target ⇐ T` + + `exit` is a control-flow jump out of a labeled block; it doesn't + deliver a value to the enclosing block, so no subsumption against + `expected` is required. -/ +def Check.exit (target : String) (_expected : HighTypeMd) (source : Option FileRange) : ResolveM StmtExprMd := do - checkSubtype source expected { val := .TVoid, source := source } pure { val := .Exit target, source := source } /-- Cases on whether the return value is `none` or `some e`, and on the arity of the enclosing procedure's declared outputs. - `TVoid <: T ∴ Γ ⊢ Return none ⇐ T` + `Γ ⊢ Return none ⇐ T` - `Γ_proc.outputs = [T_o], Γ ⊢ e ⇐ T_o, TVoid <: T ∴ Γ ⊢ Return (some e) ⇐ T` + `Γ_proc.outputs = [T_o], Γ ⊢ e ⇐ T_o ∴ Γ ⊢ Return (some e) ⇐ T` `Γ_proc.outputs = [] ∴ Γ ⊢ Return (some e) ↝ error: "void procedure cannot return a value"` `Γ_proc.outputs = [T_1; …; T_n] (n ≥ 2) ∴ Γ ⊢ Return (some e) ↝ error: "multi-output procedure cannot use 'return e'; assign to named outputs instead"` - The `Return` construct itself produces no value, so `expected` must - admit `TVoid`. The optional payload is matched against the enclosing - procedure's declared outputs (threaded through + `return` is a control-flow jump out of the procedure; it doesn't + deliver a value to the enclosing block, so no subsumption against the + surrounding `expected` is required. The optional payload is matched + against the enclosing procedure's declared outputs (threaded through `ResolveState.expectedReturnTypes`, set from `proc.outputs` by `resolveProcedure` / `resolveInstanceProcedure` for the duration of the body; `none` means "no enclosing procedure" — e.g. resolving a @@ -829,7 +833,10 @@ def Check.return (exprMd : StmtExprMd) "multi-output procedure cannot use 'return e'; assign to named outputs instead" modify fun s => { s with errors := s.errors.push diag } | _, none => pure () - checkSubtype source expected { val := .TVoid, source := source } + -- `return` is a control-flow jump; it doesn't deliver a value to the + -- enclosing block, so no TVoid-vs-expected subsumption is required. + -- The return value (if any) was already checked against the declared + -- output above via `expectedReturnTypes`. pure { val := .Return val', source := source } termination_by (exprMd, 0) decreasing_by @@ -842,24 +849,37 @@ def Check.return (exprMd : StmtExprMd) /-- Cases on whether the statement list is empty. - `Γ_0 = Γ, Γ_{i-1} ⊢ s_i ⇒ _ ⊣ Γ_i (1 ≤ i < n), Γ_{n-1} ⊢ s_n ⇐ T ∴ Γ ⊢ Block [s_1; …; s_n] label ⇐ T` + `Γ_0 = Γ, Γ_{i-1} ⊢ s_i ⇐ Unknown ⊣ Γ_i (1 ≤ i < n), Γ_{n-1} ⊢ s_n ⇐ T ∴ Γ ⊢ Block [s_1; …; s_n] label ⇐ T` `TVoid <: T ∴ Γ ⊢ Block [] label ⇐ T` Pushes `expected` into the *last* statement rather than comparing the block's synthesized type at the boundary. Errors fire at the offending subexpression, and `expected` keeps propagating through nested `Block` - / `IfThenElse` / `Hole` / `Quantifier`. Empty blocks reduce to a - subsumption check of `TVoid` against `expected` — the same check - `[⇐] Block-Empty` performs when `T` admits `TVoid`. -/ + / `IfThenElse` / `Hole` / `Quantifier`. + + Non-last statements are checked against `Unknown` so their type is + accepted regardless: this matches the Java/Python/JavaScript discipline + where `f(x);` is a valid statement even when `f` returns a value (the + value is discarded). Routing through check mode (rather than synth) + means that constructs without a synth rule — control-flow constructs + in particular — are still resolved correctly, with their own + bidirectional rules pushing `Unknown` into their subexpressions. The + trade-off is that a stray expression like `5;` is silently accepted; + flagging that belongs to a lint, not the type checker. + + Empty blocks reduce to a subsumption check of `TVoid` against + `expected` — the same check `[⇐] Block-Empty` performs when `T` + admits `TVoid`. -/ def Check.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do withScope do + let unknownTy : HighTypeMd := { val := .Unknown, source := source } let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do have : s ∈ stmts := List.dropLast_subset stmts hMem - let (s', _) ← Synth.resolveStmtExpr s; pure s') + Check.resolveStmtExpr s unknownTy) match _lastResult: stmts.getLast? with | none => checkSubtype source expected { val := .TVoid, source := source } @@ -1515,21 +1535,38 @@ def resolveParameter (param : Parameter) : ResolveM Parameter := do let name' ← defineNameCheckDup param.name (.parameter ⟨param.name, ty'⟩) return ⟨name', ty'⟩ -/-- Resolve a procedure body. Returns the resolved body and its type. -/ -def resolveBody (body : Body) : ResolveM (Body × HighTypeMd) := do +/-- Resolve a procedure body, checking its impl block (if any) against + `expected`. The expected type comes from the procedure's declared + output: a single output `T` for functional procedures, `TVoid` + otherwise. Bodies without an impl block (`Abstract`, `External`) ignore + `expected`. -/ +def resolveBody (body : Body) (expected : HighTypeMd) : ResolveM Body := do match body with | .Transparent b => - let (b', ty) ← Synth.resolveStmtExpr b - return (.Transparent b', ty) + let b' ← Check.resolveStmtExpr b expected + return .Transparent b' | .Opaque posts impl mods => let posts' ← posts.mapM (·.mapM resolveStmtExpr) - let impl' ← impl.mapM resolveStmtExpr + let impl' ← impl.mapM (Check.resolveStmtExpr · expected) let mods' ← mods.mapM resolveStmtExpr - return (.Opaque posts' impl' mods', { val := .TVoid, source := none }) + return .Opaque posts' impl' mods' | .Abstract posts => let posts' ← posts.mapM (·.mapM resolveStmtExpr) - return (.Abstract posts', { val := .TVoid, source := none }) - | .External => return (.External, { val := .TVoid, source := none }) + return .Abstract posts' + | .External => return .External + +/-- Compute the expected body type for a procedure. Functional + procedures with a single output `T` expect `T` — the body's last + statement is the result and must produce a `T`. Non-functional + procedures expect `Unknown`: their body is statement-typed and the + last statement (if any) is discarded — outputs are observed via + `return e` or named-output assignment, validated independently + inside `Check.return` via `expectedReturnTypes`. -/ +private def procedureBodyType (isFunctional : Bool) (outputs : List Parameter) + (source : Option FileRange) : HighTypeMd := + match isFunctional, outputs with + | true, [singleOutput] => singleOutput.type + | _, _ => { val := .Unknown, source := source } /-- Resolve a procedure: resolve its name, then resolve params, contracts, and body in a new scope. -/ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do @@ -1541,20 +1578,13 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do let dec' ← proc.decreases.mapM resolveStmtExpr let savedReturns := (← get).expectedReturnTypes modify fun s => { s with expectedReturnTypes := some (outputs'.map (·.type)) } - let (body', bodyTy) ← resolveBody proc.body + let bodyExpected := procedureBodyType proc.isFunctional outputs' proc.name.source + let body' ← resolveBody proc.body bodyExpected modify fun s => { s with expectedReturnTypes := savedReturns } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" modify fun s => { s with errors := s.errors.push diag } - -- Check body type matches declared output type for functional procedures with transparent bodies - if proc.isFunctional && body'.isTransparent then - match proc.outputs with - | [singleOutput] => - -- Only check when body produces a value (not void from return/while/assign) - if bodyTy.val != HighType.TVoid then - checkSubtype proc.name.source singleOutput.type bodyTy - | _ => pure () let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr return { name := procName', inputs := inputs', outputs := outputs', isFunctional := proc.isFunctional, @@ -1585,19 +1615,13 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv let dec' ← proc.decreases.mapM resolveStmtExpr let savedReturns := (← get).expectedReturnTypes modify fun s => { s with expectedReturnTypes := some (outputs'.map (·.type)) } - let (body', bodyTy) ← resolveBody proc.body + let bodyExpected := procedureBodyType proc.isFunctional outputs' proc.name.source + let body' ← resolveBody proc.body bodyExpected modify fun s => { s with expectedReturnTypes := savedReturns } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" modify fun s => { s with errors := s.errors.push diag } - -- Check body type matches declared output type for functional procedures with transparent bodies - if proc.isFunctional && body'.isTransparent then - match proc.outputs with - | [singleOutput] => - if bodyTy.val != HighType.TVoid then - checkSubtype proc.name.source singleOutput.type bodyTy - | _ => pure () let invokeOn' ← proc.invokeOn.mapM resolveStmtExpr modify fun s => { s with instanceTypeName := savedInstType } return { name := procName', inputs := inputs', outputs := outputs', From 26c0c82ebcacc7ae78d965ab5fe906aac964ff81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 22 May 2026 09:25:08 -0400 Subject: [PATCH 129/189] fix error reporting --- .../Laurel/Examples/Fundamentals/T22_ArityMismatch.lean | 2 +- StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean index dea2d510fb..bab9486ade 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean @@ -39,7 +39,7 @@ procedure mismatch() { var x: int; assign x := twoReturns() -//^^^^^^^^^^^^^^^^^^^^^^^^ error: expected 'int', got '(int, int)' +// ^^^^^^^^^^^^ error: expected 'int', got '(int, int)' }; " diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index b78f3b22df..87d792284d 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -108,7 +108,7 @@ function cmp(x: string, y: int): bool { def assignTypeMismatch := r" procedure foo() opaque { var x: int := true -//^^^^^^^^^^^^^^^^^^ error: expected 'int', got 'bool' +// ^^^^ error: expected 'int', got 'bool' }; " @@ -119,8 +119,8 @@ procedure foo() opaque { def returnTypeMismatch := r" function foo(): int { -// ^^^ error: expected 'int', got 'bool' true +//^^^^ error: expected 'int', got 'bool' }; " @@ -169,7 +169,7 @@ def assignTargetCountMismatch := r" procedure multi() returns (a: int, b: int) opaque; procedure test() opaque { var x: int := multi() -//^^^^^^^^^^^^^^^^^^^^^ error: expected 'int', got '(int, int)' +// ^^^^^^^ error: expected 'int', got '(int, int)' }; " @@ -187,7 +187,7 @@ composite Dog { } composite Cat { } procedure test() opaque { var x: Dog := new Cat -//^^^^^^^^^^^^^^^^^^^^^ error: expected 'Dog', got 'Cat' +// ^^^^^^^ error: expected 'Dog', got 'Cat' }; " From 1e8050dfdfbac7cf293805c7cd88e4443d1fb61b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 22 May 2026 09:25:28 -0400 Subject: [PATCH 130/189] have operators check as much as possible --- Strata/Languages/Laurel/Resolution.lean | 156 +++++++++++++++++++----- docs/verso/LaurelDoc.lean | 20 ++- 2 files changed, 141 insertions(+), 35 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 0e002ec048..8f357dc2e7 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -525,7 +525,7 @@ inside the mutual block below. Helpers are grouped by section to mirror the - Verification statements — `Check.assert`, `Check.assume` - Assignment — `Check.assign` - Calls — `Synth.staticCall`, `Synth.instanceCall` -- Primitive operations — `Synth.primitiveOp` +- Primitive operations — `Synth.primitiveOp`, `Check.primitiveOp` - Object forms — `Synth.new`, `Synth.asType`, `Synth.isType`, `Synth.refEq`, `Synth.pureFieldUpdate` - Verification expressions — `Synth.quantifier`, `Synth.assigned`, @@ -626,6 +626,9 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy - control flow — `Block`, `IfThenElse`, `While`, `Exit`, `Return` - verification — `Assert`, `Assume`, `Old`, `ProveBy` - holes — `Hole` (typed and untyped) + - primitive operations — `PrimitiveOp` (arithmetic and boolean + families only; comparison/equality/string-concat fall through to + the synth-then-subsume wildcard) Everything else falls back to subsumption — synthesize, then verify `isConsistentSubtype actual expected`. @@ -663,6 +666,34 @@ def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : Resolv Check.old exprMd val expected source (by rw [h_node]) | .ProveBy val proof => Check.proveBy exprMd val proof expected source (by rw [h_node]) + | .PrimitiveOp .Neg args => + Check.primitiveOp exprMd .Neg args expected source (by rw [h_node]) + | .PrimitiveOp .Add args => + Check.primitiveOp exprMd .Add args expected source (by rw [h_node]) + | .PrimitiveOp .Sub args => + Check.primitiveOp exprMd .Sub args expected source (by rw [h_node]) + | .PrimitiveOp .Mul args => + Check.primitiveOp exprMd .Mul args expected source (by rw [h_node]) + | .PrimitiveOp .Div args => + Check.primitiveOp exprMd .Div args expected source (by rw [h_node]) + | .PrimitiveOp .Mod args => + Check.primitiveOp exprMd .Mod args expected source (by rw [h_node]) + | .PrimitiveOp .DivT args => + Check.primitiveOp exprMd .DivT args expected source (by rw [h_node]) + | .PrimitiveOp .ModT args => + Check.primitiveOp exprMd .ModT args expected source (by rw [h_node]) + | .PrimitiveOp .And args => + Check.primitiveOp exprMd .And args expected source (by rw [h_node]) + | .PrimitiveOp .Or args => + Check.primitiveOp exprMd .Or args expected source (by rw [h_node]) + | .PrimitiveOp .AndThen args => + Check.primitiveOp exprMd .AndThen args expected source (by rw [h_node]) + | .PrimitiveOp .OrElse args => + Check.primitiveOp exprMd .OrElse args expected source (by rw [h_node]) + | .PrimitiveOp .Not args => + Check.primitiveOp exprMd .Not args expected source (by rw [h_node]) + | .PrimitiveOp .Implies args => + Check.primitiveOp exprMd .Implies args expected source (by rw [h_node]) | _ => -- Subsumption fallback: synth then check `actual <: expected`. let (e', actual) ← Synth.resolveStmtExpr exprMd @@ -849,24 +880,19 @@ def Check.return (exprMd : StmtExprMd) /-- Cases on whether the statement list is empty. - `Γ_0 = Γ, Γ_{i-1} ⊢ s_i ⇐ Unknown ⊣ Γ_i (1 ≤ i < n), Γ_{n-1} ⊢ s_n ⇐ T ∴ Γ ⊢ Block [s_1; …; s_n] label ⇐ T` - `TVoid <: T ∴ Γ ⊢ Block [] label ⇐ T` - Pushes `expected` into the *last* statement rather than comparing the - block's synthesized type at the boundary. Errors fire at the offending - subexpression, and `expected` keeps propagating through nested `Block` - / `IfThenElse` / `Hole` / `Quantifier`. - - Non-last statements are checked against `Unknown` so their type is - accepted regardless: this matches the Java/Python/JavaScript discipline - where `f(x);` is a valid statement even when `f` returns a value (the - value is discarded). Routing through check mode (rather than synth) - means that constructs without a synth rule — control-flow constructs - in particular — are still resolved correctly, with their own - bidirectional rules pushing `Unknown` into their subexpressions. The - trade-off is that a stray expression like `5;` is silently accepted; - flagging that belongs to a lint, not the type checker. + `Γ_0 = Γ, Γ_{i-1} ⊢ s_i ⇐ Unknown ⊣ Γ_i (1 ≤ i < n), Γ_{n-1} ⊢ s_n ⇐ T ∴ Γ ⊢ Block [s_1; …; s_n] label ⇐ T` + + The last statement carries the block's value, so it is checked + against the surrounding `expected`. Non-last statements are checked + against `Unknown`, which accepts any type via gradual subsumption — + matching the Java/Python/JavaScript discipline where `f(x);` is a + valid statement even when `f` returns a value (the value is + discarded). Routing through check mode (rather than synth) means + that constructs without a synth rule are still resolved correctly, + with their bidirectional rules pushing `Unknown` into their + subexpressions. Empty blocks reduce to a subsumption check of `TVoid` against `expected` — the same check `[⇐] Block-Empty` performs when `T` @@ -875,14 +901,15 @@ def Check.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do + let voidTy : HighTypeMd := { val := .TVoid, source := source } + let unknownTy : HighTypeMd := { val := .Unknown, source := source } withScope do - let unknownTy : HighTypeMd := { val := .Unknown, source := source } let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do have : s ∈ stmts := List.dropLast_subset stmts hMem Check.resolveStmtExpr s unknownTy) match _lastResult: stmts.getLast? with | none => - checkSubtype source expected { val := .TVoid, source := source } + checkSubtype source expected voidTy pure { val := .Block init' label, source := source } | some last => have := List.mem_of_getLast? _lastResult @@ -973,20 +1000,20 @@ def Check.assume (exprMd : StmtExprMd) -- ### Assignment -/-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy, ExpectedTy <: T ∴ Γ ⊢ Assign targets e ⇐ T` (T ≠ TVoid) - - `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy ∴ Γ ⊢ Assign targets e ⇐ TVoid` +/-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy ∴ Γ ⊢ Assign targets e ⇐ TVoid` where `ExpectedTy = T_1` if `|targets| = 1`, else `MultiValuedExpr [T_1; …; T_n]`. - The target tuple type is pushed into the RHS via + Assignment is strictly statement-position: `expected` must be + `TVoid`. The target tuple type is pushed into the RHS via `Check.resolveStmtExpr`, so bidirectional rules in the RHS receive - the assignment's type. When `T ≠ TVoid` (expression position) the - outer subsumption `ExpectedTy <: T` is enforced. When `T = TVoid` - (statement position) the subsumption is skipped: the assignment's - value is discarded as a statement, so there is nothing to compare - against `expected`. -/ + the assignment's type. Expression-position uses (e.g. + `x ++ (y := s)`) are rejected — the assignment's "value" is its + target type only by convention, and accepting it as an expression + invites bugs like the impure-side-effect-inside-expression case + where `(if c then { b := false } else (b := true)) || b` + typechecked because `bool <: bool` trivially holds. -/ def Check.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) @@ -1015,8 +1042,7 @@ def Check.assign (exprMd : StmtExprMd) | [single] => single | _ => { val := .MultiValuedExpr targetTys, source := source } let value' ← Check.resolveStmtExpr value expectedTy - unless expected.val matches .TVoid do - checkSubtype source expected expectedTy + checkSubtype source expected { val := .TVoid, source := source } pure { val := .Assign targets' value', source := source } termination_by (exprMd, 0) decreasing_by @@ -1096,8 +1122,8 @@ def Synth.instanceCall (exprMd : StmtExprMd) -- ### Primitive operations /-- Cases on the operator family. All operands are synthesized first; - then a per-family verification fires using `checkSubtype` (a post-synth - subtype test, not bidirectional check resolution). + then a per-family verification fires using `checkSubtype` (a + post-synth subtype test, not bidirectional check resolution). `Γ ⊢ args_i ⇒ U_i, U_i <: TBool, op ∈ {And, Or, AndThen, OrElse, Not, Implies} ∴ Γ ⊢ PrimitiveOp op args ⇒ TBool` @@ -1120,7 +1146,16 @@ def Synth.instanceCall (exprMd : StmtExprMd) first argument" handles `int + int → int`, `real + real → real`, etc. without unification — known relaxation: `int + real` passes since each operand individually passes `Numeric`; a proper fix needs - numeric promotion or unification), `TString` for concatenation. -/ + numeric promotion or unification), `TString` for concatenation. + + The arithmetic and boolean families also have a check-mode rule + (`Check.primitiveOp`) which is preferred when an `expected` type is + available: it pushes the operand type into each operand via + `Check.resolveStmtExpr`, surfacing operand-shaped errors at their + natural location instead of via the gradual subsumption fallthrough. + `Synth.primitiveOp` remains the entry point for synth-position + primitive ops (e.g. an unannotated `var x := a + b` or use as an + operand of a non-Check.primitiveOp construct). -/ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) (op : Operation) (args : List StmtExprMd) (source : Option FileRange) (h_expr : expr = .PrimitiveOp op args) @@ -1168,6 +1203,61 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) have := List.sizeOf_lt_of_mem ‹_ ∈ args› omega +/-- Cases on the operator family. + + `Numeric T, Γ ⊢ args_i ⇐ T, op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇐ T` + + `TBool <: T, Γ ⊢ args_i ⇐ TBool, op ∈ {And, Or, AndThen, OrElse, Not, Implies} ∴ Γ ⊢ PrimitiveOp op args ⇐ T` + + Both families run in check mode: the surrounding `expected` must + admit the family's natural result type (numeric for arithmetic, + `TBool` for boolean), and that operand type is pushed into every + operand via `Check.resolveStmtExpr`. Pushing `expected` (or `TBool`) + into operands replaces the synth-then-`checkSubtype` discipline of + `Synth.primitiveOp`, with two consequences: (a) control-flow + operands like `(if c then 1 else 2) + 3` or `(if c then a else b) && z` + are resolved correctly via `Check.ifThenElse` instead of hitting the + synth wildcard, and (b) `int + real` errors at the second operand + instead of being silently accepted under gradual mixing — the rule + now requires every operand to subtype the pushed type. + + The remaining operator families (comparison, equality, string + concatenation) stay in `Synth.primitiveOp`: their result types are + fixed (`TBool` / `TString`) and their operand constraints can't be + expressed as a single pushable type (Numeric is a predicate; + equality is symmetric). The dispatcher routes those to the wildcard + `_ =>` arm of `Check.resolveStmtExpr`. -/ +def Check.primitiveOp (exprMd : StmtExprMd) + (op : Operation) (args : List StmtExprMd) + (expected : HighTypeMd) (source : Option FileRange) + (h : exprMd.val = .PrimitiveOp op args) : + ResolveM StmtExprMd := do + let operandTy : HighTypeMd ← match op with + | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => + let ctx := (← get).typeContext + unless isNumeric ctx expected do + typeMismatch source none "expected a numeric type" expected + pure expected + | .And | .Or | .AndThen | .OrElse | .Not | .Implies => + let boolTy : HighTypeMd := { val := .TBool, source := source } + checkSubtype source expected boolTy + pure boolTy + | _ => + -- Unreachable: dispatcher routes only the arithmetic and boolean + -- families to this rule. `Unknown` keeps the function total in + -- case the dispatcher's pattern list ever drifts. + pure { val := .Unknown, source := source } + let args' ← args.attach.mapM (fun a => have := a.property; do + Check.resolveStmtExpr a.val operandTy) + pure { val := .PrimitiveOp op args', source := source } + termination_by (exprMd, 0) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + have := List.sizeOf_lt_of_mem ‹_ ∈ args› + omega + -- ### Object forms /-- Cases on whether `ref` resolves to a composite/datatype. diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 71c6595c6d..615b589fe7 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -220,7 +220,7 @@ direction explicit. - *Assignment* — \[⇐\] Assign - *Calls* — \[⇒\] Static-Call, \[⇒\] Static-Call-Multi, \[⇒\] Instance-Call - *Primitive operations* — \[⇒\] Op-Bool, \[⇒\] Op-Cmp, \[⇒\] Op-Eq, \[⇒\] Op-Arith, - \[⇒\] Op-Concat + \[⇒\] Op-Concat; \[⇐\] Op-Arith, \[⇐\] Op-Bool - *Object forms* — \[⇒\] New-Ok, \[⇒\] New-Fallback; \[⇒\] AsType; \[⇒\] IsType; \[⇒\] RefEq; \[⇒\] PureFieldUpdate - *Verification expressions* — \[⇒\] Quantifier, \[⇒\] Assigned, \[⇐\] Old, @@ -337,7 +337,7 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \mathsf{TVo ### Assignment -$$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Leftarrow \mathit{ExpectedTy} \quad \mathit{ExpectedTy} <: T}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Leftarrow T} \quad \text{([⇐] Assign)}` +$$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Leftarrow \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Leftarrow \mathsf{TVoid}} \quad \text{([⇐] Assign)}` where `ExpectedTy = T_1` if `|targets| = 1` and `MultiValuedExpr [T_1; …; T_n]` otherwise. The target's declared type `T_i` comes from the variable's scope entry (for @@ -346,6 +346,11 @@ or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. RHS receives `ExpectedTy` via `Check.resolveStmtExpr`, so bidirectional rules in the RHS propagate the assignment's type into nested constructs. +Assignment is strictly statement-position: `expected` must be `TVoid`. Expression-position +uses (e.g. `x ++ (y := s)`) are rejected — accepting an assignment as an expression with +its target's type as "value" silently masks impure-side-effect-inside-expression bugs +where the target type happens to coincide with the surrounding expectation. + {docstring Strata.Laurel.Resolution.Check.assign} ### Calls @@ -378,6 +383,17 @@ $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TString} \quad \mathit {docstring Strata.Laurel.Resolution.Synth.primitiveOp} +The arithmetic and boolean families also have a check-mode rule, used when the +surrounding context provides an `expected` type. The rule pushes the operand type +into each operand via `Check.resolveStmtExpr`, replacing the synth-then-`checkSubtype` +discipline with bidirectional check. + +$$`\frac{\mathit{Numeric}\;T \quad \Gamma \vdash \mathit{args}_i \Leftarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Leftarrow T} \quad \text{([⇐] Op-Arith)}` + +$$`\frac{\mathsf{TBool} <: T \quad \Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TBool} \quad \mathit{op} \in \{\mathsf{And}, \mathsf{Or}, \mathsf{AndThen}, \mathsf{OrElse}, \mathsf{Not}, \mathsf{Implies}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Leftarrow T} \quad \text{([⇐] Op-Bool)}` + +{docstring Strata.Laurel.Resolution.Check.primitiveOp} + ### Object forms $$`\frac{\Gamma(\mathit{ref}) \text{ is a composite or datatype } T}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] New-Ok)}` From 8be2b90f0eee98089a2699837b75e016c5140150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 26 May 2026 14:22:09 -0400 Subject: [PATCH 131/189] improve typing rules index --- docs/verso/LaurelDoc.lean | 104 +++++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 17 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 615b589fe7..9a9ae02931 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -209,28 +209,34 @@ direction explicit. ### Index -- *Subsumption* — \[⇐\] Sub -- *Literals* — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal -- *Variables* — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇐\] Var-Declare -- *Control flow* — \[⇐\] If, \[⇐\] If-NoElse; - \[⇐\] Block, \[⇐\] Block-Empty; \[⇐\] Exit; - \[⇐\] Return-None, \[⇐\] Return-Some, \[⇐\] Return-Void-Error, +- {ref "rules-subsumption"}[*Subsumption*] — \[⇐\] Sub +- {ref "rules-literals"}[*Literals*] — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal +- {ref "rules-variables"}[*Variables*] — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇐\] Var-Declare +- {ref "rules-control-flow"}[*Control flow*] — \[⇐\] If, \[⇐\] If-NoElse; + \[⇐\] Block, \[⇒\] Skip, \[⇐\] Discard, \[⇐\] Discard-Call; \[⇐\] Exit; + \[⇐\] Return-None-Void, \[⇐\] Return-None-Single, \[⇐\] Return-None-Multi, + \[⇐\] Return-Some, \[⇐\] Return-Void-Error, \[⇐\] Return-Multi-Error; \[⇐\] While -- *Verification statements* — \[⇐\] Assert, \[⇐\] Assume -- *Assignment* — \[⇐\] Assign -- *Calls* — \[⇒\] Static-Call, \[⇒\] Static-Call-Multi, \[⇒\] Instance-Call -- *Primitive operations* — \[⇒\] Op-Bool, \[⇒\] Op-Cmp, \[⇒\] Op-Eq, \[⇒\] Op-Arith, - \[⇒\] Op-Concat; \[⇐\] Op-Arith, \[⇐\] Op-Bool -- *Object forms* — \[⇒\] New-Ok, \[⇒\] New-Fallback; \[⇒\] AsType; \[⇒\] IsType; +- {ref "rules-verification-statements"}[*Verification statements*] — \[⇐\] Assert, \[⇐\] Assume +- {ref "rules-assignment"}[*Assignment*] — \[⇐\] Assign +- {ref "rules-calls"}[*Calls*] — \[⇒\] Static-Call, \[⇒\] Static-Call-Multi, + \[⇒\] Instance-Call, \[⇒\] Instance-Call-Multi +- {ref "rules-primitive-operations"}[*Primitive operations*] — \[⇒\] Op-Bool, \[⇒\] Op-Cmp, \[⇒\] Op-Eq, + \[⇒\] Op-Arith, \[⇒\] Op-Concat; \[⇐\] Op-Arith, \[⇐\] Op-Bool +- {ref "rules-object-forms"}[*Object forms*] — \[⇒\] New-Ok, \[⇒\] New-Fallback; \[⇒\] AsType; \[⇒\] IsType; \[⇒\] RefEq; \[⇒\] PureFieldUpdate -- *Verification expressions* — \[⇒\] Quantifier, \[⇒\] Assigned, \[⇐\] Old, +- {ref "rules-verification-expressions"}[*Verification expressions*] — \[⇒\] Quantifier, \[⇒\] Assigned, \[⇐\] Old, \[⇒\] Fresh, \[⇐\] ProveBy -- *Self reference* — \[⇒\] This-Inside, \[⇒\] This-Outside -- *Untyped forms* — \[⇒\] Abstract / All -- *ContractOf* — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error -- *Holes* — \[⇐\] Hole-Some, \[⇐\] Hole-None +- {ref "rules-self-reference"}[*Self reference*] — \[⇒\] This-Inside, \[⇒\] This-Outside +- {ref "rules-untyped-forms"}[*Untyped forms*] — \[⇒\] Abstract / All +- {ref "rules-contract-of"}[*ContractOf*] — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error +- {ref "rules-holes"}[*Holes*] — \[⇐\] Hole-Some, \[⇐\] Hole-None +- {ref "rules-procedure"}[*Procedure*] — Procedure ### Subsumption +%%% +tag := "rules-subsumption" +%%% $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` @@ -238,6 +244,9 @@ Fallback in {name Strata.Laurel.Resolution.Check.resolveStmtExpr}`Check.resolveS rule applies. ### Literals +%%% +tag := "rules-literals" +%%% $$`\frac{}{\Gamma \vdash \mathsf{LiteralInt}\;n \Rightarrow \mathsf{TInt}} \quad \text{([⇒] Lit-Int)}` @@ -256,6 +265,9 @@ $$`\frac{}{\Gamma \vdash \mathsf{LiteralDecimal}\;d \Rightarrow \mathsf{TReal}} {docstring Strata.Laurel.Resolution.Synth.litDecimal} ### Variables +%%% +tag := "rules-variables" +%%% $$`\frac{\Gamma(x) = T}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Local}\;x) \Rightarrow T} \quad \text{([⇒] Var-Local)}` @@ -270,6 +282,9 @@ $$`\frac{x \notin \mathrm{dom}(\Gamma) \quad \mathsf{TVoid} <: T}{\Gamma \vdash {docstring Strata.Laurel.Resolution.Check.varDeclare} ### Control flow +%%% +tag := "rules-control-flow" +%%% $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Leftarrow T \quad \Gamma \vdash \mathit{elseBr} \Leftarrow T}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Leftarrow T} \quad \text{([⇐] If)}` @@ -326,6 +341,9 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda {docstring Strata.Laurel.Resolution.Check.while} ### Verification statements +%%% +tag := "rules-verification-statements" +%%% $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Leftarrow T} \quad \text{([⇐] Assert)}` @@ -336,6 +354,9 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \mathsf{TVo {docstring Strata.Laurel.Resolution.Check.assume} ### Assignment +%%% +tag := "rules-assignment" +%%% $$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Leftarrow \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Leftarrow \mathsf{TVoid}} \quad \text{([⇐] Assign)}` @@ -354,6 +375,9 @@ where the target type happens to coincide with the surrounding expectation. {docstring Strata.Laurel.Resolution.Check.assign} ### Calls +%%% +tag := "rules-calls" +%%% $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Static-Call)}` @@ -366,6 +390,9 @@ $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{calle {docstring Strata.Laurel.Resolution.Synth.instanceCall} ### Primitive operations +%%% +tag := "rules-primitive-operations" +%%% `Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, {name Strata.Laurel.HighType.TReal}`TReal`, @@ -395,6 +422,9 @@ $$`\frac{\mathsf{TBool} <: T \quad \Gamma \vdash \mathit{args}_i \Leftarrow \mat {docstring Strata.Laurel.Resolution.Check.primitiveOp} ### Object forms +%%% +tag := "rules-object-forms" +%%% $$`\frac{\Gamma(\mathit{ref}) \text{ is a composite or datatype } T}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] New-Ok)}` @@ -424,6 +454,9 @@ $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow T_t \quad \Gamma(f) = T_f \qu {docstring Strata.Laurel.Resolution.Synth.pureFieldUpdate} ### Verification expressions +%%% +tag := "rules-verification-expressions" +%%% $$`\frac{\Gamma, x : T \vdash \mathit{body} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Quantifier}\;\mathit{mode}\;\langle x, T\rangle\;\mathit{trig}\;\mathit{body} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Quantifier)}` @@ -446,6 +479,9 @@ $$`\frac{\Gamma \vdash v \Leftarrow T \quad \Gamma \vdash \mathit{proof} \Righta {docstring Strata.Laurel.Resolution.Check.proveBy} ### Self reference +%%% +tag := "rules-self-reference" +%%% $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{some}\;T}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] This-Inside)}` @@ -454,6 +490,9 @@ $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{none}}{\Gamma \vdash \mathsf {docstring Strata.Laurel.Resolution.Synth.this} ### Untyped forms +%%% +tag := "rules-untyped-forms" +%%% $$`\frac{}{\Gamma \vdash \mathsf{Abstract}\;/\;\mathsf{All}\;\ldots \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Abstract / All)}` @@ -462,6 +501,9 @@ $$`\frac{}{\Gamma \vdash \mathsf{Abstract}\;/\;\mathsf{All}\;\ldots \Rightarrow {docstring Strata.Laurel.Resolution.Synth.all} ### ContractOf +%%% +tag := "rules-contract-of" +%%% $$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Precondition}\;\mathit{fn} \Rightarrow \mathsf{TBool} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{PostCondition}\;\mathit{fn} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] ContractOf-Bool)}` @@ -472,6 +514,9 @@ $$`\frac{\mathit{fn} \text{ is not a procedure reference}}{\Gamma \vdash \mathsf {docstring Strata.Laurel.Resolution.Synth.contractOf} ### Holes +%%% +tag := "rules-holes" +%%% $$`\frac{T_h <: T}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T_h) \Leftarrow T} \quad \text{([⇐] Hole-Some)}` @@ -481,6 +526,31 @@ $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \;\;\mapst {docstring Strata.Laurel.Resolution.Check.holeNone} +### Procedure +%%% +tag := "rules-procedure" +%%% + +A procedure body is checked against an expected type $`A` and is +resolved under a scope that includes the procedure's input and output +parameters. The Return rules above refer to the same output list +$`\overline{T_o}` that the procedure binds here. + +$$`\frac{\overline{T_o} = \mathit{proc}.\mathit{outputs}.\mathit{types} \quad A = \mathsf{bodyType}(\mathit{proc}) \quad \Gamma_\mathit{global},\,\mathit{params}(\mathit{proc}) \vdash \mathit{proc}.\mathit{body} \Leftarrow A}{\Gamma_\mathit{global} \vdash \mathsf{Procedure}\;\mathit{proc}} \quad \text{(Procedure)}` + +The body's value type $`A` is computed by `procedureBodyType`: a +single-output functional procedure expects $`A = T` (its body's last +statement is the result), while every other procedure expects +$`A = \mathsf{Unknown}` (its body is statement-typed and the last +statement's value is discarded; outputs are observed via `return e`, +matched against $`\overline{T_o}` by +{name Strata.Laurel.Resolution.Check.return}`Check.return`, or via +named-output assignment). + +{docstring Strata.Laurel.resolveProcedure} + +{docstring Strata.Laurel.resolveInstanceProcedure} + # Translation Pipeline Laurel programs are verified by translating them to Strata Core and then invoking the Core From 032c46f48a2d5eb27dfd609e2271cec6119e9899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 27 May 2026 11:11:56 -0400 Subject: [PATCH 132/189] update block rules + is/asType --- Strata/Languages/Laurel/Resolution.lean | 584 +++++++++++++----- .../Examples/Objects/T9_IfBranchJoin.lean | 14 +- docs/verso/LaurelDoc.lean | 225 +++++-- 3 files changed, 596 insertions(+), 227 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 8f357dc2e7..36df29b3d1 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -514,16 +514,41 @@ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List Hig /-! ## Typing rules -Each typing rule from the Laurel manual is implemented as its own helper -inside the mutual block below. Helpers are grouped by section to mirror the -*Typing rules* index in `LaurelDoc.lean`: +The judgment is bidirectional: + +``` +Γ ⊢ e ⇒ A (Synth.resolveStmtExpr) +Γ ⊢ e ⇐ A (Check.resolveStmtExpr) +``` + +- `Γ` — lexical scope (variables, fields, labels). +- `A` — *value type* of the term. + +The `Return` rules additionally depend on the enclosing procedure's +declared output-type list, written `T_o-bar` in the rule statements. +That list is bound on entry to a procedure body (by +`resolveProcedure` / `resolveInstanceProcedure`, stored on +`ResolveState.answerType`) and consulted only by `Check.return`; +every other rule is independent of it. Statement-typed forms +(`Var-Declare`, `Assert`, `Assume`, `While`, `Exit`, `Return`) +check at any `A`: their conclusions are polymorphic in `A` because +they contribute nothing to the surrounding value flow. `Assign` +synthesizes its target tuple type but its check rule skips the +\[⇐\] Sub boundary check when expected is `TVoid`, so it also +behaves statement-shaped in discard position. `Block` routes the +surrounding expected type to the last statement, not to non-last +statements. + +Each typing rule is implemented as its own helper inside the mutual +block below. Helpers are grouped by section to mirror the *Typing +rules* index in `LaurelDoc.lean`: - Literals — `Synth.litInt`, `Synth.litBool`, `Synth.litString`, `Synth.litDecimal` - Variables — `Synth.varLocal`, `Synth.varField`, `Check.varDeclare` - Control flow — `Check.while`, `Check.exit`, `Check.return`, `Check.block`, `Check.ifThenElse` - Verification statements — `Check.assert`, `Check.assume` -- Assignment — `Check.assign` +- Assignment — `Synth.assign`, `Check.assign` - Calls — `Synth.staticCall`, `Synth.instanceCall` - Primitive operations — `Synth.primitiveOp`, `Check.primitiveOp` - Object forms — `Synth.new`, `Synth.asType`, `Synth.isType`, `Synth.refEq`, @@ -553,8 +578,8 @@ mutual /-- Synth-mode resolution: resolve `e` and synthesize its `HighType`, written `Γ ⊢ e ⇒ T`. Each constructor with a synthesis rule delegates to its rule's helper; constructors without one (statement-shaped - constructs like `IfThenElse`, `Block`, `While`, `Return`, `Assign`, - …) hit a wildcard arm that emits a `typeMismatch` diagnostic and + constructs like `IfThenElse`, `Block`, `While`, `Return`, …) hit + a wildcard arm that emits a `typeMismatch` diagnostic and returns `Unknown` to suppress cascading errors. Synthesis returns a type inferred from the expression itself; @@ -575,6 +600,8 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy | .Var (.Local ref) => Synth.varLocal ref source | .Var (.Field target fieldName) => Synth.varField exprMd target fieldName source (by rw [h_node]) + | .Assign targets value => + Synth.assign exprMd targets value source (by rw [h_node]) | .PureFieldUpdate target fieldName newVal => Synth.pureFieldUpdate exprMd target fieldName newVal (by rw [h_node]) | .StaticCall callee args => @@ -601,6 +628,7 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy Synth.contractOf exprMd ty fn source (by rw [h_node]) | .Abstract => pure (Synth.abstract source) | .All => pure (Synth.all source) + | .Block [] label => pure (.Block [] label, Synth.emptyBlock source) | _ => let unknown : HighTypeMd := { val := .Unknown, source := source } typeMismatch source (some expr) @@ -644,24 +672,27 @@ def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : Resolv match h_node: exprMd with | AstNode.mk expr source => match h_expr: expr with - | .Block stmts label => - Check.block exprMd stmts label expected source (by rw [h_node]) + -- Empty block has a fixed type `TVoid` (Synth.emptyBlock); the wildcard + -- arm below routes it through synth-then-Sub. Non-empty blocks have no + -- synth rule and are typed structurally by Check.block. + | .Block (head :: tail) label => + Check.block exprMd (head :: tail) label expected source (by rw [h_node]) | .IfThenElse cond thenBr elseBr => Check.ifThenElse exprMd cond thenBr elseBr expected source (by rw [h_node]) | .Assign targets value => Check.assign exprMd targets value expected source (by rw [h_node]) | .Hole det none => pure (Check.holeNone det expected source) | .Hole det (some ty) => Check.holeSome det ty expected source - | .Var (.Declare param) => Check.varDeclare param expected source + | .Var (.Declare param) => Check.varDeclare param source | .While cond invs dec body => - Check.while exprMd cond invs dec body expected source (by rw [h_node]) - | .Exit target => Check.exit target expected source + Check.while exprMd cond invs dec body source (by rw [h_node]) + | .Exit target => Check.exit target source | .Return val => - Check.return exprMd val expected source (by rw [h_node]) + Check.return exprMd val source (by rw [h_node]) | .Assert ⟨condExpr, summary⟩ => - Check.assert exprMd condExpr summary expected source (by rw [h_node]) + Check.assert exprMd condExpr summary source (by rw [h_node]) | .Assume cond => - Check.assume exprMd cond expected source (by rw [h_node]) + Check.assume exprMd cond source (by rw [h_node]) | .Old val => Check.old exprMd val expected source (by rw [h_node]) | .ProveBy val proof => @@ -755,41 +786,64 @@ def Synth.varField (exprMd : StmtExprMd) try simp_all omega -/-- `x ∉ dom(Γ), TVoid <: T ∴ Γ ⊢ Var (.Declare ⟨x, T_x⟩) ⇐ T ⊣ Γ, x : T_x` - - `⊣ Γ, x : T_x` records that the surrounding scope is extended with the - new binding for the remainder of the enclosing scope. The declaration - itself produces no value, so `expected` must admit `TVoid`. -/ -def Check.varDeclare (param : Parameter) - (expected : HighTypeMd) (source : Option FileRange) : +/-- (Var-Declare) + ``` + x ∉ dom(Γ) + ───────────────────────────────────────── ⊣ Γ, x : T_x + Γ ⊢ Var (.Declare ⟨x, T_x⟩) ⇐ A + ``` + `⊣ Γ, x : T_x` records that the surrounding scope is extended with + the new binding for the remainder of the enclosing block. The + declaration itself does no work other than registering `x : T_x`, + so its conclusion is polymorphic in `A` — declarations are + statement-shaped and never deliver a value to the surrounding + context. -/ +def Check.varDeclare (param : Parameter) (source : Option FileRange) : ResolveM StmtExprMd := do let ty' ← resolveHighType param.type let name' ← defineNameCheckDup param.name (.var param.name ty') - checkSubtype source expected { val := .TVoid, source := source } pure { val := .Var (.Declare ⟨name', ty'⟩), source := source } -- ### Control flow -/-- `Γ ⊢ cond ⇐ TBool, Γ ⊢ invs_i ⇐ TBool, Γ ⊢ dec ⇐ ?, Γ ⊢ body ⇐ T, TVoid <: T ∴ Γ ⊢ While cond invs dec body ⇐ T` - - `cond` is checked against `TBool`, each invariant against `TBool`, - optional `decreases` is currently resolved without a type check (the - intended target is a numeric type), and the body is checked against - the surrounding `expected` type. The construct itself produces no - value, so `expected` must admit `TVoid`. -/ +/-- (While) + ``` + Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool + Γ ⊢ decreases ⇒ U Numeric U + Γ ⊢ body ⇐ Unknown + ───────────────────────────────────────────────── + Γ ⊢ While cond invs decreases body ⇐ A + ``` + `cond` is checked against `TBool`, and each invariant against + `TBool`. The body's *value type* is discarded — control either + re-enters the loop or falls through, so the body is checked at + `Unknown` (the gradual wildcard) and any value the body's tail + might produce is ignored. The loop itself contributes no value + to its surrounding context, so its conclusion is polymorphic in + `A`. + + The optional `decreases` clause is synthesized and required to + have a numeric type (`TInt`, `TReal`, `TFloat64`, or `Unknown` as + the gradual escape hatch), via the same `Numeric U` predicate + used by the arithmetic primitive ops. `Numeric` is a predicate, + not a single type, so the clause runs in synth mode rather than + check mode. -/ def Check.while (exprMd : StmtExprMd) (cond : StmtExprMd) (invs : List StmtExprMd) (dec : Option StmtExprMd) (body : StmtExprMd) - (expected : HighTypeMd) (source : Option FileRange) + (source : Option FileRange) (h : exprMd.val = .While cond invs dec body) : ResolveM StmtExprMd := do let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } let invs' ← invs.attach.mapM (fun a => have := a.property; do Check.resolveStmtExpr a.val { val := .TBool, source := a.val.source }) let dec' ← dec.attach.mapM (fun a => have := a.property; do - let (e', _) ← Synth.resolveStmtExpr a.val; pure e') - let body' ← Check.resolveStmtExpr body expected - checkSubtype source expected { val := .TVoid, source := source } + let (e', decTy) ← Synth.resolveStmtExpr a.val + let ctx := (← get).typeContext + unless isNumeric ctx decTy do + typeMismatch a.val.source none "expected a numeric type" decTy + pure e') + let body' ← Check.resolveStmtExpr body { val := .Unknown, source := body.source } pure { val := .While cond' invs' dec' body', source := source } termination_by (exprMd, 0) decreasing_by @@ -801,58 +855,95 @@ def Check.while (exprMd : StmtExprMd) try simp_all omega -/-- `Γ ⊢ Exit target ⇐ T` - - `exit` is a control-flow jump out of a labeled block; it doesn't - deliver a value to the enclosing block, so no subsumption against - `expected` is required. -/ -def Check.exit (target : String) (_expected : HighTypeMd) - (source : Option FileRange) : ResolveM StmtExprMd := do +/-- (Exit) + ``` + l ∈ Γ + ─────────────────── + Γ ⊢ Exit l ⇐ A + ``` + `exit` is a control-flow terminator: it transfers control out of + the enclosing labeled block and does not deliver a value to the + surrounding context. Anything after `exit l` in the same block is + dead code, flagged by `Resolution.Check.block`. The construct + checks at any `A`. -/ +def Check.exit (target : String) (source : Option FileRange) : + ResolveM StmtExprMd := do pure { val := .Exit target, source := source } -/-- Cases on whether the return value is `none` or `some e`, and on the - arity of the enclosing procedure's declared outputs. - - `Γ ⊢ Return none ⇐ T` - - `Γ_proc.outputs = [T_o], Γ ⊢ e ⇐ T_o ∴ Γ ⊢ Return (some e) ⇐ T` - - `Γ_proc.outputs = [] ∴ Γ ⊢ Return (some e) ↝ error: "void procedure cannot return a value"` - - `Γ_proc.outputs = [T_1; …; T_n] (n ≥ 2) ∴ Γ ⊢ Return (some e) ↝ error: "multi-output procedure cannot use 'return e'; assign to named outputs instead"` - - `return` is a control-flow jump out of the procedure; it doesn't - deliver a value to the enclosing block, so no subsumption against the - surrounding `expected` is required. The optional payload is matched - against the enclosing procedure's declared outputs (threaded through - `ResolveState.expectedReturnTypes`, set from `proc.outputs` by - `resolveProcedure` / `resolveInstanceProcedure` for the duration of - the body; `none` means "no enclosing procedure" — e.g. resolving a - constant initializer — and skips all `Return` checks). - - A bare `return;` is allowed in any context. In a single-output procedure - it acts as a Dafny-style early exit — the output parameter retains - whatever was last assigned to it. In a single-output procedure, `return e` - is checked against the declared output type (closing the prior soundness - gap where `return 0` in a `bool`-returning procedure went uncaught). - - Multi-output procedures use named-output assignment (`r := …` on the - declared output parameters); `return e` syntactically takes a single - `Option StmtExpr` and cannot carry multiple values, so it is flagged - with a diagnostic pointing users at the named-output convention. -/ +/-- (Return) + + Below, `T_o-bar` denotes the enclosing procedure's declared + output-type list (bound on entry to a procedure body, stored on + `ResolveState.answerType`). + + ``` + T_o-bar = [] (Return-None-Void) + ───────────────────────── + Γ ⊢ Return none ⇐ A + + T_o-bar = [T] TVoid <: T (Return-None-Single) + ────────────────────────────────── + Γ ⊢ Return none ⇐ A + + T_o-bar = [T_1;…;T_n] n ≥ 2 (Return-None-Multi) + ────────────────────────────────── + Γ ⊢ Return none ⇐ A + + T_o-bar = [T] Γ ⊢ e ⇐ T (Return-Some) + ────────────────────────────────── + Γ ⊢ Return (some e) ⇐ A + + T_o-bar = [] (Return-Void-Error) + ─────────────────────────────────────────────────────────── + Γ ⊢ Return (some e) ↝ "void procedure cannot return a value" + + T_o-bar = [T_1;…;T_n] n ≥ 2 (Return-Multi-Error) + ─────────────────────────────────────────────────────────── + Γ ⊢ Return (some e) ↝ "multi-output procedure cannot use ‘return e’" + ``` + `return` is the *only* rule whose premises depend on the enclosing + procedure's declared outputs. It is also a control-flow terminator: + it transfers control out of the enclosing procedure and does not + deliver a value to the surrounding block, so the conclusion is + polymorphic in `A` (anything after `return` in the same block is + dead code, flagged by `Resolution.Check.block`). + + When `answerType = none` we are not inside any procedure body (e.g. + resolving a constant initializer), so all `Return` checks are + skipped — `Return` should not occur there in well-formed input. + + `return;` synthesizes the missing payload as `TVoid`. In a + single-output procedure it is then required to subtype the declared + output (Return-None-Single's `TVoid <: T` premise) — accepted in + void-returning procedures, rejected in `int`/`bool`/etc. ones, + closing the soundness gap that the Dafny-style early-exit shorthand + used to leave open. In a void-output procedure it is unconditionally + accepted (Return-None-Void); in a multi-output procedure it is also + accepted (Return-None-Multi) and acts as an early-exit shorthand — + each declared output retains whatever was last assigned to it via + named-output assignment. + + `return e` is checked against the declared output type in the + single-output case. Multi-output procedures use named-output + assignment (`r := …` on the declared output parameters); `return e` + syntactically takes a single `Option StmtExpr` and cannot carry + multiple values, so it is flagged with a diagnostic pointing users + at the named-output convention. -/ def Check.return (exprMd : StmtExprMd) - (val : Option StmtExprMd) (expected : HighTypeMd) - (source : Option FileRange) + (val : Option StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Return val) : ResolveM StmtExprMd := do - let expectedReturn := (← get).expectedReturnTypes + let expectedReturn := (← get).answerType let val' ← val.attach.mapM (fun a => have := a.property; do match expectedReturn with | some [singleOutput] => Check.resolveStmtExpr a.val singleOutput | _ => let (e', _) ← Synth.resolveStmtExpr a.val; pure e') match val, expectedReturn with | none, some [] => pure () - | none, some [_] => pure () + | none, some [singleOutput] => + -- `return;` synthesizes the missing payload as `TVoid`; require it to + -- be a consistent subtype of the declared output. + checkSubtype source singleOutput { val := .TVoid, source := source } | none, some _ => pure () | some _, some [] => let diag := diagnosticFromSource source @@ -867,7 +958,7 @@ def Check.return (exprMd : StmtExprMd) -- `return` is a control-flow jump; it doesn't deliver a value to the -- enclosing block, so no TVoid-vs-expected subsumption is required. -- The return value (if any) was already checked against the declared - -- output above via `expectedReturnTypes`. + -- output above via `answerType`. pure { val := .Return val', source := source } termination_by (exprMd, 0) decreasing_by @@ -878,38 +969,109 @@ def Check.return (exprMd : StmtExprMd) simp_all omega -/-- Cases on whether the statement list is empty. - - `TVoid <: T ∴ Γ ⊢ Block [] label ⇐ T` - - `Γ_0 = Γ, Γ_{i-1} ⊢ s_i ⇐ Unknown ⊣ Γ_i (1 ≤ i < n), Γ_{n-1} ⊢ s_n ⇐ T ∴ Γ ⊢ Block [s_1; …; s_n] label ⇐ T` - +/-- (Skip) + ``` + ───────────────────────────────── + Γ ⊢ Block [] label ⇒ TVoid + ``` + The empty block has a fixed type `TVoid` — written `skip : TVoid` + in the source-language presentation. This is the only block-level + rule that synthesizes; every non-empty block reduces (via the + iteration in + `Resolution.Check.block`) to a + `head; rest` pattern whose tail eventually bottoms out here, and + `Check.block` applies subsumption at the boundary when an + `expected` type is supplied. -/ +def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := + { val := .TVoid, source := source } + +/-- (Block) Non-empty block. + ``` + head = StaticCall .. | InstanceCall .. + Γ ⊢ s_i ⇒ _ (Discard-Call, 1 ≤ i < n) + ────────────────────────── + Γ ⊢ s_i checks-non-last + + head ≠ StaticCall .., InstanceCall .. + Γ ⊢ s_i ⇐ TVoid (1 ≤ i < n) + ────────────────────────── + Γ ⊢ s_i checks-non-last + + Γ ⊢ s_i checks-non-last (1 ≤ i < n) Γ ⊢ s_n ⇐ T + ─────────────────────────────────────────────────── + Γ ⊢ Block [s_1; …; s_n] label ⇐ T + ``` The last statement carries the block's value, so it is checked - against the surrounding `expected`. Non-last statements are checked - against `Unknown`, which accepts any type via gradual subsumption — - matching the Java/Python/JavaScript discipline where `f(x);` is a - valid statement even when `f` returns a value (the value is - discarded). Routing through check mode (rather than synth) means - that constructs without a synth rule are still resolved correctly, - with their bidirectional rules pushing `Unknown` into their - subexpressions. - - Empty blocks reduce to a subsumption check of `TVoid` against - `expected` — the same check `[⇐] Block-Empty` performs when `T` - admits `TVoid`. -/ + against the surrounding `T`. Non-last positions check at `TVoid`, + which by \[⇐\] Sub admits any statement-typed form (Var-Declare, + Assign, Assert, Assume, While, Exit, Return, IfThenElse — their + rule conclusions are polymorphic in `A`, so they trivially check + at `TVoid`) and rejects bare expressions like `5;` whose type is + not consistent with `TVoid`. + + The one carve-out is **Discard-Call**: a procedure or method call + in non-last position is synthesized and its result type dropped, + *not* checked at `TVoid`. Without that carve-out, `f(x);` for a + non-void-returning `f` would be rejected even though discarding the + returned value is the standard imperative idiom (Java / Python / + JavaScript: `list.add(x);`). + + The empty block has its own rule (`Resolution.Synth.emptyBlock`, + a synthesis rule producing `TVoid`) and is handled directly in the + `Resolution.Check.resolveStmtExpr` dispatcher's wildcard arm via + the standard \[⇐\] Sub fallback. + + The block opens a fresh nested scope (so declarations made inside + don't leak), and emits a "dead code after `exit`/`return`" + diagnostic when a terminator is followed by additional statements + in the same block. -/ def Check.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do let voidTy : HighTypeMd := { val := .TVoid, source := source } - let unknownTy : HighTypeMd := { val := .Unknown, source := source } withScope do let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do have : s ∈ stmts := List.dropLast_subset stmts hMem - Check.resolveStmtExpr s unknownTy) + -- Discard-Call carve-out: a non-void-returning call in non-last + -- position is synth-and-drop instead of `⇐ TVoid`, which would + -- otherwise reject the standard `list.add(x);` idiom. + match s.val with + | .StaticCall .. | .InstanceCall .. => + let (s', _) ← Synth.resolveStmtExpr s; pure s' + | _ => Check.resolveStmtExpr s voidTy) + -- Dead-code diagnostic: any terminator (`Exit`/`Return`) in init' + -- is followed by at least one more statement (the last, or another + -- non-last). Flag it once at the position of the next statement. + let isTerminator (s : StmtExprMd) : Bool := + match s.val with + | .Exit _ | .Return _ => true + | _ => false + match init'.findIdx? isTerminator with + | some i => + let nextSource : Option FileRange := + match init'[i + 1]? with + | some next => next.source + | none => -- terminator is the last of init', so the dead one + -- is the block's actual last statement + (stmts.getLast?.bind (·.source)) + let termName : String := + match init'[i]? with + | some s => s.val.constrName + | none => "exit" + let diag := diagnosticFromSource nextSource + s!"dead code after '{termName}'" + modify fun st => { st with errors := st.errors.push diag } + | none => pure () + -- Non-empty block (the dispatcher in Check.resolveStmtExpr already + -- routes empty blocks through the synth-then-Sub fallback). The + -- last statement carries the block's value, so it is checked + -- against the surrounding `expected`. match _lastResult: stmts.getLast? with | none => - checkSubtype source expected voidTy + -- Unreachable: dispatcher narrowed `.Block (head :: tail) label`. + -- Keep this arm to remain total; falls back to the empty-block synth. + checkSubtype source expected (Synth.emptyBlock source) pure { val := .Block init' label, source := source } | some last => have := List.mem_of_getLast? _lastResult @@ -925,19 +1087,26 @@ def Check.block (exprMd : StmtExprMd) try simp_all omega -/-- When there is an else branch: - - `Γ ⊢ cond ⇐ TBool, Γ ⊢ thenBr ⇐ T, Γ ⊢ elseBr ⇐ T ∴ Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T` - - Otherwise: - - `Γ ⊢ cond ⇐ TBool, Γ ⊢ thenBr ⇐ T, TVoid <: T ∴ Γ ⊢ IfThenElse cond thenBr none ⇐ T` - - Pushes `expected` into both branches (rather than going through - If-Synth + Sub at the boundary). Errors fire at the offending branch - instead of the surrounding `if`. Without an else branch, the construct - can only succeed when `expected` admits `TVoid` — the same subsumption - check `[⇐] Block-Empty` performs for an empty block. -/ +/-- (If / If-NoElse) + ``` + Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T (If) + ────────────────────────────────────────────────────────────────── + Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T + + Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T TVoid <: T (If-NoElse) + ────────────────────────────────────────────────────────────────── + Γ ⊢ IfThenElse cond thenBr none ⇐ T + ``` + Pushes the surrounding `T` into both branches (rather than going + through If-Synth + Sub at the boundary): errors fire at the + offending branch instead of at the `if`, and the expectation + propagates through nested `Block` / `IfThenElse` / `Hole` / + `Quantifier` constructs that have their own check rules. + + Without an `else`, the implicit branch is `skip : TVoid`, so the + rule degenerates to require `TVoid <: T` — the standard \[⇐\] Sub + boundary check that `Resolution.Synth.emptyBlock` composes with + for an empty block. -/ def Check.ifThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) @@ -959,17 +1128,21 @@ def Check.ifThenElse (exprMd : StmtExprMd) -- ### Verification statements -/-- `Γ ⊢ cond ⇐ TBool, TVoid <: T ∴ Γ ⊢ Assert cond ⇐ T` - - `cond` is checked against `TBool`; the construct produces no value, - so `expected` must admit `TVoid`. -/ +/-- (Assert) + ``` + Γ ⊢ cond ⇐ TBool + ────────────────────────────────── + Γ ⊢ Assert cond ⇐ A + ``` + `cond` is checked against `TBool`. The assertion form is + statement-typed and contributes nothing to the surrounding value + flow, so its conclusion is polymorphic in `A`. -/ def Check.assert (exprMd : StmtExprMd) (condExpr : StmtExprMd) (summary : Option String) - (expected : HighTypeMd) (source : Option FileRange) + (source : Option FileRange) (h : exprMd.val = .Assert ⟨condExpr, summary⟩) : ResolveM StmtExprMd := do let cond' ← Check.resolveStmtExpr condExpr { val := .TBool, source := condExpr.source } - checkSubtype source expected { val := .TVoid, source := source } pure { val := .Assert { condition := cond', summary }, source := source } termination_by (exprMd, 0) decreasing_by @@ -979,16 +1152,20 @@ def Check.assert (exprMd : StmtExprMd) try simp_all omega -/-- `Γ ⊢ cond ⇐ TBool, TVoid <: T ∴ Γ ⊢ Assume cond ⇐ T` - - `cond` is checked against `TBool`; the construct produces no value, - so `expected` must admit `TVoid`. -/ +/-- (Assume) + ``` + Γ ⊢ cond ⇐ TBool + ────────────────────────────────── + Γ ⊢ Assume cond ⇐ A + ``` + `cond` is checked against `TBool`. The assumption form is + statement-typed and contributes nothing to the surrounding value + flow, so its conclusion is polymorphic in `A`. -/ def Check.assume (exprMd : StmtExprMd) - (cond : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) + (cond : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assume cond) : ResolveM StmtExprMd := do let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } - checkSubtype source expected { val := .TVoid, source := source } pure { val := .Assume cond', source := source } termination_by (exprMd, 0) decreasing_by @@ -1000,20 +1177,69 @@ def Check.assume (exprMd : StmtExprMd) -- ### Assignment -/-- `Γ ⊢ targets_i ⇒ T_i, Γ ⊢ e ⇐ ExpectedTy ∴ Γ ⊢ Assign targets e ⇐ TVoid` - - where `ExpectedTy = T_1` if `|targets| = 1`, else - `MultiValuedExpr [T_1; …; T_n]`. +/-- (Assign) + ``` + Γ ⊢ targets_i ⇒ T_i Γ ⊢ e ⇐ ExpectedTy + ───────────────────────────────────────────────────────── + Γ ⊢ Assign targets e ⇒ ExpectedTy + ``` + where `ExpectedTy = T_1` if `|targets| = 1` and otherwise + `MultiValuedExpr [T_1; …; T_n]`. The target tuple type is pushed + into the RHS via `Check.resolveStmtExpr`, so bidirectional rules + in the RHS receive the assignment's type. The assignment + synthesizes `ExpectedTy` — the LHS-derived target tuple type — + so the surrounding context sees the type the RHS was checked + against. -/ +def Synth.assign (exprMd : StmtExprMd) + (targets : List VariableMd) (value : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .Assign targets value) : + ResolveM (StmtExpr × HighTypeMd) := do + let targets' ← targets.attach.mapM fun ⟨v, _⟩ => do + let ⟨vv, vs⟩ := v + match vv with + | .Local ref => + let ref' ← resolveRef ref source + pure (⟨.Local ref', vs⟩ : VariableMd) + | .Field target fieldName => + let (target', _) ← Synth.resolveStmtExpr target + let fieldName' ← resolveFieldRef target' fieldName source + pure (⟨.Field target' fieldName', vs⟩ : VariableMd) + | .Declare param => + let ty' ← resolveHighType param.type + let name' ← defineNameCheckDup param.name (.var param.name ty') + pure (⟨.Declare ⟨name', ty'⟩, vs⟩ : VariableMd) + let targetType (t : VariableMd) : ResolveM HighTypeMd := do + match t.val with + | .Local ref => getVarType ref + | .Declare param => pure param.type + | .Field _ fieldName => getVarType fieldName + let targetTys ← targets'.mapM targetType + let expectedTy : HighTypeMd := match targetTys with + | [single] => single + | _ => { val := .MultiValuedExpr targetTys, source := source } + let value' ← Check.resolveStmtExpr value expectedTy + pure (.Assign targets' value', expectedTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + try (have := List.sizeOf_lt_of_mem ‹_ ∈ targets›; simp_all) + omega - Assignment is strictly statement-position: `expected` must be - `TVoid`. The target tuple type is pushed into the RHS via - `Check.resolveStmtExpr`, so bidirectional rules in the RHS receive - the assignment's type. Expression-position uses (e.g. - `x ++ (y := s)`) are rejected — the assignment's "value" is its - target type only by convention, and accepting it as an expression - invites bugs like the impure-side-effect-inside-expression case - where `(if c then { b := false } else (b := true)) || b` - typechecked because `bool <: bool` trivially holds. -/ +/-- Check-mode rule for assignment. Synthesizes the assignment's type + by inlining the same work as `Synth.assign` (resolving targets, + pushing the LHS-derived `ExpectedTy` into the RHS via + `Check.resolveStmtExpr`), then runs the standard \[⇐\] Sub + boundary check `ExpectedTy <: T` against the surrounding `expected` + — *unless* `T = TVoid`, the marker for statement position + (e.g. last statement of a block whose value is being discarded). + `Sub` against `TVoid` would only succeed when `ExpectedTy = TVoid`, + which would reject every non-void assignment used as a statement, + so the subsumption is skipped there. The synthesized value is + discarded in statement position, exactly as for calls. -/ def Check.assign (exprMd : StmtExprMd) (targets : List VariableMd) (value : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) @@ -1042,7 +1268,8 @@ def Check.assign (exprMd : StmtExprMd) | [single] => single | _ => { val := .MultiValuedExpr targetTys, source := source } let value' ← Check.resolveStmtExpr value expectedTy - checkSubtype source expected { val := .TVoid, source := source } + unless expected.val matches .TVoid do + checkSubtype source expected expectedTy pure { val := .Assign targets' value', source := source } termination_by (exprMd, 0) decreasing_by @@ -1058,7 +1285,7 @@ def Check.assign (exprMd : StmtExprMd) /-- Cases on the arity of the callee's declared outputs. - `Γ(callee) = static-procedure with inputs Ts and outputs [T], Γ ⊢ args ⇒ Us, U_i <: T_i (pairwise) ∴ Γ ⊢ StaticCall callee args ⇒ T` + `Γ(callee) = static-procedure with input T and output T', Γ ⊢ arg ⇒ U, U <: T ∴ Γ ⊢ StaticCall callee arg ⇒ T'` `Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n] (n ≠ 1), Γ ⊢ args ⇒ Us, U_i <: T_i (pairwise) ∴ Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n]` @@ -1088,7 +1315,7 @@ def Synth.staticCall (exprMd : StmtExprMd) have := List.sizeOf_lt_of_mem ‹_ ∈ args› omega -/-- `Γ ⊢ target ⇒ _, Γ(callee) = instance-procedure with inputs [self; Ts] and outputs [T], Γ ⊢ args ⇒ Us, U_i <: T_i (pairwise; self dropped) ∴ Γ ⊢ InstanceCall target callee args ⇒ T` +/-- `Γ ⊢ target ⇒ _, Γ(callee) = instance- or static-procedure with inputs [self; T] and output T', Γ ⊢ arg ⇒ U, U <: T ∴ Γ ⊢ InstanceCall target callee arg ⇒ T'` Target is synthesized; callee resolves to an instance or static procedure; arguments are checked pairwise against the callee's @@ -1282,18 +1509,28 @@ def Synth.new (ref : Identifier) (source : Option FileRange) : else { val := HighType.Unknown, source := source } pure (.New ref', ty) -/-- `Γ ⊢ target ⇒ _ ∴ Γ ⊢ AsType target T ⇒ T` - - `target` is resolved but not checked against `T` — the cast is the - user's claim. The synthesized type is `T`. - - `IsType` is the runtime test counterpart and synthesizes `TBool`. -/ +/-- `Γ ⊢ target ⇒ U, U ~ T ∨ U <: T ∨ T <: U ∴ Γ ⊢ AsType target T ⇒ T` + + `target` synthesizes some type `U`; the cast is allowed when `U` and + `T` sit in the same lineage modulo gradual `Unknown` — either + consistent after unfolding aliases/constrained types (e.g. `5 as Int` + where `Int` is a wrapper over `int`), or a subtype in either + direction (downcast `animal as Cat` when `Cat extends Animal`, + upcast `cat as Animal`). Sibling casts (`Dog as Cat`) and casts + between unrelated primitives (`"hi" as int`) are rejected. The + synthesized type is `T` — the user's claim is honored once the + relation check passes. -/ def Synth.asType (exprMd : StmtExprMd) (target : StmtExprMd) (ty : HighTypeMd) (h : exprMd.val = .AsType target ty) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', _) ← Synth.resolveStmtExpr target + let (target', targetTy) ← Synth.resolveStmtExpr target let ty' ← resolveHighType ty + let ctx := (← get).typeContext + unless isConsistentSubtype ctx targetTy ty' || isConsistentSubtype ctx ty' targetTy do + let diag := diagnosticFromSource target.source + s!"cannot cast unrelated type '{formatType targetTy}' to '{formatType ty'}'" + modify fun s => { s with errors := s.errors.push diag } pure (.AsType target' ty', ty') termination_by (exprMd, 1) decreasing_by @@ -1302,15 +1539,22 @@ def Synth.asType (exprMd : StmtExprMd) simp [h] at hsz omega -/-- `Γ ⊢ target ⇒ _ ∴ Γ ⊢ IsType target T ⇒ TBool` +/-- `Γ ⊢ target ⇒ U, U ~ T ∨ U <: T ∨ T <: U ∴ Γ ⊢ IsType target T ⇒ TBool` - `target` is resolved; the synthesized type is `TBool`. -/ + Same lineage check as `AsType` — `is` only makes sense between types + that share a lineage modulo gradual `Unknown`; testing `5 is Cat` + is statically nonsense. The synthesized type is `TBool`. -/ def Synth.isType (exprMd : StmtExprMd) (target : StmtExprMd) (ty : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .IsType target ty) : ResolveM (StmtExpr × HighTypeMd) := do - let (target', _) ← Synth.resolveStmtExpr target + let (target', targetTy) ← Synth.resolveStmtExpr target let ty' ← resolveHighType ty + let ctx := (← get).typeContext + unless isConsistentSubtype ctx targetTy ty' || isConsistentSubtype ctx ty' targetTy do + let diag := diagnosticFromSource target.source + s!"cannot test unrelated type '{formatType targetTy}' against '{formatType ty'}'" + modify fun s => { s with errors := s.errors.push diag } pure (.IsType target' ty', { val := .TBool, source := source }) termination_by (exprMd, 1) decreasing_by @@ -1645,20 +1889,38 @@ def resolveBody (body : Body) (expected : HighTypeMd) : ResolveM Body := do return .Abstract posts' | .External => return .External -/-- Compute the expected body type for a procedure. Functional - procedures with a single output `T` expect `T` — the body's last - statement is the result and must produce a `T`. Non-functional - procedures expect `Unknown`: their body is statement-typed and the - last statement (if any) is discarded — outputs are observed via - `return e` or named-output assignment, validated independently - inside `Check.return` via `expectedReturnTypes`. -/ +/-- Compute the expected *value type* `A` for a procedure body, i.e. + the `A` in `Γ ⊢ body ⇐ A`. Functional procedures with a single + output `T` expect `A = T`: the body's last statement is the result + and must produce a `T`. Non-functional procedures expect + `A = Unknown`: their body is statement-typed and the last + statement (if any) is discarded — outputs are observed via + `return e` (whose payload is matched against the procedure's + declared outputs by `Resolution.Check.return`) or via named-output + assignment. + + This computes only the body's value type. The procedure's declared + output list is bound separately by the procedure rule + (`resolveProcedure` / `resolveInstanceProcedure`) into + `ResolveState.answerType`. -/ private def procedureBodyType (isFunctional : Bool) (outputs : List Parameter) (source : Option FileRange) : HighTypeMd := match isFunctional, outputs with | true, [singleOutput] => singleOutput.type | _, _ => { val := .Unknown, source := source } -/-- Resolve a procedure: resolve its name, then resolve params, contracts, and body in a new scope. -/ +/-- (Procedure) + ``` + T_o-bar = proc.outputs.types A = procedureBodyType proc + Γ_global, params(proc) ⊢ proc.body ⇐ A + ────────────────────────────────────────────────────────── + Γ_global ⊢ Procedure proc + ``` + The body is resolved under a scope that includes the procedure's + input and output parameters, and is checked against the value type + `A` computed by `procedureBodyType`. The Return rules consult the + procedure's declared output list `T_o-bar` (stored on + `ResolveState.answerType`, set on entry and restored on exit). -/ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do let procName' ← resolveRef proc.name withScope do @@ -1666,11 +1928,11 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do let outputs' ← proc.outputs.mapM resolveParameter let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) let dec' ← proc.decreases.mapM resolveStmtExpr - let savedReturns := (← get).expectedReturnTypes - modify fun s => { s with expectedReturnTypes := some (outputs'.map (·.type)) } + let savedAnswer := (← get).answerType + modify fun s => { s with answerType := some (outputs'.map (·.type)) } let bodyExpected := procedureBodyType proc.isFunctional outputs' proc.name.source let body' ← resolveBody proc.body bodyExpected - modify fun s => { s with expectedReturnTypes := savedReturns } + modify fun s => { s with answerType := savedAnswer } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" @@ -1703,11 +1965,11 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv let outputs' ← proc.outputs.mapM resolveParameter let pres' ← proc.preconditions.mapM (·.mapM resolveStmtExpr) let dec' ← proc.decreases.mapM resolveStmtExpr - let savedReturns := (← get).expectedReturnTypes - modify fun s => { s with expectedReturnTypes := some (outputs'.map (·.type)) } + let savedAnswer := (← get).answerType + modify fun s => { s with answerType := some (outputs'.map (·.type)) } let bodyExpected := procedureBodyType proc.isFunctional outputs' proc.name.source let body' ← resolveBody proc.body bodyExpected - modify fun s => { s with expectedReturnTypes := savedReturns } + modify fun s => { s with answerType := savedAnswer } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source s!"transparent procedures are not yet supported. Add 'opaque' to make the procedure opaque" diff --git a/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean b/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean index 9149d2e647..9d5ceeb0c8 100644 --- a/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean +++ b/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean @@ -13,11 +13,13 @@ namespace Strata namespace Laurel /- -When the two branches of an `if/else` have different but subtype-related -types, the construct synthesizes their join (least upper bound) — not the -then-branch arbitrarily. So `if c then new Left else new Right`, with -`Left, Right <: Top`, synthesizes `Top`. Storing it in a `Top`-typed -variable succeeds, but storing it in a `Left`-typed variable is rejected. +When an `if/else` is checked against an expected type, the rule pushes +that type into both branches rather than going through synth + subsumption +at the boundary. So `var y: Left := if c then new Left else new Right`, +with `Left, Right <: Top`, errors at the *else-branch*: `new Right` is +checked against `Left`, and since `Right` is not a subtype of `Left`, a +"expected 'Left', got 'Right'" diagnostic fires there. The then-branch +(`new Left`) and the `var x: Top := …` assignment both pass. -/ def program := r" @@ -27,7 +29,7 @@ composite Right extends Top { } procedure test(c: bool) opaque { var x: Top := if c then new Left else new Right; var y: Left := if c then new Left else new Right -//^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: expected 'Left', got 'Top' +// ^^^^^^^^^ error: expected 'Left', got 'Right' }; " diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 9a9ae02931..5d4766b7d8 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -157,17 +157,19 @@ mismatches against the surrounding context become diagnostics. The implementatio ### Bidirectional type checking -There are two operations on expressions, written here in standard bidirectional notation: +There are two operations on expressions, written here in standard +bidirectional notation: ``` -Γ ⊢ e ⇒ T -- "e synthesizes T" (Synth.resolveStmtExpr) -Γ ⊢ e ⇐ T -- "e checks against T" (Check.resolveStmtExpr) +Γ ⊢ e ⇒ A -- "e synthesizes A" (Synth.resolveStmtExpr) +Γ ⊢ e ⇐ A -- "e checks against A" (Check.resolveStmtExpr) ``` -Synthesis returns a type inferred from the expression itself; checking verifies that the -expression has a given expected type. Each construct picks a mode based on whether its -type is determined locally (synth) or by context (check). The two judgments are connected -by a single change-of-direction rule, *subsumption*: +Synthesis returns a type inferred from the expression itself; checking +verifies that the expression has a given expected type. Each construct +picks a mode based on whether its type is determined locally (synth) or +by context (check). The two judgments are connected by a single +change-of-direction rule, *subsumption*: $$`\frac{\Gamma \vdash e \Rightarrow A \quad A <: B}{\Gamma \vdash e \Leftarrow B} \quad \text{([⇐] Sub)}` @@ -191,12 +193,22 @@ and {name Strata.Laurel.isConsistentSubtype}`isConsistentSubtype`: {docstring Strata.Laurel.isConsistentSubtype} -Side-effecting constructs synthesize {name Strata.Laurel.HighType.TVoid}`TVoid`. This -includes {name Strata.Laurel.StmtExpr.Return}`Return`, -{name Strata.Laurel.StmtExpr.Exit}`Exit`, {name Strata.Laurel.StmtExpr.While}`While`, -{name Strata.Laurel.StmtExpr.Assert}`Assert`, {name Strata.Laurel.StmtExpr.Assume}`Assume`, -{name Strata.Laurel.Variable.Declare}`Var Declare`, and the opaque/abstract/external bodies -— recorded in the rules below. +Statement-shaped constructs check at any value type. Their +conclusions are polymorphic in $`T`: the form contributes nothing to +the surrounding value flow, so its rule does not constrain $`T` +beyond what its own premises do. The block rule +({ref "rules-control-flow"}[Block]) is what supplies the value type +for a block: it routes the surrounding $`T` to the last statement +and ignores the value of every non-last statement. Every statement +form ({name Strata.Laurel.StmtExpr.Return}`Return`, +{name Strata.Laurel.StmtExpr.Exit}`Exit`, +{name Strata.Laurel.StmtExpr.While}`While`, +{name Strata.Laurel.StmtExpr.Assert}`Assert`, +{name Strata.Laurel.StmtExpr.Assume}`Assume`, +{name Strata.Laurel.StmtExpr.Assign}`Assign`, +{name Strata.Laurel.Variable.Declare}`Var Declare`) thus has a +conclusion of the form $`\Gamma \vdash s \Leftarrow A`, with $`A` +unconstrained. ## Typing rules @@ -213,12 +225,12 @@ direction explicit. - {ref "rules-literals"}[*Literals*] — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal - {ref "rules-variables"}[*Variables*] — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇐\] Var-Declare - {ref "rules-control-flow"}[*Control flow*] — \[⇐\] If, \[⇐\] If-NoElse; - \[⇐\] Block, \[⇒\] Skip, \[⇐\] Discard, \[⇐\] Discard-Call; \[⇐\] Exit; + \[⇐\] Block, \[⇒\] Skip, \[⇐\] Discard-Call; \[⇐\] Exit; \[⇐\] Return-None-Void, \[⇐\] Return-None-Single, \[⇐\] Return-None-Multi, \[⇐\] Return-Some, \[⇐\] Return-Void-Error, \[⇐\] Return-Multi-Error; \[⇐\] While - {ref "rules-verification-statements"}[*Verification statements*] — \[⇐\] Assert, \[⇐\] Assume -- {ref "rules-assignment"}[*Assignment*] — \[⇐\] Assign +- {ref "rules-assignment"}[*Assignment*] — \[⇒\] Assign, \[⇐\] Assign - {ref "rules-calls"}[*Calls*] — \[⇒\] Static-Call, \[⇒\] Static-Call-Multi, \[⇒\] Instance-Call, \[⇒\] Instance-Call-Multi - {ref "rules-primitive-operations"}[*Primitive operations*] — \[⇒\] Op-Bool, \[⇒\] Op-Cmp, \[⇒\] Op-Eq, @@ -277,7 +289,7 @@ $$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \ma {docstring Strata.Laurel.Resolution.Synth.varField} -$$`\frac{x \notin \mathrm{dom}(\Gamma) \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T_x\rangle) \Leftarrow T \dashv \Gamma, x : T_x} \quad \text{([⇐] Var-Declare)}` +$$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T_x\rangle) \Leftarrow A \;\;\dashv\;\; \Gamma, x : T_x} \quad \text{([⇐] Var-Declare)}` {docstring Strata.Laurel.Resolution.Check.varDeclare} @@ -292,51 +304,110 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda {docstring Strata.Laurel.Resolution.Check.ifThenElse} -$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` +A non-empty block routes the surrounding expected type to its last +statement; each non-last statement is checked at $`\mathsf{TVoid}`, +*except* calls — which are synthesized and have their result type +dropped. That carve-out is the only block-level rule that isn't +already a consequence of the rules for individual statements. + +$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Leftarrow \mathsf{TVoid} \;\;\dashv\;\; \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` + +$$`\frac{\mathit{head} = \mathsf{StaticCall}\;\ldots \;\lor\; \mathit{head} = \mathsf{InstanceCall}\;\ldots \quad \Gamma \vdash \mathit{head} \Rightarrow \_}{\Gamma \vdash \mathit{head} \Leftarrow \mathsf{TVoid}} \quad \text{([⇐] Discard-Call)}` + +Each $`s_i` is resolved under the scope $`\Gamma_{i-1}` produced by +its predecessor and produces a possibly extended scope $`\Gamma_i` +that the next statement sees. In practice only `Var (.Declare …)` +actually extends the scope; every other construct leaves it +unchanged so $`\Gamma_i = \Gamma_{i-1}`. The block opens a fresh +nested scope, so declarations made inside don't leak out — once the +block ends, the surrounding $`\Gamma` is restored. + +Statement-typed forms (`Var-Declare`, `Assign`, `Assert`, `Assume`, +`While`, `Exit`, `Return`, `IfThenElse`) trivially satisfy +$`\Gamma \vdash s_i \Leftarrow \mathsf{TVoid}` — their rule +conclusions are polymorphic in `A`, so they check at *any* type, +including $`\mathsf{TVoid}`. Bare expressions like `5;` fail via +\[⇐\] Sub: the synthesized type is not consistent with +$`\mathsf{TVoid}`. The Discard-Call carve-out is what allows the +standard `f(x);` idiom for a non-void-returning `f` — without it, +$`\mathit{head} \Leftarrow \mathsf{TVoid}` would force every call to +have a $`\mathsf{TVoid}`-compatible result type. + +Pushing $`T` into the last statement (rather than synthesizing the +whole block and applying \[⇐\] Sub at the boundary) means a type +mismatch is reported at the offending subexpression's source +location, and the expectation continues to propagate through nested +`Block` / `IfThenElse` / `Hole` / `Quantifier` constructs that have +their own check rules. + +$$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Skip)}` + +The empty block has a fixed type and is the only block-level rule that +synthesizes — written $`\mathsf{skip} : \mathsf{TVoid}` in the +source-language presentation. When an empty block appears in check +position with `expected ≠ TVoid`, the standard \[⇐\] Sub rule fires at +the boundary (requiring $`\mathsf{TVoid} <: \mathit{expected}`). + +{docstring Strata.Laurel.Resolution.Synth.emptyBlock} -Reading the premise: $`\Gamma_{i-1} \vdash s_i \Rightarrow \_ \dashv \Gamma_i` means $`s_i` -is resolved under the scope $`\Gamma_{i-1}` produced by its predecessor, synthesizes some -type (the `_` discards it — non-last statements are sequenced for effect, not value), and -produces a possibly extended scope $`\Gamma_i` that the next statement sees. In practice -only `Var (.Declare …)` actually extends the scope; every other construct leaves it -unchanged so $`\Gamma_i = \Gamma_{i-1}`. The *last* statement $`s_n` is checked against -the block's expected type $`T` rather than synthesizing freely. The block opens a fresh -nested scope, so declarations made inside don't leak out — once the block ends, the -surrounding $`\Gamma` is restored. +{docstring Strata.Laurel.Resolution.Check.block} -Discarding the types of non-last statements matches Java/Python/JavaScript, where -`f(x);` is a normal statement even when `f` returns a value. The trade-off is that a -stray expression like `5;` is silently accepted; flagging that belongs to a lint, not -the type checker. +$$`\frac{l \in \Gamma}{\Gamma \vdash \mathsf{Exit}\;l \Leftarrow A} \quad \text{([⇐] Exit)}` -Pushing $`T` into the tail (rather than synthesizing the whole block and applying -\[⇐\] Sub at the boundary) means a type mismatch is reported at the offending -subexpression's source location, and the expectation continues to propagate through -nested `Block` / `IfThenElse` / `Hole` / `Quantifier` constructs that have their own -check rules. +`exit` is non-returning — it transfers control out of the enclosing +labeled block, so it checks at *any* value type $`A` (no +$`\mathsf{TVoid}` side condition). -$$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Empty)}` +{docstring Strata.Laurel.Resolution.Check.exit} -With no last statement to push the expectation into, the empty-block check falls back to -a single subsumption test: an empty block is acceptable wherever `TVoid` is. +In the Return rules below, $`\overline{T_o}` denotes the declared +output-parameter type list of the enclosing procedure (an implicit +parameter of the rules — the procedure binds it once on entry). -{docstring Strata.Laurel.Resolution.Check.block} +$$`\frac{\overline{T_o} = []}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Leftarrow A} \quad \text{([⇐] Return-None-Void)}` -$$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Exit}\;\mathit{target} \Leftarrow T} \quad \text{([⇐] Exit)}` +$$`\frac{\overline{T_o} = [T] \quad \mathsf{TVoid} <:_\sim T}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Leftarrow A} \quad \text{([⇐] Return-None-Single)}` -{docstring Strata.Laurel.Resolution.Check.exit} +$$`\frac{\overline{T_o} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Leftarrow A} \quad \text{([⇐] Return-None-Multi)}` -$$`\frac{\mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Return}\;\mathsf{none} \Leftarrow T} \quad \text{([⇐] Return-None)}` +$$`\frac{\overline{T_o} = [T] \quad \Gamma \vdash e \Leftarrow T}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \Leftarrow A} \quad \text{([⇐] Return-Some)}` -$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_o] \quad \Gamma \vdash e \Leftarrow T_o \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \Leftarrow T} \quad \text{([⇐] Return-Some)}` +$$`\frac{\overline{T_o} = []}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “void procedure cannot return a value”}} \quad \text{([⇐] Return-Void-Error)}` -$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = []}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “void procedure cannot return a value”}} \quad \text{([⇐] Return-Void-Error)}` +$$`\frac{\overline{T_o} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇐] Return-Multi-Error)}` -$$`\frac{\Gamma_{\mathit{proc}}.\mathit{outputs} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇐] Return-Multi-Error)}` +`return` is the only rule whose premises depend on the enclosing +procedure's declared outputs. The conclusion's value type $`A` is +unconstrained, since `return` never falls through — it is a +control-flow terminator. The error arms fire when $`\overline{T_o}`'s +arity does not match the syntactic shape of `return e`. + +The three Return-None rules treat the missing payload as having type +$`\mathsf{TVoid}`. Void-output procedures accept it unconditionally +(Return-None-Void); single-output procedures require +$`\mathsf{TVoid} <:_\sim T` (Return-None-Single), accepting void +returns and rejecting `return;` in an `int`/`bool`/etc. procedure; +multi-output procedures accept it as an early-exit shorthand that +leaves the named outputs at whatever they were last assigned to +(Return-None-Multi). {docstring Strata.Laurel.Resolution.Check.return} -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{dec} \Leftarrow {?} \quad \Gamma \vdash \mathit{body} \Leftarrow T \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{dec}\;\mathit{body} \Leftarrow T} \quad \text{([⇐] While)}` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{decreases} \Rightarrow U \quad \mathit{Numeric}\;U \quad \Gamma \vdash \mathit{body} \Leftarrow \mathsf{Unknown}}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{decreases}\;\mathit{body} \Leftarrow A} \quad \text{([⇐] While)}` + +The body is checked at $`\mathsf{Unknown}`: control either re-enters +the loop or falls through, so the body's value type is never observed +by the surrounding context. The loop itself contributes nothing to +the surrounding $`A`, so its conclusion is polymorphic in $`A` like +every other statement-typed form. + +The optional $`\mathit{decreases}` clause is synthesized and required +to have a numeric type via the same $`\mathit{Numeric}` predicate +used by the arithmetic primitive operations. $`\mathit{Numeric}` is +a predicate (it admits $`\mathsf{TInt}`, $`\mathsf{TReal}`, +$`\mathsf{TFloat64}`, and $`\mathsf{Unknown}` as the gradual escape +hatch), not a single type, so the clause runs in synth mode rather +than check mode. {docstring Strata.Laurel.Resolution.Check.while} @@ -345,11 +416,11 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda tag := "rules-verification-statements" %%% -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Leftarrow T} \quad \text{([⇐] Assert)}` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assert}\;\mathit{cond} \Leftarrow A} \quad \text{([⇐] Assert)}` {docstring Strata.Laurel.Resolution.Check.assert} -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \mathsf{TVoid} <: T}{\Gamma \vdash \mathsf{Assume}\;\mathit{cond} \Leftarrow T} \quad \text{([⇐] Assume)}` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool}}{\Gamma \vdash \mathsf{Assume}\;\mathit{cond} \Leftarrow A} \quad \text{([⇐] Assume)}` {docstring Strata.Laurel.Resolution.Check.assume} @@ -358,19 +429,28 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \mathsf{TVo tag := "rules-assignment" %%% -$$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Leftarrow \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Leftarrow \mathsf{TVoid}} \quad \text{([⇐] Assign)}` +$$`\frac{\Gamma \vdash \mathit{targets}_i \Rightarrow T_i \quad \Gamma \vdash e \Leftarrow \mathit{ExpectedTy}}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathit{ExpectedTy}} \quad \text{([⇒] Assign)}` where `ExpectedTy = T_1` if `|targets| = 1` and `MultiValuedExpr [T_1; …; T_n]` otherwise. The target's declared type `T_i` comes from the variable's scope entry (for {name Strata.Laurel.Variable.Local}`Local` and {name Strata.Laurel.Variable.Field}`Field`) or from the {name Strata.Laurel.Variable.Declare}`Declare`-bound parameter type. The RHS receives `ExpectedTy` via `Check.resolveStmtExpr`, so bidirectional rules in the -RHS propagate the assignment's type into nested constructs. +RHS propagate the assignment's type into nested constructs. The +assignment synthesizes `ExpectedTy` — populating the surrounding +context with the target's type while the RHS is checked against it. -Assignment is strictly statement-position: `expected` must be `TVoid`. Expression-position -uses (e.g. `x ++ (y := s)`) are rejected — accepting an assignment as an expression with -its target's type as "value" silently masks impure-side-effect-inside-expression bugs -where the target type happens to coincide with the surrounding expectation. +{docstring Strata.Laurel.Resolution.Synth.assign} + +$$`\frac{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathit{ExpectedTy} \quad T = \mathsf{TVoid} \;\lor\; \mathit{ExpectedTy} <: T}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Leftarrow T} \quad \text{([⇐] Assign)}` + +The check rule synthesizes the assignment's type via \[⇒\] Assign +and then runs the standard \[⇐\] Sub boundary check `ExpectedTy <: T` +— *unless* `T = TVoid`, the marker for statement position. Pushing +`TVoid` through subsumption would only succeed when the LHS is itself +void, which would reject every non-void assignment used as a +statement, so the subsumption is skipped and the synthesized value is +discarded. {docstring Strata.Laurel.Resolution.Check.assign} @@ -379,13 +459,22 @@ where the target type happens to coincide with the surrounding expectation. tag := "rules-calls" %%% -$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Static-Call)}` +$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with input } T \text{ and output } T' \quad \Gamma \vdash \mathit{arg} \Rightarrow U \quad U <: T}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{arg} \Rightarrow T'} \quad \text{([⇒] Static-Call)}` $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` {docstring Strata.Laurel.Resolution.Synth.staticCall} -$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T] \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Instance-Call)}` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; T] \text{ and output } T' \quad \Gamma \vdash \mathit{arg} \Rightarrow U \quad U <: T}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{arg} \Rightarrow T'} \quad \text{([⇒] Instance-Call)}` + +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Instance-Call-Multi)}` + +The callee is resolved against either an instance procedure or a +static procedure (the latter handles uniformly-dispatched call syntax +where the receiver is forwarded as `self`). Output arity is forwarded +identically to +{name Strata.Laurel.Resolution.Synth.staticCall}`Synth.staticCall`'s +single-vs-multi split. {docstring Strata.Laurel.Resolution.Synth.instanceCall} @@ -398,7 +487,7 @@ tag := "rules-primitive-operations" {name Strata.Laurel.HighType.TReal}`TReal`, {name Strata.Laurel.HighType.TFloat64}`TFloat64`". -$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TBool} \quad \mathit{op} \in \{\mathsf{And}, \mathsf{Or}, \mathsf{AndThen}, \mathsf{OrElse}, \mathsf{Not}, \mathsf{Implies}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Bool)}` +$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TBool} \quad \mathit{op} \in \{\mathsf{And}, \mathsf{Or}, \mathsf{AndThen}, \mathsf{OrElse}, \mathsf{Not}, \mathsf{Implies}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Bool)}` $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \mathit{op} \in \{\mathsf{Lt}, \mathsf{Leq}, \mathsf{Gt}, \mathsf{Geq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Cmp)}` @@ -406,7 +495,23 @@ $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{ $$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \Gamma \vdash \mathit{args}.\mathsf{head} \Rightarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` -$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` +The arithmetic synth rule iterates over the candidate numeric types +$`\{\mathsf{TInt}, \mathsf{TReal}, \mathsf{TFloat64}\}` and picks the +first $`T` for which every operand bidirectionally checks at $`T`. +Diagnostics emitted by failed trials are rolled back so they don't +leak into the final error log; if every candidate fails, the *last* +trial's diagnostics are kept so the user sees a concrete error +message. The iteration is implemented via the `firstWorking` helper. + +This is symmetric in operand position (no privileged "first +argument"), and rejects mixed-numeric expressions like +$`\mathsf{int} + \mathsf{real}` — neither $`\mathsf{TInt}` nor +$`\mathsf{TReal}` admits both operands. The gradual escape hatch +$`\mathsf{Unknown}` is *not* in the candidate list (one should not +synthesize a wildcard) but operands of type $`\mathsf{Unknown}` are +accepted by every check via the standard consistency rule. + +$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` {docstring Strata.Laurel.Resolution.Synth.primitiveOp} @@ -432,11 +537,11 @@ $$`\frac{\Gamma(\mathit{ref}) \text{ is not a composite or datatype}}{\Gamma \vd {docstring Strata.Laurel.Resolution.Synth.new} -$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{AsType}\;\mathit{target}\;T \Rightarrow T} \quad \text{([⇒] AsType)}` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow U \quad U \sim T \;\vee\; U <: T \;\vee\; T <: U}{\Gamma \vdash \mathsf{AsType}\;\mathit{target}\;T \Rightarrow T} \quad \text{([⇒] AsType)}` {docstring Strata.Laurel.Resolution.Synth.asType} -$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow U \quad U \sim T \;\vee\; U <: T \;\vee\; T <: U}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` {docstring Strata.Laurel.Resolution.Synth.isType} From 9ec6dec872b97a7382eb6073b4c2e6e7a90e1130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 27 May 2026 11:12:09 -0400 Subject: [PATCH 133/189] first try at arithmetic typing --- Strata/Languages/Laurel/Resolution.lean | 184 ++++++++++++------ .../Fundamentals/T23_ArithTyping.lean | 101 ++++++++++ docs/verso/LaurelDoc.lean | 11 +- 3 files changed, 232 insertions(+), 64 deletions(-) create mode 100644 StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 36df29b3d1..7a09afa2bc 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -268,10 +268,15 @@ structure ResolveState where /-- When resolving inside an instance procedure, the owning composite type name. Used by `resolveFieldRef` to resolve `self.field` when `self` has type `Any`. -/ instanceTypeName : Option String := none - /-- When resolving inside a procedure body, the declared output types (in - declaration order). `none` means no enclosing procedure. Used by `Return` - to type-check the optional return value and to flag arity/shape mismatches. -/ - expectedReturnTypes : Option (List HighTypeMd) := none + /-- The declared output types of the enclosing procedure body, in + declaration order. `none` means we are not currently resolving + inside any procedure body (e.g. while resolving a constant + initializer); in that case `Return` cannot occur and is not + type-checked. Bound by `resolveProcedure` / + `resolveInstanceProcedure` on entry, restored on exit, and read + only by `Check.return` to type-check the optional payload of + `return e`. -/ + answerType : Option (List HighTypeMd) := none /-- Type-relation tables (alias/constrained unfolding + composite extending chains) used by the subtyping/consistency checks. Built once from `program.types` at the start of `resolve`. -/ @@ -467,6 +472,49 @@ private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := | .TInt | .TReal | .TFloat64 | .Unknown => true | _ => false +/-- The set of concrete numeric types that arithmetic operands must + inhabit, in priority order. The `Unknown` accepted by `isNumeric` as + a gradual escape hatch is *not* in this list — `Unknown` is a + wildcard, not a result type one should synthesize. + + Used by [⇒] Op-Arith via `firstWorking` to discover the result type + of an arithmetic expression. -/ +private def numericCandidates (source : Option FileRange) : List HighTypeMd := + [ { val := .TInt, source := source } + , { val := .TReal, source := source } + , { val := .TFloat64, source := source } ] + +/-- Try `attempts` in order, returning the first one whose action runs + without emitting any new diagnostics. Each trial runs against a + fresh copy of the diagnostic state (snapshotted before the trial) + and is rolled back if the trial emits errors; the *successful* + trial commits its state changes — including any state mutations + other than `errors`. If every trial fails, the diagnostics from + the *last* attempt are kept (so the user sees a concrete error + message rather than a blank failure). + + The trial-then-rollback pattern is what makes [⇒] Op-Arith feasible: + we can iterate over `numericCandidates` and bidirectionally check + every operand against each candidate, picking the first candidate + where every check succeeds — without the failed trials' diagnostics + leaking into the final error log. -/ +private def firstWorking {α : Type} + (attempts : List (ResolveM α)) : ResolveM (Option α) := do + let snapshotBefore ← get + let mut lastSnapshot := snapshotBefore + for attempt in attempts do + set snapshotBefore + let result ← attempt + let after ← get + if after.errors.size = snapshotBefore.errors.size then + return some result + lastSnapshot := after + -- Every trial emitted at least one diagnostic; restore the final + -- attempt's state so the user sees the *last* candidate's errors + -- rather than only the first. + set lastSnapshot + return none + /-- Test whether a type is a user-defined reference type. `Unknown` is accepted as a gradual escape hatch. Used by Fresh and ReferenceEquals, which only make sense on composite/datatype references. -/ @@ -1348,9 +1396,7 @@ def Synth.instanceCall (exprMd : StmtExprMd) -- ### Primitive operations -/-- Cases on the operator family. All operands are synthesized first; - then a per-family verification fires using `checkSubtype` (a - post-synth subtype test, not bidirectional check resolution). +/-- Cases on the operator family. `Γ ⊢ args_i ⇒ U_i, U_i <: TBool, op ∈ {And, Or, AndThen, OrElse, Not, Implies} ∴ Γ ⊢ PrimitiveOp op args ⇒ TBool` @@ -1358,7 +1404,7 @@ def Synth.instanceCall (exprMd : StmtExprMd) `Γ ⊢ lhs ⇒ T_l, Γ ⊢ rhs ⇒ T_r, T_l ~ T_r, op ∈ {Eq, Neq} ∴ Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool` - `Γ ⊢ args_i ⇒ U_i, Numeric U_i, Γ ⊢ args.head ⇒ T, op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇒ T` + `T ∈ {TInt, TReal, TFloat64}, Γ ⊢ args_i ⇐ T, op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇒ T` `Γ ⊢ args_i ⇒ U_i, U_i <: TString, op = StrConcat ∴ Γ ⊢ PrimitiveOp op args ⇒ TString` @@ -1368,67 +1414,87 @@ def Synth.instanceCall (exprMd : StmtExprMd) symmetric consistency under the gradual relation, so equality has no privileged operand direction. - The result type is `TBool` for booleans/comparisons/equality, the - head argument's type for arithmetic ("result is the type of the - first argument" handles `int + int → int`, `real + real → real`, - etc. without unification — known relaxation: `int + real` passes - since each operand individually passes `Numeric`; a proper fix needs - numeric promotion or unification), `TString` for concatenation. - - The arithmetic and boolean families also have a check-mode rule - (`Check.primitiveOp`) which is preferred when an `expected` type is - available: it pushes the operand type into each operand via - `Check.resolveStmtExpr`, surfacing operand-shaped errors at their - natural location instead of via the gradual subsumption fallthrough. - `Synth.primitiveOp` remains the entry point for synth-position - primitive ops (e.g. an unannotated `var x := a + b` or use as an - operand of a non-Check.primitiveOp construct). -/ + The result type is `TBool` for booleans/comparisons/equality, and + `TString` for concatenation. Boolean / Cmp / Eq / Concat all + synthesize operands first, then run a per-family check + (`checkSubtype` for boolean and concat, `isNumeric` for cmp, + `isConsistent` for equality). + + Arithmetic uses a different scheme: `firstWorking` iterates over + `numericCandidates` (currently `[TInt, TReal, TFloat64]` — `Unknown` + is excluded since one should not synthesize a wildcard) and + bidirectionally checks every operand against each candidate. The + first `T` where every check succeeds is the synthesized result + type. Failed trials are rolled back so their diagnostics don't + leak; if every trial fails, the *last* trial's diagnostics are + kept so the user sees a concrete error. + + The boolean family additionally has a check-mode rule + (`Check.primitiveOp`) preferred when an `expected` type is + available; it pushes `TBool` into operands via + `Check.resolveStmtExpr` instead of synth-then-`checkSubtype`, + surfacing operand-shaped errors at their natural location. -/ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) (op : Operation) (args : List StmtExprMd) (source : Option FileRange) (h_expr : expr = .PrimitiveOp op args) (h : exprMd.val = .PrimitiveOp op args) : ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr -- carries the constructor identity for `expr` in diagnostics - let results ← args.mapM Synth.resolveStmtExpr - let args' := results.map (·.1) - let argTypes := results.map (·.2) - let resultTy := match op with - | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies - | .Lt | .Leq | .Gt | .Geq => HighType.TBool - | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => - match argTypes.head? with - | some headTy => headTy.val - | none => HighType.TInt - | .StrConcat => HighType.TString match op with - | .And | .Or | .AndThen | .OrElse | .Not | .Implies => - for (a, aTy) in args'.zip argTypes do - checkSubtype a.source { val := .TBool, source := a.source } aTy - | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT | .Lt | .Leq | .Gt | .Geq => - let ctx := (← get).typeContext - for (a, aTy) in args'.zip argTypes do - unless isNumeric ctx aTy do - typeMismatch a.source (some expr) "expected a numeric type" aTy - | .Eq | .Neq => - match argTypes with - | [lhsTy, rhsTy] => + -- Arithmetic: iterate over candidate numeric types via `firstWorking`, + -- bidirectionally checking every operand against each candidate. + | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => + let attempts : List (ResolveM (List StmtExprMd × HighTypeMd)) := + (numericCandidates source).map fun candidate => do + let args' ← args.attach.mapM (fun a => have := a.property; do + Check.resolveStmtExpr a.val candidate) + pure (args', candidate) + match ← firstWorking attempts with + | some (args', resultTy) => pure (.PrimitiveOp op args', resultTy) + | none => + pure (.PrimitiveOp op args, { val := .Unknown, source := source }) + | _ => + let results ← args.attach.mapM (fun a => have := a.property; do + Synth.resolveStmtExpr a.val) + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let resultTy := match op with + | .Eq | .Neq | .And | .Or | .AndThen | .OrElse | .Not | .Implies + | .Lt | .Leq | .Gt | .Geq => HighType.TBool + | .StrConcat => HighType.TString + -- Unreachable: filtered above. + | _ => HighType.Unknown + match op with + | .And | .Or | .AndThen | .OrElse | .Not | .Implies => + for (a, aTy) in args'.zip argTypes do + checkSubtype a.source { val := .TBool, source := a.source } aTy + | .Lt | .Leq | .Gt | .Geq => let ctx := (← get).typeContext - unless isConsistent ctx lhsTy rhsTy do - let diag := diagnosticFromSource source - s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" - modify fun s => { s with errors := s.errors.push diag } - | _ => pure () - | .StrConcat => - for (a, aTy) in args'.zip argTypes do - checkSubtype a.source { val := .TString, source := a.source } aTy - pure (.PrimitiveOp op args', { val := resultTy, source := source }) + for (a, aTy) in args'.zip argTypes do + unless isNumeric ctx aTy do + typeMismatch a.source (some expr) "expected a numeric type" aTy + | .Eq | .Neq => + match argTypes with + | [lhsTy, rhsTy] => + let ctx := (← get).typeContext + unless isConsistent ctx lhsTy rhsTy do + let diag := diagnosticFromSource source + s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" + modify fun s => { s with errors := s.errors.push diag } + | _ => pure () + | .StrConcat => + for (a, aTy) in args'.zip argTypes do + checkSubtype a.source { val := .TString, source := a.source } aTy + | _ => pure () -- unreachable + pure (.PrimitiveOp op args', { val := resultTy, source := source }) termination_by (exprMd, 1) decreasing_by - apply Prod.Lex.left - have hsz := exprMd.sizeOf_val_lt - simp [h] at hsz - have := List.sizeOf_lt_of_mem ‹_ ∈ args› - omega + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + have := List.sizeOf_lt_of_mem ‹_ ∈ args› + omega /-- Cases on the operator family. diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean new file mode 100644 index 0000000000..cf52a1e81b --- /dev/null +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean @@ -0,0 +1,101 @@ +/- + Copyright Strata Contributors + + SPDX-License-Identifier: Apache-2.0 OR MIT +-/ + +import StrataTest.Util.TestDiagnostics +import StrataTest.Languages.Laurel.TestExamples + +open StrataTest.Util + +namespace Strata +namespace Laurel + +/-! Documents the current behaviour of the arithmetic typing rules. + + Two rules apply: + + - [⇐] Op-Arith — the *check* path. When the surrounding context + provides an expected type (e.g. an annotated `var` declaration, + a procedure parameter, an assignment target), the arithmetic + expression is checked at that type, and the type is pushed into + every operand. The error surfaces as "expected '', got + ''" at the offending operand. + + - [⇒] Op-Arith — the *synth* path. When no expected type is + available (e.g. the arithmetic expression appears as the operand + of a comparison, or its result is used in synth position), the + rule iterates over `numericCandidates` (= `[TInt, TReal, + TFloat64]`) and picks the first `T` for which every operand + bidirectionally checks at `T`. Failed trials are rolled back; if + every candidate fails, the *last* trial's diagnostics are kept. + + Homogeneous numeric operands type-check via either path. + Heterogeneous numeric operands (e.g. `int + real`) are rejected + by both paths. The gradual `Unknown` wildcard flows freely. -/ + +def arithTypingProgram := r" +procedure homogeneousInt() + opaque +{ + var x: int := 1 + 2; + assert x == 3 +}; + +procedure homogeneousReal() + opaque +{ + var x: real := 1.5 + 2.5; + assert x == 4.0 +}; + +// [⇐] Op-Arith path: the outer 'real' expectation is pushed into both +// operands. Operand '1' synthesizes 'int' and fails the check. +procedure heterogeneousCheckPath() + opaque +{ + var x: real := 1 + 2.0 +// ^ error: expected 'real', got 'int' +}; + +// [⇒] Op-Arith path: '<' synthesizes 'TBool', so its operands are in +// synth position. The arithmetic operand '1 + 2.0' iterates over +// `numericCandidates`. Every candidate fails (no T admits both 'int' +// and 'real'); the diagnostics from the *last* trial (TFloat64) are +// kept. +procedure heterogeneousSynthPath() + opaque +{ + assert (1 + 2.0) < 5 +// ^ error: expected 'float64', got 'int' +// ^^^ error: expected 'float64', got 'real' +}; + +procedure unaryNegHomogeneous() + opaque +{ + var a: int := 5; + var b: int := -a; + var c: real := 1.5; + var d: real := -c; + assert b == 0 - 5; + assert d == 0.0 - 1.5 +}; + +// Unknown (here from the unresolved name 'mystery') flows freely +// through both candidate trials, so the synth iteration succeeds at +// the first candidate (TInt). The 'mystery is not defined' diagnostic +// is the *only* error. +procedure unknownFlowsFreely() + opaque +{ + assert (mystery + 1) == 1 +// ^^^^^^^ error: 'mystery' is not defined +}; +" + +#guard_msgs(drop info, error) in +#eval testInputWithOffset "ArithTyping" arithTypingProgram 14 processLaurelFile + +end Laurel diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 5d4766b7d8..02e3d39f57 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -489,11 +489,11 @@ tag := "rules-primitive-operations" $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TBool} \quad \mathit{op} \in \{\mathsf{And}, \mathsf{Or}, \mathsf{AndThen}, \mathsf{OrElse}, \mathsf{Not}, \mathsf{Implies}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Bool)}` -$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \mathit{op} \in \{\mathsf{Lt}, \mathsf{Leq}, \mathsf{Gt}, \mathsf{Geq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Cmp)}` +$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathit{Numeric}\;U_i \quad \mathit{op} \in \{\mathsf{Lt}, \mathsf{Leq}, \mathsf{Gt}, \mathsf{Geq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Cmp)}` $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad T_l \sim T_r \quad \mathit{op} \in \{\mathsf{Eq}, \mathsf{Neq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;[\mathit{lhs}; \mathit{rhs}] \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Eq)}` -$$`\frac{\Gamma \vdash \mathit{args}_i \Leftarrow \mathit{Numeric} \quad \Gamma \vdash \mathit{args}.\mathsf{head} \Rightarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` +$$`\frac{T \in \{\mathsf{TInt}, \mathsf{TReal}, \mathsf{TFloat64}\} \quad \Gamma \vdash \mathit{args}_i \Leftarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` The arithmetic synth rule iterates over the candidate numeric types $`\{\mathsf{TInt}, \mathsf{TReal}, \mathsf{TFloat64}\}` and picks the @@ -515,9 +515,10 @@ $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TStr {docstring Strata.Laurel.Resolution.Synth.primitiveOp} -The arithmetic and boolean families also have a check-mode rule, used when the -surrounding context provides an `expected` type. The rule pushes the operand type -into each operand via `Check.resolveStmtExpr`, replacing the synth-then-`checkSubtype` +The arithmetic and boolean families also have a check-mode rule, used +when the surrounding context provides an `expected` type. The rule +pushes the operand type into each operand via +`Check.resolveStmtExpr`, replacing the synth-then-`checkSubtype` discipline with bidirectional check. $$`\frac{\mathit{Numeric}\;T \quad \Gamma \vdash \mathit{args}_i \Leftarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Leftarrow T} \quad \text{([⇐] Op-Arith)}` From d4b0d62aff58cc5540c1d41d52283468c19c2a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 27 May 2026 11:51:52 -0400 Subject: [PATCH 134/189] arith operators --- Strata/Languages/Laurel/Resolution.lean | 135 +++++++++--------- .../Fundamentals/T23_ArithTyping.lean | 14 +- docs/verso/LaurelDoc.lean | 28 ++-- 3 files changed, 91 insertions(+), 86 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 7a09afa2bc..41c8134110 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -477,43 +477,27 @@ private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := a gradual escape hatch is *not* in this list — `Unknown` is a wildcard, not a result type one should synthesize. - Used by [⇒] Op-Arith via `firstWorking` to discover the result type - of an arithmetic expression. -/ + Used by [⇒] Op-Arith to discover the result type of an arithmetic + expression: the rule synthesizes operand types and picks the first + candidate that all operand types are consistent subtypes of. -/ private def numericCandidates (source : Option FileRange) : List HighTypeMd := [ { val := .TInt, source := source } , { val := .TReal, source := source } , { val := .TFloat64, source := source } ] -/-- Try `attempts` in order, returning the first one whose action runs - without emitting any new diagnostics. Each trial runs against a - fresh copy of the diagnostic state (snapshotted before the trial) - and is rolled back if the trial emits errors; the *successful* - trial commits its state changes — including any state mutations - other than `errors`. If every trial fails, the diagnostics from - the *last* attempt are kept (so the user sees a concrete error - message rather than a blank failure). - - The trial-then-rollback pattern is what makes [⇒] Op-Arith feasible: - we can iterate over `numericCandidates` and bidirectionally check - every operand against each candidate, picking the first candidate - where every check succeeds — without the failed trials' diagnostics - leaking into the final error log. -/ -private def firstWorking {α : Type} - (attempts : List (ResolveM α)) : ResolveM (Option α) := do - let snapshotBefore ← get - let mut lastSnapshot := snapshotBefore - for attempt in attempts do - set snapshotBefore - let result ← attempt - let after ← get - if after.errors.size = snapshotBefore.errors.size then - return some result - lastSnapshot := after - -- Every trial emitted at least one diagnostic; restore the final - -- attempt's state so the user sees the *last* candidate's errors - -- rather than only the first. - set lastSnapshot - return none +/-- Find the first candidate `T` from `cands` such that every operand + type in `argTypes` is a consistent subtype of `T`. Returns `none` + if no candidate works. + + Used by [⇒] Op-Arith. The lookup is purely predicative — no state + is mutated during the search, so failed candidates don't leak any + diagnostic side-effects. The gradual `Unknown` operand type is + consistent with every candidate, so an `Unknown` operand never + blocks the iteration. -/ +private def findCommonNumericType (ctx : TypeContext) + (cands : List HighTypeMd) (argTypes : List HighTypeMd) : Option HighTypeMd := + cands.find? fun cand => + argTypes.all fun aTy => isConsistentSubtype ctx aTy cand /-- Test whether a type is a user-defined reference type. `Unknown` is accepted as a gradual escape hatch. Used by Fresh and ReferenceEquals, which only @@ -1333,27 +1317,34 @@ def Check.assign (exprMd : StmtExprMd) /-- Cases on the arity of the callee's declared outputs. - `Γ(callee) = static-procedure with input T and output T', Γ ⊢ arg ⇒ U, U <: T ∴ Γ ⊢ StaticCall callee arg ⇒ T'` + `Γ(callee) = static-procedure with input T and output T', Γ ⊢ arg ⇐ T ∴ Γ ⊢ StaticCall callee arg ⇒ T'` - `Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n] (n ≠ 1), Γ ⊢ args ⇒ Us, U_i <: T_i (pairwise) ∴ Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n]` + `Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n] (n ≠ 1), Γ ⊢ args_i ⇐ Ts_i (pairwise) ∴ Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n]` Callee is resolved against the expected kinds (parameter, static procedure, datatype constructor, constant); each argument is - synthesized and checked against the corresponding parameter type. The - result type is the (possibly multi-valued) declared output type from - `getCallInfo`. -/ + *checked* against the corresponding parameter type. The bidirectional + push lets impure-expression arguments (`{x := 1; x}`, `if c then …`, + holes) flow through their own check rules instead of bottoming out at + the synth wildcard. Arguments past the declared parameter list (or + when the callee is unresolved and `paramTypes = []`) are checked + against `Unknown`, the gradual escape hatch — this preserves the old + behavior of resolving args without flagging arity mismatches here. + The result type is the (possibly multi-valued) declared output type + from `getCallInfo`. -/ def Synth.staticCall (exprMd : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) (h : exprMd.val = .StaticCall callee args) : ResolveM (StmtExpr × HighTypeMd) := do let callee' ← resolveRef callee source (expected := #[.parameter, .staticProcedure, .datatypeConstructor, .constant]) - let results ← args.mapM Synth.resolveStmtExpr - let args' := results.map (·.1) - let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee - for ((a, aTy), paramTy) in (args'.zip argTypes).zip paramTypes do - checkSubtype a.source paramTy aTy + let unknownTy : HighTypeMd := { val := .Unknown, source := none } + let expectedTys : List HighTypeMd := + paramTypes ++ List.replicate (args.length - paramTypes.length) unknownTy + let args' ← (args.attach.zip expectedTys).mapM (fun (⟨a, hMem⟩, paramTy) => do + have := hMem + Check.resolveStmtExpr a paramTy) pure (.StaticCall callee' args', retTy) termination_by (exprMd, 1) decreasing_by @@ -1363,11 +1354,13 @@ def Synth.staticCall (exprMd : StmtExprMd) have := List.sizeOf_lt_of_mem ‹_ ∈ args› omega -/-- `Γ ⊢ target ⇒ _, Γ(callee) = instance- or static-procedure with inputs [self; T] and output T', Γ ⊢ arg ⇒ U, U <: T ∴ Γ ⊢ InstanceCall target callee arg ⇒ T'` +/-- `Γ ⊢ target ⇒ _, Γ(callee) = instance- or static-procedure with inputs [self; T] and output T', Γ ⊢ arg ⇐ T ∴ Γ ⊢ InstanceCall target callee arg ⇒ T'` Target is synthesized; callee resolves to an instance or static procedure; arguments are checked pairwise against the callee's - parameter types after dropping `self`. -/ + parameter types after dropping `self`. Like `Synth.staticCall`, the + push is bidirectional so block- and conditional-shaped arguments + route through their own check rules. -/ def Synth.instanceCall (exprMd : StmtExprMd) (target : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) @@ -1376,13 +1369,14 @@ def Synth.instanceCall (exprMd : StmtExprMd) let (target', _) ← Synth.resolveStmtExpr target let callee' ← resolveRef callee source (expected := #[.instanceProcedure, .staticProcedure]) - let results ← args.mapM Synth.resolveStmtExpr - let args' := results.map (·.1) - let argTypes := results.map (·.2) let (retTy, paramTypes) ← getCallInfo callee let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] - for ((a, aTy), paramTy) in (args'.zip argTypes).zip callParamTypes do - checkSubtype a.source paramTy aTy + let unknownTy : HighTypeMd := { val := .Unknown, source := none } + let expectedTys : List HighTypeMd := + callParamTypes ++ List.replicate (args.length - callParamTypes.length) unknownTy + let args' ← (args.attach.zip expectedTys).mapM (fun (⟨a, hMem⟩, paramTy) => do + have := hMem + Check.resolveStmtExpr a paramTy) pure (.InstanceCall target' callee' args', retTy) termination_by (exprMd, 1) decreasing_by @@ -1420,14 +1414,16 @@ def Synth.instanceCall (exprMd : StmtExprMd) (`checkSubtype` for boolean and concat, `isNumeric` for cmp, `isConsistent` for equality). - Arithmetic uses a different scheme: `firstWorking` iterates over - `numericCandidates` (currently `[TInt, TReal, TFloat64]` — `Unknown` - is excluded since one should not synthesize a wildcard) and - bidirectionally checks every operand against each candidate. The - first `T` where every check succeeds is the synthesized result - type. Failed trials are rolled back so their diagnostics don't - leak; if every trial fails, the *last* trial's diagnostics are - kept so the user sees a concrete error. + Arithmetic uses a different scheme: it synthesizes its operand + types once, then picks the first candidate from + `numericCandidates` (currently `[TInt, TReal, TFloat64]` — + `Unknown` is excluded since one should not synthesize a wildcard) + that every operand type is a consistent subtype of. The lookup + runs `findCommonNumericType`, a pure predicate, so failed + candidates do not leak any diagnostic side-effects. If no + candidate works, a single "no common numeric type" diagnostic + fires at the operator's source position, listing the operand + types so the user can see the mismatch. The boolean family additionally has a check-mode rule (`Check.primitiveOp`) preferred when an `expected` type is @@ -1441,18 +1437,23 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr -- carries the constructor identity for `expr` in diagnostics match op with - -- Arithmetic: iterate over candidate numeric types via `firstWorking`, - -- bidirectionally checking every operand against each candidate. + -- Arithmetic: synth operand types once, then pick the first numeric + -- candidate that every operand is a consistent subtype of. | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => - let attempts : List (ResolveM (List StmtExprMd × HighTypeMd)) := - (numericCandidates source).map fun candidate => do - let args' ← args.attach.mapM (fun a => have := a.property; do - Check.resolveStmtExpr a.val candidate) - pure (args', candidate) - match ← firstWorking attempts with - | some (args', resultTy) => pure (.PrimitiveOp op args', resultTy) + let results ← args.attach.mapM (fun a => have := a.property; do + Synth.resolveStmtExpr a.val) + let args' := results.map (·.1) + let argTypes := results.map (·.2) + let ctx := (← get).typeContext + match findCommonNumericType ctx (numericCandidates source) argTypes with + | some resultTy => + pure (.PrimitiveOp op args', resultTy) | none => - pure (.PrimitiveOp op args, { val := .Unknown, source := source }) + let formatted := ", ".intercalate (argTypes.map formatType) + let diag := diagnosticFromSource source + s!"no common numeric type for operands of '{op}'; got [{formatted}]" + modify fun s => { s with errors := s.errors.push diag } + pure (.PrimitiveOp op args', { val := .Unknown, source := source }) | _ => let results ← args.attach.mapM (fun a => have := a.property; do Synth.resolveStmtExpr a.val) diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean index cf52a1e81b..6c42e05c8a 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean @@ -59,17 +59,17 @@ procedure heterogeneousCheckPath() // ^ error: expected 'real', got 'int' }; -// [⇒] Op-Arith path: '<' synthesizes 'TBool', so its operands are in -// synth position. The arithmetic operand '1 + 2.0' iterates over -// `numericCandidates`. Every candidate fails (no T admits both 'int' -// and 'real'); the diagnostics from the *last* trial (TFloat64) are -// kept. +// [⇒] Op-Arith path: '<' synthesizes its operands, so '1 + 2.0' is +// resolved in synth position. The arithmetic synth rule synthesizes +// the operands ('int' and 'real') and looks for a candidate from +// [TInt, TReal, TFloat64] that both are consistent subtypes of. None +// works -- a single error fires at the operator's source position +// listing the operand types. procedure heterogeneousSynthPath() opaque { assert (1 + 2.0) < 5 -// ^ error: expected 'float64', got 'int' -// ^^^ error: expected 'float64', got 'real' +// ^^^^^^^ error: no common numeric type for operands of '+' }; procedure unaryNegHomogeneous() diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 02e3d39f57..e051cec10e 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -493,23 +493,27 @@ $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathit{Numeric}\;U $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad T_l \sim T_r \quad \mathit{op} \in \{\mathsf{Eq}, \mathsf{Neq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;[\mathit{lhs}; \mathit{rhs}] \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Eq)}` -$$`\frac{T \in \{\mathsf{TInt}, \mathsf{TReal}, \mathsf{TFloat64}\} \quad \Gamma \vdash \mathit{args}_i \Leftarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` - -The arithmetic synth rule iterates over the candidate numeric types -$`\{\mathsf{TInt}, \mathsf{TReal}, \mathsf{TFloat64}\}` and picks the -first $`T` for which every operand bidirectionally checks at $`T`. -Diagnostics emitted by failed trials are rolled back so they don't -leak into the final error log; if every candidate fails, the *last* -trial's diagnostics are kept so the user sees a concrete error -message. The iteration is implemented via the `firstWorking` helper. +$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad T \in \{\mathsf{TInt}, \mathsf{TReal}, \mathsf{TFloat64}\} \quad U_i \lesssim T \text{ (pairwise)} \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` + +The arithmetic synth rule synthesizes operand types once, then picks +the first $`T` from the candidate list +$`\{\mathsf{TInt}, \mathsf{TReal}, \mathsf{TFloat64}\}` that every +operand type is a consistent subtype of (here written +$`U_i \lesssim T` for {name Strata.Laurel.isConsistentSubtype}`isConsistentSubtype`). +The lookup runs `findCommonNumericType`, a pure predicate, so failed +candidates do not mutate the diagnostic state. If no candidate +works, a single "no common numeric type" diagnostic fires at the +operator's source position, listing the operand types so the +mismatch is concrete. This is symmetric in operand position (no privileged "first -argument"), and rejects mixed-numeric expressions like +argument") and rejects mixed-numeric expressions like $`\mathsf{int} + \mathsf{real}` — neither $`\mathsf{TInt}` nor $`\mathsf{TReal}` admits both operands. The gradual escape hatch $`\mathsf{Unknown}` is *not* in the candidate list (one should not -synthesize a wildcard) but operands of type $`\mathsf{Unknown}` are -accepted by every check via the standard consistency rule. +synthesize a wildcard) but $`\mathsf{Unknown} \lesssim T` for every +$`T`, so an operand of type $`\mathsf{Unknown}` never blocks the +iteration. $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` From b5d4190cfc29cec5fc36d75eeb89c2b3b04ed724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 27 May 2026 13:20:27 -0400 Subject: [PATCH 135/189] update rules documentation --- Strata/Languages/Laurel/Resolution.lean | 2 +- docs/verso/LaurelDoc.lean | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 41c8134110..189b8bcb36 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1398,7 +1398,7 @@ def Synth.instanceCall (exprMd : StmtExprMd) `Γ ⊢ lhs ⇒ T_l, Γ ⊢ rhs ⇒ T_r, T_l ~ T_r, op ∈ {Eq, Neq} ∴ Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool` - `T ∈ {TInt, TReal, TFloat64}, Γ ⊢ args_i ⇐ T, op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇒ T` + `Γ ⊢ args_i ⇒ U_i, T ∈ {TInt, TReal, TFloat64}, U_i <:_∼ T (pairwise), op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇒ T` `Γ ⊢ args_i ⇒ U_i, U_i <: TString, op = StrConcat ∴ Γ ⊢ PrimitiveOp op args ⇒ TString` diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index e051cec10e..c64349021b 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -459,15 +459,15 @@ discarded. tag := "rules-calls" %%% -$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with input } T \text{ and output } T' \quad \Gamma \vdash \mathit{arg} \Rightarrow U \quad U <: T}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{arg} \Rightarrow T'} \quad \text{([⇒] Static-Call)}` +$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with input } T \text{ and output } T' \quad \Gamma \vdash \mathit{arg} \Leftarrow T}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{arg} \Rightarrow T'} \quad \text{([⇒] Static-Call)}` -$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` +$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` {docstring Strata.Laurel.Resolution.Synth.staticCall} -$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; T] \text{ and output } T' \quad \Gamma \vdash \mathit{arg} \Rightarrow U \quad U <: T}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{arg} \Rightarrow T'} \quad \text{([⇒] Instance-Call)}` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; T] \text{ and output } T' \quad \Gamma \vdash \mathit{arg} \Leftarrow T}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{arg} \Rightarrow T'} \quad \text{([⇒] Instance-Call)}` -$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args} \Rightarrow Us \quad U_i <: T_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Instance-Call-Multi)}` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Instance-Call-Multi)}` The callee is resolved against either an instance procedure or a static procedure (the latter handles uniformly-dispatched call syntax From f0e535170c0ea5b38b91dd71d4338df8b060d286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 27 May 2026 14:26:20 -0400 Subject: [PATCH 136/189] simplify arith operator type inference --- Strata/Languages/Laurel/Resolution.lean | 95 ++++++++++--------- .../Fundamentals/T23_ArithTyping.lean | 30 +++--- docs/verso/LaurelDoc.lean | 38 ++++---- 3 files changed, 82 insertions(+), 81 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 189b8bcb36..6b8e7df641 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -472,32 +472,24 @@ private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := | .TInt | .TReal | .TFloat64 | .Unknown => true | _ => false -/-- The set of concrete numeric types that arithmetic operands must - inhabit, in priority order. The `Unknown` accepted by `isNumeric` as - a gradual escape hatch is *not* in this list — `Unknown` is a - wildcard, not a result type one should synthesize. - - Used by [⇒] Op-Arith to discover the result type of an arithmetic - expression: the rule synthesizes operand types and picks the first - candidate that all operand types are consistent subtypes of. -/ -private def numericCandidates (source : Option FileRange) : List HighTypeMd := - [ { val := .TInt, source := source } - , { val := .TReal, source := source } - , { val := .TFloat64, source := source } ] - -/-- Find the first candidate `T` from `cands` such that every operand - type in `argTypes` is a consistent subtype of `T`. Returns `none` - if no candidate works. - - Used by [⇒] Op-Arith. The lookup is purely predicative — no state - is mutated during the search, so failed candidates don't leak any - diagnostic side-effects. The gradual `Unknown` operand type is - consistent with every candidate, so an `Unknown` operand never - blocks the iteration. -/ -private def findCommonNumericType (ctx : TypeContext) - (cands : List HighTypeMd) (argTypes : List HighTypeMd) : Option HighTypeMd := - cands.find? fun cand => - argTypes.all fun aTy => isConsistentSubtype ctx aTy cand +/-- Least upper bound of two types under the consistency relation + (Siek–Taha). On Laurel's flat lattice the LUB collapses to the + "more informative" side: `Unknown` and `T` yields `T`; equal + types (after unfolding) yield themselves; everything else is + inconsistent and yields `none`. + + Used by [⇒] Op-Arith to fold operand types into a single result + type: a homogeneous arithmetic expression `1 + 2` yields `TInt`, + `1 + ` yields `TInt` (Unknown promotes), ` + ` yields + `Unknown`, and `1 + 2.0` is rejected. -/ +private def consistencyLub (ctx : TypeContext) + (a b : HighTypeMd) : Option HighTypeMd := + let a' := ctx.unfold a + let b' := ctx.unfold b + match a'.val, b'.val with + | .Unknown, _ => some b + | _, .Unknown => some a + | _, _ => if highEq a' b' then some a else none /-- Test whether a type is a user-defined reference type. `Unknown` is accepted as a gradual escape hatch. Used by Fresh and ReferenceEquals, which only @@ -1398,7 +1390,7 @@ def Synth.instanceCall (exprMd : StmtExprMd) `Γ ⊢ lhs ⇒ T_l, Γ ⊢ rhs ⇒ T_r, T_l ~ T_r, op ∈ {Eq, Neq} ∴ Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool` - `Γ ⊢ args_i ⇒ U_i, T ∈ {TInt, TReal, TFloat64}, U_i <:_∼ T (pairwise), op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇒ T` + `Γ ⊢ args_i ⇒ U_i, Numeric U_i, T = ⨆ U_i (consistency LUB), op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇒ T` `Γ ⊢ args_i ⇒ U_i, U_i <: TString, op = StrConcat ∴ Γ ⊢ PrimitiveOp op args ⇒ TString` @@ -1414,16 +1406,14 @@ def Synth.instanceCall (exprMd : StmtExprMd) (`checkSubtype` for boolean and concat, `isNumeric` for cmp, `isConsistent` for equality). - Arithmetic uses a different scheme: it synthesizes its operand - types once, then picks the first candidate from - `numericCandidates` (currently `[TInt, TReal, TFloat64]` — - `Unknown` is excluded since one should not synthesize a wildcard) - that every operand type is a consistent subtype of. The lookup - runs `findCommonNumericType`, a pure predicate, so failed - candidates do not leak any diagnostic side-effects. If no - candidate works, a single "no common numeric type" diagnostic - fires at the operator's source position, listing the operand - types so the user can see the mismatch. + Arithmetic follows the same shape as `Op-Eq` but for n operands: + synthesize each operand's type, require it to be `Numeric`, and + fold the operand types under `consistencyLub` (the LUB on the + flat consistency lattice — `Unknown ⊔ T = T`, `T ⊔ T = T`, + everything else inconsistent). The fold's result is the + synthesized type. If any pair is inconsistent the rule emits an + "operands have incompatible types" diagnostic listing the operand + types and falls back to `Unknown`. The boolean family additionally has a check-mode rule (`Check.primitiveOp`) preferred when an `expected` type is @@ -1437,23 +1427,40 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr -- carries the constructor identity for `expr` in diagnostics match op with - -- Arithmetic: synth operand types once, then pick the first numeric - -- candidate that every operand is a consistent subtype of. + -- Arithmetic: synth each operand's type, then take the LUB under + -- the consistency relation. This is the same discipline as + -- `Op-Eq`: operands must be pairwise consistent (with `Unknown` + -- promoting to whichever side is more informative). Each operand + -- is also required to be numeric. | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => let results ← args.attach.mapM (fun a => have := a.property; do Synth.resolveStmtExpr a.val) let args' := results.map (·.1) let argTypes := results.map (·.2) let ctx := (← get).typeContext - match findCommonNumericType ctx (numericCandidates source) argTypes with - | some resultTy => - pure (.PrimitiveOp op args', resultTy) + -- Per-operand numeric check: surface the bad operand directly. + for (a, aTy) in args'.zip argTypes do + unless isNumeric ctx aTy do + typeMismatch a.source (some expr) "expected a numeric type" aTy + -- Fold operands by consistencyLub, starting from `Unknown` so the + -- empty list (impossible for these ops, but kept for totality) + -- yields `Unknown` and a single-operand fold (`Neg`) yields the + -- operand's type. + let unknownTy : HighTypeMd := { val := .Unknown, source := source } + let resultTy := argTypes.foldl + (fun acc aTy => + match acc with + | some lub => consistencyLub ctx lub aTy + | none => none) + (some unknownTy) + match resultTy with + | some ty => pure (.PrimitiveOp op args', ty) | none => let formatted := ", ".intercalate (argTypes.map formatType) let diag := diagnosticFromSource source - s!"no common numeric type for operands of '{op}'; got [{formatted}]" + s!"Operands of '{op}' have incompatible types [{formatted}]" modify fun s => { s with errors := s.errors.push diag } - pure (.PrimitiveOp op args', { val := .Unknown, source := source }) + pure (.PrimitiveOp op args', unknownTy) | _ => let results ← args.attach.mapM (fun a => have := a.property; do Synth.resolveStmtExpr a.val) diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean index 6c42e05c8a..f306616d38 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean @@ -23,13 +23,12 @@ namespace Laurel every operand. The error surfaces as "expected '', got ''" at the offending operand. - - [⇒] Op-Arith — the *synth* path. When no expected type is - available (e.g. the arithmetic expression appears as the operand - of a comparison, or its result is used in synth position), the - rule iterates over `numericCandidates` (= `[TInt, TReal, - TFloat64]`) and picks the first `T` for which every operand - bidirectionally checks at `T`. Failed trials are rolled back; if - every candidate fails, the *last* trial's diagnostics are kept. + - [⇒] Op-Arith — the *synth* path. Each operand is synthesized, + required to be `Numeric` (`TInt` / `TReal` / `TFloat64` / + `Unknown`), and the result type is the consistency LUB of the + operand types: `Unknown ⊔ T = T`, `T ⊔ T = T`, anything else + is rejected. Same shape as `Op-Eq` but extended to n operands + and returning the LUB rather than `TBool`. Homogeneous numeric operands type-check via either path. Heterogeneous numeric operands (e.g. `int + real`) are rejected @@ -61,15 +60,15 @@ procedure heterogeneousCheckPath() // [⇒] Op-Arith path: '<' synthesizes its operands, so '1 + 2.0' is // resolved in synth position. The arithmetic synth rule synthesizes -// the operands ('int' and 'real') and looks for a candidate from -// [TInt, TReal, TFloat64] that both are consistent subtypes of. None -// works -- a single error fires at the operator's source position -// listing the operand types. +// the operands ('int' and 'real') and folds them under +// consistencyLub. 'int' and 'real' are mutually inconsistent, so +// the fold fails: a single error fires at the operator's source +// position listing the operand types. procedure heterogeneousSynthPath() opaque { assert (1 + 2.0) < 5 -// ^^^^^^^ error: no common numeric type for operands of '+' +// ^^^^^^^ error: incompatible types }; procedure unaryNegHomogeneous() @@ -83,10 +82,9 @@ procedure unaryNegHomogeneous() assert d == 0.0 - 1.5 }; -// Unknown (here from the unresolved name 'mystery') flows freely -// through both candidate trials, so the synth iteration succeeds at -// the first candidate (TInt). The 'mystery is not defined' diagnostic -// is the *only* error. +// Unknown (here from the unresolved name 'mystery') promotes to its +// neighbour under consistencyLub: 'Unknown + TInt' folds to TInt. +// The 'mystery is not defined' diagnostic is the *only* error. procedure unknownFlowsFreely() opaque { diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index c64349021b..c054f7a3ca 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -493,27 +493,23 @@ $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathit{Numeric}\;U $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad T_l \sim T_r \quad \mathit{op} \in \{\mathsf{Eq}, \mathsf{Neq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;[\mathit{lhs}; \mathit{rhs}] \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Eq)}` -$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad T \in \{\mathsf{TInt}, \mathsf{TReal}, \mathsf{TFloat64}\} \quad U_i \lesssim T \text{ (pairwise)} \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` - -The arithmetic synth rule synthesizes operand types once, then picks -the first $`T` from the candidate list -$`\{\mathsf{TInt}, \mathsf{TReal}, \mathsf{TFloat64}\}` that every -operand type is a consistent subtype of (here written -$`U_i \lesssim T` for {name Strata.Laurel.isConsistentSubtype}`isConsistentSubtype`). -The lookup runs `findCommonNumericType`, a pure predicate, so failed -candidates do not mutate the diagnostic state. If no candidate -works, a single "no common numeric type" diagnostic fires at the -operator's source position, listing the operand types so the -mismatch is concrete. - -This is symmetric in operand position (no privileged "first -argument") and rejects mixed-numeric expressions like -$`\mathsf{int} + \mathsf{real}` — neither $`\mathsf{TInt}` nor -$`\mathsf{TReal}` admits both operands. The gradual escape hatch -$`\mathsf{Unknown}` is *not* in the candidate list (one should not -synthesize a wildcard) but $`\mathsf{Unknown} \lesssim T` for every -$`T`, so an operand of type $`\mathsf{Unknown}` never blocks the -iteration. +$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathit{Numeric}\;U_i \quad T = \bigsqcup U_i \text{ (consistency LUB)} \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` + +The arithmetic synth rule mirrors $`[⇒]\,\text{Op-Eq}` but generalised +to $`n` operands. Each operand is synthesized and required to be +$`\mathit{Numeric}` (i.e. $`\mathsf{TInt}`, $`\mathsf{TReal}`, +$`\mathsf{TFloat64}`, or the gradual $`\mathsf{Unknown}`). The +result type is the *consistency LUB* $`\bigsqcup U_i` — a fold of +the operand types under +{name Strata.Laurel.isConsistent}`isConsistent`'s flat lattice: +$`\mathsf{Unknown} \sqcup T = T`, $`T \sqcup T = T`, and any other +combination is rejected. So `1 + 2` synthesizes $`\mathsf{TInt}`, +`1.5 + 2.5` synthesizes $`\mathsf{TReal}`, ` + 1` synthesizes +$`\mathsf{TInt}` (the $`\mathsf{Unknown}` operand promotes to its +neighbour), ` + ` synthesizes $`\mathsf{Unknown}`, and +`1 + 2.0` is rejected with an "operands have incompatible types" +diagnostic. The fold runs via `consistencyLub`, a pure predicate, so +the search has no diagnostic side-effects. $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` From 7bce041e90a4792d62d8a49a209931ebd58823d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 27 May 2026 16:50:48 -0400 Subject: [PATCH 137/189] error reporting on binary operators --- Strata/Languages/Laurel/Resolution.lean | 14 ++++++------- .../Fundamentals/T23_ArithTyping.lean | 20 ++++++++++++------- .../Laurel/ResolutionTypeCheckTests.lean | 4 ++-- docs/verso/LaurelDoc.lean | 6 +++--- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index b9b16fc736..72dbaeb809 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -669,7 +669,7 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy | _ => let unknown : HighTypeMd := { val := .Unknown, source := source } typeMismatch source (some expr) - "has no synthesis rule; use it in a position with a known expected type" + "this expression's type cannot be synthesized; try to annotate it or use it in a context where there is an expected type" unknown pure (expr, unknown) return ({ val := val', source := source }, ty) @@ -1424,9 +1424,9 @@ def Synth.instanceCall (exprMd : StmtExprMd) fold the operand types under `consistencyLub` (the LUB on the flat consistency lattice — `Unknown ⊔ T = T`, `T ⊔ T = T`, everything else inconsistent). The fold's result is the - synthesized type. If any pair is inconsistent the rule emits an - "operands have incompatible types" diagnostic listing the operand - types and falls back to `Unknown`. + synthesized type. If any pair is inconsistent the rule emits a + "cannot apply '' to operands of types …" diagnostic and + falls back to `Unknown`. The boolean family additionally has a check-mode rule (`Check.primitiveOp`) preferred when an `expected` type is @@ -1469,9 +1469,9 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) match resultTy with | some ty => pure (.PrimitiveOp op args', ty) | none => - let formatted := ", ".intercalate (argTypes.map formatType) + let formatted := ", ".intercalate (argTypes.map (fun t => s!"'{formatType t}'")) let diag := diagnosticFromSource source - s!"Operands of '{op}' have incompatible types [{formatted}]" + s!"cannot apply '{op}' to operands of types {formatted}" modify fun s => { s with errors := s.errors.push diag } pure (.PrimitiveOp op args', unknownTy) | _ => @@ -1500,7 +1500,7 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) let ctx := (← get).typeContext unless isConsistent ctx lhsTy rhsTy do let diag := diagnosticFromSource source - s!"Operands of '{op}' have incompatible types '{formatType lhsTy}' and '{formatType rhsTy}'" + s!"cannot compare '{formatType lhsTy}' with '{formatType rhsTy}' using '{op}'" modify fun s => { s with errors := s.errors.push diag } | _ => pure () | .StrConcat => diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean index f306616d38..a67ab92d15 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean @@ -68,7 +68,7 @@ procedure heterogeneousSynthPath() opaque { assert (1 + 2.0) < 5 -// ^^^^^^^ error: incompatible types +// ^^^^^^^ error: cannot apply '+' to operands of types 'int', 'real' }; procedure unaryNegHomogeneous() @@ -82,14 +82,20 @@ procedure unaryNegHomogeneous() assert d == 0.0 - 1.5 }; -// Unknown (here from the unresolved name 'mystery') promotes to its -// neighbour under consistencyLub: 'Unknown + TInt' folds to TInt. -// The 'mystery is not defined' diagnostic is the *only* error. -procedure unknownFlowsFreely() +// Unknown promotes to its neighbour under consistencyLub: +// 'Unknown + TInt' folds to TInt. The hole '' carries type +// 'Unknown' (it has no synthesis rule, so the synth dispatcher +// emits a 'no synthesis rule' diagnostic and falls back to +// 'Unknown'). The fold then yields TInt, and comparing to '2.0' +// (real) produces a 'cannot compare' diagnostic — proving the +// LUB returned TInt rather than Unknown (which would have passed +// the consistency check silently). +procedure unknownPromotesToNeighbour() opaque { - assert (mystery + 1) == 1 -// ^^^^^^^ error: 'mystery' is not defined + assert ( + 1) == 2.0 +// ^^^ error: this expression's type cannot be synthesized +// ^^^^^^^^^^^^^^^^ error: cannot compare 'int' with 'real' using '==' }; " diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 87d792284d..e5587ab9b9 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -145,7 +145,7 @@ function foo(): int { def equalityTypeMismatch := r" function cmp(x: int, y: string): bool { x == y -//^^^^^^ error: Operands of '==' have incompatible types 'int' and 'string' +//^^^^^^ error: cannot compare 'int' with 'string' using '==' }; " @@ -158,7 +158,7 @@ def multiOutputInExpr := r" procedure multi(x: int) returns (a: int, b: int) opaque; procedure test() opaque { assert multi(1) == 1 -// ^^^^^^^^^^^^^ error: Operands of '==' have incompatible types '(int, int)' and 'int' +// ^^^^^^^^^^^^^ error: cannot compare '(int, int)' with 'int' using '==' }; " diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index c054f7a3ca..0eeda479ea 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -507,9 +507,9 @@ combination is rejected. So `1 + 2` synthesizes $`\mathsf{TInt}`, `1.5 + 2.5` synthesizes $`\mathsf{TReal}`, ` + 1` synthesizes $`\mathsf{TInt}` (the $`\mathsf{Unknown}` operand promotes to its neighbour), ` + ` synthesizes $`\mathsf{Unknown}`, and -`1 + 2.0` is rejected with an "operands have incompatible types" -diagnostic. The fold runs via `consistencyLub`, a pure predicate, so -the search has no diagnostic side-effects. +`1 + 2.0` is rejected with a "cannot apply '+' to operands of types +'int', 'real'" diagnostic. The fold runs via `consistencyLub`, a +pure predicate, so the search has no diagnostic side-effects. $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` From e306965665b51e981c226515b7bc979a92113041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 28 May 2026 10:05:48 -0400 Subject: [PATCH 138/189] fix incorrect verso syntax --- Strata/Languages/Laurel/Resolution.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 72dbaeb809..09eb1e886b 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1425,7 +1425,7 @@ def Synth.instanceCall (exprMd : StmtExprMd) flat consistency lattice — `Unknown ⊔ T = T`, `T ⊔ T = T`, everything else inconsistent). The fold's result is the synthesized type. If any pair is inconsistent the rule emits a - "cannot apply '' to operands of types …" diagnostic and + `cannot apply '' to operands of types …` diagnostic and falls back to `Unknown`. The boolean family additionally has a check-mode rule From c33d1b8b9bd49c8b782f34fb5d9d1b696fc812a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 28 May 2026 10:36:46 -0400 Subject: [PATCH 139/189] workaround to make TCore Any ~ UserDefined Any --- Strata/Languages/Laurel/Laurel.lean | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index 658cbada38..80995b7858 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -553,6 +553,11 @@ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := is the dynamic type and is consistent with everything; otherwise structural equality after unfolding aliases / constrained types. + `TCore s` and `UserDefined s` are also treated as consistent when their + names match: the Python front-end emits `TCore "Any"` for type + annotations referring to the `Any` datatype declared in the runtime + prelude, so the two representations name the same type. + Used directly by `[⇒] Op-Eq`, where the operand types must be mutually consistent (no subtype direction is privileged), and as one half of `isConsistentSubtype`. -/ @@ -561,6 +566,7 @@ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := let b' := ctx.unfold b match a'.val, b'.val with | .Unknown, _ | _, .Unknown => true + | .TCore s, .UserDefined name | .UserDefined name, .TCore s => s == name.text | _, _ => highEq a' b' /-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For our flat lattice From 0eb841afc2d382485c2ab121d9f8c29eefa2dba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 28 May 2026 13:01:31 -0400 Subject: [PATCH 140/189] move type consistency check type-unfolding to call-site --- Strata/Languages/Laurel/Laurel.lean | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index 80995b7858..dc84189257 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -557,17 +557,31 @@ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := names match: the Python front-end emits `TCore "Any"` for type annotations referring to the `Any` datatype declared in the runtime prelude, so the two representations name the same type. + `MultiValuedExpr` is checked element-wise so the same equivalence + propagates through procedure-output tuples. Used directly by `[⇒] Op-Eq`, where the operand types must be mutually consistent (no subtype direction is privileged), and as one half of `isConsistentSubtype`. -/ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := - let a' := ctx.unfold a - let b' := ctx.unfold b - match a'.val, b'.val with - | .Unknown, _ | _, .Unknown => true - | .TCore s, .UserDefined name | .UserDefined name, .TCore s => s == name.text - | _, _ => highEq a' b' + -- `MultiValuedExpr` is checked element-wise *before* unfolding so elements + -- remain demonstrable subterms of `a`/`b`. `unfold` is `partial`, and is in + -- any case the identity on `MultiValuedExpr`, so this loses no precision. + match _a: a.val, _b: b.val with + | .MultiValuedExpr ts1, .MultiValuedExpr ts2 => + ts1.length == ts2.length && + (ts1.attach.zip ts2).all (fun (t1, t2) => isConsistent ctx t1.1 t2) + | _, _ => + let a' := ctx.unfold a + let b' := ctx.unfold b + match a'.val, b'.val with + | .Unknown, _ | _, .Unknown => true + | .TCore s, .UserDefined name | .UserDefined name, .TCore s => s == name.text + | _, _ => highEq a' b' + termination_by (SizeOf.sizeOf a) + decreasing_by + all_goals (cases a; cases b; try term_by_mem) + cases t1; term_by_mem /-- Consistent subtyping: `∃ R. sub ~ R ∧ R <: sup`. For our flat lattice this collapses to `sub ~ sup ∨ sub <: sup` — the standard collapse. From 904590704e8a304c2212bedb7d566291a315b898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 28 May 2026 16:40:38 -0400 Subject: [PATCH 141/189] Core Any + UserDefined Any as dynamic types --- Strata/Languages/Laurel/Laurel.lean | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index dc84189257..9c500e90eb 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -553,10 +553,15 @@ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := is the dynamic type and is consistent with everything; otherwise structural equality after unfolding aliases / constrained types. - `TCore s` and `UserDefined s` are also treated as consistent when their - names match: the Python front-end emits `TCore "Any"` for type - annotations referring to the `Any` datatype declared in the runtime - prelude, so the two representations name the same type. + `TCore "Any"` and `UserDefined "Any"` are also gradual — consistent with + everything. The Python front-end uses both representations to model + Python's `Any` type (depending on whether the name comes from a type + annotation or a synthesized expression type), and Python's `Any` is the + dynamic type of the gradual system, so treating both like `Unknown` + matches Python semantics. As a fallback, `TCore s` and `UserDefined s` + are also consistent when their names match for any other `s`, so the + two representations interoperate freely for non-`Any` Python datatypes + too. `MultiValuedExpr` is checked element-wise so the same equivalence propagates through procedure-output tuples. @@ -576,6 +581,8 @@ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := let b' := ctx.unfold b match a'.val, b'.val with | .Unknown, _ | _, .Unknown => true + | .TCore "Any", _ | _, .TCore "Any" => true + | .UserDefined ⟨"Any", _, _⟩, _ | _, .UserDefined ⟨"Any", _, _⟩ => true | .TCore s, .UserDefined name | .UserDefined name, .TCore s => s == name.text | _, _ => highEq a' b' termination_by (SizeOf.sizeOf a) From 28dcf867d99fb65cf2b3337e86772e566f657487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 28 May 2026 16:41:08 -0400 Subject: [PATCH 142/189] typecheck impure calls that return a value against TVoid --- Strata/Languages/Laurel/Resolution.lean | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 09eb1e886b..9a6c3a93e1 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1112,7 +1112,15 @@ def Check.block (exprMd : StmtExprMd) pure { val := .Block init' label, source := source } | some last => have := List.mem_of_getLast? _lastResult - let last' ← Check.resolveStmtExpr last expected + -- Discard-Call carve-out also applies to the last statement when the + -- block itself is in statement position (`expected = TVoid`): a call's + -- result is dropped, so it should not need to subtype `TVoid`. Without + -- this, `{ ...; foo() }` is rejected when `foo` returns a non-void + -- type, even though the block as a whole produces nothing. + let last' ← match last.val, expected.val with + | .StaticCall .., .TVoid | .InstanceCall .., .TVoid => + let (s', _) ← Synth.resolveStmtExpr last; pure s' + | _, _ => Check.resolveStmtExpr last expected pure { val := .Block (init' ++ [last']) label, source := source } termination_by (exprMd, 0) decreasing_by From a32a13600b066a531d022c37ca6ddcfafe7f2777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 28 May 2026 16:41:19 -0400 Subject: [PATCH 143/189] synth hole type --- Strata/Languages/Laurel/Resolution.lean | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 9a6c3a93e1..491b17821e 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -666,6 +666,16 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy | .Abstract => pure (Synth.abstract source) | .All => pure (Synth.all source) | .Block [] label => pure (.Block [] label, Synth.emptyBlock source) + -- Holes in synth position are gradual: an annotated hole synthesizes its + -- declared type; an unannotated one is `Unknown`. Without this carve-out, + -- a hole appearing as the target of e.g. a field access (`.f`) would + -- emit "type cannot be synthesized" and abort, which over-reports against + -- code where the hole's type is genuinely unknown to begin with. + | .Hole det none => + pure (.Hole det none, { val := .Unknown, source := source }) + | .Hole det (some ty) => + let ty' ← resolveHighType ty + pure (.Hole det (some ty'), ty') | _ => let unknown : HighTypeMd := { val := .Unknown, source := source } typeMismatch source (some expr) From 9d7f9b146968cd01138cea7f6cf318053193daeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 28 May 2026 16:54:27 -0400 Subject: [PATCH 144/189] fix test after holes synthesize their types we have to check that we are not redoing work in InferHoleTypes --- .../Examples/Fundamentals/T23_ArithTyping.lean | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean index a67ab92d15..6adda933e6 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean @@ -83,18 +83,16 @@ procedure unaryNegHomogeneous() }; // Unknown promotes to its neighbour under consistencyLub: -// 'Unknown + TInt' folds to TInt. The hole '' carries type -// 'Unknown' (it has no synthesis rule, so the synth dispatcher -// emits a 'no synthesis rule' diagnostic and falls back to -// 'Unknown'). The fold then yields TInt, and comparing to '2.0' -// (real) produces a 'cannot compare' diagnostic — proving the -// LUB returned TInt rather than Unknown (which would have passed -// the consistency check silently). +// 'Unknown + TInt' folds to TInt. The hole '' synthesizes to +// 'Unknown' (the gradual escape hatch — holes are first-class in +// synth mode and don't error). The fold then yields TInt, and +// comparing to '2.0' (real) produces a 'cannot compare' diagnostic — +// proving the LUB returned TInt rather than Unknown (which would +// have passed the consistency check silently). procedure unknownPromotesToNeighbour() opaque { assert ( + 1) == 2.0 -// ^^^ error: this expression's type cannot be synthesized // ^^^^^^^^^^^^^^^^ error: cannot compare 'int' with 'real' using '==' }; " From 47b14ca9b8f1b0e5411a17578ada558888be57cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 28 May 2026 16:56:11 -0400 Subject: [PATCH 145/189] fix misreporting on rules --- Strata/Languages/Laurel/Resolution.lean | 35 +++++++++++++++++-------- docs/verso/LaurelDoc.lean | 35 +++++++++++++++++++++---- 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 491b17821e..691b3b43b8 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -928,7 +928,7 @@ def Check.exit (target : String) (source : Option FileRange) : ───────────────────────── Γ ⊢ Return none ⇐ A - T_o-bar = [T] TVoid <: T (Return-None-Single) + T_o-bar = [T] TVoid <:~ T (Return-None-Single) ────────────────────────────────── Γ ⊢ Return none ⇐ A @@ -946,7 +946,7 @@ def Check.exit (target : String) (source : Option FileRange) : T_o-bar = [T_1;…;T_n] n ≥ 2 (Return-Multi-Error) ─────────────────────────────────────────────────────────── - Γ ⊢ Return (some e) ↝ "multi-output procedure cannot use ‘return e’" + Γ ⊢ Return (some e) ↝ "multi-output procedure cannot use 'return e'; assign to named outputs instead" ``` `return` is the *only* rule whose premises depend on the enclosing procedure's declared outputs. It is also a control-flow terminator: @@ -961,7 +961,7 @@ def Check.exit (target : String) (source : Option FileRange) : `return;` synthesizes the missing payload as `TVoid`. In a single-output procedure it is then required to subtype the declared - output (Return-None-Single's `TVoid <: T` premise) — accepted in + output (Return-None-Single's `TVoid <:~ T` premise) — accepted in void-returning procedures, rejected in `int`/`bool`/etc. ones, closing the soundness gap that the Dafny-style early-exit shorthand used to leave open. In a void-output procedure it is unconditionally @@ -975,7 +975,12 @@ def Check.exit (target : String) (source : Option FileRange) : assignment (`r := …` on the declared output parameters); `return e` syntactically takes a single `Option StmtExpr` and cannot carry multiple values, so it is flagged with a diagnostic pointing users - at the named-output convention. -/ + at the named-output convention. + + Regardless of which arm fires, `e` is always elaborated — it is + checked against the declared output in the single-output case, + otherwise synthesized — so any errors inside `e` are reported in + addition to the arity diagnostic. -/ def Check.return (exprMd : StmtExprMd) (val : Option StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Return val) : @@ -1023,12 +1028,12 @@ def Check.return (exprMd : StmtExprMd) ``` The empty block has a fixed type `TVoid` — written `skip : TVoid` in the source-language presentation. This is the only block-level - rule that synthesizes; every non-empty block reduces (via the - iteration in - `Resolution.Check.block`) to a - `head; rest` pattern whose tail eventually bottoms out here, and - `Check.block` applies subsumption at the boundary when an - `expected` type is supplied. -/ + rule that synthesizes: non-empty blocks are typed structurally by + `Resolution.Check.block` (last statement `⇐ T`, non-last positions + `⇐ TVoid` or Discard-Call) and never recurse into an empty tail, + so they never bottom out here. When an empty block appears in + check position, `Resolution.Check.resolveStmtExpr`'s wildcard arm + synth-then-subsumes via the standard \[⇐\] Sub fallback. -/ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := { val := .TVoid, source := source } @@ -1047,6 +1052,10 @@ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := Γ ⊢ s_i checks-non-last (1 ≤ i < n) Γ ⊢ s_n ⇐ T ─────────────────────────────────────────────────── Γ ⊢ Block [s_1; …; s_n] label ⇐ T + + head_n = StaticCall .. | InstanceCall .. Γ ⊢ s_n ⇒ _ + ────────────────────────────────────────────────────── (Discard-Call, last, T = TVoid) + Γ ⊢ s_n ⇐ TVoid ``` The last statement carries the block's value, so it is checked against the surrounding `T`. Non-last positions check at `TVoid`, @@ -1061,7 +1070,11 @@ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := *not* checked at `TVoid`. Without that carve-out, `f(x);` for a non-void-returning `f` would be rejected even though discarding the returned value is the standard imperative idiom (Java / Python / - JavaScript: `list.add(x);`). + JavaScript: `list.add(x);`). The same carve-out also applies to + the *last* statement when the surrounding `expected = TVoid` — + i.e. when the block itself is in statement position — so + `{ …; foo() }` is accepted as a statement even when `foo` returns + a non-void type (mirroring the inline-comment rationale below). The empty block has its own rule (`Resolution.Synth.emptyBlock`, a synthesis rule producing `TVoid`) and is handled directly in the diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 0eeda479ea..788354927b 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -307,20 +307,35 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda A non-empty block routes the surrounding expected type to its last statement; each non-last statement is checked at $`\mathsf{TVoid}`, *except* calls — which are synthesized and have their result type -dropped. That carve-out is the only block-level rule that isn't -already a consequence of the rules for individual statements. +dropped. The same Discard-Call carve-out also fires for the *last* +statement of a block when the block itself is in statement position +(i.e. $`T = \mathsf{TVoid}`), so $`\{\ldots;\;\mathit{foo}()\}` is +accepted as a statement even when `foo` returns a non-void type. +That carve-out is the only block-level rule that isn't already a +consequence of the rules for individual statements. $$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Leftarrow \mathsf{TVoid} \;\;\dashv\;\; \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` $$`\frac{\mathit{head} = \mathsf{StaticCall}\;\ldots \;\lor\; \mathit{head} = \mathsf{InstanceCall}\;\ldots \quad \Gamma \vdash \mathit{head} \Rightarrow \_}{\Gamma \vdash \mathit{head} \Leftarrow \mathsf{TVoid}} \quad \text{([⇐] Discard-Call)}` +The Discard-Call rule fires in two positions inside a block: (i) +for every non-last statement $`s_i` whose head is a call (this is +the carve-out implicit in the $`s_i \Leftarrow \mathsf{TVoid}` +premise of \[⇐\] Block above), and (ii) for the last statement +$`s_n` exactly when the surrounding expected type is +$`T = \mathsf{TVoid}` — i.e. when the block itself appears in +statement position. + Each $`s_i` is resolved under the scope $`\Gamma_{i-1}` produced by its predecessor and produces a possibly extended scope $`\Gamma_i` that the next statement sees. In practice only `Var (.Declare …)` actually extends the scope; every other construct leaves it unchanged so $`\Gamma_i = \Gamma_{i-1}`. The block opens a fresh nested scope, so declarations made inside don't leak out — once the -block ends, the surrounding $`\Gamma` is restored. +block ends, the surrounding $`\Gamma` is restored. The block also +emits a `"dead code after ''"` diagnostic when an +`Exit` or `Return` is followed by additional statements in the +same block. Statement-typed forms (`Var-Declare`, `Assign`, `Assert`, `Assume`, `While`, `Exit`, `Return`, `IfThenElse`) trivially satisfy @@ -346,7 +361,8 @@ The empty block has a fixed type and is the only block-level rule that synthesizes — written $`\mathsf{skip} : \mathsf{TVoid}` in the source-language presentation. When an empty block appears in check position with `expected ≠ TVoid`, the standard \[⇐\] Sub rule fires at -the boundary (requiring $`\mathsf{TVoid} <: \mathit{expected}`). +the boundary (`Check.resolveStmtExpr`'s subsumption-fallback wildcard +arm, requiring $`\mathsf{TVoid} <: \mathit{expected}`). {docstring Strata.Laurel.Resolution.Synth.emptyBlock} @@ -374,7 +390,7 @@ $$`\frac{\overline{T_o} = [T] \quad \Gamma \vdash e \Leftarrow T}{\Gamma \vdash $$`\frac{\overline{T_o} = []}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “void procedure cannot return a value”}} \quad \text{([⇐] Return-Void-Error)}` -$$`\frac{\overline{T_o} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use ‘return e’; assign to named outputs instead”}} \quad \text{([⇐] Return-Multi-Error)}` +$$`\frac{\overline{T_o} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use 'return e'; assign to named outputs instead”}} \quad \text{([⇐] Return-Multi-Error)}` `return` is the only rule whose premises depend on the enclosing procedure's declared outputs. The conclusion's value type $`A` is @@ -382,6 +398,11 @@ unconstrained, since `return` never falls through — it is a control-flow terminator. The error arms fire when $`\overline{T_o}`'s arity does not match the syntactic shape of `return e`. +Regardless of which arm fires, $`e` is always elaborated — it is +checked against the declared output in the single-output case, +otherwise synthesized — so any errors inside $`e` are reported in +addition to the arity diagnostic. + The three Return-None rules treat the missing payload as having type $`\mathsf{TVoid}`. Void-output procedures accept it unconditionally (Return-None-Void); single-output procedures require @@ -391,6 +412,10 @@ multi-output procedures accept it as an early-exit shorthand that leaves the named outputs at whatever they were last assigned to (Return-None-Multi). +When the surrounding context has no enclosing procedure body (e.g. +inside a constant initializer), `answerType = none` and all Return +checks are skipped; well-formed input never produces this case. + {docstring Strata.Laurel.Resolution.Check.return} $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{decreases} \Rightarrow U \quad \mathit{Numeric}\;U \quad \Gamma \vdash \mathit{body} \Leftarrow \mathsf{Unknown}}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{decreases}\;\mathit{body} \Leftarrow A} \quad \text{([⇐] While)}` From 9be6a4715305839e4fd7488a04800cc5a1b716cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 29 May 2026 16:40:19 -0400 Subject: [PATCH 146/189] explicitly include labels for namespace resolution --- Strata/Languages/Laurel/Resolution.lean | 40 +++++++++++++++++++++---- docs/verso/LaurelDoc.lean | 7 +++-- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 691b3b43b8..353c55e7eb 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -276,6 +276,11 @@ structure ResolveState where currentScopeNames : Std.HashSet String := {} /-- Per-composite-type field scopes (type name → field name → scope entry). -/ typeScopes : TypeScopes := {} + /-- Labels of enclosing labeled blocks, used by `Check.exit` to validate + that an `exit l` targets an in-scope label. Maintained as a separate + namespace (not part of `scope`) because labels are referenced by raw + string, not by `uniqueId`. -/ + labelScope : Std.HashSet String := {} /-- Diagnostics collected during resolution. -/ errors : Array DiagnosticModel := #[] /-- When resolving inside an instance procedure, the owning composite type name. @@ -399,6 +404,17 @@ def withScope (action : ResolveM α) : ResolveM α := do modify fun s => { s with scope := savedScope, currentScopeNames := savedNames } return result +/-- Run `action` with `label` (if any) added to `labelScope`, restoring the + previous label scope on exit. Used by `Check.block` so that `Check.exit` + can validate that `exit l` targets an enclosing labeled block. -/ +def withLabel (label : Option String) (action : ResolveM α) : ResolveM α := do + let savedLabels := (← get).labelScope + if let some l := label then + modify fun s => { s with labelScope := s.labelScope.insert l } + let result ← action + modify fun s => { s with labelScope := savedLabels } + return result + /-! ## AST traversal (Phase 1) -/ @@ -558,7 +574,9 @@ The judgment is bidirectional: Γ ⊢ e ⇐ A (Check.resolveStmtExpr) ``` -- `Γ` — lexical scope (variables, fields, labels). +- `Γ` — lexical scope (variables, fields). Block labels live in a + separate namespace `Γ_lbl` (`ResolveState.labelScope`), consulted + only by `Check.exit`. - `A` — *value type* of the term. The `Return` rules additionally depend on the enclosing procedure's @@ -904,7 +922,7 @@ def Check.while (exprMd : StmtExprMd) /-- (Exit) ``` - l ∈ Γ + l ∈ Γ_lbl ─────────────────── Γ ⊢ Exit l ⇐ A ``` @@ -912,9 +930,19 @@ def Check.while (exprMd : StmtExprMd) the enclosing labeled block and does not deliver a value to the surrounding context. Anything after `exit l` in the same block is dead code, flagged by `Resolution.Check.block`. The construct - checks at any `A`. -/ + checks at any `A`. + + The premise `l ∈ Γ_lbl` requires the target label to name an + enclosing labeled block; labels live in their own namespace + (`ResolveState.labelScope`, populated by `Check.block` via + `withLabel`). An unknown label is reported here as + `"label '' is not in scope"`. -/ def Check.exit (target : String) (source : Option FileRange) : ResolveM StmtExprMd := do + unless (← get).labelScope.contains target do + let diag := diagnosticFromSource source + s!"label '{target}' is not in scope" + modify fun s => { s with errors := s.errors.push diag } pure { val := .Exit target, source := source } /-- (Return) @@ -1084,13 +1112,15 @@ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := The block opens a fresh nested scope (so declarations made inside don't leak), and emits a "dead code after `exit`/`return`" diagnostic when a terminator is followed by additional statements - in the same block. -/ + in the same block. When `label` is `some l`, `l` is registered in + `ResolveState.labelScope` (via `withLabel`) for the duration of + the block so that nested `exit l` checks can see it. -/ def Check.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do let voidTy : HighTypeMd := { val := .TVoid, source := source } - withScope do + withScope <| withLabel label do let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do have : s ∈ stmts := List.dropLast_subset stmts hMem -- Discard-Call carve-out: a non-void-returning call in non-last diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 788354927b..7b6be3b5bf 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -368,11 +368,14 @@ arm, requiring $`\mathsf{TVoid} <: \mathit{expected}`). {docstring Strata.Laurel.Resolution.Check.block} -$$`\frac{l \in \Gamma}{\Gamma \vdash \mathsf{Exit}\;l \Leftarrow A} \quad \text{([⇐] Exit)}` +$$`\frac{l \in \Gamma_{\mathrm{lbl}}}{\Gamma \vdash \mathsf{Exit}\;l \Leftarrow A} \quad \text{([⇐] Exit)}` `exit` is non-returning — it transfers control out of the enclosing labeled block, so it checks at *any* value type $`A` (no -$`\mathsf{TVoid}` side condition). +$`\mathsf{TVoid}` side condition). Labels live in their own namespace +$`\Gamma_{\mathrm{lbl}}`, populated by the surrounding `Block` rule +when its $`\mathit{label}` is `some l`. An $`\mathsf{Exit}\;l` +targeting a label not in $`\Gamma_{\mathrm{lbl}}` is rejected. {docstring Strata.Laurel.Resolution.Check.exit} From c559a6281a3e30efa07f33c42be13dcd2d559f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 2 Jun 2026 11:37:40 -0400 Subject: [PATCH 147/189] generalize TCore gradual rule, drop Any special-casing isConsistent now treats every TCore _ as consistent with everything, and no longer carves out UserDefined "Any". Per review: "Any" has no special meaning in Laurel, and TCore is being removed anyway. Co-Authored-By: Claude Opus 4.7 (1M context) --- Strata/Languages/Laurel/Laurel.lean | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/Strata/Languages/Laurel/Laurel.lean b/Strata/Languages/Laurel/Laurel.lean index 9c500e90eb..d882b09e45 100644 --- a/Strata/Languages/Laurel/Laurel.lean +++ b/Strata/Languages/Laurel/Laurel.lean @@ -553,15 +553,8 @@ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := is the dynamic type and is consistent with everything; otherwise structural equality after unfolding aliases / constrained types. - `TCore "Any"` and `UserDefined "Any"` are also gradual — consistent with - everything. The Python front-end uses both representations to model - Python's `Any` type (depending on whether the name comes from a type - annotation or a synthesized expression type), and Python's `Any` is the - dynamic type of the gradual system, so treating both like `Unknown` - matches Python semantics. As a fallback, `TCore s` and `UserDefined s` - are also consistent when their names match for any other `s`, so the - two representations interoperate freely for non-`Any` Python datatypes - too. + `TCore _` is also treated as gradual — consistent with everything — + pending its removal from the type representation. `MultiValuedExpr` is checked element-wise so the same equivalence propagates through procedure-output tuples. @@ -581,9 +574,7 @@ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := let b' := ctx.unfold b match a'.val, b'.val with | .Unknown, _ | _, .Unknown => true - | .TCore "Any", _ | _, .TCore "Any" => true - | .UserDefined ⟨"Any", _, _⟩, _ | _, .UserDefined ⟨"Any", _, _⟩ => true - | .TCore s, .UserDefined name | .UserDefined name, .TCore s => s == name.text + | .TCore _, _ | _, .TCore _ => true | _, _ => highEq a' b' termination_by (SizeOf.sizeOf a) decreasing_by From 455d0247fe6092a2727b19265aa04b6bc142e6b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 2 Jun 2026 11:58:15 -0400 Subject: [PATCH 148/189] rewrite and simplify block rule --- Strata/Languages/Laurel/Resolution.lean | 117 ++++++++++++---------- docs/verso/LaurelDoc.lean | 123 ++++++++++++------------ 2 files changed, 126 insertions(+), 114 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 353c55e7eb..55df46115b 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1068,30 +1068,39 @@ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := /-- (Block) Non-empty block. ``` head = StaticCall .. | InstanceCall .. - Γ ⊢ s_i ⇒ _ (Discard-Call, 1 ≤ i < n) - ────────────────────────── - Γ ⊢ s_i checks-non-last + Γ ⊢ s_1 ⇒ _ (Discard-Call, n ≥ 2) + Γ ⊢ Block [s_2; …; s_n] _ ⇐ T + ─────────────────────────────────────────────────── + Γ ⊢ Block [s_1; s_2; …; s_n] label ⇐ T head ≠ StaticCall .., InstanceCall .. - Γ ⊢ s_i ⇐ TVoid (1 ≤ i < n) - ────────────────────────── - Γ ⊢ s_i checks-non-last - - Γ ⊢ s_i checks-non-last (1 ≤ i < n) Γ ⊢ s_n ⇐ T + Γ ⊢ s_1 ⇐ TVoid (n ≥ 2) + Γ ⊢ Block [s_2; …; s_n] _ ⇐ T ─────────────────────────────────────────────────── - Γ ⊢ Block [s_1; …; s_n] label ⇐ T + Γ ⊢ Block [s_1; s_2; …; s_n] label ⇐ T + + Γ ⊢ s_1 ⇐ T (Singleton) + ─────────────────────────────────────── + Γ ⊢ Block [s_1] label ⇐ T - head_n = StaticCall .. | InstanceCall .. Γ ⊢ s_n ⇒ _ - ────────────────────────────────────────────────────── (Discard-Call, last, T = TVoid) - Γ ⊢ s_n ⇐ TVoid + head_1 = StaticCall .. | InstanceCall .. Γ ⊢ s_1 ⇒ _ + ────────────────────────────────────────────────────── (Singleton-Discard-Call, T = TVoid) + Γ ⊢ Block [s_1] label ⇐ TVoid ``` - The last statement carries the block's value, so it is checked - against the surrounding `T`. Non-last positions check at `TVoid`, - which by \[⇐\] Sub admits any statement-typed form (Var-Declare, - Assign, Assert, Assume, While, Exit, Return, IfThenElse — their - rule conclusions are polymorphic in `A`, so they trivially check - at `TVoid`) and rejects bare expressions like `5;` whose type is - not consistent with `TVoid`. + The block rule walks the statement list recursively (via the + inner `checkStmts` helper): the singleton case forwards the + surrounding `T` to the only statement, and the cons case checks + the head at `TVoid` and recurses on the tail with the same `T`. + The empty case never fires from this entry point — the dispatcher + narrows to `head :: tail` and routes empty blocks through + `Synth.emptyBlock` + \[⇐\] Sub. + + Non-last positions check at `TVoid`, which by \[⇐\] Sub admits + any statement-typed form (Var-Declare, Assign, Assert, Assume, + While, Exit, Return, IfThenElse — their rule conclusions are + polymorphic in `A`, so they trivially check at `TVoid`) and + rejects bare expressions like `5;` whose type is not consistent + with `TVoid`. The one carve-out is **Discard-Call**: a procedure or method call in non-last position is synthesized and its result type dropped, @@ -1099,15 +1108,10 @@ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := non-void-returning `f` would be rejected even though discarding the returned value is the standard imperative idiom (Java / Python / JavaScript: `list.add(x);`). The same carve-out also applies to - the *last* statement when the surrounding `expected = TVoid` — - i.e. when the block itself is in statement position — so - `{ …; foo() }` is accepted as a statement even when `foo` returns - a non-void type (mirroring the inline-comment rationale below). - - The empty block has its own rule (`Resolution.Synth.emptyBlock`, - a synthesis rule producing `TVoid`) and is handled directly in the - `Resolution.Check.resolveStmtExpr` dispatcher's wildcard arm via - the standard \[⇐\] Sub fallback. + the *last* (i.e. only-remaining) statement when the surrounding + `expected = TVoid` — i.e. when the block itself is in statement + position — so `{ …; foo() }` is accepted as a statement even when + `foo` returns a non-void type. The block opens a fresh nested scope (so declarations made inside don't leak), and emits a "dead code after `exit`/`return`" @@ -1120,16 +1124,36 @@ def Check.block (exprMd : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do let voidTy : HighTypeMd := { val := .TVoid, source := source } + -- Per-statement helpers, one per syntactic position the recursion + -- would distinguish. Defined as local functions to keep `Check.block` + -- itself out of the mutual block's list-walking termination story: + -- `dropLast.attach.mapM` + `getLast?` below is equivalent to a + -- structural cons/singleton recursion that calls `checkNonLast` at + -- the head and recurses on the tail until the singleton arm fires + -- and forwards `expected` via `checkLast`. + let checkNonLast (s : StmtExprMd) (_h_mem : s ∈ stmts) : ResolveM StmtExprMd := do + -- Discard-Call carve-out: a non-void-returning call in non-last + -- position is synth-and-drop instead of `⇐ TVoid`, which would + -- otherwise reject the standard `list.add(x);` idiom. + match s.val with + | .StaticCall .. | .InstanceCall .. => + let (s', _) ← Synth.resolveStmtExpr s; pure s' + | _ => Check.resolveStmtExpr s voidTy + let checkLast (s : StmtExprMd) (_h_mem : s ∈ stmts) : ResolveM StmtExprMd := do + -- Discard-Call carve-out also applies to the last statement when + -- the block itself is in statement position (`expected = TVoid`): + -- a call's result is dropped, so it should not need to subtype + -- `TVoid`. Without this, `{ ...; foo() }` is rejected when `foo` + -- returns a non-void type, even though the block as a whole + -- produces nothing. + match s.val, expected.val with + | .StaticCall .., .TVoid | .InstanceCall .., .TVoid => + let (s', _) ← Synth.resolveStmtExpr s; pure s' + | _, _ => Check.resolveStmtExpr s expected withScope <| withLabel label do - let init' ← stmts.dropLast.attach.mapM (fun ⟨s, hMem⟩ => do - have : s ∈ stmts := List.dropLast_subset stmts hMem - -- Discard-Call carve-out: a non-void-returning call in non-last - -- position is synth-and-drop instead of `⇐ TVoid`, which would - -- otherwise reject the standard `list.add(x);` idiom. - match s.val with - | .StaticCall .. | .InstanceCall .. => - let (s', _) ← Synth.resolveStmtExpr s; pure s' - | _ => Check.resolveStmtExpr s voidTy) + let init' ← stmts.dropLast.attach.mapM fun ⟨s, hMem⟩ => do + have h_mem : s ∈ stmts := List.dropLast_subset stmts hMem + checkNonLast s h_mem -- Dead-code diagnostic: any terminator (`Exit`/`Return`) in init' -- is followed by at least one more statement (the last, or another -- non-last). Flag it once at the position of the next statement. @@ -1153,27 +1177,16 @@ def Check.block (exprMd : StmtExprMd) s!"dead code after '{termName}'" modify fun st => { st with errors := st.errors.push diag } | none => pure () - -- Non-empty block (the dispatcher in Check.resolveStmtExpr already - -- routes empty blocks through the synth-then-Sub fallback). The - -- last statement carries the block's value, so it is checked - -- against the surrounding `expected`. + -- Singleton/last case: forward `expected` to the trailing statement + -- (the dispatcher already narrows away the empty case, so this + -- `none` arm is dead in practice but kept to remain total). match _lastResult: stmts.getLast? with | none => - -- Unreachable: dispatcher narrowed `.Block (head :: tail) label`. - -- Keep this arm to remain total; falls back to the empty-block synth. checkSubtype source expected (Synth.emptyBlock source) pure { val := .Block init' label, source := source } | some last => have := List.mem_of_getLast? _lastResult - -- Discard-Call carve-out also applies to the last statement when the - -- block itself is in statement position (`expected = TVoid`): a call's - -- result is dropped, so it should not need to subtype `TVoid`. Without - -- this, `{ ...; foo() }` is rejected when `foo` returns a non-void - -- type, even though the block as a whole produces nothing. - let last' ← match last.val, expected.val with - | .StaticCall .., .TVoid | .InstanceCall .., .TVoid => - let (s', _) ← Synth.resolveStmtExpr last; pure s' - | _, _ => Check.resolveStmtExpr last expected + let last' ← checkLast last ‹_› pure { val := .Block (init' ++ [last']) label, source := source } termination_by (exprMd, 0) decreasing_by diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 7b6be3b5bf..dd0ca90956 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -225,7 +225,8 @@ direction explicit. - {ref "rules-literals"}[*Literals*] — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal - {ref "rules-variables"}[*Variables*] — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇐\] Var-Declare - {ref "rules-control-flow"}[*Control flow*] — \[⇐\] If, \[⇐\] If-NoElse; - \[⇐\] Block, \[⇒\] Skip, \[⇐\] Discard-Call; \[⇐\] Exit; + \[⇐\] Block-Singleton, \[⇐\] Block-Cons, \[⇒\] Skip, + \[⇐\] Discard-Call-Last, \[⇐\] Discard-Call-Cons; \[⇐\] Exit; \[⇐\] Return-None-Void, \[⇐\] Return-None-Single, \[⇐\] Return-None-Multi, \[⇐\] Return-Some, \[⇐\] Return-Void-Error, \[⇐\] Return-Multi-Error; \[⇐\] While @@ -289,7 +290,7 @@ $$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \ma {docstring Strata.Laurel.Resolution.Synth.varField} -$$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T_x\rangle) \Leftarrow A \;\;\dashv\;\; \Gamma, x : T_x} \quad \text{([⇐] Var-Declare)}` +$$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T_x\rangle) \Leftarrow A \quad \dashv \quad \Gamma, x : T_x} \quad \text{([⇐] Var-Declare)}` {docstring Strata.Laurel.Resolution.Check.varDeclare} @@ -304,49 +305,44 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda {docstring Strata.Laurel.Resolution.Check.ifThenElse} -A non-empty block routes the surrounding expected type to its last -statement; each non-last statement is checked at $`\mathsf{TVoid}`, -*except* calls — which are synthesized and have their result type -dropped. The same Discard-Call carve-out also fires for the *last* -statement of a block when the block itself is in statement position -(i.e. $`T = \mathsf{TVoid}`), so $`\{\ldots;\;\mathit{foo}()\}` is -accepted as a statement even when `foo` returns a non-void type. -That carve-out is the only block-level rule that isn't already a -consequence of the rules for individual statements. - -$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \Leftarrow \mathsf{TVoid} \;\;\dashv\;\; \Gamma_i \;(1 \le i < n) \quad \Gamma_{n-1} \vdash s_n \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` - -$$`\frac{\mathit{head} = \mathsf{StaticCall}\;\ldots \;\lor\; \mathit{head} = \mathsf{InstanceCall}\;\ldots \quad \Gamma \vdash \mathit{head} \Rightarrow \_}{\Gamma \vdash \mathit{head} \Leftarrow \mathsf{TVoid}} \quad \text{([⇐] Discard-Call)}` - -The Discard-Call rule fires in two positions inside a block: (i) -for every non-last statement $`s_i` whose head is a call (this is -the carve-out implicit in the $`s_i \Leftarrow \mathsf{TVoid}` -premise of \[⇐\] Block above), and (ii) for the last statement -$`s_n` exactly when the surrounding expected type is -$`T = \mathsf{TVoid}` — i.e. when the block itself appears in -statement position. - -Each $`s_i` is resolved under the scope $`\Gamma_{i-1}` produced by -its predecessor and produces a possibly extended scope $`\Gamma_i` -that the next statement sees. In practice only `Var (.Declare …)` -actually extends the scope; every other construct leaves it -unchanged so $`\Gamma_i = \Gamma_{i-1}`. The block opens a fresh -nested scope, so declarations made inside don't leak out — once the -block ends, the surrounding $`\Gamma` is restored. The block also -emits a `"dead code after ''"` diagnostic when an -`Exit` or `Return` is followed by additional statements in the -same block. +A non-empty block is typed by structural recursion on the statement +list: the last statement inherits the surrounding expected type, and +each non-last statement is checked at $`\mathsf{TVoid}`, *except* +calls — which are synthesized and have their result type dropped. The +same Discard-Call carve-out also fires for the *last* statement when +the block itself is in statement position (i.e. $`T = \mathsf{TVoid}`), +so $`\{\ldots;\,\mathit{foo}()\}` is accepted as a statement even when +`foo` returns a non-void type. The Discard-Call carve-outs are the only +block-level rules that aren't already consequences of the rules for +individual statements. + +$$`\frac{\Gamma \vdash s \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Singleton)}` + +$$`\frac{\Gamma \vdash s \Leftarrow \mathsf{TVoid} \quad \dashv \quad \Gamma' \quad \Gamma' \vdash \mathsf{Block}\;\mathit{rest}\;\mathit{label} \Leftarrow T \quad \mathit{rest} \ne []}{\Gamma \vdash \mathsf{Block}\;(s :: \mathit{rest})\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Cons)}` + +$$`\frac{s = \mathsf{StaticCall}\;\ldots \lor s = \mathsf{InstanceCall}\;\ldots \quad \Gamma \vdash s \Rightarrow \_ \quad \Gamma \vdash \mathsf{Block}\;\mathit{rest}\;\mathit{label} \Leftarrow T \quad \mathit{rest} \ne []}{\Gamma \vdash \mathsf{Block}\;(s :: \mathit{rest})\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Discard-Call-Cons)}` + +$$`\frac{s = \mathsf{StaticCall}\;\ldots \lor s = \mathsf{InstanceCall}\;\ldots \quad \Gamma \vdash s \Rightarrow \_}{\Gamma \vdash \mathsf{Block}\;[s]\;\mathit{label} \Leftarrow \mathsf{TVoid}} \quad \text{([⇐] Discard-Call-Last)}` + +\[⇐\] Block-Cons resolves $`s` under the incoming $`\Gamma` and +recurses on the tail under the possibly-extended scope $`\Gamma'`. In +practice only `Var (.Declare …)` actually extends the scope; every +other construct leaves it unchanged. The block opens a fresh nested +scope, so declarations made inside don't leak out — once the block +ends, the surrounding $`\Gamma` is restored. The block also emits a +`"dead code after ''"` diagnostic when an `Exit` or +`Return` is followed by additional statements in the same block. Statement-typed forms (`Var-Declare`, `Assign`, `Assert`, `Assume`, `While`, `Exit`, `Return`, `IfThenElse`) trivially satisfy -$`\Gamma \vdash s_i \Leftarrow \mathsf{TVoid}` — their rule -conclusions are polymorphic in `A`, so they check at *any* type, -including $`\mathsf{TVoid}`. Bare expressions like `5;` fail via -\[⇐\] Sub: the synthesized type is not consistent with -$`\mathsf{TVoid}`. The Discard-Call carve-out is what allows the -standard `f(x);` idiom for a non-void-returning `f` — without it, -$`\mathit{head} \Leftarrow \mathsf{TVoid}` would force every call to -have a $`\mathsf{TVoid}`-compatible result type. +$`\Gamma \vdash s \Leftarrow \mathsf{TVoid}` — their rule conclusions +are polymorphic in `A`, so they check at *any* type, including +$`\mathsf{TVoid}`. Bare expressions like `5;` fail via \[⇐\] Sub: the +synthesized type is not consistent with $`\mathsf{TVoid}`. The two +Discard-Call rules are what allow the standard `f(x);` idiom for a +non-void-returning `f` — without them, $`s \Leftarrow \mathsf{TVoid}` +would force every call to have a $`\mathsf{TVoid}`-compatible result +type. Pushing $`T` into the last statement (rather than synthesizing the whole block and applying \[⇐\] Sub at the boundary) means a type @@ -359,10 +355,13 @@ $$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{ The empty block has a fixed type and is the only block-level rule that synthesizes — written $`\mathsf{skip} : \mathsf{TVoid}` in the -source-language presentation. When an empty block appears in check -position with `expected ≠ TVoid`, the standard \[⇐\] Sub rule fires at -the boundary (`Check.resolveStmtExpr`'s subsumption-fallback wildcard -arm, requiring $`\mathsf{TVoid} <: \mathit{expected}`). +source-language presentation. The recursive Block-Cons / Block-Singleton +rules above never bottom out into an empty tail, so the empty case is +reached only when the block is empty at the dispatch site. When an +empty block appears in check position with `expected ≠ TVoid`, the +standard \[⇐\] Sub rule fires at the boundary +(`Check.resolveStmtExpr`'s subsumption-fallback wildcard arm, requiring +$`\mathsf{TVoid} <: \mathit{expected}`). {docstring Strata.Laurel.Resolution.Synth.emptyBlock} @@ -421,7 +420,7 @@ checks are skipped; well-formed input never produces this case. {docstring Strata.Laurel.Resolution.Check.return} -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{decreases} \Rightarrow U \quad \mathit{Numeric}\;U \quad \Gamma \vdash \mathit{body} \Leftarrow \mathsf{Unknown}}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{decreases}\;\mathit{body} \Leftarrow A} \quad \text{([⇐] While)}` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{invs}_i \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{decreases} \Rightarrow U \quad \mathsf{Numeric}\;U \quad \Gamma \vdash \mathit{body} \Leftarrow \mathsf{Unknown}}{\Gamma \vdash \mathsf{While}\;\mathit{cond}\;\mathit{invs}\;\mathit{decreases}\;\mathit{body} \Leftarrow A} \quad \text{([⇐] While)}` The body is checked at $`\mathsf{Unknown}`: control either re-enters the loop or falls through, so the body's value type is never observed @@ -430,8 +429,8 @@ the surrounding $`A`, so its conclusion is polymorphic in $`A` like every other statement-typed form. The optional $`\mathit{decreases}` clause is synthesized and required -to have a numeric type via the same $`\mathit{Numeric}` predicate -used by the arithmetic primitive operations. $`\mathit{Numeric}` is +to have a numeric type via the same $`\mathsf{Numeric}` predicate +used by the arithmetic primitive operations. $`\mathsf{Numeric}` is a predicate (it admits $`\mathsf{TInt}`, $`\mathsf{TReal}`, $`\mathsf{TFloat64}`, and $`\mathsf{Unknown}` as the gradual escape hatch), not a single type, so the clause runs in synth mode rather @@ -470,7 +469,7 @@ context with the target's type while the RHS is checked against it. {docstring Strata.Laurel.Resolution.Synth.assign} -$$`\frac{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathit{ExpectedTy} \quad T = \mathsf{TVoid} \;\lor\; \mathit{ExpectedTy} <: T}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Leftarrow T} \quad \text{([⇐] Assign)}` +$$`\frac{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Rightarrow \mathit{ExpectedTy} \quad T = \mathsf{TVoid} \lor \mathit{ExpectedTy} <: T}{\Gamma \vdash \mathsf{Assign}\;\mathit{targets}\;e \Leftarrow T} \quad \text{([⇐] Assign)}` The check rule synthesizes the assignment's type via \[⇒\] Assign and then runs the standard \[⇐\] Sub boundary check `ExpectedTy <: T` @@ -517,17 +516,17 @@ tag := "rules-primitive-operations" $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TBool} \quad \mathit{op} \in \{\mathsf{And}, \mathsf{Or}, \mathsf{AndThen}, \mathsf{OrElse}, \mathsf{Not}, \mathsf{Implies}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Bool)}` -$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathit{Numeric}\;U_i \quad \mathit{op} \in \{\mathsf{Lt}, \mathsf{Leq}, \mathsf{Gt}, \mathsf{Geq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Cmp)}` +$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathsf{Numeric}\;U_i \quad \mathit{op} \in \{\mathsf{Lt}, \mathsf{Leq}, \mathsf{Gt}, \mathsf{Geq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Cmp)}` $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad T_l \sim T_r \quad \mathit{op} \in \{\mathsf{Eq}, \mathsf{Neq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;[\mathit{lhs}; \mathit{rhs}] \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Eq)}` -$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathit{Numeric}\;U_i \quad T = \bigsqcup U_i \text{ (consistency LUB)} \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` +$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathsf{Numeric}\;U_i \quad T = \bigsqcup_i U_i \text{ (consistency LUB)} \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` The arithmetic synth rule mirrors $`[⇒]\,\text{Op-Eq}` but generalised to $`n` operands. Each operand is synthesized and required to be -$`\mathit{Numeric}` (i.e. $`\mathsf{TInt}`, $`\mathsf{TReal}`, +$`\mathsf{Numeric}` (i.e. $`\mathsf{TInt}`, $`\mathsf{TReal}`, $`\mathsf{TFloat64}`, or the gradual $`\mathsf{Unknown}`). The -result type is the *consistency LUB* $`\bigsqcup U_i` — a fold of +result type is the *consistency LUB* $`\bigsqcup_i U_i` — a fold of the operand types under {name Strata.Laurel.isConsistent}`isConsistent`'s flat lattice: $`\mathsf{Unknown} \sqcup T = T`, $`T \sqcup T = T`, and any other @@ -549,7 +548,7 @@ pushes the operand type into each operand via `Check.resolveStmtExpr`, replacing the synth-then-`checkSubtype` discipline with bidirectional check. -$$`\frac{\mathit{Numeric}\;T \quad \Gamma \vdash \mathit{args}_i \Leftarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Leftarrow T} \quad \text{([⇐] Op-Arith)}` +$$`\frac{\mathsf{Numeric}\;T \quad \Gamma \vdash \mathit{args}_i \Leftarrow T \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Leftarrow T} \quad \text{([⇐] Op-Arith)}` $$`\frac{\mathsf{TBool} <: T \quad \Gamma \vdash \mathit{args}_i \Leftarrow \mathsf{TBool} \quad \mathit{op} \in \{\mathsf{And}, \mathsf{Or}, \mathsf{AndThen}, \mathsf{OrElse}, \mathsf{Not}, \mathsf{Implies}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Leftarrow T} \quad \text{([⇐] Op-Bool)}` @@ -566,11 +565,11 @@ $$`\frac{\Gamma(\mathit{ref}) \text{ is not a composite or datatype}}{\Gamma \vd {docstring Strata.Laurel.Resolution.Synth.new} -$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow U \quad U \sim T \;\vee\; U <: T \;\vee\; T <: U}{\Gamma \vdash \mathsf{AsType}\;\mathit{target}\;T \Rightarrow T} \quad \text{([⇒] AsType)}` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow U \quad U \sim T \lor U <: T \lor T <: U}{\Gamma \vdash \mathsf{AsType}\;\mathit{target}\;T \Rightarrow T} \quad \text{([⇒] AsType)}` {docstring Strata.Laurel.Resolution.Synth.asType} -$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow U \quad U \sim T \;\vee\; U <: T \;\vee\; T <: U}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow U \quad U \sim T \lor U <: T \lor T <: U}{\Gamma \vdash \mathsf{IsType}\;\mathit{target}\;T \Rightarrow \mathsf{TBool}} \quad \text{([⇒] IsType)}` {docstring Strata.Laurel.Resolution.Synth.isType} @@ -619,7 +618,7 @@ tag := "rules-self-reference" $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{some}\;T}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] This-Inside)}` -$$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{none}}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{Unknown}\;\;[\text{emits “‘this’ is not allowed outside instance methods”}]} \quad \text{([⇒] This-Outside)}` +$$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{none}}{\Gamma \vdash \mathsf{This} \Rightarrow \mathsf{Unknown} \quad [\text{emits “‘this’ is not allowed outside instance methods”}]} \quad \text{([⇒] This-Outside)}` {docstring Strata.Laurel.Resolution.Synth.this} @@ -628,7 +627,7 @@ $$`\frac{\Gamma.\mathit{instanceTypeName} = \mathsf{none}}{\Gamma \vdash \mathsf tag := "rules-untyped-forms" %%% -$$`\frac{}{\Gamma \vdash \mathsf{Abstract}\;/\;\mathsf{All}\;\ldots \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Abstract / All)}` +$$`\frac{}{\Gamma \vdash \mathsf{Abstract}\,/\,\mathsf{All}\;\ldots \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Abstract / All)}` {docstring Strata.Laurel.Resolution.Synth.abstract} @@ -639,9 +638,9 @@ $$`\frac{}{\Gamma \vdash \mathsf{Abstract}\;/\;\mathsf{All}\;\ldots \Rightarrow tag := "rules-contract-of" %%% -$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Precondition}\;\mathit{fn} \Rightarrow \mathsf{TBool} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{PostCondition}\;\mathit{fn} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] ContractOf-Bool)}` +$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Precondition}\;\mathit{fn} \Rightarrow \mathsf{TBool} \qquad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{PostCondition}\;\mathit{fn} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] ContractOf-Bool)}` -$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Reads}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown} \quad\quad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{Modifies}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown}} \quad \text{([⇒] ContractOf-Set)}` +$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Reads}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown} \qquad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{Modifies}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown}} \quad \text{([⇒] ContractOf-Set)}` $$`\frac{\mathit{fn} \text{ is not a procedure reference}}{\Gamma \vdash \mathsf{ContractOf}\;\ldots\;\mathit{fn} \rightsquigarrow \text{error: “‘contractOf’ expected a procedure reference”}} \quad \text{([⇒] ContractOf-Error)}` @@ -656,7 +655,7 @@ $$`\frac{T_h <: T}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T_h) \Leftarr {docstring Strata.Laurel.Resolution.Check.holeSome} -$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \;\;\mapsto\;\; \mathsf{Hole}\;d\;(\mathsf{some}\;T)} \quad \text{([⇐] Hole-None)}` +$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \quad \mapsto \quad \mathsf{Hole}\;d\;(\mathsf{some}\;T)} \quad \text{([⇐] Hole-None)}` {docstring Strata.Laurel.Resolution.Check.holeNone} From 613e3f8095b4e06a6cbe0194d032b3ac2caaacfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 2 Jun 2026 12:10:31 -0400 Subject: [PATCH 149/189] fix label context resolution --- Strata/Languages/Laurel/Resolution.lean | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 55df46115b..e45219ac9d 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -2092,7 +2092,11 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do let savedAnswer := (← get).answerType modify fun s => { s with answerType := some (outputs'.map (·.type)) } let bodyExpected := procedureBodyType proc.isFunctional outputs' proc.name.source - let body' ← resolveBody proc.body bodyExpected + -- Pre-register the implicit `$body` label that the LaurelToCore + -- translator wraps the body in (`Core.Statement.block "$body" …`), + -- so that frontends emitting `Exit "$body"` for early-return lowering + -- (e.g. PythonToLaurel) don't trip Check.exit's label-scope check. + let body' ← withLabel (some "$body") <| resolveBody proc.body bodyExpected modify fun s => { s with answerType := savedAnswer } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source @@ -2129,7 +2133,8 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv let savedAnswer := (← get).answerType modify fun s => { s with answerType := some (outputs'.map (·.type)) } let bodyExpected := procedureBodyType proc.isFunctional outputs' proc.name.source - let body' ← resolveBody proc.body bodyExpected + -- See `resolveProcedure` for the rationale on `$body`. + let body' ← withLabel (some "$body") <| resolveBody proc.body bodyExpected modify fun s => { s with answerType := savedAnswer } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source From c50c8e6d65bad4b255face027891f0dffdf5dc42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 3 Jun 2026 16:53:50 -0400 Subject: [PATCH 150/189] update documentation --- Strata/Languages/Laurel/Resolution.lean | 131 ++++++++++-------------- docs/verso/LaurelDoc.lean | 17 +-- 2 files changed, 56 insertions(+), 92 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index e45219ac9d..2480f144a4 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1057,95 +1057,70 @@ def Check.return (exprMd : StmtExprMd) The empty block has a fixed type `TVoid` — written `skip : TVoid` in the source-language presentation. This is the only block-level rule that synthesizes: non-empty blocks are typed structurally by - `Resolution.Check.block` (last statement `⇐ T`, non-last positions - `⇐ TVoid` or Discard-Call) and never recurse into an empty tail, - so they never bottom out here. When an empty block appears in + `Resolution.Check.block` (last statement carries the value, non-last + positions `⇐ TVoid` or Discard-Call) and never recurse into an empty + tail, so they never bottom out here. When an empty block appears in check position, `Resolution.Check.resolveStmtExpr`'s wildcard arm synth-then-subsumes via the standard \[⇐\] Sub fallback. -/ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := { val := .TVoid, source := source } -/-- (Block) Non-empty block. - ``` - head = StaticCall .. | InstanceCall .. - Γ ⊢ s_1 ⇒ _ (Discard-Call, n ≥ 2) - Γ ⊢ Block [s_2; …; s_n] _ ⇐ T - ─────────────────────────────────────────────────── - Γ ⊢ Block [s_1; s_2; …; s_n] label ⇐ T - - head ≠ StaticCall .., InstanceCall .. - Γ ⊢ s_1 ⇐ TVoid (n ≥ 2) - Γ ⊢ Block [s_2; …; s_n] _ ⇐ T - ─────────────────────────────────────────────────── - Γ ⊢ Block [s_1; s_2; …; s_n] label ⇐ T - - Γ ⊢ s_1 ⇐ T (Singleton) - ─────────────────────────────────────── - Γ ⊢ Block [s_1] label ⇐ T - - head_1 = StaticCall .. | InstanceCall .. Γ ⊢ s_1 ⇒ _ - ────────────────────────────────────────────────────── (Singleton-Discard-Call, T = TVoid) - Γ ⊢ Block [s_1] label ⇐ TVoid - ``` - The block rule walks the statement list recursively (via the - inner `checkStmts` helper): the singleton case forwards the - surrounding `T` to the only statement, and the cons case checks - the head at `TVoid` and recurses on the tail with the same `T`. - The empty case never fires from this entry point — the dispatcher - narrows to `head :: tail` and routes empty blocks through - `Synth.emptyBlock` + \[⇐\] Sub. - - Non-last positions check at `TVoid`, which by \[⇐\] Sub admits - any statement-typed form (Var-Declare, Assign, Assert, Assume, - While, Exit, Return, IfThenElse — their rule conclusions are - polymorphic in `A`, so they trivially check at `TVoid`) and - rejects bare expressions like `5;` whose type is not consistent - with `TVoid`. - - The one carve-out is **Discard-Call**: a procedure or method call - in non-last position is synthesized and its result type dropped, - *not* checked at `TVoid`. Without that carve-out, `f(x);` for a - non-void-returning `f` would be rejected even though discarding the - returned value is the standard imperative idiom (Java / Python / - JavaScript: `list.add(x);`). The same carve-out also applies to - the *last* (i.e. only-remaining) statement when the surrounding - `expected = TVoid` — i.e. when the block itself is in statement - position — so `{ …; foo() }` is accepted as a statement even when - `foo` returns a non-void type. - - The block opens a fresh nested scope (so declarations made inside +/-- (Block) Check-mode typing rule for a non-empty block. + + A block's value is the value of its **last** statement; every + earlier statement is run only for its effect. The rule splits the + statement list into `init` (all but the last) and `last` and is one + recursion over that structure: + + * **non-last — `Γ ⊢ s ⇐ TVoid`.** A non-last statement is a pure + effect, so it is checked at `TVoid`. By \[⇐\] Sub this admits + every statement-shaped form (`Var-Declare`, `Assign`, `Assert`, + `Assume`, `While`, `Exit`, `Return`, `IfThenElse` — their + conclusions are polymorphic in the value type, so they trivially + check at `TVoid`) and rejects a stranded value expression like + `5;`, whose `TInt` is not consistent with `TVoid`. The one + **Discard-Call** carve-out: a call (`StaticCall`/`InstanceCall`) + is synthesized and its result dropped, so the standard + `list.add(x);` discard idiom is allowed even when the callee + returns a value. + + * **last — `Γ ⊢ last ⇐ T`.** The surrounding expected type `T` is + routed to the last statement, so a check-only trailing form + (`IfThenElse`, a nested `Block`, `Hole`, `Return`, …) still + receives its expected type. The same Discard-Call carve-out + applies when `T = TVoid` (a trailing `foo()` in statement + position discards its result, so `{ …; foo() }` type-checks as a + statement even when `foo` returns a value). + + There is deliberately no synthesis rule for non-empty blocks: a + block is statement-shaped and always occurs in check position + (procedure bodies, branches, loop bodies, assignment RHS, call + arguments all supply an expected type). A block in a synth-only + operand position has no contextual type and is reported by the + `Synth.resolveStmtExpr` wildcard. + + The block opens a fresh nested scope (declarations made inside don't leak), and emits a "dead code after `exit`/`return`" - diagnostic when a terminator is followed by additional statements - in the same block. When `label` is `some l`, `l` is registered in - `ResolveState.labelScope` (via `withLabel`) for the duration of - the block so that nested `exit l` checks can see it. -/ + diagnostic when a terminator is followed by further statements. + When `label` is `some l`, `l` is registered in + `ResolveState.labelScope` (via `withLabel`) for the block's extent + so nested `exit l` checks can see it. -/ def Check.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do let voidTy : HighTypeMd := { val := .TVoid, source := source } - -- Per-statement helpers, one per syntactic position the recursion - -- would distinguish. Defined as local functions to keep `Check.block` - -- itself out of the mutual block's list-walking termination story: - -- `dropLast.attach.mapM` + `getLast?` below is equivalent to a - -- structural cons/singleton recursion that calls `checkNonLast` at - -- the head and recurses on the tail until the singleton arm fires - -- and forwards `expected` via `checkLast`. + -- A non-last statement is an effect: check it at `TVoid`, *except* a + -- call, whose result we synthesize and drop (the `list.add(x);` idiom). let checkNonLast (s : StmtExprMd) (_h_mem : s ∈ stmts) : ResolveM StmtExprMd := do - -- Discard-Call carve-out: a non-void-returning call in non-last - -- position is synth-and-drop instead of `⇐ TVoid`, which would - -- otherwise reject the standard `list.add(x);` idiom. match s.val with | .StaticCall .. | .InstanceCall .. => let (s', _) ← Synth.resolveStmtExpr s; pure s' | _ => Check.resolveStmtExpr s voidTy + -- The last statement carries the block's value: push `expected` in + -- (so check-only forms are reachable), with the Discard-Call carve-out + -- when the block is itself in statement position (`expected = TVoid`). let checkLast (s : StmtExprMd) (_h_mem : s ∈ stmts) : ResolveM StmtExprMd := do - -- Discard-Call carve-out also applies to the last statement when - -- the block itself is in statement position (`expected = TVoid`): - -- a call's result is dropped, so it should not need to subtype - -- `TVoid`. Without this, `{ ...; foo() }` is rejected when `foo` - -- returns a non-void type, even though the block as a whole - -- produces nothing. match s.val, expected.val with | .StaticCall .., .TVoid | .InstanceCall .., .TVoid => let (s', _) ← Synth.resolveStmtExpr s; pure s' @@ -1154,9 +1129,9 @@ def Check.block (exprMd : StmtExprMd) let init' ← stmts.dropLast.attach.mapM fun ⟨s, hMem⟩ => do have h_mem : s ∈ stmts := List.dropLast_subset stmts hMem checkNonLast s h_mem - -- Dead-code diagnostic: any terminator (`Exit`/`Return`) in init' - -- is followed by at least one more statement (the last, or another - -- non-last). Flag it once at the position of the next statement. + -- Dead-code diagnostic: a terminator (`Exit`/`Return`) among the + -- non-last statements is followed by at least one more statement. + -- Flag it once at the position of the next statement. let isTerminator (s : StmtExprMd) : Bool := match s.val with | .Exit _ | .Return _ => true @@ -1177,9 +1152,9 @@ def Check.block (exprMd : StmtExprMd) s!"dead code after '{termName}'" modify fun st => { st with errors := st.errors.push diag } | none => pure () - -- Singleton/last case: forward `expected` to the trailing statement - -- (the dispatcher already narrows away the empty case, so this - -- `none` arm is dead in practice but kept to remain total). + -- Check the last statement against `expected`. The dispatcher only + -- calls `Check.block` on `head :: tail`, so the `none` (empty-list) + -- arm is dead and kept only to remain total. match _lastResult: stmts.getLast? with | none => checkSubtype source expected (Synth.emptyBlock source) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index dd0ca90956..3739b0f945 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -193,22 +193,11 @@ and {name Strata.Laurel.isConsistentSubtype}`isConsistentSubtype`: {docstring Strata.Laurel.isConsistentSubtype} -Statement-shaped constructs check at any value type. Their -conclusions are polymorphic in $`T`: the form contributes nothing to -the surrounding value flow, so its rule does not constrain $`T` -beyond what its own premises do. The block rule +The type of a block is the type of its last statement; non-last +statements can be of any type. The block rule ({ref "rules-control-flow"}[Block]) is what supplies the value type for a block: it routes the surrounding $`T` to the last statement -and ignores the value of every non-last statement. Every statement -form ({name Strata.Laurel.StmtExpr.Return}`Return`, -{name Strata.Laurel.StmtExpr.Exit}`Exit`, -{name Strata.Laurel.StmtExpr.While}`While`, -{name Strata.Laurel.StmtExpr.Assert}`Assert`, -{name Strata.Laurel.StmtExpr.Assume}`Assume`, -{name Strata.Laurel.StmtExpr.Assign}`Assign`, -{name Strata.Laurel.Variable.Declare}`Var Declare`) thus has a -conclusion of the form $`\Gamma \vdash s \Leftarrow A`, with $`A` -unconstrained. +and ignores the value of every non-last statement. ## Typing rules From 2f39d282562748f4b4eb4015246715e6db1cfd80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 4 Jun 2026 12:55:12 -0400 Subject: [PATCH 151/189] reword block rule clarify treatment of non terminal statements --- Strata/Languages/Laurel/Resolution.lean | 159 ++++++++++++++++-------- docs/verso/LaurelDoc.lean | 56 +++++---- 2 files changed, 137 insertions(+), 78 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 2480f144a4..64fd0ffe14 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -584,15 +584,33 @@ declared output-type list, written `T_o-bar` in the rule statements. That list is bound on entry to a procedure body (by `resolveProcedure` / `resolveInstanceProcedure`, stored on `ResolveState.answerType`) and consulted only by `Check.return`; -every other rule is independent of it. Statement-typed forms -(`Var-Declare`, `Assert`, `Assume`, `While`, `Exit`, `Return`) -check at any `A`: their conclusions are polymorphic in `A` because -they contribute nothing to the surrounding value flow. `Assign` -synthesizes its target tuple type but its check rule skips the -\[⇐\] Sub boundary check when expected is `TVoid`, so it also -behaves statement-shaped in discard position. `Block` routes the -surrounding expected type to the last statement, not to non-last -statements. +every other rule is independent of it. + +Several constructs are *statements*: their job is to have an effect, +not to produce a value. Their check rules accept whatever type the +surrounding context expects — the rule is written with the expected +type `A` left as a free variable (we call this **check at any `A`**), +which just means the rule does not look at `A` at all. There are two +reasons a construct ignores `A`: + +- It is a **control-flow terminator** (`Exit`, `Return`): it jumps + somewhere else and never hands a value back, so whatever the + context wanted, the jump satisfies it vacuously. `if c then 5 else + return` is fine in an `Int` context because the `else` branch never + produces anything at all. +- It runs and then **falls through without a value** (`Assert`, + `Assume`, `While`, `Var-Declare`). These conceptually have the unit + type `TVoid`; accepting any `A` is a slight over-acceptance that is + harmless in practice because such statements only ever appear in + non-last (discard) position, which is checked at `TVoid` anyway. + +`Assign` is the one statement that *does* produce a value: it +synthesizes the type of its right-hand side (so `x := e` can be used +where that type is expected), and its check rule skips the \[⇐\] Sub +boundary check only when the expected type is `TVoid` — i.e. when the +assignment is used purely for effect. `Block` routes the surrounding +expected type to its last statement (the block's value), not to the +non-last statements (which are effects, checked at `TVoid`). Each typing rule is implemented as its own helper inside the mutual block below. Helpers are grouped by section to mirror the *Typing @@ -860,9 +878,8 @@ def Synth.varField (exprMd : StmtExprMd) `⊣ Γ, x : T_x` records that the surrounding scope is extended with the new binding for the remainder of the enclosing block. The declaration itself does no work other than registering `x : T_x`, - so its conclusion is polymorphic in `A` — declarations are - statement-shaped and never deliver a value to the surrounding - context. -/ + and yields no value, so its rule accepts whatever type `A` the + context expects (the rule ignores `A`). -/ def Check.varDeclare (param : Parameter) (source : Option FileRange) : ResolveM StmtExprMd := do let ty' ← resolveHighType param.type @@ -883,9 +900,9 @@ def Check.varDeclare (param : Parameter) (source : Option FileRange) : `TBool`. The body's *value type* is discarded — control either re-enters the loop or falls through, so the body is checked at `Unknown` (the gradual wildcard) and any value the body's tail - might produce is ignored. The loop itself contributes no value - to its surrounding context, so its conclusion is polymorphic in - `A`. + might produce is ignored. A loop is a statement: it yields no + value, so its rule accepts whatever type `A` the context expects + (the rule ignores `A`). The optional `decreases` clause is synthesized and required to have a numeric type (`TInt`, `TReal`, `TFloat64`, or `Unknown` as @@ -926,11 +943,13 @@ def Check.while (exprMd : StmtExprMd) ─────────────────── Γ ⊢ Exit l ⇐ A ``` - `exit` is a control-flow terminator: it transfers control out of - the enclosing labeled block and does not deliver a value to the - surrounding context. Anything after `exit l` in the same block is - dead code, flagged by `Resolution.Check.block`. The construct - checks at any `A`. + `exit` is a control-flow terminator — an unconditional jump out of + the enclosing labeled block. Because it never falls through, it + never delivers a value, so the rule accepts whatever type `A` the + context expects (the rule ignores `A`): an `exit` slots into any + position, even one expecting a value, since control leaves before + any value would be needed. Anything after `exit l` in the same + block is dead code, flagged by `Resolution.Check.block`. The premise `l ∈ Γ_lbl` requires the target label to name an enclosing labeled block; labels live in their own namespace @@ -977,11 +996,13 @@ def Check.exit (target : String) (source : Option FileRange) : Γ ⊢ Return (some e) ↝ "multi-output procedure cannot use 'return e'; assign to named outputs instead" ``` `return` is the *only* rule whose premises depend on the enclosing - procedure's declared outputs. It is also a control-flow terminator: - it transfers control out of the enclosing procedure and does not - deliver a value to the surrounding block, so the conclusion is - polymorphic in `A` (anything after `return` in the same block is - dead code, flagged by `Resolution.Check.block`). + procedure's declared outputs. Like `Exit`, it is a control-flow + terminator: it transfers control out of the enclosing procedure and + never falls through to the surrounding block, so the rule accepts + whatever type `A` the context expects (the rule ignores `A`). The + returned value, if any, is checked against the procedure's declared + output rather than against `A`. Anything after `return` in the same + block is dead code, flagged by `Resolution.Check.block`. When `answerType = none` we are not inside any procedure body (e.g. resolving a constant initializer), so all `Return` checks are @@ -1065,6 +1086,40 @@ def Check.return (exprMd : StmtExprMd) def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := { val := .TVoid, source := source } +/-- (Discard) Check a statement in *effect position*, written `Γ ⊢ s ⋄`. + + Laurel has no syntactic statement/expression split — everything is a + `StmtExpr` — so "what may appear where its value is discarded" is + defined by this rule rather than by the grammar. A statement `s` is + admitted in effect position iff one of: + + * **`Γ ⊢ s ⇐ TVoid`** — `s` checks against `TVoid`. Every + statement-shaped form lands here: `Var-Declare`, `Assign`, `Assert`, + `Assume`, `While`, the terminators `Exit`/`Return` (whose check + rules are polymorphic in the expected type), an `IfThenElse` with + void branches, and a nested void `Block`. A stranded *value* — a + literal `5`, a variable load `x`, a comparison `a < b`, a `new`, a + value-producing `IfThenElse` — fails this check (its type is not + consistent with `TVoid`) and is reported as dead code. + + * **Discard-Call** — `s` is a call (`StaticCall`/`InstanceCall`). The + call is synthesized and its result dropped, so the `list.add(x);` + idiom type-checks even when the callee returns a value. A call is + the *only* value-producing form admitted in effect position: its + effects are the point and its result is incidental. + + This is the single definition of "what counts as a statement". It is + used by `Check.block` for every non-last statement, and for the last + statement when the block itself sits in statement position + (`expected = TVoid`). -/ +def Check.statement (s : StmtExprMd) : ResolveM StmtExprMd := do + match s.val with + | .StaticCall .. | .InstanceCall .. => + let (s', _) ← Synth.resolveStmtExpr s; pure s' + | _ => Check.resolveStmtExpr s { val := .TVoid, source := s.source } + termination_by (s, 4) + decreasing_by all_goals (apply Prod.Lex.right; decide) + /-- (Block) Check-mode typing rule for a non-empty block. A block's value is the value of its **last** statement; every @@ -1073,12 +1128,12 @@ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := recursion over that structure: * **non-last — `Γ ⊢ s ⇐ TVoid`.** A non-last statement is a pure - effect, so it is checked at `TVoid`. By \[⇐\] Sub this admits - every statement-shaped form (`Var-Declare`, `Assign`, `Assert`, - `Assume`, `While`, `Exit`, `Return`, `IfThenElse` — their - conclusions are polymorphic in the value type, so they trivially - check at `TVoid`) and rejects a stranded value expression like - `5;`, whose `TInt` is not consistent with `TVoid`. The one + effect, so it is checked at `TVoid`. This admits every statement + form (`Var-Declare`, `Assign`, `Assert`, `Assume`, `While`, + `Exit`, `Return`, `IfThenElse`), since each either yields no + value or — for the terminators `Exit`/`Return` — accepts any + expected type, and rejects a stranded value expression like `5;`, + whose `TInt` is not consistent with `TVoid`. The one **Discard-Call** carve-out: a call (`StaticCall`/`InstanceCall`) is synthesized and its result dropped, so the standard `list.add(x);` discard idiom is allowed even when the callee @@ -1109,22 +1164,18 @@ def Check.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Block stmts label) : ResolveM StmtExprMd := do - let voidTy : HighTypeMd := { val := .TVoid, source := source } - -- A non-last statement is an effect: check it at `TVoid`, *except* a - -- call, whose result we synthesize and drop (the `list.add(x);` idiom). - let checkNonLast (s : StmtExprMd) (_h_mem : s ∈ stmts) : ResolveM StmtExprMd := do - match s.val with - | .StaticCall .. | .InstanceCall .. => - let (s', _) ← Synth.resolveStmtExpr s; pure s' - | _ => Check.resolveStmtExpr s voidTy - -- The last statement carries the block's value: push `expected` in - -- (so check-only forms are reachable), with the Discard-Call carve-out - -- when the block is itself in statement position (`expected = TVoid`). + -- A non-last statement is in effect position: admitted by `Check.statement` + -- (`Γ ⊢ s ⋄` — checks at `TVoid`, with the Discard-Call carve-out for calls). + let checkNonLast (s : StmtExprMd) (_h_mem : s ∈ stmts) : ResolveM StmtExprMd := + Check.statement s + -- The last statement carries the block's value: push `expected` in (so + -- check-only forms are reachable). When the block itself sits in statement + -- position (`expected = TVoid`), the last statement is also in effect + -- position and goes through `Check.statement` (same Discard-Call carve-out). let checkLast (s : StmtExprMd) (_h_mem : s ∈ stmts) : ResolveM StmtExprMd := do - match s.val, expected.val with - | .StaticCall .., .TVoid | .InstanceCall .., .TVoid => - let (s', _) ← Synth.resolveStmtExpr s; pure s' - | _, _ => Check.resolveStmtExpr s expected + match expected.val with + | .TVoid => Check.statement s + | _ => Check.resolveStmtExpr s expected withScope <| withLabel label do let init' ← stmts.dropLast.attach.mapM fun ⟨s, hMem⟩ => do have h_mem : s ∈ stmts := List.dropLast_subset stmts hMem @@ -1220,9 +1271,9 @@ def Check.ifThenElse (exprMd : StmtExprMd) ────────────────────────────────── Γ ⊢ Assert cond ⇐ A ``` - `cond` is checked against `TBool`. The assertion form is - statement-typed and contributes nothing to the surrounding value - flow, so its conclusion is polymorphic in `A`. -/ + `cond` is checked against `TBool`. `assert` is a statement: it + yields no value, so the rule accepts whatever type `A` the context + expects (the rule ignores `A`). -/ def Check.assert (exprMd : StmtExprMd) (condExpr : StmtExprMd) (summary : Option String) (source : Option FileRange) @@ -1244,9 +1295,9 @@ def Check.assert (exprMd : StmtExprMd) ────────────────────────────────── Γ ⊢ Assume cond ⇐ A ``` - `cond` is checked against `TBool`. The assumption form is - statement-typed and contributes nothing to the surrounding value - flow, so its conclusion is polymorphic in `A`. -/ + `cond` is checked against `TBool`. `assume` is a statement: it + yields no value, so the rule accepts whatever type `A` the context + expects (the rule ignores `A`). -/ def Check.assume (exprMd : StmtExprMd) (cond : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assume cond) : @@ -2029,8 +2080,8 @@ def resolveBody (body : Body) (expected : HighTypeMd) : ResolveM Body := do the `A` in `Γ ⊢ body ⇐ A`. Functional procedures with a single output `T` expect `A = T`: the body's last statement is the result and must produce a `T`. Non-functional procedures expect - `A = Unknown`: their body is statement-typed and the last - statement (if any) is discarded — outputs are observed via + `A = Unknown`: their body is run as a statement and the last + statement's value (if any) is discarded — outputs are observed via `return e` (whose payload is matched against the procedure's declared outputs by `Resolution.Check.return`) or via named-output assignment. diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 3739b0f945..789c175fb6 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -322,16 +322,18 @@ ends, the surrounding $`\Gamma` is restored. The block also emits a `"dead code after ''"` diagnostic when an `Exit` or `Return` is followed by additional statements in the same block. -Statement-typed forms (`Var-Declare`, `Assign`, `Assert`, `Assume`, -`While`, `Exit`, `Return`, `IfThenElse`) trivially satisfy -$`\Gamma \vdash s \Leftarrow \mathsf{TVoid}` — their rule conclusions -are polymorphic in `A`, so they check at *any* type, including -$`\mathsf{TVoid}`. Bare expressions like `5;` fail via \[⇐\] Sub: the -synthesized type is not consistent with $`\mathsf{TVoid}`. The two -Discard-Call rules are what allow the standard `f(x);` idiom for a -non-void-returning `f` — without them, $`s \Leftarrow \mathsf{TVoid}` -would force every call to have a $`\mathsf{TVoid}`-compatible result -type. +Statement forms (`Var-Declare`, `Assign`, `Assert`, `Assume`, +`While`, `Exit`, `Return`, `IfThenElse`) all check against +$`\mathsf{TVoid}`. They fit there for one of two reasons: most yield +no value (so the unit type $`\mathsf{TVoid}` is exactly right), and +the terminators `Exit`/`Return` accept *any* expected type (their +rules leave the value type free — see \[⇐\] Exit and the Return rules +below — because control leaves before any value is needed). Bare +expressions like `5;` fail via \[⇐\] Sub: the synthesized type is not +consistent with $`\mathsf{TVoid}`. The two Discard-Call rules are what +allow the standard `f(x);` idiom for a non-void-returning `f` — +without them, $`s \Leftarrow \mathsf{TVoid}` would force every call to +have a $`\mathsf{TVoid}`-compatible result type. Pushing $`T` into the last statement (rather than synthesizing the whole block and applying \[⇐\] Sub at the boundary) means a type @@ -358,12 +360,15 @@ $`\mathsf{TVoid} <: \mathit{expected}`). $$`\frac{l \in \Gamma_{\mathrm{lbl}}}{\Gamma \vdash \mathsf{Exit}\;l \Leftarrow A} \quad \text{([⇐] Exit)}` -`exit` is non-returning — it transfers control out of the enclosing -labeled block, so it checks at *any* value type $`A` (no -$`\mathsf{TVoid}` side condition). Labels live in their own namespace -$`\Gamma_{\mathrm{lbl}}`, populated by the surrounding `Block` rule -when its $`\mathit{label}` is `some l`. An $`\mathsf{Exit}\;l` -targeting a label not in $`\Gamma_{\mathrm{lbl}}` is rejected. +`exit` is an unconditional jump out of the enclosing labeled block. +Because control leaves before any value is needed, the rule accepts +*any* expected value type $`A` — it leaves $`A` free, with no +$`\mathsf{TVoid}` side condition — so an `exit` slots into any +position, even one expecting a value. Labels live in their own +namespace $`\Gamma_{\mathrm{lbl}}`, populated by the surrounding +`Block` rule when its $`\mathit{label}` is `some l`. An +$`\mathsf{Exit}\;l` targeting a label not in $`\Gamma_{\mathrm{lbl}}` +is rejected. {docstring Strata.Laurel.Resolution.Check.exit} @@ -384,10 +389,13 @@ $$`\frac{\overline{T_o} = []}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) $$`\frac{\overline{T_o} = [T_1; \ldots; T_n] \quad (n \ge 2)}{\Gamma \vdash \mathsf{Return}\;(\mathsf{some}\;e) \rightsquigarrow \text{error: “multi-output procedure cannot use 'return e'; assign to named outputs instead”}} \quad \text{([⇐] Return-Multi-Error)}` `return` is the only rule whose premises depend on the enclosing -procedure's declared outputs. The conclusion's value type $`A` is -unconstrained, since `return` never falls through — it is a -control-flow terminator. The error arms fire when $`\overline{T_o}`'s -arity does not match the syntactic shape of `return e`. +procedure's declared outputs. The conclusion's value type $`A` is left +free — the rule accepts any expected type — because `return` is a +control-flow terminator: it never falls through, so it can stand in +any position, even one expecting a value. The returned value (if any) +is checked against the procedure's declared output, not against $`A`. +The error arms fire when $`\overline{T_o}`'s arity does not match the +syntactic shape of `return e`. Regardless of which arm fires, $`e` is always elaborated — it is checked against the declared output in the single-output case, @@ -413,9 +421,9 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda The body is checked at $`\mathsf{Unknown}`: control either re-enters the loop or falls through, so the body's value type is never observed -by the surrounding context. The loop itself contributes nothing to -the surrounding $`A`, so its conclusion is polymorphic in $`A` like -every other statement-typed form. +by the surrounding context. A loop is a statement and yields no value, +so the rule accepts any expected type $`A` (it leaves $`A` free), +exactly like the other statement forms. The optional $`\mathit{decreases}` clause is synthesized and required to have a numeric type via the same $`\mathsf{Numeric}` predicate @@ -663,7 +671,7 @@ $$`\frac{\overline{T_o} = \mathit{proc}.\mathit{outputs}.\mathit{types} \quad A The body's value type $`A` is computed by `procedureBodyType`: a single-output functional procedure expects $`A = T` (its body's last statement is the result), while every other procedure expects -$`A = \mathsf{Unknown}` (its body is statement-typed and the last +$`A = \mathsf{Unknown}` (its body is run as a statement and the last statement's value is discarded; outputs are observed via `return e`, matched against $`\overline{T_o}` by {name Strata.Laurel.Resolution.Check.return}`Check.return`, or via From 370542fefa30f8304cf50ebd88f11cf5fea0363d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 4 Jun 2026 15:06:42 -0400 Subject: [PATCH 152/189] rename lub to join --- Strata/Languages/Laurel/Resolution.lean | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index ec400f7326..e399d9efdd 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -510,7 +510,7 @@ private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := type: a homogeneous arithmetic expression `1 + 2` yields `TInt`, `1 + ` yields `TInt` (Unknown promotes), ` + ` yields `Unknown`, and `1 + 2.0` is rejected. -/ -private def consistencyLub (ctx : TypeContext) +private def join (ctx : TypeContext) (a b : HighTypeMd) : Option HighTypeMd := let a' := ctx.unfold a let b' := ctx.unfold b @@ -1520,7 +1520,7 @@ def Synth.instanceCall (exprMd : StmtExprMd) Arithmetic follows the same shape as `Op-Eq` but for n operands: synthesize each operand's type, require it to be `Numeric`, and - fold the operand types under `consistencyLub` (the LUB on the + fold the operand types under `join` (the LUB on the flat consistency lattice — `Unknown ⊔ T = T`, `T ⊔ T = T`, everything else inconsistent). The fold's result is the synthesized type. If any pair is inconsistent the rule emits a @@ -1554,7 +1554,7 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) for (a, aTy) in args'.zip argTypes do unless isNumeric ctx aTy do typeMismatch a.source (some expr) "expected a numeric type" aTy - -- Fold operands by consistencyLub, starting from `Unknown` so the + -- Fold operands by join, starting from `Unknown` so the -- empty list (impossible for these ops, but kept for totality) -- yields `Unknown` and a single-operand fold (`Neg`) yields the -- operand's type. @@ -1562,7 +1562,7 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) let resultTy := argTypes.foldl (fun acc aTy => match acc with - | some lub => consistencyLub ctx lub aTy + | some lub => join ctx lub aTy | none => none) (some unknownTy) match resultTy with From 178dfa109fe7266b5f0e5381d07dd4b921098a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 4 Jun 2026 16:53:20 -0400 Subject: [PATCH 153/189] fix compilation pipeline --- Strata/Languages/Laurel/Resolution.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index e399d9efdd..04853126a4 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -834,7 +834,7 @@ def Synth.litString (v : String) (source : Option FileRange) : StmtExpr × HighT (.LiteralString v, { val := .TString, source := source }) /-- `Γ ⊢ LiteralDecimal d ⇒ TReal` -/ -def Synth.litDecimal (v : Decimal) (source : Option FileRange) : StmtExpr × HighTypeMd := +def Synth.litDecimal (v : StrataDDM.Decimal) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralDecimal v, { val := .TReal, source := source }) -- ### Variables From aacf861c490eedb5c3adddfe7318908016966517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 4 Jun 2026 16:56:15 -0400 Subject: [PATCH 154/189] centralize $body tag --- Strata/Languages/Laurel/LaurelAST.lean | 15 +++++++++++++++ .../Languages/Laurel/LaurelToCoreTranslator.lean | 6 +++--- Strata/Languages/Laurel/Resolution.lean | 4 ++-- StrataPython/StrataPython/PythonToLaurel.lean | 6 +++--- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Strata/Languages/Laurel/LaurelAST.lean b/Strata/Languages/Laurel/LaurelAST.lean index a901e7bf02..a7177b2c5f 100644 --- a/Strata/Languages/Laurel/LaurelAST.lean +++ b/Strata/Languages/Laurel/LaurelAST.lean @@ -390,6 +390,21 @@ def StmtExpr.constrName : StmtExpr → String @[expose] abbrev StmtExprMd := AstNode StmtExpr @[expose] abbrev VariableMd := AstNode Variable +/-- The label of the implicit block that wraps every procedure body. + + `LaurelToCoreTranslator` lowers each procedure body to a single + `Core.Statement.block bodyLabel …`, and lowers an early `return` + (or, in the Python frontend, a Python `return`) to `Exit bodyLabel`, + so that jumping to the end of the body falls through past the block. + The resolution pass pre-registers this label in scope (via `withLabel`) + before walking a body, so those `Exit bodyLabel` jumps resolve even + though the label has no syntactic declaration site. + + Shared here so the translator, the resolver, and frontends agree on the + exact string rather than each hard-coding it. The leading `$` keeps it + out of the user-name space (no source identifier can contain `$`). -/ +def bodyLabel : String := "$body" + theorem AstNode.sizeOf_val_lt {t : Type} [SizeOf t] (e : AstNode t) : sizeOf e.val < sizeOf e := by cases e; grind diff --git a/Strata/Languages/Laurel/LaurelToCoreTranslator.lean b/Strata/Languages/Laurel/LaurelToCoreTranslator.lean index 83d6ad6335..594027f873 100644 --- a/Strata/Languages/Laurel/LaurelToCoreTranslator.lean +++ b/Strata/Languages/Laurel/LaurelToCoreTranslator.lean @@ -485,11 +485,11 @@ def translateStmt (stmt : StmtExprMd) | .Return valueOpt => match valueOpt with | none => - return [.exit "$body" md] + return [.exit bodyLabel md] | some _ => let d := md.toDiagnostic "Return statement with value should have been eliminated by EliminateValueReturns pass" DiagnosticType.StrataBug emitCoreDiagnostic d - return [.exit "$body" md] + return [.exit bodyLabel md] | .While cond invariants decreasesExpr body => let condExpr ← translateExpr cond let invExprs ← invariants.mapM (fun i => do return ("", ← translateExpr i)) @@ -570,7 +570,7 @@ def translateProcedure (proc : Procedure) : TranslateM Core.Procedure := do pure (postconditions.map fun (label, check) => Core.Statement.assume label check.expr mdWithUnknownLoc) -- Wrap body in a labeled block so early returns (exit) work correctly. - let body : List Core.Statement := [.block "$body" bodyStmts mdWithUnknownLoc] + let body : List Core.Statement := [.block bodyLabel bodyStmts mdWithUnknownLoc] let spec : Core.Procedure.Spec := { preconditions, postconditions } return { header, spec, body } diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 04853126a4..594eacf5b1 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -2158,8 +2158,8 @@ def resolveInstanceProcedure (typeName : Identifier) (proc : Procedure) : Resolv let savedAnswer := (← get).answerType modify fun s => { s with answerType := some (outputs'.map (·.type)) } let bodyExpected := procedureBodyType proc.isFunctional outputs' proc.name.source - -- See `resolveProcedure` for the rationale on `$body`. - let body' ← withLabel (some "$body") <| resolveBody proc.body bodyExpected + -- See `resolveProcedure` for the rationale on `bodyLabel`. + let body' ← withLabel (some bodyLabel) <| resolveBody proc.body bodyExpected modify fun s => { s with answerType := savedAnswer } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source diff --git a/StrataPython/StrataPython/PythonToLaurel.lean b/StrataPython/StrataPython/PythonToLaurel.lean index 48e76c3e3e..58364463c6 100644 --- a/StrataPython/StrataPython/PythonToLaurel.lean +++ b/StrataPython/StrataPython/PythonToLaurel.lean @@ -1815,7 +1815,7 @@ partial def translateStmt (ctx : TranslationContext) (s : stmt SourceRange) let whileWrapped := mkStmtExprMdWithLoc (StmtExpr.Block [whileStmt] (some breakLabel)) md return (loopCtx, preamble ++ [whileWrapped]) - -- Return statement: assign to the LaurelResult output parameter, then exit $body. + -- Return statement: assign to the LaurelResult output parameter, then exit the body block. | .Return _ value => do let stmts ← match value.val with | some expr => do @@ -1824,8 +1824,8 @@ partial def translateStmt (ctx : TranslationContext) (s : stmt SourceRange) -- Coerce Composite return values to Any for LaurelResult : Any let eRef ← coerceToAny ctx expr eRef let assign := mkStmtExprMdWithLoc (StmtExpr.Assign [mkVariableMd (.Local PyLauFuncReturnVar)] eRef) md - .ok $ preamble ++ [assign, mkStmtExprMdWithLoc (StmtExpr.Exit "$body") md] - | none => .ok [mkStmtExprMdWithLoc (StmtExpr.Exit "$body") md] + .ok $ preamble ++ [assign, mkStmtExprMdWithLoc (StmtExpr.Exit bodyLabel) md] + | none => .ok [mkStmtExprMdWithLoc (StmtExpr.Exit bodyLabel) md] return (ctx, stmts) -- Assert statement From c780864931b8b1152cdf2f04611bd8c90d7f3e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 4 Jun 2026 16:56:30 -0400 Subject: [PATCH 155/189] clarify typing rules --- Strata/Languages/Laurel/Resolution.lean | 43 +++++++++++++++++++------ docs/verso/LaurelDoc.lean | 8 +++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 594eacf5b1..949b0db63f 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1426,8 +1426,9 @@ def Check.assign (exprMd : StmtExprMd) `Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n] (n ≠ 1), Γ ⊢ args_i ⇐ Ts_i (pairwise) ∴ Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n]` Callee is resolved against the expected kinds (parameter, static - procedure, datatype constructor, constant); each argument is - *checked* against the corresponding parameter type. The bidirectional + procedure, datatype constructor, datatype destructor, constant); each + argument is *checked* against the corresponding parameter type. The + bidirectional push lets impure-expression arguments (`{x := 1; x}`, `if c then …`, holes) flow through their own check rules instead of bottoming out at the synth wildcard. Arguments past the declared parameter list (or @@ -1839,7 +1840,20 @@ def Synth.quantifier (exprMd : StmtExprMd) /-- `Γ ⊢ name ⇒ _ ∴ Γ ⊢ Assigned name ⇒ TBool` - `name` is synthesized; the construct synthesizes `TBool`. -/ + `assigned x` is a verification predicate that holds when `x` has + been definitely assigned. The construct unconditionally synthesizes + `TBool`; the operand's synthesized type is discarded, and `Assigned` + imposes no constraint on it. + + The operand is still resolved (via `Synth.resolveStmtExpr`) purely + for its name-resolution side effects — its identifier must point at a + definition so that downstream passes can reason about the binding — + but the result type is thrown away. `Assigned` is meant to name a + variable or field, yet its AST field is an arbitrary `StmtExpr` + (`Assigned (name : StmtExprMd)`), so this rule does *not* enforce + that shape: it is not correct-by-construction, and the type checker + deliberately leaves the operand unconstrained rather than rejecting, + say, `assigned (a + b)`. -/ def Synth.assigned (exprMd : StmtExprMd) (name : StmtExprMd) (source : Option FileRange) (h : exprMd.val = .Assigned name) : @@ -1855,8 +1869,19 @@ def Synth.assigned (exprMd : StmtExprMd) /-- `Γ ⊢ v ⇐ T ∴ Γ ⊢ Old v ⇐ T` - `Old v` has the same type as `v`, so the surrounding expectation - propagates straight through. -/ + `old(v)` refers to the pre-state value of `v` in a postcondition. + It has the same type as `v`, so the surrounding expectation + propagates straight through: `v` is checked against the same `T`, + and the result is wrapped back up as `Old v'`. + + The rule is type-transparent and deliberately does *not* restrict + `v` to an identifier or lvalue. `old` wraps an arbitrary expression + (`Old (value : StmtExprMd)`), matching Dafny, where `old(this.f + + g())` is legal — the pre-state is taken of the whole expression. + Whether `v` denotes something whose pre-state is meaningful is a + well-formedness question for the verifier's heap model, not a typing + one, so resolution only resolves names inside `v` and checks its + type; it imposes no syntactic shape on `v`. -/ def Check.old (exprMd : StmtExprMd) (val : StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) (h : exprMd.val = .Old val) : @@ -2117,11 +2142,11 @@ def resolveProcedure (proc : Procedure) : ResolveM Procedure := do let savedAnswer := (← get).answerType modify fun s => { s with answerType := some (outputs'.map (·.type)) } let bodyExpected := procedureBodyType proc.isFunctional outputs' proc.name.source - -- Pre-register the implicit `$body` label that the LaurelToCore - -- translator wraps the body in (`Core.Statement.block "$body" …`), - -- so that frontends emitting `Exit "$body"` for early-return lowering + -- Pre-register the implicit `bodyLabel` block that the LaurelToCore + -- translator wraps every body in (`Core.Statement.block bodyLabel …`), + -- so that frontends emitting `Exit bodyLabel` for early-return lowering -- (e.g. PythonToLaurel) don't trip Check.exit's label-scope check. - let body' ← withLabel (some "$body") <| resolveBody proc.body bodyExpected + let body' ← withLabel (some bodyLabel) <| resolveBody proc.body bodyExpected modify fun s => { s with answerType := savedAnswer } if !proc.isFunctional && body'.isTransparent then let diag := diagnosticFromSource proc.name.source diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index aef67c54d2..cc2969e101 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -358,6 +358,14 @@ $`\mathsf{TVoid} <: \mathit{expected}`). {docstring Strata.Laurel.Resolution.Check.block} +The Discard-Call carve-outs and the "checks against $`\mathsf{TVoid}`" +behaviour for non-last (and discarded-last) statements are factored out +into {name Strata.Laurel.Resolution.Check.statement}`Check.statement`, +the single definition of what counts as a statement in effect position +($`\Gamma \vdash s\;\diamond`): + +{docstring Strata.Laurel.Resolution.Check.statement} + $$`\frac{l \in \Gamma_{\mathrm{lbl}}}{\Gamma \vdash \mathsf{Exit}\;l \Leftarrow A} \quad \text{([⇐] Exit)}` `exit` is an unconditional jump out of the enclosing labeled block. From 980d1adc3db73480de018a0551f149e240e458be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 5 Jun 2026 10:19:52 -0400 Subject: [PATCH 156/189] fix imports --- .../Fundamentals/T23_ArithTyping.lean | 7 +++++-- .../Examples/Objects/T9_IfBranchJoin.lean | 7 +++++-- .../Laurel/ResolutionTypeCheckTests.lean | 20 +++++++++++-------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean index 6adda933e6..f966d64337 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean @@ -3,9 +3,12 @@ SPDX-License-Identifier: Apache-2.0 OR MIT -/ +module -import StrataTest.Util.TestDiagnostics -import StrataTest.Languages.Laurel.TestExamples +meta import all StrataTest.Util.TestDiagnostics +meta import all StrataTest.Languages.Laurel.TestExamples + +meta section open StrataTest.Util diff --git a/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean b/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean index 9d5ceeb0c8..30c7c104df 100644 --- a/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean +++ b/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean @@ -3,9 +3,12 @@ SPDX-License-Identifier: Apache-2.0 OR MIT -/ +module -import StrataTest.Util.TestDiagnostics -import StrataTest.Languages.Laurel.TestExamples +meta import all StrataTest.Util.TestDiagnostics +meta import all StrataTest.Languages.Laurel.TestExamples + +meta section open StrataTest.Util diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index e5587ab9b9..18e659fb2a 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -3,28 +3,32 @@ SPDX-License-Identifier: Apache-2.0 OR MIT -/ +module /- Tests that the resolution pass detects type checking errors — e.g. using an int where a bool is expected, or passing the wrong type to a procedure. -/ -import StrataTest.Util.TestDiagnostics -import Strata.DDM.Elab -import Strata.DDM.BuiltinDialects.Init -import Strata.Languages.Laurel.Grammar.LaurelGrammar -import Strata.Languages.Laurel.Grammar.ConcreteToAbstractTreeTranslator -import Strata.Languages.Laurel.Resolution +meta import all StrataTest.Util.TestDiagnostics +meta import StrataDDM.Elab +meta import StrataDDM.BuiltinDialects.Init +meta import Strata.Languages.Laurel.Grammar.LaurelGrammar +meta import Strata.Languages.Laurel.Grammar.ConcreteToAbstractTreeTranslator +meta import Strata.Languages.Laurel.Resolution + +meta section open StrataTest.Util open Strata -open Strata.Elab (parseStrataProgramFromDialect) +open StrataDDM (initDialect) +open StrataDDM.Elab (parseStrataProgramFromDialect) namespace Strata.Laurel /-- Run only parsing + resolution and return diagnostics (no SMT verification). -/ private def processResolution (input : Lean.Parser.InputContext) : IO (Array Diagnostic) := do - let dialects := Strata.Elab.LoadedDialects.ofDialects! #[initDialect, Laurel] + let dialects := StrataDDM.Elab.LoadedDialects.ofDialects! #[initDialect, Laurel] let strataProgram ← parseStrataProgramFromDialect dialects Laurel.name input let uri := Strata.Uri.file input.fileName match Laurel.TransM.run uri (Laurel.parseProgram strataProgram) with From 3c15bb96dcac72d8292cefc706dc284ac69d9537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 8 Jun 2026 09:45:27 -0400 Subject: [PATCH 157/189] fix variable shadowing --- Strata/Languages/Laurel/Resolution.lean | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 949b0db63f..7afd8326e1 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -527,17 +527,22 @@ private def isReference (ctx : TypeContext) (ty : HighTypeMd) : Bool := | .UserDefined _ | .Unknown => true | _ => false -/-- Get the type of a resolved reference. Tries the lexical scope by name - first; if that misses (notably for fields, which are scoped under - qualified keys like "Container.intValue"), falls back to a uniqueId - lookup populated as definitions are registered. -/ +/-- Get the type of a resolved reference. Prefers the resolved definition by + `uniqueId` (the post-resolution ground truth, populated as definitions are + registered and never shadowed): a field reference carries its field's + `uniqueId`, but its bare `text` may collide with a same-named local in + `scope`, so a name-keyed lookup would read the shadowing local's type + instead of the field's. Falls back to a name lookup for references whose + `uniqueId` is not filled in — notably local loads, which `Synth.varLocal` + passes here unresolved and which are correctly keyed by `text` — and + finally to `Unknown`. -/ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do let s ← get - match s.scope.get? ref.text with - | some (_, node) => pure node.getType + match ref.uniqueId.bind s.idToNode.get? with + | some node => pure node.getType | none => - match ref.uniqueId.bind s.idToNode.get? with - | some node => pure node.getType + match s.scope.get? ref.text with + | some (_, node) => pure node.getType | none => pure { val := .Unknown, source := ref.source } /-- Get the call return type and parameter types for a callee from scope. -/ From 7e42c7ad88e00fb0b2da76b1d7e84c27b21f95b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 8 Jun 2026 11:43:29 -0400 Subject: [PATCH 158/189] add small glossary --- docs/verso/LaurelDoc.lean | 54 ++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index cc2969e101..796a2d8ba6 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -35,6 +35,9 @@ Laurel is an intermediate verification language designed to serve as a target fo garbage-collected languages that include imperative features, such as Java, Python, and JavaScript. Laurel tries to include any features that are common to those three languages. +In the feature lists below, items marked *(WIP)* are designed or planned but not +yet fully implemented; everything else is available today. + Laurel enables doing various forms of verification: - Deductive verification - (WIP) Model checking @@ -84,11 +87,24 @@ share a single implementation type, the StmtExpr. This reduces duplication for c like conditionals and variable declarations. Each StmtExpr has a user facing type, which for statement-like constructs could be void. +## Roadmap + +This manual follows the language from the ground up: it first describes Laurel's +types, then its unified expression/statement model, then procedures and whole +programs. It then turns to the bidirectional type checker, and finally to the +translation pipeline that lowers a checked Laurel program to Strata Core. + # Types Laurel's type system includes primitive types, collection types, and user-defined types. -## Primitive Types +## The Type System + +The {name Strata.Laurel.HighType}`HighType` type enumerates every type Laurel +tracks — primitives, collections, and user-defined types, alongside a few +internal constructors (such as `THeap`, `Unknown`, and `MultiValuedExpr`) that +the compilation pipeline introduces and that cannot be written in the surface +grammar. {docstring Strata.Laurel.HighType} @@ -206,7 +222,31 @@ Each construct is given as a derivation. `Γ` is the current lexical scope (see every premise and conclusion unless a rule explicitly extends it (written `Γ, x : T`). Each rule is tagged with `[⇒]` (synthesis) or `[⇐]` (checking) to make the -direction explicit. +direction explicit. The {ref "rules-procedure"}[*Procedure*] rule is the one +exception: it is a top-level well-formedness judgment and carries no direction +tag. + +The following notation recurs throughout the rules: + +- $`A <: B` — subtyping ({name Strata.Laurel.isSubtype}`isSubtype`); see + *Gradual typing* above. +- $`A \sim B` — the *consistency* relation + {name Strata.Laurel.isConsistent}`isConsistent`: symmetric, with + $`\mathsf{Unknown}` acting as a wildcard. +- $`A <:_\sim B` — the *consistent-subtype* relation + {name Strata.Laurel.isConsistentSubtype}`isConsistentSubtype`, the gradual + combination of the two above. +- $`\mathsf{Numeric}\;T` — a predicate holding when $`T` is consistent with one + of $`\mathsf{TInt}`, $`\mathsf{TReal}`, $`\mathsf{TFloat64}`. +- $`\dashv \Gamma'` — a rule's *output scope*: the judgment threads $`\Gamma` in + and produces $`\Gamma'` out. Only \[⇐\] Var-Declare and \[⇐\] Block-Cons use + this to extend the scope. +- $`\rightsquigarrow \text{error: …}` — the rule emits an error and aborts; no + type is produced. +- $`[\text{emits …}]` — the rule produces its type but also emits a diagnostic. +- $`\mapsto` — elaboration: the construct is rewritten to the form on the right. + +The Index below links to each construct's subsection. ### Index @@ -214,8 +254,8 @@ direction explicit. - {ref "rules-literals"}[*Literals*] — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal - {ref "rules-variables"}[*Variables*] — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇐\] Var-Declare - {ref "rules-control-flow"}[*Control flow*] — \[⇐\] If, \[⇐\] If-NoElse; - \[⇐\] Block-Singleton, \[⇐\] Block-Cons, \[⇒\] Skip, - \[⇐\] Discard-Call-Last, \[⇐\] Discard-Call-Cons; \[⇐\] Exit; + \[⇐\] Block-Singleton, \[⇐\] Block-Cons, + \[⇐\] Discard-Call-Cons, \[⇐\] Discard-Call-Last, \[⇒\] Skip; \[⇐\] Exit; \[⇐\] Return-None-Void, \[⇐\] Return-None-Single, \[⇐\] Return-None-Multi, \[⇐\] Return-Some, \[⇐\] Return-Void-Error, \[⇐\] Return-Multi-Error; \[⇐\] While @@ -540,8 +580,8 @@ combination is rejected. So `1 + 2` synthesizes $`\mathsf{TInt}`, $`\mathsf{TInt}` (the $`\mathsf{Unknown}` operand promotes to its neighbour), ` + ` synthesizes $`\mathsf{Unknown}`, and `1 + 2.0` is rejected with a "cannot apply '+' to operands of types -'int', 'real'" diagnostic. The fold runs via `consistencyLub`, a -pure predicate, so the search has no diagnostic side-effects. +'int', 'real'" diagnostic. The fold runs via `join`, a +pure function, so the search has no diagnostic side-effects. $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` @@ -674,7 +714,7 @@ resolved under a scope that includes the procedure's input and output parameters. The Return rules above refer to the same output list $`\overline{T_o}` that the procedure binds here. -$$`\frac{\overline{T_o} = \mathit{proc}.\mathit{outputs}.\mathit{types} \quad A = \mathsf{bodyType}(\mathit{proc}) \quad \Gamma_\mathit{global},\,\mathit{params}(\mathit{proc}) \vdash \mathit{proc}.\mathit{body} \Leftarrow A}{\Gamma_\mathit{global} \vdash \mathsf{Procedure}\;\mathit{proc}} \quad \text{(Procedure)}` +$$`\frac{\overline{T_o} = \mathit{proc}.\mathit{outputs}.\mathit{types} \quad A = \mathsf{procedureBodyType}(\mathit{proc}) \quad \Gamma_\mathit{global},\,\mathit{params}(\mathit{proc}) \vdash \mathit{proc}.\mathit{body} \Leftarrow A}{\Gamma_\mathit{global} \vdash \mathsf{Procedure}\;\mathit{proc}} \quad \text{(Procedure)}` The body's value type $`A` is computed by `procedureBodyType`: a single-output functional procedure expects $`A = T` (its body's last From dcff9b4ad74f8430da8998502f34b1701b0ff183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 8 Jun 2026 11:43:45 -0400 Subject: [PATCH 159/189] test variable "shadowing" (in a field) --- .../Laurel/ResolutionTypeCheckTests.lean | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 18e659fb2a..1eb6694640 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -198,4 +198,28 @@ procedure test() opaque { #guard_msgs (error, drop all) in #eval testInputWithOffset "UserDefinedCrossType" userDefinedCrossType 170 processResolution +/-! ## Field type is read from the field, not a shadowing local + +A field reference (`c#flag`) carries the field's `uniqueId`, but its bare +name can collide with a same-named local. `getVarType` must read the field's +declared type (`bool`) — not the shadowing local's type (`int`) — so the +assignment of an `int` to a `bool` field is still rejected. (Regression guard +for the scope-first lookup that previously returned the local's type and +silently dropped the mismatch.) -/ + +def fieldShadowedByLocal := r" +composite C { + var flag: bool +} +procedure test() opaque { + var c: C := new C; + var flag: int := 0; + c#flag := flag +// ^^^^ error: expected 'bool', got 'int' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "FieldShadowedByLocal" fieldShadowedByLocal 184 processResolution + end Laurel From 6208951fc45902cbb2ae0b7f447554af27867459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 8 Jun 2026 12:41:41 -0400 Subject: [PATCH 160/189] clearer high level explanations --- docs/verso/LaurelDoc.lean | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 796a2d8ba6..b039e2c5fc 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -35,6 +35,14 @@ Laurel is an intermediate verification language designed to serve as a target fo garbage-collected languages that include imperative features, such as Java, Python, and JavaScript. Laurel tries to include any features that are common to those three languages. +This manual follows the language from the ground up: it first describes Laurel's +types, then its unified expression/statement model, then procedures and whole +programs. It then turns to type checking — a per-construct reference for the +bidirectional rules — and finally to the translation pipeline that lowers a +checked Laurel program to Strata Core. + +## Features + In the feature lists below, items marked *(WIP)* are designed or planned but not yet fully implemented; everything else is available today. @@ -87,24 +95,16 @@ share a single implementation type, the StmtExpr. This reduces duplication for c like conditionals and variable declarations. Each StmtExpr has a user facing type, which for statement-like constructs could be void. -## Roadmap - -This manual follows the language from the ground up: it first describes Laurel's -types, then its unified expression/statement model, then procedures and whole -programs. It then turns to the bidirectional type checker, and finally to the -translation pipeline that lowers a checked Laurel program to Strata Core. - # Types -Laurel's type system includes primitive types, collection types, and user-defined types. - -## The Type System +Laurel's types come in two groups: those a user can write — primitives, +collections, and user-defined types — and a few internal constructors the +implementation introduces that have no surface syntax. The {name Strata.Laurel.HighType}`HighType` type enumerates every type Laurel -tracks — primitives, collections, and user-defined types, alongside a few -internal constructors (such as `THeap`, `Unknown`, and `MultiValuedExpr`) that -the compilation pipeline introduces and that cannot be written in the surface -grammar. +tracks. Alongside the user-writable types it also includes internal constructors +(such as `THeap`, `Unknown`, and `MultiValuedExpr`) that the compiler introduces +during resolution and later passes; these have no surface syntax. {docstring Strata.Laurel.HighType} From 851f8c990c7bf9b0eaa74ef407d219b8e205f41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 8 Jun 2026 15:31:52 -0400 Subject: [PATCH 161/189] update docstring rule presentation --- Strata/Languages/Laurel/Resolution.lean | 267 ++++++++++++++++++------ 1 file changed, 200 insertions(+), 67 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 7afd8326e1..8df166dbfd 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -844,8 +844,12 @@ def Synth.litDecimal (v : StrataDDM.Decimal) (source : Option FileRange) : StmtE -- ### Variables -/-- `Γ(x) = T ∴ Γ ⊢ Var (.Local x) ⇒ T` - +/-- (Var-Local) + ``` + Γ(x) = T + ────────────────────── + Γ ⊢ Var (.Local x) ⇒ T + ``` Resolves `ref` against the lexical scope and reads its declared type. -/ def Synth.varLocal (ref : Identifier) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do @@ -853,8 +857,13 @@ def Synth.varLocal (ref : Identifier) (source : Option FileRange) : let ty ← getVarType ref pure (.Var (.Local ref'), ty) -/-- `Γ ⊢ e ⇒ _, Γ(f) = T_f ∴ Γ ⊢ Var (.Field e f) ⇒ T_f` - +/-- (Var-Field) + ``` + Γ ⊢ e ⇒ _ + Γ(f) = T_f + ─────────────────────────── + Γ ⊢ Var (.Field e f) ⇒ T_f + ``` `f` is looked up against the type of `e` (or the enclosing instance type for `self.f`); the typing rule itself is path-agnostic. -/ def Synth.varField (exprMd : StmtExprMd) @@ -894,8 +903,10 @@ def Check.varDeclare (param : Parameter) (source : Option FileRange) : /-- (While) ``` - Γ ⊢ cond ⇐ TBool Γ ⊢ invs_i ⇐ TBool - Γ ⊢ decreases ⇒ U Numeric U + Γ ⊢ cond ⇐ TBool + Γ ⊢ invs_i ⇐ TBool + Γ ⊢ decreases ⇒ U + Numeric U Γ ⊢ body ⇐ Unknown ───────────────────────────────────────────────── Γ ⊢ While cond invs decreases body ⇐ A @@ -1230,11 +1241,15 @@ def Check.block (exprMd : StmtExprMd) /-- (If / If-NoElse) ``` - Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T Γ ⊢ elseBr ⇐ T (If) + Γ ⊢ cond ⇐ TBool (If) + Γ ⊢ thenBr ⇐ T + Γ ⊢ elseBr ⇐ T ────────────────────────────────────────────────────────────────── Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇐ T - Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇐ T TVoid <: T (If-NoElse) + Γ ⊢ cond ⇐ TBool (If-NoElse) + Γ ⊢ thenBr ⇐ T + TVoid <: T ────────────────────────────────────────────────────────────────── Γ ⊢ IfThenElse cond thenBr none ⇐ T ``` @@ -1320,7 +1335,8 @@ def Check.assume (exprMd : StmtExprMd) /-- (Assign) ``` - Γ ⊢ targets_i ⇒ T_i Γ ⊢ e ⇐ ExpectedTy + Γ ⊢ targets_i ⇒ T_i + Γ ⊢ e ⇐ ExpectedTy ───────────────────────────────────────────────────────── Γ ⊢ Assign targets e ⇒ ExpectedTy ``` @@ -1425,11 +1441,18 @@ def Check.assign (exprMd : StmtExprMd) -- ### Calls /-- Cases on the arity of the callee's declared outputs. - - `Γ(callee) = static-procedure with input T and output T', Γ ⊢ arg ⇐ T ∴ Γ ⊢ StaticCall callee arg ⇒ T'` - - `Γ(callee) = static-procedure with inputs Ts and outputs [T_1; …; T_n] (n ≠ 1), Γ ⊢ args_i ⇐ Ts_i (pairwise) ∴ Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n]` - + ``` + Γ(callee) = static-procedure with input T and output T' (Static-Call) + Γ ⊢ arg ⇐ T + ────────────────────────────────────────────────────── + Γ ⊢ StaticCall callee arg ⇒ T' + + Γ(callee) = static-procedure with inputs Ts (Static-Call-Multi) + and outputs [T_1; …; T_n] (n ≠ 1) + Γ ⊢ args_i ⇐ Ts_i (pairwise) + ────────────────────────────────────────────────────── + Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] + ``` Callee is resolved against the expected kinds (parameter, static procedure, datatype constructor, datatype destructor, constant); each argument is *checked* against the corresponding parameter type. The @@ -1464,8 +1487,15 @@ def Synth.staticCall (exprMd : StmtExprMd) have := List.sizeOf_lt_of_mem ‹_ ∈ args› omega -/-- `Γ ⊢ target ⇒ _, Γ(callee) = instance- or static-procedure with inputs [self; T] and output T', Γ ⊢ arg ⇐ T ∴ Γ ⊢ InstanceCall target callee arg ⇒ T'` - +/-- (Instance-Call) + ``` + Γ ⊢ target ⇒ _ + Γ(callee) = instance- or static-procedure + with inputs [self; T] and output T' + Γ ⊢ arg ⇐ T + ───────────────────────────────────────── + Γ ⊢ InstanceCall target callee arg ⇒ T' + ``` Target is synthesized; callee resolves to an instance or static procedure; arguments are checked pairwise against the callee's parameter types after dropping `self`. Like `Synth.staticCall`, the @@ -1501,17 +1531,39 @@ def Synth.instanceCall (exprMd : StmtExprMd) -- ### Primitive operations /-- Cases on the operator family. - - `Γ ⊢ args_i ⇒ U_i, U_i <: TBool, op ∈ {And, Or, AndThen, OrElse, Not, Implies} ∴ Γ ⊢ PrimitiveOp op args ⇒ TBool` - - `Γ ⊢ args_i ⇒ U_i, Numeric U_i, op ∈ {Lt, Leq, Gt, Geq} ∴ Γ ⊢ PrimitiveOp op args ⇒ TBool` - - `Γ ⊢ lhs ⇒ T_l, Γ ⊢ rhs ⇒ T_r, T_l ~ T_r, op ∈ {Eq, Neq} ∴ Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool` - - `Γ ⊢ args_i ⇒ U_i, Numeric U_i, T = ⨆ U_i (consistency LUB), op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇒ T` - - `Γ ⊢ args_i ⇒ U_i, U_i <: TString, op = StrConcat ∴ Γ ⊢ PrimitiveOp op args ⇒ TString` - + ``` + Γ ⊢ args_i ⇒ U_i (Op-Bool) + U_i <: TBool + op ∈ {And, Or, AndThen, OrElse, Not, Implies} + ───────────────────────────────────────────── + Γ ⊢ PrimitiveOp op args ⇒ TBool + + Γ ⊢ args_i ⇒ U_i (Op-Cmp) + Numeric U_i + op ∈ {Lt, Leq, Gt, Geq} + ───────────────────────────────────────────── + Γ ⊢ PrimitiveOp op args ⇒ TBool + + Γ ⊢ lhs ⇒ T_l (Op-Eq) + Γ ⊢ rhs ⇒ T_r + T_l ~ T_r + op ∈ {Eq, Neq} + ───────────────────────────────────────────── + Γ ⊢ PrimitiveOp op [lhs; rhs] ⇒ TBool + + Γ ⊢ args_i ⇒ U_i (Op-Arith) + Numeric U_i + T = ⨆ U_i (consistency LUB) + op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} + ───────────────────────────────────────────── + Γ ⊢ PrimitiveOp op args ⇒ T + + Γ ⊢ args_i ⇒ U_i (Op-Concat) + U_i <: TString + op = StrConcat + ───────────────────────────────────────────── + Γ ⊢ PrimitiveOp op args ⇒ TString + ``` `Numeric T` is the predicate "T unfolds to TInt / TReal / TFloat64 (or Unknown via the gradual escape hatch)" — not a single type, so it cannot serve as an `expected` for `Check.resolveStmtExpr`. `~` is @@ -1623,11 +1675,19 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) omega /-- Cases on the operator family. - - `Numeric T, Γ ⊢ args_i ⇐ T, op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ∴ Γ ⊢ PrimitiveOp op args ⇐ T` - - `TBool <: T, Γ ⊢ args_i ⇐ TBool, op ∈ {And, Or, AndThen, OrElse, Not, Implies} ∴ Γ ⊢ PrimitiveOp op args ⇐ T` - + ``` + Numeric T (Op-Arith) + Γ ⊢ args_i ⇐ T + op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} + ───────────────────────────────────────────── + Γ ⊢ PrimitiveOp op args ⇐ T + + TBool <: T (Op-Bool) + Γ ⊢ args_i ⇐ TBool + op ∈ {And, Or, AndThen, OrElse, Not, Implies} + ───────────────────────────────────────────── + Γ ⊢ PrimitiveOp op args ⇐ T + ``` Both families run in check mode: the surrounding `expected` must admit the family's natural result type (numeric for arithmetic, `TBool` for boolean), and that operand type is pushed into every @@ -1680,11 +1740,15 @@ def Check.primitiveOp (exprMd : StmtExprMd) -- ### Object forms /-- Cases on whether `ref` resolves to a composite/datatype. + ``` + Γ(ref) is a composite or datatype T (New-Ok) + ─────────────────────────────────── + Γ ⊢ New ref ⇒ UserDefined T - `Γ(ref) is a composite or datatype T ∴ Γ ⊢ New ref ⇒ UserDefined T` - - `Γ(ref) is not a composite or datatype ∴ Γ ⊢ New ref ⇒ Unknown` - + Γ(ref) is not a composite or datatype (New-Fallback) + ─────────────────────────────────── + Γ ⊢ New ref ⇒ Unknown + ``` When `ref` resolves to a composite or datatype, the type is `UserDefined ref`; otherwise `Unknown` (suppresses cascading errors after the kind diagnostic has already fired). -/ @@ -1701,8 +1765,13 @@ def Synth.new (ref : Identifier) (source : Option FileRange) : else { val := HighType.Unknown, source := source } pure (.New ref', ty) -/-- `Γ ⊢ target ⇒ U, U ~ T ∨ U <: T ∨ T <: U ∴ Γ ⊢ AsType target T ⇒ T` - +/-- (AsType) + ``` + Γ ⊢ target ⇒ U + U ~ T ∨ U <: T ∨ T <: U + ────────────────────────────────────────────── + Γ ⊢ AsType target T ⇒ T + ``` `target` synthesizes some type `U`; the cast is allowed when `U` and `T` sit in the same lineage modulo gradual `Unknown` — either consistent after unfolding aliases/constrained types (e.g. `5 as Int` @@ -1731,8 +1800,13 @@ def Synth.asType (exprMd : StmtExprMd) simp [h] at hsz omega -/-- `Γ ⊢ target ⇒ U, U ~ T ∨ U <: T ∨ T <: U ∴ Γ ⊢ IsType target T ⇒ TBool` - +/-- (IsType) + ``` + Γ ⊢ target ⇒ U + U ~ T ∨ U <: T ∨ T <: U + ────────────────────────────────────────────── + Γ ⊢ IsType target T ⇒ TBool + ``` Same lineage check as `AsType` — `is` only makes sense between types that share a lineage modulo gradual `Unknown`; testing `5 is Cat` is statically nonsense. The synthesized type is `TBool`. -/ @@ -1755,8 +1829,16 @@ def Synth.isType (exprMd : StmtExprMd) simp [h] at hsz omega -/-- `Γ ⊢ lhs ⇒ T_l, Γ ⊢ rhs ⇒ T_r, isReference T_l, isReference T_r, T_l ~ T_r ∴ Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool` - +/-- (RefEq) + ``` + Γ ⊢ lhs ⇒ T_l + Γ ⊢ rhs ⇒ T_r + isReference T_l + isReference T_r + T_l ~ T_r + ────────────────────────────────────────────────── + Γ ⊢ ReferenceEquals lhs rhs ⇒ TBool + ``` Both operands must be reference types (`UserDefined` or `Unknown`) — reference equality is meaningless on primitives. The operands must also be mutually consistent (the symmetric `isConsistent`), so @@ -1790,8 +1872,14 @@ def Synth.refEq (exprMd : StmtExprMd) (expr : StmtExpr) simp [h] at hsz omega -/-- `Γ ⊢ target ⇒ T_t, Γ(f) = T_f, Γ ⊢ newVal ⇐ T_f ∴ Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t` - +/-- (PureFieldUpdate) + ``` + Γ ⊢ target ⇒ T_t + Γ(f) = T_f + Γ ⊢ newVal ⇐ T_f + ───────────────────────────────────────────────────── + Γ ⊢ PureFieldUpdate target f newVal ⇒ T_t + ``` `target` is synthesized, `f` resolved against `T_t` (or the enclosing instance type), and `newVal` checked against the field's declared type. The synthesized type is `T_t` — updating a field on a pure type @@ -1815,8 +1903,12 @@ def Synth.pureFieldUpdate (exprMd : StmtExprMd) -- ### Verification expressions -/-- `Γ, x : T ⊢ body ⇐ TBool ∴ Γ ⊢ Quantifier mode ⟨x, T⟩ trig body ⇒ TBool` - +/-- (Quantifier) + ``` + Γ, x : T ⊢ body ⇐ TBool + ──────────────────────────────────────────── + Γ ⊢ Quantifier mode ⟨x, T⟩ trig body ⇒ TBool + ``` Opens a fresh scope, binds `x : T` (in scope only for the body and trigger), resolves the optional trigger, and checks the body against `TBool` since a quantifier is a proposition. Without that body check, @@ -1843,8 +1935,12 @@ def Synth.quantifier (exprMd : StmtExprMd) try simp_all omega -/-- `Γ ⊢ name ⇒ _ ∴ Γ ⊢ Assigned name ⇒ TBool` - +/-- (Assigned) + ``` + Γ ⊢ name ⇒ _ + ──────────────────────────── + Γ ⊢ Assigned name ⇒ TBool + ``` `assigned x` is a verification predicate that holds when `x` has been definitely assigned. The construct unconditionally synthesizes `TBool`; the operand's synthesized type is discarded, and `Assigned` @@ -1872,8 +1968,12 @@ def Synth.assigned (exprMd : StmtExprMd) simp [h] at hsz omega -/-- `Γ ⊢ v ⇐ T ∴ Γ ⊢ Old v ⇐ T` - +/-- (Old) + ``` + Γ ⊢ v ⇐ T + ─────────────── + Γ ⊢ Old v ⇐ T + ``` `old(v)` refers to the pre-state value of `v` in a postcondition. It has the same type as `v`, so the surrounding expectation propagates straight through: `v` is checked against the same `T`, @@ -1900,8 +2000,13 @@ def Check.old (exprMd : StmtExprMd) simp [h] at hsz omega -/-- `Γ ⊢ v ⇒ T, isReference T ∴ Γ ⊢ Fresh v ⇒ TBool` - +/-- (Fresh) + ``` + Γ ⊢ v ⇒ T + isReference T + ──────────────────────────── + Γ ⊢ Fresh v ⇒ TBool + ``` `v` is synthesized and must have a reference type (`UserDefined` or `Unknown`) — `Fresh` only makes sense on heap-allocated references, so `fresh(5)` is rejected. The construct itself synthesizes `TBool`. -/ @@ -1922,8 +2027,13 @@ def Synth.fresh (exprMd : StmtExprMd) (expr : StmtExpr) simp [h] at hsz omega -/-- `Γ ⊢ v ⇐ T, Γ ⊢ proof ⇒ _ ∴ Γ ⊢ ProveBy v proof ⇐ T` - +/-- (ProveBy) + ``` + Γ ⊢ v ⇐ T + Γ ⊢ proof ⇒ _ + ──────────────────────────── + Γ ⊢ ProveBy v proof ⇐ T + ``` `ProveBy v proof` has the same type as `v` (the proof is just a hint for downstream verification), so the surrounding expectation propagates into `v`. The proof itself has no constraint on its type @@ -1948,10 +2058,15 @@ def Check.proveBy (exprMd : StmtExprMd) /-- Cases on whether `instanceTypeName` is set (i.e., we're inside an instance method). - `Γ.instanceTypeName = some T ∴ Γ ⊢ This ⇒ UserDefined T` - - `Γ.instanceTypeName = none ∴ Γ ⊢ This ⇒ Unknown` (emits "'this' is not allowed outside instance methods") + ``` + Γ.instanceTypeName = some T (This-Inside) + ─────────────────────────── + Γ ⊢ This ⇒ UserDefined T + Γ.instanceTypeName = none (This-Outside) + ─────────────────────────── + Γ ⊢ This ⇒ Unknown (emits "'this' is not allowed outside instance methods") + ``` When `instanceTypeName` is set (we're inside an instance method, populated on `ResolveState` by `resolveInstanceProcedure` for the duration of an instance method body), `This` synthesizes @@ -1990,12 +2105,23 @@ def Synth.all (source : Option FileRange) : StmtExpr × HighTypeMd := /-- Cases on the contract type `ty` and on whether `fn` is a procedure reference. - `fn = Var (.Local id), Γ(id) ∈ {staticProcedure, instanceProcedure} ∴ Γ ⊢ ContractOf Precondition fn ⇒ TBool and Γ ⊢ ContractOf PostCondition fn ⇒ TBool` - - `fn = Var (.Local id), Γ(id) ∈ {staticProcedure, instanceProcedure} ∴ Γ ⊢ ContractOf Reads fn ⇒ TSet Unknown and Γ ⊢ ContractOf Modifies fn ⇒ TSet Unknown` - - `fn is not a procedure reference ∴ Γ ⊢ ContractOf _ fn ↝ error: "'contractOf' expected a procedure reference"` - + ``` + fn = Var (.Local id) (ContractOf-Bool) + Γ(id) ∈ {staticProcedure, instanceProcedure} + ──────────────────────────────────────────── + Γ ⊢ ContractOf Precondition fn ⇒ TBool + Γ ⊢ ContractOf PostCondition fn ⇒ TBool + + fn = Var (.Local id) (ContractOf-Set) + Γ(id) ∈ {staticProcedure, instanceProcedure} + ──────────────────────────────────────────── + Γ ⊢ ContractOf Reads fn ⇒ TSet Unknown + Γ ⊢ ContractOf Modifies fn ⇒ TSet Unknown + + fn is not a procedure reference (ContractOf-Error) + ──────────────────────────────────────────── + Γ ⊢ ContractOf _ fn ↝ error: "'contractOf' expected a procedure reference" + ``` `ContractOf ty fn` extracts a procedure's contract clause as a value: its preconditions (`Precondition`), postconditions (`PostCondition`), reads set (`Reads`), or modifies set (`Modifies`). `fn` must be a @@ -2047,8 +2173,12 @@ def Synth.contractOf (exprMd : StmtExprMd) -- ### Holes -/-- `T_h <: T ∴ Γ ⊢ Hole d (some T_h) ⇐ T` - +/-- (Hole-Some) + ``` + T_h <: T + ──────────────────────────── + Γ ⊢ Hole d (some T_h) ⇐ T + ``` A typed hole carries the user's annotation `T_h`. The annotation is resolved and verified against the surrounding `expected` type via subsumption; the resolved annotation is preserved on the node so @@ -2060,8 +2190,11 @@ def Check.holeSome (det : Bool) (ty : HighTypeMd) (expected : HighTypeMd) checkSubtype source expected ty' pure { val := .Hole det (some ty'), source := source } -/-- `Γ ⊢ Hole d none ⇐ T ↦ Γ ⊢ Hole d (some T)` - +/-- (Hole-None) + ``` + ──────────────────────────────────────── + Γ ⊢ Hole d none ⇐ T ↦ Γ ⊢ Hole d (some T) + ``` An untyped hole in check mode records the expected type on the node so downstream passes (hole elimination) don't have to infer it again. -/ From 4eaa89ee7129932b4829ca47f306d5bd8dd6f37e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Mon, 8 Jun 2026 16:18:28 -0400 Subject: [PATCH 162/189] add in undocumented cases --- Strata/Languages/Laurel/Resolution.lean | 93 ++++++++++++++++--------- docs/verso/LaurelDoc.lean | 43 +++++++++--- 2 files changed, 96 insertions(+), 40 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 8df166dbfd..76dedc378c 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -892,7 +892,14 @@ def Synth.varField (exprMd : StmtExprMd) the new binding for the remainder of the enclosing block. The declaration itself does no work other than registering `x : T_x`, and yields no value, so its rule accepts whatever type `A` the - context expects (the rule ignores `A`). -/ + context expects (the rule ignores `A`). + + `x ∉ dom(Γ)` is a soft side condition, not a hard premise: when `x` + is already bound in the current scope, `defineNameCheckDup` emits a + `"Duplicate definition '' is already defined in this scope"` + diagnostic and still extends the scope — but with an *unresolved* + placeholder rather than `x : T_x`, so later uses of `x` do not + cascade further type errors. -/ def Check.varDeclare (param : Parameter) (source : Option FileRange) : ResolveM StmtExprMd := do let ty' ← resolveHighType param.type @@ -1442,10 +1449,11 @@ def Check.assign (exprMd : StmtExprMd) /-- Cases on the arity of the callee's declared outputs. ``` - Γ(callee) = static-procedure with input T and output T' (Static-Call) - Γ ⊢ arg ⇐ T + Γ(callee) = static-procedure with inputs Ts (Static-Call) + and output [T'] (single output) + Γ ⊢ args_i ⇐ Ts_i (pairwise) ────────────────────────────────────────────────────── - Γ ⊢ StaticCall callee arg ⇒ T' + Γ ⊢ StaticCall callee args ⇒ T' Γ(callee) = static-procedure with inputs Ts (Static-Call-Multi) and outputs [T_1; …; T_n] (n ≠ 1) @@ -1453,9 +1461,13 @@ def Check.assign (exprMd : StmtExprMd) ────────────────────────────────────────────────────── Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` - Callee is resolved against the expected kinds (parameter, static - procedure, datatype constructor, datatype destructor, constant); each - argument is *checked* against the corresponding parameter type. The + The two rules differ only in *output* arity — argument checking is + identical. Callee is resolved against the expected kinds (parameter, + static procedure, datatype constructor, datatype destructor, constant); + each argument is *checked* against the corresponding parameter type. + Surplus arguments (beyond the declared parameters, or when the callee is + unresolved so `paramTypes = []`) are checked against `Unknown`, the + gradual escape hatch, rather than flagged as an arity error. The bidirectional push lets impure-expression arguments (`{x := 1; x}`, `if c then …`, holes) flow through their own check rules instead of bottoming out at @@ -1487,20 +1499,29 @@ def Synth.staticCall (exprMd : StmtExprMd) have := List.sizeOf_lt_of_mem ‹_ ∈ args› omega -/-- (Instance-Call) +/-- Cases on the arity of the callee's declared outputs. ``` - Γ ⊢ target ⇒ _ + Γ ⊢ target ⇒ _ (Instance-Call) + Γ(callee) = instance- or static-procedure + with inputs [self; Ts] and output [T'] (single output) + Γ ⊢ args_i ⇐ Ts_i (pairwise; self dropped) + ───────────────────────────────────────── + Γ ⊢ InstanceCall target callee args ⇒ T' + + Γ ⊢ target ⇒ _ (Instance-Call-Multi) Γ(callee) = instance- or static-procedure - with inputs [self; T] and output T' - Γ ⊢ arg ⇐ T + with inputs [self; Ts] and outputs [T_1; …; T_n] (n ≠ 1) + Γ ⊢ args_i ⇐ Ts_i (pairwise; self dropped) ───────────────────────────────────────── - Γ ⊢ InstanceCall target callee arg ⇒ T' + Γ ⊢ InstanceCall target callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` - Target is synthesized; callee resolves to an instance or static - procedure; arguments are checked pairwise against the callee's - parameter types after dropping `self`. Like `Synth.staticCall`, the - push is bidirectional so block- and conditional-shaped arguments - route through their own check rules. -/ + The two rules differ only in *output* arity. Target is synthesized; + callee resolves to an instance or static procedure; arguments are + checked pairwise against the callee's parameter types after dropping + `self`, with surplus arguments checked against `Unknown` (as in + `Synth.staticCall`). Like `Synth.staticCall`, the push is bidirectional + so block- and conditional-shaped arguments route through their own + check rules. -/ def Synth.instanceCall (exprMd : StmtExprMd) (target : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) @@ -1741,17 +1762,23 @@ def Check.primitiveOp (exprMd : StmtExprMd) /-- Cases on whether `ref` resolves to a composite/datatype. ``` - Γ(ref) is a composite or datatype T (New-Ok) - ─────────────────────────────────── - Γ ⊢ New ref ⇒ UserDefined T + ref is a composite or datatype, (New-Ok) + or is unresolved, or is absent from Γ + ────────────────────────────────────── + Γ ⊢ New ref ⇒ UserDefined ref - Γ(ref) is not a composite or datatype (New-Fallback) - ─────────────────────────────────── + ref resolves to a non-type kind (New-Fallback) + ────────────────────────────────────── Γ ⊢ New ref ⇒ Unknown ``` When `ref` resolves to a composite or datatype, the type is - `UserDefined ref`; otherwise `Unknown` (suppresses cascading errors - after the kind diagnostic has already fired). -/ + `UserDefined ref`. The `Unknown` fallback fires *only* when `ref` + resolves to a present definition whose kind is neither composite nor + datatype (e.g. a variable or procedure name); this suppresses + cascading errors after the kind diagnostic has already fired. An + *unresolved* `ref`, or one absent from scope, takes the `UserDefined` + branch instead — `resolveRef` has already reported the name, so + re-flagging it here would only duplicate that diagnostic. -/ def Synth.new (ref : Identifier) (source : Option FileRange) : ResolveM (StmtExpr × HighTypeMd) := do let ref' ← resolveRef ref source @@ -2107,18 +2134,19 @@ def Synth.all (source : Option FileRange) : StmtExpr × HighTypeMd := ``` fn = Var (.Local id) (ContractOf-Bool) - Γ(id) ∈ {staticProcedure, instanceProcedure} + Γ(id) ∈ {staticProcedure, instanceProcedure, unresolved} ──────────────────────────────────────────── Γ ⊢ ContractOf Precondition fn ⇒ TBool Γ ⊢ ContractOf PostCondition fn ⇒ TBool fn = Var (.Local id) (ContractOf-Set) - Γ(id) ∈ {staticProcedure, instanceProcedure} + Γ(id) ∈ {staticProcedure, instanceProcedure, unresolved} ──────────────────────────────────────────── Γ ⊢ ContractOf Reads fn ⇒ TSet Unknown Γ ⊢ ContractOf Modifies fn ⇒ TSet Unknown - fn is not a procedure reference (ContractOf-Error) + fn is not a Var (.Local) resolving to a procedure (ContractOf-Error) + or unresolved name ──────────────────────────────────────────── Γ ⊢ ContractOf _ fn ↝ error: "'contractOf' expected a procedure reference" ``` @@ -2126,10 +2154,13 @@ def Synth.all (source : Option FileRange) : StmtExpr × HighTypeMd := its preconditions (`Precondition`), postconditions (`PostCondition`), reads set (`Reads`), or modifies set (`Modifies`). `fn` must be a direct identifier reference resolving to a procedure — a contract - belongs to a *named* procedure, not an arbitrary expression. Anything - else fires the diagnostic *"'contractOf' expected a procedure - reference"* and the construct synthesizes `Unknown` to suppress - cascading errors. + belongs to a *named* procedure, not an arbitrary expression. The + diagnostic *"'contractOf' expected a procedure reference"* fires (and + the construct synthesizes `Unknown` to suppress cascading errors) when + `fn` is anything other than a `Var (.Local id)`, or resolves to a + present definition that is not a procedure. An *unresolved* `id`, or + one absent from scope, is accepted without firing the diagnostic — + its name-resolution error was already reported. `Precondition` and `PostCondition` are propositions, hence `TBool`. `Reads` and `Modifies` are sets of heap-allocated locations — diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index b039e2c5fc..b95266ff35 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -229,7 +229,12 @@ tag. The following notation recurs throughout the rules: - $`A <: B` — subtyping ({name Strata.Laurel.isSubtype}`isSubtype`); see - *Gradual typing* above. + *Gradual typing* above. In a *checking* premise or side condition (e.g. + \[⇐\] Sub, \[⇐\] If-NoElse, \[⇐\] Assign, the check-mode operator rules, and + \[⇐\] Hole-Some) the boundary check is the gradual consistent-subtype + relation $`<:_\sim` below — the implementation routes every such check + through {name Strata.Laurel.isConsistentSubtype}`isConsistentSubtype`, never + bare $`<:` — so $`\mathsf{Unknown}` is admitted on either side. - $`A \sim B` — the *consistency* relation {name Strata.Laurel.isConsistent}`isConsistent`: symmetric, with $`\mathsf{Unknown}` acting as a wildcard. @@ -321,6 +326,12 @@ $$`\frac{\Gamma \vdash e \Rightarrow \_ \quad \Gamma(f) = T_f}{\Gamma \vdash \ma $$`\frac{x \notin \mathrm{dom}(\Gamma)}{\Gamma \vdash \mathsf{Var}\;(\mathsf{.Declare}\;\langle x, T_x\rangle) \Leftarrow A \quad \dashv \quad \Gamma, x : T_x} \quad \text{([⇐] Var-Declare)}` +$`x \notin \mathrm{dom}(\Gamma)` is a soft side condition rather than a +hard premise: when $`x` is already bound in the current scope the rule still +fires, $`[\text{emits “Duplicate definition …”}]`, and extends the scope — +but with an *unresolved* placeholder instead of $`x : T_x`, so later uses of +$`x` don't cascade further type errors. + {docstring Strata.Laurel.Resolution.Check.varDeclare} ### Control flow @@ -531,13 +542,13 @@ discarded. tag := "rules-calls" %%% -$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with input } T \text{ and output } T' \quad \Gamma \vdash \mathit{arg} \Leftarrow T}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{arg} \Rightarrow T'} \quad \text{([⇒] Static-Call)}` +$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and output } [T'] \text{ (single output)} \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow T'} \quad \text{([⇒] Static-Call)}` $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` {docstring Strata.Laurel.Resolution.Synth.staticCall} -$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; T] \text{ and output } T' \quad \Gamma \vdash \mathit{arg} \Leftarrow T}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{arg} \Rightarrow T'} \quad \text{([⇒] Instance-Call)}` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; Ts] \text{ and output } [T'] \text{ (single output)} \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow T'} \quad \text{([⇒] Instance-Call)}` $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Instance-Call-Multi)}` @@ -546,7 +557,11 @@ static procedure (the latter handles uniformly-dispatched call syntax where the receiver is forwarded as `self`). Output arity is forwarded identically to {name Strata.Laurel.Resolution.Synth.staticCall}`Synth.staticCall`'s -single-vs-multi split. +single-vs-multi split. In both call families the single- and multi-output +rules differ only in the *output* arity; argument checking is the same, and +surplus arguments (beyond the declared parameters, or when the callee is +unresolved) are checked against $`\mathsf{Unknown}` rather than flagged as an +arity error. {docstring Strata.Laurel.Resolution.Synth.instanceCall} @@ -604,9 +619,14 @@ $$`\frac{\mathsf{TBool} <: T \quad \Gamma \vdash \mathit{args}_i \Leftarrow \mat tag := "rules-object-forms" %%% -$$`\frac{\Gamma(\mathit{ref}) \text{ is a composite or datatype } T}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{UserDefined}\;T} \quad \text{([⇒] New-Ok)}` +$$`\frac{\mathit{ref} \text{ is a composite or datatype, or is unresolved, or is absent from } \Gamma}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{UserDefined}\;\mathit{ref}} \quad \text{([⇒] New-Ok)}` + +$$`\frac{\mathit{ref} \text{ resolves to a non-type kind}}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] New-Fallback)}` -$$`\frac{\Gamma(\mathit{ref}) \text{ is not a composite or datatype}}{\Gamma \vdash \mathsf{New}\;\mathit{ref} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] New-Fallback)}` +The $`\mathsf{Unknown}` fallback fires *only* when $`\mathit{ref}` resolves to +a present definition whose kind is neither composite nor datatype. An +unresolved or out-of-scope $`\mathit{ref}` takes the New-Ok branch instead, so +the kind diagnostic that `resolveRef` already emitted is not duplicated. {docstring Strata.Laurel.Resolution.Synth.new} @@ -683,11 +703,16 @@ $$`\frac{}{\Gamma \vdash \mathsf{Abstract}\,/\,\mathsf{All}\;\ldots \Rightarrow tag := "rules-contract-of" %%% -$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Precondition}\;\mathit{fn} \Rightarrow \mathsf{TBool} \qquad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{PostCondition}\;\mathit{fn} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] ContractOf-Bool)}` +$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}, \mathit{unresolved}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Precondition}\;\mathit{fn} \Rightarrow \mathsf{TBool} \qquad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{PostCondition}\;\mathit{fn} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] ContractOf-Bool)}` + +$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}, \mathit{unresolved}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Reads}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown} \qquad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{Modifies}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown}} \quad \text{([⇒] ContractOf-Set)}` -$$`\frac{\mathit{fn} = \mathsf{Var}\;(\mathsf{.Local}\;\mathit{id}) \quad \Gamma(\mathit{id}) \in \{\mathit{staticProcedure}, \mathit{instanceProcedure}\}}{\Gamma \vdash \mathsf{ContractOf}\;\mathsf{Reads}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown} \qquad \Gamma \vdash \mathsf{ContractOf}\;\mathsf{Modifies}\;\mathit{fn} \Rightarrow \mathsf{TSet}\;\mathsf{Unknown}} \quad \text{([⇒] ContractOf-Set)}` +$$`\frac{\mathit{fn} \text{ is not a } \mathsf{Var}\;(\mathsf{.Local}) \text{ resolving to a procedure or unresolved name}}{\Gamma \vdash \mathsf{ContractOf}\;\ldots\;\mathit{fn} \rightsquigarrow \text{error: “‘contractOf’ expected a procedure reference”}} \quad \text{([⇒] ContractOf-Error)}` -$$`\frac{\mathit{fn} \text{ is not a procedure reference}}{\Gamma \vdash \mathsf{ContractOf}\;\ldots\;\mathit{fn} \rightsquigarrow \text{error: “‘contractOf’ expected a procedure reference”}} \quad \text{([⇒] ContractOf-Error)}` +The $`\mathit{unresolved}` kind is admitted so an already-reported +name-resolution error is not duplicated; ContractOf-Error fires only when +$`\mathit{fn}` resolves to a *present* non-procedure definition (or is not a +local reference at all). {docstring Strata.Laurel.Resolution.Synth.contractOf} From ca3f6ba0ec746684ae1c953759d1d74f22066f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 9 Jun 2026 10:10:16 -0400 Subject: [PATCH 163/189] add synth ifthenelse/block --- Strata/Languages/Laurel/Resolution.lean | 117 ++++++++++++++++++ .../Laurel/ResolutionTypeCheckTests.lean | 43 +++++++ 2 files changed, 160 insertions(+) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 76dedc378c..318f2004dc 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -705,7 +705,11 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy Synth.contractOf exprMd ty fn source (by rw [h_node]) | .Abstract => pure (Synth.abstract source) | .All => pure (Synth.all source) + | .IfThenElse cond thenBr elseBr => + Synth.ifThenElse exprMd cond thenBr elseBr source (by rw [h_node]) | .Block [] label => pure (.Block [] label, Synth.emptyBlock source) + | .Block (head :: tail) label => + Synth.block exprMd (head :: tail) label source (by rw [h_node]) -- Holes in synth position are gradual: an annotated hole synthesizes its -- declared type; an unannotated one is `Unknown`. Without this carve-out, -- a hole appearing as the target of e.g. a field access (`.f`) would @@ -1289,6 +1293,119 @@ def Check.ifThenElse (exprMd : StmtExprMd) try simp_all omega +/-- (If-Synth) + ``` + Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e T_t ~ T_e (If-Synth) + ────────────────────────────────────────────────────────────────────────── + Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T_t + + Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ _ (If-Synth-NoElse) + ────────────────────────────────────────────────────────────────────────── + Γ ⊢ IfThenElse cond thenBr none ⇒ TVoid + ``` + Synth-mode rule for an `if` used where no expected type is available + (e.g. as an operand of `==`/`<`/`++`, whose operands are synthesized). + `cond` is checked against `TBool`; both branches are *synthesized*. + With an `else`, the two branch types must be mutually consistent + (`isConsistent`, the symmetric gradual relation — `Unknown` flows + freely either way); the result is the then-branch's type as a + representative. Inconsistent branches (e.g. `if c then 1 else "x"`) + emit a diagnostic and synthesize `Unknown` to suppress cascading + errors. Without an `else`, the `if` cannot produce a value on the + missing branch, so it synthesizes `TVoid`. + + This is the synth counterpart to `Check.ifThenElse`: when an expected + type *is* available the dispatcher prefers the check rule (pushing the + type into both branches); this rule fires only at the synth wildcard. -/ +def Synth.ifThenElse (exprMd : StmtExprMd) + (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) + (source : Option FileRange) + (h : exprMd.val = .IfThenElse cond thenBr elseBr) : + ResolveM (StmtExpr × HighTypeMd) := do + let cond' ← Check.resolveStmtExpr cond { val := .TBool, source := cond.source } + let (thenBr', thenTy) ← Synth.resolveStmtExpr thenBr + match elseBr with + | none => + pure (.IfThenElse cond' thenBr' none, { val := .TVoid, source := source }) + | some e => + let (e', elseTy) ← Synth.resolveStmtExpr e + let ctx := (← get).typeContext + let ty ← + if isConsistent ctx thenTy elseTy then + pure thenTy + else + let diag := diagnosticFromSource source + s!"'if' branches have incompatible types '{formatType thenTy}' and '{formatType elseTy}'" + modify fun s => { s with errors := s.errors.push diag } + pure { val := .Unknown, source := source } + pure (.IfThenElse cond' thenBr' (some e'), ty) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try simp_all + omega + +/-- (Block-Synth) + ``` + Γ ⊢ s ⋄ (for each non-last s) Γ ⊢ last ⇒ T (Block-Synth) + ────────────────────────────────────────────────────────────── + Γ ⊢ Block (init ++ [last]) label ⇒ T + ``` + Synth-mode rule for a non-empty block used where no expected type is + available (e.g. `{ x := 1; x } == y`). Mirrors `Check.block`'s + structure — fresh scope, optional label, non-last statements checked + in effect position (`Check.statement`, i.e. at `TVoid` with the + Discard-Call carve-out), dead-code-after-terminator diagnostic — but + *synthesizes* the last statement instead of checking it against an + expected type, and returns that synthesized type as the block's value + type. The empty block is handled by `Synth.emptyBlock` at the + dispatch site; this rule only runs on `head :: tail`. -/ +def Synth.block (exprMd : StmtExprMd) + (stmts : List StmtExprMd) (label : Option String) + (source : Option FileRange) + (h : exprMd.val = .Block stmts label) : ResolveM (StmtExpr × HighTypeMd) := do + withScope <| withLabel label do + let init' ← stmts.dropLast.attach.mapM fun ⟨s, hMem⟩ => do + have h_mem : s ∈ stmts := List.dropLast_subset stmts hMem + Check.statement s + let isTerminator (s : StmtExprMd) : Bool := + match s.val with + | .Exit _ | .Return _ => true + | _ => false + match init'.findIdx? isTerminator with + | some i => + let nextSource : Option FileRange := + match init'[i + 1]? with + | some next => next.source + | none => (stmts.getLast?.bind (·.source)) + let termName : String := + match init'[i]? with + | some s => s.val.constrName + | none => "exit" + let diag := diagnosticFromSource nextSource + s!"dead code after '{termName}'" + modify fun st => { st with errors := st.errors.push diag } + | none => pure () + match _lastResult: stmts.getLast? with + | none => + pure (.Block init' label, Synth.emptyBlock source) + | some last => + have := List.mem_of_getLast? _lastResult + let (last', lastTy) ← Synth.resolveStmtExpr last + pure (.Block (init' ++ [last']) label, lastTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + try (have := List.sizeOf_lt_of_mem ‹_ ∈ stmts›) + try simp_all + omega + -- ### Verification statements /-- (Assert) diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 1eb6694640..033cb90386 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -222,4 +222,47 @@ procedure test() opaque { #guard_msgs (error, drop all) in #eval testInputWithOffset "FieldShadowedByLocal" fieldShadowedByLocal 184 processResolution +/-! ## `if`/`block` in synth-only operand position + +An `if`/`then`/`else` (or non-empty block) used where operands are +synthesized — e.g. as an operand of `==`/`<`/`++` — now has a synth rule +(`Synth.ifThenElse` / `Synth.block`). Previously it hit the synth wildcard +and emitted a spurious "type cannot be synthesized" error. With both +branches consistent, the `if` synthesizes the branch type and resolves +cleanly (no diagnostics). -/ + +def ifInSynthPositionOk := r" +function foo(c: bool): bool { + (if c then 1 else 2) == 3 +}; +" + +#guard_msgs (drop info) in +#eval testInputWithOffset "IfInSynthPositionOk" ifInSynthPositionOk 198 processResolution + +def blockInSynthPositionOk := r" +function foo(): bool { + { 1 } == 1 +}; +" + +#guard_msgs (drop info) in +#eval testInputWithOffset "BlockInSynthPositionOk" blockInSynthPositionOk 208 processResolution + +/-! ## `if` with incompatible branch types (synth position) + +When an `if` is synthesized and its two branches have mutually +inconsistent types, `Synth.ifThenElse` reports the mismatch at the `if` +and synthesizes `Unknown` to suppress cascading errors. -/ + +def ifBranchesIncompatible := r" +function foo(c: bool): bool { + (if c then 1 else true) == 3 +// ^^^^^^^^^^^^^^^^^^^^^ error: 'if' branches have incompatible types 'int' and 'bool' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "IfBranchesIncompatible" ifBranchesIncompatible 218 processResolution + end Laurel From bece7b2d9cd8e11fdedf02d383c87f19fb3822d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 9 Jun 2026 10:14:07 -0400 Subject: [PATCH 164/189] void procedures check against void instead of MultiReturn [] --- Strata/Languages/Laurel/Resolution.lean | 2 ++ .../Laurel/ResolutionTypeCheckTests.lean | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 318f2004dc..d5127ea76c 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -551,11 +551,13 @@ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List Hig match s.scope.get? callee.text with | some (_, .staticProcedure proc) => let retTy := match proc.outputs with + | [] => { val := .TVoid, source := callee.source } | [singleOutput] => singleOutput.type | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } pure (retTy, proc.inputs.map (·.type)) | some (_, .instanceProcedure _ proc) => let retTy := match proc.outputs with + | [] => { val := .TVoid, source := callee.source } | [singleOutput] => singleOutput.type | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } pure (retTy, proc.inputs.map (·.type)) diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 033cb90386..d4dd92633f 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -265,4 +265,23 @@ function foo(c: bool): bool { #guard_msgs (error, drop all) in #eval testInputWithOffset "IfBranchesIncompatible" ifBranchesIncompatible 218 processResolution +/-! ## Void procedure call in value position + +A call to a `void` procedure (no `returns` clause) used where a value is +expected now synthesizes `TVoid` rather than the internal-only empty +`MultiValuedExpr []`. The diagnostic therefore reports the type as `'void'` +instead of the placeholder `'()'` that an empty tuple rendered as. (Regression +guard for `getCallInfo` mapping an empty output list to `TVoid`.) -/ + +def voidCallInValuePosition := r" +procedure act() opaque; +procedure test() opaque { + assert act() == 1 +// ^^^^^^^^^^ error: cannot compare 'void' with 'int' using '==' +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "VoidCallInValuePosition" voidCallInValuePosition 234 processResolution + end Laurel From 53cf3e7c3f15a76d20a1c3ffc1407779d16a730b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 9 Jun 2026 10:16:44 -0400 Subject: [PATCH 165/189] old types pass through --- Strata/Languages/Laurel/Resolution.lean | 61 ++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index d5127ea76c..4ee6ae99bf 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -633,7 +633,7 @@ rules* index in `LaurelDoc.lean`: - Object forms — `Synth.new`, `Synth.asType`, `Synth.isType`, `Synth.refEq`, `Synth.pureFieldUpdate` - Verification expressions — `Synth.quantifier`, `Synth.assigned`, - `Synth.fresh`, `Check.old`, `Check.proveBy` + `Synth.fresh`, `Synth.old`/`Check.old`, `Synth.proveBy`/`Check.proveBy` - Self reference — `Synth.this` - Untyped forms — `Synth.abstract`, `Synth.all` - ContractOf — `Synth.contractOf` @@ -703,6 +703,10 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy Synth.assigned exprMd name source (by rw [h_node]) | .Fresh val => Synth.fresh exprMd expr val source h_expr (by rw [h_node]) + | .Old val => + Synth.old exprMd val source (by rw [h_node]) + | .ProveBy val proof => + Synth.proveBy exprMd val proof source (by rw [h_node]) | .ContractOf ty fn => Synth.contractOf exprMd ty fn source (by rw [h_node]) | .Abstract => pure (Synth.abstract source) @@ -2146,6 +2150,34 @@ def Check.old (exprMd : StmtExprMd) simp [h] at hsz omega +/-- (Old-Synth) + ``` + Γ ⊢ v ⇒ T + ─────────────── + Γ ⊢ Old v ⇒ T + ``` + `old` is a *universal morphism*: it is fully type-transparent, so + `old(v)` has exactly the type of `v` and passes through every + operation. When `old(...)` appears in a synthesis position (e.g. as + an operand of `==`/`<`/`++`, which synthesize their operands — the + documented postcondition pattern `ensures counter.value == + old(counter.value) + 1`), `v` is synthesized and its type `T` is + returned unchanged, wrapped back up as `Old v'`. Without this rule the + construct would fall into the synth wildcard and spuriously report + that its type cannot be synthesized. -/ +def Synth.old (exprMd : StmtExprMd) + (val : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .Old val) : + ResolveM (StmtExpr × HighTypeMd) := do + let (val', valTy) ← Synth.resolveStmtExpr val + pure (.Old val', valTy) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + /-- (Fresh) ``` Γ ⊢ v ⇒ T @@ -2199,6 +2231,33 @@ def Check.proveBy (exprMd : StmtExprMd) simp [h] at hsz omega +/-- (ProveBy-Synth) + ``` + Γ ⊢ v ⇒ T + Γ ⊢ proof ⇒ _ + ──────────────────────────── + Γ ⊢ ProveBy v proof ⇒ T + ``` + Like `old`, `ProveBy v proof` is type-transparent in `v` — the proof + is just a hint for downstream verification and carries no typing + constraint. In a synthesis position `v` is synthesized for its type + `T`, `proof` is synthesized only for its name-resolution side effects + (its type is discarded), and `T` is returned. -/ +def Synth.proveBy (exprMd : StmtExprMd) + (val proof : StmtExprMd) (source : Option FileRange) + (h : exprMd.val = .ProveBy val proof) : + ResolveM (StmtExpr × HighTypeMd) := do + let (val', valTy) ← Synth.resolveStmtExpr val + let (proof', _) ← Synth.resolveStmtExpr proof + pure (.ProveBy val' proof', valTy) + termination_by (exprMd, 1) + decreasing_by + all_goals + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + simp [h] at hsz + omega + -- ### Self reference /-- Cases on whether `instanceTypeName` is set (i.e., we're inside an From 21834f09b1fb3a7b7dd6d99d83c6410fad49eb5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 9 Jun 2026 10:17:22 -0400 Subject: [PATCH 166/189] stop dropping first parameter on staticProcedure --- Strata/Languages/Laurel/Resolution.lean | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 4ee6ae99bf..814bd3d224 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1654,7 +1654,22 @@ def Synth.instanceCall (exprMd : StmtExprMd) let callee' ← resolveRef callee source (expected := #[.instanceProcedure, .staticProcedure]) let (retTy, paramTypes) ← getCallInfo callee - let callParamTypes := match paramTypes with | _ :: rest => rest | [] => [] + -- The callee resolves to either an instance- or a static-procedure. An + -- instance procedure's first parameter is the implicit `self` receiver, + -- which is not supplied positionally here, so it must be dropped before + -- pairing parameter types with `args`. A static procedure (also accepted + -- on this path) has no `self`, so all its parameters are real and none may + -- be dropped. We distinguish the two by the same scope lookup `getCallInfo` + -- uses. (NOTE: this branch is currently unreachable — no Laurel/Python + -- frontend produces a `StmtExpr.InstanceCall`; the only call production + -- lowers to `StaticCall`. The guard below keeps this rule correct should a + -- method-call production ever be added.) + let dropSelf : Bool := match (← get).scope.get? callee.text with + | some (_, .instanceProcedure ..) => true + | _ => false + let callParamTypes := + if dropSelf then (match paramTypes with | _ :: rest => rest | [] => []) + else paramTypes let unknownTy : HighTypeMd := { val := .Unknown, source := none } let expectedTys : List HighTypeMd := callParamTypes ++ List.replicate (args.length - callParamTypes.length) unknownTy From d8159587645e72aa00f8a0ffea3df868c9b5ed62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 9 Jun 2026 10:22:43 -0400 Subject: [PATCH 167/189] document new rules (synth if/block) --- docs/verso/LaurelDoc.lean | 52 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index b95266ff35..8fc87bc4dd 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -258,9 +258,10 @@ The Index below links to each construct's subsection. - {ref "rules-subsumption"}[*Subsumption*] — \[⇐\] Sub - {ref "rules-literals"}[*Literals*] — \[⇒\] Lit-Int, \[⇒\] Lit-Bool, \[⇒\] Lit-String, \[⇒\] Lit-Decimal - {ref "rules-variables"}[*Variables*] — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇐\] Var-Declare -- {ref "rules-control-flow"}[*Control flow*] — \[⇐\] If, \[⇐\] If-NoElse; +- {ref "rules-control-flow"}[*Control flow*] — \[⇐\] If, \[⇐\] If-NoElse, + \[⇒\] If-Synth, \[⇒\] If-Synth-NoElse; \[⇐\] Block-Singleton, \[⇐\] Block-Cons, - \[⇐\] Discard-Call-Cons, \[⇐\] Discard-Call-Last, \[⇒\] Skip; \[⇐\] Exit; + \[⇐\] Discard-Call-Cons, \[⇐\] Discard-Call-Last, \[⇒\] Skip, \[⇒\] Block-Synth; \[⇐\] Exit; \[⇐\] Return-None-Void, \[⇐\] Return-None-Single, \[⇐\] Return-None-Multi, \[⇐\] Return-Some, \[⇐\] Return-Void-Error, \[⇐\] Return-Multi-Error; \[⇐\] While @@ -273,7 +274,7 @@ The Index below links to each construct's subsection. - {ref "rules-object-forms"}[*Object forms*] — \[⇒\] New-Ok, \[⇒\] New-Fallback; \[⇒\] AsType; \[⇒\] IsType; \[⇒\] RefEq; \[⇒\] PureFieldUpdate - {ref "rules-verification-expressions"}[*Verification expressions*] — \[⇒\] Quantifier, \[⇒\] Assigned, \[⇐\] Old, - \[⇒\] Fresh, \[⇐\] ProveBy + \[⇒\] Old-Synth, \[⇒\] Fresh, \[⇐\] ProveBy, \[⇒\] ProveBy-Synth - {ref "rules-self-reference"}[*Self reference*] — \[⇒\] This-Inside, \[⇒\] This-Outside - {ref "rules-untyped-forms"}[*Untyped forms*] — \[⇒\] Abstract / All - {ref "rules-contract-of"}[*ContractOf*] — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error @@ -345,6 +346,22 @@ $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vda {docstring Strata.Laurel.Resolution.Check.ifThenElse} +When an `if` appears in *operand* position — where no expected type is +available to push down (e.g. as an operand of $`==` / $`<` / $`+\!+`, +whose operands are synthesized) — the synth counterpart fires instead. +With an `else`, both branches are synthesized and their types must be +mutually consistent ($`\sim`, the symmetric gradual relation); +inconsistent branches $`[\text{emits “'if' branches have incompatible +types X and Y”}]` and synthesize $`\mathsf{Unknown}`. Without an +`else`, the missing branch cannot produce a value, so the `if` +synthesizes $`\mathsf{TVoid}`. + +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e \quad T_t \sim T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t} \quad \text{([⇒] If-Synth)}` + +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow \_}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-Synth-NoElse)}` + +{docstring Strata.Laurel.Resolution.Synth.ifThenElse} + A non-empty block is typed by structural recursion on the statement list: the last statement inherits the surrounding expected type, and each non-last statement is checked at $`\mathsf{TVoid}`, *except* @@ -407,6 +424,17 @@ $`\mathsf{TVoid} <: \mathit{expected}`). {docstring Strata.Laurel.Resolution.Synth.emptyBlock} +A *non-empty* block also synthesizes when used in operand position +(e.g. $`\{\,x := 1;\; x\,\} == y`). It mirrors \[⇐\] Block-Cons / +\[⇐\] Block-Singleton — fresh scope, optional label, non-last +statements checked in effect position ($`\diamond`), dead-code +diagnostics — but *synthesizes* the last statement instead of checking +it, and returns that type as the block's value type. + +$$`\frac{\Gamma \vdash s \;\diamond \;(\text{for each non-last } s) \quad \Gamma \vdash \mathit{last} \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;(\mathit{init} \mathbin{+\!+} [\mathit{last}])\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block-Synth)}` + +{docstring Strata.Laurel.Resolution.Synth.block} + {docstring Strata.Laurel.Resolution.Check.block} The Discard-Call carve-outs and the "checks against $`\mathsf{TVoid}`" @@ -668,6 +696,15 @@ $$`\frac{\Gamma \vdash v \Leftarrow T}{\Gamma \vdash \mathsf{Old}\;v \Leftarrow {docstring Strata.Laurel.Resolution.Check.old} +`old` is type-transparent, so it also synthesizes: in operand position +(e.g. the postcondition pattern `ensures counter.value == old(counter.value) + 1`, +where $`==` synthesizes its operands) $`v` is synthesized and its type +returned unchanged. + +$$`\frac{\Gamma \vdash v \Rightarrow T}{\Gamma \vdash \mathsf{Old}\;v \Rightarrow T} \quad \text{([⇒] Old-Synth)}` + +{docstring Strata.Laurel.Resolution.Synth.old} + $$`\frac{\Gamma \vdash v \Rightarrow T \quad \mathsf{isReference}\;T}{\Gamma \vdash \mathsf{Fresh}\;v \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Fresh)}` {docstring Strata.Laurel.Resolution.Synth.fresh} @@ -676,6 +713,15 @@ $$`\frac{\Gamma \vdash v \Leftarrow T \quad \Gamma \vdash \mathit{proof} \Righta {docstring Strata.Laurel.Resolution.Check.proveBy} +Like `old`, `ProveBy` is type-transparent in `v`, so it also +synthesizes: in operand position $`v` is synthesized for its type $`T`, +$`\mathit{proof}` is synthesized only for its name-resolution side +effects (its type discarded), and $`T` is returned. + +$$`\frac{\Gamma \vdash v \Rightarrow T \quad \Gamma \vdash \mathit{proof} \Rightarrow \_}{\Gamma \vdash \mathsf{ProveBy}\;v\;\mathit{proof} \Rightarrow T} \quad \text{([⇒] ProveBy-Synth)}` + +{docstring Strata.Laurel.Resolution.Synth.proveBy} + ### Self reference %%% tag := "rules-self-reference" From bc069b35ffbebec418f529f5cc4ce35278fde90a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 9 Jun 2026 10:29:52 -0400 Subject: [PATCH 168/189] remove stale documentation --- Strata/Languages/Laurel/LaurelAST.lean | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Strata/Languages/Laurel/LaurelAST.lean b/Strata/Languages/Laurel/LaurelAST.lean index a7177b2c5f..26226bf6ba 100644 --- a/Strata/Languages/Laurel/LaurelAST.lean +++ b/Strata/Languages/Laurel/LaurelAST.lean @@ -536,8 +536,11 @@ partial def TypeContext.unfold (ctx : TypeContext) (ty : HighTypeMd) | _ => ty /-- All ancestors of a composite type (including itself), reachable via - repeated `extending` lookups. The `fuel` cap is the number of distinct - type names ever registered, bounding the BFS even with malformed input. -/ + repeated `extending` lookups. Implemented as a visited-set BFS over the + `extending` graph: the accumulator `acc` doubles as the visited set, and + every node is `insert`ed before its parents are enqueued, so each name is + processed at most once. The accumulator only grows, hence cycles in the + (possibly malformed) graph terminate — no `fuel` parameter is needed. -/ partial def TypeContext.ancestors (ctx : TypeContext) (name : String) : Std.HashSet String := let rec go (acc : Std.HashSet String) (frontier : List String) : Std.HashSet String := match frontier with From 8d093688fda5ecb8a5521f2ca9e38f6ede29ca9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 9 Jun 2026 11:15:19 -0400 Subject: [PATCH 169/189] fix rule presentation --- Strata/Languages/Laurel/Resolution.lean | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 814bd3d224..6e9db1986a 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -895,8 +895,8 @@ def Synth.varField (exprMd : StmtExprMd) /-- (Var-Declare) ``` x ∉ dom(Γ) - ───────────────────────────────────────── ⊣ Γ, x : T_x - Γ ⊢ Var (.Declare ⟨x, T_x⟩) ⇐ A + ──────────────────────────────────────────── + Γ ⊢ Var (.Declare ⟨x, T_x⟩) ⇐ A ⊣ Γ, x : T_x ``` `⊣ Γ, x : T_x` records that the surrounding scope is extended with the new binding for the remainder of the enclosing block. The From c875841f85eb7badb7072582e83ff906e63c3092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 9 Jun 2026 11:19:03 -0400 Subject: [PATCH 170/189] void staticProcedures return TVoid --- Strata/Languages/Laurel/LaurelTypes.lean | 1 + 1 file changed, 1 insertion(+) diff --git a/Strata/Languages/Laurel/LaurelTypes.lean b/Strata/Languages/Laurel/LaurelTypes.lean index c3554d24e3..6e8cb40e8e 100644 --- a/Strata/Languages/Laurel/LaurelTypes.lean +++ b/Strata/Languages/Laurel/LaurelTypes.lean @@ -24,6 +24,7 @@ def getCallType (source : Option FileRange) (model : SemanticModel) (callee : Id | .datatypeDestructor _ fld => fld.type | .parameter p => p.type | .staticProcedure proc => match proc.outputs with + | [] => { val := .TVoid, source := source } | [singleOutput] => singleOutput.type | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } | .unresolved source => { val := HighType.Unknown, source := source } From 88e4499b3745a3424ae607aae24107a68a3d31ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 9 Jun 2026 11:27:15 -0400 Subject: [PATCH 171/189] add call resolution support for instanceProcedures --- Strata/Languages/Laurel/LaurelTypes.lean | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Strata/Languages/Laurel/LaurelTypes.lean b/Strata/Languages/Laurel/LaurelTypes.lean index 6e8cb40e8e..95907507d9 100644 --- a/Strata/Languages/Laurel/LaurelTypes.lean +++ b/Strata/Languages/Laurel/LaurelTypes.lean @@ -27,6 +27,11 @@ def getCallType (source : Option FileRange) (model : SemanticModel) (callee : Id | [] => { val := .TVoid, source := source } | [singleOutput] => singleOutput.type | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } + -- Mirrors `.staticProcedure`; currently unreachable (instance calls are lowered to static calls / not yet emitted). + | .instanceProcedure _ proc => match proc.outputs with + | [] => { val := .TVoid, source := source } + | [singleOutput] => singleOutput.type + | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } | .unresolved source => { val := HighType.Unknown, source := source } | astNode => dbg_trace s!"BUG: static call to {callee} not to a procedure but to a {repr astNode}" From e1025ef9815f240bb859d9a7f7990493907a3a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 9 Jun 2026 16:31:03 -0400 Subject: [PATCH 172/189] make bitvectors numeric --- Strata/Languages/Laurel/Resolution.lean | 5 +++-- .../Laurel/ResolutionTypeCheckTests.lean | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 6e9db1986a..92099fc99b 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -491,13 +491,14 @@ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (ac unless isConsistentSubtype ctx actual expected do typeMismatch source none s!"expected '{formatType expected}'" actual -/-- Test whether a type is in the set of numeric primitives. `Unknown` is +/-- Test whether a type is in the set of numeric primitives + (`TInt` / `TReal` / `TFloat64` / `TBv`). `Unknown` is accepted as a gradual escape hatch. Aliases and constrained types are unfolded first so e.g. `nat` (constrained over `int`) counts as numeric. Used by Op-Cmp / Op-Arith. -/ private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := match (ctx.unfold ty).val with - | .TInt | .TReal | .TFloat64 | .Unknown => true + | .TInt | .TReal | .TFloat64 | .TBv _ | .Unknown => true | _ => false /-- Least upper bound of two types under the consistency relation diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index d4dd92633f..d2a6cc7903 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -284,4 +284,21 @@ procedure test() opaque { #guard_msgs (error, drop all) in #eval testInputWithOffset "VoidCallInValuePosition" voidCallInValuePosition 234 processResolution +/-! ## Bitvectors are numeric + +Bitvector operands (`bv n`) participate in arithmetic and comparison +operators just like the other numeric primitives. `isNumeric` therefore +accepts `TBv`, so a comparison of two bitvector parameters resolves +cleanly with no diagnostics. (Regression guard for `isNumeric` previously +rejecting `TBv` and emitting a spurious "expected a numeric type" error.) -/ + +def bitvectorComparisonOk := r" +function cmp(x: bv 32, y: bv 32): bool { + x < y +}; +" + +#guard_msgs (drop info) in +#eval testInputWithOffset "BitvectorComparisonOk" bitvectorComparisonOk 250 processResolution + end Laurel From 1fd40cc0067cc8a987677a03c3e36796250f274b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 12:54:23 -0400 Subject: [PATCH 173/189] typecontext -> typelattice --- Strata/Languages/Laurel/LaurelAST.lean | 32 +++++++++++++++-------- Strata/Languages/Laurel/Resolution.lean | 34 ++++++++++++------------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/Strata/Languages/Laurel/LaurelAST.lean b/Strata/Languages/Laurel/LaurelAST.lean index 715ffa49b1..62ddacfec0 100644 --- a/Strata/Languages/Laurel/LaurelAST.lean +++ b/Strata/Languages/Laurel/LaurelAST.lean @@ -523,8 +523,18 @@ deriving instance BEq for HighType unwraps to (alias target / constrained base). Followed transitively to reach a non-alias, non-constrained type. - `extendingMap` maps a composite type's name to the *direct* parents in - its `extending` list. Walked transitively for the subtype check. -/ -structure TypeContext where + its `extending` list. Walked transitively for the subtype check. + + Keyed by type-name *text* (`String`), not `Identifier`: this is consistent + with how `highEq` decides `UserDefined` equality (by `.text`), and is forced + because the lattice is built from the *unresolved* program in + `TypeLattice.ofTypes`, before the resolution pass assigns `uniqueId`s. + Consequence: nominal type identity is by name text, so subtyping + (`ancestors` walking `extendingMap`) assumes type names are globally unique. + Safe today (no module system); revisit when modules / namespacing / imports + land, since two distinct same-named types would otherwise share an + inheritance chain. -/ +structure TypeLattice where unfoldMap : Std.HashMap String HighTypeMd := {} extendingMap : Std.HashMap String (List String) := {} deriving Inhabited @@ -533,7 +543,7 @@ structure TypeContext where Composites and primitives are returned unchanged. A `visited` set guards against cycles in the alias/constrained graph (already cycle-checked elsewhere, but keeps `unfold` safe to call independently). -/ -partial def TypeContext.unfold (ctx : TypeContext) (ty : HighTypeMd) +partial def TypeLattice.unfold (ctx : TypeLattice) (ty : HighTypeMd) (visited : Std.HashSet String := {}) : HighTypeMd := match ty.val with | .UserDefined name => @@ -549,7 +559,7 @@ partial def TypeContext.unfold (ctx : TypeContext) (ty : HighTypeMd) every node is `insert`ed before its parents are enqueued, so each name is processed at most once. The accumulator only grows, hence cycles in the (possibly malformed) graph terminate — no `fuel` parameter is needed. -/ -partial def TypeContext.ancestors (ctx : TypeContext) (name : String) : Std.HashSet String := +partial def TypeLattice.ancestors (ctx : TypeLattice) (name : String) : Std.HashSet String := let rec go (acc : Std.HashSet String) (frontier : List String) : Std.HashSet String := match frontier with | [] => acc @@ -562,14 +572,14 @@ partial def TypeContext.ancestors (ctx : TypeContext) (name : String) : Std.Hash go {} [name] /-- Pure subtyping `<:`. Walks the `extending` chain for `CompositeType` - (via `TypeContext.ancestors`), unfolds `TypeAlias` to its target, and - unwraps `ConstrainedType` to its base (both via `TypeContext.unfold`), + (via `TypeLattice.ancestors`), unfolds `TypeAlias` to its target, and + unwraps `ConstrainedType` to its base (both via `TypeLattice.unfold`), then falls back to structural equality via `highEq`. Used together with `isConsistent` to form `isConsistentSubtype`, which is what the bidirectional checker invokes at every check-mode boundary (rule `[⇐] Sub`). -/ -def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := +def isSubtype (ctx : TypeLattice) (sub sup : HighTypeMd) : Bool := let sub' := ctx.unfold sub let sup' := ctx.unfold sup match sub'.val, sup'.val with @@ -591,7 +601,7 @@ def isSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := Used directly by `[⇒] Op-Eq`, where the operand types must be mutually consistent (no subtype direction is privileged), and as one half of `isConsistentSubtype`. -/ -def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := +def isConsistent (ctx : TypeLattice) (a b : HighTypeMd) : Bool := -- `MultiValuedExpr` is checked element-wise *before* unfolding so elements -- remain demonstrable subterms of `a`/`b`. `unfold` is `partial`, and is in -- any case the identity on `MultiValuedExpr`, so this loses no precision. @@ -628,7 +638,7 @@ def isConsistent (ctx : TypeContext) (a b : HighTypeMd) : Bool := carve-out — user-defined types are now a regular participant in `<:`, with `isSubtype` walking inheritance chains and unwrapping aliases and constrained types to deliver real checking on user-defined code. -/ -def isConsistentSubtype (ctx : TypeContext) (sub sup : HighTypeMd) : Bool := +def isConsistentSubtype (ctx : TypeLattice) (sub sup : HighTypeMd) : Bool := isConsistent ctx sub sup || isSubtype ctx sub sup def HighType.isBool : HighType → Bool @@ -803,11 +813,11 @@ def TypeDefinition.name : TypeDefinition → Identifier | .Datatype ty => ty.name | .Alias ty => ty.name -/-- Build a `TypeContext` from a list of `TypeDefinition`s. +/-- Build a `TypeLattice` from a list of `TypeDefinition`s. Aliases populate `unfoldMap` with their target; constrained types populate it with their base; composites populate `extendingMap` with their direct parents. Datatypes contribute nothing — they're nominal and irreducible. -/ -def TypeContext.ofTypes (types : List TypeDefinition) : TypeContext := +def TypeLattice.ofTypes (types : List TypeDefinition) : TypeLattice := types.foldl (init := {}) fun ctx td => match td with | .Alias ta => { ctx with unfoldMap := ctx.unfoldMap.insert ta.name.text ta.target } diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index f432e7c7c9..9ff7e32edd 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -299,7 +299,7 @@ structure ResolveState where /-- Type-relation tables (alias/constrained unfolding + composite extending chains) used by the subtyping/consistency checks. Built once from `program.types` at the start of `resolve`. -/ - typeContext : TypeContext := {} + typeLattice : TypeLattice := {} @[expose] abbrev ResolveM := StateM ResolveState @@ -488,7 +488,7 @@ private def typeMismatch (source : Option FileRange) (construct : Option StmtExp actual type is already in hand (assignment, call args, body vs declared output) — equivalent to `Check.resolveStmtExpr e expected` but without re-synthesizing. -/ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (actual : HighTypeMd) : ResolveM Unit := do - let ctx := (← get).typeContext + let ctx := (← get).typeLattice unless isConsistentSubtype ctx actual expected do typeMismatch source none s!"expected '{formatType expected}'" actual @@ -497,7 +497,7 @@ private def checkSubtype (source : Option FileRange) (expected : HighTypeMd) (ac accepted as a gradual escape hatch. Aliases and constrained types are unfolded first so e.g. `nat` (constrained over `int`) counts as numeric. Used by Op-Cmp / Op-Arith. -/ -private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := +private def isNumeric (ctx : TypeLattice) (ty : HighTypeMd) : Bool := match (ctx.unfold ty).val with | .TInt | .TReal | .TFloat64 | .TBv _ | .Unknown => true | _ => false @@ -512,7 +512,7 @@ private def isNumeric (ctx : TypeContext) (ty : HighTypeMd) : Bool := type: a homogeneous arithmetic expression `1 + 2` yields `TInt`, `1 + ` yields `TInt` (Unknown promotes), ` + ` yields `Unknown`, and `1 + 2.0` is rejected. -/ -private def join (ctx : TypeContext) +private def join (ctx : TypeLattice) (a b : HighTypeMd) : Option HighTypeMd := let a' := ctx.unfold a let b' := ctx.unfold b @@ -524,7 +524,7 @@ private def join (ctx : TypeContext) /-- Test whether a type is a user-defined reference type. `Unknown` is accepted as a gradual escape hatch. Used by Fresh and ReferenceEquals, which only make sense on composite/datatype references. -/ -private def isReference (ctx : TypeContext) (ty : HighTypeMd) : Bool := +private def isReference (ctx : TypeLattice) (ty : HighTypeMd) : Bool := match (ctx.unfold ty).val with | .UserDefined _ | .Unknown => true | _ => false @@ -962,7 +962,7 @@ def Check.while (exprMd : StmtExprMd) Check.resolveStmtExpr a.val { val := .TBool, source := a.val.source }) let dec' ← dec.attach.mapM (fun a => have := a.property; do let (e', decTy) ← Synth.resolveStmtExpr a.val - let ctx := (← get).typeContext + let ctx := (← get).typeLattice unless isNumeric ctx decTy do typeMismatch a.val.source none "expected a numeric type" decTy pure e') @@ -1347,7 +1347,7 @@ def Synth.ifThenElse (exprMd : StmtExprMd) pure (.IfThenElse cond' thenBr' none, { val := .TVoid, source := source }) | some e => let (e', elseTy) ← Synth.resolveStmtExpr e - let ctx := (← get).typeContext + let ctx := (← get).typeLattice let ty ← if isConsistent ctx thenTy elseTy then pure thenTy @@ -1781,7 +1781,7 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) Synth.resolveStmtExpr a.val) let args' := results.map (·.1) let argTypes := results.map (·.2) - let ctx := (← get).typeContext + let ctx := (← get).typeLattice -- Per-operand numeric check: surface the bad operand directly. for (a, aTy) in args'.zip argTypes do unless isNumeric ctx aTy do @@ -1821,14 +1821,14 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) for (a, aTy) in args'.zip argTypes do checkSubtype a.source { val := .TBool, source := a.source } aTy | .Lt | .Leq | .Gt | .Geq => - let ctx := (← get).typeContext + let ctx := (← get).typeLattice for (a, aTy) in args'.zip argTypes do unless isNumeric ctx aTy do typeMismatch a.source (some expr) "expected a numeric type" aTy | .Eq | .Neq => match argTypes with | [lhsTy, rhsTy] => - let ctx := (← get).typeContext + let ctx := (← get).typeLattice unless isConsistent ctx lhsTy rhsTy do let diag := diagnosticFromSource source s!"cannot compare '{formatType lhsTy}' with '{formatType rhsTy}' using '{op}'" @@ -1888,7 +1888,7 @@ def Check.primitiveOp (exprMd : StmtExprMd) ResolveM StmtExprMd := do let operandTy : HighTypeMd ← match op with | .Neg | .Add | .Sub | .Mul | .Div | .Mod | .DivT | .ModT => - let ctx := (← get).typeContext + let ctx := (← get).typeLattice unless isNumeric ctx expected do typeMismatch source none "expected a numeric type" expected pure expected @@ -1969,7 +1969,7 @@ def Synth.asType (exprMd : StmtExprMd) ResolveM (StmtExpr × HighTypeMd) := do let (target', targetTy) ← Synth.resolveStmtExpr target let ty' ← resolveHighType ty - let ctx := (← get).typeContext + let ctx := (← get).typeLattice unless isConsistentSubtype ctx targetTy ty' || isConsistentSubtype ctx ty' targetTy do let diag := diagnosticFromSource target.source s!"cannot cast unrelated type '{formatType targetTy}' to '{formatType ty'}'" @@ -1999,7 +1999,7 @@ def Synth.isType (exprMd : StmtExprMd) ResolveM (StmtExpr × HighTypeMd) := do let (target', targetTy) ← Synth.resolveStmtExpr target let ty' ← resolveHighType ty - let ctx := (← get).typeContext + let ctx := (← get).typeLattice unless isConsistentSubtype ctx targetTy ty' || isConsistentSubtype ctx ty' targetTy do let diag := diagnosticFromSource target.source s!"cannot test unrelated type '{formatType targetTy}' against '{formatType ty'}'" @@ -2038,7 +2038,7 @@ def Synth.refEq (exprMd : StmtExprMd) (expr : StmtExpr) let _ := h_expr let (lhs', lhsTy) ← Synth.resolveStmtExpr lhs let (rhs', rhsTy) ← Synth.resolveStmtExpr rhs - let ctx := (← get).typeContext + let ctx := (← get).typeLattice unless isReference ctx lhsTy do typeMismatch lhs'.source (some expr) "expected a reference type" lhsTy unless isReference ctx rhsTy do @@ -2235,7 +2235,7 @@ def Synth.fresh (exprMd : StmtExprMd) (expr : StmtExpr) ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr let (val', valTy) ← Synth.resolveStmtExpr val - unless isReference (← get).typeContext valTy do + unless isReference (← get).typeLattice valTy do typeMismatch val'.source (some expr) "expected a reference type" valTy pure (.Fresh val', { val := .TBool, source := source }) termination_by (exprMd, 1) @@ -2896,8 +2896,8 @@ def resolve (program : Program) (existingModel: Option SemanticModel := none) : return { staticProcedures := staticProcs', staticFields := staticFields', types := types', constants := constants' } let nextId := existingModel.elim 1 (fun m => m.nextId) - let typeContext := TypeContext.ofTypes program.types - let (program', finalState) := phase1.run { nextId := nextId, typeContext } + let typeLattice := TypeLattice.ofTypes program.types + let (program', finalState) := phase1.run { nextId := nextId, typeLattice } -- Phase 2: build refToDef from the resolved program (all definitions now have UUIDs) let refToDef := buildRefToDef program' { program := program', From 5bae88d8e517861d51b8c65e01ab851c7e7221a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 12:54:43 -0400 Subject: [PATCH 174/189] explain invariance rules for collection types --- Strata/Languages/Laurel/LaurelAST.lean | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Strata/Languages/Laurel/LaurelAST.lean b/Strata/Languages/Laurel/LaurelAST.lean index 62ddacfec0..b31996ec3c 100644 --- a/Strata/Languages/Laurel/LaurelAST.lean +++ b/Strata/Languages/Laurel/LaurelAST.lean @@ -589,6 +589,34 @@ def isSubtype (ctx : TypeLattice) (sub sup : HighTypeMd) : Bool := (ctx.ancestors subName.text).contains supName.text || highEq sub' sup' | _, _ => highEq sub' sup' +/- ### Variance policy (covers `isSubtype` and `isConsistent`) + All child-carrying constructors are INVARIANT by design: `isConsistent` + bottoms out in `highEq` (structural equality) for `TSet`, `TMap`, + `TTypedField`, `Applied`, `Pure`, and `Intersection`. So `TSet Unknown ~ + TSet TInt` is FALSE — `Unknown` is a wildcard only at the TOP of a type, + never under a constructor. This is intentional: `TSet` / `TMap` are MUTABLE + collections, where covariance would be unsound; if you don't know the + element type, write a bare `Unknown`, not `TSet Unknown`. + + `MultiValuedExpr` is the SOLE exception that recurses (element-wise + consistency, not equality). It is not a mutable container: it is a transient + tuple of independent procedure-output values matched against multi-assignment + targets, so per-element consistency (letting an `Unknown` output flow into + one slot) is correct rather than unsound. + + `Pure b` is invariant today, but it is the one constructor where covariance + would be SOUND and desirable — it is the immutable value-view of a composite, + and immutability is exactly the condition that makes covariance safe. + TODO: Pure could be covariant once it matters (immutable value-view ⇒ covariance is sound) + + `Applied` (generics) is invariant as the safe default for not-yet-designed + parametric types; real variance is per-constructor and deliberately deferred. + + `Intersection` is NOT a variance question: `A & B` has lattice structure + (`A & B <: A`, `A & B <: B`, etc.) that is not modeled, and the current + `highEq` arm zips element-wise IN DECLARATION ORDER, so `A & B ≠ B & A` even + though intersection is conceptually unordered. Known limitation, to fix with + bespoke subtyping rules when intersections become live. -/ /-- Consistency `~` (Siek–Taha): the symmetric gradual relation. `Unknown` is the dynamic type and is consistent with everything; otherwise structural equality after unfolding aliases / constrained types. From fa629e3568a22be93d890757e6d6a6d9d3e957f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 15:44:04 -0400 Subject: [PATCH 175/189] fix(laurel): collapse unresolved type names to Unknown to stop diagnostic cascade When a variable is declared with an undefined type name, resolveRef already emits the single 'is not defined' diagnostic. Previously the dangling UserDefined reference was kept (the `| none => true` branch in resolveHighType), so the variable's later uses were type-checked against a phantom type, producing a cascade of follow-on mismatches. Collapse the unresolved reference to Unknown so only the one name-resolution error is reported. Co-Authored-By: Claude Opus 4.8 (1M context) --- Strata/Languages/Laurel/Resolution.lean | 10 ++++++-- .../Laurel/ResolutionTypeCheckTests.lean | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 9ff7e32edd..bae2bc2781 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -425,12 +425,18 @@ def resolveHighType (ty : HighTypeMd) : ResolveM HighTypeMd := do | .UserDefined ref => let ref' ← resolveRef ref ty.source (expected := #[.compositeType, .constrainedType, .datatypeDefinition, .typeAlias]) - -- If the reference resolved to the wrong kind, treat the type as Unknown to avoid cascading errors + -- If the reference failed to resolve (name not defined) or resolved to the + -- wrong kind, treat the type as Unknown to avoid cascading errors. The single + -- "is not defined" / "wrong kind" diagnostic was already emitted by `resolveRef`; + -- collapsing the dangling `UserDefined` to `Unknown` keeps the variable's later + -- uses from being type-checked against a phantom type. A name that genuinely + -- resolves to a composite/datatype/alias/constrained type stays `UserDefined` + -- so real subtype checking still works. let s ← get let kindOk : Bool := match s.scope.get? ref.text with | some (_, node) => node.kind == .unresolved || (#[ResolvedNodeKind.compositeType, .constrainedType, .datatypeDefinition, .typeAlias].contains node.kind) - | none => true -- unresolved references already reported + | none => false -- name not defined: resolveRef already reported it if kindOk then pure (HighType.UserDefined ref') else pure HighType.Unknown | .TTypedField vt => diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index d2a6cc7903..05a49de171 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -301,4 +301,27 @@ function cmp(x: bv 32, y: bv 32): bool { #guard_msgs (drop info) in #eval testInputWithOffset "BitvectorComparisonOk" bitvectorComparisonOk 250 processResolution +/-! ## An unresolved declared type collapses to `Unknown` (no cascade) + +A variable declared with an undefined type name reports only the single +"is not defined" name-resolution error. `resolveHighType` collapses the +dangling `UserDefined` to `Unknown` once its name fails to resolve, so the +variable's later uses are not type-checked against a phantom type and no +cascade of follow-on mismatches (`0` vs the bad type, `x` vs `int`) is emitted. +(Regression guard: before the collapse-to-`Unknown` fix this program produced +three diagnostics — the name-resolution error plus the `0`-vs-`UndefinedType` +initializer mismatch and the `x`-vs-`int` use mismatch; it must now produce +exactly one.) -/ + +def unresolvedDeclaredTypeNoCascade := r" +procedure useUndef() opaque { + var x: UndefinedType := 0; +// ^^^^^^^^^^^^^ error: 'UndefinedType' is not defined + var y: int := x + 2 +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "UnresolvedDeclaredTypeNoCascade" unresolvedDeclaredTypeNoCascade 308 processResolution + end Laurel From 644c1112503e5d197442176dfd05dba74e8bac89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 15:44:24 -0400 Subject: [PATCH 176/189] feat(laurel): reject over-arity calls to known procedures Add a `procArity` helper that returns the declared positional parameter count only when the callee genuinely resolves to a static or instance procedure (dropping the implicit `self` for instance procedures), and `none` for every other resolution kind. `Synth.staticCall` and `Synth.instanceCall` now emit an over-arity diagnostic when a call supplies more arguments than the procedure declares. Unresolved names, constructors, parameters, and constants are left untouched so no spurious or duplicate diagnostic is produced. Under-arity is not flagged. This moves the over-arity error into the resolution pass, replacing the old Core unifier ArityMismatch message (T22 updated accordingly). Co-Authored-By: Claude Opus 4.8 (1M context) --- Strata/Languages/Laurel/Resolution.lean | 85 +++++++++++++++---- .../Fundamentals/T22_ArityMismatch.lean | 11 ++- .../Laurel/ResolutionTypeCheckTests.lean | 37 ++++++++ 3 files changed, 114 insertions(+), 19 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index bae2bc2781..f7191ebdd7 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -579,6 +579,27 @@ private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List Hig | some (_, .constant c) => pure (c.type, []) | _ => pure ({ val := .Unknown, source := callee.source }, []) +/-- The number of positional arguments `callee` accepts, *only* when it + genuinely resolves to a procedure with a known parameter count. Returns + `none` for every other resolution kind — unresolved names (whose + `getCallInfo` `paramTypes` is `[]` purely because the name was not found), + datatype constructors/testers, parameters, and constants — so that the + over-arity check in the call rules does not fire on those (which would + duplicate the already-reported name-resolution error, or wrongly flag a + constructor/parameter/constant call). + + For an instance procedure the implicit `self` receiver is not supplied + positionally at an `InstanceCall` site, so it is dropped here exactly as + the `dropSelf` logic in `Synth.instanceCall` does. `dropSelf` is passed by + the caller: `false` for `Synth.staticCall` (no `self`), and `true` for an + instance procedure reached through `Synth.instanceCall`. -/ +private def procArity (callee : Identifier) (dropSelf : Bool) : ResolveM (Option Nat) := do + match (← get).scope.get? callee.text with + | some (_, .staticProcedure proc) => pure (some proc.inputs.length) + | some (_, .instanceProcedure _ proc) => + pure (some (if dropSelf then proc.inputs.length - 1 else proc.inputs.length)) + | _ => pure none + /-! ## Typing rules The judgment is bidirectional: @@ -1607,17 +1628,23 @@ def Check.assign (exprMd : StmtExprMd) The two rules differ only in *output* arity — argument checking is identical. Callee is resolved against the expected kinds (parameter, static procedure, datatype constructor, datatype destructor, constant); - each argument is *checked* against the corresponding parameter type. - Surplus arguments (beyond the declared parameters, or when the callee is - unresolved so `paramTypes = []`) are checked against `Unknown`, the - gradual escape hatch, rather than flagged as an arity error. The - bidirectional - push lets impure-expression arguments (`{x := 1; x}`, `if c then …`, - holes) flow through their own check rules instead of bottoming out at - the synth wildcard. Arguments past the declared parameter list (or - when the callee is unresolved and `paramTypes = []`) are checked - against `Unknown`, the gradual escape hatch — this preserves the old - behavior of resolving args without flagging arity mismatches here. + each argument is *checked* against the corresponding parameter type. The + bidirectional push lets impure-expression arguments (`{x := 1; x}`, + `if c then …`, holes) flow through their own check rules instead of + bottoming out at the synth wildcard. + + When the callee resolves to a static procedure with a known parameter + count and the call supplies *more* arguments than it declares, an + over-arity diagnostic is emitted (the surplus arguments are still + resolved first, against `Unknown`, so errors inside them are reported + too). The check fires *only* for genuine procedures (`procArity`); for an + unresolved name (where `paramTypes = []` purely because the name was not + found), a datatype constructor/tester, a parameter, or a constant, no + arity diagnostic is emitted — surplus arguments are checked against + `Unknown`, the gradual escape hatch, exactly as before, so no + spurious/duplicate diagnostic is produced. Under-arity (too few + arguments) is deliberately not flagged. + The result type is the (possibly multi-valued) declared output type from `getCallInfo`. -/ def Synth.staticCall (exprMd : StmtExprMd) @@ -1633,6 +1660,20 @@ def Synth.staticCall (exprMd : StmtExprMd) let args' ← (args.attach.zip expectedTys).mapM (fun (⟨a, hMem⟩, paramTy) => do have := hMem Check.resolveStmtExpr a paramTy) + -- Over-arity check: reject calls that supply MORE arguments than the callee + -- declares, but *only* when the callee genuinely resolves to a procedure with + -- a known parameter count (`procArity`). For any other resolution kind — + -- unresolved name, datatype constructor/tester, parameter, constant — we leave + -- the Unknown-padding behavior above untouched, so no spurious/duplicate + -- arity diagnostic is emitted (an unresolved name already reported "not + -- defined"). Args are resolved above regardless, so errors inside surplus + -- arguments are still reported. The return type is unchanged to suppress + -- cascading errors. Under-arity (too few args) is deliberately not flagged. + if let some arity ← procArity callee (dropSelf := false) then + if args.length > arity then + let diag := diagnosticFromSource source + s!"call to '{callee}' expects {arity} argument(s) but {args.length} were provided" + modify fun s => { s with errors := s.errors.push diag } pure (.StaticCall callee' args', retTy) termination_by (exprMd, 1) decreasing_by @@ -1662,10 +1703,13 @@ def Synth.staticCall (exprMd : StmtExprMd) The two rules differ only in *output* arity. Target is synthesized; callee resolves to an instance or static procedure; arguments are checked pairwise against the callee's parameter types after dropping - `self`, with surplus arguments checked against `Unknown` (as in - `Synth.staticCall`). Like `Synth.staticCall`, the push is bidirectional - so block- and conditional-shaped arguments route through their own - check rules. -/ + `self`. As in `Synth.staticCall`, supplying *more* arguments than the + callee declares (compared against the post-`self` parameter count) emits + an over-arity diagnostic when the callee genuinely resolves to a + procedure, while surplus arguments against any other resolution kind are + still checked against `Unknown` with no arity diagnostic. Like + `Synth.staticCall`, the push is bidirectional so block- and + conditional-shaped arguments route through their own check rules. -/ def Synth.instanceCall (exprMd : StmtExprMd) (target : StmtExprMd) (callee : Identifier) (args : List StmtExprMd) (source : Option FileRange) @@ -1697,6 +1741,17 @@ def Synth.instanceCall (exprMd : StmtExprMd) let args' ← (args.attach.zip expectedTys).mapM (fun (⟨a, hMem⟩, paramTy) => do have := hMem Check.resolveStmtExpr a paramTy) + -- Over-arity check (mirrors `Synth.staticCall`): reject calls supplying more + -- arguments than the callee declares, comparing against the post-`self` + -- parameter count. `procArity` is given the same `dropSelf` flag computed + -- above, so an instance procedure's implicit `self` is excluded; it returns + -- `none` for any non-procedure resolution, leaving the Unknown-padding (and + -- no duplicate diagnostic) for those. Args are resolved above regardless. + if let some arity ← procArity callee dropSelf then + if args.length > arity then + let diag := diagnosticFromSource source + s!"call to '{callee}' expects {arity} argument(s) but {args.length} were provided" + modify fun s => { s with errors := s.errors.push diag } pure (.InstanceCall target' callee' args', retTy) termination_by (exprMd, 1) decreasing_by diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean index 3933468b4b..9cf3036787 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean @@ -15,6 +15,12 @@ open StrataTest.Util namespace Strata namespace Laurel +-- Over-arity is now caught in the resolution pass (type checking added to +-- Laurel resolution): calling `f` (1 parameter) with 2 arguments is rejected +-- with the resolution-pass arity diagnostic. Because resolution errors short- +-- circuit the pipeline before Core translation, the call no longer reaches the +-- Core unifier, so the old `ArityMismatch ❌ Type checking error / Impossible to +-- unify …` message is replaced by the resolution diagnostic below. def arityMismatchProgram := r" function f(x: int): int { x }; @@ -22,13 +28,10 @@ procedure caller() opaque { var y: int := f(1, 2) +// ^^^^^^^ error: call to 'f' expects 1 argument(s) but 2 were provided }; " -/-- -error: ArityMismatch(79-100) ❌ Type checking error. -Impossible to unify int with (arrow int $__ty35). --/ #guard_msgs(drop info, error) in #eval testInputWithOffset "ArityMismatch" arityMismatchProgram 14 processLaurelFile diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 05a49de171..a3f8ac6bef 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -301,6 +301,43 @@ function cmp(x: bv 32, y: bv 32): bool { #guard_msgs (drop info) in #eval testInputWithOffset "BitvectorComparisonOk" bitvectorComparisonOk 250 processResolution +/-! ## Over-arity calls are rejected + +A call that supplies more arguments than the callee declares is rejected with +an arity diagnostic. The check fires only when the callee genuinely resolves to +a procedure with a known parameter count (`procArity`). Under-arity (too few +arguments) is deliberately not flagged. -/ + +def overArityCall := r" +function foo(x: int): int { x }; +function bar(): int { + foo(1, 2) +//^^^^^^^^^ error: call to 'foo' expects 1 argument(s) but 2 were provided +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "OverArityCall" overArityCall 269 processResolution + +/-! ## A too-many-args call to an *unresolved* name does not double-report + +Calling a name that does not resolve to any definition with surplus arguments +reports only the name-resolution error — not a spurious arity error on top. +`procArity` returns `none` for an unresolved name (its empty `paramTypes` is an +artifact of the name not being found, not a zero-arity procedure), so the +over-arity check is skipped. (Regression guard for the no-duplicate-diagnostic +behavior.) -/ + +def overArityUnresolvedCallNoDouble := r" +function bar(): int { + nope(1, 2) +//^^^^^^^^^^ error: 'nope' is not defined +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "OverArityUnresolvedCallNoDouble" overArityUnresolvedCallNoDouble 289 processResolution + /-! ## An unresolved declared type collapses to `Unknown` (no cascade) A variable declared with an undefined type name reports only the single From eb5f354110945e5e88095e045c676509081573be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 15:44:42 -0400 Subject: [PATCH 177/189] fix(laurel): reject multi-output calls used as operator operands A multi-output call (`multi(x)` declared `returns (a, b)`) used in value position has type `MultiValuedExpr`, an internal pseudo-type with no Core lowering. Letting it reach an operator slot crashes a later pass as a StrataBug. Add a `reportMultiValued` guard in `Synth.primitiveOp` that, across all operator families, emits a position-oriented diagnostic per offending operand and short-circuits to the operator's natural result type, suppressing the per-family check and its cascading error. This also fixes the case where both operands share the same `MultiValuedExpr` shape (e.g. `multi(1) == multi(2)`), which `isConsistent` would otherwise accept. Co-Authored-By: Claude Opus 4.8 (1M context) --- Strata/Languages/Laurel/Resolution.lean | 33 ++++++++++++++++++- .../Laurel/ResolutionTypeCheckTests.lean | 20 ++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index f7191ebdd7..acb92f5dd7 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1831,6 +1831,22 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) (h : exprMd.val = .PrimitiveOp op args skipProof) : ResolveM (StmtExpr × HighTypeMd) := do let _ := h_expr -- carries the constructor identity for `expr` in diagnostics + -- Guard (all operator families): a `MultiValuedExpr` operand is a + -- multi-output call (`multi(x)` declared `returns (a, b)`) used in value + -- position. It is an internal pseudo-type with no Core lowering, so it must + -- never reach an operator slot — letting it through crashes a later pass as + -- a `StrataBug`. Emit the position-oriented diagnostic per offending operand + -- and return `true` so the caller short-circuits to the operator's natural + -- result type, suppressing the per-family check (and its cascading error) + -- on that operand. + let reportMultiValued (a : StmtExprMd) (aTy : HighTypeMd) : ResolveM Bool := do + match aTy.val with + | .MultiValuedExpr _ => + let diag := diagnosticFromSource a.source + "multi-output call cannot be used as a value here; it returns multiple values. Use a multi-target assignment instead" + modify fun s => { s with errors := s.errors.push diag } + pure true + | _ => pure false match op with -- Arithmetic: synth each operand's type, then take the LUB under -- the consistency relation. This is the same discipline as @@ -1842,6 +1858,14 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) Synth.resolveStmtExpr a.val) let args' := results.map (·.1) let argTypes := results.map (·.2) + let unknownTy : HighTypeMd := { val := .Unknown, source := source } + -- Multi-output operand guard: short-circuit to `Unknown` (arithmetic's + -- natural cascade-suppression type) once any operand is multi-valued. + let mut hasMulti := false + for (a, aTy) in args'.zip argTypes do + if (← reportMultiValued a aTy) then hasMulti := true + if hasMulti then + return (.PrimitiveOp op args' skipProof, unknownTy) let ctx := (← get).typeLattice -- Per-operand numeric check: surface the bad operand directly. for (a, aTy) in args'.zip argTypes do @@ -1851,7 +1875,6 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) -- empty list (impossible for these ops, but kept for totality) -- yields `Unknown` and a single-operand fold (`Neg`) yields the -- operand's type. - let unknownTy : HighTypeMd := { val := .Unknown, source := source } let resultTy := argTypes.foldl (fun acc aTy => match acc with @@ -1877,6 +1900,14 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) | .StrConcat => HighType.TString -- Unreachable: filtered above. | _ => HighType.Unknown + -- Multi-output operand guard: short-circuit to the operator's natural + -- result type (`TBool` for bool/cmp/eq, `TString` for concat) once any + -- operand is multi-valued, suppressing the per-family check below. + let mut hasMulti := false + for (a, aTy) in args'.zip argTypes do + if (← reportMultiValued a aTy) then hasMulti := true + if hasMulti then + return (.PrimitiveOp op args' skipProof, { val := resultTy, source := source }) match op with | .And | .Or | .AndThen | .OrElse | .Not | .Implies => for (a, aTy) in args'.zip argTypes do diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index a3f8ac6bef..865b84641a 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -162,13 +162,31 @@ def multiOutputInExpr := r" procedure multi(x: int) returns (a: int, b: int) opaque; procedure test() opaque { assert multi(1) == 1 -// ^^^^^^^^^^^^^ error: cannot compare '(int, int)' with 'int' using '==' +// ^^^^^^^^ error: multi-output call cannot be used as a value here }; " #guard_msgs (error, drop all) in #eval testInputWithOffset "MultiOutputInExpr" multiOutputInExpr 146 processResolution +/-- A multi-output call in operator-operand (value) position is rejected with a +position-oriented diagnostic, even when both operands have the *same* +`MultiValuedExpr` shape (so `isConsistent` would otherwise accept them). Without +this guard `multi(1) == multi(2)` passes resolution and crashes a later pass as +a `StrataBug`, since `MultiValuedExpr` has no Core lowering. The guard fires per +offending operand (here both), short-circuiting the per-family equality check. -/ +def multiOutputBothSides := r" +procedure multi(x: int) returns (a: int, b: int) opaque; +procedure test() opaque { + assert multi(1) == multi(2) +// ^^^^^^^^ error: multi-output call cannot be used as a value here +// ^^^^^^^^ error: multi-output call cannot be used as a value here +}; +" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "MultiOutputBothSides" multiOutputBothSides 146 processResolution + def assignTargetCountMismatch := r" procedure multi() returns (a: int, b: int) opaque; procedure test() opaque { From d90c03b6c6f73926beb0f9a93a2f6053765ec95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 15:44:51 -0400 Subject: [PATCH 178/189] docs(laurel): document bitvectors as a numeric type Update the Numeric predicate documentation in the Laurel rules doc to list TBv (a bitvector of any width) alongside TInt, TReal, and TFloat64, and to note Unknown as the gradual escape hatch, matching the actual `isNumeric` behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/verso/LaurelDoc.lean | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 8fc87bc4dd..dbf97d8824 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -242,7 +242,9 @@ The following notation recurs throughout the rules: {name Strata.Laurel.isConsistentSubtype}`isConsistentSubtype`, the gradual combination of the two above. - $`\mathsf{Numeric}\;T` — a predicate holding when $`T` is consistent with one - of $`\mathsf{TInt}`, $`\mathsf{TReal}`, $`\mathsf{TFloat64}`. + of $`\mathsf{TInt}`, $`\mathsf{TReal}`, $`\mathsf{TFloat64}`, or + $`\mathsf{TBv}_w` (a bitvector of any width $`w`), with $`\mathsf{Unknown}` + admitted as the gradual escape hatch. - $`\dashv \Gamma'` — a rule's *output scope*: the judgment threads $`\Gamma` in and produces $`\Gamma'` out. Only \[⇐\] Var-Declare and \[⇐\] Block-Cons use this to extend the scope. @@ -516,9 +518,9 @@ The optional $`\mathit{decreases}` clause is synthesized and required to have a numeric type via the same $`\mathsf{Numeric}` predicate used by the arithmetic primitive operations. $`\mathsf{Numeric}` is a predicate (it admits $`\mathsf{TInt}`, $`\mathsf{TReal}`, -$`\mathsf{TFloat64}`, and $`\mathsf{Unknown}` as the gradual escape -hatch), not a single type, so the clause runs in synth mode rather -than check mode. +$`\mathsf{TFloat64}`, $`\mathsf{TBv}_w` (a bitvector of any width), and +$`\mathsf{Unknown}` as the gradual escape hatch), not a single type, so +the clause runs in synth mode rather than check mode. {docstring Strata.Laurel.Resolution.Check.while} @@ -600,7 +602,9 @@ tag := "rules-primitive-operations" `Numeric` abbreviates "consistent with one of {name Strata.Laurel.HighType.TInt}`TInt`, {name Strata.Laurel.HighType.TReal}`TReal`, -{name Strata.Laurel.HighType.TFloat64}`TFloat64`". +{name Strata.Laurel.HighType.TFloat64}`TFloat64`, or +{name Strata.Laurel.HighType.TBv}`TBv` (a bitvector of any width)", with +`Unknown` admitted as the gradual escape hatch. $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TBool} \quad \mathit{op} \in \{\mathsf{And}, \mathsf{Or}, \mathsf{AndThen}, \mathsf{OrElse}, \mathsf{Not}, \mathsf{Implies}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Bool)}` @@ -613,7 +617,8 @@ $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathsf{Numeric}\;U The arithmetic synth rule mirrors $`[⇒]\,\text{Op-Eq}` but generalised to $`n` operands. Each operand is synthesized and required to be $`\mathsf{Numeric}` (i.e. $`\mathsf{TInt}`, $`\mathsf{TReal}`, -$`\mathsf{TFloat64}`, or the gradual $`\mathsf{Unknown}`). The +$`\mathsf{TFloat64}`, $`\mathsf{TBv}_w` (a bitvector of any width), or +the gradual $`\mathsf{Unknown}`). The result type is the *consistency LUB* $`\bigsqcup_i U_i` — a fold of the operand types under {name Strata.Laurel.isConsistent}`isConsistent`'s flat lattice: From 6f5aad52c3d6c7733cd39ad590ab686c2d661eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 15:45:24 -0400 Subject: [PATCH 179/189] refactor(laurel): symmetrize Synth.ifThenElse via join (branch-order independence) `Synth.ifThenElse` now returns the symmetric join of the two consistent branch types (`(join ctx thenTy elseTy).getD thenTy`) rather than just the then-branch type. A hole branch promotes to the other branch's concrete type regardless of branch order, so the synthesized type is independent of which branch is written first. `isConsistent` remains the accept/reject gate; the lone TCore corner where `join` is `none` falls back to the then-branch type, leaving that boundary unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- Strata/Languages/Laurel/Resolution.lean | 17 ++++--- .../Laurel/ResolutionTypeCheckTests.lean | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index acb92f5dd7..f9a26a3876 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1353,11 +1353,16 @@ def Check.ifThenElse (exprMd : StmtExprMd) `cond` is checked against `TBool`; both branches are *synthesized*. With an `else`, the two branch types must be mutually consistent (`isConsistent`, the symmetric gradual relation — `Unknown` flows - freely either way); the result is the then-branch's type as a - representative. Inconsistent branches (e.g. `if c then 1 else "x"`) - emit a diagnostic and synthesize `Unknown` to suppress cascading - errors. Without an `else`, the `if` cannot produce a value on the - missing branch, so it synthesizes `TVoid`. + freely either way); when consistent, the result is their symmetric + `join` (`Unknown ⊔ T = T`), so a hole branch promotes to the other + branch's concrete type and the synthesized type is independent of + branch order. (`isConsistent` stays the accept/reject gate: it admits + a lone `TCore` corner where `join` is `none`, for which the result + falls back to the then-branch type, leaving that boundary unchanged.) + Inconsistent branches (e.g. `if c then 1 else "x"`) emit a diagnostic + and synthesize `Unknown` to suppress cascading errors. Without an + `else`, the `if` cannot produce a value on the missing branch, so it + synthesizes `TVoid`. This is the synth counterpart to `Check.ifThenElse`: when an expected type *is* available the dispatcher prefers the check rule (pushing the @@ -1377,7 +1382,7 @@ def Synth.ifThenElse (exprMd : StmtExprMd) let ctx := (← get).typeLattice let ty ← if isConsistent ctx thenTy elseTy then - pure thenTy + pure ((join ctx thenTy elseTy).getD thenTy) else let diag := diagnosticFromSource source s!"'if' branches have incompatible types '{formatType thenTy}' and '{formatType elseTy}'" diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 865b84641a..265d3656b1 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -283,6 +283,52 @@ function foo(c: bool): bool { #guard_msgs (error, drop all) in #eval testInputWithOffset "IfBranchesIncompatible" ifBranchesIncompatible 218 processResolution +/-! ## `if` in operand position synthesizes a *symmetric* branch join + +`Synth.ifThenElse` returns the symmetric join of the two consistent branch +types as the representative type (`(join ctx thenTy elseTy).getD thenTy`), +not just the then-branch type. So a hole branch (``, type `Unknown`) +promotes to the other branch's concrete type regardless of branch order: +both `(if c then else "x")` and `(if c then "x" else )` synthesize +`string`. As the operand of a numeric `<`, both orders therefore report the +*same* "expected a numeric type, got 'string'" diagnostic at the *same* +span — locking in symmetry. (Before the join, the then-first order returned +`Unknown` and was silently accepted, while only the else-first order +errored.) -/ + +def ifJoinSymmetricThenHole : String := + "\nfunction foo(c: bool): bool {\n" ++ + " (if c then else \"x\") < 1\n" ++ + "// ^^^^^^^^^^^^^^^^^^^^^^ error: '<' expected a numeric type, got 'string'\n" ++ + "};\n" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "IfJoinSymmetricThenHole" ifJoinSymmetricThenHole 246 processResolution + +def ifJoinSymmetricElseHole : String := + "\nfunction foo(c: bool): bool {\n" ++ + " (if c then \"x\" else ) < 1\n" ++ + "// ^^^^^^^^^^^^^^^^^^^^^^ error: '<' expected a numeric type, got 'string'\n" ++ + "};\n" + +#guard_msgs (error, drop all) in +#eval testInputWithOffset "IfJoinSymmetricElseHole" ifJoinSymmetricElseHole 256 processResolution + +/-! ## `if` branch join recovers precision from a hole + +When one branch is a hole (`Unknown`) and the other is a concrete numeric +type, the join recovers the concrete type (`Unknown ⊔ int = int`) rather +than collapsing to `Unknown`. So `if c then else 5` synthesizes a usable +`int` and resolves cleanly where an `int` is expected — no diagnostics. -/ + +def ifJoinRecoversInt : String := + "\nfunction bar(c: bool): int {\n" ++ + " if c then else 5\n" ++ + "};\n" + +#guard_msgs (drop info) in +#eval testInputWithOffset "IfJoinRecoversInt" ifJoinRecoversInt 270 processResolution + /-! ## Void procedure call in value position A call to a `void` procedure (no `returns` clause) used where a value is From 7c32f11520159034ed0397a0fb370770cb0fb17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 16:53:55 -0400 Subject: [PATCH 180/189] collapse identical branches --- Strata/Languages/Laurel/LaurelTypes.lean | 7 +------ Strata/Languages/Laurel/Resolution.lean | 8 +------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/Strata/Languages/Laurel/LaurelTypes.lean b/Strata/Languages/Laurel/LaurelTypes.lean index 2ce24e4346..2c7a36967f 100644 --- a/Strata/Languages/Laurel/LaurelTypes.lean +++ b/Strata/Languages/Laurel/LaurelTypes.lean @@ -23,12 +23,7 @@ def getCallType (source : Option FileRange) (model : SemanticModel) (callee : Id | .datatypeConstructor t _ => ⟨ .UserDefined t, source ⟩ | .datatypeDestructor _ fld => fld.type | .parameter p => p.type - | .staticProcedure proc => match proc.outputs with - | [] => { val := .TVoid, source := source } - | [singleOutput] => singleOutput.type - | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } - -- Mirrors `.staticProcedure`; currently unreachable (instance calls are lowered to static calls / not yet emitted). - | .instanceProcedure _ proc => match proc.outputs with + | .staticProcedure proc | .instanceProcedure _ proc => match proc.outputs with | [] => { val := .TVoid, source := source } | [singleOutput] => singleOutput.type | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 19c16ed2fe..6ccb8bf236 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -432,13 +432,7 @@ private def getVarType (ref : Identifier) : ResolveM HighTypeMd := do private def getCallInfo (callee : Identifier) : ResolveM (HighTypeMd × List HighTypeMd) := do let s ← get match s.scope.get? callee.text with - | some (_, .staticProcedure proc) => - let retTy := match proc.outputs with - | [] => { val := .TVoid, source := callee.source } - | [singleOutput] => singleOutput.type - | outputs => { val := .MultiValuedExpr (outputs.map (·.type)), source := none } - pure (retTy, proc.inputs.map (·.type)) - | some (_, .instanceProcedure _ proc) => + | some (_, .staticProcedure proc) | some (_, .instanceProcedure _ proc) => let retTy := match proc.outputs with | [] => { val := .TVoid, source := callee.source } | [singleOutput] => singleOutput.type From 03e56c67a80821cca2d98b34644550b815c44b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 17:21:06 -0400 Subject: [PATCH 181/189] make which op category we check explicit --- Strata/Languages/Laurel/Resolution.lean | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 6ccb8bf236..7984a5b826 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -699,6 +699,18 @@ def Check.resolveStmtExpr (exprMd : StmtExprMd) (expected : HighTypeMd) : Resolv Check.old exprMd val expected source (by rw [h_node]) | .ProveBy val proof => Check.proveBy exprMd val proof expected source (by rw [h_node]) + -- Only the arithmetic (`Neg`/`Add`/…/`ModT`) and boolean + -- (`And`/`Or`/…/`Implies`) families get a dedicated check arm: these push + -- `expected` inward through `Check.primitiveOp`. The remaining operators — + -- comparison/equality (`Eq`/`Neq`/`Lt`/…) and `StrConcat` — have no inward + -- push, so they are deliberately omitted here and fall through to the + -- synth-then-subsume wildcard at the bottom of this match. + -- + -- The arms are written out one operator per line rather than collapsed: an + -- inner `match op` would duplicate the wildcard's subsumption body, and a + -- binder distributed across an or-pattern (`.PrimitiveOp (op@.Neg) …`) + -- defeats the `by rw [h_node]` dependent-match proof, so the explicit + -- enumeration is the form that actually typechecks. | .PrimitiveOp .Neg args skipProof => Check.primitiveOp exprMd .Neg args skipProof expected source (by rw [h_node]) | .PrimitiveOp .Add args skipProof => From d36cbdf268b888090a76bc90d3a18e73f368d84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 17:32:37 -0400 Subject: [PATCH 182/189] remove self explanatory docstring --- .../Laurel/Examples/Fundamentals/T22_ArityMismatch.lean | 6 ------ 1 file changed, 6 deletions(-) diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean index 9cf3036787..20936c4036 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean @@ -15,12 +15,6 @@ open StrataTest.Util namespace Strata namespace Laurel --- Over-arity is now caught in the resolution pass (type checking added to --- Laurel resolution): calling `f` (1 parameter) with 2 arguments is rejected --- with the resolution-pass arity diagnostic. Because resolution errors short- --- circuit the pipeline before Core translation, the call no longer reaches the --- Core unifier, so the old `ArityMismatch ❌ Type checking error / Impossible to --- unify …` message is replaced by the resolution diagnostic below. def arityMismatchProgram := r" function f(x: int): int { x }; From a32917c7d064fad5d728d3bfd9674265ea2e528c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Thu, 11 Jun 2026 17:53:56 -0400 Subject: [PATCH 183/189] simplify stale explanation --- Strata/Languages/Laurel/Resolution.lean | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 7984a5b826..865c159452 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -854,8 +854,7 @@ def Check.varDeclare (param : Parameter) (source : Option FileRange) : (the rule ignores `A`). The optional `decreases` clause is synthesized and required to - have a numeric type (`TInt`, `TReal`, `TFloat64`, or `Unknown` as - the gradual escape hatch), via the same `Numeric U` predicate + have a numeric type, via the same `Numeric U` predicate used by the arithmetic primitive ops. `Numeric` is a predicate, not a single type, so the clause runs in synth mode rather than check mode. -/ From b85c4801b20ea2a3dd9567342f511d4792908967 Mon Sep 17 00:00:00 2001 From: Shilpi Goel Date: Fri, 12 Jun 2026 10:00:26 -0700 Subject: [PATCH 184/189] Merge main into main2 (2026-06-11) (#1363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Periodic merge of `main` into `main2` to keep main2 up to date with core improvements - 46 new commits from main since PR #1346 (mostly repo-removal PRs: #1339, #1351, #1343, #1329, #1334, plus code changes) - All conflicts resolved by keeping main2's versions (sub-repos stay local, no module migration, preserves main2-only features like Procedure.Body sum type, transparent procs, array axiomatization) - Fixed duplicate StrataDDM git entries that auto-merged into `docs/api/lake-manifest.json` and `docs/verso/lake-manifest.json` --------- Co-authored-by: Aaron Tomb Co-authored-by: Michael Tautschnig Co-authored-by: Kiro Co-authored-by: Juneyoung Lee <136006969+aqjune-aws@users.noreply.github.com> Co-authored-by: keyboardDrummer-bot Co-authored-by: Mikaël Mayer Co-authored-by: thanhnguyen-aws Co-authored-by: Fabio Madge Co-authored-by: Joe Hendrix Co-authored-by: Claude Opus 4.6 Co-authored-by: June Lee Co-authored-by: David Deng Co-authored-by: David Deng Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Mikael Mayer Co-authored-by: Remy Willems Co-authored-by: keyboardDrummer-bot Co-authored-by: Sagar Joshi <72283186+sagjoshi@users.noreply.github.com> --- .kiro/steering/structure.md | 6 +- README.md | 29 +--- Scripts/JavaGenTestData.lean | 79 +++++++++++ Scripts/LaurelToCBMC.lean | 134 ++++++++++++++++++ .../CBMC/GOTO/test_property_summary_e2e.sh | 3 +- .../Backends/CBMC/cbmc-string-support.patch | 61 ++++++++ .../Languages/Laurel/run_laurel_cbmc_tests.sh | 7 +- 7 files changed, 286 insertions(+), 33 deletions(-) create mode 100644 Scripts/JavaGenTestData.lean create mode 100644 Scripts/LaurelToCBMC.lean create mode 100644 StrataTest/Backends/CBMC/cbmc-string-support.patch diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md index 4445fa325b..e29800ee73 100644 --- a/.kiro/steering/structure.md +++ b/.kiro/steering/structure.md @@ -15,8 +15,6 @@ Strata is a Lean4 verification framework using **dialects** as composable langua - `Strata/` - Core implementation (DDM, dialects, languages, transforms, backends) - `StrataTest/` - Unit tests (mirrors Strata/ structure) - `Examples/` - Sample programs (`.st` files, naming: `..st`) -- `Tools/` - External tools (BoogieToStrata, Python utilities) -- `vcs/` - Generated SMT2 verification conditions ### Core Components @@ -34,9 +32,7 @@ Strata is a Lean4 verification framework using **dialects** as composable langua - `Core/` - Primary verification language (procedures, contracts, VCG, SMT encoding) - `C_Simp/` - Simplified C-like language - `Dyn/` - Dynamic language example -- `Laurel/` - A common representation for front-end languages like Java, Python and JavaScript. -Translated to Core. -- `Python/` - The well-known Python language +- `Laurel/` - A common representation for front-end languages like Java, Python and JavaScript. Translated to Core. **`Strata/Transform/`** - Program Transformations - Each transformation has implementation + optional correctness proof (`*Correct.lean`) diff --git a/README.md b/README.md index e39b1ebd6d..3102b9c54d 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,10 @@ changes!** (`cvc5` and `z3`). See [Installing dependencies → SMT Solvers](#smt-solvers) below. -3. **Python 3.11+**: required for Python-related tests and the `strata` - Python tooling. See [Installing dependencies → Python](#python) below. - -4. **Java JDK (11 or later)**: required for Java code generation tests. +3. **Java JDK (11 or later)**: required for Java code generation tests. See [Installing dependencies → Java](#java-for-code-generation-tests) below. -5. **ion-java jar (1.11.11)**: required for the Java/Ion integration test. +4. **ion-java jar (1.11.11)**: required for the Java/Ion integration test. See [Installing dependencies → Java](#java-for-code-generation-tests) below. ### Installing dependencies @@ -54,18 +51,6 @@ cp /path/to/cvc5 /path/to/z3 ~/.local/bin/ # or: sudo cp /path/to/cvc5 /path/to/z3 /usr/local/bin/ ``` -#### Python - -Python 3.11 or later is required. Install the `strata` Python package inside a -virtual environment (recommended; avoids PEP 668's `externally-managed-environment` -error on Debian/Ubuntu 23.04+): - -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install ./Tools/Python -``` - #### Java (for code generation tests) A JDK (11+) providing `javac` must be on your `PATH`. For running the @@ -81,7 +66,6 @@ wget -q -O StrataTestExtra/Languages/Java/testdata/ion-java-1.11.11.jar \ ```bash cvc5 --version # should print version info z3 --version # should print version info -python3 --version # should be 3.11+ ``` ## Build @@ -111,13 +95,10 @@ Two kinds of tests coexist in this repo: These accept prefix filters: ```bash -# Run all extra tests except Python (which requires the Python package) -lake test -- --exclude Languages.Python - -# Run only Python extra tests (requires `pip install ./Tools/Python`) -lake test -- Languages.Python +# Run all extra tests except those in the Imperative namespace +lake test -- --exclude DL.Imperative -# Run all extra tests (Python tests will fail without the Python package above) +# Run all extra tests lake test ``` diff --git a/Scripts/JavaGenTestData.lean b/Scripts/JavaGenTestData.lean new file mode 100644 index 0000000000..00a2e890dc --- /dev/null +++ b/Scripts/JavaGenTestData.lean @@ -0,0 +1,79 @@ +/- + Copyright Strata Contributors + + SPDX-License-Identifier: Apache-2.0 OR MIT +-/ +import StrataDDM + +/-! +# Java test data generation helper + +Usage: + lake env lean --run Scripts/JavaGenTestData.lean javaGen + lake env lean --run Scripts/JavaGenTestData.lean print --include + +Note: Unlike the former CLI `javaGen` command, this script only loads dialects +from files — it does not support referencing preloaded dialect names directly. +This is sufficient for the testdata regeneration workflow. +-/ + +open StrataDDM + +def javaGen (dialectPath packageName outputDir : String) : IO Unit := do + let fm ← mkDialectFileMap + let d ← readStrataDialectFile fm dialectPath + match StrataDDM.Java.generateDialect d packageName with + | .ok files => + StrataDDM.Java.writeJavaFiles outputDir packageName files + IO.println s!"Generated Java files for {d.name} in {outputDir}/{StrataDDM.Java.packageToPath packageName}" + | .error msg => + IO.eprintln s!"Error generating Java: {msg}" + IO.Process.exit 1 + +def printFile (includeDirs : List String) (file : String) : IO Unit := do + let fm ← mkDialectFileMap + let mut fm := fm + for dir in includeDirs do + match ← fm.addSearchPath dir |>.toBaseIO with + | .error msg => + IO.eprintln msg + IO.Process.exit 1 + | .ok fm' => fm := fm' + let ld ← fm.getLoaded + if mem : file ∈ ld.dialects then + IO.print <| ld.dialects.format file mem + return + match ← readStrataFile fm file with + | .dialect d => + let ld ← fm.getLoaded + if mem : d.name ∈ ld.dialects then + IO.print <| ld.dialects.format d.name mem + else + IO.eprintln "Internal error reading file." + IO.Process.exit 1 + | .program pgm => + IO.print <| toString pgm + +private def parseIncludeArgs (args : List String) : List String × List String := + go args [] +where + go : List String → List String → List String × List String + | "--include" :: dir :: rest, includes => go rest (dir :: includes) + | other, includes => (includes.reverse, other) + +def main (args : List String) : IO Unit := do + match args with + | "javaGen" :: dialectPath :: packageName :: outputDir :: _ => + javaGen dialectPath packageName outputDir + | "print" :: rest => + let (includeDirs, fileArgs) := parseIncludeArgs rest + match fileArgs with + | [file] => printFile includeDirs file + | _ => + IO.eprintln "Usage: ... print [--include ]... " + IO.Process.exit 1 + | _ => + IO.eprintln "Usage:" + IO.eprintln " lake env lean --run Scripts/JavaGenTestData.lean javaGen " + IO.eprintln " lake env lean --run Scripts/JavaGenTestData.lean print [--include ]... " + IO.Process.exit 1 diff --git a/Scripts/LaurelToCBMC.lean b/Scripts/LaurelToCBMC.lean new file mode 100644 index 0000000000..61388af488 --- /dev/null +++ b/Scripts/LaurelToCBMC.lean @@ -0,0 +1,134 @@ +/- + Copyright Strata Contributors + + SPDX-License-Identifier: Apache-2.0 OR MIT +-/ +import Strata.Backends.CBMC.GOTO.CoreToGOTOPipeline +import Strata.Languages.Laurel + +/-! # LaurelToCBMC + +Script that replaces `laurel_to_cbmc.sh`. Translates a Laurel `.lr.st` source +file through the full Strata pipeline to CBMC verification: + +1. Parse Laurel source → Laurel AST +2. Translate Laurel → Core +3. Inline procedures, type-check, generate CProver GOTO JSON +4. Invoke `symtab2gb` to produce a GOTO binary +5. Invoke `goto-cc` to add C scaffolding +6. Invoke `goto-instrument --dfcc` for contract instrumentation +7. Invoke `cbmc` for bounded model checking + +Usage: + lake env lean --run Scripts/LaurelToCBMC.lean + +Environment variables: + CBMC - path to cbmc binary (default: cbmc) + GOTO_CC - path to goto-cc binary (default: goto-cc) + GOTO_INSTRUMENT - path to goto-instrument binary (default: goto-instrument) +-/ + +open Strata + +/-- Strip well-known Strata file suffixes from a file path's basename. -/ +private def deriveBaseName (file : String) : String := + let name := System.FilePath.fileName file |>.getD file + let suffixes := [".lr.st", ".laurel.st", ".st"] + match suffixes.find? (name.endsWith ·) with + | some sfx => (name.dropEnd sfx.length).toString + | none => name + +/-- Read an environment variable, returning a default if unset or empty. -/ +private def getEnvOrDefault (var : String) (default : String) : IO String := do + match ← IO.getEnv var with + | some v => if v.isEmpty then pure default else pure v + | none => pure default + +/-- Run an external process. Prints stdout/stderr to the caller's streams and + returns the exit code. -/ +private def runProcess (step : String) (cmd : String) (args : Array String) : IO UInt32 := do + let proc ← IO.Process.spawn { + cmd := cmd + args := args + stdout := .inherit + stderr := .inherit + stdin := .inherit + } + let exitCode ← proc.wait + if exitCode != 0 then + IO.eprintln s!"Error: {step} failed (exit code {exitCode})" + return exitCode + +/-- The Laurel-to-GOTO translation pipeline. Parses a Laurel source file, + translates to Core, inlines procedures, type-checks, and emits CProver GOTO + JSON files (`.symtab.json` and `.goto.json`) in the + given output directory. -/ +private def laurelAnalyzeToGoto (path : System.FilePath) (outputDir : System.FilePath) + (baseName : String) : IO Unit := do + let content ← IO.FS.readFile path + let laurelProgram ← Strata.parseLaurelText path content + match ← Strata.Laurel.translate {} laurelProgram with + | (none, diags) => + throw (IO.userError s!"Core translation errors: {diags.map (·.message)}") + | (some coreProgram, _) => + -- Use the output directory as a prefix so files land in tmpDir + let outputBaseName := (outputDir / baseName).toString + match ← Strata.inlineCoreToGotoFiles coreProgram outputBaseName + (sourceText := some content) |>.toBaseIO with + | .ok () => pure () + | .error msg => throw (IO.userError msg) + +def main (args : List String) : IO UInt32 := do + match args with + | [file] => + unless file.endsWith ".lr.st" || file.endsWith ".laurel.st" do + IO.eprintln s!"Error: expected a .lr.st file, got: {file}" + return 1 + let path : System.FilePath := file + unless ← path.pathExists do + IO.eprintln s!"Error: file not found: {file}" + return 1 + let baseName := deriveBaseName file + + -- Use a temporary directory for intermediate files (cleaned up automatically) + IO.FS.withTempDir fun tmpDir => do + + -- Step 1: Laurel → GOTO JSON (in tmp dir) + let result ← (laurelAnalyzeToGoto path tmpDir baseName).toBaseIO + match result with + | .error e => + IO.eprintln s!"Error: {e}" + return 1 + | .ok () => pure () + + let symTabFile := (tmpDir / s!"{baseName}.symtab.json").toString + let gotoFile := (tmpDir / s!"{baseName}.goto.json").toString + let gbFile := (tmpDir / s!"{baseName}.gb").toString + let ccGbFile := (tmpDir / s!"{baseName}_cc.gb").toString + let dfccGbFile := (tmpDir / s!"{baseName}_dfcc.gb").toString + + -- Step 2: symtab2gb + let rc ← runProcess "symtab2gb" "symtab2gb" + #[symTabFile, "--goto-functions", gotoFile, "--out", gbFile] + if rc != 0 then return rc + + -- Step 3: goto-cc (add C scaffolding) + let gotoCC ← getEnvOrDefault "GOTO_CC" "goto-cc" + let rc ← runProcess "goto-cc" gotoCC + #["--function", "main", "-o", ccGbFile, gbFile] + if rc != 0 then return rc + + -- Step 4: goto-instrument --dfcc + let gotoInstrument ← getEnvOrDefault "GOTO_INSTRUMENT" "goto-instrument" + let rc ← runProcess "goto-instrument --dfcc" gotoInstrument + #["--dfcc", "main", ccGbFile, dfccGbFile] + if rc != 0 then return rc + + -- Step 5: cbmc verification + let cbmc ← getEnvOrDefault "CBMC" "cbmc" + runProcess "cbmc" cbmc + #[dfccGbFile, "--function", "main", "--z3", "--verbosity", "9"] + + | _ => + IO.eprintln "Usage: LaurelToCBMC " + return 1 diff --git a/StrataTest/Backends/CBMC/GOTO/test_property_summary_e2e.sh b/StrataTest/Backends/CBMC/GOTO/test_property_summary_e2e.sh index 47b1871cdc..cfd20dceda 100755 --- a/StrataTest/Backends/CBMC/GOTO/test_property_summary_e2e.sh +++ b/StrataTest/Backends/CBMC/GOTO/test_property_summary_e2e.sh @@ -8,7 +8,6 @@ set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" -LAUREL_TO_CBMC="$PROJECT_ROOT/StrataTest/Languages/Laurel/laurel_to_cbmc.sh" WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT @@ -26,7 +25,7 @@ procedure main() LAUREL # Run the full pipeline (strata → symtab2gb → goto-cc → goto-instrument → cbmc) -cbmc_out=$("$LAUREL_TO_CBMC" "$WORK/test.lr.st" 2>&1 || true) +cbmc_out=$(lake -d "$PROJECT_ROOT" env lean --run "$PROJECT_ROOT/Scripts/LaurelToCBMC.lean" "$WORK/test.lr.st" 2>&1 || true) # Verify CBMC output contains property summaries for summary in "addition equals eight" "difference equals two"; do diff --git a/StrataTest/Backends/CBMC/cbmc-string-support.patch b/StrataTest/Backends/CBMC/cbmc-string-support.patch new file mode 100644 index 0000000000..225f336438 --- /dev/null +++ b/StrataTest/Backends/CBMC/cbmc-string-support.patch @@ -0,0 +1,61 @@ +diff --git a/src/solvers/smt2/smt2_conv.cpp b/src/solvers/smt2/smt2_conv.cpp +index e00becc56e..43bd7888d3 100644 +--- a/src/solvers/smt2/smt2_conv.cpp ++++ b/src/solvers/smt2/smt2_conv.cpp +@@ -2707,8 +2707,24 @@ void smt2_convt::convert_expr(const exprt &expr) + else if(expr.id() == ID_function_application) + { + const auto &function_application_expr = to_function_application_expr(expr); ++ ++ // Check for string operations by looking at the function symbol name ++ std::string fn_name; ++ if(function_application_expr.function().id() == ID_symbol) ++ fn_name = id2string( ++ to_symbol_expr(function_application_expr.function()).get_identifier()); ++ ++ if(fn_name == "Str.Concat" && ++ function_application_expr.arguments().size() == 2) ++ { ++ out << "(str.++ "; ++ convert_expr(function_application_expr.arguments()[0]); ++ out << ' '; ++ convert_expr(function_application_expr.arguments()[1]); ++ out << ')'; ++ } + // do not use parentheses if there function is a constant +- if(function_application_expr.arguments().empty()) ++ else if(function_application_expr.arguments().empty()) + { + convert_expr(function_application_expr.function()); + } +@@ -3763,6 +3779,21 @@ void smt2_convt::convert_constant(const constant_exprt &expr) + out << "(_ bv" << (value_int - range_type.get_from()) << " " << width + << ")"; + } ++ else if(expr_type.id()==ID_string) ++ { ++ const std::string &value = id2string(expr.get_value()); ++ out << "\""; ++ for(char c : value) ++ { ++ if(c == '"') ++ out << "\"\""; ++ else if(c == '\\') ++ out << "\\\\"; ++ else ++ out << c; ++ } ++ out << "\""; ++ } + else + UNEXPECTEDCASE("unknown constant: "+expr_type.id_string()); + } +@@ -5991,6 +6022,8 @@ void smt2_convt::convert_type(const typet &type) + UNEXPECTEDCASE("unsuppored range type"); + out << "(_ BitVec " << address_bits(size) << ")"; + } ++ else if(type.id()==ID_string) ++ out << "String"; + else + { + UNEXPECTEDCASE("unsupported type: "+type.id_string()); diff --git a/StrataTest/Languages/Laurel/run_laurel_cbmc_tests.sh b/StrataTest/Languages/Laurel/run_laurel_cbmc_tests.sh index fd700d2a43..8ca1b74b17 100755 --- a/StrataTest/Languages/Laurel/run_laurel_cbmc_tests.sh +++ b/StrataTest/Languages/Laurel/run_laurel_cbmc_tests.sh @@ -8,12 +8,15 @@ # appears in CBMC output with the correct status. # # Environment variables: -# CBMC - path to cbmc binary (default: cbmc) +# CBMC - path to cbmc binary (default: cbmc) +# GOTO_CC - path to goto-cc binary (default: goto-cc) +# GOTO_INSTRUMENT - path to goto-instrument binary (default: goto-instrument) set -o pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" TESTS_DIR="$SCRIPT_DIR/tests" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" EXPECTED="$TESTS_DIR/cbmc_expected.txt" passed=0 @@ -40,7 +43,7 @@ for lr_file in "$TESTS_DIR"/*.lr.st; do fi # Run the pipeline - output=$("$SCRIPT_DIR/laurel_to_cbmc.sh" "$lr_file" 2>&1) + output=$(lake -d "$PROJECT_ROOT" env lean --run "$PROJECT_ROOT/Scripts/LaurelToCBMC.lean" "$lr_file" 2>&1) if [ $? -ne 0 ] && ! echo "$output" | grep -q "VERIFICATION"; then echo "ERR: $bn (pipeline error)" echo "$output" | tail -3 From 88a66b96fe82a687baa76023a9d24bca85d499eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 12 Jun 2026 14:06:21 -0400 Subject: [PATCH 185/189] update documentation --- Strata/Languages/Laurel/Resolution.lean | 31 +++++++++++++----------- docs/verso/LaurelDoc.lean | 32 ++++++++++++++++++------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 865c159452..8363126d60 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -384,7 +384,7 @@ private def isNumeric (ctx : TypeLattice) (ty : HighTypeMd) : Bool := | _ => false /-- Least upper bound of two types under the consistency relation - (Siek–Taha). On Laurel's flat lattice the LUB collapses to the + (Siek–Taha). On Laurel's flat lattice the join collapses to the "more informative" side: `Unknown` and `T` yields `T`; equal types (after unfolding) yield themselves; everything else is inconsistent and yields `none`. @@ -562,7 +562,7 @@ mutual /-- Synth-mode resolution: resolve `e` and synthesize its `HighType`, written `Γ ⊢ e ⇒ T`. Each constructor with a synthesis rule delegates to its rule's helper; constructors without one (statement-shaped - constructs like `IfThenElse`, `Block`, `While`, `Return`, …) hit + constructs like `While`, `Exit`, `Return`, …) hit a wildcard arm that emits a `typeMismatch` diagnostic and returns `Unknown` to suppress cascading errors. @@ -1220,9 +1220,10 @@ def Check.ifThenElse (exprMd : StmtExprMd) /-- (If-Synth) ``` - Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e T_t ~ T_e (If-Synth) + Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ T_t Γ ⊢ elseBr ⇒ T_e + T_t ~ T_e T = T_t ⨆ T_e (consistency join) (If-Synth) ────────────────────────────────────────────────────────────────────────── - Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T_t + Γ ⊢ IfThenElse cond thenBr (some elseBr) ⇒ T Γ ⊢ cond ⇐ TBool Γ ⊢ thenBr ⇒ _ (If-Synth-NoElse) ────────────────────────────────────────────────────────────────────────── @@ -1505,11 +1506,12 @@ def Check.assign (exprMd : StmtExprMd) Γ ⊢ StaticCall callee args ⇒ T' Γ(callee) = static-procedure with inputs Ts (Static-Call-Multi) - and outputs [T_1; …; T_n] (n ≠ 1) + and outputs [T_1; …; T_n] (n ≥ 2) Γ ⊢ args_i ⇐ Ts_i (pairwise) ────────────────────────────────────────────────────── Γ ⊢ StaticCall callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` + A callee with *zero* outputs synthesizes `TVoid` (the n = 0 case). The two rules differ only in *output* arity — argument checking is identical. Callee is resolved against the expected kinds (parameter, static procedure, datatype constructor, datatype destructor, constant); @@ -1580,11 +1582,12 @@ def Synth.staticCall (exprMd : StmtExprMd) Γ ⊢ target ⇒ _ (Instance-Call-Multi) Γ(callee) = instance- or static-procedure - with inputs [self; Ts] and outputs [T_1; …; T_n] (n ≠ 1) + with inputs [self; Ts] and outputs [T_1; …; T_n] (n ≥ 2) Γ ⊢ args_i ⇐ Ts_i (pairwise; self dropped) ───────────────────────────────────────── Γ ⊢ InstanceCall target callee args ⇒ MultiValuedExpr [T_1; …; T_n] ``` + A callee with *zero* outputs synthesizes `TVoid` (the n = 0 case). The two rules differ only in *output* arity. Target is synthesized; callee resolves to an instance or static procedure; arguments are checked pairwise against the callee's parameter types after dropping @@ -1673,7 +1676,7 @@ def Synth.instanceCall (exprMd : StmtExprMd) Γ ⊢ args_i ⇒ U_i (Op-Arith) Numeric U_i - T = ⨆ U_i (consistency LUB) + T = ⨆ U_i (consistency join) op ∈ {Neg, Add, Sub, Mul, Div, Mod, DivT, ModT} ───────────────────────────────────────────── Γ ⊢ PrimitiveOp op args ⇒ T @@ -1684,7 +1687,7 @@ def Synth.instanceCall (exprMd : StmtExprMd) ───────────────────────────────────────────── Γ ⊢ PrimitiveOp op args ⇒ TString ``` - `Numeric T` is the predicate "T unfolds to TInt / TReal / TFloat64 + `Numeric T` is the predicate "T unfolds to TInt / TReal / TFloat64 / TBv (or Unknown via the gradual escape hatch)" — not a single type, so it cannot serve as an `expected` for `Check.resolveStmtExpr`. `~` is symmetric consistency under the gradual relation, so equality has no @@ -1698,7 +1701,7 @@ def Synth.instanceCall (exprMd : StmtExprMd) Arithmetic follows the same shape as `Op-Eq` but for n operands: synthesize each operand's type, require it to be `Numeric`, and - fold the operand types under `join` (the LUB on the + fold the operand types under `join` (the join on the flat consistency lattice — `Unknown ⊔ T = T`, `T ⊔ T = T`, everything else inconsistent). The fold's result is the synthesized type. If any pair is inconsistent the rule emits a @@ -1728,12 +1731,12 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) match aTy.val with | .MultiValuedExpr _ => let diag := diagnosticFromSource a.source - "multi-output call cannot be used as a value here; it returns multiple values. Use a multi-target assignment instead" + "multi-output call cannot be used as a value here; it returns multiple values. Unpack it into separate variables first" modify fun s => { s with errors := s.errors.push diag } pure true | _ => pure false match op with - -- Arithmetic: synth each operand's type, then take the LUB under + -- Arithmetic: synth each operand's type, then take the join under -- the consistency relation. This is the same discipline as -- `Op-Eq`: operands must be pairwise consistent (with `Unknown` -- promoting to whichever side is more informative). Each operand @@ -1763,7 +1766,7 @@ def Synth.primitiveOp (exprMd : StmtExprMd) (expr : StmtExpr) let resultTy := argTypes.foldl (fun acc aTy => match acc with - | some lub => join ctx lub aTy + | some acc => join ctx acc aTy | none => none) (some unknownTy) match resultTy with @@ -2450,8 +2453,8 @@ def resolveParameter (param : Parameter) : ResolveM Parameter := do /-- Resolve a procedure body, checking its impl block (if any) against `expected`. The expected type comes from the procedure's declared - output: a single output `T` for functional procedures, `TVoid` - otherwise. Bodies without an impl block (`Abstract`, `External`) ignore + output: a single output `T` for single-output functional procedures, + `Unknown` otherwise. Bodies without an impl block (`Abstract`, `External`) ignore `expected`. -/ def resolveBody (body : Body) (expected : HighTypeMd) : ResolveM Body := do match body with diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 1c271fe1af..1806bde50b 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -340,7 +340,7 @@ The Index below links to each construct's subsection. - {ref "rules-self-reference"}[*Self reference*] — \[⇒\] This-Inside, \[⇒\] This-Outside - {ref "rules-untyped-forms"}[*Untyped forms*] — \[⇒\] Abstract / All - {ref "rules-contract-of"}[*ContractOf*] — \[⇒\] ContractOf-Bool, \[⇒\] ContractOf-Set, \[⇒\] ContractOf-Error -- {ref "rules-holes"}[*Holes*] — \[⇐\] Hole-Some, \[⇐\] Hole-None +- {ref "rules-holes"}[*Holes*] — \[⇐\] Hole-Some, \[⇐\] Hole-None, \[⇒\] Hole-Synth-None, \[⇒\] Hole-Synth-Some - {ref "rules-procedure"}[*Procedure*] — Procedure ### Subsumption @@ -414,11 +414,14 @@ whose operands are synthesized) — the synth counterpart fires instead. With an `else`, both branches are synthesized and their types must be mutually consistent ($`\sim`, the symmetric gradual relation); inconsistent branches $`[\text{emits “'if' branches have incompatible -types X and Y”}]` and synthesize $`\mathsf{Unknown}`. Without an -`else`, the missing branch cannot produce a value, so the `if` +types X and Y”}]` and synthesize $`\mathsf{Unknown}`. The result is the +join $`T_t \sqcup T_e` of the two branch types, so when one branch is a +hole ($`\mathsf{Unknown}`) the join promotes to the other branch's +concrete type, and the synthesized type is independent of branch order. +Without an `else`, the missing branch cannot produce a value, so the `if` synthesizes $`\mathsf{TVoid}`. -$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e \quad T_t \sim T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t} \quad \text{([⇒] If-Synth)}` +$$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e \quad T_t \sim T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t \sqcup T_e} \quad \text{([⇒] If-Synth)}` $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow \_}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-Synth-NoElse)}` @@ -634,13 +637,13 @@ tag := "rules-calls" $$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and output } [T'] \text{ (single output)} \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow T'} \quad \text{([⇒] Static-Call)}` -$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` +$$`\frac{\Gamma(\mathit{callee}) = \text{static-procedure with inputs } Ts \text{ and outputs } [T_1; \ldots; T_n],\; n \ge 2 \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise)}}{\Gamma \vdash \mathsf{StaticCall}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Static-Call-Multi)}` {docstring Strata.Laurel.Resolution.Synth.staticCall} $$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; Ts] \text{ and output } [T'] \text{ (single output)} \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow T'} \quad \text{([⇒] Instance-Call)}` -$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T_1; \ldots; T_n],\; n \ne 1 \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Instance-Call-Multi)}` +$$`\frac{\Gamma \vdash \mathit{target} \Rightarrow \_ \quad \Gamma(\mathit{callee}) = \text{instance- or static-procedure with inputs } [\mathit{self}; Ts] \text{ and outputs } [T_1; \ldots; T_n],\; n \ge 2 \quad \Gamma \vdash \mathit{args}_i \Leftarrow Ts_i \text{ (pairwise; self dropped)}}{\Gamma \vdash \mathsf{InstanceCall}\;\mathit{target}\;\mathit{callee}\;\mathit{args} \Rightarrow \mathsf{MultiValuedExpr}\;[T_1; \ldots; T_n]} \quad \text{([⇒] Instance-Call-Multi)}` The callee is resolved against either an instance procedure or a static procedure (the latter handles uniformly-dispatched call syntax @@ -651,7 +654,9 @@ single-vs-multi split. In both call families the single- and multi-output rules differ only in the *output* arity; argument checking is the same, and surplus arguments (beyond the declared parameters, or when the callee is unresolved) are checked against $`\mathsf{Unknown}` rather than flagged as an -arity error. +arity error. A zero-output ($`n = 0`) procedure call is the third case in the +arity split: it synthesizes $`\mathsf{TVoid}` rather than a +$`\mathsf{MultiValuedExpr}`. {docstring Strata.Laurel.Resolution.Synth.instanceCall} @@ -672,14 +677,14 @@ $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathsf{Numeric}\;U $$`\frac{\Gamma \vdash \mathit{lhs} \Rightarrow T_l \quad \Gamma \vdash \mathit{rhs} \Rightarrow T_r \quad T_l \sim T_r \quad \mathit{op} \in \{\mathsf{Eq}, \mathsf{Neq}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;[\mathit{lhs}; \mathit{rhs}] \Rightarrow \mathsf{TBool}} \quad \text{([⇒] Op-Eq)}` -$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathsf{Numeric}\;U_i \quad T = \bigsqcup_i U_i \text{ (consistency LUB)} \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` +$$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad \mathsf{Numeric}\;U_i \quad T = \bigsqcup_i U_i \text{ (consistency join)} \quad \mathit{op} \in \{\mathsf{Neg}, \mathsf{Add}, \mathsf{Sub}, \mathsf{Mul}, \mathsf{Div}, \mathsf{Mod}, \mathsf{DivT}, \mathsf{ModT}\}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow T} \quad \text{([⇒] Op-Arith)}` The arithmetic synth rule mirrors $`[⇒]\,\text{Op-Eq}` but generalised to $`n` operands. Each operand is synthesized and required to be $`\mathsf{Numeric}` (i.e. $`\mathsf{TInt}`, $`\mathsf{TReal}`, $`\mathsf{TFloat64}`, $`\mathsf{TBv}_w` (a bitvector of any width), or the gradual $`\mathsf{Unknown}`). The -result type is the *consistency LUB* $`\bigsqcup_i U_i` — a fold of +result type is the *consistency join* $`\bigsqcup_i U_i` — a fold of the operand types under {name Strata.Laurel.isConsistent}`isConsistent`'s flat lattice: $`\mathsf{Unknown} \sqcup T = T`, $`T \sqcup T = T`, and any other @@ -840,6 +845,15 @@ $$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Leftarrow T \quad \map {docstring Strata.Laurel.Resolution.Check.holeNone} +In synth position no expected type is available to push into the hole, so +an unannotated hole synthesizes the gradual $`\mathsf{Unknown}` while an +annotated hole synthesizes its annotation $`T_h` (this is what lets +` + 1` synthesize $`\mathsf{TInt}`). + +$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;\mathsf{none} \Rightarrow \mathsf{Unknown}} \quad \text{([⇒] Hole-Synth-None)}` + +$$`\frac{}{\Gamma \vdash \mathsf{Hole}\;d\;(\mathsf{some}\;T_h) \Rightarrow T_h} \quad \text{([⇒] Hole-Synth-Some)}` + ### Procedure %%% tag := "rules-procedure" From f118ed68caf0e9874d9b34521cbfe5721a9e128b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 12 Jun 2026 14:06:29 -0400 Subject: [PATCH 186/189] update tests --- .../Fundamentals/T23_ArithTyping.lean | 65 ++-- .../Examples/Objects/T9_IfBranchJoin.lean | 29 +- .../Languages/Laurel/ResolutionKindTests.lean | 2 +- .../Laurel/ResolutionTypeCheckTests.lean | 283 ++++++++---------- 4 files changed, 146 insertions(+), 233 deletions(-) diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean index f966d64337..639af83913 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T23_ArithTyping.lean @@ -3,41 +3,28 @@ SPDX-License-Identifier: Apache-2.0 OR MIT -/ -module -meta import all StrataTest.Util.TestDiagnostics -meta import all StrataTest.Languages.Laurel.TestExamples - -meta section +import StrataTest.Util.TestLaurel open StrataTest.Util - -namespace Strata -namespace Laurel +open Strata /-! Documents the current behaviour of the arithmetic typing rules. Two rules apply: - - [⇐] Op-Arith — the *check* path. When the surrounding context - provides an expected type (e.g. an annotated `var` declaration, - a procedure parameter, an assignment target), the arithmetic - expression is checked at that type, and the type is pushed into - every operand. The error surfaces as "expected '', got - ''" at the offending operand. - - - [⇒] Op-Arith — the *synth* path. Each operand is synthesized, - required to be `Numeric` (`TInt` / `TReal` / `TFloat64` / - `Unknown`), and the result type is the consistency LUB of the - operand types: `Unknown ⊔ T = T`, `T ⊔ T = T`, anything else - is rejected. Same shape as `Op-Eq` but extended to n operands - and returning the LUB rather than `TBool`. + - [⇐] Op-Arith — the *check* path: an expected type from context + is pushed into every operand. + - [⇒] Op-Arith — the *synth* path: operands are synthesized and + folded under `join` to the result type. - Homogeneous numeric operands type-check via either path. - Heterogeneous numeric operands (e.g. `int + real`) are rejected - by both paths. The gradual `Unknown` wildcard flows freely. -/ + Homogeneous numeric operands type-check via either path; + heterogeneous ones (e.g. `int + real`) are rejected by both. The + gradual `Unknown` wildcard flows freely. -/ -def arithTypingProgram := r" +#eval testLaurelResolution <| +#strata +program Laurel; procedure homogeneousInt() opaque { @@ -52,8 +39,7 @@ procedure homogeneousReal() assert x == 4.0 }; -// [⇐] Op-Arith path: the outer 'real' expectation is pushed into both -// operands. Operand '1' synthesizes 'int' and fails the check. +// [⇐] Op-Arith: check path (the `var: real` annotation drives it). procedure heterogeneousCheckPath() opaque { @@ -61,12 +47,7 @@ procedure heterogeneousCheckPath() // ^ error: expected 'real', got 'int' }; -// [⇒] Op-Arith path: '<' synthesizes its operands, so '1 + 2.0' is -// resolved in synth position. The arithmetic synth rule synthesizes -// the operands ('int' and 'real') and folds them under -// consistencyLub. 'int' and 'real' are mutually inconsistent, so -// the fold fails: a single error fires at the operator's source -// position listing the operand types. +// [⇒] Op-Arith: synth path (the `<` forces `1 + 2.0` into synth position). procedure heterogeneousSynthPath() opaque { @@ -85,22 +66,14 @@ procedure unaryNegHomogeneous() assert d == 0.0 - 1.5 }; -// Unknown promotes to its neighbour under consistencyLub: -// 'Unknown + TInt' folds to TInt. The hole '' synthesizes to -// 'Unknown' (the gradual escape hatch — holes are first-class in -// synth mode and don't error). The fold then yields TInt, and -// comparing to '2.0' (real) produces a 'cannot compare' diagnostic — -// proving the LUB returned TInt rather than Unknown (which would -// have passed the consistency check silently). +// The hole '' synthesizes to 'Unknown', and `join` promotes it to +// its neighbour: 'Unknown + TInt' folds to TInt. The error against +// '2.0' (real) is what proves the join returned TInt, not Unknown +// (Unknown would have compared silently). procedure unknownPromotesToNeighbour() opaque { assert ( + 1) == 2.0 // ^^^^^^^^^^^^^^^^ error: cannot compare 'int' with 'real' using '==' }; -" - -#guard_msgs(drop info, error) in -#eval testInputWithOffset "ArithTyping" arithTypingProgram 14 processLaurelFile - -end Laurel +#end diff --git a/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean b/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean index 30c7c104df..3091f90d3e 100644 --- a/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean +++ b/StrataTest/Languages/Laurel/Examples/Objects/T9_IfBranchJoin.lean @@ -3,29 +3,21 @@ SPDX-License-Identifier: Apache-2.0 OR MIT -/ -module -meta import all StrataTest.Util.TestDiagnostics -meta import all StrataTest.Languages.Laurel.TestExamples - -meta section +import StrataTest.Util.TestLaurel open StrataTest.Util - -namespace Strata -namespace Laurel +open Strata /- -When an `if/else` is checked against an expected type, the rule pushes -that type into both branches rather than going through synth + subsumption -at the boundary. So `var y: Left := if c then new Left else new Right`, -with `Left, Right <: Top`, errors at the *else-branch*: `new Right` is -checked against `Left`, and since `Right` is not a subtype of `Left`, a -"expected 'Left', got 'Right'" diagnostic fires there. The then-branch -(`new Left`) and the `var x: Top := …` assignment both pass. +An `if/else` in check position pushes the expected type into *both* branches +(rather than synth + subsume at the boundary), so a branch whose value doesn't +match the expected type errors at that branch. -/ -def program := r" +#eval testLaurelResolution <| +#strata +program Laurel; composite Top { } composite Left extends Top { } composite Right extends Top { } @@ -34,7 +26,4 @@ procedure test(c: bool) opaque { var y: Left := if c then new Left else new Right // ^^^^^^^^^ error: expected 'Left', got 'Right' }; -" - -#guard_msgs (drop info) in -#eval testInputWithOffset "IfBranchJoin" program 22 processLaurelFile +#end diff --git a/StrataTest/Languages/Laurel/ResolutionKindTests.lean b/StrataTest/Languages/Laurel/ResolutionKindTests.lean index e846003c30..99a8a9aa7f 100644 --- a/StrataTest/Languages/Laurel/ResolutionKindTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionKindTests.lean @@ -80,6 +80,6 @@ program Laurel; procedure multi(x: int) returns (a: int, b: int) opaque; procedure test() opaque { assert multi(1) == 1 -// ^^^^^^^^ error: Multi-output procedure 'multi' used in expression position +// ^^^^^^^^ error: multi-output call cannot be used as a value here }; #end diff --git a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean index 265d3656b1..dfb6e79568 100644 --- a/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean +++ b/StrataTest/Languages/Laurel/ResolutionTypeCheckTests.lean @@ -3,200 +3,163 @@ SPDX-License-Identifier: Apache-2.0 OR MIT -/ -module /- Tests that the resolution pass detects type checking errors — e.g. using an int where a bool is expected, or passing the wrong type to a procedure. -/ -meta import all StrataTest.Util.TestDiagnostics -meta import StrataDDM.Elab -meta import StrataDDM.BuiltinDialects.Init -meta import Strata.Languages.Laurel.Grammar.LaurelGrammar -meta import Strata.Languages.Laurel.Grammar.ConcreteToAbstractTreeTranslator -meta import Strata.Languages.Laurel.Resolution - -meta section +import StrataTest.Util.TestLaurel open StrataTest.Util open Strata -open StrataDDM (initDialect) -open StrataDDM.Elab (parseStrataProgramFromDialect) - -namespace Strata.Laurel - -/-- Run only parsing + resolution and return diagnostics (no SMT verification). -/ -private def processResolution (input : Lean.Parser.InputContext) : IO (Array Diagnostic) := do - let dialects := StrataDDM.Elab.LoadedDialects.ofDialects! #[initDialect, Laurel] - let strataProgram ← parseStrataProgramFromDialect dialects Laurel.name input - let uri := Strata.Uri.file input.fileName - match Laurel.TransM.run uri (Laurel.parseProgram strataProgram) with - | .error e => throw (IO.userError s!"Translation errors: {e}") - | .ok program => - let result := resolve program - let files := Map.insert Map.empty uri input.fileMap - return result.errors.toList.map (fun dm => dm.toDiagnostic files) |>.toArray /-! ## Non-boolean conditions -/ -def ifCondNotBool := r" +#eval testLaurelResolution <| +#strata +program Laurel; function foo(x: int): int { if x then 1 else 0 // ^ error: expected 'bool', got 'int' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "IfCondNotBool" ifCondNotBool 44 processResolution +#end -def assertCondNotBool := r" +#eval testLaurelResolution <| +#strata +program Laurel; procedure baz() opaque { var x: int := 42; assert x // ^ error: expected 'bool', got 'int' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "AssertCondNotBool" assertCondNotBool 54 processResolution +#end -def assumeCondNotBool := r" +#eval testLaurelResolution <| +#strata +program Laurel; procedure qux() opaque { var x: int := 42; assume x // ^ error: expected 'bool', got 'int' }; -" +#end -#guard_msgs (error, drop all) in -#eval testInputWithOffset "AssumeCondNotBool" assumeCondNotBool 64 processResolution - -def whileCondNotBool := r" +#eval testLaurelResolution <| +#strata +program Laurel; procedure wh() opaque { var x: int := 1; while (x) { } // ^ error: expected 'bool', got 'int' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "WhileCondNotBool" whileCondNotBool 74 processResolution +#end /-! ## Logical operator type checks -/ -def logicalAndNotBool := r" +#eval testLaurelResolution <| +#strata +program Laurel; function foo(x: int, y: bool): bool { x && y //^ error: expected 'bool', got 'int' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "LogicalAndNotBool" logicalAndNotBool 84 processResolution +#end /-! ## Numeric operator type checks -/ -def comparisonNotNumeric := r" +#eval testLaurelResolution <| +#strata +program Laurel; function cmp(x: string, y: int): bool { x < y //^ error: '<' expected a numeric type, got 'string' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "ComparisonNotNumeric" comparisonNotNumeric 94 processResolution +#end /-! ## Assignment type checks -/ -def assignTypeMismatch := r" +#eval testLaurelResolution <| +#strata +program Laurel; procedure foo() opaque { var x: int := true // ^^^^ error: expected 'int', got 'bool' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "AssignTypeMismatch" assignTypeMismatch 104 processResolution +#end /-! ## Function return type checks -/ -def returnTypeMismatch := r" +#eval testLaurelResolution <| +#strata +program Laurel; function foo(): int { true //^^^^ error: expected 'int', got 'bool' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "ReturnTypeMismatch" returnTypeMismatch 114 processResolution +#end /-! ## Call argument type checks -/ -def callArgTypeMismatch := r" +#eval testLaurelResolution <| +#strata +program Laurel; function bar(x: int): int { x }; function foo(): int { bar(true) // ^^^^ error: expected 'int', got 'bool' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "CallArgTypeMismatch" callArgTypeMismatch 124 processResolution +#end /-! ## Equality operator type checks -/ -def equalityTypeMismatch := r" +#eval testLaurelResolution <| +#strata +program Laurel; function cmp(x: int, y: string): bool { x == y //^^^^^^ error: cannot compare 'int' with 'string' using '==' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "EqualityTypeMismatch" equalityTypeMismatch 134 processResolution +#end /-! ## Multi-output procedures -/ -def multiOutputInExpr := r" +#eval testLaurelResolution <| +#strata +program Laurel; procedure multi(x: int) returns (a: int, b: int) opaque; procedure test() opaque { assert multi(1) == 1 // ^^^^^^^^ error: multi-output call cannot be used as a value here }; -" +#end -#guard_msgs (error, drop all) in -#eval testInputWithOffset "MultiOutputInExpr" multiOutputInExpr 146 processResolution - -/-- A multi-output call in operator-operand (value) position is rejected with a +/-! A multi-output call in operator-operand (value) position is rejected with a position-oriented diagnostic, even when both operands have the *same* `MultiValuedExpr` shape (so `isConsistent` would otherwise accept them). Without this guard `multi(1) == multi(2)` passes resolution and crashes a later pass as a `StrataBug`, since `MultiValuedExpr` has no Core lowering. The guard fires per offending operand (here both), short-circuiting the per-family equality check. -/ -def multiOutputBothSides := r" +#eval testLaurelResolution <| +#strata +program Laurel; procedure multi(x: int) returns (a: int, b: int) opaque; procedure test() opaque { assert multi(1) == multi(2) // ^^^^^^^^ error: multi-output call cannot be used as a value here // ^^^^^^^^ error: multi-output call cannot be used as a value here }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "MultiOutputBothSides" multiOutputBothSides 146 processResolution +#end -def assignTargetCountMismatch := r" +#eval testLaurelResolution <| +#strata +program Laurel; procedure multi() returns (a: int, b: int) opaque; procedure test() opaque { var x: int := multi() // ^^^^^^^ error: expected 'int', got '(int, int)' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "AssignTargetCountMismatch" assignTargetCountMismatch 156 processResolution +#end /-! ## UserDefined cross-type assignment @@ -204,17 +167,16 @@ Assignments between unrelated composites are rejected: `isSubtype` walks `extending` chains, so two composites with no common ancestor are not subtypes of each other. -/ -def userDefinedCrossType := r" +#eval testLaurelResolution <| +#strata +program Laurel; composite Dog { } composite Cat { } procedure test() opaque { var x: Dog := new Cat // ^^^^^^^ error: expected 'Dog', got 'Cat' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "UserDefinedCrossType" userDefinedCrossType 170 processResolution +#end /-! ## Field type is read from the field, not a shadowing local @@ -225,7 +187,9 @@ assignment of an `int` to a `bool` field is still rejected. (Regression guard for the scope-first lookup that previously returned the local's type and silently dropped the mismatch.) -/ -def fieldShadowedByLocal := r" +#eval testLaurelResolution <| +#strata +program Laurel; composite C { var flag: bool } @@ -235,10 +199,7 @@ procedure test() opaque { c#flag := flag // ^^^^ error: expected 'bool', got 'int' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "FieldShadowedByLocal" fieldShadowedByLocal 184 processResolution +#end /-! ## `if`/`block` in synth-only operand position @@ -249,23 +210,21 @@ and emitted a spurious "type cannot be synthesized" error. With both branches consistent, the `if` synthesizes the branch type and resolves cleanly (no diagnostics). -/ -def ifInSynthPositionOk := r" +#eval testLaurelResolution <| +#strata +program Laurel; function foo(c: bool): bool { (if c then 1 else 2) == 3 }; -" - -#guard_msgs (drop info) in -#eval testInputWithOffset "IfInSynthPositionOk" ifInSynthPositionOk 198 processResolution +#end -def blockInSynthPositionOk := r" +#eval testLaurelResolution <| +#strata +program Laurel; function foo(): bool { { 1 } == 1 }; -" - -#guard_msgs (drop info) in -#eval testInputWithOffset "BlockInSynthPositionOk" blockInSynthPositionOk 208 processResolution +#end /-! ## `if` with incompatible branch types (synth position) @@ -273,15 +232,14 @@ When an `if` is synthesized and its two branches have mutually inconsistent types, `Synth.ifThenElse` reports the mismatch at the `if` and synthesizes `Unknown` to suppress cascading errors. -/ -def ifBranchesIncompatible := r" +#eval testLaurelResolution <| +#strata +program Laurel; function foo(c: bool): bool { (if c then 1 else true) == 3 // ^^^^^^^^^^^^^^^^^^^^^ error: 'if' branches have incompatible types 'int' and 'bool' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "IfBranchesIncompatible" ifBranchesIncompatible 218 processResolution +#end /-! ## `if` in operand position synthesizes a *symmetric* branch join @@ -296,23 +254,23 @@ span — locking in symmetry. (Before the join, the then-first order returned `Unknown` and was silently accepted, while only the else-first order errored.) -/ -def ifJoinSymmetricThenHole : String := - "\nfunction foo(c: bool): bool {\n" ++ - " (if c then else \"x\") < 1\n" ++ - "// ^^^^^^^^^^^^^^^^^^^^^^ error: '<' expected a numeric type, got 'string'\n" ++ - "};\n" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "IfJoinSymmetricThenHole" ifJoinSymmetricThenHole 246 processResolution - -def ifJoinSymmetricElseHole : String := - "\nfunction foo(c: bool): bool {\n" ++ - " (if c then \"x\" else ) < 1\n" ++ - "// ^^^^^^^^^^^^^^^^^^^^^^ error: '<' expected a numeric type, got 'string'\n" ++ - "};\n" +#eval testLaurelResolution <| +#strata +program Laurel; +function foo(c: bool): bool { + (if c then else "x") < 1 +// ^^^^^^^^^^^^^^^^^^^^^^ error: '<' expected a numeric type, got 'string' +}; +#end -#guard_msgs (error, drop all) in -#eval testInputWithOffset "IfJoinSymmetricElseHole" ifJoinSymmetricElseHole 256 processResolution +#eval testLaurelResolution <| +#strata +program Laurel; +function foo(c: bool): bool { + (if c then "x" else ) < 1 +// ^^^^^^^^^^^^^^^^^^^^^^ error: '<' expected a numeric type, got 'string' +}; +#end /-! ## `if` branch join recovers precision from a hole @@ -321,13 +279,13 @@ type, the join recovers the concrete type (`Unknown ⊔ int = int`) rather than collapsing to `Unknown`. So `if c then else 5` synthesizes a usable `int` and resolves cleanly where an `int` is expected — no diagnostics. -/ -def ifJoinRecoversInt : String := - "\nfunction bar(c: bool): int {\n" ++ - " if c then else 5\n" ++ - "};\n" - -#guard_msgs (drop info) in -#eval testInputWithOffset "IfJoinRecoversInt" ifJoinRecoversInt 270 processResolution +#eval testLaurelResolution <| +#strata +program Laurel; +function bar(c: bool): int { + if c then else 5 +}; +#end /-! ## Void procedure call in value position @@ -337,16 +295,15 @@ expected now synthesizes `TVoid` rather than the internal-only empty instead of the placeholder `'()'` that an empty tuple rendered as. (Regression guard for `getCallInfo` mapping an empty output list to `TVoid`.) -/ -def voidCallInValuePosition := r" +#eval testLaurelResolution <| +#strata +program Laurel; procedure act() opaque; procedure test() opaque { assert act() == 1 // ^^^^^^^^^^ error: cannot compare 'void' with 'int' using '==' }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "VoidCallInValuePosition" voidCallInValuePosition 234 processResolution +#end /-! ## Bitvectors are numeric @@ -356,14 +313,13 @@ accepts `TBv`, so a comparison of two bitvector parameters resolves cleanly with no diagnostics. (Regression guard for `isNumeric` previously rejecting `TBv` and emitting a spurious "expected a numeric type" error.) -/ -def bitvectorComparisonOk := r" +#eval testLaurelResolution <| +#strata +program Laurel; function cmp(x: bv 32, y: bv 32): bool { x < y }; -" - -#guard_msgs (drop info) in -#eval testInputWithOffset "BitvectorComparisonOk" bitvectorComparisonOk 250 processResolution +#end /-! ## Over-arity calls are rejected @@ -372,16 +328,15 @@ an arity diagnostic. The check fires only when the callee genuinely resolves to a procedure with a known parameter count (`procArity`). Under-arity (too few arguments) is deliberately not flagged. -/ -def overArityCall := r" +#eval testLaurelResolution <| +#strata +program Laurel; function foo(x: int): int { x }; function bar(): int { foo(1, 2) //^^^^^^^^^ error: call to 'foo' expects 1 argument(s) but 2 were provided }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "OverArityCall" overArityCall 269 processResolution +#end /-! ## A too-many-args call to an *unresolved* name does not double-report @@ -392,15 +347,14 @@ artifact of the name not being found, not a zero-arity procedure), so the over-arity check is skipped. (Regression guard for the no-duplicate-diagnostic behavior.) -/ -def overArityUnresolvedCallNoDouble := r" +#eval testLaurelResolution <| +#strata +program Laurel; function bar(): int { nope(1, 2) //^^^^^^^^^^ error: 'nope' is not defined }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "OverArityUnresolvedCallNoDouble" overArityUnresolvedCallNoDouble 289 processResolution +#end /-! ## An unresolved declared type collapses to `Unknown` (no cascade) @@ -414,15 +368,12 @@ three diagnostics — the name-resolution error plus the `0`-vs-`UndefinedType` initializer mismatch and the `x`-vs-`int` use mismatch; it must now produce exactly one.) -/ -def unresolvedDeclaredTypeNoCascade := r" +#eval testLaurelResolution <| +#strata +program Laurel; procedure useUndef() opaque { var x: UndefinedType := 0; // ^^^^^^^^^^^^^ error: 'UndefinedType' is not defined var y: int := x + 2 }; -" - -#guard_msgs (error, drop all) in -#eval testInputWithOffset "UnresolvedDeclaredTypeNoCascade" unresolvedDeclaredTypeNoCascade 308 processResolution - -end Laurel +#end From 175446ffc9dab3d9722742583995d60d22c42974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Fri, 12 Jun 2026 15:04:05 -0400 Subject: [PATCH 187/189] fix duplicate error reported --- .../Laurel/Examples/Fundamentals/T22_ArityMismatch.lean | 5 ----- 1 file changed, 5 deletions(-) diff --git a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean index fd81e1c602..03effa44cb 100644 --- a/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean +++ b/StrataTest/Languages/Laurel/Examples/Fundamentals/T22_ArityMismatch.lean @@ -11,11 +11,6 @@ open Strata /-! ## Function called with too many arguments -/ -/-- -error: <#strata>(436-457) ❌ Type checking error. -Impossible to unify int with (arrow int $__ty35). --/ -#guard_msgs in #eval testLaurel <| #strata program Laurel; From bff10cf9ff369692fc007a05da562ff259093ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Tue, 16 Jun 2026 14:17:06 -0400 Subject: [PATCH 188/189] better formatting for documentation --- Strata/Languages/Laurel/Resolution.lean | 47 +++-- docs/verso/LaurelDoc.lean | 235 +++++++++++++++--------- 2 files changed, 175 insertions(+), 107 deletions(-) diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 8363126d60..7f70f08b60 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -1020,19 +1020,19 @@ def Check.return (exprMd : StmtExprMd) rw [Option.mem_def.mp ‹_ ∈ val›, Option.some.sizeOf_spec] at hsz omega -/-- (Skip) +/-- (Empty-Block) ``` ───────────────────────────────── Γ ⊢ Block [] label ⇒ TVoid ``` - The empty block has a fixed type `TVoid` — written `skip : TVoid` - in the source-language presentation. This is the only block-level - rule that synthesizes: non-empty blocks are typed structurally by - `Resolution.Check.block` (last statement carries the value, non-last - positions `⇐ TVoid` or Discard-Call) and never recurse into an empty - tail, so they never bottom out here. When an empty block appears in - check position, `Resolution.Check.resolveStmtExpr`'s wildcard arm - synth-then-subsumes via the standard \[⇐\] Sub fallback. -/ + The empty block has a fixed type `TVoid`. This is the only + block-level rule that synthesizes unconditionally: non-empty blocks + are typed structurally by `Resolution.Check.block` (last statement + carries the value, non-last positions `⇐ TVoid` or Discard-Call), + which always splits off a last statement and so never reaches an + empty list. When an empty block appears in check position, + `Resolution.Check.resolveStmtExpr`'s wildcard arm synth-then-subsumes + via the standard \[⇐\] Sub fallback. -/ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := { val := .TVoid, source := source } @@ -1074,8 +1074,8 @@ def Check.statement (s : StmtExprMd) : ResolveM StmtExprMd := do A block's value is the value of its **last** statement; every earlier statement is run only for its effect. The rule splits the - statement list into `init` (all but the last) and `last` and is one - recursion over that structure: + statement list into `[s₁; … ; sₙ]` (all but the last) and `last`, + handling each part as follows: * **non-last — `Γ ⊢ s ⇐ TVoid`.** A non-last statement is a pure effect, so it is checked at `TVoid`. This admits every statement @@ -1097,12 +1097,11 @@ def Check.statement (s : StmtExprMd) : ResolveM StmtExprMd := do position discards its result, so `{ …; foo() }` type-checks as a statement even when `foo` returns a value). - There is deliberately no synthesis rule for non-empty blocks: a - block is statement-shaped and always occurs in check position - (procedure bodies, branches, loop bodies, assignment RHS, call - arguments all supply an expected type). A block in a synth-only - operand position has no contextual type and is reported by the - `Synth.resolveStmtExpr` wildcard. + A block most often occurs in check position (procedure bodies, + branches, loop bodies, assignment RHS, and call arguments all supply + an expected type). When one appears in synth-only operand position + with no contextual type, `Resolution.Synth.block` handles it with the + same structure, synthesizing the last statement instead. The block opens a fresh nested scope (declarations made inside don't leak), and emits a "dead code after `exit`/`return`" @@ -1194,10 +1193,10 @@ def Check.block (exprMd : StmtExprMd) propagates through nested `Block` / `IfThenElse` / `Hole` / `Quantifier` constructs that have their own check rules. - Without an `else`, the implicit branch is `skip : TVoid`, so the - rule degenerates to require `TVoid <: T` — the standard \[⇐\] Sub - boundary check that `Resolution.Synth.emptyBlock` composes with - for an empty block. -/ + Without an `else`, the implicit branch is an empty block of type + `TVoid`, so the rule degenerates to require `TVoid <: T` — the + standard \[⇐\] Sub boundary check that `Resolution.Synth.emptyBlock` + composes with for an empty block. -/ def Check.ifThenElse (exprMd : StmtExprMd) (cond thenBr : StmtExprMd) (elseBr : Option StmtExprMd) (expected : HighTypeMd) (source : Option FileRange) @@ -1281,9 +1280,9 @@ def Synth.ifThenElse (exprMd : StmtExprMd) /-- (Block-Synth) ``` - Γ ⊢ s ⋄ (for each non-last s) Γ ⊢ last ⇒ T (Block-Synth) + Γ ⊢ sᵢ ⋄ (1 ≤ i ≤ n) Γ ⊢ last ⇒ T (Block-Synth) ────────────────────────────────────────────────────────────── - Γ ⊢ Block (init ++ [last]) label ⇒ T + Γ ⊢ Block [s₁; … ; sₙ; last] label ⇒ T ``` Synth-mode rule for a non-empty block used where no expected type is available (e.g. `{ x := 1; x } == y`). Mirrors `Check.block`'s @@ -1293,7 +1292,7 @@ def Synth.ifThenElse (exprMd : StmtExprMd) *synthesizes* the last statement instead of checking it against an expected type, and returns that synthesized type as the block's value type. The empty block is handled by `Synth.emptyBlock` at the - dispatch site; this rule only runs on `head :: tail`. -/ + dispatch site; this rule only runs on a non-empty block. -/ def Synth.block (exprMd : StmtExprMd) (stmts : List StmtExprMd) (label : Option String) (source : Option FileRange) diff --git a/docs/verso/LaurelDoc.lean b/docs/verso/LaurelDoc.lean index 1806bde50b..891eef33a7 100644 --- a/docs/verso/LaurelDoc.lean +++ b/docs/verso/LaurelDoc.lean @@ -77,6 +77,71 @@ def laurelPipelineDependencyGraph : Verso.Doc.Elab.BlockCommandOf Unit := fun () let blocks ← ast.blocks.mapM (Markdown.blockFromMarkdown · (handleHeaders := Markdown.strongEmphHeaders)) `(Verso.Doc.Block.concat #[$blocks,*]) +-- A set-apart *example* box. Renders its contents inside a tinted, bordered +-- panel with an "Example" header, so concrete examples stand out from the +-- surrounding explanatory prose. Authored via the `:::example` directive below. +block_extension Block.«example» (title : Option String) where + data := Lean.toJson (title : Option String) + traverse _ _ _ := pure none + toHtml := some fun _goI goB _id data contents => open Verso.Output.Html in do + let title : Option String := + match Lean.fromJson? (α := Option String) data with + | .ok t => t + | .error _ => none + let label := title.getD "Example" + pure {{ +
+
{{ label }}
+
{{← contents.mapM goB}}
+
+ }} + extraCss := [ +r#" +.laurel-example { + border: 1px solid #98B2C0; + border-left: 4px solid #4A90E2; + border-radius: 0.4rem; + background: #F5F9FF; + margin-top: var(--verso--box-vertical-margin); + margin-bottom: var(--verso--box-vertical-margin); + overflow: hidden; +} +.laurel-example-header { + font-family: var(--verso-structure-font-family); + font-style: italic; + font-size: 0.875rem; + font-weight: bold; + color: #2A5680; + background: #E4EEF8; + padding: 0.3rem var(--verso--box-padding); +} +.laurel-example-body { + padding: 0.2rem var(--verso--box-padding); +} +"# + ] + toTeX := some fun _goI goB _id _data contents => open Verso.Output.TeX in open Verso.Doc.TeX in do + pure <| .seq <| ← contents.mapM fun b => do + pure <| .seq #[← goB b, .raw "\n"] + +/-- Configuration for the `:::example` directive: an optional title shown in the + box header (defaults to "Example"). -/ +structure LaurelExampleConfig where + title : Option String := none + +open Verso.ArgParse in +instance : Verso.ArgParse.FromArgs LaurelExampleConfig Verso.Doc.Elab.DocElabM where + fromArgs := LaurelExampleConfig.mk <$> + ((positional' `title <&> some) <|> pure none) + +/-- Sets its contents apart in a styled *example* box (see `Block.example`). + Optionally takes a title: `:::example "Arithmetic join"` … `:::`. -/ +@[directive] +def «example» : Verso.Doc.Elab.DirectiveExpanderOf LaurelExampleConfig + | {title}, stxs => do + let args ← stxs.mapM Verso.Doc.Elab.elabBlock + ``(Verso.Doc.Block.other (Block.«example» $(Lean.quote title)) #[ $[ $args ],* ]) + #doc (Manual) "The Laurel Language" => %%% shortTitle := "Laurel" @@ -91,16 +156,16 @@ Laurel tries to include any features that are common to those three languages. This manual follows the language from the ground up: it first describes Laurel's types, then its unified expression/statement model, then procedures and whole -programs. It then turns to type checking — a per-construct reference for the -bidirectional rules — and finally to the translation pipeline that lowers a -checked Laurel program to Strata Core. +programs. It then turns to type checking, done in a bidirectional way, and finally to the translation +pipeline that lowers a checked Laurel program to Strata Core. ## Features In the feature lists below, items marked *(WIP)* are designed or planned but not yet fully implemented; everything else is available today. -Laurel enables doing various forms of verification: +Laurel enables doing various forms of analyses : +- Type checking - Testing - (WIP) Property-based testing - (WIP) Bounded symbolic execution @@ -269,12 +334,6 @@ and {name Strata.Laurel.isConsistentSubtype}`isConsistentSubtype`: {docstring Strata.Laurel.isConsistentSubtype} -The type of a block is the type of its last statement; non-last -statements can be of any type. The block rule -({ref "rules-control-flow"}[Block]) is what supplies the value type -for a block: it routes the surrounding $`T` to the last statement -and ignores the value of every non-last statement. - ## Typing rules Each construct is given as a derivation. `Γ` is the current lexical scope (see @@ -306,8 +365,9 @@ The following notation recurs throughout the rules: $`\mathsf{TBv}_w` (a bitvector of any width $`w`), with $`\mathsf{Unknown}` admitted as the gradual escape hatch. - $`\dashv \Gamma'` — a rule's *output scope*: the judgment threads $`\Gamma` in - and produces $`\Gamma'` out. Only \[⇐\] Var-Declare and \[⇐\] Block-Cons use - this to extend the scope. + and produces $`\Gamma'` out. Only \[⇐\] Var-Declare extends the scope; the + block rules thread it statement-to-statement (the $`\Gamma_{i-1} \to + \Gamma_i` chain in \[⇐\] Block / \[⇒\] Block-Synth). - $`\rightsquigarrow \text{error: …}` — the rule emits an error and aborts; no type is produced. - $`[\text{emits …}]` — the rule produces its type but also emits a diagnostic. @@ -322,8 +382,8 @@ The Index below links to each construct's subsection. - {ref "rules-variables"}[*Variables*] — \[⇒\] Var-Local, \[⇒\] Var-Field, \[⇐\] Var-Declare - {ref "rules-control-flow"}[*Control flow*] — \[⇐\] If, \[⇐\] If-NoElse, \[⇒\] If-Synth, \[⇒\] If-Synth-NoElse; - \[⇐\] Block-Singleton, \[⇐\] Block-Cons, - \[⇐\] Discard-Call-Cons, \[⇐\] Discard-Call-Last, \[⇒\] Skip, \[⇒\] Block-Synth; \[⇐\] Exit; + \[⇐\] Block, \[⇒\] Block-Synth, \[⋄\] Stmt, \[⋄\] Discard-Call, + \[⇒\] Empty-Block; \[⇐\] Exit; \[⇐\] Return-None-Void, \[⇐\] Return-None-Single, \[⇐\] Return-None-Multi, \[⇐\] Return-Some, \[⇐\] Return-Void-Error, \[⇐\] Return-Multi-Error; \[⇐\] While @@ -421,92 +481,98 @@ concrete type, and the synthesized type is independent of branch order. Without an `else`, the missing branch cannot produce a value, so the `if` synthesizes $`\mathsf{TVoid}`. +:::example "`if` in operand position" +- `(if c then 1 else 2) == y` — both branches $`\mathsf{TInt}`, so the `if` synthesizes $`\mathsf{TInt}` +- `if c then 1 else ` — the hole branch promotes; synthesizes $`\mathsf{TInt}` +- `if c then 1 else "x"` — incompatible branches: *'if' branches have incompatible types 'int' and 'string'*, synthesizes $`\mathsf{Unknown}` +- `if c then 1` (no `else`) — synthesizes $`\mathsf{TVoid}` +::: + $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow T_t \quad \Gamma \vdash \mathit{elseBr} \Rightarrow T_e \quad T_t \sim T_e}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;(\mathsf{some}\;\mathit{elseBr}) \Rightarrow T_t \sqcup T_e} \quad \text{([⇒] If-Synth)}` $$`\frac{\Gamma \vdash \mathit{cond} \Leftarrow \mathsf{TBool} \quad \Gamma \vdash \mathit{thenBr} \Rightarrow \_}{\Gamma \vdash \mathsf{IfThenElse}\;\mathit{cond}\;\mathit{thenBr}\;\mathsf{none} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] If-Synth-NoElse)}` {docstring Strata.Laurel.Resolution.Synth.ifThenElse} -A non-empty block is typed by structural recursion on the statement -list: the last statement inherits the surrounding expected type, and -each non-last statement is checked at $`\mathsf{TVoid}`, *except* -calls — which are synthesized and have their result type dropped. The -same Discard-Call carve-out also fires for the *last* statement when -the block itself is in statement position (i.e. $`T = \mathsf{TVoid}`), -so $`\{\ldots;\,\mathit{foo}()\}` is accepted as a statement even when -`foo` returns a non-void type. The Discard-Call carve-outs are the only -block-level rules that aren't already consequences of the rules for -individual statements. +A non-empty block is typed by splitting its statement list into the +*last* statement and the statements before it. The last statement +carries the block's value and inherits the surrounding expected type; +each earlier statement runs only for its effect — written +$`\Gamma \vdash s\;\diamond` (*effect position*: the statement's value +is discarded). The check and synth rules share this shape, differing +only in how the last statement is treated: -$$`\frac{\Gamma \vdash s \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Singleton)}` +$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \;\diamond \;\dashv\; \Gamma_i \;\;(1 \le i \le n) \quad \Gamma_n \vdash \mathit{last} \Leftarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n; \mathit{last}]\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block)}` -$$`\frac{\Gamma \vdash s \Leftarrow \mathsf{TVoid} \quad \dashv \quad \Gamma' \quad \Gamma' \vdash \mathsf{Block}\;\mathit{rest}\;\mathit{label} \Leftarrow T \quad \mathit{rest} \ne []}{\Gamma \vdash \mathsf{Block}\;(s :: \mathit{rest})\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Block-Cons)}` +$$`\frac{\Gamma_0 = \Gamma \quad \Gamma_{i-1} \vdash s_i \;\diamond \;\dashv\; \Gamma_i \;\;(1 \le i \le n) \quad \Gamma_n \vdash \mathit{last} \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;[s_1; \ldots; s_n; \mathit{last}]\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block-Synth)}` -$$`\frac{s = \mathsf{StaticCall}\;\ldots \lor s = \mathsf{InstanceCall}\;\ldots \quad \Gamma \vdash s \Rightarrow \_ \quad \Gamma \vdash \mathsf{Block}\;\mathit{rest}\;\mathit{label} \Leftarrow T \quad \mathit{rest} \ne []}{\Gamma \vdash \mathsf{Block}\;(s :: \mathit{rest})\;\mathit{label} \Leftarrow T} \quad \text{([⇐] Discard-Call-Cons)}` +\[⇐\] Block fires whenever an expected type $`T` is supplied (procedure +bodies, branches, loop bodies, assignment RHS, call arguments); +\[⇒\] Block-Synth fires in operand position, where no expected type is +available (e.g. $`\{\,x := 1;\; x\,\} == y`), synthesizing the last +statement's type as the block's value type. -$$`\frac{s = \mathsf{StaticCall}\;\ldots \lor s = \mathsf{InstanceCall}\;\ldots \quad \Gamma \vdash s \Rightarrow \_}{\Gamma \vdash \mathsf{Block}\;[s]\;\mathit{label} \Leftarrow \mathsf{TVoid}} \quad \text{([⇐] Discard-Call-Last)}` +When the block itself sits in statement position ($`T = \mathsf{TVoid}`) +the last statement is in effect position too: its premise becomes +$`\mathit{last}\;\diamond` rather than $`\mathit{last} \Leftarrow +\mathsf{TVoid}`, so a trailing call discards its result and +$`\{\ldots;\,\mathit{foo}()\}` type-checks as a statement even when +`foo` returns a non-void type. -\[⇐\] Block-Cons resolves $`s` under the incoming $`\Gamma` and -recurses on the tail under the possibly-extended scope $`\Gamma'`. In -practice only `Var (.Declare …)` actually extends the scope; every -other construct leaves it unchanged. The block opens a fresh nested -scope, so declarations made inside don't leak out — once the block -ends, the surrounding $`\Gamma` is restored. The block also emits a -`"dead code after ''"` diagnostic when an `Exit` or -`Return` is followed by additional statements in the same block. +The effect-position judgment $`\Gamma \vdash s\;\diamond` admits a +statement in one of two ways: + +$$`\frac{\Gamma \vdash s \Leftarrow \mathsf{TVoid} \;\dashv\; \Gamma'}{\Gamma \vdash s \;\diamond \;\dashv\; \Gamma'} \quad \text{([⋄] Stmt)}` + +$$`\frac{s = \mathsf{StaticCall}\;\ldots \lor s = \mathsf{InstanceCall}\;\ldots \quad \Gamma \vdash s \Rightarrow \_}{\Gamma \vdash s \;\diamond \;\dashv\; \Gamma} \quad \text{([⋄] Discard-Call)}` -Statement forms (`Var-Declare`, `Assign`, `Assert`, `Assume`, -`While`, `Exit`, `Return`, `IfThenElse`) all check against -$`\mathsf{TVoid}`. They fit there for one of two reasons: most yield -no value (so the unit type $`\mathsf{TVoid}` is exactly right), and -the terminators `Exit`/`Return` accept *any* expected type (their +\[⋄\] Stmt admits every statement form (`Var-Declare`, `Assign`, +`Assert`, `Assume`, `While`, `Exit`, `Return`, `IfThenElse`): each +either yields no value — so $`\mathsf{TVoid}` is exactly right — or, for +the terminators `Exit`/`Return`, accepts *any* expected type (their rules leave the value type free — see \[⇐\] Exit and the Return rules -below — because control leaves before any value is needed). Bare -expressions like `5;` fail via \[⇐\] Sub: the synthesized type is not -consistent with $`\mathsf{TVoid}`. The two Discard-Call rules are what -allow the standard `f(x);` idiom for a non-void-returning `f` — -without them, $`s \Leftarrow \mathsf{TVoid}` would force every call to -have a $`\mathsf{TVoid}`-compatible result type. - -Pushing $`T` into the last statement (rather than synthesizing the -whole block and applying \[⇐\] Sub at the boundary) means a type -mismatch is reported at the offending subexpression's source -location, and the expectation continues to propagate through nested -`Block` / `IfThenElse` / `Hole` / `Quantifier` constructs that have -their own check rules. - -$$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Skip)}` +below — because control leaves before any value is needed). A stranded +value such as `5;` fails it, since $`\mathsf{TInt}` is not consistent +with $`\mathsf{TVoid}`. \[⋄\] Discard-Call is the one carve-out for a +value-producing form: a call is synthesized and its result dropped, so +the standard `f(x);` idiom is allowed even when `f` returns a value. +These two are the only block-level cases that aren't already +consequences of the rules for the individual statement forms. + +Only `Var (.Declare …)` actually extends the scope $`\Gamma_i`; every +other statement leaves it unchanged. The block opens a fresh nested +scope, so declarations made inside don't leak out — once the block ends, +the surrounding $`\Gamma` is restored. It also emits a +`"dead code after ''"` diagnostic when an `Exit` or +`Return` is followed by further statements in the same block. + +Pushing $`T` into the last statement (rather than synthesizing the whole +block and applying \[⇐\] Sub at the boundary) means a type mismatch is +reported at the offending subexpression's source location, and the +expectation keeps propagating through nested `Block` / `IfThenElse` / +`Hole` / `Quantifier` constructs that have their own check rules. + +$$`\frac{}{\Gamma \vdash \mathsf{Block}\;[]\;\mathit{label} \Rightarrow \mathsf{TVoid}} \quad \text{([⇒] Empty-Block)}` The empty block has a fixed type and is the only block-level rule that -synthesizes — written $`\mathsf{skip} : \mathsf{TVoid}` in the -source-language presentation. The recursive Block-Cons / Block-Singleton -rules above never bottom out into an empty tail, so the empty case is -reached only when the block is empty at the dispatch site. When an -empty block appears in check position with `expected ≠ TVoid`, the -standard \[⇐\] Sub rule fires at the boundary +synthesizes unconditionally. \[⇐\] Block and \[⇒\] Block-Synth always +split off a *last* statement, so they never reach an empty list; the +empty case is hit only when the block is literally empty at the dispatch +site. When an empty block appears in check position with +`expected ≠ TVoid`, the standard \[⇐\] Sub rule fires at the boundary (`Check.resolveStmtExpr`'s subsumption-fallback wildcard arm, requiring $`\mathsf{TVoid} <: \mathit{expected}`). {docstring Strata.Laurel.Resolution.Synth.emptyBlock} -A *non-empty* block also synthesizes when used in operand position -(e.g. $`\{\,x := 1;\; x\,\} == y`). It mirrors \[⇐\] Block-Cons / -\[⇐\] Block-Singleton — fresh scope, optional label, non-last -statements checked in effect position ($`\diamond`), dead-code -diagnostics — but *synthesizes* the last statement instead of checking -it, and returns that type as the block's value type. - -$$`\frac{\Gamma \vdash s \;\diamond \;(\text{for each non-last } s) \quad \Gamma \vdash \mathit{last} \Rightarrow T}{\Gamma \vdash \mathsf{Block}\;(\mathit{init} \mathbin{+\!+} [\mathit{last}])\;\mathit{label} \Rightarrow T} \quad \text{([⇒] Block-Synth)}` - {docstring Strata.Laurel.Resolution.Synth.block} {docstring Strata.Laurel.Resolution.Check.block} -The Discard-Call carve-outs and the "checks against $`\mathsf{TVoid}`" -behaviour for non-last (and discarded-last) statements are factored out -into {name Strata.Laurel.Resolution.Check.statement}`Check.statement`, -the single definition of what counts as a statement in effect position -($`\Gamma \vdash s\;\diamond`): +The $`\Gamma \vdash s\;\diamond` judgment — the \[⋄\] Stmt / \[⋄\] +Discard-Call carve-out above — is the single definition of what counts +as a statement in effect position, factored out into +{name Strata.Laurel.Resolution.Check.statement}`Check.statement`: {docstring Strata.Laurel.Resolution.Check.statement} @@ -688,13 +754,16 @@ result type is the *consistency join* $`\bigsqcup_i U_i` — a fold of the operand types under {name Strata.Laurel.isConsistent}`isConsistent`'s flat lattice: $`\mathsf{Unknown} \sqcup T = T`, $`T \sqcup T = T`, and any other -combination is rejected. So `1 + 2` synthesizes $`\mathsf{TInt}`, -`1.5 + 2.5` synthesizes $`\mathsf{TReal}`, ` + 1` synthesizes -$`\mathsf{TInt}` (the $`\mathsf{Unknown}` operand promotes to its -neighbour), ` + ` synthesizes $`\mathsf{Unknown}`, and -`1 + 2.0` is rejected with a "cannot apply '+' to operands of types -'int', 'real'" diagnostic. The fold runs via `join`, a -pure function, so the search has no diagnostic side-effects. +combination is rejected. The fold runs via `join`, a pure function, so +the search has no diagnostic side-effects. + +:::example "Arithmetic operand join" +- `1 + 2` synthesizes $`\mathsf{TInt}` +- `1.5 + 2.5` synthesizes $`\mathsf{TReal}` +- ` + 1` synthesizes $`\mathsf{TInt}` — the $`\mathsf{Unknown}` operand promotes to its neighbour +- ` + ` synthesizes $`\mathsf{Unknown}` +- `1 + 2.0` is rejected: *cannot apply '+' to operands of types 'int', 'real'* +::: $$`\frac{\Gamma \vdash \mathit{args}_i \Rightarrow U_i \quad U_i <: \mathsf{TString} \quad \mathit{op} = \mathsf{StrConcat}}{\Gamma \vdash \mathsf{PrimitiveOp}\;\mathit{op}\;\mathit{args} \Rightarrow \mathsf{TString}} \quad \text{([⇒] Op-Concat)}` From 265f1d14496cfeeff8669989b1d1216000b32c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEESCO?= Date: Wed, 17 Jun 2026 16:10:20 -0400 Subject: [PATCH 189/189] typecheck ++/-- --- Strata/Languages/Laurel/LaurelAST.lean | 3 + Strata/Languages/Laurel/Resolution.lean | 100 ++++++++++++++++++++---- 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/Strata/Languages/Laurel/LaurelAST.lean b/Strata/Languages/Laurel/LaurelAST.lean index 45734548d2..bbaec0fdfa 100644 --- a/Strata/Languages/Laurel/LaurelAST.lean +++ b/Strata/Languages/Laurel/LaurelAST.lean @@ -402,8 +402,11 @@ def StmtExpr.constrName : StmtExpr → String | .LiteralBool .. => "boolean literal" | .LiteralString .. => "string literal" | .LiteralDecimal .. => "decimal literal" + | .LiteralBv .. => "bitvector literal" | .Var .. => "variable" | .Assign .. => ":=" + | .IncrDecr _ .Incr .. => "++" + | .IncrDecr _ .Decr .. => "--" | .PureFieldUpdate .. => "field update" | .StaticCall .. => "call" | .PrimitiveOp op .. => toString op diff --git a/Strata/Languages/Laurel/Resolution.lean b/Strata/Languages/Laurel/Resolution.lean index 79e08be001..7f81346fd1 100644 --- a/Strata/Languages/Laurel/Resolution.lean +++ b/Strata/Languages/Laurel/Resolution.lean @@ -672,6 +672,7 @@ def Synth.resolveStmtExpr (exprMd : StmtExprMd) : ResolveM (StmtExprMd × HighTy | .LiteralBool v => pure (Synth.litBool v source) | .LiteralString v => pure (Synth.litString v source) | .LiteralDecimal v => pure (Synth.litDecimal v source) + | .LiteralBv v width => pure (Synth.litBv v width source) | .Var (.Local ref) => Synth.varLocal ref source | .IncrDecr mode op target => Synth.incrDecr exprMd mode op target source (by rw [h_node]) @@ -862,6 +863,11 @@ def Synth.litString (v : String) (source : Option FileRange) : StmtExpr × HighT def Synth.litDecimal (v : StrataDDM.Decimal) (source : Option FileRange) : StmtExpr × HighTypeMd := (.LiteralDecimal v, { val := .TReal, source := source }) +/-- `Γ ⊢ LiteralBv v (width := n) ⇒ TBv n` — a bitvector literal's type is + fixed by its declared width. -/ +def Synth.litBv (v : Nat) (width : Nat) (source : Option FileRange) : StmtExpr × HighTypeMd := + (.LiteralBv v width, { val := .TBv width, source := source }) + -- ### Variables /-- (Var-Local) @@ -1145,11 +1151,14 @@ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := value-producing `IfThenElse` — fails this check (its type is not consistent with `TVoid`) and is reported as dead code. - * **Discard-Call** — `s` is a call (`StaticCall`/`InstanceCall`). The - call is synthesized and its result dropped, so the `list.add(x);` - idiom type-checks even when the callee returns a value. A call is - the *only* value-producing form admitted in effect position: its - effects are the point and its result is incidental. + * **Discard-Call / Discard-IncrDecr** — `s` is a call + (`StaticCall`/`InstanceCall`) or an `IncrDecr` (`x++`/`--x`). The + expression is synthesized and its result dropped, so the + `list.add(x);` idiom type-checks even when the callee returns a + value, and a bare `x++;` is admitted even though `++` synthesizes + the (non-void) target type. These are the value-producing forms + admitted in effect position: their effects are the point and their + results are incidental. This is the single definition of "what counts as a statement". It is used by `Check.block` for every non-last statement, and for the last @@ -1157,7 +1166,7 @@ def Synth.emptyBlock (source : Option FileRange) : HighTypeMd := (`expected = TVoid`). -/ def Check.statement (s : StmtExprMd) : ResolveM StmtExprMd := do match s.val with - | .StaticCall .. | .InstanceCall .. => + | .StaticCall .. | .InstanceCall .. | .IncrDecr .. => let (s', _) ← Synth.resolveStmtExpr s; pure s' | _ => Check.resolveStmtExpr s { val := .TVoid, source := s.source } termination_by (s, 4) @@ -1587,6 +1596,60 @@ def Check.assign (exprMd : StmtExprMd) simp only [AstNode.mk.sizeOf_spec, Variable.Field.sizeOf_spec] at hmem) omega +-- ### Increment / decrement + +/-- (IncrDecr) + ``` + Γ ⊢ target ⇒ T T ∈ {int, int-based constrained} + ───────────────────────────────────────────────── + Γ ⊢ IncrDecr mode op target ⇒ T + ``` + `++`/`--` reads and writes its target, so it synthesizes the target's + own type. The target is resolved the same way as an `Assign` target (a + `Local` is resolved against scope; a `Field` synthesizes its receiver + and resolves the field against it; the `Declare` form should not occur — + the translator rejects it — and is handled conservatively). The element + type is then checked by `checkIncrDecrTargetType`, which emits a Laurel + diagnostic when `++`/`--` is applied to an unsupported type (`bv`, + `real`, `float64`) rather than letting a raw Core unification error leak + from the later `EliminateIncrDecr` lowering. Used in expression position + (`var y := ++x`, `if x++ > 0`, `f(x++)`); in statement position the + yielded value is discarded by `Check.statement`. -/ +def Synth.incrDecr (exprMd : StmtExprMd) + (mode : IncrDecrMode) (op : IncrDecrOp) (target : VariableMd) + (source : Option FileRange) + (h : exprMd.val = .IncrDecr mode op target) : + ResolveM (StmtExpr × HighTypeMd) := do + let target' ← match h_tgt : target.val with + | .Local ref => + let ref' ← resolveRef ref source + pure (⟨.Local ref', target.source⟩ : VariableMd) + | .Field tgt fieldName => + let (tgt', _) ← Synth.resolveStmtExpr tgt + let fieldName' ← resolveFieldRef tgt' fieldName source + pure (⟨.Field tgt' fieldName', target.source⟩ : VariableMd) + | .Declare param => + -- Should not occur — the translator rejects a declaration target; + -- treat conservatively by resolving its type only. + let ty' ← resolveHighType param.type + pure (⟨.Declare ⟨param.name, ty'⟩, target.source⟩ : VariableMd) + checkIncrDecrTargetType op target' source + let resultTy ← match target'.val with + | .Local ref => getVarType ref + | .Declare param => pure param.type + | .Field _ fieldName => getVarType fieldName + pure (.IncrDecr mode op target', resultTy) + termination_by (exprMd, 1) + decreasing_by + apply Prod.Lex.left + have hsz := exprMd.sizeOf_val_lt + rw [h] at hsz + simp only [StmtExpr.IncrDecr.sizeOf_spec] at hsz + have hsz2 := target.sizeOf_val_lt + rw [h_tgt] at hsz2 + simp only [Variable.Field.sizeOf_spec] at hsz2 + omega + -- ### Calls /-- Cases on the arity of the callee's declared outputs. @@ -1696,20 +1759,29 @@ def Synth.instanceCall (exprMd : StmtExprMd) (h : exprMd.val = .InstanceCall target callee args) : ResolveM (StmtExpr × HighTypeMd) := do let (target', _) ← Synth.resolveStmtExpr target - let callee' ← resolveRef callee source + -- An instance procedure is registered under the container-scoped key + -- `TypeName$method` (see `preRegisterTopLevel` / `resolveInstanceProcedure`), + -- matching the lifted top-level static procedure that `LiftInstanceProcedures` + -- produces. Look the method up under that key, derived from the receiver's + -- type; fall back to the bare callee name when the target's type can't be + -- determined (an unresolved name, which already reported its own error). + let lookupKey ← match (← targetTypeName target') with + | some tyName => pure (containerScopedName (mkId tyName) callee) + | none => pure callee + let resolved ← resolveRef lookupKey source (expected := #[.instanceProcedure, .staticProcedure]) - let (retTy, paramTypes) ← getCallInfo callee + -- Preserve the user-facing callee text for diagnostics; only stamp the + -- resolved `uniqueId` from the container-scoped lookup. + let callee' := { callee with uniqueId := resolved.uniqueId } + let (retTy, paramTypes) ← getCallInfo lookupKey -- The callee resolves to either an instance- or a static-procedure. An -- instance procedure's first parameter is the implicit `self` receiver, -- which is not supplied positionally here, so it must be dropped before -- pairing parameter types with `args`. A static procedure (also accepted -- on this path) has no `self`, so all its parameters are real and none may -- be dropped. We distinguish the two by the same scope lookup `getCallInfo` - -- uses. (NOTE: this branch is currently unreachable — no Laurel/Python - -- frontend produces a `StmtExpr.InstanceCall`; the only call production - -- lowers to `StaticCall`. The guard below keeps this rule correct should a - -- method-call production ever be added.) - let dropSelf : Bool := match (← get).scope.get? callee.text with + -- uses. + let dropSelf : Bool := match (← get).scope.get? lookupKey.text with | some (_, .instanceProcedure ..) => true | _ => false let callParamTypes := @@ -1727,7 +1799,7 @@ def Synth.instanceCall (exprMd : StmtExprMd) -- above, so an instance procedure's implicit `self` is excluded; it returns -- `none` for any non-procedure resolution, leaving the Unknown-padding (and -- no duplicate diagnostic) for those. Args are resolved above regardless. - if let some arity ← procArity callee dropSelf then + if let some arity ← procArity lookupKey dropSelf then if args.length > arity then let diag := diagnosticFromSource source s!"call to '{callee}' expects {arity} argument(s) but {args.length} were provided"