Skip to content

Commit b0cd92f

Browse files
hyperpolymathclaude
andcommitted
feat(corpus): AFP extractor — Isabelle 98 → 20,584 proofs
Isabelle had 98 proofs, all from the tropical-resource-typing port — no coverage of the Archive of Formal Proofs that ships vendored under external_corpora/afp/thys (9,652 .thy files across 893 articles). scripts/extract_afp.jl scans AFP .thy files, strips block comments, and extracts lemma / theorem / corollary / proposition / schematic_goal declarations. For each it captures the goal (from either a direct quoted statement or an `assumes "..." shows "..."` block) and the opening of the proof (`by …`, `apply … done`, or `proof … qed`, bounded to 400 chars). A per-article cap of 25 proofs keeps one mega-article from dominating — 217,593 raw matches collapse to 20,486 balanced records covering all 893 articles. Output: training_data/proof_states_afp.jsonl (IDs 220000–240485). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d0762eb commit b0cd92f

2 files changed

Lines changed: 20827 additions & 0 deletions

File tree

scripts/extract_afp.jl

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
#!/usr/bin/env julia
2+
# SPDX-License-Identifier: PMPL-1.0-or-later
3+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
4+
#
5+
# extract_afp.jl — Mine the Archive of Formal Proofs (Isabelle/HOL)
6+
# vendored under external_corpora/afp/thys for ECHIDNA training JSONL.
7+
#
8+
# Isabelle was badly under-represented (98 proofs, all from the small
9+
# tropical-resource-typing port). The vendored AFP carries 6,461 .thy
10+
# files — we take a capped sample (~25 per article) so one mega-article
11+
# cannot skew the distribution, while still producing tens of thousands
12+
# of lemmas.
13+
#
14+
# Extraction strategy
15+
# -------------------
16+
# Scan .thy files for top-level keywords
17+
#
18+
# lemma | theorem | corollary | proposition | schematic_goal
19+
#
20+
# followed by an optional attribute list and an optional name, then:
21+
#
22+
# 1. A quoted statement — "... \<Longrightarrow> ..."
23+
# 2. Or an `assumes "..." shows "..."` block.
24+
#
25+
# followed by a proof starting with `by`, `apply`, or `proof ...`.
26+
#
27+
# Output
28+
# ------
29+
# training_data/proof_states_afp.jsonl
30+
# training_data/stats_afp.json
31+
#
32+
# ID range: 220000+
33+
#
34+
# Run: julia scripts/extract_afp.jl
35+
36+
using JSON3
37+
using Dates
38+
39+
const REPO_ROOT = dirname(dirname(abspath(@__FILE__)))
40+
const AFP_ROOT = joinpath(REPO_ROOT, "external_corpora", "afp", "thys")
41+
const OUTPUT_DIR = joinpath(REPO_ROOT, "training_data")
42+
const OUTPUT_FILE = joinpath(OUTPUT_DIR, "proof_states_afp.jsonl")
43+
const STATS_FILE = joinpath(OUTPUT_DIR, "stats_afp.json")
44+
const START_ID = 220000
45+
const MAX_PER_ARTICLE = 25 # keep the corpus balanced across AFP entries
46+
47+
# Keyword that introduces a theorem-shaped declaration.
48+
const THM_KW = raw"(?:lemma|theorem|corollary|proposition|schematic_goal)"
49+
50+
# One quoted string, allowing escaped quotes inside.
51+
const QUOTED = raw"\"(?:\\.|[^\"\\])*\""
52+
53+
# Regexes — compiled once.
54+
const NAMED_RE = Regex(
55+
string(
56+
"(?m)^\\s*", THM_KW, "\\s+",
57+
raw"([A-Za-z][A-Za-z0-9_']*)", # name
58+
raw"(?:\s*\[[^\]]*\])?", # optional [simp], [dest!], ...
59+
raw"\s*:\s*",
60+
),
61+
)
62+
const UNNAMED_RE = Regex(
63+
string("(?m)^\\s*", THM_KW, raw"\s*:?\s*\""),
64+
)
65+
const QUOTED_RE = Regex(QUOTED)
66+
const PROOF_HEAD_RE = r"^\s*(?:by\s|apply\s|proof\s|using\s|unfolding\s|\.\s*$)"
67+
68+
# ---------------------------------------------------------------------------
69+
# Parsing helpers
70+
# ---------------------------------------------------------------------------
71+
72+
"""
73+
strip_comments(src) -> String
74+
75+
Remove Isabelle line comments (`-- ...` is NOT used; Isabelle uses
76+
`(* ... *)` and text in `⟨...⟩`). We only strip block comments.
77+
"""
78+
function strip_comments(src::AbstractString)::String
79+
# Fast path if nothing to do.
80+
occursin("(*", src) || return String(src)
81+
out = IOBuffer()
82+
i = firstindex(src)
83+
n = lastindex(src)
84+
depth = 0
85+
while i <= n
86+
if i < n && src[i] == '(' && src[nextind(src, i)] == '*'
87+
depth += 1
88+
i = nextind(src, nextind(src, i))
89+
continue
90+
end
91+
if depth > 0 && i < n && src[i] == '*' && src[nextind(src, i)] == ')'
92+
depth -= 1
93+
i = nextind(src, nextind(src, i))
94+
continue
95+
end
96+
if depth == 0
97+
write(out, src[i])
98+
end
99+
i = nextind(src, i)
100+
end
101+
return String(take!(out))
102+
end
103+
104+
"""
105+
collect_quoted_after(src, idx) -> Union{Nothing, Tuple{String, Int}}
106+
107+
Starting at byte index `idx`, skip whitespace and return the next
108+
quoted string (body and the index just past the closing quote). Handles
109+
multi-line quotes.
110+
"""
111+
function collect_quoted_after(src::AbstractString, idx::Int)
112+
n = lastindex(src)
113+
# Skip whitespace.
114+
while idx <= n && isspace(src[idx])
115+
idx = nextind(src, idx)
116+
end
117+
idx <= n || return nothing
118+
src[idx] == '"' || return nothing
119+
j = nextind(src, idx)
120+
while j <= n
121+
c = src[j]
122+
if c == '\\' && j < n
123+
j = nextind(src, nextind(src, j))
124+
continue
125+
end
126+
if c == '"'
127+
body = src[nextind(src, idx):prevind(src, j)]
128+
return (String(body), nextind(src, j))
129+
end
130+
j = nextind(src, j)
131+
end
132+
return nothing
133+
end
134+
135+
"""
136+
tail_body(src, idx) -> String
137+
138+
Read from `idx` until the next top-level keyword (a line starting with
139+
another theorem-like keyword, `end`, `datatype`, `definition`,
140+
`fun`, `primrec`, etc.). Returns the chunk so we can mine the proof.
141+
"""
142+
function tail_body(src::AbstractString, idx::Int)::String
143+
remainder = src[idx:end]
144+
# Cut off at the next top-level boundary.
145+
cut_re = r"\n(?:lemma|theorem|corollary|proposition|schematic_goal|end|datatype|definition|fun|primrec|theory)\b"
146+
m = match(cut_re, remainder)
147+
return m === nothing ? String(remainder) : String(remainder[1:m.offset - 1])
148+
end
149+
150+
"""
151+
summarise_proof(body) -> String
152+
153+
Pull the first proof chunk — up to the next `qed`, `done`, or 400
154+
chars, whichever comes first. Returned verbatim (may contain Isabelle
155+
escape sequences).
156+
"""
157+
function summarise_proof(body::AbstractString)::String
158+
trimmed = lstrip(body)
159+
# Happy path: `by ...`
160+
m = match(r"^(by\s+(?:\([^()]*\)|[^\n\s]+)(?:[^\n]*))", trimmed)
161+
m !== nothing && return String(m.match)
162+
# `apply ... done`
163+
m = match(r"^(apply\s.*?^done)"m, trimmed)
164+
m !== nothing && return String(m.match)[1:min(400, lastindex(m.match))]
165+
# `proof ... qed`
166+
m = match(r"^(proof.*?^qed)"m, trimmed)
167+
m !== nothing && return String(m.match)[1:min(400, lastindex(m.match))]
168+
# Fallback: first line.
169+
idx_nl = findfirst('\n', trimmed)
170+
return idx_nl === nothing ? String(trimmed) : String(trimmed[1:prevind(trimmed, idx_nl)])
171+
end
172+
173+
"""
174+
extract_goal(body) -> Tuple{String, Vector{String}}
175+
176+
Given the body that follows a name-colon, extract the goal statement
177+
and any `assumes` context.
178+
"""
179+
function extract_goal(body::AbstractString)
180+
assumes = String[]
181+
goal = ""
182+
remainder = lstrip(body)
183+
184+
function _quote_after(s::AbstractString)
185+
qidx = findfirst('"', s)
186+
qidx === nothing && return nothing
187+
return collect_quoted_after(s, qidx)
188+
end
189+
190+
# assumes blocks
191+
while startswith(remainder, "assumes")
192+
q = _quote_after(remainder)
193+
q === nothing && break
194+
push!(assumes, q[1])
195+
remainder = lstrip(remainder[q[2]:end])
196+
# Another `and "..."` can follow.
197+
while startswith(remainder, "and")
198+
q2 = _quote_after(remainder)
199+
q2 === nothing && break
200+
push!(assumes, q2[1])
201+
remainder = lstrip(remainder[q2[2]:end])
202+
end
203+
end
204+
205+
if startswith(remainder, "shows")
206+
q = _quote_after(remainder)
207+
q !== nothing && (goal = q[1]; remainder = remainder[q[2]:end])
208+
else
209+
# Plain `"goal"` form.
210+
q = collect_quoted_after(remainder, firstindex(remainder))
211+
q !== nothing && (goal = q[1]; remainder = remainder[q[2]:end])
212+
end
213+
214+
return (goal, assumes, remainder)
215+
end
216+
217+
# ---------------------------------------------------------------------------
218+
# Per-file extraction
219+
# ---------------------------------------------------------------------------
220+
221+
function parse_file(path::String)::Vector{Dict{String,Any}}
222+
out = Dict{String,Any}[]
223+
local raw::String
224+
try
225+
raw = read(path, String)
226+
catch
227+
return out
228+
end
229+
src = strip_comments(raw)
230+
231+
article = begin
232+
rel = relpath(path, AFP_ROOT)
233+
first(splitpath(rel))
234+
end
235+
236+
seen = Set{String}()
237+
238+
# Iterate over all named-theorem matches.
239+
for m in eachmatch(NAMED_RE, src)
240+
name = String(m.captures[1])
241+
body_start = m.offset + sizeof(m.match)
242+
body_start <= lastindex(src) || continue
243+
body = tail_body(src, body_start)
244+
goal, assumes, rest = extract_goal(body)
245+
isempty(goal) && continue
246+
proof = summarise_proof(rest)
247+
key = name * "@" * article
248+
if !(key in seen)
249+
push!(seen, key)
250+
length(goal) > 400 && (goal = goal[1:400] * "")
251+
if length(proof) > 400
252+
proof = proof[1:400] * ""
253+
end
254+
push!(out, Dict{String,Any}(
255+
"theorem" => name,
256+
"goal" => goal,
257+
"context" => assumes,
258+
"tactic_proof" => proof,
259+
"source" => "afp/" * relpath(path, AFP_ROOT),
260+
"_article" => article,
261+
))
262+
end
263+
end
264+
265+
return out
266+
end
267+
268+
# ---------------------------------------------------------------------------
269+
# Main
270+
# ---------------------------------------------------------------------------
271+
272+
function main()
273+
println("EXTRACT AFP (Isabelle)")
274+
println("=" ^ 60)
275+
isdir(AFP_ROOT) || error("Missing $AFP_ROOT — vendor AFP first.")
276+
277+
files = String[]
278+
for (root, _, names) in walkdir(AFP_ROOT)
279+
for n in names
280+
endswith(n, ".thy") && push!(files, joinpath(root, n))
281+
end
282+
end
283+
println("Scanning $(length(files)) .thy files under AFP")
284+
285+
per_article = Dict{String, Int}()
286+
total_raw = 0
287+
all_recs = Dict{String,Any}[]
288+
289+
for f in files
290+
recs = parse_file(f)
291+
total_raw += length(recs)
292+
for r in recs
293+
article = r["_article"]
294+
if get(per_article, article, 0) < MAX_PER_ARTICLE
295+
push!(all_recs, r)
296+
per_article[article] = get(per_article, article, 0) + 1
297+
end
298+
end
299+
end
300+
301+
println("Raw declarations: $total_raw")
302+
println("After per-article cap: $(length(all_recs)) (cap=$MAX_PER_ARTICLE)")
303+
println("Articles represented: $(length(per_article))")
304+
305+
mkpath(OUTPUT_DIR)
306+
nid = START_ID
307+
open(OUTPUT_FILE, "w") do fh
308+
for rec in all_recs
309+
delete!(rec, "_article")
310+
rec["id"] = nid
311+
rec["prover"] = "Isabelle"
312+
JSON3.write(fh, rec)
313+
println(fh)
314+
nid += 1
315+
end
316+
end
317+
total = nid - START_ID
318+
println("Wrote $total records to $OUTPUT_FILE")
319+
320+
stats = Dict{String,Any}(
321+
"prover" => "Isabelle",
322+
"total_proofs" => total,
323+
"files_scanned" => length(files),
324+
"articles" => length(per_article),
325+
"raw_before_cap" => total_raw,
326+
"per_article_cap" => MAX_PER_ARTICLE,
327+
"id_range" => [START_ID, nid - 1],
328+
"source" => "external_corpora/afp/thys",
329+
"extraction_date" => string(today()),
330+
"extractor" => "scripts/extract_afp.jl",
331+
)
332+
open(STATS_FILE, "w") do fh
333+
JSON3.pretty(fh, stats)
334+
end
335+
println("Wrote $STATS_FILE")
336+
337+
println("=" ^ 60)
338+
println("DONE")
339+
end
340+
341+
main()

0 commit comments

Comments
 (0)