Files
lean-bench/textbook/formalize_exercises.py
T
claude-bot 04d827b3b3 Split pipeline into real bench (exercises) vs library candidates (body)
Restructures the textbook pipeline around the distinction the body-derived
dataset had been blurring: a bench should be actual unsolved problems, and
a reusable premise library should only ever contain statements that have
really been proven, the way Mathlib itself works.

- library_candidates/goodman-1.6.jsonl (renamed from datasets/goodman-1.6.jsonl):
  the book's own Theorem/Proposition/Lemma/Corollary claims from the main
  text. Not bench targets -- premises waiting to be proven once via a real
  lean-bench run, then assembled into the reusable library by
  build_library.py (which already only keeps success:true entries with
  their checked proof, unchanged by this commit).
- datasets/goodman-1.6-exercises.jsonl (new): the actual bench, sourced
  from the book's Exercises 1.6 block via two new scripts,
  textbook/extract_exercises.py (slices the Exercises block, lists its
  numbered items, drops a known false-positive class where a wrapped
  cross-reference like "...Proposition\n1.6.19." looks like a real
  exercise heading) and textbook/formalize_exercises.py (LLM step, given
  both the exercises and body text together since many exercises say
  "Prove Proposition N.N.N" instead of restating the claim).
- Both formalize.py and formalize_exercises.py's validate() now enforce
  that every checklist item is covered by at least one output entry
  (included or explicitly excluded with a reason); formalize_exercises.py
  did not have this yet and a first real run silently dropped 7 of 15
  checklist items with no trace before this was added.
- All 12 model-produced exercise entries needed real fixes before commit,
  the same review pass applied to the body pilot: 3 exercises literally
  ask to prove an already-stated body claim (Exercise 1.6.1 -> Proposition
  1.6.7, 1.6.2 -> Proposition 1.6.9, 1.6.7 -> Proposition 1.6.18, 1.6.9 ->
  Corollary 1.6.20, 1.6.15 -> Lemma 1.6.26) and now reuse that claim's own
  already-checked statement verbatim instead of an independently
  re-derived (and in one case strictly weaker) restatement; two entries
  (1.6.2, 1.6.11) had literal math prose like "I(m, n)" or
  "gcd(n1,...,nk)" leaked into the statement field, which is not valid
  Lean; several had untyped/ambiguous gcd or Prime usage fixed the same
  way the earlier body fixes were (Nat.Prime, IsCoprime, explicit casts,
  IsGreatest instead of max on a Set); and Exercise 1.6.12 part (a) turned
  out to be a discussion question whose unconditional-identity reading is
  actually false (6, 10, 15 are jointly coprime but not pairwise coprime),
  so it was moved to the excluded file with a reason instead of committed
  as a false theorem.
- Definitions can now be real, compiled Lean, not only JSON metadata:
  Definition 1.6.22 (gcd of a list of integers, previously a null
  placeholder since no single Mathlib constant matches it) now has a real
  hand-written `def IsGCDOfList`, and textbook/build_definitions.py
  compiles any manifest's non-null lean_code entries into
  lib/Definitions.lean. goodman-1-6-24 (library) and the new
  goodman-1-6-ex-11-b/c, goodman-1-6-ex-12 (bench) all now use
  IsGCDOfList instead of repeating an inline existential.
- textbook/README.md rewritten around the two-track design and records
  how this is meant to connect to lean-agent's eventual goal-directed
  proof search against the growing library (not yet built).
