Skip to content

Commit 751ea34

Browse files
abdoo8080claude
authored andcommitted
feat: support get-model and print SMT-LIB models
Parse the `(get-model)` command and return a flag from the parser indicating whether a model should be printed. When the query is satisfiable, print a model following SMT-LIB `get-model` semantics: one `define-fun` per user-declared symbol (`declare-const`/`declare-fun`), excluding defined symbols. Since `bv_decide`'s counterexample may be partial, missing constants are completed with all-zeros for bitvectors and `false` for booleans; booleans are decoded from their `BitVec.ofBool` reflection. Implemented on both the kernel and no-kernel paths. Along the way: - Make `smtSymbolToName` keep the exact symbol string (`Name.mkSimple`) instead of splitting on `.`, so symbols round-trip faithfully and distinct symbols such as `a.0` and `a.00` are no longer conflated; `formatSmtSymbol` reads the string back directly. - Teach `introsP` to step over leading `define-sort` `let` binders so declared constants are reached (and shared with `bv_decide`), and unfold sort aliases when printing. - Restore the spurious-counterexample check lost when switching to `bvDecide'`: a shared `reportCounterExample` reports `sat`/model for genuine counterexamples and errors (exit 1) for spurious ones, consistently on both paths. - Add `Test/Model.lean` covering symbol round-tripping, bitvector literal formatting, model printing (bitvecs, booleans, completion, sort aliases), and declared-vs-defined symbol selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9423f50 commit 751ea34

6 files changed

Lines changed: 332 additions & 56 deletions

File tree

Leanwuzla/Basic.lean

Lines changed: 158 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,168 @@
1-
import Lean.Meta.Tactic.BVDecide
1+
module
2+
3+
public import Lean.Expr
4+
public import Lean.Meta.Basic
5+
public import Lean.Meta.Tactic.BVDecide.Counterexample
6+
public import Std.Tactic.BVDecide.Bitblast.BVExpr.Basic
7+
public import Std.Tactic.BVDecide.Syntax
8+
import all Lean.Meta.Tactic.BVDecide.Counterexample
9+
210

311
open Lean
12+
open Std.Tactic.BVDecide
13+
14+
/--
15+
Render a `width`-bit bitvector with the given numeric `value` as an SMT-LIB
16+
binary literal (e.g. `#b00000001`).
17+
-/
18+
public def formatBitVecLiteral (value : Nat) (width : Nat) : String := Id.run do
19+
let mut s := "#b"
20+
for i in [0:width] do
21+
let bit := (value >>> (width - 1 - i)) &&& 1
22+
s := s.push (if bit == 1 then '1' else '0')
23+
return s
24+
25+
/--
26+
Render a Lean `Name` as an SMT-LIB symbol, quoting it with `|...|` if it is not
27+
a valid simple symbol.
28+
-/
29+
public def formatSmtSymbol (n : Name) : String :=
30+
-- `smtSymbolToName` builds every symbol as a single-component name holding the
31+
-- exact string, so we read it back directly rather than relying on
32+
-- `Name.toString`'s escaping/pseudo-syntax handling.
33+
let s := match n with
34+
| .mkSimple s => s
35+
| _ => n.toString (escape := false)
36+
let isSimpleChar c := Char.isAlphanum c || "~!@$%^&*_-+=<>.?/".contains c
37+
let needsQuote :=
38+
match s.toList with
39+
| [] => true
40+
| c :: cs => c.isDigit || !(c :: cs).all isSimpleChar
41+
if needsQuote then "|" ++ s ++ "|" else s
42+
43+
/--
44+
Unfold a chain of `let`-bound sort aliases (introduced by `define-sort`) to the
45+
underlying sort.
46+
-/
47+
private partial def resolveSortAlias (e : Expr) : MetaM Expr := do
48+
match e with
49+
| .fvar fvarId =>
50+
match ← fvarId.getDecl with
51+
| .ldecl (value := v) .. => resolveSortAlias v
52+
| _ => return e
53+
| _ => return e
454

