---
name: aeneas-lean-core
description: Translation model, spec patterns, tactic reference, and pitfalls for Aeneas Lean proofs
---

# Aeneas + Lean Core Skill File

## Context
Aeneas translates Rust programs to pure Lean code via the LLBC intermediate representation. The generated code uses the `Result` error monad. Proofs verify functional correctness by writing specification theorems tagged with `@[step]`.

## PREREQUISITE: Use lean-lsp-mcp for All Proof Work

**Before writing or editing any Lean proof**, use the lean-lsp-mcp tools.
See the `lean-lsp-mcp` skill file for the full tool reference and workflow.

## Reading Aeneas-Generated Code

### File layout
- `Types.lean` — Rust types → Lean inductive types
- `Funs.lean` — Rust functions → Lean monadic functions (Result monad)
- `FunsExternal.lean` — Hand-written models of external (e.g., std library) functions
- `FunsExternal_Template.lean` — Template generated by Aeneas with `sorry` bodies for external functions
- `TypesExternal.lean` — Hand-written models of external types
- `TypesExternal_Template.lean` — Template generated by Aeneas with `sorry` bodies for external types

When an axiom originates from a `FunsExternal_Template.lean` (or `TypesExternal_Template.lean`),
the model must be provided in `FunsExternal.lean` (or `TypesExternal.lean` respectively).

### Scalar types
`U8`, `U16`, `U32`, `U64`, `U128`, `Usize` (unsigned), `I8`, `I16`, `I32`, `I64`, `I128`, `Isize` (signed).
- `.val` gives the `Nat` (unsigned) or `Int` (signed) value
- `.bv` gives the `BitVec` representation
- Literals: `0#u32`, `1#i64`, etc.
- Arithmetic operations return `Result` (may fail on overflow)

### Translation patterns
| Rust | Lean |
|---|---|
| `&mut T` param | `T` param (by value) |
| Return `&'a mut T` | Returns `Result (T × (T → ...))` — backward continuation |
| `&T` (shared) | `T` param (may be copied) |
| `panic!()` | `fail` |
| Integer overflow | `fail` |
| `Box<T>` | `T` |
| `Vec<T>` | `Vec T` (Aeneas Vec, backed by `List`) |
| `[T; N]` | `Array T N` |
| Pattern match | `match` |
| Traits | Type classes |

### The backward continuation pattern
When a Rust function returns a mutable borrow, the Lean translation returns a tuple `(value, backward_fn)`:
```lean
def choose {T : Type} (b : Bool) (x : T) (y : T) :
  Result (T × (T → T × T)) :=
  if b then ok (x, fun z => (z, y))
  else ok (y, fun z => (x, z))
```
The backward function propagates updates back to the original variables.

### Writing models of Rust code
When writing hand-written models of Rust functions (e.g., in `FunsExternal.lean`):
- **Do NOT use `partial`** — `partial` definitions are opaque and cannot be unfolded or
  reasoned about. If the function lives in `Result`, use `partial_fixpoint` instead.
- **Do NOT use `private`** — we need access to all definitions so that we can unfold
  them and reason about them in proofs.

## The Specification Pattern

### Template
```lean
/-- **Spec theorem for `crate_name::module::function_name`**
Concise natural-language description of the spec. -/
@[step]
theorem function_name.spec (params : Types) (preconditions : hypotheses) :
    function_name params ⦃ (result : ResultType) =>
      postcondition result ⦄ := by
  unfold function_name
  step  -- or step* for automation
  -- finish remaining goals
```

### With backward function
```lean
@[step]
theorem function_name.spec (param1 : U32) (param2 : Slice U16)
    (hpre : param1.val < param2.length) :
    function_name param1 param2 ⦃ (result : U32) (back : U32 → Slice U16) =>
      postcondition_on_result ∧
      postcondition_on_backward_function back ⦄ := by ...
```

### Sorry'd definitions vs sorry'd theorems

A sorry'd **definition** (`def foo := sorry`) needs a **value** — a Lean term of the
correct type. A sorry'd **theorem** (`theorem foo := by sorry`) needs a **proof** — a
tactic sequence. Agents often confuse these, trying to write tactics for a `def` or
trying to produce a term for a `theorem`. Know which one you're filling:

