align / code /scripts /p2 /p2_common.py
viai957's picture
Add files using upload-large-folder tool
63f41a6 verified
Raw History Blame Contribute Delete
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")))