5-
private partial def getIntrosSize (e : Expr) : Nat :=
6-
go 0 e
55+
/--
56+
Print a model for a satisfiable query following SMT-LIB `get-model` semantics.
57+
58+
`fvars` are the free variables corresponding to the SMT-LIB declared constants
59+
(in declaration order) and `equations` is the (possibly partial) assignment
60+
found by `bv_decide`. Booleans are reflected as one-bit bitvectors, so a boolean
61+
constant `b` shows up in `equations` as `BitVec.ofBool b`. Since `bv_decide`'s
62+
counterexample is not guaranteed to assign every declared constant, any constant
63+
missing from `equations` is completed with all-zeros for bitvectors and `false`
64+
for booleans.
65+
66+
Must be run in a context in which `fvars` are valid (e.g. inside the goal's
67+
`withContext`).
68+
-/
69+
public def printModel (fvars : Array FVarId) (equations : Array (Expr × BVExpr.PackedBitVec)) :
70+
MetaM Unit := do
71+
-- Index the values found by `bv_decide` by the free variable they assign.
72+
-- Bitvector constants appear directly as free variables, whereas boolean
73+
-- constants `b` appear wrapped as `BitVec.ofBool b` with a one-bit value.
74+
let mut values : Std.HashMap FVarId Nat := {}
75+
for (e, pv) in equations do
76+
if e.isFVar then
77+
values := values.insert e.fvarId! pv.bv.toNat
78+
else if let .app (.const ``BitVec.ofBool []) x := e then
79+
if x.isFVar then
80+
values := values.insert x.fvarId! pv.bv.toNat
81+
let mut lines : Array String := #[]
82+
for fvar in fvars do
83+
let decl ← fvar.getDecl
84+
let sym := formatSmtSymbol decl.userName
85+
-- A declared constant may have a `define-sort` alias as its type, in which
86+
-- case it is a `let`-bound free variable that we unfold to its definition.
87+
match ← resolveSortAlias decl.type with
88+
| .const ``Bool [] =>
89+
-- A missing assignment is completed with `false`.
90+
let value := values.getD fvar 0 == 1
91+
lines := lines.push s!" (define-fun {sym} () Bool {value})"
92+
| .app (.const ``BitVec []) we =>
93+
let some w := we.nat? | continue
94+
let value := values.getD fvar 0
95+
lines := lines.push
96+
s!" (define-fun {sym} () (_ BitVec {w}) {formatBitVecLiteral value w})"
97+
| _ =>
98+
-- Skip declarations outside the supported QF_BV fragment (e.g. functions
99+
-- with arguments), which cannot appear in a counterexample anyway.
100+
continue
101+
let model :=
102+
if lines.isEmpty then "(\n)"
103+
else "(\n" ++ String.intercalate "\n" lines.toList ++ "\n)"
104+
logInfo model
105+
106+
open Lean.Meta.Tactic.BVDecide in
107+
/--
108+
Report the outcome of a satisfiable query from a `bv_decide` counterexample.
109+
110+
If the counterexample is genuine, print `sat` (and, when `getModel` is set, the
111+
model) and return exit code `0`. If it is *spurious* -- i.e. `bv_decide`
112+
abstracted an unsupported subterm as an opaque variable, or did not use a
113+
relevant hypothesis, so the assignment may not actually satisfy the problem --
114+
report it as an error and return exit code `1`, mirroring `bvDecide`.
115+
-/
116+
public def reportCounterExample (fvars : Array FVarId) (getModel : Bool)
117+
(counterExample : CounterExample) : MetaM UInt8 := do
118+
let diagnosis ← DiagnosisM.run DiagnosisM.diagnose counterExample
119+
if diagnosis.uninterpretedSymbols.isEmpty && diagnosis.unusedRelevantHypotheses.isEmpty then
120+
logInfo "sat"
121+
if getModel then
122+
printModel fvars counterExample.equations
123+
return (0 : UInt8)
124+
else
125+
logError (← addMessageContextFull (← explainCounterExampleQuality counterExample))
126+
return (1 : UInt8)
127+
128+
/--
129+
Count the leading `let` binders (introduced by `define-sort`) followed by the
130+
`forall` binders (introduced by `declare-fun`/`declare-const`) of `e`. Traversal
131+
stops at the first `let` following the foralls, which corresponds to the
132+
`define-fun`/`define-const` bindings of the body. Returns the number of leading
133+
`let`s and the number of following `forall`s, respectively.
134+
-/
135+
private partial def getIntrosSize (e : Expr) : Nat × Nat :=
136+
goLets 0 e
7137
where
8-
go (size : Nat) : Expr → Nat
9-
| .forallE _ _ b _ => go (size + 1) b
10-
| .mdata _ b => go size b
11-
| _ => size
138+
goLets (lets : Nat) : Expr → Nat × Nat
139+
| .letE _ _ _ b _ => goLets (lets + 1) b
140+
| .mdata _ b => goLets lets b
141+
| e => (lets, goForalls 0 e)
142+
goForalls (foralls : Nat) : Expr → Nat
143+
| .forallE _ _ b _ => goForalls (foralls + 1) b
144+
| .mdata _ b => goForalls foralls b
145+
| _ => foralls
12146

