Skip to content

Commit ce73bbe

Browse files
authored
feat: detailed feedback on decide tactic failure (#4674)
When the `decide` tactic fails, it can try to give hints about the failure: - It tells you which `Decidable` instances it unfolded, by making use of the diagnostics feature. - If it encounters `Eq.rec`, it gives you a hint that one of these instances was likely defined using tactics. - If it encounters `Classical.choice`, it hints that you might have classical instances in scope. - During this, it tries to process `Decidable.rec`s and matchers to pin blame on a particular instance that failed to reduce. This idea comes from discussion with Heather Macbeth [on Zulip](https://leanprover.zulipchat.com/#narrow/stream/270676-lean4/topic/Decidable.20with.20structures/near/449409870).
1 parent f0eab4b commit ce73bbe

File tree

9 files changed

+259
-86
lines changed

9 files changed

+259
-86
lines changed

src/Lean/Elab/Tactic/ElabTerm.lean

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,8 @@ def elabAsFVar (stx : Syntax) (userName? : Option Name := none) : TacticM FVarId
359359
| _ => throwUnsupportedSyntax
360360

361361
/--
362-
Make sure `expectedType` does not contain free and metavariables.
363-
It applies zeta and zetaDelta-reduction to eliminate let-free-vars.
362+
Make sure `expectedType` does not contain free and metavariables.
363+
It applies zeta and zetaDelta-reduction to eliminate let-free-vars.
364364
-/
365365
private def preprocessPropToDecide (expectedType : Expr) : TermElabM Expr := do
366366
let mut expectedType ← instantiateMVars expectedType
@@ -370,31 +370,95 @@ private def preprocessPropToDecide (expectedType : Expr) : TermElabM Expr := do
370370
throwError "expected type must not contain free or meta variables{indentExpr expectedType}"
371371
return expectedType
372372

373+
/--
374+
Given the decidable instance `inst`, reduces it and returns a decidable instance expression
375+
in whnf that can be regarded as the reason for the failure of `inst` to fully reduce.
376+
-/
377+
private partial def blameDecideReductionFailure (inst : Expr) : MetaM Expr := do
378+
let inst ← whnf inst
379+
-- If it's the Decidable recursor, then blame the major premise.
380+
if inst.isAppOfArity ``Decidable.rec 5 then
381+
return ← blameDecideReductionFailure inst.appArg!
382+
-- If it is a matcher, look for a discriminant that's a Decidable instance to blame.
383+
if let .const c _ := inst.getAppFn then
384+
if let some info ← getMatcherInfo? c then
385+
if inst.getAppNumArgs == info.arity then
386+
let args := inst.getAppArgs
387+
for i in [0:info.numDiscrs] do
388+
let inst' := args[info.numParams + 1 + i]!
389+
if (← Meta.isClass? (← inferType inst')) == ``Decidable then
390+
let inst'' ← whnf inst'
391+
if !(inst''.isAppOf ``isTrue || inst''.isAppOf ``isFalse) then
392+
return ← blameDecideReductionFailure inst''
393+
return inst
394+
373395
@[builtin_tactic Lean.Parser.Tactic.decide] def evalDecide : Tactic := fun _ =>
374396
closeMainGoalUsing fun expectedType => do
375397
let expectedType ← preprocessPropToDecide expectedType
376398
let d ← mkDecide expectedType
377399
let d ← instantiateMVars d
378400
-- Get instance from `d`
379401
let s := d.appArg!
380-
-- Reduce the instance rather than `d` itself, since that gives a nicer error message on failure.
402+
-- Reduce the instance rather than `d` itself for diagnostics purposes.
381403
let r ← withAtLeastTransparency .default <| whnf s
382-
if r.isAppOf ``isFalse then
383-
throwError "\
384-
tactic 'decide' proved that the proposition\
385-
{indentExpr expectedType}\n\
386-
is false"
387-
unless r.isAppOf ``isTrue do
388-
throwError "\
389-
tactic 'decide' failed for proposition\
390-
{indentExpr expectedType}\n\
391-
since its 'Decidable' instance reduced to\
392-
{indentExpr r}\n\
393-
rather than to the 'isTrue' constructor."
394-
-- While we have a proof from reduction, we do not embed it in the proof term,
395-
-- but rather we let the kernel recompute it during type checking from a more efficient term.
396-
let rflPrf ← mkEqRefl (toExpr true)
397-
return mkApp3 (Lean.mkConst ``of_decide_eq_true) expectedType s rflPrf
404+
if r.isAppOf ``isTrue then
405+
-- Success!
406+
-- While we have a proof from reduction, we do not embed it in the proof term,
407+
-- and instead we let the kernel recompute it during type checking from the following more efficient term.
408+
let rflPrf ← mkEqRefl (toExpr true)
409+
return mkApp3 (Lean.mkConst ``of_decide_eq_true) expectedType s rflPrf
410+
else
411+
-- Diagnose the failure, lazily so that there is no performance impact if `decide` isn't being used interactively.
412+
throwError MessageData.ofLazyM (es := #[expectedType]) do
413+
if r.isAppOf ``isFalse then
414+
return m!"\
415+
tactic 'decide' proved that the proposition\
416+
{indentExpr expectedType}\n\
417+
is false"
418+
-- Re-reduce the instance and collect diagnostics, to get all unfolded Decidable instances
419+
let (reason, unfoldedInsts) ← withoutModifyingState <| withOptions (fun opt => diagnostics.set opt true) do
420+
modifyDiag (fun _ => {})
421+
let reason ← withAtLeastTransparency .default <| blameDecideReductionFailure s
422+
let unfolded := (← get).diag.unfoldCounter.foldl (init := #[]) fun cs n _ => cs.push n
423+
let unfoldedInsts ← unfolded |>.qsort Name.lt |>.filterMapM fun n => do
424+
let e ← mkConstWithLevelParams n
425+
if (← Meta.isClass? (← inferType e)) == ``Decidable then
426+
return m!"'{MessageData.ofConst e}'"
427+
else
428+
return none
429+
return (reason, unfoldedInsts)
430+
let stuckMsg :=
431+
if unfoldedInsts.isEmpty then
432+
m!"Reduction got stuck at the '{MessageData.ofConstName ``Decidable}' instance{indentExpr reason}"
433+
else
434+
let instances := if unfoldedInsts.size == 1 then "instance" else "instances"
435+
m!"After unfolding the {instances} {MessageData.andList unfoldedInsts.toList}, \
436+
reduction got stuck at the '{MessageData.ofConstName ``Decidable}' instance{indentExpr reason}"
437+
let hint :=
438+
if reason.isAppOf ``Eq.rec then
439+
m!"\n\n\
440+
Hint: Reduction got stuck on '▸' ({MessageData.ofConstName ``Eq.rec}), \
441+
which suggests that one of the '{MessageData.ofConstName ``Decidable}' instances is defined using tactics such as 'rw' or 'simp'. \
442+
To avoid tactics, make use of functions such as \
443+
'{MessageData.ofConstName ``inferInstanceAs}' or '{MessageData.ofConstName ``decidable_of_decidable_of_iff}' \
444+
to alter a proposition."
445+
else if reason.isAppOf ``Classical.choice then
446+
m!"\n\n\
447+
Hint: Reduction got stuck on '{MessageData.ofConstName ``Classical.choice}', \
448+
which indicates that a '{MessageData.ofConstName ``Decidable}' instance \
449+
is defined using classical reasoning, proving an instance exists rather than giving a concrete construction. \
450+
The 'decide' tactic works by evaluating a decision procedure via reduction, and it cannot make progress with such instances. \
451+
This can occur due to the 'opened scoped Classical' command, which enables the instance \
452+
'{MessageData.ofConstName ``Classical.propDecidable}'."
453+
else
454+
MessageData.nil
455+
return m!"\
456+
tactic 'decide' failed for proposition\
457+
{indentExpr expectedType}\n\
458+
since its '{MessageData.ofConstName ``Decidable}' instance\
459+
{indentExpr s}\n\
460+
did not reduce to '{MessageData.ofConstName ``isTrue}' or '{MessageData.ofConstName ``isFalse}'.\n\n\
461+
{stuckMsg}{hint}"
398462

399463
private def mkNativeAuxDecl (baseName : Name) (type value : Expr) : TermElabM Name := do
400464
let auxName ← Term.mkAuxName baseName

src/Lean/PrettyPrinter.lean

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,17 +128,32 @@ def ofLazyM (f : MetaM MessageData) (es : Array Expr := #[]) : MessageData :=
128128
instantiateMVarsCore mvarctxt a |>.1.hasSyntheticSorry
129129
))
130130

131-
/-- Pretty print a const expression using `delabConst` and generate terminfo.
131+
/--
132+
Pretty print a const expression using `delabConst` and generate terminfo.
132133
This function avoids inserting `@` if the constant is for a function whose first
133134
argument is implicit, which is what the default `toMessageData` for `Expr` does.
134-
Panics if `e` is not a constant. -/
135+
Panics if `e` is not a constant.
136+
-/
135137
def ofConst (e : Expr) : MessageData :=
136138
if e.isConst then
137139
let delab : Delab := withOptionAtCurrPos `pp.tagAppFns true delabConst
138140
.ofFormatWithInfosM (PrettyPrinter.ppExprWithInfos (delab := delab) e)
139141
else
140142
panic! "not a constant"
141143

144+
/--
145+
Pretty print a constant given its name, similar to `Lean.MessageData.ofConst`.
146+
Uses the constant's universe level parameters when pretty printing.
147+
If there is no such constant in the environment, the name is simply formatted.
148+
-/
149+
def ofConstName (constName : Name) : MessageData :=
150+
.ofFormatWithInfosM do
151+
if let some info := (← getEnv).find? constName then
152+
let delab : Delab := withOptionAtCurrPos `pp.tagAppFns true delabConst
153+
PrettyPrinter.ppExprWithInfos (delab := delab) (.const constName <| info.levelParams.map mkLevelParam)
154+
else
155+
return format constName
156+
142157
/-- Generates `MessageData` for a declaration `c` as `c.{<levels>} <params> : <type>`, with terminfo. -/
143158
def signature (c : Name) : MessageData :=
144159
.ofFormatWithInfosM (PrettyPrinter.ppSignature c)

tests/lean/2161.lean

Lines changed: 0 additions & 22 deletions
This file was deleted.

tests/lean/2161.lean.expected.out

Lines changed: 0 additions & 12 deletions
This file was deleted.

tests/lean/decideTactic.lean

Lines changed: 0 additions & 21 deletions
This file was deleted.

tests/lean/decideTactic.lean.expected.out

Lines changed: 0 additions & 8 deletions
This file was deleted.

tests/lean/run/2161.lean

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
structure Foo where num : Nat deriving DecidableEq
2+
3+
namespace Foo
4+
5+
instance : OfNat Foo n := ⟨⟨n⟩⟩
6+
7+
/-! # Example 1 -/
8+
9+
@[irreducible] def mul (a b : Foo) : Foo :=
10+
let d := Nat.gcd a.num 1
11+
⟨(a.num.div d) * (b.num.div d)⟩
12+
13+
-- should fail fast; exact heartbeat count at time of writing is 31
14+
set_option maxHeartbeats 310
15+
/--
16+
error: tactic 'decide' failed for proposition
17+
((mul 4 1).mul 1).mul 1 = 4
18+
since its 'Decidable' instance
19+
instDecidableEqFoo (((mul 4 1).mul 1).mul 1) 4
20+
did not reduce to 'isTrue' or 'isFalse'.
21+
22+
After unfolding the instances 'decEqFoo✝', 'instDecidableEqFoo', 'instDecidableEqNat' and
23+
'Nat.decEq', reduction got stuck at the 'Decidable' instance
24+
match h : (((mul 4 1).mul 1).mul 1).num.beq 4 with
25+
| true => isTrue ⋯
26+
| false => isFalse ⋯
27+
-/
28+
#guard_msgs in
29+
example : ((Foo.mul 4 1).mul 1).mul 1 = 4 := by decide
30+
31+
/-! # Example 2 -/
32+
33+
@[irreducible] def add (a b : Foo) : Foo := ⟨a.num * b.num⟩
34+
35+
-- should not succeed (and fail fast); exact heartbeat count at time of writing is 21
36+
/--
37+
error: tactic 'decide' failed for proposition
38+
((add 4 1).add 1).add 1 = 4
39+
since its 'Decidable' instance
40+
instDecidableEqFoo (((add 4 1).add 1).add 1) 4
41+
did not reduce to 'isTrue' or 'isFalse'.
42+
43+
After unfolding the instances 'decEqFoo✝', 'instDecidableEqFoo', 'instDecidableEqNat' and
44+
'Nat.decEq', reduction got stuck at the 'Decidable' instance
45+
match h : (((add 4 1).add 1).add 1).num.beq 4 with
46+
| true => isTrue ⋯
47+
| false => isFalse ⋯
48+
-/
49+
#guard_msgs in
50+
example : ((Foo.add 4 1).add 1).add 1 = 4 := by decide

tests/lean/run/4644.lean

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@ example: check_sorted #[0, 3, 3, 5, 8, 10, 10, 10] := by
1717
/--
1818
error: tactic 'decide' failed for proposition
1919
check_sorted #[0, 3, 3, 5, 8, 10, 10, 10] = true
20-
since its 'Decidable' instance reduced to
20+
since its 'Decidable' instance
21+
instDecidableEqBool (check_sorted #[0, 3, 3, 5, 8, 10, 10, 10]) true
22+
did not reduce to 'isTrue' or 'isFalse'.
23+
24+
After unfolding the instances 'instDecidableEqBool' and 'Bool.decEq', reduction got stuck at the 'Decidable' instance
2125
match check_sorted #[0, 3, 3, 5, 8, 10, 10, 10], true with
2226
| false, false => isTrue ⋯
2327
| false, true => isFalse ⋯
2428
| true, false => isFalse ⋯
2529
| true, true => isTrue ⋯
26-
rather than to the 'isTrue' constructor.
2730
-/
2831
#guard_msgs in
2932
example: check_sorted #[0, 3, 3, 5, 8, 10, 10, 10] := by

0 commit comments

Comments
 (0)