Download code/scripts/p2/p2_common.py from viai957/align: direct link, hf CLI and curl.
- Browser
- Download file 16.9 kB
-
https://huggingface.co/viai957/align/resolve/main/code/scripts/p2/p2_common.py
- Command line
-
hf download hf://viai957/align/code/scripts/p2/p2_common.py
-
curl -L -o p2_common.py https://huggingface.co/viai957/align/resolve/main/code/scripts/p2/p2_common.py
16.9 kB
| """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"))) | |