Skip to content

Commit f519002

Browse files
shigoelclaudeMikaelMayer
authored
Separate SMT error categories: encoding, timeout, crash (#1090)
## Summary Distinguishes three error types in `VCResult` instead of a single "Implementation Error" string: - `VCError.encoding`: SMT encoding failure (Strata bug, exit code 3) - `VCError.solverTimeout`: solver exceeded time limit (exit 0 — treated as inconclusive) - `VCError.solverCrash`: solver process failed (exit code 3) Timeout detection checks z3's `"timeout"` on stdout and cvc5's `"interrupted by timeout"` on stderr. `pyAnalyzeLaurel` summary partitions timeouts separately from internal errors. `strata verify` command uses exit code 3 for internal errors (previously all non-success was exit 2). Solver invocation errors now return a `VCResult` instead of throwing `DiagnosticModel`, so remaining obligations continue processing. ### Exit code table | Code | Meaning | When | |------|---------|------| | 0 | Success / Inconclusive | All assertions pass, some inconclusive, or solver timed out | | 1 | User error | Bad input, unsupported flag | | 2 | Failures found | Assertion violations | | 3 | Internal error | SMT encoding failure or solver crash | | 4 | Known limitation | Unsupported Python construct | --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Mikael Mayer <mimayere@amazon.com>
1 parent 34920a3 commit f519002

7 files changed

Lines changed: 118 additions & 53 deletions

File tree

DiffTestCore.lean

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def checkMatch (pyRegex testStr : String) (mode : MatchMode)
108108
if vc.isSuccess then return .match
109109
else if vc.isFailure then return .noMatch
110110
else return match vc.outcome with
111-
| .error msg => .smtError s!"impl: {msg}"
111+
| .error err => .smtError s!"impl: {err}"
112112
| _ => .smtError "unknown"
113113

114114
def main (args : List String) : IO UInt32 := do

Strata/DL/Imperative/SMTUtils.lean

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,26 @@ def runSolver (solver : String) (args : Array String) : IO IO.Process.Output :=
115115
-- stdout: {repr output.stdout}"
116116
return output
117117

118+
/-- Classifies the error when the solver fails to produce a valid verdict. -/
119+
inductive SolverError where
120+
| timeout (detail : String)
121+
| crash (detail : String)
122+
123+
instance : ToString SolverError where
124+
toString
125+
| .timeout d => s!"Solver timeout: {d}"
126+
| .crash d => s!"Solver crash: {d}"
127+
128+
/-- True when the word "timeout" appears as a whitespace-delimited token
129+
in the solver's stdout or stderr. z3 emits "timeout" as a standalone
130+
line on stdout; cvc5 prints "interrupted by timeout." on stderr.
131+
Only called when verdict parsing has already failed. -/
132+
private def isTimeoutOutput (output : IO.Process.Output) : Bool :=
133+
let hasWord (s : String) := s.splitOn "\n" |>.any fun line =>
134+
line.splitOn " " |>.any fun tok =>
135+
tok.trimAscii.toString == "timeout" || tok.trimAscii.toString == "timeout."
136+
hasWord output.stdout || hasWord output.stderr
137+
118138
---------------------------------------------------------------------
119139
-- SMTDDM-based parsing
120140
---------------------------------------------------------------------
@@ -206,7 +226,7 @@ def solverResult {P : PureExpr} [ToFormat P.Ident]
206226
(vars : List P.TypedIdent) (output : IO.Process.Output)
207227
(E : Strata.SMT.EncoderState) (smtsolver : String)
208228
(satisfiabilityCheck validityCheck : Bool)
209-
: IO (Except Format (Result P.Ident × Result P.Ident)) := do
229+
: IO (Except SolverError (Result P.Ident × Result P.Ident)) := do
210230
let stdout := output.stdout
211231

212232
-- Helper to parse a single verdict and model
@@ -234,29 +254,26 @@ def solverResult {P : PureExpr} [ToFormat P.Ident]
234254
| "unknown" => return some (.unknown, skipToNextVerdict rest)
235255
| _ => return none
236256

237-
-- Parse results based on which checks are enabled
238-
match ← (if satisfiabilityCheck then parseVerdict stdout else pure (some (.unknown, stdout))) with
239-
| some (satResult, remaining) =>
240-
match ← (if validityCheck then parseVerdict remaining else pure (some (.unknown, remaining))) with
241-
| some (validityResult, _) => return .ok (satResult, validityResult)
242-
| none =>
243-
let stderr := output.stderr
244-
let hasExecError := stderr.contains "could not execute external process"
245-
let hasFileError := stderr.contains "No such file or directory"
246-
let suggestion :=
247-
if (hasExecError || hasFileError) && smtsolver == Core.defaultSolver then
248-
s!" \nEnsure {Core.defaultSolver} is on your PATH or use --solver to specify another SMT solver."
249-
else ""
250-
return .error s!"stderr:{stderr}{suggestion}\nsolver stdout: {output.stdout}\n"
251-
| none =>
257+
let mkError (output : IO.Process.Output) : SolverError :=
252258
let stderr := output.stderr
253259
let hasExecError := stderr.contains "could not execute external process"
254260
let hasFileError := stderr.contains "No such file or directory"
255261
let suggestion :=
256262
if (hasExecError || hasFileError) && smtsolver == Core.defaultSolver then
257263
s!" \nEnsure {Core.defaultSolver} is on your PATH or use --solver to specify another SMT solver."
258264
else ""
259-
return .error s!"stderr:{stderr}{suggestion}\nsolver stdout: {output.stdout}\n"
265+
let detail := s!"stderr:{stderr}{suggestion}\nsolver stdout: {output.stdout}\n"
266+
if isTimeoutOutput output then .timeout detail else .crash detail
267+
268+
-- Parse results based on which checks are enabled
269+
match ← (if satisfiabilityCheck then parseVerdict stdout else pure (some (.unknown, stdout))) with
270+
| some (satResult, remaining) =>
271+
match ← (if validityCheck then parseVerdict remaining else pure (some (.unknown, remaining))) with
272+
| some (validityResult, _) => return .ok (satResult, validityResult)
273+
| none =>
274+
return .error (mkError output)
275+
| none =>
276+
return .error (mkError output)
260277

261278
def addLocationInfo {P : PureExpr} [BEq P.Ident]
262279
(md : Imperative.MetaData P) (message : String × String)
@@ -287,7 +304,7 @@ def dischargeObligation {P : PureExpr} [ToFormat P.Ident] [BEq P.Ident]
287304
(solver_options : Array String) (printFilename : Bool)
288305
(satisfiabilityCheck validityCheck : Bool)
289306
(skipSolver : Bool := false) :
290-
IO (Except Format (Result P.Ident × Result P.Ident × Strata.SMT.EncoderState)) := do
307+
IO (Except SolverError (Result P.Ident × Result P.Ident × Strata.SMT.EncoderState)) := do
291308
let handle ← IO.FS.Handle.mk filename IO.FS.Mode.write
292309
let solver ← Strata.SMT.Solver.fileWriter handle
293310

Strata/Languages/Core/SarifOutput.lean

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,9 @@ def vcResultToSarifResult (mode : VerificationMode) (files : Map Strata.Uri Lean
115115
let ruleId := vcr.obligation.label
116116
let relatedLocations := extractRelatedLocations files vcr.obligation.metadata
117117
match vcr.outcome with
118-
| .error msg =>
118+
| .error err =>
119119
let level := .error
120-
let messageText := s!"Verification error: {msg}"
120+
let messageText := s!"Verification error: {err}"
121121
let message : Strata.Sarif.Message := { text := messageText }
122122
let locations := match extractLocation files vcr.obligation.metadata with
123123
| some loc => #[locationToSarif loc]

Strata/Languages/Core/Verifier.lean

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ def dischargeObligation
203203
(label : String)
204204
(varDefinitions : List VarDefinition := [])
205205
(varDeclarations : List VarDeclaration := [])
206-
: IO (Except Format (SMT.Result × SMT.Result × EncoderState)) := do
206+
: IO (Except Imperative.SMT.SolverError (SMT.Result × SMT.Result × EncoderState)) := do
207207
-- CVC5 requires --incremental for multiple (check-sat) commands
208208
let baseFlags := getSolverFlags options
209209
let needsIncremental := satisfiabilityCheck && validityCheck
@@ -588,13 +588,26 @@ def LExprModel.format (model : LExprModel) : Format :=
588588
instance : ToFormat LExprModel where
589589
format := LExprModel.format
590590

591+
/-- Classifies errors that prevent a verification condition from being resolved. -/
592+
inductive VCError where
593+
| encoding (msg : String)
594+
| solverTimeout (msg : String)
595+
| solverCrash (msg : String)
596+
deriving Repr, BEq
597+
598+
instance : ToString VCError where
599+
toString
600+
| .encoding msg => s!"SMT Encoding Error! {msg}"
601+
| .solverTimeout msg => s!"Solver Timeout! {msg}"
602+
| .solverCrash msg => s!"SMT Solver Crash! {msg}"
603+
591604
/--
592605
A collection of all information relevant to a verification condition's
593606
analysis.
594607
-/
595608
structure VCResult where
596609
obligation : Imperative.ProofObligation Expression
597-
outcome : Except String VCOutcome := .error "not yet computed"
610+
outcome : Except VCError VCOutcome
598611
estate : EncoderState := EncoderState.init
599612
verbose : VerboseMode := .normal
600613
checkLevel : CheckLevel := .minimal
@@ -625,7 +638,7 @@ instance : ToFormat VCResult where
625638
match r.outcome with
626639
| .error e =>
627640
let prop := r.obligation.property
628-
f!"Obligation: {r.obligation.label}\nProperty: {prop}\nResult: 🚨 Implementation Error! {e}"
641+
f!"Obligation: {r.obligation.label}\nProperty: {prop}\nResult: 🚨 {toString e}"
629642
| .ok outcome =>
630643
let modelFmt :=
631644
if r.verbose >= .models && !r.lexprModel.isEmpty then
@@ -644,7 +657,7 @@ def VCResult.formatOutcome (r : VCResult) : String :=
644657
let suffix := o.pathSummary prop r.checkLevel r.checkMode
645658
s!"{o.emoji prop r.checkLevel r.checkMode} \
646659
{o.label prop r.checkLevel r.checkMode}{suffix}"
647-
| .error e => s!"🚨 {e}"
660+
| .error e => s!"🚨 {toString e}"
648661

649662
/-- Deductive-mode success: the assertion's validity is proven (`isPass`).
650663
Includes unreachable paths (vacuously true). For bug-finding mode,
@@ -668,8 +681,13 @@ def VCResult.isUnknown (vr : VCResult) : Bool :=
668681

669682
def VCResult.isImplementationError (vr : VCResult) : Bool :=
670683
match vr.outcome with
671-
| .error _ => true
672-
| .ok _ => false
684+
| .error (.encoding _) | .error (.solverCrash _) => true
685+
| _ => false
686+
687+
def VCResult.isTimeout (vr : VCResult) : Bool :=
688+
match vr.outcome with
689+
| .error (.solverTimeout _) => true
690+
| _ => false
673691

674692
def VCResult.isNotSuccess (vcResult : Core.VCResult) :=
675693
!Core.VCResult.isSuccess vcResult
@@ -946,11 +964,18 @@ def getObligationResult (assumptionTerms : List Term) (obligationTerm : Term)
946964
assumptionTerms obligationTerm ctx satisfiabilityCheck validityCheck
947965
(label := obligation.label) (varDefinitions := varDefinitions) (varDeclarations := varDeclarations))
948966
match ans with
949-
| .error e =>
950-
dbg_trace f!"\n\nObligation {obligation.label}: SMT Solver Invocation Error!\
951-
\n\nError: {e}\
952-
{if options.verbose >= .debug then prog else ""}"
953-
.error <| DiagnosticModel.fromFormat e
967+
| .error solverError =>
968+
let vcError : VCError := match solverError with
969+
| .timeout d => .solverTimeout d
970+
| .crash d => .solverCrash d
971+
dbg_trace f!"\n\nObligation {obligation.label}: {vcError}\
972+
{if options.verbose >= VerboseMode.debug then prog else ""}"
973+
return { obligation := obligation,
974+
outcome := Except.error vcError,
975+
verbose := options.verbose,
976+
checkLevel := options.checkLevel,
977+
checkMode := options.checkMode,
978+
lexprModel := [] }
954979
| .ok (satResult, validityResult, estate) =>
955980
-- Convert unvalidated sat results to unknown when phases require validation
956981
let (adjSat, satPhaseLog) := satResult.adjustForPhases phases obligation
@@ -1043,7 +1068,7 @@ def verifySingleEnv (oblProgram : Program)
10431068
checkLevel := options.checkLevel, checkMode := options.checkMode, lexprModel := [] }
10441069
results := results.push result
10451070
peResolvedCount := peResolvedCount + 1
1046-
if result.isFailure || result.isImplementationError then
1071+
if result.isFailure || result.isImplementationError || result.isTimeout then
10471072
if options.verbose >= .debug then
10481073
let prog := f!"\n\n[DEBUG] Evaluated program:\n{Core.formatProgram p}"
10491074
dbg_trace f!"\n\nResult: {result}\n{prog}"
@@ -1058,9 +1083,8 @@ def verifySingleEnv (oblProgram : Program)
10581083
smtEncodeNs := smtEncodeNs + (t3 - t2)
10591084
match maybeTerms with
10601085
| .error err =>
1061-
let err := f!"SMT Encoding Error! " ++ err
10621086
let result := { obligation,
1063-
outcome := .error (toString err),
1087+
outcome := .error (.encoding (toString err)),
10641088
verbose := options.verbose,
10651089
checkLevel := options.checkLevel,
10661090
checkMode := options.checkMode,
@@ -1230,7 +1254,11 @@ def toDiagnosticModel (vcr : Core.VCResult)
12301254
(phases : List Core.AbstractedPhase := []) : Option DiagnosticModel :=
12311255
let fileRange := (Imperative.getFileRange vcr.obligation.metadata).getD default
12321256
match vcr.outcome with
1233-
| .error msg => some { fileRange, message := s!"analysis error: {msg}", type := DiagnosticType.StrataBug }
1257+
| .error err =>
1258+
let diagType := match err with
1259+
| .solverTimeout _ => DiagnosticType.Warning
1260+
| _ => DiagnosticType.StrataBug
1261+
some { fileRange, message := s!"analysis error: {err}", type := diagType }
12341262
| .ok outcome =>
12351263
let message? : Option String :=
12361264
if vcr.obligation.property == .cover then

StrataMain.lean

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,12 @@ All `strata` subcommands use a common exit code scheme:
5252
| 0 | Success | Analysis passed, inconclusive, or `--no-solve` completed. |
5353
| 1 | User error | Bad input: invalid arguments, malformed source, etc. |
5454
| 2 | Failures found | Analysis completed and found failures. |
55-
| 3 | Internal error | Tool bug, unexpected solver result, or translation crash. |
55+
| 3 | Internal error | SMT encoding failure, solver crash, or translation bug. |
5656
| 4 | Known limitation | Intentionally unsupported language construct. |
5757
5858
Codes 1–2 are **user-actionable** (fix the input or the code under analysis).
59-
Codes 3–4 are **tool-side** (report as a bug or wait for support). -/
59+
Codes 3–4 are **tool-side** (report as a bug or wait for support).
60+
Exit 0 covers success, inconclusive results, and solver timeouts. -/
6061

6162
namespace ExitCode
6263
def userError : UInt8 := 1
@@ -508,10 +509,15 @@ private def exitPyAnalyzeKnownLimitation {α} (message : String) : IO α := do
508509
/-- Print the final RESULT/DETAIL lines based on solver outcomes.
509510
Always called on successful pipeline completion (as opposed to the
510511
exit helpers above, which are called on early pipeline failure).
511-
Classification uses successive partitioning: implementation errors are
512-
removed first, then the classifier partitions the rest into
512+
Classification uses successive partitioning: timeouts and implementation
513+
errors are removed first, then the classifier partitions the rest into
513514
success / failure / inconclusive (guaranteeing disjointness).
514-
Unreachable count is reported as supplementary info. -/
515+
Unreachable count is reported as supplementary info.
516+
517+
Exit-code priority (highest wins):
518+
- Internal error (exit 3): encoding failures or solver crashes
519+
- Failures found (exit 2): assertion violations
520+
- Inconclusive / success / solver timeout (exit 0) -/
515521
private def printPyAnalyzeSummary (vcResults : Array Core.VCResult)
516522
(checkMode : VerificationMode := .deductive) : IO Unit := do
517523
let classifier : ResultClassifier :=
@@ -520,27 +526,34 @@ private def printPyAnalyzeSummary (vcResults : Array Core.VCResult)
520526
{ isSuccess := (·.isBugFindingSuccess)
521527
isFailure := (·.isBugFindingFailure) }
522528
| _ => {}
523-
-- 1. Partition out implementation errors (broken results, not classifiable).
524-
let (implError, classifiable) :=
529+
-- 1. Partition out implementation errors and timeouts (not classifiable).
530+
let (implError, rest1) :=
525531
vcResults.partition (fun r => r.isImplementationError || r.hasSMTError)
532+
let (timeouts, classifiable) := rest1.partition (·.isTimeout)
526533
-- 2. Successive partitioning via the classifier: success → failure → inconclusive.
527534
let (success, rest) := classifiable.partition classifier.isSuccess
528535
let (failure, inconclusive) := rest.partition classifier.isFailure
529536
-- 3. Unreachable is informational (not a separate partition).
530537
let nUnreachable := vcResults.filter (·.isUnreachable) |>.size
531538
let nImplError := implError.size
539+
let nTimeout := timeouts.size
532540
let nSuccess := success.size
533541
let nFailure := failure.size
534542
let nInconclusive := inconclusive.size
535543
let unreachableStr := if nUnreachable > 0 then s!", {nUnreachable} unreachable" else ""
536-
let implErrorStr := if nImplError > 0 then s!", {nImplError} implementation errors" else ""
537-
let counts := s!"{nSuccess} passed, {nFailure} failed, {nInconclusive} inconclusive{unreachableStr}{implErrorStr}"
544+
let implErrorStr := if nImplError > 0 then s!", {nImplError} internal errors" else ""
545+
let timeoutStr := if nTimeout > 0 then s!", {nTimeout} solver timeouts" else ""
546+
let counts := s!"{nSuccess} passed, {nFailure} failed, {nInconclusive} inconclusive{unreachableStr}{timeoutStr}{implErrorStr}"
538547
if nImplError > 0 then
539548
exitPyAnalyzeInternalError s!"An unexpected result was produced. {counts}"
540549
else if nFailure > 0 then
541550
exitPyAnalyzeFailuresFound counts
542551
else
543-
printPyAnalyzeResult (if nInconclusive > 0 then "Inconclusive" else "Analysis success") counts
552+
let label :=
553+
if nTimeout > 0 then "Solver timeout"
554+
else if nInconclusive > 0 then "Inconclusive"
555+
else "Analysis success"
556+
printPyAnalyzeResult label counts
544557

545558
private def deriveBaseName (file : String) : String :=
546559
let name := System.FilePath.fileName file |>.getD file
@@ -958,7 +971,7 @@ def laurelAnalyzeCommand : Command where
958971
| some vcResults =>
959972
IO.println s!"==== RESULTS ===="
960973
for vc in vcResults do
961-
IO.println s!"{vc.obligation.label}: {match vc.outcome with | .ok o => repr o | .error e => e}"
974+
IO.println s!"{vc.obligation.label}: {match vc.outcome with | .ok o => repr o | .error e => toString e}"
962975

963976
def laurelAnalyzeToGotoCommand : Command where
964977
name := "laurelAnalyzeToGoto"
@@ -1307,8 +1320,15 @@ def verifyCommand : Command where
13071320
else
13081321
let provedGoalCount := (vcResults.filter Core.VCResult.isSuccess).size
13091322
let failedGoalCount := (vcResults.filter Core.VCResult.isNotSuccess).size
1323+
-- Encoding failures, solver crashes, or per-check SMT errors (exit 3)
1324+
let hasImplError := vcResults.any (fun r => r.isImplementationError || r.hasSMTError)
1325+
-- Assertion violations that are not timeouts or internal errors (exit 2)
1326+
let hasFailure := vcResults.any (fun r => !r.isSuccess && !r.isTimeout && !r.isImplementationError && !r.hasSMTError)
13101327
println! f!"Finished with {provedGoalCount} goals passed, {failedGoalCount} failed."
1311-
IO.Process.exit ExitCode.failuresFound
1328+
if hasImplError then
1329+
IO.Process.exit ExitCode.internalError
1330+
else if hasFailure then
1331+
IO.Process.exit ExitCode.failuresFound
13121332

13131333
def pyInterpretCommand : Command where
13141334
name := "pyInterpret"

StrataTest/Languages/Core/Examples/Regex.lean

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,14 +182,14 @@ str.in.re("a", bad_re_loop(1))
182182
info:
183183
Obligation: assert_0
184184
Property: assert
185-
Result: 🚨 Implementation Error! SMT Encoding Error! Natural numbers expected as indices for re.loop.
185+
Result: 🚨 SMT Encoding Error! Natural numbers expected as indices for re.loop.
186186
Original expression: re.loop(re.range("a", "z"), 1, bvar!0)
187187
-- Errors: Unsupported construct in lexprToExpr: bvar index out of bounds: 0
188188
Context: Global scope:
189189
190190
Obligation: assert_1
191191
Property: assert
192-
Result: 🚨 Implementation Error! SMT Encoding Error! Natural numbers expected as indices for re.loop.
192+
Result: 🚨 SMT Encoding Error! Natural numbers expected as indices for re.loop.
193193
Original expression: re.loop(re.range("a", "z"), 1, bvar!0)
194194
-- Errors: Unsupported construct in lexprToExpr: bvar index out of bounds: 0
195195
Context: Global scope:

StrataTest/Languages/Core/Tests/LambdaHigherOrderTests.lean

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ spec {
139139
info:
140140
Obligation: TestLambdaApply_ensures_0
141141
Property: assert
142-
Result: 🚨 Implementation Error! SMT Encoding Error! Cannot encode function 'apply' to SMT: it has function-typed parameter(s) [f]. Higher-order functions cannot be encoded to SMT. Consider marking the function as `inline`.
142+
Result: 🚨 SMT Encoding Error! Cannot encode function 'apply' to SMT: it has function-typed parameter(s) [f]. Higher-order functions cannot be encoded to SMT. Consider marking the function as `inline`.
143143
-/
144144
#guard_msgs in
145145
#eval verify lambdaApplyNoInlinePgm (options := .quiet)
@@ -167,7 +167,7 @@ spec {
167167
/-- info:
168168
Obligation: Test_ensures_0
169169
Property: assert
170-
Result: 🚨 Implementation Error! SMT Encoding Error! Cannot encode function 'mkFn' to SMT: its body contains a lambda expression. Lambda abstractions cannot be encoded to SMT. Consider marking the function as `inline`.-/
170+
Result: 🚨 SMT Encoding Error! Cannot encode function 'mkFn' to SMT: its body contains a lambda expression. Lambda abstractions cannot be encoded to SMT. Consider marking the function as `inline`.-/
171171
#guard_msgs in
172172
#eval verify lambdaInBodyPgm (options := .quiet)
173173

@@ -253,7 +253,7 @@ spec {
253253

254254
/-- info: Obligation: Test_ensures_0
255255
Property: assert
256-
Result: 🚨 Implementation Error! SMT Encoding Error! Cannot encode lambda expression to SMT. Lambda abstractions must be eliminated (e.g., by beta-reduction) before SMT encoding.
256+
Result: 🚨 SMT Encoding Error! Cannot encode lambda expression to SMT. Lambda abstractions must be eliminated (e.g., by beta-reduction) before SMT encoding.
257257
Lambda: fun x : int => x + 1-/
258258
#guard_msgs in
259259
#eval verify lambdaInAssertPgm (options := .quiet)

0 commit comments

Comments
 (0)