Skip to content

Commit 64438dd

Browse files
authored
fix: grind? suggestions for goals with inaccessible hypothesis names (#14022)
This PR fixes `grind?` for goals containing hypotheses with inaccessible names. Distinct terms that differ only in inaccessible variables have identical anchors, so anchor references in generated tactic scripts could resolve to the wrong term during replay, producing empty `grind only` suggestions and scripts that could not close the goal. The `cases` tactic now supports anchor ordinal references (e.g., `cases #a56e/2` selects the second candidate matching the anchor), and `grind?` uses them to disambiguate colliding anchors. The fix consists of the following changes: * Add anchor ordinal references to the `cases` tactic and use them in generated scripts and `cases?` suggestions. The ordinal is resolved against the case-split candidates that are ready to be split, skipping resolved ones. * Compute the anchor reference for a case-split from the candidate list as it was before `selectNextSplit?` prunes it. The pruned list does not contain the selected candidate, so ordinals computed from it did not match the list inspected by the `cases` tactic during replay. This also fixes a latent issue where the number of anchor digits did not account for the candidate being split. * Stop expanding the number of anchor digits when two terms have identical 64-bit anchors. Expanding cannot disambiguate them, and it produced confusing 16-digit references such as `#e48b8a1c9d724d48` that were still ambiguous. * Report an issue (in verbose mode) when a local theorem anchor collides with a different local theorem. The collision is harmless for `instantiate`: all matching theorems are instantiated during replay. * Preserve parameters that mark types for case-splitting in the suggestions. A parameter such as `[EqvGen]` both adds the constructors as E-matching theorems and marks the type for case-splitting. The latter has no script representation, so dropping the parameter made `cases` steps on facts of these types fail when the suggestion was applied. The script suggestion now carries these parameters: `grind [EqvGen] => ...`. For the example in #13877, `grind?` now produces the same replayable suggestions for both the inaccessible-name and the named version of the goal: ```lean grind [EqvGen] => instantiate only [= reflTransGen_iff_eq, EqvGen.rel] instantiate only [#9242] cases #14e8 · cases #d806 <;> cases #f38a <;> cases #e48b <;> instantiate only [#9242] · instantiate only [= reflTransGen_iff_eq] cases #d806 · cases #f38a <;> cases #5525 <;> instantiate only [#3442] · cases #f38a · cases #ae98/2 · instantiate only [#3442] · cases #e48b <;> instantiate only [#9242] · cases #ae98/2 · instantiate only [#3442] · cases #e48b <;> instantiate only [#9242] ``` Closes #13877.
1 parent f92bd6a commit 64438dd

11 files changed

Lines changed: 345 additions & 52 deletions

File tree

src/Init/Grind/Interactive.lean

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,20 @@ syntax (name := showGoals) "show_goals" : grind
113113
declare_syntax_cat grind_ref (behavior := both)
114114

115115
syntax:max anchor : grind_ref
116+
/--
117+
Anchor with an ordinal disambiguator. Distinct case-split candidates may have the same anchor.
118+
For example, two candidates that differ only in inaccessible variables have identical anchors.
119+
`#a56e/2` refers to the second candidate (in the case-split candidate list) matching the anchor `#a56e`.
120+
-/
121+
syntax:max anchor noWs "/" noWs num : grind_ref
116122
syntax term : grind_ref
117123

118124
/--
119125
Performs a case-split on a logical connective, `match`-expression, `if-then-else`-expression,
120126
or inductive predicate. The argument is an anchor referencing one of the case-split candidates
121127
in the `grind` state. You can use `cases?` to select a specific candidate using a code action.
128+
If multiple candidates match the anchor (e.g., they differ only in inaccessible variables),
129+
an ordinal reference such as `#a56e/2` selects the second matching candidate.
122130
-/
123131
syntax (name := cases) "cases " grind_ref : grind
124132

src/Lean/Elab/Tactic/Grind/BuiltinTactic.lean

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -287,29 +287,63 @@ def split? (c : SplitInfo) : Action := fun goal kna kp => do
287287
Action.splitCore c numCases isRec (stopAtFirstFailure := false) (compress := false) >> Action.intros genNew >> Action.assertAll
288288
a goal kna kp
289289

290-
@[builtin_grind_tactic cases] def evalCases : GrindTactic := fun stx => do
291-
let `(grind| cases #$anchor:hexnum) := stx | throwUnsupportedSyntax
290+
@[builtin_grind_tactic cases] def evalCases : GrindTactic := fun stx => withMainContext do
291+
let (anchor, ordinal) ← match stx with
292+
| `(grind| cases #$anchor:hexnum) => pure ((anchor : TSyntax `hexnum), 1)
293+
| `(grind| cases #$anchor:hexnum/$i:num) =>
294+
if i.getNat == 0 then
295+
throwErrorAt i "invalid anchor ordinal, it must be ≥ 1"
296+
pure (anchor, i.getNat)
297+
| _ => throwUnsupportedSyntax
292298
let anchorRef ← elabAnchorRef anchor
299+
let c? ← liftGoalM do
300+
let mut remaining := ordinal
301+
let mut firstMatch? := none
302+
for c in (← get).split.candidates do
303+
let a ← c.getAnchor
304+
if anchorRef.matches a then
305+
if firstMatch?.isNone then
306+
firstMatch? := some c
307+
if (← checkSplitStatus c) matches .ready .. then
308+
remaining := remaining - 1
309+
if remaining == 0 then
310+
return some c
311+
if ordinal == 1 then
312+
/-
313+
**Note**: Fall back to a candidate that is not ready so that `split?` can
314+
produce a more informative error message.
315+
-/
316+
return firstMatch?
317+
else
318+
return none
319+
let some c := c? | throwError "`cases` tactic failed, invalid anchor"
293320
let goal ← getMainGoal
294-
let candidates := goal.split.candidates
295-
let c ← liftGrindM <| goal.withContext do
296-
for c in candidates do
297-
let anchor ← c.getAnchor
298-
if anchorRef.matches anchor then
299-
return c
300-
throwError "`cases` tactic failed, invalid anchor"
301321
goal.withContext <| withRef anchor <| logAnchor c
302322
liftAction <| split? c
303323

304324
def mkCasesSuggestions (candidates : Array SplitCandidateWithAnchor) (numDigits : Nat) : MetaM (Array Tactic.TryThis.Suggestion) := do
305-
candidates.mapM fun { anchor, e, .. } => do
325+
let shift := (64 - 4*numDigits).toUInt64
326+
let mut counts : Std.HashMap UInt64 Nat := {}
327+
let mut suggestions := #[]
328+
for { anchor, e, .. } in candidates do
306329
let anchorStx ← mkAnchorSyntax numDigits anchor
307-
let tac ← `(grind| cases $anchorStx:anchor)
330+
/-
331+
**Note**: Distinct candidates may have identical anchors (e.g., they differ only in
332+
inaccessible variables). We use ordinal references (e.g., `#a56e/2`) to disambiguate them.
333+
-/
334+
let anchorPrefix := anchor >>> shift
335+
let ordinal := counts.getD anchorPrefix 0
336+
counts := counts.insert anchorPrefix (ordinal + 1)
337+
let tac ← if ordinal == 0 then
338+
`(grind| cases $anchorStx:anchor)
339+
else
340+
`(grind| cases $anchorStx:anchor/$(quote (ordinal + 1)):num)
308341
let msg ← addMessageContext m!"{tac} for{indentExpr e}"
309-
return {
342+
suggestions := suggestions.push {
310343
suggestion := .tsyntax tac
311344
messageData? := some msg
312345
}
346+
return suggestions
313347

314348
@[builtin_grind_tactic casesTrace] def evalCasesTrace : GrindTactic := fun stx => withMainContext do
315349
let `(grind| cases? $[$filter?]?) := stx | throwUnsupportedSyntax

src/Lean/Elab/Tactic/Grind/Main.lean

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -374,20 +374,29 @@ def evalGrindTraceCore (stx : Syntax) (trace := true) (verbose := true) (useSorr
374374
-- is a local variable) must be preserved because they produce anchors that need
375375
-- the original term to be loaded during replay.
376376
-- Non-ident terms (like `show P by tac`) need to be preserved explicitly.
377-
let termParamStxs : Array Grind.TParam ← paramStxs.filterM fun p => do
378-
match p with
379-
| `(Parser.Tactic.grindParam| $[$_:grindMod]? $id:ident) =>
380-
-- Check if this ident resolves to local variable dot notation
381-
-- If so, keep it because it's not a simple global declaration
382-
if let some (_, _ :: _) := (← resolveLocalName id.getId) then
383-
return true
384-
else
385-
return false
386-
| `(Parser.Tactic.grindParam| ! $[$_:grindMod]? $id:ident) =>
387-
if let some (_, _ :: _) := (← resolveLocalName id.getId) then
388-
return true
377+
-- Params that mark types for case-splitting (e.g., `[EqvGen]` where `EqvGen` is an
378+
-- inductive predicate, or `[cases T]`) must also be preserved: the marking is not
379+
-- representable in the generated script, and without it `cases` steps on facts of
380+
-- these types fail during replay.
381+
-- **TODO**: This syntactic filtering is a stopgap: it duplicates parameter-elaboration
382+
-- logic and silently depends on which side effects are representable in scripts.
383+
-- A more robust solution is to make the script self-contained, e.g., a script step that
384+
-- marks a type for case-splitting, and tracking which parameters were actually used.
385+
let keepIdentParam (mod? : Option (TSyntax ``Parser.Attr.grindMod)) (id : Ident) : TacticM Bool := do
386+
if let some (_, _ :: _) := (← resolveLocalName id.getId) then
387+
return true
388+
else if let some mod := mod? then
389+
return (← Grind.getAttrKindCore mod) matches .cases _
390+
else
391+
let declName? ← try pure (some (← realizeGlobalConstNoOverload id)) catch _ => pure none
392+
if let some declName := declName? then
393+
Grind.isCasesAttrCandidate declName false
389394
else
390395
return false
396+
let termParamStxs : Array Grind.TParam ← paramStxs.filterM fun p => do
397+
match p with
398+
| `(Parser.Tactic.grindParam| $[$mod?:grindMod]? $id:ident) => keepIdentParam mod? id
399+
| `(Parser.Tactic.grindParam| ! $[$mod?:grindMod]? $id:ident) => keepIdentParam mod? id
391400
| `(Parser.Tactic.grindParam| - $_:ident) => return false
392401
| `(Parser.Tactic.grindParam| #$_:hexnum) => return false
393402
| _ => return true
@@ -413,7 +422,15 @@ def evalGrindTraceCore (stx : Syntax) (trace := true) (verbose := true) (useSorr
413422
let configStx' := filterSuggestionsAndLocalsFromGrindConfig configStx
414423
let tacs ← Grind.mkGrindOnlyTactics configStx' seq termParamStxs
415424
let seq := Grind.Action.mkGrindSeq seq
416-
let tac ← `(tactic| grind $configStx':optConfig => $seq:grindSeq)
425+
/-
426+
**Note**: The script must carry the preserved parameters (e.g., types marked for
427+
case-splitting). The tactic was verified with these parameters active, and `cases`
428+
steps may fail without them.
429+
-/
430+
let tac ← if termParamStxs.isEmpty then
431+
`(tactic| grind $configStx':optConfig => $seq:grindSeq)
432+
else
433+
`(tactic| grind $configStx':optConfig [$termParamStxs,*] => $seq:grindSeq)
417434
let tacs := tacs.push tac
418435
return tacs
419436
| .stuck gs =>

src/Lean/Meta/Tactic/Grind/Anchor.lean

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,22 +97,28 @@ public class HasAnchor (α : Type u) where
9797
getAnchor : α → UInt64
9898

9999
/--
100-
Returns the number of digits needed to distinguish the anchors in `es`
100+
Returns the number of digits needed to distinguish the anchors in `es`.
101+
102+
Elements with identical (full) anchors cannot be distinguished by adding more digits.
103+
Thus, they are ignored by this function. Callers must handle these collisions separately
104+
(e.g., the `cases` tactic uses ordinal references such as `#a56e/2`).
101105
-/
102106
public def getNumDigitsForAnchors [HasAnchor α] (es : Array α) : Nat :=
103107
go 4
104108
where
105109
go (numDigits : Nat) : Nat := Id.run do
106110
if 4*numDigits < 64 then
107111
let shift := 64 - 4*numDigits
108-
let mut found : Std.HashSet UInt64 := {}
112+
let mut found : Std.HashMap UInt64 UInt64 := {}
109113
for e in es do
110114
let a := HasAnchor.getAnchor e
111115
let a' := a >>> shift.toUInt64
112-
if found.contains a' then
113-
return (← go (numDigits+1))
116+
if let some b := found[a']? then
117+
-- **Note**: if the full anchors are equal, expanding the number of digits does not help.
118+
if b != a then
119+
return (← go (numDigits+1))
114120
else
115-
found := found.insert a'
121+
found := found.insert a' a
116122
return numDigits
117123
else
118124
return numDigits

src/Lean/Meta/Tactic/Grind/CollectParams.lean

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ partial def collect (tac : TGrind) : Collect Unit := do
5959
| _ => return ()
6060
| `(grind| $tac₁:grind <;> $tac₂:grind) => collect tac₁; collect tac₂
6161
| `(grind| cases $a:anchor) => pushAnchor a
62+
| `(grind| cases $a:anchor/$_:num) => pushAnchor a
6263
| `(grind| use [$params,*]) =>
6364
collectInstantiateParams params
6465
| `(grind| instantiate $[only]? $[approx]? $[[$params?,*]]?) =>

src/Lean/Meta/Tactic/Grind/EMatchAction.lean

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ where
7979
Creates an `instantiate` tactic that takes the `usedThms` as parameters.
8080
-/
8181
def mkInstantiateTactic (goal : Goal) (usedThms : Array EMatchTheorem) (approx : Bool) : GrindM TGrind := goal.withContext do
82-
let numDigits ← getNumDigitsForLocalTheoremAnchors goal
82+
let entries ← getLocalTheoremAnchors goal
83+
let numDigits := getNumDigitsForAnchors entries
8384
let mut params : Array (TSyntax ``Parser.Tactic.Grind.thm) := #[]
8485
let mut foundFns : NameSet := {}
8586
let mut foundLocals : Std.HashSet Grind.Origin := {}
@@ -97,7 +98,15 @@ def mkInstantiateTactic (goal : Goal) (usedThms : Array EMatchTheorem) (approx :
9798
params := params.push param
9899
| _ => unless foundLocals.contains thm.origin do
99100
foundLocals := foundLocals.insert thm.origin
100-
let anchor ← getAnchor (← inferType thm.proof)
101+
let type ← inferType thm.proof
102+
let anchor ← getAnchor type
103+
/-
104+
**Note**: Distinct local theorems may have the same anchor (e.g., they differ only in
105+
inaccessible variables). This is not an error: when replaying the generated tactic,
106+
all matching theorems are instantiated. We just report an issue flagging the collision.
107+
-/
108+
if entries.any fun entry => entry.anchor == anchor && entry.e != type then
109+
reportIssue! "anchor `#{anchorToString numDigits anchor}` is ambiguous, distinct local theorems have the same anchor{indentExpr type}\nall of them are instantiated by the generated `instantiate` tactic"
101110
let param ← `(Parser.Tactic.Grind.thm| $(← mkAnchorSyntax numDigits anchor):anchor)
102111
params := params.push param
103112
match params.isEmpty, approx with

src/Lean/Meta/Tactic/Grind/Split.lean

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -281,9 +281,13 @@ structure SplitCandidateAnchors where
281281
/--
282282
Returns case-split candidates. Case-splits that are tagged as `.resolved` or `.notReady` are skipped.
283283
Applies additional `filter` if provided.
284+
If `candidates?` is provided, it is used instead of the candidates stored in the goal.
284285
-/
285-
def getSplitCandidateAnchors (filter : Expr → GoalM Bool := fun _ => return true) : GoalM SplitCandidateAnchors := do
286-
let candidates := (← get).split.candidates
286+
def getSplitCandidateAnchors (filter : Expr → GoalM Bool := fun _ => return true)
287+
(candidates? : Option (List SplitInfo) := none) : GoalM SplitCandidateAnchors := do
288+
let candidates ← match candidates? with
289+
| some candidates => pure candidates
290+
| none => pure (← get).split.candidates
287291
let candidates ← candidates.toArray.filterMapM fun c => do
288292
let e := c.getExpr
289293
let anchor ← c.getAnchor
@@ -302,6 +306,47 @@ def getSplitCandidateAnchors (filter : Expr → GoalM Bool := fun _ => return tr
302306
let numDigits := getNumDigitsForAnchors candidates
303307
return { candidates, numDigits }
304308

309+
/--
310+
Anchor reference for a case-split candidate in generated tactic scripts.
311+
`ordinal` is the 0-based position of the candidate among the (ready) candidates whose
312+
anchors share the same displayed prefix. Distinct candidates may have identical anchors
313+
(e.g., candidates that differ only in inaccessible variables), and the ordinal is used
314+
to disambiguate them.
315+
-/
316+
structure SplitAnchorRefInfo where
317+
numDigits : Nat
318+
anchor : UInt64
319+
ordinal : Nat
320+
321+
/--
322+
Computes the anchor reference information for the case-split candidate `c`.
323+
The candidate list must be the one stored in the goal before the case-split is selected and
324+
performed (i.e., before `selectNextSplit?` prunes the list and before `c` is marked as resolved),
325+
so that it matches the list inspected by the `cases` tactic when replaying the generated script.
326+
-/
327+
def mkSplitAnchorRefInfo (c : SplitInfo) (candidates? : Option (List SplitInfo) := none) : GoalM SplitAnchorRefInfo := do
328+
let { candidates, numDigits } ← getSplitCandidateAnchors (candidates? := candidates?)
329+
let anchor ← c.getAnchor
330+
let shift := (64 - 4*numDigits).toUInt64
331+
let mut ordinal := 0
332+
for cand in candidates do
333+
if cand.anchor >>> shift == anchor >>> shift then
334+
if cand.c == c then
335+
return { numDigits, anchor, ordinal }
336+
ordinal := ordinal + 1
337+
-- `c` should be in the candidate list; fall back to the first match.
338+
return { numDigits, anchor, ordinal := 0 }
339+
340+
/--
341+
Creates the anchor reference syntax for the result of `mkSplitAnchorRefInfo`.
342+
-/
343+
def SplitAnchorRefInfo.toSyntax (info : SplitAnchorRefInfo) : CoreM (TSyntax `grind) := do
344+
let anchorStx ← mkAnchorSyntax info.numDigits info.anchor
345+
if info.ordinal == 0 then
346+
`(grind| cases $anchorStx:anchor)
347+
else
348+
`(grind| cases $anchorStx:anchor/$(quote (info.ordinal + 1)):num)
349+
305350
namespace Action
306351

307352
/--
@@ -404,11 +449,23 @@ Remark: `numCases` and `isRec` are computed using `checkSplitStatus`.
404449
-/
405450
def splitCore (c : SplitInfo) (numCases : Nat) (isRec : Bool)
406451
(stopAtFirstFailure : Bool)
407-
(compress : Bool) : Action := fun goal _ kp => do
452+
(compress : Bool)
453+
(candidates? : Option (List SplitInfo) := none) : Action := fun goal _ kp => do
408454
let traceEnabled := (← getConfig).trace
409455
let mvarId ← goal.mkAuxMVar
410456
let cExpr := c.getExpr
411-
let ((mvarIds, numDigits), goal) ← GoalM.run goal do
457+
let ((mvarIds, anchorInfo?), goal) ← GoalM.run goal do
458+
/-
459+
**Note**: The anchor reference must be computed before the case-split is performed,
460+
so that the candidate list matches the one inspected by the `cases` tactic when
461+
replaying the generated script. Callers that prune the candidate list during
462+
case-split selection (e.g., `splitNext` via `selectNextSplit?`) must provide the
463+
original list using `candidates?`.
464+
-/
465+
let anchorInfo? ← if traceEnabled then
466+
pure (some (← mkSplitAnchorRefInfo c candidates?))
467+
else
468+
pure none
412469
let gen ← getGeneration cExpr
413470
let genNew := if numCases > 1 || isRec then gen+1 else gen
414471
saveSplitDiagInfo cExpr genNew numCases c.source
@@ -420,11 +477,7 @@ def splitCore (c : SplitInfo) (numCases : Nat) (isRec : Bool)
420477
casesMatch mvarId cExpr
421478
else
422479
casesWithTrace mvarId (← mkCasesMajor cExpr)
423-
let numDigits ← if traceEnabled then
424-
pure (← getSplitCandidateAnchors).numDigits
425-
else
426-
pure 0
427-
return (mvarIds, numDigits)
480+
return (mvarIds, anchorInfo?)
428481
let numSubgoals := mvarIds.length
429482
/-
430483
**Split counter heuristic**: We do not increment `numSplits` for the first case (`i = 0`)
@@ -470,9 +523,8 @@ def splitCore (c : SplitInfo) (numCases : Nat) (isRec : Bool)
470523
else
471524
goal.mvarId.assign (← instantiateMVars (mkMVar mvarId))
472525
if stuckNew.isEmpty then
473-
if traceEnabled then
474-
let anchor ← goal.withContext <| getAnchor cExpr
475-
let cases ← `(grind| cases $(← mkAnchorSyntax numDigits anchor):anchor)
526+
if let some anchorInfo := anchorInfo? then
527+
let cases ← anchorInfo.toSyntax
476528
return .closed (← mkCasesResultSeq cases seqNew compress)
477529
else
478530
return .closed []
@@ -497,13 +549,19 @@ next => lia
497549
Then with `compress = true` it generates `cases #50fc <;> lia`
498550
-/
499551
def splitNext (stopAtFirstFailure := true) (compress := true) : Action := fun goal kna kp => do
552+
/-
553+
**Note**: `selectNextSplit?` prunes the candidate list (e.g., it removes the selected
554+
candidate and resolved ones). We save the original list because it is needed for
555+
computing anchor reference information for the generated tactic script.
556+
-/
557+
let candidates := goal.split.candidates
500558
let (r, goal) ← GoalM.run goal selectNextSplit?
501559
let .some c numCases isRec _ := r
502560
| kna goal
503561
let cExpr := c.getExpr
504562
let gen := goal.getGeneration cExpr
505563
let genNew := if numCases > 1 || isRec then gen+1 else gen
506-
let x : Action := splitCore c numCases isRec stopAtFirstFailure compress >> intros genNew >> assertAll
564+
let x : Action := splitCore c numCases isRec stopAtFirstFailure compress (candidates? := some candidates) >> intros genNew >> assertAll
507565
x goal kna kp
508566

509567
end Action

0 commit comments

Comments
 (0)