File size: 10,853 Bytes
2c7b2f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# Usage guide (English)

This package is **DuoTactic** β€” a Lean 4 proof-search harness β€” together with the 39.58 M-parameter
tactic model (`leanoar-schemec-40m`) that it can use as an optional candidate generator.

* To **run the harness** (sections 3 onward) you need Lean `4.35.0-rc2` and a mathlib4 checkout with a
  prebuilt `.lake` cache (~10 GB). This is what produces miniF2F `test` 32.8 % / `valid` 34.8 %, at
  45 s per problem, with every accepted proof re-compiled by the kernel.
* To **load or sample the model** (sections 0–2) you need no Lean. Note that `SchemeC` is **not** a
  standard `transformers` model: it is defined in `code/leanoar/model.py`, its weights are plain
  `lit_model.pth` / `model.safetensors` files, and it emits a *single Lean tactic* per call. There is
  no `AutoModelForCausalLM.from_pretrained`. Everything below is copy-paste runnable from the package
  root.

```
duotactic/
β”œβ”€β”€ code/eval_minif2f.py       DuoTactic itself: the end-to-end search harness
β”œβ”€β”€ code/leanoar/              search + batched verifier + the 40M model
β”œβ”€β”€ checkpoints/e6/            model weights (highest dev top-1)
β”œβ”€β”€ checkpoints/stage3-e3/     model weights, end-to-end best variant (same pass@1, 16% faster)
β”œβ”€β”€ data/dev/                  dev protocol arrays (1024 rows)
β”œβ”€β”€ data/minif2f/              miniF2F valid/test records + .lean files
β”œβ”€β”€ data/whitelist/            seed for the search's first-token whitelist
β”œβ”€β”€ examples/dev_sample.jsonl  16 real dev states + the real mathlib tactic
β”œβ”€β”€ config.json metrics.json tokenizer_v1.json special_tokens_v1.json SHA256SUMS
└── README.md  README.zh.md  docs/USAGE.md  docs/USAGE.zh.md
```

## 0. Install and verify

```bash
pip install -r code/requirements.txt      # torch>=2.4, tokenizers>=0.19, numpy>=1.26, safetensors>=0.4
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"

python code/verify_package.py             # full check, 1024 dev rows (β‰ˆ1 min on GPU)
python code/verify_package.py --quick     # 128 rows, CPU-friendly
```

`verify_package.py` prints four sections and ends with `PASS` / `FAIL`:

1. **parameter count** β€” rebuilds `SchemeC` from `config.json`, must be 39,582,595 for both variants;
2. **dev protocol** β€” recomputes top-1/top-5/CE on the packaged 1024 rows and compares with
   `metrics.json` (tolerance 5e-4). This is the check that makes the published numbers auditable;
3. **inference wiring** β€” runs the packaged example state through the sampler (it also shows whether
   the model's preferred first token matches mathlib's; a miss is normal and does not fail the check);
4. **checksums** β€” every file against `SHA256SUMS`.

Exit code is 0 only if 1, 2 and 4 pass. Use `--skip-sums` if you only have the weights.

## 1. Standalone inference (no Lean)

```bash
python code/generate.py                              # packaged dev example #0
python code/generate.py --example 3 --k 8            # another real dev state
python code/generate.py --state-file state.txt       # your own state (format below)
python code/generate.py --ckpt checkpoints/stage3-e3 # the other variant
```

A **state** is the text Lean prints for a goal, i.e. hypotheses then `⊒ goal`:

```
n m : β„•
h : n < m
⊒ n ≀ m
```

You can obtain one from Lean with `trace_state` inside a proof, or from an `unsolved goals` error
message. In your own code:

```python
import json, os, sys, torch
sys.path.insert(0, 'code')
from common import load_model, load_tokenizer, encode_state, specials, whitelist
from generate import propose

net, cfg, device = load_model('checkpoints/e6')     # or 'checkpoints/stage3-e3'
tok, wl = load_tokenizer(), whitelist()
for tactic, avg_logprob in propose(net, tok, wl, state, k=16, device=device):
    ...   # send to Lean for verification
```

Four rules that matter:

* **Restrict the first token** to `first_token_whitelist.json` (64 ids, 97.6 % of training first
  tokens). `propose()` already does this. Without it, most candidates start with meaningless tokens
  and the type-check rate collapses.
* **One candidate is one tactic.** The model separates two intended steps by a double space and may
  end a truncated tactic with a dangling connective; `propose()` cuts both.
* **Truncation**: states longer than `ctx βˆ’ 4 βˆ’ max_new_tokens` tokens keep the head 60 % and the
  tail 40 % (the `⊒ goal` line is last). Never feed more than `context_length` tokens total.
* **Only the Lean kernel decides correctness.** Ask for many candidates and verify them; do not trust
  any single output. Whole-tactic exact match is 0/200, and a "valid" (type-checking) candidate is
  still not a proof.

## 2. Reproduce the dev metric

```bash
python code/eval_dev.py --all --compare     # both variants, 1024 rows, compare with metrics.json
python code/eval_dev.py --ckpt checkpoints/e6 --rows 256
```

Expected (tolerance 5e-4):

| variant | top-1 | top-5 | CE |
|---|---|---|---|
| `checkpoints/e6` | 0.3076 | 0.7090 | 2.5494 |
| `checkpoints/stage3-e3` | 0.2891 | 0.7246 | 2.4971 |

The protocol: take the first 1024 rows of `data/dev/dev_ids.npy` whose second token is `<|FORWARD|>`,
read the logits at `mask_start βˆ’ 1` and score the token at `mask_start`. **Do not compare these with
the numbers a trainer prints** β€” those come from a length-bucketed reshuffle of the dev split.

## 3. End-to-end proof search on miniF2F (needs Lean + mathlib)

