File size: 19,721 Bytes
63f41a6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 | #!/usr/bin/env python
"""Stream candidate Lean statement sets into the P2 Problem JSONL schema (plan v3, Phase 0a item 4).
Sources (docs/workbook/data/Datasets.json): FormalMATH-All, NuminaMath-LEAN-Sol,
Nemotron-Math-Proofs-v1, RL-Goedel-Pset (levels 3-5 / 4-5), Lean4-FormalConjectures.
Properties
* Streaming and memory-bounded: JSONL files are read line by line over HTTP (Range-resumable,
`align.data.hub.iter_lines`), parquet-only datasets through the datasets-server JSON /rows API
(no pyarrow). Nothing is ever held beyond one row plus the per-source dedup set of statement shas.
* Every row is pinned to the dataset revision sha (HF API, resolved once at start) and carries
`provenance = {source_id, revision, row_index, license, ...}` plus an `ancestry` record
(`root` = own problem_id, `parents` = []), the field `align.data.decontam` propagates to mutation
children.
* Output rows are PROBLEM_FIELDS (+ `provenance`, `ancestry`): `formal_statement` is
`GOEDEL_HEADER + ... := by sorry` (the header is REWRITTEN when the source's differs, recorded in
`provenance.header_rewritten` / `original_header`; whether the rewrite still elaborates is the
elaboration check's job, `elab_hash.py` / `build_problems.py --phase check`).
* Nothing is dropped silently: every drop is counted by reason in `ingest_manifest.json` and the first
few examples per reason are kept in `<source>.drops.sample.json`.
HF_HOME=/apps/development/rlhf/hf_cache HF_HUB_DISABLE_XET=1 \
.venv/bin/python scripts/p2/supply/ingest.py --out-dir .tmp/p2/supply --workers 2
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import logging
import os
import re
import sys
import time
import urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any, Callable, Iterator
REPO = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(REPO))
os.environ.setdefault("HF_HOME", "/apps/development/rlhf/hf_cache")
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
def _load_common():
if "p2_common" in sys.modules:
return sys.modules["p2_common"]
spec = importlib.util.spec_from_file_location("p2_common", REPO / "scripts" / "p2" / "p2_common.py")
mod = importlib.util.module_from_spec(spec)
sys.modules["p2_common"] = mod
spec.loader.exec_module(mod)
return mod
C = _load_common()
from align.data import hub # noqa: E402 (after sys.path)
log = logging.getLogger("p2.supply.ingest")
MAX_STATEMENT_CHARS = 4000 # same cap as build_problems.py
DEFAULT_OUT = Path(os.environ.get("ALIGN_P2_TMP", "/apps/development/rlhf/align/.tmp/p2")) / "supply"
_BY_SORRY_TAIL = re.compile(r":=\s*sorry\s*$")
_BY_TAIL = re.compile(r":=\s*by\s*$")
# --------------------------------------------------------------------------- source registry
class Source:
def __init__(self, key, repo_id, license_, iterate, split="train", problem_source=None, note=""):
self.key, self.repo_id, self.license, self.iterate = key, repo_id, license_, iterate
self.split, self.problem_source, self.note = split, problem_source or key, note
def _it_formalmath(src: Source, rev: str, args) -> Iterator[dict]:
"""FormalMATH_all.json is one 13 MB JSON array (the repo has no JSONL); read once, yield rows."""
req = urllib.request.Request(hub.resolve_url(src.repo_id, "FormalMATH_all.json", rev))
with urllib.request.urlopen(req, timeout=120) as r:
data = json.loads(r.read())
for i, row in enumerate(data):
yield {"row_index": i, "statement": row.get("autoformalization"), "header": None,
"informal": row.get("refined_statement"), "name": row.get("theorem_names"),
"extra": {"domain": row.get("domain"), "orig_source": row.get("source")}}
def _it_numina(src: Source, rev: str, args) -> Iterator[dict]:
for i, raw in enumerate(hub.iter_lines(hub.resolve_url(src.repo_id, "train.jsonl", rev))):
row = json.loads(raw) if raw.strip() else None
if row is None:
continue
yield {"row_index": i, "statement": row.get("formal_statement"), "header": None,
"informal": row.get("problem"), "name": row.get("uuid"),
"english": str(row.get("is_english")).lower() != "false",
"extra": {"uuid": row.get("uuid"), "orig_source": row.get("source"),
"statement_source": row.get("statement_source")}}
def _it_nemotron(src: Source, rev: str, args) -> Iterator[dict]:
"""29.5 GB file (whole trajectories): never downloaded in full. `--nemotron-stripes` evenly spaced
Range windows of `--nemotron-rows/stripes` lines each, so the sample is not biased to the file head.
Row locator = byte offset of the line (stable for a pinned revision)."""
url = hub.resolve_url(src.repo_id, "data/lean.jsonl", rev)
req = urllib.request.Request(url, method="HEAD")
with hub._urlopen(req) as r: # follows the redirect
size = int(r.headers["Content-Length"])
stripes = max(1, args.nemotron_stripes)
per = max(1, args.nemotron_rows // stripes)
for s in range(stripes):
start = (size // stripes) * s
pos = start
n = 0
first = start > 0 # the first line after a mid-file Range is a fragment: skip it
for raw in hub.iter_lines(url, start_byte=start):
here = pos
pos += len(raw) + 1
if first:
first = False
continue
if not raw.strip():
continue
try:
row = json.loads(raw)
except json.JSONDecodeError:
continue # truncated last line of the file only
yield {"row_index": here, "statement": row.get("formal_statement"), "header": row.get("lean_header"),
"informal": row.get("problem"), "name": row.get("uuid"),
"extra": {"uuid": row.get("uuid"), "orig_source": row.get("source"), "byte_offset": here,
"stripe": s, "row_license": row.get("license")}}
n += 1
if n >= per:
break
def _parquet_or_server_rows(repo_id: str, rev: str, max_rows: int | None) -> Iterator[dict]:
"""Rows of the single `train` split. With pyarrow (run under .venv-rollout; the import lives in
align.data.ingest_parquet, the one whitelisted module) the pinned parquet shard(s) are fetched once to
HF_HOME and streamed in row batches. Without it: the datasets-server JSON /rows API, paced at 1 page/s
with a long backoff on HTTP 429 (the server rate-limits sequential paging)."""
from align.data import ingest_parquet as ip
try:
ip._require_pyarrow()
have_pa = True
except ip.IngestUnavailable:
have_pa = False
if have_pa:
dest = Path(os.environ["HF_HOME"]) / "align_streams" / "supply" / repo_id.replace("/", "__") / rev
dest.mkdir(parents=True, exist_ok=True)
files = [e for e in hub.list_repo_files(repo_id, rev, "data") if e.get("type") == "file" and e["path"].endswith(".parquet")]
emitted = 0
for e in sorted(files, key=lambda e: e["path"]):
target = dest / Path(e["path"]).name
want = (e.get("lfs") or {}).get("oid")
if not (target.exists() and target.stat().st_size == e["size"]):
tmp = target.with_name(target.name + f".tmp{os.getpid()}")
try:
with urllib.request.urlopen(hub.resolve_url(repo_id, e["path"], rev), timeout=300) as r, open(tmp, "wb") as f:
while chunk := r.read(4 << 20):
f.write(chunk)
if want and C.sha256_file(tmp) != want:
raise RuntimeError(f"{e['path']}: sha256 differs from the hub's LFS oid")
os.replace(tmp, target)
finally:
if tmp.exists():
tmp.unlink()
for row in ip.iter_parquet_rows(target, prefer_ingested=False):
if max_rows and emitted >= max_rows:
return
emitted += 1
yield row
return
offset, emitted = 0, 0
while not max_rows or emitted < max_rows:
for attempt in range(8):
try:
page = hub.fetch_rows_page(repo_id, "default", "train", offset, 100, use_cache=False)
break
except hub.HubError as err:
if "429" not in str(err) or attempt == 7:
raise
time.sleep(60)
rows = page["rows"]
if not rows:
return
for r in rows:
if r.get("truncated_cells"):
raise hub.HubError(f"truncated cells at row {r.get('row_idx')}")
if max_rows and emitted >= max_rows:
return
emitted += 1
yield r["row"]
offset += len(rows)
if offset >= page.get("num_rows_total", offset + 1):
return
time.sleep(1.0)
def _it_rows(src: Source, rev: str, args) -> Iterator[dict]:
for i, row in enumerate(_parquet_or_server_rows(src.repo_id, rev, args.max_rows)):
yield {"row_index": i, "statement": row.get("formal_statement"), "header": None,
"informal": row.get("informal_statement"), "name": row.get("problem_id"),
"extra": {"orig_problem_id": row.get("problem_id"), "orig_source": row.get("source")}}
def _it_conjectures(src: Source, rev: str, args) -> Iterator[dict]:
"""Rows are declarations: statement = everything after the name (binders + type)."""
for i, row in enumerate(_parquet_or_server_rows(src.repo_id, rev, args.max_rows)):
kind = (row.get("type") or "").strip()
imports = row.get("imports") or "[]"
yield {"row_index": i, "statement": None, "header": None, "informal": row.get("docstring") or None,
"name": row.get("symbolic_name"), "decl_kind": kind, "decl_tail": row.get("statement"),
"imports": imports,
"extra": {"library": row.get("library"), "filename": row.get("filename"),
"source_url": row.get("source_url"), "upstream_commit": row.get("commit")}}
SOURCES: dict[str, Source] = {s.key: s for s in [
Source("formalmath-all", "SphereLab/FormalMATH-All", "mit", _it_formalmath),
Source("numinamath-lean-sol", "iiis-lean/NuminaMath-LEAN-Sol", "apache-2.0", _it_numina),
Source("nemotron-math-proofs-v1", "nvidia/Nemotron-Math-Proofs-v1", "cc-by-sa-4.0", _it_nemotron,
note="sampled in Range stripes; the full file is 29.5 GB of trajectories"),
Source("rl-goedel-pset-l45", "HangHor/RL-Goedel-Pset-Level-4-5", "mit", _it_rows,
note="levels 4-5 proper (46,290 rows)"),
Source("rl-goedel-pset-l35", "HangHor/RL-Goedel-Pset-Level-3-5", "mit", _it_rows,
note="levels 3-5 superset of l45; not in the default set"),
Source("lean4-formalconjectures", "phanerozoic/Lean4-FormalConjectures", "apache-2.0", _it_conjectures),
]}
DEFAULT_SOURCES = ["formalmath-all", "numinamath-lean-sol", "nemotron-math-proofs-v1", "rl-goedel-pset-l45",
"lean4-formalconjectures"]
def repo_revision(repo_id: str) -> str:
with urllib.request.urlopen(f"https://huggingface.co/api/datasets/{repo_id}", timeout=60) as r:
return json.loads(r.read())["sha"]
# --------------------------------------------------------------------------- pure conversion
def assemble_statement(rec: dict) -> tuple[str, dict]:
"""-> (candidate full source text, extra provenance). Raises ContractError('drop:<reason>')."""
extra: dict[str, Any] = {}
if rec.get("decl_kind") is not None: # FormalConjectures declaration
kind = rec["decl_kind"]
if kind not in ("theorem", "lemma"):
raise C.ContractError("drop:not_theorem_or_lemma")
imps = str(rec.get("imports", "")).lower()
if "formalconjectures" in imps or "formal_conjectures" in imps:
raise C.ContractError("drop:needs_nonmathlib_imports")
if not rec.get("name") or not rec.get("decl_tail"):
raise C.ContractError("drop:empty_statement")
raw = f"{kind} {rec['name']} {rec['decl_tail'].strip()} := by sorry"
return C.GOEDEL_HEADER + raw, {"constructed_from_declaration": True}
raw = rec.get("statement")
if not isinstance(raw, str) or not raw.strip():
raise C.ContractError("drop:empty_statement")
raw = raw.strip("\n")
if rec.get("header"):
raw = rec["header"].rstrip() + "\n\n" + raw.lstrip()
if _BY_SORRY_TAIL.search(raw): # `:= sorry` term-mode placeholder
raw = _BY_SORRY_TAIL.sub(":= by sorry", raw)
extra["sorry_term_rewritten"] = True
elif _BY_TAIL.search(raw): # statement stored without the placeholder
raw = raw.rstrip() + " sorry"
extra["sorry_appended"] = True
elif ":= by" not in raw and re.search(r"\bsorry\b", raw) is None:
raw = raw.rstrip() + " := by sorry" # statement stored without any body
extra["body_appended"] = True
return raw, extra
def _open_names(header: str) -> set[str]:
out: set[str] = set()
for ln in header.splitlines():
ln = ln.strip()
if ln.startswith("open "):
out.update(ln[5:].split())
return out
def to_problem(rec: dict, source: Source, revision: str, pid: str) -> tuple[dict, dict]:
"""Full conversion of one raw row -> (Problem record, side info). Raises ContractError('drop:<reason>')."""
raw, extra = assemble_statement(rec)
if len(raw) > MAX_STATEMENT_CHARS:
raise C.ContractError("drop:too_long")
try:
stmt = C.normalize_formal_statement(raw)
except C.ContractError:
raise C.ContractError("drop:not_single_by_sorry")
try:
name, header, _sig = C.statement_key(stmt)
except C.ContractError as e:
why = re.sub(r"`[^`]*`", "`X`", str(e).replace("unsupported statement: ", ""))
raise C.ContractError(f"drop:unsupported_shape: {why}"[:120])
prov_header = {}
if header.rstrip() != C.GOEDEL_HEADER.rstrip():
parsed = C._strict().parse_statement(stmt)
body = stmt[len(parsed.header_text):].lstrip("\n")
stmt = C.GOEDEL_HEADER + body
prov_header = {"header_rewritten": True, "original_header": header.strip()[:600]}
extra_open = sorted(_open_names(header) - _open_names(C.GOEDEL_HEADER))
if extra_open: # the rewrite drops namespaces the statement may rely on: elaboration decides
prov_header["header_extra_opens"] = extra_open
try: # the rewritten statement must still be a supported shape
C.statement_key(stmt)
except C.ContractError:
raise C.ContractError("drop:unsupported_after_header_rewrite")
prob = {
"problem_id": pid, "source": source.problem_source, "split": "train", "formal_statement": stmt,
"informal_prefix": rec.get("informal") if isinstance(rec.get("informal"), str) and rec["informal"].strip() else None,
"statement_sha": C.statement_sha(stmt),
"provenance": {"source_id": source.repo_id, "revision": revision, "row_index": rec["row_index"],
"license": source.license, "theorem_name": name, **extra, **prov_header, **rec.get("extra", {})},
"ancestry": {"root": pid, "parents": []},
}
C.validate_problem(prob)
return prob, {"header_rewritten": bool(prov_header)}
def ingest_source(src: Source, revision: str, out_dir: Path, args) -> dict:
counts: Counter = Counter()
drop_examples: dict[str, list] = {}
seen: set[str] = set()
t0 = time.time()
out_path = out_dir / f"{src.key}.jsonl"
tmp = out_path.with_name(out_path.name + f".tmp{os.getpid()}")
try:
with open(tmp, "w", encoding="utf-8") as f:
for rec in src.iterate(src, revision, args):
counts["rows_seen"] += 1
if rec.get("english") is False:
counts["drop:not_english"] += 1
continue
pid = f"{src.key}-{rec['row_index']}"
try:
prob, side = to_problem(rec, src, revision, pid)
except C.ContractError as e:
why = str(e)
if not why.startswith("drop:"):
raise
counts[why] += 1
ex = drop_examples.setdefault(why, [])
if len(ex) < 3:
ex.append({"row_index": rec["row_index"], "name": rec.get("name")})
continue
if prob["statement_sha"] in seen:
counts["drop:duplicate_statement"] += 1
continue
seen.add(prob["statement_sha"])
f.write(json.dumps(prob, ensure_ascii=False) + "\n")
counts["kept"] += 1
counts["header_rewritten"] += int(side["header_rewritten"])
counts["header_extra_opens"] += int("header_extra_opens" in prob["provenance"])
if counts["rows_seen"] % 20000 == 0:
log.info("%s: %d rows, %d kept", src.key, counts["rows_seen"], counts["kept"])
f.flush()
os.fsync(f.fileno())
os.replace(tmp, out_path)
finally:
if tmp.exists():
tmp.unlink()
(out_dir / f"{src.key}.drops.sample.json").write_text(json.dumps(drop_examples, indent=1, ensure_ascii=False))
return {"source_id": src.repo_id, "revision": revision, "license": src.license, "note": src.note,
"counts": dict(counts), "seconds": round(time.time() - t0, 1), "output": str(out_path),
"output_sha256": C.sha256_file(out_path)}
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--out-dir", default=str(DEFAULT_OUT))
ap.add_argument("--sources", nargs="+", default=DEFAULT_SOURCES, choices=sorted(SOURCES))
ap.add_argument("--workers", type=int, default=2, help="parallel sources; hard-capped at 2")
ap.add_argument("--max-rows", type=int, default=0, help="cap rows per datasets-server source (0 = all)")
ap.add_argument("--nemotron-rows", type=int, default=60000)
ap.add_argument("--nemotron-stripes", type=int, default=8)
a = ap.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
out_dir = Path(a.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
revs = {k: repo_revision(SOURCES[k].repo_id) for k in a.sources}
log.info("revisions: %s", revs)
workers = max(1, min(2, a.workers))
results: dict[str, Any] = {}
with ThreadPoolExecutor(max_workers=workers) as ex:
futs = {k: ex.submit(ingest_source, SOURCES[k], revs[k], out_dir, a) for k in a.sources}
for k, fu in futs.items():
try:
results[k] = fu.result()
log.info("%s: %s", k, results[k]["counts"])
except Exception as e: # one failed source must not discard the others' results
log.error("%s FAILED: %r", k, e)
results[k] = {"source_id": SOURCES[k].repo_id, "revision": revs[k], "error": repr(e)}
man = out_dir / "ingest_manifest.json"
prev = json.loads(man.read_text()) if man.exists() else {}
prev.update(results)
man.write_text(json.dumps(prev, indent=1, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
|