|
| 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