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