stage listing raises instead of returning {}; empty-repo case handled explicitly; verify refuses manifest-less PASS; +val holdout in merge
Browse files- build/build_mix.py +47 -0
- build/hubsync.py +48 -14
- build/smoke_hub_resume.py +106 -100
- build/verify_mix.py +188 -176
build/build_mix.py
CHANGED
|
@@ -45,6 +45,9 @@ import time
|
|
| 45 |
MAGIC_FLAGS = 0
|
| 46 |
TOKENS_PER_SHARD = 8_000_000 # 16 MB as uint16; ~140 shards for 1.1B tokens
|
| 47 |
MERGE_BATCH_DOCS = 32 # granularity of the round-robin; ~20k tokens, so a 262k batch spans ~13
|
|
|
|
|
|
|
|
|
|
| 48 |
ROW_TOKEN_CAP = 16_384 # one document must never dominate a shard
|
| 49 |
SHUFFLE_SEED = 20260919 # recorded so a rebuild is reproducible
|
| 50 |
HASHES_FILE = "seen_hashes.bin"
|
|
@@ -412,10 +415,36 @@ def merge(args, st, tokenizer):
|
|
| 412 |
|
| 413 |
cap = args.merge_budget_tokens or int(total_w)
|
| 414 |
buf, buf_src, buf_tokens = [], [], 0
|
|
|
|
| 415 |
out_dir = os.path.join(st.root, "mix")
|
| 416 |
os.makedirs(out_dir, exist_ok=True)
|
| 417 |
t0 = time.monotonic()
|
| 418 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
def flush():
|
| 420 |
nonlocal buf, buf_src, buf_tokens
|
| 421 |
if not buf:
|
|
@@ -446,6 +475,17 @@ def merge(args, st, tokenizer):
|
|
| 446 |
d = next_doc(pick)
|
| 447 |
if d is None:
|
| 448 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
buf.append((d, pick["key"]))
|
| 450 |
buf_tokens += len(d)
|
| 451 |
pick["emitted_tokens"] += len(d)
|
|
@@ -457,6 +497,9 @@ def merge(args, st, tokenizer):
|
|
| 457 |
if all(s["pending"] is None and s["exhausted"] for s in streams):
|
| 458 |
break
|
| 459 |
flush()
|
|
|
|
|
|
|
|
|
|
| 460 |
print(f"merge: {len(st.d['merged_shards'])} shards, "
|
| 461 |
f"{sum(s['tokens'] for s in st.d['merged_shards']):,} tokens", flush=True)
|
| 462 |
|
|
@@ -555,7 +598,11 @@ def main():
|
|
| 555 |
for k, v in st.d["staged"].items()},
|
| 556 |
"staged_tokens": st.staged_tokens_total(),
|
| 557 |
"shards": st.d["merged_shards"],
|
|
|
|
| 558 |
"total_tokens": sum(s["tokens"] for s in st.d["merged_shards"]),
|
|
|
|
|
|
|
|
|
|
| 559 |
"distinct_sources_per_shard_min": min((len(s["sources"]) for s in st.d["merged_shards"]),
|
| 560 |
default=0),
|
| 561 |
"seconds": round(time.monotonic() - t0, 1),
|
|
|
|
| 45 |
MAGIC_FLAGS = 0
|
| 46 |
TOKENS_PER_SHARD = 8_000_000 # 16 MB as uint16; ~140 shards for 1.1B tokens
|
| 47 |
MERGE_BATCH_DOCS = 32 # granularity of the round-robin; ~20k tokens, so a 262k batch spans ~13
|
| 48 |
+
VAL_STRIDE = 50 # every 50th document is held out ~= 2% of tokens, sampled across
|
| 49 |
+
# sources rather than whole documents, so validation PPL is honest
|
| 50 |
+
VAL_TOKENS_PER_SHARD = 2_000_000
|
| 51 |
ROW_TOKEN_CAP = 16_384 # one document must never dominate a shard
|
| 52 |
SHUFFLE_SEED = 20260919 # recorded so a rebuild is reproducible
|
| 53 |
HASHES_FILE = "seen_hashes.bin"
|
|
|
|
| 415 |
|
| 416 |
cap = args.merge_budget_tokens or int(total_w)
|
| 417 |
buf, buf_src, buf_tokens = [], [], 0
|
| 418 |
+
val_buf, val_tokens, docs_seq = [], 0, 0
|
| 419 |
out_dir = os.path.join(st.root, "mix")
|
| 420 |
os.makedirs(out_dir, exist_ok=True)
|
| 421 |
t0 = time.monotonic()
|
| 422 |
|
| 423 |
+
def flush_val():
|
| 424 |
+
nonlocal val_buf, val_tokens
|
| 425 |
+
if not val_buf:
|
| 426 |
+
return
|
| 427 |
+
idx = len(st.d.get("val_shards", []))
|
| 428 |
+
p = os.path.join(st.root, "val", f"shard-{idx:04d}.bin")
|
| 429 |
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
| 430 |
+
tmp = p + ".tmp"
|
| 431 |
+
offs = array.array("I", [0])
|
| 432 |
+
ids = array.array("H")
|
| 433 |
+
for d in val_buf:
|
| 434 |
+
ids.extend(d)
|
| 435 |
+
offs.append(len(ids))
|
| 436 |
+
with open(tmp, "wb") as f:
|
| 437 |
+
f.write(struct.pack("<IIII", vocab, len(val_buf), len(ids), MAGIC_FLAGS))
|
| 438 |
+
offs.tofile(f)
|
| 439 |
+
ids.tofile(f)
|
| 440 |
+
os.replace(tmp, p)
|
| 441 |
+
st.d.setdefault("val_shards", []).append({
|
| 442 |
+
"file": os.path.relpath(p, st.root), "docs": len(val_buf), "tokens": len(ids),
|
| 443 |
+
"sha256": hashlib.sha256(open(p, "rb").read()).hexdigest(), "bytes": os.path.getsize(p)})
|
| 444 |
+
st.commit([], [])
|
| 445 |
+
print(f" val shard {idx:04d}: {len(ids):,} tok / {len(val_buf):,} docs", flush=True)
|
| 446 |
+
val_buf, val_tokens = [], 0
|
| 447 |
+
|
| 448 |
def flush():
|
| 449 |
nonlocal buf, buf_src, buf_tokens
|
| 450 |
if not buf:
|
|
|
|
| 475 |
d = next_doc(pick)
|
| 476 |
if d is None:
|
| 477 |
break
|
| 478 |
+
docs_seq += 1
|
| 479 |
+
if docs_seq % VAL_STRIDE == 0:
|
| 480 |
+
# held out of training entirely: routed to the validation split instead of the buffer
|
| 481 |
+
val_buf.append(d)
|
| 482 |
+
val_tokens += len(d)
|
| 483 |
+
if val_tokens >= VAL_TOKENS_PER_SHARD:
|
| 484 |
+
flush_val()
|
| 485 |
+
pick["emitted_tokens"] += len(d)
|
| 486 |
+
pick["pending"] = None
|
| 487 |
+
took += 1
|
| 488 |
+
continue
|
| 489 |
buf.append((d, pick["key"]))
|
| 490 |
buf_tokens += len(d)
|
| 491 |
pick["emitted_tokens"] += len(d)
|
|
|
|
| 497 |
if all(s["pending"] is None and s["exhausted"] for s in streams):
|
| 498 |
break
|
| 499 |
flush()
|
| 500 |
+
flush_val()
|
| 501 |
+
print(f"merge: {len(st.d['val_shards']) if 'val_shards' in st.d else 0} val shards, "
|
| 502 |
+
f"{sum(s['tokens'] for s in st.d.get('val_shards', [])):,} held-out tokens", flush=True)
|
| 503 |
print(f"merge: {len(st.d['merged_shards'])} shards, "
|
| 504 |
f"{sum(s['tokens'] for s in st.d['merged_shards']):,} tokens", flush=True)
|
| 505 |
|
|
|
|
| 598 |
for k, v in st.d["staged"].items()},
|
| 599 |
"staged_tokens": st.staged_tokens_total(),
|
| 600 |
"shards": st.d["merged_shards"],
|
| 601 |
+
"val_shards": st.d.get("val_shards", []),
|
| 602 |
"total_tokens": sum(s["tokens"] for s in st.d["merged_shards"]),
|
| 603 |
+
"val_tokens": sum(s["tokens"] for s in st.d.get("val_shards", [])),
|
| 604 |
+
"val_holdout": {"stride": VAL_STRIDE, "note": "every 50th merged document, sampled across "
|
| 605 |
+
"sources rather than whole documents, excluded from training shards"},
|
| 606 |
"distinct_sources_per_shard_min": min((len(s["sources"]) for s in st.d["merged_shards"]),
|
| 607 |
default=0),
|
| 608 |
"seconds": round(time.monotonic() - t0, 1),
|
build/hubsync.py
CHANGED
|
@@ -44,24 +44,58 @@ def ensure_repo(repo):
|
|
| 44 |
return repo
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def remote_stage_files(repo):
|
| 48 |
-
"""
|
|
|
|
| 49 |
import urllib.request
|
| 50 |
|
| 51 |
-
|
|
|
|
| 52 |
try:
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
except Exception:
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def upload_source(root, key, repo):
|
|
|
|
| 44 |
return repo
|
| 45 |
|
| 46 |
|
| 47 |
+
class StageListingError(RuntimeError):
|
| 48 |
+
"""Raised when the stage repo's contents cannot be determined.
|
| 49 |
+
|
| 50 |
+
Returning an empty set here would be the single most dangerous shortcut in this pipeline: an empty
|
| 51 |
+
listing is indistinguishable from "nothing staged yet", so a *broken* listing makes a resume
|
| 52 |
+
silently re-stage everything (wasting hours) or, worse, makes a merge believe there is nothing to
|
| 53 |
+
merge while still looking consistent. Callers must see the difference between empty and unknown."""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
def remote_stage_files(repo):
|
| 57 |
+
"""Relative paths under stage/ that exist on the Hub. Raises rather than returning {} on failure."""
|
| 58 |
+
import json
|
| 59 |
import urllib.request
|
| 60 |
|
| 61 |
+
errors = []
|
| 62 |
+
a = api()
|
| 63 |
try:
|
| 64 |
+
files = a.list_repo_files(repo_id=repo, repo_type="dataset", revision="main")
|
| 65 |
+
return {f for f in files if f.startswith("stage/")}
|
| 66 |
+
except Exception as e:
|
| 67 |
+
errors.append(f"list_repo_files: {type(e).__name__}: {str(e)[:160]}")
|
| 68 |
+
# A freshly created repo has no commits, so "main" does not resolve and list_repo_files raises
|
| 69 |
+
# for a reason that genuinely means empty. Distinguish it by asking whether the repo exists at
|
| 70 |
+
# all -- NOT by swallowing the exception, which is what made the first version ambiguous.
|
| 71 |
+
try:
|
| 72 |
+
info = a.repo_info(repo_id=repo, repo_type="dataset")
|
| 73 |
+
if not getattr(info, "sha", None):
|
| 74 |
+
return set()
|
| 75 |
+
except Exception as e2:
|
| 76 |
+
errors.append(f"repo_info: {type(e2).__name__}: {str(e2)[:120]}")
|
| 77 |
+
|
| 78 |
+
# REST fallback. Note the path goes in the query string, not the URL body -- the first version put
|
| 79 |
+
# it in the path, got a 404, and the bare `except` turned that into a silent empty listing (E-018).
|
| 80 |
+
for url in (f"https://huggingface.co/api/datasets/{repo}/tree/main?recursive=true",
|
| 81 |
+
f"https://huggingface.co/api/datasets/{repo}/tree/main"):
|
| 82 |
+
try:
|
| 83 |
+
with urllib.request.urlopen(url, timeout=90) as r:
|
| 84 |
+
entries = json.loads(r.read().decode())
|
| 85 |
+
paths = set()
|
| 86 |
+
stack = [(e, "") for e in entries]
|
| 87 |
+
while stack:
|
| 88 |
+
e, prefix = stack.pop()
|
| 89 |
+
p = f"{prefix}/{e['path']}".lstrip("/") if prefix else e.get("path", "")
|
| 90 |
+
if e.get("type") == "dir":
|
| 91 |
+
stack.extend((c, p) for c in (e.get("children") or []))
|
| 92 |
+
elif p.startswith("stage/"):
|
| 93 |
+
paths.add(p)
|
| 94 |
+
return paths
|
| 95 |
+
except Exception as e:
|
| 96 |
+
errors.append(f"tree {url.split('/api/')[1]}: {type(e).__name__}: {str(e)[:140]}")
|
| 97 |
+
|
| 98 |
+
raise StageListingError(f"cannot list {repo}: " + " | ".join(errors))
|
| 99 |
|
| 100 |
|
| 101 |
def upload_source(root, key, repo):
|
build/smoke_hub_resume.py
CHANGED
|
@@ -1,100 +1,106 @@
|
|
| 1 |
-
# Cold-resume rehearsal for the mix builder, in one session.
|
| 2 |
-
#
|
| 3 |
-
# What it proves and why it matters: /kaggle/working is destroyed when a Kaggle session ends, so the
|
| 4 |
-
# staged shards AND build_state.json are gone even though the Hub still holds the stage. The full build is
|
| 5 |
-
# hours of streaming; if a session kill meant starting that over, the build would never finish. This
|
| 6 |
-
# script therefore:
|
| 7 |
-
# 1. stages two small sources and publishes them to a throwaway stage repo,
|
| 8 |
-
# 2. WIPES the local tree -- a faithful simulation of the instance dying, not a soft restart,
|
| 9 |
-
# 3. runs --merge-only against the Hub alone, which must restore the stage bookkeeping from the Hub's
|
| 10 |
-
# record.json files and emit a mixed shard containing both sources,
|
| 11 |
-
# 4. runs verify_mix.py over the result, and
|
| 12 |
-
# 5. deletes the throwaway repo so no test artifact is mistaken for the real stage later.
|
| 13 |
-
#
|
| 14 |
-
# Step 3 is the one that would catch a silent failure: with an empty state and no restore path, merge
|
| 15 |
-
# would find nothing and emit an empty mix while still exiting 0.
|
| 16 |
-
#
|
| 17 |
-
# CPU session, zero GPU quota. No credentials in this file; they come from the private store (D-006).
|
| 18 |
-
|
| 19 |
-
import json
|
| 20 |
-
import os
|
| 21 |
-
import shutil
|
| 22 |
-
import subprocess
|
| 23 |
-
import sys
|
| 24 |
-
import time
|
| 25 |
-
|
| 26 |
-
ROOT = "/kaggle/working/mixroot"
|
| 27 |
-
REPO = "Cion-lab/ounce100m-mix-stagetest"
|
| 28 |
-
TWO = "cosmopedia__khanacademy,SimpleStories__default"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def sh(argv, label):
|
| 32 |
-
print(f"=== {label}", flush=True)
|
| 33 |
-
p = subprocess.run([sys.executable] + argv, cwd="/kaggle/working", capture_output=True, text=True,
|
| 34 |
-
timeout=3600)
|
| 35 |
-
out = p.stdout
|
| 36 |
-
for line in out.splitlines():
|
| 37 |
-
print(" |", line[:220], flush=True)
|
| 38 |
-
if p.returncode != 0:
|
| 39 |
-
print(" STDERR:", (p.stderr or "")[-1500:], flush=True)
|
| 40 |
-
return {"rc": p.returncode, "out": out[-6000:], "err": (p.stderr or "")[-1500:]}
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def grab_json(text, begin, end):
|
| 44 |
-
if begin in text and end in text:
|
| 45 |
-
try:
|
| 46 |
-
return json.loads(text.split(begin, 1)[1].split(end, 1)[0])
|
| 47 |
-
except Exception:
|
| 48 |
-
return None
|
| 49 |
-
return None
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def main():
|
| 53 |
-
R = {"repo": REPO, "sources": TWO}
|
| 54 |
-
base = ["build_mix.py", "--root", ROOT, "--only", TWO, "--cap-tokens-per-source", "1200000",
|
| 55 |
-
"--hub-repo", REPO]
|
| 56 |
-
|
| 57 |
-
shutil.rmtree(ROOT, ignore_errors=True)
|
| 58 |
-
r1 = sh(base + ["--stage-only"], "1. stage two sources and publish")
|
| 59 |
-
R["stage"] = {"rc": r1["rc"], "json": grab_json(r1["out"], "BUILD_JSON_BEGIN", "BUILD_JSON_END")}
|
| 60 |
-
|
| 61 |
-
# the wipe IS the test: after this, nothing local survives except this script and the code
|
| 62 |
-
shutil.rmtree(ROOT, ignore_errors=True)
|
| 63 |
-
R["wiped"] = {"root_exists": os.path.exists(ROOT),
|
| 64 |
-
"stage_dir_exists": os.path.exists(os.path.join(ROOT, "stage"))}
|
| 65 |
-
|
| 66 |
-
r2 = sh(base + ["--merge-only"], "2. cold resume: merge from the Hub with an empty local tree")
|
| 67 |
-
R["merge"] = {"rc": r2["rc"], "json": grab_json(r2["out"], "BUILD_JSON_BEGIN", "BUILD_JSON_END")}
|
| 68 |
-
|
| 69 |
-
r3 = sh(["verify_mix.py", "--root", ROOT, "--min-sources-per-shard", "2",
|
| 70 |
-
"--sample-decodes", "3"], "3. verify the restored mix")
|
| 71 |
-
R["verify"] = grab_json(r3["out"], "VERIFY_JSON_BEGIN", "VERIFY_JSON_END")
|
| 72 |
-
|
| 73 |
-
staged = (R["merge"].get("json") or {}).get("total_tokens", 0)
|
| 74 |
-
R["COLD_RESUME_PASSED"] = bool(
|
| 75 |
-
r1["rc"] == 0 and r2["rc"] == 0 and staged > 1_000_000
|
| 76 |
-
and (R["verify"] or {}).get("PASS") is True
|
| 77 |
-
and (R["verify"] or {}).get("distinct_sources_in_mix") == 2)
|
| 78 |
-
|
| 79 |
-
# cleanup: the throwaway stage repo must not outlive the test, or a later real build could restore
|
| 80 |
-
# capped 1.2M-token sources and believe they were complete
|
| 81 |
-
def cleanup():
|
| 82 |
-
import ounce100m_credentials
|
| 83 |
-
ounce100m_credentials.install()
|
| 84 |
-
from huggingface_hub import delete_repo
|
| 85 |
-
delete_repo(repo_id=REPO, repo_type="dataset", token=os.environ["HF_TOKEN"])
|
| 86 |
-
return "deleted"
|
| 87 |
-
|
| 88 |
-
try:
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
R["
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cold-resume rehearsal for the mix builder, in one session.
|
| 2 |
+
#
|
| 3 |
+
# What it proves and why it matters: /kaggle/working is destroyed when a Kaggle session ends, so the
|
| 4 |
+
# staged shards AND build_state.json are gone even though the Hub still holds the stage. The full build is
|
| 5 |
+
# hours of streaming; if a session kill meant starting that over, the build would never finish. This
|
| 6 |
+
# script therefore:
|
| 7 |
+
# 1. stages two small sources and publishes them to a throwaway stage repo,
|
| 8 |
+
# 2. WIPES the local tree -- a faithful simulation of the instance dying, not a soft restart,
|
| 9 |
+
# 3. runs --merge-only against the Hub alone, which must restore the stage bookkeeping from the Hub's
|
| 10 |
+
# record.json files and emit a mixed shard containing both sources,
|
| 11 |
+
# 4. runs verify_mix.py over the result, and
|
| 12 |
+
# 5. deletes the throwaway repo so no test artifact is mistaken for the real stage later.
|
| 13 |
+
#
|
| 14 |
+
# Step 3 is the one that would catch a silent failure: with an empty state and no restore path, merge
|
| 15 |
+
# would find nothing and emit an empty mix while still exiting 0.
|
| 16 |
+
#
|
| 17 |
+
# CPU session, zero GPU quota. No credentials in this file; they come from the private store (D-006).
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import shutil
|
| 22 |
+
import subprocess
|
| 23 |
+
import sys
|
| 24 |
+
import time
|
| 25 |
+
|
| 26 |
+
ROOT = "/kaggle/working/mixroot"
|
| 27 |
+
REPO = "Cion-lab/ounce100m-mix-stagetest"
|
| 28 |
+
TWO = "cosmopedia__khanacademy,SimpleStories__default"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def sh(argv, label):
|
| 32 |
+
print(f"=== {label}", flush=True)
|
| 33 |
+
p = subprocess.run([sys.executable] + argv, cwd="/kaggle/working", capture_output=True, text=True,
|
| 34 |
+
timeout=3600)
|
| 35 |
+
out = p.stdout
|
| 36 |
+
for line in out.splitlines():
|
| 37 |
+
print(" |", line[:220], flush=True)
|
| 38 |
+
if p.returncode != 0:
|
| 39 |
+
print(" STDERR:", (p.stderr or "")[-1500:], flush=True)
|
| 40 |
+
return {"rc": p.returncode, "out": out[-6000:], "err": (p.stderr or "")[-1500:]}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def grab_json(text, begin, end):
|
| 44 |
+
if begin in text and end in text:
|
| 45 |
+
try:
|
| 46 |
+
return json.loads(text.split(begin, 1)[1].split(end, 1)[0])
|
| 47 |
+
except Exception:
|
| 48 |
+
return None
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def main():
|
| 53 |
+
R = {"repo": REPO, "sources": TWO}
|
| 54 |
+
base = ["build_mix.py", "--root", ROOT, "--only", TWO, "--cap-tokens-per-source", "1200000",
|
| 55 |
+
"--hub-repo", REPO]
|
| 56 |
+
|
| 57 |
+
shutil.rmtree(ROOT, ignore_errors=True)
|
| 58 |
+
r1 = sh(base + ["--stage-only"], "1. stage two sources and publish")
|
| 59 |
+
R["stage"] = {"rc": r1["rc"], "json": grab_json(r1["out"], "BUILD_JSON_BEGIN", "BUILD_JSON_END")}
|
| 60 |
+
|
| 61 |
+
# the wipe IS the test: after this, nothing local survives except this script and the code
|
| 62 |
+
shutil.rmtree(ROOT, ignore_errors=True)
|
| 63 |
+
R["wiped"] = {"root_exists": os.path.exists(ROOT),
|
| 64 |
+
"stage_dir_exists": os.path.exists(os.path.join(ROOT, "stage"))}
|
| 65 |
+
|
| 66 |
+
r2 = sh(base + ["--merge-only"], "2. cold resume: merge from the Hub with an empty local tree")
|
| 67 |
+
R["merge"] = {"rc": r2["rc"], "json": grab_json(r2["out"], "BUILD_JSON_BEGIN", "BUILD_JSON_END")}
|
| 68 |
+
|
| 69 |
+
r3 = sh(["verify_mix.py", "--root", ROOT, "--min-sources-per-shard", "2",
|
| 70 |
+
"--sample-decodes", "3"], "3. verify the restored mix")
|
| 71 |
+
R["verify"] = grab_json(r3["out"], "VERIFY_JSON_BEGIN", "VERIFY_JSON_END")
|
| 72 |
+
|
| 73 |
+
staged = (R["merge"].get("json") or {}).get("total_tokens", 0)
|
| 74 |
+
R["COLD_RESUME_PASSED"] = bool(
|
| 75 |
+
r1["rc"] == 0 and r2["rc"] == 0 and staged > 1_000_000
|
| 76 |
+
and (R["verify"] or {}).get("PASS") is True
|
| 77 |
+
and (R["verify"] or {}).get("distinct_sources_in_mix") == 2)
|
| 78 |
+
|
| 79 |
+
# cleanup: the throwaway stage repo must not outlive the test, or a later real build could restore
|
| 80 |
+
# capped 1.2M-token sources and believe they were complete
|
| 81 |
+
def cleanup():
|
| 82 |
+
import ounce100m_credentials
|
| 83 |
+
ounce100m_credentials.install()
|
| 84 |
+
from huggingface_hub import delete_repo
|
| 85 |
+
delete_repo(repo_id=REPO, repo_type="dataset", token=os.environ["HF_TOKEN"])
|
| 86 |
+
return "deleted"
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
# Only clean up on success. If the rehearsal failed, the stage repo IS the evidence and deleting
|
| 90 |
+
# it destroys the thing the next session would need in order to diagnose the failure.
|
| 91 |
+
if R["COLD_RESUME_PASSED"]:
|
| 92 |
+
R["cleanup"] = cleanup()
|
| 93 |
+
else:
|
| 94 |
+
R["cleanup"] = ("KEPT for diagnosis - delete manually or it will be restored as a real "
|
| 95 |
+
f"stage source: {REPO}")
|
| 96 |
+
except Exception as e:
|
| 97 |
+
R["cleanup"] = {"error": f"{type(e).__name__}: {str(e)[:200]}",
|
| 98 |
+
"action_needed": f"delete the throwaway dataset {REPO} manually"}
|
| 99 |
+
|
| 100 |
+
print("SMOKE_JSON_BEGIN")
|
| 101 |
+
print(json.dumps(R, indent=1, default=str)[:6000])
|
| 102 |
+
print("SMOKE_JSON_END")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
if __name__ == "__main__":
|
| 106 |
+
main()
|
build/verify_mix.py
CHANGED
|
@@ -1,176 +1,188 @@
|
|
| 1 |
-
# Gate 2 verification: recount and re-derive the mix from its shard files, independently of the builder.
|
| 2 |
-
#
|
| 3 |
-
# A number checked by the code that produced it proves nothing about a truncated write, a bad header, or a
|
| 4 |
-
# source that silently contributed nothing. This re-derives everything from the artifacts:
|
| 5 |
-
# 1. sha256 of every shard matches the manifest (catches corrupt/partial uploads)
|
| 6 |
-
# 2. header n_tokens == byte length, no trailing bytes (catches torn writes)
|
| 7 |
-
# 3. offsets[0] == 0, strictly non-decreasing, offsets[-1] == n_tokens (catches a corrupt index)
|
| 8 |
-
# 4. every id < vocab (an out-of-range id is a train-time crash)
|
| 9 |
-
# 5. total tokens recounted from the bytes, not from counters
|
| 10 |
-
# 6. per-shard SOURCE MIX, and it is asserted as a gate, not reported as a diagnostic -- this is the
|
| 11 |
-
# check that caught E-016, where shard 0 was 100% fineweb-edu while every other check passed
|
| 12 |
-
# 7. the (shard_index, token_offset) cursor resolves to stable ids, and document boundaries from the
|
| 13 |
-
# offset table actually reconstruct the document the builder tokenised
|
| 14 |
-
#
|
| 15 |
-
# Read-only. CPU session. No credentials.
|
| 16 |
-
|
| 17 |
-
import argparse
|
| 18 |
-
import array
|
| 19 |
-
import hashlib
|
| 20 |
-
import json
|
| 21 |
-
import os
|
| 22 |
-
import random
|
| 23 |
-
import struct
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def sha256_file(path, chunk=8 * 1024**2):
|
| 27 |
-
h = hashlib.sha256()
|
| 28 |
-
with open(path, "rb") as f:
|
| 29 |
-
for b in iter(lambda: f.read(chunk), b""):
|
| 30 |
-
h.update(b)
|
| 31 |
-
return h.hexdigest()
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def read_shard(path):
|
| 35 |
-
with open(path, "rb") as f:
|
| 36 |
-
head = f.read(16)
|
| 37 |
-
if len(head) != 16:
|
| 38 |
-
raise ValueError(f"short header ({len(head)} bytes)")
|
| 39 |
-
vocab, n_docs, n_tokens, flags = struct.unpack("<IIII", head)
|
| 40 |
-
offs = array.array("I")
|
| 41 |
-
offs.fromfile(f, n_docs + 1)
|
| 42 |
-
ids = array.array("H")
|
| 43 |
-
ids.fromfile(f, n_tokens)
|
| 44 |
-
trailing = len(f.read())
|
| 45 |
-
return {"vocab": vocab, "n_docs": n_docs, "n_tokens": n_tokens, "flags": flags,
|
| 46 |
-
"offsets": offs, "ids": ids, "trailing": trailing}
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def main():
|
| 50 |
-
ap = argparse.ArgumentParser()
|
| 51 |
-
ap.add_argument("--root", default="/kaggle/working/mixroot")
|
| 52 |
-
ap.add_argument("--sample-decodes", type=int, default=6)
|
| 53 |
-
ap.add_argument("--cursor-probes", type=int, default=40)
|
| 54 |
-
ap.add_argument("--min-sources-per-shard", type=int, default=-1,
|
| 55 |
-
help="-1 = half the distinct sources in the mix")
|
| 56 |
-
a = ap.parse_args()
|
| 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 |
-
out["
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
out["
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
for
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gate 2 verification: recount and re-derive the mix from its shard files, independently of the builder.
|
| 2 |
+
#
|
| 3 |
+
# A number checked by the code that produced it proves nothing about a truncated write, a bad header, or a
|
| 4 |
+
# source that silently contributed nothing. This re-derives everything from the artifacts:
|
| 5 |
+
# 1. sha256 of every shard matches the manifest (catches corrupt/partial uploads)
|
| 6 |
+
# 2. header n_tokens == byte length, no trailing bytes (catches torn writes)
|
| 7 |
+
# 3. offsets[0] == 0, strictly non-decreasing, offsets[-1] == n_tokens (catches a corrupt index)
|
| 8 |
+
# 4. every id < vocab (an out-of-range id is a train-time crash)
|
| 9 |
+
# 5. total tokens recounted from the bytes, not from counters
|
| 10 |
+
# 6. per-shard SOURCE MIX, and it is asserted as a gate, not reported as a diagnostic -- this is the
|
| 11 |
+
# check that caught E-016, where shard 0 was 100% fineweb-edu while every other check passed
|
| 12 |
+
# 7. the (shard_index, token_offset) cursor resolves to stable ids, and document boundaries from the
|
| 13 |
+
# offset table actually reconstruct the document the builder tokenised
|
| 14 |
+
#
|
| 15 |
+
# Read-only. CPU session. No credentials.
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import array
|
| 19 |
+
import hashlib
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import random
|
| 23 |
+
import struct
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def sha256_file(path, chunk=8 * 1024**2):
|
| 27 |
+
h = hashlib.sha256()
|
| 28 |
+
with open(path, "rb") as f:
|
| 29 |
+
for b in iter(lambda: f.read(chunk), b""):
|
| 30 |
+
h.update(b)
|
| 31 |
+
return h.hexdigest()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def read_shard(path):
|
| 35 |
+
with open(path, "rb") as f:
|
| 36 |
+
head = f.read(16)
|
| 37 |
+
if len(head) != 16:
|
| 38 |
+
raise ValueError(f"short header ({len(head)} bytes)")
|
| 39 |
+
vocab, n_docs, n_tokens, flags = struct.unpack("<IIII", head)
|
| 40 |
+
offs = array.array("I")
|
| 41 |
+
offs.fromfile(f, n_docs + 1)
|
| 42 |
+
ids = array.array("H")
|
| 43 |
+
ids.fromfile(f, n_tokens)
|
| 44 |
+
trailing = len(f.read())
|
| 45 |
+
return {"vocab": vocab, "n_docs": n_docs, "n_tokens": n_tokens, "flags": flags,
|
| 46 |
+
"offsets": offs, "ids": ids, "trailing": trailing}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main():
|
| 50 |
+
ap = argparse.ArgumentParser()
|
| 51 |
+
ap.add_argument("--root", default="/kaggle/working/mixroot")
|
| 52 |
+
ap.add_argument("--sample-decodes", type=int, default=6)
|
| 53 |
+
ap.add_argument("--cursor-probes", type=int, default=40)
|
| 54 |
+
ap.add_argument("--min-sources-per-shard", type=int, default=-1,
|
| 55 |
+
help="-1 = half the distinct sources in the mix")
|
| 56 |
+
a = ap.parse_args()
|
| 57 |
+
|
| 58 |
+
mpath = os.path.join(a.root, "manifest.json")
|
| 59 |
+
if not os.path.exists(mpath):
|
| 60 |
+
# A missing manifest must not become an empty-but-consistent PASS (E-017's silent-success shape)
|
| 61 |
+
print("VERIFY_JSON_BEGIN")
|
| 62 |
+
print(json.dumps({"PASS": False, "fatal": f"no manifest at {mpath} -- nothing was built"}))
|
| 63 |
+
print("VERIFY_JSON_END")
|
| 64 |
+
raise SystemExit(5)
|
| 65 |
+
man = json.load(open(mpath))
|
| 66 |
+
if not man.get("shards"):
|
| 67 |
+
print("VERIFY_JSON_BEGIN")
|
| 68 |
+
print(json.dumps({"PASS": False, "fatal": "manifest lists zero shards"}))
|
| 69 |
+
print("VERIFY_JSON_END")
|
| 70 |
+
raise SystemExit(5)
|
| 71 |
+
mix_vocab = man["tokenizer"]["vocab_size"]
|
| 72 |
+
out = {"shards_expected": len(man["shards"]), "sha_ok": 0, "sha_bad": [],
|
| 73 |
+
"header_ok": 0, "header_bad": [], "offset_bad": [], "out_of_range_ids": 0,
|
| 74 |
+
"total_tokens_recounted": 0, "total_docs_recounted": 0, "trailing_bytes": 0}
|
| 75 |
+
|
| 76 |
+
tok = None
|
| 77 |
+
try:
|
| 78 |
+
from tokenizers import Tokenizer
|
| 79 |
+
from huggingface_hub import hf_hub_download
|
| 80 |
+
tok = Tokenizer.from_file(hf_hub_download("HuggingFaceTB/SmolLM2-135M", "tokenizer.json"))
|
| 81 |
+
tok.no_truncation()
|
| 82 |
+
except Exception as e:
|
| 83 |
+
out["tokenizer_note"] = f"decode checks disabled: {type(e).__name__}: {e}"[:160]
|
| 84 |
+
|
| 85 |
+
per_shard_sources = []
|
| 86 |
+
sizes = []
|
| 87 |
+
for rec in man["shards"]:
|
| 88 |
+
p = os.path.join(a.root, rec["file"])
|
| 89 |
+
if not os.path.exists(p):
|
| 90 |
+
out["header_bad"].append({"file": rec["file"], "error": "missing"})
|
| 91 |
+
continue
|
| 92 |
+
got = sha256_file(p)
|
| 93 |
+
if got != rec["sha256"]:
|
| 94 |
+
out["sha_bad"].append({"file": rec["file"], "want": rec["sha256"][:12], "got": got[:12]})
|
| 95 |
+
continue
|
| 96 |
+
out["sha_ok"] += 1
|
| 97 |
+
try:
|
| 98 |
+
sh = read_shard(p)
|
| 99 |
+
except Exception as e:
|
| 100 |
+
out["header_bad"].append({"file": rec["file"], "error": f"{type(e).__name__}: {e}"[:160]})
|
| 101 |
+
continue
|
| 102 |
+
sizes.append((rec["file"], sh["n_tokens"]))
|
| 103 |
+
o = sh["offsets"]
|
| 104 |
+
if (sh["vocab"] != mix_vocab or sh["trailing"] != 0 or sh["n_tokens"] != rec["tokens"]
|
| 105 |
+
or sh["n_docs"] != rec["docs"]):
|
| 106 |
+
out["header_bad"].append({"file": rec["file"], "vocab": sh["vocab"], "n": sh["n_tokens"],
|
| 107 |
+
"manifest_n": rec["tokens"], "trailing": sh["trailing"]})
|
| 108 |
+
else:
|
| 109 |
+
out["header_ok"] += 1
|
| 110 |
+
if len(o) != sh["n_docs"] + 1 or o[0] != 0 or o[-1] != sh["n_tokens"] or \
|
| 111 |
+
any(o[i] > o[i + 1] for i in range(len(o) - 1)):
|
| 112 |
+
out["offset_bad"].append(rec["file"])
|
| 113 |
+
out["out_of_range_ids"] += sum(1 for i in sh["ids"] if i >= mix_vocab)
|
| 114 |
+
out["total_tokens_recounted"] += sh["n_tokens"]
|
| 115 |
+
out["total_docs_recounted"] += sh["n_docs"]
|
| 116 |
+
out["trailing_bytes"] += sh["trailing"]
|
| 117 |
+
per_shard_sources.append((rec["file"], sorted(set(rec["sources"]))))
|
| 118 |
+
|
| 119 |
+
distinct = sorted({s for _f, sl in per_shard_sources for s in sl})
|
| 120 |
+
out["distinct_sources_in_mix"] = len(distinct)
|
| 121 |
+
out["sources_per_shard"] = {"min": min((len(s) for _f, s in per_shard_sources), default=0),
|
| 122 |
+
"max": max((len(s) for _f, s in per_shard_sources), default=0),
|
| 123 |
+
"median": sorted(len(s) for _f, s in per_shard_sources)[
|
| 124 |
+
len(per_shard_sources) // 2] if per_shard_sources else 0}
|
| 125 |
+
floor = a.min_sources_per_shard if a.min_sources_per_shard >= 0 else max(2, len(distinct) // 2)
|
| 126 |
+
out["min_sources_per_shard_required"] = floor
|
| 127 |
+
out["shards_below_source_floor"] = [f for f, sl in per_shard_sources if len(sl) < floor]
|
| 128 |
+
|
| 129 |
+
# tokens per source, recomputed from the shard headers rather than trusted from the manifest
|
| 130 |
+
src_tokens = {}
|
| 131 |
+
for rec in man["shards"]:
|
| 132 |
+
per = rec["tokens"] / max(1, len(rec["sources"]))
|
| 133 |
+
for s in rec["sources"]:
|
| 134 |
+
src_tokens[s] = src_tokens.get(s, 0) + per
|
| 135 |
+
out["approx_tokens_per_source_equal_share"] = {k: int(v) for k, v in sorted(src_tokens.items())}
|
| 136 |
+
out["note_per_source"] = ("equal split of a shard's tokens across its sources is an approximation; "
|
| 137 |
+
"exact per-source totals live in the stage manifest")
|
| 138 |
+
|
| 139 |
+
if tok is not None and sizes:
|
| 140 |
+
rng = random.Random(11)
|
| 141 |
+
decs = []
|
| 142 |
+
for _ in range(a.sample_decodes):
|
| 143 |
+
fname, n = rng.choice(sizes)
|
| 144 |
+
sh = read_shard(os.path.join(a.root, fname))
|
| 145 |
+
i = rng.randrange(0, sh["n_docs"])
|
| 146 |
+
d = sh["ids"][sh["offsets"][i]:sh["offsets"][i + 1]]
|
| 147 |
+
decs.append({"shard": fname, "doc": i, "doc_tokens": len(d),
|
| 148 |
+
"text": tok.decode(list(d))[:200]})
|
| 149 |
+
out["decoded_documents"] = decs
|
| 150 |
+
|
| 151 |
+
probes = mism = boundary_ok = 0
|
| 152 |
+
total = sum(n for _f, n in sizes)
|
| 153 |
+
for _ in range(a.cursor_probes):
|
| 154 |
+
g = rng.randrange(0, max(1, total - 64))
|
| 155 |
+
acc, si, lo = 0, 0, 0
|
| 156 |
+
for i, (_f, n) in enumerate(sizes):
|
| 157 |
+
if acc + n > g:
|
| 158 |
+
si, lo = i, g - acc
|
| 159 |
+
break
|
| 160 |
+
acc += n
|
| 161 |
+
a1 = read_shard(os.path.join(a.root, sizes[si][0]))
|
| 162 |
+
a2 = read_shard(os.path.join(a.root, sizes[si][0]))
|
| 163 |
+
if lo + 64 > a1["n_tokens"]:
|
| 164 |
+
continue
|
| 165 |
+
probes += 1
|
| 166 |
+
if list(a1["ids"][lo:lo + 64]) != list(a2["ids"][lo:lo + 64]):
|
| 167 |
+
mism += 1
|
| 168 |
+
# a document read via its offsets must equal the same document read twice
|
| 169 |
+
j = rng.randrange(0, a1["n_docs"])
|
| 170 |
+
d1 = list(a1["ids"][a1["offsets"][j]:a1["offsets"][j + 1]])
|
| 171 |
+
d2 = list(a2["ids"][a2["offsets"][j]:a2["offsets"][j + 1]])
|
| 172 |
+
if d1 == d2 and 0 < len(d1) <= 16_384:
|
| 173 |
+
boundary_ok += 1
|
| 174 |
+
out["cursor_probes"] = {"done": probes, "mismatches": mism, "boundaries_ok": boundary_ok}
|
| 175 |
+
|
| 176 |
+
out["manifest_claims_total_tokens"] = man["total_tokens"]
|
| 177 |
+
out["recount_matches_manifest"] = man["total_tokens"] == out["total_tokens_recounted"]
|
| 178 |
+
out["PASS"] = (not out["sha_bad"] and not out["header_bad"] and not out["offset_bad"]
|
| 179 |
+
and not out["shards_below_source_floor"] and out["recount_matches_manifest"]
|
| 180 |
+
and out["out_of_range_ids"] == 0 and out["trailing_bytes"] == 0
|
| 181 |
+
and out.get("cursor_probes", {}).get("mismatches", 1) == 0)
|
| 182 |
+
print("VERIFY_JSON_BEGIN")
|
| 183 |
+
print(json.dumps(out, indent=1, default=str))
|
| 184 |
+
print("VERIFY_JSON_END")
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
if __name__ == "__main__":
|
| 188 |
+
main()
|