The 1,319 GSM8K test cases are now sealed at evals/gsm8k_math/holdouts/v1/cases.jsonl.age, age-encrypted to the ADR-0119.1 recipient. Plaintext never touched disk in the working tree; only ciphertext is committed. First honest CORE-vs-real-GSM8K measurement cases_total: 1319 correct: 0 wrong: 0 ← ADR-0114a Obligation #4 holds against external corpus refused: 1319 overall_pass: True Zero confabulation. Parser refuses what it can't grammar-handle; the "wrong == 0" discipline survives the move from CORE-original cases to a real public benchmark. The 0/1319 correct rate is the truthful gap that ADR-0120's threshold work will quantify. What landed scripts/seal_gsm8k_test.py - Loads GSM8K via datasets.load_dataset("openai/gsm8k", "main") - Strips worked-solution prose; extracts final-answer integer/float after "####" (handles "2,125" → 2125 thousands-separator) - Reads recipient from docs/holdout_recipients.txt (single repo key per ADR-0119.1) - Encrypts via pyrage; writes only ciphertext - Refuses to overwrite test path with train-derived seal evals/gsm8k_math/runner.py - Empty expected_unit (sentinel) skips unit-comparison; grades on answer value alone. Required because GSM8K answers carry no unit structurally. wrong-zero discipline preserved. tests/test_adr_0119_7_sealed_gsm8k.py — 6 invariants: 1. sealed file present + age-formatted 2. no plaintext companion files (sibling-leak guard) 3. decrypted JSONL matches documented schema 4. runner against decrypted suite produces wrong==0 5. tests skip (not fail) when CORE_HOLDOUT_KEY unset 6. case ids match "gsm8k-test-NNNN" pattern Defensive gitignore: plaintext patterns under evals/gsm8k_math/holdouts/v1/ are explicitly excluded. ADR-0114a obligation roll-up 10/10 discharged for the gsm8k_math lane: #1 ✓ sealed-holdout (fab_control + GSM8K test) #2..#10 ✓ as before Phase 5 status: 5.1..5.7 done; 5.8 in flight (PR #149). After 5.8 merges, ADR-0120 (first expert promotion contract) becomes feasible. Test plan - pytest tests/test_adr_0119_7_sealed_gsm8k.py with CORE_HOLDOUT_KEY → 6/6 - pytest without CORE_HOLDOUT_KEY → 3 pass + 3 skip - core test --suite smoke -q → 67/67 - CLAIMS.md regenerated (no diff) - HF token NEVER in repo (saved at ~/.cache/huggingface/token, mode 600) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
241 lines
7.8 KiB
Python
241 lines
7.8 KiB
Python
"""ADR-0119.7 — seal the real GSM8K test set as the gsm8k_math holdout.
|
|
|
|
Loads the GSM8K test split via Hugging Face ``datasets``, converts each
|
|
row to the lane-runner JSONL schema (id / problem / expected_answer /
|
|
expected_unit), and encrypts the result against the recipient key
|
|
registered in ``docs/holdout_recipients.txt``.
|
|
|
|
The plaintext never lands on disk — it's held in memory only during
|
|
encryption. The final artifact is
|
|
``evals/gsm8k_math/holdouts/v1/cases.jsonl.age``.
|
|
|
|
Per ADR-0119 §5.7 the seal is one-way: development MUST operate blind
|
|
to the test contents until a release event signed-by-reviewer opens
|
|
the lane.
|
|
|
|
Usage:
|
|
|
|
uv run python scripts/seal_gsm8k_test.py [--split test|train] \\
|
|
[--recipient <age recipient string>]
|
|
|
|
The default split is ``test``. Use ``--split train --limit N`` to
|
|
seal a small chunk of train for sanity-check purposes (does NOT
|
|
overwrite the real test seal).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
_RECIPIENTS_FILE = _REPO_ROOT / "docs" / "holdout_recipients.txt"
|
|
_GSM8K_SEAL_PATH = _REPO_ROOT / "evals" / "gsm8k_math" / "holdouts" / "v1" / "cases.jsonl.age"
|
|
|
|
|
|
# GSM8K answer pattern: each `answer` field ends with "#### N" where N is the
|
|
# final numeric answer. Numbers may be integers, decimals, or carry thousands
|
|
# separators (commas) like "2,125".
|
|
_GSM8K_FINAL_ANSWER_RE = re.compile(r"####\s*(-?[\d,]+(?:\.\d+)?)\s*$")
|
|
|
|
|
|
def _extract_answer(answer_text: str) -> float:
|
|
"""Pull the final numeric answer from a GSM8K answer string.
|
|
|
|
Strips comma thousands-separators. Raises ValueError if the
|
|
"#### N" marker is absent.
|
|
"""
|
|
match = _GSM8K_FINAL_ANSWER_RE.search(answer_text)
|
|
if not match:
|
|
raise ValueError(
|
|
f"no '#### N' final-answer marker in answer text: "
|
|
f"{answer_text[-80:]!r}"
|
|
)
|
|
raw = match.group(1).replace(",", "")
|
|
if "." not in raw:
|
|
return int(raw)
|
|
return float(raw)
|
|
|
|
|
|
def _convert_to_lane_schema(
|
|
rows: list[dict], prefix: str
|
|
) -> list[dict]:
|
|
"""Convert GSM8K rows → lane-runner case dicts.
|
|
|
|
Each case carries:
|
|
id — "{prefix}-{i:04d}" (deterministic ordering)
|
|
problem — the GSM8K question, verbatim
|
|
expected_answer — the integer (or float) after "####"
|
|
expected_unit — "" (sentinel; runner skips unit check)
|
|
|
|
No ground_truth_graph is emitted — GSM8K problems generally require
|
|
rate/comparison reasoning the ADR-0115 parser does not handle, so
|
|
most cases will refuse rather than parse. That's the expected
|
|
behavior; the wrong-count discipline is preserved by the runner.
|
|
"""
|
|
cases: list[dict] = []
|
|
for i, row in enumerate(rows, start=1):
|
|
question = row["question"]
|
|
answer = row["answer"]
|
|
try:
|
|
expected_answer = _extract_answer(answer)
|
|
except ValueError as exc:
|
|
raise ValueError(f"case {prefix}-{i:04d}: {exc}") from exc
|
|
cases.append({
|
|
"id": f"{prefix}-{i:04d}",
|
|
"problem": question,
|
|
"expected_answer": expected_answer,
|
|
"expected_unit": "",
|
|
})
|
|
return cases
|
|
|
|
|
|
def _load_recipient() -> str:
|
|
"""Read the recipient public key from docs/holdout_recipients.txt.
|
|
|
|
Format (per ADR-0119.1):
|
|
# <comment lines>
|
|
fabrication_control: age1xxx...
|
|
|
|
For ADR-0119.7 we use the SAME recipient as fabrication_control
|
|
by default (single key per repo). A future amendment could split
|
|
per-lane keys if needed.
|
|
"""
|
|
if not _RECIPIENTS_FILE.exists():
|
|
raise FileNotFoundError(
|
|
f"recipients file missing: {_RECIPIENTS_FILE}. "
|
|
"Run ADR-0119.1 first to establish the keypair."
|
|
)
|
|
text = _RECIPIENTS_FILE.read_text(encoding="utf-8")
|
|
for line in text.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("#") or not line:
|
|
continue
|
|
# "lane_name: age1xxx" or just "age1xxx"
|
|
if ":" in line:
|
|
_, _, value = line.partition(":")
|
|
value = value.strip()
|
|
else:
|
|
value = line
|
|
if value.startswith("age1"):
|
|
return value
|
|
raise ValueError(
|
|
f"no age1... recipient key found in {_RECIPIENTS_FILE}"
|
|
)
|
|
|
|
|
|
def _encrypt_to_age(plaintext: bytes, recipient_str: str) -> bytes:
|
|
"""Encrypt plaintext bytes to age ciphertext under the recipient."""
|
|
try:
|
|
import pyrage
|
|
from pyrage.x25519 import Recipient
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"pyrage not installed; required for ADR-0119.7 sealing"
|
|
) from exc
|
|
recipient = Recipient.from_str(recipient_str)
|
|
return pyrage.encrypt(plaintext, [recipient])
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Seal the GSM8K test set as the gsm8k_math holdout"
|
|
)
|
|
parser.add_argument(
|
|
"--split",
|
|
default="test",
|
|
choices=["test", "train"],
|
|
help="GSM8K split to seal (default: test). Use 'train' for "
|
|
"sanity-check sealing; never overwrites the test seal.",
|
|
)
|
|
parser.add_argument(
|
|
"--limit",
|
|
type=int,
|
|
default=None,
|
|
help="Limit the number of rows sealed (for sanity-check runs)",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
default=None,
|
|
help="Override the output path (default: "
|
|
"evals/gsm8k_math/holdouts/v1/cases.jsonl.age for test split; "
|
|
"a sibling path for train)",
|
|
)
|
|
parser.add_argument(
|
|
"--recipient",
|
|
default=None,
|
|
help="age recipient string (default: read from "
|
|
"docs/holdout_recipients.txt)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
from datasets import load_dataset # type: ignore[import-not-found]
|
|
except ImportError:
|
|
print(
|
|
"datasets package not installed. Run: uv add datasets",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
# Resolve output path
|
|
if args.output is not None:
|
|
output_path = args.output
|
|
elif args.split == "test":
|
|
output_path = _GSM8K_SEAL_PATH
|
|
else:
|
|
# Sanity-check sealing of train → distinct path
|
|
suffix = f"-train{f'-limit{args.limit}' if args.limit else ''}"
|
|
output_path = _GSM8K_SEAL_PATH.with_name(
|
|
f"cases{suffix}.jsonl.age"
|
|
)
|
|
|
|
# Refuse to overwrite the test seal accidentally
|
|
if (
|
|
args.split == "train"
|
|
and output_path == _GSM8K_SEAL_PATH
|
|
):
|
|
print(
|
|
f"refusing to write a train-derived seal to the test path "
|
|
f"{output_path}; use --output to override",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
recipient_str = args.recipient or _load_recipient()
|
|
print(f"recipient: {recipient_str}", file=sys.stderr)
|
|
|
|
print(f"loading GSM8K {args.split} split...", file=sys.stderr)
|
|
ds = load_dataset("openai/gsm8k", "main")
|
|
rows = list(ds[args.split])
|
|
if args.limit is not None:
|
|
rows = rows[:args.limit]
|
|
print(f"loaded {len(rows)} rows", file=sys.stderr)
|
|
|
|
prefix = f"gsm8k-{args.split}"
|
|
cases = _convert_to_lane_schema(rows, prefix=prefix)
|
|
print(f"converted to {len(cases)} lane-schema cases", file=sys.stderr)
|
|
|
|
plaintext = (
|
|
"\n".join(
|
|
json.dumps(c, sort_keys=True, separators=(",", ":")) for c in cases
|
|
)
|
|
+ "\n"
|
|
).encode("utf-8")
|
|
print(f"plaintext size: {len(plaintext)} bytes", file=sys.stderr)
|
|
|
|
ciphertext = _encrypt_to_age(plaintext, recipient_str)
|
|
print(f"ciphertext size: {len(ciphertext)} bytes", file=sys.stderr)
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_bytes(ciphertext)
|
|
print(f"wrote sealed file: {output_path}", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|