File size: 16,915 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
"""Shared helpers for the P2 data / verification / evaluation scripts (stdlib + numpy).

Loaded BY PATH from the sibling scripts (`scripts/` is not a package and the dependency
firewall rejects an import it cannot place in stdlib / allowed / align), the same way
`scripts/lean_ceiling_test.py` loads its case tables.

Contents: the three JSONL record schemas of the P2 contract with strict validators, an
append-only JSONL writer that survives a SIGKILL mid-line, and the statement / proof
normalisations used for decontamination and for "distinct proof" selection.
"""

from __future__ import annotations

import hashlib
import importlib.util
import json
import os
import re
import threading
from pathlib import Path
from typing import Any, Iterable, Iterator, Optional

REPO = Path(__file__).resolve().parents[2]

#: The header Goedel's DeepSeekCoTHandler.extrac_code prepends to every extracted proof
#: (reference-repos/Goedel-Prover-V2/src/utils.py). A pool statement with any other header
#: can never be matched by a generated proof (strict verifier: header token-equality), so it
#: is unwinnable and is dropped at build time instead of becoming a silent 0.
GOEDEL_HEADER = "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n"

PROBLEM_FIELDS = ("problem_id", "source", "split", "formal_statement", "informal_prefix", "statement_sha")
GENERATION_FIELDS = (
    "problem_id", "sample_idx", "round", "cap_tokens", "prompt_ids", "completion_ids", "prompt_text",
    "completion_text", "finish_reason", "lean_code", "messages", "model_path", "model_rev", "sampling",
    "parent",
)
KEY_FIELDS = ("problem_id", "sample_idx", "round", "cap_tokens")


class ContractError(ValueError):
    """A record violates the P2 shared contract. Always loud: a lenient reader is how a
    cap-mislabelled or stale-verdict row ends up in a table."""


# --------------------------------------------------------------------------- hashing


def sha256_text(s: str) -> str:
    return hashlib.sha256(s.encode("utf-8")).hexdigest()


def sha256_file(path: str | os.PathLike, chunk: int = 4 << 20) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        while True:
            b = f.read(chunk)
            if not b:
                break
            h.update(b)
    return h.hexdigest()


# --------------------------------------------------------------------------- jsonl io


def iter_jsonl(path: str | os.PathLike) -> Iterator[dict]:
    """Strict JSONL reader: a malformed line raises with its line number (no skipping)."""
    with open(path, encoding="utf-8") as f:
        for i, line in enumerate(f, 1):
            if not line.strip():
                continue
            try:
                obj = json.loads(line)
            except json.JSONDecodeError as e:
                raise ContractError(f"{path}:{i}: malformed JSON ({e.msg})") from e
            if not isinstance(obj, dict):
                raise ContractError(f"{path}:{i}: expected a JSON object, got {type(obj).__name__}")
            yield obj


def write_jsonl_atomic(path: str | os.PathLike, rows: Iterable[dict]) -> int:
    """tmp + fsync + rename. A half-written problem file looks complete; never leave one."""
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    tmp = p.with_name(p.name + f".tmp{os.getpid()}")
    n = 0
    try:
        with open(tmp, "w", encoding="utf-8") as f:
            for r in rows:
                f.write(json.dumps(r, ensure_ascii=False) + "\n")
                n += 1
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, p)
    finally:
        if tmp.exists():
            tmp.unlink()
    return n


def repair_torn_tail(path: str | os.PathLike) -> int:
    """Make an append-only JSONL safe to resume: if the last line was cut off by a kill, drop
    it. Returns the number of bytes removed. A MIDDLE line that does not parse is not a torn
    write (appends only ever damage the tail), so that raises when the file is read."""
    p = Path(path)
    if not p.exists() or p.stat().st_size == 0:
        return 0
    with open(p, "rb") as f:
        data = f.read()
    if data.endswith(b"\n"):
        return 0
    cut = data.rfind(b"\n") + 1  # 0 when the file has a single unterminated line
    tail = data[cut:]
    try:
        json.loads(tail.decode("utf-8"))
        with open(p, "ab") as f:  # complete object that lost only its newline
            f.write(b"\n")
        return 0
    except (json.JSONDecodeError, UnicodeDecodeError):
        with open(p, "r+b") as f:
            f.truncate(cut)
            f.flush()
            os.fsync(f.fileno())
        return len(tail)


