Conversation
Introduce a Static Single Assignment (SSA) transformation that converts procedure bodies so every variable is assigned exactly once via init. Mutations (set, havoc) become fresh init declarations. At if-then-else join points, conditional init expressions merge divergent variable versions. The transformation: - Runs after callElim and loopElim (no calls or loops expected) - Converts set to init with fresh variable names (x_0, x_1, ...) - Converts havoc to nondet init - Rewrites expressions to reference current SSA variable versions - Emits conditional merge inits at if-then-else join points - Uses modelPreserving validation (semantics-preserving) Integration: - Available as --pass ssa in the transform CLI command - Available as TransformPass.ssa in the SimpleAPI - Not part of the default verification pipeline (opt-in) Files: - Strata/Transform/SSA.lean: transformation implementation - StrataTest/Transform/SSA.lean: 16 tests covering straight-line, if-then-else, havoc, nested branches, multiple variables, inout params, and pipeline chaining - Strata/SimpleAPI.lean: TransformPass.ssa + applyPass integration - StrataMain.lean: ssa added to valid transform passes
Refactor SSA transformation to use the standard CoreTransformM monad instead of a custom SSAM monad. This aligns with the transform API conventions used by other transforms (CallElim, LoopElim, etc.). Changes: - Use CoreGenState.gen for fresh name generation (ssa_ prefix) - Use Transform.incrementStat for statistics - Use Transform.createFvar for fvar construction - Thread Env through function parameters instead of custom state - Replace panic! with empty program fallback in test helper
Use #eval instead of #eval! as recommended. The #eval! was a leftover from an earlier iteration that had sorry dependencies.
d92aea2 to
859a46f
Compare
tautschnig
left a comment
There was a problem hiding this comment.
Bug 1 — output parameters lose their final assignment
In transformCmd, the .set r … case generates var ssa_r_N : int := … instead of preserving the write to the output parameter r. After SSA, the declared output is never assigned.
Minimal repro:
procedure inc(x : int, out y : int)
spec { ensures (y == x + 1); }
{ y := x + 1; };
Before SSA: inc_ensures_0 : ✅ pass. After [.ssa]: body becomes var ssa_y_0 : int := x + 1; — y is never written — and inc_ensures_0 : ❌ fail.
See the inline comment on transformCmd. The fix is probably to emit a final r := ssa_r_N for every output whose env ident diverged from its original ident, at the end of each procedure body (and similarly for inout parameters).
Bug 2 — phi merges can reference out-of-scope or undefined names
Strata Core blocks introduce lexical scope (see Translate.lean:1332 — translateBlock does not propagate boundVars out of the block). emitJoinMerges emits phi inits at the join point that reference the branches' in-block SSA names, which are no longer in scope after the ITE. And when a variable is declared only in one branch (and wasn't in preEnv), the other branch's getIdOr falls back to ⟨origName, ()⟩ — the raw pre-SSA name, which may never have been declared at all.
Minimal repro:
procedure f(c : bool, out r : int) {
if (c) { var x : int := 42; }
r := 0;
};
SSA output:
var ssa_$ssa_cond_0 : bool := c;
if (ssa_$ssa_cond_0) { var ssa_x_1 : int := 42; }
var ssa_phi_x_2 : int := if ssa_$ssa_cond_0 then ssa_x_1 else x;
var ssa_r_3 : int := 0;
Both ssa_x_1 (declared inside the then-block) and x (never declared) appear in the outer scope. Strata's current type checker happens to accept this (that's probably its own bug), but it's clearly wrong. The logic should either skip join-merges for variables that weren't in preEnv, or lift the declarations themselves out of their branches to procedure scope before emitting the phi.
See the inline comment on emitJoinMerges / getIdOr.
Pipeline validation is wrong
Given Bug 1, the .modelPreserving annotation on ssaPipelinePhase is false: there exist programs where SSA changes verification outcomes from pass to fail. Until Bug 1 is fixed, this phase shouldn't claim model preservation. Consider .unchecked or a new .transformModels validation with explicit documentation — or .notImplemented while the transform is still experimental / opt-in.
Test coverage is too weak to catch either bug
All 16 tests use one of three weak oracles — see inline on StrataTest/Transform/SSA.lean. A single VC-outcome round-trip test would have caught Bug 1; a simple "no undefined identifiers" check on the output would have caught Bug 2.
Refactoring notes
getIdOr's fallback to⟨origName, ()⟩silently produces potentially-undefined references. It should either be removed (in favor of throwing a diagnostic at the call site) or, if a fallback really is needed, asserted unreachable via apanic!-replacement that reports the bug.genSSAIdent "$ssa_cond"yieldsssa_$ssa_cond_N, a double-ssa_/ double-$name that is ugly to read in traces. Pick"cond"(→ssa_cond_N) or bypass thessa_prefix for these internal names.initEnvFromProcedureseeds the env for outputs identically to inputs — document that outputs require a final re-assignment (once Bug 1 is fixed) so a future reader doesn't try to "simplify" the initialization.collectAllKeys : List Env → List Stringis only ever called with a 3-element list; either specialize it or keep it general and add a 0/1/2/empty test.rewriteExprshortcircuits onenv.isEmpty, butsubstFvarswith an empty map is already a no-op; the check is harmless but redundant.- The
rewriteExpr/rewriteExprOrNondetsplit plusgetIdOr/getTyOr/varChangedmakesemitJoinMergeshard to follow — consider extracting aphiEntry : String → Env → Env → Env → Option (Ident × Ident × Ty)that returnsnonefor vars that don't need a phi and handles the scoping / lookup concerns in one place.
Theorems / tests worth locking in
All of these would either prevent re-occurrence of the two bugs or push the transform toward the .modelPreserving guarantee:
- Single-assignment: for all
p : Program, every procedure body produced byssaTransformhas distinctinitidentifiers (modulo nested blocks). This is the defining invariant of SSA form. - Scoping: for all
p : Program, every free-variable reference in the body produced byssaTransform presolves to ainit-declared identifier in an enclosing scope. (Would have failed for Bug 2.) - Output preservation: for all
p : Programand every procedurePwith outputo, the final value ofounder the transformed body equals the final value ofounder the original body. (Would have failed for Bug 1.) - VC-outcome preservation: a property-test (plausible / random Core procedures) that
verify pandverify (runTransforms p [.ssa])produce identical VC outcomes per label. - Well-typedness preservation: a simpler prerequisite to #4 — if the original type-checks, so does the SSA output.
#1 and #2 should be tractable as pencil-and-paper proofs over the structure of transformStmt / transformCmd. #3 and #4 are harder but the payoff is high: they would justify the .modelPreserving claim that currently isn't true.
Use #eval instead of #eval! as recommended. The #eval! was a leftover from an earlier iteration that had sorry dependencies.
tautschnig
left a comment
There was a problem hiding this comment.
Remaining soundness bug: flattened ITE loses the path-condition guard for branch-specific asserts.
Reproducer I ran locally on this branch:
procedure check_branch_assert(x : int) {
if (x > 0) {
assert [pos]: (x > 0);
}
};Original: verifies (path condition for the assert is x > 0 from the branch guard — trivially entails the assert).
After --pass ssa, the program becomes (copied verbatim from the output):
procedure check_branch_assert (x : int)
{
var ssa_cond_0 : bool := x > 0;
assert [pos]: x > 0;
};
SSA'd: fails — x is an input, the assert is now unconditional, and x = -5 is a counterexample the solver picks up. The if (x > 0) { … } wrapper has been flattened away, and the assert no longer has ssa_cond_0 → … as its guard.
This falsifies the .modelPreserving claim (SSA.lean:256): the SSA'd program produces a counterexample (x = -5) that is not a counterexample in the original (where x = -5 simply skips the then-branch). So an SSA run in deductive mode reports false-alarm failures, and an SSA run in bug-finding mode reports spurious counterexamples. Both are the kind of unsoundness .modelPreserving is meant to rule out.
The .ite .det arm at :186-198 flattens both branches inline:
return (mergedEnv, [condInit] ++ thenStmts' ++ elseStmts' ++ merges)The standard fix in SSA is either (a) guard each branch-local assert with an implication ssa_cond_0 → assert_expr before flattening, or (b) keep the .ite intact and only add phi inits at the join (no flattening). Details inline on transformStmt. Worst-case, route this under .modelToValidate until the fix lands.
Other observations (all smaller):
ssaTransformalways reportschanged = true(:241) even when no procedures were transformed (e.g., a program of only axioms/types). Pipeline consumers treatchangedas "something changed"; reportingtrueunconditionally makes downstream fixpoint loops non-terminating.emitOutputAssignmentsreintroducessetinto the output program (:173). This breaks the SSA invariant ("every variable assigned exactly once") for output parameters specifically — the output getsvar ssa_y_N := …followed byset y := ssa_y_N. Worth a docstring note that this exception is intentional to preserve the procedure contract, so a reader doesn't misread "SSA" as literally meaning every variable is init-once.emitOutputAssignmentsskips outputs whose final SSA id equals the original. Fine for the common case, but for an output parameter that a procedure never assigns, we skip — which is correct. Worth a comment saying so.- Duplicated
.ite .det/.ite .nondetarms (:186-205) differ only in the RHS ofcondInit(.det condExpr'vs.nondet). Extract a helperbuildIteLocal (condInitRhs : ExprOrNondet Expression)that emits condVar + branches + merges. Not blocking. rewriteExpr'ssubstFvarshandles bound variables correctly (standard capture-avoiding), but there's no test that exercises SSA inside a quantifier (the onlyLExprbinders in the asserts that might appear in procedure bodies). Worth a fixture:assert [q]: ∀ i : int. x > i— verify SSA renamesxbut leavesialone.
Test-coverage gaps:
- The new
SSACorrectnessTestssection (:324-423) adds 3 round-trip fixtures; none of them exercise branch-specific asserts, so the soundness bug above is invisible to the test suite. Adding mycheck_branch_assertreproducer would catch it. - 14 of the 16 tests use the oracle
toString fmt result != toString fmt pgm(unchanged since thread T14). Those tests only check that the transform did something; they pass if a transform deletes every procedure, or if it emits spurious counterexamples. I'd like to see at least one of them replaced with a structural#guard_msgscheck on the expected output shape.
Proof suggestions:
Three theorems would formalize the SSA invariants and (importantly) would fail to prove today because of the soundness bug — making the bug provable-as-a-lemma, which is the right place for it:
/-- Single-assignment invariant: after `ssaTransform`, no variable is assigned more than once. -/
theorem ssaTransform_single_assignment
(p : Program) (p' : Program) (h : Core.Transform.run p (Core.SSA.ssaTransform · |>.map (·.2)) = .ok p') :
∀ proc ∈ p'.decls.filterMap (·.getProc?),
(∀ name, (Core.SSA.countInits name proc.body) + (Core.SSA.countSets name proc.body) ≤ 1)(The output-writeback set is the one documented exception — the statement would need an explicit carve-out for procedure output parameters.)
/-- Scoping invariant: every fvar referenced in `ssaTransform p` is either an input,
an output, or introduced by an SSA-generated init earlier in the same scope. -/
theorem ssaTransform_closed_fvars (p p') : …/-- Model-preservation: if the SSA'd program has a counterexample for obligation Q,
the original program has one too. Fails under the current `.ite` flattening
— proving this is the right gate for flipping back from `.modelToValidate`. -/
theorem ssaTransform_preserves_model (p p' : Program) (Q : ProofObligation Expression) (m : Model) :
Core.Transform.run p (Core.SSA.ssaTransform · |>.map (·.2)) = .ok p' →
Q ∈ obligationsOf p' → isModel Q m → ∃ Q', Q' ∈ obligationsOf p ∧ isModel Q' mFiling these even as sorry-terminated stubs would make the soundness story visible at the type level and would prevent silent regressions when a future edit touches the .ite arm.
| | .ite cond thenStmts elseStmts md => | ||
| match cond with | ||
| | .det condExpr => | ||
| -- In SSA, branching is captured entirely by phi expressions. | ||
| -- Both branches are flattened to the outer scope; the condition | ||
| -- variable selects which branch's values are used via the phi. | ||
| let condExpr' := rewriteExpr env condExpr | ||
| let condVar ← genSSAIdent "cond" | ||
| let condInit := Statement.init condVar (.forAll [] LMonoTy.bool) (.det condExpr') md | ||
| let (thenEnv, thenStmts') ← transformBlock env thenStmts | ||
| let (elseEnv, elseStmts') ← transformBlock env elseStmts | ||
| let (mergedEnv, merges) ← emitJoinMerges condVar env thenEnv elseEnv md | ||
| -- Flatten: condition init, then-branch stmts, else-branch stmts, phi merges. | ||
| -- All at the same scope level so phi references are valid. | ||
| return (mergedEnv, [condInit] ++ thenStmts' ++ elseStmts' ++ merges) |
There was a problem hiding this comment.
Soundness bug: branch-specific asserts lose their path-condition guard. I reproduced this on the current branch:
procedure check_branch_assert(x : int) {
if (x > 0) { assert [pos]: (x > 0); }
};Original: verifies. After --pass ssa:
procedure check_branch_assert (x : int)
{
var ssa_cond_0 : bool := x > 0;
assert [pos]: x > 0;
};
SSA'd: fails (x = -5 is a spurious counterexample). The if is flattened away but the assert isn't guarded by ssa_cond_0, so the verifier checks x > 0 unconditionally.
Two standard fixes:
- Guard branch asserts at flattening time. In
transformBlock(called with the branch env), whenever an assert is encountered inside a then/else branch, rewrite it asassert (condVar → original_assert). This preserves VC semantics while still flattening. - Don't flatten — keep the
.iteintact and only add phi inits at the join. Emit the ITE as.ite cond' (thenStmts' ++ localPhis) (elseStmts' ++ localPhis) md ++ outerPhis. The phis for variables changed in only one branch use thepreEnvvalue on the other side, which is whatphiEntryalready computes. This is the standard SSA-with-control-flow representation.
Option (2) is less invasive — the flattening was the source of the bug. Option (1) is simpler to implement on top of the current code.
Until one of these lands, please downgrade .modelPreserving at line 256 to .modelToValidate so the solver validates models it produces — see inline comment there.
Also add the reproducer to SSACorrectnessTests:
def SSABranchAssertRoundTrip :=
#strata
program Core;
procedure check_branch_assert(x : int) {
if (x > 0) { assert [pos]: (x > 0); }
};
#end
/-- info: true -/
#guard_msgs in
#eval show IO Bool from do
let pgm := translate SSABranchAssertRoundTrip
let origResults ← … -- verify, should pass
let .ok ssaPgm := Core.runTransforms pgm [.ssa] | return false
let ssaResults ← … -- verify ssaPgm, should ALSO pass (currently fails)
return origPass && ssaPass| match d with | ||
| | .proc proc md => return .proc (← transformProcedure proc) md | ||
| | other => return other | ||
| return (true, { decls := decls }) |
There was a problem hiding this comment.
Nit: return (true, { decls := decls }) always reports changed = true, even when p.decls contains no procedures (or every procedure had an empty body, short-circuited at :228). Downstream consumers that run passes to a fixpoint will loop forever on SSA applied to an axiom-only program.
| return (true, { decls := decls }) | |
| -- Track whether any procedure was actually transformed. An empty / proc-less | |
| -- program is a no-op; reporting `changed = true` would force fixpoint loops | |
| -- in the transform driver to iterate indefinitely. | |
| let mut anyChanged := false | |
| let decls ← p.decls.mapM fun d => | |
| match d with | |
| | .proc proc md => | |
| if proc.body.isEmpty then pure (.proc proc md) | |
| else do | |
| anyChanged := true | |
| return .proc (← transformProcedure proc) md | |
| | other => return other | |
| return (anyChanged, { decls := decls }) |
| /-- Emit final `set` statements to write SSA-renamed values back to the | ||
| original output/inout parameter identifiers. This preserves the | ||
| procedure's contract semantics. -/ | ||
| def emitOutputAssignments (proc : Procedure) (finalEnv : Env) | ||
| (md : Imperative.MetaData Expression) : List Statement := | ||
| (proc.header.outputs : List _).filterMap fun (outId, _) => | ||
| match finalEnv.get? outId.name with | ||
| | some info => | ||
| if info.ident != outId then | ||
| some (Statement.set outId (createFvar info.ident) md) | ||
| else none | ||
| | none => none |
There was a problem hiding this comment.
Worth documenting the intentional SSA-invariant exception here: after emitOutputAssignments, the output parameter gets assigned via set outId := ssa_y_N in addition to its var ssa_y_N : int := … init. That is a genuine double-assignment on outId — once implicitly at the procedure boundary (when we seed it in initEnvFromProcedure), once here.
A reader checking "does every variable have exactly one init?" would find this confusing.
| /-- Emit final `set` statements to write SSA-renamed values back to the | |
| original output/inout parameter identifiers. This preserves the | |
| procedure's contract semantics. -/ | |
| def emitOutputAssignments (proc : Procedure) (finalEnv : Env) | |
| (md : Imperative.MetaData Expression) : List Statement := | |
| (proc.header.outputs : List _).filterMap fun (outId, _) => | |
| match finalEnv.get? outId.name with | |
| | some info => | |
| if info.ident != outId then | |
| some (Statement.set outId (createFvar info.ident) md) | |
| else none | |
| | none => none | |
| /-- Emit final `set` statements to write SSA-renamed values back to the | |
| original output/inout parameter identifiers. This preserves the | |
| procedure's contract semantics. | |
| Note (SSA-invariant exception): the output parameter's original name is | |
| reassigned here via `set` even though SSA nominally permits exactly one | |
| assignment per name. This is deliberate: the procedure contract references | |
| outputs by their declared name, so the SSA form has to write the final | |
| SSA value back before returning. All *local* variables remain strictly | |
| single-assignment. -/ | |
| def emitOutputAssignments (proc : Procedure) (finalEnv : Env) | |
| (md : Imperative.MetaData Expression) : List Statement := | |
| (proc.header.outputs : List _).filterMap fun (outId, _) => | |
| match finalEnv.get? outId.name with | |
| | some info => | |
| -- Skip when the output was never assigned in the body (its SSA id | |
| -- equals the original seed from `initEnvFromProcedure`). | |
| if info.ident != outId then | |
| some (Statement.set outId (createFvar info.ident) md) | |
| else none | |
| | none => none |
| def ssaPipelinePhase : PipelinePhase where | ||
| transform := ssaTransform | ||
| phase.name := "SSA" | ||
| phase.getValidation _ := .modelPreserving |
There was a problem hiding this comment.
Downgrade to .modelToValidate until the branch-assert soundness bug is fixed. Per the inline on :184-198 and the top-level body: the current .ite flattening produces spurious counterexamples (verified on this branch: the check_branch_assert reproducer fails in SSA form where it passes in original). .modelPreserving means "sat results are sound" — which is exactly what this flattening breaks.
| phase.getValidation _ := .modelPreserving | |
| /-- SSA pipeline phase: converts procedure bodies to SSA form. | |
| Correctness status (NOT YET `.modelPreserving`): the current `.ite` | |
| handling flattens branches without guarding branch-specific asserts by | |
| the branch condition. This introduces spurious counterexamples for | |
| programs like `if c then assert b`, where the SSA form checks `b` | |
| unconditionally even though the original only reaches the assert on | |
| the `c = true` path. See the TODO on `transformStmt`'s `.ite` arm. | |
| Until the guard-or-preserve fix lands, models must be validated — a | |
| counterexample reported by the solver after SSA may be spurious. | |
| TODO: once the fix lands, flip back to `.modelPreserving` and file a | |
| formal proof of model-preservation. -/ | |
| def ssaPipelinePhase : PipelinePhase where | |
| transform := ssaTransform | |
| phase.name := "SSA" | |
| phase.getValidation _ := .modelToValidate (fun _ => false) |
(The concrete validator fun _ => false conservatively rejects every model — safe default. A sharper validator that re-runs the original program on the candidate counterexample is the follow-up work.)
| /-! ## SSA Correctness Oracle Tests -/ | ||
| section SSACorrectnessTests | ||
|
|
||
| /-- VC-outcome round-trip: ensures that passes before SSA still passes after. | ||
| This catches Bug 1 (output parameter loss). -/ | ||
| def SSAIncRoundTrip := | ||
| #strata | ||
| program Core; | ||
| procedure inc(x : int, out y : int) | ||
| spec { | ||
| ensures (y == (x + 1)); | ||
| } { | ||
| y := (x + 1); | ||
| }; | ||
| #end | ||
|
|
||
| /-- info: true -/ | ||
| #guard_msgs in | ||
| #eval show IO Bool from do | ||
| let pgm := translate SSAIncRoundTrip | ||
| -- Verify original passes | ||
| let origResults ← | ||
| EIO.toIO (fun e => IO.Error.userError e) | ||
| (Strata.Core.verifyProgram pgm | ||
| { Core.VerifyOptions.default with verbose := .quiet } | ||
| (proceduresToVerify := some ["inc"])) | ||
| let origPass := origResults.all Core.VCResult.isSuccess | ||
| -- Verify SSA'd version also passes | ||
| let .ok ssaPgm := Core.runTransforms pgm [.ssa] | ||
| | return false | ||
| let ssaResults ← | ||
| EIO.toIO (fun e => IO.Error.userError e) | ||
| (Strata.Core.verifyProgram ssaPgm | ||
| { Core.VerifyOptions.default with verbose := .quiet } | ||
| (proceduresToVerify := some ["inc"])) | ||
| let ssaPass := ssaResults.all Core.VCResult.isSuccess | ||
| return origPass && ssaPass | ||
|
|
||
| /-- VC-outcome round-trip with if-then-else and output parameter. -/ | ||
| def SSABranchRoundTrip := | ||
| #strata | ||
| program Core; | ||
| procedure max(a : int, b : int, out r : int) | ||
| spec { | ||
| ensures (r >= a); | ||
| ensures (r >= b); | ||
| } { | ||
| if (a >= b) { | ||
| r := a; | ||
| } else { | ||
| r := b; | ||
| } | ||
| }; | ||
| #end | ||
|
|
||
| /-- info: true -/ | ||
| #guard_msgs in | ||
| #eval show IO Bool from do | ||
| let pgm := translate SSABranchRoundTrip | ||
| let origResults ← | ||
| EIO.toIO (fun e => IO.Error.userError e) | ||
| (Strata.Core.verifyProgram pgm | ||
| { Core.VerifyOptions.default with verbose := .quiet } | ||
| (proceduresToVerify := some ["max"])) | ||
| let origPass := origResults.all Core.VCResult.isSuccess | ||
| let .ok ssaPgm := Core.runTransforms pgm [.ssa] | ||
| | return false | ||
| let ssaResults ← | ||
| EIO.toIO (fun e => IO.Error.userError e) | ||
| (Strata.Core.verifyProgram ssaPgm | ||
| { Core.VerifyOptions.default with verbose := .quiet } | ||
| (proceduresToVerify := some ["max"])) | ||
| let ssaPass := ssaResults.all Core.VCResult.isSuccess | ||
| return origPass && ssaPass | ||
|
|
||
| /-- Scoping: variables declared inside a branch should NOT appear in phi merges | ||
| at the outer scope. This catches Bug 2 (out-of-scope references). -/ | ||
| def SSAScopingTest := | ||
| #strata | ||
| program Core; | ||
| procedure f(c : bool, out r : int) { | ||
| if (c) { | ||
| var x : int := 42; | ||
| } else { | ||
| } | ||
| r := 0; | ||
| }; | ||
| #end | ||
|
|
||
| /-- info: true -/ | ||
| #guard_msgs in | ||
| #eval do | ||
| let pgm := translate SSAScopingTest | ||
| let result := runSSA pgm | ||
| let s := toString (Std.format result) | ||
| -- The output should NOT contain "ssa_phi_x" since x was only declared | ||
| -- inside the then-branch and wasn't in scope before the ITE. | ||
| return (s.splitOn "ssa_phi_x").length == 1 | ||
|
|
||
| end SSACorrectnessTests |
There was a problem hiding this comment.
Two gaps in the correctness suite:
- Branch-specific asserts. Add the reproducer from my review body —
if c then assert b-shaped procedures — so the soundness bug onSSA.lean:184-198is caught by the test suite:
def SSABranchAssertRoundTrip :=
#strata
program Core;
procedure check_branch_assert(x : int) {
if (x > 0) { assert [pos]: (x > 0); }
};
#end
/-- info: true -/
#guard_msgs in
#eval show IO Bool from do
let pgm := translate SSABranchAssertRoundTrip
let origResults ← … (as SSAIncRoundTrip)
let origPass := origResults.all Core.VCResult.isSuccess
let .ok ssaPgm := Core.runTransforms pgm [.ssa] | return false
let ssaResults ← …
let ssaPass := ssaResults.all Core.VCResult.isSuccess
return origPass && ssaPass -- fails today; passes once SSA guards branch asserts- Structural / snapshot tests. The 14 SSA tests at
:43-322use the oracletoString fmt result != toString fmt pgm— they only check that something changed. A transform that deletes every procedure passes all 14; so does a transform that emits the empty program with one globalvar. Consider replacing at least one of these (e.g.SSATest1) with a#guard_msgssnapshot of the expected SSA output, so a regression in the shape of the produced program surfaces directly rather than only via a downstream verification failure.
Introduce a Static Single Assignment (SSA) transformation that converts
procedure bodies so every variable is assigned exactly once via init.
Mutations (set, havoc) become fresh init declarations. At if-then-else
join points, conditional init expressions merge divergent variable versions.
Uses the standard CoreTransformM monad with CoreGenState.gen for fresh
name generation (ssa_ prefix), aligning with transform API conventions.
Integration:
Files: