|
| 1 | +#!/usr/bin/env julia |
| 2 | +# SPDX-License-Identifier: PMPL-1.0-or-later |
| 3 | +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) |
| 4 | +# |
| 5 | +# Mathematical Components (mathcomp) extractor for ECHIDNA training data. |
| 6 | +# Reads .v Coq/ssreflect source files and extracts Lemma/Theorem statements |
| 7 | +# together with their proofs (ssreflect tactics). |
| 8 | +# |
| 9 | +# Input: external_corpora/mathcomp/ (git clone of math-comp/math-comp) |
| 10 | +# Output: training_data/proof_states_mathcomp.a2ml |
| 11 | +# training_data/tactics_mathcomp.a2ml |
| 12 | +# training_data/stats_mathcomp.a2ml |
| 13 | + |
| 14 | +using Dates |
| 15 | +include("a2ml_emit.jl") |
| 16 | +using .A2MLEmit |
| 17 | + |
| 18 | +const ID_BASE = 200000 |
| 19 | +const PROVER = "Coq" # mathcomp targets Rocq/Coq |
| 20 | + |
| 21 | +# Match Lemma/Theorem/Corollary/Fact/Proposition/Remark <name> <binders>? : <stmt>. |
| 22 | +# We keep statement capture short-circuited at the first top-level period followed |
| 23 | +# by whitespace or newline — imperfect but fast. |
| 24 | +const STMT_PAT = r"(Lemma|Theorem|Corollary|Fact|Proposition|Remark)\s+([A-Za-z_][A-Za-z0-9_']*)\s*([^:.]*?):\s*(.*?)\.\s*\n"s |
| 25 | + |
| 26 | +const PROOF_PAT = r"Proof\.(.*?)(Qed|Defined|Admitted)\s*\."s |
| 27 | + |
| 28 | +""" |
| 29 | + split_tactics(proof_body::AbstractString) -> Vector{String} |
| 30 | +
|
| 31 | +Split ssreflect proof body into tactic steps at top-level periods. |
| 32 | +Balanced brackets are respected so `case: (foo x).` stays together. |
| 33 | +""" |
| 34 | +function split_tactics(proof_body::AbstractString)::Vector{String} |
| 35 | + tactics = String[] |
| 36 | + buf = IOBuffer() |
| 37 | + depth = 0 |
| 38 | + for c in proof_body |
| 39 | + if c == '(' || c == '[' || c == '{' |
| 40 | + depth += 1; print(buf, c) |
| 41 | + elseif c == ')' || c == ']' || c == '}' |
| 42 | + depth = max(0, depth - 1); print(buf, c) |
| 43 | + elseif c == '.' && depth == 0 |
| 44 | + s = strip(String(take!(buf))) |
| 45 | + isempty(s) || push!(tactics, s) |
| 46 | + else |
| 47 | + print(buf, c) |
| 48 | + end |
| 49 | + end |
| 50 | + s = strip(String(take!(buf))) |
| 51 | + isempty(s) || push!(tactics, s) |
| 52 | + return tactics |
| 53 | +end |
| 54 | + |
| 55 | +function find_v_files(base_dir::String)::Vector{String} |
| 56 | + files = String[] |
| 57 | + for (root, _dirs, fs) in walkdir(base_dir) |
| 58 | + for f in fs |
| 59 | + endswith(f, ".v") && push!(files, joinpath(root, f)) |
| 60 | + end |
| 61 | + end |
| 62 | + sort!(files) |
| 63 | + return files |
| 64 | +end |
| 65 | + |
| 66 | +function extract_all(base_dir::String) |
| 67 | + files = find_v_files(base_dir) |
| 68 | + println("Found $(length(files)) .v files in $base_dir") |
| 69 | + |
| 70 | + proof_states = Dict{String,Any}[] |
| 71 | + tactics = Dict{String,Any}[] |
| 72 | + skipped_noproof = 0 |
| 73 | + errors = 0 |
| 74 | + |
| 75 | + for (idx, fpath) in enumerate(files) |
| 76 | + content = try |
| 77 | + read(fpath, String) |
| 78 | + catch |
| 79 | + errors += 1 |
| 80 | + continue |
| 81 | + end |
| 82 | + |
| 83 | + theorem_matches = try |
| 84 | + collect(eachmatch(STMT_PAT, content)) |
| 85 | + catch |
| 86 | + errors += 1 |
| 87 | + continue |
| 88 | + end |
| 89 | + |
| 90 | + for tm in theorem_matches |
| 91 | + kind, name, binders, stmt = tm.captures |
| 92 | + record_id = ID_BASE + length(proof_states) |
| 93 | + |
| 94 | + # Look for Proof block immediately after the theorem statement |
| 95 | + proof_start = tm.offset + length(tm.match) |
| 96 | + rest = content[min(proof_start, lastindex(content)):end] |
| 97 | + pm = try |
| 98 | + match(PROOF_PAT, rest) |
| 99 | + catch |
| 100 | + nothing |
| 101 | + end |
| 102 | + |
| 103 | + if pm === nothing |
| 104 | + skipped_noproof += 1 |
| 105 | + continue |
| 106 | + end |
| 107 | + |
| 108 | + proof_body = pm.captures[1] |
| 109 | + terminator = pm.captures[2] |
| 110 | + steps = split_tactics(proof_body) |
| 111 | + isempty(steps) && continue |
| 112 | + |
| 113 | + theorem = String(strip(name)) |
| 114 | + goal = String(strip(stmt)) |
| 115 | + # Join binders into goal if present |
| 116 | + bstr = String(strip(binders)) |
| 117 | + isempty(bstr) || (goal = bstr * " : " * goal) |
| 118 | + |
| 119 | + rel = relpath(fpath, base_dir) |
| 120 | + push!(proof_states, Dict{String,Any}( |
| 121 | + "id" => record_id, |
| 122 | + "prover" => PROVER, |
| 123 | + "theorem" => theorem, |
| 124 | + "goal" => goal, |
| 125 | + "context" => String[], |
| 126 | + "source" => "mathcomp", |
| 127 | + "kind" => String(kind), |
| 128 | + "terminator" => String(terminator), |
| 129 | + "file" => rel, |
| 130 | + "proof_steps" => length(steps), |
| 131 | + )) |
| 132 | + |
| 133 | + for (step_idx, tac) in enumerate(steps) |
| 134 | + push!(tactics, Dict{String,Any}( |
| 135 | + "proof_id" => record_id, |
| 136 | + "step" => step_idx, |
| 137 | + "tactic" => tac, |
| 138 | + "prover" => PROVER, |
| 139 | + )) |
| 140 | + end |
| 141 | + end |
| 142 | + |
| 143 | + idx % 200 == 0 && println(" processed $idx/$(length(files)) files ...") |
| 144 | + end |
| 145 | + |
| 146 | + id_range = isempty(proof_states) ? "none" : "$(ID_BASE)-$(ID_BASE + length(proof_states) - 1)" |
| 147 | + stats = Dict{String,Any}( |
| 148 | + "version" => "v2.0-mathcomp", |
| 149 | + "extraction_date" => Dates.format(now(), dateformat"yyyy-mm-ddTHH:MM:SS"), |
| 150 | + "total_files_scanned" => length(files), |
| 151 | + "total_proofs" => length(proof_states), |
| 152 | + "total_tactics" => length(tactics), |
| 153 | + "skipped_no_proof" => skipped_noproof, |
| 154 | + "read_errors" => errors, |
| 155 | + "source" => "Mathematical Components (math-comp/math-comp)", |
| 156 | + "id_range" => id_range, |
| 157 | + ) |
| 158 | + return proof_states, tactics, stats |
| 159 | +end |
| 160 | + |
| 161 | +function save_results(proof_states, tactics, stats; output_dir="training_data") |
| 162 | + mkpath(output_dir) |
| 163 | + write_records_file( |
| 164 | + joinpath(output_dir, "proof_states_mathcomp.a2ml"), |
| 165 | + stats, proof_states, "proof-state"; |
| 166 | + header="mathcomp proof-state records (Coq/ssreflect training data)") |
| 167 | + write_records_file( |
| 168 | + joinpath(output_dir, "tactics_mathcomp.a2ml"), |
| 169 | + stats, tactics, "tactic"; |
| 170 | + header="mathcomp tactic records (ssreflect tactics per step)") |
| 171 | + open(joinpath(output_dir, "stats_mathcomp.a2ml"), "w") do fh |
| 172 | + println(fh, "# SPDX-License-Identifier: PMPL-1.0-or-later") |
| 173 | + println(fh, "# mathcomp extraction statistics"); println(fh) |
| 174 | + A2MLEmit.write_metadata_table(fh, stats) |
| 175 | + end |
| 176 | + println("\nSaved $(length(proof_states)) proof states, $(length(tactics)) tactics -> $output_dir/*_mathcomp.a2ml") |
| 177 | +end |
| 178 | + |
| 179 | +function main()::Int |
| 180 | + println("=" ^ 60) |
| 181 | + println("ECHIDNA Mathcomp Extractor") |
| 182 | + println("=" ^ 60) |
| 183 | + base_dir = "external_corpora/mathcomp" |
| 184 | + if !isdir(base_dir) |
| 185 | + println("ERROR: Corpus directory not found: $base_dir") |
| 186 | + return 1 |
| 187 | + end |
| 188 | + proof_states, tactics, stats = extract_all(base_dir) |
| 189 | + if isempty(proof_states) |
| 190 | + println("\nWARNING: No proof states extracted.") |
| 191 | + return 1 |
| 192 | + end |
| 193 | + save_results(proof_states, tactics, stats) |
| 194 | + println("\nTotal: $(stats["total_proofs"]) proofs / $(stats["total_tactics"]) tactics (IDs $(stats["id_range"]))") |
| 195 | + return 0 |
| 196 | +end |
| 197 | + |
| 198 | +if abspath(PROGRAM_FILE) == @__FILE__ |
| 199 | + exit(main()) |
| 200 | +end |
0 commit comments