class AppendJsonl:
    """Thread-safe append-only JSONL writer; every `append` is flushed and fsynced so a
    SIGKILL loses at most the record being written (which `repair_torn_tail` discards)."""

    def __init__(self, path: str | os.PathLike) -> None:
        self.path = Path(path)
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.torn_bytes_removed = repair_torn_tail(self.path)
        self._f = open(self.path, "ab")
        self._lock = threading.Lock()

    def append(self, rec: dict) -> None:
        line = (json.dumps(rec, ensure_ascii=False) + "\n").encode("utf-8")
        with self._lock:
            self._f.write(line)
            self._f.flush()
            os.fsync(self._f.fileno())

    def close(self) -> None:
        self._f.close()

    def __enter__(self) -> "AppendJsonl":
        return self

    def __exit__(self, *exc) -> None:
        self.close()


# --------------------------------------------------------------------------- contract


def gen_key(rec: dict) -> tuple:
    return (rec["problem_id"], int(rec["sample_idx"]), int(rec["round"]), int(rec["cap_tokens"]))


def validate_problem(rec: dict) -> dict:
    missing = [k for k in PROBLEM_FIELDS if k not in rec]
    if missing:
        raise ContractError(f"problem {rec.get('problem_id')!r} lacks fields {missing}")
    for k in ("problem_id", "source", "split", "formal_statement", "statement_sha"):
        if not isinstance(rec[k], str) or not rec[k].strip():
            raise ContractError(f"problem field {k!r} must be a non-empty string, got {rec[k]!r}")
    if rec["informal_prefix"] is not None and not isinstance(rec["informal_prefix"], str):
        raise ContractError("informal_prefix must be a string or null")
    return rec


def effective_max_tokens(rec: dict) -> int:
    """What the sampler was actually allowed to emit. `cap_tokens` is the NOMINAL cap every
    number is labelled with; `sampling.max_tokens` is smaller only when the context window,
    not the cap, ended the sample (`context_clipped`): at cap 40960 with a 40960-token window
    every sample loses its prompt length (~250-600 tokens)."""
    mt = (rec.get("sampling") or {}).get("max_tokens")
    return int(mt) if mt is not None else int(rec["cap_tokens"])


def validate_generation(rec: dict) -> dict:
    """Shape checks on one Generation record. Cross-record checks live in the callers."""
    missing = [k for k in GENERATION_FIELDS if k not in rec]
    pid = rec.get("problem_id")
    if missing:
        raise ContractError(f"generation {pid!r} lacks fields {missing}")
    for k in ("sample_idx", "round", "cap_tokens"):
        if not isinstance(rec[k], int) or isinstance(rec[k], bool) or rec[k] < 0:
            raise ContractError(f"generation {pid!r}: {k} must be a non-negative int, got {rec[k]!r}")
    if rec["cap_tokens"] < 1:
        raise ContractError(f"generation {pid!r}: cap_tokens must be >= 1")
    if rec["finish_reason"] not in ("stop", "length"):
        raise ContractError(f"generation {pid!r}: finish_reason {rec['finish_reason']!r} not in stop|length")
    for k in ("prompt_ids", "completion_ids"):
        v = rec[k]
        if not isinstance(v, list) or not v or not all(isinstance(t, int) and not isinstance(t, bool) for t in v):
            raise ContractError(f"generation {pid!r}: {k} must be a non-empty list of ints")
    eff = effective_max_tokens(rec)
    if eff > rec["cap_tokens"]:
        raise ContractError(
            f"generation {pid!r}: sampling.max_tokens={eff} exceeds the stated cap_tokens={rec['cap_tokens']} -- the "
            "cap label understates what the sampler was allowed, and every number must state its true cap"
        )
    if len(rec["completion_ids"]) > eff:
        raise ContractError(
            f"generation {pid!r}: {len(rec['completion_ids'])} completion tokens exceed the sampler's max_tokens={eff} "
            f"(cap_tokens={rec['cap_tokens']}) -- the cap label is wrong, and every number must state its true cap"
        )
    if rec["finish_reason"] == "length" and len(rec["completion_ids"]) != eff:
        raise ContractError(
            f"generation {pid!r}: finish_reason=length but {len(rec['completion_ids'])} tokens != max_tokens {eff} "
            "(the run was cut by a different limit than the one it is labelled with)"
        )
    if rec["lean_code"] is not None and not isinstance(rec["lean_code"], str):
        raise ContractError(f"generation {pid!r}: lean_code must be a string or null")
    parent = rec["parent"]
    if rec["round"] == 0 and parent is not None:
        raise ContractError(f"generation {pid!r}: round 0 cannot have a parent")
    if rec["round"] > 0:
        if not (isinstance(parent, dict) and {"problem_id", "sample_idx", "round"} <= set(parent)):
            raise ContractError(f"generation {pid!r}: round {rec['round']} needs a parent {{problem_id,sample_idx,round}}")
        if parent["problem_id"] != pid or parent["round"] != rec["round"] - 1:
            raise ContractError(f"generation {pid!r}: parent {parent} is not the previous round of the same problem")
    return rec


