|
| 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_agda.jl — Mine the locally-vendored Agda standard library |
| 6 | +# (external_corpora/agda-stdlib) for proof-shaped declarations and emit |
| 7 | +# ECHIDNA-format JSONL. |
| 8 | +# |
| 9 | +# Agda was the worst-represented prover in the corpus (1 proof of |
| 10 | +# 209,517). The stdlib vendored under external_corpora/ carries ~1,260 |
| 11 | +# .agda files — more than enough to lift Agda out of single-digit |
| 12 | +# territory. |
| 13 | +# |
| 14 | +# Extraction strategy |
| 15 | +# ------------------- |
| 16 | +# Agda top-level declarations follow the shape |
| 17 | +# |
| 18 | +# name : Type |
| 19 | +# |
| 20 | +# where `Type` may span multiple lines. We: |
| 21 | +# |
| 22 | +# 1. Walk agda-stdlib/src/**/*.agda. |
| 23 | +# 2. Skip blank lines, line/block comments, pragmas, module / open / |
| 24 | +# import / private / record / data / syntax / instance directives. |
| 25 | +# 3. Accumulate a signature line + its indented continuations. |
| 26 | +# 4. Keep a declaration when the signature references proof-relevant |
| 27 | +# Agda constructs: propositional equality `_≡_`, decidable `Dec`, |
| 28 | +# relations (`Rel`, `_≤_`, `_<_`), universally-quantified |
| 29 | +# statements (`∀`, `Π`), `All`, `Any`, `¬`, `⊥`, `⊤`. |
| 30 | +# 5. Emit one JSONL record per kept declaration. |
| 31 | +# |
| 32 | +# Output |
| 33 | +# ------ |
| 34 | +# training_data/proof_states_agda.jsonl |
| 35 | +# training_data/stats_agda.json |
| 36 | +# |
| 37 | +# ID range: 210000+ |
| 38 | +# |
| 39 | +# Run: julia scripts/extract_agda.jl |
| 40 | + |
| 41 | +using JSON3 |
| 42 | +using Dates |
| 43 | + |
| 44 | +const REPO_ROOT = dirname(dirname(abspath(@__FILE__))) |
| 45 | +const AGDA_ROOT = joinpath(REPO_ROOT, "external_corpora", "agda-stdlib") |
| 46 | +const AGDA_SRC = joinpath(AGDA_ROOT, "src") |
| 47 | +const OUTPUT_DIR = joinpath(REPO_ROOT, "training_data") |
| 48 | +const OUTPUT_FILE = joinpath(OUTPUT_DIR, "proof_states_agda.jsonl") |
| 49 | +const STATS_FILE = joinpath(OUTPUT_DIR, "stats_agda.json") |
| 50 | +const START_ID = 210000 |
| 51 | + |
| 52 | +# Signature-level filters. |
| 53 | +const PROOFY = [ |
| 54 | + "_≡_", "≡", "_≢_", "≢", |
| 55 | + "Dec ", "Dec.", "Dec(", "Dec{", |
| 56 | + "Rel ", "_≤_", "_<_", "_≥_", "_>_", |
| 57 | + "∀", "Π", "Σ", |
| 58 | + "All ", "Any ", |
| 59 | + "¬", "⊥", "⊤", |
| 60 | + "Reflexive", "Symmetric", "Transitive", "Total", |
| 61 | + "IsCommutative", "IsAssociative", "Injective", "Surjective", "Bijective", |
| 62 | + "Monoid", "Group", "Ring", "Semiring", "Lattice", |
| 63 | + "Homomorphism", "Isomorphism", |
| 64 | +] |
| 65 | + |
| 66 | +# Lines we never open a declaration on. |
| 67 | +const SKIP_PREFIXES = [ |
| 68 | + "module", "open", "import", "private", "abstract", "mutual", |
| 69 | + "syntax", "infix", "infixl", "infixr", |
| 70 | + "record", "data", "postulate", "instance", |
| 71 | + "pattern", "field", "variable", "let", "where", |
| 72 | + "{-", "---", "-- ", |
| 73 | + "{-#", "primitive", |
| 74 | +] |
| 75 | + |
| 76 | +const KW_AS_NAME = Set([ |
| 77 | + "module", "open", "import", "private", "where", "let", "in", |
| 78 | + "data", "record", "field", "instance", "postulate", "primitive", |
| 79 | + "with", "rewrite", "do", "λ", "forall", "syntax", |
| 80 | + "infix", "infixl", "infixr", "abstract", "mutual", |
| 81 | + "pattern", "variable", "constructor", "hiding", "renaming", |
| 82 | +]) |
| 83 | + |
| 84 | +# --------------------------------------------------------------------------- |
| 85 | +# Parsing |
| 86 | +# --------------------------------------------------------------------------- |
| 87 | + |
| 88 | +""" |
| 89 | + line_indent(line) -> Int |
| 90 | +
|
| 91 | +Number of leading spaces on `line` (tabs count as 2). |
| 92 | +""" |
| 93 | +function line_indent(line::AbstractString)::Int |
| 94 | + n = 0 |
| 95 | + for c in line |
| 96 | + c == ' ' ? (n += 1) : c == '\t' ? (n += 2) : break |
| 97 | + end |
| 98 | + return n |
| 99 | +end |
| 100 | + |
| 101 | +""" |
| 102 | + is_skippable(stripped) -> Bool |
| 103 | +
|
| 104 | +True if this trimmed line cannot begin a top-level signature. |
| 105 | +""" |
| 106 | +function is_skippable(stripped::AbstractString)::Bool |
| 107 | + isempty(stripped) && return true |
| 108 | + for p in SKIP_PREFIXES |
| 109 | + startswith(stripped, p) && return true |
| 110 | + end |
| 111 | + return false |
| 112 | +end |
| 113 | + |
| 114 | +""" |
| 115 | + split_signature(line) -> Union{Nothing, Tuple{String, String}} |
| 116 | +
|
| 117 | +If `line` parses as `name : body`, return `(name, body)`. Otherwise |
| 118 | +return `nothing`. Only accepts a non-indented line. |
| 119 | +
|
| 120 | +Colon parsing: take the first ` : ` that is NOT inside parens / braces |
| 121 | +and NOT adjacent to `::`. |
| 122 | +""" |
| 123 | +function split_signature(line::AbstractString) |
| 124 | + depth = 0 |
| 125 | + i = firstindex(line) |
| 126 | + while i < lastindex(line) |
| 127 | + c = line[i] |
| 128 | + if c == '(' || c == '{' || c == '[' |
| 129 | + depth += 1 |
| 130 | + elseif c == ')' || c == '}' || c == ']' |
| 131 | + depth -= 1 |
| 132 | + elseif depth == 0 && c == ':' && i > firstindex(line) |
| 133 | + prev = prevind(line, i) |
| 134 | + nxt = nextind(line, i) |
| 135 | + if line[prev] == ' ' && nxt <= lastindex(line) && line[nxt] == ' ' |
| 136 | + # Exclude `::` just in case (Agda uses `:` but be safe). |
| 137 | + nxt2 = nxt <= lastindex(line) ? nxt : lastindex(line) |
| 138 | + if nxt2 <= lastindex(line) && line[nxt2] != ':' |
| 139 | + name = strip(line[firstindex(line):prev]) |
| 140 | + body = strip(line[nxt:lastindex(line)]) |
| 141 | + return (String(name), String(body)) |
| 142 | + end |
| 143 | + end |
| 144 | + end |
| 145 | + i = nextind(line, i) |
| 146 | + end |
| 147 | + return nothing |
| 148 | +end |
| 149 | + |
| 150 | +""" |
| 151 | + looks_proofy(sig) -> Bool |
| 152 | +
|
| 153 | +True if the signature body references a proof-relevant construct. |
| 154 | +""" |
| 155 | +function looks_proofy(sig::AbstractString)::Bool |
| 156 | + for kw in PROOFY |
| 157 | + occursin(kw, sig) && return true |
| 158 | + end |
| 159 | + return false |
| 160 | +end |
| 161 | + |
| 162 | +""" |
| 163 | + parse_file(path) -> Vector{Dict{String,Any}} |
| 164 | +
|
| 165 | +Pull keepable declarations out of one Agda source file. |
| 166 | +""" |
| 167 | +function parse_file(path::String)::Vector{Dict{String,Any}} |
| 168 | + out = Dict{String,Any}[] |
| 169 | + local lines::Vector{String} |
| 170 | + try |
| 171 | + lines = readlines(path) |
| 172 | + catch |
| 173 | + return out |
| 174 | + end |
| 175 | + |
| 176 | + i = 1 |
| 177 | + n = length(lines) |
| 178 | + seen = Set{String}() |
| 179 | + rel = relpath(path, REPO_ROOT) |
| 180 | + |
| 181 | + while i <= n |
| 182 | + raw = lines[i] |
| 183 | + stripped = lstrip(raw) |
| 184 | + |
| 185 | + # Top-level only: skip anything indented or skippable. |
| 186 | + if line_indent(raw) != 0 || is_skippable(stripped) |
| 187 | + i += 1 |
| 188 | + continue |
| 189 | + end |
| 190 | + |
| 191 | + sigparts = split_signature(stripped) |
| 192 | + if sigparts === nothing |
| 193 | + i += 1 |
| 194 | + continue |
| 195 | + end |
| 196 | + name, body = sigparts |
| 197 | + |
| 198 | + # Guard against single-char or keyword "names". |
| 199 | + if isempty(name) || name in KW_AS_NAME || length(name) > 120 |
| 200 | + i += 1 |
| 201 | + continue |
| 202 | + end |
| 203 | + # Multi-word names (e.g. "f g h") are not proper declarations. |
| 204 | + occursin(' ', name) && (i += 1; continue) |
| 205 | + |
| 206 | + # Accumulate continuation lines (indented or blank in between). |
| 207 | + buf = [body] |
| 208 | + j = i + 1 |
| 209 | + while j <= n |
| 210 | + nxt = lines[j] |
| 211 | + nstripped = lstrip(nxt) |
| 212 | + if isempty(nstripped) |
| 213 | + j += 1; continue |
| 214 | + end |
| 215 | + if line_indent(nxt) > 0 |
| 216 | + push!(buf, strip(nstripped)) |
| 217 | + j += 1 |
| 218 | + else |
| 219 | + break |
| 220 | + end |
| 221 | + end |
| 222 | + |
| 223 | + sig_body = replace(join(buf, " "), r"\s+" => " ") |
| 224 | + # Bound the recorded signature length to keep JSONL rows compact. |
| 225 | + if length(sig_body) > 400 |
| 226 | + sig_body = sig_body[1:400] * "…" |
| 227 | + end |
| 228 | + |
| 229 | + if looks_proofy(sig_body) |
| 230 | + key = name * "@" * rel |
| 231 | + if !(key in seen) |
| 232 | + push!(seen, key) |
| 233 | + ctx = collect_context(sig_body) |
| 234 | + push!(out, Dict{String,Any}( |
| 235 | + "theorem" => name, |
| 236 | + "goal" => sig_body, |
| 237 | + "context" => ctx, |
| 238 | + "source" => "agda-stdlib/" * relpath(path, AGDA_ROOT), |
| 239 | + )) |
| 240 | + end |
| 241 | + end |
| 242 | + |
| 243 | + i = max(j, i + 1) |
| 244 | + end |
| 245 | + |
| 246 | + return out |
| 247 | +end |
| 248 | + |
| 249 | +""" |
| 250 | + collect_context(sig) -> Vector{String} |
| 251 | +
|
| 252 | +Pick up to ten distinctive keywords from the signature so the dataloader |
| 253 | +has a non-empty context field to work with. |
| 254 | +""" |
| 255 | +function collect_context(sig::AbstractString)::Vector{String} |
| 256 | + ctx = String[] |
| 257 | + for kw in ( |
| 258 | + "_≡_", "Dec", "Rel", "_≤_", "_<_", "∀", "Π", "Σ", |
| 259 | + "All", "Any", "¬", "⊥", "⊤", |
| 260 | + "Reflexive", "Symmetric", "Transitive", |
| 261 | + "IsCommutative", "Injective", "Surjective", |
| 262 | + "Monoid", "Group", "Ring", "Semiring", |
| 263 | + "Homomorphism", "Isomorphism", |
| 264 | + ) |
| 265 | + occursin(kw, sig) && push!(ctx, kw) |
| 266 | + length(ctx) >= 10 && break |
| 267 | + end |
| 268 | + return ctx |
| 269 | +end |
| 270 | + |
| 271 | +# --------------------------------------------------------------------------- |
| 272 | +# Main |
| 273 | +# --------------------------------------------------------------------------- |
| 274 | + |
| 275 | +function main() |
| 276 | + println("EXTRACT AGDA") |
| 277 | + println("=" ^ 60) |
| 278 | + |
| 279 | + isdir(AGDA_SRC) || error("Missing $AGDA_SRC — vendor agda-stdlib first.") |
| 280 | + |
| 281 | + files = String[] |
| 282 | + for (root, _, names) in walkdir(AGDA_SRC) |
| 283 | + for n in names |
| 284 | + endswith(n, ".agda") && push!(files, joinpath(root, n)) |
| 285 | + end |
| 286 | + end |
| 287 | + println("Scanning $(length(files)) .agda files under $AGDA_SRC") |
| 288 | + |
| 289 | + all_records = Dict{String,Any}[] |
| 290 | + parsed_ok = 0 |
| 291 | + for f in files |
| 292 | + recs = parse_file(f) |
| 293 | + if !isempty(recs) |
| 294 | + parsed_ok += 1 |
| 295 | + append!(all_records, recs) |
| 296 | + end |
| 297 | + end |
| 298 | + println("Files with keepable decls: $parsed_ok") |
| 299 | + println("Raw declarations: $(length(all_records))") |
| 300 | + |
| 301 | + # Assign IDs and write JSONL. |
| 302 | + mkpath(OUTPUT_DIR) |
| 303 | + nid = START_ID |
| 304 | + open(OUTPUT_FILE, "w") do fh |
| 305 | + for rec in all_records |
| 306 | + rec["id"] = nid |
| 307 | + rec["prover"] = "Agda" |
| 308 | + rec["tactic_proof"] = "" # Agda stdlib uses equational proofs; |
| 309 | + # the body lives in the source file |
| 310 | + # referenced in rec["source"]. |
| 311 | + JSON3.write(fh, rec) |
| 312 | + println(fh) |
| 313 | + nid += 1 |
| 314 | + end |
| 315 | + end |
| 316 | + total = nid - START_ID |
| 317 | + println("Wrote $total records to $OUTPUT_FILE") |
| 318 | + |
| 319 | + # Stats. |
| 320 | + stats = Dict{String,Any}( |
| 321 | + "prover" => "Agda", |
| 322 | + "total_proofs" => total, |
| 323 | + "files_scanned" => length(files), |
| 324 | + "files_with_proofs" => parsed_ok, |
| 325 | + "id_range" => [START_ID, nid - 1], |
| 326 | + "source" => "external_corpora/agda-stdlib", |
| 327 | + "extraction_date" => string(today()), |
| 328 | + "extractor" => "scripts/extract_agda.jl", |
| 329 | + ) |
| 330 | + open(STATS_FILE, "w") do fh |
| 331 | + JSON3.pretty(fh, stats) |
| 332 | + end |
| 333 | + println("Wrote $STATS_FILE") |
| 334 | + |
| 335 | + println("=" ^ 60) |
| 336 | + println("DONE") |
| 337 | +end |
| 338 | + |
| 339 | +main() |
0 commit comments