Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Strata/SimpleAPI.lean
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Strata.Transform.LoopElim
public import Strata.Transform.ProcedureInlining
import Strata.Transform.FilterProcedures
import Strata.Transform.IrrelevantAxioms
import Strata.Transform.SSA

public import Strata.Languages.Core.Options
public import Strata.Languages.Core.Verifier
Expand Down Expand Up @@ -246,6 +247,7 @@ inductive Core.TransformPass where
| inlineProcedures (opts : Core.InlineTransformOptions := {})
| loopElim
| callElim
| ssa
| filterProcedures (procs : List String)
| removeIrrelevantAxioms (funcs : List String)

Expand All @@ -261,6 +263,9 @@ private def Core.applyPass (program : Core.Program) (pass : Core.TransformPass)
| .callElim =>
let (_, prog) ← Core.Transform.runProgram coreCallElimCmd program
return prog
| .ssa =>
let (_, prog) ← Core.SSA.ssaTransform program
return prog
| .filterProcedures procs =>
Core.FilterProcedures.run program procs
| .removeIrrelevantAxioms funcs =>
Expand Down
260 changes: 260 additions & 0 deletions Strata/Transform/SSA.lean
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/-
Copyright Strata Contributors

SPDX-License-Identifier: Apache-2.0 OR MIT
-/
module

public import Strata.Languages.Core.PipelinePhase

/-! # SSA (Static Single Assignment) Transformation

Converts Strata Core procedure bodies into SSA form where every variable is
assigned exactly once via `init`. At if-then-else join points, conditional
`init` expressions merge divergent variable versions.

Preconditions: runs after `callElim` and `loopElim`.

Note: After transforming the body, the transform emits final assignments back
to output/inout parameters so that the procedure's contract is preserved.
-/

namespace Core
namespace SSA

open Imperative Lambda
open Core.Transform

public section

/-- Statistics keys tracked by the SSA transformation. -/
inductive Stats where
| renamedVars
| joinPointMerges

#derive_prefixed_toString Stats "SSA"

/-- An entry in the SSA environment: the current versioned identifier and its type. -/
structure VarInfo where
ident : Expression.Ident
ty : Expression.Ty
deriving Repr, BEq

/-- The SSA environment maps original variable names to their current version and type. -/
abbrev Env := Std.HashMap String VarInfo

/-- SSA name prefix for fresh variables. -/
def ssaVarPrefix (id : String) : String := s!"ssa_{id}"

/-- SSA name prefix for phi (join-point merge) variables. -/
def ssaPhiPrefix (id : String) : String := s!"ssa_phi_{id}"

/-- Generate a fresh SSA identifier using the CoreTransformM generator. -/
def genSSAIdent (baseName : String) : CoreTransformM Expression.Ident :=
genIdent ⟨baseName, ()⟩ ssaVarPrefix

/-- Generate a fresh SSA phi identifier for join-point merges. -/
def genSSAPhiIdent (baseName : String) : CoreTransformM Expression.Ident :=
genIdent ⟨baseName, ()⟩ ssaPhiPrefix

/-- Rewrite free variables in an expression according to the SSA environment. -/
def rewriteExpr (env : Env) (e : Expression.Expr) : Expression.Expr :=
let sm : Map Expression.Ident Expression.Expr :=
env.fold (init := Map.empty) fun acc origName info =>
acc.insert ⟨origName, ()⟩ (createFvar info.ident)
Lambda.LExpr.substFvars e sm

/-- Rewrite free variables in an `ExprOrNondet`. -/
def rewriteExprOrNondet (env : Env) (e : ExprOrNondet Expression) : ExprOrNondet Expression :=
e.map (rewriteExpr env ·)

/-- Check if a variable changed between two environments. -/
private def varChanged (info pre : Option VarInfo) : Bool :=
match info, pre with
| some t, some p => t.ident != p.ident
| some _, none => true
| none, _ => false

/-- Compute a phi entry for a variable at a join point. Returns `none` if the
variable doesn't need a merge (unchanged in both branches, or wasn't in
scope before the ITE). Only merges variables present in `preEnv`. -/
private def phiEntry (origName : String) (preEnv thenEnv elseEnv : Env)
: Option (Expression.Ident × Expression.Ident × Expression.Ty) := do
let preInfo ← preEnv.get? origName
let thenInfo := thenEnv.get? origName
let elseInfo := elseEnv.get? origName
if !varChanged thenInfo (some preInfo) && !varChanged elseInfo (some preInfo) then
.none
else
let thenId := (thenInfo.map (·.ident)).getD preInfo.ident
let elseId := (elseInfo.map (·.ident)).getD preInfo.ident
.some (thenId, elseId, preInfo.ty)