def load_problems(path: str | os.PathLike) -> dict[str, dict]:
    out: dict[str, dict] = {}
    for rec in iter_jsonl(path):
        validate_problem(rec)
        if rec["problem_id"] in out:
            raise ContractError(f"{path}: duplicate problem_id {rec['problem_id']!r}")
        out[rec["problem_id"]] = rec
    if not out:
        raise ContractError(f"{path}: no problems")
    return out


def code_sha(lean_code: Optional[str]) -> Optional[str]:
    return None if lean_code is None else sha256_text(lean_code)


# --------------------------------------------------------------------------- statements


_BY = re.compile(r":=\s*by\b")


def normalize_formal_statement(raw: str) -> str:
    """Goedel's statement normalisation (`lean4_code.split(":= by")[0] + ":= by sorry"`), made
    safe. Goedel's split silently TRUNCATES a statement that contains `:= by` earlier (for
    example a `(h : x = by norm_num)` binder) and so changes the problem; here that raises. A
    line break between `:=` and `by` (`:=\\n  by sorry`) is accepted and canonicalised, which
    Goedel's literal split cannot handle at all."""
    s = raw.strip("\n")
    hits = list(_BY.finditer(s))
    if len(hits) != 1:
        raise ContractError(f"statement must contain exactly one ':= by', found {len(hits)}")
    m = hits[0]
    tail = s[m.end() :]
    if tail.strip() not in ("", "sorry"):
        raise ContractError(f"text after ':= by' is not a bare sorry: {tail.strip()[:60]!r}")
    return s[: m.start()] + ":= by sorry"


def _strict():
    from align.verify.lean import strict  # lazy: keeps `import p2_common` cheap

    return strict


def statement_key(statement: str) -> tuple[str, str, tuple[str, ...]]:
    """(theorem name, header text, signature tokens) of a formal statement, via the SAME parser
    the verifier uses. Raises ContractError when the verifier could not use the statement."""
    p = _strict().parse_statement(statement)
    if not p.ok:
        raise ContractError(f"unsupported statement: {p.problem}")
    return p.name, p.header_text, tuple(p.sig_toks)


def statement_sha(statement: str) -> str:
    """sha256 of the whitespace- and comment-normalised signature (lexer tokens joined by one
    space). The theorem NAME is deliberately excluded: a renamed copy is the same problem."""
    _, _, sig = statement_key(statement)
    return sha256_text(" ".join(sig))


_KEEP_LOWER = frozenset(
    "fun forall exists if then else let have by sorry at with from in do match return where "
    "sin cos tan exp log sqrt abs max min id fst snd succ pred gcd lcm choose fact floor ceil "
    "card sum prod range filter map deriv logb arctan sinh cosh cexp nnabs dist norm "
    "theorem lemma".split()
)


