Skip to content

Commit 24b8f04

Browse files
authored
Per-invariant source ranges for loop-invariant diagnostics (#1361)
## Summary Loop-invariant verification diagnostics (a failing `invariant(...)` in a `while`/`for` loop) previously pointed at the **whole loop** instead of the specific invariant that failed. This change threads each invariant's source range through the loop's metadata so loop elimination can attribute each invariant's verification condition to that invariant's own source location. This support is required for [JVerify#437](strata-org/jverify#437) ## Problem In Strata, loop-invariant proof obligations are synthesized by `LoopElim` and were tagged with the loop-wide metadata `md`. The per-invariant source range was lost earlier in the pipeline: Core loop invariants are bare `(label, expr)` pairs and Core expressions are `Unit`-annotated, so they carry no source range. A front-end-only change does not help: the diagnostic location is driven by the synthesized assert's metadata, not by anything attached to the invariant expression. The fix therefore has to live in Strata. ## Solution Thread each invariant's source range through the loop's existing `MetaData` array and use it per-invariant in `LoopElim`. When a per-invariant provenance is present, each invariant's generated assert/assume is attributed to that invariant's own source location; otherwise we fall back to the loop metadata `md`, so loops not originating from Laurel (Core `.st`, C_Simp) are unchanged. ## Testing `StrataTest/.../Fundamentals/T13_WhileLoopsError.lean` adds two caret-annotated regression tests using the existing diagnostic harness (`TestDiagnostics`, `matchesDiagnostic`), which checks the exact start/end line and column of each diagnostic: 1. **`badInitialInvariant`** — a single `invariant i >= 0` that fails on entry. The caret asserts the diagnostic lands on the invariant expression, not the `while` loop. 2. **`secondInvariantFails`** — two invariants where the first holds on entry but the second (`invariant j >= 0`) does not. The caret asserts the diagnostic points specifically at the failing second invariant. This is the case that distinguishes per-invariant attribution from loop-wide attribution: before the fix the range would resolve to the loop, so the carets would not match. Verification: - `lake build Strata` — compiles, no proof breakage. - `lake build StrataTest` — all `#guard_msgs` tests pass, including the new ones. - The fallback to the loop `md` keeps existing Core `.st` and C_Simp loop diagnostics unchanged.
1 parent 8abb463 commit 24b8f04

5 files changed

Lines changed: 128 additions & 9 deletions

File tree

Strata/DL/Imperative/MetaData.lean

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,27 @@ def MetaData.eraseAllElems {P : PureExpr} [BEq P.Ident]
293293
(md : MetaData P) (fld : MetaDataElem.Field P) : MetaData P :=
294294
md.filter (fun e => !(e.fld == fld))
295295

296+
/-- Label for a per-loop-invariant source provenance. Loop lowering stores one
297+
such element per invariant, in invariant order, so that loop elimination can
298+
attribute each invariant's verification condition to the specific invariant's
299+
source location rather than to the whole loop. -/
300+
def MetaData.invariantProvenanceLabel : String := "invariantProvenance"
301+
302+
/-- Append a loop invariant's provenance to metadata. These are appended in
303+
invariant order. Note this matches on the `.label` constructor directly, so
304+
it needs no `BEq P.Ident` instance. -/
305+
def MetaData.pushInvariantProvenance {P : PureExpr} (md : MetaData P) (p : Provenance) : MetaData P :=
306+
md.push { fld := .label MetaData.invariantProvenanceLabel, value := .provenance p }
307+
308+
/-- Get all per-invariant provenances from metadata, in the order they were
309+
pushed (matching the loop's invariant order). -/
310+
def MetaData.getInvariantProvenances {P : PureExpr} (md : MetaData P) : Array Provenance :=
311+
md.filterMap fun elem =>
312+
match elem.fld, elem.value with
313+
| .label l, .provenance p =>
314+
if l == MetaData.invariantProvenanceLabel then some p else none
315+
| _, _ => none
316+
296317
/-- Replace the primary provenance with a new one, shifting existing related
297318
provenances and prepending the old primary provenance. -/
298319
def MetaData.setCallSiteFileRange {P : PureExpr} [BEq P.Ident]

Strata/Languages/Laurel/LaurelAST.lean

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -418,11 +418,15 @@ def Condition.mapM [Monad m] (f : AstNode StmtExpr → m (AstNode StmtExpr)) (c
418418
def Condition.mapCondition (f : AstNode StmtExpr → AstNode StmtExpr) (c : Condition) : Condition :=
419419
{ c with condition := f c.condition }
420420

421+
/-- Build a provenance from an optional source location. -/
422+
def fileRangeToProvenance (source : Option FileRange) : Provenance :=
423+
match source with
424+
| some fr => Provenance.ofSourceRange fr.file fr.range
425+
| none => .synthesized .laurel
426+
421427
/-- Build Core metadata from an optional source location. -/
422428
def fileRangeToCoreMd (source : Option FileRange) : Imperative.MetaData Core.Expression :=
423-
match source with
424-
| some fr => Imperative.MetaData.ofSourceRange fr.file fr.range
425-
| none => Imperative.MetaData.ofProvenance (.synthesized .laurel)
429+
Imperative.MetaData.ofProvenance (fileRangeToProvenance source)
426430

427431
/-- Build Core metadata from an AstNode's source location. -/
428432
def astNodeToCoreMd (node : AstNode α) : Imperative.MetaData Core.Expression :=

Strata/Languages/Laurel/LaurelToCoreTranslator.lean

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,15 @@ def translateStmt (stmt : StmtExprMd)
502502
let invExprs ← invariants.mapM (fun i => do return ("", ← translateExpr i))
503503
let decreasingExprCore ← decreasesExpr.mapM (translateExpr)
504504
let bodyStmts ← translateStmt body
505-
return [Imperative.Stmt.loop (.det condExpr) decreasingExprCore invExprs bodyStmts md]
505+
-- Attach each invariant's source provenance to the loop metadata, in
506+
-- invariant order, so loop elimination can point an invariant's
507+
-- verification condition at the specific invariant rather than the whole
508+
-- loop. (The Core loop IR stores invariants as `(label, expr)` pairs with
509+
-- no per-invariant metadata slot, and Core expressions carry no source
510+
-- range, so we thread the ranges through the loop metadata instead.)
511+
let mdWithInvs := invariants.foldl
512+
(fun acc i => acc.pushInvariantProvenance (fileRangeToProvenance i.source)) md
513+
return [Imperative.Stmt.loop (.det condExpr) decreasingExprCore invExprs bodyStmts mdWithInvs]
506514
| .Exit target =>
507515
return [Imperative.Stmt.exit target md]
508516
| .Hole _ _ =>

Strata/Transform/LoopElim.lean

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,16 +142,34 @@ def Stmt.removeLoopsM
142142
-- reference to the source invariant.
143143
let invSuffix : Nat → String → String := fun i lbl =>
144144
if lbl.isEmpty then toString i else s!"{i}_{lbl}"
145+
-- Per-invariant source provenance, threaded through the loop metadata by the
146+
-- Laurel→Core translation (in invariant order). When present, each
147+
-- invariant's generated assert/assume is attributed to that invariant's own
148+
-- source location instead of the whole loop; otherwise we fall back to the
149+
-- loop metadata `md` (e.g. for loops not originating from Laurel).
150+
-- Contract: `invProvs` holds one provenance per invariant, in invariant
151+
-- order, pushed by the Laurel→Core translation (`pushInvariantProvenance`).
152+
-- When the sizes disagree (e.g. a loop not originating from Laurel, or a
153+
-- future desync between push and retrieval) we fall back to the loop `md`
154+
-- via the index lookup below, so a mismatch degrades gracefully rather than
155+
-- mis-attributing ranges.
156+
let invProvs := MetaData.getInvariantProvenances md
157+
let invMd : Nat → MetaData P := fun i =>
158+
-- Only real source locations (`.loc`) refine the diagnostic; a synthesized
159+
-- provenance carries no usable range, so fall back to the loop `md`
160+
match invProvs[i]? with
161+
| some (p@(.loc ..)) => MetaData.ofProvenance p
162+
| _ => md
145163
let entry_invariants := invariants.mapIdx fun i (lbl, inv) =>
146-
Stmt.cmd (HasPassiveCmds.assert s!"entry_invariant_{loop_num}_{invSuffix i lbl}" inv md)
164+
Stmt.cmd (HasPassiveCmds.assert s!"entry_invariant_{loop_num}_{invSuffix i lbl}" inv (invMd i))
147165
let entry_invariant_assumes := invariants.mapIdx fun i (lbl, inv) =>
148-
Stmt.cmd (HasPassiveCmds.assume s!"assume_entry_invariant_{loop_num}_{invSuffix i lbl}" inv md)
166+
Stmt.cmd (HasPassiveCmds.assume s!"assume_entry_invariant_{loop_num}_{invSuffix i lbl}" inv (invMd i))
149167
let first_iter_facts :=
150168
.block s!"first_iter_asserts_{loop_num}" (entry_invariants ++ entry_invariant_assumes) {}
151169
let inv_assumes := invariants.mapIdx fun i (lbl, inv) =>
152-
Stmt.cmd (HasPassiveCmds.assume s!"{loopElimInvariantPrefix}{loop_num}_{invSuffix i lbl}" inv md)
170+
Stmt.cmd (HasPassiveCmds.assume s!"{loopElimInvariantPrefix}{loop_num}_{invSuffix i lbl}" inv (invMd i))
153171
let maintain_invariants := invariants.mapIdx fun i (lbl, inv) =>
154-
Stmt.cmd (HasPassiveCmds.assert s!"arbitrary_iter_maintain_invariant_{loop_num}_{invSuffix i lbl}" inv md)
172+
Stmt.cmd (HasPassiveCmds.assert s!"arbitrary_iter_maintain_invariant_{loop_num}_{invSuffix i lbl}" inv (invMd i))
155173
-- Guard-specific parts: assume_guard, termination, not_guard
156174
let (guard_assumes, pre_termination, post_termination, exit_guard) ← match guard with
157175
| .det g => do
@@ -183,7 +201,7 @@ def Stmt.removeLoopsM
183201
([havocd, arbitrary_iter_assumes] ++ pre_termination ++
184202
body_statements ++ maintain_invariants ++ post_termination) {}
185203
let invariant_assumes := invariants.mapIdx fun i (lbl, inv) =>
186-
Stmt.cmd (HasPassiveCmds.assume s!"invariant_{loop_num}_{invSuffix i lbl}" inv md)
204+
Stmt.cmd (HasPassiveCmds.assume s!"invariant_{loop_num}_{invSuffix i lbl}" inv (invMd i))
187205
let exit_state_assumes := [havocd] ++ exit_guard ++ invariant_assumes
188206
let loop_passive :=
189207
.ite guard (arbitrary_iter_facts :: exit_state_assumes) [] {}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/-
2+
Copyright Strata Contributors
3+
4+
SPDX-License-Identifier: Apache-2.0 OR MIT
5+
-/
6+
7+
import StrataTest.Util.TestLaurel
8+
9+
open StrataTest.Util
10+
open Strata
11+
12+
/-! ## Loop-invariant failures point at the specific invariant
13+
14+
These negative tests pin each failing loop invariant's diagnostic to that
15+
invariant's own source range (per-invariant source ranges threaded through
16+
loop elimination), rather than the whole loop. -/
17+
18+
#eval testLaurel
19+
#strata
20+
program Laurel;
21+
procedure badInitialInvariant()
22+
opaque
23+
{
24+
var i: int := -1;
25+
while(i < 10)
26+
invariant i >= 0
27+
// ^^^^^^ error: assertion does not hold
28+
{
29+
i := i + 1
30+
}
31+
};
32+
#end
33+
34+
#eval testLaurel
35+
#strata
36+
program Laurel;
37+
procedure secondInvariantFails()
38+
opaque
39+
{
40+
var i: int := 0;
41+
var j: int := -1;
42+
while(i < 10)
43+
invariant i >= 0
44+
invariant j >= 0
45+
// ^^^^^^ error: assertion does not hold
46+
{
47+
i := i + 1;
48+
j := j + 1
49+
}
50+
};
51+
#end
52+
53+
#eval testLaurel
54+
#strata
55+
program Laurel;
56+
procedure forSecondInvFails()
57+
opaque
58+
{
59+
var j: int := -1;
60+
for(var i: int := 0; i < 10; i := i + 1)
61+
invariant i >= 0
62+
invariant j >= 0
63+
// ^^^^^^ error: assertion does not hold
64+
{
65+
j := j + 1
66+
}
67+
};
68+
#end

0 commit comments

Comments
 (0)