- New tests for extract_exercises.py and build_definitions.py; full
  local suite still green (18/19, the one failure is the pre-existing,
  unrelated lean_agent import error in test_dataset.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017vbLFFJiHSZqGZHEkjvG5x
2026-09-18 19:27:40 +00:00

186 lines
9.5 KiB
Python

"""LLM-assisted formalization of a section's EXERCISES into Lean 4 bench targets.
These are the real lean-bench targets: problems the reader is meant to solve, as
opposed to formalize.py, which turns the book's own stated Theorems/Propositions/
Lemmas/Corollaries into a *candidate library* -- premises meant to be proven once
and then reused, not exam questions. See textbook/README.md for the split.
Exercises often reference the body by number instead of restating the claim (e.g.
"1.6.2. Prove Proposition 1.6.9.", or "1.6.15. Prove Lemma 1.6.26."), so this script
gives the model the exercise text *and* the full body section text together, and
tells it to resolve such references into a self-contained statement using the
body's actual content -- never leave a Lean statement that just says "the thing
Exercise N asks for" without spelling out what that thing is.
Same caveats as formalize.py: never runs Lean itself, the source PDF's math symbols
can be corrupted, and output must go through check_statements.py before trusting it
or spending lean-agent proof attempts on it.
"""
import argparse
import json
import re
from pathlib import Path
try:
from .llm import ChatClient, load_dotenv
from .formalize import extract_json_block
except ImportError:
from llm import ChatClient, load_dotenv
from formalize import extract_json_block
SYSTEM_PROMPT = """You are formalizing the EXERCISES of one section of an \
undergraduate abstract algebra textbook (Goodman, "Algebra: Abstract and \
Concrete") into Lean 4 bench targets using Mathlib.
You will receive:
1. The book's own Theorem/Proposition/Lemma/Corollary/Definition statements for \
this section (body text), for reference.
2. The section's numbered exercises (their own independent numbering, unrelated to \
the body's numbering -- Exercise 1.6.5 has nothing to do with body item 1.6.5).
3. A checklist of every exercise number in this section, so you know exactly what \
to cover. EVERY number in the checklist MUST appear in your output as at least one \
entry (an excluded one is fine, e.g. for a pure computation -- see below -- but \
silently leaving a checklist number out entirely is never acceptable and your \
output will be rejected if you do this). Do not invent numbers that are not listed.
WARNING: the source PDF's math font encoding is broken in this extraction -- \
symbols like congruence, set membership, comparisons, superscripts and subscripts \
are frequently dropped or garbled. Use your own knowledge of standard undergraduate \
number theory / algebra to reconstruct the intended mathematics from context; do \
not invent content that is not actually in the section.
Many exercises say things like "Prove Proposition 1.6.9" or "Prove Lemma 1.6.26" \
instead of restating a claim. For those, use the body text to find what that \
referenced item actually says, and write ITS full claim as the Lean statement -- \
never emit something like "the statement of Proposition 1.6.9" as a placeholder; \
the reader of your output has no access to the body text, only the Lean statement.
An exercise with lettered sub-parts (a), (b), (c) that each state an independent \
provable claim should become one entry PER PART (id suffixed "-a", "-b", ... in \
part order), the same way the body pipeline splits multi-part propositions. Keep a \
single entry for one exercise if its parts are really one claim you naturally \
express as a single Lean statement (e.g. an iff with two directions written as \
separate parts).
Produce exactly one JSON object with a single key "exercises" (a JSON array), \
inside one ```json fenced code block, and nothing else outside that block.
Each element of "exercises":
{
"id": "goodman-1-6-ex-5",
"source_label": "Exercise 1.6.5",
"kind": "exercise",
"statement": "the Lean 4 proposition only, i.e. what goes after `theorem name : `, \
using Mathlib vocabulary wherever possible instead of reinventing definitions",
"prelude": "Lean import lines (and, rarely, a small needed `def`) this statement \
needs, usually just \\"import Mathlib\\"",
"natural_language": "a short original English restatement in your own words (do \
not quote the source text verbatim)",
"requires": ["list of Mathlib identifiers the statement uses"],
"references": ["body labels this exercise restates or depends on, e.g. \
'Proposition 1.6.9', or [] if none"],
"include_in_bench": true,
"exclude_reason": null
}
Set "include_in_bench" to false (with a short "exclude_reason") instead of omitting \
an entry when an exercise is not a genuine general claim to formalize and prove -- \
e.g. it asks the reader to write a computer program, perform one specific numeric \
computation with no general statement, answer a yes/no or "discuss" question with \
no single formal claim, or the extracted text is too corrupted to recover its \
meaning confidently -- still emit the entry so nothing silently disappears, just \
mark it excluded.
id must be "goodman-" + section-prefix-with-hyphens + "-ex-" + the exercise's own \
last number component (and a lettered suffix for sub-parts), e.g. Exercise 1.6.5 -> \
"goodman-1-6-ex-5", part (a) of Exercise 1.6.11 -> "goodman-1-6-ex-11-a". Do not \
include a proof, "sorry", or "by" -- statement is only the proposition, no theorem \
keyword or name. Do not wrap the JSON in anything other than a single ```json \
fenced block."""
def user_prompt(section_text, exercises_text, checklist):
checklist_lines = '\n'.join(f"- Exercise {item['number']}: {item['first_line']}"
for item in checklist)
return (f"Checklist of exercise numbers in this section:\n{checklist_lines}\n\n"
f"Body text (for resolving 'Prove Proposition N.N.N'-style references):\n"
f"{section_text}\n\n"
f"Exercises text:\n{exercises_text}")
def validate(data, checklist=None):
if not isinstance(data, dict) or 'exercises' not in data:
raise ValueError('Response must be a JSON object with an "exercises" array')
exercises = data['exercises']
if not isinstance(exercises, list):
raise ValueError('"exercises" must be an array')
seen_ids = set()
covered_numbers = set()
for item in exercises:
for field in ('id', 'source_label', 'statement', 'prelude', 'include_in_bench'):
if field not in item:
raise ValueError(f'exercise entry missing field {field!r}: {item}')
if item['id'] in seen_ids:
raise ValueError(f'Duplicate exercise id: {item["id"]}')
seen_ids.add(item['id'])
# "Exercise 1.6.11(a)" or "Exercise 1.6.11" -> "1.6.11"
m = re.search(r'(\d+\.\d+\.\d+)', item['source_label'])
if m:
covered_numbers.add(m.group(1))
if checklist is not None:
expected = {c['number'] for c in checklist}
missing = expected - covered_numbers
if missing:
raise ValueError(
'Model silently dropped checklist items instead of marking them '
'include_in_bench=false with a reason (never allowed): '
+ ', '.join(sorted(missing, key=lambda s: tuple(int(p) for p in s.split('.'))))
)
return exercises
def main():
parser = argparse.ArgumentParser(description='LLM-formalize a section\'s exercises into Lean 4 bench targets')
parser.add_argument('section_text', type=Path, help='body text, from extract_section.py')
parser.add_argument('exercises_text', type=Path, help='from extract_exercises.py')
parser.add_argument('exercises_index', type=Path, help='from extract_exercises.py')
parser.add_argument('--out-dataset', type=Path, required=True, help='JSONL of exercises with include_in_bench=true')
parser.add_argument('--out-excluded', type=Path, help='JSONL of exercise entries the model excluded, for review')
parser.add_argument('--model', required=True)
parser.add_argument('--base-url', default='https://dashscope.aliyuncs.com/compatible-mode/v1')
parser.add_argument('--api-key-env', default='DASHSCOPE_API_KEY')
parser.add_argument('--env-file', type=Path, default=Path(__file__).resolve().parents[1] / '.env')
parser.add_argument('--timeout', type=float, default=300)
args = parser.parse_args()
load_dotenv(args.env_file)
client = ChatClient(args.model, args.base_url, args.api_key_env, args.timeout)
checklist = json.loads(args.exercises_index.read_text(encoding='utf-8'))
section_text = args.section_text.read_text(encoding='utf-8')
exercises_text = args.exercises_text.read_text(encoding='utf-8')
messages = [{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt(section_text, exercises_text, checklist)}]
reply = client.complete(messages)
data = extract_json_block(reply)
exercises = validate(data, checklist)
included = [t for t in exercises if t.get('include_in_bench')]
excluded = [t for t in exercises if not t.get('include_in_bench')]
with args.out_dataset.open('w', encoding='utf-8') as stream:
for item in included:
stream.write(json.dumps(item, ensure_ascii=False) + '\n')
if args.out_excluded:
with args.out_excluded.open('w', encoding='utf-8') as stream:
for item in excluded:
stream.write(json.dumps(item, ensure_ascii=False) + '\n')
print(f'{len(included)} exercises -> {args.out_dataset}')
if excluded:
note = f' (see {args.out_excluded})' if args.out_excluded else ' (pass --out-excluded to keep them)'
print(f'{len(excluded)} excluded{note}')
if __name__ == '__main__':
main()