Skip to content

Commit 0cddd8f

Browse files
aqjune-awskondylidou
authored andcommitted
Parse and lift the SMT counterexamples to LExpr (strata-org#495)
This patch - Parses the output of SMT solver containing counterexample using SMTDDM, - Lifts SMTDDM.Term to SMT.Term, then to LExpr, and - Let Core.verify properly print the counterexample using the new printer of Core. To implement this, - A new `parseCategoryFromDialect` function is added to DDM. It parses a string using a specific syntax category. - A bug in SpaceSepBy making only odd'th symbols to be parsed was fixed. It seems SpaceSepBy was only used by SMTDDM. - The old CexParser.lean is erased. A new StrataTest/Languages/Core/CounterExampleLiftTest.lean test shows a handful of interesting results. --- strata-org#482 and this pull request have overlapping edits. Merging strata-org#482 does not introduce merge conflict, but tests need updates. Would be great if strata-org#482 is reviewed first! :) --- Thanks to Kiro :) By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent cf7fac0 commit 0cddd8f

File tree

17 files changed

+608
-219
lines changed

17 files changed

+608
-219
lines changed

Strata/DDM/Elab.lean

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,5 +412,57 @@ def parseStrataProgramFromDialect (dialects : LoadedDialects) (dialect : Dialect
412412
return s!"{msg} {e.pos.line}:{e.pos.column}: {← e.data.toString}\n"
413413
throw (IO.userError errMsg)
414414

415+
/--
416+
Parse a single syntax category from a loaded dialect set.
417+
418+
Unlike `parseStrataProgramFromDialect`, this targets a specific syntax
419+
category (e.g. `q\`SMTResponse.GetValueResponse`) rather than parsing a
420+
full program of `Command`s. This avoids ambiguity issues that arise when
421+
multiple categories share the same surface syntax.
422+
423+
Returns the elaborated `Operation` on success.
424+
-/
425+
def parseCategoryFromDialect
426+
(dialects : LoadedDialects) (cat : QualifiedIdent) (input : InputContext)
427+
(startPos : String.Pos.Raw := 0)
428+
(stopPos : String.Pos.Raw := input.endPos)
429+
: IO Strata.Operation := do
430+
let leanEnv ← Lean.mkEmptyEnvironment 0
431+
-- Open dialects using the same pattern as elabProgramRest: start from
432+
-- initDeclState (which has Init open) and open each dialect via
433+
-- ensureLoaded! to avoid panics from HashMap iteration order.
434+
let s := DeclState.initDeclState
435+
let s := dialects.dialects.toList.foldl (init := s) fun s d =>
436+
DeclState.ensureLoaded! s dialects d.name
437+
-- Run the category-level parser
438+
let ps := Parser.runCatParser s.tokenTable s.parserMap leanEnv input startPos stopPos cat
439+
if ps.hasError then
440+
let errMsg ← ps.allErrors.foldlM (init := "Parse errors:\n") fun msg (pos, stk, err) =>
441+
let m := Lean.mkErrorMessage input pos stk err
442+
return s!"{msg} {m.pos.line}:{m.pos.column}: {← m.data.toString}\n"
443+
throw (IO.userError errMsg)
444+
if ps.stxStack.size == 0 then
445+
throw (IO.userError "Parser returned no syntax")
446+
let stx := ps.stxStack.back
447+
-- Elaborate the parsed syntax into an Operation
448+
let ctx : ElabContext := {
449+
dialects := dialects.dialects
450+
syntaxElabs := dialects.syntaxElabMap
451+
openDialectSet := s.openDialectSet
452+
typeOrCatDeclMap := s.typeOrCatDeclMap
453+
metadataDeclMap := s.metadataDeclMap
454+
globalContext := s.globalContext
455+
inputContext := input
456+
missingImport := false
457+
}
458+
let es : ElabState := {}
459+
let (tree, es) := elabOperation (.empty s.globalContext) stx ctx es
460+
if es.errors.size > 0 then
461+
let errMsg ← es.errors.foldlM (init := "Elaboration errors:\n") fun msg e =>
462+
return s!"{msg} {e.pos.line}:{e.pos.column}: {← e.data.toString}\n"
463+
throw (IO.userError errMsg)
464+
let op := tree.info.asOp!.op
465+
return op
466+
415467
end Strata.Elab
416468
end

Strata/DDM/Elab/Core.lean

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1235,7 +1235,7 @@ partial def catElaborator (c : SyntaxCat) : TypingContext → Syntax → ElabM T
12351235
| q`Init.CommaSepBy =>
12361236
elabSeqWith c .comma "commaSepBy" (·.getSepArgs)
12371237
| q`Init.SpaceSepBy =>
1238-
elabSeqWith c .space "spaceSepBy" (·.getSepArgs)
1238+
elabSeqWith c .space "spaceSepBy" (·.getArgs)
12391239
| q`Init.SpacePrefixSepBy =>
12401240
elabSeqWith c .spacePrefix "spacePrefixSepBy" (·.getArgs)
12411241
| q`Init.NewlineSepBy =>

Strata/DDM/Format.lean

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ namespace Strata
1919
/--
2020
Check if a character is valid for starting a regular identifier.
2121
Regular identifiers must start with a letter or underscore.
22+
23+
NOTE: When updating this function, you will want to consider updating Strata/DDM/Parser.lean as well.
2224
-/
2325
private def isIdBegin (c : Char) : Bool :=
2426
c.isAlpha || c == '_' || c == '$'

Strata/DDM/Parser.lean

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,12 @@ def stringInputContext (fileName : System.FilePath) (contents : String) : InputC
117117
fileName := fileName.toString
118118
fileMap := FileMap.ofString contents
119119

120+
-- When updating this, you will want to consider updating Strata.isIdBegin in Strata/DDM/Format.lean as well.
120121
private def strataIsIdFirst (c : Char) : Bool :=
121-
c.isAlpha || c == '_'
122+
c.isAlpha || c == '_' || c == '$'
122123

123124
private def strataIsIdRest (c : Char) : Bool :=
124-
c.isAlphanum || c == '_' || c == '\'' || c == '.' || c == '?' || c == '!'
125+
c.isAlphanum || c == '_' || c == '\'' || c == '.' || c == '?' || c == '!' || c == '$'
125126

126127
private def isIdFirstOrBeginEscape (c : Char) : Bool :=
127128
strataIsIdFirst c || isIdBeginEscape c

Strata/DL/Imperative/SMTUtils.lean

Lines changed: 126 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
-/
66

77
import Strata.DL.SMT.SMT
8+
import Strata.DL.SMT.DDMTransform.Parse
9+
import Strata.DL.SMT.DDMTransform.Translate
10+
import Strata.DDM.Elab
11+
import Strata.DDM.Format
812
import Strata.DL.Imperative.PureExpr
913
import Strata.DL.Imperative.EvalContext
1014

@@ -16,16 +20,22 @@ namespace SMT
1620

1721
/--
1822
A counterexample derived from an SMT solver is a map from an identifier
19-
to a string.
23+
to an `SMT.Term`.
2024
-/
21-
abbrev CounterEx (Ident : Type) := Map Ident String
25+
abbrev CounterEx (Ident : Type) := Map Ident Strata.SMT.Term
26+
27+
/-- Render an `SMT.Term` to a string via the SMTDDM translation. -/
28+
private def termToString (t : Strata.SMT.Term) : String :=
29+
match Strata.SMTDDM.termToString t with
30+
| .ok s => s
31+
| .error _ => repr t |>.pretty
2232

2333
def CounterEx.format {Ident} [ToFormat Ident] (cex : CounterEx Ident) : Format :=
2434
match cex with
2535
| [] => ""
26-
| [(id, v)] => f!"({id}, {v})"
36+
| [(id, v)] => f!"({id}, {termToString v})"
2737
| (id, v) :: rest =>
28-
(f!"({id}, {v}) ") ++ CounterEx.format rest
38+
(f!"({id}, {termToString v}) ") ++ CounterEx.format rest
2939

3040
instance {Ident} [ToFormat Ident] : ToFormat (CounterEx Ident) where
3141
format := CounterEx.format
@@ -83,27 +93,6 @@ def getSMTId {Ident Ty} [ToFormat Ident]
8393
let key : Strata.SMT.UF := { id := var', args := [], out := ty' }
8494
.ok (E.ufs[key]!)
8595

86-
def getModel (m : String) : Except Format (List Strata.SMT.CExParser.KeyValue) := do
87-
let cex ← Strata.SMT.CExParser.parseCEx m
88-
return cex.pairs
89-
90-
def processModel {P : PureExpr} [ToFormat P.Ident]
91-
(typedVarToSMTFn : P.Ident → P.Ty → Except Format (String × Strata.SMT.TermType))
92-
(vars : List P.TypedIdent) (cexs : List Strata.SMT.CExParser.KeyValue)
93-
(E : Strata.SMT.EncoderState) : Except Format (CounterEx P.Ident) := do
94-
match vars with
95-
| [] => return []
96-
| (var, ty) :: vrest =>
97-
let id ← @getSMTId P.Ident P.Ty _ typedVarToSMTFn var ty E
98-
let value ← findCExValue id cexs
99-
let pair := (var, value)
100-
let rest ← processModel typedVarToSMTFn vrest cexs E
101-
.ok (pair :: rest)
102-
where findCExValue id cexs : Except Format String :=
103-
match cexs.find? (fun p => p.key == id) with
104-
| none => .error f!"Cannot find model for id: {id}"
105-
| some p => .ok p.value
106-
10796
def runSolver (solver : String) (args : Array String) : IO IO.Process.Output := do
10897
let output ← IO.Process.output {
10998
cmd := solver
@@ -114,9 +103,90 @@ def runSolver (solver : String) (args : Array String) : IO IO.Process.Output :=
114103
-- stdout: {repr output.stdout}"
115104
return output
116105

106+
---------------------------------------------------------------------
107+
-- SMTDDM-based parsing
108+
---------------------------------------------------------------------
109+
110+
/--
111+
Parse a verdict line ("sat", "unsat", "unknown") via the SMTResponse DDM
112+
dialect. Returns `some .sat`, `some .unsat`, `some .unknown`, or `none`
113+
on parse/conversion failure.
114+
-/
115+
private def parseVerdict (line : String) : IO (Option (Result PUnit)) := do
116+
let inputCtx := Strata.Parser.stringInputContext "solver" (line ++ "\n")
117+
let prg ←
118+
try Strata.Elab.parseStrataProgramFromDialect
119+
Strata.SMTResponseDDM.smtResponseDialects "SMTResponse" inputCtx
120+
catch _ => return none
121+
if prg.commands.isEmpty then return none
122+
let op := prg.commands[0]!
123+
match Strata.SMTResponseDDM.Command.ofAst op with
124+
| .ok (.specific_success_response _ (.ssr_check_sat _ (.csr_sat _))) => return some (.sat [])
125+
| .ok (.specific_success_response _ (.ssr_check_sat _ (.csr_unsat _))) => return some .unsat
126+
| .ok (.specific_success_response _ (.ssr_check_sat _ (.csr_unknown _))) => return some .unknown
127+
| _ => return none
128+
129+
/--
130+
Parse a `(get-value ...)` model response using the SMTResponse DDM dialect.
131+
Uses `parseCategoryFromDialect` targeting `SMTResponse.GetValueResponse`
132+
directly, which avoids the ambiguity that arises when parsing at the
133+
`Command` level.
134+
135+
Returns a list of (key-string, value-Term) pairs on success.
136+
-/
137+
private def parseModelDDM (modelStr : String) : IO (List (String × Strata.SMT.Term)) := do
138+
let inputCtx := Strata.Parser.stringInputContext "solver-model" modelStr
139+
let op ←
140+
try Strata.Elab.parseCategoryFromDialect
141+
Strata.SMTResponseDDM.smtResponseDialects q`SMTResponse.GetValueResponse inputCtx
142+
catch _ => return []
143+
match Strata.SMTResponseDDM.GetValueResponse.ofAst op with
144+
| .ok (.get_value_response _ vps) =>
145+
let pairs ← vps.val.toList.filterMapM fun vp =>
146+
match vp with
147+
| .valuation_pair _ t1 t2 => do
148+
match Strata.SMTResponseDDM.translateFromDDMTermToUntyped t2 with
149+
| .ok t2' =>
150+
return .some (Strata.SMTResponseDDM.formatArg (.op (Strata.SMTResponseDDM.Term.toAst t1)),
151+
t2')
152+
| .error _ =>
153+
-- The model has an SMT expression (e.g., (lambda ...)) which cannot
154+
-- be represented in Strata.SMT.Term. Filter out this variable from
155+
-- the model.
156+
return .none
157+
return pairs
158+
| .error _ => return []
159+
160+
/--
161+
Process a parsed model (list of key-string / value-Term pairs) against the
162+
expected variables, matching each variable's SMT-encoded name to its
163+
value in the model.
164+
-/
165+
private def processModel {P : PureExpr} [ToFormat P.Ident]
166+
(typedVarToSMTFn : P.Ident → P.Ty → Except Format (String × Strata.SMT.TermType))
167+
(vars : List P.TypedIdent) (pairs : List (String × Strata.SMT.Term))
168+
(E : Strata.SMT.EncoderState) : Except Format (CounterEx P.Ident) := do
169+
match vars with
170+
| [] => return []
171+
| (var, ty) :: vrest =>
172+
let id ← @getSMTId P.Ident P.Ty _ typedVarToSMTFn var ty E
173+
let value ← findValue id pairs
174+
let rest ← processModel typedVarToSMTFn vrest pairs E
175+
.ok ((var, value) :: rest)
176+
where findValue id pairs : Except Format Strata.SMT.Term :=
177+
match pairs.find? (fun p => p.fst == id) with
178+
| none => .error f!"Cannot find model for id: {id}"
179+
| some p => .ok p.snd
180+
117181
/--
118182
Interprets the output of SMT solver.
119183
184+
Both the verdict line (sat/unsat/unknown) and the model (when sat) are
185+
parsed using the SMTResponse DDM dialect. The verdict is parsed as a full
186+
`Command`, while the model is parsed by targeting the
187+
`SMTResponse.GetValueResponse` category directly via
188+
`parseCategoryFromDialect`.
189+
120190
When `reachCheck` is `true`, the solver output contains two verdict lines:
121191
the first is the reachability check result (are the path-condition assumptions
122192
satisfiable?), and the second is the proof check result. The reachability
@@ -128,46 +198,45 @@ def solverResult {P : PureExpr} [ToFormat P.Ident]
128198
(vars : List P.TypedIdent) (output : IO.Process.Output)
129199
(E : Strata.SMT.EncoderState) (smtsolver : String)
130200
(reachCheck : Bool := false)
131-
: Except Format (Option (Result P.Ident) × Result P.Ident) := do
201+
: IO (Except Format (Option (Result P.Ident) × Result P.Ident)) := do
132202
let stdout := output.stdout
133-
-- When reachCheck is true, the first line of stdout is the reachability
134-
-- verdict; strip it and parse it separately.
135-
let (reachResult, proofStdout) := if reachCheck then
136-
let pos := stdout.find (· == '\n')
137-
let reachVerdictStr := (stdout.extract stdout.startPos pos).trimAscii
138-
let reachResult : Result P.Ident := match reachVerdictStr with
139-
| "sat" => .sat []
140-
| "unsat" => .unsat
141-
| _ => .unknown
142-
let remaining := (stdout.extract pos stdout.endPos).drop 1
143-
(some reachResult, remaining)
203+
-- Split the next line from the remaining stdout, returning (line, rest).
204+
let splitLine (s : String) : String × String :=
205+
let pos := s.find (· == '\n')
206+
let line := (s.extract s.startPos pos).trimAscii.toString
207+
let rest := s.extract pos s.endPos
208+
(line, rest)
209+
-- When reachCheck is true, the first line is the reachability verdict.
210+
let (reachResult, proofStdout) ← if reachCheck then do
211+
let (reachLine, remaining) := splitLine stdout
212+
let reachResult : Result P.Ident ← do
213+
match ← parseVerdict reachLine with
214+
| some (.sat _) => pure (.sat [])
215+
| some .unsat => pure .unsat
216+
| _ => pure .unknown
217+
pure (some reachResult, remaining.drop 1 |>.toString)
144218
else
145-
(none, stdout)
146-
-- Parse the proof verdict from the (possibly trimmed) stdout
147-
let pos := proofStdout.find (· == '\n')
148-
let verdict := proofStdout.extract proofStdout.startPos pos |>.trimAscii
149-
let rest := proofStdout.extract pos proofStdout.endPos
150-
match verdict with
151-
| "sat" =>
152-
let rawModel ← getModel rest
153-
-- We suppress any model processing errors.
154-
-- Likely, these would be because of the suboptimal implementation
155-
-- of the model parser, which shouldn't hold back useful
156-
-- feedback (i.e., problem was `sat`) from the user.
157-
match (processModel typedVarToSMTFn vars rawModel E) with
158-
| .ok model => .ok (reachResult, .sat model)
159-
| .error _model_err => .ok (reachResult, .sat [])
160-
| "unsat" => .ok (reachResult, .unsat)
161-
| "unknown" => .ok (reachResult, .unknown)
162-
| _ =>
219+
pure (none, stdout)
220+
-- Parse the proof verdict from the (possibly trimmed) stdout.
221+
let (verdictStr, rest) := splitLine proofStdout
222+
match ← parseVerdict verdictStr with
223+
| some (.sat _) =>
224+
-- Parse model via SMTDDM targeting GetValueResponse category directly.
225+
let pairs ← parseModelDDM rest
226+
match processModel typedVarToSMTFn vars pairs E with
227+
| .ok model => return .ok (reachResult, .sat model)
228+
| .error _ => return .ok (reachResult, .sat [])
229+
| some .unsat => return .ok (reachResult, .unsat)
230+
| some .unknown => return .ok (reachResult, .unknown)
231+
| _ =>
163232
let stderr := output.stderr
164233
let hasExecError := stderr.contains "could not execute external process"
165234
let hasFileError := stderr.contains "No such file or directory"
166235
let suggestion :=
167236
if (hasExecError || hasFileError) && smtsolver == Core.defaultSolver then
168237
s!" \nEnsure {Core.defaultSolver} is on your PATH or use --solver to specify another SMT solver."
169238
else ""
170-
.error s!"stderr:{stderr}{suggestion}\nsolver stdout: {output.stdout}\n"
239+
return .error s!"stderr:{stderr}{suggestion}\nsolver stdout: {output.stdout}\n"
171240

172241
def addLocationInfo {P : PureExpr} [BEq P.Ident]
173242
(md : Imperative.MetaData P) (message : String × String)
@@ -211,7 +280,7 @@ def dischargeObligation {P : PureExpr} [ToFormat P.Ident] [BEq P.Ident]
211280
if printFilename then IO.println s!"Wrote problem to {filename}."
212281

213282
let solver_output ← runSolver smtsolver (#[filename] ++ solver_options)
214-
match solverResult typedVarToSMTFn vars solver_output estate smtsolver (reachCheck := reachCheck) with
283+
match solverResult typedVarToSMTFn vars solver_output estate smtsolver (reachCheck := reachCheck) with
215284
| .error e => return .error e
216285
| .ok (reachDecision, result) => return .ok (reachDecision, result, estate)
217286

0 commit comments

Comments
 (0)