def alpha_sha(statement: str) -> str:
    """Like `statement_sha` but invariant to consistent renaming of local (lowercase, undotted)
    identifiers. Deliberately conservative: it also equates `sin x = 1` with `cos y = 1`; a
    false positive here costs one pool statement, a false negative costs a contaminated
    evaluation."""
    _, _, sig = statement_key(statement)
    mp: dict[str, str] = {}
    out = []
    for t in sig:
        if re.fullmatch(r"[a-z_][A-Za-z0-9_'₀-₉]*", t) and t not in _KEEP_LOWER:
            out.append(mp.setdefault(t, f"v{len(mp)}"))
        else:
            out.append(t)
    return sha256_text(" ".join(out))


_LEAN_PROMPTS = None


def _lean_prompts():
    """`extras/vllm_rollout/lean_prompts.py` (the byte-faithful port of Goedel's extraction), loaded
    BY PATH: it is stdlib-only at import and is the single source of truth for what the model's
    code block means, so the SFT selection must not grow a second, slightly different regex."""
    global _LEAN_PROMPTS
    if _LEAN_PROMPTS is None:
        spec = importlib.util.spec_from_file_location("p2_lean_prompts", REPO / "extras" / "vllm_rollout" / "lean_prompts.py")
        mod = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(mod)
        _LEAN_PROMPTS = mod
    return _LEAN_PROMPTS


def displayed_signature(completion_text: str) -> Optional[tuple[str, ...]]:
    """Signature tokens of the theorem the MODEL WROTE in the code block Goedel's extractor takes
    (last ```lean4 fence, first `theorem ... := by`), i.e. BEFORE `problem_check` swaps the original
    statement in. None when there is no such theorem or the verifier's parser cannot read it.

    Why this exists: `problem_check` replaces whatever statement the model wrote with the original,
    so a sample whose displayed theorem is `v = 66` but whose tactics happen to prove the original
    `v = 65` is ACCEPTED, and its completion then teaches the model to state the wrong theorem.
    Goedel's evaluation protocol credits it (and so does ours, to stay comparable); a TRAINING row
    must not."""
    lp = _lean_prompts()
    extracted = lp.DeepSeekCoTHandler().extrac_code(completion_text)
    if extracted == "None":
        return None
    proof = lp.remove_comments(extracted)
    span = lp.return_theorem_to_replace(proof)
    if span is None:
        return None
    shown = re.sub(r":=\s*by$", ":= by sorry", proof[span[0] : span[1]].strip())
    parsed = _strict().parse_statement(shown)
    return tuple(parsed.sig_toks) if parsed.ok else None


def displayed_statement_matches(formal_statement: str, completion_text: str) -> bool:
    """True iff the theorem the model displayed has the same signature tokens as `formal_statement`
    (theorem NAME ignored: `problem_check` renames it anyway and a renamed copy is the same problem)."""
    shown = displayed_signature(completion_text)
    return shown is not None and shown == statement_key(formal_statement)[2]


def norm_name(name: str) -> str:
    return re.sub(r"[^a-z0-9]", "", name.lower())


def informal_tokens(text: str) -> list[str]:
    return re.findall(r"[a-z0-9]+", text.lower())


def ngrams(tokens: list[str], n: int) -> set[tuple[str, ...]]:
    return {tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)} if len(tokens) >= n else set()


def proof_hash(lean_code: str) -> str:
    """Hash of a proof invariant to whitespace and comments (lexer tokens), used to prefer
    DISTINCT proofs. Falls back to whitespace-collapsed text when the lexer gives up."""
    lx = _strict().lex(lean_code)
    if lx.broken:
        return sha256_text(" ".join(lean_code.split()))
    return sha256_text(" ".join(t.text for t in lx.toks))


def compile_view(full_code: str) -> str:
    """Goedel's `compile.py:handle`: drop every line whose stripped text starts with import /
    set_option / open. Goedel compiles THIS text in a REPL environment that already ran the
    header, so the line numbers of its error dicts index this text, and `get_error_str` must be
    given this text (not `full_code`). Identical to `extras/vllm_rollout/lean_prompts.compile_view`
    (a test pins the two together)."""
    lines = full_code.split("\n")
    return "\n".join(l for l in lines if not (l.strip().startswith("import") or l.strip().startswith("set_option") or l.strip().startswith("open")))