Cion-lab commited on
Commit
9f37a2c
·
verified ·
1 Parent(s): 15c6b87

stage listing raises instead of returning {}; empty-repo case handled explicitly; verify refuses manifest-less PASS; +val holdout in merge

Browse files
Files changed (4) hide show
  1. build/build_mix.py +47 -0
  2. build/hubsync.py +48 -14
  3. build/smoke_hub_resume.py +106 -100
  4. 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
- """Set of relative paths under stage/ that already exist on the Hub."""
 
49
  import urllib.request
50
 
51
- url = f"https://huggingface.co/api/datasets/{repo}/tree/main/stage?recursive=true"
 
52
  try:
53
- with urllib.request.urlopen(url, timeout=90) as r:
54
- entries = json.loads(r.read().decode())
55
- except Exception:
56
- return set()
57
- out = set()
58
- for e in entries:
59
- if e.get("type") != "file":
60
- continue
61
- out.add(e["path"])
62
- # a directory in the tree listing means we must recurse; recursive=true should cover it, but
63
- # if the server ignored the flag we get dirs and no files, which the caller detects as empty
64
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- R["cleanup"] = cleanup()
90
- except Exception as e:
91
- R["cleanup"] = {"error": f"{type(e).__name__}: {str(e)[:200]}",
92
- "action_needed": f"delete the throwaway dataset {REPO} manually"}
93
-
94
- print("SMOKE_JSON_BEGIN")
95
- print(json.dumps(R, indent=1, default=str)[:6000])
96
- print("SMOKE_JSON_END")
97
-
98
-
99
- if __name__ == "__main__":
100
- main()
 
 
 
 
 
 
 
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
- man = json.load(open(os.path.join(a.root, "manifest.json")))
59
- mix_vocab = man["tokenizer"]["vocab_size"]
60
- out = {"shards_expected": len(man["shards"]), "sha_ok": 0, "sha_bad": [],
61
- "header_ok": 0, "header_bad": [], "offset_bad": [], "out_of_range_ids": 0,
62
- "total_tokens_recounted": 0, "total_docs_recounted": 0, "trailing_bytes": 0}
63
-
64
- tok = None
65
- try:
66
- from tokenizers import Tokenizer
67
- from huggingface_hub import hf_hub_download
68
- tok = Tokenizer.from_file(hf_hub_download("HuggingFaceTB/SmolLM2-135M", "tokenizer.json"))
69
- tok.no_truncation()
70
- except Exception as e:
71
- out["tokenizer_note"] = f"decode checks disabled: {type(e).__name__}: {e}"[:160]
72
-
73
- per_shard_sources = []
74
- sizes = []
75
- for rec in man["shards"]:
76
- p = os.path.join(a.root, rec["file"])
77
- if not os.path.exists(p):
78
- out["header_bad"].append({"file": rec["file"], "error": "missing"})
79
- continue
80
- got = sha256_file(p)
81
- if got != rec["sha256"]:
82
- out["sha_bad"].append({"file": rec["file"], "want": rec["sha256"][:12], "got": got[:12]})
83
- continue
84
- out["sha_ok"] += 1
85
- try:
86
- sh = read_shard(p)
87
- except Exception as e:
88
- out["header_bad"].append({"file": rec["file"], "error": f"{type(e).__name__}: {e}"[:160]})
89
- continue
90
- sizes.append((rec["file"], sh["n_tokens"]))
91
- o = sh["offsets"]
92
- if (sh["vocab"] != mix_vocab or sh["trailing"] != 0 or sh["n_tokens"] != rec["tokens"]
93
- or sh["n_docs"] != rec["docs"]):
94
- out["header_bad"].append({"file": rec["file"], "vocab": sh["vocab"], "n": sh["n_tokens"],
95
- "manifest_n": rec["tokens"], "trailing": sh["trailing"]})
96
- else:
97
- out["header_ok"] += 1
98
- if len(o) != sh["n_docs"] + 1 or o[0] != 0 or o[-1] != sh["n_tokens"] or \
99
- any(o[i] > o[i + 1] for i in range(len(o) - 1)):
100
- out["offset_bad"].append(rec["file"])
101
- out["out_of_range_ids"] += sum(1 for i in sh["ids"] if i >= mix_vocab)
102
- out["total_tokens_recounted"] += sh["n_tokens"]
103
- out["total_docs_recounted"] += sh["n_docs"]
104
- out["trailing_bytes"] += sh["trailing"]
105
- per_shard_sources.append((rec["file"], sorted(set(rec["sources"]))))
106
-
107
- distinct = sorted({s for _f, sl in per_shard_sources for s in sl})
108
- out["distinct_sources_in_mix"] = len(distinct)
109
- out["sources_per_shard"] = {"min": min((len(s) for _f, s in per_shard_sources), default=0),
110
- "max": max((len(s) for _f, s in per_shard_sources), default=0),
111
- "median": sorted(len(s) for _f, s in per_shard_sources)[
112
- len(per_shard_sources) // 2] if per_shard_sources else 0}
113
- floor = a.min_sources_per_shard if a.min_sources_per_shard >= 0 else max(2, len(distinct) // 2)
114
- out["min_sources_per_shard_required"] = floor
115
- out["shards_below_source_floor"] = [f for f, sl in per_shard_sources if len(sl) < floor]
116
-
117
- # tokens per source, recomputed from the shard headers rather than trusted from the manifest
118
- src_tokens = {}
119
- for rec in man["shards"]:
120
- per = rec["tokens"] / max(1, len(rec["sources"]))
121
- for s in rec["sources"]:
122
- src_tokens[s] = src_tokens.get(s, 0) + per
123
- out["approx_tokens_per_source_equal_share"] = {k: int(v) for k, v in sorted(src_tokens.items())}
124
- out["note_per_source"] = ("equal split of a shard's tokens across its sources is an approximation; "
125
- "exact per-source totals live in the stage manifest")
126
-
127
- if tok is not None and sizes:
128
- rng = random.Random(11)
129
- decs = []
130
- for _ in range(a.sample_decodes):
131
- fname, n = rng.choice(sizes)
132
- sh = read_shard(os.path.join(a.root, fname))
133
- i = rng.randrange(0, sh["n_docs"])
134
- d = sh["ids"][sh["offsets"][i]:sh["offsets"][i + 1]]
135
- decs.append({"shard": fname, "doc": i, "doc_tokens": len(d),
136
- "text": tok.decode(list(d))[:200]})
137
- out["decoded_documents"] = decs
138
-
139
- probes = mism = boundary_ok = 0
140
- total = sum(n for _f, n in sizes)
141
- for _ in range(a.cursor_probes):
142
- g = rng.randrange(0, max(1, total - 64))
143
- acc, si, lo = 0, 0, 0
144
- for i, (_f, n) in enumerate(sizes):
145
- if acc + n > g:
146
- si, lo = i, g - acc
147
- break
148
- acc += n
149
- a1 = read_shard(os.path.join(a.root, sizes[si][0]))
150
- a2 = read_shard(os.path.join(a.root, sizes[si][0]))
151
- if lo + 64 > a1["n_tokens"]:
152
- continue
153
- probes += 1
154
- if list(a1["ids"][lo:lo + 64]) != list(a2["ids"][lo:lo + 64]):
155
- mism += 1
156
- # a document read via its offsets must equal the same document read twice
157
- j = rng.randrange(0, a1["n_docs"])
158
- d1 = list(a1["ids"][a1["offsets"][j]:a1["offsets"][j + 1]])
159
- d2 = list(a2["ids"][a2["offsets"][j]:a2["offsets"][j + 1]])
160
- if d1 == d2 and 0 < len(d1) <= 16_384:
161
- boundary_ok += 1
162
- out["cursor_probes"] = {"done": probes, "mismatches": mism, "boundaries_ok": boundary_ok}
163
-
164
- out["manifest_claims_total_tokens"] = man["total_tokens"]
165
- out["recount_matches_manifest"] = man["total_tokens"] == out["total_tokens_recounted"]
166
- out["PASS"] = (not out["sha_bad"] and not out["header_bad"] and not out["offset_bad"]
167
- and not out["shards_below_source_floor"] and out["recount_matches_manifest"]
168
- and out["out_of_range_ids"] == 0 and out["trailing_bytes"] == 0
169
- and out.get("cursor_probes", {}).get("mismatches", 1) == 0)
170
- print("VERIFY_JSON_BEGIN")
171
- print(json.dumps(out, indent=1, default=str))
172
- print("VERIFY_JSON_END")
173
-
174
-
175
- if __name__ == "__main__":
176
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
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()