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