Core: Sequence bounds preconditions and VC-printer fallback fix#1100
Core: Sequence bounds preconditions and VC-printer fallback fix#1100fabiomadge wants to merge 4 commits intomainfrom
Conversation
dacef58 to
5e268e5
Compare
| private def classifyPrecondition (funcName : String) (precondIdx : Nat := 0) : Option String := | ||
| and overflow), the precondition index distinguishes them. | ||
|
|
||
| Exposed (not `private`) so that tests can verify the classification for each |
There was a problem hiding this comment.
This does not need to be a in a comment. You can just change the visibility
There was a problem hiding this comment.
Done — reverted to private and dropped the stale "Exposed" docstring. No test actually needs it public (the metadata-check test uses collectPrecondAsserts + metadata inspection instead).
handleZeroaryOps fell back to logging an error and returning re.none() for any 0-ary op outside the regex set. That silently substituted a regex primitive for unrelated ops in VC printer output; users saw re.none() where e.g. Sequence.empty() was intended. Switch the fallback to mkGenericCall, matching how handleUnaryOps and handleBinaryOps already handle unknown ops. The printer now emits the op name as a free-variable reference, preserving the intent. Parseable Sequence.empty<T>() syntax is still a separate grammar-level feature; this commit only fixes the printer-side noise.
polyUneval is the combinator used to declare unevaluated polymorphic functions with axioms. Unlike unaryOp and binaryOp, it had no way to attach preconditions; callers had to hand-build the WFLFunc. Add a 'preconditions' parameter and the matching free-vars proof obligation (subset of the function's input names), defaulting to empty. No behavioral change for existing callers.
5e268e5 to
1925770
Compare
atomb
left a comment
There was a problem hiding this comment.
This looks nice. There's a little room to reduce repetition, however.
Sequence.select and Sequence.update now require `0 <= i < length(s)`; Sequence.take and Sequence.drop require `0 <= n <= length(s)`. PrecondElim picks these up and generates VC obligations at call sites, both in statement positions (via transformStmt) and in pure positions (via mkContractWFProc / mkFuncWFProc) — so requires/ensures/quantifier-body subscripts are also covered. Obligations carry the propertyType metadata "outOfBoundsAccess" (new MetaData constant) and flow through a new PropertyType.outOfBoundsAccess enum variant — with matching entries in the statement-eval / obligation-extraction / cmd-eval metadata-to-PropertyType conversion sites — to finally render as "out-of-bounds-access" in SARIF output, matching how divisionByZero and arithmeticOverflow are classified. Side effect: `propertyTypeToClassification` in SarifOutput.lean was previously dead code; `vcResultToSarifResult` never set `properties.propertyType` so the SARIF output defaulted every obligation to "assert". Wiring this up means divisionByZero and arithmeticOverflow obligations now also classify correctly in SARIF — a pre-existing bug this PR incidentally fixes.
New tests in StrataTest/Transform/PrecondElim.lean:
- Test 10: Sequence.select in a procedure body emits the bounds assert
(PrecondElim is unconditional — it inserts regardless of any
surrounding requires guard; the SMT solver discharges).
- Test 10c: Sequence.select inside a requires clause triggers the
$$wf-procedure path (mkContractWFProc).
- Test 10d: Sequence.select inside a function body triggers the
function-body $$wf path (mkFuncWFStmts).
- Test 11: collectPrecondAsserts attaches outOfBoundsAccess metadata
for all four partial ops and a nested call. Mirrors
OverflowCheckTest.lean. Also verifies Sequence.length emits
no obligation (it is total).
- Test 12: Sequence.empty printer regression for the commit-1 fix —
renders as a generic call, not re.none().
New property-classification tests in
StrataTest/Languages/Core/Tests/SarifOutputTests.lean cover all five
PropertyType variants, exercising the SARIF wiring fix in commit 3.
Collateral test updates for real behavioral changes:
- StrataTest/Languages/Core/Examples/Seq.lean: expected VC output
includes the new bounds obligations (all SMT-provable from the
surrounding context, except the pre-existing contains_yes unknown).
- StrataTest/Languages/Core/Tests/ProgramEvalTests.lean: Sequence func
signatures now render with the attached requires clauses.
- StrataTest/Languages/Core/Examples/Loops.lean: commit-1 printer fix
propagates (re.none() -> top, error message format updated).
1925770 to
bbf0d27
Compare
tautschnig
left a comment
There was a problem hiding this comment.
Proof-coverage suggestions ("what to prove next"). These are all follow-ups, not blocking.
-
convertMetaDataPropertyType_inverse_of_classifyPrecondition— for every(fn, idx)thatclassifyPreconditionmaps tosome metaString,convertMetaDataPropertyTypeapplied to aMetaDatatagged with that string returns a non-.assertPropertyType. Concretely: there shouldn't be a gap whereclassifyPreconditiontags a call butconvertMetaDataPropertyTypesilently reverts to.assert. Trivial to state, would catch the string-mismatch class of regressions the triplication enables. -
PropertyType-to-SARIF round-trip —(propertyTypeToClassification p).fromClassification? = some pfor allp : PropertyType, wherefromClassification?is a matching deserializer (not yet written). Would makePropertyTypethe single source of truth and mechanically prune theMetaData.*string constants. -
mkSeqBoundsPrecond_form— a structural theorem about(mkSeqBoundsPrecond x k).expr: it equalsboolAnd (intLe 0 x) (k.opExpr x (seqLength s))(or the concrete LExpr shape). MakesTest 10's off-by-one blind spot (see inline comment #2) explicit and proved rather than merely tested. Small. -
seqSelect_denote_partial— semantic soundness of the bounds precondition with respect to Lean'sList.get?:0 ≤ i < s.length → (seqSelect s i).denote = .some (s.get! i). This isFactoryCorrect.leanterritory (cf. PR #1103) rather than this PR, but worth tracking as the obvious continuation: the preconditions added here are only useful insofar as they correspond to a semantic partiality. -
collectPrecondAsserts-spec — a purely structural theorem that for every sub-expression that is a call to a function withNpreconditions,collectPrecondAssertsproduces exactlyNassertstatements, each tagged byclassifyPrecondition. Test 10 verifies counts 1, 1, 1, 1, and 2 empirically; a theorem would subsume all of that and catch e.g. a future refactor that stops visiting nested calls.
Refactoring opportunities (not blocking).
- As noted in finding #3, consolidate
MetaData.{divisionByZero,...}/propertyTypeToClassification/convertMetaDataPropertyTypeinto a single table. - The new
assertOutOfBoundsObligationshelper in the tests is a good pattern; the pre-existing overflow/division tests in the same file (Test 1,Test 3,Test 5) could share a sibling helper, reducing per-test boilerplate uniformly.
| /-- Classify a function precondition into a property type for SARIF reporting. | ||
| For functions with multiple preconditions (e.g., SafeSDiv has both div-by-zero | ||
| and overflow), the precondition index distinguishes them. -/ | ||
| private def classifyPrecondition (funcName : String) (precondIdx : Nat := 0) : Option String := | ||
| and overflow), the precondition index distinguishes them. | ||
|
|
||
| Exposed (not `private`) so that tests can verify the classification for each | ||
| partial op without having to inspect metadata on generated asserts. -/ | ||
| def classifyPrecondition (funcName : String) (precondIdx : Nat := 0) : Option String := |
There was a problem hiding this comment.
Carryover from joscoh's thread at this location (#1100 (comment)...). The reply says:
Done — reverted to
privateand dropped the stale "Exposed" docstring. No test actually needs it public (the metadata-check test usescollectPrecondAsserts+ metadata inspection instead).
But the code in bbf0d27f7 still has def classifyPrecondition (public in the module sense) and still carries the "Exposed (not private)" paragraph. I grepped the tree: the only callers of classifyPrecondition are line 113 in this same file — the tests use collectPrecondAsserts + md.getPropertyType as the reply says — so private is genuinely safe. This is presumably an unpushed intent.
Suggested replacement for lines 64–70:
| /-- Classify a function precondition into a property type for SARIF reporting. | |
| For functions with multiple preconditions (e.g., SafeSDiv has both div-by-zero | |
| and overflow), the precondition index distinguishes them. -/ | |
| private def classifyPrecondition (funcName : String) (precondIdx : Nat := 0) : Option String := | |
| and overflow), the precondition index distinguishes them. | |
| Exposed (not `private`) so that tests can verify the classification for each | |
| partial op without having to inspect metadata on generated asserts. -/ | |
| def classifyPrecondition (funcName : String) (precondIdx : Nat := 0) : Option String := | |
| /-- Classify a function precondition into a property type for SARIF reporting. | |
| For functions with multiple preconditions (e.g., SafeSDiv has both div-by-zero | |
| and overflow), the precondition index distinguishes them. -/ | |
| private def classifyPrecondition (funcName : String) (precondIdx : Nat := 0) : Option String := |
| private def mkSeqBoundsPrecond | ||
| (varName : String) (k : SeqBoundKind) : | ||
| Strata.DL.Util.FuncPrecondition (LExpr CoreLParams.mono) CoreLParams.Metadata := | ||
| let sVar : LExpr CoreLParams.mono := .fvar default "s" (some (seqTy mty[%a])) |
There was a problem hiding this comment.
Implicit coupling: mkSeqBoundsPrecond hard-codes "s" here, which is only correct because the four callers (seqSelectFunc, seqUpdateFunc, seqTakeFunc, seqDropFunc) all happen to name their sequence input "s". The h_precond proof obligation on polyUneval catches mismatches at definition time — so this isn't a correctness trap, but the constraint isn't documented at the point where it bites.
Either take a second seqVarName parameter (symmetric with the existing varName for the index/count):
| let sVar : LExpr CoreLParams.mono := .fvar default "s" (some (seqTy mty[%a])) | |
| /-- Precondition `0 <= varName && varName `k.opExpr` Sequence.length(seqVarName)`. -/ | |
| private def mkSeqBoundsPrecond | |
| (seqVarName : String) (varName : String) (k : SeqBoundKind) : | |
| Strata.DL.Util.FuncPrecondition (LExpr CoreLParams.mono) CoreLParams.Metadata := | |
| let sVar : LExpr CoreLParams.mono := .fvar default seqVarName (some (seqTy mty[%a])) | |
| let xVar : LExpr CoreLParams.mono := .fvar default varName (some mty[int]) |
and update the four call sites to mkSeqBoundsPrecond "s" "i" .Lt etc. — slightly more verbose at the call sites, but the helper's contract is now self-describing.
Or, if you prefer to keep the helper's signature narrow, add a one-line doc note: Assumes the enclosing function names its sequence input "s".
|
|
||
| /-- Check that `collectPrecondAsserts` produces exactly `expectedCount` | ||
| obligations from `expr`, each tagged with `outOfBoundsAccess`. -/ | ||
| private def assertOutOfBoundsObligations | ||
| (expr : Core.Expression.Expr) (expectedCount : Nat) : IO Unit := do | ||
| let stmts := collectPrecondAsserts Core.Factory expr "test" #[] | ||
| assert! stmts.length == expectedCount | ||
| for s in stmts do | ||
| let md : MetaData Core.Expression := match s with | ||
| | Statement.assert _ _ md => md | _ => #[] | ||
| assert! md.getPropertyType == some MetaData.outOfBoundsAccess |
There was a problem hiding this comment.
assertOutOfBoundsObligations checks the obligation count and the metadata tag, but not the obligation content. That means an off-by-one regression in mkSeqBoundsPrecond — say, someone flips intLtFunc to intLeFunc for the .Lt case, producing ... && i <= length(s) for Sequence.select — slides past Test 10 entirely (count and tag both stay right).
The proof-coverage suggestion in the main review body (mkSeqBoundsPrecond_form) is the strong version. A lighter-weight test-level check that would catch this class of regression cheaply:
/-- Check that `collectPrecondAsserts` produces exactly `expectedCount`
obligations from `expr`, each tagged with `outOfBoundsAccess` and
each having the expected `boolAnd`-of-two-int-comparisons shape
(so off-by-one regressions in `mkSeqBoundsPrecond` are caught). -/
private def assertOutOfBoundsObligations
(expr : Core.Expression.Expr) (expectedCount : Nat) : IO Unit := do
let stmts := collectPrecondAsserts Core.Factory expr "test" #[]
assert! stmts.length == expectedCount
for s in stmts do
match s with
| Statement.assert _ e md =>
assert! md.getPropertyType == some MetaData.outOfBoundsAccess
-- The obligation is `boolAnd (intLe 0 _) (intLt|intLe _ (seqLength _))`.
match e with
| LExpr.app _ (LExpr.app _ andOp _) _ =>
assert! andOp.isBoolAnd -- pseudo; real predicate will depend on LExpr shape
| _ => assert! false
| _ => assert! false(Exact predicate isBoolAnd would need spelling out; the intent is to fail loudly if the top-level constructor stops being boolAnd.) Happy to prepare a concrete patch if you'd like.
| /-- Convert a `MetaData` entry's property-type classification string to the | ||
| `PropertyType` enum. Falls back to `.assert` when the metadata carries | ||
| no classification or an unrecognized string; callers that emit | ||
| propertyType classifications should add a matching arm here. -/ | ||
| def convertMetaDataPropertyType {P : PureExpr} [BEq P.Ident] | ||
| (md : MetaData P) : PropertyType := | ||
| match md.getPropertyType with | ||
| | some s => | ||
| if s == MetaData.divisionByZero then .divisionByZero | ||
| else if s == MetaData.arithmeticOverflow then .arithmeticOverflow | ||
| else if s == MetaData.outOfBoundsAccess then .outOfBoundsAccess | ||
| else .assert | ||
| | none => .assert |
There was a problem hiding this comment.
We now have three parallel representations of the same enum:
MetaData.divisionByZero/arithmeticOverflow/outOfBoundsAccess—Stringconstants (MetaData.lean:293–299).propertyTypeToClassification : PropertyType → String(SarifOutput.lean:92) — the display side, kebab-case.convertMetaDataPropertyType : MetaData → PropertyType— the decode side, using the constants from (1).
Adding a new PropertyType variant requires changes in all three places plus a new kebab/camel string pair, and forgetting (1)+(3) silently degrades to .assert at runtime — there's no compile-time check that (1) and (3) stay in sync.
One way to consolidate: a single definition that tabulates the serialization plus inverse:
/-- Canonical mapping from `PropertyType` to the pair of (metadata tag, SARIF classification) strings. -/
def PropertyType.serialization : PropertyType → Option (String × String)
| .divisionByZero => some ("divisionByZero", "division-by-zero")
| .arithmeticOverflow => some ("arithmeticOverflow", "arithmetic-overflow")
| .outOfBoundsAccess => some ("outOfBoundsAccess", "out-of-bounds-access")
| .cover => some ("cover", "cover")
| .assert => noneThen propertyTypeToClassification and convertMetaDataPropertyType both derive from this single source, and the MetaData.* constants can go. A #[simp] round-trip lemma (fromClassification? (toClassification p) = some p) gives compile-time coverage of the set. See the proof-coverage suggestion in the main review body.
MikaelMayer
left a comment
There was a problem hiding this comment.
🤖 Clean PR. The convertMetaDataPropertyType extraction is a good de-duplication, SeqBoundKind is a nice way to prevent nested-precondition accidents, and the SARIF property-type wiring fix is a genuine improvement (was dead code before). Test coverage is solid.
No new issues beyond what's already been flagged in existing threads.
| -- ----------------------------------------------------------------------- | ||
| -- Test: unknown 0-ary operation renders as generic call (e.g. | ||
| -- `Sequence.empty`, which is registered in the factory but not yet | ||
| -- parseable in raw Core source). Previously, the `handleZeroaryOps` |
There was a problem hiding this comment.
🤖 Nit: "Previously, the handleZeroaryOps fallback silently substituted re.none()." — this is a non-stateless comment referencing the old behavior. Consider rephrasing to describe what the test verifies without referencing the prior state, e.g.:
-- Test: unknown 0-ary operation renders as a generic call (e.g.
-- `Sequence.empty`, which is registered in the factory but not yet
-- parseable in raw Core source).
Blocker for Laurel Seq/Array in #1073. Two related Core changes:
1. Fix VC-printer fallback for unknown 0-ary ops
handleZeroaryOpsused to emitre.none()as its fallback, so VCs containing any 0-ary op outside the regex set (e.g.Sequence.empty) rendered asre.none()in printer output. Switched tomkGenericCall, matching howhandleUnaryOps/handleBinaryOpsalready handle unknown ops.2. Bounds preconditions for
Sequence.select/update/take/dropFollowing the
Int.SafeDivpattern:Sequence.select0 <= i && i < Sequence.length(s)Sequence.update0 <= i && i < Sequence.length(s)Sequence.take0 <= n && n <= Sequence.length(s)Sequence.drop0 <= n && n <= Sequence.length(s)Sequence.length/empty/append/build/containsremain total.PrecondElimpicks the obligations up automatically at call sites in imperative code (viatransformStmt) and in pure positions likerequires/ensures/ quantifier bodies / function bodies (via the synthetic$$wfprocedures).Obligations carry
propertyType = "outOfBoundsAccess"(newMetaDataconstant, mirroringdivisionByZero), flow through a newPropertyType.outOfBoundsAccessenum variant and the three metadata-to-PropertyType conversion sites, and render as"out-of-bounds-access"in SARIF output.Incidental fix
While wiring the SARIF classification, I noticed
propertyTypeToClassificationinSarifOutput.leanwas pre-existing dead code:vcResultToSarifResultnever setproperties.propertyType, so every obligation defaulted to"assert"in SARIF output. This PR wires it up, sodivisionByZeroandarithmeticOverflowobligations now also classify correctly in SARIF alongside the newoutOfBoundsAccess.Testing
New tests in
StrataTest/Transform/PrecondElim.lean:Sequence.selectin procedure body, in arequiresclause (triggersmkContractWFProc), and in a function body (triggersmkFuncWFStmts).collectPrecondAssertsattachesoutOfBoundsAccessmetadata for all four partial ops and a nestedSequence.select(Sequence.update(...))call. MirrorsOverflowCheckTest.lean.Sequence.emptyrenders as a generic call in VC printer output, notre.none().Plus new property-classification tests in
StrataTest/Languages/Core/Tests/SarifOutputTests.leancovering all fivePropertyTypevariants.Collateral updates to existing tests reflect the new obligations (
Seq.lean), updatedrequireson Sequence function signatures (ProgramEvalTests.lean), and the 0-ary printer fix (Loops.lean). Note: the bounds obligations inSeq.leanappear astrue && 0 < length(...)— the partial evaluator simplifies0 <= 0totruebut does not further simplifytrue && XtoX. This is a pre-existing evaluator gap, not newly introduced by this PR; the SMT solver discharges the obligation trivially.Known downstream impact
PR #1073 (Laurel Seq/Array) emits
Sequence.select/update/take/dropcalls from its translation. ItsT18_Sequencestest uses#guard_msgs(drop info, error)on a diagnostics-only pipeline, so the test assertions should still pass syntactically — but individual sequence-manipulation programs now require bounds guards to fully verify. PR #1073 will need to adjust any end-to-end verification examples after this one merges. No in-repo Laurel tests are broken by this PR.Out of scope
Parseable
Sequence.empty<T>()syntax in raw Core source — a grammar-level feature for a separate PR. The printer fix here handles the display side; SMT encoding was already correct.