13147
/--
14-
Introduce only forall binders and preserve names.
148+
Introduce the leading `define-sort` `let` binders together with the
149+
`declare-fun`/`declare-const` `forall` binders, preserving names. Returns the
150+
free variables corresponding to the declared symbols, i.e. those coming from the
151+
`forall` binders only (the introduced sort definitions are excluded).
15152
-/
16-
def _root_.Lean.MVarId.introsP (mvarId : MVarId) : MetaM (Array FVarId × MVarId) := do
153+
public def _root_.Lean.MVarId.introsP (mvarId : MVarId) : MetaM (Array FVarId × MVarId) := do
17154
let type ← mvarId.getType
18155
let type ← instantiateMVars type
19-
let n := getIntrosSize type
20-
if n == 0 then
156+
let (numLets, numForalls) := getIntrosSize type
157+
if numLets + numForalls == 0 then
21158
return (#[], mvarId)
22159
else
23-
mvarId.introNP n
160+
let (fvars, mvarId) ← mvarId.introNP (numLets + numForalls)
161+
-- Drop the leading sort definitions; keep only the declared symbols.
162+
return (fvars.extract numLets fvars.size, mvarId)
24163

25164
open Meta.Tactic.BVDecide in
26-
structure Context where
165+
public structure Context where
27166
acNf : Bool
28167
parseOnly : Bool
29168
timeout : Nat
@@ -34,15 +173,15 @@ structure Context where
34173
disableKernel : Bool
35174
solverMode : Elab.Tactic.BVDecide.SolverMode
36175

37-
abbrev SolverM := ReaderT Context MetaM
176+
public abbrev SolverM := ReaderT Context MetaM
38177

39178
namespace SolverM
40179

41-
def getParseOnly : SolverM Bool := return (← read).parseOnly
42-
def getInput : SolverM String := return (← read).input
43-
def getKernelDisabled : SolverM Bool := return (← read).disableKernel
180+
public def getParseOnly : SolverM Bool := return (← read).parseOnly
181+
public def getInput : SolverM String := return (← read).input
182+
public def getKernelDisabled : SolverM Bool := return (← read).disableKernel
44183

45-
def getBVDecideConfig : SolverM Elab.Tactic.BVDecide.BVDecideConfig := do
184+
public def getBVDecideConfig : SolverM Elab.Tactic.BVDecide.BVDecideConfig := do
46185
let ctx ← read
47186
return {
48187
timeout := ctx.timeout
@@ -56,7 +195,7 @@ def getBVDecideConfig : SolverM Elab.Tactic.BVDecide.BVDecideConfig := do
56195
solverMode := ctx.solverMode
57196
}
58197

59-
def run (x : SolverM α) (ctx : Context) (coreContext : Core.Context) (coreState : Core.State) :
198+
public def run (x : SolverM α) (ctx : Context) (coreContext : Core.Context) (coreState : Core.State) :
60199
IO α := do
61200
let (res, _, _) ← ReaderT.run x ctx |> (Meta.MetaM.toIO · coreContext coreState)
62201
return res

Leanwuzla/NoKernel.lean

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import Leanwuzla.Basic
1+
module
2+
3+
public import Leanwuzla.Basic
4+
import all Lean.Meta.Tactic.BVDecide
5+
26

37
open Lean Std.Sat Std.Tactic.BVDecide
48
open Meta.Tactic.BVDecide
@@ -24,17 +28,24 @@ def runSolver (cnf : CNF Nat) (solver : System.FilePath) (lratPath : System.File
2428

2529
return .ok lratProof
2630

27-
def decideSmtNoKernel (type : Expr) : SolverM UInt8 := do
31+
public def decideSmtNoKernel (type : Expr) (getModel : Bool) : SolverM UInt8 := do
2832
let solver ← determineSolver
2933
let g := (← Meta.mkFreshExprMVar type).mvarId!
30-
let (_, g) ← g.introsP
34+
let (fvars, g) ← g.introsP
3135
trace[Meta.Tactic.bv] m!"Working on goal: {g}"
3236
try
3337
g.withContext $ IO.FS.withTempFile fun _ lratPath => do
3438
let cfg ← SolverM.getBVDecideConfig
3539
match ← Normalize.bvNormalize g cfg with
3640
| some g =>
37-
let bvExpr := (← M.run <| reflectBV g).bvExpr
41+
-- Reflect the goal and, at the same time, record the atom assignment so
42+
-- that we can reconstruct a model if the query turns out to be sat.
43+
let (bvExpr, atomsAssignment, unusedHypotheses) ← M.run do
44+
let reflectionResult ← reflectBV g
45+
let flipper := fun (expr, {width, atomNumber, synthetic}) =>
46+
(atomNumber, (width, expr, synthetic))
47+
let atomsAssignment := Std.HashMap.ofList ((← getThe State).atoms.toList.map flipper)
48+
return (reflectionResult.bvExpr, atomsAssignment, reflectionResult.unusedHypotheses)
3849

3950
let entry ←
4051
withTraceNode `bv (fun _ => return "Bitblasting BVLogicalExpr to AIG") do
@@ -43,7 +54,7 @@ def decideSmtNoKernel (type : Expr) : SolverM UInt8 := do
4354
let aigSize := entry.aig.decls.size
4455
trace[Meta.Tactic.bv] s!"AIG has {aigSize} nodes."
4556

46-
let (cnf, _) ←
57+
let (cnf, map) ←
4758
withTraceNode `sat (fun _ => return "Converting AIG to CNF") do
4859
-- lazyPure to prevent compiler lifting
4960
IO.lazyPure (fun _ =>
@@ -68,19 +79,23 @@ def decideSmtNoKernel (type : Expr) : SolverM UInt8 := do
6879
else
6980
logInfo "Error: Failed to check LRAT cert"
7081
return (1 : UInt8)
71-
| .error .. =>
72-
logInfo "sat"
73-
return (0 : UInt8)
82+
| .error assignment =>
83+
let equations := reconstructCounterExample map assignment aigSize atomsAssignment
84+
reportCounterExample fvars getModel { goal := g, unusedHypotheses, equations }
7485
| none =>
7586
logInfo "unsat"
7687
return (0 : UInt8)
7788
catch e =>
7889
-- TODO: improve handling of sat cases. This is a temporary workaround.
7990
let message ← e.toMessageData.toString
8091
if message.startsWith "None of the hypotheses are in the supported BitVec fragment" then
81-
-- We fully support SMT-LIB v2.6. Getting the above error message means
82-
-- the goal was reduced to `False` with only `True` as an assumption.
92+
-- We fully support SMT-LIB v2.6. Getting the above error message means the
93+
-- goal was reduced to `False` with only `True` as an assumption. Every
94+
-- declared constant is then unconstrained, so the model completed entirely
95+
-- with default values is a valid one.
8396
logInfo "sat"
97+
if getModel then
98+
g.withContext do printModel fvars #[]
8499
return (0 : UInt8)
85100
else
86101
logError m!"Error: {e.toMessageData}"

Leanwuzla/Parser.lean

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,12 @@ private def mkBitVecShiftRight (w : Nat) : Expr :=
110110

111111
def smtSymbolToName (s : String) : Name :=
112112
let s := if s.startsWith "|" && s.endsWith "|" then String.Pos.Raw.extract s (s.rawStartPos + '|') (s.rawEndPos - '|') else s
113-
-- Quote the string if a natural translation to Name fails
114-
if s.toName == .anonymous then
115-
Name.mkSimple s
116-
else
117-
s.toName
113+
-- SMT-LIB symbols are flat (non-hierarchical) strings, so we always build a
114+
-- single-component name rather than splitting on `.`. This keeps the exact
115+
-- symbol intact -- so it can be printed back faithfully (e.g. in models, see
116+
-- `formatSmtSymbol`) -- and avoids conflating distinct symbols that only
117+
-- differ in a way `String.toName` normalizes away, such as `a.0` and `a.00`.
118+
Name.mkSimple s
118119

119120
/-- Returns two types: the first is the canonical type and the second is the
120121
user-provided one (mainly for pretty-printing). -/
@@ -562,6 +563,9 @@ structure Query where
562563
funDecls : List Sexp := []
563564
funDefs : List Sexp := []
564565
asserts : List Sexp := []
566+
/-- Whether the query contains a `(get-model)` command, in which case a model
567+
should be printed when the query is satisfiable. -/
568+
getModel : Bool := false
565569

566570
def parseQuery (query : Query) : ParserM Expr := do
567571
withTypeDefs query.typeDefs <| withFunDecls query.funDecls <| withFunDefs query.funDefs do
@@ -587,6 +591,8 @@ where
587591
go { query with funDefs := sexp!{(define-fun {n} {ps} {s} {b})} :: query.funDefs } cmds
588592
| sexp!{(assert {p})} :: cmds =>
589593
go { query with asserts := sexp!{(assert {p})} :: query.asserts } cmds
594+
| sexp!{(get-model)} :: cmds =>
595+
go { query with getModel := true } cmds
590596
-- TODO: We should parse `(check-sat)` command. We currently return `sat` if
591597
-- `(check-sat)` command is missing.
592598
| _ :: cmds =>
@@ -595,14 +601,19 @@ where
595601
{ typeDefs := query.typeDefs.reverse
596602
funDecls := query.funDecls.reverse
597603
funDefs := query.funDefs.reverse
598-
asserts := query.asserts.reverse }
604+
asserts := query.asserts.reverse
605+
getModel := query.getModel }
599606

600-
def parseSmt2Query (query : String) : Except MessageData Expr :=
607+
/-- Parse an SMT-LIB2 query, returning the goal expression together with a flag
608+
indicating whether a model should be printed (i.e. whether the query
609+
contained a `(get-model)` command). -/
610+
def parseSmt2Query (query : String) : Except MessageData (Expr × Bool) := do
601611
match Sexp.Parser.manySexps!.run query with
602612
| Except.error e =>
603613
.error s!"{e}"
604614
| Except.ok cmds =>
605615
let query := filterCmds cmds
606-
(parseQuery query).run' {}
616+
let e ← (parseQuery query).run' {}
617+
return (e, query.getModel)
607618

608619
end Parser

0 commit comments

Comments
 (0)