### 3.1 Prerequisites

* A **Lean 4.35.0-rc2** toolchain (e.g. `elan toolchain install leanprover/lean4:v4.35.0-rc2`).
  Note: this is a *release candidate*; newer Lean versions will re-elaborate mathlib differently.
* A **mathlib4 checkout with a prebuilt `.lake` cache** (this project used the Sept 2026 mathlib
  snapshot; the exact commit is not pinned in the release, so state text may drift slightly on other
  revisions). Build the cache with `lake exe cache get` inside the checkout β€” a full local build takes
  hours, the olean cache takes minutes and ~7 GB.
* β‰₯ 16 GB RAM if you use `--lean-parallel 6` (each `lean --json` process loads mathlib; ~0.4 s per
  candidate, 6 in parallel measured at 0.034 s/candidate).

```bash
export LEANOAR_LEAN_BIN=/path/to/lean-4.35.0-rc2-linux/bin/lean
export LEANOAR_MATHLIB=/path/to/mathlib4
$LEANOAR_LEAN_BIN --version          # sanity check
```

### 3.2 The run that produced the published number

```bash
python code/eval_minif2f.py --split valid --n 89 --start 0 \
    --k 16 --beam 4 --depth 6 --budget 45 --round-budget 30 \
    --force --force-set v2 --force-depth 99 --lean-parallel 6 \
    --ckpt checkpoints/stage3-e3 --tag mine
```

Expected: **30/89 = 33.7 %**, β‰ˆ52 min on an RTX 4060 Laptop (the `e6` variant gives the same 30
problems in β‰ˆ60 min). The full `valid` split was run end-to-end with this exact configuration:
**85/244 = 34.8 %** in β‰ˆ2.3 h (segments 33.7 % / 40.4 % / 28.8 %; 78 of the 85 solutions are one
tactic). Longer runs are easier as `--lean-parallel` rises: the fixed cost of every `lean --json`
call is β‰ˆ6.6 s of `import Mathlib` (vs β‰ˆ0.03 s per candidate), so parallelism and import trimming
are where the wall clock goes.

What the flags do β€” this is where the score comes from:

| Flag | Meaning |
|---|---|
| `--k 16` | candidates per state (top-16 whitelisted first tokens, greedy continuation) |
| `--beam 4` | how many open states are expanded per round |
| `--depth 6` | max proof length in tactics (measured: 25 of 30 solutions are depth 1) |
| `--budget 45` | wall-clock seconds per problem; `--round-budget 30` caps a single round |
| `--force --force-set v2` | inject the exported automation table (`rfl`, `ring`, `simp_all`, `omega`, …, `simp_all ; ring`, `gcongr`) at every depth. **This is the single biggest lever**: with it the first-89 result is 30/89, without it the whole 244 gives 8/244. |
| `--lean-parallel 6` | verify 6 chunks of candidates concurrently (pure engineering: 2.07Γ— wall clock) |
| `--narrow-imports` | per problem, replace the full Mathlib import by `#min_imports` + `Mathlib.Tactic` (measured 3.41 s β†’ 1.74 s per `lean --json` call). A candidate that fails with "unknown identifier/tactic" is automatically re-checked with the full import, so narrowing cannot lose a proof (verified: 85/85 accepted valid proofs still compile) |
| `--no-model` | ablation switch: verify only the injected table, no model proposals. On valid problems 0-9 this reproduces the model's result exactly (2/10, same problems, same proof terms) in half the wall clock |

Before quoting any pass rate, read **β€œWhere this score comes from”** in `README.md`: these are
*system* numbers (injected automation table + search + kernel verification), and the shipped model
supplies 0 of the accepted proofs.

### 3.3 Reading and resuming results

Results are appended to `reports/search_eval_valid_mine.jsonl` (one JSON object per problem:
`solved`, `depth`, `seconds`, `verified`, `proof`, `confirmed`, `reason`) and every run prints
cumulative progress with an ETA. Because the file is append-only, an interrupted run resumes with
`--start <rows already written>` β€” or simply re-run the same command.

### 3.4 Troubleshooting

* `initial-state-failed` for a problem: its statement does not even compile against your mathlib
  revision. Two miniF2F problems fail on mathlib master for everyone
  (`mathd_numbertheory_711`, `imosl_2007_algebra_p6`).
* `No such file or directory: .../bin/lean`: set `LEANOAR_LEAN_BIN`.
* Lean errors about missing oleans / `unknown module prefix 'Mathlib'`: set `LEANOAR_MATHLIB` to a
  checkout with a **built** `.lake/build/lib/lean` (and `.lake/packages/*`); the verifier assembles
  `LEAN_PATH` from those paths.
* Very low `verified` counts: check that the first-token whitelist is being applied and that the
  tokenizer is `tokenizer_v1.json`.
* A state longer than the prompt budget used to overflow the context by one token in old builds; the
  shipped `search.py` budgets `ctx βˆ’ 4 βˆ’ max_new_tokens` and hard-stops at `ctx`. If you edit the
  sampler, keep that invariant.

## 4. Extending

* Swap in your own weights: put `lit_model.pth` (+ `model.safetensors`) in a directory and pass
  `--ckpt <dir>` to `generate.py` / `eval_dev.py` / `eval_minif2f.py`. `config.json` must match the
  architecture; the vocabulary and context length are fixed.
* Add automation tactics to `FORCE_SETS` in `code/eval_minif2f.py` β€” measured gains in this project
  came almost exclusively from that table and from the search/verifier side, not from the model.
* The value head is shipped but unusable (AUC 0.5201, below the policy's own confidence). Do not
  build a reranker on it.
* `code/leanoar/data.py` is included because the search code imports `load_specials` from it; the
  training pipeline itself is not part of this package.