- **`def`** (sorry'd definition): provide a concrete Lean expression. Common patterns:
  - Byte assembly: `arrayToSpecBytes field1 ++ arrayToSpecBytes field2`
  - Extraction: `(arrayToSpecBytes field).extract start len`
  - Casting: `expr.cast (by scalar_tac)`  or  `expr.cast (by simp [...])`
  - Concatenation: `a ++ b ++ c` with `Vector.append`
- **`theorem`** (sorry'd theorem): provide a tactic proof (`by unfold ...; step*; ...`)

When you encounter `noncomputable def foo ... := sorry`, step back and ask:
*"What concrete data should this be?"* Read the docstring and the expected type
carefully. The answer is almost always a composition of conversion functions
(`arrayToSpecBytes`, `sliceToSpecBytes`, `toPoly`) applied to the relevant fields,
possibly with `.extract`, `.cast`, or `++`.

**⛔ Sorry'd definitions are NEVER acceptable — not even at scaffold stage.** A `def`
with `sorry` as its body is not a placeholder — it is a hole that makes every theorem
referencing it vacuously true. Unlike a sorry'd *proof* (which leaves an honest gap in
a correct statement), a sorry'd *definition* poisons the statement itself: any
postcondition that mentions the definition can be trivially satisfied because `sorry`
unifies with any type.

This applies to ALL definitions: bridge/conversion functions (e.g., `aesBlockToArray`,
`toSpecBytes`), spec helper functions (e.g., `ghashAfterMacData`, `computeTagSpec`),
well-formedness predicates, and any `noncomputable def` used in theorem statements.

**The fix is always the same: write the concrete body immediately.** If you cannot
write the body because:
- **You don't understand the computation**: Read the Rust source, the spec, and the
  docstring more carefully. The body is always derivable from these.
- **The body requires a function that doesn't exist yet**: Define that function first
  (with a concrete body), then use it.
- **The body is complex**: That's fine — write it anyway. A complex concrete body is
  infinitely better than `sorry`. Use `let` bindings, helper functions, or pattern
  matching to keep it readable.

**The only exception** is for definitions whose bodies require Lean infrastructure that
genuinely does not exist yet (e.g., a typeclass instance that Aeneas hasn't generated).
In such cases, document the blocker precisely (see "No Vague Blockers" pitfall) and
report it to the user.

### Indentation rules for spec theorems
- `@[step]` and `theorem name`: base indentation (0 additional)
- Arguments, preconditions, and the line with the function application: +4 spaces
- Postconditions inside `⦃ ⦄`: +6 spaces
- Proof body (after `:= by`): +2 spaces
- Always annotate the result with its type: `(result : ResultType) =>`

### Key guidelines for spec theorems
- **Docstring**: add a `/-- **Spec theorem for \`full::rust::path\`** ... -/` docstring
  with a concise natural-language description of the spec.
- **Naming**: the theorem name is the function's full Lean name with suffix `.spec`.
  Open namespaces so the identifier prefix doesn't clutter the code.
- **Argument names**: name the spec theorem arguments exactly like the corresponding
  entities in the original Lean function definition.
- **Result type annotation**: always annotate the result binder with its type,
  e.g., `⦃ (result : U32) =>` — this makes the postcondition readable and helps
  type inference.
- **Postconditions**: write them as a conjunction `post1 ∧ post2 ∧ ...` inside `⦃ ⦄`.
- **Dependent postconditions**: if type-checking `b` in `a ∧ b` requires `a` to hold
  (e.g., `b` contains a `getElem` expression and `a` provides the index bound), use
  `∃ (_ : a), b` instead. This makes the proof of `a` available when elaborating `b`:
  ```lean
  -- BAD: won't type-check because res[i] needs hlen
  hlen : res.length = n ∧ res[i] = 42
  -- GOOD: hlen is in scope when type-checking res[i]
  ∃ (hlen : res.length = n), res[i] = 42
  ```
- **`@[step]` attribute**: always add it so the Aeneas `step` tactic can
  find the theorem. Start a new line after the attribute.

<!-- ⚠️ SYNC RULE: source of truth for postcondition rules below is aeneas-lean-core "Postcondition quality" -->

### Error results: use exhaustive match, not conjunctions

When a function returns a result type that includes an error (e.g.,
`Result (Error × Output)`), the postcondition must use an **exhaustive `match`** on
the error/status, not a conjunction of implications.

```lean
-- ⛔ BAD: conjunction of implications — hard to read, easy to miss cases
theorem process.spec (input : Slice U8) :
    process input
    ⦃ (err, output) =>
      (err = Error.NoError → output.length = INPUT_SIZE ∧ ...) ∧
      (err = Error.Overflow → True) ∧
      (err = Error.InvalidArg → True) ⦄ := by ...
-- Problems: (1) non-exhaustive — what if err is Error.Memory?
-- (2) the conjunction structure makes it hard to see which cases are actually specified
-- (3) caller must destruct the conjunction and find the right implication

-- ✅ GOOD: exhaustive match — every case is explicit
theorem process.spec (input : Slice U8) :
    process input
    ⦃ (err, output) =>
      match err with
      | Error.NoError => output.length = INPUT_SIZE ∧ ...
      | Error.Overflow => True
      | Error.InvalidArg => True
      | Error.Memory => True ⦄ := by ...
-- Every error case is visible. Unhandled cases are `True` (to be filled in later).
-- The match is exhaustive — Lean enforces that all constructors are covered.
```

**Why `match` over conjunctions:**
- **Exhaustiveness.** Lean enforces that a `match` covers all constructors. A
  conjunction of implications can silently omit cases.
- **Readability.** Each case is a separate branch — easy to see what's specified
  and what's `True` (placeholder).
- **Usability for callers.** After `step`, the caller gets the relevant branch
  directly — no need to destruct a conjunction and hunt for the right implication.

**Use `True` for unspecified branches.** When you don't yet know what to say about
an error case, write `True` — it's an honest placeholder that doesn't constrain
anything. Fill it in when the spec for that case is known.

### The `⦃ ⦄` notation
Weakest precondition: `f ⦃ x => P x ⦄` means "f succeeds with value x and P x holds."

When the result is a tuple, **always decompose it in the binder** — do NOT use
projectors (`.1`, `.2`, `.fst`, `.snd`, etc.):
```lean
-- GOOD: decompose the tuple in the binder
fun_name a b ⦃ (x : U32) (s : Slice U16) =>
  x.val < 100 ∧ s.length = a.length ⦄

-- BAD: projectors are hard to read and fragile
fun_name a b ⦃ result =>
  result.1.val < 100 ∧ result.2.length = a.length ⦄
```

### Recursive loop proofs: `unfold` + `step`, never `partial_fixpoint_induct`

Aeneas-generated loops can produce recursive functions (e.g., `foo_loop0`). To prove
specs for these, use `unfold` + `by_cases` + `step` with `termination_by`:

```lean
@[step]
theorem foo_loop0.spec (iter : core.ops.range.Range Std.U8)
    ...
    (h_done : ∀ j, j < iter.start.val → invariant j) :
    foo_loop0 iter ... ⦃ ... ⦄ := by
  unfold foo_loop0
  by_cases hlt : iter.start.val < iter.end.val
  · -- Some case: iteration body
    rw [core.iter.range.IteratorRange.next_U8_def]; simp [hlt]
    step as ⟨next_start, h_ns⟩
    step as ⟨..., h_...⟩   -- one per monadic bind in the body
    step*                    -- handles recursive call automatically
    · -- invariant update goal (new element + old element)
      intro j hj
      simp only [h_ns] at hj
      by_cases hjj : j = iter.start.val
      · -- New element: current iteration
        subst hjj; ...
      · -- Old element: from h_done
        have hj_lt : j < iter.start.val := by omega
        simp only [*]; simp_lists [*]
  · -- None case: iterator exhausted
    rw [core.iter.range.IteratorRange.next_U8_def]; simp [hlt]
    exact fun j hj => h_done j (by agrind)
  termination_by iter.end.val - iter.start.val
  decreasing_by scalar_decr_tac
```

**Why not `partial_fixpoint_induct`?** It requires:
- An explicit motive repeating the entire postcondition
- An `admissible` proof that is typically sorry'd (giving only partial correctness)
- Manual IH threading with extra `exact` goals for every precondition

The `unfold + step` pattern avoids all of this because:
- `step` on the recursive call automatically applies the `@[step]`-tagged theorem
  being proved (self-reference works because Lean can see the recursive definition)
- `termination_by` + `decreasing_by scalar_decr_tac` handles well-foundedness
- Preconditions of the recursive call (like simulation witnesses) are auto-resolved
  from context by `step*`

**Invariant update pattern** (inside the Some case, after `step*`): The recursive call
goal asks the invariant for `[0, next_start)`. Split with `by_cases hjj : j = iter.start.val`:
- **New element** (`j = iter.start`): prove from the current iteration's computation
- **Old element** (`j < iter.start`): delegate to `h_done` via `simp only [*]; simp_lists [*]`

## Proof Development Workflow

### ⛔ Use the LSP for all checking — `lake build` only at the very end

**Use only the lean-lsp-mcp tools** (diagnostics, goal, multi_attempt, etc.) when
developing proofs: they give you incremental proof checking.
**Do NOT run `lake build`** while developing proofs — the LSP server and `lake build`
write to the same `.lake/build/` directory concurrently, causing file corruption,
transient build failures, and wasted rebuild time.

**`lake build` is allowed only once, at the very end**, as a final verification that
the entire project builds. During proof development, the LSP gives you instant
feedback on individual files without the overhead or corruption risk of a full build.

### The step*? → fix → collapse workflow:
1. `step*?` — generates expanded proof script (one `step` per monadic call)
2. Copy the generated script into your proof
3. **Immediately scaffold `· agrind` for every sub-goal** that remains open —
   this is the first thing you do after pasting. Do NOT attempt to close any
   goal before all are scaffolded.
4. Work through each `· agrind` one at a time: if `agrind` didn't close a goal,
   inspect with `lean_goal`, pick the right tactic, and replace
5. Once the full proof works, try collapsing sections back to `step*`
6. If `step*` works on the whole body, use that (shorter is better)
7. If not, keep the expanded script for the parts that need manual intervention

### Naming hypotheses introduced by `step`

When `step` applies a spec theorem, it introduces the result variable and any
postcondition components into the context. Without `as`, postconditions get inaccessible
names (`✝`, `✝¹`, etc.). Use `step as ⟨...⟩` to name them one by one, in order:

```lean
-- Suppose foo_spec has postcondition: ⦃ r => r.length = n ∧ ∀ i, r[i]! = 0 ⦄
step as ⟨ r ⟩              -- name the result only; postcondition components are unnamed
step as ⟨ r, h_len ⟩       -- name result + first conjunct (r.length = n)
step as ⟨ r, h_len, h_val ⟩ -- name result + both conjuncts
```

Each name binds one component of the postcondition's top-level structure
(conjunction components, existential witnesses). If unsure how many components
there are, use `lean_goal` after a plain `step` to inspect the unnamed
hypotheses, then add names to `step as ⟨...⟩` to match. If you provide too
many names, Lean warns `"Too many ids provided"` — remove the excess.

## Tactic Quick Reference

### Primary tactics (Aeneas-specific)
| Tactic | Use for | Syntax |
|---|---|---|
| `step` | Apply function spec | `step`, `step as ⟨x, h⟩`, `step with thm` |
| `step*` | Repeated step (can take 60–120s on big functions — see "Tactics can take a long time" above) | `step*` |
| `step*?` | Generate proof script | `step*?` |
| `scalar_tac` | Integer arithmetic/bounds | `scalar_tac`, `scalar_tac +nonLin` |
| `simp_scalar` | Simplify scalar exprs | `simp_scalar [lemmas]` |
| `simp_lists` | Simplify list/slice structural ops (setSlice!, replicate, append, take, drop, length, get/set) | `simp_lists [lemmas]` |
| `bvify` | Lift to BitVec | `bvify N`, `bvify N at h` |
| `bv_tac` | Decide BitVec goals | `bv_tac N` |
| `natify` | Convert to Nat | `natify`, `natify at h` |
| `zmodify` | Convert to ZMod | `zmodify`, `zmodify [to N]` |
| `simp_ifs` | Simplify if-then-else | `simp_ifs` |
| `simp_bool_prop` | Simplify bool/prop | `simp_bool_prop` |
| `ring_eq_nf` | Cancel common terms in equalities | `ring_eq_nf`, `ring_eq_nf at h` |
| `fcongr` | Congruence (safe whnf) | `fcongr`, `fcongr N` |
| `split_conjs` | Split ∧ recursively, then scaffold `· agrind` per sub-goal | `split_conjs`, `split_conjs at h` |

### Lean builtins (commonly used)

> **🔑 DEFAULT TACTIC: When you don't know what to do, use `agrind`.**
> It is always the first tactic to try. If `agrind` fails, try `grind`. **Do NOT
> reach for `simp_all`** — it is very slow in large contexts and drops hypotheses.

| Tactic | Use for |
|---|---|
| `agrind` | **Default tactic — always try first.** Fast, handles most goals |
| `grind` | General automation (slower but more powerful — try if `agrind` fails) |
| `simp [*]` | Simplification with all hypotheses |
| `simp_all` | **⚠️ AVOID** — very slow in big contexts, silently drops hypotheses. Prefer `agrind` |
| `tauto` | Propositional tautologies |
| `decide` | Concrete finite computations |

### ⛔ BANNED TACTICS — NEVER USE THESE

**`omega`, `linarith`, `nlinarith` are BANNED in all Aeneas proofs.** This is the single
most important rule for writing idiomatic Aeneas proofs. These tactics do not understand
Aeneas scalar types (U8, U16, U32, U64, U128, Usize, I8, ..., Isize), do not know their
bounds (e.g., `U32.max`), do not understand `Slice.length`, `Vec.length`, or any Aeneas
container. They will either fail silently or produce fragile proofs that break on model changes.

| Banned tactic | Why it fails | Use instead (in preference order) |
|---|---|---|
| `omega` | Cannot reason about `.val`, `.bv`, scalar bounds, list/slice lengths | `agrind` > `grind` > `scalar_tac` |
| `linarith` | Same: no knowledge of scalar types, no `.val` reasoning | `agrind` > `grind` > `scalar_tac` |
| `nlinarith` | Same, plus explosion risk on nonlinear goals | `agrind` > `grind` > `scalar_tac +nonLin` or `simp_scalar` |
| `congr N` | Default transparency unfolds definitions deeply → heartbeat timeout | `fcongr N` (reducible transparency, same subgoals, no deep unfolding) |
| `cases x with \| Foo => ...` | Named arms are a single elaboration unit — breaks incrementality | `cases x with` then `·` per branch |

**Preference order for replacements: `agrind` first, then `grind`, then `scalar_tac`.**
`agrind` is the default tactic for Aeneas proofs — always try it first. It is fast and
handles most goals. `grind` is slower but more powerful. `scalar_tac` is specialized for
arithmetic bounds and should be used when the goal is purely arithmetic. **Do NOT use
`simp_all` as a general-purpose tactic** — it is very slow in the large contexts typical
of Aeneas proofs and silently drops hypotheses. Use `agrind` instead.

**This applies everywhere:** in `step` theorem proofs, in helper lemmas, in `have` steps,
in `termination_by`/`decreasing_by` blocks, in `calc` chains — everywhere, including pure
`Nat` arithmetic in `decreasing_by` clauses. There are **no exceptions**.

**If you see a proof using these banned tactics, it must be rewritten.** Replace:
- `omega` → `agrind` (or `grind`, or `scalar_tac`)
- `linarith` → `agrind` (or `grind`, or `scalar_tac`)
- `nlinarith` → `agrind` (or `grind`, or `scalar_tac +nonLin`, or `simp_scalar`)

### Bitwise reasoning: `bv_tac` and `bvify`

**Always use `bv_tac N` for goals involving bitwise operations** (`&&&`, `|||`, `^^^`, `~~~`, `>>>`, `<<<`, `%` on bounded types). The `N` is the bit width (e.g., `bv_tac 8` for U8, `bv_tac 16` for U16).

**How `bv_tac N` works internally:**
1. **`bvify N`** — rewrites the goal to work over `BitVec N` by applying `@[bvify_simps]` lemmas (e.g., `(x &&& y).bv = x.bv &&& y.bv`, `(x ^^^ y).bv = x.bv ^^^ y.bv`)
2. **`bv_decide`** — decides the resulting BitVec goal via SAT solving

**Diagnosing `bv_tac` failures:** If `bv_tac` fails with a goal that contains non-decomposed expressions (e.g., `(x &&& y).bv` instead of `x.bv &&& y.bv`, or `(someOp x).val` not reduced), it means the `bvify` preprocessing step is stuck because a `@[bvify_simps]` lemma is missing. In that case:
- Identify the non-decomposed subexpression in the error
- Add the missing `@[bvify_simps]` simp lemma to the Aeneas standard library
- Then retry `bv_tac`

**Common identity lemmas** (already in Aeneas, marked `@[simp]`):
- `U8.and_allOnes`: `x &&& 255#u8 = x`, `U16.and_allOnes`: `x &&& 65535#u16 = x`, etc.
- `U8.val_and_max`: `x.val &&& 255 = x.val`, etc.
- `U8.bv_mod_size`: `x.bv % 256#8 = x.bv`, etc.
- `U8.val_mod_size`: `x.val % 256 = x.val`, etc.

### Loop Translation: Prefer `-loops-to-rec`

Aeneas supports two loop translation modes:
- **`-loops-to-rec`** (recommended): Translates loops to recursive Lean functions. This is the mode used for most verified proofs so far. The resulting code uses direct recursion with `termination_by` / `decreasing_by`, and proofs use `unfold` + `step` (Pattern 4 below).
- **Fixed-point combinator** (default): Translates loops using a `loop` fixed-point operator. Proofs use `loop.spec_decr_nat` (Pattern 5 below). The proof infrastructure for this mode is less mature — fewer lemmas, less automation, and less battle-tested.

We are in the process of switching the default translation style to use the fixed-point combinator, but the proof infrastructure for it is not yet fully developed. Until it matures, **use `-loops-to-rec`** for any project where you need to write proofs.

### Pattern: Recursive loop (most common loop pattern)
```lean
-- Loops become _loop auxiliary functions. Write a separate theorem for each.
-- The loop invariant is both the precondition and postcondition.
@[step]
theorem zero_loop.spec (x : alloc.vec.Vec U32) (i : Usize) (h : i.val ≤ x.length) :
  zero_loop x i ⦃ x' =>
    x'.length = x.length ∧
    (∀ j, j < i.val → x'[j]! = x[j]!) ∧
    (∀ j, i.val ≤ j → j < x.length → x'[j]! = 0#u32) ⦄ := by
  unfold zero_loop
  simp; split
  · step ...    -- step applies zero_loop.spec recursively
  · simp; scalar_tac
termination_by x.length - i.val
decreasing_by scalar_decr_tac
```

### Pattern: Loop with `loop.spec_decr_nat` (fixed-point combinator)
```lean
-- Some loops use the `loop` combinator instead of direct recursion
@[step]
theorem my_loop.spec (x : MyState) (h : x.inv) :
  my_loop_body.loop x ⦃ r => r.post ⦄ := by
  apply loop.spec_decr_nat (measure := fun s => s.remaining) (inv := fun s => s.inv)
  · intro s hs
    unfold my_loop_body
    step*
    · agrind -- sub-goal 1
    · agrind -- sub-goal 2
    split_conjs
    · agrind -- conjunct 1
    · agrind -- conjunct 2
  · exact h
```

## Proof Style and Maintainability

### Address all warnings
Lean warnings are not optional — they indicate problems that must be fixed:
- **"This simp argument is unused"**: Remove the unused lemma from `simp only [...]`.
- **"Too many ids provided"**: Reduce binder count in `step as ⟨...⟩`.
- **"'...' tactic does nothing"** / **"'...' tactic is never executed"**: Remove the dead tactic.
- **"unused variable"**: Remove or prefix with `_`.
- **"Used `tac1 <;> tac2` where `(tac1; tac2)` would suffice"**: Replace `<;>` with `;`. The `<;>` combinator broadcasts to all goals — when there is only one goal, plain `;` (sequencing) is clearer and avoids accidental broadcasting if a future edit introduces multiple goals.
The only acceptable warning is `"declaration uses 'sorry'"` for remaining proof obligations.

**This applies to sorry'd proofs too.** A proof containing `sorry` may still generate
warnings (dead tactics, unused simp args, unused variables around the sorry). These
must be fixed — keep sorry'd proofs clean so they're ready for completion.

### Always override `get_elem_tactic` with `agrind`

**Always override `get_elem_tactic`** so that `a[i]` auto-discharges bounds with `agrind`.
This avoids writing `(by ...)` blocks for getElem bounds throughout definitions and proofs:

```lean
/- Override get_elem_tactic so that a[i] auto-discharges bounds with agrind -/
scoped macro_rules
| `(tactic| get_elem_tactic) => `(tactic| agrind)
```

The override must be either **`scoped`** (in a namespace — open the scope where needed)
or **`local`**. When writing definitions or proving lemmas, **open the corresponding
scope** so the override is active.

**Why `agrind`:** It handles both linear and nonlinear index arithmetic (e.g.,
`r * M + c < N * M` from `Fin` bounds), and produces compact proof terms that don't
cause kernel type-checking slowness (see "Never embed expensive `(by ...)` proof terms
in theorem TYPE SIGNATURES" below).

### Do not introduce redundant macros or notations
Do **not** define a new macro or notation when the desired behavior can be achieved
by overriding or configuring an existing Lean mechanism. Redundant macros create
maintenance burden (must be kept in sync, imported everywhere, can conflict when
multiple files define the same macro).

**Example:** The `[i]^` macro was introduced in a project to make array access work
without explicit bound proofs. This is strictly subsumed by the `get_elem_tactic`
override above — it does the same thing but requires a different syntax and must be
defined/imported separately.

**General rule:** Before defining a macro, check whether an existing tactic override,
attribute, or notation mechanism already covers the use case. If so, use that instead.

### Comment style: use block comments
Use block comments `/- ... -/` rather than multiple lines starting with `--`.
For multi-line comments, use:
```lean
/- This is a multi-line comment
   explaining something important. -/
```
Not:
```lean
-- This is a multi-line comment
-- explaining something important.
```
Single-line `--` is acceptable for very short inline annotations, but for anything
spanning multiple lines, prefer `/- ... -/`.

### Spaces around binary operators in comments
Always put spaces around binary operators (`<`, `>`, `=`, `≤`, `≥`, `+`, `*`, etc.) in
comments and doc strings. Write `j < N`, not `j<N`. This avoids a VS Code highlighter bug
that misinterprets `<` as an HTML tag opening, causing broken syntax highlighting in the file.

### Avoid large `simp only` calls in implementation proofs
Do **not** leave big `simp only [lemma1, lemma2, ..., lemma20]` or `simp_all only [...]`
calls in proofs about the generated Rust code. The generated model can be unstable (names
change when Rust code is re-extracted), making such proofs hard to maintain. Prefer
`simp [*]`, `agrind`, or targeted rewrites.

**Exception:** Large `simp only` calls are fine in proofs about auxiliary specifications
(pure mathematical lemmas), where the definitions are stable.

### Register Rust global/const definitions with solver attributes
Pure (i.e., not living in `Result`) Rust global/const scalar definitions should be
registered with all relevant solver attributes:
```lean
@[simp, scalar_tac_simps, agrind =, grind =, bvify]
theorem MY_CONST_val : MY_CONST.val = 42 := by decide
```
This is generally safe and lets all solvers use the concrete value directly.

**Why this matters for `step` / `step*`:** When `step` applies a function spec, it
generates precondition sub-goals (e.g., bounds checks). If the solver (`agrind`,
`scalar_tac`, etc.) already knows the concrete values of global constants via its
attributes, it can discharge these sub-goals automatically — they disappear entirely.
Without the attributes, each sub-goal requires a manual `simp [CONST]; solver` call
in a cdot block, which is verbose and fragile.

**Detecting missing attributes — patterns to look for:**

1. **Repeated `simp [foo₁, ...]; solver` in cdot sub-goals after `step` / `step*`:**
   If the same constants appear in `simp` calls before a solver across multiple
   sub-goals, those constants should be promoted to solver attributes. Once marked,
   the sub-goals are discharged by `step` / `step*` automatically and disappear.
   ```lean
   -- BEFORE: manual simp + solver in every sub-goal
   step as ⟨i1, _⟩
   · simp only [NBAR, LOGQ] at *; scalar_tac
   step as ⟨i2, _⟩
   · simp only [NBAR, LOGQ] at *; scalar_tac

   -- AFTER: mark NBAR, LOGQ with @[scalar_tac_simps] (and other attrs)
   -- sub-goals disappear, proof simplifies to:
   step as ⟨i1, _⟩
   step as ⟨i2, _⟩
   ```

2. **`have hFoo : CONST.val = N := by simp ...` followed by `simp [hFoo]; solver`:**
   A local hypothesis that unfolds a global constant to its concrete value is a strong
   signal that the global definition is missing solver attributes. Mark the definition
   directly — the hypothesis becomes unnecessary.

3. **`simp` with a simpset (e.g., `global_simps`):** Use `simp?` to discover which
   individual lemmas `simp` actually uses, then mark those with the appropriate solver
   attributes.

The attribute-to-solver mapping:

| Solver | Attribute |
|--------|-----------|
| `scalar_tac` | `@[scalar_tac_simps]` |
| `agrind` | `@[agrind =]` |
| `grind` | `@[grind =]` |
| `bv_tac` | `@[bvify]` |

### Extract auxiliary lemmas for complex sub-proofs
When a proof step requires complex non-linear arithmetic, bitwise reasoning, or
multi-step calculation, **extract it as a separate auxiliary lemma** rather than
inlining it in the middle of a `step*` proof. This:
- Gives the sub-proof its own small context (faster, less noise)
- Keeps the main proof clean and readable
- Makes maintenance easier when the model changes

```lean
-- BAD: complex arithmetic inlined in the middle of step*
theorem main.spec ... := by
  unfold main_fn
  step*
  -- 15 lines of manual arithmetic here
  step*

-- GOOD: extract as auxiliary lemma
private theorem aux_arith (a b : Nat) (h1 : ...) (h2 : ...) :
  a * b % q = ... := by
  simp_scalar
  ...

theorem main.spec ... := by
  unfold main_fn
  step*
  · apply aux_arith ...
  step*
```

### Unification pitfalls with `exact`/`apply`

<!-- ⚠️ SYNC RULE: aeneas-tactics-quickref references this section in 3 places -->

When `exact h` or `apply thm` is slow (heartbeat timeout) or triggers `maxRecDepth`,
the root cause is almost always that the **goal and the term being applied differ in
subterms that are definitionally but not syntactically equal**. Lean's kernel must
WHNF-reduce (weak head normal form) both sides to check equality. If those subterms
involve large structures (arrays, foldl over 24 elements, 5×5×64 state matrices),
WHNF reduction explodes — even though the terms are "the same" mathematically.

**Symptoms:**
- `exact h` takes minutes or times out, even though `h` "obviously" matches the goal
- `apply thm` triggers `maxRecDepth` or heartbeat limit
- The proof works fine with `sorry` but hangs when you replace `sorry` with `exact`

**Root cause pattern:** The goal says `f a` and the hypothesis says `f b`, where
`a` and `b` are definitionally equal but written differently. For example:
- Goal: `state.get (rhoXY ⟨n, hn⟩).1 (rhoXY ⟨n, hn⟩).2 z = ...`
- Hypothesis `h`: `state.get state_n.2.1 state_n.2.2 z = ...`
- We know `state_n.2 = rhoXY ⟨n, hn⟩`, so they're definitionally equal
- But `exact h` forces the kernel to WHNF both `(rhoXY ⟨n, hn⟩).1` and `state_n.2.1`
  to check they match — which involves reducing `rhoXY` on all 24 cases

### Scaffolding workflow: `· agrind` first, then fix failures

<!-- ⚠️ SYNC RULE: also in aeneas-tactics-quickref "Scaffolding workflow" -->

> **🔑 MANDATORY: After every `step*`, immediately scaffold one `· agrind` per
> remaining sub-goal.** This is the very first thing you do — before inspecting
> goals, before trying tactics, before anything else. Never leave `step*` without
> its cdot scaffolding. **The same rule applies to `split_conjs`** and any other
> tactic that produces multiple sub-goals (e.g., `split`, `cases`).

```lean
-- After step*:
step*
· agrind -- goal 1
· agrind -- goal 2
· agrind -- goal 3
· agrind -- goal 4

-- After split_conjs:
split_conjs
· agrind -- conjunct 1
· agrind -- conjunct 2
· agrind -- conjunct 3
```

This is the mandatory workflow — never skip scaffolding and jump to `all_goals`
or `<;>` to "handle them all at once". Critical benefits:

- **`agrind` closes most sub-goals immediately** — many goals produced by `step*`
  and `split_conjs` are arithmetic bounds or simple equalities that `agrind` handles.
- **Each goal becomes independently inspectable** — for goals where `agrind` fails,
  use `lean_goal` on that line to see exactly the context and target.
- **Edits are incremental** — replacing one `· agrind` with a different tactic only
  re-elaborates that single goal, not the others.
- **No `all_goals` temptation** — the structure prevents bulk tactics.

After scaffolding, check which `· agrind` goals still have errors. For those,
inspect with `lean_goal`, pick the right tactic, and replace. This is the
correct workflow even for 20+ goals — never try to close them in bulk. If
there are too many goals (15+), consider fold decomposition first (see the
`aeneas-crypto-verification` skill file).

### Avoid early case splits on parameters in step proofs
In cryptographic code, functions are often parameterized by a parameter set (e.g.,
`p : Spec.Crypto.parameterSet`) from which lengths, dimensions, and bounds are derived.
**Do NOT case-split on such parameters at the beginning of a `step` proof.** This
duplicates the entire proof for each parameter variant and makes the proof unmaintainable.
**Measured impact:** `cases p <;> step*` at the top level turned a 110s proof into 327s
(3× slowdown). Moving `cases p` to only the residual goals that need concrete values
brought it back to 112s.

Instead:
- **Do case splits locally** inside specific proof obligations that need concrete values:
  ```lean
  -- BAD: duplicates the entire proof N times
  theorem my_fn.spec (p : Spec.Crypto.parameterSet) ... := by
    cases p <;> (unfold my_fn; step*; ...)

  -- GOOD: case split only where needed
  theorem my_fn.spec (p : Spec.Crypto.parameterSet) ... := by
    unfold my_fn
    step*
    · cases p <;> simp_all [Spec.Crypto.n, Spec.Crypto.k] <;> scalar_tac  -- only this sub-goal needs it
    step*
  ```

- **Give definitions/lemmas to `agrind`/`grind`** so they can case-split automatically:
  ```lean
  -- Give agrind the parameter definition so it can case-split internally
  attribute [local agrind] Spec.Crypto.n Spec.Crypto.k in
  theorem my_fn.spec ... := by
    unfold my_fn
    step*  -- agrind (used by step for preconditions) now auto-splits on p
  ```
  This is especially effective because `agrind` is the default tactic used by `step`
  to discharge preconditions — giving it the right definitions lets it handle parameter-
  dependent bounds without any manual case splits.

  **Practical tips for `attribute [local agrind]`:**
  - You may need to give **multiple definitions** (not just the top-level parameter
    accessor, but also definitions it depends on). Figuring out which ones is tricky.
  - **Isolate in a temporary `example`**: Copy the proof obligation into a standalone
    `example` with a minimal context, then experiment with `attribute [local agrind]`
    on different definitions until the `example` goes through. Once you find the right
    set, go back to the main proof.
  - **⚠️ Monitor proof time carefully.** Adding definitions to `agrind`/`grind` can
    cause proof time to explode. Remember that `step*` calls `agrind` on **every
    precondition** it encounters — so marking a definition as `[local agrind]` affects
    every `step` step in the proof, not just the one you're targeting. After each
    addition, check that `step*` doesn't become significantly slower. If it does,
    back off and use auxiliary lemmas or local case splits instead.

- **Write auxiliary lemmas** for arithmetic facts that depend on the parameter:
  ```lean
  private lemma n_mul_k_bound (p : Spec.Crypto.parameterSet) :
      Spec.Crypto.n p * Spec.Crypto.k p < 2^16 := by
    cases p <;> native_decide
  ```

When proving non-linear arithmetic goals (with modulo, division, shifts, etc.) that
require multiple steps:

1. **Decompose into a chain of `have` steps**, each provable by `simp_scalar`:
```lean
have h1 : a * b < 2^32 := by simp_scalar
have h2 : (a * b) / q < q := by simp_scalar
have h3 : (a * b) % q = a * b - q * ((a * b) / q) := by simp_scalar
simp only [h3]; simp_scalar
```

2. **Or use `calc`** for equational chains:
```lean
calc a * b % (2^16)
    _ = a * b % (2^16) := rfl
    _ = (a * b) - (2^16) * ((a * b) / 2^16) := by simp_scalar
    _ = ... := by simp_scalar
```

3. **Use `ring`** in `have`/`calc` steps for purely algebraic equalities (no
   inequalities, no modulo, no division — just `+`, `*`, `-` rearrangements):
```lean
have h : (a + b) * c = a * c + b * c := by ring
calc (x + 1) * (x + 1)
    _ = x * x + 2 * x + 1 := by ring
```

4. **If `simp_scalar` can't close a step**: either provide more lemmas
   (`simp_scalar [my_lemma]`), mark lemmas for simp_scalar
   (`attribute [local simp_scalar_simps] my_lemma`), or apply the relevant
   theorem manually. If no suitable theorem exists, prove it as an auxiliary lemma.

5. **Always simplify shifts to modulo/division**: rewrite `x >>> n` as `x / 2^n`
   and `x <<< n` as `x * 2^n` (or `x % 2^n` for the relevant bits). Use
   `Nat.shiftRight_eq_div_pow` and `Nat.shiftLeft_eq_mul_pow` or the corresponding
   scalar lemmas.

## Critical Pitfalls

1. **Termination error after unfold + step**: Function starts with match/if → use `split` first
2. **Nat subtraction truncated**: `3 - 5 = 0` in Nat → use Int, add preconditions, or rewrite as addition (`z + y = x` instead of `z = x - y`)
3. **`simp_all` is slow and removes hypotheses**: `simp_all` is very slow in big contexts (common in Aeneas proofs) and silently drops hypotheses. **Always prefer `agrind`** as your default tactic. If you need simplification, use `simp [*]` or `simp [h1, h2]` — they are faster and preserve hypotheses
4. **`agrind` fails**: Try `simp [*]; agrind`
5. **`grind` explodes**: Use `agrind` instead (controlled context)
6. **Progress applies wrong spec**: Use `step with specific_theorem`
7. **"Too many ids provided" from `step`**: You gave more binder names in `step as ⟨...⟩` than the tactic produced. Remove excess names until the count matches. Check `lean_goal` to see how many binders the postcondition actually has.
8. **"This simp argument is unused"**: A lemma in `simp only [...]` or `simp_all only [...]` didn't fire. **Always fix this** — remove the unused lemma from the list. Don't ignore these warnings.
9. **"'...' tactic does nothing"** / **"'...' tactic is never executed"**: The tactic call is dead code. **Always fix this** — remove the tactic entirely. Don't leave dead tactics in proofs.
10. **Report misbehaving tactics.** If a tactic doesn't do what it should — for example, `step` fails to make progress even though the appropriate `@[step]` lemma exists, or `scalar_tac` can't close a pure arithmetic goal it should handle — **report this to the user**. It may indicate a bug or missing feature worth fixing upstream.
11. **Keep `maxHeartbeats` reasonable (< 8M).** Lean's default (200K) is too low for Aeneas proofs — increase to 1M as a baseline. But if a proof needs more than ~8M heartbeats, the proof is ill-structured or uses tactics inefficiently. Don't just bump the number — instead: decompose the function with fold theorems, extract sub-goals as auxiliary lemmas, minimize the context with `clear`, prefer `agrind` over `grind`, or use `step*?` instead of `step*` for finer control. **⛔ NEVER use `set_option ... in` inside a proof script** (e.g., within a `by` block). The `in` scoping inside a tactic block makes everything below it a single elaboration unit — any edit forces full re-elaboration, destroying incrementality. Using `set_option ... in` **before** a theorem declaration is fine and standard practice (e.g., `set_option maxHeartbeats 16000000 in theorem ...`).
<!-- ⚠️ SYNC RULE: the measure tactic is defined in aeneas-tactics-quickref "Profiling proof time" -->
12. **⚠️ Keep proof wall-clock time < 60s — this is important.** Fast proofs enable fast iteration. Even the biggest proofs (for functions of 50+ lines) should complete in under 60 seconds wall-clock (including kernel proof-term replay). If a proof takes longer, it must be fixed — decompose it, extract auxiliary lemmas, or use more direct proof strategies. **Detecting kernel replay slowness:** In the LSP, after all tactics are elaborated, the server reports it is still processing the last proof line AND the `theorem` declaration line (plus `set_option ... in` above it). If it stays in this state a long time, the kernel is replaying the proof term — the fix is to produce simpler/smaller proof terms (decompose the function, extract sub-goals as separate lemmas). **Profiling:** `set_option trace.profiler true in` only measures tactic execution time — it does NOT include kernel type-checking of the proof terms that tactics produce. The discrepancy can be huge. Use the `measure` tactic wrapper (see the `aeneas-tactics-quickref` skill file, "Profiling proof time") to get true wall-clock time including kernel checking. If `trace.profiler` says tactics are fast but `measure` is slow, the bottleneck is kernel replay.
13. **⚠️ Keeping Lean reactive is critical (&lt; 0.5s per tactic).** Adding a tactic at the end of a proof should take &lt; 0.5s (everything above is cached). If it takes several seconds, big chunks are being re-elaborated. See the "Extract inline `(by ...)` blocks" and "Never embed `(by ...)` in type signatures" and "Avoid `step* <;> tactic`" sections above for the main causes and fixes.
14. **Dependent proof terms break `rw`/`simp only`.** When a term has a proof argument that depends on the value being rewritten (e.g., `partial_sum arr bound (hbound : bound ≤ N)`), `simp`/`rw` tries to update both the value AND the proof simultaneously and may loop. **Fix:** Use `fcongr 1` to peel off the proof argument (handled by proof irrelevance), then rewrite the value part separately. (Never use `congr 1` — it uses default transparency and can cause heartbeat timeouts; see item 26.)
    ```lean
    -- BAD: loops because hbound depends on bound
    simp only [show bound = new_bound from h] at goal_with_partial_sum
    -- GOOD: fcongr 1 separates value from proof
    fcongr 1  -- one goal for the value, one trivial goal for the proof
    ```
15. **`step*` doesn't recognize structure field projections.** When a function is accessed via structure field projection (e.g., `(Params p).shake` instead of `specShake`), `step` can't match it to an `@[step]` lemma. **Fix:** Add a simp lemma `@[simp, step_simps]` that unfolds the projection, then `simp only [step_simps]` before `step*`.
    ```lean
    @[simp, step_simps]
    theorem Params_shake : (Params p).shake = specShake := by rfl
    -- In the proof:
    simp only [step_simps]; step*
    ```
16. **Doc comments `/--` before `set_option` cause parse errors.** Doc comments must be followed by a declaration (`theorem`, `def`, etc.), not `set_option ... in`. Use a regular comment (`/- ... -/` or `-- ...`) instead.
17. **Concrete computation goals need `native_decide`.** Goals like `¬(64 % Usize.size = 64) ⊢ False` (from `wrapping_add` preconditions) are concrete computations that `agrind`/`grind`/`scalar_tac` cannot efficiently evaluate. **Fix:** Use `native_decide` (or `decide` for smaller computations).
18. **In loop proofs (`spec_gen`), prefer `agrind` over `scalar_tac` throughout.** Loop invariant proofs carry complex hypotheses in context throughout the entire proof body. Any `scalar_tac` call risks a simp loop (see item 11). When one `scalar_tac` fails in a spec_gen, mass-replace ALL `scalar_tac` → `agrind` in the proof body — don't fix them one by one, because any remaining `scalar_tac` is at risk as the context grows.
19. **⛔ `congr` is BANNED — always use `fcongr`.** `congr N` uses default transparency, which means it may WHNF (weak head normal form) the function being decomposed. If the function is complex — recursive, contains loops, has large bodies — this causes deterministic heartbeat timeout. This is not a rare edge case; it happens routinely in Aeneas proofs. **Always use `fcongr N` instead** — it uses reducible transparency, producing the same subgoals without unfolding function bodies. When arguments have dependent types with different indices (e.g., `Vector α n` vs `Vector α m`), subgoals use `HEq` (`≍`). Show the underlying data is equal, then close with proof irrelevance.
    ```lean
    -- BAD: congr unfolds f deeply, heartbeat timeout
    congr 1

    -- GOOD: fcongr uses reducible transparency, same subgoals
    fcongr 1
    · -- a₁ = b₁
      simp [...]
    · -- a₂ ≍ b₂  (HEq when dependent type indices differ)
      ...
    ```

## Attribute Management

```lean
-- File-level setup for crypto/array proofs
#setup_aeneas_simps

-- Register lemmas for tactics
attribute [local simp_scalar_simps] my_lemma
attribute [local simp_lists_simps] my_lemma
attribute [local agrind] my_lemma
```

Safe to activate many local lemmas for `simp_scalar`/`simp_lists` — simp-based, no complexity explosion.

## `step_array_spec`: Step Theorems for Constant Arrays

When a Rust program has a global constant array, Aeneas translates it as a Lean
`Array` definition. To let `step` / `step*` automatically handle indexing into
that array, use the `step_array_spec` macro. It generates an `@[step]` theorem
proving that `Array.index_usize` succeeds and the returned element satisfies a
user-specified predicate.

### Syntax

```lean
[local | scoped] step_array_spec (name := <theorem_name>) <array>[<index>]!
  { <elem> => <predicate> }
  by <tactic>
```

- **`array`**: the constant array definition name
- **`index`**: the index variable (becomes a `Usize` parameter in the theorem)
- **`elem`**: bound variable for the returned element
- **`predicate`**: a `Prop` about `elem` and `index` (the postcondition)
- **`tactic`**: proves the proof obligation expressed via `Array.allIdx` — typically
  `native_decide` for arrays up to a few hundred elements, or `decide +kernel` for
  small arrays
- **Prefix**: `local` makes the theorem `private` with `@[local step]`;
  `scoped` uses `@[scoped step]`; no prefix uses `@[step]`

### Example

```lean
-- Given a constant array:
def const_array : Array U32 8#usize := Array.make 8#usize [
  0#u32, 1#u32, 2#u32, 3#u32, 4#u32, 5#u32, 6#u32, 7#u32,
]

-- Generate a step theorem:
step_array_spec (name := const_array_spec) const_array[i]!
  { x => x.val = i.val }
  by native_decide

-- This generates (roughly):
-- @[step] theorem const_array_spec (i : Usize) (_ : i.val < 8) :
--   Array.index_usize const_array i ⦃ x => x.val = i.val ⦄
```

### When to use

- **Global constant arrays** referenced in hot loops (e.g., `KECCAK_RHO_K`,
  round constants, S-boxes). Register them once with `step_array_spec` and
  `step*` will discharge array indexing calls automatically.
- **Large arrays**: prefer `native_decide` over `decide` for the proof obligation
  — `decide` can be very slow on arrays with 25+ elements.
- **Scoped vs local**: use `scoped` if the theorem should be visible when the
  namespace is opened; use `local` for file-private arrays.