/-- Transform a single command in SSA form. Returns updated env and new statements. -/
def transformCmd (env : Env) (cmd : Command) : CoreTransformM (Env × List Statement) := do
match cmd with
| .cmd (.init name ty rhs imd) =>
let rhs' := rewriteExprOrNondet env rhs
let freshId ← genSSAIdent name.name
let env' := env.insert name.name { ident := freshId, ty := ty }
incrementStat s!"{Stats.renamedVars}"
return (env', [Statement.init freshId ty rhs' imd])
| .cmd (.set name (.det expr) smd) =>
Comment thread
sagjoshi marked this conversation as resolved.
let expr' := rewriteExpr env expr
match env.get? name.name with
| some info =>
let freshId ← genSSAIdent name.name
let some mty := LTy.toMonoType? info.ty
| throw (Strata.DiagnosticModel.fromMessage s!"SSA: type of '{name.name}' is not a monotype")
let env' := env.insert name.name { ident := freshId, ty := info.ty }
incrementStat s!"{Stats.renamedVars}"
return (env', [Statement.init freshId (.forAll [] mty) (.det expr') smd])
| none => throw (Strata.DiagnosticModel.fromMessage s!"SSA: variable '{name.name}' not found in environment (set)")
| .cmd (.set name .nondet smd) =>
match env.get? name.name with
| some info =>
let freshId ← genSSAIdent name.name
let some mty := LTy.toMonoType? info.ty
| throw (Strata.DiagnosticModel.fromMessage s!"SSA: type of '{name.name}' is not a monotype")
let env' := env.insert name.name { ident := freshId, ty := info.ty }
incrementStat s!"{Stats.renamedVars}"
return (env', [Statement.init freshId (.forAll [] mty) .nondet smd])
| none => throw (Strata.DiagnosticModel.fromMessage s!"SSA: variable '{name.name}' not found in environment (havoc)")
| .cmd (.assert label b amd) =>
return (env, [Statement.assert label (rewriteExpr env b) amd])
| .cmd (.assume label b amd) =>
return (env, [Statement.assume label (rewriteExpr env b) amd])
| .cmd (.cover label b cmd') =>
return (env, [Statement.cover label (rewriteExpr env b) cmd'])
| .call _ _ _ => throw (Strata.DiagnosticModel.fromMessage "SSA: unexpected call command (callElim should have run first)")

/-- Emit conditional merge inits at a join point. Only merges variables that
were in scope before the ITE (`preEnv`). The `condVar` determines which
branch's value to select; for nondet branches it is itself nondet. -/
def emitJoinMerges (condVar : Expression.Ident)
(preEnv thenEnv elseEnv : Env)
(md : Imperative.MetaData Expression) : CoreTransformM (Env × List Statement) := do
let mut env := preEnv
let mut merges : List Statement := []
-- Only iterate over variables that were in scope before the ITE.
for (origName, _) in preEnv.toList do
match phiEntry origName preEnv thenEnv elseEnv with
| none => pure ()
| some (thenId, elseId, ty) =>
let some mty := LTy.toMonoType? ty
| throw (Strata.DiagnosticModel.fromMessage s!"SSA: type of '{origName}' is not a monotype at join point")
let freshId ← genSSAPhiIdent origName
let iteExpr : Expression.Expr :=
Lambda.LExpr.ite () (createFvar condVar) (createFvar thenId) (createFvar elseId)
merges := merges ++ [Statement.init freshId (.forAll [] mty) (.det iteExpr) md]
env := env.insert origName { ident := freshId, ty := ty }
incrementStat s!"{Stats.joinPointMerges}"
return (env, merges)

/-- Initialize the SSA environment from a procedure's parameters.
Both inputs and outputs are seeded so that assignments to outputs
get tracked. After transformation, `emitOutputAssignments` writes
the final SSA values back to the original output identifiers. -/
def initEnvFromProcedure (proc : Procedure) : Env :=
let env := (proc.header.inputs : List _).foldl (fun acc (id, mty) =>
acc.insert id.name { ident := id, ty := .forAll [] mty }) {}
(proc.header.outputs : List _).foldl (fun acc (id, mty) =>
acc.insert id.name { ident := id, ty := .forAll [] mty }) env

/-- 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
Comment on lines +164 to +175
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/-- 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


mutual
partial def transformStmt (env : Env) (s : Statement) : CoreTransformM (Env × List Statement) := do
match s with
| .cmd cmd => transformCmd env cmd
| .block label stmts md =>
let (env', stmts') ← transformBlock env stmts
return (env', [Stmt.block label stmts' md])
| .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)
Comment on lines +184 to +198
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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 as assert (condVar → original_assert). This preserves VC semantics while still flattening.
  2. Don't flatten — keep the .ite intact 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 the preEnv value on the other side, which is what phiEntry already 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

| .nondet =>
let condVar ← genSSAIdent "nondet_cond"
let condInit := Statement.init condVar (.forAll [] LMonoTy.bool) .nondet md
let (thenEnv, thenStmts') ← transformBlock env thenStmts
let (elseEnv, elseStmts') ← transformBlock env elseStmts
let (mergedEnv, merges) ← emitJoinMerges condVar env thenEnv elseEnv md
return (mergedEnv, [condInit] ++ thenStmts' ++ elseStmts' ++ merges)
| .loop _ _ _ _ _ =>
throw (Strata.DiagnosticModel.fromMessage "SSA: unexpected loop statement (loopElim should have run first)")
| .exit _ _ =>
throw (Strata.DiagnosticModel.fromMessage "SSA: unexpected exit statement")
| .funcDecl decl md => return (env, [Stmt.funcDecl decl md])
| .typeDecl tc md => return (env, [Stmt.typeDecl tc md])

partial def transformBlock (env : Env) (stmts : List Statement)
: CoreTransformM (Env × List Statement) := do
let mut curEnv := env
let mut result : List Statement := []
for s in stmts do
let (env', stmts') ← transformStmt curEnv s
curEnv := env'
result := result ++ stmts'
return (curEnv, result)
end

/-- Transform a single procedure into SSA form. After transforming the body,
emits final assignments back to output parameters so that the procedure's
ensures clauses remain valid. -/
def transformProcedure (proc : Procedure) : CoreTransformM Procedure := do
Comment thread
sagjoshi marked this conversation as resolved.
if proc.body.isEmpty then return proc
let env := initEnvFromProcedure proc
let (finalEnv, body') ← transformBlock env proc.body
-- Emit final assignments: set each output param back from its SSA name.
let outputAssigns := emitOutputAssignments proc finalEnv MetaData.empty
return { proc with body := body' ++ outputAssigns }

/-- SSA transformation on an entire program, using CoreTransformM. -/
def ssaTransform (p : Program) : CoreTransformM (Bool × Program) := do
let decls ← p.decls.mapM fun d =>
match d with
| .proc proc md => return .proc (← transformProcedure proc) md
| other => return other
return (true, { decls := decls })
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 })


/-- SSA pipeline phase: converts procedure bodies to SSA form.

Correctness status: The transform emits final assignments to output
parameters to preserve procedure contracts. The `modelPreserving`
annotation is justified because:
- Every variable is assigned exactly once (SSA invariant)
- Output parameters receive their final SSA value via explicit set
- Phi merges only reference variables in scope before the ITE

TODO: formal proof of single-assignment, scoping, and output preservation. -/
def ssaPipelinePhase : PipelinePhase where
transform := ssaTransform
phase.name := "SSA"
phase.getValidation _ := .modelPreserving
Comment thread
sagjoshi marked this conversation as resolved.
Comment thread
sagjoshi marked this conversation as resolved.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.)


end -- public section
end SSA
end Core
4 changes: 3 additions & 1 deletion StrataMain.lean
Original file line number Diff line number Diff line change
Expand Up @@ -1137,7 +1137,7 @@ structure CommandGroup where
commonFlags : List Flag := []

private def validPasses :=
"inlineProcedures, loopElim, callElim, filterProcedures, removeIrrelevantAxioms"
"inlineProcedures, loopElim, callElim, ssa, filterProcedures, removeIrrelevantAxioms"

/-- A single transform pass together with the `--procedures`/`--functions`
that were specified immediately after it on the command line. -/
Expand Down Expand Up @@ -1212,6 +1212,8 @@ def transformCommand : Command where
passes := passes ++ [.loopElim]
| "callElim" =>
passes := passes ++ [.callElim]
| "ssa" =>
passes := passes ++ [.ssa]
| "filterProcedures" =>
if pc.procedures.isEmpty then
exitFailure "filterProcedures requires --procedures"
Expand Down
Loading
Loading